fix(javascript): require API identity for static application roles

This commit is contained in:
morluto
2026-10-24 02:29:43 +08:00
parent 5c16a85a67
commit 5f13ddd755
15 changed files with 413 additions and 174 deletions
+14 -1
View File
@@ -249,7 +249,8 @@ utility-process runtime paths retain the runtime filename selected by the caller
Selected bounded text is then parsed as inert data to recover:
- `package.json` metadata and declared main or renderer entrypoints;
- Electron preload paths and renderer files visible in static syntax;
- Electron preload paths owned by proven BrowserWindow options and renderer
files loaded through proven BrowserWindow instances;
- local HTML script entrypoints;
- Webpack and Rspack chunk registrations and module factories represented as
AST literals, plus esbuild `__commonJS` module tables; recovered esbuild
@@ -366,6 +367,18 @@ prefix is never presented as a complete stable fingerprint.
## Authority and unknowns
API identity is a derivation: a familiar method or property name does not
establish a browser or Electron API. The shared lexical, ambient-write, and
member-mutation owners must establish the receiver before static API facts become
roles or storage observations. Path `join`, `resolve`, and `fileURLToPath` use
proven Node imports; source-relative URL conversion additionally requires an
unmodified global URL constructor and `import.meta.url`. Other calls preserve
their expressions. A property named `preload` outside a proven BrowserWindow
configuration does not establish a script entrypoint. Renderer recovery currently
covers direct BrowserWindow instances, stable local aliases, and their
`webContents`; other receiver-producing APIs remain unmodeled. Complete AST
traversal does not imply complete API-model coverage.
Artifact bytes and AST syntax are observations. Entrypoint resolution, imports,
loads, calls, and persistence relationships are static inferences and explicitly
say that syntax does not prove runtime execution. A malformed or unavailable
@@ -2937,7 +2937,7 @@ describe("contextual JavaScript module identity", () => {
new BrowserWindow({ webPreferences: {
preload: new URL("./remote-base.js", location.href)
}});
window.loadURL("file:///renderer/index.html");
new BrowserWindow().loadURL("file:///renderer/index.html");
`,
);
@@ -135,7 +135,7 @@ const inspectBrowserWindow = (
context.source,
lookup.status,
resolvedPreferences,
context.nodePathResolve,
context.pathIntrinsics,
);
context.accumulator.unknownFindings += collected.unknown;
const finding: ElectronBrowserWindowFinding = {
@@ -154,6 +154,23 @@ const inspectBrowserWindow = (
module_key: null,
location: range(node),
};
if (
finding.preload_path !== null &&
finding.preload_resolution_context !== null
)
addLocatedFinding(context, {
collection: context.accumulator.roles,
key: `role\0preload\0${finding.preload_path}`,
node,
value: {
role: "preload",
path: finding.preload_path,
resolution_context: finding.preload_resolution_context,
mechanism: "BrowserWindow:webPreferences.preload",
module_key: null,
location: range(node),
},
});
addLocatedFinding(context, {
collection: context.accumulator.browserWindows,
key: "electron-window\0BrowserWindow",
@@ -166,7 +183,7 @@ const collectWindowOptions = (
source: string,
lookupStatus: "explicit" | "missing" | "dynamic",
preferences: t.Node | undefined,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
): {
readonly status: ElectronBrowserWindowFinding["web_preferences_status"];
readonly preferences: readonly ElectronWebPreference[];
@@ -180,13 +197,13 @@ const collectWindowOptions = (
preload: { preload_path: null, preload_resolution_context: null },
unknown: lookupStatus === "missing" ? 0 : 1,
};
return collectWebPreferences(source, preferences, nodePathResolve);
return collectWebPreferences(source, preferences, pathIntrinsics);
};
const collectWebPreferences = (
source: string,
object: t.ObjectExpression,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
): {
readonly status: "object-literal";
readonly preferences: readonly ElectronWebPreference[];
@@ -198,7 +215,7 @@ const collectWebPreferences = (
const effectivePreload = staticObjectProperty(object, "preload");
const preloadPath =
effectivePreload.status === "explicit"
? staticPath(effectivePreload.property.value, nodePathResolve)
? staticPath(effectivePreload.property.value, pathIntrinsics)
: undefined;
const preload: ElectronBrowserWindowPreload =
effectivePreload.status === "explicit" && preloadPath !== undefined
@@ -206,7 +223,7 @@ const collectWebPreferences = (
preload_path: preloadPath,
preload_resolution_context: staticPathResolutionContext(
effectivePreload.property.value,
nodePathResolve,
pathIntrinsics,
),
}
: { preload_path: null, preload_resolution_context: null };
@@ -238,7 +255,7 @@ const collectWebPreferences = (
}
const path =
name === "preload"
? staticPath(property.value, nodePathResolve)
? staticPath(property.value, pathIntrinsics)
: undefined;
const value: ElectronStaticValue =
path === undefined
@@ -316,7 +333,7 @@ const inspectUtilityProcess = (
const modulePath =
moduleNode === undefined
? undefined
: staticPath(moduleNode, context.nodePathResolve);
: staticPath(moduleNode, context.pathIntrinsics);
// Electron treats a non-array object in the argv slot as the options.
const shapes = context.expressionShapes;
const options = optionsAfterOptionalArgv(node.arguments, shapes);
@@ -333,7 +350,7 @@ const inspectUtilityProcess = (
const serviceName =
declaredName === undefined
? undefined
: staticPath(declaredName, context.nodePathResolve);
: staticPath(declaredName, context.pathIntrinsics);
if (
serviceNameProperty.status === "dynamic" ||
(declaredName !== undefined && serviceName === undefined)
@@ -351,7 +368,7 @@ const inspectUtilityProcess = (
module_path: modulePath,
module_resolution_context: staticPathResolutionContext(
moduleNode,
context.nodePathResolve,
context.pathIntrinsics,
),
module_expression: null,
};
@@ -0,0 +1,67 @@
import * as t from "@babel/types";
import { electronCalleePath } from "./electronStaticAnalysisValues.js";
import {
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import {
semanticStableInitializer,
type JavaScriptBindingIndex,
} from "./javascriptSemanticState.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
/** A renderer load belongs to an intact, source-proven BrowserWindow instance. */
export const isElectronRendererLoad = (
callee: t.Node,
site: t.Node,
context: JavaScriptFindingContext,
index: JavaScriptBindingIndex,
): boolean => {
callee = unwrapJavaScriptExpression(callee).node;
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
return false;
const method = semanticStaticPropertyKey(callee.property, callee.computed);
if (method !== "loadFile" && method !== "loadURL") return false;
let receiver = unwrapJavaScriptExpression(callee.object).node;
const path: string[] = [method];
if (
t.isMemberExpression(receiver) &&
semanticStaticPropertyKey(receiver.property, receiver.computed) ===
"webContents"
) {
receiver = unwrapJavaScriptExpression(receiver.object).node;
path.unshift("webContents");
}
const seen = new Set<string>();
while (t.isIdentifier(receiver)) {
const binding = index.bindingForReferenceAt(receiver);
if (binding === undefined || seen.has(binding.bindingId)) return false;
seen.add(binding.bindingId);
const initializer = semanticStableInitializer(binding, index.bindings);
if (
initializer === undefined ||
initializer.projection.length !== 0 ||
!semanticBindingAtRead(binding, receiver, index.bindings.parentsByNode)
.hasDefiniteInitializer
)
return false;
completeSemanticSteps(index.indexMemberMutationFactsSteps());
if (index.referenceMayHaveChangedAt(site, receiver, path)) return false;
receiver = unwrapJavaScriptExpression(initializer.node).node;
}
if (!t.isNewExpression(receiver)) return false;
const constructor = electronCalleePath(receiver.callee, context);
return (
constructor?.length === 1 &&
constructor[0] === "BrowserWindow" &&
context.electronExportIntact?.([
"electron",
"BrowserWindow",
"prototype",
...path,
]) === true
);
};
@@ -14,10 +14,11 @@ import {
traverseJavaScriptAstSteps,
} from "./javascriptSemanticTraversal.js";
import { isElectronRendererLoad } from "./electronStaticRenderer.js";
import { inspectElectronStaticNode } from "./electronStaticAnalysis.js";
import { hasPossibleCommonJsDelegateWrite } from "./javascriptSemanticRequireOrigin.js";
import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js";
import { createStaticNodePathResolveQuery } from "./javascriptStaticPathIntrinsics.js";
import { createStaticPathIntrinsicQuery } from "./javascriptStaticPathIntrinsics.js";
import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js";
import { finishElectronBrowserWindowsSteps } from "./electronStaticAnalysisBrowser.js";
import {
@@ -34,7 +35,6 @@ import {
finishModuleRequireCalls,
finishSqliteStorageCalls,
inspectCall,
inspectRoleProperty,
inspectRpcNode,
finishRpcScanSteps,
indexVitePreloadDependencyTable,
@@ -103,12 +103,15 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
bindings,
);
const findings: JavaScriptFindingContext = {
nodePathResolve: createStaticNodePathResolveQuery(index),
pathIntrinsics: createStaticPathIntrinsicQuery(index),
rendererLoad: (callee, site) =>
isElectronRendererLoad(callee, site, findings, index),
source,
accumulator,
vitePreloadDependencyTable: indexVitePreloadDependencyTable(file.program),
openReceiverFacts: receivers.openReceivers,
electronBindings: electronIdentity.bindings,
electronExportIntact: electronIdentity.intactExport,
electronInvocationPath: electronIdentity.invocationPath,
electronAppPathChangePossible: electronIdentity.mayChangeAppPath,
rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index),
@@ -330,8 +333,5 @@ const inspectNode = (
kind: "dynamic-import",
specifier: stringValue(node.source),
});
if (t.isObjectProperty(node)) {
inspectRoleProperty(node, findings);
}
inspectRpcNode(node, findings);
};
@@ -433,7 +433,12 @@ it("preserves preload and utility paths through exact computed join members", ()
),
)
.toEqual([
["preload", "preload.js", "filesystem-expression", "property:preload"],
[
"preload",
"preload.js",
"filesystem-expression",
"BrowserWindow:webPreferences.preload",
],
]);
expect
.soft(
@@ -541,11 +546,14 @@ it("keeps dynamic, interpolated, and invalid template keys unresolved", () => {
describe("static callee boundary matching", () => {
it("matches complete callee members without suffix collisions", () => {
const analysis = analyzeJavaScriptStaticSource(`
const {BrowserWindow} = require("electron");
const win = new BrowserWindow();
win.loadFile("./window.html");
BrowserWindow.loadURL("https://app.example.test");
new BrowserWindow().loadURL("https://app.example.test");
navigator.serviceWorker.register("./worker.js");
loadFile("./bare.html");
win?.loadURL("https://optional.example.test");
const optional = new BrowserWindow();
optional?.loadURL("https://optional.example.test");
uploadFile("./upload.html");
preloadFile("./preload.html");
@@ -559,9 +567,8 @@ describe("static callee boundary matching", () => {
analysis.role_paths.map(({ path, mechanism }) => [path, mechanism]),
).toEqual([
["./window.html", "call:win.loadFile"],
["https://app.example.test", "call:BrowserWindow.loadURL"],
["./bare.html", "call:loadFile"],
["https://optional.example.test", "call:win.loadURL"],
["https://app.example.test", "call:loadURL"],
["https://optional.example.test", "call:optional.loadURL"],
]);
// The document base URL is unknown, so the script path stays unresolved.
expect(
@@ -17,9 +17,6 @@ import {
range,
rangeForOffsets,
sourceSlice,
staticPath,
staticPathResolutionContext,
storageKind,
stringValue,
} from "./javascriptStaticAnalysisHelpers.js";
import {
@@ -147,11 +144,10 @@ export const inspectCall = (
specifier: moduleUrlSpecifier(node, bindings),
});
inspectEndpointCall(node, name, first, context, bindings);
inspectStorageCall(node, name, first, context);
if (first === "audio" || first === "video")
noteMediaElementPreloads(node, accumulator);
inspectStorageCall(node, name, first, context, bindings);
if (
(name === "loadFile" || name.endsWith(".loadFile")) &&
context.rendererLoad?.(node.callee, node) === true &&
first !== undefined
)
addLocatedFinding(context, {
@@ -167,7 +163,11 @@ export const inspectCall = (
location: range(node),
},
});
if ((name === "loadURL" || name.endsWith(".loadURL")) && first !== undefined)
if (
(name === "loadURL" || name.endsWith(".loadURL")) &&
context.rendererLoad?.(node.callee, node) === true &&
first !== undefined
)
addLocatedFinding(context, {
collection: accumulator.roles,
key: `role\0renderer\0${first}`,
@@ -246,23 +246,6 @@ const workerConstructorKind = (
: undefined;
};
// Element factories such as jsx("audio", props) and createElement("video",
// props) carry the HTML media `preload` hint ("none", "metadata", "auto"),
// which never names an Electron preload script.
const noteMediaElementPreloads = (
node: JavaScriptCallLike,
accumulator: AnalysisAccumulator,
): void => {
const props = node.arguments[1];
if (!t.isObjectExpression(props)) return;
for (const property of props.properties)
if (
t.isObjectProperty(property) &&
semanticStaticPropertyName(property.key, property.computed) === "preload"
)
accumulator.mediaElementPreloads.add(property);
};
const requireCallSpecifier = (
node: JavaScriptCallLike,
bindings: JavaScriptSemanticAnalysisState,
@@ -824,37 +807,12 @@ export function* finishRpcScanSteps(
}
}
export const inspectRoleProperty = (
node: t.ObjectProperty,
context: FindingContext,
): void => {
if (semanticStaticPropertyName(node.key, node.computed) !== "preload") return;
if (context.accumulator.mediaElementPreloads.has(node)) return;
const path = staticPath(node.value, context.nodePathResolve);
if (path === undefined) return;
addLocatedFinding(context, {
collection: context.accumulator.roles,
key: `role\0preload\0${path}`,
node,
value: {
role: "preload",
path,
resolution_context: staticPathResolutionContext(
node.value,
context.nodePathResolve,
),
mechanism: "property:preload",
module_key: null,
location: range(node),
},
});
};
const inspectStorageCall = (
node: JavaScriptCallLike,
name: string,
first: string | undefined,
context: FindingContext,
bindings: JavaScriptSemanticAnalysisState,
): void => {
if (name === "Database" || name.endsWith(".Database")) {
context.accumulator.pendingSqliteCalls.push({ node, name });
@@ -870,7 +828,7 @@ const inspectStorageCall = (
? "indexed-db"
: receiverFact === "cache-storage"
? "cache-storage"
: storageKind(name);
: browserStorageKind(node.callee, bindings);
if (storage === undefined) return;
addLocatedFinding(context, {
collection: context.accumulator.storage,
@@ -886,6 +844,35 @@ const inspectStorageCall = (
});
};
const browserStorageKind = (
callee: t.Node,
bindings: JavaScriptSemanticAnalysisState,
):
| "local-storage"
| "session-storage"
| "indexed-db"
| "cache-storage"
| undefined => {
callee = unwrapJavaScriptExpression(callee).node;
if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee))
return undefined;
const method = semanticStaticPropertyKey(callee.property, callee.computed);
if (method === null) return undefined;
if (["getItem", "setItem", "removeItem", "clear", "key"].includes(method)) {
if (isUnmodifiedGlobalReference(callee, bindings, ["localStorage", method]))
return "local-storage";
if (
isUnmodifiedGlobalReference(callee, bindings, ["sessionStorage", method])
)
return "session-storage";
}
if (isUnmodifiedGlobalReference(callee, bindings, ["indexedDB", "open"]))
return "indexed-db";
if (isUnmodifiedGlobalReference(callee, bindings, ["caches", "open"]))
return "cache-storage";
return undefined;
};
/** Constructor spelling selects a candidate; source provenance owns certainty. */
export const finishSqliteStorageCalls = (
context: FindingContext,
@@ -173,29 +173,34 @@ export const staticArrayValues = (
/** Classify whether inert path syntax is a module specifier or file expression. */
export type JavaScriptStaticPathIntrinsicQuery = (
node: t.Node,
fact?: "source-directory",
fact?:
| "resolve"
| "join"
| "file-url-to-path"
| "source-url"
| "source-directory",
) => boolean;
export const staticPathResolutionContext = (
node: t.Node,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
): JavaScriptStaticPathContext =>
isFilesystemPathExpression(node, nodePathResolve)
isFilesystemPathExpression(node, pathIntrinsics)
? "filesystem-expression"
: "module-specifier";
/** Resolve a path string composed only from inert literal syntax. */
export const staticPath = (
node: t.Node,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
): string | undefined => {
node = unwrapJavaScriptExpression(node).node;
const value = stringValue(node);
if (value !== undefined) return value;
if (t.isBinaryExpression(node, { operator: "+" }))
return staticConcatenation(node, nodePathResolve);
return staticConcatenation(node, pathIntrinsics);
if (t.isCallExpression(node) || t.isNewExpression(node))
return staticCallPath(node, nodePathResolve);
return staticCallPath(node, pathIntrinsics);
return undefined;
};
@@ -206,7 +211,7 @@ export const staticPath = (
*/
const staticConcatenation = (
node: t.BinaryExpression,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
): string | undefined => {
const operands: t.Node[] = [];
let current: t.Node = node;
@@ -214,11 +219,11 @@ const staticConcatenation = (
operands.push(current.right);
current = unwrapJavaScriptExpression(current.left).node;
}
const anchored = isDirectoryPath(current, nodePathResolve);
const anchored = isDirectoryPath(current, pathIntrinsics);
if (!anchored) operands.push(current);
let text = "";
for (const operand of operands.reverse()) {
const value = staticPath(operand, nodePathResolve);
const value = staticPath(operand, pathIntrinsics);
if (value === undefined) return undefined;
text += value;
}
@@ -228,26 +233,27 @@ const staticConcatenation = (
const staticCallPath = (
node: t.CallExpression | t.NewExpression,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
): string | undefined => {
if (t.isCallExpression(node) && nodePathResolve?.(node.callee))
return staticResolvedPath(node, nodePathResolve);
const name = calleeName(node.callee);
if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) {
return staticFileUrlPath(node, nodePathResolve);
}
if (name.endsWith(".resolve")) return undefined;
if (!name.endsWith(".join") && name !== "join") return undefined;
if (t.isCallExpression(node) && pathIntrinsics?.(node.callee))
return staticResolvedPath(node, pathIntrinsics);
if (
t.isCallExpression(node) &&
pathIntrinsics?.(node.callee, "file-url-to-path")
)
return staticFileUrlPath(node, pathIntrinsics);
if (!t.isCallExpression(node) || !pathIntrinsics?.(node.callee, "join"))
return undefined;
const parts: string[] = [];
for (const argument of node.arguments) {
if (isDirectoryPath(argumentNode(argument), nodePathResolve)) {
if (isDirectoryPath(argumentNode(argument), pathIntrinsics)) {
// join never resets at an absolute segment, so `/preload.js` after the
// source directory stays inside it.
parts.push(".");
continue;
}
const value = t.isNode(argument)
? staticPath(argument, nodePathResolve)
? staticPath(argument, pathIntrinsics)
: undefined;
if (value === undefined) return undefined;
parts.push(value);
@@ -270,15 +276,15 @@ export const staticSourceRelativeUrlPath = (
/** Convert an exact static file URL expression to a POSIX path. */
export const staticFileUrlPath = (
node: t.CallExpression | t.NewExpression,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
): string | undefined => {
if (node.arguments.length !== 1) return undefined;
const rawArgument = argumentNode(node.arguments[0]);
if (rawArgument === undefined) return undefined;
const argument = unwrapJavaScriptExpression(rawArgument).node;
if (isSourceFileUrl(argument))
if (isSourceFileUrl(argument) && pathIntrinsics?.(argument, "source-url"))
return sourceRelativeFileUrlPath(argument)?.path;
const value = staticPath(argument, nodePathResolve);
const value = staticPath(argument, pathIntrinsics);
const rawPath = value?.split(/[?#]/u, 1)[0] ?? "";
if (
value === undefined ||
@@ -354,19 +360,19 @@ const isSourceFileUrl = (
const staticResolvedPath = (
node: t.CallExpression | t.NewExpression,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
): string | undefined => {
const parts: string[] = [];
let anchored = false;
// Node walks right-to-left and never inspects arguments before its anchor.
for (let index = node.arguments.length - 1; index >= 0; index -= 1) {
const argument = node.arguments[index];
if (isDirectoryPath(argumentNode(argument), nodePathResolve)) {
if (isDirectoryPath(argumentNode(argument), pathIntrinsics)) {
anchored = true;
break;
}
const value = t.isNode(argument)
? staticPath(argument, nodePathResolve)
? staticPath(argument, pathIntrinsics)
: undefined;
if (value === undefined) return undefined;
parts.push(value);
@@ -383,45 +389,27 @@ const staticResolvedPath = (
const isFilesystemPathExpression = (
node: t.Node,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
): boolean => {
node = unwrapJavaScriptExpression(node).node;
const value = stringValue(node);
if (value !== undefined) return value.startsWith("/");
if (isFileIdentity(node) || isDirectoryPath(node, nodePathResolve))
if (isFileIdentity(node) || isDirectoryPath(node, pathIntrinsics))
return true;
if (t.isBinaryExpression(node, { operator: "+" }))
return (
isFilesystemPathExpression(node.left, nodePathResolve) ||
isFilesystemPathExpression(node.right, nodePathResolve)
isFilesystemPathExpression(node.left, pathIntrinsics) ||
isFilesystemPathExpression(node.right, pathIntrinsics)
);
if (t.isCallExpression(node) || t.isNewExpression(node)) {
const name = calleeName(node.callee);
if (
nodePathResolve?.(node.callee) &&
staticCallPath(node, nodePathResolve) !== undefined
)
return true;
// A URL object is not a path string until fileURLToPath converts it.
if (name === "URL" || name.endsWith(".URL")) return false;
if (name === "fileURLToPath" || name.endsWith(".fileURLToPath"))
return (
staticFileUrlPath(node, nodePathResolve) !== undefined ||
node.arguments.some((argument) => {
const value = argumentNode(argument);
return (
value !== undefined &&
isFilesystemPathExpression(value, nodePathResolve)
);
})
);
return node.arguments.some((argument) => {
const value = argumentNode(argument);
return (
value !== undefined &&
isFilesystemPathExpression(value, nodePathResolve)
);
});
if (!t.isCallExpression(node)) return false;
const native =
pathIntrinsics?.(node.callee) ||
pathIntrinsics?.(node.callee, "join") ||
pathIntrinsics?.(node.callee, "file-url-to-path");
return (
native === true && staticCallPath(node, pathIntrinsics) !== undefined
);
}
return false;
};
@@ -429,11 +417,11 @@ const isFilesystemPathExpression = (
/** `__dirname` or the dirname of the source file, as a string. */
const isDirectoryPath = (
node: t.Node | undefined,
nodePathResolve?: JavaScriptStaticPathIntrinsicQuery,
pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery,
): boolean => {
if (node !== undefined) node = unwrapJavaScriptExpression(node).node;
return (
node !== undefined && nodePathResolve?.(node, "source-directory") === true
node !== undefined && pathIntrinsics?.(node, "source-directory") === true
);
};
@@ -704,8 +692,8 @@ export const endpointArgument = (
return undefined;
};
/** Classify a recognized storage API call name. */
export const storageKind = (
/** Spelling-only candidate exclusion for overloaded open calls; not API identity. */
const storageKind = (
name: string,
): JavaScriptStaticStorage["kind"] | undefined => {
if (name.includes("localStorage.")) return "local-storage";
@@ -80,8 +80,6 @@ export interface JavaScriptAnalysisAccumulator {
readonly moduleRangeIndex: JavaScriptModuleRangeIndex;
/** Binding identities proven as third-parameter loaders of recovered factories. */
readonly factoryRequireBindingIds: Set<string>;
/** `preload` attributes of audio and video elements, which are not scripts. */
readonly mediaElementPreloads: WeakSet<t.Node>;
readonly seen: Set<string>;
visitedNodes: number;
unknownFindings: number;
@@ -89,8 +87,10 @@ export interface JavaScriptAnalysisAccumulator {
/** Shared source and accumulator for helper inspections. */
export interface JavaScriptFindingContext {
/** Node path.resolve identity established by shared callee and mutation owners. */
readonly nodePathResolve?: JavaScriptStaticPathIntrinsicQuery;
/** Native path/URL identities established by shared lexical and mutation owners. */
readonly pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery;
readonly electronExportIntact?: (path: readonly string[]) => boolean;
readonly rendererLoad?: (callee: t.Node, site: t.Node) => boolean;
readonly rpcScan: JavaScriptRpcScan;
/** Shared bounded argv/nullish argument shapes for optional-slot signatures. */
readonly expressionShapes: JavaScriptExpressionShapeResolver;
@@ -159,7 +159,6 @@ export const createJavaScriptAnalysisAccumulator = (
nativeAddonBindings: [],
moduleRangeIndex: new JavaScriptModuleRangeIndex(sourceLength),
factoryRequireBindingIds: new Set(),
mediaElementPreloads: new WeakSet(),
seen: new Set(),
visitedNodes: 0,
unknownFindings: 0,
@@ -0,0 +1,87 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const windowPrefix =
"import {BrowserWindow} from 'electron'; import {fileURLToPath} from 'node:url';";
it.each([
'class URL {constructor(input, base) {return new globalThis.URL("./actual.js", base);}}',
"globalThis.URL = class {};",
'import.meta.url = "file:///another/main.js";',
])(
"retains a dynamic preload when source URL identity is unavailable: %s",
(prefix) => {
const analysis = analyzeJavaScriptStaticSource(`${windowPrefix}${prefix}
new BrowserWindow({webPreferences:{preload:fileURLToPath(new URL("./decoy.js", import.meta.url))}});`);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: null },
]);
expect(analysis.role_paths).toEqual([]);
},
);
it.each([
'["a", "b"].join("/")',
'settings.join("/app", "preload.js")',
'settings.fileURLToPath("file:///decoy.js")',
])("does not assign native path semantics to %s", (expression) => {
const analysis =
analyzeJavaScriptStaticSource(`import {BrowserWindow} from 'electron';
const settings = {join() {}, fileURLToPath() {}};
new BrowserWindow({webPreferences:{preload:${expression}}});`);
expect(analysis.electron.browser_windows).toMatchObject([
{ preload_path: null },
]);
expect(analysis.role_paths).toEqual([]);
});
it("recovers native aliases and emits preload roles only for their Electron owner", () => {
const analysis = analyzeJavaScriptStaticSource(`${windowPrefix}
import {join as append} from 'node:path';
const unrelated = {preload: "./unrelated.js"};
new BrowserWindow({webPreferences:{preload:append(__dirname, "preload.js")}});
new BrowserWindow({webPreferences:{preload:fileURLToPath(new URL("./native.js", import.meta.url))}});`);
expect(
analysis.electron.browser_windows.map(({ preload_path }) => preload_path),
).toEqual(["preload.js", "./native.js"]);
expect(analysis.role_paths.map(({ path }) => path)).toEqual([
"preload.js",
"./native.js",
]);
});
it.each(["localStorage", "sessionStorage"])(
"requires browser identity for %s",
(name) => {
const local = analyzeJavaScriptStaticSource(
`const ${name} = {setItem(key, value) {return value;}}; ${name}.setItem("ghost", "value");`,
);
expect(local.storage).toEqual([]);
const global = analyzeJavaScriptStaticSource(
`globalThis.${name}.setItem("real", "value");`,
);
expect(global.storage).toMatchObject([{ name: "real" }]);
const overwritten = analyzeJavaScriptStaticSource(
`globalThis.${name}.setItem = () => {}; ${name}.setItem("ghost", "value");`,
);
expect(overwritten.storage).toEqual([]);
},
);
it("requires an intact Electron instance for renderer roles", () => {
const local = analyzeJavaScriptStaticSource(
`const cache = {loadFile(value) {return value;}};cache.loadFile("data.json");`,
);
expect(local.role_paths).toEqual([]);
const native = analyzeJavaScriptStaticSource(
`import {BrowserWindow as Win} from 'electron';const window = new Win(); window.loadFile("index.html");`,
);
expect(native.role_paths).toMatchObject([
{ role: "renderer", path: "index.html" },
]);
const replaced = analyzeJavaScriptStaticSource(
`import {BrowserWindow as Win} from 'electron';const window = new Win(); window.loadFile = () => {};window.loadFile("data.json");`,
);
expect(replaced.role_paths).toEqual([]);
});
@@ -11,11 +11,11 @@ import {
import { unwrapJavaScriptExpression } from "./javascriptAstValues.js";
import type { JavaScriptStaticPathIntrinsicQuery } from "./javascriptStaticAnalysisHelpers.js";
/** Prove Node's path resolver before applying its argument-selection semantics. */
export const createStaticNodePathResolveQuery = (
/** Prove path and URL intrinsic identity before applying native semantics. */
export const createStaticPathIntrinsicQuery = (
index: JavaScriptBindingIndex,
): JavaScriptStaticPathIntrinsicQuery => {
const facts = new WeakMap<t.Node, boolean>();
const facts = new WeakMap<t.Node, Map<string, boolean>>();
const nativeMethod = (
callee: t.Node,
method: string,
@@ -64,10 +64,24 @@ export const createStaticNodePathResolveQuery = (
nativeMethod(callee.callee, "dirname", ["path", "path/posix"]) &&
sourceFile(callee.arguments[0]);
}
const cached = facts.get(callee);
const kind = fact ?? "resolve";
const cached = facts.get(callee)?.get(kind);
if (cached !== undefined) return cached;
const proven = nativeMethod(callee, "resolve", ["path", "path/posix"]);
facts.set(callee, proven);
const proven =
kind === "source-url"
? t.isNewExpression(callee) &&
callee.arguments.length === 2 &&
isUnmodifiedGlobalReference(callee.callee, index.bindings, "URL") &&
isUnmodifiedImportMetaUrl(callee.arguments[1], index.bindings)
: kind === "file-url-to-path"
? nativeMethod(callee, "fileURLToPath", ["url"])
: nativeMethod(callee, kind, ["path", "path/posix"]);
let entry = facts.get(callee);
if (entry === undefined) {
entry = new Map();
facts.set(callee, entry);
}
entry.set(kind, proven);
return proven;
};
};
@@ -417,9 +417,14 @@ const assertPathProjection = (
) => {
const node = parsePathExpression(expression);
// This syntax matrix supplies identity explicitly; full-source cases prove it.
const nodePathResolve = (
const pathIntrinsics = (
callee: import("@babel/types").Node,
fact?: "source-directory",
fact?:
| "resolve"
| "join"
| "file-url-to-path"
| "source-url"
| "source-directory",
) => {
if (fact === "source-directory") {
const name = calleeName(
@@ -429,36 +434,27 @@ const assertPathProjection = (
name === "__dirname" || name === "dirname" || name.endsWith(".dirname")
);
}
if (fact === "source-url") return true;
const name = calleeName(callee);
return name.endsWith(".resolve") && !name.includes("win32");
if (fact === "file-url-to-path") return name.endsWith("fileURLToPath");
return name.endsWith(`.${fact ?? "resolve"}`) && !name.includes("win32");
};
expect(staticPath(node, nodePathResolve)).toBe(path);
expect(staticPathResolutionContext(node, nodePathResolve)).toBe(context);
const {
analysis,
property,
genericPrefix,
windowPrefix,
windowExpression,
utilityExpression,
} = analyzePathConsumers(expression);
expect(staticPath(node, pathIntrinsics)).toBe(path);
expect(staticPathResolutionContext(node, pathIntrinsics)).toBe(context);
const { analysis, windowExpression, utilityExpression } =
analyzePathConsumers(expression);
expect(analysis.parse_status).toBe("complete");
expect(analysis.parse_error_count).toBe(0);
expect(analysis.role_paths).toEqual(
(
[
[1, genericPrefix.length],
[2, windowPrefix.length],
] as const
).map(([line, column]) => ({
expect(analysis.role_paths).toEqual([
{
role: "preload",
path,
resolution_context: context,
mechanism: "property:preload",
mechanism: "BrowserWindow:webPreferences.preload",
module_key: null,
location: lineRange(line, column, column + property.length),
})),
);
location: lineRange(2, 0, windowExpression.length),
},
]);
expect(analysis.electron.browser_windows).toEqual([
{
options_status: "object-literal",
@@ -0,0 +1,60 @@
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
cliTest.for(["CLI", "stdio MCP"] as const)(
"preserves static API identity through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-static-api-identity-");
await writeFixtureFiles(root, {
"package.json": JSON.stringify({ main: "main.mjs", type: "module" }),
"main.mjs": `
import {BrowserWindow as Win} from 'electron';
import {fileURLToPath} from 'node:url';
class URL {constructor(input, base) {return new globalThis.URL('./actual.js', base);}}
const cache = {loadFile(value) {return value;}};
cache.loadFile('data.json');
const localStorage = {setItem(key, value) {return value;}};
localStorage.setItem('ghost', 'value');
globalThis.localStorage.setItem('real', 'value');
const unrelated = {preload: './decoy.js'};
new Win({webPreferences:{preload:fileURLToPath(new URL('./decoy.js', import.meta.url))}});
new Win({webPreferences:{preload:'./preload.js'}}).loadFile('index.html');
`,
"actual.js": "export {};",
"decoy.js": "export {};",
"preload.js": "export {};",
"data.json": "{}",
"index.html": "<!doctype html><title>Fixture</title>",
});
const result = await analyzeJavaScriptDirectory(surface, cli, root);
expect(result.statistics.parse_failures).toBe(0);
const roles = result.graph.nodes.filter(
({ kind }) => kind === "electron-preload" || kind === "electron-renderer",
);
expect(
roles.flatMap(({ observations }) =>
observations.map(({ properties }) => properties.resolved_path),
),
).toEqual(expect.arrayContaining(["preload.js", "index.html"]));
expect(
roles.flatMap(({ observations }) =>
observations.map(({ properties }) => properties.resolved_path),
),
).not.toEqual(expect.arrayContaining(["decoy.js"]));
expect(
roles.flatMap(({ observations }) =>
observations.map(({ properties }) => properties.resolved_path),
),
).not.toEqual(expect.arrayContaining(["data.json"]));
const storage = result.graph.nodes.filter(({ kind }) => kind === "storage");
expect(
storage.flatMap(({ observations }) =>
observations.map(({ properties }) => properties.name),
),
).toEqual(["real"]);
},
);
@@ -55,8 +55,7 @@ cliTest.for(
const { graph } = result;
const windows = graph.nodes.filter(({ kind }) => kind === "browser-window");
expect(windows).toHaveLength(expectedRoles);
// Generic `property:preload` discovery is separate from a proven
// BrowserWindow identity; only the latter may create this association.
// Preload roles share the BrowserWindow identity owner.
expect(
graph.nodes.filter(
({ kind, observations }) =>
@@ -67,6 +66,9 @@ cliTest.for(
),
),
).toHaveLength(expectedRoles);
expect(
graph.nodes.filter(({ kind }) => kind === "electron-preload"),
).toHaveLength(expectedRoles);
const main = findSourceModule(graph, "main.cjs");
expect(main).toBeDefined();
const imports = graph.edges.filter(
@@ -45,8 +45,10 @@ describe("Electron option keys in reconstructed application graphs", () => {
expect(result.electron_summary.preload_entrypoints).toBe(0);
expect(result.electron_summary.explicit_web_preferences).toBe(0);
// The independent property:preload heuristic can still discover the nested
// literal. It must not carry a BrowserWindow-specific observation or edge.
// A nested literal cannot bypass the unresolved options owner.
expect(
result.graph.nodes.filter(({ kind }) => kind === "electron-preload"),
).toEqual([]);
expect(windowPreloadNodes(result)).toEqual([]);
expect(
result.graph.edges.filter(