Skip to content

Commit 0267255

Browse files
committed
Add MADBugs-style RCE evidence checks
1 parent b584df9 commit 0267255

4 files changed

Lines changed: 178 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ It is intentionally **not** a full static analyzer. It answers the practical pre
1414
- The report has a PoC/repro indicator.
1515
- Dangerous capability terms exist in the repo.
1616
- Agent/tool context is detected so intended functionality is not mislabeled as RCE.
17+
- Critical RCE reports include MADBugs-style proof context: affected/tested version, attack surface, root cause, exploit chain, safe PoC result/cleanup, and fix guidance.
1718
- Optional simple GitHub issue/PR duplicate search via `gh`.
1819

1920
## Install locally
@@ -66,6 +67,8 @@ Every report should answer:
6667
5. What asset or user is harmed?
6768
6. What proof shows this works on current HEAD?
6869

70+
For Critical/High RCE claims, `finding-vetter` also looks for the compact evidence pattern that showed up repeatedly in MADBugs writeups: affected/tested version, default attack surface, root-cause code path, attacker-input-to-impact chain, safe PoC side effect with cleanup, and concise fix guidance.
71+
6972
## Agent/tool API rule
7073

7174
For agent and AI repos, command execution or file access may be intended functionality.

examples/strong-agent-rce.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
Severity: Critical
44

5+
Affected: sample-agent-repo current HEAD.
6+
Tested on: bundled example repo in default configuration.
7+
Attack surface: HTTP API exposed by the server routes.
8+
59
A remote unauthenticated attacker can POST to `/api/tools/run` in the default server configuration.
610

711
Affected files: `server/tools.py`, `server/routes.py`
@@ -14,12 +18,19 @@ Boundary crossed: unauthenticated HTTP caller can invoke an agent-only command t
1418

1519
Trusted component: `run_command()` passes request JSON into subprocess.
1620

21+
Root cause: `run_tool_route()` forwards attacker-controlled JSON into `run_command()`, which calls `subprocess.run(..., shell=True)` without authentication, authorization, or approval.
22+
23+
Exploit chain: HTTP request -> JSON `cmd` -> `run_tool_route()` -> `run_command()` -> shell.
24+
1725
Impact: host command execution as the server user.
1826

1927
PoC:
2028

2129
```bash
2230
curl -s http://127.0.0.1:8000/api/tools/run -d '{"cmd":"id > /tmp/finding-vetter-poc"}'
31+
test -f /tmp/finding-vetter-poc && rm /tmp/finding-vetter-poc
2332
```
2433

25-
Proof: `/tmp/finding-vetter-poc` is created and then removed.
34+
Expected result: `/tmp/finding-vetter-poc` is created and then removed, demonstrating a safe side effect.
35+
36+
Fix: require authentication and explicit user approval before invoking the tool, and replace shell execution with an allowlisted argv runner using `shell=False`.

src/finding_vetter/core.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ class ParsedReport:
3737
has_repro: bool = False
3838
attacker_mentions: list[str] = field(default_factory=list)
3939
impact_terms: list[str] = field(default_factory=list)
40+
has_affected_or_tested_version: bool = False
41+
has_attack_surface: bool = False
42+
has_root_cause: bool = False
43+
has_exploit_chain: bool = False
44+
has_safe_side_effect: bool = False
45+
has_fix_guidance: bool = False
4046

4147

4248
@dataclass
@@ -85,6 +91,30 @@ def parse_report(path: Path) -> ParsedReport:
8591
report.attacker_mentions = sorted(set(re.findall(r"(?i)\b(unauthenticated|remote|authenticated|low-privilege|workspace member|malicious website|prompt injection|admin|operator|local user)\b", text)))
8692
lower = text.lower()
8793
report.impact_terms = [t for t in RCE_TERMS + FILE_TERMS + SSRF_TERMS + AUTH_TERMS if t in lower]
94+
report.has_affected_or_tested_version = bool(re.search(
95+
r"(?im)^\s*(?:affected(?!\s+files?)|tested on|confirmed against|version|commit|current head|default configuration)\b",
96+
text,
97+
))
98+
report.has_attack_surface = bool(re.search(
99+
r"(?i)\b(attack surface|entrypoint|entry point|reachable|default configuration|port \d+|victim action)\b",
100+
text,
101+
))
102+
report.has_root_cause = bool(re.search(
103+
r"(?i)\b(root cause|bug is|vulnerability is|without checking|without auth|bounds check|validation|sanitize|source-to-sink|copies attacker-controlled)\b",
104+
text,
105+
))
106+
report.has_exploit_chain = bool(re.search(
107+
r"(?i)\b(exploit chain|trigger|primitive|source[- ]to[- ]sink|->|leads to|reaches the sink)\b",
108+
text,
109+
))
110+
report.has_safe_side_effect = bool(re.search(
111+
r"(?i)\b(expected result|actual result|safe side effect|created and then removed|cleanup|rm /tmp|test -f|id > /tmp|touch /tmp)\b",
112+
text,
113+
))
114+
report.has_fix_guidance = bool(re.search(
115+
r"(?i)\b(fix|patch|mitigation|remediation|allowlist|denylist|bounds check|validate|sanitize|shell=false|authentication)\b",
116+
text,
117+
))
88118
return report
89119

90120

@@ -161,9 +191,59 @@ def collect_repo_evidence(repo: Path, report: ParsedReport) -> tuple[list[str],
161191
else:
162192
missing.append("No clear attacker position found, e.g. unauthenticated, low-privilege, malicious website, prompt injection.")
163193

194+
if _claims_high_impact_rce(report):
195+
_add_madbugs_style_evidence(report, confirmed, missing)
196+
164197
return confirmed, missing, agent_context, dangerous_hits
165198

166199

200+
def _claims_high_impact_rce(report: ParsedReport) -> bool:
201+
lower = report.text.lower()
202+
return ((report.severity or "").lower() in {"critical", "high"} or "critical" in lower) and any(
203+
term in lower for term in RCE_TERMS
204+
)
205+
206+
207+
def _add_madbugs_style_evidence(report: ParsedReport, confirmed: list[str], missing: list[str]) -> None:
208+
checks = [
209+
(
210+
report.has_affected_or_tested_version,
211+
"MADBugs-style affected/tested version or commit context is present.",
212+
"Critical RCE needs affected/tested version or current-HEAD/default-config context.",
213+
),
214+
(
215+
report.has_attack_surface,
216+
"Attack surface or reachable entrypoint context is present.",
217+
"Critical RCE needs attack-surface/default-reachability context.",
218+
),
219+
(
220+
report.has_root_cause,
221+
"Root-cause explanation is present.",
222+
"Critical RCE needs root-cause/source-to-sink explanation, not only a dangerous sink.",
223+
),
224+
(
225+
report.has_exploit_chain,
226+
"Exploit chain or trigger path is present.",
227+
"Critical RCE needs a trigger/exploit chain from attacker input to impact.",
228+
),
229+
(
230+
report.has_safe_side_effect,
231+
"PoC describes an expected result, cleanup, or safe side effect.",
232+
"Critical RCE PoC should use a safe side effect and state expected result/cleanup.",
233+
),
234+
(
235+
report.has_fix_guidance,
236+
"Fix, patch, or mitigation guidance is present.",
237+
"Critical RCE report should include concise fix/mitigation guidance.",
238+
),
239+
]
240+
for ok, good, bad in checks:
241+
if ok:
242+
confirmed.append(good)
243+
else:
244+
missing.append(bad)
245+
246+
167247
def generate_questions(report: ParsedReport, agent_context: bool, dangerous_hits: dict[str, list[str]]) -> list[str]:
168248
lower = report.text.lower()
169249
questions: list[str] = [
@@ -183,7 +263,9 @@ def generate_questions(report: ParsedReport, agent_context: bool, dangerous_hits
183263
questions += [
184264
"Who can reach the command/code execution sink in default configuration?",
185265
"Is there authentication, authorization, user approval, or sandboxing before execution?",
186-
"What safe command proves impact, and is cleanup documented?",
266+
"What affected/tested version or commit proves this is current?",
267+
"What is the root-cause source-to-sink chain from attacker input to execution?",
268+
"What safe side effect proves impact, and is cleanup documented?",
187269
]
188270
if any(term in lower for term in FILE_TERMS) or "file read/write" in dangerous_hits:
189271
questions += [
@@ -208,6 +290,7 @@ def decide_verdict(report: ParsedReport, missing: list[str], agent_context: bool
208290
missing_symbols = [m for m in missing if "symbol/string not found" in m or "endpoint/path string not found" in m]
209291
no_repro = any("No obvious PoC" in m for m in missing)
210292
no_attacker = any("No clear attacker" in m for m in missing)
293+
missing_madbugs_rce_context = [m for m in missing if m.startswith("Critical RCE needs") or m.startswith("Critical RCE PoC")]
211294
lower = report.text.lower()
212295
claimed_critical_rce = (report.severity or "").lower() == "critical" or "critical" in lower and any(t in lower for t in RCE_TERMS)
213296

@@ -217,6 +300,8 @@ def decide_verdict(report: ParsedReport, missing: list[str], agent_context: bool
217300
return "WEAK", "This appears to involve agent/tool functionality, but the report does not prove unauthorized boundary crossing.", "Rewrite as a potential boundary issue, then prove unauthorized invocation, approval bypass, sandbox escape, cross-user impact, or secret exposure."
218301
if no_attacker:
219302
return "WEAK", "The report does not define who the attacker is or how they reach the issue.", "Add a precise attacker model before claiming severity."
303+
if claimed_critical_rce and missing_madbugs_rce_context:
304+
return "NEEDS_WORK", "Critical RCE is plausible, but missing MADBugs-style context: affected/tested version, root cause, exploit chain, safe PoC evidence, or fix guidance.", "Add the affected/tested version, default attack surface, source-to-sink root cause, safe side-effect PoC with cleanup, and concise fix guidance."
220305
if no_repro:
221306
return "NEEDS_WORK", "The claim may be plausible, but it lacks a minimal PoC/repro.", "Add a safe repro against current HEAD and document expected vs actual result."
222307
if claimed_critical_rce and "auth" not in lower and "unauth" not in lower and "approval" not in lower:
@@ -287,6 +372,12 @@ def vet(repo: Path, report_path: Path, owner_repo: str | None = None) -> VetResu
287372
"has_repro": report.has_repro,
288373
"attacker_mentions": report.attacker_mentions,
289374
"impact_terms": report.impact_terms,
375+
"has_affected_or_tested_version": report.has_affected_or_tested_version,
376+
"has_attack_surface": report.has_attack_surface,
377+
"has_root_cause": report.has_root_cause,
378+
"has_exploit_chain": report.has_exploit_chain,
379+
"has_safe_side_effect": report.has_safe_side_effect,
380+
"has_fix_guidance": report.has_fix_guidance,
290381
},
291382
)
292383

tests/test_madbugs_style.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from pathlib import Path
2+
3+
from finding_vetter.core import vet
4+
5+
6+
def _write_rce_repo(tmp_path: Path) -> Path:
7+
repo = tmp_path / "repo"
8+
(repo / "server").mkdir(parents=True)
9+
(repo / "server" / "handler.py").write_text(
10+
"import subprocess\n\n"
11+
"def handle(request):\n"
12+
" cmd = request.json['cmd']\n"
13+
" return subprocess.run(cmd, shell=True)\n",
14+
encoding="utf-8",
15+
)
16+
return repo
17+
18+
19+
def test_critical_rce_needs_madbugs_style_repro_context(tmp_path: Path):
20+
repo = _write_rce_repo(tmp_path)
21+
report = tmp_path / "thin-rce.md"
22+
report.write_text(
23+
"# Critical RCE\n\n"
24+
"Severity: Critical\n\n"
25+
"Affected files: `server/handler.py`\n\n"
26+
"Attacker: remote unauthenticated user.\n\n"
27+
"Entrypoint: `POST /run`\n\n"
28+
"Boundary crossed: unauthenticated HTTP caller reaches subprocess.\n\n"
29+
"PoC: curl http://target/run -d '{\"cmd\":\"id\"}'\n",
30+
encoding="utf-8",
31+
)
32+
33+
result = vet(repo, report)
34+
35+
assert result.verdict == "NEEDS_WORK"
36+
assert any("affected/tested version" in item.lower() for item in result.missing)
37+
assert any("root-cause" in item.lower() for item in result.missing)
38+
assert any("safe side effect" in q.lower() for q in result.questions)
39+
40+
41+
def test_madbugs_style_rce_report_passes_with_versions_chain_and_fix(tmp_path: Path):
42+
repo = _write_rce_repo(tmp_path)
43+
report = tmp_path / "grounded-rce.md"
44+
report.write_text(
45+
"# Unauthenticated command execution in handler\n\n"
46+
"Severity: Critical\n\n"
47+
"Affected: demo-agent <= 1.2.3.\n"
48+
"Tested on: commit abc123 in default configuration.\n"
49+
"Attack surface: HTTP API exposed on port 8000.\n\n"
50+
"Affected files: `server/handler.py`\n\n"
51+
"Attacker: remote unauthenticated user.\n"
52+
"Entrypoint: `POST /run`\n"
53+
"Boundary crossed: unauthenticated HTTP caller can invoke a server-side shell command.\n\n"
54+
"Root cause: `handle()` copies attacker-controlled JSON into `subprocess.run(..., shell=True)` without auth.\n"
55+
"Exploit chain: HTTP request -> JSON `cmd` -> `handle()` -> shell.\n"
56+
"Impact: host command execution as the server user.\n\n"
57+
"PoC:\n```bash\n"
58+
"curl http://127.0.0.1:8000/run -d '{\"cmd\":\"id > /tmp/fv-safe-poc\"}'\n"
59+
"test -f /tmp/fv-safe-poc && rm /tmp/fv-safe-poc\n"
60+
"```\n\n"
61+
"Expected result: `/tmp/fv-safe-poc` is created, proving a safe side effect.\n"
62+
"Fix: require authentication and pass an allowlisted argv array with `shell=False`.\n",
63+
encoding="utf-8",
64+
)
65+
66+
result = vet(repo, report)
67+
68+
assert result.verdict == "PASS"
69+
assert any("affected/tested version" in item.lower() for item in result.confirmed)
70+
assert any("root-cause" in item.lower() for item in result.confirmed)
71+
assert not any("safe side effect" in item.lower() for item in result.missing)

0 commit comments

Comments
 (0)