fix(pcap): preserve HTTP/2 URI and selected-tool admission facts
This commit is contained in:
1 parent
928ba76882
commit
4cb1f1d820
7 files changed
+213
-60
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
);
|
||||
Vendored
+16
-8
@@ -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
|
||||
|
||||
Reference in new issue
Block a user