From 78a1e1956e817f26d6e66dd06da211bf71483040 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:23:07 +0800 Subject: [PATCH] fix(pcap): validate selected resource limiter before capture (#7947) * fix(pcap): validate selected resource limiter before capture * fix(pcap): verify limiter capabilities and preserve probe output --- src/pcap/PcapNativeBound.test.ts | 179 +++++++++++++++++- src/pcap/PcapNativeBound.ts | 99 ++++++++-- src/pcap/PcapProvider.ts | 24 ++- tests/boundary/pcap/pcapProvider.test.ts | 88 ++++++++- .../process/artifactSelectedCommand.test.ts | 10 + 5 files changed, 375 insertions(+), 25 deletions(-) diff --git a/src/pcap/PcapNativeBound.test.ts b/src/pcap/PcapNativeBound.test.ts index 6af01f9144..684d2d9934 100644 --- a/src/pcap/PcapNativeBound.test.ts +++ b/src/pcap/PcapNativeBound.test.ts @@ -1,8 +1,13 @@ -import { chmod, writeFile } from "node:fs/promises"; +import { chmod, symlink, 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 { + AnalysisCancelledError, + AnalysisArtifactChangedError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; import { BOUNDED_PRODUCER_LIMITS, prlimitCeilingArguments, @@ -10,6 +15,12 @@ import { } from "../process/LinuxResourceLimits.js"; import { preparePcapNativeBound } from "./PcapNativeBound.js"; +const probeResult = (stdout: string, stderr = "") => ({ + stdout, + capturedOutput: { stdout, stderr, truncated: false }, +}); +const help = " --as[=limit] --fsize[=limit] --cpu[=limit] --core[=limit]"; + const resources = { readLimits: (limits: LinuxResourceLimits) => Promise.resolve(limits), } as ArtifactAnalysisResources; @@ -35,7 +46,9 @@ const withPlatform = async ( it("uses the shared 1 GiB ceiling for the Windows job memory limit", async () => { await withPlatform("win32", async () => { - await expect(preparePcapNativeBound({}, resources)).resolves.toMatchObject({ + await expect( + preparePcapNativeBound({}, resources, async () => probeResult("")), + ).resolves.toMatchObject({ nativeBound: { memoryLimitBytes: BOUNDED_PRODUCER_LIMITS.address_space_bytes, }, @@ -43,6 +56,64 @@ it("uses the shared 1 GiB ceiling for the Windows job memory limit", async () => }); }); +it("accepts selected limiter symlinks with different and whitespace-bearing basenames", async () => { + const root = await createTestTempDirectory("rea-prlimit-alias-"); + const executable = join(root, "prlimit"); + await writeFile(executable, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + for (const name of ["bounded-capture", " limiter", " limiter "]) { + const selected = join(root, name); + await symlink(executable, selected); + await withPlatform("linux", async () => { + const bound = await preparePcapNativeBound( + { REA_TSHARK_PRLIMIT_COMMAND: selected }, + resources, + async (selection, args) => { + expect(selection.invocationPath).toBe(selected); + return probeResult( + args[0] === "--help" ? help : `${name} from util-linux 2.38.1\n`, + ); + }, + ); + expect(bound.nativeBound.limiter?.command.invocationPath).toBe(selected); + }); + } +}); + +it("attributes malformed limiter version output while retaining producer evidence", async () => { + const root = await createTestTempDirectory("rea-prlimit-invalid-output-"); + const selected = join(root, "prlimit"); + await writeFile(selected, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + const capturedOutput = { stdout: "\ufffd", stderr: "", truncated: false }; + const failure = new AnalysisOutputError( + "inspect_pcap_capture", + "invalid UTF-8", + { + capturedOutput, + diagnostics: { invalid_utf8_streams: ["stdout"] }, + }, + ); + await withPlatform("linux", async () => { + await expect( + preparePcapNativeBound( + { REA_TSHARK_PRLIMIT_COMMAND: selected }, + resources, + async () => { + throw failure; + }, + ), + ).rejects.toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + capturedOutput, + diagnostics: { + invalid_utf8_streams: ["stdout"], + configuration_key: "REA_TSHARK_PRLIMIT_COMMAND", + configured: selected, + }, + userMessage: expect.stringContaining("unreadable version output"), + }); + }); +}); + 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"); @@ -57,6 +128,10 @@ it("pins capture ceilings through the shared prlimit prefix and keeps 300 CPU se const prepared = await preparePcapNativeBound( { REA_TSHARK_PRLIMIT_COMMAND: limiter }, resources, + async (_selection, args) => + probeResult( + args[0] === "--help" ? help : "prlimit from util-linux 2.38.1\n", + ), ); expect(prepared.nativeBound.limiter?.arguments).toEqual( prlimitCeilingArguments(limits), @@ -64,3 +139,103 @@ it("pins capture ceilings through the shared prlimit prefix and keeps 300 CPU se expect(prepared.limitations[0]).toContain(String(limits.cpu_seconds)); }); }); + +it("rejects a directory or a command that does not identify as util-linux prlimit", async () => { + const root = await createTestTempDirectory("rea-invalid-prlimit-"); + const executable = join(root, "not-prlimit"); + await writeFile(executable, "#!/bin/sh\nexit 0\n"); + await chmod(executable, 0o700); + await withPlatform("linux", async () => { + for (const [path, output] of [ + [root, "prlimit from util-linux 2.38"], + [executable, ""], + ] as const) { + await expect( + preparePcapNativeBound( + { REA_TSHARK_PRLIMIT_COMMAND: path }, + resources, + async () => probeResult(output), + ), + ).rejects.toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + diagnostics: { + configuration_key: "REA_TSHARK_PRLIMIT_COMMAND", + configured: path, + }, + }); + } + }); +}); + +it("preserves cancellation and selected-command identity failures during the probe", async () => { + const root = await createTestTempDirectory("rea-prlimit-probe-failure-"); + const limiter = join(root, "prlimit"); + await writeFile(limiter, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + await withPlatform("linux", async () => { + for (const failure of [ + new AnalysisCancelledError("inspect_pcap_capture"), + new AnalysisArtifactChangedError( + "inspect_pcap_capture", + limiter, + "changed", + ), + new AnalysisOutputError("inspect_pcap_capture", "invalid output", { + cleanup: { reason: "stop failed", resources: ["owned-process"] }, + }), + ]) { + await expect( + preparePcapNativeBound( + { REA_TSHARK_PRLIMIT_COMMAND: limiter }, + resources, + async () => { + throw failure; + }, + ), + ).rejects.toBe(failure); + } + }); +}); + +it("rejects another util-linux command and retains the rejected help output", async () => { + const root = await createTestTempDirectory("rea-prlimit-impostor-"); + const selected = join(root, "lsblk"); + await writeFile(selected, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + const capturedOutput = probeResult( + " --output columns", + "help diagnostic", + ).capturedOutput; + await withPlatform("linux", async () => { + await expect( + preparePcapNativeBound( + { REA_TSHARK_PRLIMIT_COMMAND: selected }, + resources, + async (_selection, args) => + args[0] === "--help" + ? { stdout: capturedOutput.stdout, capturedOutput } + : probeResult("lsblk from util-linux 2.38\n"), + ), + ).rejects.toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + capturedOutput, + }); + }); +}); + +it("retains rejected version output", async () => { + const root = await createTestTempDirectory("rea-prlimit-rejected-version-"); + const selected = join(root, "prlimit"); + await writeFile(selected, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + const output = probeResult("unexpected banner", "diagnostic"); + await withPlatform("linux", async () => { + await expect( + preparePcapNativeBound( + { REA_TSHARK_PRLIMIT_COMMAND: selected }, + resources, + async () => output, + ), + ).rejects.toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + capturedOutput: output.capturedOutput, + }); + }); +}); diff --git a/src/pcap/PcapNativeBound.ts b/src/pcap/PcapNativeBound.ts index d44a015605..04aa08da74 100644 --- a/src/pcap/PcapNativeBound.ts +++ b/src/pcap/PcapNativeBound.ts @@ -3,17 +3,25 @@ import { pcapCaptureHostSupported, PCAP_CAPTURE_UNSUPPORTED_HOST_REASON, } from "../domain/pcap/pcapAnalysis.js"; -import { access } from "node:fs/promises"; +import { access, stat } from "node:fs/promises"; import { constants } from "node:fs"; -import { isAbsolute } from "node:path"; -import { AnalysisError } from "../domain/analysisErrorBase.js"; -import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; +import { basename, isAbsolute } from "node:path"; +import { + AnalysisError, + type AnalysisCapturedOutput, +} from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, +} 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"; +import type { ArtifactCommandPath } from "../process/ArtifactCommandPath.js"; const PRLIMIT_COMMAND_ENV = "REA_TSHARK_PRLIMIT_COMMAND"; /** Capture CPU ceiling, in seconds. */ @@ -23,6 +31,17 @@ const PCAP_CPU_SECONDS = 300; export const preparePcapNativeBound = async ( environment: Readonly>, resources: ArtifactAnalysisResources, + probe: ( + selected: ArtifactCommandPath, + arguments_: string[], + ) => Promise<{ + readonly stdout: string; + readonly capturedOutput: AnalysisCapturedOutput; + }>, + signal?: AbortSignal, + operation: + | "inspect_pcap_client" + | "inspect_pcap_capture" = "inspect_pcap_capture", ): Promise<{ nativeBound: NonNullable[0]["nativeBound"]>; limitations: readonly string[]; @@ -39,19 +58,56 @@ export const preparePcapNativeBound = async ( } if (!pcapCaptureHostSupported(process.platform)) { const reason = PCAP_CAPTURE_UNSUPPORTED_HOST_REASON; - throw new AnalysisCapabilityUnavailableError( - "pcap", - "inspect_pcap_capture", - reason, - { userMessage: reason }, - ); + throw new AnalysisCapabilityUnavailableError("pcap", operation, reason, { + userMessage: reason, + }); } const configured = environment[PRLIMIT_COMMAND_ENV] ?? "/usr/bin/prlimit"; + let probeFailure: AnalysisError | undefined; + let capturedOutput: AnalysisCapturedOutput | undefined; try { if (!isAbsolute(configured)) throw new Error(`${PRLIMIT_COMMAND_ENV} must be absolute.`); - const selection = await selectArtifactCommandPath(configured); + const selection = await selectArtifactCommandPath(configured, signal); + if (!(await stat(selection.resolvedPath)).isFile()) + throw new Error("The selected limiter is not a regular file."); await access(selection.resolvedPath, constants.X_OK); + const readProbe = async (argument: "--version" | "--help") => { + try { + const output = await probe(selection, [argument]); + capturedOutput = output.capturedOutput; + return output.stdout; + } catch (cause) { + if ( + (cause instanceof AnalysisCapabilityUnavailableError || + cause instanceof AnalysisOutputError) && + !cause.cleanupIncomplete + ) { + probeFailure = cause; + throw new Error( + cause instanceof AnalysisOutputError + ? `The selected limiter produced unreadable ${argument === "--version" ? "version" : "help"} output.` + : "The selected limiter could not be run.", + { cause }, + ); + } + throw cause; + } + }; + const version = await readProbe("--version"); + const prefix = `${basename(selection.invocationPath)} from util-linux `; + if ( + !version.startsWith(prefix) || + version.slice(prefix.length).trim() === "" + ) + throw new Error( + "The selected limiter did not report a util-linux prlimit version.", + ); + const help = await readProbe("--help"); + for (const flag of ["--as", "--fsize", "--cpu", "--core"]) { + if (!new RegExp(`(?:^|\\s)${flag}(?:[=\\[\\s,]|$)`, "u").test(help)) + throw new Error(`The selected limiter does not advertise ${flag}.`); + } const limits = await resources.readLimits({ address_space_bytes: BOUNDED_PRODUCER_LIMITS.address_space_bytes, file_bytes: BOUNDED_PRODUCER_LIMITS.file_bytes, @@ -68,17 +124,22 @@ export const preparePcapNativeBound = async ( ]; return { nativeBound, limitations }; } catch (cause) { + if (signal?.aborted === true && !(cause instanceof AnalysisError)) + throw new AnalysisCancelledError(operation, { + cause: signal.reason, + }); if (cause instanceof AnalysisError) throw cause; const reason = "Cannot establish the selected Linux resource limiter before processing capture bytes."; - throw new AnalysisCapabilityUnavailableError( - "pcap", - "inspect_pcap_capture", - reason, - { - cause, - userMessage: `${reason} (${cause instanceof Error ? cause.message : String(cause)}) Check that ${PRLIMIT_COMMAND_ENV} (default /usr/bin/prlimit) names an absolute, executable util-linux prlimit.`, + throw new AnalysisCapabilityUnavailableError("pcap", operation, reason, { + cause, + capturedOutput: probeFailure?.capturedOutput ?? capturedOutput, + diagnostics: { + ...probeFailure?.diagnostics, + configuration_key: PRLIMIT_COMMAND_ENV, + configured, }, - ); + userMessage: `${reason} (${cause instanceof Error ? cause.message : String(cause)}) Check that ${PRLIMIT_COMMAND_ENV} (default /usr/bin/prlimit) names an absolute, executable util-linux prlimit.`, + }); } }; diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index 3f1d7d235e..521c9ba6ec 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -414,10 +414,28 @@ export class PcapProvider { private nativeBound: Parameters[0]["nativeBound"]; private nativeLimitations: readonly string[] = []; - private async prepareCaptureBound(): Promise { + private async prepareCaptureBound( + operation: PcapOperation, + signal?: AbortSignal, + ): Promise { const prepared = await preparePcapNativeBound( this.environment, this.#resources, + async (selected, arguments_) => { + await assertArtifactCommandPath(selected, operation, { signal }); + const output = await this.run( + operation, + "version", + selected.invocationPath, + arguments_, + { timeoutMs: 20_000, maxOutputBytes: 64 * 1024 }, + signal, + ); + await assertArtifactCommandPath(selected, operation, { signal }); + return output; + }, + signal, + operation, ); this.nativeBound = prepared.nativeBound; this.nativeLimitations = prepared.limitations; @@ -543,7 +561,7 @@ export class PcapProvider { "No enforceable native memory boundary is available for capture summaries on this host.", diagnostics: client, }; - await this.prepareCaptureBound(); + await this.prepareCaptureBound("inspect_pcap_client", signal); return { status: "available", code: null, @@ -795,7 +813,7 @@ export class PcapProvider { ), ); else { - await this.prepareCaptureBound(); + await this.prepareCaptureBound(request.operation, signal); snapshot = await PcapSnapshot.create(); this.snapshots.add(snapshot); const identity = await snapshot.capture(request.input.path, signal); diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index c67506a3f0..184b4ff7be 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { watch } from "node:fs"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, symlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { expect, onTestFinished, test, vi } from "vitest"; @@ -540,6 +540,92 @@ it("names the prlimit selection when the Linux limiter is unusable", async () => expect(projected.message).toContain("Check that REA_TSHARK_PRLIMIT_COMMAND"); }); +it("rejects directory and non-prlimit selections before opening the capture", async () => { + const root = await createTestTempDirectory("rea-pcap-invalid-limiter-"); + const tools = await writeStubTools(root); + for (const configured of [root, "/usr/bin/true"]) { + const provider = new PcapProvider({ + environment: { + REA_TSHARK_COMMAND: tools.tshark, + REA_CAPINFOS_COMMAND: tools.capinfos, + REA_TSHARK_PRLIMIT_COMMAND: configured, + }, + }); + try { + const result = await provider.execute({ + operation: "inspect_pcap_capture", + input: { path: "/not-opened/capture.pcap" }, + }); + if (result.ok) throw new Error("expected an unusable limiter"); + const error = projectAnalysisError(result.error); + expect(error).toMatchObject({ + code: "capability_unavailable", + details: { + diagnostics: { + configuration_key: "REA_TSHARK_PRLIMIT_COMMAND", + configured, + }, + }, + }); + expect(error.message).toContain("REA_TSHARK_PRLIMIT_COMMAND"); + expect(error.message).not.toContain("Cannot run selected tool"); + } finally { + await provider.close(); + } + } +}); + +it("rejects a different util-linux command before opening capture bytes", async () => { + const root = await createTestTempDirectory("rea-pcap-other-util-linux-"); + const tools = await writeStubTools(root); + const provider = new PcapProvider({ + environment: { + REA_TSHARK_COMMAND: tools.tshark, + REA_CAPINFOS_COMMAND: tools.capinfos, + REA_TSHARK_PRLIMIT_COMMAND: "/usr/bin/lsblk", + }, + }); + try { + const result = await provider.execute({ + operation: "inspect_pcap_capture", + input: { path: "/not-opened/capture.pcap" }, + }); + if (result.ok) throw new Error("expected an unusable limiter"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + capturedOutput: { stdout: expect.stringContaining("--output") }, + }); + } finally { + await provider.close(); + } +}); + +it("accepts real prlimit selected through differently named symlinks", async () => { + const root = await createTestTempDirectory("rea-pcap-prlimit-alias-"); + const tools = await writeStubTools(root); + for (const name of ["bounded-capture", " limiter "]) { + const limiter = join(root, name); + await symlink("/usr/bin/prlimit", limiter); + const provider = new PcapProvider({ + environment: { + REA_TSHARK_COMMAND: tools.tshark, + REA_CAPINFOS_COMMAND: tools.capinfos, + REA_TSHARK_PRLIMIT_COMMAND: limiter, + }, + }); + try { + const result = await provider.execute({ + operation: "inspect_pcap_capture", + input: { path: await captureFixture(root) }, + }); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ coverage: "complete" }); + } finally { + await provider.close(); + } + } +}); + // Capture execution requires the Linux native resource bound used by providerWith. it.each([ { pass: "selected", invalidUtf8: false }, diff --git a/tests/boundary/process/artifactSelectedCommand.test.ts b/tests/boundary/process/artifactSelectedCommand.test.ts index 429997bf08..9e9f4da671 100644 --- a/tests/boundary/process/artifactSelectedCommand.test.ts +++ b/tests/boundary/process/artifactSelectedCommand.test.ts @@ -474,6 +474,16 @@ it.skipIf(process.platform === "win32")( prepared = await preparePcapNativeBound( { REA_TSHARK_PRLIMIT_COMMAND: selected }, resources, + async (_selected, args) => { + const stdout = + args[0] === "--help" + ? " --as[=limit] --fsize[=limit] --cpu[=limit] --core[=limit]" + : "prlimit from util-linux 2.38.1"; + return { + stdout, + capturedOutput: { stdout, stderr: "", truncated: false }, + }; + }, ); } finally { Object.defineProperty(process, "platform", platform);