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.
149 lines
4.7 KiB
Python
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())
|