mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
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
505 lines
18 KiB
Python
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
|