fix(audit): bind unpacked ASAR member reads to their inventoried digests

A selected ASAR is captured and checked against its inventoried digest, which
binds packed members. An unpacked member is read from its .unpacked companion
on disk instead, and readContainerMember and readEntryBytes never compared
those bytes with the member digest inventory recorded, so a companion
replaced between inventory and the scan was accepted.

readContainerMember now passes the member's inventoried digest for unpacked
entries, and readStreamBounded hashes the full stream, draining past the read
allowance when needed, and fails with "ASAR member changed before
interpretation", which surfaces as an artifact_changed error like the
container check. A regression test replaces a companion after inventory. The
guide, spec, plan and tasks describe the binding.
This commit is contained in:
Matt Van Horn committed 2026-11-07 17:00:56 -08:00
1 parent fb32c969c0
commit ea87cf8984
6 files changed
+93 -6

No files matched your search

+1 -1
View File
@@ -50,7 +50,7 @@ The audit composes existing readers. It does not reread Electron fuse bytes itse
Source map text is counted and then dropped. A selected file is read through the stable artifact reader. Text and source-map members stay inside the allowances listed under `coverage.allowances`, and each kind has its own allowance, so binary members cannot use up the read that a later source map needs. Native images on disk are registered from inventory classification and are not limited to those allowances. Other files and package members are identified from a bounded header. The bytes a package member's text or map read returns decide before its name does, so a PE called `payload.js.map` is read as a PE. A header with no NUL byte, or one that starts with a UTF-16 byte order mark, is scanned as text, so names such as `.env.production`, `server.key`, and `credentials` do not depend on an extension list. UTF-16 without a byte order mark has NUL bytes and is not recognized this way. A member already known to be text, such as a script, is also scanned when it is UTF-8 holding a stray NUL. One whose header is at least an eighth NUL bytes is taken to be wide text. It is not decoded and is counted as skipped, as is UTF-32. A binary plist is decoded, so its strings, dictionary keys and data are scanned in whichever encoding each object uses. One that cannot be decoded still has its bytes scanned and is counted as skipped. A file whose header could not be read leaves `text.credential_markers` undecided. A packaged ELF, Mach-O, or PE image is read in full and written to a temporary copy, removed afterward, so it can be probed for an embedded package. All packaged images share one `max_file_bytes` allowance. An image that does not fit, or whose bytes cannot be read, is not probed and leaves member coverage `unknown`. A packaged PE is also read for its debug records. An SVG file is read for its scripts as an HTML file is. A script that an HTML file loads is examined as a script whatever it is called, and an HTML file loaded this way is still examined as HTML. One read before that HTML file, one that is not found, one at a location the audit cannot place, or one past the 1 MiB of resolved script paths the audit keeps (`MAX_RELEASE_EXPOSURE_SCRIPT_TARGET_BYTES`) leaves `javascript.source_map_sources` undecided. Skipped members are listed under `coverage.omitted`. When the selection is a file, the parent directory is listed up to a fixed entry allowance for sibling `.pdb`, `.dSYM`, `.debug`, and linker `.map` debug companions. A sibling source map that the selected file declares, such as `app.js.map`, or a sibling script that a selected HTML file loads, such as `app.js`, is not read as part of the artifact, so it leaves `javascript.source_map_sources` undecided.
Inventory stops at 65,536 entries or 2 GiB of hashed bytes, and member coverage then stays `unknown`. A declared integrity value that disagrees with the bytes does not stop the audit, because signing an unpacked native module after packaging is routine. The contradiction is recorded in the limitations and the audit continues.
Inventory stops at 65,536 entries or 2 GiB of hashed bytes, and member coverage then stays `unknown`. A packed ASAR member is read from the captured archive, whose digest must match inventory. An unpacked member is read from its `.unpacked` companion, so its full bytes must also match the digest inventory recorded for that member, and a companion replaced after inventory fails the audit as a changed artifact. A declared integrity value that disagrees with the bytes does not stop the audit, because signing an unpacked native module after packaging is routine. The contradiction is recorded in the limitations and the audit continues.
A member of a package that inventory did not expand is reported as the host file path followed by the member path, for example `/dist/app.dll/notes.txt`. Inventory-expanded members keep their path relative to the selection.
@@ -29,7 +29,10 @@ service.
Every file read is bound to its inventoried digest. A file or container whose
bytes differ from inventory at read time adds a change diagnostic and leaves the
affected checks `unknown`. Native images carry their
affected checks `unknown`. An unpacked ASAR member is read from its companion
on disk rather than the captured archive, so its bytes are compared with the
member digest that inventory recorded, and findings cannot mix an inventoried
release with bytes written later. Native images carry their
inventory digest into the Go, Rust and fuse reads.
## Effects and compatibility
@@ -53,7 +53,7 @@ what was observed, so a team can review or gate a release before it ships.
`data:` maps, HTML and SVG script loading, debug companions, credential markers
in text members and binary plists, UTF-16 and NUL handling, read allowances,
same-name package members, packaged native images and changed bytes after
inventory.
inventory, including an unpacked ASAR companion replaced after inventory.
- Service tests cover Electron fuse decisions, Go and Rust metadata, partial
inventory and cancellation with a partial report.
- CLI and MCP return the same report; `--max-file-bytes` accepts the documented
@@ -7,3 +7,4 @@
- [x] Verify real fixtures with `npm run verify:release-exposure`.
- [x] Document current behavior in the release exposure guide.
- [x] Accept decimal `--max-file-bytes` tokens through `cliDecimalField`.
- [x] Bind unpacked ASAR member reads to their inventoried digests.
+54 -1
View File
@@ -3,7 +3,7 @@ import { Readable } from "node:stream";
import { deflateRawSync } from "node:zlib";
import type { Dirent } from "node:fs";
import { mkdir, symlink, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it, vi } from "vitest";
@@ -734,6 +734,59 @@ describe("ReleaseExposureReader limits", () => {
});
});
describe("ReleaseExposureReader unpacked ASAR members", () => {
it("rejects an unpacked ASAR companion replaced after inventory", async () => {
const root = await createTestTempDirectory(
"rea-release-exposure-unpacked-",
);
const archive = join(root, "app.asar");
const companion = join(`${archive}.unpacked`, "dist", "notes.txt");
const notes = Buffer.from(
`-----BEGIN PRIVATE KEY-----\n${PLANTED_KEY}\n-----END PRIVATE KEY-----\n`,
);
await writeFile(
archive,
asarHeaderBytes(
JSON.stringify({
files: {
dist: {
files: { "notes.txt": { size: notes.length, unpacked: true } },
},
},
}),
),
);
await mkdir(dirname(companion), { recursive: true });
await writeFile(companion, notes);
expect((await inspect(archive)).textLocations).toContain("dist/notes.txt");
const actual = await vi.importActual<
typeof import("./inventory/ArtifactInventory.js")
>("./inventory/ArtifactInventory.js");
inventoryRead.impl = async (...args) => {
const inventory = await actual.inventoryArtifactInScope(...args);
await writeFile(companion, Buffer.alloc(notes.length, 0x61));
return inventory;
};
const reader = new ReleaseExposureReader();
try {
const result = await reader.inspect(
{ path: archive },
undefined,
ignoreText,
);
if (result.ok) throw new Error("Expected a failed audit.");
expect(result.error).toBeInstanceOf(AnalysisArtifactChangedError);
expect(result.error.message).toContain(
"ASAR member changed before interpretation: dist/notes.txt",
);
} finally {
inventoryRead.impl = undefined;
await reader.close();
}
});
});
describe("ReleaseExposureReader package cleanup", () => {
it("rejects a replaced inventoried package before listing and closes its reader", async () => {
const root = await createTestTempDirectory(
+32 -2
View File
@@ -1,4 +1,5 @@
import { isUtf8 } from "node:buffer";
import { createHash } from "node:crypto";
import type { Dirent } from "node:fs";
import { lstat, mkdtemp, opendir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -1889,6 +1890,7 @@ const readOccurrence = async (
containerPath,
containerFormat,
shaOf(container, nodeSha),
shaOf(occurrence, nodeSha),
occurrence,
ordinals,
containers,
@@ -2286,6 +2288,7 @@ const readContainerMember = async (
parentPath: string,
parentFormat: string,
expectedSha256: string | undefined,
memberSha256: string | undefined,
occurrence: InventoryOccurrence,
ordinals: ReadonlyMap<string, number>,
containers: Map<string, OpenContainer>,
@@ -2310,12 +2313,21 @@ const readContainerMember = async (
ordinals.get(occurrence.occurrence_id) ?? 0
];
if (entry === undefined || entry.kind !== "file") return { kind: "unread" };
// The container digest binds packed members to the captured snapshot. An
// unpacked member is read from its companion on disk, so it is bound to
// the digest inventory recorded for that member instead.
return await readEntryBytes(
container.reader,
entry,
budget,
state,
input.signal,
entry.unpacked === true && memberSha256 !== undefined
? {
sha256: memberSha256,
changed: `ASAR member changed before interpretation: ${occurrence.logical_path}`,
}
: undefined,
);
} catch (cause) {
rethrowReadFailure(cause);
@@ -2407,18 +2419,27 @@ const readEntryBytes = async (
budget: number,
state: ScanState,
signal: AbortSignal | undefined,
expected?: ExpectedMemberDigest,
): Promise<MemberRead> => {
const stream = await reader.open(entry, signal);
return readStreamBounded(stream, budget, state, signal);
return readStreamBounded(stream, budget, state, signal, expected);
};
/** An inventoried digest the member's complete bytes must still match. */
interface ExpectedMemberDigest {
readonly sha256: string;
readonly changed: string;
}
const readStreamBounded = async (
stream: Readable,
budget: number,
state: ScanState,
signal: AbortSignal | undefined,
expected?: ExpectedMemberDigest,
): Promise<MemberRead> => {
const chunks: Buffer[] = [];
const hash = expected === undefined ? undefined : createHash("sha256");
let total = 0;
let overBudget = false;
let failure: unknown;
@@ -2427,14 +2448,23 @@ const readStreamBounded = async (
if (signal?.aborted)
throw new AnalysisCancelledError(OPERATION, { cause: signal.reason });
const piece = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
hash?.update(piece);
// Past the allowance only the digest check still reads the bytes.
if (overBudget) continue;
chunks.push(piece);
total += piece.length;
state.memberBytesRead += piece.length;
if (total > budget) {
overBudget = true;
break;
if (hash === undefined) break;
}
}
if (
expected !== undefined &&
hash !== undefined &&
hash.digest("hex") !== expected.sha256
)
throw new ArtifactReaderFailure("integrity", expected.changed);
} catch (cause) {
failure = cause;
}