Files
Open_Ultra_SAST/src/openultrasast/benchmark.py
T
Marius CiepluchandClaude Opus 5 6c205b728f feat(learning-harness): corpus hygiene at load, with the defects it finds
Task 1.3. An expected row may carry a family label, stored opaquely because the
classifier owns the taxonomy and the corpus module never imports the learning
package. Every pair carries a computed reason when it cannot be scored: a
declared known limit wins, otherwise identical twin when the two excerpts are
the same code once the provenance header is stripped. No row is ever dropped.

Two corpus defects the new accounting surfaces, committed as an artifact beside
the numbers they cap. Nineteen repositories sit on both sides of the split, so a
learning path could be taught by one file of a repository and evaluated on
another. Ten groups of pairs share a vulnerable excerpt byte for byte, mostly
the same fix harvested twice at different granularity.

The split helper keeps every declared split, lets rows that named none inherit
their repository's side, and otherwise assigns a whole repository at once,
oldest first, so nothing straddles by our doing. Pair signals can now be
restricted to one split, which is what lets a learning path teach from train
rows only.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-06 09:14:51 +02:00

505 lines
18 KiB
Python

from __future__ import annotations
import json
import tomllib
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
from .findings import StaticFinding
@dataclass(frozen=True)
class BenchmarkSource:
type: str
path: str
@dataclass(frozen=True)
class ExpectedFinding:
cwe: str
vulnerability_class: str
path: str
evidence: str
rule_id: str | None = None
line: int | None = None
function: str | None = None
sink: str | None = None
mechanism: str | None = None
obligation: str | None = None # additive (authorization-obligations, Req 7.2): an OPERATION_KIND the labeled function owes
family: str | None = None # additive (learning-harness, Req 4): an opaque family id; the classifier owns the taxonomy
@dataclass(frozen=True)
class BenchmarkManifest:
name: str
language: str
frameworks: list[str]
setup: list[str]
source: BenchmarkSource
modes: list[str]
expected: list[ExpectedFinding]
known_noise: list[str]
baselines: list[str]
@dataclass(frozen=True)
class BaselineFinding:
tool: str
finding_id: str
path: str
line: int | None
rule_id: str | None
cwe: str | None
vulnerability_class: str | None
evidence: str
@dataclass(frozen=True)
class BenchmarkMiss:
cwe: str
vulnerability_class: str
path: str
evidence: str
reason: str
rule_id: str | None = None
@dataclass(frozen=True)
class BenchmarkBaselineDelta:
tool: str
matched_expected_total: int
missed_expected_total: int
extra_findings_total: int
@dataclass(frozen=True)
class BenchmarkCalibrationRecord:
cwe: str
vulnerability_class: str
path: str
evidence: str
failed_stage: str
next_improvement_candidate: str
reason: str
rule_id: str | None = None
@dataclass(frozen=True)
class BenchmarkFalsePositive:
finding_id: str
path: str
line: int | None
reason: str
rule_id: str = ""
@dataclass(frozen=True)
class RuleRecommendation:
rule_id: str
action: str # keep | shadow | loosen
matched: int
missed: int
false_positives: int
rationale: str
@dataclass(frozen=True)
class BenchmarkMetrics:
expected_findings_total: int
actual_findings_total: int
matched_findings_total: int
missed_findings_total: int
false_positive_findings_total: int
recall: float | None
precision: float | None
runtime_seconds: float | None
model_usage: dict[str, object]
artifact_links: dict[str, str]
per_rule: dict[str, dict[str, int]]
@dataclass(frozen=True)
class BenchmarkRun:
benchmark_run_id: str
root: Path
manifest: BenchmarkManifest
@dataclass(frozen=True)
class BenchmarkResult:
benchmark_run_id: str
benchmark_name: str
language: str
mode: str
scan_id: str | None
scan_run_dir: str | None
metrics: BenchmarkMetrics
misses: list[BenchmarkMiss]
false_positives: list[BenchmarkFalsePositive]
baseline_deltas: list[BenchmarkBaselineDelta]
calibration_records: list[BenchmarkCalibrationRecord]
rule_recommendations: list[RuleRecommendation]
def load_benchmark_manifest(path: Path) -> BenchmarkManifest:
payload = tomllib.loads(path.read_text())
source = payload.get("source", {})
expected = payload.get("expected", [])
return BenchmarkManifest(
name=str(payload["name"]),
language=str(payload["language"]),
frameworks=[str(item) for item in payload.get("frameworks", [])],
setup=[str(item) for item in payload.get("setup", [])],
source=BenchmarkSource(type=str(source.get("type", "local")), path=str(source["path"])),
modes=[str(item) for item in payload.get("modes", [])],
expected=[
ExpectedFinding(
cwe=str(item["cwe"]),
vulnerability_class=str(item.get("class", item.get("vulnerability_class", "unknown"))),
path=str(item["path"]),
evidence=str(item.get("evidence", "")),
rule_id=str(item["rule_id"]) if "rule_id" in item else None,
line=int(item["line"]) if "line" in item else None,
function=str(item["function"]) if "function" in item else None,
sink=str(item["sink"]) if "sink" in item else None,
mechanism=str(item["mechanism"]) if "mechanism" in item else None,
)
for item in expected
],
known_noise=[str(item) for item in payload.get("known_noise", [])],
baselines=[str(item) for item in payload.get("baselines", [])],
)
def resolve_benchmark_source(manifest_path: Path, manifest: BenchmarkManifest) -> Path:
if manifest.source.type != "local":
raise ValueError(f"unsupported benchmark source type: {manifest.source.type}")
source = Path(manifest.source.path)
if not source.is_absolute():
source = manifest_path.parent / source
return source.resolve()
def create_benchmark_run(target: Path, manifest: BenchmarkManifest) -> BenchmarkRun:
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
benchmark_run_id = f"{timestamp}-{uuid4().hex[:8]}"
root = target.resolve() / ".openultrasast" / "benchmarks" / benchmark_run_id
root.mkdir(parents=True, exist_ok=False)
return BenchmarkRun(benchmark_run_id=benchmark_run_id, root=root, manifest=manifest)
def load_findings(path: Path) -> list[StaticFinding]:
payload = json.loads(path.read_text())
return [StaticFinding(**item) for item in payload.get("findings", [])]
def load_baseline_findings(manifest_path: Path, manifest: BenchmarkManifest) -> list[BaselineFinding]:
baseline_findings: list[BaselineFinding] = []
for baseline in manifest.baselines:
path = Path(baseline)
if not path.is_absolute():
path = manifest_path.parent / path
if not path.exists():
continue
payload = json.loads(path.read_text())
items = payload.get("findings", payload if isinstance(payload, list) else [])
for item in items:
baseline_findings.append(
BaselineFinding(
tool=str(item.get("tool", path.stem)),
finding_id=str(item.get("finding_id", item.get("id", ""))),
path=str(item.get("path", "")),
line=int(item["line"]) if "line" in item and item["line"] is not None else None,
rule_id=str(item["rule_id"]) if "rule_id" in item else None,
cwe=str(item["cwe"]) if "cwe" in item else None,
vulnerability_class=str(item.get("class", item.get("vulnerability_class", ""))) or None,
evidence=str(item.get("evidence", "")),
)
)
return baseline_findings
def evaluate_benchmark(
*,
run: BenchmarkRun,
mode: str,
findings: list[StaticFinding],
scan_id: str | None,
scan_run_dir: Path | None,
runtime_seconds: float | None = None,
baseline_findings: list[BaselineFinding] | None = None,
) -> BenchmarkResult:
matched_finding_indexes: set[int] = set()
misses: list[BenchmarkMiss] = []
for expected in run.manifest.expected:
match_index = _matching_finding_index(expected, findings, matched_finding_indexes)
if match_index is None:
misses.append(_miss(expected))
else:
matched_finding_indexes.add(match_index)
false_positives = [_false_positive(finding) for index, finding in enumerate(findings) if index not in matched_finding_indexes]
baseline_deltas = _baseline_deltas(run.manifest.expected, baseline_findings or [])
calibration_records = [_calibration_record(miss) for miss in misses]
per_rule = _per_rule_metrics(findings, matched_finding_indexes, misses)
rule_recommendations = _rule_recommendations(per_rule)
expected_total = len(run.manifest.expected)
actual_total = len(findings)
missed_total = len(misses)
matched_total = expected_total - missed_total
false_positive_total = len(false_positives)
recall = matched_total / expected_total if expected_total else None
precision = matched_total / actual_total if actual_total else None
return BenchmarkResult(
benchmark_run_id=run.benchmark_run_id,
benchmark_name=run.manifest.name,
language=run.manifest.language,
mode=mode,
scan_id=scan_id,
scan_run_dir=str(scan_run_dir) if scan_run_dir else None,
metrics=BenchmarkMetrics(
expected_findings_total=expected_total,
actual_findings_total=actual_total,
matched_findings_total=matched_total,
missed_findings_total=missed_total,
false_positive_findings_total=false_positive_total,
recall=recall,
precision=precision,
runtime_seconds=runtime_seconds,
model_usage={},
artifact_links=_artifact_links(scan_run_dir),
per_rule=per_rule,
),
misses=misses,
false_positives=false_positives,
baseline_deltas=baseline_deltas,
calibration_records=calibration_records,
rule_recommendations=rule_recommendations,
)
def write_benchmark_artifacts(run: BenchmarkRun, manifest_path: Path, result: BenchmarkResult) -> None:
(run.root / "benchmark_manifest.toml").write_text(manifest_path.read_text())
(run.root / "benchmark_result.json").write_text(json.dumps(_jsonable(asdict(result)), indent=2, sort_keys=True) + "\n")
(run.root / "calibration_records.json").write_text(
json.dumps(_jsonable([asdict(record) for record in result.calibration_records]), indent=2, sort_keys=True) + "\n"
)
(run.root / "external_baseline_deltas.json").write_text(
json.dumps(_jsonable([asdict(delta) for delta in result.baseline_deltas]), indent=2, sort_keys=True) + "\n"
)
(run.root / "rule_policy_delta.json").write_text(
json.dumps(_jsonable([asdict(rec) for rec in result.rule_recommendations]), indent=2, sort_keys=True) + "\n"
)
def _matching_finding_index(expected: ExpectedFinding, findings: list[StaticFinding], used_indexes: set[int]) -> int | None:
for index, finding in enumerate(findings):
if index in used_indexes:
continue
if _matches_expected(expected, finding):
return index
return None
def _matches_expected(expected: ExpectedFinding, finding: StaticFinding) -> bool:
expected_path = expected.path.strip("*")
if expected.rule_id and not finding.finding_id.startswith(f"{expected.rule_id}:"):
return False
if expected.line is not None and finding.line != expected.line:
return False
if expected_path and expected_path not in finding.path:
return False
if expected.function and finding.function_name != expected.function:
return False
if expected.sink and _normalize(expected.sink) not in _finding_text(finding):
return False
if expected.evidence and not _evidence_matches(expected.evidence, finding):
return False
return _cwe_matches(expected.cwe, finding)
def _miss(expected: ExpectedFinding) -> BenchmarkMiss:
return BenchmarkMiss(
cwe=expected.cwe,
vulnerability_class=expected.vulnerability_class,
path=expected.path,
evidence=expected.evidence,
reason="no OpenUltraSAST finding matched the expected benchmark vulnerability",
rule_id=expected.rule_id,
)
def _false_positive(finding: StaticFinding) -> BenchmarkFalsePositive:
return BenchmarkFalsePositive(
finding_id=finding.finding_id,
path=finding.path,
line=finding.line,
reason="finding did not match any expected benchmark vulnerability",
rule_id=_rule_id_of(finding.finding_id),
)
def _rule_id_of(finding_id: str) -> str:
return finding_id.split(":", 1)[0]
def _per_rule_metrics(
findings: list[StaticFinding],
matched_finding_indexes: set[int],
misses: list[BenchmarkMiss],
) -> dict[str, dict[str, int]]:
per_rule: dict[str, dict[str, int]] = {}
def bucket(rule_id: str) -> dict[str, int]:
return per_rule.setdefault(rule_id, {"matched": 0, "missed": 0, "false_positives": 0})
for index, finding in enumerate(findings):
rule_id = _rule_id_of(finding.finding_id)
key = "matched" if index in matched_finding_indexes else "false_positives"
bucket(rule_id)[key] += 1
for miss in misses:
if miss.rule_id:
bucket(miss.rule_id)["missed"] += 1
return dict(sorted(per_rule.items()))
def _rule_recommendations(per_rule: dict[str, dict[str, int]]) -> list[RuleRecommendation]:
recommendations: list[RuleRecommendation] = []
for rule_id, counts in per_rule.items():
matched, missed, fps = counts["matched"], counts["missed"], counts["false_positives"]
if fps > 0:
action, rationale = "shadow", "rule produced false positives; stage it before it drags precision"
elif missed > 0:
action, rationale = "loosen", "rule missed expected findings; widen or lower its evidence floor"
else:
action, rationale = "keep", "rule matched expected findings with no false positives"
recommendations.append(
RuleRecommendation(
rule_id=rule_id,
action=action,
matched=matched,
missed=missed,
false_positives=fps,
rationale=rationale,
)
)
return recommendations
def _baseline_deltas(expected_findings: list[ExpectedFinding], baseline_findings: list[BaselineFinding]) -> list[BenchmarkBaselineDelta]:
tools = sorted({finding.tool for finding in baseline_findings})
deltas: list[BenchmarkBaselineDelta] = []
for tool in tools:
tool_findings = [finding for finding in baseline_findings if finding.tool == tool]
matched_indexes: set[int] = set()
matched_expected_total = 0
for expected in expected_findings:
match_index = _matching_baseline_index(expected, tool_findings, matched_indexes)
if match_index is not None:
matched_indexes.add(match_index)
matched_expected_total += 1
deltas.append(
BenchmarkBaselineDelta(
tool=tool,
matched_expected_total=matched_expected_total,
missed_expected_total=len(expected_findings) - matched_expected_total,
extra_findings_total=len(tool_findings) - len(matched_indexes),
)
)
return deltas
def _matching_baseline_index(expected: ExpectedFinding, findings: list[BaselineFinding], used_indexes: set[int]) -> int | None:
for index, finding in enumerate(findings):
if index in used_indexes:
continue
if _baseline_matches_expected(expected, finding):
return index
return None
def _baseline_matches_expected(expected: ExpectedFinding, finding: BaselineFinding) -> bool:
expected_path = expected.path.strip("*")
if expected.rule_id and finding.rule_id and finding.rule_id != expected.rule_id:
return False
if expected.line is not None and finding.line is not None and finding.line != expected.line:
return False
if expected_path and expected_path not in finding.path:
return False
if finding.cwe and finding.cwe.lower() != expected.cwe.lower():
return False
return not (expected.sink and expected.sink.lower() not in _normalize(finding.evidence))
def _calibration_record(miss: BenchmarkMiss) -> BenchmarkCalibrationRecord:
# A miss tied to a known rule points the loop at that rule; an unattributed
# miss (no rule fired for the class) is a coverage gap for a language hunter.
candidate = f"rule:{miss.rule_id}" if miss.rule_id else "language_hunter"
return BenchmarkCalibrationRecord(
cwe=miss.cwe,
vulnerability_class=miss.vulnerability_class,
path=miss.path,
evidence=miss.evidence,
failed_stage="benchmark_ground_truth_matching",
next_improvement_candidate=candidate,
reason=miss.reason,
rule_id=miss.rule_id,
)
def _artifact_links(scan_run_dir: Path | None) -> dict[str, str]:
if scan_run_dir is None:
return {}
return {
"findings": str(scan_run_dir / "findings.json"),
"manifest": str(scan_run_dir / "manifest.json"),
"report": str(scan_run_dir / "report.md"),
"sarif": str(scan_run_dir / "report.sarif"),
}
def _cwe_matches(cwe: str, finding: StaticFinding) -> bool:
text = _finding_text(finding)
cwe_lower = cwe.lower()
if "cwe-" not in text:
return True
return cwe_lower in text
def _evidence_matches(evidence: str, finding: StaticFinding) -> bool:
tokens = [token for token in _normalize(evidence).split() if len(token) > 2]
if not tokens:
return True
text = _finding_text(finding)
return any(token in text for token in tokens)
def _finding_text(finding: StaticFinding) -> str:
parts = [
finding.finding_id,
finding.title,
finding.rationale,
finding.function_name or "",
" ".join(finding.tags),
" ".join(finding.reachability_conditions),
]
return _normalize(" ".join(parts))
def _normalize(value: str) -> str:
normalized = value.lower().replace("memcpy", "memcpy memory copy")
normalized = normalized.replace("copies", "copy")
return " ".join(normalized.replace("-", " ").replace("_", " ").split())
def _jsonable(value: object) -> object:
if isinstance(value, Path):
return str(value)
if isinstance(value, dict):
return {key: _jsonable(item) for key, item in value.items()}
if isinstance(value, list):
return [_jsonable(item) for item in value]
return value