mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 17:59:08 +00:00
Probe files with unrecognized names as text, keep undecided fuse slices unknown, forward opaque container coverage, read paths from Go build flags, and compare inventoried digests before accepting owner facts. Keep typed cleanup failures and attach the partial report when a later owner is cancelled. Pin the verifier's @electron/fuses version and move the tests to the named contract family.
307 lines
10 KiB
JavaScript
307 lines
10 KiB
JavaScript
/** Audit a local Electron .app before and after fuse and source-map remediation. */
|
|
import { execFile } from "node:child_process";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { promisify } from "node:util";
|
|
|
|
import { importVerifierSource } from "./lib/verifier-source-loader.mjs";
|
|
|
|
const exec = promisify(execFile);
|
|
const { ELECTRON_FUSE_SENTINEL } = await importVerifierSource(
|
|
new URL("../src/domain/electron/electronFuses.ts", import.meta.url).href,
|
|
import.meta.url,
|
|
);
|
|
const PLANTED_SOURCE = "release-exposure-planted-secret-7c2e9a";
|
|
const OUTPUT = resolve("docs/verification/release-exposure-2026-10-31.md");
|
|
const TEXT_MEMBER_BYTES = 256 * 1024;
|
|
const ELECTRON_FUSES_VERSION = "2.1.3";
|
|
|
|
const asarArchive = (files) => {
|
|
let offset = 0;
|
|
const table = {};
|
|
const bodies = [];
|
|
for (const file of files) {
|
|
table[file.name] = { size: file.bytes.length, offset: String(offset) };
|
|
bodies.push(file.bytes);
|
|
offset += file.bytes.length;
|
|
}
|
|
const text = Buffer.from(JSON.stringify({ files: table }));
|
|
const aligned = Math.ceil(text.length / 4) * 4;
|
|
const headerSize = 8 + aligned;
|
|
const header = Buffer.alloc(8 + headerSize);
|
|
header.writeUInt32LE(4, 0);
|
|
header.writeUInt32LE(headerSize, 4);
|
|
header.writeUInt32LE(headerSize - 4, 8);
|
|
header.writeUInt32LE(text.length, 12);
|
|
text.copy(header, 16);
|
|
return Buffer.concat([header, ...bodies]);
|
|
};
|
|
|
|
const frameworkBinary = (states) => {
|
|
const header = Buffer.alloc(64);
|
|
header[0] = 0x7f;
|
|
header[1] = 0x45;
|
|
header[2] = 0x4c;
|
|
header[3] = 0x46;
|
|
header[4] = 2;
|
|
header[5] = 1;
|
|
header[6] = 1;
|
|
header.writeUInt16LE(2, 16);
|
|
header.writeUInt16LE(62, 18);
|
|
header.writeUInt32LE(1, 20);
|
|
header.writeUInt16LE(64, 52);
|
|
const wire = Buffer.concat([
|
|
Buffer.from(ELECTRON_FUSE_SENTINEL, "ascii"),
|
|
Buffer.from([1, states.length, ...states]),
|
|
]);
|
|
return Buffer.concat([header, wire, Buffer.alloc(TEXT_MEMBER_BYTES)]);
|
|
};
|
|
|
|
const sourceMap = () =>
|
|
Buffer.from(
|
|
JSON.stringify({
|
|
version: 3,
|
|
file: "app.js",
|
|
sources: ["app.js"],
|
|
names: [],
|
|
mappings: "",
|
|
sourcesContent: [`export const secret = "${PLANTED_SOURCE}";\n`],
|
|
}),
|
|
);
|
|
|
|
const writeApp = async (root, { withMap, states }) => {
|
|
const resources = join(root, "Contents", "Resources");
|
|
const framework = join(
|
|
root,
|
|
"Contents",
|
|
"Frameworks",
|
|
"Electron Framework.framework",
|
|
);
|
|
await mkdir(resources, { recursive: true });
|
|
await mkdir(framework, { recursive: true });
|
|
const files = [{ name: "app.js", bytes: Buffer.from("console.log(1)\n") }];
|
|
if (withMap) files.push({ name: "app.js.map", bytes: sourceMap() });
|
|
await writeFile(join(resources, "app.asar"), asarArchive(files));
|
|
await writeFile(
|
|
join(framework, "Electron Framework"),
|
|
frameworkBinary(states),
|
|
);
|
|
};
|
|
|
|
const audit = async (directory, extraArgs = []) => {
|
|
const args = [
|
|
resolve("scripts/rea.mjs"),
|
|
"audit-release-exposure",
|
|
directory,
|
|
"--json",
|
|
...extraArgs,
|
|
];
|
|
try {
|
|
const { stdout } = await exec(process.execPath, args, {
|
|
encoding: "utf8",
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
timeout: 180000,
|
|
});
|
|
return { status: 0, stdout };
|
|
} catch (cause) {
|
|
const stdout = typeof cause.stdout === "string" ? cause.stdout : "";
|
|
const status = typeof cause.code === "number" ? cause.code : undefined;
|
|
if (status !== undefined && stdout.length > 0) return { status, stdout };
|
|
throw cause;
|
|
}
|
|
};
|
|
|
|
const checksOf = (stdout) => {
|
|
const evidence = JSON.parse(stdout);
|
|
const report = evidence.normalized_result;
|
|
return report.checks.map((check) => ({
|
|
id: check.id,
|
|
status: check.status,
|
|
severity: check.severity,
|
|
}));
|
|
};
|
|
|
|
const statusOf = (checks, id) =>
|
|
checks.find((check) => check.id === id)?.status;
|
|
|
|
const expectStatus = (label, checks, id, value) => {
|
|
if (statusOf(checks, id) !== value)
|
|
throw new Error(
|
|
`${label} ${id} is ${statusOf(checks, id)}, expected ${value}`,
|
|
);
|
|
};
|
|
|
|
const table = (checks) => {
|
|
const rows = [
|
|
["Check", "Status", "Severity"],
|
|
...checks.map((check) => [
|
|
`\`${check.id}\``,
|
|
`\`${check.status}\``,
|
|
`\`${String(check.severity)}\``,
|
|
]),
|
|
];
|
|
const widths = rows[0].map((_, column) =>
|
|
Math.max(...rows.map((row) => row[column].length)),
|
|
);
|
|
const line = (cells) =>
|
|
`| ${cells.map((cell, column) => cell.padEnd(widths[column])).join(" | ")} |`;
|
|
return [
|
|
line(rows[0]),
|
|
line(widths.map((width) => "-".repeat(width))),
|
|
...rows.slice(1).map(line),
|
|
].join("\n");
|
|
};
|
|
|
|
const installFuses = async (root) => {
|
|
const prefix = join(root, "fuses");
|
|
await mkdir(prefix);
|
|
await exec(
|
|
"npm",
|
|
[
|
|
"install",
|
|
"--prefix",
|
|
prefix,
|
|
`@electron/fuses@${ELECTRON_FUSES_VERSION}`,
|
|
],
|
|
{
|
|
encoding: "utf8",
|
|
timeout: 180000,
|
|
},
|
|
);
|
|
const packageJson = JSON.parse(
|
|
await readFile(
|
|
join(prefix, "node_modules", "@electron", "fuses", "package.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
const exported = packageJson.exports;
|
|
const dot =
|
|
exported !== null && typeof exported === "object"
|
|
? exported["."]
|
|
: exported;
|
|
const nested = dot !== null && typeof dot === "object" ? dot : undefined;
|
|
const entry =
|
|
(typeof dot === "string" ? dot : undefined) ??
|
|
(typeof nested?.import === "string" ? nested.import : undefined) ??
|
|
(typeof nested?.default === "string" ? nested.default : undefined) ??
|
|
(typeof packageJson.module === "string" ? packageJson.module : undefined) ??
|
|
(typeof packageJson.main === "string" ? packageJson.main : undefined);
|
|
if (entry === undefined)
|
|
throw new Error("@electron/fuses package entry was not a file path");
|
|
const href = pathToFileURL(
|
|
join(prefix, "node_modules", "@electron", "fuses", entry),
|
|
).href;
|
|
const api = await import(href);
|
|
return { version: packageJson.version, api };
|
|
};
|
|
|
|
const root = await mkdtemp(join(tmpdir(), "rea-release-exposure-"));
|
|
try {
|
|
const beforeApp = join(root, "before", "MyApp.app");
|
|
const afterApp = join(root, "after", "MyApp.app");
|
|
const enabled = [0x31, 0x31, 0x31, 0x31, 0x30, 0x30];
|
|
await writeApp(beforeApp, { withMap: true, states: enabled });
|
|
await writeApp(afterApp, { withMap: false, states: enabled });
|
|
const { version, api } = await installFuses(root);
|
|
const { flipFuses, FuseVersion, FuseV1Options } = api;
|
|
await flipFuses(
|
|
join(
|
|
afterApp,
|
|
"Contents",
|
|
"Frameworks",
|
|
"Electron Framework.framework",
|
|
"Electron Framework",
|
|
),
|
|
{
|
|
version: FuseVersion.V1,
|
|
resetAdHocDarwinSignature: false,
|
|
[FuseV1Options.RunAsNode]: false,
|
|
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
|
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
|
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
|
},
|
|
);
|
|
const gated = await audit(beforeApp, ["--fail_on", "high"]);
|
|
if (gated.status !== 3)
|
|
throw new Error(
|
|
`--fail_on high exited ${String(gated.status)}, expected 3`,
|
|
);
|
|
const before = await audit(beforeApp);
|
|
const after = await audit(afterApp);
|
|
if (
|
|
before.stdout.includes(PLANTED_SOURCE) ||
|
|
after.stdout.includes(PLANTED_SOURCE) ||
|
|
gated.stdout.includes(PLANTED_SOURCE)
|
|
)
|
|
throw new Error("Planted source text was copied into the audit report");
|
|
const beforeChecks = checksOf(before.stdout);
|
|
const afterChecks = checksOf(after.stdout);
|
|
for (const id of [
|
|
"javascript.source_map_sources",
|
|
"packaging.extractable_payload",
|
|
"electron.run_as_node",
|
|
"electron.node_cli_inspect",
|
|
"electron.only_load_from_asar",
|
|
])
|
|
expectStatus("before", beforeChecks, id, "exposed");
|
|
expectStatus("before", beforeChecks, "electron.asar_integrity", "unknown");
|
|
expectStatus("before", beforeChecks, "go.build_paths", "unknown");
|
|
expectStatus("before", beforeChecks, "rust.dependency_metadata", "unknown");
|
|
expectStatus(
|
|
"after",
|
|
afterChecks,
|
|
"javascript.source_map_sources",
|
|
"not_observed",
|
|
);
|
|
expectStatus("after", afterChecks, "javascript.source_maps", "not_observed");
|
|
expectStatus("after", afterChecks, "electron.run_as_node", "not_observed");
|
|
expectStatus(
|
|
"after",
|
|
afterChecks,
|
|
"electron.node_cli_inspect",
|
|
"not_observed",
|
|
);
|
|
expectStatus(
|
|
"after",
|
|
afterChecks,
|
|
"electron.only_load_from_asar",
|
|
"not_observed",
|
|
);
|
|
expectStatus("after", afterChecks, "electron.asar_integrity", "unknown");
|
|
expectStatus(
|
|
"after",
|
|
afterChecks,
|
|
"packaging.extractable_payload",
|
|
"exposed",
|
|
);
|
|
expectStatus("after", afterChecks, "go.build_paths", "unknown");
|
|
expectStatus("after", afterChecks, "rust.dependency_metadata", "unknown");
|
|
const document = `# Release exposure verification 2026-10-31
|
|
|
|
Ran \`node scripts/verify-release-exposure.mjs\` with Node ${process.version}. The package script is \`npm run verify:release-exposure\`, which runs \`npm run build:cached\` first because \`scripts/rea.mjs\` loads the compiled CLI. \`@electron/fuses\` ${version} was installed into a temporary prefix and \`flipFuses\` was called on the after framework binary.
|
|
|
|
The fixture is \`MyApp.app\`. \`Contents/Resources/app.asar\` holds \`app.js\`. Before, it also holds \`app.js.map\` with \`sourcesContent\`. \`Contents/Frameworks/Electron Framework.framework/Electron Framework\` is an ELF larger than 256 KiB with the Electron fuse sentinel and a version-1 fuse wire. After, the archive is rebuilt without the map and \`flipFuses\` disables \`RunAsNode\` and \`EnableNodeCliInspectArguments\` and enables \`OnlyLoadAppFromAsar\` and \`EnableEmbeddedAsarIntegrityValidation\`. \`app.js\` remains, so \`packaging.extractable_payload\` stays exposed. Embedded ASAR integrity stays \`unknown\` on ELF. Go and Rust stay \`unknown\` because the framework binary is registered and has no build metadata; that is not \`not_applicable\`.
|
|
|
|
\`--fail_on high\` on the before app exited ${String(gated.status)}.
|
|
|
|
## Before
|
|
|
|
${table(beforeChecks)}
|
|
|
|
## After
|
|
|
|
${table(afterChecks)}
|
|
|
|
\`javascript.source_map_sources\`, \`electron.run_as_node\`, \`electron.node_cli_inspect\`, and \`electron.only_load_from_asar\` changed from \`exposed\` to \`not_observed\`. \`electron.asar_integrity\` stayed \`unknown\`. \`packaging.extractable_payload\` stayed \`exposed\`.
|
|
`;
|
|
await writeFile(OUTPUT, document);
|
|
console.log(`wrote ${OUTPUT}`);
|
|
console.log(`node ${process.version}`);
|
|
console.log(`@electron/fuses ${version}`);
|
|
console.log(`fail_on high exit ${String(gated.status)}`);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|