diff --git a/docs/pcap-analysis.md b/docs/pcap-analysis.md index 8618b7bd75..b65607e55e 100644 --- a/docs/pcap-analysis.md +++ b/docs/pcap-analysis.md @@ -30,6 +30,8 @@ Evidence. Capture inspection requires Linux with util-linux prlimit or Windows with owned Job Objects. macOS capture inspection returns a capability gap because the new native memory boundary is unverified; its version probes remain available. +A selected tool directory or unreadable tool is a configuration capability +refusal naming its selected path, before any capture bytes are opened. Tools and installed libraries/plugins are caller-trusted. An empty private personal Wireshark configuration is used; this is not an OS sandbox. @@ -88,7 +90,11 @@ All examined inventories are retained within the resource budget; no fixed 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. +request targets are not promoted to absolute URIs. The inventory also consumes +`http2.request.full_uri`; a decoded HTTP/2 `:path` without a corresponding full +URI leaves coverage partial. The HTTP URI profile covers HTTP/1 and HTTP/2; +HTTP/3 URI semantics are unexamined. All admitted URI fields share the same +credential exclusion and source-value retention rules. 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 bf525d1fe7..fa14f13e6f 100644 --- a/src/pcap/PcapOutput.test.ts +++ b/src/pcap/PcapOutput.test.ts @@ -318,3 +318,33 @@ it.each([ expect(reason.cleanupIncomplete).toBe(true); }, ); + +it("includes decoded HTTP/2 URIs with shared credential handling and reports unresolved targets", async () => { + const full = await parseTsharkJsonOutput( + capture([ + { + "http2.request.full_uri": [ + "http://example.test/h2", + "https://user:pass@example.test/kept", + ], + "http2.headers.path": ["/h2", "/kept"], + }, + { "http.request.full_uri": ["http://example.test/h1"] }, + ]), + ); + expect(full.httpRequestUris).toEqual([ + "http://example.test/h2", + "https://example.test/kept", + "http://example.test/h1", + ]); + expect(full.httpRequestUriCount).toBe(3); + expect(full.httpUriRedactionCount).toBe(1); + expect(full.partial).toBe(false); + expect( + ( + await parseTsharkJsonOutput( + capture([{ "http2.headers.path": ["/unresolved"] }]), + ) + ).partial, + ).toBe(true); +}); diff --git a/src/pcap/PcapOutput.ts b/src/pcap/PcapOutput.ts index 0d00bf6384..60c5316d64 100644 --- a/src/pcap/PcapOutput.ts +++ b/src/pcap/PcapOutput.ts @@ -354,8 +354,20 @@ export const parseTsharkJsonOutput = async ( if (relativeRequestCount > unmatchedFullUriCount) unresolvedHttpUriTarget = true; + const http2Uris = field("http2.request.full_uri").filter( + (value) => value !== "", + ); + if ( + field("http2.headers.path").filter((value) => value !== "").length > + http2Uris.length + ) + unresolvedHttpUriTarget = true; for (const candidate of [ ...fullUriCandidates, + ...http2Uris.map((value) => ({ + value, + parsed: parseRecordedTransportUrl(value), + })), ...requestUriCandidates.filter(({ parsed }) => parsed.origin !== null), ]) { const { value, parsed } = candidate; diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index e1b67a476c..57a6fc8c34 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -76,6 +76,8 @@ const FIELDS = [ "tls.handshake.extensions_server_name", "http.request.full_uri", "http.request.uri", + "http2.request.full_uri", + "http2.headers.path", ]; const pcapPartialEvidence = ( @@ -235,6 +237,35 @@ export class PcapProvider { ); } + /** Selected-tool reads are configuration admission, independently of capture bytes. */ + private async readToolIdentity( + operation: PcapOperation, + path: string, + signal?: AbortSignal, + ) { + try { + return await this.#resources.read(path, 64 * 1024 * 1024, signal); + } catch (cause) { + if (cause instanceof AnalysisError) throw cause; + if ( + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + throw cause; + const classify = (failure: unknown) => + failure instanceof AnalysisError + ? failure + : pcapToolUnavailable( + operation, + `Cannot read selected Wireshark tool ${path}: ${failure instanceof Error ? failure.message : String(failure)}`, + { cause: failure }, + ); + throw cause instanceof ArtifactReaderFailure + ? this.#resources.cleanupFailure(cause, classify, undefined, operation) + : classify(cause); + } + } + private async run( operation: PcapOperation, stage: "version" | "capinfos" | "tshark", @@ -252,9 +283,9 @@ export class PcapProvider { const selected = this.wiresharkIdentities.get(command); try { if (selected !== undefined) { - const before = await this.#resources.read( + const before = await this.readToolIdentity( + operation, selected.path, - 64 * 1024 * 1024, signal, ); assertWiresharkExecutableIdentity( @@ -297,10 +328,7 @@ export class PcapProvider { }); commandReturned = true; if (selected !== undefined) { - const after = await this.#resources.read( - selected.path, - 64 * 1024 * 1024, - ); + const after = await this.readToolIdentity(operation, selected.path); assertWiresharkExecutableIdentity( operation, selected.path, @@ -392,9 +420,9 @@ export class PcapProvider { ); throw cause; } - const snapshot = await this.#resources.read( + const snapshot = await this.readToolIdentity( + operation, resolvedPath, - 64 * 1024 * 1024, signal, ); this.wiresharkIdentities.set(selected.command, { @@ -1016,9 +1044,10 @@ export class PcapProvider { "Both tools read one owned snapshot; target identity comes from its admitted source descriptor", "DNS address values are associated only with a single question in the same response packet; values may include additional DNS records, not only final resolution answers", "Only bytes present in the saved capture are examined; encryption and capture truncation can hide higher-level fields", + "HTTP URI inventory examines decoded HTTP/1 and HTTP/2 request fields; HTTP/3 URI semantics are outside this profile", ...(fields.partial ? [ - "One or more projected categories were capped, credential-bearing URIs were omitted or coalesced, or DNS address attribution remains unresolved; counts retain the examined totals", + "One or more projected categories were capped, credential-bearing URIs were omitted or coalesced, or DNS address attribution or HTTP URI targets remain unresolved; counts retain the examined totals", ] : []), ...(fields.httpUriRedactionCount > 0 diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index 7802046793..bc22b9d42a 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -726,3 +726,15 @@ it.each([false, true])( ).rejects.toMatchObject({ code: "ENOENT" }); }, ); + +it("requests HTTP/2 URI facts and projects them through capture inspection", async () => { + const { provider, root } = await providerWith({ http2Uri: true }); + const path = await captureFixture(root); + const result = await evidenceResult(provider, { + operation: "inspect_pcap_capture", + input: { path }, + }); + expect(result.http_request_uris).toEqual(["http://example.test/h2"]); + expect(result.http_request_uri_count).toBe(1); + expect(result.coverage).toBe("complete"); +}); diff --git a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts index f7ba18e8ef..d58f49669d 100644 --- a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts +++ b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts @@ -179,51 +179,80 @@ exit 2 }, ); -it("resolves a retained tool-probe error after its process owner closes", async () => { - let mayClean = false; - let launches = 0; - const launcher: PcapCommandLauncher = async (spawn) => { - const launched = await spawnOwnedProviderProcess(spawn); - launches++; - if (launches !== 1) return launched; - const cleanup = - launched.cleanup ?? (() => cleanupOwnedProcessGroup(launched.ownership)); - return { - ...launched, - cleanup: async () => { - const result = await cleanup(); - if (!mayClean) throw new Error("injected tool-probe cleanup failure"); - return result; - }, +it.each([0, 7])( + "retains failed-probe cleanup across admission retry and resolves after owner close: exit=%s", + async (exitCode) => { + let mayClean = false; + let launches = 0; + const launcher: PcapCommandLauncher = async (spawn) => { + const launched = await spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: [ + "-e", + `process.stdout.write("TShark (Wireshark) 4.6.6\\n"); process.exitCode = ${exitCode}`, + ], + expectedCommand: process.execPath, + }); + launches++; + if (launches !== 1) return launched; + const cleanup = + launched.cleanup ?? + (() => cleanupOwnedProcessGroup(launched.ownership)); + return { + ...launched, + cleanup: async () => { + const result = await cleanup(); + if (!mayClean) throw new Error("injected tool-probe cleanup failure"); + return result; + }, + }; }; - }; - const provider = new PcapProvider({ - environment: { - REA_TSHARK_COMMAND: tsharkStub, - REA_CAPINFOS_COMMAND: capinfosStub, - }, - launcher, - }); - onTestFinished(async () => { + const provider = new PcapProvider({ + environment: { + REA_TSHARK_COMMAND: tsharkStub, + REA_CAPINFOS_COMMAND: capinfosStub, + }, + launcher, + }); + onTestFinished(async () => { + mayClean = true; + await provider.close(); + }); + + const result = await provider.execute({ + operation: "inspect_pcap_client", + input: {}, + }); + if (result.ok) throw new Error("Expected retained tool-probe cleanup"); + expect(result.error.cleanupIncomplete).toBe(true); + if (exitCode === 0) + expect(result.error.diagnostics).toMatchObject({ + reason: "injected tool-probe cleanup failure", + }); + expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete"); + const retry = await provider.execute({ + operation: "inspect_pcap_client", + input: {}, + }); + if (retry.ok) + throw new Error("Expected retained cleanup to block admission"); + expect(projectAnalysisError(retry.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + resources: expect.arrayContaining(result.error.cleanupResources), + }, + }); + expect(retry.error.cleanupResources.length).toBeGreaterThan(0); + expect(launches).toBe(1); + mayClean = true; await provider.close(); - }); - - const result = await provider.execute({ - operation: "inspect_pcap_client", - input: {}, - }); - if (result.ok) throw new Error("Expected retained tool-probe cleanup"); - expect(result.error.cleanupIncomplete).toBe(true); - expect(result.error.diagnostics).toMatchObject({ - reason: "injected tool-probe cleanup failure", - }); - - mayClean = true; - await provider.close(); - expect(result.error.cleanupIncomplete).toBe(false); - expect(launches).toBe(1); -}); + expect(result.error.cleanupIncomplete).toBe(false); + expect(retry.error.cleanupIncomplete).toBe(false); + expect(launches).toBe(1); + }, +); it.runIf(hasNativeBound)( "inspects a capture with metadata, endpoints, DNS, and protocol facts", @@ -365,3 +394,30 @@ it("retains a verification admission owner before translating cancellation", asy await owner.close(); } }); + +it.each(["tshark", "capinfos"] as const)( + "classifies directory-selected %s as a configuration refusal with its path", + async (tool) => { + const root = await createTestTempDirectory("rea-pcap-tool-directory-"); + const provider = new PcapProvider({ + environment: { + REA_TSHARK_COMMAND: tool === "tshark" ? root : tsharkStub, + REA_CAPINFOS_COMMAND: tool === "capinfos" ? root : capinfosStub, + }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "inspect_pcap_client", + input: {}, + }); + if (result.ok) throw new Error("Expected selected tool admission refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "capability_unavailable", + message: expect.stringContaining(root), + details: { provider_id: "pcap", operation: "inspect_pcap_client" }, + }); + expect(projectAnalysisError(result.error).message).toContain( + "Wireshark 4.x", + ); + }, +); diff --git a/tests/fixtures/pcap/pcapTestTools.ts b/tests/fixtures/pcap/pcapTestTools.ts index 025164cb82..6d12af37b1 100644 --- a/tests/fixtures/pcap/pcapTestTools.ts +++ b/tests/fixtures/pcap/pcapTestTools.ts @@ -53,6 +53,7 @@ export const TSHARK_JSON = JSON.stringify([ export interface PcapStubToolsOptions { readonly failCapinfos?: boolean; readonly httpAbsoluteUriOnly?: boolean; + readonly http2Uri?: boolean; readonly invalidTsharkStream?: "stdout" | "stderr"; readonly invalidTsharkVersionStream?: "stdout" | "stderr"; readonly invalidCapinfosStream?: "stdout" | "stderr"; @@ -68,17 +69,22 @@ export const writeStubTools = async ( ): Promise<{ tshark: string; capinfos: string }> => { const tshark = join(root, "tshark"); const capinfos = join(root, "capinfos"); - const tsharkOutput = options.literalReplacement + const tsharkOutput = options.http2Uri ? TSHARK_JSON.replace( - "http://api.example.com/v1/data", - "http://api.example.com/�", - ) - : options.httpAbsoluteUriOnly + '"http.request.full_uri":["http://api.example.com/v1/data"]', + '"http2.request.full_uri":["http://example.test/h2"],"http2.headers.path":["/h2"]', + ).replace('eth:ethertype:ip:tcp:http"', 'eth:ethertype:ip:tcp:http2"') + : options.literalReplacement ? TSHARK_JSON.replace( - '"http.request.full_uri":["http://api.example.com/v1/data"]', - '"http.request.uri":["http://proxy.example.test/path"]', + "http://api.example.com/v1/data", + "http://api.example.com/�", ) - : TSHARK_JSON; + : 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; await writeFile( tshark, `#!/bin/sh @@ -89,6 +95,8 @@ if [ "$1" = "--version" ]; then fi [ "$1" = "-n" ] && [ "$2" = "-r" ] && [ "$4" = "-T" ] && [ "$5" = "json" ] || exit 2 case " $* " in *" -e http.request.uri "*) ;; *) exit 2 ;; esac +case " $* " in *" -e http2.request.full_uri "*) ;; *) exit 2 ;; esac +case " $* " in *" -e http2.headers.path "*) ;; *) exit 2 ;; esac ${options.invalidTsharkStream === "stdout" ? `printf '%s' '[{"_source":{"layers":{"frame.number":["1"],"frame.protocols":["eth:ip:tcp:http"],"http.request.full_uri":["http://example.test/'; printf '\\377'; printf '%s' '"]}}}]'` : `printf '%s' '${tsharkOutput.replace(/'/gu, "'\\''")}'`} ${options.invalidTsharkStream === "stderr" ? `printf '\\377' >&2` : ""} exit 0