mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 06:15:59 +00:00
* feat(elf): decode Linux core dump process and memory mapping notes Inspect Linux core dump note tables and decode structured metadata without requiring external tools or debuggers: - NT_PRPSINFO (type 3): decode process ID, parent ID, process group, session, UID, GID, execution state, filename, and command line arguments bounded by the text budget. - NT_FILE (type 0x46494c45): decode mapped memory ranges (start_address, end_address, file_offset_pages) and mapped file paths with page size. - NT_SIGINFO (type 0x53494749): decode faulting signal number, errno, and code. - NT_AUXV (type 6): decode auxiliary vector pairs with known AT_* tag names. - Support both 32-bit and 64-bit, little-endian and big-endian targets. - Retain unknown core note types gracefully without partial success errors. - Add comprehensive unit tests, fixtures, acceptance tests, and documentation. * fix(elf): preserve raw path bytes and enforce auxv termination * fix(elf): account for path hex text budget and enforce auxv trailing padding * fix(elf): account for row and scan limits, ABI signal order, and lossless source IDs * fix(elf): cache auxv padding scans, pre-charge entries, and isolate page-size test
1083 lines
32 KiB
TypeScript
1083 lines
32 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { expect, it } from "vitest";
|
|
import { inspectElfNotesInputSchema } from "../../domain/native/elfNotes.js";
|
|
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
|
import { decodeElfSectionNameTable } from "../metadata/ElfSectionNameTable.js";
|
|
import { readElfDirectoryFromBuffer } from "../metadata/NativeSectionDirectory.js";
|
|
import {
|
|
compressedElfNoteFixture,
|
|
compressedElfNoteSectionFixture,
|
|
coreAuxvNote,
|
|
coreFileNote,
|
|
corePrpsinfoNote,
|
|
coreSiginfoNote,
|
|
elfNote,
|
|
elfNoteFixture,
|
|
elfNoteTablePastEnd,
|
|
elfExtendedSectionCountFixture,
|
|
gnuAbiTagNote,
|
|
gnuBuildIdNote,
|
|
gnuPropertyNote,
|
|
} from "./ElfNotes.fixture.js";
|
|
import {
|
|
ElfNoteSectionNameBudgetFailure,
|
|
parseElfNotes,
|
|
} from "./ElfNoteParser.js";
|
|
|
|
const defaults = inspectElfNotesInputSchema.parse({
|
|
path: "/artifacts/app",
|
|
});
|
|
|
|
const parse = (bytes: Buffer, input: typeof defaults = defaults) =>
|
|
parseElfNotes(
|
|
bytes,
|
|
{
|
|
path: input.path,
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
bytes: bytes.length,
|
|
},
|
|
input,
|
|
);
|
|
|
|
const elf64 = (
|
|
fields: {
|
|
readonly phoff?: number;
|
|
readonly shoff?: number;
|
|
readonly phnum?: number;
|
|
readonly shnum?: number;
|
|
readonly phentsize?: number;
|
|
readonly shentsize?: number;
|
|
},
|
|
tail?: Buffer,
|
|
): Buffer => {
|
|
const header = Buffer.alloc(64);
|
|
header.write("\x7fELF", 0, "latin1");
|
|
header[4] = 2;
|
|
header[5] = 1;
|
|
header[6] = 1;
|
|
header.writeUInt16LE(2, 16);
|
|
header.writeUInt16LE(62, 18);
|
|
header.writeUInt32LE(1, 20);
|
|
header.writeBigUInt64LE(BigInt(fields.phoff ?? 0), 32);
|
|
header.writeBigUInt64LE(BigInt(fields.shoff ?? 0), 40);
|
|
header.writeUInt16LE(64, 52);
|
|
header.writeUInt16LE(fields.phentsize ?? 56, 54);
|
|
header.writeUInt16LE(fields.phnum ?? 0, 56);
|
|
header.writeUInt16LE(fields.shentsize ?? 64, 58);
|
|
header.writeUInt16LE(fields.shnum ?? 0, 60);
|
|
return tail === undefined ? header : Buffer.concat([header, tail]);
|
|
};
|
|
|
|
it("reads GNU build-id and ABI-tag notes from ELF32 and ELF64", () => {
|
|
for (const bits of [32, 64] as const) {
|
|
const bytes = elfNoteFixture({ bits, ownership: "program" });
|
|
const report = parse(bytes);
|
|
expect(report.format).toBe(bits === 64 ? "elf64" : "elf32");
|
|
expect(report.byte_order).toBe("little");
|
|
expect(report.tables).toHaveLength(1);
|
|
expect(report.tables[0]?.kind).toBe("program");
|
|
const buildId = report.entries.find(
|
|
(entry) => entry.type_name === "gnu_build_id",
|
|
);
|
|
const abi = report.entries.find(
|
|
(entry) => entry.type_name === "gnu_abi_tag",
|
|
);
|
|
expect(buildId?.gnu?.build_id_hex).toBe(
|
|
"0123456789abcdef0123456789abcdef01234567",
|
|
);
|
|
expect(abi?.gnu?.abi_tag).toMatchObject({
|
|
os: 0,
|
|
os_name: "linux",
|
|
major: 3,
|
|
minor: 2,
|
|
subminor: 0,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("reads the same notes from an SHT_NOTE section including its name", () => {
|
|
const report = parse(elfNoteFixture({ ownership: "section" }));
|
|
expect(report.tables).toEqual([
|
|
expect.objectContaining({
|
|
kind: "section",
|
|
name: ".note.gnu.build-id",
|
|
}),
|
|
]);
|
|
expect(
|
|
report.entries.some((entry) => entry.type_name === "gnu_build_id"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
[32, true],
|
|
[32, false],
|
|
[64, true],
|
|
[64, false],
|
|
] as const)(
|
|
"refuses compressed ELF%i %s-endian note storage without parsing its compression header",
|
|
(bits, little) => {
|
|
expect(() => parse(compressedElfNoteSectionFixture(bits, little))).toThrow(
|
|
expect.objectContaining({
|
|
kind: "unsupported",
|
|
message: expect.stringContaining(
|
|
"Compressed ELF note sections are unsupported",
|
|
),
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("does not allocate the declared decoded note size, but still validates stored ranges", () => {
|
|
const bytes = compressedElfNoteSectionFixture();
|
|
const noteSection = 64 + 64;
|
|
const storedOffset = Number(bytes.readBigUInt64LE(noteSection + 24));
|
|
bytes.writeBigUInt64LE(0xffffffffffffffffn, storedOffset + 8);
|
|
expect(() => parse(bytes)).toThrow(
|
|
expect.objectContaining({ kind: "unsupported" }),
|
|
);
|
|
bytes.writeBigUInt64LE(0n, noteSection + 32);
|
|
expect(() => parse(bytes)).toThrow(
|
|
expect.objectContaining({ kind: "unsupported" }),
|
|
);
|
|
bytes.writeBigUInt64LE(BigInt(bytes.length), noteSection + 32);
|
|
expect(() => parse(bytes)).toThrow(ArtifactReaderFailure);
|
|
});
|
|
|
|
it("preserves unknown ELF64 section flag bits without treating them as file sizes", () => {
|
|
const bytes = elfNoteFixture({ ownership: "section" });
|
|
bytes.writeBigUInt64LE(0x8000000000000000n, 64 + 64 + 8);
|
|
bytes.writeBigUInt64LE(0x8000000000000000n, 64 + 2 * 64 + 8);
|
|
expect(parse(bytes).entries[0]?.gnu?.build_id_hex).toBe(
|
|
"0123456789abcdef0123456789abcdef01234567",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
[32, true, 1],
|
|
[32, false, 1],
|
|
[64, true, 1],
|
|
[64, false, 1],
|
|
[32, true, 2],
|
|
[32, false, 2],
|
|
[64, true, 2],
|
|
[64, false, 2],
|
|
] as const)(
|
|
"decompresses an ELF%i little-endian=%s section-name table using algorithm %i",
|
|
(bits, little, compressionType) => {
|
|
const report = parse(
|
|
compressedElfNoteFixture(bits, little, compressionType),
|
|
);
|
|
expect(report.tables).toEqual([
|
|
expect.objectContaining({ kind: "section", name: ".note.gnu.build-id" }),
|
|
]);
|
|
expect(report.entries[0]?.gnu?.build_id_hex).toBe(
|
|
"0123456789abcdef0123456789abcdef01234567",
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([1, 2])(
|
|
"admits a decoded section name larger than algorithm %i storage",
|
|
(compressionType) => {
|
|
const name = "n".repeat(512);
|
|
const bytes = compressedElfNoteFixture(64, true, compressionType, name);
|
|
expect(bytes.length).toBeLessThan(name.length);
|
|
const report = parse(bytes);
|
|
expect(report.tables[0]?.name).toBe(name);
|
|
},
|
|
);
|
|
|
|
it.each([1, 2])(
|
|
"rejects malformed algorithm %i section-name storage",
|
|
(compressionType) => {
|
|
for (const damage of ["corrupt", "truncated", "too-large", "too-small"]) {
|
|
const bytes = compressedElfNoteFixture(64, true, compressionType);
|
|
const sectionOffset = Number(bytes.readBigUInt64LE(40));
|
|
const nameHeader = sectionOffset + bytes.readUInt16LE(62) * 64;
|
|
const namesOffset = Number(bytes.readBigUInt64LE(nameHeader + 24));
|
|
if (damage === "corrupt") bytes[namesOffset + 24] = 0;
|
|
else if (damage === "truncated")
|
|
bytes.writeBigUInt64LE(
|
|
bytes.readBigUInt64LE(nameHeader + 32) - 1n,
|
|
nameHeader + 32,
|
|
);
|
|
else
|
|
bytes.writeBigUInt64LE(
|
|
bytes.readBigUInt64LE(namesOffset + 8) +
|
|
(damage === "too-large" ? 1n : -1n),
|
|
namesOffset + 8,
|
|
);
|
|
expect(() => parse(bytes), damage).toThrow(
|
|
/compressed section-name table/u,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("refuses a Zstandard frame requiring more than the decoder window allowance", () => {
|
|
const header = Buffer.alloc(24);
|
|
header.writeUInt32LE(2, 0);
|
|
header.writeBigUInt64LE(1n, 16);
|
|
// An empty last raw block with Window_Descriptor 0x90: a 2^28-byte window.
|
|
const frame = Buffer.from("28b52ffd0090010000", "hex");
|
|
expect(
|
|
decodeElfSectionNameTable(
|
|
Buffer.concat([header, frame]),
|
|
0x800n,
|
|
64,
|
|
true,
|
|
0,
|
|
),
|
|
).toMatchObject({
|
|
state: "malformed",
|
|
message: expect.stringMatching(/memory/iu),
|
|
});
|
|
});
|
|
|
|
it("keeps note facts but marks section names unavailable for unknown compression", () => {
|
|
const report = parse(compressedElfNoteFixture(64, true, 3));
|
|
expect(report.tables[0]?.name).toBeNull();
|
|
expect(report.entries[0]?.gnu?.build_id_hex).toBe(
|
|
"0123456789abcdef0123456789abcdef01234567",
|
|
);
|
|
expect(report.limitations).toContain(
|
|
"ELF section names are unavailable because compression type 3 is unsupported.",
|
|
);
|
|
});
|
|
|
|
it.each([1, 2])(
|
|
"admits algorithm %i section names when max_file_bytes includes expansion",
|
|
(compressionType) => {
|
|
const bytes = compressedElfNoteFixture(64, true, compressionType);
|
|
const sectionOffset = Number(bytes.readBigUInt64LE(40));
|
|
const nameHeader = sectionOffset + bytes.readUInt16LE(62) * 64;
|
|
const namesOffset = Number(bytes.readBigUInt64LE(nameHeader + 24));
|
|
const decodedNamesBytes = Number(bytes.readBigUInt64LE(namesOffset + 8));
|
|
const minimumMaxFileBytes = bytes.length + decodedNamesBytes;
|
|
const input = inspectElfNotesInputSchema.parse({
|
|
path: defaults.path,
|
|
max_file_bytes: bytes.length + 1,
|
|
});
|
|
let failure: unknown;
|
|
try {
|
|
parse(bytes, input);
|
|
} catch (cause) {
|
|
failure = cause;
|
|
}
|
|
expect(failure).toBeInstanceOf(ElfNoteSectionNameBudgetFailure);
|
|
expect(failure).toMatchObject({
|
|
minimumMaxFileBytes: BigInt(minimumMaxFileBytes),
|
|
selectedMaxFileBytes: bytes.length + 1,
|
|
});
|
|
|
|
const admitted = parse(
|
|
bytes,
|
|
inspectElfNotesInputSchema.parse({
|
|
path: defaults.path,
|
|
max_file_bytes: minimumMaxFileBytes,
|
|
}),
|
|
);
|
|
expect(admitted.tables[0]?.name).toBe(".note.gnu.build-id");
|
|
},
|
|
);
|
|
|
|
it("reports independent PT_NOTE and SHT_NOTE declarations of the same bytes", () => {
|
|
const report = parse(elfNoteFixture({ ownership: "both" }));
|
|
expect(report.tables.map((table) => table.kind).sort()).toEqual([
|
|
"program",
|
|
"section",
|
|
]);
|
|
expect(
|
|
report.entries.filter((entry) => entry.type_name === "gnu_build_id"),
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
it("reports missing note tables without claiming notes never existed", () => {
|
|
const report = parse(elfNoteFixture({ ownership: "none" }));
|
|
expect(report.tables).toEqual([]);
|
|
expect(report.entries).toEqual([]);
|
|
expect(report.limitations[0]).toMatch(/No PT_NOTE/u);
|
|
});
|
|
|
|
it("fails a truncated note payload without partial success", () => {
|
|
const notes = gnuBuildIdNote();
|
|
notes.writeUInt32LE(64, 0);
|
|
const bytes = elfNoteFixture({ ownership: "program", notes });
|
|
expect(() => parse(bytes)).toThrow(ArtifactReaderFailure);
|
|
});
|
|
|
|
it("keeps non-GNU owners as unknown types with descriptor hashes", () => {
|
|
const payload = Buffer.from("hello");
|
|
const notes = elfNote("CORE", 1, payload);
|
|
const report = parse(elfNoteFixture({ ownership: "program", notes }));
|
|
expect(report.entries[0]).toMatchObject({
|
|
owner: "CORE",
|
|
type_name: "unknown",
|
|
gnu: null,
|
|
});
|
|
expect(report.entries[0]?.descriptor.sha256).toBe(
|
|
createHash("sha256").update(payload).digest("hex"),
|
|
);
|
|
});
|
|
|
|
it("reads a big-endian ELF32 GNU build-id", () => {
|
|
const notes = gnuBuildIdNote(undefined, false);
|
|
const report = parse(
|
|
elfNoteFixture({
|
|
bits: 32,
|
|
little: false,
|
|
machine: 20,
|
|
ownership: "program",
|
|
notes,
|
|
}),
|
|
);
|
|
expect(report.byte_order).toBe("big");
|
|
expect(report.machine).toBe(20);
|
|
expect(report.entries[0]?.gnu?.build_id_hex).toBe(
|
|
"0123456789abcdef0123456789abcdef01234567",
|
|
);
|
|
});
|
|
|
|
it("reads an 8-aligned GNU property note from a PT_NOTE program header", () => {
|
|
const notes = gnuPropertyNote();
|
|
expect(notes.length).toBe(48);
|
|
const report = parse(
|
|
elfNoteFixture({ ownership: "program", notes, alignment: 8 }),
|
|
);
|
|
expect(report.tables).toEqual([
|
|
expect.objectContaining({ kind: "program", alignment: 8 }),
|
|
]);
|
|
expect(report.entries).toHaveLength(1);
|
|
expect(report.entries[0]).toMatchObject({
|
|
owner: "GNU",
|
|
type: 5,
|
|
type_name: "gnu_property",
|
|
namesz: 4,
|
|
descsz: 32,
|
|
});
|
|
expect(report.entries[0]?.descriptor.location.offset).toBe(
|
|
(report.entries[0]?.location.offset ?? 0) + 16,
|
|
);
|
|
});
|
|
|
|
it("reads an 8-aligned GNU property note from an SHT_NOTE section", () => {
|
|
const report = parse(
|
|
elfNoteFixture({
|
|
ownership: "section",
|
|
notes: gnuPropertyNote(),
|
|
alignment: 8,
|
|
sectionName: ".note.gnu.property",
|
|
}),
|
|
);
|
|
expect(report.tables).toEqual([
|
|
expect.objectContaining({
|
|
kind: "section",
|
|
name: ".note.gnu.property",
|
|
alignment: 8,
|
|
}),
|
|
]);
|
|
expect(report.entries[0]?.type_name).toBe("gnu_property");
|
|
expect(report.entries[0]?.descriptor.bytes).toBe(32);
|
|
});
|
|
|
|
it.each([0, 1] as const)(
|
|
"treats declared note alignment %i as 4-byte padding",
|
|
(alignment) => {
|
|
const report = parse(elfNoteFixture({ ownership: "program", alignment }));
|
|
expect(report.tables[0]?.alignment).toBe(4);
|
|
expect(
|
|
report.entries.some((entry) => entry.type_name === "gnu_build_id"),
|
|
).toBe(true);
|
|
expect(
|
|
report.entries.some((entry) => entry.type_name === "gnu_abi_tag"),
|
|
).toBe(true);
|
|
},
|
|
);
|
|
|
|
const hostElf = [
|
|
"/bin/ls",
|
|
"/usr/bin/ls",
|
|
"/usr/bin/env",
|
|
].reduce<Buffer | null>((found, path) => {
|
|
if (found) return found;
|
|
try {
|
|
const bytes = readFileSync(path);
|
|
if (
|
|
bytes.length > 64 &&
|
|
bytes[0] === 0x7f &&
|
|
bytes[1] === 0x45 &&
|
|
bytes[2] === 0x4c &&
|
|
bytes[3] === 0x46
|
|
)
|
|
return bytes;
|
|
} catch {
|
|
return found;
|
|
}
|
|
return found;
|
|
}, null);
|
|
|
|
it.skipIf(hostElf === null)(
|
|
"parses a host ELF without requiring it to contain notes",
|
|
() => {
|
|
const report = parse(hostElf!);
|
|
expect(report.coverage.status).toBe("complete");
|
|
const buildId = report.entries.find(
|
|
(entry) => entry.type_name === "gnu_build_id",
|
|
);
|
|
const abi = report.entries.find(
|
|
(entry) => entry.type_name === "gnu_abi_tag",
|
|
);
|
|
if (buildId !== undefined)
|
|
expect(buildId.gnu?.build_id_hex).toMatch(/^[0-9a-f]+$/u);
|
|
if (abi !== undefined)
|
|
expect(abi.gnu?.abi_tag).toMatchObject({
|
|
os: expect.any(Number),
|
|
major: expect.any(Number),
|
|
minor: expect.any(Number),
|
|
subminor: expect.any(Number),
|
|
});
|
|
},
|
|
);
|
|
|
|
const unterminatedNoteSections = (
|
|
sections: number,
|
|
nameBytes: number,
|
|
): Buffer => {
|
|
const shnum = sections + 2;
|
|
const tableOffset = 64 + shnum * 64;
|
|
const shdr = (type: number, offset = 0, size = 0): Buffer => {
|
|
const header = Buffer.alloc(64);
|
|
header.writeUInt32LE(type, 4);
|
|
header.writeBigUInt64LE(BigInt(offset), 24);
|
|
header.writeBigUInt64LE(BigInt(size), 32);
|
|
return header;
|
|
};
|
|
const image = Buffer.concat([
|
|
elf64({ shoff: 64, shnum }),
|
|
shdr(0),
|
|
shdr(3, tableOffset, nameBytes),
|
|
...Array.from({ length: sections }, () => shdr(7, tableOffset)),
|
|
Buffer.alloc(nameBytes, 0x41),
|
|
]);
|
|
image.writeUInt16LE(1, 62);
|
|
return image;
|
|
};
|
|
|
|
it.each([
|
|
{ offset: 2, name: "é", state: "known" },
|
|
{ offset: 3, name: "", state: "known" },
|
|
{ offset: 4, name: null, state: "unavailable" },
|
|
])(
|
|
"retains ELF section-name offset $offset consistently across consumers",
|
|
({ offset, name, state }) => {
|
|
const bytes = unterminatedNoteSections(1, 4);
|
|
bytes.set([0x41, 0x42, 0xe9, 0], bytes.length - 4);
|
|
bytes.writeUInt32LE(offset, 64 + 2 * 64);
|
|
const notes = parse(bytes);
|
|
const directory = readElfDirectoryFromBuffer(bytes);
|
|
expect(notes.tables[0]?.name).toBe(name);
|
|
expect(directory.sections[2]).toMatchObject({ name, name_state: state });
|
|
if (name === null) {
|
|
expect(notes.coverage.status).toBe("complete");
|
|
expect(notes.limitations).toContain(
|
|
"ELF section names are incomplete because a name offset is outside its string table.",
|
|
);
|
|
expect(directory.section_names_limitation).toBe(
|
|
"ELF section names are incomplete because a name offset is outside its string table",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([65535, 65536])(
|
|
"preserves consumer-specific section-name windows for NUL at byte %i",
|
|
(terminatorOffset) => {
|
|
const bytes = unterminatedNoteSections(1, terminatorOffset + 1);
|
|
bytes[bytes.length - 1] = 0;
|
|
const name = "A".repeat(terminatorOffset);
|
|
const notes = parse(bytes);
|
|
const directory = readElfDirectoryFromBuffer(bytes);
|
|
expect(notes.tables[0]?.name).toBe(name);
|
|
expect(notes.coverage.status).toBe("complete");
|
|
expect(directory.sections[2]).toMatchObject(
|
|
terminatorOffset === 65535
|
|
? { name, name_state: "known" }
|
|
: { name: null, name_state: "truncated" },
|
|
);
|
|
if (terminatorOffset === 65536)
|
|
expect(directory.section_names_limitation).toBe(
|
|
"ELF section names are unavailable because a name is not NUL-terminated",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("bounds repeated scans of an unterminated section name", () => {
|
|
const report = parse(unterminatedNoteSections(2, 64));
|
|
expect(report.tables.map(({ name }) => name)).toEqual([null, null]);
|
|
expect(report.coverage.status).toBe("complete");
|
|
expect(
|
|
report.limitations.filter((value) => value.includes("not NUL-terminated")),
|
|
).toEqual([
|
|
"ELF section names are incomplete because a name is not NUL-terminated.",
|
|
]);
|
|
let failure: unknown;
|
|
try {
|
|
parse(unterminatedNoteSections(3, 400));
|
|
} catch (cause) {
|
|
failure = cause;
|
|
}
|
|
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
|
|
expect(failure).toMatchObject({ reason: "limit", boundary: "memory" });
|
|
expect(failure).toHaveProperty(
|
|
"message",
|
|
expect.stringMatching(/byte budget/u),
|
|
);
|
|
});
|
|
|
|
it("rejects note text that repeats beyond the shared byte budget", () => {
|
|
const notes = elfNote("A".repeat(40), 1, Buffer.alloc(0));
|
|
const once = elfNoteFixture({ ownership: "program", notes });
|
|
const twice = elfNoteFixture({ ownership: "both", notes });
|
|
const input = inspectElfNotesInputSchema.parse({
|
|
path: defaults.path,
|
|
max_file_bytes: 200,
|
|
});
|
|
expect(parse(once, input).entries).toHaveLength(1);
|
|
expect(() => parse(twice, input)).toThrow(ArtifactReaderFailure);
|
|
expect(() => parse(twice, input)).toThrow(/byte budget/u);
|
|
});
|
|
|
|
it("rejects program and section header tables that do not fit", () => {
|
|
expect(() =>
|
|
parse(elf64({ phoff: 64, phnum: 1 }, Buffer.from([1, 0, 0, 0]))),
|
|
).toThrow(/leaves the artifact/u);
|
|
expect(() =>
|
|
parse(
|
|
elf64({ shoff: 64, shnum: 1, shentsize: 64 }, Buffer.from([0, 0, 0, 0])),
|
|
),
|
|
).toThrow(/leaves the artifact/u);
|
|
});
|
|
|
|
it("rejects an extended section count stored in section zero", () => {
|
|
expect(() => parse(elf64({ shoff: 64, shnum: 0 }))).toThrow(
|
|
/extended numbering/u,
|
|
);
|
|
});
|
|
|
|
const repeatedNotes = (count: number, notes: Buffer): Buffer => {
|
|
const headers = Buffer.alloc(56 * count);
|
|
const offset = 64 + headers.length;
|
|
for (let index = 0; index < count; index += 1) {
|
|
const at = index * 56;
|
|
headers.writeUInt32LE(4, at);
|
|
headers.writeBigUInt64LE(BigInt(offset), at + 8);
|
|
headers.writeBigUInt64LE(BigInt(notes.length), at + 32);
|
|
headers.writeBigUInt64LE(4n, at + 48);
|
|
}
|
|
return Buffer.concat([elf64({ phoff: 64, phnum: count }), headers, notes]);
|
|
};
|
|
|
|
it("reuses one descriptor hash and GNU NUL scan across note headers", () => {
|
|
const payload = Buffer.alloc(400);
|
|
const hashed = parse(repeatedNotes(6, elfNote("X", 1, payload)));
|
|
expect(hashed.entries).toHaveLength(6);
|
|
expect(
|
|
new Set(hashed.entries.map((entry) => entry.descriptor.sha256)).size,
|
|
).toBe(1);
|
|
const gold = parse(repeatedNotes(6, elfNote("GNU", 4, payload)));
|
|
expect(gold.entries).toHaveLength(6);
|
|
expect(gold.entries.every((entry) => entry.gnu?.gold_version === "")).toBe(
|
|
true,
|
|
);
|
|
const distinct = Buffer.concat([
|
|
elfNote("X", 1, payload),
|
|
elfNote("X", 1, Buffer.alloc(400, 1)),
|
|
]);
|
|
expect(() =>
|
|
parse(
|
|
elfNoteFixture({ ownership: "program", notes: distinct }),
|
|
inspectElfNotesInputSchema.parse({
|
|
path: defaults.path,
|
|
max_file_bytes: payload.length + 160,
|
|
}),
|
|
),
|
|
).toThrow(/byte budget/u);
|
|
});
|
|
|
|
it("decodes a GNU gold version by scanning trailing NULs", () => {
|
|
const trailing = elfNote("GNU", 4, Buffer.from("gold\0\0x\0", "latin1"));
|
|
expect(
|
|
parse(elfNoteFixture({ ownership: "program", notes: trailing })).entries[0]
|
|
?.gnu?.gold_version,
|
|
).toBe("gold\0\0x");
|
|
const descriptor = Buffer.concat([Buffer.alloc(131_072), Buffer.from("x")]);
|
|
const started = Date.now();
|
|
const report = parse(
|
|
elfNoteFixture({
|
|
ownership: "program",
|
|
notes: elfNote("GNU", 4, descriptor),
|
|
}),
|
|
);
|
|
expect(Date.now() - started).toBeLessThan(1_000);
|
|
expect(report.entries[0]?.gnu?.gold_version).toBe(`${"\0".repeat(131_072)}x`);
|
|
});
|
|
|
|
it("rejects a declared note table that extends past the file", () => {
|
|
const truncated = elfNoteTablePastEnd();
|
|
expect(truncated.length).toBe(137);
|
|
expect(() => parse(truncated)).toThrow(ArtifactReaderFailure);
|
|
expect(() => parse(truncated)).toThrow(/leaves the artifact/u);
|
|
const present = Buffer.concat([truncated, Buffer.alloc(3)]);
|
|
expect(parse(present).entries).toHaveLength(1);
|
|
expect(parse(present).tables[0]?.location).toEqual({
|
|
offset: 120,
|
|
bytes: 20,
|
|
});
|
|
});
|
|
|
|
it("fails when the entry budget is exhausted", () => {
|
|
const notes = Buffer.concat([
|
|
gnuBuildIdNote(),
|
|
gnuAbiTagNote(),
|
|
elfNote("GNU", 4, Buffer.from("gold")),
|
|
]);
|
|
const bytes = elfNoteFixture({ ownership: "program", notes });
|
|
expect(() =>
|
|
parseElfNotes(
|
|
bytes,
|
|
{
|
|
path: defaults.path,
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
bytes: bytes.length,
|
|
},
|
|
inspectElfNotesInputSchema.parse({ path: defaults.path, max_entries: 1 }),
|
|
),
|
|
).toThrow(ArtifactReaderFailure);
|
|
});
|
|
|
|
it("keeps unsafe table offsets ahead of unsupported extended numbering", () => {
|
|
const bytes = elfExtendedSectionCountFixture();
|
|
bytes.writeBigUInt64LE(0xffffffffffffffffn, 32);
|
|
expect(() => parse(bytes)).toThrow(
|
|
"ELF offset or size exceeds a safe integer.",
|
|
);
|
|
});
|
|
|
|
it("does not apply note precision admission to unrelated program records", () => {
|
|
const bytes = elf64({ phoff: 64, phnum: 1 }, Buffer.alloc(56));
|
|
bytes.writeUInt32LE(0x70000001, 64);
|
|
for (const relative of [8, 16, 24, 32, 40, 48])
|
|
bytes.writeBigUInt64LE(0xffffffffffffffffn, 64 + relative);
|
|
expect(parse(bytes).tables).toEqual([]);
|
|
bytes.writeUInt32LE(4, 64);
|
|
expect(() => parse(bytes)).toThrow(
|
|
"ELF offset or size exceeds a safe integer.",
|
|
);
|
|
});
|
|
|
|
it("keeps compressed-note refusal ahead of alignment precision admission", () => {
|
|
const bytes = compressedElfNoteSectionFixture();
|
|
const sectionTable = Number(bytes.readBigUInt64LE(40));
|
|
bytes.writeBigUInt64LE(0xffffffffffffffffn, sectionTable + 64 + 48);
|
|
expect(() => parse(bytes)).toThrow(
|
|
"Compressed ELF note sections are unsupported",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{ bits: 32, little: true },
|
|
{ bits: 32, little: false },
|
|
{ bits: 64, little: true },
|
|
{ bits: 64, little: false },
|
|
] as const)(
|
|
"rejects dangling section names with and without notes (ELF$bits $little)",
|
|
({ bits, little }) => {
|
|
for (const ownership of ["program", "none"] as const) {
|
|
const bytes = elfNoteFixture({ bits, little, ownership });
|
|
const before = parse(bytes);
|
|
const nameOffset = bits === 64 ? 62 : 50;
|
|
if (little) bytes.writeUInt16LE(5, nameOffset);
|
|
else bytes.writeUInt16BE(5, nameOffset);
|
|
expect(() => parse(bytes)).toThrow("ELF section table is missing");
|
|
if (little) bytes.writeUInt16LE(0, nameOffset);
|
|
else bytes.writeUInt16BE(0, nameOffset);
|
|
expect(parse(bytes)).toEqual(before);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("decodes Linux core dump notes (NT_PRPSINFO, NT_FILE, NT_SIGINFO, NT_AUXV) for 64-bit little-endian", () => {
|
|
const notes = Buffer.concat([
|
|
corePrpsinfoNote({
|
|
bits: 64,
|
|
little: true,
|
|
state: 0,
|
|
sname: "R",
|
|
pid: 4242,
|
|
ppid: 1,
|
|
pgrp: 4242,
|
|
sid: 4242,
|
|
uid: 1001,
|
|
gid: 1002,
|
|
fname: "test-proc",
|
|
psargs: "/usr/bin/test-proc --daemon -v",
|
|
}),
|
|
coreFileNote(
|
|
[
|
|
{
|
|
start: 0x400000n,
|
|
end: 0x405000n,
|
|
file_ofs: 0n,
|
|
path: "/usr/bin/test-proc",
|
|
},
|
|
{
|
|
start: 0x7fff0000n,
|
|
end: 0x7fff2000n,
|
|
file_ofs: 10n,
|
|
path: "/lib/x86_64-linux-gnu/libc.so.6",
|
|
},
|
|
],
|
|
4096,
|
|
64,
|
|
true,
|
|
),
|
|
coreSiginfoNote(11, 1, 0, true), // SIGSEGV, SEGV_MAPERR
|
|
coreAuxvNote(
|
|
[
|
|
[6, 4096n], // AT_PAGESZ
|
|
[3, 0x400040n], // AT_PHDR
|
|
[9, 0x401000n], // AT_ENTRY
|
|
[999, 0x1234n], // Unknown auxv tag
|
|
],
|
|
64,
|
|
true,
|
|
),
|
|
]);
|
|
|
|
const bytes = elfNoteFixture({
|
|
bits: 64,
|
|
little: true,
|
|
type: 4, // ET_CORE
|
|
ownership: "program",
|
|
notes,
|
|
});
|
|
|
|
const report = parse(bytes);
|
|
expect(report.entries).toHaveLength(4);
|
|
|
|
// 1. NT_PRPSINFO
|
|
expect(report.entries[0]).toMatchObject({
|
|
owner: "CORE",
|
|
type: 3,
|
|
type_name: "core_prpsinfo",
|
|
gnu: null,
|
|
core: {
|
|
prpsinfo: {
|
|
state: 0,
|
|
pid: 4242,
|
|
ppid: 1,
|
|
pgrp: 4242,
|
|
sid: 4242,
|
|
uid: 1001,
|
|
gid: 1002,
|
|
filename: "test-proc",
|
|
psargs: "/usr/bin/test-proc --daemon -v",
|
|
},
|
|
file: null,
|
|
siginfo: null,
|
|
auxv: null,
|
|
},
|
|
});
|
|
|
|
// 2. NT_FILE
|
|
expect(report.entries[1]).toMatchObject({
|
|
owner: "CORE",
|
|
type: 0x46494c45,
|
|
type_name: "core_file",
|
|
gnu: null,
|
|
core: {
|
|
prpsinfo: null,
|
|
file: {
|
|
count: 2,
|
|
page_size: 4096,
|
|
mappings: [
|
|
{
|
|
start_address: "0x400000",
|
|
end_address: "0x405000",
|
|
file_offset_pages: "0x0",
|
|
path: "/usr/bin/test-proc",
|
|
path_hex: Buffer.from("/usr/bin/test-proc").toString("hex"),
|
|
},
|
|
{
|
|
start_address: "0x7fff0000",
|
|
end_address: "0x7fff2000",
|
|
file_offset_pages: "0xa",
|
|
path: "/lib/x86_64-linux-gnu/libc.so.6",
|
|
path_hex: Buffer.from("/lib/x86_64-linux-gnu/libc.so.6").toString(
|
|
"hex",
|
|
),
|
|
},
|
|
],
|
|
},
|
|
siginfo: null,
|
|
auxv: null,
|
|
},
|
|
});
|
|
|
|
// 3. NT_SIGINFO
|
|
expect(report.entries[2]).toMatchObject({
|
|
owner: "CORE",
|
|
type: 0x53494749,
|
|
type_name: "core_siginfo",
|
|
gnu: null,
|
|
core: {
|
|
prpsinfo: null,
|
|
file: null,
|
|
siginfo: {
|
|
signo: 11,
|
|
errno: 0,
|
|
code: 1,
|
|
},
|
|
auxv: null,
|
|
},
|
|
});
|
|
|
|
// 4. NT_AUXV
|
|
expect(report.entries[3]).toMatchObject({
|
|
owner: "CORE",
|
|
type: 6,
|
|
type_name: "core_auxv",
|
|
gnu: null,
|
|
core: {
|
|
prpsinfo: null,
|
|
file: null,
|
|
siginfo: null,
|
|
auxv: [
|
|
{ type: 6, type_name: "AT_PAGESZ", value: "0x1000" },
|
|
{ type: 3, type_name: "AT_PHDR", value: "0x400040" },
|
|
{ type: 9, type_name: "AT_ENTRY", value: "0x401000" },
|
|
{ type: 999, type_name: null, value: "0x1234" },
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("decodes Linux core dump notes for 32-bit big-endian", () => {
|
|
const notes = Buffer.concat([
|
|
corePrpsinfoNote({
|
|
bits: 32,
|
|
little: false,
|
|
state: 1,
|
|
pid: 123,
|
|
ppid: 1,
|
|
pgrp: 123,
|
|
sid: 123,
|
|
uid: 500,
|
|
gid: 500,
|
|
fname: "proc32",
|
|
psargs: "proc32 arg",
|
|
}),
|
|
coreFileNote(
|
|
[
|
|
{
|
|
start: 0x10000,
|
|
end: 0x12000,
|
|
file_ofs: 0,
|
|
path: "/bin/proc32",
|
|
},
|
|
],
|
|
4096,
|
|
32,
|
|
false,
|
|
),
|
|
coreAuxvNote(
|
|
[
|
|
[6, 4096],
|
|
[11, 500],
|
|
],
|
|
32,
|
|
false,
|
|
),
|
|
]);
|
|
|
|
const bytes = elfNoteFixture({
|
|
bits: 32,
|
|
little: false,
|
|
type: 4,
|
|
ownership: "program",
|
|
notes,
|
|
});
|
|
|
|
const report = parse(bytes);
|
|
expect(report.entries).toHaveLength(3);
|
|
expect(report.entries[0]?.core?.prpsinfo).toMatchObject({
|
|
state: 1,
|
|
pid: 123,
|
|
uid: 500,
|
|
gid: 500,
|
|
filename: "proc32",
|
|
psargs: "proc32 arg",
|
|
});
|
|
expect(report.entries[1]?.core?.file).toMatchObject({
|
|
count: 1,
|
|
page_size: 4096,
|
|
mappings: [
|
|
{
|
|
start_address: "0x10000",
|
|
end_address: "0x12000",
|
|
file_offset_pages: "0x0",
|
|
path: "/bin/proc32",
|
|
path_hex: Buffer.from("/bin/proc32").toString("hex"),
|
|
},
|
|
],
|
|
});
|
|
expect(report.entries[2]?.core?.auxv).toEqual([
|
|
{ type: 6, type_name: "AT_PAGESZ", value: "0x1000" },
|
|
{ type: 11, type_name: "AT_UID", value: "0x1f4" },
|
|
]);
|
|
});
|
|
|
|
it("tolerates malformed core notes by returning null without throwing", () => {
|
|
const truncatedPrpsinfo = elfNote("CORE", 3, Buffer.alloc(20)); // requires 136 for 64-bit
|
|
const truncatedFile = elfNote(
|
|
"CORE",
|
|
0x46494c45,
|
|
Buffer.from("00000000000000010000000000001000", "hex"), // count 1, page_size 4096, but no map entries or filenames
|
|
);
|
|
const truncatedSiginfo = elfNote("CORE", 0x53494749, Buffer.alloc(4)); // requires 12 bytes
|
|
const unterminatedAuxv = elfNote(
|
|
"CORE",
|
|
6,
|
|
Buffer.from("00000000000000060000000000001000", "hex"), // AT_PAGESZ=4096 but lacks AT_NULL terminator
|
|
);
|
|
const truncatedAuxv = elfNote("CORE", 6, Buffer.alloc(7)); // not multiple of entry size
|
|
const notes = Buffer.concat([
|
|
truncatedPrpsinfo,
|
|
truncatedFile,
|
|
truncatedSiginfo,
|
|
unterminatedAuxv,
|
|
truncatedAuxv,
|
|
]);
|
|
|
|
const bytes = elfNoteFixture({
|
|
bits: 64,
|
|
ownership: "program",
|
|
notes,
|
|
});
|
|
|
|
const report = parse(bytes);
|
|
expect(report.entries).toHaveLength(5);
|
|
expect(report.entries[0]?.core).toBeNull();
|
|
expect(report.entries[0]?.type_name).toBe("core_prpsinfo");
|
|
expect(report.entries[1]?.core).toBeNull();
|
|
expect(report.entries[1]?.type_name).toBe("core_file");
|
|
expect(report.entries[2]?.core).toBeNull();
|
|
expect(report.entries[2]?.type_name).toBe("core_siginfo");
|
|
expect(report.entries[3]?.core).toBeNull();
|
|
expect(report.entries[3]?.type_name).toBe("core_auxv");
|
|
expect(report.entries[4]?.core).toBeNull();
|
|
expect(report.entries[4]?.type_name).toBe("core_auxv");
|
|
});
|
|
|
|
it("decodes siginfo using target ABI for MIPS machines", () => {
|
|
const desc = Buffer.alloc(128);
|
|
desc.writeInt32LE(11, 0); // signo = SIGSEGV
|
|
desc.writeInt32LE(1, 4); // code = SEGV_MAPERR (offset 4 on MIPS)
|
|
desc.writeInt32LE(0, 8); // errno = 0 (offset 8 on MIPS)
|
|
const mipsSiginfo = elfNote("CORE", 0x53494749, desc, true);
|
|
const bytes = elfNoteFixture({
|
|
bits: 32,
|
|
machine: 8, // EM_MIPS
|
|
ownership: "program",
|
|
notes: mipsSiginfo,
|
|
});
|
|
const report = parse(bytes);
|
|
expect(report.entries[0]?.core?.siginfo).toEqual({
|
|
signo: 11,
|
|
code: 1,
|
|
errno: 0,
|
|
});
|
|
});
|
|
|
|
it("preserves negative process IDs and signal numbers without rewriting to zero", () => {
|
|
const prps = corePrpsinfoNote({
|
|
pid: -1,
|
|
ppid: -1,
|
|
pgrp: -1,
|
|
sid: -1,
|
|
});
|
|
const sig = coreSiginfoNote(-1, -2, -3);
|
|
const bytes = elfNoteFixture({
|
|
bits: 64,
|
|
ownership: "program",
|
|
notes: Buffer.concat([prps, sig]),
|
|
});
|
|
const report = parse(bytes);
|
|
expect(report.entries[0]?.core?.prpsinfo).toMatchObject({
|
|
pid: -1,
|
|
ppid: -1,
|
|
pgrp: -1,
|
|
sid: -1,
|
|
});
|
|
expect(report.entries[1]?.core?.siginfo).toMatchObject({
|
|
signo: -1,
|
|
code: -2,
|
|
errno: -3,
|
|
});
|
|
});
|
|
|
|
it("rejects 64-bit auxv tags and out-of-range page sizes with core: null without schema error", () => {
|
|
const validAuxv = coreAuxvNote([[6, 4096n]], 64, true);
|
|
const wideTagAuxv = Buffer.from(validAuxv);
|
|
wideTagAuxv.writeBigUInt64LE(0x100000000n, 20);
|
|
|
|
const validFile = coreFileNote(
|
|
[
|
|
{
|
|
start: 0x400000n,
|
|
end: 0x401000n,
|
|
file_ofs: 0n,
|
|
path: "/bin/app",
|
|
},
|
|
],
|
|
4096,
|
|
64,
|
|
true,
|
|
);
|
|
const widePageSizeFile = Buffer.from(validFile);
|
|
widePageSizeFile.writeBigUInt64LE(0x100000000n, 28);
|
|
|
|
const bytes = elfNoteFixture({
|
|
bits: 64,
|
|
ownership: "program",
|
|
notes: Buffer.concat([wideTagAuxv, widePageSizeFile]),
|
|
});
|
|
const report = parse(bytes);
|
|
expect(report.entries[0]?.core).toBeNull();
|
|
expect(report.entries[1]?.core).toBeNull();
|
|
});
|
|
|
|
it("does not spend text budget on malformed or discarded NT_FILE paths", () => {
|
|
const header = Buffer.alloc(16);
|
|
header.writeBigUInt64LE(2n, 0); // count = 2
|
|
header.writeBigUInt64LE(4096n, 8); // page_size = 4096
|
|
const mappings = Buffer.alloc(48); // 2 mappings * 24 bytes
|
|
const paths = Buffer.from(
|
|
"/bin/app\0unterminated_path_without_null",
|
|
"latin1",
|
|
);
|
|
const badFileNote = elfNote(
|
|
"CORE",
|
|
0x46494c45,
|
|
Buffer.concat([header, mappings, paths]),
|
|
);
|
|
const bytes = elfNoteFixture({
|
|
bits: 64,
|
|
ownership: "program",
|
|
notes: badFileNote,
|
|
});
|
|
const report = parse(bytes);
|
|
expect(report.entries[0]?.core).toBeNull();
|
|
});
|