From 3e0599a9499bffc9dbbb68aa3025e8983058902b Mon Sep 17 00:00:00 2001 From: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:59:03 +0800 Subject: [PATCH] fix(javascript): preserve loader identity across terminal origins (#9107) Exclude only proven separate values from Webpack loader alias walks; preserve unsupported, projected and unresolved origins as unknown and retain the real alias-state bound. Refs #7253. The full Bitwarden precision regression remains open. Validation: 336 focused tests and check:fast passed; real Bitwarden preload identity retained; full main/preload completed with disclosed partial coverage. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- .../electronBundledRequireIdentity.test.ts | 123 ++++++++++++++++++ .../javascriptBundledRequireOrigin.ts | 37 ++++-- .../javascriptBundlerRuntimeScopes.test.ts | 46 +++++++ 3 files changed, 193 insertions(+), 13 deletions(-) diff --git a/src/domain/javascript/electronBundledRequireIdentity.test.ts b/src/domain/javascript/electronBundledRequireIdentity.test.ts index d2f10ef81f..c30a34a2c1 100644 --- a/src/domain/javascript/electronBundledRequireIdentity.test.ts +++ b/src/domain/javascript/electronBundledRequireIdentity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { runInNewContext } from "node:vm"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; @@ -181,6 +182,128 @@ describe("Electron identity through Webpack externals", () => { }); }); +describe("Webpack loader alias bounds", () => { + const getterSource = (initializers: readonly string[], selection = "") => + bootstrap( + EXTERNAL, + 'const s=n.n(n(45840));new(s().BrowserWindow)({});s().ipcMain.handle("ready",()=>{});', + `let box={};${initializers.map((value) => `if(flag)box=${value};`).join("")}${selection}box.n=()=>0;n(100);`, + ); + + it.each([false, true])( + "preserves only proven Electron identity beside a large object union (loader=%s)", + (includesLoader) => { + const source = getterSource([ + ...Array(520).fill("{}"), + ...(includesLoader ? ["n"] : []), + ]); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toHaveLength( + includesLoader ? 0 : 1, + ); + expect(analysis.electron.ipc).toHaveLength(includesLoader ? 0 : 1); + if (!includesLoader) + expect(analysis.analysis_coverage).toMatchObject({ + examination: { + semantic_resource_limits: expect.not.arrayContaining([ + "alias-selections", + ]), + }, + }); + }, + ); + + it.each( + [1, 520].flatMap((count) => + [ + "flag?n:{}", + "flag&&n", + "flag||n", + "(0,n)", + "getLoader()", + "new GetLoader()", + "holder.loader", + "unresolvedLoader", + ].map((value) => ({ count, value })), + ), + )( + "keeps $count possible loader values through $value unknown", + ({ count, value }) => { + const analysis = analyzeJavaScriptStaticSource( + getterSource(Array(count).fill(value)), + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toEqual([]); + expect(analysis.electron.ipc).toEqual([]); + }, + ); + + it.each(["[]", "()=>0", "class {}", "0", "'text'"])( + "retains the getter when terminal origins are %s", + (value) => { + const analysis = analyzeJavaScriptStaticSource( + getterSource(Array(520).fill(value)), + ); + expect(analysis.electron.browser_windows).toHaveLength(1); + expect(analysis.electron.ipc).toHaveLength(1); + }, + ); + + it.each([1, 520])("keeps %s projected loader writes unknown", (count) => { + const analysis = analyzeJavaScriptStaticSource( + getterSource([], "if(flag)({slot:box}={slot:n});".repeat(count)), + ); + expect(analysis.electron.browser_windows).toEqual([]); + expect(analysis.electron.ipc).toEqual([]); + }); + + it.each(["conditional", "projected"])( + "observes no runtime window after a %s alias replaces the getter", + (kind) => { + let windows = 0; + const source = + kind === "conditional" + ? getterSource(Array(520).fill("flag?n:{}")) + : getterSource([], "if(flag)({slot:box}={slot:n});"); + expect(() => + runInNewContext(source, { + flag: true, + require: () => ({ + BrowserWindow: class { + constructor() { + windows += 1; + } + }, + ipcMain: { handle() {} }, + }), + }), + ).toThrow(/is not a function/u); + expect(windows).toBe(0); + }, + ); + + it("keeps a limited loader-alias walk unknown", () => { + const aliases = Array.from( + { length: 520 }, + (_, index) => `const box${index + 1}=box${index};`, + ).join(""); + const analysis = analyzeJavaScriptStaticSource( + bootstrap( + EXTERNAL, + "const s=n.n(n(45840));new(s().BrowserWindow)({});", + `const box0={};${aliases}box520.n=()=>0;n(100);`, + ), + ); + expect(analysis.electron.browser_windows).toEqual([]); + expect(analysis.analysis_coverage).toMatchObject({ + examination: { + semantic_resource_limits: expect.arrayContaining(["alias-selections"]), + }, + }); + }); +}); + describe("Webpack externals with a tampered runtime", () => { it.each([ [ diff --git a/src/domain/javascript/javascriptBundledRequireOrigin.ts b/src/domain/javascript/javascriptBundledRequireOrigin.ts index 0e7db5e856..9a69f44dfb 100644 --- a/src/domain/javascript/javascriptBundledRequireOrigin.ts +++ b/src/domain/javascript/javascriptBundledRequireOrigin.ts @@ -6,6 +6,7 @@ import { proveJavaScriptBootstrapIntegrity } from "./javascriptBootstrapIntegrit import { semanticMemberChain, semanticStaticPropertyKey, + readExactJavaScriptLiteral, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { collectJavaScriptIdentityWritesSteps } from "./javascriptElectronMemberWrites.js"; @@ -159,32 +160,42 @@ const canonicalWebpackInteropBindings = ( // The shared identity-write owner covers assignment, delete, destructuring, // and Object/Reflect mutators; any write that may select `n` counts. const writes = new Set(); + const interop = createSemanticModuleInterop(state); + const excludesLoader = (node: t.Node): boolean => + interop.isFreshObject(node) || readExactJavaScriptLiteral(node).found; const loaderMayOwnReference = (root: t.Node): boolean => { - const pending = [root]; - const seen = new Set(); + const first = unwrapJavaScriptExpression(root).node; + if (!t.isIdentifier(first)) return !excludesLoader(first); + // Only proven separate values are terminals. An unsupported expression + // might return the loader, so preserve that uncertainty instead of dropping it. + const pending = [first]; + const seen = new Set(); while (pending.length > 0) { - const reference = pending.pop(); - if (reference === undefined || seen.has(reference)) continue; - seen.add(reference); + const node = pending.pop(); + if (node === undefined || seen.has(node)) continue; + seen.add(node); if (seen.size > SEMANTIC_ALIAS_SELECTION_LIMIT) { state.valueResourceLimits.add("alias-selections"); return true; } - const node = unwrapJavaScriptExpression(reference).node; - if (!t.isIdentifier(node)) continue; const binding = resolveSemanticBindingState(state, node, node.name); - if (binding === undefined) continue; + if (binding === undefined) return true; if (loaderBindingIds.has(binding.bindingId)) return true; const selected = semanticBindingAtRead(binding, node, state).binding; - for (const source of [ - ...selected.initializers, - ...selected.referenceInitializers, + for (const initializers of [ + selected.initializers, + selected.referenceInitializers, ]) - if (source.projection.length === 0) pending.push(source.node); + for (const source of initializers) { + // A fresh container does not establish the selected property's value. + if (source.projection.length !== 0) return true; + const next = unwrapJavaScriptExpression(source.node).node; + if (t.isIdentifier(next)) pending.push(next); + else if (!excludesLoader(next)) return true; + } } return false; }; - const interop = createSemanticModuleInterop(state); completeSemanticSteps( collectJavaScriptIdentityWritesSteps( state.program, diff --git a/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts b/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts index 2ad1529870..0bc0547d3a 100644 --- a/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts +++ b/tests/boundary/cli/javascriptBundlerRuntimeScopes.test.ts @@ -26,6 +26,52 @@ const runtimeSource = ( const appA = runtimeSource("webpackChunkAppA", "a"); const appB = runtimeSource("webpackChunkAppB", "b"); +for (const includesLoader of [false, true]) { + cliTest( + `preserves only proven Webpack Electron facts (conditional loader=${includesLoader})`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-webpack-loader-union-"); + const assignments = Array.from( + { length: 520 }, + () => `if(flag)box=${includesLoader ? "flag?load:{}" : "{}"};`, + ).join(""); + await writeFixtureFiles(root, { + "main.cjs": `(()=>{ + var modules={ + 1:module=>{module.exports=require("electron")}, + 2:(module,exports,load)=>{ + const electron=load.n(load(1)); + new(electron().BrowserWindow)({}); + electron().ipcMain.handle("ready",()=>{}); + } + },cache={}; + function load(id){ + var found=cache[id];if(found!==undefined)return found.exports; + var module=cache[id]={exports:{}}; + return modules[id](module,module.exports,load),module.exports; + } + load.n=value=>value&&value.__esModule?()=>value.default:()=>value; + let box={};${assignments}box.n=()=>0;load(2); + })();`, + }); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(result.summary).toMatchObject({ + browser_windows: includesLoader ? 0 : 1, + ipc: { + operations: includesLoader ? 0 : 1, + main_handlers: includesLoader ? 0 : 1, + }, + }); + }, + ); +} + cliTest( "keeps source-derived runtime, module and chunk tuples collision-free", async ({ cli }) => {