Files
Open_Ultra_SAST/src/openultrasast/calibration.py
T

326 lines
12 KiB
Python
Raw Normal View History

2026-06-17 21:13:51 +02:00
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from enum import StrEnum
from pathlib import Path
from .findings import StaticFinding
2026-06-18 10:06:21 +02:00
from .rank import RankingScore
from .verification import VerificationResult, VerificationStatus
2026-06-21 21:02:18 +02:00
# Repeated rejections can push a scope below the normal 1.0 ranking floor.
MIN_CALIBRATED_PRIORITY = 0.1
2026-06-17 21:13:51 +02:00
class FalsePositiveReason(StrEnum):
UNREACHABLE_PATH = "unreachable_path"
MISSING_ATTACKER_CONTROL = "missing_attacker_control"
SANITIZER_DISPROVED = "sanitizer_disproved"
STATIC_RULE_MISMATCH = "static_rule_mismatch"
INCORRECT_MODEL_ASSUMPTION = "incorrect_model_assumption"
DUPLICATE = "duplicate"
INSUFFICIENT_IMPACT = "insufficient_impact"
UNSUPPORTED = "unsupported"
CONTRADICTED = "contradicted"
UNVERIFIED = "unverified"
class FindingOutcome(StrEnum):
VERIFIED = "verified"
FALSE_POSITIVE = "false_positive"
REJECTED = "rejected"
DUPLICATE = "duplicate"
UNSUPPORTED = "unsupported"
CONTRADICTED = "contradicted"
UNVERIFIED = "unverified"
@dataclass(frozen=True)
class FalsePositiveLearning:
finding_id: str
path: str
vulnerability_class: str
reason: FalsePositiveReason
evidence: str
scope: str
demotion: float = 0.5
@dataclass(frozen=True)
class RankingMetrics:
tier: str
verified_findings: int
false_positives: int
time_to_verification_seconds: float
missed_fixture_vulnerabilities: int
false_positive_reasons: dict[str, int]
@dataclass(frozen=True)
class RankingCalibration:
path: str
original_priority: float
calibrated_priority: float
applied_learning_ids: list[str]
retrieval_filter_adjustments: dict[str, str]
prompt_constraints: list[str]
def record_false_positive_learning(
finding: StaticFinding,
*,
reason: FalsePositiveReason,
evidence: str,
scope: str | None = None,
) -> FalsePositiveLearning:
if not evidence.strip():
raise ValueError("false-positive learning requires evidence")
vulnerability_class = _vulnerability_class(finding)
return FalsePositiveLearning(
finding_id=finding.finding_id,
path=finding.path,
vulnerability_class=vulnerability_class,
reason=reason,
evidence=evidence,
scope=scope or _default_scope(finding.path, vulnerability_class),
)
def learning_from_finding_outcome(
finding: StaticFinding,
*,
outcome: FindingOutcome,
evidence: str,
reason: FalsePositiveReason | None = None,
scope: str | None = None,
) -> FalsePositiveLearning | None:
if outcome == FindingOutcome.VERIFIED:
return None
return record_false_positive_learning(
finding,
reason=reason or _reason_from_outcome(outcome),
evidence=evidence,
scope=scope,
)
def compute_ranking_metrics(events: list[dict[str, object]], *, tier: str) -> RankingMetrics:
verified = 0
false_positives = 0
reason_counts: dict[str, int] = {}
verification_times: list[float] = []
missed = 0
for event in events:
if event.get("tier") != tier:
continue
outcome = event.get("outcome")
if outcome == FindingOutcome.VERIFIED:
verified += 1
if outcome in _FALSE_POSITIVE_OUTCOMES:
false_positives += 1
reason = str(event.get("reason", FalsePositiveReason.UNVERIFIED))
reason_counts[reason] = reason_counts.get(reason, 0) + 1
duration = event.get("time_to_verification_seconds")
if isinstance(duration, int | float) and not isinstance(duration, bool):
verification_times.append(float(duration))
missed_value = event.get("missed_fixture_vulnerabilities", 0)
if isinstance(missed_value, int) and not isinstance(missed_value, bool):
missed += missed_value
return RankingMetrics(
tier=tier,
verified_findings=verified,
false_positives=false_positives,
time_to_verification_seconds=round(sum(verification_times) / len(verification_times), 2) if verification_times else 0.0,
missed_fixture_vulnerabilities=missed,
false_positive_reasons=dict(sorted(reason_counts.items())),
)
def calibrate_ranking(ranking: RankingScore, learnings: list[FalsePositiveLearning]) -> RankingCalibration:
applicable = [learning for learning in learnings if _learning_applies(ranking.path, learning)]
calibrated = ranking.priority
prompt_constraints: list[str] = []
retrieval_adjustments: dict[str, str] = {}
for learning in applicable:
calibrated = max(MIN_CALIBRATED_PRIORITY, calibrated - learning.demotion)
2026-06-18 10:06:21 +02:00
prompt_constraints.append(
f"Do not repeat unsupported {learning.vulnerability_class} claim for scope {learning.scope} without new evidence."
)
2026-06-17 21:13:51 +02:00
retrieval_adjustments[learning.vulnerability_class] = learning.scope
return RankingCalibration(
path=ranking.path,
original_priority=ranking.priority,
calibrated_priority=round(calibrated, 2),
applied_learning_ids=[learning.finding_id for learning in applicable],
retrieval_filter_adjustments=retrieval_adjustments,
prompt_constraints=prompt_constraints,
)
def calibrate_rankings(
rankings: list[RankingScore],
learnings: list[FalsePositiveLearning],
) -> tuple[list[RankingScore], list[RankingCalibration]]:
calibrations = [calibrate_ranking(ranking, learnings) for ranking in rankings]
calibrated = [apply_ranking_calibration(ranking, calibration) for ranking, calibration in zip(rankings, calibrations, strict=True)]
return sorted(calibrated, key=lambda item: (-item.priority, item.path)), calibrations
def apply_ranking_calibration(ranking: RankingScore, calibration: RankingCalibration) -> RankingScore:
if ranking.path != calibration.path:
raise ValueError("calibration path does not match ranking path")
return RankingScore(
path=ranking.path,
surface=ranking.surface,
influence=ranking.influence,
reachability=ranking.reachability,
priority=max(MIN_CALIBRATED_PRIORITY, min(ranking.priority, calibration.calibrated_priority)),
2026-06-17 21:13:51 +02:00
rationale=f"{ranking.rationale}; calibrated by false-positive learning" if calibration.applied_learning_ids else ranking.rationale,
model_id=ranking.model_id,
static_boosts=ranking.static_boosts,
)
def write_false_positive_learnings(learnings: list[FalsePositiveLearning], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"learnings": [asdict(learning) for learning in learnings]}, indent=2, sort_keys=True) + "\n")
def write_ranking_calibrations(calibrations: list[RankingCalibration], path: Path) -> None:
applied = [calibration for calibration in calibrations if calibration.applied_learning_ids]
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"calibrations": [asdict(calibration) for calibration in applied]}, indent=2, sort_keys=True) + "\n")
def load_false_positive_learnings(path: Path) -> list[FalsePositiveLearning]:
if not path.exists():
return []
payload = json.loads(path.read_text())
learnings: list[FalsePositiveLearning] = []
for item in payload.get("learnings", []):
if not isinstance(item, dict):
continue
learnings.append(
FalsePositiveLearning(
finding_id=str(item["finding_id"]),
path=str(item["path"]),
vulnerability_class=str(item["vulnerability_class"]),
reason=FalsePositiveReason(item["reason"]),
evidence=str(item["evidence"]),
scope=str(item["scope"]),
demotion=float(item.get("demotion", 0.5)),
)
)
return learnings
def merge_false_positive_learnings(
existing: list[FalsePositiveLearning],
new: list[FalsePositiveLearning],
) -> list[FalsePositiveLearning]:
by_id: dict[str, FalsePositiveLearning] = {learning.finding_id: learning for learning in existing}
for learning in new:
by_id[learning.finding_id] = learning
return [by_id[finding_id] for finding_id in sorted(by_id)]
def learnings_from_verifications(
findings: list[StaticFinding],
verifications: list[VerificationResult],
) -> list[FalsePositiveLearning]:
finding_by_id = {finding.finding_id: finding for finding in findings}
learnings: list[FalsePositiveLearning] = []
for result in verifications:
if result.status == VerificationStatus.ACCEPTED:
continue
finding = finding_by_id.get(result.finding_id)
if finding is None:
continue
outcome = FindingOutcome.UNVERIFIED if result.status == VerificationStatus.NEEDS_EVIDENCE else FindingOutcome.REJECTED
learning = learning_from_finding_outcome(
finding,
outcome=outcome,
evidence=f"{result.counter_case} {result.required_next_step}".strip(),
reason=_reason_from_verification(finding, result.status),
)
if learning is not None:
learnings.append(learning)
return learnings
# A confirmed false positive lowers a finding's effective reachability multiplier
# by this factor in the project score (it does not delete the rule).
FP_REACHABILITY_FACTOR = 0.4
_BASE_REACH_MULT = {"reachable": 1.0, "inferred-file-surface": 0.6, "unknown": 0.4}
def fp_reachability_overrides(
findings: list[StaticFinding],
learnings: list[FalsePositiveLearning],
) -> dict[str, float]:
"""Lowered score multipliers for findings whose scope has a confirmed FP.
Keyed by ``finding_id``; consumed by the scoring model so a confirmed false
positive reduces the score penalty instead of the rule being deleted.
"""
overrides: dict[str, float] = {}
for finding in findings:
vulnerability_class = _vulnerability_class(finding)
if any(_learning_applies(finding.path, learning) and learning.vulnerability_class == vulnerability_class for learning in learnings):
base = _BASE_REACH_MULT.get(finding.reachability_status, _BASE_REACH_MULT["unknown"])
overrides[finding.finding_id] = round(base * FP_REACHABILITY_FACTOR, 3)
return overrides
def _reason_from_verification(finding: StaticFinding, status: VerificationStatus) -> FalsePositiveReason:
if finding.reachability_status == "unknown":
return FalsePositiveReason.UNREACHABLE_PATH
if status == VerificationStatus.NEEDS_EVIDENCE:
return FalsePositiveReason.UNVERIFIED
return FalsePositiveReason.CONTRADICTED
2026-06-17 21:13:51 +02:00
def _vulnerability_class(finding: StaticFinding) -> str:
if finding.tags:
return finding.tags[0]
return finding.title.lower().replace(" ", "_")[:64]
def _default_scope(path: str, vulnerability_class: str) -> str:
parent = Path(path).parent.as_posix()
return f"{parent if parent != '.' else path}:{vulnerability_class}"
def _learning_applies(path: str, learning: FalsePositiveLearning) -> bool:
if learning.path == path:
return True
if ":" not in learning.scope:
return False
scope_path, _ = learning.scope.rsplit(":", 1)
return scope_path not in ("", ".") and path.startswith(f"{scope_path}/")
def _reason_from_outcome(outcome: FindingOutcome) -> FalsePositiveReason:
if outcome == FindingOutcome.DUPLICATE:
return FalsePositiveReason.DUPLICATE
if outcome == FindingOutcome.UNSUPPORTED:
return FalsePositiveReason.UNSUPPORTED
if outcome == FindingOutcome.CONTRADICTED:
return FalsePositiveReason.CONTRADICTED
if outcome == FindingOutcome.UNVERIFIED:
return FalsePositiveReason.UNVERIFIED
if outcome in (FindingOutcome.FALSE_POSITIVE, FindingOutcome.REJECTED):
return FalsePositiveReason.INCORRECT_MODEL_ASSUMPTION
raise ValueError(f"unsupported finding outcome for false-positive learning: {outcome}")
_FALSE_POSITIVE_OUTCOMES = {
FindingOutcome.FALSE_POSITIVE,
FindingOutcome.REJECTED,
FindingOutcome.DUPLICATE,
FindingOutcome.UNSUPPORTED,
FindingOutcome.CONTRADICTED,
FindingOutcome.UNVERIFIED,
}