mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
d93fbda390
commit
87b9793c53
46 files changed
+3213
-355
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user