render_report.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.10"
  4. # ///
  5. """Render the analysis report deterministically from findings JSON.
  6. Injects a validated findings JSON object into the report shell's
  7. report-data island and writes the self-contained HTML atomically.
  8. With --md, also writes a markdown rendering of the same data as the
  9. archival artifact.
  10. Refuses (non-zero exit, message on stderr) when the JSON does not
  11. parse, fails shape validation, or still carries the shell's
  12. placeholder subject — a refused render means fix the findings file
  13. and re-run, never hand-edit the HTML.
  14. Usage:
  15. uv run render_report.py <findings.json> --shell <report-shell.html> \
  16. -o <out.html> [--md <out.md>]
  17. On success prints one JSON line: output paths, grade, and severity
  18. counts derived from the findings array.
  19. """
  20. from __future__ import annotations
  21. import argparse
  22. import json
  23. import os
  24. import re
  25. import sys
  26. import tempfile
  27. from pathlib import Path
  28. SEVERITIES = ("critical", "high", "medium", "low")
  29. GRADES = ("excellent", "good", "fair", "poor")
  30. PLACEHOLDER_SUBJECT = "__PLACEHOLDER__"
  31. ISLAND_RE = re.compile(
  32. r'(<script[^>]*\bid="report-data"[^>]*>)(.*?)(</script>)', re.DOTALL
  33. )
  34. def fail(message: str) -> None:
  35. print(f"render_report: {message}", file=sys.stderr)
  36. sys.exit(1)
  37. def validate(data: object) -> list[str]:
  38. """Return a list of shape errors; empty list means valid."""
  39. if not isinstance(data, dict):
  40. return ["top level must be a JSON object"]
  41. errors: list[str] = []
  42. subject = data.get("subject")
  43. if not isinstance(subject, str) or not subject.strip():
  44. errors.append('"subject" must be a non-empty string')
  45. elif PLACEHOLDER_SUBJECT in subject:
  46. errors.append(
  47. f'"subject" still carries the placeholder {PLACEHOLDER_SUBJECT}; '
  48. "this is the unfilled shell sample, not real findings"
  49. )
  50. findings = data.get("findings")
  51. if not isinstance(findings, list):
  52. errors.append('"findings" must be an array (use [] for a clean pass)')
  53. else:
  54. for i, finding in enumerate(findings):
  55. if not isinstance(finding, dict):
  56. errors.append(f"findings[{i}] must be an object")
  57. grade = data.get("grade")
  58. if grade is not None and grade not in GRADES:
  59. errors.append(f'"grade" must be one of: {", ".join(GRADES)}')
  60. for key in ("themes", "recommendations"):
  61. value = data.get(key)
  62. if value is not None and (
  63. not isinstance(value, list)
  64. or any(not isinstance(item, dict) for item in value)
  65. ):
  66. errors.append(f'"{key}" must be an array of objects')
  67. strengths = data.get("strengths")
  68. if strengths is not None and (
  69. not isinstance(strengths, list)
  70. or any(not isinstance(item, str) for item in strengths)
  71. ):
  72. errors.append('"strengths" must be an array of strings')
  73. return errors
  74. def severity_counts(findings: list[dict]) -> dict[str, int]:
  75. counts = {sev: 0 for sev in SEVERITIES}
  76. for finding in findings:
  77. sev = finding.get("severity")
  78. counts[sev if sev in counts else "low"] += 1
  79. return counts
  80. def inject(shell_html: str, data: dict) -> str:
  81. payload = json.dumps(data, ensure_ascii=False, indent=2)
  82. # A "</" sequence inside a JSON string would close the script tag
  83. # early in the browser; "<\/" is the same string to JSON.parse.
  84. payload = payload.replace("</", "<\\/")
  85. def replace(match: re.Match) -> str:
  86. return match.group(1) + "\n" + payload + "\n" + match.group(3)
  87. new_html, count = ISLAND_RE.subn(replace, shell_html, count=1)
  88. if count != 1:
  89. fail('shell has no <script id="report-data"> island to fill')
  90. return new_html
  91. def atomic_write(path: Path, text: str) -> None:
  92. path.parent.mkdir(parents=True, exist_ok=True)
  93. fd, tmp = tempfile.mkstemp(
  94. dir=path.parent, prefix=path.name + ".", suffix=".tmp"
  95. )
  96. try:
  97. with os.fdopen(fd, "w", encoding="utf-8") as handle:
  98. handle.write(text)
  99. handle.flush()
  100. os.fsync(handle.fileno())
  101. os.replace(tmp, path)
  102. except BaseException:
  103. try:
  104. os.unlink(tmp)
  105. except OSError:
  106. pass
  107. raise
  108. def _finding_lines(finding: dict, heading_level: str) -> list[str]:
  109. fid = str(finding.get("id", ""))
  110. title = str(finding.get("title", "(untitled finding)"))
  111. lines = [f"{heading_level} {fid} — {title}" if fid else f"{heading_level} {title}", ""]
  112. for key, label in (
  113. ("lens", "Lens"),
  114. ("location", "Location"),
  115. ("evidence", "Evidence"),
  116. ("recommendation", "Recommendation"),
  117. ("proposed_smallest", "Proposed smallest"),
  118. ("predicted_delta", "Predicted delta"),
  119. ):
  120. value = finding.get(key)
  121. if value:
  122. value = f"`{value}`" if key == "location" else str(value)
  123. lines.append(f"- {label}: {value}")
  124. lines.append("")
  125. return lines
  126. def render_md(data: dict) -> str:
  127. findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
  128. by_id = {str(f.get("id")): f for f in findings if f.get("id") is not None}
  129. counts = severity_counts(findings)
  130. lines: list[str] = []
  131. lines.append(f"# Analysis Report: {data.get('subject', '')}")
  132. lines.append("")
  133. meta = []
  134. if data.get("generated"):
  135. meta.append(f"Generated: {data['generated']}")
  136. if data.get("schema_version") is not None:
  137. meta.append(f"Schema: {data['schema_version']}")
  138. if meta:
  139. lines.append(" · ".join(meta))
  140. lines.append("")
  141. if data.get("grade"):
  142. lines.append(f"**Grade: {str(data['grade']).capitalize()}**")
  143. lines.append("")
  144. if data.get("verdict"):
  145. lines.append(f"> {data['verdict']}")
  146. lines.append("")
  147. summary = data.get("summary")
  148. if isinstance(summary, str) and summary:
  149. lines.append(summary)
  150. lines.append("")
  151. lines.append("| Severity | Count |")
  152. lines.append("| --- | --- |")
  153. for sev in SEVERITIES:
  154. lines.append(f"| {sev.capitalize()} | {counts[sev]} |")
  155. lines.append("")
  156. themes = data.get("themes") or []
  157. if themes:
  158. lines.append("## Themes")
  159. lines.append("")
  160. for i, theme in enumerate(themes, 1):
  161. lines.append(f"### {i}. {theme.get('title', '(untitled theme)')}")
  162. lines.append("")
  163. if theme.get("root_cause"):
  164. lines.append(f"- Root cause: {theme['root_cause']}")
  165. if theme.get("action"):
  166. lines.append(f"- Fix: {theme['action']}")
  167. ids = theme.get("finding_ids") or []
  168. if ids:
  169. lines.append("- Findings:")
  170. for fid in ids:
  171. finding = by_id.get(str(fid))
  172. if finding:
  173. loc = finding.get("location")
  174. suffix = f" — `{loc}`" if loc else ""
  175. lines.append(
  176. f" - `{fid}` {finding.get('title', '')}{suffix}"
  177. )
  178. else:
  179. lines.append(f" - `{fid}`")
  180. lines.append("")
  181. strengths = data.get("strengths") or []
  182. if strengths:
  183. lines.append("## Strengths")
  184. lines.append("")
  185. for strength in strengths:
  186. lines.append(f"- {strength}")
  187. lines.append("")
  188. recommendations = data.get("recommendations") or []
  189. if recommendations:
  190. lines.append("## Recommendations")
  191. lines.append("")
  192. for i, rec in enumerate(recommendations, 1):
  193. rank = rec.get("rank", i)
  194. resolves = rec.get("resolves")
  195. if isinstance(resolves, list) and resolves:
  196. suffix = " (resolves: " + ", ".join(map(str, resolves)) + ")"
  197. elif isinstance(resolves, (int, float)):
  198. suffix = f" (resolves {int(resolves)} findings)"
  199. else:
  200. suffix = ""
  201. lines.append(f"{rank}. {rec.get('action', '')}{suffix}")
  202. lines.append("")
  203. # Optional agent blocks: rendered only when present so the same
  204. # renderer serves both the workflow and agent schemas.
  205. profile = data.get("agent_profile")
  206. if isinstance(profile, dict) and any(profile.values()):
  207. lines.append("## Agent Profile")
  208. lines.append("")
  209. for key, label in (
  210. ("name", "Name"),
  211. ("title", "Title"),
  212. ("agent_type", "Type"),
  213. ("mission", "Mission"),
  214. ):
  215. if profile.get(key):
  216. lines.append(f"- {label}: {profile[key]}")
  217. lines.append("")
  218. capabilities = data.get("capabilities")
  219. if isinstance(capabilities, list) and capabilities:
  220. lines.append("## Capabilities")
  221. lines.append("")
  222. for cap in capabilities:
  223. if not isinstance(cap, dict) or not cap.get("name"):
  224. continue
  225. kind = f" ({cap['kind']})" if cap.get("kind") else ""
  226. note = f" — {cap['note']}" if cap.get("note") else ""
  227. lines.append(f"- **{cap['name']}**{kind}{note}")
  228. lines.append("")
  229. detailed = data.get("detailed_analysis")
  230. if isinstance(detailed, dict) and detailed:
  231. lines.append("## Per-Lens Verdicts")
  232. lines.append("")
  233. for lens, verdict in detailed.items():
  234. if verdict:
  235. lines.append(f"- **{lens}**: {verdict}")
  236. lines.append("")
  237. sanctum = data.get("sanctum")
  238. if isinstance(sanctum, dict) and sanctum.get("present") is not False:
  239. rows = []
  240. if sanctum.get("location"):
  241. rows.append(f"- Location: `{sanctum['location']}`")
  242. files = sanctum.get("files") or []
  243. if files:
  244. rows.append("- Files: " + ", ".join(f"`{f}`" for f in files))
  245. if sanctum.get("note"):
  246. rows.append(f"- Note: {sanctum['note']}")
  247. if rows:
  248. lines.append("## Sanctum (runtime memory)")
  249. lines.append("")
  250. lines.extend(rows)
  251. lines.append("")
  252. experience = data.get("experience")
  253. if isinstance(experience, dict):
  254. journeys = [
  255. j for j in experience.get("journeys") or [] if isinstance(j, dict)
  256. ]
  257. headless = experience.get("headless")
  258. if journeys or headless:
  259. lines.append("## Experience")
  260. lines.append("")
  261. for journey in journeys:
  262. steps = f" — {journey['steps']}" if journey.get("steps") else ""
  263. lines.append(f"- **{journey.get('name', '(unnamed journey)')}**{steps}")
  264. if headless:
  265. lines.append(f"- Headless: {headless}")
  266. lines.append("")
  267. lines.append("## Findings")
  268. lines.append("")
  269. if not findings:
  270. lines.append("No findings: the scanners returned a clean pass.")
  271. lines.append("")
  272. else:
  273. for sev in SEVERITIES:
  274. group = [
  275. f
  276. for f in findings
  277. if (f.get("severity") if f.get("severity") in SEVERITIES else "low")
  278. == sev
  279. ]
  280. if not group:
  281. continue
  282. lines.append(f"### {sev.capitalize()} ({len(group)})")
  283. lines.append("")
  284. for finding in group:
  285. lines.extend(_finding_lines(finding, "####"))
  286. return "\n".join(lines).rstrip() + "\n"
  287. def main() -> int:
  288. parser = argparse.ArgumentParser(
  289. description="Inject findings JSON into the report shell and render HTML (+ optional markdown)."
  290. )
  291. parser.add_argument("findings", type=Path, help="path to findings.json")
  292. parser.add_argument(
  293. "--shell", type=Path, required=True, help="path to report-shell.html"
  294. )
  295. parser.add_argument(
  296. "-o", "--output", type=Path, required=True, help="output HTML path"
  297. )
  298. parser.add_argument(
  299. "--md", type=Path, help="also write a markdown rendering to this path"
  300. )
  301. args = parser.parse_args()
  302. try:
  303. raw = args.findings.read_text(encoding="utf-8")
  304. except OSError as err:
  305. fail(f"cannot read {args.findings}: {err}")
  306. try:
  307. data = json.loads(raw)
  308. except json.JSONDecodeError as err:
  309. fail(f"{args.findings} is not valid JSON: {err}")
  310. errors = validate(data)
  311. if errors:
  312. fail(
  313. f"{args.findings} failed shape validation:\n - "
  314. + "\n - ".join(errors)
  315. )
  316. try:
  317. shell_html = args.shell.read_text(encoding="utf-8")
  318. except OSError as err:
  319. fail(f"cannot read shell {args.shell}: {err}")
  320. atomic_write(args.output, inject(shell_html, data))
  321. if args.md:
  322. atomic_write(args.md, render_md(data))
  323. findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
  324. print(
  325. json.dumps(
  326. {
  327. "html_report": str(args.output),
  328. "md_report": str(args.md) if args.md else None,
  329. "grade": data.get("grade"),
  330. "counts": severity_counts(findings),
  331. "findings": len(findings),
  332. }
  333. )
  334. )
  335. return 0
  336. if __name__ == "__main__":
  337. sys.exit(main())