diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs index 3ca254d7ed..78a3958e53 100644 --- a/scripts/verify/architecture-guards.mjs +++ b/scripts/verify/architecture-guards.mjs @@ -89,23 +89,49 @@ check( ["src/domain/javascript/javascriptSemanticTraversal.ts"], ); -// A producer that pins both the soft and hard ceiling of every resource must take -// the vector from one owner. Audited 2026-10-13 against the other prlimit lanes: -// src/pcap/PcapNativeBound.ts pairs soft and hard but budgets 300 CPU seconds and -// has no resident-input ceiling; src/evm/EvmoleInterfaceProvider.ts, -// src/firmware/FirmwareCommand.ts and src/javascript/recovery/WakaruCommand.ts -// spell soft-only forms (`--core=0:`, `--core=0`). Those four stay as they are; -// a new paired producer calls prlimitArgumentVector instead of spelling options. +// A producer that pins both the soft and hard ceiling of every resource takes +// the vector from one owner. prlimitCeilingArguments is the command-less prefix +// of prlimitArgumentVector; both live in src/process/LinuxResourceLimits.ts. +// Re-audited 2026-10-22 with util-linux 2.39.3: a bare value sets both soft and +// hard, the same as soft:hard when the numbers are equal. `soft:` leaves the +// inherited hard ceiling unchanged. +// FirmwareCommand and WakaruCommand call prlimitArgumentVector and keep their +// own file-size and CPU budgets (120 CPU seconds; firmware file size is the +// call output budget or the 8 MiB report allowance; Wakaru file size is the +// 128 MiB output allowance). Their 1 GiB address space is +// BOUNDED_PRODUCER_LIMITS.address_space_bytes. PcapNativeBound calls +// prlimitCeilingArguments because PcapCommand appends the producer command. +// Its CPU ceiling stays 300 seconds and it has no resident-input budget; +// address space and file size use those shared fields. +// selectEvmWorkerLimits stays a separate selector: Result failures, unit +// checks, and a refusal when soft exceeds hard. It must not call +// clampLinuxResourceLimits. check( "inline paired prlimit ceilings (use prlimitArgumentVector from src/process/LinuxResourceLimits)", files, /--core=0:0/, - ["src/process/LinuxResourceLimits.ts", "src/pcap/PcapNativeBound.ts"], + ["src/process/LinuxResourceLimits.ts"], ); -// Census is spelling-shaped, like the rules above: a lane that expresses the same -// ceilings differently (`1024 ** 3`, camelCase fields) is not caught here, which is -// why the four lanes above are listed rather than left to a future reader. +check( + "bare prlimit value (a bare value sets both ceilings; use prlimitArgumentVector)", + files, + /--core=0(?!:)/, +); + +// 2026-10-22: soft-only `resource:` options retain the inherited hard ceiling. +// EVMole budgets differ (3 GiB address space, 16 MiB file, 30 CPU seconds), so +// this lane must not call prlimitArgumentVector. +check( + "soft-only prlimit ceilings (paired producers use prlimitArgumentVector)", + files, + /--core=0:(?!0)/, + ["src/evm/EvmoleInterfaceProvider.ts"], +); + +// Resident input is the input_bytes field of BOUNDED_PRODUCER_LIMITS. CamelCase +// inputBytes values with a different policy (an overridable pwntools admission, +// historical capture intake) are not this budget. check( "inline resident-input budget (use BOUNDED_PRODUCER_LIMITS from src/process/LinuxResourceLimits)", files, diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index a3ca7ff68a..8e99f2d49f 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -23,6 +23,8 @@ import { firmwareProcessLimitRecovery, firmwareSignalLimitError, } from "./FirmwareFailures.js"; +import { prlimitArgumentVector } from "../process/LinuxResourceLimits.js"; +import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; import { spawnOwnedProviderProcess, ProviderProcessSupervisor, @@ -30,7 +32,6 @@ import { type ProviderProcessSnapshot, type SpawnedOwnedProviderProcess, } from "../process/ProviderProcess.js"; -import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; import { FIRMWARE_LIMITS } from "./FirmwareRelease.js"; import { FirmwareFileAccess, @@ -159,12 +160,15 @@ export const runFirmwareCommand = async (context: { const spawned = await (context.launcher ?? spawnOwnedProviderProcess)({ command: context.limiter, arguments: [ - `--as=${FIRMWARE_LIMITS.addressSpaceBytes}`, - `--fsize=${context.outputBudget?.bytes ?? FIRMWARE_LIMITS.reportBytes}`, - "--core=0", - `--cpu=${FIRMWARE_LIMITS.cpuSeconds}`, - "--", - context.command, + ...prlimitArgumentVector( + { + address_space_bytes: FIRMWARE_LIMITS.addressSpaceBytes, + file_bytes: + context.outputBudget?.bytes ?? FIRMWARE_LIMITS.reportBytes, + cpu_seconds: FIRMWARE_LIMITS.cpuSeconds, + }, + context.command, + ), ...context.args, ], expectedCommand: null, diff --git a/src/firmware/FirmwareRelease.ts b/src/firmware/FirmwareRelease.ts index 7a62ae98b7..67b4a46ae6 100644 --- a/src/firmware/FirmwareRelease.ts +++ b/src/firmware/FirmwareRelease.ts @@ -1,3 +1,5 @@ +import { BOUNDED_PRODUCER_LIMITS } from "../process/LinuxResourceLimits.js"; + /** Audited upstream sources; installed tool bytes are fingerprinted separately. */ export const FIRMWARE_RELEASES = { binwalk: { @@ -30,6 +32,6 @@ export const FIRMWARE_LIMITS = { inputBytes: 128 * 1024 * 1024, reportBytes: 8 * 1024 * 1024, diagnosticBytes: 8 * 1024 * 1024, - addressSpaceBytes: 1024 * 1024 * 1024, + addressSpaceBytes: BOUNDED_PRODUCER_LIMITS.address_space_bytes, cpuSeconds: 120, } as const; diff --git a/src/javascript/recovery/WakaruCommand.test.ts b/src/javascript/recovery/WakaruCommand.test.ts index fd88254976..460847d7fa 100644 --- a/src/javascript/recovery/WakaruCommand.test.ts +++ b/src/javascript/recovery/WakaruCommand.test.ts @@ -11,6 +11,7 @@ import { runWakaruCommand, type WakaruLauncher, } from "./WakaruCommand.js"; +import { prlimitArgumentVector } from "../../process/LinuxResourceLimits.js"; import { RECOVERY_LIMITS } from "./WakaruRelease.js"; it("names the Wakaru configuration requirement when it is not configured", async () => { @@ -182,6 +183,52 @@ it("enforces the shared deadline while exited output streams remain open", async } }); +it("pins both Wakaru ceilings through the shared prlimit vector", async () => { + let observed: readonly string[] | undefined; + const producer = createProducer(); + const command = "/selected/wakaru"; + await expect( + runWakaruCommand({ + command, + limiter: "/usr/bin/prlimit", + arguments: ["--version"], + cwd: "/tmp/unused", + environment: {}, + deadline: performance.now() + 5_000, + launcher: async (options) => { + observed = options.arguments; + setImmediate(() => { + producer.stdout.end(); + producer.stderr.end(); + producer.emit("exit", 0, null); + producer.emit("close", 0, null); + }); + return { + process: producer, + ownership: { + runId: options.runId, + leaderPid: 12345, + processGroupId: 12345, + expectedParentPid: process.pid, + }, + cleanup: async () => ({ cleaned: true, signaled: false }), + }; + }, + }), + ).resolves.toMatchObject({ exit_code: 0 }); + expect(observed).toEqual([ + ...prlimitArgumentVector( + { + address_space_bytes: RECOVERY_LIMITS.addressSpaceBytes, + file_bytes: RECOVERY_LIMITS.outputBytes, + cpu_seconds: RECOVERY_LIMITS.cpuSeconds, + }, + command, + ), + "--version", + ]); +}); + it("honors cancellation while waiting for output after exit", async () => { const controller = new AbortController(); const { context, producer } = commandFixture((process) => { diff --git a/src/javascript/recovery/WakaruCommand.ts b/src/javascript/recovery/WakaruCommand.ts index 3ec17a9c8e..2cfe86beba 100644 --- a/src/javascript/recovery/WakaruCommand.ts +++ b/src/javascript/recovery/WakaruCommand.ts @@ -9,6 +9,7 @@ import { } from "../../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { prlimitArgumentVector } from "../../process/LinuxResourceLimits.js"; import { cleanupOwnedProcessGroup } from "../../process/ProcessOwnership.js"; import { ProviderProcessSupervisor, @@ -118,12 +119,14 @@ export const runWakaruCommand = async (context: WakaruCommandContext) => { const spawned = await (context.launcher ?? spawnOwnedProviderProcess)({ command: context.limiter, arguments: [ - `--as=${String(RECOVERY_LIMITS.addressSpaceBytes)}`, - `--fsize=${String(RECOVERY_LIMITS.outputBytes)}`, - "--core=0", - "--cpu=120", - "--", - context.command, + ...prlimitArgumentVector( + { + address_space_bytes: RECOVERY_LIMITS.addressSpaceBytes, + file_bytes: RECOVERY_LIMITS.outputBytes, + cpu_seconds: RECOVERY_LIMITS.cpuSeconds, + }, + context.command, + ), ...context.arguments, ], expectedCommand: null, diff --git a/src/javascript/recovery/WakaruRelease.ts b/src/javascript/recovery/WakaruRelease.ts index 561d5b48b8..399769d5b0 100644 --- a/src/javascript/recovery/WakaruRelease.ts +++ b/src/javascript/recovery/WakaruRelease.ts @@ -1,3 +1,5 @@ +import { BOUNDED_PRODUCER_LIMITS } from "../../process/LinuxResourceLimits.js"; + /** Unchanged upstream source pin; executed bytes are identified separately. */ export const WAKARU_RELEASE = { version: "1.14.0", @@ -34,7 +36,8 @@ export const RECOVERY_LIMITS = { outputBytes: 128 * 1024 * 1024, outputEntries: 10000, reportBytes: 8 * 1024 * 1024, - addressSpaceBytes: 1024 * 1024 * 1024, + addressSpaceBytes: BOUNDED_PRODUCER_LIMITS.address_space_bytes, + cpuSeconds: 120, timeoutMs: 120000, } as const; diff --git a/src/pcap/PcapNativeBound.test.ts b/src/pcap/PcapNativeBound.test.ts new file mode 100644 index 0000000000..6af01f9144 --- /dev/null +++ b/src/pcap/PcapNativeBound.test.ts @@ -0,0 +1,66 @@ +import { chmod, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import type { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js"; +import { + BOUNDED_PRODUCER_LIMITS, + prlimitCeilingArguments, + type LinuxResourceLimits, +} from "../process/LinuxResourceLimits.js"; +import { preparePcapNativeBound } from "./PcapNativeBound.js"; + +const resources = { + readLimits: (limits: LinuxResourceLimits) => Promise.resolve(limits), +} as ArtifactAnalysisResources; + +const withPlatform = async ( + platform: NodeJS.Platform, + run: () => Promise, +) => { + const previousPlatform = process.platform; + Object.defineProperty(process, "platform", { + configurable: true, + value: platform, + }); + try { + await run(); + } finally { + Object.defineProperty(process, "platform", { + configurable: true, + value: previousPlatform, + }); + } +}; + +it("uses the shared 1 GiB ceiling for the Windows job memory limit", async () => { + await withPlatform("win32", async () => { + await expect(preparePcapNativeBound({}, resources)).resolves.toMatchObject({ + nativeBound: { + memoryLimitBytes: BOUNDED_PRODUCER_LIMITS.address_space_bytes, + }, + }); + }); +}); + +it("pins capture ceilings through the shared prlimit prefix and keeps 300 CPU seconds", async () => { + const root = await createTestTempDirectory("rea-pcap-prlimit-"); + const limiter = join(root, "prlimit"); + await writeFile(limiter, "#!/bin/sh\n"); + await chmod(limiter, 0o700); + const limits = { + address_space_bytes: BOUNDED_PRODUCER_LIMITS.address_space_bytes, + file_bytes: BOUNDED_PRODUCER_LIMITS.file_bytes, + cpu_seconds: 300, + }; + await withPlatform("linux", async () => { + const prepared = await preparePcapNativeBound( + { REA_TSHARK_PRLIMIT_COMMAND: limiter }, + resources, + ); + expect(prepared.nativeBound.limiter?.arguments).toEqual( + prlimitCeilingArguments(limits), + ); + expect(prepared.limitations[0]).toContain(String(limits.cpu_seconds)); + }); +}); diff --git a/src/pcap/PcapNativeBound.ts b/src/pcap/PcapNativeBound.ts index dc98bfca07..75ef19baec 100644 --- a/src/pcap/PcapNativeBound.ts +++ b/src/pcap/PcapNativeBound.ts @@ -4,9 +4,15 @@ import { isAbsolute } from "node:path"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import type { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js"; +import { + BOUNDED_PRODUCER_LIMITS, + prlimitCeilingArguments, +} from "../process/LinuxResourceLimits.js"; import type { runPcapCommand } from "./PcapCommand.js"; const PRLIMIT_COMMAND_ENV = "REA_TSHARK_PRLIMIT_COMMAND"; +/** Capture CPU ceiling, in seconds. */ +const PCAP_CPU_SECONDS = 300; /** Own native capture admission and the exact enforced resource profile. */ export const preparePcapNativeBound = async ( @@ -18,7 +24,9 @@ export const preparePcapNativeBound = async ( }> => { if (process.platform === "win32") { return { - nativeBound: { memoryLimitBytes: 1024 ** 3 }, + nativeBound: { + memoryLimitBytes: BOUNDED_PRODUCER_LIMITS.address_space_bytes, + }, limitations: [ "Each owned Windows tool tree has a 1 GiB Job Object committed-memory limit; this is not a machine-wide quota.", ], @@ -41,20 +49,14 @@ export const preparePcapNativeBound = async ( const limiter = await realpath(configured); await access(limiter, constants.X_OK); const limits = await resources.readLimits({ - address_space_bytes: 1024 ** 3, - file_bytes: 64 * 1024 ** 2, - cpu_seconds: 300, + address_space_bytes: BOUNDED_PRODUCER_LIMITS.address_space_bytes, + file_bytes: BOUNDED_PRODUCER_LIMITS.file_bytes, + cpu_seconds: PCAP_CPU_SECONDS, }); const nativeBound = { limiter: { command: limiter, - arguments: [ - `--as=${limits.address_space_bytes}:${limits.address_space_bytes}`, - `--fsize=${limits.file_bytes}:${limits.file_bytes}`, - `--cpu=${limits.cpu_seconds}:${limits.cpu_seconds}`, - "--core=0:0", - "--", - ], + arguments: prlimitCeilingArguments(limits), }, }; const limitations = [ diff --git a/src/process/LinuxResourceLimits.test.ts b/src/process/LinuxResourceLimits.test.ts index f342638add..cc09974fb6 100644 --- a/src/process/LinuxResourceLimits.test.ts +++ b/src/process/LinuxResourceLimits.test.ts @@ -11,6 +11,7 @@ import { BOUNDED_PRODUCER_LIMITS, clampLinuxResourceLimits, prlimitArgumentVector, + prlimitCeilingArguments, readLinuxResourceLimits, } from "./LinuxResourceLimits.js"; @@ -87,9 +88,11 @@ it("pins the shared producer budget in one place", () => { }); }); it("pairs each effective ceiling with itself before the producer argv", () => { - expect( - prlimitArgumentVector(BOUNDED_PRODUCER_LIMITS, "/bin/producer"), - ).toEqual([ + const vector = prlimitArgumentVector( + BOUNDED_PRODUCER_LIMITS, + "/bin/producer", + ); + expect(vector).toEqual([ "--as=1073741824:1073741824", "--fsize=67108864:67108864", "--cpu=30:30", @@ -97,6 +100,9 @@ it("pairs each effective ceiling with itself before the producer argv", () => { "--", "/bin/producer", ]); + expect(prlimitCeilingArguments(BOUNDED_PRODUCER_LIMITS)).toEqual( + vector.slice(0, -1), + ); expect( prlimitArgumentVector( clampLinuxResourceLimits(BOUNDED_PRODUCER_LIMITS, report("10 20")), diff --git a/src/process/LinuxResourceLimits.ts b/src/process/LinuxResourceLimits.ts index a11a7f410d..12da2b80cf 100644 --- a/src/process/LinuxResourceLimits.ts +++ b/src/process/LinuxResourceLimits.ts @@ -24,19 +24,26 @@ export const BOUNDED_PRODUCER_LIMITS = { cpu_seconds: 30, } as const; -/** Pin soft and hard ceilings together, then hand the producer argv to the limiter. */ -export const prlimitArgumentVector = ( +/** + * Pin soft and hard ceilings together, ending at the prlimit option terminator. + * Callers that append the producer command themselves use this prefix. + */ +export const prlimitCeilingArguments = ( limits: LinuxResourceLimits, - command: string, ): string[] => [ `--as=${limits.address_space_bytes}:${limits.address_space_bytes}`, `--fsize=${limits.file_bytes}:${limits.file_bytes}`, `--cpu=${limits.cpu_seconds}:${limits.cpu_seconds}`, "--core=0:0", "--", - command, ]; +/** Pin soft and hard ceilings together, then hand the producer argv to the limiter. */ +export const prlimitArgumentVector = ( + limits: LinuxResourceLimits, + command: string, +): string[] => [...prlimitCeilingArguments(limits), command]; + /** Never raise either inherited soft policy or hard ceiling. */ export const clampLinuxResourceLimits = ( configured: LinuxResourceLimits, diff --git a/tests/boundary/firmware/firmwareIntegration.test.ts b/tests/boundary/firmware/firmwareIntegration.test.ts index 13cfadb00d..0aeb8d0492 100644 --- a/tests/boundary/firmware/firmwareIntegration.test.ts +++ b/tests/boundary/firmware/firmwareIntegration.test.ts @@ -7,6 +7,7 @@ import { } from "../../fixtures/firmware/provider.js"; import { firmwareResultSchemas } from "../../../src/domain/firmware/firmwareAnalysis.js"; import { FirmwareAnalysisService } from "../../../src/application/firmware/FirmwareAnalysisService.js"; +import { FIRMWARE_LIMITS } from "../../../src/firmware/FirmwareRelease.js"; import { FirmwareProvider } from "../../../src/firmware/FirmwareProvider.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; @@ -109,7 +110,11 @@ it("binds region observations to exact bytes and preserves provider-reported unc report: [{ Analysis: { file_map: [{ id: "hit" }] } }], }); expect(fixture.launches[1]?.args).toEqual( - expect.arrayContaining(["--as=1073741824", "--threads", "1"]), + expect.arrayContaining([ + `--as=${String(FIRMWARE_LIMITS.addressSpaceBytes)}:${String(FIRMWARE_LIMITS.addressSpaceBytes)}`, + "--threads", + "1", + ]), ); await assertFirmwareCleanup(fixture.launches); }); diff --git a/tests/process-global/firmwarePublicationDeadline.test.ts b/tests/process-global/firmwarePublicationDeadline.test.ts index 88dbf433c6..762b229cf9 100644 --- a/tests/process-global/firmwarePublicationDeadline.test.ts +++ b/tests/process-global/firmwarePublicationDeadline.test.ts @@ -9,6 +9,7 @@ import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.j import { ProviderProcessSupervisor } from "../../src/process/ProviderProcess.js"; import { finishAnalysisCommand } from "../../src/cli/finishAnalysisCommand.js"; import { AnalysisOutputError } from "../../src/domain/analysisErrorCore.js"; +import { FIRMWARE_LIMITS } from "../../src/firmware/FirmwareRelease.js"; import { firmwareFixture, assertFirmwareCleanup, @@ -279,7 +280,11 @@ test("uses the selected phase deadline inside an owned command beyond the former }); expect(fixture.launches).toHaveLength(2); expect( - fixture.launches.every((launch) => launch.args.includes("--cpu=120")), + fixture.launches.every((launch) => + launch.args.includes( + `--cpu=${String(FIRMWARE_LIMITS.cpuSeconds)}:${String(FIRMWARE_LIMITS.cpuSeconds)}`, + ), + ), ).toBe(true); await fixture.close(); await assertFirmwareCleanup(fixture.launches);