fix(pe): preserve resource directory paths and source ranges

Retain each resource directory's type/name path and incoming entry ranges before metadata accounting. Empty branches keep their identity, and shared directory bytes retain each distinct incoming path. Validate the expanded facts in the shared service and advertise them through the canonical contract.

Validation: parser, service, filesystem and public CLI/MCP regressions cover numeric/named empty branches, shared directories and unchanged artifact/payload hashes. Controls use authored PE32/PE32+ files; Windows loader behavior remains unverified.

Fixes #6872
This commit is contained in:
morluto committed 2026-10-24 03:06:58 +08:00
1 parent 254a8e7018
commit 15190ffc49
9 files changed
+174 -12

No files matched your search

+8
View File
@@ -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
@@ -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)),
@@ -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;
@@ -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: [
+10
View File
@@ -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,
+2
View File
@@ -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: [],
});
});
+2
View File
@@ -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),
+16 -7
View File
@@ -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);
}
@@ -312,6 +312,104 @@ const expectPeInventoriesAcrossAdapters = async (
}
};
const expectPeEmptyBranchesAcrossAdapters = async (
cli: TestCli,
call: PeResourceCall,
root: string,
): Promise<void> => {
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 },
});