From a5bf62d52b492b3fc55d3e35f1ab2f8891ddd8ba Mon Sep 17 00:00:00 2001 From: aktasbatuhan Date: Mon, 8 Jun 2026 15:57:22 +0100 Subject: [PATCH 1/3] feat(report): Markdown findings report (kai report) The no-browser companion to kai.viewer: same on-disk source (/exploits.json) rendered as a plain-text Markdown report you can pipe into CI, paste into a PR, or read over SSH. - render_markdown(): a sorted summary table (confirmed/critical first) plus per-finding sections -- facts, why-exploitable, exploit sketch, CVSS 3.1 breakdown table, fenced PoC, and the patch as a ```diff - render_run(): load a run dir's findings and render - python -m kai.report [-o OUT] (stdout by default) Reuses kai.viewer.findings.load_findings; no live state backend. --- src/kai/report.py | 148 +++++++++++++++++++++++++++++++++++++++++++ tests/test_report.py | 86 +++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 src/kai/report.py create mode 100644 tests/test_report.py diff --git a/src/kai/report.py b/src/kai/report.py new file mode 100644 index 0000000..48b8372 --- /dev/null +++ b/src/kai/report.py @@ -0,0 +1,148 @@ +"""Render a run's findings as a Markdown security report. + +The no-browser companion to :mod:`kai.viewer`: same on-disk source +(``/exploits.json``), but a plain-text report you can pipe into CI, +paste into a PR, or read over SSH. Markdown renders on GitHub and stays +legible in a terminal, so one format serves both. + + python -m kai.report [-o OUT] +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from kai.viewer.findings import Finding, load_findings + +_SEVERITY_ORDER = ("critical", "high", "medium", "low", "none") + + +def _cell(text: str) -> str: + """Make a value safe for a single Markdown table cell.""" + + return str(text).replace("|", r"\|").replace("\n", " ").strip() + + +def _score(finding: Finding) -> str: + return f"{finding.cvss_score:.1f}" if finding.cvss_score is not None else "—" + + +def _location(finding: Finding) -> str: + file = finding.file.split("/")[-1] if finding.file else "" + return f"{file}:{finding.function}" if finding.function else file + + +def _summary_line(findings: list[Finding]) -> str: + counts: dict[str, int] = {} + for f in findings: + counts[f.severity] = counts.get(f.severity, 0) + 1 + parts = [f"{counts[s]} {s}" for s in _SEVERITY_ORDER if counts.get(s)] + n = len(findings) + head = f"**{n} finding{'s' if n != 1 else ''}**" + return f"{head} · {' · '.join(parts)}" if parts else head + + +def _summary_table(findings: list[Finding]) -> list[str]: + rows = [ + "| CVSS | Severity | Finding | Category | Location | Status |", + "|---|---|---|---|---|---|", + ] + for f in findings: + status = f.status + (" ✓" if f.confirmed else "") + rows.append( + f"| {_score(f)} | {f.severity} | {_cell(f.title)} | " + f"{_cell(f.category.replace('_', ' '))} | {_cell(_location(f))} | " + f"{_cell(status)} |" + ) + return rows + + +def _finding_section(idx: int, f: Finding) -> list[str]: + out: list[str] = [] + sev = f" ({f.severity})" if f.severity != "none" else "" + out.append(f"## {idx}. {f.title} · CVSS {_score(f)}{sev}") + out.append("") + + facts = [ + ("Location", f"{f.file} · `{f.function}()`" if f.function else f.file), + ("Category", f.category.replace("_", " ")), + ("Attacker", f.attacker_role), + ("Precondition", f.prerequisite), + ("Status", f.status + (" · confirmed" if f.confirmed else "")), + ] + for label, value in facts: + if value: + out.append(f"- **{label}:** {value}") + out.append("") + + if f.hypothesis: + out += ["**Why it's exploitable**", "", f.hypothesis, ""] + if f.exploit_sketch: + out += ["**Exploit sketch**", "", f.exploit_sketch, ""] + + if f.cvss_rows: + out += ["**CVSS 3.1**" + (f" — `{f.cvss_vector}`" if f.cvss_vector else ""), ""] + out += ["| Metric | Value | Justification |", "|---|---|---|"] + for r in f.cvss_rows: + out.append(f"| {r['metric']} | {r['value']} | {_cell(r['why'])} |") + out.append("") + + if f.poc_code: + out += ["**Proof of concept**", "", "```", f.poc_code, "```", ""] + if f.patch: + out += ["**Suggested patch**", "", "```diff", f.patch, "```", ""] + if f.critic_summary: + out += ["**Critic**", "", f.critic_summary, ""] + return out + + +def render_markdown(findings: list[Finding], title: str = "") -> str: + """Render a sorted findings list into a Markdown report.""" + + lines = [f"# Security findings{f' — {title}' if title else ''}", ""] + if not findings: + lines += ["No findings recorded for this run.", ""] + return "\n".join(lines) + + lines += [_summary_line(findings), ""] + lines += _summary_table(findings) + lines += ["", "---", ""] + for idx, f in enumerate(findings, start=1): + lines += _finding_section(idx, f) + return "\n".join(lines).rstrip() + "\n" + + +def render_run(run_dir: Path) -> str: + """Load ``/exploits.json`` and render the Markdown report.""" + + run_dir = Path(run_dir) + return render_markdown(load_findings(run_dir), title=run_dir.name) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python -m kai.report", + description="Render a run's findings as a Markdown security report.", + ) + parser.add_argument("run_dir", help="run directory (a state// dir)") + parser.add_argument("-o", "--output", help="write to PATH (default: stdout)") + args = parser.parse_args(argv) + + run_dir = Path(args.run_dir) + if not run_dir.is_dir(): + print(f"error: {run_dir} is not a directory", file=sys.stderr) + return 2 + + markdown = render_run(run_dir) + if args.output: + Path(args.output).write_text(markdown, encoding="utf-8") + print(args.output) + else: + sys.stdout.write(markdown) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..6ce795e --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,86 @@ +"""Tests for the Markdown findings report.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from kai.report import main, render_markdown, render_run +from kai.viewer.findings import load_findings + +_EXPLOITS = [ + { + "exploit_id": "e2", + "status": "rejected", + "confirmed": False, + "hypothesis": "Fee truncation rounds small trades to zero.", + "file": "contracts/Fees.sol", + "function": "calcFee", + "category": "theoretical_bounds", + "cvss_score": 4.3, + }, + { + "exploit_id": "e1", + "status": "verified", + "confirmed": True, + "hypothesis": "Reentrancy in withdraw drains the vault.", + "file": "contracts/Vault.sol", + "function": "withdraw", + "category": "active_exploit", + "severity": "critical", + "cvss_score": 9.1, + "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "cvss_justification": {"AV": "remote attacker"}, + "poc_code": "contract Attacker { function pwn() external {} }", + "patch": "- call_before_update;\n+ update_before_call;", + "attacker_role": "anyone", + "prerequisite": "a non-zero deposit", + }, +] + + +def _write_run(dir_path: Path) -> None: + (dir_path / "exploits.json").write_text(json.dumps(_EXPLOITS), encoding="utf-8") + + +def test_report_summary_table_and_order(tmp_path: Path) -> None: + _write_run(tmp_path) + md = render_markdown(load_findings(tmp_path), title="myrepo") + + assert md.startswith("# Security findings — myrepo") + assert "**2 findings** · 1 critical · 1 medium" in md + # Summary table header + both findings, confirmed-critical sorted first. + assert "| CVSS | Severity | Finding | Category | Location | Status |" in md + crit_at = md.index("Reentrancy in withdraw") + med_at = md.index("Fee truncation") + assert crit_at < med_at + + +def test_report_sections_and_code_fences(tmp_path: Path) -> None: + _write_run(tmp_path) + md = render_run(tmp_path) + + assert "## 1. Reentrancy in withdraw" in md + assert "CVSS 9.1 (critical)" in md + assert "- **Attacker:** anyone" in md + # PoC fenced, patch fenced as a diff, CVSS breakdown table present. + assert "```\ncontract Attacker" in md + assert "```diff\n- call_before_update;" in md + assert "| AV | Network | remote attacker |" in md + + +def test_report_empty(tmp_path: Path) -> None: + md = render_markdown([], title="empty") + assert "No findings recorded for this run." in md + + +def test_main_writes_file(tmp_path: Path) -> None: + _write_run(tmp_path) + out = tmp_path / "report.md" + rc = main([str(tmp_path), "-o", str(out)]) + assert rc == 0 + assert "Reentrancy in withdraw" in out.read_text(encoding="utf-8") + + +def test_main_rejects_non_dir(tmp_path: Path) -> None: + assert main([str(tmp_path / "nope")]) == 2 From 8c8f44b6a6b1f7df0ce2ae0e4dcd5d153a430b46 Mon Sep 17 00:00:00 2001 From: aktasbatuhan Date: Mon, 8 Jun 2026 16:19:52 +0100 Subject: [PATCH 2/3] feat(report): add --format html (styled single-page document) kai report --format html renders a self-contained, fully-expanded report document that shares the viewer's design system (kai.viewer.style) -- same palette, severity dots, CVSS tables, and +/- patch diff as kai view, so the two surfaces never drift. Markdown stays the default (-f md); --open opens the rendered file. Unlike the interactive viewer (master-detail + trace tabs), this is a linear document meant to be printed, attached, or shared. Static HTML with all dynamic values escaped server-side. --format scales to sarif/json later. --- src/kai/report.py | 208 +++++++++++++++++++++++++++++++++++++++++-- tests/test_report.py | 26 +++++- 2 files changed, 224 insertions(+), 10 deletions(-) diff --git a/src/kai/report.py b/src/kai/report.py index 48b8372..0885be4 100644 --- a/src/kai/report.py +++ b/src/kai/report.py @@ -5,15 +5,21 @@ paste into a PR, or read over SSH. Markdown renders on GitHub and stays legible in a terminal, so one format serves both. - python -m kai.report [-o OUT] + python -m kai.report [--format md|html] [-o OUT] + +``--format html`` renders a styled single-page document using the viewer's +design system (:mod:`kai.viewer.style`), so it matches ``kai view``. """ from __future__ import annotations import argparse import sys +from html import escape from pathlib import Path +from ra.viewer import style + from kai.viewer.findings import Finding, load_findings _SEVERITY_ORDER = ("critical", "high", "medium", "low", "none") @@ -121,13 +127,187 @@ def render_run(run_dir: Path) -> str: return render_markdown(load_findings(run_dir), title=run_dir.name) +# --------------------------------------------------------------------------- +# HTML (--format html): a styled single-page report document. +# +# Shares kai.viewer.style so it matches `kai view` exactly. Unlike the +# interactive viewer (master-detail + trace tabs), this is a linear, fully +# expanded document meant to be printed, attached, or shared. Static HTML, +# so every dynamic value is escaped server-side. +# --------------------------------------------------------------------------- + +_REPORT_LAYOUT = """\ + header.doc { max-width: 820px; margin: 0 auto; padding: 22px 24px 14px; } + header.doc h1 { margin: 0 0 6px; font-size: 22px; font-weight: 600; } + header.doc .summary { font-size: 13px; color: var(--muted-2); } + header.doc .summary b { color: var(--ink); } + header.doc .summary .crit { color: var(--accent); font-weight: 600; } + .toggle { float: right; border: 1px solid var(--rule-2); background: none; color: var(--muted-2); + border-radius: 5px; cursor: pointer; font-size: 12px; padding: 3px 8px; } + main.report { max-width: 820px; margin: 0 auto; padding: 8px 24px 64px; } + .summary-table { margin: 18px 0 4px; } + .finding { border-top: 1px solid var(--rule); padding-top: 22px; margin-top: 26px; } + .finding:first-of-type { border-top: 0; margin-top: 14px; } + .finding h2 { font-size: 18px; margin: 0 0 4px; font-weight: 600; line-height: 1.35; } + .finding .where { font-size: 12.5px; color: var(--muted); margin-bottom: 14px; } +""" + +_REPORT_CSS = style.base_css() + _REPORT_LAYOUT + +_THEME_TOGGLE = ( + '" +) + + +def _bar(finding: Finding) -> str: + if finding.cvss_score is None: + return "" + pct = max(0, min(100, round(finding.cvss_score / 10 * 100))) + return f'' + + +def _summary_row(f: Finding) -> str: + status = escape(f.status + (" ✓" if f.confirmed else "")) + return ( + f'' + f'{_score(f)}{_bar(f)}' + f'{escape(f.title)}' + f'{escape(f.category.replace("_", " "))}' + f'{escape(_location(f))}{status}' + ) + + +def _html_diff(patch: str) -> str: + lines = [] + for line in patch.split("\n"): + cls = "add" if line.startswith("+") else "del" if line.startswith("-") else "" + lines.append(f'{escape(line)}' if cls else escape(line)) + return '
' + "\n".join(lines) + "
" + + +def _html_finding(idx: int, f: Finding) -> str: + sev = f" ({escape(f.severity)})" if f.severity != "none" else "" + where = escape(f.file) + (f" · {escape(f.function)}()" if f.function else "") + out = [ + f'
', + f'

{idx}. {escape(f.title)} · CVSS {_score(f)}{sev}

', + f'
{where}
', + ] + + kv = [] + for label, value in ( + ("Category", f.category.replace("_", " ")), + ("Attacker", f.attacker_role), + ("Precondition", f.prerequisite), + ("Status", f.status + (" · confirmed" if f.confirmed else "")), + ): + if value: + kv.append(f"
{escape(label)}
{escape(value)}
") + if kv: + out.append('
' + "".join(kv) + "
") + + if f.hypothesis: + out += ['
Why it\'s exploitable
', + f'

{escape(f.hypothesis)}

'] + if f.exploit_sketch: + out += ['
Exploit sketch
', + f'

{escape(f.exploit_sketch)}

'] + if f.cvss_rows: + out.append('
CVSS 3.1 vector
') + if f.cvss_vector: + out.append(f'
{escape(f.cvss_vector)}
') + rows = "".join( + f'{escape(r["metric"])}' + f'{escape(r["value"])}' + f'{escape(r["why"])}' + for r in f.cvss_rows + ) + out.append(f'
{rows}
') + if f.poc_code: + out += ['
Proof of concept
', + f'
{escape(f.poc_code)}
'] + if f.patch: + out += ['
Suggested patch
', _html_diff(f.patch)] + if f.critic_summary: + out += ['
Critic
', + f'

{escape(f.critic_summary)}

'] + out.append("
") + return "\n".join(out) + + +def render_html(findings: list[Finding], title: str = "") -> str: + """Render a styled, self-contained single-page HTML report document.""" + + crit = sum(1 for f in findings if f.severity == "critical") + n = len(findings) + summary = f"{n} finding{'s' if n != 1 else ''}" + if crit: + summary += f' · {crit} critical' + + body = [ + '
', + _THEME_TOGGLE, + f'

Security findings{" — " + escape(title) if title else ""}

', + f'
{summary}
', + "
", + '
', + ] + if not findings: + body.append('
No findings recorded for this run.
') + else: + body.append( + '' + "" + "" + + "".join(_summary_row(f) for f in findings) + + "
CVSSFindingCategoryLocationStatus
" + ) + body += [_html_finding(i, f) for i, f in enumerate(findings, start=1)] + body.append("
") + + return ( + "\n" + '' + '' + f"kai — {escape(title) or 'findings'}\n" + f"\n\n" + + "\n".join(body) + + "\n\n" + ) + + +def render_run_html(run_dir: Path) -> str: + """Load ``/exploits.json`` and render the HTML report document.""" + + run_dir = Path(run_dir) + return render_html(load_findings(run_dir), title=run_dir.name) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="python -m kai.report", - description="Render a run's findings as a Markdown security report.", + description="Render a run's findings as a security report.", ) parser.add_argument("run_dir", help="run directory (a state// dir)") - parser.add_argument("-o", "--output", help="write to PATH (default: stdout)") + parser.add_argument( + "-f", + "--format", + choices=("md", "html"), + default="md", + help="md (Markdown, default) or html (styled single-page document)", + ) + parser.add_argument( + "-o", + "--output", + help="write to PATH (md: default stdout; html: default /report.html)", + ) + parser.add_argument( + "--open", + action="store_true", + help="open the rendered file in a browser (html only)", + ) args = parser.parse_args(argv) run_dir = Path(args.run_dir) @@ -135,12 +315,22 @@ def main(argv: list[str] | None = None) -> int: print(f"error: {run_dir} is not a directory", file=sys.stderr) return 2 - markdown = render_run(run_dir) - if args.output: - Path(args.output).write_text(markdown, encoding="utf-8") - print(args.output) - else: - sys.stdout.write(markdown) + if args.format == "md": + markdown = render_run(run_dir) + if args.output: + Path(args.output).write_text(markdown, encoding="utf-8") + print(args.output) + else: + sys.stdout.write(markdown) + return 0 + + target = Path(args.output) if args.output else run_dir / "report.html" + target.write_text(render_run_html(run_dir), encoding="utf-8") + print(target) + if args.open: + import webbrowser + + webbrowser.open(target.resolve().as_uri()) return 0 diff --git a/tests/test_report.py b/tests/test_report.py index 6ce795e..38fd197 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -5,7 +5,7 @@ import json from pathlib import Path -from kai.report import main, render_markdown, render_run +from kai.report import main, render_html, render_markdown, render_run from kai.viewer.findings import load_findings _EXPLOITS = [ @@ -84,3 +84,27 @@ def test_main_writes_file(tmp_path: Path) -> None: def test_main_rejects_non_dir(tmp_path: Path) -> None: assert main([str(tmp_path / "nope")]) == 2 + + +def test_html_report_is_self_contained_and_styled(tmp_path: Path) -> None: + _write_run(tmp_path) + html = render_html(load_findings(tmp_path), title="myrepo") + + assert html.startswith("") + # Fully offline, and shares the viewer's design tokens (one design system). + assert "http://" not in html and "https://" not in html + assert "--accent:" in html # tokens from kai.viewer.style + assert 'class="finding sev-critical' in html + assert "Reentrancy in withdraw" in html + assert '
' in html
+    # The patch diff classes drive the +/- colouring.
+    assert '' in html and '' in html
+
+
+def test_main_format_html_writes_file(tmp_path: Path) -> None:
+    _write_run(tmp_path)
+    rc = main([str(tmp_path), "--format", "html"])
+    assert rc == 0
+    out = tmp_path / "report.html"
+    assert out.exists()
+    assert "Reentrancy in withdraw" in out.read_text(encoding="utf-8")

From ed88eb8993b9e6eb66a04b987e93071afaf8ae98 Mon Sep 17 00:00:00 2001
From: aktasbatuhan 
Date: Tue, 9 Jun 2026 15:19:48 +0100
Subject: [PATCH 3/3] fix(report): create parent dirs for -o output paths

Match the viewer fix (#98): 'kai report -o some/new/dir/report.md' (and
--format html) now mkdir the parent instead of raising FileNotFoundError.
---
 src/kai/report.py | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/src/kai/report.py b/src/kai/report.py
index 0885be4..cdd6de9 100644
--- a/src/kai/report.py
+++ b/src/kai/report.py
@@ -318,13 +318,16 @@ def main(argv: list[str] | None = None) -> int:
     if args.format == "md":
         markdown = render_run(run_dir)
         if args.output:
-            Path(args.output).write_text(markdown, encoding="utf-8")
-            print(args.output)
+            out = Path(args.output)
+            out.parent.mkdir(parents=True, exist_ok=True)
+            out.write_text(markdown, encoding="utf-8")
+            print(out)
         else:
             sys.stdout.write(markdown)
         return 0
 
     target = Path(args.output) if args.output else run_dir / "report.html"
+    target.parent.mkdir(parents=True, exist_ok=True)
     target.write_text(render_run_html(run_dir), encoding="utf-8")
     print(target)
     if args.open: