fix(audit): confirm FAT identity for probed package members

The package probe treated a decoded FAT table as a universal Mach-O. A
JVM class past the 4 KiB probe shares the FAT magic, so its truncated
prefix decoded as a table, the member counted as an unread native image,
and Electron could not be ruled out of a complete scan.

Pass the member's declared size to the sniff and take identity from
classifyFatContainerEvidence. A member with no usable size, or with
slices past the probe, stays counted as possibly native.
This commit is contained in:
Matt Van Horn committed 2026-11-01 22:20:57 -08:00
1 parent bdbb070000
commit 86163530af
2 files changed
+67 -4

No files matched your search

@@ -21,6 +21,7 @@ import { asarHeaderBytes } from "../../tests/fixtures/asarHeader.js";
import { binaryPlistObjectTable } from "../../tests/fixtures/binaryPlistObjectTable.js";
import { javaClassFile } from "../../tests/fixtures/javaClassFile.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { universalMacho } from "../../tests/fixtures/universalMacho.js";
import { windowsNativeArtifactFixture } from "../../tests/fixtures/windowsNativeArtifact.js";
import {
ArtifactReaderFailure,
@@ -2021,6 +2022,36 @@ describe("ReleaseExposureReader unexpanded package PE reads", () => {
});
});
describe("ReleaseExposureReader probed FAT identity", () => {
const omittedNative = async (bytes: Buffer): Promise<number | undefined> => {
const root = await bundled([{ path: "member.bin", bytes }]);
const scanned = await inspect(root);
return scanned.omitted.find(
(item) => item.subject === PACKAGED_NATIVE_IMAGES,
)?.count;
};
it("keeps a JVM class past the probe out of the packaged native images", async () => {
const bytes = javaClassFile(MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES);
expect(bytes.length).toBeGreaterThan(
MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES,
);
await expect(omittedNative(bytes)).resolves.toBeUndefined();
});
it("counts a universal image whose slice headers sit past the probe", async () => {
const slice = Buffer.alloc(64);
slice.writeUInt32BE(0xcffaedfe, 0);
const { bytes, slices } = universalMacho([
{ bytes: slice, cpuType: 0x0100000c, cpuSubtype: 0 },
]);
expect(slices[0]?.offset).toBeGreaterThanOrEqual(
MAX_RELEASE_EXPOSURE_MEMBER_PROBE_BYTES,
);
await expect(omittedNative(bytes)).resolves.toBe(1);
});
});
describe("ReleaseExposureReader bounded listing and cleanup", () => {
it("reports sibling close failure with the completed scan and retains its owner", async () => {
const root = await createTestTempDirectory(
+36 -4
View File
@@ -52,6 +52,7 @@ import type {
ArtifactOccurrence,
} from "../domain/artifactGraph.js";
import {
classifyFatContainerEvidence,
readFatContainerHeader,
readMachoHeaderPrefixFacts,
} from "../domain/apple/machoContainer.js";
@@ -522,6 +523,7 @@ const scanOccurrences = async (
occurrence.artifact_kind,
),
encrypted: occurrence.encrypted,
declaredSize: occurrence.declared_size,
location,
packageFormat:
packaged === undefined ? null : asPackageFormat(formatOf(packaged)),
@@ -755,6 +757,8 @@ interface MemberView {
readonly format: string;
readonly kind: string;
readonly encrypted: boolean;
/** Full member size its owner declared, when one did. */
readonly declaredSize: number | null;
/** Reported path. A package member names its host file. */
readonly location: string;
readonly packageFormat: PackageFormat | null;
@@ -842,6 +846,7 @@ const examineSelectedFile = async (
format: rootFormat,
kind: "file",
encrypted: false,
declaredSize: bytes.length,
location: input.path,
packageFormat: null,
read: (budget) => Promise.resolve(fitAllowance(bytes, budget)),
@@ -1413,7 +1418,12 @@ const examinePackagedBinary = async (
const header = probe.kind === "bytes" ? probe.bytes : probe.prefix;
const native =
asNativeFormat(member.format) ??
(header === null ? null : sniffNativeFormat(header));
(header === null
? null
: sniffNativeFormat(
header,
probe.kind === "bytes" ? header.length : member.declaredSize,
));
if (native === null && header?.toString("latin1", 0, 2) === "MZ") {
const windows = observeMzWindowsHeader(header);
if (
@@ -1753,6 +1763,7 @@ const scanUnexpandedContainer = async (
? "javascript"
: ownerMemberKind(entry.formatMetadata, "file"),
encrypted: entry.encrypted,
declaredSize: entry.declaredSize,
location,
packageFormat: format,
read: (budget) =>
@@ -2293,8 +2304,29 @@ const hasCompanionName = (logical: string): boolean => {
);
};
/**
* FAT identity from the container owner. A prefix can only rule a universal
* image out against the member's real size: without one, or with slices past
* the prefix, the image may be native and stays counted as one.
*/
const sniffFatFormat = (
bytes: Buffer,
size: number | null,
): "mach-o-universal" | null => {
const known = size !== null && size >= bytes.length;
const header = readFatContainerHeader(bytes, known ? size : bytes.length);
if (header.state !== "parsed") return null;
return known &&
classifyFatContainerEvidence(header, bytes).state === "not-mach-o"
? null
: "mach-o-universal";
};
/** Header facts from the executable owners. A JVM class is not a FAT Mach-O. */
const sniffNativeFormat = (bytes: Buffer): NativeFormat | null => {
const sniffNativeFormat = (
bytes: Buffer,
size: number | null = bytes.length,
): NativeFormat | null => {
if (
bytes.length >= 4 &&
bytes[0] === 0x7f &&
@@ -2304,8 +2336,8 @@ const sniffNativeFormat = (bytes: Buffer): NativeFormat | null => {
)
return "elf";
if (readMachoHeaderPrefixFacts(bytes).format !== null) return "mach-o";
if (readFatContainerHeader(bytes, bytes.length).state === "parsed")
return "mach-o-universal";
const universal = sniffFatFormat(bytes, size);
if (universal !== null) return universal;
const windows = observeMzWindowsHeader(bytes);
if (windows.state === "declared" && windows.signature === "PE") return "pe";
return null;