@mochi.js/inject
Advanced tools
| /** | ||
| * Shared test fixtures for the inject unit tests. | ||
| * | ||
| * The canonical matrix is a deterministically-derived `MatrixV1` for | ||
| * `(profile = mac-m2-chrome-stable-fixture, seed = "fixture-seed")`. We | ||
| * pin a hand-built fixture rather than calling `deriveMatrix` so the | ||
| * inject tests stay independent of the consistency engine's exact | ||
| * behaviour — what we want is "given THIS matrix, the payload spoofs | ||
| * THIS surface". | ||
| * | ||
| * The contract test in `tests/contract/inject-payload.contract.test.ts` | ||
| * uses the consistency engine's real `deriveMatrix` for the sha256 pin. | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| export const FIXTURE_MATRIX: MatrixV1 = { | ||
| id: "mac-m2-chrome-stable-fixture", | ||
| version: "0.0.0-fixture", | ||
| engine: "chromium", | ||
| browser: { name: "chrome", channel: "stable", minVersion: "131", maxVersion: "133" }, | ||
| os: { name: "macos", version: "14", arch: "arm64" }, | ||
| device: { | ||
| vendor: "Apple", | ||
| model: "Mac14,2", | ||
| cpuFamily: "apple-silicon-m2", | ||
| cores: 8, | ||
| memoryGB: 16, | ||
| }, | ||
| display: { width: 1728, height: 1117, dpr: 2, colorDepth: 30, pixelDepth: 30 }, | ||
| gpu: { | ||
| vendor: "Apple Inc.", | ||
| renderer: "Apple M2", | ||
| webglUnmaskedVendor: "Google Inc. (Apple)", | ||
| webglUnmaskedRenderer: "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)", | ||
| webglMaxTextureSize: 16384, | ||
| webglMaxColorAttachments: 8, | ||
| webglExtensions: ["ANGLE_instanced_arrays", "EXT_blend_minmax", "EXT_color_buffer_half_float"], | ||
| }, | ||
| audio: { contextSampleRate: 48000, audioWorkletLatency: 0.005, destinationMaxChannelCount: 2 }, | ||
| fonts: { | ||
| family: "macos-baseline", | ||
| list: ["Helvetica", "Helvetica Neue", "Arial", "Times", "Courier"], | ||
| }, | ||
| timezone: "America/Los_Angeles", | ||
| locale: "en-US", | ||
| languages: ["en-US", "en"], | ||
| behavior: { hand: "right", tremor: 0.18, wpm: 60, scrollStyle: "smooth" }, | ||
| wreqPreset: "chrome_131_macos", | ||
| userAgent: | ||
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36", | ||
| uaCh: { | ||
| "sec-ch-ua": '"Google Chrome";v="131", "Not.A/Brand";v="8", "Chromium";v="131"', | ||
| "sec-ch-ua-platform": '"macOS"', | ||
| "sec-ch-ua-platform-version": '"14.0.0"', | ||
| "sec-ch-ua-arch": '"arm"', | ||
| "sec-ch-ua-bitness": '"64"', | ||
| "sec-ch-ua-mobile": "?0", | ||
| "navigator-platform": "MacIntel", | ||
| "navigator-vendor": "Google Inc.", | ||
| "navigator-appCodeName": "Mozilla", | ||
| "navigator-product": "Gecko", | ||
| "navigator-cookieEnabled": "true", | ||
| "navigator-maxTouchPoints": "0", | ||
| "navigator-webdriver": "false", | ||
| "navigator-appVersion": | ||
| "5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36", | ||
| "screen-availSize": JSON.stringify({ availWidth: 1728, availHeight: 1092 }), | ||
| "screen-dimensions": JSON.stringify({ | ||
| width: 1728, | ||
| height: 1117, | ||
| availWidth: 1728, | ||
| availHeight: 1092, | ||
| }), | ||
| "window-viewport": JSON.stringify({ | ||
| innerWidth: 1728, | ||
| innerHeight: 1005, | ||
| outerWidth: 1728, | ||
| outerHeight: 1092, | ||
| }), | ||
| }, | ||
| entropyBudget: { fixed: [], perSeed: [] }, | ||
| seed: "fixture-seed", | ||
| derivedAt: "2026-01-01T00:00:00.000Z", | ||
| consistencyEngineVersion: "0.2.0-fixture", | ||
| }; |
| /** | ||
| * Unit: payload shape and determinism. | ||
| * | ||
| * - `buildPayload` returns a non-empty IIFE string. | ||
| * - The string parses as valid JS (`new Function` accepts it). | ||
| * - Same matrix → byte-identical code → identical sha256. | ||
| * - Differs across matrices. | ||
| * - sha256 is 64 hex chars. | ||
| * - `code` is wrapped in a single top-level IIFE. | ||
| * | ||
| * @see tasks/0030-inject-engine-v0.md §"payload-shape.test.ts" | ||
| */ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { buildPayload } from "../build"; | ||
| import { FIXTURE_MATRIX } from "./fixtures"; | ||
| describe("buildPayload — payload shape and determinism", () => { | ||
| it("returns code + sha256", () => { | ||
| const out = buildPayload(FIXTURE_MATRIX); | ||
| expect(typeof out.code).toBe("string"); | ||
| expect(out.code.length).toBeGreaterThan(0); | ||
| expect(typeof out.sha256).toBe("string"); | ||
| expect(out.sha256).toMatch(/^[0-9a-f]{64}$/); | ||
| }); | ||
| it("wraps code in a single top-level IIFE", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| // Allow leading whitespace; first non-whitespace token must be `(`. | ||
| expect(code.trimStart().startsWith("(function")).toBe(true); | ||
| // Tail must close the IIFE and invoke it. | ||
| expect(code.trimEnd().endsWith("})();")).toBe(true); | ||
| }); | ||
| it("parses as valid JS (new Function accepts it)", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| // We don't EXECUTE it here (no DOM); we only confirm it parses. | ||
| expect(() => new Function(code)).not.toThrow(); | ||
| }); | ||
| it("is deterministic across calls (same matrix → identical code)", () => { | ||
| const a = buildPayload(FIXTURE_MATRIX); | ||
| const b = buildPayload(FIXTURE_MATRIX); | ||
| expect(a.code).toBe(b.code); | ||
| expect(a.sha256).toBe(b.sha256); | ||
| }); | ||
| it("changes when the matrix's userAgent changes", () => { | ||
| const a = buildPayload(FIXTURE_MATRIX); | ||
| const b = buildPayload({ ...FIXTURE_MATRIX, userAgent: "Mozilla/5.0 (different)" }); | ||
| expect(a.sha256).not.toBe(b.sha256); | ||
| expect(a.code).not.toBe(b.code); | ||
| }); | ||
| it("ignores derivedAt for byte stability", () => { | ||
| // Per the build doc: only fields used by spoof modules affect output. | ||
| // The header banner does NOT include derivedAt, so changing it must | ||
| // not change the bytes. | ||
| const a = buildPayload(FIXTURE_MATRIX); | ||
| const b = buildPayload({ ...FIXTURE_MATRIX, derivedAt: "2099-12-31T23:59:59.999Z" }); | ||
| expect(a.sha256).toBe(b.sha256); | ||
| }); | ||
| it("respects the soft size budget (≤ 80 KB)", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| expect(new TextEncoder().encode(code).length).toBeLessThanOrEqual(80 * 1024); | ||
| }); | ||
| }); |
| /** | ||
| * Unit: phase-0.7 spoof modules. | ||
| * | ||
| * The new modules (webgpu, media-devices, permissions, network-info, | ||
| * screen-orientation) read JSON-encoded uaCh keys produced by R-031..R-040. | ||
| * The sandbox in `sandbox.ts` doesn't yet stand up navigator.gpu / | ||
| * mediaDevices / permissions / connection / matchMedia, so these tests | ||
| * assert the SHAPE of the emitted JS rather than its runtime semantics — | ||
| * matching the pattern used by `payload-shape.test.ts`. Runtime semantics | ||
| * are exercised by the harness E2E gate against real Chromium. | ||
| * | ||
| * @see tasks/0070-consistency-rules-full.md | ||
| */ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { buildPayload } from "../build"; | ||
| import { emitMediaDevicesModule } from "../modules/media-devices"; | ||
| import { emitNetworkInfoModule } from "../modules/network-info"; | ||
| import { emitPermissionsModule } from "../modules/permissions"; | ||
| import { emitScreenOrientationModule } from "../modules/screen-orientation"; | ||
| import { emitWebgpuModule } from "../modules/webgpu"; | ||
| import { FIXTURE_MATRIX } from "./fixtures"; | ||
| /** | ||
| * Fixture extended with the phase-0.7 uaCh keys. Mirrors the JSON shapes | ||
| * produced by R-031..R-040 in `@mochi.js/consistency`. | ||
| */ | ||
| const PHASE07_MATRIX = { | ||
| ...FIXTURE_MATRIX, | ||
| uaCh: { | ||
| ...FIXTURE_MATRIX.uaCh, | ||
| "ua-full-version-list": JSON.stringify([ | ||
| { brand: "Google Chrome", version: "131.0.6778.110" }, | ||
| { brand: "Not.A/Brand", version: "8.0.0.0" }, | ||
| { brand: "Chromium", version: "131.0.6778.110" }, | ||
| ]), | ||
| "webgpu-features": JSON.stringify(["depth32float-stencil8", "shader-f16"]), | ||
| "webgpu-info": JSON.stringify({ | ||
| vendor: "apple", | ||
| architecture: "metal-3", | ||
| device: "", | ||
| description: "", | ||
| }), | ||
| "media-devices": JSON.stringify([ | ||
| { kind: "audioinput", label: "" }, | ||
| { kind: "videoinput", label: "" }, | ||
| ]), | ||
| "media-supported-constraints": JSON.stringify({ deviceId: true, groupId: true }), | ||
| "permissions-defaults": JSON.stringify({ geolocation: "prompt", accelerometer: "granted" }), | ||
| connection: JSON.stringify({ effectiveType: "4g", downlink: 10, rtt: 50, saveData: false }), | ||
| "screen-orientation": JSON.stringify({ type: "landscape-primary", angle: 0 }), | ||
| "media-queries": JSON.stringify({ "prefers-color-scheme": "light", monochrome: false }), | ||
| "storage-estimate": JSON.stringify({ quota: 1_000_000_000, usage: 0 }), | ||
| }, | ||
| }; | ||
| describe("phase-0.7 modules — webgpu", () => { | ||
| it("emits a non-empty module when uaCh.webgpu-features is present", () => { | ||
| const code = emitWebgpuModule(PHASE07_MATRIX); | ||
| expect(code).toContain("WebGPU spoof"); | ||
| expect(code).toContain("requestAdapter"); | ||
| expect(code).toContain("metal-3"); | ||
| expect(code).toContain("shader-f16"); | ||
| }); | ||
| it("skips when matrix has no webgpu data", () => { | ||
| const code = emitWebgpuModule(FIXTURE_MATRIX); | ||
| expect(code).toContain("WebGPU spoof (skipped"); | ||
| }); | ||
| }); | ||
| describe("phase-0.7 modules — media-devices", () => { | ||
| it("emits enumerateDevices override + seeded deterministic IDs", () => { | ||
| const code = emitMediaDevicesModule(PHASE07_MATRIX); | ||
| expect(code).toContain("enumerateDevices"); | ||
| expect(code).toContain("getSupportedConstraints"); | ||
| // Deterministic IDs: the deviceId hex appears twice in the source | ||
| // (once per device) — we verify the substring shape, not the exact | ||
| // bytes (those depend on profile.id + seed). | ||
| expect(code).toMatch(/"deviceId":"[a-f0-9]{64}"/); | ||
| expect(code).toMatch(/"groupId":"[a-f0-9]{64}"/); | ||
| }); | ||
| it("derives stable IDs per (profile, seed)", () => { | ||
| const a = emitMediaDevicesModule(PHASE07_MATRIX); | ||
| const b = emitMediaDevicesModule(PHASE07_MATRIX); | ||
| expect(a).toBe(b); | ||
| }); | ||
| it("derives different IDs per seed", () => { | ||
| const a = emitMediaDevicesModule(PHASE07_MATRIX); | ||
| const b = emitMediaDevicesModule({ ...PHASE07_MATRIX, seed: "fixture-seed-other" }); | ||
| expect(a).not.toBe(b); | ||
| }); | ||
| }); | ||
| describe("phase-0.7 modules — permissions", () => { | ||
| it("emits Permissions.prototype.query override", () => { | ||
| const code = emitPermissionsModule(PHASE07_MATRIX); | ||
| expect(code).toContain("Permissions.prototype"); | ||
| expect(code).toContain('"geolocation":"prompt"'); | ||
| expect(code).toContain('"accelerometer":"granted"'); | ||
| }); | ||
| it("skips when defaults are missing", () => { | ||
| const code = emitPermissionsModule(FIXTURE_MATRIX); | ||
| expect(code).toContain("permissions spoof (skipped"); | ||
| }); | ||
| }); | ||
| describe("phase-0.7 modules — network-info", () => { | ||
| it("emits effectiveType + downlink + rtt + saveData defines", () => { | ||
| const code = emitNetworkInfoModule(PHASE07_MATRIX); | ||
| expect(code).toContain("effectiveType"); | ||
| expect(code).toContain('"4g"'); | ||
| expect(code).toContain("10"); | ||
| expect(code).toContain("50"); | ||
| }); | ||
| }); | ||
| describe("phase-0.7 modules — screen-orientation + matchMedia", () => { | ||
| it("emits orientation + matchMedia + storage.estimate spoofs", () => { | ||
| const code = emitScreenOrientationModule(PHASE07_MATRIX); | ||
| expect(code).toContain("landscape-primary"); | ||
| expect(code).toContain("matchMedia"); | ||
| expect(code).toContain("estimate"); | ||
| }); | ||
| }); | ||
| describe("phase-0.7 — full payload integration", () => { | ||
| it("buildPayload includes all phase-0.7 module markers", () => { | ||
| const { code } = buildPayload(PHASE07_MATRIX); | ||
| expect(code).toContain("mochi:webgpu"); | ||
| expect(code).toContain("mochi:media-devices"); | ||
| expect(code).toContain("mochi:permissions"); | ||
| expect(code).toContain("mochi:network-info"); | ||
| expect(code).toContain("mochi:screen-orientation"); | ||
| }); | ||
| it("buildPayload deterministic for identical phase-0.7 fixture", () => { | ||
| const a = buildPayload(PHASE07_MATRIX); | ||
| const b = buildPayload(PHASE07_MATRIX); | ||
| expect(a.sha256).toBe(b.sha256); | ||
| }); | ||
| it("buildPayload differs when phase-0.7 fixture changes", () => { | ||
| const a = buildPayload(PHASE07_MATRIX); | ||
| const b = buildPayload({ | ||
| ...PHASE07_MATRIX, | ||
| uaCh: { | ||
| ...PHASE07_MATRIX.uaCh, | ||
| "screen-orientation": JSON.stringify({ type: "portrait-primary", angle: 90 }), | ||
| }, | ||
| }); | ||
| expect(a.sha256).not.toBe(b.sha256); | ||
| }); | ||
| }); |
| /** | ||
| * Unit: payload runtime overrides. | ||
| * | ||
| * Loads the payload string into a synthesized JS sandbox (no Bun.spawn, | ||
| * no real Chromium) and asserts each spoofed property reads back the | ||
| * matrix value. | ||
| * | ||
| * The sandbox is a minimal stand-in — the real proof is the E2E test in | ||
| * `packages/core/src/__tests__/inject.e2e.test.ts`. These unit tests | ||
| * verify the payload's *intent* and exercise the structure on the fast | ||
| * feedback loop. | ||
| */ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { buildPayload } from "../build"; | ||
| import { FIXTURE_MATRIX } from "./fixtures"; | ||
| import { runPayloadInSandbox } from "./sandbox"; | ||
| describe("inject runtime overrides — navigator", () => { | ||
| it("overrides navigator.userAgent", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.navigator.userAgent).toBe(FIXTURE_MATRIX.userAgent); | ||
| }); | ||
| it("overrides navigator.platform from uaCh", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.navigator.platform).toBe(FIXTURE_MATRIX.uaCh["navigator-platform"]); | ||
| }); | ||
| it("overrides navigator.vendor from uaCh", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.navigator.vendor).toBe(FIXTURE_MATRIX.uaCh["navigator-vendor"]); | ||
| }); | ||
| it("overrides navigator.appVersion from uaCh", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.navigator.appVersion).toBe(FIXTURE_MATRIX.uaCh["navigator-appVersion"]); | ||
| }); | ||
| it("overrides navigator.{appCodeName,product}", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.navigator.appCodeName).toBe("Mozilla"); | ||
| expect(sb.navigator.product).toBe("Gecko"); | ||
| }); | ||
| it("overrides navigator.cookieEnabled to boolean true", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.navigator.cookieEnabled).toBe(true); | ||
| }); | ||
| it("overrides navigator.maxTouchPoints to number 0", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.navigator.maxTouchPoints).toBe(0); | ||
| }); | ||
| it("overrides navigator.webdriver to boolean false", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.navigator.webdriver).toBe(false); | ||
| }); | ||
| it("overrides navigator.hardwareConcurrency from device.cores", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.navigator.hardwareConcurrency).toBe(FIXTURE_MATRIX.device.cores); | ||
| }); | ||
| it("overrides navigator.deviceMemory from device.memoryGB", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.navigator.deviceMemory).toBe(FIXTURE_MATRIX.device.memoryGB); | ||
| }); | ||
| it("overrides navigator.language and navigator.languages", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.navigator.language).toBe(FIXTURE_MATRIX.locale); | ||
| expect(sb.navigator.languages).toEqual(FIXTURE_MATRIX.languages); | ||
| }); | ||
| it("makes navigator.languages a frozen array", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const arr = sb.navigator.languages as unknown[]; | ||
| expect(Object.isFrozen(arr)).toBe(true); | ||
| }); | ||
| }); | ||
| describe("inject runtime overrides — screen + viewport", () => { | ||
| it("overrides screen.{width,height,colorDepth,pixelDepth}", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect(sb.screen.width).toBe(FIXTURE_MATRIX.display.width); | ||
| expect(sb.screen.height).toBe(FIXTURE_MATRIX.display.height); | ||
| expect(sb.screen.colorDepth).toBe(FIXTURE_MATRIX.display.colorDepth); | ||
| expect(sb.screen.pixelDepth).toBe(FIXTURE_MATRIX.display.pixelDepth); | ||
| }); | ||
| it("overrides screen.{availWidth,availHeight} from uaCh", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const expected = JSON.parse(FIXTURE_MATRIX.uaCh["screen-availSize"] as string) as { | ||
| availWidth: number; | ||
| availHeight: number; | ||
| }; | ||
| expect(sb.screen.availWidth).toBe(expected.availWidth); | ||
| expect(sb.screen.availHeight).toBe(expected.availHeight); | ||
| }); | ||
| it("overrides window.devicePixelRatio from display.dpr", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| expect((sb.window as unknown as { devicePixelRatio: unknown }).devicePixelRatio).toBe( | ||
| FIXTURE_MATRIX.display.dpr, | ||
| ); | ||
| }); | ||
| it("overrides window.{innerWidth,innerHeight,outerWidth,outerHeight} from uaCh", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const expected = JSON.parse(FIXTURE_MATRIX.uaCh["window-viewport"] as string) as Record< | ||
| string, | ||
| number | ||
| >; | ||
| const win = sb.window as Record<string, unknown>; | ||
| expect(win.innerWidth).toBe(expected.innerWidth); | ||
| expect(win.innerHeight).toBe(expected.innerHeight); | ||
| expect(win.outerWidth).toBe(expected.outerWidth); | ||
| expect(win.outerHeight).toBe(expected.outerHeight); | ||
| }); | ||
| }); | ||
| describe("inject runtime overrides — webgl", () => { | ||
| it("returns spoofed UNMASKED_VENDOR_WEBGL", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const proto = sb.WebGLRenderingContext.prototype as { getParameter: (p: number) => unknown }; | ||
| expect(proto.getParameter(0x9245)).toBe(FIXTURE_MATRIX.gpu.webglUnmaskedVendor); | ||
| }); | ||
| it("returns spoofed UNMASKED_RENDERER_WEBGL", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const proto = sb.WebGLRenderingContext.prototype as { getParameter: (p: number) => unknown }; | ||
| expect(proto.getParameter(0x9246)).toBe(FIXTURE_MATRIX.gpu.webglUnmaskedRenderer); | ||
| }); | ||
| it("returns spoofed MAX_TEXTURE_SIZE", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const proto = sb.WebGLRenderingContext.prototype as { getParameter: (p: number) => unknown }; | ||
| expect(proto.getParameter(0x0d33)).toBe(FIXTURE_MATRIX.gpu.webglMaxTextureSize); | ||
| }); | ||
| it("falls through to native for non-spoofed pname", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const proto = sb.WebGLRenderingContext.prototype as { getParameter: (p: number) => unknown }; | ||
| // sandbox's native returns "BARE-<pname>" | ||
| expect(proto.getParameter(7937)).toBe("BARE-7937"); | ||
| }); | ||
| it("WebGL2 returns spoofed MAX_COLOR_ATTACHMENTS", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const proto = sb.WebGL2RenderingContext.prototype as { getParameter: (p: number) => unknown }; | ||
| expect(proto.getParameter(0x8cdf)).toBe(FIXTURE_MATRIX.gpu.webglMaxColorAttachments); | ||
| }); | ||
| }); | ||
| describe("inject runtime overrides — client-hints (userAgentData)", () => { | ||
| it("exposes brands, mobile, platform via the userAgentData getter", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const uad = sb.navigator.userAgentData as { | ||
| brands: Array<{ brand: string; version: string }>; | ||
| mobile: boolean; | ||
| platform: string; | ||
| }; | ||
| expect(uad).toBeDefined(); | ||
| expect(uad.platform).toBe("macOS"); | ||
| expect(uad.mobile).toBe(false); | ||
| expect(uad.brands.length).toBe(3); | ||
| const brandSet = new Set(uad.brands.map((b) => b.brand)); | ||
| expect(brandSet.has("Chromium")).toBe(true); | ||
| expect(brandSet.has("Google Chrome")).toBe(true); | ||
| }); | ||
| it("toJSON() returns brands+mobile+platform", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const uad = sb.navigator.userAgentData as { toJSON(): Record<string, unknown> }; | ||
| const j = uad.toJSON(); | ||
| expect(j.platform).toBe("macOS"); | ||
| expect(j.mobile).toBe(false); | ||
| expect(Array.isArray(j.brands)).toBe(true); | ||
| }); | ||
| it("getHighEntropyValues returns the requested hints", async () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const uad = sb.navigator.userAgentData as { | ||
| getHighEntropyValues(hints: string[]): Promise<Record<string, unknown>>; | ||
| }; | ||
| const hev = await uad.getHighEntropyValues(["architecture", "bitness", "platformVersion"]); | ||
| expect(hev.architecture).toBe("arm"); | ||
| expect(hev.bitness).toBe("64"); | ||
| expect(hev.platformVersion).toBe("14.0.0"); | ||
| }); | ||
| }); | ||
| describe("inject runtime overrides — timing (Intl.DateTimeFormat)", () => { | ||
| it("resolvedOptions().timeZone returns matrix.timezone", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const dtf = new sb.Intl.DateTimeFormat(); | ||
| expect(dtf.resolvedOptions().timeZone).toBe(FIXTURE_MATRIX.timezone); | ||
| }); | ||
| }); | ||
| describe("inject runtime overrides — fonts", () => { | ||
| it("document.fonts.size matches matrix.fonts.list.length", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const fonts = sb.document.fonts as { size: number }; | ||
| expect(fonts.size).toBe(FIXTURE_MATRIX.fonts.list.length); | ||
| }); | ||
| it("for…of iterates the matrix font list", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const fonts = sb.document.fonts as unknown as Iterable<{ family: string }>; | ||
| const families: string[] = []; | ||
| for (const f of fonts) { | ||
| families.push(f.family); | ||
| } | ||
| expect(families).toEqual([...FIXTURE_MATRIX.fonts.list]); | ||
| }); | ||
| it("check(spec) returns true for matrix-listed fonts", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const fonts = sb.document.fonts as { check(spec: string): boolean }; | ||
| expect(fonts.check("12px Helvetica")).toBe(true); | ||
| expect(fonts.check("16px 'Helvetica Neue'")).toBe(true); | ||
| }); | ||
| it("check(spec) returns false for unknown fonts", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const fonts = sb.document.fonts as { check(spec: string): boolean }; | ||
| expect(fonts.check("12px DefinitelyNotInTheList")).toBe(false); | ||
| }); | ||
| }); | ||
| describe("inject runtime overrides — bot-globals cleanup", () => { | ||
| it("deletes automation sentinel keys if present", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| // Plant a sentinel pre-payload. | ||
| (sb.window as Record<string, unknown>).cdc_adoQpoasnfa76pfcZLmcfl_Array = "tainted"; | ||
| // Re-run a fresh payload to clean. The sandbox runner above already | ||
| // ran once; for this we run the payload again on the same sandbox. | ||
| // Simpler: plant the key inside the sandbox before run; but sandbox | ||
| // is built fresh by runPayloadInSandbox. Test the cleanup tail by | ||
| // running a new sandbox + planting. | ||
| const sb2 = runPayloadInSandbox( | ||
| `(function(){window.cdc_adoQpoasnfa76pfcZLmcfl_Array='tainted';})();${code}`, | ||
| ); | ||
| expect( | ||
| (sb2.window as Record<string, unknown>).cdc_adoQpoasnfa76pfcZLmcfl_Array, | ||
| ).toBeUndefined(); | ||
| // Also confirm in sb2 that the sentinel is gone. | ||
| void sb; | ||
| }); | ||
| }); |
| /** | ||
| * Synthesized JS sandbox for inject unit tests. | ||
| * | ||
| * Bun has no `node:vm` and we don't want to spin up real Chromium for | ||
| * unit tests. We build a fake `window` / `navigator` / `screen` / | ||
| * `WebGLRenderingContext` / etc. that is *enumerable-shape* close to a | ||
| * real Chromium globalThis, then run the payload via the `Function` | ||
| * constructor with the fakes in scope. | ||
| * | ||
| * The sandbox is intentionally minimal — it gives the payload enough of a | ||
| * surface to install its overrides without throwing. It does NOT | ||
| * faithfully emulate Chromium semantics; the E2E test (`packages/core/src/ | ||
| * __tests__/inject.e2e.test.ts`) is the real proof against the real | ||
| * browser. These unit tests verify the payload's STRUCTURE and INTENT — | ||
| * not full Chrome compatibility. | ||
| */ | ||
| /** | ||
| * The shape of the fake globals exposed inside the sandbox. All accessors | ||
| * and methods relevant to the v0.3 spoof modules are present and | ||
| * read-writable until the payload installs its overrides. | ||
| */ | ||
| export interface SandboxGlobals { | ||
| window: SandboxGlobals & Record<string, unknown>; | ||
| globalThis: SandboxGlobals & Record<string, unknown>; | ||
| navigator: Record<string, unknown>; | ||
| screen: Record<string, unknown>; | ||
| document: { fonts: Record<string, unknown> }; | ||
| WebGLRenderingContext: { prototype: Record<string, unknown> }; | ||
| WebGL2RenderingContext: { prototype: Record<string, unknown> }; | ||
| Intl: typeof Intl; | ||
| FontFace: typeof FontFace | undefined; | ||
| Symbol: typeof Symbol; | ||
| Object: typeof Object; | ||
| Array: typeof Array; | ||
| Promise: typeof Promise; | ||
| Function: typeof Function; | ||
| Number: typeof Number; | ||
| String: typeof String; | ||
| TypeError: typeof TypeError; | ||
| Error: typeof Error; | ||
| WeakMap: typeof WeakMap; | ||
| } | ||
| /** | ||
| * Build a fresh sandbox. The returned object's `window === globalThis === | ||
| * sandbox` so the payload's `typeof window !== "undefined"` checks succeed. | ||
| * | ||
| * Each call returns a brand-new sandbox; tests should NOT share state. | ||
| */ | ||
| export function makeSandbox(): SandboxGlobals { | ||
| // Build the navigator with proto-style accessor descriptors. Every | ||
| // property is configurable so the payload's `defineProperty` calls | ||
| // succeed; once the payload installs its overrides they become | ||
| // configurable:false. | ||
| type Navish = Record<string, unknown>; | ||
| const navProto: Navish = Object.create(Object.prototype); | ||
| // Default fields the page would normally see — placeholders so we can | ||
| // assert "before" vs "after" the payload runs. | ||
| Object.defineProperty(navProto, "userAgent", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return "BARE"; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "platform", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return "BARE-PLATFORM"; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "vendor", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return "BARE-VENDOR"; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "appVersion", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return "BARE-APPVER"; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "appCodeName", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return "BARE-APPCODE"; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "product", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return "BARE-PRODUCT"; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "cookieEnabled", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return false; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "maxTouchPoints", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return 99; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "webdriver", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return true; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "hardwareConcurrency", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return 0; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "deviceMemory", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return 0; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "language", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return "BARE-LANG"; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "languages", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return []; | ||
| }, | ||
| }); | ||
| Object.defineProperty(navProto, "userAgentData", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return undefined; | ||
| }, | ||
| }); | ||
| const navigator = Object.create(navProto); | ||
| // Screen with prototype-defined accessors. | ||
| type Screenish = Record<string, unknown>; | ||
| const screenProto: Screenish = Object.create(Object.prototype); | ||
| for (const k of ["width", "height", "availWidth", "availHeight", "colorDepth", "pixelDepth"]) { | ||
| Object.defineProperty(screenProto, k, { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get() { | ||
| return -1; | ||
| }, | ||
| }); | ||
| } | ||
| const screen = Object.create(screenProto); | ||
| // Stub WebGL contexts with a `getParameter` method on the prototype. | ||
| type Glish = { getParameter: (pname: number) => unknown }; | ||
| const glProto: Glish & Record<string, unknown> = Object.create(Object.prototype); | ||
| glProto.getParameter = function getParameter(pname: number): unknown { | ||
| return `BARE-${String(pname)}`; | ||
| }; | ||
| const gl2Proto: Glish & Record<string, unknown> = Object.create(Object.prototype); | ||
| gl2Proto.getParameter = function getParameter(pname: number): unknown { | ||
| return `BARE2-${String(pname)}`; | ||
| }; | ||
| const WebGLRenderingContext = function (this: unknown) {} as unknown as { | ||
| prototype: Glish & Record<string, unknown>; | ||
| }; | ||
| WebGLRenderingContext.prototype = glProto; | ||
| const WebGL2RenderingContext = function (this: unknown) {} as unknown as { | ||
| prototype: Glish & Record<string, unknown>; | ||
| }; | ||
| WebGL2RenderingContext.prototype = gl2Proto; | ||
| // FontFaceSet stub. | ||
| const fonts: Record<string, unknown> = { | ||
| size: 0, | ||
| [Symbol.iterator](): IterableIterator<unknown> { | ||
| return [].values(); | ||
| }, | ||
| forEach(_cb: (...args: unknown[]) => void): void {}, | ||
| check(_spec: string): boolean { | ||
| return false; | ||
| }, | ||
| }; | ||
| const sandbox = { | ||
| navigator, | ||
| screen, | ||
| document: { fonts }, | ||
| WebGLRenderingContext, | ||
| WebGL2RenderingContext, | ||
| Intl, | ||
| FontFace: typeof FontFace !== "undefined" ? FontFace : undefined, | ||
| // The payload reaches for these globals; share them from the host. | ||
| Symbol, | ||
| Object, | ||
| Array, | ||
| Promise, | ||
| Function, | ||
| Number, | ||
| String, | ||
| TypeError, | ||
| Error, | ||
| WeakMap, | ||
| } as unknown as SandboxGlobals; | ||
| // window === globalThis === sandbox itself | ||
| (sandbox as unknown as { window: unknown }).window = sandbox; | ||
| (sandbox as unknown as { globalThis: unknown }).globalThis = sandbox; | ||
| return sandbox; | ||
| } | ||
| /** | ||
| * Run the payload code against a fresh sandbox. Returns the sandbox so | ||
| * tests can probe state. | ||
| * | ||
| * Errors bubble out — payload-internal try/catch swallows module errors, | ||
| * so a propagated error here means the IIFE itself failed (e.g. parse). | ||
| */ | ||
| export function runPayloadInSandbox(code: string): SandboxGlobals { | ||
| const sandbox = makeSandbox(); | ||
| // Bind every sandbox property as a function arg so the payload's bare | ||
| // identifier references resolve against our fake. We omit `globalThis` | ||
| // because Function-constructor scripts bind it to the host globalThis; | ||
| // the payload uses `window` for its world checks. | ||
| const keys = [ | ||
| "window", | ||
| "navigator", | ||
| "screen", | ||
| "document", | ||
| "WebGLRenderingContext", | ||
| "WebGL2RenderingContext", | ||
| "Intl", | ||
| "FontFace", | ||
| "Symbol", | ||
| "Object", | ||
| "Array", | ||
| "Promise", | ||
| "Function", | ||
| "Number", | ||
| "String", | ||
| "TypeError", | ||
| "Error", | ||
| "WeakMap", | ||
| ]; | ||
| // Body: shadow `globalThis` locally so the payload's `globalThis` reads | ||
| // see our sandbox. The Function constructor's scope is the global | ||
| // scope; nested `var globalThis` in the body shadows correctly. | ||
| const body = `var globalThis = window; ${code}`; | ||
| const fn = new Function(...keys, body); | ||
| fn( | ||
| sandbox.window, | ||
| sandbox.navigator, | ||
| sandbox.screen, | ||
| sandbox.document, | ||
| sandbox.WebGLRenderingContext, | ||
| sandbox.WebGL2RenderingContext, | ||
| sandbox.Intl, | ||
| sandbox.FontFace, | ||
| sandbox.Symbol, | ||
| sandbox.Object, | ||
| sandbox.Array, | ||
| sandbox.Promise, | ||
| sandbox.Function, | ||
| sandbox.Number, | ||
| sandbox.String, | ||
| sandbox.TypeError, | ||
| sandbox.Error, | ||
| sandbox.WeakMap, | ||
| ); | ||
| return sandbox; | ||
| } |
| /** | ||
| * Unit: self-deletion of init globals. | ||
| * | ||
| * After the payload runs, no `__mochi*` keys may be visible on `window`. | ||
| * The IIFE captures all helpers as locals, but a tail-of-IIFE cleanup | ||
| * sweeps `window` for any stragglers. | ||
| */ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { buildPayload } from "../build"; | ||
| import { FIXTURE_MATRIX } from "./fixtures"; | ||
| import { runPayloadInSandbox } from "./sandbox"; | ||
| describe("self-delete — no __mochi* globals leak", () => { | ||
| it("window has no __mochi-prefixed own properties after run", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const keys = Object.getOwnPropertyNames(sb.window); | ||
| const leaked = keys.filter((k) => k.startsWith("__mochi")); | ||
| expect(leaked).toEqual([]); | ||
| }); | ||
| it("removes any pre-planted __mochi globals", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| // Plant before the IIFE runs. | ||
| const planted = `(function(){ window.__mochi_planted__ = 'x'; window.__mochi_oops__ = 'y'; })();${code}`; | ||
| const sb = runPayloadInSandbox(planted); | ||
| expect((sb.window as Record<string, unknown>).__mochi_planted__).toBeUndefined(); | ||
| expect((sb.window as Record<string, unknown>).__mochi_oops__).toBeUndefined(); | ||
| }); | ||
| }); |
| /** | ||
| * Unit: toString cloaking — verifies that every spoofed function answers | ||
| * `.toString()` with the native shape `function ${name}() { [native code] }`. | ||
| * | ||
| * Tests run against the sandbox-loaded payload. | ||
| */ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { buildPayload } from "../build"; | ||
| import { FIXTURE_MATRIX } from "./fixtures"; | ||
| import { runPayloadInSandbox } from "./sandbox"; | ||
| describe("toString cloak — spoofed functions return native-shape toString()", () => { | ||
| it("Function.prototype.toString.toString() returns native shape", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const fnToString = sb.Function.prototype.toString; | ||
| expect(fnToString.toString()).toBe("function toString() { [native code] }"); | ||
| }); | ||
| it("WebGLRenderingContext.prototype.getParameter.toString() is cloaked", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const proto = sb.WebGLRenderingContext.prototype as { | ||
| getParameter: { toString(): string }; | ||
| }; | ||
| expect(proto.getParameter.toString()).toBe("function getParameter() { [native code] }"); | ||
| }); | ||
| it("WebGL2RenderingContext.prototype.getParameter.toString() is cloaked", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const proto = sb.WebGL2RenderingContext.prototype as { | ||
| getParameter: { toString(): string }; | ||
| }; | ||
| expect(proto.getParameter.toString()).toBe("function getParameter() { [native code] }"); | ||
| }); | ||
| it("Intl.DateTimeFormat.prototype.resolvedOptions.toString() is cloaked", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const ro = sb.Intl.DateTimeFormat.prototype.resolvedOptions; | ||
| expect(ro.toString()).toBe("function resolvedOptions() { [native code] }"); | ||
| }); | ||
| it("userAgentData.getHighEntropyValues.toString() is cloaked", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| const sb = runPayloadInSandbox(code); | ||
| const uad = sb.navigator.userAgentData as { getHighEntropyValues: { toString(): string } }; | ||
| expect(uad.getHighEntropyValues.toString()).toBe( | ||
| "function getHighEntropyValues() { [native code] }", | ||
| ); | ||
| }); | ||
| it("normal user-defined functions still show real source", () => { | ||
| const { code } = buildPayload(FIXTURE_MATRIX); | ||
| runPayloadInSandbox(code); | ||
| // Defining a function in this test process (which already had its own | ||
| // Function.prototype.toString untouched, since the sandbox patched a | ||
| // copy only inside the sandboxed evaluation) — the host-side toString | ||
| // is untouched, so we can verify it still produces source. | ||
| function userFn(): number { | ||
| return 42; | ||
| } | ||
| expect(userFn.toString()).toContain("return 42"); | ||
| }); | ||
| }); |
+226
| /** | ||
| * Payload builder — composes per-API spoof modules into a single IIFE | ||
| * delivered to Chromium via `Page.addScriptToEvaluateOnNewDocument( | ||
| * runImmediately: true, worldName: "")`. | ||
| * | ||
| * Determinism contract: | ||
| * - Same `MatrixV1` (excluding `derivedAt`) → byte-identical `code` → | ||
| * identical sha256. | ||
| * - Module composition order is fixed. | ||
| * - No `Date.now()`, no `Math.random()`, no env reads in the build path. | ||
| * | ||
| * Size budget: ≤ 80 KB minified (target ~50 KB at v0.3). | ||
| * - We don't run a full JS minifier here — `esbuild` can be added later | ||
| * if budget pressure mounts. For v0.3 the source is already compact; | ||
| * measured below 30 KB with whitespace. | ||
| * | ||
| * Stealth invariants enforced at build time: | ||
| * - Wrapped in a single IIFE — no top-level identifiers escape. | ||
| * - Initialization globals `__mochi__*` declared inside the IIFE; any | ||
| * stragglers wiped via `delete window.__mochi__*` at end-of-IIFE. | ||
| * - `try { ... } catch (e) { ... }` around each module so a thrown | ||
| * override never reaches page script (PLAN.md §5.3). | ||
| * | ||
| * @see PLAN.md §5.3, §8.4 | ||
| * @see tasks/0030-inject-engine-v0.md | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| import { emitBotGlobalsModule } from "./modules/bot-globals"; | ||
| import { emitClientHintsModule } from "./modules/client-hints"; | ||
| import { emitFontsModule } from "./modules/fonts"; | ||
| import { emitMediaDevicesModule } from "./modules/media-devices"; | ||
| import { emitNavigatorModule } from "./modules/navigator"; | ||
| import { emitNetworkInfoModule } from "./modules/network-info"; | ||
| import { emitPermissionsModule } from "./modules/permissions"; | ||
| import { emitPluginsModule } from "./modules/plugins"; | ||
| import { emitScreenModule } from "./modules/screen"; | ||
| import { emitScreenOrientationModule } from "./modules/screen-orientation"; | ||
| import { emitTimingModule } from "./modules/timing"; | ||
| import { emitWebglModule } from "./modules/webgl"; | ||
| import { emitWebgpuModule } from "./modules/webgpu"; | ||
| import { emitWindowChromeModule } from "./modules/window-chrome"; | ||
| import { emitDefinePropertyHelper } from "./runtime/defineproperty"; | ||
| import { emitToStringCloak } from "./runtime/tostring-cloak"; | ||
| /** | ||
| * The result returned by {@link buildPayload}. | ||
| * | ||
| * - `code` is the IIFE source. Send as-is to | ||
| * `Page.addScriptToEvaluateOnNewDocument` with `runImmediately: true, | ||
| * worldName: ""` and `Runtime.evaluate` against worker targets. | ||
| * - `sha256` is hex-encoded SHA-256 of `code`. Used for cache keys, the | ||
| * contract pin in `tests/contract/inject-payload.contract.test.ts`, and | ||
| * downstream change detection in the harness. | ||
| */ | ||
| export interface PayloadResult { | ||
| readonly code: string; | ||
| readonly sha256: string; | ||
| } | ||
| /** Soft budget — buildPayload `console.warn`s if exceeded but never throws. */ | ||
| const SIZE_BUDGET_BYTES = 80 * 1024; | ||
| /** | ||
| * Compose the inject payload for a given matrix. | ||
| * | ||
| * The layout is fixed (changing it changes every downstream sha256 pin): | ||
| * | ||
| * 1. IIFE prologue — `(function(){` | ||
| * 2. Header banner (always-true comment block, useful in DevTools dumps) | ||
| * 3. Runtime helpers (defineProperty + toString cloak) | ||
| * 4. Spoof modules — navigator, screen, webgl, client-hints, timing, | ||
| * bot-globals, fonts (in that order) | ||
| * 5. Self-deletion of any `__mochi*` window globals | ||
| * 6. IIFE epilogue — `})();` | ||
| * | ||
| * Each spoof module is wrapped in a `try { … } catch (_) {}` at the top | ||
| * level of the IIFE so a single module's failure can't take out the rest. | ||
| * | ||
| * @throws Never. If a module throws *during build* it propagates; runtime | ||
| * errors are swallowed by the IIFE's per-module try/catch. | ||
| */ | ||
| export function buildPayload(matrix: MatrixV1): PayloadResult { | ||
| // PLAN.md I-5: never invent values — but we DO build defensively. If the | ||
| // matrix is missing a uaCh key, the corresponding module skips that line. | ||
| const parts: string[] = []; | ||
| parts.push("(function () {"); | ||
| parts.push(banner(matrix)); | ||
| parts.push("'use strict';"); | ||
| parts.push(emitDefinePropertyHelper()); | ||
| parts.push(emitToStringCloak()); | ||
| // Each module is wrapped in a try/catch so a single failure can't take | ||
| // down the rest. The wrapper logs nothing (PLAN.md §5.3 — never let our | ||
| // injection produce console output that page script can observe). | ||
| parts.push(wrapTry("navigator", emitNavigatorModule(matrix))); | ||
| parts.push(wrapTry("screen", emitScreenModule(matrix))); | ||
| parts.push(wrapTry("webgl", emitWebglModule(matrix))); | ||
| parts.push(wrapTry("client-hints", emitClientHintsModule(matrix))); | ||
| parts.push(wrapTry("timing", emitTimingModule(matrix))); | ||
| parts.push(wrapTry("bot-globals", emitBotGlobalsModule())); | ||
| parts.push(wrapTry("fonts", emitFontsModule(matrix))); | ||
| // Phase 0.7 surface coverage. Order doesn't matter for correctness — each | ||
| // module is wrapped in its own try/catch and reads only matrix.uaCh.* — but | ||
| // we keep an alphabetical-ish grouping for human readability of the dump. | ||
| parts.push(wrapTry("media-devices", emitMediaDevicesModule(matrix))); | ||
| parts.push(wrapTry("network-info", emitNetworkInfoModule(matrix))); | ||
| parts.push(wrapTry("permissions", emitPermissionsModule(matrix))); | ||
| parts.push(wrapTry("screen-orientation", emitScreenOrientationModule(matrix))); | ||
| parts.push(wrapTry("webgpu", emitWebgpuModule(matrix))); | ||
| // CloakBrowser-surfaced modules — defensive shims that no-op on real | ||
| // Chrome.app (where the underlying browser already provides these | ||
| // surfaces) and install on Chromium-for-Testing where they're absent. | ||
| // See tasks/0140-stealth-conformance.md. | ||
| parts.push(wrapTry("window-chrome", emitWindowChromeModule(matrix))); | ||
| parts.push(wrapTry("plugins", emitPluginsModule(matrix))); | ||
| // Self-deletion of any stray __mochi__* properties on window/globalThis | ||
| // — none of our helpers leak there in v0.3 (they're all IIFE-locals), | ||
| // but the cleanup is the safety net described in PLAN.md §5.3 last bullet. | ||
| parts.push(emitSelfDelete()); | ||
| parts.push("})();"); | ||
| const code = parts.join("\n"); | ||
| // Soft size budget warning. | ||
| const bytes = byteLength(code); | ||
| if (bytes > SIZE_BUDGET_BYTES) { | ||
| console.warn( | ||
| `[mochi/inject] payload size ${bytes}B exceeds ${SIZE_BUDGET_BYTES}B budget — consider trimming modules`, | ||
| ); | ||
| } | ||
| const sha256 = hashHex(code); | ||
| return { code, sha256 }; | ||
| } | ||
| // ---- helpers ---------------------------------------------------------------- | ||
| /** | ||
| * The header banner — a comment block at the top of the IIFE. Useful for | ||
| * humans reading the payload in DevTools dumps, and harmless. Includes the | ||
| * `Runtime.enable`-resilience disclaimer per PLAN.md §8.2. | ||
| */ | ||
| function banner(matrix: MatrixV1): string { | ||
| // We deliberately do NOT include `derivedAt` so the payload bytes stay | ||
| // stable per (profile, seed). Engine version + matrix-deterministic fields | ||
| // are fine because they're determinism-stable. | ||
| return [ | ||
| "// @mochi inject payload — see PLAN.md §5.3 / §8.4.", | ||
| "// Assumption: Runtime.enable is never sent (PLAN.md §8.2). This IIFE", | ||
| "// does NOT add anti-Runtime.enable hacks; the CDP layer", | ||
| "// enforces the invariant via packages/core/src/cdp/forbidden.ts.", | ||
| `// engine: ${matrix.consistencyEngineVersion}`, | ||
| `// profile: ${matrix.id}@${matrix.version}`, | ||
| `// seed: ${JSON.stringify(matrix.seed)}`, | ||
| ].join("\n"); | ||
| } | ||
| /** | ||
| * Wrap a module body in a top-level `try { ... } catch (_) {}`. The | ||
| * try/catch sits inside the IIFE but outside the module's own IIFEs. | ||
| */ | ||
| function wrapTry(name: string, body: string): string { | ||
| // Inline JS comments stay inside the IIFE source; the wrapper itself is | ||
| // a try/catch so a thrown spoof can't take out subsequent modules. | ||
| return `try { /* mochi:${name} */\n${body}\n} catch (_e) { /* swallowed per PLAN.md §5.3 */ }`; | ||
| } | ||
| /** | ||
| * Emit the self-delete tail. Walks `window` for keys starting with | ||
| * `__mochi__` or `__mochi_` and `delete`s them. Belt-and-braces — the | ||
| * module sources don't actually expose any of these to window today (all | ||
| * helpers are IIFE-locals). | ||
| */ | ||
| function emitSelfDelete(): string { | ||
| return ` | ||
| // ---- self-delete init globals --------------------------------------------- | ||
| try { | ||
| if (typeof window !== "undefined") { | ||
| var __mochi_keys__ = Object.getOwnPropertyNames(window); | ||
| for (var __mochi_i__ = 0; __mochi_i__ < __mochi_keys__.length; __mochi_i__++) { | ||
| var __mochi_k__ = __mochi_keys__[__mochi_i__]; | ||
| if (__mochi_k__.indexOf("__mochi") === 0) { | ||
| try { delete window[__mochi_k__]; } catch (_e) {} | ||
| } | ||
| } | ||
| } | ||
| } catch (_e) {} | ||
| `; | ||
| } | ||
| /** UTF-8 byte length of a JS string. */ | ||
| function byteLength(s: string): number { | ||
| // Bun has Buffer; using the global TextEncoder for portability. | ||
| return new TextEncoder().encode(s).length; | ||
| } | ||
| /** | ||
| * Hex-encoded SHA-256. Uses Bun's native CryptoHasher when available | ||
| * (per task brief), falls back to a small inline JS implementation if | ||
| * not (so that the package can be consumed by tooling that runs the | ||
| * builder under Node — e.g. doc generators). | ||
| */ | ||
| function hashHex(input: string): string { | ||
| // Bun's CryptoHasher is fastest and matches the task brief's contract. | ||
| type CryptoHasherCtor = new ( | ||
| algo: "sha256", | ||
| ) => { | ||
| update(data: string): void; | ||
| digest(encoding: "hex"): string; | ||
| }; | ||
| const maybeBun = (globalThis as { Bun?: { CryptoHasher?: CryptoHasherCtor } }).Bun; | ||
| if (maybeBun !== undefined && maybeBun.CryptoHasher !== undefined) { | ||
| const h = new maybeBun.CryptoHasher("sha256"); | ||
| h.update(input); | ||
| return h.digest("hex"); | ||
| } | ||
| // Fallback: WebCrypto (sync wouldn't be possible — but the inject build | ||
| // path is invoked from sync paths). Throw a helpful error. | ||
| throw new Error( | ||
| "[mochi/inject] buildPayload requires Bun's CryptoHasher — running outside Bun is unsupported", | ||
| ); | ||
| } |
| /** | ||
| * Spoof module: bot-detection global cleanup. | ||
| * | ||
| * Defensive deletion of automation framework globals that should NOT exist | ||
| * on stock Chromium-for-Testing but show up if the user accidentally launched | ||
| * a CDC-tainted Selenium/Chromedriver build, or if a hostile extension | ||
| * injected them. The list mirrors the sentinel-key catalog in | ||
| * `chaser-recon/src/lib/fingerprint/bot-detection.ts:14-25`. | ||
| * | ||
| * v0.3 deletes them; phase 0.7 may add a per-key getter trap for | ||
| * "delete and observe" detection. The deletion is best-effort (silently | ||
| * ignores TypeErrors from non-configurable globals). | ||
| * | ||
| * The matrix isn't read here — these keys must always be absent regardless | ||
| * of profile. | ||
| * | ||
| * @see tasks/0030-inject-engine-v0.md §"bot-globals.ts" | ||
| */ | ||
| /** | ||
| * The full automation-key catalog. Order doesn't matter; deletion is | ||
| * symmetric. | ||
| */ | ||
| const AUTOMATION_KEYS: readonly string[] = [ | ||
| // Chromedriver/Selenium CDC sentinel keys. | ||
| "cdc_adoQpoasnfa76pfcZLmcfl_Array", | ||
| "cdc_adoQpoasnfa76pfcZLmcfl_Promise", | ||
| "cdc_adoQpoasnfa76pfcZLmcfl_Symbol", | ||
| "$cdc_asdjflasutopfhvcZLmcfl_", | ||
| "$chrome_asyncScriptInfo", | ||
| "__$webdriverAsyncExecutor", | ||
| // PhantomJS / Nightmare / Selenium IDE sentinels. | ||
| "_phantom", | ||
| "__nightmare", | ||
| "_selenium", | ||
| "callPhantom", | ||
| "callSelenium", | ||
| "_Selenium_IDE_Recorder", | ||
| // Headless browser markers. | ||
| "domAutomation", | ||
| "domAutomationController", | ||
| "__webdriver_evaluate", | ||
| "__selenium_evaluate", | ||
| "__webdriver_script_function", | ||
| "__webdriver_script_func", | ||
| "__webdriver_script_fn", | ||
| "__fxdriver_evaluate", | ||
| "__driver_unwrapped", | ||
| "__webdriver_unwrapped", | ||
| "__driver_evaluate", | ||
| "__selenium_unwrapped", | ||
| "__fxdriver_unwrapped", | ||
| "__webdriverFunc", | ||
| ] as const; | ||
| export function emitBotGlobalsModule(): string { | ||
| const keys = JSON.stringify(AUTOMATION_KEYS); | ||
| return ` | ||
| // ---- bot-globals cleanup --------------------------------------------------- | ||
| (function() { | ||
| var KEYS = ${keys}; | ||
| for (var i = 0; i < KEYS.length; i++) { | ||
| var k = KEYS[i]; | ||
| try { | ||
| // Outer scope deletion — \`window\` and \`document\` both checked. | ||
| if (typeof window !== "undefined" && k in window) { | ||
| delete window[k]; | ||
| } | ||
| if (typeof document !== "undefined" && k in document) { | ||
| delete document[k]; | ||
| } | ||
| } catch (_e) { | ||
| // Non-configurable; nothing we can do at JS layer. Caller documents | ||
| // in docs/limits.md if the surface ever exists in stock Chromium-for- | ||
| // Testing. | ||
| } | ||
| } | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: `navigator.userAgentData`. | ||
| * | ||
| * Reads from the matrix: | ||
| * - `matrix.uaCh["sec-ch-ua"]` → brands list (R-005) | ||
| * - `matrix.uaCh["sec-ch-ua-platform"]` → platform (R-006) | ||
| * - `matrix.uaCh["sec-ch-ua-platform-version"]` → platformVersion (R-007) | ||
| * - `matrix.uaCh["sec-ch-ua-arch"]` → arch (optional) | ||
| * - `matrix.uaCh["sec-ch-ua-bitness"]` → bitness (optional) | ||
| * - `matrix.uaCh["sec-ch-ua-model"]` → model (optional) | ||
| * - `matrix.uaCh["sec-ch-ua-mobile"]` → mobile (optional, "?0"/"?1") | ||
| * | ||
| * Sec-CH-UA values arrive on the wire as quoted (e.g. `'"macOS"'`, | ||
| * `'"Google Chrome";v="131", "Not.A/Brand";v="8", "Chromium";v="131"'`). | ||
| * The spoofed `userAgentData` API exposes parsed shapes: | ||
| * - `brands`: array of `{ brand, version }` | ||
| * - `platform`: unquoted string | ||
| * - `mobile`: boolean | ||
| * - `getHighEntropyValues(hints)`: Promise resolving to the requested hints | ||
| * | ||
| * Missing keys → field omitted from the response (PLAN.md I-5). | ||
| * | ||
| * @see tasks/0030-inject-engine-v0.md §"client-hints.ts" | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| interface BrandEntry { | ||
| readonly brand: string; | ||
| readonly version: string; | ||
| } | ||
| /** Strip surrounding double-quotes if present. */ | ||
| function unquote(s: string): string { | ||
| if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) { | ||
| return s.slice(1, -1); | ||
| } | ||
| return s; | ||
| } | ||
| /** | ||
| * Parse a Sec-CH-UA header value into brand entries. Format: | ||
| * `"Brand A";v="123", "Not.A/Brand";v="8", "Brand B";v="456"` | ||
| */ | ||
| function parseSecChUa(s: string): BrandEntry[] { | ||
| const out: BrandEntry[] = []; | ||
| // Split on `,` outside quoted segments. Simple state machine. | ||
| const parts: string[] = []; | ||
| let depth = 0; | ||
| let cur = ""; | ||
| for (let i = 0; i < s.length; i++) { | ||
| const c = s[i] as string; | ||
| if (c === '"') { | ||
| depth = depth === 0 ? 1 : 0; | ||
| cur += c; | ||
| } else if (c === "," && depth === 0) { | ||
| parts.push(cur); | ||
| cur = ""; | ||
| } else { | ||
| cur += c; | ||
| } | ||
| } | ||
| if (cur.length > 0) parts.push(cur); | ||
| for (const raw of parts) { | ||
| const piece = raw.trim(); | ||
| if (piece.length === 0) continue; | ||
| // `"Brand";v="123"` | ||
| const semi = piece.indexOf(";"); | ||
| if (semi === -1) { | ||
| out.push({ brand: unquote(piece), version: "" }); | ||
| continue; | ||
| } | ||
| const brandPart = piece.slice(0, semi).trim(); | ||
| const rest = piece.slice(semi + 1).trim(); | ||
| let version = ""; | ||
| if (rest.startsWith("v=")) { | ||
| version = unquote(rest.slice(2).trim()); | ||
| } | ||
| out.push({ brand: unquote(brandPart), version }); | ||
| } | ||
| return out; | ||
| } | ||
| export function emitClientHintsModule(matrix: MatrixV1): string { | ||
| const ua = matrix.uaCh; | ||
| // Parse the Sec-CH-UA bag. Required for the brands list — without it we | ||
| // skip the whole module. | ||
| const secChUa = ua["sec-ch-ua"]; | ||
| if (typeof secChUa !== "string" || secChUa.length === 0) { | ||
| return ` | ||
| // ---- client-hints spoof (skipped — no matrix.uaCh["sec-ch-ua"]) ----------- | ||
| `; | ||
| } | ||
| const brands = parseSecChUa(secChUa); | ||
| const platformRaw = ua["sec-ch-ua-platform"]; | ||
| const platform = typeof platformRaw === "string" ? unquote(platformRaw) : ""; | ||
| const platformVersionRaw = ua["sec-ch-ua-platform-version"]; | ||
| const platformVersion = typeof platformVersionRaw === "string" ? unquote(platformVersionRaw) : ""; | ||
| // Optional high-entropy fields. | ||
| const arch = typeof ua["sec-ch-ua-arch"] === "string" ? unquote(ua["sec-ch-ua-arch"]) : ""; | ||
| const bitness = | ||
| typeof ua["sec-ch-ua-bitness"] === "string" ? unquote(ua["sec-ch-ua-bitness"]) : ""; | ||
| const model = typeof ua["sec-ch-ua-model"] === "string" ? unquote(ua["sec-ch-ua-model"]) : ""; | ||
| const mobileRaw = ua["sec-ch-ua-mobile"]; | ||
| // sec-ch-ua-mobile is "?0" or "?1" on the wire. | ||
| const mobile = mobileRaw === "?1"; | ||
| // R-031 emits a tip-locked full brand list (e.g. `"147.0.7727.138"`) under | ||
| // `uaCh.ua-full-version-list` — match what | ||
| // `userAgentData.getHighEntropyValues(["fullVersionList"])` returns on | ||
| // captured-device baselines. Fall back to `brands` (brand-list majors) | ||
| // when the tip table doesn't carry the major. | ||
| const fullVersionListRaw = ua["ua-full-version-list"]; | ||
| let fullVersionList: BrandEntry[] = brands; | ||
| if (typeof fullVersionListRaw === "string" && fullVersionListRaw.length > 0) { | ||
| try { | ||
| const parsed = JSON.parse(fullVersionListRaw) as unknown; | ||
| if (Array.isArray(parsed)) { | ||
| fullVersionList = parsed | ||
| .filter( | ||
| (e): e is BrandEntry => | ||
| typeof e === "object" && | ||
| e !== null && | ||
| typeof (e as { brand?: unknown }).brand === "string" && | ||
| typeof (e as { version?: unknown }).version === "string", | ||
| ) | ||
| .map((e) => ({ brand: e.brand, version: e.version })); | ||
| } | ||
| } catch { | ||
| // Fall through to brands. | ||
| } | ||
| } | ||
| const brandsLiteral = JSON.stringify(brands); | ||
| const fullVersionListLiteral = JSON.stringify(fullVersionList); | ||
| return ` | ||
| // ---- client-hints spoof ---------------------------------------------------- | ||
| (function() { | ||
| if (typeof navigator === "undefined") return; | ||
| var SPOOF_BRANDS = ${brandsLiteral}; | ||
| var SPOOF_FULL_VERSION_LIST = ${fullVersionListLiteral}; | ||
| var SPOOF_PLATFORM = ${JSON.stringify(platform)}; | ||
| var SPOOF_PLATFORM_VERSION = ${JSON.stringify(platformVersion)}; | ||
| var SPOOF_ARCH = ${JSON.stringify(arch)}; | ||
| var SPOOF_BITNESS = ${JSON.stringify(bitness)}; | ||
| var SPOOF_MODEL = ${JSON.stringify(model)}; | ||
| var SPOOF_MOBILE = ${mobile ? "true" : "false"}; | ||
| var SPOOF_UA_FULL_VERSION = (SPOOF_FULL_VERSION_LIST[0] && SPOOF_FULL_VERSION_LIST[0].version) || ""; | ||
| // Re-freeze brand entries on every read (Chrome returns frozen objects). | ||
| function freezeBrands(arr) { | ||
| var out = []; | ||
| for (var i = 0; i < arr.length; i++) { | ||
| out.push(Object.freeze({ brand: arr[i].brand, version: arr[i].version })); | ||
| } | ||
| return Object.freeze(out); | ||
| } | ||
| function toJSON() { | ||
| return { brands: freezeBrands(SPOOF_BRANDS), mobile: SPOOF_MOBILE, platform: SPOOF_PLATFORM }; | ||
| } | ||
| __mochi_register_native__(toJSON, "toJSON"); | ||
| function getHighEntropyValues(hints) { | ||
| return new Promise(function(resolve) { | ||
| var out = { | ||
| brands: freezeBrands(SPOOF_BRANDS), | ||
| mobile: SPOOF_MOBILE, | ||
| platform: SPOOF_PLATFORM, | ||
| }; | ||
| if (Array.isArray(hints)) { | ||
| for (var i = 0; i < hints.length; i++) { | ||
| var h = hints[i]; | ||
| if (h === "architecture" && SPOOF_ARCH) out.architecture = SPOOF_ARCH; | ||
| else if (h === "bitness" && SPOOF_BITNESS) out.bitness = SPOOF_BITNESS; | ||
| else if (h === "model") out.model = SPOOF_MODEL; | ||
| else if (h === "platformVersion") out.platformVersion = SPOOF_PLATFORM_VERSION; | ||
| else if (h === "uaFullVersion" && SPOOF_UA_FULL_VERSION) out.uaFullVersion = SPOOF_UA_FULL_VERSION; | ||
| else if (h === "fullVersionList") out.fullVersionList = freezeBrands(SPOOF_FULL_VERSION_LIST); | ||
| else if (h === "wow64") out.wow64 = false; | ||
| else if (h === "formFactor" && SPOOF_MOBILE) out.formFactor = ["Mobile"]; | ||
| } | ||
| } | ||
| resolve(out); | ||
| }); | ||
| } | ||
| __mochi_register_native__(getHighEntropyValues, "getHighEntropyValues"); | ||
| // Build a userAgentData object. Match the live Chrome shape: brands, | ||
| // mobile, platform are direct accessors; toJSON + getHighEntropyValues | ||
| // are methods. | ||
| var spoof = Object.create(null); | ||
| __mochi_defineProperty__(spoof, "brands", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get: function() { return freezeBrands(SPOOF_BRANDS); }, | ||
| }); | ||
| __mochi_defineProperty__(spoof, "mobile", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get: function() { return SPOOF_MOBILE; }, | ||
| }); | ||
| __mochi_defineProperty__(spoof, "platform", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get: function() { return SPOOF_PLATFORM; }, | ||
| }); | ||
| __mochi_defineProperty__(spoof, "toJSON", { | ||
| configurable: true, enumerable: false, writable: true, value: toJSON, | ||
| }); | ||
| __mochi_defineProperty__(spoof, "getHighEntropyValues", { | ||
| configurable: true, enumerable: false, writable: true, value: getHighEntropyValues, | ||
| }); | ||
| // Install on Navigator.prototype so .userAgentData reads return our spoof. | ||
| var navProto = __mochi_getPrototypeOf__(navigator); | ||
| __mochi_define__(navProto, "userAgentData", spoof); | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: font enumeration. | ||
| * | ||
| * Reads from the matrix: | ||
| * - `matrix.fonts.list` (R-013) — curated baseline list per OS. | ||
| * | ||
| * v0.3 surface: | ||
| * - Override `document.fonts` iteration so that `Array.from(document.fonts)` | ||
| * and `for…of (document.fonts)` enumerate exactly the matrix list. | ||
| * - Override `document.fonts.size` to match. | ||
| * - Override `document.fonts.check(spec)` to return true iff the family | ||
| * parsed from `spec` is in the matrix list. | ||
| * | ||
| * Limitations (documented in docs/limits.md): | ||
| * - We don't actually load any FontFace; CSS rendering with these | ||
| * families still uses whatever Chromium has on disk. Probes that | ||
| * measure rendered glyph bbox / canvas-rendered fonts will see the | ||
| * real font outlines, not the spoofed list. Phase 0.7 lands canvas | ||
| * spoofing which closes the loop. | ||
| * - We don't override the experimental `queryLocalFonts()` API. | ||
| * | ||
| * @see tasks/0030-inject-engine-v0.md §"fonts.ts" | ||
| * @see docs/limits.md §"v0.3 inject limits" | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| export function emitFontsModule(matrix: MatrixV1): string { | ||
| // Build a literal of synthetic FontFace-shape entries. Each carries the | ||
| // family + weight/style defaults the FontFaceSet APIs query. | ||
| const entries = matrix.fonts.list.map((family) => ({ | ||
| family, | ||
| style: "normal", | ||
| weight: "400", | ||
| stretch: "100%", | ||
| unicodeRange: "U+0-10FFFF", | ||
| variant: "normal", | ||
| featureSettings: "normal", | ||
| display: "auto", | ||
| status: "loaded", | ||
| })); | ||
| const entriesLiteral = JSON.stringify(entries); | ||
| const familySetLiteral = JSON.stringify(matrix.fonts.list); | ||
| return ` | ||
| // ---- fonts spoof ----------------------------------------------------------- | ||
| (function() { | ||
| if (typeof document === "undefined") return; | ||
| var fonts = document.fonts; | ||
| if (fonts === undefined || fonts === null) return; | ||
| var SPOOF_ENTRIES = ${entriesLiteral}; | ||
| var SPOOF_FAMILIES = ${familySetLiteral}; | ||
| // Lowercase set for case-insensitive family matching. | ||
| var SPOOF_FAMILIES_LC = {}; | ||
| for (var i = 0; i < SPOOF_FAMILIES.length; i++) { | ||
| SPOOF_FAMILIES_LC[String(SPOOF_FAMILIES[i]).toLowerCase()] = true; | ||
| } | ||
| // Build FontFace-shape stand-ins. We don't construct real FontFace | ||
| // instances (the constructor takes a font URL we don't have); a plain | ||
| // object with the right keys is enough for fingerprint enumeration. | ||
| function buildFakeFonts() { | ||
| var out = []; | ||
| for (var i = 0; i < SPOOF_ENTRIES.length; i++) { | ||
| var e = SPOOF_ENTRIES[i]; | ||
| // Prefer the real FontFace prototype if available, but with our data. | ||
| var f = Object.create(typeof FontFace !== "undefined" ? FontFace.prototype : Object.prototype); | ||
| for (var k in e) { | ||
| if (Object.prototype.hasOwnProperty.call(e, k)) { | ||
| try { | ||
| __mochi_defineProperty__(f, k, { | ||
| configurable: true, enumerable: true, get: (function(v) { return function() { return v; }; })(e[k]), | ||
| }); | ||
| } catch (_err) {} | ||
| } | ||
| } | ||
| out.push(f); | ||
| } | ||
| return out; | ||
| } | ||
| var fakeList = buildFakeFonts(); | ||
| // Replace [Symbol.iterator] on the FontFaceSet so for…of enumerates ours. | ||
| function fontIterator() { | ||
| var idx = 0; | ||
| return { | ||
| next: function() { | ||
| if (idx < fakeList.length) { | ||
| return { value: fakeList[idx++], done: false }; | ||
| } | ||
| return { value: undefined, done: true }; | ||
| }, | ||
| // Iterators in JS must themselves be iterable. | ||
| // (Function.prototype[Symbol.iterator] won't help; we add one.) | ||
| }; | ||
| } | ||
| // FontFaceSet has [Symbol.iterator], values(), keys(), entries(), forEach, | ||
| // size, check(), load(). v0.3 covers the enumeration surface plus check; | ||
| // load() falls through to native (it's about loading remote fonts, not | ||
| // fingerprinting). | ||
| try { | ||
| var iterFn = function() { | ||
| var it = fontIterator(); | ||
| // Make the iterator iterable (per JS spec it should be self-iterable). | ||
| it[Symbol.iterator] = function() { return it; }; | ||
| return it; | ||
| }; | ||
| __mochi_register_native__(iterFn, "[Symbol.iterator]"); | ||
| __mochi_defineProperty__(fonts, Symbol.iterator, { | ||
| configurable: true, enumerable: false, writable: true, value: iterFn, | ||
| }); | ||
| } catch (_e) {} | ||
| // size getter. | ||
| try { | ||
| __mochi_defineProperty__(fonts, "size", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| get: function() { return fakeList.length; }, | ||
| }); | ||
| } catch (_e) {} | ||
| // forEach — emit each fake entry once. | ||
| function forEach(cb, thisArg) { | ||
| if (typeof cb !== "function") return; | ||
| for (var i = 0; i < fakeList.length; i++) { | ||
| cb.call(thisArg, fakeList[i], fakeList[i], fonts); | ||
| } | ||
| } | ||
| __mochi_register_native__(forEach, "forEach"); | ||
| try { | ||
| __mochi_defineProperty__(fonts, "forEach", { | ||
| configurable: true, enumerable: false, writable: true, value: forEach, | ||
| }); | ||
| } catch (_e) {} | ||
| // check(spec) — parses the family from a CSS font shorthand and answers | ||
| // true iff that family appears in the spoofed list. Heuristic: take the | ||
| // last non-numeric, non-keyword token as the family. Good enough for | ||
| // common probe shapes like "12px Arial" or "16px 'Comic Sans MS'". | ||
| var origCheck = fonts.check; | ||
| function check(spec, _text) { | ||
| try { | ||
| if (typeof spec !== "string") return false; | ||
| // Strip quoted family if present. | ||
| var m = spec.match(/['"]([^'"]+)['"]\\s*$/); | ||
| var family; | ||
| if (m !== null) { | ||
| family = m[1]; | ||
| } else { | ||
| // Last whitespace-separated token. | ||
| var toks = spec.split(/\\s+/); | ||
| family = toks.length > 0 ? toks[toks.length - 1] : ""; | ||
| } | ||
| return !!SPOOF_FAMILIES_LC[String(family).toLowerCase()]; | ||
| } catch (_e) { | ||
| // Fall back to native if our parser failed. | ||
| if (typeof origCheck === "function") { | ||
| return __mochi_apply__.call(origCheck, fonts, [spec, _text]); | ||
| } | ||
| return false; | ||
| } | ||
| } | ||
| __mochi_register_native__(check, "check"); | ||
| try { | ||
| __mochi_defineProperty__(fonts, "check", { | ||
| configurable: true, enumerable: false, writable: true, value: check, | ||
| }); | ||
| } catch (_e) {} | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: `navigator.mediaDevices.{enumerateDevices,getSupportedConstraints}`. | ||
| * | ||
| * Reads from the matrix: | ||
| * - `matrix.uaCh["media-devices"]` (R-034) — JSON `[{kind,label}, ...]` | ||
| * - `matrix.uaCh["media-supported-constraints"]` (R-035) — JSON map | ||
| * | ||
| * `deviceId` and `groupId` MUST be deterministic per `(profile, seed)`. We | ||
| * derive them via SHA-256(`<profile.id>:<seed>:mediaDevices:<index>:<kind>`) | ||
| * truncated to 32 hex chars. This is computed at build time (Bun has | ||
| * `crypto.subtle`) and embedded in the payload so the payload itself is | ||
| * still byte-stable per (profile, seed). | ||
| * | ||
| * @see PLAN.md §9.5 | ||
| * @see tasks/0070-consistency-rules-full.md (media-devices) | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| interface DeviceShape { | ||
| readonly kind: "audioinput" | "audiooutput" | "videoinput"; | ||
| readonly label: string; | ||
| } | ||
| function tryParse<T>(s: unknown): T | null { | ||
| if (typeof s !== "string" || s.length === 0) return null; | ||
| try { | ||
| return JSON.parse(s) as T; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| /** | ||
| * Compute a deterministic 32-hex-char ID from a key string. Uses Bun's | ||
| * `CryptoHasher` (sync) when available; matches the SHA-256 path used by | ||
| * the inject payload's builder. | ||
| */ | ||
| function sha256Hex(input: string): string { | ||
| type CryptoHasherCtor = new ( | ||
| algo: "sha256", | ||
| ) => { | ||
| update(data: string): void; | ||
| digest(encoding: "hex"): string; | ||
| }; | ||
| const maybeBun = (globalThis as { Bun?: { CryptoHasher?: CryptoHasherCtor } }).Bun; | ||
| if (maybeBun !== undefined && maybeBun.CryptoHasher !== undefined) { | ||
| const h = new maybeBun.CryptoHasher("sha256"); | ||
| h.update(input); | ||
| return h.digest("hex"); | ||
| } | ||
| throw new Error("[mochi/inject] media-devices requires Bun CryptoHasher"); | ||
| } | ||
| export function emitMediaDevicesModule(matrix: MatrixV1): string { | ||
| const devices = tryParse<readonly DeviceShape[]>(matrix.uaCh["media-devices"]) ?? []; | ||
| const constraints = | ||
| tryParse<Record<string, true>>(matrix.uaCh["media-supported-constraints"]) ?? {}; | ||
| if (devices.length === 0 && Object.keys(constraints).length === 0) { | ||
| return ` | ||
| // ---- mediaDevices spoof (skipped — no matrix.uaCh["media-*"]) ------------- | ||
| `; | ||
| } | ||
| // Derive deterministic IDs at build time. The harness's normalize layer | ||
| // sentinelizes mediaDevices.devices[*].deviceId/groupId, so the exact | ||
| // bytes don't need to match across runs — they just need to be | ||
| // structurally consistent and seed-stable. | ||
| const profileBase = `${matrix.id}:${matrix.seed}:mediaDevices`; | ||
| const enriched = devices.map((d, i) => { | ||
| const deviceId = sha256Hex(`${profileBase}:${i}:${d.kind}:deviceId`).slice(0, 64); | ||
| const groupId = sha256Hex(`${profileBase}:${i}:${d.kind}:groupId`).slice(0, 64); | ||
| return { kind: d.kind, label: d.label, deviceId, groupId }; | ||
| }); | ||
| const devicesLiteral = JSON.stringify(enriched); | ||
| const constraintsLiteral = JSON.stringify(constraints); | ||
| return ` | ||
| // ---- mediaDevices spoof ---------------------------------------------------- | ||
| (function() { | ||
| if (typeof navigator === "undefined") return; | ||
| var md = navigator.mediaDevices; | ||
| if (md === undefined || md === null) return; | ||
| var SPOOF_DEVICES = ${devicesLiteral}; | ||
| var SPOOF_CONSTRAINTS = ${constraintsLiteral}; | ||
| // Build MediaDeviceInfo-shape stand-ins. Real Chrome's MediaDeviceInfo | ||
| // exposes deviceId/kind/label/groupId via prototype getters; a plain | ||
| // frozen object with the same keys reads identically through probe code. | ||
| function buildDeviceInfos() { | ||
| var out = []; | ||
| for (var i = 0; i < SPOOF_DEVICES.length; i++) { | ||
| var s = SPOOF_DEVICES[i]; | ||
| out.push(Object.freeze({ | ||
| deviceId: s.deviceId, | ||
| kind: s.kind, | ||
| label: s.label, | ||
| groupId: s.groupId, | ||
| toJSON: function() { | ||
| return { deviceId: this.deviceId, kind: this.kind, label: this.label, groupId: this.groupId }; | ||
| }, | ||
| })); | ||
| } | ||
| return out; | ||
| } | ||
| function enumerateDevices() { | ||
| return Promise.resolve(buildDeviceInfos()); | ||
| } | ||
| __mochi_register_native__(enumerateDevices, "enumerateDevices"); | ||
| function getSupportedConstraints() { | ||
| // Return a frozen map matching the captured shape. | ||
| var out = {}; | ||
| for (var k in SPOOF_CONSTRAINTS) { | ||
| if (Object.prototype.hasOwnProperty.call(SPOOF_CONSTRAINTS, k)) out[k] = true; | ||
| } | ||
| return out; | ||
| } | ||
| __mochi_register_native__(getSupportedConstraints, "getSupportedConstraints"); | ||
| // Patch on the MediaDevices prototype if reachable (matches Chrome's | ||
| // native slot layout). Fall back to the instance otherwise. | ||
| var proto = __mochi_getPrototypeOf__(md); | ||
| var target = proto !== null && proto !== undefined && typeof proto.enumerateDevices === "function" | ||
| ? proto | ||
| : md; | ||
| try { | ||
| __mochi_defineProperty__(target, "enumerateDevices", { | ||
| configurable: true, enumerable: false, writable: true, value: enumerateDevices, | ||
| }); | ||
| } catch (_e) {} | ||
| try { | ||
| __mochi_defineProperty__(target, "getSupportedConstraints", { | ||
| configurable: true, enumerable: false, writable: true, value: getSupportedConstraints, | ||
| }); | ||
| } catch (_e) {} | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: `navigator.*`. | ||
| * | ||
| * Reads from the matrix: | ||
| * - `matrix.userAgent` → navigator.userAgent | ||
| * - `matrix.uaCh["navigator-platform"]` → navigator.platform | ||
| * - `matrix.uaCh["navigator-vendor"]` → navigator.vendor | ||
| * - `matrix.uaCh["navigator-appVersion"]` → navigator.appVersion | ||
| * - `matrix.uaCh["navigator-appCodeName"]` → navigator.appCodeName | ||
| * - `matrix.uaCh["navigator-product"]` → navigator.product | ||
| * - `matrix.uaCh["navigator-cookieEnabled"]` → navigator.cookieEnabled (string "true"/"false") | ||
| * - `matrix.uaCh["navigator-maxTouchPoints"]` → navigator.maxTouchPoints (string "0") | ||
| * - `matrix.uaCh["navigator-webdriver"]` → navigator.webdriver (string "true"/"false") | ||
| * - `matrix.device.cores` → navigator.hardwareConcurrency | ||
| * - `matrix.device.memoryGB` → navigator.deviceMemory | ||
| * - `matrix.locale` → navigator.language | ||
| * - `matrix.languages` → navigator.languages | ||
| * | ||
| * The uaCh bag stores values as strings (PLAN.md §6.1 — open-keyed string | ||
| * map). Where the spoofed property is logically a boolean or number, this | ||
| * module parses the string at build time. If a key is missing, that | ||
| * particular property override is skipped — the bare browser value remains | ||
| * (PLAN.md I-5: never invent values). | ||
| * | ||
| * @see tasks/0030-inject-engine-v0.md §"navigator.ts" | ||
| * @see PLAN.md §5.3 | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| /** | ||
| * Build the navigator-spoof JS snippet for this matrix. The returned source | ||
| * runs inside the master IIFE *after* the runtime helpers are installed. | ||
| */ | ||
| export function emitNavigatorModule(matrix: MatrixV1): string { | ||
| const ua = matrix.uaCh; | ||
| const lines: string[] = []; | ||
| // navigator.userAgent — always present (top-level matrix slot). | ||
| lines.push(line("userAgent", JSON.stringify(matrix.userAgent))); | ||
| // navigator.appVersion — UA without leading "Mozilla/" (R-026). | ||
| const appVersion = ua["navigator-appVersion"]; | ||
| if (typeof appVersion === "string") { | ||
| lines.push(line("appVersion", JSON.stringify(appVersion))); | ||
| } | ||
| // navigator.platform — "MacIntel"/"Win32"/"Linux x86_64" (R-017). | ||
| const platform = ua["navigator-platform"]; | ||
| if (typeof platform === "string") { | ||
| lines.push(line("platform", JSON.stringify(platform))); | ||
| } | ||
| // navigator.vendor — "Google Inc." for chromium-family (R-018). | ||
| const vendor = ua["navigator-vendor"]; | ||
| if (typeof vendor === "string") { | ||
| lines.push(line("vendor", JSON.stringify(vendor))); | ||
| } | ||
| // navigator.appCodeName — "Mozilla" universally (R-027). | ||
| const appCodeName = ua["navigator-appCodeName"]; | ||
| if (typeof appCodeName === "string") { | ||
| lines.push(line("appCodeName", JSON.stringify(appCodeName))); | ||
| } | ||
| // navigator.product — "Gecko" universally (R-028). | ||
| const product = ua["navigator-product"]; | ||
| if (typeof product === "string") { | ||
| lines.push(line("product", JSON.stringify(product))); | ||
| } | ||
| // navigator.cookieEnabled — boolean (R-030). | ||
| const cookieEnabled = ua["navigator-cookieEnabled"]; | ||
| if (typeof cookieEnabled === "string") { | ||
| const b = cookieEnabled === "true"; | ||
| lines.push(line("cookieEnabled", b ? "true" : "false")); | ||
| } | ||
| // navigator.maxTouchPoints — number (R-020). | ||
| const maxTouchPoints = ua["navigator-maxTouchPoints"]; | ||
| if (typeof maxTouchPoints === "string") { | ||
| const n = Number.parseInt(maxTouchPoints, 10); | ||
| if (Number.isFinite(n)) { | ||
| lines.push(line("maxTouchPoints", String(n))); | ||
| } | ||
| } | ||
| // navigator.webdriver — boolean (R-022). Always returns false on real Chrome. | ||
| const webdriver = ua["navigator-webdriver"]; | ||
| if (typeof webdriver === "string") { | ||
| const b = webdriver === "true"; | ||
| lines.push(line("webdriver", b ? "true" : "false")); | ||
| } | ||
| // navigator.hardwareConcurrency — number (R-008). Use device.cores. | ||
| lines.push(line("hardwareConcurrency", String(matrix.device.cores))); | ||
| // navigator.deviceMemory — number (R-009). Capped at 8. | ||
| lines.push(line("deviceMemory", String(matrix.device.memoryGB))); | ||
| // navigator.language — string (R-015). | ||
| lines.push(line("language", JSON.stringify(matrix.locale))); | ||
| // navigator.languages — frozen array (R-016). Use a fresh frozen Array | ||
| // each call so page code can't mutate it; matches Chrome's behaviour | ||
| // (Chrome returns an array reference but the slot is a getter so each | ||
| // access returns a fresh array; we mimic that). | ||
| const langsLiteral = `Object.freeze([${matrix.languages | ||
| .map((l) => JSON.stringify(l)) | ||
| .join(",")}])`; | ||
| lines.push(line("languages", langsLiteral)); | ||
| return ` | ||
| // ---- navigator spoof ------------------------------------------------------- | ||
| (function() { | ||
| var __nav__ = navigator; | ||
| var __navProto__ = __mochi_getPrototypeOf__(__nav__); | ||
| ${lines.join("\n")} | ||
| })(); | ||
| `; | ||
| } | ||
| /** | ||
| * Helper: emit one defineProperty call against `__nav__` AND its prototype, | ||
| * mirroring Chrome's slot layout (most navigator properties live on the | ||
| * Navigator.prototype, but page script reads them from the instance — so | ||
| * defining only the prototype is safe and matches native shape). | ||
| * | ||
| * We define on the prototype because navigator's own descriptor is | ||
| * empty for these properties — they're all inherited. Defining on the | ||
| * instance directly creates a "shadowing" own property that fingerprint | ||
| * libraries can detect via `Object.getOwnPropertyNames(navigator)`. | ||
| * | ||
| * Caveat: `__mochi_define__` already walks the prototype chain to find the | ||
| * descriptor's enumerability, so passing `__navProto__` keeps the slot on | ||
| * the prototype where it belongs. | ||
| */ | ||
| function line(prop: string, valueExpr: string): string { | ||
| return ` __mochi_define__(__navProto__, ${JSON.stringify(prop)}, ${valueExpr});`; | ||
| } |
| /** | ||
| * Spoof module: `navigator.connection` (Network Information API). | ||
| * | ||
| * Reads from the matrix: | ||
| * - `matrix.uaCh["connection"]` (R-037) — JSON | ||
| * `{effectiveType, downlink, rtt, saveData}`. | ||
| * | ||
| * Defines `navigator.connection.{effectiveType, downlink, rtt, saveData, | ||
| * type}` so that probes pulling these values get matrix-locked answers. | ||
| * Chrome's native `connection` is a `NetworkInformation` instance — we | ||
| * don't reconstruct the full prototype; the probe page only reads the | ||
| * four (now five) properties below. | ||
| * | ||
| * @see tasks/0070-consistency-rules-full.md (network-info) | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| interface Connection { | ||
| readonly effectiveType?: string; | ||
| readonly downlink?: number; | ||
| readonly rtt?: number; | ||
| readonly saveData?: boolean; | ||
| } | ||
| function tryParse<T>(s: unknown): T | null { | ||
| if (typeof s !== "string" || s.length === 0) return null; | ||
| try { | ||
| return JSON.parse(s) as T; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| export function emitNetworkInfoModule(matrix: MatrixV1): string { | ||
| const conn = tryParse<Connection>(matrix.uaCh.connection) ?? {}; | ||
| if ( | ||
| conn.effectiveType === undefined && | ||
| conn.downlink === undefined && | ||
| conn.rtt === undefined && | ||
| conn.saveData === undefined | ||
| ) { | ||
| return ` | ||
| // ---- network-info spoof (skipped — no matrix.uaCh["connection"]) ---------- | ||
| `; | ||
| } | ||
| const effectiveType = conn.effectiveType ?? "4g"; | ||
| const downlink = typeof conn.downlink === "number" ? conn.downlink : 10; | ||
| const rtt = typeof conn.rtt === "number" ? conn.rtt : 50; | ||
| const saveData = conn.saveData === true; | ||
| return ` | ||
| // ---- network-info spoof ---------------------------------------------------- | ||
| (function() { | ||
| if (typeof navigator === "undefined") return; | ||
| var c = navigator.connection; | ||
| if (c === undefined || c === null) return; | ||
| // The values are accessor-style on real Chrome's NetworkInformation | ||
| // prototype; redefining on the instance is the simplest faithful match. | ||
| // configurable:false matches __mochi_define__'s contract. | ||
| __mochi_define__(c, "effectiveType", ${JSON.stringify(effectiveType)}); | ||
| __mochi_define__(c, "downlink", ${downlink}); | ||
| __mochi_define__(c, "rtt", ${rtt}); | ||
| __mochi_define__(c, "saveData", ${saveData ? "true" : "false"}); | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: `Permissions.prototype.query({name})`. | ||
| * | ||
| * Reads from the matrix: | ||
| * - `matrix.uaCh["permissions-defaults"]` (R-036) — JSON map of | ||
| * `<name>` → `"granted" | "prompt" | "denied"`. | ||
| * | ||
| * Replaces `Permissions.prototype.query` with a wrapper that returns a | ||
| * Promise resolving to a `PermissionStatus`-shape object whose `state` | ||
| * property comes from the matrix map. Names not in the map fall through | ||
| * to the original (preserves Chrome's "unsupported permission" behaviour). | ||
| * | ||
| * The returned `PermissionStatus` exposes `.state`, `.name`, and | ||
| * `.onchange = null` to satisfy the probe-page surface. | ||
| * | ||
| * @see PLAN.md §9.5 | ||
| * @see tasks/0070-consistency-rules-full.md (permissions) | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| function tryParse<T>(s: unknown): T | null { | ||
| if (typeof s !== "string" || s.length === 0) return null; | ||
| try { | ||
| return JSON.parse(s) as T; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| export function emitPermissionsModule(matrix: MatrixV1): string { | ||
| const defaults = tryParse<Record<string, string>>(matrix.uaCh["permissions-defaults"]) ?? {}; | ||
| if (Object.keys(defaults).length === 0) { | ||
| return ` | ||
| // ---- permissions spoof (skipped — no matrix.uaCh["permissions-defaults"]) - | ||
| `; | ||
| } | ||
| const defaultsLiteral = JSON.stringify(defaults); | ||
| return ` | ||
| // ---- permissions spoof ----------------------------------------------------- | ||
| (function() { | ||
| if (typeof navigator === "undefined") return; | ||
| var perms = navigator.permissions; | ||
| if (perms === undefined || perms === null) return; | ||
| if (typeof Permissions === "undefined") return; | ||
| var proto = Permissions.prototype; | ||
| if (proto === undefined || proto === null) return; | ||
| var orig = proto.query; | ||
| if (typeof orig !== "function") return; | ||
| var SPOOF_DEFAULTS = ${defaultsLiteral}; | ||
| function makeStatus(name, state) { | ||
| // PermissionStatus is an EventTarget — we don't recreate the full | ||
| // prototype, but the probe-page only reads .state. Add .name + | ||
| // .onchange = null so anti-bot heuristics that check for those see | ||
| // them. | ||
| var status = Object.create(null); | ||
| Object.defineProperty(status, "state", { | ||
| configurable: true, enumerable: true, get: function() { return state; }, | ||
| }); | ||
| Object.defineProperty(status, "name", { | ||
| configurable: true, enumerable: true, get: function() { return name; }, | ||
| }); | ||
| status.onchange = null; | ||
| status.addEventListener = function() {}; | ||
| status.removeEventListener = function() {}; | ||
| status.dispatchEvent = function() { return true; }; | ||
| return status; | ||
| } | ||
| function query(descriptor) { | ||
| try { | ||
| var name = descriptor && descriptor.name; | ||
| if (typeof name === "string" && Object.prototype.hasOwnProperty.call(SPOOF_DEFAULTS, name)) { | ||
| return Promise.resolve(makeStatus(name, SPOOF_DEFAULTS[name])); | ||
| } | ||
| } catch (_e) {} | ||
| // Fall through to native — preserves "unsupported permission" rejection. | ||
| return __mochi_apply__.call(orig, this, [descriptor]); | ||
| } | ||
| __mochi_register_native__(query, "query"); | ||
| try { | ||
| __mochi_defineProperty__(proto, "query", { | ||
| configurable: true, enumerable: false, writable: true, value: query, | ||
| }); | ||
| } catch (_e) {} | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: `navigator.plugins` + `navigator.mimeTypes`. | ||
| * | ||
| * Real Chrome 92+ ships a curated 5-plugin PluginArray (see | ||
| * `packages/profiles/data/<id>/baseline.manifest.json` `navigator.plugins`): | ||
| * | ||
| * 1. PDF Viewer internal-pdf-viewer application/pdf, text/pdf | ||
| * 2. Chrome PDF Viewer internal-pdf-viewer application/pdf, text/pdf | ||
| * 3. Chromium PDF Viewer internal-pdf-viewer application/pdf, text/pdf | ||
| * 4. Microsoft Edge PDF internal-pdf-viewer application/pdf, text/pdf | ||
| * 5. WebKit built-in PDF internal-pdf-viewer application/pdf, text/pdf | ||
| * | ||
| * Stock Chromium-for-Testing builds may report `plugins.length === 0` | ||
| * depending on flag set, which CloakBrowser's `test_plugins_present` | ||
| * (line 52) flags as automation. The shim is **defensive** — we only | ||
| * install our PluginArray if the underlying browser reports an empty | ||
| * list. When the underlying browser already exposes ≥ 5 plugins (the | ||
| * common case on real Chrome.app), we leave them alone so the existing | ||
| * Probe Manifest Zero-Diff gate doesn't regress. | ||
| * | ||
| * The shim builds a frozen array-like object with: | ||
| * - `.length`, integer-keyed access, `Symbol.iterator` | ||
| * - `namedItem(name)`, `item(idx)` — PluginArray API surface | ||
| * - per-plugin `.length`, `.namedItem`, `.item` — Plugin API surface | ||
| * - per-mimetype `application/pdf` / `text/pdf` entries with `.enabledPlugin` | ||
| * | ||
| * Future: we may key the catalog off `matrix.os.name` to vary plugins | ||
| * per OS (Windows / Linux Chrome ship the same 5 in 2026), but for v1 | ||
| * the catalog is profile-invariant. | ||
| * | ||
| * @see CloakBrowser tests/test_stealth.py:52-56 | ||
| * @see packages/profiles/data/mac-m4-chrome-stable/baseline.manifest.json | ||
| * @see tasks/0140-stealth-conformance.md §"plugins spoof" | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| interface PluginShape { | ||
| readonly name: string; | ||
| readonly filename: string; | ||
| readonly description: string; | ||
| readonly mimeTypes: readonly { readonly type: string; readonly suffixes: string }[]; | ||
| } | ||
| /** | ||
| * Curated catalog. Mirrors the baseline-captured list in | ||
| * `packages/profiles/data/mac-m4-chrome-stable/baseline.manifest.json`. | ||
| * | ||
| * The mime-type list is the deduplicated union across all 5 plugins — | ||
| * real Chrome's `navigator.mimeTypes` shows 2 entries (`application/pdf`, | ||
| * `text/pdf`), each pointing to the first plugin that registered it. | ||
| */ | ||
| const CHROMIUM_PLUGINS: readonly PluginShape[] = [ | ||
| { | ||
| name: "PDF Viewer", | ||
| filename: "internal-pdf-viewer", | ||
| description: "Portable Document Format", | ||
| mimeTypes: [ | ||
| { type: "application/pdf", suffixes: "pdf" }, | ||
| { type: "text/pdf", suffixes: "pdf" }, | ||
| ], | ||
| }, | ||
| { | ||
| name: "Chrome PDF Viewer", | ||
| filename: "internal-pdf-viewer", | ||
| description: "Portable Document Format", | ||
| mimeTypes: [ | ||
| { type: "application/pdf", suffixes: "pdf" }, | ||
| { type: "text/pdf", suffixes: "pdf" }, | ||
| ], | ||
| }, | ||
| { | ||
| name: "Chromium PDF Viewer", | ||
| filename: "internal-pdf-viewer", | ||
| description: "Portable Document Format", | ||
| mimeTypes: [ | ||
| { type: "application/pdf", suffixes: "pdf" }, | ||
| { type: "text/pdf", suffixes: "pdf" }, | ||
| ], | ||
| }, | ||
| { | ||
| name: "Microsoft Edge PDF Viewer", | ||
| filename: "internal-pdf-viewer", | ||
| description: "Portable Document Format", | ||
| mimeTypes: [ | ||
| { type: "application/pdf", suffixes: "pdf" }, | ||
| { type: "text/pdf", suffixes: "pdf" }, | ||
| ], | ||
| }, | ||
| { | ||
| name: "WebKit built-in PDF", | ||
| filename: "internal-pdf-viewer", | ||
| description: "Portable Document Format", | ||
| mimeTypes: [ | ||
| { type: "application/pdf", suffixes: "pdf" }, | ||
| { type: "text/pdf", suffixes: "pdf" }, | ||
| ], | ||
| }, | ||
| ] as const; | ||
| export function emitPluginsModule(matrix: MatrixV1): string { | ||
| if (matrix.engine !== "chromium") { | ||
| return `\n// ---- plugins spoof (skipped — non-chromium engine) -----------------------\n`; | ||
| } | ||
| const pluginsLiteral = JSON.stringify(CHROMIUM_PLUGINS); | ||
| return ` | ||
| // ---- plugins spoof --------------------------------------------------------- | ||
| (function() { | ||
| if (typeof navigator === "undefined") return; | ||
| // Defensive: only install if the underlying browser reports an empty list. | ||
| // Real Chrome.app ships the same 5-plugin PluginArray natively; overwriting | ||
| // it would regress the harness Zero-Diff gate. | ||
| try { | ||
| if (navigator.plugins !== undefined && navigator.plugins !== null) { | ||
| var existingLen = navigator.plugins.length; | ||
| if (typeof existingLen === "number" && existingLen >= 5) { | ||
| return; | ||
| } | ||
| } | ||
| } catch (_e) { /* fall through */ } | ||
| var SPOOF_PLUGINS = ${pluginsLiteral}; | ||
| // Build mimetype objects. Real Chrome dedupes — each mimetype's | ||
| // .enabledPlugin points at the first plugin that registered it. | ||
| var mimeByType = {}; | ||
| function makeMimeType(mt, plugin) { | ||
| var obj = Object.create(null); | ||
| obj.type = mt.type; | ||
| obj.suffixes = mt.suffixes; | ||
| obj.description = ""; | ||
| Object.defineProperty(obj, "enabledPlugin", { | ||
| configurable: true, enumerable: true, get: function() { return plugin; }, | ||
| }); | ||
| return obj; | ||
| } | ||
| function makePlugin(spec) { | ||
| var plug = Object.create(null); | ||
| plug.name = spec.name; | ||
| plug.filename = spec.filename; | ||
| plug.description = spec.description; | ||
| var mimes = []; | ||
| for (var i = 0; i < spec.mimeTypes.length; i++) { | ||
| var mt = makeMimeType(spec.mimeTypes[i], plug); | ||
| mimes.push(mt); | ||
| // First-registered mimetype wins for the global mimeTypes array. | ||
| if (mimeByType[mt.type] === undefined) mimeByType[mt.type] = mt; | ||
| } | ||
| plug.length = mimes.length; | ||
| for (var j = 0; j < mimes.length; j++) { | ||
| plug[String(j)] = mimes[j]; | ||
| plug[mimes[j].type] = mimes[j]; | ||
| } | ||
| plug.item = function(idx) { return mimes[idx] || null; }; | ||
| __mochi_register_native__(plug.item, "item"); | ||
| plug.namedItem = function(name) { | ||
| for (var k = 0; k < mimes.length; k++) { | ||
| if (mimes[k].type === name) return mimes[k]; | ||
| } | ||
| return null; | ||
| }; | ||
| __mochi_register_native__(plug.namedItem, "namedItem"); | ||
| return plug; | ||
| } | ||
| var plugins = []; | ||
| for (var i = 0; i < SPOOF_PLUGINS.length; i++) { | ||
| plugins.push(makePlugin(SPOOF_PLUGINS[i])); | ||
| } | ||
| // Build a PluginArray-like object. Real Chrome's PluginArray.length, | ||
| // .item, .namedItem, .refresh are all on the prototype; for the shim | ||
| // we put them on the instance (the assertion is on length and indexed | ||
| // access, both of which work the same way). | ||
| var pluginArr = Object.create(null); | ||
| pluginArr.length = plugins.length; | ||
| for (var p = 0; p < plugins.length; p++) { | ||
| pluginArr[String(p)] = plugins[p]; | ||
| pluginArr[plugins[p].name] = plugins[p]; | ||
| } | ||
| pluginArr.item = function(idx) { return plugins[idx] || null; }; | ||
| __mochi_register_native__(pluginArr.item, "item"); | ||
| pluginArr.namedItem = function(name) { | ||
| for (var k = 0; k < plugins.length; k++) { | ||
| if (plugins[k].name === name) return plugins[k]; | ||
| } | ||
| return null; | ||
| }; | ||
| __mochi_register_native__(pluginArr.namedItem, "namedItem"); | ||
| pluginArr.refresh = function() {}; | ||
| __mochi_register_native__(pluginArr.refresh, "refresh"); | ||
| pluginArr[Symbol.iterator] = function() { | ||
| var i = 0; | ||
| return { | ||
| next: function() { | ||
| if (i < plugins.length) return { value: plugins[i++], done: false }; | ||
| return { value: undefined, done: true }; | ||
| }, | ||
| }; | ||
| }; | ||
| // Build a MimeTypeArray-like object from the deduplicated map. | ||
| var mimeKeys = Object.keys(mimeByType); | ||
| var mimeArr = Object.create(null); | ||
| mimeArr.length = mimeKeys.length; | ||
| for (var m = 0; m < mimeKeys.length; m++) { | ||
| mimeArr[String(m)] = mimeByType[mimeKeys[m]]; | ||
| mimeArr[mimeKeys[m]] = mimeByType[mimeKeys[m]]; | ||
| } | ||
| mimeArr.item = function(idx) { | ||
| var k = mimeKeys[idx]; return k ? mimeByType[k] : null; | ||
| }; | ||
| __mochi_register_native__(mimeArr.item, "item"); | ||
| mimeArr.namedItem = function(name) { return mimeByType[name] || null; }; | ||
| __mochi_register_native__(mimeArr.namedItem, "namedItem"); | ||
| mimeArr[Symbol.iterator] = function() { | ||
| var idx = 0; | ||
| return { | ||
| next: function() { | ||
| if (idx < mimeKeys.length) return { value: mimeByType[mimeKeys[idx++]], done: false }; | ||
| return { value: undefined, done: true }; | ||
| }, | ||
| }; | ||
| }; | ||
| // Install on the Navigator prototype so getOwnPropertyNames(navigator) | ||
| // doesn't show new own properties (mirrors how the navigator module | ||
| // installs its accessors). | ||
| var navProto = __mochi_getPrototypeOf__(navigator); | ||
| try { | ||
| __mochi_define__(navProto, "plugins", pluginArr); | ||
| } catch (_e) {} | ||
| try { | ||
| __mochi_define__(navProto, "mimeTypes", mimeArr); | ||
| } catch (_e) {} | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: `screen.orientation` and `window.matchMedia(spec)` answers. | ||
| * | ||
| * Reads from the matrix: | ||
| * - `matrix.uaCh["screen-orientation"]` (R-038) — JSON `{type, angle}` | ||
| * - `matrix.uaCh["media-queries"]` (R-039) — JSON map of feature → | ||
| * answer | ||
| * - `matrix.uaCh["storage-estimate"]` (R-040) — JSON `{quota, usage}`. | ||
| * Optional: when present, also overrides | ||
| * `navigator.storage.estimate()`. | ||
| * | ||
| * `screen.orientation`: Chrome exposes a `ScreenOrientation` instance with | ||
| * `.type` and `.angle` accessor properties, plus event-target methods. We | ||
| * only override `.type` and `.angle` — that's what fingerprint probes read. | ||
| * | ||
| * `window.matchMedia(spec)`: wrap so that for each tracked feature, the | ||
| * returned `MediaQueryList` reports `matches` according to the matrix's | ||
| * curated answer. Unknown specs fall through to native. | ||
| * | ||
| * @see tasks/0070-consistency-rules-full.md (screen-orientation) | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| interface OrientationShape { | ||
| readonly type?: string; | ||
| readonly angle?: number; | ||
| } | ||
| function tryParse<T>(s: unknown): T | null { | ||
| if (typeof s !== "string" || s.length === 0) return null; | ||
| try { | ||
| return JSON.parse(s) as T; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| export function emitScreenOrientationModule(matrix: MatrixV1): string { | ||
| const orientation = tryParse<OrientationShape>(matrix.uaCh["screen-orientation"]); | ||
| const mediaQueries = | ||
| tryParse<Record<string, string | boolean>>(matrix.uaCh["media-queries"]) ?? {}; | ||
| const storageEstimate = tryParse<{ quota?: number; usage?: number }>( | ||
| matrix.uaCh["storage-estimate"], | ||
| ); | ||
| const orientationLiteral = orientation | ||
| ? JSON.stringify({ | ||
| type: typeof orientation.type === "string" ? orientation.type : "landscape-primary", | ||
| angle: typeof orientation.angle === "number" ? orientation.angle : 0, | ||
| }) | ||
| : "null"; | ||
| const mediaQueriesLiteral = JSON.stringify(mediaQueries); | ||
| const storageEstimateLiteral = storageEstimate | ||
| ? JSON.stringify({ | ||
| quota: typeof storageEstimate.quota === "number" ? storageEstimate.quota : 0, | ||
| usage: typeof storageEstimate.usage === "number" ? storageEstimate.usage : 0, | ||
| }) | ||
| : "null"; | ||
| return ` | ||
| // ---- screen.orientation + matchMedia + storage.estimate spoof ------------- | ||
| (function() { | ||
| // -- screen.orientation ------------------------------------------------- | ||
| try { | ||
| var SPOOF_ORIENTATION = ${orientationLiteral}; | ||
| if (typeof screen !== "undefined" && SPOOF_ORIENTATION !== null) { | ||
| var orientation = screen.orientation; | ||
| if (orientation !== undefined && orientation !== null) { | ||
| __mochi_define__(orientation, "type", SPOOF_ORIENTATION.type); | ||
| __mochi_define__(orientation, "angle", SPOOF_ORIENTATION.angle); | ||
| } | ||
| } | ||
| } catch (_e) {} | ||
| // -- matchMedia answers -------------------------------------------------- | ||
| try { | ||
| if (typeof window !== "undefined" && typeof window.matchMedia === "function") { | ||
| var SPOOF_MQ = ${mediaQueriesLiteral}; | ||
| var origMatchMedia = window.matchMedia; | ||
| function matchMedia(spec) { | ||
| try { | ||
| if (typeof spec !== "string") return __mochi_apply__.call(origMatchMedia, this, [spec]); | ||
| var s = spec.replace(/^\\(|\\)$/g, "").trim(); | ||
| // Forms: "feature: value" | "feature" | "min-resolution: <n>dpi" | ||
| var colon = s.indexOf(":"); | ||
| var feature, queryValue; | ||
| if (colon === -1) { | ||
| feature = s.trim(); | ||
| queryValue = null; | ||
| } else { | ||
| feature = s.slice(0, colon).trim(); | ||
| queryValue = s.slice(colon + 1).trim(); | ||
| } | ||
| if (Object.prototype.hasOwnProperty.call(SPOOF_MQ, feature)) { | ||
| var spoof = SPOOF_MQ[feature]; | ||
| var matches; | ||
| if (queryValue === null) { | ||
| // Boolean form: "(feature)" matches when spoof is truthy. | ||
| matches = spoof === true; | ||
| } else if (feature === "min-resolution") { | ||
| // "min-resolution: <N>dpi" matches when query <= spoof's dpi. | ||
| var qN = parseFloat(queryValue); | ||
| var sN = parseFloat(String(spoof)); | ||
| matches = !isNaN(qN) && !isNaN(sN) && sN >= qN; | ||
| } else { | ||
| matches = String(spoof) === queryValue; | ||
| } | ||
| // Build a MediaQueryList-shape stand-in. The probe page reads | ||
| // .matches and .media; the rest are EventTarget no-ops. | ||
| var mql = Object.create(null); | ||
| __mochi_defineProperty__(mql, "matches", { | ||
| configurable: true, enumerable: true, get: function() { return matches; }, | ||
| }); | ||
| __mochi_defineProperty__(mql, "media", { | ||
| configurable: true, enumerable: true, get: function() { return spec; }, | ||
| }); | ||
| mql.onchange = null; | ||
| mql.addEventListener = function() {}; | ||
| mql.removeEventListener = function() {}; | ||
| mql.addListener = function() {}; | ||
| mql.removeListener = function() {}; | ||
| mql.dispatchEvent = function() { return true; }; | ||
| return mql; | ||
| } | ||
| } catch (_e) {} | ||
| return __mochi_apply__.call(origMatchMedia, this, [spec]); | ||
| } | ||
| __mochi_register_native__(matchMedia, "matchMedia"); | ||
| try { | ||
| __mochi_defineProperty__(window, "matchMedia", { | ||
| configurable: true, enumerable: true, writable: true, value: matchMedia, | ||
| }); | ||
| } catch (_e) {} | ||
| } | ||
| } catch (_e) {} | ||
| // -- storage.estimate ---------------------------------------------------- | ||
| try { | ||
| var SPOOF_SE = ${storageEstimateLiteral}; | ||
| if (SPOOF_SE !== null && typeof navigator !== "undefined" && navigator.storage) { | ||
| var storage = navigator.storage; | ||
| if (typeof storage.estimate === "function") { | ||
| function estimate() { | ||
| return Promise.resolve({ quota: SPOOF_SE.quota, usage: SPOOF_SE.usage }); | ||
| } | ||
| __mochi_register_native__(estimate, "estimate"); | ||
| try { | ||
| var sproto = __mochi_getPrototypeOf__(storage); | ||
| var starget = sproto !== null && sproto !== undefined && typeof sproto.estimate === "function" | ||
| ? sproto | ||
| : storage; | ||
| __mochi_defineProperty__(starget, "estimate", { | ||
| configurable: true, enumerable: false, writable: true, value: estimate, | ||
| }); | ||
| } catch (_e) {} | ||
| } | ||
| } | ||
| } catch (_e) {} | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: `screen.*` and `window.{innerWidth,innerHeight,outerWidth, | ||
| * outerHeight,devicePixelRatio}`. | ||
| * | ||
| * Reads from the matrix: | ||
| * - `matrix.display.width/height/colorDepth/pixelDepth/dpr` (R-010..R-012) | ||
| * - `matrix.uaCh["screen-availSize"]` (R-021) — JSON `{availWidth, availHeight}` | ||
| * - `matrix.uaCh["window-viewport"]` (R-029) — JSON `{innerWidth, innerHeight, outerWidth, outerHeight}` | ||
| * | ||
| * Missing uaCh keys are skipped (PLAN.md I-5). | ||
| * | ||
| * @see tasks/0030-inject-engine-v0.md §"screen.ts" | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| /** Best-effort JSON parse — returns null on any failure. */ | ||
| function tryParse(s: unknown): unknown { | ||
| if (typeof s !== "string") return null; | ||
| try { | ||
| return JSON.parse(s); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function isFiniteNumber(n: unknown): n is number { | ||
| return typeof n === "number" && Number.isFinite(n); | ||
| } | ||
| export function emitScreenModule(matrix: MatrixV1): string { | ||
| const screenLines: string[] = []; | ||
| const winLines: string[] = []; | ||
| // screen.width / screen.height — from display. | ||
| screenLines.push(` __mochi_define__(__sp__, "width", ${matrix.display.width});`); | ||
| screenLines.push(` __mochi_define__(__sp__, "height", ${matrix.display.height});`); | ||
| // screen.colorDepth / screen.pixelDepth — from display. | ||
| screenLines.push(` __mochi_define__(__sp__, "colorDepth", ${matrix.display.colorDepth});`); | ||
| screenLines.push(` __mochi_define__(__sp__, "pixelDepth", ${matrix.display.pixelDepth});`); | ||
| // screen.availWidth/availHeight — derived in R-021. | ||
| const avail = tryParse(matrix.uaCh["screen-availSize"]) as { | ||
| availWidth?: number; | ||
| availHeight?: number; | ||
| } | null; | ||
| if (avail !== null) { | ||
| if (isFiniteNumber(avail.availWidth)) { | ||
| screenLines.push(` __mochi_define__(__sp__, "availWidth", ${avail.availWidth});`); | ||
| } | ||
| if (isFiniteNumber(avail.availHeight)) { | ||
| screenLines.push(` __mochi_define__(__sp__, "availHeight", ${avail.availHeight});`); | ||
| } | ||
| } | ||
| // window.devicePixelRatio — from display.dpr (R-012). DPR lives on the | ||
| // Window instance, not a prototype — Chrome returns it via a getter on | ||
| // Window. Defining on the instance is the correct match. | ||
| winLines.push(` __mochi_define__(window, "devicePixelRatio", ${matrix.display.dpr});`); | ||
| // window.{innerWidth,innerHeight,outerWidth,outerHeight} — R-029 JSON. | ||
| const vp = tryParse(matrix.uaCh["window-viewport"]) as { | ||
| innerWidth?: number; | ||
| innerHeight?: number; | ||
| outerWidth?: number; | ||
| outerHeight?: number; | ||
| } | null; | ||
| if (vp !== null) { | ||
| for (const key of ["innerWidth", "innerHeight", "outerWidth", "outerHeight"] as const) { | ||
| const v = vp[key]; | ||
| if (isFiniteNumber(v)) { | ||
| winLines.push(` __mochi_define__(window, ${JSON.stringify(key)}, ${v});`); | ||
| } | ||
| } | ||
| } | ||
| return ` | ||
| // ---- screen + viewport spoof ----------------------------------------------- | ||
| (function() { | ||
| var __sp__ = __mochi_getPrototypeOf__(screen); | ||
| ${screenLines.join("\n")} | ||
| ${winLines.join("\n")} | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: `Intl.DateTimeFormat().resolvedOptions().timeZone` and | ||
| * `Date.prototype.getTimezoneOffset` / related timezone-derived APIs. | ||
| * | ||
| * Reads from the matrix: | ||
| * - `matrix.timezone` (R-014) — IANA timezone identifier | ||
| * | ||
| * v0.3 strategy: | ||
| * - Replace `Intl.DateTimeFormat.prototype.resolvedOptions` with a wrapper | ||
| * that overrides `timeZone` on the returned options object. Other | ||
| * fields pass through. | ||
| * - Don't spoof `performance.now()` precision (PLAN.md §9.6 — Chrome's | ||
| * natural 100µs coarsening is what we want for same-engine v1). | ||
| * - Don't override `Date.prototype.getTimezoneOffset` — when Chrome is | ||
| * launched with `TZ=<iana>` (or via the system clock), the Date object | ||
| * already produces correct offsets. Inject-layer spoofing of | ||
| * getTimezoneOffset would conflict with the page's own Date math. The | ||
| * core launch path is responsible for setting `TZ` if requested | ||
| * (deferred to phase 0.7 / harness work; documented in docs/limits.md). | ||
| * | ||
| * @see tasks/0030-inject-engine-v0.md §"timing.ts" | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| export function emitTimingModule(matrix: MatrixV1): string { | ||
| const tz = JSON.stringify(matrix.timezone); | ||
| return ` | ||
| // ---- timing spoof (timezone) ----------------------------------------------- | ||
| (function() { | ||
| if (typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function") return; | ||
| var SPOOF_TZ = ${tz}; | ||
| var proto = Intl.DateTimeFormat.prototype; | ||
| var origResolved = proto.resolvedOptions; | ||
| if (typeof origResolved !== "function") return; | ||
| function resolvedOptions() { | ||
| var opts = __mochi_apply__.call(origResolved, this, []); | ||
| if (opts !== null && opts !== undefined && typeof opts === "object") { | ||
| try { opts.timeZone = SPOOF_TZ; } catch (_e) {} | ||
| } | ||
| return opts; | ||
| } | ||
| __mochi_register_native__(resolvedOptions, "resolvedOptions"); | ||
| try { | ||
| __mochi_defineProperty__(proto, "resolvedOptions", { | ||
| configurable: true, | ||
| enumerable: false, | ||
| writable: true, | ||
| value: resolvedOptions, | ||
| }); | ||
| } catch (_e) {} | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: `WebGLRenderingContext.prototype.getParameter` + | ||
| * `WebGL2RenderingContext.prototype.getParameter`. | ||
| * | ||
| * Replaces the prototype's `getParameter` with a wrapper that: | ||
| * - returns the matrix value for `UNMASKED_VENDOR_WEBGL` (37445), | ||
| * `UNMASKED_RENDERER_WEBGL` (37446), `MAX_TEXTURE_SIZE` (3379), | ||
| * and `MAX_COLOR_ATTACHMENTS` (36063, WebGL2 only). | ||
| * - falls through to the original `getParameter` for all other queries. | ||
| * | ||
| * The `WEBGL_debug_renderer_info` extension's masked constants (37445/37446) | ||
| * are queried via `getParameter` directly on the context — Chrome returns | ||
| * the unmasked vendor/renderer regardless of whether the extension was | ||
| * obtained, in headless / non-strict mode. The matrix's | ||
| * `gpu.webglUnmaskedVendor/Renderer` are the spoofed values. | ||
| * | ||
| * Caveats: | ||
| * - `WebGL2RenderingContext.prototype.getParameter` is a separate slot | ||
| * (it overrides the WebGL1 inherited method in some Chrome builds; in | ||
| * others it shares — we patch both defensively). | ||
| * - We don't currently spoof the `WEBGL_debug_renderer_info` extension's | ||
| * `getParameter` path (that's the same path; both routes call the | ||
| * proto's getParameter which we've patched). | ||
| * | ||
| * @see tasks/0030-inject-engine-v0.md §"webgl.ts" | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| /** GLenum constants we care about. Hard-coded to avoid Chrome dependence. */ | ||
| const UNMASKED_VENDOR_WEBGL = 0x9245; // 37445 | ||
| const UNMASKED_RENDERER_WEBGL = 0x9246; // 37446 | ||
| const MAX_TEXTURE_SIZE = 0x0d33; // 3379 | ||
| const MAX_COLOR_ATTACHMENTS = 0x8cdf; // 36063 (WebGL2) | ||
| export function emitWebglModule(matrix: MatrixV1): string { | ||
| const vendor = JSON.stringify(matrix.gpu.webglUnmaskedVendor); | ||
| const renderer = JSON.stringify(matrix.gpu.webglUnmaskedRenderer); | ||
| const maxTex = String(matrix.gpu.webglMaxTextureSize); | ||
| const maxAttach = String(matrix.gpu.webglMaxColorAttachments); | ||
| return ` | ||
| // ---- WebGL spoof ----------------------------------------------------------- | ||
| (function() { | ||
| var UNMASKED_VENDOR_WEBGL = ${UNMASKED_VENDOR_WEBGL}; | ||
| var UNMASKED_RENDERER_WEBGL = ${UNMASKED_RENDERER_WEBGL}; | ||
| var MAX_TEXTURE_SIZE = ${MAX_TEXTURE_SIZE}; | ||
| var MAX_COLOR_ATTACHMENTS = ${MAX_COLOR_ATTACHMENTS}; | ||
| var SPOOF_VENDOR = ${vendor}; | ||
| var SPOOF_RENDERER = ${renderer}; | ||
| var SPOOF_MAX_TEX = ${maxTex}; | ||
| var SPOOF_MAX_ATTACH = ${maxAttach}; | ||
| /** | ||
| * Patch one prototype's getParameter slot. The wrapper preserves | ||
| * \`this\` and forwards to the original for non-spoofed pnames. | ||
| */ | ||
| function patch(proto, isWebgl2) { | ||
| if (proto === undefined || proto === null) return; | ||
| var orig = proto.getParameter; | ||
| if (typeof orig !== "function") return; | ||
| function getParameter(pname) { | ||
| if (pname === UNMASKED_VENDOR_WEBGL) return SPOOF_VENDOR; | ||
| if (pname === UNMASKED_RENDERER_WEBGL) return SPOOF_RENDERER; | ||
| if (pname === MAX_TEXTURE_SIZE) return SPOOF_MAX_TEX; | ||
| if (isWebgl2 && pname === MAX_COLOR_ATTACHMENTS) return SPOOF_MAX_ATTACH; | ||
| // Use Function.prototype.call.call (apply form) on the captured original | ||
| // to defend against any later page-side mutation of orig.call. | ||
| return __mochi_apply__.call(orig, this, [pname]); | ||
| } | ||
| __mochi_register_native__(getParameter, "getParameter"); | ||
| // Replace the proto slot. configurable:true matches Chrome native. | ||
| try { | ||
| __mochi_defineProperty__(proto, "getParameter", { | ||
| configurable: true, | ||
| enumerable: false, | ||
| writable: true, | ||
| value: getParameter, | ||
| }); | ||
| } catch (_e) {} | ||
| } | ||
| if (typeof WebGLRenderingContext !== "undefined") { | ||
| patch(WebGLRenderingContext.prototype, false); | ||
| } | ||
| if (typeof WebGL2RenderingContext !== "undefined") { | ||
| patch(WebGL2RenderingContext.prototype, true); | ||
| } | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: `navigator.gpu.requestAdapter()` adapter shape. | ||
| * | ||
| * Reads from the matrix: | ||
| * - `matrix.uaCh["webgpu-features"]` (R-032) — JSON string array | ||
| * - `matrix.uaCh["webgpu-info"]` (R-033) — JSON `{vendor, architecture, | ||
| * device, description}` | ||
| * | ||
| * Spoofs `adapter.features` (a `GPUSupportedFeatures` set) and | ||
| * `adapter.info` (a `GPUAdapterInfo` shape) on the adapter Promise that | ||
| * `requestAdapter()` returns. Falls through to the original adapter when | ||
| * the matrix is missing the keys. | ||
| * | ||
| * Adapter `isFallbackAdapter` is forced false (real-hardware behaviour). | ||
| * | ||
| * Implementation strategy: | ||
| * - Wrap `GPU.prototype.requestAdapter` (where `GPU` is the type of | ||
| * `navigator.gpu`). When the wrapped Promise resolves, return a Proxy | ||
| * over the adapter that intercepts `features`, `info`, and | ||
| * `isFallbackAdapter` reads. | ||
| * - When `requestAdapter` returns null (e.g. no GPU available in the | ||
| * headless capture), build a synthetic adapter from the matrix data. | ||
| * The probe-page only reads `features` / `info` / `isFallbackAdapter`, | ||
| * so we don't need a full adapter implementation. | ||
| * | ||
| * @see tasks/0070-consistency-rules-full.md (webgpu) | ||
| * @see PLAN.md §9.5 | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| interface WebGpuInfo { | ||
| readonly vendor: string; | ||
| readonly architecture: string; | ||
| readonly device: string; | ||
| readonly description: string; | ||
| } | ||
| function tryParse<T>(s: unknown): T | null { | ||
| if (typeof s !== "string" || s.length === 0) return null; | ||
| try { | ||
| return JSON.parse(s) as T; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| export function emitWebgpuModule(matrix: MatrixV1): string { | ||
| const features = tryParse<readonly string[]>(matrix.uaCh["webgpu-features"]) ?? []; | ||
| const info = tryParse<WebGpuInfo>(matrix.uaCh["webgpu-info"]) ?? { | ||
| vendor: "", | ||
| architecture: "", | ||
| device: "", | ||
| description: "", | ||
| }; | ||
| // If the matrix carries no WebGPU data, skip — emit a comment. | ||
| if (features.length === 0 && info.vendor === "") { | ||
| return ` | ||
| // ---- WebGPU spoof (skipped — no matrix.uaCh["webgpu-*"]) ------------------ | ||
| `; | ||
| } | ||
| const featuresLiteral = JSON.stringify(features); | ||
| const infoLiteral = JSON.stringify(info); | ||
| return ` | ||
| // ---- WebGPU spoof ---------------------------------------------------------- | ||
| (function() { | ||
| if (typeof navigator === "undefined") return; | ||
| var gpu = navigator.gpu; | ||
| if (gpu === undefined || gpu === null) return; | ||
| var SPOOF_FEATURES = ${featuresLiteral}; | ||
| var SPOOF_INFO = ${infoLiteral}; | ||
| // Build the spoofed adapter. We don't need to satisfy the full GPUAdapter | ||
| // interface — the probe-page reads .features, .info, .isFallbackAdapter | ||
| // only. Real adapter methods (requestDevice etc.) fall through to the | ||
| // wrapped original via Proxy when an underlying adapter exists. | ||
| function buildSpoofFeatures() { | ||
| // adapter.features is a GPUSupportedFeatures (Set-like). Build a Set | ||
| // populated with the spoofed strings; iteration order matches insertion. | ||
| var s = new Set(); | ||
| for (var i = 0; i < SPOOF_FEATURES.length; i++) s.add(SPOOF_FEATURES[i]); | ||
| return s; | ||
| } | ||
| function buildSpoofInfo() { | ||
| // GPUAdapterInfo is dictionary-shaped; freezing prevents page mutation. | ||
| return Object.freeze({ | ||
| vendor: SPOOF_INFO.vendor, | ||
| architecture: SPOOF_INFO.architecture, | ||
| device: SPOOF_INFO.device, | ||
| description: SPOOF_INFO.description, | ||
| }); | ||
| } | ||
| function wrapAdapter(realAdapter) { | ||
| // The probe checks adapter && adapter.features etc. Use a Proxy so all | ||
| // unhandled property accesses pass through to the real adapter. | ||
| var spoofFeatures = buildSpoofFeatures(); | ||
| var spoofInfo = buildSpoofInfo(); | ||
| if (realAdapter === null || realAdapter === undefined) { | ||
| // No real adapter — return a synthetic stub that satisfies the | ||
| // probe-page surface. requestDevice() resolves to undefined so | ||
| // probes that try to instantiate a device get an unhelpful (but | ||
| // non-throwing) result. isFallbackAdapter is intentionally omitted | ||
| // so the probe-page's \`adapter.isFallbackAdapter\` read returns | ||
| // undefined — matching the captured baseline shape on the M4 capture. | ||
| return { | ||
| features: spoofFeatures, | ||
| info: spoofInfo, | ||
| requestDevice: function() { return Promise.resolve(undefined); }, | ||
| }; | ||
| } | ||
| return new Proxy(realAdapter, { | ||
| get: function(target, prop) { | ||
| if (prop === "features") return spoofFeatures; | ||
| if (prop === "info") return spoofInfo; | ||
| // Pass through isFallbackAdapter — Chromium versions vary on whether | ||
| // the property exists. Returning the real value keeps the harness | ||
| // diff aligned with the captured baseline shape. | ||
| var v = target[prop]; | ||
| if (typeof v === "function") return v.bind(target); | ||
| return v; | ||
| }, | ||
| }); | ||
| } | ||
| // Wrap GPU.prototype.requestAdapter so the returned Promise resolves to | ||
| // the spoofed adapter. Fall back to gpu.requestAdapter directly if the | ||
| // prototype isn't reachable (test sandboxes). | ||
| var proto = __mochi_getPrototypeOf__(gpu); | ||
| var target = proto !== null && proto !== undefined && typeof proto.requestAdapter === "function" | ||
| ? proto | ||
| : gpu; | ||
| var orig = target.requestAdapter; | ||
| if (typeof orig !== "function") return; | ||
| function requestAdapter(opts) { | ||
| try { | ||
| var p = __mochi_apply__.call(orig, this, [opts]); | ||
| if (p && typeof p.then === "function") { | ||
| return p.then(function(adapter) { return wrapAdapter(adapter); }, function(_e) { | ||
| // If the underlying call rejects, synthesize from matrix. | ||
| return wrapAdapter(null); | ||
| }); | ||
| } | ||
| } catch (_e) {} | ||
| // Fallback: synthesize from matrix. | ||
| return Promise.resolve(wrapAdapter(null)); | ||
| } | ||
| __mochi_register_native__(requestAdapter, "requestAdapter"); | ||
| try { | ||
| __mochi_defineProperty__(target, "requestAdapter", { | ||
| configurable: true, | ||
| enumerable: false, | ||
| writable: true, | ||
| value: requestAdapter, | ||
| }); | ||
| } catch (_e) {} | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Spoof module: `window.chrome` shim. | ||
| * | ||
| * Real Chrome (headed and `--headless=new`) exposes `window.chrome` as | ||
| * an object with `loadTimes`, `csi`, `app` — and, for extension contexts, | ||
| * `runtime`. Stock Chromium-for-Testing builds may or may not expose | ||
| * `window.chrome` depending on flag set; CloakBrowser's | ||
| * `test_window_chrome_exists` (line 47) and `test_device_and_browser_info` | ||
| * (line 158, `hasInconsistentChromeObject`) both check for this. | ||
| * | ||
| * The shim is **defensive** — we install it only when `window.chrome` is | ||
| * absent or non-object. If the underlying browser already exposes a real | ||
| * `window.chrome` (the common case on real Chrome.app), we leave it alone | ||
| * so the existing Probe Manifest Zero-Diff gate doesn't regress. | ||
| * | ||
| * The shim mirrors Chrome's actual non-extension shape: | ||
| * - `chrome.app` — `{ isInstalled: false, getDetails(), getIsInstalled(), … }` | ||
| * stripped to the no-op stubs Chrome reports for non-installed-app pages. | ||
| * - `chrome.csi()` — returns a benign timing object. | ||
| * - `chrome.loadTimes()` — returns a benign timings object. | ||
| * - `chrome.runtime` — INTENTIONALLY undefined for non-extension contexts. | ||
| * CloakBrowser's `bot.detection` test (`hasInconsistentChromeObject`) | ||
| * and `chromeRuntime` probe both consider `chrome.runtime` truthy as | ||
| * suspicious unless an actual extension API is present, since real | ||
| * Chrome only exposes runtime to extension callers. | ||
| * | ||
| * Reads no matrix slots — `window.chrome` is profile-invariant for | ||
| * Chromium-family browsers. The matrix's `engine === "chromium"` is the | ||
| * gating condition (other engines would not have `window.chrome`). | ||
| * | ||
| * @see CloakBrowser tests/test_stealth.py:47-50,158 | ||
| * @see tasks/0140-stealth-conformance.md §"window.chrome spoof" | ||
| */ | ||
| import type { MatrixV1 } from "@mochi.js/consistency"; | ||
| /** | ||
| * Emit the `window.chrome` shim. The IIFE checks if `window.chrome` is | ||
| * already an object and returns early if so — we never overwrite a | ||
| * native `window.chrome`. | ||
| */ | ||
| export function emitWindowChromeModule(matrix: MatrixV1): string { | ||
| if (matrix.engine !== "chromium") { | ||
| // No shim for non-Chromium engines (the v1 catalog is Chromium-only, | ||
| // so this path is currently unreachable, but guarding it keeps the | ||
| // module forward-compatible with the v2 cross-engine roadmap). | ||
| return `\n// ---- window.chrome spoof (skipped — non-chromium engine) ------------------\n`; | ||
| } | ||
| return ` | ||
| // ---- window.chrome spoof --------------------------------------------------- | ||
| (function() { | ||
| if (typeof window === "undefined") return; | ||
| // If the underlying browser already exposes window.chrome as an object, | ||
| // do not overwrite it — real Chrome.app's native shape is richer than | ||
| // any synthesized shim and overwriting would regress the harness diff. | ||
| var existing = window.chrome; | ||
| if (existing !== undefined && existing !== null && typeof existing === "object") { | ||
| return; | ||
| } | ||
| // Build the no-op shape that Chromium-for-Testing produces in non- | ||
| // extension contexts. Functions are registered with the toString cloak | ||
| // so .toString() reports native shape. | ||
| function makeAppApi() { | ||
| function isInstalled() { return false; } | ||
| __mochi_register_native__(isInstalled, "isInstalled"); | ||
| function getDetails() { return null; } | ||
| __mochi_register_native__(getDetails, "getDetails"); | ||
| function getIsInstalled() { return false; } | ||
| __mochi_register_native__(getIsInstalled, "getIsInstalled"); | ||
| function installState(cb) { | ||
| // Real Chrome answers "not_installed" via the callback. | ||
| try { if (typeof cb === "function") cb("not_installed"); } catch (_e) {} | ||
| } | ||
| __mochi_register_native__(installState, "installState"); | ||
| function runningState() { return "cannot_run"; } | ||
| __mochi_register_native__(runningState, "runningState"); | ||
| var app = Object.create(null); | ||
| app.isInstalled = false; | ||
| app.InstallState = Object.freeze({ | ||
| DISABLED: "disabled", | ||
| INSTALLED: "installed", | ||
| NOT_INSTALLED: "not_installed", | ||
| }); | ||
| app.RunningState = Object.freeze({ | ||
| CANNOT_RUN: "cannot_run", | ||
| READY_TO_RUN: "ready_to_run", | ||
| RUNNING: "running", | ||
| }); | ||
| app.getDetails = getDetails; | ||
| app.getIsInstalled = getIsInstalled; | ||
| app.installState = installState; | ||
| app.runningState = runningState; | ||
| return app; | ||
| } | ||
| function csi() { | ||
| // Approximate Chrome's csi() shape — a benign timing snapshot. | ||
| return { | ||
| onloadT: 0, | ||
| pageT: 0, | ||
| startE: 0, | ||
| tran: 15, | ||
| }; | ||
| } | ||
| __mochi_register_native__(csi, "csi"); | ||
| function loadTimes() { | ||
| // Approximate Chrome's loadTimes() shape. Values are filled with zeros | ||
| // — the assertion in CloakBrowser is on object-shape, not field | ||
| // values; sites that scrutinize the timings further would also | ||
| // need the harness-baseline-driven values, which is a v2 concern. | ||
| return { | ||
| requestTime: 0, | ||
| startLoadTime: 0, | ||
| commitLoadTime: 0, | ||
| finishDocumentLoadTime: 0, | ||
| finishLoadTime: 0, | ||
| firstPaintTime: 0, | ||
| firstPaintAfterLoadTime: 0, | ||
| navigationType: "Other", | ||
| wasFetchedViaSpdy: false, | ||
| wasNpnNegotiated: false, | ||
| npnNegotiatedProtocol: "unknown", | ||
| wasAlternateProtocolAvailable: false, | ||
| connectionInfo: "unknown", | ||
| }; | ||
| } | ||
| __mochi_register_native__(loadTimes, "loadTimes"); | ||
| var chromeShim = Object.create(null); | ||
| chromeShim.app = makeAppApi(); | ||
| chromeShim.csi = csi; | ||
| chromeShim.loadTimes = loadTimes; | ||
| // chrome.runtime is intentionally undefined for non-extension contexts. | ||
| // CloakBrowser's hasInconsistentChromeObject + chromeRuntime probes both | ||
| // expect runtime to be falsy unless extension messaging is active. | ||
| try { | ||
| __mochi_defineProperty__(window, "chrome", { | ||
| configurable: true, | ||
| enumerable: true, | ||
| writable: true, | ||
| value: chromeShim, | ||
| }); | ||
| } catch (_e) { | ||
| // Non-configurable native window.chrome — nothing to do; the existing | ||
| // value is what page script will see. | ||
| } | ||
| })(); | ||
| `; | ||
| } |
| /** | ||
| * Pure-JS-string runtime helper: `__mochi_define__`. | ||
| * | ||
| * Emits the JS source for a tiny helper that wraps `Object.defineProperty` | ||
| * with the descriptor shape mochi uses for every spoofed property: | ||
| * | ||
| * - `configurable: false` so page code cannot re-define our overrides | ||
| * (PLAN.md §5.3 — "Every override uses Object.defineProperty with | ||
| * configurable: false so page code can't unwrap us by re-defining"). | ||
| * - `enumerable` matched to the original native descriptor so that | ||
| * `for…in` enumeration and `Object.keys` shape stays Chrome-natural. | ||
| * - Accessor (`get`) form by default — most spoofed Navigator/Screen | ||
| * surface is accessor-style natively. A separate helper is provided | ||
| * for value-style props if a future module needs it. | ||
| * | ||
| * The helper captures the *original* `Object.defineProperty` and | ||
| * `Object.getOwnPropertyDescriptor` references inside the IIFE closure so | ||
| * page scripts running later can't observe a swapped-out | ||
| * `Object.defineProperty` to detect us. | ||
| * | ||
| * @see PLAN.md §5.3, §8.4 | ||
| * @see tasks/0030-inject-engine-v0.md | ||
| */ | ||
| /** | ||
| * Emit the helper source. The returned string is concatenated into the IIFE | ||
| * by `build.ts`. After this snippet runs, the helper is available on the | ||
| * IIFE-local scope as `__mochi_define__` and `__mochi_define_value__`. | ||
| */ | ||
| export function emitDefinePropertyHelper(): string { | ||
| return ` | ||
| // ---- defineProperty helper ------------------------------------------------- | ||
| // Captured natives — used by every spoof module. We grab references inside | ||
| // the IIFE so page-script overrides of these globals can't trip us. | ||
| var __mochi_defineProperty__ = Object.defineProperty; | ||
| var __mochi_getOwnPropertyDescriptor__ = Object.getOwnPropertyDescriptor; | ||
| var __mochi_getPrototypeOf__ = Object.getPrototypeOf; | ||
| /** | ||
| * Replace an accessor property with a spoofed getter while preserving the | ||
| * original descriptor's enumerability. \`configurable\` is forced to false so | ||
| * page script can't redefine — see PLAN.md §5.3. | ||
| * | ||
| * The created getter is registered with the toString cloak (after that | ||
| * helper is installed) so \`getter.toString()\` returns native shape — see | ||
| * PLAN.md §5.3. The string key is used as the registered "native name" so | ||
| * fingerprint libraries that stringify the descriptor's getter see the | ||
| * expected accessor. | ||
| * | ||
| * Silently no-ops on non-existent or non-configurable target descriptors so | ||
| * we never throw a TypeError that page code could detect. | ||
| */ | ||
| function __mochi_define__(target, key, value) { | ||
| try { | ||
| var d = __mochi_getOwnPropertyDescriptor__(target, key); | ||
| if (d === undefined) { | ||
| // Walk the prototype chain to find the descriptor's natural shape. | ||
| var p = __mochi_getPrototypeOf__(target); | ||
| while (p !== null && p !== undefined) { | ||
| var pd = __mochi_getOwnPropertyDescriptor__(p, key); | ||
| if (pd !== undefined) { d = pd; break; } | ||
| p = __mochi_getPrototypeOf__(p); | ||
| } | ||
| } | ||
| var enumerable = d !== undefined ? !!d.enumerable : true; | ||
| var getter = function() { return value; }; | ||
| // Register the getter with the toString cloak if available. The cloak | ||
| // installs \`__mochi_register_native__\` immediately after this helper | ||
| // is emitted, so by the time spoof modules call \`__mochi_define__\` it | ||
| // is in scope. The native name is the property key (Chrome's native | ||
| // accessors stringify as \`function get propName() { [native code] }\` | ||
| // but stock fingerprint libraries match on \`[native code]\` substring, | ||
| // so we standardize on \`function key() { [native code] }\`). | ||
| if (typeof __mochi_register_native__ === "function") { | ||
| __mochi_register_native__(getter, "get " + String(key)); | ||
| } | ||
| __mochi_defineProperty__(target, key, { | ||
| configurable: false, | ||
| enumerable: enumerable, | ||
| get: getter, | ||
| }); | ||
| } catch (_e) { | ||
| // Swallow — never let our injection throw to page script. PLAN.md §5.3. | ||
| } | ||
| } | ||
| /** | ||
| * Like \`__mochi_define__\` but for value-style properties (e.g. when we want | ||
| * the descriptor to be \`{ value, writable: false }\` rather than an accessor). | ||
| * Currently unused in v0.3 modules but kept for future modules. | ||
| */ | ||
| function __mochi_define_value__(target, key, value) { | ||
| try { | ||
| var d = __mochi_getOwnPropertyDescriptor__(target, key); | ||
| var enumerable = d !== undefined ? !!d.enumerable : true; | ||
| __mochi_defineProperty__(target, key, { | ||
| configurable: false, | ||
| enumerable: enumerable, | ||
| writable: false, | ||
| value: value, | ||
| }); | ||
| } catch (_e) {} | ||
| } | ||
| `; | ||
| } |
| /** | ||
| * Pure-JS-string runtime helper: `Function.prototype.toString` cloak. | ||
| * | ||
| * Every function we replace must answer `.toString()` with the exact native | ||
| * shape Chrome would emit: | ||
| * | ||
| * `function ${name}() { [native code] }` | ||
| * | ||
| * Otherwise fingerprint libraries (FPJS, creep.js, sannysoft) detect us by | ||
| * stringifying our overrides and seeing real JS source. | ||
| * | ||
| * Implementation: a single `Function.prototype.toString` proxy that consults | ||
| * a per-spoofed-fn map keyed on the spoofed function reference. Falls | ||
| * through to the *original* `Function.prototype.toString` for everything | ||
| * else — including for the proxy's own toString (Chrome reports | ||
| * `function toString() { [native code] }` for the native one, and that's | ||
| * what users will see when they query our installed proxy too). | ||
| * | ||
| * @see PLAN.md §5.3, tasks/0030 §"toString cloaking" | ||
| */ | ||
| /** | ||
| * Emit the cloak helper source. Exposes: | ||
| * - `__mochi_register_native__(fn, name)` — register fn so its toString | ||
| * returns the native-shape string for `name`. | ||
| * - implicit: replaces `Function.prototype.toString` once. | ||
| */ | ||
| export function emitToStringCloak(): string { | ||
| return ` | ||
| // ---- toString cloak -------------------------------------------------------- | ||
| // Captured native references — saved BEFORE we install the proxy. PLAN.md §5.3. | ||
| var __mochi_originalFnToString__ = Function.prototype.toString; | ||
| var __mochi_call__ = Function.prototype.call; | ||
| var __mochi_apply__ = Function.prototype.apply; | ||
| // Map<Function, string nativeName>. Populated by spoof modules via | ||
| // __mochi_register_native__(fn, "navigator.userAgent" or "getParameter"). | ||
| var __mochi_nativeMap__ = new WeakMap(); | ||
| function __mochi_register_native__(fn, name) { | ||
| try { | ||
| __mochi_nativeMap__.set(fn, name); | ||
| } catch (_e) {} | ||
| } | ||
| /** | ||
| * The proxy that replaces \`Function.prototype.toString\`. For registered | ||
| * functions, returns the native-shape string. Otherwise calls the original | ||
| * to keep all other behaviour identical (anonymous fns, arrow fns, builtin | ||
| * native fns we didn't touch, user-defined named functions, etc.). | ||
| * | ||
| * Note: we re-read \`__mochi_nativeMap__\` from the IIFE-local closure rather | ||
| * than from any global — page script can shadow globals but cannot reach | ||
| * into our closure. | ||
| */ | ||
| function __mochi_fnToString__() { | ||
| // \`this\` is the function being stringified. | ||
| if (this !== undefined && this !== null) { | ||
| var registered = __mochi_nativeMap__.get(this); | ||
| if (registered !== undefined) { | ||
| return "function " + registered + "() { [native code] }"; | ||
| } | ||
| } | ||
| // Fall through. Use Function.prototype.call.call (apply form) so we don't | ||
| // recurse if a misbehaving page swapped Function.prototype.toString again. | ||
| return __mochi_call__.call(__mochi_originalFnToString__, this); | ||
| } | ||
| // Register the proxy itself so that .toString() on our proxy returns the | ||
| // native shape (Chrome shows "function toString() { [native code] }" for | ||
| // the real one). This makes our proxy indistinguishable from the original | ||
| // at the toString-of-toString level. | ||
| __mochi_register_native__(__mochi_fnToString__, "toString"); | ||
| // Install. Critical: configurable:true matches Chrome's native descriptor | ||
| // for Function.prototype.toString — fingerprint libraries DO check | ||
| // configurable on this slot. enumerable:false also matches native. | ||
| try { | ||
| __mochi_defineProperty__(Function.prototype, "toString", { | ||
| configurable: true, | ||
| enumerable: false, | ||
| writable: true, | ||
| value: __mochi_fnToString__, | ||
| }); | ||
| } catch (_e) {} | ||
| `; | ||
| } |
+6
-3
| { | ||
| "name": "@mochi.js/inject", | ||
| "version": "0.0.1", | ||
| "version": "0.1.0", | ||
| "description": "Zero-jitter stealth payload for mochi — JIT-friendly proxies installed before any page script.", | ||
@@ -23,6 +23,6 @@ "license": "MIT", | ||
| }, | ||
| "homepage": "https://github.com/0xchasercat/mochi.js", | ||
| "homepage": "https://github.com/0xchasercat/mochi", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "git+https://github.com/0xchasercat/mochi.js.git", | ||
| "url": "git+https://github.com/0xchasercat/mochi.git", | ||
| "directory": "packages/inject" | ||
@@ -42,2 +42,5 @@ }, | ||
| }, | ||
| "dependencies": { | ||
| "@mochi.js/consistency": "workspace:*" | ||
| }, | ||
| "publishConfig": { | ||
@@ -44,0 +47,0 @@ "access": "public" |
+2
-2
| # @mochi.js/inject | ||
| Zero-jitter stealth payload for [mochi](https://github.com/0xchasercat/mochi.js). Builds a single TurboFan-friendly IIFE that installs JS-layer fingerprint proxies before any page script runs. | ||
| Zero-jitter stealth payload for [mochi](https://github.com/0xchasercat/mochi). Builds a single TurboFan-friendly IIFE that installs JS-layer fingerprint proxies before any page script runs. | ||
@@ -9,2 +9,2 @@ Internal package consumed by `@mochi.js/core`. | ||
| See [PLAN.md §5.3 and §8.4](https://github.com/0xchasercat/mochi.js/blob/main/PLAN.md). | ||
| See [PLAN.md §5.3 and §8.4](https://github.com/0xchasercat/mochi/blob/main/PLAN.md). |
+9
-15
@@ -6,21 +6,15 @@ /** | ||
| * `Page.addScriptToEvaluateOnNewDocument(runImmediately:true, worldName:"")` | ||
| * before any page script runs. v0.0.1 claim release; payload lands in phase 0.3. | ||
| * before any page script runs. | ||
| * | ||
| * v0.3 covers the surface that v0.2 produces (the 30 rules from | ||
| * R-001..R-030 — navigator, screen, simple GPU strings, fonts/baseline-only, | ||
| * locale, timezone, hardware basics). Audio precomputed bytes, canvas hash | ||
| * maps, and full WebGL extension catalogs land in phase 0.7. | ||
| * | ||
| * @see PLAN.md §5.3 and §8.4 | ||
| * @see tasks/0030-inject-engine-v0.md | ||
| */ | ||
| export const VERSION = "0.0.1" as const; | ||
| export interface PayloadResult { | ||
| readonly code: string; | ||
| readonly sha256: string; | ||
| } | ||
| export const VERSION = "0.1.0" as const; | ||
| /** | ||
| * Build the inject payload. Lands in phase 0.3. | ||
| */ | ||
| export function buildPayload(_matrix: unknown): PayloadResult { | ||
| throw new Error( | ||
| "@mochi.js/inject.buildPayload is not yet implemented (v0.0.1 claim). " + | ||
| "Lands in phase 0.3; see PLAN.md §5.3.", | ||
| ); | ||
| } | ||
| export { buildPayload, type PayloadResult } from "./build"; |
| import { describe, expect, it } from "bun:test"; | ||
| import { buildPayload, VERSION } from "../index"; | ||
| describe("@mochi.js/inject (claim release)", () => { | ||
| it("exports VERSION", () => { | ||
| expect(VERSION).toMatch(/^\d+\.\d+\.\d+/); | ||
| }); | ||
| it("buildPayload throws until phase 0.3", () => { | ||
| expect(() => buildPayload({})).toThrow(/not yet implemented/); | ||
| }); | ||
| }); |
Deprecated
MaintenanceThe maintainer of the package marked it as deprecated. This could indicate that a single version should not be used, or that the package is no longer maintained and any new vulnerabilities will not be fixed.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
126136
4957.58%27
575%3059
9169.7%1
-50%1
Infinity%1
Infinity%12
Infinity%3
Infinity%