fold/bench/score_binary.py
trvon e30764fb19
bench: extend fold_eval scoring with labeled negative cases
Add negative_cases_from_universe to expand an explicit file-by-rule universe
into labeled negatives (enabling TP/FP/TN/FN, not just TP/FP), plus project-eval
harness updates in run_project_eval and the score/score_fp/quality-gate helpers.
2026-08-16 16:43:51 -06:00

149 lines
4.7 KiB
Python

#!/usr/bin/env python3
"""Score Fold's binary lane against a CWE-grounded gold corpus.
Mirrors the source lane's bench/score_fp.py, but for the binary backend:
for each ELF in a corpus dir, run `fold scan --mode binary`, collect findings,
and score per-detector TP/FP/FN/F1 against a gold/disposition JSON.
Usage:
python3 bench/score_binary.py --corpus tests/fixtures/binary/cwe \
--gold bench/gold/binary-security.json \
--fold-bin build/release/src/cli/fold
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_GOLD = ROOT / "bench" / "gold" / "binary-security.json"
DEFAULT_CORPUS = ROOT / "tests" / "fixtures" / "binary" / "cwe"
PCODE_SCAN = ROOT / "build" / "release" / "pcode_scan"
# FindingKind enum integer -> gold kind name (mirror include/fold/report/finding.hpp).
KIND_TO_NAME = {
2: "dead",
5: "null-deref",
6: "int-overflow",
7: "int-truncation",
8: "sign-conversion",
9: "div-by-zero",
10: "oob-access",
11: "taint-track",
12: "unused-function",
15: "uninit-local-read",
16: "use-after-free",
17: "use-after-move",
24: "uninit-read",
27: "double-free",
29: "memory-leak",
}
def load_gold(path: Path) -> dict[str, set[str]]:
"""Return {finding_kind: {binary_basename}} from the gold JSON."""
try:
doc = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
raise SystemExit(f"gold file {path} is missing or invalid: {exc}") from exc
out: dict[str, set[str]] = {}
for entry in doc.get("expected", []):
kind = entry["kind"]
out.setdefault(kind, set()).update(entry["files"])
return out
def run_binary_scan(binary: Path) -> list[dict]:
"""Run the dedicated P-Code scan tool and return normalized findings.
`fold scan --mode binary` is heuristic metadata only; the P-Code security
detectors (div-by-zero, oob-access, null-deref, ...) are exposed through the
`pcode_scan` smoke driver, which prints `kind=<int> fn=<name>` lines.
"""
if not PCODE_SCAN.exists():
return []
import re
try:
proc = subprocess.run(
[str(PCODE_SCAN), str(binary)],
capture_output=True,
text=True,
check=False,
timeout=90,
)
except subprocess.TimeoutExpired:
print(f" (timeout) {binary.name}", file=sys.stderr)
return []
findings = []
for line in (proc.stdout or "").splitlines():
m = re.search(r"kind=(\d+)", line)
if not m:
continue
try:
kind_int = int(m.group(1))
except ValueError:
continue
kind = KIND_TO_NAME.get(kind_int)
if kind:
findings.append({"kind": kind, "basename": binary.name})
return findings
def score(
gold: dict[str, set[str]], findings: list[dict], corpus_names: set[str]
) -> dict:
"""Score per gold kind: TP/FP/FN/F1 over the corpus binaries."""
results = {}
for kind, gold_files in sorted(gold.items()):
found = {f["basename"] for f in findings if f["kind"] == kind}
gold_in_corpus = gold_files & corpus_names
tp = found & gold_in_corpus
fp = found - gold_in_corpus
fn = gold_in_corpus - found
p = len(tp) / (len(tp) + len(fp)) if (len(tp) + len(fp)) else 0.0
r = len(tp) / (len(tp) + len(fn)) if (len(tp) + len(fn)) else 0.0
f1 = 2 * p * r / (p + r) if (p + r) else 0.0
results[kind] = {
"tp": len(tp),
"fp": len(fp),
"fn": len(fn),
"precision": p,
"recall": r,
"f1": f1,
}
return results
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS)
ap.add_argument("--gold", type=Path, default=DEFAULT_GOLD)
ap.add_argument(
"--fold-bin", type=Path, default=ROOT / "build/release/src/cli/fold"
)
args = ap.parse_args()
gold = load_gold(args.gold)
corpus_names = {p.name for p in args.corpus.glob("*.elf")}
all_findings: list[dict] = []
for binary in sorted(args.corpus.glob("*.elf")):
all_findings.extend(run_binary_scan(binary))
results = score(gold, all_findings, corpus_names)
print(f"{'kind':<16} {'TP':>3} {'FP':>3} {'FN':>3} {'P':>6} {'R':>6} {'F1':>6}")
for kind, r in results.items():
print(
f"{kind:<16} {r['tp']:>3} {r['fp']:>3} {r['fn']:>3} "
f"{r['precision']:>6.3f} {r['recall']:>6.3f} {r['f1']:>6.3f}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())