fix(elf): report incomplete kernel module coverage (#9475)

This commit is contained in:
morluto authored and GitHub committed 2026-11-09 06:09:01 +08:00
1 parent 44d232fccb
commit 5553acfe50
8 files changed
+183 -33

No files matched your search

+8
View File
@@ -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 ||
+5 -1
View File
@@ -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()),
+7 -2
View File
@@ -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"]),
];
+25
View File
@@ -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",
+51 -29
View File
@@ -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,
};
};
+33
View File
@@ -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 }) => {