Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions src/domain/javascript/electronStaticAnalysisBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
23 changes: 9 additions & 14 deletions src/domain/javascript/javascriptSemanticChildProcesses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions src/domain/javascript/javascriptSemanticExpressionShapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -181,6 +182,7 @@ it.each([
index.receivers.rpcClientRoots,
index,
),
expressionShapes: createSemanticExpressionShapeResolver(index.bindings),
};
traverseJavaScriptAst(file.program, {
enter(node) {
Expand Down
2 changes: 2 additions & 0 deletions src/domain/javascript/javascriptStaticAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/domain/javascript/javascriptStaticAnalysisState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
Expand All @@ -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<ReturnType<typeof reconstructJavaScriptArtifact>>;

const windowPreloadNodes = (result: Reconstruction) =>
Expand Down
Loading