diff --git a/agentprof/report/runner.py b/agentprof/report/runner.py index 0f7caf6..6b5c703 100644 --- a/agentprof/report/runner.py +++ b/agentprof/report/runner.py @@ -23,6 +23,9 @@ DEFAULT_REPORT_DIR = APP_DIR / "reports" REPORT_ID_MAX_LENGTH = 128 REPORT_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") +MARKDOWN_ESCAPE_RE = re.compile(r"([\\`*\[\]()!|])") +MARKDOWN_AUTO_LINK_RE = re.compile(r"(?i)\b((?:https?|ftp)://)") +MARKDOWN_WWW_LINK_RE = re.compile(r"(?i)\bwww\.") MULTI_AGENT_WASTE_KIND = "multi_agent_waste" @@ -217,10 +220,10 @@ def _cost_to_json(record: CostLedgerRecord) -> dict[str, Any]: def _markdown_report(payload: dict[str, Any]) -> str: summary = payload["summary"] lines = [ - f"# AgentProf Report: {payload['project']}", + f"# AgentProf Report: {_markdown_text(payload['project'])}", "", - f"Report ID: `{payload['report_id']}`", - f"Generated at: `{payload['generated_at']}`", + f"Report ID: {_markdown_inline_code(payload['report_id'])}", + f"Generated at: {_markdown_inline_code(payload['generated_at'])}", "", "## Summary", "", @@ -241,7 +244,7 @@ def _markdown_report(payload: dict[str, Any]) -> str: [ "## Visuals", "", - f"![Multi-agent waste estimate]({multi_agent_svg})", + f"![Multi-agent waste estimate]({_markdown_link_target(multi_agent_svg)})", "", ] ) @@ -266,10 +269,10 @@ def _markdown_report(payload: dict[str, Any]) -> str: for record in payload["cost_ledger"]: lines.append( "| " - f"{record['cost_type']} | " + f"{_markdown_table_cell(record['cost_type'])} | " f"{_format_usd(record['amount_usd'])} | " - f"{record['attribution_method']} | " - f"{record['issue_id'] or ''} |" + f"{_markdown_table_cell(record['attribution_method'])} | " + f"{_markdown_table_cell(record['issue_id'] or '')} |" ) lines.append("") @@ -278,23 +281,23 @@ def _markdown_report(payload: dict[str, Any]) -> str: def _markdown_issue(issue: dict[str, Any]) -> list[str]: lines = [ - f"### {issue['title']}", + f"### {_markdown_text(issue['title'])}", "", - f"- Issue ID: `{issue['issue_id']}`", - f"- Kind: `{issue['kind']}`", - f"- Severity: `{issue['severity']}`", - f"- Confidence: `{issue['confidence']}`", + f"- Issue ID: {_markdown_inline_code(issue['issue_id'])}", + f"- Kind: {_markdown_inline_code(issue['kind'])}", + f"- Severity: {_markdown_inline_code(issue['severity'])}", + f"- Confidence: {_markdown_inline_code(issue['confidence'])}", f"- Affected traces: {issue['affected_traces']}", f"- Affected spans: {issue['affected_spans']}", f"- Wasted cost: {_format_usd(issue['wasted_cost_usd'])}", f"- Potential savings: {_format_usd(issue['potential_savings_usd'])}", "", - f"Recommendation: {issue['recommendation']}", + f"Recommendation: {_markdown_text(issue['recommendation'])}", "", ] if issue["recommended_tests"]: lines.extend(["Recommended tests:", ""]) - lines.extend(f"- {test}" for test in issue["recommended_tests"]) + lines.extend(f"- {_markdown_text(test)}" for test in issue["recommended_tests"]) lines.append("") if issue["evidence"]: lines.extend(["Evidence:", ""]) @@ -302,7 +305,10 @@ def _markdown_issue(issue: dict[str, Any]) -> list[str]: location = ":".join( part for part in (item["trace_id"], item["span_id"]) if part ) - lines.append(f"- `{location}` {item['message']}") + lines.append( + f"- {_markdown_inline_code(location or 'unknown')} " + f"{_markdown_text(item['message'])}" + ) lines.append("") return lines @@ -444,6 +450,53 @@ def _agent_label(visual: dict[str, Any]) -> str: return str(count) +def _markdown_text(value: Any) -> str: + return _escape_markdown(_single_line(value)) + + +def _markdown_table_cell(value: Any) -> str: + return _markdown_text(value) + + +def _markdown_inline_code(value: Any) -> str: + text = _single_line(value) + max_backticks = max( + (len(match.group(0)) for match in re.finditer(r"`+", text)), + default=0, + ) + marker = "`" * (max_backticks + 1) + padding = " " if text.startswith("`") or text.endswith("`") else "" + return f"{marker}{padding}{text}{padding}{marker}" + + +def _markdown_link_target(value: Any) -> str: + text = _single_line(value) + return ( + text.replace("\\", "%5C") + .replace(" ", "%20") + .replace("(", "%28") + .replace(")", "%29") + ) + + +def _escape_markdown(value: str) -> str: + text = escape(value, quote=False) + text = MARKDOWN_ESCAPE_RE.sub(r"\\\1", text) + text = MARKDOWN_AUTO_LINK_RE.sub( + lambda match: match.group(1).replace(":", ":"), + text, + ) + text = MARKDOWN_WWW_LINK_RE.sub( + lambda match: match.group(0).replace(".", "."), + text, + ) + return text + + +def _single_line(value: Any) -> str: + return "" if value is None else " ".join(str(value).split()) + + def _default_report_id(generated_at: datetime) -> str: return f"agentprof-{generated_at.astimezone(UTC).strftime('%Y%m%dT%H%M%S%fZ')}" diff --git a/tests/test_report.py b/tests/test_report.py index 6a08b73..6a76b98 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -230,6 +230,37 @@ def test_generate_report_handles_empty_analysis_results(tmp_path: Path) -> None: assert store.stats()["reports"] == 1 +def test_generate_report_escapes_markdown_injection(tmp_path: Path) -> None: + store = DuckDBStore(tmp_path / "agentprof.duckdb") + _seed_markdown_injection_issue(store) + + result = generate_report( + store, + project="Tracer [link](http://project.example)", + output_dir=tmp_path / "reports", + report_id="escaped-report", + generated_at=_dt("2026-04-26T12:00:00+00:00"), + ) + markdown = result.report_md_path.read_text(encoding="utf-8") + + assert "" not in markdown + assert " None: store = DuckDBStore(tmp_path / "agentprof.duckdb") @@ -606,5 +637,48 @@ def _seed_multi_agent_issue(store: DuckDBStore) -> None: ) +def _seed_markdown_injection_issue(store: DuckDBStore) -> None: + issue = IssueRecord( + issue_id="retry_loop:evil|id", + kind="retry_loop", + title="Pwn](http://evil.example) ", + severity="medium", + confidence="high", + first_seen=_dt("2026-04-26T10:00:00+00:00"), + last_seen=_dt("2026-04-26T10:00:02+00:00"), + affected_traces=1, + affected_spans=1, + total_cost_usd=Decimal("0.030"), + wasted_cost_usd=Decimal("0.020"), + potential_savings_usd=Decimal("0.020"), + recommendation="![](http://attacker.example/leak)\n[evil]: http://evil.example", + recommended_tests=["[click me](https://test.example) www.test.example `danger`"], + ) + evidence = IssueEvidenceRecord( + issue_id=issue.issue_id, + trace_id="trace`id", + span_id="span`id", + evidence_type="retry_attempt", + message="`code` ![](http://attacker.example/img.png)\n[ref]: http://evil.example ", + attributes={}, + ) + cost = CostLedgerRecord( + trace_id="trace`id", + span_id="span`id", + issue_id=issue.issue_id, + cost_type="wasted|retry\n[ref]: http://evil.example", + amount_usd=Decimal("0.020"), + attribution_method="retry_loop](http://evil.example)", + confidence="source", + ) + store.replace_analysis_results( + issue_kind="retry_loop", + attribution_method="retry_loop](http://evil.example)", + issues=[issue], + evidence=[evidence], + cost_records=[cost], + ) + + def _dt(value: str) -> datetime: return datetime.fromisoformat(value).astimezone(UTC)