mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-07-24 01:43:28 +00:00
652 lines
24 KiB
Python
Executable File
652 lines
24 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Render a scan's machine-readable artifacts from its run directory.
|
|
|
|
Writes CLAUDE-SECURITY-RESULTS.jsonl (one finding per line, fields in a fixed
|
|
order) and the CLAUDE-SECURITY-REVISION-<tag>.json stamp, places the report
|
|
markdown beside them, then removes the scan's run directory now that its
|
|
records are rendered. Filenames, JSONL field order, and verification.status
|
|
semantics are stable across releases.
|
|
|
|
Usage: render_report.py <run-dir> [--products-dir <dir>]
|
|
Python 3.9-compatible, stdlib only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
from collections.abc import Mapping
|
|
from datetime import datetime, timezone
|
|
from typing import NoReturn, TypedDict, cast
|
|
|
|
JsonMap = Mapping[str, object]
|
|
Finding = dict[str, object]
|
|
|
|
|
|
class Panel(TypedDict, total=False):
|
|
"""A validated panel round: an int vote count and the fixed voter count."""
|
|
|
|
true: int
|
|
false: int
|
|
voters: int
|
|
|
|
|
|
class VerificationSummary(TypedDict, total=False):
|
|
"""The stamp's `verification` object; every path names why if not verified."""
|
|
|
|
status: str
|
|
candidates: int
|
|
candidates_deduped: int
|
|
panel_votes: int
|
|
panel_reviewed_findings: int
|
|
panel_quorum_findings: int
|
|
unreviewed_candidate_sites: object
|
|
attested_findings: int
|
|
reason: str | None
|
|
researchers_dispatched: int
|
|
researchers_returned: int
|
|
|
|
|
|
REPORT_FIELDS = (
|
|
"id",
|
|
"title",
|
|
"impact",
|
|
"file",
|
|
"line",
|
|
"description",
|
|
"exploit_scenario",
|
|
"preconditions",
|
|
"category",
|
|
"severity",
|
|
"confidence",
|
|
"recommendation",
|
|
"cwe_id",
|
|
"snippet",
|
|
"symbol",
|
|
)
|
|
|
|
SEPARATOR_ESCAPES = {0x85: "\\u0085", 0x2028: "\\u2028", 0x2029: "\\u2029"}
|
|
|
|
SEVERITIES = ("HIGH", "MEDIUM", "LOW")
|
|
CONFIDENCES = ("low", "medium", "high")
|
|
CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3}
|
|
|
|
PANEL_VOTER_COUNT = 3
|
|
PANEL_KEEP_QUORUM = 2
|
|
|
|
REVISION_PREFIX = "CLAUDE-SECURITY-REVISION-"
|
|
RUN_DIR_NAME = ".claude-security-run"
|
|
# \Z, not $: `$` also matches before a trailing newline, and this names a file.
|
|
HEX_RE = re.compile(r"^[0-9a-fA-F]{7,64}\Z")
|
|
FINDING_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\Z")
|
|
|
|
CATEGORY_ALIASES = {
|
|
"sqli": "sql-injection",
|
|
"sql injection": "sql-injection",
|
|
"rce": "command-injection",
|
|
"command execution": "command-injection",
|
|
"cmdi": "command-injection",
|
|
"xss": "xss",
|
|
"cross-site scripting": "xss",
|
|
"csrf": "csrf",
|
|
"cross-site request forgery": "csrf",
|
|
"ssrf": "ssrf",
|
|
"path traversal": "path-traversal",
|
|
"directory traversal": "path-traversal",
|
|
"idor": "idor",
|
|
"authz bypass": "improper-authorization",
|
|
"authn bypass": "auth-bypass",
|
|
"hardcoded credentials": "hardcoded-secret",
|
|
"hardcoded password": "hardcoded-secret",
|
|
"secret": "hardcoded-secret",
|
|
"weak cryptography": "weak-crypto",
|
|
"insecure randomness": "weak-randomness",
|
|
"uaf": "use-after-free",
|
|
"oob read": "out-of-bounds-read",
|
|
"oob write": "out-of-bounds-write",
|
|
"denial of service": "dos",
|
|
"prototype pollution": "prototype-pollution",
|
|
}
|
|
|
|
|
|
class RenderError(Exception):
|
|
"""A refusal; the message names what the caller must fix."""
|
|
|
|
|
|
def as_map(value: object) -> JsonMap | None:
|
|
"""The value as a str-keyed mapping, or None when it is not one."""
|
|
if isinstance(value, dict):
|
|
return cast("JsonMap", value)
|
|
return None
|
|
|
|
|
|
def die(message: str) -> NoReturn:
|
|
sys.stderr.write(f"render_report.py: {message}\n")
|
|
sys.exit(1)
|
|
|
|
|
|
def read_json(run_dir: str, name: str, required: bool = True) -> object:
|
|
path = os.path.join(run_dir, name)
|
|
try:
|
|
with open(path, encoding="utf-8") as handle:
|
|
return cast("object", json.load(handle))
|
|
except OSError as error:
|
|
if required:
|
|
msg = f"{name} is missing from the run directory. Write it before running this script."
|
|
raise RenderError(msg) from error
|
|
return None
|
|
except ValueError as error:
|
|
msg = f"{name} is not valid JSON: {error}"
|
|
raise RenderError(msg) from error
|
|
|
|
|
|
def normalize_category(raw: object) -> str:
|
|
"""Lowercase/slugify a category and fold known synonyms."""
|
|
text = str(raw or "").strip().lower()
|
|
if text in CATEGORY_ALIASES:
|
|
return CATEGORY_ALIASES[text]
|
|
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
|
return CATEGORY_ALIASES.get(slug, slug)
|
|
|
|
|
|
def confidence_value(raw: object) -> str:
|
|
"""A finding's stated confidence, normalized to low|medium|high; refuses others."""
|
|
if isinstance(raw, str):
|
|
word = raw.strip().lower()
|
|
if word in CONFIDENCE_RANK:
|
|
return word
|
|
msg = "confidence {!r} is not one of {}".format(raw, "/".join(CONFIDENCES))
|
|
raise RenderError(msg)
|
|
|
|
|
|
def panel_complete(record: object) -> Panel | None:
|
|
"""The validated panel dict for one round record, or None.
|
|
|
|
A complete panel has `voters` equal to PANEL_VOTER_COUNT and an integer
|
|
`true` vote count.
|
|
"""
|
|
round_record = as_map(record)
|
|
if round_record is None:
|
|
return None
|
|
panel = as_map(round_record.get("panel"))
|
|
if panel is None:
|
|
return None
|
|
panel_true = panel.get("true")
|
|
if not isinstance(panel_true, int) or isinstance(panel_true, bool):
|
|
return None
|
|
if panel.get("voters") != PANEL_VOTER_COUNT:
|
|
return None
|
|
panel_false = panel.get("false")
|
|
return {
|
|
"true": panel_true,
|
|
"false": panel_false if isinstance(panel_false, int) else 0,
|
|
"voters": PANEL_VOTER_COUNT,
|
|
}
|
|
|
|
|
|
def vote_confidence_ceiling(rounds: object) -> str | None:
|
|
"""The vote-backed confidence ceiling for one finding, or None.
|
|
|
|
A unanimous panel yields `high`; a keep quorum below unanimity yields
|
|
`medium`. None means no usable vote record.
|
|
"""
|
|
panel = panel_complete(rounds)
|
|
if panel is None:
|
|
return None
|
|
return "high" if panel.get("true", 0) >= PANEL_VOTER_COUNT else "medium"
|
|
|
|
|
|
def build_finding(raw: object, index: int, rounds_by_id: JsonMap) -> Finding:
|
|
"""Validate one finding into exactly REPORT_FIELDS, in order."""
|
|
item = as_map(raw)
|
|
if item is None:
|
|
msg = f"findings.json item {index} is not an object"
|
|
raise RenderError(msg)
|
|
finding_id = str(item.get("id") or f"F{index + 1}")
|
|
if not FINDING_ID_RE.match(finding_id):
|
|
msg = f"finding id {finding_id!r} is not a valid id"
|
|
raise RenderError(msg)
|
|
|
|
for required in ("title", "file", "description", "exploit_scenario"):
|
|
if not item.get(required):
|
|
msg = f"finding {finding_id} is missing required field {required!r}"
|
|
raise RenderError(msg)
|
|
|
|
severity = str(item.get("severity", "")).strip().upper()
|
|
if severity not in SEVERITIES:
|
|
msg = "finding {} severity {!r} is not one of {}".format(
|
|
finding_id, item.get("severity"), "/".join(SEVERITIES)
|
|
)
|
|
raise RenderError(msg)
|
|
|
|
confidence = confidence_value(item.get("confidence"))
|
|
ceiling = vote_confidence_ceiling(rounds_by_id.get(finding_id))
|
|
if ceiling is not None and CONFIDENCE_RANK[confidence] > CONFIDENCE_RANK[ceiling]:
|
|
confidence = ceiling
|
|
|
|
raw_line = item.get("line", 0)
|
|
try:
|
|
line = int(raw_line) if isinstance(raw_line, (int, float, str)) else int(str(raw_line))
|
|
except (TypeError, ValueError, OverflowError) as error:
|
|
msg = "finding {} line {!r} is not an integer".format(finding_id, item.get("line"))
|
|
raise RenderError(msg) from error
|
|
|
|
preconditions_raw: object = item.get("preconditions") or []
|
|
if not isinstance(preconditions_raw, list):
|
|
msg = f"finding {finding_id} preconditions must be a list"
|
|
raise RenderError(msg)
|
|
|
|
cwe = item.get("cwe_id")
|
|
if cwe:
|
|
text = str(cwe).strip().upper().replace("_", "-")
|
|
if re.match(r"^\d{1,5}$", text):
|
|
text = "CWE-" + text
|
|
cwe = text if re.match(r"^CWE-\d{1,5}$", text) else None
|
|
else:
|
|
cwe = None
|
|
|
|
finding = {
|
|
"id": finding_id,
|
|
"title": item.get("title"),
|
|
"impact": item.get("impact") or "",
|
|
"file": item.get("file"),
|
|
"line": line,
|
|
"description": item.get("description"),
|
|
"exploit_scenario": item.get("exploit_scenario"),
|
|
"preconditions": [str(p) for p in cast("list[object]", preconditions_raw)],
|
|
"category": normalize_category(item.get("category")),
|
|
"severity": severity,
|
|
"confidence": confidence,
|
|
"recommendation": item.get("recommendation") or "",
|
|
"cwe_id": cwe,
|
|
"snippet": item.get("snippet") or "",
|
|
"symbol": item.get("symbol") or "",
|
|
}
|
|
return {k: finding[k] for k in REPORT_FIELDS}
|
|
|
|
|
|
def read_coverage(run_dir: str) -> tuple[JsonMap | None, str]:
|
|
"""The optional coverage.json for the informational run_shape field.
|
|
|
|
Returns (map_or_None, source): source is "coverage.json" when the file
|
|
is a usable object, "unavailable" when it is absent, and "unreadable" when
|
|
it exists but is not a usable object.
|
|
"""
|
|
name = "coverage.json"
|
|
try:
|
|
raw = read_json(run_dir, name, required=False)
|
|
except RenderError:
|
|
return None, "unreadable"
|
|
if raw is None:
|
|
present = os.path.exists(os.path.join(run_dir, name))
|
|
return None, ("unreadable" if present else "unavailable")
|
|
cov = as_map(raw)
|
|
if cov is None:
|
|
return None, "unreadable"
|
|
return cov, name
|
|
|
|
|
|
COVERAGE_TEXT_CAP = 300
|
|
|
|
|
|
def coverage_text(value: object, cap: int = COVERAGE_TEXT_CAP) -> str | None:
|
|
"""A coverage string, trimmed to `cap`, or None when the value is not a string."""
|
|
if not isinstance(value, str):
|
|
return None
|
|
if len(value) > cap:
|
|
return value[:cap] + f"...[+{len(value) - cap} chars]"
|
|
return value
|
|
|
|
|
|
def skipped_components(raw: object) -> list[dict[str, object]] | None:
|
|
"""coverage.skippedComponents as [{name, paths, reason}], or None when unusable."""
|
|
if not isinstance(raw, list):
|
|
return None
|
|
out: list[dict[str, object]] = []
|
|
for entry in cast("list[object]", raw):
|
|
item = as_map(entry)
|
|
if item is None:
|
|
continue
|
|
paths_raw = item.get("paths")
|
|
paths_in: list[object] = (
|
|
cast("list[object]", paths_raw) if isinstance(paths_raw, list) else []
|
|
)
|
|
paths = [text for text in (coverage_text(p, 200) for p in paths_in) if text]
|
|
out.append({
|
|
"name": coverage_text(item.get("name"), 100) or "",
|
|
"paths": paths,
|
|
"reason": coverage_text(item.get("reason")) or "",
|
|
})
|
|
return out
|
|
|
|
|
|
def coverage_enum(value: object, allowed: tuple[str, ...]) -> str | None:
|
|
"""A coverage enum field, or None when absent or not one of the known values."""
|
|
return value if isinstance(value, str) and value in allowed else None
|
|
|
|
|
|
def run_shape(coverage: JsonMap | None, source: str, effort: object) -> dict[str, object]:
|
|
"""What shape actually ran, distinct from the effort tier that was asked."""
|
|
shape: dict[str, object] = {"requested_effort": effort, "collapsed": None, "source": source}
|
|
if coverage is None:
|
|
return shape
|
|
shape["collapsed"] = coverage.get("collapsed")
|
|
shape["diff_files"] = coverage.get("diffFiles")
|
|
shape["diff_lines"] = coverage.get("diffLines")
|
|
shape["scope_files"] = coverage.get("scopeFiles")
|
|
shape["empty_diff"] = bool(coverage.get("emptyDiff"))
|
|
shape["empty_scope"] = bool(coverage.get("emptyScope"))
|
|
shape["researchers_dispatched"] = coverage.get("researchersDispatched")
|
|
shape["skipped_components"] = skipped_components(coverage.get("skippedComponents"))
|
|
shape["completeness_check_outcome"] = coverage_enum(
|
|
coverage.get("completenessCheckOutcome"),
|
|
("checked", "partial", "not-checkable", "not-applicable"),
|
|
)
|
|
unaccounted_raw = coverage.get("unaccountedTopLevelDirs")
|
|
unaccounted_in: list[object] = (
|
|
cast("list[object]", unaccounted_raw) if isinstance(unaccounted_raw, list) else []
|
|
)
|
|
shape["unaccounted_top_level_dirs"] = [
|
|
text for text in (coverage_text(x, 200) for x in unaccounted_in) if text
|
|
]
|
|
shape["inventory_fallback"] = coverage_enum(
|
|
coverage.get("inventoryFallback"),
|
|
("inventory-failed", "empty-partition", "incomplete-partition"),
|
|
)
|
|
top_count = coverage.get("topLevelCount")
|
|
shape["top_level_dir_count"] = (
|
|
top_count if isinstance(top_count, int) and not isinstance(top_count, bool) else None
|
|
)
|
|
return shape
|
|
|
|
|
|
def verification_summary(
|
|
findings: list[Finding],
|
|
votes: JsonMap,
|
|
votes_present: bool = True,
|
|
) -> VerificationSummary:
|
|
"""Compute the stamp's verification object from the vote record.
|
|
|
|
status is 'verified' only when the vote record proves the panel ran for
|
|
every finding the report contains; otherwise 'unverified' with a `reason`.
|
|
votes_present is False when votes.json was absent from the run directory.
|
|
"""
|
|
rounds = as_map(votes.get("rounds")) or {}
|
|
panel_reviewed = 0
|
|
panel_quorum = 0
|
|
incomplete: list[str] = []
|
|
|
|
for finding in findings:
|
|
finding_id = str(finding.get("id", ""))
|
|
panel = panel_complete(rounds.get(finding_id))
|
|
if panel is None:
|
|
incomplete.append(finding_id)
|
|
continue
|
|
panel_reviewed += 1
|
|
if panel.get("true", 0) >= PANEL_KEEP_QUORUM:
|
|
panel_quorum += 1
|
|
|
|
def as_count(key: str) -> int:
|
|
"""A vote count as a non-negative int; a wrong shape is a refusal."""
|
|
value = votes.get(key, 0)
|
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
msg = (
|
|
f"votes.json field {key!r} is not a non-negative integer ({value!r}); the "
|
|
"vote record is malformed"
|
|
)
|
|
raise RenderError(msg)
|
|
return value
|
|
|
|
def optional_count(key: str) -> int | None:
|
|
"""A count that may be absent: None when so, else as_count's contract."""
|
|
if key not in votes:
|
|
return None
|
|
return as_count(key)
|
|
|
|
candidates_recorded = "candidates" in votes
|
|
researchers_dispatched = optional_count("researchers_dispatched")
|
|
researchers_returned = optional_count("researchers_returned")
|
|
|
|
summary: dict[str, object] = {
|
|
"status": "verified",
|
|
"candidates": as_count("candidates"),
|
|
"candidates_deduped": as_count("candidates_deduped"),
|
|
"panel_votes": as_count("panel_votes"),
|
|
"panel_reviewed_findings": panel_reviewed,
|
|
"panel_quorum_findings": panel_quorum,
|
|
"unreviewed_candidate_sites": as_count("unreviewed_candidate_sites"),
|
|
"attested_findings": 0,
|
|
"reason": None,
|
|
}
|
|
if researchers_dispatched is not None:
|
|
summary["researchers_dispatched"] = researchers_dispatched
|
|
if researchers_returned is not None:
|
|
summary["researchers_returned"] = researchers_returned
|
|
|
|
reportable: list[Finding] = findings
|
|
if not votes_present:
|
|
summary["status"] = "unverified"
|
|
summary["reason"] = (
|
|
"votes.json is absent from the run directory: the verification "
|
|
"pipeline left no vote record, so nothing about this report can be "
|
|
"attested"
|
|
)
|
|
elif not candidates_recorded:
|
|
summary["status"] = "unverified"
|
|
summary["reason"] = (
|
|
"votes.json has no 'candidates' field: the vote record does not "
|
|
"prove the pipeline ran, so nothing about this report can be attested"
|
|
)
|
|
elif researchers_dispatched and researchers_returned == 0:
|
|
summary["status"] = "unverified"
|
|
summary["reason"] = (
|
|
f"{researchers_dispatched} research agent(s) were dispatched but none returned; "
|
|
"the scan examined nothing"
|
|
)
|
|
elif incomplete:
|
|
summary["status"] = "unverified"
|
|
summary["reason"] = (
|
|
f"these findings have no complete {PANEL_VOTER_COUNT}-voter panel round: "
|
|
f"{', '.join(sorted(incomplete))}"
|
|
)
|
|
elif reportable and panel_quorum != len(reportable):
|
|
summary["status"] = "unverified"
|
|
summary["reason"] = (
|
|
f"{len(reportable) - panel_quorum} of {len(reportable)} reported findings did not "
|
|
"reach the keep quorum, so the report contains findings the panel rejected"
|
|
)
|
|
elif not findings and not votes.get("rounds") and summary["candidates"]:
|
|
summary["status"] = "unverified"
|
|
summary["reason"] = f"{summary['candidates']} candidates were recorded but none was paneled"
|
|
elif not findings and rounds and not any(panel_complete(record) for record in rounds.values()):
|
|
summary["status"] = "unverified"
|
|
summary["reason"] = (
|
|
f"{len(rounds)} panel round(s) were dispatched but none completed a full "
|
|
f"{PANEL_VOTER_COUNT}-voter review; no candidate was actually verified"
|
|
)
|
|
return cast("VerificationSummary", cast("object", summary))
|
|
|
|
|
|
def revision_tag(revision: object) -> str:
|
|
"""The stamp's filename tag: <sha12>[-dirty], or UNVERSIONED."""
|
|
rev = as_map(revision) or {}
|
|
sha = rev.get("commit") or rev.get("head")
|
|
if not sha:
|
|
return "UNVERSIONED"
|
|
if not (isinstance(sha, str) and HEX_RE.match(sha)):
|
|
msg = f"the run's revision {sha!r} is not a hex commit id, so it cannot name the stamp file"
|
|
raise RenderError(msg)
|
|
return sha[:12] + ("" if rev.get("dirty") is False else "-dirty")
|
|
|
|
|
|
def atomic_write(path: str, text: str) -> None:
|
|
"""Write `text` atomically: a temp file in the same directory, then replace."""
|
|
directory = os.path.dirname(path)
|
|
handle, temp = tempfile.mkstemp(dir=directory, prefix=".render.")
|
|
try:
|
|
with os.fdopen(handle, "w", encoding="utf-8") as out:
|
|
out.write(text)
|
|
out.flush()
|
|
os.fsync(out.fileno())
|
|
os.replace(temp, path)
|
|
except BaseException:
|
|
with contextlib.suppress(OSError):
|
|
os.unlink(temp)
|
|
raise
|
|
|
|
|
|
def jsonl_line(finding: Finding) -> str:
|
|
"""One finding, fixed field order, separators escaped."""
|
|
text = json.dumps(finding, ensure_ascii=False, sort_keys=False)
|
|
return text.translate(SEPARATOR_ESCAPES)
|
|
|
|
|
|
def render(run_dir: str, products_dir: str) -> tuple[list[Finding], VerificationSummary, str]:
|
|
meta_raw = read_json(run_dir, "scan-meta.json")
|
|
findings_raw = read_json(run_dir, "findings.json")
|
|
votes: object = read_json(run_dir, "votes.json", required=False)
|
|
coverage, coverage_source = read_coverage(run_dir)
|
|
votes_present = votes is not None
|
|
if votes is None:
|
|
votes = {}
|
|
|
|
if not isinstance(findings_raw, list):
|
|
raise RenderError("findings.json must be a JSON array (use [] for no findings)")
|
|
meta = as_map(meta_raw)
|
|
if meta is None:
|
|
raise RenderError("scan-meta.json must be a JSON object")
|
|
votes_map = as_map(votes)
|
|
if votes_map is None:
|
|
raise RenderError("votes.json must be a JSON object mapping the vote record")
|
|
rounds_raw = votes_map.get("rounds")
|
|
rounds_by_id: JsonMap = {} if rounds_raw is None else (as_map(rounds_raw) or {})
|
|
if rounds_raw is not None and not isinstance(rounds_raw, dict):
|
|
kind = type(rounds_raw).__name__
|
|
msg = f"votes.json 'rounds' must be an object keyed by finding id, not {kind}"
|
|
raise RenderError(msg)
|
|
findings = [
|
|
build_finding(raw, i, rounds_by_id)
|
|
for i, raw in enumerate(cast("list[object]", findings_raw))
|
|
]
|
|
|
|
seen = {}
|
|
for finding in findings:
|
|
if finding["id"] in seen:
|
|
msg = "finding id {!r} appears twice in findings.json".format(finding["id"])
|
|
raise RenderError(msg)
|
|
seen[finding["id"]] = True
|
|
|
|
markdown_path = os.path.join(run_dir, "CLAUDE-SECURITY-RESULTS.md")
|
|
if not os.path.isfile(markdown_path):
|
|
raise RenderError(
|
|
"CLAUDE-SECURITY-RESULTS.md is missing. Write the human-readable "
|
|
"report before running this script."
|
|
)
|
|
with open(markdown_path, encoding="utf-8", newline="") as handle:
|
|
markdown = handle.read()
|
|
|
|
counts: dict[str, int] = dict.fromkeys(SEVERITIES, 0)
|
|
for finding in findings:
|
|
counts[str(finding.get("severity", ""))] += 1
|
|
|
|
verification = verification_summary(findings, votes_map, votes_present=votes_present)
|
|
revision: object = meta.get("revision") or {}
|
|
tag = revision_tag(revision)
|
|
|
|
atomic_write(
|
|
os.path.join(products_dir, "CLAUDE-SECURITY-RESULTS.jsonl"),
|
|
"".join(jsonl_line(f) + "\n" for f in findings),
|
|
)
|
|
markdown_out = os.path.join(products_dir, "CLAUDE-SECURITY-RESULTS.md")
|
|
if os.path.realpath(markdown_path) != os.path.realpath(markdown_out):
|
|
atomic_write(markdown_out, markdown)
|
|
os.unlink(markdown_path)
|
|
|
|
stamp: dict[str, object] = {
|
|
"generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
|
"scan_root": meta.get("scan_root"),
|
|
"products_dir": products_dir,
|
|
"mode": meta.get("mode"),
|
|
"scope": meta.get("scope") or [],
|
|
"revision": revision,
|
|
"revision_source": meta.get("revision_source") or "self-reported",
|
|
"model": meta.get("model"),
|
|
"effort": meta.get("effort"),
|
|
"run_shape": run_shape(coverage, coverage_source, meta.get("effort")),
|
|
"findings": {
|
|
"total": len(findings),
|
|
"high": counts["HIGH"],
|
|
"medium": counts["MEDIUM"],
|
|
"low": counts["LOW"],
|
|
},
|
|
"verification": verification,
|
|
}
|
|
for stale in os.listdir(products_dir):
|
|
if stale.startswith(REVISION_PREFIX) and stale.endswith(".json"):
|
|
os.unlink(os.path.join(products_dir, stale))
|
|
atomic_write(
|
|
os.path.join(products_dir, f"{REVISION_PREFIX}{tag}.json"),
|
|
json.dumps(stamp, indent=2) + "\n",
|
|
)
|
|
|
|
return findings, verification, tag
|
|
|
|
|
|
def remove_run_dir(run_dir: str, products_dir: str) -> str:
|
|
"""Remove the scan's run directory once rendered; returns a one-line status."""
|
|
target = os.path.normpath(os.path.abspath(run_dir))
|
|
if os.path.basename(target) != RUN_DIR_NAME:
|
|
return f"kept {run_dir} (not a {RUN_DIR_NAME} run directory)"
|
|
if os.path.realpath(target) == os.path.realpath(products_dir):
|
|
return f"kept {run_dir} (it holds the products)"
|
|
try:
|
|
shutil.rmtree(target)
|
|
except OSError as error:
|
|
detail = error.args[0] if error.args else error
|
|
return f"WARNING: could not remove run directory {run_dir}: {detail}"
|
|
return f"removed run directory {run_dir}"
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
products_dir: str | None = None
|
|
args = list(argv)
|
|
if len(args) == 3 and args[1] == "--products-dir":
|
|
products_dir = args.pop(2)
|
|
args.pop(1)
|
|
if len(args) != 1:
|
|
die("usage: render_report.py <run-dir> [--products-dir <dir>]")
|
|
run_dir = args[0]
|
|
if not os.path.isdir(run_dir):
|
|
die(f"not a directory: {run_dir}")
|
|
products_dir = products_dir or run_dir
|
|
if not os.path.isdir(products_dir):
|
|
die(f"products directory is not a directory: {products_dir}")
|
|
try:
|
|
findings, verification, tag = render(run_dir, products_dir)
|
|
except RenderError as error:
|
|
die(str(error))
|
|
except OSError as error:
|
|
die(f"could not read or write the report's files: {error}")
|
|
removal = remove_run_dir(run_dir, products_dir)
|
|
print(
|
|
f"wrote CLAUDE-SECURITY-RESULTS.jsonl ({len(findings)} finding"
|
|
f"{'' if len(findings) == 1 else 's'}) and {REVISION_PREFIX}{tag}.json "
|
|
f"into {products_dir}"
|
|
)
|
|
print(f"stamp: {REVISION_PREFIX}{tag}.json")
|
|
print(f"verification.status: {verification.get('status')}")
|
|
reason = verification.get("reason")
|
|
if reason:
|
|
print(f"verification.reason: {reason}")
|
|
print(removal)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|