feat(pe): inspect Authenticode certificate tables

Add inspect_pe_certificates for IMAGE_DIRECTORY_ENTRY_SECURITY
WIN_CERTIFICATE entries and structurally decoded PKCS#7 signer
and X.509 identity fields. The workflow reads a stable PE image
without executing it or verifying signatures, chains, timestamps,
or Authenticode digest coverage.
This commit is contained in:
Matt Van Horn authored and Cursor Agent committed 2026-10-21 23:23:10 +00:00
1 parent d7ca93cc2c
commit 47789cdaa8
33 files changed
+1841 -5

No files matched your search

@@ -219,6 +219,13 @@ explicit path or endpoint and do not need it.
delivery requires `--evidence_output`.
See the
[Rust binary guide](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
- PE Authenticode certificate table: `inspect_pe_certificates` with the explicit
local PE32/PE32+ file. This built-in target-free inspection requires no
Windows SDK. Preserve WIN_CERTIFICATE ranges, types, payload SHA-256, and
PKCS#7 signer/X.509 identity fields. A missing security directory is observed
absence of that table, not proof the image is unsigned in every catalog sense.
Signatures are not verified. See the
[PE certificate guide](https://github.com/morluto/rea/blob/main/docs/pe-certificates.md).
- Explicit local SQLite database snapshot: `inspect_sqlite_database`. Inspect
with Node.js 24.x >=24.15 or 26+ providing native SQLite limits. Read schema
@@ -94,6 +94,19 @@ dependencies. An omitted package kind stays null. A rustc commit hash is
inferred from a read-only `/rustc/<40-hex>/` path and does not by itself
identify the compiler. See [Rust binaries](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
## PE Authenticode certificate tables
Use `inspect_pe_certificates` for an explicit local PE32/PE32+ image when the
question concerns the Authenticode certificate table, WIN_CERTIFICATE types, or
embedded PKCS#7 signer and X.509 identity fields. No `open_binary`, Windows SDK,
or target execution is required. The CLI equivalent is
`inspect-pe-certificates PATH`. Preserve artifact SHA-256, table file ranges,
payload hashes, and original validity-time spellings. A missing
`IMAGE_DIRECTORY_ENTRY_SECURITY` directory is observed absence of that table,
not proof the image is unsigned in every catalog or overlay sense. Signatures,
chains, timestamps, and Authenticode digest coverage remain unobserved. See
[PE Authenticode certificate tables](https://github.com/morluto/rea/blob/main/docs/pe-certificates.md).
## SQLite snapshots
Use `inspect_sqlite_database` with an explicit local database snapshot for its
+1
View File
@@ -191,6 +191,7 @@ Additional tools and host support depend on the target:
| IL2CPP metadata | Unity global-metadata.dat assembly images, type and method names and index ranges | Built-in static decoding of versions 24, 27, 29, 31 and 39; no Unity required |
| Godot PCK | Archive inventory and extraction, original header/directory facts and selected content identities | Built-in static formats 2, 3 and 4; no Godot required |
| Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) |
| PE certificates | Authenticode WIN_CERTIFICATE entries and PKCS#7 signer/X.509 identity fields | Built-in static PE inspection; [PE certificates](docs/pe-certificates.md) |
| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` |
| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) |
| SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits |
+1
View File
@@ -32,6 +32,7 @@ export default defineConfig({
{ text: "IL2CPP metadata", link: "/il2cpp-metadata" },
{ text: "Model artifacts", link: "/model-artifacts" },
{ text: "Rust dependencies", link: "/rust-binaries" },
{ text: "PE certificates", link: "/pe-certificates" },
{
text: "JavaScript and Electron",
link: "/javascript-artifact-reconstruction",
+12
View File
@@ -145,6 +145,18 @@ dependency edges, and the section's file range. A missing `.dep-v0` section is
null metadata, not proof that the binary has no dependencies. See
[Rust binaries](rust-binaries.md).
Inspect a PE Authenticode certificate table without executing the image or
verifying signatures:
```bash
rea inspect-pe-certificates /absolute/path/to/application.exe --json
```
The workflow reports `WIN_CERTIFICATE` entries and, for PKCS#7 signedData,
embedded X.509 serial, issuer, subject, and validity fields. A missing security
directory is observed absence of that table. See
[PE Authenticode certificate tables](pe-certificates.md).
Configure [Hopper or Ghidra](installation.md#hopper), the
[IDA adapter](ida-provider.md), or [Kuna](kuna-provider.md) before analyzing a
native target. Substitute
+1 -1
View File
@@ -50,7 +50,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
| Task | Guides |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) |
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [PE certificates](pe-certificates.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) |
| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md) |
+45
View File
@@ -0,0 +1,45 @@
# PE Authenticode certificate tables
`inspect_pe_certificates` / `inspect-pe-certificates` answers whether one
selected local PE32/PE32+ image declares an `IMAGE_DIRECTORY_ENTRY_SECURITY`
table, which `WIN_CERTIFICATE` entries that table stores, and which PKCS#7
signer and X.509 fields those entries carry. It reads the file without
executing it and does not require a Windows SDK, catalog file, or disassembler.
```bash
rea inspect-pe-certificates /absolute/path/to/application.exe --json
```
The CLI resolves relative paths against the caller's working directory. MCP
requires an absolute filesystem path and preserves that exact selection:
```json
{
"name": "inspect_pe_certificates",
"arguments": { "path": "/artifacts/application.exe" }
}
```
## Observations
The inline Evidence result contains the artifact path, SHA-256, and byte length.
`table` is the file-offset security directory, or `null` when the directory is
absent or zero. Each `entries[]` item reports `WIN_CERTIFICATE` length,
revision, type, and the payload SHA-256.
For `WIN_CERTIFICATE` type 2 (PKCS#7 signedData) `pkcs7` contains digest
algorithm OIDs, embedded X.509 serial/issuer/subject/validity fields, and
signer identifier facts. Other certificate types keep `pkcs7` null.
A missing table is observed absence of that directory. It is not proof the
image is unsigned in every catalog or overlay sense.
## Limits and failures
The selected file is limited to 64 MiB by default (512 MiB maximum). At most
eight `WIN_CERTIFICATE` entries are examined by default. Malformed PE headers,
truncated certificate entries, and PKCS#7 that is not structurally signedData
fail without partial success.
This workflow does not verify signatures, certificate chains, timestamps, or
Authenticode digest coverage of image bytes.
+10 -1
View File
@@ -238,7 +238,9 @@ export const providerCatalog = (sources) => {
identity: sources.pwntoolsProvider.PWNTOOLS_PROVIDER_IDENTITY,
contracts:
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter(
({ name }) => name !== "inspect_pe_resources",
({ name }) =>
name !== "inspect_pe_resources" &&
name !== "inspect_pe_certificates",
),
},
{
@@ -248,6 +250,13 @@ export const providerCatalog = (sources) => {
({ name }) => name === "inspect_pe_resources",
),
},
{
identity: sources.artifactProviders.PE_CERTIFICATES_PROVIDER,
contracts:
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS.filter(
({ name }) => name === "inspect_pe_certificates",
),
},
{
identity: sources.captureProvider.TSHARK_PROVIDER_IDENTITY,
contracts: sources.captureContracts.CAPTURE_TOOL_CONTRACTS,
+2
View File
@@ -499,6 +499,8 @@ const workflowAvailabilityFor = ({
};
if (name === "inspect_pe_resources")
return { reason: "available", remediation: null };
if (name === "inspect_pe_certificates")
return { reason: "available", remediation: null };
if (name === "inspect_evm_interface") {
if (policy.evmInterfaceEnabled !== true)
return {
@@ -49,6 +49,13 @@ export const PE_RESOURCES_PROVIDER = Object.freeze({
version: "1",
} as const);
/** Provider identity for portable, execution-free PE Authenticode table decoding. */
export const PE_CERTIFICATES_PROVIDER = Object.freeze({
id: "rea-pe-certificates",
name: "REA PE certificate parser",
version: "1",
} as const);
/** Provider identity for deterministic managed-code comparison workflows. */
export const MANAGED_WORKFLOW_PROVIDER = {
id: "rea-dotnet-workflows",
@@ -0,0 +1,20 @@
import type {
ExecutionOptions,
ProviderIdentity,
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type {
InspectPeCertificatesInput,
PeCertificates,
} from "../../domain/native/peCertificates.js";
import type { Result } from "../../domain/result.js";
/** Stable read and PE certificate-table decoding, independent of a disassembler session. */
export interface PeCertificatesPort {
readonly identity: ProviderIdentity;
close?(): Promise<void>;
inspect(
input: InspectPeCertificatesInput,
options?: ExecutionOptions,
): Promise<Result<PeCertificates, AnalysisError>>;
}
@@ -0,0 +1,76 @@
import { expect, it } from "vitest";
import { PeCertificatesService } from "./PeCertificatesService.js";
import { PE_CERTIFICATES_PROVIDER } from "../InvestigationProviders.js";
import type { PeCertificates } from "../../domain/native/peCertificates.js";
import { ok } from "../../domain/result.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
const identity = PE_CERTIFICATES_PROVIDER;
const report: PeCertificates = {
artifact: {
path: "/artifacts/app.exe",
sha256: "a".repeat(64),
bytes: 512,
},
format: "pe32",
machine: 0x14c,
table: null,
entries: [],
coverage: { status: "complete", examined_entries: 0, certificates: 0 },
limitations: ["IMAGE_DIRECTORY_ENTRY_SECURITY is absent."],
};
it("rejects a relative path and extra arguments before reading", async () => {
let calls = 0;
const service = new PeCertificatesService({
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 certificate table", async () => {
const result = await new PeCertificatesService({
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" },
normalized_result: report,
});
});
it("discards an observation if the caller cancels while the provider completes", async () => {
const controller = new AbortController();
const service = new PeCertificatesService({
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);
});
@@ -0,0 +1,146 @@
import { isAbsolute } from "node:path";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { PeCertificatesPort } from "./PeCertificatesPort.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 {
inspectPeCertificatesInputSchema,
peCertificatesSchema,
type PeCertificates,
} from "../../domain/native/peCertificates.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import {
decodePeMachine,
peOptionalHeaderMagic,
type PeArchitecture,
} from "../../domain/peMachine.js";
import { err, ok, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
const OPERATION = "inspect_pe_certificates";
/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */
export class PeCertificatesService {
constructor(readonly provider: PeCertificatesPort) {}
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));
const input = inspectPeCertificatesInputSchema.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 = peCertificatesSchema.safeParse(result.value);
if (
!report.success ||
report.data.artifact.path !== input.data.path ||
!validReport(
report.data,
input.data.max_entries,
input.data.max_file_bytes,
)
)
return err(
new AnalysisOutputError(
OPERATION,
"PE certificate 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 },
...(value.table === null
? []
: [
{
kind: "file-offset-range" as const,
start: value.table.location.offset,
end: value.table.location.offset + value.table.location.bytes,
},
]),
];
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: PeCertificates,
): 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 validReport = (
report: PeCertificates,
maxEntries: number,
maxBytes: number,
): boolean => {
const inside = ({ offset, bytes }: { offset: number; bytes: number }) =>
offset <= report.artifact.bytes - bytes;
if (
report.artifact.bytes > maxBytes ||
report.coverage.examined_entries > maxEntries ||
report.coverage.certificates !==
report.entries.reduce(
(count, entry) => count + (entry.pkcs7?.certificates.length ?? 0),
0,
) ||
report.entries.length > maxEntries
)
return false;
if (report.table !== null && !inside(report.table.location)) return false;
return report.entries.every(
(entry) => inside(entry.location) && inside(entry.payload.location),
);
};
+34
View File
@@ -3,6 +3,7 @@ import {
createRecordedCrashService,
createBinaryLayoutService,
createPeResourcesService,
createPeCertificatesService,
} from "../composition/binaryDiagnostics.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
@@ -12,6 +13,7 @@ import { withCommandCancellation } from "./commandCancellation.js";
import { inspectBinaryLayoutInputSchema } from "../domain/native/binaryLayout.js";
import { inspectRecordedCrashInputSchema } from "../domain/native/recordedCrash.js";
import { inspectPeResourcesInputSchema } from "../domain/native/peResources.js";
import { inspectPeCertificatesInputSchema } from "../domain/native/peCertificates.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
@@ -22,6 +24,38 @@ export const registerBinaryDiagnosticsCommands = (
logger: Logger,
environment: Readonly<NodeJS.ProcessEnv>,
): void => {
cli.command(CLI_COMMANDS.inspectPeCertificates, {
description:
"Inspect PE Authenticode certificate table identities without verifying signatures",
args: z.object({ path: z.string().describe("Local PE32/PE32+ image") }),
options: z.object({
maxFileBytes: inspectPeCertificatesInputSchema.shape.max_file_bytes,
maxEntries: inspectPeCertificatesInputSchema.shape.max_entries,
}),
run: ({ args, options }) =>
withCommandCancellation(async (signal) => {
const pe = createPeCertificatesService();
return await logCliCommand(
logger,
CLI_COMMANDS.inspectPeCertificates,
async () =>
finishAnalysisCommand(
() =>
pe.inspect(
{
path: absoluteCallerPath(args.path),
max_file_bytes: options.maxFileBytes,
max_entries: options.maxEntries,
},
{ signal },
),
pe,
"rea-pe-certificates",
"inspect_pe_certificates",
),
);
}),
});
cli.command(CLI_COMMANDS.inspectPeResources, {
description:
"Inspect PE resource identities, payload ranges, hashes and icon-group relationships",
+1
View File
@@ -65,6 +65,7 @@ export const CLI_COMMANDS = Object.freeze({
inspectGpuCodeObjects: "inspect-gpu-code-objects",
inspectRustBinary: "inspect-rust-binary",
inspectPeResources: "inspect-pe-resources",
inspectPeCertificates: "inspect-pe-certificates",
inspectAnalysisView: "inspect-analysis-view",
listFridaDevices: "list-frida-devices",
listFridaProcesses: "list-frida-processes",
+6
View File
@@ -4,11 +4,17 @@ import { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayo
import { PwntoolsLayoutProvider } from "../native/pwntools/PwntoolsLayoutProvider.js";
import { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js";
import { PeResourcesProvider } from "../native/pe/PeResourcesProvider.js";
import { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.js";
import { PeCertificatesProvider } from "../native/pe/PeCertificatesProvider.js";
/** Portable PE inspection with no engine, installation or target acquisition. */
export const createPeResourcesService = (): PeResourcesService =>
new PeResourcesService(new PeResourcesProvider());
/** Portable Authenticode table inspection with no engine or catalog verification. */
export const createPeCertificatesService = (): PeCertificatesService =>
new PeCertificatesService(new PeCertificatesProvider());
/** Compose the offline adapter without import-time I/O or dependency acquisition. */
export const createBinaryLayoutService = (
environment: Readonly<NodeJS.ProcessEnv> = process.env,
@@ -18,6 +18,10 @@ import {
inspectPeResourcesInputSchema,
peResourcesSchema,
} from "../../domain/native/peResources.js";
import {
inspectPeCertificatesInputSchema,
peCertificatesSchema,
} from "../../domain/native/peCertificates.js";
/** Caller-selected delivery of completed binary layout Evidence. */
export const inspectBinaryLayoutRequestSchema =
@@ -25,6 +29,21 @@ export const inspectBinaryLayoutRequestSchema =
/** Offline binary diagnostics are independent of macOS process/UI providers. */
export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
{
name: "inspect_pe_certificates",
...toolContractMetadata("inspect_pe_certificates"),
kind: "native-provider",
description:
"Inspect one explicit local PE32/PE32+ image's IMAGE_DIRECTORY_ENTRY_SECURITY table without executing it, verifying signatures or opening a disassembler. Returns WIN_CERTIFICATE file ranges, types and payload SHA-256, plus structurally decoded PKCS#7 signedData digest algorithms, embedded X.509 serial/issuer/subject/validity fields and signer identifiers when type 2 is present. A missing directory is observed absence of that table, not proof the image is unsigned in every catalog or overlay sense. Authenticode digest coverage, chain validity and timestamp trust remain unobserved. Reads a stable regular file; malformed PE headers, truncated WIN_CERTIFICATE entries and non-signedData PKCS#7 return no partial success. Portable on supported Node hosts; no Windows SDK or engine installation required.",
inputSchema: inspectPeCertificatesInputSchema,
outputSchema: evidenceResultOf(peCertificatesSchema),
examples: [
{
title: "Inspect PE Authenticode certificate identities",
input: { path: "/artifacts/application.exe" },
},
],
},
{
name: "inspect_pe_resources",
...toolContractMetadata("inspect_pe_resources"),
+1
View File
@@ -28,6 +28,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
inspect_pe_resources: "states that it does not execute the image",
inspect_desktop_application:
"states that directory triage performs no target execution",
inspect_pe_certificates: "states that it does not execute the image",
inspect_go_binary: "reads embedded metadata without executing the target",
inspect_rust_binary:
"reads an embedded dependency list without executing the target",
+1
View File
@@ -252,6 +252,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
idempotent: false,
}),
inspect_pe_resources: evidence,
inspect_pe_certificates: evidence,
inspect_analysis_view: evidence,
inspect_firmware_regions: effects({
mutatesSession: true,
+1
View File
@@ -25,6 +25,7 @@ export class ArtifactOperationError extends AnalysisError {
| "inspect_artifact"
| "inspect_desktop_application"
| "inspect_pe_resources"
| "inspect_pe_certificates"
| "extract_artifact"
| "decode_interface_builder"
| "inspect_asset_catalog"
+38
View File
@@ -0,0 +1,38 @@
import { expect, it } from "vitest";
import {
inspectPeCertificatesInputSchema,
peCertificateTypeName,
peCertificatesSchema,
} from "./peCertificates.js";
it("maps WIN_CERTIFICATE type numbers without inventing unknown names", () => {
expect(peCertificateTypeName(1)).toBe("x509");
expect(peCertificateTypeName(2)).toBe("pkcs7");
expect(peCertificateTypeName(3)).toBe("reserved");
expect(peCertificateTypeName(4)).toBe("ts_stack_signed");
expect(peCertificateTypeName(99)).toBe("unknown");
});
it("rejects extra caller arguments and admits an absent certificate table", () => {
expect(
inspectPeCertificatesInputSchema.safeParse({
path: "/artifacts/app.exe",
extra: true,
}).success,
).toBe(false);
const report = peCertificatesSchema.parse({
artifact: {
path: "/artifacts/app.exe",
sha256: "a".repeat(64),
bytes: 512,
},
format: "pe32",
machine: 0x14c,
table: null,
entries: [],
coverage: { status: "complete", examined_entries: 0, certificates: 0 },
limitations: ["IMAGE_DIRECTORY_ENTRY_SECURITY is absent."],
});
expect(report.table).toBeNull();
expect(report.entries).toEqual([]);
});
+148
View File
@@ -0,0 +1,148 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
import { localPathStringSchema } from "../localPath.js";
const uint = z.number().int().min(0).max(0xffffffff);
const range = z.strictObject({ offset: uint, bytes: uint });
/** Hard ceiling on the caller-selected input read budget. */
export const MAX_INSPECT_PE_CERTIFICATE_FILE_BYTES = 536870912;
/** Hard ceiling on concatenated WIN_CERTIFICATE entries. */
export const MAX_INSPECT_PE_CERTIFICATE_ENTRIES = 64;
export const PE_CERTIFICATE_TYPES = [
"x509",
"pkcs7",
"reserved",
"ts_stack_signed",
"unknown",
] as const;
export type PeCertificateType = (typeof PE_CERTIFICATE_TYPES)[number];
/** Map WIN_CERTIFICATE wCertificateType to a named kind; unknown stays unknown. */
export const peCertificateTypeName = (type: number): PeCertificateType => {
switch (type) {
case 0x0001:
return "x509";
case 0x0002:
return "pkcs7";
case 0x0003:
return "reserved";
case 0x0004:
return "ts_stack_signed";
default:
return "unknown";
}
};
/** Complete bounded inspection of one PE Authenticode certificate table. */
export const inspectPeCertificatesInputSchema = z.strictObject({
path: localPathStringSchema.describe(
"Absolute filesystem path to a PE image",
),
max_file_bytes: z
.number()
.int()
.min(64)
.max(MAX_INSPECT_PE_CERTIFICATE_FILE_BYTES)
.default(67108864)
.describe("Maximum stable artifact bytes to read (default 64 MiB)"),
max_entries: z
.number()
.int()
.min(1)
.max(MAX_INSPECT_PE_CERTIFICATE_ENTRIES)
.default(8)
.describe(
"Maximum WIN_CERTIFICATE entries to examine; exceeding the budget fails without partial success",
),
});
const distinguishedNameSchema = z.strictObject({
text: z.string(),
attributes: z.array(
z.strictObject({
oid: z.string(),
oid_name: z.string().nullable(),
value: z.string(),
}),
),
});
const x509CertificateSchema = z.strictObject({
location: range,
der_sha256: digestSchema,
serial_hex: z.string().regex(/^[0-9a-f]*$/),
issuer: distinguishedNameSchema,
subject: distinguishedNameSchema,
not_before: z.string(),
not_after: z.string(),
signature_algorithm_oid: z.string(),
signature_algorithm_name: z.string().nullable(),
});
const signerInfoSchema = z.strictObject({
digest_algorithm_oid: z.string(),
digest_algorithm_name: z.string().nullable(),
signature_algorithm_oid: z.string(),
signature_algorithm_name: z.string().nullable(),
issuer: distinguishedNameSchema.nullable(),
serial_hex: z
.string()
.regex(/^[0-9a-f]*$/)
.nullable(),
authenticated_attributes: z.boolean(),
});
const pkcs7Schema = z.strictObject({
content_type_oid: z.string(),
content_type_name: z.string().nullable(),
digest_algorithm_oids: z.array(z.string()),
certificates: z.array(x509CertificateSchema),
signers: z.array(signerInfoSchema),
});
const certificateEntrySchema = z.strictObject({
index: uint,
location: range,
length: uint,
revision: uint,
certificate_type: uint,
certificate_type_name: z.enum(PE_CERTIFICATE_TYPES),
payload: z.strictObject({
location: range,
bytes: uint,
sha256: digestSchema,
}),
pkcs7: pkcs7Schema.nullable(),
});
/** Observed IMAGE_DIRECTORY_ENTRY_SECURITY table; absence is null, not unsigned-everywhere. */
export const peCertificatesSchema = z.strictObject({
artifact: z.strictObject({
path: z.string().min(1),
sha256: digestSchema,
bytes: uint,
}),
format: z.enum(["pe32", "pe32-plus"]),
machine: uint,
table: z
.strictObject({
location: range,
data_directory_location: range,
})
.nullable(),
entries: z.array(certificateEntrySchema),
coverage: z.strictObject({
status: z.literal("complete"),
examined_entries: uint,
certificates: uint,
}),
limitations: z.array(z.string()),
});
export type PeCertificates = z.infer<typeof peCertificatesSchema>;
export type InspectPeCertificatesInput = z.infer<
typeof inspectPeCertificatesInputSchema
>;
+345
View File
@@ -0,0 +1,345 @@
const OID_NAMES: Readonly<Record<string, string>> = {
"1.2.840.113549.1.7.1": "data",
"1.2.840.113549.1.7.2": "signedData",
"1.2.840.113549.1.1.1": "rsaEncryption",
"1.2.840.113549.1.1.5": "sha1WithRSAEncryption",
"1.2.840.113549.1.1.11": "sha256WithRSAEncryption",
"1.2.840.113549.1.1.12": "sha384WithRSAEncryption",
"1.2.840.113549.1.1.13": "sha512WithRSAEncryption",
"1.2.840.113549.1.9.3": "contentType",
"1.2.840.113549.1.9.4": "messageDigest",
"1.2.840.113549.1.9.5": "signingTime",
"1.3.14.3.2.26": "sha1",
"2.5.4.3": "commonName",
"2.5.4.6": "countryName",
"2.5.4.10": "organizationName",
"2.5.4.11": "organizationalUnitName",
"2.16.840.1.101.3.4.2.1": "sha256",
"2.16.840.1.101.3.4.2.2": "sha384",
"2.16.840.1.101.3.4.2.3": "sha512",
};
export interface DerNode {
readonly tag: number;
readonly constructed: boolean;
readonly start: number;
readonly end: number;
readonly content: Buffer;
readonly children: readonly DerNode[];
}
export class PeCertificateDerFailure extends Error {
constructor(message: string) {
super(message);
this.name = "PeCertificateDerFailure";
}
}
const fail = (message: string): never => {
throw new PeCertificateDerFailure(message);
};
const required = <T>(value: T | undefined, message: string): T =>
value === undefined ? fail(message) : value;
/** Decode one DER/BER TLV; constructed nodes own their child sequence. */
export const readDer = (
bytes: Buffer,
offset = 0,
end = bytes.length,
depth = 0,
): DerNode => {
if (depth > 32) return fail("DER nesting exceeds 32 levels.");
if (offset >= end || offset + 2 > end)
return fail("Truncated DER tag or length.");
const tag = required(bytes[offset], "Truncated DER tag.");
const constructed = (tag & 0x20) !== 0;
let cursor = offset + 1;
const lengthByte = required(bytes[cursor], "Truncated DER length.");
cursor += 1;
let length: number;
if ((lengthByte & 0x80) === 0) length = lengthByte;
else {
const width = lengthByte & 0x7f;
if (width === 0 || width > 4) return fail("Unsupported DER length form.");
if (cursor + width > end) return fail("Truncated DER long length.");
length = 0;
for (let index = 0; index < width; index += 1) {
length = (length << 8) | required(bytes[cursor], "Truncated DER length.");
cursor += 1;
}
}
const contentEnd = cursor + length;
if (length < 0 || contentEnd > end)
return fail("DER value exceeds its parent.");
const content = bytes.subarray(cursor, contentEnd);
const children: DerNode[] = [];
if (constructed) {
let at = cursor;
while (at < contentEnd) {
const child = readDer(bytes, at, contentEnd, depth + 1);
children.push(child);
at = child.end;
}
}
return {
tag,
constructed,
start: offset,
end: contentEnd,
content,
children,
};
};
export const oidName = (oid: string): string | null => OID_NAMES[oid] ?? null;
/** Decode an OBJECT IDENTIFIER content buffer as dotted decimal. */
export const decodeOid = (content: Buffer): string => {
if (content.length === 0) return fail("Empty OBJECT IDENTIFIER.");
const first = required(content[0], "Empty OBJECT IDENTIFIER.");
const parts = [Math.floor(first / 40), first % 40];
let value = 0;
for (let index = 1; index < content.length; index += 1) {
const byte = required(content[index], "Truncated OBJECT IDENTIFIER.");
value = (value << 7) | (byte & 0x7f);
if ((byte & 0x80) === 0) {
parts.push(value);
value = 0;
} else if (index === content.length - 1)
return fail("Truncated OBJECT IDENTIFIER component.");
}
return parts.join(".");
};
const decodeUtf16Be = (bytes: Buffer): string => {
if (bytes.length % 2 !== 0) return fail("BMPString length is odd.");
let text = "";
for (let index = 0; index < bytes.length; index += 2)
text += String.fromCharCode(bytes.readUInt16BE(index));
return text;
};
const decodePrintable = (node: DerNode): string => {
switch (node.tag) {
case 0x0c:
case 0x13:
case 0x16:
case 0x17:
case 0x18:
case 0x1b:
return node.content.toString("utf8");
case 0x1e:
return decodeUtf16Be(node.content);
default:
return node.content.toString("latin1");
}
};
const integerHex = (content: Buffer): string => {
let start = 0;
while (start < content.length - 1 && content[start] === 0) start += 1;
return content.subarray(start).toString("hex");
};
const sequence = (node: DerNode, label: string): readonly DerNode[] => {
if (node.tag !== 0x30) return fail(`Expected SEQUENCE for ${label}.`);
return node.children;
};
const algorithmOid = (node: DerNode): string => {
const children = sequence(node, "AlgorithmIdentifier");
const oid = children[0];
if (oid === undefined || oid.tag !== 0x06)
return fail("AlgorithmIdentifier is missing an OBJECT IDENTIFIER.");
return decodeOid(oid.content);
};
interface Attribute {
readonly oid: string;
readonly oid_name: string | null;
readonly value: string;
}
const decodeName = (
node: DerNode,
): {
readonly text: string;
readonly attributes: readonly Attribute[];
} => {
const attributes: Attribute[] = [];
for (const rdn of sequence(node, "Name")) {
if (rdn.tag !== 0x31) return fail("Name RDN is not a SET.");
for (const attribute of rdn.children) {
const parts = sequence(attribute, "AttributeTypeAndValue");
const oidNode = parts[0];
const valueNode = parts[1];
if (
oidNode === undefined ||
oidNode.tag !== 0x06 ||
valueNode === undefined
)
return fail("AttributeTypeAndValue is truncated.");
const oid = decodeOid(oidNode.content);
attributes.push({
oid,
oid_name: oidName(oid),
value: decodePrintable(valueNode),
});
}
}
const text = attributes
.map((attribute) => {
const key = attribute.oid_name ?? attribute.oid;
return `${key}=${attribute.value}`;
})
.join(", ");
return { text, attributes };
};
const validityTime = (node: DerNode): string => {
if (node.tag !== 0x17 && node.tag !== 0x18)
return fail("Validity instant is not UTCTime or GeneralizedTime.");
return decodePrintable(node);
};
export interface ParsedCertificate {
readonly start: number;
readonly end: number;
readonly serial_hex: string;
readonly issuer: ReturnType<typeof decodeName>;
readonly subject: ReturnType<typeof decodeName>;
readonly not_before: string;
readonly not_after: string;
readonly signature_algorithm_oid: string;
}
/** Decode one X.509 Certificate SEQUENCE at a known file-relative DER node. */
export const parseCertificate = (node: DerNode): ParsedCertificate => {
const children = sequence(node, "Certificate");
const tbs = children[0];
const signatureAlgorithm = children[1];
if (tbs === undefined || signatureAlgorithm === undefined)
return fail("Certificate is missing TBSCertificate or signatureAlgorithm.");
const tbsChildren = sequence(tbs, "TBSCertificate");
let index = 0;
if (tbsChildren[0]?.tag === 0xa0) index = 1;
const serial = tbsChildren[index];
const issuer = tbsChildren[index + 2];
const validity = tbsChildren[index + 3];
const subject = tbsChildren[index + 4];
if (
serial === undefined ||
serial.tag !== 0x02 ||
issuer === undefined ||
validity === undefined ||
subject === undefined
)
return fail(
"TBSCertificate is missing serial, issuer, validity, or subject.",
);
const validityChildren = sequence(validity, "Validity");
const notBefore = validityChildren[0];
const notAfter = validityChildren[1];
if (notBefore === undefined || notAfter === undefined)
return fail("Validity is missing notBefore or notAfter.");
return {
start: node.start,
end: node.end,
serial_hex: integerHex(serial.content),
issuer: decodeName(issuer),
subject: decodeName(subject),
not_before: validityTime(notBefore),
not_after: validityTime(notAfter),
signature_algorithm_oid: algorithmOid(signatureAlgorithm),
};
};
export interface ParsedSigner {
readonly digest_algorithm_oid: string;
readonly signature_algorithm_oid: string;
readonly issuer: ReturnType<typeof decodeName> | null;
readonly serial_hex: string | null;
readonly authenticated_attributes: boolean;
}
const parseSigner = (node: DerNode): ParsedSigner => {
const children = sequence(node, "SignerInfo");
const version = children[0];
const sid = children[1];
const digestAlg = children[2];
if (version === undefined || version.tag !== 0x02)
return fail("SignerInfo is missing version.");
if (sid === undefined || digestAlg === undefined)
return fail("SignerInfo is missing sid or digestAlgorithm.");
let issuer = null;
let serial_hex: string | null = null;
if (sid.tag === 0x30) {
const name = sid.children[0];
const serial = sid.children[1];
if (name !== undefined && serial !== undefined && serial.tag === 0x02) {
issuer = decodeName(name);
serial_hex = integerHex(serial.content);
}
}
let cursor = 3;
const authenticated = children[cursor]?.tag === 0xa0;
if (authenticated) cursor += 1;
const signatureAlg = children[cursor];
if (signatureAlg === undefined)
return fail("SignerInfo is missing signatureAlgorithm.");
return {
digest_algorithm_oid: algorithmOid(digestAlg),
signature_algorithm_oid: algorithmOid(signatureAlg),
issuer,
serial_hex,
authenticated_attributes: authenticated,
};
};
export interface ParsedSignedData {
readonly content_type_oid: string;
readonly digest_algorithm_oids: readonly string[];
readonly certificates: readonly ParsedCertificate[];
readonly signers: readonly ParsedSigner[];
}
const parseSignedData = (node: DerNode): ParsedSignedData => {
const children = sequence(node, "SignedData");
const digestSet = children[1];
if (digestSet === undefined || digestSet.tag !== 0x31)
return fail("SignedData is missing digestAlgorithms.");
const digest_algorithm_oids = digestSet.children.map(algorithmOid);
const certificates: ParsedCertificate[] = [];
const signers: ParsedSigner[] = [];
for (const child of children.slice(3)) {
if (child.tag === 0xa0)
for (const certificate of child.children)
certificates.push(parseCertificate(certificate));
else if (child.tag === 0x31)
for (const signer of child.children) signers.push(parseSigner(signer));
}
return {
content_type_oid: "1.2.840.113549.1.7.2",
digest_algorithm_oids,
certificates,
signers,
};
};
/** Decode ContentInfo wrapping SignedData; other content types remain unsupported. */
export const parsePkcs7SignedData = (bytes: Buffer): ParsedSignedData => {
const root = readDer(bytes);
const children = sequence(root, "ContentInfo");
const type = children[0];
const content = children[1];
if (type === undefined || type.tag !== 0x06)
return fail("ContentInfo is missing contentType.");
const oid = decodeOid(type.content);
if (oid !== "1.2.840.113549.1.7.2")
return fail(`PKCS#7 contentType ${oid} is not signedData.`);
const signedData = content?.children[0];
if (content === undefined || content.tag !== 0xa0 || signedData === undefined)
return fail("signedData content is missing.");
return parseSignedData(signedData);
};
+87
View File
@@ -0,0 +1,87 @@
import { createHash } from "node:crypto";
import { expect, it } from "vitest";
import { inspectPeCertificatesInputSchema } from "../../domain/native/peCertificates.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
peCertificateFixture,
peWithoutCertificateTableFixture,
pkcs7SignedDataFixture,
} from "./PeCertificates.fixture.js";
import { parsePeCertificates } from "./PeCertificateParser.js";
const defaults = inspectPeCertificatesInputSchema.parse({
path: "/artifacts/app.exe",
});
const parse = (bytes: Buffer) =>
parsePeCertificates(
bytes,
{
path: defaults.path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
},
defaults,
);
it("reads PE32 and PE32+ PKCS#7 certificate tables", () => {
for (const plus of [false, true]) {
const bytes = peCertificateFixture(plus);
const report = parse(bytes);
expect(report.format).toBe(plus ? "pe32-plus" : "pe32");
expect(report.table).not.toBeNull();
expect(report.entries).toHaveLength(1);
expect(report.entries[0]).toMatchObject({
revision: 0x0200,
certificate_type: 2,
certificate_type_name: "pkcs7",
});
expect(report.entries[0]?.pkcs7).toMatchObject({
content_type_oid: "1.2.840.113549.1.7.2",
content_type_name: "signedData",
digest_algorithm_oids: ["2.16.840.1.101.3.4.2.1"],
});
const certificate = report.entries[0]?.pkcs7?.certificates[0];
expect(certificate?.serial_hex).toBe("11223344");
expect(certificate?.subject.text).toContain("commonName=REA Fixture");
expect(certificate?.not_before).toBe("250101000000Z");
expect(report.coverage).toEqual({
status: "complete",
examined_entries: 1,
certificates: 1,
});
}
});
it("reports an absent security directory without claiming the image is unsigned", () => {
const report = parse(peWithoutCertificateTableFixture());
expect(report.table).toBeNull();
expect(report.entries).toEqual([]);
expect(report.limitations[0]).toMatch(
/IMAGE_DIRECTORY_ENTRY_SECURITY is absent/u,
);
});
it("fails truncated WIN_CERTIFICATE payloads without partial success", () => {
const bytes = peCertificateFixture();
const tableOffset = bytes.readUInt32LE(0x80 + 24 + 96 + 32);
bytes.writeUInt32LE(4, tableOffset);
expect(() => parse(bytes)).toThrow(ArtifactReaderFailure);
});
it("fails PKCS#7 payloads that are not signedData", () => {
const bytes = peCertificateFixture(false, Buffer.from("not-pkcs7"), 0x0002);
expect(() => parse(bytes)).toThrow(ArtifactReaderFailure);
});
it("keeps unknown certificate types as payload hashes without PKCS#7 projection", () => {
const payload = pkcs7SignedDataFixture();
const report = parse(peCertificateFixture(false, payload, 0x0001));
expect(report.entries[0]).toMatchObject({
certificate_type_name: "x509",
pkcs7: null,
});
expect(report.entries[0]?.payload.sha256).toBe(
createHash("sha256").update(payload).digest("hex"),
);
});
+221
View File
@@ -0,0 +1,221 @@
import { createHash } from "node:crypto";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import type {
InspectPeCertificatesInput,
PeCertificates,
} from "../../domain/native/peCertificates.js";
import { peCertificateTypeName } from "../../domain/native/peCertificates.js";
import {
oidName,
parsePkcs7SignedData,
PeCertificateDerFailure,
} from "./PeCertificateDer.js";
const fail = (message: string): never => {
throw new ArtifactReaderFailure("format", message);
};
const requireRange = (bytes: Buffer, offset: number, length: number): void => {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 0 ||
offset > bytes.length - length
)
fail(
`PE certificate range ${String(offset)} + ${String(length)} leaves the artifact.`,
);
};
const align8 = (value: number): number => Math.ceil(value / 8) * 8;
const sha256 = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
const LIMITATIONS = [
"This workflow does not verify signatures, certificate chains, timestamps, or Authenticode digest coverage of image bytes.",
"PKCS#7 fields are decoded structurally; cryptographic validity remains unobserved.",
] as const;
const ABSENT_LIMITATIONS = [
"IMAGE_DIRECTORY_ENTRY_SECURITY is absent. That is not proof the image is unsigned in every catalog or overlay sense.",
"This workflow does not verify signatures, certificate chains, timestamps, or Authenticode digest coverage of image bytes.",
] as const;
/** Decode IMAGE_DIRECTORY_ENTRY_SECURITY as a file-offset WIN_CERTIFICATE table. */
export const parsePeCertificates = (
bytes: Buffer,
artifact: PeCertificates["artifact"],
input: InspectPeCertificatesInput,
): PeCertificates => {
requireRange(bytes, 0, 64);
if (bytes.readUInt16LE(0) !== 0x5a4d) fail("Expected an MZ PE image.");
const pe = bytes.readUInt32LE(60);
requireRange(bytes, pe, 24);
if (bytes.readUInt32LE(pe) !== 0x4550) fail("Expected a PE signature.");
const machine = bytes.readUInt16LE(pe + 4);
const optionalSize = bytes.readUInt16LE(pe + 20);
const optional = pe + 24;
requireRange(bytes, optional, optionalSize);
if (optionalSize < 64) fail("Truncated PE optional header.");
const magic = bytes.readUInt16LE(optional);
if (magic !== 0x10b && magic !== 0x20b)
fail("Unsupported PE optional header magic.");
const format = magic === 0x20b ? "pe32-plus" : "pe32";
const countAt = magic === 0x10b ? 92 : 108;
const directoriesAt = countAt + 4;
if (optionalSize < directoriesAt) fail("Truncated PE data directory count.");
const directoryCount = bytes.readUInt32LE(optional + countAt);
if (directoryCount > Math.floor((optionalSize - directoriesAt) / 8))
fail("PE directory count exceeds its optional header.");
const headerSize = bytes.readUInt32LE(optional + 60);
if (headerSize > bytes.length) fail("Invalid PE SizeOfHeaders.");
const securityIndex = 4;
if (directoryCount <= securityIndex) {
return {
artifact,
format,
machine,
table: null,
entries: [],
coverage: { status: "complete", examined_entries: 0, certificates: 0 },
limitations: [...ABSENT_LIMITATIONS],
};
}
const directoryAt = optional + directoriesAt + securityIndex * 8;
requireRange(bytes, directoryAt, 8);
const tableOffset = bytes.readUInt32LE(directoryAt);
const tableSize = bytes.readUInt32LE(directoryAt + 4);
if (tableOffset === 0 && tableSize === 0) {
return {
artifact,
format,
machine,
table: null,
entries: [],
coverage: { status: "complete", examined_entries: 0, certificates: 0 },
limitations: [...ABSENT_LIMITATIONS],
};
}
if (tableSize === 0 || tableOffset < headerSize)
fail("PE certificate table offset overlaps headers or has zero size.");
requireRange(bytes, tableOffset, tableSize);
const table = bytes.subarray(tableOffset, tableOffset + tableSize);
const entries: PeCertificates["entries"] = [];
let at = 0;
let examined = 0;
while (at < table.length) {
if (table.length - at < 8) {
if (table.subarray(at).some((byte) => byte !== 0))
fail("WIN_CERTIFICATE header is truncated.");
break;
}
examined += 1;
if (examined > input.max_entries)
throw new ArtifactReaderFailure(
"limit",
`PE certificate table exceeds the ${String(input.max_entries)}-entry budget.`,
{ boundary: "capacity" },
);
const length = table.readUInt32LE(at);
const revision = table.readUInt16LE(at + 4);
const certificateType = table.readUInt16LE(at + 6);
if (length < 8)
fail("WIN_CERTIFICATE dwLength is shorter than its header.");
if (at + length > table.length)
fail("WIN_CERTIFICATE dwLength exceeds the certificate table.");
const payload = table.subarray(at + 8, at + length);
let pkcs7: PeCertificates["entries"][number]["pkcs7"] = null;
if (peCertificateTypeName(certificateType) === "pkcs7") {
try {
const parsed = parsePkcs7SignedData(payload);
pkcs7 = {
content_type_oid: parsed.content_type_oid,
content_type_name: oidName(parsed.content_type_oid),
digest_algorithm_oids: [...parsed.digest_algorithm_oids],
certificates: parsed.certificates.map((certificate) => ({
location: {
offset: tableOffset + at + 8 + certificate.start,
bytes: certificate.end - certificate.start,
},
der_sha256: sha256(
payload.subarray(certificate.start, certificate.end),
),
serial_hex: certificate.serial_hex,
issuer: {
text: certificate.issuer.text,
attributes: [...certificate.issuer.attributes],
},
subject: {
text: certificate.subject.text,
attributes: [...certificate.subject.attributes],
},
not_before: certificate.not_before,
not_after: certificate.not_after,
signature_algorithm_oid: certificate.signature_algorithm_oid,
signature_algorithm_name: oidName(
certificate.signature_algorithm_oid,
),
})),
signers: parsed.signers.map((signer) => ({
digest_algorithm_oid: signer.digest_algorithm_oid,
digest_algorithm_name: oidName(signer.digest_algorithm_oid),
signature_algorithm_oid: signer.signature_algorithm_oid,
signature_algorithm_name: oidName(signer.signature_algorithm_oid),
issuer:
signer.issuer === null
? null
: {
text: signer.issuer.text,
attributes: [...signer.issuer.attributes],
},
serial_hex: signer.serial_hex,
authenticated_attributes: signer.authenticated_attributes,
})),
};
} catch (cause) {
if (cause instanceof PeCertificateDerFailure)
fail(`PKCS#7 signedData is malformed: ${cause.message}`);
throw cause;
}
}
entries.push({
index: entries.length,
location: { offset: tableOffset + at, bytes: length },
length,
revision,
certificate_type: certificateType,
certificate_type_name: peCertificateTypeName(certificateType),
payload: {
location: { offset: tableOffset + at + 8, bytes: payload.length },
bytes: payload.length,
sha256: sha256(payload),
},
pkcs7,
});
const next = align8(at + length);
if (next <= at) fail("WIN_CERTIFICATE alignment did not advance.");
at = next;
}
const certificates = entries.reduce(
(count, entry) => count + (entry.pkcs7?.certificates.length ?? 0),
0,
);
return {
artifact,
format,
machine,
table: {
location: { offset: tableOffset, bytes: tableSize },
data_directory_location: { offset: directoryAt, bytes: 8 },
},
entries,
coverage: {
status: "complete",
examined_entries: examined,
certificates,
},
limitations: [...LIMITATIONS],
};
};
+152
View File
@@ -0,0 +1,152 @@
const concat = (...parts: Buffer[]): Buffer => Buffer.concat(parts);
const encodeLength = (length: number): Buffer => {
if (length < 0x80) return Buffer.from([length]);
if (length <= 0xff) return Buffer.from([0x81, length]);
const bytes = Buffer.alloc(3);
bytes[0] = 0x82;
bytes.writeUInt16BE(length, 1);
return bytes;
};
const tlv = (tag: number, content: Buffer): Buffer =>
concat(Buffer.from([tag]), encodeLength(content.length), content);
const seq = (...children: Buffer[]): Buffer => tlv(0x30, concat(...children));
const set = (...children: Buffer[]): Buffer => tlv(0x31, concat(...children));
const integer = (value: number | Buffer): Buffer =>
tlv(0x02, Buffer.isBuffer(value) ? value : Buffer.from([value]));
const encodeBase128 = (value: number): number[] => {
if (value < 0x80) return [value];
const stack: number[] = [];
let remaining = value;
while (remaining > 0) {
stack.push(remaining & 0x7f);
remaining >>= 7;
}
stack.reverse();
return stack.map((byte, index) =>
index === stack.length - 1 ? byte : byte | 0x80,
);
};
const oid = (parts: readonly number[]): Buffer => {
const encoded: number[] = [40 * parts[0]! + parts[1]!];
for (const part of parts.slice(2)) encoded.push(...encodeBase128(part));
return tlv(0x06, Buffer.from(encoded));
};
const utf8 = (value: string): Buffer => tlv(0x0c, Buffer.from(value, "utf8"));
const utc = (value: string): Buffer => tlv(0x17, Buffer.from(value, "ascii"));
const nul = (): Buffer => tlv(0x05, Buffer.alloc(0));
const octet = (value: Buffer): Buffer => tlv(0x04, value);
const bitString = (value: Buffer): Buffer =>
tlv(0x03, concat(Buffer.from([0]), value));
const context = (number: number, content: Buffer, constructed = true): Buffer =>
tlv((constructed ? 0xa0 : 0x80) | number, content);
const SHA256_RSA = oid([1, 2, 840, 113549, 1, 1, 11]);
const SHA256 = oid([2, 16, 840, 1, 101, 3, 4, 2, 1]);
const RSA = oid([1, 2, 840, 113549, 1, 1, 1]);
const SIGNED_DATA = oid([1, 2, 840, 113549, 1, 7, 2]);
const DATA = oid([1, 2, 840, 113549, 1, 7, 1]);
const CN = oid([2, 5, 4, 3]);
const O = oid([2, 5, 4, 10]);
const attribute = (type: Buffer, value: Buffer): Buffer => seq(type, value);
const rdn = (...attributes: Buffer[]): Buffer => set(...attributes);
const name = (commonName: string, organization: string): Buffer =>
seq(
rdn(attribute(CN, utf8(commonName))),
rdn(attribute(O, utf8(organization))),
);
const algorithm = (identifier: Buffer): Buffer => seq(identifier, nul());
/** Hand-authored PKCS#7 signedData, independent of the production decoder. */
export const pkcs7SignedDataFixture = (
commonName = "REA Fixture",
organization = "REA Tests",
): Buffer => {
const serial = Buffer.from([0x11, 0x22, 0x33, 0x44]);
const subject = name(commonName, organization);
const tbs = seq(
context(0, integer(2)),
integer(serial),
algorithm(SHA256_RSA),
subject,
seq(utc("250101000000Z"), utc("260101000000Z")),
subject,
seq(algorithm(RSA), bitString(Buffer.from("pubkey"))),
);
const certificate = seq(
tbs,
algorithm(SHA256_RSA),
bitString(Buffer.from("signature")),
);
const signer = seq(
integer(1),
seq(subject, integer(serial)),
algorithm(SHA256),
algorithm(SHA256_RSA),
octet(Buffer.from("sig")),
);
const signedData = seq(
integer(1),
set(algorithm(SHA256)),
seq(DATA),
context(0, certificate),
set(signer),
);
return seq(SIGNED_DATA, context(0, signedData));
};
/** Minimal PE32/PE32+ image with an overlay IMAGE_DIRECTORY_ENTRY_SECURITY table. */
export const peCertificateFixture = (
plus = false,
payload: Buffer = pkcs7SignedDataFixture(),
type = 0x0002,
): Buffer => {
const headerBytes = 0x200;
const alignedPayload = payload;
const entryLength = 8 + alignedPayload.length;
const padded = align8(entryLength);
const table = Buffer.alloc(padded);
table.writeUInt32LE(entryLength, 0);
table.writeUInt16LE(0x0200, 4);
table.writeUInt16LE(type, 6);
alignedPayload.copy(table, 8);
const bytes = Buffer.alloc(headerBytes + table.length);
bytes.writeUInt16LE(0x5a4d, 0);
bytes.writeUInt32LE(0x80, 60);
bytes.writeUInt32LE(0x4550, 0x80);
bytes.writeUInt16LE(plus ? 0x8664 : 0x14c, 0x84);
bytes.writeUInt16LE(1, 0x86);
const optionalSize = plus ? 240 : 224;
bytes.writeUInt16LE(optionalSize, 0x94);
bytes.writeUInt16LE(plus ? 0x20b : 0x10b, 0x98);
bytes.writeUInt32LE(headerBytes, 0x98 + 60);
bytes.writeUInt32LE(16, 0x98 + (plus ? 108 : 92));
const directoryAt = 0x98 + (plus ? 112 : 96) + 4 * 8;
bytes.writeUInt32LE(headerBytes, directoryAt);
bytes.writeUInt32LE(table.length, directoryAt + 4);
table.copy(bytes, headerBytes);
return bytes;
};
const align8 = (value: number): number => Math.ceil(value / 8) * 8;
/** PE image whose optional header admits no security data directory. */
export const peWithoutCertificateTableFixture = (plus = false): Buffer => {
const bytes = Buffer.alloc(0x200);
bytes.writeUInt16LE(0x5a4d, 0);
bytes.writeUInt32LE(0x80, 60);
bytes.writeUInt32LE(0x4550, 0x80);
bytes.writeUInt16LE(plus ? 0x8664 : 0x14c, 0x84);
bytes.writeUInt16LE(1, 0x86);
const optionalSize = plus ? 240 : 224;
bytes.writeUInt16LE(optionalSize, 0x94);
bytes.writeUInt16LE(plus ? 0x20b : 0x10b, 0x98);
bytes.writeUInt32LE(0x200, 0x98 + 60);
bytes.writeUInt32LE(4, 0x98 + (plus ? 108 : 92));
return bytes;
};
+201
View File
@@ -0,0 +1,201 @@
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
import {
ArtifactFileCleanupFailure,
bindArtifactFileCleanup,
artifactFileCancellationCause,
} from "../../artifacts/ArtifactFileOperation.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import type { PeCertificatesPort } from "../../application/binaryDiagnostics/PeCertificatesPort.js";
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import {
MAX_INSPECT_PE_CERTIFICATE_ENTRIES,
MAX_INSPECT_PE_CERTIFICATE_FILE_BYTES,
type InspectPeCertificatesInput,
} from "../../domain/native/peCertificates.js";
import { err, ok } from "../../domain/result.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { PE_CERTIFICATES_PROVIDER } from "../../application/InvestigationProviders.js";
import { parsePeCertificates } from "./PeCertificateParser.js";
const OPERATION = "inspect_pe_certificates";
/** Portable parser over one bounded, identity-checked regular file. */
export class PeCertificatesProvider implements PeCertificatesPort {
readonly identity = PE_CERTIFICATES_PROVIDER;
readonly #resources = new ArtifactResourceScope();
async close(): Promise<void> {
try {
await this.#resources.close();
} catch (cause: unknown) {
if (
!(cause instanceof ArtifactReaderFailure) ||
cause.cleanup === undefined
)
throw cause;
const error = new ProviderCleanupError(
PE_CERTIFICATES_PROVIDER.id,
cause.cleanup.resources,
{ reason: cause.cleanup.reason },
{ cause, operation: OPERATION },
);
bindArtifactFileCleanup(cause, error);
throw error;
}
}
async inspect(input: InspectPeCertificatesInput, options?: ExecutionOptions) {
try {
return await this.#resources.run(async () => {
const snapshot = await readStableArtifact(
input.path,
input.max_file_bytes,
options?.signal,
undefined,
this.#resources,
);
return ok(
parsePeCertificates(
snapshot.bytes,
{
path: input.path,
sha256: snapshot.sha256,
bytes: snapshot.bytes.length,
},
input,
),
);
});
} catch (cause) {
return err(peCertificatesFailure(cause, input, options?.signal));
}
}
}
const inputBudgetRemediation = (
boundary: "file-size" | "capacity",
input: InspectPeCertificatesInput,
): string =>
boundary === "file-size"
? input.max_file_bytes >= MAX_INSPECT_PE_CERTIFICATE_FILE_BYTES
? `The image exceeds the ${String(MAX_INSPECT_PE_CERTIFICATE_FILE_BYTES)}-byte input ceiling this operation accepts; select a smaller artifact.`
: `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`
: input.max_entries >= MAX_INSPECT_PE_CERTIFICATE_ENTRIES
? `The image exceeds the ${String(MAX_INSPECT_PE_CERTIFICATE_ENTRIES)}-entry certificate-table ceiling this operation accepts; select a smaller artifact.`
: `Raise max_entries above ${String(input.max_entries)} or select a smaller artifact; the limit is the caller-selected certificate entry budget.`;
const peCertificatesFailurePrimary = (
cause: unknown,
input: InspectPeCertificatesInput,
) => {
if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity")
return new AnalysisArtifactChangedError(
OPERATION,
input.path,
cause.message,
{
cause,
},
);
if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
return new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
});
if (cause instanceof ArtifactReaderFailure) {
if (cause.reason === "limit")
return new AnalysisResourceConstraintError(
OPERATION,
cause.boundary ?? "memory",
cause.message,
{
max_file_bytes: input.max_file_bytes,
max_entries: input.max_entries,
},
{
cause,
...(cause.boundary === undefined || cause.boundary === "memory"
? {}
: {
retryable: false,
remediationAction: inputBudgetRemediation(
cause.boundary,
input,
),
}),
},
);
if (cause.reason === "path" || cause.reason === "format")
return new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
reason: "invalid_format",
message: cause.message,
},
]);
}
if (cause instanceof Error && "code" in cause) {
if (cause.code === "EACCES" || cause.code === "EPERM")
return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, {
cause,
});
if (
["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG"].includes(
String(cause.code),
)
)
return new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
reason: "invalid_value",
message: `Selected PE image could not be read (${String(cause.code)}): ${input.path}.`,
},
]);
}
return new ArtifactOperationError(
OPERATION,
cause instanceof ArtifactReaderFailure ? cause.reason : "io",
undefined,
cause instanceof Error
? cause.message
: "PE certificate inspection failed.",
{ cause },
);
};
const peCertificatesFailure = (
cause: unknown,
input: InspectPeCertificatesInput,
signal: AbortSignal | undefined,
) => {
const primary =
cause instanceof ArtifactFileCleanupFailure &&
cause.fileCleanupFailure.outcome.kind === "failed"
? peCertificatesFailurePrimary(
cause.fileCleanupFailure.outcome.cause,
input,
)
: cause instanceof ArtifactFileCleanupFailure && signal?.aborted
? new AnalysisCancelledError(OPERATION, { cause: signal.reason })
: peCertificatesFailurePrimary(cause, input);
if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined)
return primary;
const cleanup = new ProviderCleanupError(
PE_CERTIFICATES_PROVIDER.id,
cause.cleanup.resources,
{ reason: cause.cleanup.reason },
{ operation: OPERATION, cause },
);
const error = analysisErrorWithCleanupFailure(primary, cleanup, OPERATION);
bindArtifactFileCleanup(cause, error);
return error;
};
+20 -3
View File
@@ -12,8 +12,15 @@ import { wasmArtifactModule } from "../composition/wasm.js";
import { registerWasmTools } from "./registerWasmTools.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { captureProviderEnvironment } from "./providerEnvironment.js";
import { createPeResourcesService } from "../composition/binaryDiagnostics.js";
import {
createBinaryLayoutService,
createPeCertificatesService,
createPeResourcesService,
createRecordedCrashService,
} from "../composition/binaryDiagnostics.js";
import type { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.js";
import type { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js";
import { registerPeCertificatesTool } from "./registerPeCertificatesTool.js";
import { registerPeResourcesTool } from "./registerPeResourcesTool.js";
import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
import {
@@ -25,7 +32,6 @@ import type { SqliteDatabaseService } from "../application/sqlite/SqliteDatabase
import { createSqliteDatabaseService } from "../composition/sqlite.js";
import { registerSqliteTools } from "./registerSqliteTools.js";
import { registerRecordedCrashTools } from "./registerRecordedCrashTools.js";
import { createRecordedCrashService } from "../composition/binaryDiagnostics.js";
import { createDesktopApplicationService } from "../composition/desktop.js";
import { registerDesktopApplicationTool } from "./registerDesktopApplicationTool.js";
import { createGoBinaryService } from "../composition/go.js";
@@ -46,7 +52,6 @@ import { registerAnalysisViewTool } from "./registerAnalysisViewTool.js";
import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js";
import { FridaInstrumentationManager } from "../frida/FridaInstrumentationManager.js";
import { registerFridaTools } from "./registerFridaTools.js";
import { createBinaryLayoutService } from "../composition/binaryDiagnostics.js";
import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js";
import { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import {
@@ -338,6 +343,7 @@ export const createServer = (
const il2cppMetadata =
options.il2cppMetadata ?? createIl2cppMetadataService();
const peResources = createPeResourcesService();
const peCertificates = createPeCertificatesService();
const electronFuses = options.electronFuses ?? createElectronFuseService();
const webNetworkCapture =
options.webNetworkCapture ?? createWebNetworkCaptureService(environment);
@@ -425,6 +431,7 @@ export const createServer = (
modelArtifact.close(),
gpuCodeObjects.close(),
peResources.close(),
peCertificates.close(),
electronFuses.close(),
webNetworkCapture.close(),
javascriptRecovery.close?.() ?? Promise.resolve(),
@@ -488,6 +495,7 @@ export const createServer = (
rustBinary,
il2cppMetadata,
peResources,
peCertificates,
electronFuses,
webNetworkCapture,
};
@@ -719,6 +727,7 @@ const registerConfiguredAnalysisTools = (
rustBinary,
il2cppMetadata,
peResources,
peCertificates,
webNetworkCapture,
artifactResources,
}: ServerToolContext,
@@ -767,6 +776,13 @@ const registerConfiguredAnalysisTools = (
recordEvidence,
withAdmittedAnalysis,
);
registerPeCertificatesTool(
server,
peCertificates,
toolLogger,
recordEvidence,
withAdmittedAnalysis,
);
registerGoTools(
server,
goBinary,
@@ -900,6 +916,7 @@ interface ServerToolContext extends ReturnType<typeof createSessionRecorders> {
readonly rustBinary: RustBinaryService;
readonly il2cppMetadata: Il2cppMetadataService;
readonly peResources: PeResourcesService;
readonly peCertificates: PeCertificatesService;
readonly electronFuses: ElectronFuseService;
readonly webNetworkCapture: WebNetworkCaptureService;
readonly artifactResources: ArtifactResourceScope;
+31
View File
@@ -0,0 +1,31 @@
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.js";
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { Logger } from "pino";
import { createEvidenceToolHandler } from "./contractToolHandler.js";
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
/** Register the explicit-file PE certificate-table workflow and retain its complete Evidence. */
export const registerPeCertificatesTool = (
server: EvidenceMcpServer,
service: PeCertificatesService,
logger: Logger,
recordEvidence?: EvidenceWriter["recordEvidence"],
withAdmittedAnalysis?: WithAdmittedAnalysis,
): void => {
const contract = toolContract("inspect_pe_certificates");
const handler = createEvidenceToolHandler(
server,
(input, options) => service.inspect(input, options),
logger,
recordEvidence,
withAdmittedAnalysis,
);
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
handler(contract),
);
};
@@ -0,0 +1,64 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../src/contracts/toolContracts.js";
import {
peCertificateFixture,
peWithoutCertificateTableFixture,
} from "../../src/native/pe/PeCertificates.fixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
cliTest(
"reports PKCS#7 signer identity through the public CLI",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-cert-cli-");
const path = join(root, "signed.exe");
await writeFile(path, peCertificateFixture());
const result = await cli.run({
arguments: ["inspect-pe-certificates", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract("inspect_pe_certificates").outputSchema.parse(
result.json,
);
expect(
evidence.normalized_result.entries[0]?.pkcs7?.certificates[0]?.serial_hex,
).toBe("11223344");
},
);
cliTest(
"resolves a relative PE path against the working directory",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-cert-cli-rel-");
await writeFile(
join(root, "unsigned.exe"),
peWithoutCertificateTableFixture(),
);
const result = await cli.run({
arguments: ["inspect-pe-certificates", "unsigned.exe", "--json"],
cwd: root,
});
expect(result.exitCode, result.stderr).toBe(0);
const evidence = toolContract("inspect_pe_certificates").outputSchema.parse(
result.json,
);
expect(evidence.normalized_result.table).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-cert-cli-bad-");
const path = join(root, "broken.exe");
await writeFile(path, Buffer.from("MZ"));
const result = await cli.run({
arguments: ["inspect-pe-certificates", path, "--json"],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: { operation: "inspect_pe_certificates" },
});
});
@@ -218,6 +218,7 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
{ capture_path: nul, output_directory: "/tmp/exported-scripts" },
],
["inspect_pe_resources", { path: nul }],
["inspect_pe_certificates", { path: nul }],
["inspect_binary_layout", { path: nul }],
["inspect_recorded_crash", { path: nul }],
["inspect_wasm_artifact", { path: nul }],
@@ -0,0 +1,57 @@
import { symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createPeCertificatesService } from "../../../src/composition/binaryDiagnostics.js";
import { peCertificateFixture } from "../../../src/native/pe/PeCertificates.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("PE certificate stable filesystem boundary", () => {
it("reports non-file and malformed PE selections as caller input errors", async () => {
const root = await createTestTempDirectory("rea-pe-cert-files-");
const path = join(root, "signed.exe");
const alias = join(root, "alias.exe");
const malformed = join(root, "malformed.exe");
await writeFile(path, peCertificateFixture());
await writeFile(malformed, Buffer.from("not a pe"));
await symlink(path, alias);
const service = createPeCertificatesService();
for (const [candidate, reason] of [
[root, "invalid_format"],
[alias, "invalid_format"],
[malformed, "invalid_format"],
[join(root, "missing.exe"), "invalid_value"],
[join(path, "child.exe"), "invalid_value"],
] as const) {
const result = await service.inspect({ path: candidate });
if (result.ok) throw new Error("Expected file acquisition failure");
expect(result.error).toMatchObject({
_tag: "AnalysisInputError",
operation: "inspect_pe_certificates",
issues: [
{
path: ["path"],
reason,
message: expect.any(String),
},
],
});
}
});
it("returns typed resource and cancellation failures", async () => {
const root = await createTestTempDirectory("rea-pe-cert-budget-");
const path = join(root, "signed.exe");
const bytes = peCertificateFixture();
await writeFile(path, bytes);
const service = createPeCertificatesService();
const tooSmall = await service.inspect({ path, max_file_bytes: 64 });
if (tooSmall.ok) throw new Error("Expected resource budget failure");
expect(tooSmall.error._tag).toBe("AnalysisResourceConstraintError");
const cancelled = await service.inspect(
{ path },
{ signal: AbortSignal.abort() },
);
if (cancelled.ok) throw new Error("Expected cancellation");
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
});
});
@@ -0,0 +1,72 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { peCertificateFixture } from "../../../src/native/pe/PeCertificates.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js";
const connect = async (mcp: TestMcp) => {
const session = createTestBinarySession(() => {
throw new Error("PE certificate inspection must not start a deep provider");
});
const client = await mcp.connect(createServer({ kind: "session", session }));
return { client, session };
};
mcpTest(
"advertises inspect_pe_certificates and reads PKCS#7 signer identity",
async ({ mcp }) => {
const { client, session } = await connect(mcp);
try {
const advertised = (await client.listTools()).tools.find(
(tool) => tool.name === "inspect_pe_certificates",
);
expect(advertised?.annotations).toMatchObject({
readOnlyHint: false,
idempotentHint: true,
});
const root = await createTestTempDirectory("rea-pe-cert-mcp-");
const path = join(root, "signed.exe");
await writeFile(path, peCertificateFixture(true));
const response = await client.callTool({
name: "inspect_pe_certificates",
arguments: { path },
});
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
const evidence = toolContract(
"inspect_pe_certificates",
).outputSchema.parse(response.structuredContent);
expect(evidence.normalized_result.format).toBe("pe32-plus");
expect(
evidence.normalized_result.entries[0]?.pkcs7?.certificates[0]
?.serial_hex,
).toBe("11223344");
} finally {
await session.close();
}
},
);
mcpTest(
"requires an absolute path for inspect_pe_certificates",
async ({ mcp }) => {
const { client, session } = await connect(mcp);
try {
const response = await client.callTool({
name: "inspect_pe_certificates",
arguments: { path: "signed.exe" },
});
expect(response.isError).toBe(true);
expect(parseMcpToolError(response).error).toMatchObject({
code: "invalid_request",
details: { operation: "inspect_pe_certificates" },
});
} finally {
await session.close();
}
},
);