diff --git a/audit/README.md b/audit/README.md index cd8d0640..93ec8883 100644 --- a/audit/README.md +++ b/audit/README.md @@ -107,12 +107,17 @@ python audit/aggregate/report.py --selftest python audit/static/run_static.py --selftest # full pipeline end-to-end on fixtures ``` +`run_static.py` writes all four report formats to its `--out` directory: +`report.md`, `report.json`, `report.sarif` (upload to GitHub code scanning), and +`report.html` (a self-contained heatmap page). + ## Status -- **Done (this slice):** static build-free runners, normalization + taxonomy - (incl. the OWN001 `[resource:]` split and OWN014 region-escape labelling), - DevExpress baseline-suppress, cross-tool agreement scoring, the pain heatmap, - markdown + json reports, the analyzer-injection props/targets, and selftests. -- **Deferred:** HTML + merged-SARIF renderers (more views over the same model); - the runtime layer (FlaUI + ClrMD leak-harness, duplicate-immutable detector); - the AI-reviewer layer; feeding confirmed findings back into the OwnLang corpus. +- **Done:** static build-free runners, normalization + taxonomy (incl. the OWN001 + `[resource:]` split, OWN014 region-escape labelling, and OWN050 routed to the + coverage ledger), DevExpress baseline-suppress, cross-tool agreement scoring, the + pain heatmap, **all four renderers (markdown / json / merged SARIF / HTML)**, the + analyzer-injection props/targets, and selftests. +- **Deferred:** the runtime layer (FlaUI + ClrMD leak-harness, duplicate-immutable + detector); the AI-reviewer layer; feeding confirmed findings back into the + OwnLang corpus. diff --git a/audit/aggregate/report.py b/audit/aggregate/report.py index 4ec72c06..6a903ae9 100755 --- a/audit/aggregate/report.py +++ b/audit/aggregate/report.py @@ -6,9 +6,11 @@ * **Markdown** — for humans / a GitHub run summary (as oracle/mine do today). * **JSON** — machine-readable, for the downstream AI layer and regression diffs. + * **merged SARIF** — one combined SARIF 2.1.0 log (audit categories as rules) so + the findings surface in GitHub code scanning / the Security tab. + * **HTML** — a self-contained heatmap page for browsing. -HTML and merged-SARIF renderers are deferred (Plan.md §3.5) — they are additional -views over the same scored model, not new analysis. +All four are views over the same scored model, not new analysis. The coverage section is load-bearing: it states which tiers ran, which categories are NO-TOOL / deferred-to-runtime, how many DevExpress findings were suppressed, @@ -19,6 +21,7 @@ from __future__ import annotations import argparse +import html import json import sys from pathlib import Path @@ -29,6 +32,9 @@ from score import score from score import to_json as score_to_json +# Audit P0..P3 severities -> SARIF result levels (merged-SARIF renderer). +SEV_TO_LEVEL = {"P0": "error", "P1": "error", "P2": "warning", "P3": "note"} + # Human labels for the Plan.md §2 categories, used in the coverage section. CATEGORY_LABELS = { 1: "IDisposable leak", 2: "event/subscription leak & region escape", @@ -115,6 +121,12 @@ def _coverage_section(meta: dict[str, Any], cov: dict[str, Any], if cov.get("suppressed_by"): for reason, n in sorted(cov["suppressed_by"].items()): out.append(f" - suppressed — {reason}: {n}") + skipped = cov.get("analysis_skipped", 0) + if skipped: + by = cov.get("analysis_skipped_by") or {} + detail = ", ".join(f"{r} x{n}" for r, n in sorted(by.items())) + out.append(f"- analysis-skipped (coverage notes, not scored): {skipped}" + + (f" — {detail}" if detail else "")) no_tool = meta.get("no_tool_static") or [] if no_tool: labels = ", ".join(f"{c} ({CATEGORY_LABELS.get(c, '?')})" for c in no_tool) @@ -137,12 +149,143 @@ def render_json(meta: dict[str, Any], cov: dict[str, Any], return {"meta": meta, "coverage": cov, **score_to_json(scored)} +def render_sarif(meta: dict[str, Any], cov: dict[str, Any], + scored: dict[str, Any]) -> dict[str, Any]: + """One merged SARIF 2.1.0 log for GitHub code scanning (Plan.md §3.5). + + Audit categories become the SARIF rules (so the Security tab groups by our + taxonomy, not the underlying tool ids); each scored cluster is one result, with + the contributing tools, confidence and pain carried in properties. Suppressed + and analysis-skipped counts ride in the run properties so the honesty ledger + travels with the log.""" + rules: dict[str, dict[str, Any]] = {} + results: list[dict[str, Any]] = [] + for c in scored["clusters"]: + rule_id = f"own-audit/{c.category_name}" + level = SEV_TO_LEVEL.get(c.severity, "warning") + if rule_id not in rules: + rules[rule_id] = { + "id": rule_id, + "name": c.category_name, + "shortDescription": { + "text": f"{CATEGORY_LABELS.get(c.category, c.category_name)} " + f"(category {c.category})"}, + "defaultConfiguration": {"level": level}, + "properties": {"category": c.category}, + } + # A genuinely file-level finding (parse_sarif records line 0 when the + # upstream result has no region) must STAY file-level — omit region rather + # than fabricate a line-1 region that mis-pins the code-scanning alert. + phys: dict[str, Any] = {"artifactLocation": {"uri": c.path}} + if c.line >= 1: + phys["region"] = {"startLine": c.line} + results.append({ + "ruleId": rule_id, + "level": level, + "message": {"text": f"[{c.severity} · {c.confidence}] {c.category_name} — " + + "; ".join(f"{f.tool}:{f.rule}" for f in c.findings)}, + "locations": [{"physicalLocation": phys}], + "properties": {"category": c.category, "categoryName": c.category_name, + "confidence": c.confidence, "severity": c.severity, + "pain": c.pain, "tools": c.tools}, + }) + return { + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [{ + "tool": {"driver": { + "name": "Own.NET Audit", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": list(rules.values()), + }}, + "results": results, + "properties": { + "target": meta.get("target", ""), + "commit": meta.get("commit", ""), + "suppressed": cov.get("suppressed", 0), + "analysisSkipped": cov.get("analysis_skipped", 0), + }, + }], + } + + +def render_html(meta: dict[str, Any], cov: dict[str, Any], + scored: dict[str, Any], max_list: int = 200) -> str: + """A self-contained HTML heatmap page (Plan.md §3.5) — no external assets.""" + e = html.escape + clusters = scored["clusters"] + high = [c for c in clusters if c.confidence == "high"] + sev_class = {"P0": "p0", "P1": "p1", "P2": "p2", "P3": "p3"} + + rows = "\n".join( + f"
{e(c.path)}:{c.line} "
+ f"{e(c.category_name)} "
+ f"{e(', '.join(c.tools))}{e(str(meta.get('target', '?')))}{scored['totals']['clusters']} findings + ({scored['totals']['high_confidence']} high-confidence, + {scored['totals']['candidates']} candidate)
+| module | pain | findings | high-conf | +top category |
|---|