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
|
claude mcp add mcbebop -- uvx mcbebop
|
||||||
```
|
```
|
||||||
|
|
||||||
## Credits
|
## Licence
|
||||||
|
|
||||||
`arsdk-xml/` is Parrot SA's own protocol definition, BSD-3-Clause. See
|
This package is MIT (`LICENSE`), and it vendors one third-party component:
|
||||||
`arsdk-xml/PROVENANCE.md`.
|
`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"
|
description = "MCP server for the Parrot Bebop 2 drone: telemetry, camera, files, and every ARSDK command"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
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"}]
|
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
||||||
keywords = ["mcp", "drone", "parrot", "bebop", "arsdk", "fastmcp"]
|
keywords = ["mcp", "drone", "parrot", "bebop", "arsdk", "fastmcp"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -35,7 +39,78 @@ requires = ["uv_build>=0.11.3,<0.12.0"]
|
|||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
[tool.uv.build-backend]
|
[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]
|
[dependency-groups]
|
||||||
dev = ["ruff>=0.16", "pytest>=8.0", "pytest-asyncio>=0.25"]
|
dev = ["ruff>=0.16", "pytest>=8.0", "pytest-asyncio>=0.25"]
|
||||||
|
|||||||
+6
-1
@@ -459,7 +459,12 @@ class FakeBebop:
|
|||||||
self.emit(
|
self.emit(
|
||||||
"common.SettingsState.ProductVersionChanged", acked=True, software="4.7.1", hardware="HW_05"
|
"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("common.SettingsState.ProductSerialLowChanged", acked=True, low="0000000SIM")
|
||||||
self.emit("ardrone3.SettingsState.MotorFlightsStatusChanged", acked=True,
|
self.emit("ardrone3.SettingsState.MotorFlightsStatusChanged", acked=True,
|
||||||
nbFlights=42, lastFlightDuration=611, totalFlightDuration=26_340) # fmt: skip
|
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():
|
def test_an_explicit_address_is_trusted_without_probing():
|
||||||
# A caller who names an address wants the connection error, not a
|
# A caller who names an address wants the connection error, not a
|
||||||
# discovery verdict, so find() must not quietly return None here.
|
# 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 is not None
|
||||||
assert (found.ip, found.via) == ("10.1.2.3", "given")
|
assert (found.ip, found.via) == ("192.0.2.7", "given")
|
||||||
assert found.address == ("10.1.2.3", 44444)
|
assert found.address == ("192.0.2.7", 44444)
|
||||||
|
|
||||||
|
|
||||||
# -- an event we cannot name ---------------------------------------------
|
# -- 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
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import mcbebop
|
import mcbebop
|
||||||
from mcbebop.server import build_server
|
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:
|
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():
|
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"
|
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"))
|
total += sum(len(c.findall("cmd")) for c in proj.iter("class"))
|
||||||
assert total == 264, f"expected 264 commands, found {total}"
|
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