fix(javascript): preserve lexical open and exact template semantics

This commit is contained in:
morluto
2026-10-09 01:55:12 +08:00
parent fe297e56e8
commit d6cc9ca3a6
8 changed files with 106 additions and 20 deletions
@@ -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
@@ -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"]);
});
@@ -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 }),
}),
);
});
});
@@ -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)
@@ -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;
@@ -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. */
@@ -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)",
+14 -2
View File
@@ -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");