mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(artifacts): distinguish JVM classes from universal Mach-O files (#2327)
* test(artifacts): reproduce JVM class inventory misclassification * fix(artifacts): validate FAT declarations before classifying executables --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CPU, fatImage, machoImage } from "../apple/MachoImage.fixture.js";
|
||||
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
|
||||
|
||||
const fat = (wide = false): Buffer =>
|
||||
Buffer.from(fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }], wide));
|
||||
|
||||
describe("bounded FAT inventory classification", () => {
|
||||
it("requires the complete table but not slice bytes in the retained prefix", () => {
|
||||
const bytes = fat();
|
||||
for (const length of [4, 7, 8, 27]) {
|
||||
expect(
|
||||
classifyArtifactContent(
|
||||
"native",
|
||||
bytes.subarray(0, length),
|
||||
bytes.length,
|
||||
),
|
||||
).toEqual({ kind: "unknown", format: "unknown" });
|
||||
}
|
||||
expect(
|
||||
classifyArtifactContent(
|
||||
"native.class",
|
||||
bytes.subarray(0, 28),
|
||||
bytes.length,
|
||||
),
|
||||
).toEqual({ kind: "executable", format: "mach-o-universal" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty table", (bytes: Buffer) => bytes.writeUInt32BE(0, 4)],
|
||||
["slice overlapping table", (bytes: Buffer) => bytes.writeUInt32BE(0, 16)],
|
||||
["empty slice", (bytes: Buffer) => bytes.writeUInt32BE(0, 20)],
|
||||
[
|
||||
"slice beyond file",
|
||||
(bytes: Buffer) => bytes.writeUInt32BE(bytes.length, 20),
|
||||
],
|
||||
["unaligned slice", (bytes: Buffer) => bytes.writeUInt32BE(4097, 16)],
|
||||
["unsupported alignment", (bytes: Buffer) => bytes.writeUInt32BE(53, 24)],
|
||||
] as const)("keeps %s unknown", (_label, mutate) => {
|
||||
const bytes = fat();
|
||||
mutate(bytes);
|
||||
expect(classifyArtifactContent("native", bytes)).toEqual({
|
||||
kind: "unknown",
|
||||
format: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer Mach-O from an out-of-bounds architecture count", () => {
|
||||
const bytes = fat();
|
||||
bytes.writeUInt32BE(0xffffffff, 4);
|
||||
expect(classifyArtifactContent("native", bytes)).toEqual({
|
||||
kind: "resource",
|
||||
format: "file",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not lose unsafe FAT64 range values to number conversion", () => {
|
||||
const bytes = fat(true);
|
||||
bytes.writeBigUInt64BE(BigInt(Number.MAX_SAFE_INTEGER) + 1n, 16);
|
||||
expect(classifyArtifactContent("native", bytes)).toEqual({
|
||||
kind: "unknown",
|
||||
format: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires valid ranges for every declared slice", () => {
|
||||
const bytes = Buffer.from(
|
||||
fatImage([
|
||||
{ cpu: CPU.arm64, bytes: machoImage({}) },
|
||||
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
|
||||
]),
|
||||
);
|
||||
bytes.writeUInt32BE(bytes.length, 40);
|
||||
expect(classifyArtifactContent("native", bytes)).toEqual({
|
||||
kind: "unknown",
|
||||
format: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires observed slice magic for an unknown CPU type", () => {
|
||||
const cpu = { type: 0x12345678, subtype: 0 };
|
||||
const bytes = Buffer.from(fatImage([{ cpu, bytes: machoImage({ cpu }) }]));
|
||||
expect(
|
||||
classifyArtifactContent("native", bytes.subarray(0, 28), bytes.length),
|
||||
).toEqual({ kind: "unknown", format: "unknown" });
|
||||
expect(classifyArtifactContent("native", bytes)).toEqual({
|
||||
kind: "executable",
|
||||
format: "mach-o-universal",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([NaN, Infinity, -1, 8.5, Number.MAX_SAFE_INTEGER + 1, 27])(
|
||||
"keeps invalid or contradictory file size %s unknown",
|
||||
(size) => {
|
||||
const bytes = fat();
|
||||
expect(classifyArtifactContent("native", bytes, size)).toEqual({
|
||||
kind: "unknown",
|
||||
format: "unknown",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -63,6 +63,11 @@ export const readFatContainerHeader = (
|
||||
const magic = view.getUint32(0, false);
|
||||
if (![FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic))
|
||||
return { state: "not-fat" };
|
||||
if (!Number.isSafeInteger(fileSize) || fileSize < bytes.byteLength)
|
||||
return {
|
||||
state: "malformed",
|
||||
reason: "FAT file size is invalid or contradicts retained bytes",
|
||||
};
|
||||
if (bytes.byteLength < 8) {
|
||||
return fileSize < 8
|
||||
? { state: "malformed", reason: "FAT header is truncated" }
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import {
|
||||
CPU,
|
||||
fatImage,
|
||||
machoImage,
|
||||
} from "../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js";
|
||||
import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.for(["jar", "zip"])(
|
||||
"distinguishes JVM classes from universal Mach-O files in %s inventory",
|
||||
async (extension, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-class-inventory-");
|
||||
const path = join(root, `mixed.${extension}`);
|
||||
// The large class can hold the entire 65-entry table suggested by its
|
||||
// version bytes. Checking only the table length does not distinguish it.
|
||||
const classes = [
|
||||
["Hello.class", javaClass()],
|
||||
["Big.class", javaClass("x".repeat(4_000))],
|
||||
["renamed.bin", javaClass()],
|
||||
] as const;
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
for (const [name, bytes] of classes)
|
||||
await writer.add(name, new Uint8ArrayReader(bytes));
|
||||
const nativePaths: string[] = [];
|
||||
for (const wide of [false, true]) {
|
||||
for (const littleEndian of [false, true]) {
|
||||
const name = `fat${wide ? 64 : 32}-${littleEndian ? "le" : "be"}`;
|
||||
nativePaths.push(name);
|
||||
await writer.add(
|
||||
name,
|
||||
new Uint8ArrayReader(
|
||||
fatImage(
|
||||
[{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }],
|
||||
wide,
|
||||
littleEndian,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
await writeFile(path, await writer.close());
|
||||
|
||||
const reply = await cli.run({
|
||||
arguments: ["inspect-artifact", path, "--json"],
|
||||
});
|
||||
expect(reply.exitCode, reply.stdout + reply.stderr).toBe(0);
|
||||
const inspection = artifactInspectionResultSchema.parse(
|
||||
parseEvidence(reply.json).normalized_result,
|
||||
);
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
inspection.substeps[0]?.evidence.normalized_result,
|
||||
);
|
||||
for (const name of nativePaths) {
|
||||
expect(inventory.occurrences).toContainEqual(
|
||||
expect.objectContaining({
|
||||
logical_path: name,
|
||||
artifact_kind: "executable",
|
||||
artifact_format: "mach-o-universal",
|
||||
limitations: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const [name, bytes] of classes) {
|
||||
const occurrence = inventory.occurrences.find(
|
||||
(item) => item.logical_path === name,
|
||||
);
|
||||
expect(occurrence).toBeDefined();
|
||||
expect.soft(occurrence?.artifact_kind, name).not.toBe("executable");
|
||||
expect.soft(occurrence?.artifact_format, name).not.toMatch(/^mach-o/u);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const node = inventory.nodes.find((item) => item.sha256 === sha256);
|
||||
expect(node).toBeDefined();
|
||||
expect.soft(node?.format, name).not.toMatch(/^mach-o/u);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** Minimal valid Java 21 class, with an optional unused UTF-8 constant. */
|
||||
const javaClass = (extra?: string): Buffer => {
|
||||
const utf8 = (value: string): Buffer => {
|
||||
const text = Buffer.from(value);
|
||||
const header = Buffer.alloc(3);
|
||||
header[0] = 1;
|
||||
header.writeUInt16BE(text.length, 1);
|
||||
return Buffer.concat([header, text]);
|
||||
};
|
||||
const constants = [
|
||||
Buffer.from([7, 0, 2]),
|
||||
utf8("Hello"),
|
||||
Buffer.from([7, 0, 4]),
|
||||
utf8("java/lang/Object"),
|
||||
...(extra === undefined ? [] : [utf8(extra)]),
|
||||
];
|
||||
const header = Buffer.from("cafebabe000000410000", "hex");
|
||||
header.writeUInt16BE(constants.length + 1, 8);
|
||||
return Buffer.concat([
|
||||
header,
|
||||
...constants,
|
||||
Buffer.from("0021000100030000000000000000", "hex"),
|
||||
]);
|
||||
};
|
||||
Reference in New Issue
Block a user