From c43da8950e2c455a913acea9569201d75edd7215 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 13:59:11 -0700 Subject: [PATCH] [eric] swarm: scan workspace file bytes for secrets on export, not just payload keys --- backend/apps/swarm/redact.py | 18 ++++++++++++++++++ backend/apps/swarm/ziputil.py | 8 +++++++- backend/tests/test_swarm_bundle.py | 13 +++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/backend/apps/swarm/redact.py b/backend/apps/swarm/redact.py index 1a97d20c..cc39b063 100644 --- a/backend/apps/swarm/redact.py +++ b/backend/apps/swarm/redact.py @@ -79,3 +79,21 @@ def find_denied_keys(value: Any, _path: str = "") -> list[str]: for i, v in enumerate(value): found.extend(find_denied_keys(v, f"{_path}[{i}]")) return found + + +def _looks_secret(text: str) -> bool: + return any(pat.search(text) for pat in _CONTENT_PATTERNS) + + +def find_secrets_in_files(files: dict[str, bytes]) -> list[str]: + """Paths of any file whose text body holds a secret-shaped literal. Payloads + get scrubbed key-and-content, but raw workspace files (an app's source) were + only key-scanned, so a key hardcoded in a .js would slip. Binary files are + skipped (a null byte means it isn't text someone pasted a token into).""" + hits: list[str] = [] + for path, data in files.items(): + if b"\x00" in data[:4096]: + continue + if _looks_secret(data.decode("utf-8", errors="ignore")): + hits.append(path) + return hits diff --git a/backend/apps/swarm/ziputil.py b/backend/apps/swarm/ziputil.py index b4682695..bdf1aa5c 100644 --- a/backend/apps/swarm/ziputil.py +++ b/backend/apps/swarm/ziputil.py @@ -12,7 +12,7 @@ import shutil import tempfile import zipfile -from .redact import find_denied_keys +from .redact import find_denied_keys, find_secrets_in_files MANIFEST_NAME = "manifest.json" @@ -46,6 +46,12 @@ def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> raise BundleError( f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}" ) + leaky_files = find_secrets_in_files(files) + if leaky_files: + raise BundleError( + f"refusing to export: a secret-shaped value is in {leaky_files[0]}; " + "remove it (use an environment variable) and try again" + ) entries: dict[str, bytes] = {} for bid, payload in payloads.items(): entries[f"entities/{bid}/payload.json"] = json.dumps(payload, indent=2).encode("utf-8") diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index dcb5b733..66fc20a4 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -99,6 +99,19 @@ def test_pack_refuses_denied_key(): pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {}) +def test_pack_refuses_secret_in_workspace_file(): + # A key hardcoded in app source (not .env) must not ride along; pack scans + # file bytes, not just payload keys. + leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n" + with pytest.raises(BundleError): + pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak}) + + +def test_pack_allows_clean_workspace_file(): + raw = pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/app.js": b"export default 1"}) + assert zipfile.is_zipfile(io.BytesIO(raw)) + + def test_app_export_drops_machine_env(tmp_path, monkeypatch): # The live .env holds the source machine's absolute paths + pinned port; it # must never ride along. .env.example (portable) does.