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
This commit is contained in:
morluto committed 2026-10-23 19:07:46 +08:00
1 parent 4524bbd8df
commit 1ece9630e6
5 files changed
+117 -5

No files matched your search

+3
View File
@@ -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
+50
View File
@@ -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)}{}]`),
+45 -3
View File
@@ -212,6 +212,7 @@ export const parseTsharkJsonOutput = async (
const uris = new Set<string>();
const observedUris = new Set<string>();
let uriRedactions = 0;
let unresolvedHttpUriTarget = false;
const unattributed = new Set<string>();
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<string, number>();
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,
};
};
+1
View File
@@ -74,6 +74,7 @@ const FIELDS = [
"dns.aaaa",
"tls.handshake.extensions_server_name",
"http.request.full_uri",
"http.request.uri",
];
const pcapPartialEvidence = (
+18 -2
View File
@@ -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 },