Files
Open_Ultra_SAST/src/openultrasast/model/scan.py
T
Marius CiepluchandClaude Opus 5.5 65dcb46b5e fix(scan): symlink, unsupported-language and unparsed-file gaps are owned by what they name
This extends dbcb230 to the remaining graph-integrity gaps. In the independent
evaluation these gaps were repository-wide:
- alerta: one symlink, `alerta/app.wsgi`, which is not even source, made 261 of 261
  questions unresolved.
- wger and Signal K: a language no frontend reads did the same.
- YesWiki: unparsed files did the same.

Now:
- the partition build records the paths a symlink boundary skipped (a directory
  ends in `/`) and the languages no frontend reads;
- `files_unparsed` carries its full paths;
- the scan demotes only questions about an owned path or under an owned directory,
  and only questions in an owned language.
Every gap stays in the report as a boundary. A gap that names nothing keeps its
partition-wide or repository-wide reach. Tier-0 pruning stays off, as before.

The tests that pinned repository-wide reach now assert ownership. The one about a
vendor exclusion masking an integrity failure keeps its intent, using a census gap
that names no file.

Retune after protocol-v1; exploratory only.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
2026-09-25 14:30:36 +02:00

1650 lines
84 KiB
Python

"""The repository driver (contributor-scan, Req 3).
Pair scoring built one CPG per pair and asked about one region. A repository has thousands of regions, and the
two costs that were free at pair scale dominate here:
* **CPG construction.** A build takes 5-30 seconds on a forty-line excerpt. One per region would make a
repository scan unusable, so this builds **one** and reuses it across every region.
* **Model calls.** The enumerator caps candidates at eight per region, which says nothing useful about a scan
with two thousand regions. The budget is a **total for the run**, spent on the highest-ranked regions first.
It bounds model calls and nothing else: when it runs out the JUDGE is withheld and the graph keeps deciding,
because arbitration costs nothing. Those regions are counted into ``regions_unasked``, and anything
``max_regions`` cut before arbitration into ``regions_unjudged``. A tool that silently analyses a tenth of a
repository and reports a clean result is worse than one that says what it did not look at.
Ordering puts rung before rank: what the model established outranks what the LLM merely proposed, whatever
the region's risk score said beforehand.
"""
from __future__ import annotations
import inspect
import json
import logging
import time
from collections.abc import Callable, Mapping, Sequence
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path
from typing import Any
from ..cpg.backend import TIMING, CpgResult
from ..semantic.facts import load_facts
from .candidates import enumerate_candidates
from .config_value import request_params as config_params
from .contracts import (
ChangeContext,
DeferredQuestion,
ExecutionBudget,
FamilyCoverage,
QuestionIdentity,
QuestionOutcome,
RankedQuestion,
ScopeDecision,
)
from .dominance import request_params as dominance_params
from .evidence import TIER_EXCLUDE, Evidence, evidence_from_rows
from .ladder import Rung
from .layout import is_vendored, layout_facts, with_layout
from .partitions import PartitionCoverage, build_partitions
from .pipeline import ModelFinding, scan_region
from .regions import MODULE_SCOPE, ScanRegion, affected_context
from .specs import ConfigSpec, DominanceSpec, TaintSpec, config_specs, dominance_specs, taint_specs
from .taint import request_params as taint_params
from .taxonomy import load_families
# The three arbiters a region can be routed to. Naming the union keeps the batcher honest: `object`
# would let a fourth spec kind reach `_grouped` and silently fall through to the taint branch.
ArbiterSpec = TaintSpec | DominanceSpec | ConfigSpec
# How far below an entry point a sink may sit and still be attributed to it.
#
# An entry point is the trust boundary, so its parameters ARE attacker input and a sink it reaches is its
# responsibility -- which is what makes VAmPI's SQL injection askable at all: a request parameter reaches
# `get_by_username`, is passed to `User.get_user` in another module, interpolated there and executed. A
# function-scoped question sees neither end of that.
#
# Bounded, and deliberately shallow. "Every method transitively reachable from a handler" is most of a
# repository: the question stops being about this entry point and the query stops being affordable. Three
# levels covers handler -> service -> data access, which is where these bugs live. Raising it is a
# measurement, not a preference.
ENTRY_POINT_CALL_DEPTH = 3
logger = logging.getLogger(__name__)
_RUNG_ORDER = {Rung.ENTAILED: 0, Rung.CORROBORATED: 1, Rung.SUSPICION: 2, Rung.EXECUTION_CONFIRMED: -1}
@dataclass(frozen=True)
class ScanBudget:
"""A total for the run, not a per-region cap."""
max_model_calls: int = 200
max_regions: int = 500
# Compute the evidence tier of every (region, family) pair and RECORD it. Phase 0 of flow-aware-ranking:
# the instrument runs on every real scan so later phases are measurable, and it changes nothing about
# what is asked or in what order. One extra query invocation without dataflow -- a JVM load, then
# milliseconds per pair.
tiering: bool = True
# Phase 1 of flow-aware-ranking: do not ask a taint question whose answer is known in advance. A pair at
# tier 0 has no sink of its family in reach, and a family with no sink cannot produce a flow -- in any
# 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)
class ModelScanResult:
findings: tuple[ModelFinding, ...] = ()
by_rung: Mapping[str, int] = field(default_factory=dict)
regions_scanned: int = 0
regions_unjudged: int = 0
# Arbitrated by the graph, but with the judge withheld because the budget was spent. Not the same as
# unjudged, and not the same as clean.
regions_unasked: int = 0
model_calls: int = 0
cost_usd: float = 0.0
seconds: float = 0.0
# The split, not just the total. Which stage a scan's wall-clock actually goes to decides real questions
# -- whether a warm Joern server would buy anything, whether the budget or the CPG is the ceiling -- and
# a single figure cannot answer any of them.
build_seconds: float = 0.0
query_seconds: float = 0.0
# Per query kind, because the total cannot say whether the cost is JVM startup (which a warm server
# would remove) or CPGQL evaluation (which it would not). That is the whole of the server-mode decision.
query_seconds_by_kind: Mapping[str, float] = field(default_factory=dict)
degradations: tuple[Mapping[str, object], ...] = ()
# The evidence tier of each (path, function, family) pair the scan collected, and the count per tier.
# Recorded, not acted on: this is what "the ranker got better" will be measured against.
tiers: tuple[tuple[str, str, str, int], ...] = ()
tier_counts: Mapping[int, int] = field(default_factory=dict)
# Taint requests not issued because their pair was tier 0. Exact, so not a degradation: nothing that
# could have been found was skipped. Reported so the request count is legible.
requests_pruned: int = 0
partitions: tuple[PartitionCoverage, ...] = ()
change_context: ChangeContext | None = None
scope: ScopeDecision | None = None
question_outcomes: tuple[QuestionOutcome, ...] = ()
family_coverage: tuple[FamilyCoverage, ...] = ()
@property
def arbitrate_seconds(self) -> float:
"""What is left once the CPG is built and queried: the judge calls and the arbiters themselves."""
return round(max(self.seconds - self.build_seconds - self.query_seconds, 0.0), 2)
def scan_repository(
root: Path,
regions: Sequence[ScanRegion],
*,
backend: Any,
client: Any | None = None,
model: str = "",
budget: ScanBudget | None = None,
execution_budget: ExecutionBudget | None = None,
ranking_mode: str | None = None,
unit: str = "repository",
population_complete: bool = False,
change_context: ChangeContext | None = None,
) -> ModelScanResult:
"""Run with an optional transaction deadline; graph ownership ends on every exit."""
if ranking_mode not in (None, "static", "evidence"):
raise ValueError("ranking_mode must be static or evidence")
limits = budget if budget is not None else ScanBudget()
if ranking_mode is not None:
limits = replace(limits, order_by_evidence=ranking_mode == "evidence")
mode = "evidence" if limits.order_by_evidence else "static"
layout = layout_facts()
regions = tuple(r for r in regions if not is_vendored(r.path, layout))
# Reject duplicate canonical questions before any engine work or resource ownership.
identities = [_identity(unit, r, f) for r in regions for f in r.families]
if len(set(identities)) != len(identities):
raise ValueError("duplicate question identity")
started = time.monotonic()
owned: list[Any] = []
try:
result = _scan_repository_impl(
root,
regions,
backend=backend,
client=client if execution_budget is None else None,
model=model,
budget=limits,
ranking_mode=mode,
unit=unit,
population_complete=population_complete,
change_context=change_context,
execution_budget=execution_budget,
owned=owned,
)
finally:
for cpg in owned:
_dispose(cpg)
# An engine session belongs to the transaction, so it ends with the transaction. Leaving it
# open leaks a JVM holding the configured heap, which is how the first session-enabled run
# exhausted its container and made every later request fail.
close = getattr(backend, "close_session", None)
if callable(close):
try:
close()
except Exception as error: # noqa: BLE001 -- tidying up must not fail a completed scan
logger.warning("could not end the engine session: %s", error)
diagnostics = list(result.degradations)
if execution_budget is not None:
if client is not None:
diagnostics.append({"stage": "model", "reason": "model_withheld_execution_budget"})
reasons = ["deadline_exhausted"] if time.monotonic() >= execution_budget.deadline_monotonic else []
for cpg in owned:
read_diagnostics = getattr(cpg, "execution_diagnostics", None)
if callable(read_diagnostics):
reasons.extend(read_diagnostics())
for reason in dict.fromkeys(reasons):
diagnostics.append({"stage": "model", "reason": reason})
return replace(result, degradations=tuple(diagnostics), seconds=round(time.monotonic() - started, 2))
def _scan_repository_impl(
root: Path,
regions: Sequence[ScanRegion],
*,
backend: Any,
client: Any | None = None,
model: str = "",
budget: ScanBudget | None = None,
execution_budget: ExecutionBudget | None = None,
owned: list[Any],
ranking_mode: str,
unit: str,
population_complete: bool,
change_context: ChangeContext | None,
) -> ModelScanResult:
"""Arbitrate a repository's regions under one CPG and one budget."""
limits = budget if budget is not None else ScanBudget()
started = time.monotonic()
degradations: list[Mapping[str, object]] = []
taxonomy = load_families()
build_started = time.monotonic()
# The dominant region language, so a failed `joern-parse` can retry through that frontend directly.
counts: dict[str, int] = {}
for region in regions:
counts[region.language] = counts.get(region.language, 0) + 1
dominant = max(counts, key=lambda name: (counts[name], name)) if counts else ""
declarations: dict[str, set[str]] = {}
for region in regions:
declarations.setdefault(region.path, set()).add(region.language)
if root.is_dir():
cpg = build_partitions(
root,
lambda source, language: _build(backend, source, language, execution_budget=execution_budget),
execution_budget,
{path: tuple(sorted(languages)) for path, languages in declarations.items()},
)
degradations.extend(_boundary_degradations(cpg))
else:
cpg = _build(backend, root, dominant, execution_budget=execution_budget)
build_seconds = round(time.monotonic() - build_started, 2)
if cpg is None or (hasattr(cpg, "graphs") and not cpg.graphs):
if cpg is not None:
owned.append(cpg)
scope, _ = _scope_work(
regions,
{},
limits,
unit,
ranking_mode,
population_complete,
failure="cpg_build_failed",
change_context=change_context,
execution_budget=execution_budget,
)
scope = replace(scope, unresolved_boundaries=tuple(dict.fromkeys((*scope.unresolved_boundaries, *getattr(cpg, "boundaries", ())))))
return ModelScanResult(
partitions=getattr(cpg, "partitions", ()),
change_context=change_context,
scope=scope,
family_coverage=_family_coverage(scope, ()),
by_rung=_empty_tally(),
regions_unjudged=len(regions),
seconds=round(time.monotonic() - started, 2),
build_seconds=build_seconds,
degradations=({"stage": "model", "reason": "cpg_build_failed", "detail": getattr(backend, "last_failure", "")},),
)
owned.append(cpg)
# A CPG the frontend built with files missing is not the repository the caller asked about, and no
# verdict over it can say anything about the code that was dropped. There is no rung for that, because
# the ladder labels verdicts and none was ever produced here -- so it is a degradation, named, with the
# files listed. See `cpg.backend._unparsed_files` for how a frontend drops a file while exiting 0.
if getattr(cpg, "unparsed", ()): # a backend need not carry the field
unparsed = tuple(cpg.unparsed)
degradations.append(
{
"stage": "model",
"reason": "files_unparsed",
"count": len(unparsed),
"files": [Path(name).name for name in unparsed[:20]],
# Whole, so the gap can be owned by exactly these files.
"owned_paths": [str(name).removeprefix("./") for name in unparsed],
}
)
# Count calls here rather than reading a client's own meter: the budget is this driver's contract and
# must hold for any client, including one that keeps no usage log.
counted = _CountingClient(client) if client is not None else None
collected: list[tuple[ModelFinding, float]] = []
scanned = 0
# Sort here rather than trusting the caller. The budget decides what goes unexamined, so the order it is
# spent in belongs to whoever holds the budget.
ordered_regions = sorted(with_layout(regions), key=lambda r: (not r.shipped, -r.rank, r.path, r.function or ""))
# The hook link table, built once for the whole scan. It is a property of the repository rather than of
# any region, and it is read from the source text because the graph does not carry it: php2cpg drops a
# registration's callback argument, so `add_filter("hook", array($this, "m"))` reaches the CPG as
# `add_filter("hook", )`. See `mapping.php_hook_callbacks`.
hooks = _hook_callbacks(root, regions)
# The region cap is a COVERAGE fact, not a tuning knob nobody needs to hear about. Paid Memberships Pro
# yields 4,463 regions from 637 files, so a default budget of 500 examines 11% of the repository -- and
# a report that does not say so invites its silence to be read as a clean bill of health for the other
# 89%. `budget_exhausted` is a different thing: that is the JUDGE running out, this is the work never
# being collected at all.
if len(ordered_regions) > limits.max_regions:
degradations.append(
{
"stage": "model",
"reason": "regions_truncated",
"examined": limits.max_regions,
"total": len(ordered_regions),
}
)
# Phase 1b: the evidence tier of every taint pair, before any dataflow is asked. Same query, `evidenceOnly`,
# 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]] = []
per_kind: dict[str, float] = {}
evidence_by_pair: dict[tuple[str, str, str], Evidence] = {}
context_rows: dict[QuestionIdentity, list[Mapping[str, object]]] = {}
if _within_deadline(execution_budget) and limits.tiering and callable(getattr(cpg, "run_batch", None)):
evidence_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(evidence_candidates, execution_budget=execution_budget),
hooks,
degradations=degradations,
context_rows=context_rows if change_context is not None else None,
context_files=_changed_files(change_context) if change_context is not None else None,
unit=unit,
)
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)
context_gaps: set[QuestionIdentity] = set()
if change_context is not None:
change_context, context_gaps = affected_context(
change_context, [_identity(unit, r, f) for r in ordered_regions for f in r.families], context_rows, root, execution_budget
)
# Authoritative selection is made once, from the actual final ranker order.
# Stable question IDs are the IDs sent to every execution request below.
#
# A tier-zero vector says no sink of this family is in reach, and a question with no sink cannot produce a
# flow. Pruning those is what makes a large population affordable: arbitration costs one to two seconds a
# question and a 673-file plugin asks 2,526 of them, while the structural pass that produced the vectors
# answered 17,700 requests in 44 s.
#
# It is switched off where the graph might be missing the very sink the vector did not see -- an unparsed
# file, an excluded tree, a shard that cannot see its neighbour. That is right, and it was applied to the
# whole scan, which is not: a census gap belongs to ONE partition, and a JavaScript file missing from the
# JavaScript graph is no reason to arbitrate every tier-zero question a PHP family asks. On the measured
# plugin that single distinction is the difference between 2,526 arbitrated questions and the few hundred
# that could say anything.
#
# `cross_partition_semantics_unresolved` leaves this set entirely, for the reason it left the integrity
# gaps: a PHP family's sink is PHP, so the existence of a second language cannot be why its vector saw none.
prune_blockers = {
"files_unparsed",
"cpg_empty",
"partition_file_census_incomplete",
"partition_file_census_unavailable",
"cpg_sharded",
"vendor_semantics_unresolved",
"symlink_context_unresolved",
"frontend_unsupported",
"source_unreadable",
"ambiguous_frontend_path",
"typescript_property_support_unvalidated",
}
blocking = [d for d in degradations if d.get("reason") in prune_blockers]
# A blocker that names no partition is about the whole read and stops pruning everywhere.
scope_limits = replace(limits, prune_tier0=False) if any(not d.get("census_language") for d in blocking) else limits
unprunable_languages = frozenset(str(d["census_language"]) for d in blocking if d.get("census_language"))
scope, work = _scope_work(
ordered_regions,
evidence_by_pair,
scope_limits,
unit,
ranking_mode,
population_complete,
change_context=change_context,
context_gaps=context_gaps,
execution_budget=execution_budget,
unprunable_languages=unprunable_languages,
)
outcomes: dict[str, QuestionOutcome] = {}
# 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
# the queries themselves are milliseconds once the CPG is loaded.
rows_by_id: dict[str, list[object]] = {}
too_expensive_ids: set[str] = set()
reserved_ids: set[str] = set()
census_reported = False
query_started = time.monotonic()
pruned = sum(q.reason == "tier_zero" for q in scope.deferred)
# Time the phases AFTER this one need. Querying fills rows; arbitration turns them into findings and the
# outcomes are assembled from that, and neither costs the engine anything -- so a scan that spends its
# whole budget on queries reports nothing at all, however many of them answered.
#
# Measured 2026-09-22, and by accident. A per-query timeout used to cancel the whole scan, which stopped
# the query phase early and left time to arbitrate: 78 findings. Removing that cancellation was right on
# its own terms and made the result worse, 0 of 406 questions completed, because the query phase then ran
# the full 2,700 s deadline to exhaustion. The cascade had been an accidental budget guard, and this is
# the deliberate one.
reserve = _arbitration_reserve(execution_budget)
for kind, requests in _grouped(work, hook_callbacks=hooks).items():
if not _within_deadline(execution_budget, reserve=reserve):
degradations.append({"stage": "model", "reason": "query_budget_reserved", "kind": kind, "requests": len(requests)})
break
kind_started = time.monotonic()
batch = getattr(cpg, "run_batch", None)
if callable(batch):
# The portioner is the retry layer, so it asks in SINGLE attempts: a portion timed with
# `run_batch` folds that method's own split-retry into the number, which once read 953 s for one
# portion and collapsed the sizer. `run_batch_once` is one invocation whose kill returns what it
# streamed. A backend without it falls back to `run_batch`, unchanged.
once = getattr(cpg, "run_batch_once", None)
expensive: dict[str, float] = {}
held: list[str] = []
answered_batch = _batched(
once if callable(once) else batch,
kind,
requests,
weights=_sink_weights(work, evidence_by_pair),
families=_request_families(work),
within=lambda: _within_deadline(execution_budget, reserve=reserve),
too_expensive=expensive,
unasked=held,
)
if held:
# NEVER ASKED, held back so arbitration has time. Counted as `query_failed` they read as an
# engine that could not answer, which is a different and more alarming fact.
reserved_ids.update(held)
degradations.append({"stage": "model", "reason": "query_budget_reserved", "kind": kind, "requests": len(held)})
if expensive:
# Asked ALONE and still over the ceiling: this question costs more than one invocation may
# spend, which is a fact about the question and not a transient failure. Reported apart from
# `query_failed`, so a reader can tell a scan that ran out of luck from one that met a question
# it cannot afford.
too_expensive_ids.update(expensive)
degradations.append(
{
"stage": "model",
"reason": "query_too_expensive",
"kind": kind,
"requests": len(expensive),
"max_seconds": max(expensive.values()),
}
)
if answered_batch is None:
# The engine could not answer. That is NOT an empty result, and recording it is what keeps a
# failed scan from reading as a clean repository: on a 117k-line PHP checkout every query
# failed at CPG load and the scan reported 500 regions examined with nothing found.
if len(requests) > len(held):
degradations.append({"stage": "model", "reason": "query_failed", "kind": kind, "requests": len(requests) - len(held)})
else:
# The graph census rides the batch under a reserved key. A frontend that fails every file
# still exits 0 with a valid CPG containing nothing, and `joern-parse` does not propagate
# its warnings, so without this a scan of an EMPTY graph is indistinguishable from a scan
# that found nothing -- which is how a two-file WordPress slice reported a clean bill of
# health over a 12KB graph with no methods in it at all.
census = answered_batch.pop("__census__", None)
if census and not census_reported:
entry = census[0] if isinstance(census[0], Mapping) else {}
methods = int(str(entry.get("methods", "0")) or 0)
if methods == 0 or entry.get("census_failure"):
degradations.append(
{
"stage": "model",
"reason": "cpg_empty" if "methods" in entry and methods == 0 else entry.get("census_failure", "cpg_empty"),
**{k: entry[k] for k in ("methods", "files", "missing_file_names", "census_language") if k in entry},
}
)
# More than one graph means some files could only be built apart from the rest, and a
# flow whose source is in one shard and whose sink is in another does not exist for any
# question we can ask. Reporting the split is the difference between a partial answer and
# a partial answer that looks whole.
shards = int(str(entry.get("shards", "1")) or 1)
if shards > 1:
degradations.append({"stage": "model", "reason": "cpg_sharded", "shards": shards})
census_reported = True
valid = {rid: rows for rid, rows in answered_batch.items() if rid in requests and isinstance(rows, list)}
rows_by_id.update(valid)
missing = len(requests) - len(valid) - len(expensive) - len(held)
if missing:
degradations.append({"stage": "model", "reason": "query_failed", "kind": kind, "requests": missing})
else: # a backend without batching still works, one call at a time
for rid, params in requests.items():
if not _within_deadline(execution_budget):
break
answered = cpg.run(kind, params)
if isinstance(answered, list):
rows_by_id[rid] = list(answered)
else:
degradations.append({"stage": "model", "reason": "query_failed", "kind": kind, "requests": 1})
per_kind[kind] = round(time.monotonic() - kind_started, 2)
query_seconds = round(time.monotonic() - query_started, 2)
# Phase 3: arbitrate from the rows already in hand. The arbiters are unchanged: each is handed a
# CpgResult that simply returns its own prefetched rows.
#
# The budget bounds MODEL CALLS, and nothing else. Arbitration is the CPG alone -- it costs no call, no
# token and no money -- so an exhausted budget withholds the JUDGE and lets the graph keep deciding. It
# used to break the loop instead, which spent a paid limit to stop unpaid work: on VAmPI that left 16 of
# 22 regions unexamined and missed a BOLA the graph entails for free.
#
# `judged` counts regions actually ARBITRATED and `unasked` those arbitrated with no judge behind them.
# Both are reported: a region the graph settled and a region the graph was silent about but nobody could
# afford to ask are not the same result, and collapsing them is the silent truncation this prevents.
judged: set[tuple[str, str | None]] = set()
unasked: set[tuple[str, str | None]] = set()
for rid, region, spec in work:
if not _within_deadline(execution_budget):
break
# Unanswered requests are never adjudicated from invented empty responses.
if rid not in rows_by_id:
continue
exhausted = counted is not None and counted.calls >= limits.max_model_calls
family = getattr(spec, "family", "")
prefetched = CpgResult(cpg_path=cpg.cpg_path, run=_prefetched(rows_by_id.get(rid, [])))
judge = None if exhausted else counted
try:
# Candidates exist to be PUT to the judge, and `scan_region` discards them the moment there is
# no judge to put them to. Enumerating them anyway costs a parse of the region's file per family
# per region: on a 637-file plugin that was 2,500 enumerations and 737 SECONDS of arbitration
# over rows that had already been fetched. Nobody should pay for a question nobody will ask.
candidates: list[dict[str, object]] = []
if judge is not None:
enumerated = enumerate_candidates(root, region, family, taxonomy=taxonomy)
candidates = [{"id": c.id, "text": c.text, "line": c.line} for c in enumerated.candidates]
found = scan_region(
prefetched,
spec,
path=region.path,
function=region.function or "",
client=judge,
model=model,
candidates=candidates,
# A repository's parameters are not all attacker input -- but an ENTRY POINT's are, by
# definition. That is the trust boundary, and it is the one place both halves of the old
# objection stop applying.
parameter_sources=_parameters_are_input(region),
call_depth=ENTRY_POINT_CALL_DEPTH if _is_entry_point(region) else 0,
)
except Exception as exc: # noqa: BLE001 -- one bad region must not end the scan
logger.warning("model scan failed for %s: %s", region.path, exc)
degradations.append({"stage": "model", "reason": "region_failed", "path": region.path})
outcomes[rid] = QuestionOutcome(
_identity(unit, region, family), "unresolved", "arbitration_failed", _rows_json(rows_by_id[rid])
)
continue
outcomes[rid] = QuestionOutcome(
_identity(unit, region, family), "completed", "query_answered_and_arbitrated", _rows_json(rows_by_id[rid])
)
judged.add((region.path, region.function))
if exhausted:
unasked.add((region.path, region.function))
collected.extend((finding, region.rank) for finding in found)
if unasked:
degradations.append({"stage": "model", "reason": "budget_exhausted", "regions_unasked": len(unasked)})
graph_gaps = tuple(str(d["reason"]) for d in degradations if d.get("reason") in GRAPH_INTEGRITY_GAPS)
# An integrity gap reaches the questions it can actually be wrong about, which for a census gap is the
# ONE partition it came from. A partitioned scan raises a gap per graph, and a repository-wide demotion
# let four JavaScript files -- a webpack build output, a minified select2, a build config and one source
# file -- take the admissibility of 4,425 PHP regions with them. Third instance of this shape in one week,
# after vendor exclusion acting as a global veto and line ambiguity inherited by every question, so the
# rule is now written in the design: a gap is owned by the narrowest scope that caused it.
scoped_graph_gaps: dict[str, list[str]] = {}
unscoped_graph_gaps: list[str] = []
# A gap that NAMES what it is about is owned by exactly that. The first evaluation on untouched code
# (benchmarks/independent/results-v1.json) completed no question at all on eight of eleven projects, and
# the traced causes all had this shape: `.eslintrc.js` missing from a JavaScript graph, one symlinked
# `app.wsgi` that is not even source, one unparsed file, a Go directory in a Python project -- and every
# question of the language, or of the repository, marked unresolved. A missing, skipped or unparsed file
# makes the graph incomplete ABOUT that file (a skipped directory, about what is under it); a language no
# frontend reads, about that language. An answer about anything else is still an answer, and every gap
# stays in the report as a boundary. A gap that names nothing reaches its partition, or everything.
path_owned_gaps: set[str] = set()
for item in degradations:
if item.get("reason") not in GRAPH_INTEGRITY_GAPS:
continue
partition = str(item.get("census_language") or "")
named_paths = item.get("owned_paths") or (item.get("missing_file_names") if partition else None)
if isinstance(named_paths, list) and named_paths and all(isinstance(name, str) for name in named_paths):
path_owned_gaps.update(name.removeprefix("./") for name in named_paths)
elif partition:
scoped_graph_gaps.setdefault(partition, []).append(str(item["reason"]))
else:
unscoped_graph_gaps.append(str(item["reason"]))
def owns(path: str) -> bool:
path = path.removeprefix("./")
return path in path_owned_gaps or any(gap.endswith("/") and path.startswith(gap) for gap in path_owned_gaps)
# A declared exclusion is a deliberate scope choice, not a failure to read the graph. It stays
# in coverage, and whether a particular answer needed the excluded semantics is carried by that
# question's own unresolved call destinations, not assumed for every answer in the repository.
declared_gaps = tuple(str(d["reason"]) for d in degradations if d.get("reason") in DECLARED_EXCLUSION_GAPS)
for question in scope.selected:
rid = question.identity.question_id
if rid not in outcomes:
has_answer = rid in rows_by_id
outcomes[rid] = QuestionOutcome(
question.identity,
"not_arbitrated" if has_answer else "unanswered",
"query_too_expensive"
if rid in too_expensive_ids
else "query_budget_reserved"
if rid in reserved_ids
else "deadline_exhausted"
if not _within_deadline(execution_budget)
else "query_unanswered",
_rows_json(rows_by_id[rid]) if has_answer else None,
)
if question.identity in context_gaps and outcomes[rid].status == "completed":
outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="change_context_incomplete")
if outcomes[rid].status == "completed":
# A gap with no partition is about the whole read and reaches everything. A scoped one reaches its
# own partition, and a question whose language the scan cannot place is treated as reached: an
# unplaceable question is not evidence that the gap missed it.
language = question.identity.language
reached = bool(unscoped_graph_gaps) or bool(scoped_graph_gaps.get(language)) or owns(question.identity.path)
if reached or (scoped_graph_gaps and not language):
outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="graph_incomplete")
scope = replace(scope, unresolved_boundaries=tuple(dict.fromkeys((*scope.unresolved_boundaries, *graph_gaps, *declared_gaps))))
ordered_outcomes = tuple(outcomes[q.identity.question_id] for q in scope.selected)
scanned = len(judged)
findings = _ordered(_deduplicated(collected))
# The graph has answered everything it is going to. Its scratch tree holds the CPG, the request files and
# joern's own working copy -- tens of megabytes per scan -- and nothing else reclaims it.
return ModelScanResult(
partitions=getattr(cpg, "partitions", ()),
findings=findings,
by_rung=_tally(findings),
regions_scanned=scanned,
regions_unjudged=max(len(regions) - scanned, 0),
regions_unasked=len(unasked),
model_calls=counted.calls if counted is not None else 0,
cost_usd=round(_cost(counted), 4),
seconds=round(time.monotonic() - started, 2),
build_seconds=build_seconds,
query_seconds=query_seconds,
query_seconds_by_kind=per_kind,
degradations=tuple(degradations),
tiers=tuple(tiers),
tier_counts={tier: sum(1 for t in tiers if t[3] == tier) for tier in sorted({t[3] for t in tiers})},
requests_pruned=pruned,
scope=scope,
question_outcomes=ordered_outcomes,
family_coverage=_family_coverage(scope, ordered_outcomes),
change_context=change_context,
)
# Reading the graph failed or produced something incomplete. An answer drawn from it cannot be
# trusted, so a completed outcome is demoted.
#
def _boundary_degradations(cpg: Any) -> list[dict[str, Any]]:
"""One degradation per boundary, carrying what it is about where the partition build recorded it: the
skipped paths of a symlink boundary, one entry per language no frontend reads. See the ownership rule in
`scan_repository`."""
paths = getattr(cpg, "boundary_paths", {}) or {}
languages = getattr(cpg, "boundary_languages", {}) or {}
out: list[dict[str, Any]] = []
for reason in cpg.boundaries:
if paths.get(reason):
out.append({"stage": "model", "reason": reason, "owned_paths": list(paths[reason])})
elif languages.get(reason):
out.extend({"stage": "model", "reason": reason, "census_language": language} for language in languages[reason])
else:
out.append({"stage": "model", "reason": reason})
return out
# The test for membership is whether the gap could make a reported answer WRONG, not whether it cost
# coverage. An unparsed file may hold the sanitizer that would have cleared a flow this scan reports,
# so `files_unparsed` belongs here and demoting is the conservative reading. A missing file the
# partition census expected is the same shape. That is why `cross_partition_semantics_unresolved` is
# NOT here: a flow this tool cannot follow from one language into another can hide a finding, never
# invent one, because a PHP sink is sanitised in PHP. Demoting on it protected nothing and cost
# everything -- measured 2026-09-21, where a WordPress plugin with 36 regions of admin JavaScript
# completed none of 520 questions while establishing 43 findings, and admission requires a completed
# outcome. Every multi-language repository was unadmittable by construction, which is every plugin
# this product was built for.
GRAPH_INTEGRITY_GAPS = frozenset(
{
"files_unparsed",
"cpg_empty",
"partition_file_census_incomplete",
"partition_file_census_unavailable",
"cpg_sharded",
"symlink_context_unresolved",
"frontend_unsupported",
"source_unreadable",
"ambiguous_frontend_path",
"typescript_property_support_unvalidated",
}
)
# Declared, intentional exclusions. The exclusion stays physical and stays reported; it is not
# evidence that any particular first-party answer is wrong. Treating it as one demoted every
# answer on every real repository, because every real repository excludes its dependencies.
# Declared, intentional exclusions. The exclusion stays physical and stays reported -- both classes are
# added to the scope's unresolved boundaries, so a declared gap is still published as unresolved; the class
# decides only whether it demotes every answer in the repository.
#
# `cross_partition_semantics_unresolved` is one of these: analysing each language separately, and not
# modelling a flow that leaves one for another, is a deliberate scope choice of exactly the kind vendor
# exclusion is. It was in the integrity set until 2026-09-21 and the reason for moving it is above.
DECLARED_EXCLUSION_GAPS = frozenset({"vendor_semantics_unresolved", "cross_partition_semantics_unresolved"})
def _identity(unit: str, region: ScanRegion, family: str) -> QuestionIdentity:
return QuestionIdentity(unit, region.language, region.path, region.function, family)
def _rows_json(rows: object) -> str:
return json.dumps(rows, sort_keys=True, separators=(",", ":"))
def _scope_work(
regions: Sequence[ScanRegion],
evidence: Mapping[tuple[str, str, str], Evidence],
limits: ScanBudget,
unit: str,
mode: str,
population_complete: bool,
*,
failure: str | None = None,
change_context: ChangeContext | None = None,
context_gaps: set[QuestionIdentity] | None = None,
execution_budget: ExecutionBudget | None = None,
# Languages whose graph might be missing the sink their vectors did not see. Pruning is withheld from
# their questions and from nobody else's.
unprunable_languages: frozenset[str] = frozenset(),
) -> tuple[ScopeDecision, list[tuple[str, ScanRegion, ArbiterSpec]]]:
"""Consume the ranker's final order once; the returned work IS selected scope."""
selected: list[RankedQuestion] = []
deferred: list[DeferredQuestion] = []
work: list[tuple[str, ScanRegion, ArbiterSpec]] = []
boundaries: list[str] = [] if population_complete else ["population_not_asserted_complete"]
if failure:
boundaries.append(failure)
if change_context is not None:
boundaries.extend(change_context.unresolved_boundaries)
for index, region in enumerate(regions):
for family in region.families:
identity = _identity(unit, region, family)
if failure is None and not _within_deadline(execution_budget):
failure = "deadline_exhausted"
boundaries.append(failure)
spec = None if failure else _spec_for(family, region.language)
vector = evidence.get((region.path, region.function or "", family))
facts: tuple[str, ...] = ("static_rank=" + str(region.rank),)
if failure:
facts += ("analysis_not_started=" + failure,)
elif vector is not None:
facts += ("normalized_evidence=" + _rows_json(asdict(vector)), "tier=" + str(vector.tier), "score=" + str(vector.score))
elif isinstance(spec, TaintSpec):
facts += ("evidence_unknown",)
boundaries.append("evidence_unknown:" + identity.question_id)
else:
facts += ("taint_evidence_not_applicable",)
if change_context is not None:
facts += (
"change_sites="
+ _rows_json(
{
"base_revision": change_context.base_revision,
"head_revision": change_context.head_revision,
"changed_paths": change_context.changed_paths,
"deleted_paths": change_context.deleted_paths,
"declaration_paths": change_context.declaration_paths,
"path_encoding": change_context.path_encoding,
}
),
)
facts += tuple(
"affected_relationship=" + _rows_json(r.to_payload()) for r in change_context.relationships if r.target == identity
)
if identity in (context_gaps or set()):
facts += ("change_context_incomplete",)
reason = (
failure
if failure
else "unsupported_family"
if spec is None
else failure
or (
"region_budget"
if index >= limits.max_regions
else "tier_zero"
if limits.prune_tier0
and identity.language not in unprunable_languages
and identity not in (context_gaps or set())
and isinstance(spec, TaintSpec)
and vector is not None
and vector.tier == TIER_EXCLUDE
else None
)
)
if reason:
deferred.append(DeferredQuestion(identity, reason, facts))
if reason == "unsupported_family":
boundaries.append("unsupported_family:" + identity.question_id)
else:
assert spec is not None
selected.append(
RankedQuestion(
identity,
float(-len(selected)),
facts,
vector.tier if vector is not None else None,
vector.score if vector is not None else None,
)
)
work.append((identity.question_id, region, spec))
return ScopeDecision(
"existing-static-evidence-v1", mode, population_complete, tuple(selected), tuple(deferred), tuple(boundaries)
), work
def _family_coverage(scope: ScopeDecision, outcomes: tuple[QuestionOutcome, ...]) -> tuple[FamilyCoverage, ...]:
population: list[RankedQuestion | DeferredQuestion] = [*scope.selected, *scope.deferred]
keys = sorted({(q.identity.unit, q.identity.language, q.identity.family) for q in population})
def matches(identity: QuestionIdentity, key: tuple[str, str, str]) -> bool:
return (identity.unit, identity.language, identity.family) == key
return tuple(
FamilyCoverage(
*key,
selected=sum(matches(q.identity, key) for q in scope.selected),
completed=sum(matches(q.identity, key) and q.status == "completed" for q in outcomes),
unanswered=sum(matches(q.identity, key) and q.status != "completed" for q in outcomes),
deferred=sum(matches(q.identity, key) for q in scope.deferred),
unsupported=sum(matches(q.identity, key) and q.reason == "unsupported_family" for q in scope.deferred),
)
for key in keys
)
# The share of what is left when querying starts that is kept back for arbitration and reporting. A quarter
# with a sixty-second floor: on the measured plugin the phases after querying took roughly 220 s for 400
# questions, and a scan too small for the floor to matter is a scan where the floor costs nothing.
ARBITRATION_RESERVE_SHARE = 0.2
ARBITRATION_RESERVE_FLOOR = 60.0
# A reserve may never take more than half of what is left. Held back without this, the floor swallowed a
# whole small budget and the query loop never ran at all -- a scan with a fifth of a second to spend must
# still spend it, and a control caught exactly that.
ARBITRATION_RESERVE_CAP = 0.5
def _arbitration_reserve(execution_budget: ExecutionBudget | None) -> float:
if execution_budget is None:
return 0.0
remaining = max(0.0, execution_budget.deadline_monotonic - time.monotonic())
return min(max(ARBITRATION_RESERVE_FLOOR, remaining * ARBITRATION_RESERVE_SHARE), remaining * ARBITRATION_RESERVE_CAP)
def _pair_key(region: ScanRegion, spec: ArbiterSpec) -> tuple[str, str, str]:
return (region.path, region.function or "", getattr(spec, "family", ""))
# What one arbitration request costs the engine, in the only unit that predicts it: the sink CALLS its
# family matches in scope. `reachableByFlows` is asked per matched sink, so a request whose family is `echo`
# asks a different question from one whose family is `$wpdb->get_var`, at identical request counts.
#
# Measured 2026-09-22 on one WordPress plugin's graph, at a fixed 100 requests each: SQL injection 147 s with
# six declared sinks, path 194 s with sixteen, output encoding 704 s with seven. The declared count does not
# predict cost and the request count does not predict cost. The matched count does.
#
# The budget is in sink visits rather than requests, so a family whose sinks are rare rides in one batch and
# a family whose sinks are everywhere is split until it fits. Splitting by request count, which is what the
# backend's failure retry does, cannot help: every part carries the same everywhere-sinks.
MAX_SINK_VISITS_PER_BATCH = 600
def _sink_weights(
work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], evidence_by_pair: Mapping[tuple[str, str, str], Any]
) -> dict[str, int]:
"""Sink calls in scope per request id, from the evidence pass that already counted them.
A request the evidence pass never saw weighs one. That is deliberate: an unknown cost must not make a
batch look cheap, but neither should it dominate a budget it was never measured against.
"""
weights: dict[str, int] = {}
for rid, region, spec in work:
evidence = evidence_by_pair.get(_pair_key(region, spec))
sinks = getattr(evidence, "sinks", ()) if evidence is not None else ()
weights[rid] = max(1, len(sinks))
return weights
def _changed_files(context: ChangeContext) -> tuple[str, ...] | None:
"""The head-side files a context location can be attached to, exactly the set `affected_context` reads.
``None`` when one cannot be sent intact -- a newline in a path -- so the engine itemises everything.
"""
changed = {context.decode_path(p) for p in context.changed_paths} | {context.decode_path(r.head_path) for r in context.renames}
if any("\n" in path or not path for path in changed):
return None
return tuple(sorted(changed))
def _request_families(work: Sequence[tuple[str, ScanRegion, ArbiterSpec]]) -> dict[str, str]:
"""The family of each request id: the unit the sizer learns a cost rate for."""
return {rid: str(getattr(spec, "family", "")) for rid, _region, spec in work}
def _sized_batches(requests: Mapping[str, Mapping[str, object]], weights: Mapping[str, int]) -> list[dict[str, Mapping[str, object]]]:
"""Chunks whose implied sink work stays under the budget, in the order the ranker gave.
The order is the ranker's and must stay the ranker's. A first attempt packed heaviest-first, which is
better packing and worse analysis: on the measured plugin the expensive output-encoding chunks ran first,
spent the deadline, and left injection with 114 questions asked and none answered -- the one family
holding a true positive on that subject. A deadline always cuts a tail, and the tail it should cut is the
one the ranker put last.
One request over the budget on its own gets its own chunk rather than being dropped: the scan's job is to
report that it is expensive, not to decide it is unaskable.
"""
ordered = list(requests)
chunks: list[dict[str, Mapping[str, object]]] = []
current: dict[str, Mapping[str, object]] = {}
carried = 0
for rid in ordered:
weight = weights.get(rid, 1)
if current and carried + weight > MAX_SINK_VISITS_PER_BATCH:
chunks.append(current)
current, carried = {}, 0
current[rid] = requests[rid]
carried += weight
if current:
chunks.append(current)
return chunks
def _batched(
batch: Any,
kind: str,
requests: Mapping[str, Mapping[str, object]],
*,
weights: Mapping[str, int],
families: Mapping[str, str] | None = None,
within: Callable[[], bool] = lambda: True,
too_expensive: dict[str, float] | None = None,
unasked: list[str] | None = None,
) -> dict[str, list[object]] | None:
"""Ask one kind in portions the engine can afford, and merge what answered.
A portion that fails leaves its own requests unanswered rather than discarding the answers beside it.
Everything answered is returned; only a kind where no portion answered at all is reported as unavailable.
A request that could not be answered even alone, and spent more than a portion's target trying, is
recorded in ``too_expensive`` with the seconds it took -- a different fact from a failure, and reported
as one.
"""
chunks = _sized_batches(requests, weights)
# The cost model is MEASURED, per family. The engine reports what each answer cost, so the sizer learns
# the fixed start as the wall time the answers do not account for and a seconds-per-sink-visit rate for
# each family from its own answers. One global rate fitted to whole portions could not tell one 90 s
# request from thirty 3 s ones: on the plugin it fitted the tail, fell to three visits per portion, and
# never grew back, because a fast portion under an assumed 70 s start read as no information at all.
sizer = _PortionSizer(weights, families)
if len(chunks) > 1:
logger.info(
"cpg %s split into %d batches by sink weight (%d request(s), %d sink visit(s))",
kind,
len(chunks),
len(requests),
sum(weights.get(rid, 1) for rid in requests),
)
merged: dict[str, list[object]] = {}
answered_any = False
# A QUEUE, not a fixed list, because a killed portion leaves a remainder that must be re-asked rather than
# dropped. The queue keeps working while there is time and something left to ask; `fit` sizes the head
# against the current cost model.
queue: list[dict[str, Mapping[str, object]]] = list(chunks)
# Requests that were RUNNING when a portion was killed. The engine answers in order and streams each answer
# as it finishes, so after a kill the first id without an answer is the one that held the process at the
# ceiling, and every id after it never started. Those are not evidence of anything and go straight back;
# the culprit is asked alone, after everything else, so an expensive question cannot hold the queue.
#
# This is the difference between a TRANSIENT kill and a genuinely expensive question. The earlier queue
# re-asked the whole remainder at half the size, and on the plugin that meant the same expensive
# output-encoding request killing portion after portion while the cheap ones behind it waited.
isolated: list[str] = []
while queue or isolated:
# Checked per portion, not per kind: one kind runs every portion inside a single loop iteration, so a
# per-kind check would let taint spend the whole budget before the reserve looked.
if not within():
left = [rid for c in queue for rid in c] + isolated
logger.info("cpg %s: %d request(s) left unasked to keep time for arbitration", kind, len(left))
if unasked is not None:
unasked.extend(left)
break
alone = not queue
chunk = {isolated[0]: requests[isolated.pop(0)]} if alone else sizer.fit(queue.pop(0), pending=queue)
started = time.monotonic()
answer = batch(kind, chunk)
seconds = time.monotonic() - started
timing = _answer_timing(answer)
answered_ids = {rid for rid in (answer or ()) if rid in chunk}
remainder = [rid for rid in chunk if rid not in answered_ids]
if answer is not None:
answered_any = True
merged.update({rid: rows for rid, rows in answer.items() if rid in chunk})
if "__census__" in answer and "__census__" not in merged:
merged["__census__"] = answer["__census__"]
# A kill is a partial answer from an engine that LOADED: the census arrived, so the process was
# working through requests in order. Nothing at all -- no census -- is a failure to start or a crash,
# and says nothing about which request was at fault.
killed = answer is not None and bool(remainder)
culprit = remainder[0] if killed else None
sizer.observe(chunk, seconds, answered=answered_ids, timing=timing, culprit=culprit)
if not remainder:
continue
if alone or len(chunk) == 1:
# Asked alone and still unanswered. Termination rests here: a single request is never re-queued.
if seconds >= PORTION_TARGET_SECONDS and too_expensive is not None:
too_expensive[remainder[0]] = round(seconds, 1)
logger.info("cpg %s: request %s unanswered when asked alone (%.0fs)", kind, remainder[0], seconds)
continue
if culprit is not None:
isolated.append(culprit)
rest = {rid: chunk[rid] for rid in remainder if rid != culprit}
logger.info(
"cpg %s: portion killed on request %s after %d of %d answered; %d re-queued, the culprit asked alone later",
kind,
culprit,
len(answered_ids),
len(chunk),
len(rest),
)
else:
# Nothing answered and no census: the sizer has already made this family dearer, so `fit` will cut
# the portion smaller next time. Heads shrink to single requests, and a single request is dropped.
rest = {rid: chunk[rid] for rid in remainder}
if rest:
queue.insert(0, rest)
return merged if answered_any else None
def _answer_timing(answer: Mapping[str, object] | None) -> dict[str, float]:
"""Seconds per answered request id, from the engine's own streamed cost; empty when it reported none."""
timing: dict[str, float] = {}
raw = answer.get(TIMING) if isinstance(answer, Mapping) else None
if not isinstance(raw, list):
return timing
for entry in raw:
if isinstance(entry, Mapping) and isinstance(entry.get("ms"), (int, float)):
rid = str(entry.get("id", ""))
# Shards each answer the same id; its cost is what all of them spent.
timing[rid] = timing.get(rid, 0.0) + float(entry["ms"]) / 1000.0
return timing
# What one portion should take: long enough that the fixed JVM start is a minority of it, short enough that
# a mis-estimate is bounded and a kill costs little.
PORTION_TARGET_SECONDS = 120.0
# A PRIOR for the start-and-load, used until one whole portion has shown the real one: its wall time less the
# time its answers report. Measured 70 s on a 4 MB graph once, and 25 s on the plugin in the trace that
# showed the constant was wrong -- which is why it is a prior and not a fact.
PORTION_FIXED_SECONDS = 70.0
# A family with no answers yet is costed at the dearest rate seen so far, never below this. Pessimism is the
# cheaper mistake: an over-estimate costs one extra JVM start, an under-estimate costs a ceiling.
PORTION_PRIOR_SECONDS_PER_VISIT = (PORTION_TARGET_SECONDS - PORTION_FIXED_SECONDS) / MAX_SINK_VISITS_PER_BATCH
# The least the engine's work may be budgeted per portion, whatever the fixed start is measured at.
PORTION_MIN_WORK_SECONDS = 10.0
class _PortionSizer:
"""Size portions in predicted SECONDS, from what the engine has shown each family costs.
Every answered request contributes its own reported cost to its family's rate (seconds per sink visit).
A whole portion also shows the fixed start: its wall time less what its answers account for. A killed
portion's culprit contributes a LOWER BOUND -- it ran for at least the time the others do not explain --
so its family becomes dearer and the next portion of that family is cut to fit. An engine that reports no
costs is costed from the portion's wall time, spread over its requests by prediction.
"""
def __init__(self, weights: Mapping[str, int], families: Mapping[str, str] | None = None) -> None:
self.weights = weights
self.families = families or {}
self.fixed = PORTION_FIXED_SECONDS
self._fixed_seen: list[float] = []
self.spent: dict[str, float] = {}
self.visited: dict[str, int] = {}
self.penalty: dict[str, float] = {}
def weight(self, chunk: Mapping[str, object]) -> int:
return sum(self.weights.get(rid, 1) for rid in chunk)
def rate(self, family: str) -> float:
"""Seconds per sink visit for a family: its own, else the dearest known, else the prior."""
known = [self.spent[f] / self.visited[f] for f in self.visited if self.visited[f] > 0]
base = self.spent[family] / self.visited[family] if self.visited.get(family) else max([PORTION_PRIOR_SECONDS_PER_VISIT, *known])
return base * self.penalty.get(family, 1.0)
def cost(self, rid: str) -> float:
return self.rate(self.families.get(rid, "")) * self.weights.get(rid, 1)
@property
def allowance(self) -> float:
"""Predicted engine seconds one portion may carry, once the fixed start is paid."""
return max(PORTION_MIN_WORK_SECONDS, PORTION_TARGET_SECONDS - self.fixed)
def fit(
self, chunk: dict[str, Mapping[str, object]], *, pending: list[dict[str, Mapping[str, object]]]
) -> dict[str, Mapping[str, object]]:
"""Cut this portion to the allowance, or grow it from the ones behind it while it still fits.
Always at least one request, however dear: the scan's job is to report that it is expensive, not to
decide it is unaskable. The rest of a cut portion goes to the FRONT of the queue, in ranker order.
"""
kept: dict[str, Mapping[str, object]] = {}
carried = 0.0
for rid, req in chunk.items():
if kept and carried + self.cost(rid) > self.allowance:
break
kept[rid] = req
carried += self.cost(rid)
rest = {rid: req for rid, req in chunk.items() if rid not in kept}
if rest:
pending.insert(0, rest)
return kept
# Grown id by id, not chunk by chunk: the queue's head is often one large chunk -- the remainder of the
# ranker's first cut -- and waiting for the whole of it to fit kept portions at one request after a
# collapse even once the engine had shown each costs a fraction of a second.
while pending:
head = pending[0]
taken = []
for rid in head:
if carried + self.cost(rid) > self.allowance:
break
taken.append(rid)
carried += self.cost(rid)
for rid in taken:
kept[rid] = head.pop(rid)
if head:
break
pending.pop(0)
return kept
def _learn(self, rid: str, seconds: float) -> None:
family = self.families.get(rid, "")
self.spent[family] = self.spent.get(family, 0.0) + max(0.0, seconds)
self.visited[family] = self.visited.get(family, 0) + self.weights.get(rid, 1)
def observe(
self,
chunk: Mapping[str, object],
seconds: float,
*,
answered: set[str] | frozenset[str],
timing: Mapping[str, float] | None = None,
culprit: str | None = None,
) -> None:
timing = timing or {}
done = [rid for rid in chunk if rid in answered]
whole = len(done) == len(chunk)
reported = all(rid in timing for rid in done)
if done and reported:
for rid in done:
self._learn(rid, timing[rid])
explained = sum(timing[rid] for rid in done)
if whole:
# The fixed start is what the answers do not explain. The median of what has been seen, so one
# slow start (a cold page cache, a busy host) does not resize every portion after it.
self._fixed_seen.append(max(0.0, seconds - explained))
self.fixed = sorted(self._fixed_seen)[len(self._fixed_seen) // 2]
elif done:
# No per-answer costs: spread the portion's work over its requests in proportion to prediction.
predicted = {rid: self.cost(rid) for rid in done}
total = sum(predicted.values()) or 1.0
work = max(0.0, seconds - self.fixed)
if whole and seconds < self.fixed:
self.fixed = seconds # a portion cannot finish before its own start: the prior was too high
for rid in done:
self._learn(rid, work * predicted[rid] / total)
explained = work
else:
explained = 0.0
if culprit is not None:
# It ran at least as long as nothing else accounts for, and did not finish. A lower bound, which is
# exactly what the next portion of its family should be sized against.
self._learn(culprit, seconds - self.fixed - (explained if reported else 0.0))
elif not done:
# Nothing answered and no census: the engine said nothing about cost, only that this was too much.
for family in {self.families.get(rid, "") for rid in chunk}:
self.penalty[family] = self.penalty.get(family, 1.0) * 2
logger.info(
"cpg portion of %d request(s), %d sink visit(s): %d answered in %.0fs; fixed start %.0fs, allowance %.0fs",
len(chunk),
self.weight(chunk),
len(done),
seconds,
self.fixed,
self.allowance,
)
def _collect(
regions: Sequence[ScanRegion], *, execution_budget: ExecutionBudget | None = None
) -> 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:
if not _within_deadline(execution_budget):
return work
spec = _spec_for(family, region.language)
if spec is None:
continue
work.append((f"{len(work)}", region, spec))
return work
EVIDENCE_PORTION = 4000 # requests per evidence invocation; roughly ten seconds of work behind one JVM start
def _portioned_evidence(cpg: Any, requests: Mapping[str, Mapping[str, object]]) -> dict[str, Any] | None:
"""The evidence pass in portions, merged. ``None`` only when no portion answered.
Typed as the batch returns it -- rows are the engine's mappings, and the caller reads them as such.
"""
ids = list(requests)
if len(ids) <= EVIDENCE_PORTION:
answered: dict[str, Any] | None = cpg.run_batch("taint", requests)
return answered
merged: dict[str, Any] = {}
answered_any = False
for start in range(0, len(ids), EVIDENCE_PORTION):
part = cpg.run_batch("taint", {rid: requests[rid] for rid in ids[start : start + EVIDENCE_PORTION]})
if part is None:
continue
answered_any = True
census = part.pop("__census__", None)
if census is not None and "__census__" not in merged:
merged["__census__"] = census
merged.update(part)
return merged if answered_any else None
def _evidence_pass(
cpg: Any,
work: Sequence[tuple[str, ScanRegion, ArbiterSpec]],
hooks: str,
*,
degradations: list[Mapping[str, object]] | None = None,
context_rows: dict[QuestionIdentity, list[Mapping[str, object]]] | None = None,
context_files: tuple[str, ...] | None = None,
unit: str = "repository",
) -> 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
# With change context, the engine itemises locations only in the files the change touched and summarises
# the rest, because those are the only locations `affected_context` can attach. `None` means the change's
# files cannot be named safely, and then every location is itemised as before.
context = {"contextEvidence": "true"} if context_rows is not None else {}
if context and context_files is not None:
context.update({"contextFilter": "true", "contextPaths": "\n".join(context_files)})
requests = {rid: {**dict(params), "evidenceOnly": "true", **context} for rid, params in grouped_taint.items()}
# Portioned by request count, because evidence has no per-request weight yet -- it is the pass that
# PRODUCES the weights. It is cheap per request, 17,700 in 44 s, and it was still one all-or-nothing
# batch: a 22,125-request evidence pass on a plugin's push transaction exceeded its ceiling under load and
# cost the whole scan. Streaming keeps what finished when a portion is killed; portioning keeps a single
# kill from being the whole pass.
answered = _portioned_evidence(cpg, requests)
if answered is None:
return {}, len(requests)
census = answered.pop("__census__", None)
if census and degradations is not None:
entry = census[0] if isinstance(census[0], Mapping) else {}
try:
methods = int(str(entry.get("methods", "0")))
shards = int(str(entry.get("shards", "1")))
except ValueError:
methods, shards = 0, 1
if methods <= 0 or entry.get("census_failure"):
degradations.append(
{
"stage": "model",
"reason": "cpg_empty" if "methods" in entry and methods <= 0 else entry.get("census_failure", "cpg_empty"),
# The partition travels here too. This pass raises the same gap as the arbitration pass,
# and carrying the language in one place and not the other left an unscoped copy that
# demoted every question in the repository -- the scoping fix looked applied and was not.
**{k: entry[k] for k in ("methods", "files", "missing_file_names", "census_language") if k in entry},
}
)
if shards > 1:
degradations.append({"stage": "model", "reason": "cpg_sharded", "shards": shards})
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 or rid not in requests or not isinstance(rows, list):
continue
region, spec = pair
if context_rows is not None:
context_rows[_identity(unit, region, spec.family)] = [r for r in rows if isinstance(r, Mapping)]
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", ()),
shipped=bool(getattr(region, "shipped", True)),
)
if evidence is not None:
out[_pair_key(region, spec)] = evidence
# Legacy ranker keys omit language. An overlapping frontend population cannot
# borrow another language's vector; partitioned callers retain distinct IDs.
languages_by_pair: dict[tuple[str, str, str], set[str]] = {}
for rid, region, spec in work:
if rid in requests:
languages_by_pair.setdefault(_pair_key(region, spec), set()).add(region.language)
for key, languages in languages_by_pair.items():
if len(languages) > 1:
out.pop(key, None)
if context_rows is not None:
_context_pass(cpg, work, context_rows, unit=unit)
return out, len(requests) - len(out)
def _context_pass(
cpg: Any,
work: Sequence[tuple[str, ScanRegion, ArbiterSpec]],
context_rows: dict[QuestionIdentity, list[Mapping[str, object]]],
*,
unit: str,
) -> None:
"""Collect change context for the families the evidence pass does not ask.
The evidence pass dispatches taint only, because only taint has an evidence vector to rank by.
Change context is a different question and every family needs its own: without this, dominance
and configuration questions reported `context_projection_unavailable` on every repository and
could never complete, which left 231 of 436 questions unresolved on the measured Node case.
Each family's own query supplies its own scope. A failure here leaves the family's context
absent, which the caller already treats as unresolved, so it degrades exactly as before.
"""
grouped = _grouped(list(work))
by_rid = {rid: (region, spec) for rid, region, spec in work}
for kind in ("dominance", "config"):
requests = {rid: {**dict(params), "contextEvidence": "true"} for rid, params in grouped.get(kind, {}).items()}
if not requests:
continue
answered = cpg.run_batch(kind, requests)
if answered is None:
continue
answered.pop("__census__", None)
for rid, rows in answered.items():
pair = by_rid.get(rid)
if pair is None or not isinstance(rows, list):
continue
region, spec = pair
context_rows[_identity(unit, region, spec.family)] = [r for r in rows if isinstance(r, Mapping)]
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)
if callable(cleanup):
try:
cleanup()
except Exception as exc: # noqa: BLE001 -- a scan that found things must not fail while tidying up
logger.warning("could not remove the cpg scratch directory: %s", exc)
def _within_deadline(budget: ExecutionBudget | None, *, reserve: float = 0.0) -> bool:
"""Is there time left, keeping back `reserve` seconds for whatever must happen after this?
A reserve of zero is the committed meaning and every caller but the query loop uses it. The query loop
passes one, because rows the engine returned are worth nothing until something turns them into findings.
"""
return budget is None or time.monotonic() < budget.deadline_monotonic - reserve
def _build(backend: Any, root: Path, language: str, *, execution_budget: ExecutionBudget | None = None) -> Any:
"""Build through the backend, passing the language and the vendored trees when the backend can use them."""
from .layout import vendored_directories
if not _within_deadline(execution_budget):
return None
excluded = vendored_directories(root) if root.is_dir() else ()
if execution_budget is not None:
try:
inspect.signature(backend.build).bind(root, language=language, exclude=excluded, execution_budget=execution_budget)
except (TypeError, ValueError):
# An old backend cannot silently turn a bounded push into an ordinary scan.
return None
if not _within_deadline(execution_budget):
return None
return backend.build(root, language=language, exclude=excluded, execution_budget=execution_budget)
try:
return backend.build(root, language=language, exclude=excluded)
except TypeError: # a backend from before the retry existed, including every test double
try:
return backend.build(root, language=language)
except TypeError:
return backend.build(root)
def _prefetched(rows: list[object]): # type: ignore[no-untyped-def]
"""A `run` that ignores the question and returns rows already fetched in the batch."""
def run(query: str, params: Mapping[str, object]) -> object:
del query, params
return rows
return run
def _grouped(
work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], *, hook_callbacks: str = ""
) -> dict[str, dict[str, Mapping[str, object]]]:
"""The work, keyed by query kind, with each request built by the arbiter's OWN parameter function.
Building the parameters here instead would be a second copy that drifts from the arbiter's -- the exact
shape of the five bugs the predecessor spent a session finding.
"""
grouped: dict[str, dict[str, Mapping[str, object]]] = {}
for rid, region, spec in work:
function = region.function or ""
entry = _is_entry_point(region)
if isinstance(spec, DominanceSpec):
kind, params = "dominance", dominance_params(spec, function=function, file=region.path)
elif isinstance(spec, ConfigSpec):
kind, params = "config", config_params(spec, function=function, file=region.path)
else:
kind, params = (
"taint",
taint_params(
spec,
function=function,
file=region.path,
parameter_sources=_parameters_are_input(region),
call_depth=ENTRY_POINT_CALL_DEPTH if entry else 0,
hook_callbacks=hook_callbacks,
field_parameter_sources=entry,
),
)
grouped.setdefault(kind, {})[rid] = params
return grouped
def _hook_callbacks(root: Path, regions: Sequence[ScanRegion]) -> str:
"""``hook:callback;hook:callback`` for the PHP files this scan is about, or ``""``.
Rendered here rather than in the query because a CPGQL parameter is a string, and rendered from the
regions rather than by walking the tree because the regions already name every file in scope.
"""
paths = sorted({region.path for region in regions if region.language == "php" and region.path})
if not paths:
return ""
from ..mapping import php_hook_callbacks
from ..preprocess import FileTarget
targets = [
FileTarget(path=path, absolute_path=str(root / path), language="php", loc=0, tags=[], has_fuzz_entry_point=False) for path in paths
]
table = php_hook_callbacks(root, targets)
return ";".join(f"{hook}:{name}" for hook, names in table.items() for name in names)
def _is_entry_point(region: ScanRegion) -> bool:
"""Is this region a place untrusted input actually enters?
A named function the entry-point mapper found, not a file the walker fell back to. The distinction is
what makes treating parameters as untrusted sound: a handler's parameters carry request data, an
arbitrary helper's carry whatever its caller had.
A module body is excluded even when the mapper marked it an entry point: a module has no parameters, so
there is nothing for the parameter-source rule to mean there.
So is a file the project does not declare it ships. Every one of libpng's entry points was a `main()` in
an example program or a test tool, and they carried the whole interprocedural budget with them; a
library has no entry points, so there was nothing else for it to anchor to. Those regions are still
arbitrated, function-locally -- not shipped is not unscanned.
"""
return region.source == "entry_point" and bool(region.function) and region.function != MODULE_SCOPE and region.shipped
def _parameters_are_input(region: ScanRegion) -> bool:
"""Are this region's own parameters attacker input? Only where the mapper saw a request arrive.
`function` is the mapper's catch-all: every named function it found, with no evidence of a request. On a
WordPress plugin that is 3,507 of 4,412 entries, and treating their parameters as input made every internal
helper a source -- `sendCancelAdminEmail($user, $old_level_id)`, a gateway's
`pmpro_checkout_before_change_membership_level($user_id, $morder)` fired by `do_action` with internal ids.
Adjudicated on PMPro 2026-09-23, those were false SQL injections. Routes, parsers and privileged surfaces
keep the rule; JavaScript and Python emit no `function` entries at all. The region is still followed into
its callees -- call depth stays with `_is_entry_point` -- only its parameters stop being sources.
"""
return _is_entry_point(region) and region.entry_kind != "function" and not _registry_supplied(region)
def _registry_supplied(region: ScanRegion) -> bool:
"""Was this entry registered in a string-keyed registry whose callbacks get the REGISTRY's arguments?
A WordPress hook callback is called by `do_action`/`apply_filters` with whatever the call site passes:
`pmpro_membership_history_profile_fields($user)` on `edit_user_profile` gets the `WP_User` WordPress
loaded, and an adjudicated SQL injection on PMPro was that object's `ID`. The dispatch facts say which
registrations do hand a callback request-borne arguments (a shortcode's `$atts`); every other one does not.
"""
if not region.entry_registry:
return False
for fact in load_facts().for_language(region.language).dispatches:
if region.entry_registry in fact.register:
return region.entry_registry not in fact.request_arguments
return False
def _spec_for(family: str, language: str) -> ArbiterSpec | None:
"""The spec whose arbiter can decide this family. Routing stays a lookup; dispatch stays in `_arbitrate`."""
if family == "access_control":
return dominance_specs(language=language).get(family)
if family == "config_secrets":
return config_specs(language=language).get(family)
return taint_specs(language=language).get(family)
def _deduplicated(collected: Sequence[tuple[ModelFinding, float]]) -> list[tuple[ModelFinding, float]]:
"""One finding per (site, family), keeping the strongest rung and counting the regions that reached it.
Task 2.4 let a region's question follow the call graph, and the moment it did, many entry points could
reach one shared sink -- libpng reported the same site twenty-six times. A contributor shown the same
defect twenty-six times learns to scroll past it, and "reached from 26 entry points" is the useful half
of that observation.
Two families at one site stay two findings: `echo $_GET[...]` is an injection question and an output
encoding question, and they have different answers.
"""
best: dict[tuple[str, str], tuple[ModelFinding, float]] = {}
for finding, rank in collected:
key = (finding.site, finding.family)
current = best.get(key)
if current is None:
best[key] = (finding, rank)
continue
kept, kept_rank = current
stronger = finding if _RUNG_ORDER[finding.rung] < _RUNG_ORDER[kept.rung] else kept
best[key] = (replace(stronger, reached_from=kept.reached_from + finding.reached_from), max(rank, kept_rank))
return list(best.values())
def _ordered(collected: Sequence[tuple[ModelFinding, float]]) -> tuple[ModelFinding, ...]:
"""Rung first, then the region's rank, then the site — a total order, so two runs agree."""
return tuple(finding for finding, _ in sorted(collected, key=lambda pair: (_RUNG_ORDER.get(pair[0].rung, 9), -pair[1], pair[0].site)))
def _empty_tally() -> dict[str, int]:
return {rung.value: 0 for rung in Rung}
def _tally(findings: Sequence[ModelFinding]) -> dict[str, int]:
tally = _empty_tally()
for finding in findings:
tally[finding.rung.value] = tally.get(finding.rung.value, 0) + 1
return tally
class _CountingClient:
"""Wraps a chat client so the driver counts its own spend, whatever the client records."""
def __init__(self, inner: Any) -> None:
self._inner = inner
self.calls = 0
def complete(self, **kwargs: Any) -> Any:
self.calls += 1
return self._inner.complete(**kwargs)
def cost_usd(self) -> float:
meter = getattr(self._inner, "cost_usd", None)
return float(meter()) if callable(meter) else 0.0
def _cost(client: object | None) -> float:
meter = getattr(client, "cost_usd", None)
return float(meter()) if callable(meter) else 0.0
__all__ = ["ModelScanResult", "ScanBudget", "scan_repository"]