From 5553acfe500b4ae32fee9fa9e147d0996a45917a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:09:01 +0800 Subject: [PATCH] fix(elf): report incomplete kernel module coverage (#9475) --- docs/kernel-modules.md | 8 ++ .../binaryDiagnostics/KernelModuleService.ts | 3 +- src/domain/native/kernelModule.ts | 6 +- src/native/elf/KernelModule.fixture.ts | 9 ++- src/native/elf/KernelModuleParser.test.ts | 25 ++++++ src/native/elf/KernelModuleParser.ts | 80 ++++++++++++------- tests/acceptance/kernelModuleCli.test.ts | 33 ++++++++ tests/boundary/mcp/kernelModuleMcp.test.ts | 52 ++++++++++++ 8 files changed, 183 insertions(+), 33 deletions(-) diff --git a/docs/kernel-modules.md b/docs/kernel-modules.md index c2974caf80..81a4275793 100644 --- a/docs/kernel-modules.md +++ b/docs/kernel-modules.md @@ -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 diff --git a/src/application/binaryDiagnostics/KernelModuleService.ts b/src/application/binaryDiagnostics/KernelModuleService.ts index 0300327657..c86c474f04 100644 --- a/src/application/binaryDiagnostics/KernelModuleService.ts +++ b/src/application/binaryDiagnostics/KernelModuleService.ts @@ -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 || diff --git a/src/domain/native/kernelModule.ts b/src/domain/native/kernelModule.ts index 944fb55eaa..01116721ed 100644 --- a/src/domain/native/kernelModule.ts +++ b/src/domain/native/kernelModule.ts @@ -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()), diff --git a/src/native/elf/KernelModule.fixture.ts b/src/native/elf/KernelModule.fixture.ts index 9c34b8a103..6993a5714f 100644 --- a/src/native/elf/KernelModule.fixture.ts +++ b/src/native/elf/KernelModule.fixture.ts @@ -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"]), ]; diff --git a/src/native/elf/KernelModuleParser.test.ts b/src/native/elf/KernelModuleParser.test.ts index 426c85d6cc..797f388b8c 100644 --- a/src/native/elf/KernelModuleParser.test.ts +++ b/src/native/elf/KernelModuleParser.test.ts @@ -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", diff --git a/src/native/elf/KernelModuleParser.ts b/src/native/elf/KernelModuleParser.ts index ac741eb83d..b24878bb20 100644 --- a/src/native/elf/KernelModuleParser.ts +++ b/src/native/elf/KernelModuleParser.ts @@ -174,7 +174,7 @@ const parseModinfo = ( use: SectionUse, examined: { count: number }, maxEntries: number, -): Report["modinfo"] => { +): NonNullable => { 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 => { 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 => { 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 | NonNullable, + ) => + (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, }; }; diff --git a/tests/acceptance/kernelModuleCli.test.ts b/tests/acceptance/kernelModuleCli.test.ts index e52b3c09a3..5eacc44488 100644 --- a/tests/acceptance/kernelModuleCli.test.ts +++ b/tests/acceptance/kernelModuleCli.test.ts @@ -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 }) => { diff --git a/tests/boundary/mcp/kernelModuleMcp.test.ts b/tests/boundary/mcp/kernelModuleMcp.test.ts index 9c9bf655f7..10a3126acf 100644 --- a/tests/boundary/mcp/kernelModuleMcp.test.ts +++ b/tests/boundary/mcp/kernelModuleMcp.test.ts @@ -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 }) => {