From f0069cbee4551e7988cfc4865ccf03dda48dc0dd Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:13:57 +0000 Subject: [PATCH] fix(browser): require storage fingerprint approval --- docs/browser-observation.md | 9 +++++---- scripts/verify-real-browser.mjs | 1 + src/application/BrowserEvidence.ts | 1 + src/browser/CdpCaptureStorage.ts | 2 +- src/cliBrowserCommands.ts | 1 + src/cliObservationOptions.ts | 4 ++++ src/contracts/browserToolContracts.ts | 1 + src/domain/browserObservation.ts | 7 +++++++ tests/browserObservation.test.ts | 1 + tests/cdpBrowserProvider.test.ts | 2 ++ tests/webCaptureDiff.test.ts | 2 ++ 11 files changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 5bd0361709..3823305f33 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -92,11 +92,11 @@ rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \ --include-json-body-shapes --json-body-schema-approved \ --include-websocket-shapes --websocket-shape-approved \ --include-script-sources \ - --include-storage-keys \ + --include-storage-keys --include-storage-fingerprints \ --json ``` -Accessibility text and script content may contain application secrets and become part of the returned Evidence. Accessibility and console text have independent per-field and aggregate UTF-8 byte limits; credential-shaped console substrings are redacted. JSON and WebSocket captures retain paths, types, counts, and truncation only, never values or examples. Storage values remain redacted even when key-name capture is approved; SHA-256 identity/value fingerprints support comparison only when cookies, DOM storage, IndexedDB, and Cache Storage were all captured completely. +Accessibility text and script content may contain application secrets and become part of the returned Evidence. Accessibility and console text have independent per-field and aggregate UTF-8 byte limits; credential-shaped console substrings are redacted. JSON and WebSocket captures retain paths, types, counts, and truncation only, never values or examples. Storage values remain redacted even when key-name capture is approved. `--include-storage-fingerprints` separately approves SHA-256 identity/value fingerprints, which support comparison only when cookies, DOM storage, IndexedDB, and Cache Storage were all captured completely. Screenshot pixels require both `--approved` and `--screenshot-approved`: @@ -122,7 +122,8 @@ rea capture-web-screenshot http://127.0.0.1:9222 TARGET_ID \ "include_websocket_shapes": false, "websocket_shape_approved": false, "include_script_sources": false, - "include_storage_keys": false + "include_storage_keys": false, + "include_storage_fingerprints": false } ``` @@ -141,7 +142,7 @@ REA removes sensitive values before normalized event data is retained: - Request/response bodies are not requested or parsed by default. With independent approval, only allowed-origin JSON media types are read within per-body and aggregate budgets, converted immediately to value-free property paths/types, and discarded. `Network.getResponseBody` is never sent without that approval. - Console observations with an approved stack source retain call type, argument types, timestamp, and redacted source location. With independent approval, only already-delivered primitive values are retained after credential redaction and byte bounds; objects, getters, and remote properties are never expanded. - WebSocket observations retain direction, opcode, and payload byte length. With independent approval, bounded text frames are classified as text or value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained. -- Storage observations always redact values. Key names, IndexedDB names, cache names, and stable content fingerprints are omitted unless explicitly requested. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. +- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` approval. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. - Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Source content is omitted unless explicitly requested and becomes a self-verifying `rea://web-content/sha256/...` artifact subject to per-script and aggregate byte limits. Cross-origin frames, resources, scripts, events, and workers are excluded unless their exact origins are also approved. Excluded target details are counted without being exposed. diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index b92fbacb80..d29dfc5aaa 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -91,6 +91,7 @@ try { target_id: target, observation_ms: 1_000, include_storage_keys: true, + include_storage_fingerprints: true, }), ); if (!observed.ok) throw observed.error; diff --git a/src/application/BrowserEvidence.ts b/src/application/BrowserEvidence.ts index 2c2d0f9dfb..159a05ac02 100644 --- a/src/application/BrowserEvidence.ts +++ b/src/application/BrowserEvidence.ts @@ -164,6 +164,7 @@ const browserParameters = ( include_websocket_shapes: input.include_websocket_shapes, include_script_sources: input.include_script_sources, include_storage_keys: input.include_storage_keys, + include_storage_fingerprints: input.include_storage_fingerprints, limits: input.limits, ...(input.include_script_sources && "source_capture_approved" in input ? { diff --git a/src/browser/CdpCaptureStorage.ts b/src/browser/CdpCaptureStorage.ts index 69e3df76e0..7b3905a9bf 100644 --- a/src/browser/CdpCaptureStorage.ts +++ b/src/browser/CdpCaptureStorage.ts @@ -71,7 +71,7 @@ export const captureStorage = async ( : [{ name: name.slice(0, 1_024), id } satisfies CapturedCache]; }); const maximum = context.input.limits.max_storage_keys; - const fingerprints = context.input.include_storage_keys + const fingerprints = context.input.include_storage_fingerprints ? await captureStorageFingerprints({ context, origin, diff --git a/src/cliBrowserCommands.ts b/src/cliBrowserCommands.ts index 3d816d460f..69c168a210 100644 --- a/src/cliBrowserCommands.ts +++ b/src/cliBrowserCommands.ts @@ -134,6 +134,7 @@ const registerPageInspection = ( websocket_shape_approved: options.websocketShapeApproved, include_script_sources: options.includeScriptSources, include_storage_keys: options.includeStorageKeys, + include_storage_fingerprints: options.includeStorageFingerprints, limits: { max_frames: options.maxFrames, max_dom_nodes: options.maxDomNodes, diff --git a/src/cliObservationOptions.ts b/src/cliObservationOptions.ts index 99840900ed..a1aa7e9a34 100644 --- a/src/cliObservationOptions.ts +++ b/src/cliObservationOptions.ts @@ -74,6 +74,10 @@ export const browserPageInspectionOptions = z.object({ .boolean() .default(false) .describe("Include storage key names without values"), + includeStorageFingerprints: z + .boolean() + .default(false) + .describe("Include content-derived storage fingerprints"), maxFrames: boundedCount("page frames", 1_000, 200), maxDomNodes: boundedCount("DOM nodes", 10_000, 2_000), maxAxNodes: boundedCount("accessibility nodes", 10_000, 2_000), diff --git a/src/contracts/browserToolContracts.ts b/src/contracts/browserToolContracts.ts index 7f2172973a..55fb44bbe1 100644 --- a/src/contracts/browserToolContracts.ts +++ b/src/contracts/browserToolContracts.ts @@ -164,6 +164,7 @@ export const BROWSER_TOOL_CONTRACTS = [ websocket_shape_approved: false, include_script_sources: false, include_storage_keys: false, + include_storage_fingerprints: false, limits: { max_frames: 200, max_dom_nodes: 2_000, diff --git a/src/domain/browserObservation.ts b/src/domain/browserObservation.ts index bf79c33f4f..406dd86f67 100644 --- a/src/domain/browserObservation.ts +++ b/src/domain/browserObservation.ts @@ -222,11 +222,18 @@ export const inspectWebPageInputSchema = z websocket_shape_approved: z.boolean().default(false), include_script_sources: z.boolean().default(false), include_storage_keys: z.boolean().default(false), + include_storage_fingerprints: z.boolean().default(false), limits: browserInspectionLimitsSchema.default( DEFAULT_BROWSER_INSPECTION_LIMITS, ), }) .superRefine((input, context) => { + if (input.include_storage_fingerprints && !input.include_storage_keys) + context.addIssue({ + code: "custom", + path: ["include_storage_fingerprints"], + message: "Storage fingerprints require storage key capture", + }); if ( input.limits.max_script_source_bytes > input.limits.max_total_script_source_bytes diff --git a/tests/browserObservation.test.ts b/tests/browserObservation.test.ts index 226b87a6da..cf9aa85c8d 100644 --- a/tests/browserObservation.test.ts +++ b/tests/browserObservation.test.ts @@ -167,6 +167,7 @@ describe("browser observation contracts", () => { { include_console_text: true }, { include_json_body_shapes: true }, { include_websocket_shapes: true }, + { include_storage_fingerprints: true }, ]) expect( inspectWebPageInputSchema.safeParse({ ...base, ...input }).success, diff --git a/tests/cdpBrowserProvider.test.ts b/tests/cdpBrowserProvider.test.ts index 7570928d42..f4c5484850 100644 --- a/tests/cdpBrowserProvider.test.ts +++ b/tests/cdpBrowserProvider.test.ts @@ -257,6 +257,7 @@ describe("CdpBrowserProvider", () => { approved: true, observation_ms: 0, include_storage_keys: true, + include_storage_fingerprints: true, }), ); @@ -1262,6 +1263,7 @@ describe("CdpBrowserProvider", () => { expect(result.value.storage.session_storage_keys).toHaveLength(1); expect(result.value.storage.indexed_db_names).toHaveLength(1); expect(result.value.storage.cache_names).toHaveLength(1); + expect(result.value.storage.content_fingerprints).toEqual([]); }); it("degrades optional domains but propagates protocol and payload failures", async () => { diff --git a/tests/webCaptureDiff.test.ts b/tests/webCaptureDiff.test.ts index 116e68e15e..d3b110ae75 100644 --- a/tests/webCaptureDiff.test.ts +++ b/tests/webCaptureDiff.test.ts @@ -138,6 +138,7 @@ describe("web capture diff", () => { observation_ms: 0, include_accessibility_text: true, include_storage_keys: true, + include_storage_fingerprints: true, }), ); if (!captured.ok) throw captured.error; @@ -211,6 +212,7 @@ describe("web capture diff", () => { observation_ms: 0, include_accessibility_text: true, include_storage_keys: true, + include_storage_fingerprints: true, }), ); if (!captured.ok) throw captured.error;