mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
feat(model): phase 2 of flow-aware-ranking -- spend the region budget by evidence, weights published
`Evidence.score` orders pairs within a tier by one table of weights (open sinks, cleansed sinks, local / carried / near sources, entry, declared public), capped so volume stops counting, and zero for tier 0 so the weights can rank but never manufacture. `ScanBudget.order_by_evidence` runs the evidence pass over every region and cuts `max_regions` from each region's best (tier, score); `order_by_evidence()` is the one ordering function, used by the scan and by `benchmarks/ranking/position.py --evidence`. Off by default until the position benchmark says where the pinned CVEs land under it. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
7ce0a30161
commit
60d13d7b6e
@@ -21,6 +21,20 @@ TIER_CLEANSED = 2 # a source exists, but every sink call is bound or cleansed o
|
||||
TIER_OPEN = 3 # a source exists and some sink call is computed and uncleansed: ask first
|
||||
TIER_OPEN_PUBLIC = 4 # tier 3 and declared public, or a source on the same statement: ask very first
|
||||
|
||||
# The within-tier weights, published here because they are the whole of what "heuristic ranking" means in
|
||||
# phase 2 and a reader should be able to see every one of them at once. They order, they never exclude.
|
||||
WEIGHTS: dict[str, float] = {
|
||||
"open_sink": 1.0, # per sink call that is computed, uncleansed and unbound, up to OPEN_SINK_CAP
|
||||
"cleansed_sink": -0.5, # per sink call that is cleansed on the call or bound, up to CLEANSED_SINK_CAP
|
||||
"source_local": 2.0, # a modelled source in the region's own body
|
||||
"carried": 1.5, # stage one of the two-stage join says a field or dispatch value arrives tainted
|
||||
"source_near": 1.0, # a modelled source within the arbiter's callDepth
|
||||
"entry": 0.5, # parameters untrusted by contract
|
||||
"access_declared_public": 1.0,
|
||||
}
|
||||
OPEN_SINK_CAP = 5
|
||||
CLEANSED_SINK_CAP = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SinkEvidence:
|
||||
@@ -77,6 +91,33 @@ class Evidence:
|
||||
return TIER_OPEN_PUBLIC
|
||||
return TIER_OPEN
|
||||
|
||||
@property
|
||||
def score(self) -> float:
|
||||
"""Order WITHIN a tier, by the published weights below. Never across tiers: the tier is the predicate.
|
||||
|
||||
A weighted sum of the same exact facts the tier is made of, so it can rank but cannot manufacture --
|
||||
a pair with no sink scores nothing and stays tier 0 whatever its weights. Counts are capped because
|
||||
a file-scope region with 346 sinks is not 346 times more likely to hold the bug than one with 5;
|
||||
the cap is where the evidence stops saying more. Phase 2 of flow-aware-ranking.
|
||||
"""
|
||||
if self.tier == TIER_EXCLUDE:
|
||||
return 0.0 # no sink in reach: nothing to weigh, whatever the sources say
|
||||
w = WEIGHTS
|
||||
return (
|
||||
w["open_sink"] * min(len(self.open_sinks), OPEN_SINK_CAP)
|
||||
+ w["cleansed_sink"] * min(sum(1 for s in self.sinks if s.cleansed_on_call or self.is_bound(s)), CLEANSED_SINK_CAP)
|
||||
+ (w["source_local"] if self.source_local else 0.0)
|
||||
+ (w["carried"] if self.carried else 0.0)
|
||||
+ (w["source_near"] if self.source_near else 0.0)
|
||||
+ (w["entry"] if self.entry else 0.0)
|
||||
+ (w["access_declared_public"] if self.access_declared_public else 0.0)
|
||||
)
|
||||
|
||||
@property
|
||||
def order_key(self) -> tuple[int, float]:
|
||||
"""Higher sorts first: ``sorted(..., key=lambda e: e.order_key, reverse=True)``."""
|
||||
return (self.tier, self.score)
|
||||
|
||||
|
||||
def evidence_from_rows(
|
||||
rows: Sequence[Mapping[str, object]] | None,
|
||||
@@ -139,11 +180,14 @@ def _as_int(value: object) -> int:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CLEANSED_SINK_CAP",
|
||||
"OPEN_SINK_CAP",
|
||||
"TIER_CLEANSED",
|
||||
"TIER_EXCLUDE",
|
||||
"TIER_NO_SOURCE",
|
||||
"TIER_OPEN",
|
||||
"TIER_OPEN_PUBLIC",
|
||||
"WEIGHTS",
|
||||
"Evidence",
|
||||
"SinkEvidence",
|
||||
"evidence_from_rows",
|
||||
|
||||
+108
-41
@@ -26,10 +26,10 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..cpg.backend import CpgResult
|
||||
from .evidence import TIER_EXCLUDE, evidence_from_rows
|
||||
from .candidates import enumerate_candidates
|
||||
from .config_value import request_params as config_params
|
||||
from .dominance import request_params as dominance_params
|
||||
from .evidence import TIER_EXCLUDE, Evidence, evidence_from_rows
|
||||
from .ladder import Rung
|
||||
from .pipeline import ModelFinding, scan_region
|
||||
from .regions import MODULE_SCOPE, ScanRegion
|
||||
@@ -75,6 +75,12 @@ class ScanBudget:
|
||||
# language, under any dataflow model. Skipping it cannot lose a finding. It is the only tier that
|
||||
# excludes, which is why it is the only one allowed to act before the ranker is measured.
|
||||
prune_tier0: bool = True
|
||||
# Phase 2 of flow-aware-ranking: spend the region budget by EVIDENCE, not by static rank. The evidence
|
||||
# pass runs over `evidence_regions` regions in static order (0 = every region; it costs milliseconds a
|
||||
# pair), each region takes its best pair's (tier, score), and `max_regions` is then cut from that
|
||||
# order. Off by default until the position benchmark says where the pinned CVEs land under it.
|
||||
order_by_evidence: bool = False
|
||||
evidence_regions: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -185,50 +191,32 @@ def scan_repository(
|
||||
}
|
||||
)
|
||||
|
||||
# Phase 1: collect the whole scan's questions, for at most `max_regions` regions. Nothing is asked yet.
|
||||
work: list[tuple[str, ScanRegion, ArbiterSpec]] = []
|
||||
for region in ordered_regions[: limits.max_regions]:
|
||||
for family in region.families:
|
||||
spec = _spec_for(family, region.language)
|
||||
if spec is None:
|
||||
continue
|
||||
work.append((f"{len(work)}", region, spec))
|
||||
|
||||
# Phase 1b: the evidence tier of every taint pair, before any dataflow is asked. Same query, `evidenceOnly`,
|
||||
# no `reachableByFlows`. Recorded on the result and NOT used to reorder or skip anything -- that is a
|
||||
# later phase, and it must be measured against this record before it is allowed to change behaviour.
|
||||
# no `reachableByFlows`. Phase 0 recorded it; phase 1 prunes tier 0 on it; phase 2 spends the region
|
||||
# budget by it. Under phase 2 the pass runs over MORE regions than the budget, which is the point.
|
||||
tiers: list[tuple[str, str, str, int]] = []
|
||||
tier_by_rid: dict[str, int] = {}
|
||||
per_kind: dict[str, float] = {}
|
||||
evidence_by_pair: dict[tuple[str, str, str], Evidence] = {}
|
||||
if limits.tiering and callable(getattr(cpg, "run_batch", None)):
|
||||
grouped_taint = _grouped(work, hook_callbacks=hooks).get("taint", {})
|
||||
if grouped_taint:
|
||||
evidence_requests = {rid: {**dict(params), "evidenceOnly": "true"} for rid, params in grouped_taint.items()}
|
||||
tier_started = time.monotonic()
|
||||
answered_evidence = cpg.run_batch("taint", evidence_requests)
|
||||
per_kind["evidence"] = round(time.monotonic() - tier_started, 2)
|
||||
if answered_evidence is None:
|
||||
degradations.append({"stage": "model", "reason": "query_failed", "kind": "evidence", "requests": len(evidence_requests)})
|
||||
else:
|
||||
answered_evidence.pop("__census__", None)
|
||||
by_rid = {rid: (region, spec) for rid, region, spec in work}
|
||||
for rid, rows in answered_evidence.items():
|
||||
pair = by_rid.get(rid)
|
||||
if pair is None:
|
||||
continue
|
||||
region, spec = pair
|
||||
evidence = evidence_from_rows(
|
||||
rows,
|
||||
entry=_is_entry_point(region),
|
||||
# Rank 1.0 is the public tier. Provenance -- declared by a route contract versus
|
||||
# inferred from an absent decorator -- is not yet carried on ScanRegion, so this is
|
||||
# the proxy phase 0 has; the brief names it as the field to thread through next.
|
||||
access_declared_public=region.rank >= 1.0,
|
||||
bound_names=getattr(spec, "safe_shape_sinks", ()),
|
||||
)
|
||||
if evidence is not None:
|
||||
tiers.append((region.path, region.function or "", getattr(spec, "family", ""), evidence.tier))
|
||||
tier_by_rid[rid] = evidence.tier
|
||||
candidates = (
|
||||
ordered_regions[: (limits.evidence_regions or len(ordered_regions))]
|
||||
if limits.order_by_evidence
|
||||
else ordered_regions[: limits.max_regions]
|
||||
)
|
||||
tier_started = time.monotonic()
|
||||
evidence_by_pair, failed = _evidence_pass(cpg, _collect(candidates), hooks)
|
||||
per_kind["evidence"] = round(time.monotonic() - tier_started, 2)
|
||||
if failed:
|
||||
degradations.append({"stage": "model", "reason": "query_failed", "kind": "evidence", "requests": failed})
|
||||
tiers = [(path, function, family, evidence.tier) for (path, function, family), evidence in evidence_by_pair.items()]
|
||||
if limits.order_by_evidence and evidence_by_pair:
|
||||
ordered_regions = order_by_evidence(ordered_regions, evidence_by_pair)
|
||||
|
||||
# Phase 1: collect the whole scan's questions, for at most `max_regions` regions. Nothing is asked yet.
|
||||
work = _collect(ordered_regions[: limits.max_regions])
|
||||
tier_by_rid = {
|
||||
rid: evidence_by_pair[_pair_key(region, spec)].tier for rid, region, spec in work if _pair_key(region, spec) in evidence_by_pair
|
||||
}
|
||||
|
||||
# Phase 2: ONE invocation per query kind. JVM startup dominates a repository scan -- a call per region
|
||||
# per family put a ten-line file at four minutes and a thousand regions at roughly fifty hours -- while
|
||||
@@ -362,6 +350,85 @@ def scan_repository(
|
||||
)
|
||||
|
||||
|
||||
def _pair_key(region: ScanRegion, spec: ArbiterSpec) -> tuple[str, str, str]:
|
||||
return (region.path, region.function or "", getattr(spec, "family", ""))
|
||||
|
||||
|
||||
def _collect(regions: Sequence[ScanRegion]) -> list[tuple[str, ScanRegion, ArbiterSpec]]:
|
||||
"""Every (region, family) question with an arbiter, in the order given, with batch-local ids."""
|
||||
work: list[tuple[str, ScanRegion, ArbiterSpec]] = []
|
||||
for region in regions:
|
||||
for family in region.families:
|
||||
spec = _spec_for(family, region.language)
|
||||
if spec is None:
|
||||
continue
|
||||
work.append((f"{len(work)}", region, spec))
|
||||
return work
|
||||
|
||||
|
||||
def _evidence_pass(
|
||||
cpg: Any, work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], hooks: str
|
||||
) -> tuple[dict[tuple[str, str, str], Evidence], int]:
|
||||
"""The evidence vector of every taint pair in ``work``, keyed by (path, function, family).
|
||||
|
||||
Returns the vectors and the number of requests the engine did not answer (0 when it answered), so a
|
||||
failed pass is recorded rather than read as "no evidence anywhere".
|
||||
"""
|
||||
grouped_taint = _grouped(list(work), hook_callbacks=hooks).get("taint", {})
|
||||
if not grouped_taint:
|
||||
return {}, 0
|
||||
requests = {rid: {**dict(params), "evidenceOnly": "true"} for rid, params in grouped_taint.items()}
|
||||
answered = cpg.run_batch("taint", requests)
|
||||
if answered is None:
|
||||
return {}, len(requests)
|
||||
answered.pop("__census__", None)
|
||||
by_rid = {rid: (region, spec) for rid, region, spec in work}
|
||||
out: dict[tuple[str, str, str], Evidence] = {}
|
||||
for rid, rows in answered.items():
|
||||
pair = by_rid.get(rid)
|
||||
if pair is None:
|
||||
continue
|
||||
region, spec = pair
|
||||
evidence = evidence_from_rows(
|
||||
rows,
|
||||
entry=_is_entry_point(region),
|
||||
# Rank 1.0 is the public tier. Provenance -- declared by a route contract versus inferred from
|
||||
# an absent decorator -- is not yet carried on ScanRegion, so this is the proxy phase 0 has;
|
||||
# the brief names it as the field to thread through next.
|
||||
access_declared_public=region.rank >= 1.0,
|
||||
bound_names=getattr(spec, "safe_shape_sinks", ()),
|
||||
)
|
||||
if evidence is not None:
|
||||
out[_pair_key(region, spec)] = evidence
|
||||
return out, 0
|
||||
|
||||
|
||||
def order_by_evidence(regions: Sequence[ScanRegion], evidence: Mapping[tuple[str, str, str], Evidence]) -> list[ScanRegion]:
|
||||
"""Regions by their best pair's (tier, score), then by the static order they arrived in.
|
||||
|
||||
ONE definition, used by the scan to spend its budget and by `benchmarks/ranking/position.py` to say
|
||||
where a pinned CVE lands under it; if they drifted, the benchmark would measure the wrong thing. A
|
||||
region with no evidence at all -- no taint family, or a pass that failed -- sorts after every region
|
||||
that has some, in static order: the scan does not know it is safe, it knows nothing about it.
|
||||
"""
|
||||
best: dict[tuple[str, str], tuple[int, float]] = {}
|
||||
for (path, function, _family), vector in evidence.items():
|
||||
key = (path, function)
|
||||
best[key] = max(best.get(key, (-1, 0.0)), vector.order_key)
|
||||
indexed = list(enumerate(regions))
|
||||
return [
|
||||
region
|
||||
for _, region in sorted(
|
||||
indexed,
|
||||
key=lambda item: (
|
||||
-best.get((item[1].path, item[1].function or ""), (-1, 0.0))[0],
|
||||
-best.get((item[1].path, item[1].function or ""), (-1, 0.0))[1],
|
||||
item[0],
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _dispose(cpg: object) -> None:
|
||||
"""Release a built CPG's scratch tree. Never raises: cleanup must not turn a good scan into a failure."""
|
||||
cleanup = getattr(cpg, "cleanup", None)
|
||||
|
||||
Reference in New Issue
Block a user