From 1ece9630e630cd07cf129066c1f6d8785cb8f439 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:48:46 +0800 Subject: [PATCH] fix(pcap): retain absolute HTTP request targets Reuse parsed URI facts and preserve uncertainty when mixed request records cannot all be resolved from the producer output. Fixes #6537 --- docs/pcap-analysis.md | 3 ++ src/pcap/PcapOutput.test.ts | 50 ++++++++++++++++++++++++ src/pcap/PcapOutput.ts | 48 +++++++++++++++++++++-- src/pcap/PcapProvider.ts | 1 + tests/boundary/pcap/pcapProvider.test.ts | 20 +++++++++- 5 files changed, 117 insertions(+), 5 deletions(-) diff --git a/docs/pcap-analysis.md b/docs/pcap-analysis.md index 5749fa3294..da0e607e8b 100644 --- a/docs/pcap-analysis.md +++ b/docs/pcap-analysis.md @@ -86,6 +86,9 @@ not process identity or proof of the destination's authenticity. All examined inventories are retained within the resource budget; no fixed 200/100/50-item truncation is applied. HTTP transport credentials are excluded without rewriting other URI bytes; ambiguous credential spans cause omission. +The URI inventory includes absolute-form HTTP request targets when TShark cannot +construct `http.request.full_uri` because no Host header was reported; relative +request targets are not promoted to absolute URIs. Counts preserve distinct observed source URIs before omission or coalescing. `coverage: "partial"` records unresolved DNS attribution or URI item loss; component credential exclusion is also counted explicitly. Diagnostics retain diff --git a/src/pcap/PcapOutput.test.ts b/src/pcap/PcapOutput.test.ts index 251abb39e2..bf525d1fe7 100644 --- a/src/pcap/PcapOutput.test.ts +++ b/src/pcap/PcapOutput.test.ts @@ -237,6 +237,56 @@ it("excludes transport credentials without normalizing observed URL bytes", asyn expect(result.partial).toBe(true); }); +it("retains HTTP absolute-form request targets when Wireshark cannot synthesize full_uri", async () => { + const result = await parseTsharkJsonOutput( + capture([ + { "http.request.uri": ["http://proxy.example.test/a/../resource"] }, + { "http.request.uri": ["/relative/path"] }, + ]), + ); + expect(result.httpRequestUris).toEqual([ + "http://proxy.example.test/a/../resource", + ]); + expect(result.httpRequestUriCount).toBe(1); + expect(result.partial).toBe(true); +}); + +it("marks mixed request arrays partial when a relative target has no full URI", async () => { + const result = await parseTsharkJsonOutput( + capture([ + { + "http.request.uri": ["/resolved/with-host", "/unresolved/without-host"], + "http.request.full_uri": ["http://host.example/resolved/with-host"], + }, + ]), + ); + expect(result.httpRequestUris).toEqual([ + "http://host.example/resolved/with-host", + ]); + expect(result.partial).toBe(true); +}); + +it("accounts for paired full URIs and absolute-form values without inventing array joins", async () => { + const result = await parseTsharkJsonOutput( + capture([ + { + "http.request.uri": ["/first", "/second", "http://proxy.example/third"], + "http.request.full_uri": [ + "http://host.example/first", + "http://host.example/second", + "http://proxy.example/third", + ], + }, + ]), + ); + expect(result.httpRequestUris).toEqual([ + "http://host.example/first", + "http://host.example/second", + "http://proxy.example/third", + ]); + expect(result.partial).toBe(false); +}); + it("refuses dense JSON before its expanded graph exceeds the decoding budget", async () => { await expect( parseTsharkJsonOutput(`[${"{},".repeat(600_000)}{}]`), diff --git a/src/pcap/PcapOutput.ts b/src/pcap/PcapOutput.ts index 1f6da7ee6e..0d00bf6384 100644 --- a/src/pcap/PcapOutput.ts +++ b/src/pcap/PcapOutput.ts @@ -212,6 +212,7 @@ export const parseTsharkJsonOutput = async ( const uris = new Set(); const observedUris = new Set(); let uriRedactions = 0; + let unresolvedHttpUriTarget = false; const unattributed = new Set(); let unattributedPackets = 0; let retainedBytes = 0; @@ -319,10 +320,48 @@ export const parseTsharkJsonOutput = async ( if (!protocols.has(protocol)) charge(protocol); protocols.set(protocol, (protocols.get(protocol) ?? 0) + 1); } - for (const value of field("http.request.full_uri")) { + const fullUris = field("http.request.full_uri"); + const fullUriCandidates = fullUris.map((value) => ({ + value, + parsed: parseRecordedTransportUrl(value), + })); + const requestUriCandidates = field("http.request.uri").map((value) => ({ + value, + parsed: parseRecordedTransportUrl(value), + })); + // Wireshark emits full_uri alongside each request URI it can resolve. + // Raw absolute-form targets stand on their own; relative targets need a + // corresponding full_uri occurrence. Compare counts only after consuming + // exact absolute-form duplicates, since output arrays have no join key. + const absoluteRequests = new Map(); + let relativeRequestCount = 0; + for (const candidate of requestUriCandidates) { + if (candidate.value === "") continue; + if (candidate.parsed.origin !== null) + absoluteRequests.set( + candidate.value, + (absoluteRequests.get(candidate.value) ?? 0) + 1, + ); + else relativeRequestCount += 1; + } + let unmatchedFullUriCount = 0; + for (const candidate of fullUriCandidates) { + const absoluteCount = absoluteRequests.get(candidate.value) ?? 0; + if (absoluteCount > 0) + absoluteRequests.set(candidate.value, absoluteCount - 1); + else if (candidate.value !== "") unmatchedFullUriCount += 1; + } + if (relativeRequestCount > unmatchedFullUriCount) + unresolvedHttpUriTarget = true; + + for (const candidate of [ + ...fullUriCandidates, + ...requestUriCandidates.filter(({ parsed }) => parsed.origin !== null), + ]) { + const { value, parsed } = candidate; if (value === "" || observedUris.has(value)) continue; add(observedUris, value); - const { credentials } = parseRecordedTransportUrl(value); + const { credentials } = parsed; if (credentials !== null) uriRedactions += 1; if (credentials === "ambiguous") continue; add( @@ -362,6 +401,9 @@ export const parseTsharkJsonOutput = async ( unattributedDnsAddressCount: unattributed.size, unattributedDnsPackets: unattributedPackets, packetCount: packets.length, - partial: observedUris.size > uris.size || unattributedPackets > 0, + partial: + observedUris.size > uris.size || + unattributedPackets > 0 || + unresolvedHttpUriTarget, }; }; diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index 51555d736a..04baa195ea 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -74,6 +74,7 @@ const FIELDS = [ "dns.aaaa", "tls.handshake.extensions_server_name", "http.request.full_uri", + "http.request.uri", ]; const pcapPartialEvidence = ( diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index 5b65346368..582579e0d4 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -74,7 +74,10 @@ const TSHARK_JSON = JSON.stringify([ const writeStubTools = async ( root: string, - options: { readonly failCapinfos?: boolean } = {}, + options: { + readonly failCapinfos?: boolean; + readonly httpAbsoluteUriOnly?: boolean; + } = {}, ): Promise<{ tshark: string; capinfos: string }> => { const tshark = join(root, "tshark"); const capinfos = join(root, "capinfos"); @@ -83,7 +86,8 @@ const writeStubTools = async ( `#!/bin/sh if [ "$1" = "--version" ]; then printf 'TShark (Wireshark) 4.6.6-stub\\n'; exit 0; fi [ "$1" = "-n" ] && [ "$2" = "-r" ] && [ "$4" = "-T" ] && [ "$5" = "json" ] || exit 2 -printf '%s' '${TSHARK_JSON.replace(/'/gu, "'\\''")}' +case " $* " in *" -e http.request.uri "*) ;; *) exit 2 ;; esac +printf '%s' '${(options.httpAbsoluteUriOnly ? TSHARK_JSON.replace('"http.request.full_uri":["http://api.example.com/v1/data"]', '"http.request.uri":["http://proxy.example.test/path"]') : TSHARK_JSON).replace(/'/gu, "'\\''")}' exit 0 `, { mode: 0o755 }, @@ -109,6 +113,7 @@ exit 0 const providerWith = async ( options: { failCapinfos?: boolean; + httpAbsoluteUriOnly?: boolean; launcher?: PcapCommandLauncher; beforeClose?: () => void; } = {}, @@ -231,6 +236,17 @@ it("inspects a capture with endpoints, DNS, TLS SNI, and HTTP", async () => { expect(result.coverage).toBe("complete"); }); +it("retains an absolute HTTP request target without a Host-derived full URI", async () => { + const { provider, root } = await providerWith({ httpAbsoluteUriOnly: true }); + const path = await captureFixture(root); + const result = await evidenceResult(provider, { + operation: "inspect_pcap_capture", + input: { path }, + }); + expect(result.http_request_uris).toEqual(["http://proxy.example.test/path"]); + expect(result.coverage).toBe("complete"); +}); + it.each([ { stage: "capinfos", denySnapshotClose: false }, { stage: "tshark", denySnapshotClose: false },