mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
feat(real-world-vfc-slice): add labeled OpenSSL/Firefox/Chromium/curl pair corpus
Harvest 176 isolated vuln-vs-fix functions (109 CVEs, 352 training rows) into benchmarks/pairs/vfc with provenance and a JSONL training manifest. Score the slice as an honesty dashboard (ousast pairs --slice vfc); keep the pair gate on the local fixtures only.
This commit is contained in:
@@ -125,7 +125,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
help="scan isolated vuln-vs-fixed pairs (TP on vuln, silent on fix) and emit improve-loop signals",
|
||||
)
|
||||
pairs.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
|
||||
pairs.add_argument("--slice", choices=("all", "local", "github", "sast"), default="all")
|
||||
pairs.add_argument("--slice", choices=("all", "local", "github", "sast", "vfc"), default="all")
|
||||
pairs.add_argument("--json", action="store_true", help="print the pair scoreboard as JSON")
|
||||
|
||||
subparsers.add_parser("mcp", help="run the narrow MCP server over stdio for OpenCode integration")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Pair-efficiency gate: local fixtures must fire on vuln and stay silent on fix.
|
||||
|
||||
The stage-1 smoke gate stays on cheat-sheet trees. This module measures
|
||||
differential detection and only *fails CI* on the local slice. GitHub/SVEN
|
||||
pairs are printed as the honesty dashboard (real VFC misses become improve
|
||||
signals, they do not block merge).
|
||||
differential detection and only *fails CI* on the local slice. GitHub/SVEN,
|
||||
sast, and vfc pairs are honesty dashboards (`ousast pairs --slice ...`);
|
||||
they are not evaluated here so a large vfc catalog cannot slow the merge gate.
|
||||
|
||||
python -m openultrasast.pair_gate
|
||||
"""
|
||||
@@ -36,7 +36,7 @@ def pair_gate(cases: Sequence[PairCase] | None = None) -> PairGateVerdict:
|
||||
local = select_slice(catalog, "local")
|
||||
if not local:
|
||||
return PairGateVerdict(passed=False, reasons=("no local pairs in catalog",), result=evaluate_catalog(()))
|
||||
result = evaluate_catalog(catalog)
|
||||
result = evaluate_catalog(local)
|
||||
local_metrics = result.per_slice.get("local")
|
||||
reasons: list[str] = []
|
||||
if local_metrics is None:
|
||||
@@ -62,6 +62,12 @@ def print_pair_metrics(label: str, result: PairEvalResult) -> None:
|
||||
f"vuln {metrics.vuln_recall:>6.2%} silent {metrics.specificity:>6.2%} "
|
||||
f"Youden {metrics.youden:>+7.2%} labeled {metrics.labeled_recall:>6.2%}"
|
||||
)
|
||||
for slice_name, inner in result.scorers.items():
|
||||
for scorer, metrics in inner.items():
|
||||
print(
|
||||
f" {slice_name:<8} scorer={scorer:<10} pair_pass {metrics.pair_pass_rate:>6.2%} "
|
||||
f"({metrics.pair_correct}/{metrics.pairs}) Youden {metrics.youden:>+7.2%}"
|
||||
)
|
||||
for outcome in result.outcomes:
|
||||
mark = "PASS" if outcome.pair_correct else "MISS" if not outcome.detected_vuln else "LEAK"
|
||||
print(
|
||||
|
||||
+70
-40
@@ -11,7 +11,7 @@ import shutil
|
||||
import tempfile
|
||||
import tomllib
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .benchmark import (
|
||||
@@ -30,7 +30,9 @@ from .semantic import OverlayRecord, adjudicate
|
||||
|
||||
DEFAULT_CATALOG = Path("benchmarks/pairs/catalog.toml")
|
||||
DEFAULT_SAST_CATALOG = Path("benchmarks/pairs/sast/catalog.toml")
|
||||
DEFAULT_VFC_CATALOG = Path("benchmarks/pairs/vfc/catalog.toml")
|
||||
DEFAULT_DATASETS = Path("benchmarks/pairs/datasets.toml")
|
||||
OVERLAY_SLICES = frozenset({"sast", "vfc"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -95,6 +97,7 @@ class PairEvalResult:
|
||||
overall: PairCorpusMetrics
|
||||
per_slice: dict[str, PairCorpusMetrics]
|
||||
signals: tuple[dict[str, object], ...]
|
||||
scorers: dict[str, dict[str, PairCorpusMetrics]] = field(default_factory=dict)
|
||||
|
||||
|
||||
def load_datasets(path: Path = DEFAULT_DATASETS) -> tuple[dict[str, object], ...]:
|
||||
@@ -104,8 +107,11 @@ def load_datasets(path: Path = DEFAULT_DATASETS) -> tuple[dict[str, object], ...
|
||||
|
||||
def load_pair_catalog(path: Path = DEFAULT_CATALOG) -> tuple[PairCase, ...]:
|
||||
cases = _load_catalog_file(path)
|
||||
if path.resolve() == DEFAULT_CATALOG.resolve() and DEFAULT_SAST_CATALOG.exists():
|
||||
cases = cases + _load_catalog_file(DEFAULT_SAST_CATALOG)
|
||||
if path.resolve() == DEFAULT_CATALOG.resolve():
|
||||
if DEFAULT_SAST_CATALOG.exists():
|
||||
cases = cases + _load_catalog_file(DEFAULT_SAST_CATALOG)
|
||||
if DEFAULT_VFC_CATALOG.exists():
|
||||
cases = cases + _load_catalog_file(DEFAULT_VFC_CATALOG)
|
||||
return cases
|
||||
|
||||
|
||||
@@ -142,48 +148,21 @@ def evaluate_pair(case: PairCase) -> PairOutcome:
|
||||
with tempfile.TemporaryDirectory(prefix="ousast-pair-") as scratch:
|
||||
vuln_root = _materialize(Path(scratch) / "vuln", case.vuln_file, case.relpath)
|
||||
fix_root = _materialize(Path(scratch) / "fixed", case.fixed_file, case.relpath)
|
||||
if case.slice == "sast":
|
||||
if case.slice in OVERLAY_SLICES:
|
||||
return _evaluate_overlay_pair(case, vuln_root, fix_root)
|
||||
vuln_findings = _quick_scan(vuln_root)
|
||||
fix_findings = _quick_scan(fix_root)
|
||||
vuln_result = evaluate_benchmark(
|
||||
run=BenchmarkRun(benchmark_run_id="pair", root=Path("/tmp/pair"), manifest=_manifest(case)),
|
||||
mode="quick",
|
||||
findings=vuln_findings,
|
||||
scan_id=None,
|
||||
scan_run_dir=None,
|
||||
)
|
||||
expected_total = len(case.expected)
|
||||
matched = vuln_result.metrics.matched_findings_total
|
||||
recall = matched / expected_total if expected_total else 1.0
|
||||
leaks = _fix_leaks(case, fix_findings)
|
||||
detected = recall + 1e-12 >= case.min_recall
|
||||
silent = not leaks
|
||||
misses = tuple(f"{miss.rule_id or '-'}:{miss.path}:{miss.cwe}" for miss in vuln_result.misses)
|
||||
leak_ids = tuple(finding.finding_id for finding in leaks)
|
||||
return PairOutcome(
|
||||
name=case.name,
|
||||
slice=case.slice,
|
||||
language=case.language,
|
||||
origin=case.origin,
|
||||
detected_vuln=detected,
|
||||
silent_fix=silent,
|
||||
pair_correct=detected and silent,
|
||||
vuln_matched=matched,
|
||||
vuln_expected=expected_total,
|
||||
vuln_findings=len(vuln_findings),
|
||||
fix_findings=len(fix_findings),
|
||||
fix_leaks=len(leaks),
|
||||
recall=recall,
|
||||
misses=misses,
|
||||
leaks=leak_ids,
|
||||
commit_url=case.commit_url,
|
||||
cve=case.cve,
|
||||
)
|
||||
return _evaluate_inventory_pair(case, vuln_root, fix_root)
|
||||
|
||||
|
||||
def evaluate_catalog(cases: Sequence[PairCase]) -> PairEvalResult:
|
||||
outcomes = tuple(evaluate_pair(case) for case in cases)
|
||||
scorers: dict[str, dict[str, PairCorpusMetrics]] = {}
|
||||
vfc_cases = tuple(case for case in cases if case.slice == "vfc")
|
||||
if vfc_cases:
|
||||
overlay_outcomes = tuple(item for item in outcomes if item.slice == "vfc")
|
||||
scorers["vfc"] = {
|
||||
"overlay": _metrics(overlay_outcomes),
|
||||
"inventory": _metrics(tuple(_inventory_only(case) for case in vfc_cases)),
|
||||
}
|
||||
return PairEvalResult(
|
||||
outcomes=outcomes,
|
||||
overall=_metrics(outcomes),
|
||||
@@ -192,6 +171,7 @@ def evaluate_catalog(cases: Sequence[PairCase]) -> PairEvalResult:
|
||||
for slice_name in sorted({item.slice for item in outcomes})
|
||||
},
|
||||
signals=tuple(build_pair_signals(outcomes)),
|
||||
scorers=scorers,
|
||||
)
|
||||
|
||||
|
||||
@@ -220,6 +200,10 @@ def result_payload(result: PairEvalResult) -> dict[str, object]:
|
||||
"per_slice": {name: asdict(metrics) for name, metrics in result.per_slice.items()},
|
||||
"outcomes": [asdict(outcome) for outcome in result.outcomes],
|
||||
"signals": list(result.signals),
|
||||
"scorers": {
|
||||
slice_name: {scorer: asdict(metrics) for scorer, metrics in inner.items()}
|
||||
for slice_name, inner in result.scorers.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +274,52 @@ def _overlay_scan(target: Path) -> tuple[list[StaticFinding], list[OverlayRecord
|
||||
return findings, adjudicate(root=target, targets=targets, findings=findings)
|
||||
|
||||
|
||||
def _inventory_only(case: PairCase) -> PairOutcome:
|
||||
with tempfile.TemporaryDirectory(prefix="ousast-pair-inv-") as scratch:
|
||||
vuln_root = _materialize(Path(scratch) / "vuln", case.vuln_file, case.relpath)
|
||||
fix_root = _materialize(Path(scratch) / "fixed", case.fixed_file, case.relpath)
|
||||
return _evaluate_inventory_pair(case, vuln_root, fix_root)
|
||||
|
||||
|
||||
def _evaluate_inventory_pair(case: PairCase, vuln_root: Path, fix_root: Path) -> PairOutcome:
|
||||
vuln_findings = _quick_scan(vuln_root)
|
||||
fix_findings = _quick_scan(fix_root)
|
||||
vuln_result = evaluate_benchmark(
|
||||
run=BenchmarkRun(benchmark_run_id="pair", root=Path("/tmp/pair"), manifest=_manifest(case)),
|
||||
mode="quick",
|
||||
findings=vuln_findings,
|
||||
scan_id=None,
|
||||
scan_run_dir=None,
|
||||
)
|
||||
expected_total = len(case.expected)
|
||||
matched = vuln_result.metrics.matched_findings_total
|
||||
recall = matched / expected_total if expected_total else 1.0
|
||||
leaks = _fix_leaks(case, fix_findings)
|
||||
detected = recall + 1e-12 >= case.min_recall
|
||||
silent = not leaks
|
||||
misses = tuple(f"{miss.rule_id or '-'}:{miss.path}:{miss.cwe}" for miss in vuln_result.misses)
|
||||
leak_ids = tuple(finding.finding_id for finding in leaks)
|
||||
return PairOutcome(
|
||||
name=case.name,
|
||||
slice=case.slice,
|
||||
language=case.language,
|
||||
origin=case.origin,
|
||||
detected_vuln=detected,
|
||||
silent_fix=silent,
|
||||
pair_correct=detected and silent,
|
||||
vuln_matched=matched,
|
||||
vuln_expected=expected_total,
|
||||
vuln_findings=len(vuln_findings),
|
||||
fix_findings=len(fix_findings),
|
||||
fix_leaks=len(leaks),
|
||||
recall=recall,
|
||||
misses=misses,
|
||||
leaks=leak_ids,
|
||||
commit_url=case.commit_url,
|
||||
cve=case.cve,
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_overlay_pair(case: PairCase, vuln_root: Path, fix_root: Path) -> PairOutcome:
|
||||
vuln_findings, vuln_overlay = _overlay_scan(vuln_root)
|
||||
fix_findings, fix_overlay = _overlay_scan(fix_root)
|
||||
|
||||
Reference in New Issue
Block a user