From 1081f81484e9c8e9c8193101aa1731d64974a14c Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:51:32 +0800 Subject: [PATCH] fix(browser): preserve unknown worker target ownership Parse optional CDP worker relationships once for browser and Electron collectors. Keep absent ownership unavailable, malformed relationships invalid, and proven foreign targets outside the selected scope; retain existing origin and file authorization. Add public CLI protocol fixtures and documentation for coverage and withheld details. Fixes #6475 --- docs/browser-observation.md | 5 + docs/electron-observation.md | 5 + src/browser/CdpCaptureCompleteness.ts | 1 + src/browser/CdpElectronWorkers.ts | 28 ++- src/browser/CdpPageCaptureWorkers.test.ts | 46 +++++ src/browser/CdpPageCaptureWorkers.ts | 37 ++-- src/browser/CdpWorkerTargetOwnership.ts | 38 ++++ src/domain/browserCompleteness.ts | 1 + .../cli/browserWorkerOwnershipCli.test.ts | 183 ++++++++++++++++++ 9 files changed, 315 insertions(+), 29 deletions(-) create mode 100644 src/browser/CdpWorkerTargetOwnership.ts create mode 100644 tests/boundary/cli/browserWorkerOwnershipCli.test.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 9bd8527919..2fa32b417c 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -412,6 +412,11 @@ Frames, resources, scripts, events, and workers outside the selected origin scop Retained workers include validated opener-target and parent-frame IDs when CDP provides them; these relationships do not expand the exact-origin boundary or prove static module ownership. +Workers without either relationship are withheld with an `unattributed_target` +exclusion and `workers` in `unavailable_sections`: a same-origin URL does not +establish which page owns a worker. A supplied relationship to another target or +frame remains `out_of_target_scope`. Missing ownership also remains unknown when +the worker URL is empty; withheld target details are not returned. ## Completeness and limits diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 945661ac28..2528e9b6ff 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -87,6 +87,11 @@ target, frame, script, and resource, a worker URL must resolve to a local file before it is retained. Relationship IDs improve attribution but do not prove which static module started a worker or that its work completed. +Missing opener and parent relationships leave worker ownership unknown. Such +targets are withheld with an `unattributed_target` exclusion and unavailable +`workers` coverage, even when their URL names a local file. Explicit foreign +relationships remain `out_of_target_scope`; malformed supplied relationship IDs +are `invalid_protocol_value`. Inspection does not retain DOM values, execute renderer code, navigate, click, invoke Electron IPC, close a target, or terminate the application. diff --git a/src/browser/CdpCaptureCompleteness.ts b/src/browser/CdpCaptureCompleteness.ts index 4c53f835c8..0659835173 100644 --- a/src/browser/CdpCaptureCompleteness.ts +++ b/src/browser/CdpCaptureCompleteness.ts @@ -62,6 +62,7 @@ export class CdpCaptureCompleteness { if (POLICY_REASONS.has(reason)) this.#policyFiltered.add(section); if ( reason === "provider_unavailable" || + reason === "unattributed_target" || reason === "invalid_protocol_value" ) this.#unavailable.add(section); diff --git a/src/browser/CdpElectronWorkers.ts b/src/browser/CdpElectronWorkers.ts index f7acb95825..51a6b1e9e2 100644 --- a/src/browser/CdpElectronWorkers.ts +++ b/src/browser/CdpElectronWorkers.ts @@ -11,6 +11,7 @@ import { optionalCdpCommand } from "./CdpOptionalCommand.js"; import { authorizedElectronFile } from "./ElectronFileScope.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { workerTargetOwnership } from "./CdpWorkerTargetOwnership.js"; /** Inventory local worker targets without attaching to them. */ export const captureElectronWorkers = async (input: { @@ -53,13 +54,22 @@ export const captureElectronWorkers = async (input: { } const type = cdpStringValue(target.type) ?? ""; if (!type.includes("worker")) continue; - const openerTargetId = boundedTargetField(target.openerId); - const parentFrameId = boundedTargetField(target.parentFrameId); - if ( - openerTargetId !== input.target.id && - (parentFrameId === null || !input.frameIds.has(parentFrameId)) - ) { - input.completeness.exclude("workers", "out_of_target_scope"); + const ownership = workerTargetOwnership( + target, + input.target.id, + input.frameIds, + ); + if (!ownership.ok) { + input.completeness.exclude("workers", "invalid_protocol_value"); + continue; + } + if (ownership.value.scope !== "selected") { + input.completeness.exclude( + "workers", + ownership.value.scope === "unknown" + ? "unattributed_target" + : "out_of_target_scope", + ); continue; } const path = await authorizedElectronFile( @@ -86,8 +96,8 @@ export const captureElectronWorkers = async (input: { type, file_path: path, attached: target.attached, - opener_target_id: openerTargetId, - parent_frame_id: parentFrameId, + opener_target_id: ownership.value.openerTargetId, + parent_frame_id: ownership.value.parentFrameId, }); } return workers.sort((left, right) => diff --git a/src/browser/CdpPageCaptureWorkers.test.ts b/src/browser/CdpPageCaptureWorkers.test.ts index 77768ad997..bf67749b96 100644 --- a/src/browser/CdpPageCaptureWorkers.test.ts +++ b/src/browser/CdpPageCaptureWorkers.test.ts @@ -125,3 +125,49 @@ describe("web worker target inventory", () => { ); }); }); + +it.each([`${origin}/worker.js`, ""])( + "keeps an unattributed worker unavailable rather than policy filtered: %s", + async (url) => { + const result = await capture({ + targetInfos: [ + { targetId: "worker", type: "worker", url, attached: false }, + ], + }); + expect(result.workers).toEqual([]); + expect(result.completeness).toMatchObject({ + status: "attach_limited", + conditions: ["attach_limited"], + unavailable_sections: ["workers"], + policy_filtered_sections: [], + excluded: [ + { section: "workers", reason: "unattributed_target", count: 1 }, + ], + }); + }, +); + +it.each([{ openerId: "" }, { parentFrameId: null }])( + "retains invalid supplied relationship coverage: %j", + async (relationship) => { + const result = await capture({ + targetInfos: [ + { + targetId: "worker", + type: "worker", + url: `${origin}/worker.js`, + attached: false, + ...relationship, + }, + ], + }); + expect(result.workers).toEqual([]); + expect(result.completeness).toMatchObject({ + unavailable_sections: ["workers"], + policy_filtered_sections: [], + excluded: [ + { section: "workers", reason: "invalid_protocol_value", count: 1 }, + ], + }); + }, +); diff --git a/src/browser/CdpPageCaptureWorkers.ts b/src/browser/CdpPageCaptureWorkers.ts index 1ff14ad9d4..701e9cf7e9 100644 --- a/src/browser/CdpPageCaptureWorkers.ts +++ b/src/browser/CdpPageCaptureWorkers.ts @@ -9,6 +9,7 @@ import { } from "./CdpCaptureValues.js"; import type { CaptureContext } from "./CdpPageCapture.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; +import { workerTargetOwnership } from "./CdpWorkerTargetOwnership.js"; interface CaptureWorkersInput { readonly context: CaptureContext; @@ -59,29 +60,25 @@ export const captureWorkers = async ( malformedEntries += 1; continue; } - const openerId = - target.openerId === undefined - ? undefined - : cdpStringValue(target.openerId); - const parentFrameId = - target.parentFrameId === undefined - ? undefined - : cdpStringValue(target.parentFrameId); - if ( - (target.openerId !== undefined && openerId === undefined) || - (target.parentFrameId !== undefined && parentFrameId === undefined) - ) { + const ownership = workerTargetOwnership( + target, + context.target.id, + frameIds, + ); + if (!ownership.ok) { malformedEntries += 1; continue; } - const url = allowedSanitizedUrl(target.url, allowedOrigins); - const relatedToPage = - openerId === context.target.id || - (parentFrameId !== undefined && frameIds.has(parentFrameId)); - if (!relatedToPage) { - completeness.exclude("workers", "out_of_target_scope"); + if (ownership.value.scope !== "selected") { + completeness.exclude( + "workers", + ownership.value.scope === "unknown" + ? "unattributed_target" + : "out_of_target_scope", + ); continue; } + const url = allowedSanitizedUrl(target.url, allowedOrigins); if (url === undefined) { const rawUrl = cdpStringValue(target.url); completeness.exclude( @@ -100,8 +97,8 @@ export const captureWorkers = async ( url: url.url, origin: url.origin, attached: target.attached, - opener_target_id: openerId ?? null, - parent_frame_id: parentFrameId ?? null, + opener_target_id: ownership.value.openerTargetId, + parent_frame_id: ownership.value.parentFrameId, }); } if (malformedEntries > 0) diff --git a/src/browser/CdpWorkerTargetOwnership.ts b/src/browser/CdpWorkerTargetOwnership.ts new file mode 100644 index 0000000000..27611119c0 --- /dev/null +++ b/src/browser/CdpWorkerTargetOwnership.ts @@ -0,0 +1,38 @@ +import { err, ok, type Result } from "../domain/result.js"; +import { cdpStringValue } from "./CdpCaptureValues.js"; + +/** Optional CDP relationships establish ownership only when supplied. */ +export const workerTargetOwnership = ( + target: Readonly>, + selectedTargetId: string, + frameIds: ReadonlySet, +): Result< + { + readonly scope: "selected" | "foreign" | "unknown"; + readonly openerTargetId: string | null; + readonly parentFrameId: string | null; + }, + { readonly _tag: "InvalidWorkerRelationship" } +> => { + const openerTargetId = cdpStringValue(target.openerId); + const parentFrameId = cdpStringValue(target.parentFrameId); + if ( + (target.openerId !== undefined && + (openerTargetId === undefined || openerTargetId === "")) || + (target.parentFrameId !== undefined && + (parentFrameId === undefined || parentFrameId === "")) + ) + return err({ _tag: "InvalidWorkerRelationship" }); + const selected = + openerTargetId === selectedTargetId || + (parentFrameId !== undefined && frameIds.has(parentFrameId)); + return ok({ + scope: selected + ? "selected" + : openerTargetId === undefined && parentFrameId === undefined + ? "unknown" + : "foreign", + openerTargetId: openerTargetId ?? null, + parentFrameId: parentFrameId ?? null, + }); +}; diff --git a/src/domain/browserCompleteness.ts b/src/domain/browserCompleteness.ts index bf57ea9a2c..39da24fe40 100644 --- a/src/domain/browserCompleteness.ts +++ b/src/domain/browserCompleteness.ts @@ -36,6 +36,7 @@ const browserExclusionReasonSchema = z.enum([ "unattributed_origin", "not_approved", "out_of_target_scope", + "unattributed_target", "provider_unavailable", "invalid_protocol_value", "resource_budget_exhausted", diff --git a/tests/boundary/cli/browserWorkerOwnershipCli.test.ts b/tests/boundary/cli/browserWorkerOwnershipCli.test.ts new file mode 100644 index 0000000000..fae680d3ca --- /dev/null +++ b/tests/boundary/cli/browserWorkerOwnershipCli.test.ts @@ -0,0 +1,183 @@ +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { afterEach, expect, it } from "vitest"; + +import { webPageInspectionSchema } from "../../../src/domain/browserObservationSchemas.js"; +import { electronPageInspectionSchema } from "../../../src/domain/javascript/electronObservation.js"; +import { runCliJson } from "../../fixtures/cliJsonProcess.js"; +import { + startFakeCdpBrowser, + type FakeCdpBrowser, +} from "../../fixtures/fakeCdpBrowser.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const browsers: FakeCdpBrowser[] = []; +const temporary: string[] = []; + +afterEach(async () => { + await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); + await Promise.all( + temporary + .splice(0) + .map(async (root) => rm(root, { recursive: true, force: true })), + ); +}); + +const normalizedResult = (value: unknown): unknown => { + if ( + typeof value !== "object" || + value === null || + !("normalized_result" in value) + ) + throw new Error("Expected CLI Evidence"); + return value.normalized_result; +}; + +it("keeps missing worker ownership unknown through inspect-web-page", async () => { + const browser = await startFakeCdpBrowser({ + commandResult: ({ method }, origin) => { + if (method !== "Target.getTargets") return undefined; + const worker = { + type: "worker", + title: "Fixture", + attached: false, + canAccessOpener: false, + url: `${origin}/worker.js`, + }; + return { + targetInfos: [ + { ...worker, targetId: "related-opener", openerId: "allowed-page" }, + { + ...worker, + targetId: "related-parent", + parentFrameId: "frame-main", + }, + { ...worker, targetId: "withheld-unknown" }, + { ...worker, targetId: "withheld-empty-url", url: "" }, + { ...worker, targetId: "withheld-foreign", openerId: "other-page" }, + { + ...worker, + targetId: "withheld-foreign-parent", + parentFrameId: "other-frame", + }, + { ...worker, targetId: "withheld-invalid", openerId: null }, + { + ...worker, + targetId: "withheld-disallowed", + openerId: "allowed-page", + url: "https://foreign.test/worker.js", + }, + ], + }; + }, + }); + browsers.push(browser); + const evidence = await runCliJson([ + "inspect-web-page", + browser.endpoint, + "allowed-page", + "--observation-ms", + "0", + "--json", + ]); + expect(evidence).toMatchObject({ + operation: "inspect_web_page", + provider: { id: "rea-cdp-browser" }, + }); + const inspection = webPageInspectionSchema.parse(normalizedResult(evidence)); + expect(inspection.workers).toEqual([ + expect.objectContaining({ + target_id: "related-opener", + opener_target_id: "allowed-page", + parent_frame_id: null, + }), + expect.objectContaining({ + target_id: "related-parent", + opener_target_id: null, + parent_frame_id: "frame-main", + }), + ]); + expect(inspection.completeness.unavailable_sections).toContain("workers"); + expect( + inspection.completeness.excluded.filter( + ({ section }) => section === "workers", + ), + ).toEqual([ + { section: "workers", reason: "disallowed_origin", count: 1 }, + { section: "workers", reason: "invalid_protocol_value", count: 1 }, + { section: "workers", reason: "out_of_target_scope", count: 2 }, + { section: "workers", reason: "unattributed_target", count: 2 }, + ]); + expect(JSON.stringify(evidence)).not.toContain("withheld-"); + expect( + browser.commands.some(({ method }) => method === "Target.detachFromTarget"), + ).toBe(true); +}, 20_000); + +it("uses the same ownership facts for passive Electron worker inspection", async () => { + const root = await createTestTempDirectory("rea-worker-ownership-"); + temporary.push(root); + await writeFile(join(root, "index.html"), ""); + await writeFile(join(root, "worker.js"), "void 0;"); + const browser = await startFakeCdpBrowser({ + electronFileUrl: pathToFileURL(join(root, "index.html")).href, + commandResult: ({ method }) => { + if (method !== "Target.getTargets") return undefined; + const worker = { + type: "worker", + title: "Fixture", + attached: false, + canAccessOpener: false, + url: pathToFileURL(join(root, "worker.js")).href, + }; + return { + targetInfos: [ + { ...worker, targetId: "related", openerId: "electron-page" }, + { ...worker, targetId: "withheld-unknown" }, + { ...worker, targetId: "withheld-empty-url", url: "" }, + { ...worker, targetId: "withheld-foreign", openerId: "other-page" }, + { ...worker, targetId: "withheld-invalid", parentFrameId: 42 }, + ], + }; + }, + }); + browsers.push(browser); + const evidence = await runCliJson([ + "inspect-electron-page", + browser.endpoint, + "electron-page", + "--observation-ms", + "0", + "--json", + ]); + expect(evidence).toMatchObject({ + operation: "inspect_electron_page", + provider: { id: "rea-cdp-electron" }, + }); + const inspection = electronPageInspectionSchema.parse( + normalizedResult(evidence), + ); + expect(inspection.workers).toEqual([ + expect.objectContaining({ + target_id: "related", + opener_target_id: "electron-page", + parent_frame_id: null, + }), + ]); + expect(inspection.completeness.unavailable_sections).toContain("workers"); + expect( + inspection.completeness.excluded.filter( + ({ section }) => section === "workers", + ), + ).toEqual([ + { section: "workers", reason: "invalid_protocol_value", count: 1 }, + { section: "workers", reason: "out_of_target_scope", count: 1 }, + { section: "workers", reason: "unattributed_target", count: 2 }, + ]); + expect(JSON.stringify(evidence)).not.toContain("withheld-"); + expect( + browser.commands.some(({ method }) => method === "Target.detachFromTarget"), + ).toBe(true); +}, 20_000);