Skip to content

Commit a4033ae

Browse files
feat: show line-level evidence locations (#2)
1 parent 0ef9bcc commit a4033ae

3 files changed

Lines changed: 107 additions & 7 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Security reports often fail for preventable reasons:
2222
- a Critical RCE claim lacks a safe repro, tested version, or root-cause chain
2323
- similar issues are already public in GitHub issues or PRs
2424

25-
Verifymate acts like a checklist-driven review partner: it compares the report to a checkout, highlights confirmed evidence, flags weak spots, and lists the questions a maintainer is likely to ask.
25+
Verifymate acts like a checklist-driven review partner: it compares the report to a checkout, highlights confirmed evidence with file/line snippets, flags weak spots, and lists the questions a maintainer is likely to ask.
2626

2727
## What it checks
2828

@@ -31,6 +31,7 @@ Verifymate acts like a checklist-driven review partner: it compares the report t
3131
- The report includes an attacker model.
3232
- The report includes a PoC/repro indicator.
3333
- Dangerous capability terms exist in the repo.
34+
- Line-level evidence locations show where referenced files, symbols, endpoints, and risky capabilities were found.
3435
- Agent/tool context is detected so intended functionality is not mislabeled as RCE.
3536
- Critical/High RCE reports include MADBugs-style proof context:
3637
- affected/tested version or current commit

src/finding_vetter/core.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ class VetResult:
140140
checks: list[dict[str, str]] = field(default_factory=list)
141141
confirmed: list[str] = field(default_factory=list)
142142
missing: list[str] = field(default_factory=list)
143+
evidence_locations: list[dict[str, str]] = field(default_factory=list)
143144
questions: list[str] = field(default_factory=list)
144145
duplicate_matches: list[str] = field(default_factory=list)
145146
suggested_rewrite: str = ""
@@ -240,9 +241,10 @@ def _looks_like_source_path(ref: str) -> bool:
240241
return bool(re.search(r"\.(py|js|ts|tsx|jsx|go|rs|java|rb|php|c|cc|cpp|h|hpp|yml|yaml|json|toml|md)$", ref))
241242

242243

243-
def collect_repo_evidence(repo: Path, report: ParsedReport) -> tuple[list[str], list[str], bool, dict[str, list[str]]]:
244+
def collect_repo_evidence(repo: Path, report: ParsedReport) -> tuple[list[str], list[str], list[dict[str, str]], bool, dict[str, list[str]]]:
244245
confirmed: list[str] = []
245246
missing: list[str] = []
247+
evidence_locations: list[dict[str, str]] = []
246248
dangerous_hits: dict[str, list[str]] = {}
247249

248250
all_text_files = list(_iter_text_files(repo))
@@ -257,19 +259,24 @@ def collect_repo_evidence(repo: Path, report: ParsedReport) -> tuple[list[str],
257259
path = (repo / file_ref).resolve()
258260
if path.exists() and _is_relative_to(path, repo.resolve()):
259261
confirmed.append(f"Referenced file exists: `{file_ref}`")
262+
evidence_locations.append(_evidence_location("file", file_ref, file_ref, 1, "Referenced file exists."))
260263
else:
261264
missing.append(f"Referenced file not found on current checkout: `{file_ref}`")
262265

263266
for symbol in report.symbols[:50]:
264-
if _search_literal(all_text_files, symbol):
267+
hit = _find_literal_location(all_text_files, symbol, repo)
268+
if hit:
265269
confirmed.append(f"Referenced symbol/string found: `{symbol}`")
270+
evidence_locations.append(_evidence_location("symbol", symbol, *hit))
266271
else:
267272
missing.append(f"Referenced symbol/string not found: `{symbol}`")
268273

269274
for endpoint in report.endpoints[:30]:
270275
needle = endpoint.split(maxsplit=1)[-1]
271-
if _search_literal(all_text_files, needle):
276+
hit = _find_literal_location(all_text_files, needle, repo)
277+
if hit:
272278
confirmed.append(f"Referenced endpoint/path string found: `{endpoint}`")
279+
evidence_locations.append(_evidence_location("endpoint", endpoint, *hit))
273280
else:
274281
missing.append(f"Referenced endpoint/path string not found: `{endpoint}`")
275282

@@ -278,6 +285,10 @@ def collect_repo_evidence(repo: Path, report: ParsedReport) -> tuple[list[str],
278285
if hits:
279286
dangerous_hits[category] = hits[:8]
280287
confirmed.append(f"Dangerous-capability terms present ({category}): {', '.join(hits[:5])}")
288+
for term in hits[:3]:
289+
hit = _find_literal_location(all_text_files, term, repo, case_sensitive=False)
290+
if hit:
291+
evidence_locations.append(_evidence_location(category, term, *hit))
281292

282293
if report.has_repro:
283294
confirmed.append("Report appears to include a PoC/repro section or command.")
@@ -292,7 +303,7 @@ def collect_repo_evidence(repo: Path, report: ParsedReport) -> tuple[list[str],
292303
if _claims_high_impact_rce(report):
293304
_add_madbugs_style_evidence(report, confirmed, missing)
294305

295-
return confirmed, missing, agent_context, dangerous_hits
306+
return confirmed, missing, _dedupe_locations(evidence_locations), agent_context, dangerous_hits
296307

297308

298309
def _claims_high_impact_rce(report: ParsedReport) -> bool:
@@ -574,7 +585,7 @@ def duplicate_search(owner_repo: str | None, report: ParsedReport) -> list[str]:
574585

575586
def vet(repo: Path, report_path: Path, owner_repo: str | None = None) -> VetResult:
576587
report = parse_report(report_path)
577-
confirmed, missing, agent_context, dangerous_hits = collect_repo_evidence(repo, report)
588+
confirmed, missing, evidence_locations, agent_context, dangerous_hits = collect_repo_evidence(repo, report)
578589
questions = generate_questions(report, agent_context, dangerous_hits)
579590
duplicates = duplicate_search(owner_repo, report)
580591
verdict, reason, rewrite = decide_verdict(report, missing, agent_context)
@@ -588,6 +599,7 @@ def vet(repo: Path, report_path: Path, owner_repo: str | None = None) -> VetResu
588599
checks=checks,
589600
confirmed=_dedupe(confirmed),
590601
missing=_dedupe(missing),
602+
evidence_locations=evidence_locations,
591603
questions=questions,
592604
duplicate_matches=duplicates,
593605
suggested_rewrite=rewrite,
@@ -633,6 +645,12 @@ def render_markdown(result: VetResult) -> str:
633645
"",
634646
]
635647
lines += _bullet_list(result.confirmed, empty="No strong confirming evidence found.")
648+
if result.evidence_locations:
649+
lines += ["", "## Evidence locations", ""]
650+
lines += [
651+
f"- `{item['file']}:{item['line']}` — **{item['kind']}** `{item['term']}`: {item['snippet']}"
652+
for item in result.evidence_locations
653+
]
636654
lines += ["", "## Missing or weak evidence", ""]
637655
lines += _bullet_list(result.missing, empty="No obvious blockers found by the lightweight checks.")
638656
lines += ["", "## Maintainer will ask", ""]
@@ -676,6 +694,43 @@ def _search_literal(files: Iterable[Path], needle: str) -> bool:
676694
return False
677695

678696

697+
698+
def _find_literal_location(files: Iterable[Path], needle: str, repo: Path, *, case_sensitive: bool = True) -> tuple[str, int, str] | None:
699+
if not needle:
700+
return None
701+
needle_cmp = needle if case_sensitive else needle.lower()
702+
for path in files:
703+
try:
704+
rel = path.relative_to(repo).as_posix()
705+
except ValueError:
706+
rel = path.as_posix()
707+
for line_no, line in enumerate(_safe_read(path).splitlines(), 1):
708+
haystack = line if case_sensitive else line.lower()
709+
if needle_cmp in haystack:
710+
return rel, line_no, line.strip()[:200]
711+
return None
712+
713+
714+
def _evidence_location(kind: str, term: str, file: str, line: int, snippet: str) -> dict[str, str]:
715+
return {
716+
"kind": kind,
717+
"term": term,
718+
"file": file,
719+
"line": str(line),
720+
"snippet": snippet,
721+
}
722+
723+
724+
def _dedupe_locations(items: Iterable[dict[str, str]]) -> list[dict[str, str]]:
725+
seen: set[tuple[str, str, str, str]] = set()
726+
out: list[dict[str, str]] = []
727+
for item in items:
728+
key = (item["kind"], item["term"], item["file"], item["line"])
729+
if key not in seen:
730+
seen.add(key)
731+
out.append(item)
732+
return out[:40]
733+
679734
def _dedupe(items: Iterable[str]) -> list[str]:
680735
seen = set()
681736
out = []

tests/test_core.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from pathlib import Path
22

3-
from finding_vetter.core import parse_report, vet
3+
from finding_vetter.core import parse_report, render_markdown, vet
44

55

66
def test_weak_agent_rce_needs_boundary(tmp_path: Path):
@@ -44,3 +44,47 @@ def test_parse_endpoint_and_attacker(tmp_path: Path):
4444
assert "POST /api/tools/run" in parsed.endpoints
4545
assert parsed.has_repro
4646
assert "remote" in [x.lower() for x in parsed.attacker_mentions]
47+
48+
49+
def test_vet_records_line_level_evidence_locations(tmp_path: Path):
50+
repo = tmp_path / "repo"
51+
repo.mkdir()
52+
(repo / "server.py").write_text(
53+
"from flask import Flask\n"
54+
"app = Flask(__name__)\n"
55+
"@app.post('/api/run')\n"
56+
"def run_command():\n"
57+
" import subprocess\n"
58+
" return subprocess.run('id', shell=True)\n",
59+
encoding="utf-8",
60+
)
61+
report = tmp_path / "finding.md"
62+
report.write_text(
63+
"# Command execution\n\n"
64+
"Severity: High\n\n"
65+
"Affected files: `server.py`\n\n"
66+
"Entrypoint: `POST /api/run`\n\n"
67+
"Attacker: remote unauthenticated user\n\n"
68+
"PoC: curl /api/run\n\n"
69+
"`run_command()` reaches subprocess with shell=True.\n",
70+
encoding="utf-8",
71+
)
72+
73+
result = vet(repo, report)
74+
75+
assert any(
76+
item["kind"] == "symbol"
77+
and item["term"] == "run_command"
78+
and item["file"] == "server.py"
79+
and item["line"] == "4"
80+
for item in result.evidence_locations
81+
)
82+
assert any(
83+
item["kind"] == "endpoint" and item["term"] == "POST /api/run" and item["line"] == "3"
84+
for item in result.evidence_locations
85+
)
86+
assert any(item["kind"] == "command execution" and item["term"] == "shell=True" for item in result.evidence_locations)
87+
88+
markdown = render_markdown(result)
89+
assert "## Evidence locations" in markdown
90+
assert "`server.py:4`" in markdown

0 commit comments

Comments
 (0)