render_report.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895
  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), CLAUDE-SECURITY-RESULTS.sarif (the same findings as a SARIF 2.1.0 log)
  5. and the CLAUDE-SECURITY-REVISION-<tag>.json stamp, places the report markdown
  6. beside them, then removes the scan's run directory now that its records are
  7. rendered. Each finding sits at the line of its file its quoted code is on and
  8. carries an id computed from the file there, and findings that name one rule
  9. at one line of a file are one record in every product (see one_per_site).
  10. Filenames, JSONL field order, and verification.status semantics are stable
  11. across releases.
  12. Usage:
  13. render_report.py <run_dir> [--products-dir <dir>]
  14. Exits 0 on success, 1 on a refusal naming what is wrong, 2 on a usage error.
  15. A finding whose path cannot be carried is refused by name instead: the render
  16. still exits 0, delivers the products without it, and marks the stamp
  17. unverified (see verification.refused_findings).
  18. Python 3.9-compatible, stdlib only.
  19. """
  20. from __future__ import annotations
  21. import argparse
  22. import os
  23. import re
  24. import shutil
  25. import sys
  26. import uuid
  27. from collections import Counter
  28. from datetime import datetime, timezone
  29. from pathlib import Path
  30. from typing import TYPE_CHECKING, NamedTuple, TypedDict
  31. # The lib/ package lives next to this script. Python normally adds a script's own
  32. # directory to the import path, but not under -P or PYTHONSAFEPATH, so we add it here.
  33. sys.path.insert(0, str(Path(__file__).resolve().parent))
  34. from lib import absolute, console, cwe, plugin, sarif, secret, source, strictjson
  35. from lib.chain import chain_of, pending_ranks
  36. from lib.finding import (
  37. CONFIDENCES,
  38. PANEL_KEEP_QUORUM,
  39. PANEL_VOTER_COUNT,
  40. SEVERITIES,
  41. Finding,
  42. FindingError,
  43. FindingPathError,
  44. Record,
  45. build_finding,
  46. panel_complete,
  47. scan_prefix_shaped,
  48. )
  49. from lib.strictjson import JsonMap, is_int, is_list, is_map, is_str
  50. if TYPE_CHECKING:
  51. from collections.abc import Mapping, Sequence
  52. class _ResearcherCounts(TypedDict, total=False):
  53. """The two verification counts a vote record may omit."""
  54. researchers_dispatched: int
  55. researchers_returned: int
  56. class _RefusalRecord(TypedDict):
  57. id: str
  58. reason: str
  59. class _RefusedFindings(TypedDict, total=False):
  60. """The refusals a render may add; absent entirely when every finding carried."""
  61. refused_findings: list[_RefusalRecord]
  62. class VerificationSummary(_ResearcherCounts, _RefusedFindings):
  63. """The stamp's `verification` object; every path names why if not verified."""
  64. status: str
  65. candidates: int
  66. candidates_deduped: int
  67. panel_votes: int
  68. panel_reviewed_findings: int
  69. panel_quorum_findings: int
  70. unreviewed_candidate_sites: int
  71. incomplete_panel_candidates: int
  72. attested_findings: int
  73. reason: str | None
  74. reason_kind: str | None
  75. class Meta(NamedTuple):
  76. """The scan meta a render reads back: the scan itself, and the stamp fields beside it."""
  77. scan: sarif.Scan
  78. scan_root: str
  79. revision: object
  80. revision_source: str
  81. model: object
  82. effort: object
  83. class Rendered(NamedTuple):
  84. """A completed render: the findings, their verification, and the stamp's tag."""
  85. findings: list[Record]
  86. verification: VerificationSummary
  87. tag: str
  88. REVISION_PREFIX = "CLAUDE-SECURITY-REVISION-"
  89. STAMP_ONLY = frozenset({"duration_s", "verification_runs", "reason_kind"})
  90. JSONL_NAME = "CLAUDE-SECURITY-RESULTS.jsonl"
  91. SARIF_NAME = "CLAUDE-SECURITY-RESULTS.sarif"
  92. SANITIZED_REMOTE_RE = re.compile(
  93. r"https://[a-z0-9.-]+(?::[0-9]+)?/(?:[A-Za-z0-9._~/-]|%[0-9A-F]{2})+\Z"
  94. )
  95. class Args(argparse.Namespace):
  96. """The parsed command line."""
  97. run_dir: str = ""
  98. products_dir: str | None = None
  99. class RenderError(Exception):
  100. """A refusal; the message names what the caller must fix."""
  101. def read_json(run_dir: Path, name: str) -> object:
  102. """The JSON value in a run file the render requires; a missing or malformed one is a refusal."""
  103. try:
  104. return strictjson.load(run_dir / name)
  105. except FileNotFoundError as error:
  106. msg = f"{name} is missing from the run directory. Write it before running this script."
  107. raise RenderError(msg) from error
  108. except ValueError as error:
  109. msg = f"{name} is not valid JSON: {error}"
  110. raise RenderError(msg) from error
  111. def read_votes(run_dir: Path) -> JsonMap | None:
  112. """The workflow's vote record, or None when votes.json is absent or not marked as its own."""
  113. try:
  114. raw = strictjson.load(run_dir / "votes.json")
  115. except FileNotFoundError:
  116. return None
  117. except ValueError as error:
  118. msg = f"votes.json is not valid JSON: {error}"
  119. raise RenderError(msg) from error
  120. if not is_map(raw):
  121. raise RenderError("votes.json must be a JSON object mapping the vote record")
  122. if raw.get("provenance") != plugin.VOTES_PROVENANCE:
  123. return None
  124. return raw
  125. def read_coverage(run_dir: Path) -> tuple[JsonMap | None, str]:
  126. """The optional coverage.json for the informational run_shape field.
  127. Returns (map_or_None, source): source is "coverage.json" when the file
  128. is a usable object, "unavailable" when it is absent, and "unreadable" when
  129. it exists but is not a usable object.
  130. """
  131. name = "coverage.json"
  132. try:
  133. raw = strictjson.load(run_dir / name)
  134. except FileNotFoundError:
  135. return None, "unavailable"
  136. except (OSError, ValueError):
  137. return None, "unreadable"
  138. return (raw, name) if is_map(raw) else (None, "unreadable")
  139. COVERAGE_TEXT_CAP = 300
  140. def coverage_text(value: object, cap: int = COVERAGE_TEXT_CAP) -> str | None:
  141. """A coverage string, trimmed to `cap`, or None when the value is not a string."""
  142. if not is_str(value):
  143. return None
  144. if len(value) > cap:
  145. return value[:cap] + f"...[+{len(value) - cap} chars]"
  146. return value
  147. def coverage_texts(raw: object, cap: int) -> list[str]:
  148. """The strings among a coverage list, each trimmed to `cap`; anything else is dropped."""
  149. items: list[object] = raw if is_list(raw) else []
  150. return [text for item in items if (text := coverage_text(item, cap))]
  151. def tree_relative(path: str, scan_root: str) -> str | None:
  152. """A skipped path relative to the scan root; None for an absolute spelling of anything else."""
  153. if not absolute.spelled(path):
  154. return path
  155. if not os.path.isabs(path):
  156. return None
  157. try:
  158. return absolute.relative(os.path.realpath(path), scan_root)
  159. except (ValueError, OSError):
  160. return None
  161. def skipped_component(item: JsonMap, scan_root: str) -> dict[str, object]:
  162. paths = (tree_relative(path, scan_root) for path in coverage_texts(item.get("paths"), 200))
  163. return {
  164. "name": coverage_text(item.get("name"), 100) or "",
  165. "paths": [path for path in paths if path is not None],
  166. "reason": coverage_text(item.get("reason")) or "",
  167. }
  168. def skipped_components(raw: object, scan_root: str) -> list[dict[str, object]] | None:
  169. """coverage.skippedComponents as [{name, paths, reason}], or None when unusable."""
  170. if not is_list(raw):
  171. return None
  172. return [skipped_component(entry, scan_root) for entry in raw if is_map(entry)]
  173. def coverage_enum(value: object, allowed: tuple[str, ...]) -> str | None:
  174. """A coverage enum field, or None when absent or not one of the known values."""
  175. return value if is_str(value) and value in allowed else None
  176. def coverage_count(value: object) -> int | None:
  177. """A coverage count field, or None when absent or not an integer."""
  178. return value if is_int(value) else None
  179. class ResearchCoverage(TypedDict):
  180. """The stamp's research_coverage: the coverage account's counts, and whether a list was cut."""
  181. files: int
  182. read: int
  183. not_reached: int
  184. unaccounted: int
  185. outside_components: int
  186. capped: bool
  187. def research_coverage(raw: object) -> ResearchCoverage | None:
  188. """coverage.research as the stamp carries it, or None when the account was not checked."""
  189. tree = raw.get("tree") if is_map(raw) else None
  190. if not is_map(raw) or not is_map(tree):
  191. return None
  192. files, read, not_reached, unaccounted, outside = (
  193. tree.get(key) for key in ("files", "read", "notReached", "unaccounted", "outsideComponents")
  194. )
  195. if not (
  196. is_int(files)
  197. and is_int(read)
  198. and is_int(not_reached)
  199. and is_int(unaccounted)
  200. and is_int(outside)
  201. ):
  202. return None
  203. return {
  204. "files": files,
  205. "read": read,
  206. "not_reached": not_reached,
  207. "unaccounted": unaccounted,
  208. "outside_components": outside,
  209. "capped": raw.get("capped") is True,
  210. }
  211. def run_shape(
  212. coverage: JsonMap | None,
  213. source: str,
  214. effort: object,
  215. scan_root: str,
  216. research: ResearchCoverage | None,
  217. ) -> dict[str, object]:
  218. """What shape actually ran, distinct from the effort tier that was asked."""
  219. shape: dict[str, object] = {"requested_effort": effort, "collapsed": None, "source": source}
  220. if coverage is None:
  221. return shape
  222. return {
  223. **shape,
  224. "collapsed": coverage_enum(coverage.get("collapsed"), ("small-diff", "small-scope")),
  225. "diff_files": coverage_count(coverage.get("diffFiles")),
  226. "diff_lines": coverage_count(coverage.get("diffLines")),
  227. "scope_files": coverage_count(coverage.get("scopeFiles")),
  228. "empty_diff": bool(coverage.get("emptyDiff")),
  229. "empty_scope": bool(coverage.get("emptyScope")),
  230. "researchers_dispatched": coverage_count(coverage.get("researchersDispatched")),
  231. "verification_runs": coverage_count(coverage.get("verificationRun")),
  232. "skipped_components": skipped_components(coverage.get("skippedComponents"), scan_root),
  233. "completeness_check_outcome": coverage_enum(
  234. coverage.get("completenessCheckOutcome"),
  235. ("checked", "partial", "not-checkable", "not-applicable"),
  236. ),
  237. "unaccounted_top_level_dirs": coverage_texts(coverage.get("unaccountedTopLevelDirs"), 200),
  238. "inventory_fallback": coverage_enum(
  239. coverage.get("inventoryFallback"),
  240. ("inventory-failed", "empty-partition", "incomplete-partition"),
  241. ),
  242. "top_level_dir_count": coverage_count(coverage.get("topLevelCount")),
  243. "target_files": coverage_count(coverage.get("targetFiles")),
  244. "component_cap": coverage_count(coverage.get("componentCap")),
  245. "research_coverage": research,
  246. }
  247. def handed_on(votes: JsonMap) -> int:
  248. """How many candidates the vote record's chain leaves to a run that did not complete."""
  249. raw = votes.get("chain")
  250. if raw is None:
  251. return 0
  252. try:
  253. parsed = chain_of(raw)
  254. except ValueError as error:
  255. msg = f"votes.json chain {error}; the vote record is malformed"
  256. raise RenderError(msg) from error
  257. return len(pending_ranks(parsed))
  258. def verification_summary(
  259. findings: Sequence[Finding],
  260. votes: JsonMap,
  261. votes_present: bool = True,
  262. continuing: int = 0,
  263. refused: Sequence[_RefusalRecord] = (),
  264. ) -> VerificationSummary:
  265. """Compute the stamp's verification object from the vote record and the render's refusals.
  266. status is 'verified' only when the vote record proves a complete panel
  267. round for every finding the report contains and for every other candidate
  268. it holds a round for, and the render refused nothing; otherwise
  269. 'unverified' with a `reason` in prose and a fixed `reason_kind` word.
  270. `incomplete_panel_candidates` counts the unreported candidates whose round
  271. is not complete. votes_present is False when read_votes returned None;
  272. `continuing` is handed_on(votes), the candidates left to a verification run
  273. that did not complete. `refused` lists the findings the render refused,
  274. which the summary repeats under `refused_findings`.
  275. """
  276. raw_rounds = votes.get("rounds")
  277. rounds: JsonMap = raw_rounds if is_map(raw_rounds) else {}
  278. panels = [(f["id"], panel_complete(rounds.get(f["id"]))) for f in findings]
  279. incomplete = sorted(finding_id for finding_id, panel in panels if panel is None)
  280. reviewed = [panel for _, panel in panels if panel is not None]
  281. quorum = sum(panel["true"] >= PANEL_KEEP_QUORUM for panel in reviewed)
  282. reported = {f["id"] for f in findings}
  283. dropped_incomplete = sorted(
  284. round_id
  285. for round_id, record in rounds.items()
  286. if round_id not in reported and panel_complete(record) is None
  287. )
  288. def as_count(key: str) -> int:
  289. """A vote count as a non-negative int; a wrong shape is a refusal."""
  290. raw = votes.get(key, 0)
  291. if not is_int(raw) or raw < 0:
  292. msg = (
  293. f"votes.json field {key!r} is not a non-negative integer ({raw!r}); the "
  294. "vote record is malformed"
  295. )
  296. raise RenderError(msg)
  297. return raw
  298. candidates = as_count("candidates")
  299. dispatched = as_count("researchers_dispatched") if "researchers_dispatched" in votes else None
  300. returned = as_count("researchers_returned") if "researchers_returned" in votes else None
  301. kind: str | None = None
  302. reason: str | None = None
  303. if not votes_present:
  304. kind = "no-vote-record"
  305. reason = (
  306. "votes.json is absent from the run directory or is not the scan workflow's record: "
  307. "the verification pipeline left no vote record, so nothing about this report can "
  308. "be attested"
  309. )
  310. elif "candidates" not in votes:
  311. kind = "no-candidate-count"
  312. reason = (
  313. "votes.json has no 'candidates' field: the vote record does not prove the pipeline "
  314. "ran, so nothing about this report can be attested"
  315. )
  316. elif dispatched and returned == 0:
  317. kind = "nothing-examined"
  318. reason = (
  319. f"{dispatched} research agent(s) were dispatched but none returned; the scan "
  320. "examined nothing"
  321. )
  322. elif incomplete:
  323. kind = "finding-panel-incomplete"
  324. reason = (
  325. f"these findings have no complete {PANEL_VOTER_COUNT}-voter panel round: "
  326. f"{', '.join(incomplete)}"
  327. )
  328. elif findings and quorum != len(findings):
  329. kind = "finding-below-quorum"
  330. reason = (
  331. f"{len(findings) - quorum} of {len(findings)} reported findings did not reach the "
  332. "keep quorum, so the report contains findings the panel rejected"
  333. )
  334. elif not findings and not rounds and candidates:
  335. kind = "candidates-not-paneled"
  336. reason = f"{candidates} candidates were recorded but none was paneled"
  337. elif not findings and rounds and not any(map(panel_complete, rounds.values())):
  338. kind = "no-panel-completed"
  339. reason = (
  340. f"{len(rounds)} panel round(s) were dispatched but none completed a full "
  341. f"{PANEL_VOTER_COUNT}-voter review; no candidate was actually verified"
  342. )
  343. elif dropped_incomplete:
  344. kind = "candidate-panel-incomplete"
  345. reason = (
  346. f"{len(dropped_incomplete)} candidate(s) were dropped without a complete "
  347. f"{PANEL_VOTER_COUNT}-voter panel round: {', '.join(dropped_incomplete)}"
  348. )
  349. elif continuing:
  350. kind = "continuation-incomplete"
  351. reason = continuation_text(continuing)
  352. refusals = list(refused)
  353. if refusals:
  354. names = ", ".join(record["id"] for record in refusals)
  355. refusal_reason = (
  356. f"{len(refusals)} finding(s) were refused at render and are absent "
  357. f"from this report: {names}"
  358. )
  359. reason = f"{reason}; {refusal_reason}" if reason else refusal_reason
  360. kind = kind or "findings-refused"
  361. summary: VerificationSummary = {
  362. "status": "verified" if reason is None else "unverified",
  363. "candidates": candidates,
  364. "candidates_deduped": as_count("candidates_deduped"),
  365. "panel_votes": as_count("panel_votes"),
  366. "panel_reviewed_findings": len(reviewed),
  367. "panel_quorum_findings": quorum,
  368. "unreviewed_candidate_sites": as_count("unreviewed_candidate_sites"),
  369. "incomplete_panel_candidates": len(dropped_incomplete),
  370. "attested_findings": 0,
  371. "reason": reason,
  372. "reason_kind": kind,
  373. }
  374. if dispatched is not None:
  375. summary["researchers_dispatched"] = dispatched
  376. if returned is not None:
  377. summary["researchers_returned"] = returned
  378. if refusals:
  379. summary["refused_findings"] = refusals
  380. return summary
  381. def revision_tag(revision: object) -> str:
  382. """The stamp's filename tag: <sha12>[-dirty], or UNVERSIONED."""
  383. if not is_map(revision):
  384. msg = f"the run's revision {revision!r} is not an object, so it cannot name the stamp file"
  385. raise RenderError(msg)
  386. sha = revision.get("commit") or revision.get("head")
  387. if not sha:
  388. return "UNVERSIONED"
  389. if not is_str(sha) or not plugin.SHA_RE.match(sha):
  390. msg = f"the run's revision {sha!r} is not a hex commit id, so it cannot name the stamp file"
  391. raise RenderError(msg)
  392. return sha[:12] + ("" if revision.get("dirty") is False else "-dirty")
  393. def scan_of(meta: JsonMap) -> Meta:
  394. """The scan meta the run records, every field shape-checked; a wrong one is a refusal."""
  395. scan_id = meta.get("scan_id")
  396. try:
  397. value = uuid.UUID(scan_id) if is_str(scan_id) else None
  398. except ValueError:
  399. value = None
  400. if value is None or value.version is None or not 1 <= value.version <= 5:
  401. msg = (
  402. f"scan-meta.json scan_id {scan_id!r} is not a version 1-5 UUID; "
  403. "rerun write_scan_meta.py to mint one"
  404. )
  405. raise RenderError(msg)
  406. mode = meta.get("mode")
  407. if not is_str(mode) or mode not in plugin.MODES:
  408. msg = f"scan-meta.json mode {mode!r} is not a scan mode; rerun write_scan_meta.py"
  409. raise RenderError(msg)
  410. scan_root = meta.get("scan_root")
  411. if not is_str(scan_root) or not scan_root.strip():
  412. msg = f"scan-meta.json scan_root {scan_root!r} is not a path; rerun write_scan_meta.py"
  413. raise RenderError(msg)
  414. prefix = meta.get("scan_prefix")
  415. if prefix is None:
  416. prefix = ""
  417. if not is_str(prefix) or not scan_prefix_shaped(prefix):
  418. msg = (
  419. f"scan-meta.json scan_prefix {prefix!r} is not a path prefix; rerun write_scan_meta.py"
  420. )
  421. raise RenderError(msg)
  422. remote = meta.get("remote")
  423. if remote is not None and (not is_str(remote) or not SANITIZED_REMOTE_RE.match(remote)):
  424. msg = (
  425. f"scan-meta.json remote {remote!r} is not a sanitized repository URL; "
  426. "rerun write_scan_meta.py"
  427. )
  428. raise RenderError(msg)
  429. scope = meta.get("scope", [])
  430. entries = [entry for entry in scope if is_str(entry)] if is_list(scope) else []
  431. if not is_list(scope) or len(entries) != len(scope):
  432. msg = (
  433. f"scan-meta.json scope {scope!r} is not the list of paths the scan covered; "
  434. "rerun write_scan_meta.py"
  435. )
  436. raise RenderError(msg)
  437. revision: object = meta.get("revision")
  438. if revision is None:
  439. revision = {}
  440. revision_source = meta.get("revision_source", "self-reported")
  441. if not is_str(revision_source):
  442. msg = (
  443. f"scan-meta.json revision_source {revision_source!r} does not name what vouches for "
  444. "the revision; rerun write_scan_meta.py"
  445. )
  446. raise RenderError(msg)
  447. clean_commit: str | None = None
  448. if is_map(revision) and revision.get("dirty") is False:
  449. commit = revision.get("commit")
  450. clean_commit = commit if is_str(commit) and plugin.SHA_RE.match(commit) else None
  451. scan = sarif.Scan(
  452. id=value,
  453. mode=mode,
  454. prefix=prefix,
  455. remote=remote,
  456. scope=tuple(entries),
  457. revision=clean_commit,
  458. )
  459. return Meta(scan, scan_root, revision, revision_source, meta.get("model"), meta.get("effort"))
  460. def elapsed_seconds(started_at: object, now: datetime) -> int | None:
  461. """Whole seconds from scan-meta.json's started_at to now, floored at 0.
  462. None when started_at is absent, unparseable or timezone-naive.
  463. """
  464. if not is_str(started_at):
  465. return None
  466. try:
  467. started = datetime.fromisoformat(started_at)
  468. except ValueError:
  469. return None
  470. return max(int((now - started).total_seconds()), 0) if started.tzinfo else None
  471. def jsonl_text(findings: Sequence[Record]) -> str:
  472. """The findings as JSONL: one record per line as the products carry it, findings.json order."""
  473. return "".join(strictjson.text(secret.withheld(item)) + "\n" for item in findings)
  474. def strength(finding: Finding) -> tuple[int, int]:
  475. """A finding's rank among those at one site: severity first, then confidence."""
  476. return -SEVERITIES.index(finding["severity"]), CONFIDENCES.index(finding["confidence"])
  477. def one_per_site(findings: Sequence[Record], scan: sarif.Scan) -> tuple[list[Record], list[str]]:
  478. """The findings reduced to one per site, and one disclosure sentence per finding merged away.
  479. A site is a rule at a line of a file (sarif.site), which is what a result
  480. stands for to a SARIF or JSONL consumer, so the products carry one record
  481. for it: of the findings at one site the strongest is kept, the first of
  482. them in findings.json order when they tie, and each of the others is
  483. named in a sentence with the finding it was merged into. A finding with
  484. no site, one whose line was never determined, is kept as it is.
  485. """
  486. sites = [sarif.site(item, scan) for item in findings]
  487. by_site: dict[sarif.Site, list[Record]] = {}
  488. for item, where in zip(findings, sites):
  489. if where is not None:
  490. by_site.setdefault(where, []).append(item)
  491. kept = {where: max(group, key=strength) for where, group in by_site.items()}
  492. merged = [
  493. f"finding {other['id']} names the same site as finding {kept[where]['id']}, "
  494. f"{where.path}:{where.line} under rule {where.rule}; merged into it"
  495. for where, group in by_site.items()
  496. for other in group
  497. if other is not kept[where]
  498. ]
  499. unmerged = [
  500. item if where is None else kept[where]
  501. for item, where in zip(findings, sites)
  502. if where is None or item is by_site[where][0]
  503. ]
  504. return unmerged, merged
  505. def unrecognized_cwes(findings: Sequence[Finding]) -> list[str]:
  506. """One disclosure sentence per finding whose declared CWE the pinned release does not define."""
  507. return [
  508. f"finding {item['id']} cwe_id {item['cwe_id']} is not a weakness in "
  509. f"CWE {cwe.catalog.version}; filed as Uncategorized"
  510. for item in findings
  511. if not cwe.catalog.defines(cwe.id_number(item["cwe_id"]))
  512. ]
  513. def continuation_text(continuing: int) -> str:
  514. """The sentence the stamp's reason and the log's notification share for an unfinished chain."""
  515. return f"{continuing} candidate(s) were handed to a verification run that did not complete"
  516. def notifications_of(
  517. shape: Mapping[str, object],
  518. research: ResearchCoverage | None,
  519. verification: VerificationSummary,
  520. merged: Sequence[str],
  521. unrecognized: Sequence[str],
  522. symlinks: Sequence[str],
  523. revision: object,
  524. continuing: int,
  525. refused: Sequence[_RefusalRecord],
  526. ) -> list[dict[str, object]]:
  527. """The invocation notifications: skipped, capped, merged, mislabeled, refused or unverified.
  528. `research` is research_coverage's. The sentences of `merged` (one_per_site)
  529. are disclosed at level note, those
  530. of `unrecognized` (unrecognized_cwes) at level warning. `symlinks` names
  531. the root-level symbolic links the scan's extent left out unfollowed;
  532. `continuing` is handed_on(votes); `refused` is the render's per-finding
  533. path refusals, each disclosed at level warning.
  534. """
  535. note = sarif.notification
  536. skipped = shape.get("skipped_components")
  537. notes = [
  538. note("coverage/skipped-component", "note", f"Skipped component {s['name']}: {s['reason']}")
  539. for s in (skipped if is_list(skipped) else [])
  540. if is_map(s)
  541. ]
  542. if is_map(revision) and revision.get("sparse") is True:
  543. absent = revision.get("not_checked_out_dirs")
  544. names = ", ".join(d for d in (absent if is_list(absent) else []) if is_str(d))
  545. text = "Sparse checkout: only the checked-out part of the repository was scanned"
  546. if names:
  547. text += f"; tracked top-level directories not checked out: {names}"
  548. notes.append(note("coverage/sparse-checkout", "note", text))
  549. unaccounted = shape.get("unaccounted_top_level_dirs")
  550. if is_list(unaccounted) and unaccounted:
  551. names = ", ".join(d for d in unaccounted if is_str(d))
  552. text = f"Top-level directories the accepted partition left unaccounted: {names}"
  553. notes.append(note("coverage/unaccounted-top-level-dirs", "note", text))
  554. if symlinks:
  555. names = ", ".join(symlinks)
  556. text = f"Root-level symbolic links not followed, left out of the scan's extent: {names}"
  557. notes.append(note("coverage/unfollowed-symlinks", "note", text))
  558. if research and (research["not_reached"] or research["unaccounted"]):
  559. floor, ceiling = ("at least ", "at most ") if research["capped"] else ("", "")
  560. text = (
  561. f"Research coverage: of {research['files']} files in the components researched, "
  562. f"{floor}{research['read']} read to a conclusion, "
  563. f"{research['not_reached']} declared not reached, "
  564. f"{ceiling}{research['unaccounted']} in no researcher's account; "
  565. f"{research['outside_components']} more outside every component"
  566. )
  567. notes.append(note("coverage/files-not-reached", "note", text))
  568. if unreviewed := verification["unreviewed_candidate_sites"]:
  569. text = f"{unreviewed} candidate site(s) were recorded but never reviewed by the panel"
  570. notes.append(note("coverage/unverified-by-cap", "warning", text))
  571. if dropped := verification["incomplete_panel_candidates"]:
  572. text = (
  573. f"{dropped} candidate(s) were dropped without a complete "
  574. f"{PANEL_VOTER_COUNT}-voter panel round"
  575. )
  576. notes.append(note("verification/incomplete-panel", "warning", text))
  577. if continuing:
  578. text = continuation_text(continuing)
  579. notes.append(note("verification/continuation-incomplete", "warning", text))
  580. notes += [note("finding/merged", "note", text) for text in merged]
  581. notes += [note("cwe/unrecognized", "warning", text) for text in unrecognized]
  582. notes += [
  583. note(
  584. "verification/refused-finding",
  585. "warning",
  586. f"Finding {record['id']} was refused at render and is absent from "
  587. f"this report: {record['reason']}",
  588. )
  589. for record in refused
  590. ]
  591. if verification["status"] == "unverified":
  592. notes.append(note("verification/unverified", "error", verification["reason"] or ""))
  593. return notes
  594. def built_or_refused(
  595. raw: object,
  596. index: int,
  597. rounds_by_id: JsonMap,
  598. scan_root: str,
  599. scan_prefix: str,
  600. must_exist: bool,
  601. ) -> Finding | FindingPathError:
  602. """One finding carried, or the named path refusal that kept it out of the report."""
  603. try:
  604. return build_finding(raw, index, rounds_by_id, scan_root, scan_prefix, must_exist)
  605. except FindingPathError as error:
  606. return error
  607. def render(run_dir: Path, products_dir: Path) -> Rendered:
  608. """Read the run's records, validate them, build every product, then write them, stamp last."""
  609. meta = read_json(run_dir, "scan-meta.json")
  610. if not is_map(meta):
  611. raise RenderError("scan-meta.json must be a JSON object")
  612. findings_in = read_json(run_dir, "findings.json")
  613. if not is_list(findings_in):
  614. raise RenderError("findings.json must be a JSON array (use [] for no findings)")
  615. coverage, coverage_source = read_coverage(run_dir)
  616. votes_raw = read_votes(run_dir)
  617. votes: JsonMap = {} if votes_raw is None else votes_raw
  618. rounds_raw = votes.get("rounds")
  619. rounds_by_id: JsonMap = {}
  620. if rounds_raw is not None:
  621. if not is_map(rounds_raw):
  622. kind = type(rounds_raw).__name__
  623. msg = f"votes.json 'rounds' must be an object keyed by finding id, not {kind}"
  624. raise RenderError(msg)
  625. rounds_by_id = rounds_raw
  626. scan, scan_root, revision, revision_source, model, effort = scan_of(meta)
  627. tag = revision_tag(revision)
  628. outcomes = [
  629. built_or_refused(raw, i, rounds_by_id, scan_root, scan.prefix, scan.mode == "scan")
  630. for i, raw in enumerate(findings_in)
  631. ]
  632. built = [item for item in outcomes if not isinstance(item, FindingPathError)]
  633. path_errors = [item for item in outcomes if isinstance(item, FindingPathError)]
  634. refused: list[_RefusalRecord] = [
  635. {"id": item.finding_id, "reason": f"its file {item.wrong}"} for item in path_errors
  636. ]
  637. counted = Counter([f["id"] for f in built] + [record["id"] for record in refused])
  638. repeated = sorted(finding_id for finding_id, count in counted.items() if count > 1)
  639. if repeated:
  640. msg = f"findings.json uses these finding ids more than once: {', '.join(repeated)}"
  641. raise RenderError(msg)
  642. sources = {
  643. path: text
  644. for path in {f["file"] for f in built}
  645. if (text := source.read(scan_root, path)) is not None
  646. }
  647. refused_secrets = [e.snippet for e in path_errors if secret.is_credential_cwe(e.cwe)]
  648. records = sarif.placed(built, scan, sources, refused_secrets=refused_secrets)
  649. findings, merged = one_per_site(records, scan)
  650. markdown_path = run_dir / "CLAUDE-SECURITY-RESULTS.md"
  651. if not os.path.isfile(markdown_path):
  652. raise RenderError(
  653. "CLAUDE-SECURITY-RESULTS.md is missing. Write the human-readable "
  654. "report before running this script."
  655. )
  656. markdown = markdown_path.read_bytes()
  657. try:
  658. markdown.decode("utf-8")
  659. except UnicodeDecodeError as error:
  660. msg = f"CLAUDE-SECURITY-RESULTS.md is not valid UTF-8: {error}"
  661. raise RenderError(msg) from error
  662. counts = Counter(f["severity"] for f in findings)
  663. continuing = handed_on(votes)
  664. verification = verification_summary(
  665. findings,
  666. votes,
  667. votes_present=votes_raw is not None,
  668. continuing=continuing,
  669. refused=refused,
  670. )
  671. research = research_coverage(coverage.get("research") if coverage else None)
  672. shape = run_shape(coverage, coverage_source, effort, scan_root, research)
  673. generated = datetime.now(timezone.utc).replace(microsecond=0)
  674. stamp: dict[str, object] = {
  675. "generated_at": generated.isoformat(),
  676. "duration_s": elapsed_seconds(meta.get("started_at"), generated),
  677. "scan_id": str(scan.id),
  678. "mode": scan.mode,
  679. "scan_prefix": scan.prefix,
  680. "scope": list(scan.scope),
  681. "revision": revision,
  682. "revision_source": revision_source,
  683. "model": model,
  684. "effort": effort,
  685. "run_shape": shape,
  686. "findings": {
  687. "total": len(findings),
  688. "critical": counts["CRITICAL"],
  689. "high": counts["HIGH"],
  690. "medium": counts["MEDIUM"],
  691. "low": counts["LOW"],
  692. },
  693. "verification": verification,
  694. }
  695. jsonl = jsonl_text(findings)
  696. run_properties = {
  697. key: {k: v for k, v in value.items() if k not in STAMP_ONLY} if is_map(value) else value
  698. for key, value in stamp.items()
  699. if key not in STAMP_ONLY and (key != "model" or value is not None)
  700. }
  701. panels = {
  702. f["id"]: panel for f in findings if (panel := panel_complete(rounds_by_id.get(f["id"])))
  703. }
  704. unrecognized = unrecognized_cwes(findings)
  705. unfinished = [continuation_text(continuing)] if continuing else []
  706. refusal_lines = [f"refused {error.finding_id}: {error}" for error in path_errors]
  707. for text in merged + unrecognized + unfinished + refusal_lines:
  708. sys.stderr.write(f"render_report.py: {text}\n")
  709. symlinks = coverage_texts(meta.get("unfollowed_symlinks"), 200)
  710. notifications = notifications_of(
  711. shape, research, verification, merged, unrecognized, symlinks, revision, continuing, refused
  712. )
  713. sarif_log = sarif.log(findings, scan, plugin.version(), run_properties, panels, notifications)
  714. sarif_doc = strictjson.text(sarif_log, indent=2) + "\n"
  715. for stale in products_dir.iterdir():
  716. if stale.name.startswith(REVISION_PREFIX) and stale.suffix == ".json":
  717. stale.unlink()
  718. (products_dir / JSONL_NAME).write_bytes(jsonl.encode())
  719. (products_dir / SARIF_NAME).write_bytes(sarif_doc.encode())
  720. markdown_out = products_dir / "CLAUDE-SECURITY-RESULTS.md"
  721. # realpath, not Path.resolve(): on 3.9 for Windows resolve() raises on volumes realpath accepts.
  722. relocated = os.path.realpath(markdown_path) != os.path.realpath(markdown_out)
  723. if relocated:
  724. markdown_out.write_bytes(markdown)
  725. stamp_path = products_dir / f"{REVISION_PREFIX}{tag}.json"
  726. stamp_path.write_bytes((strictjson.text(stamp, indent=2) + "\n").encode())
  727. if relocated:
  728. markdown_path.unlink()
  729. return Rendered(findings, verification, tag)
  730. def remove_run_dir(run_dir: Path, products_dir: Path) -> str:
  731. """Remove the scan's run directory once rendered; returns a one-line status."""
  732. # abspath folds ".." without following symlinks, so the check below sees this path's own name.
  733. target = Path(os.path.abspath(run_dir))
  734. if target.name != plugin.RUN_DIR_NAME:
  735. return f"kept {run_dir} (not a {plugin.RUN_DIR_NAME} run directory)"
  736. if os.path.realpath(target) == os.path.realpath(products_dir):
  737. return f"kept {run_dir} (it holds the products)"
  738. try:
  739. shutil.rmtree(str(target))
  740. except OSError as error:
  741. detail = console.removal_failure_detail(error)
  742. return f"WARNING: could not remove run directory {run_dir}: {detail}"
  743. return f"removed run directory {run_dir}"
  744. def argument_parser() -> argparse.ArgumentParser:
  745. """The command line: which run directory to render, and where its products go."""
  746. parser = argparse.ArgumentParser(
  747. prog="render_report.py",
  748. description="Render a scan's machine-readable artifacts from its run directory.",
  749. allow_abbrev=False,
  750. )
  751. parser.add_argument("run_dir", help="the run directory holding the scan's records")
  752. parser.add_argument(
  753. "--products-dir", help="where the products are written (default: the run directory)"
  754. )
  755. return parser
  756. def main(argv: list[str]) -> int:
  757. parser = argument_parser()
  758. args = parser.parse_args(argv, namespace=Args())
  759. if not os.path.isdir(args.run_dir):
  760. parser.error(f"not a directory: {args.run_dir}")
  761. products = args.products_dir or args.run_dir
  762. if not os.path.isdir(products):
  763. parser.error(f"products directory is not a directory: {products}")
  764. run_dir, products_dir = Path(args.run_dir), Path(products)
  765. try:
  766. rendered = render(run_dir, products_dir)
  767. except (RenderError, FindingError) as error:
  768. sys.stderr.write(f"render_report.py: {error}\n")
  769. return 1
  770. except OSError as error:
  771. sys.stderr.write(f"render_report.py: could not read or write the report's files: {error}\n")
  772. return 1
  773. removal = remove_run_dir(run_dir, products_dir)
  774. count = len(rendered.findings)
  775. stamp_name = f"{REVISION_PREFIX}{rendered.tag}.json"
  776. print(
  777. f"wrote {JSONL_NAME}, {SARIF_NAME} ({count} finding{'' if count == 1 else 's'}) "
  778. f"and {stamp_name} into {products_dir}"
  779. )
  780. print(f"stamp: {stamp_name}")
  781. print(f"verification.status: {rendered.verification['status']}")
  782. if reason := rendered.verification["reason"]:
  783. print(f"verification.reason: {reason}")
  784. print(removal)
  785. return 0
  786. if __name__ == "__main__":
  787. console.tolerate_undecodable_names()
  788. sys.exit(main(sys.argv[1:]))