fix(pe): report exhausted certificate decoder allowances as resource limits (#7881)

* fix(pe): report exhausted certificate decoder allowances as resource limits

A PKCS#7 payload that exhausted the fixed 64 KiB decoded-text or 16,384-value ASN.1 allowance was refused as malformed input with advice to correct request fields. Keep the allowance and limit on the decoder failure and report it as a nonretryable resource_constraint with fixed-allowance recovery.

Fixes #7531

* test(pe): cover certificate allowances through MCP

---------

Co-authored-by: morluto <[email protected]>
This commit is contained in:
RRFZandmorluto authored and GitHub committed 2026-10-29 07:36:56 +08:00
1 parent fd297b1b17
commit 8c83886891
7 files changed
+216 -5

No files matched your search

+5 -1
View File
@@ -54,7 +54,11 @@ fails the same way. SignedData must contain at least one `SignerInfo`, and
each `SignerInfo` must carry a non-empty signature value. Each embedded X.509
certificate must include `subjectPublicKeyInfo` and `signatureValue`. Decoded
certificate name text, serials, validity spellings, and subject key identifiers
share a 64 KiB budget per PKCS#7 payload. Unsupported certificate name
share a 64 KiB budget per PKCS#7 payload, and each payload decodes at most
16,384 ASN.1 values. Exhausting either fixed allowance returns a nonretryable
`resource_constraint` error naming it, not an invalid-input error; this release
has no runtime override, and `max_file_bytes` and `max_entries` do not raise
it. Unsupported certificate name
encodings, including GeneralString, fail explicitly. TeletexString (T.61)
values are rendered with the ISO-8859-1 compatibility mapping used by OpenSSL;
this is not a full T.61 decoder, so the result includes a limitation whenever
+20 -3
View File
@@ -26,8 +26,18 @@ const OID_NAMES: Readonly<Record<string, string>> = {
"2.16.840.1.101.3.4.2.3": "sha512",
};
/** A fixed per-payload decoder allowance and its limit. */
export interface PeCertificateDecodeAllowance {
readonly allowance: "asn1-nodes" | "decoded-text";
readonly limit: number;
}
export class PeCertificateAsn1Failure extends Error {
constructor(message: string) {
constructor(
message: string,
/** Set when a fixed decoder allowance, not malformed DER, refused the payload. */
readonly exhausted: PeCertificateDecodeAllowance | null = null,
) {
super(message);
this.name = "PeCertificateAsn1Failure";
}
@@ -49,7 +59,13 @@ export const readAsn1 = (bytes: Buffer): Asn1BerNode => {
try {
return new Asn1BerReader(bytes, ASN1_NODE_BUDGET).read();
} catch (cause: unknown) {
if (cause instanceof Asn1BerFailure) return fail(cause.message);
if (cause instanceof Asn1BerFailure)
throw new PeCertificateAsn1Failure(
cause.message,
cause.kind === "budget-exceeded"
? { allowance: "asn1-nodes", limit: cause.limit ?? ASN1_NODE_BUDGET }
: null,
);
throw cause;
}
};
@@ -78,8 +94,9 @@ interface DecodedNameBudget {
const chargeDecodedName = (budget: DecodedNameBudget, bytes: number): void => {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > budget.remaining)
fail(
throw new PeCertificateAsn1Failure(
`Certificate text exceeds the ${String(MAX_DECODED_NAME_BYTES)}-byte decoded budget.`,
{ allowance: "decoded-text", limit: MAX_DECODED_NAME_BYTES },
);
budget.remaining -= bytes;
};
+6 -1
View File
@@ -169,7 +169,12 @@ export const parsePeCertificates = (
})),
};
} catch (cause) {
if (cause instanceof PeCertificateAsn1Failure)
// An exhausted decoder allowance is not evidence of malformed DER;
// the provider reports it as a fixed resource allowance.
if (
cause instanceof PeCertificateAsn1Failure &&
cause.exhausted === null
)
fail(`PKCS#7 signedData is malformed: ${cause.message}`);
throw cause;
}
+34
View File
@@ -27,7 +27,12 @@ import {
type InspectPeCertificatesInput,
} from "../../domain/native/peCertificates.js";
import { err, ok } from "../../domain/result.js";
import { fixedAllowanceOptions } from "../../domain/fixedAllowanceRemediation.js";
import { PE_CERTIFICATES_PROVIDER } from "../../domain/investigationProviders.js";
import {
PeCertificateAsn1Failure,
type PeCertificateDecodeAllowance,
} from "./PeCertificateAsn1.js";
import { parsePeCertificates } from "./PeCertificateParser.js";
const OPERATION = "inspect_pe_certificates";
@@ -89,6 +94,29 @@ const inputBudgetRemediation = (
? `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.`;
/** A fixed PKCS#7 decoder allowance; neither request fields nor retries raise it. */
const decoderAllowanceFailure = (
cause: PeCertificateAsn1Failure,
{ allowance, limit }: PeCertificateDecodeAllowance,
) =>
new AnalysisResourceConstraintError(
OPERATION,
"memory",
`PKCS#7 signedData exceeds a fixed decoder allowance: ${cause.message}`,
allowance === "asn1-nodes"
? { max_asn1_values: limit }
: { max_decoded_text_bytes: limit },
{
cause,
...fixedAllowanceOptions(
allowance === "asn1-nodes"
? `The PE certificate ASN.1 allowance of ${String(limit)} values per PKCS#7 payload (this release has no runtime override)`
: `The PE certificate decoded-text allowance of ${String(limit)} bytes per PKCS#7 payload (this release has no runtime override)`,
"parser",
),
},
);
const peCertificatesFailurePrimary = (
facts: ArtifactFilePrimaryFacts,
input: InspectPeCertificatesInput,
@@ -115,6 +143,12 @@ const peCertificatesFailurePrimary = (
return new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
});
if (
origin === "owned-or-classified" &&
cause instanceof PeCertificateAsn1Failure &&
cause.exhausted !== null
)
return decoderAllowanceFailure(cause, cause.exhausted);
if (
origin === "owned-or-classified" &&
cause instanceof ArtifactReaderFailure
@@ -8,6 +8,7 @@ import {
pkcs7SignedDataFixture,
pkcs7SignedDataIndefiniteEnvelopeFixture,
} from "../../src/native/pe/PeCertificates.fixture.js";
import { repeatedCertificateSignedData } from "../fixtures/peCertificateAllowance.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
@@ -141,3 +142,78 @@ cliTest(
});
},
);
cliTest(
"inventories repeated certificates within both decoder allowances",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-pe-cert-cli-repeated-");
const path = join(root, "chain.exe");
await writeFile(
path,
peCertificateFixture(false, repeatedCertificateSignedData(200)),
);
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,
).toHaveLength(200);
},
);
cliTest.for([
// Each repeated certificate decodes to 43 ASN.1 values and about 200 bytes
// of text, so 350 stay within the value allowance and exceed the text one.
{
allowance: "decoded certificate text",
certificates: 350,
limits: { max_decoded_text_bytes: 65_536 },
message: "65536-byte decoded budget",
},
{
allowance: "ASN.1 value count",
certificates: 600,
limits: { max_asn1_values: 16_384 },
message: "16384-node budget",
},
])(
"reports the fixed $allowance allowance as a resource constraint",
async ({ certificates, limits, message }, { cli }) => {
const root = await createTestTempDirectory("rea-pe-cert-cli-allowance-");
const path = join(root, "large.exe");
await writeFile(
path,
peCertificateFixture(false, repeatedCertificateSignedData(certificates)),
);
const result = await cli.run({
arguments: ["inspect-pe-certificates", path, "--json"],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "resource_constraint",
retryable: false,
details: {
operation: "inspect_pe_certificates",
reported_limits: limits,
},
});
const { message: reason, remediation } = result.json as {
message: string;
remediation: { action: string };
};
expect(reason).toContain(message);
expect(remediation.action).toContain(String(Object.values(limits)[0]));
expect(remediation.action).toContain("no runtime override");
expect(remediation.action).toContain(
"Repeating the unchanged request fails the same way",
);
},
);
@@ -8,6 +8,7 @@ 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";
import { repeatedCertificateSignedData } from "../../fixtures/peCertificateAllowance.js";
const connect = async (mcp: TestMcp) => {
const session = createTestBinarySession(() => {
@@ -70,3 +71,34 @@ mcpTest(
}
},
);
mcpTest.for([
{ count: 350, reported_limits: { max_decoded_text_bytes: 65_536 } },
{ count: 600, reported_limits: { max_asn1_values: 16_384 } },
])(
"reports decoder capacity as a nonretryable resource failure for $count certificates",
async ({ count, reported_limits }, { mcp }) => {
const { client, session } = await connect(mcp);
try {
const root = await createTestTempDirectory("rea-pe-cert-mcp-budget-");
const path = join(root, "large.exe");
await writeFile(
path,
peCertificateFixture(false, repeatedCertificateSignedData(count)),
);
const response = await client.callTool({
name: "inspect_pe_certificates",
arguments: { path },
});
expect(response.isError).toBe(true);
expect(parseMcpToolError(response).error).toMatchObject({
code: "resource_constraint",
category: "resource_constraint",
retryable: false,
details: { operation: "inspect_pe_certificates", reported_limits },
});
} finally {
await session.close();
}
},
);
+43
View File
@@ -0,0 +1,43 @@
import { pkcs7SignedDataFixture } from "../../src/native/pe/PeCertificates.fixture.js";
import { readAsn1 } from "../../src/native/pe/PeCertificateAsn1.js";
/** One DER TLV with a minimal definite length. */
const derValue = (tag: number, ...parts: Buffer[]): Buffer => {
const content = Buffer.concat(parts);
const octets: number[] = [];
for (let rest = content.length; rest > 0; rest = Math.floor(rest / 256))
octets.unshift(rest % 256);
const length =
content.length < 0x80
? [content.length]
: [0x80 | octets.length, ...octets];
return Buffer.concat([Buffer.from([tag, ...length]), content]);
};
/** The fixture's SignedData with its one certificate repeated `count` times. */
export const repeatedCertificateSignedData = (count: number): Buffer => {
const fixture = pkcs7SignedDataFixture();
const raw = (node: { start: number; end: number } | undefined): Buffer => {
if (node === undefined) throw new Error("Unexpected fixture layout");
return fixture.subarray(node.start, node.end);
};
const [type, explicit] = readAsn1(fixture).children;
const [version, digests, encapsulated, certificates, signers] =
explicit?.children[0]?.children ?? [];
const certificate = raw(certificates?.children[0]);
return derValue(
0x30,
raw(type),
derValue(
0xa0,
derValue(
0x30,
raw(version),
raw(digests),
raw(encapsulated),
derValue(0xa0, ...Array.from({ length: count }, () => certificate)),
raw(signers),
),
),
);
};