Pre-publish privacy audit: scrub the simulator serial, harden the sdist, add licences
Audited the package against the two-stage procedure before a first PyPI publish. The sdist and wheel were already tight, but three things needed fixing and the controls needed to become real rather than documented. The simulator volunteered high="PI04" as its product serial, which is the real Bebop 2 serial prefix. Nothing unique to one aircraft, but a realistic prefix invites being quoted into a bug report as a specimen, so it now reads "N0TAREAL" / "0000000SIM" with a comment saying why it is nonsense on purpose. The low half and the 500.0 no-fix GPS sentinel were already fake. Hardened [tool.uv.build-backend] source-exclude well past the directories that exist today: captures at any depth, log dumps, recorded media by extension, caches, and anything credential-shaped. .gitignore governs git and source-exclude governs the sdist; a capture can sit in one and not the other, which is how this kind of data reaches an immutable index. Verified the broad patterns do not over-reach: arsdk-xml/ with PROVENANCE.md and tools/logs.py both still ship. Added LICENSE (MIT) and LICENSE-arsdk-xml (Parrot SA's BSD-3-Clause), and corrected the declared licence to "MIT AND BSD-3-Clause". The vendored XML ships in both artifacts because nothing here decodes a command without it, so MIT alone understated what is in the box. Both texts now appear in the artifacts and in the metadata. test_packaging.py grows privacy guards that fail on a serial prefix, a P7 CPU id, any MAC, a high-precision coordinate, an absolute home path, or any private address other than the drone's own documented 192.168.42.0/24. Each pattern was checked against the real identifiers to confirm it bites, since a guard that passes on an empty tree proves nothing. Example address in test_arsdk_session.py moved to RFC 5737 space. 504 tests pass, ruff clean.
This commit is contained in:
@@ -404,10 +404,12 @@ def test_probe_gives_up_on_a_closed_port_quickly():
|
||||
def test_an_explicit_address_is_trusted_without_probing():
|
||||
# A caller who names an address wants the connection error, not a
|
||||
# discovery verdict, so find() must not quietly return None here.
|
||||
found = discovery.find("10.1.2.3")
|
||||
# 192.0.2.0/24 is RFC 5737 documentation space, so this cannot name a host
|
||||
# on whatever network the suite happens to run on.
|
||||
found = discovery.find("192.0.2.7")
|
||||
assert found is not None
|
||||
assert (found.ip, found.via) == ("10.1.2.3", "given")
|
||||
assert found.address == ("10.1.2.3", 44444)
|
||||
assert (found.ip, found.via) == ("192.0.2.7", "given")
|
||||
assert found.address == ("192.0.2.7", 44444)
|
||||
|
||||
|
||||
# -- an event we cannot name ---------------------------------------------
|
||||
|
||||
+100
-2
@@ -1,14 +1,61 @@
|
||||
"""Guards that the package stays coherent as the streams land."""
|
||||
"""Guards that the package stays coherent as the streams land.
|
||||
|
||||
The second half of this file is a privacy guard rather than a coherence one.
|
||||
This package was written against a real aircraft parked at a real address, so
|
||||
the things that identify it -- the serial, the SoC CPU id, the Wi-Fi BSSID, the
|
||||
first GPS fix -- all passed through this working tree at some point. PyPI is
|
||||
immutable per version and sdists are mirrored within minutes, so the audit that
|
||||
catches a stray identifier has to run before every publish, not after one.
|
||||
"""
|
||||
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import mcbebop
|
||||
from mcbebop.server import build_server
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
|
||||
# Every pattern here is a shape that identifies the owner's aircraft or
|
||||
# network. The comment on each says what it is, so a future hit is diagnosable
|
||||
# rather than mysterious.
|
||||
_FORBIDDEN = (
|
||||
# A Bebop 2 serial's high half. The full serial also appears in the
|
||||
# aircraft's SSID, which makes it a locator and not just a label.
|
||||
(re.compile(r"PI04"), "a real Bebop 2 serial prefix"),
|
||||
# The P7 SoC CPU id: 'P7' then a long uppercase run. 'P7ID', the command
|
||||
# name in ardrone3.xml, is too short to match and is meant to be here.
|
||||
(re.compile(r"P7[0-9A-Z]{8,}"), "a P7 SoC CPU id"),
|
||||
# Any MAC address at all, not just a Parrot OUI.
|
||||
(re.compile(r"\b[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}\b"), "a MAC address"),
|
||||
# A plausible residential coordinate. The simulator reports ARSDK's 500.0
|
||||
# no-fix sentinel instead, which has no decimal places to match.
|
||||
(re.compile(r"\b[0-9]{1,3}\.[0-9]{6,}"), "a high-precision coordinate"),
|
||||
# An absolute build path leaks a username and the local layout.
|
||||
(re.compile(r"/home/[a-z]"), "an absolute home-directory path"),
|
||||
)
|
||||
|
||||
# 192.168.42.0/24 is the drone's own fixed network and is in Parrot's public
|
||||
# documentation, so it is expected. Any other private address is somebody's
|
||||
# real LAN; use RFC 5737 documentation space in examples instead.
|
||||
_PRIVATE_IP = re.compile(
|
||||
r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
|
||||
r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}"
|
||||
r"|192\.168\.\d{1,3}\.\d{1,3})\b"
|
||||
)
|
||||
_ALLOWED_IP = re.compile(r"\b192\.168\.42\.\d{1,3}\b")
|
||||
|
||||
|
||||
def _shipped_files() -> list[Path]:
|
||||
"""Every text file that reaches an artifact, which is src/ plus the docs."""
|
||||
files = [ROOT / "README.md", ROOT / "pyproject.toml"]
|
||||
files += [p for p in (ROOT / "src").rglob("*") if p.is_file() and "__pycache__" not in p.parts]
|
||||
return files
|
||||
|
||||
|
||||
def _pyproject() -> dict:
|
||||
return tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())
|
||||
return tomllib.loads((ROOT / "pyproject.toml").read_text())
|
||||
|
||||
|
||||
def test_version_matches_pyproject():
|
||||
@@ -38,3 +85,54 @@ def test_vendored_xml_present_and_parses():
|
||||
assert proj.find("class") is not None, f"{name} has no <class> elements"
|
||||
total += sum(len(c.findall("cmd")) for c in proj.iter("class"))
|
||||
assert total == 264, f"expected 264 commands, found {total}"
|
||||
|
||||
|
||||
# -- privacy guards ------------------------------------------------------
|
||||
def test_no_shipped_file_carries_an_aircraft_identifier():
|
||||
hits = []
|
||||
for path in _shipped_files():
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
for pattern, what in _FORBIDDEN:
|
||||
if pattern.search(line):
|
||||
hits.append(f"{path.relative_to(ROOT)}:{lineno} looks like {what}: {line.strip()[:90]}")
|
||||
assert not hits, "identifying data in a file that ships:\n" + "\n".join(hits)
|
||||
|
||||
|
||||
def test_no_shipped_file_names_a_private_network_but_the_drones_own():
|
||||
hits = []
|
||||
for path in _shipped_files():
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
for lineno, line in enumerate(text.splitlines(), 1):
|
||||
for found in _PRIVATE_IP.finditer(line):
|
||||
if not _ALLOWED_IP.fullmatch(found.group()):
|
||||
hits.append(f"{path.relative_to(ROOT)}:{lineno} names {found.group()}")
|
||||
assert not hits, (
|
||||
"a private address other than the drone's own 192.168.42.0/24; "
|
||||
"use RFC 5737 documentation space:\n" + "\n".join(hits)
|
||||
)
|
||||
|
||||
|
||||
def test_sdist_excludes_captures_and_dev_trees():
|
||||
"""The sdist exclusions are a separate mechanism from .gitignore.
|
||||
|
||||
A file can be gitignored and still swept into an sdist, which is how
|
||||
captures reach PyPI. Assert the directories that hold real data off the
|
||||
aircraft are named in both places.
|
||||
"""
|
||||
excluded = set(_pyproject()["tool"]["uv"]["build-backend"]["source-exclude"])
|
||||
ignored = (ROOT / ".gitignore").read_text().split()
|
||||
for name in ("captures", "tests"):
|
||||
assert name in excluded or f"{name}/**" in excluded, f"{name} is not excluded from the sdist"
|
||||
for name in ("captures",):
|
||||
assert f"{name}/" in ignored or name in ignored, f"{name} is not gitignored"
|
||||
|
||||
|
||||
def test_declared_licence_files_exist():
|
||||
"""A licence named in metadata but absent from the tree ships a lie."""
|
||||
declared = _pyproject()["project"]["license-files"]
|
||||
assert declared, "license-files must name the licence texts so they ship"
|
||||
for name in declared:
|
||||
assert (ROOT / name).is_file(), f"{name} is declared in license-files but missing"
|
||||
# The vendored XML is Parrot's and the expression has to say so.
|
||||
assert "BSD-3-Clause" in _pyproject()["project"]["license"]
|
||||
|
||||
Reference in New Issue
Block a user