From 72e75e9a481d44ad010ef08208a1942b1b3c9b59 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:49:03 +0800 Subject: [PATCH] fix(metadata): preserve declared phandles and relocation fields Keep malformed canonical devicetree phandles unresolved instead of falling back to legacy identities. Report the PE dynamic relocation offset and section at their exact source widths with Size-gated absence and zero preservation. Clarify classic kernel-module version coverage and byte-preserving string encoding. Validation: 158 focused parser, service, filesystem, and finalization regressions; 14 CLI/MCP tests; build, typecheck/lint, formatting, and docs checks passed. Fixtures establish parser behavior; no external analysis engines were exercised. --- docs/devicetree.md | 7 +- docs/kernel-modules.md | 15 ++++- docs/pe-load-config.md | 7 +- .../PeLoadConfigService.test.ts | 2 + src/domain/native/peLoadConfig.ts | 24 ++++++- .../devicetree/DevicetreeParser.test.ts | 55 ++++++++++++++++ src/firmware/devicetree/DevicetreeParser.ts | 17 ++++- src/native/elf/KernelModuleParser.ts | 2 + src/native/pe/PeLoadConfig.fixture.ts | 13 +++- src/native/pe/PeLoadConfigParser.test.ts | 53 +++++++++++++-- src/native/pe/PeLoadConfigParser.ts | 33 +++++++--- tests/acceptance/devicetreeCli.test.ts | 37 ++++++++++- tests/acceptance/peLoadConfigCli.test.ts | 11 +++- tests/boundary/mcp/devicetreeMcp.test.ts | 66 +++++++++++++++++++ tests/boundary/mcp/peLoadConfigMcp.test.ts | 11 +++- 15 files changed, 324 insertions(+), 29 deletions(-) diff --git a/docs/devicetree.md b/docs/devicetree.md index 6cc8dae16a..81f5b5cf99 100644 --- a/docs/devicetree.md +++ b/docs/devicetree.md @@ -30,8 +30,11 @@ bootargs/stdout-path and aliases. An undecodable alias retains its name with a null path; it is not silently treated as absent. Single-cell `interrupt-parent`, `remote-endpoint` and `msi-parent` references resolve through phandle declarations. Missing or duplicate targets have null target paths. A node whose `phandle` and `linux,phandle` disagree -adds a limitation, and references to either value stay unresolved. Binding-specific reference arrays and overlays are not -resolved; an empty reference list does not prove absence of hardware links. +adds a limitation, and references to either value stay unresolved. A present +`phandle` that is not one cell also adds a limitation; `linux,phandle` cannot +resolve that node when the canonical declaration is malformed. Binding-specific +reference arrays and overlays are not resolved; an empty reference list does not +prove absence of hardware links. MCP `detail: summary` retains complete Evidence in the session. Follow its `parent_evidence_id` with `inspect_analysis_view`: diff --git a/docs/kernel-modules.md b/docs/kernel-modules.md index 32aed2b532..c2974caf80 100644 --- a/docs/kernel-modules.md +++ b/docs/kernel-modules.md @@ -32,14 +32,23 @@ the signature trailer are not examined. `.modinfo` entries stay in file order. Duplicate keys such as `alias` and `parm` are preserved. Each entry keeps its key, value, file range, and SHA-256. Values are the bytes after the first `=`. NUL padding between -strings is skipped. +strings is skipped. Keys and values use a byte-preserving Latin1 projection: +each source byte becomes the same-numbered Unicode code point. They are not +decoded as UTF-8. `raw_sha256` hashes the original entry bytes, including its +terminating NUL when present; `raw_hex` shows those bytes when they fit the +inline limit. `__versions` entries are `struct modversion_info`: a 64-byte record whose CRC is an `unsigned long` and whose name occupies the remaining bytes. ELF32 stores a 32-bit CRC and a 60-byte name. ELF64 stores a 64-bit unsigned long and a 56-byte name. CRCs are zero-padded lowercase hex, 8 or 16 characters, not a JavaScript number. The layout is reported from the ELF class even when -the section is absent. +the section is absent. Names use the same byte-preserving Latin1 projection +as `.modinfo` keys and values. Only this classic `__versions` layout is +decoded. `versions.status: "absent"` means no `__versions` section was found +when section names were complete; it does not establish the absence of version +metadata in `__version_ext_names` and `__version_ext_crcs`, which Linux uses +with `CONFIG_EXTENDED_MODVERSIONS` and this workflow does not decode. `.gnu.linkonce.this_module` is reported as present, absent, or unknown. `layout` is always `unknown` because `struct module` changes with the kernel @@ -105,6 +114,8 @@ does not execute the module or validate an appended signature. The record layout follows Linux v6.12's [`modversion_info`](https://github.com/torvalds/linux/blob/v6.12/include/linux/module.h) and [`MAX_PARAM_PREFIX_LEN`](https://github.com/torvalds/linux/blob/v6.12/include/linux/moduleparam.h). +The [Linux kbuild module versioning guide](https://docs.kernel.org/kbuild/modules.html#module-versioning) +describes the separate extended version sections. Trailer fields and acceptance rules follow [`module_signature`](https://github.com/torvalds/linux/blob/v6.12/include/linux/module_signature.h) and [`mod_check_sig`](https://github.com/torvalds/linux/blob/v6.12/kernel/module_signature.c). diff --git a/docs/pe-load-config.md b/docs/pe-load-config.md index 5ae8625f47..37d5998ffe 100644 --- a/docs/pe-load-config.md +++ b/docs/pe-load-config.md @@ -28,7 +28,9 @@ directory range when the two differ. Unavailable fields are `null`; a covered ze The report includes the security cookie, PE32 SafeSEH table/count, CFG pointers and table/count, address-taken IAT and long-jump tables/counts, EH continuation table/count, and XFG check pointer. CHPE and dynamic relocation pointers are raw -values; their targets and mitigation-table entries are not decoded. +values. The dynamic relocation table offset (DWORD) and section (WORD) are +reported separately when `Size` covers them, including zero values; REA does +not infer a VA from that pair or decode the target and mitigation-table entries. Guard flags retain the raw DWORD, recognized names, unknown bits, and the CFG entry stride. Mitigation claims derived from those flags carry @@ -44,7 +46,8 @@ success is returned. An unmapped callback **target** retains its VA/RVA with an unknown file offset. Load-config versions beyond the supported fields remain undecoded. -Layout references: [Microsoft PE format](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) +Layout references: [Microsoft PE format](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format), +[IMAGE_LOAD_CONFIG_DIRECTORY32](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_load_config_directory32), and [IMAGE_LOAD_CONFIG_DIRECTORY64](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-image_load_config_directory64). For a real cross-compiled fixture, run `npm run verify:pe:load-config` with Zig diff --git a/src/application/binaryDiagnostics/PeLoadConfigService.test.ts b/src/application/binaryDiagnostics/PeLoadConfigService.test.ts index a535215a9b..6882dea115 100644 --- a/src/application/binaryDiagnostics/PeLoadConfigService.test.ts +++ b/src/application/binaryDiagnostics/PeLoadConfigService.test.ts @@ -40,6 +40,8 @@ const legacyX86: PeLoadConfig = { guard_long_jump_target_count: null, dynamic_value_reloc_table: null, chpe_metadata_pointer: null, + dynamic_value_reloc_table_offset: null, + dynamic_value_reloc_table_section: null, guard_eh_continuation_table: null, guard_eh_continuation_count: null, guard_xfg_check_function_pointer: null, diff --git a/src/domain/native/peLoadConfig.ts b/src/domain/native/peLoadConfig.ts index c78b9b8ea1..f6a23fb750 100644 --- a/src/domain/native/peLoadConfig.ts +++ b/src/domain/native/peLoadConfig.ts @@ -207,13 +207,22 @@ export const PE_LOAD_CONFIG_RAW_FIELDS = [ ] as const; export type PeLoadConfigRawField = (typeof PE_LOAD_CONFIG_RAW_FIELDS)[number]; +/** Source values of the dynamic relocation table offset and section slots. */ +export const PE_LOAD_CONFIG_NUMBER_FIELDS = [ + "dynamic_value_reloc_table_offset", + "dynamic_value_reloc_table_section", +] as const; +export type PeLoadConfigNumberField = + (typeof PE_LOAD_CONFIG_NUMBER_FIELDS)[number]; + export type PeLoadConfigField = | PeLoadConfigLocatedField | PeLoadConfigCountField | PeLoadConfigRawField + | PeLoadConfigNumberField | "guard_flags"; -type FieldLayout = { readonly offset: number; readonly width: 4 | 8 }; +type FieldLayout = { readonly offset: number; readonly width: 2 | 4 | 8 }; /** * Offsets of IMAGE_LOAD_CONFIG_DIRECTORY32. A field is present when Size @@ -235,6 +244,8 @@ const PE32_LOAD_CONFIG_FIELDS: Record = { guard_long_jump_target_count: { offset: 116, width: 4 }, dynamic_value_reloc_table: { offset: 120, width: 4 }, chpe_metadata_pointer: { offset: 124, width: 4 }, + dynamic_value_reloc_table_offset: { offset: 136, width: 4 }, + dynamic_value_reloc_table_section: { offset: 140, width: 2 }, guard_eh_continuation_table: { offset: 164, width: 4 }, guard_eh_continuation_count: { offset: 168, width: 4 }, guard_xfg_check_function_pointer: { offset: 172, width: 4 }, @@ -260,6 +271,8 @@ const PE64_LOAD_CONFIG_FIELDS: Record = { guard_long_jump_target_count: { offset: 184, width: 8 }, dynamic_value_reloc_table: { offset: 192, width: 8 }, chpe_metadata_pointer: { offset: 200, width: 8 }, + dynamic_value_reloc_table_offset: { offset: 224, width: 4 }, + dynamic_value_reloc_table_section: { offset: 228, width: 2 }, guard_eh_continuation_table: { offset: 264, width: 8 }, guard_eh_continuation_count: { offset: 272, width: 8 }, guard_xfg_check_function_pointer: { offset: 280, width: 8 }, @@ -355,6 +368,13 @@ const loadConfigSchema = z guard_long_jump_target_count: safeCount.nullable(), dynamic_value_reloc_table: hexVa.nullable(), chpe_metadata_pointer: hexVa.nullable(), + dynamic_value_reloc_table_offset: uint.nullable(), + dynamic_value_reloc_table_section: z + .number() + .int() + .min(0) + .max(0xffff) + .nullable(), guard_eh_continuation_table: locatedSchema.nullable(), guard_eh_continuation_count: safeCount.nullable(), guard_xfg_check_function_pointer: locatedSchema.nullable(), @@ -368,7 +388,7 @@ export const PE_LOAD_CONFIG_LIMITATIONS = [ "Claims such as CFG instrumentation are inferred from GuardFlags bits and are not observations of instrumented instructions.", "A missing TLS or load-config directory is observed absence of that table, not proof the image never registered callbacks or mitigations through another mechanism.", "SafeSEH table and count are reported only for PE32. PE32+ struct slots that share those names are not a SafeSEH table.", - "CHPE metadata and dynamic value relocation pointers are raw virtual addresses. Their targets are not decoded.", + "CHPE metadata and dynamic value relocation pointers are raw virtual addresses. Dynamic relocation table offset and section are separate source values; their target is not derived or decoded.", "Load-config fields newer than GuardXFGCheckFunctionPointer stay unread. A Size larger than the fields this version knows does not fail the read.", ] as const; diff --git a/src/firmware/devicetree/DevicetreeParser.test.ts b/src/firmware/devicetree/DevicetreeParser.test.ts index 74ac9f4656..51e990f59b 100644 --- a/src/firmware/devicetree/DevicetreeParser.test.ts +++ b/src/firmware/devicetree/DevicetreeParser.test.ts @@ -189,6 +189,61 @@ it("leaves conflicting phandle declarations unresolved", () => { "phandle at /split conflicts with linux,phandle (0x1 vs 0x2); references to either value are unresolved.", ]); }); +it.each([ + ["empty", Buffer.alloc(0)], + ["short", Buffer.from([0, 0])], + ["multiple cells", cells(1, 2)], +])( + "does not use a legacy phandle when the canonical value is %s", + (_name, canonical) => { + const r = parse( + devicetreeFixture({ + name: "", + children: [ + { + name: "target", + properties: { phandle: canonical, "linux,phandle": cells(3) }, + }, + { name: "consumer", properties: { "interrupt-parent": cells(3) } }, + ], + }), + ); + expect(r.references).toMatchObject([ + { source: "/consumer", phandle: 3, target: null }, + ]); + expect(r.nodes[1]?.properties[0]).toMatchObject({ + name: "phandle", + bytes: canonical.length, + hex: canonical.toString("hex"), + derived: { cells: null }, + }); + expect(r.limitations).toContain( + "phandle at /target is not a single cell; this node's reference identity remains unresolved.", + ); + }, +); +it("resolves a legacy phandle only when the canonical property is absent", () => { + const r = parse( + devicetreeFixture({ + name: "", + children: [ + { name: "legacy", properties: { "linux,phandle": cells(3) } }, + { name: "canonical", properties: { phandle: cells(4) } }, + { + name: "consumer", + properties: { + "interrupt-parent": cells(3), + "remote-endpoint": cells(4), + }, + }, + ], + }), + ); + expect(r.references.map((ref) => [ref.phandle, ref.target])).toEqual([ + [3, "/legacy"], + [4, "/canonical"], + ]); +}); it("rejects unterminated reservations and unknown tokens", () => { const reservation = devicetreeFixture(); reservation.fill(1, 56, 72); diff --git a/src/firmware/devicetree/DevicetreeParser.ts b/src/firmware/devicetree/DevicetreeParser.ts index e67c1da0bb..768aa669cd 100644 --- a/src/firmware/devicetree/DevicetreeParser.ts +++ b/src/firmware/devicetree/DevicetreeParser.ts @@ -286,10 +286,20 @@ export const parseDevicetree = ( "Phandle references cover only single-cell interrupt-parent, remote-endpoint and msi-parent; binding-specific arrays and overlays are not resolved.", ]; for (const node of nodes) { - const declared = ["phandle", "linux,phandle"].flatMap((name) => { - const handle = prop(node.path, name)?.derived.cells?.[0]; + const canonical = prop(node.path, "phandle"); + const legacy = prop(node.path, "linux,phandle"); + const malformedCanonical = + canonical !== undefined && canonical.derived.cells === null; + const declared = [canonical, legacy].flatMap((property) => { + const handle = property?.derived.cells?.[0]; return handle === undefined ? [] : [handle]; }); + if (malformedCanonical) { + admit(node.path.length * 2 + 256); + limitations.push( + `phandle at ${node.path} is not a single cell; this node's reference identity remains unresolved.`, + ); + } // Disagreeing declarations leave both values ambiguous rather than choosing one. const conflict = declared.length === 2 && declared[0] !== declared[1]; if (conflict) { @@ -298,12 +308,13 @@ export const parseDevicetree = ( `phandle at ${node.path} conflicts with linux,phandle (0x${declared[0]?.toString(16)} vs 0x${declared[1]?.toString(16)}); references to either value are unresolved.`, ); } + const unambiguous = !malformedCanonical && !conflict; for (const handle of declared) if (handle !== 0 && handle !== 0xffffffff) { const existing = handles.get(handle); handles.set( handle, - !conflict && (existing === undefined || existing === node.path) + unambiguous && (existing === undefined || existing === node.path) ? node.path : null, ); diff --git a/src/native/elf/KernelModuleParser.ts b/src/native/elf/KernelModuleParser.ts index 00a7104ca0..fca7c650b2 100644 --- a/src/native/elf/KernelModuleParser.ts +++ b/src/native/elf/KernelModuleParser.ts @@ -87,6 +87,8 @@ const baseLimitations = (): Limitations => [ "This workflow does not execute the module, resolve imported symbols, or validate an appended signature.", "struct module in .gnu.linkonce.this_module is kernel-version specific and is not decoded.", "__ksymtab entry layout is kernel-version specific and is not decoded.", + "Only classic __versions records are decoded. Their absence does not establish the absence of CONFIG_EXTENDED_MODVERSIONS metadata in __version_ext_names and __version_ext_crcs.", + "Metadata strings use a byte-preserving Latin1 projection; their original text encoding is unobserved.", ]; const headerFacts = ( diff --git a/src/native/pe/PeLoadConfig.fixture.ts b/src/native/pe/PeLoadConfig.fixture.ts index 1b07af03a2..ed3cdb6f52 100644 --- a/src/native/pe/PeLoadConfig.fixture.ts +++ b/src/native/pe/PeLoadConfig.fixture.ts @@ -31,6 +31,8 @@ export const PE_LOAD_CONFIG_FIXTURE = { callbackRvas: [0x2000, 0x2010, 0x9000], chpe: 0x33333333, dynamicReloc: 0x22222222, + dynamicRelocOffset: 0x40, + dynamicRelocSection: 1, guardFlags: 0x500, sehCount: 2, ignoredSeh: 0x44444444, @@ -275,7 +277,8 @@ const writeLoadConfig = ( if (field.offset + field.width > written) return; const atField = configAt + field.offset; if (field.width === 8) bytes.writeBigUInt64LE(value, atField); - else bytes.writeUInt32LE(Number(value), atField); + else if (field.width === 4) bytes.writeUInt32LE(Number(value), atField); + else bytes.writeUInt16LE(Number(value), atField); }; const cookie = options.securityCookie === "zero" ? 0n : vaOf(layout.cookieRva); @@ -306,6 +309,14 @@ const writeLoadConfig = ( writeField("guard_long_jump_target_count", BigInt(layout.longjmpCount)); writeField("dynamic_value_reloc_table", BigInt(layout.dynamicReloc)); writeField("chpe_metadata_pointer", BigInt(layout.chpe)); + writeField( + "dynamic_value_reloc_table_offset", + BigInt(layout.dynamicRelocOffset), + ); + writeField( + "dynamic_value_reloc_table_section", + BigInt(layout.dynamicRelocSection), + ); writeField("guard_eh_continuation_table", vaOf(layout.ehTableRva)); writeField("guard_eh_continuation_count", BigInt(layout.ehCount)); writeField("guard_xfg_check_function_pointer", vaOf(layout.xfgRva)); diff --git a/src/native/pe/PeLoadConfigParser.test.ts b/src/native/pe/PeLoadConfigParser.test.ts index 22da57d38b..2287402975 100644 --- a/src/native/pe/PeLoadConfigParser.test.ts +++ b/src/native/pe/PeLoadConfigParser.test.ts @@ -169,7 +169,7 @@ it("resolves callback sections in bounded time for the largest section table", a */ const SPEC_FIELDS: Record< 32 | 64, - readonly (readonly [PeLoadConfigField, number, 4 | 8])[] + readonly (readonly [PeLoadConfigField, number, 2 | 4 | 8])[] > = { 32: [ ["security_cookie", 60, 4], @@ -186,6 +186,8 @@ const SPEC_FIELDS: Record< ["guard_long_jump_target_count", 116, 4], ["dynamic_value_reloc_table", 120, 4], ["chpe_metadata_pointer", 124, 4], + ["dynamic_value_reloc_table_offset", 136, 4], + ["dynamic_value_reloc_table_section", 140, 2], ["guard_eh_continuation_table", 164, 4], ["guard_eh_continuation_count", 168, 4], ["guard_xfg_check_function_pointer", 172, 4], @@ -205,6 +207,8 @@ const SPEC_FIELDS: Record< ["guard_long_jump_target_count", 184, 8], ["dynamic_value_reloc_table", 192, 8], ["chpe_metadata_pointer", 200, 8], + ["dynamic_value_reloc_table_offset", 224, 4], + ["dynamic_value_reloc_table_section", 228, 2], ["guard_eh_continuation_table", 264, 8], ["guard_eh_continuation_count", 272, 8], ["guard_xfg_check_function_pointer", 280, 8], @@ -215,10 +219,10 @@ const SPEC_FIELDS: Record< const LOAD_CONFIG_AT = 0x640; /** A distinct sentinel per field that fills its whole width. */ -const sentinel = (index: number, width: 4 | 8): bigint => +const sentinel = (index: number, width: 2 | 4 | 8): bigint => width === 8 ? 0x0000_0102_0304_0500n + BigInt(index) - : BigInt(0x01020300 + index); + : BigInt((width === 4 ? 0x01020300 : 0x1200) + index); /** Rewrite the struct from spec offsets alone, then declare `size`. */ const specImage = (bits: 32 | 64, size: number): Buffer => { @@ -233,7 +237,9 @@ const specImage = (bits: 32 | 64, size: number): Buffer => { SPEC_FIELDS[bits].forEach(([, offset, width], index) => { const value = sentinel(index, width); if (width === 8) bytes.writeBigUInt64LE(value, LOAD_CONFIG_AT + offset); - else bytes.writeUInt32LE(Number(value), LOAD_CONFIG_AT + offset); + else if (width === 4) + bytes.writeUInt32LE(Number(value), LOAD_CONFIG_AT + offset); + else bytes.writeUInt16LE(Number(value), LOAD_CONFIG_AT + offset); }); return bytes; }; @@ -245,6 +251,11 @@ const expectedField = (name: PeLoadConfigField, value: bigint): unknown => { if (name.endsWith("_count")) return Number(value); if (name === "dynamic_value_reloc_table" || name === "chpe_metadata_pointer") return `0x${value.toString(16)}`; + if ( + name === "dynamic_value_reloc_table_offset" || + name === "dynamic_value_reloc_table_section" + ) + return Number(value); return expect.objectContaining({ va: `0x${value.toString(16)}`, rva: null }); }; @@ -284,3 +295,37 @@ describe.each([32, 64] as const)( }); }, ); + +it.each([32, 64] as const)( + "PE%s retains dynamic relocation offset and section with a zero pointer", + async (bits) => { + const pointerOffset = bits === 32 ? 120 : 192; + const offsetOffset = bits === 32 ? 136 : 224; + const sectionOffset = bits === 32 ? 140 : 228; + for (const [offset, section] of [ + [0, 0], + [0x40, 1], + [0xffffffff, 0xffff], + ] as const) { + const bytes = specImage(bits, bits === 32 ? 192 : 320); + bytes.fill( + 0, + LOAD_CONFIG_AT + pointerOffset, + LOAD_CONFIG_AT + pointerOffset + (bits === 32 ? 4 : 8), + ); + bytes.writeUInt32LE(offset, LOAD_CONFIG_AT + offsetOffset); + bytes.writeUInt16LE(section, LOAD_CONFIG_AT + sectionOffset); + const report = await parsePeLoadConfig( + bytes, + { path: "/sample.exe", sha256: "a".repeat(64), bytes: bytes.length }, + inspectPeLoadConfigInputSchema.parse({ path: "/sample.exe" }), + ); + expect(peLoadConfigSchema.safeParse(report).success).toBe(true); + expect(report.load_config).toMatchObject({ + dynamic_value_reloc_table: "0x0", + dynamic_value_reloc_table_offset: offset, + dynamic_value_reloc_table_section: section, + }); + } + }, +); diff --git a/src/native/pe/PeLoadConfigParser.ts b/src/native/pe/PeLoadConfigParser.ts index 938c2867f1..3dd24b1903 100644 --- a/src/native/pe/PeLoadConfigParser.ts +++ b/src/native/pe/PeLoadConfigParser.ts @@ -22,6 +22,7 @@ import { type PeLoadConfigField, type PeLoadConfigLocated, type PeLoadConfigLocatedField, + type PeLoadConfigNumberField, type PeLoadConfigRawField, type PeLoadConfigTls, } from "../../domain/native/peLoadConfig.js"; @@ -419,7 +420,7 @@ const readLoadConfig = ({ const readField = (name: PeLoadConfigField): bigint | null => { if (!loadConfigFieldCovered(bits, size, name)) return null; const field = loadConfigFieldLayout(bits, name); - return readPointerWidth( + return readUnsignedField( bytes, directory.file_offset + field.offset, field.width, @@ -432,7 +433,9 @@ const readLoadConfig = ({ const value = readField(name); return value === null ? null : locate(value); }; - const count = (name: PeLoadConfigCountField): number | null => { + const number = ( + name: PeLoadConfigCountField | PeLoadConfigNumberField, + ): number | null => { const value = readField(name); if (value === null) return null; if (value > BigInt(Number.MAX_SAFE_INTEGER)) @@ -454,13 +457,13 @@ const readLoadConfig = ({ structure_location: { offset: directory.file_offset, bytes: size }, security_cookie: located("security_cookie"), se_handler_table: located("se_handler_table"), - se_handler_count: count("se_handler_count"), + se_handler_count: number("se_handler_count"), guard_cf_check_function_pointer: located("guard_cf_check_function_pointer"), guard_cf_dispatch_function_pointer: located( "guard_cf_dispatch_function_pointer", ), guard_cf_function_table: located("guard_cf_function_table"), - guard_cf_function_count: count("guard_cf_function_count"), + guard_cf_function_count: number("guard_cf_function_count"), guard_flags: decoded === null ? null @@ -473,15 +476,21 @@ const readLoadConfig = ({ guard_address_taken_iat_entry_table: located( "guard_address_taken_iat_entry_table", ), - guard_address_taken_iat_entry_count: count( + guard_address_taken_iat_entry_count: number( "guard_address_taken_iat_entry_count", ), guard_long_jump_target_table: located("guard_long_jump_target_table"), - guard_long_jump_target_count: count("guard_long_jump_target_count"), + guard_long_jump_target_count: number("guard_long_jump_target_count"), dynamic_value_reloc_table: raw("dynamic_value_reloc_table"), chpe_metadata_pointer: raw("chpe_metadata_pointer"), + dynamic_value_reloc_table_offset: number( + "dynamic_value_reloc_table_offset", + ), + dynamic_value_reloc_table_section: number( + "dynamic_value_reloc_table_section", + ), guard_eh_continuation_table: located("guard_eh_continuation_table"), - guard_eh_continuation_count: count("guard_eh_continuation_count"), + guard_eh_continuation_count: number("guard_eh_continuation_count"), guard_xfg_check_function_pointer: located( "guard_xfg_check_function_pointer", ), @@ -489,10 +498,14 @@ const readLoadConfig = ({ }; }; -const readPointerWidth = ( +const readUnsignedField = ( bytes: Buffer, offset: number, - width: 4 | 8, + width: 2 | 4 | 8, readPointer: (offset: number) => bigint, ): bigint => - width === 8 ? readPointer(offset) : BigInt(bytes.readUInt32LE(offset)); + width === 8 + ? readPointer(offset) + : BigInt( + width === 4 ? bytes.readUInt32LE(offset) : bytes.readUInt16LE(offset), + ); diff --git a/tests/acceptance/devicetreeCli.test.ts b/tests/acceptance/devicetreeCli.test.ts index 891e4dd00d..07396f9061 100644 --- a/tests/acceptance/devicetreeCli.test.ts +++ b/tests/acceptance/devicetreeCli.test.ts @@ -3,7 +3,10 @@ import { join } from "node:path"; import { expect } from "vitest"; import { z } from "zod"; import { toolContract } from "../../src/contracts/toolContracts.js"; -import { devicetreeFixture } from "../../src/firmware/devicetree/Devicetree.fixture.js"; +import { + cells, + devicetreeFixture, +} from "../../src/firmware/devicetree/Devicetree.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; @@ -37,6 +40,38 @@ cliTest("inspects a relative DTB and preserves the source", async ({ cli }) => { }); expect(await readFile(path)).toEqual(bytes); }); +cliTest( + "does not resolve through a legacy phandle when the canonical value is malformed", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-dtb-cli-phandle-"); + const path = join(root, "board.dtb"); + await writeFile( + path, + devicetreeFixture({ + name: "", + children: [ + { + name: "target", + properties: { phandle: Buffer.alloc(0), "linux,phandle": cells(3) }, + }, + { name: "consumer", properties: { "interrupt-parent": cells(3) } }, + ], + }), + ); + const result = await cli.run({ + arguments: ["inspect-devicetree", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = toolContract("inspect_devicetree").outputSchema.parse( + result.json, + ).normalized_result; + if (!("references" in report)) throw new Error("Expected complete report"); + expect(report.references).toMatchObject([{ phandle: 3, target: null }]); + expect(report.limitations).toContain( + "phandle at /target is not a single cell; this node's reference identity remains unresolved.", + ); + }, +); cliTest( "retains complete DTB Evidence when delivering a summary", async ({ cli }) => { diff --git a/tests/acceptance/peLoadConfigCli.test.ts b/tests/acceptance/peLoadConfigCli.test.ts index 273293a6b1..077d1d4734 100644 --- a/tests/acceptance/peLoadConfigCli.test.ts +++ b/tests/acceptance/peLoadConfigCli.test.ts @@ -2,7 +2,10 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; -import { peLoadConfigImage } from "../../src/native/pe/PeLoadConfig.fixture.js"; +import { + PE_LOAD_CONFIG_FIXTURE, + peLoadConfigImage, +} from "../../src/native/pe/PeLoadConfig.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; @@ -20,6 +23,12 @@ for (const bits of [32, 64] as const) { ); expect(evidence.normalized_result.bits).toBe(bits); expect(evidence.normalized_result.tls?.callbacks).toHaveLength(3); + expect(evidence.normalized_result.load_config).toMatchObject({ + dynamic_value_reloc_table_offset: + PE_LOAD_CONFIG_FIXTURE.dynamicRelocOffset, + dynamic_value_reloc_table_section: + PE_LOAD_CONFIG_FIXTURE.dynamicRelocSection, + }); expect(evidence.subject?.local_path).toBe(join(root, "app.exe")); }); } diff --git a/tests/boundary/mcp/devicetreeMcp.test.ts b/tests/boundary/mcp/devicetreeMcp.test.ts index 46bfdb9e93..506107164a 100644 --- a/tests/boundary/mcp/devicetreeMcp.test.ts +++ b/tests/boundary/mcp/devicetreeMcp.test.ts @@ -16,6 +16,7 @@ import { onTestFinished } from "vitest"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { + cells, devicetreeFixture, dtString, } from "../../../src/firmware/devicetree/Devicetree.fixture.js"; @@ -172,6 +173,71 @@ mcpTest( }, 60_000, ); +mcpTest( + "keeps a malformed canonical phandle unresolved in retained reference pages", + async ({ mcp }) => { + const session = createTestBinarySession(() => { + throw new Error("DTB inspection must not start an engine"); + }); + const client = await mcp.connect(createDtbServer(session)); + try { + const root = await createTestTempDirectory("rea-dtb-mcp-phandle-"); + const path = join(root, "board.dtb"); + await writeFile( + path, + devicetreeFixture({ + name: "", + children: [ + { + name: "target", + properties: { phandle: cells(1, 2), "linux,phandle": cells(3) }, + }, + { name: "consumer", properties: { "interrupt-parent": cells(3) } }, + ], + }), + ); + const response = await client.callTool({ + name: "inspect_devicetree", + arguments: { path, detail: "summary" }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const result = toolContract("inspect_devicetree").outputSchema.parse( + response.structuredContent, + ).normalized_result; + if (!("parent_evidence_id" in result)) + throw new Error("Expected summary view"); + expect(result.summary.unresolved_reference_count).toBe(1); + await unlink(path); + const page = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { + kind: "retained-evidence", + evidence_id: result.parent_evidence_id, + }, + view: { kind: "devicetree", collection: "references" }, + }, + }); + expect(page.isError, JSON.stringify(page)).not.toBe(true); + expect(page.structuredContent).toMatchObject({ + normalized_result: { + devicetree: { unresolved_reference_count: 1 }, + items: [ + { + source: "/consumer", + property: "interrupt-parent", + phandle: 3, + target: null, + }, + ], + }, + }); + } finally { + await session.close(); + } + }, + 60_000, +); mcpTest("rejects relative paths and unsupported versions", async ({ mcp }) => { const session = createTestBinarySession(() => { throw new Error("No engine"); diff --git a/tests/boundary/mcp/peLoadConfigMcp.test.ts b/tests/boundary/mcp/peLoadConfigMcp.test.ts index ef2c9f43ce..8a347e9bd5 100644 --- a/tests/boundary/mcp/peLoadConfigMcp.test.ts +++ b/tests/boundary/mcp/peLoadConfigMcp.test.ts @@ -3,7 +3,10 @@ import { join } from "node:path"; import { expect } from "vitest"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { createServer } from "../../../src/server/createServer.js"; -import { peLoadConfigImage } from "../../../src/native/pe/PeLoadConfig.fixture.js"; +import { + PE_LOAD_CONFIG_FIXTURE, + peLoadConfigImage, +} from "../../../src/native/pe/PeLoadConfig.fixture.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { mcpTest } from "../../support/mcp/mcpFixture.js"; @@ -48,6 +51,12 @@ mcpTest( expect( evidence.normalized_result.load_config?.guard_flags?.flags, ).toContain("IMAGE_GUARD_CF_INSTRUMENTED"); + expect(evidence.normalized_result.load_config).toMatchObject({ + dynamic_value_reloc_table_offset: + PE_LOAD_CONFIG_FIXTURE.dynamicRelocOffset, + dynamic_value_reloc_table_section: + PE_LOAD_CONFIG_FIXTURE.dynamicRelocSection, + }); expect(await readFile(path)).toEqual(bytes); const bad = await client.callTool( {