diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index e4afbde3f1..5a5f75ef42 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -270,6 +270,10 @@ not register or wrap the Fiddler MCP server. sensitive-value exclusions. Redacted byte fields retain neither original bytes nor their digest. Unknown extension names and token-looking query parameters do not establish sensitivity. + HAR authentication fields, transport URLs and base64 content are interpreted + only inside `log.entries`. Similarly named unknown fields in the document + container remain opaque evidence; explicit sensitive-value declarations still + apply to both scopes. Mark sensitive literal string/UTF-8 byte values with MCP `sensitive_values`, or repeat CLI `--sensitive-value`. These declarations are never persisted in diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index 3f26b2b106..055879df0f 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -1,9 +1,70 @@ import { createHash } from "node:crypto"; import { expect, it } from "vitest"; -import { historicalHar } from "../../../tests/fixtures/historicalHar.js"; +import { + historicalHar, + harWithContainerLookalikes, +} from "../../../tests/fixtures/historicalHar.js"; import { decodeCaptureText } from "./CaptureJson.js"; import { decodeHarCapture } from "./HarCapture.js"; +it("interprets HTTP fields only within HAR entries, preserving document lookalikes", () => { + const fixture = harWithContainerLookalikes(); + const decoded = decodeHarCapture(JSON.stringify(fixture), []); + expect(decoded.container.reported).toMatchObject({ + request: fixture.request, + response: fixture.response, + }); + expect(decoded.container.redactions).toEqual([]); + expect(decoded.records[0]).toMatchObject({ + reported: { + request: { + url: "https://example.test/opaque", + headers: [{ name: "Authorization", value: null }], + cookies: [{ name: "session", value: null }], + }, + response: { + redirectURL: "https://example.test/redirect", + headers: [{ name: "Set-Cookie", value: null }], + }, + }, + binary_fields: [ + { pointer: "/response/content/text", content_base64: "QUJD", bytes: 3 }, + ], + }); + const marked = decodeHarCapture(JSON.stringify(fixture), [ + "ordinary-root-evidence", + ]); + expect(marked.container.reported).toMatchObject({ + request: { + headers: [{ value: null }], + cookies: [{ value: null }], + }, + response: { headers: [{ value: null }], content: { text: null } }, + }); + expect(marked.container.redactions).toHaveLength(4); + expect( + marked.container.redactions.every( + ({ reason }) => reason === "explicit-sensitive-value", + ), + ).toBe(true); +}); + +it("does not decode container lookalikes when excluding explicitly sensitive body bytes", () => { + const fixture = harWithContainerLookalikes(); + fixture.response.content.text = "QUJD"; + const decoded = decodeHarCapture(JSON.stringify(fixture), ["ABC"]); + expect(decoded.container.reported).toMatchObject({ + response: { content: { text: "QUJD" } }, + }); + expect(decoded.container.redactions).toEqual([]); + expect(decoded.records[0]).toMatchObject({ + reported: { response: { content: { text: null } } }, + binary_fields: [ + { state: "redacted", content_base64: null, bytes: null, sha256: null }, + ], + }); +}); + it.each([ { values: [] }, { values: ["response"] }, diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index 80f3e59d8a..a0dd0cfc4f 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -32,7 +32,7 @@ export const decodeHarCapture = ( sensitiveValues: readonly string[], ): DecodedCapture => { const raw = parseCaptureJson(text, "HAR"); - const validation = projectHar(raw, [], false); + const validation = projectHar(raw, [], "validation"); const adaptedPlaywrightEntries = new Set( createHarValidator()(validation.reported), ); @@ -55,11 +55,11 @@ export const decodeHarCapture = ( const container = projectHar( { ...raw, log: { ...raw.log, entries: null } }, sensitiveValues, - true, + "container", ); const records = entries.map((entry, ordinal): WebNetworkCaptureRecord => { // Validate each original record even when a structural ancestor excludes it. - const projected = projectHar(entry, sensitiveValues, true); + const projected = projectHar(entry, sensitiveValues, "entry"); const projection: Projection = recordsExcluded ? { reported: null, @@ -102,12 +102,13 @@ export const decodeHarCapture = ( }; }; -/** Evidence projection when `redact` is true; the upstream schema validation copy otherwise. */ +/** Relative HTTP paths belong only to entries, never similarly named container extensions. */ const projectHar = ( value: unknown, sensitiveValues: readonly string[], - redact: boolean, + scope: "validation" | "container" | "entry", ): Projection => { + const redact = scope !== "validation"; const redactor = new CaptureRedaction(sensitiveValues); const numeric_literals: Projection["numeric_literals"] = []; const binary_fields: Projection["binary_fields"] = []; @@ -136,7 +137,7 @@ const projectHar = ( pointer, ); if ( - redact && + scope === "entry" && parent !== undefined && redactor.harCredential(pointer, parent) ) @@ -144,7 +145,7 @@ const projectHar = ( if (item instanceof LosslessNumber) return projectNumber(item, pointer); if (typeof item === "string") { if ( - redact && + scope === "entry" && pointer === "/response/content/text" && parent?.encoding === "base64" ) { @@ -191,7 +192,7 @@ const projectHar = ( ? redactor.text( item, pointer, - redactor.harTransportUrl(pointer, parent), + scope === "entry" && redactor.harTransportUrl(pointer, parent), ) : item; } diff --git a/tests/boundary/browser/historicalWebCapture.test.ts b/tests/boundary/browser/historicalWebCapture.test.ts index ee0b9d14cb..9c3bebbdc5 100644 --- a/tests/boundary/browser/historicalWebCapture.test.ts +++ b/tests/boundary/browser/historicalWebCapture.test.ts @@ -42,7 +42,68 @@ const cli = async ( throw cause; } }; -import { historicalHar } from "../../fixtures/historicalHar.js"; +import { + historicalHar, + harWithContainerLookalikes, +} from "../../fixtures/historicalHar.js"; + +it("preserves opaque HAR container fields through CLI and MCP", async () => { + const root = await createTestTempDirectory("rea-har-container-fields-"); + const path = join(root, "capture.har"); + const fixture = harWithContainerLookalikes(); + const original = JSON.stringify(fixture); + await writeFile(path, original); + const { call } = await connectLocalToolsMcp(); + for (const marked of [false, true]) { + const sensitive = marked ? ["ordinary-root-evidence"] : []; + const fromCli = await cli( + path, + "har", + marked ? ["--sensitive-value", sensitive[0]!] : [], + ); + expect(fromCli.ok, fromCli.stdout).toBe(true); + const cliEvidence = parseEvidence(JSON.parse(fromCli.stdout)); + const response = await call("inspect_web_network_capture", { + capture_path: path, + format: "har", + sensitive_values: sensitive, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence.evidence_id).toBe(cliEvidence.evidence_id); + expect(evidence.normalized_result).toEqual(cliEvidence.normalized_result); + const capture = webNetworkCaptureSchema.parse(evidence.normalized_result); + expect(capture.container.reported).toMatchObject({ + request: { + url: fixture.request.url, + headers: [{ value: marked ? null : "ordinary-root-evidence" }], + cookies: [{ value: marked ? null : "ordinary-root-evidence" }], + }, + response: { + redirectURL: fixture.response.redirectURL, + headers: [{ value: marked ? null : "ordinary-root-evidence" }], + content: { text: marked ? null : "ordinary-root-evidence" }, + }, + }); + expect(capture.records[0]).toMatchObject({ + reported: { + request: { + url: "https://example.test/opaque", + headers: [{ value: null }], + cookies: [{ value: null }], + }, + response: { + redirectURL: "https://example.test/redirect", + headers: [{ value: null }], + }, + }, + binary_fields: [ + { pointer: "/response/content/text", content_base64: "QUJD", bytes: 3 }, + ], + }); + } + expect(await readFile(path, "utf8")).toBe(original); +}); it.each([ { encoded: "1e3", literal: "1000" }, diff --git a/tests/fixtures/historicalHar.ts b/tests/fixtures/historicalHar.ts index a7ae76bd95..656485786d 100644 --- a/tests/fixtures/historicalHar.ts +++ b/tests/fixtures/historicalHar.ts @@ -35,6 +35,46 @@ export const historicalHar = () => ({ }, }); +/** Unknown document fields may resemble entry fields without owning HTTP semantics. */ +export const harWithContainerLookalikes = () => { + const fixture = historicalHar(); + const entry = fixture.log.entries[0]!; + const request = { + ...entry.request, + url: "https://user:password@example.test/opaque", + headers: [{ name: "Authorization", value: "ordinary-root-evidence" }], + cookies: [{ name: "session", value: "ordinary-root-evidence" }], + }; + const response = { + ...entry.response, + redirectURL: "https://user:password@example.test/redirect", + headers: [{ name: "Set-Cookie", value: "ordinary-root-evidence" }], + content: { + ...entry.response.content, + encoding: "base64", + text: "ordinary-root-evidence", + }, + }; + return { + ...fixture, + request, + response, + log: { + ...fixture.log, + entries: [ + { + ...entry, + request, + response: { + ...response, + content: { ...response.content, text: "QUJD" }, + }, + }, + ], + }, + }; +}; + /** Request paths exercising only the public API surface format rules. */ export const apiSurfaceHar = () => { const fixture = historicalHar();