From 47fa6ab3795d547207001ffb01643c0aa39dcad8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Teima <35694178+UnknownHacker1@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:23:47 -0500 Subject: [PATCH] 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 <35694178+UnknownHacker1@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- .../ReconstructionObligationPolicies.ts | 6 + .../javascript/ElectronBoundaryAnalysis.ts | 4 + .../javascript/ElectronBoundaryGraph.ts | 2 + .../ElectronBoundaryGraphProtocols.ts | 129 +++++++ .../javascript/JavaScriptArtifactAnalysis.ts | 3 +- .../JavaScriptArtifactGraphBuilder.ts | 3 +- .../javascriptExportShapeComparisonExample.ts | 1 + .../javascriptRuntimeReconciliationExample.ts | 1 + .../javascript/electronStaticAnalysis.ts | 2 + .../electronStaticAnalysisProtocol.test.ts | 333 ++++++++++++++++++ .../electronStaticAnalysisProtocol.ts | 299 ++++++++++++++++ .../javascript/electronStaticAnalysisTypes.ts | 31 ++ .../javascriptApplicationAnalysis.ts | 5 +- ...javascriptApplicationAnalysisSteps.test.ts | 12 + .../javascriptApplicationGraphSchemas.ts | 1 + ...avascriptApplicationProjectionResources.ts | 3 +- .../javascriptApplicationVersionKeys.ts | 14 +- .../javascript/javascriptFeatureTrace.ts | 11 +- .../javascript/javascriptStaticAnalysis.ts | 4 + .../javascriptStaticAnalysisHelpers.ts | 1 + .../javascriptStaticAnalysisState.ts | 3 + .../JavaScriptAnalysisWorkerSchemas.ts | 17 + .../electronProtocolBoundaries.test.ts | 259 ++++++++++++++ tests/fixtures/javascriptWorkerCheckpoint.ts | 1 + 24 files changed, 1134 insertions(+), 11 deletions(-) create mode 100644 src/application/javascript/ElectronBoundaryGraphProtocols.ts create mode 100644 src/domain/javascript/electronStaticAnalysisProtocol.test.ts create mode 100644 src/domain/javascript/electronStaticAnalysisProtocol.ts create mode 100644 tests/boundary/filesystem/javascript-applications/electronProtocolBoundaries.test.ts diff --git a/src/application/ReconstructionObligationPolicies.ts b/src/application/ReconstructionObligationPolicies.ts index 09275584b6..557ae7099f 100644 --- a/src/application/ReconstructionObligationPolicies.ts +++ b/src/application/ReconstructionObligationPolicies.ts @@ -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", diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index 828c67f473..8b1b3c404f 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -220,6 +220,10 @@ export const summarizeElectronBoundaries = ( moduleKind: finding.module_kind, }), ).length, + protocol_registrations: javascript.reduce( + (count, { electron }) => count + electron.protocols.length, + 0, + ), }; }; diff --git a/src/application/javascript/ElectronBoundaryGraph.ts b/src/application/javascript/ElectronBoundaryGraph.ts index 752c3294f0..f9d6589885 100644 --- a/src/application/javascript/ElectronBoundaryGraph.ts +++ b/src/application/javascript/ElectronBoundaryGraph.ts @@ -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); }; diff --git a/src/application/javascript/ElectronBoundaryGraphProtocols.ts b/src/application/javascript/ElectronBoundaryGraphProtocols.ts new file mode 100644 index 0000000000..41b4f2a9c8 --- /dev/null +++ b/src/application/javascript/ElectronBoundaryGraphProtocols.ts @@ -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, + }); +}; diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index d459e8f041..52f7858c4e 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -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; diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index 92f8a36f85..e59e673224 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -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 [ diff --git a/src/contracts/javascript/javascriptExportShapeComparisonExample.ts b/src/contracts/javascript/javascriptExportShapeComparisonExample.ts index ddc6e47c31..a044225ceb 100644 --- a/src/contracts/javascript/javascriptExportShapeComparisonExample.ts +++ b/src/contracts/javascript/javascriptExportShapeComparisonExample.ts @@ -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, diff --git a/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts b/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts index 78dbb2c3d5..a13aea0422 100644 --- a/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts +++ b/src/contracts/javascript/javascriptRuntimeReconciliationExample.ts @@ -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, diff --git a/src/domain/javascript/electronStaticAnalysis.ts b/src/domain/javascript/electronStaticAnalysis.ts index 366aa7161b..f5699d0894 100644 --- a/src/domain/javascript/electronStaticAnalysis.ts +++ b/src/domain/javascript/electronStaticAnalysis.ts @@ -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); }; diff --git a/src/domain/javascript/electronStaticAnalysisProtocol.test.ts b/src/domain/javascript/electronStaticAnalysisProtocol.test.ts new file mode 100644 index 0000000000..5df4809b64 --- /dev/null +++ b/src/domain/javascript/electronStaticAnalysisProtocol.test.ts @@ -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"] }, + ]); + }); +}); diff --git a/src/domain/javascript/electronStaticAnalysisProtocol.ts b/src/domain/javascript/electronStaticAnalysisProtocol.ts new file mode 100644 index 0000000000..8667c9da2b --- /dev/null +++ b/src/domain/javascript/electronStaticAnalysisProtocol.ts @@ -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; diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index 241a499c26..ee08b13cb4 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -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[]; } diff --git a/src/domain/javascript/javascriptApplicationAnalysis.ts b/src/domain/javascript/javascriptApplicationAnalysis.ts index ab47db7bbe..d99ff3f5d4 100644 --- a/src/domain/javascript/javascriptApplicationAnalysis.ts +++ b/src/domain/javascript/javascriptApplicationAnalysis.ts @@ -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({ diff --git a/src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts b/src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts index d754a9897a..04ec344d45 100644 --- a/src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts +++ b/src/domain/javascript/javascriptApplicationAnalysisSteps.test.ts @@ -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"); + }); +}); diff --git a/src/domain/javascript/javascriptApplicationGraphSchemas.ts b/src/domain/javascript/javascriptApplicationGraphSchemas.ts index 7dc521194c..f97745a39b 100644 --- a/src/domain/javascript/javascriptApplicationGraphSchemas.ts +++ b/src/domain/javascript/javascriptApplicationGraphSchemas.ts @@ -37,6 +37,7 @@ export const JAVASCRIPT_APPLICATION_NODE_KINDS = [ "context-bridge-api", "ipc-channel", "ipc-handler", + "url-scheme", "worker", "service-worker", "endpoint", diff --git a/src/domain/javascript/javascriptApplicationProjectionResources.ts b/src/domain/javascript/javascriptApplicationProjectionResources.ts index 352d7fc22d..eb00d04894 100644 --- a/src/domain/javascript/javascriptApplicationProjectionResources.ts +++ b/src/domain/javascript/javascriptApplicationProjectionResources.ts @@ -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) { diff --git a/src/domain/javascript/javascriptApplicationVersionKeys.ts b/src/domain/javascript/javascriptApplicationVersionKeys.ts index 634b81b2cc..8d33a29477 100644 --- a/src/domain/javascript/javascriptApplicationVersionKeys.ts +++ b/src/domain/javascript/javascriptApplicationVersionKeys.ts @@ -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 +> = { "ipc-channel": "channel", "url-scheme": "scheme" }; + const semanticValue = ( node: ApplicationNode, properties: Readonly>, @@ -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) ?? diff --git a/src/domain/javascript/javascriptFeatureTrace.ts b/src/domain/javascript/javascriptFeatureTrace.ts index cad6890ed2..38872bf7cf 100644 --- a/src/domain/javascript/javascriptFeatureTrace.ts +++ b/src/domain/javascript/javascriptFeatureTrace.ts @@ -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")); diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 1e9598f814..770d3f4905 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -334,6 +334,10 @@ const finalizeStaticAnalysis = ( accumulator.nativeAddonBindings, accumulator.moduleRangeIndex, ), + protocols: finalizeLocatedFindings( + accumulator.protocols, + accumulator.moduleRangeIndex, + ), }, limitations, }; diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index f892d9a9b4..40a2f4166e 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -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.", diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index b9498a413a..cd6b6e4425 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -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[]; readonly moduleRangeIndex: JavaScriptModuleRangeIndex; /** Binding identities proven as third-parameter loaders of recovered factories. */ readonly factoryRequireBindingIds: Set; @@ -182,6 +184,7 @@ export const createJavaScriptAnalysisAccumulator = ( utilityProcesses: [], nativeAddonBindings: [], pendingNativeAddonBindings: [], + protocols: [], moduleRangeIndex: new JavaScriptModuleRangeIndex(sourceLength), factoryRequireBindingIds: new Set(), seen: new Set(), diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 443032cbf9..0c17677dde 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -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, diff --git a/tests/boundary/filesystem/javascript-applications/electronProtocolBoundaries.test.ts b/tests/boundary/filesystem/javascript-applications/electronProtocolBoundaries.test.ts new file mode 100644 index 0000000000..a112fd8ae3 --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/electronProtocolBoundaries.test.ts @@ -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 => { + 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); + }); +}); diff --git a/tests/fixtures/javascriptWorkerCheckpoint.ts b/tests/fixtures/javascriptWorkerCheckpoint.ts index 385af4f4b2..e247033cdc 100644 --- a/tests/fixtures/javascriptWorkerCheckpoint.ts +++ b/tests/fixtures/javascriptWorkerCheckpoint.ts @@ -17,6 +17,7 @@ export const staticCheckpointFacts = { sender_validations: [], utility_processes: [], native_addon_bindings: [], + protocols: [], }, limitations: [], };