Commit b94aafccf9
Unsigned
Layout: unified · split
.github/workflows/release.yml +2 −2
| @@ -11,11 +11,11 @@ jobs: | |||
| 11 | steps: | 11 | steps: |
| 12 | - uses: actions/checkout@v5 | 12 | - uses: actions/checkout@v5 |
| 13 | - name: Install uv | 13 | - name: Install uv |
| 14 | uses: astral-sh/setup-uv@v6 | 14 | uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 |
| 15 | - name: Build | 15 | - name: Build |
| 16 | run: uv build | 16 | run: uv build |
| 17 | - name: Check | 17 | - name: Check |
| 18 | run: uvx twine check dist/* | 18 | run: uvx twine@7.0.0 check dist/* |
| 19 | - uses: actions/upload-artifact@v4 | 19 | - uses: actions/upload-artifact@v4 |
| 20 | with: | 20 | with: |
| 21 | name: dist | 21 | name: dist |
audit_report/diff.py +54 −43
| @@ -185,6 +185,33 @@ def _md_table(rows: list[dict[str, str]]) -> list[str]: | |||
| 185 | return out | 185 | return out |
| 186 | 186 | ||
| 187 | 187 | ||
| 188 | def _render_delta_md(delta, category: str) -> list[str]: | ||
| 189 | rule = delta.rule | ||
| 190 | out = [ | ||
| 191 | f"### {rule.title}", | ||
| 192 | "", | ||
| 193 | f"- **Rule:** `{rule.id}` · **Severity:** {rule.severity}", | ||
| 194 | f"- **Controls:** {', '.join(rule.controls) or '—'}", | ||
| 195 | f"- **Change:** {_transition(delta)}", | ||
| 196 | ] | ||
| 197 | if delta.new_reason: | ||
| 198 | out.append(f"- **Now:** {delta.new_reason}") | ||
| 199 | if category == REGRESSED and rule.remediation: | ||
| 200 | out.append(f"- **Remediation:** {rule.remediation.strip()}") | ||
| 201 | out.append("") | ||
| 202 | if delta.evidence_added: | ||
| 203 | out.append("**Newly failing rows:**") | ||
| 204 | out.append("") | ||
| 205 | out.extend(_md_table(delta.evidence_added)) | ||
| 206 | out.append("") | ||
| 207 | if delta.evidence_removed: | ||
| 208 | out.append("**No longer failing rows:**") | ||
| 209 | out.append("") | ||
| 210 | out.extend(_md_table(delta.evidence_removed)) | ||
| 211 | out.append("") | ||
| 212 | return out | ||
| 213 | |||
| 214 | |||
| 188 | def _render_md(diff: DiffReport) -> str: | 215 | def _render_md(diff: DiffReport) -> str: |
| 189 | counts = diff.counts | 216 | counts = diff.counts |
| 190 | out: list[str] = [] | 217 | out: list[str] = [] |
| @@ -213,27 +240,7 @@ def _render_md(diff: DiffReport) -> str: | |||
| 213 | out.append(f"## {_CATEGORY_HEADING[category]}") | 240 | out.append(f"## {_CATEGORY_HEADING[category]}") |
| 214 | out.append("") | 241 | out.append("") |
| 215 | for delta in rows: | 242 | for delta in rows: |
| 216 | rule = delta.rule | 243 | out.extend(_render_delta_md(delta, category)) |
| 217 | out.append(f"### {rule.title}") | ||
| 218 | out.append("") | ||
| 219 | out.append(f"- **Rule:** `{rule.id}` · **Severity:** {rule.severity}") | ||
| 220 | out.append(f"- **Controls:** {', '.join(rule.controls) or '—'}") | ||
| 221 | out.append(f"- **Change:** {_transition(delta)}") | ||
| 222 | if delta.new_reason: | ||
| 223 | out.append(f"- **Now:** {delta.new_reason}") | ||
| 224 | if category == REGRESSED and rule.remediation: | ||
| 225 | out.append(f"- **Remediation:** {rule.remediation.strip()}") | ||
| 226 | out.append("") | ||
| 227 | if delta.evidence_added: | ||
| 228 | out.append("**Newly failing rows:**") | ||
| 229 | out.append("") | ||
| 230 | out.extend(_md_table(delta.evidence_added)) | ||
| 231 | out.append("") | ||
| 232 | if delta.evidence_removed: | ||
| 233 | out.append("**No longer failing rows:**") | ||
| 234 | out.append("") | ||
| 235 | out.extend(_md_table(delta.evidence_removed)) | ||
| 236 | out.append("") | ||
| 237 | 244 | ||
| 238 | unchanged = diff.counts[UNCHANGED] | 245 | unchanged = diff.counts[UNCHANGED] |
| 239 | if unchanged: | 246 | if unchanged: |
| @@ -275,6 +282,31 @@ table.added caption { color: #c1272d; } table.removed caption { color: #1a7f37; | |||
| 275 | ) | 282 | ) |
| 276 | 283 | ||
| 277 | 284 | ||
| 285 | def _render_delta_html(delta, category: str) -> str: | ||
| 286 | rule = delta.rule | ||
| 287 | body = [ | ||
| 288 | f"<h3>{escape(rule.title)}</h3>", | ||
| 289 | ( | ||
| 290 | f"<dl><dt>Rule</dt><dd><code>{escape(rule.id)}</code> · " | ||
| 291 | f"{escape(rule.severity)}</dd>" | ||
| 292 | ), | ||
| 293 | f"<dt>Controls</dt><dd>{escape(', '.join(rule.controls) or '—')}</dd>", | ||
| 294 | f"<dt>Change</dt><dd class='transition'>{escape(_transition(delta))}</dd>", | ||
| 295 | ] | ||
| 296 | if delta.new_reason: | ||
| 297 | body.append(f"<dt>Now</dt><dd>{escape(delta.new_reason)}</dd>") | ||
| 298 | if category == REGRESSED and rule.remediation: | ||
| 299 | body.append(f"<dt>Remediation</dt><dd>{escape(rule.remediation.strip())}</dd>") | ||
| 300 | body.append("</dl>") | ||
| 301 | if delta.evidence_added: | ||
| 302 | body.append(_html_table(delta.evidence_added, "Newly failing rows", "added")) | ||
| 303 | if delta.evidence_removed: | ||
| 304 | body.append( | ||
| 305 | _html_table(delta.evidence_removed, "No longer failing rows", "removed") | ||
| 306 | ) | ||
| 307 | return f"<div class='delta {category}'>{''.join(body)}</div>" | ||
| 308 | |||
| 309 | |||
| 278 | def _render_html(diff: DiffReport) -> str: | 310 | def _render_html(diff: DiffReport) -> str: |
| 279 | counts = diff.counts | 311 | counts = diff.counts |
| 280 | parts: list[str] = [] | 312 | parts: list[str] = [] |
| @@ -307,28 +339,7 @@ def _render_html(diff: DiffReport) -> str: | |||
| 307 | continue | 339 | continue |
| 308 | parts.append(f"<h2>{escape(_CATEGORY_HEADING[category])}</h2>") | 340 | parts.append(f"<h2>{escape(_CATEGORY_HEADING[category])}</h2>") |
| 309 | for delta in rows: | 341 | for delta in rows: |
| 310 | rule = delta.rule | 342 | parts.append(_render_delta_html(delta, category)) |
| 311 | body = [ | ||
| 312 | f"<h3>{escape(rule.title)}</h3>", | ||
| 313 | ( | ||
| 314 | f"<dl><dt>Rule</dt><dd><code>{escape(rule.id)}</code> · " | ||
| 315 | f"{escape(rule.severity)}</dd>" | ||
| 316 | ), | ||
| 317 | f"<dt>Controls</dt><dd>{escape(', '.join(rule.controls) or '—')}</dd>", | ||
| 318 | f"<dt>Change</dt><dd class='transition'>{escape(_transition(delta))}</dd>", | ||
| 319 | ] | ||
| 320 | if delta.new_reason: | ||
| 321 | body.append(f"<dt>Now</dt><dd>{escape(delta.new_reason)}</dd>") | ||
| 322 | if category == REGRESSED and rule.remediation: | ||
| 323 | body.append(f"<dt>Remediation</dt><dd>{escape(rule.remediation.strip())}</dd>") | ||
| 324 | body.append("</dl>") | ||
| 325 | if delta.evidence_added: | ||
| 326 | body.append(_html_table(delta.evidence_added, "Newly failing rows", "added")) | ||
| 327 | if delta.evidence_removed: | ||
| 328 | body.append( | ||
| 329 | _html_table(delta.evidence_removed, "No longer failing rows", "removed") | ||
| 330 | ) | ||
| 331 | parts.append(f"<div class='delta {category}'>{''.join(body)}</div>") | ||
| 332 | 343 | ||
| 333 | if counts[UNCHANGED]: | 344 | if counts[UNCHANGED]: |
| 334 | parts.append(f"<p class='meta'>{counts[UNCHANGED]} rule(s) unchanged.</p>") | 345 | parts.append(f"<p class='meta'>{counts[UNCHANGED]} rule(s) unchanged.</p>") |
audit_report/rules.py +29 −22
| @@ -71,19 +71,19 @@ def _as_number(value: str) -> float | None: | |||
| 71 | return None | 71 | return None |
| 72 | 72 | ||
| 73 | 73 | ||
| 74 | def match(condition: dict, row: dict[str, str]) -> bool: | 74 | def _compare_numeric(op: str, raw: str, value) -> bool: |
| 75 | """Return True if *condition* holds for *row*. | 75 | left, right = _as_number(raw), _as_number(str(value)) |
| 76 | 76 | if left is None or right is None: | |
| 77 | Raises ``ValueError`` on a malformed condition so ruleset bugs surface | 77 | return False |
| 78 | loudly rather than silently evaluating to False. | 78 | return { |
| 79 | """ | 79 | "gt": left > right, |
| 80 | if "all" in condition: | 80 | "gte": left >= right, |
| 81 | return all(match(c, row) for c in condition["all"]) | 81 | "lt": left < right, |
| 82 | if "any" in condition: | 82 | "lte": left <= right, |
| 83 | return any(match(c, row) for c in condition["any"]) | 83 | }[op] |
| 84 | if "not" in condition: | 84 | |
| 85 | return not match(condition["not"], row) | 85 | |
| 86 | 86 | def _match_leaf(condition: dict, row: dict[str, str]) -> bool: | |
| 87 | column = condition.get("column") | 87 | column = condition.get("column") |
| 88 | op = condition.get("op") | 88 | op = condition.get("op") |
| 89 | if column is None or op is None: | 89 | if column is None or op is None: |
| @@ -109,19 +109,26 @@ def match(condition: dict, row: dict[str, str]) -> bool: | |||
| 109 | choices = {str(v).strip().lower() for v in (value or [])} | 109 | choices = {str(v).strip().lower() for v in (value or [])} |
| 110 | return (norm in choices) if op == "in" else (norm not in choices) | 110 | return (norm in choices) if op == "in" else (norm not in choices) |
| 111 | if op in ("gt", "gte", "lt", "lte"): | 111 | if op in ("gt", "gte", "lt", "lte"): |
| 112 | left, right = _as_number(raw), _as_number(str(value)) | 112 | return _compare_numeric(op, raw, value) |
| 113 | if left is None or right is None: | ||
| 114 | return False | ||
| 115 | return { | ||
| 116 | "gt": left > right, | ||
| 117 | "gte": left >= right, | ||
| 118 | "lt": left < right, | ||
| 119 | "lte": left <= right, | ||
| 120 | }[op] | ||
| 121 | 113 | ||
| 122 | raise ValueError(f"unknown operator: {op!r}") | 114 | raise ValueError(f"unknown operator: {op!r}") |
| 123 | 115 | ||
| 124 | 116 | ||
| 117 | def match(condition: dict, row: dict[str, str]) -> bool: | ||
| 118 | """Return True if *condition* holds for *row*. | ||
| 119 | |||
| 120 | Raises ``ValueError`` on a malformed condition so ruleset bugs surface | ||
| 121 | loudly rather than silently evaluating to False. | ||
| 122 | """ | ||
| 123 | if "all" in condition: | ||
| 124 | return all(match(c, row) for c in condition["all"]) | ||
| 125 | if "any" in condition: | ||
| 126 | return any(match(c, row) for c in condition["any"]) | ||
| 127 | if "not" in condition: | ||
| 128 | return not match(condition["not"], row) | ||
| 129 | return _match_leaf(condition, row) | ||
| 130 | |||
| 131 | |||
| 125 | def load_ruleset(path: str | Path) -> Ruleset: | 132 | def load_ruleset(path: str | Path) -> Ruleset: |
| 126 | """Parse a ruleset YAML file into a :class:`Ruleset`, validating each rule.""" | 133 | """Parse a ruleset YAML file into a :class:`Ruleset`, validating each rule.""" |
| 127 | text = Path(path).read_text(encoding="utf-8") | 134 | text = Path(path).read_text(encoding="utf-8") |
tests/test_diff.py +2 −1
| @@ -67,7 +67,8 @@ def test_diff_render_markdown(): | |||
| 67 | def test_diff_render_html_self_contained(): | 67 | def test_diff_render_html_self_contained(): |
| 68 | html = diff.render(_build(), "html") | 68 | html = diff.render(_build(), "html") |
| 69 | assert html.startswith("<!doctype html>") | 69 | assert html.startswith("<!doctype html>") |
| 70 | assert "http://" not in html and "https://" not in html | 70 | assert "http://" not in html |
| 71 | assert "https://" not in html | ||
| 71 | assert "Evidence Drift" in html | 72 | assert "Evidence Drift" in html |
| 72 | 73 | ||
| 73 | 74 | ||
tests/test_reporters.py +4 −2
| @@ -35,7 +35,8 @@ def test_html_render_is_self_contained(): | |||
| 35 | assert html.startswith("<!doctype html>") | 35 | assert html.startswith("<!doctype html>") |
| 36 | assert "<style>" in html | 36 | assert "<style>" in html |
| 37 | # No external resource references. | 37 | # No external resource references. |
| 38 | assert "http://" not in html and "https://" not in html | 38 | assert "http://" not in html |
| 39 | assert "https://" not in html | ||
| 39 | assert "src=" not in html | 40 | assert "src=" not in html |
| 40 | 41 | ||
| 41 | 42 | ||
| @@ -79,8 +80,9 @@ def test_html_escapes_evidence(tmp_path): | |||
| 79 | 80 | ||
| 80 | 81 | ||
| 81 | def test_unknown_format_raises(): | 82 | def test_unknown_format_raises(): |
| 83 | report = _report() | ||
| 82 | with pytest.raises(ValueError, match="unknown format"): | 84 | with pytest.raises(ValueError, match="unknown format"): |
| 83 | reporters.render(_report(), "pdf") | 85 | reporters.render(report, "pdf") |
| 84 | 86 | ||
| 85 | 87 | ||
| 86 | def test_cli_writes_files_and_exit_code(tmp_path): | 88 | def test_cli_writes_files_and_exit_code(tmp_path): |
tests/test_trend.py +2 −1
| @@ -83,7 +83,8 @@ def test_trend_render_markdown(): | |||
| 83 | def test_trend_render_html_self_contained(): | 83 | def test_trend_render_html_self_contained(): |
| 84 | html = trend.render(_build(), "html") | 84 | html = trend.render(_build(), "html") |
| 85 | assert html.startswith("<!doctype html>") | 85 | assert html.startswith("<!doctype html>") |
| 86 | assert "http://" not in html and "https://" not in html | 86 | assert "http://" not in html |
| 87 | assert "https://" not in html | ||
| 87 | assert "class='trend'" in html | 88 | assert "class='trend'" in html |
| 88 | 89 | ||
| 89 | 90 | ||