Commit Graph
57 Commits
Author SHA1 Message Date
Marius Ciepluch 7c1c2d4475 fix(contributor-scan): retain declared Gruntfile and report census gaps honestly 2026-09-14 08:07:06 +02:00
Marius Ciepluch 51fabef9ea feat(contributor-scan): retain first-party JavaScript tests in graphs 2026-09-13 11:29:48 +02:00
Marius Ciepluch d02406e758 docs(pre-push-safety-net): record frontend retention prerequisite 2026-09-13 11:05:44 +02:00
Marius CiepluchandClaude Fable 5.1 3b3b96be5f fix(cpg): the interpreter precondition probes the frontend's own parser script; 4.0.625 verified
php2cpg drives PHP-Parser through `php` from a script inside its own install. A second install the
containerised interpreter had no mount for parsed 1 of 637 files and exited 0 -- caught by the build
census, and now refused by name before any build. On 4.0.625 with the closure exclusion gated off:
pmpro 637 of 637 in 100 s, WP Statistics 115 s with only the two data tables excluded. Phase-2 v5
verdict and the within-tier reading recorded in the brief.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-11 13:52:17 +02:00
Marius CiepluchandClaude Fable 5.1 b6cccd1444 fix(bench,cpg): the position benchmark builds in the recipe's language and reports per repository; a doubly-failed build disposes its scratch
WP Statistics's first region is JavaScript, and a build asked for as JavaScript went looking for
jssrc2cpg, failed both launchers, lost the whole run's output and left a 9.6 MB scratch directory behind.
Also notes upstream: joern#6281 is fixed in v4.0.625.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-11 11:30:12 +02:00
Marius CiepluchandClaude Fable 5.1 7ce0a30161 docs(cpg): name the upstream issue for joern-parse --overlaysonly (joernio/joern#6283)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-11 10:42:35 +02:00
Marius CiepluchandClaude Fable 5.1 0f35578d5b perf(cpg): one taint row per evidence key, with the path count
Ten real pmpro pairs at callDepth 3 returned 3,838 flow rows. Every field the driver reads is a property
of the sink or of (source kind, source, sanitized); only `length` varies per path, and the driver ranks
by the shortest. Grouped on that key with the minimum length and a `paths` count: 1,155 rows carrying
3,838 paths, same time. The specs are corrected too -- the first write-up called the depth-3 rows
"mostly the same evidence repeated", and 3.3x is what the measurement says; the other 30x is distinct
sources.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-11 09:14:25 +02:00
Marius CiepluchandClaude Fable 5.1 5be63bdfea docs(specs): the whole-repository scan completes -- 72 minutes, CVE-2023-23488 found, 250 findings
pmpro, 637 files, the product's own build path after 60963f5: build 97 s with overlays, evidence 76 s,
taint 4,105 s for 348 real requests (11.8 s each), arbitrate 99 s, peak child RSS 2.4 GB. The CVE is at
class.memberorder.php:936 among 250 entailed findings across 40 files -- the number the flow-aware-ranking
spec now has to concentrate. callDepth measured on the same graph: a 1.4x time term and a 100x payload
term (3,838 rows for ten requests at depth 3 against 38 at depth 1).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-11 09:10:14 +02:00
Marius CiepluchandClaude Fable 5.1 60963f565c fix(cpg): the real-request cost, attributed and removed -- overlays once, seeds scoped, data tables out
Task 5.13, measured on ten real pmpro pairs at callDepth 1 with each join half switched separately and
the 54 s JVM fixed cost subtracted: both halves off 11.1 s/request, field half 10.9 s, hook half past
49 s (timeout). Stage timers put it in `hookSeedsIn("")`: every field access in all 7,061 methods put
through the field join before the first callback was answered. Seeds are now the callback's own body
and file, which is where WP Statistics's `set_current_page` and `$this->rest_hits = ...` both live.
Both halves on: 9.7 s/request. `sourceNodes` is also materialised once per request instead of once
per sink.

The WP Statistics re-verification then exposed the larger term. A `joern --script` run is two JVMs and
`-J-Xmx` sizes only the launcher, so the heap knob governed nothing; the backend now sets
JAVA_TOOL_OPTIONS too. And since PHP builds went through php2cpg directly (1ce7e9d, an hour after the
hook half was verified from a joern-parse build), every query batch has recomputed the dataflow overlay
at load -- 54 s on pmpro, and on WP Statistics an OutOfMemoryError inside OssDataFlow after 21 minutes
at 2 GB, no completion in 25 minutes at 4 GB, and no help from --max-num-def at 1000 or 200. Two
vendored browser-profile tables, one array literal each, were 60% of that graph.

Three changes, all named in the log: `queries/overlay.sc` applies the overlays once at build time and
the saved graph replaces the raw one (joern-parse --overlaysonly NPEs in 4.0.623); a source file over
1 MB is excluded as a data table and reported in `unparsed`; and the seed scoping above. After: the WP
Statistics graph reloads in 8 s, the `record` request that timed out at 520 s answers in 39 s including
the JVM, and CVE-2022-25148 is entailed again with `sourceKind = hook`. A raw-vs-saved payload diff on
pmpro shows identical evidence keys and fewer duplicate flow rows.

Also: the backend's own unit tests left fifteen ousast-cpg-* directories in /tmp per run; conftest now
keeps tempfile under the test's directory. The compile test covers overlay.sc.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-11 01:16:00 +02:00
Marius CiepluchandClaude Fable 5.1 9a8e9f083f feat(ranking): phase 1 -- tier-0 taint requests are no longer asked
A pair with no sink of its family in reach cannot yield a flow, in any
language under any dataflow model, so its taint request is never issued. The
count is recorded as requests_pruned -- exact, so not a degradation -- and a
test asserts that every other pair's request still goes out.

    637-file plugin, 500-region budget
    tier_counts   {0: 2161, 1: 60, 2: 20, 3: 211, 4: 48}
    pruned        2,161 of 2,500 = 86%        evidence pass 94.5s
    taint         339 requests -> 2,400s TIMEOUT

Gate met and exceeded; the scan still does not finish, and the reason corrects
phase 0. "1,148 ms per request" was an average over a 250-request set that was
88% tier 0 and answered instantly; the ~30 real requests in it cost ~9.5s each.
Pruning removes exactly the cheap questions and keeps the expensive ones, so it
cannot make them faster. The honest cost of a taint question that CAN have an
answer is 7-10s at callDepth=3, and that is what phase 2 and 5.13 design
against -- not 1.1s, and not 6.6s either. Both specs corrected.

869 tests pass.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 19:18:20 +02:00
Marius CiepluchandClaude Fable 5.1 88ada8e48c feat(ranking): phase 0 -- the baseline, the evidence instrument, and a 7x taint speedup found on the way
flow-aware-ranking phase 0: measure before changing anything.

THE BASELINE, committed as benchmarks/ranking/baselines/2026-09-10-access-rank.json
from benchmarks/ranking/position.py, which sorts regions exactly as the scan does:

    pmpro         4,463 regions  83.8% at 0.30   CVE at 390   budget_at_recall 391
    mwwpform        983 regions  81.7% at 0.30   CVE at 512   budget_at_recall 513
    wpstatistics  1,673 regions  75.3% at 0.30   CVE at 503   budget_at_recall 504
    vampi            25 regions                  BOLA at 15, 10; SQLI reached transitively

That corrects the brief's own cross-assessment: "concentration, not inclusion"
was measured on pmpro alone. On the other two plugins the CVE sits OUTSIDE a
500-region budget and is never examined; the slices found those CVEs only
because a slice has too few regions to exclude anything.

THE INSTRUMENT: `evidenceOnly` mode on the taint query -- what is in scope,
which sink calls, what shape, whether a sanitizer wraps THEIR argument, whether
a source is local or reachable -- returning before reachableByFlows is ever
called. model/evidence.py turns its rows into a vector per (region, family)
and a tier; scan.py records every pair's tier on the result and acts on none of
it. Every later phase is measured against a record made by scans that did not
know they were being measured.

    2,500 pairs in 98.9s incl. JVM load, ~40 ms per pair
    tier 0 -- no sink of that family in reach: 2,161 of 2,500 = 86%

Two earlier attempts ran past an hour each with no payload, and both were my
fault: repository-wide walks repeated per request -- the sink scan, the
reachable-method BFS, familyInRepo. Memoised across the batch.

AND THE SAME FIX REACHES THE DATAFLOW MODE. sinkCalls and reachableMethods do
not depend on the family, and were recomputed for all 2,500 requests. The
identical 250-request set with both joins on:

    2,026.3s -> 287.0s      8,105 -> 1,148 ms per request      same 5,779 rows

Most of what 5.13 measured as "the base taint query" was never dataflow. A 7x
speedup with nothing about the analysis changed; both corrections recorded in
the specs. Combined with 86% tier 0, the whole-repository taint query that
timed out at 40 minutes is ~339 real requests at ~1.1s: about six minutes.

Also: the module audit manifest is regenerated (91 load-bearing, 7 standalone,
0 orphaned) because evidence.py is now imported by the scan, and the
failed-query test is order-independent since the evidence pass records its own
failure before taint's.

869 tests pass; query compile test 4 passed; gates byte-identical.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 18:22:18 +02:00
Marius CiepluchandClaude Opus 5 7e397beca6 docs(flow-aware-ranking): brief -- rank pairs by evidence, in phases that each must prove themselves
A taint request costs 6.63s, a 637-file plugin yields 4,463 regions, and 83.8%
of them share one rank ordered alphabetically. The ranker is not weak, it is
looking at the wrong thing.

The evidence that this is tractable, from one plugin:

    getMemberOrderByCode  (holds CVE-2023-23488)  rank 0.30
        $wpdb->get_var("... WHERE code = '" . $code . "' LIMIT 1")
    getMemberOrderByID    (sibling)               rank 0.30
        $wpdb->get_row("... WHERE id = '$id' LIMIT 1")
    pmpro_rest_api_get_membership_level           rank 0.80
        no SQL sink at all

A region with no sink outranks two holding raw concatenated SQL. The signal is
in the sink call's own text -- interpolated versus bound -- and nothing in the
ranker reads it.

And the detail that decides whether a keyword rule suffices: `esc_sql` IS in
scope for the CVE's function, applied to a different statement. "A sanitizer is
present, rank it lower" demotes exactly the right answer. Knowing which call it
wraps is reading, not matching -- which is the case for a model rather than a
table.

Unit: (region, family) PAIRS, tiered by cheap evidence. Tier 0 (no sink of that
family in scope) is the only tier that excludes rather than orders, so it alone
must be exact; above it, being wrong costs position rather than the finding.
That bound is what makes a model safe here -- a ranker cannot manufacture a
finding.

Six phases, each shipping only if it beats the previous one on a repository it
was not developed against: baseline, exact pruning (the phase that makes a scan
finish), heuristic tiers, the LLM ranker, breaking the closed loop, then prompt
optimisation. Phase 2 carries an explicit possibility that the model is not
needed for tiering -- and knowing that is worth more than assuming it. Phase 5
comes last because an optimiser run against a censored metric optimises the
censorship.

Records what the prompt must carry, and that the question is never "is this
vulnerable" -- that is the arbiter's job and the one thing a ranker must not be
asked.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 16:06:55 +02:00
Marius CiepluchandClaude Opus 5 62052b9525 docs: where PHP detection stands, and the shortest path to a finishing scan
Feeds the day's measurements back into the spec and reorders the roadmap
around what is actually blocking.

STATUS. Three CVEs found on real WordPress plugins -- CVE-2023-23488,
CVE-2023-6559, CVE-2022-25148 -- each entailed by the graph with no model in
the loop and each absent from its fixed side. Two of them require the
two-stage join and would be invisible without it. The detector is not the
problem.

The repository is. A 637-file plugin builds in 76s to a 4.25MB graph,
dominance and config answer, taint does not finish. Three blockers in the
order they bite: cost (6.63 s/request), ranking (84% of regions share one rank,
ordered alphabetically), coverage (access_control never asked for PHP,
output_encoding untrustworthy while the sanitizer list is flat).

And what is NOT a blocker, contrary to most of what this file said this
morning: php2cpg. One genuine defect, 0.2% of files, detected and excluded and
reported. Everything else attributed to the frontend was a local php shim
proxying stdio, which cost four wrong diagnoses in a day.

NEW TASK 5.14, and it is the cheapest thing on the board. 53% of taint
requests are put to a family whose sinks are not in scope -- all 487 of PMPro's
deserialization requests go to a repository that never calls unserialize.
Skipping them cannot lose a finding, because a family with no sink cannot
produce a flow. 4.6 hours -> 2.2, before any ranking work.

ROADMAP reordered around one question: the shortest path to a scan of a real
plugin that reports its CVE. Prune the impossible questions, then rank (an LLM
job, and safe as one because ranking cannot manufacture a finding, with the
closed-loop problem treated as a design requirement rather than a caveat), then
attack the per-request cost only if those two are not enough.

857 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 16:01:21 +02:00
Marius CiepluchandClaude Opus 5 83ab533fc4 docs(contributor-scan): the ranker is 84% one tier, and the loop is closed
Three corrections and one design requirement, from measurement and from
maintainer direction.

MEASURED. 4,463 PMPro regions carry five distinct ranks, and 3,742 of them --
83.8% -- share rank 0.30, ordered alphabetically by path. That tier spans
positions 159..3,900, and 341 of the 500 examined regions come from it: two
thirds of the budget spent on an arbitrary alphabetical slice. It is not a
weak ranker, it is barely a ranker.

CORRECTED. CVE-2023-23488's region is at position 390 of 4,463 -- INSIDE the
budget and examined. I stated repeatedly that it ranked ~1036 and was never
reached; that number came from before the array-callable and method-recogniser
fixes moved it. The reason it goes unreported is the taint query timing out,
so no region got a verdict at all. Ranking is a real problem; it is not this
miss, and conflating them would have sent the fix to the wrong place.

DIRECTION. Ranking is the LLM's job, not a tie-breaker for a residue. The
judgement "is this worth looking at" is a judgement about unfamiliar code.
Facts and generic features stay as cheap evidence for the model to read and as
the fallback when none is configured.

AND THE LOOP IS CLOSED, which is worse than train-on-test and was the sharper
point. A region the ranker excludes is never arbitrated, so it never produces
a label, so the ranker never learns it was wrong -- the feedback is censored by
the policy being trained, and every run confirms the ordering it already had.
Optimising on that makes it more confident, not more correct.

Breaking the loop is therefore a design requirement, with two cheap mechanisms
recorded: an exploration slice (~10% of budget sampled from outside the top-K,
the only labels the policy did not choose) and full labelling on repositories
small enough to arbitrate every region, which is policy-independent ground
truth. Inverse-propensity weighting is noted as available if the ranking is
made stochastic, and as unnecessary otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 15:49:57 +02:00
Marius CiepluchandClaude Opus 5 a895bceefc docs(contributor-scan): 5.13 -- a taint request costs 6.6s, and the joins are not why
Four configurations over one verified graph (637 files, 7,061 methods, census
confirmed BEFORE anything was timed) and one 250-request set:

    both joins off   1657.5s   6,630 ms/request   5779 rows
    field half only  1623.0s   6,492 ms/request   5779 rows
    hook half only   1781.0s   7,124 ms/request   5779 rows
    both on          2026.3s   8,105 ms/request   5779 rows

The base query is the cost. The field half is free -- inside noise, measured
slightly faster -- the hook half adds 7.5%, both add 22%. Cutting them buys a
fifth of the time and costs two of the three CVEs.

Corrects a number I published earlier: ">0.94s per request" assumed taint
completed within its 2,400s ceiling. It timed out, so that was a floor dressed
as an estimate. The real figure is seven times higher.

2,500 requests is ~4.6 hours; all 4,463 regions would be ~41. Repository-scale
PHP is not slow, it is the wrong shape of question asked thousands of times.

Caveat kept with the numbers: all four configurations return an identical 5,779
rows because these are the TOP 50 regions -- REST handlers where framework
sources already exist and the joins add nothing. The joins earn their keep
lower down (`_delete_files` at rank 0.30). This measures their cost faithfully
and says nothing about their value; the two must not be conflated when deciding
what to cut.

Levers, none of them the arbiter: fewer and better regions (5.12, ~10x on its
own), fewer families per region (five are asked of every region regardless of
whether its scope holds a sink of that family), and callDepth -- three levels
over 7,061 methods is what makes a request cost seconds.

Also notes that commit 7308570 carried the OPENULTRASAST_CPG_QUERY_TIMEOUT
change under a docs message; `git add -A` swept it in and the message does not
say so.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 15:13:33 +02:00
Marius CiepluchandClaude Opus 5 7308570ba6 docs(contributor-scan): 5.12 -- the ranker knows one framework, everything else is "unknown"
The rank SCALE is generic: `_ACCESS_RANK` maps abstract access levels to
numbers and knows no framework. The EVIDENCE feeding it does not --
`_wordpress_hook_access` holds the literals `wp_ajax_nopriv_` and `wp_ajax_`,
`_rest_route_access` holds `__return_true`. One framework, written in Python.

The consequence is not "slightly worse elsewhere", it is no ordering at all.
Anything unrecognised falls to review-required = 0.30 and ties break
alphabetically by path. That is where CVE-2023-23488's region sits -- position
~1036 of 4,463, below every REST handler -- so a 500-region budget never
reaches it. On a Laravel or Django codebase EVERY region lands in that tier:
thousands of regions ordered by filename.

Three layers, and the model is only the third:

  * facts, not code -- an [[access]] fact declares hook patterns and their
    access level, the way [[dispatch]] already replaced hardcoded
    add_action/apply_filters today;
  * generic features needing no framework knowledge -- modelled sinks in the
    file, a modelled source present, call-graph distance to the nearest sink,
    shipped, an unsanitized path to any sink. This is what would move
    CVE-2023-23488's region on its own, and it should exist BEFORE any model,
    or the model's contribution cannot be measured against anything;
  * the LLM for the residue the first two cannot separate.

Ranking is a safe place for a model because it cannot manufacture a finding:
it decides what is looked at, never what is reported. The arbiters still
decide, the gates stay byte-identical, and a misrank costs recall -- now
visibly, since regions_truncated reports 500 of 4,463.

Records the constraints that must be settled before spending model budget:
batched over a prefilter (4,463 calls a scan is not a CI tool),
leave-one-repository-out (this project already has the train-on-test scar),
a recorded ranking so baselines diff, and honest degradation with no model.

A model proposing FACTS for an unknown framework is a different, slower loop
needing human approval -- ranking at runtime is not policy, a fact table is.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 12:59:24 +02:00
Marius CiepluchandClaude Opus 5 07df314e12 fix(cpg): prove the frontend can read the source before building, and check the graph always
Two permanent guards, and a correction that motivates both.

THE CORRECTION. There is no php2cpg defect. Every PHP build on this machine
ran through a local `php` shim proxying stdio through `docker run`, and
php2cpg depends on the INTERLEAVING of its parser's stderr banners with its
stdout JSON to attribute each document to a file. The proxy scrambles it:

    streaming shim   drops 72-88   767KB / 969KB / 1.2MB   different every run
    buffered shim    drops 0       5,703 bytes             empty, and silent
    NATIVE php       drops 0       2,872,996 bytes         identical x3

The 637-file tree with native php: 0 drops, a 4,329,471-byte CPG, 20.0s,
1.9GB peak -- which is exactly what task 5.10 said before I started
"correcting" it. No intermittent failures, no malformed graph, no dataflow
assertion, no scale limit.

GUARD 1: the interpreter must prove it can read the tree, before anything is
built. Every instrument failure this project has had takes one shape -- the
input was unreadable and the tool reported a plausible ZERO -- and it has cost
four wrong diagnoses in a day:

    a php that could not see ~/.cache   -> "cannot handle 637 files"
    a php whose stdio was proxied       -> "drops half its batches"
    a container missing a bind mount    -> "0 dropped", 5,703-byte graph

None announce themselves: file_exists() is false, the parser writes nothing,
the frontend exits 0, and "no errors" reads exactly like success. Checking the
output cannot separate that from an empty repository. Checking that the input
arrived can, and costs one exec.

GUARD 2: the graph census is taken on EVERY build, not only one the frontend
warned about. A graph holding nothing arrives with no warnings at all --
silence is the symptom that has no symptom -- and the shortfall is now
reported as "the frontend reported no failures but the graph holds N of M
source files".

`cpg_build_failed` carries that detail through to the report instead of being
a bare reason.

Also adds tests/test_cpg_queries_compile.py: the queries are 900 lines of
Scala that nothing compiled. A rowsFor signature change shipped with 850 tests
passing and broke every query at runtime, found by a measurement failing hours
later. Each query is now compiled AND run against a real graph wherever joern
exists, and must return a parseable payload.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 11:35:35 +02:00
Marius CiepluchandClaude Opus 5 f3f41e2fa9 docs: the 637-file wall is the taint query, not memory and not vendored code
Follow-up measurements, because the obvious explanations were all wrong.

Not memory: at 2GB AND 4GB the CPG never finishes loading, with no
OutOfMemoryError either time, both runs stopping in the same place --
Braintree\Util. What costs is Joern's dataflow overlay, recomputed per import.

Not scope, though scope is real and worth fixing on its own terms: 429 of the
637 files are vendored SDKs (Braintree, Stripe, each with its own LICENSE) and
31 are tests. PMPro's own code is 177 files, 62k lines. Auditing a plugin by
scanning the payment SDK it bundles is wrong regardless of cost -- those
findings are not the plugin's bugs -- and it is the same idea shipped.py
already applies to Makefile.am, which PHP has no equivalent of.

Scoping to authored code helps and does not fix it:

    regions 4,463 -> 1,324    total 996.6s -> 843.7s    findings still 0

600.3s of that 843.7s is the taint query hitting QUERY_TIMEOUT_SECONDS (300s)
on each of two shards. Two timeouts, not work.

So the wall is the per-request cost of the taint query at 2,500 requests. The
next measurement is what the two-stage joins cost at scale -- 1.6x on a slice
(62s -> 97s) and plausibly the dominant term here. Raising the timeout before
knowing that would only buy a slower way to find out.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 09:06:03 +02:00
Marius CiepluchandClaude Opus 5 ebf37a9b3c docs(contributor-scan): the 637-file scan, measured -- it decides nothing
preprocess + mapping   107s -> 673 targets, 4,412 entry points, 4,463 regions
    build                  255.9s   (retries, shards, census)
    query                  720.9s   (taint 600.3, dominance 64.1, config 56.5)
    arbitrate               19.8s
    total                  996.6s   peak child RSS 1,132 MB
    regions examined       500 of 4,463 -- 11%, the default cap
    findings               0
    CVE-2023-23488         not reported

The taint query dies in ForkJoinPool after ten minutes, and so does a bare
`cpg.file.size` census, so it is the CPG under a 2GB heap rather than the
request payload. The engine reports all of it -- files_unparsed, query_failed
for 2,500 regions, cpg_sharded -- so nothing reads as clean. That is the
machinery working; it is not a result.

The honest statement of PHP detection today is that the slices find the CVEs
and the repository does not. One file finds CVE-2023-23488, two files find it
with a shard, 637 files find nothing. No release claim about scanning a
WordPress plugin can rest on this.

Two of my own defects surfaced on the way, both invisible at slice scale and
both now fixed: hookCallbacks repeated into every request, and candidates
enumerated for a judge nobody was going to ask.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 08:48:05 +02:00
Marius CiepluchandClaude Opus 5 a762860b17 docs(contributor-scan): the 637-file premise was wrong, and it was my own shim
This task opened with "php2cpg builds a 4.3MB CPG for a 637-file plugin and
importing it throws". It never read the plugin.

`php` on this machine is a local shim that runs php:8.3-cli in docker,
mounting $HOME/joern, /tmp and $PWD -- and not $HOME/.cache, which is where
every pinned checkout lives. PHP could not see one file of it:

    php -r 'var_dump(file_exists($f));'  ->  bool(false)   # file plainly there
    php2cpg <637-file checkout>          ->  6,224-byte CPG, exit 0,
                                             ZERO reported failures

Every parser batch failed with `File  does not exist.` and php2cpg wrote an
empty graph. Second false conclusion this same shim has manufactured -- 5.5 is
the first, recorded there as "NOT A DEFECT, it was the shim" -- and the second
time a measurement here was taken through a broken instrument.

Local only: the shipped image installs php-cli natively, so no released path
was affected. And the engine's own honesty machinery was right all along -- a
scan over that graph reports `cpg_empty`. It was the hand-run php2cpg and the
note in this task that read a wrong conclusion out of it.

With the cache mounted, the actual envelope for 637 files / 117k lines:

    wall 33.0s    peak RSS 1,217 MB    CPG 1.6 MB    156 drops (69 files)

Affordable on a CI runner rather than a vertical-scale box, which was the open
question behind this task.

The drops are per-BATCH, not per-file. php2cpg hands its parser ~20 files per
invocation and a failed batch reports every file in it; dropped file sizes run
from 67 bytes to 433 KB, so size has nothing to do with which files are lost.
Being in an unlucky batch does. That is why retrying works, and why "exclude
the difficult file" was the wrong mental model -- worth correcting in the
sharding rationale once the whole-repository scan finishes measuring.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 07:49:35 +02:00
Marius CiepluchandClaude Opus 5 9cce5a3181 feat(cpg): give the files a frontend refuses a CPG of their own
A dropped file is dropped from the ONLY graph there is, so every question
about it has no answer -- which is how a 637-file plugin became a scan of
nothing. But those files build perfectly well alone: the failure is
per-invocation, not per-file. So a file the main build refuses is excluded
from it, given a second CPG, and both are queried with rows concatenated per
request id.

    pmpro two-file slice   before: cpg_empty, 0 findings
                           after:  18 entailed incl. CVE-2023-23488 at :936,
                                   reported with cpg_sharded

What this does NOT buy is a flow that crosses the shard boundary: a source in
the excluded file whose sink is in the rest of the tree is invisible to every
question asked, and no merging of results recovers it. That is why the split
is REPORTED rather than quietly served as if it were whole.

The excluded-file tree is symlinks at the original relative paths. A region
asks about `classes/class.memberorder.php` and the queries match a filename by
suffix, so a flattened copy would cheerfully answer about a file nobody asked
about.

Reports gain "What could not be analysed", rendering cpg_sharded, cpg_empty,
files_unparsed, query_failed and budget_exhausted in the reader's terms. A
degradation is a fact about coverage, not a warning to file away: silence in
the coverage table means "no fact table covered this", and silence about an
empty or split graph means something else entirely.

Also removes the WordPress vocabulary from the query, which was the other half
of this request. `apply_filters` was a hardcoded Set in taint.sc and
`add_action|add_filter|add_shortcode` a hardcoded alternation in mapping.py.
Both are now a `[[dispatch]]` fact:

    [[dispatch]]
    id = "wordpress_hooks"
    register = ["add_action", "add_filter", "add_shortcode"]
    apply = ["apply_filters", ..., "do_action", ...]

The shape is general -- a registry keyed by a string literal at both ends, as
Django signals, jQuery events and Symfony's dispatcher all are -- so a second
framework is a row in a TOML rather than an edit to a query. `dispatch_apply`
is empty for every language that declares none, and the hook half then does
nothing at all rather than looking for names that mean nothing there.

848 tests pass; gates byte-identical. wpstatistics 3/2 with the CVE at :225 on
the vulnerable side only, mwwpform 1/0, pmpro 18/1, VAmPI 4 unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-10 07:32:20 +02:00
Marius CiepluchandClaude Opus 5 1ce7e9db9b fix(cpg): build php through the frontend and retry while it drops files
The intermittent empty-graph builds that cost four manual retries to measure
WP Statistics at all.

First, a correction: the explanation this repo carried was wrong. It said
php-parse's stderr banners interleave into block-buffered stdout and corrupt
the JSON. Capturing the exact bytes php2cpg receives -- by putting a logging
wrapper on `php` -- shows its input arriving complete and valid: two
well-formed JSON documents, 5,176,594 bytes, byte-identical across runs, with
no banner anywhere inside them. The defect is in php2cpg's own reading of that
input, and nothing outside it repairs a given attempt.

Three fixes were tried against the old theory and each changed nothing, which
is how the theory died:

  * an unbuffered PHP shim via --php-parser-bin        0 of 6 builds usable
  * compacting the JSON, 5,176,594 -> 943,028 bytes    0 of 6
  * -J-Djava.util.concurrent.ForkJoinPool.common.parallelism=1   0 of 6

What is true is that the failure is INTERMITTENT, and that `joern-parse` is
both worse at it and silent about it. Measured on one WordPress slice:

    joern-parse   5 of 8 builds usable, and says nothing when they are not
    php2cpg      11 of 12 builds usable, and names every file it dropped

So php builds through the frontend directly and retries while it reports
dropped files. Retrying is sound only because the failure is intermittent AND
reported -- retrying a silent failure would be superstition; retrying a
reported one is just using the report. It gives up after four attempts and
reports what is still missing rather than pretending a partial graph is whole.

    wp-vuln through the driver   before: 4+ manual retries to get one build
                                 after:  6 of 6, 3 entailed findings each time

joern-parse remains the path for every other language, and the fallback when
no frontend is on PATH.

843 tests pass; gates byte-identical; mwwpform, pmpro and VAmPI unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 21:11:12 +02:00
Marius CiepluchandClaude Opus 5 d6908f18ba feat(cpg): the hook half — CVE-2022-25148 found across an add_filter/apply_filters edge
The last of the three WordPress flaw classes 5.8 measured as missed. The
value crosses a registry inside WordPress core:

    add_filter('wp_statistics_current_page', array($this, 'set_current_page'));
    return apply_filters('wp_statistics_current_page', $current_page);

php2cpg drops the callback argument entirely -- the CPG holds
`add_filter("wp_statistics_current_page", )` -- so the edge cannot come from
the graph at any price. It comes from the source text instead, where both ends
are literals, via `mapping.php_hook_callbacks`.

                 vulnerable                              fixed
  wpstatistics   3 entailed, incl. pages.php:225:record  2, that one absent
  mwwpform       1 ($this->attachments)                  0
  pmpro          18, incl. CVE at :936                   1
  VAmPI          4                                       unchanged

The correctly-prepared sibling query one line below the CVE is not flagged on
either side.

Four things this cost, each measured rather than guessed:

  * Half one must be asked per ARRAY KEY, not per callback. "Does an
    unsanitized value reach this callback's return" is TRUE on both sides: the
    fix escapes `type` and `id` and leaves `search_query` alone. A
    callback-level answer flags the fixed side as readily as the vulnerable
    one, which is a leak, not a detection. The key is a literal at both ends,
    so it joins the way the hook name and the field name do -- the same idea
    for the third time.

  * The sanitizer test had to stop crediting a sibling's cleansing. The
    vulnerable query is ONE concatenation holding both `esc_sql($page_uri)`
    and the injectable `{$current_page['id']}`, so asking whether a
    sanitizer's name appears in a path element's text marked the whole flow
    sanitized. An element must now BE the cleansing -- a call to it, or its
    name as a string literal, which keeps `array_map('esc_sql', ...)` working.

  * Hook seeds are repository-wide where field seeds are file-scoped. A hook
    registry is global by construction: the callback is in one file and its
    `apply_filters` in another. The query returned nothing until those two
    scopes were separated.

  * `sourceKind` had to be REPORTED, not inferred. By the time a row reaches
    Python a hook source reads as `$current_page["id"]` and a parameter as
    `$args`, and `_usable_flows` discards parameter flows whenever a modelled
    one is present -- so seven sanitized `$_SERVER["REQUEST_URI"]` flows
    displaced the CVE between the query and the verdict. That preference then
    had to move PER SINK SITE: applied across a region it decides one site's
    question with another site's evidence, and PMPro's CVE at :936 vanished
    from a region still reporting fifteen other findings.

`verdict` now delegates to `verdicts` rather than running its own selection
over the union, because two copies of "which flow decides" is exactly the
duplication this module has been bitten by before -- and had been again: the
per-site preference did not reach the single-verdict path, so the two entry
points disagreed about the same rows.

841 tests pass; gates byte-identical; VAmPI unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 20:42:35 +02:00
Marius CiepluchandClaude Opus 5 dcdbe322a1 feat(cpg): taint a field where it is assigned — the field half of 5.11
`_delete_files()` takes no parameters. CVE-2023-6559 arrives as a FIELD,
written by one method and read by another:

    public function __construct( ..., array $attachments = array() )
        $this->attachments = $attachments;          // half one ends here
    protected function _delete_files()
        foreach ( $this->attachments as $file )     // half two starts here
            unlink( $file );

Both halves were already in the graph. Only the join was missing, and the key
it joins on is the literal `$this->attachments`, written identically at both
ends -- so this is a second question asked with the query that already exists,
not an edge a frontend has to invent.

A read of `$this->F` is a source when some assignment to the same literal
`$this->F` in the same file is fed by an unsanitized flow from a seed. Scoped
three ways so a field name common to every class ever written cannot become a
wormhole: only fields the sink scope reads, only assignments in the same file,
and the right-hand side must itself be reachable from a source.

The receiver could then leave `parameterNodes` -- the precision fix refused
two commits ago, now sound, because the path it was standing in for is carried
by the field it actually travels through:

                          before                     after
  mwwpform vulnerable     1, from `this`             1, from $this->attachments
  mwwpform fixed          0                          0
  pmpro vulnerable        15 -- 4 named, 11 receiver 18 -- all named or field
  pmpro fixed             1, receiver-sourced        1
  VAmPI                   4 entailed                 4 entailed, unchanged

CVE-2023-23488 is still at class.memberorder.php:936 and CVE-2023-6559 at
class.mail.php:259. Every finding now names the value it followed instead of
naming the object.

Three things cost a measurement each, recorded in the task:

  * A summary must carry the SANITIZATION status of the half it summarises,
    not merely its reachability. Without that clause,
    `$this->sqlQuery = "..." . esc_sql($x) . "..."` reads as tainted -- and
    PMPro builds most of its queries that way, so the fixed side went from 1
    finding to 13. That is the pair no longer separating, which is the only
    number that matters.
  * Two-phase `reachableBy` then `reachableByFlows` is SLOWER, not faster:
    183s -> 256s. Running the analysis twice costs more than skipping path
    reconstruction saves. Reverted.
  * Memoizing per field rather than per file took it to 97s, about 1.6x the
    pre-field cost for that class, because only fields some region reads are
    worth the flow query.

`inScope` is factored out of `sinkCalls` so the field sources are scoped to
exactly the same place as the sinks, rather than a second copy of those three
branches drifting from the first.

841 tests pass; gates byte-identical; VAmPI unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 19:42:20 +02:00
Marius CiepluchandClaude Opus 5 e41d6dd7ea docs(contributor-scan): 5.11 -- a detection strategy, not a verdict of impossible
"Structurally impossible" is a verdict, and a verdict is not a plan. Replacing
both structural-miss tasks with the one mechanism that covers them, because
they are the same shape: two halves of a path joined by a key that is a
literal in the source text.

    hook:   $_REQUEST -> ... -> return of set_current_page
            key: 'wp_statistics_current_page'   literal at both ends
            return of apply_filters(key) -> ... -> $wpdb->get_row

    field:  $_POST -> ... -> assignment to $this->attachments
            key: attachments                    a field name at both ends
            read of $this->attachments -> ... -> unlink

Neither half is missing from the graph. Only the join is. So this is not a
request for a frontend to draw an edge it cannot draw -- it is a second
question asked with the query that already exists:

  stage 1  the existing taint question against a synthetic endpoint: does
           untrusted data reach a callback's return, or an assignment to
           $this->F?
  table    built by reading literals, never inferring: add_filter('H', c)
           against apply_filters('H', ...); $this->F = against $this->F
  stage 2  does the apply_filters return, or the field read, reach a sink?
  join     on the key, with the RUNG carrying the join's uncertainty -- one
           registered callback and a literal key entails; several callbacks or
           a computed hook name corroborates, and the witness says which

"Any hook may reach any handler" is refused absolutely: a complete graph over
the plugin would entail everything.

No new dependency, no CPG mutation, no IR -- both stages ride the batch that
already runs, which is the point of expressing it as a query-level join.

It also recovers the precision fix 5.8 had to refuse. With the field half
working, _delete_files() is reached through $this->attachments rather than
through $this being tainted wholesale, so the receiver can then leave
parameter sources and take PMPro's 11 receiver-sourced findings with it.
5.8's numbers are the before-side of that measurement, and both observables
are pairs already pinned.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 18:45:55 +02:00
Marius CiepluchandClaude Opus 5 2ba1aaf649 docs(contributor-scan): scope 5.11 to the hook registry, having checked its neighbours
"String-named callable" sounds like one gap and is at least two, so the
neighbouring forms were measured rather than assumed. All three came back
sound, and none of them is what breaks WP Statistics:

  * taint SURVIVES a string-named callable -- array_map('trim', $_GET['a'])
    and call_user_func('trim', $_GET['a']) both reach the sink and are
    entailed, exactly like a plain call;
  * a sanitizer named by a string IS recognised -- array_map('esc_sql', ...)
    suppresses, which is the form PMPro actually writes;
  * the textual sanitizer test does not over-suppress. A query where a SIBLING
    value is esc_sql-wrapped is still entailed for the unwrapped one, and a
    decoy 'esc_sql' literal flowing to the same sink does not suppress either,
    because flow.elements are the nodes on that value's own path.

I expected the second and third to be defects and they are not. Recording the
negative result so the next reader does not spend the same hour on it.

The hook edge is different in kind, which is why it stays its own task:
`add_filter` and `apply_filters` are not one expression carrying a callback,
they are two statements in two files joined only through a hashtable inside
WordPress core -- code that is not even in the scanned tree.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 18:44:26 +02:00
Marius CiepluchandClaude Opus 5 435c14f3ec docs(contributor-scan): 5.8 -- WordPress class by class, including what it cannot ask
Three plugins, each pinned at a version whose advisory was read out of the
code, each measured against its own fixed commit:

  unauth injection, sink in the receiving function  pmpro        FOUND
  arbitrary file operation                          mwwpform     FOUND
  unauth injection across a WordPress hook          wpstatistics MISSED
  missing capability check                          --           cannot be asked
  unescaped output                                  --           cannot be trusted

Both hits are entailed by the graph alone -- no judge, no spend -- and both
disappear on the fixed side, because both fixes are calls the fact tables
carry: `$wpdb->prepare` and `realpath()`. The engine is not recognising a bug;
it is failing to find a cleansing call on a path it resolved, then finding one.

Precision is stated rather than flattered. PMPro's vulnerable side reports 15
entailed, 4 from a named parameter and 11 from the receiver, and one of those
survives into 2.9.8 at a query whose every fragment is `esc_sql`-wrapped.

The miss is the useful half, and it is checkable rather than inferred. WP
Statistics' CVE has a modelled source, a modelled sink and `esc_sql` as its
fix, and reports nothing on either side, because the file holding the sink has
zero superglobals, makes no call into the class holding the source, and is
joined to it only by

    add_filter('wp_statistics_current_page', array($this, 'set_current_page'));
    return apply_filters('wp_statistics_current_page', $current_page);

Both ends name the callback with a string, so no frontend can draw the edge.
Most plugin data travels this way. What the engine reports there instead is two
other sites, the same class at the wrong lines, identical on both sides -- and
not noise: upstream annotates both `// phpcs:ignore WordPress.DB.PreparedSQL`
and later rewrote one with `$wpdb->prepare`.

Two classes could not be asked at all, which is not the same as a miss.
`dominance_specs(language="php")` is empty, so missing-capability is never
asked; and `output_encoding` shares the one flat sanitizer list, which holds
`esc_sql` and `intval` but not `esc_html` -- wrong in both directions, so no
XSS verdict from this engine currently means anything.

5.10's root cause is recorded too: php2cpg's parser bridge, a race rather than
a scale limit, and the mitigation is per-file exclusion. Adds 5.11 (the hook
edge, with CVE-2022-25148 as its observable) and 5.12 (taint a field where it
is assigned, with CVE-2023-6559 as its guard).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 18:36:00 +02:00
Marius CiepluchandClaude Opus 5 96e7476c6d feat: the first real CVE, found in production plugin code
CVE-2023-23488 is detected at
classes/class.memberorder.php:936:getMemberOrderByCode -- the exact line
verified in the source, in unmodified Paid Memberships Pro 2.9.7.

A two-file slice carrying both ends of the CVE, 2885 lines, loads and
queries in 122 seconds and reports 16 entailed injection findings.

**The other fifteen are not noise.** The file holds 26 `$wpdb` query calls
and **zero** of them use `->prepare`; all 26 interpolate or concatenate a
variable. The engine reports 16 of 26, so it under-reports rather than
over-reports, and every sampled finding is a genuine unprepared query --
`get_row("... WHERE id = '$id'")`, `get_var("... WHERE ID = '" .
$this->user_id . "'")`. CVE-2023-23488 is simply the instance that got an
identifier, because it is reachable unauthenticated; the rest are the same
defect with different reachability.

That answers the question that prompted this: whether the system can find
one real CVE. It can, in production code, at the exact line, in two
minutes, for a cent. What it could not do was survive its own plumbing at
637 files -- four separate quiet failures on this one checkout, all between
the scan and the arbiter, none of them in the analysis.

What this does NOT settle is recorded with it: the full checkout still does
not load and that boundary is unmeasured; whether php2cpg resolves
`new X()` and `$this->method()` is still unknown, since this slice reached
the sink through a same-function flow rather than across the object; and
one CVE on one plugin is not the class-by-class table over several plugins
that 5.8 asks for.

On cutting the input with a model, proposed when size looked like the
blocker: the evidence argues for scoping rather than triage. Two of 637
files found the CVE, task 2.10 already scopes by what a project declares it
ships, and diff-scoping is this spec's thesis. A model choosing what to cut
adds a fifth quiet-failure surface -- drop the file holding the bug and the
scan reports clean with nothing recording why, which is the shape of all
four failures found today. The reverse direction is where it fits: ranking
which regions get the budget, not choosing what to remove.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 17:24:37 +02:00
Marius CiepluchandClaude Opus 5 21fdb554aa fix(cpg): a query that could not be asked is not an empty answer
contributor-scan 5.7, and it was the wrong question.

**The 117k-line pmpro CPG does not load.** Importing it throws in a
ForkJoinParallelCpgPassWithAccumulator during overlay application, so every
query against it returns nothing. It is not a call-graph question at all.

Isolated by running the ARBITER against two prebuilt CPGs rather than
writing a sixth ad-hoc Scala probe, four of which had already failed on my
own mistakes:

  small wpdb CPG   get_order_by_code       -> 1 model_entailed
  pmpro CPG        getMemberOrderByCode    -> 0

Both have their source and sink in ONE function, both were asked with
parameter sources and depth 3, same arbiter and same spec. The CPG is the
only difference.

That clears the facts, which is worth stating: `$wpdb` sinks work, the
parameter-source rule works, and a PHP parameter reaching `$wpdb->get_var`
through a concat is entailed. Whether php2cpg resolves `new X()` and
`$this->method()` is still unmeasured -- it cannot be measured on a CPG
that will not load -- and is recorded as 5.10 along with finding the size
boundary.

**And the defect underneath it, which is the fourth quiet failure found on
this one checkout.** `query_batch` returned `{}` for a failure and `{}` for
a genuine empty answer, so a caller could not tell "could not ask" from
"nothing found". Every query on this scan failed at load and it reported
500 regions examined, no findings, and no degradation: a vulnerable plugin,
clean.

A failed batch now returns None, unparseable output counts as a failure
rather than an empty answer, and the driver records a `query_failed`
degradation naming the kind and the request count.

This is the failure mode the rung ladder structurally cannot catch. Every
other honesty mechanism here labels a VERDICT -- suspicion,
regions_unjudged, regions_unasked, stated family limits -- and when no
verdict is produced there is nothing to label.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 17:15:51 +02:00
Marius CiepluchandClaude Opus 5 db273dde53 docs(specs): WordPress flaw classes, the corpus insight, and an IR brief
Three directions recorded, each grounded in what this session measured.

**WordPress by flaw class, not by plugin** (tasks 5.8, 5.9). One plugin is
a checkout; a class is a claim. WordPress's recurring shapes are narrow and
each maps to a family that already exists -- unauthenticated wp_ajax_nopriv
reaching $wpdb, a missing capability check, unescaped output, arbitrary
file operations. Two of the four are blocked on gaps this session found:
access control cannot be asked at all because PHP has no obligation facts
(5.9), and unescaped output is blocked on the flat sanitizer list (5.6).
The observable is a class-by-class table across several plugins saying
found, missed, or out of scope -- a single found CVE is not the artifact
worth teaching from.

**Why a corpus of benchmarks is not enough on its own**, written into
overview.md as the finding it is rather than an opinion. Twelve engine
defects in one session against three real repositories, and the three gates
stayed byte-identical through every fix. A gate that does not move is a
corpus that could not see the bug. The reason is structural: a pair is a
labelled function with a declared family and a known answer, so it cannot
exercise which region to look at, what to spend, whether a capability is
reachable, whether two entry points share a sink, whether the question fits
in an argv slot, or whether a flow crosses a module. Three of the twelve
were quiet failures where the tool reported clean and every honesty counter
agreed -- which a benchmark score cannot express, because the score is
computed from the verdicts that exist. The corpus is the holdout that stops
per-repository tuning, and the wrong instrument for finding out whether the
tool works.

**ir-consolidation** (brief, unapproved) separates two questions the IR
suggestion contains, which have opposite answers. The SOURCE IR is already
carried rather than about to be added: semantic/ is 3218 lines, 16% of the
codebase, measured at 2.2% entailment against the CPG's 41.3% and still
running as a proposer -- and Vine and P-Code are binary IRs, so the work
there is subtraction. The MISSING abstraction is C arithmetic, and there
GhidraPAL's three-valued-logic abstract interpreter over Ghidra P-Code is
exactly the right shape: "can this index exceed this allocation" is a value
question, which is what a bit-level TVL domain answers and what every
libpng overflow needs. Vine is dormant since 2009 and is not the candidate.

The constraint is recorded as an ordering: question 1 lands before question
2 starts, or adopting an IR makes the growth problem worse. And the cost
that matters is not lines but the number of things that can silently return
nothing -- this session found three such in a single PHP scan, and a binary
pipeline adds a build, a lifter and a domain, each able to produce an empty
result that looks like a clean one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 17:04:04 +02:00
Marius CiepluchandClaude Opus 5 8579c10e7c docs: the WordPress checkout scans, and does not find its CVE
Reporting the result rather than the intention.

Paid Memberships Pro 2.9.7 with CVE-2023-23488 is pinned, verified in the
code, and scans cleanly: 637 files, 117k lines, 1577 regions, build 47s,
query 74s, 140s total, no degradations. **The CVE is not detected.**

Both ends are inside the budget -- the REST handler at position 290 as a
recognised entry point, the `$wpdb->get_var` sink at 472 -- and the taint
query runs for 23 seconds. So the gap is the middle: the flow crosses
`new MemberOrder( $code )` into a constructor and then
`$this->getMemberOrderByCode( $code )`, both dynamic in PHP. Whether
php2cpg resolves either into a callee edge is UNVERIFIED; two attempts to
query the call graph failed on script errors and were abandoned rather than
guessed at. Recorded as 5.7, with the answer required before anything is
concluded about the fact tables.

Three engine defects had to be fixed before the scan could produce even
this result, and each had previously reported the plugin clean for a
different reason:

  1577 regions, 0 scanned    joern-parse failed where php2cpg succeeded
  500 scanned, taint 0.05s   a 1.49MB batch over Linux's 128KB argv cap
  500 scanned, taint 21.7s   the entry point was not recognised as one

The middle one is the one to remember. Every honesty mechanism in this
codebase labels a verdict -- suspicion, regions_unjudged, regions_unasked,
stated family limits -- and there no verdict was produced to label, so a
vulnerable plugin was reported clean with every counter agreeing.

What is established: the WordPress engine path works end to end. What is
not: detection on a real plugin. One checkout that does not find its CVE is
not yet evidence either way about the facts, and 5.7 has to answer whether
the call graph can carry the flow at all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 16:43:10 +02:00
Marius CiepluchandClaude Opus 5 922ed84053 feat(repos): a WordPress plugin with a verified CVE, and the WordPress DB API
contributor-scan 5.1's detection half. Paid Memberships Pro 2.9.7 pinned at
1a900be3, carrying CVE-2023-23488 -- unauthenticated SQL injection through
the `code` parameter of the /pmpro/v1/order REST route.

**Verified in the code, not taken from the advisory.** At this commit
classes/class.memberorder.php:936 reads

  $id = $wpdb->get_var("SELECT id FROM $wpdb->pmpro_membership_orders
                        WHERE code = '" . $code . "' LIMIT 1");

and tag 2.9.8 replaces it with `$wpdb->prepare( "... code = %s ...", $code )`.
637 PHP files and 117k lines, so it is also the first PHP checkout large
enough to say anything about an envelope.

**WordPress never calls mysqli; it goes through $wpdb**, so the facts could
not have found this. Added qualified: `$wpdb->get_var`, `->get_results`,
`->get_row`, `->get_col`, `->query`, `->replace`. Qualified because a bare
`get_var` or `query` matches any method of that name anywhere -- the
generic-name trap that made `query.get` match every `.get(` in a repository
this morning. `->insert`, `->update` and `->delete` are deliberately absent:
they take arrays and WordPress prepares them itself.

**And the thing that nearly went wrong.** The obvious next step when
writing PHP facts is to add WordPress's sanitizers -- sanitize_text_field,
esc_html, esc_attr. Doing so would have made this CVE invisible, because
its handler calls `sanitize_text_field( $params['code'] )` and then
concatenates the result into a query. sanitize_text_field is a real
sanitizer that does nothing whatsoever about SQL injection.

`TaintSpec.sanitizers` is one flat list per language whose comment states
the assumption outright -- "a sanitizer that breaks one flow breaks it
whatever the sink's family" -- and PHP falsifies it. So only SQL-specific
cleansers are listed, which leaves XSS under-sanitized instead, and the
real fix is recorded as task 5.6: a discharge counts for the obligation it
discharges. That is task 2.6's finding arriving in the taint family, where
the dominance arbiter already reads kind from the facts and the taint spec
has no equivalent.

The end-to-end scan of this checkout is not yet reported: the first run
returned 1577 regions with 0 scanned, and the CPG builds standalone at
4.3MB with no errors, so the two do not yet agree. Numbers when they do.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 16:13:17 +02:00
Marius CiepluchandClaude Opus 5 47b00d739b feat(model): one defect is one finding, however many regions reach it
contributor-scan 2.9. 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.

Findings are deduplicated on (site, family), keeping the strongest rung
and counting the regions that reached it -- "reached from 26 regions" is
the useful half of that observation and one row is the readable half. Two
families at one site stay two findings, because `echo $_GET[...]` is an
injection question and an output-encoding question with different answers.

The duplication that motivated this was removed by another route: the 26
rows were all the `fgets`-matched-as-`gets` false positive, and the sink
matcher fix took them out. So this changes no current number and is a
structural guarantee rather than a measured reduction -- the mechanism that
produced them is untouched, and only the finding that made it visible is
gone.

+25 lines for a guarantee, in a session that has been asked to watch its
growth. The audit is unchanged at 97 modules, 90 load-bearing and 7
standalone, with no orphans.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 15:56:01 +02:00
Marius CiepluchandClaude Opus 5 60d2a1a92c feat(model): an endpoint its contract declares open carries no obligation
contributor-scan 2.8. VAmPI's last two false positives were register_user
and login_user, both declared open by its OpenAPI spec: the arbiter was
reporting a missing guard on endpoints whose contract says there is
nothing to miss.

**The fix keys on provenance, not on the value, and that distinction is
the whole task.** The mapper says "public" in two very different
situations. An OpenAPI operation with no `security` block is DECLARED
open, because the spec is a complete contract. A Flask handler with no
`@login_required` is "public" only because nothing was found -- which is
precisely the bug this family exists to report. Suppressing on the value
alone would have silenced the detector on its primary case, so
EntryPointRecord now carries `access_declared` and only a declaration
removes the obligation.

WordPress supplied the second source of declared access on the same day --
wp_ajax_nopriv_ for a logged-out handler, wp_ajax_ for a logged-in one,
permission_callback => __return_true for an open REST route. A rule resting
on one framework's convention would be a special case; two make it a shape.

  vampi entailed              6 -> 4
  access-control precision    2 of 4 -> 2 of 2
  known vulnerabilities       3 of 3 still entailed
  access_control offered to   6 of 14 handlers, exactly the six the spec
                              declares authenticated

**And the guard that decides whether it is a real improvement:** the
access-control pair corpus, which nobody tuned this against, is unmoved --
pair_correct 3 and leak 1 across all nine, pair_correct 2 and leak 0.0%
across the seven from projects unrelated to VAmPI, identical to task 2.6.
The precision was not bought by silencing the family. Pair excerpts carry
no route manifest and no hook, so nothing in them is declared open and the
rule correctly does not fire.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 15:39:58 +02:00
Marius CiepluchandClaude Opus 5 3e1d13e4b6 feat(reports): the model's own suspicion band is a proposal too
contributor-scan 2.17, completing Req 5.5.

Task 2.11 made static pattern matches proportionate and left the model
layer's own suspicions alone, reasoning that a proposer had reasoned about
them. That does not survive inspection. A model suspicion is what the
enumerator proposed at a site the graph could NOT decide, affirmed by a
judge with no coverage there -- and its own rationale says exactly that:
"the model resolved no flow here; its sink table may not cover this API".
It is a pattern match with an opinion attached, and it reads like a
reasoned claim only because a model produced it.

  vampi    60 findings, 61 sections -> 26, report 1085 lines -> 446
  libpng   198 findings, 39 sections -> 30

**What it costs, which is the number that decides whether the rule is
right.** For each known vulnerability the recipe declares, take every
finding row at its location and ask whether any survives the rule. All
three of vampi's -- the SQL injection and both documented BOLAs -- are
still found at model_entailed and all three survive. Nothing known is
hidden. Three vulnerabilities in one repository is also a statement about
how small the evidence base is, and the artifact says so.

The obligations checker sits at `suspicion` too and is NOT grouped: its
output comes from a structural analysis, an obligated operation with no
discharging guard, rather than from a guess. An earlier attempt at this
rule filtered on rung alone and would have collapsed it; six tests failed
and they were right to.

"Pattern matches" is now "Proposed, not corroborated", since it holds two
shapes. Model rows group by FAMILY rather than by rule, because their rule
is always `model`.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 15:23:57 +02:00
Marius CiepluchandClaude Opus 5 af7e33ef3e feat(reports): say what was looked for, and what cannot be decided
contributor-scan 2.16, implementing the approved Req 5.6. `c/memory`
scored zero true positives across 89k lines of C and three distinct
classes of false one, for a structural reason: it models string CALLS and
libpng overflows through arithmetic.

The task allowed retiring the family. Kept, scoped and stated instead --
`strcpy` misuse is real in plenty of C and the family earns its place for
that. Retiring it would have removed the disclosure along with the
coverage, leaving a C scan silent for a reason nobody could read.

**Its own description was the quiet-failure risk.** It claimed "bounds,
lifetime, arithmetic", which is exactly what it does not decide. It now
names the APIs it models, and a new `limits` field in families.toml says
what it cannot: an overflow expressed as an index, a pointer offset, or a
memcpy whose size is computed -- which is how most real C overflows are
written, and how every one of libpng's is.

A limit recorded in a fact table nobody reads is no better than silence, so
the report renders it. **What was analysed** lists every family offered to
a region whether or not it found anything, because a family that ran and
reported nothing is the case a reader is most likely to misread, and it
states plainly that silence means no fact table covered the code rather
than that the code is safe.

The counts are derived from the fact tables the arbiter actually used, so
they cannot drift from what the engine looked for; only the limit sentence
is authored, because "cannot decide an overflow expressed as an index" is a
statement about an abstraction and no table can produce it.

A C scan that finds nothing now says:

  | c | `memory` | 121 | 9 |
  **`memory` (c)** — Models CALLS, not arithmetic...

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 15:08:01 +02:00
Marius CiepluchandClaude Opus 5 f488e625c2 fix(specs): the php2cpg "blocker" was the shim, not the frontend
Task 5.5 recorded that php2cpg emits a CPG containing only one PHP file --
the last alphabetically -- and called it a blocker for the WordPress
target. Rerun with a native php-cli, as asked, and it is not true.

  php 8.3.6, no shim, same joern 4.0.623, same two files:
  files:   plugin.php, vuln.php
  methods: mp_fetch_report, mp_save_report, mp_render_box, mp_helper,
           get_user, safe_user, show, run

Both files, all eight methods, and no PHP-Parser banner warning at all --
the banner that looked like the thread to pull was a symptom of the same
thing and does not occur natively.

The cause was the workaround. This machine has no PHP interpreter, so the
shim ran each `php` invocation in its own container; php2cpg drives
PHP-Parser once per file, and that per-invocation isolation kept only one
file's JSON.

**The lesson is worth more than the fix.** Two independent reproductions
agreed, and both were measured through the same broken instrument. A
consistent result is not a correct one when every measurement shares an
apparatus -- the same trap as a corpus that cannot see the defect it is
scoring, which is this session's other recurring finding.

And with the instrument fixed, 5.4 is demonstrably done. A two-file PHP
tree yields 10 function-scoped regions and 7 entailed findings in 15
seconds: SQL injection in both WordPress handlers, XSS in the shortcode
renderer, and all three of the original probe's. safe_user is correctly
absent, its mysqli_real_escape_string dropping it to corroborated. The
wp_ajax_nopriv_ handler ranks 1.0 and the plain helper 0.3.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 14:45:36 +02:00
Marius CiepluchandClaude Opus 5 0ffa99b5e6 feat(model): one finding per sink site, and WordPress's registration model
contributor-scan 5.4. Both halves, and the second found a frontend blocker.

**One verdict per region was right for a pair and wrong for a file.** A
pair is one labelled function with one bug, so one answer is correct. A
region spanning a PHP file is not: the probe's SQL injection on line 5 and
command injection on line 13 produced ONE injection verdict, `system` won
it on flow length, and the SQL injection went unreported by construction
rather than by any failure of the analysis.

`taint.verdicts()` now returns one verdict per distinct sink site,
strongest first, and its first element is exactly what `verdict()`
returns -- so the pair path and every committed measurement resting on it
are unchanged, and the gates are byte-identical. Both entry points share
`_usable_flows` so neither can drift from the other.

  php probe   2 of 3 -> 3 of 3
  vampi       6 entailed, unchanged, now with lines on access-control
              findings from task 2.7 (users.py:187, books.py:51 -- the two
              documented BOLAs at their exact lines)
  libpng      1 -> 2 entailed; the second was hidden by the collapse.
              Neither is validated and both are in contrib/.

**WordPress registers handlers rather than decorating them, and the hook
name is the declaration.** `wp_ajax_nopriv_*` is WordPress saying
logged-out callers reach this, so it is public; `wp_ajax_*` fires only for
logged-in ones; `permission_callback => __return_true` declares a REST
route open. That is the declared-provenance signal task 2.8 needs,
arriving from a second framework. Plus every top-level PHP function as its
own region, without which a file is one region however many functions it
holds: 1 file-level region became 10 function-scoped ones.

**And a blocker, recorded as 5.5.** php2cpg emits a CPG containing only
ONE PHP file, the last alphabetically. Reproduced twice on independent
samples; both files parse cleanly under `php -l` and under PHP-Parser
directly, and joern-parse reports success either way. php2cpg logs "Found
warning in PHP-Parser JSON output" naming the survivor, and that warning is
PHP-Parser's own `====> File X:` banner appearing in what php2cpg expects
to be pure JSON.

Not isolated yet: this machine has no PHP interpreter, so php2cpg was
driven through a container shim. The banner comes from PHP-Parser rather
than the shim, which makes a shim-only cause unlikely but not excluded, and
rerunning with the native php-cli in the shipped image is the first step.
It blocks WordPress outright -- a plugin is many files, and a scan that
silently covers one of them and reports success is the quiet failure Req
5.6 was approved for.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 14:30:48 +02:00
Marius CiepluchandClaude Opus 5 61811512a8 feat(facts): PHP semantic facts, validated against a real PHP CPG
contributor-scan 5.1, the first step toward the v0.1 WordPress target.

Written against a PHP CPG rather than against PHP source, because the two
do not look alike. Verified with joern-parse 4.0.623 / php2cpg on a probe:

  $_GET["name"]               ->  call `<operator>.indexAccess`, CODE $_GET["name"]
  mysqli_query($conn, $sql)   ->  call NAMED mysqli_query
  echo $_GET["msg"]           ->  call NAMED echo

So sources match on code and sinks match on name, and every entry the
probe did not exercise is marked unverified in the file -- `include` and
`require` among them, which is PHP's most characteristic path bug and the
first thing to check on a real checkout.

Five families derive (injection, output_encoding, path, deserialization,
untrusted_destination) with no sink that is its own sanitizer -- asserted
by a test, because that bug class has now appeared three times in three
matchers. `php` is added to the region model's languages, without which
the facts would have reached nothing.

The arbiter decides 4 of 4 on the probe: the SQL concat entails, its
`mysqli_real_escape_string` twin drops to corroborated, `echo` XSS entails
and `system` command injection entails.

**A whole scan finds 2 of those 3, and the gap is the finding.** PHP files
yield FILE-LEVEL regions because no PHP entry points are mapped, and the
taint arbiter returns one verdict per region per family -- so a file-level
region reports at most one injection finding however many it holds.
`system` won on flow length and the SQL injection went unreported. That is
the mirror image of task 2.9: that one is many regions collapsing to one
site, this is many sites collapsing to one verdict, and it bites hardest
exactly where PHP needs it not to, since a WordPress file is large and has
no decorator for the mapper to find.

Recorded as task 5.4 with WordPress's registration model (add_action,
add_filter, admin-ajax, REST routes), which the mapper already handles in
principle for other frameworks.

A 13-line probe is not a WordPress result. What is established is that the
seam works end to end for PHP; everything about scale and coverage is
unmeasured, and there are still zero PHP pairs in a corpus of 263 python,
351 c_cpp, 47 javascript and 49 java.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 14:18:57 +02:00
Marius CiepluchandClaude Opus 5 bccc89be52 docs(specs): approve the group-2 amendments, generate finding-feedback-loop requirements
**Amendments approved and folded into the numbered requirements** rather
than left as a proposal appended to the file:

- Req 5.5 — a report proportionate to the evidence behind a finding. A
  rule whose only evidence is a text match is summarised past a stated
  number of sites; a finding a proposer reasoned about keeps its detail
  whatever its rung; nothing leaves findings.json, which is the record a
  baseline is compared against.
- Req 5.6 — a family that cannot decide a class states the limit, and no
  scan presents an absence of findings as evidence of absence.
- Req 4.6 — at least one pinned checkout carries a vulnerability a family
  can decide in principle, so a scan producing nothing is distinguishable
  from an engine that cannot see.

Each carries the evidence that produced it, and the amendment record keeps
what group 2 confirmed rather than changed: Req 8's overfitting warning
earned its place twice, Req 3.3's honesty counters made an invisible
failure visible, and Req 11.2's gates held through all nine defect fixes.

Req 5.5 is half implemented. Task 2.11 made pattern matches proportionate;
task 2.17 now carries the model layer's own suspicion band, which is 38 of
vampi's 60 findings and is an enumerated candidate the graph could NOT
decide with a judge's opinion attached -- a pattern match with an opinion,
not a reasoned claim, and weaker evidence than the obligations checker's
structural output.

**finding-feedback-loop requirements generated**, seven of them: a
dismissal file the developer owns and the tool never writes, rejecting one
without a reason because a suppression list without reasons is a graveyard
nobody can audit; a counter-example ledger whose entries are runnable
regression cases, grouped by shared cause so a recurring bug is visible as
one; a model that proposes a cause into a review queue and decides nothing,
validated by reverting the three known matcher fixes and measuring what it
recovers; dismissals plus a recipe's known vulnerabilities as one split
evaluation set with the holdout unread and asserted; evolution scoped to
the judge and residual questions with the fact tables off limits to any
automated means; an adoption that reports what it traded and reverts itself
when net-negative; and the loop inheriting the corpus blind spot, so its
evidence base is repositories and it states how few it has.

Phase: requirements, generated, not approved.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 13:40:45 +02:00
Marius CiepluchandClaude Opus 5 72932ed879 docs(specs): group 2 feedback as amendments, and lift out what was never specified
Following the Kiro workflow rather than continuing to write tasks.

**A workflow error, corrected.** Two task groups -- a counter-example
ledger, and per-repository evolutionary prompting so developers can commit
dismissals and evolve the harness -- were appended to contributor-scan's
tasks.md on 2026-09-09 without passing requirements or design. They are not
refinements of scan integration; they are a different feature, and writing
them as tasks skipped the two phases that decide whether a feature is
shaped right. They are lifted to .kiro/specs/finding-feedback-loop/, at
brief, unapproved.

That brief states the boundary the design turns on: an optimiser may evolve
the QUESTION (the judge and residual prompts, which are how we ask) and
never the FACTS (tables, rulesets, the declared policy file, which are what
we claim). The closed-loop leak measured on 2026-09-05 is why -- five of
eleven candidate shapes were taught by the holdout pairs they then
recovered, reporting a -0.059 holdout Youden as +0.059. It also states the
acceptance test for the model's role: revert the three matcher fixes and
see whether the grouping rediscovers the known causes.

**Three requirements amendments, drafted and unapproved**, at the end of
contributor-scan/requirements.md:

- Req 5 should state what a report may repeat. libpng emitted 198 findings
  and nothing in the spec forbade it. Task 2.11 fixed pattern noise; the
  model layer's own suspicion band is still 38 of vampi's 60, and it is the
  same epistemic class -- an enumerated candidate the graph could not
  decide and an LLM affirmed.
- A family that cannot decide a class must say so. c/memory scored zero
  true positives on 89k lines of C for a structural reason, and a silent
  scan reads as a clean bill of health. Worse than noise because it is
  quiet, and the one failure mode the rung ladder does not already guard.
- Req 4.1 is satisfied by a checkout that cannot demonstrate detection at
  all -- libpng's CVE is out of family and no version of it can be
  otherwise. It should require one that can.

And what group 2 confirmed rather than changed: Req 8's overfitting warning
stopped a rule being extended until one repository was clean and forced the
access-control work onto pairs nobody tuned against; Req 3.3's honesty
counters are what made a budget stopping free work visible at all; and Req
11.2's byte-identical gates held through all nine defect fixes, which says
from the other side that the corpus could not have caught any of them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 13:36:33 +02:00
Marius CiepluchandClaude Opus 5 00e178d69e feat(reports): a report proportionate to what the engine can say
contributor-scan 2.11. libpng emitted 198 sections, 163 of them asserting
that `memcpy` and `strcpy` are PRESENT in a library that uses them
correctly throughout. That is the noise developers have learned to skip in
every other tool, and shipping it costs the findings beside it their
credibility.

**Filtering on rung was the obvious rule and it was wrong.** The
obligations checker emits at `suspicion` too, so hiding that rung would
have hidden an entire detector -- six tests failed and they were right to.
The discriminator is evidence_level: on libpng 163 of 198 rows are
`static_corroboration`, on vampi 3 of 60 are, and the model layer and the
obligations checker carry `suspicion` there instead. That is exactly the
distinction the ladder exists to make -- an undecided CLAIM about a site
is not an observation that an API exists.

So a pattern-only rule is grouped once it has more than three sites, and
keeps its full sections under that. One `python-flask-debug` is a finding a
contributor can act on and must not be summarised away; fifty-four
`c-unsafe-copy` are a property of the codebase.

  libpng   198 findings, 198 sections -> 39, 161 grouped into three lines
  vampi    60 findings, 61 sections -> 61, nothing grouped

Counted, never dropped: findings.json stays complete because it is the
record a baseline is compared against, and the grouped section says the
model reached none of these and that its silence means no fact table
covered the site rather than that the site is safe.

What it does not fix, recorded rather than glossed: vampi still emits 61
sections for a 520-line application, 51 of them reasoned claims this rule
does not touch. It fixes pattern noise, not volume.

Also records what group 2 established about the three corpus asks, so none
is started on a wrong assumption. Joern ships php2cpg but our fact tables
have zero PHP families, so WordPress would parse and produce nothing --
fact tables first, corpus second. An older vulnerable libpng will not help
for a reason no version can fix. Vibe-code repositories are the best fit
and the cheapest route to a second true-positive checkout, because Python
and JavaScript are the two languages whose fact tables are complete.

And Group 8, for per-repository state a developer owns: a committed
dismissal file the tool reads and never writes, dismissals as a split
evaluation set, and GEPA/SIMBA-style evolution scoped to the QUESTION --
the judge and residual prompts -- never to the fact tables. That boundary
is the whole design: a prompt is how we ask, a fact table is what we
claim, and the closed-loop leak measured on 2026-09-05 is why the
distinction is not negotiable.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 13:29:26 +02:00
Marius CiepluchandClaude Opus 5 2589c3d47a docs(contributor-scan): what c/memory covers, and what SMT covers instead
libpng's library code never calls strcpy, strcat, sprintf or gets -- all
twelve files that do are under contrib/. It overflows through array and
pointer arithmetic and memcpy sizing, which is a different question from
the one this family asks: "can this expression exceed this allocation"
rather than "does tainted data reach this call".

That is a constraint problem. It is what SMT is for, and what this project
deliberately did not adopt -- the architecture decision was abstract
interpretation as arbiter, taken because a model layer can be built from
code where execution cannot be assumed. That decision was right for the
flow families and it does not reach this one. The maintainer has attacked
libpng exactly this way, using SMT to lay out memory and then guiding a
fuzzer to the inputs that reach it, which is evidence about the shape of
the problem rather than a plan to reimplement it: the honest routes to C
memory safety are constraint solving or execution, and the ladder already
has a rung for the second.

So 2.15 now separates the cheap route (pin a C project whose CVE runs
through a string function, giving the family its first true positive) from
the expensive one (model the arithmetic, a different engine), instead of
implying a sink-table fix would cover both.

And 2.16 asks the question this spec's predecessor would have asked:
c/memory has scored zero true positives and three separate classes of
false one -- a substring match, a bound it could not see, an allocation
sized to fit. Every one was a real engine defect and every one is fixed,
but the family has never been right. Keep it scoped and labelled, or
retire it; either way with a number behind it, and never leaving a silent
C scan to read as a clean bill of health.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 13:11:30 +02:00
Marius CiepluchandClaude Opus 5 c1191b3a6b feat(model): an access-control finding names a line, and what the PDG does not give us
contributor-scan 2.7. Taint and config verdicts have always carried a
`path:line:function` location; dominance never did, so every
access-control finding reached a contributor as
`api_views/users.py:?:update_password` -- the right file by accident,
because the region was asked about that file, and no line at all. The
query now reports the operation's file and the verdict carries the
location, with an engine that cannot report one getting no location rather
than an invented one.

Two questions answered by measurement while doing it.

**Does this factor in the PDG?** The data half, centrally: `reachableByFlows`
is the entire taint arbiter and `joern-parse` applies the `dataflowOss`
overlay. The control half, not at all -- and it is not available to us.
Measured: 0 of 13,184 calls in the libpng CPG and 0 of 1,152 in the vampi
CPG have a controlling control structure, so `controlledBy` returns nothing
in either. Everything guard-shaped therefore runs on CFG dominance or on
syntactic conditions, which is exactly why 2.12's first attempt failed:
libpng's bound reaches its sink through an `error` flag, and that is a
control-dependence fact and nothing else. Recorded as 2.14, including the
possibility that it cannot be enabled -- which would bound what guard
reasoning this engine can ever do.

**Would checking out a vulnerable libpng find a known CVE?** No, and not
because of the version. Every one of the twelve files using
`strcpy`/`strcat`/`sprintf`/`gets` is under `contrib/`; the library itself
uses none of them. libpng overflows through array and pointer arithmetic
and `memcpy` sizing, and five of its buffer-overflow fixes touch no
modelled sink at all. Pinning an older commit would produce the same
silence for the same reason.

That leaves two routes of very different size, recorded as 2.15: pin a C
project whose CVE genuinely runs through a string function, which is cheap
and would give the memory family its first true positive; or model how C
actually overflows, which is size arithmetic rather than sink matching and
should not be started by accident.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 13:09:47 +02:00
Marius CiepluchandClaude Opus 5 9221ba8e40 feat(model): a bound on the data lowers the rung, it does not clear it
contributor-scan 2.12. c/injection and c/memory carried zero sanitizers,
so no C flow could ever be discharged: every `argv -> strcpy` entailed
however carefully it was bounded.

Which sinks can be discharged this way is read from the sink fact's own
CWE rather than declared: CWE-121 is a stack buffer overflow, so
gets/sprintf/strcat/strcpy qualify. It is the only overflow CWE in the
fact tables and it is C-only, so no Python or JavaScript sink is touched
and the pair corpus is unaffected -- gates byte-identical.

Not a sanitizer list, because `strlen` appears in the guarded and the
unguarded case alike; the discharge is a guard, not a call.

**The first implementation required the bound to DOMINATE the sink, and it
found nothing.** libpng guards `strcpy(outname+len, ".png")` with
`(len = strlen(inname)) > 250`, but that check sits on an else-if chain
whose sibling branch skips it entirely, and the protection reaches the
sink through an `error` flag: `++error` in the guarded branch, `if
(!error)` around the copy. Joern's dominator set for that sink has 89
calls and correctly excludes the bound; `controlledBy` returns none
either. Verified against the CPG before the rule was changed rather than
after it failed.

So the bound is searched in the method, and the consequence is
corroborated rather than silence. That is the honest rung: a bound on this
variable means the graph has NOT shown the copy is unguarded, and it has
not shown it is guarded either -- off-by-one being how bounds fail. The
judge is asked. Proving the flag-mediated case needs path-sensitive
reasoning this arbiter does not do.

  libpng entailed   3 -> 1

The survivor is also a false positive: `strcpy(output + len, ".icc")` in
iccfrompng.c, where `output = malloc(len + 5)` makes it fit exactly. That
is a different discharge shape -- allocated to size, not bounds-checked --
and it is recorded as 2.13 rather than chased. Both shapes were found on
one repository, and extending a rule until that repository is clean is the
overfitting Req 8 exists to prevent; 2.13 requires a second C checkout
with a genuine CWE-121 so the change can be measured for what it silences
as well as what it clears.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 13:01:30 +02:00
Marius CiepluchandClaude Opus 5 6dc25d77e4 feat(model): let the project say what it ships, and rank that first
contributor-scan 2.10. Every one of libpng's twenty-five entry-point
regions was a `main()` -- twenty-three under contrib/, plus example.c and
pngtest.c -- and those regions carried the fifty interprocedural taint
requests that dominated a 300s query phase. A library has no entry points,
so entry-point-driven analysis had nothing to anchor to except its own
example programs.

The task said to derive this rather than hardcode `contrib/`, and a project
already declares it. Makefile.am names installed targets in
`lib_LTLIBRARIES` and `bin_PROGRAMS`, each target's `<target>_SOURCES`
names its files, and `check_PROGRAMS`/`noinst_PROGRAMS` are automake's own
word for "not installed" -- exactly the distinction wanted. Taking every
`_SOURCES` would have called libpng's whole test suite shipped code, since
contrib/libtests declares its sources the same way the library does.

  interprocedural regions   25 -> 1
  taint query               300.1s -> 125.0s
  total query               311.9s -> 138.5s
  regions                   121 -> 121, unchanged by design

The surviving interprocedural region is contrib/tools/pngfix.c:main, which
libpng genuinely installs.

Two rules keep it honest. **Silence is not exclusion**: a project with no
build file this can read gets None back and nothing changes, so vampi's
numbers are untouched. **Not shipped is not unscanned**: those regions are
still arbitrated function-locally, because a contributor editing a test
must still get an answer -- what they lose is the expensive treatment and
first claim on the budget.

Entailed findings went 0 -> 3, and all three are false positives at
contrib/gregbook/wpng.c:374, `strcpy(outname+len, ".png")`. It is safe:
outname is char[256] and line 325 guards with
`if ((len = strlen(inname)) > 250)`. They were not introduced so much as
uncovered -- a different callDepth meant a different sink set, and the 26
fgets false positives were masking them.

The structural cause is recorded as 2.12: c/injection and c/memory carry
ZERO sanitizers, so no C flow can ever be discharged and every
`argv -> strcpy` entails however carefully it is bounded. The discharge
here is not a call but a guard that dominates the sink, which is precisely
what dominance.sc already does for access control and is wired to that one
family.

Also adds Group 7, five tasks for stopping the same false positive twice.
Two of today's three false-positive classes were one bug in three
matchers -- `resolve` in `resolveUrl`, `user` in `users`, `gets` in
`fgets` -- each fixed locally and each next one surviving because nobody
carried the fix across. A model may group counter-examples by cause and
draft an edit; only a split-respecting measurement may adopt one, because
the predecessor's improve lever admitted shapes taught by the holdout pairs
they then recovered and turned a -0.059 Youden into +0.059.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 12:45:21 +02:00
Marius CiepluchandClaude Opus 5 67497c4d68 fix(cpg): a sink is matched as a word, not as a substring
`fgets` contains `gets`. The sink matcher's last clause was an unbounded
`methodFullName.contains(n)`, so every bounded read in libpng matched the
sink for the unbounded one. All 26 entailed findings on that repository
were the same false positive:

  *argv -> fgets(pnmline, 256, wpng_info.infile)   (line 335, 8 steps)

which is the safe API being reported as the dangerous one -- and `fgets`
is in the fact tables as a SOURCE, never a sink. Verified against the CPG:
methodFullName is exactly "fgets", and .contains("gets") is true.

Third instance of one bug class in this codebase, after the sanitizer that
matched `resolve` inside `resolveUrl` and the discharger that matched
`user` inside `users`. The sink matcher had never been given the same
treatment. All four of its clauses now run through the shared bounded
matcher, and that matcher is the punctuation-tolerant version written for
task 2.6 -- so `input(`, which an unconditional lookahead had made
unmatchable in `input(x)`, works for the first time as well.

  libpng entailed   26 -> 0

Zero is the right answer for a PNG library whose only known CVE is out of
family. Gates byte-identical, so no pair outcome depended on the substring
match.

What remains on that scan is recorded, not claimed: 113 `c-unsafe-*` rows
assert an API is present rather than misused, and 50 overlay-coverage rows
are annotations rather than findings. 82 of the 113 are under `contrib/`.
Task 2.11 covers whether that band is worth putting in front of a
contributor at all; 2.10 already covers not scanning example code.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 11:43:00 +02:00
Marius CiepluchandClaude Opus 5 68b4e551f3 feat(model): the envelope checkout, and no to Joern server mode
contributor-scan 2.2 for libpng and the 2.3 decision it exists to inform.

89,082 lines of C across 93 files, under a 2GB heap cap:

  CPG build      16.1s (25s cold)
  Joern queries  250.0s
  arbitration    122.0s (105 calls, $0.0122)
  whole scan     446s
  peak RSS       911MB
  regions        121, all arbitrated, 32 unasked
  CVE-2025-66293 out_of_scope, as the recipe declares

Memory is not the constraint a fleet runner would hit: 911MB leaves room
for the rest of the job on a 2-vCPU/7GB runner. The memory pressure seen
while building this was concurrent JVMs on one desktop, not repository
size.

**Server mode: no.** The query phase splits taint 243.3s / config 10.7s
over two invocations, and fixed overhead measured on the same CPG is ~13s
per invocation. So ~26s of the 250s is JVM startup and ~224s is CPGQL
evaluation: a warm server removes 6% of the scan and none of the part that
costs. The driver now records query seconds per kind, because a single
total cannot tell startup from evaluation and the two answer this
oppositely.

The small repository pointed the other way. vampi's 34.3s over three kinds
is roughly three startups and almost no evaluation, which made a warm JVM
look worth having; the ratio inverts with size. That inversion is why the
task was sequenced after a real repository rather than decided on a
520-line file.

On whether one whole-repository CPG is the only shape for a horizontal
fleet: no, and it is also not where the pressure is. The build
parallelises and costs 16s; the query does not shard and costs 250s, so
splitting a repository across runners divides the cheap phase -- and after
2.4 a shard boundary hides exactly the cross-module flows that matter.
Scoping to a contributor's diff cuts the expensive phase instead. 911MB
for 89k lines extrapolates to roughly 5GB at a million lines, which would
exceed a standard runner, so the ceiling is real and further out than this
checkout can locate.

Two defects recorded rather than fixed, both invisible at vampi scale:
2.9, all 26 entailed findings are the same site reported 26 times, because
regions that share a sink each emit their own; and 2.10, 22 of the 30
highest-ranked regions are libpng's `contrib/` example programs rather than
the library.

Also ignores Joern's `workspace/`, which a maintainer querying a CPG by
hand from the repository root will create.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 11:30:02 +02:00
Marius CiepluchandClaude Opus 5 d15985f695 fix(model): a guard discharges the obligation it is a guard of
contributor-scan 2.6. Access control entailed 7 of 9 VAmPI handlers,
including three that do authorize and three the OpenAPI spec declares
public. Five separate defects, each invisible to a corpus of one-function
excerpts and each found by asking the query what it actually matched.

**A trailing segment is not a match.** `query.get` was matched by its last
segment, so every `.get(` in the repository became a protected database
operation -- `request.headers.get('Authorization')` and
`request_data.get('username')` included. Four of the seven false positives
came from this, and so did the apparent detection of the known BOLA: it was
entailed because fetching the Authorization header had no dominating guard,
not because of the defect. A qualified token now requires its qualifier.

**Configuration is not authorization.** dominance_specs took every
obligation fact. `security_setting` operations are discharged by a VALUE,
which config_specs already claims them for; pooled in here they made
`app.run(...)` an operation requiring an authorization guard and put
thirteen tokens in BOTH lists -- an obligation that is its own discharge,
the same shape as the taint sink that used to sanitize itself.

**Input validation is not authorization.** `jsonschema.validate` was
counted as a guard, so handlers with no authorization check at all read as
guarded. No operation in the table requires validated_input.

**A guard discharges the obligation it is a guard of.** The largest one.
`orm-read` requires an identity_constraint or an ownership_check;
`token_validator` is a path_guard. Flattened into one list, authenticating
the caller discharged an object-level obligation -- a handler that
authenticates and then looks the record up by a PATH parameter read as
fully guarded. The facts state that relation and the spec was discarding
it; it is now carried and the engine asks per operation.

**A word boundary needs a word.** Found while fixing the above. Guard
matching wrapped every token in `(?<![A-Za-z0-9_])...(?![A-Za-z0-9_])`.
The identity token `['sub']` begins with `[`, and the character before it
in `resp['sub']` is `p` -- so the assertion could never hold. Every
identity token starting with punctuation was unmatchable, and the identity
constraint that separates a correct lookup from an IDOR was invisible.

Also: dominance rows were repository-wide, so the model's unguarded
`delete_user` was attributed to the API handler of the same name. Rows are
scoped to the region's file, which is what the rung's own justification
says -- the evidence is a file contradicting itself.

Measured against VAmPI's OpenAPI `security` blocks, which declare per
operation whether auth is required, and agree with the code:

  vampi precision   28.6% (2/7) -> 50.0% (2/4)
  known found       3 of 3, all model_entailed

And on the labelled access-control pairs, which nobody tuned this against:

  all 9 pairs       pair_correct 3 -> 3, leak 44.4% -> 11.1%
  7 non-VAmPI       pair_correct 2 -> 2, leak 42.9% -> 0.0%

Every detection lost was on a pair whose FIXED side was flagged too, which
scores nothing. No correct detection was traded for the precision.

get_by_title was flagged before it was in the recipe: its code carries the
comment "# Broken Object Level Authorization" and the README lists it, so a
real finding was being scored as a false positive. It is now the recipe's
third known vulnerability.

Both remaining false positives are endpoints the spec declares public.
Recorded as 2.8, with the reason the mapper's access_level cannot simply be
trusted: for decorator frameworks "public" means "no decorator found",
which is the bug itself.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 11:02:48 +02:00
Marius CiepluchandClaude Opus 5 0fcd5feade feat(model): a region for the code that is in no function
contributor-scan 2.5. VAmPI's `host='0.0.0.0'` is now reported exactly
once, at app.py:17:<module>. Before task 2.2 it was reported eight times
in eight files that do not contain it; after 2.2 it was reported nowhere.

Two causes, one shape: nothing could ask about module-level code.

**The main-guard marker is not a function.** `__main__` is what the mapper
emits for `if __name__ == '__main__':`. Sent to the engine as a function
name it matched nothing, because a CPG calls a file's top-level code
`<module>` -- verified against the VAmPI CPG rather than assumed. The
region model translates the marker, which is the one place that turns what
the mapper meant into what the scanner asks.

**A function-scoped file had no region for its module body.** Settings,
route registration and globals live in no function, and every region's
query filters by function name. A file-level region beside the handlers
would report each handler's findings a second time, which is why the
earlier rule dropped it as "a second full-file pass". A module-scope region
PARTITIONS the file instead of overlapping it. That only became cheap after
batching: before it, a region was a JVM.

Two things this needed to be correct rather than merely present:

- A module method spans its whole file, so the taint query's line-range
  containment made `<module>` the parent of every function in it. Under
  module scope, membership is direct -- otherwise the module region reports
  every handler's sinks again, which is the duplication the old rule was
  avoiding.
- A module body is not a handler. The marker-derived region inherited the
  handler families and entailed a missing authorization check on the main
  guard: a finding with no possible remediation. Caught by the first
  repository run that had the region, and now covered by a test.

  bind address   8 wrong files -> 0 -> 1, at app.py:17:<module>
  regions        22 -> 25
  entailed       8 -> 9
  query time     37.4s -> 41.3s
  peak RSS       827MB -> 823MB
  cost           $0.0067 -> $0.0068

Three more regions for four seconds and no more model calls: the budget is
a total and was already spent, so regions_unasked rose 17 -> 20 -- those
regions got the free pass and are reported as unasked, not as clean.

`<module>` is Joern's Python name for a module body; JavaScript uses
`:program`, so this is verified for Python only.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 09:43:53 +02:00