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>
This commit is contained in:
morlutoandMert Kaya committed 2026-10-18 07:40:25 +08:00
1 parent f0b9ef34c4
commit 796faa0e4e
9 files changed
+130 -19

No files matched your search

+5
View File
@@ -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.
+65 -6
View File
@@ -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", () => {
+40 -6
View File
@@ -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<string, string>();
@@ -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 &&
+1 -1
View File
@@ -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,
+4 -2
View File
@@ -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",
});
+5 -1
View File
@@ -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([
@@ -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,
+5 -2
View File
@@ -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
+3 -1
View File
@@ -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