mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
feat(electron): inspect packaged Electron fuse state without execution (#2399)
Add bounded, read-only inspection of packaged Electron fuse state for ELF, PE, and Mach-O applications through CLI and MCP workflows. Preserve artifact provenance and unknown values, resolve app bundles without execution, reject malformed or overlapping containers before scanning, and keep output compact for agent use. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Result<ElectronFuseReport, AnalysisError>>;
|
||||
}
|
||||
@@ -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<Result<Evidence, AnalysisError>> {
|
||||
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<typeof electronFuseReportSchema.parse>,
|
||||
) => {
|
||||
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<typeof electronFuseReportSchema.parse>,
|
||||
) => [
|
||||
{ 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,
|
||||
})),
|
||||
),
|
||||
];
|
||||
@@ -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<Record<string, string | undefined>>,
|
||||
): void => {
|
||||
registerElectronObservationCommands(cli, logger);
|
||||
registerElectronFuseCommand(cli, logger);
|
||||
registerElectronActiveCommand(cli, logger, environment);
|
||||
registerJavaScriptApplicationCommand(cli, logger);
|
||||
registerJavaScriptRuntimeReconciliationCommand(cli, logger);
|
||||
};
|
||||
|
||||
const registerElectronFuseCommand = (
|
||||
cli: ReturnType<typeof Cli.create>,
|
||||
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<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
@@ -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[];
|
||||
|
||||
@@ -648,6 +648,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
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,
|
||||
|
||||
@@ -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<Record<string, z.ZodObject>>;
|
||||
|
||||
/** Exact Evidence schema for read-only Electron fuse inspection. */
|
||||
export const electronFuseOutputSchemas = {
|
||||
inspect_electron_fuses: evidenceResultOf(electronFuseReportSchema),
|
||||
};
|
||||
|
||||
@@ -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<typeof sentinel>[],
|
||||
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);
|
||||
});
|
||||
@@ -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<typeof fuseStateSchema>;
|
||||
export type ElectronFuseSentinel = z.infer<typeof electronFuseSentinelSchema>;
|
||||
export type ElectronFuseSlice = z.infer<typeof electronFuseSliceSchema>;
|
||||
export type ElectronFuseImplication = z.infer<
|
||||
typeof electronFuseImplicationSchema
|
||||
>;
|
||||
export type ElectronFuseReport = z.infer<typeof electronFuseReportSchema>;
|
||||
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.",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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<ResolvedElectronFuseTarget> => {
|
||||
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<ResolvedElectronFuseFile> => {
|
||||
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<ResolvedElectronFuseFile> => {
|
||||
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<ResolvedElectronFuseFile> => {
|
||||
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<WalkResult> => {
|
||||
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<readonly ElectronFusePathNode[]> => {
|
||||
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<readonly ElectronFusePathNode[]> => {
|
||||
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));
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<Result<ElectronFuseReport, AnalysisError>> {
|
||||
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),
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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<ElectronFuseImage> => {
|
||||
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<void> => {
|
||||
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<ReturnType<typeof openRegularFile>>,
|
||||
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<ReturnType<typeof openRegularFile>>,
|
||||
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<ReturnType<typeof openRegularFile>>,
|
||||
path: string,
|
||||
sentinelOffset: number,
|
||||
sliceEnd: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<ElectronFuseSentinel> => {
|
||||
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<ReturnType<typeof openRegularFile>>,
|
||||
path: string,
|
||||
offset: number,
|
||||
length: number,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Buffer> => {
|
||||
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<boolean> => {
|
||||
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));
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <Input>(
|
||||
options: ElectronToolRegistration & {
|
||||
readonly delivery: ToolResultDelivery;
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = 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();
|
||||
}
|
||||
},
|
||||
);
|
||||
Vendored
+146
@@ -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"));
|
||||
Reference in New Issue
Block a user