From d6cc9ca3a6e92604c837f61c193baf5abedaabc8 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:16:24 +0800 Subject: [PATCH] fix(javascript): preserve lexical open and exact template semantics --- docs/javascript-artifact-reconstruction.md | 6 ++++ .../JavaScriptOpenClassification.test.ts | 9 +++++- .../electronStaticAnalysisOptions.test.ts | 20 +++++++++++++ .../electronStaticAnalysisValues.ts | 5 ---- .../javascript/javascriptSemanticAnalysis.ts | 16 ++++++++++- .../javascriptStaticAnalysisHelpers.ts | 26 +++++++++-------- .../javascriptStaticPathResolution.test.ts | 28 +++++++++++++++++++ src/domain/webBundleAnalyzer.test.ts | 16 +++++++++-- 8 files changed, 106 insertions(+), 20 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 5238f42bea..51b4f1a985 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -166,6 +166,12 @@ Selected bounded text is then parsed as inert data to recover: - utility-process entrypoints and native `.node` binding requests without parsing or executing the add-on. +Overloaded `.open` calls use lexical receiver facts: ambient browser window and +document receivers are treated as browsing-context or document operations, +while locally shadowed receivers can still contribute network endpoint +candidates. A template recovered with a parser error and no cooked value stays +dynamic; its raw spelling is not treated as a valid JavaScript string. + Each recovered bundle module retains the exact factory-source digest. A complete bounded AST also receives a `babel-ast-v1` structural fingerprint that ignores ordinary identifier names while retaining syntax, literals, operators, object diff --git a/src/application/javascript/JavaScriptOpenClassification.test.ts b/src/application/javascript/JavaScriptOpenClassification.test.ts index 66f6b10450..0a7369ea22 100644 --- a/src/application/javascript/JavaScriptOpenClassification.test.ts +++ b/src/application/javascript/JavaScriptOpenClassification.test.ts @@ -23,6 +23,13 @@ it("classifies overloaded open calls from lexical receiver facts", async () => { } const xhr = new XMLHttpRequest(); xhr.open(method, "/api"); + parent.open("GET", "/parent-target"); + self.open("POST", "/self-target"); + document.open("GET", "/document-target"); + { + const parent = new XMLHttpRequest(); + parent.open(method, "/shadowed-parent"); + } indexedDB.open("records", "2"); `, ); @@ -46,6 +53,6 @@ it("classifies overloaded open calls from lexical receiver facts", async () => { .flatMap(({ observations }) => observations.map(({ properties }) => properties.storage_kind), ); - expect(endpoints).toEqual(["/api", "/dav"]); + expect(endpoints).toEqual(["/api", "/dav", "/shadowed-parent"]); expect(storage).toEqual(["indexed-db"]); }); diff --git a/src/domain/javascript/electronStaticAnalysisOptions.test.ts b/src/domain/javascript/electronStaticAnalysisOptions.test.ts index 024df75824..a6b9a922ef 100644 --- a/src/domain/javascript/electronStaticAnalysisOptions.test.ts +++ b/src/domain/javascript/electronStaticAnalysisOptions.test.ts @@ -44,4 +44,24 @@ describe("Electron option evidence", () => { service_name: null, }); }); + + it("keeps recovered templates with invalid escapes dynamic", () => { + const source = [ + "new BrowserWindow({ webPreferences: { sandbox: `", + String.raw`\unicode`, + "` } });", + ].join(""); + const analysis = analyzeJavaScriptStaticSource(source); + + expect(analysis.parse_status).toBe("partial"); + expect(analysis.parse_error_count).toBe(1); + expect( + analysis.electron.browser_windows[0]?.web_preferences, + ).toContainEqual( + expect.objectContaining({ + name: "sandbox", + value: expect.objectContaining({ status: "dynamic", value: null }), + }), + ); + }); }); diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index 554b457b54..0311009f90 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -148,11 +148,6 @@ const literalValue = ( if (node === null || node === undefined) return { found: false }; const literal = readExactJavaScriptLiteral(node); if (literal.found) return literal; - if (t.isTemplateLiteral(node) && node.expressions.length === 0) - return { - found: true, - value: node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? "", - }; if ( t.isUnaryExpression(node, { operator: "-" }) && t.isNumericLiteral(node.argument) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index eb599b6a7c..f2f54e0eb6 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -179,6 +179,10 @@ export const classifyParsedJavaScriptOpenReceivers = ( if (fact !== undefined) facts.set(node.start ?? -1, fact); return; } + // A lexically bound receiver overrides ambient browser globals. Mark an + // otherwise unresolved local as local so name-based window.open + // heuristics do not hide its XHR-like overloads. + facts.set(node.start ?? -1, "local"); if (receiver === "indexedDB" || receiver === "caches") { facts.set( node.start ?? -1, @@ -235,7 +239,17 @@ export const classifyParsedJavaScriptOpenReceivers = ( const openGlobalFact = ( name: string, ): JavaScriptOpenReceiverFact | undefined => { - if (name === "window") return "window"; + if ( + name === "window" || + name === "self" || + name === "globalThis" || + name === "top" || + name === "parent" || + name === "opener" || + name === "frames" || + name === "document" + ) + return "window"; if (name === "indexedDB") return "indexed-db"; if (name === "caches") return "cache-storage"; return undefined; diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 007dc23f21..e1cc0e2e8a 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -11,7 +11,10 @@ import type { JavaScriptStaticPathContext, JavaScriptStaticStorage, } from "./javascriptStaticAnalysisTypes.js"; -import { semanticStaticPropertyName } from "./javascriptAstValues.js"; +import { + readExactJavaScriptLiteral, + semanticStaticPropertyName, +} from "./javascriptAstValues.js"; import { compareCodePoints } from "../canonicalOrdering.js"; export { @@ -179,10 +182,8 @@ export const staticPathResolutionContext = ( const staticPathAt = (node: t.Node): string | undefined => { if (t.isStringLiteral(node)) return node.value; - if (t.isTemplateLiteral(node) && node.expressions.length === 0) { - const value = node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw; - return value; - } + if (t.isTemplateLiteral(node) && node.expressions.length === 0) + return stringValue(node); if (t.isBinaryExpression(node, { operator: "+" })) { const left = staticPathAt(node.left); const right = staticPathAt(node.right); @@ -447,6 +448,9 @@ const xhrOpenUrl = ( ): string | undefined => { const url = stringValue(urlNode); if (url === undefined) return undefined; + // A lexical fact is stronger than the receiver's spelling. In particular, + // browser Window and Document globals overload `open` with URL/content APIs. + if (receiverFact === "window") return undefined; const method = stringValue(methodNode); // IndexedDB and Cache Storage `open` take a name and a version, so a // receiver spelled as storage needs a standard HTTP method literal. @@ -458,8 +462,7 @@ const xhrOpenUrl = ( storageKind(name) !== undefined; if (method === undefined) return storage || - receiverFact === "window" || - WINDOW_OPEN_CALL.test(name) || + (receiverFact === undefined && WINDOW_OPEN_CALL.test(name)) || FS_OPEN_FLAGS.has(url) || BROWSING_CONTEXT_KEYWORDS.has(url.toLowerCase()) ? undefined @@ -600,10 +603,11 @@ const memberPropertyName = ( export const stringValue = ( node: t.Node | null | undefined, ): string | undefined => { - if (t.isStringLiteral(node)) return node.value; - if (t.isTemplateLiteral(node) && node.expressions.length === 0) - return node.quasis[0]?.value.cooked ?? undefined; - return undefined; + if (node === null || node === undefined) return undefined; + const literal = readExactJavaScriptLiteral(node); + return literal.found && typeof literal.value === "string" + ? literal.value + : undefined; }; /** Return a string or numeric argument literal. */ diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index 070e7b084f..1c44e06823 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -167,6 +167,34 @@ describe("inert JavaScript path resolution", () => { ); }); + it("keeps recovered templates with invalid escapes unknown as paths", () => { + const expression = ["`", String.raw`\unicode`, "`"].join(""); + const { analysis } = analyzePathConsumers(expression); + + expect(analysis.parse_status).toBe("partial"); + expect(analysis.parse_error_count).toBe(3); + expect(analysis.role_paths).toEqual([]); + expect(analysis.electron.browser_windows).toMatchObject([ + { + preload_path: null, + preload_resolution_context: null, + web_preferences: [ + { + name: "preload", + value: { status: "dynamic", value: null, expression }, + }, + ], + }, + ]); + expect(analysis.electron.utility_processes).toEqual([ + expect.objectContaining({ + module_path: null, + module_resolution_context: null, + module_expression: expression, + }), + ]); + }); + it.each([ "path.join()", "path.join(__dirname)", diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index affb2c78f9..3fc0f6dc83 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -191,6 +191,17 @@ describe("web bundle static-analysis parity", () => { document.open("text/html", "/replace"); parent.open("GET", "/parent-xhr"); self.open("post", "/self-xhr"); + top.open("GET", "/top-target"); + opener.open("GET", "/opener-target"); + frames.open("GET", "/frames-target"); + globalThis.open("GET", "/global-target"); + document.open("GET", "/document-target"); + const parentWindow = parent; + parentWindow.open("GET", "/alias-target"); + { + const parent = new XMLHttpRequest(); + parent.open(method, "/shadowed-parent-xhr"); + } xhr.open(method, "api/relative"); fs.open(path, "w+", done); popup.open(url, "_TOP"); @@ -201,14 +212,15 @@ describe("web bundle static-analysis parity", () => { ).toEqual([ "/dav/", "/dynamic-method", - "/parent-xhr", - "/self-xhr", + "/shadowed-parent-xhr", "/xhr-get", "api/relative", "https://xhr.example.test/submit", ]); }); +}); +describe("web bundle API classification parity", () => { it("reads storage open versions as storage rather than endpoints in both analyzers", () => { const source = ` indexedDB.open(databaseName, "2");