feat(captures): discover installed protocol and field definitions (#2496)
* feat(captures): discover installed protocol and field definitions * test(captures): isolate the direct glossary oracle profile * test(captures): accept padded kernel limit reports * fix(captures): detach every retained glossary column * fix(captures): describe every field discovery CLI option * fix(captures): retain discovery operation across cleanup retries --------- Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
This commit is contained in:
parent
b9ee921560
commit
9c34c091b3
|
|
@ -76,6 +76,9 @@ explicit path or endpoint and do not need it.
|
|||
Facts retain all-recorded-events-before-selection scope. Other qlog layouts
|
||||
and PCAP correlation are unsupported.
|
||||
- User-owned Electron runtime already open: `list_electron_targets`.
|
||||
- Packet-field discovery: `inspect_packet_fields` searches installed protocols
|
||||
and field names/types before choosing fields for `inspect_packet_capture`.
|
||||
Query/page the relevant records; registrations do not establish observed traffic.
|
||||
- Recorded packet captures: `inspect_packet_capture` with selected TShark fields,
|
||||
display filters and decode-as rules. Requires Linux, util-linux prlimit and `REA_TSHARK_COMMAND`; see
|
||||
[capture guide](https://github.com/morluto/rea/blob/main/docs/packet-captures.md).
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
- run: sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y tshark util-linux python3
|
||||
- run: npm ci
|
||||
- run: npm run build:cached
|
||||
- run: npm run test:focused -- src/process/LinuxResourceLimits.test.ts tests/boundary/captures/captureProvider.test.ts tests/boundary/captures/captureDescriptorCleanup.test.ts tests/boundary/captures/resourceLimits.test.ts tests/boundary/captures/packetOutput.test.ts tests/boundary/mcp/packetCaptureMcp.test.ts tests/boundary/cli/captureCleanup.test.ts
|
||||
- run: npm run test:focused -- tests/boundary/captures/fieldDiscovery.test.ts src/process/LinuxResourceLimits.test.ts tests/boundary/captures/captureProvider.test.ts tests/boundary/captures/captureDescriptorCleanup.test.ts tests/boundary/captures/resourceLimits.test.ts tests/boundary/captures/packetOutput.test.ts tests/boundary/mcp/packetCaptureMcp.test.ts tests/boundary/cli/captureCleanup.test.ts
|
||||
- run: node scripts/verify-packet-capture.mjs
|
||||
env:
|
||||
REA_TSHARK_COMMAND: /usr/bin/tshark
|
||||
|
|
|
|||
|
|
@ -128,3 +128,49 @@ Each provider admits one inspection at a time, including acquisition and cleanup
|
|||
An occupied provider returns a retryable capacity error before acquiring another
|
||||
file or worker. Closing the provider stops new admission and drains accepted work;
|
||||
failed cleanup remains owned and can be retried.
|
||||
|
||||
## Discover protocols and field names
|
||||
|
||||
`inspect_packet_fields` / `inspect-packet-fields` queries the installed TShark
|
||||
registration glossary before selecting fields for capture inspection:
|
||||
|
||||
```sh
|
||||
rea inspect-packet-fields --query usb
|
||||
rea inspect-packet-fields --kind fields --protocol usb --query idVendor
|
||||
rea inspect-packet-fields --kind fields --protocol udp --all
|
||||
```
|
||||
|
||||
`kind` defaults to `protocols`; `fields` selects field definitions. `protocol`
|
||||
selects an exact protocol abbreviation (the protocol itself or its child fields).
|
||||
`query` is a case-insensitive literal substring in name, abbreviation or field
|
||||
description. `offset` skips matching records; `limit` defaults to 50. MCP
|
||||
`limit: null` or CLI `--all` requests all remaining matches within the resource
|
||||
budget. `--all` and `--limit` conflict. The window reports examined records of
|
||||
the selected kind before filters, matching records, returned records and
|
||||
`has_more`; it never counts capture packets.
|
||||
|
||||
Returned records preserve field type, parent protocol, display base, bitmask,
|
||||
description and original glossary line number. Empty producer strings remain
|
||||
empty; protocol records have null field-only properties. Repeated abbreviations
|
||||
remain separate registrations. A listed field is an installation capability, not
|
||||
proof that the field appears in a selected recording.
|
||||
|
||||
The adapter uses the documented `tshark -G fields` grammar under its existing
|
||||
private empty profile and Linux process limits. TShark 4.6.9's full glossary was
|
||||
measured at 22,303,197 bytes; discovery admits at most **32 MiB** combined command
|
||||
output (capture inspection remains at **4 MiB**). Parsing walks lines once and
|
||||
constructs objects only for the selected page. Returned strings are copied so
|
||||
retained pages do not retain the complete glossary string. Before constructing a selected
|
||||
record it charges six times the source UTF-8 bytes plus 512 bytes against an
|
||||
**8 MiB** representation budget, covering JSON escaping and record bookkeeping.
|
||||
If that budget would be exceeded, narrow the query or page size; results are not
|
||||
silently truncated. This is a conservative admission budget, not measured heap
|
||||
usage. Parent pipe buffers and producer per-process limits remain distinct.
|
||||
|
||||
Tool/limiter identity, version, stderr and selected arguments are retained in
|
||||
Evidence. No capture, interface or device is opened. Discovery shares capture
|
||||
analysis's admission slot and cleanup owner. Each call rereads the glossary;
|
||||
installed plugins, libraries and global configuration remain trusted and are not
|
||||
fully fingerprinted. There is no cache or snapshot-consistent pagination claim.
|
||||
The real Linux lane checks public CLI/MCP queries against the direct glossary,
|
||||
including USB vendor-ID typing, negative queries and paging.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { estimateTokenCount } from "tokenx";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, readdir } from "node:fs/promises";
|
||||
import { readFile, writeFile, readdir, mkdir, rm } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
|
@ -91,6 +92,20 @@ const source = pcap(1, [
|
|||
// LINKTYPE_BLUETOOTH_HCI_H4 = 187; H4 command packet, HCI Reset opcode 0x0c03, no parameters.
|
||||
const bluetoothSource = pcap(187, [Buffer.from("01030c00", "hex")]);
|
||||
let cases = 0;
|
||||
const discoveryMeasurements = [];
|
||||
const inheritedAddressSpace = (await readFile("/proc/self/limits", "utf8"))
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("Max address space "))
|
||||
?.trim()
|
||||
.match(/^Max address space\s+(\S+)\s+(\S+)\s+bytes$/u);
|
||||
assert.ok(inheritedAddressSpace);
|
||||
const expectedAddressSpace = Math.min(
|
||||
1073741824,
|
||||
...inheritedAddressSpace
|
||||
.slice(1)
|
||||
.map((value) => (value === "unlimited" ? Infinity : Number(value))),
|
||||
);
|
||||
|
||||
try {
|
||||
await writeFile(path, source);
|
||||
await writeFile(bluetooth, bluetoothSource);
|
||||
|
|
@ -121,14 +136,16 @@ try {
|
|||
.digest("hex"),
|
||||
);
|
||||
assert.equal(report.hardware_acquisition, "not-performed");
|
||||
assert.equal(report.limits.address_space_bytes, 1073741824);
|
||||
assert.equal(report.limits.address_space_bytes, expectedAddressSpace);
|
||||
assert.equal(report.tool.configured_path, process.env.REA_TSHARK_COMMAND);
|
||||
assert.equal(
|
||||
report.resource_limiter.configured_path,
|
||||
process.env.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
|
||||
);
|
||||
assert.ok(
|
||||
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"),
|
||||
report.resource_limiter.arguments.includes(
|
||||
`--as=${expectedAddressSpace}:${expectedAddressSpace}`,
|
||||
),
|
||||
);
|
||||
assert.equal(report.configuration, "private-empty-personal-profile");
|
||||
const flags = Object.entries(input)
|
||||
|
|
@ -313,6 +330,133 @@ try {
|
|||
true,
|
||||
);
|
||||
cases++;
|
||||
const discovery = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_packet_fields",
|
||||
);
|
||||
assert.ok(discovery?.outputSchema);
|
||||
assert.ok(ajv.validateSchema(discovery.inputSchema));
|
||||
assert.ok(ajv.validateSchema(discovery.outputSchema));
|
||||
const nativeProfile = join(root.path, "native-profile");
|
||||
await mkdir(nativeProfile);
|
||||
const native = (
|
||||
await execute(process.env.REA_TSHARK_COMMAND, ["-G", "fields"], {
|
||||
env: { ...environment, WIRESHARK_CONFIG_DIR: nativeProfile },
|
||||
timeout: 30000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
})
|
||||
).stdout;
|
||||
await rm(nativeProfile, { recursive: true });
|
||||
const nativeLines = native.split(/\r?\n/);
|
||||
if (nativeLines.at(-1) === "") nativeLines.pop();
|
||||
for (const input of [
|
||||
{ query: "usb" },
|
||||
{ limit: 1 },
|
||||
{ offset: 1, limit: 2 },
|
||||
{ kind: "fields", protocol: "usb", query: "idVendor" },
|
||||
{ kind: "fields", protocol: "udp", limit: null },
|
||||
{ kind: "fields", query: "rea_no_such_field" },
|
||||
]) {
|
||||
const started = performance.now();
|
||||
const response = await client.callTool({
|
||||
name: discovery.name,
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(
|
||||
ajv.validate(discovery.outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
const report = evidence.normalized_result;
|
||||
const kind = input.kind ?? "protocols";
|
||||
const query = (input.query ?? "").toLowerCase();
|
||||
const expectedMatches = nativeLines.flatMap((line, index) => {
|
||||
const columns = line.split("\t");
|
||||
if ((kind === "protocols") !== (columns[0] === "P")) return [];
|
||||
if (
|
||||
input.protocol !== undefined &&
|
||||
(columns[0] === "P" ? columns[2] : columns[4]) !== input.protocol
|
||||
)
|
||||
return [];
|
||||
if (
|
||||
![
|
||||
columns[1],
|
||||
columns[2],
|
||||
...(columns[0] === "F" ? [columns[7]] : []),
|
||||
].some((value) => value.toLowerCase().includes(query))
|
||||
)
|
||||
return [];
|
||||
return [{ sourceLine: index + 1, columns }];
|
||||
});
|
||||
const offset = input.offset ?? 0;
|
||||
const limit = input.limit === null ? undefined : (input.limit ?? 50);
|
||||
const expectedPage = expectedMatches.slice(
|
||||
offset,
|
||||
limit === undefined ? undefined : offset + limit,
|
||||
);
|
||||
assert.deepEqual(
|
||||
report.records.map((record) => record.source_line),
|
||||
expectedPage.map((record) => record.sourceLine),
|
||||
);
|
||||
assert.deepEqual(report.output_window, {
|
||||
examined: nativeLines.filter((line) =>
|
||||
line.startsWith(kind === "protocols" ? "P\t" : "F\t"),
|
||||
).length,
|
||||
matching: expectedMatches.length,
|
||||
returned: expectedPage.length,
|
||||
has_more:
|
||||
Math.max(0, expectedMatches.length - offset) > expectedPage.length,
|
||||
});
|
||||
discoveryMeasurements.push({
|
||||
input,
|
||||
elapsed_ms: +(performance.now() - started).toFixed(2),
|
||||
response_bytes: Buffer.byteLength(JSON.stringify(response)),
|
||||
estimated_tokens_one_tool_request_response: estimateTokenCount(
|
||||
JSON.stringify(discovery) +
|
||||
JSON.stringify(input) +
|
||||
JSON.stringify(response),
|
||||
),
|
||||
});
|
||||
for (const record of report.records) {
|
||||
const columns = nativeLines[record.source_line - 1].split("\t");
|
||||
assert.equal(record.name, columns[1]);
|
||||
assert.equal(record.abbreviation, columns[2]);
|
||||
assert.equal(record.field_type, columns[0] === "F" ? columns[3] : null);
|
||||
assert.equal(
|
||||
record.parent_protocol,
|
||||
columns[0] === "F" ? columns[4] : null,
|
||||
);
|
||||
assert.equal(record.description, columns[0] === "F" ? columns[7] : null);
|
||||
assert.equal(record.display_base, columns[0] === "F" ? columns[5] : null);
|
||||
assert.equal(record.bitmask, columns[0] === "F" ? columns[6] : null);
|
||||
}
|
||||
if (input.query === "idVendor")
|
||||
assert.ok(
|
||||
report.records.some(
|
||||
(row) =>
|
||||
row.abbreviation === "usb.idVendor" &&
|
||||
row.field_type === "FT_UINT16",
|
||||
),
|
||||
);
|
||||
if (input.query === "usb")
|
||||
assert.ok(report.records.some((row) => row.abbreviation === "usb"));
|
||||
if (input.query === "rea_no_such_field")
|
||||
assert.equal(report.output_window.matching, 0);
|
||||
const flags = Object.entries(input).flatMap(([key, value]) =>
|
||||
value === null ? ["--all"] : [`--${key}`, String(value)],
|
||||
);
|
||||
const cli = JSON.parse(
|
||||
(
|
||||
await execute(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-packet-fields", ...flags, "--format", "json"],
|
||||
{ env: environment, timeout: 60000, maxBuffer: 16 * 1024 * 1024 },
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
assert.deepEqual(cli.normalized_result, report);
|
||||
cases++;
|
||||
}
|
||||
await client.close();
|
||||
assert.deepEqual((await readdir(root.path)).sort(), [
|
||||
"bluetooth.pcap",
|
||||
|
|
@ -329,6 +473,9 @@ console.log(
|
|||
{
|
||||
status: "passed",
|
||||
cases,
|
||||
discovery_measurements: discoveryMeasurements,
|
||||
measurement_scope:
|
||||
"Warm MCP server, fresh TShark processes; tokenx estimates include one advertised tool/request/response, not billed usage. Model-driven selection, CPU and peak process-family memory are unmeasured; no cache/speedup claim.",
|
||||
host: `${process.platform}/${process.arch}`,
|
||||
scope:
|
||||
"real TShark CLI/MCP; synthetic Ethernet/IPv4/UDP/DNS and Bluetooth HCI PCAP; no hardware acquisition",
|
||||
|
|
|
|||
|
|
@ -397,7 +397,7 @@ const workflowAvailabilityFor = ({
|
|||
remediation:
|
||||
"On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.",
|
||||
};
|
||||
if (name === "inspect_packet_capture")
|
||||
if (name === "inspect_packet_capture" || name === "inspect_packet_fields")
|
||||
return policy.packetCaptureEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import {
|
||||
inspectPacketFieldsInputSchema,
|
||||
packetFieldsCatalogSchema,
|
||||
type InspectPacketFieldsInput,
|
||||
} from "../../domain/captures/packetFields.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
|
|
@ -24,6 +30,10 @@ export interface PacketCapturePort {
|
|||
input: InspectPacketCaptureInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
inspectFields(
|
||||
input: InspectPacketFieldsInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -74,14 +84,58 @@ export class PacketCaptureService {
|
|||
"Capture analyzer returned malformed data or changed the selected artifact identity/options.",
|
||||
),
|
||||
);
|
||||
const evidence = createEvidence(execution.subject, execution.provider, {
|
||||
operation,
|
||||
parameters: input.data,
|
||||
result: report.data,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
});
|
||||
return this.#evidence(operation, input.data, execution, options);
|
||||
}
|
||||
async inspectFields(
|
||||
raw: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const operation = "inspect_packet_fields";
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const input = inspectPacketFieldsInputSchema.safeParse(raw);
|
||||
if (!input.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(operation, input.error.issues, raw, {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
const decoded = await this.provider.inspectFields(input.data, options);
|
||||
if (!decoded.ok) return decoded;
|
||||
const execution = decoded.value;
|
||||
const report = packetFieldsCatalogSchema.safeParse(execution.result);
|
||||
if (
|
||||
!report.success ||
|
||||
JSON.stringify(report.data.selection) !== JSON.stringify(input.data) ||
|
||||
execution.subject?.path !== report.data.tool.path ||
|
||||
execution.subject.sha256 !== report.data.tool.sha256
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Field discovery returned malformed data or changed the selected installation/options.",
|
||||
),
|
||||
);
|
||||
return this.#evidence(operation, input.data, execution, options);
|
||||
}
|
||||
#evidence(
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
execution: AnalysisExecution,
|
||||
options?: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> {
|
||||
const evidence = createEvidence(
|
||||
execution.subject ?? undefined,
|
||||
execution.provider,
|
||||
{
|
||||
operation,
|
||||
parameters,
|
||||
result: execution.result,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
},
|
||||
);
|
||||
return options?.signal?.aborted
|
||||
? err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,12 @@
|
|||
import {
|
||||
type InspectPacketFieldsInput,
|
||||
packetFieldsCatalogSchema,
|
||||
} from "../domain/captures/packetFields.js";
|
||||
import {
|
||||
parseFieldDiscovery,
|
||||
FieldDiscoveryBudgetError,
|
||||
FIELD_RECORD_BUDGET,
|
||||
} from "./parseFieldDiscovery.js";
|
||||
import {
|
||||
readLinuxResourceLimits,
|
||||
type LinuxResourceLimits,
|
||||
|
|
@ -91,17 +100,31 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
return this.#resources.run(
|
||||
() => this.#inspect(input, options),
|
||||
() => this.#executeRequest(input, options),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
inspectFields(
|
||||
input: InspectPacketFieldsInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
return this.#resources.run(
|
||||
() => this.#executeRequest(input, options),
|
||||
options?.signal,
|
||||
"inspect_packet_fields",
|
||||
);
|
||||
}
|
||||
close(): Promise<void> {
|
||||
return this.#resources.close();
|
||||
}
|
||||
async #inspect(
|
||||
input: InspectPacketCaptureInput,
|
||||
async #executeRequest(
|
||||
input: InspectPacketCaptureInput | InspectPacketFieldsInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
const operation = "path" in input ? OPERATION : "inspect_packet_fields";
|
||||
// TShark 4.6.9 emits a 22.3 MB glossary; keep capture output capped at 4 MiB.
|
||||
const limits =
|
||||
"path" in input ? LIMITS : { ...LIMITS, output_bytes: 32 * 1024 * 1024 };
|
||||
let phase:
|
||||
| "configuration"
|
||||
| "artifact"
|
||||
|
|
@ -109,12 +132,12 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
| "producer"
|
||||
| "integrity" = "configuration";
|
||||
let capturedOutput: AnalysisCapturedOutput | undefined;
|
||||
let resourcePath = input.path;
|
||||
let resourcePath = "path" in input ? input.path : "TShark field glossary";
|
||||
let effectiveLimits: LinuxResourceLimits | undefined;
|
||||
let root: ArtifactAnalysisRuntime | undefined;
|
||||
let result: Result<AnalysisExecution, AnalysisError>;
|
||||
try {
|
||||
if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION);
|
||||
if (options?.signal?.aborted) throw new AnalysisCancelledError(operation);
|
||||
await this.#resources.release();
|
||||
if (this.platform !== "linux")
|
||||
throw new Error(
|
||||
|
|
@ -141,7 +164,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
64 * 1024 * 1024,
|
||||
options?.signal,
|
||||
);
|
||||
effectiveLimits = await this.#resources.readLimits(LIMITS);
|
||||
effectiveLimits = await this.#resources.readLimits(limits);
|
||||
const limitArguments = [
|
||||
`--as=${effectiveLimits.address_space_bytes}:${effectiveLimits.address_space_bytes}`,
|
||||
`--fsize=${effectiveLimits.file_bytes}:${effectiveLimits.file_bytes}`,
|
||||
|
|
@ -152,7 +175,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
];
|
||||
phase = "workspace";
|
||||
root = await this.createRuntime();
|
||||
this.#resources.retain(root, undefined);
|
||||
this.#resources.retain(root, undefined, operation);
|
||||
const snapshotPath = join(root.path, "capture");
|
||||
const configurationPath = join(root.path, "configuration");
|
||||
await mkdir(configurationPath, { mode: 0o700 });
|
||||
|
|
@ -170,8 +193,8 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
},
|
||||
},
|
||||
{
|
||||
timeoutMs: LIMITS.command_timeout_ms,
|
||||
diagnosticBytes: LIMITS.output_bytes,
|
||||
timeoutMs: limits.command_timeout_ms,
|
||||
diagnosticBytes: limits.output_bytes,
|
||||
acceptNonZeroExit: true,
|
||||
},
|
||||
{
|
||||
|
|
@ -200,24 +223,24 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
);
|
||||
if (currentLimiter.sha256 !== limiterIdentity.sha256)
|
||||
throw new AnalysisArtifactChangedError(
|
||||
OPERATION,
|
||||
operation,
|
||||
limiter,
|
||||
"Configured resource limiter changed during analysis.",
|
||||
{ capturedOutput },
|
||||
);
|
||||
if (current.sha256 !== tool.sha256)
|
||||
throw new AnalysisArtifactChangedError(
|
||||
OPERATION,
|
||||
operation,
|
||||
command,
|
||||
"Configured TShark executable changed during analysis.",
|
||||
{ capturedOutput },
|
||||
);
|
||||
phase = previousPhase;
|
||||
resourcePath = input.path;
|
||||
resourcePath = "path" in input ? input.path : command;
|
||||
if (output.exitCode !== 0)
|
||||
throw new ProviderAdapterError(
|
||||
TSHARK_PROVIDER_IDENTITY.id,
|
||||
OPERATION,
|
||||
operation,
|
||||
{
|
||||
diagnostics: {
|
||||
exit_code: output.exitCode ?? null,
|
||||
|
|
@ -238,86 +261,15 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
throw new Error(
|
||||
"Expected TShark 4.x; inspect the selected installation.",
|
||||
);
|
||||
phase = "artifact";
|
||||
const selected = await this.#resources.read(
|
||||
input.path,
|
||||
LIMITS.input_bytes,
|
||||
options?.signal,
|
||||
);
|
||||
phase = "workspace";
|
||||
await writeFile(snapshotPath, selected.bytes, {
|
||||
flag: "wx",
|
||||
mode: 0o400,
|
||||
});
|
||||
phase = "producer";
|
||||
const args = [
|
||||
"-n",
|
||||
"-r",
|
||||
"capture",
|
||||
"-T",
|
||||
"json",
|
||||
"--no-duplicate-keys",
|
||||
"--temp-dir=.",
|
||||
...(input.two_pass ? ["-2"] : []),
|
||||
...(input.packet_limit === null
|
||||
? []
|
||||
: ["-c", String(input.packet_limit)]),
|
||||
...(input.display_filter === null ? [] : ["-Y", input.display_filter]),
|
||||
...input.decode_as.flatMap((value) => ["-d", value]),
|
||||
...[...new Set(["frame.number", ...input.fields])].flatMap((field) => [
|
||||
"-e",
|
||||
field,
|
||||
]),
|
||||
];
|
||||
const output = await execute(args);
|
||||
phase = "integrity";
|
||||
resourcePath = snapshotPath;
|
||||
if (
|
||||
(await this.#resources.read(snapshotPath, LIMITS.input_bytes))
|
||||
.sha256 !== selected.sha256
|
||||
)
|
||||
throw new AnalysisArtifactChangedError(
|
||||
OPERATION,
|
||||
snapshotPath,
|
||||
"TShark changed the private capture copy.",
|
||||
capturedOutput === undefined ? {} : { capturedOutput },
|
||||
);
|
||||
phase = "producer";
|
||||
resourcePath = input.path;
|
||||
const matchingPackets = parsePacketOutput(
|
||||
output.stdout.text,
|
||||
input.fields,
|
||||
);
|
||||
const start = Math.min(input.match_offset, matchingPackets.length);
|
||||
const end =
|
||||
input.match_limit === null
|
||||
? matchingPackets.length
|
||||
: start + Math.min(input.match_limit, matchingPackets.length - start);
|
||||
const packets = matchingPackets.slice(start, end);
|
||||
const report = packetCaptureSchema.parse({
|
||||
artifact: {
|
||||
path: input.path,
|
||||
sha256: selected.sha256,
|
||||
bytes: selected.bytes.length,
|
||||
},
|
||||
selection: input,
|
||||
packets,
|
||||
output_window: {
|
||||
matching_packets: matchingPackets.length,
|
||||
returned_packets: packets.length,
|
||||
has_more: end < matchingPackets.length,
|
||||
},
|
||||
read_scope:
|
||||
input.packet_limit === null ? "to-eof" : "up-to-input-packet-limit",
|
||||
configuration: "private-empty-personal-profile",
|
||||
diagnostics: output.stderr.text,
|
||||
const metadata = (args: readonly string[], diagnostics: string) => ({
|
||||
diagnostics,
|
||||
tool: {
|
||||
configured_path: configured,
|
||||
path: command,
|
||||
sha256: tool.sha256,
|
||||
version_banner: version.stdout.text,
|
||||
version_diagnostics: version.stderr.text,
|
||||
arguments: args,
|
||||
arguments: [...args],
|
||||
},
|
||||
resource_limiter: {
|
||||
configured_path: configuredLimiter,
|
||||
|
|
@ -325,33 +277,136 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
sha256: limiterIdentity.sha256,
|
||||
arguments: limitArguments,
|
||||
},
|
||||
limits: { ...LIMITS, ...effectiveLimits },
|
||||
hardware_acquisition: "not-performed",
|
||||
limits: { ...limits, ...effectiveLimits },
|
||||
hardware_acquisition: "not-performed" as const,
|
||||
});
|
||||
result = ok(
|
||||
createAnalysisExecution(report, TSHARK_PROVIDER_IDENTITY, {
|
||||
subject: {
|
||||
if (!("path" in input)) {
|
||||
phase = "producer";
|
||||
const args = ["-G", "fields"];
|
||||
const output = await execute(args);
|
||||
const report = packetFieldsCatalogSchema.parse({
|
||||
selection: input,
|
||||
...parseFieldDiscovery(output.stdout.text, input),
|
||||
...metadata(args, output.stderr.text),
|
||||
limits: {
|
||||
...limits,
|
||||
...effectiveLimits,
|
||||
record_budget_bytes: FIELD_RECORD_BUDGET,
|
||||
},
|
||||
configuration: "private-empty-personal-profile",
|
||||
});
|
||||
result = ok(
|
||||
createAnalysisExecution(report, TSHARK_PROVIDER_IDENTITY, {
|
||||
subject: { path: command, sha256: tool.sha256, format: "file" },
|
||||
limitations: [
|
||||
"Protocol and field registrations describe the selected installation, not traffic observed in a capture. Duplicate abbreviations remain distinct producer records with source line numbers.",
|
||||
"Each request probes again without caching. The private empty personal profile does not fingerprint or isolate installed libraries, global configuration or plugins; they remain trusted.",
|
||||
"No capture file, interface or device is opened. Resource ceilings do not constitute an OS sandbox. Glossary output is capped at 32 MiB and selected record representation at 8 MiB before expansion.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
phase = "artifact";
|
||||
const selected = await this.#resources.read(
|
||||
input.path,
|
||||
LIMITS.input_bytes,
|
||||
options?.signal,
|
||||
);
|
||||
phase = "workspace";
|
||||
await writeFile(snapshotPath, selected.bytes, {
|
||||
flag: "wx",
|
||||
mode: 0o400,
|
||||
});
|
||||
phase = "producer";
|
||||
const args = [
|
||||
"-n",
|
||||
"-r",
|
||||
"capture",
|
||||
"-T",
|
||||
"json",
|
||||
"--no-duplicate-keys",
|
||||
"--temp-dir=.",
|
||||
...(input.two_pass ? ["-2"] : []),
|
||||
...(input.packet_limit === null
|
||||
? []
|
||||
: ["-c", String(input.packet_limit)]),
|
||||
...(input.display_filter === null
|
||||
? []
|
||||
: ["-Y", input.display_filter]),
|
||||
...input.decode_as.flatMap((value) => ["-d", value]),
|
||||
...[...new Set(["frame.number", ...input.fields])].flatMap(
|
||||
(field) => ["-e", field],
|
||||
),
|
||||
];
|
||||
const output = await execute(args);
|
||||
phase = "integrity";
|
||||
resourcePath = snapshotPath;
|
||||
if (
|
||||
(await this.#resources.read(snapshotPath, LIMITS.input_bytes))
|
||||
.sha256 !== selected.sha256
|
||||
)
|
||||
throw new AnalysisArtifactChangedError(
|
||||
operation,
|
||||
snapshotPath,
|
||||
"TShark changed the private capture copy.",
|
||||
capturedOutput === undefined ? {} : { capturedOutput },
|
||||
);
|
||||
phase = "producer";
|
||||
resourcePath = "path" in input ? input.path : command;
|
||||
const matchingPackets = parsePacketOutput(
|
||||
output.stdout.text,
|
||||
input.fields,
|
||||
);
|
||||
const start = Math.min(input.match_offset, matchingPackets.length);
|
||||
const end =
|
||||
input.match_limit === null
|
||||
? matchingPackets.length
|
||||
: start +
|
||||
Math.min(input.match_limit, matchingPackets.length - start);
|
||||
const packets = matchingPackets.slice(start, end);
|
||||
const report = packetCaptureSchema.parse({
|
||||
artifact: {
|
||||
path: input.path,
|
||||
sha256: selected.sha256,
|
||||
format: "file",
|
||||
bytes: selected.bytes.length,
|
||||
},
|
||||
locations: [{ kind: "artifact-path", path: input.path }],
|
||||
limitations: [
|
||||
"Fields are TShark dissector observations under the selected filter/decode-as/pass settings, not independently verified protocol semantics. Missing fields are absent from the producer output, not known zero or empty values.",
|
||||
"Packet limits count input packets before display filtering. Filtered results do not establish whole-capture or device traffic absence. Two-pass reassembly cannot recover bytes missing from the recording or beyond the read limit.",
|
||||
"Repeated field values remain ordered string arrays; raw packet bytes are included only when explicitly selected as a supported field. Export index/score metadata are omitted. All stderr, including warnings on exit zero, is retained.",
|
||||
"A private empty personal configuration is used. Installed libraries, global configuration and dissector plugins are trusted and not fully fingerprinted. File-only analysis and disabled name resolution do not constitute an OS sandbox.",
|
||||
"Linux prlimit enforces soft and hard ceilings before execution, preserving stricter inherited limits: at most 1 GiB address space per process, 64 MiB per file, 30 CPU seconds and no core dumps. Effective limits are reported. Resident input is capped at 32 MiB, each command at 30 wall-clock seconds and combined output at 4 MiB. Output overflow fails. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
selection: input,
|
||||
packets,
|
||||
output_window: {
|
||||
matching_packets: matchingPackets.length,
|
||||
returned_packets: packets.length,
|
||||
has_more: end < matchingPackets.length,
|
||||
},
|
||||
read_scope:
|
||||
input.packet_limit === null ? "to-eof" : "up-to-input-packet-limit",
|
||||
configuration: "private-empty-personal-profile",
|
||||
...metadata(args, output.stderr.text),
|
||||
});
|
||||
result = ok(
|
||||
createAnalysisExecution(report, TSHARK_PROVIDER_IDENTITY, {
|
||||
subject: {
|
||||
path: input.path,
|
||||
sha256: selected.sha256,
|
||||
format: "file",
|
||||
},
|
||||
locations: [{ kind: "artifact-path", path: input.path }],
|
||||
limitations: [
|
||||
"Fields are TShark dissector observations under the selected filter/decode-as/pass settings, not independently verified protocol semantics. Missing fields are absent from the producer output, not known zero or empty values.",
|
||||
"Packet limits count input packets before display filtering. Filtered results do not establish whole-capture or device traffic absence. Two-pass reassembly cannot recover bytes missing from the recording or beyond the read limit.",
|
||||
"Repeated field values remain ordered string arrays; raw packet bytes are included only when explicitly selected as a supported field. Export index/score metadata are omitted. All stderr, including warnings on exit zero, is retained.",
|
||||
"A private empty personal configuration is used. Installed libraries, global configuration and dissector plugins are trusted and not fully fingerprinted. File-only analysis and disabled name resolution do not constitute an OS sandbox.",
|
||||
"Linux prlimit enforces soft and hard ceilings before execution, preserving stricter inherited limits: at most 1 GiB address space per process, 64 MiB per file, 30 CPU seconds and no core dumps. Effective limits are reported. Resident input is capped at 32 MiB, each command at 30 wall-clock seconds and combined output at 4 MiB. Output overflow fails. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (
|
||||
root !== undefined &&
|
||||
cause instanceof OwnedCommandFailure &&
|
||||
cause.cleanupOwner !== undefined
|
||||
)
|
||||
this.#resources.retain(root, cause.cleanupOwner);
|
||||
this.#resources.retain(root, cause.cleanupOwner, operation);
|
||||
result = err(
|
||||
this.#failure(
|
||||
cause,
|
||||
|
|
@ -360,18 +415,23 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
root?.path ?? null,
|
||||
effectiveLimits,
|
||||
capturedOutput,
|
||||
operation,
|
||||
limits,
|
||||
),
|
||||
);
|
||||
}
|
||||
return this.#resources.finish(result, (execution) =>
|
||||
createEvidence(execution.subject ?? undefined, execution.provider, {
|
||||
operation: OPERATION,
|
||||
parameters: input,
|
||||
result: execution.result,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
}),
|
||||
return this.#resources.finish(
|
||||
result,
|
||||
(execution) =>
|
||||
createEvidence(execution.subject ?? undefined, execution.provider, {
|
||||
operation,
|
||||
parameters: input,
|
||||
result: execution.result,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
}),
|
||||
operation,
|
||||
);
|
||||
}
|
||||
#failure(
|
||||
|
|
@ -386,6 +446,11 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
workspacePath: string | null,
|
||||
effectiveLimits: LinuxResourceLimits | undefined,
|
||||
observedOutput: AnalysisCapturedOutput | undefined,
|
||||
operation = OPERATION,
|
||||
limits: {
|
||||
readonly output_bytes: number;
|
||||
readonly command_timeout_ms: number;
|
||||
} = LIMITS,
|
||||
): AnalysisError {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
|
||||
|
|
@ -393,15 +458,34 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
cause.cleanup,
|
||||
cause,
|
||||
observedOutput,
|
||||
operation,
|
||||
);
|
||||
if (
|
||||
(cause instanceof Error && cause.name === "AbortError") ||
|
||||
(cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
|
||||
)
|
||||
return new AnalysisCancelledError(OPERATION, { cause });
|
||||
return new AnalysisCancelledError(operation, { cause });
|
||||
if (cause instanceof FieldDiscoveryBudgetError)
|
||||
return new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
cause.message,
|
||||
{ record_budget_bytes: FIELD_RECORD_BUDGET },
|
||||
{
|
||||
cause,
|
||||
...(observedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: observedOutput }),
|
||||
},
|
||||
);
|
||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||
if (cause instanceof OwnedCommandFailure)
|
||||
return this.#resources.commandFailure(cause, LIMITS, effectiveLimits);
|
||||
return this.#resources.commandFailure(
|
||||
cause,
|
||||
limits,
|
||||
effectiveLimits,
|
||||
operation,
|
||||
);
|
||||
if (phase === "workspace") {
|
||||
const code =
|
||||
cause instanceof Error &&
|
||||
|
|
@ -411,13 +495,13 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
: null;
|
||||
if (code === "ENOSPC" || code === "EDQUOT" || code === "EFBIG")
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
operation,
|
||||
"file-size",
|
||||
`Private analysis workspace failed (${code}): ${reason}`,
|
||||
{ workspace_path: workspacePath, system_code: code },
|
||||
{ cause },
|
||||
);
|
||||
return new ProviderAdapterError(TSHARK_PROVIDER_IDENTITY.id, OPERATION, {
|
||||
return new ProviderAdapterError(TSHARK_PROVIDER_IDENTITY.id, operation, {
|
||||
cause,
|
||||
userMessage: `Cannot prepare the private analysis workspace: ${reason}`,
|
||||
diagnostics: {
|
||||
|
|
@ -440,12 +524,12 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
["limit", "integrity", "path"].includes(cause.reason)
|
||||
)
|
||||
return new AnalysisArtifactChangedError(
|
||||
OPERATION,
|
||||
operation,
|
||||
path,
|
||||
`Post-run identity verification failed for ${path}: ${reason}`,
|
||||
detail,
|
||||
);
|
||||
return new ProviderAdapterError(TSHARK_PROVIDER_IDENTITY.id, OPERATION, {
|
||||
return new ProviderAdapterError(TSHARK_PROVIDER_IDENTITY.id, operation, {
|
||||
...detail,
|
||||
diagnostics: { stage: "integrity", resource_path: path, reason },
|
||||
});
|
||||
|
|
@ -453,10 +537,13 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
if (phase === "configuration")
|
||||
return new AnalysisCapabilityUnavailableError(
|
||||
TSHARK_PROVIDER_IDENTITY.id,
|
||||
OPERATION,
|
||||
operation,
|
||||
reason,
|
||||
{
|
||||
cause,
|
||||
...(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).`,
|
||||
},
|
||||
);
|
||||
|
|
@ -466,14 +553,14 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
cause.reason === "limit"
|
||||
)
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
operation,
|
||||
"file-size",
|
||||
reason,
|
||||
{ input_bytes: LIMITS.input_bytes },
|
||||
{ cause },
|
||||
);
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity")
|
||||
return new AnalysisArtifactChangedError(OPERATION, path, reason, {
|
||||
return new AnalysisArtifactChangedError(operation, path, reason, {
|
||||
cause,
|
||||
});
|
||||
if (
|
||||
|
|
@ -482,13 +569,18 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
"code" in cause &&
|
||||
(cause.code === "EACCES" || cause.code === "EPERM")
|
||||
)
|
||||
return new AnalysisAccessDeniedError(OPERATION, path, cause.code, {
|
||||
return new AnalysisAccessDeniedError(operation, path, cause.code, {
|
||||
cause,
|
||||
});
|
||||
if (phase === "artifact")
|
||||
return new AnalysisInputError(OPERATION, { cause }, [
|
||||
return new AnalysisInputError(operation, { cause }, [
|
||||
{ path: ["path"], reason: "invalid_value", message: reason },
|
||||
]);
|
||||
return new AnalysisOutputError(OPERATION, reason, { cause });
|
||||
return new AnalysisOutputError(operation, reason, {
|
||||
cause,
|
||||
...(observedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: observedOutput }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import type {
|
||||
InspectPacketFieldsInput,
|
||||
PacketFieldRecord,
|
||||
} from "../domain/captures/packetFields.js";
|
||||
|
||||
export class FieldDiscoveryBudgetError extends Error {}
|
||||
export const FIELD_RECORD_BUDGET = 8 * 1024 * 1024;
|
||||
|
||||
/** TShark -G fields: P/name/abbreviation or F/name/abbreviation/type/parent/base/mask/blurb. */
|
||||
export const parseFieldDiscovery = (
|
||||
text: string,
|
||||
input: InspectPacketFieldsInput,
|
||||
) => {
|
||||
const copy = (value: string) =>
|
||||
Buffer.from(value, "utf16le").toString("utf16le");
|
||||
const query = input.query.toLowerCase();
|
||||
const records: PacketFieldRecord[] = [];
|
||||
let examined = 0,
|
||||
matching = 0,
|
||||
sourceLine = 0,
|
||||
budget = 0;
|
||||
// Walk the bounded text once; allocate record objects only for the requested page.
|
||||
for (let start = 0; start < text.length;) {
|
||||
const end = text.indexOf("\n", start);
|
||||
let line = text.slice(start, end < 0 ? text.length : end);
|
||||
start = end < 0 ? text.length : end + 1;
|
||||
sourceLine++;
|
||||
if (line.endsWith("\r")) line = line.slice(0, -1);
|
||||
const columns = line.split("\t", 9);
|
||||
const [tag, name, abbreviation] = columns;
|
||||
if (
|
||||
!(
|
||||
(tag === "P" && columns.length === 3) ||
|
||||
(tag === "F" && columns.length === 8)
|
||||
) ||
|
||||
name === undefined ||
|
||||
abbreviation === undefined ||
|
||||
abbreviation === ""
|
||||
)
|
||||
throw new Error(
|
||||
`Malformed TShark field glossary record at line ${sourceLine}`,
|
||||
);
|
||||
if ((input.kind === "protocols") !== (tag === "P")) continue;
|
||||
examined++;
|
||||
if (
|
||||
input.protocol !== null &&
|
||||
(tag === "P" ? abbreviation : columns[4]) !== input.protocol
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
![name, abbreviation, ...(tag === "F" ? [columns[7] ?? ""] : [])].some(
|
||||
(value) => value.toLowerCase().includes(query),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
matching++;
|
||||
if (
|
||||
matching <= input.offset ||
|
||||
records.length >= (input.limit ?? Number.MAX_SAFE_INTEGER)
|
||||
)
|
||||
continue;
|
||||
// Six bytes per source byte covers worst-case JSON escaping, plus record bookkeeping.
|
||||
budget += Buffer.byteLength(line, "utf8") * 6 + 512;
|
||||
if (budget > FIELD_RECORD_BUDGET)
|
||||
throw new FieldDiscoveryBudgetError(
|
||||
"Selected glossary records exceed the 8 MiB representation budget; narrow the query or page size",
|
||||
);
|
||||
// Detach retained fields from the much larger producer string.
|
||||
records.push({
|
||||
source_line: sourceLine,
|
||||
kind: tag === "P" ? "protocol" : "field",
|
||||
name: copy(name),
|
||||
abbreviation: copy(abbreviation),
|
||||
field_type: tag === "F" ? copy(columns[3] ?? "") : null,
|
||||
parent_protocol: tag === "F" ? copy(columns[4] ?? "") : null,
|
||||
display_base: tag === "F" ? copy(columns[5] ?? "") : null,
|
||||
bitmask: tag === "F" ? copy(columns[6] ?? "") : null,
|
||||
description: tag === "F" ? copy(columns[7] ?? "") : null,
|
||||
});
|
||||
}
|
||||
if (sourceLine === 0)
|
||||
throw new Error("TShark returned an empty field glossary");
|
||||
return {
|
||||
records,
|
||||
output_window: {
|
||||
examined,
|
||||
matching,
|
||||
returned: records.length,
|
||||
has_more: Math.max(0, matching - input.offset) > records.length,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { inspectPacketFieldsInputSchema } from "../domain/captures/packetFields.js";
|
||||
import { z } from "incur";
|
||||
import type { PacketCaptureService } from "../application/captures/PacketCaptureService.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
|
|
@ -22,9 +23,73 @@ export const registerCaptureCommands = (
|
|||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
service: Pick<
|
||||
PacketCaptureService,
|
||||
"inspect" | "close"
|
||||
"inspect" | "inspectFields" | "close"
|
||||
> = createPacketCaptureService(environment),
|
||||
): void => {
|
||||
const finish = async (
|
||||
result: Awaited<ReturnType<PacketCaptureService["inspect"]>>,
|
||||
operation: string,
|
||||
) => {
|
||||
// A one-shot command must retry retained cleanup before discarding its owner.
|
||||
try {
|
||||
await service.close();
|
||||
} catch (cause) {
|
||||
const cleanup =
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderCleanupError(
|
||||
"tshark-capture",
|
||||
[],
|
||||
{ reason: String(cause) },
|
||||
{ cause, operation: operation },
|
||||
);
|
||||
if (result.ok) cleanup.retainPartialObservation(result.value);
|
||||
return projectAnalysisError(
|
||||
result.ok
|
||||
? cleanup
|
||||
: analysisErrorWithCleanupFailure(result.error, cleanup, operation),
|
||||
);
|
||||
}
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
};
|
||||
cli.command(CLI_COMMANDS.inspectPacketFields, {
|
||||
description: "Search installed TShark protocol and field definitions",
|
||||
options: z
|
||||
.object({
|
||||
...inspectPacketFieldsInputSchema.shape,
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.optional()
|
||||
.describe("Maximum matching records to return; defaults to 50"),
|
||||
all: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Return all remaining matches within resource limits"),
|
||||
})
|
||||
.superRefine((options, context) => {
|
||||
if (options.all && options.limit !== undefined)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["all"],
|
||||
message: "all and limit are mutually exclusive",
|
||||
});
|
||||
}),
|
||||
run: ({ options: { all, ...options } }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectPacketFields, async () =>
|
||||
finish(
|
||||
await service.inspectFields(
|
||||
{ ...options, ...(all ? { limit: null } : {}) },
|
||||
{ signal },
|
||||
),
|
||||
"inspect_packet_fields",
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectPacketCapture, {
|
||||
description:
|
||||
"Inspect an existing packet capture with caller-supplied TShark",
|
||||
|
|
@ -68,31 +133,7 @@ export const registerCaptureCommands = (
|
|||
},
|
||||
{ signal },
|
||||
);
|
||||
// A one-shot command must retry retained cleanup before discarding its owner.
|
||||
try {
|
||||
await service.close();
|
||||
} catch (cause) {
|
||||
const cleanup =
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderCleanupError(
|
||||
"tshark-capture",
|
||||
[],
|
||||
{ reason: String(cause) },
|
||||
{ cause, operation: "inspect_packet_capture" },
|
||||
);
|
||||
if (result.ok) cleanup.retainPartialObservation(result.value);
|
||||
return projectAnalysisError(
|
||||
result.ok
|
||||
? cleanup
|
||||
: analysisErrorWithCleanupFailure(
|
||||
result.error,
|
||||
cleanup,
|
||||
"inspect_packet_capture",
|
||||
),
|
||||
);
|
||||
}
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
return finish(result, "inspect_packet_capture");
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/** Canonical names used by every one-shot CLI command registration. */
|
||||
export const CLI_COMMANDS = Object.freeze({
|
||||
inspectPacketFields: "inspect-packet-fields",
|
||||
inspectPacketCapture: "inspect-packet-capture",
|
||||
decodeSignalRecording: "decode-signal-recording",
|
||||
inspectWasmArtifact: "inspect-wasm-artifact",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
inspectPacketFieldsInputSchema,
|
||||
packetFieldsCatalogSchema,
|
||||
} from "../../domain/captures/packetFields.js";
|
||||
import {
|
||||
inspectPacketCaptureInputSchema,
|
||||
packetCaptureSchema,
|
||||
|
|
@ -6,6 +10,22 @@ import type { ToolContract } from "../toolContractTypes.js";
|
|||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
export const CAPTURE_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_packet_fields",
|
||||
...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.",
|
||||
inputSchema: inspectPacketFieldsInputSchema,
|
||||
outputSchema: evidenceResultOf(packetFieldsCatalogSchema),
|
||||
examples: [
|
||||
{ title: "Find USB protocol registrations", input: { query: "usb" } },
|
||||
{
|
||||
title: "Find the installed USB vendor-ID field",
|
||||
input: { kind: "fields", protocol: "usb", query: "idVendor" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "inspect_packet_capture",
|
||||
...toolContractMetadata("inspect_packet_capture"),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,11 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
|||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
}),
|
||||
inspect_packet_fields: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
}),
|
||||
inspect_packet_capture: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import { z } from "zod";
|
||||
import { packetCaptureSchema } from "./packetCapture.js";
|
||||
|
||||
export const inspectPacketFieldsInputSchema = z.strictObject({
|
||||
kind: z
|
||||
.enum(["protocols", "fields"])
|
||||
.default("protocols")
|
||||
.describe(
|
||||
"Return protocol registrations or field definitions; defaults to protocols",
|
||||
),
|
||||
protocol: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(8192)
|
||||
.nullable()
|
||||
.default(null)
|
||||
.describe(
|
||||
"Exact protocol abbreviation; selects that protocol or its registered fields",
|
||||
),
|
||||
query: z
|
||||
.string()
|
||||
.max(8192)
|
||||
.default("")
|
||||
.describe(
|
||||
"Case-insensitive literal substring in name, abbreviation or field description",
|
||||
),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(0)
|
||||
.describe("Number of matching records to skip; defaults to 0"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.nullable()
|
||||
.default(50)
|
||||
.describe(
|
||||
"Maximum matches returned; null requests all remaining within the record budget",
|
||||
),
|
||||
});
|
||||
export type InspectPacketFieldsInput = z.infer<
|
||||
typeof inspectPacketFieldsInputSchema
|
||||
>;
|
||||
export const packetFieldRecordSchema = z.strictObject({
|
||||
source_line: z.number().int().positive(),
|
||||
kind: z.enum(["protocol", "field"]),
|
||||
name: z.string(),
|
||||
abbreviation: z.string(),
|
||||
field_type: z.string().nullable(),
|
||||
parent_protocol: z.string().nullable(),
|
||||
display_base: z.string().nullable(),
|
||||
bitmask: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
});
|
||||
export type PacketFieldRecord = z.infer<typeof packetFieldRecordSchema>;
|
||||
export const packetFieldsCatalogSchema = z
|
||||
.strictObject({
|
||||
selection: inspectPacketFieldsInputSchema,
|
||||
records: z.array(packetFieldRecordSchema),
|
||||
output_window: z.strictObject({
|
||||
examined: z.number().int().nonnegative(),
|
||||
matching: z.number().int().nonnegative(),
|
||||
returned: z.number().int().nonnegative(),
|
||||
has_more: z.boolean(),
|
||||
}),
|
||||
diagnostics: z.string(),
|
||||
tool: packetCaptureSchema.shape.tool,
|
||||
resource_limiter: packetCaptureSchema.shape.resource_limiter,
|
||||
limits: packetCaptureSchema.shape.limits.extend({
|
||||
record_budget_bytes: z.literal(8388608),
|
||||
}),
|
||||
configuration: z.literal("private-empty-personal-profile"),
|
||||
hardware_acquisition: z.literal("not-performed"),
|
||||
})
|
||||
.superRefine((report, context) => {
|
||||
const w = report.output_window;
|
||||
const remaining = Math.max(0, w.matching - report.selection.offset);
|
||||
const expected = Math.min(remaining, report.selection.limit ?? remaining);
|
||||
if (
|
||||
w.matching > w.examined ||
|
||||
w.returned !== expected ||
|
||||
report.records.length !== expected ||
|
||||
w.has_more !== remaining > expected ||
|
||||
report.records.some(
|
||||
(row) =>
|
||||
row.kind !==
|
||||
(report.selection.kind === "protocols" ? "protocol" : "field"),
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["output_window"],
|
||||
message:
|
||||
"Field discovery coverage must match the selected page and record kind",
|
||||
});
|
||||
});
|
||||
|
|
@ -36,7 +36,10 @@ export class ArtifactAnalysisResources {
|
|||
readonly #artifacts = new ArtifactResourceScope();
|
||||
readonly #pending = new Map<
|
||||
ArtifactAnalysisRuntime,
|
||||
ProviderProcessSupervisor | undefined
|
||||
{
|
||||
readonly owner: ProviderProcessSupervisor | undefined;
|
||||
readonly operation: string;
|
||||
}
|
||||
>();
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#busy = false;
|
||||
|
|
@ -56,14 +59,15 @@ export class ArtifactAnalysisResources {
|
|||
run<T>(
|
||||
work: () => Promise<Result<T, AnalysisError>>,
|
||||
signal?: AbortSignal,
|
||||
operation = this.operation,
|
||||
): Promise<Result<T, AnalysisError>> {
|
||||
if (this.#closed || signal?.aborted)
|
||||
return Promise.resolve(err(new AnalysisCancelledError(this.operation)));
|
||||
return Promise.resolve(err(new AnalysisCancelledError(operation)));
|
||||
if (this.#busy)
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisResourceConstraintError(
|
||||
this.operation,
|
||||
operation,
|
||||
"capacity",
|
||||
this.capacity.message,
|
||||
this.capacity.details,
|
||||
|
|
@ -73,7 +77,7 @@ export class ArtifactAnalysisResources {
|
|||
this.#busy = true;
|
||||
const running = this.#tail
|
||||
.then(() => this.#artifacts.run(work))
|
||||
.catch((cause: unknown) => err(this.#artifactFailure(cause)))
|
||||
.catch((cause: unknown) => err(this.#artifactFailure(cause, operation)))
|
||||
.finally(() => {
|
||||
this.#busy = false;
|
||||
});
|
||||
|
|
@ -101,8 +105,9 @@ export class ArtifactAnalysisResources {
|
|||
retain(
|
||||
root: ArtifactAnalysisRuntime,
|
||||
owner?: ProviderProcessSupervisor,
|
||||
operation = this.operation,
|
||||
): void {
|
||||
this.#pending.set(root, owner);
|
||||
this.#pending.set(root, { owner, operation });
|
||||
}
|
||||
|
||||
get pendingPaths(): readonly string[] {
|
||||
|
|
@ -110,12 +115,13 @@ export class ArtifactAnalysisResources {
|
|||
}
|
||||
|
||||
async release(): Promise<void> {
|
||||
for (const [root, owner] of this.#pending) {
|
||||
for (const [root, retained] of this.#pending) {
|
||||
try {
|
||||
const { owner } = retained;
|
||||
if (owner !== undefined) {
|
||||
const stopped = await owner.stop();
|
||||
if (stopped.status === "incomplete") throw new Error(stopped.reason);
|
||||
this.#pending.set(root, undefined);
|
||||
this.#pending.set(root, { ...retained, owner: undefined });
|
||||
}
|
||||
await root.close();
|
||||
this.#pending.delete(root);
|
||||
|
|
@ -124,7 +130,7 @@ export class ArtifactAnalysisResources {
|
|||
this.providerId,
|
||||
[root.path],
|
||||
{ reason: String(cause) },
|
||||
{ operation: this.operation, cause },
|
||||
{ operation: retained.operation, cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +140,7 @@ export class ArtifactAnalysisResources {
|
|||
observation: NonNullable<ArtifactReaderFailure["cleanup"]>,
|
||||
cause: unknown,
|
||||
capturedOutput?: AnalysisCapturedOutput,
|
||||
operation = this.operation,
|
||||
): ProviderCleanupError {
|
||||
return new ProviderCleanupError(
|
||||
this.providerId,
|
||||
|
|
@ -144,7 +151,7 @@ export class ArtifactAnalysisResources {
|
|||
? {}
|
||||
: { captured_output: { ...capturedOutput } }),
|
||||
},
|
||||
{ operation: this.operation, cause },
|
||||
{ operation, cause },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +162,7 @@ export class ArtifactAnalysisResources {
|
|||
readonly output_bytes: number;
|
||||
},
|
||||
effectiveLimits: LinuxResourceLimits | undefined,
|
||||
operation = this.operation,
|
||||
): AnalysisError {
|
||||
const capturedOutput =
|
||||
cause.snapshot === null
|
||||
|
|
@ -171,16 +179,16 @@ export class ArtifactAnalysisResources {
|
|||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
};
|
||||
if (cause.reason === "cancelled")
|
||||
return new AnalysisCancelledError(this.operation, detail);
|
||||
return new AnalysisCancelledError(operation, detail);
|
||||
if (cause.reason === "timeout")
|
||||
return new AnalysisTimeoutError(
|
||||
this.operation,
|
||||
operation,
|
||||
limits.command_timeout_ms,
|
||||
detail,
|
||||
);
|
||||
if (cause.reason === "output-limit")
|
||||
return new AnalysisResourceConstraintError(
|
||||
this.operation,
|
||||
operation,
|
||||
"memory",
|
||||
cause.message,
|
||||
{ output_bytes: limits.output_bytes },
|
||||
|
|
@ -192,18 +200,19 @@ export class ArtifactAnalysisResources {
|
|||
cause.snapshot?.signal === "SIGXCPU")
|
||||
)
|
||||
return new AnalysisResourceConstraintError(
|
||||
this.operation,
|
||||
operation,
|
||||
cause.snapshot.signal === "SIGXFSZ" ? "file-size" : "cpu",
|
||||
`Producer terminated with ${cause.snapshot.signal}; the signal's exact cause is not independently established.`,
|
||||
effectiveLimits === undefined ? null : { ...effectiveLimits },
|
||||
detail,
|
||||
);
|
||||
return new AnalysisOutputError(this.operation, cause.message, detail);
|
||||
return new AnalysisOutputError(operation, cause.message, detail);
|
||||
}
|
||||
|
||||
async finish<T>(
|
||||
result: Result<T, AnalysisError>,
|
||||
observation: (value: T) => AnalysisPartialObservation,
|
||||
operation = this.operation,
|
||||
): Promise<Result<T, AnalysisError>> {
|
||||
try {
|
||||
await this.release();
|
||||
|
|
@ -223,7 +232,7 @@ export class ArtifactAnalysisResources {
|
|||
: projectAnalysisError(result.error),
|
||||
},
|
||||
{
|
||||
operation: this.operation,
|
||||
operation,
|
||||
cause,
|
||||
...(partialObservation === undefined ? {} : { partialObservation }),
|
||||
},
|
||||
|
|
@ -260,11 +269,11 @@ export class ArtifactAnalysisResources {
|
|||
if (descriptorFailure !== undefined) throw descriptorFailure;
|
||||
}
|
||||
|
||||
#artifactFailure(cause: unknown): AnalysisError {
|
||||
#artifactFailure(cause: unknown, operation = this.operation): AnalysisError {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
|
||||
return this.cleanupFailure(cause.cleanup, cause);
|
||||
return new ProviderAdapterError(this.providerId, this.operation, {
|
||||
return this.cleanupFailure(cause.cleanup, cause, undefined, operation);
|
||||
return new ProviderAdapterError(this.providerId, operation, {
|
||||
cause,
|
||||
diagnostics: { stage: "artifact-resources", reason: String(cause) },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,4 +28,17 @@ export const registerCaptureTools = (
|
|||
toolRegistrationOptions(contract),
|
||||
handler(contract),
|
||||
);
|
||||
const discovery = toolContract("inspect_packet_fields");
|
||||
const discover = createEvidenceToolHandler(
|
||||
server,
|
||||
(input, options) => service.inspectFields(input, options),
|
||||
logger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
server.registerTool(
|
||||
discovery.name,
|
||||
toolRegistrationOptions(discovery),
|
||||
discover(discovery),
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { packetFieldsCatalogSchema } from "../../../src/domain/captures/packetFields.js";
|
||||
import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js";
|
||||
import { it, expect, onTestFinished } from "vitest";
|
||||
import { writeFile, readdir, chmod, symlink } from "node:fs/promises";
|
||||
|
|
@ -20,14 +21,15 @@ const fixture = async (mode = "ok", failCleanup = false) => {
|
|||
await writeFile(
|
||||
script,
|
||||
`import {readFileSync, writeFileSync, chmodSync, truncateSync} from 'node:fs';
|
||||
if (process.argv.includes('--version')) { console.log('TShark (Wireshark) 4.6.9 (fixture)'); console.error('version warning'); }
|
||||
if (process.argv.includes('--version')) { console.log(${JSON.stringify(mode)} === 'bad-version' ? 'TShark (Wireshark) 3.0.0' : 'TShark (Wireshark) 4.6.9 (fixture)'); console.error('version warning'); }
|
||||
else if (${JSON.stringify(mode)} === 'nonzero') { console.error('invalid decoder'); process.exitCode = 1; }
|
||||
else if (${JSON.stringify(mode)} === 'malformed-copy') { chmodSync('capture', 0o600); writeFileSync('capture', 'changed'); }
|
||||
else if (${JSON.stringify(mode)} === 'oversized-copy') { chmodSync('capture', 0o600); truncateSync('capture', 32 * 1024 * 1024 + 1); console.log('[]'); }
|
||||
else if (${JSON.stringify(mode)} === 'overflow') process.stdout.write('x'.repeat(5 * 1024 * 1024));
|
||||
else if (${JSON.stringify(mode)} === 'overflow') process.stdout.write('x'.repeat((process.argv.includes('-G') ? 33 : 5) * 1024 * 1024));
|
||||
else if (${JSON.stringify(mode)} === 'wait') { console.log('ready'); setInterval(() => {}, 1000); }
|
||||
else if (${JSON.stringify(mode)} === 'invalid-json') console.log('not json');
|
||||
else if (${JSON.stringify(mode)} === 'many') console.log(JSON.stringify(Array.from({length:90},(_,i)=>({_source:{layers:{"frame.number":[String(i+1)],"ip.addr":["192.0.2.1"]}}}))));
|
||||
else if (process.argv.includes('-G')) { console.log('P\\tUniversal Serial Bus\\tusb\\nF\\tVendor ID\\tusb.idVendor\\tFT_UINT16\\tusb\\tBASE_HEX\\t0x0\\t\\nF\\tOther Vendor\\tusb.idVendor\\tFT_UINT32\\tusb\\tBASE_HEX\\t0x0\\tSecond registration\\nP\\tInternet Protocol\\tip\\nF\\tAddress\\tip.addr\\tFT_IPv4\\tip\\t\\t0x0\\tSource or destination'); console.error('glossary warning'); }
|
||||
else { if(readFileSync('capture', 'utf8') !== 'recording bytes') throw Error('wrong copy'); console.log(JSON.stringify([{_source:{layers:{"frame.number":["1"],"ip.addr":["192.0.2.1","192.0.2.2"]}}}])); console.error('producer warning'); }
|
||||
`,
|
||||
);
|
||||
|
|
@ -301,6 +303,9 @@ it("retains a completed observation when cancellation arrives at the service bou
|
|||
const execution = await f.service.provider.inspect(input);
|
||||
if (!execution.ok) throw execution.error;
|
||||
const service = new PacketCaptureService({
|
||||
inspectFields: async () => {
|
||||
throw new Error("Unexpected field discovery call");
|
||||
},
|
||||
inspect: async () => {
|
||||
f.controller.abort();
|
||||
return execution;
|
||||
|
|
@ -465,3 +470,158 @@ it("rejects oversized selector lists and UTF-8 argv expansion before provider wo
|
|||
}
|
||||
expect(f.calls()).toBe(0);
|
||||
});
|
||||
|
||||
it("discovers installed protocols and field definitions without a capture", async () => {
|
||||
const f = await fixture();
|
||||
for (const [input, expected] of [
|
||||
[
|
||||
{ query: "USB" },
|
||||
{
|
||||
records: [{ abbreviation: "usb", kind: "protocol", field_type: null }],
|
||||
output_window: { examined: 2, matching: 1, returned: 1 },
|
||||
},
|
||||
],
|
||||
[
|
||||
{ kind: "fields", protocol: "usb", limit: 1 },
|
||||
{
|
||||
records: [
|
||||
{
|
||||
abbreviation: "usb.idVendor",
|
||||
field_type: "FT_UINT16",
|
||||
source_line: 2,
|
||||
},
|
||||
],
|
||||
output_window: {
|
||||
examined: 3,
|
||||
matching: 2,
|
||||
returned: 1,
|
||||
has_more: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ kind: "fields", protocol: "usb", offset: 1, limit: null },
|
||||
{
|
||||
records: [
|
||||
{
|
||||
abbreviation: "usb.idVendor",
|
||||
field_type: "FT_UINT32",
|
||||
source_line: 3,
|
||||
},
|
||||
],
|
||||
output_window: { matching: 2, returned: 1, has_more: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
{ kind: "fields", query: "destination" },
|
||||
{
|
||||
records: [
|
||||
{
|
||||
abbreviation: "ip.addr",
|
||||
description: "Source or destination",
|
||||
display_base: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{ query: "no-such-protocol" },
|
||||
{ records: [], output_window: { matching: 0, returned: 0 } },
|
||||
],
|
||||
] as const) {
|
||||
const result = await f.service.inspectFields(input);
|
||||
if (!result.ok) throw result.error;
|
||||
const report = packetFieldsCatalogSchema.parse(
|
||||
result.value.normalized_result,
|
||||
);
|
||||
expect(report).toMatchObject(expected);
|
||||
expect(report.tool.arguments).toEqual(["-G", "fields"]);
|
||||
expect(report.tool.version_diagnostics).toBe("version warning\n");
|
||||
expect(report.diagnostics).toBe("glossary warning\n");
|
||||
expect(report.limits.output_bytes).toBe(32 * 1024 * 1024);
|
||||
}
|
||||
expect(
|
||||
(await readdir(f.directory)).filter((name) => name.startsWith("decoder-")),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("shares discovery admission with inspection and refuses work after shutdown", async () => {
|
||||
const f = await fixture("gated");
|
||||
const active = f.service.inspect({ path: f.path, fields: ["ip.addr"] });
|
||||
await f.acquired;
|
||||
const busy = await f.service.inspectFields({});
|
||||
f.release();
|
||||
if (!(await active).ok) throw new Error("Inspection failed");
|
||||
expect(busy).toMatchObject({
|
||||
ok: false,
|
||||
error: { operation: "inspect_packet_fields", resource: "capacity" },
|
||||
});
|
||||
await f.service.close();
|
||||
expect(await f.service.inspectFields({})).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
operation: "inspect_packet_fields",
|
||||
_tag: "AnalysisCancelledError",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"bad-version",
|
||||
"nonzero",
|
||||
"overflow",
|
||||
"wait",
|
||||
"oversized-tool",
|
||||
"invalid-json",
|
||||
])("retains field discovery failure facts: %s", async (mode) => {
|
||||
const f = await fixture(mode);
|
||||
const result = await f.service.inspectFields(
|
||||
{},
|
||||
{ signal: f.controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected failure");
|
||||
expect(result.error).toMatchObject({ operation: "inspect_packet_fields" });
|
||||
if (mode === "bad-version")
|
||||
expect(result.error.capturedOutput).toMatchObject({
|
||||
stdout: "TShark (Wireshark) 3.0.0\n",
|
||||
stderr: "version warning\n",
|
||||
});
|
||||
if (mode === "invalid-json")
|
||||
expect(result.error.capturedOutput?.stdout).toBe("not json\n");
|
||||
if (mode === "overflow")
|
||||
expect(result.error).toMatchObject({
|
||||
resource: "memory",
|
||||
reportedLimits: { output_bytes: 32 * 1024 * 1024 },
|
||||
});
|
||||
});
|
||||
|
||||
it("retains completed discovery on late cancellation and cleanup failure", async () => {
|
||||
for (const mode of ["post-run-abort", "cleanup"]) {
|
||||
const f = await fixture(mode, mode === "cleanup");
|
||||
const result = await f.service.inspectFields(
|
||||
{ query: "usb" },
|
||||
{ signal: f.controller.signal },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
partialObservation: {
|
||||
operation: "inspect_packet_fields",
|
||||
normalized_result: { records: [{ abbreviation: "usb" }] },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (mode === "cleanup") {
|
||||
await f.service.inspectFields({});
|
||||
await f.service.inspect({ path: f.path, fields: ["ip.addr"] });
|
||||
expect(f.calls()).toBe(2);
|
||||
for (let attempt = 0; attempt < 2; attempt++)
|
||||
await expect(f.service.close()).rejects.toMatchObject({
|
||||
operation: "inspect_packet_fields",
|
||||
cleanupIncomplete: true,
|
||||
});
|
||||
}
|
||||
f.allowCleanup();
|
||||
await f.service.close();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { inspectPacketFieldsInputSchema } from "../../../src/domain/captures/packetFields.js";
|
||||
import { parseFieldDiscovery } from "../../../src/captures/parseFieldDiscovery.js";
|
||||
|
||||
it("keeps duplicate registrations and source ordinals instead of overwriting abbreviations", () => {
|
||||
const text =
|
||||
"P\tUSB\tusb\r\nF\tVendor\tusb.idVendor\tFT_UINT16\tusb\tBASE_HEX\t0x0\t\r\nF\tVendor\tusb.idVendor\tFT_UINT32\tusb\tBASE_HEX\t0x0\t";
|
||||
const result = parseFieldDiscovery(
|
||||
text,
|
||||
inspectPacketFieldsInputSchema.parse({
|
||||
kind: "fields",
|
||||
query: "idVendor",
|
||||
offset: 1,
|
||||
}),
|
||||
);
|
||||
expect(result.records).toMatchObject([
|
||||
{
|
||||
source_line: 3,
|
||||
abbreviation: "usb.idVendor",
|
||||
field_type: "FT_UINT32",
|
||||
description: "",
|
||||
},
|
||||
]);
|
||||
expect(result.output_window).toEqual({
|
||||
examined: 2,
|
||||
matching: 2,
|
||||
returned: 1,
|
||||
has_more: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects damaged records even outside the selected page", () => {
|
||||
for (const text of [
|
||||
"",
|
||||
"P\tmissing",
|
||||
"F\tname\tabbr",
|
||||
"P\tname\t",
|
||||
"P\tUSB\tusb\nBAD",
|
||||
])
|
||||
expect(() =>
|
||||
parseFieldDiscovery(
|
||||
text,
|
||||
inspectPacketFieldsInputSchema.parse({ limit: 1 }),
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("bounds explicit full-result expansion while allowing a page from the same large glossary", () => {
|
||||
const text = "P\t" + "Long name ".repeat(30) + "\tabbr\n";
|
||||
const glossary = text.repeat(6000);
|
||||
expect(() =>
|
||||
parseFieldDiscovery(
|
||||
glossary,
|
||||
inspectPacketFieldsInputSchema.parse({ limit: null }),
|
||||
),
|
||||
).toThrow("representation budget");
|
||||
const result = parseFieldDiscovery(
|
||||
glossary,
|
||||
inspectPacketFieldsInputSchema.parse({ offset: 5999, limit: 1 }),
|
||||
);
|
||||
expect(result.output_window).toEqual({
|
||||
examined: 6000,
|
||||
matching: 6000,
|
||||
returned: 1,
|
||||
has_more: false,
|
||||
});
|
||||
});
|
||||
|
|
@ -32,6 +32,9 @@ it.each([false, true])(
|
|||
silentLogger,
|
||||
{},
|
||||
{
|
||||
inspectFields: async () => {
|
||||
throw new Error("Unexpected field discovery call");
|
||||
},
|
||||
inspect: async () => err(original),
|
||||
close: async () => {
|
||||
await Promise.resolve();
|
||||
|
|
@ -87,6 +90,9 @@ it("rejects an oversized selector budget before the CLI invokes its service", as
|
|||
silentLogger,
|
||||
{},
|
||||
{
|
||||
inspectFields: async () => {
|
||||
throw new Error("Unexpected field discovery call");
|
||||
},
|
||||
inspect: async () => {
|
||||
called = true;
|
||||
throw new Error("Must not inspect");
|
||||
|
|
@ -125,6 +131,9 @@ it("rejects conflicting output options before the CLI invokes its service", asyn
|
|||
silentLogger,
|
||||
{},
|
||||
{
|
||||
inspectFields: async () => {
|
||||
throw new Error("Unexpected field discovery call");
|
||||
},
|
||||
inspect: async () => {
|
||||
called = true;
|
||||
throw new Error("Must not inspect");
|
||||
|
|
|
|||
|
|
@ -8,42 +8,53 @@ import { Ajv2020 } from "ajv/dist/2020.js";
|
|||
import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
|
||||
it("advertises valid target-free packet capture schemas and actionable missing-tool diagnostics", async () => {
|
||||
const harness = await createApplicationMcpHarness({
|
||||
serverOptions: { environment: {} },
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
const tool = (await harness.client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_packet_capture",
|
||||
);
|
||||
if (tool?.outputSchema === undefined) throw new Error("Capture tool missing");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const inputSchema: Record<string, unknown> = tool.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = tool.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
const input = {
|
||||
path:
|
||||
process.platform === "win32" ? "C:\\recording.pcap" : "/recording.pcap",
|
||||
fields: ["ip.addr"],
|
||||
};
|
||||
expect(ajv.validate(inputSchema, input)).toBe(true);
|
||||
expect(ajv.validate(inputSchema, { ...input, interface: "lo0" })).toBe(false);
|
||||
const result = await harness.client.callTool({
|
||||
name: tool.name,
|
||||
arguments: input,
|
||||
});
|
||||
expect(parseMcpToolError(result)).toMatchObject({
|
||||
error: {
|
||||
code: "capability_unavailable",
|
||||
details: {
|
||||
operation: tool.name,
|
||||
reason: expect.stringContaining("REA_TSHARK_COMMAND"),
|
||||
it.each(["inspect_packet_capture", "inspect_packet_fields"])(
|
||||
"advertises valid target-free schemas and missing-tool diagnostics: %s",
|
||||
async (name) => {
|
||||
const harness = await createApplicationMcpHarness({
|
||||
serverOptions: { environment: {} },
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
const tool = (await harness.client.listTools()).tools.find(
|
||||
(tool) => tool.name === name,
|
||||
);
|
||||
if (tool?.outputSchema === undefined)
|
||||
throw new Error("Capture tool missing");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const inputSchema: Record<string, unknown> = tool.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = tool.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
const input =
|
||||
name === "inspect_packet_fields"
|
||||
? { query: "usb" }
|
||||
: {
|
||||
path:
|
||||
process.platform === "win32"
|
||||
? "C:\\recording.pcap"
|
||||
: "/recording.pcap",
|
||||
fields: ["ip.addr"],
|
||||
};
|
||||
expect(ajv.validate(inputSchema, input)).toBe(true);
|
||||
expect(ajv.validate(inputSchema, { ...input, interface: "lo0" })).toBe(
|
||||
false,
|
||||
);
|
||||
const result = await harness.client.callTool({
|
||||
name: tool.name,
|
||||
arguments: input,
|
||||
});
|
||||
expect(parseMcpToolError(result)).toMatchObject({
|
||||
error: {
|
||||
code: "capability_unavailable",
|
||||
details: {
|
||||
operation: tool.name,
|
||||
reason: expect.stringContaining("REA_TSHARK_COMMAND"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.client.ping();
|
||||
});
|
||||
});
|
||||
await harness.client.ping();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["cancelled", "cleanup"])(
|
||||
"retains completed Evidence after %s failure",
|
||||
|
|
@ -78,6 +89,9 @@ it.each(["cancelled", "cleanup"])(
|
|||
},
|
||||
);
|
||||
const packetCapture = new PacketCaptureService({
|
||||
inspectFields: async () => {
|
||||
throw new Error("Unexpected field discovery call");
|
||||
},
|
||||
inspect: async () => err(failure),
|
||||
close: async () => {},
|
||||
});
|
||||
|
|
@ -120,6 +134,9 @@ it.each(["cancelled", "cleanup"])(
|
|||
it("rejects oversized selector expansion through the MCP boundary before invoking the provider", async () => {
|
||||
let calls = 0;
|
||||
const packetCapture = new PacketCaptureService({
|
||||
inspectFields: async () => {
|
||||
throw new Error("Unexpected field discovery call");
|
||||
},
|
||||
inspect: async () => {
|
||||
calls++;
|
||||
return err(new AnalysisCancelledError("inspect_packet_capture"));
|
||||
|
|
|
|||
Loading…
Reference in New Issue