render_report.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  1. #!/usr/bin/env python3
  2. """Render a scan's machine-readable artifacts from its run directory.
  3. Writes CLAUDE-SECURITY-RESULTS.jsonl (one finding per line, fields in a fixed
  4. order) and the CLAUDE-SECURITY-REVISION-<tag>.json stamp, places the report
  5. markdown beside them, then removes the scan's run directory now that its
  6. records are rendered. Filenames, JSONL field order, and verification.status
  7. semantics are stable across releases.
  8. Usage: render_report.py <run-dir> [--products-dir <dir>]
  9. Python 3.9-compatible, stdlib only.
  10. """
  11. from __future__ import annotations
  12. import contextlib
  13. import json
  14. import os
  15. import re
  16. import shutil
  17. import sys
  18. import tempfile
  19. from collections.abc import Mapping
  20. from datetime import datetime, timezone
  21. from typing import NoReturn, TypedDict, cast
  22. JsonMap = Mapping[str, object]
  23. Finding = dict[str, object]
  24. class Panel(TypedDict, total=False):
  25. """A validated panel round: an int vote count and the fixed voter count."""
  26. true: int
  27. false: int
  28. voters: int
  29. class VerificationSummary(TypedDict, total=False):
  30. """The stamp's `verification` object; every path names why if not verified."""
  31. status: str
  32. candidates: int
  33. candidates_deduped: int
  34. panel_votes: int
  35. panel_reviewed_findings: int
  36. panel_quorum_findings: int
  37. unreviewed_candidate_sites: object
  38. attested_findings: int
  39. reason: str | None
  40. researchers_dispatched: int
  41. researchers_returned: int
  42. REPORT_FIELDS = (
  43. "id",
  44. "title",
  45. "impact",
  46. "file",
  47. "line",
  48. "description",
  49. "exploit_scenario",
  50. "preconditions",
  51. "category",
  52. "severity",
  53. "confidence",
  54. "recommendation",
  55. "cwe_id",
  56. "snippet",
  57. "symbol",
  58. )
  59. SEPARATOR_ESCAPES = {0x85: "\\u0085", 0x2028: "\\u2028", 0x2029: "\\u2029"}
  60. SEVERITIES = ("HIGH", "MEDIUM", "LOW")
  61. CONFIDENCES = ("low", "medium", "high")
  62. CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3}
  63. PANEL_VOTER_COUNT = 3
  64. PANEL_KEEP_QUORUM = 2
  65. REVISION_PREFIX = "CLAUDE-SECURITY-REVISION-"
  66. RUN_DIR_NAME = ".claude-security-run"
  67. # \Z, not $: `$` also matches before a trailing newline, and this names a file.
  68. HEX_RE = re.compile(r"^[0-9a-fA-F]{7,64}\Z")
  69. FINDING_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\Z")
  70. CATEGORY_ALIASES = {
  71. "sqli": "sql-injection",
  72. "sql injection": "sql-injection",
  73. "rce": "command-injection",
  74. "command execution": "command-injection",
  75. "cmdi": "command-injection",
  76. "xss": "xss",
  77. "cross-site scripting": "xss",
  78. "csrf": "csrf",
  79. "cross-site request forgery": "csrf",
  80. "ssrf": "ssrf",
  81. "path traversal": "path-traversal",
  82. "directory traversal": "path-traversal",
  83. "idor": "idor",
  84. "authz bypass": "improper-authorization",
  85. "authn bypass": "auth-bypass",
  86. "hardcoded credentials": "hardcoded-secret",
  87. "hardcoded password": "hardcoded-secret",
  88. "secret": "hardcoded-secret",
  89. "weak cryptography": "weak-crypto",
  90. "insecure randomness": "weak-randomness",
  91. "uaf": "use-after-free",
  92. "oob read": "out-of-bounds-read",
  93. "oob write": "out-of-bounds-write",
  94. "denial of service": "dos",
  95. "prototype pollution": "prototype-pollution",
  96. }
  97. class RenderError(Exception):
  98. """A refusal; the message names what the caller must fix."""
  99. def as_map(value: object) -> JsonMap | None:
  100. """The value as a str-keyed mapping, or None when it is not one."""
  101. if isinstance(value, dict):
  102. return cast("JsonMap", value)
  103. return None
  104. def die(message: str) -> NoReturn:
  105. sys.stderr.write(f"render_report.py: {message}\n")
  106. sys.exit(1)
  107. def read_json(run_dir: str, name: str, required: bool = True) -> object:
  108. path = os.path.join(run_dir, name)
  109. try:
  110. with open(path, encoding="utf-8") as handle:
  111. return cast("object", json.load(handle))
  112. except OSError as error:
  113. if required:
  114. msg = f"{name} is missing from the run directory. Write it before running this script."
  115. raise RenderError(msg) from error
  116. return None
  117. except ValueError as error:
  118. msg = f"{name} is not valid JSON: {error}"
  119. raise RenderError(msg) from error
  120. def normalize_category(raw: object) -> str:
  121. """Lowercase/slugify a category and fold known synonyms."""
  122. text = str(raw or "").strip().lower()
  123. if text in CATEGORY_ALIASES:
  124. return CATEGORY_ALIASES[text]
  125. slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
  126. return CATEGORY_ALIASES.get(slug, slug)
  127. def confidence_value(raw: object) -> str:
  128. """A finding's stated confidence, normalized to low|medium|high; refuses others."""
  129. if isinstance(raw, str):
  130. word = raw.strip().lower()
  131. if word in CONFIDENCE_RANK:
  132. return word
  133. msg = "confidence {!r} is not one of {}".format(raw, "/".join(CONFIDENCES))
  134. raise RenderError(msg)
  135. def panel_complete(record: object) -> Panel | None:
  136. """The validated panel dict for one round record, or None.
  137. A complete panel has `voters` equal to PANEL_VOTER_COUNT and an integer
  138. `true` vote count.
  139. """
  140. round_record = as_map(record)
  141. if round_record is None:
  142. return None
  143. panel = as_map(round_record.get("panel"))
  144. if panel is None:
  145. return None
  146. panel_true = panel.get("true")
  147. if not isinstance(panel_true, int) or isinstance(panel_true, bool):
  148. return None
  149. if panel.get("voters") != PANEL_VOTER_COUNT:
  150. return None
  151. panel_false = panel.get("false")
  152. return {
  153. "true": panel_true,
  154. "false": panel_false if isinstance(panel_false, int) else 0,
  155. "voters": PANEL_VOTER_COUNT,
  156. }
  157. def vote_confidence_ceiling(rounds: object) -> str | None:
  158. """The vote-backed confidence ceiling for one finding, or None.
  159. A unanimous panel yields `high`; a keep quorum below unanimity yields
  160. `medium`. None means no usable vote record.
  161. """
  162. panel = panel_complete(rounds)
  163. if panel is None:
  164. return None
  165. return "high" if panel.get("true", 0) >= PANEL_VOTER_COUNT else "medium"
  166. def build_finding(raw: object, index: int, rounds_by_id: JsonMap) -> Finding:
  167. """Validate one finding into exactly REPORT_FIELDS, in order."""
  168. item = as_map(raw)
  169. if item is None:
  170. msg = f"findings.json item {index} is not an object"
  171. raise RenderError(msg)
  172. finding_id = str(item.get("id") or f"F{index + 1}")
  173. if not FINDING_ID_RE.match(finding_id):
  174. msg = f"finding id {finding_id!r} is not a valid id"
  175. raise RenderError(msg)
  176. for required in ("title", "file", "description", "exploit_scenario"):
  177. if not item.get(required):
  178. msg = f"finding {finding_id} is missing required field {required!r}"
  179. raise RenderError(msg)
  180. severity = str(item.get("severity", "")).strip().upper()
  181. if severity not in SEVERITIES:
  182. msg = "finding {} severity {!r} is not one of {}".format(
  183. finding_id, item.get("severity"), "/".join(SEVERITIES)
  184. )
  185. raise RenderError(msg)
  186. confidence = confidence_value(item.get("confidence"))
  187. ceiling = vote_confidence_ceiling(rounds_by_id.get(finding_id))
  188. if ceiling is not None and CONFIDENCE_RANK[confidence] > CONFIDENCE_RANK[ceiling]:
  189. confidence = ceiling
  190. raw_line = item.get("line", 0)
  191. try:
  192. line = int(raw_line) if isinstance(raw_line, (int, float, str)) else int(str(raw_line))
  193. except (TypeError, ValueError, OverflowError) as error:
  194. msg = "finding {} line {!r} is not an integer".format(finding_id, item.get("line"))
  195. raise RenderError(msg) from error
  196. preconditions_raw: object = item.get("preconditions") or []
  197. if not isinstance(preconditions_raw, list):
  198. msg = f"finding {finding_id} preconditions must be a list"
  199. raise RenderError(msg)
  200. cwe = item.get("cwe_id")
  201. if cwe:
  202. text = str(cwe).strip().upper().replace("_", "-")
  203. if re.match(r"^\d{1,5}$", text):
  204. text = "CWE-" + text
  205. cwe = text if re.match(r"^CWE-\d{1,5}$", text) else None
  206. else:
  207. cwe = None
  208. finding = {
  209. "id": finding_id,
  210. "title": item.get("title"),
  211. "impact": item.get("impact") or "",
  212. "file": item.get("file"),
  213. "line": line,
  214. "description": item.get("description"),
  215. "exploit_scenario": item.get("exploit_scenario"),
  216. "preconditions": [str(p) for p in cast("list[object]", preconditions_raw)],
  217. "category": normalize_category(item.get("category")),
  218. "severity": severity,
  219. "confidence": confidence,
  220. "recommendation": item.get("recommendation") or "",
  221. "cwe_id": cwe,
  222. "snippet": item.get("snippet") or "",
  223. "symbol": item.get("symbol") or "",
  224. }
  225. return {k: finding[k] for k in REPORT_FIELDS}
  226. def read_coverage(run_dir: str) -> tuple[JsonMap | None, str]:
  227. """The optional coverage.json for the informational run_shape field.
  228. Returns (map_or_None, source): source is "coverage.json" when the file
  229. is a usable object, "unavailable" when it is absent, and "unreadable" when
  230. it exists but is not a usable object.
  231. """
  232. name = "coverage.json"
  233. try:
  234. raw = read_json(run_dir, name, required=False)
  235. except RenderError:
  236. return None, "unreadable"
  237. if raw is None:
  238. present = os.path.exists(os.path.join(run_dir, name))
  239. return None, ("unreadable" if present else "unavailable")
  240. cov = as_map(raw)
  241. if cov is None:
  242. return None, "unreadable"
  243. return cov, name
  244. COVERAGE_TEXT_CAP = 300
  245. def coverage_text(value: object, cap: int = COVERAGE_TEXT_CAP) -> str | None:
  246. """A coverage string, trimmed to `cap`, or None when the value is not a string."""
  247. if not isinstance(value, str):
  248. return None
  249. if len(value) > cap:
  250. return value[:cap] + f"...[+{len(value) - cap} chars]"
  251. return value
  252. def skipped_components(raw: object) -> list[dict[str, object]] | None:
  253. """coverage.skippedComponents as [{name, paths, reason}], or None when unusable."""
  254. if not isinstance(raw, list):
  255. return None
  256. out: list[dict[str, object]] = []
  257. for entry in cast("list[object]", raw):
  258. item = as_map(entry)
  259. if item is None:
  260. continue
  261. paths_raw = item.get("paths")
  262. paths_in: list[object] = (
  263. cast("list[object]", paths_raw) if isinstance(paths_raw, list) else []
  264. )
  265. paths = [text for text in (coverage_text(p, 200) for p in paths_in) if text]
  266. out.append({
  267. "name": coverage_text(item.get("name"), 100) or "",
  268. "paths": paths,
  269. "reason": coverage_text(item.get("reason")) or "",
  270. })
  271. return out
  272. def coverage_enum(value: object, allowed: tuple[str, ...]) -> str | None:
  273. """A coverage enum field, or None when absent or not one of the known values."""
  274. return value if isinstance(value, str) and value in allowed else None
  275. def run_shape(coverage: JsonMap | None, source: str, effort: object) -> dict[str, object]:
  276. """What shape actually ran, distinct from the effort tier that was asked."""
  277. shape: dict[str, object] = {"requested_effort": effort, "collapsed": None, "source": source}
  278. if coverage is None:
  279. return shape
  280. shape["collapsed"] = coverage.get("collapsed")
  281. shape["diff_files"] = coverage.get("diffFiles")
  282. shape["diff_lines"] = coverage.get("diffLines")
  283. shape["scope_files"] = coverage.get("scopeFiles")
  284. shape["empty_diff"] = bool(coverage.get("emptyDiff"))
  285. shape["empty_scope"] = bool(coverage.get("emptyScope"))
  286. shape["researchers_dispatched"] = coverage.get("researchersDispatched")
  287. shape["skipped_components"] = skipped_components(coverage.get("skippedComponents"))
  288. shape["completeness_check_outcome"] = coverage_enum(
  289. coverage.get("completenessCheckOutcome"),
  290. ("checked", "partial", "not-checkable", "not-applicable"),
  291. )
  292. unaccounted_raw = coverage.get("unaccountedTopLevelDirs")
  293. unaccounted_in: list[object] = (
  294. cast("list[object]", unaccounted_raw) if isinstance(unaccounted_raw, list) else []
  295. )
  296. shape["unaccounted_top_level_dirs"] = [
  297. text for text in (coverage_text(x, 200) for x in unaccounted_in) if text
  298. ]
  299. shape["inventory_fallback"] = coverage_enum(
  300. coverage.get("inventoryFallback"),
  301. ("inventory-failed", "empty-partition", "incomplete-partition"),
  302. )
  303. top_count = coverage.get("topLevelCount")
  304. shape["top_level_dir_count"] = (
  305. top_count if isinstance(top_count, int) and not isinstance(top_count, bool) else None
  306. )
  307. return shape
  308. def verification_summary(
  309. findings: list[Finding],
  310. votes: JsonMap,
  311. votes_present: bool = True,
  312. ) -> VerificationSummary:
  313. """Compute the stamp's verification object from the vote record.
  314. status is 'verified' only when the vote record proves the panel ran for
  315. every finding the report contains; otherwise 'unverified' with a `reason`.
  316. votes_present is False when votes.json was absent from the run directory.
  317. """
  318. rounds = as_map(votes.get("rounds")) or {}
  319. panel_reviewed = 0
  320. panel_quorum = 0
  321. incomplete: list[str] = []
  322. for finding in findings:
  323. finding_id = str(finding.get("id", ""))
  324. panel = panel_complete(rounds.get(finding_id))
  325. if panel is None:
  326. incomplete.append(finding_id)
  327. continue
  328. panel_reviewed += 1
  329. if panel.get("true", 0) >= PANEL_KEEP_QUORUM:
  330. panel_quorum += 1
  331. def as_count(key: str) -> int:
  332. """A vote count as a non-negative int; a wrong shape is a refusal."""
  333. value = votes.get(key, 0)
  334. if isinstance(value, bool) or not isinstance(value, int) or value < 0:
  335. msg = (
  336. f"votes.json field {key!r} is not a non-negative integer ({value!r}); the "
  337. "vote record is malformed"
  338. )
  339. raise RenderError(msg)
  340. return value
  341. def optional_count(key: str) -> int | None:
  342. """A count that may be absent: None when so, else as_count's contract."""
  343. if key not in votes:
  344. return None
  345. return as_count(key)
  346. candidates_recorded = "candidates" in votes
  347. researchers_dispatched = optional_count("researchers_dispatched")
  348. researchers_returned = optional_count("researchers_returned")
  349. summary: dict[str, object] = {
  350. "status": "verified",
  351. "candidates": as_count("candidates"),
  352. "candidates_deduped": as_count("candidates_deduped"),
  353. "panel_votes": as_count("panel_votes"),
  354. "panel_reviewed_findings": panel_reviewed,
  355. "panel_quorum_findings": panel_quorum,
  356. "unreviewed_candidate_sites": as_count("unreviewed_candidate_sites"),
  357. "attested_findings": 0,
  358. "reason": None,
  359. }
  360. if researchers_dispatched is not None:
  361. summary["researchers_dispatched"] = researchers_dispatched
  362. if researchers_returned is not None:
  363. summary["researchers_returned"] = researchers_returned
  364. reportable: list[Finding] = findings
  365. if not votes_present:
  366. summary["status"] = "unverified"
  367. summary["reason"] = (
  368. "votes.json is absent from the run directory: the verification "
  369. "pipeline left no vote record, so nothing about this report can be "
  370. "attested"
  371. )
  372. elif not candidates_recorded:
  373. summary["status"] = "unverified"
  374. summary["reason"] = (
  375. "votes.json has no 'candidates' field: the vote record does not "
  376. "prove the pipeline ran, so nothing about this report can be attested"
  377. )
  378. elif researchers_dispatched and researchers_returned == 0:
  379. summary["status"] = "unverified"
  380. summary["reason"] = (
  381. f"{researchers_dispatched} research agent(s) were dispatched but none returned; "
  382. "the scan examined nothing"
  383. )
  384. elif incomplete:
  385. summary["status"] = "unverified"
  386. summary["reason"] = (
  387. f"these findings have no complete {PANEL_VOTER_COUNT}-voter panel round: "
  388. f"{', '.join(sorted(incomplete))}"
  389. )
  390. elif reportable and panel_quorum != len(reportable):
  391. summary["status"] = "unverified"
  392. summary["reason"] = (
  393. f"{len(reportable) - panel_quorum} of {len(reportable)} reported findings did not "
  394. "reach the keep quorum, so the report contains findings the panel rejected"
  395. )
  396. elif not findings and not votes.get("rounds") and summary["candidates"]:
  397. summary["status"] = "unverified"
  398. summary["reason"] = f"{summary['candidates']} candidates were recorded but none was paneled"
  399. elif not findings and rounds and not any(panel_complete(record) for record in rounds.values()):
  400. summary["status"] = "unverified"
  401. summary["reason"] = (
  402. f"{len(rounds)} panel round(s) were dispatched but none completed a full "
  403. f"{PANEL_VOTER_COUNT}-voter review; no candidate was actually verified"
  404. )
  405. return cast("VerificationSummary", cast("object", summary))
  406. def revision_tag(revision: object) -> str:
  407. """The stamp's filename tag: <sha12>[-dirty], or UNVERSIONED."""
  408. rev = as_map(revision) or {}
  409. sha = rev.get("commit") or rev.get("head")
  410. if not sha:
  411. return "UNVERSIONED"
  412. if not (isinstance(sha, str) and HEX_RE.match(sha)):
  413. msg = f"the run's revision {sha!r} is not a hex commit id, so it cannot name the stamp file"
  414. raise RenderError(msg)
  415. return sha[:12] + ("" if rev.get("dirty") is False else "-dirty")
  416. def atomic_write(path: str, text: str) -> None:
  417. """Write `text` atomically: a temp file in the same directory, then replace."""
  418. directory = os.path.dirname(path)
  419. handle, temp = tempfile.mkstemp(dir=directory, prefix=".render.")
  420. try:
  421. with os.fdopen(handle, "w", encoding="utf-8") as out:
  422. out.write(text)
  423. out.flush()
  424. os.fsync(out.fileno())
  425. os.replace(temp, path)
  426. except BaseException:
  427. with contextlib.suppress(OSError):
  428. os.unlink(temp)
  429. raise
  430. def jsonl_line(finding: Finding) -> str:
  431. """One finding, fixed field order, separators escaped."""
  432. text = json.dumps(finding, ensure_ascii=False, sort_keys=False)
  433. return text.translate(SEPARATOR_ESCAPES)
  434. def render(run_dir: str, products_dir: str) -> tuple[list[Finding], VerificationSummary, str]:
  435. meta_raw = read_json(run_dir, "scan-meta.json")
  436. findings_raw = read_json(run_dir, "findings.json")
  437. votes: object = read_json(run_dir, "votes.json", required=False)
  438. coverage, coverage_source = read_coverage(run_dir)
  439. votes_present = votes is not None
  440. if votes is None:
  441. votes = {}
  442. if not isinstance(findings_raw, list):
  443. raise RenderError("findings.json must be a JSON array (use [] for no findings)")
  444. meta = as_map(meta_raw)
  445. if meta is None:
  446. raise RenderError("scan-meta.json must be a JSON object")
  447. votes_map = as_map(votes)
  448. if votes_map is None:
  449. raise RenderError("votes.json must be a JSON object mapping the vote record")
  450. rounds_raw = votes_map.get("rounds")
  451. rounds_by_id: JsonMap = {} if rounds_raw is None else (as_map(rounds_raw) or {})
  452. if rounds_raw is not None and not isinstance(rounds_raw, dict):
  453. kind = type(rounds_raw).__name__
  454. msg = f"votes.json 'rounds' must be an object keyed by finding id, not {kind}"
  455. raise RenderError(msg)
  456. findings = [
  457. build_finding(raw, i, rounds_by_id)
  458. for i, raw in enumerate(cast("list[object]", findings_raw))
  459. ]
  460. seen = {}
  461. for finding in findings:
  462. if finding["id"] in seen:
  463. msg = "finding id {!r} appears twice in findings.json".format(finding["id"])
  464. raise RenderError(msg)
  465. seen[finding["id"]] = True
  466. markdown_path = os.path.join(run_dir, "CLAUDE-SECURITY-RESULTS.md")
  467. if not os.path.isfile(markdown_path):
  468. raise RenderError(
  469. "CLAUDE-SECURITY-RESULTS.md is missing. Write the human-readable "
  470. "report before running this script."
  471. )
  472. with open(markdown_path, encoding="utf-8", newline="") as handle:
  473. markdown = handle.read()
  474. counts: dict[str, int] = dict.fromkeys(SEVERITIES, 0)
  475. for finding in findings:
  476. counts[str(finding.get("severity", ""))] += 1
  477. verification = verification_summary(findings, votes_map, votes_present=votes_present)
  478. revision: object = meta.get("revision") or {}
  479. tag = revision_tag(revision)
  480. atomic_write(
  481. os.path.join(products_dir, "CLAUDE-SECURITY-RESULTS.jsonl"),
  482. "".join(jsonl_line(f) + "\n" for f in findings),
  483. )
  484. markdown_out = os.path.join(products_dir, "CLAUDE-SECURITY-RESULTS.md")
  485. if os.path.realpath(markdown_path) != os.path.realpath(markdown_out):
  486. atomic_write(markdown_out, markdown)
  487. os.unlink(markdown_path)
  488. stamp: dict[str, object] = {
  489. "generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
  490. "scan_root": meta.get("scan_root"),
  491. "products_dir": products_dir,
  492. "mode": meta.get("mode"),
  493. "scope": meta.get("scope") or [],
  494. "revision": revision,
  495. "revision_source": meta.get("revision_source") or "self-reported",
  496. "model": meta.get("model"),
  497. "effort": meta.get("effort"),
  498. "run_shape": run_shape(coverage, coverage_source, meta.get("effort")),
  499. "findings": {
  500. "total": len(findings),
  501. "high": counts["HIGH"],
  502. "medium": counts["MEDIUM"],
  503. "low": counts["LOW"],
  504. },
  505. "verification": verification,
  506. }
  507. for stale in os.listdir(products_dir):
  508. if stale.startswith(REVISION_PREFIX) and stale.endswith(".json"):
  509. os.unlink(os.path.join(products_dir, stale))
  510. atomic_write(
  511. os.path.join(products_dir, f"{REVISION_PREFIX}{tag}.json"),
  512. json.dumps(stamp, indent=2) + "\n",
  513. )
  514. return findings, verification, tag
  515. def remove_run_dir(run_dir: str, products_dir: str) -> str:
  516. """Remove the scan's run directory once rendered; returns a one-line status."""
  517. target = os.path.normpath(os.path.abspath(run_dir))
  518. if os.path.basename(target) != RUN_DIR_NAME:
  519. return f"kept {run_dir} (not a {RUN_DIR_NAME} run directory)"
  520. if os.path.realpath(target) == os.path.realpath(products_dir):
  521. return f"kept {run_dir} (it holds the products)"
  522. try:
  523. shutil.rmtree(target)
  524. except OSError as error:
  525. detail = error.args[0] if error.args else error
  526. return f"WARNING: could not remove run directory {run_dir}: {detail}"
  527. return f"removed run directory {run_dir}"
  528. def main(argv: list[str]) -> int:
  529. products_dir: str | None = None
  530. args = list(argv)
  531. if len(args) == 3 and args[1] == "--products-dir":
  532. products_dir = args.pop(2)
  533. args.pop(1)
  534. if len(args) != 1:
  535. die("usage: render_report.py <run-dir> [--products-dir <dir>]")
  536. run_dir = args[0]
  537. if not os.path.isdir(run_dir):
  538. die(f"not a directory: {run_dir}")
  539. products_dir = products_dir or run_dir
  540. if not os.path.isdir(products_dir):
  541. die(f"products directory is not a directory: {products_dir}")
  542. try:
  543. findings, verification, tag = render(run_dir, products_dir)
  544. except RenderError as error:
  545. die(str(error))
  546. except OSError as error:
  547. die(f"could not read or write the report's files: {error}")
  548. removal = remove_run_dir(run_dir, products_dir)
  549. print(
  550. f"wrote CLAUDE-SECURITY-RESULTS.jsonl ({len(findings)} finding"
  551. f"{'' if len(findings) == 1 else 's'}) and {REVISION_PREFIX}{tag}.json "
  552. f"into {products_dir}"
  553. )
  554. print(f"stamp: {REVISION_PREFIX}{tag}.json")
  555. print(f"verification.status: {verification.get('status')}")
  556. reason = verification.get("reason")
  557. if reason:
  558. print(f"verification.reason: {reason}")
  559. print(removal)
  560. return 0
  561. if __name__ == "__main__":
  562. sys.exit(main(sys.argv[1:]))