2026-09-09 07:28:30 +02:00
|
|
|
"""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
|
2026-09-09 08:46:21 +02:00
|
|
|
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.
|
2026-09-09 07:28:30 +02:00
|
|
|
|
|
|
|
|
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 logging
|
|
|
|
|
import time
|
|
|
|
|
from collections.abc import Mapping, Sequence
|
2026-09-09 15:56:01 +02:00
|
|
|
from dataclasses import dataclass, field, replace
|
2026-09-09 07:28:30 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
2026-09-09 07:57:35 +02:00
|
|
|
from ..cpg.backend import CpgResult
|
2026-09-09 07:28:30 +02:00
|
|
|
from .candidates import enumerate_candidates
|
2026-09-09 07:57:35 +02:00
|
|
|
from .config_value import request_params as config_params
|
|
|
|
|
from .dominance import request_params as dominance_params
|
2026-09-11 10:47:47 +02:00
|
|
|
from .evidence import TIER_EXCLUDE, Evidence, evidence_from_rows
|
2026-09-09 07:28:30 +02:00
|
|
|
from .ladder import Rung
|
2026-09-11 14:27:50 +02:00
|
|
|
from .layout import with_layout
|
2026-09-09 07:28:30 +02:00
|
|
|
from .pipeline import ModelFinding, scan_region
|
2026-09-09 09:43:53 +02:00
|
|
|
from .regions import MODULE_SCOPE, ScanRegion
|
2026-09-09 07:28:30 +02:00
|
|
|
from .specs import ConfigSpec, DominanceSpec, TaintSpec, config_specs, dominance_specs, taint_specs
|
2026-09-09 07:57:35 +02:00
|
|
|
from .taint import request_params as taint_params
|
2026-09-09 07:28:30 +02:00
|
|
|
from .taxonomy import load_families
|
|
|
|
|
|
2026-09-09 07:57:35 +02:00
|
|
|
# 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
|
|
|
|
|
|
2026-09-09 09:27:19 +02:00
|
|
|
# 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
|
|
|
|
|
|
2026-09-09 07:28:30 +02:00
|
|
|
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
|
2026-09-10 18:22:18 +02:00
|
|
|
# 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
|
2026-09-10 19:18:20 +02:00
|
|
|
# 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
|
2026-09-11 10:47:47 +02:00
|
|
|
# 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
|
2026-09-09 07:28:30 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
2026-09-09 08:46:21 +02:00
|
|
|
# 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
|
2026-09-09 07:28:30 +02:00
|
|
|
model_calls: int = 0
|
|
|
|
|
cost_usd: float = 0.0
|
|
|
|
|
seconds: float = 0.0
|
2026-09-09 08:46:21 +02:00
|
|
|
# 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
|
2026-09-09 11:30:02 +02:00
|
|
|
# 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)
|
2026-09-09 07:28:30 +02:00
|
|
|
degradations: tuple[Mapping[str, object], ...] = ()
|
2026-09-10 18:22:18 +02:00
|
|
|
# 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)
|
2026-09-10 19:18:20 +02:00
|
|
|
# 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
|
2026-09-09 07:28:30 +02:00
|
|
|
|
2026-09-09 08:46:21 +02:00
|
|
|
@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)
|
|
|
|
|
|
2026-09-09 07:28:30 +02:00
|
|
|
|
|
|
|
|
def scan_repository(
|
|
|
|
|
root: Path,
|
|
|
|
|
regions: Sequence[ScanRegion],
|
|
|
|
|
*,
|
|
|
|
|
backend: Any,
|
|
|
|
|
client: Any | None = None,
|
|
|
|
|
model: str = "",
|
|
|
|
|
budget: ScanBudget | None = 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()
|
2026-09-09 08:46:21 +02:00
|
|
|
build_started = time.monotonic()
|
2026-09-09 16:19:32 +02:00
|
|
|
# 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 ""
|
|
|
|
|
cpg = _build(backend, root, dominant)
|
2026-09-09 08:46:21 +02:00
|
|
|
build_seconds = round(time.monotonic() - build_started, 2)
|
2026-09-09 07:28:30 +02:00
|
|
|
if cpg is None:
|
|
|
|
|
return ModelScanResult(
|
|
|
|
|
by_rung=_empty_tally(),
|
|
|
|
|
regions_unjudged=len(regions),
|
|
|
|
|
seconds=round(time.monotonic() - started, 2),
|
2026-09-09 08:46:21 +02:00
|
|
|
build_seconds=build_seconds,
|
2026-09-10 11:35:35 +02:00
|
|
|
degradations=({"stage": "model", "reason": "cpg_build_failed", "detail": getattr(backend, "last_failure", "")},),
|
2026-09-09 07:28:30 +02:00
|
|
|
)
|
|
|
|
|
|
2026-09-09 18:07:28 +02:00
|
|
|
# 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]]}
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-09 07:28:30 +02:00
|
|
|
# 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.
|
2026-09-11 14:27:50 +02:00
|
|
|
ordered_regions = sorted(with_layout(regions), key=lambda r: (not r.shipped, -r.rank, r.path, r.function or ""))
|
2026-09-09 07:28:30 +02:00
|
|
|
|
2026-09-09 20:42:35 +02:00
|
|
|
# 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)
|
|
|
|
|
|
2026-09-10 08:30:38 +02:00
|
|
|
# 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),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-10 18:22:18 +02:00
|
|
|
# Phase 1b: the evidence tier of every taint pair, before any dataflow is asked. Same query, `evidenceOnly`,
|
2026-09-11 10:47:47 +02:00
|
|
|
# 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.
|
2026-09-10 18:22:18 +02:00
|
|
|
tiers: list[tuple[str, str, str, int]] = []
|
|
|
|
|
per_kind: dict[str, float] = {}
|
2026-09-11 10:47:47 +02:00
|
|
|
evidence_by_pair: dict[tuple[str, str, str], Evidence] = {}
|
2026-09-10 18:22:18 +02:00
|
|
|
if limits.tiering and callable(getattr(cpg, "run_batch", None)):
|
2026-09-11 10:47:47 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-10 18:22:18 +02:00
|
|
|
|
2026-09-09 07:57:35 +02:00
|
|
|
# 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]] = {}
|
2026-09-09 18:07:28 +02:00
|
|
|
census_reported = False
|
2026-09-09 08:46:21 +02:00
|
|
|
query_started = time.monotonic()
|
2026-09-10 19:18:20 +02:00
|
|
|
pruned = 0
|
2026-09-09 20:42:35 +02:00
|
|
|
for kind, requests in _grouped(work, hook_callbacks=hooks).items():
|
2026-09-10 19:18:20 +02:00
|
|
|
if kind == "taint" and limits.prune_tier0 and tier_by_rid:
|
|
|
|
|
# Tier 0 is exact: a family with no sink in reach cannot yield a flow. Measured on a 637-file
|
|
|
|
|
# plugin, that is 2,161 of 2,500 requests -- 86% of a query that timed out at forty minutes.
|
|
|
|
|
keep = {rid: params for rid, params in requests.items() if tier_by_rid.get(rid, TIER_EXCLUDE + 1) != TIER_EXCLUDE}
|
|
|
|
|
pruned = len(requests) - len(keep)
|
|
|
|
|
requests = keep
|
|
|
|
|
if not requests:
|
|
|
|
|
per_kind[kind] = 0.0
|
|
|
|
|
continue
|
2026-09-09 11:30:02 +02:00
|
|
|
kind_started = time.monotonic()
|
2026-09-09 07:57:35 +02:00
|
|
|
batch = getattr(cpg, "run_batch", None)
|
|
|
|
|
if callable(batch):
|
2026-09-09 17:15:51 +02:00
|
|
|
answered_batch = batch(kind, requests)
|
|
|
|
|
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.
|
|
|
|
|
degradations.append({"stage": "model", "reason": "query_failed", "kind": kind, "requests": len(requests)})
|
|
|
|
|
else:
|
2026-09-09 18:07:28 +02:00
|
|
|
# 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:
|
|
|
|
|
degradations.append({"stage": "model", "reason": "cpg_empty", "files": int(str(entry.get("files", "0")) or 0)})
|
2026-09-10 07:32:20 +02:00
|
|
|
# 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})
|
2026-09-09 18:07:28 +02:00
|
|
|
census_reported = True
|
2026-09-09 17:15:51 +02:00
|
|
|
rows_by_id.update(answered_batch)
|
2026-09-09 07:57:35 +02:00
|
|
|
else: # a backend without batching still works, one call at a time
|
|
|
|
|
for rid, params in requests.items():
|
|
|
|
|
answered = cpg.run(kind, params)
|
|
|
|
|
rows_by_id[rid] = list(answered) if isinstance(answered, list) else []
|
2026-09-09 11:30:02 +02:00
|
|
|
per_kind[kind] = round(time.monotonic() - kind_started, 2)
|
2026-09-09 08:46:21 +02:00
|
|
|
query_seconds = round(time.monotonic() - query_started, 2)
|
2026-09-09 07:57:35 +02:00
|
|
|
|
|
|
|
|
# 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.
|
|
|
|
|
#
|
2026-09-09 08:46:21 +02:00
|
|
|
# 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.
|
2026-09-09 07:57:35 +02:00
|
|
|
judged: set[tuple[str, str | None]] = set()
|
2026-09-09 08:46:21 +02:00
|
|
|
unasked: set[tuple[str, str | None]] = set()
|
2026-09-09 07:57:35 +02:00
|
|
|
for rid, region, spec in work:
|
2026-09-09 08:46:21 +02:00
|
|
|
exhausted = counted is not None and counted.calls >= limits.max_model_calls
|
2026-09-09 07:57:35 +02:00
|
|
|
family = getattr(spec, "family", "")
|
|
|
|
|
prefetched = CpgResult(cpg_path=cpg.cpg_path, run=_prefetched(rows_by_id.get(rid, [])))
|
2026-09-10 08:18:17 +02:00
|
|
|
judge = None if exhausted else counted
|
2026-09-09 07:57:35 +02:00
|
|
|
try:
|
2026-09-10 08:18:17 +02:00
|
|
|
# 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]
|
2026-09-09 07:57:35 +02:00
|
|
|
found = scan_region(
|
|
|
|
|
prefetched,
|
|
|
|
|
spec,
|
2026-09-09 08:46:21 +02:00
|
|
|
path=region.path,
|
2026-09-09 07:57:35 +02:00
|
|
|
function=region.function or "",
|
2026-09-10 08:18:17 +02:00
|
|
|
client=judge,
|
2026-09-09 07:57:35 +02:00
|
|
|
model=model,
|
|
|
|
|
candidates=candidates,
|
2026-09-09 09:27:19 +02:00
|
|
|
# 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=_is_entry_point(region),
|
|
|
|
|
call_depth=ENTRY_POINT_CALL_DEPTH if _is_entry_point(region) else 0,
|
2026-09-09 07:57:35 +02:00
|
|
|
)
|
|
|
|
|
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})
|
|
|
|
|
continue
|
|
|
|
|
judged.add((region.path, region.function))
|
2026-09-09 08:46:21 +02:00
|
|
|
if exhausted:
|
|
|
|
|
unasked.add((region.path, region.function))
|
2026-09-09 07:57:35 +02:00
|
|
|
collected.extend((finding, region.rank) for finding in found)
|
|
|
|
|
|
2026-09-09 08:46:21 +02:00
|
|
|
if unasked:
|
|
|
|
|
degradations.append({"stage": "model", "reason": "budget_exhausted", "regions_unasked": len(unasked)})
|
|
|
|
|
|
2026-09-09 07:57:35 +02:00
|
|
|
scanned = len(judged)
|
2026-09-09 07:28:30 +02:00
|
|
|
|
2026-09-09 15:56:01 +02:00
|
|
|
findings = _ordered(_deduplicated(collected))
|
2026-09-10 15:54:50 +02:00
|
|
|
# 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.
|
|
|
|
|
_dispose(cpg)
|
2026-09-09 07:28:30 +02:00
|
|
|
return ModelScanResult(
|
|
|
|
|
findings=findings,
|
|
|
|
|
by_rung=_tally(findings),
|
|
|
|
|
regions_scanned=scanned,
|
|
|
|
|
regions_unjudged=max(len(regions) - scanned, 0),
|
2026-09-09 08:46:21 +02:00
|
|
|
regions_unasked=len(unasked),
|
2026-09-09 07:28:30 +02:00
|
|
|
model_calls=counted.calls if counted is not None else 0,
|
|
|
|
|
cost_usd=round(_cost(counted), 4),
|
|
|
|
|
seconds=round(time.monotonic() - started, 2),
|
2026-09-09 08:46:21 +02:00
|
|
|
build_seconds=build_seconds,
|
|
|
|
|
query_seconds=query_seconds,
|
2026-09-09 11:30:02 +02:00
|
|
|
query_seconds_by_kind=per_kind,
|
2026-09-09 07:28:30 +02:00
|
|
|
degradations=tuple(degradations),
|
2026-09-10 18:22:18 +02:00
|
|
|
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})},
|
2026-09-10 19:18:20 +02:00
|
|
|
requests_pruned=pruned,
|
2026-09-09 07:28:30 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 10:47:47 +02:00
|
|
|
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", ()),
|
2026-09-11 13:45:00 +02:00
|
|
|
shipped=bool(getattr(region, "shipped", True)),
|
2026-09-11 10:47:47 +02:00
|
|
|
)
|
|
|
|
|
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],
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 15:54:50 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 16:19:32 +02:00
|
|
|
def _build(backend: Any, root: Path, language: str) -> Any:
|
2026-09-11 14:27:50 +02:00
|
|
|
"""Build through the backend, passing the language and the vendored trees when the backend can use them."""
|
|
|
|
|
from .layout import vendored_directories
|
|
|
|
|
|
|
|
|
|
excluded = vendored_directories(root) if root.is_dir() else ()
|
2026-09-09 16:19:32 +02:00
|
|
|
try:
|
2026-09-11 14:27:50 +02:00
|
|
|
return backend.build(root, language=language, exclude=excluded)
|
2026-09-09 16:19:32 +02:00
|
|
|
except TypeError: # a backend from before the retry existed, including every test double
|
2026-09-11 14:27:50 +02:00
|
|
|
try:
|
|
|
|
|
return backend.build(root, language=language)
|
|
|
|
|
except TypeError:
|
|
|
|
|
return backend.build(root)
|
2026-09-09 16:19:32 +02:00
|
|
|
|
|
|
|
|
|
2026-09-09 07:57:35 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 20:42:35 +02:00
|
|
|
def _grouped(
|
|
|
|
|
work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], *, hook_callbacks: str = ""
|
|
|
|
|
) -> dict[str, dict[str, Mapping[str, object]]]:
|
2026-09-09 07:57:35 +02:00
|
|
|
"""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 ""
|
2026-09-09 09:27:19 +02:00
|
|
|
entry = _is_entry_point(region)
|
2026-09-09 07:57:35 +02:00
|
|
|
if isinstance(spec, DominanceSpec):
|
2026-09-09 11:02:48 +02:00
|
|
|
kind, params = "dominance", dominance_params(spec, function=function, file=region.path)
|
2026-09-09 07:57:35 +02:00
|
|
|
elif isinstance(spec, ConfigSpec):
|
2026-09-09 08:46:21 +02:00
|
|
|
kind, params = "config", config_params(spec, function=function, file=region.path)
|
2026-09-09 07:57:35 +02:00
|
|
|
else:
|
2026-09-09 09:27:19 +02:00
|
|
|
kind, params = (
|
|
|
|
|
"taint",
|
|
|
|
|
taint_params(
|
|
|
|
|
spec,
|
|
|
|
|
function=function,
|
|
|
|
|
file=region.path,
|
|
|
|
|
parameter_sources=entry,
|
|
|
|
|
call_depth=ENTRY_POINT_CALL_DEPTH if entry else 0,
|
2026-09-09 20:42:35 +02:00
|
|
|
hook_callbacks=hook_callbacks,
|
2026-09-09 09:27:19 +02:00
|
|
|
),
|
|
|
|
|
)
|
2026-09-09 07:57:35 +02:00
|
|
|
grouped.setdefault(kind, {})[rid] = params
|
|
|
|
|
return grouped
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 20:42:35 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 09:27:19 +02:00
|
|
|
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.
|
2026-09-09 09:43:53 +02:00
|
|
|
|
|
|
|
|
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.
|
2026-09-09 12:45:21 +02:00
|
|
|
|
|
|
|
|
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.
|
2026-09-09 09:27:19 +02:00
|
|
|
"""
|
2026-09-09 12:45:21 +02:00
|
|
|
return region.source == "entry_point" and bool(region.function) and region.function != MODULE_SCOPE and region.shipped
|
2026-09-09 09:27:19 +02:00
|
|
|
|
|
|
|
|
|
2026-09-09 07:57:35 +02:00
|
|
|
def _spec_for(family: str, language: str) -> ArbiterSpec | None:
|
2026-09-09 07:28:30 +02:00
|
|
|
"""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)
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 15:56:01 +02:00
|
|
|
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())
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 07:28:30 +02:00
|
|
|
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"]
|