From b4bc6cf3ee49af6a1cfd1244fddbdfcfdc13c92e Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:30:06 +0800 Subject: [PATCH] fix(captures): scope HAR HTTP interpretation to entries (#4351) * fix(captures): scope HAR HTTP interpretation to entries * test(snapshot): preserve Evidence-scoped native workflow unknowns * test(process): assert owned snapshot cancellation outcomes * test(process): preserve snapshot race causes and owned cleanup * test(cli): scope runtime cleanup provider injection CLI boundary tests share a module graph. The cleanup fixture's module mock cannot replace a provider factory already captured by the real command registration, leaving its close count at zero after another test imports it. Inject a typed provider factory into the registration instance, defaulting to the existing production factory. Both commands still create their own provider at execution time. Remove the redundant runtime context wrapper and the global mock state. Keep the real child CLI parity case and verify that neither command completion nor JSON output precedes awaited cleanup; retain the close-count, cleanup error and completed Evidence assertions. The unchanged test passes alone but fails with the command module preloaded. Both modes pass after the correction. Seven related files pass 28 tests; check and docs:check also pass on Node24.18.0/npm11.16.0 macOS arm64. * test(artifacts): use valid ASAR bytes for signature control * test(mcp): isolate snapshot filesystem fault injection * test(process): distinguish unacquired resources in failure progress * chore: restore main's copies of files the merge hook reformatted The pre-commit oxlint --fix touched two files this PR does not change; keep them identical to main. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- docs/web-network-captures.md | 4 ++ src/browser/history/HarCapture.test.ts | 63 ++++++++++++++++++- src/browser/history/HarCapture.ts | 17 ++--- .../browser/historicalWebCapture.test.ts | 63 ++++++++++++++++++- tests/fixtures/historicalHar.ts | 40 ++++++++++++ 5 files changed, 177 insertions(+), 10 deletions(-) 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();