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:
@@ -0,0 +1,32 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ryan Malloy
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
This package also distributes a vendored third-party component under a
|
||||
different licence. See LICENSE-arsdk-xml for the full text.
|
||||
|
||||
src/mcbebop/arsdk-xml/common.xml
|
||||
src/mcbebop/arsdk-xml/ardrone3.xml
|
||||
Copyright (C) 2014 Parrot SA, BSD-3-Clause.
|
||||
|
||||
The combined distribution is therefore "MIT AND BSD-3-Clause".
|
||||
@@ -0,0 +1,37 @@
|
||||
The files src/mcbebop/arsdk-xml/common.xml and src/mcbebop/arsdk-xml/ardrone3.xml
|
||||
are Parrot SA's own ARSDK protocol definitions, redistributed unmodified except
|
||||
as recorded in src/mcbebop/arsdk-xml/PROVENANCE.md. Upstream:
|
||||
https://github.com/Parrot-Developers/arsdk-xml
|
||||
|
||||
The licence text below is reproduced from the header of those files.
|
||||
|
||||
----------------------------------------------------------------------
|
||||
|
||||
Copyright (C) 2014 Parrot SA
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Parrot nor the names
|
||||
of its contributors may be used to endorse or promote products
|
||||
derived from this software without specific prior written
|
||||
permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
|
||||
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
|
||||
OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
|
||||
AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
|
||||
OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGE.
|
||||
@@ -84,7 +84,12 @@ uvx mcbebop
|
||||
claude mcp add mcbebop -- uvx mcbebop
|
||||
```
|
||||
|
||||
## Credits
|
||||
## Licence
|
||||
|
||||
`arsdk-xml/` is Parrot SA's own protocol definition, BSD-3-Clause. See
|
||||
`arsdk-xml/PROVENANCE.md`.
|
||||
This package is MIT (`LICENSE`), and it vendors one third-party component:
|
||||
`src/mcbebop/arsdk-xml/` is Parrot SA's own protocol definition, BSD-3-Clause
|
||||
(`LICENSE-arsdk-xml`). It ships in both the sdist and the wheel because nothing
|
||||
here can decode a single command without it. What that snapshot is and how it
|
||||
differs from upstream is recorded in `arsdk-xml/PROVENANCE.md`.
|
||||
|
||||
So the distribution as a whole is `MIT AND BSD-3-Clause`.
|
||||
|
||||
+77
-2
@@ -4,7 +4,11 @@ version = "2026.10.02"
|
||||
description = "MCP server for the Parrot Bebop 2 drone: telemetry, camera, files, and every ARSDK command"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = "MIT"
|
||||
# Our own code is MIT. The vendored arsdk-xml/ is Parrot SA's, BSD-3-Clause,
|
||||
# and it ships in both artifacts because the package cannot decode a single
|
||||
# command without it, so the distribution as a whole is both.
|
||||
license = "MIT AND BSD-3-Clause"
|
||||
license-files = ["LICENSE", "LICENSE-arsdk-xml"]
|
||||
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
||||
keywords = ["mcp", "drone", "parrot", "bebop", "arsdk", "fastmcp"]
|
||||
classifiers = [
|
||||
@@ -35,7 +39,78 @@ requires = ["uv_build>=0.11.3,<0.12.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
source-exclude = ["CLAUDE.md", ".env", ".env.*", ".mcp.json", "tests", "captures"]
|
||||
# Err toward excluding. This repo sits next to a real aircraft, and `captures/`
|
||||
# holds video of the owner's home plus `ckcm.bin` system logs that carry the
|
||||
# serial, the CPU id and a real GPS fix together. None of it may ever reach an
|
||||
# sdist, which is immutable and mirrored within minutes of upload.
|
||||
#
|
||||
# `.gitignore` does NOT protect this: it governs git, and source-exclude
|
||||
# governs the sdist. A file can easily be in one and not the other, so the
|
||||
# patterns below are deliberately broader than the directories that exist
|
||||
# today -- a capture dropped into a new directory next year is still excluded.
|
||||
# test_packaging.py asserts both halves stay in agreement.
|
||||
source-exclude = [
|
||||
# Operator-private context and anything credential-shaped.
|
||||
"CLAUDE.md",
|
||||
".env",
|
||||
".env.*",
|
||||
".mcp.json",
|
||||
".claude",
|
||||
".claude/**",
|
||||
# Dev-only trees. Tests carry fixtures copy-pasted from a live aircraft.
|
||||
"tests",
|
||||
"tests/**",
|
||||
"docs",
|
||||
"docs/**",
|
||||
"scripts",
|
||||
"scripts/**",
|
||||
"*.ipynb",
|
||||
# Captures off the aircraft, at any depth, plus log dumps.
|
||||
"captures",
|
||||
"captures/**",
|
||||
"**/captures/**",
|
||||
"**/logs/**",
|
||||
"ckcm*",
|
||||
"**/ckcm*",
|
||||
# Recorded media and binary blobs by extension, so a stray file in a
|
||||
# directory nobody thought to list is still caught.
|
||||
"*.rtpcap",
|
||||
"*.raw",
|
||||
"*.h264",
|
||||
"*.bin",
|
||||
"*.pcap",
|
||||
"*.pcapng",
|
||||
"*.mp4",
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.wav",
|
||||
"*.ulg",
|
||||
"**/*.rtpcap",
|
||||
"**/*.raw",
|
||||
"**/*.h264",
|
||||
"**/*.bin",
|
||||
"**/*.jpg",
|
||||
"**/*.jpeg",
|
||||
"**/*.png",
|
||||
# Caches and build output. .pyc files embed the absolute build path, which
|
||||
# leaks a username and the local directory layout.
|
||||
"**/__pycache__",
|
||||
"**/__pycache__/**",
|
||||
"*.pyc",
|
||||
"**/*.pyc",
|
||||
".pytest_cache",
|
||||
".pytest_cache/**",
|
||||
".ruff_cache",
|
||||
".ruff_cache/**",
|
||||
".venv",
|
||||
".venv/**",
|
||||
"dist",
|
||||
"dist/**",
|
||||
"build",
|
||||
"build/**",
|
||||
"*.egg-info",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["ruff>=0.16", "pytest>=8.0", "pytest-asyncio>=0.25"]
|
||||
|
||||
+6
-1
@@ -459,7 +459,12 @@ class FakeBebop:
|
||||
self.emit(
|
||||
"common.SettingsState.ProductVersionChanged", acked=True, software="4.7.1", hardware="HW_05"
|
||||
)
|
||||
self.emit("common.SettingsState.ProductSerialHighChanged", acked=True, high="PI04")
|
||||
# Deliberately nonsense, and please keep it that way. A real Bebop 2
|
||||
# serial is the aircraft's identity: it appears in its SSID and in the
|
||||
# ckcm system log beside the CPU id and the last GPS fix. A simulator
|
||||
# that volunteered a realistic-looking one would get quoted into a bug
|
||||
# report as if it were a specimen, so this one reads "not a real sim".
|
||||
self.emit("common.SettingsState.ProductSerialHighChanged", acked=True, high="N0TAREAL")
|
||||
self.emit("common.SettingsState.ProductSerialLowChanged", acked=True, low="0000000SIM")
|
||||
self.emit("ardrone3.SettingsState.MotorFlightsStatusChanged", acked=True,
|
||||
nbFlights=42, lastFlightDuration=611, totalFlightDuration=26_340) # fmt: skip
|
||||
|
||||
@@ -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