Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2cffbdfdc | ||
|
|
5e046bb374 | ||
|
|
4924706e25 | ||
|
|
211d4b2219 | ||
|
|
cb6dc4f3ef | ||
|
|
658c29b7fc | ||
|
|
a22adc7809 | ||
|
|
e74e4d8f0b | ||
|
|
f4eeba3654 | ||
|
|
bbecf6fb05 |
No files matched your search
@@ -10,10 +10,17 @@ Supply an existing TShark 4.x installation **on Linux**, with util-linux
|
||||
`prlimit` available at `/usr/bin/prlimit`. Override its absolute path with
|
||||
`REA_TSHARK_PRLIMIT_COMMAND` if required. Unsupported hosts, including macOS
|
||||
and Windows, are refused before inspection because this integration requires
|
||||
an enforced address-space limit:
|
||||
an enforced address-space limit.
|
||||
|
||||
TShark selection follows the same rule as [`inspect_pcap_capture`](pcap-analysis.md):
|
||||
an absolute `REA_TSHARK_COMMAND`, otherwise `tshark` from the first absolute
|
||||
`PATH` entry that holds an executable file. A relative `REA_TSHARK_COMMAND` is
|
||||
refused rather than looked up. Either way the selected executable is resolved,
|
||||
hashed and rechecked after each run, and Evidence records its
|
||||
`selection_source` (`environment` or `path`):
|
||||
|
||||
```sh
|
||||
export REA_TSHARK_COMMAND=/absolute/path/to/tshark
|
||||
export REA_TSHARK_COMMAND=/absolute/path/to/tshark # optional when tshark is on PATH
|
||||
rea inspect-packet-capture /artifacts/device.pcapng \
|
||||
--fields ip.addr --fields udp.port --fields data.data \
|
||||
--display_filter 'udp' --format json
|
||||
|
||||
+10
-1
@@ -17,7 +17,16 @@ rea inspect-pcap-capture /captures/session.pcapng --json
|
||||
```
|
||||
|
||||
Without explicit tool commands, this family resolves them through PATH and
|
||||
records that selection source. Client inspection reads no capture file. Capture
|
||||
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; 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.
|
||||
|
||||
@@ -478,7 +478,7 @@ const workflowAvailabilityFor = ({
|
||||
: {
|
||||
reason: "provider_missing",
|
||||
remediation:
|
||||
"On Linux, supply TShark 4.x via absolute REA_TSHARK_COMMAND and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.",
|
||||
"On Linux, supply TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND, and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.",
|
||||
};
|
||||
if (name === "decode_signal_recording" || name === "inspect_signal_decoders")
|
||||
return policy.signalRecordingEnabled === true
|
||||
|
||||
@@ -75,7 +75,7 @@ const varint = (value: number): Buffer => {
|
||||
const encodeValue = (
|
||||
value: number | string | boolean | null | { ref: number },
|
||||
): Buffer => {
|
||||
if (typeof value === "boolean") return Buffer.from([value ? 5 : 4]);
|
||||
if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]);
|
||||
if (value === null) return Buffer.from([9]);
|
||||
if (typeof value === "number") {
|
||||
const bytes = Buffer.alloc(5);
|
||||
|
||||
@@ -77,24 +77,6 @@ describe("NIBArchive decoder", () => {
|
||||
expect(decoded.coder_version).toBe(10);
|
||||
});
|
||||
|
||||
it("decodes boolean value type 4 as false and 5 as true", () => {
|
||||
const archive = encodeArchive({
|
||||
classes: ["NSButton"],
|
||||
objects: [{ classIndex: 0, values: { NSControlContinuous: null } }],
|
||||
});
|
||||
// The only value record is key varint 0x80 then one-byte type 9 (nil);
|
||||
// overwrite that type byte to pin the format's own boolean encoding.
|
||||
const typeOffset = archive.readUInt32LE(38) + 1;
|
||||
expect(archive[typeOffset]).toBe(9);
|
||||
const decodeType = (type: number) => {
|
||||
const bytes = Buffer.from(archive);
|
||||
bytes[typeOffset] = type;
|
||||
return decodeNibArchive(bytes).objects[0]?.values.NSControlContinuous;
|
||||
};
|
||||
expect(decodeType(4)).toBe(false);
|
||||
expect(decodeType(5)).toBe(true);
|
||||
});
|
||||
|
||||
it("counts AppKit outlet, action, and binding connector objects", () => {
|
||||
const archive = encodeArchive({
|
||||
classes: [
|
||||
|
||||
@@ -374,12 +374,10 @@ const readValue = (
|
||||
? Number(value)
|
||||
: value.toString();
|
||||
}
|
||||
// NIBArchive value type 4 is false and 5 is true, as ibtool --export
|
||||
// reports for the same compiled fields.
|
||||
case 4:
|
||||
return false;
|
||||
case 5:
|
||||
return true;
|
||||
case 5:
|
||||
return false;
|
||||
case 6:
|
||||
ensureRange(cursor.offset, 4, end, `value ${index}`);
|
||||
{
|
||||
|
||||
@@ -57,6 +57,14 @@ import {
|
||||
OwnedCommandFailure,
|
||||
} from "../process/OwnedCommand.js";
|
||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
import {
|
||||
TSHARK_COMMAND_ENV,
|
||||
assertWiresharkExecutableIdentity,
|
||||
wiresharkExecutableIdentity,
|
||||
locateWiresharkTool,
|
||||
parseWiresharkVersion,
|
||||
selectWiresharkTool,
|
||||
} from "../pcap/PcapConfiguration.js";
|
||||
|
||||
const OPERATION = "inspect_packet_capture";
|
||||
export const TSHARK_PROVIDER_IDENTITY = {
|
||||
@@ -139,18 +147,22 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
||||
await this.#resources.release();
|
||||
if (this.platform !== "linux")
|
||||
throw new Error(
|
||||
"Packet inspection requires Linux with util-linux prlimit and REA_TSHARK_COMMAND so expanded captures are processed under an enforced address-space limit.",
|
||||
"Packet inspection requires Linux with util-linux prlimit and TShark 4.x (on PATH or through absolute REA_TSHARK_COMMAND) so expanded captures are processed under an enforced address-space limit.",
|
||||
);
|
||||
const selection = selectWiresharkTool(this.environment, "tshark");
|
||||
const configured = locateWiresharkTool(selection, this.environment);
|
||||
if (configured === undefined)
|
||||
throw new Error(
|
||||
`TShark 4.x was not found on PATH; add it to PATH or set absolute ${TSHARK_COMMAND_ENV}.`,
|
||||
);
|
||||
const configured = this.environment.REA_TSHARK_COMMAND ?? "";
|
||||
if (!isAbsolute(configured))
|
||||
throw new Error("Supply absolute REA_TSHARK_COMMAND for TShark 4.x.");
|
||||
const command = await realpath(configured);
|
||||
await access(command, constants.X_OK);
|
||||
const tool = await this.#resources.read(
|
||||
const toolSnapshot = await this.#resources.read(
|
||||
command,
|
||||
64 * 1024 * 1024,
|
||||
options?.signal,
|
||||
);
|
||||
const tool = wiresharkExecutableIdentity(toolSnapshot);
|
||||
const configuredLimiter =
|
||||
this.environment.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
|
||||
if (!isAbsolute(configuredLimiter))
|
||||
@@ -220,13 +232,13 @@ 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,
|
||||
wiresharkExecutableIdentity(current),
|
||||
{ capturedOutput },
|
||||
);
|
||||
phase = previousPhase;
|
||||
resourcePath = "path" in input ? input.path : command;
|
||||
if (output.exitCode !== 0)
|
||||
@@ -245,11 +257,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
||||
};
|
||||
phase = "configuration";
|
||||
const version = await execute(["--version"]);
|
||||
if (
|
||||
!/^TShark \(Wireshark\) 4\.[0-9]+\.[0-9]+(?:[ .(\r\n]|$)/u.test(
|
||||
version.stdout.text,
|
||||
)
|
||||
)
|
||||
if (parseWiresharkVersion(version.stdout.text, "tshark") === null)
|
||||
throw new Error(
|
||||
"Expected TShark 4.x; inspect the selected installation.",
|
||||
);
|
||||
@@ -257,6 +265,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
||||
diagnostics,
|
||||
tool: {
|
||||
configured_path: configured,
|
||||
selection_source: selection.source,
|
||||
path: command,
|
||||
sha256: tool.sha256,
|
||||
version_banner: version.stdout.text,
|
||||
@@ -553,7 +562,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
||||
...(observedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: observedOutput }),
|
||||
userMessage: `${reason} On Linux, configure TShark 4.x through REA_TSHARK_COMMAND and util-linux prlimit through REA_TSHARK_PRLIMIT_COMMAND (default /usr/bin/prlimit).`,
|
||||
userMessage: `${reason} On Linux, supply TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND, and util-linux prlimit through REA_TSHARK_PRLIMIT_COMMAND (default /usr/bin/prlimit).`,
|
||||
},
|
||||
);
|
||||
if (
|
||||
|
||||
@@ -15,7 +15,7 @@ export const CAPTURE_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_packet_fields"),
|
||||
kind: "artifact-provider",
|
||||
description:
|
||||
"Discover protocols or field names/types registered in the installed TShark 4.x. kind defaults to protocols; use fields and optional exact protocol abbreviation to select field definitions. query searches name/abbreviation/description by case-insensitive literal substring. Return 50 matches by default with offset/limit paging; null requests all remaining within resource limits. Preserve type/base/bitmask/description, duplicate registrations, source line numbers, coverage, tool identity and diagnostics. Linux, REA_TSHARK_COMMAND and prlimit required. No capture file, interface or binary target needed. Registrations describe tool capabilities, not observed traffic. Each call rereads the glossary; use selected fields with inspect_packet_capture.",
|
||||
"Discover protocols or field names/types registered in the installed TShark 4.x. kind defaults to protocols; use fields and optional exact protocol abbreviation to select field definitions. query searches name/abbreviation/description by case-insensitive literal substring. Return 50 matches by default with offset/limit paging; null requests all remaining within resource limits. Preserve type/base/bitmask/description, duplicate registrations, source line numbers, coverage, tool identity and diagnostics. Linux, TShark 4.x (PATH or absolute REA_TSHARK_COMMAND) and prlimit required. No capture file, interface or binary target needed. Registrations describe tool capabilities, not observed traffic. Each call rereads the glossary; use selected fields with inspect_packet_capture.",
|
||||
inputSchema: inspectPacketFieldsInputSchema,
|
||||
outputSchema: evidenceResultOf(packetFieldsCatalogSchema),
|
||||
examples: [
|
||||
@@ -31,7 +31,7 @@ export const CAPTURE_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_packet_capture"),
|
||||
kind: "artifact-provider",
|
||||
description:
|
||||
"Inspect caller-selected fields in an existing local packet capture with TShark 4.x. Return 50 matching frames by default, with match_offset/match_limit paging and coverage counts; match_limit:null requests all remaining matches. Preserve repeated values, frame numbers, display filter, decode-as rules and pass mode in inline Evidence with source/tool identity and diagnostics. Optional packet_limit counts input packets before filtering; output paging does not shorten dissection or two-pass reassembly. Requires Linux, util-linux prlimit, and absolute REA_TSHARK_COMMAND. File-only analysis; no live capture or device communication. Missing fields or no matches do not establish absence of device traffic.",
|
||||
"Inspect caller-selected fields in an existing local packet capture with TShark 4.x. Return 50 matching frames by default, with match_offset/match_limit paging and coverage counts; match_limit:null requests all remaining matches. Preserve repeated values, frame numbers, display filter, decode-as rules and pass mode in inline Evidence with source/tool identity and diagnostics. Optional packet_limit counts input packets before filtering; output paging does not shorten dissection or two-pass reassembly. Requires Linux, util-linux prlimit, and TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND. File-only analysis; no live capture or device communication. Missing fields or no matches do not establish absence of device traffic.",
|
||||
inputSchema: inspectPacketCaptureInputSchema,
|
||||
outputSchema: evidenceResultOf(packetCaptureSchema),
|
||||
examples: [
|
||||
|
||||
@@ -74,6 +74,23 @@ const diagnostics = (offset: number, limit: number) =>
|
||||
limit,
|
||||
}) as const;
|
||||
|
||||
it("reads historical capture Evidence without inventing executable identity", () => {
|
||||
const result = projectAnalysisView(capture(""), { kind: "summary" });
|
||||
if (!result.ok) throw result.error;
|
||||
if (result.value.kind !== "summary")
|
||||
throw new Error("Expected a packet summary.");
|
||||
if (!("client" in result.value.summary))
|
||||
throw new Error("Expected packet client facts.");
|
||||
expect(result.value.summary.client).toMatchObject({
|
||||
tshark_resolved_path: null,
|
||||
tshark_sha256: null,
|
||||
tshark_identity_status: "unresolved",
|
||||
capinfos_resolved_path: null,
|
||||
capinfos_sha256: null,
|
||||
capinfos_identity_status: "unresolved",
|
||||
});
|
||||
});
|
||||
|
||||
it("pages diagnostics without splitting a surrogate pair", () => {
|
||||
const parent = capture("ab😀cd");
|
||||
expect(projectAnalysisView(parent, diagnostics(3, 2))).toMatchObject({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { analysisViewInputError } from "./analysisViewErrors.js";
|
||||
import { z } from "zod";
|
||||
import { pcapResultSchemas } from "../pcap/pcapAnalysis.js";
|
||||
import { pcapEvidenceSchemas } from "../pcap/pcapAnalysis.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "./analysisViewCoverage.js";
|
||||
import { analysisTextPage } from "./analysisViewText.js";
|
||||
|
||||
const captureSchema = pcapResultSchemas.inspect_pcap_capture;
|
||||
const captureSchema = pcapEvidenceSchemas.inspect_pcap_capture;
|
||||
const fields = captureSchema.shape;
|
||||
const pageShape = {
|
||||
kind: z.literal("pcap"),
|
||||
|
||||
@@ -123,6 +123,11 @@ export const packetCaptureSchema = z
|
||||
diagnostics: z.string(),
|
||||
tool: z.strictObject({
|
||||
configured_path: z.string(),
|
||||
selection_source: z
|
||||
.enum(["environment", "path"])
|
||||
.describe(
|
||||
"Whether REA_TSHARK_COMMAND selected the tool or PATH located it",
|
||||
),
|
||||
path: z.string(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
version_banner: z.string(),
|
||||
|
||||
@@ -41,9 +41,29 @@ 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"]),
|
||||
});
|
||||
|
||||
/** Defaults for historical report Evidence written before executable identity was recorded. */
|
||||
const legacyClientIdentity = client.extend({
|
||||
tshark_resolved_path: localPathStringSchema.nullable().default(null),
|
||||
tshark_sha256: digestSchema.nullable().default(null),
|
||||
tshark_identity_status: z
|
||||
.enum(["verified", "unresolved"])
|
||||
.default("unresolved"),
|
||||
capinfos_resolved_path: localPathStringSchema.nullable().default(null),
|
||||
capinfos_sha256: digestSchema.nullable().default(null),
|
||||
capinfos_identity_status: z
|
||||
.enum(["verified", "unresolved"])
|
||||
.default("unresolved"),
|
||||
});
|
||||
|
||||
const endpoint = z.strictObject({
|
||||
@@ -108,3 +128,10 @@ export const pcapResultSchemas = {
|
||||
.optional(),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
/** Read compatibility for retained capture Evidence from earlier REA versions. */
|
||||
export const pcapEvidenceSchemas = {
|
||||
inspect_pcap_capture: pcapResultSchemas.inspect_pcap_capture.extend({
|
||||
client: legacyClientIdentity,
|
||||
}),
|
||||
} as const;
|
||||
@@ -1,9 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
CAPINFOS_COMMAND_ENV,
|
||||
TSHARK_COMMAND_ENV,
|
||||
locateWiresharkTool,
|
||||
parseWiresharkVersion,
|
||||
resolvePcapTools,
|
||||
selectWiresharkTool,
|
||||
wiresharkExecutableIdentity,
|
||||
} from "./PcapConfiguration.js";
|
||||
import { parseCapinfosOutput, parseTsharkJsonOutput } from "./PcapOutput.js";
|
||||
|
||||
@@ -20,13 +28,51 @@ describe("resolvePcapTools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to PATH resolution without the environment keys", () => {
|
||||
expect(resolvePcapTools({})).toEqual({
|
||||
it("keeps bare PATH selections for the Windows PATH/PATHEXT launcher", () => {
|
||||
expect(resolvePcapTools({}, "win32")).toEqual({
|
||||
tshark: { command: "tshark", source: "path" },
|
||||
capinfos: { command: "capinfos", source: "path" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when no absolute PATH entry holds the tool", () => {
|
||||
expect(() => resolvePcapTools({ PATH: "relative-only" }, "linux")).toThrow(
|
||||
/tshark was not found in an absolute PATH entry/u,
|
||||
);
|
||||
});
|
||||
|
||||
// Windows X_OK reports existence only, so executable-bit selection is POSIX-only.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"selects the same executable as the packet tools ahead of a relative PATH entry",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-wireshark-shared-");
|
||||
const decoy = join(root, "relative-decoy");
|
||||
const installed = join(root, "installed");
|
||||
for (const directory of [decoy, installed]) {
|
||||
await mkdir(directory);
|
||||
for (const tool of ["tshark", "capinfos"]) {
|
||||
await writeFile(join(directory, tool), "");
|
||||
await chmod(join(directory, tool), 0o700);
|
||||
}
|
||||
}
|
||||
const environment = {
|
||||
PATH: [relative(process.cwd(), decoy), installed].join(":"),
|
||||
};
|
||||
|
||||
const pcap = resolvePcapTools(environment, "linux");
|
||||
expect(pcap).toEqual({
|
||||
tshark: { command: join(installed, "tshark"), source: "path" },
|
||||
capinfos: { command: join(installed, "capinfos"), source: "path" },
|
||||
});
|
||||
expect(pcap.tshark.command).toBe(
|
||||
locateWiresharkTool(
|
||||
selectWiresharkTool(environment, "tshark"),
|
||||
environment,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a relative tshark path explicitly", () => {
|
||||
expect(() =>
|
||||
resolvePcapTools({ [TSHARK_COMMAND_ENV]: "bin/tshark" }),
|
||||
@@ -40,6 +86,88 @@ describe("resolvePcapTools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("locateWiresharkTool", () => {
|
||||
it("keeps an environment selection as the configured absolute path", () => {
|
||||
expect(
|
||||
locateWiresharkTool(
|
||||
{ command: "/opt/wireshark/tshark", source: "environment" },
|
||||
{ PATH: "/usr/bin" },
|
||||
),
|
||||
).toBe("/opt/wireshark/tshark");
|
||||
});
|
||||
|
||||
// Windows X_OK reports existence only, so executable-bit selection is POSIX-only.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"uses the first absolute PATH entry holding an executable regular file",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-wireshark-path-");
|
||||
const relative = join(root, "relative");
|
||||
const notExecutable = join(root, "not-executable");
|
||||
const directoryEntry = join(root, "directory-entry");
|
||||
const selected = join(root, "selected");
|
||||
const later = join(root, "later");
|
||||
for (const directory of [notExecutable, directoryEntry, selected, later])
|
||||
await mkdir(directory);
|
||||
await writeFile(join(notExecutable, "tshark"), "");
|
||||
await chmod(join(notExecutable, "tshark"), 0o600);
|
||||
await mkdir(join(directoryEntry, "tshark"));
|
||||
for (const directory of [selected, later]) {
|
||||
await writeFile(join(directory, "tshark"), "");
|
||||
await chmod(join(directory, "tshark"), 0o700);
|
||||
}
|
||||
const PATH = [
|
||||
"relative-entry",
|
||||
relative,
|
||||
notExecutable,
|
||||
directoryEntry,
|
||||
selected,
|
||||
later,
|
||||
].join(":");
|
||||
|
||||
expect(
|
||||
locateWiresharkTool({ command: "tshark", source: "path" }, { PATH }),
|
||||
).toBe(join(selected, "tshark"));
|
||||
expect(
|
||||
locateWiresharkTool(
|
||||
{ command: "tshark", source: "path" },
|
||||
{ PATH: [relative, notExecutable, directoryEntry].join(":") },
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
locateWiresharkTool({ command: "tshark", source: "path" }, {}),
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("parseWiresharkVersion", () => {
|
||||
it("admits Wireshark 4.x banners for either tool and rejects other majors", () => {
|
||||
expect(
|
||||
parseWiresharkVersion(
|
||||
"\nTShark (Wireshark) 4.6.9 (Git v4.6.9 packaged as 4.6.9-1)\n",
|
||||
"tshark",
|
||||
),
|
||||
).toBe("TShark (Wireshark) 4.6.9 (Git v4.6.9 packaged as 4.6.9-1)");
|
||||
expect(
|
||||
parseWiresharkVersion("Capinfos (Wireshark) 4.0.17\n", "capinfos"),
|
||||
).toBe("Capinfos (Wireshark) 4.0.17");
|
||||
expect(parseWiresharkVersion("tcpdump 4.99.4\n", "tshark")).toBeNull();
|
||||
expect(() =>
|
||||
parseWiresharkVersion("TShark (Wireshark) 3.6.2\n", "tshark"),
|
||||
).toThrow(/requires Wireshark 4\.x/u);
|
||||
});
|
||||
});
|
||||
|
||||
it("projects executable snapshots to compact identity facts", () => {
|
||||
const bytes = new Uint8Array(64);
|
||||
const identity = wiresharkExecutableIdentity({
|
||||
bytes,
|
||||
sha256: "a".repeat(64),
|
||||
});
|
||||
expect(identity).toEqual({ bytes: 64, sha256: "a".repeat(64) });
|
||||
expect(Object.values(identity)).not.toContain(bytes);
|
||||
});
|
||||
|
||||
const REAL_CAPINFOS = [
|
||||
"File name: /work/rich-capture.pcap",
|
||||
"File type: Wireshark/tcpdump/... - pcap",
|
||||
|
||||
+130
-56
@@ -1,10 +1,43 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import { accessSync, constants, statSync } from "node:fs";
|
||||
import { delimiter, isAbsolute, join } from "node:path";
|
||||
|
||||
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
|
||||
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: number;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
/** Retain only identity facts after a bounded executable read. */
|
||||
export const wiresharkExecutableIdentity = (snapshot: {
|
||||
readonly bytes: Uint8Array;
|
||||
readonly sha256: string;
|
||||
}): WiresharkExecutableIdentity => ({
|
||||
bytes: snapshot.bytes.byteLength,
|
||||
sha256: snapshot.sha256,
|
||||
});
|
||||
|
||||
/** 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 !== expected.bytes || 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";
|
||||
@@ -27,64 +60,99 @@ export const pcapToolUnavailable = (
|
||||
userMessage: `${/[.!?]$/u.test(reason) ? reason : `${reason}.`} ${TOOL_REMEDIATION}`,
|
||||
});
|
||||
|
||||
/** How each tool was selected for this provider instance. */
|
||||
export interface PcapToolSelection {
|
||||
readonly tshark: { command: string; source: "environment" | "path" };
|
||||
readonly capinfos: { command: string; source: "environment" | "path" };
|
||||
/** How one caller-supplied Wireshark tool was selected. */
|
||||
export interface WiresharkToolSelection {
|
||||
readonly command: string;
|
||||
readonly source: "environment" | "path";
|
||||
}
|
||||
|
||||
/** How each tool was selected for this provider instance. */
|
||||
export interface PcapToolSelection {
|
||||
readonly tshark: WiresharkToolSelection;
|
||||
readonly capinfos: WiresharkToolSelection;
|
||||
}
|
||||
|
||||
const TOOL_COMMAND_ENV = {
|
||||
tshark: TSHARK_COMMAND_ENV,
|
||||
capinfos: CAPINFOS_COMMAND_ENV,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve the caller-supplied tshark and capinfos binaries. Each accepts an
|
||||
* absolute environment path or falls back to PATH resolution, mirroring the
|
||||
* mitmdump pattern. REA never installs the Wireshark suite.
|
||||
* The one selection rule for a caller-supplied Wireshark tool: an absolute
|
||||
* environment path, otherwise the bare tool name resolved through PATH. A
|
||||
* relative environment value is configuration to fix, never a PATH lookup.
|
||||
* REA never installs the Wireshark suite.
|
||||
*/
|
||||
export const selectWiresharkTool = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
tool: "tshark" | "capinfos",
|
||||
): WiresharkToolSelection => {
|
||||
const key = TOOL_COMMAND_ENV[tool];
|
||||
const configured = environment[key];
|
||||
if (configured === undefined || configured.trim() === "")
|
||||
return { command: tool, source: "path" };
|
||||
if (!isAbsolute(configured))
|
||||
throw new PcapConfigurationFailure(
|
||||
`${tool}_not_absolute`,
|
||||
`${key} must be an absolute path to the ${tool} binary`,
|
||||
{ configured },
|
||||
);
|
||||
return { command: configured, source: "environment" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the caller-supplied tshark and capinfos binaries to the executables
|
||||
* the packet tools would also select. Windows keeps PATH/PATHEXT resolution in
|
||||
* the launcher, which the POSIX locator does not model.
|
||||
*/
|
||||
export const resolvePcapTools = (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): PcapToolSelection => {
|
||||
const tsharkEnv = environment[TSHARK_COMMAND_ENV];
|
||||
const capinfosEnv = environment[CAPINFOS_COMMAND_ENV];
|
||||
if (
|
||||
tsharkEnv !== undefined &&
|
||||
tsharkEnv.trim() !== "" &&
|
||||
!isAbsolute(tsharkEnv)
|
||||
)
|
||||
throw new PcapConfigurationFailure(
|
||||
"tshark_not_absolute",
|
||||
`${TSHARK_COMMAND_ENV} must be an absolute path to the tshark binary`,
|
||||
{ configured: tsharkEnv },
|
||||
);
|
||||
if (
|
||||
capinfosEnv !== undefined &&
|
||||
capinfosEnv.trim() !== "" &&
|
||||
!isAbsolute(capinfosEnv)
|
||||
)
|
||||
throw new PcapConfigurationFailure(
|
||||
"capinfos_not_absolute",
|
||||
`${CAPINFOS_COMMAND_ENV} must be an absolute path to the capinfos binary`,
|
||||
{ configured: capinfosEnv },
|
||||
);
|
||||
return {
|
||||
tshark: {
|
||||
command:
|
||||
tsharkEnv === undefined || tsharkEnv.trim() === ""
|
||||
? "tshark"
|
||||
: tsharkEnv,
|
||||
source:
|
||||
tsharkEnv === undefined || tsharkEnv.trim() === ""
|
||||
? "path"
|
||||
: "environment",
|
||||
},
|
||||
capinfos: {
|
||||
command:
|
||||
capinfosEnv === undefined || capinfosEnv.trim() === ""
|
||||
? "capinfos"
|
||||
: capinfosEnv,
|
||||
source:
|
||||
capinfosEnv === undefined || capinfosEnv.trim() === ""
|
||||
? "path"
|
||||
: "environment",
|
||||
},
|
||||
// Report every configuration error before consulting PATH for either tool.
|
||||
const selected = {
|
||||
tshark: selectWiresharkTool(environment, "tshark"),
|
||||
capinfos: selectWiresharkTool(environment, "capinfos"),
|
||||
};
|
||||
const locate = (tool: "tshark" | "capinfos"): WiresharkToolSelection => {
|
||||
const selection = selected[tool];
|
||||
if (selection.source === "environment" || platform === "win32")
|
||||
return selection;
|
||||
const command = locateWiresharkTool(selection, environment);
|
||||
if (command === undefined)
|
||||
throw new PcapConfigurationFailure(
|
||||
`${tool}_missing`,
|
||||
`${tool} was not found in an absolute PATH entry; add it to PATH or set ${TOOL_COMMAND_ENV[tool]} to its absolute path`,
|
||||
);
|
||||
return { command, source: "path" };
|
||||
};
|
||||
return { tshark: locate("tshark"), capinfos: locate("capinfos") };
|
||||
};
|
||||
|
||||
/**
|
||||
* Locate a selected tool as an absolute executable so its identity can be
|
||||
* hashed. Environment selections are already absolute. PATH selections use the
|
||||
* first absolute PATH entry holding an executable regular file; relative PATH
|
||||
* entries are skipped because they would depend on the working directory.
|
||||
* Returns undefined when PATH holds no such executable.
|
||||
*/
|
||||
export const locateWiresharkTool = (
|
||||
selection: WiresharkToolSelection,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): string | undefined => {
|
||||
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);
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK);
|
||||
if (statSync(candidate).isFile()) return candidate;
|
||||
} catch {
|
||||
// Absent or non-executable candidates leave later PATH entries eligible.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export type PcapConfigurationFailureCode =
|
||||
@@ -108,8 +176,11 @@ export class PcapConfigurationFailure extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Recognize the selected Wireshark tool's version banner. */
|
||||
const parseVersion = (
|
||||
/**
|
||||
* Recognize the selected Wireshark tool's version banner: null when the first
|
||||
* line is not a Wireshark banner, a failure when its major version is not 4.
|
||||
*/
|
||||
export const parseWiresharkVersion = (
|
||||
stdout: string,
|
||||
tool: "tshark" | "capinfos",
|
||||
): string | null => {
|
||||
@@ -127,7 +198,7 @@ const parseVersion = (
|
||||
if (parsed[1] !== "4")
|
||||
throw new PcapConfigurationFailure(
|
||||
`${tool}_version_unsupported`,
|
||||
"Packet summary inspection currently requires Wireshark 4.x tools; select an installation with the admitted output profile.",
|
||||
"Packet capture inspection currently requires Wireshark 4.x tools; select an installation with the admitted output profile.",
|
||||
{ version: line },
|
||||
);
|
||||
return line;
|
||||
@@ -143,8 +214,11 @@ export const inspectPcapClient = async (
|
||||
selection: PcapToolSelection,
|
||||
probe: (command: string) => Promise<string>,
|
||||
): Promise<PcapClientIdentity> => ({
|
||||
tsharkVersion: parseVersion(await probe(selection.tshark.command), "tshark"),
|
||||
capinfosVersion: parseVersion(
|
||||
tsharkVersion: parseWiresharkVersion(
|
||||
await probe(selection.tshark.command),
|
||||
"tshark",
|
||||
),
|
||||
capinfosVersion: parseWiresharkVersion(
|
||||
await probe(selection.capinfos.command),
|
||||
"capinfos",
|
||||
),
|
||||
|
||||
+144
-20
@@ -23,11 +23,15 @@ import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js";
|
||||
import {
|
||||
PcapConfigurationFailure,
|
||||
assertWiresharkExecutableIdentity,
|
||||
inspectPcapClient,
|
||||
locateWiresharkTool,
|
||||
resolvePcapTools,
|
||||
pcapConfigurationAvailability,
|
||||
pcapToolUnavailable,
|
||||
wiresharkExecutableIdentity,
|
||||
type PcapToolSelection,
|
||||
type WiresharkExecutableIdentity,
|
||||
} from "./PcapConfiguration.js";
|
||||
import {
|
||||
parseCapinfosOutput,
|
||||
@@ -46,8 +50,11 @@ import { createPcapEvidence } from "../domain/pcap/pcapEvidence.js";
|
||||
import { preparePcapNativeBound } from "./PcapNativeBound.js";
|
||||
import { PcapSnapshot } from "./PcapSnapshot.js";
|
||||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.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",
|
||||
@@ -128,6 +135,7 @@ const pcapCommandCleanupError = (
|
||||
export interface PcapProviderOptions {
|
||||
readonly environment?: Readonly<Record<string, string | undefined>>;
|
||||
readonly launcher?: PcapCommandLauncher;
|
||||
readonly artifactReader?: typeof readStableArtifact;
|
||||
}
|
||||
|
||||
interface RetainedPcapCommand {
|
||||
@@ -148,18 +156,13 @@ interface RetainedSnapshotFailure {
|
||||
|
||||
/** Inspect saved captures through bounded, owned tool processes and one stable input copy. */
|
||||
export class PcapProvider {
|
||||
readonly #resources = new ArtifactAnalysisResources(
|
||||
"pcap",
|
||||
"inspect_pcap_capture",
|
||||
readStableArtifact,
|
||||
readLinuxResourceLimits,
|
||||
{
|
||||
message: "A packet summary is already active; retry after it completes.",
|
||||
details: { max_concurrent_inspections: 1 },
|
||||
},
|
||||
);
|
||||
readonly #resources: ArtifactAnalysisResources;
|
||||
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,
|
||||
@@ -173,6 +176,17 @@ export class PcapProvider {
|
||||
constructor(options: PcapProviderOptions = {}) {
|
||||
this.environment = options.environment ?? process.env;
|
||||
this.launcher = options.launcher;
|
||||
this.#resources = new ArtifactAnalysisResources(
|
||||
"pcap",
|
||||
"inspect_pcap_capture",
|
||||
options.artifactReader ?? readStableArtifact,
|
||||
readLinuxResourceLimits,
|
||||
{
|
||||
message:
|
||||
"A packet summary is already active; retry after it completes.",
|
||||
details: { max_concurrent_inspections: 1 },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async run(
|
||||
@@ -187,10 +201,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,
|
||||
wiresharkExecutableIdentity(before),
|
||||
);
|
||||
}
|
||||
output = await runPcapCommand({
|
||||
operation,
|
||||
command,
|
||||
command: selected?.path ?? command,
|
||||
arguments: args,
|
||||
limits,
|
||||
signal,
|
||||
@@ -219,6 +247,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,
|
||||
wiresharkExecutableIdentity(after),
|
||||
{
|
||||
capturedOutput: {
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
truncated: output.cleanup?.capturedOutput.truncated ?? false,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (
|
||||
retainedCommand !== undefined &&
|
||||
@@ -263,6 +310,40 @@ export class PcapProvider {
|
||||
|
||||
private async client(operation: PcapOperation, signal?: AbortSignal) {
|
||||
const selection = resolvePcapTools(this.environment);
|
||||
this.wiresharkIdentities.clear();
|
||||
for (const [name, selected] of [
|
||||
["tshark", selection.tshark],
|
||||
["capinfos", selection.capinfos],
|
||||
] as const) {
|
||||
const path = locateWiresharkTool(selected, this.environment);
|
||||
if (path === undefined) continue;
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(path);
|
||||
} catch (cause) {
|
||||
if (
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
||||
)
|
||||
throw new PcapConfigurationFailure(
|
||||
`${name}_missing`,
|
||||
`The selected ${name} executable could not be found at its configured path.`,
|
||||
{ command: selected.command },
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
const snapshot = await this.#resources.read(
|
||||
resolvedPath,
|
||||
64 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
this.wiresharkIdentities.set(selected.command, {
|
||||
path: resolvedPath,
|
||||
identity: wiresharkExecutableIdentity(snapshot),
|
||||
});
|
||||
}
|
||||
const identity = await inspectPcapClient(
|
||||
selection,
|
||||
async (command) =>
|
||||
@@ -292,9 +373,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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -567,17 +669,39 @@ export class PcapProvider {
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
result = err(
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: cause instanceof PcapConfigurationFailure
|
||||
? pcapToolUnavailable(request.operation, cause.message, {
|
||||
cause,
|
||||
const classifyArtifactFailure = (failure: unknown): AnalysisError =>
|
||||
failure instanceof AnalysisError
|
||||
? failure
|
||||
: failure instanceof ArtifactReaderFailure &&
|
||||
failure.reason === "cancelled"
|
||||
? new AnalysisCancelledError(request.operation, {
|
||||
cause: artifactFileCancellationCause(failure),
|
||||
...(failure.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: failure.partialObservation }),
|
||||
})
|
||||
: new AnalysisProtocolError(
|
||||
`Packet-capture analysis failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ cause },
|
||||
),
|
||||
`Packet-capture analysis failed: ${failure instanceof Error ? failure.message : String(failure)}`,
|
||||
{ cause: failure },
|
||||
);
|
||||
result = err(
|
||||
cause instanceof ArtifactReaderFailure
|
||||
? this.#resources.cleanupFailure(
|
||||
cause,
|
||||
classifyArtifactFailure,
|
||||
undefined,
|
||||
request.operation,
|
||||
)
|
||||
: cause instanceof AnalysisError
|
||||
? cause
|
||||
: cause instanceof PcapConfigurationFailure
|
||||
? pcapToolUnavailable(request.operation, cause.message, {
|
||||
cause,
|
||||
})
|
||||
: new AnalysisProtocolError(
|
||||
`Packet-capture analysis failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (signal?.aborted && result.ok)
|
||||
|
||||
@@ -114,6 +114,10 @@ import type { AndroidAnalysisPort } from "../application/android/AndroidAnalysis
|
||||
import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js";
|
||||
import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js";
|
||||
import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js";
|
||||
import {
|
||||
locateWiresharkTool,
|
||||
selectWiresharkTool,
|
||||
} from "../pcap/PcapConfiguration.js";
|
||||
import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js";
|
||||
import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js";
|
||||
import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js";
|
||||
@@ -198,6 +202,21 @@ export interface CreateServerOptions {
|
||||
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
|
||||
}
|
||||
|
||||
/** Apply the shared TShark selection rule without starting the tool. */
|
||||
const tsharkLocatable = (environment: Readonly<NodeJS.ProcessEnv>): boolean => {
|
||||
try {
|
||||
return (
|
||||
locateWiresharkTool(
|
||||
selectWiresharkTool(environment, "tshark"),
|
||||
environment,
|
||||
) !== undefined
|
||||
);
|
||||
} catch {
|
||||
// A relative REA_TSHARK_COMMAND is configuration the tool reports at execution.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const installSessionToolAvailability = (
|
||||
session: BinarySessionPort | undefined,
|
||||
options: CreateServerOptions,
|
||||
@@ -214,7 +233,7 @@ const installSessionToolAvailability = (
|
||||
optionalFeatures: {
|
||||
packetCaptureEnabled:
|
||||
options.packetCapture !== undefined ||
|
||||
(linux && isAbsolute(environment.REA_TSHARK_COMMAND ?? "")),
|
||||
(linux && tsharkLocatable(environment)),
|
||||
signalRecordingEnabled:
|
||||
options.signalRecording !== undefined ||
|
||||
(linux && isAbsolute(environment.REA_SIGROK_COMMAND ?? "")),
|
||||
|
||||
@@ -41,6 +41,7 @@ else { if(readFileSync('capture', 'utf8') !== 'recording bytes') throw Error('wr
|
||||
const configuredCommand =
|
||||
mode === "symlink" ? join(directory, "configured-tool") : script;
|
||||
if (mode === "symlink") await symlink(script, configuredCommand);
|
||||
if (mode === "path") await symlink(script, join(directory, "tshark"));
|
||||
const controller = new AbortController();
|
||||
let calls = 0;
|
||||
let mayClean = !failCleanup;
|
||||
@@ -49,7 +50,9 @@ else { if(readFileSync('capture', 'utf8') !== 'recording bytes') throw Error('wr
|
||||
const provider = new TsharkCaptureProvider(
|
||||
{
|
||||
...process.env,
|
||||
REA_TSHARK_COMMAND: configuredCommand,
|
||||
...(mode === "path"
|
||||
? { PATH: directory, REA_TSHARK_COMMAND: undefined }
|
||||
: { REA_TSHARK_COMMAND: configuredCommand }),
|
||||
REA_TSHARK_PRLIMIT_COMMAND: configuredCommand,
|
||||
},
|
||||
async (input) => {
|
||||
@@ -408,6 +411,41 @@ it("refuses unsupported hosts before launching or allocating a workspace", async
|
||||
});
|
||||
});
|
||||
|
||||
it("selects TShark from PATH with the shared rule and records the selection", async () => {
|
||||
const f = await fixture("path");
|
||||
const result = await f.service.inspect({ path: f.path, fields: ["ip.addr"] });
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.normalized_result).toMatchObject({
|
||||
tool: {
|
||||
configured_path: join(f.directory, "tshark"),
|
||||
selection_source: "path",
|
||||
path: join(f.directory, "producer.mjs"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a relative REA_TSHARK_COMMAND instead of searching PATH", async () => {
|
||||
const provider = new TsharkCaptureProvider(
|
||||
{ REA_TSHARK_COMMAND: "tshark", PATH: "/usr/bin" },
|
||||
async () => {
|
||||
throw new Error("Must not launch");
|
||||
},
|
||||
async () => {
|
||||
throw new Error("Must not allocate");
|
||||
},
|
||||
"linux",
|
||||
);
|
||||
const result = await new PacketCaptureService(provider).inspect({
|
||||
path: "/recording.pcap",
|
||||
fields: ["ip.addr"],
|
||||
});
|
||||
if (result.ok) throw new Error("Expected configuration refusal");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
reason: "REA_TSHARK_COMMAND must be an absolute path to the tshark binary",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves complete Evidence when aborted during post-run integrity checks", async () => {
|
||||
const f = await fixture("post-run-abort");
|
||||
const result = await f.service.inspect(
|
||||
@@ -435,6 +473,7 @@ it("preserves configured symlink paths separately from resolved tool identities"
|
||||
expect(result.value.normalized_result).toMatchObject({
|
||||
tool: {
|
||||
configured_path: join(f.directory, "configured-tool"),
|
||||
selection_source: "environment",
|
||||
path: join(f.directory, "producer.mjs"),
|
||||
},
|
||||
resource_limiter: {
|
||||
|
||||
@@ -15,9 +15,15 @@ const clientIdentity = {
|
||||
tshark_command: "/usr/bin/tshark",
|
||||
tshark_source: "path",
|
||||
tshark_version: "TShark (Wireshark) 4.6.6",
|
||||
tshark_resolved_path: null,
|
||||
tshark_sha256: null,
|
||||
tshark_identity_status: "unresolved",
|
||||
capinfos_command: "/usr/bin/capinfos",
|
||||
capinfos_source: "path",
|
||||
capinfos_version: "Capinfos (Wireshark) 4.6.6",
|
||||
capinfos_resolved_path: null,
|
||||
capinfos_sha256: null,
|
||||
capinfos_identity_status: "unresolved",
|
||||
};
|
||||
|
||||
it("advertises exact valid pcap schemas and records inline evidence through MCP", async () => {
|
||||
|
||||
@@ -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",
|
||||
details: { path: tshark },
|
||||
});
|
||||
});
|
||||
|
||||
it("inspects a capture with endpoints, DNS, TLS SNI, and HTTP", async () => {
|
||||
const { provider, root } = await providerWith();
|
||||
const path = await captureFixture(root);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { dirname, join } from "node:path";
|
||||
import { expect, onTestFinished, test, vi } from "vitest";
|
||||
|
||||
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
|
||||
import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js";
|
||||
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
||||
import { resolvedAnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
||||
@@ -62,6 +64,7 @@ const fixture = async (
|
||||
reason: unknown,
|
||||
failedCleanupAttempts: number,
|
||||
activeVersion = false,
|
||||
artifactReader: typeof readStableArtifact = readStableArtifact,
|
||||
) => {
|
||||
const root = await createTestTempDirectory("rea-pcap-provider-cancel-");
|
||||
const tools = await writeTools(root, mode, activeVersion);
|
||||
@@ -100,6 +103,7 @@ const fixture = async (
|
||||
REA_CAPINFOS_COMMAND: tools.capinfos,
|
||||
},
|
||||
launcher,
|
||||
artifactReader,
|
||||
});
|
||||
onTestFinished(async () => provider.close());
|
||||
return { provider, root, capture, cleanupAttempts: () => cleanupAttempts };
|
||||
@@ -123,6 +127,31 @@ it("availability preserves the producer's exact cancellation reason", async () =
|
||||
expect(failure).toMatchObject({ cause: reason });
|
||||
});
|
||||
|
||||
it("executable identity hashing preserves artifact-read cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = { caller: "cancel executable identity read" };
|
||||
const { provider } = await fixture(
|
||||
"active",
|
||||
controller,
|
||||
reason,
|
||||
0,
|
||||
false,
|
||||
async () => {
|
||||
throw new ArtifactReaderFailure("cancelled", "identity read cancelled", {
|
||||
cancellation: { reason },
|
||||
});
|
||||
},
|
||||
);
|
||||
const result = await provider.execute(
|
||||
{ operation: "inspect_pcap_client", input: {} },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
});
|
||||
|
||||
it("active cancellation stays primary through retained command and snapshot cleanup retry", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = "caller cancellation token";
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* refusal paths, and cancellation before spawn.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { open, 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";
|
||||
@@ -52,6 +52,41 @@ const evidenceResult = async (
|
||||
return result.value.result as Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Windows keeps PATH/PATHEXT resolution in the launcher; POSIX hosts locate the file.
|
||||
it.skipIf(isWindows)(
|
||||
"runs and reports the tools located on PATH rather than bare names",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-pcap-path-");
|
||||
await symlink(tsharkStub, join(directory, "tshark"));
|
||||
await symlink(capinfosStub, join(directory, "capinfos"));
|
||||
const provider = new PcapProvider({
|
||||
environment: { PATH: `relative-entry:${directory}` },
|
||||
});
|
||||
onTestFinished(() => provider.close());
|
||||
const result = await evidenceResult(provider, {
|
||||
operation: "inspect_pcap_client",
|
||||
input: {},
|
||||
});
|
||||
expect(result.client).toMatchObject({
|
||||
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",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("inspects both tools through cross-platform stubs", async () => {
|
||||
const result = await evidenceResult(providerOver(), {
|
||||
operation: "inspect_pcap_client",
|
||||
@@ -60,11 +95,81 @@ 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("projects a missing configured tool as a capability gap", async () => {
|
||||
const provider = new PcapProvider({
|
||||
environment: {
|
||||
REA_TSHARK_COMMAND: tsharkStub,
|
||||
REA_CAPINFOS_COMMAND: join(import.meta.dirname, "missing-capinfos"),
|
||||
},
|
||||
});
|
||||
onTestFinished(() => provider.close());
|
||||
const result = await provider.execute({
|
||||
operation: "inspect_pcap_client",
|
||||
input: {},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
message: expect.stringContaining(
|
||||
"Supply Wireshark 4.x tshark and capinfos",
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user