diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 456342b30a..e37ea9669d 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -178,6 +178,9 @@ sanitized source even with explicit sensitivity options. If that source pass is interrupted, unchecked bodies are not published as partial Evidence. Framing overrides apply only to format-owned body coordinates and preserve exclusions for the transport declared in MIME metadata. +Explicitly excluded or conflicting MIME metadata is not treated as absent: +automatic framing leaves such a non-empty body uninterpreted, adds a record +limitation and keeps `protobuf_coverage` partial unless `framing` is selected. Unknown or explicitly excluded HTTP coding metadata is not treated as absent: SAZ/native bodies remain opaque until identity coding is established. Raw header values and retained body bytes remain available in source Evidence. diff --git a/src/browser/history/protobuf/ProtobufCapture.ts b/src/browser/history/protobuf/ProtobufCapture.ts index 0eaaf78806..cdd33791e3 100644 --- a/src/browser/history/protobuf/ProtobufCapture.ts +++ b/src/browser/history/protobuf/ProtobufCapture.ts @@ -93,6 +93,10 @@ const excludeBody = ( record.redactions.push({ pointer, reason }); }; +/** A media type's case-insensitive essence; parameters do not select framing. */ +const mediaTypeEssence = (value: string) => + value.split(";", 1)[0]!.trim().toLowerCase(); + /** Format-aware owner of body metadata; callers never reinterpret producer JSON. */ const bodyMetadata = ( record: WebNetworkCaptureRecord, @@ -143,28 +147,47 @@ const bodyMetadata = ( if (typeof name === "string") { const key = name.toLowerCase(); const knownValue = typeof value === "string" ? value : undefined; + const previous = headers.get(key); + // Repeated declarations agree only when known and equal; media types by essence. headers.set( key, - headers.has(key) && headers.get(key) !== knownValue - ? undefined - : knownValue, + !headers.has(key) + ? knownValue + : previous !== undefined && + knownValue !== undefined && + (key === "content-type" + ? mediaTypeEssence(previous) === mediaTypeEssence(knownValue) + : previous === knownValue) + ? previous + : undefined, ); } else headerNamesKnown = false; } } + // A visible declaration selects framing. Absence is established only when every header + // name is known; excluded or conflicting values leave framing applicability unknown. + const header = headers.get("content-type"); + const automaticFraming = (contentType: string | undefined) => { + const essence = + contentType === undefined ? undefined : mediaTypeEssence(contentType); + return essence !== undefined && Object.hasOwn(PROTOBUF_FRAMING, essence) + ? PROTOBUF_FRAMING[essence as keyof typeof PROTOBUF_FRAMING] + : undefined; + }; if (format === "har") { if ( pointer !== `/${side}/${side === "request" ? "postData" : "content"}/text` ) return undefined; - const content = object( + // HAR requires a string mimeType; any other value is an excluded declaration. + const mimeType = object( message[side === "request" ? "postData" : "content"], - ); + )?.mimeType; + const contentType = typeof mimeType === "string" ? mimeType : header; return { - contentType: - typeof content?.mimeType === "string" - ? content.mimeType - : headers.get("content-type"), + contentType, + automaticFraming: automaticFraming(contentType), + contentTypeKnown: contentType !== undefined, httpIdentityKnown: true, httpCodingKnown: true, }; @@ -172,7 +195,11 @@ const bodyMetadata = ( if (pointer !== `/${side}/body` && pointer !== `/${side}/content`) return undefined; return { - contentType: headers.get("content-type"), + contentType: header, + automaticFraming: automaticFraming(header), + contentTypeKnown: + header !== undefined || + (headerNamesKnown && !headers.has("content-type")), httpCodingKnown: headerNamesKnown && ["content-encoding", "transfer-encoding"].every( @@ -257,17 +284,21 @@ export const prepareCaptureProtobuf = async ( incomplete = true; continue; } - const contentType = metadata.contentType; - const essence = contentType?.split(";", 1)[0]?.trim().toLowerCase(); - const automatic = - essence === undefined - ? undefined - : PROTOBUF_FRAMING[essence as keyof typeof PROTOBUF_FRAMING]; + const { contentType, automaticFraming: automatic } = metadata; const framing = options?.framing === undefined || options.framing === "auto" ? automatic : options.framing; - if (framing === undefined) continue; + if (framing === undefined) { + // An empty body has no protobuf content under any framing. + if (!metadata.contentTypeKnown && field.bytes > 0) { + incomplete = true; + record.limitations.push( + "Selected body's MIME metadata is excluded, conflicting or unavailable, so automatic protobuf framing applicability is unknown; its retained bytes were not interpreted. Select explicit framing to decode them.", + ); + } + continue; + } if ( field.bytes > WEB_NETWORK_CAPTURE_LIMITS.inputBytes || field.content_base64.length > diff --git a/tests/acceptance/tools/protobufCapture.test.ts b/tests/acceptance/tools/protobufCapture.test.ts index 46db5f33af..8c725b8de4 100644 --- a/tests/acceptance/tools/protobufCapture.test.ts +++ b/tests/acceptance/tools/protobufCapture.test.ts @@ -629,3 +629,39 @@ captureTest.for( expect(await readFile(path, "utf8")).toBe(original); }, ); + +captureTest( + "keeps automatic framing partial when a HAR mimeType is excluded through MCP", + async ({ mcp }) => { + const root = await createTestTempDirectory("rea-protobuf-mime-"); + const path = join(root, "capture.har"); + await writeFile(path, protobufHar()); + const reply = await mcp.call("inspect_web_network_capture", { + capture_path: path, + format: "har", + record_ordinals: [0], + sensitive_values: ["grpc-web+proto"], + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const report = webNetworkCaptureSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + expect(report.records[0]?.redactions).toContainEqual({ + pointer: "/response/content/mimeType", + reason: "explicit-sensitive-value", + }); + expect(report.records[0]?.binary_fields[0]).toMatchObject({ + pointer: "/response/content/text", + state: "retained", + }); + expect(report.records[0]?.protobuf).toBeUndefined(); + expect(report.records[0]?.limitations).toContainEqual( + expect.stringMatching(/^Selected body's MIME metadata is excluded/), + ); + expect(report.protobuf_coverage).toEqual({ + status: "partial", + selected_records: 1, + inspected_records: 1, + }); + }, +); diff --git a/tests/boundary/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts index a325304db7..de1bec10ed 100644 --- a/tests/boundary/browser/sazWebCapture.test.ts +++ b/tests/boundary/browser/sazWebCapture.test.ts @@ -289,6 +289,114 @@ it("checks trailer credentials before hidden metadata blocks analysis and withho expect(await readFile(path)).toEqual(bytes); }); +it("keeps automatic framing applicability unknown when SAZ Content-Type is excluded or conflicting", async () => { + const body = protobufFrame(Buffer.from([8, 1])); + const response = (headers: string) => + Buffer.concat([ + Buffer.from( + `HTTP/1.1 200 OK\r\n${headers}Content-Length: ${body.length}\r\n\r\n`, + ), + body, + ]); + const bytes = await archive([ + ["raw/1_s.txt", response("Content-Type: application/grpc\r\n")], + [ + "raw/2_s.txt", + response( + "Content-Type: application/grpc\r\nContent-Type: text/plain\r\n", + ), + ], + ["raw/3_s.txt", response("")], + ["raw/4_s.txt", response("Content-Type: application/octet-stream\r\n")], + [ + "raw/5_s.txt", + response( + "Content-Type: application/grpc\r\nContent-Type: Application/GRPC ; x=1\r\n", + ), + ], + ]); + const path = await fixture(bytes); + const inspect = async (extra: string[]) => + webNetworkCaptureSchema.parse((await cli(path, extra)).normalized_result); + const unknown = expect.stringMatching( + /^Selected body's MIME metadata is excluded, conflicting or unavailable/, + ); + const retainedBody = expect.objectContaining({ + pointer: "/response/body", + state: "retained", + sha256: createHash("sha256").update(body).digest("hex"), + }); + + // Repeated declarations with one media-type essence agree; distinct essences conflict. + const visible = await inspect([ + "--record", + "0", + "--record", + "1", + "--record", + "4", + ]); + const record = (ordinal: number) => + visible.records.find((item) => item.ordinal === ordinal); + for (const ordinal of [0, 4]) { + expect(record(ordinal)?.protobuf).toMatchObject([ + { status: "complete", fields: [{ number: 1, value: "1" }] }, + ]); + expect(record(ordinal)?.limitations).toEqual([]); + } + // The first raw declaration, including its SAZ leading space, remains the reported value. + expect(record(4)?.protobuf?.[0]?.content_type).toBe(" application/grpc"); + expect(record(1)?.protobuf).toBeUndefined(); + expect(record(1)?.limitations).toEqual([unknown]); + expect(visible.protobuf_coverage).toEqual({ + status: "partial", + selected_records: 3, + inspected_records: 3, + }); + + const known = await inspect(["--record", "2", "--record", "3"]); + expect(known.protobuf_coverage?.status).toBe("complete"); + for (const record of known.records) { + expect(record.protobuf).toBeUndefined(); + expect(record.limitations).toEqual([]); + } + + for (const sensitive of ["application/grpc", "Content-Type"]) { + const excluded = await inspect([ + "--record", + "0", + "--sensitive-value", + sensitive, + ]); + expect(excluded.protobuf_coverage, sensitive).toEqual({ + status: "partial", + selected_records: 1, + inspected_records: 1, + }); + expect(excluded.records[0]?.protobuf).toBeUndefined(); + expect(excluded.records[0]?.limitations).toEqual([unknown]); + expect(excluded.records[0]?.binary_fields).toContainEqual(retainedBody); + expect(excluded.records[0]?.redactions).toContainEqual({ + pointer: `/response/headers/0/${sensitive === "Content-Type" ? "name" : "value"}`, + reason: "explicit-sensitive-value", + }); + } + + const explicit = await inspect([ + "--record", + "0", + "--sensitive-value", + "application/grpc", + "--protobuf-framing", + "grpc", + ]); + expect(explicit.protobuf_coverage?.status).toBe("complete"); + expect(explicit.records[0]?.protobuf).toMatchObject([ + { status: "complete", fields: [{ number: 1, value: "1" }] }, + ]); + expect(await readFile(path)).toEqual(bytes); +}); + it("inspects native SAZ through the public CLI and MCP with equal evidence, bytes and archive coordinates", async () => { const bytes = await archive(); const path = await fixture(bytes);