From 09d4876c83e3e696c671a35d2c35055ed2480280 Mon Sep 17 00:00:00 2001 From: Matt Partida Date: Mon, 4 May 2026 21:56:29 -0700 Subject: [PATCH] feat: add markdown config risk summaries --- README.md | 8 ++ .../scripts/config_risk_summary.py | 73 ++++++++++++++++++- tests/test_config_risk_summary.py | 20 +++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1acb966..3cbf9cb 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,14 @@ python3 skills/agent-security/scripts/config_risk_summary.py \ < examples/high-risk-agent-config.json ``` +Emit a Markdown summary for PR comments, issue updates, or human-readable reports: + +```bash +python3 skills/agent-security/scripts/config_risk_summary.py \ + --format markdown \ + < examples/high-risk-agent-config.json +``` + Score prompt-injection exposure from a config/status JSON object: ```bash diff --git a/skills/agent-security/scripts/config_risk_summary.py b/skills/agent-security/scripts/config_risk_summary.py index 957e04a..e795007 100644 --- a/skills/agent-security/scripts/config_risk_summary.py +++ b/skills/agent-security/scripts/config_risk_summary.py @@ -94,11 +94,79 @@ def truthy(value: Any) -> bool: return value is True or (isinstance(value, str) and value.lower() in {"true", "yes", "enabled", "on"}) +def markdown_cell(value: Any, *, code: bool = False) -> str: + if value is None: + return "" + if isinstance(value, (list, tuple)): + text = ", ".join(str(item) for item in value) + else: + text = str(value) + text = text.replace("\\", "\\\\").replace("|", "\\|").replace("\n", "
") + if code and text: + return f"`{text}`" + return text + + +def render_markdown(summary: dict[str, Any]) -> str: + lines = ["# Agent Security Config Risk Summary", ""] + if summary["ok"]: + lines.append("**Overall:** no high/critical findings") + else: + lines.append("**Overall:** high risk findings present") + lines.append(f"**Risk count:** {summary['risk_count']}") + lines.append("") + lines.append("## Severity counts") + lines.append("") + if summary["counts"]: + for severity in ("error", "critical", "high", "warn", "info"): + count = summary["counts"].get(severity) + if count: + lines.append(f"- **{severity}:** {count}") + else: + lines.append("- No findings") + lines.append("") + lines.append("## Findings") + lines.append("") + findings = summary["findings"] + if not findings: + lines.append("No findings.") + lines.append("") + return "\n".join(lines) + + lines.append("| Severity | Rule | Risk | Field | Recommendation |") + lines.append("| --- | --- | --- | --- | --- |") + for finding in findings: + field = finding.get("field") or finding.get("fields") or "" + recommendation = finding.get("recommendation") or finding.get("reason") or "" + details = [] + for key in ("agent", "index", "risk_class", "value", "expected"): + if key in finding: + details.append(f"{key}={finding[key]}") + if details: + recommendation = " — ".join(part for part in [str(recommendation), "; ".join(details)] if part) + lines.append( + "| " + + " | ".join( + [ + markdown_cell(finding.get("severity")), + markdown_cell(finding.get("rule_id")), + markdown_cell(finding.get("risk")), + markdown_cell(field, code=bool(field)), + markdown_cell(recommendation), + ] + ) + + " |" + ) + lines.append("") + return "\n".join(lines) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--strict", action="store_true", help="exit nonzero on error/high/critical findings") parser.add_argument("--fail-on", choices=["error", "critical", "high", "warn", "info"], default=None) parser.add_argument("--compact", action="store_true", help="emit compact JSON") + parser.add_argument("--format", choices=["json", "markdown"], default="json", help="output format (default: json)") args = parser.parse_args() cfg, initial_findings = load_json() @@ -221,7 +289,10 @@ def add(severity: str, risk: str, **extra: Any) -> None: } for f in findings: summary["counts"][f["severity"]] = summary["counts"].get(f["severity"], 0) + 1 - print(json.dumps(summary, separators=(",", ":") if args.compact else None, indent=None if args.compact else 2, sort_keys=True)) + if args.format == "markdown": + print(render_markdown(summary)) + else: + print(json.dumps(summary, separators=(",", ":") if args.compact else None, indent=None if args.compact else 2, sort_keys=True)) fail_on = args.fail_on or ("high" if args.strict else None) if fail_on: diff --git a/tests/test_config_risk_summary.py b/tests/test_config_risk_summary.py index 99f0ccf..5906042 100644 --- a/tests/test_config_risk_summary.py +++ b/tests/test_config_risk_summary.py @@ -67,3 +67,23 @@ def test_key_findings_include_stable_rule_ids(): assert findings_by_risk["persistence_available_in_untrusted_content_context"]["rule_id"] == "ASG-003" assert findings_by_risk["elevated_enabled_without_allowlist"]["rule_id"] == "ASG-004" assert findings_by_risk["exec_security_full"]["rule_id"] == "ASG-008" + + +def test_markdown_format_renders_summary_and_findings_table(): + payload = { + "browser": {"enabled": True, "ssrfPolicy": {"dangerouslyAllowPrivateNetwork": True}}, + "bindings": [{"agentId": "shared", "match": {"channel": "discord", "peer": {"kind": "channel"}}}], + } + proc = run_script(payload, "--format", "markdown") + assert proc.returncode == 0 + assert proc.stdout.startswith("# Agent Security Config Risk Summary\n") + assert "**Overall:** high risk findings present" in proc.stdout + assert "| Severity | Rule | Risk | Field | Recommendation |" in proc.stdout + assert "| high | ASG-002 | browser_private_network_allowed | `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` | |" in proc.stdout + assert "| critical | ASG-006 | shared_channel_with_private_network_browser | | |" in proc.stdout + + +def test_markdown_format_escapes_table_pipes(): + proc = run_script({"agents": {"list": [{"id": "agent|one", "tools": {"elevated": {"enabled": True}}}]}}, "--format", "markdown") + assert proc.returncode == 0 + assert "agent\\|one" in proc.stdout