feat(electron): report custom URL protocol registrations

Expose static Electron protocol call facts through the shared application graph and existing CLI/MCP Evidence views. Preserve canonical case-insensitive scheme identity, original spellings, exact source provenance and explicit unknowns; keep registration success and runtime routing unproven.

Integration fixes also enforce module-only privileged API ownership, reuse the shared finding-key owner, and align the owning type documentation with the Evidence semantics. Validation and bounded real Obsidian CLI/MCP measurements are recorded in the PR.

Co-authored-by: UnknownHacker1 <[email protected]>
Co-authored-by: gensokyo-dev <[email protected]>
This commit is contained in:
Abdelrahman Teimaandgensokyo-dev authored and GitHub committed 2026-10-31 02:23:47 +08:00
1 parent 69bf61f7c7
commit 47fa6ab379
24 files changed
+1134 -11

No files matched your search

@@ -38,6 +38,12 @@ const APPLICATION_NODE_POLICIES: Readonly<
"negative",
"malformed",
]),
// Scheme links reach the running application through the OS or a session.
"url-scheme": policy("electron", "url-scheme", "packaged-process", [
"positive",
"negative",
"malformed",
]),
endpoint: policy("protocol", "endpoint", "protocol", [
"positive",
"negative",
@@ -220,6 +220,10 @@ export const summarizeElectronBoundaries = (
moduleKind: finding.module_kind,
}),
).length,
protocol_registrations: javascript.reduce(
(count, { electron }) => count + electron.protocols.length,
0,
),
};
};
@@ -2,6 +2,7 @@ import type { ElectronIpcContext } from "./ElectronBoundaryAnalysis.js";
import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js";
import { addElectronIpcBoundaries } from "./ElectronBoundaryGraphIpc.js";
import { addElectronNativeBoundaries } from "./ElectronBoundaryGraphNative.js";
import { addElectronProtocolBoundaries } from "./ElectronBoundaryGraphProtocols.js";
import { addElectronWindowBoundaries } from "./ElectronBoundaryGraphWindows.js";
import { addElectronCallPaths } from "./ElectronBoundaryGraphCallPaths.js";
@@ -13,5 +14,6 @@ export const addElectronBoundaries = (
addElectronWindowBoundaries(context);
addElectronIpcBoundaries(context, ipc);
addElectronNativeBoundaries(context);
addElectronProtocolBoundaries(context);
addElectronCallPaths(context, ipc);
};
@@ -0,0 +1,129 @@
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
import type { ElectronProtocolFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
artifactLocalIdentity,
javascriptAnalysisCoverage,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import {
addElectronInferenceEdge,
electronFindingSourceNode,
electronObservationIdentity,
} from "./ElectronBoundaryGraphContext.js";
interface ProtocolInput {
readonly context: JavaScriptArtifactGraphContext;
readonly file: JavaScriptArtifactFile;
readonly value: ElectronProtocolFinding;
readonly coverage: JavaScriptArtifactGraphCoverage;
}
const URL_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/u;
const REGISTRATION_LIMITATION =
"A registration call is present in source; successful registration, control-flow reachability and operating-system or session routing are not established.";
/** Project custom URL scheme registrations as schemes the application claims. */
export const addElectronProtocolBoundaries = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed;
if (javascript === null) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const value of javascript.electron.protocols)
addProtocolRegistration({ context, file, value, coverage });
}
};
const addProtocolRegistration = (input: ProtocolInput): void => {
const { context, file, value, coverage } = input;
const source = electronFindingSourceNode(
context,
file,
value.module_key,
value.module_runtime,
);
if (source === undefined) return;
const literal = value.scheme.status === "literal";
const raw = value.scheme.value;
// RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ).
const scheme =
literal && typeof raw === "string" && URL_SCHEME.test(raw)
? raw.toLowerCase()
: null;
// A non-string or malformed literal is observed exactly but names no scheme.
const invalid = literal && scheme === null;
const facts = {
scheme,
scheme_expression: value.scheme.expression,
scheme_literal: literal ? raw : null,
resolution:
scheme !== null ? "literal" : invalid ? "invalid-literal" : "dynamic",
api: value.api,
role: value.role,
session: value.session,
enabled_privileges: value.enabled_privileges,
};
const limitations =
scheme !== null
? [REGISTRATION_LIMITATION]
: invalid
? [
REGISTRATION_LIMITATION,
"The scheme argument is a literal that is not a valid URL scheme, so no URL can target it as written.",
]
: [
REGISTRATION_LIMITATION,
"The scheme expression is dynamic; its runtime value remains unknown.",
];
// Scheme names are case-insensitive; retain their spelling separately from
// canonical identity. One node per scheme across the artifact, so one both
// privileged and handled is one entry point with several registrations.
const node = context.accumulator.addNode({
kind: "url-scheme",
identity:
scheme === null
? artifactLocalIdentity(
file.sha256,
"electron-dynamic-url-scheme",
// Identical bytes at two paths are separate occurrences whose
// expressions can resolve differently.
`${file.path}:${value.api}:${javascriptSourceRangeKey(value.location)}`,
)
: electronObservationIdentity(
context,
"electron-url-scheme",
`literal:${scheme}`,
),
observations: [
{
label: scheme ?? value.scheme.expression ?? String(value.scheme.value),
properties: facts,
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-protocol-registration",
coverage,
limitations,
}),
},
],
});
addElectronInferenceEdge(context, {
source,
target: node,
file,
range: value.location,
coverage,
// Granting privileges exposes the scheme; the other roles claim its links.
relation: value.role === "privileged-scheme" ? "exposes" : "handles",
operation: "map-protocol-registration",
properties: facts,
limitations,
});
};
@@ -668,7 +668,8 @@ const findingCount = (analysis: JavaScriptStaticAnalysis): number =>
analysis.electron.ipc.length +
analysis.electron.sender_validations.length +
analysis.electron.utility_processes.length +
analysis.electron.native_addon_bindings.length;
analysis.electron.native_addon_bindings.length +
analysis.electron.protocols.length;
const optionalString = (value: unknown): string | null =>
typeof value === "string" && value.length > 0 ? value : null;
@@ -366,7 +366,8 @@ const graphLimitations = (
javascript.electron.ipc.length +
javascript.electron.sender_validations.length +
javascript.electron.utility_processes.length +
javascript.electron.native_addon_bindings.length),
javascript.electron.native_addon_bindings.length +
javascript.electron.protocols.length),
0,
);
return [
@@ -64,6 +64,7 @@ const summary = {
resolved_utility_entrypoints: 0,
native_addon_bindings: 0,
resolved_native_addon_bindings: 0,
protocol_registrations: 0,
};
const statistics = {
relevant_files: 1,
@@ -159,6 +159,7 @@ export const JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE = createEvidence(
resolved_utility_entrypoints: 0,
native_addon_bindings: 0,
resolved_native_addon_bindings: 0,
protocol_registrations: 0,
},
statistics: {
relevant_files: 1,
@@ -3,6 +3,7 @@ import * as t from "@babel/types";
import { inspectElectronBrowserNode } from "./electronStaticAnalysisBrowser.js";
import { inspectElectronIpcNode } from "./electronStaticAnalysisIpc.js";
import { inspectElectronNativeNode } from "./electronStaticAnalysisNative.js";
import { inspectElectronProtocolNode } from "./electronStaticAnalysisProtocol.js";
import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js";
import { electronCalleePath } from "./electronStaticAnalysisValues.js";
import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js";
@@ -38,4 +39,5 @@ export const inspectElectronStaticNode = (
node,
value: { module_key: null, location: range(node) },
});
inspectElectronProtocolNode(node, context);
};
@@ -0,0 +1,333 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
const protocolsOf = (source: string) =>
analyzeJavaScriptStaticSource(source).electron.protocols;
const literal = (value: string) => ({
status: "literal",
value,
expression: null,
});
describe("Electron custom protocol registrations", () => {
it("does not invent the module-only privileged API on a session protocol", () => {
const protocols = protocolsOf(`
const { protocol, session } = require("electron");
session.defaultSession.protocol.registerSchemesAsPrivileged([{ scheme: "fake" }]);
session.defaultSession.protocol.handle("real", handler);
protocol.registerSchemesAsPrivileged([{ scheme: "real" }]);
`);
expect(protocols.map(({ api, scheme }) => [api, scheme])).toEqual([
["handle", literal("real")],
["registerSchemesAsPrivileged", literal("real")],
]);
});
it("records OS registration, privileged schemes, handlers and interceptors", () => {
const protocols = protocolsOf(`
const { app, protocol, session } = require("electron");
app.setAsDefaultProtocolClient("myapp");
protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { standard: true, secure: true, bypassCSP: false } },
{ scheme: "media", privileges: { stream: true } },
]);
protocol.handle("app", (request) => fetch(request.url));
session.defaultSession.protocol.registerFileProtocol("media", serve);
protocol.interceptHttpProtocol("https", rewrite);
`);
expect(protocols).toMatchObject([
{
api: "setAsDefaultProtocolClient",
role: "os-default-client",
scheme: literal("myapp"),
session: "default-protocol",
enabled_privileges: null,
},
{
api: "registerSchemesAsPrivileged",
role: "privileged-scheme",
scheme: literal("app"),
enabled_privileges: ["secure", "standard"],
},
{
api: "registerSchemesAsPrivileged",
role: "privileged-scheme",
scheme: literal("media"),
enabled_privileges: ["stream"],
},
{ api: "handle", role: "handler", scheme: literal("app") },
{
api: "registerFileProtocol",
role: "handler",
scheme: literal("media"),
session: "default-session",
},
{
api: "interceptHttpProtocol",
role: "interceptor",
scheme: literal("https"),
},
]);
});
it("follows Electron identity through namespace and alias forms", () => {
expect(
protocolsOf(`
const electron = require("electron");
const desktop = electron.app;
desktop.setAsDefaultProtocolClient("notes");
`),
).toMatchObject([{ scheme: literal("notes") }]);
});
it("keeps non-literal schemes and privileges as unknown values", () => {
const protocols = protocolsOf(`
const { app, protocol } = require("electron");
app.setAsDefaultProtocolClient(config.scheme);
protocol.registerSchemesAsPrivileged([{ scheme: name, privileges: options }]);
protocol.registerSchemesAsPrivileged(schemes);
`);
expect(protocols).toMatchObject([
{
role: "os-default-client",
scheme: { status: "dynamic", expression: "config.scheme" },
},
{
role: "privileged-scheme",
scheme: { status: "dynamic", expression: "name" },
enabled_privileges: null,
},
{
role: "privileged-scheme",
scheme: { status: "dynamic", expression: "schemes" },
enabled_privileges: null,
},
]);
});
it("keeps one finding per entry when entries are not object literals", () => {
expect(
protocolsOf(`
const { protocol } = require("electron");
protocol.registerSchemesAsPrivileged([firstScheme, secondScheme]);
`),
).toMatchObject([
{ scheme: { status: "dynamic", expression: "firstScheme" } },
{ scheme: { status: "dynamic", expression: "secondScheme" } },
]);
});
it("uses the last value of a repeated privilege key", () => {
expect(
protocolsOf(`
const { protocol } = require("electron");
protocol.registerSchemesAsPrivileged([{
scheme: "app",
privileges: { secure: true, standard: true, secure: false, standard: true },
}]);
`),
).toMatchObject([{ enabled_privileges: ["standard"] }]);
});
it.each([
[
"a local object named protocol",
'const protocol = { handle() {} }; protocol.handle("app", h);',
],
[
"a query rather than a registration",
'const { protocol } = require("electron"); protocol.isProtocolHandled("app");',
],
[
"a removal",
'const { app } = require("electron"); app.removeAsDefaultProtocolClient("app");',
],
[
"a non-default session",
'const { session } = require("electron"); session.fromPartition("p").protocol.handle("app", h);',
],
])("ignores %s", (_name, source) => {
expect(protocolsOf(source)).toEqual([]);
});
});
describe("Electron protocol registrations in TypeScript sources", () => {
it("reads TypeScript-asserted lists, entries and privileges", () => {
expect(
protocolsOf(`
const { protocol } = require("electron");
protocol.registerSchemesAsPrivileged([
{ scheme: "a", privileges: { secure: true } as const },
{ scheme: "b" } satisfies Entry,
] as const);
`),
).toMatchObject([
{ scheme: literal("a"), enabled_privileges: ["secure"] },
{ scheme: literal("b"), enabled_privileges: [] },
]);
});
it("reads TypeScript-asserted schemes and privilege values", () => {
expect(
protocolsOf(`
const { app, protocol } = require("electron");
app.setAsDefaultProtocolClient("notes" as const);
protocol.registerSchemesAsPrivileged([
{
scheme: "app" as const,
privileges: { secure: true as const, standard: true satisfies boolean, stream: false as const },
},
]);
`),
).toMatchObject([
{ role: "os-default-client", scheme: literal("notes") },
{ scheme: literal("app"), enabled_privileges: ["secure", "standard"] },
]);
});
});
describe("Electron privileged scheme spreads", () => {
const privilegesOf = (privileges: string) =>
protocolsOf(`
const { protocol } = require("electron");
protocol.registerSchemesAsPrivileged([{ scheme: "app", privileges: ${privileges} }]);
`)[0]?.enabled_privileges;
it.each([
[
"a literal spread before a key",
"{ ...{ secure: true }, standard: true }",
["secure", "standard"],
],
[
"a later key overriding a spread",
"{ ...{ secure: true, stream: true }, stream: false }",
["secure"],
],
[
"a later spread overriding a key",
"{ standard: true, ...{ standard: false } }",
[],
],
[
"nested literal spreads",
"{ ...{ ...{ secure: true } as const } }",
["secure"],
],
[
"a __proto__ setter, which is not an own key",
"{ ...{ __proto__: true }, secure: true }",
["secure"],
],
["a nullish spread", "{ secure: true, ...null, ...void 0 }", ["secure"]],
["allowExtensions", "{ allowExtensions: true }", ["allowExtensions"]],
[
"a key that is not an Electron privilege",
"{ secure: true, notAPrivilege: true }",
["secure"],
],
])("reads %s", (_name, privileges, expected) => {
expect(privilegesOf(privileges)).toEqual(expected);
});
it.each([
["a spread of an unknown value", "{ ...options, secure: true }"],
[
"a privilege with an unknown value",
"{ secure: true, bypassCSP: config.allow }",
],
[
"a spread containing a getter",
"{ ...{ get secure() { return true; } } }",
],
])("keeps %s unknown", (_name, privileges) => {
expect(privilegesOf(privileges)).toBeNull();
});
});
describe("Electron protocol registrations without a scheme", () => {
it("records nothing when the scheme is known to be absent", () => {
expect(
protocolsOf(`
const { app, protocol } = require("electron");
protocol.handle();
app.setAsDefaultProtocolClient(void 0);
protocol.registerFileProtocol(null, serve);
`),
).toEqual([]);
});
it.each([
["a hole", ","],
["an entry without a scheme", "{ privileges: { secure: true } },"],
["an entry with a null scheme", "{ scheme: null },"],
["a string entry", '"other",'],
])("records nothing for a list with %s", (_name, invalid) => {
// Electron converts the whole list or throws before registering anything.
expect(
protocolsOf(`
const { protocol } = require("electron");
protocol.registerSchemesAsPrivileged([${invalid} { scheme: "app" }]);
`),
).toEqual([]);
});
it("keeps entries next to a spread, which can supply any entries", () => {
expect(
protocolsOf(`
const { protocol } = require("electron");
protocol.registerSchemesAsPrivileged([...base, { scheme: "app" }]);
`),
).toMatchObject([
{ scheme: { status: "dynamic" } },
{ scheme: literal("app") },
]);
});
});
describe("Electron protocol calls that register nothing", () => {
it.each([
["a handler without a callback", 'protocol.handle("app");'],
["a handler with a null callback", 'protocol.handle("app", null);'],
[
"an interceptor without a callback",
'protocol.interceptHttpProtocol("https");',
],
[
"a privileged scheme object outside an array",
'protocol.registerSchemesAsPrivileged({ scheme: "app" });',
],
[
"a privileged scheme string outside an array",
'protocol.registerSchemesAsPrivileged("app");',
],
])("ignores %s", (_name, call) => {
expect(
protocolsOf(`const { protocol } = require("electron"); ${call}`),
).toEqual([]);
});
it("keeps a handler whose callback may come from a spread", () => {
expect(
protocolsOf(`
const { protocol } = require("electron");
protocol.handle("app", ...rest);
`),
).toMatchObject([{ role: "handler", scheme: literal("app") }]);
});
it("reads privileges past a nullish spread in the entry", () => {
expect(
protocolsOf(`
const { protocol } = require("electron");
protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { secure: true }, ...null, ...void 0 },
]);
`),
).toMatchObject([
{ scheme: literal("app"), enabled_privileges: ["secure"] },
]);
});
});
@@ -0,0 +1,299 @@
import * as t from "@babel/types";
import { compositeKey } from "../unicodeCodePointOrder.js";
import type {
ElectronProtocolFinding,
ElectronStaticValue,
} from "./electronStaticAnalysisTypes.js";
import {
boundedExpression,
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;
if (role !== undefined && !handlerKnownMissing(node))
addProtocol(context, {
node,
api: target.api,
role,
session: target.session,
scheme: argumentNode(node.arguments[0]),
});
};
/** Electron requires the callback, so a call without one installs nothing. */
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 = {
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;
@@ -213,6 +213,36 @@ export interface ElectronNativeAddonBindingFinding {
readonly location: JavaScriptSourceRange;
}
/**
* One custom URL scheme registration call observed at an Electron `app` or
* `protocol` call site. Successful registration, control-flow reachability
* and operating-system or session routing remain unproven.
*/
export interface ElectronProtocolFinding {
/** The exact Electron member called. */
readonly api: string;
/**
* `os-default-client`: `app.setAsDefaultProtocolClient`, which asks the OS to
* open the scheme's links in this app; `privileged-scheme`: an entry passed to
* `protocol.registerSchemesAsPrivileged`; `handler` and `interceptor`:
* `protocol.handle`/`register*Protocol` and `intercept*Protocol`.
*/
readonly role:
| "os-default-client"
| "privileged-scheme"
| "handler"
| "interceptor";
readonly scheme: ElectronStaticValue;
/** `session.defaultSession.protocol` rather than the default `protocol` module. */
readonly session: "default-protocol" | "default-session";
/** Privilege keys with a literal `true`; null unless `role` is `privileged-scheme` with a literal object. */
readonly enabled_privileges: readonly string[] | null;
readonly module_key: string | null;
/** Recovered factory runtime; absent in older or unbundled findings. */
readonly module_runtime?: string;
readonly location: JavaScriptSourceRange;
}
/** All Electron-specific facts collected during one shared AST traversal. */
export interface ElectronStaticFindings {
/** Calls that may replace the application path used by Electron loadFile. */
@@ -223,6 +253,7 @@ export interface ElectronStaticFindings {
readonly sender_validations: readonly ElectronSenderValidationFinding[];
readonly utility_processes: readonly ElectronUtilityProcessFinding[];
readonly native_addon_bindings: readonly ElectronNativeAddonBindingFinding[];
readonly protocols: readonly ElectronProtocolFinding[];
/** Source-bound candidate paths through bundled exports and IPC closures. */
readonly call_paths?: readonly ElectronCallPath[];
}
@@ -12,8 +12,7 @@ import {
isValidatedImmutableJavaScriptSemanticGraph,
type JavaScriptSemanticGraph,
} from "./javascriptSemanticGraph.js";
import { digestSchema } from "../digests.js";
import { prefixedDigestSchema } from "../digests.js";
import { digestSchema, prefixedDigestSchema } from "../digests.js";
import { integrityContradictionSchema } from "../artifactGraph.js";
import { artifactIntegrityPolicySchema } from "../artifactIntegrityPolicy.js";
import { localPathStringSchema } from "../localPath.js";
@@ -57,6 +56,8 @@ const electronBoundarySummarySchema = z.strictObject({
resolved_utility_entrypoints: countSchema,
native_addon_bindings: countSchema,
resolved_native_addon_bindings: countSchema,
/** Absent in evidence produced before protocol registrations were reported. */
protocol_registrations: countSchema.exactOptional(),
});
const reconstructionStatisticsSchema = z.strictObject({
@@ -446,3 +446,15 @@ describe("skipped_javascript_files backward compatibility", () => {
expect(result.statistics.skipped_javascript_files).toBe(3);
});
});
describe("Electron boundary summary compatibility", () => {
it("reads results produced before protocol registrations were counted", () => {
const current = example();
const { protocol_registrations: _count, ...summary } = current.summary;
const parsed = javascriptApplicationAnalysisResultSchema.parse({
...current,
summary,
});
expect(parsed.summary).not.toHaveProperty("protocol_registrations");
});
});
@@ -37,6 +37,7 @@ export const JAVASCRIPT_APPLICATION_NODE_KINDS = [
"context-bridge-api",
"ipc-channel",
"ipc-handler",
"url-scheme",
"worker",
"service-worker",
"endpoint",
@@ -21,7 +21,8 @@ export const estimateJavaScriptStaticApplicationProjection = (
facts.electron.ipc.length +
facts.electron.sender_validations.length +
facts.electron.utility_processes.length +
facts.electron.native_addon_bindings.length;
facts.electron.native_addon_bindings.length +
facts.electron.protocols.length;
let records = findings * 8;
let repeatedBytes = 0;
for (const registration of facts.bundler_registrations) {
@@ -251,6 +251,11 @@ const semanticKey = (node: ApplicationNode): string | null => {
return value === null ? null : `semantic\0${node.kind}\0${value}`;
};
/** Kinds keyed by one literal name, unless that name is a dynamic expression. */
const LITERAL_NAME_PROPERTIES: Partial<
Record<ApplicationNode["kind"], string>
> = { "ipc-channel": "channel", "url-scheme": "scheme" };
const semanticValue = (
node: ApplicationNode,
properties: Readonly<Record<string, JsonValue>>,
@@ -262,10 +267,13 @@ const semanticValue = (
properties.storage_kind,
properties.name ?? properties.mechanism,
);
if (node.kind === "ipc-channel")
return properties.resolution === "dynamic"
const literalKey = LITERAL_NAME_PROPERTIES[node.kind];
if (literalKey !== undefined)
return ["dynamic", "invalid-literal"].includes(
String(properties.resolution),
)
? null
: (exactString(properties.channel) ?? firstLabel(node));
: (exactString(properties[literalKey]) ?? firstLabel(node));
if (node.kind === "context-bridge-api")
return (
firstLabel(node) ??
@@ -282,9 +282,14 @@ const pathTo = (
};
const isTerminal = (node: ApplicationNode): boolean =>
["endpoint", "storage", "native-addon", "native-export", "unknown"].includes(
node.kind,
) ||
[
"endpoint",
"storage",
"url-scheme",
"native-addon",
"native-export",
"unknown",
].includes(node.kind) ||
(node.kind === "javascript-expression" &&
node.observations.some((o) => o.properties.step_kind === "terminal"));
@@ -334,6 +334,10 @@ const finalizeStaticAnalysis = (
accumulator.nativeAddonBindings,
accumulator.moduleRangeIndex,
),
protocols: finalizeLocatedFindings(
accumulator.protocols,
accumulator.moduleRangeIndex,
),
},
limitations,
};
@@ -49,6 +49,7 @@ export const failedJavaScriptStaticAnalysis = (): JavaScriptStaticAnalysis => ({
sender_validations: [],
utility_processes: [],
native_addon_bindings: [],
protocols: [],
},
limitations: [
"JavaScript source could not be parsed; absence of findings is not evidence of absence.",
@@ -21,6 +21,7 @@ import type {
ElectronIpcFinding,
ElectronNativeAddonBindingFinding,
ElectronSenderValidationFinding,
ElectronProtocolFinding,
ElectronUtilityProcessFinding,
} from "./electronStaticAnalysisTypes.js";
import { JavaScriptModuleRangeIndex } from "./javascriptStaticAnalysisFindings.js";
@@ -89,6 +90,7 @@ export interface JavaScriptAnalysisAccumulator {
| t.VariableDeclarator
| t.AssignmentExpression
)[];
readonly protocols: LocatedJavaScriptFinding<ElectronProtocolFinding>[];
readonly moduleRangeIndex: JavaScriptModuleRangeIndex;
/** Binding identities proven as third-parameter loaders of recovered factories. */
readonly factoryRequireBindingIds: Set<string>;
@@ -182,6 +184,7 @@ export const createJavaScriptAnalysisAccumulator = (
utilityProcesses: [],
nativeAddonBindings: [],
pendingNativeAddonBindings: [],
protocols: [],
moduleRangeIndex: new JavaScriptModuleRangeIndex(sourceLength),
factoryRequireBindingIds: new Set(),
seen: new Set(),
@@ -300,6 +300,23 @@ const electron = z
dynamic_member_access: z.boolean().optional(),
}),
),
protocols: z.array(
z
.strictObject({
...findingContext,
api: z.string(),
role: z.enum([
"os-default-client",
"privileged-scheme",
"handler",
"interceptor",
]),
scheme: staticValue,
session: z.enum(["default-protocol", "default-session"]),
enabled_privileges: strings.nullable(),
})
.transform(withRuntime),
),
})
.transform(({ call_paths, app_path_changes, ...rest }) => ({
...rest,
@@ -0,0 +1,259 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import {
analyzeJavaScriptApplication,
reconstructJavaScriptArtifact,
} from "../../../support/javascriptApplicationScope.js";
import { parseEvidence } from "../../../../src/domain/evidence.js";
import { parseJavaScriptApplicationGraph } from "../../../../src/domain/javascript/javascriptApplicationGraph.js";
import { traceApplicationFeature } from "../../../../src/domain/javascript/javascriptFeatureTrace.js";
import { applicationObligationPolicy } from "../../../../src/application/ReconstructionObligationPolicies.js";
const MAIN = String.raw`
const { app, protocol } = require("electron");
protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { standard: true, secure: true } },
]);
app.setAsDefaultProtocolClient("notes");
app.whenReady().then(() => {
protocol.handle("app", (request) => fetch(request.url));
protocol.handle(config.scheme, (request) => fetch(request.url));
});
`;
const protocolFixture = async (): Promise<string> => {
const root = await createTestTempDirectory("rea-electron-protocols-");
await Promise.all([
writeFile(
join(root, "package.json"),
JSON.stringify({ name: "rea-electron-protocols", main: "main.cjs" }),
),
writeFile(join(root, "main.cjs"), MAIN),
]);
return root;
};
describe("Electron URL scheme boundaries", () => {
it("joins case-insensitive scheme identities while preserving declared spellings", async () => {
const root = await createTestTempDirectory("rea-electron-protocol-case-");
await Promise.all([
writeFile(
join(root, "package.json"),
JSON.stringify({ name: "protocol-case", main: "main.cjs" }),
),
writeFile(
join(root, "main.cjs"),
[
'const { protocol } = require("electron");',
'protocol.registerSchemesAsPrivileged([{ scheme: "App", privileges: { secure: true } }]);',
'protocol.handle("app", () => null);',
].join("\n"),
),
]);
const result = await reconstructJavaScriptArtifact({ input_path: root });
const schemes = parseJavaScriptApplicationGraph(result.graph).nodes.filter(
({ kind }) => kind === "url-scheme",
);
expect(schemes).toHaveLength(1);
expect(
schemes[0]?.observations.map(({ properties }) => properties),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
scheme: "app",
scheme_literal: "App",
role: "privileged-scheme",
}),
expect.objectContaining({
scheme: "app",
scheme_literal: "app",
role: "handler",
}),
]),
);
});
it("projects each registered scheme into the application graph", async () => {
const result = await reconstructJavaScriptArtifact({
input_path: await protocolFixture(),
});
const graph = parseJavaScriptApplicationGraph(result.graph);
const schemes = graph.nodes.filter(({ kind }) => kind === "url-scheme");
const byLabel = new Map(
schemes.map((node) => [node.observations[0]?.label, node]),
);
expect([...byLabel.keys()].toSorted()).toEqual([
"app",
"config.scheme",
"notes",
]);
// The privileged registration and the handler describe one entry point.
expect(
byLabel
.get("app")
?.observations.map(({ properties }) => properties)
.toSorted((left, right) =>
String(left.role).localeCompare(String(right.role)),
),
).toMatchObject([
{ api: "handle", role: "handler", scheme: "app", resolution: "literal" },
{
api: "registerSchemesAsPrivileged",
role: "privileged-scheme",
enabled_privileges: ["secure", "standard"],
},
]);
expect(byLabel.get("notes")?.observations[0]?.properties).toMatchObject({
api: "setAsDefaultProtocolClient",
role: "os-default-client",
});
expect(
byLabel.get("config.scheme")?.observations[0]?.properties,
).toMatchObject({ scheme: null, resolution: "dynamic" });
const relations = graph.edges
.filter(({ target_node_id: target }) =>
schemes.some(({ node_id: id }) => id === target),
)
.map(({ relation, properties }) => [relation, properties.role])
.toSorted((left, right) =>
String(left[1]).localeCompare(String(right[1])),
);
expect(relations).toEqual([
["handles", "handler"],
["handles", "handler"],
["handles", "os-default-client"],
["exposes", "privileged-scheme"],
]);
});
it("counts every registration in the application summary", async () => {
const result = await analyzeJavaScriptApplication({
input_path: await protocolFixture(),
});
if (!result.ok) throw result.error;
expect(parseEvidence(result.value)).toMatchObject({
normalized_result: { summary: { protocol_registrations: 4 } },
});
});
it("keeps non-string and malformed literal schemes as observed, not dynamic", async () => {
const root = await createTestTempDirectory(
"rea-electron-protocol-literal-",
);
await Promise.all([
writeFile(
join(root, "package.json"),
JSON.stringify({
name: "rea-electron-protocol-literal",
main: "main.cjs",
}),
),
writeFile(
join(root, "main.cjs"),
[
'const { protocol } = require("electron");',
"protocol.handle(42, () => null);",
'protocol.handle("not a scheme", () => null);',
].join("\n"),
),
]);
const result = await reconstructJavaScriptArtifact({ input_path: root });
const observations = parseJavaScriptApplicationGraph(result.graph)
.nodes.filter(({ kind }) => kind === "url-scheme")
.map(({ observations: [first] }) => first)
.toSorted((left, right) =>
String(left?.label).localeCompare(String(right?.label)),
);
expect(observations).toMatchObject([
{
label: "42",
properties: {
scheme: null,
scheme_literal: 42,
resolution: "invalid-literal",
},
},
{
label: "not a scheme",
properties: {
scheme: null,
scheme_literal: "not a scheme",
resolution: "invalid-literal",
},
},
]);
});
});
describe("Electron URL scheme graph consumers", () => {
it("ends feature traces at schemes and gives them a reconstruction policy", async () => {
const result = await reconstructJavaScriptArtifact({
input_path: await protocolFixture(),
});
const graph = parseJavaScriptApplicationGraph(result.graph);
const schemeIds = new Set(
graph.nodes
.filter(({ kind }) => kind === "url-scheme")
.map(({ node_id: id }) => id),
);
const registering = graph.edges.find(({ target_node_id: target }) =>
schemeIds.has(target),
)?.source_node_id;
const trace = traceApplicationFeature({
sourceEvidenceId: `ev_${"0".repeat(64)}`,
graph,
nativeEvidence: [],
direction: "outgoing",
seed: {
kind: "node-id",
value: registering ?? "",
match: "exact",
case_sensitive: true,
},
});
expect(
new Set(
trace.paths
.filter(({ end_kind: kind }) => kind === "url-scheme")
.map(({ end_node_id: id }) => id),
),
).toEqual(schemeIds);
expect(applicationObligationPolicy("url-scheme")).toMatchObject({
applicationLayer: "electron",
family: "url-scheme",
fixtureAuthority: "packaged-process",
});
});
it("keeps dynamic schemes in identical files as separate occurrences", async () => {
const root = await createTestTempDirectory("rea-electron-protocol-copies-");
const source = [
'const { protocol } = require("electron");',
"protocol.handle(config.scheme, () => null);",
].join("\n");
// Byte-identical files share a digest but are separate module occurrences.
await Promise.all([
writeFile(
join(root, "package.json"),
JSON.stringify({
name: "rea-electron-protocol-copies",
main: "main.cjs",
}),
),
writeFile(join(root, "main.cjs"), source),
writeFile(join(root, "copy.cjs"), source),
]);
const result = await reconstructJavaScriptArtifact({ input_path: root });
expect(
parseJavaScriptApplicationGraph(result.graph).nodes.filter(
({ kind }) => kind === "url-scheme",
),
).toHaveLength(2);
});
});
+1
View File
@@ -17,6 +17,7 @@ export const staticCheckpointFacts = {
sender_validations: [],
utility_processes: [],
native_addon_bindings: [],
protocols: [],
},
limitations: [],
};