Files
reverse-engineering-anything/src/domain/javascript/electronStaticAnalysisProtocol.ts
T
morlutoandShuaixinHuang b46f4fcebd fix(javascript): preserve loader authority for exact suffixes (#9477)
* fix(javascript): require exact loader suffix case

* fix(javascript): share exact loader mode across import resolution

* test(process): align symlink fixture with filesystem checkpoints

* test(javascript): normalize the module resolution oracle path separators

* test(frida): assert the CLI spawn Evidence contract

* test(mcp): use an unsupported analysis-view parent

* test(browser): separate source-location line bounds from parity

* fix(javascript): preserve uncertain loader provenance

* test(javascript): cover uncertain require resolution

* fix(javascript): retain conditional Electron evidence

* fix(javascript): preserve timed semantic provenance

* fix(javascript): retain conditional builtin effects

* test(javascript): distinguish bundled host authority

* fix(javascript): preserve nested loader invocation identity

* test(javascript): qualify Electron fixture authority

* fix(javascript): preserve loader invocation authority

* fix(javascript): respect source order for resolver escapes

* fix(javascript): check nested loader callable mutations

* fix(javascript): retain conditional constructor requests

* fix(reference): retain constructor loader relationships

* fix(reference): follow nested loader callable paths

* refactor(javascript): share callable mutation interpretation

* fix(javascript): require exact package loader suffixes

* refactor(javascript): unify conditional relation evidence

* refactor(javascript): unify conditional node evidence

* fix(javascript): retain unknown authority for mixed case sources

* fix(javascript): preserve loader uncertainty for dotfiles

* fix(reference): classify javascript suffix dotfiles

* test(reference): cover suffix dotfiles through cli

* fix(javascript): retain equivalent conditional built-in origins

* fix(javascript): preserve wrapped resolver operation

* fix(javascript): retain nested conditional built-in receivers

* test(javascript): declare CommonJS in native addon fixtures

* fix(javascript): retain wrapped resolver candidates

* style: format merged native command schema

* fix(javascript): preserve uncertain loader authority

* test(javascript): require candidate preload evidence

* test(javascript): cover reference loader member writes

---------

Co-authored-by: ShuaixinHuang <1836656514@qq.com>
2026-11-09 13:21:24 +08:00

307 lines
9.8 KiB
TypeScript

import * as t from "@babel/types";
import { compositeKey } from "../unicodeCodePointOrder.js";
import type {
ElectronProtocolFinding,
ElectronStaticValue,
} from "./electronStaticAnalysisTypes.js";
import {
boundedExpression,
electronCandidateAuthority,
electronCalleePath,
electronStaticValue,
} from "./electronStaticAnalysisValues.js";
import {
isNullishJavaScriptSyntax,
staticObjectProperty,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js";
import { argumentNode, range } from "./javascriptStaticAnalysisHelpers.js";
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
type Role = ElectronProtocolFinding["role"];
type Session = ElectronProtocolFinding["session"];
/** Electron's `CustomScheme` privileges for `protocol.registerSchemesAsPrivileged`. */
const PRIVILEGE_NAMES = [
"allowExtensions",
"allowServiceWorkers",
"bypassCSP",
"codeCache",
"corsEnabled",
"secure",
"standard",
"stream",
"supportFetchAPI",
] as const;
const HANDLERS = new Set([
"handle",
"registerFileProtocol",
"registerHttpProtocol",
"registerStringProtocol",
"registerBufferProtocol",
"registerStreamProtocol",
]);
const INTERCEPTORS = new Set([
"interceptFileProtocol",
"interceptHttpProtocol",
"interceptStringProtocol",
"interceptBufferProtocol",
"interceptStreamProtocol",
]);
/**
* Inspect custom URL scheme registrations: `app.setAsDefaultProtocolClient`,
* `protocol.registerSchemesAsPrivileged`, and scheme handlers or interceptors
* on `protocol` or `session.defaultSession.protocol`.
*/
export const inspectElectronProtocolNode = (
node: t.Node,
context: JavaScriptFindingContext,
): void => {
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
const path = electronCalleePath(node.callee, context);
if (path === undefined) return;
if (path.length === 2 && path[0] === "app") {
if (path[1] === "setAsDefaultProtocolClient")
addProtocol(context, {
node,
api: "setAsDefaultProtocolClient",
role: "os-default-client",
session: "default-protocol",
scheme: argumentNode(node.arguments[0]),
});
return;
}
const target = protocolTarget(path);
if (target === undefined) return;
if (target.api === "registerSchemesAsPrivileged") {
// This global API is exported by the module, not session.protocol.
if (target.session === "default-protocol")
inspectPrivilegedSchemes(node, target.session, context);
return;
}
const role: Role | undefined = HANDLERS.has(target.api)
? "handler"
: INTERCEPTORS.has(target.api)
? "interceptor"
: undefined;
// handle installs its own native callback and invokes the supplied handler
// only when a request arrives; a missing handler does not prevent registration.
if (
role !== undefined &&
(target.api === "handle" || !handlerKnownMissing(node))
)
addProtocol(context, {
node,
api: target.api,
role,
session: target.session,
scheme: argumentNode(node.arguments[0]),
});
};
/** Legacy native registration APIs require a callback before installing it. */
const handlerKnownMissing = (
node: t.CallExpression | t.OptionalCallExpression,
): boolean => {
// A spread can supply the callback at runtime.
if (node.arguments.slice(0, 2).some((value) => t.isSpreadElement(value)))
return false;
const handler = typeless(argumentNode(node.arguments[1]));
return handler === undefined || isNullishJavaScriptSyntax(handler);
};
const protocolTarget = (
path: readonly string[],
): { readonly session: Session; readonly api: string } | undefined => {
if (path.length === 2 && path[0] === "protocol" && path[1] !== undefined)
return { session: "default-protocol", api: path[1] };
if (
path.length === 4 &&
path[0] === "session" &&
path[1] === "defaultSession" &&
path[2] === "protocol" &&
path[3] !== undefined
)
return { session: "default-session", api: path[3] };
return undefined;
};
/** Record one finding per array entry; a non-literal list stays one dynamic finding. */
const inspectPrivilegedSchemes = (
node: t.CallExpression | t.OptionalCallExpression,
session: Session,
context: JavaScriptFindingContext,
): void => {
// TypeScript assertions such as `[...] as const` do not change the value.
const list = typeless(argumentNode(node.arguments[0]));
// Electron converts the argument to `CustomScheme[]`; other syntax registers nothing.
if (isKnownNonArray(list)) return;
// Electron converts every entry or throws, so one entry that cannot be a
// `CustomScheme` means the call registers nothing.
if (t.isArrayExpression(list) && list.elements.some(isKnownInvalidEntry))
return;
const entries = t.isArrayExpression(list) ? list.elements : [list];
for (const [index, entry] of entries.entries()) {
const candidate = typeless(entry ?? undefined);
const scheme = staticObjectProperty(
candidate,
"scheme",
isNullishJavaScriptSyntax,
);
addProtocol(context, {
node,
located: candidate ?? node,
entry: index,
api: "registerSchemesAsPrivileged",
role: "privileged-scheme",
session,
scheme: scheme.status === "explicit" ? scheme.value : candidate,
privileges: enabledPrivileges(candidate, context),
});
}
};
const isKnownNonArray = (node: t.Node | undefined): boolean =>
t.isObjectExpression(node) ||
t.isLiteral(node) ||
t.isFunction(node) ||
t.isClassExpression(node);
/** A hole, non-object syntax, or an object whose `scheme` is known absent. */
const isKnownInvalidEntry = (
entry: t.ArrayExpression["elements"][number],
): boolean => {
if (entry === null) return true;
// A spread can contribute any entries at runtime.
if (t.isSpreadElement(entry)) return false;
const candidate = typeless(entry);
if (
t.isLiteral(candidate) ||
t.isArrayExpression(candidate) ||
t.isFunction(candidate) ||
t.isClassExpression(candidate)
)
return true;
if (!t.isObjectExpression(candidate)) return false;
const scheme = staticObjectProperty(
candidate,
"scheme",
isNullishJavaScriptSyntax,
);
return (
scheme.status === "missing" ||
(scheme.status === "explicit" &&
isNullishJavaScriptSyntax(typeless(scheme.value) ?? scheme.value))
);
};
/**
* Privilege keys whose effective (last) value is literal `true`; null when the
* object is not a literal with static keys or a privilege value is not a
* boolean literal. A later key wins, as at runtime.
*/
const enabledPrivileges = (
entry: t.Node | undefined,
context: JavaScriptFindingContext,
): readonly string[] | null => {
const lookup = staticObjectProperty(
entry,
"privileges",
isNullishJavaScriptSyntax,
);
if (lookup.status === "missing") return [];
const privileges =
lookup.status === "explicit" ? typeless(lookup.value) : undefined;
if (!t.isObjectExpression(privileges)) {
context.accumulator.unknownFindings += 1;
return null;
}
// The shared object rules own spreads, accessors, `__proto__` setters and
// nullish spreads. Only Electron's privilege names are read, so the work
// stays linear in the object size.
const enabled: string[] = [];
for (const name of PRIVILEGE_NAMES) {
const lookup = staticObjectProperty(
privileges,
name,
isNullishJavaScriptSyntax,
);
if (lookup.status !== "explicit") {
if (lookup.status === "missing") continue;
context.accumulator.unknownFindings += 1;
return null;
}
const value = typeless(lookup.value);
// An unknown value may enable the privilege, so the set is unknown.
if (!t.isBooleanLiteral(value)) {
context.accumulator.unknownFindings += 1;
return null;
}
if (value.value) enabled.push(name);
}
return enabled.toSorted();
};
interface ProtocolInput {
readonly node: t.CallExpression | t.OptionalCallExpression;
/** The source range reported for this finding; defaults to the call. */
readonly located?: t.Node;
/** Array position for one of several entries passed to a single call. */
readonly entry?: number;
readonly api: string;
readonly role: Role;
readonly session: Session;
readonly scheme: t.Node | null | undefined;
readonly privileges?: readonly string[] | null;
}
const addProtocol = (
context: JavaScriptFindingContext,
input: ProtocolInput,
): void => {
// An omitted, `undefined` or `null` scheme is known absence, not an unknown.
const schemeNode = typeless(input.scheme);
if (schemeNode === undefined || isNullishJavaScriptSyntax(schemeNode)) return;
const located = input.located ?? input.node;
const scheme = schemeValue(context, input.scheme, located);
if (scheme.status === "dynamic") context.accumulator.unknownFindings += 1;
const finding: ElectronProtocolFinding = {
...electronCandidateAuthority(input.node.callee, context),
api: input.api,
role: input.role,
scheme,
session: input.session,
enabled_privileges: input.privileges ?? null,
module_key: null,
location: range(located),
};
addLocatedFinding(context, {
collection: context.accumulator.protocols,
key: compositeKey(["electron-protocol", input.api, input.entry ?? null]),
node: located,
value: finding,
});
};
const schemeValue = (
context: JavaScriptFindingContext,
scheme: t.Node | null | undefined,
located: t.Node,
): ElectronStaticValue =>
scheme === null || scheme === undefined
? {
status: "dynamic",
value: null,
expression: boundedExpression(context.source, located),
}
: electronStaticValue(context.source, typeless(scheme));
const typeless = (node: t.Node | null | undefined): t.Node | undefined =>
node === null || node === undefined
? undefined
: unwrapJavaScriptExpression(node).node;