Explorar el Código

fix(skills): Parse Cargo.lock without tomllib so py<3.11 scans crates

exposure-check.py parsed Cargo.lock with tomllib and returned early on
ImportError. tomllib is 3.11+, so on any older interpreter the Cargo
branch silently scanned nothing and reported an exposed tree as clean --
a false negative, the one failure mode a security scanner must not have,
on an ecosystem SKILL.md advertises unconditionally.

Parse the file line-wise instead, as pypi-ops/publish-preflight.sh
already does for the same reason. Safe because Cargo.lock is machine
generated with a rigid shape; the parser stays table-aware so the
top-level `version = 3|4` format key and [[patch.unused]] / [metadata]
tables are not mistaken for installed crates. Verified identical to
tomllib across a real 529-package lockfile.

The old fixture was a bare [[package]] that both a correct and a
table-blind parser passed, which is why the gap went unnoticed. Rebuild
it as a realistic lockfile and assert the unused-patch crate is skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0xDarkMatter hace 1 mes
padre
commit
68676b71b8

+ 32 - 9
skills/supply-chain-defense/scripts/exposure-check.py

@@ -243,17 +243,40 @@ def parse_composer_lock(path: Path, components):  # composer.lock (JSON)
             add(components, "composer", meta.get("name"), meta.get("version"), path)
 
 
-def parse_cargo_lock(path: Path, components):  # Cargo.lock (TOML; needs py3.11+ tomllib)
+# Cargo.lock is TOML, but parsed line-wise rather than with tomllib: tomllib is
+# 3.11+, and this script must run on whatever python is on PATH. Skipping Cargo on
+# 3.10 made the scan report *clean* on an exposed tree — a silent false negative,
+# the one failure mode a security scanner must not have. Same tradeoff (and reason)
+# as pypi-ops/scripts/publish-preflight.sh. Safe because the file is machine-
+# generated by cargo with a rigid shape: a flat sequence of [[package]] tables,
+# each with `name = "..."` / `version = "..."` on their own lines, values never
+# multi-line. Only [[package]] counts — the top-level `version = 3|4` lockfile-
+# format key sits before any table, and [[patch.unused]] / [metadata] tables carry
+# name/version keys that are NOT installed components.
+CARGO_TABLE_RE = re.compile(r"^\s*\[\[?([^\]]+)\]\]?\s*$")
+CARGO_KV_RE = re.compile(r'^\s*(name|version)\s*=\s*"([^"]*)"\s*$')
+
+
+def parse_cargo_lock(path: Path, components):  # Cargo.lock (TOML subset; see above)
     try:
-        import tomllib
-    except ImportError:
-        return  # tomllib is 3.11+; skip Cargo on older pythons
-    try:
-        doc = tomllib.loads(read_text_tolerant(path))
-    except Exception:  # OSError or tomllib.TOMLDecodeError
+        lines = read_text_tolerant(path).splitlines()
+    except OSError:
         return
-    for pkg in doc.get("package", []):
-        add(components, "cargo", pkg.get("name"), pkg.get("version"), path)
+    in_pkg, name, ver = False, None, None
+    for line in lines:
+        table = CARGO_TABLE_RE.match(line)
+        if table:
+            if in_pkg:
+                add(components, "cargo", name, ver, path)
+            in_pkg, name, ver = table.group(1) == "package", None, None
+        elif in_pkg:
+            kv = CARGO_KV_RE.match(line)
+            if kv and kv.group(1) == "name":
+                name = kv.group(2)
+            elif kv:
+                ver = kv.group(2)
+    if in_pkg:  # flush the final table (no trailing header to trigger it)
+        add(components, "cargo", name, ver, path)
 
 
 def parse_go_sum(path: Path, components):  # go.sum lines: "<module> <version>[/go.mod] <hash>"

+ 30 - 3
skills/supply-chain-defense/tests/run.sh

@@ -36,6 +36,7 @@ ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
 no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
 expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
 expect_has()  { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
+expect_lacks() { case "$3" in *"$2"*) no "$1 (unexpected '$2')";; *) ok "$1";; esac; }
 
 echo "=== supply-chain-defense self-test ==="
 
@@ -72,13 +73,39 @@ out="$(SC_EXT_DIRS="$SB/ext" "$PYTHON" "$SCRIPTS/exposure-check.py" --root "$SB/
 expect_exit "editor-extension IOC -> 10" 10 "$rc"
 expect_has  "flags Nx Console 18.95.0" "nrwl.angular-console@18.95.0" "$out"
 
-# new ecosystem (Cargo) parsing + match via a custom catalog
+# new ecosystem (Cargo) parsing + match via a custom catalog. Fixture is a
+# REALISTIC Cargo.lock, not a bare [[package]]: the `version = 4` format preamble
+# and the [[patch.unused]] / [metadata] tables are what separate a table-aware
+# parser from a blind name/version regex sweep. A minimal fixture passed either
+# way, which is how the parser silently scanned nothing on py<3.11.
 mkdir -p "$SB/rust"
-printf '[[package]]\nname = "evilcrate"\nversion = "6.6.6"\n' > "$SB/rust/Cargo.lock"
-printf '{"schema_version":"v0.1.0","entries":[{"id":"T","name":"t","ecosystem":"cargo","package":"evilcrate","versions":["6.6.6"],"severity":"critical"}]}' > "$SB/cat.json"
+cat > "$SB/rust/Cargo.lock" <<'LOCK'
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "evilcrate"
+version = "6.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "deadbeef"
+dependencies = [
+ "memchr",
+]
+
+[[patch.unused]]
+name = "ghostcrate"
+version = "9.9.9"
+
+[metadata]
+"checksum evilcrate 6.6.6 (registry+https://x)" = "deadbeef"
+LOCK
+printf '{"schema_version":"v0.1.0","entries":[{"id":"T","name":"t","ecosystem":"cargo","package":"evilcrate","versions":["6.6.6"],"severity":"critical"},{"id":"T2","name":"t2","ecosystem":"cargo","package":"ghostcrate","versions":["9.9.9"],"severity":"critical"}]}' > "$SB/cat.json"
 out="$("$PYTHON" "$SCRIPTS/exposure-check.py" --catalog "$SB/cat.json" --root "$SB/rust" --no-extensions --findings-only 2>&1)"; rc=$?
 expect_exit "cargo lockfile IOC -> 10" 10 "$rc"
+# exact version also proves the `version = 4` preamble wasn't attached to the crate
 expect_has  "flags cargo crate" "evilcrate@6.6.6" "$out"
+expect_lacks "skips [[patch.unused]] crate" "ghostcrate" "$out"
 
 # frontend lockfiles — pnpm + yarn (FED teams); axios 1.14.1 is the seeded IOC
 mkdir -p "$SB/pnpm" "$SB/yarn"