fix(javascript): trace wrapped Electron IPC paths in large bundles (#1874)

Co-authored-by: David <17119984+fengdwx@users.noreply.github.com>
This commit is contained in:
DavidandDavid authored and GitHub committed 2026-10-12 09:28:59 +08:00
1 parent d93fbda390
commit 87b9793c53
46 files changed
+3213 -355

No files matched your search

+41 -2
View File
@@ -174,6 +174,24 @@ default). Reaching it fails the current source with the same memory resource
failure and reports `worker_process_memory_limit_bytes`, also a coverage limit;
the next source starts a new analyzer. Bun elsewhere enforces no analyzer limit.
The default semantic capacities are 100000 nodes across the application and
20000 per source. Large bundles may explicitly select `max_semantic_nodes` and
`max_semantic_nodes_per_file` (CLI: `--max-semantic-nodes` and
`--max-semantic-nodes-per-file`), each from 1 to 1000000. The per-file offer is
also bounded by the remaining application capacity. Raising these controls does
not bypass worker heap, execution deadline or parent result admission. Truncated
coverage reports both effective node ceilings; omitted relationships remain
unknown.
While sources remain, the sequential worker reserves one next compact static
checkpoint at its 8 MiB bound with the same 6x retention expansion used at
admission. Earlier checkpoints are already charged to retained bytes; the
remaining inventory does not multiply the reserve. Streamed file transfers keep the default 512 MiB
allowance, and can grow with the parent's offered retention budget (8x byte
expansion), up to 2 GiB. Individual records remain bounded to 8 MiB. The parent
still independently checks actual bytes, record overhead, digests and source
identities before adopting results.
A source that exhausts the analyzer heap does not terminate the CLI/MCP
process. Completed static facts are retained, other files can still be analyzed,
and unexamined semantic relationships remain explicit unknowns. The parent
@@ -366,9 +384,30 @@ Source-map contents are parsed as local input. The graph stores original source
Only explicitly present BrowserWindow values are observations. REA does not
substitute version-dependent Electron defaults for omitted `webPreferences`.
Dynamic option objects, bridge keys, API objects, and IPC channel expressions
Unresolved option objects, bridge keys, API objects, and IPC channel expressions
remain unknown and make coverage partial.
Recognized literal module-table bootstraps also support bounded lexical
substitution through module exports, import helper bodies, local wrapper calls,
returned closures, object spreads, and callback parameters. Recovered Electron
paths retain source ranges, module identities, literal channels and branch
conditions as static candidates. Unknown spreads, reassigned bindings, accessors,
dynamic channels and unsupported syntax do not become exact implementations.
This pass neither executes the bundle nor proves that runtime guards pass.
An ambiguous or unresolved handler retains its channel registration but does
not acquire a path to one arbitrarily selected callback. Unknown factory
conditions retain both return branches, including fallthrough. Short-circuit
selection distinguishes truthiness from nullishness; uncertain `??` paths
retain a nullish guard rather than a falsy guard.
These paths appear as contextual `javascript-expression` nodes. An exact `api`
seed such as `desktop.copy` can trace an exposed member through a unique IPC
pairing to an external API call. `addListener` is normalized to `on`. Semantic
projection retains these boundary paths before bulk function definitions, within
the existing per-file node budget; omitted general semantics remain explicitly
partial. Wrapper expansion is bounded by one million visits/substitutions and
128 nested substitutions, with exhaustion reported as unknown coverage.
Electron API identity follows proven lexical module aliases. Namespace interop
also preserves identity when the complete Rolldown helper operations, lexical
roles, live getters, and captured intrinsic origins match, or when a stable
@@ -415,7 +454,7 @@ do not establish that an application has no Electron boundary usage.
IPC pairing is an inference, not an observation. A renderer `invoke` pairs only
with one unique `ipcMain.handle`/`handleOnce` candidate on the same exact literal
channel; a renderer send pairs only with one unique `ipcMain.on`/`once`
channel; a renderer send pairs only with one unique `ipcMain.on`/`once`/`addListener`
candidate. Dynamic channels are never paired, and multiple compatible handlers
are reported as ambiguous without adding a caller-to-handler edge. A matching
channel still does not prove registration order, reachability, or runtime use.
@@ -221,4 +221,4 @@ const resolvesToFile = (input: ResolveToFileInput): boolean => {
};
const ipcRecordKey = (path: string, finding: ElectronIpcFinding): string =>
`${path}\0${String(finding.location.start.line)}:${String(finding.location.start.column)}\0${finding.side}\0${finding.operation}`;
`${path}\0${String(finding.location.start.line)}:${String(finding.location.start.column)}\0${finding.side}\0${finding.operation}\0${finding.channel ?? finding.channel_expression}\0${JSON.stringify(finding.handler_location)}`;
@@ -2,6 +2,7 @@ import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphCo
import { addElectronIpcBoundaries } from "./ElectronBoundaryGraphIpc.js";
import { addElectronNativeBoundaries } from "./ElectronBoundaryGraphNative.js";
import { addElectronWindowBoundaries } from "./ElectronBoundaryGraphWindows.js";
import { addElectronCallPaths } from "./ElectronBoundaryGraphCallPaths.js";
/** Add every static Electron process and security-boundary fact to the JAG. */
export const addElectronBoundaries = (
@@ -10,4 +11,5 @@ export const addElectronBoundaries = (
addElectronWindowBoundaries(context);
addElectronIpcBoundaries(context);
addElectronNativeBoundaries(context);
addElectronCallPaths(context);
};
@@ -0,0 +1,179 @@
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js";
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import {
collectElectronIpcRecords,
unambiguousElectronIpcPairings,
type ElectronIpcRecord,
} from "./ElectronBoundaryAnalysis.js";
import {
addElectronInferenceEdge,
electronFindingSourceNode,
electronRangeKey,
} from "./ElectronBoundaryGraphContext.js";
import {
artifactLocalIdentity,
javascriptAnalysisCoverage,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { staticInferenceEvidence } from "./JavaScriptArtifactGraphEvidence.js";
interface ProjectedPath {
readonly file: JavaScriptArtifactFile;
readonly path: ElectronCallPath;
readonly coverage: JavaScriptArtifactGraphCoverage;
readonly nodes: readonly ApplicationNode[];
}
/** Retain context-specific wrapper paths, then join only unique IPC records. */
export const addElectronCallPaths = (
context: JavaScriptArtifactGraphContext,
): void => {
const projected: ProjectedPath[] = [];
for (const { file, javascript } of context.analysis.files) {
if (javascript === null) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const path of javascript.electron.call_paths ?? []) {
const pathId = sha256Text(JSON.stringify(path));
const nodes = path.steps.map((step, index) =>
context.accumulator.addNode({
kind: "javascript-expression",
identity: artifactLocalIdentity(
file.sha256,
"electron-call-path-step",
`${pathId}:${String(index)}`,
),
observations: [
{
label: step.label,
properties: {
path_id: pathId,
entry: path.entry,
entry_kind: path.entry_kind,
channel: path.channel,
terminal: path.terminal,
step_kind: step.kind,
step_index: index,
module_key: step.module_key,
module_runtime: step.module_runtime,
conditions: path.conditions,
resolution: "candidate",
},
evidence: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: step.location,
operation: "substitute-electron-wrapper",
coverage,
limitations: [
"Lexical wrapper substitution is a static candidate; guards, runtime overrides and execution remain unverified.",
],
}),
},
],
}),
);
const first = nodes[0],
entry = path.steps[0];
const owner = entry
? electronFindingSourceNode(
context,
file,
entry.module_key,
entry.module_runtime ?? undefined,
)
: undefined;
if (first && entry && owner)
addElectronInferenceEdge(context, {
source: owner,
target: first,
file,
range: entry.location,
coverage,
relation: "contains",
operation: "locate-electron-wrapper-path",
properties: {
path_id: pathId,
entry: path.entry,
resolution: "candidate",
},
});
for (let i = 1; i < nodes.length; i += 1) {
const source = nodes[i - 1],
target = nodes[i],
previous = path.steps[i - 1],
step = path.steps[i];
if (!source || !target || !previous || !step) continue;
addElectronInferenceEdge(context, {
source,
target,
file,
range: step.location,
coverage,
relation: ["exposure", "factory", "ipc"].includes(previous.kind)
? "maps_to"
: "calls",
operation: "trace-electron-wrapper-path",
properties: {
path_id: pathId,
entry: path.entry,
channel: path.channel,
resolution: "candidate",
conditions: path.conditions,
},
});
}
projected.push({ file, path, coverage, nodes });
}
}
for (const { transmission, handler } of unambiguousElectronIpcPairings(
collectElectronIpcRecords(context.analysis),
)) {
const sources = projected.filter(
(p) => p.path.entry_kind === "bridge-member" && matches(p, transmission),
);
const targets = projected.filter(
(p) => p.path.entry_kind === "ipc-handler" && matches(p, handler),
);
for (const from of sources)
for (const to of targets) {
const source = from.nodes.at(-1),
target = to.nodes[0];
if (!source || !target) continue;
addElectronInferenceEdge(context, {
source,
target,
file: from.file,
range: transmission.finding.location,
coverage: from.coverage,
relation:
transmission.finding.mode === "invoke" ? "invokes" : "sends",
operation: "join-electron-wrapper-paths",
properties: {
channel: transmission.finding.channel,
pairing_basis: "unique-exact-literal-channel",
resolution: "candidate",
handler_path: to.file.path,
conditions: to.path.conditions,
},
limitations: [
"The unique static channel match does not establish runtime registration, sender acceptance, or dispatch.",
],
});
}
}
};
const matches = (
projected: ProjectedPath,
record: ElectronIpcRecord,
): boolean =>
projected.file.path === record.file.path &&
projected.path.channel === record.finding.channel &&
projected.path.steps.some(
(step) =>
electronRangeKey(step.location) ===
electronRangeKey(record.finding.location),
);
@@ -27,6 +27,7 @@ export interface ElectronGraphEdgeInput {
| "exposes"
| "sends"
| "invokes"
| "calls"
| "handles";
readonly operation: string;
readonly properties: Readonly<Record<string, unknown>>;
@@ -7,7 +7,10 @@ import {
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import {
collectElectronIpcRecords,
unambiguousElectronIpcPairings,
@@ -120,11 +123,18 @@ const addChannelNode = (
: `literal:${finding.channel}`;
return context.accumulator.addNode({
kind: "ipc-channel",
identity: electronObservationIdentity(
context,
"electron-ipc-channel",
identityKey,
),
identity:
finding.resolution_basis === "wrapper-substitution"
? artifactLocalIdentity(
file.sha256,
"electron-inferred-ipc-channel",
identityKey,
)
: electronObservationIdentity(
context,
"electron-ipc-channel",
identityKey,
),
observations: [
{
label: finding.channel ?? finding.channel_expression,
@@ -136,18 +146,24 @@ const addChannelNode = (
channel_expression: finding.channel_expression,
resolution: finding.channel === null ? "dynamic" : "literal",
},
evidence: astObservationEvidence({
evidence: (finding.resolution_basis === "wrapper-substitution"
? staticInferenceEvidence
: astObservationEvidence)({
sha256: file.sha256,
path: file.path,
range: finding.location,
operation: "observe-ipc-channel-expression",
coverage,
limitations:
finding.channel === null
finding.resolution_basis === "wrapper-substitution"
? [
"The channel expression is dynamic; its runtime value and pairings remain unknown.",
"The channel is inferred through lexical wrapper substitution; runtime registration and dispatch remain unverified.",
]
: [],
: finding.channel === null
? [
"The channel expression is dynamic; its runtime value and pairings remain unknown.",
]
: [],
}),
},
],
@@ -177,7 +193,9 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
handler_kind: finding.handler_kind,
handler_location: finding.handler_location,
},
evidence: astObservationEvidence({
evidence: (finding.resolution_basis === "wrapper-substitution"
? staticInferenceEvidence
: astObservationEvidence)({
sha256: file.sha256,
path: file.path,
range: handlerRange,
@@ -13,7 +13,10 @@ import {
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import {
addElectronAstEdge,
addElectronInferenceEdge,
@@ -190,8 +193,11 @@ const addContextBridge = (
api_status: value.api_status,
members: value.members,
unknown_members: value.unknown_members,
resolution_basis: value.resolution_basis ?? "direct-syntax",
},
evidence: astObservationEvidence({
evidence: (value.resolution_basis === "wrapper-substitution"
? staticInferenceEvidence
: astObservationEvidence)({
sha256: file.sha256,
path: file.path,
range: value.location,
@@ -91,6 +91,12 @@ const parameters = (
): EvidenceObservation["parameters"] => ({
format: input.format,
integrity_policy: input.integrity_policy,
...(input.max_semantic_nodes === undefined
? {}
: { max_semantic_nodes: input.max_semantic_nodes }),
...(input.max_semantic_nodes_per_file === undefined
? {}
: { max_semantic_nodes_per_file: input.max_semantic_nodes_per_file }),
...(input.source_paths === undefined
? {}
: { source_paths: input.source_paths }),
@@ -66,6 +66,12 @@ export const analyzeJavaScriptApplicationValidated = async (
input_path: input.input_path,
format: input.format,
integrity_policy: input.integrity_policy,
...(input.max_semantic_nodes === undefined
? {}
: { max_semantic_nodes: input.max_semantic_nodes }),
...(input.max_semantic_nodes_per_file === undefined
? {}
: { max_semantic_nodes_per_file: input.max_semantic_nodes_per_file }),
...(input.source_paths === undefined
? {}
: { source_paths: input.source_paths }),
@@ -139,7 +139,14 @@ export const reconstructJavaScriptArtifact = async (
"parse_javascript_sources",
`Parsing and projecting ${String(files.files.length - (files.source_selection?.unselected_files ?? 0))} selected application source files`,
);
const semanticProjection = createJavaScriptSemanticGraphProjection();
const semanticProjection = createJavaScriptSemanticGraphProjection({
...(input.max_semantic_nodes === undefined
? {}
: { totalNodes: input.max_semantic_nodes }),
...(input.max_semantic_nodes_per_file === undefined
? {}
: { fileNodes: input.max_semantic_nodes_per_file }),
});
const worker = createJavaScriptSourceAnalysis(
{
heapMb:
@@ -6,6 +6,8 @@ import { expect, it } from "vitest";
import {
SEMANTIC_GRAPH_FILE_NODE_CEILING,
SEMANTIC_GRAPH_NODE_CEILING,
createJavaScriptSemanticGraphProjection,
projectJavaScriptSemanticFileSteps,
} from "./JavaScriptSemanticGraphBuilder.js";
import {
addSemanticGraphNode,
@@ -905,3 +907,48 @@ it.each(["{}", "unknownRoot", "{ a: null }"])(
expect(trace.nodes.filter(({ kind }) => kind === "literal")).toEqual([]);
},
);
it("projects and adopts more than the default per-file capacity when explicitly offered", () => {
const text = "external;\n".repeat(20_001);
const file: JavaScriptArtifactFile = {
path: "large.js",
container_sha256: SHA256,
sha256: SHA256,
bytes: Buffer.byteLength(text),
inventory_artifact_id: `art_${SHA256}`,
kind: "javascript",
unpacked: false,
text: { included: true, value: text },
};
const steps = projectJavaScriptSemanticFileSteps(
file,
analyzeJavaScriptSemantics(text),
20_002,
);
let result = steps.next();
while (!result.done) result = steps.next();
expect(result.value.nodes).toHaveLength(20_002);
expect(result.value.truncated).toBe(false);
const projection = createJavaScriptSemanticGraphProjection({
totalNodes: 20_010,
fileNodes: 20_002,
});
projection.adoptFileProjection(result.value);
expect(projection.remainingFileNodeBudget()).toBe(8);
const graph = projection.finish(SHA256, {
graph_id: `jag_${"b".repeat(64)}`,
nodes: [],
});
expect(graph.coverage).toMatchObject({
truncated: false,
omitted_nodes: 0,
omitted_relations: 0,
});
const bounded = createJavaScriptSemanticGraphProjection({
totalNodes: 20_001,
fileNodes: 20_002,
});
expect(() => bounded.adoptFileProjection(result.value)).toThrow(
"exceed the offered tree capacity",
);
});
@@ -41,7 +41,11 @@ import {
type ApplicationCoverage,
} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { javaScriptAnalysisCoverageLimits } from "../../domain/javascript/javascriptAnalysisResourceControls.js";
import {
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS,
JAVASCRIPT_SEMANTIC_NODE_MAXIMUM,
javaScriptAnalysisCoverageLimits,
} from "../../domain/javascript/javascriptAnalysisResourceControls.js";
import { projectSemanticFunctionFingerprints } from "./JavaScriptSemanticGraphFingerprintProjection.js";
import {
projectSemanticClosureCaptures,
@@ -65,6 +69,8 @@ import {
type SemanticGraphProjectionState as BuilderState,
} from "./JavaScriptSemanticGraphConstruction.js";
import type { JavaScriptSemanticFileProjection } from "../../domain/javascript/javascriptSemanticFileProjection.js";
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import { projectElectronSemanticCallPaths } from "./JavaScriptSemanticGraphElectronProjection.js";
interface BuilderInput {
readonly rootArtifactSha256: string;
@@ -105,13 +111,13 @@ interface FileContext extends SemanticFlowProjectionContext {
* (`truncated`, `omitted_nodes`, `omitted_relations`, `limits`) rather than
* silently dropping data.
*
* Sizing: a retained semantic node costs roughly 3 KB once serialized, and V8
* additionally caps a single string at 512 MB. The result is serialized after the
* object graph is built and validated, so both phases must fit together. A single
* minified vendor bundle can account for most of a tree's nodes, so a whole-tree
* ceiling without a per-file ceiling does not bound the work. Both are applied.
* Defaults bound work even when ample heap is available. Callers may raise the
* capacities explicitly; isolated worker heap, bounded record transport and
* parent result admission independently constrain actual retained memory. The
* result uses incremental serialization rather than a single JSON string.
*/
export const SEMANTIC_GRAPH_NODE_CEILING = 100_000;
export const SEMANTIC_GRAPH_NODE_CEILING =
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodes;
/**
* Per-file share of the semantic node budget.
@@ -119,7 +125,8 @@ export const SEMANTIC_GRAPH_NODE_CEILING = 100_000;
* Bound each source file independently so one large bundled library cannot
* consume the whole tree's budget and starve the application's own modules.
*/
export const SEMANTIC_GRAPH_FILE_NODE_CEILING = 20_000;
export const SEMANTIC_GRAPH_FILE_NODE_CEILING =
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodesPerFile;
export const buildJavaScriptSemanticGraph = ({
rootArtifactSha256,
@@ -129,7 +136,11 @@ export const buildJavaScriptSemanticGraph = ({
const projection = createJavaScriptSemanticGraphProjection();
for (const analyzed of analysis.files) {
if (analyzed.semantic !== null)
projection.projectFile(analyzed.file, analyzed.semantic.ir);
projection.projectFile(
analyzed.file,
analyzed.semantic.ir,
analyzed.javascript?.electron.call_paths,
);
else if (
!analyzed.file.text.included &&
analyzed.file.text.reason === "outside-analysis-scope"
@@ -162,11 +173,13 @@ export interface JavaScriptSemanticGraphProjection {
readonly projectFile: (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
paths?: readonly ElectronCallPath[],
) => void;
/** Project one file in steps between its projection passes. */
readonly projectFileSteps: (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
paths?: readonly ElectronCallPath[],
) => Generator<void, void>;
readonly finish: (
rootArtifactSha256: string,
@@ -179,234 +192,241 @@ export interface JavaScriptSemanticGraphProjection {
}
/** Accumulate semantic nodes while each source IR is still file-local. */
export const createJavaScriptSemanticGraphProjection =
(): JavaScriptSemanticGraphProjection => {
const state = emptyState();
const fingerprints: JavaScriptSemanticFingerprint[] = [];
let truncatedFiles = 0;
let unavailableFiles = 0;
let resourceTruncatedFiles = 0;
const executionLimits = new Map<
string,
ApplicationCoverage["limits"][number]
>();
const projectSourceSteps = function* (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
): Generator<void, void> {
if (state.nodes.size >= SEMANTIC_GRAPH_NODE_CEILING) {
truncatedFiles += 1;
return;
}
const remainingTreeBudget =
SEMANTIC_GRAPH_NODE_CEILING - state.nodes.size;
state.fileNodeBudget = Math.min(
SEMANTIC_GRAPH_FILE_NODE_CEILING,
remainingTreeBudget,
);
state.fileNodesDropped = false;
fingerprints.push(...(yield* projectFileSteps(file, ir, state)));
// Nodes were dropped only if the budget actually blocked creation. A file
// that exactly fills its share ends with a zero budget without dropping
// anything, so the remaining budget alone cannot decide truncation.
if (state.fileNodesDropped) truncatedFiles += 1;
state.fileNodeBudget = null;
};
const finishWith = <Value>(
factory: (input: unknown) => Value,
rootArtifactSha256: string,
applicationGraph: BuilderInput["applicationGraph"],
): Value => {
bindSemanticGraphApplicationNodes(state, applicationGraph);
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
const relations = [...state.relations.values()];
const unknowns = [...state.unknowns.values()];
type Family = (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number];
const retainedByFamily = new Map<Family, number>();
const unknownIdsByFamily = new Map<Family, string[]>();
for (const relation of relations) {
const family = JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation];
retainedByFamily.set(family, (retainedByFamily.get(family) ?? 0) + 1);
}
for (const unknown of unknowns) {
const identifiers = unknownIdsByFamily.get(unknown.family);
if (identifiers === undefined)
unknownIdsByFamily.set(unknown.family, [unknown.unknown_id]);
else identifiers.push(unknown.unknown_id);
}
const graph = factory({
schema: "JavaScriptSemanticRelationGraph",
root_artifact_sha256: rootArtifactSha256,
application_graph_id: applicationGraph.graph_id,
root_node_ids: [...state.roots],
evidence_contexts: state.evidenceContexts.contexts,
nodes: [...state.nodes.values()],
relations,
// The input is transferred to the factory; `fingerprints` is cleared below.
fingerprints: [...fingerprints],
unknowns,
coverage: {
status:
truncatedFiles > 0 || unavailableFiles > 0 ? "partial" : "unknown",
truncated: truncatedFiles > 0 || resourceTruncatedFiles > 0,
omitted_nodes: truncatedFiles > 0 || unavailableFiles > 0 ? null : 0,
export const createJavaScriptSemanticGraphProjection = (
limits: { readonly totalNodes?: number; readonly fileNodes?: number } = {},
): JavaScriptSemanticGraphProjection => {
const nodeCeiling = limits.totalNodes ?? SEMANTIC_GRAPH_NODE_CEILING;
const fileNodeCeiling = limits.fileNodes ?? SEMANTIC_GRAPH_FILE_NODE_CEILING;
for (const value of [nodeCeiling, fileNodeCeiling])
if (
!Number.isSafeInteger(value) ||
value < 1 ||
value > JAVASCRIPT_SEMANTIC_NODE_MAXIMUM
)
throw new RangeError("Semantic graph requires valid node capacities");
const state = emptyState();
const fingerprints: JavaScriptSemanticFingerprint[] = [];
let truncatedFiles = 0;
let unavailableFiles = 0;
let resourceTruncatedFiles = 0;
const executionLimits = new Map<
string,
ApplicationCoverage["limits"][number]
>();
const projectSourceSteps = function* (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
paths: readonly ElectronCallPath[] = [],
): Generator<void, void> {
if (state.nodes.size >= nodeCeiling) {
truncatedFiles += 1;
return;
}
const remainingTreeBudget = nodeCeiling - state.nodes.size;
state.fileNodeBudget = Math.min(fileNodeCeiling, remainingTreeBudget);
state.fileNodesDropped = false;
fingerprints.push(...(yield* projectFileSteps(file, ir, state, paths)));
// Nodes were dropped only if the budget actually blocked creation. A file
// that exactly fills its share ends with a zero budget without dropping
// anything, so the remaining budget alone cannot decide truncation.
if (state.fileNodesDropped) truncatedFiles += 1;
state.fileNodeBudget = null;
};
const finishWith = <Value>(
factory: (input: unknown) => Value,
rootArtifactSha256: string,
applicationGraph: BuilderInput["applicationGraph"],
): Value => {
bindSemanticGraphApplicationNodes(state, applicationGraph);
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
const relations = [...state.relations.values()];
const unknowns = [...state.unknowns.values()];
type Family = (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number];
const retainedByFamily = new Map<Family, number>();
const unknownIdsByFamily = new Map<Family, string[]>();
for (const relation of relations) {
const family = JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation];
retainedByFamily.set(family, (retainedByFamily.get(family) ?? 0) + 1);
}
for (const unknown of unknowns) {
const identifiers = unknownIdsByFamily.get(unknown.family);
if (identifiers === undefined)
unknownIdsByFamily.set(unknown.family, [unknown.unknown_id]);
else identifiers.push(unknown.unknown_id);
}
const graph = factory({
schema: "JavaScriptSemanticRelationGraph",
root_artifact_sha256: rootArtifactSha256,
application_graph_id: applicationGraph.graph_id,
root_node_ids: [...state.roots],
evidence_contexts: state.evidenceContexts.contexts,
nodes: [...state.nodes.values()],
relations,
// The input is transferred to the factory; `fingerprints` is cleared below.
fingerprints: [...fingerprints],
unknowns,
coverage: {
status:
truncatedFiles > 0 || unavailableFiles > 0 ? "partial" : "unknown",
truncated: truncatedFiles > 0 || resourceTruncatedFiles > 0,
omitted_nodes: truncatedFiles > 0 || unavailableFiles > 0 ? null : 0,
omitted_relations:
truncatedFiles > 0 || unavailableFiles > 0 ? null : 0,
limits: [
...executionLimits.values(),
...(truncatedFiles > 0
? [
{
name: "semantic_graph_node_ceiling",
value: nodeCeiling,
unit: "items" as const,
},
{
name: "semantic_graph_file_node_ceiling",
value: fileNodeCeiling,
unit: "items" as const,
},
]
: []),
],
families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({
family,
status: semanticFamilyStatus(family),
retained_relations: retainedByFamily.get(family) ?? 0,
omitted_relations:
truncatedFiles > 0 || unavailableFiles > 0 ? null : 0,
limits: [
...executionLimits.values(),
...(truncatedFiles > 0
? [
{
name: "semantic_graph_node_ceiling",
value: SEMANTIC_GRAPH_NODE_CEILING,
unit: "items" as const,
},
]
: []),
],
families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({
family,
status: semanticFamilyStatus(family),
retained_relations: retainedByFamily.get(family) ?? 0,
omitted_relations:
truncatedFiles > 0 || unavailableFiles > 0 ? null : 0,
unknown_ids: unknownIdsByFamily.get(family) ?? [],
})),
},
limitations: [
"The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.",
"Local data flow does not claim control-flow-sensitive reaching definitions or arbitrary dynamic property resolution.",
"Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.",
"Function fingerprints are static candidates; equal digests can remain ambiguous and do not prove behavioral equivalence.",
"Event extraction covers EventEmitter-style literal registrations, removals, and dispatch candidates; dynamic names remain unknown.",
"Timer extraction covers global or node:timers scheduling and exact local-handle cancellation.",
"Child-process extraction covers asynchronous node:child_process creation, literal argv/env/stdio options, exit/error listeners, and kill signals.",
"Configuration extraction covers process.env, process.argv, node:fs reads, and direct logical defaults.",
"Request extraction covers fetch, WebSocket, node:http/node:https construction, direct option fields, and exact local response consumers.",
"Boundary extraction covers unshadowed JSON/global coercions plus parse and validation method candidates.",
"Resource extraction covers built-in filesystem/network acquisition and exact local close/destroy/end handles.",
],
});
state.nodes.clear();
state.relations.clear();
state.unknowns.clear();
state.roots.clear();
state.evidenceContexts.clear();
fingerprints.length = 0;
return graph;
};
return {
remainingFileNodeBudget: () =>
Math.min(
SEMANTIC_GRAPH_FILE_NODE_CEILING,
Math.max(0, SEMANTIC_GRAPH_NODE_CEILING - state.nodes.size),
),
adoptFileProjection: (projection) => {
const newNodes = projection.nodes.filter(
({ node_id }) => !state.nodes.has(node_id),
);
if (
newNodes.length >
Math.min(
SEMANTIC_GRAPH_FILE_NODE_CEILING,
SEMANTIC_GRAPH_NODE_CEILING - state.nodes.size,
)
)
throw new RangeError(
"Transferred semantic nodes exceed the offered tree capacity",
);
for (const context of projection.evidenceContexts)
state.evidenceContexts.adopt(context);
for (const node of newNodes) state.nodes.set(node.node_id, node);
for (const relation of projection.relations)
state.relations.set(relation.relation_id, relation);
for (const unknown of projection.unknowns)
state.unknowns.set(unknown.unknown_id, unknown);
for (const root of projection.roots) state.roots.add(root);
fingerprints.push(...projection.fingerprints);
if (projection.truncated) truncatedFiles += 1;
unknown_ids: unknownIdsByFamily.get(family) ?? [],
})),
},
recordUnavailableFile: (file, reason, limits, unknownReason) => {
const retainedLimits = javaScriptAnalysisCoverageLimits(limits);
for (const limit of retainedLimits) {
executionLimits.set(
JSON.stringify([limit.name, limit.value, limit.unit]),
limit,
);
}
state.fileNodeBudget = Math.min(
SEMANTIC_GRAPH_FILE_NODE_CEILING,
Math.max(0, SEMANTIC_GRAPH_NODE_CEILING - state.nodes.size),
);
const module = retainNode(state, file, {
kind: "module",
roleKey: "module",
location: null,
label: file.path,
functionNodeId: null,
});
state.fileNodeBudget = null;
if (module !== null) state.roots.add(module.node_id);
const evidence = {
...observedSemanticEvidence(file, null),
authority: "unknown" as const,
state: "unknown" as const,
confidence: "unknown" as const,
coverage: partialApplicationCoverage(retainedLimits, null),
limitations: [reason],
};
for (const family of JAVASCRIPT_SEMANTIC_RELATION_FAMILIES) {
const unknown = createJavaScriptSemanticGraphUnknown(
{
node_id: module?.node_id ?? null,
family,
relation_kinds: JAVASCRIPT_SEMANTIC_RELATIONS.filter(
(relation) =>
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation] === family,
),
reason: unknownReason,
detail: reason,
candidate_node_ids: [],
evidence,
},
state.evidenceContexts,
);
state.unknowns.set(unknown.unknown_id, unknown);
}
unavailableFiles += 1;
if (unknownReason === "resource-limit" && retainedLimits.length > 0)
resourceTruncatedFiles += 1;
},
projectFile: (file, ir) => {
const steps = projectSourceSteps(file, ir);
while (steps.next().done !== true);
},
projectFileSteps: projectSourceSteps,
finish: (rootArtifactSha256, applicationGraph) =>
finishWith(
createJavaScriptSemanticGraph,
rootArtifactSha256,
applicationGraph,
),
finishImmutableSteps: (rootArtifactSha256, applicationGraph) =>
finishWith(
sealTransferredJavaScriptSemanticGraphSteps,
rootArtifactSha256,
applicationGraph,
),
};
limitations: [
"The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.",
"Local data flow does not claim control-flow-sensitive reaching definitions or arbitrary dynamic property resolution.",
"Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.",
"Function fingerprints are static candidates; equal digests can remain ambiguous and do not prove behavioral equivalence.",
"Event extraction covers EventEmitter-style literal registrations, removals, and dispatch candidates; dynamic names remain unknown.",
"Timer extraction covers global or node:timers scheduling and exact local-handle cancellation.",
"Child-process extraction covers asynchronous node:child_process creation, literal argv/env/stdio options, exit/error listeners, and kill signals.",
"Configuration extraction covers process.env, process.argv, node:fs reads, and direct logical defaults.",
"Request extraction covers fetch, WebSocket, node:http/node:https construction, direct option fields, and exact local response consumers.",
"Boundary extraction covers unshadowed JSON/global coercions plus parse and validation method candidates.",
"Resource extraction covers built-in filesystem/network acquisition and exact local close/destroy/end handles.",
],
});
state.nodes.clear();
state.relations.clear();
state.unknowns.clear();
state.roots.clear();
state.evidenceContexts.clear();
fingerprints.length = 0;
return graph;
};
return {
remainingFileNodeBudget: () =>
Math.min(fileNodeCeiling, Math.max(0, nodeCeiling - state.nodes.size)),
adoptFileProjection: (projection) => {
const newNodes = projection.nodes.filter(
({ node_id }) => !state.nodes.has(node_id),
);
if (
newNodes.length >
Math.min(fileNodeCeiling, nodeCeiling - state.nodes.size)
)
throw new RangeError(
"Transferred semantic nodes exceed the offered tree capacity",
);
for (const context of projection.evidenceContexts)
state.evidenceContexts.adopt(context);
for (const node of newNodes) state.nodes.set(node.node_id, node);
for (const relation of projection.relations)
state.relations.set(relation.relation_id, relation);
for (const unknown of projection.unknowns)
state.unknowns.set(unknown.unknown_id, unknown);
for (const root of projection.roots) state.roots.add(root);
fingerprints.push(...projection.fingerprints);
if (projection.truncated) truncatedFiles += 1;
},
recordUnavailableFile: (file, reason, limits, unknownReason) => {
const retainedLimits = javaScriptAnalysisCoverageLimits(limits);
for (const limit of retainedLimits) {
executionLimits.set(
JSON.stringify([limit.name, limit.value, limit.unit]),
limit,
);
}
state.fileNodeBudget = Math.min(
fileNodeCeiling,
Math.max(0, nodeCeiling - state.nodes.size),
);
const module = retainNode(state, file, {
kind: "module",
roleKey: "module",
location: null,
label: file.path,
functionNodeId: null,
});
state.fileNodeBudget = null;
if (module !== null) state.roots.add(module.node_id);
const evidence = {
...observedSemanticEvidence(file, null),
authority: "unknown" as const,
state: "unknown" as const,
confidence: "unknown" as const,
coverage: partialApplicationCoverage(retainedLimits, null),
limitations: [reason],
};
for (const family of JAVASCRIPT_SEMANTIC_RELATION_FAMILIES) {
const unknown = createJavaScriptSemanticGraphUnknown(
{
node_id: module?.node_id ?? null,
family,
relation_kinds: JAVASCRIPT_SEMANTIC_RELATIONS.filter(
(relation) =>
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation] === family,
),
reason: unknownReason,
detail: reason,
candidate_node_ids: [],
evidence,
},
state.evidenceContexts,
);
state.unknowns.set(unknown.unknown_id, unknown);
}
unavailableFiles += 1;
if (unknownReason === "resource-limit" && retainedLimits.length > 0)
resourceTruncatedFiles += 1;
},
projectFile: (file, ir, paths) => {
const steps = projectSourceSteps(file, ir, paths);
while (steps.next().done !== true);
},
projectFileSteps: projectSourceSteps,
finish: (rootArtifactSha256, applicationGraph) =>
finishWith(
createJavaScriptSemanticGraph,
rootArtifactSha256,
applicationGraph,
),
finishImmutableSteps: (rootArtifactSha256, applicationGraph) =>
finishWith(
sealTransferredJavaScriptSemanticGraphSteps,
rootArtifactSha256,
applicationGraph,
),
};
};
/** Project one source with the parent's remaining node capacity, retaining no full IR. */
export function* projectJavaScriptSemanticFileSteps(
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
nodeBudget: number,
paths: readonly ElectronCallPath[] = [],
): Generator<void, JavaScriptSemanticFileProjection> {
if (
!Number.isSafeInteger(nodeBudget) ||
nodeBudget < 0 ||
nodeBudget > SEMANTIC_GRAPH_FILE_NODE_CEILING
nodeBudget > JAVASCRIPT_SEMANTIC_NODE_MAXIMUM
)
throw new RangeError(
"File semantic projection requires a valid offered node capacity",
@@ -414,7 +434,7 @@ export function* projectJavaScriptSemanticFileSteps(
const state = emptyState();
state.fileNodeBudget = nodeBudget;
state.fileNodesDropped = false;
const fingerprints = yield* projectFileSteps(file, ir, state);
const fingerprints = yield* projectFileSteps(file, ir, state, paths);
return {
roots: [...state.roots],
evidenceContexts: state.evidenceContexts.contexts,
@@ -430,6 +450,7 @@ function* projectFileSteps(
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
state: BuilderState,
paths: readonly ElectronCallPath[] = [],
): Generator<void, JavaScriptSemanticFingerprint[]> {
yield;
const moduleNode = retainNode(state, file, {
@@ -441,6 +462,7 @@ function* projectFileSteps(
});
if (moduleNode === null) return [];
state.roots.add(moduleNode.node_id);
yield* projectElectronSemanticCallPaths(file, paths, state);
// Node creation keeps source order, which the per-file node budget relies
// on; yields between nodes do not change it.
const callableNodes = new Map<string, JavaScriptSemanticGraphNode>();
@@ -0,0 +1,72 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { projectJavaScriptSemanticFileSteps } from "./JavaScriptSemanticGraphBuilder.js";
const source = `${Array.from({ length: 40 }, (_, i) => `function library${String(i)}(){return ${String(i)}}`).join("\n")}
const e=require("electron");
function register(channel,callback){e.ipcMain.addListener(channel,event=>allowed(event)&&callback(event))}
register("test:copy",event=>event.sender.copy());`;
const file: JavaScriptArtifactFile = {
path: "main.js",
sha256: "a".repeat(64),
container_sha256: "b".repeat(64),
bytes: source.length,
inventory_artifact_id: `art_${"a".repeat(64)}`,
kind: "javascript",
unpacked: false,
text: { included: true, value: source },
};
const facts = analyzeJavaScriptStaticSource(source);
const ir = analyzeJavaScriptSemantics(source);
const project = (budget: number) => {
const steps = projectJavaScriptSemanticFileSteps(
file,
ir,
budget,
facts.electron.call_paths,
);
let result = steps.next();
while (!result.done) result = steps.next();
return result.value;
};
it("retains Electron calls before library definitions consume the node budget", () => {
const result = project(20);
expect(result.nodes).toHaveLength(20);
expect(result.truncated).toBe(true);
const terminal = result.nodes.find(
(node) => node.label === "ipc-event.sender.copy",
);
expect(terminal).toBeDefined();
expect(
result.relations.some(
(r) =>
r.target_node_id === terminal?.node_id && r.resolution === "candidate",
),
).toBe(true);
expect(
result.nodes.some(
(node) =>
node.properties.entry === "test:copy" &&
Array.isArray(node.properties.conditions) &&
node.properties.conditions.length === 1,
),
).toBe(true);
});
it("reports truncation and keeps referential integrity even within a boundary path", () => {
const result = project(3);
expect(result.nodes).toHaveLength(3);
expect(result.truncated).toBe(true);
expect(
result.nodes.some((node) => node.label === "ipc-event.sender.copy"),
).toBe(false);
const retained = new Set(result.nodes.map((node) => node.node_id));
expect(
result.relations.every(
(r) => retained.has(r.source_node_id) && retained.has(r.target_node_id),
),
).toBe(true);
});
@@ -0,0 +1,73 @@
import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
import {
addSemanticGraphRelation,
retainSemanticGraphNode,
type SemanticGraphProjectionState,
} from "./JavaScriptSemanticGraphConstruction.js";
import { inferredSemanticEvidenceAt } from "./JavaScriptSemanticGraphEvidence.js";
/** Give discovered process-boundary paths budget before bulk library definitions. */
export function* projectElectronSemanticCallPaths(
file: JavaScriptArtifactFile,
paths: readonly ElectronCallPath[],
state: SemanticGraphProjectionState,
): Generator<void, void> {
for (const path of paths) {
yield;
const pathId = sha256Text(JSON.stringify(path));
let previous: ReturnType<typeof retainSemanticGraphNode> = null;
let previousKind: ElectronCallPath["steps"][number]["kind"] | undefined;
for (const [index, step] of path.steps.entries()) {
const node = retainSemanticGraphNode(state, file, {
kind:
step.kind === "function"
? "function"
: step.kind === "exposure"
? "boundary"
: "call-site",
roleKey: `electron-path:${pathId}:${String(index)}`,
location: step.location,
label: step.label,
functionNodeId: null,
properties: {
path_id: pathId,
entry: path.entry,
channel: path.channel,
terminal: path.terminal,
step_kind: step.kind,
resolution: "candidate",
conditions: path.conditions.map((c) => ({
expression: c.expression,
location: {
start: { ...c.location.start },
end: { ...c.location.end },
},
})),
},
});
if (index === 0 && node !== null) state.roots.add(node.node_id);
addSemanticGraphRelation(state, {
source: previous,
target: node,
relation:
previousKind === "ipc"
? "registers-listener"
: previousKind === "exposure" || previousKind === "factory"
? "aliases"
: "calls",
resolution: "candidate",
evidence: inferredSemanticEvidenceAt(file, step.location),
properties: {
path_id: pathId,
entry: path.entry,
channel: path.channel,
basis: "lexical-wrapper-substitution",
},
});
previous = node;
previousKind = step.kind;
}
}
}
+9
View File
@@ -200,6 +200,15 @@ const registerJavaScriptApplicationCommand = (
input_path: args.path,
format: options.artifactFormat,
integrity_policy: options.integrityPolicy,
...(options.maxSemanticNodes === undefined
? {}
: { max_semantic_nodes: options.maxSemanticNodes }),
...(options.maxSemanticNodesPerFile === undefined
? {}
: {
max_semantic_nodes_per_file:
options.maxSemanticNodesPerFile,
}),
...(options.sourcePath === undefined
? {}
: { source_paths: options.sourcePath }),
+4
View File
@@ -61,6 +61,10 @@ export const electronPageInspectionOptions = z.object({
});
export const javascriptApplicationOptions = z.object({
maxSemanticNodes:
javaScriptAnalysisResourceControlsSchema.shape.max_semantic_nodes,
maxSemanticNodesPerFile:
javaScriptAnalysisResourceControlsSchema.shape.max_semantic_nodes_per_file,
sourcePath: javaScriptSourcePathsSchema
.optional()
.describe(
@@ -67,7 +67,12 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async (
);
await callbacks.phase("projection");
const projection = await completeJavaScriptAnalysisSteps(
projectJavaScriptSemanticFileSteps(file, ir, nodeBudget),
projectJavaScriptSemanticFileSteps(
file,
ir,
nodeBudget,
facts.electron.call_paths,
),
);
return { module, projection };
};
@@ -175,7 +175,7 @@ const addValidation = (
});
};
const ipcDescriptor = (
export const ipcDescriptor = (
path: readonly string[] | undefined,
): ElectronIpcDescriptor | undefined => {
if (path?.length !== 2) return undefined;
@@ -201,6 +201,7 @@ const ipcDescriptor = (
operation: "send-to-host",
mode: "send",
};
case "addListener":
case "on":
return { side: "renderer", operation: "on", mode: "listen" };
case "once":
@@ -209,6 +210,7 @@ const ipcDescriptor = (
}
const main = object === "ipcMain" ? operation : undefined;
switch (main) {
case "addListener":
case "on":
return { side: "main", operation: "on", mode: "listen" };
case "once":
@@ -52,6 +52,7 @@ export type ElectronContextBridgeApiKey =
/** One contextBridge API declaration without executing its API object. */
export type ElectronContextBridgeFinding = ElectronContextBridgeApiKey & {
readonly resolution_basis?: "wrapper-substitution";
readonly world: "main" | "isolated";
readonly world_id: ElectronStaticValue | null;
readonly api_status: "object-literal" | "dynamic" | "missing";
@@ -112,6 +113,8 @@ type ElectronIpcHandler =
};
interface ElectronIpcFindingContext {
/** Absent on direct syntax observations; wrappers require inferred evidence. */
readonly resolution_basis?: "wrapper-substitution";
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
@@ -192,4 +195,34 @@ export interface ElectronStaticFindings {
readonly sender_validations: readonly ElectronSenderValidationFinding[];
readonly utility_processes: readonly ElectronUtilityProcessFinding[];
readonly native_addon_bindings: readonly ElectronNativeAddonBindingFinding[];
/** Source-bound candidate paths through bundled exports and IPC closures. */
readonly call_paths?: readonly ElectronCallPath[];
}
export interface ElectronCallPathStep {
readonly kind:
| "exposure"
| "factory"
| "function"
| "call"
| "ipc"
| "terminal";
readonly label: string;
readonly location: JavaScriptSourceRange;
readonly module_key: string | null;
readonly module_runtime: string | null;
}
/** A statically substituted candidate, never a runtime reachability claim. */
export interface ElectronCallPath {
readonly entry_kind: "bridge-member" | "ipc-handler";
readonly entry: string;
readonly channel: string | null;
readonly terminal: string;
readonly resolution: "candidate";
readonly steps: readonly ElectronCallPathStep[];
readonly conditions: readonly {
readonly expression: string;
readonly location: JavaScriptSourceRange;
}[];
}
@@ -0,0 +1,260 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const bundle = (entry: string) => `(() => {
const modules = {
2(module) { module.exports = require("electron"); },
5(module,exports,load) {
const electron=load(2);
Object.defineProperty(exports,"__esModule",{value:true});
exports.sender = function(channel) {return (...args)=>send(channel,...args)};
function send(channel,...args) {electron.ipcRenderer.send(channel,...args)}
exports.events = {addListener(channel,handler) {
const guarded = (event,...args) => {if (allowed(event)) handler(event,...args)};
return electron.ipcMain.addListener(channel,guarded);
}};
},
9(module,exports,load) {const electron=load(2), helper=load(5); ${entry}}
};
function load(id) {const m={exports:{}};modules[id].call(m.exports,m,m.exports,load);return m.exports;}
load(9);
})();`;
describe("wrapped Electron control-flow alternatives", () => {
it.each(["flag ? copy : cut", "flag ? copy : unknownHandler", "choose()"])(
"does not select one IPC handler from %s",
(handler) => {
const result = analyzeJavaScriptStaticSource(`
const e = require("electron");
const copy = event => event.sender.copy();
const cut = event => event.sender.cut();
function choose() { if (flag) return copy; }
function register(channel, handler) { e.ipcMain.on(channel, handler); }
register("test:copy", ${handler});
`);
expect(result.electron.ipc).toContainEqual(
expect.objectContaining({
channel: "test:copy",
side: "main",
handler_kind: "dynamic-expression",
resolution_basis: "wrapper-substitution",
}),
);
expect(result.electron.call_paths ?? []).toEqual([]);
expect(result.parse_status).toBe("partial");
},
);
it.each(["'ready'", "false", "0", "''", "({})", "(()=>{})"])(
"short-circuits nullish fallback for %s in callbacks and seed calls",
(left) => {
for (const source of [
`e.ipcMain.on("test:copy", event => { ${left} ?? event.sender.copy(); });`,
`${left} ?? e.ipcMain.on("test:copy", event => event.sender.copy());`,
`e.ipcMain.on("test:copy", event => { const selected = ${left} ?? (()=>event.sender.copy()); selected(); });`,
])
expect(
analyzeJavaScriptStaticSource(`const e=require("electron");${source}`)
.electron.call_paths ?? [],
).toEqual([]);
},
);
it.each(["null", "undefined", "void 0"])(
"follows nullish fallback for %s",
(left) => {
const result = analyzeJavaScriptStaticSource(`const e=require("electron");
${left} ?? e.ipcMain.on("test:copy", event => { ${left} ?? event.sender.copy(); });`);
expect(result.electron.call_paths).toContainEqual(
expect.objectContaining({
terminal: "ipc-event.sender.copy",
conditions: [],
}),
);
},
);
it("retains a nullish guard for unknown fallback rather than a falsy guard", () => {
for (const source of [
`e.ipcMain.on("test:copy", event => { value ?? event.sender.copy(); });`,
`value ?? e.ipcMain.on("test:copy", event => event.sender.copy());`,
]) {
const result = analyzeJavaScriptStaticSource(
`const e=require("electron");${source}`,
);
expect(result.electron.call_paths).toContainEqual(
expect.objectContaining({
terminal: "ipc-event.sender.copy",
conditions: [
expect.objectContaining({
expression: "(value) === null || (value) === undefined",
}),
],
}),
);
}
});
it.each([
"if (flag) return copy; else return cut; return dead;",
"if (flag) { return copy; } return cut;",
"if (flag) { if (other) return copy; else return cut; } else return cut; return dead;",
])("retains every possible factory return: %s", (body) => {
const result = analyzeJavaScriptStaticSource(`const e=require("electron");
const copy=()=>e.ipcRenderer.send("test:copy"), cut=()=>e.ipcRenderer.send("test:cut"), dead=()=>e.ipcRenderer.send("test:dead");
function choose(){${body}}
e.contextBridge.exposeInMainWorld("desktop", {copy:choose()});`);
expect(
[
...new Set(result.electron.call_paths?.map((path) => path.channel)),
].sort(),
).toEqual(["test:copy", "test:cut"]);
});
});
describe("wrapped Electron call paths", () => {
it("connects exposed closures to IPC sends and text clipboard calls", () => {
const result = analyzeJavaScriptStaticSource(
bundle(`
const text = value => navigator.clipboard.writeText(value);
const api={copy:helper.sender("example:copy"),copyText:text};
electron.contextBridge.exposeInMainWorld("desktop",api);
`),
);
expect(result.electron.context_bridge_apis).toMatchObject([
{ api_key: "desktop", members: ["copy", "copyText"] },
]);
expect(result.electron.ipc).toContainEqual(
expect.objectContaining({
side: "renderer",
channel: "example:copy",
operation: "send",
}),
);
expect(result.electron.call_paths).toEqual(
expect.arrayContaining([
expect.objectContaining({
entry: "desktop.copy",
channel: "example:copy",
terminal: "electron.ipcRenderer.send",
}),
expect.objectContaining({
entry: "desktop.copyText",
terminal: "navigator.clipboard.writeText",
}),
]),
);
});
it("substitutes guarded listener callbacks and preserves guard evidence", () => {
const result = analyzeJavaScriptStaticSource(
bundle(
`helper.events.addListener("example:copy",event=>event.sender.copy());`,
),
);
expect(result.electron.ipc).toContainEqual(
expect.objectContaining({
side: "main",
channel: "example:copy",
operation: "on",
}),
);
expect(result.electron.call_paths).toContainEqual(
expect.objectContaining({
entry_kind: "ipc-handler",
channel: "example:copy",
terminal: "ipc-event.sender.copy",
conditions: [expect.objectContaining({ expression: "allowed(event)" })],
}),
);
});
it("does not identify overwritten Electron namespaces or shadowed require", () => {
for (const source of [
`const e=require("electron");e.ipcMain={on(){}};e.ipcMain.on("bad",event=>event.sender.copy());`,
`function require(){return {ipcMain:{on(){}}}};const e=require("electron");e.ipcMain.on("bad",event=>event.sender.copy());`,
])
expect(
analyzeJavaScriptStaticSource(source).electron.call_paths ?? [],
).toEqual([]);
});
it("does not follow a statically false callback branch", () => {
const result = analyzeJavaScriptStaticSource(
bundle(
`helper.events.addListener("example:copy",event=>{if(false)event.sender.copy()});`,
),
);
expect(result.electron.call_paths ?? []).toEqual([]);
});
it("retains early rejection guards and excludes calls after definite returns", () => {
const guarded = analyzeJavaScriptStaticSource(
`const e=require("electron");e.ipcMain.on("guard",event=>{if(!allowed(event))return;event.sender.copy()});`,
);
expect(guarded.electron.call_paths).toContainEqual(
expect.objectContaining({
conditions: [
expect.objectContaining({ expression: "!(!allowed(event))" }),
],
}),
);
for (const body of [
"return;event.sender.copy()",
"if(true)return;event.sender.copy()",
"if(false)event.sender.copy()",
])
expect(
analyzeJavaScriptStaticSource(
`const e=require("electron");e.ipcMain.on("dead",event=>{${body}});`,
).electron.call_paths ?? [],
).toEqual([]);
expect(
analyzeJavaScriptStaticSource(
`const e=require("electron");if(false)e.ipcMain.on("dead",event=>event.sender.copy());`,
).electron.call_paths ?? [],
).toEqual([]);
});
it("rejects dynamic channels, computed overwrites, unknown spreads and reassigned callback parameters", () => {
for (const entry of [
`helper.events.addListener(runtimeChannel,event=>event.sender.copy());`,
`electron[runtimeKey]={};electron.ipcMain.on("bad",event=>event.sender.copy());`,
`const api={copy:helper.sender("bad"),...unknownApi};electron.contextBridge.exposeInMainWorld("desktop",api);`,
`helper.events.addListener("bad",event=>{event=other;event.sender.copy()});`,
])
expect(
analyzeJavaScriptStaticSource(bundle(entry)).electron.call_paths ?? [],
).toEqual([]);
});
it("resolves renamed default and namespace import helpers from their syntax", () => {
const result = analyzeJavaScriptStaticSource(
bundle(`
const defaultImport=x=>x&&x.__esModule?x:{default:x};
function namespaceImport(x){if(x&&x.__esModule)return x;return unknownWrap(x)}
const E=defaultImport(load(2)),H=namespaceImport(load(5));
E.default.contextBridge.exposeInMainWorld("renamed",{copy:(0,H.sender)("test:renamed")});
`),
);
expect(result.electron.call_paths).toContainEqual(
expect.objectContaining({
entry: "renamed.copy",
channel: "test:renamed",
terminal: "electron.ipcRenderer.send",
}),
);
expect(
result.electron.call_paths
?.flatMap((p) => p.steps)
.every((step) => step.label.length > 0),
).toBe(true);
});
it("does not trust helper names or follow returns after a definite throw", () => {
for (const helper of [
`function __toESM() {return {ipcRenderer:{send(){}}}}`,
`function __toESM(value) {throw Error("inert");return value;}`,
`function __toESM(value) {if(true){throw Error("inert")}return value;}`,
]) {
const result = analyzeJavaScriptStaticSource(`
const electron = require("electron");
${helper}
const wrapped = __toESM(electron);
electron.contextBridge.exposeInMainWorld("desktop",{
copy:()=>wrapped.ipcRenderer.send("test:copy")
});
`);
expect(result.electron.call_paths ?? []).toEqual([]);
}
});
});
@@ -0,0 +1,461 @@
import * as t from "@babel/types";
import { JavaScriptSymbolicBindings } from "./javascriptSymbolicBindings.js";
import {
symbolicVariants,
symbolicTruth,
symbolicUnknown,
type SymbolicValue,
type SymbolicEnvironment,
type SymbolicFunction,
} from "./javascriptSymbolicValues.js";
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
import type { JavaScriptAnalysisAccumulator } from "./javascriptStaticAnalysisState.js";
import type {
ElectronCallPath,
ElectronCallPathStep,
ElectronIpcDescriptor,
ElectronIpcFinding,
} from "./electronStaticAnalysisTypes.js";
import {
childNodes,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { range, calleeName } from "./javascriptStaticAnalysisHelpers.js";
import { ipcDescriptor } from "./electronStaticAnalysisIpc.js";
import {
symbolicDefiniteExit,
symbolicLogicalRight,
} from "./javascriptSymbolicControlFlow.js";
const callLabel = (node: t.Node): string =>
t.isSequenceExpression(node)
? callLabel(node.expressions.at(-1) ?? node)
: calleeName(node) || "local call";
interface PathContext {
readonly entryNode: t.Node;
readonly entry: string;
readonly entryKind: ElectronCallPath["entry_kind"] | null;
readonly channel: string | null;
readonly steps: readonly ElectronCallPathStep[];
readonly conditions: ElectronCallPath["conditions"];
readonly active: ReadonlySet<t.Function>;
}
/** Substitutes inert wrapper syntax; every emitted path remains a candidate. */
export const collectElectronWrappedCallPaths = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
source: string,
accumulator: JavaScriptAnalysisAccumulator,
): readonly ElectronCallPath[] => {
// Bounds repeated interprocedural expansion independently of bundle bytes.
// Exhaustion is reported as unknown coverage, never as a negative finding.
let remainingVisits = 1_000_000;
const resolver = new JavaScriptSymbolicBindings(state, program);
const paths: ElectronCallPath[] = [];
const seen = new Set<string>();
const step = (
node: t.Node,
kind: ElectronCallPathStep["kind"],
label: string,
): ElectronCallPathStep => {
const module = accumulator.moduleRangeIndex.find(node.start);
return {
kind,
label,
location: range(node),
module_key: module?.key ?? null,
module_runtime: module?.runtime ?? null,
};
};
const condition = (
context: PathContext,
node: t.Node,
positive: boolean | "nullish" = true,
): PathContext => {
const expression = source
.slice(node.start ?? 0, node.end ?? 0)
.slice(0, 512);
return {
...context,
conditions: [
...context.conditions,
{
expression:
positive === "nullish"
? `(${expression}) === null || (${expression}) === undefined`
: positive
? expression
: `!(${expression})`,
location: range(node),
},
],
};
};
const emit = (
context: PathContext,
node: t.Node,
terminal: string,
channel = context.channel,
): void => {
if (context.entryKind === null) return;
const path: ElectronCallPath = {
entry_kind: context.entryKind,
entry: context.entry,
channel,
terminal,
resolution: "candidate",
steps: [...context.steps, step(node, "terminal", terminal)],
conditions: context.conditions,
};
const key = JSON.stringify(path);
if (seen.has(key)) return;
seen.add(key);
paths.push(path);
};
const functionBody = (
fn: SymbolicFunction,
args: readonly SymbolicValue[],
context: PathContext,
): void => {
if (context.active.has(fn.node)) return;
const name =
(t.isFunctionExpression(fn.node) || t.isFunctionDeclaration(fn.node)) &&
fn.node.id
? fn.node.id.name
: "closure";
walk(fn.node.body, resolver.bind(fn, args), {
...context,
steps: [
...context.steps,
...fn.origins.map((n) =>
step(
n,
"factory",
t.isCallExpression(n) ? callLabel(n.callee) : "factory",
),
),
step(fn.node, "function", name),
],
active: new Set([...context.active, fn.node]),
});
};
const ipc = (
node: t.CallExpression | t.OptionalCallExpression,
descriptor: ElectronIpcDescriptor,
args: readonly SymbolicValue[],
context: PathContext,
endpoint: string,
): void => {
const first = args[0];
if (first?.kind !== "literal" || typeof first.value !== "string") return;
const channel = first.value;
const location = range(context.entryNode);
const handlers = symbolicVariants(
args[1] ?? symbolicUnknown("missing handler"),
);
// A channel registration must not acquire one arbitrary handler from a union.
// Keep the unresolved registration so it still competes with other handlers.
const fn =
handlers.length === 1 && handlers[0]?.kind === "function"
? handlers[0]
: undefined;
const handlerNode = node.arguments[1];
const finding: ElectronIpcFinding =
descriptor.mode === "send" || descriptor.mode === "invoke"
? {
...descriptor,
channel,
channel_expression: null,
handler_kind: null,
handler_location: null,
module_key: null,
location,
}
: fn
? {
...descriptor,
channel,
channel_expression: null,
handler_kind: "inline-function",
handler_location: range(fn.node),
module_key: null,
location,
}
: handlerNode
? {
...descriptor,
channel,
channel_expression: null,
handler_kind: "dynamic-expression",
handler_location: range(handlerNode),
module_key: null,
location,
}
: {
...descriptor,
channel,
channel_expression: null,
handler_kind: "missing",
handler_location: null,
module_key: null,
location,
};
const duplicate = accumulator.ipc.some(
({ value }) =>
value.side === finding.side &&
value.operation === finding.operation &&
value.channel === channel &&
[JSON.stringify(location), JSON.stringify(range(node))].includes(
JSON.stringify(value.location),
),
);
if (!duplicate) {
accumulator.ipc.push({
offset: context.entryNode.start ?? -1,
value: { ...finding, resolution_basis: "wrapper-substitution" },
});
if (descriptor.mode !== "send" && descriptor.mode !== "invoke" && !fn)
accumulator.unknownFindings += 1;
}
if (descriptor.mode === "send" || descriptor.mode === "invoke") {
emit(context, node, endpoint, channel);
return;
}
if (descriptor.side === "main" && fn)
functionBody(
fn,
[
{ kind: "external", path: ["ipc-event"] },
symbolicUnknown("IPC payload"),
],
{
...context,
entryKind: "ipc-handler",
entry: channel,
channel,
steps: [...context.steps, step(node, "ipc", endpoint)],
},
);
};
const bridge = (
node: t.CallExpression | t.OptionalCallExpression,
args: readonly SymbolicValue[],
context: PathContext,
): void => {
const key = args[0];
const object = args[1];
if (key?.kind !== "literal" || typeof key.value !== "string" || !object)
return;
const members = [...resolver.members(object)].sort();
const location = range(node);
const existing = accumulator.contextBridgeApis.find(
({ value }) =>
JSON.stringify(value.location) === JSON.stringify(location),
);
const finding = {
world: "main" as const,
world_id: null,
api_key: key.value,
api_key_expression: null,
api_status: "dynamic" as const,
resolution_basis: "wrapper-substitution" as const,
members,
unknown_members: resolver.unknownMembers(object),
module_key: null,
location,
};
if (existing)
existing.value.members.length === 0 &&
accumulator.contextBridgeApis.splice(
accumulator.contextBridgeApis.indexOf(existing),
1,
{ offset: node.start ?? -1, value: finding },
);
else
accumulator.contextBridgeApis.push({
offset: node.start ?? -1,
value: finding,
});
for (const name of members) {
const value = resolver.property(object, name);
for (const fn of symbolicVariants(value))
if (fn.kind === "function") {
functionBody(
fn,
fn.node.params.map(() => symbolicUnknown("public API argument")),
{
...context,
entryNode: node,
entryKind: "bridge-member",
entry: `${key.value}.${name}`,
channel: null,
steps: [step(node, "exposure", `${key.value}.${name}`)],
active: new Set(),
},
);
}
}
};
const call = (
node: t.CallExpression | t.OptionalCallExpression,
env: SymbolicEnvironment,
context: PathContext,
): void => {
const value = resolver.evaluate(node.callee, env);
const args = resolver.arguments(node, env);
for (const callee of symbolicVariants(value)) {
if (callee.kind === "function") {
functionBody(callee, args, {
...context,
steps: [...context.steps, step(node, "call", callLabel(node.callee))],
});
} else if (callee.kind === "external") {
const path = callee.path;
const endpoint = path.join(".");
if (/^electron(?:\/(?:main|renderer|common))?$/u.test(path[0] ?? "")) {
const descriptor = ipcDescriptor(path.slice(1));
if (descriptor) ipc(node, descriptor, args, context, endpoint);
else if (
path.slice(1).join(".") === "contextBridge.exposeInMainWorld"
)
bridge(node, args, context);
else if (path[1] === "clipboard") emit(context, node, endpoint);
} else if (
(path[0] === "navigator" && path[1] === "clipboard") ||
(path[0] === "ipc-event" && path[1] === "sender")
)
emit(context, node, endpoint);
}
}
};
function walk(
node: t.Node,
env: SymbolicEnvironment,
context: PathContext,
): void {
if (--remainingVisits < 0 || t.isFunction(node) || t.isClass(node)) return;
if (t.isBlockStatement(node)) {
let next = context;
for (const statement of node.body) {
walk(statement, env, next);
if (symbolicDefiniteExit(statement)) break;
if (t.isIfStatement(statement)) {
const truth = symbolicTruth(resolver.evaluate(statement.test, env));
if (truth !== undefined) {
if (
symbolicDefiniteExit(
truth ? statement.consequent : statement.alternate,
)
)
break;
} else if (symbolicDefiniteExit(statement.consequent))
next = condition(next, statement.test, false);
else if (symbolicDefiniteExit(statement.alternate))
next = condition(next, statement.test);
}
}
return;
}
if (t.isIfStatement(node) || t.isConditionalExpression(node)) {
const truth = symbolicTruth(resolver.evaluate(node.test, env));
if (truth !== false)
walk(
node.consequent,
env,
truth === undefined ? condition(context, node.test) : context,
);
if (truth !== true && node.alternate)
walk(
node.alternate,
env,
truth === undefined ? condition(context, node.test, false) : context,
);
return;
}
if (t.isLogicalExpression(node)) {
walk(node.left, env, context);
const right = symbolicLogicalRight(
node.operator,
resolver.evaluate(node.left, env),
);
if (right === false) return;
walk(
node.right,
env,
right === undefined
? condition(
context,
node.left,
node.operator === "??" ? "nullish" : node.operator === "&&",
)
: context,
);
return;
}
if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
call(node, env, context);
for (const child of childNodes(node)) walk(child, env, context);
}
traverseJavaScriptAst(program, {
enter: (node) => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node))
return;
if (!t.isStringLiteral(node.arguments[0])) return;
const env = resolver.environmentAt(node);
let context: PathContext = {
entryNode: node,
entry: "",
entryKind: null,
channel: null,
steps: [],
conditions: [],
active: new Set(),
};
for (
let child: t.Node = node, parent = state.parentsByNode.get(child);
parent && !t.isFunction(parent);
child = parent, parent = state.parentsByNode.get(child)
) {
if (t.isIfStatement(parent) || t.isConditionalExpression(parent)) {
if (child === parent.test) continue;
const truth = symbolicTruth(resolver.evaluate(parent.test, env));
const positive = child === parent.consequent;
if (truth !== undefined && truth !== positive) return;
if (truth === undefined)
context = condition(context, parent.test, positive);
} else if (t.isLogicalExpression(parent) && child === parent.right) {
const right = symbolicLogicalRight(
parent.operator,
resolver.evaluate(parent.left, env),
);
if (right === false) return;
if (right === undefined)
context = condition(
context,
parent.left,
parent.operator === "??" ? "nullish" : parent.operator === "&&",
);
} else if (t.isBlockStatement(parent)) {
const index = parent.body.findIndex(
(statement) => statement === child,
);
if (
index >= 0 &&
parent.body
.slice(0, index)
.some((statement) => symbolicDefiniteExit(statement))
)
return;
}
}
call(node, env, context);
},
});
if (remainingVisits < 0 || resolver.truncated) {
accumulator.unknownFindings += 1;
accumulator.electronCallPathLimitReached = true;
}
return paths;
};
@@ -0,0 +1,22 @@
import { expect, it } from "vitest";
import { javaScriptAnalysisResourceControlsSchema } from "./javascriptAnalysisResourceControls.js";
it("keeps omitted operational choices absent and validates explicit semantic budgets", () => {
expect(javaScriptAnalysisResourceControlsSchema.parse({})).toEqual({});
expect(
javaScriptAnalysisResourceControlsSchema.parse({
max_semantic_nodes: 750_000,
max_semantic_nodes_per_file: 700_000,
}),
).toEqual({
max_semantic_nodes: 750_000,
max_semantic_nodes_per_file: 700_000,
});
for (const value of [0, -1, 1.5, 1_000_001, Infinity, NaN]) {
for (const key of ["max_semantic_nodes", "max_semantic_nodes_per_file"])
expect(
javaScriptAnalysisResourceControlsSchema.safeParse({ [key]: value })
.success,
).toBe(false);
}
});
@@ -6,8 +6,13 @@ import type { ApplicationCoverage } from "./javascriptApplicationEvidenceSchemas
export const JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS = {
heapMb: 1024,
timeoutMs: 300_000,
semanticNodes: 100_000,
semanticNodesPerFile: 20_000,
} as const;
/** Maximum caller-selected node capacity; heap and transfer admission still apply. */
export const JAVASCRIPT_SEMANTIC_NODE_MAXIMUM = 1_000_000;
/** Project observed execution limits without inventing unmeasured values. */
export const javaScriptAnalysisCoverageLimits = (
limits: Readonly<Record<string, JsonValue>> | null,
@@ -37,6 +42,24 @@ export const javaScriptAnalysisCoverageLimits = (
/** Optional operational choices; omission keeps them out of Evidence parameters. */
export const javaScriptAnalysisResourceControlsSchema = z.strictObject({
max_semantic_nodes: z
.number()
.int()
.min(1)
.max(JAVASCRIPT_SEMANTIC_NODE_MAXIMUM)
.optional()
.describe(
"Maximum retained semantic nodes across the application; defaults to 100000. Worker heap and parent result admission remain enforced.",
),
max_semantic_nodes_per_file: z
.number()
.int()
.min(1)
.max(JAVASCRIPT_SEMANTIC_NODE_MAXIMUM)
.optional()
.describe(
"Maximum retained semantic nodes per source file; defaults to 20000, additionally bounded by max_semantic_nodes and memory/transfer limits.",
),
max_heap_mb: z
.number()
.int()
@@ -24,6 +24,7 @@ export const JAVASCRIPT_APPLICATION_NODE_KINDS = [
"javascript-asset",
"javascript-chunk",
"javascript-module",
"javascript-expression",
"source-map",
"source-module",
"browser-window",
@@ -37,6 +37,14 @@ export const estimateJavaScriptStaticApplicationProjection = (
}
for (const binding of facts.electron.native_addon_bindings)
records += binding.members.length * 3;
for (const path of facts.electron.call_paths ?? []) {
// One contextual node and edge per step, with repeated guards and identity.
records += path.steps.length * 4 + 8;
repeatedBytes +=
Buffer.byteLength(JSON.stringify(path.conditions)) *
path.steps.length *
2;
}
const metadataBytes = 2048 + Buffer.byteLength(sourcePath) * 4;
return (checkpointBytes * 4 + repeatedBytes + records * metadataBytes) * 8;
};
@@ -0,0 +1,111 @@
import * as t from "@babel/types";
import { semanticStaticPropertyKey } from "./javascriptAstValues.js";
import { moduleFactory } from "./javascriptStaticAnalysisHelpers.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
/** A literal module table dispatched by a standalone CommonJS-style runtime. */
interface JavaScriptBootstrap {
readonly body: t.BlockStatement;
readonly table: t.ObjectExpression;
readonly loaderName: string;
}
/** Recognize syntax, never invoke a bootstrap or infer it from variable names. */
export const javascriptBootstrapAt = (
call: t.CallExpression,
): JavaScriptBootstrap | undefined => {
if (
(!t.isFunctionExpression(call.callee) &&
!t.isArrowFunctionExpression(call.callee)) ||
!t.isBlockStatement(call.callee.body) ||
call.callee.params.length !== 0 ||
call.arguments.length !== 0
)
return undefined;
const body = call.callee.body;
const tables = new Map<string, t.ObjectExpression>();
for (const statement of body.body) {
if (!t.isVariableDeclaration(statement)) continue;
for (const declaration of statement.declarations) {
if (
t.isIdentifier(declaration.id) &&
t.isObjectExpression(declaration.init) &&
declaration.init.properties.length > 0 &&
declaration.init.properties.every(
(property) => moduleFactory(property) !== undefined,
)
)
tables.set(declaration.id.name, declaration.init);
}
}
if (tables.size === 0) return undefined;
for (const loader of body.body) {
if (
!t.isFunctionDeclaration(loader) ||
loader.id === null ||
loader.id === undefined ||
loader.params.length !== 1
)
continue;
const parameter = loader.params[0];
if (!t.isIdentifier(parameter)) continue;
const loaderName = loader.id.name;
let tableName: string | undefined;
traverseJavaScriptAst(loader.body, {
enter: (node, _parent, ancestors) => {
if (
!t.isCallExpression(node) ||
ancestors().some((node) => t.isFunction(node))
)
return;
const candidate = dispatchedTableName(node, loaderName, parameter.name);
if (candidate !== undefined && tables.has(candidate))
tableName = candidate;
},
});
const table = tableName === undefined ? undefined : tables.get(tableName);
if (table !== undefined && tableName !== undefined)
return { body, table, loaderName };
}
return undefined;
};
const dispatchedTableName = (
call: t.CallExpression,
loaderName: string,
keyName: string,
): string | undefined => {
const member = call.callee;
const viaCall =
t.isMemberExpression(member) &&
semanticStaticPropertyKey(member.property, member.computed) === "call";
const dispatch =
viaCall && t.isMemberExpression(member) ? member.object : member;
if (
!t.isMemberExpression(dispatch) ||
!dispatch.computed ||
!t.isIdentifier(dispatch.object) ||
!t.isIdentifier(dispatch.property, { name: keyName })
)
return undefined;
const args = viaCall ? call.arguments.slice(1) : call.arguments;
const [module, exports, require] = args;
if (
args.length !== 3 ||
!t.isIdentifier(module) ||
!t.isMemberExpression(exports) ||
!t.isIdentifier(exports.object, { name: module.name }) ||
semanticStaticPropertyKey(exports.property, exports.computed) !==
"exports" ||
!t.isIdentifier(require, { name: loaderName })
)
return undefined;
if (
viaCall &&
(!t.isNode(call.arguments[0]) ||
!t.isNodesEquivalent(call.arguments[0], exports))
)
return undefined;
return dispatch.object.name;
};
@@ -82,7 +82,12 @@ const kindMatches = (
({ properties }) => properties.endpoint_kind === "route",
)
);
if (kind === "api") return node.kind === "context-bridge-api";
if (kind === "api")
return (
node.kind === "context-bridge-api" ||
(node.kind === "javascript-expression" &&
node.observations.some((o) => o.properties.step_kind === "exposure"))
);
if (kind === "channel") return node.kind === "ipc-channel";
if (kind === "module")
return ["javascript-asset", "javascript-module", "source-module"].includes(
@@ -217,7 +217,9 @@ const pathTo = (
const isTerminal = (node: ApplicationNode): boolean =>
["endpoint", "storage", "native-addon", "native-export", "unknown"].includes(
node.kind,
);
) ||
(node.kind === "javascript-expression" &&
node.observations.some((o) => o.properties.step_kind === "terminal"));
const factSummary = (
nodes: readonly ApplicationNode[],
@@ -9,6 +9,85 @@ const resultValue = (body: string) =>
"result",
).returnSites[0]?.value;
describe("bounded paths through dynamic selectors and class origins", () => {
it("bounds linked-list walks through class instances", () => {
const start = performance.now();
const analysis = analyzeJavaScriptSemantics(`
class List { tail = null; }
export function walk() {
const list = new List();
let node = list.tail;
while (node) { use(node.value.parent); node = node.previous; }
return 42;
}
`);
expect(onlyCallable(analysis, "walk").returnSites[0]?.value).toEqual({
status: "literal",
value: 42,
});
expect(performance.now() - start).toBeLessThan(2000);
}, 30000);
it("retains effects through class getter aliases while bounding loop paths", () => {
expect(
resultValue(`
const shared = { value: 1 };
class List { get tail() { return { value: shared }; } }
let node = new List().tail;
while (node) { use(node.value); node = node.previous; }
return shared.value;
`)?.status,
).toBe("unknown");
});
it("bounds dynamic-key setter loops that also create literal containers", () => {
const start = performance.now();
const analysis = analyzeJavaScriptSemantics(`
export function set(root, keys, value) {
let node = root;
for (const [index, key] of keys.entries()) {
if (index === keys.length - 1) { node[key] = value; return root; }
const child = node[key];
if (isObject(child)) { node = child; continue; }
const fresh = isIndex(keys[index + 1]) ? [] : {};
node[key] = fresh;
node = fresh;
}
return root;
}
export function unaffected() { return 42; }
`);
expect(onlyCallable(analysis, "unaffected").returnSites[0]?.value).toEqual({
status: "literal",
value: 42,
});
expect(onlyCallable(analysis, "set").returnSites[0]?.value.status).toBe(
"unknown",
);
expect(performance.now() - start).toBeLessThan(2000);
}, 30000);
it("retains shared-child mutations reached by dynamic-key alias loops", () => {
expect(
resultValue(`
const shared = { value: 1 };
const source = { child: { next: shared }, keep: 3 };
let node = source.child;
while (flag) { node = node[key] || {}; node.value = 2; }
return shared.value;
`)?.status,
).toBe("unknown");
expect(
resultValue(`
const source = { child: { value: 1 }, keep: 3 };
let node = source.child;
while (flag) { node = node[key] || {}; node.value = 2; }
return source.keep;
`),
).toEqual({ status: "literal", value: 3 });
});
});
describe("bounded member path propagation", () => {
it("bounds cyclic member paths with no observed container origin", () => {
const branches = Array.from(
@@ -121,6 +121,15 @@ export const analyzeParsedJavaScriptReferences = (
return collectSemanticReferences(file.program, state, name);
};
/** Share the lexical index with bounded, demand-driven boundary analyses. */
export function* indexJavaScriptBindingsSteps(
program: t.Program,
): Generator<void, JavaScriptSemanticAnalysisState> {
const state = createState(program);
yield* collectDefinitionsSteps(program, state);
return state;
}
/** Recover semantics from an already parsed JavaScript artifact. */
export const analyzeParsedJavaScriptSemantics = (
file: ParsedJavaScriptSource,
@@ -718,6 +718,9 @@ const referencedValues = (
type MutationObservableOrigin =
| t.ObjectExpression
| t.ArrayExpression
| t.ClassDeclaration
| t.ClassExpression
| t.NewExpression
| "ambient"
| "array"
| "array-prototype"
@@ -808,11 +811,6 @@ const observableMutationPaths = (
const observer = bindingNodes.get(binding.bindingId);
if (observer === undefined) continue;
for (const initializer of binding.initializers) {
const expression = unwrapJavaScriptExpression(initializer.node).node;
// Preserve member paths through class values and local instances so
// getter-result aliases can be resolved at the accessor boundary.
if (t.isClass(expression) || t.isNewExpression(expression))
(observer.origins ??= new Set()).add("opaque");
link(
project(expressionNode(initializer.node), initializer.projection),
observer,
@@ -837,7 +835,14 @@ const observableMutationPaths = (
const selected = pendingExpressions.pop();
if (selected === undefined) break;
const { expression, observer } = selected;
if (t.isObjectExpression(expression) || t.isArrayExpression(expression))
// Keep class/getter projections distinct so cycle pruning does not
// discard observable escapes through a shared getter result.
if (
t.isObjectExpression(expression) ||
t.isArrayExpression(expression) ||
t.isClass(expression) ||
t.isNewExpression(expression)
)
(observer.origins ??= new Set()).add(expression);
else if (t.isIdentifier(expression)) {
const binding = resolveSemanticBindingState(
@@ -1,4 +1,5 @@
import * as t from "@babel/types";
import { collectElectronWrappedCallPaths } from "./electronWrappedCallPaths.js";
import {
ELECTRON_IDENTITY_LIMITATION,
ELECTRON_MODULE,
@@ -27,6 +28,7 @@ import {
} from "./javascriptStaticAnalysisCalls.js";
import {
inspectBundlerRegistration,
inspectBundlerBootstrap,
inspectEsbuildWrapper,
} from "./javascriptStaticAnalysisBundler.js";
import { finalizeLocatedFindings } from "./javascriptStaticAnalysisFindings.js";
@@ -38,6 +40,7 @@ import {
import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js";
import {
classifyParsedJavaScriptElectronBindingsSteps,
indexJavaScriptBindingsSteps,
classifyParsedJavaScriptRpcClientRootsSteps,
classifyParsedJavaScriptOpenReceivers,
type JavaScriptOpenReceiverFact,
@@ -90,6 +93,13 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
electronBindings,
rpcScan: createJavaScriptRpcScan(source, clientRoots),
});
const bindings = yield* indexJavaScriptBindingsSteps(file.program);
accumulator.electronCallPaths = collectElectronWrappedCallPaths(
file.program,
bindings,
source,
accumulator,
);
addSourceMapDirectives(source, file.comments ?? [], accumulator);
return finalizeStaticAnalysis(source, file, accumulator);
}
@@ -114,6 +124,16 @@ const finalizeStaticAnalysis = (
): JavaScriptStaticAnalysis => {
const parserErrors = file.errors.length;
const limitations = [
...(accumulator.electronCallPaths?.length
? [
"Bundled Electron call paths use static argument and closure substitutions. Conditions, ambient helper overrides, runtime reachability and arbitrary side effects remain unproven.",
]
: []),
...(accumulator.electronCallPathLimitReached
? [
"Electron wrapper expansion reached its resource budget (1000000 visits/substitutions or depth 128); additional paths remain unknown.",
]
: []),
...(parserErrors === 0
? []
: [
@@ -162,6 +182,9 @@ const finalizeStaticAnalysis = (
source_map_urls: accumulator.sourceMaps,
vendors: detectVendors(source),
electron: {
...(accumulator.electronCallPaths?.length
? { call_paths: accumulator.electronCallPaths }
: {}),
browser_windows: finalizeLocatedFindings(
accumulator.browserWindows,
accumulator.moduleRangeIndex,
@@ -198,6 +221,7 @@ const inspectNode = (
const { source, accumulator } = findings;
inspectElectronStaticNode(node, findings);
if (t.isCallExpression(node)) {
inspectBundlerBootstrap(source, node, accumulator);
inspectBundlerRegistration(source, node, accumulator);
inspectEsbuildWrapper(source, node, accumulator);
inspectCall(source, node, findings);
@@ -1,4 +1,5 @@
import * as t from "@babel/types";
import { javascriptBootstrapAt } from "./javascriptBootstrap.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
@@ -77,6 +78,61 @@ export const inspectBundlerRegistration = (
);
};
/** Recover standalone Webpack tables whose factories are called by a local loader. */
export const inspectBundlerBootstrap = (
source: string,
call: t.CallExpression,
accumulator: AnalysisAccumulator,
): void => {
const bootstrap = javascriptBootstrapAt(call);
if (bootstrap === undefined) return;
const runtime = `webpack-bootstrap@${String(call.start ?? 0)}`;
const recovered = recoverBundlerModules(
source,
bootstrap.table,
runtime,
accumulator,
);
const entries: string[] = [];
let unknownEntries = 0;
traverseJavaScriptAst(bootstrap.body, {
enter: (node, _parent, ancestors) => {
if (
!t.isCallExpression(node) ||
ancestors().some((node) => t.isFunction(node)) ||
!t.isIdentifier(node.callee, { name: bootstrap.loaderName })
)
return;
const value = argumentValue(node.arguments[0]);
if (value === undefined) unknownEntries++;
else entries.push(value);
},
});
const registration: JavaScriptBundlerRegistration = {
bundler: "webpack",
runtime,
chunk_keys: [`bootstrap@${String(call.start ?? 0)}`],
unknown_chunk_keys: 0,
runtime_require_name: bootstrap.loaderName,
runtime_module_cache_status: "not-observed",
entry_module_keys: uniqueValues(entries).values,
unknown_entry_module_keys: unknownEntries,
async_chunk_keys: recovered.asyncChunkKeys,
unknown_async_chunk_keys: recovered.unknownAsyncChunkKeys,
modules: recovered.modules.sort((left, right) =>
compareUnicodeCodePoints(left.module_key, right.module_key),
),
location: range(call),
};
accumulator.unknownFindings +=
unknownEntries + recovered.unknownAsyncChunkKeys;
addFindingOnce(
accumulator,
`registration\0${registrationKey(registration)}`,
() => accumulator.registrations.push(registration),
);
};
/** Inspect esbuild helper wrappers and recover their literal module tables. */
export const inspectEsbuildWrapper = (
source: string,
@@ -3,6 +3,53 @@ import { expect, it } from "vitest";
import { JavaScriptModuleRangeIndex } from "./javascriptStaticAnalysisFindings.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
it.each(["direct", "call"])(
"recovers standalone module factories dispatched with %s",
(style) => {
const dispatch =
style === "call"
? "modules[id].call(module.exports, module, module.exports, load)"
: "modules[id](module, module.exports, load)";
const source = `(() => {
const modules = {
17(module, exports, req) { exports.read = req(23); },
23(module) { module.exports = require("electron"); }
};
const cache = {};
function load(id) {
if (cache[id]) return cache[id].exports;
const module = cache[id] = { exports: {} };
${dispatch};
return module.exports;
}
load(17);
})();`;
const analysis = analyzeJavaScriptStaticSource(source);
expect(analysis.bundler_registrations).toMatchObject([
{
bundler: "webpack",
entry_module_keys: ["17"],
modules: [{ module_key: "17" }, { module_key: "23" }],
},
]);
expect(analysis.references).toEqual(
expect.arrayContaining([
expect.objectContaining({ specifier: "23", module_key: "17" }),
expect.objectContaining({ specifier: "electron", module_key: "23" }),
]),
);
},
);
it("does not infer a bootstrap from a function table without a matching loader", () => {
const analysis = analyzeJavaScriptStaticSource(`(() => {
const handlers = { 17(module) { module.exports = require("electron"); } };
function load(id) { return handlers[id](userInput); }
load(17);
})();`);
expect(analysis.bundler_registrations).toEqual([]);
});
it("preserves first insertion precedence and inclusive interval bounds", () => {
const nested = new JavaScriptModuleRangeIndex(100);
expect(nested.find(0)).toBeUndefined();
@@ -10,6 +10,7 @@ import type {
JavaScriptStaticStorage,
} from "./javascriptStaticAnalysisTypes.js";
import type {
ElectronCallPath,
ElectronBrowserWindowFinding,
ElectronContextBridgeFinding,
ElectronIpcFinding,
@@ -36,6 +37,8 @@ export interface LocatedJavaScriptFinding<Value> {
/** Mutable collections used during one inert AST traversal. */
export interface JavaScriptAnalysisAccumulator {
electronCallPaths?: readonly ElectronCallPath[];
electronCallPathLimitReached?: boolean;
readonly references: LocatedJavaScriptFinding<JavaScriptStaticReference>[];
readonly endpoints: LocatedJavaScriptFinding<JavaScriptStaticEndpoint>[];
readonly storage: LocatedJavaScriptFinding<JavaScriptStaticStorage>[];
@@ -0,0 +1,55 @@
import { expect, it } from "vitest";
import * as t from "@babel/types";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import {
completeSemanticSteps,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { JavaScriptSymbolicBindings } from "./javascriptSymbolicBindings.js";
import { symbolicVariants } from "./javascriptSymbolicValues.js";
it("substitutes numeric modules, named exports and captured wrapper arguments", () => {
const parsed = parseJavaScriptSource(`(() => {
const modules = {
4(module) { module.exports = require("electron"); },
8(module, exports, load) {
const electron = load(4);
exports.sender = function(channel) { return (...args) => electron.ipcRenderer.send(channel,...args); };
},
12(module, exports, load) { const helpers = load(8); const api = {copy: helpers.sender("test:copy")}; inspect(api.copy); }
};
function load(id) { const m = {exports:{}}; modules[id](m,m.exports,load); return m.exports; }
load(12);
})();`)!;
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const resolver = new JavaScriptSymbolicBindings(state, parsed.program);
let found = false;
traverseJavaScriptAst(parsed.program, {
enter: (node) => {
if (
!t.isCallExpression(node) ||
!t.isIdentifier(node.callee, { name: "inspect" })
)
return;
const value = resolver.evaluate(node.arguments[0]);
const fn = symbolicVariants(value).find((v) => v.kind === "function");
expect(fn?.kind).toBe("function");
if (fn?.kind !== "function" || !t.isCallExpression(fn.node.body))
throw new Error("closure missing");
const env = resolver.bind(fn, []);
expect(resolver.evaluate(fn.node.body.callee, env)).toEqual({
kind: "external",
path: ["electron", "ipcRenderer", "send"],
});
expect(resolver.arguments(fn.node.body, env)[0]).toEqual({
kind: "literal",
value: "test:copy",
});
found = true;
},
});
expect(found).toBe(true);
});
@@ -0,0 +1,616 @@
import * as t from "@babel/types";
import {
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { javascriptBootstrapAt } from "./javascriptBootstrap.js";
import { symbolicLogicalRight } from "./javascriptSymbolicControlFlow.js";
import {
moduleFactory,
modulePropertyName,
} from "./javascriptStaticAnalysisHelpers.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import {
symbolicUnion,
symbolicUnknown,
symbolicVariants,
symbolicTruth,
type SymbolicValue,
type SymbolicEnvironment,
type SymbolicFunction,
} from "./javascriptSymbolicValues.js";
interface PropertyWrite {
readonly path: readonly string[];
readonly value: t.Node;
readonly site: t.Node;
}
interface SymbolicReturnFlow {
readonly values: readonly SymbolicValue[];
readonly fallsThrough: boolean;
}
const emptyEnvironment: SymbolicEnvironment = { values: new Map() };
/** Demand-driven substitutions for local bindings, closures and literal module tables. */
export class JavaScriptSymbolicBindings {
readonly #writes = new Map<string, PropertyWrite[]>();
readonly #environments = new WeakMap<t.Node, SymbolicEnvironment>();
readonly #cache = new WeakMap<
SymbolicEnvironment,
WeakMap<t.Node, SymbolicValue>
>();
readonly #active = new Set<t.Node>();
readonly #activeFunctions = new Set<t.Function>();
readonly #exportAliases = new Map<string, string>();
readonly #moduleBindings = new Set<string>();
readonly #replacements = new Map<string, t.Node>();
readonly #propertyActive = new WeakMap<object, Set<string>>();
readonly #mutated = new Set<string>();
readonly #namespaceOwners = new Map<string, t.Function>();
readonly #unsafeExports = new Set<string>();
#remainingEvaluations = 1_000_000;
#depth = 0;
truncated = false;
constructor(
readonly state: JavaScriptSemanticAnalysisState,
program: t.Program,
) {
traverseJavaScriptAst(program, {
enter: (node) => {
if (t.isCallExpression(node)) this.#indexBootstrap(node);
},
});
traverseJavaScriptAst(program, {
enter: (node) => {
if (t.isAssignmentExpression(node))
this.#indexWrite(node.left, node.right, node);
if (
t.isCallExpression(node) &&
t.isMemberExpression(node.callee) &&
t.isIdentifier(node.callee.object, { name: "Object" }) &&
!resolveSemanticBindingState(state, node.callee.object, "Object") &&
semanticStaticPropertyKey(
node.callee.property,
node.callee.computed,
) === "defineProperty"
) {
const [target, key, descriptor] = node.arguments;
if (
t.isIdentifier(target) &&
t.isStringLiteral(key) &&
t.isObjectExpression(descriptor)
) {
const value = descriptor.properties.find(
(p) =>
t.isObjectProperty(p) &&
semanticStaticPropertyKey(p.key, p.computed) === "value",
);
if (t.isObjectProperty(value))
this.#addWrite(target, [key.value], value.value, node);
}
}
},
});
}
environmentAt(node: t.Node): SymbolicEnvironment {
let current: t.Node | undefined = node;
while (current) {
const found = this.#environments.get(current);
if (found) return found;
current = this.state.parentsByNode.get(current);
}
return emptyEnvironment;
}
evaluate(
input: t.Node | null | undefined,
environment?: SymbolicEnvironment,
): SymbolicValue {
if (!input) return { kind: "literal", value: undefined };
if (--this.#remainingEvaluations < 0 || this.#depth >= 128) {
this.truncated = true;
return symbolicUnknown("symbolic resource limit");
}
const node = unwrapJavaScriptExpression(input).node;
const env = environment ?? this.environmentAt(node);
const cache = this.#cache.get(env) ?? new WeakMap<t.Node, SymbolicValue>();
this.#cache.set(env, cache);
const cached = cache.get(node);
if (cached) return cached;
if (this.#active.has(node)) return symbolicUnknown("cyclic binding");
this.#active.add(node);
this.#depth += 1;
try {
const value = this.#evaluate(node, env);
cache.set(node, value);
return value;
} finally {
this.#active.delete(node);
this.#depth -= 1;
}
}
property(value: SymbolicValue, key: string): SymbolicValue {
const active = this.#propertyActive.get(value) ?? new Set<string>();
if (active.has(key)) return symbolicUnknown("cyclic property");
active.add(key);
this.#propertyActive.set(value, active);
try {
return this.#property(value, key);
} finally {
active.delete(key);
}
}
#property(value: SymbolicValue, key: string): SymbolicValue {
if (value.kind === "union")
return symbolicUnion(value.values.map((v) => this.property(v, key)));
if (value.kind === "external")
return { kind: "external", path: [...value.path, key] };
if (value.kind === "record")
return value.values.get(key) ?? symbolicUnknown("missing property");
if (value.kind === "namespace") {
if (
this.#mutated.has(value.bindingId) ||
this.#unsafeExports.has(`${value.bindingId}:${key}`)
)
return symbolicUnknown("ambiguous export writes");
const replacement = this.#replacements.get(value.bindingId);
if (replacement)
return this.property(
this.evaluate(replacement, value.environment),
key,
);
const writes = (this.#writes.get(value.bindingId) ?? []).filter(
(w) => w.path.length === 1 && w.path[0] === key,
);
const write = writes.at(-1);
return write
? this.evaluate(write.value, value.environment)
: symbolicUnknown("unresolved module export");
}
if (value.kind === "object") {
for (const p of [...value.node.properties].reverse()) {
if (t.isSpreadElement(p)) {
const spread = this.evaluate(p.argument, value.environment);
const found = this.property(spread, key);
if (found.kind !== "unknown") return found;
if (
this.unknownMembers(spread) > 0 ||
this.members(spread).includes(key)
)
return found;
} else if (semanticStaticPropertyKey(p.key, p.computed) === key) {
return t.isObjectMethod(p) && p.kind !== "method"
? symbolicUnknown("accessor property")
: this.evaluate(
t.isObjectProperty(p) ? p.value : p,
value.environment,
);
} else if (semanticStaticPropertyKey(p.key, p.computed) === null)
return symbolicUnknown("dynamic property overwrite");
}
}
if (value.kind === "array" && /^\d+$/u.test(key))
return value.values[Number(key)] ?? symbolicUnknown("missing argument");
return symbolicUnknown("unresolved property");
}
members(
value: SymbolicValue,
active = new Set<SymbolicValue>(),
): readonly string[] {
if (active.has(value)) return [];
active.add(value);
if (value.kind === "union")
return [...new Set(value.values.flatMap((v) => this.members(v, active)))];
if (value.kind === "record") return [...value.values.keys()];
if (value.kind === "namespace") {
const replacement = this.#replacements.get(value.bindingId);
return replacement
? this.members(this.evaluate(replacement, value.environment), active)
: [
...new Set(
(this.#writes.get(value.bindingId) ?? []).flatMap((w) =>
w.path.length === 1 ? w.path : [],
),
),
];
}
if (value.kind !== "object") return [];
return [
...new Set(
value.node.properties.flatMap((p) =>
t.isSpreadElement(p)
? this.members(this.evaluate(p.argument, value.environment), active)
: [semanticStaticPropertyKey(p.key, p.computed)].filter(
(k): k is string => k !== null,
),
),
),
];
}
unknownMembers(
value: SymbolicValue,
active = new Set<SymbolicValue>(),
): number {
if (active.has(value)) return 1;
active.add(value);
if (value.kind === "union")
return value.values.reduce(
(sum, v) => sum + this.unknownMembers(v, active),
0,
);
if (value.kind === "record") return 0;
if (value.kind === "namespace") {
if (this.#mutated.has(value.bindingId)) return 1;
const replacement = this.#replacements.get(value.bindingId);
return replacement
? this.unknownMembers(
this.evaluate(replacement, value.environment),
active,
)
: 0;
}
if (value.kind !== "object") return 1;
return value.node.properties.reduce(
(sum, p) =>
sum +
(t.isSpreadElement(p)
? this.unknownMembers(
this.evaluate(p.argument, value.environment),
new Set(active),
)
: Number(semanticStaticPropertyKey(p.key, p.computed) === null)),
0,
);
}
bind(
fn: SymbolicFunction,
args: readonly SymbolicValue[],
): SymbolicEnvironment {
const values = new Map<string, SymbolicValue>();
fn.node.params.forEach((param, index) => {
const target = t.isRestElement(param) ? param.argument : param;
if (!t.isIdentifier(target)) return;
const binding = resolveSemanticBindingState(
this.state,
target,
target.name,
);
if (binding)
values.set(
binding.bindingId,
t.isRestElement(param)
? { kind: "array", values: args.slice(index) }
: (args[index] ?? symbolicUnknown("runtime parameter")),
);
});
return { values, parent: fn.environment };
}
arguments(
node: t.CallExpression | t.OptionalCallExpression,
env: SymbolicEnvironment,
): readonly SymbolicValue[] {
return node.arguments.flatMap((a) => {
if (!t.isSpreadElement(a)) return [this.evaluate(a, env)];
const value = this.evaluate(a.argument, env);
return value.kind === "array"
? value.values
: [symbolicUnknown("dynamic spread arguments")];
});
}
#evaluate(node: t.Node, env: SymbolicEnvironment): SymbolicValue {
if (
t.isStringLiteral(node) ||
t.isNumericLiteral(node) ||
t.isBooleanLiteral(node)
)
return { kind: "literal", value: node.value };
if (t.isNullLiteral(node)) return { kind: "literal", value: null };
if (t.isFunction(node))
return { kind: "function", node, environment: env, origins: [] };
if (t.isObjectExpression(node))
return { kind: "object", node, environment: env };
if (t.isIdentifier(node)) return this.#identifier(node, env);
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
const key = semanticStaticPropertyKey(node.property, node.computed);
return key === null
? symbolicUnknown("dynamic member")
: this.property(this.evaluate(node.object, env), key);
}
if (t.isSequenceExpression(node))
return this.evaluate(node.expressions.at(-1), env);
if (t.isAssignmentExpression(node)) return this.evaluate(node.right, env);
if (t.isAwaitExpression(node)) return this.evaluate(node.argument, env);
if (t.isUnaryExpression(node)) {
const value = this.evaluate(node.argument, env);
if (node.operator === "void")
return { kind: "literal", value: undefined };
const truth = symbolicTruth(value);
if (node.operator === "!" && truth !== undefined)
return { kind: "literal", value: !truth };
}
if (t.isConditionalExpression(node)) {
const truth = symbolicTruth(this.evaluate(node.test, env));
return truth === undefined
? symbolicUnion([
this.evaluate(node.consequent, env),
this.evaluate(node.alternate, env),
])
: this.evaluate(truth ? node.consequent : node.alternate, env);
}
if (t.isLogicalExpression(node)) {
const left = this.evaluate(node.left, env);
const right = symbolicLogicalRight(node.operator, left);
if (right !== undefined)
return right ? this.evaluate(node.right, env) : left;
return symbolicUnion([left, this.evaluate(node.right, env)]);
}
if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
return this.#call(node, env);
return symbolicUnknown(`unsupported ${node.type}`);
}
#identifier(node: t.Identifier, env: SymbolicEnvironment): SymbolicValue {
const binding = resolveSemanticBindingState(this.state, node, node.name);
if (
!binding &&
node.name === "undefined" &&
!semanticResolutionBlocked(this.state, node, node.name)
)
return { kind: "literal", value: undefined };
if (!binding)
return !semanticResolutionBlocked(this.state, node, node.name) &&
["require", "navigator", "String", "Boolean"].includes(node.name)
? { kind: "external", path: [node.name] }
: symbolicUnknown("unbound identifier");
if (binding.definitions.some((d) => d.kind === "assignment"))
return symbolicUnknown("reassigned binding");
if (this.#mutated.has(binding.bindingId))
return symbolicUnknown("dynamic member writes");
for (let e: SymbolicEnvironment | undefined = env; e; e = e.parent) {
const value = e.values.get(binding.bindingId);
if (value) return value;
}
if (this.#writes.has(binding.bindingId))
return symbolicUnknown("overwritten object members");
const initializer =
binding.initializers.length === 1 ? binding.initializers[0] : undefined;
if (!initializer)
return symbolicUnknown("missing or ambiguous initializer");
if (this.state.conditionalInitializers.has(initializer.node))
return symbolicUnknown("conditional initializer");
let value = this.evaluate(initializer.node, env);
for (const key of initializer.projection) {
if (key === null) return symbolicUnknown("dynamic destructuring");
value = this.property(value, String(key));
}
return value;
}
#call(
node: t.CallExpression | t.OptionalCallExpression,
env: SymbolicEnvironment,
): SymbolicValue {
const callee = this.evaluate(node.callee, env);
const args = this.arguments(node, env);
return symbolicUnion(
symbolicVariants(callee).map((value) => {
if (value.kind === "loader") {
const key = args[0];
return key?.kind === "literal"
? (value.modules.get(String(key.value)) ??
symbolicUnknown("missing module"))
: symbolicUnknown("dynamic module");
}
if (value.kind === "external" && value.path.join(".") === "require") {
const spec = args[0];
return spec?.kind === "literal" && typeof spec.value === "string"
? { kind: "external", path: [spec.value] }
: symbolicUnknown("dynamic require");
}
if (value.kind !== "function" || this.#activeFunctions.has(value.node))
return symbolicUnknown("unresolved or recursive call");
this.#activeFunctions.add(value.node);
try {
const bound = this.bind(value, args);
const result = t.isBlockStatement(value.node.body)
? this.#returns(value.node.body.body, bound)
: this.evaluate(value.node.body, bound);
return symbolicUnion(
symbolicVariants(result).map((v) =>
v.kind === "function"
? { ...v, origins: [...value.origins, node, ...v.origins] }
: v,
),
);
} finally {
this.#activeFunctions.delete(value.node);
}
}),
);
}
#returns(
statements: readonly t.Statement[],
env: SymbolicEnvironment,
): SymbolicValue {
const flow = this.#returnFlow(statements, env);
return symbolicUnion([
...flow.values,
...(flow.fallsThrough
? [{ kind: "literal" as const, value: undefined }]
: []),
]);
}
#returnFlow(
statements: readonly t.Statement[],
env: SymbolicEnvironment,
depth = 0,
): SymbolicReturnFlow {
const possible: SymbolicValue[] = [];
for (const statement of statements) {
if (--this.#remainingEvaluations < 0 || depth >= 128) {
this.truncated = true;
return {
values: [...possible, symbolicUnknown("symbolic resource limit")],
fallsThrough: false,
};
}
if (t.isThrowStatement(statement))
return {
values: [...possible, symbolicUnknown("wrapper exits by throwing")],
fallsThrough: false,
};
if (t.isReturnStatement(statement))
return {
values: [...possible, this.evaluate(statement.argument, env)],
fallsThrough: false,
};
if (t.isBlockStatement(statement)) {
const flow = this.#returnFlow(statement.body, env, depth + 1);
possible.push(...flow.values);
if (!flow.fallsThrough)
return { values: possible, fallsThrough: false };
}
if (t.isIfStatement(statement)) {
const truth = symbolicTruth(this.evaluate(statement.test, env));
const branches =
truth === undefined
? [statement.consequent, statement.alternate]
: [truth ? statement.consequent : statement.alternate];
const flows = branches.map((branch) =>
this.#returnFlow(branch ? [branch] : [], env, depth + 1),
);
possible.push(...flows.flatMap((flow) => flow.values));
if (flows.every((flow) => !flow.fallsThrough))
return { values: possible, fallsThrough: false };
}
}
return { values: possible, fallsThrough: true };
}
#indexBootstrap(call: t.CallExpression): void {
const bootstrap = javascriptBootstrapAt(call);
if (!bootstrap) return;
const modules = new Map<string, SymbolicValue>();
for (const property of bootstrap.table.properties) {
const factory = moduleFactory(property);
if (!factory) continue;
const values = new Map<string, SymbolicValue>();
const environment = { values };
const first = factory.params[0];
const second = factory.params[1];
const third = factory.params[2];
const binding = (n: t.Node | undefined) =>
t.isIdentifier(n)
? resolveSemanticBindingState(this.state, n, n.name)?.bindingId
: undefined;
const moduleId = binding(first);
const exportsId = binding(second);
const loaderId = binding(third);
const namespaceId = `${String(factory.start)}:exports`;
this.#namespaceOwners.set(namespaceId, factory);
const namespace: SymbolicValue = {
kind: "namespace",
bindingId: namespaceId,
environment,
};
if (moduleId) {
values.set(moduleId, {
kind: "record",
values: new Map([["exports", namespace]]),
});
this.#exportAliases.set(moduleId, namespaceId);
this.#moduleBindings.add(moduleId);
}
if (exportsId) {
values.set(exportsId, namespace);
this.#exportAliases.set(exportsId, namespaceId);
}
if (loaderId) values.set(loaderId, { kind: "loader", modules });
this.#environments.set(factory, environment);
modules.set(modulePropertyName(property), namespace);
}
}
#indexWrite(target: t.Node, value: t.Node, site: t.Node): void {
const path: string[] = [];
let root = target;
let dynamic = false;
while (t.isMemberExpression(root)) {
const key = semanticStaticPropertyKey(root.property, root.computed);
if (key === null) dynamic = true;
else path.unshift(key);
root = root.object;
}
if (dynamic && t.isIdentifier(root)) {
const binding = resolveSemanticBindingState(this.state, root, root.name);
if (binding)
this.#mutated.add(
this.#exportAliases.get(binding.bindingId) ?? binding.bindingId,
);
return;
}
if (t.isIdentifier(root)) this.#addWrite(root, path, value, site);
}
#addWrite(
root: t.Identifier,
path: readonly string[],
value: t.Node,
site: t.Node,
): void {
const binding = resolveSemanticBindingState(this.state, root, root.name);
if (!binding) return;
const alias = this.#exportAliases.get(binding.bindingId);
if (this.#moduleBindings.has(binding.bindingId) && path[0] !== "exports")
return;
const key = alias ?? binding.bindingId;
const selected = this.#moduleBindings.has(binding.bindingId)
? path.slice(1)
: path;
if (alias) {
const owner = this.#namespaceOwners.get(alias);
for (
let parent = this.state.parentsByNode.get(site);
parent && parent !== owner;
parent = this.state.parentsByNode.get(parent)
) {
if (
t.isFunction(parent) ||
t.isIfStatement(parent) ||
t.isConditionalExpression(parent) ||
t.isLogicalExpression(parent) ||
t.isLoop(parent) ||
t.isSwitchCase(parent) ||
t.isTryStatement(parent)
) {
if (selected.length === 0) this.#mutated.add(key);
else this.#unsafeExports.add(`${key}:${selected[0] ?? ""}`);
return;
}
}
}
if (alias && selected.length === 0) {
this.#replacements.set(key, value);
return;
}
const writes = this.#writes.get(key) ?? [];
writes.push({ path: selected, value, site });
this.#writes.set(key, writes);
}
}
@@ -0,0 +1,53 @@
import * as t from "@babel/types";
import {
symbolicTruth,
type SymbolicValue,
} from "./javascriptSymbolicValues.js";
/** Whether short-circuit evaluation reaches the right operand; unknown stays unknown. */
export const symbolicLogicalRight = (
operator: t.LogicalExpression["operator"],
left: SymbolicValue,
): boolean | undefined => {
if (left.kind === "union") {
const choices = left.values.map((value) =>
symbolicLogicalRight(operator, value),
);
return choices.length > 0 &&
choices.every((choice) => choice === choices[0])
? choices[0]
: undefined;
}
if (operator === "??") {
if (left.kind === "literal")
return left.value === null || left.value === undefined;
return [
"function",
"object",
"namespace",
"array",
"record",
"loader",
].includes(left.kind)
? false
: undefined;
}
const truth = symbolicTruth(left);
return truth === undefined ? undefined : operator === "&&" ? truth : !truth;
};
/** Only definite synchronous exits; unsupported control flow stays unknown. */
export const symbolicDefiniteExit = (
node: t.Node | null | undefined,
depth = 0,
): boolean => {
if (!node || depth >= 128) return false;
if (t.isReturnStatement(node) || t.isThrowStatement(node)) return true;
if (t.isBlockStatement(node))
return node.body.some((child) => symbolicDefiniteExit(child, depth + 1));
return (
t.isIfStatement(node) &&
symbolicDefiniteExit(node.consequent, depth + 1) &&
symbolicDefiniteExit(node.alternate, depth + 1)
);
};
@@ -0,0 +1,64 @@
import type * as t from "@babel/types";
/** A lexical substitution environment, never a JavaScript execution context. */
export interface SymbolicEnvironment {
readonly values: ReadonlyMap<string, SymbolicValue>;
readonly parent?: SymbolicEnvironment;
}
export interface SymbolicFunction {
readonly kind: "function";
readonly node: t.Function;
readonly environment: SymbolicEnvironment;
readonly origins: readonly t.Node[];
}
export type SymbolicValue =
| { readonly kind: "unknown"; readonly reason: string }
| {
readonly kind: "literal";
readonly value: string | number | boolean | null | undefined;
}
| { readonly kind: "external"; readonly path: readonly string[] }
| {
readonly kind: "object";
readonly node: t.ObjectExpression;
readonly environment: SymbolicEnvironment;
}
| {
readonly kind: "namespace";
readonly bindingId: string;
readonly environment: SymbolicEnvironment;
}
| {
readonly kind: "loader";
readonly modules: ReadonlyMap<string, SymbolicValue>;
}
| { readonly kind: "array"; readonly values: readonly SymbolicValue[] }
| {
readonly kind: "record";
readonly values: ReadonlyMap<string, SymbolicValue>;
}
| { readonly kind: "union"; readonly values: readonly SymbolicValue[] }
| SymbolicFunction;
export const symbolicUnknown = (reason: string): SymbolicValue => ({
kind: "unknown",
reason,
});
export const symbolicVariants = (
value: SymbolicValue,
): readonly SymbolicValue[] =>
value.kind === "union" ? value.values : [value];
export const symbolicUnion = (
values: readonly SymbolicValue[],
): SymbolicValue => {
const unique = [...new Set(values.flatMap(symbolicVariants))];
return unique.length === 1 ? unique[0]! : { kind: "union", values: unique };
};
export const symbolicTruth = (value: SymbolicValue): boolean | undefined =>
value.kind === "literal"
? Boolean(value.value)
: ["function", "object", "namespace", "array"].includes(value.kind)
? true
: undefined;
@@ -20,6 +20,7 @@ import {
export const JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES = 8 * 1024 * 1024;
const FRAME_BYTES = JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES;
const TRANSFER_BYTES = 512 * 1024 * 1024;
const MAX_TRANSFER_BYTES = 2 * 1024 * 1024 * 1024;
const BATCH_BYTES = 1024 * 1024;
const recordSchema = z.discriminatedUnion("kind", [
z.strictObject({
@@ -36,7 +37,7 @@ const recordSchema = z.discriminatedUnion("kind", [
/** Complete-file coordinates authenticated before adopting any worker facts. */
export const javaScriptAnalysisTransferDescriptorSchema = z.strictObject({
bytes: z.number().int().nonnegative().max(TRANSFER_BYTES),
bytes: z.number().int().nonnegative().max(MAX_TRANSFER_BYTES),
sha256: digestSchema,
records: z.number().int().nonnegative(),
module_bytes: z.number().int().nonnegative(),
@@ -52,11 +53,24 @@ export const javaScriptTransferRetentionBytes = (
records: number,
): number => bytes * 8 + records * 64;
const encodeTransferRecord = (input: unknown, previousBytes: number) => {
/** Larger streamed files require corresponding parent admission capacity. */
export const javaScriptTransferByteBudget = (
resultBudgetBytes: number,
): number =>
Math.min(
MAX_TRANSFER_BYTES,
Math.max(TRANSFER_BYTES, Math.floor(resultBudgetBytes / 8)),
);
const encodeTransferRecord = (
input: unknown,
previousBytes: number,
transferBytes = TRANSFER_BYTES,
) => {
const record = recordSchema.parse(input);
const line = `${JSON.stringify(record)}\n`;
const size = Buffer.byteLength(line);
if (size > FRAME_BYTES || previousBytes + size > TRANSFER_BYTES)
if (size > FRAME_BYTES || previousBytes + size > transferBytes)
throw new AnalysisResourceConstraintError(
"analyze_javascript_application",
"transport",
@@ -65,7 +79,7 @@ const encodeTransferRecord = (input: unknown, previousBytes: number) => {
: "Complete JavaScript file facts exceed the transfer byte budget",
{
transfer_record_bytes: FRAME_BYTES,
transfer_file_bytes: TRANSFER_BYTES,
transfer_file_bytes: transferBytes,
record_bytes: size,
transfer_bytes_at_least: previousBytes + size,
},
@@ -108,7 +122,16 @@ export const writeJavaScriptAnalysisTransfer = async (
path: string,
module: JavaScriptModuleSemanticIr,
projection: JavaScriptSemanticFileProjection,
transferBytes = TRANSFER_BYTES,
): Promise<Descriptor> => {
if (
!Number.isSafeInteger(transferBytes) ||
transferBytes < 1 ||
transferBytes > MAX_TRANSFER_BYTES
)
throw new RangeError(
"JavaScript transfer requires a valid complete-file byte budget",
);
const output = await open(path, "wx", 0o600);
const digest = createHash("sha256");
let bytes = 0;
@@ -129,7 +152,11 @@ export const writeJavaScriptAnalysisTransfer = async (
batchBytes = 0;
};
const append = async (input: unknown): Promise<void> => {
const { line, size, kind } = encodeTransferRecord(input, bytes);
const { line, size, kind } = encodeTransferRecord(
input,
bytes,
transferBytes,
);
bytes += size;
records += 1;
if (
@@ -1,7 +1,10 @@
import { z } from "zod";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { digestSchema } from "../../domain/digests.js";
import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS } from "../../domain/javascript/javascriptAnalysisResourceControls.js";
import {
JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS,
JAVASCRIPT_SEMANTIC_NODE_MAXIMUM,
} from "../../domain/javascript/javascriptAnalysisResourceControls.js";
import { javaScriptAnalysisTransferDescriptorSchema } from "./JavaScriptAnalysisTransfer.js";
/** Independent worker execution budget; source length is not an admission rule. */
@@ -14,7 +17,7 @@ export const JAVASCRIPT_ANALYSIS_WORKER_DEFAULTS = {
/** Caller-selected artifact identity, separate from the decoded source-text digest. */
export const javaScriptWorkerRequestSchema = z.strictObject({
id: z.uuid(),
node_budget: z.number().int().min(0).max(20_000),
node_budget: z.number().int().min(0).max(JAVASCRIPT_SEMANTIC_NODE_MAXIMUM),
text_sha256: digestSchema,
file: z.strictObject({
path: z.string(),
@@ -23,13 +23,19 @@ const findingContext = {
module_runtime: z.string().optional(),
location: range,
};
const withRuntime = <Value extends { module_runtime?: string | undefined }>(
const withRuntime = <
Value extends {
module_runtime?: string | undefined;
resolution_basis?: "wrapper-substitution" | undefined;
},
>(
value: Value,
) => {
const { module_runtime, ...rest } = value;
const { module_runtime, resolution_basis, ...rest } = value;
return {
...rest,
...(module_runtime === undefined ? {} : { module_runtime }),
...(resolution_basis === undefined ? {} : { resolution_basis }),
};
};
const staticValue = z.discriminatedUnion("status", [
@@ -62,12 +68,14 @@ const ipcChannel = <Shape extends z.ZodRawShape>(shape: Shape) =>
z.union([
z.strictObject({
...findingContext,
resolution_basis: z.literal("wrapper-substitution").optional(),
...shape,
channel: z.string(),
channel_expression: z.null(),
}),
z.strictObject({
...findingContext,
resolution_basis: z.literal("wrapper-substitution").optional(),
...shape,
channel: z.null(),
channel_expression: z.string(),
@@ -113,10 +121,11 @@ const ipc = z
}),
])
.transform((value) => {
const { module_runtime, ...rest } = value;
const { module_runtime, resolution_basis, ...rest } = value;
return {
...rest,
...(module_runtime === undefined ? {} : { module_runtime }),
...(resolution_basis === undefined ? {} : { resolution_basis }),
};
});
const windowShape = {
@@ -128,6 +137,7 @@ const windowShape = {
),
};
const bridgeShape = {
resolution_basis: z.literal("wrapper-substitution").optional(),
...findingContext,
world: z.enum(["main", "isolated"]),
world_id: staticValue.nullable(),
@@ -136,99 +146,134 @@ const bridgeShape = {
unknown_members: count,
};
const utilityShape = { ...findingContext, service_name: z.string().nullable() };
const electron = z.strictObject({
browser_windows: z.array(
z.union([
z
.strictObject({
...windowShape,
preload_path: z.string(),
preload_resolution_context: pathContext,
})
.transform(withRuntime),
z
.strictObject({
...windowShape,
preload_path: z.null(),
preload_resolution_context: z.null(),
})
.transform(withRuntime),
]),
),
context_bridge_apis: z.array(
z.union([
z
.strictObject({
...bridgeShape,
api_key: z.string(),
api_key_expression: z.null(),
})
.transform(withRuntime),
z
.strictObject({
...bridgeShape,
api_key: z.null(),
api_key_expression: z.string(),
})
.transform(withRuntime),
z
.strictObject({
...bridgeShape,
api_key: z.null(),
api_key_expression: z.null(),
})
.transform(withRuntime),
]),
),
ipc: z.array(ipc),
sender_validations: z.array(
z.strictObject({
...findingContext,
subject: z.enum([
"sender-url",
"sender-origin",
"sender-frame",
"sender-id",
"frame-id",
"process-id",
const electron = z
.strictObject({
call_paths: z
.array(
z.strictObject({
entry_kind: z.enum(["bridge-member", "ipc-handler"]),
entry: z.string(),
channel: z.string().nullable(),
terminal: z.string(),
resolution: z.literal("candidate"),
steps: z.array(
z.strictObject({
kind: z.enum([
"exposure",
"factory",
"function",
"call",
"ipc",
"terminal",
]),
label: z.string(),
location: range,
module_key: z.string().nullable(),
module_runtime: z.string().nullable(),
}),
),
conditions: z.array(
z.strictObject({ expression: z.string(), location: range }),
),
}),
)
.optional(),
browser_windows: z.array(
z.union([
z
.strictObject({
...windowShape,
preload_path: z.string(),
preload_resolution_context: pathContext,
})
.transform(withRuntime),
z
.strictObject({
...windowShape,
preload_path: z.null(),
preload_resolution_context: z.null(),
})
.transform(withRuntime),
]),
mechanism: z.string(),
expected: staticValue,
enforcement: z.literal("unknown"),
}),
),
utility_processes: z.array(
z.union([
z
.strictObject({
...utilityShape,
module_path: z.string(),
module_resolution_context: pathContext,
module_expression: z.null(),
})
.transform(withRuntime),
z
.strictObject({
...utilityShape,
module_path: z.null(),
module_resolution_context: z.null(),
module_expression: z.string(),
})
.transform(withRuntime),
]),
),
native_addon_bindings: z.array(
z.strictObject({
...findingContext,
specifier: z.string(),
binding_kind: z.enum(["import", "require", "re-export"]),
module_kind: z.enum(["import", "require"]),
members: strings,
namespace_access: z.boolean().optional(),
dynamic_member_access: z.boolean().optional(),
}),
),
});
),
context_bridge_apis: z.array(
z.union([
z
.strictObject({
...bridgeShape,
api_key: z.string(),
api_key_expression: z.null(),
})
.transform(withRuntime),
z
.strictObject({
...bridgeShape,
api_key: z.null(),
api_key_expression: z.string(),
})
.transform(withRuntime),
z
.strictObject({
...bridgeShape,
api_key: z.null(),
api_key_expression: z.null(),
})
.transform(withRuntime),
]),
),
ipc: z.array(ipc),
sender_validations: z.array(
z.strictObject({
...findingContext,
subject: z.enum([
"sender-url",
"sender-origin",
"sender-frame",
"sender-id",
"frame-id",
"process-id",
]),
mechanism: z.string(),
expected: staticValue,
enforcement: z.literal("unknown"),
}),
),
utility_processes: z.array(
z.union([
z
.strictObject({
...utilityShape,
module_path: z.string(),
module_resolution_context: pathContext,
module_expression: z.null(),
})
.transform(withRuntime),
z
.strictObject({
...utilityShape,
module_path: z.null(),
module_resolution_context: z.null(),
module_expression: z.string(),
})
.transform(withRuntime),
]),
),
native_addon_bindings: z.array(
z.strictObject({
...findingContext,
specifier: z.string(),
binding_kind: z.enum(["import", "require", "re-export"]),
module_kind: z.enum(["import", "require"]),
members: strings,
namespace_access: z.boolean().optional(),
dynamic_member_access: z.boolean().optional(),
}),
),
})
.transform(({ call_paths, ...rest }) => ({
...rest,
...(call_paths === undefined ? {} : { call_paths }),
}));
const referenceShape = {
...findingContext,
kind: z.enum([
@@ -9,7 +9,10 @@ import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptA
import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptModuleSemanticIr } from "../../domain/javascript/javascriptModuleSemanticIr.js";
import type { JavaScriptSemanticFileProjection } from "../../domain/javascript/javascriptSemanticFileProjection.js";
import { writeJavaScriptAnalysisTransfer } from "./JavaScriptAnalysisTransfer.js";
import {
javaScriptTransferByteBudget,
writeJavaScriptAnalysisTransfer,
} from "./JavaScriptAnalysisTransfer.js";
import { javaScriptWorkerStaticAnalysisSchema } from "./JavaScriptAnalysisWorkerSchemas.js";
import {
JAVASCRIPT_ANALYSIS_WORKER_DEFAULTS as DEFAULTS,
@@ -95,6 +98,7 @@ const analyzeJob = async (
requests: AsyncIterator<string>,
): Promise<void> => {
try {
let resultBudgetBytes = 0;
const source = await readFile(join(root, `${request.id}.source`), "utf8");
if (
createHash("sha256").update(source).digest("hex") !== request.text_sha256
@@ -148,7 +152,8 @@ const analyzeJob = async (
throw new TypeError(
"JavaScript worker admission names a different source job",
);
return admission.semantic_result_budget_bytes;
resultBudgetBytes = admission.semantic_result_budget_bytes;
return resultBudgetBytes;
},
},
);
@@ -160,6 +165,7 @@ const analyzeJob = async (
join(root, `${request.id}.records`),
result.module,
result.projection,
javaScriptTransferByteBudget(resultBudgetBytes),
);
await send({ kind: "complete", id: request.id, descriptor });
} catch (cause: unknown) {
@@ -0,0 +1,200 @@
import { expect } from "vitest";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { applicationFeatureTraceResultSchema } from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js";
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
const analyze = async (cli: TestCli, extra = "") => {
const root = await createTestTempDirectory("rea-electron-wrapped-paths-");
await writeFixtureFiles(root, {
"preload.js": `const e=require("electron");
const sender=channel=>(...args)=>e.ipcRenderer.send(channel,...args);
e.contextBridge.exposeInMainWorld("desktop",{copy:sender("test:copy"),cut:sender("test:cut")});`,
"main.js": `const e=require("electron");
function register(channel,handler){e.ipcMain.addListener(channel,event=>allowed(event)&&handler(event))}
register("test:copy",event=>event.sender.copy());
register("test:cut",event=>event.sender.cut());${extra}`,
});
const output = await cli.run({
arguments: ["analyze-javascript-application", root, "--json"],
});
expect(output.exitCode).toBe(0);
const { normalized_result: result } = z
.object({
normalized_result: javascriptApplicationAnalysisResultSchema,
})
.parse(output.json);
return { root, application: output.json, ...result };
};
cliTest(
"traces a wrapped bridge member through a unique IPC channel using the public CLI",
async ({ cli }) => {
const { root, application, graph } = await analyze(cli);
const byId = new Map(graph.nodes.map((n) => [n.node_id, n]));
const joins = graph.edges.filter(
(e) => e.evidence.extractor.operation === "join-electron-wrapper-paths",
);
expect(joins).toHaveLength(2);
for (const edge of joins) {
expect(edge.evidence).toMatchObject({
state: "inferred",
authority: "static-relationship-inference",
});
expect(edge.properties.pairing_basis).toBe(
"unique-exact-literal-channel",
);
expect(
byId.get(edge.source_node_id)?.observations[0]?.properties.channel,
).toBe(edge.properties.channel);
expect(
byId.get(edge.target_node_id)?.observations[0]?.properties.channel,
).toBe(edge.properties.channel);
expect(edge.properties.conditions).toEqual([
expect.objectContaining({ expression: "allowed(event)" }),
]);
}
const start = graph.nodes.find((n) =>
n.observations.some(
(o) =>
o.properties.entry === "desktop.copy" &&
o.properties.step_kind === "exposure",
),
);
expect(start).toBeDefined();
const visited = new Set<string>(),
pending = start ? [start.node_id] : [];
while (pending.length) {
const id = pending.pop();
if (!id || visited.has(id)) continue;
visited.add(id);
for (const edge of graph.edges)
if (edge.source_node_id === id) pending.push(edge.target_node_id);
}
expect(
[...visited].some((id) =>
byId
.get(id)
?.observations.some((o) => o.label === "ipc-event.sender.copy"),
),
).toBe(true);
expect(
[...visited].some((id) =>
byId
.get(id)
?.observations.some((o) => o.label === "ipc-event.sender.cut"),
),
).toBe(false);
const input = join(root, "trace-input.json");
await writeFile(
input,
JSON.stringify({
application,
seed: { kind: "api", value: "desktop.copy", match: "exact" },
direction: "outgoing",
}),
);
const traced = await cli.run({
arguments: ["trace-application-feature", input, "--json"],
});
expect(traced.exitCode).toBe(0);
const { normalized_result: trace } = z
.object({
normalized_result: applicationFeatureTraceResultSchema,
})
.parse(traced.json);
expect(trace.seed_matches).toHaveLength(1);
expect(
trace.paths.some(
(path) =>
path.contains_inference &&
path.node_ids.length > 8 &&
byId
.get(path.end_node_id)
?.observations.some((o) => o.label === "ipc-event.sender.copy"),
),
).toBe(true);
},
);
for (const surface of ["CLI", "stdio MCP"] as const) {
cliTest(
`preserves handler ambiguity and short-circuit factory paths through ${surface}`,
async ({ cli }) => {
const root = await createTestTempDirectory("rea-electron-branch-paths-");
await writeFixtureFiles(root, {
"preload.js": `const e=require("electron");
function choose(){
if(flag)return ()=>e.ipcRenderer.send("test:copy");
else return ()=>e.ipcRenderer.send("test:cut");
return ()=>e.ipcRenderer.send("test:dead");
}
e.contextBridge.exposeInMainWorld("desktop", {
choose:choose(),
ambiguous:()=>e.ipcRenderer.send("test:ambiguous"),
competing:()=>e.ipcRenderer.send("test:competing"),
fallback:()=>e.ipcRenderer.send("test:fallback"),
});`,
"main.js": `const e=require("electron");
const copy=event=>event.sender.copy(), cut=event=>event.sender.cut();
function register(channel,handler){e.ipcMain.on(channel,handler)}
register("test:copy",copy);register("test:cut",cut);
register("test:ambiguous",flag?copy:cut);
register("test:competing",copy);
register("test:competing",flag?copy:unknownHandler);
register("test:fallback",event=>{false ?? event.sender.copy()});`,
});
const { graph } = await analyzeJavaScriptDirectory(surface, cli, root);
const joins = graph.edges.filter(
(edge) =>
edge.evidence.extractor.operation === "join-electron-wrapper-paths",
);
expect(joins.map((edge) => edge.properties.channel).sort()).toEqual([
"test:copy",
"test:cut",
]);
const steps = graph.nodes
.flatMap((node) => node.observations)
.filter(
(observation) => observation.properties.step_kind === "terminal",
);
expect(
[
...new Set(
steps
.filter((step) => step.properties.entry === "desktop.choose")
.map((step) => step.properties.channel),
),
].sort(),
).toEqual(["test:copy", "test:cut"]);
expect(
steps.some(
(step) =>
step.properties.entry_kind === "ipc-handler" &&
["test:ambiguous", "test:fallback"].includes(
String(step.properties.channel),
),
),
).toBe(false);
},
);
}
cliTest(
"does not join a channel with another untraced compatible handler",
async ({ cli }) => {
const { graph } = await analyze(
cli,
'e.ipcMain.on("test:copy",event=>unresolved(event));',
);
const joins = graph.edges.filter(
(e) => e.evidence.extractor.operation === "join-electron-wrapper-paths",
);
expect(joins.map((e) => e.properties.channel)).toEqual(["test:cut"]);
},
);
@@ -137,3 +137,67 @@ cliTest(
expect(output.json).toEqual({});
},
);
cliTest(
"honors caller-selected semantic node limits across files and records them in Evidence",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-js-node-budgets-");
await writeFile(join(root, "first.js"), "external;\n".repeat(40));
await writeFile(join(root, "second.js"), "another;\n".repeat(40));
const output = await cli.run({
arguments: [
"analyze-javascript-application",
root,
"--format",
"json",
"--max-semantic-nodes",
"50",
"--max-semantic-nodes-per-file",
"30",
],
});
expect(output.exitCode).toBe(0);
const result = z
.object({
parameters: z.object({
max_semantic_nodes: z.number(),
max_semantic_nodes_per_file: z.number(),
}),
normalized_result: z.object({
semantic_graph: z.object({
nodes: z.array(
z.object({ identity: z.object({ module_path: z.string() }) }),
),
coverage: z.object({
truncated: z.boolean(),
omitted_nodes: z.number().nullable(),
limits: z.array(
z.object({ name: z.string(), value: z.number() }),
),
}),
}),
}),
})
.parse(output.json);
expect(result.parameters).toEqual({
max_semantic_nodes: 50,
max_semantic_nodes_per_file: 30,
});
const graph = result.normalized_result.semantic_graph;
expect(graph.nodes).toHaveLength(50);
expect(
graph.nodes.filter((node) => node.identity.module_path === "first.js"),
).toHaveLength(30);
expect(
graph.nodes.filter((node) => node.identity.module_path === "second.js"),
).toHaveLength(20);
expect(graph.coverage.truncated).toBe(true);
expect(graph.coverage.omitted_nodes).toBeNull();
expect(graph.coverage.limits).toEqual(
expect.arrayContaining([
{ name: "semantic_graph_node_ceiling", value: 50 },
{ name: "semantic_graph_file_node_ceiling", value: 30 },
]),
);
},
);
@@ -0,0 +1,78 @@
import { createHash } from "node:crypto";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { analyzeJavaScriptSemantics } from "../../../src/domain/javascript/javascriptSemanticAnalysis.js";
import { projectJavaScriptModuleSemantics } from "../../../src/domain/javascript/javascriptModuleSemanticIr.js";
import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js";
import { completeJavaScriptAnalysisSteps } from "../../../src/application/javascript/JavaScriptAnalysisControl.js";
import { projectJavaScriptSemanticFileSteps } from "../../../src/application/javascript/JavaScriptSemanticGraphBuilder.js";
import {
javaScriptTransferByteBudget,
writeJavaScriptAnalysisTransfer,
readJavaScriptAnalysisTransfer,
} from "../../../src/javascript/analysis/JavaScriptAnalysisTransfer.js";
it("allows larger streamed transfers only with corresponding parent capacity", () => {
const mib = 1024 * 1024;
expect(javaScriptTransferByteBudget(0)).toBe(512 * mib);
expect(javaScriptTransferByteBudget(4096 * mib)).toBe(512 * mib);
expect(javaScriptTransferByteBudget(12288 * mib)).toBe(1536 * mib);
expect(javaScriptTransferByteBudget(32768 * mib)).toBe(2048 * mib);
});
it("enforces the offered complete-file byte budget while preserving authenticated round trips", async () => {
const root = await createTestTempDirectory("rea-js-transfer-budget-");
const text = "export function add(value) { return value + 1; }";
const file: JavaScriptArtifactFile = {
path: "app.mjs",
sha256: createHash("sha256").update(text).digest("hex"),
container_sha256: "a".repeat(64),
bytes: Buffer.byteLength(text),
inventory_artifact_id: "fixture-source",
kind: "javascript",
unpacked: false,
text: { included: true, value: text },
};
const ir = analyzeJavaScriptSemantics(text);
const module = projectJavaScriptModuleSemantics(ir);
const projection = await completeJavaScriptAnalysisSteps(
projectJavaScriptSemanticFileSteps(file, ir, 100),
);
const observed = await writeJavaScriptAnalysisTransfer(
join(root, "observed"),
module,
projection,
);
const exactPath = join(root, "exact");
const exact = await writeJavaScriptAnalysisTransfer(
exactPath,
module,
projection,
observed.bytes,
);
expect(exact).toEqual(observed);
expect(await readJavaScriptAnalysisTransfer(exactPath, exact, file)).toEqual({
module,
projection,
});
await expect(
writeJavaScriptAnalysisTransfer(
join(root, "short"),
module,
projection,
observed.bytes - 1,
),
).rejects.toMatchObject({
resource: "transport",
reportedLimits: { transfer_file_bytes: observed.bytes - 1 },
});
await expect(
writeJavaScriptAnalysisTransfer(
join(root, "oversized"),
module,
projection,
2 ** 31 + 1,
),
).rejects.toBeInstanceOf(RangeError);
});