fix(pcap): share selected tool identity verification

This commit is contained in:
morluto committed 2026-10-22 05:40:52 +08:00
1 parent 658c29b7fc
commit cb6dc4f3ef
7 files changed
+222 -12

No files matched your search

+7 -1
View File
@@ -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.
+4 -7
View File
@@ -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)
+6
View File
@@ -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({
+28 -1
View File
@@ -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<Record<string, string | undefined>>,
): 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);
+78 -1
View File
@@ -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<Record<string, string | undefined>>;
private readonly launcher: PcapCommandLauncher | undefined;
private readonly wiresharkIdentities = new Map<
string,
{ readonly path: string; readonly identity: WiresharkExecutableIdentity }
>();
private readonly snapshots = new Set<PcapSnapshot>();
private readonly snapshotFailures = new Map<
PcapSnapshot,
@@ -187,10 +195,24 @@ export class PcapProvider {
let cleanup: PcapCommandCleanup | undefined;
let retainedCommand: RetainedPcapCommand | undefined;
let output: Awaited<ReturnType<typeof runPcapCommand>>;
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",
},
};
}
+40 -1
View File
@@ -141,7 +141,7 @@ const captureFixture = async (root: string): Promise<string> => {
};
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);
@@ -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;