mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user