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

@@ -526,6 +526,25 @@ If fingerprint construction reaches its structural
bound, the fingerprint is unavailable and its status is `truncated`; a bounded
prefix is never presented as a complete stable fingerprint.
## Cached-data compilation boundaries
The [feature specification](../../specs/javascript/v8-cached-data/spec.md) defines
the static admission and uncertainty contract.
A `node:vm` compilation reports whether `cachedData` is declared, absent or
unknown. Explicitly supplied undefined bytes are treated as absent; shadowed
identifiers remain declarations. Source syntax alone does not establish valid cache bytes, V8 acceptance
or execution. The summary counts declared compilations, not executions.
A proven synchronous filesystem read or awaited promise-based read supplies a
cache path. The producer retains its path base: source-directory expressions
can name inventoried artifacts, while relative literal paths depend on the
target working directory and remain unresolved. Other supplied values retain
their bounded source expression. Declared paths, exact-file lookup
status, candidate paths and resolution limitations remain separate; an unresolved
path never becomes a resolved file. Opaque inventoried cache bytes can remain
unavailable to the JavaScript resolver even when their occurrence is known.
## Authority and unknowns
API identity is a derivation: a familiar method or property name does not
@@ -0,0 +1,10 @@
# Implementation plan
1. Derive VM callable identity and intact option objects from existing semantic
owners after mutation indexing; retain unresolved spreads as unknown.
2. Retain proven cache reads and their parsed bases through the named worker
contract. Resolve only source-directory paths against admitted inventory.
3. Project declarations into explicit graph unknowns and compilation summaries,
preserving source ranges, unresolved paths and execution limitations.
4. Verify negative mutation/spread controls and public filesystem workflows;
document runtime qualification separately from static analysis.
@@ -0,0 +1,43 @@
# V8 cached-data compilation boundaries
## Analyst question
Which source-visible Node VM compilation sites declare or may receive cached
bytes, and which admitted artifact can be associated with those bytes?
## Scope and authority
Static application analysis recognizes member calls to `vm.Script` and
`vm.compileFunction` when the shared built-in callable owner establishes their
identity. Analysis reads admitted source and inventory only. It does not invoke
the target, validate V8 cache bytes, establish cache acceptance, or execute code.
Node separates compilation, cache rejection and subsequent execution; see the
[Node VM contract](https://nodejs.org/docs/latest-v24.x/api/vm.html).
## Owning facts
- Resolve complete callable identity after shared mutation facts settle. ESM
default/namespace imports and proven CommonJS bindings use the same owner.
Replaced methods do not establish VM authority.
- `cached_data_status` is `declared` for an explicit cachedData property,
`absent` for proven omission or a proven undefined cache value, and `unknown` for unresolved options or argument
spreads that can supply or shift the options position. Declaration does not
prove a valid runtime value or successful cache use.
- A proven `fs.readFileSync` or awaited `fs/promises.readFile` can identify a
cache path. Unawaited promises and unproven readers retain bounded source
expressions. A supplied string alone is not proof that a file was read.
- Preserve the parsed path base through the worker boundary. Only a proven
source-directory base permits an application inventory lookup. Relative
literal paths depend on the unknown target working directory; source paths
and unresolved expressions stay separate from resolved artifact identity.
- Graph cache boundaries remain explicit unknowns with source provenance.
Absent sites do not add cache-boundary uncertainty. Summary counts describe
declared compilations, never observed executions.
## Compatibility and verification
Existing source declarations and inventory remain available. Optional path-base
facts may be absent in historical analyses; omission does not prove a base.
Public application workflows verify worker serialization, summary and graph
projection with local files. No test that merely supplies cache-shaped bytes
establishes real V8 acceptance or target execution.
@@ -0,0 +1,10 @@
# Tasks
- [x] Model declared, absent and unknown cached-data sites.
- [x] Reuse built-in callable and option-object ownership.
- [x] Retain synchronous and awaited promise read provenance and path bases.
- [x] Carry facts through worker serialization to summary and graph consumers.
- [x] Exercise malformed authority, argument spreads and unresolved paths.
- [x] Verify public filesystem application workflows and document limitations.
Real V8 acceptance and execution remain outside this static feature's scope.
@@ -1,3 +1,4 @@
import { isPossibleCachedDataCompilation } from "../../domain/javascript/electronDynamicCodeFacts.js";
import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import type { ElectronIpcFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
@@ -200,7 +201,10 @@ export const electronBoundaryFactCount = (
javascript.electron.native_addon_bindings.filter(
provenElectronFinding,
).length +
javascript.electron.protocols.filter(provenElectronFinding).length),
javascript.electron.protocols.filter(provenElectronFinding).length +
(javascript.electron.dynamic_code ?? []).filter(
isPossibleCachedDataCompilation,
).length),
0,
);
@@ -398,6 +402,14 @@ export const summarizeElectronBoundaries = (
}),
).length,
native_addon_bindings: nativeBindings.length,
v8_cached_data_compilations: javascript.reduce(
(count, { electron }) =>
count +
(electron.dynamic_code ?? []).filter(
({ cached_data_status }) => cached_data_status === "declared",
).length,
0,
),
resolved_native_addon_bindings: nativeBindings.filter(({ file, finding }) =>
resolvesToFile({
file,
@@ -3,6 +3,7 @@ import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphCo
import { addElectronIpcBoundaries } from "./ElectronBoundaryGraphIpc.js";
import { addElectronNativeBoundaries } from "./ElectronBoundaryGraphNative.js";
import { addElectronProtocolBoundaries } from "./ElectronBoundaryGraphProtocols.js";
import { addElectronDynamicCodeBoundaries } from "./ElectronBoundaryGraphDynamicCode.js";
import { addElectronWindowBoundaries } from "./ElectronBoundaryGraphWindows.js";
import { addElectronCallPaths } from "./ElectronBoundaryGraphCallPaths.js";
@@ -15,5 +16,6 @@ export const addElectronBoundaries = (
addElectronIpcBoundaries(context, ipc);
addElectronNativeBoundaries(context);
addElectronProtocolBoundaries(context);
addElectronDynamicCodeBoundaries(context);
addElectronCallPaths(context, ipc);
};
@@ -0,0 +1,156 @@
import { isPossibleCachedDataCompilation } from "../../domain/javascript/electronDynamicCodeFacts.js";
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
import type {
ElectronDynamicCodeFinding,
ElectronDynamicCodeSource,
} from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
javascriptAnalysisCoverage,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
import {
addElectronInferenceEdge,
electronFindingSourceNode,
electronObservationIdentity,
} from "./ElectronBoundaryGraphContext.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
interface DynamicCodeInput {
readonly context: JavaScriptArtifactGraphContext;
readonly file: JavaScriptArtifactFile;
readonly value: ElectronDynamicCodeFinding;
readonly coverage: JavaScriptArtifactGraphCoverage;
}
const CACHED_DATA_COMPILATION_LIMITATION =
"Source syntax declares or may supply cachedData to a V8 compilation API. Static analysis does not establish valid cache bytes, V8 acceptance, or execution; logic inside a possible cache remains unknown.";
/**
* Project `node:vm` compilations. A `cachedData` handoff becomes an explicit
* unknown carrying the inventoried cache artifact, so a thin wrapper whose
* preload logic lives in a bytecode cache is not read as an empty preload.
*/
export const addElectronDynamicCodeBoundaries = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed;
if (javascript === null) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const value of javascript.electron.dynamic_code ?? [])
addDynamicCodeSite({ context, file, value, coverage });
}
};
const addDynamicCodeSite = (input: DynamicCodeInput): void => {
const { context, file, value, coverage } = input;
if (!isPossibleCachedDataCompilation(value)) return;
const source = electronFindingSourceNode(
context,
file,
value.module_key,
value.module_runtime,
);
if (source === undefined) return;
const cache = value.cached_data;
const resolved = resolveCache(context, file, cache);
const node = context.accumulator.addNode({
kind: "unknown",
identity: electronObservationIdentity(
context,
"javascript-v8-cached-data",
compositeKey([
file.path,
value.api,
cache.source_path,
cache.source_expression,
javascriptSourceRangeKey(value.location),
]),
),
observations: [
{
label: cache.source_path ?? cache.source_expression,
properties: {
api: value.api,
cached_data_status: value.cached_data_status,
cache_path: cache.source_path,
cache_expression: cache.source_expression,
cache_path_base:
cache.source_path === null
? null
: (cache.source_path_base ?? null),
resolved_cache_path: resolved?.resolved_path ?? null,
resolution_status: resolved?.resolution_status ?? null,
resolution_context: resolved?.resolution_context ?? null,
candidate_cache_path: resolved?.candidate_path ?? null,
resolution_limitations: resolved?.limitations ?? [],
source_text: value.source_text.value,
source_expression: value.source_text.expression,
compilation_boundary: "v8-cached-data",
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-v8-cached-data-compilation",
coverage,
limitations: [CACHED_DATA_COMPILATION_LIMITATION],
}),
},
],
});
addElectronInferenceEdge(context, {
source,
target: node,
file,
range: value.location,
coverage,
relation: "loads",
operation: "associate-v8-cached-data-compilation",
properties: {
api: value.api,
cache_path: cache.source_path,
cache_expression: cache.source_expression,
cache_path_base:
cache.source_path === null ? null : (cache.source_path_base ?? null),
resolved_cache_path: resolved?.resolved_path ?? null,
resolution_status: resolved?.resolution_status ?? null,
resolution_context: resolved?.resolution_context ?? null,
candidate_cache_path: resolved?.candidate_path ?? null,
resolution_limitations: resolved?.limitations ?? [],
},
limitations: [CACHED_DATA_COMPILATION_LIMITATION],
});
};
/**
* The cache artifact the handoff names, when the path is inert syntax. The
* cache often is not a file this analysis reads, so lookup uses the full
* inventory map rather than the relevant-file projection.
*/
const resolveCache = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
cache: ElectronDynamicCodeSource,
) => {
if (
cache.source_path === null ||
cache.source_path_base !== "source-directory"
)
return null;
const resolved = resolveArtifactPathByContext({
declaredPath: cache.source_path,
sourcePath: file.path,
context: cache.source_resolution_context,
files: context.filesByPath,
jsonDocuments: context.analysis.jsonDocuments,
directoryPaths: context.directoryPaths,
unanalyzedFiles: context.unanalyzedFiles,
runtimePathKind: "exact-file",
});
return resolved;
};
@@ -775,7 +775,8 @@ const findingCount = (analysis: JavaScriptStaticAnalysis): number =>
analysis.electron.sender_validations.length +
analysis.electron.utility_processes.length +
analysis.electron.native_addon_bindings.length +
analysis.electron.protocols.length;
analysis.electron.protocols.length +
(analysis.electron.dynamic_code ?? []).length;
const optionalString = (value: unknown): string | null =>
typeof value === "string" && value.length > 0 ? value : null;
@@ -1,3 +1,4 @@
import { isPossibleCachedDataCompilation } from "../../domain/javascript/electronDynamicCodeFacts.js";
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import {
electronIncompleteSourceCount,
@@ -434,6 +435,15 @@ const graphLimitations = (
"Native member names are requested by JavaScript syntax and are not verified binary exports in this workflow.",
]
: []),
...(context.analysis.files.some(({ javascript }) =>
(javascript?.electron.dynamic_code ?? []).some(
isPossibleCachedDataCompilation,
),
)
? [
"A node:vm compilation may receive cachedData; static syntax does not establish cache acceptance or execution, and possible cached logic remains unknown rather than absent.",
]
: []),
...(coverage === "complete"
? []
: [
@@ -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(),
@@ -304,6 +304,36 @@ const electron = z
dynamic_member_access: z.boolean().optional(),
}),
),
dynamic_code: z
.array(
z
.strictObject({
...findingContext,
api: z.enum(["vm.Script", "vm.compileFunction"]),
cached_data_status: z.enum(["declared", "absent", "unknown"]),
cached_data: z.union([
z.strictObject({
source_path: z.string(),
source_path_base: z
.enum(["source-directory", "literal-path"])
.optional(),
source_resolution_context: z.enum([
"filesystem-expression",
"module-specifier",
]),
source_expression: z.null(),
}),
z.strictObject({
source_path: z.null(),
source_resolution_context: z.null(),
source_expression: z.string(),
}),
]),
source_text: staticValue,
})
.transform(withRuntime),
)
.optional(),
protocols: z.array(
z
.strictObject({
@@ -322,10 +352,11 @@ const electron = z
.transform(withRuntime),
),
})
.transform(({ call_paths, app_path_changes, ...rest }) => ({
.transform(({ call_paths, app_path_changes, dynamic_code, ...rest }) => ({
...rest,
...(app_path_changes === undefined ? {} : { app_path_changes }),
...(call_paths === undefined ? {} : { call_paths }),
...(dynamic_code === undefined ? {} : { dynamic_code }),
}));
const referenceShape = {
...findingContext,
@@ -0,0 +1,189 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import {
analyzeJavaScriptApplication,
reconstructJavaScriptArtifact,
} from "../../../support/javascriptApplicationScope.js";
import { parseEvidence } from "../../../../src/domain/evidence.js";
import { parseJavaScriptApplicationGraph } from "../../../../src/domain/javascript/javascriptApplicationGraph.js";
const WRAPPER = String.raw`
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();
`;
const vmFixture = async (source: string): Promise<string> => {
const root = await createTestTempDirectory("rea-electron-vm-cache-");
await Promise.all([
writeFile(
join(root, "package.json"),
JSON.stringify({ name: "rea-electron-vm-cache", main: "main.cjs" }),
),
writeFile(join(root, "main.cjs"), source),
writeFile(join(root, "preload.bundle.cache"), Buffer.from([0, 1, 2, 255])),
]);
return root;
};
describe("V8 cached data compilation boundaries", () => {
it("projects an explicit unknown for the bytecode handoff", async () => {
const result = await reconstructJavaScriptArtifact({
input_path: await vmFixture(WRAPPER),
});
const graph = parseJavaScriptApplicationGraph(result.graph);
const unknown = graph.nodes.filter(
({ kind, observations }) =>
kind === "unknown" &&
observations.some(
({ properties }) =>
properties.compilation_boundary === "v8-cached-data",
),
);
expect(unknown).toHaveLength(1);
expect(unknown[0]?.observations[0]?.properties).toMatchObject({
api: "vm.Script",
cached_data_status: "declared",
cache_path: "preload.bundle.cache",
resolved_cache_path: null,
candidate_cache_path: "preload.bundle.cache",
resolution_status: "unavailable",
source_expression: "sourcePlaceholder",
});
expect(unknown[0]?.observations[0]?.evidence.limitations).toEqual(
expect.arrayContaining([
expect.stringContaining("does not establish valid cache bytes"),
]),
);
expect(
graph.edges.filter(({ target_node_id: target }) =>
unknown.some(({ node_id }) => node_id === target),
),
).toEqual([
expect.objectContaining({
relation: "loads",
properties: expect.objectContaining({ api: "vm.Script" }),
}),
]);
});
it("stays silent when compilation has no bytecode cache", async () => {
const result = await reconstructJavaScriptArtifact({
input_path: await vmFixture(String.raw`
const vm = require("node:vm");
new vm.Script("require('node:fs')").runInThisContext();
vm.compileFunction("return 1", [], {});
`),
});
const graph = parseJavaScriptApplicationGraph(result.graph);
expect(
graph.nodes.filter(({ observations }) =>
observations.some(
({ properties }) =>
properties.compilation_boundary === "v8-cached-data",
),
),
).toEqual([]);
expect(graph.limitations.some((note) => note.includes("cachedData"))).toBe(
false,
);
});
});
describe("V8 cached data summary", () => {
it("counts cachedData declarations without claiming an empty preload", async () => {
const result = await analyzeJavaScriptApplication({
input_path: await vmFixture(WRAPPER),
});
if (!result.ok) throw result.error;
expect(parseEvidence(result.value)).toMatchObject({
normalized_result: {
summary: expect.objectContaining({ v8_cached_data_compilations: 1 }),
},
});
});
});
it("keeps missing, dynamic and arbitrary cachedData sources distinct", async () => {
for (const [options, status, path] of [
[
'{ cachedData: fs.readFileSync("./missing.cache") }',
"declared",
"./missing.cache",
],
["getOptions()", "unknown", null],
['{ cachedData: "./preload.bundle.cache" }', "declared", null],
] as const) {
const root = await vmFixture(
`const fs = require("node:fs"); const vm = require("node:vm"); new vm.Script("void 0", ${options});`,
);
const result = await reconstructJavaScriptArtifact({ input_path: root });
const graph = parseJavaScriptApplicationGraph(result.graph);
const observation = graph.nodes
.flatMap((node) => node.observations)
.find(
({ properties }) =>
properties.compilation_boundary === "v8-cached-data",
);
if (options.includes('cachedData: "'))
expect(observation?.properties.cache_expression).toBe(
'"./preload.bundle.cache"',
);
expect(observation?.properties).toMatchObject({
cached_data_status: status,
cache_path: path,
resolved_cache_path: null,
});
}
});
it("does not resolve a cwd-relative filesystem cache to an inventoried source sibling", async () => {
const root = await vmFixture(
`const fs = require("node:fs"); const vm = require("node:vm"); new vm.Script(source, { cachedData: fs.readFileSync("./preload.bundle.cache") });`,
);
const result = await reconstructJavaScriptArtifact({ input_path: root });
const graph = parseJavaScriptApplicationGraph(result.graph);
const observation = graph.nodes
.flatMap(({ observations }) => observations)
.find(
({ properties }) => properties.compilation_boundary === "v8-cached-data",
);
expect(observation?.properties).toMatchObject({
cache_path: "./preload.bundle.cache",
cache_path_base: "literal-path",
resolved_cache_path: null,
});
});
it("omits opaque-cache graph boundaries for proven undefined cache bytes", async () => {
const root = await vmFixture(
'const vm = require("node:vm"); new vm.Script(source, { cachedData: void 0 });',
);
const result = await reconstructJavaScriptArtifact({ input_path: root });
const graph = parseJavaScriptApplicationGraph(result.graph);
expect(
graph.nodes.some(({ observations }) =>
observations.some(
({ properties }) =>
properties.compilation_boundary === "v8-cached-data",
),
),
).toBe(false);
});
it("does not count source-only VM compilation as recovered Electron semantics", async () => {
const root = await vmFixture(
'const electron = require("electron"); const vm = require("node:vm"); new vm.Script(source);',
);
const result = await analyzeJavaScriptApplication({ input_path: root });
if (!result.ok) throw result.error;
expect(parseEvidence(result.value).normalized_result).toMatchObject({
summary: { electron_semantics: "empty", v8_cached_data_compilations: 0 },
});
});