Files
rea/src/domain/javascript/electronWrappedCallPaths.ts
T
Ysopwnable 2c7583599b fix(javascript): preserve guarded factory fallthrough (#4341)
Preserve per-exit factory initialization and shared export facts.
Keep capture/caller boundaries, call-kind semantics and conditional
registration evidence consistent through CLI and MCP.

Fixes #3963
2026-10-16 13:10:49 +08:00

755 lines
26 KiB
TypeScript

import * as t from "@babel/types";
import { runtimeFunctionParameters } from "./javascriptAstValues.js";
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 { JavaScriptElectronIdentity } from "./javascriptSemanticAnalysis.js";
import type { JavaScriptAnalysisAccumulator } from "./javascriptStaticAnalysisState.js";
import type {
ElectronCallPath,
ElectronCallPathStep,
ElectronContextBridgeFinding,
ElectronIpcDescriptor,
ElectronIpcFinding,
ElectronStaticValue,
} from "./electronStaticAnalysisTypes.js";
import {
contextBridgeExposureArguments,
type JavaScriptContextBridgeExposureArguments,
} from "./javascriptContextBridgeExposure.js";
import { boundedExpression } from "./electronStaticAnalysisValues.js";
import { argumentNode } from "./javascriptStaticAnalysisHelpers.js";
import {
childNodes,
traverseJavaScriptAst,
} from "./javascriptSemanticTraversal.js";
import { range, calleeName } from "./javascriptStaticAnalysisHelpers.js";
import { ipcDescriptor } from "./electronStaticAnalysisIpc.js";
import {
symbolicDefiniteExit,
symbolicLogicalRight,
} from "./javascriptSymbolicControlFlow.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
const callLabel = (node: t.Node): string =>
t.isSequenceExpression(node)
? callLabel(node.expressions.at(-1) ?? node)
: calleeName(node) || "local call";
const ipcEndpointKey = (value: ElectronIpcFinding): string =>
JSON.stringify([value.side, value.operation, value.channel, value.location]);
/** Duplicate evidence already recorded for one endpoint and call site. */
interface IpcEndpointFacts {
// A directly observed transmission stays one fact however it is reached.
observedTransmission: boolean;
readonly invocations: Set<string>;
}
const ipcInvocationKey = (location: unknown): string =>
String(JSON.stringify(location));
const indexIpcFinding = (
index: Map<string, IpcEndpointFacts>,
value: ElectronIpcFinding,
): void => {
const key = ipcEndpointKey(value);
let facts = index.get(key);
if (facts === undefined) {
facts = { observedTransmission: false, invocations: new Set() };
index.set(key, facts);
}
facts.observedTransmission ||=
value.resolution_basis === undefined &&
(value.mode === "send" || value.mode === "invoke");
facts.invocations.add(ipcInvocationKey(value.invocation_location));
};
interface PathContext {
readonly entryNode: t.Node;
readonly entry: string;
readonly entryPath?: readonly string[];
readonly entryKind: ElectronCallPath["entry_kind"] | null;
readonly channel: string | null;
readonly steps: readonly ElectronCallPathStep[];
readonly conditions: ElectronCallPath["conditions"];
readonly active: ReadonlySet<t.Function>;
}
/** Substitutes inert wrapper syntax; every emitted path remains a candidate. */
export const collectElectronWrappedCallPaths = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
source: string,
accumulator: JavaScriptAnalysisAccumulator,
identity: JavaScriptElectronIdentity,
): 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, identity);
const paths: ElectronCallPath[] = [];
const seen = new Set<string>();
const unconditionalRegistrations = new Set<string>();
const step = (
node: t.Node,
kind: ElectronCallPathStep["kind"],
label: string,
): ElectronCallPathStep => {
const module = accumulator.moduleRangeIndex.find(node.start);
return {
kind,
label,
location: range(node),
module_key: module?.key ?? null,
module_runtime: module?.runtime ?? null,
};
};
const condition = (
context: PathContext,
node: t.Node,
positive: boolean | "nullish" | "not-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 === "not-nullish"
? `(${expression}) !== null && (${expression}) !== undefined`
: positive
? expression
: `!(${expression})`,
location: range(node),
},
],
};
};
const sequentialFallthrough = (
statement: t.Statement,
env: SymbolicEnvironment,
context: PathContext,
): { readonly continues: boolean; readonly context: PathContext } => {
if (symbolicDefiniteExit(statement)) return { continues: false, context };
if (!t.isIfStatement(statement)) return { continues: true, context };
const truth = symbolicTruth(resolver.evaluate(statement.test, env));
if (truth !== undefined)
return symbolicDefiniteExit(
truth ? statement.consequent : statement.alternate,
)
? { continues: false, context }
: { continues: true, context };
if (symbolicDefiniteExit(statement.consequent))
return {
continues: true,
context: condition(context, statement.test, false),
};
if (symbolicDefiniteExit(statement.alternate))
return {
continues: true,
context: condition(context, statement.test),
};
return { continues: true, context };
};
const metadataContext = (
context: PathContext,
value: SymbolicValue,
): PathContext | null => {
if (value.conditionUnresolved) {
accumulator.unknownFindings += 1;
return null;
}
return (
value.conditions?.reduce(
(current, branch) => condition(current, branch.node, branch.positive),
context,
) ?? context
);
};
// Duplicate checks consult only facts at the same endpoint and call site.
// Scanning every prior fact made bundles with many registrations quadratic.
let ipcByEndpoint: Map<string, IpcEndpointFacts> | undefined;
const ipcAtEndpoint = (): Map<string, IpcEndpointFacts> => {
if (ipcByEndpoint !== undefined) return ipcByEndpoint;
ipcByEndpoint = new Map();
for (const { value } of accumulator.ipc)
indexIpcFinding(ipcByEndpoint, value);
return ipcByEndpoint;
};
const hasConditions = (value: SymbolicValue): boolean =>
symbolicVariants(value).some((variant) => variant.conditions?.length);
function* bridgeMemberFunctions(
value: SymbolicValue,
segments: readonly string[] = [],
active = new Set<SymbolicValue>(),
): Generator<{
readonly name: string;
readonly path: readonly string[];
readonly fn: SymbolicFunction;
}> {
if (active.has(value)) return;
if (active.size >= 128) {
accumulator.unknownFindings += 1;
accumulator.electronCallPathLimitReached = true;
return;
}
const nested = new Set(active);
nested.add(value);
for (const member of [...resolver.members(value)].sort()) {
if (--remainingVisits < 0) return;
const path = [...segments, member];
const name = path.join(".");
for (const variant of symbolicVariants(
resolver.property(value, member),
)) {
if (variant.kind === "function") yield { name, path, fn: variant };
else if (resolver.members(variant).length > 0)
yield* bridgeMemberFunctions(variant, path, nested);
}
if (remainingVisits < 0) return;
}
}
const isolatedBridgeCall = (
node: t.CallExpression | t.OptionalCallExpression,
):
| { readonly env: SymbolicEnvironment; readonly callee: SymbolicValue }
| undefined => {
const env = resolver.environmentAt(node);
const callee = resolver.evaluate(node.callee, env);
return symbolicVariants(callee).some(
(callee) =>
callee.kind === "external" &&
identity.intactExport(callee.path) &&
callee.path.slice(1).join(".") ===
"contextBridge.exposeInIsolatedWorld",
)
? { env, callee }
: undefined;
};
const electronStaticSymbolicValue = (
value: SymbolicValue | undefined,
node: t.Node | undefined,
): ElectronStaticValue => {
const variants = value === undefined ? [] : symbolicVariants(value);
const variant = variants.length === 1 ? variants[0] : undefined;
if (variant?.kind === "literal" && variant.value !== undefined)
return {
status: "literal",
value: variant.value,
expression: null,
};
return {
status: "dynamic",
value: null,
expression: boundedExpression(source, 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,
...(context.entryKind !== "bridge-member" ||
context.entryPath === undefined
? {}
: { entry_path: context.entryPath }),
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,
invocationNode: t.Node,
caller?: SymbolicEnvironment,
): void => {
const body = resolver.callBody(fn);
if (body === undefined || context.active.has(fn.node)) return;
if (fn.conditionUnresolved === true) {
accumulator.unknownFindings += 1;
return;
}
const name =
(t.isFunctionExpression(fn.node) || t.isFunctionDeclaration(fn.node)) &&
fn.node.id
? fn.node.id.name
: "closure";
walk(body, resolver.bind(fn, args, invocationNode, caller), {
...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,
guardedDispatch: boolean,
): void => {
const first = args[0];
if (first?.kind !== "literal" || typeof first.value !== "string") {
accumulator.unknownFindings += 1;
return;
}
const channelContext = metadataContext(context, first);
if (!channelContext) return;
const handlerValue = args[1] ?? symbolicUnknown("missing handler");
const handlerContext = metadataContext(channelContext, handlerValue);
if (!handlerContext) return;
const channel = first.value;
const location = range(node);
const invocationLocation =
JSON.stringify(range(context.entryNode)) === JSON.stringify(location)
? undefined
: range(context.entryNode);
const findingContext = {
module_key: null,
location,
...(invocationLocation === undefined
? {}
: { invocation_location: invocationLocation }),
};
const handlers = symbolicVariants(handlerValue);
// 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,
...findingContext,
}
: fn
? {
...descriptor,
channel,
channel_expression: null,
handler_kind: "inline-function",
handler_location: range(fn.node),
...findingContext,
}
: handlerNode
? {
...descriptor,
channel,
channel_expression: null,
handler_kind: "dynamic-expression",
handler_location: range(handlerNode),
...findingContext,
}
: {
...descriptor,
channel,
channel_expression: null,
handler_kind: "missing",
handler_location: null,
...findingContext,
};
// A fully observed transmission stays one source fact when its callback is
// traced. Registrations retain invocation identity because handlers compete.
const recorded = ipcAtEndpoint().get(ipcEndpointKey(finding));
const duplicate =
recorded !== undefined &&
(recorded.observedTransmission ||
recorded.invocations.has(ipcInvocationKey(invocationLocation)));
const findingGuarded =
context.conditions.length > 0 || hasConditions(first) || guardedDispatch;
// Registry facts do not establish every bridge entry's reachability. Only
// this exact entry and provenance trace can discharge its guarded copy.
const registrationKey =
descriptor.side === "main"
? JSON.stringify([
endpoint,
channel,
location,
range(context.entryNode),
context.entryKind,
context.entry,
context.entryPath,
context.steps,
])
: undefined;
if (!findingGuarded && registrationKey !== undefined)
unconditionalRegistrations.add(registrationKey);
if (!duplicate && !findingGuarded) {
const value: ElectronIpcFinding = {
...finding,
resolution_basis: "wrapper-substitution",
};
accumulator.ipc.push({ offset: node.start ?? -1, value });
indexIpcFinding(ipcAtEndpoint(), value);
if (descriptor.mode !== "send" && descriptor.mode !== "invoke" && !fn)
accumulator.unknownFindings += 1;
} else if (
findingGuarded &&
(registrationKey === undefined ||
!unconditionalRegistrations.has(registrationKey))
) {
accumulator.unknownFindings += 1;
// A guarded registration is a path candidate even when its callback has
// no observable body. Keep its predicate instead of promoting it to an
// unconditional IPC fact or dropping the registration entirely.
if (descriptor.side === "main")
emit(
{
...channelContext,
entryKind: channelContext.entryKind ?? "ipc-handler",
entry: channelContext.entry || channel,
},
node,
endpoint,
channel,
);
}
if (descriptor.mode === "send" || descriptor.mode === "invoke") {
emit(channelContext, node, endpoint, channel);
return;
}
if (descriptor.side === "main" && fn)
functionBody(
fn,
[
{ kind: "external", path: ["ipc-event"] },
symbolicUnknown("IPC payload"),
],
{
...handlerContext,
entryKind: "ipc-handler",
entry: channel,
channel,
steps: [...context.steps, step(node, "ipc", endpoint)],
},
node,
);
};
const bridge = (
node: t.CallExpression | t.OptionalCallExpression,
exposure: JavaScriptContextBridgeExposureArguments<SymbolicValue>,
context: PathContext,
guardedDispatch: boolean,
): void => {
const key = exposure.apiKey;
const object = exposure.api;
const worldValue =
exposure.worldId === null
? null
: (exposure.worldId ?? symbolicUnknown("missing world id"));
const worldContext =
worldValue === null ? context : metadataContext(context, worldValue);
if (!worldContext) return;
const worldId =
worldValue === null
? null
: electronStaticSymbolicValue(
worldValue,
exposure.worldIdIndex === null
? undefined
: argumentNode(node.arguments[exposure.worldIdIndex]),
);
if (worldId?.status === "dynamic") accumulator.unknownFindings += 1;
if (key?.kind !== "literal" || typeof key.value !== "string" || !object) {
accumulator.unknownFindings += 1;
return;
}
const keyContext = metadataContext(worldContext, key);
if (!keyContext || !metadataContext(keyContext, 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: ElectronContextBridgeFinding = {
world: exposure.world,
world_id: worldId,
api_key: key.value,
api_key_expression: null,
api_status: "dynamic" as const,
resolution_basis: "wrapper-substitution" as const,
// The direct syntax pass recursively retains literal dotted paths. The
// symbolic resolver enumerates only immediate object keys, so its
// replacement must not erase paths already established at this call.
members: [
...new Set([...(existing?.value.members ?? []), ...members]),
].sort(compareUnicodeCodePoints),
unknown_members: resolver.unknownMembers(object),
module_key: null,
location,
};
// This report shape has no predicate field, so a guarded world or object
// cannot be recorded as an unconditional exposure fact.
const guardedWorld = worldValue !== null && hasConditions(worldValue);
const guardedKey = hasConditions(key);
const guardedObject = hasConditions(object);
const guarded =
context.conditions.length > 0 ||
guardedWorld ||
guardedKey ||
guardedObject ||
guardedDispatch;
if (guarded) accumulator.unknownFindings += 1;
// Existing entries are raw syntax observations and remain valid even when
// symbolic substitution can only describe this call under a predicate.
if (!guarded && existing)
(existing.value.api_status !== "object-literal" ||
existing.value.unknown_members > 0) &&
accumulator.contextBridgeApis.splice(
accumulator.contextBridgeApis.indexOf(existing),
1,
{ offset: node.start ?? -1, value: finding },
);
else if (!guarded)
accumulator.contextBridgeApis.push({
offset: node.start ?? -1,
value: finding,
});
for (const { name, path, fn } of bridgeMemberFunctions(object)) {
const memberContext = metadataContext(keyContext, fn);
if (!memberContext) continue;
const entry = `${key.value}.${name}`;
functionBody(
fn,
runtimeFunctionParameters(fn.node).map(() =>
symbolicUnknown("public API argument"),
),
{
...memberContext,
entryNode: node,
entryKind: "bridge-member",
entry,
entryPath: [key.value, ...path],
channel: null,
steps: [step(node, "exposure", entry)],
active: new Set(),
},
node,
);
}
};
const call = (
node: t.CallExpression | t.OptionalCallExpression,
env: SymbolicEnvironment,
context: PathContext,
evaluatedCallee?: SymbolicValue,
): void => {
const value = evaluatedCallee ?? resolver.evaluate(node.callee, env);
const args = resolver.arguments(node, env);
for (const callee of symbolicVariants(value)) {
const branchContext = metadataContext(context, callee);
if (!branchContext) continue;
if (callee.kind === "function") {
functionBody(
callee,
args,
{
...branchContext,
steps: [
...branchContext.steps,
step(node, "call", callLabel(node.callee)),
],
},
node,
env,
);
} else if (callee.kind === "external") {
const path = callee.path;
const endpoint = path.join(".");
if (identity.intactExport(path)) {
const descriptor = ipcDescriptor(path.slice(1));
const exposure =
path.length === 3 && path[1] === "contextBridge"
? contextBridgeExposureArguments(path[2] ?? "", args)
: undefined;
if (exposure)
bridge(node, exposure, branchContext, hasConditions(callee));
else if (descriptor)
ipc(
node,
descriptor,
args,
branchContext,
endpoint,
hasConditions(callee),
);
else if (path[1] === "clipboard") emit(branchContext, node, endpoint);
} else if (
(path[0] === "navigator" && path[1] === "clipboard") ||
(path[0] === "ipc-event" && path[1] === "sender")
)
emit(branchContext, 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);
const fallthrough = sequentialFallthrough(statement, env, next);
if (!fallthrough.continues) break;
next = fallthrough.context;
}
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;
const isolatedSeed = t.isStringLiteral(node.arguments[0])
? undefined
: isolatedBridgeCall(node);
if (!t.isStringLiteral(node.arguments[0]) && isolatedSeed === undefined)
return;
const env = isolatedSeed?.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) {
for (const statement of parent.body.slice(0, index)) {
const fallthrough = sequentialFallthrough(
statement,
env,
context,
);
if (!fallthrough.continues) return;
context = fallthrough.context;
}
}
}
}
call(node, env, context, isolatedSeed?.callee);
},
});
if (remainingVisits < 0 || resolver.truncated) {
accumulator.unknownFindings += 1;
accumulator.electronCallPathLimitReached = true;
}
if (resolver.unresolved) accumulator.unknownFindings += 1;
return paths;
};