fold/bench/run_fold_on_juliet.py
trvon c4e42cec0a
Add scan router and MCP job queue
Split source and binary scan routing with merge-only hybrid support, binary provenance, and router-backed MCP scan contexts.

Add async MCP scan jobs for long-running scans, detector precision fixes, benchmark scripts, docs, and integration coverage.
2026-05-19 00:24:12 -04:00

418 lines
15 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import shlex
import subprocess
import sys
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
JULIET_ROOT = REPO_ROOT / "bench" / "corpora" / "juliet" / "filtered"
GOLD_ROOT = REPO_ROOT / "bench" / "gold"
RESULTS_ROOT = REPO_ROOT / "bench" / "results"
DEFAULT_FOLD = REPO_ROOT / "build" / "release" / "src" / "cli" / "fold"
LLVM_BIN = Path(os.environ.get("LLVM_BIN", "/opt/homebrew/opt/llvm@22/bin"))
CLANG = LLVM_BIN / "clang"
CLANGXX = LLVM_BIN / "clang++"
CWE_CONFIG = {
"476": {"kinds": ["null-deref"], "detectors": ["null-deref"]},
"369": {
"kinds": ["div-by-zero"],
"detectors": ["div-by-zero"],
"exclude_globs": [
"*CWE369_Divide_by_Zero__float_listenSocket_73a.cpp",
"*CWE369_Divide_by_Zero__float_listenSocket_74a.cpp",
"*CWE369_Divide_by_Zero__int_listen_socket_divide_73a.cpp",
"*CWE369_Divide_by_Zero__int_listen_socket_divide_74a.cpp",
"*CWE369_Divide_by_Zero__int_listen_socket_modulo_73a.cpp",
"*CWE369_Divide_by_Zero__int_listen_socket_modulo_74a.cpp",
],
},
"190": {"kinds": ["int-overflow"], "detectors": ["int-overflow"]},
"191": {"kinds": ["int-overflow"], "detectors": ["int-overflow"]},
"194": {"kinds": ["sign-conversion"], "detectors": ["sign-conversion"]},
"195": {"kinds": ["sign-conversion"], "detectors": ["sign-conversion"]},
"196": {"kinds": ["sign-conversion"], "detectors": ["sign-conversion"]},
"197": {"kinds": ["int-truncation"], "detectors": ["int-truncation"]},
"121": {"kinds": ["oob-access"], "detectors": ["oob"]},
"122": {"kinds": ["oob-access"], "detectors": ["oob"]},
"124": {"kinds": ["oob-access"], "detectors": ["oob"]},
"126": {"kinds": ["oob-access"], "detectors": ["oob"]},
"127": {"kinds": ["oob-access"], "detectors": ["oob"]},
"561": {
"kinds": ["dead", "dead-statement", "unused-function"],
"detectors": ["dead", "dead-statement", "unused-function"],
},
}
SOLVER_RE = re.compile(
r"^\[solver-profile\]\s+(?P<detector>\S+)\s+"
r"queries=(?P<queries>\d+)\s+sat=(?P<sat>\d+)\s+unsat=(?P<unsat>\d+)\s+"
r"unknown=(?P<unknown>\d+)\s+decisions=(?P<decisions>\d+)\s+"
r"conflicts=(?P<conflicts>\d+)\s+restarts=(?P<restarts>\d+)\s+"
r"propagations=(?P<propagations>\d+)\s+z3_time_ms=(?P<z3_time_ms>\d+)\s+"
r"wall_ms=(?P<wall_ms>\d+)"
)
EMITTED_RE = re.compile(r"emitted\s+(?P<count>\d+)\s+findings total")
def now_iso() -> str:
return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H-%M-%SZ")
def file_basename(path: str) -> str:
return Path(path).name
def load_gold(cwe: str) -> dict:
gold_path = GOLD_ROOT / f"juliet-{cwe}.json"
return json.loads(gold_path.read_text())
def score_file_level(findings: list[dict], gold_entries: list[dict], kinds: list[str]) -> dict:
kind_set = set(kinds)
gold_files = {
file_basename(entry["files"][0])
for entry in gold_entries
if entry.get("kind") in kind_set
}
finding_files = {file_basename(f["file"]) for f in findings if f.get("kind") in kind_set}
tp_files = gold_files & finding_files
tp = len(tp_files)
fp = len(finding_files - gold_files)
fn = len(gold_files - finding_files)
precision = tp / len(finding_files) if finding_files else 0.0
recall = tp / len(gold_files) if gold_files else 0.0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
return {
"true_positive": tp,
"false_positive": fp,
"false_negative": fn,
"gold_total": len(gold_files),
"finding_groups": len(finding_files),
"precision": precision,
"recall": recall,
"f1": f1,
"matched_files": sorted(tp_files),
"missed_files": sorted(gold_files - finding_files),
}
def normalize_kind(kind: str) -> str:
return {
"dead-branch": "dead",
"duplicate-class": "class-dup",
}.get(kind, kind)
def write_compile_db(corpus_dir: Path, db_path: Path) -> list[dict]:
entries: list[dict] = []
include_dirs = set()
for path in sorted(corpus_dir.rglob("*")):
if path.is_dir() and path.name == "testcasesupport":
include_dirs.add(str(path))
if path.is_dir() and any(path.glob("*.h")):
include_dirs.add(str(path))
inc_flags = [flag for d in sorted(include_dirs) for flag in ("-I", d)]
for src in sorted(corpus_dir.rglob("*.c")):
entries.append({
"directory": str(corpus_dir),
"command": [str(CLANG), "-std=c11", *inc_flags, "-c", str(src)],
"file": str(src),
})
for src in sorted(corpus_dir.rglob("*.cpp")):
entries.append({
"directory": str(corpus_dir),
"command": [str(CLANGXX), "-std=c++17", *inc_flags, "-c", str(src)],
"file": str(src),
})
db_path.write_text(json.dumps([
{"directory": e["directory"], "command": shlex.join(e["command"]), "file": e["file"]}
for e in entries
], indent=2) + "\n")
return entries
def normalize_findings(findings: list[dict]) -> list[dict]:
dedup: dict[tuple[str, str], dict] = {}
for finding in findings:
key = (str(Path(finding["file"]).resolve()), finding["kind"])
current = dedup.get(key)
if current is None or finding.get("line", 0) < current.get("line", 0):
dedup[key] = finding
return sorted(dedup.values(), key=lambda item: (item["file"], item["line"]))
def normalize_finding_record(finding: dict) -> list[dict]:
if "file" in finding:
return [{
"file": finding.get("file", ""),
"line": int(finding.get("line", 0) or 0),
"kind": normalize_kind(str(finding.get("kind", "unknown"))),
"id": finding.get("id", ""),
}]
kind = normalize_kind(str(finding.get("kind", "unknown")))
fid = str(finding.get("id", ""))
locations = finding.get("locations", [])
out: list[dict] = []
for loc in locations:
out.append({
"file": loc.get("file", ""),
"line": int(loc.get("line", 0) or 0),
"kind": kind,
"id": fid,
})
return out
def parse_sarif(sarif_path: Path) -> list[dict]:
if not sarif_path.exists():
return []
doc = json.loads(sarif_path.read_text())
findings: list[dict] = []
for run in doc.get("runs", []):
for result in run.get("results", []):
kind = normalize_kind(result.get("ruleId", "fold.unknown").removeprefix("fold."))
fid = result.get("properties", {}).get("id", "")
for loc in result.get("locations", []):
physical = loc.get("physicalLocation", {})
artifact = physical.get("artifactLocation", {})
region = physical.get("region", {})
findings.append({
"file": artifact.get("uri", ""),
"line": int(region.get("startLine", 0) or 0),
"kind": kind,
"id": fid,
})
return normalize_findings(findings)
def parse_findings_json(json_path: Path) -> list[dict]:
if not json_path.exists():
return []
doc = json.loads(json_path.read_text())
# Runner payloads wrap the actual normalized findings under "findings".
if isinstance(doc, dict) and isinstance(doc.get("findings"), list):
findings: list[dict] = []
for item in doc["findings"]:
findings.extend(normalize_finding_record(item))
return normalize_findings(findings)
# Worker/native JSON can also show up as NDJSON-style envelopes flattened
# into a list of {kind: "finding", data: {...}} items.
if isinstance(doc, list):
findings = []
for item in doc:
if not isinstance(item, dict):
continue
if item.get("kind") == "finding" and isinstance(item.get("data"), dict):
findings.extend(normalize_finding_record(item["data"]))
else:
findings.extend(normalize_finding_record(item))
return normalize_findings(findings)
return []
def load_findings(cwe_out: Path) -> tuple[list[dict], str]:
sarif_path = cwe_out / "fold.sarif"
findings = parse_sarif(sarif_path)
if findings:
return findings, "sarif"
json_path = cwe_out / "fold.findings.json"
findings = parse_findings_json(json_path)
if findings:
return findings, "json"
payload_path = cwe_out / "fold.json"
findings = parse_findings_json(payload_path)
if findings:
return findings, "payload"
return [], "none"
def parse_profiles(log_text: str, expected_detectors: list[str]) -> dict:
totals = {
"queries": 0,
"sat": 0,
"unsat": 0,
"unknown": 0,
"decisions": 0,
"conflicts": 0,
"restarts": 0,
"propagations": 0,
"z3_time_ms": 0,
"wall_ms": 0,
}
expected = set(expected_detectors)
for line in log_text.splitlines():
match = SOLVER_RE.match(line.strip())
if not match:
continue
if match.group("detector") not in expected:
continue
for key in totals:
totals[key] += int(match.group(key))
emitted = 0
for match in EMITTED_RE.finditer(log_text):
emitted = int(match.group("count"))
return {"solver": totals, "findings_emitted_total": emitted}
def run_cwe(cwe: str, fold_bin: Path, processes: int, out_root: Path) -> dict:
config = CWE_CONFIG[cwe]
kinds = list(config["kinds"])
detectors = list(config["detectors"])
exclude_globs = config.get("exclude_globs", [])
gold = load_gold(cwe)
corpus_dir = JULIET_ROOT / cwe
if not corpus_dir.is_dir():
raise FileNotFoundError(f"missing Juliet filtered corpus: {corpus_dir}")
cwe_out = out_root / cwe
cwe_out.mkdir(parents=True, exist_ok=True)
db_path = cwe_out / "compile_commands.json"
entries = write_compile_db(corpus_dir, db_path)
sarif_path = cwe_out / "fold.sarif"
stdout_path = cwe_out / "fold.stdout.log"
stderr_path = cwe_out / "fold.stderr.log"
command = [
str(fold_bin), "scan",
"--compile-db", str(db_path),
"--format", "sarif",
"--output", str(sarif_path),
"--min-body-stmts", "1",
"--processes", str(processes),
"--profile-solver",
"--profile-reach",
]
for detector in detectors:
command.extend(["--detectors", detector])
for pattern in exclude_globs:
command.extend(["--exclude-glob", pattern])
env = os.environ.copy()
env["PATH"] = f"{LLVM_BIN}:{env.get('PATH', '')}"
start = time.monotonic()
proc = subprocess.run(command, capture_output=True, text=True, env=env)
elapsed = time.monotonic() - start
stdout_path.write_text(proc.stdout)
stderr_path.write_text(proc.stderr)
combined_log = proc.stdout + "\n" + proc.stderr
if "subprocess_fanout: bad NDJSON line:" in combined_log:
raise RuntimeError(
f"parallel worker serialization dropped findings for CWE-{cwe}; rerun with --processes 1"
)
findings, findings_source = load_findings(cwe_out)
if not findings and not sarif_path.exists() and proc.returncode < 0:
raise RuntimeError(
f"fold scan for CWE-{cwe} terminated before writing results (rc={proc.returncode})"
)
score = score_file_level(findings, gold["expected"], kinds)
profile = parse_profiles(combined_log, detectors + kinds)
payload = {
"tool": "fold",
"cwe": cwe,
"kind": ",".join(kinds),
"kinds": kinds,
"detector": ",".join(detectors),
"detectors": detectors,
"version": subprocess.run([str(fold_bin), "--version"], capture_output=True, text=True).stdout.strip() or "unknown",
"elapsed_s": elapsed,
"exit_code": proc.returncode,
"entries": len(entries),
"excluded_globs": exclude_globs,
"findings": findings,
"findings_source": findings_source,
"score": score,
"profile": profile,
"artifacts": {
"compile_db": str(db_path),
"sarif": str(sarif_path),
"stdout_log": str(stdout_path),
"stderr_log": str(stderr_path),
},
}
(cwe_out / "fold.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
return payload
def render_summary(results: list[dict]) -> str:
lines = [
"# fold on Juliet",
"",
"| CWE | Kind | TP | FP | FN | Findings | Gold | Precision | Recall | F1 | Time | Queries | Sat | Unsat | Unknown |",
"|-----|------|----|----|----|----------|------|-----------|--------|----|------|---------|-----|-------|---------|",
]
for result in results:
score = result["score"]
solver = result["profile"]["solver"]
lines.append(
f"| CWE-{result['cwe']} | {result['kind']} | {score['true_positive']} | {score['false_positive']} | "
f"{score['false_negative']} | {score['finding_groups']} | {score['gold_total']} | "
f"{score['precision']:.1%} | {score['recall']:.1%} | {score['f1']:.1%} | {result['elapsed_s']:.1f}s | "
f"{solver['queries']} | {solver['sat']} | {solver['unsat']} | {solver['unknown']} |"
)
lines.append("")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--cwes",
nargs="+",
default=[
"476", "369", "190", "191", "194", "195", "196", "197",
"121", "122", "124", "126", "127", "561",
],
)
parser.add_argument("--fold-bin", default=str(DEFAULT_FOLD))
parser.add_argument("--processes", type=int, default=1)
parser.add_argument("--out-root", default=str(RESULTS_ROOT))
args = parser.parse_args()
fold_bin = Path(args.fold_bin).resolve()
if not fold_bin.exists():
print(f"fold binary not found: {fold_bin}", file=sys.stderr)
return 2
if not CLANG.exists() or not CLANGXX.exists():
print(f"clang/clang++ not found under {LLVM_BIN}", file=sys.stderr)
return 2
stamp = now_iso()
out_root = Path(args.out_root).resolve() / f"{stamp}_fold-juliet"
out_root.mkdir(parents=True, exist_ok=True)
results = []
for cwe in args.cwes:
if cwe not in CWE_CONFIG:
print(f"unsupported CWE: {cwe}", file=sys.stderr)
return 2
print(f"running fold on Juliet CWE-{cwe}...", file=sys.stderr)
results.append(run_cwe(cwe, fold_bin, args.processes, out_root))
summary = render_summary(results)
(out_root / "summary.md").write_text(summary + "\n")
print(summary)
print(f"results written to {out_root}")
return 0
if __name__ == "__main__":
raise SystemExit(main())