From 796faa0e4e6bf745bcdc33acef65af8ccfc3622d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:40:25 +0800 Subject: [PATCH] fix(pcap): preserve exact UTC capture instants Request epoch timestamps from capinfos and normalize them once at the metadata boundary, preserving nanosecond precision and explicit unknowns. Bound numeric parsing to the producer representation and cover negative fractions, calendar limits, malformed output and MCP projection. Adapted from PR #4909. Fixes #4907. Validation: 47 passed, 24 platform-specific skips across five focused files; check:fast and test discovery passed. No live Wireshark or Linux/Windows capture execution was performed on this macOS host. Co-authored-by: Mert Kaya <47145541+mertkayacs@users.noreply.github.com> --- docs/pcap-analysis.md | 5 ++ src/pcap/PcapConfiguration.test.ts | 71 +++++++++++++++++-- src/pcap/PcapOutput.ts | 46 ++++++++++-- src/pcap/PcapProvider.ts | 2 +- tests/boundary/mcp/pcapMcp.test.ts | 6 +- tests/boundary/pcap/pcapProvider.test.ts | 6 +- .../pcap/pcapProviderCrossPlatform.test.ts | 2 + tests/fixtures/pcap/capinfos-stub.cmd | 7 +- tests/fixtures/pcap/capinfos-stub.sh | 4 +- 9 files changed, 130 insertions(+), 19 deletions(-) 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