Files
reverse-engineering-anything/tests/acceptance/peImportsCli.test.ts
T
4ef916da03 feat(pe): inspect import and delay-load tables (#6509)
* feat(pe): inspect import and delay-load tables

Add inspect_pe_imports / inspect-pe-imports to read PE32/PE32+ import
and delay-load directories without executing the image. The report keeps
DLL names, named and ordinal functions, ILT/IAT RVAs, and a pefile
import hash of the standard import table. A missing directory is
observed absence, not proof the image never imported APIs.

* fix(pe): default PE import fixture options

Allow peImportImage() without an argument so CLI and filesystem tests
can use the KERNEL32 sample without repeating an empty options object.

* test(pe): keep import path cases on the dedicated MCP contract

inspect_pe_imports already rejects relative MCP paths in its own
boundary test. Leave the shared absolute-path matrix to tools whose
handler issue projection matches that suite's expected shape.

* fix(native): index virtual-to-file mapping lookups

mappedFileOffset scans every mapping twice per span, so a parser that
translates one span per table entry costs sections x entries. Add
fileMappingLookup, which builds an O(n log n) interval index once and
answers each span in O(log n) with the same accept, reject and
translation results. A differential test checks it against
mappedFileOffset over randomized overlapping mappings and zero fills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra7LM2NyQ7DumdssxbUq5g

* fix(pe): report partial import evidence and exact pefile hashes

- Withhold imphash with an explicit reason whenever the standard import
  table is partial (unreadable names or thunks, missing tables), or when
  pefile 2024.8.26 would hash a different row sequence: invalid or
  truncated names, ordinal 0, descriptors without functions, tables its
  bounds or repeat/spread heuristics discard, and its 8192-thunk import
  symbol cap. Delay-load issues do not withhold it.
- Hash ordinal imports with pefile's complete ordlookup tables and its
  ord<decimal> fallback instead of declining unmapped ordinals.
- Report a nonterminal descriptor whose FirstThunk is zero, and a
  delay-load descriptor whose IAT or INT RVA is zero, as malformed_entry
  partial coverage instead of a complete empty inventory.
- Directory evidence lists the file spans actually consumed for the
  descriptor array and terminator; discontiguous mappings produce
  separate spans and separate Evidence ranges.
- Carry exhausted limits as PeImportLimitFailure.limit and derive
  recovery from it; the fixed 16 MiB retained-text ceiling reports a
  fixed allowance instead of max_file_bytes advice.
- Translate RVAs through the indexed mapping lookup and yield with a
  cancellation check every 1024 table entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra7LM2NyQ7DumdssxbUq5g

* fix(pe): read only own ordinal tables for DLL names

DLL names such as constructor and __proto__ pass pefile's name rule but
resolved inherited object members, throwing before the ord<n> fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra7LM2NyQ7DumdssxbUq5g

* fix(native): preserve PE import facts and prove hash compatibility

* test(native): verify PE import workflows and measured costs

* refactor(native): narrow PE facts across the container union

* docs(native): record integrated PE import validation

* fix(native): bound every PE import RVA span

* docs(native): measure RVA guard completed workflows

* fix(pe): reconcile import coverage with retained rows

* fix(pe): retain partial imports at descriptor mapping gaps

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-24 11:44:12 +08:00

192 lines
6.7 KiB
TypeScript

import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../src/contracts/toolContracts.js";
import {
peImportImage,
peImportFixtureOffset,
peImportLeadingSectionImage,
peImportManySectionsImage,
peImportDirectoryAtRva,
peImportDirectoryMappingStops,
peWithoutImportTableFixture,
} from "../fixtures/binaryDiagnostics/peImports.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
cliTest("reports PE import names through the public CLI", async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-imports-cli-");
const path = join(root, "app.exe");
await writeFile(path, peImportImage());
const result = await cli.run({
arguments: ["inspect-pe-imports", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract("inspect_pe_imports").outputSchema.parse(
result.json,
);
expect(evidence.normalized_result.imports[0]?.name).toBe("KERNEL32.dll");
expect(evidence.normalized_result.imphash.md5).toBe(
"92cef6cc91a85825f3c8a0516517c66f",
);
});
cliTest(
"resolves a relative PE path against the working directory",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-imports-cli-rel-");
await writeFile(join(root, "unsigned.exe"), peWithoutImportTableFixture());
const result = await cli.run({
arguments: ["inspect-pe-imports", "unsigned.exe", "--json"],
cwd: root,
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract("inspect_pe_imports").outputSchema.parse(
result.json,
);
expect(evidence.normalized_result.import_directory).toBeNull();
expect(evidence.subject?.local_path).toBe(join(root, "unsigned.exe"));
},
);
cliTest("reports a truncated PE as invalid input", async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-imports-cli-bad-");
const path = join(root, "broken.exe");
await writeFile(path, Buffer.from("MZ"));
const result = await cli.run({
arguments: ["inspect-pe-imports", path, "--json"],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: { operation: "inspect_pe_imports" },
});
});
cliTest(
"preserves directory declarations and partial rows at RVA and mapping boundaries",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-pe-imports-cli-directory-rva-",
);
for (const [kind, rva, retained, mappingGap] of [
["standard", 0xffffffec, 1, false],
["standard", 0xfffffff0, 0, false],
["delay", 0xffffffe0, 1, false],
["delay", 0xfffffff0, 0, false],
["standard", 0x40000000, 0, true],
["standard", 0x40000000, 1, true],
["delay", 0x40000000, 0, true],
["delay", 0x40000000, 1, true],
] as const) {
const path = join(root, `${kind}-${String(rva)}-${String(retained)}.exe`);
await writeFile(
path,
mappingGap
? peImportDirectoryMappingStops(kind, retained)
: peImportDirectoryAtRva(kind, rva),
);
const result = await cli.run({
arguments: ["inspect-pe-imports", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const report = toolContract("inspect_pe_imports").outputSchema.parse(
result.json,
).normalized_result;
const directory =
kind === "standard"
? report.import_directory
: report.delay_import_directory;
const rows = kind === "standard" ? report.imports : report.delay_imports;
expect(directory).toMatchObject({ rva });
expect(directory?.locations).toHaveLength(retained);
expect(rows).toHaveLength(retained);
expect(report.coverage.status).toBe("partial");
expect(report.imphash.md5).toBe(
kind === "delay" ? "92cef6cc91a85825f3c8a0516517c66f" : null,
);
}
},
);
cliTest(
"retains a partial inventory when an IAT RVA overflows",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-imports-cli-overflow-");
for (const bits of [32, 64] as const) {
const bytes = peImportImage({ bits });
bytes.writeUInt32LE(
0x100000000 - bits / 8,
peImportFixtureOffset(0x1000) + 16,
);
const path = join(root, `app-${String(bits)}.exe`);
await writeFile(path, bytes);
const result = await cli.run({
arguments: ["inspect-pe-imports", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const report = toolContract("inspect_pe_imports").outputSchema.parse(
result.json,
).normalized_result;
expect(report.imports[0]?.functions.map((row) => row.name)).toEqual([
"GetProcAddress",
]);
expect(report.coverage.status).toBe("partial");
expect(report.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "malformed_entry",
location: "0x100000000",
}),
]),
);
expect(report.imphash.md5).toBeNull();
}
},
);
cliTest(
"keeps complete facts when pefile cannot see the same import table",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-pe-imports-cli-compatibility-",
);
const functions = Array.from({ length: 140 }, (_, i) => ({
name: `Function${String(i).padStart(3, "0")}`,
}));
const cases = [
[
"cross-section",
peImportImage({
sectionBytes: 0x4000,
splitAt: 0x1000,
dlls: [{ name: "KERNEL32.dll", functions }],
}),
140,
],
["section-cap", peImportManySectionsImage(2049, 2), 2],
["null-section", peImportLeadingSectionImage(false), 2],
] as const;
for (const [name, bytes, count] of cases) {
const path = join(root, `${name}.exe`);
await writeFile(path, bytes);
const result = await cli.run({
arguments: ["inspect-pe-imports", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const report = toolContract("inspect_pe_imports").outputSchema.parse(
result.json,
).normalized_result;
expect(report.imports[0]?.name).toBe("KERNEL32.dll");
expect(report.imports[0]?.functions).toHaveLength(count);
expect(report.coverage.status).toBe("complete");
expect(report.imphash).toMatchObject({
md5: null,
reason: expect.stringMatching(/mapping|compatibility/u),
});
if (name === "cross-section")
expect(report.imports[0]?.functions[128]?.name).toBe("Function128");
}
},
);