fix(electron): keep literal IPC sends ambiguous beside unresolved handlers (#8120)

* fix(electron): keep literal IPC sends ambiguous beside unresolved handlers

A main handler on an unresolved channel can receive any literal renderer
send of a compatible mode, but pairing ignored it unless it had a scoped
receiver. The send was then reported unpaired, or paired with another
handler. Treat an unresolved-channel handler as a competitor that never
pairs.

The wrapper pass seeds only calls with a literal first argument, so it now
records which invocations of a dynamic site it traced. The syntax finding
gains `invocation_channels: "literal"` only when every invocation is a
traced direct call of an unexported local function supplying a literal
channel. Such a site is already represented by its per-invocation findings
and does not compete.

Fixes #8118

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(electron): keep guarded wrapper registrations competing in IPC pairing

A guarded wrapper registration keeps its predicate on a call path and has
no IPC finding. It still counted toward `invocation_channels: "literal"`,
so pairing lost it: a send on that channel was reported unpaired, or
paired uniquely with a direct handler. The enumerated site now carries
these channels as `guarded_invocation_channels`, and each one competes for
its own channel without pairing.

Index each binding's direct call sites by appending in place. Spreading
the earlier calls on every reference was quadratic in a file's call count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-30 02:21:23 -07:00
1 parent 72039ecca9
commit 81378efb71
6 files changed
+317 -19

No files matched your search

@@ -138,7 +138,22 @@ export type ElectronIpcFinding = ElectronIpcDescriptor &
ElectronIpcFindingContext &
(
| { readonly channel: string; readonly channel_expression: null }
| { readonly channel: null; readonly channel_expression: string }
| {
readonly channel: null;
readonly channel_expression: string;
/**
* Every invocation of this site is a traced direct call that supplied a
* literal channel, recorded as its own wrapper-substitution finding or
* guarded call path. Absent when any invocation may supply another value.
*/
readonly invocation_channels?: "literal";
/**
* Channels from guarded invocations, which keep their predicate on a
* call path instead of becoming findings. Each still competes for its
* channel in pairing.
*/
readonly guarded_invocation_channels?: readonly string[];
}
);
/** Static check involving an IPC sender, frame, process, URL, or origin. */
+164 -11
View File
@@ -13,6 +13,7 @@ import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import { semanticIdentifierRole } from "./javascriptSemanticProjection.js";
import type { JavaScriptElectronIdentity } from "./javascriptSemanticAnalysis.js";
@@ -138,7 +139,9 @@ const priorPath = (
};
/** One physical IPC operation, independent of how its channel was resolved. */
const ipcSiteKey = (value: ElectronIpcFinding): string =>
const ipcSiteKey = (
value: Pick<ElectronIpcFinding, "side" | "operation" | "location">,
): string =>
JSON.stringify([
value.side,
value.operation,
@@ -432,6 +435,110 @@ export function* collectElectronWrappedCallPathsSteps(
let ipcByEndpoint: Map<string, IpcEndpointFacts> | undefined;
// Direct operations whose channel the syntax pass left unresolved, by site.
let unresolvedIpcSites: Map<string, number> | undefined;
// Traced invocation outcomes at IPC sites: the call sites of invocations
// that supplied a literal channel, or any other outcome.
const literalInvocationSites = new Map<
string,
{
readonly node: t.Node;
readonly calls: Set<string>;
// Literals whose guarded registration has no IPC finding of its own.
readonly guardedChannels: Set<string>;
}
>();
const openInvocationSites = new Set<string>();
// Runtime references to each binding: direct call sites, or null when any
// other reference can invoke it from sites the trace never seeds.
let directCalls:
| Map<JavaScriptSemanticBindingState, t.CallExpression[] | null>
| undefined;
const directCallsTo = (
binding: JavaScriptSemanticBindingState,
): readonly t.CallExpression[] | null => {
if (directCalls === undefined) {
const references = new Map<
JavaScriptSemanticBindingState,
t.CallExpression[] | null
>();
traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, {
enter: (node, parent, readAncestors) => {
if (
!t.isIdentifier(node) ||
parent === null ||
semanticIdentifierRole(node, parent, readAncestors) === null
)
return;
const referenced = resolveSemanticBindingState(
state,
node,
node.name,
);
if (referenced === undefined) return;
const calls = references.get(referenced);
if (calls === null) return;
if (!t.isCallExpression(parent) || parent.callee !== node)
references.set(referenced, null);
else if (calls === undefined) references.set(referenced, [parent]);
else calls.push(parent);
},
});
directCalls = references;
}
return directCalls.get(binding) ?? [];
};
/**
* Whether every invocation of the function enclosing a node is a direct call
* whose own invocation chain the trace followed. Seeds start only at literal
* arguments, so an untraced call site may supply any channel.
*/
const tracedInvocationsOnly = (
node: t.Node,
traced: ReadonlySet<string>,
active: ReadonlySet<t.Node> = new Set(),
): boolean => {
let fn = state.parentsByNode.get(node);
while (fn !== undefined && !t.isFunction(fn))
fn = state.parentsByNode.get(fn);
// Top-level code runs once with the values the trace evaluated.
if (fn === undefined) return true;
if (active.has(fn)) return false;
const declarator = state.parentsByNode.get(fn);
const declaration = t.isFunctionDeclaration(fn)
? fn
: t.isVariableDeclarator(declarator) && declarator.init === fn
? state.parentsByNode.get(declarator)
: undefined;
const owner =
declaration === undefined
? undefined
: state.parentsByNode.get(declaration);
// An exported declaration is invoked by importers this file never sees.
if (
declaration === undefined ||
t.isExportNamedDeclaration(owner) ||
t.isExportDefaultDeclaration(owner)
)
return false;
const id = t.isFunctionDeclaration(fn)
? fn.id
: t.isVariableDeclarator(declarator) && t.isIdentifier(declarator.id)
? declarator.id
: undefined;
const binding =
id === undefined || id === null
? undefined
: resolveSemanticBindingState(state, id, id.name);
const calls = binding === undefined ? null : directCallsTo(binding);
return (
calls !== null &&
calls.length > 0 &&
calls.every(
(call) =>
traced.has(ipcInvocationKey(range(call))) &&
tracedInvocationsOnly(call, traced, new Set([...active, fn])),
)
);
};
const ipcAtEndpoint = (): Map<string, IpcEndpointFacts> => {
if (ipcByEndpoint !== undefined) return ipcByEndpoint;
ipcByEndpoint = new Map();
@@ -825,18 +932,26 @@ export function* collectElectronWrappedCallPathsSteps(
endpoint: string,
guardedDispatch: boolean,
): void => {
const location = range(node);
// The site evaluated in its own scope is not an invocation of it.
const invocationSite =
context.calls.length > 0
? ipcSiteKey({ ...descriptor, location })
: undefined;
const open = (): void => {
if (invocationSite !== undefined) openInvocationSites.add(invocationSite);
};
const first = args[0];
if (first?.kind !== "literal" || typeof first.value !== "string") {
accumulator.unknownFindings += 1;
return;
return open();
}
const channelContext = metadataContext(context, first);
if (!channelContext) return;
if (!channelContext) return open();
const handlerValue = args[1] ?? symbolicUnknown("missing handler");
const handlerContext = metadataContext(channelContext, handlerValue);
if (!handlerContext) return;
if (!handlerContext) return open();
const channel = first.value;
const location = range(node);
const invocationLocation =
javascriptSourceRangeKey(range(context.entryNode)) ===
javascriptSourceRangeKey(location)
@@ -936,6 +1051,21 @@ export function* collectElectronWrappedCallPathsSteps(
: undefined;
if (!findingGuarded && registrationKey !== undefined)
unconditionalRegistrations.add(registrationKey);
// A guarded registration keeps its predicate on the call path instead of
// becoming an IPC finding, so the site carries its channel to pairing.
const guardedOnly =
findingGuarded &&
(registrationKey === undefined ||
!unconditionalRegistrations.has(registrationKey));
if (invocationSite !== undefined) {
let traced = literalInvocationSites.get(invocationSite);
if (traced === undefined) {
traced = { node, calls: new Set(), guardedChannels: new Set() };
literalInvocationSites.set(invocationSite, traced);
}
for (const call of context.calls) traced.calls.add(call);
if (guardedOnly) traced.guardedChannels.add(channel);
}
if (!duplicate && !findingGuarded) {
const value: ElectronIpcFinding = {
...finding,
@@ -986,11 +1116,7 @@ export function* collectElectronWrappedCallPathsSteps(
if (descriptor.mode !== "send" && descriptor.mode !== "invoke" && !fn)
accumulator.unknownFindings += 1;
}
} else if (
findingGuarded &&
(registrationKey === undefined ||
!unconditionalRegistrations.has(registrationKey))
) {
} else if (guardedOnly) {
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
@@ -1435,7 +1561,34 @@ export function* collectElectronWrappedCallPathsSteps(
if (remainingVisits < 0 || resolver.truncated) {
accumulator.unknownFindings += 1;
accumulator.electronCallPathLimitReached = true;
}
} else
// A dynamic site's channels are enumerated only when the complete trace
// reached it with literal channels, no other invocation outcome, and no
// way to invoke its wrapper from sites the trace does not seed.
for (const [site, index] of unresolvedIpcSites ?? []) {
const entry = accumulator.ipc[index];
const traced = literalInvocationSites.get(site);
if (
entry?.value.channel === null &&
traced !== undefined &&
!openInvocationSites.has(site) &&
tracedInvocationsOnly(traced.node, traced.calls)
)
accumulator.ipc[index] = {
...entry,
value: {
...entry.value,
invocation_channels: "literal",
...(traced.guardedChannels.size === 0
? {}
: {
guarded_invocation_channels: [...traced.guardedChannels].sort(
compareUnicodeCodePoints,
),
}),
},
};
}
if (resolver.unresolved) accumulator.unknownFindings += 1;
return paths;
}