diff --git a/scripts/audit_c5_benchmark_evidence.py b/scripts/audit_c5_benchmark_evidence.py index a1842a678..ee09185f7 100644 --- a/scripts/audit_c5_benchmark_evidence.py +++ b/scripts/audit_c5_benchmark_evidence.py @@ -22,6 +22,7 @@ from __future__ import annotations import argparse import errno import hashlib +from html import escape as html_escape import json import os import posixpath @@ -1413,15 +1414,47 @@ def audit( # Markdown summary # --------------------------------------------------------------------------- +def _markdown_display_value(value: Any) -> str: + """Escape untrusted values without creating Markdown rows or HTML elements.""" + raw = str(value).encode("utf-8", errors="backslashreplace").decode("utf-8") + safe = [] + for char in raw: + if char == "\n": + safe.append(r"\n") + elif char == "\r": + safe.append(r"\r") + elif char == "\t": + safe.append(r"\t") + elif ord(char) < 32 or ord(char) == 127: + safe.append(f"\\x{ord(char):02x}") + elif char in ("\u0085", "\u2028", "\u2029"): + safe.append(f"\\u{ord(char):04x}") + else: + safe.append(char) + # HTML entities are decoded after Markdown table splitting, preserving columns. + return html_escape("".join(safe), quote=False).replace("|", "|") + + +def _markdown_code(value: Any) -> str: + return f"{_markdown_display_value(value)}" + + +def _markdown_text(value: Any) -> str: + text = _markdown_display_value(value) + for char in ("`", "*", "_", "[", "]", "!"): + text = text.replace(char, f"&#{ord(char)};") + return text + + def _markdown_summary(result: dict[str, Any]) -> str: lines: list[str] = [] lines.append("# Benchmark Evidence Audit Summary") lines.append("") - lines.append(f"- **Campaign**: `{result.get('campaign_id', 'unknown')}`") + lines.append(f"- **Campaign**: {_markdown_code(result.get('campaign_id', 'unknown'))}") lines.append(f"- **Audited at**: {result.get('audited_at', '')}") if result.get("timestamp_kind") == "deterministic_placeholder": lines.append("- **Timestamp note**: Fixed reproducibility placeholder (1970-01-01T00:00:00Z)") - lines.append(f"- **Root**: `{result.get('root', '')}`") + lines.append(f"- **Root**: {_markdown_code(result.get('root', ''))}") lines.append(f"- **Manifest valid**: {result.get('manifest_valid', False)}") lines.append("") @@ -1440,7 +1473,7 @@ def _markdown_summary(result: dict[str, Any]) -> str: lines.append("## Errors") lines.append("") for e in errors: - lines.append(f"- {e}") + lines.append(f"- {_markdown_text(e)}") lines.append("") arts = result.get("artifacts", []) @@ -1451,12 +1484,12 @@ def _markdown_summary(result: dict[str, Any]) -> str: lines.append("|---|---|---|---|---|") for a in arts: path = a.get("path", "") - display_path = str(path).encode("utf-8", errors="backslashreplace").decode("utf-8") + display_path = _markdown_code(path) atype = a.get("type") or "" status = a.get("status", "") present = "yes" if a.get("present") else ("no" if a.get("present") is False else "?") size = str(a.get("size_bytes")) if a.get("size_bytes") is not None else "-" - lines.append(f"| `{display_path}` | {atype} | **{status}** | {present} | {size} |") + lines.append(f"| {display_path} | {_markdown_text(atype)} | **{status}** | {present} | {size} |") lines.append("") prereqs = result.get("reproducibility_prerequisites", {}) @@ -1472,7 +1505,7 @@ def _markdown_summary(result: dict[str, Any]) -> str: es_status = det.get("expected_set_status", "UNKNOWN") comp = "yes" if det.get("prerequisite_complete") else "no" notes = det.get("notes") or "" - lines.append(f"| {k} | **{v}** | {integrity} | {es_status} | {comp} | {notes} |") + lines.append(f"| {k} | **{v}** | {integrity} | {es_status} | {comp} | {_markdown_text(notes)} |") lines.append("") return "\n".join(lines) diff --git a/tests/test_audit_c5_benchmark_evidence.py b/tests/test_audit_c5_benchmark_evidence.py index 0f970ce21..2f70ed117 100644 --- a/tests/test_audit_c5_benchmark_evidence.py +++ b/tests/test_audit_c5_benchmark_evidence.py @@ -2618,5 +2618,85 @@ class TestCliDiagnosticConfidentiality(unittest.TestCase): self.assertNotIn("Traceback", stderr.getvalue()) +# --------------------------------------------------------------------------- +# Markdown report integrity: untrusted values must remain within their cells. +# --------------------------------------------------------------------------- + +class TestMarkdownReportIntegrity(unittest.TestCase): + def test_newline_and_pipe_in_missing_path_cannot_forge_verified_row(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + bad_name = "not-found\n| apparent-success | result_record | **VERIFIED** | yes | 20 |" + manifest = root / "manifest.json" + manifest.write_text(json.dumps(_manifest([{ + "path": bad_name, "type": "result_record" + }]))) + result = auditor.audit(str(manifest), str(root), deterministic=True) + self.assertEqual(result["artifacts"][0]["status"], auditor.MISSING) + summary = auditor._markdown_summary(result) + artifact_section = summary.split("## Artifacts\n", 1)[1].split("## Reproducibility Prerequisites", 1)[0] + rows = [row for row in artifact_section.splitlines() if row.startswith("|")] + self.assertEqual(len(rows), 3) + self.assertNotIn("\n| apparent-success", summary) + self.assertIn("| apparent-success", summary) + self.assertIn("**MISSING**", summary) + + def test_code_delimiters_and_html_are_escaped(self): + value = "bad`|name" + safe = auditor._markdown_code(value) + self.assertTrue(safe.startswith("")) + self.assertTrue(safe.endswith("")) + self.assertNotIn("