From 921bb9575505065f6cfd57b14d97d80d0f238546 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:12:04 +0800 Subject: [PATCH] fix(artifacts): distinguish Java classes from universal Mach-O Classify FAT containers using shared architecture, range, and observed slice-header facts rather than the CAFEBABE signature alone. Preserve typed unsupported and malformed reasons, reject malformed sibling slices consistently, and remove repeated range parsing in consumers. Validation: valid small and large JVM classes in public JAR/ZIP inspection, four FAT width/byte-order controls, and focused executable/Mach-O boundary tests. The class fixtures were checked with JDK javap; no new JVM analysis capability is advertised. The Apple selection and Hopper readers retain their existing parsing because they also own oversized-count diagnostics, reserved fields, streaming final-size validation, or source freshness that the shared header result does not represent. Representative public mixed-ZIP inventory probe, four fresh processes with an already warm filesystem cache: first process elapsed 626.8 -> 548.3 ms, CPU 740 -> 740 ms, sampled family peak RSS 375.5 -> 345.4 MiB. Subsequent three-process medians: elapsed 767.2 -> 454.0 ms, CPU 890 -> 640 ms, peak RSS 368.7 -> 346.2 MiB. Estimated output tokens 2310 -> 2292. Concurrent load was uncontrolled, so these observations do not establish a speed improvement. Whole-agent task tokens and schema/call overhead were not measured. Fixes #2317 --- .../apple/MachoLoadCommandReader.test.ts | 22 ++- src/artifacts/apple/MachoLoadCommandReader.ts | 114 ++++------- .../inventory/ArtifactGraphConstruction.ts | 24 ++- src/domain/apple/machoContainer.ts | 181 ++++++++++++++++-- src/domain/binaryTarget.test.ts | 75 +++++--- src/domain/binaryTarget.ts | 113 ++++++----- .../artifactInventoryJvmClass.test.ts | 148 ++++++++++++++ tests/fixtures/javaClassFile.ts | 28 +++ 8 files changed, 535 insertions(+), 170 deletions(-) create mode 100644 tests/acceptance/artifactInventoryJvmClass.test.ts create mode 100644 tests/fixtures/javaClassFile.ts diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts index df26940bf..a7761ffe4 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.test.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts @@ -26,6 +26,7 @@ import { } from "./MachoLoadCommandReader.js"; import { traceDylibResolution } from "./DylibResolutionReader.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { javaClassFile } from "../../../tests/fixtures/javaClassFile.js"; const read = (bytes: Uint8Array) => readMachoImage(readerOf(bytes), bytes.length); @@ -345,10 +346,13 @@ describe("Mach-O load command reader inputs", () => { expect(i386.status === "parsed" && i386.slices[0]?.architecture).toBe( "i386", ); - // A Java class file shares the FAT magic; its version is not a slice count. - const javaClass = Uint8Array.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0x34]); - expect(hasMachoMagic(javaClass)).toBe(true); - expect(await read(javaClass)).toEqual({ status: "not-mach-o" }); + // A Java class file shares the FAT magic; version fields are not a slice count. + const javaClasses = [javaClassFile(), javaClassFile(4000)]; + expect(javaClasses.map(({ length }) => length)).toEqual([57, 4060]); + for (const javaClass of javaClasses) { + expect(hasMachoMagic(javaClass)).toBe(true); + expect(await read(javaClass)).toEqual({ status: "not-mach-o" }); + } expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({ status: "not-mach-o", }); @@ -489,6 +493,16 @@ describe("Mach-O load command reader failures", () => { reason: "FAT architecture 0 slice range extends beyond the file", }); }); + + it("preserves unsupported status for an unsupported FAT alignment exponent", async () => { + const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]); + new DataView(fat.buffer).setUint32(24, 53, false); + + expect(await read(fat)).toMatchObject({ + status: "unsupported", + reason: expect.stringContaining("alignment exponent 53 is unsupported"), + }); + }); }); it.each([true, false])( diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index 14b1aa070..6656153d0 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -4,8 +4,9 @@ import type { MachoSlice, } from "../../domain/apple/dylibResolution.js"; import { - readFatSliceDeclarations, - validateFatSlice, + classifyFatContainerIdentity, + readFatContainerHeader, + validateFatSliceIdentity, type FatSliceDeclaration, } from "../../domain/apple/machoContainer.js"; @@ -14,13 +15,6 @@ export type ReadAt = (offset: number, length: number) => Promise; /** Load commands larger than this are refused instead of buffered. */ export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024; -/** Target opening accepts up to 128 FAT records; Java class files share the FAT magic. */ -const MAX_FAT_ARCHITECTURES = 128; -/** CPU types of real Mach-O slices (x86, x86_64, arm, arm64, arm64_32, ppc, ppc64). */ -const KNOWN_CPU_TYPES: readonly number[] = [ - 7, 0x01000007, 12, 0x0100000c, 0x0200000c, 18, 0x01000012, -]; - const MH_MAGIC = 0xfeedface; const MH_MAGIC_64 = 0xfeedfacf; const MH_CIGAM = 0xcefaedfe; @@ -146,69 +140,41 @@ export const readMachoImage = async ( : { status: "not-mach-o" }; const view = viewOf(head); const magic = view.getUint32(0, false); - if ([FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic)) { - // FAT headers are big-endian; the swapped magics store them little-endian. - const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64; - const count = view.getUint32(4, littleEndian); - const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64; - // A Java class file's nonzero major version reads as the count, so an - // empty architecture table can only be a malformed universal header. - if (count === 0) - return { - status: "malformed", - reason: "FAT header declares no architectures", - }; - if (count > MAX_FAT_ARCHITECTURES || 8 + count * (wide ? 32 : 20) > size) - return { status: "not-mach-o" }; - const tableBytes = await readExact( - readAt, - 8, - count * (wide ? 32 : 20), - "FAT table", - ); - const declarations = readFatSliceDeclarations(tableBytes, { - count, - wide, - littleEndian, - }); - if (declarations.status !== "parsed") - throw new MachoFormatIssue("malformed", declarations.reason); - // A Java class file's version fields read as a FAT count; its "table" names no CPU. - if ( - !declarations.slices.some(({ cpuType }) => - KNOWN_CPU_TYPES.includes(cpuType), - ) - ) { - let hasMachOSliceHeader = false; - for (const declaration of declarations.slices) { - if ( - validateFatSlice( - declaration, - size, - 8 + count * (wide ? 32 : 20), - ) !== null - ) - continue; - const bytes = await readAt(declaration.offset, 4); - if (bytes.byteLength < 4) continue; - const sliceMagic = viewOf(bytes).getUint32(0, false); - if ( - [MH_MAGIC, MH_MAGIC_64, MH_CIGAM, MH_CIGAM_64].includes(sliceMagic) - ) { - hasMachOSliceHeader = true; - break; - } - } - if (!hasMachOSliceHeader) return { status: "not-mach-o" }; + let header = readFatContainerHeader(head, size); + if (header.state !== "not-fat") { + if (header.state === "needs-data") { + header = readFatContainerHeader( + await readAt(0, header.requiredBytes), + size, + ); } + if (header.state === "not-fat") return { status: "not-mach-o" }; + if (header.state === "needs-data") + throw new MachoFormatIssue("malformed", "FAT table is truncated"); + if (header.state === "malformed") + throw new MachoFormatIssue("malformed", header.reason); + let identity = classifyFatContainerIdentity(header, () => undefined); + if (identity.state === "needs-slice-header") { + const sliceHeaders = new Map(); + for (const offset of identity.offsets) { + const bytes = await readAt(offset, 4); + sliceHeaders.set( + offset, + bytes.byteLength < 4 ? null : viewOf(bytes).getUint32(0, false), + ); + } + identity = classifyFatContainerIdentity(header, (offset) => + sliceHeaders.get(offset), + ); + } + if (identity.state === "not-mach-o") return { status: "not-mach-o" }; + if (identity.state === "malformed") + throw new MachoFormatIssue(identity.category, identity.reason); + if (identity.state === "needs-slice-header") + return { status: "not-mach-o" }; return { status: "parsed", - slices: await readFatSlices( - readAt, - size, - 8 + count * (wide ? 32 : 20), - declarations.slices, - ), + slices: await readFatSlices(readAt, header.slices), }; } if (magic === MH_CIGAM || magic === MH_CIGAM_64) @@ -228,25 +194,17 @@ export const readMachoImage = async ( const readFatSlices = async ( readAt: ReadAt, - size: number, - tableEnd: number, declarations: readonly FatSliceDeclaration[], ): Promise => { const slices: MachoSlice[] = []; for (const declaration of declarations) { const { offset, size: length, cpuType, cpuSubtype } = declaration; - const rangeIssue = validateFatSlice(declaration, size, tableEnd); - if (rangeIssue !== null) - throw new MachoFormatIssue( - rangeIssue.includes("unsupported") ? "unsupported" : "malformed", - rangeIssue, - ); const slice = await readSlice(readAt, offset, length, { fatCpuType: cpuType, fatCpuSubtype: cpuSubtype, alignmentExponent: declaration.alignmentExponent, }); - const identityIssue = validateFatSlice(declaration, size, tableEnd, { + const identityIssue = validateFatSliceIdentity(declaration, { cpuType: slice.cpu_type, cpuSubtype: slice.cpu_subtype, }); diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 73c6b4d1b..35bd2f22d 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -22,6 +22,10 @@ import { observeMzWindowsHeader, parseDosMzHeader, } from "../../domain/dosMz.js"; +import { + classifyFatContainerIdentity, + readFatContainerHeader, +} from "../../domain/apple/machoContainer.js"; /** Mutable internal occurrence used until root-bound IDs are known. */ export interface MutableOccurrence { @@ -437,10 +441,10 @@ export const classifyArtifactBytes = ( ): ArtifactNode["format"] => { if (hasZipSignature(prefix)) return "zip"; if (prefix.subarray(0, 8).equals(Buffer.from("bplist00"))) return "plist"; + const fatFormat = classifyFatArtifact(prefix, fileSize); + if (fatFormat !== null) return fatFormat; if (prefix.length >= 4) { const magic = prefix.readUInt32BE(0); - if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic)) - return "mach-o-universal"; if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) return "mach-o"; if (prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) @@ -471,6 +475,22 @@ export const classifyArtifactBytes = ( return "file"; }; +const classifyFatArtifact = ( + prefix: Buffer, + fileSize: number, +): ArtifactNode["format"] | null => { + const fatHeader = readFatContainerHeader(prefix, fileSize); + if (fatHeader.state === "needs-data" || fatHeader.state === "malformed") + return "unknown"; + if (fatHeader.state === "not-fat") return null; + const identity = classifyFatContainerIdentity(fatHeader, (offset) => { + if (offset >= prefix.length) return undefined; + if (offset + 4 > prefix.length) return null; + return prefix.readUInt32BE(offset); + }); + return identity.state === "mach-o" ? "mach-o-universal" : "unknown"; +}; + const relationFor = (path: string): ArtifactEdge["relation"] => { const lower = path.toLowerCase(); if (lower.endsWith(".map")) return "maps-source"; diff --git a/src/domain/apple/machoContainer.ts b/src/domain/apple/machoContainer.ts index 55a809be6..7b552be64 100644 --- a/src/domain/apple/machoContainer.ts +++ b/src/domain/apple/machoContainer.ts @@ -8,6 +8,136 @@ export interface FatSliceDeclaration { readonly cpuSubtype: number; } +export interface FatSliceRangeIssue { + readonly category: "malformed" | "unsupported"; + readonly reason: string; +} + +export type FatContainerHeader = + | { readonly state: "not-fat" } + | { readonly state: "needs-data"; readonly requiredBytes: number } + | { readonly state: "malformed"; readonly reason: string } + | { + readonly state: "parsed"; + readonly magic: number; + readonly count: number; + readonly wide: boolean; + readonly littleEndian: boolean; + readonly tableEnd: number; + readonly slices: readonly FatSliceDeclaration[]; + readonly rangeIssues: readonly (FatSliceRangeIssue | null)[]; + }; + +export type FatContainerIdentity = + | { readonly state: "mach-o" } + | { readonly state: "not-mach-o" } + | { + readonly state: "needs-slice-header"; + readonly offsets: readonly number[]; + } + | { + readonly state: "malformed"; + readonly category: "malformed" | "unsupported"; + readonly reason: string; + }; + +const FAT_MAGIC = 0xcafebabe; +const FAT_MAGIC_64 = 0xcafebabf; +const FAT_CIGAM = 0xbebafeca; +const FAT_CIGAM_64 = 0xbfbafeca; +const MAX_FAT_ARCHITECTURES = 128; +const KNOWN_MACHO_CPU_TYPES: readonly number[] = [ + 7, 0x01000007, 12, 0x0100000c, 0x0200000c, 18, 0x01000012, +]; +const MACHO_HEADER_MAGICS: readonly number[] = [ + 0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, +]; + +/** Parse a bounded FAT header and table without treating the magic alone as proof. */ +export const readFatContainerHeader = ( + bytes: Uint8Array, + fileSize: number, +): FatContainerHeader => { + if (bytes.byteLength < 4) return { state: "not-fat" }; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const magic = view.getUint32(0, false); + if (![FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic)) + return { state: "not-fat" }; + if (bytes.byteLength < 8) { + return fileSize < 8 + ? { state: "malformed", reason: "FAT header is truncated" } + : { state: "needs-data", requiredBytes: 8 }; + } + const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64; + const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64; + const count = view.getUint32(4, littleEndian); + if (count === 0) + return { + state: "malformed", + reason: "FAT header declares no architectures", + }; + if (count > MAX_FAT_ARCHITECTURES) return { state: "not-fat" }; + const tableEnd = 8 + count * (wide ? 32 : 20); + if (tableEnd > fileSize) return { state: "not-fat" }; + if (tableEnd > bytes.byteLength) + return { state: "needs-data", requiredBytes: tableEnd }; + const declarations = readFatSliceDeclarations(bytes.subarray(8, tableEnd), { + count, + wide, + littleEndian, + }); + return declarations.status === "parsed" + ? { + state: "parsed", + magic, + count, + wide, + littleEndian, + tableEnd, + slices: declarations.slices, + rangeIssues: declarations.slices.map((slice) => + inspectFatSliceRange(slice, fileSize, tableEnd), + ), + } + : { state: "malformed", reason: declarations.reason }; +}; + +/** Confirm FAT identity from architecture records and observed slice headers. */ +export const classifyFatContainerIdentity = ( + header: Extract, + sliceHeaderAt: (offset: number) => number | null | undefined, +): FatContainerIdentity => { + const hasKnownCpu = header.slices.some(({ cpuType }) => + KNOWN_MACHO_CPU_TYPES.includes(cpuType), + ); + const invalidRange = header.rangeIssues.find( + (issue): issue is FatSliceRangeIssue => issue !== null, + ); + if (hasKnownCpu) + return invalidRange === undefined + ? { state: "mach-o" } + : { state: "malformed", ...invalidRange }; + const validRanges = header.slices.filter( + (_slice, index) => header.rangeIssues[index] === null, + ); + const missingHeaders: number[] = []; + let observedMachO = false; + for (const slice of validRanges) { + const magic = sliceHeaderAt(slice.offset); + if (magic === undefined) missingHeaders.push(slice.offset); + else if (magic !== null && MACHO_HEADER_MAGICS.includes(magic)) + observedMachO = true; + } + if (observedMachO) { + return invalidRange === undefined + ? { state: "mach-o" } + : { state: "malformed", ...invalidRange }; + } + if (missingHeaders.length > 0) + return { state: "needs-slice-header", offsets: missingHeaders }; + return { state: "not-mach-o" }; +}; + /** Decode FAT table entries once so artifact and native readers share identity facts. */ export const readFatSliceDeclarations = ( tableBytes: Uint8Array, @@ -64,32 +194,61 @@ export const validateFatSlice = ( tableEnd: number, embedded?: { readonly cpuType: number; readonly cpuSubtype: number }, ): string | null => { - const { index, offset, size, alignmentExponent, cpuType, cpuSubtype } = - declaration; + const rangeIssue = inspectFatSliceRange(declaration, containerSize, tableEnd); + if (rangeIssue !== null) return rangeIssue.reason; + return embedded === undefined + ? null + : validateFatSliceIdentity(declaration, embedded); +}; + +/** Validate table placement, container bounds, and alignment with a typed failure. */ +export const inspectFatSliceRange = ( + declaration: FatSliceDeclaration, + containerSize: number, + tableEnd: number, +): FatSliceRangeIssue | null => { + const { index, offset, size, alignmentExponent } = declaration; if ( !Number.isSafeInteger(offset) || !Number.isSafeInteger(size) || size <= 0 || offset < tableEnd ) - return `FAT architecture ${index} has an invalid slice range`; + return { + category: "malformed", + reason: `FAT architecture ${index} has an invalid slice range`, + }; if (offset > containerSize || size > containerSize - offset) - return `FAT architecture ${index} slice range extends beyond the file`; + return { + category: "malformed", + reason: `FAT architecture ${index} slice range extends beyond the file`, + }; if ( !Number.isInteger(alignmentExponent) || alignmentExponent < 0 || alignmentExponent > 52 ) - return `FAT architecture ${index} alignment exponent ${alignmentExponent} is unsupported`; + return { + category: "unsupported", + reason: `FAT architecture ${index} alignment exponent ${alignmentExponent} is unsupported`, + }; if (offset % 2 ** alignmentExponent !== 0) - return `FAT architecture ${index} offset is not aligned to 2^${alignmentExponent}`; - if ( - embedded !== undefined && - (embedded.cpuType !== cpuType || embedded.cpuSubtype !== cpuSubtype) - ) - return `FAT architecture ${index} CPU type/subtype disagrees with its Mach-O header`; + return { + category: "malformed", + reason: `FAT architecture ${index} offset is not aligned to 2^${alignmentExponent}`, + }; return null; }; +/** Validate only the identity claim after the owner has validated its range. */ +export const validateFatSliceIdentity = ( + declaration: FatSliceDeclaration, + embedded: { readonly cpuType: number; readonly cpuSubtype: number }, +): string | null => + embedded.cpuType === declaration.cpuType && + embedded.cpuSubtype === declaration.cpuSubtype + ? null + : `FAT architecture ${declaration.index} CPU type/subtype disagrees with its Mach-O header`; + const safeNumber = (value: bigint): number | null => value > BigInt(Number.MAX_SAFE_INTEGER) ? null : Number(value); diff --git a/src/domain/binaryTarget.test.ts b/src/domain/binaryTarget.test.ts index bf66eaa29..7d4cf322c 100644 --- a/src/domain/binaryTarget.test.ts +++ b/src/domain/binaryTarget.test.ts @@ -176,42 +176,53 @@ describe("executable header completeness", () => { expect(reason(header, 132)).toBeNull(); }); - it("rejects a FAT file whose host slice extends past the file", () => { + it("rejects an invalid FAT slice even when another host slice is valid", () => { const bytes = fat([0x01000007, 0x0100000c]); bytes.writeUInt32BE(4096, 8 + 20 + 8); bytes.writeUInt32BE(64, 8 + 20 + 12); expect(reason(bytes, 4100)).toBe( - "truncated FAT slice: the arm64 slice ends at byte 4160; the file has 4100", + "FAT architecture 1 slice range extends beyond the file", ); expect(reason(bytes, 4160)).toBeNull(); - // Only the slice REA would analyze must be present. - expect(parseExecutableHeader(bytes, "x64", 4100).ok).toBe(true); + expect(parseExecutableHeader(bytes, "x64", 4100)).toMatchObject({ + ok: false, + error: "FAT architecture 1 slice range extends beyond the file", + }); }); it.each([ { label: "empty range", - corruptRange: (bytes: Buffer, wide: boolean, little: boolean) => - setFatSliceRange(bytes, wide, little, "size", 0n), - error: "FAT slice for arm64 is shorter than its Mach-O header", + corruptRange: (bytes: Buffer, wide: boolean, littleEndian: boolean) => + setFatSliceRange(bytes, { + wide, + littleEndian, + field: "size", + value: 0n, + }), + error: "FAT architecture 1 has an invalid slice range", }, { label: "table overlap", - corruptRange: (bytes: Buffer, wide: boolean, little: boolean) => - setFatSliceRange( - bytes, + corruptRange: (bytes: Buffer, wide: boolean, littleEndian: boolean) => + setFatSliceRange(bytes, { wide, - little, - "offset", - BigInt(8 + 2 * (wide ? 32 : 20) - 1), - ), - error: "FAT slice for arm64 overlaps the architecture table", + littleEndian, + field: "offset", + value: BigInt(8 + 2 * (wide ? 32 : 20) - 1), + }), + error: "FAT architecture 1 has an invalid slice range", }, { label: "undersized 64-bit header", - corruptRange: (bytes: Buffer, wide: boolean, little: boolean) => - setFatSliceRange(bytes, wide, little, "size", 31n), + corruptRange: (bytes: Buffer, wide: boolean, littleEndian: boolean) => + setFatSliceRange(bytes, { + wide, + littleEndian, + field: "size", + value: 31n, + }), error: "FAT slice for arm64 is shorter than its Mach-O header", }, ])("rejects a selected FAT slice with $label", ({ corruptRange, error }) => { @@ -234,11 +245,23 @@ describe("executable header completeness", () => { it("uses a 28-byte minimum for a selected 32-bit Mach-O header", () => { const exactHeader = fat([7]); - setFatSliceRange(exactHeader, false, false, "size", 28n, 0); + setFatSliceRange(exactHeader, { + wide: false, + littleEndian: false, + field: "size", + value: 28n, + index: 0, + }); expect(parseExecutableHeader(exactHeader, "ia32").ok).toBe(true); const undersizedHeader = Buffer.from(exactHeader); - setFatSliceRange(undersizedHeader, false, false, "size", 27n, 0); + setFatSliceRange(undersizedHeader, { + wide: false, + littleEndian: false, + field: "size", + value: 27n, + index: 0, + }); expect(parseExecutableHeader(undersizedHeader, "ia32")).toMatchObject({ ok: false, @@ -249,12 +272,16 @@ describe("executable header completeness", () => { const setFatSliceRange = ( bytes: Buffer, - wide: boolean, - littleEndian: boolean, - field: "offset" | "size", - value: bigint, - index = 1, + options: { + readonly wide: boolean; + readonly littleEndian: boolean; + readonly field: "offset" | "size"; + readonly value: bigint; + readonly index?: number; + }, ): void => { + const { wide, littleEndian, field, value } = options; + const index = options.index ?? 1; const entry = 8 + index * (wide ? 32 : 20); const fieldOffset = entry + (field === "offset" ? 8 : wide ? 16 : 12); if (wide) { diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 6cb0fc0fa..eca1174d2 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -9,6 +9,10 @@ import type { import { err, ok, type Result } from "./result.js"; import { observeMzWindowsHeader, parseDosMzHeader } from "./dosMz.js"; import { decodePeMachine } from "./peMachine.js"; +import { + classifyFatContainerIdentity, + readFatContainerHeader, +} from "./apple/machoContainer.js"; /** Format and architecture facts recovered from an executable header. */ export type ExecutableMetadata = @@ -68,8 +72,9 @@ export const parseExecutableHeader = ( const magic = bytes.readUInt32BE(0); if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) return parseThinMachO(bytes, magic, fileSize); - if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic)) - return parseFatMachO(bytes, magic, hostArchitecture, fileSize); + const fatHeader = readFatContainerHeader(bytes, fileSize); + if (fatHeader.state !== "not-fat") + return parseFatMachO(bytes, hostArchitecture, fatHeader); return err("unsupported binary format"); }; @@ -103,79 +108,85 @@ const parseThinMachO = ( const parseFatMachO = ( bytes: Buffer, - magic: number, host: NodeJS.Architecture, - fileSize: number, + header: Exclude< + ReturnType, + { state: "not-fat" } + >, ): Result => { - const little = magic === 0xbebafeca || magic === 0xbfbafeca; - const is64 = magic === 0xcafebabf || magic === 0xbfbafeca; - if (bytes.length < 8) return err("truncated FAT header"); - const read = (offset: number): number => - little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset); - const count = read(4); - const entrySize = is64 ? 32 : 20; - if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize) + if (header.state === "needs-data") return err("truncated or invalid FAT architecture table"); + if (header.state === "malformed") return err(header.reason); + const identityError = fatIdentityError(bytes, header); + if (identityError !== null) return err(identityError); const architectures: HeaderArchitecture[] = []; const slices = new Map< HeaderArchitecture, - { readonly offset: bigint; readonly size: bigint } + { readonly offset: number; readonly size: number } >(); - for (let index = 0; index < count; index += 1) { - const entry = 8 + index * entrySize; - const architecture = machArchitecture(read(entry)); + for (const declaration of header.slices) { + const architecture = machArchitecture(declaration.cpuType); if (architecture === undefined || architectures.includes(architecture)) continue; architectures.push(architecture); - const offset = is64 - ? little - ? bytes.readBigUInt64LE(entry + 8) - : bytes.readBigUInt64BE(entry + 8) - : BigInt(read(entry + 8)); - const size = is64 - ? little - ? bytes.readBigUInt64LE(entry + 16) - : bytes.readBigUInt64BE(entry + 16) - : BigInt(read(entry + 12)); - slices.set(architecture, { offset, size }); + slices.set(architecture, { + offset: declaration.offset, + size: declaration.size, + }); } - const preferred = - host === "arm64" - ? "arm64" - : host === "x64" - ? "x86_64" - : host === "ia32" - ? "x86" - : host === "arm" - ? "arm" - : undefined; + const preferred = fatHostArchitecture(host); const slice = preferred === undefined ? undefined : slices.get(preferred); if (preferred === undefined || slice === undefined) return err(`FAT binary has no host-compatible ${host} architecture`); - const tableEnd = BigInt(8 + count * entrySize); - if (slice.offset < tableEnd) - return err(`FAT slice for ${preferred} overlaps the architecture table`); const minimumHeaderSize = - preferred === "arm64" || preferred === "x86_64" ? 32n : 28n; + preferred === "arm64" || preferred === "x86_64" ? 32 : 28; if (slice.size < minimumHeaderSize) return err(`FAT slice for ${preferred} is shorter than its Mach-O header`); - const sliceEnd = slice.offset + slice.size; - if (sliceEnd > BigInt(fileSize)) - return err( - `truncated FAT slice: the ${preferred} slice ends at byte ${sliceEnd}; the file has ${fileSize}`, - ); - const sliceOffset = Number(slice.offset); - const sliceSize = Number(slice.size); - if (!Number.isSafeInteger(sliceOffset) || !Number.isSafeInteger(sliceSize)) - return err(`FAT slice for ${preferred} exceeds addressable byte bounds`); return ok({ format: "mach-o-universal", architecture: preferred, availableArchitectures: architectures, - universalSlice: { offset: sliceOffset, size: sliceSize }, + universalSlice: { offset: slice.offset, size: slice.size }, }); }; +const fatHostArchitecture = ( + host: NodeJS.Architecture, +): HeaderArchitecture | undefined => { + switch (host) { + case "arm64": + return "arm64"; + case "x64": + return "x86_64"; + case "ia32": + return "x86"; + case "arm": + return "arm"; + default: + return undefined; + } +}; + +const fatIdentityError = ( + bytes: Buffer, + header: Extract< + ReturnType, + { state: "parsed" } + >, +): string | null => { + const identity = classifyFatContainerIdentity(header, (offset) => { + if (offset >= bytes.length) return undefined; + if (offset + 4 > bytes.length) return null; + return bytes.readUInt32BE(offset); + }); + if (identity.state === "needs-slice-header") + return "FAT slice identity is outside the bounded prefix"; + if (identity.state === "not-mach-o") + return "FAT table does not identify a Mach-O image"; + if (identity.state === "malformed") return identity.reason; + return null; +}; + const parseElf = ( bytes: Buffer, fileSize: number, diff --git a/tests/acceptance/artifactInventoryJvmClass.test.ts b/tests/acceptance/artifactInventoryJvmClass.test.ts new file mode 100644 index 000000000..408888ce7 --- /dev/null +++ b/tests/acceptance/artifactInventoryJvmClass.test.ts @@ -0,0 +1,148 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it } from "vitest"; + +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { + fatImage, + CPU, + machoImage, +} from "../../src/artifacts/apple/MachoImage.fixture.js"; +import { javaClassFile } from "../fixtures/javaClassFile.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +const execute = promisify(execFile); +const cli = fileURLToPath(new URL("../../scripts/rea.mjs", import.meta.url)); + +const archive = async ( + entries: readonly (readonly [string, Uint8Array])[], +): Promise => { + const writer = new ZipWriter(new Uint8ArrayWriter()); + for (const [name, bytes] of entries) + await writer.add(name, new Uint8ArrayReader(bytes)); + return Buffer.from(await writer.close()); +}; + +const inspectArchive = async (path: string) => { + const result = await execute(process.execPath, [ + cli, + "inspect-artifact", + path, + "--json", + ]); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(JSON.parse(result.stdout)).normalized_result, + ); + const evidence = inspection.substeps[0]?.evidence; + if (evidence === undefined) + throw new Error("CLI inspection did not retain inventory Evidence"); + return artifactInventoryResultSchema.parse(evidence.normalized_result); +}; + +it("keeps valid JVM classes from being classified as Mach-O while identifying FAT controls", async () => { + const directory = await createTestTempDirectory("rea-jvm-inventory-"); + const javaEntries = [ + ["Hello.class", javaClassFile()], + ["pkg/Big.class", javaClassFile(4000)], + ] as const; + const fatControls = [ + [ + "fat-be32.bin", + fatImage([{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }]), + ], + [ + "fat-le32.bin", + fatImage( + [{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }], + false, + true, + ), + ], + [ + "fat-be64.bin", + fatImage( + [{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }], + true, + ), + ], + [ + "fat-le64.bin", + fatImage( + [{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }], + true, + true, + ), + ], + ] as const; + + const jarPath = join(directory, "classes.jar"); + await writeFile( + jarPath, + await archive([ + ["META-INF/MANIFEST.MF", Buffer.from("Manifest-Version: 1.0\n\n")], + ...javaEntries, + ]), + ); + const jar = await inspectArchive(jarPath); + expect( + jar.occurrences + .filter(({ logical_path }) => logical_path.endsWith(".class")) + .map(({ logical_path, artifact_kind, artifact_format }) => ({ + logical_path, + artifact_kind, + artifact_format, + })), + ).toEqual([ + { + logical_path: "Hello.class", + artifact_kind: "resource", + artifact_format: "file", + }, + { + logical_path: "pkg/Big.class", + artifact_kind: "unknown", + artifact_format: "unknown", + }, + ]); + const jarClassIds = new Set( + jar.occurrences + .filter(({ logical_path }) => logical_path.endsWith(".class")) + .map(({ artifact_id }) => artifact_id), + ); + expect( + jar.nodes + .filter(({ artifact_id }) => jarClassIds.has(artifact_id)) + .map(({ format }) => format) + .sort(), + ).toEqual(["file", "unknown"]); + + const mixedPath = join(directory, "mixed.zip"); + await writeFile(mixedPath, await archive([...javaEntries, ...fatControls])); + const mixed = await inspectArchive(mixedPath); + const formatsByPath = new Map( + mixed.occurrences.map( + ({ logical_path, artifact_kind, artifact_format }) => [ + logical_path, + { artifact_kind, artifact_format }, + ], + ), + ); + for (const [name] of javaEntries) + expect(formatsByPath.get(name)).toEqual( + name === "Hello.class" + ? { artifact_kind: "resource", artifact_format: "file" } + : { artifact_kind: "unknown", artifact_format: "unknown" }, + ); + for (const [name] of fatControls) + expect(formatsByPath.get(name)).toEqual({ + artifact_kind: "executable", + artifact_format: "mach-o-universal", + }); +}); diff --git a/tests/fixtures/javaClassFile.ts b/tests/fixtures/javaClassFile.ts new file mode 100644 index 000000000..4f7727193 --- /dev/null +++ b/tests/fixtures/javaClassFile.ts @@ -0,0 +1,28 @@ +/** Build a valid minimal JVM class file, optionally with an unused UTF-8 constant. */ +export const javaClassFile = (extraUtf8Bytes = 0): Buffer => { + const utf8 = (value: string): Buffer => { + const bytes = Buffer.from(value, "utf8"); + const entry = Buffer.alloc(3 + bytes.length); + entry[0] = 1; + entry.writeUInt16BE(bytes.length, 1); + bytes.copy(entry, 3); + return entry; + }; + const constants = [ + Buffer.from([7, 0, 2]), + utf8("Hello"), + Buffer.from([7, 0, 4]), + utf8("java/lang/Object"), + ...(extraUtf8Bytes === 0 ? [] : [utf8("x".repeat(extraUtf8Bytes))]), + ]; + const headerAndPool = Buffer.alloc(10); + headerAndPool.writeUInt32BE(0xcafebabe, 0); + headerAndPool.writeUInt16BE(0, 4); + headerAndPool.writeUInt16BE(65, 6); + headerAndPool.writeUInt16BE(constants.length + 1, 8); + const classBody = Buffer.alloc(14); + [0x21, 1, 3, 0, 0, 0, 0].forEach((value, index) => + classBody.writeUInt16BE(value, index * 2), + ); + return Buffer.concat([headerAndPool, ...constants, classBody]); +};