mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 20:49:28 +00:00
512 lines
37 KiB
Markdown
512 lines
37 KiB
Markdown
# Brief: flow-aware-ranking — spend the expensive query where the cheap evidence is strongest
|
||
|
||
**Status:** brief only, unapproved.
|
||
|
||
## Current amendment — 2026-09-12
|
||
|
||
This section supersedes forward-looking interpretations below; the iteration history is retained.
|
||
The product goal is a scalable, actionable pre-push security safety net as AI accelerates code changes.
|
||
PMPro and WordPress are regression workloads within that goal, not its scope boundary. See
|
||
`../pre-push-safety-net/` for proposed push requirements and design, and
|
||
`../../steering/safety-net.md` for the maintainer's product direction.
|
||
|
||
**The ranker remains the scope mechanism.** It selects and orders the regions/questions that
|
||
receive analysis. The push adapter supplies change and affected first-party context; it does not
|
||
create a competing scope heuristic. Third-party vendor code remains excluded from both targets
|
||
and graphs. Tests remain available but unshipped, as already decided. Missing external semantics
|
||
need explicit summaries or unresolved coverage, never automatic vendor reintroduction.
|
||
|
||
### Verified repository facts and bounded interpretations
|
||
|
||
- Latest recorded phase-2 budgets: PMPro **82**, WP Statistics **91**, MW WP Form **54**, VAmPI **16**.
|
||
The `<50` gate remains unmet. The evidence passes recorded no unanswered pairs. These are stored
|
||
ranking measurements, not fresh detection/precision results or a pre-push latency demonstration.
|
||
- **Equal-score ties are not the whole remaining problem.** At fixed scores, perfect tie-breaking
|
||
still needs at least **68** regions for PMPro and **86** for WP Statistics (67/85 strictly
|
||
higher-scored same-tier regions precede the targets). An LLM restricted to equal-score ties
|
||
cannot pass `<50`. Within-tier reordering across scores or a new feature is a distinct experiment.
|
||
- Docker, Compose and provisioning still default to 4.0.623; the latest measurements use 4.0.625.
|
||
Align the shipped baseline before claiming deployment parity. New files restored by the engine
|
||
upgrade are legitimate coverage, not a ranker regression to tune away.
|
||
- Ordinary CLI scans do not enable `ScanBudget.order_by_evidence`; preserve the experimental status
|
||
until the chosen acceptance profile passes. Record mode explicitly in every future comparison.
|
||
- `position.py` currently equates an unmatched target function with `reached_transitively` and
|
||
omits it from `budget_at_recall`. Require a queried witness for transitive detection; otherwise
|
||
keep the target unresolved in the denominator. This currently affects VAmPI's SQLI accounting.
|
||
- Graph construction precedes region selection. Ranking improves query allocation but does not
|
||
itself make graph builds incremental. The push design must measure both costs.
|
||
|
||
### Revised experimental contract (draft, awaiting approval)
|
||
|
||
**Maintainer clarification:** PMPro remains the chosen core-optimization example. Require a
|
||
JavaScript/Node API transfer workload, especially Express routes/middleware, to test that those
|
||
optimizations generalize. Frontend/framework facts map concrete semantics to common evidence;
|
||
core ranking and scope must not gain PHP-specific branches or language-specific score bonuses.
|
||
Equivalent normalized evidence must produce equivalent tier/score decisions. See
|
||
`../pre-push-safety-net/evaluation.md` for the PMPro/Node/Python/C workload roles, proposed NodeGoat
|
||
integration candidate, and separate untouched Node evaluation. C/C++ is valuable for checking
|
||
shared machinery and abstraction limits; libpng's envelope is not a positive detection result.
|
||
|
||
1. Freeze engine, facts, layout/scope, ranker mode and workload before comparing an ordering change.
|
||
Record selected/deferred question IDs and unanswered queries. Separate engine and scope changes
|
||
from ranking improvements; vendor exclusion is an explicit boundary change.
|
||
2. Keep `budget_at_recall` as a scheduling diagnostic, not the product gate. Measure actual
|
||
vulnerable/fixed outcomes, actionable-alert precision, benign-push interruption, supported recall,
|
||
completed coverage, and total time on the same workload. No unanswered target disappears.
|
||
3. Keep the repeatedly inspected PHP plugins as regression workloads. Use untouched repositories
|
||
and benign/security-changing pushes for new evaluations, with per-language/framework/family
|
||
outcomes. Broader C memory reasoning is not solved by ordering taint questions better.
|
||
4. Before another LLM iteration, distinguish changing equal-score ties from reordering a whole tier.
|
||
Compare the candidate with the frozen heuristic at equal total cost, including model latency.
|
||
Protect obligation/configuration work: a taint-only score must not silently starve those families.
|
||
5. Include a recorded exploration allocation and sufficiently small fully evaluated workloads in
|
||
the first learned-ranking experiment, rather than deferring selection-bias measurement until
|
||
after phase 3. Use a recorded seed; report unresolved/unexamined population and sampling limits.
|
||
6. Ranking can decide where to look, never whether to alert. The hook's admission policy requires
|
||
supported change attribution, a useful witness and repair direction even for an entailed result.
|
||
Advisory mode must meet that same bar. No model may change facts, scope exclusions or rungs.
|
||
|
||
This amendment does not approve the brief or its next phases. Reconcile requirements/design before
|
||
generating implementation tasks; preserve prior measured iterations as evidence rather than relabeling
|
||
them approved specifications.
|
||
|
||
## Why now
|
||
|
||
> "we need a taint flow aware ranker, that is capable to put high risk areas into tiers"
|
||
> "can you engineer a prompt with enough information to steer the llm making ranker decisions"
|
||
> "this can be done, but it requires an iterative concept and clear plans"
|
||
|
||
A taint request costs **6.63 seconds** (contributor-scan 5.13). A 637-file plugin yields **4,463 regions**, and
|
||
the current ranker gives 83.8% of them the same rank, ordered alphabetically by path. So the budget is spent
|
||
arbitrarily and the scan does not finish.
|
||
|
||
The ranker is not weak. It is looking at the wrong thing.
|
||
|
||
## The evidence that this is tractable
|
||
|
||
Three regions from one plugin, with what the ranker sees today and what is actually in the code:
|
||
|
||
| region | rank today | sink in scope |
|
||
|---|---|---|
|
||
| `getMemberOrderByCode` — **holds CVE-2023-23488** | 0.30 | `$wpdb->get_var("... WHERE code = '" . $code . "' LIMIT 1")` |
|
||
| `getMemberOrderByID` — sibling | 0.30 | `$wpdb->get_row("... WHERE id = '$id' LIMIT 1")` |
|
||
| `pmpro_rest_api_get_membership_level` | **0.80** | none — no SQL sink at all |
|
||
|
||
A region with no sink outranks two holding raw concatenated SQL. The discriminating signal is in the sink
|
||
call's own source text — interpolated versus bound — and nothing in the ranker reads it.
|
||
|
||
One detail decides whether a keyword rule suffices, and it does not: `esc_sql` IS in scope for the CVE's
|
||
function, applied to a different statement. "A sanitizer is present, rank it lower" demotes precisely the
|
||
right answer. Knowing which call it wraps is reading, not matching.
|
||
|
||
## The unit, and the tiers
|
||
|
||
**Rank (region, family) PAIRS, not regions.** The expensive question is "does a source reach a sink of family
|
||
F here"; every cheap approximation of it is free from the fact tables and a text scan.
|
||
|
||
| tier | evidence | |
|
||
|---|---|---|
|
||
| **0** | no sink of F in scope | **never ask** — exact, cannot lose a finding |
|
||
| 1 | sink present, no source in scope or reachable | ask last |
|
||
| 2 | sink and source, a cleansing call on THAT call | may still corroborate |
|
||
| 3 | sink and source, interpolated shape, no cleansing on that call, entry point or declared public | ask first |
|
||
|
||
Tier 0 is the only tier that excludes rather than orders, so it alone must be exact. Above it, being wrong
|
||
costs position, not the finding. That bound is what makes a model safe here: **a ranker cannot manufacture a
|
||
finding**, it decides what is looked at, and `regions_truncated` already reports what went unexamined.
|
||
|
||
## The iterative concept
|
||
|
||
Each phase ships only if it beats the previous one **on a repository the change was not developed against**.
|
||
A phase that cannot show that is not an improvement, it is a story.
|
||
|
||
- **Phase 0 — the oracle and the baseline.** The evidence extractor (no model) and a scoring harness over the
|
||
pinned CVEs, reporting each one's rank position. Gate: reproduces today's numbers — CVE-2023-23488 at
|
||
position 390 of 4,463, 83.8% of regions in one tier. Without a baseline nothing later is measurable.
|
||
|
||
**Done 2026-09-10.** `benchmarks/ranking/position.py`, committed as
|
||
`benchmarks/ranking/baselines/2026-09-10-access-rank.json`:
|
||
|
||
| repository | regions | largest tier | CVE position | `budget_at_recall` |
|
||
|---|---|---|---|---|
|
||
| `pmpro` | 4,463 | 83.8% at 0.30 | 390 | 391 |
|
||
| `mwwpform` | 983 | 81.7% at 0.30 | **512** | **513** |
|
||
| `wpstatistics` | 1,673 | 75.3% at 0.30 | **503** | **504** |
|
||
| `vampi` | 25 | 36.0% at 1.00 | 15, 10 (SQLI reached transitively) | 16 |
|
||
|
||
**And this corrects the cross-assessment above.** "Ranking's job is concentration, not inclusion" was
|
||
measured on `pmpro` alone, where the CVE is inside the budget. On the other two plugins the CVE sits at
|
||
512 and 503 — **outside a 500-region budget** — so the current ranker never examines it at all, and the
|
||
slices found those CVEs only because a slice has too few regions to exclude anything. Both jobs are
|
||
real: inclusion on two repositories, concentration on all three. The single metric `budget_at_recall`
|
||
covers both, which is why it is the one that matters.
|
||
|
||
The instrument is `evidenceOnly` mode on the taint query, recorded on every scan as `ModelScanResult.tiers`
|
||
and `tier_counts`, and acted on by nothing — so every later phase is measured against a record made by
|
||
scans that did not know they were being measured.
|
||
|
||
**Measured cost of the instrument**, 637-file graph, the scan's own 500-region request set:
|
||
|
||
2,500 pairs in 98.9 s including JVM load — about 40 ms per pair
|
||
tier 0 (no sink of that family in reach): 2,161 of 2,500 = 86%
|
||
|
||
Two attempts before that ran past an hour each without producing a payload, and the cause was mine both
|
||
times: repository-wide walks — the sink scan, the reachable-method BFS, `familyInRepo` — repeated per
|
||
request. Memoising them across the batch is what made "milliseconds per pair" true rather than claimed.
|
||
|
||
**And the same memoisation reaches the dataflow mode.** The identical 250-request set that cost 2,026.3 s
|
||
(8,105 ms per request) with both joins on now costs **287.0 s — 1,148 ms per request — for the same 5,779
|
||
rows.** Most of what contributor-scan 5.13 measured as "the base taint query" was the per-request scans,
|
||
not the dataflow. That correction is recorded there.
|
||
- **Phase 1 — tier 0 only, no model.** Exact pruning. Gate: **53% fewer taint requests issued, measured** —
|
||
and nothing more. 1,177 requests at 6.63 s is still 2.2 hours, so this phase does NOT make the scan finish;
|
||
it halves the problem. Claiming completion here would send someone looking for a bug where there is none.
|
||
|
||
**Done 2026-09-10, gate exceeded, scan still does not finish — and the reason corrects phase 0's cost
|
||
figure.** On the 637-file plugin: `tier_counts = {0: 2161, 1: 60, 2: 20, 3: 211, 4: 48}`, **2,161 of 2,500
|
||
requests pruned (86%)**, evidence pass 94.5 s. The remaining **339 requests still hit the 2,400 s ceiling**
|
||
— more than 7 s each.
|
||
|
||
**The scan finishes, 2026-09-11.** Not because of anything in this spec: contributor-scan 5.13 found
|
||
that every query batch had been recomputing Joern's dataflow overlay at load since PHP builds went
|
||
through php2cpg (54 s fixed cost per batch; an OutOfMemoryError on WP Statistics), that the hook half's
|
||
seeds were repository-wide, and that two vendored data tables were 60% of one graph. With the overlays
|
||
applied once at build time, the seeds scoped to the callback's file, and files over 1 MB excluded as
|
||
data, the same whole-repository scan on the 637-file plugin completes: **build 97 s (overlays included),
|
||
evidence 76 s, taint 4,105 s for 348 real requests (11.8 s each), arbitrate 99 s, total 72 minutes**,
|
||
`tier_counts = {0: 2152, 1: 60, 2: 20, 3: 218, 4: 50}`, peak child RSS 2.4 GB. **CVE-2023-23488 is
|
||
found at `class.memberorder.php:936`**, among 250 entailed findings across 40 files. The 250 is the next
|
||
fact to reckon with -- it is what "500 regions when 50 would do" looks like as output -- and it is phase
|
||
2's material. `callDepth`, measured on the same graph, is a 1.4× time term and a 100× payload term
|
||
(3,838 flow rows for ten requests at depth 3 against 38 at depth 1). Collapsing rows per evidence key
|
||
takes 3.3× of that out (1,155 rows carrying 3,838 paths, exact); the rest is depth 3 naming more
|
||
distinct sources per sink, which is evidence the ranker gets to use.
|
||
|
||
Phase 0 reported 1,148 ms per request after memoisation. That was an average over a 250-request set of
|
||
which 88% were tier 0 and returned instantly; the ~30 real requests in it cost about 9.5 s each. Pruning
|
||
removes exactly the cheap questions and keeps the expensive ones, so it cannot make those faster. The
|
||
honest per-request cost of a taint question that *can* have an answer is **7–10 s at `callDepth = 3`**,
|
||
and that is the number phase 2 and 5.13 must work against — not 1.1 s, and not 6.6 s either.
|
||
- **Phase 2 — heuristic tiers, still no model.** The vector and tiers defined below, ordered within a tier
|
||
by a published weighted sum. Gate: `budget_at_recall` — the smallest K holding every pinned CVE — **below
|
||
50 on every pinned repository**, and `pmpro`'s taint query completing at that K. This is the phase that
|
||
makes a scan finish, because the job is concentration: the CVE is already inside 500, the question is
|
||
whether it is inside 50. **If this passes, the model is a refinement, not the plan** — and knowing that is
|
||
worth more than assuming it.
|
||
**Implemented 2026-09-11, measurement in flight.** `Evidence.score` is the published weighted sum, and
|
||
every weight is in one table in `model/evidence.py`: per open sink +1.0 (capped at 5), per cleansed or
|
||
bound sink −0.5 (capped at 3), local source +2.0, carried +1.5, near source +1.0, entry +0.5, declared
|
||
public +1.0. A tier-0 pair scores 0 whatever its sources say, so the weights order but cannot
|
||
manufacture. `ScanBudget.order_by_evidence` runs the evidence pass over every region (or
|
||
`evidence_regions` of them), takes each region's best pair's (tier, score), and cuts `max_regions` from
|
||
that order; `order_by_evidence()` is one function used by the scan and by
|
||
`benchmarks/ranking/position.py --evidence`, so the benchmark measures the order the scan spends. Off
|
||
by default until the benchmark says where the pinned CVEs land. A region with no taint family sorts
|
||
after every region with evidence -- the scan does not know it is safe, it knows nothing -- which is a
|
||
known cost of this phase for families like `access_control` and is noted here rather than hidden.
|
||
|
||
**Measured 2026-09-11, weights v1** (`benchmarks/ranking/baselines/2026-09-11-evidence-rank.json`), the
|
||
evidence pass over EVERY region of each repository, Joern 4.0.623:
|
||
|
||
| repository | pairs | evidence pass | tiers 0 / 1 / 2 / 3 / 4 | `budget_at_recall` static → evidence |
|
||
|---|---|---|---|---|
|
||
| pmpro | 22,315 | 255 s | 21,275 / 74 / 86 / 759 / 121 | **391 → 116** |
|
||
| wpstatistics | 8,359 | 156 s | 7,468 / 77 / 5 / 726 / 83 | **504 → 223** |
|
||
| mwwpform | 4,915 | 68 s | 4,511 / 37 / 0 / 357 / 10 | **513 → 292** |
|
||
| vampi | 125 | 16 s | 123 / 1 / 0 / 0 / 1 | 16 → 16 |
|
||
|
||
Concentration of 2.2× to 3.4× on the three PHP repositories, from exact facts and a seven-entry weight
|
||
table; the gate (< 50 everywhere) is not met. Two readings, one per shape of miss: pmpro's CVE is
|
||
INSIDE its 121 tier-4 regions, so what remains there is order within the top tier -- the tie-heavy part
|
||
this brief handed to phase 3. WP Statistics's `record()` and MW WP Form's `_delete_files()` are in
|
||
tier 3, one below where their sources say they belong, because `carried` -- stage one of the two-stage
|
||
join, which the arbiter computes anyway -- was not yet reported by the evidence branch. It is now
|
||
(`taint.sc` reports it only where a sink exists, so tier-0 pairs pay nothing).
|
||
|
||
**With `carried` (v2), as they land:** mwwpform **292 → 30** (CVE-2023-6559 at 29, among 37 tier-4
|
||
regions; evidence pass 68 → 84 s) -- the first repository under the gate. vampi unchanged at 16. pmpro's
|
||
v2 pass hit the scan's 300 s default query ceiling (v1 took 255 s; the join facts on its 1,040
|
||
sink-bearing pairs cost more than the 45 s of headroom) and answered nothing, which the benchmark
|
||
reported as `tiers={}` and the static order; the benchmark now sets its own ceiling (1,800 s). WP Statistics's v2 pass went the same way (v1 156 s,
|
||
v2 past 300 s), so both are re-measured under it.
|
||
|
||
**Then the flow form was the wall, not the ceiling:** pmpro's v2 pass did not finish in 1,800 s either
|
||
(the arbiter's `carried` is a `reachableByFlows` per candidate assignment, and 1,040 sink-bearing pairs
|
||
of them). So `carried` in evidence mode became STRUCTURE (v3): a field the region reads is assigned, in
|
||
its file, from a source expression, from a parameter of the assigning method, or from a same-file
|
||
method that reads a source; a hook applied in scope has a registered callback holding one of those.
|
||
Milliseconds again (pmpro 266 s, wpstatistics 168 s, mwwpform 69 s):
|
||
|
||
| repository | static | v1 (no carried) | v2 (flow carried) | v3 (structural) | tier 4 under v3 |
|
||
|---|---|---|---|---|---|
|
||
| pmpro | 391 | 116 | timed out | **69** | 555 |
|
||
| wpstatistics | 504 | 223 | timed out | **172** | 569 |
|
||
| mwwpform | 513 | 292 | **30** | 68 | 84 |
|
||
| vampi | 16 | 16 | 16 | 16 | 1 |
|
||
|
||
The proxy is too loose: folding "assigned from a parameter" in with "assigned from a source" put 555
|
||
and 569 regions in tier 4 -- every constructor assigns a field from a parameter -- and the CVEs sit in
|
||
those tiers on score alone. v4 splits the provenance: source-fed (or fed by a same-file method that
|
||
reads a source) promotes to tier 4; parameter-fed only orders within a tier (weight +0.75).
|
||
|
||
**v4, measured:** pmpro 69 and wpstatistics 172, both UNCHANGED with tier 4 still 555 and 569 -- their
|
||
tier-4 members are source-fed under the one-level rule (fields assigned from calls to same-file
|
||
helpers that read `$_REQUEST`, which on those plugins is a real request path, so the tier is roughly
|
||
honest and the CVE's position there is a within-tier question). mwwpform went 68 → **248**: its CVE's
|
||
region is fed by `$this->attachments = $attachments` in a constructor, exactly the parameter-fed shape
|
||
the split demotes, and exactly the shape the exact flow had put at 30. So v5 asks the exact flow for
|
||
precisely the parameter-fed fields a sink-bearing region reads -- memoised per (family, file, field)
|
||
for the batch, which the arbiter's memos were not until today -- and for nothing else.
|
||
|
||
**v5, measured, and the phase-2 verdict on it** (`2026-09-11-evidence-rank-v5.json`, with the head of
|
||
each order recorded): pmpro **69**, wpstatistics **172**, mwwpform **68**, vampi 16. The flow confirms
|
||
every parameter-fed field -- a constructor's `$this->x = $x` trivially flows from its parameter -- so
|
||
v5 equals v3 for mwwpform; the v2 "30" came from the older scoping (the region's file, not the
|
||
field's), which happened to fit that one case. 68 is the honest structural number for that shape.
|
||
|
||
Reading the heads: every region ahead of a CVE is in the CVE's own tier on a tied or higher score --
|
||
pmpro's CVE scores 7.0 with 23 regions at 10.0, 13 at 8.5, 10 at 8.0 and 14 at 7.0 ahead of it;
|
||
wpstatistics's sits behind 96 regions tied at 7.0. The tiers are done; the ties are the whole of what
|
||
is left, exactly as the cross-assessment said. One exact fact was missing from the vector: `shipped`,
|
||
the build's own word on what is product, was on the region and not in the score, and 10 of the 67
|
||
regions ahead of MW WP Form's CVE were `test-mail.php`. Weight −2.0 for an unshipped file (v6).
|
||
|
||
**v6: no change (mwwpform 68), and the reason is a fact the vector does not have.** `declared_sources`
|
||
answers only from a build manifest that lists sources (Makefile.am and kin); a WordPress plugin ships a
|
||
`composer.json` and a `package.json`, neither of which does, so `shipped` is None and every region --
|
||
the 14 test-file regions ahead of the CVE included -- counts as product. Knowing that `tests/`,
|
||
`test-*.php` and `*Test.php` are not product is ecosystem convention, which is to say policy: it
|
||
belongs in a `[[layout]]` fact table per ecosystem, approved like the `[[dispatch]]` facts were, not in
|
||
a constant here. **Approved and done 2026-09-11** (b821ac6), with the maintainer's second decision in
|
||
the same row: vendored trees are OUT -- of the targets and of the graph -- because a bundled library is
|
||
a separate unit, analysed as one or not at all ("divide and rule"; libraries ship as their own DLLs).
|
||
Test paths stay in scope and are not product. Both re-measured; and the baseline Joern is 4.0.625 from
|
||
here (the closure exclusion gated off, both plugins building clean), measured separately first so the
|
||
two changes are not confounded.
|
||
|
||
**Measured, both** (`2026-09-11-evidence-rank-joern-4.0.625.json`, `...-layout.json`):
|
||
|
||
| repository | v6 on 4.0.623 | 4.0.625 alone | 4.0.625 + layout | regions |
|
||
|---|---|---|---|---|
|
||
| pmpro | 69 | 82 | **82** | 4,463 → 4,463 |
|
||
| wpstatistics | 172 | 173 | **91** | 1,673 → 824 |
|
||
| mwwpform | 68 | 68 | **54** | 983 → 983 |
|
||
| vampi | 16 | 16 | 16 | 25 |
|
||
|
||
4.0.625 puts the two once-excluded files back (pmpro's `fields.php`, WP Statistics's `install.php`)
|
||
and costs pmpro 13 positions, which is correct rather than worse: that code is real. The layout row
|
||
halves WP Statistics -- 207 vendored files gone, its graph built in 45 s instead of 139 s, its evidence
|
||
pass in 69 s instead of 184 s -- and takes 14 test regions out of MW WP Form's way. pmpro has neither a
|
||
vendored tree nor a test path the row names, so it is where the gate now stands: 82, 54, 91 against 50,
|
||
every region ahead of a CVE in its own tier on a tied or higher score. Phase 2 has done what exact
|
||
facts and a weight table can do; the ties are phase 3's. After that, within-tier order is phase 3's -- a model reading the code the ties cannot --
|
||
or one more exact path fact (`distance`), and the gate stays unmet until one of them moves it.
|
||
|
||
- **Phase 3 — the LLM ranker.** Same evidence, ordered by a model. Gate: beats phase 2 leave-one-repository-out.
|
||
If it does not, phase 2 ships and phase 3 does not.
|
||
- **Phase 4 — break the closed loop.** An exploration slice (~10% of budget sampled outside the top-K) and
|
||
full labelling on repositories small enough to arbitrate every region. Gate: a measured estimate of the
|
||
selection bias in phase 3's numbers.
|
||
- **Phase 5 — prompt optimisation (GEPA / SIMBA / DSPy).** Only after 3 and 4. An optimiser run against a
|
||
censored metric optimises the censorship.
|
||
|
||
## What the prompt has to carry
|
||
|
||
The question put to the model is **not** "is this vulnerable" — that is adjudication, it is the arbiter's job,
|
||
and it is the one thing a ranker must never be asked. It is:
|
||
|
||
> Given a fixed budget of expensive dataflow queries, which of these sites are worth spending it on?
|
||
|
||
and the evidence it needs is textual and already available: the sink calls in scope **with their source
|
||
text**, the untrusted-input expressions found, the cleansing calls and **which call each one wraps**, the
|
||
sink's shape (bound versus interpolated), the declared access and its provenance, and whether the region is
|
||
reachable from a request handler.
|
||
|
||
## Constraints, from measurement and from prior scars
|
||
|
||
- **Cost.** One model call per region is 4,463 calls a scan. Batch over a prefilter, or it does not ship.
|
||
- **The loop is closed.** A region the ranker excludes is never arbitrated, never labelled, so the ranker
|
||
never learns it was wrong. Every run confirms the ordering it already had.
|
||
- **N projects.** Four pinned CVEs is not a training set. This project already has the closed-loop
|
||
train-on-test scar; the reported figure must come from a repository the optimiser never saw.
|
||
- **Determinism.** Two runs of one repository must agree or a baseline diff is meaningless. The ranking is
|
||
recorded in the manifest.
|
||
- **Degradation.** With no model configured, phases 0–2 still rank, and the report says which ranker ordered
|
||
the scan.
|
||
|
||
## Cross-assessment against the measurements
|
||
|
||
Read against what was actually measured today, the plan above has two errors and one gap. They are kept
|
||
here, corrected, rather than silently rewritten, because the corrections are themselves results.
|
||
|
||
**Phase 1's gate is wrong.** It says pruning makes `pmpro`'s taint query complete. It does not. Pruning
|
||
removes 53% of requests, and 1,177 × 6.63 s is still **2.2 hours** — nowhere near the 300 s default or the
|
||
2,400 s that was tried. Pruning halves the problem; it does not solve it. The honest gate is "53% fewer
|
||
requests issued, measured", and completion belongs to phase 2. Stating otherwise would have sent someone
|
||
looking for a bug in phase 1 when phase 1 had done exactly what it can.
|
||
|
||
*Amended 2026-09-11:* completion arrived, and it belonged to neither phase 1 nor phase 2 -- it belonged to
|
||
the instrument (contributor-scan 5.13). Pruning was and is exact; what made 339 requests exceed 2,400 s was
|
||
a dataflow overlay recomputed per batch and a seed set computed repository-wide, and the scan now completes
|
||
in 72 minutes with the CVE found. The paragraph above stands as written because its reasoning was right on
|
||
the numbers it had; the numbers were the instrument's.
|
||
|
||
**Ranking's job is concentration, not inclusion.** The brief implies the CVE is missed because the ranker
|
||
excluded it. It did not: CVE-2023-23488's region sits at position 390 of 4,463, inside the 500-region budget,
|
||
and was examined. It goes unreported because the budget is spent on 500 regions when 50 would do, and 500
|
||
is unaffordable at 6.63 s per family per region. So the ranker's success metric is not "is the CVE in the
|
||
budget" — it already is — but **"how small can the budget be while the CVE stays in it"**. That is a
|
||
different objective, and it is the one that makes a scan finish.
|
||
|
||
**The gap: nothing above says what the evidence IS.** "Sink shape" and "cleansing on the call" are named,
|
||
not defined, and a heuristic that is not defined cannot be language-agnostic because there is nothing to
|
||
check for language leakage. The rest of this brief defines it.
|
||
|
||
Two things the measurements confirm and the plan should lean on harder:
|
||
|
||
- **The joins are cheap and must survive any ranker.** 22% of query cost, two of three CVEs. A ranker that
|
||
demotes field- or hook-carried regions because they show no direct source is a ranker that loses those
|
||
CVEs. The evidence vector below therefore carries the join's own stage-one result as a field.
|
||
- **84% of regions in one tier means any separation is progress**, and the separation does not need to be
|
||
clever to be large. A tier 0 that is merely exact already reorders the budget more than the current ranker
|
||
ever has.
|
||
|
||
## The evidence vector, defined
|
||
|
||
The unit is a **(region, family) pair**. For each, a fixed vector of fields is computed. Every field is
|
||
defined in terms of **fact-table categories** — source, sink, sanitizer, dispatch, access — never in terms of
|
||
a language's syntax, which is what makes the definition language-agnostic: any language with a fact table
|
||
gets the same vector, and a heuristic that mentions `$wpdb` or `wp_ajax_` has by construction left the
|
||
vector and must be rejected in review.
|
||
|
||
**Where it is computed matters as much as what it is.** The graph is already built for the arbiter, at 76 s
|
||
for 637 files. Extracting these fields is a CPG query **without dataflow** — call nodes, their arguments,
|
||
their enclosing methods, the call graph one hop out — which is milliseconds per call rather than 6.63 s per
|
||
request. The extractor is therefore a sibling of `taint.sc` that never calls `reachableByFlows`:
|
||
|
||
> **Tiering is the CPG query without dataflow. Arbitration is the CPG query with it. Same graph, same
|
||
> facts, two costs.**
|
||
|
||
That is the whole architecture in one line, and it is why the evidence is language-agnostic rather than
|
||
merely language-neutral-in-intent: the CPG normalises syntax across frontends, so "the argument of this call
|
||
is a literal" means the same thing in PHP, Python and C.
|
||
|
||
### Sink evidence — from `[[sink]]` facts of family F
|
||
|
||
| field | definition | exactness |
|
||
|---|---|---|
|
||
| `sinks` | count of calls in scope matching a sink of F | exact |
|
||
| `shape` | per sink call, one of: **`literal`** (the vulnerable-position argument is a constant), **`bound`** (the call is a declared-safe form — a `parameterized` sanitizer fact wraps it, or its arity/shape matches the bound form the taint query already recognises), **`computed`** (the argument is an expression: concatenation, interpolation, a variable, a call result) | exact for `literal` and `bound`; `computed` is the residue |
|
||
| `cleansed_on_call` | per sink call, whether a sanitizer of F is an **ancestor of the vulnerable-position argument in the AST** — not merely present in the same function | exact, and this is the field a text scan gets wrong: `esc_sql` in scope on a different statement is `false` here |
|
||
|
||
### Source evidence — from `[[source]]` facts and the entry-point contract
|
||
|
||
| field | definition | exactness |
|
||
|---|---|---|
|
||
| `source_local` | a modelled source expression occurs in the region's own body | exact |
|
||
| `source_near` | a modelled source occurs in a method within k call-graph hops of the region (k = the arbiter's `callDepth`) | exact for the graph the arbiter will use |
|
||
| `entry` | the region is an entry point per the mapper, so its parameters are untrusted by contract | exact |
|
||
| `access` | the declared access level, **with provenance**: `declared` (a registration or route contract says so) or `inferred` (nothing found) — because "public" meaning "no decorator found" is the false positive the project has already paid for | exact |
|
||
| `carried` | the region reads a **field** whose assignment is fed by a source anywhere in the file, or a **dispatch** value whose registered producer is — i.e. stage one of the two-stage join, which is already computed and memoised, reused here as evidence | exact where computed; absent (not false) where the join was not run |
|
||
|
||
### Path evidence — cheap structural proxies for what dataflow would establish
|
||
|
||
| field | definition |
|
||
|---|---|
|
||
| `distance` | fewest call-graph hops from any source (local, near, entry parameter, or carried) to any sink of F: 0 = same statement, 1 = same method, 2+ = across calls, ∞ = no source found |
|
||
| `cleansing_between` | any sanitizer of F occurs on a call-graph path between a source and a sink — a weaker, cheaper version of `cleansed_on_call` that catches cleansing done in a helper |
|
||
|
||
### Structural and prior evidence
|
||
|
||
| field | definition |
|
||
|---|---|
|
||
| `shipped` | the project's own build declaration names this file (existing `shipped.py`) |
|
||
| `family_in_repo` | any sink of F exists **anywhere** in the repository |
|
||
| `prior` | a committed baseline recorded a finding at this site, or the site was in an exploration slice before |
|
||
|
||
## The tiers, defined as predicates over the vector
|
||
|
||
| tier | predicate | role |
|
||
|---|---|---|
|
||
| **0** | `sinks == 0` in the reachable scope, **or** `family_in_repo == false` | **exclude** — exact; the only tier that removes work |
|
||
| 1 | `sinks > 0` and no `source_local`, `source_near`, `entry`, or `carried` | ask last — nothing untrusted can arrive |
|
||
| 2 | a source exists, but **every** sink call is `bound` or `cleansed_on_call` | ask late — a flow may exist and will corroborate, not entail |
|
||
| 3 | a source exists and **some** sink call is `computed` and not `cleansed_on_call` | ask first |
|
||
| 3★ | tier 3 **and** (`access == declared public` or `distance ≤ 1` or `carried`) | ask very first |
|
||
|
||
Two properties are load-bearing and must hold in every implementation:
|
||
|
||
1. **Only tier 0 excludes; every other tier orders.** So the only place exactness is *required* is tier 0,
|
||
and tier 0's predicate has a proof: a family with no sink in reach cannot produce a flow, in any language,
|
||
under any dataflow model. Above tier 0, being wrong costs position, and `regions_truncated` reports what
|
||
position cost.
|
||
2. **Tier 0's scope must match the arbiter's.** At `callDepth = 0` the scope is the region; at depth k it is
|
||
the region plus its reachable methods. A tier 0 computed on the file when the arbiter will look k hops
|
||
out is not exact and must not be called so. The repository-wide form (`family_in_repo == false`) is exact
|
||
at every depth and is the cheap first cut — it alone removes all 487 `deserialization` requests on `pmpro`.
|
||
|
||
**Within a tier**, order by a score. In phase 2 that score is a fixed weighted sum over the vector, published
|
||
in the manifest so two runs agree. In phase 3 it is the model's ordering **of the same vector**, which is
|
||
what makes the two comparable: same input, different ordering function, one held-out metric.
|
||
|
||
## Where the model helps, and where it must not be allowed to
|
||
|
||
The vector above is exact wherever exactness was cheap. What remains genuinely uncertain is narrower than
|
||
"ranking":
|
||
|
||
- **Ambiguous cleansing.** `cleansed_on_call` is exact when the sanitizer wraps the argument directly. It is
|
||
`false` — correctly, but unhelpfully — when cleansing happens two statements earlier into a variable that is
|
||
then interpolated. Deciding whether *that* cleansing reaches *this* call is reading the code, and it is the
|
||
single largest source of tier-2/tier-3 misplacement.
|
||
- **Unfamiliar shapes.** A sink call whose form the `bound` rule does not recognise, in a framework with no
|
||
facts yet. The model can say "this looks bound" as an *ordering* opinion; it cannot add a `bound` fact,
|
||
because a fact is policy.
|
||
- **Within-tier priority** when the fixed score ties, which on a real repository it will, constantly.
|
||
|
||
And the boundaries:
|
||
|
||
- **The model never computes tier 0.** Exclusion is exact or it is a silent miss, and a model's "no sink
|
||
here" is not a proof.
|
||
- **The model never sees the question "is this vulnerable".** It sees "which of these is worth an expensive
|
||
query", with the vector and the sink call's source text. Adjudication is the arbiter's, and keeping it
|
||
there is what keeps the gates byte-identical.
|
||
- **The model's input is the vector plus text, never the arbiter's verdict** on the same run — that is the
|
||
closed loop feeding on itself.
|
||
|
||
## Metrics, so every phase can fail
|
||
|
||
| metric | definition | phase |
|
||
|---|---|---|
|
||
| `position(cve)` | rank position of each pinned CVE's (region, family) pair | 0 → all |
|
||
| `requests_issued` | taint requests after tiering | 1 |
|
||
| `budget_at_recall` | the smallest K such that every pinned CVE is in the top K — the concentration metric | 2, 3 |
|
||
| `time_to_complete` | wall clock for the taint query at that K | 2, 3 |
|
||
| `held_out_position` | `position(cve)` on a repository excluded from development of the change | 2, 3, 5 |
|
||
| `exploration_yield` | findings per region in the exploration slice versus in the top-K — the direct measure of what the ranker is missing | 4 |
|
||
|
||
`budget_at_recall` is the one that decides the release question. Today it is ≥ 390 on `pmpro`. If phase 2
|
||
takes it below 50, the scan finishes in minutes and the model is a refinement; if it does not, the model is
|
||
the plan.
|
||
|
||
## Open questions for the design phase
|
||
|
||
Two of the four questions this brief opened with are answered by the definition above, and are kept so the
|
||
answer is visible:
|
||
|
||
1. ~~Where does the evidence extractor live?~~ **It is a CPG query without dataflow, a sibling of `taint.sc`
|
||
in `cpg/queries/`.** Not the mapper: the mapper reads text and would have to re-derive per language what
|
||
the graph already normalises. The vector is language-agnostic *because* the graph is.
|
||
2. ~~Per file or per reachable set?~~ **Both, in that order.** `family_in_repo` first — exact at any depth,
|
||
free, a fifth of the budget on `pmpro` — then the reachable set at the arbiter's own `callDepth`. Never the
|
||
file alone above depth zero, because that is not exact and must not be called tier 0.
|
||
3. How many pinned repositories before leave-one-out means anything? Four is not enough. This is the
|
||
corpus ask, and it is the same ask contributor-scan 5.8 made: plugins chosen by installed base, CVEs read
|
||
out of the code.
|
||
4. Does the arbiter's verdict feed back as a label? **Only through the exploration slice.** A verdict on a
|
||
region the ranker chose is a label the policy selected, and training on it closes the loop. A verdict on
|
||
an exploration region is the only unbiased label the scan produces, and it is the one worth keeping.
|
||
5. **New:** the weighted sum in phase 2 has weights. Where do they come from before any model exists?
|
||
Proposed: set by hand from the tier semantics, published in the manifest, and treated as a baseline for
|
||
phase 3 to beat — not tuned, because tuning four weights on four CVEs is the overfit the plan exists to
|
||
avoid.
|