2026-06-17 21:13:51 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
2026-09-06 07:24:13 +02:00
|
|
|
from collections.abc import Collection
|
|
|
|
|
from dataclasses import asdict, dataclass, replace
|
2026-06-17 21:13:51 +02:00
|
|
|
from pathlib import Path
|
2026-09-06 07:24:13 +02:00
|
|
|
from typing import TYPE_CHECKING, Protocol
|
2026-06-17 21:13:51 +02:00
|
|
|
|
|
|
|
|
from .preprocess import FileTarget
|
|
|
|
|
|
2026-09-06 07:24:13 +02:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from .findings import StaticFinding
|
|
|
|
|
|
2026-06-17 21:13:51 +02:00
|
|
|
|
|
|
|
|
class RankingError(ValueError):
|
|
|
|
|
"""Raised when ranker output does not match the expected schema."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class RankingScore:
|
|
|
|
|
path: str
|
|
|
|
|
surface: int
|
|
|
|
|
influence: int
|
|
|
|
|
reachability: int
|
|
|
|
|
priority: float
|
|
|
|
|
rationale: str
|
|
|
|
|
model_id: str | None
|
|
|
|
|
static_boosts: list[str]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RankerClient(Protocol):
|
|
|
|
|
def complete_json(self, *, model: str, messages: list[dict[str, str]], timeout_seconds: int = 60) -> object:
|
|
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rank_targets(targets: list[FileTarget], model_rankings: object | None = None, model_id: str | None = None) -> list[RankingScore]:
|
|
|
|
|
model_by_path = parse_ranker_response(model_rankings, model_id) if model_rankings is not None else {}
|
|
|
|
|
rankings: list[RankingScore] = []
|
|
|
|
|
for target in targets:
|
|
|
|
|
heuristic = heuristic_rank(target)
|
|
|
|
|
override = model_by_path.get(target.path)
|
|
|
|
|
rankings.append(_merge_rankings(heuristic, override) if override is not None else heuristic)
|
|
|
|
|
return sorted(rankings, key=lambda item: (-item.priority, item.path))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rank_targets_with_model(
|
|
|
|
|
targets: list[FileTarget],
|
|
|
|
|
*,
|
|
|
|
|
client: RankerClient,
|
|
|
|
|
model: str,
|
|
|
|
|
chunk_size: int = 150,
|
|
|
|
|
timeout_seconds: int = 60,
|
|
|
|
|
) -> list[RankingScore]:
|
|
|
|
|
if chunk_size < 1:
|
|
|
|
|
raise RankingError("chunk_size must be at least 1")
|
|
|
|
|
model_rankings: list[dict[str, object]] = []
|
|
|
|
|
for start in range(0, len(targets), chunk_size):
|
|
|
|
|
chunk = targets[start : start + chunk_size]
|
|
|
|
|
response = client.complete_json(
|
|
|
|
|
model=model,
|
|
|
|
|
messages=[
|
|
|
|
|
{"role": "system", "content": _ranker_system_prompt()},
|
|
|
|
|
{"role": "user", "content": json.dumps([_target_prompt_payload(target) for target in chunk], sort_keys=True)},
|
|
|
|
|
],
|
|
|
|
|
timeout_seconds=timeout_seconds,
|
|
|
|
|
)
|
|
|
|
|
for ranking in parse_ranker_response(response, model).values():
|
|
|
|
|
model_rankings.append(asdict(ranking))
|
|
|
|
|
return rank_targets(targets, {"rankings": model_rankings}, model_id=model)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_rankings(rankings: list[RankingScore], path: Path) -> None:
|
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
payload = {"rankings": [asdict(ranking) for ranking in rankings]}
|
|
|
|
|
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def heuristic_rank(target: FileTarget) -> RankingScore:
|
|
|
|
|
tags = set(target.tags)
|
|
|
|
|
surface = 1
|
|
|
|
|
influence = 1
|
|
|
|
|
reachability = 1
|
|
|
|
|
boosts: list[str] = []
|
|
|
|
|
|
|
|
|
|
if "memory_unsafe" in tags:
|
|
|
|
|
surface = max(surface, 3)
|
|
|
|
|
boosts.append("memory_unsafe")
|
|
|
|
|
if "parser" in tags or "deserialization" in tags:
|
|
|
|
|
surface = max(surface, 4)
|
|
|
|
|
reachability = max(reachability, 3)
|
|
|
|
|
boosts.append("input_parser")
|
|
|
|
|
if "network_entry" in tags:
|
|
|
|
|
reachability = max(reachability, 4)
|
|
|
|
|
boosts.append("network_entry")
|
|
|
|
|
if "auth_boundary" in tags:
|
|
|
|
|
surface = max(surface, 3)
|
|
|
|
|
reachability = max(reachability, 3)
|
|
|
|
|
boosts.append("auth_boundary")
|
|
|
|
|
if "crypto" in tags:
|
|
|
|
|
influence = max(influence, 4)
|
|
|
|
|
boosts.append("crypto")
|
|
|
|
|
if "syscall_entry" in tags or "filesystem_entry" in tags:
|
|
|
|
|
surface = max(surface, 3)
|
|
|
|
|
boosts.append("privileged_boundary")
|
|
|
|
|
if target.has_fuzz_entry_point or "fuzzable" in tags:
|
|
|
|
|
surface = max(surface, 5)
|
|
|
|
|
reachability = max(reachability, 4)
|
|
|
|
|
boosts.append("fuzzable")
|
|
|
|
|
if target.loc >= 500:
|
|
|
|
|
influence = max(influence, 3)
|
|
|
|
|
boosts.append("large_file")
|
2026-06-18 07:57:06 +02:00
|
|
|
reachability_boost = _reachability_from_hints(target.reachability_hints)
|
|
|
|
|
if reachability_boost:
|
|
|
|
|
reachability = max(reachability, reachability_boost)
|
|
|
|
|
boosts.append("entry_point_reachable")
|
2026-06-17 21:13:51 +02:00
|
|
|
|
|
|
|
|
return RankingScore(
|
|
|
|
|
path=target.path,
|
|
|
|
|
surface=surface,
|
|
|
|
|
influence=influence,
|
|
|
|
|
reachability=reachability,
|
|
|
|
|
priority=composite_priority(surface, influence, reachability),
|
|
|
|
|
rationale=_heuristic_rationale(target, boosts),
|
|
|
|
|
model_id=None,
|
|
|
|
|
static_boosts=sorted(set(boosts)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def composite_priority(surface: int, influence: int, reachability: int) -> float:
|
|
|
|
|
return round(_clamp_score(surface) * 0.5 + _clamp_score(influence) * 0.2 + _clamp_score(reachability) * 0.3, 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_ranker_response(payload: object, model_id: str | None) -> dict[str, RankingScore]:
|
|
|
|
|
items = _ranking_items(payload)
|
|
|
|
|
rankings: dict[str, RankingScore] = {}
|
|
|
|
|
for item in items:
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
raise RankingError("each ranking item must be an object")
|
|
|
|
|
path = item.get("path")
|
|
|
|
|
rationale = item.get("rationale", "model ranking")
|
|
|
|
|
if not isinstance(path, str) or not path:
|
|
|
|
|
raise RankingError("ranking item requires non-empty path")
|
|
|
|
|
if not isinstance(rationale, str) or not rationale:
|
|
|
|
|
raise RankingError(f"ranking item for {path} requires rationale")
|
|
|
|
|
surface = _required_score(item, "surface", path)
|
|
|
|
|
influence = _required_score(item, "influence", path)
|
|
|
|
|
reachability = _required_score(item, "reachability", path)
|
|
|
|
|
static_boosts = item.get("static_boosts", [])
|
|
|
|
|
if not isinstance(static_boosts, list):
|
|
|
|
|
raise RankingError(f"ranking item for {path} has invalid static_boosts")
|
|
|
|
|
rankings[path] = RankingScore(
|
|
|
|
|
path=path,
|
|
|
|
|
surface=surface,
|
|
|
|
|
influence=influence,
|
|
|
|
|
reachability=reachability,
|
|
|
|
|
priority=composite_priority(surface, influence, reachability),
|
|
|
|
|
rationale=rationale,
|
|
|
|
|
model_id=model_id,
|
|
|
|
|
static_boosts=sorted(str(boost) for boost in static_boosts),
|
|
|
|
|
)
|
|
|
|
|
return rankings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ranking_items(payload: object) -> list[object]:
|
|
|
|
|
if isinstance(payload, list):
|
|
|
|
|
return payload
|
|
|
|
|
if isinstance(payload, dict):
|
|
|
|
|
rankings = payload.get("rankings")
|
|
|
|
|
if isinstance(rankings, list):
|
|
|
|
|
return rankings
|
|
|
|
|
raise RankingError("ranker response must be a list or object with rankings list")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _required_score(item: dict[str, object], field: str, path: str) -> int:
|
|
|
|
|
value = item.get(field)
|
|
|
|
|
if not isinstance(value, int) or isinstance(value, bool) or value < 1 or value > 5:
|
|
|
|
|
raise RankingError(f"ranking item for {path} requires integer {field} from 1 to 5")
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _merge_rankings(heuristic: RankingScore, model: RankingScore) -> RankingScore:
|
|
|
|
|
return RankingScore(
|
|
|
|
|
path=heuristic.path,
|
|
|
|
|
surface=max(heuristic.surface, model.surface),
|
|
|
|
|
influence=max(heuristic.influence, model.influence),
|
|
|
|
|
reachability=max(heuristic.reachability, model.reachability),
|
|
|
|
|
priority=composite_priority(
|
|
|
|
|
max(heuristic.surface, model.surface),
|
|
|
|
|
max(heuristic.influence, model.influence),
|
|
|
|
|
max(heuristic.reachability, model.reachability),
|
|
|
|
|
),
|
|
|
|
|
rationale=model.rationale,
|
|
|
|
|
model_id=model.model_id,
|
|
|
|
|
static_boosts=sorted(set(heuristic.static_boosts) | set(model.static_boosts)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _heuristic_rationale(target: FileTarget, boosts: list[str]) -> str:
|
|
|
|
|
if boosts:
|
|
|
|
|
return f"heuristic rank from tags: {', '.join(sorted(set(boosts)))}"
|
|
|
|
|
return f"baseline rank for {target.language} file"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _clamp_score(value: int) -> int:
|
|
|
|
|
return min(5, max(1, value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ranker_system_prompt() -> str:
|
|
|
|
|
return (
|
|
|
|
|
"Score each file for security review priority. Return JSON only with a rankings array. "
|
|
|
|
|
"Each item must include path, surface, influence, reachability, rationale, and optional static_boosts. "
|
|
|
|
|
"Scores must be integers from 1 to 5."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _target_prompt_payload(target: FileTarget) -> dict[str, object]:
|
|
|
|
|
return {
|
|
|
|
|
"path": target.path,
|
|
|
|
|
"language": target.language,
|
|
|
|
|
"loc": target.loc,
|
|
|
|
|
"tags": target.tags,
|
|
|
|
|
"has_fuzz_entry_point": target.has_fuzz_entry_point,
|
2026-06-18 07:57:06 +02:00
|
|
|
"reachability_hints": target.reachability_hints,
|
2026-06-17 21:13:51 +02:00
|
|
|
}
|
2026-06-18 07:57:06 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reachability_from_hints(hints: list[dict[str, object]]) -> int:
|
|
|
|
|
score = 0
|
|
|
|
|
for hint in hints:
|
|
|
|
|
access_level = hint.get("access_level")
|
|
|
|
|
kind = hint.get("kind")
|
|
|
|
|
if access_level == "public":
|
|
|
|
|
score = max(score, 5 if kind in {"route", "parser", "fuzz"} else 4)
|
|
|
|
|
elif access_level in {"authenticated", "contract-only/callback"}:
|
|
|
|
|
score = max(score, 4)
|
|
|
|
|
elif access_level == "role-restricted":
|
|
|
|
|
score = max(score, 3)
|
2026-06-18 10:06:21 +02:00
|
|
|
elif access_level == "review-required" or access_level == "local-only":
|
2026-06-18 07:57:06 +02:00
|
|
|
score = max(score, 2)
|
|
|
|
|
return score
|
2026-09-06 07:24:13 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# authorization-obligations Req 6.4: obligation findings rank by resource sensitivity and evidence label, and never above
|
|
|
|
|
# a sandbox-proven finding in the same run. Every other finding keeps its priority.
|
|
|
|
|
_OBLIGATION_LABEL_WEIGHT = {"declared_policy_violation": 0.9, "consistency_violation": 0.6, "function_local": 0.3}
|
|
|
|
|
_OBLIGATION_SENSITIVITY_WEIGHT = {"high": 1.0, "medium": 0.7, "low": 0.4}
|
|
|
|
|
_PROVEN_LEVELS = frozenset({"crash_reproduced", "exploit_demonstrated", "patch_validated", "root_cause_explained"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rank_obligations(findings: list[StaticFinding], *, proven_ids: Collection[str] = ()) -> list[StaticFinding]:
|
|
|
|
|
"""Rank obligation findings strictly below every proven finding in the run. Proof rungs live on verdicts, not on
|
|
|
|
|
``StaticFinding``, so the sandbox's proven finding ids are passed in; ``evidence_level`` is honoured as well."""
|
|
|
|
|
proven = [f.ranking_priority for f in findings if f.evidence_level in _PROVEN_LEVELS or f.finding_id in proven_ids]
|
|
|
|
|
ceiling = min(proven) if proven else None
|
|
|
|
|
ranked: list[StaticFinding] = []
|
|
|
|
|
for finding in findings:
|
|
|
|
|
if not finding.finding_id.startswith("obligation:"):
|
|
|
|
|
ranked.append(finding)
|
|
|
|
|
continue
|
|
|
|
|
label = next((tag.split(":", 1)[1] for tag in finding.tags if tag.startswith("obligation_evidence:")), "function_local")
|
|
|
|
|
weight = _OBLIGATION_SENSITIVITY_WEIGHT.get(finding.severity, 0.7) * _OBLIGATION_LABEL_WEIGHT.get(label, 0.3)
|
|
|
|
|
if ceiling is not None and weight >= ceiling:
|
|
|
|
|
weight = max(ceiling * 0.99, 0.0) * _OBLIGATION_LABEL_WEIGHT.get(label, 0.3) / 0.9 # strictly below the lowest proven finding
|
|
|
|
|
ranked.append(replace(finding, ranking_priority=round(weight, 4)))
|
|
|
|
|
return ranked
|