fix(pcap): preserve HTTP/2 URI and selected-tool admission facts

This commit is contained in:
morluto committed 2026-10-24 02:35:59 +08:00
1 parent 928ba76882
commit 4cb1f1d820
7 files changed
+213 -60

No files matched your search

+7 -1
View File
@@ -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
+30
View File
@@ -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);
});
+12
View File
@@ -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;
+38 -9
View File
@@ -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
+12
View File
@@ -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");
});
@@ -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",
);
},
);
+16 -8
View File
@@ -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