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:
2026-10-03 11:21:13 -06:00
parent e9b0c264c5
commit c5e84c47ae
7 changed files with 265 additions and 11 deletions
+32
View File
@@ -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".
+37
View File
@@ -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.
+8 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5 -3
View File
@@ -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
View File
@@ -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"]