diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index eb37c6a2b4..b721d829dc 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -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. diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index a718f6ed0f..54e8b9bd5b 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -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)}`; diff --git a/src/application/javascript/ElectronBoundaryGraph.ts b/src/application/javascript/ElectronBoundaryGraph.ts index 740c7f0011..d77e496fbe 100644 --- a/src/application/javascript/ElectronBoundaryGraph.ts +++ b/src/application/javascript/ElectronBoundaryGraph.ts @@ -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); }; diff --git a/src/application/javascript/ElectronBoundaryGraphCallPaths.ts b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts new file mode 100644 index 0000000000..ef9d53750e --- /dev/null +++ b/src/application/javascript/ElectronBoundaryGraphCallPaths.ts @@ -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), + ); diff --git a/src/application/javascript/ElectronBoundaryGraphContext.ts b/src/application/javascript/ElectronBoundaryGraphContext.ts index cc96f92799..a02b5f0161 100644 --- a/src/application/javascript/ElectronBoundaryGraphContext.ts +++ b/src/application/javascript/ElectronBoundaryGraphContext.ts @@ -27,6 +27,7 @@ export interface ElectronGraphEdgeInput { | "exposes" | "sends" | "invokes" + | "calls" | "handles"; readonly operation: string; readonly properties: Readonly>; diff --git a/src/application/javascript/ElectronBoundaryGraphIpc.ts b/src/application/javascript/ElectronBoundaryGraphIpc.ts index d38ba48ae7..f96da041a8 100644 --- a/src/application/javascript/ElectronBoundaryGraphIpc.ts +++ b/src/application/javascript/ElectronBoundaryGraphIpc.ts @@ -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, diff --git a/src/application/javascript/ElectronBoundaryGraphWindows.ts b/src/application/javascript/ElectronBoundaryGraphWindows.ts index f8d192db6e..63f62bd12c 100644 --- a/src/application/javascript/ElectronBoundaryGraphWindows.ts +++ b/src/application/javascript/ElectronBoundaryGraphWindows.ts @@ -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, diff --git a/src/application/javascript/JavaScriptApplicationEvidence.ts b/src/application/javascript/JavaScriptApplicationEvidence.ts index aded8f2ddc..b812f237eb 100644 --- a/src/application/javascript/JavaScriptApplicationEvidence.ts +++ b/src/application/javascript/JavaScriptApplicationEvidence.ts @@ -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 }), diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index c7da5df880..ea56ce5910 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -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 }), diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 3b6ebaa13e..bd95415c65 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -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: diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 590f4950b4..4816be96cb 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -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", + ); +}); diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index 9bbfe8e157..6a7d0920a0 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -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; 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 { - 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 = ( - 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(); - const unknownIdsByFamily = new Map(); - 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 { + 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 = ( + 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(); + const unknownIdsByFamily = new Map(); + 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 { 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 { 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(); diff --git a/src/application/javascript/JavaScriptSemanticGraphElectronProjection.test.ts b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.test.ts new file mode 100644 index 0000000000..07a8656daf --- /dev/null +++ b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.test.ts @@ -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); +}); diff --git a/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts new file mode 100644 index 0000000000..53b5bdce66 --- /dev/null +++ b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.ts @@ -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 { + for (const path of paths) { + yield; + const pathId = sha256Text(JSON.stringify(path)); + let previous: ReturnType = 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; + } + } +} diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index b3004720c4..3f558930b8 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -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 }), diff --git a/src/cliObservationOptions.ts b/src/cliObservationOptions.ts index 52fa94eb63..e7db14e0e4 100644 --- a/src/cliObservationOptions.ts +++ b/src/cliObservationOptions.ts @@ -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( diff --git a/src/composition/javascriptAnalysisWorkerProcess.ts b/src/composition/javascriptAnalysisWorkerProcess.ts index 3b94f46771..6f3d5bfec2 100644 --- a/src/composition/javascriptAnalysisWorkerProcess.ts +++ b/src/composition/javascriptAnalysisWorkerProcess.ts @@ -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 }; }; diff --git a/src/domain/javascript/electronStaticAnalysisIpc.ts b/src/domain/javascript/electronStaticAnalysisIpc.ts index 0b43f0727b..5cebf0cc92 100644 --- a/src/domain/javascript/electronStaticAnalysisIpc.ts +++ b/src/domain/javascript/electronStaticAnalysisIpc.ts @@ -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": diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index 6a4bbf0bff..8877a6b476 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -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; + }[]; } diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts new file mode 100644 index 0000000000..e85f28b6f1 --- /dev/null +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -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([]); + } + }); +}); diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts new file mode 100644 index 0000000000..7868996713 --- /dev/null +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -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; +} + +/** 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(); + 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; +}; diff --git a/src/domain/javascript/javascriptAnalysisResourceControls.test.ts b/src/domain/javascript/javascriptAnalysisResourceControls.test.ts new file mode 100644 index 0000000000..e5f98bda3d --- /dev/null +++ b/src/domain/javascript/javascriptAnalysisResourceControls.test.ts @@ -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); + } +}); diff --git a/src/domain/javascript/javascriptAnalysisResourceControls.ts b/src/domain/javascript/javascriptAnalysisResourceControls.ts index a457dc2e95..c627ad880b 100644 --- a/src/domain/javascript/javascriptAnalysisResourceControls.ts +++ b/src/domain/javascript/javascriptAnalysisResourceControls.ts @@ -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> | 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() diff --git a/src/domain/javascript/javascriptApplicationGraphSchemas.ts b/src/domain/javascript/javascriptApplicationGraphSchemas.ts index 17e19fb108..90af9ee439 100644 --- a/src/domain/javascript/javascriptApplicationGraphSchemas.ts +++ b/src/domain/javascript/javascriptApplicationGraphSchemas.ts @@ -24,6 +24,7 @@ export const JAVASCRIPT_APPLICATION_NODE_KINDS = [ "javascript-asset", "javascript-chunk", "javascript-module", + "javascript-expression", "source-map", "source-module", "browser-window", diff --git a/src/domain/javascript/javascriptApplicationProjectionResources.ts b/src/domain/javascript/javascriptApplicationProjectionResources.ts index 0e5ba7570d..3e5280c9e8 100644 --- a/src/domain/javascript/javascriptApplicationProjectionResources.ts +++ b/src/domain/javascript/javascriptApplicationProjectionResources.ts @@ -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; }; diff --git a/src/domain/javascript/javascriptBootstrap.ts b/src/domain/javascript/javascriptBootstrap.ts new file mode 100644 index 0000000000..065d54eb83 --- /dev/null +++ b/src/domain/javascript/javascriptBootstrap.ts @@ -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(); + 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; +}; diff --git a/src/domain/javascript/javascriptFeatureSeed.ts b/src/domain/javascript/javascriptFeatureSeed.ts index 5342850685..929733f002 100644 --- a/src/domain/javascript/javascriptFeatureSeed.ts +++ b/src/domain/javascript/javascriptFeatureSeed.ts @@ -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( diff --git a/src/domain/javascript/javascriptFeatureTrace.ts b/src/domain/javascript/javascriptFeatureTrace.ts index e535080a93..47333bab60 100644 --- a/src/domain/javascript/javascriptFeatureTrace.ts +++ b/src/domain/javascript/javascriptFeatureTrace.ts @@ -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[], diff --git a/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts index ecd3e99a6f..ed98d2626f 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts @@ -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( diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 517ed58364..f8e09d5831 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -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 { + const state = createState(program); + yield* collectDefinitionsSteps(program, state); + return state; +} + /** Recover semantics from an already parsed JavaScript artifact. */ export const analyzeParsedJavaScriptSemantics = ( file: ParsedJavaScriptSource, diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 35f3a0ca9c..e3f3a29422 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -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( diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 50af5c7659..53b1618b2c 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -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); diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index 4e141b58e3..fd33ff006e 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -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, diff --git a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts index ddee2ce32d..589af7f3cf 100644 --- a/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisModuleIndex.test.ts @@ -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(); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 44f6f31846..3c93d2fa3d 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -10,6 +10,7 @@ import type { JavaScriptStaticStorage, } from "./javascriptStaticAnalysisTypes.js"; import type { + ElectronCallPath, ElectronBrowserWindowFinding, ElectronContextBridgeFinding, ElectronIpcFinding, @@ -36,6 +37,8 @@ export interface LocatedJavaScriptFinding { /** Mutable collections used during one inert AST traversal. */ export interface JavaScriptAnalysisAccumulator { + electronCallPaths?: readonly ElectronCallPath[]; + electronCallPathLimitReached?: boolean; readonly references: LocatedJavaScriptFinding[]; readonly endpoints: LocatedJavaScriptFinding[]; readonly storage: LocatedJavaScriptFinding[]; diff --git a/src/domain/javascript/javascriptSymbolicBindings.test.ts b/src/domain/javascript/javascriptSymbolicBindings.test.ts new file mode 100644 index 0000000000..a083d3a04f --- /dev/null +++ b/src/domain/javascript/javascriptSymbolicBindings.test.ts @@ -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); +}); diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts new file mode 100644 index 0000000000..94e6931ecc --- /dev/null +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -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(); + readonly #environments = new WeakMap(); + readonly #cache = new WeakMap< + SymbolicEnvironment, + WeakMap + >(); + readonly #active = new Set(); + readonly #activeFunctions = new Set(); + readonly #exportAliases = new Map(); + readonly #moduleBindings = new Set(); + readonly #replacements = new Map(); + readonly #propertyActive = new WeakMap>(); + readonly #mutated = new Set(); + readonly #namespaceOwners = new Map(); + readonly #unsafeExports = new Set(); + #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(); + 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(); + 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(), + ): 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(), + ): 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(); + 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(); + for (const property of bootstrap.table.properties) { + const factory = moduleFactory(property); + if (!factory) continue; + const values = new Map(); + 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); + } +} diff --git a/src/domain/javascript/javascriptSymbolicControlFlow.ts b/src/domain/javascript/javascriptSymbolicControlFlow.ts new file mode 100644 index 0000000000..79bccd0a42 --- /dev/null +++ b/src/domain/javascript/javascriptSymbolicControlFlow.ts @@ -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) + ); +}; diff --git a/src/domain/javascript/javascriptSymbolicValues.ts b/src/domain/javascript/javascriptSymbolicValues.ts new file mode 100644 index 0000000000..e88cc8d325 --- /dev/null +++ b/src/domain/javascript/javascriptSymbolicValues.ts @@ -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; + 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; + } + | { readonly kind: "array"; readonly values: readonly SymbolicValue[] } + | { + readonly kind: "record"; + readonly values: ReadonlyMap; + } + | { 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; diff --git a/src/javascript/analysis/JavaScriptAnalysisTransfer.ts b/src/javascript/analysis/JavaScriptAnalysisTransfer.ts index 75a3f1692e..43e20486a6 100644 --- a/src/javascript/analysis/JavaScriptAnalysisTransfer.ts +++ b/src/javascript/analysis/JavaScriptAnalysisTransfer.ts @@ -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 => { + 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 => { - const { line, size, kind } = encodeTransferRecord(input, bytes); + const { line, size, kind } = encodeTransferRecord( + input, + bytes, + transferBytes, + ); bytes += size; records += 1; if ( diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts index 110dfe3b70..0cf42176e8 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts @@ -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(), diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 92bfaec352..cba1954e39 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -23,13 +23,19 @@ const findingContext = { module_runtime: z.string().optional(), location: range, }; -const withRuntime = ( +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: 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([ diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts index 25ab14e64a..0e9f15b59a 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts @@ -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, ): Promise => { 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) { diff --git a/tests/boundary/cli/electronWrappedCallPaths.test.ts b/tests/boundary/cli/electronWrappedCallPaths.test.ts new file mode 100644 index 0000000000..6e40c142e8 --- /dev/null +++ b/tests/boundary/cli/electronWrappedCallPaths.test.ts @@ -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(), + 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"]); + }, +); diff --git a/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts b/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts index 0c694999e4..a70eb463ba 100644 --- a/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts +++ b/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts @@ -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 }, + ]), + ); + }, +); diff --git a/tests/boundary/javascript/javascriptAnalysisTransferBudgets.test.ts b/tests/boundary/javascript/javascriptAnalysisTransferBudgets.test.ts new file mode 100644 index 0000000000..864df3ff2e --- /dev/null +++ b/tests/boundary/javascript/javascriptAnalysisTransferBudgets.test.ts @@ -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); +});