378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
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";
|
|
|
|
describe("resolvePcapTools", () => {
|
|
it("prefers absolute environment paths for both tools", () => {
|
|
expect(
|
|
resolvePcapTools({
|
|
[TSHARK_COMMAND_ENV]: "/usr/bin/tshark",
|
|
[CAPINFOS_COMMAND_ENV]: "/usr/bin/capinfos",
|
|
}),
|
|
).toEqual({
|
|
tshark: { command: "/usr/bin/tshark", source: "environment" },
|
|
capinfos: { command: "/usr/bin/capinfos", source: "environment" },
|
|
});
|
|
});
|
|
|
|
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" }),
|
|
).toThrow(/must be an absolute path/u);
|
|
});
|
|
|
|
it("rejects a relative capinfos path explicitly", () => {
|
|
expect(() =>
|
|
resolvePcapTools({ [CAPINFOS_COMMAND_ENV]: "capinfos" }),
|
|
).toThrow(/must be an absolute path/u);
|
|
});
|
|
});
|
|
|
|
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",
|
|
"File encapsulation: Linux cooked-mode capture v2",
|
|
"Packet size limit: file hdr: 262144 bytes",
|
|
"Number of packets: 50",
|
|
"File size: 23279 bytes",
|
|
"Capture duration: 0.373473 seconds",
|
|
"Earliest packet time: 1791728020.608505",
|
|
"Latest packet time: 1791728020.981978",
|
|
"SHA256: c94f94742b3288b9a979711509ed5eab3028576301f8938319de3c22a6f5c40b",
|
|
].join("\n");
|
|
|
|
describe("parseCapinfosOutput", () => {
|
|
it("projects metadata from real capinfos output", () => {
|
|
expect(parseCapinfosOutput(REAL_CAPINFOS)).toEqual({
|
|
fileType: "Wireshark/tcpdump/... - pcap",
|
|
encapsulation: "Linux cooked-mode capture v2",
|
|
packetCount: 50,
|
|
captureStart: "2026-10-11T14:13:40.608505Z",
|
|
captureEnd: "2026-10-11T14:13:40.981978Z",
|
|
captureDurationSeconds: 0.373473,
|
|
snapshotLength: 262144,
|
|
sha256:
|
|
"c94f94742b3288b9a979711509ed5eab3028576301f8938319de3c22a6f5c40b",
|
|
});
|
|
});
|
|
|
|
it("retains timestamps from the older capinfos labels", () => {
|
|
const output = REAL_CAPINFOS.replace(
|
|
"Earliest packet time",
|
|
"First packet time",
|
|
).replace("Latest packet time", "Last packet time");
|
|
const metadata = parseCapinfosOutput(output);
|
|
expect(metadata.captureStart).toBe("2026-10-11T14:13:40.608505Z");
|
|
expect(metadata.captureEnd).toBe("2026-10-11T14:13:40.981978Z");
|
|
});
|
|
|
|
it("projects epoch timestamps to UTC instants and keeps other spellings unknown", () => {
|
|
const withTimes = (start: string, end: string): string =>
|
|
REAL_CAPINFOS.replace("1791728020.608505", start).replace(
|
|
"1791728020.981978",
|
|
end,
|
|
);
|
|
expect(
|
|
parseCapinfosOutput(withTimes("1791728020", "1791728020.500000")),
|
|
).toMatchObject({
|
|
captureStart: "2026-10-11T14:13:40Z",
|
|
captureEnd: "2026-10-11T14:13:40.500000Z",
|
|
});
|
|
// capinfos prints n/a for an empty capture, and its default wall-clock
|
|
// spelling carries no zone, so neither names an instant.
|
|
expect(
|
|
parseCapinfosOutput(withTimes("n/a", "2026-10-11 14:13:40.981978")),
|
|
).toMatchObject({ captureStart: null, captureEnd: null });
|
|
// a packet before the epoch prints with a leading minus; the whole seconds
|
|
// and the fraction are borrowed so the instant stays exact
|
|
expect(parseCapinfosOutput(withTimes("-1", "-1.250000"))).toMatchObject({
|
|
captureStart: "1969-12-31T23:59:59Z",
|
|
captureEnd: "1969-12-31T23:59:58.750000Z",
|
|
});
|
|
expect(parseCapinfosOutput(withTimes("-0.5", "0"))).toMatchObject({
|
|
captureStart: "1969-12-31T23:59:59.5Z",
|
|
captureEnd: "1970-01-01T00:00:00Z",
|
|
});
|
|
// beyond the RFC 3339 year range in either direction stays unknown
|
|
expect(
|
|
parseCapinfosOutput(withTimes("-62167219201", "253402300800")),
|
|
).toMatchObject({ captureStart: null, captureEnd: null });
|
|
});
|
|
|
|
it.each([
|
|
["0.000000001", "1970-01-01T00:00:00.000000001Z"],
|
|
["-0.000000001", "1969-12-31T23:59:59.999999999Z"],
|
|
["-1.000000", "1969-12-31T23:59:59.000000Z"],
|
|
["-0.000000", "1970-01-01T00:00:00.000000Z"],
|
|
["-62167219200.000000001", null],
|
|
["-62167219200", "0000-01-01T00:00:00Z"],
|
|
["253402300799.999999999", "9999-12-31T23:59:59.999999999Z"],
|
|
["253402300800", null],
|
|
["NaN", null],
|
|
["Infinity", null],
|
|
["1791728020.5junk", null],
|
|
["1e3", null],
|
|
["1.1234567890", null],
|
|
["9".repeat(1000), null],
|
|
])("preserves the exact instant or unknown for %s", (text, expected) => {
|
|
const metadata = parseCapinfosOutput(
|
|
REAL_CAPINFOS.replace("1791728020.608505", text).replace(
|
|
"1791728020.981978",
|
|
text,
|
|
),
|
|
);
|
|
expect(metadata.captureStart).toBe(expected);
|
|
expect(metadata.captureEnd).toBe(expected);
|
|
});
|
|
|
|
it("keeps missing capture timestamps unknown", () => {
|
|
const output = REAL_CAPINFOS.split("\n")
|
|
.filter((line) => !line.includes("packet time:"))
|
|
.join("\n");
|
|
expect(parseCapinfosOutput(output)).toMatchObject({
|
|
captureStart: null,
|
|
captureEnd: null,
|
|
});
|
|
});
|
|
|
|
it("rejects empty metadata rather than reporting complete unknown facts", () => {
|
|
expect(() => parseCapinfosOutput("")).toThrow(/machine-readable/u);
|
|
});
|
|
});
|
|
|
|
const REAL_TSHARK_JSON = JSON.stringify([
|
|
{
|
|
_source: {
|
|
layers: {
|
|
"frame.number": ["1"],
|
|
"frame.protocols": ["sll:ethertype:ip:udp:dns"],
|
|
"ip.src": ["192.168.1.1"],
|
|
"ip.dst": ["8.8.8.8"],
|
|
"dns.qry.name": ["example.com"],
|
|
"dns.flags.response": ["1"],
|
|
"dns.a": ["93.184.216.34"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
_source: {
|
|
layers: {
|
|
"frame.number": ["2"],
|
|
"frame.protocols": ["sll:ethertype:ip:tcp:tls"],
|
|
"ip.src": ["192.168.1.1"],
|
|
"ip.dst": ["93.184.216.34"],
|
|
"tls.handshake.extensions_server_name": ["example.com"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
_source: {
|
|
layers: {
|
|
"frame.number": ["3"],
|
|
"frame.protocols": ["sll:ethertype:ip:tcp:http"],
|
|
"ip.src": ["192.168.1.1"],
|
|
"ip.dst": ["93.184.216.34"],
|
|
"http.request.full_uri": ["http://example.com/page"],
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
describe("parseTsharkJsonOutput", () => {
|
|
it("projects endpoints, DNS, TLS SNI, protocols, and HTTP URIs", async () => {
|
|
const result = await parseTsharkJsonOutput(REAL_TSHARK_JSON);
|
|
expect(result.endpoints).toEqual(
|
|
expect.arrayContaining([
|
|
{ address: "192.168.1.1", port: null, packet_count: 3 },
|
|
{ address: "8.8.8.8", port: null, packet_count: 1 },
|
|
{ address: "93.184.216.34", port: null, packet_count: 2 },
|
|
]),
|
|
);
|
|
expect(result.dnsQueries).toEqual([
|
|
{
|
|
query: "example.com",
|
|
response_addresses: ["93.184.216.34"],
|
|
response_address_count: 1,
|
|
},
|
|
]);
|
|
expect(result.tlsSniValues).toEqual(["example.com"]);
|
|
expect(result.httpRequestUris).toEqual(["http://example.com/page"]);
|
|
expect(result.protocolDistribution).toEqual(
|
|
expect.arrayContaining([
|
|
{ protocol: "sll:ethertype:ip:udp:dns", packet_count: 1 },
|
|
{ protocol: "sll:ethertype:ip:tcp:tls", packet_count: 1 },
|
|
{ protocol: "sll:ethertype:ip:tcp:http", packet_count: 1 },
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("retains every endpoint within the aggregate resource budget", async () => {
|
|
const packets = Array.from({ length: 10 }, (_, index) => ({
|
|
_source: {
|
|
layers: {
|
|
"frame.number": [String(index + 1)],
|
|
"frame.protocols": ["ip:tcp"],
|
|
"ip.src": [`10.0.0.${index}`],
|
|
"ip.dst": ["10.0.1.1"],
|
|
},
|
|
},
|
|
}));
|
|
const result = await parseTsharkJsonOutput(JSON.stringify(packets));
|
|
expect(result.endpoints).toHaveLength(11);
|
|
expect(result.endpointCount).toBe(11); // 10 src + 1 dst
|
|
});
|
|
|
|
it.each([
|
|
"",
|
|
"not json",
|
|
"{}",
|
|
"null",
|
|
"[null]",
|
|
'[{"_source":{"layers":{"ip.src":[3]}}}]',
|
|
])("rejects malformed producer output %s", async (text) => {
|
|
await expect(parseTsharkJsonOutput(text)).rejects.toMatchObject({
|
|
_tag: "AnalysisProtocolError",
|
|
});
|
|
});
|
|
});
|