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);