mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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
This commit is contained in:
1 parent
d13a036e34
commit
1081f81484
9 files changed
+315
-29
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Record<string, unknown>>,
|
||||
selectedTargetId: string,
|
||||
frameIds: ReadonlySet<string>,
|
||||
): 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,
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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"), "<html></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);
|
||||
Reference in new issue
Block a user