feat(electron): report V8 cachedData compilation boundaries (#9501)

* feat(electron): report V8 cachedData execution boundaries

analyze-javascript-application treated new vm.Script(source, {cachedData}) and vm.compileFunction(..., {cachedData}) as ordinary source. V8 deserializes previously compiled bytecode for those calls, so an Electron preload wrapper that delegates its whole contextBridge and IPC surface to a .cache/.jsc snapshot appeared to have been fully analyzed with zero exposed APIs.

Record each vm compilation with its inert cache path when the cachedData value traces to a readFileSync/readFile of path syntax, and project a cached-data execution unknown plus a loads edge from the owning source. Compilations without a cache stay recorded as no-cached-data so absence remains distinguishable from an unparsed snapshot. Count cachedData executions in the Electron summary and state the coverage limit in the graph limitations.

* style(cli): wrap the native data type path description

The `inspect-native-data-type` argument description line is 82 columns, over the repository's 80-column `.oxfmtrc.json` limit, so `npm run format:check` fails on main and on every branch merged from it. It came in with #9465 and the following commits to this file did not touch it. Reformat the one call the formatter objects to; no behaviour change.

* style(domain): reformat the pending dynamic-code accumulator field

`npm run format:check` fails on this branch, which is what `Static checks` reports: oxfmt wants the `pendingDynamicCode` union on one line. Applying it is the whole change; no behaviour is touched.

* fix(electron): preserve VM callable and cache source uncertainty

* docs(javascript): specify cached-data compilation boundaries

* fix(electron): exclude disabled caches from recovered boundaries

---------

Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-09 20:16:18 +08:00
1 parent ab1b9e35d0
commit 2c27c2a43b
21 files changed
+982 -4

No files matched your search

@@ -0,0 +1,6 @@
import type { ElectronDynamicCodeFinding } from "./electronStaticAnalysisTypes.js";
/** A source-only compilation cannot establish an opaque cache boundary. */
export const isPossibleCachedDataCompilation = (
finding: ElectronDynamicCodeFinding,
): boolean => finding.cached_data_status !== "absent";
@@ -1,6 +1,7 @@
import * as t from "@babel/types";
import { inspectElectronBrowserNode } from "./electronStaticAnalysisBrowser.js";
import { deferElectronDynamicCode } from "./electronStaticAnalysisDynamicCode.js";
import { inspectElectronIpcNode } from "./electronStaticAnalysisIpc.js";
import { inspectElectronNativeNode } from "./electronStaticAnalysisNative.js";
import { inspectElectronProtocolNode } from "./electronStaticAnalysisProtocol.js";
@@ -20,6 +21,7 @@ export const inspectElectronStaticNode = (
inspectElectronBrowserNode(node, context);
inspectElectronIpcNode(node, context);
inspectElectronNativeNode(node, context);
deferElectronDynamicCode(node, context);
const path =
t.isCallExpression(node) || t.isOptionalCallExpression(node)
? (electronCalleePath(node.callee, context) ??
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js";
// A `.js` fixture parses as ESM, and then every `require` call keeps only
// candidate loader authority. These cases describe CommonJS packaging, so they
// use the CommonJS fixture that the sibling Electron paths also rely on.
const analyze = (source: string) => analyzeCommonJsStaticFixture(source);
describe("node:vm bytecode cache boundaries", () => {
it("records the artifact named by an inert readFileSync cache argument", () => {
const analysis = analyze(`
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const cachedData = fs.readFileSync(path.join(__dirname, "preload.bundle.cache"));
new vm.Script(sourcePlaceholder, { cachedData }).runInThisContext();
`);
expect(analysis.electron.dynamic_code).toEqual([
expect.objectContaining({
api: "vm.Script",
cached_data_status: "declared",
cached_data: {
source_path: "preload.bundle.cache",
source_path_base: "source-directory",
source_resolution_context: "filesystem-expression",
source_expression: null,
},
source_text: expect.objectContaining({
expression: "sourcePlaceholder",
}),
}),
]);
});
it("resolves compileFunction's positional cachedData argument", () => {
const analysis = analyze(`
const vm = require("node:vm");
vm.compileFunction(text, [], { cachedData: loadCache() });
`);
expect(analysis.electron.dynamic_code).toEqual([
expect.objectContaining({
api: "vm.compileFunction",
cached_data_status: "declared",
cached_data: expect.objectContaining({
source_path: null,
source_expression: "loadCache()",
}),
}),
]);
});
it("keeps compilations without a bytecode cache as compilations without declared cachedData", () => {
const analysis = analyze(`
const vm = require("node:vm");
new vm.Script("./main.js");
`);
expect(analysis.electron.dynamic_code).toEqual([
expect.objectContaining({
api: "vm.Script",
cached_data_status: "absent",
}),
]);
});
// A local object named `vm` is not the module, so its members name no VM API.
it("ignores an unrelated local object that shares the module name", () => {
const analysis = analyze(`
const vm = { Script: class Script {} };
new vm.Script("./main.js", { cachedData: "x" });
`);
expect(analysis.electron.dynamic_code ?? []).toEqual([]);
});
});
it("preserves unknown options and conservative constant-option reads without claiming execution", () => {
for (const [source, status] of [
['new vm.Script("x", options);', "unknown"],
[
'const options = { cachedData: "./cache" }; new vm.Script("x", options);',
"declared",
],
['new vm.Script("x", { cachedData: value, ...options });', "unknown"],
['new vm.Script("x", { ...options, cachedData: value });', "declared"],
]) {
const result = analyze('const vm = require("node:vm"); ' + source);
expect(result.electron.dynamic_code?.[0], source).toMatchObject({
cached_data_status: status,
cached_data: { source_path: null },
});
}
});
it("keeps cache read authority unknown after overwriting the filesystem method", () => {
const analysis = analyze(`
const fs = require("node:fs");
const vm = require("node:vm");
fs.readFileSync = fakeReader;
new vm.Script(source, { cachedData: fs.readFileSync("./not-a-native-cache") });
`);
expect(analysis.electron.dynamic_code?.[0]?.cached_data).toMatchObject({
source_path: null,
source_resolution_context: null,
});
});
it.each([
"new vm.Script(...args);",
"new vm.Script(source, ...options);",
"vm.compileFunction(...args);",
"vm.compileFunction(source, ...args);",
"vm.compileFunction(source, [], ...options);",
])(
"does not infer cachedData absence through argument spreads: %s",
(source) => {
const analysis = analyze('const vm = require("node:vm"); ' + source);
expect(analysis.electron.dynamic_code?.[0]?.cached_data_status).toBe(
"unknown",
);
},
);
it.each(['import * as vm from "node:vm";', 'import vm from "node:vm";'])(
"recognizes ESM VM callable authority: %s",
(declaration) => {
const analysis = analyzeJavaScriptStaticSource(
declaration + " new vm.Script(source, { cachedData });",
);
expect(analysis.electron.dynamic_code?.[0]?.cached_data_status).toBe(
"declared",
);
},
);
it("does not report a replaced VM method as a built-in compilation", () => {
const analysis = analyze(
'const vm = require("node:vm"); vm.Script = Fake; new vm.Script(source, { cachedData });',
);
expect(analysis.electron.dynamic_code ?? []).toEqual([]);
});
it("retains awaited promise reads without treating an unawaited promise as cache bytes", () => {
for (const [awaited, expected] of [
["await ", "preload.bundle.cache"],
["", null],
] as const) {
const analysis = analyze(`
const fs = require("node:fs/promises");
const path = require("node:path");
const vm = require("node:vm");
async function load() {
const cachedData = ${awaited}fs.readFile(path.join(__dirname, "preload.bundle.cache"));
new vm.Script(source, { cachedData });
}
`);
expect(analysis.electron.dynamic_code?.[0]?.cached_data.source_path).toBe(
expected,
);
}
});
it.each(["void 0", "undefined"])(
"keeps disabled cache value %s out of possible cache boundaries",
(value) => {
const analysis = analyze(
`const vm = require("node:vm"); new vm.Script(source, { cachedData: ${value} }); vm.compileFunction(source, [], { cachedData: ${value} });`,
);
expect(
analysis.electron.dynamic_code?.map(
({ cached_data_status }) => cached_data_status,
),
).toEqual(["absent", "absent"]);
expect(
analysis.electron.dynamic_code?.[0]?.cached_data.source_expression,
).toBe(value);
},
);
it("does not confuse a shadowed undefined value with a disabled cache", () => {
const analysis = analyze(
'const vm = require("node:vm"); function load(undefined) { new vm.Script(source, { cachedData: undefined }); }',
);
expect(analysis.electron.dynamic_code?.[0]?.cached_data_status).toBe(
"declared",
);
});
@@ -0,0 +1,239 @@
import { resolveElectronOptionObject } from "./electronStaticOptionObjects.js";
import * as t from "@babel/types";
import { compositeKey } from "../unicodeCodePointOrder.js";
import type {
ElectronDynamicCodeFinding,
ElectronDynamicCodeSource,
} from "./electronStaticAnalysisTypes.js";
import {
boundedExpression,
electronStaticValue,
} from "./electronStaticAnalysisValues.js";
import { builtinDataEffectCallablePath } from "./javascriptSemanticDataEffectHelpers.js";
import {
semanticStaticPropertyKey,
staticObjectProperty,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js";
import {
argumentNode,
range,
staticFileExpressionPath,
} from "./javascriptStaticAnalysisHelpers.js";
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
import {
semanticStableInitializer,
type JavaScriptBindingIndex,
} from "./javascriptSemanticState.js";
interface DynamicCodeInput {
readonly context: JavaScriptFindingContext;
readonly index: JavaScriptBindingIndex;
readonly node: t.NewExpression | t.CallExpression;
readonly api: ElectronDynamicCodeFinding["api"];
readonly cachedDataArgument: "named" | "positional";
}
/**
* Record `vm.Script`/`vm.compileFunction` sites during the shared traversal.
* Option objects may be rebound, so the `cachedData` lookup is deferred to the
* same post-traversal step that settles the other Electron findings.
*/
export const deferElectronDynamicCode = (
node: t.Node,
context: JavaScriptFindingContext,
): void => {
// Identity waits for the settled index; this admits only the two syntax shapes
// whose callee could name a `vm` member.
const candidate =
(t.isNewExpression(node) && syntacticMember(node.callee) === "Script") ||
(t.isCallExpression(node) &&
syntacticMember(node.callee) === "compileFunction");
if (candidate) context.accumulator.pendingDynamicCode.push(node);
};
const syntacticMember = (callee: t.Node): string | null => {
const node = unwrapJavaScriptExpression(callee).node;
return t.isMemberExpression(node) || t.isOptionalMemberExpression(node)
? semanticStaticPropertyKey(node.property, node.computed)
: null;
};
/** Resolve the deferred `node:vm` compilations against the settled index. */
export function* finishElectronDynamicCodeSteps(
context: JavaScriptFindingContext,
index: JavaScriptBindingIndex,
): Generator<void, void> {
const pending = context.accumulator.pendingDynamicCode;
if (pending.length === 0) return;
yield* index.indexMemberMutationFactsSteps();
for (const node of pending) {
const api = t.isNewExpression(node)
? vmMemberAt(node.callee, index) === "Script"
? "vm.Script"
: undefined
: vmMemberAt(node.callee, index) === "compileFunction"
? "vm.compileFunction"
: undefined;
if (api === undefined) continue;
const input: DynamicCodeInput = {
context,
index,
node,
api,
cachedDataArgument: api === "vm.Script" ? "named" : "positional",
};
yield* addCompilation(input);
}
pending.length = 0;
}
/** Resolve the complete callable through the shared built-in identity owner. */
const vmMemberAt = (
callee: t.Node,
index: JavaScriptBindingIndex,
): string | undefined => {
const path = builtinDataEffectCallablePath(callee, index.bindings, ["vm"]);
return path?.length === 1 ? path[0] : undefined;
};
function* addCompilation(input: DynamicCodeInput): Generator<void, void> {
const { context, node, api, cachedDataArgument } = input;
const optionsPosition = cachedDataArgument === "named" ? 1 : 2;
const spreadBeforeOptions = node.arguments
.slice(0, optionsPosition + 1)
.some((argument) => t.isSpreadElement(argument));
const options = argumentNode(node.arguments[optionsPosition]);
const lookup = spreadBeforeOptions
? ({ status: "dynamic" } as const)
: options === undefined
? ({ status: "missing" } as const)
: (() => {
const resolved = resolveElectronOptionObject(
options,
options,
input.index,
["cachedData"],
);
return resolved === undefined
? { status: "dynamic" as const }
: staticObjectProperty(resolved, "cachedData");
})();
const disabled =
lookup.status === "explicit" &&
!t.isNullLiteral(unwrapJavaScriptExpression(lookup.value).node) &&
context.expressionShapes.isNullish(lookup.value);
const cachedData: ElectronDynamicCodeSource =
lookup.status === "explicit"
? sourceOf(input, lookup.value)
: {
source_path: null,
source_resolution_context: null,
source_expression:
lookup.status === "dynamic"
? "cachedData: [dynamic-options]"
: "[absent]",
};
addLocatedFinding(context, {
collection: context.accumulator.dynamicCode,
key: compositeKey([
"electron-dynamic-code",
api,
cachedData.source_path,
cachedData.source_expression,
]),
node,
value: {
api,
cached_data_status:
lookup.status === "explicit"
? disabled
? "absent"
: "declared"
: lookup.status === "missing"
? "absent"
: "unknown",
cached_data: cachedData,
source_text: electronStaticValue(
context.source,
argumentNode(node.arguments[0]),
),
module_key: null,
location: range(node),
},
});
yield;
}
const sourceOf = (
input: DynamicCodeInput,
node: t.Node,
): ElectronDynamicCodeSource => {
// `cachedData = fs.readFileSync(<path>)` names the cache through the read's
// own argument; the call itself is not a path expression.
const path = cachePathExpression(
input.index,
stableExpression(input.index, node) ?? node,
);
const fact =
path === undefined
? undefined
: staticFileExpressionPath(path, input.context.pathIntrinsics);
return fact === undefined
? {
source_path: null,
source_resolution_context: null,
source_expression: boundedExpression(input.context.source, node),
}
: {
source_path: fact.path,
source_path_base: fact.base,
source_resolution_context: "filesystem-expression",
source_expression: null,
};
};
/** The path argument of a proven `fs` read that produced the cache value. */
const cachePathExpression = (
index: JavaScriptBindingIndex,
node: t.Node,
): t.Node | undefined => {
const value = unwrapJavaScriptExpression(node).node;
const awaited = t.isAwaitExpression(value);
const expression = awaited
? unwrapJavaScriptExpression(value.argument).node
: value;
if (!t.isCallExpression(expression)) return undefined;
const read = builtinDataEffectCallablePath(
expression.callee,
index.bindings,
awaited ? ["fs/promises"] : ["fs"],
);
const target =
read?.length === 1 && read[0] === (awaited ? "readFile" : "readFileSync")
? argumentNode(expression.arguments[0])
: undefined;
return target === undefined
? undefined
: unwrapJavaScriptExpression(target).node;
};
/**
* Follow one unrebound `const` to its sole initializer so a cache read into a
* local name still names the artifact, exactly as the option-object owner does.
*/
const stableExpression = (
index: JavaScriptBindingIndex,
node: t.Node,
): t.Node | undefined => {
const expression = unwrapJavaScriptExpression(node).node;
if (!t.isIdentifier(expression)) return undefined;
const binding = index.bindingForReferenceAt(expression);
if (binding?.mutable !== false) return undefined;
const initializer = semanticStableInitializer(binding, index.bindings);
return initializer === undefined
? undefined
: unwrapJavaScriptExpression(initializer.node).node;
};
@@ -276,6 +276,46 @@ export interface ElectronProtocolFinding {
readonly location: JavaScriptSourceRange;
}
/** Source of a script's bytes: an inert path expression or dynamic syntax. */
export type ElectronDynamicCodeSource =
| {
readonly source_path: string;
readonly source_path_base?:
| "source-directory"
| "literal-path"
| undefined;
readonly source_resolution_context: JavaScriptStaticPathContext;
readonly source_expression: null;
}
| {
readonly source_path: null;
readonly source_resolution_context: null;
readonly source_expression: string;
};
/**
* Source syntax for one node:vm compilation. Cache declaration does not establish
* accepted bytes or runtime execution.
*/
export interface ElectronDynamicCodeFinding {
readonly api: "vm.Script" | "vm.compileFunction";
/**
* Whether cachedData is declared, proven absent, or unresolved in options.
*/
readonly cached_data_status: "declared" | "absent" | "unknown";
/** The option or positional argument naming the backing cache artifact. */
readonly cached_data: ElectronDynamicCodeSource;
/**
* The source text argument when it is inert syntax, which is the case a
* bytecode-cache handoff replaces at runtime.
*/
readonly source_text: ElectronStaticValue;
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
}
/** All Electron-specific facts collected during one shared AST traversal. */
export interface ElectronStaticFindings {
/** Calls that may replace the application path used by Electron loadFile. */
@@ -287,6 +327,8 @@ export interface ElectronStaticFindings {
readonly utility_processes: readonly ElectronUtilityProcessFinding[];
readonly native_addon_bindings: readonly ElectronNativeAddonBindingFinding[];
readonly protocols: readonly ElectronProtocolFinding[];
/** `node:vm` compilations whose code may come from an opaque bytecode cache. */
readonly dynamic_code?: readonly ElectronDynamicCodeFinding[];
/** Source-bound candidate paths through bundled exports and IPC closures. */
readonly call_paths?: readonly ElectronCallPath[];
}
@@ -58,6 +58,8 @@ const electronBoundarySummarySchema = z.strictObject({
resolved_native_addon_bindings: countSchema,
/** Absent in evidence produced before protocol registrations were reported. */
protocol_registrations: countSchema.exactOptional(),
/** `node:vm` compilations whose logic may live in an opaque bytecode cache. */
v8_cached_data_compilations: countSchema.exactOptional(),
/**
* Whether the Electron boundary surface was actually recovered. `empty` means
* an analyzed source names Electron but no boundary fact was recovered, so the
@@ -22,7 +22,8 @@ export const estimateJavaScriptStaticApplicationProjection = (
facts.electron.sender_validations.length +
facts.electron.utility_processes.length +
facts.electron.native_addon_bindings.length +
facts.electron.protocols.length;
facts.electron.protocols.length +
(facts.electron.dynamic_code ?? []).length;
let records = findings * 8;
let repeatedBytes = 0;
for (const registration of facts.bundler_registrations) {
@@ -725,6 +725,7 @@ const BUILTIN_RECEIVER_METHODS: Readonly<Record<string, ReadonlySet<string>>> =
path: new Set(["resolve", "join", "dirname"]),
"path/posix": new Set(["resolve", "join", "dirname"]),
url: new Set(["fileURLToPath"]),
vm: new Set(["compileFunction"]),
fs: new Set([
"readFile",
"readFileSync",
@@ -21,6 +21,7 @@ import {
resolveElectronBrowserWindowReceiver,
} from "./electronStaticRenderer.js";
import { finishElectronNativeBindingsSteps } from "./electronStaticAnalysisNative.js";
import { finishElectronDynamicCodeSteps } from "./electronStaticAnalysisDynamicCode.js";
import { inspectElectronStaticNode } from "./electronStaticAnalysis.js";
import {
hasPossibleCommonJsDelegateWrite,
@@ -151,6 +152,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
finishBundlerExports(accumulator, index);
yield* finishElectronNativeBindingsSteps(findings, index);
yield* finishElectronBrowserWindowsSteps(findings, index);
yield* finishElectronDynamicCodeSteps(findings, index);
if (accumulator.pendingModuleRequireCalls.length > 0) {
yield* index.indexMemberMutationFactsSteps();
finishModuleRequireCalls(findings, index);
@@ -365,6 +367,14 @@ const finalizeStaticAnalysis = (
accumulator.nativeAddonBindings,
accumulator.moduleRangeIndex,
),
...(accumulator.dynamicCode.length > 0
? {
dynamic_code: finalizeLocatedFindings(
accumulator.dynamicCode,
accumulator.moduleRangeIndex,
),
}
: {}),
protocols: finalizeLocatedFindings(
accumulator.protocols,
accumulator.moduleRangeIndex,
@@ -22,6 +22,7 @@ import type {
ElectronNativeAddonBindingFinding,
ElectronSenderValidationFinding,
ElectronProtocolFinding,
ElectronDynamicCodeFinding,
ElectronUtilityProcessFinding,
ElectronWindowConstructor,
} from "./electronStaticAnalysisTypes.js";
@@ -95,6 +96,8 @@ export interface JavaScriptAnalysisAccumulator {
| t.AssignmentExpression
)[];
readonly protocols: LocatedJavaScriptFinding<ElectronProtocolFinding>[];
readonly dynamicCode: LocatedJavaScriptFinding<ElectronDynamicCodeFinding>[];
readonly pendingDynamicCode: (t.NewExpression | t.CallExpression)[];
readonly moduleRangeIndex: JavaScriptModuleRangeIndex;
/** Binding identities proven as third-parameter loaders of recovered factories. */
readonly factoryRequireBindingIds: Set<string>;
@@ -201,6 +204,8 @@ export const createJavaScriptAnalysisAccumulator = (
nativeAddonBindings: [],
pendingNativeAddonBindings: [],
protocols: [],
dynamicCode: [],
pendingDynamicCode: [],
moduleRangeIndex: new JavaScriptModuleRangeIndex(sourceLength),
factoryRequireBindingIds: new Set(),
seen: new Set(),