From cb6dc4f3eff27ed2576c85a2f631b3811dc38dbb Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:40:52 +0800 Subject: [PATCH] fix(pcap): share selected tool identity verification --- docs/pcap-analysis.md | 8 +- src/captures/TsharkCaptureProvider.ts | 11 +-- src/domain/pcap/pcapAnalysis.ts | 6 ++ src/pcap/PcapConfiguration.ts | 29 ++++++- src/pcap/PcapProvider.ts | 79 ++++++++++++++++++- tests/boundary/pcap/pcapProvider.test.ts | 41 +++++++++- .../pcap/pcapProviderCrossPlatform.test.ts | 60 +++++++++++++- 7 files changed, 222 insertions(+), 12 deletions(-) diff --git a/docs/pcap-analysis.md b/docs/pcap-analysis.md index 39d718d429..b9fe8e9fb9 100644 --- a/docs/pcap-analysis.md +++ b/docs/pcap-analysis.md @@ -20,7 +20,13 @@ Without explicit tool commands, this family resolves them through PATH and records that selection source. On Linux and macOS it runs the tool found in the first absolute PATH entry holding an executable file, the same selection the [packet capture tools](packet-captures.md) make; relative PATH entries are -skipped. Windows keeps PATH/PATHEXT resolution in the process launcher. Client inspection reads no capture file. Capture +skipped. Windows keeps PATH/PATHEXT resolution in the process launcher. Client +inspection reads no capture file; on hosts where PATH/PATHEXT does not expose +an absolute candidate, its executable digest and resolved path are reported as +unresolved. Otherwise the canonical executable path and SHA-256 are reported. +The pcap provider and packet tools share the selected-tool identity check, and +changes to a resolved executable during a run fail with artifact-changed +Evidence. Capture inspection requires Linux with util-linux prlimit or Windows with owned Job Objects. macOS capture inspection returns a capability gap because the new native memory boundary is unverified; its version probes remain available. diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index 4943fb51c2..f3b5f4f078 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -59,6 +59,7 @@ import { import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; import { TSHARK_COMMAND_ENV, + assertWiresharkExecutableIdentity, locateWiresharkTool, parseWiresharkVersion, selectWiresharkTool, @@ -229,13 +230,9 @@ export class TsharkCaptureProvider implements PacketCapturePort { "Configured resource limiter changed during analysis.", { capturedOutput }, ); - if (current.sha256 !== tool.sha256) - throw new AnalysisArtifactChangedError( - operation, - command, - "Configured TShark executable changed during analysis.", - { capturedOutput }, - ); + assertWiresharkExecutableIdentity(operation, command, tool, current, { + capturedOutput, + }); phase = previousPhase; resourcePath = "path" in input ? input.path : command; if (output.exitCode !== 0) diff --git a/src/domain/pcap/pcapAnalysis.ts b/src/domain/pcap/pcapAnalysis.ts index 21c64f3659..c05122712f 100644 --- a/src/domain/pcap/pcapAnalysis.ts +++ b/src/domain/pcap/pcapAnalysis.ts @@ -41,9 +41,15 @@ const client = z.strictObject({ tshark_command: z.string().min(1), tshark_source: z.enum(["environment", "path"]), tshark_version: z.string().min(1).nullable(), + tshark_resolved_path: localPathStringSchema.nullable(), + tshark_sha256: digestSchema.nullable(), + tshark_identity_status: z.enum(["verified", "unresolved"]), capinfos_command: z.string().min(1), capinfos_source: z.enum(["environment", "path"]), capinfos_version: z.string().min(1).nullable(), + capinfos_resolved_path: localPathStringSchema.nullable(), + capinfos_sha256: digestSchema.nullable(), + capinfos_identity_status: z.enum(["verified", "unresolved"]), }); const endpoint = z.strictObject({ diff --git a/src/pcap/PcapConfiguration.ts b/src/pcap/PcapConfiguration.ts index 7ee86c184f..1ef05d81e4 100644 --- a/src/pcap/PcapConfiguration.ts +++ b/src/pcap/PcapConfiguration.ts @@ -6,6 +6,32 @@ import type { ProviderRejectionCode } from "../contracts/providerSelection.js"; import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js"; import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { AnalysisArtifactChangedError } from "../domain/analysisErrorCore.js"; + +export interface WiresharkExecutableIdentity { + readonly bytes: Uint8Array; + readonly sha256: string; +} + +/** Shared identity owner used to verify each caller-selected tool run. */ +export const assertWiresharkExecutableIdentity = ( + operation: string, + path: string, + expected: WiresharkExecutableIdentity, + actual: WiresharkExecutableIdentity, + options?: AnalysisErrorOptions, +): void => { + if ( + actual.bytes.byteLength !== expected.bytes.byteLength || + actual.sha256 !== expected.sha256 + ) + throw new AnalysisArtifactChangedError( + operation, + path, + "The selected Wireshark executable changed during analysis.", + options, + ); +}; /** Environment keys selecting the caller's tshark and capinfos binaries. */ export const TSHARK_COMMAND_ENV = "REA_TSHARK_COMMAND"; @@ -108,7 +134,8 @@ export const locateWiresharkTool = ( selection: WiresharkToolSelection, environment: Readonly>, ): string | undefined => { - if (selection.source === "environment") return selection.command; + if (selection.source === "environment" || isAbsolute(selection.command)) + return selection.command; for (const directory of (environment.PATH ?? "").split(delimiter)) { if (!isAbsolute(directory)) continue; const candidate = join(directory, selection.command); diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index 304f99b77d..48d7545e19 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -23,11 +23,14 @@ import { err, ok, type Result } from "../domain/result.js"; import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; import { PcapConfigurationFailure, + assertWiresharkExecutableIdentity, inspectPcapClient, + locateWiresharkTool, resolvePcapTools, pcapConfigurationAvailability, pcapToolUnavailable, type PcapToolSelection, + type WiresharkExecutableIdentity, } from "./PcapConfiguration.js"; import { parseCapinfosOutput, @@ -48,6 +51,7 @@ import { PcapSnapshot } from "./PcapSnapshot.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; import { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js"; import { readLinuxResourceLimits } from "../process/LinuxResourceLimits.js"; +import { realpath } from "node:fs/promises"; export const PCAP_PROVIDER_IDENTITY = { id: "pcap", @@ -160,6 +164,10 @@ export class PcapProvider { ); private readonly environment: Readonly>; private readonly launcher: PcapCommandLauncher | undefined; + private readonly wiresharkIdentities = new Map< + string, + { readonly path: string; readonly identity: WiresharkExecutableIdentity } + >(); private readonly snapshots = new Set(); private readonly snapshotFailures = new Map< PcapSnapshot, @@ -187,10 +195,24 @@ export class PcapProvider { let cleanup: PcapCommandCleanup | undefined; let retainedCommand: RetainedPcapCommand | undefined; let output: Awaited>; + const selected = this.wiresharkIdentities.get(command); try { + if (selected !== undefined) { + const before = await this.#resources.read( + selected.path, + 64 * 1024 * 1024, + signal, + ); + assertWiresharkExecutableIdentity( + operation, + selected.path, + selected.identity, + before, + ); + } output = await runPcapCommand({ operation, - command, + command: selected?.path ?? command, arguments: args, limits, signal, @@ -219,6 +241,25 @@ export class PcapProvider { cleanup = { reason, resources, capturedOutput }; }, }); + if (selected !== undefined) { + const after = await this.#resources.read( + selected.path, + 64 * 1024 * 1024, + ); + assertWiresharkExecutableIdentity( + operation, + selected.path, + selected.identity, + after, + { + capturedOutput: { + stdout: output.stdout, + stderr: output.stderr, + truncated: output.cleanup?.capturedOutput.truncated ?? false, + }, + }, + ); + } } catch (cause) { if ( retainedCommand !== undefined && @@ -263,6 +304,21 @@ export class PcapProvider { private async client(operation: PcapOperation, signal?: AbortSignal) { const selection = resolvePcapTools(this.environment); + this.wiresharkIdentities.clear(); + for (const selected of [selection.tshark, selection.capinfos]) { + const path = locateWiresharkTool(selected, this.environment); + if (path === undefined) continue; + const resolvedPath = await realpath(path); + const identity = await this.#resources.read( + resolvedPath, + 64 * 1024 * 1024, + signal, + ); + this.wiresharkIdentities.set(selected.command, { + path: resolvedPath, + identity, + }); + } const identity = await inspectPcapClient( selection, async (command) => @@ -292,9 +348,30 @@ export class PcapProvider { tshark_command: selection.tshark.command, tshark_source: selection.tshark.source, tshark_version: identity.tsharkVersion, + tshark_resolved_path: + this.wiresharkIdentities.get(selection.tshark.command)?.path ?? null, + tshark_sha256: + this.wiresharkIdentities.get(selection.tshark.command)?.identity + .sha256 ?? null, + tshark_identity_status: this.wiresharkIdentities.has( + selection.tshark.command, + ) + ? "verified" + : "unresolved", capinfos_command: selection.capinfos.command, capinfos_source: selection.capinfos.source, capinfos_version: identity.capinfosVersion, + capinfos_resolved_path: + this.wiresharkIdentities.get(selection.capinfos.command)?.path ?? + null, + capinfos_sha256: + this.wiresharkIdentities.get(selection.capinfos.command)?.identity + .sha256 ?? null, + capinfos_identity_status: this.wiresharkIdentities.has( + selection.capinfos.command, + ) + ? "verified" + : "unresolved", }, }; } diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index fbc8b13584..1004f2acf1 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -141,7 +141,7 @@ const captureFixture = async (root: string): Promise => { }; it("inspects both tools without reading a capture", async () => { - const { provider } = await providerWith(); + const { provider, root } = await providerWith(); const result = await evidenceResult(provider, { operation: "inspect_pcap_client", input: {}, @@ -149,11 +149,50 @@ it("inspects both tools without reading a capture", async () => { expect(result.client).toMatchObject({ tshark_source: "environment", tshark_version: "TShark (Wireshark) 4.6.6-stub", + tshark_sha256: createHash("sha256") + .update(await readFile(join(root, "tshark"))) + .digest("hex"), + tshark_identity_status: "verified", capinfos_source: "environment", capinfos_version: "Capinfos (Wireshark) 4.6.6-stub", + capinfos_sha256: createHash("sha256") + .update(await readFile(join(root, "capinfos"))) + .digest("hex"), + capinfos_identity_status: "verified", }); }); +it("rejects a selected TShark whose bytes change during its version probe", async () => { + const root = await createTestTempDirectory("rea-pcap-tool-identity-"); + const { tshark, capinfos } = await writeStubTools(root); + await writeFile( + tshark, + `#!/bin/sh +if [ "$1" = "--version" ]; then + printf 'TShark (Wireshark) 4.6.6-stub\\n' + printf '\\n# changed during version probe\\n' >> "$0" + exit 0 +fi +exit 2 +`, + { mode: 0o755 }, + ); + const provider = new PcapProvider({ + environment: { REA_TSHARK_COMMAND: tshark, REA_CAPINFOS_COMMAND: capinfos }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "inspect_pcap_client", + input: {}, + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "artifact_changed", + path: tshark, + }); +}); + it("inspects a capture with endpoints, DNS, TLS SNI, and HTTP", async () => { const { provider, root } = await providerWith(); const path = await captureFixture(root); diff --git a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts index 0fb4e67393..da213766d5 100644 --- a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts +++ b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts @@ -7,7 +7,7 @@ * refusal paths, and cancellation before spawn. */ import { createHash } from "node:crypto"; -import { open, symlink, writeFile } from "node:fs/promises"; +import { open, readFile, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, onTestFinished, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -71,8 +71,18 @@ it.skipIf(isWindows)( tshark_command: join(directory, "tshark"), tshark_source: "path", tshark_version: expect.stringContaining("4.6.6") as unknown, + tshark_resolved_path: await realpath(tsharkStub), + tshark_sha256: createHash("sha256") + .update(await readFile(join(directory, "tshark"))) + .digest("hex"), + tshark_identity_status: "verified", capinfos_command: join(directory, "capinfos"), capinfos_source: "path", + capinfos_resolved_path: await realpath(capinfosStub), + capinfos_sha256: createHash("sha256") + .update(await readFile(join(directory, "capinfos"))) + .digest("hex"), + capinfos_identity_status: "verified", }); }, ); @@ -85,11 +95,59 @@ it("inspects both tools through cross-platform stubs", async () => { expect(result.client).toMatchObject({ tshark_source: "environment", tshark_version: expect.stringContaining("4.6.6") as unknown, + tshark_resolved_path: await realpath(tsharkStub), capinfos_source: "environment", capinfos_version: expect.stringContaining("4.6.6") as unknown, + capinfos_resolved_path: await realpath(capinfosStub), + tshark_sha256: createHash("sha256") + .update(await readFile(tsharkStub)) + .digest("hex"), + tshark_identity_status: "verified", + capinfos_sha256: createHash("sha256") + .update(await readFile(capinfosStub)) + .digest("hex"), + capinfos_identity_status: "verified", }); }); +it.skipIf(isWindows)( + "rejects TShark that changes during its version probe", + async () => { + const directory = await createTestTempDirectory("rea-pcap-mutated-tool-"); + const tshark = join(directory, "tshark"); + const capinfos = join(directory, "capinfos"); + await writeFile( + tshark, + `#!/bin/sh +if [ "$1" = "--version" ]; then + printf 'TShark (Wireshark) 4.6.6-stub\\n' + printf '\\n# changed during version probe\\n' >> "$0" + exit 0 +fi +exit 2 +`, + { mode: 0o755 }, + ); + await symlink(capinfosStub, capinfos); + const provider = new PcapProvider({ + environment: { + REA_TSHARK_COMMAND: tshark, + REA_CAPINFOS_COMMAND: capinfos, + }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "inspect_pcap_client", + input: {}, + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "artifact_changed", + }); + }, +); + it("resolves a retained tool-probe error after its process owner closes", async () => { let mayClean = false; let launches = 0;