diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index b0b5b8b33a..426a05ae56 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -122,7 +122,7 @@ explicit path or endpoint and do not need it. pages; select one trace, stream ID and direction for contribution/range pages. Facts retain all-recorded-events-before-selection scope. Other qlog layouts and PCAP correlation are unsupported. -- User-owned Electron runtime already open: `list_electron_targets`. +- User-owned Electron runtime already open: `list_electron_targets`. Before an electron-main Inspector attempt, check packaged fuses with `inspect_electron_fuses`; disabled `EnableNodeCliInspectArguments` means the app ignores `--inspect`. - Packet-field discovery: `inspect_packet_fields` searches installed protocols and field names/types before choosing fields for `inspect_packet_capture`. Query/page the relevant records; registrations do not establish observed traffic. diff --git a/.agents/skills/reverse-engineer-anything/references/runtime-observation.md b/.agents/skills/reverse-engineer-anything/references/runtime-observation.md index ba197ecb8e..df75bcaf3d 100644 --- a/.agents/skills/reverse-engineer-anything/references/runtime-observation.md +++ b/.agents/skills/reverse-engineer-anything/references/runtime-observation.md @@ -8,9 +8,11 @@ selected page's current origin. Electron discovery lists eligible local `file://` targets without a configured file-root allowlist. Start passive browser work with `list_browser_targets`; start Electron runtime work with -`list_electron_targets`. Inspect the target selected for the user's task. Observation -with these tools is passive: never claim REA clicked, navigated, evaluated page JavaScript, -invoked IPC, or captured prior activity. REA does not contain the page's network. +`list_electron_targets`. Before an electron-main Inspector attempt, check +packaged fuses with `inspect_electron_fuses`. Inspect the target selected for +the user's task. Observation with these tools is passive: never claim REA +clicked, navigated, evaluated page JavaScript, invoked IPC, or captured prior +activity. REA does not contain the page's network. Passive captures omit raw header collections, credential header values, storage values, and raw WebSocket/JSON bodies. Selected response metadata, including diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index dd562d8137..440338f987 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -44,6 +44,7 @@ export default defineConfig({ link: "/web-network-captures", }, { text: "Electron observation", link: "/electron-observation" }, + { text: "Electron fuses", link: "/electron-fuses" }, { text: "Process capture", link: "/process-capture" }, { text: ".NET assemblies", link: "/managed-code-analysis" }, { text: "Android applications", link: "/android-analysis" }, diff --git a/docs/cli.md b/docs/cli.md index de180122b7..76dde4f4fb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -292,6 +292,7 @@ Choose the guide for the target and intended interaction: - [Browser observation](browser-observation.md): inspect a selected page through CDP. - [Browser scenarios](browser-scenario-contract.md): run declared interactions and capture their results. - [Electron observation](electron-observation.md): inspect an Electron renderer or capture an application scenario. +- [Electron fuses](electron-fuses.md): read packaged fuse bytes before expecting `--inspect` to reach an Electron main process. - [Node/Electron Inspector](javascript-runtime-observation.md): record script locations and execution contexts. - [Process capture](process-capture.md): run an executable and compare terminal, exit and filesystem observations. diff --git a/docs/electron-fuses.md b/docs/electron-fuses.md new file mode 100644 index 0000000000..206ea24bb1 --- /dev/null +++ b/docs/electron-fuses.md @@ -0,0 +1,82 @@ +# Electron fuse state + +`inspect_electron_fuses` / `inspect-electron-fuses` answers whether a packaged +Electron app will honor `--inspect` and `--inspect-brk`, `ELECTRON_RUN_AS_NODE`, +embedded ASAR integrity validation, and loading application code only from +`app.asar`. It reads the fuse wire stored in the Electron binary. It does not +launch the app, modify fuse bytes, or use the network. + +```bash +rea inspect-electron-fuses /Applications/Example.app --json +``` + +The CLI resolves a relative path against the caller's working directory. MCP +requires an absolute filesystem path and preserves that exact selection: + +```json +{ + "name": "inspect_electron_fuses", + "arguments": { "path": "/Applications/Example.app" } +} +``` + +Check this before `observe_javascript_runtime` with `electron-main`. When +`EnableNodeCliInspectArguments` is disabled or removed, Electron ignores +Inspector flags, and a later target listing can simply show no main-process +target. + +## What is read + +On macOS, pass the `.app` bundle or the Electron Framework binary. Bundle and +framework selection first tries the top-level `Electron Framework` binary, +then `Versions/Current/Electron Framework`, then +`Versions/A/Electron Framework`. Symlinks are followed only while they stay +inside that framework. +On Windows and Linux, pass the main Electron executable. The wire lives in +that executable rather than in `app.asar`. + +The inline Evidence result contains: + +- the selected path, the resolved regular file, its SHA-256, size, and container + format (`mach-o`, `mach-o-universal`, `pe`, or `elf`); +- one slice entry for a thin Mach-O, PE, or ELF file, and one entry per slice of + a universal Mach-O, with file-absolute `sentinel_offset` values; +- `wire_version`, `declared_length`, and ordered fuses `{ index, name, state, raw_byte }`; +- `sentinel_count`, including zero and more than one; +- `runtime_implications`, each marked `relationship: "derivation"` and tied to + the fuse index it came from. + +Version 1 names, in index order, are `RunAsNode`, `EnableCookieEncryption`, +`EnableNodeOptionsEnvironmentVariable`, `EnableNodeCliInspectArguments`, +`EnableEmbeddedAsarIntegrityValidation`, `OnlyLoadAppFromAsar`, +`LoadBrowserProcessSpecificV8Snapshot`, `GrantFileProtocolExtraPrivileges`, +and `WasmTrapHandlers`. An index past that table, or any index on another wire +version, keeps `name: null` and the raw byte. + +Wire bytes are `0x30` disabled, `0x31` enabled, and `0x72` removed. Any other +byte is `unknown`. A removed fuse is not enabled: Inspector flags and +`ELECTRON_RUN_AS_NODE` are not honored, and application code is not restricted +to `app.asar`. On macOS and Windows, a removed or disabled +`EnableEmbeddedAsarIntegrityValidation` byte means embedded ASAR integrity is +not enforced, and an enabled byte means it is enforced. Electron compiles that +validation only on macOS and Windows. On a Linux ELF executable the observed +byte is preserved and the enforcement outcome is `unknown`. + +## Unknown and multiple sentinels + +No sentinel means the fuse state is unknown. It does not mean the fuses are +disabled. A wire that continues past the slice is reported as truncated, with +only the fuse bytes that were present. Two or more sentinels in one slice are +all reported. REA does not pick one. + +Fuse bytes do not prove that the application enables Inspector some other way, +for example through `app.commandLine` or a remote-debugging switch in +application code. + +The selected file must be a regular file, or a bundle that resolves to one. +Symlinks are followed only inside `Electron Framework.framework`. The reader +admits at most 1 GiB, searches with 256 KiB windows, and keeps at most 32 +sentinels per slice. A real nine-fuse, one-slice result is about 1,200 CLI +tokens; a two-slice result is about 1,300 tokens. Exact size varies with path +lengths and limitations. This inspection creates no temporary files and does +not modify the selected bytes. diff --git a/docs/electron-observation.md b/docs/electron-observation.md index eb98a41f35..e702978d94 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -7,6 +7,10 @@ This passive runtime surface is distinct from the target-free static workflow. Static analysis reads a local ASAR or extracted directory without executing application code. Passive observation inspects an existing Electron page. The results keep static inferences and runtime observations distinct. +Packaged fuse state is a separate static read: +[`inspect_electron_fuses`](electron-fuses.md) reports whether this binary will +honor Inspector flags, `ELECTRON_RUN_AS_NODE`, embedded ASAR integrity, and +`app.asar`-only loading. Use the separate [`reconcile_javascript_runtime`](javascript-runtime-reconciliation.md) workflow when both Evidence sets already exist. diff --git a/docs/index.md b/docs/index.md index 9e0912dd10..5acee1b55c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,7 +52,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). | Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | | Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [JEB](jeb-analysis.md) | | Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | -| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md) | +| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) | | Inspect assemblies, packages, or firmware | [.NET](managed-code-analysis.md), [Android](android-analysis.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) | | Observe or compare a browser scenario | [CDP observation](browser-observation.md), [scenarios](browser-scenario-contract.md), [runtime evidence](web-runtime.md), [historical captures](web-network-captures.md) | | Trace captured website source | [Source locations](web-source-location.md), [module traces](website-module-trace.md), [script export](website-script-export.md) | diff --git a/docs/javascript-runtime-observation.md b/docs/javascript-runtime-observation.md index 9a0e90b1fd..439572eba0 100644 --- a/docs/javascript-runtime-observation.md +++ b/docs/javascript-runtime-observation.md @@ -36,6 +36,12 @@ application starts, and REA never resumes it or sends `Runtime.runIfWaitingForDebugger`. Resume a paused target with a separate caller-owned debugger before requesting application runtime observations. +For a packaged Electron app, read +[`inspect_electron_fuses`](electron-fuses.md) before starting an `electron-main` +target. When `EnableNodeCliInspectArguments` is disabled or removed, Electron +ignores `--inspect` and `--inspect-brk`. A missing main-process target after +launch does not, by itself, show that fuse. + The caller supplies the literal-loopback HTTP endpoint directly. Selecting an endpoint exposes all targets it serves and their local script locations or renderer origins. A target must match the exact requested target ID and expose a same-port WebSocket reported by that endpoint. Targets explicitly marked attached are rejected rather than displacing another debugger. Node's `/json/list` omits attachment state, so REA preserves it as `null` (unknown), reports that limitation, and permits observation unless the endpoint explicitly reports `attached: true`. Local paths are canonicalized after symlink resolution. The `attached` field in JavaScript runtime and browser target results is nullable: `true` and `false` are reported only when discovery supplied a boolean; `null` means the endpoint omitted or could not report the state. CDP `TargetInfo` worker records require an `attached` boolean; REA excludes a worker record with a missing or malformed value as an invalid protocol value. diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 48f90c6e7a..64296b3ceb 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -167,6 +167,7 @@ export const providerCatalog = (sources) => { reconciliationContracts, observationContracts, activeContracts, + fuseContracts, } = electronContractSlices(sources); return [ { @@ -261,6 +262,10 @@ export const providerCatalog = (sources) => { identity: sources.rustProvider.RUST_BINARY_PROVIDER_IDENTITY, contracts: sources.rustContracts.RUST_TOOL_CONTRACTS, }, + { + identity: sources.electronFuseProvider.ELECTRON_FUSE_PROVIDER_IDENTITY, + contracts: fuseContracts, + }, { identity: sources.sqliteDomain.SQLITE_PROVIDER_IDENTITY, contracts: sources.sqliteContracts.SQLITE_TOOL_CONTRACTS, @@ -425,17 +430,23 @@ const electronContractSlices = (sources) => { ({ name }) => name !== "analyze_javascript_application" && name !== "reconcile_javascript_runtime" && - name !== "capture_electron_scenario", + name !== "capture_electron_scenario" && + name !== "inspect_electron_fuses", ); const activeContracts = sources.electronContracts.ELECTRON_TOOL_CONTRACTS.filter( ({ name }) => name === "capture_electron_scenario", ); + const fuseContracts = + sources.electronContracts.ELECTRON_TOOL_CONTRACTS.filter( + ({ name }) => name === "inspect_electron_fuses", + ); if ( applicationContracts.length !== 1 || reconciliationContracts.length !== 1 || observationContracts.length !== 2 || - activeContracts.length !== 1 + activeContracts.length !== 1 || + fuseContracts.length !== 1 ) throw new Error("Electron provider capability ownership drifted"); return { @@ -443,5 +454,6 @@ const electronContractSlices = (sources) => { reconciliationContracts, observationContracts, activeContracts, + fuseContracts, }; }; diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index 262493b731..2cdfb64d20 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -96,6 +96,7 @@ const SOURCE_PATHS = { webNetworkCaptureProvider: "dist/browser/history/CaptureRelease.js", browserScenarioContracts: "dist/contracts/browserScenarioToolContracts.js", electronContracts: "dist/contracts/javascript/electronToolContracts.js", + electronFuseProvider: "dist/electron/ElectronFuseProvider.js", javascriptRuntimeObservationContracts: "dist/contracts/javascript/javascriptRuntimeObservationToolContracts.js", applicationContracts: "dist/contracts/applicationToolContracts.js", diff --git a/scripts/lib/test-projects.mjs b/scripts/lib/test-projects.mjs index 68fb90dbd0..f284625cc4 100644 --- a/scripts/lib/test-projects.mjs +++ b/scripts/lib/test-projects.mjs @@ -17,7 +17,7 @@ export const SOURCE_TEST_PROJECTS = [ group: "source", include: [ "src/*.test.ts", - "src/{apktool,jeb,adb,artifacts,browser,dotnet,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,rust,server,wasm,windows}/**/*.test.ts", + "src/{apktool,jeb,adb,artifacts,browser,dotnet,electron,evm,firmware,flutter,pcap,ghidra,go,hopper,ida,inspector,javascript,native,process,reference,rust,server,wasm,windows}/**/*.test.ts", ], }, { diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 029b233577..5f3fba6a6f 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -344,7 +344,11 @@ const workflowAvailabilityFor = ({ kind, policy, }: AvailabilityContext): Availability | null => { - if (name === "inspect_go_binary" || name === "inspect_rust_binary") + if ( + name === "inspect_go_binary" || + name === "inspect_rust_binary" || + name === "inspect_electron_fuses" + ) return { reason: "available", remediation: null }; if (name === "inspect_sqlite_database") { const capability = sqliteDatabaseCapability(process.versions.node); diff --git a/src/application/electron/ElectronFusePort.ts b/src/application/electron/ElectronFusePort.ts new file mode 100644 index 0000000000..06354f2c5d --- /dev/null +++ b/src/application/electron/ElectronFusePort.ts @@ -0,0 +1,19 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + ElectronFuseReport, + InspectElectronFusesInput, +} from "../../domain/electron/electronFuses.js"; +import type { Result } from "../../domain/result.js"; + +/** Replaceable read-only Electron fuse reader with no target-execution authority. */ +export interface ElectronFusePort { + readonly identity: ProviderIdentity; + inspect( + input: InspectElectronFusesInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/electron/ElectronFuseService.ts b/src/application/electron/ElectronFuseService.ts new file mode 100644 index 0000000000..9719a6fe20 --- /dev/null +++ b/src/application/electron/ElectronFuseService.ts @@ -0,0 +1,116 @@ +import { isAbsolute } from "node:path"; + +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { ElectronFusePort } from "./ElectronFusePort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, +} from "../../domain/analysisErrorCore.js"; +import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js"; +import { + electronFuseReportSchema, + inspectElectronFusesInputSchema, +} from "../../domain/electron/electronFuses.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +const OPERATION = "inspect_electron_fuses"; + +/** Shared exact input validation and artifact-bound Evidence for CLI and MCP. */ +export class ElectronFuseService { + constructor(readonly provider: ElectronFusePort) {} + + /** Read fuse wires for one explicitly selected path without launching it. */ + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const input = inspectElectronFusesInputSchema.safeParse(rawInput); + if (!input.success) + return err( + analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { + cause: input.error, + }), + ); + if (!isAbsolute(input.data.path)) + return err( + new AnalysisInputError(OPERATION, undefined, [ + { + path: ["path"], + reason: "invalid_format", + message: "Expected an absolute filesystem path on this host.", + }, + ]), + ); + const inspected = await this.provider.inspect(input.data, options); + if (!inspected.ok) return inspected; + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const report = electronFuseReportSchema.safeParse(inspected.value); + if ( + !report.success || + report.data.artifact.selected_path !== input.data.path + ) + return err( + new AnalysisOutputError( + OPERATION, + "Electron fuse reader returned a malformed report or changed the selected artifact identity.", + ), + ); + const value = report.data; + const architecture = subjectArchitecture(value); + return ok( + createEvidence( + { + path: value.artifact.resolved_path, + sha256: value.artifact.sha256, + format: value.artifact.format, + ...(architecture === undefined ? {} : { architecture }), + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.data, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: locationsFor(value), + }, + ), + ); + } +} + +const subjectArchitecture = ( + report: ReturnType, +) => { + if (report.artifact.format === "mach-o-universal") return undefined; + if (report.slices.length !== 1) return undefined; + const name = report.slices[0]?.architecture; + return BINARY_ARCHITECTURES.find((supported) => supported === name); +}; + +const locationsFor = ( + report: ReturnType, +) => [ + { kind: "artifact-path" as const, path: report.artifact.selected_path }, + ...(report.artifact.resolved_path === report.artifact.selected_path + ? [] + : [ + { + kind: "artifact-path" as const, + path: report.artifact.resolved_path, + }, + ]), + ...report.slices.flatMap((slice) => + slice.sentinels.map((sentinel) => ({ + kind: "file-offset" as const, + offset: sentinel.sentinel_offset, + })), + ), +]; diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index 3f558930b8..37ee3e70b6 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -1,3 +1,5 @@ +import { resolve } from "node:path"; + import { Cli, z } from "incur"; import { logCliCommand } from "../cliLogging.js"; @@ -12,6 +14,7 @@ import { projectInputIssues } from "../domain/inputIssueProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { Logger } from "pino"; import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { createElectronFuseService } from "../composition/electronFuses.js"; import { parseCliJsonInput, resolveCliJsonPaths } from "../cliJsonInput.js"; import { electronPageInspectionOptions, @@ -27,11 +30,40 @@ export const registerElectronCommands = ( environment: Readonly>, ): void => { registerElectronObservationCommands(cli, logger); + registerElectronFuseCommand(cli, logger); registerElectronActiveCommand(cli, logger, environment); registerJavaScriptApplicationCommand(cli, logger); registerJavaScriptRuntimeReconciliationCommand(cli, logger); }; +const registerElectronFuseCommand = ( + cli: ReturnType, + logger: Logger, +): void => { + const service = createElectronFuseService(); + cli.command(CLI_COMMANDS.inspectElectronFuses, { + description: + "Inspect packaged Electron fuse bytes without launching the application", + args: z.object({ + path: z + .string() + .describe( + "Local Electron .app, Electron Framework binary, PE executable, or ELF executable", + ), + }), + run: ({ args }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectElectronFuses, async () => { + const result = await service.inspect( + { path: resolve(args.path) }, + { signal }, + ); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); +}; + const registerElectronActiveCommand = ( cli: ReturnType, logger: Logger, diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 798451158f..8bbca596b0 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -138,6 +138,7 @@ export const CLI_COMMANDS = Object.freeze({ captureBrowserScenario: "capture-browser-scenario", listElectronTargets: "list-electron-targets", inspectElectronPage: "inspect-electron-page", + inspectElectronFuses: "inspect-electron-fuses", captureElectronScenario: "capture-electron-scenario", listJavaScriptRuntimeTargets: "list-javascript-runtime-targets", observeJavaScriptRuntime: "observe-javascript-runtime", diff --git a/src/composition/electronFuses.ts b/src/composition/electronFuses.ts new file mode 100644 index 0000000000..f74c436c01 --- /dev/null +++ b/src/composition/electronFuses.ts @@ -0,0 +1,6 @@ +import { ElectronFuseService } from "../application/electron/ElectronFuseService.js"; +import { ElectronFuseProvider } from "../electron/ElectronFuseProvider.js"; + +/** Compose read-only fuse inspection without a runtime, network, or fuse edit. */ +export const createElectronFuseService = (): ElectronFuseService => + new ElectronFuseService(new ElectronFuseProvider()); diff --git a/src/contracts/javascript/electronToolContracts.ts b/src/contracts/javascript/electronToolContracts.ts index f7a9ed732f..293b20b687 100644 --- a/src/contracts/javascript/electronToolContracts.ts +++ b/src/contracts/javascript/electronToolContracts.ts @@ -1,6 +1,8 @@ import type { ToolContract } from "../toolContractTypes.js"; import { toolContractMetadata } from "../toolEffects.js"; +import { electronFuseOutputSchemas } from "../toolOutputSchemaGroups.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +import { inspectElectronFusesInputSchema } from "../../domain/electron/electronFuses.js"; import { electronPageInspectionSchema, electronTargetListSchema, @@ -147,4 +149,19 @@ export const ELECTRON_TOOL_CONTRACTS = [ { title: "Exercise an owned Electron application", input: activeExample }, ], }, + { + name: "inspect_electron_fuses", + ...toolContractMetadata("inspect_electron_fuses"), + kind: "artifact-provider", + description: + "Inspect packaged Electron fuse bytes in one local macOS app bundle, Electron Framework binary, Windows PE executable, or Linux ELF executable. Returns the artifact digest, per-slice wire version, fuse index, version-1 name, state, and raw byte, plus derived implications for Inspector flags, ELECTRON_RUN_AS_NODE, embedded ASAR integrity, and app.asar-only loading. A missing sentinel is unknown fuse state. Reads artifact bytes only and does not modify the selected file or open a runtime target.", + inputSchema: inspectElectronFusesInputSchema, + outputSchema: electronFuseOutputSchemas.inspect_electron_fuses, + examples: [ + { + title: "Read packaged Electron fuse state", + input: { path: "/Applications/Example.app" }, + }, + ], + }, ] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index a010467be7..7fe152e9bd 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -648,6 +648,7 @@ export const TOOL_EFFECTS: Readonly> = { list_javascript_runtime_targets: browserEvidence, observe_javascript_runtime: browserEvidence, analyze_javascript_application: evidence, + inspect_electron_fuses: evidence, export_web_scripts: effects({ mutatesSession: true, writesFilesystem: true, diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index bbadef367a..9263e5ba4f 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -88,6 +88,7 @@ import { } from "./toolOutputSchemaPrimitives.js"; import { processCaptureSchema } from "../domain/process/processCapture.js"; +import { electronFuseReportSchema } from "../domain/electron/electronFuses.js"; const contextFacetSchema = z.discriminatedUnion("state", [ z.object({ state: z.literal("available"), value: jsonValueSchema }), z.object({ @@ -584,3 +585,8 @@ export const sessionOutputSchemas = { }), ), } satisfies Readonly>; + +/** Exact Evidence schema for read-only Electron fuse inspection. */ +export const electronFuseOutputSchemas = { + inspect_electron_fuses: evidenceResultOf(electronFuseReportSchema), +}; diff --git a/src/domain/electron/electronFuses.test.ts b/src/domain/electron/electronFuses.test.ts new file mode 100644 index 0000000000..c074ae1763 --- /dev/null +++ b/src/domain/electron/electronFuses.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "vitest"; + +import { + describeElectronFuseFindings, + electronFuseName, + electronFuseReportSchema, + electronFuseState, + ELECTRON_FUSE_SENTINEL, + ELECTRON_FUSE_STATE_BYTES, + ELECTRON_FUSE_V1_NAMES, + type ElectronFuseSlice, +} from "./electronFuses.js"; + +const artifact = { + selected_path: "/artifacts/Electron", + resolved_path: "/artifacts/Electron", + sha256: "a".repeat(64), + bytes: 128, + format: "mach-o" as const, +}; + +const sentinel = ( + offset: number, + fuses: readonly number[], + options?: { + readonly version?: number | null; + readonly declared?: number | null; + readonly complete?: boolean; + }, +) => ({ + sentinel_offset: offset, + wire_version: options?.version === undefined ? 1 : options.version, + declared_length: + options?.declared === undefined ? fuses.length : options.declared, + fuses: fuses.map((raw, index) => ({ + index, + name: electronFuseName( + options?.version === undefined ? 1 : (options.version ?? 1), + index, + ), + state: electronFuseState(raw), + raw_byte: raw, + })), + complete: options?.complete ?? true, +}); + +const slice = ( + sentinels: ReturnType[], + index = 0, +): ElectronFuseSlice => ({ + index, + file_offset: 0, + size: 128, + architecture: "arm64", + sentinel_count: sentinels.length, + sentinels, +}); + +it("validates implications across repeated FAT slices and rejects a false claim", () => { + const slices = Array.from({ length: 128 }, (_, index) => ({ + ...slice( + Array.from({ length: 32 }, (_, offset) => + sentinel( + offset * 64, + Array.from({ length: 8 }, () => ELECTRON_FUSE_STATE_BYTES.enabled), + ), + ), + index, + ), + size: 4096, + })); + const report = { + artifact: { ...artifact, bytes: 4096, format: "mach-o-universal" as const }, + slices, + ...describeElectronFuseFindings(slices, "mach-o-universal"), + }; + expect(electronFuseReportSchema.safeParse(report).success).toBe(true); + const last = report.runtime_implications.at(-1); + expect(last).toBeDefined(); + expect( + electronFuseReportSchema.safeParse({ + ...report, + runtime_implications: [{ ...last, outcome: "unknown" }], + }).success, + ).toBe(false); +}); + +describe("Electron fuse wire decoding", () => { + it("keeps the 32-byte sentinel and version-1 names pinned", () => { + expect(ELECTRON_FUSE_SENTINEL).toHaveLength(32); + expect(ELECTRON_FUSE_V1_NAMES[3]).toBe("EnableNodeCliInspectArguments"); + expect(electronFuseName(1, 8)).toBe("WasmTrapHandlers"); + expect(electronFuseName(1, 9)).toBeNull(); + expect(electronFuseName(2, 0)).toBeNull(); + }); + + it("preserves disabled, enabled, removed, and unknown bytes", () => { + expect(electronFuseState(ELECTRON_FUSE_STATE_BYTES.disabled)).toBe( + "disabled", + ); + expect(electronFuseState(ELECTRON_FUSE_STATE_BYTES.enabled)).toBe( + "enabled", + ); + expect(electronFuseState(ELECTRON_FUSE_STATE_BYTES.removed)).toBe( + "removed", + ); + expect(electronFuseState(0x99)).toBe("unknown"); + }); + + it("derives runtime implications from the fuse index and leaves unknowns unknown", () => { + const fuses = [ + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.enabled, + 0x99, + ELECTRON_FUSE_STATE_BYTES.removed, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.enabled, + ]; + const report = electronFuseReportSchema.parse({ + artifact, + slices: [slice([sentinel(8, fuses)])], + ...describeElectronFuseFindings([slice([sentinel(8, fuses)])], "mach-o"), + }); + expect(report.runtime_implications).toEqual([ + expect.objectContaining({ + subject: "node_cli_inspect_arguments", + fuse_index: 3, + outcome: "not_honored", + relationship: "derivation", + }), + expect.objectContaining({ + subject: "electron_run_as_node", + fuse_index: 0, + outcome: "not_honored", + }), + expect.objectContaining({ + subject: "embedded_asar_integrity_validation", + fuse_index: 4, + outcome: "not_enforced", + }), + expect.objectContaining({ + subject: "only_load_app_from_asar", + fuse_index: 5, + outcome: "restricted_to_app_asar", + }), + ]); + expect(report.slices[0] !== undefined && "fuses" in report.slices[0]).toBe( + false, + ); + expect(report.slices[0]?.sentinels[0]?.fuses[2]).toMatchObject({ + index: 2, + name: "EnableNodeOptionsEnvironmentVariable", + state: "unknown", + raw_byte: 0x99, + }); + }); + + it("does not treat a missing sentinel or a short wire as disabled fuses", () => { + const missing = describeElectronFuseFindings([slice([])], "mach-o"); + expect(missing.runtime_implications).toEqual([]); + expect(missing.limitations.join(" ")).toContain( + "unknown, not that every fuse is disabled", + ); + expect(missing.limitations.join(" ")).toContain( + "fuse state for that slice is unknown, not disabled", + ); + const truncated = describeElectronFuseFindings( + [ + slice([ + sentinel(8, [ELECTRON_FUSE_STATE_BYTES.enabled], { + declared: 4, + complete: false, + }), + ]), + ], + "mach-o", + ); + expect(truncated.runtime_implications).toEqual([ + expect.objectContaining({ + subject: "electron_run_as_node", + outcome: "honored", + }), + ]); + expect(truncated.limitations.join(" ")).toContain("truncated"); + expect(truncated.limitations.join(" ")).toContain("fuse index 3"); + }); + + it("reports every sentinel and skips implications for an unpinned wire version", () => { + const findings = describeElectronFuseFindings( + [ + slice([ + sentinel(8, [ELECTRON_FUSE_STATE_BYTES.enabled], { version: 2 }), + sentinel( + 40, + Array.from({ length: 6 }, () => ELECTRON_FUSE_STATE_BYTES.disabled), + ), + ]), + ], + "mach-o", + ); + expect(findings.runtime_implications).toHaveLength(4); + expect(findings.limitations.join(" ")).toContain("none is selected"); + expect(findings.limitations.join(" ")).toContain("wire version 2"); + expect( + findings.runtime_implications.every( + (item) => item.sentinel_offset === 40, + ), + ).toBe(true); + }); +}); + +describe("ELF embedded ASAR integrity applicability", () => { + it("does not claim ASAR integrity enforcement for an enabled fuse on ELF", () => { + const fuses = [ + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.enabled, + ]; + const slices = [slice([sentinel(4, fuses)])]; + const elf = describeElectronFuseFindings(slices, "elf"); + expect(slices[0]?.sentinels[0]?.fuses[4]).toMatchObject({ + index: 4, + name: "EnableEmbeddedAsarIntegrityValidation", + state: "enabled", + raw_byte: ELECTRON_FUSE_STATE_BYTES.enabled, + }); + expect( + elf.runtime_implications.find( + (item) => item.subject === "embedded_asar_integrity_validation", + ), + ).toMatchObject({ + relationship: "derivation", + fuse_index: 4, + outcome: "unknown", + }); + expect(elf.limitations.join(" ")).toContain("only on macOS and Windows"); + expect( + describeElectronFuseFindings(slices, "mach-o").runtime_implications.find( + (item) => item.subject === "embedded_asar_integrity_validation", + ), + ).toMatchObject({ outcome: "enforced" }); + expect( + describeElectronFuseFindings(slices, "pe").runtime_implications.find( + (item) => item.subject === "embedded_asar_integrity_validation", + ), + ).toMatchObject({ outcome: "enforced" }); + expect( + describeElectronFuseFindings( + slices, + "mach-o-universal", + ).runtime_implications.find( + (item) => item.subject === "embedded_asar_integrity_validation", + ), + ).toMatchObject({ outcome: "enforced" }); + }); +}); + +it("rejects claimed wire bytes outside their slice and unsupported derivations", () => { + const slices = [slice([sentinel(4, [ELECTRON_FUSE_STATE_BYTES.disabled])])]; + const findings = describeElectronFuseFindings(slices, "mach-o"); + const report = { artifact, slices, ...findings }; + expect(electronFuseReportSchema.safeParse(report).success).toBe(true); + expect( + electronFuseReportSchema.safeParse({ + ...report, + artifact: { ...artifact, bytes: 36 }, + slices: [{ ...slices[0], size: 36 }], + }).success, + ).toBe(false); + expect( + electronFuseReportSchema.safeParse({ + ...report, + runtime_implications: findings.runtime_implications.map((fact) => ({ + ...fact, + outcome: "honored", + })), + }).success, + ).toBe(false); + expect( + electronFuseReportSchema.safeParse({ + ...report, + runtime_implications: [ + { + relationship: "derivation", + subject: "node_cli_inspect_arguments", + fuse_index: 3, + outcome: "honored", + slice_index: 0, + sentinel_offset: 4, + }, + ], + }).success, + ).toBe(false); +}); diff --git a/src/domain/electron/electronFuses.ts b/src/domain/electron/electronFuses.ts new file mode 100644 index 0000000000..ffacd44819 --- /dev/null +++ b/src/domain/electron/electronFuses.ts @@ -0,0 +1,477 @@ +import { z } from "zod"; + +import { digestSchema } from "../digests.js"; +import { localPathStringSchema } from "../localPath.js"; + +/** ASCII sentinel that introduces an Electron fuse wire in an Electron binary. */ +export const ELECTRON_FUSE_SENTINEL = "dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX"; + +/** Wire bytes from the Electron fuse wire: disabled, enabled, and removed. */ +export const ELECTRON_FUSE_STATE_BYTES = { + disabled: 0x30, + enabled: 0x31, + removed: 0x72, +} as const; + +/** + * FuseV1 index order. An index past this table, or any index on another wire + * version, keeps a null name and its raw byte. + */ +export const ELECTRON_FUSE_V1_NAMES = [ + "RunAsNode", + "EnableCookieEncryption", + "EnableNodeOptionsEnvironmentVariable", + "EnableNodeCliInspectArguments", + "EnableEmbeddedAsarIntegrityValidation", + "OnlyLoadAppFromAsar", + "LoadBrowserProcessSpecificV8Snapshot", + "GrantFileProtocolExtraPrivileges", + "WasmTrapHandlers", +] as const; + +const FUSE_IMPLICATIONS = [ + { + subject: "node_cli_inspect_arguments", + fuseIndex: 3, + enabled: "honored", + inactive: "not_honored", + }, + { + subject: "electron_run_as_node", + fuseIndex: 0, + enabled: "honored", + inactive: "not_honored", + }, + { + subject: "embedded_asar_integrity_validation", + fuseIndex: 4, + enabled: "enforced", + inactive: "not_enforced", + }, + { + subject: "only_load_app_from_asar", + fuseIndex: 5, + enabled: "restricted_to_app_asar", + inactive: "not_restricted_to_app_asar", + }, +] as const; + +const OTHER_MEANS_LIMITATION = + "Fuse state does not prove the application enables Inspector through other means, such as app.commandLine or a remote-debugging switch in application code."; +const MISSING_SENTINEL_LIMITATION = + "A missing sentinel means fuse state is unknown, not that every fuse is disabled."; + +const byteSchema = z.number().int().min(0).max(255); +const fuseStateSchema = z.enum(["enabled", "disabled", "removed", "unknown"]); + +/** One fuse byte, with a version-1 name when the pinned table covers its index. */ +export const electronFuseSchema = z.strictObject({ + index: z.number().int().nonnegative(), + name: z.string().min(1).nullable(), + state: fuseStateSchema, + raw_byte: byteSchema, +}); + +const electronFuseSentinelSchema = z.strictObject({ + sentinel_offset: z.number().int().nonnegative(), + wire_version: byteSchema.nullable(), + declared_length: byteSchema.nullable(), + fuses: z.array(electronFuseSchema), + complete: z.boolean(), +}); + +const electronFuseSliceSchema = z.strictObject({ + index: z.number().int().nonnegative(), + file_offset: z.number().int().nonnegative(), + size: z.number().int().nonnegative(), + architecture: z.string().min(1).nullable(), + sentinel_count: z.number().int().nonnegative(), + sentinels: z.array(electronFuseSentinelSchema), +}); + +const implicationLocation = { + relationship: z.literal("derivation"), + slice_index: z.number().int().nonnegative(), + sentinel_offset: z.number().int().nonnegative(), +}; + +/** Derived runtime meaning of one observed fuse byte. The fuse bytes stay observed. */ +export const electronFuseImplicationSchema = z.discriminatedUnion("subject", [ + z.strictObject({ + ...implicationLocation, + subject: z.literal("node_cli_inspect_arguments"), + fuse_index: z.literal(3), + outcome: z.enum(["honored", "not_honored", "unknown"]), + }), + z.strictObject({ + ...implicationLocation, + subject: z.literal("electron_run_as_node"), + fuse_index: z.literal(0), + outcome: z.enum(["honored", "not_honored", "unknown"]), + }), + z.strictObject({ + ...implicationLocation, + subject: z.literal("embedded_asar_integrity_validation"), + fuse_index: z.literal(4), + outcome: z.enum(["enforced", "not_enforced", "unknown"]), + }), + z.strictObject({ + ...implicationLocation, + subject: z.literal("only_load_app_from_asar"), + fuse_index: z.literal(5), + outcome: z.enum([ + "restricted_to_app_asar", + "not_restricted_to_app_asar", + "unknown", + ]), + }), +]); + +/** Inline fuse observation for one selected app bundle or Electron executable. */ +export const electronFuseReportSchema = z + .strictObject({ + artifact: z + .strictObject({ + selected_path: localPathStringSchema, + resolved_path: localPathStringSchema, + sha256: digestSchema, + bytes: z.number().int().nonnegative(), + format: z.enum(["mach-o", "mach-o-universal", "pe", "elf"]), + }) + .describe( + "Caller-selected path, resolved regular file, and the container that was read", + ), + slices: z + .array(electronFuseSliceSchema) + .describe( + "One entry per universal Mach-O slice, or a single entry for a thin Mach-O, PE, or ELF file. Sentinel offsets are file-absolute.", + ), + runtime_implications: z + .array(electronFuseImplicationSchema) + .describe( + "Derivations from version-1 fuse bytes. Each entry names the fuse index it came from.", + ), + limitations: z.array(z.string().min(1)), + }) + .superRefine((report, context) => { + validateReportBounds(report, context); + }); + +export const inspectElectronFusesInputSchema = z.strictObject({ + path: localPathStringSchema.describe( + "Absolute filesystem path to a macOS .app bundle, Electron Framework binary, Windows PE executable, or Linux ELF executable", + ), +}); + +export type ElectronFuseState = z.infer; +export type ElectronFuseSentinel = z.infer; +export type ElectronFuseSlice = z.infer; +export type ElectronFuseImplication = z.infer< + typeof electronFuseImplicationSchema +>; +export type ElectronFuseReport = z.infer; +export type InspectElectronFusesInput = z.infer< + typeof inspectElectronFusesInputSchema +>; + +/** Decode one fuse wire byte. Any other value stays unknown and keeps the raw byte. */ +export const electronFuseState = (byte: number): ElectronFuseState => { + if (byte === ELECTRON_FUSE_STATE_BYTES.disabled) return "disabled"; + if (byte === ELECTRON_FUSE_STATE_BYTES.enabled) return "enabled"; + if (byte === ELECTRON_FUSE_STATE_BYTES.removed) return "removed"; + return "unknown"; +}; + +/** Pinned version-1 name, or null when this wire version or index has no table entry. */ +export const electronFuseName = ( + wireVersion: number, + index: number, +): string | null => + wireVersion === 1 ? (ELECTRON_FUSE_V1_NAMES[index] ?? null) : null; + +/** + * Attach derivations and reading limitations without choosing among multiple + * sentinels. Embedded ASAR integrity is compiled only for Mach-O and PE, so an + * ELF byte stays observed and its enforcement outcome stays unknown. + */ +export const describeElectronFuseFindings = ( + slices: readonly ElectronFuseSlice[], + container: ElectronFuseReport["artifact"]["format"], +): { + readonly runtime_implications: ElectronFuseImplication[]; + readonly limitations: string[]; +} => { + const runtime_implications: ElectronFuseImplication[] = []; + const limitations = [OTHER_MEANS_LIMITATION, MISSING_SENTINEL_LIMITATION]; + for (const slice of slices) { + if (slice.sentinel_count === 0) + limitations.push( + `Slice ${slice.index} at file offset ${slice.file_offset} contains no Electron fuse sentinel; fuse state for that slice is unknown, not disabled.`, + ); + else if (slice.sentinel_count > 1) + limitations.push( + `Slice ${slice.index} at file offset ${slice.file_offset} contains ${slice.sentinel_count} Electron fuse sentinels; every sentinel is reported and none is selected.`, + ); + for (const sentinel of slice.sentinels) + describeSentinel( + slice.index, + sentinel, + container, + runtime_implications, + limitations, + ); + } + return { runtime_implications, limitations }; +}; + +/** Electron compiles this validation for macOS (Mach-O) and Windows (PE) only. */ +const embeddedAsarIntegrityApplies = ( + container: ElectronFuseReport["artifact"]["format"], +): boolean => + container === "mach-o" || + container === "mach-o-universal" || + container === "pe"; + +const describeSentinel = ( + sliceIndex: number, + sentinel: ElectronFuseSentinel, + container: ElectronFuseReport["artifact"]["format"], + implications: ElectronFuseImplication[], + limitations: string[], +): void => { + if (!sentinel.complete) + limitations.push( + `Electron fuse wire at file offset ${sentinel.sentinel_offset} is truncated; fuse bytes past the slice stay unknown.`, + ); + if (sentinel.wire_version === null) return; + if (sentinel.wire_version !== 1) { + limitations.push( + `Electron fuse wire at file offset ${sentinel.sentinel_offset} uses wire version ${sentinel.wire_version}; version 1 is the only pinned name table, so runtime implications for this wire are unknown.`, + ); + return; + } + for (const spec of FUSE_IMPLICATIONS) { + const fuse = sentinel.fuses.find((item) => item.index === spec.fuseIndex); + if (fuse === undefined) { + limitations.push( + `Electron fuse wire at file offset ${sentinel.sentinel_offset} does not include fuse index ${spec.fuseIndex}; that runtime implication is unknown.`, + ); + continue; + } + const applicabilityUnknown = + spec.subject === "embedded_asar_integrity_validation" && + !embeddedAsarIntegrityApplies(container); + if (applicabilityUnknown) + limitations.push( + `Electron compiles embedded ASAR integrity validation only on macOS and Windows; the observed fuse byte at file offset ${sentinel.sentinel_offset} does not establish enforcement.`, + ); + implications.push( + implicationFor(spec, applicabilityUnknown ? "unknown" : fuse.state, { + slice_index: sliceIndex, + sentinel_offset: sentinel.sentinel_offset, + }), + ); + } +}; + +const implicationFor = ( + spec: (typeof FUSE_IMPLICATIONS)[number], + state: ElectronFuseState, + location: { + readonly slice_index: number; + readonly sentinel_offset: number; + }, +): ElectronFuseImplication => { + const outcome = + state === "enabled" + ? spec.enabled + : state === "unknown" + ? "unknown" + : spec.inactive; + return { + relationship: "derivation", + subject: spec.subject, + fuse_index: spec.fuseIndex, + outcome, + ...location, + } as ElectronFuseImplication; +}; + +const validateReportBounds = ( + report: ElectronFuseReport, + context: z.RefinementCtx, +): void => { + const size = report.artifact.bytes; + for (const [sliceIndex, slice] of report.slices.entries()) { + if (slice.index !== sliceIndex) + context.addIssue({ + code: "custom", + path: ["slices", sliceIndex, "index"], + message: "Slice index does not match its position in the report.", + }); + if (slice.sentinel_count !== slice.sentinels.length) + context.addIssue({ + code: "custom", + path: ["slices", sliceIndex, "sentinel_count"], + message: "Sentinel count does not match the reported sentinels.", + }); + if (slice.file_offset > size || slice.size > size - slice.file_offset) + context.addIssue({ + code: "custom", + path: ["slices", sliceIndex], + message: "Slice range extends outside the selected artifact.", + }); + for (const [sentinelIndex, sentinel] of slice.sentinels.entries()) + validateSentinel(slice, sentinel, sliceIndex, sentinelIndex, context); + } + const expected = new Set( + describeElectronFuseFindings( + report.slices, + report.artifact.format, + ).runtime_implications.map(implicationKey), + ); + for (const [index, implication] of report.runtime_implications.entries()) { + if (!expected.has(implicationKey(implication))) + context.addIssue({ + code: "custom", + path: ["runtime_implications", index], + message: + "Runtime implication is not supported by the reported fuse bytes and applicability.", + }); + const slice = report.slices[implication.slice_index]; + if ( + slice?.sentinels.some( + (sentinel) => sentinel.sentinel_offset === implication.sentinel_offset, + ) !== true + ) + context.addIssue({ + code: "custom", + path: ["runtime_implications", index], + message: "Runtime implication does not reference a reported sentinel.", + }); + } +}; + +const implicationKey = (fact: ElectronFuseImplication): string => + JSON.stringify([ + fact.slice_index, + fact.sentinel_offset, + fact.subject, + fact.fuse_index, + fact.outcome, + ]); + +const validateSentinel = ( + slice: ElectronFuseSlice, + sentinel: ElectronFuseSentinel, + sliceIndex: number, + sentinelIndex: number, + context: z.RefinementCtx, +): void => { + const sentinelEnd = sentinel.sentinel_offset + ELECTRON_FUSE_SENTINEL.length; + const wireEnd = + sentinelEnd + + (sentinel.wire_version === null ? 0 : 1) + + (sentinel.declared_length === null ? 0 : 1) + + sentinel.fuses.length; + if (wireEnd > slice.file_offset + slice.size) + context.addIssue({ + code: "custom", + path: ["slices", sliceIndex, "sentinels", sentinelIndex], + message: "Reported wire header or fuse bytes extend outside the slice.", + }); + if ( + sentinel.sentinel_offset < slice.file_offset || + sentinelEnd > slice.file_offset + slice.size + ) + context.addIssue({ + code: "custom", + path: [ + "slices", + sliceIndex, + "sentinels", + sentinelIndex, + "sentinel_offset", + ], + message: "Sentinel is outside its slice.", + }); + if (sentinel.wire_version === null && sentinel.declared_length !== null) + context.addIssue({ + code: "custom", + path: [ + "slices", + sliceIndex, + "sentinels", + sentinelIndex, + "declared_length", + ], + message: "A wire without a version byte cannot declare a fuse count.", + }); + if ( + sentinel.complete && + (sentinel.declared_length === null || + sentinel.fuses.length !== sentinel.declared_length) + ) + context.addIssue({ + code: "custom", + path: ["slices", sliceIndex, "sentinels", sentinelIndex, "complete"], + message: "A complete wire must contain every declared fuse byte.", + }); + if ( + !sentinel.complete && + sentinel.declared_length !== null && + sentinel.fuses.length >= sentinel.declared_length + ) + context.addIssue({ + code: "custom", + path: ["slices", sliceIndex, "sentinels", sentinelIndex, "complete"], + message: "An incomplete wire must omit at least one declared fuse byte.", + }); + for (const [fuseIndex, fuse] of sentinel.fuses.entries()) { + if (fuse.index !== fuseIndex) + context.addIssue({ + code: "custom", + path: [ + "slices", + sliceIndex, + "sentinels", + sentinelIndex, + "fuses", + fuseIndex, + "index", + ], + message: "Fuse index does not match its wire position.", + }); + if (fuse.state !== electronFuseState(fuse.raw_byte)) + context.addIssue({ + code: "custom", + path: [ + "slices", + sliceIndex, + "sentinels", + sentinelIndex, + "fuses", + fuseIndex, + "state", + ], + message: "Fuse state does not match its raw byte.", + }); + if ( + sentinel.wire_version !== null && + fuse.name !== electronFuseName(sentinel.wire_version, fuse.index) + ) + context.addIssue({ + code: "custom", + path: [ + "slices", + sliceIndex, + "sentinels", + sentinelIndex, + "fuses", + fuseIndex, + "name", + ], + message: "Fuse name does not match the pinned version-1 table.", + }); + } +}; diff --git a/src/electron/ElectronFusePath.ts b/src/electron/ElectronFusePath.ts new file mode 100644 index 0000000000..399e001f51 --- /dev/null +++ b/src/electron/ElectronFusePath.ts @@ -0,0 +1,307 @@ +import type { BigIntStats } from "node:fs"; +import { lstat, readlink } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, sep } from "node:path"; + +import { isPathWithinRoot } from "../domain/localPath.js"; + +const FRAMEWORK_NAME = "Electron Framework.framework"; +const BINARY_NAME = "Electron Framework"; +const MAX_SYMLINK_HOPS = 32; + +/** Filesystem facts owned by path admission and consumed unchanged by the reader. */ +export interface ElectronFusePathNode { + readonly path: string; + readonly metadata: BigIntStats; +} +export interface ResolvedElectronFuseFile { + readonly path: string; + readonly metadata: BigIntStats; + readonly nodes: readonly ElectronFusePathNode[]; +} +export interface ResolvedElectronFuseTarget { + readonly selectedPath: string; + readonly file: ResolvedElectronFuseFile; +} + +/** A selected path is not an admitted Electron bundle or regular file. */ +export class ElectronFusePathFailure extends Error { + constructor( + readonly reason: "invalid_value" | "invalid_format", + message: string, + ) { + super(message); + this.name = "ElectronFusePathFailure"; + } +} + +type WalkResult = + | ({ readonly kind: "file" } & ResolvedElectronFuseFile) + | { readonly kind: "absent" | "escapes" | "not-file" | "loop" }; + +/** + * Admit a regular executable, a macOS app bundle, or an Electron Framework + * binary reached through `Versions/Current` without leaving the framework. + */ +export const resolveElectronFuseTarget = async ( + selectedPath: string, + signal?: AbortSignal, +): Promise => { + signal?.throwIfAborted(); + const metadata = await lstat(selectedPath, { bigint: true }); + let file: ResolvedElectronFuseFile; + if (metadata.isSymbolicLink()) + file = await resolveFrameworkSymlink(selectedPath, signal); + else if (metadata.isFile()) + file = + frameworkRootOf(selectedPath) === null + ? { path: selectedPath, metadata, nodes: [] } + : await resolveFrameworkSymlink(selectedPath, signal); + else if (metadata.isDirectory()) + file = await resolveBundleBinary(selectedPath, signal); + else + throw new ElectronFusePathFailure( + "invalid_format", + `Expected a regular file or Electron app bundle: ${selectedPath}`, + ); + return { selectedPath, file }; +}; + +const resolveFrameworkSymlink = async ( + selectedPath: string, + signal?: AbortSignal, +): Promise => { + const root = frameworkRootOf(selectedPath); + if (root === null) + throw new ElectronFusePathFailure( + "invalid_format", + `Expected a regular file without a symlink: ${selectedPath}`, + ); + const nodes = await requireFrameworkRoot(root, selectedPath); + const relativePath = relativeInside(root, selectedPath); + if (relativePath === null) + throw new ElectronFusePathFailure( + "invalid_value", + `Framework symlink escapes ${FRAMEWORK_NAME}: ${selectedPath}`, + ); + return requireFile( + await resolveInside(root, relativePath, signal), + selectedPath, + nodes, + ); +}; + +const resolveBundleBinary = async ( + selectedPath: string, + signal?: AbortSignal, +): Promise => { + const name = basename(selectedPath); + if (name === FRAMEWORK_NAME) + return resolveKnownBinary(selectedPath, selectedPath, signal); + if (name.endsWith(".app")) { + return resolveKnownBinary( + join(selectedPath, "Contents", "Frameworks", FRAMEWORK_NAME), + selectedPath, + signal, + ); + } + throw new ElectronFusePathFailure( + "invalid_format", + `Expected an Electron .app bundle, ${FRAMEWORK_NAME} directory, or executable file: ${selectedPath}`, + ); +}; + +/** `root` is the framework directory for both an `.app` and a framework selection. */ +const resolveKnownBinary = async ( + root: string, + bundle: string, + signal: AbortSignal | undefined, +): Promise => { + const nodes = await requireFrameworkRoot(root, bundle); + for (const candidate of [ + BINARY_NAME, + `Versions/Current/${BINARY_NAME}`, + `Versions/A/${BINARY_NAME}`, + ]) { + const selected = await resolveInside(root, candidate, signal); + if (selected.kind === "file") return requireFile(selected, bundle, nodes); + if (selected.kind !== "absent") + throw pathWalkFailure(selected.kind, bundle); + } + throw new ElectronFusePathFailure( + "invalid_value", + `Electron Framework binary was not found inside ${bundle}.`, + ); +}; + +const resolveInside = async ( + root: string, + relativePath: string, + signal?: AbortSignal, +): Promise => { + const pending = relativePath.split("/").filter((segment) => segment !== ""); + const resolved: string[] = []; + const nodes: ElectronFusePathNode[] = []; + let fileMetadata: BigIntStats | undefined; + let hops = 0; + let last: "file" | "directory" | undefined; + while (pending.length > 0) { + signal?.throwIfAborted(); + const segment = pending.shift() ?? ""; + if (segment === ".") continue; + if (segment === "..") { + if (resolved.length === 0) return { kind: "escapes" }; + resolved.pop(); + last = "directory"; + continue; + } + const absolute = join(root, ...resolved, segment); + if (!isPathWithinRoot(root, absolute)) return { kind: "escapes" }; + let metadata; + try { + metadata = await lstat(absolute, { bigint: true }); + } catch (cause: unknown) { + if (isMissing(cause)) return { kind: "absent" }; + throw cause; + } + if (metadata.isSymbolicLink()) { + nodes.push({ path: absolute, metadata }); + hops += 1; + if (hops > MAX_SYMLINK_HOPS) return { kind: "loop" }; + const target = await readlink(absolute); + if (isAbsolute(target)) return { kind: "escapes" }; + pending.unshift(...target.split("/").filter((part) => part !== "")); + continue; + } + if (metadata.isDirectory()) { + nodes.push({ path: absolute, metadata }); + resolved.push(segment); + last = "directory"; + continue; + } + if (!metadata.isFile() || pending.length > 0) return { kind: "not-file" }; + resolved.push(segment); + fileMetadata = metadata; + last = "file"; + } + if (last !== "file" || fileMetadata === undefined) + return { kind: "not-file" }; + const path = join(root, ...resolved); + return isPathWithinRoot(root, path) + ? { kind: "file", path, metadata: fileMetadata, nodes } + : { kind: "escapes" }; +}; + +/** Validate the app-parent chain equally for app, framework, and binary selection. */ +const requireFrameworkRoot = async ( + root: string, + selectedPath: string, +): Promise => { + const frameworks = dirname(root); + const contents = dirname(frameworks); + const app = dirname(contents); + if ( + basename(frameworks) === "Frameworks" && + basename(contents) === "Contents" && + basename(app).endsWith(".app") + ) + return requireRealDirectories( + dirname(app), + [basename(app), "Contents", "Frameworks", FRAMEWORK_NAME], + selectedPath, + ); + else + return requireRealDirectories( + dirname(root), + [basename(root)], + selectedPath, + ); +}; + +/** + * Each named directory below `anchor` must be a real directory, so the + * framework root cannot redirect the walk outside the selected bundle. + */ +const requireRealDirectories = async ( + anchor: string, + segments: readonly string[], + selectedPath: string, +): Promise => { + let current = anchor; + const nodes: ElectronFusePathNode[] = []; + for (const segment of segments) { + current = join(current, segment); + let metadata; + try { + metadata = await lstat(current, { bigint: true }); + } catch (cause: unknown) { + if (isMissing(cause)) + throw new ElectronFusePathFailure( + "invalid_value", + `Electron Framework binary was not found inside ${selectedPath}.`, + ); + throw cause; + } + nodes.push({ path: current, metadata }); + if (metadata.isSymbolicLink()) + throw pathWalkFailure("escapes", selectedPath); + if (!metadata.isDirectory()) + throw pathWalkFailure("not-file", selectedPath); + } + return nodes; +}; + +const requireFile = ( + walk: WalkResult, + selectedPath: string, + nodes: readonly ElectronFusePathNode[], +): ResolvedElectronFuseFile => { + if (walk.kind === "file") + return { + path: walk.path, + metadata: walk.metadata, + nodes: [...nodes, ...walk.nodes], + }; + if (walk.kind === "absent") + throw new ElectronFusePathFailure( + "invalid_value", + `Electron Framework symlink target was not found: ${selectedPath}`, + ); + throw pathWalkFailure(walk.kind, selectedPath); +}; + +const pathWalkFailure = ( + kind: "escapes" | "not-file" | "loop", + path: string, +): ElectronFusePathFailure => + new ElectronFusePathFailure( + "invalid_value", + kind === "escapes" + ? `Framework symlink escapes ${FRAMEWORK_NAME}: ${path}` + : kind === "loop" + ? `Framework symlink hop limit exceeded: ${path}` + : `Electron Framework path is not a regular file: ${path}`, + ); + +const frameworkRootOf = (path: string): string | null => { + let current = dirname(path); + while (basename(current) !== FRAMEWORK_NAME) { + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return current; +}; + +const relativeInside = (root: string, path: string): string | null => { + // Keep dot segments until resolveInside has followed preceding symlinks. + // Lexical normalization would select a different file for link/../ paths. + const prefix = root.endsWith(sep) ? root : `${root}${sep}`; + if (!path.startsWith(prefix)) return null; + const value = path.slice(prefix.length); + return value === "" ? null : value.split(sep).join("/"); +}; + +const isMissing = (cause: unknown): boolean => + cause instanceof Error && + "code" in cause && + ["ENOENT", "ENOTDIR"].includes(String(cause.code)); diff --git a/src/electron/ElectronFuseProvider.test.ts b/src/electron/ElectronFuseProvider.test.ts new file mode 100644 index 0000000000..fa43f4787d --- /dev/null +++ b/src/electron/ElectronFuseProvider.test.ts @@ -0,0 +1,684 @@ +import { resolveElectronFuseTarget } from "./ElectronFusePath.js"; +import { RegularFileChangedError } from "../filesystem/RegularFile.js"; +import { mkdir, symlink, writeFile, truncate, rename } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { + elfFuseImage, + fuseWire, + FUSE_SENTINEL, + OBSIDIAN_FUSE_BYTES, + peFuseImage, + sentinelFreeMacho, + thinFuseImage, + thinMacho, + truncatedFuseImage, + twoSentinelElf, + universalFuseImage, +} from "../../tests/fixtures/electron/fuseImage.js"; +import { ELECTRON_FUSE_STATE_BYTES } from "../domain/electron/electronFuses.js"; +import { ElectronFuseProvider } from "./ElectronFuseProvider.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { readElectronFuseImage } from "./ElectronFuseReader.js"; + +const provider = new ElectronFuseProvider(); + +const inspect = async (path: string) => { + const result = await provider.inspect({ path }); + if (!result.ok) throw result.error; + return result.value; +}; + +describe("Electron fuse wire reading", () => { + it("reads thin Mach-O, universal Mach-O, PE, and ELF wires", async () => { + const root = await createTestTempDirectory("rea-electron-fuses-"); + const thin = thinFuseImage(OBSIDIAN_FUSE_BYTES); + const universal = universalFuseImage(); + const pe = peFuseImage(OBSIDIAN_FUSE_BYTES); + const elf = elfFuseImage([ + ELECTRON_FUSE_STATE_BYTES.enabled, + ELECTRON_FUSE_STATE_BYTES.removed, + 0x99, + ]); + await writeFile(join(root, "thin"), thin.bytes); + await writeFile(join(root, "universal"), universal.bytes); + await writeFile(join(root, "app.exe"), pe.bytes); + await writeFile(join(root, "app.elf"), elf.bytes); + + const thinReport = await inspect(join(root, "thin")); + expect(thinReport.artifact).toMatchObject({ + format: "mach-o", + bytes: thin.bytes.length, + }); + expect(thinReport.slices).toMatchObject([ + { + architecture: "arm64", + sentinel_count: 1, + sentinels: [ + { + sentinel_offset: thin.sentinelOffset, + wire_version: 1, + declared_length: 8, + complete: true, + }, + ], + }, + ]); + expect( + thinReport.slices[0]?.sentinels[0]?.fuses.map((fuse) => fuse.state), + ).toEqual([ + "disabled", + "enabled", + "disabled", + "disabled", + "disabled", + "disabled", + "disabled", + "enabled", + ]); + + const universalReport = await inspect(join(root, "universal")); + expect(universalReport.artifact.format).toBe("mach-o-universal"); + expect(universalReport.slices).toMatchObject([ + { + file_offset: universal.left.fileOffset, + architecture: "x86_64", + sentinels: [{ sentinel_offset: universal.left.sentinelOffset }], + }, + { + file_offset: universal.right.fileOffset, + architecture: "arm64", + sentinels: [{ sentinel_offset: universal.right.sentinelOffset }], + }, + ]); + expect( + universalReport.runtime_implications.filter( + (item) => item.subject === "node_cli_inspect_arguments", + ), + ).toEqual([ + expect.objectContaining({ + slice_index: 0, + outcome: "not_honored", + sentinel_offset: universal.left.sentinelOffset, + }), + expect.objectContaining({ + slice_index: 1, + outcome: "honored", + sentinel_offset: universal.right.sentinelOffset, + }), + ]); + + const peReport = await inspect(join(root, "app.exe")); + expect(peReport.artifact.format).toBe("pe"); + expect(peReport.slices[0]?.sentinels[0]?.sentinel_offset).toBe( + pe.sentinelOffset, + ); + const elfReport = await inspect(join(root, "app.elf")); + expect(elfReport.slices[0]?.sentinels[0]?.fuses).toMatchObject([ + { name: "RunAsNode", state: "enabled", raw_byte: 0x31 }, + { name: "EnableCookieEncryption", state: "removed", raw_byte: 0x72 }, + { + name: "EnableNodeOptionsEnvironmentVariable", + state: "unknown", + raw_byte: 0x99, + }, + ]); + expect(elfReport.runtime_implications).toEqual([ + expect.objectContaining({ + subject: "electron_run_as_node", + outcome: "honored", + }), + ]); + }); + + it("keeps a truncated wire, duplicate sentinels, and a missing sentinel", async () => { + const root = await createTestTempDirectory("rea-electron-fuses-partial-"); + await writeFile(join(root, "truncated"), truncatedFuseImage()); + const duplicated = twoSentinelElf(); + await writeFile(join(root, "duplicated"), duplicated.bytes); + await writeFile(join(root, "empty"), sentinelFreeMacho()); + + const truncated = await inspect(join(root, "truncated")); + expect(truncated.slices[0]?.sentinels[0]).toMatchObject({ + wire_version: 1, + declared_length: 4, + complete: false, + fuses: [{ index: 0, state: "enabled", raw_byte: 0x31 }], + }); + expect(truncated.limitations.join(" ")).toContain("truncated"); + + const duplicates = await inspect(join(root, "duplicated")); + expect(duplicates.slices[0]?.sentinel_count).toBe(2); + expect( + duplicates.slices[0]?.sentinels.map((item) => item.sentinel_offset), + ).toEqual([...duplicated.offsets]); + expect(duplicates.limitations.join(" ")).toContain("none is selected"); + + const missing = await inspect(join(root, "empty")); + expect(missing.slices[0]?.sentinel_count).toBe(0); + expect(missing.runtime_implications).toEqual([]); + expect(missing.limitations.join(" ")).toContain("unknown, not disabled"); + }); +}); + +describe("ELF embedded ASAR integrity applicability", () => { + it("preserves an enabled ASAR integrity fuse without claiming enforcement on ELF", async () => { + const root = await createTestTempDirectory("rea-electron-fuses-elf-asar-"); + const fuses = [ + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.enabled, + ]; + const image = elfFuseImage(fuses); + const path = join(root, "electron"); + await writeFile(path, image.bytes); + const report = await inspect(path); + expect(report.artifact.format).toBe("elf"); + expect(report.slices[0]?.sentinels[0]?.fuses[4]).toMatchObject({ + index: 4, + name: "EnableEmbeddedAsarIntegrityValidation", + state: "enabled", + raw_byte: ELECTRON_FUSE_STATE_BYTES.enabled, + }); + expect( + report.runtime_implications.find( + (item) => item.subject === "embedded_asar_integrity_validation", + ), + ).toMatchObject({ + relationship: "derivation", + fuse_index: 4, + outcome: "unknown", + sentinel_offset: image.sentinelOffset, + }); + expect(report.limitations.join(" ")).toContain("only on macOS and Windows"); + }); +}); + +describe("Electron fuse reader resource boundaries", () => { + it("rejects invalid scan window sizes before allocating or scanning", async () => { + const root = await createTestTempDirectory( + "rea-electron-fuses-window-limit-", + ); + const path = join(root, "window"); + await writeFile(path, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes); + for (const chunkBytes of [0, -1, 1.5, 256 * 1024 + 1]) + await expect(readElectronFuseImage(path, { chunkBytes })).rejects.toThrow( + /chunkBytes must be an integer between/, + ); + }); + + it("rejects overlapping universal Mach-O slices before repeated searching", async () => { + const root = await createTestTempDirectory("rea-electron-fuses-overlap-"); + const image = universalFuseImage().bytes; + const firstOffset = image.readUInt32BE(16); + const firstSize = image.readUInt32BE(20); + image.writeUInt32BE(0x01000007, 28); + image.writeUInt32BE(firstOffset, 36); + image.writeUInt32BE(firstSize, 40); + const path = join(root, "overlapping"); + await writeFile(path, image); + + const result = await provider.inspect({ path }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + expect.objectContaining({ + path: ["path"], + reason: "invalid_format", + message: expect.stringContaining("slices 0 and 1 overlap"), + }), + ], + }); + }); +}); + +describe("Electron fuse path and container admission", () => { + it("finds a sentinel that crosses a read window", async () => { + const root = await createTestTempDirectory("rea-electron-fuses-window-"); + const image = thinFuseImage(OBSIDIAN_FUSE_BYTES); + const path = join(root, "window"); + await writeFile(path, image.bytes); + const read = await readElectronFuseImage(path, { chunkBytes: 20 }); + expect(read.slices[0]?.sentinels[0]?.sentinel_offset).toBe( + image.sentinelOffset, + ); + expect(read.slices[0]?.sentinels[0]?.fuses).toHaveLength(8); + }); + + it("resolves an app bundle and a framework symlink inside the bundle", async () => { + const root = await createTestTempDirectory("rea-electron-fuses-app-"); + const image = thinFuseImage(OBSIDIAN_FUSE_BYTES); + const app = join(root, "Fixture.app"); + const versions = join( + app, + "Contents", + "Frameworks", + "Electron Framework.framework", + "Versions", + ); + await mkdir(join(versions, "A"), { recursive: true }); + const binary = join(versions, "A", "Electron Framework"); + await writeFile(binary, image.bytes); + await symlink("A", join(versions, "Current")); + await symlink( + "Versions/Current/Electron Framework", + join(versions, "..", "Electron Framework"), + ); + + const fromApp = await inspect(app); + expect(fromApp.artifact.selected_path).toBe(app); + expect(fromApp.artifact.resolved_path).toBe(binary); + expect(fromApp.slices[0]?.sentinel_count).toBe(1); + + const link = join(versions, "..", "Electron Framework"); + const fromLink = await inspect(link); + expect(fromLink.artifact.resolved_path).toBe(binary); + expect(fromLink.artifact.sha256).toBe(fromApp.artifact.sha256); + }); + + it("rejects an app bundle whose Versions/Current symlink leaves the framework", async () => { + const root = await createTestTempDirectory( + "rea-electron-fuses-framework-root-", + ); + const app = join(root, "Fixture.app"); + const framework = join( + app, + "Contents", + "Frameworks", + "Electron Framework.framework", + ); + await mkdir(join(framework, "Versions"), { recursive: true }); + await mkdir(join(app, "Contents", "Other"), { recursive: true }); + await writeFile( + join(app, "Contents", "Other", "Electron Framework"), + elfFuseImage([ELECTRON_FUSE_STATE_BYTES.enabled]).bytes, + ); + await symlink("../../../Other", join(framework, "Versions", "Current")); + + for (const path of [app, framework]) { + const result = await provider.inspect({ path }); + expect(result.ok, path).toBe(false); + if (result.ok) continue; + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + expect.objectContaining({ + path: ["path"], + message: expect.stringContaining("escapes"), + }), + ], + }); + } + }); + + it("refuses a framework symlink that leaves the bundle", async () => { + const root = await createTestTempDirectory("rea-electron-fuses-escape-"); + const versions = join( + root, + "Fixture.app", + "Contents", + "Frameworks", + "Electron Framework.framework", + "Versions", + ); + await mkdir(versions, { recursive: true }); + await symlink("/etc", join(versions, "Current")); + const result = await provider.inspect({ + path: join(root, "Fixture.app"), + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + expect.objectContaining({ + path: ["path"], + message: expect.stringContaining("escapes"), + }), + ], + }); + }); + + it("rejects an unrecognized container and a big-endian Mach-O image", async () => { + const root = await createTestTempDirectory("rea-electron-fuses-reject-"); + const text = join(root, "notes.txt"); + const bigEndian = join(root, "big"); + const java = join(root, "Main.class"); + await writeFile(text, "This is not an executable container."); + const header = Buffer.alloc(32); + header.writeUInt32BE(0xfeedfacf, 0); + await writeFile(bigEndian, header); + await writeFile( + java, + Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00, 0x00, 0x37]), + ); + for (const path of [text, bigEndian, java]) { + const result = await provider.inspect({ path }); + expect(result.ok, path).toBe(false); + if (!result.ok) + expect(result.error._tag).toBe("AnalysisUnsupportedTargetError"); + } + }); + + it("does not invent a sentinel from bytes that only straddle two slices", async () => { + const root = await createTestTempDirectory("rea-electron-fuses-span-"); + const left = thinMacho(0x01000007, Buffer.alloc(16)); + const right = thinMacho(0x0100000c, Buffer.alloc(16)); + const overhang = 10; + FUSE_SENTINEL.copy(left, left.length - overhang); + const gap = Buffer.alloc(FUSE_SENTINEL.length - overhang); + FUSE_SENTINEL.copy(gap, 0, overhang); + const tableEnd = 48; + const rightOffset = tableEnd + left.length + gap.length; + const table = Buffer.alloc(tableEnd); + table.writeUInt32BE(0xcafebabe, 0); + table.writeUInt32BE(2, 4); + table.writeUInt32BE(0x01000007, 8); + table.writeUInt32BE(tableEnd, 16); + table.writeUInt32BE(left.length, 20); + table.writeUInt32BE(0x0100000c, 28); + table.writeUInt32BE(rightOffset, 36); + table.writeUInt32BE(right.length, 40); + const path = join(root, "split"); + await writeFile(path, Buffer.concat([table, left, gap, right])); + const report = await inspect(path); + expect(report.slices.map((item) => item.sentinel_count)).toEqual([0, 0]); + }); +}); + +describe("direct Electron Framework binary selection", () => { + const frameworkFixture = async (prefix: string) => { + const root = await createTestTempDirectory(prefix); + const app = join(root, "Fixture.app"); + const framework = join( + app, + "Contents", + "Frameworks", + "Electron Framework.framework", + ); + await mkdir(join(framework, "Versions"), { recursive: true }); + return { app, framework }; + }; + + it("resolves a binary selected through Versions/Current to its target", async () => { + const { framework } = await frameworkFixture("rea-electron-fuses-direct-"); + await mkdir(join(framework, "Versions", "A"), { recursive: true }); + const binary = join(framework, "Versions", "A", "Electron Framework"); + await writeFile(binary, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes); + await symlink("A", join(framework, "Versions", "Current")); + + const selected = join( + framework, + "Versions", + "Current", + "Electron Framework", + ); + const report = await inspect(selected); + expect(report.artifact.selected_path).toBe(selected); + expect(report.artifact.resolved_path).toBe(binary); + }); + + it("follows symlinks before parent segments in a selected binary path", async () => { + const { framework } = await frameworkFixture("rea-electron-fuses-parent-"); + const versions = join(framework, "Versions"); + await mkdir(join(versions, "nested", "B"), { recursive: true }); + await mkdir(join(versions, "nested", "A"), { recursive: true }); + await mkdir(join(versions, "A"), { recursive: true }); + const binary = join(versions, "nested", "A", "Electron Framework"); + await writeFile(binary, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes); + await writeFile( + join(versions, "A", "Electron Framework"), + sentinelFreeMacho(), + ); + await symlink("nested/B", join(versions, "Current")); + const selected = `${versions}/Current/../A/Electron Framework`; + const report = await inspect(selected); + expect(report.artifact.selected_path).toBe(selected); + expect(report.artifact.resolved_path).toBe(binary); + expect(report.slices[0]?.sentinel_count).toBe(1); + }); + + it("rejects a binary selected through a Versions/Current link that leaves the framework", async () => { + const { app, framework } = await frameworkFixture( + "rea-electron-fuses-direct-escape-", + ); + await mkdir(join(app, "Contents", "Other"), { recursive: true }); + await writeFile( + join(app, "Contents", "Other", "Electron Framework"), + elfFuseImage([ELECTRON_FUSE_STATE_BYTES.enabled]).bytes, + ); + await symlink("../../../Other", join(framework, "Versions", "Current")); + + const result = await provider.inspect({ + path: join(framework, "Versions", "Current", "Electron Framework"), + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + expect.objectContaining({ + path: ["path"], + message: expect.stringContaining("escapes"), + }), + ], + }); + }); + + it("rejects a framework directory that is a symlink outside the bundle", async () => { + const root = await createTestTempDirectory( + "rea-electron-fuses-framework-link-", + ); + const outside = join(root, "outside"); + await mkdir(join(outside, "Versions", "A"), { recursive: true }); + await writeFile( + join(outside, "Versions", "A", "Electron Framework"), + thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes, + ); + const app = join(root, "Example.app"); + const frameworks = join(app, "Contents", "Frameworks"); + await mkdir(frameworks, { recursive: true }); + const framework = join(frameworks, "Electron Framework.framework"); + await symlink(outside, framework); + + for (const path of [ + app, + join(framework, "Versions", "A", "Electron Framework"), + ]) { + const result = await provider.inspect({ path }); + expect(result.ok, path).toBe(false); + if (result.ok) continue; + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + issues: [ + expect.objectContaining({ + path: ["path"], + message: expect.stringContaining("escapes"), + }), + ], + }); + } + }); +}); + +it.each(["Contents", "Frameworks"])( + "rejects a redirected %s for every app selection form", + async (segment) => { + const root = await createTestTempDirectory("rea-electron-parent-link-"); + const app = join(root, "Fixture.app"); + const outside = join(root, "outside"); + const actualFramework = join( + outside, + ...(segment === "Contents" ? ["Frameworks"] : []), + "Electron Framework.framework", + ); + await mkdir(join(actualFramework, "Versions", "A"), { recursive: true }); + await writeFile( + join(actualFramework, "Versions", "A", "Electron Framework"), + thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes, + ); + await symlink("A", join(actualFramework, "Versions", "Current")); + await mkdir(segment === "Contents" ? app : join(app, "Contents"), { + recursive: true, + }); + await symlink( + outside, + join( + app, + "Contents", + ...(segment === "Frameworks" ? ["Frameworks"] : []), + ), + ); + const framework = join( + app, + "Contents", + "Frameworks", + "Electron Framework.framework", + ); + for (const path of [ + app, + framework, + join(framework, "Versions", "Current", "Electron Framework"), + ]) { + const result = await provider.inspect({ path }); + expect(result.ok, path).toBe(false); + if (!result.ok) expect(result.error._tag).toBe("AnalysisInputError"); + } + }, +); + +it("enforces the 32 sentinel boundary with typed and public limit details", async () => { + const root = await createTestTempDirectory("rea-electron-sentinel-limit-"); + const wire = Buffer.concat([FUSE_SENTINEL, fuseWire(1, [0x31])]); + for (const count of [32, 33]) { + const path = join(root, String(count)); + await writeFile( + path, + thinMacho( + 0x0100000c, + Buffer.concat(Array.from({ length: count }, () => wire)), + ), + ); + const result = await provider.inspect({ path }); + if (count === 32) { + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.slices[0]?.sentinel_count).toBe(32); + } else { + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "resource_constraint", + details: { reported_limits: { maximum_sentinels_per_slice: 32 } }, + }); + } + } + } +}); + +it("rejects a sparse artifact over 1 GiB before scanning", async () => { + const root = await createTestTempDirectory("rea-electron-input-limit-"); + const path = join(root, "large"); + await writeFile(path, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes); + await truncate(path, 1024 * 1024 * 1024 + 1); + const result = await provider.inspect({ path }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisResourceConstraintError"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "resource_constraint", + details: { reported_limits: { maximum_input_bytes: 1024 * 1024 * 1024 } }, + }); + } +}); + +it("follows the active framework binary while an inactive A version remains", async () => { + const root = await createTestTempDirectory("rea-electron-current-version-"); + const app = join(root, "Fixture.app"); + const framework = join( + app, + "Contents", + "Frameworks", + "Electron Framework.framework", + ); + for (const [version, state] of [ + ["A", 0x30], + ["B", 0x31], + ] as const) { + await mkdir(join(framework, "Versions", version), { recursive: true }); + await writeFile( + join(framework, "Versions", version, "Electron Framework"), + thinFuseImage([state]).bytes, + ); + } + await symlink("B", join(framework, "Versions", "Current")); + await symlink( + "Versions/Current/Electron Framework", + join(framework, "Electron Framework"), + ); + for (const path of [app, framework, join(framework, "Electron Framework")]) { + const report = await inspect(path); + expect(report.artifact.resolved_path).toBe( + join(framework, "Versions", "B", "Electron Framework"), + ); + expect(report.slices[0]?.sentinels[0]?.fuses[0]?.state).toBe("enabled"); + } +}); + +it("rejects a checked version directory swapped before the reader opens it", async () => { + const root = await createTestTempDirectory("rea-electron-directory-swap-"); + const app = join(root, "Fixture.app"); + const framework = join( + app, + "Contents", + "Frameworks", + "Electron Framework.framework", + ); + const version = join(framework, "Versions", "A"); + const outside = join(root, "outside"); + await mkdir(version, { recursive: true }); + await mkdir(outside); + await writeFile( + join(version, "Electron Framework"), + thinFuseImage([0x30]).bytes, + ); + await writeFile( + join(outside, "Electron Framework"), + thinFuseImage([0x31]).bytes, + ); + const located = await resolveElectronFuseTarget(app); + await rename(version, `${version}.saved`); + await symlink(outside, version); + await expect( + readElectronFuseImage(located.file.path, { selection: located.file }), + ).rejects.toBeInstanceOf(RegularFileChangedError); +}); + +it("reports a checked version directory removed before open as changed", async () => { + const root = await createTestTempDirectory("rea-electron-directory-missing-"); + const app = join(root, "Fixture.app"); + const version = join( + app, + "Contents", + "Frameworks", + "Electron Framework.framework", + "Versions", + "A", + ); + await mkdir(version, { recursive: true }); + await writeFile( + join(version, "Electron Framework"), + thinFuseImage([0x30]).bytes, + ); + const located = await resolveElectronFuseTarget(app); + await rename(version, `${version}.saved`); + await expect( + readElectronFuseImage(located.file.path, { selection: located.file }), + ).rejects.toBeInstanceOf(RegularFileChangedError); +}); diff --git a/src/electron/ElectronFuseProvider.ts b/src/electron/ElectronFuseProvider.ts new file mode 100644 index 0000000000..d05baff43d --- /dev/null +++ b/src/electron/ElectronFuseProvider.ts @@ -0,0 +1,170 @@ +import type { ExecutionOptions } from "../application/AnalysisProvider.js"; +import type { ElectronFusePort } from "../application/electron/ElectronFusePort.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import { + describeElectronFuseFindings, + electronFuseReportSchema, + type ElectronFuseReport, + type InspectElectronFusesInput, +} from "../domain/electron/electronFuses.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { ZodError } from "zod"; +import { RegularFileChangedError } from "../filesystem/RegularFile.js"; +import { NonRegularFileReadError } from "../filesystem/RegularFile.js"; +import { + ElectronFusePathFailure, + resolveElectronFuseTarget, +} from "./ElectronFusePath.js"; +import { + electronFuseReadLimitation, + ElectronFuseFormatFailure, + ElectronFuseResourceFailure, + readElectronFuseImage, +} from "./ElectronFuseReader.js"; + +/** Bundled fuse-wire reader. It does not flip fuses or launch the application. */ +export const ELECTRON_FUSE_PROVIDER_IDENTITY = { + id: "electron-fuses", + name: "REA Electron fuse reader", + version: "1", +} as const; + +const OPERATION = "inspect_electron_fuses"; + +/** Read packaged Electron fuse wires from one selected local file or app bundle. */ +export class ElectronFuseProvider implements ElectronFusePort { + readonly identity = ELECTRON_FUSE_PROVIDER_IDENTITY; + + async inspect( + input: InspectElectronFusesInput, + options?: ExecutionOptions, + ): Promise> { + try { + options?.signal?.throwIfAborted(); + const located = await resolveElectronFuseTarget( + input.path, + options?.signal, + ); + options?.signal?.throwIfAborted(); + const image = await readElectronFuseImage(located.file.path, { + selection: located.file, + ...(options?.signal === undefined ? {} : { signal: options.signal }), + }); + const findings = describeElectronFuseFindings(image.slices, image.format); + const parsed = electronFuseReportSchema.safeParse({ + artifact: { + selected_path: located.selectedPath, + resolved_path: located.file.path, + sha256: image.sha256, + bytes: image.bytes, + format: image.format, + }, + slices: image.slices, + runtime_implications: findings.runtime_implications, + limitations: [...findings.limitations, electronFuseReadLimitation()], + }); + if (!parsed.success) + throw new AnalysisOutputError( + OPERATION, + "Electron fuse reader produced a report outside its schema.", + { cause: parsed.error }, + ); + options?.signal?.throwIfAborted(); + return ok(parsed.data); + } catch (cause: unknown) { + return err(failureFor(cause, input.path, options?.signal)); + } + } +} + +const failureFor = ( + cause: unknown, + path: string, + signal?: AbortSignal, +): AnalysisError => { + if (cause instanceof AnalysisError) return cause; + if (signal?.aborted) return new AnalysisCancelledError(OPERATION); + if (cause instanceof ElectronFusePathFailure) + return new AnalysisInputError(OPERATION, { cause }, [ + { path: ["path"], reason: cause.reason, message: cause.message }, + ]); + if (cause instanceof ElectronFuseResourceFailure) + return new AnalysisResourceConstraintError( + OPERATION, + cause.boundary === "file-size" ? "file-size" : "capacity", + cause.message, + cause.boundary === "file-size" + ? { maximum_input_bytes: 1024 * 1024 * 1024 } + : { maximum_sentinels_per_slice: 32 }, + { cause }, + ); + if (cause instanceof ElectronFuseFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, { + cause, + }) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact ${path}: ${cause.message}`, + }, + ]); + if (cause instanceof RegularFileChangedError) + return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { + cause, + }); + if (cause instanceof NonRegularFileReadError) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ]); + if (cause instanceof ZodError) + return new AnalysisOutputError( + OPERATION, + "Electron fuse reader produced a report outside its schema.", + { cause }, + ); + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, path, cause.code, { + cause, + }); + if ( + ["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes( + String(cause.code), + ) + ) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`, + }, + ]); + } + return new ProviderAdapterError( + ELECTRON_FUSE_PROVIDER_IDENTITY.id, + OPERATION, + { + cause, + diagnostics: { + path, + reason: cause instanceof Error ? cause.message : String(cause), + }, + }, + ); +}; diff --git a/src/electron/ElectronFuseReader.ts b/src/electron/ElectronFuseReader.ts new file mode 100644 index 0000000000..4db52b9c7d --- /dev/null +++ b/src/electron/ElectronFuseReader.ts @@ -0,0 +1,480 @@ +import type { ResolvedElectronFuseFile } from "./ElectronFusePath.js"; +import { createHash } from "node:crypto"; +import { lstat } from "node:fs/promises"; + +import { + hasMachoMagic, + readMachoImage, +} from "../artifacts/apple/MachoLoadCommandReader.js"; +import type { ReadAt } from "../artifacts/apple/MachoLoadCommandReader.js"; +import { + electronFuseName, + electronFuseState, + ELECTRON_FUSE_SENTINEL, + type ElectronFuseSentinel, + type ElectronFuseSlice, +} from "../domain/electron/electronFuses.js"; +import { + openRegularFile, + RegularFileChangedError, + sameRegularFileState, +} from "../filesystem/RegularFile.js"; + +/** Admission and search bounds for one Electron executable. */ +export const ELECTRON_FUSE_LIMITS = { + maximumFileBytes: 1024 * 1024 * 1024, + chunkBytes: 256 * 1024, + maximumSentinelsPerSlice: 32, + maximumPeHeaderOffset: 1024 * 1024, +} as const; + +const SENTINEL = Buffer.from(ELECTRON_FUSE_SENTINEL, "ascii"); +const FAT_MAGICS = new Set([0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca]); + +/** Container identity and per-slice fuse wires. Offsets are file-absolute. */ +export interface ElectronFuseImage { + readonly sha256: string; + readonly bytes: number; + readonly format: "mach-o" | "mach-o-universal" | "pe" | "elf"; + readonly slices: readonly ElectronFuseSlice[]; +} + +type ElectronFuseContainerFormat = ElectronFuseImage["format"]; + +interface SliceRange { + readonly index: number; + readonly fileOffset: number; + readonly size: number; + readonly architecture: string | null; +} + +/** The selected container is malformed or outside the supported formats. */ +export class ElectronFuseFormatFailure extends Error { + constructor( + readonly kind: "malformed" | "unsupported", + message: string, + ) { + super(message); + this.name = "ElectronFuseFormatFailure"; + } +} + +/** A measured bound was hit before a complete fuse report could be returned. */ +export class ElectronFuseResourceFailure extends Error { + constructor( + readonly boundary: "file-size" | "capacity", + message: string, + ) { + super(message); + this.name = "ElectronFuseResourceFailure"; + } +} + +/** + * Hash one admitted regular file and search each slice for the fuse sentinel. + * The search keeps only a sentinel-sized overlap between chunks. + */ +export const readElectronFuseImage = async ( + path: string, + options?: { + readonly signal?: AbortSignal; + readonly chunkBytes?: number; + readonly selection?: ResolvedElectronFuseFile; + }, +): Promise => { + const signal = options?.signal; + signal?.throwIfAborted(); + const selection = options?.selection; + const recheckPath = async (candidate: string) => { + signal?.throwIfAborted(); + try { + const metadata = await lstat(candidate, { bigint: true }); + signal?.throwIfAborted(); + return metadata; + } catch (cause: unknown) { + if (selection !== undefined && isMissingPath(cause)) + throw new RegularFileChangedError(path); + throw cause; + } + }; + const assertPathNodes = async (): Promise => { + if (selection === undefined) return; + if (selection.path !== path) throw new RegularFileChangedError(path); + for (const node of selection.nodes) { + const current = await recheckPath(node.path); + if ( + current.dev !== node.metadata.dev || + current.ino !== node.metadata.ino || + current.isDirectory() !== node.metadata.isDirectory() || + current.isSymbolicLink() !== node.metadata.isSymbolicLink() + ) + throw new RegularFileChangedError(path); + } + }; + await assertPathNodes(); + const before = await recheckPath(path); + if ( + selection !== undefined && + !sameRegularFileState(selection.metadata, before) + ) + throw new RegularFileChangedError(path); + if (!before.isFile() || before.isSymbolicLink()) + throw new ElectronFuseFormatFailure( + "unsupported", + `Expected a regular file without a symlink: ${path}`, + ); + if (before.size > BigInt(ELECTRON_FUSE_LIMITS.maximumFileBytes)) + throw new ElectronFuseResourceFailure( + "file-size", + `Selected artifact exceeds ${ELECTRON_FUSE_LIMITS.maximumFileBytes} bytes: ${path}`, + ); + const size = Number(before.size); + const chunkBytes = options?.chunkBytes ?? ELECTRON_FUSE_LIMITS.chunkBytes; + if ( + !Number.isSafeInteger(chunkBytes) || + chunkBytes < 1 || + chunkBytes > ELECTRON_FUSE_LIMITS.chunkBytes + ) + throw new TypeError( + `chunkBytes must be an integer between 1 and ${ELECTRON_FUSE_LIMITS.chunkBytes}.`, + ); + const handle = await openRegularFile(path, { symlinks: "reject", signal }); + try { + const opened = await handle.stat({ bigint: true }); + if (!sameRegularFileState(before, opened)) + throw new RegularFileChangedError(path); + await assertPathNodes(); + const readAt = readerFor(handle, size, path, signal); + const container = await identifyContainer(readAt, size, path); + const scanned = await scanSlices( + handle, + path, + size, + container.slices, + chunkBytes, + signal, + ); + const current = await recheckPath(path); + if (!sameRegularFileState(before, current)) + throw new RegularFileChangedError(path); + await assertPathNodes(); + return { + sha256: scanned.sha256, + bytes: size, + format: container.format, + slices: scanned.slices, + }; + } finally { + await handle.close(); + } +}; + +/** Sentence describing the file and sentinel bounds enforced by this reader. */ +export const electronFuseReadLimitation = (): string => + `The selected file is admitted as one regular file of at most 1 GiB. Sentinel search uses ${ELECTRON_FUSE_LIMITS.chunkBytes}-byte windows, and each slice keeps at most ${ELECTRON_FUSE_LIMITS.maximumSentinelsPerSlice} sentinels.`; + +const readerFor = + ( + handle: Awaited>, + size: number, + path: string, + signal: AbortSignal | undefined, + ): ReadAt => + async (offset, length) => { + signal?.throwIfAborted(); + if (length <= 0 || offset < 0 || offset >= size) return new Uint8Array(); + const bounded = Math.min(length, size - offset); + const buffer = Buffer.alloc(bounded); + const read = await handle.read(buffer, 0, bounded, offset); + if (read.bytesRead !== bounded) throw new RegularFileChangedError(path); + return buffer.subarray(0, read.bytesRead); + }; + +const identifyContainer = async ( + readAt: ReadAt, + size: number, + path: string, +): Promise<{ + readonly format: ElectronFuseContainerFormat; + readonly slices: readonly SliceRange[]; +}> => { + const head = await readAt(0, Math.min(size, 8)); + if (hasMachoMagic(head)) return identifyMacho(readAt, size, head, path); + if (isElf(head)) + return { + format: "elf", + slices: [{ index: 0, fileOffset: 0, size, architecture: null }], + }; + if (await isPe(readAt, size)) + return { + format: "pe", + slices: [{ index: 0, fileOffset: 0, size, architecture: null }], + }; + throw new ElectronFuseFormatFailure( + "unsupported", + `Selected artifact ${path} does not contain a Mach-O, ELF, or PE container.`, + ); +}; + +const identifyMacho = async ( + readAt: ReadAt, + size: number, + head: Uint8Array, + path: string, +): Promise<{ + readonly format: ElectronFuseContainerFormat; + readonly slices: readonly SliceRange[]; +}> => { + const facts = await readMachoImage(readAt, size); + if (facts.status !== "parsed") { + const reason = + facts.status === "not-mach-o" + ? `Selected artifact ${path} does not contain a Mach-O, ELF, or PE container.` + : facts.reason; + throw new ElectronFuseFormatFailure( + facts.status === "malformed" ? "malformed" : "unsupported", + reason, + ); + } + const format = isFat(head) ? "mach-o-universal" : "mach-o"; + const slices = facts.slices.map((slice, index) => ({ + index, + fileOffset: slice.slice_offset, + size: slice.slice_size, + architecture: slice.architecture, + })); + assertNonOverlappingSlices(slices); + return { + format, + slices, + }; +}; + +/** A FAT architecture owns a distinct byte range; overlap is malformed. */ +const assertNonOverlappingSlices = (ranges: readonly SliceRange[]): void => { + const ordered = [...ranges].sort( + (left, right) => left.fileOffset - right.fileOffset, + ); + for (let index = 1; index < ordered.length; index += 1) { + const previous = ordered[index - 1]; + const current = ordered[index]; + if ( + previous !== undefined && + current !== undefined && + current.fileOffset < previous.fileOffset + previous.size + ) + throw new ElectronFuseFormatFailure( + "malformed", + `Mach-O slices ${previous.index} and ${current.index} overlap.`, + ); + } +}; + +const scanSlices = async ( + handle: Awaited>, + path: string, + size: number, + ranges: readonly SliceRange[], + chunkBytes: number, + signal: AbortSignal | undefined, +): Promise<{ + readonly sha256: string; + readonly slices: readonly ElectronFuseSlice[]; +}> => { + const states = ranges.map((slice) => ({ + slice, + carry: Buffer.alloc(0), + hits: [] as number[], + })); + const hash = createHash("sha256"); + const chunk = Buffer.allocUnsafe(chunkBytes); + let position = 0; + while (position < size) { + signal?.throwIfAborted(); + const length = Math.min(chunkBytes, size - position); + const read = await handle.read(chunk, 0, length, position); + if (read.bytesRead !== length) throw new RegularFileChangedError(path); + const piece = chunk.subarray(0, length); + hash.update(piece); + absorbChunk(states, piece, position); + position += length; + } + if (position !== size) throw new RegularFileChangedError(path); + const slices: ElectronFuseSlice[] = []; + for (const state of states) { + const sentinels: ElectronFuseSentinel[] = []; + for (const offset of state.hits) + sentinels.push( + await parseWire( + handle, + path, + offset, + state.slice.fileOffset + state.slice.size, + signal, + ), + ); + slices.push({ + index: state.slice.index, + file_offset: state.slice.fileOffset, + size: state.slice.size, + architecture: state.slice.architecture, + sentinel_count: sentinels.length, + sentinels, + }); + } + return { sha256: hash.digest("hex"), slices }; +}; + +const absorbChunk = ( + states: readonly { + readonly slice: SliceRange; + carry: Buffer; + readonly hits: number[]; + }[], + chunk: Buffer, + position: number, +): void => { + const chunkEnd = position + chunk.length; + for (const state of states) { + const start = Math.max(position, state.slice.fileOffset); + const end = Math.min(chunkEnd, state.slice.fileOffset + state.slice.size); + if (start >= end) continue; + const piece = chunk.subarray(start - position, end - position); + const carryLength = state.carry.length; + const window = + carryLength === 0 ? piece : Buffer.concat([state.carry, piece]); + const windowFileStart = start - carryLength; + let from = 0; + while (from <= window.length - SENTINEL.length) { + const found = window.indexOf(SENTINEL, from); + if (found < 0) break; + const sentinelOffset = windowFileStart + found; + if (!state.hits.includes(sentinelOffset)) { + if (state.hits.length >= ELECTRON_FUSE_LIMITS.maximumSentinelsPerSlice) + throw new ElectronFuseResourceFailure( + "capacity", + `Slice ${state.slice.index} contains more than ${ELECTRON_FUSE_LIMITS.maximumSentinelsPerSlice} Electron fuse sentinels.`, + ); + state.hits.push(sentinelOffset); + } + from = found + 1; + } + const tail = SENTINEL.length - 1; + state.carry = + window.length <= tail + ? Buffer.from(window) + : Buffer.from(window.subarray(window.length - tail)); + } +}; + +const parseWire = async ( + handle: Awaited>, + path: string, + sentinelOffset: number, + sliceEnd: number, + signal: AbortSignal | undefined, +): Promise => { + const wireStart = sentinelOffset + SENTINEL.length; + const available = sliceEnd - wireStart; + if (available < 1) + return { + sentinel_offset: sentinelOffset, + wire_version: null, + declared_length: null, + fuses: [], + complete: false, + }; + const header = await readExact( + handle, + path, + wireStart, + Math.min(available, 2), + signal, + ); + const wireVersion = header[0]; + if (wireVersion === undefined || header.length < 2) + return { + sentinel_offset: sentinelOffset, + wire_version: wireVersion ?? null, + declared_length: null, + fuses: [], + complete: false, + }; + const declared = header[1] ?? 0; + const fuseLength = Math.min(declared, available - 2); + const fuseBytes = + fuseLength === 0 + ? Buffer.alloc(0) + : await readExact(handle, path, wireStart + 2, fuseLength, signal); + return { + sentinel_offset: sentinelOffset, + wire_version: wireVersion, + declared_length: declared, + fuses: [...fuseBytes].map((raw, index) => ({ + index, + name: electronFuseName(wireVersion, index), + state: electronFuseState(raw), + raw_byte: raw, + })), + complete: fuseBytes.length === declared, + }; +}; + +const readExact = async ( + handle: Awaited>, + path: string, + offset: number, + length: number, + signal: AbortSignal | undefined, +): Promise => { + signal?.throwIfAborted(); + const buffer = Buffer.alloc(length); + const read = await handle.read(buffer, 0, length, offset); + if (read.bytesRead !== length) throw new RegularFileChangedError(path); + return buffer; +}; + +const isElf = (head: Uint8Array): boolean => + head.length >= 4 && + head[0] === 0x7f && + head[1] === 0x45 && + head[2] === 0x4c && + head[3] === 0x46; + +const isFat = (head: Uint8Array): boolean => { + if (head.length < 4) return false; + const magic = Buffer.from(head.buffer, head.byteOffset, 4).readUInt32BE(0); + return FAT_MAGICS.has(magic); +}; + +const isPe = async (readAt: ReadAt, size: number): Promise => { + if (size < 64) return false; + const header = await readAt(0, 64); + if (header.length < 64 || header[0] !== 0x4d || header[1] !== 0x5a) + return false; + const offset = Buffer.from( + header.buffer, + header.byteOffset, + header.byteLength, + ).readUInt32LE(0x3c); + if ( + !Number.isSafeInteger(offset) || + offset < 64 || + offset > ELECTRON_FUSE_LIMITS.maximumPeHeaderOffset || + offset > size - 4 + ) + return false; + const signature = await readAt(offset, 4); + return ( + signature.length === 4 && + signature[0] === 0x50 && + signature[1] === 0x45 && + signature[2] === 0 && + signature[3] === 0 + ); +}; + +const isMissingPath = (cause: unknown): boolean => + cause instanceof Error && + "code" in cause && + ["ENOENT", "ENOTDIR"].includes(String(cause.code)); diff --git a/src/server/createServer.ts b/src/server/createServer.ts index e637f8d0d2..ce0873dd32 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -22,6 +22,8 @@ import { registerRecordedCrashTools } from "./registerRecordedCrashTools.js"; import { createRecordedCrashService } from "../composition/binaryDiagnostics.js"; import { createGoBinaryService } from "../composition/go.js"; import type { GoBinaryService } from "../application/go/GoBinaryService.js"; +import { createElectronFuseService } from "../composition/electronFuses.js"; +import type { ElectronFuseService } from "../application/electron/ElectronFuseService.js"; import { registerGoTools } from "./registerGoTools.js"; import { createRustBinaryService } from "../composition/rust.js"; import type { RustBinaryService } from "../application/rust/RustBinaryService.js"; @@ -157,6 +159,7 @@ export interface CreateServerOptions { readonly binaryLayout?: BinaryLayoutService; readonly goBinary?: GoBinaryService; readonly rustBinary?: RustBinaryService; + readonly electronFuses?: ElectronFuseService; readonly recordedCrash?: RecordedCrashService; readonly firmwareAnalysis?: FirmwareAnalysisPort; readonly javascriptRecovery?: JavaScriptRecoveryPort; @@ -884,6 +887,7 @@ const registerObservationTools = ({ evidenceById, electron: options.electronObservation, electronActive: options.electronActiveObservation, + electronFuses: options.electronFuses ?? createElectronFuseService(), observationLoadFailure: options.optionalProviderLoadFailures?.electronObservation, activeLoadFailure: diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts index e8008177da..96a518e0f2 100644 --- a/src/server/registerElectronTools.ts +++ b/src/server/registerElectronTools.ts @@ -21,6 +21,7 @@ import { analyzeJavaScriptApplicationValidated } from "../application/javascript import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { reconcileJavaScriptRuntimeEvidenceValidated } from "../application/javascript/JavaScriptRuntimeReconciliationService.js"; import type { ProgressReporter } from "../application/ProgressReporter.js"; +import type { ElectronFuseService } from "../application/electron/ElectronFuseService.js"; import { toolContract } from "../contracts/toolContracts.js"; import type { ToolContract } from "../contracts/toolContractTypes.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -32,6 +33,7 @@ import { logToolExecution } from "./toolLogging.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; import { runAdmittedToolOperation } from "./admittedToolOperation.js"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; interface ElectronToolRegistration { readonly artifactResources: ArtifactResourceScope; @@ -40,6 +42,7 @@ interface ElectronToolRegistration { readonly activeLoadFailure?: OptionalProviderLoadFailure | undefined; readonly electron: ElectronObservationPort | undefined; readonly electronActive: ElectronActiveObservationPort | undefined; + readonly electronFuses: ElectronFuseService; readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined; readonly evidenceById: EvidenceLookup | undefined; readonly withAdmittedAnalysis?: WithAdmittedAnalysis; @@ -57,6 +60,7 @@ export const registerElectronTools = ( options: ElectronToolRegistration, ): void => { const registration = { ...options, delivery: server.delivery, server }; + registerElectronFuseTool(server, options); const listContract = toolContract("list_electron_targets"); const inspectContract = toolContract("inspect_electron_page"); const analyzeContract = toolContract("analyze_javascript_application"); @@ -141,6 +145,25 @@ export const registerElectronTools = ( ); }; +const registerElectronFuseTool = ( + server: EvidenceMcpServer, + options: ElectronToolRegistration, +): void => { + const contract = toolContract("inspect_electron_fuses"); + const handler = createEvidenceToolHandler( + server, + (input, execution) => options.electronFuses.inspect(input, execution), + options.logger, + options.recordEvidence, + options.withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; + const runElectronTool = async ( options: ElectronToolRegistration & { readonly delivery: ToolResultDelivery; diff --git a/tests/acceptance/electronFusesCli.test.ts b/tests/acceptance/electronFusesCli.test.ts new file mode 100644 index 0000000000..763d2faf58 --- /dev/null +++ b/tests/acceptance/electronFusesCli.test.ts @@ -0,0 +1,150 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { expect } from "vitest"; + +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { + OBSIDIAN_FUSE_BYTES, + sentinelFreeMacho, + thinFuseImage, + universalFuseImage, +} from "../fixtures/electron/fuseImage.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "reads thin and universal Electron fuse wires through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-fuses-cli-"); + const thin = thinFuseImage(OBSIDIAN_FUSE_BYTES); + const universal = universalFuseImage(); + await writeFile(join(root, "thin"), thin.bytes); + await writeFile(join(root, "universal"), universal.bytes); + + const thinResult = await cli.run({ + arguments: ["inspect-electron-fuses", "thin", "--json"], + cwd: root, + }); + expect(thinResult.exitCode, thinResult.stderr).toBe(0); + const thinEvidence = toolContract( + "inspect_electron_fuses", + ).outputSchema.parse(thinResult.json); + expect(thinEvidence.subject?.local_path).toBe(join(root, "thin")); + expect(thinEvidence.subject?.digest.sha256).toBe( + createHash("sha256").update(thin.bytes).digest("hex"), + ); + expect(thinEvidence.subject?.architecture).toBe("arm64"); + expect( + thinEvidence.normalized_result.slices[0]?.sentinels[0], + ).toMatchObject({ + sentinel_offset: thin.sentinelOffset, + wire_version: 1, + declared_length: 8, + }); + expect( + thinEvidence.normalized_result.runtime_implications.find( + (item) => item.subject === "node_cli_inspect_arguments", + ), + ).toMatchObject({ outcome: "not_honored", fuse_index: 3 }); + + const universalResult = await cli.run({ + arguments: ["inspect-electron-fuses", join(root, "universal"), "--json"], + }); + expect(universalResult.exitCode, universalResult.stderr).toBe(0); + const universalEvidence = toolContract( + "inspect_electron_fuses", + ).outputSchema.parse(universalResult.json); + expect(universalEvidence.normalized_result.artifact.format).toBe( + "mach-o-universal", + ); + expect(universalEvidence.subject?.architecture).toBeNull(); + expect( + universalEvidence.normalized_result.slices.map((slice) => ({ + file_offset: slice.file_offset, + sentinel_offset: slice.sentinels[0]?.sentinel_offset, + })), + ).toEqual([ + { + file_offset: universal.left.fileOffset, + sentinel_offset: universal.left.sentinelOffset, + }, + { + file_offset: universal.right.fileOffset, + sentinel_offset: universal.right.sentinelOffset, + }, + ]); + }, +); + +cliTest( + "reports a missing sentinel as unknown fuse state through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-fuses-cli-empty-"); + const path = join(root, "empty"); + await writeFile(path, sentinelFreeMacho()); + const result = await cli.run({ + arguments: ["inspect-electron-fuses", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_electron_fuses").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.slices[0]?.sentinel_count).toBe(0); + expect(evidence.normalized_result.runtime_implications).toEqual([]); + expect(evidence.limitations.join(" ")).toContain( + "unknown, not that every fuse is disabled", + ); + }, +); + +cliTest( + "resolves an Electron app bundle to its framework binary through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-fuses-cli-app-"); + const image = thinFuseImage(OBSIDIAN_FUSE_BYTES); + const app = join(root, "Fixture.app"); + const binary = join( + app, + "Contents", + "Frameworks", + "Electron Framework.framework", + "Versions", + "A", + "Electron Framework", + ); + await mkdir(join(binary, ".."), { recursive: true }); + await writeFile(binary, image.bytes); + await symlink("A", join(binary, "..", "..", "Current")); + const result = await cli.run({ + arguments: ["inspect-electron-fuses", app, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_electron_fuses").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.artifact).toMatchObject({ + selected_path: app, + resolved_path: binary, + format: "mach-o", + }); + expect(evidence.subject?.local_path).toBe(binary); + }, +); + +cliTest( + "rejects an unrecognized container through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-electron-fuses-cli-text-"); + const path = join(root, "notes.txt"); + await writeFile(path, "This is not an executable container."); + const result = await cli.run({ + arguments: ["inspect-electron-fuses", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "unsupported_target", + details: { operation: "inspect_electron_fuses", path }, + }); + }, +); diff --git a/tests/boundary/mcp/electronFusesMcp.test.ts b/tests/boundary/mcp/electronFusesMcp.test.ts new file mode 100644 index 0000000000..db398cd0f7 --- /dev/null +++ b/tests/boundary/mcp/electronFusesMcp.test.ts @@ -0,0 +1,180 @@ +import type { Client } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { ELECTRON_FUSE_STATE_BYTES } from "../../../src/domain/electron/electronFuses.js"; +import { + elfFuseImage, + truncatedFuseImage, + twoSentinelElf, +} from "../../fixtures/electron/fuseImage.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +const fuseMcpTest = mcpTest.extend<{ + fuse: { client: Client }; +}>({ + fuse: async ({ mcp }, use) => { + const session = createTestBinarySession(() => { + throw new Error( + "Electron fuse inspection must not start a deep provider", + ); + }); + try { + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + await use({ client }); + } finally { + await session.close(); + } + }, +}); + +fuseMcpTest( + "advertises fuse inspection and preserves unknown fuse bytes through MCP", + async ({ fuse: { client }, mcp }) => { + const session = createTestBinarySession(() => { + throw new Error( + "Electron fuse inspection must not start a deep provider", + ); + }); + try { + const connected = await mcp.connect( + createServer({ kind: "session", session }), + ); + const status = await connected.callTool({ + name: "binary_session", + arguments: {}, + }); + const availability = expandToolAvailability( + toolContract("binary_session").outputSchema.parse( + status.structuredContent, + ).result.tool_availability, + ); + expect(availability).toContainEqual( + expect.objectContaining({ + name: "inspect_electron_fuses", + available: true, + reason: "available", + }), + ); + const advertised = (await connected.listTools()).tools.find( + (tool) => tool.name === "inspect_electron_fuses", + ); + if (advertised?.outputSchema === undefined) + throw new Error("Electron fuse inspection must publish both schemas"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + expect(advertised.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }); + + const root = await createTestTempDirectory("rea-electron-fuses-mcp-"); + const elf = elfFuseImage([ELECTRON_FUSE_STATE_BYTES.removed, 0x99]); + const path = join(root, "application"); + await writeFile(path, elf.bytes); + const response = await connected.callTool({ + name: "inspect_electron_fuses", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const parsed = toolContract("inspect_electron_fuses").outputSchema.parse( + response.structuredContent, + ); + const evidence = parseEvidence(parsed); + expect(evidence.confidence).toBe("observed"); + expect(parsed.normalized_result.artifact.format).toBe("elf"); + expect( + parsed.normalized_result.slices[0]?.sentinels[0]?.fuses, + ).toMatchObject([ + { index: 0, name: "RunAsNode", state: "removed", raw_byte: 0x72 }, + { + index: 1, + name: "EnableCookieEncryption", + state: "unknown", + raw_byte: 0x99, + }, + ]); + expect(parsed.normalized_result.runtime_implications).toEqual([ + expect.objectContaining({ + subject: "electron_run_as_node", + relationship: "derivation", + outcome: "not_honored", + fuse_index: 0, + }), + ]); + expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + + const duplicated = twoSentinelElf(); + const duplicatePath = join(root, "duplicated"); + await writeFile(duplicatePath, duplicated.bytes); + const duplicates = await connected.callTool({ + name: "inspect_electron_fuses", + arguments: { path: duplicatePath }, + }); + expect(duplicates.isError).not.toBe(true); + const duplicateEvidence = toolContract( + "inspect_electron_fuses", + ).outputSchema.parse(duplicates.structuredContent); + expect( + duplicateEvidence.normalized_result.slices[0]?.sentinels.map( + (item) => item.sentinel_offset, + ), + ).toEqual([...duplicated.offsets]); + + const truncatedPath = join(root, "truncated"); + await writeFile(truncatedPath, truncatedFuseImage()); + const truncated = await connected.callTool({ + name: "inspect_electron_fuses", + arguments: { path: truncatedPath }, + }); + expect(truncated.isError).not.toBe(true); + const truncatedEvidence = toolContract( + "inspect_electron_fuses", + ).outputSchema.parse(truncated.structuredContent); + expect( + truncatedEvidence.normalized_result.slices[0]?.sentinels[0], + ).toMatchObject({ + complete: false, + declared_length: 4, + wire_version: 1, + }); + + const relative = await client.callTool({ + name: "inspect_electron_fuses", + arguments: { path: "relative.bin" }, + }); + expect(relative.isError).toBe(true); + expect(parseMcpToolError(relative)).toMatchObject({ + error: { + code: "invalid_request", + details: { + operation: "inspect_electron_fuses", + issues: [expect.objectContaining({ path: ["path"] })], + }, + }, + }); + } finally { + await session.close(); + } + }, +); diff --git a/tests/fixtures/electron/fuseImage.ts b/tests/fixtures/electron/fuseImage.ts new file mode 100644 index 0000000000..e887f83ece --- /dev/null +++ b/tests/fixtures/electron/fuseImage.ts @@ -0,0 +1,146 @@ +import { + ELECTRON_FUSE_SENTINEL, + ELECTRON_FUSE_STATE_BYTES, +} from "../../../src/domain/electron/electronFuses.js"; + +export const FUSE_SENTINEL = Buffer.from(ELECTRON_FUSE_SENTINEL, "ascii"); + +/** Obsidian's observed version-1 states: disabled, enabled, then six more with the last enabled. */ +export const OBSIDIAN_FUSE_BYTES = [ + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.enabled, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.disabled, + ELECTRON_FUSE_STATE_BYTES.enabled, +] as const; + +export const fuseWire = (version: number, fuses: readonly number[]): Buffer => + Buffer.from([version, fuses.length, ...fuses]); + +export const thinMacho = (cpuType: number, body: Buffer): Buffer => { + const header = Buffer.alloc(32); + header.writeUInt32LE(0xfeedfacf, 0); + header.writeUInt32LE(cpuType, 4); + header.writeUInt32LE(0, 8); + header.writeUInt32LE(2, 12); + return Buffer.concat([header, body]); +}; + +export const thinFuseImage = ( + fuses: readonly number[], + version = 1, +): { readonly bytes: Buffer; readonly sentinelOffset: number } => ({ + bytes: thinMacho( + 0x0100000c, + Buffer.concat([FUSE_SENTINEL, fuseWire(version, fuses)]), + ), + sentinelOffset: 32, +}); + +export const universalFuseImage = (): { + readonly bytes: Buffer; + readonly left: { + readonly fileOffset: number; + readonly sentinelOffset: number; + }; + readonly right: { + readonly fileOffset: number; + readonly sentinelOffset: number; + }; +} => { + const left = thinMacho( + 0x01000007, + Buffer.concat([FUSE_SENTINEL, fuseWire(1, OBSIDIAN_FUSE_BYTES)]), + ); + const rightFuses = [...OBSIDIAN_FUSE_BYTES]; + rightFuses[3] = ELECTRON_FUSE_STATE_BYTES.enabled; + const right = thinMacho( + 0x0100000c, + Buffer.concat([FUSE_SENTINEL, fuseWire(1, rightFuses)]), + ); + const tableEnd = 48; + const table = Buffer.alloc(tableEnd); + table.writeUInt32BE(0xcafebabe, 0); + table.writeUInt32BE(2, 4); + const writeEntry = ( + index: number, + cpuType: number, + offset: number, + size: number, + ) => { + const base = 8 + index * 20; + table.writeUInt32BE(cpuType, base); + table.writeUInt32BE(0, base + 4); + table.writeUInt32BE(offset, base + 8); + table.writeUInt32BE(size, base + 12); + table.writeUInt32BE(0, base + 16); + }; + writeEntry(0, 0x01000007, tableEnd, left.length); + writeEntry(1, 0x0100000c, tableEnd + left.length, right.length); + return { + bytes: Buffer.concat([table, left, right]), + left: { fileOffset: tableEnd, sentinelOffset: tableEnd + 32 }, + right: { + fileOffset: tableEnd + left.length, + sentinelOffset: tableEnd + left.length + 32, + }, + }; +}; + +export const peFuseImage = ( + fuses: readonly number[], +): { readonly bytes: Buffer; readonly sentinelOffset: number } => { + const header = Buffer.alloc(0x80); + header.write("MZ", 0, "ascii"); + header.writeUInt32LE(0x80, 0x3c); + const body = Buffer.concat([ + Buffer.from("PE\0\0", "binary"), + FUSE_SENTINEL, + fuseWire(1, fuses), + ]); + return { bytes: Buffer.concat([header, body]), sentinelOffset: 0x84 }; +}; + +export const elfFuseImage = ( + fuses: readonly number[], + version = 1, +): { readonly bytes: Buffer; readonly sentinelOffset: number } => ({ + bytes: Buffer.concat([ + Buffer.from([0x7f, 0x45, 0x4c, 0x46]), + FUSE_SENTINEL, + fuseWire(version, fuses), + ]), + sentinelOffset: 4, +}); + +export const truncatedFuseImage = (): Buffer => + thinMacho( + 0x0100000c, + Buffer.concat([FUSE_SENTINEL, Buffer.from([1, 4, 0x31])]), + ); + +export const twoSentinelElf = (): { + readonly bytes: Buffer; + readonly offsets: readonly [number, number]; +} => { + const wire = fuseWire(1, OBSIDIAN_FUSE_BYTES); + const first = 4; + const second = first + FUSE_SENTINEL.length + wire.length + 2; + return { + bytes: Buffer.concat([ + Buffer.from([0x7f, 0x45, 0x4c, 0x46]), + FUSE_SENTINEL, + wire, + Buffer.from([0, 0]), + FUSE_SENTINEL, + wire, + ]), + offsets: [first, second], + }; +}; + +export const sentinelFreeMacho = (): Buffer => + thinMacho(0x0100000c, Buffer.from("no fuse wire in this image"));