From 73b756430f933da3d86d181c3b6e470d7acca6ad Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 22 Oct 2026 01:17:40 -0700 Subject: [PATCH] fix(javascript): read utilityProcess.fork options from the argv slot (#6280) Electron's utilityProcess.fork(modulePath[, args][, options]) treats a non-array object in the second argument as the options and ignores the third. The static extractor only read the third argument, so `fork("./worker.js", { serviceName })` lost the declared name while reporting exact, complete coverage, and an object in the second slot let an ignored third argument supply the name. Move the optional-argv options selection that child_process already used into the shared expression-shape owner and reuse it for utility processes. Unproven argv shapes, spreads and identifier options remain unknown with partial coverage; nullish options or a nullish serviceName declare no name, matching Electron's runtime checks. Fixes #6208 Co-authored-by: Claude Opus 5.5 --- .../electronStaticAnalysisBrowser.ts | 27 +++++-- .../javascriptSemanticChildProcesses.ts | 23 +++--- .../javascriptSemanticExpressionShapes.ts | 28 +++++++ ...ascriptStaticAnalysis.mapEndpoints.test.ts | 2 + .../javascript/javascriptStaticAnalysis.ts | 2 + .../javascriptStaticAnalysisState.ts | 3 + .../electronOptionKeys.test.ts | 77 +++++++++++++++---- 7 files changed, 126 insertions(+), 36 deletions(-) diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index 6bc5de9867..93afe2902b 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -32,6 +32,7 @@ import { } from "./javascriptAstValues.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; import type { JavaScriptBindingIndex } from "./javascriptSemanticState.js"; +import { optionsAfterOptionalArgv } from "./javascriptSemanticExpressionShapes.js"; import { hasElectronOptionObjectCandidate, resolveElectronOptionObject, @@ -316,13 +317,27 @@ const inspectUtilityProcess = ( moduleNode === undefined ? undefined : staticPath(moduleNode, context.nodePathResolve); - const options = argumentNode(node.arguments[2]); - const serviceNameProperty = staticObjectProperty(options, "serviceName"); - const serviceName = - serviceNameProperty.status === "explicit" - ? staticPath(serviceNameProperty.property.value, context.nodePathResolve) + // Electron treats a non-array object in the argv slot as the options. + const shapes = context.expressionShapes; + const options = optionsAfterOptionalArgv(node.arguments, shapes); + const serviceNameProperty = + options === undefined || shapes.isNullish(options) + ? { status: "missing" as const } + : staticObjectProperty(options, "serviceName", shapes.isNullish); + // A nullish serviceName selects Electron's default display name. + const declaredName = + serviceNameProperty.status === "explicit" && + !shapes.isNullish(serviceNameProperty.property.value) + ? serviceNameProperty.property.value : undefined; - if (serviceNameProperty.status !== "missing" && serviceName === undefined) + const serviceName = + declaredName === undefined + ? undefined + : staticPath(declaredName, context.nodePathResolve); + if ( + serviceNameProperty.status === "dynamic" || + (declaredName !== undefined && serviceName === undefined) + ) context.accumulator.unknownFindings += 1; if (modulePath === undefined) context.accumulator.unknownFindings += 1; const moduleReference = diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 3140e08bf1..b2ad8bbe92 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -7,7 +7,11 @@ import type { JavaScriptSemanticChildProcessSpawn, } from "./javascriptSemanticIr.js"; import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; -import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; +import { + createSemanticExpressionShapeResolver, + optionsAfterOptionalArgv, + type JavaScriptExpressionShapeResolver, +} from "./javascriptSemanticExpressionShapes.js"; import { semanticStaticPropertyName, staticObjectProperty, @@ -41,9 +45,7 @@ interface ChildProcessAnalysis { readonly childProcessInteractions: readonly JavaScriptSemanticChildProcessInteraction[]; } -type ChildSpawnContext = ReturnType< - typeof createSemanticExpressionShapeResolver ->; +type ChildSpawnContext = JavaScriptExpressionShapeResolver; interface SpawnCandidate { readonly node: t.CallExpression | t.OptionalCallExpression; @@ -381,22 +383,15 @@ const spawnArguments = ( const args = candidate.node.arguments; if (t.isSpreadElement(args[0])) return { options: args[0], argvCount: null }; const second = args[1] && unwrapJavaScriptExpression(args[1]).node; - const third = args[2] && unwrapJavaScriptExpression(args[2]).node; - const expressionShape = context; const argv = candidate.method !== "exec" && t.isArrayExpression(second) ? second : undefined; - // Only a proven argv array or nullish placeholder admits third-slot options. - // Otherwise the second argument may itself be options, ignoring the third. + // exec(command[, options][, callback]) has no argv slot. const options = - candidate.method === "exec" || second === undefined + candidate.method === "exec" ? second - : argv !== undefined || - expressionShape.isArray(second) || - expressionShape.isNullish(second) - ? third - : second; + : optionsAfterOptionalArgv(args, context); return { options, argvCount: diff --git a/src/domain/javascript/javascriptSemanticExpressionShapes.ts b/src/domain/javascript/javascriptSemanticExpressionShapes.ts index b82ca9f397..3cc73ac0e9 100644 --- a/src/domain/javascript/javascriptSemanticExpressionShapes.ts +++ b/src/domain/javascript/javascriptSemanticExpressionShapes.ts @@ -24,6 +24,11 @@ import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { javaScriptRuntimeContext } from "./javascriptSourceParser.js"; type Shape = "array" | "callback" | "unknown"; + +/** Bounded argument-shape queries shared by call-signature owners. */ +export type JavaScriptExpressionShapeResolver = ReturnType< + typeof createSemanticExpressionShapeResolver +>; const commonJsWrapperParameterNames = new Set([ "exports", "require", @@ -137,3 +142,26 @@ export const createSemanticExpressionShapeResolver = ( }, }; }; + +/** + * Select the options argument of a `(target[, argv][, options])` signature, + * such as Node child_process spawn/execFile/fork or Electron + * utilityProcess.fork. Only a proven argv array or nullish placeholder admits + * third-slot options; any other second argument may itself be the options + * object, which makes the runtime ignore the third. Spread arguments are + * returned unchanged so callers keep their unknown positions unknown. The + * returned node is unwrapped; `undefined` means no options were supplied. + */ +export const optionsAfterOptionalArgv = ( + args: readonly t.CallExpression["arguments"][number][], + shapes: JavaScriptExpressionShapeResolver, +): t.Node | undefined => { + const [first, second, third] = args; + if (t.isSpreadElement(first)) return first; + if (second === undefined) return undefined; + const options = + shapes.isArray(second) || shapes.isNullish(second) ? third : second; + return options === undefined + ? undefined + : unwrapJavaScriptExpression(options).node; +}; diff --git a/src/domain/javascript/javascriptStaticAnalysis.mapEndpoints.test.ts b/src/domain/javascript/javascriptStaticAnalysis.mapEndpoints.test.ts index 80739e52f4..751df70cf2 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.mapEndpoints.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.mapEndpoints.test.ts @@ -7,6 +7,7 @@ import { createJavaScriptAnalysisAccumulator } from "./javascriptStaticAnalysisS import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; +import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; import { completeSemanticSteps, traverseJavaScriptAst, @@ -181,6 +182,7 @@ it.each([ index.receivers.rpcClientRoots, index, ), + expressionShapes: createSemanticExpressionShapeResolver(index.bindings), }; traverseJavaScriptAst(file.program, { enter(node) { diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index e6c5cd79fe..c65bebbc7f 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -16,6 +16,7 @@ import { inspectElectronStaticNode } from "./electronStaticAnalysis.js"; import { hasPossibleCommonJsDelegateWrite } from "./javascriptSemanticRequireOrigin.js"; import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js"; import { createStaticNodePathResolveQuery } from "./javascriptStaticPathIntrinsics.js"; +import { createSemanticExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; import { finishElectronBrowserWindowsSteps } from "./electronStaticAnalysisBrowser.js"; import { detectVendors, @@ -109,6 +110,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( electronInvocationPath: electronIdentity.invocationPath, electronAppPathChangePossible: electronIdentity.mayChangeAppPath, rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index), + expressionShapes: createSemanticExpressionShapeResolver(bindings), }; yield* traverseStaticSourceSteps(file, findings, bindings); yield* finishElectronBrowserWindowsSteps(findings, index); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 79438ec3ed..f9943d66a6 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -2,6 +2,7 @@ import type * as t from "@babel/types"; import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js"; import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; import type { JavaScriptStaticPathIntrinsicQuery } from "./javascriptStaticAnalysisHelpers.js"; +import type { JavaScriptExpressionShapeResolver } from "./javascriptSemanticExpressionShapes.js"; import type { JavaScriptBundlerRegistration, @@ -91,6 +92,8 @@ export interface JavaScriptFindingContext { /** Node path.resolve identity established by shared callee and mutation owners. */ readonly nodePathResolve?: JavaScriptStaticPathIntrinsicQuery; readonly rpcScan: JavaScriptRpcScan; + /** Shared bounded argv/nullish argument shapes for optional-slot signatures. */ + readonly expressionShapes: JavaScriptExpressionShapeResolver; readonly source: string; readonly accumulator: JavaScriptAnalysisAccumulator; readonly vitePreloadDependencyTable: readonly (string | undefined)[] | null; diff --git a/tests/boundary/filesystem/javascript-applications/electronOptionKeys.test.ts b/tests/boundary/filesystem/javascript-applications/electronOptionKeys.test.ts index 1b1c0325a1..c979355381 100644 --- a/tests/boundary/filesystem/javascript-applications/electronOptionKeys.test.ts +++ b/tests/boundary/filesystem/javascript-applications/electronOptionKeys.test.ts @@ -110,22 +110,6 @@ describe("Electron option keys in reconstructed application graphs", () => { }, ); - it("keeps a computed serviceName unknown in the utility-process observation", async () => { - const result = await reconstructSource(` - const serviceName = "env"; - utilityProcess.fork("./worker.js", [], { [serviceName]: "wrong-name" }); - `); - const utilities = result.graph.nodes.filter( - ({ kind }) => kind === "electron-utility", - ); - expect(utilities).toHaveLength(1); - expect(utilities[0]?.observations[0]).toMatchObject({ - label: "./worker.js", - properties: { service_name: null, module_path: "./worker.js" }, - evidence: { coverage: { status: "partial" } }, - }); - }); - it.each([ ["-0", { status: "dynamic", value: null, expression: "-0" }], ["-0.0", { status: "dynamic", value: null, expression: "-0.0" }], @@ -149,6 +133,67 @@ describe("Electron option keys in reconstructed application graphs", () => { ); }); +describe("Electron utility-process options in reconstructed application graphs", () => { + // Electron's fork(modulePath[, args][, options]) treats a non-array object in + // the args slot as the options and then ignores the third argument. + it.each([ + ["second-slot options", `{ serviceName: "gpu" }`, "gpu", "complete"], + ["explicit argv", `[], { serviceName: "gpu" }`, "gpu", "complete"], + ["undefined argv", `undefined, { serviceName: "gpu" }`, "gpu", "complete"], + ["null argv", `null, { serviceName: "gpu" }`, "gpu", "complete"], + ["void argv", `void 0, { serviceName: "gpu" }`, "gpu", "complete"], + ["const argv", `argv, { serviceName: "gpu" }`, "gpu", "complete"], + ["wrapped options", `({ serviceName: "gpu" })`, "gpu", "complete"], + [ + "ignored third", + `{ serviceName: "gpu" }, { serviceName: "x" }`, + "gpu", + "complete", + ], + ["omitted options", "", null, "complete"], + ["options without name", `{ stdio: "pipe" }`, null, "complete"], + ["nullish options", `[], null`, null, "complete"], + ["nullish name", `{ serviceName: undefined }`, null, "complete"], + ["identifier options", "options", null, "partial"], + ["spread arguments", "...forkArguments", null, "partial"], + ["spread options", "[], ...forkArguments", null, "partial"], + ["unproven argv", `makeArgv(), { serviceName: "x" }`, null, "partial"], + ["computed key", `{ [key]: "x" }`, null, "partial"], + ["computed key after argv", `[], { [key]: "x" }`, null, "partial"], + [ + "shadowed undefined argv", + `undefined, { serviceName: "x" }`, + null, + "partial", + "function launch(undefined) { %s }", + ], + ] as const)( + "selects utility-process options from %s", + async (_, args, serviceName, coverage, scope: string = "%s") => { + const call = `utilityProcess.fork("./worker.js"${args === "" ? "" : `, ${args}`});`; + const result = await reconstructSource(` + const argv = ["--gpu"]; + const key = "serviceName"; + ${scope.replace("%s", call)} + `); + const utilities = result.graph.nodes.filter( + ({ kind }) => kind === "electron-utility", + ); + expect(utilities).toHaveLength(1); + // Observation order follows content identity, so select by operation. + const fork = utilities[0]?.observations.find( + ({ evidence }) => + evidence.extractor.operation === "observe-utility-process-fork", + ); + expect(fork).toMatchObject({ + label: serviceName ?? "./worker.js", + properties: { service_name: serviceName, module_path: "./worker.js" }, + evidence: { confidence: "exact", coverage: { status: coverage } }, + }); + }, + ); +}); + type Reconstruction = Awaited>; const windowPreloadNodes = (result: Reconstruction) =>