mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
* refactor(verify): give the package and release verifiers' roots to VerifierFixture * fix(verify): import VerifierFixture at the published-package verifier's top level
309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
/** Audit a local Electron .app before and after fuse and source-map remediation. */
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
import { importVerifierSource } from "./lib/verifier-source-loader.mjs";
|
|
import { createVerifierCommands } from "./lib/verify-commands.mjs";
|
|
import {
|
|
VerifierFixture,
|
|
withVerifierFixture,
|
|
} from "./lib/verifier-fixture.mjs";
|
|
|
|
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 commands.exec(process.execPath, args, {
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
timeout: 180000,
|
|
});
|
|
return { status: 0, stdout };
|
|
} catch (cause) {
|
|
const stdout = cause.snapshot?.stdout.text ?? "";
|
|
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 commands.exec(
|
|
"npm",
|
|
[
|
|
"install",
|
|
"--prefix",
|
|
prefix,
|
|
`@electron/fuses@${ELECTRON_FUSES_VERSION}`,
|
|
],
|
|
{ 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 commands = await createVerifierCommands();
|
|
const fixture = await VerifierFixture.open({
|
|
prefix: "rea-release-exposure-",
|
|
private: false,
|
|
});
|
|
const root = fixture.path;
|
|
// The root is removed only after the owned commands confirm.
|
|
fixture.releaseAfter(() => commands.close());
|
|
await withVerifierFixture(fixture, async () => {
|
|
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)}`);
|
|
});
|