audit-labs/audit-report
Turn audit-tools evidence packages into control-mapped, auditor-ready reports.
clone: git clone https://gitbay.org/audit-labs/audit-report.git
b94aafccf922b8730bd7e5bad28d49dc8408b794
unsigned
author: Christian Cleberg <hello@cleberg.net> · 2026-08-09T01:32:54Z
.github/workflows/release.yml | 4 +- audit_report/diff.py | 97 ++++++++++++++++++++++++------------------- audit_report/rules.py | 51 +++++++++++++---------- tests/test_diff.py | 3 +- tests/test_reporters.py | 6 ++- tests/test_trend.py | 3 +- 6 files changed, 93 insertions(+), 71 deletions(-) @@ -11,11 +11,11 @@ jobs: steps: - uses: actions/checkout@v5 - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 - name: Build run: uv build - name: Check - run: uvx twine check dist/* + run: uvx twine@7.0.0 check dist/* - uses: actions/upload-artifact@v4 with: name: dist @@ -185,6 +185,33 @@ def _md_table(rows: list[dict[str, str]]) -> list[str]: return out +def _render_delta_md(delta, category: str) -> list[str]: + rule = delta.rule + out = [ + f"### {rule.title}", + "", + f"- **Rule:** `{rule.id}` · **Severity:** {rule.severity}", + f"- **Controls:** {', '.join(rule.controls) or '—'}", + f"- **Change:** {_transition(delta)}", + ] + if delta.new_reason: + out.append(f"- **Now:** {delta.new_reason}") + if category == REGRESSED and rule.remediation: + out.append(f"- **Remediation:** {rule.remediation.strip()}") + out.append("") + if delta.evidence_added: + out.append("**Newly failing rows:**") + out.append("") + out.extend(_md_table(delta.evidence_added)) + out.append("") + if delta.evidence_removed: + out.append("**No longer failing rows:**") + out.append("") + out.extend(_md_table(delta.evidence_removed)) + out.append("") + return out + + def _render_md(diff: DiffReport) -> str: counts = diff.counts out: list[str] = [] @@ -213,27 +240,7 @@ def _render_md(diff: DiffReport) -> str: out.append(f"## {_CATEGORY_HEADING[category]}") out.append("") for delta in rows: - rule = delta.rule - out.append(f"### {rule.title}") - out.append("") - out.append(f"- **Rule:** `{rule.id}` · **Severity:** {rule.severity}") - out.append(f"- **Controls:** {', '.join(rule.controls) or '—'}") - out.append(f"- **Change:** {_transition(delta)}") - if delta.new_reason: - out.append(f"- **Now:** {delta.new_reason}") - if category == REGRESSED and rule.remediation: - out.append(f"- **Remediation:** {rule.remediation.strip()}") - out.append("") - if delta.evidence_added: - out.append("**Newly failing rows:**") - out.append("") - out.extend(_md_table(delta.evidence_added)) - out.append("") - if delta.evidence_removed: - out.append("**No longer failing rows:**") - out.append("") - out.extend(_md_table(delta.evidence_removed)) - out.append("") + out.extend(_render_delta_md(delta, category)) unchanged = diff.counts[UNCHANGED] if unchanged: @@ -275,6 +282,31 @@ table.added caption { color: #c1272d; } table.removed caption { color: #1a7f37; ) +def _render_delta_html(delta, category: str) -> str: + rule = delta.rule + body = [ + f"<h3>{escape(rule.title)}</h3>", + ( + f"<dl><dt>Rule</dt><dd><code>{escape(rule.id)}</code> · " + f"{escape(rule.severity)}</dd>" + ), + f"<dt>Controls</dt><dd>{escape(', '.join(rule.controls) or '—')}</dd>", + f"<dt>Change</dt><dd class='transition'>{escape(_transition(delta))}</dd>", + ] + if delta.new_reason: + body.append(f"<dt>Now</dt><dd>{escape(delta.new_reason)}</dd>") + if category == REGRESSED and rule.remediation: + body.append(f"<dt>Remediation</dt><dd>{escape(rule.remediation.strip())}</dd>") + body.append("</dl>") + if delta.evidence_added: + body.append(_html_table(delta.evidence_added, "Newly failing rows", "added")) + if delta.evidence_removed: + body.append( + _html_table(delta.evidence_removed, "No longer failing rows", "removed") + ) + return f"<div class='delta {category}'>{''.join(body)}</div>" + + def _render_html(diff: DiffReport) -> str: counts = diff.counts parts: list[str] = [] @@ -307,28 +339,7 @@ def _render_html(diff: DiffReport) -> str: continue parts.append(f"<h2>{escape(_CATEGORY_HEADING[category])}</h2>") for delta in rows: - rule = delta.rule - body = [ - f"<h3>{escape(rule.title)}</h3>", - ( - f"<dl><dt>Rule</dt><dd><code>{escape(rule.id)}</code> · " - f"{escape(rule.severity)}</dd>" - ), - f"<dt>Controls</dt><dd>{escape(', '.join(rule.controls) or '—')}</dd>", - f"<dt>Change</dt><dd class='transition'>{escape(_transition(delta))}</dd>", - ] - if delta.new_reason: - body.append(f"<dt>Now</dt><dd>{escape(delta.new_reason)}</dd>") - if category == REGRESSED and rule.remediation: - body.append(f"<dt>Remediation</dt><dd>{escape(rule.remediation.strip())}</dd>") - body.append("</dl>") - if delta.evidence_added: - body.append(_html_table(delta.evidence_added, "Newly failing rows", "added")) - if delta.evidence_removed: - body.append( - _html_table(delta.evidence_removed, "No longer failing rows", "removed") - ) - parts.append(f"<div class='delta {category}'>{''.join(body)}</div>") + parts.append(_render_delta_html(delta, category)) if counts[UNCHANGED]: parts.append(f"<p class='meta'>{counts[UNCHANGED]} rule(s) unchanged.</p>") @@ -71,19 +71,19 @@ def _as_number(value: str) -> float | None: return None -def match(condition: dict, row: dict[str, str]) -> bool: - """Return True if *condition* holds for *row*. - - Raises ``ValueError`` on a malformed condition so ruleset bugs surface - loudly rather than silently evaluating to False. - """ - if "all" in condition: - return all(match(c, row) for c in condition["all"]) - if "any" in condition: - return any(match(c, row) for c in condition["any"]) - if "not" in condition: - return not match(condition["not"], row) - +def _compare_numeric(op: str, raw: str, value) -> bool: + left, right = _as_number(raw), _as_number(str(value)) + if left is None or right is None: + return False + return { + "gt": left > right, + "gte": left >= right, + "lt": left < right, + "lte": left <= right, + }[op] + + +def _match_leaf(condition: dict, row: dict[str, str]) -> bool: column = condition.get("column") op = condition.get("op") if column is None or op is None: @@ -109,19 +109,26 @@ def match(condition: dict, row: dict[str, str]) -> bool: choices = {str(v).strip().lower() for v in (value or [])} return (norm in choices) if op == "in" else (norm not in choices) if op in ("gt", "gte", "lt", "lte"): - left, right = _as_number(raw), _as_number(str(value)) - if left is None or right is None: - return False - return { - "gt": left > right, - "gte": left >= right, - "lt": left < right, - "lte": left <= right, - }[op] + return _compare_numeric(op, raw, value) raise ValueError(f"unknown operator: {op!r}") +def match(condition: dict, row: dict[str, str]) -> bool: + """Return True if *condition* holds for *row*. + + Raises ``ValueError`` on a malformed condition so ruleset bugs surface + loudly rather than silently evaluating to False. + """ + if "all" in condition: + return all(match(c, row) for c in condition["all"]) + if "any" in condition: + return any(match(c, row) for c in condition["any"]) + if "not" in condition: + return not match(condition["not"], row) + return _match_leaf(condition, row) + + def load_ruleset(path: str | Path) -> Ruleset: """Parse a ruleset YAML file into a :class:`Ruleset`, validating each rule.""" text = Path(path).read_text(encoding="utf-8") @@ -67,7 +67,8 @@ def test_diff_render_markdown(): def test_diff_render_html_self_contained(): html = diff.render(_build(), "html") assert html.startswith("<!doctype html>") - assert "http://" not in html and "https://" not in html + assert "http://" not in html + assert "https://" not in html assert "Evidence Drift" in html @@ -35,7 +35,8 @@ def test_html_render_is_self_contained(): assert html.startswith("<!doctype html>") assert "<style>" in html # No external resource references. - assert "http://" not in html and "https://" not in html + assert "http://" not in html + assert "https://" not in html assert "src=" not in html @@ -79,8 +80,9 @@ def test_html_escapes_evidence(tmp_path): def test_unknown_format_raises(): + report = _report() with pytest.raises(ValueError, match="unknown format"): - reporters.render(_report(), "pdf") + reporters.render(report, "pdf") def test_cli_writes_files_and_exit_code(tmp_path): @@ -83,7 +83,8 @@ def test_trend_render_markdown(): def test_trend_render_html_self_contained(): html = trend.render(_build(), "html") assert html.startswith("<!doctype html>") - assert "http://" not in html and "https://" not in html + assert "http://" not in html + assert "https://" not in html assert "class='trend'" in html