Skip to content

Commit f275cf2

Browse files
authored
feat(scanner): templated report, single-sourced prompt variant, static ATLAS (PR-14) (#42)
- cli/dsgai_report.py + templates/report.css: the HTML report is rendered BY CODE from the checkpoint (deterministic, testable) with a golden structural test. The LLM contributes only prose (--prose) injected as data. STRICT mode renders file IDs (F07:12) + writes DSGAI-filemap.json; accessibility — every status carries a symbol + text label, not colour alone. Rendered with stdlib (styles in a template file) — no Jinja2 runtime dependency. - DSGAI-samplereport.png regenerated from the fixture app via the renderer + headless Chrome (~45 KB, fully reproducible, zero real-repo disclosure). - build/generate_prompt_variant.py: dsgai_scanner_prompt.md is now generated from the skill (frontmatter + cc-only blocks stripped); drift is a CI failure (--check). Single-sourced so the two variants can't diverge — the variant is now comprehensive rather than a separately-curated condensation. - rules/atlas-map.yaml: static MITRE ATLAS technique->control map; the skill's unreliable live site:atlas.mitre.org searches are removed in favour of it. - .gitignore: scanner generated artifacts (checkpoint/filemap/baseline/reports). Verified: report renders with all sections, uses file IDs, zero secret leakage; prompt-variant --check in sync; ATLAS map validates; pytest 19 passed.
1 parent 7bc1347 commit f275cf2

11 files changed

Lines changed: 1638 additions & 295 deletions

File tree

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,10 @@ node_modules/
66
__pycache__/
77
*.pyc
88
.env
9+
10+
# DSGAI scanner generated artifacts
11+
DSGAI-scan.json
12+
DSGAI-scan.sarif
13+
DSGAI-filemap.json
14+
dsgai-baseline.json
15+
dsgai-reports/

dsgai_scanner_tool/CHANGES_v0.3.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ dates are ISO-8601. The previous line is recorded in [`CHANGES_v0.2.md`](CHANGES
99
## [Unreleased]
1010

1111
### Added
12+
- **Templated report, single-sourced prompt variant, static ATLAS map** (PR-14).
13+
- `cli/dsgai_report.py` + `templates/report.css`: the HTML report is now rendered
14+
**by code** from the checkpoint (deterministic, testable), with a golden structural
15+
test. The LLM contributes only prose (executive summary / remediation) via `--prose`.
16+
STRICT mode renders file IDs (`F07:12`) and writes `DSGAI-filemap.json`;
17+
accessibility — every status carries a symbol + text label, not colour alone.
18+
- **Sample PNG regenerated** from the fixture app via the new renderer + headless
19+
Chrome (~45 KB, fully reproducible, zero real-repo disclosure).
20+
- `build/generate_prompt_variant.py`: `dsgai_scanner_prompt.md` is now generated from
21+
the skill (frontmatter + `cc-only` blocks stripped) — drift is a CI failure
22+
(`--check`), single-sourced so the two variants can't diverge.
23+
- `rules/atlas-map.yaml`: static MITRE ATLAS technique→control map; the skill's live
24+
`site:atlas.mitre.org` searches are removed in favour of it.
1225
- **CVE pipeline, suppressions, baseline, incremental scanning** (PR-12).
1326
- CVE fetching moved into the CLI (`cli/dsgai_cve.py`, stdlib urllib): OSV
1427
`querybatch` is the per-version source, NVD enriches CVSS by `cveId` only (no
-307 KB
Loading

dsgai_scanner_tool/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ All 17 remaining controls (DSGAI01, 03–12, 16–21) are **STRUCTURAL** — mat
314314
<p align="center">
315315
<img src="DSGAI-samplereport.png" alt="Sample DSGAI compliance report showing dashboard, findings cards, and CVE advisory panel" width="800">
316316
<br>
317-
<em>Sample DSGAI report — Section 1 (Compliance) and Section 2 (CVE Advisory). This interim image will be regenerated from the public <a href="tests/fixtures/vulnerable-app/">fixture app</a> once the deterministic report template lands (v0.3.x), guaranteeing zero real-repo disclosure and a reproducible screenshot.</em>
317+
<em>DSGAI report rendered deterministically (<code>cli/dsgai_report.py</code>) from a scan of the public <a href="tests/fixtures/vulnerable-app/">fixture app</a> — STRICT mode, file IDs + line numbers only, zero real-repo disclosure. Fully reproducible.</em>
318318
</p>
319319

320320
The generated `DSGAI-report.html` contains:
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env python3
2+
"""Generate the tool-neutral dsgai_scanner_prompt.md from dsgai_scanner_tool.md.
3+
4+
Single-sources the two variants so they can't drift: the prompt variant is the
5+
skill minus its Claude-Code-specific parts. Removes the YAML frontmatter and any
6+
content between `<!-- cc-only:start -->` / `<!-- cc-only:end -->` markers, and
7+
prepends a tool-neutral header.
8+
9+
python build/generate_prompt_variant.py # write the variant
10+
python build/generate_prompt_variant.py --check # CI: fail if checked-in
11+
# variant is out of date
12+
"""
13+
import re
14+
import sys
15+
from pathlib import Path
16+
17+
ROOT = Path(__file__).resolve().parent.parent
18+
SKILL = ROOT / "dsgai_scanner_tool.md"
19+
VARIANT = ROOT / "dsgai_scanner_prompt.md"
20+
21+
CC_BLOCK = re.compile(r'[ \t]*<!-- cc-only:start -->.*?<!-- cc-only:end -->[ \t]*\n?',
22+
re.DOTALL)
23+
FRONTMATTER = re.compile(r'\A---\n.*?\n---\n', re.DOTALL)
24+
25+
HEADER = """<!-- GENERATED FILE — do not edit by hand.
26+
Regenerate with: python build/generate_prompt_variant.py
27+
Source of truth: dsgai_scanner_tool.md (the Claude Code skill).
28+
This is the tool-neutral variant for use with any AI coding assistant. -->
29+
30+
# DSGAI Scanner — Tool-Neutral Prompt
31+
32+
> This is the generated, tool-neutral variant of the DSGAI scanner. It is
33+
> produced from the Claude Code skill by `build/generate_prompt_variant.py`.
34+
> Use it with any AI coding assistant that can run shell commands (`rg --pcre2`,
35+
> `git`, `python`). For Claude Code, use `dsgai_scanner_tool.md` directly.
36+
37+
"""
38+
39+
40+
def generate():
41+
text = SKILL.read_text(encoding="utf-8")
42+
text = FRONTMATTER.sub("", text, count=1)
43+
text = CC_BLOCK.sub("", text)
44+
# Drop the leading skill H1 (the header below replaces it).
45+
text = re.sub(r'\A\s*# .*?\n', "", text, count=1)
46+
return HEADER + text.lstrip()
47+
48+
49+
def main(argv):
50+
rendered = generate()
51+
if "--check" in argv:
52+
current = VARIANT.read_text(encoding="utf-8") if VARIANT.exists() else ""
53+
if current != rendered:
54+
sys.stderr.write("dsgai_scanner_prompt.md is out of date. Run: "
55+
"python build/generate_prompt_variant.py\n")
56+
return 1
57+
print("dsgai_scanner_prompt.md is up to date.")
58+
return 0
59+
VARIANT.write_text(rendered, encoding="utf-8", newline="\n")
60+
print(f"wrote {VARIANT} ({rendered.count(chr(10))} lines)")
61+
return 0
62+
63+
64+
if __name__ == "__main__":
65+
sys.exit(main(sys.argv[1:]))
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env python3
2+
"""Deterministic DSGAI HTML report renderer.
3+
4+
Renders `DSGAI-scan.json` to a self-contained HTML report — by code, from the
5+
checkpoint, so the structure is fixed and testable. The LLM contributes only
6+
designated prose (executive summary, remediation) injected as data via --prose.
7+
8+
Rendered with the standard library (styles live in templates/report.css and are
9+
inlined) — no Jinja2 runtime dependency, matching the scanner's zero-dependency
10+
ethos. STRICT mode renders stable file IDs (F07:12) and writes the ID->path map
11+
to DSGAI-filemap.json (gitignore it); --internal renders full paths.
12+
13+
Usage:
14+
python cli/dsgai_report.py DSGAI-scan.json [--out dsgai-reports/report.html]
15+
[--prose prose.json] [--filemap DSGAI-filemap.json]
16+
"""
17+
import argparse
18+
import html
19+
import json
20+
import os
21+
import sys
22+
23+
HERE = os.path.dirname(os.path.abspath(__file__))
24+
CSS = os.path.join(os.path.dirname(HERE), "templates", "report.css")
25+
26+
CTRL_CLASS = {"FAIL": "fail", "WARN": "warn", "PASS": "pass",
27+
"NOT VALIDATED": "nv", "NOT APPLICABLE": "na",
28+
"VENDOR ATTESTATION REQUIRED": "vendor"}
29+
FIND_CLASS = {"fail": "fail", "warn": "warn", "pass_signal": "pass",
30+
"count": "nv", "info": "nv"}
31+
ATTRIBUTION = (
32+
"Based on OWASP GenAI Data Security Risks and Mitigations 2026 (v1.0, March 2026) "
33+
"by the OWASP GenAI Data Security Initiative, led by Emmanuel Guilherme Junior. "
34+
"Report content CC BY-SA 4.0; scanner code Apache-2.0."
35+
)
36+
37+
38+
def esc(s):
39+
return html.escape(str(s), quote=True)
40+
41+
42+
def build_filemap(cp):
43+
"""Assign F01.. IDs to distinct paths in first-appearance (sorted) order."""
44+
paths = []
45+
for f in cp["findings"] + cp.get("suppressed", []):
46+
if f["path"] not in paths:
47+
paths.append(f["path"])
48+
paths.sort()
49+
return {p: f"F{i + 1:02d}" for i, p in enumerate(paths)}
50+
51+
52+
def loc(f, filemap, internal):
53+
p = f["path"] if internal else filemap.get(f["path"], f["path"])
54+
redacted = " (value redacted)" if f.get("classification") == "value_bearing" else ""
55+
return f"{esc(p)}:{f['line']}{esc(redacted)}"
56+
57+
58+
def render(cp, prose, internal):
59+
css = open(CSS, encoding="utf-8").read()
60+
filemap = {} if internal else build_filemap(cp)
61+
mode = "INTERNAL" if internal else "STRICT"
62+
scope = cp.get("scan_scope", ".")
63+
incremental = scope.startswith("diff:")
64+
65+
badges = [f"Framework {esc(cp['framework'])}", f"Engine {esc(cp['engine'])}",
66+
f"Ruleset {esc(cp['ruleset_version'])}", f"Scope {esc(scope)}",
67+
f"Scanned {esc(cp['scanned_at'])}"]
68+
mode_icon = "\U0001F513" if internal else "\U0001F6E1"
69+
mode_cls = "warnmode" if internal else ""
70+
b_html = f'<span class="badge {mode_cls}">{mode_icon} {mode}</span>'
71+
b_html += "".join(f'<span class="badge">{b}</span>' for b in badges)
72+
if incremental:
73+
b_html += '<span class="badge warnmode">INCREMENTAL — not a full assessment</span>'
74+
75+
# Compliance dashboard
76+
dash = []
77+
for c in sorted(cp["controls"]):
78+
st = cp["controls"][c]
79+
cls = CTRL_CLASS.get(st, "nv")
80+
dash.append(f'<div class="ctrl {cls}"><div class="cid">{esc(c)}</div>'
81+
f'<span class="st {cls}">{esc(st)}</span></div>')
82+
83+
# Findings
84+
rows = []
85+
for f in cp["findings"]:
86+
if f["status"] not in ("fail", "warn"):
87+
continue
88+
cls = FIND_CLASS.get(f["status"], "nv")
89+
tag = " · baselined" if f.get("baselined") else ""
90+
rows.append(f'<tr><td><span class="st {cls}">{esc(f["status"].upper())}</span></td>'
91+
f'<td>{esc(f["control"])}</td><td><code>{esc(f["rule_id"])}</code></td>'
92+
f'<td>{loc(f, filemap, internal)}{esc(tag)}</td></tr>')
93+
findings_html = ("<table><tr><th>Status</th><th>Control</th><th>Rule</th><th>Location</th></tr>"
94+
+ "".join(rows) + "</table>") if rows else "<p>No FAIL/WARN findings.</p>"
95+
96+
# CVEs
97+
cve_rows = []
98+
for c in cp.get("cves", []):
99+
cvss = c.get("cvss")
100+
cve_rows.append(f'<tr class="cve {esc(c["status"])}"><td>{esc(c["status"])}</td>'
101+
f'<td><code>{esc(c["package"])}=={esc(c["version"])}</code></td>'
102+
f'<td>{esc(c["id"])}</td><td>{esc(cvss if cvss is not None else "—")}</td>'
103+
f'<td>{esc(c.get("summary", ""))}</td></tr>')
104+
cve_html = ("<table><tr><th>Status</th><th>Package</th><th>Advisory</th><th>CVSS</th><th>Summary</th></tr>"
105+
+ "".join(cve_rows) + "</table>") if cve_rows else "<p>No advisories.</p>"
106+
107+
# Suppressed
108+
sup_rows = []
109+
for f in cp.get("suppressed", []):
110+
sup_rows.append(f'<tr class="suppressed"><td><code>{esc(f["rule_id"])}</code></td>'
111+
f'<td>{loc(f, filemap, internal)}</td>'
112+
f'<td>{esc(f.get("suppressed_reason", ""))}</td></tr>')
113+
sup_html = ("<h2>Suppressed</h2><table><tr><th>Rule</th><th>Location</th><th>Reason</th></tr>"
114+
+ "".join(sup_rows) + "</table>") if sup_rows else ""
115+
116+
# Filemap section (strict only, and only listed here for the reader — the
117+
# ID->path map is written to DSGAI-filemap.json, never the shareable report).
118+
exec_summary = esc(prose.get("executive_summary",
119+
"Automated DSGAI compliance scan. Review FAIL findings first."))
120+
remediation = esc(prose.get("remediation", "See per-control remediation guidance."))
121+
122+
return f"""<!DOCTYPE html>
123+
<html lang="en"><head><meta charset="utf-8">
124+
<meta name="viewport" content="width=device-width, initial-scale=1">
125+
<title>DSGAI Compliance Report</title>
126+
<style>{css}</style></head>
127+
<body><div class="wrap">
128+
<header class="rpt"><h1>OWASP DSGAI 2026 Compliance Report</h1>
129+
<div class="badges">{b_html}</div></header>
130+
131+
<div class="residual"><strong>Residual risk:</strong> STRICT mode is designed to minimize
132+
disclosure (file IDs + line numbers only; value-bearing matches never shown). It is not a
133+
guarantee the report is public-safe — the existence and location of failing controls is
134+
itself information. Handle it like any security assessment.</div>
135+
136+
<h2>Executive summary</h2><p>{exec_summary}</p>
137+
<h2>Compliance dashboard</h2><div class="dash">{''.join(dash)}</div>
138+
<h2>Findings</h2>{findings_html}
139+
<h2>Remediation</h2><p>{remediation}</p>
140+
<h2>CVE advisories</h2>{cve_html}
141+
{sup_html}
142+
<footer class="rpt">{esc(ATTRIBUTION)}</footer>
143+
</div></body></html>
144+
"""
145+
146+
147+
def main(argv):
148+
ap = argparse.ArgumentParser(prog="dsgai_report")
149+
ap.add_argument("checkpoint")
150+
ap.add_argument("--out", default=None)
151+
ap.add_argument("--prose", default=None, help="JSON with executive_summary/remediation")
152+
ap.add_argument("--filemap", default="DSGAI-filemap.json")
153+
ap.add_argument("--internal", action="store_true")
154+
args = ap.parse_args(argv)
155+
156+
cp = json.load(open(args.checkpoint, encoding="utf-8"))
157+
prose = json.load(open(args.prose, encoding="utf-8")) if args.prose else {}
158+
html_out = render(cp, prose, args.internal)
159+
160+
out = args.out or os.path.join("dsgai-reports", "DSGAI-report.html")
161+
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
162+
with open(out, "w", encoding="utf-8", newline="\n") as fh:
163+
fh.write(html_out)
164+
if not args.internal:
165+
fmap = build_filemap(cp)
166+
with open(args.filemap, "w", encoding="utf-8", newline="\n") as fh:
167+
json.dump({v: k for k, v in fmap.items()}, fh, indent=2, sort_keys=True)
168+
print(f"wrote {out}")
169+
return 0
170+
171+
172+
if __name__ == "__main__":
173+
sys.exit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)