fix(pwntools): let callers widen the artifact input bound (#2486)
* fix(pwntools): let callers widen the artifact input bound The pwntools adapters rejected every artifact over 32 MiB before any analysis ran, and nothing in configuration, environment, CLI or MCP could raise that bound, so real recorded core dumps stayed unanalyzable. Resolve the bound from REA_PWNTOOLS_MAX_INPUT_BYTES in front of the shipped default. An absent value keeps 32 MiB, and a value outside 1..1073741824 bytes is refused as unusable provider configuration before the artifact is read instead of falling back silently. * fix(pwntools): name the bound variable in its own refusal An unusable REA_PWNTOOLS_MAX_INPUT_BYTES travelled through the shared engine-absence helper, which always stamped configuration_key REA_PWNTOOLS_PYTHON, so a caller following that field was pointed at the Python executable instead of the bound it had just set. The guide also described the refusal as an invalid request while the decoder reports provider_unavailable. Let the failure context carry the key under review and pass the bound's own name at that call site, then state the code callers actually receive in the guide. Assert the key on the projected rejection so the engine default and the bound override each stay attributed to the right setting. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
parent
59d33a2733
commit
c44d17fd3c
|
|
@ -98,8 +98,10 @@ section, symbol, mitigation or linkage views. The default `complete` detail and
|
|||
CLI output are unchanged.
|
||||
|
||||
Complete results have a 32 MiB input, 64 MiB reply, 1 MiB combined diagnostics
|
||||
and 30-second owned command deadline. The reply budget applies to the decoder
|
||||
record; the CLI/MCP Evidence envelope adds copies and encoding overhead. The
|
||||
and 30-second owned command deadline. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the
|
||||
input bound alone, up to 1 GiB; the reply and deadline bounds are unchanged. The
|
||||
reply budget applies to the decoder record; the CLI/MCP Evidence envelope adds
|
||||
copies and encoding overhead. The
|
||||
pinned MCP SDK defaults to a 10 MiB receive buffer. Large complete results need
|
||||
a caller-configured `StdioClientTransport({ maxBufferSize: ... })` and a matching
|
||||
server `REA_MCP_MAX_RESPONSE_BYTES`. See [MCP delivery budgets](mcp-contracts.md#tool-results).
|
||||
|
|
|
|||
|
|
@ -72,9 +72,13 @@ debugger context is `observed`.
|
|||
|
||||
Initial Linux x64 limits: 32 MiB input, 64 MiB decoder reply, 1 MiB diagnostics per
|
||||
stage, 30 seconds owned wall deadline per stage, 3 GiB address-space soft ceiling
|
||||
and 30 seconds CPU soft ceiling. Tighter inherited soft/hard limits are retained;
|
||||
hard limits are not raised. Complete oversized results fail explicitly. These
|
||||
process limits do not constitute a sandbox for caller-supplied engine code.
|
||||
and 30 seconds CPU soft ceiling. `REA_PWNTOOLS_MAX_INPUT_BYTES` raises the input
|
||||
bound alone, up to 1 GiB, and returns `provider_unavailable` when it is not a
|
||||
decimal integer in range; the reply, diagnostic and deadline bounds stay fixed, so a
|
||||
large core that outruns them still fails explicitly instead of partially. Tighter
|
||||
inherited soft/hard limits are retained; hard limits are not raised. Complete
|
||||
oversized results fail explicitly. These process limits do not constitute a sandbox
|
||||
for caller-supplied engine code.
|
||||
|
||||
Resource exit statuses need matching private failure-branch markers. Unmarked
|
||||
launcher failures retain their actual status/output and unknown resource cause.
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
PWNTOOLS_PROVIDER_IDENTITY,
|
||||
PWNTOOLS_LIMITS,
|
||||
} from "./PwntoolsRelease.js";
|
||||
import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js";
|
||||
|
||||
const HOST_REQUIREMENT =
|
||||
"The initial real-verified pwntools artifact profile supports Linux x64 only.";
|
||||
|
|
@ -245,11 +246,23 @@ export class PwntoolsDecoder<
|
|||
undefined,
|
||||
context,
|
||||
);
|
||||
const inputLimit = pwntoolsInputBytes(this.environment);
|
||||
if (!inputLimit.configured)
|
||||
throw pwntoolsUnavailable(
|
||||
inputLimit.error,
|
||||
selectedPath,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
...context,
|
||||
configurationKey: "REA_PWNTOOLS_MAX_INPUT_BYTES",
|
||||
},
|
||||
);
|
||||
phase = "artifact-read";
|
||||
selectedPath = input.path;
|
||||
const snapshot = await readStableArtifact(
|
||||
input.path,
|
||||
PWNTOOLS_LIMITS.inputBytes,
|
||||
inputLimit.bytes,
|
||||
options?.signal,
|
||||
undefined,
|
||||
this.#resources,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface PwntoolsFailureContext {
|
|||
readonly operation: string;
|
||||
readonly providerId: string;
|
||||
readonly precedingOutput?: AnalysisCapturedOutput;
|
||||
readonly configurationKey?: string;
|
||||
}
|
||||
const DEFAULT_CONTEXT = {
|
||||
operation: "inspect_binary_layout",
|
||||
|
|
@ -274,7 +275,7 @@ export const pwntoolsUnavailable = (
|
|||
code: "provider_unavailable",
|
||||
reason,
|
||||
diagnostics: {
|
||||
configuration_key: "REA_PWNTOOLS_PYTHON",
|
||||
configuration_key: context.configurationKey ?? "REA_PWNTOOLS_PYTHON",
|
||||
executable_path: path,
|
||||
...(systemCode === undefined ? {} : { system_code: systemCode }),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js";
|
||||
import { pwntoolsUnavailable } from "./PwntoolsFailures.js";
|
||||
import {
|
||||
PWNTOOLS_LIMITS,
|
||||
PWNTOOLS_PROVIDER_IDENTITY,
|
||||
} from "./PwntoolsRelease.js";
|
||||
|
||||
const CONSTRAINT =
|
||||
"REA_PWNTOOLS_MAX_INPUT_BYTES must be a decimal integer from 1 to " +
|
||||
"1073741824 bytes, and REA never installs Python or packages.";
|
||||
|
||||
describe("pwntools input bound", () => {
|
||||
it("keeps the shipped default when the override is absent", () => {
|
||||
expect(pwntoolsInputBytes({})).toEqual({
|
||||
configured: true,
|
||||
bytes: PWNTOOLS_LIMITS.inputBytes,
|
||||
});
|
||||
expect(PWNTOOLS_LIMITS.inputBytes).toBe(32 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["1", 1],
|
||||
["67108864", 64 * 1024 * 1024],
|
||||
["172879872", 172_879_872],
|
||||
["1073741824", 1024 * 1024 * 1024],
|
||||
] as const)("accepts %s as %s bytes", (value, expected) => {
|
||||
expect(pwntoolsInputBytes({ REA_PWNTOOLS_MAX_INPUT_BYTES: value })).toEqual(
|
||||
{ configured: true, bytes: expected },
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"0",
|
||||
"-1",
|
||||
"33554432.0",
|
||||
"1e8",
|
||||
" 67108864",
|
||||
"67_108_864",
|
||||
"0x2000000",
|
||||
"1073741825",
|
||||
"99999999999999999999",
|
||||
"not-a-number",
|
||||
])("rejects %s instead of widening the bound silently", (value) => {
|
||||
expect(pwntoolsInputBytes({ REA_PWNTOOLS_MAX_INPUT_BYTES: value })).toEqual(
|
||||
{
|
||||
configured: false,
|
||||
error: CONSTRAINT,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a value above the ceiling that a float would round", () => {
|
||||
const ceiling = pwntoolsInputBytes({
|
||||
REA_PWNTOOLS_MAX_INPUT_BYTES: "1073741824",
|
||||
});
|
||||
const above = pwntoolsInputBytes({
|
||||
REA_PWNTOOLS_MAX_INPUT_BYTES: "10737418241",
|
||||
});
|
||||
expect(ceiling.configured).toBe(true);
|
||||
expect(above).toEqual({ configured: false, error: CONSTRAINT });
|
||||
});
|
||||
|
||||
it("never narrows the bound below the shipped default implicitly", () => {
|
||||
const accepted = [1, 1024, PWNTOOLS_LIMITS.inputBytes].map((bytes) =>
|
||||
pwntoolsInputBytes({ REA_PWNTOOLS_MAX_INPUT_BYTES: String(bytes) }),
|
||||
);
|
||||
expect(accepted.every((limit) => limit.configured)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pwntools unavailability configuration key", () => {
|
||||
const context = {
|
||||
operation: "inspect_binary_layout",
|
||||
providerId: PWNTOOLS_PROVIDER_IDENTITY.id,
|
||||
};
|
||||
const reportedKey = (configurationKey?: string) =>
|
||||
pwntoolsUnavailable(
|
||||
CONSTRAINT,
|
||||
"/caller-selected/python",
|
||||
undefined,
|
||||
undefined,
|
||||
configurationKey === undefined
|
||||
? context
|
||||
: { ...context, configurationKey },
|
||||
).rejections[0]?.diagnostics["configuration_key"];
|
||||
|
||||
it("keeps the engine key unless the failure is about the bound", () => {
|
||||
expect(reportedKey()).toBe("REA_PWNTOOLS_PYTHON");
|
||||
expect(reportedKey("REA_PWNTOOLS_MAX_INPUT_BYTES")).toBe(
|
||||
"REA_PWNTOOLS_MAX_INPUT_BYTES",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js";
|
||||
|
||||
// The artifact is read into one in-memory buffer and staged again as a private
|
||||
// snapshot before the owned bridge parses it under a 3 GiB address-space
|
||||
// reservation, so an override stays within a third of that reservation.
|
||||
const MAXIMUM_INPUT_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
/** Optional caller-supplied input bound, in bytes, without a decimal point. */
|
||||
export const pwntoolsInputBytesSchema = z
|
||||
.string()
|
||||
.regex(/^[1-9]\d*$/u, "must be a positive decimal integer")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().safe().max(MAXIMUM_INPUT_BYTES));
|
||||
|
||||
const CONSTRAINT =
|
||||
`REA_PWNTOOLS_MAX_INPUT_BYTES must be a decimal integer from 1 to ` +
|
||||
`${String(MAXIMUM_INPUT_BYTES)} bytes, and REA never installs Python or packages.`;
|
||||
|
||||
/** Resolved input bound, or the reason the caller's override cannot be used. */
|
||||
export type PwntoolsInputBytes =
|
||||
| { readonly configured: true; readonly bytes: number }
|
||||
| { readonly configured: false; readonly error: string };
|
||||
|
||||
/**
|
||||
* Resolve the admitted artifact size for the pwntools adapters. An unset value
|
||||
* keeps the shipped default, so the bound is only ever widened deliberately.
|
||||
*/
|
||||
export const pwntoolsInputBytes = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): PwntoolsInputBytes => {
|
||||
const value = environment.REA_PWNTOOLS_MAX_INPUT_BYTES;
|
||||
if (value === undefined)
|
||||
return { configured: true, bytes: PWNTOOLS_LIMITS.inputBytes };
|
||||
const parsed = pwntoolsInputBytesSchema.safeParse(value);
|
||||
return parsed.success
|
||||
? { configured: true, bytes: parsed.data }
|
||||
: { configured: false, error: CONSTRAINT };
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { open } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { PwntoolsLayoutProvider } from "../../../src/native/pwntools/PwntoolsLayoutProvider.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const unsupportedHost = process.platform !== "linux" || process.arch !== "x64";
|
||||
const DEFAULT_INPUT_BYTES = 33_554_432;
|
||||
// One byte above the shipped bound, so the admission decision is the only
|
||||
// difference between the first two cases.
|
||||
const ARTIFACT_BYTES = DEFAULT_INPUT_BYTES + 1;
|
||||
|
||||
/** Sparse regular file just larger than the shipped input bound. */
|
||||
const oversizedArtifact = async (): Promise<string> => {
|
||||
const root = await createTestTempDirectory("rea-pwntools-input-bound-");
|
||||
const path = join(root, "recorded-core");
|
||||
const handle = await open(path, "w");
|
||||
await handle.truncate(ARTIFACT_BYTES);
|
||||
await handle.close();
|
||||
return path;
|
||||
};
|
||||
|
||||
/** One admission attempt, reported as the projected caller-visible failure. */
|
||||
const inspect = async (override?: string) => {
|
||||
const path = await oversizedArtifact();
|
||||
const provider = new PwntoolsLayoutProvider(
|
||||
{
|
||||
REA_PWNTOOLS_PYTHON: process.execPath,
|
||||
...(override === undefined
|
||||
? {}
|
||||
: { REA_PWNTOOLS_MAX_INPUT_BYTES: override }),
|
||||
},
|
||||
async (spawn) =>
|
||||
spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: ["-e", "process.stdout.write('no reply')"],
|
||||
}),
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
await provider.close();
|
||||
});
|
||||
const result = await provider.inspect({ path });
|
||||
if (result.ok) throw new Error("Expected the bounded decoder to fail");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
return { projected, text: JSON.stringify(projected) };
|
||||
};
|
||||
|
||||
it.runIf(!unsupportedHost)(
|
||||
"refuses an artifact above the shipped bound when no override is set",
|
||||
async () => {
|
||||
const { projected, text } = await inspect();
|
||||
expect(projected.code).toBe("invalid_request");
|
||||
expect(text).toContain(
|
||||
`Selected artifact exceeds ${String(DEFAULT_INPUT_BYTES)} bytes`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost)(
|
||||
"admits the same artifact once the override widens the bound",
|
||||
async () => {
|
||||
const { projected, text } = await inspect(String(ARTIFACT_BYTES));
|
||||
// Admission passed, so the only remaining failure is that the owned decoder
|
||||
// never wrote a reply, which also proves the process really ran.
|
||||
expect(text).not.toContain("Selected artifact exceeds");
|
||||
expect(projected.code).toBe("unreadable_output");
|
||||
expect(text).toContain("no reply");
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost)(
|
||||
"refuses an unusable override before reading the artifact",
|
||||
async () => {
|
||||
const { projected, text } = await inspect("0");
|
||||
expect(projected.code).toBe("provider_unavailable");
|
||||
expect(projected.details).toMatchObject({
|
||||
rejections: [
|
||||
{ diagnostics: { configuration_key: "REA_PWNTOOLS_MAX_INPUT_BYTES" } },
|
||||
],
|
||||
});
|
||||
expect(text).toContain("REA_PWNTOOLS_MAX_INPUT_BYTES must be a decimal");
|
||||
expect(text).not.toContain("Selected artifact exceeds");
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue