mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 18:22:42 +00:00
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:
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);
|
||||
};
|
||||
Reference in new issue
Block a user