mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
18d0319436ade0495e8a3ea9b1a3e1ffc1e611c8
425
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
18d0319436 |
fix(cpg): a query that exceeds its own ceiling no longer cancels the scan
Two different timeouts reached the same handler and got the same consequence. If the SCAN deadline is gone, cancellation is right: stop everything promptly, which is what the push two-second gate measures. If a single query merely exceeded its own per-query ceiling, the scan may have most of its budget left and every other batch is independent and cheaper -- and cancelling took them all. Measured on Paid Memberships Pro: one batch of 116 requests hit a 300 s ceiling 734 s into a 2,700 s scan, and the four batches behind it returned in 0.0 s unanswered with thirty-three minutes to spare. The handler already distinguished the two cases in the reason it recorded, deadline_exhausted against process_timeout, and then set the cancel deadline in both. Fourth instance this week of one rule, now written in the design: a failure is owned by the narrowest scope that caused it. 1,420 tests pass, eighteen skipped, including the cancellation gate. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
6365c49849 |
feat(scan): size a taint batch by the sink work it implies, in the ranker order
A taint batch costs what its matched sink NODES cost, because reachableByFlows is asked per sink. Measured at a fixed 100 requests on one plugin graph: SQL injection 147 s with six declared sinks, path 194 s with sixteen, output encoding 704 s with seven. The declared count does not predict cost and the request count does not predict cost. So batches are budgeted in sink visits rather than requests, using the count the evidence pass already produced: a family whose sinks are rare rides in one batch, and a family whose sinks are everywhere is split until it fits. A request over the budget on its own gets its own batch rather than being dropped, because the scan job is to report that it is expensive, not to decide it is unaskable. Measured on Paid Memberships Pro at 500 regions, where every earlier run produced nothing at all: 380 arbitration requests carrying 5,807 sink visits split into 12 batches, every one answered, heaviest 103 s and none near the 300 s ceiling. 144 findings where there were none. The order is the ranker and must stay the ranker. A first attempt packed heaviest-first, which packs better and analyses worse: the expensive output-encoding batches ran first, spent the deadline, and left injection with 114 questions asked and none answered -- the one family holding a true positive on that subject. A deadline always cuts a tail, and the tail it should cut is the one the ranker put last. 1,420 tests pass, eighteen skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
9910dd18b9 |
measure(php): the query cost is the matched sink count, and the blocker is identified
Open since September and re-measured nine times. The cost of a taint batch is the number of CALL NODES a family sink names match in the repository. Not the request count, not the declared sink count, not any request parameter. reachableByFlows is asked per matched sink, and PHP soutput-encoding sinks are echo and print, which a WordPress plugin writes thousands of times. Three controlled sweeps on one graph, built once. Batch size exonerated: 400 requests answer in 170 s, and cost per request FALLS with size because roughly 70 s is fixed JVM start and graph load. Request parameters exonerated: at 200 requests the full production shape, call depth 3 with parameter sources, costs 221 s against a 150 s baseline. The family is the variable: at the same 100 requests, SQL injection 147 s with six declared sinks, path 194 s with sixteen, output encoding 704 s with seven. And the driver named its own expensive step once it was asked to: 2,435 evidence requests in 45.5 s, then 315 arbitration requests unanswered at 1,200 s, then a split abandoned for want of time. It survived since September because every reproduction outside the driver carried the SQL sinks -- the family the declared target belongs to, and the one family that is cheap on this subject. Nine hypotheses were eliminated by re-measuring the cheap case. Splitting never rescued it because a failed batch is split by REQUEST COUNT and each part carries the same everywhere-sinks. contributor-scan 7.7 carries the fix: budget a batch by the sink nodes it implies, using the count the evidence pass already computes, and report a family whose sinks are everywhere as expensive rather than as failed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
4a5bf0aaf9 |
test(cpg): log what each engine invocation costs and whether it answered
Nine hypotheses about the PHP query cost have now been eliminated by rebuilding the request outside the driver: batch size, call depth, parameter sources, hook table, argument length, sharding, dataflow cost, the malformed-CPG bug and the engine version. Every one of them answered in under four minutes while the driver spent whatever ceiling it was given. The reason the guessing continued is that a scan issues several invocations and the record could not say which of them burned the budget. It now logs the query kind, the request count, the elapsed time and whether it answered, for the whole batch and for each part of a split retry, including the part where a split is abandoned for want of time. This is task 16 applied to the backend rather than to a harness: an instrument that cannot name its own expensive step turns a measurement into a guess, and this one has been a guess since September. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
617fab157c |
measure(php): batch size is not the cost, and the curve is sublinear
Four census runs on this subject spent whatever query ceiling they were given: 363 s of 300, 1,255 s
of 1,200, 1,874 s of 1,800, at question counts of 2,526 and 406. Cost that tracks the budget rather
than the work is not per-question cost, and tier-zero pruning cutting the count six-fold changed
nothing. A census runs one batch, so no census could answer whether the batch itself scales.
One graph, built once, five batch sizes, every one answered:
25 requests 71.4 s 2.86 s each
50 requests 76.0 s 1.52 s each
100 requests 155.4 s 1.55 s each
200 requests 166.7 s 0.83 s each
400 requests 169.7 s 0.42 s each
Sublinear, and roughly 70 s of it is fixed JVM start and graph load. Four hundred dataflow requests
answer in 170 seconds on the graph production cannot finish 406 on.
So the difference is in the request PARAMETERS. The probe sent callDepth=0 and no parameter sources;
production sends callDepth=3 with parameter sources on every entry point, plus a hook table, a
dispatch list and context evidence. Task 13.2 recorded in September that every reproduction outside
the push path answered in under a minute and the difference was not known. Size is now removed as a
candidate and a four-variant probe is running to isolate the rest.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
|
||
|
|
3b6e642433 |
fix(scan): prune a tier-zero question unless ITS partition is the incomplete one
The mechanism already existed. A question whose evidence vector says no sink of its family is in reach cannot produce a flow, and tier-zero pruning defers it. That was switched off for the whole scan whenever any of twelve degradation reasons appeared, and a multi-language repository always carries two of them, so the measured plugin arbitrated every question it could ask. Third instance this week of one blast radius, and the invariant written into the design on Monday named it. A prune blocker that names a partition now withholds pruning from that partition questions and from nobody else. cross_partition_semantics_unresolved leaves the blocker set entirely, for the reason it left the integrity gaps: a PHP family sink is PHP, so a second language cannot be why its vector saw none. Measured on Paid Memberships Pro. At 25 regions arbitration falls from 127 questions to 34 with 93 recorded deferred as tier_zero, and 19 findings before and after, identical family by family. At 500 it falls from 2,526 to 406 with 2,120 deferred. It is not sufficient, and that is the useful half. The 500-region run still fails in the same way at the same cost, because 406 questions at roughly one and a half seconds each still exceed the 300-second per-batch ceiling. This lever moved the volume; the ceiling is a separate limit. The census now records what a scan skipped and why, per family, so a cheaper scan and a narrower one stop looking identical: a family whose every question was pruned reports no sink of this family in reach rather than region offered, no question asked. 1,420 tests pass, eighteen skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
b0500ecfa0 |
measure(contributor-scan): first ontology coverage run, and it found a family we do not have
NodeGoat models 18 of 175 distinct call names, covering 12 percent of 1,185 calls. That share is not a score: most of what a program calls is not security-relevant, and a high number would mean the tables were too loose. It is a baseline to move deliberately. The top product candidate is res.render, 23 calls with five in a method that reads untrusted input. Task 14.3 reasoned its way to that same gap by hand and declined to model it as a flow sink; the instrument found it in one run, which is the validation that matters. It also surfaced Math.random in the user data-access module, and weak randomness is a weakness class this ontology has no family for at all -- so no family census could have found it, because the family list is itself the blind spot. That is the kind of gap only a coverage instrument finds. It had to be taught what is not an API. The first run ranked _tmp_3.push and __ecma.Array.factory first, which is how the frontend lowers a destructuring and an array literal, and a Cypress suite and a Gruntfile held four of the next five. Both are separated now, because noise at the top of a worklist is how a worklist stops being read. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
0beae46ba9 |
feat(contributor-scan): ask a repository what it calls that the ontology does not model
Five of seven families established nothing in the family census, and every one failed for an ontology reason with the flow engine ready and nothing to aim at. Each was found by investigating one family at a time over days. vocabulary.sc asks it once: census the calls, subtract everything any fact table names, rank the remainder by how often it is called and whether it sits in a method that reads untrusted input. It needs no dataflow, because the structural pass already walks every call. The harness reads its token set from the SPECS rather than from the TOML, so it measures what the analyser actually consults: a fact the loader drops is not part of the ontology however neatly it is written. Ontology sizes today are 152 tokens for JavaScript, 167 for Python, 84 for PHP. Both carry the constraint the maintainer set: the output is a candidate list, never an ontology. A name becomes a fact only when someone resolves it to a library API, states the weakness class and records provenance, because adding a name because a subject called it is how a general-purpose tool becomes a fixture of its own benchmark. Also records what the bounded PHP transfer attempt found. At 25 regions the first case now runs to completion and writes a full artifact in 1,706 s, where at 100 it had exhausted a 2,400 s deadline with nothing. But 25 of 4,461 regions is 0.6 percent of the population and the declared target was not among them, so the question was never asked. Task 13.3 therefore depends on 7.3, skipping arbitration where the evidence says there is nothing to find: raising the budget is already measured to fail, and making most of the budget unnecessary has not been tried. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
032efc926c |
spec(pre-push-safety-net): a harness must report the cause of its own failure
Named by a week in which three separate causes looked identical as one count of unresolved. The census reported 19 findings and 0 completed questions side by side without complaint, a stale image measured the engine a bug had been diagnosed against, and a partition fix looked applied while an unscoped copy of the same gap kept demoting everything. Every harness record should carry the counts that disambiguate its failure modes, the degradations whole rather than by reason, and the engine and ruleset identities actually used; and each harness should assert its own consistency, because findings above zero with completion at zero is a contradiction an instrument can notice faster than a person. This was missed from the previous commit by an escaping error in the edit, not by intent. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
e245040855 |
spec: name the fact tables an ontology, and commit to a general-purpose hook
The source, sink, sanitizer, operation, discharger and layout rows are not a pattern list. They are an ontology of program elements and their security relations, and nearly all of this tool accuracy lives there: the family census found five of seven families establishing nothing, every one for an ontology reason with the flow engine ready and nothing to aim at. The hook is general-purpose, not application-specific. The ontology describes libraries, frameworks and language constructs; it does not describe repositories. An entry names an API and is justified by what that API does, carries provenance and a weakness class, and may not encode a path or identifier peculiar to one project -- a plugin bundled library tree was measured and declined on exactly that ground. A measured gap is a candidate, never an entry: accretion by subject is how a general tool becomes a fixture of its benchmark. contributor-scan owns the ontology, so its design carries that statement and a new group 7 makes growth measurable rather than anecdotal: report what a repository calls that nothing models, key sanitizers to the weakness they prevent, skip arbitration where the evidence says there is nothing to find, share one matcher across the queries, require a degradation to declare its scope, and commit golden findings so a silent change cannot pass. pre-push-safety-net carries the general-purpose test for its own reviews, and a task for harnesses that report the cause of their own failure -- named by a week in which three separate causes looked identical as one count. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
3a18d025f4 |
spec(m4): the independent population must hold a multi-language repository
Task 13.4 found that a second language in a repository cost every question its completion, and so cost every finding its admission, on the declared v0.1 target: a WordPress plugin is PHP plus its admin JavaScript. Neither subject that passes today could reveal it, because both hold exactly one language. A population of single-language repositories would qualify a capability that fails on the product own target. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
a487d1dfb5 |
fix(push): a gap from another language partition no longer blocks admission
_blocking_degradations already exempted declared exclusions. It now also ignores a gap another language partition raised, because a JavaScript file missing from the JavaScript graph cannot make a PHP answer wrong. Treating it as though it could left a WordPress plugin -- PHP plus its admin JavaScript, which is every plugin -- unable to admit a finding it had already established. A gap that names no partition still reaches everything, and so does every gap when the claim own language is unknown, because unknown scope is not evidence of independence. The control asserts all four directions. With this and the scan fixes, task 13.4 is verified on the subject that motivated it: PMPro completes 120 of 127 questions where it completed none, its 19 findings unchanged, and the seven still unresolved are the JavaScript partition own questions, which is the correct answer. 1,420 tests pass, eighteen skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
d105d2f84a |
fix(scan): carry the partition in both census passes, and PHP completes 120 of 127
The scoping fix looked applied and was not. The same census gap is raised in TWO passes, arbitration and evidence, and only the first carried the partition it came from, so an unscoped copy kept demoting every question in the repository. One key in one dict. What found it was the record, not the code: the census now prints each outcome reason rather than only its status, and three separate causes had been looking identical as a bare count of unresolved. Measured on Paid Memberships Pro at 25 regions: questions completed goes from 0 to 120 of 127, with its 19 findings unchanged. The seven still unresolved are the JavaScript partition own questions, which is correct, because the four missing files are its files and its answers are the ones that could be wrong. Still open, and recorded rather than implied: the push policy _blocking_degradations exempts declared exclusions but does not yet respect a partition, and a post-hoc diagnostic copy carries no language, so a JavaScript gap would still block admission of a PHP finding. 1,419 tests pass, eighteen skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
29cef32bac |
fix(scan): scope a partition census gap to its own partition, and write the rule down
A missing JavaScript file cannot make a PHP answer wrong. A census gap now carries the partition it was raised for, and reaches only that partition questions. On the measured plugin four JavaScript files -- a webpack build output, a minified select2, a build config and one source file -- had taken the admissibility of 4,425 PHP regions with them. Two controls exercise the real boundaries rather than injecting a reason, and both were confirmed to FAIL with the old classification restored, so they guard what they claim to. And the rule is now in the design, because this was the fifth instance found and fixed one at a time. Two invariants, eleven instances between them: A degradation is owned by the narrowest scope that caused it. Vendor exclusion as a global veto, line ambiguity inherited by every question, an empty scope blocking a sibling comparison, a language boundary demoting every answer, a census gap demoting every answer. The fourth and fifth cost the declared v0.1 target its entire admissibility, and neither passing subject could reveal it because both hold one language. A token matches the thing it names, not text that contains it. gets inside fgets, resolve inside resolveUrl, user inside users, a sink word inside a comment, a dotted setting on its last segment, a flow query reading the receiver. Each carries a review test: a new degradation must state the scope it reaches and justify demoting that far, and a text match must be bounded AND confined to the part of the node that names the callee. 1,419 tests pass, eighteen skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
c93669c34f |
fix(scan): a language boundary is a declared limit, not a graph-integrity failure
Membership of GRAPH_INTEGRITY_GAPS should turn on whether a gap can make a reported answer WRONG, not on whether it cost coverage. An unparsed file may hold the sanitizer that would have cleared a flow this scan reports, so it belongs there. A flow this tool cannot follow from one language into another can hide a finding and never invent one, because a PHP sink is sanitised in PHP. cross_partition_semantics_unresolved therefore moves to DECLARED_EXCLUSION_GAPS. It touches exactly two places, both of which already honour the distinction: the outcome demotion in scan.py, and _blocking_degradations in the push policy, whose docstring already says a declared exclusion is not one of them. The reason stays published in the scope unresolved boundaries either way, so the limit is still reported; the class decides only whether every answer in the repository is demoted. This corrects the task note written earlier today, which said not to reclassify on the grounds that it would claim cross-language flows are out of scope rather than unresolved. That was a misreading of the class. PMPro still completes none of its questions, and the measurement now names why: the JavaScript partition expected 36 files and holds 32, so partition_file_census_incomplete fires and is still an integrity gap. The four absent files are js/blocks/blocks.build.js, js/jsapi.js, js/select2.min.js and webpack.config.js -- a build output, a source file, a minified third-party library and a build config. A bundled select2 costs 4,425 PHP regions their admissibility. Same over-broad blast radius, third instance of one principle in this thread, and the fix direction is recorded. The census harness now records each degradation whole rather than only its reason, because a partition census that reports files missing carries WHICH files and without them the gap is a label rather than a lead. 1,417 tests pass, eighteen skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
73e9c1fed6 |
measure(m3): two languages in a repository defeat completion, and so defeat admission
No PHP question is ever marked completed, and PHP was never the variable. partitions.py raises cross_partition_semantics_unresolved whenever more than one language partition exists, that reason is a member of GRAPH_INTEGRITY_GAPS, and a graph-integrity gap demotes every completed outcome in the scan. Admission requires a completed outcome. The evidence is one column wide. NodeGoat is JavaScript alone and completes 299 of 299. VAmPI is Python alone and completes 35 of 35. PMPro is PHP with 36 regions of bundled JavaScript and completes 0 of 127 and 0 of 520 while establishing 19 and 43 findings. NodeGoat own degradation, vendor_semantics_unresolved, is a DECLARED exclusion rather than an integrity gap, which is exactly why it does not demote. This lands on the declared v0.1 target. A WordPress plugin is PHP plus its admin JavaScript, so every one of them is multi-language, so on the target this tool was built for it can detect defects and never admit one. Neither subject that currently passes could reveal it, which is why a census across a third subject was worth running. Recording the boundary is right: a flow from PHP into JavaScript is semantics the partitioned graph cannot follow. Demoting every question is not, including one wholly inside a single PHP file. Task 12.3 already fixed this bug class one level down by making a gap question-owned so it could not block another question comparison; the same principle applies to a partition boundary. Reclassifying the reason as a declared exclusion is explicitly the wrong fix, because that would claim cross-language flows are out of scope when they are unresolved. Task 13.4 rewritten with the diagnosis, and the milestone amendment now ranks it with M2 rather than behind it: a narrow JavaScript-only release can proceed without it, the WordPress target cannot proceed at all. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
6bd6e3531e |
test(m3): let the transfer replay take a region budget
The replay hardcoded 500 regions, which on a 673-file PHP plugin asks 2,526 questions and fits no ceiling this project sets. The budget is now an argument, defaulting to 500 so every earlier measurement keeps its shape. It bounds the RUN, never the repository: the artifact census still reports what was left unexamined, and a bounded population establishes transfer rather than coverage. Task 13.3. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
5b7288aa58 |
spec(pre-push-safety-net): amend the sequence and declare the shippable scope
Four decisions, recorded after the family detection census and the PHP correction. They change sequence and declared scope; they change no acceptance threshold and no owner boundary. Close M3 at a scale whose questions complete, before anything else. The not-established PHP record came from two instrument defects rather than from the analyser, and bounded to 25 and 100 regions PMPro completes with no timeout and establishes a true-positive SQL injection. Tasks 13.3 and 13.4 carry the replay and the question-completion gap, the second because admission reads outcomes and M4 measures completion at 95 percent. Reserve the untouched M4 population now, in parallel. M4 already permits preparation before the frozen candidate exists and it is the longest lead-time item on the path. Task 15.1. M2 is the only engineering item left and cannot be scoped away: a changed-code transaction spends its whole budget in the build, and meeting the latency gate by analysing less is explicitly not the same result. Declare the v1 advisory scope as the families whose claims can be ADMITTED, which requires the entailed rung: injection, untrusted destination and configuration. Access control is excluded because on an application that splits route from data access the obligation is carried and a carried obligation corroborates by design, so the family would ship as silence. Task 15.2. The risk the amendment does not remove is stated with it: every number so far comes from subjects used to build the analyser, one week of examining two of them surfaced seven false positives, and the first independent evaluation should be expected to fail. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
1f51c82b51 |
fix(census): stop hiding findings behind a question-outcome count, and correct the PHP record
PHP is not silent. Paid Memberships Pro establishes findings, including a true positive, and this project's own instrument is why it looked otherwise. The census harness ordered its verdict on completion before findings, so a family with eighteen findings and no completed question reported "asked but no question completed". Findings are now checked first: a question-outcome count is bookkeeping and cannot hide the thing being censused. NodeGoat and VAmPI were never affected, because they complete every question and their verdicts reached the findings check. The scan summary now also carries the total findings and the outcome status counts, so the two numbers can be seen disagreeing instead of one silently winning. The second mistake was mine rather than the harness's: only the 500-region runs had been looked at, which are the ones that time out. What PHP actually detects on PMPro. At 25 regions, 19 findings in seven minutes with no timeout; at 100 regions, 43 findings in twelve minutes with no timeout. Among them adminpages/ discountcodes.php:749, where sanitize_text_field($_REQUEST["s"]) reaches $wpdb->get_results($sqlQuery, OBJECT) through `$sqlQuery .= "WHERE code LIKE '%$s%' "` with no prepare. That is the class of CVE-2023-23488, which php.toml's own comment names as the reason those WordPress sanitizers are kept out of the sanitizer list. The prediction and the measurement agree. The precision cost beside it is measured too: output encoding reports eighteen findings at 25 regions and the three sampled are all properly escaped, two with esc_attr and one coerced with intval. Those escapers are absent from a sanitizer list that is flat across families, because none of them stops SQL injection. Keeping the injection finding costs this family its precision. The scale limit is roughly one to two seconds per question. 25 and 100 regions complete cleanly; 500 regions asks 2,526 questions, needs more than the 1,800-second ceiling and is killed. Four things are measured NOT to be the problem: the malformed-CPG bug (fixed in 4.0.625, verified on the real trigger file), the dataflow overlay (42 s on the full 637-file graph), the engine version (4.0.625 and 4.0.631 indistinguishable), and the bundled Stripe, Braintree and Twocheckout SDKs under includes/lib that the vendored list does not cover -- that was my hypothesis for the cost and the overlay measures 42 s with them and 37 s without, so it is refuted. What remains is bookkeeping, not detection: questions_completed is zero in every PHP run including the ones that find things, with cross_partition_semantics_unresolved and partition_file_census_incomplete present throughout. The admission policy reads those outcomes. 1,417 tests pass, eighteen skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
99abe12062 |
measure(php): quantify what remains once the malformed-CPG bug is excluded
The graph bug is fixed and was never what these runs were hitting. joernio/joern#6281, this project's own report, is a PHP `global` inside a closure giving a node two AST parents, which made the dataflow overlay throw and cost the whole graph. Filed against 4.0.623, fixed by #6270 in v4.0.625, which the image has pinned since before any measurement recorded under task 13.2. Verified today on the real trigger rather than a transcription of the repro, which does not reproduce: PMPro's includes/fields.php builds and applies the default overlays cleanly on 4.0.625 and on 4.0.631, files=2 methods=158, no ReachingDefPass assertion. What remains is runtime. A taint batch of 2,526 questions over the 673-file checkout does not complete in thirty minutes. Raising the query ceiling six-fold, from 300 to 1,800 seconds, changed nothing but how long the failure took: query time went from 363 s to 1,874 s, and zero questions completed in both. The eight-way split retry did not rescue it either, finishing its parts in about three minutes against a deadline with 3,300 seconds still on it, which is worth its own look. The engine pin stays at 4.0.625. An image on 4.0.631, today's release, was built and measured and is indistinguishable on this subject: 627.7 s against 619.1 s, 363.4 s against 367.3 s of query time, zero completed in both. There is no demonstrated benefit to weigh against re-verifying every JavaScript and Python measurement on a new engine. Should that change, the bump is three values rather than a port: the upstream AstGenRunner.scala is byte-identical in 4.0.631 and its patch anchor still appears exactly once, so only the jar path, the jar checksum and the source URL tag move. Both are recorded. Also closes task 14.5. Neither prototype pollution nor deserialization has a subject that can answer, each for its own reason. Deserialization's mechanism exists on exactly one available subject and that subject cannot be queried at all. Prototype pollution has no available subject containing the mechanism, so its ability to answer is established on a control instead. 1,417 tests pass, eighteen skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
2c5fba55ef |
fix(semantic-facts): reach the deserialization form WordPress actually writes
Across four WordPress plugins and 1,164 PHP files there is exactly one deserialization site, and
it is spelled maybe_unserialize. The bare token never reached it, and correctly so: the sink
matcher is word-bounded and the character before unserialize inside maybe_unserialize is a word
character. A token a repository never spells is a family that can never answer, which is what the
2026-09-19 census recorded for this one. maybe_unserialize is a real entry point rather than a
safe wrapper: it calls unserialize whenever the string looks serialized.
Also adds the prototype control. That family had never established anything on any subject, and
its only census report was the word `set` inside a comment. No repository available to this
project contains the mechanism: the only first-party matches on the Node subject are
app.set("view engine", "html") and its sibling, Express settings whose arguments are literals. So
the control demonstrates what the family can do, on a merge of the request body into an object,
and asserts the Express setting is not reported alongside it.
Corrects the PHP record under task 13.2. joernio/joern#6281, this project's own report, is a PHP
`global` inside a closure producing a node with two AST parents, which made the dataflow overlay
throw and cost the whole graph. It was filed against 4.0.623 and fixed by #6270 in v4.0.625,
which the image has pinned since before any measurement recorded here. Verified today on the real
trigger rather than on a transcription of the repro: PMPro's includes/fields.php, the file the
original bisection found, builds and applies the default overlays cleanly on 4.0.625 and on
4.0.631 alike. What remains under that task is a runtime failure and must not be re-diagnosed as
the graph bug.
1,417 tests pass, eighteen skipped.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
|
||
|
|
3bd844055c |
fix(config): report every permissive setting in a file, not the first one
NodeGoat's server.js configures a permissive session at line 78 and disables template
auto-escaping at line 135, both inside the same function. The arbiter returned one verdict per
question, so the escaping defect that task 14.3 added was detected on every run and reported on
none: which of two real defects a contributor saw depended on where in the file it sat.
Configuration now returns one verdict per setting whose value the model could read. The pipeline
already emitted one finding per entailed answer, so this arbiter was the only place still folding
them to one.
Three boundaries are drawn rather than assumed.
- A setting whose value is COMPUTED stays one verdict per region. That band is a question for the
judge rather than a finding, and returning every undecided setting would multiply model calls
for claims the graph did not establish.
- A call carrying two permissive literals is one finding. cors({origin: true, credentials: true})
is one misconfiguration, not two.
- Config verdicts now carry a path:line:function location. Without one they shared the region's
identity and the deduplicator kept a single finding, so the plural answer collapsed straight
back to one. That is the same gap dominance carried until its findings stopped arriving with no
line at all.
Dominance is left answering once per region, and that is settled rather than pending: its claim is
that a region contradicts itself, so the region is the unit and a second verdict would restate the
same inconsistency. The pipeline now says so where it used to call this a known gap.
Measured: NodeGoat's config_secrets goes from one finding to two, server.js:78 for the session and
server.js:135 for the disabled escaping, with the applied marked.setOptions fix between them
staying silent. VAmPI is unchanged and no other family moved.
1,417 tests pass, seventeen skipped; thirteen Joern-gated query tests pass in the image.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
|
||
|
|
e6bdac46e2 |
fix(access-control): decide the sibling scope, and see identity a destructuring spelled away
Two of NodeGoat's three corroborated claims were correct code. `const { userId } = req.session`
lowers to a temporary and then a field read, so nothing in the graph spells session.userId by the
time the value reaches dao.getByUserId(userId, ...), and the constraint the handler really applies
was invisible.
The arbiter now walks back three hops over the method's own assignments from the names the
operation is GIVEN, and req.session joins the identity sources. The token is only safe because of
that requirement: what discharges an object-level obligation is that the value passed to the
operation comes from the authenticated context, not that a session is mentioned nearby. A handler
that reads the session for logging and keys its query on a path parameter is the bug this family
exists for, and it stays unguarded.
Two exclusions the measurement forced, each caught by a page whose whole purpose is the leak it
demonstrates.
- A callback is not a constraint. Taking identifiers from the whole argument subtree credited
NodeGoat's memo listing with a discharge, because memosDAO.getAllMemos((err, docs) => ... userId
...) reads every memo in the collection and only RENDERS the session's user. Method references
are excluded from the argument names.
- An assignment is not a dominating guard. The session read dominates the query, so that clause
credited it instead and the memo listing went quiet again. An assignment is a read of the
authenticated context, not a check applied to it, so operator calls are excluded there -- the
same exclusion the sink matcher needed. The leak stays reported.
On NodeGoat displayContributions and displayProfile go silent, the allocations insecure direct
object reference, both benefits pages and the memo listing stay, and the login and signup handlers
become honestly unguarded rather than accidentally cleared by a callback's text. Six corroborated
where there were seven, and a better six. VAmPI is unchanged at two entailed findings.
The sibling scope is decided by measurement and kept at the file. On NodeGoat the region's own
file entails two handlers, both public routes and both false, and no true defect at all. Every
handler in the application entails six: four true, and the same two public routes. Both scopes
carry the same false positives, because neither can tell a public route from a protected one --
a semantic question that is already the corroborated band's residual. With the carried rung capped
the scope changes no verdict, only the witness, so widening it would buy a repository-wide walk
per question and no precision. A public-route discriminator would change that; the file scope is
not what holds this family back.
1,415 tests pass, sixteen skipped; thirteen Joern-gated query tests pass in the image.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
|
||
|
|
c0427e6ceb |
feat(access-control): model the collection read, by argument shape rather than by name
NodeGoat's documented insecure direct object reference reads with allocationsCol.find(searchCriteria()), and `find` was absent from an operation table holding findOne, findById, findAll and findByPk. The obligation was missing at the commonest read there is, so that defect raised nothing at any point in the pipeline. It was absent for a good reason, and the reason is the argument, not the name. Measured on wp-statistics, a WordPress plugin that ships jQuery: its assets hold 88 calls named find, of which 70 pass a jQuery selector, 13 pass a predicate to Array.prototype.find and 5 pass a selector built at run time by a template literal or a concatenation. None is a collection read. So the obligation is claimed only where the first argument has the shape of a query document. The operation fact states which argument with query_document_arg, and the arbiter that can see argument shapes decides it: no arguments at all, an object literal, or a builder call are claimed; a method reference, a literal, an operator call and a bare identifier are not. Each exclusion is one of the measured classes above rather than a guess. Zero of those 88 calls became an obligated operation, and the zero is measured rather than empty: the same run's census reports 60 files and 4,552 methods, and the files holding those calls are in it. On NodeGoat all three find calls are collection reads and all three are claimed, which raises the obligation at four more handlers -- the allocations handler that takes userId from req.params on an authenticated route, the benefits pages whose isAdmin guard is commented out in the route table, and the memo listing. Seven of fifteen handlers now reach a verdict, all corroborated, none reported without a judge, and the census is unchanged on both subjects. A bare identifier is deliberately not claimed: col.find(query) and arr.find(pred) are the same shape and neither repository measured holds one, so this is a stated false negative rather than an unmeasured false-positive risk. 1,415 tests pass, sixteen skipped; twelve Joern-gated query tests pass in the image. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
102a21fc4e |
feat(access-control): carry the obligation across the route-and-DAO split
The census measured this family completing 35 questions on NodeGoat with no obligation raised anywhere: every modelled data operation lives in app/data/*-dao.js, none in a route handler file, and operations were scoped to the region's own file. The family had nothing to be right or wrong about. The obligation now follows the one named call the handler writes, out of the region's file and no further. How it is followed is the point. jssrc2cpg does not resolve allocationsDAO.getByUserIdAndThreshold(...) to the DAO body: the callee is an external stub with no code. That stub's fullName records where the member was written, so the implementation is reached by the graph's own provenance rather than by a repository-wide name guess, which is the false positive the file scope was closed against. A control asserts it: a third module defining a member of the same name that nothing imports is not attributed. A row is attributed to the region's DECLARED function, which is what lets a question about displayAllocations match a graph that holds a <lambda>. A carried row is also checked for a guard at the CALL, because that is where a handler establishes who is asking. The method holding a carried operation joins the question's context scope, so the module holding the query cannot change under a claim that depends on it without the change being attributed. The witness names both sites and the finding is located at the call, which is the line a contributor changed. A carried obligation corroborates, it does not entail, and that was measured rather than assumed. Entailing it reported handleSignup, which looks a user up by name to check the name is free during registration and is public by design, because two authenticated siblings in the same file pass req.session.userId into the same module. The entailed rung's justification is that one FILE contradicts itself, same file and same trust context; carried, the compared operations live in a data-access module that handlers of different trust contexts share. The corroborated band already asks the right residual: does this operation require a check, or is the route legitimately public? Measured on NodeGoat: 15 handlers asked, obligations raised at zero before and six now, three reaching a verdict, all corroborated, none reported without a judge. VAmPI unchanged at two entailed findings, because it keeps handler and query in one file and carries nothing. What this does not reach is recorded, not implied. NodeGoat's documented insecure direct object reference still raises nothing, because its module reads with allocationsCol.find(...) and find is not a modelled operation: Array.prototype.find and jQuery's .find are everywhere, so it needs an argument-shape discriminator and its own measurement (task 14.8). Two of the three corroborated claims are correct code whose identity read a destructuring spelled away, and the cross-file contradiction that would entail the real defect needs a sibling-scope decision (task 14.7). 1,415 tests pass, fifteen skipped; eleven Joern-gated query tests pass in the image. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
f31ef862a7 |
fix(output-encoding): ask about the server half, and say what the family cannot decide
The census recorded this family completing 44 questions on a Node application and finding
nothing, which is what a client-only vocabulary must do there: all six declared sinks were DOM
sinks. NodeGoat's cross-site scripting is server-side.
Decision: the family asks about a value reaching a rendering call, and states in its own
declaration that it cannot decide template-mediated escaping. Whether res.render is safe depends
on the engine's autoescape setting and on the template's markup, neither of which is at the
call, so res.render is not a sink and the family's limits field says so. A new CWE-79 sink
covers res.write, res.end, res.send and the response.* spellings; res.json stays out because an
application/json body is not a document.
Template auto-escaping is claimed where its evidence is, as a configuration defect:
swig.setDefaults, nunjucks.configure and marked.setOptions are security settings now.
It establishes a finding: app/routes/research.js:25, model_entailed,
`req.query.url + req.query.symbol -> res.write(body)`. The handler fetches a URL the caller
chooses and writes the response body into a text/html response, so the document served from
this origin is the attacker's. The record states how coarse the step is: the path from url to
body runs through the client's callback parameter, which the graph treats as reachable from the
call's arguments, so the conclusion rests on the call's semantics rather than on what the graph
established. Five of seven families now establish findings.
Four defects were found on the way, three of them false positives this change would otherwise
have shipped.
- The permissive-value set was shared across every setting, while a boolean's polarity belongs
to the setting: true leaves the door open in cors({origin: true}) and closes it in
swig.setDefaults({autoescape: true}). Adding the template engines would have reported the fix
as the defect on the line that offers it. ConfigSpec now carries per-setting values, and
Jinja2's Environment moved out of Python's shared list for the same latent reason.
- config.sc matched a dotted setting on its trailing segment alone, so marked.setOptions would
have claimed an editor's setOptions. dominance.sc already required the qualifier.
- taint.sc asked its flow query of every argument including the receiver at index 0. On a
response sink the receiver is the response object, so res.end() -- which takes no data -- was
reported exactly like the res.write(body) two lines above it. Flows now use the real
arguments, as arity already did.
- The arbiter sorted a file's settings by line number as TEXT, so line 126 preceded line 78 and
which setting a file reported depended on the spelling of its line numbers.
Template escaping disabled at server.js:135 is detected, measured model_entailed against the
real query and the real arbiter, and still not reported: the arbiter returns one verdict per
question and that file has an earlier permissive setting. Task 14.6 carries it rather than
hiding it.
1,415 tests pass, thirteen skipped; nine Joern-gated query tests pass in the image.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
|
||
|
|
57f5a3f0dc |
fix(semantic-facts): give untrusted destination the clients a Node server calls
The family census recorded this family completing 44 questions on NodeGoat, finding the open redirect, and having no way to ask about the repository's documented SSRF: needle.get on a query-string URL at app/routes/research.js:16. A thin vocabulary is a silent false negative. One data change, no query change. The declared set now covers axios, got, needle, request, superagent, unirest, undici and fetch as bare module tokens, plus http.get, http.request, https.get and https.request dotted. Bare is the documented call form of each module and covers its verb methods too, since a token matches word-bounded against the callee and axios is bounded inside axios.get; axios.get and axios.post are dropped as subsumed rather than unwanted. http and https stay dotted because their namespace also holds createServer, and a bare token would have claimed every server a repository starts. The request is now reported at app/routes/research.js:16, model_entailed, witness `req.query.url + req.query.symbol -> needle.get(url, ...)`. This family goes from one finding to two on NodeGoat, every other family is unchanged, and 299 of 299 questions still complete. Measured on that graph, needle matches exactly one call and the eleven other clients match none in first-party or vendored code, so NodeGoat shows the gap closing and cannot show those clients' noise. A control asserts both halves. A handler calling axios.get with a literal and needle.get with req.query.url reports the tainted client alone, and it reads the shipped vocabulary rather than a copy, so removing the client fails it. The CWE-601 redirect entry is untouched: a Location-header redirect is value-shaped and stays outside what this family asks. 1,411 tests pass, eleven skipped; seven Joern-gated query tests pass in the image. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
4e2619e248 |
feat(pre-push-safety-net): census the declared families and fix a sink matched in a comment
Seven vulnerability families are declared and two had been proven. The census asks each of them, on whole checkouts of NodeGoat and VAmPI, whether it establishes anything at all, and reports a family that answered and found nothing separately from one that was never asked. Four establish findings: injection, access_control, config_secrets and untrusted_destination. Output encoding, path, prototype and deserialization establish nothing on either subject. All 299 NodeGoat and 35 VAmPI questions completed, so the silences are answers. Three of those silences are diagnosed gaps, not clean code. NodeGoat's documented SSRF calls needle.get, which the sink vocabulary does not hold. Its XSS is server-rendered, and output encoding declares only DOM sinks, so no question it can ask could have found it. Its two access-control defects raise no obligation at all: measured with the real dominance query, every modelled data operation lives in app/data/*-dao.js and none in a route handler file, while the family scopes operations to the region's own file. Tasks 14.2 to 14.5 carry each gap; none is fixed here. The census also found a false positive and it is fixed. NodeGoat's only prototype report was the word `set` inside `// set these up in case we have an error case`: the matched node is the <operator>.assignment holding the entire 1,000-character signup handler, and sinkMatches searched a node's whole text. It now confines the text clause to the callee and excludes operator nodes. Fourth instance of this bug class after fgets/gets, resolve/resolveUrl and user/users. Measured on NodeGoat's non-vendor calls, injection sinks fall 28 to 3, prototype 13 to 2 and untrusted destination 26 to 7, with no true positive lost; req.query had been an injection sink because `query` is word-bounded inside it. Witnesses now name the sink call rather than the assignment that contained it. 1,411 tests pass, ten skipped; the six Joern-gated query tests pass in the image, including a new one that fails on the shape this fixed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
770bae0e9e |
test(m3): record the PHP blocker as open, six hypotheses eliminated
Paused at the maintainer's direction to return to vulnerability work, with the state recorded rather than left implied. Measured and eliminated: batch size, since 17,700 requests answer in 44 s with a 2.6 MB response; dataflow cost, 50 execution requests in 24 s; call depth 3 with parameter sources, 24.7 s for 20 requests; argument length, a 16 KB hook table against a 128 KB single-argument cap; hook callback resolution, one second of difference with 233 hooks; and build or sharding, 79 s with no shards. Preparation measures 273.5 s, which is real but a small share of a 3,600-second case. Every reproduction of the failing 22,125-request batch outside the push path answers in under a minute. The difference from the production invocation is not identified, and the exception itself has still never been read: the backend logged a stack trace's tail, which is frames, and the corrected extractor reports its head, which on this engine is JVM startup noise. Four instrument defects were found and fixed along the way, all of the same shape: a failure detail that discarded the cause, stages that reported no cost, logging that was never configured so the new stage lines could not emit, and a harness that buffers stderr until a case ends. Node and Python transfer stand. PHP stays unestablished. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
6089c67bf6 |
fix(cpg): report the line that names an engine failure, not its frames
Every PHP failure logged `cpg batch taint failed: dure1.java:10)`, which is the middle of a stack frame and says nothing. A trace puts its exception on the FIRST line and frames after it, and the backend logged the last 400 characters, so the cause was discarded and the noise kept. Four investigations were spent on that. Failures now report the leading non-frame lines, which is where the exception and its message are, with the frames dropped. A control asserts an OutOfMemoryError survives while a frame does not, and that a timeout and an empty exit stay distinguishable. 1,411 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
dfc29238b5 |
fix(cli): make the stage log reachable, nothing configured logging (M3)
The stage costs added for PHP diagnosis never appeared: nothing in this project configures logging, so the default root level of WARNING dropped every informational line and a PMPro case ran for an hour producing none. That is the same failure shape the project keeps relearning. A run that reports nothing is indistinguishable from a run with nothing to report, and it is how four PHP investigations were spent reading a later stage's timeout as the cause. Logging is now configured from OUSAST_LOG_LEVEL, quiet by default and sent to stderr when asked, so a hook stays silent while a diagnostic run says where its budget went. Verified end to end: six stage lines with durations on a real replay. 1,410 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
45f9d6562e |
feat(push): report what each stage cost as it finishes (M3 diagnosis)
Three PHP investigations were spent reading `cpg batch taint failed: timeout` as a slow query. The query is not slow: 4,425 evidence requests answer in 30 s, 50 dataflow requests in 24 s, and 20 entry-point requests at call depth 3 with parameter sources in 24.7 s. The production PHP build takes 79 s and does not shard. None of that explains a case running 26 minutes without finishing. The reason it could not be diagnosed is that nothing reported where the budget went. A run that is killed, or that blames a later stage's timeout, left no account of what had already consumed the transaction, and a subprocess bounded by the remaining time reports its own timeout whatever spent it. Each stage now records and logs what it cost as it finishes, so a killed run still says where it was. A control asserts the stages report and that each line carries its duration. 1,410 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
537483657d |
test(m3): PHP still not established; blocker is the dataflow execution pass
Retried the PMPro transfer with batch splitting, a 4500-second deadline and a 900-second query ceiling. Stopped after 26 minutes with no case completed and no verdict, so PHP transfer remains unestablished. A probe narrowed the cause and corrected my earlier reading of it. The evidence pass answers all 4,425 PMPro PHP regions in ONE batch in 29.7 seconds with a 681 KB response, exit 0, and its cost is fixed rather than per request: one request took 40.2 s and 4,425 took 29.7 s. Neither request count nor response size is anywhere near a limit, so batch size and serialization are not the cause as I first supposed from a single extrapolated data point. What fails is the execution pass, which runs reachableByFlows per candidate. The probe's own scope is recorded with it: it sent evidenceOnly=true and therefore measured the structural pass, not the failing one. Those numbers would otherwise read as evidence that PMPro's taint work is cheap, which they are not. Splitting stays, because it is correct on its own terms and recovers a batch that fails whole. It is worth noting it also makes an unrecoverable batch slower to abandon, since each of the eight parts burns its own share of the query ceiling. M3 remains two of three: Node established in M1b, Python established, PHP blocked on dataflow cost for a 1,274-file repository. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
aa66429a82 |
fix(cpg): answer a failed batch in parts instead of losing it whole (M3)
PMPro's 1,274 PHP files produced a response the engine could not serialize: it failed inside ujson while building its own answer array, and every question in that batch was lost with it. Three budgets from 900 to 3000 seconds made no difference, because the size of the answer was the problem rather than the time available. One invocation stays the first thing tried, so a working scan costs exactly what it did. Each request in a batch is independent, so a batch that fails as a whole can still answer in parts: on failure the requests are split and retried, and a part that fails leaves its own requests unanswered rather than discarding every answer beside them. Only a batch where no part answered is reported as unavailable, which keeps "could not ask" distinct from "no rows". Controls cover all three shapes: a whole batch that fails is answered in parts with the split recorded, a failing part leaves only its own requests unanswered while the rest are kept, and a batch no part can answer is still unavailable rather than empty. Whether this is enough for PMPro is a separate question the measurement will answer: a single taint request on that tree already takes 40 seconds and returns 36 KB, so the batch may be slow as well as large. 1,410 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
d64abc2d43 |
test(m3): PHP transfer not established; the taint batch cannot answer PMPro
All three PMPro comparisons exhausted a 3000-second deadline with a 1200-second query ceiling and produced no artifact, so there is no target evidence at all. The engine reports `cpg batch taint failed` twice per case on the 1,274-file tree: first an exception whose trace runs through ujson array transformation, then a timeout on the retry. Budgets tried: 900 s, 1200 s and 3000 s deadlines against 300 s and 1200 s query ceilings. The pins, source receipts and declared target are all correct, so the question was asked properly and the engine could not answer it. This is a runtime blocker on a large repository, not evidence about whether the design transfers to PHP. Two harness defects were found on the way, and both had made an earlier run look like a transfer failure rather than an instrument problem. The manifest labels a case by what it is, vulnerable or fixed or benign, while the id says what the comparison does, introduce or repair or benign; filtering on one while keying expectations by the other ran a single case and reported an all-false verdict. And the per-query ceiling is taken as the minimum with the remaining transaction time, so raising only the deadline could never have helped, and twice did not. M3 is not met. Its exit asks for Node, PHP and Python together: Python is established, Node was established in M1b, PHP is blocked. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
bb72fcd413 |
test(m3): Python transfer detects all three declared targets (M3 13.1)
Generalized the replay instrument so the case family is an argument rather than a hard-coded target, since a harness that only knows one language cannot say whether the design transfers. Targets, pins and expectations come from the committed inputs, so Python is asked exactly the way Node was, through the same core, ranker, comparison and admission. VAmPI-SQLI models/user_model.py:73:get_user detected, answered VAmPI-BOLA api_views/users.py:187:update_password detected, answered VAmPI-BOLA-BOOKS api_views/books.py:51:get_by_title detected, answered 35 of 35 questions complete on every case, nothing reported new, zero admitted defects. Two results stand out. The SQL injection is the evidence the milestone spec names as previously unresolved. And two of the three targets are access control, the family that could not complete a question on any repository before group 12, so this is the first time broken object-level authorization has been asked on Python at all, and it answered on both endpoints. VAmPI has the same revision on both sides, so the regression mode establishes detection and completion, never novelty. It asserts that directly: with nothing changed, a target reported new would be a comparison defect, and none was. That tests the novelty logic from the opposite side to the NodeGoat case. Identical revisions cannot show novelty, and VAmPI is inspected teaching data that cannot qualify production Python support. No capability enabled, no latency claim. 1,407 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
fc5dc453b6 |
test(ranker): every question now completes on the real case (M1b 12.4)
Measured after scoping a registration-only region to its file. M1a, before group 12 0 of 436 head questions completed after per-family context 298 of 436 after region attribution 299 of 299, and 299 of 299 on the base The count falls from 436 to 299 because the mis-attributed function-scoped regions in a route-registration module are gone, replaced by file-scoped questions the graph can answer. Nothing is unresolved on either revision. Detections are unchanged: the vulnerable revision is still new with operation_absent_from_comparable_base, the fixed twin analyzed and quiet, the unchanged revision not a regression. What remains is recorded limits rather than failures: 210 bounded-reachability boundaries, each owned by the question that reported it, and one vendor exclusion. Neither blocks a comparison. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
573b764bbb |
fix(ranker): scope a region to the file that defines its handler (M1b 12.4)
A route-registration module registers handlers that other modules define. app/routes/index.js requires seven handler modules and defines none, yet regions there were scoped to handleContributionsUpdate and its siblings. The graph rightly held no such method, so every question in those regions was unresolvable: 138 across six families on that one repository. The entry record still names the handler. The authorization question is which middleware guards which handler, and that is cross-file by nature. It now also records whether the file defines what it names, and a region built from an entry that does not is scoped to the file instead of to a method the file lacks. The handler's own module still gets its function-scoped region, so nothing is lost. My first attempt changed the mapper to stop naming cross-file handlers at all. An existing control caught it by asserting that a multi-line registration names the handler its middleware guards, which is exactly the attribution the authorization family needs. The evidence was against me and the fix belonged one layer up, in region construction. 1,407 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
ca2c3a8b5f |
test(contributor-scan): record the group 12 end state (M1b 12.1-12.3)
Final verification of the three security capability fixes on the pinned revisions. head questions completed 205 -> 298 of 436 base questions completed 436 of 436 access control completed 0 -> 52 context_projection_unavailable 116 -> 0 context_scope_empty 138 -> 0 Every remaining unresolved question now reports context_function_unresolved and names the function its file does not define, so nothing is unresolved for a reason the tool could not explain. That is a region-attribution question for the ranker, not a limit of what the engine can establish. Detections are unchanged: the vulnerable revision is still new with operation_absent_from_comparable_base and every verdict entry holds. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
b95a371b7e |
feat(contributor-scan): name the function a question cannot locate (M1b 12.3)
After per-family context, every remaining unresolved question reported context_scope_empty, all in three files. The cause is not a missing producer: app/routes/index.js requires seven handler modules and defines none of them, so a question naming handleContributionsUpdate there names a function that file genuinely does not hold, and the empty scope is the correct answer. Reporting that as "scope empty" hid which of two different things had happened. A file the frontend produced no methods for is one problem; a question naming a function defined in another module is another, and it is the common one. Taint and configuration now separate them, reporting context_function_unresolved with the name in the first case and context_scope_empty only when the file has no methods at all. The driver no longer stacks its generic gap on top of a query that already explained itself, and the new gap is question-owned so it cannot block another question's comparison. Completion is deliberately unchanged. The tempting fix is to widen such a question to its whole file, which would raise the number by answering a question nobody asked; a question whose function the graph does not hold stays unresolved, with the name now recoverable from the question that asked it. Group 12 is complete: admission no longer rejects genuine findings on a mis-extracted operation symbol, both families that could never complete their change context now can, and the remaining unresolved questions say precisely why. 1,406 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
3e4ab8d219 |
test(contributor-scan): measure the per-family context effect (M1b 12.2)
Rerunning the pinned revisions with each family producing its own change context. head questions completed 205 -> 298 of 436 context_projection_unavailable 116 -> 0 access_control completed 0 -> 52 Access control is the headline: the family could not complete a question on any repository before, and now completes every question it is asked. That is a vulnerability class the tool could not reason about at all. Detections are unchanged. The vulnerable revision is still new with operation_absent_from_comparable_base, the fixed twin is analyzed and quiet, the unchanged revision is not a regression. The remaining 138 unresolved questions are all context_scope_empty and sit in three files, none of them access control. They are function-scoped questions whose named function the query cannot locate in the graph, so the scope it describes is honestly empty. That is the query-to-operation identity problem M1a recorded at the witness end, showing up at the scope end, and it is task 12.3 rather than something to patch by widening a question to its whole file. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
a91551e547 |
feat(contributor-scan): give each family its own change context (M1b 12.2)
Access control and configuration could not complete their change context on any repository, which left 231 of 436 questions unresolved on the measured Node case and made two of the three shipped families unable to answer. The gap was larger than a missing query block. The evidence pass dispatches taint only, because only taint has an evidence vector to rank by, so these families were never asked for context at all. A new context pass asks each family with the flag set. Each query describes its own scope, which the owner research required and which matters: copying taint's projection would describe a reachability walk these queries never perform. Dominance exports the file's methods labelled operation, guard or sibling, because its claim is that a file contradicts itself. Configuration exports the file's methods labelled setting or sibling, because it asks what a file declares. Both report an empty scope explicitly rather than implying one. A family whose context query answers nothing keeps no context and stays unresolved, so none can complete on another family's context. A control asserts that directly. Confirmed on a real NodeGoat graph through the production backend: both families produce a context summary and seven scoped methods where they previously produced nothing. All seven are siblings for that file, which is the honest answer since it declares no access-control operation and no matched setting. 1,405 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
4ee31142ab |
fix(pre-push-safety-net): take the operation symbol from the call (M1b 12.1)
Admission extracted everything before the first parenthesis, so the operation for `const preTax = eval(req.body.preTax)` was `const preTax = eval`. No real declaration can enumerate every assignment a codebase might write, so admission would have rejected genuine findings on operation_semantics_mismatch. The M1b demonstration only passed because its declaration listed the assignment spellings. The symbol is now the trailing qualified callee. Receiver spelling stays part of it, so `$wpdb->prepare` and `prepare` remain different operations and a declaration cannot widen from one to the other. A spelling that is not a qualified name, such as a dynamic `handlers[name](x)`, yields the statement text, matches no declaration and stays ineligible exactly as before. Controls cover the assigned call, the receiver distinction, the dynamic spelling, and that a declaration for a different operation still does not cover it. The recorded-veto report reads the same helper instead of repeating the extraction. 1,403 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
6653b6aca8 |
spec(pre-push-safety-net): record the M2 direction, do not rebuild a graph per push
Both attacks on engine startup are measured and neither reaches the gate. The session is equivalent for every shipped query kind and helps a long transaction, 326 s to 243 s, but its own 9 to 13 second startup is a third of a 30-second budget so it usually declines to start. Removing JavaScript's second engine start is real, 11.0 s against 21.0 s on identical graphs, and was reverted for an asymmetric census gate that made 12 transactions pay two starts. What the profile names is not startup. A changed-code transaction reaches its deadline inside the build: head build 25.2 s of 30 s, query time 0.0 s. The cost is building a fresh whole-repository graph for every pushed revision. Direction accepted: reuse the graph across revisions rather than optimise the rebuild. M2 stays unmet, the gates stay unchanged, and the work is not scheduled here. Group 12 returns to security capability, which is where the remaining release risk sits. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
ed0f12b593 |
revert: JavaScript one-start build did not deliver in the push path (M2 11.6)
Reverts
|
||
|
|
f04a839181 |
perf(cpg): build JavaScript in one engine start, gated on the census (M2 11.6)
JavaScript was the only language paying two engine starts: the declared frontend and then a separate overlay pass. joern-parse applies the overlays itself. On the pinned Node tree that is 11.0 s against 21.0 s. The declared frontend stays as the fallback, because it is what the versioned retention adaptation and the explicit exclusion policy run through, and the census decides per build: a graph that does not hold every readable declared file is rebuilt through the frontend rather than trusted. Exclusions keep the frontend path outright. Measured under the production retention environment, both paths produce the same 44 files, the same 579 methods, the same file names, the same five overlays, the Gruntfile and all eighteen first-party test files, with no file present in one graph and missing from the other. My first measurement did not establish that. It ran without the retention environment, both paths dropped to 25 files, and the apparent equivalence was two runs discarding the same things. The existing control named for that decision failed against the change built on it, which is exactly what it is for; the evidence was wrong, not the control. That control now asserts the intent directly: the faster path is tried first, and a short census rebuilds through the frontend. 1,401 tests passed, nine skipped. A build saving is not the latency gate; the profile rerun decides that. Rollout stays NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
7acb897d77 |
fix(cpg): stop a failed session start from consuming the transaction budget
The seven-class profile showed a 25.3 s head "build" of which the trace attributes only 4.5 s to the frontend. The remaining twenty seconds were the session's startup poll, which was bounded by `min(startup_timeout, deadline - now)` and therefore ran until the transaction deadline was exhausted. The fallback then had nothing left, so the transport's failure mode consumed exactly the budget it existed to save. A session now refuses to start unless enough budget survives for the work it exists to serve, and the wait is bounded by its own allowance rather than by the whole deadline. Under the 30-second hook deadline it declines immediately instead of burning what is left. Controls cover both: refusing is immediate and starts no process, and a startup wait ends at its allowance nowhere near a long deadline. This does not make the gate reachable; it removes a self-inflicted cost that was hiding the real one. 1,401 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
ffbd864195 |
test(runtime): measure the seven-class profile with the session; NO-GO (M2 11.5)
The unchanged seven-class profile, three samples each, with the engine session enabled. Every changed-code workload still times out at the 30-second deadline, and identical-tip moved the wrong way: p50 rose from 18.5 s to 24.2 s. The cause is in the artifacts rather than inferred. Thirteen transactions report session_unavailable:session_startup_timeout, so under the real deadline the session usually never starts and everything falls back to the disposable path. Startup is 9 to 13 seconds, about a third of the whole budget, and it is spent before any analysis happens. Where a session did start, on cached-graph transactions whose remaining work is small, it cost more than it saved. That is the honest shape of the result: a transaction-owned session amortizes engine startup across the invocations inside one transaction, and a warm changed-code push does not have enough of them. It helps a long development-budget transaction, 326 s to 243 s measured earlier, and cannot help a 30-second one. Correctness is unchanged throughout: the same three pinned revisions through the production rules with the session enabled reproduce the M1 verdict exactly. Group 11 as designed cannot reach the gate, so the dominant remaining cost is named and the decision is raised rather than worked around. Task 11.6 records the candidates, since two of them change the product's shape: a session shared across transactions is a resident local service, which the accepted milestone spec does not imply, and analysing less is a scope change rather than a speed change. The 30-second and 2-second gates stay unchanged. Rollout stays NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
7fc6713e78 |
test(cpg): confirm the session stays alive and halves query time (M2 11.4)
The leak was the whole cause. With the session closed by the transaction that owns it, a session-enabled replay of the same pinned revisions under the same budget keeps its session for the whole run: no session_query_failed appears on either side, where the previous run reported one per side and fell back to the disposable path for everything. Measured on the same pins, instrument and budget, changing only the transport: vulnerable revision 326.4 s -> 243.3 s head query time 63.6 s -> 32.0 s base query time 85.1 s -> 51.2 s dominance and config ~14 s each -> ~4 s each Detections are unchanged: witness present, fixed twin analyzed and quiet, unchanged revision not a regression, kill criterion not triggered. Speed bought by losing a detection would not be speed. This is a cold transaction on one teaching repository under a 900-second development budget. It is not the hook latency gate, which is warm changed-code p95 against the frozen seven-class profile, and that is task 11.5. Rollout stays NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
566b862b0a |
fix(cpg): end an engine session with the transaction that owns it (M2)
The first session-enabled replay produced correct detections and no speedup, reporting session_query_failed:census:session_request_failed on both sides. A targeted probe ruled out the sequence I suspected: an overlay followed by a census in one session both answer correctly, with the census confirming the overlays were saved. What the probe exposed instead is a leak. A build runs on a copy of the backend, so the session started during the build belonged to that copy and was never closed, leaving a JVM holding the configured 2 GB heap for the life of the process. Two builds per transaction meant two of them, which is enough to exhaust a 3 GB container and make every later request fail. It also matches the host running out of memory during these runs. The build now closes any session its copy started, and the driver ends the session with the transaction, since that is what transaction-owned means. Controls cover both: a build leaves nothing alive on the copy that started it, and a completed scan closes the backend's session. Closing never fails a scan that found things. This is a genuine defect found by measurement rather than by the suite, which passed throughout. Whether it was the whole cause of the census failure is not yet established; the next session-enabled run will say. 1,399 tests passed, nine skipped. Rollout stays NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |