mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 00:07:18 +00:00
fix(elf): report incomplete kernel module coverage (#9475)
This commit is contained in:
1 parent
44d232fccb
commit
5553acfe50
8 files changed
+183
-33
No files matched your search
@@ -70,6 +70,14 @@ trailer is observed absence, not proof the module is unsigned.
|
||||
When section names are incomplete, a missing named section is `unknown`
|
||||
rather than `absent`. An empty file-backed section is present and empty.
|
||||
|
||||
`coverage.status` is `partial` when supported facts cannot all be examined or
|
||||
interpreted, including incomplete section names, malformed or unsupported
|
||||
metadata, and omitted duplicate named sections. When multiple `.modinfo`,
|
||||
`__versions`, `.gnu.linkonce.this_module`, `.init.text`, or `.exit.text`
|
||||
sections exist, the first is examined and a limitation identifies the omission.
|
||||
`complete` covers the supported inspection described here; it does not claim
|
||||
that `struct module`, symbol-table records, or certificate chains were decoded.
|
||||
|
||||
## Limits and failures
|
||||
|
||||
The selected file is limited to 64 MiB by default (512 MiB maximum). At most
|
||||
|
||||
@@ -315,7 +315,8 @@ const validReport = (
|
||||
);
|
||||
if (report.applicability !== "applicable") return false;
|
||||
if (
|
||||
report.coverage.status !== "complete" ||
|
||||
(report.coverage.status !== "complete" &&
|
||||
report.coverage.status !== "partial") ||
|
||||
report.modinfo === null ||
|
||||
report.versions === null ||
|
||||
report.this_module === null ||
|
||||
|
||||
@@ -258,7 +258,11 @@ export const kernelModuleSchema = z.strictObject({
|
||||
exit_text: sizedSectionSchema.nullable(),
|
||||
signature: signatureSchema.nullable(),
|
||||
coverage: z.strictObject({
|
||||
status: z.enum(["complete", "not_applicable", "unsupported"]),
|
||||
status: z
|
||||
.enum(["complete", "partial", "not_applicable", "unsupported"])
|
||||
.describe(
|
||||
"Whether the supported inspection examined the declared facts; omitted or undecodable facts make coverage partial.",
|
||||
),
|
||||
examined_entries: uint,
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
|
||||
@@ -98,10 +98,11 @@ export interface KernelModuleFixtureOptions {
|
||||
readonly signature?: Buffer | null;
|
||||
/** Declare a second `.modinfo` section with the same bytes. */
|
||||
readonly repeatModinfo?: boolean;
|
||||
/** Extra empty file-backed sections that share one name-table string. */
|
||||
/** Extra file-backed sections that share one name-table string; empty by default. */
|
||||
readonly sharedNameSections?: {
|
||||
readonly name: string;
|
||||
readonly count: number;
|
||||
readonly body?: Buffer;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,7 +170,11 @@ export const kernelModuleFixture = (
|
||||
const shared = options.sharedNameSections;
|
||||
if (shared !== undefined)
|
||||
for (let index = 0; index < shared.count; index++)
|
||||
bodies.push({ name: shared.name, type: 1, body: Buffer.alloc(0) });
|
||||
bodies.push({
|
||||
name: shared.name,
|
||||
type: 1,
|
||||
body: shared.body ?? Buffer.alloc(0),
|
||||
});
|
||||
const labels = [
|
||||
...new Set([...bodies.map((section) => section.name), ".shstrtab"]),
|
||||
];
|
||||
|
||||
@@ -69,6 +69,7 @@ it("distinguishes absent metadata from unavailable section names", () => {
|
||||
const unknown = parse(bytes);
|
||||
expect(unknown.modinfo?.status).toBe("unknown");
|
||||
expect(unknown.versions?.status).toBe("unknown");
|
||||
expect(unknown.coverage.status).toBe("partial");
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -133,6 +134,25 @@ it.each([
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[".init.text", "init_text", 4],
|
||||
[".exit.text", "exit_text", 1],
|
||||
] as const)(
|
||||
"discloses duplicate %s sections while retaining the first size",
|
||||
(name, key, size) => {
|
||||
const report = parse(
|
||||
kernelModuleFixture({
|
||||
sharedNameSections: { name, count: 1, body: Buffer.from([1, 2, 3]) },
|
||||
}),
|
||||
);
|
||||
expect(report[key]).toMatchObject({ status: "present", bytes: size });
|
||||
expect(report.coverage.status).toBe("partial");
|
||||
expect(report.limitations).toContain(
|
||||
`Multiple ${name} sections are declared; the first is examined.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps earlier .modinfo entries when the last span is unterminated", () => {
|
||||
const report = parse(
|
||||
kernelModuleFixture({
|
||||
@@ -167,6 +187,7 @@ it("reports a missing separator, an empty key, and an over-long field per entry"
|
||||
"modinfo_field_exceeds_limit",
|
||||
"modinfo_field_exceeds_limit",
|
||||
]);
|
||||
expect(report.coverage.status).toBe("partial");
|
||||
expect(report.modinfo?.entries[1]).toMatchObject({
|
||||
key: null,
|
||||
value: "GPL",
|
||||
@@ -180,6 +201,7 @@ it("reports a missing separator, an empty key, and an over-long field per entry"
|
||||
|
||||
it("closes __versions when the section size is not a multiple of 64", () => {
|
||||
const report = parse(kernelModuleFixture({ versions: Buffer.alloc(63) }));
|
||||
expect(report.coverage.status).toBe("partial");
|
||||
expect(report.versions).toMatchObject({
|
||||
status: "malformed",
|
||||
entries: [],
|
||||
@@ -223,6 +245,7 @@ it("reports an inconsistent signature length without a PKCS#7 range", () => {
|
||||
signature: moduleSignatureTrailer(Buffer.from("abcd"), 50_000),
|
||||
}),
|
||||
);
|
||||
expect(report.coverage.status).toBe("partial");
|
||||
expect(report.signature).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "signature_length_inconsistent",
|
||||
@@ -330,6 +353,7 @@ it("examines the first of two .modinfo sections and records the duplicate", () =
|
||||
it("does not decode compressed, nobits, or out-of-file metadata sections", () => {
|
||||
const compressed = kernelModuleFixture();
|
||||
compressed.writeBigUInt64LE(0x800n, 64 + 64 + 8);
|
||||
expect(parse(compressed).coverage.status).toBe("partial");
|
||||
expect(parse(compressed).modinfo).toMatchObject({
|
||||
status: "unsupported",
|
||||
entries: [],
|
||||
@@ -390,6 +414,7 @@ it("rejects ELF bytes preceding a bare signature magic as an inconsistent traile
|
||||
signature: Buffer.from(MODULE_SIGNATURE_MAGIC, "latin1"),
|
||||
}),
|
||||
);
|
||||
expect(report.coverage.status).toBe("partial");
|
||||
expect(report.signature).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "signature_length_inconsistent",
|
||||
|
||||
@@ -174,7 +174,7 @@ const parseModinfo = (
|
||||
use: SectionUse,
|
||||
examined: { count: number },
|
||||
maxEntries: number,
|
||||
): Report["modinfo"] => {
|
||||
): NonNullable<Report["modinfo"]> => {
|
||||
if (section === undefined)
|
||||
return {
|
||||
status: use.namesComplete ? "absent" : "unknown",
|
||||
@@ -271,7 +271,7 @@ const parseVersions = (
|
||||
use: SectionUse,
|
||||
examined: { count: number },
|
||||
maxEntries: number,
|
||||
): Report["versions"] => {
|
||||
): NonNullable<Report["versions"]> => {
|
||||
const layout =
|
||||
bits === 64 ? "elf64_unsigned_long_name56" : "elf32_unsigned_long_name60";
|
||||
const entryBytes = KERNEL_MODULE_VERSION_ENTRY_BYTES;
|
||||
@@ -380,7 +380,7 @@ const sized = (
|
||||
namesComplete: boolean,
|
||||
limitations: Limitations,
|
||||
label: string,
|
||||
): Report["init_text"] => {
|
||||
): NonNullable<Report["init_text"]> => {
|
||||
if (section === undefined)
|
||||
return {
|
||||
status: namesComplete ? "absent" : "unknown",
|
||||
@@ -525,28 +525,27 @@ export const parseKernelModule = (
|
||||
limitations.push(image.section_names_limitation);
|
||||
const use = { namesComplete, limitations };
|
||||
const examined = { count: 0 };
|
||||
const noteDuplicates = (name: string, found: readonly ElfSection[]): void => {
|
||||
if (found.length > 1)
|
||||
let duplicateSections = false;
|
||||
const firstNamedSection = (name: string): ElfSection | undefined => {
|
||||
const found = sectionsNamed(sections, name);
|
||||
if (found.length > 1) {
|
||||
duplicateSections = true;
|
||||
limitations.push(
|
||||
`Multiple ${name} sections are declared; the first is examined.`,
|
||||
);
|
||||
}
|
||||
return found[0];
|
||||
};
|
||||
const modinfoSections = sectionsNamed(sections, ".modinfo");
|
||||
const versionSections = sectionsNamed(sections, "__versions");
|
||||
const moduleSections = sectionsNamed(sections, ".gnu.linkonce.this_module");
|
||||
noteDuplicates(".modinfo", modinfoSections);
|
||||
noteDuplicates("__versions", versionSections);
|
||||
noteDuplicates(".gnu.linkonce.this_module", moduleSections);
|
||||
const modinfo = parseModinfo(
|
||||
bytes,
|
||||
modinfoSections[0],
|
||||
firstNamedSection(".modinfo"),
|
||||
use,
|
||||
examined,
|
||||
input.max_entries,
|
||||
);
|
||||
const versions = parseVersions(
|
||||
bytes,
|
||||
versionSections[0],
|
||||
firstNamedSection("__versions"),
|
||||
image.bits,
|
||||
image.byte_order === "little",
|
||||
use,
|
||||
@@ -554,7 +553,7 @@ export const parseKernelModule = (
|
||||
input.max_entries,
|
||||
);
|
||||
const modulePresence = presence(
|
||||
moduleSections[0],
|
||||
firstNamedSection(".gnu.linkonce.this_module"),
|
||||
namesComplete,
|
||||
bytes.length,
|
||||
);
|
||||
@@ -596,6 +595,38 @@ export const parseKernelModule = (
|
||||
if (versions?.status === "absent")
|
||||
limitations.push("No __versions section was declared.");
|
||||
limitations.push(signatureLimitation(signature));
|
||||
const initText = sized(
|
||||
bytes,
|
||||
firstNamedSection(".init.text"),
|
||||
namesComplete,
|
||||
limitations,
|
||||
".init.text",
|
||||
);
|
||||
const exitText = sized(
|
||||
bytes,
|
||||
firstNamedSection(".exit.text"),
|
||||
namesComplete,
|
||||
limitations,
|
||||
".exit.text",
|
||||
);
|
||||
const sectionIncomplete = (
|
||||
section: NonNullable<Report["modinfo"]> | NonNullable<Report["versions"]>,
|
||||
) =>
|
||||
(section.status !== "present" && section.status !== "absent") ||
|
||||
section.entries.some((entry) => entry.status !== "observed");
|
||||
const coveragePartial =
|
||||
!namesComplete ||
|
||||
duplicateSections ||
|
||||
unbacked > 0 ||
|
||||
ksymtab.some((section) => section.reason !== null) ||
|
||||
sectionIncomplete(modinfo) ||
|
||||
sectionIncomplete(versions) ||
|
||||
modulePresence.status === "unsupported" ||
|
||||
(modulePresence.status === "present" && modulePresence.location === null) ||
|
||||
(initText?.status === "present" && initText.bytes === null) ||
|
||||
(exitText?.status === "present" && exitText.bytes === null) ||
|
||||
signature?.status === "malformed" ||
|
||||
signature?.status === "unsupported";
|
||||
return {
|
||||
artifact,
|
||||
...facts,
|
||||
@@ -613,22 +644,13 @@ export const parseKernelModule = (
|
||||
status: namesComplete ? "observed" : "unknown",
|
||||
sections: ksymtab,
|
||||
},
|
||||
init_text: sized(
|
||||
bytes,
|
||||
sectionsNamed(sections, ".init.text")[0],
|
||||
namesComplete,
|
||||
limitations,
|
||||
".init.text",
|
||||
),
|
||||
exit_text: sized(
|
||||
bytes,
|
||||
sectionsNamed(sections, ".exit.text")[0],
|
||||
namesComplete,
|
||||
limitations,
|
||||
".exit.text",
|
||||
),
|
||||
init_text: initText,
|
||||
exit_text: exitText,
|
||||
signature,
|
||||
coverage: { status: "complete", examined_entries: examined.count },
|
||||
coverage: {
|
||||
status: coveragePartial ? "partial" : "complete",
|
||||
examined_entries: examined.count,
|
||||
},
|
||||
limitations,
|
||||
};
|
||||
};
|
||||
@@ -42,6 +42,39 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"reports duplicate sections as partial through the public CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-kernel-module-duplicate-");
|
||||
const path = join(root, "duplicate.ko");
|
||||
await writeFile(
|
||||
path,
|
||||
kernelModuleFixture({
|
||||
sharedNameSections: {
|
||||
name: ".init.text",
|
||||
count: 1,
|
||||
body: Buffer.from([1, 2, 3]),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-kernel-module", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
const evidence = toolContract("inspect_kernel_module").outputSchema.parse(
|
||||
result.json,
|
||||
);
|
||||
expect(evidence.normalized_result.coverage.status).toBe("partial");
|
||||
expect(evidence.normalized_result.init_text).toMatchObject({
|
||||
status: "present",
|
||||
bytes: 4,
|
||||
});
|
||||
expect(evidence.normalized_result.limitations).toContain(
|
||||
"Multiple .init.text sections are declared; the first is examined.",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"resolves a relative kernel module path against the working directory",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import type { AnySchema } from "ajv";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
@@ -63,6 +65,56 @@ mcpTest(
|
||||
},
|
||||
);
|
||||
|
||||
mcpTest(
|
||||
"reports duplicate sections as partial in MCP Evidence",
|
||||
async ({ mcp }) => {
|
||||
const { client, session } = await connect(mcp);
|
||||
try {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-kernel-module-duplicate-mcp-",
|
||||
);
|
||||
const path = join(root, "duplicate.ko");
|
||||
await writeFile(
|
||||
path,
|
||||
kernelModuleFixture({
|
||||
sharedNameSections: {
|
||||
name: ".exit.text",
|
||||
count: 1,
|
||||
body: Buffer.from([1, 2, 3]),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const response = await client.callTool({
|
||||
name: "inspect_kernel_module",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
|
||||
const outputSchema = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_kernel_module",
|
||||
)?.outputSchema;
|
||||
if (outputSchema === undefined) throw new Error("Missing output schema");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
expect(ajv.validateSchema(outputSchema as AnySchema)).toBe(true);
|
||||
expect(
|
||||
ajv.compile(outputSchema as AnySchema)(response.structuredContent),
|
||||
).toBe(true);
|
||||
const evidence = toolContract("inspect_kernel_module").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
expect(evidence.normalized_result.coverage.status).toBe("partial");
|
||||
expect(evidence.normalized_result.exit_text).toMatchObject({
|
||||
status: "present",
|
||||
bytes: 1,
|
||||
});
|
||||
expect(evidence.normalized_result.limitations).toContain(
|
||||
"Multiple .exit.text sections are declared; the first is examined.",
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
mcpTest(
|
||||
"requires an absolute path for inspect_kernel_module",
|
||||
async ({ mcp }) => {
|
||||
|
||||
Reference in new issue
Block a user