diff --git a/docs/pcap-analysis.md b/docs/pcap-analysis.md index 1df3e5ccd..3e13ec225 100644 --- a/docs/pcap-analysis.md +++ b/docs/pcap-analysis.md @@ -58,6 +58,11 @@ tools read that copy, which is hashed again after analysis. Capture metadata and literal field values are producer observations; address/protocol counts and DNS associations are derivations. Capture Evidence has historical-reference authority. +`capture_start` and `capture_end` are RFC 3339 UTC instants derived from +`capinfos -S` epoch seconds, retaining all producer fractional digits (up to +nanoseconds). Empty captures, malformed timestamps and instants outside years +0000–9999 remain `null`; host-local timestamps without a zone are not inferred. + Addresses count once per packet, including loopback and nested IP occurrences. DNS addresses are associated only with one question in the same response packet; additional records may be included, so this does not prove final resolution. diff --git a/src/pcap/PcapConfiguration.test.ts b/src/pcap/PcapConfiguration.test.ts index decca2812..5fa458893 100644 --- a/src/pcap/PcapConfiguration.test.ts +++ b/src/pcap/PcapConfiguration.test.ts @@ -48,8 +48,8 @@ const REAL_CAPINFOS = [ "Number of packets: 50", "File size: 23279 bytes", "Capture duration: 0.373473 seconds", - "Earliest packet time: 2026-10-11 14:13:40.608505", - "Latest packet time: 2026-10-11 14:13:40.981978", + "Earliest packet time: 1791728020.608505", + "Latest packet time: 1791728020.981978", "SHA256: c94f94742b3288b9a979711509ed5eab3028576301f8938319de3c22a6f5c40b", ].join("\n"); @@ -59,8 +59,8 @@ describe("parseCapinfosOutput", () => { fileType: "Wireshark/tcpdump/... - pcap", encapsulation: "Linux cooked-mode capture v2", packetCount: 50, - captureStart: "2026-10-11 14:13:40.608505", - captureEnd: "2026-10-11 14:13:40.981978", + captureStart: "2026-10-11T14:13:40.608505Z", + captureEnd: "2026-10-11T14:13:40.981978Z", captureDurationSeconds: 0.373473, snapshotLength: 262144, sha256: @@ -74,8 +74,67 @@ describe("parseCapinfosOutput", () => { "First packet time", ).replace("Latest packet time", "Last packet time"); const metadata = parseCapinfosOutput(output); - expect(metadata.captureStart).toBe("2026-10-11 14:13:40.608505"); - expect(metadata.captureEnd).toBe("2026-10-11 14:13:40.981978"); + 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", () => { diff --git a/src/pcap/PcapOutput.ts b/src/pcap/PcapOutput.ts index 274a85e81..9a9838f58 100644 --- a/src/pcap/PcapOutput.ts +++ b/src/pcap/PcapOutput.ts @@ -27,6 +27,40 @@ export interface CapinfosMetadata { readonly sha256: string | null; } +/** + * capinfos -S prints each packet timestamp as seconds since the Unix epoch + * with the capture's own fractional precision, a leading minus for an instant + * before the epoch. Its default spelling is a zone-less local wall-clock time, + * so one capture reads differently on hosts in different time zones. Project + * the exact value to an RFC 3339 UTC instant, keeping the fractional digit + * count. "n/a" (an empty capture), a wall-clock spelling and a value outside + * years 0000 to 9999 stay unknown. + */ +const captureInstant = (value: string | undefined): string | null => { + // Wireshark formats at most 64-bit seconds and nine fractional digits. + const match = /^(-?)(\d{1,20})(?:\.(\d{1,9}))?$/u.exec(value ?? ""); + if (match?.[2] === undefined) return null; + const negative = match[1] === "-"; + let seconds = BigInt(match[2]); + let fraction = match[3] ?? ""; + if (negative) { + // -1.25 is one and a quarter seconds before the epoch: whole second + // -2 plus 0.75, so the fraction is borrowed from the next whole second. + const digits = fraction.length; + const borrowed = digits > 0 && BigInt(fraction) > 0n; + seconds = -seconds - (borrowed ? 1n : 0n); + if (borrowed) + fraction = (10n ** BigInt(digits) - BigInt(fraction)) + .toString() + .padStart(digits, "0"); + } + if (seconds < -62_167_219_200n || seconds > 253_402_300_799n) return null; + const date = new Date(Number(seconds) * 1000); + const year = date.getUTCFullYear(); + if (Number.isNaN(year) || year < 0 || year > 9999) return null; + return `${date.toISOString().slice(0, 19)}${fraction === "" ? "" : `.${fraction}`}Z`; +}; + /** capinfos -M with a C locale provides exact numbers instead of "20 k". */ export const parseCapinfosOutput = (stdout: string): CapinfosMetadata => { const lines = new Map(); @@ -66,12 +100,12 @@ export const parseCapinfosOutput = (stdout: string): CapinfosMetadata => { fileType, encapsulation, packetCount, - captureStart: - lines.get("Earliest packet time") ?? - lines.get("First packet time") ?? - null, - captureEnd: - lines.get("Latest packet time") ?? lines.get("Last packet time") ?? null, + captureStart: captureInstant( + lines.get("Earliest packet time") ?? lines.get("First packet time"), + ), + captureEnd: captureInstant( + lines.get("Latest packet time") ?? lines.get("Last packet time"), + ), captureDurationSeconds: duration === null ? null : Number(duration[1]), snapshotLength: snapshotLength !== null && diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index 2e9abed31..420557584 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -578,7 +578,7 @@ export class PcapProvider { const info = await this.run( "inspect_pcap_capture", selection.capinfos.command, - ["-M", "--", snapshot.path], + ["-M", "-S", "--", snapshot.path], { timeoutMs: 60_000, maxOutputBytes: 64 * 1024 }, signal, snapshot, diff --git a/tests/boundary/mcp/pcapMcp.test.ts b/tests/boundary/mcp/pcapMcp.test.ts index d3a6d203d..dff164ee5 100644 --- a/tests/boundary/mcp/pcapMcp.test.ts +++ b/tests/boundary/mcp/pcapMcp.test.ts @@ -43,8 +43,8 @@ it("advertises exact valid pcap schemas and records inline evidence through MCP" file_type: "Wireshark/tcpdump/... - pcap", encapsulation: "Linux cooked-mode capture v2", packet_count: 50, - capture_start: "2026-10-11 14:13:40.608505", - capture_end: "2026-10-11 14:13:40.981978", + capture_start: "2026-10-11T14:13:40.608505Z", + capture_end: "2026-10-11T14:13:40.981978Z", capture_duration_seconds: 0.373473, snapshot_length: 262144, endpoints: [ @@ -112,6 +112,8 @@ it("advertises exact valid pcap schemas and records inline evidence through MCP" const evidence = parseEvidence(JSON.parse(text)); expect(evidence.normalized_result).toMatchObject({ packet_count: 50, + capture_start: "2026-10-11T14:13:40.608505Z", + capture_end: "2026-10-11T14:13:40.981978Z", tls_sni_values: ["example.com"], coverage: "complete", }); diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index 9c51b96e5..fbc8b1358 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -26,6 +26,8 @@ const CAPINFOS_OUTPUT = [ "Packet size limit: file hdr: 262144 bytes", "Number of packets: 3", "Capture duration: 0.123 seconds", + "Earliest packet time: 1791728020.608505", + "Latest packet time: 1791728020.981978", ].join("\n"); const TSHARK_JSON = JSON.stringify([ @@ -86,7 +88,7 @@ exit 0 capinfos, `#!/bin/sh if [ "$1" = "--version" ]; then printf 'Capinfos (Wireshark) 4.6.6-stub\\n'; exit 0; fi -[ "$1" = "-M" ] && [ "$2" = "--" ] && [ "$#" = "3" ] || exit 2 +[ "$1" = "-M" ] && [ "$2" = "-S" ] && [ "$3" = "--" ] && [ "$#" = "4" ] || exit 2 if [ "${String(options.failCapinfos === true)}" = "true" ]; then printf 'capinfos: The file is not a capture file' >&2; exit 1 fi @@ -165,6 +167,8 @@ it("inspects a capture with endpoints, DNS, TLS SNI, and HTTP", async () => { sha256: createHash("sha256").update("pcap-fixture-bytes").digest("hex"), }); expect(result.file_type).toBe("Wireshark/tcpdump/... - pcap"); + expect(result.capture_start).toBe("2026-10-11T14:13:40.608505Z"); + expect(result.capture_end).toBe("2026-10-11T14:13:40.981978Z"); expect(result.packet_count).toBe(3); expect(result.endpoints).toEqual( expect.arrayContaining([ diff --git a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts index 8f07d6c7c..8170e712a 100644 --- a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts +++ b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts @@ -131,6 +131,8 @@ it.runIf(hasNativeBound)( }); expect(result.file_type).toContain("pcap"); expect(result.packet_count).toBe(3); + expect(result.capture_start).toBe("2026-10-11T14:13:40.608505Z"); + expect(result.capture_end).toBe("2026-10-11T14:13:40.981978Z"); expect((result.endpoints as unknown[]).length).toBeGreaterThan(0); expect((result.protocol_distribution as unknown[]).length).toBeGreaterThan( 0, diff --git a/tests/fixtures/pcap/capinfos-stub.cmd b/tests/fixtures/pcap/capinfos-stub.cmd index 6f783bcf5..8fca0945d 100644 --- a/tests/fixtures/pcap/capinfos-stub.cmd +++ b/tests/fixtures/pcap/capinfos-stub.cmd @@ -7,8 +7,9 @@ if "%~1"=="--version" ( ) if not "%~1"=="-M" exit /b 2 -if not "%~2"=="--" exit /b 2 -if "%~3"=="" exit /b 2 +if not "%~2"=="-S" exit /b 2 +if not "%~3"=="--" exit /b 2 +if "%~4"=="" exit /b 2 echo File name: capture.pcap echo File type: Wireshark/tcpdump/... - pcap @@ -16,4 +17,6 @@ echo File encapsulation: Ethernet echo Packet size limit: file hdr: 262144 bytes echo Number of packets: 3 echo Capture duration: 0.123 seconds +echo Earliest packet time: 1791728020.608505 +echo Latest packet time: 1791728020.981978 exit /b 0 diff --git a/tests/fixtures/pcap/capinfos-stub.sh b/tests/fixtures/pcap/capinfos-stub.sh index 47d61b1b1..52700df1c 100755 --- a/tests/fixtures/pcap/capinfos-stub.sh +++ b/tests/fixtures/pcap/capinfos-stub.sh @@ -1,11 +1,13 @@ #!/bin/sh # POSIX capinfos stub (mirrors capinfos-stub.cmd for Windows parity). if [ "$1" = "--version" ]; then printf 'Capinfos (Wireshark) 4.6.6-posix-stub\n'; exit 0; fi -[ "$1" = "-M" ] && [ "$2" = "--" ] && [ "$#" = "3" ] || exit 2 +[ "$1" = "-M" ] && [ "$2" = "-S" ] && [ "$3" = "--" ] && [ "$#" = "4" ] || exit 2 printf '%s\n' 'File name: capture.pcap' printf '%s\n' 'File type: Wireshark/tcpdump/... - pcap' printf '%s\n' 'File encapsulation: Ethernet' printf '%s\n' 'Packet size limit: file hdr: 262144 bytes' printf '%s\n' 'Number of packets: 3' printf '%s\n' 'Capture duration: 0.123 seconds' +printf '%s\n' 'Earliest packet time: 1791728020.608505' +printf '%s\n' 'Latest packet time: 1791728020.981978' exit 0