Files
CASAN/AINative_OKR_CASAN5/.specify/scripts/bash/supply-chain-scan.py
T
thanhnvandClaude Opus 4.8 c51e0f88a3 feat(track-c-mvp): C1 action gating + C2 supply-chain gate
C1 (V17) action-gate.sh: gates the ACTION, not just the tool name. Outcome model
  ALLOW/WARN/REQUIRE_APPROVAL/BLOCK. BLOCK on sensitive-file writes (.env, *.pem,
  id_rsa, .github/workflows, .ssh, .aws/credentials, .npmrc) and destructive/
  remote-exec commands (rm -rf /, curl|bash, chmod 777, git push --force);
  REQUIRE_APPROVAL on dependency installs and non-local network egress (clears
  only with an audited CASAN_ACTION_APPROVER). Decisions logged to action-gate.jsonl.
C2 (V18) supply-chain-gate.sh + supply-chain-scan.py: diffs package.json /
  requirements.txt / pom.xml / build.gradle against a baseline (explicit or git
  HEAD). BLOCK on denylisted/known-malicious packages, typosquats (edit-distance 1
  to a known package), and dangerous lifecycle scripts (pre/post/install);
  REQUIRE_APPROVAL on any new dependency. Emits a dep-diff report and records
  which live scanners (npm audit / pip-audit / osv-scanner) are available.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:51:43 +09:00

150 lines
5.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""CASAN Track C-MVP — supply-chain manifest diff + risk scan (C2, V18 core).
Diffs a dependency manifest against a baseline and classifies the change.
Prints "<OUTCOME>|<reason>" on stdout and writes a JSON report.
Argv: <manifest> <baseline> <known-packages.txt> <malicious-packages.txt>
<report.json> <scanners-space-separated>
Outcomes: BLOCK (denylist / typosquat / dangerous lifecycle),
REQUIRE_APPROVAL (new dependency added), ALLOW (no new deps).
Deterministic: no network; a local denylist is authoritative when no live
scanner is available (their availability is recorded honestly in the report).
"""
import json
import os
import re
import sys
def read(path: str) -> str:
try:
return open(path, encoding="utf-8").read()
except OSError:
return ""
def load_list(path: str):
out = []
for line in read(path).splitlines():
line = line.strip()
if line and not line.startswith("#"):
out.append(line)
return out
def parse_deps(text: str, name: str):
"""Return ({pkg: version}, [dangerous lifecycle scripts])."""
deps, scripts = {}, []
base = os.path.basename(name).lower()
if base == "package.json" or name.endswith(".json"):
try:
data = json.loads(text) if text.strip() else {}
except ValueError:
data = {}
for sect in ("dependencies", "devDependencies",
"optionalDependencies", "peerDependencies"):
for k, v in (data.get(sect) or {}).items():
deps[k] = str(v)
for hook in ("preinstall", "install", "postinstall"):
s = (data.get("scripts") or {}).get(hook)
if s:
scripts.append(hook + ":" + s)
elif base == "requirements.txt" or name.endswith(".txt"):
for line in text.splitlines():
line = line.split("#", 1)[0].strip()
if not line:
continue
m = re.match(r"^([A-Za-z0-9._-]+)\s*([=<>!~].*)?$", line)
if m:
deps[m.group(1)] = (m.group(2) or "").strip()
elif base == "pom.xml" or name.endswith(".xml"):
for m in re.finditer(r"<artifactId>([^<]+)</artifactId>", text):
deps[m.group(1).strip()] = ""
elif base.startswith("build.gradle"):
for m in re.finditer(r"""['"]([\w.\-]+):([\w.\-]+):([\w.\-]+)['"]""", text):
deps[m.group(1) + ":" + m.group(2)] = m.group(3)
return deps, scripts
def levenshtein(a: str, b: str) -> int:
if abs(len(a) - len(b)) > 1:
return 2
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
row = [i]
for j, cb in enumerate(b, 1):
row.append(min(prev[j] + 1, row[-1] + 1, prev[j - 1] + (ca != cb)))
prev = row
return prev[-1]
def main() -> int:
manifest, baseline, known_path, deny_path, report_path, scanners_raw = sys.argv[1:7]
scanners = [s for s in scanners_raw.split() if s]
known = load_list(known_path)
deny = set(load_list(deny_path))
cur, cur_scripts = parse_deps(read(manifest), manifest)
base_deps, base_scripts = parse_deps(read(baseline), manifest)
added = {k: v for k, v in cur.items() if k not in base_deps}
new_scripts = [s for s in cur_scripts if s not in base_scripts]
findings = []
approval = []
for name, ver in added.items():
clean_ver = ver.lstrip("=<>!~^ ")
ident = (name + "@" + clean_ver) if ver else name
denied = (
name in deny
or ident in deny
or any(d.split("@")[0] == name and "@" in d and d.split("@", 1)[1] in ver for d in deny)
)
if denied:
findings.append({"package": name, "reason": "denylisted_or_known_malicious"})
continue
if name not in known:
near = next((k for k in known if levenshtein(name.lower(), k.lower()) == 1), None)
if near:
findings.append({"package": name, "reason": "typosquat_of:" + near})
continue
approval.append({"package": name, "version": ver})
for s in new_scripts:
findings.append({"package": "<lifecycle-script>", "reason": "dangerous_lifecycle:" + s[:120]})
if findings:
outcome = "BLOCK"
elif approval:
outcome = "REQUIRE_APPROVAL"
else:
outcome = "ALLOW"
report = {
"manifest": manifest,
"scanners_available": scanners,
"scanners_note": "local denylist authoritative when no live scanner present",
"added": [{"package": k, "version": v} for k, v in added.items()],
"new_lifecycle_scripts": new_scripts,
"blocked": findings,
"require_approval": approval,
"outcome": outcome,
}
with open(report_path, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
if findings:
reason = ";".join(x["package"] + ":" + x["reason"] for x in findings)
elif approval:
reason = ",".join(x["package"] for x in approval)
else:
reason = "no_new_dependencies"
print(outcome + "|" + reason)
return 0
if __name__ == "__main__":
sys.exit(main())