mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
a8b226a5c8386fa2fa591469e1d74fc21cbd5f47
284
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a8b226a5c8 |
feat(cpg): portion the work instead of capping it -- a kill keeps what finished
A hard per-invocation ceiling is all-or-nothing. A batch killed at 301 s lost every answer it had already computed, and the retry paid a fresh JVM start per part to ask them all again: measured, 116 requests killed at 300 s, then eight restarts carrying the same everywhere-sinks in every part. Three coordinated changes make portioning, not the cap, the thing that governs cost. The taint query STREAMS. The census first, then one line per request as it is answered, flushed as it goes, inside the unchanged fence. The other queries do not stream and need not; the driver reads both forms, and a legacy single document parses exactly as before. The driver HARVESTS a kill. The process group is terminated first -- a first cut read the pipe before killing and a pipe whose writer is alive never reaches end-of-file; the suite own hanging-engine control sat on that read for twenty-eight minutes -- then the pipe is drained and every complete line becomes an answer, marked partial. The retry asks only the remainder. Portion sizing ADAPTS. After each portion the observed seconds per sink visit, less the fixed start, resizes the rest toward a 120 s target; a portion that did not answer halves the budget rather than being fitted, because its true cost is unknown and above the ceiling. A portion cut down to fit puts its remainder back at the front of the queue. A census LINE carries nothing but the census. A first cut misread a legacy document that carried the census beside its answers as a census line and dropped every answer it sat beside; a control caught it. Task 7.8 in contributor-scan. Verified: 1,423 host tests pass, nineteen skipped, including new controls for the assembler in both forms and under a kill, for a retry that asks only the remainder, and for the sizer. Not yet verified: the joern-gated streaming check and the plugin measurement, which wait for the transfer replay to free the machine and are stated as open in the task. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
d0026f4ea5 |
fix(scan): check the arbitration reserve per batch, not per query kind
The reserve was computed once and checked between kinds, and one kind is a single iteration that runs every one of its batches inside it. So taint spent the whole budget and the reserve only stopped the cheap kinds queued behind it: 0 of 406 questions completed, 2,435 s of a 2,700 s deadline in queries, identical to having no reserve at all. The granularity of a budget check has to match the granularity of the work it guards. It is now checked before each batch, and the batches left unasked are logged rather than silently dropped. 1,420 tests pass, eighteen skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
8cf1b383d4 |
fix(scan): keep budget back for arbitration, because rows are not findings
Querying fills rows. Arbitration turns them into findings and the outcomes are assembled from that, and neither costs the engine anything -- so a scan that spends its whole budget on queries reports nothing at all, however many of them answered. Found by accident, which is the honest way to tell it. A per-query timeout used to cancel the whole scan. Removing that cancellation was right on its own terms and made the measured result worse: the query phase then ran the full 2,700 s deadline to exhaustion and completed 0 of 406 questions, where the cascade had stopped it at 958 s and left time to arbitrate 78 findings. The cascade had been an accidental budget guard. This is the deliberate one. The query loop now stops issuing batches with a reserve in hand: a fifth of what was left when querying started, a sixty-second floor, and never more than half. The cap is not decoration -- held back without it the floor swallowed a whole small budget and the query loop never ran at all, which a control caught. A scan with a fifth of a second to spend must still spend it. What the loop skipped is recorded as query_budget_reserved with the kind and the request count, not left to look like a query that failed. 1,420 tests pass, eighteen skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
d2362cd18d |
fix(cpg): carry the engine's own account when a session request fails (M2)
The session-enabled NodeGoat run reported session_query_failed:census: session_request_failed and fell back, which is correct behaviour but says nothing about why. A bare transport failure cannot distinguish a timeout from a crash from an out-of-memory kill, and the only account of which it was is in the server's own log. A transport failure now carries the exception and the tail of that log, and the backend logs it with the process exit code alongside its own label. A control asserts an OutOfMemoryError in the log reaches the caller. This is diagnosis, not a repair: the run's timings were unchanged because the session died early and everything fell back to the disposable path. 1,397 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
fc69055a32 |
feat(cpg): add an explicit environment switch for the engine session (M2 11.3)
A measured run needs to enable the transport without changing any default. The switch reads as enabled only for the exact value "1"; unset, empty, "0", "true" and anything else leave the disposable path in place, and an explicit constructor argument still wins in both directions. 1,396 tests passed, nine skipped. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
87f7976a58 |
perf(cpg): run the overlay pass in-session and verify it saved (M2 11.4)
The overlay was the last fixed cost bypassing the session: a whole JVM start, 15.8 s in the measured Node runs, while the census and query batches already route through it. `save` writes under the process working directory, which for a session is its own private scratch rather than the build scratch, so the saved graph is now looked for in both. Finding neither keeps the raw graph and records it rather than reporting success, because a graph without its dataflow layer has that layer recomputed by every later batch, 54 s each on a 637-file plugin, with nothing otherwise saying so. The disposable invocation remains the fallback and runs unchanged whenever the session is off or cannot answer. Controls cover both new paths: an engine that reports success but saves no graph keeps the raw one and notes it, and a graph saved under the session's workspace is found and applied. 1,395 tests passed, nine skipped. The transport stays off by default; task 11.5 measures against the frozen profile. Rollout stays NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
7e3f12bc00 |
feat(cpg): add a transaction-owned engine session and route it opt-in (M2 11.1, 11.3)
JVM startup, not analysis, is what a push scan spends its time on: each disposable script invocation costs 13 to 23 seconds while the work takes under a second. The session owns one Joern server per transaction on loopback with an OS-chosen port and no published port. Three properties are load-bearing, and the probe that settled the design demonstrated why each is needed. An HTTP status is not an answer. The server returns success even for a compile error, with the diagnostic in its body, so every request redirects the evaluated code's console output into a fresh private file behind a nonce, and the nonce is verified before the answer is accepted. The engine's body is retained for exactly the case where the receipt is missing, without which that failure explains nothing. A poisoned session answers nothing afterwards, and cancellation kills the process group rather than the launcher, which this project has already run a machine out of memory by neglecting. What the probe established about the engine, after three iterations that each failed loudly: runScript is absent from the server REPL; a shipped @main script cannot be nested in a block expression, so definitions go to the top level unwrapped; and the REPL shadows Console with io.joern.console.Console, which has no withOut, so capture must qualify scala.Console. With that fixed, both mechanisms answer identically to the disposable invocation, 0.8 s against 9.8 s. The backend routes batch and single queries through the session only when session_transport is enabled, which it is not by default. Any failure falls back to the disposable invocation rather than reporting a zero, and each script is defined under its own name because every script declares exec. Equivalence for taint, dominance and configuration is task 11.2 and gates any measured run. 1,391 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 |
||
|
|
cec1c4a428 |
feat(pre-push-safety-net): render the experimental evaluation explanation (M1b 10.10)
The M1b exit run established the comparison but could not show a developer-readable consequence or repair direction, because both belong to an evaluated capability declaration and the installed registry is deliberately empty. The experimental evaluation output now renders, for each change-attributed finding, its family, exact location, witness, change attribution, provenance, engine method and operation, together with a consequence and repair direction produced from a supplied declaration through the same grounded-template path the admitted case uses. No prose is invented: when no declaration matches, or a template is not grounded in the operation and context, or the declaration does not cover the operation symbol, the explanation is absent and the reason is stated. Declarations reach this rendering only. The runner never passes them to admission, so nothing in that file can enable a capability or emit an alert however it is written, and a control asserts that even a declaration claiming PASS admits nothing. The flag requires explicit base/head replay and the recorded-veto flag. The evaluation instrument generates its own unreviewed declaration bound to the run's analysis semantics, asserting that it is neither enabled nor PASS, so the demonstration cannot drift into a qualified claim. 1,377 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 |
||
|
|
541ee5813e |
feat(pre-push-safety-net): carry query-to-operation provenance (M1b 10.8)
M1a recorded that the NodeGoat target is selected through a file-scoped question naming no function while the engine reports method <lambda>2, and that this did not block the comparison once the question completed. It was still only visible by digging the method out of a serialized detail blob. Evidence now carries the engine-reported method beside the question that produced it, and the relationship between them is recorded: exact when the question named that method, file_scope when the question named no function and the engine supplied one, unresolved when the two disagree, and method_unreported when the engine named none. Recorded, not applied. No region is renamed, no benchmark function name is injected into a region, and no coincident site is counted. The recorded-veto report now reads the field instead of reparsing the detail blob. 1,371 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 |
||
|
|
9a67f54ea1 |
fix(pre-push-safety-net): bind dependency gaps to the candidate (M1b 10.7)
The runner handed every candidate the whole transaction's coverage reason list as its unresolved dependencies, so admission always answered dependency_unresolved. That list even included other candidates' comparison outcomes, so one unresolved comparison anywhere marked every other claim as having an unresolved dependency. Gaps are now attributed. A boundary carrying a question id counts only when that question is in this candidate's required comparison scope, the same scope 10.2 established. A gap nothing attributed to any question always counts, because unknown ownership is not evidence of independence. Integrity degradations count. A declared exclusion, a correspondence ambiguity and a bounded reachability do not, because each was already weighed against this specific claim: the first is a scope choice, the second is decided per operation, and the third is decided per claim by the comparison. Found by the M1b production rerun rather than by the suite: the comparison reached a correct new verdict and admission still rejected it for a dependency belonging to unrelated questions. It is not an M1b exit blocker, since eligibility stays disabled, but it would have blocked admission forever once capabilities qualify. 1,371 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 |
||
|
|
bc68000e93 |
fix(pre-push-safety-net): apply the exclusion distinction inside comparison too
The NodeGoat rerun after 10.4-10.6 showed both target questions completing, which is what those tasks were for, but the comparison still reported head_context_incomplete. Two checks inside comparison had not been updated with the same distinction: any degradation blocked, and any scope boundary blocked. A declared exclusion and a correspondence ambiguity both reached those checks. The first is a scope choice rather than a failure to read the graph, and the second is decided per operation since 10.3, so neither should block a claim. Both remain in aggregate coverage. Integrity degradations, unknown ownership and every other boundary still block, and bounded reachability is still filtered separately because it blocks only claims resting on a flow not having been traced. 1,368 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 |
||
|
|
c1cc332bc1 |
feat(pre-push-safety-net): stop declared exclusion vetoing first-party answers (M1b 10.6)
vendor_semantics_unresolved was grouped with the graph integrity failures, so any declared exclusion demoted every otherwise complete answer in the repository to graph_incomplete. Every real repository excludes its dependencies, so this fired on all of them, including on a complete first-party census with successful query receipts. Graph boundaries are now split by what they mean. An integrity failure says the graph could not be read or is incomplete, so an answer drawn from it is demoted: unparsed files, an empty or sharded graph, an incomplete or unavailable census, an unresolved symlink, an unsupported frontend, unreadable source, an ambiguous frontend path and unvalidated TypeScript property support all keep that behavior. A declared exclusion is a deliberate scope choice. It stays physical, stays out of targets and graphs, and stays reported in coverage, but it is not evidence that a first-party answer is wrong. Whether a particular answer needed the excluded semantics is carried by that question's own unresolved call destinations, recorded per question in 10.5, rather than assumed for every answer in the repository. Controls exercise the real partition logic rather than an injected reason: a vendored directory leaves the answer completed with the exclusion still reported, an unresolved symlink still demotes, and the two together still demote. 1,365 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 |
||
|
|
b202aabd8b |
feat(pre-push-safety-net): bind the reachability boundary to what it limits (M1b 10.5)
dynamic_external_or_depth_context_unresolved was applied as though it invalidated everything about a question. It does not. Bounded reachability can hide further flows; it cannot invalidate a flow that was traced, and it cannot remove a call from a structurally scoped enumeration. At scan level the boundary is now recorded against its owning question without demoting that question's answer. An unavailable context projection, an empty context scope and an unreadable method extent are unknown ownership and still demote. In comparison it no longer blocks the head's own claim, since the finding rests on a path that was actually traced, and no longer blocks an absence read off the enumerated inventory. It still blocks every claim that rests on a flow not having been traced, including the anchored source-connection branch, which now reports base_reachability_bounded rather than silently passing. The query declares the basis of its enumeration. A file- or nesting-scoped question walks calls structurally, so an unresolved destination cannot remove one from scope. Following the call graph makes the reachable set the scope, so the same unresolved destination can shrink what was enumerated; that inventory cannot establish absence while reachability is bounded, and says so. One prior control asserted a question's own reachability gap blocks its own claim. It is updated with its reason and split, so an own projection gap is still proven to block while an own reachability gap no longer does. 1,362 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 |
||
|
|
c73efd2bde |
feat(pre-push-safety-net): export the enumerated operation inventory (M1b 10.4)
A taint answer reports traced flows, so an absent row means either that the operation is not present or that no traced path reached it. Change comparison needs that distinction to say a change introduced an operation, and reachability is deliberately bounded, so the two can never be separated from flow rows alone. That is why the veto guarding the confusion is permanently true on any real Express application, and why it could be neither discharged nor safely removed. The taint query now exports one row per operation it enumerated in scope, plus a completion marker. The marker is what makes an empty inventory meaningful: without it a scope that was never enumerated and a scope holding no such operation are indistinguishable. Comparison separates three outcomes on a changed line: the operation is present in the base without a traced flow, which is a reachability statement and not novelty; the operation is absent from an enumerated base, which establishes that the change introduced it; or the base scope was never enumerated, which stays explicitly unresolved. A malformed inventory row is not an empty inventory. The arbiter now skips any row carrying a kind, so an inventory row cannot be normalized into a flow with an empty source that names no attacker input. Verified against the real engine on an authored two-revision JavaScript control: the operation is enumerated on both sides, the base carries it with zero traced flows, the zero-operation marker appears for an empty scope, and answers and findings are unchanged. No new sink inference, alias resolver or second source IR. 1,354 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 |
||
|
|
86980d8aa0 |
feat(pre-push-safety-net): measure M1b step 1 and record the inventory prerequisite
Rerunning the three NodeGoat revisions after tasks 10.1-10.3 shows the recorded view now classifies the case: the vulnerable revision reaches new with operation_absent_from_comparable_base, and the unchanged revision reaches unchanged with its base location resolved to the same lines. Both previously stopped at operation_correspondence_unresolved and witness_identity_unresolved. The rerun also caught a defect in the repair itself: the new novelty reason was missing from the admission allowlist, so admission rejected the candidate as change_unsupported. Fixed with a control. The production path is still blocked by the last two vetoes, and the measurement shows neither can be scoped or relaxed as the plan described. A taint answer reports traced flows, so an absent row means either that the operation does not exist in the base or that no traced flow reached it. dynamic_external_or_depth_context_unresolved exists to stop the second being read as the first, and removing it would turn a wrong absence into a wrong new finding. It is also computed over every in-scope method, so on any real Express application it is always true and can never be discharged, which is why the tool reports nothing on real projects. The resolution is new evidence rather than a relaxed rule: export the operations the query actually enumerated, separately from the flows it traced, so absence is established from the inventory while a traced-flow claim still requires complete reachability. That is now task 10.4, ahead of the two vetoes it unblocks, and it adds no detector, alias resolver or second source IR. 1,348 tests passed, nine skipped. No capability enabled; rollout stays NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
8ff1d9a638 |
feat(pre-push-safety-net): scope comparison completeness and ambiguity (M1b 10.2, 10.3)
Two vetoes M1a recorded were global where the evidence is local. Comparison required every selected base question to complete, so one unsupported family anywhere prevented an otherwise complete target comparison. Completeness is now demanded over the required scope: the counterpart question plus the dependencies the change context actually recorded for it. Every other gap stays in aggregate coverage and is still reported, never discharged. An incomplete counterpart, an incomplete recorded dependency, an unknown-ownership boundary and a truncated base scan all still yield unknown. Correspondence ambiguity was recorded file-wide and inherited by every question, so repeated unchanged lines in one file invalidated every question in the repository and blocked the whole transaction comparison. It is now decided per operation by the correspondence classifier: an operation needing an ambiguous anchor stays uncomparable, while a uniquely mapped or newly introduced operation in the same file is unaffected. The mapping is still withheld and still reported. On the NodeGoat case the ambiguity is on the target's own file, so file-level scoping would not have been enough; only per-operation resolution is. 1,347 tests passed, nine skipped. No capability enabled; rollout stays NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
7e0d01d9e8 |
feat(pre-push-safety-net): classify an operation the change introduced (M1b 10.1)
M1a measured that a newly introduced operation sits on a changed line, has no base line anchor, and therefore stayed unclassified even with every completeness veto lifted. Establish absence over the complete counterpart answer instead of over a line anchor. Correspondence now reports why a head operation does not map: `changed_line` inside a recorded head edit, `ambiguous` where several base lines could correspond, or `unmapped` where no anchor exists. Only the first supports reasoning about an introduced operation, and only when the base counterpart answer is complete and contains no operation with that spelling. Movement is matched on the operation spelling alone, so a benign identifier rename that changes only the source text reads as movement rather than a new flow. The existing rename control caught that; the key was too brittle before. One prior control is superseded and updated with its reason: a new operation on a changed line in a comparable existing file is now new rather than unknown. Movement, ambiguity, unmapped operations, discharged or unentailed claims and an incomplete or failed base all remain unknown. 1,337 tests passed, nine skipped. No capability enabled; rollout stays NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
3224884d8f |
feat(pre-push-safety-net): record admission vetoes and meet M1a on NodeGoat
Add group 9 to the approved task plan as the bounded owner amendment release milestone M1a requires, implement the task-local recorded-veto evaluation flag, and run the three pinned NodeGoat revisions under it. The flag re-runs the production comparison and admission over a lifted copy of the same evidence and records each veto instead of applying it. Recognized completeness vetoes are lifted and logged; failures never are, because silence from a failure is not a quiet fixed twin. It is off by default, accepted only on explicit base/head replay, refused in Git stdin mode, labels every payload it produces, and admits no defect even when a lifted candidate otherwise would. M1a outcome, inside its one-week box and with expectations frozen on disk first: the three entailed <lambda>2 witnesses are present at contributions.js 32-34; the fixed twin was genuinely analyzed and quiet, answering its target question with zero rows and no deadline reason while the same findings appear as base-only; the unchanged revision was analyzed and recorded unchanged. The kill criterion did not trigger. Five vetoes fired and were recorded. M1a also corrected the diagnosis: with all five lifted the detection still stops at operation_correspondence_unresolved, because a newly introduced operation sits on a changed line and has no base anchor. The unchanged revision resolves its base location on the same lines and classifies as unchanged, which isolates correspondence as the mechanism. That fifth veto was not in the earlier four-veto diagnosis and is now M1b's central repair. Diagnosis only. No defect admitted, no capability enabled, no gate changed, no latency claim; the 900-second development budget cannot measure the hook gate. Rollout and task 8.3 remain NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo |
||
|
|
04f888019b | release: prepare v1.2.0-alpha.1 and record rollout blockers | ||
|
|
7c1c2d4475 | fix(contributor-scan): retain declared Gruntfile and report census gaps honestly |