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>
This commit is contained in:
authored and GitHub committed 2026-10-24 11:44:12 +08:00
1 parent f508a1a47e
commit 4ef916da03
66 files changed
+4824 -31

No files matched your search

@@ -0,0 +1,49 @@
import { expect, it } from "vitest";
import { ElfNotesService } from "./ElfNotesService.js";
import { PeCertificatesService } from "./PeCertificatesService.js";
import { PeImportsService } from "./PeImportsService.js";
import {
ELF_NOTES_PROVIDER,
PE_CERTIFICATES_PROVIDER,
PE_IMPORTS_PROVIDER,
} from "../../domain/investigationProviders.js";
const inspect = (): never => {
throw new Error("An already cancelled request must not reach its provider");
};
it.each([
[
"PE imports",
() => new PeImportsService({ identity: PE_IMPORTS_PROVIDER, inspect }),
],
[
"PE certificates",
() =>
new PeCertificatesService({
identity: PE_CERTIFICATES_PROVIDER,
inspect,
}),
],
[
"ELF notes",
() => new ElfNotesService({ identity: ELF_NOTES_PROVIDER, inspect }),
],
] as const)(
"preserves an early cancellation reason for %s before validation",
async (_label, create) => {
const service = create();
try {
for (const reason of [null, { caller: "selected task" }]) {
const result = await service.inspect(null, {
signal: AbortSignal.abort(reason),
});
if (result.ok) throw new Error("Expected early cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
}
} finally {
await service.close();
}
},
);
@@ -34,7 +34,9 @@ export class ElfNotesService {
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
return err(
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
);
const input = inspectElfNotesInputSchema.safeParse(rawInput);
if (!input.success)
return err(
@@ -37,7 +37,9 @@ export class PeCertificatesService {
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
return err(
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
);
const input = inspectPeCertificatesInputSchema.safeParse(rawInput);
if (!input.success)
return err(
@@ -0,0 +1,20 @@
import type {
ExecutionOptions,
ProviderIdentity,
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type {
InspectPeImportsInput,
PeImports,
} from "../../domain/native/peImports.js";
import type { Result } from "../../domain/result.js";
/** Stable read and PE import-table decoding, independent of a disassembler session. */
export interface PeImportsPort {
readonly identity: ProviderIdentity;
close?(): Promise<void>;
inspect(
input: InspectPeImportsInput,
options?: ExecutionOptions,
): Promise<Result<PeImports, AnalysisError>>;
}
@@ -0,0 +1,242 @@
import { expect, it } from "vitest";
import { PeImportsService } from "./PeImportsService.js";
import { PE_IMPORTS_PROVIDER } from "../../domain/investigationProviders.js";
import type { PeImports } from "../../domain/native/peImports.js";
import { peImportsSchema } from "../../domain/native/peImports.js";
import { ok } from "../../domain/result.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
const identity = PE_IMPORTS_PROVIDER;
const report: PeImports = {
artifact: {
path: "/artifacts/app.exe",
sha256: "a".repeat(64),
bytes: 512,
},
format: "pe32-plus",
machine: 0x8664,
bits: 64,
architecture: "amd64",
import_directory: null,
delay_import_directory: null,
imports: [],
delay_imports: [],
imphash: {
algorithm: "pefile",
md5: null,
reason: "IMAGE_DIRECTORY_ENTRY_IMPORT is absent.",
},
coverage: {
status: "complete",
reason: null,
examined_dlls: 0,
imports: 0,
delay_imports: 0,
},
issues: [],
limitations: ["IMAGE_DIRECTORY_ENTRY_IMPORT is absent."],
};
it("rejects a relative path and extra arguments before reading", async () => {
let calls = 0;
const service = new PeImportsService({
identity,
inspect: () => {
calls += 1;
return Promise.resolve(ok(report));
},
});
const relative = await service.inspect({ path: "app.exe" });
const extra = await service.inspect({
path: report.artifact.path,
extra: true,
});
expect(relative.ok).toBe(false);
expect(extra.ok).toBe(false);
if (!relative.ok) expect(relative.error).toBeInstanceOf(AnalysisInputError);
if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError);
expect(calls).toBe(0);
});
it("records the selected PE and an absent import table", async () => {
const result = await new PeImportsService({
identity,
inspect: () => Promise.resolve(ok(report)),
}).inspect({ path: report.artifact.path });
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
confidence: "observed",
subject: { format: "pe", architecture: "x86_64" },
normalized_result: report,
});
});
it.each(["imports", "delay_imports", "examined_dlls"] as const)(
"rejects contradictory complete %s coverage",
async (field) => {
const result = await new PeImportsService({
identity,
inspect: () =>
Promise.resolve(
ok({
...report,
coverage: { ...report.coverage, [field]: 1 },
}),
),
}).inspect({ path: report.artifact.path });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
},
);
it.each(["import_directory", "delay_import_directory"] as const)(
"accepts unexamined %s declarations only with partial coverage",
async (field) => {
for (const status of ["complete", "partial"] as const) {
const value: PeImports = {
...report,
[field]: { rva: 0xfffffff0, size: 40, locations: [] },
coverage: {
...report.coverage,
status,
reason:
status === "partial"
? "First descriptor cannot be examined."
: null,
},
issues:
status === "partial"
? [
{
code: "malformed_entry",
location: "0xfffffff0",
message: "First descriptor cannot be examined.",
},
]
: [],
};
const result = await new PeImportsService({
identity,
inspect: () => Promise.resolve(ok(value)),
}).inspect({ path: report.artifact.path });
expect(result.ok).toBe(status === "partial");
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
}
},
);
it("discards an observation if the caller cancels while the provider completes", async () => {
const controller = new AbortController();
const service = new PeImportsService({
identity,
inspect: () => {
controller.abort();
return Promise.resolve(ok(report));
},
});
const result = await service.inspect(
{ path: report.artifact.path },
{ signal: controller.signal },
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
});
it("publishes every consumed directory span as its own Evidence location", async () => {
const split: PeImports = {
...report,
import_directory: {
rva: 0x1000,
size: 40,
locations: [
{ offset: 0x100, bytes: 20 },
{ offset: 0x1e0, bytes: 20 },
],
},
imports: [
{
index: 0,
name: "KERNEL32.dll",
name_rva: 0x1100,
original_first_thunk_rva: 0x1120,
first_thunk_rva: 0x1128,
time_date_stamp: 0,
forwarder_chain: 0,
location: { offset: 0x100, bytes: 20 },
functions: [],
},
],
coverage: { ...report.coverage, examined_dlls: 1 },
imphash: { ...report.imphash, reason: "The descriptor has no functions." },
limitations: ["Loaded import state remains unobserved."],
};
const result = await new PeImportsService({
identity,
inspect: () => Promise.resolve(ok(split)),
}).inspect({ path: report.artifact.path });
if (!result.ok) throw result.error;
expect(result.value.locations).toEqual([
{ kind: "artifact-path", path: report.artifact.path },
{ kind: "file-offset-range", start: 0x100, end: 0x114 },
{ kind: "file-offset-range", start: 0x1e0, end: 0x1f4 },
]);
const outside = await new PeImportsService({
identity,
inspect: () =>
Promise.resolve(
ok({
...split,
import_directory: {
rva: 0x1000,
size: 40,
locations: [
{ offset: 0x100, bytes: 20 },
{ offset: 0x1f8, bytes: 20 },
],
},
}),
),
}).inspect({ path: report.artifact.path });
expect(outside.ok).toBe(false);
if (!outside.ok) expect(outside.error).toBeInstanceOf(AnalysisOutputError);
});
it("retains examined unsupported descriptors without inventing DLL rows", async () => {
const partial: PeImports = {
...report,
delay_import_directory: {
rva: 0x1000,
size: 64,
locations: [{ offset: 0x100, bytes: 64 }],
},
coverage: {
...report.coverage,
status: "partial",
reason: "An unsupported descriptor was examined.",
examined_dlls: 1,
},
issues: [
{
code: "unsupported_form",
location: "0x1000",
message: "Delay descriptor uses virtual addresses.",
},
],
};
const result = await new PeImportsService({
identity,
inspect: () => Promise.resolve(ok(partial)),
}).inspect({ path: report.artifact.path });
if (!result.ok) throw result.error;
const normalized = peImportsSchema.parse(result.value.normalized_result);
expect(normalized.coverage).toMatchObject({
status: "partial",
examined_dlls: 1,
});
expect(normalized.delay_imports).toEqual([]);
});
@@ -0,0 +1,177 @@
import { isAbsolute } from "node:path";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { PeImportsPort } from "./PeImportsPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import {
inspectPeImportsInputSchema,
peImportsSchema,
type PeImports,
} from "../../domain/native/peImports.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import {
decodePeMachine,
peOptionalHeaderMagic,
type PeArchitecture,
} from "../../domain/peMachine.js";
import { err, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
const OPERATION = "inspect_pe_imports";
/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */
export class PeImportsService {
constructor(readonly provider: PeImportsPort) {}
close(): Promise<void> {
return this.provider.close?.() ?? Promise.resolve();
}
async inspect(
rawInput: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
);
const input = inspectPeImportsInputSchema.safeParse(rawInput);
if (!input.success)
return err(
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, {
cause: input.error,
}),
);
if (!isAbsolute(input.data.path))
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["path"],
reason: "invalid_format",
message: "Expected an absolute filesystem path on this host.",
},
]),
);
const result = await this.provider.inspect(input.data, options);
if (!result.ok) return result;
const report = peImportsSchema.safeParse(result.value);
if (
!report.success ||
report.data.artifact.path !== input.data.path ||
!validReport(report.data, input.data)
)
return err(
new AnalysisOutputError(
OPERATION,
"PE import provider returned malformed output or changed artifact identity.",
),
);
const value = report.data;
const architecture = subjectArchitecture(value);
const locations = [
{ kind: "artifact-path" as const, path: value.artifact.path },
...fileRange(value.import_directory),
...fileRange(value.delay_import_directory),
];
return completeProviderEvidence(
createEvidence(
{
path: value.artifact.path,
format: "pe",
sha256: value.artifact.sha256,
...(architecture === undefined ? {} : { architecture }),
},
this.provider.identity,
{
operation: OPERATION,
parameters: input.data,
result: value,
confidence: "observed",
limitations: value.limitations,
locations,
},
),
options?.signal,
);
}
}
const subjectArchitecture = (report: PeImports): PeArchitecture | undefined => {
const machine = decodePeMachine(report.machine);
if (machine === undefined || machine.nativeOsOverride) return undefined;
const magic = report.format === "pe32-plus" ? 0x20b : 0x10b;
return magic === peOptionalHeaderMagic(machine.architecture)
? machine.architecture
: undefined;
};
const fileRange = (
directory: PeImports["import_directory"],
): readonly {
kind: "file-offset-range";
start: number;
end: number;
}[] =>
directory === null
? []
: directory.locations.map(({ offset, bytes }) => ({
kind: "file-offset-range" as const,
start: offset,
end: offset + bytes,
}));
const validReport = (
report: PeImports,
input: {
readonly max_dlls: number;
readonly max_functions: number;
readonly max_file_bytes: number;
},
): boolean => {
const inside = ({ offset, bytes }: { offset: number; bytes: number }) =>
offset <= report.artifact.bytes - bytes;
const dllCount = report.imports.length + report.delay_imports.length;
if (
report.artifact.bytes > input.max_file_bytes ||
report.coverage.examined_dlls > input.max_dlls ||
dllCount > report.coverage.examined_dlls ||
(report.coverage.status === "complete" &&
(dllCount !== report.coverage.examined_dlls || report.issues.length > 0))
)
return false;
const importCount = report.imports.reduce(
(count, dll) => count + dll.functions.length,
0,
);
const delayImportCount = report.delay_imports.reduce(
(count, dll) => count + dll.functions.length,
0,
);
if (
importCount !== report.coverage.imports ||
delayImportCount !== report.coverage.delay_imports ||
importCount + delayImportCount > input.max_functions
)
return false;
for (const directory of [
report.import_directory,
report.delay_import_directory,
]) {
if (
directory !== null &&
(!directory.locations.every(inside) ||
(directory.locations.length === 0 &&
report.coverage.status === "complete"))
)
return false;
}
const descriptorsFit = (
rows: readonly { location: { offset: number; bytes: number } }[],
) => rows.every((row) => inside(row.location));
return descriptorsFit(report.imports) && descriptorsFit(report.delay_imports);
};