diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index c4779846e1..b4b5bf5f12 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -193,6 +193,14 @@ reported. Directory headers, entries, data entries and payloads retain original file offsets and lengths. Each opaque payload has its own SHA-256; code page and reserved data-entry fields are preserved. +Every `directories[]` row retains its identity `path` and the matching incoming +`entry_locations`: both are empty for the root, then contain the type and +optionally the name. Empty branches keep these identities even when they have +no payload leaves. Shared directory bytes produce a row for each incoming path, +with the original directory range and each path's own entry locations. These +rows are included in the metadata budget. A present empty root remains distinct +from an absent resource data directory (`directory: null`, `directories: []`). + RT_GROUP_ICON records expose original image descriptors and RT_ICON candidates. A reference resolves only when an icon with the same numeric ID and language exists. Missing references, other-language candidates and declared-size mismatch diff --git a/src/application/binaryDiagnostics/PeResourcesService.test.ts b/src/application/binaryDiagnostics/PeResourcesService.test.ts index 3b52b91f9a..3b08d79961 100644 --- a/src/application/binaryDiagnostics/PeResourcesService.test.ts +++ b/src/application/binaryDiagnostics/PeResourcesService.test.ts @@ -30,6 +30,9 @@ it.each([ "outside-range", "wrong-coverage", "bad-icon-reference", + "mismatched-directory-path", + "outside-directory-entry", + "outside-directory-name", ])( "rejects invalid provider output before creating Evidence: %s", async (problem) => { @@ -40,6 +43,25 @@ it.each([ if (problem === "wrong-coverage") report.coverage.resources = 99; if (problem === "bad-icon-reference") report.icon_groups[0]!.images[0]!.candidate_resource_indices = [999]; + if (problem === "mismatched-directory-path") + report.directories[1]!.path = []; + if (problem === "outside-directory-entry") + report.directories[1]!.entry_locations[0] = { + offset: report.artifact.bytes, + bytes: 8, + }; + if (problem === "outside-directory-name") { + const directory = report.directories.find( + (item) => item.path[1]?.kind === "name", + ); + const identity = directory?.path[1]; + if (directory === undefined || identity?.kind !== "name") + throw new Error("Missing named directory fixture"); + directory.path[1] = { + ...identity, + location: { offset: report.artifact.bytes, bytes: 2 }, + }; + } const service = new PeResourcesService({ identity: PE_RESOURCES_PROVIDER, inspect: () => Promise.resolve(ok(report)), diff --git a/src/application/binaryDiagnostics/PeResourcesService.ts b/src/application/binaryDiagnostics/PeResourcesService.ts index 51a8a56206..b297e80e03 100644 --- a/src/application/binaryDiagnostics/PeResourcesService.ts +++ b/src/application/binaryDiagnostics/PeResourcesService.ts @@ -113,6 +113,8 @@ const validReport = ( ): boolean => { const inside = ({ offset, bytes }: { offset: number; bytes: number }) => offset <= report.artifact.bytes - bytes; + const identityInside = (identity: PeResourceIdentity): boolean => + identity.kind === "id" || inside(identity.location); if ( report.artifact.bytes > maxBytes || report.coverage.resources !== report.resources.length || @@ -135,7 +137,16 @@ const validReport = ( !inside(report.directory.data_directory_location)) ) return false; - if (!report.directories.every((item) => inside(item.location))) return false; + if ( + !report.directories.every( + (item) => + inside(item.location) && + item.path.length === item.entry_locations.length && + item.entry_locations.every(inside) && + item.path.every(identityInside), + ) + ) + return false; if ( !report.resources.every( (item, index) => @@ -143,9 +154,7 @@ const validReport = ( inside(item.data_entry_location) && inside(item.payload.location) && item.entry_locations.every(inside) && - [item.type, item.name, item.language].every( - (identity) => identity.kind === "id" || inside(identity.location), - ), + [item.type, item.name, item.language].every(identityInside), ) ) return false; diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index 7d58bed172..732af307d6 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -108,7 +108,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_pe_resources"), kind: "native-provider", description: - "Inspect one explicit local PE32/PE32+ image's complete bounded type/name/language resource tree without executing it or opening a disassembler. Returns numeric IDs versus original UTF-16 names, directory/data-entry file locations, payload RVA/file ranges, complete SHA-256 hashes and static RT_GROUP_ICON image-resource candidates. Language fallback, image decoding and actual shell/window loading remain unobserved. Reads a stable regular file; malformed or ambiguous mappings, non-identical overlapping ranges and exhausted budgets return no partial success. Portable on supported Node hosts; no Windows SDK or engine installation required.", + "Inspect one explicit local PE32/PE32+ image's complete bounded type/name/language resource tree without executing it or opening a disassembler. Returns numeric IDs versus original UTF-16 names, directory identity paths including empty branches, directory/data-entry file locations, payload RVA/file ranges, complete SHA-256 hashes and static RT_GROUP_ICON image-resource candidates. Language fallback, image decoding and actual shell/window loading remain unobserved. Reads a stable regular file; malformed or ambiguous mappings, non-identical overlapping ranges and exhausted budgets return no partial success. Portable on supported Node hosts; no Windows SDK or engine installation required.", inputSchema: inspectPeResourcesInputSchema, outputSchema: evidenceResultOf(peResourcesSchema), examples: [ diff --git a/src/domain/native/peResources.ts b/src/domain/native/peResources.ts index 91b669f18a..e0345d1a32 100644 --- a/src/domain/native/peResources.ts +++ b/src/domain/native/peResources.ts @@ -65,6 +65,16 @@ export const peResourcesSchema = z.strictObject({ .nullable(), directories: z.array( z.strictObject({ + path: z + .array(identity) + .max(2) + .describe( + "Type/name identities leading to this directory; [] for the root", + ), + entry_locations: z + .array(range) + .max(2) + .describe("Incoming entry file ranges corresponding to path"), location: range, characteristics: uint, timestamp: uint, diff --git a/src/native/pe/PeResourceParser.test.ts b/src/native/pe/PeResourceParser.test.ts index ecf3ec4195..1d34b6048f 100644 --- a/src/native/pe/PeResourceParser.test.ts +++ b/src/native/pe/PeResourceParser.test.ts @@ -133,12 +133,14 @@ describe("portable PE resource parsing", () => { const fixture = peResourceFixture(false, []); expect(await parse(fixture.bytes)).toMatchObject({ directory: { rva: 0x2000 }, + directories: [{ path: [], entry_locations: [] }], resources: [], coverage: { resources: 0, examined_entries: 0 }, }); fixture.bytes.fill(0, fixture.directoryAt, fixture.directoryAt + 8); expect(await parse(fixture.bytes)).toMatchObject({ directory: null, + directories: [], resources: [], }); }); diff --git a/src/native/pe/PeResourceParser.ts b/src/native/pe/PeResourceParser.ts index 6a56ac15c2..271b67d68c 100644 --- a/src/native/pe/PeResourceParser.ts +++ b/src/native/pe/PeResourceParser.ts @@ -132,6 +132,8 @@ export const parsePeResources = async ( examined += count; relative(at, 16 + 8 * count); const directory = { + path, + entry_locations: entryLocations, location: recordRange(header, 16 + 8 * count, "directory"), characteristics: bytes.readUInt32LE(header), timestamp: bytes.readUInt32LE(header + 4), diff --git a/src/native/pe/PeResources.fixture.ts b/src/native/pe/PeResources.fixture.ts index 45c2e51f57..822065809e 100644 --- a/src/native/pe/PeResources.fixture.ts +++ b/src/native/pe/PeResources.fixture.ts @@ -4,6 +4,10 @@ interface Resource { readonly language: number | string; readonly payload: Buffer; } +interface EmptyBranch { + readonly type: number | string; + readonly name?: number | string; +} interface Tree { readonly children: Map< string, @@ -30,7 +34,7 @@ const iconGroup = (): Buffer => { /** Hand-authored PE resource bytes, independent of the production decoder. */ export const peResourceFixture = ( plus = false, - resources: readonly Resource[] = [ + resources: readonly (Resource | EmptyBranch)[] = [ { type: 3, name: 1, language: 1033, payload: Buffer.from("ICON") }, { type: 3, name: 1, language: 1041, payload: Buffer.from("JAPN") }, { type: 10, name: "7", language: 1033, payload: Buffer.from("Hello") }, @@ -43,17 +47,22 @@ export const peResourceFixture = ( const tree: Tree = { children: new Map() }; for (const resource of resources) { let parent = tree; - for (const [depth, identity] of [ - resource.type, - resource.name, - resource.language, - ].entries()) { + const path = + "payload" in resource + ? [resource.type, resource.name, resource.language] + : resource.name === undefined + ? [resource.type] + : [resource.type, resource.name]; + for (const [depth, identity] of path.entries()) { const key = `${typeof identity}:${String(identity)}`; let child = parent.children.get(key); if (child === undefined) { child = { identity, - value: depth === 2 ? resource : { children: new Map() }, + value: + depth === 2 && "payload" in resource + ? resource + : { children: new Map() }, }; parent.children.set(key, child); } diff --git a/tests/acceptance/applications/peResources.test.ts b/tests/acceptance/applications/peResources.test.ts index 8b606bc011..8e50fe2a5b 100644 --- a/tests/acceptance/applications/peResources.test.ts +++ b/tests/acceptance/applications/peResources.test.ts @@ -312,6 +312,104 @@ const expectPeInventoriesAcrossAdapters = async ( } }; +const expectPeEmptyBranchesAcrossAdapters = async ( + cli: TestCli, + call: PeResourceCall, + root: string, +): Promise => { + for (const shared of [false, true]) { + const fixture = shared + ? peResourceFixture(true, [{ type: 10 }, { type: 11 }]) + : peResourceFixture(false, [ + { type: "10" }, + { type: 10 }, + { type: 11 }, + { type: 20, name: "7" }, + { type: 20, name: 7 }, + { type: 99, name: 1, language: 1033, payload: Buffer.from("opaque") }, + ]); + if (shared) { + // Two distinct root identities point to exactly the same empty table. + fixture.bytes.writeUInt32LE( + fixture.bytes.readUInt32LE(0x400 + 20), + 0x400 + 28, + ); + } + const path = join( + root, + shared ? "shared-empty64.exe" : "empty-branches32.exe", + ); + await writeFile(path, fixture.bytes); + const result = await runFileCli(cli, { + arguments: ["inspect-pe-resources", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = contract.outputSchema.parse(result.json); + const response = await call({ path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const mcp = contract.outputSchema.parse(response.structuredContent); + expect(mcp.normalized_result).toEqual(evidence.normalized_result); + expect(mcp.evidence_id).toBe(evidence.evidence_id); + const report = peResourcesSchema.parse(evidence.normalized_result); + const paths = report.directories.map((directory) => + directory.path.map((identity) => + identity.kind === "id" ? identity.id : identity.name, + ), + ); + expect(paths).toEqual( + shared + ? [[], [10], [11]] + : [[], ["10"], [10], [11], [20], [20, "7"], [20, 7], [99], [99, 1]], + ); + expect(report.coverage).toEqual({ + status: "complete", + examined_entries: shared ? 2 : 9, + resources: shared ? 0 : 1, + }); + for (const directory of report.directories) + expect(directory.entry_locations).toHaveLength(directory.path.length); + expect(report.directories[0]?.entry_locations).toEqual([]); + if (shared) { + expect(report.resources).toEqual([]); + expect(report.directories[1]?.location).toEqual( + report.directories[2]?.location, + ); + expect(report.directories[1]?.entry_locations).toEqual([ + { offset: 0x400 + 16, bytes: 8 }, + ]); + expect(report.directories[2]?.entry_locations).toEqual([ + { offset: 0x400 + 24, bytes: 8 }, + ]); + } else { + expect(report.directories[1]?.path).toEqual([ + { + kind: "name", + name: "10", + utf16le_hex: "31003000", + location: { offset: 0x400 + 56, bytes: 6 }, + }, + ]); + expect(report.directories[5]?.path[1]).toMatchObject({ + kind: "name", + name: "7", + utf16le_hex: "3700", + }); + const resource = report.resources[0]!; + expect(resource.payload.sha256).toBe( + createHash("sha256").update("opaque").digest("hex"), + ); + expect(report.directories.at(-1)).toMatchObject({ + path: [resource.type, resource.name], + entry_locations: resource.entry_locations.slice(0, 2), + }); + } + expect(evidence.subject?.digest?.sha256).toBe( + createHash("sha256").update(fixture.bytes).digest("hex"), + ); + expect(await readFile(path)).toEqual(fixture.bytes); + } +}; + cliTest( "inspects PE32/PE32+ resources through compiled CLI and real stdio MCP without an active target", async ({ cli }) => { @@ -359,6 +457,7 @@ cliTest( "inspect_pe_resources", ); await expectPeInventoriesAcrossAdapters(cli, call, root); + await expectPeEmptyBranchesAcrossAdapters(cli, call, root); await expectPeWordAlignedRecordsAcrossAdapters({ cli, call, @@ -385,6 +484,7 @@ cliTest( contract.outputSchema.parse(empty.structuredContent).normalized_result, ).toMatchObject({ directory: null, + directories: [], resources: [], coverage: { status: "complete", resources: 0 }, });