mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
1 parent
fd297b1b17
commit
8c83886891
7 files changed
+216
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user