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( {