Fix the cross-frontend defects the JS rebuild exposed, and two gaps the CVE campaign found - #46
Conversation
…acks Two steps of the cross-frontend audit. ruby lowerExpr had no case for `bare_assoc_hash`/`hash`, so a keyword argument became one ruby.unsupported intrinsic and the argument vanished. Nor for `@label`, the keyword key itself -- a symbol literal like the other scalars. test/ruby/rails_query_sqli emitted that intrinsic on every run. The hash is lowered like `array`: each key and value lowered so a source or sink inside still fires, container left untainted. Untainted is the point rather than a shortcut -- ActiveRecord's `where(name: params[:q])` is parameterized by construction, and a hash that carried its values' taint would make every such call a false positive. That sample now passes for its documented reason (the #1 sink pinning) instead of because the argument was erased. Ruby findings are byte-identical, 33 before and after. requireNoFallbackIntrinsic moves from the JS test file into internal/testsupport, parameterized by intrinsic name, and python and ruby gain the whole-tree test JS already had -- fallback count plus a pinned Skipped(). Both halves are otherwise invisible: frontend.Batch errors only when ZERO files convert, so a frontend can drop all but one file and still report Converted, and an unmodelled construct costs findings with nothing failing. That guard is what makes the rest of the audit's fixes verifiable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
The collectors and the lowering split the statement tree between them: lowerBody/lowerStmt descend into the control-flow compounds and skip a def, trusting the collector to claim it. Both collectors entered only def/class bodies, so a def under `if`/`try`/`with`/`rescue`/a block was claimed by NEITHER -- it left no error and no fallback intrinsic, it simply was not analyzed. `if TYPE_CHECKING:`, feature flags and `except ImportError:` shims all take that shape. Both now recurse through every node that opens no scope rather than enumerating compounds, which is what keeps them in step with the lowering. Ruby additionally gains `class << self`: its Ripper tag appeared in no switch at all, so a singleton body was dropped whole. A def inside one is a class method, so it is named and shaped like `def self.m` -- class-qualified canonical name, receiver in parameter slot 0. No finding changes across either corpus; both repros now produce findings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
…ings Two ways the MIR lowering was quietly wrong about provenance. An rvalue form the parser did not recognize became an empty string constant -- clean data. Taint died at it and the result read as a legitimately safe value, with no error and nothing failing; every other frontend emits a marker instead. It now emits rust.unsupported, and the corpus is guarded for it crate by crate the way the corpus itself is driven, so cargo resolves each crate's deps. Separately, rustc's spans for macro-expanded code point into its own sysroot: anything through format! lands in /rustc/<hash>/library/alloc/src/macros.rs, which does not exist on the scanning machine. Three steps of the shipped sql_injection sample's taint path were pinned there. A span outside the tree being scanned is now rejected in favour of the last one accepted from user code -- the macro's call site. No finding changes across the Rust corpus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
Ripper counts columns from 0, and the frontend passed that straight through
while Python adds one, JS computes off-start+1 and Go's tokeniser is 1-based.
Every Ruby column shipped one to the left of what it named.
Nothing could have caught it: expected.yaml asserts counts and an occasional
line, and the position golden covered JavaScript only. It is now per language
and keyed by sample, so a language whose toolchain is absent contributes
nothing and the rest still assert. Rendering piggybacks on the corpus scans, so
the oracle costs no extra work; the JS rows are unchanged.
Two regeneration hazards found while generating the goldens, both of the same
shape as the defects being fixed -- a helper whose output cannot tell "nothing
found" from "did not run":
- a scan whose frontend failed reports zero findings, so regenerating from it
EMPTIED the manifest of every language the machine lacks a toolchain for.
Coverage tells them apart, and a golden write skips such a sample too.
- max/line/sink in a manifest are hand-written; regenerating from scan output
deleted them. They are now carried over for every rule that still fires.
Java's test guard also asked the wrong question: it checked that `java` was on
PATH while ConvertFile enforces a JDK 24 floor, so an old JDK passed the guard
and then failed every test. Both the converter tests and the corpus now ask the
frontend the same question it asks itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
rustc encodes a spec-bearing or explicit-index argument (`{:>10}`, `{0}`) with a
control byte the MIR template decoder does not model, and it failed the whole
decode on one -- leaving an EMPTY template. Downstream that is not "unknown", it
is the positive claim that the format string contains no host, so hostFixed()
could not prove the host constant and the finding fired.
The effect: adding a width specifier to provably safe code turned it into a
high-confidence CWE-918. Same code with a plain `{}` produces nothing.
The decode now stops at such a token instead of failing. What it read up to
there came from literal-run tokens -- compile-time text that cannot carry taint
-- so the constant prefix stands, and the remainder is rendered as one argument
insertion, which the skeleton reads as dynamic. A tainted host still fires:
`format!("https://{:>10}/v1/", p)` yields no constant authority.
No byte archaeology was widened; the failure is local rather than total.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
The gate is per language and asserts only what a run actually scanned, so the Java rows could not be produced on a machine below the JDK 24 floor -- and CI, which has one, then failed on the missing file. Generated against Temurin 25. Java columns are 0 throughout: the frontend reads bytecode, whose LineNumber table carries no column. That is now pinned rather than merely true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
🛡️ Quality Gate —
|
| Area | + | − |
|---|---|---|
cmd/ |
9 | 1 |
converters/ |
667 | 95 |
internal/ |
77 | 25 |
rulepacks/ |
177 | 34 |
Counts
cmd converters internal pkg proto rulepacks; excludes*_test.go,testdata/,test/, generated*.pb.go.
2 · Corpus signal/noise (TP / FP / FN)
| Metric | Base | Head | Δ |
|---|---|---|---|
| TP | 236 | 241 | 5 |
| FP | 0 | 0 | 0 |
| FN | 0 | 0 | 0 |
| Precision | 1.000 | 1.000 | +0.000 |
| Recall | 1.000 | 1.000 | +0.000 |
| F1 | 1.000 | 1.000 | +0.000 |
⚠️ Sample count differs (base N=321, head N=329) — the PR added/removed corpus samples, or a toolchain differs between checkouts. The raw TP/FN deltas partly reflect that, so read precision/recall (rates) rather than the counts.
3 · Rule changes
- Added: react-xss-props, ruby-xss-cell-option
- Modified: react-xss, ruby-open-redirect, ruby-path-traversal, ruby-ssrf, ruby-xss, svelte-xss, vue-xss
4 · Performance · gated (benchstat, count=10)
Engine hot paths and per-language full-pipeline scans, all compared by
benchstat so the base→head difference is statistically reliable rather than
wall-clock noise. A language whose toolchain is absent is skipped.
goos: linux
goarch: amd64
pkg: github.com/bytevet/godzilla/internal/analysis
cpu: AMD EPYC 9V74 80-Core Processor
│ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
│ sec/op │ sec/op vs base │
Engine_RuleScaling/rules=1-4 1.578m ± 2% 1.601m ± 1% ~ (p=0.043 n=10)
Engine_RuleScaling/rules=10-4 11.37m ± 3% 11.27m ± 3% ~ (p=0.579 n=10)
Engine_RuleScaling/rules=50-4 36.80m ± 2% 36.99m ± 5% ~ (p=0.796 n=10)
Engine_RuleScaling/rules=200-4 114.5m ± 5% 113.3m ± 4% ~ (p=0.631 n=10)
Engine_InertRules/inert=0-4 61.32µ ± 0% 61.64µ ± 0% ~ (p=0.019 n=10)
Engine_InertRules/inert=14-4 62.03µ ± 0% 62.15µ ± 2% ~ (p=0.061 n=10)
Engine_InertRules/inert=100-4 64.47µ ± 0% 65.28µ ± 1% +1.27% (p=0.000 n=10)
geomean 1.517m 1.522m +0.30%
│ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
│ B/op │ B/op vs base │
Engine_RuleScaling/rules=1-4 1.393Mi ± 0% 1.393Mi ± 0% ~ (p=0.807 n=10)
Engine_RuleScaling/rules=10-4 11.11Mi ± 0% 11.11Mi ± 0% ~ (p=0.123 n=10)
Engine_RuleScaling/rules=50-4 54.28Mi ± 0% 54.28Mi ± 0% ~ (p=0.165 n=10)
Engine_RuleScaling/rules=200-4 216.2Mi ± 0% 216.2Mi ± 0% ~ (p=0.739 n=10)
Engine_InertRules/inert=0-4 41.21Ki ± 0% 41.21Ki ± 0% ~ (p=1.000 n=10) ¹
Engine_InertRules/inert=14-4 41.57Ki ± 0% 41.57Ki ± 0% ~ (p=1.000 n=10) ¹
Engine_InertRules/inert=100-4 43.95Ki ± 0% 43.95Ki ± 0% ~ (p=1.000 n=10) ¹
geomean 1.438Mi 1.438Mi +0.00%
¹ all samples are equal
│ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
│ allocs/op │ allocs/op vs base │
Engine_RuleScaling/rules=1-4 3.651k ± 0% 3.651k ± 0% ~ (p=1.000 n=10) ¹
Engine_RuleScaling/rules=10-4 22.41k ± 0% 22.42k ± 0% ~ (p=0.071 n=10)
Engine_RuleScaling/rules=50-4 105.7k ± 0% 105.7k ± 0% ~ (p=0.137 n=10)
Engine_RuleScaling/rules=200-4 417.9k ± 0% 417.9k ± 0% ~ (p=0.957 n=10)
Engine_InertRules/inert=0-4 57.00 ± 0% 57.00 ± 0% ~ (p=1.000 n=10) ¹
Engine_InertRules/inert=14-4 59.00 ± 0% 59.00 ± 0% ~ (p=1.000 n=10) ¹
Engine_InertRules/inert=100-4 59.00 ± 0% 59.00 ± 0% ~ (p=1.000 n=10) ¹
geomean 2.558k 2.558k +0.00%
¹ all samples are equal
pkg: github.com/bytevet/godzilla/internal/rules
│ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
│ sec/op │ sec/op vs base │
MatchGlob-4 152.1n ± 1% 152.3n ± 2% ~ (p=0.781 n=10)
│ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
│ B/op │ B/op vs base │
MatchGlob-4 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
¹ all samples are equal
│ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
│ allocs/op │ allocs/op vs base │
MatchGlob-4 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
¹ all samples are equal
pkg: github.com/bytevet/godzilla/internal/scan
│ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
│ sec/op │ sec/op vs base │
Scan_Python-4 31.60m ± 0% 31.41m ± 0% -0.58% (p=0.001 n=10)
Scan_JS-4 653.5µ ± 2% 657.4µ ± 2% ~ (p=0.631 n=10)
Scan_Rust-4 45.49m ± 0% 44.89m ± 0% -1.33% (p=0.000 n=10)
Scan_Java-4 497.6m ± 2% 491.3m ± 3% ~ (p=0.315 n=10)
Scan_Ruby-4 77.48m ± 1% 77.29m ± 0% ~ (p=0.436 n=10)
Scan_GoWithDeps-4 2.739 ± 3% 2.769 ± 2% ~ (p=0.684 n=10)
Scan_GoSimple-4 135.2m ± 1% 135.0m ± 1% ~ (p=0.393 n=10)
geomean 75.05m 74.85m -0.27%
│ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
│ B/op │ B/op vs base │
Scan_Python-4 527.2Ki ± 3% 447.9Ki ± 2% -15.04% (p=0.000 n=10)
Scan_JS-4 384.4Ki ± 1% 383.4Ki ± 0% ~ (p=0.247 n=10)
Scan_Rust-4 664.7Ki ± 2% 539.9Ki ± 3% -18.78% (p=0.000 n=10)
Scan_Java-4 1361.5Ki ± 2% 459.3Ki ± 5% -66.27% (p=0.000 n=10)
Scan_Ruby-4 609.6Ki ± 4% 418.1Ki ± 3% -31.41% (p=0.000 n=10)
Scan_GoWithDeps-4 1.441Gi ± 0% 1.442Gi ± 0% ~ (p=0.075 n=10)
Scan_GoSimple-4 8.570Mi ± 0% 8.582Mi ± 0% +0.14% (p=0.000 n=10)
geomean 2.772Mi 2.132Mi -23.07%
│ /tmp/godzilla-qgate.7gE8Xe/bench-base.txt │ /tmp/godzilla-qgate.7gE8Xe/bench-head.txt │
│ allocs/op │ allocs/op vs base │
Scan_Python-4 3.328k ± 4% 2.381k ± 0% -28.46% (p=0.000 n=10)
Scan_JS-4 1.936k ± 0% 1.919k ± 0% -0.85% (p=0.000 n=10)
Scan_Rust-4 3.994k ± 5% 2.625k ± 0% -34.28% (p=0.000 n=10)
Scan_Java-4 12.143k ± 0% 2.000k ± 1% -83.53% (p=0.000 n=10)
Scan_Ruby-4 4.260k ± 2% 2.010k ± 0% -52.80% (p=0.000 n=10)
Scan_GoWithDeps-4 18.86M ± 0% 18.86M ± 0% +0.01% (p=0.000 n=10)
Scan_GoSimple-4 63.78k ± 0% 63.89k ± 0% +0.17% (p=0.000 n=10)
geomean 20.65k 12.86k -37.73%
Gate blocks on a regression that is significant at alpha=0.01 (benchstat marks anything weaker as
~) on: Engine_RuleScaling,Engine_InertRules,MatchGlob,Scan_GoWithDeps,Scan_GoSimple,Scan_Python,Scan_JS,Scan_Rust,Scan_Java,Scan_Ruby — timesec/op> 10%, memoryB/op/allocs/op> 10%. The strict alpha keeps subprocess/GC run-to-run noise on the heavier scans from tripping the gate.
Both revisions were built and benchmarked back-to-back on this runner; numbers are only comparable within a single run.
…fault A decorator on a PARAMETER does not parse without TypeScript's legacy decorators, and that loses the WHOLE file -- NestJS and Angular are built out of them. It cost nocodb 58 of 1469 files, all controllers, strategies and filters; n8n the same shape. Decorators on classes, methods and properties parse without the flag, so only the parameter form is affected. esbuild-jsast now exposes the flag (its Options doc carries the finding that motivated the rung): setting it LOWERS every experimental decorator in the file, not only the parameter ones -- Class.Decorators comes back empty and the decorators reappear as __decorateClass/__decorateParam calls after the class. Set always, that would trade the as-written tree of every decorated file for the few that cannot parse otherwise. Reached only after the dialect ladder has failed, the lowered shape is confined to files that would contribute nothing at all. The pair of tests is the point. One pins that a parameter decorator now parses; the other pins that a class/method/property-decorated file is NOT lowered, which is what keeps the rung confined -- if that one starts reporting __decorate* calls, every decorated file has begun paying for the few that need it. Both read the lowered IR, since the __decorate* calls are how the flag is observable from outside. nocodb: PARTIAL(1411/1469) -> ok, 1 finding before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PCaxUp2Vi5PiATCTQuxSci
A package re-exporting another's function as a variable -- `var Params = macaron.Params`, how grafana's pkg/web surfaces macaron -- turns every call through it into an INDIRECT call: SSA loads the global and calls the loaded value, so there is no static callee and convertCall left the name EMPTY. An unnamed callee matches no source, sink or propagator glob, so the call was invisible to every rule. grafana CVE-2021-43798 reaches os.Open through exactly that alias, and a minimal repro of the shape confirmed it: the direct call fires, the aliased one does not. collectFuncAliases maps such a variable to its function, and convertCall names the call after it. Semantic, like every other Go callee -- the FQN of the function it resolves to, not the variable the source wrote -- so _go-common.yaml now also names macaron's own spelling of the accessor; both are needed, since a project may call it either way. The guard is what keeps this sound: only a variable with EXACTLY ONE store program-wide is mapped, and only when that store's value is a function. Counting every store rather than only the function-valued ones is the load-bearing part -- a var later reassigned to a mock or to nil has no single answer at the call site, and must stay unresolved rather than resolve to whichever value was seen first. The sample's third case is that control, and max: 2 fails if it ever starts resolving. Two notes for whoever touches this next. worker() is a hand-maintained list of the whole-program results each lowering goroutine needs, and a field left out of it is silently EMPTY rather than nil-panicking -- the analysis just stops having an effect, which is how this landed inert the first time. And gogs, a real macaron consumer, turns out to scan NONDETERMINISTICALLY: the same binary on the same tree reported 127, 117 and 125 findings across three runs, so the effect here was measured by set-comparing repeated JSON runs rather than by counts. Zero stable gains, zero stable losses -- the point being no false positives, since the repro and sample carry the recall claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PCaxUp2Vi5PiATCTQuxSci
The sink half of the cells model already landed -- a cells template is compiled by Erbse, which has no SafeBuffer, so `<%= %>` there is a raw sink. This is the source half: `options[...]` inside a cell, the arguments its CALLER passed. Decidim CVE-2024-41673 is a request value handed in that way and interpolated unescaped. The frontend emits it the way params already works -- a synthetic source CALL from lowerAref -- but scoped two ways, because unlike params a cell option is NOT untrusted by construction. It is emitted only under a cells directory, where `options` has exactly one meaning, and named `ruby:@cell.options` so it can never collide with a real method of that name. isCellsPath is now the single predicate behind both halves of the model, so the sink and the source cannot come to disagree about what a cell is. The rule ships at severity: low, which is the whole design. A cell is invoked from a controller or another view, so its argument may be request-derived or entirely internal and the frontend cannot tell which. Low keeps these advisory under the default `-fail-on medium` gate while still reporting them -- the trade py-insecure-config already makes -- and a cross-function flow lands at Medium confidence, inside the LLM reviewer's range. Raising it is a gate change, not a tuning knob. Measured, and it corrected the estimate that had blocked this. The fear was a flood: decidim interpolates in 325 cell templates, and an earlier count of 675 interpolation SITES was read as if it predicted findings. Scanning the whole of decidim at the vulnerable commit yields exactly ONE cell-option finding -- a site is not a flow, and most cells never route an option into raw output. The first draft did flood, for an unrelated reason worth recording: it carried `extend: $_ruby-sources.yaml`, whose ordinary request sources made it a duplicate of ruby-xss on every `raw(params[:x])` -- six corpus false positives, in files that are not cells at all. The corpus precision floor caught it. The rule now declares its one source and nothing else, and the sample's negatives pin the scoping: html_escape neutralizes, a model read is not sourced, and `options` in app/helpers must stay inert. This does NOT flip the CVE. Its flow crosses from the cell class into the cell's own template, and a template's bare `<%= label %>` is a separate module with nothing linking it to the class -- same file fires, split across .rb and .erb does not. That is cross-module cell/template linkage, a third gap, neither source nor sink. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PCaxUp2Vi5PiATCTQuxSci
A cell's class and its template are separate files, so the flow that matters crosses a module boundary. A template's `<%= label %>` is a bare name in its OWN module with no def of that name, so it lowered to an inert free identifier and the two halves of a cell never met -- source in the class, unescaped sink in the template, and nothing in between. Resolved the way the JS frontend already resolves a cross-module default import: the template emits a MARKER callee and a PostProgram pass rewrites it once every file is lowered, since the class may not be parsed yet. The pairing is the cells convention -- templates live in a directory named after the cell and the class sits BESIDE that directory with a `_cell` suffix, so `app/cells/foo/show` is rendered by `app/cells/foo_cell`. A name the class does not define (a Rails helper, something from an included module) and an AMBIGUOUS name in a file holding several cell classes both strip back to the plain bare name, so they can still match a rule glob instead of dangling. Also carries `t`/`translate`, since a translation contains what it interpolates and the flow runs through one. Scope, measured rather than assumed: this does NOT flip decidim CVE-2024-41673, and the reason is worth recording because it is not a gap. The CVE interpolates via `t("key", current: index)`, and a hash argument is lowered pairwise with the CONTAINER deliberately left untainted -- tainting it would fire on every `where(name: params[:q])`, ActiveRecord's hash form being parameterized by construction. `t(x)` flows and `t("k", name: x)` does not, verified as a pair. Interpolating a translation and parameterizing a query are the same shape to the frontend; separating them needs per-key taint, not another propagator, and flipping that tradeoff belongs with the ActiveRecord measurement it was made on. The sample's negatives are the scoping: an identical `<%= title %>` under app/views must stay inert, since an ActionView template escapes and has no paired cell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PCaxUp2Vi5PiATCTQuxSci
The quality gate failed this PR on Scan_Java B/op +34.79% and allocs/op +43.08%, against a Ruby-only commit that cannot touch the Java frontend. The benchmark is bimodal on a SINGLE build, no base/head involved -- ten runs of the unchanged tree: b.N=1 ~3.3-3.6 MB/op ~33k allocs/op (2 runs) b.N=2 ~1.86-1.91 MB/op ~17.4k allocs/op (8 runs) Per-op cost halves as b.N doubles, which is a fixed once-per-process cost being amortized: the Java frontend compiles its dump helper and caches it, and each subprocess frontend memoizes its toolchain probe. A scan taking ~0.7s per op never gets past b.N=1 or 2, so which value it lands on is decided by wall-clock timing rather than by the code -- and benchstat compares two revisions that happened to land differently. The gate's own ±35% / ±42% variance on those samples is the artifact showing through. Scanning once outside the loop moves that cost where it belongs. Java now measures 462-512 KB/op (±5%) and 1,986-2,019 allocs/op (±1%), so ~15k of the 17k "allocations per scan" were process setup counted once and divided. Absolute numbers drop for every per-language benchmark, which is a measurement change and not a speedup -- steady-state scan cost is what they now report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rao1GBomZ53fL2ydytD96r
The rust position golden wanted rewriting on every regeneration, differently depending on whether cargo had run. Chasing that turned up the actual defect: a cargo build runs rustc IN the crate directory, so its MIR spans are crate-relative, and span() stored them verbatim. A finding reported at "src/lib.rs:17" names no particular file -- every crate in a workspace has one. srclines cannot open it for a snippet, SARIF cannot annotate it, and underRoot resolved it with filepath.Abs, i.e. against the PROCESS cwd, so whether a span counted as inside the scanned tree depended on where the scan was launched from. Resolving against the crate directory the module was lowered with fixes all of it, and makes the built path agree with the source-lowered one, which reports absolute paths. That agreement is what the golden needed: the corpus now passes both with and without GODZILLA_ALLOW_BUILD, where before it could only match the machine it was generated on. Two rows that had been pinned as `src=-|sink=-` -- no position at all -- now carry real ones. The three remaining `-` rows are dependency spans outside the crate, which the sysroot filter rejects by design and identically either way. Found alongside it, in the same area: main.go called buildpolicy.SetAllowed unconditionally, so the -allow-build flag's FALSE default unset GODZILLA_ALLOW_BUILD -- the environment variable that flag's own help text offers as the alternative could never take effect through the CLI. Only an explicitly passed flag decides the policy now. The regression tests pin both halves, since they pull in opposite directions: a crate-relative span must be joined onto the crate dir, and an absolute sysroot span (everything expanded from format!) must still be rejected rather than joined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PCaxUp2Vi5PiATCTQuxSci
A scan of the same tree returned a different finding count on each run --
127/117/117 on gogs, and 5..21 for go-command-injection alone with the IR,
the scope and the rule all held fixed. Two independent causes, both map
iteration order reaching a first-seen-wins decision:
- buildMethodImpls, buildCallers and buildGlobalReaders each build their
name lists by ranging over a map, so the worklist visited functions in
a per-run order. Every summary channel is first-seen-wins, so that
order decides which origin a callee's summary keeps. Sort them.
- stringParamOrigin picked ONE parameter out of `seeds` with a `break` on
first match. Several parameters share an origin whenever a call site
passed the same tainted value twice, and which one won was random --
sometimes a string parameter (summarize the wrapper), sometimes the
receiver (drop it). Return every string match instead: the engine
cannot tell which parameter carried the value to the sink, so
summarizing one silently drops a caller that taints another.
Net effect on gogs: 134 findings, byte-identical across runs, +6 over the
union of three pre-fix runs and none lost. The added ones are real: a route
param reaching git-module's BranchCommitID/TagCommitID, whose wrapper
previously had only one of its parameters summarized. Corpus signal/noise
is unchanged at precision 1.000 / recall 1.000 over 325 samples.
Tests pin the sortedness of all three indexes (with a guard against the
assertion going vacuous) and the every-match contract of
stringParamOrigins, because losing either shows up as a flaky number
rather than a failure.
react-xss has fired ZERO times across all 61 applications in the CVE-recall campaign. Its sink is right -- dangerouslySetInnerHTML, via the synthetic js:__godzilla_react_html -- but its only sources were three react-router hooks and the shared browser globals, so no value a component was HANDED could ever reach it. Ghost CVE-2026-24778 is the shape it was missing: `this.context.site.portal_signup_terms_html` rendered as raw HTML. Two halves: - A capital-initial function's destructured first parameter now binds each property to a `js:props.<key>` read, reusing the trick COV-11 uses for a destructured request handler. `function C({html})` is the dominant React idiom and it lost the prop entirely: the pattern binds no identifier, so the parameter took an `_arg0` name and `html` read as an unbound global. The predicate is React's own dispatch rule -- JSX compiles a lowercase tag to a host-element string, so only a capital-initial function can be a component. Deciding by "first parameter is destructured" instead would have downgraded a destructured `{req}` handler from a request source to a plain local. - A new rule, react-xss-props, at severity: low. The `this.props`/`this.context` forms already lowered to matchable callees and needed only the globs. It is split out rather than added to react-xss for the reason ruby-xss-cell-option is split: a prop is not request input by construction, so seeding it inside the gating rule would put every component that renders its own argument into the default -fail-on medium gate. It carries no `extend:` -- pulling in the browser-source fragment is what cost ruby-xss-cell-option six corpus false positives. Also drops ghost's `subdir: ghost` from the campaign's projects.yaml. Ghost is a monorepo and that scoped the scan to its core package, excluding apps/portal -- the tree this CVE's own ground-truth files live in. The entry could not score no matter what the engine found. Measured on Ghost at the vulnerable ref: 5 findings, all 5 in ground-truth files, landing on signup-page.js:560 -- the exact vulnerable line -- plus offer-page and the three accent_color injections the same commit fixed. The CVE goes class-only -> HIT. Corpus holds at precision 1.000 / recall 1.000, TP 239 -> 240 over 327 samples.
Ruby erased every hash: a keyword list lowered its pairs for side effects and returned the constant "", so `t(key, name: tainted)` dropped the value and a `**splat` was not lowered at all -- assocPairs kept only assoc_new. Python has carried keyword taint through builtin.kwarg since it was written; Ruby simply never opted in. decidim CVE-2024-41673 is the shape, and ruby-xss-cell-option's own comment already named this as the reason it could describe the CVE without detecting it. The frontend now appends one builtin.kwarg marker per pair AFTER every positional argument, and lowers a `**` splat as a plain trailing value. No engine or gIR change: builtin.kwarg is already an intrinsic propagator and already unwrapped into Arg.Name for rule guards. The hash keeps its inert placeholder in the positional slot, and that is load-bearing rather than tidy. A rule pins its injection point by logical index, and `User.where(name: params[:q])` puts its hash at exactly the index `ruby:*.where#1` names -- let a tainted value occupy the slot and every parameterized ActiveRecord query becomes an injection finding. Appending is the same choice the Python frontend makes for its splat markers. On the callee side, paramNames grows a matching inert `@kwargs` slot so the appended markers land on the keyword parameters rather than one slot early; the alignment is by order and only holds when the call passes every declared positional, which the comment says plainly. Ruby sinks were almost all unpinned, and an unpinned sink treats every argument as an injection point -- so keyword taint turned six ordinary Rails idioms into findings. Each gets the pin it should always have had: redirect_to/redirect #0 (a flash `notice:` is not the destination), send_file/send_data #0 (`filename:` is a Content-Disposition value, not a path), and the SSRF sinks pin their URL, where the `not hostFixed()` guard actively made it worse by reading a tainted keyword as a controllable host. `render` gets a per-sink `when:` naming html/inline/plain/text/body, which separates real HTML output from `locals:` (auto-escaped) and `json:`. That last one is also a detection gained: `render html: params[:q]` is what ruby-xss's sink comment always described and could never reach. Measured: decidim CVE-2024-41673 goes class-only -> HIT. On the decidim tree the only rule that moves is ruby-xss-cell-option, +6 (22 -> 28), all severity: low so none gate, and all the same cell-option-through-a-translation shape as the CVE. No other rule changes on a large Rails app. Corpus holds at precision 1.000 / recall 1.000, FP=0 FN=0 over 329 samples.
Quality pass over the previous two commits. No intended behavior change, with
one exception noted below.
- builtin.kwarg's two-channel contract (Operands feeds the taint propagator,
Call.Args feeds unwrapKwarg) was written out in both the Python and Ruby
frontends. Moved to ssabuild.SetKwargMarker so the pairing -- whose failure
mode is silent taint loss -- is stated once.
- The JS collector carried a `components` map mirroring `handlers`. The two are
not the same kind of fact: `handlers` records something only the route
registration elsewhere in the AST knows, while component-ness is a pure
function of the function's own name, which pendingFunc already holds. Derived
at the use site instead, dropping the map and four plumbing sites. The
predicate moves next to the rest of the React model in lower.go and uses the
byte-range form the repo already uses for first-character checks.
- Ruby: `pairParamNames` replaces two copies of the same [ident, default] walk;
`symbolText`/`labelName` replace three spellings of the same unwrap. That
third one was not cosmetic -- assocKeyName read a symbol key one level too
shallow, so `render :html => params[:q]` produced a nameless marker the new
kwargs guard could not see. The hashrocket spelling now fires.
- Ruby: a literal keyword key was lowered only to discard the result, which
allocates a constant nothing reads; only a computed key needs it now.
appendArgList appends into a caller-supplied slice, so lowerDotCall builds
its argument list in one exact-sized allocation instead of three.
- A `**splat` inside a hash in VALUE position is now lowered like one in
argument position; the two paths had silently diverged.
- Extracted _js-html-sanitizers.yaml. react-xss-props had inlined the same five
globs _js-sfc-xss.yaml carries, and a sanitizer known to one raw-HTML rule but
not another is a false positive on the same file in the same scan.
- Comments: the pin-vs-keyword rationale was stated in five places and the Ruby
receiver-offset convention in three. Each rulepack now points at the sample
that pins it, and the convention -- including why a pin cannot name a keyword
-- is documented once in docs/writing-rules.md.
Corpus unchanged at precision 1.000 / recall 1.000, FP=0 FN=0 over 329 samples.
Not taken, all three genuine and all larger than a cleanup: binding a kwarg
marker to a callee parameter BY NAME in the engine's handleCall (which would
retire the @kwargs placeholder and close the known misalignment when a call omits
an optional positional); setting CallCommon.MethodName in the Ruby frontend so
its sink indices match the documented receiver-excluded convention (it also feeds
CHA, so it is a behavior change); and extending the sink spec to name a keyword
injection point directly, which is a Rule-model change and needs sign-off.
Asking whether the JavaScript rebuild generalized turned up six live defects in the other frontends. All were reproduced against the working tree before being written down. Two further fixes came from the real-world CVE campaign, which now scores 18/21 (85.7%) on scorable ground truth, up from 15/21.
The frontend defects share one root cause: the JS frontend was the only one whose "I could not model this" path was guarded. CLAUDE.md states the convention — an unmodelled node yields a fallback marker and converter tests fail if one appears — and it was enforced for one frontend out of seven.
Skipped()was asserted only for JS. Everywhere else, a construct the lowering did not understand cost findings with no error, no marker, and no failing test.Ordering matters: the shared guard is what makes every other fix verifiable, so each known fallback is removed before the guard that would trip on it.
What changed
User.where(name: params[:q])became oneruby.unsupportedand the argument disappearedtestsupport.RequireNoFallbackIntrinsic, called whole-tree from js/python/ruby/rust withSkipped()pinned alongsidedefinside control flow was claimed by nobody — the collectors entered only def/class bodies while the lowering descends intoif/while/for/with/try/rescue/blocks and skips defs, trusting the collectorclass << selfappeared in no switch at all, so a singleton body was dropped wholesclasshandled; a def inside one is named and shaped likedef self.m— class-qualified canonical name, receiver in parameter slot 0rust.unsupportedintrinsic, with the corpus guarded crate by crate so cargo resolves each crate's depssql_injectiontaint path were pinned at/rustc//library/alloc/src/macros.rs, which does not exist on the scanning machineoff − start + 1and Go's tokeniser is 1-basedcol + 1, checked against the actual text at the reported positionrequireJavachecked thatjavawas onPATHwhile the JDK 24 floor is enforced insideConvertFile, so an old JDK passed the guard and then failed every testformat!placeholder ({:>10}) failed the whole template decode, leaving an EMPTY template — which reads downstream as "this format string contains no host"That last one is the sharpest: adding a width specifier to provably safe code produced a high-confidence CWE-918 false positive that the same code with a plain
{}does not.test/rust/ssrf_safe_format_specis a new sample that fails on the pre-fix tree.Two gaps the CVE campaign found
TypeScript parameter decorators did not parse, which loses the WHOLE file — NestJS and Angular are built out of them. It cost nocodb 58 of 1469 files and 190 of 5271 in another revision, all controllers, strategies and filters; n8n the same shape. Decorators on classes, methods and properties parse fine, so only the parameter form is affected.
esbuild-jsastnow exposes the flag, and itsOptionsdoc carries the finding that shaped the fix: setting it lowers every experimental decorator in the file, not only the parameter ones —Class.Decoratorscomes back empty and they reappear as__decorateClass/__decorateParamcalls. Set always, that would trade the as-written tree of every decorated file for the few that cannot parse otherwise. So it is a ladder rung, reached only after the dialect ladder fails, which confines the lowered shape to files that would contribute nothing at all.The pair of tests is the point. One pins that a parameter decorator parses; the other pins that a class/method/property-decorated file is not lowered — if that ever starts reporting
__decorate*, every decorated file has begun paying for the few that need it.Measured: nocodb
PARTIAL(1411/1469)→okandPARTIAL(5081/5271)→ok, n8n three revisions to within 3 files, all with findings unchanged. Six repos reached full JS coverage.A call through a function-holding package variable had no callee name. A package re-exporting another's function as a variable —
var Params = macaron.Params, how grafana'spkg/websurfaces macaron — makes every call through it indirect: SSA loads the global and calls the loaded value, so there is no static callee andconvertCallleft the name empty. An unnamed callee matches no source, sink or propagator glob, so the call was invisible to every rule.collectFuncAliasesmaps such a variable to its function and the call is named after it — semantically, like every other Go callee, so_go-common.yamlalso names macaron's own spelling of the accessor. The guard is what keeps it sound: only a variable with exactly one store program-wide is mapped, and only when that store's value is a function. Counting every store rather than only the function-valued ones is load-bearing — a var later reassigned to a mock or to nil has no single answer at the call site and must stay unresolved. The sample's third case is that control.This flipped grafana CVE-2021-43798 from class-only to a hit (
go-path-traversalatpkg/api/plugins.go:303), the flow that had been traced by hand and set aside as a separate engine gap. Path-traversal is now 7/8.Verification
Every fix has a gate that was confirmed to fail without it — by reverting the change and watching the test go red, not by trusting green.
Findings are byte-identical across the Python, Ruby and Rust corpora (98 / 33 / 12, per-sample-dir scans matching how
test/corpusdrivesscan.Scan). The JS golden rows are unchanged; the one extracrossfile_interprocrow appears only because the golden now scans per sample dir, as the corpus does.Java subtests skip locally (JDK 21) and run in CI.
Three verification hazards found on the way
Generating the goldens turned up two defects in
TestRegenerateManifestsof the same shape as the ones being fixed — a helper whose output cannot tell "nothing found" from "did not run". Both are fixed here:Coveragetells them apart, and a golden write skips such a sample too.max/line/sinkin a manifest are hand-written — nothing in scan output implies them — and regenerating deleted them. They are now carried over for every rule that still fires.The third is new, and it bit rather than being theoretical: regenerating still discards hand-written prose, and running
-run RegenerateManifeststo update one position golden rewrote 227expected.yamlfiles, stripping their rationale comments.TestRegeneratePositionGoldensis the correct target and updates only the goldens — the two are deliberately separate. Reverted, not committed. Left alone as a separate change, but the split is easy to miss and costs the reviewer nothing to know.Explicitly not done
rbdump.rband its consumer; the Ruby fixes here do not need it.rustc_publichelper binary. The principled fix for the MIR text boundary, but it needs nightly and a rebuild per user toolchain.decodeFmtTemplate. The failure is made local rather than total instead.worker()'s field list structural. It is a hand-maintained list of the whole-program results each lowering goroutine needs, and a field left out is silently empty rather than nil-panicking — which is how the alias map landed inert the first time. Documented on the function; folding the shared fields into one copied struct would prevent recurrence but touches many call sites.Determinism, and two gaps the campaign found (later commits)
The nondeterminism recorded above as an unrelated observation turned out to be two engine defects, both now fixed — so the count-based verification hazard it described is gone.
analysis: make a scan's findings deterministicThe same binary on the same tree reported 127/117/117 findings on gogs. With the IR, the scope and the rule all held fixed, one rule swung
[5 12 12 14 15 13 15 5]across eight runs. Two independent causes, both map-iteration order reaching a first-seen-wins decision:buildMethodImpls,buildCallersandbuildGlobalReadersbuilt their name lists by ranging over a map, so the worklist visited functions in a different order each run. Every summary channel is first-seen-wins, so that order decides which origin a callee's summary keeps. Now sorted at construction.stringParamOriginpicked one parameter withbreakon the first map match. Several parameters share an origin whenever a call site passes the same tainted value twice, and which index won was random — sometimes a string parameter (summarize the wrapper), sometimes the receiver (drop it). It now returns every string match: the engine cannot tell which parameter carried the value to the sink, so summarizing one silently drops a caller that taints another.Localized by tracing every worklist enqueue with its originating channel and diffing runs, not by inspection. After sorting the first cause, traces matched for 46,241 lines and the next divergence was an addition, which led to the
break.A full CLI scan of gogs is now byte-identical across five runs at 134 findings. +6 findings versus the union of three pre-fix runs, 0 lost; the added ones are real (a route-derived
refreaching git-module'sBranchCommitID/TagCommitID). Tests pin the sortedness of all three indexes, with a guard so that assertion cannot go vacuous.js: source a React component's props and contextreact-xsshad fired zero times across all 61 campaign applications. The sink was right; the sources were three react-router hooks and the browser globals, so no value a component was handed could reach it.function C({html})lost the prop entirely — the pattern binds no identifier, so the parameter took an_arg0name andhtmlread as an unbound global. A capital-initial function's destructured first parameter now binds each property tojs:props.<key>, reusing the trick COV-11 uses for destructured request handlers. The predicate is React's own dispatch rule: JSX compiles a lowercase tag to a host-element string. Deciding by "first parameter is destructured" would have been a regression, not just noise — it rebinds a destructured{req}handler to a local and loses a real request source.react-xss-props, a new rule atseverity: low. Thethis.props/this.contextforms already lowered to matchable callees and needed only globs. Split out rather than folded intoreact-xssfor the reasonruby-xss-cell-optionis split: a prop is not request input by construction. It carries noextend:— inheriting the browser-source fragment is what costruby-xss-cell-optionsix corpus false positives.Also drops ghost's
subdir: ghostfrom the campaign'sprojects.yaml. Ghost is a monorepo and that scoped the scan to its core package, excludingapps/portal— the tree this CVE's own ground-truth files live in. The entry could not score no matter what the engine found; the rule change alone was not enough.ghost CVE-2026-24778: class-only → HIT. 5 findings on the vulnerable tree, all in ground-truth files, landing on
signup-page.js:560— the exact vulnerable line.ruby: carry taint through keyword argumentsRuby erased every hash: a keyword list lowered its pairs for side effects and returned
"", and a**splatwas not lowered at all. Python has carried keyword taint throughbuiltin.kwargsince it was written; Ruby never opted in.ruby-xss-cell-option's own comment already named this as why it could describe decidim CVE-2024-41673 without detecting it.The frontend now appends one
builtin.kwargmarker per pair after every positional argument. No engine or gIR change —builtin.kwargis already an intrinsic propagator and already unwrapped intoArg.Name.The hash keeps its inert placeholder in the positional slot, and that is load-bearing: a rule pins its injection point by logical index, and
User.where(name: params[:q])puts its hash at exactly the indexruby:*.where#1names. Let a tainted value occupy the slot and every parameterized ActiveRecord query becomes a finding. On the callee sideparamNamesgrows a matching inert@kwargsslot so markers land on the keyword parameters rather than one slot early.Ruby sinks were almost all unpinned, and an unpinned sink treats every argument as an injection point — so keyword taint turned six ordinary Rails idioms into findings. Each got the pin it should always have had:
redirect_to/redirect#0(a flashnotice:is not the destination),send_file/send_data#0(filename:is a Content-Disposition value), and the SSRF sinks pin their URL, where thenot hostFixed()guard actively made it worse by reading a tainted keyword as a controllable host.rendergets a per-sinkwhen:naming html/inline/plain/text/body.That last one is also a detection gained:
render html: params[:q]is whatruby-xss's sink comment always described and could never reach.decidim CVE-2024-41673: class-only → HIT. On the decidim tree the only rule that moves is
ruby-xss-cell-option, +6 (22 → 28), allseverity: lowso none gate, all the same cell-option-through-a-translation shape as the CVE. No other rule changes on a large Rails app.cleanup: dedupe the kwarg marker, derive the component predicateA quality pass over the two commits above.
builtin.kwarg's two-channel contract moved tossabuild.SetKwargMarkerso the pairing — whose failure mode is silent taint loss — is stated once instead of in both frontends. The JScomponentsmap went away: unlikehandlers, component-ness is a pure function of a namependingFuncalready holds. Ruby lost two copies of the same parameter walk and three spellings of the same unwrap — that third was not cosmetic,assocKeyNameread a symbol key one level too shallow, sorender :html => params[:q]produced a nameless marker the new guard could not see. Extracted_js-html-sanitizers.yaml, since a sanitizer known to one raw-HTML rule but not another is a false positive on the same file in the same scan. The pin-vs-keyword rationale had been stated in five places and the Ruby receiver-offset convention in three; each rulepack now points at the sample that pins it, and the convention is documented once indocs/writing-rules.md.Not taken, all three genuine and all larger than a cleanup: binding a kwarg marker to a callee parameter by name in
handleCall(which would retire the@kwargsplaceholder and close the known misalignment when a call omits an optional positional); settingCallCommon.MethodNamein the Ruby frontend so its sink indices match the documented receiver-excluded convention (it also feeds CHA, so it is a behavior change); and extending the sink spec to name a keyword injection point directly, which is a Rule-model change and needs sign-off.Campaign result
Re-run on a fresh live fetch of 61 OSV.dev advisories, all 61 scanned:
Scorable recall 16/21 → 18/21, both flips from the work above. One further change, verified against a pre-change binary rather than assumed: mlflow CVE-2024-27133 moved miss → class-only because
react-xss-propsfires on mlflow's own React UI (EditableNote.tsx, a prop reachingdangerouslySetInnerHTML) — the new rule working in a third application, just not in that CVE's ground-truth file.The single remaining scorable miss is directus CVE-2025-55746, and the two class-onlys are nocodb and mlflow. All three take their untrusted value as an ordinary function parameter rather than a component prop or a framework accessor, which is a different gap from either fixed here.
Corpus holds at precision 1.000 / recall 1.000, FP=0 FN=0 over 329 samples.
make gate,go test ./...andmake gate-llvmgreen.