diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index f27a38db40..8add33b5a9 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -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 diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 4e549ce493..2f05267286 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -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 diff --git a/README.md b/README.md index b0d470e45e..e2a1de80bf 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 86d825c13d..621f4e5862 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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", diff --git a/docs/cli.md b/docs/cli.md index e291589bb1..349f400b1f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/docs/index.md b/docs/index.md index 070b8aad51..f3d8444b0d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) | diff --git a/docs/pe-certificates.md b/docs/pe-certificates.md new file mode 100644 index 0000000000..e4c3723807 --- /dev/null +++ b/docs/pe-certificates.md @@ -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. diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index d4b51ead22..0782ff4cdc 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -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, diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index d0eabbb0d9..b629bea092 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -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 { diff --git a/src/application/InvestigationProviders.ts b/src/application/InvestigationProviders.ts index 510111d929..97aa0feb5d 100644 --- a/src/application/InvestigationProviders.ts +++ b/src/application/InvestigationProviders.ts @@ -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", diff --git a/src/application/binaryDiagnostics/PeCertificatesPort.ts b/src/application/binaryDiagnostics/PeCertificatesPort.ts new file mode 100644 index 0000000000..755ea3baca --- /dev/null +++ b/src/application/binaryDiagnostics/PeCertificatesPort.ts @@ -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; + inspect( + input: InspectPeCertificatesInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/PeCertificatesService.test.ts b/src/application/binaryDiagnostics/PeCertificatesService.test.ts new file mode 100644 index 0000000000..30fa4b88f0 --- /dev/null +++ b/src/application/binaryDiagnostics/PeCertificatesService.test.ts @@ -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); +}); diff --git a/src/application/binaryDiagnostics/PeCertificatesService.ts b/src/application/binaryDiagnostics/PeCertificatesService.ts new file mode 100644 index 0000000000..0ce7aa9e18 --- /dev/null +++ b/src/application/binaryDiagnostics/PeCertificatesService.ts @@ -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 { + return this.provider.close?.() ?? Promise.resolve(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + 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), + ); +}; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 2e38126d66..0ee7c74611 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -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, ): 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", diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 91ff0fb0eb..053e4d0fdd 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -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", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index afadee6c50..9f0c31166c 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -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 = process.env, diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index dad3769415..2190dfe827 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -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"), diff --git a/src/contracts/toolEffects.test.ts b/src/contracts/toolEffects.test.ts index 29cd1a04e6..1eb2c43e5b 100644 --- a/src/contracts/toolEffects.test.ts +++ b/src/contracts/toolEffects.test.ts @@ -28,6 +28,7 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly> = { 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", diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index b10480a7cb..c87be1b58f 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -252,6 +252,7 @@ export const TOOL_EFFECTS: Readonly> = { idempotent: false, }), inspect_pe_resources: evidence, + inspect_pe_certificates: evidence, inspect_analysis_view: evidence, inspect_firmware_regions: effects({ mutatesSession: true, diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 8e4fc38797..92c29ea636 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -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" diff --git a/src/domain/native/peCertificates.test.ts b/src/domain/native/peCertificates.test.ts new file mode 100644 index 0000000000..eaa3e470af --- /dev/null +++ b/src/domain/native/peCertificates.test.ts @@ -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([]); +}); diff --git a/src/domain/native/peCertificates.ts b/src/domain/native/peCertificates.ts new file mode 100644 index 0000000000..48ecbba545 --- /dev/null +++ b/src/domain/native/peCertificates.ts @@ -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; +export type InspectPeCertificatesInput = z.infer< + typeof inspectPeCertificatesInputSchema +>; diff --git a/src/native/pe/PeCertificateDer.ts b/src/native/pe/PeCertificateDer.ts new file mode 100644 index 0000000000..2a8d22e85e --- /dev/null +++ b/src/native/pe/PeCertificateDer.ts @@ -0,0 +1,345 @@ +const OID_NAMES: Readonly> = { + "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 = (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; + readonly subject: ReturnType; + 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 | 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); +}; diff --git a/src/native/pe/PeCertificateParser.test.ts b/src/native/pe/PeCertificateParser.test.ts new file mode 100644 index 0000000000..9e7cfba2fe --- /dev/null +++ b/src/native/pe/PeCertificateParser.test.ts @@ -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"), + ); +}); diff --git a/src/native/pe/PeCertificateParser.ts b/src/native/pe/PeCertificateParser.ts new file mode 100644 index 0000000000..bbb4fbd775 --- /dev/null +++ b/src/native/pe/PeCertificateParser.ts @@ -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], + }; +}; diff --git a/src/native/pe/PeCertificates.fixture.ts b/src/native/pe/PeCertificates.fixture.ts new file mode 100644 index 0000000000..4d382e2169 --- /dev/null +++ b/src/native/pe/PeCertificates.fixture.ts @@ -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; +}; diff --git a/src/native/pe/PeCertificatesProvider.ts b/src/native/pe/PeCertificatesProvider.ts new file mode 100644 index 0000000000..bd64e37c05 --- /dev/null +++ b/src/native/pe/PeCertificatesProvider.ts @@ -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 { + 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; +}; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index fa98728744..43195f3ac4 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -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 { readonly rustBinary: RustBinaryService; readonly il2cppMetadata: Il2cppMetadataService; readonly peResources: PeResourcesService; + readonly peCertificates: PeCertificatesService; readonly electronFuses: ElectronFuseService; readonly webNetworkCapture: WebNetworkCaptureService; readonly artifactResources: ArtifactResourceScope; diff --git a/src/server/registerPeCertificatesTool.ts b/src/server/registerPeCertificatesTool.ts new file mode 100644 index 0000000000..80da753b8f --- /dev/null +++ b/src/server/registerPeCertificatesTool.ts @@ -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), + ); +}; diff --git a/tests/acceptance/peCertificatesCli.test.ts b/tests/acceptance/peCertificatesCli.test.ts new file mode 100644 index 0000000000..0a20398c46 --- /dev/null +++ b/tests/acceptance/peCertificatesCli.test.ts @@ -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" }, + }); +}); diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts index 980638aa5b..81c21e7b2d 100644 --- a/tests/acceptance/tools/localToolContracts.test.ts +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -218,6 +218,7 @@ const pathRequests: readonly [string, Record][] = [ { 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 }], diff --git a/tests/boundary/filesystem/peCertificates.test.ts b/tests/boundary/filesystem/peCertificates.test.ts new file mode 100644 index 0000000000..3e09cfdd35 --- /dev/null +++ b/tests/boundary/filesystem/peCertificates.test.ts @@ -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"); + }); +}); diff --git a/tests/boundary/mcp/peCertificatesMcp.test.ts b/tests/boundary/mcp/peCertificatesMcp.test.ts new file mode 100644 index 0000000000..88da46dc15 --- /dev/null +++ b/tests/boundary/mcp/peCertificatesMcp.test.ts @@ -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(); + } + }, +);