mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const staticCheckpointFacts = {
|
||||
sender_validations: [],
|
||||
utility_processes: [],
|
||||
native_addon_bindings: [],
|
||||
protocols: [],
|
||||
},
|
||||
limitations: [],
|
||||
};
|
||||
Reference in new issue
Block a user