From d1dcebce2c0820469ed63e33497a14258971b710 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:17:29 +0800 Subject: [PATCH] fix(evidence): preserve literal keys and response body ownership Retain exact empty and computed literal keys in JavaScript observations. Bind CDP response bodies to request occurrences, account for late responses, and validate the target after final progress callbacks. --- ...CdpCaptureEvents.redirectBodyShape.test.ts | 6 +- src/browser/CdpCaptureEvents.ts | 82 +++- src/browser/CdpPageCapture.ts | 32 +- .../electronStaticAnalysisIdentity.test.ts | 362 ++++++++++-------- .../electronStaticAnalysisValues.ts | 17 +- .../electronWrappedCallPaths.test.ts | 32 ++ .../javascriptStaticAnalysisHelpers.ts | 17 +- ...BrowserProvider.lifecycle_failures.test.ts | 30 ++ .../cdpBrowserProvider.network.test.ts | 203 ++++++++++ .../browser/cdpJsonShapeComparison.test.ts | 6 +- 10 files changed, 576 insertions(+), 211 deletions(-) diff --git a/src/browser/CdpCaptureEvents.redirectBodyShape.test.ts b/src/browser/CdpCaptureEvents.redirectBodyShape.test.ts index 6faa983683..26cb7593ee 100644 --- a/src/browser/CdpCaptureEvents.redirectBodyShape.test.ts +++ b/src/browser/CdpCaptureEvents.redirectBodyShape.test.ts @@ -39,7 +39,9 @@ it("preserves an already captured response shape without marking it unavailable" }, }, }); - events.ingestResponseBody("request-1", { + const candidate = events.responseBodyRequests()[0]; + expect(candidate).toBeDefined(); + events.ingestResponseBody(candidate!, { body: '{"response":true}', base64Encoded: false, }); @@ -62,7 +64,7 @@ it("preserves an already captured response shape without marking it unavailable" capturedShape, ); expect(events.network.get("request-1")?.body_shapes.status).toBe("included"); - expect(events.responseBodyRequestIds()).toEqual([]); + expect(events.responseBodyRequests()).toEqual([]); expect(events.completeness.snapshot().unavailable_sections).not.toContain( "json_body_shapes", ); diff --git a/src/browser/CdpCaptureEvents.ts b/src/browser/CdpCaptureEvents.ts index 547d31b3a4..b3f1ce41b8 100644 --- a/src/browser/CdpCaptureEvents.ts +++ b/src/browser/CdpCaptureEvents.ts @@ -7,11 +7,18 @@ import type { CdpCaptureEventsState } from "./CdpCaptureEventState.js"; import type { CapturedScript, NetworkState } from "./CdpCaptureEventTypes.js"; import * as handlers from "./CdpCaptureEventHandlers.js"; import { isJsonMediaType } from "./CdpCaptureEventHelpers.js"; -import { recordValue } from "./CdpCaptureValues.js"; +import { cdpStringValue, recordValue } from "./CdpCaptureValues.js"; + +export interface ResponseBodyCandidate { + readonly requestId: string; + readonly occurrence: number; +} /** Event accumulator that validates and normalizes payloads at ingestion. */ export class CdpCaptureEvents implements CdpCaptureEventsState { #scriptMetadataBudgetExclusionsRecorded = false; + readonly #networkRequestOccurrences = new Map(); + readonly #responseBodyExaminedOccurrences = new Map(); readonly scripts = new Map(); readonly scriptMetadataBudgetOmissionsById = new Map(); rejectedScriptMetadataBudgetCount = 0; @@ -64,6 +71,8 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { this.executionContextFrames.clear(); this.network.clear(); this.networkRequestTimestamps.clear(); + this.#networkRequestOccurrences.clear(); + this.#responseBodyExaminedOccurrences.clear(); this.malformedRedirectRequestIds.clear(); this.webSocketConnectionsById.clear(); this.console.length = 0; @@ -128,9 +137,18 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { case "Debugger.scriptParsed": handlers.handleScriptParsed(this, params); break; - case "Network.requestWillBeSent": + case "Network.requestWillBeSent": { + const requestId = cdpStringValue(params.requestId); + const previous = + requestId === undefined ? undefined : this.network.get(requestId); handlers.handleRequestWillBeSent(this, params); + if (requestId !== undefined && this.network.get(requestId) !== previous) + this.#networkRequestOccurrences.set( + requestId, + (this.#networkRequestOccurrences.get(requestId) ?? 0) + 1, + ); break; + } case "Network.responseReceived": handlers.handleResponseReceived(this, params); break; @@ -165,24 +183,68 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { : null; } - responseBodyRequestIds(): readonly string[] { + responseBodyRequests(): readonly ResponseBodyCandidate[] { if (!this.input.include_json_body_shapes) return []; return [...this.network.values()] .filter( (request) => !this.malformedRedirectRequestIds.has(request.request_id), ) .filter((request) => isJsonMediaType(request.mime_type)) - .map((request) => request.request_id); + .flatMap((request) => { + const occurrence = this.#networkRequestOccurrences.get( + request.request_id, + ); + return occurrence === undefined + ? [] + : [{ requestId: request.request_id, occurrence }]; + }); } - responseBodyUnavailable(requestId: string): void { - if (this.malformedRedirectRequestIds.has(requestId)) return; - bodyShapes.updateResponseBodyShape(this, requestId, null); + currentResponseBodyRequest( + candidate: ResponseBodyCandidate, + ): NetworkState | undefined { + if ( + candidate.occurrence !== + this.#networkRequestOccurrences.get(candidate.requestId) + ) + return undefined; + return this.network.get(candidate.requestId); + } + + responseBodyUnavailable(candidate: ResponseBodyCandidate): void { + if (this.currentResponseBodyRequest(candidate) === undefined) return; + this.#responseBodyExaminedOccurrences.set( + candidate.requestId, + candidate.occurrence, + ); + if (this.malformedRedirectRequestIds.has(candidate.requestId)) return; + bodyShapes.updateResponseBodyShape(this, candidate.requestId, null); this.completeness.unavailable("json_body_shapes"); } - ingestResponseBody(requestId: string, value: unknown): void { - if (this.malformedRedirectRequestIds.has(requestId)) return; - bodyShapes.ingestResponseBodyShape(this, requestId, value); + /** + * Mark eligible response bodies that arrived after the fetch pass was + * enumerated. + */ + settleUnexaminedResponseBodies(): void { + for (const candidate of this.responseBodyRequests()) { + const request = this.currentResponseBodyRequest(candidate); + if ( + request?.body_shapes.response === null && + this.#responseBodyExaminedOccurrences.get(candidate.requestId) !== + candidate.occurrence + ) + this.responseBodyUnavailable(candidate); + } + } + + ingestResponseBody(candidate: ResponseBodyCandidate, value: unknown): void { + if (this.currentResponseBodyRequest(candidate) === undefined) return; + this.#responseBodyExaminedOccurrences.set( + candidate.requestId, + candidate.occurrence, + ); + if (this.malformedRedirectRequestIds.has(candidate.requestId)) return; + bodyShapes.ingestResponseBodyShape(this, candidate.requestId, value); } } diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 4de472660a..0d97576b5e 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -153,6 +153,7 @@ const captureAuthorizedPage = async ( ); if (!input.include_storage_keys) state.events.completeness.exclude("storage_keys", "not_approved", null); + await report(context.progress, 3, "Normalizing browser evidence"); const completedFrameResult = await authorizedMainFrame({ connection: context.connection, sessionId: context.sessionId, @@ -165,7 +166,10 @@ const captureAuthorizedPage = async ( throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); if (state.events.navigationDuringCapture || completedUrl !== attachedUrl) throw new BrowserObservationError("inspect_web_page", "target_changed"); - await report(context.progress, 3, "Normalizing browser evidence"); + // Body IDs were enumerated before asynchronous CDP reads. Events remain + // enabled through the final capture, so account for eligible responses that + // arrived while those reads were pending. + state.events.settleUnexaminedResponseBodies(); state.events.recordScriptMetadataBudgetExclusions(); return { inspection: normalizedInspection(state, { @@ -185,14 +189,14 @@ const captureAuthorizedPage = async ( const captureJsonResponseBodies = async ( state: CaptureState, ): Promise => { - const requestIds = state.events.responseBodyRequestIds(); - for (let index = 0; index < requestIds.length; index += 1) { - const requestId = requestIds[index]; - if (requestId === undefined) continue; - const request = state.events.network.get(requestId); - if (request === undefined) continue; - if (request.encoded_data_length === null) { - state.events.responseBodyUnavailable(requestId); + const requests = state.events.responseBodyRequests(); + for (let index = 0; index < requests.length; index += 1) { + const candidate = requests[index]; + if (candidate === undefined) continue; + const current = state.events.currentResponseBodyRequest(candidate); + if (current === undefined) continue; + if (current.encoded_data_length === null) { + state.events.responseBodyUnavailable(candidate); continue; } let result: unknown; @@ -200,7 +204,7 @@ const captureJsonResponseBodies = async ( result = await optionalCdpCommand( state.context, "Network.getResponseBody", - { requestId }, + { requestId: candidate.requestId }, state.limitations, ); } catch (cause: unknown) { @@ -211,17 +215,17 @@ const captureJsonResponseBodies = async ( cause.reportedMessage === null ) throw cause; - state.events.responseBodyUnavailable(requestId); + state.events.responseBodyUnavailable(candidate); state.limitations.push( - `Response body unavailable for request ${requestId}: ${cause.userMessage}`, + `Response body unavailable for request ${candidate.requestId}: ${cause.userMessage}`, ); continue; } if (result !== undefined) { - state.events.ingestResponseBody(requestId, result); + state.events.ingestResponseBody(candidate, result); continue; } - for (const remaining of requestIds.slice(index)) + for (const remaining of requests.slice(index)) state.events.responseBodyUnavailable(remaining); return; } diff --git a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts index 0005a19772..527b0b4631 100644 --- a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts +++ b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts @@ -1,172 +1,200 @@ -import { describe, expect, it } from "vitest"; +import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; -describe("Electron API identity", () => { - it("recovers APIs through an ES module default import of Electron", () => { - const analysis = analyzeJavaScriptStaticSource(` - import electron from "electron"; - const { ipcMain } = electron; - new electron.BrowserWindow({ webPreferences: { preload: "./preload.js" } }); - electron.contextBridge.exposeInMainWorld("api", { ping: true }); - electron.ipcRenderer.invoke("default:ping"); - ipcMain.handle("destructured:ping", () => "pong"); - electron.default.ipcMain.handle("nested-default", () => "local"); - `); +it("recovers APIs through an ES module default import of Electron", () => { + const analysis = analyzeJavaScriptStaticSource(` + import electron from "electron"; + const { ipcMain } = electron; + new electron.BrowserWindow({ webPreferences: { preload: "./preload.js" } }); + electron.contextBridge.exposeInMainWorld("api", { ping: true }); + electron.ipcRenderer.invoke("default:ping"); + ipcMain.handle("destructured:ping", () => "pong"); + electron.default.ipcMain.handle("nested-default", () => "local"); + `); - expect(analysis.electron.browser_windows).toMatchObject([ - { preload_path: "./preload.js" }, - ]); - expect(analysis.electron.context_bridge_apis).toMatchObject([ - { api_key: "api", members: ["ping"] }, - ]); - expect( - analysis.electron.ipc.map(({ side, operation, channel }) => ({ - side, - operation, - channel, - })), - ).toEqual([ - { side: "renderer", operation: "invoke", channel: "default:ping" }, - { side: "main", operation: "handle", channel: "destructured:ping" }, - ]); - }); - - it("recovers APIs and sender checks written with optional chaining", () => { - const analysis = analyzeJavaScriptStaticSource(` - const { contextBridge, ipcMain, utilityProcess } = require("electron"); - contextBridge?.exposeInMainWorld("api", {}); - utilityProcess?.fork("./worker.js"); - ipcMain?.handle("optional:ping", (event) => { - if (!event.senderFrame?.url.startsWith("file://")) return null; - return event.sender?.getURL() === "file:///index.html"; - }); - `); - - expect(analysis.electron.context_bridge_apis).toMatchObject([ - { api_key: "api" }, - ]); - expect(analysis.electron.utility_processes).toMatchObject([ - { module_path: "./worker.js" }, - ]); - expect(analysis.electron.ipc).toMatchObject([ - { side: "main", operation: "handle", channel: "optional:ping" }, - ]); - expect( - analysis.electron.sender_validations.map( - ({ subject, mechanism, expected }) => ({ - subject, - mechanism, - expected: expected.value, - }), - ), - ).toEqual([ - { - subject: "sender-url", - mechanism: "call:startsWith", - expected: "file://", - }, - { - subject: "sender-url", - mechanism: "comparison:===", - expected: "file:///index.html", - }, - ]); - }); - - it("follows bundler namespace interop helpers without accepting other writes", () => { - const analysis = analyzeJavaScriptStaticSource(` - var __toESM = (mod) => mod; - var __importStar = (mod) => mod; - let electron = require("electron"); - electron = __toESM(electron); - new electron.BrowserWindow({ webPreferences: { preload: "./rolldown.js" } }); - var import_electron = __toESM(require("electron")); - new import_electron.BrowserWindow({ webPreferences: { preload: "./esbuild.js" } }); - function _interopRequireWildcard(mod) { return mod; } - const Electron = __importStar(require("electron")); - new Electron.BrowserWindow({ webPreferences: { preload: "./tsc.js" } }); - - let replaced = require("electron"); - replaced = __toESM(require("./fake")); - new replaced.BrowserWindow({ webPreferences: { preload: "./replaced.js" } }); - let rewritten = require("electron"); - rewritten = __toESM(rewritten); - rewritten = fake; - new rewritten.BrowserWindow({ webPreferences: { preload: "./rewritten.js" } }); - const babel = _interopRequireWildcard(require("electron")); - new babel.BrowserWindow({ webPreferences: { preload: "./babel.js" } }); - function nested() { - const __toESM = (mod) => mod; - const scoped = __toESM(require("electron")); - new scoped.BrowserWindow({ webPreferences: { preload: "./nested.js" } }); - } - `); - - expect( - analysis.electron.browser_windows.map(({ preload_path }) => preload_path), - ).toEqual(["./rolldown.js", "./esbuild.js", "./tsc.js", "./babel.js"]); - }); - - it("does not trust unbound helper names or non-function helper values", () => { - const analysis = analyzeJavaScriptStaticSource(` - const __toESM = makeLookalike(); - const electron = __toESM(require("electron")); - new electron.BrowserWindow({}); - let unbound = require("electron"); - unbound = __importStar(unbound); - new unbound.BrowserWindow({}); - `); - - expect(analysis.electron.browser_windows).toEqual([]); - }); - - it("follows stable declaration aliases but not an assigned default parameter", () => { - const analysis = analyzeJavaScriptStaticSource(` - const direct = require("electron"); - const constAlias = direct; - new constAlias.BrowserWindow({webPreferences:{preload:"./const.js"}}); - - let letAlias = require("electron"); - new letAlias.BrowserWindow({webPreferences:{preload:"./let.js"}}); - - const { BrowserWindow } = require("electron"); - new BrowserWindow({webPreferences:{preload:"./destructured.js"}}); - - function withDefault(electron = require("./not-electron")) { - if (flag) electron = require("electron"); - new electron.BrowserWindow({webPreferences:{preload:"./default.js"}}); - } - `); - - expect( - analysis.electron.browser_windows.map(({ preload_path }) => preload_path), - ).toEqual(["./const.js", "./let.js", "./destructured.js"]); - }); - - it("preserves one initializer across variable redeclarations without accepting writes", () => { - const analysis = analyzeJavaScriptStaticSource(` - var electron; - var electron = require("electron"); - var electron; - new electron.BrowserWindow({webPreferences:{preload:"./redeclared.js"}}); - - var replaced; - var replaced = require("electron"); - replaced = fake; - new replaced.BrowserWindow({webPreferences:{preload:"./replaced.js"}}); - - var initializedTwice = require("electron"); - var initializedTwice = fake; - new initializedTwice.BrowserWindow({}); - - var conditional; - if (flag) { var conditional = require("electron"); } - new conditional.BrowserWindow({}); - `); - - expect( - analysis.electron.browser_windows.map(({ preload_path }) => preload_path), - ).toEqual(["./redeclared.js"]); - }); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "./preload.js" }, + ]); + expect(analysis.electron.context_bridge_apis).toMatchObject([ + { api_key: "api", members: ["ping"] }, + ]); + expect( + analysis.electron.ipc.map(({ side, operation, channel }) => ({ + side, + operation, + channel, + })), + ).toEqual([ + { side: "renderer", operation: "invoke", channel: "default:ping" }, + { side: "main", operation: "handle", channel: "destructured:ping" }, + ]); +}); + +it("recovers APIs and sender checks written with optional chaining", () => { + const analysis = analyzeJavaScriptStaticSource(` + const { contextBridge, ipcMain, utilityProcess } = require("electron"); + contextBridge?.exposeInMainWorld("api", {}); + utilityProcess?.fork("./worker.js"); + ipcMain?.handle("optional:ping", (event) => { + if (!event.senderFrame?.url.startsWith("file://")) return null; + return event.sender?.getURL() === "file:///index.html"; + }); + `); + + expect(analysis.electron.context_bridge_apis).toMatchObject([ + { api_key: "api" }, + ]); + expect(analysis.electron.utility_processes).toMatchObject([ + { module_path: "./worker.js" }, + ]); + expect(analysis.electron.ipc).toMatchObject([ + { side: "main", operation: "handle", channel: "optional:ping" }, + ]); + expect( + analysis.electron.sender_validations.map( + ({ subject, mechanism, expected }) => ({ + subject, + mechanism, + expected: expected.value, + }), + ), + ).toEqual([ + { + subject: "sender-url", + mechanism: "call:startsWith", + expected: "file://", + }, + { + subject: "sender-url", + mechanism: "comparison:===", + expected: "file:///index.html", + }, + ]); +}); + +it("preserves empty and computed literal contextBridge member keys", () => { + const analysis = analyzeJavaScriptStaticSource(` + const { contextBridge } = require("electron"); + const dynamicKey = getKey(); + contextBridge.exposeInMainWorld("api", { + "": { nested: { "": true } }, + ["computed"]: true, + }); + contextBridge.exposeInMainWorld("dynamic", { + [dynamicKey]: true, + }); + `); + + const finding = analysis.electron.context_bridge_apis.find( + ({ api_key }) => api_key === "api", + ); + expect(finding).toMatchObject({ + api_status: "object-literal", + members: ["", ".nested", ".nested.", "computed"], + unknown_members: 0, + }); + expect(finding?.resolution_basis).toBeUndefined(); + expect(analysis.electron.context_bridge_apis).toContainEqual( + expect.objectContaining({ + api_key: "dynamic", + unknown_members: 1, + }), + ); +}); + +it("follows bundler namespace interop helpers without accepting other writes", () => { + const analysis = analyzeJavaScriptStaticSource(` + var __toESM = (mod) => mod; + var __importStar = (mod) => mod; + let electron = require("electron"); + electron = __toESM(electron); + new electron.BrowserWindow({ webPreferences: { preload: "./rolldown.js" } }); + var import_electron = __toESM(require("electron")); + new import_electron.BrowserWindow({ webPreferences: { preload: "./esbuild.js" } }); + function _interopRequireWildcard(mod) { return mod; } + const Electron = __importStar(require("electron")); + new Electron.BrowserWindow({ webPreferences: { preload: "./tsc.js" } }); + + let replaced = require("electron"); + replaced = __toESM(require("./fake")); + new replaced.BrowserWindow({ webPreferences: { preload: "./replaced.js" } }); + let rewritten = require("electron"); + rewritten = __toESM(rewritten); + rewritten = fake; + new rewritten.BrowserWindow({ webPreferences: { preload: "./rewritten.js" } }); + const babel = _interopRequireWildcard(require("electron")); + new babel.BrowserWindow({ webPreferences: { preload: "./babel.js" } }); + function nested() { + const __toESM = (mod) => mod; + const scoped = __toESM(require("electron")); + new scoped.BrowserWindow({ webPreferences: { preload: "./nested.js" } }); + } + `); + + expect( + analysis.electron.browser_windows.map(({ preload_path }) => preload_path), + ).toEqual(["./rolldown.js", "./esbuild.js", "./tsc.js", "./babel.js"]); +}); + +it("does not trust unbound helper names or non-function helper values", () => { + const analysis = analyzeJavaScriptStaticSource(` + const __toESM = makeLookalike(); + const electron = __toESM(require("electron")); + new electron.BrowserWindow({}); + let unbound = require("electron"); + unbound = __importStar(unbound); + new unbound.BrowserWindow({}); + `); + + expect(analysis.electron.browser_windows).toEqual([]); +}); + +it("follows stable declaration aliases but not an assigned default parameter", () => { + const analysis = analyzeJavaScriptStaticSource(` + const direct = require("electron"); + const constAlias = direct; + new constAlias.BrowserWindow({webPreferences:{preload:"./const.js"}}); + + let letAlias = require("electron"); + new letAlias.BrowserWindow({webPreferences:{preload:"./let.js"}}); + + const { BrowserWindow } = require("electron"); + new BrowserWindow({webPreferences:{preload:"./destructured.js"}}); + + function withDefault(electron = require("./not-electron")) { + if (flag) electron = require("electron"); + new electron.BrowserWindow({webPreferences:{preload:"./default.js"}}); + } + `); + + expect( + analysis.electron.browser_windows.map(({ preload_path }) => preload_path), + ).toEqual(["./const.js", "./let.js", "./destructured.js"]); +}); + +it("preserves one initializer across variable redeclarations without accepting writes", () => { + const analysis = analyzeJavaScriptStaticSource(` + var electron; + var electron = require("electron"); + var electron; + new electron.BrowserWindow({webPreferences:{preload:"./redeclared.js"}}); + + var replaced; + var replaced = require("electron"); + replaced = fake; + new replaced.BrowserWindow({webPreferences:{preload:"./replaced.js"}}); + + var initializedTwice = require("electron"); + var initializedTwice = fake; + new initializedTwice.BrowserWindow({}); + + var conditional; + if (flag) { var conditional = require("electron"); } + new conditional.BrowserWindow({}); + `); + + expect( + analysis.electron.browser_windows.map(({ preload_path }) => preload_path), + ).toEqual(["./redeclared.js"]); }); diff --git a/src/domain/javascript/electronStaticAnalysisValues.ts b/src/domain/javascript/electronStaticAnalysisValues.ts index 1fb8d366dd..2fe9d5858f 100644 --- a/src/domain/javascript/electronStaticAnalysisValues.ts +++ b/src/domain/javascript/electronStaticAnalysisValues.ts @@ -4,6 +4,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import type { ElectronStaticValue } from "./electronStaticAnalysisTypes.js"; import { readExactJavaScriptLiteral, + semanticStaticPropertyKey, semanticStaticPropertyName, } from "./javascriptAstValues.js"; import { sourceSlice } from "./javascriptStaticAnalysisHelpers.js"; @@ -124,7 +125,7 @@ export const collectContextBridgeMembers = ( members: [], unknown: 0, }; - collectMembersAt(node, "", 0, state); + collectMembersAt(node, [], state); const members = [...new Set(state.members)].sort(compareUnicodeCodePoints); return { status: "object-literal", @@ -135,11 +136,10 @@ export const collectContextBridgeMembers = ( const collectMembersAt = ( object: t.ObjectExpression, - prefix: string, - depth: number, + prefix: readonly string[], state: { members: string[]; unknown: number }, ): void => { - if (depth >= 8) { + if (prefix.length >= 8) { state.unknown += 1; return; } @@ -148,15 +148,16 @@ const collectMembersAt = ( state.unknown += 1; continue; } - const name = semanticStaticPropertyName(property.key, property.computed); - if (name === "" || property.computed) { + const name = semanticStaticPropertyKey(property.key, property.computed); + if (name === null) { state.unknown += 1; continue; } - const path = prefix === "" ? name : `${prefix}.${name}`; + const segments = [...prefix, name]; + const path = segments.join("."); state.members.push(path); if (t.isObjectProperty(property) && t.isObjectExpression(property.value)) - collectMembersAt(property.value, path, depth + 1, state); + collectMembersAt(property.value, segments, state); } }; diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index b39ac7b691..47d61004a5 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -503,6 +503,38 @@ it("resolves renamed default and namespace import helpers from their syntax", () .every((step) => step.label.length > 0), ).toBe(true); }); +it("resolves an empty bootstrap module key through an exported Electron bridge", () => { + const result = analyzeJavaScriptStaticSource(`(() => { + const modules = { + ""(module) { module.exports = require("electron"); }, + 9(module, exports, load) { + const electron = load(""); + electron.contextBridge.exposeInMainWorld("desktop", { + copy: () => electron.ipcRenderer.send("test:empty-module"), + }); + }, + }; + function load(id) { + const module = { exports: {} }; + modules[id].call(module.exports, module, module.exports, load); + return module.exports; + } + load(9); + })();`); + expect(result.bundler_registrations).toMatchObject([ + { + entry_module_keys: ["9"], + modules: [{ module_key: "" }, { module_key: "9" }], + }, + ]); + expect(result.electron.call_paths).toContainEqual( + expect.objectContaining({ + entry: "desktop.copy", + channel: "test:empty-module", + terminal: "electron.ipcRenderer.send", + }), + ); +}); it("does not trust helper names or follow returns after a definite throw", () => { for (const helper of [ `function __toESM() {return {ipcRenderer:{send(){}}}}`, diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 8038edf44f..40ffe6e1f6 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -13,6 +13,7 @@ import type { } from "./javascriptStaticAnalysisTypes.js"; import { readExactJavaScriptLiteral, + semanticStaticPropertyKey, semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -131,15 +132,15 @@ export const moduleFactory = ( /** Derive a literal or explicitly computed module key. */ export const modulePropertyName = ( property: t.ObjectMethod | t.ObjectProperty | t.SpreadElement, -): string => - t.isObjectMethod(property) || t.isObjectProperty(property) - ? property.computed && - !t.isStringLiteral(property.key) && - !t.isNumericLiteral(property.key) - ? `[computed@${String(property.start ?? -1)}]` - : semanticStaticPropertyName(property.key, property.computed) || - `[unknown@${String(property.start ?? -1)}]` +): string => { + if (!t.isObjectMethod(property) && !t.isObjectProperty(property)) + return `[unknown@${String(property.start ?? -1)}]`; + const key = semanticStaticPropertyKey(property.key, property.computed); + if (key !== null) return key; + return property.computed + ? `[computed@${String(property.start ?? -1)}]` : `[unknown@${String(property.start ?? -1)}]`; +}; /** Read the factory-local bundler require parameter when declared. */ export const factoryRequireName = ( diff --git a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts index 3109e9d003..7c0d07445e 100644 --- a/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.lifecycle_failures.test.ts @@ -14,6 +14,36 @@ import { import { trackBrowser } from "./cdpBrowserProvider.support.js"; describe("CdpBrowserProvider: cancellation and session events", () => { + it("honors cancellation during the final progress callback before publishing inspection", async () => { + const browser = await startFakeCdpBrowser(); + trackBrowser(browser); + const controller = new AbortController(); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }), + { + signal: controller.signal, + progress: { + report(update) { + if (update.completed === 3) controller.abort(); + return Promise.resolve(); + }, + }, + }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "inspect_web_page", + }, + }); + }); + it("projects direct-session cancellation onto the requested operation", async () => { const browser = await startFakeCdpBrowser({ pageScopedVersionWebSocket: true, diff --git a/tests/boundary/browser/cdpBrowserProvider.network.test.ts b/tests/boundary/browser/cdpBrowserProvider.network.test.ts index 65ed14180e..a481af2cea 100644 --- a/tests/boundary/browser/cdpBrowserProvider.network.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.network.test.ts @@ -131,6 +131,203 @@ it("preserves unavailable response observations and captures completed peers", a ).toEqual(["missing-body", "available"]); }); +it("marks JSON response bodies unavailable when their events arrive during body capture", async () => { + const browser = await startFakeCdpBrowser({ + commandEvents(command, origin) { + const session = + command.sessionId === undefined ? {} : { sessionId: command.sessionId }; + if (command.method === "Network.enable") { + const url = `${origin}/initial.json`; + return [ + { + ...session, + method: "Network.requestWillBeSent", + params: { + requestId: "initial", + request: { url, method: "GET" }, + initiator: { type: "other" }, + }, + }, + { + ...session, + method: "Network.responseReceived", + params: { + requestId: "initial", + response: { url, status: 200, mimeType: "application/json" }, + }, + }, + { + ...session, + method: "Network.loadingFinished", + params: { requestId: "initial", encodedDataLength: 16 }, + }, + ]; + } + if (command.method !== "Network.getResponseBody") return undefined; + const url = `${origin}/late.json`; + return [ + { + ...session, + method: "Network.requestWillBeSent", + params: { + requestId: "late", + request: { url, method: "GET" }, + initiator: { type: "other" }, + }, + }, + { + ...session, + method: "Network.responseReceived", + params: { + requestId: "late", + response: { url, status: 200, mimeType: "application/json" }, + }, + }, + { + ...session, + method: "Network.loadingFinished", + params: { requestId: "late", encodedDataLength: 14 }, + }, + ]; + }, + commandResult(command) { + return command.method === "Network.getResponseBody" + ? { body: '{"initial":true}', base64Encoded: false } + : undefined; + }, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + include_json_body_shapes: true, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.network.requests).toEqual([ + expect.objectContaining({ + request_id: "initial", + body_shapes: { + status: "included", + request: null, + response: expect.any(Object), + }, + }), + expect.objectContaining({ + request_id: "late", + body_shapes: { status: "unavailable", request: null, response: null }, + }), + ]); + expect(result.value.completeness.unavailable_sections).toContain( + "json_body_shapes", + ); +}); + +it("does not attach a redirect response body to the reused request ID", async () => { + const browser = await startFakeCdpBrowser({ + commandEvents(command, origin) { + const session = + command.sessionId === undefined ? {} : { sessionId: command.sessionId }; + if (command.method === "Network.enable") { + const url = `${origin}/before-redirect.json`; + return [ + { + ...session, + method: "Network.requestWillBeSent", + params: { + requestId: "reused", + request: { url, method: "GET" }, + initiator: { type: "other" }, + }, + }, + { + ...session, + method: "Network.responseReceived", + params: { + requestId: "reused", + response: { url, status: 302, mimeType: "application/json" }, + }, + }, + { + ...session, + method: "Network.loadingFinished", + params: { requestId: "reused", encodedDataLength: 12 }, + }, + ]; + } + if (command.method !== "Network.getResponseBody") return undefined; + const previousUrl = `${origin}/before-redirect.json`; + const nextUrl = `${origin}/after-redirect.json`; + return [ + { + ...session, + method: "Network.requestWillBeSent", + params: { + requestId: "reused", + request: { url: nextUrl, method: "GET" }, + initiator: { type: "other" }, + redirectResponse: { + url: previousUrl, + status: 302, + mimeType: "application/json", + encodedDataLength: 12, + }, + }, + }, + { + ...session, + method: "Network.responseReceived", + params: { + requestId: "reused", + response: { + url: nextUrl, + status: 200, + mimeType: "application/json", + }, + }, + }, + { + ...session, + method: "Network.loadingFinished", + params: { requestId: "reused", encodedDataLength: 14 }, + }, + ]; + }, + commandResult(command) { + return command.method === "Network.getResponseBody" + ? { body: '{"belongs":"redirect"}', base64Encoded: false } + : undefined; + }, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + include_json_body_shapes: true, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.network.requests).toEqual([ + expect.objectContaining({ + request_id: "reused", + url: `${browser.allowedOrigin}/after-redirect.json`, + redirects: [expect.objectContaining({ status: 302 })], + body_shapes: { status: "unavailable", request: null, response: null }, + }), + ]); + expect(result.value.completeness.unavailable_sections).toContain( + "json_body_shapes", + ); +}); + it("marks malformed JSON body shapes unavailable while preserving partial request shapes", async () => { const browser = await startFakeCdpBrowser({ commandEvents(command, origin) { @@ -301,6 +498,12 @@ it("rejects malformed response-body envelopes and preserves request shapes", asy reason: "invalid_protocol_value", count: 2, }); + expect(result.value.completeness.excluded).not.toContainEqual( + expect.objectContaining({ + section: "json_body_shapes", + reason: "provider_unavailable", + }), + ); }); it.each([ diff --git a/tests/boundary/browser/cdpJsonShapeComparison.test.ts b/tests/boundary/browser/cdpJsonShapeComparison.test.ts index 900e21c670..677e8bd044 100644 --- a/tests/boundary/browser/cdpJsonShapeComparison.test.ts +++ b/tests/boundary/browser/cdpJsonShapeComparison.test.ts @@ -339,8 +339,10 @@ const captureBody = (source: BodySource, body: string): WebPageInspection => { encodedDataLength: Buffer.byteLength(responseBody), }, }); - expect(events.responseBodyRequestIds()).toEqual(["request-1"]); - events.ingestResponseBody("request-1", { + const candidate = events.responseBodyRequests()[0]; + expect(candidate?.requestId).toBe("request-1"); + expect(candidate).toBeDefined(); + events.ingestResponseBody(candidate!, { body: source === "base64 response" ? Buffer.from(responseBody).toString("base64")