fix(capture): keep excluded MIME protobuf applicability partial (#6012)

* fix(capture): keep excluded MIME protobuf applicability partial

Automatic protobuf framing treated an explicitly excluded, conflicting or
unavailable Content-Type the same as a known-absent one, so a selected body
was skipped and coverage still upgraded to complete. Body metadata now
reports whether MIME applicability is established; an unresolved non-empty
body adds a record limitation and keeps protobuf_coverage partial. HAR uses
its required mimeType, falling back only to a visible header value.

Fixes #5926

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(capture): compare repeated Content-Type declarations by essence

Repeated Content-Type headers that differ only by case, whitespace or
parameters declare the same media type, yet the raw-string comparison
collapsed them to an unknown value and the new applicability check
reported partial coverage. Body metadata now compares media-type
essences and owns automatic framing selection, so the consumer no longer
re-parses the raw value; framing lookup ignores inherited properties.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-21 07:58:31 -07:00
committed by GitHub
parent 1f92c77398
commit 4dfa1c67dc
4 changed files with 195 additions and 17 deletions
+3
View File
@@ -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.
+48 -17
View File
@@ -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 >
@@ -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,
});
},
);
@@ -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);