diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..17a14c4
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,37 @@
+## Related Issue
+
+Closes #
+
+## Description
+
+
+
+## Type of Change
+
+- [ ] Bug fix
+- [ ] New feature
+- [ ] Documentation update
+- [ ] Other
+
+## Testing
+
+
+
+## Fingerprint Report
+
+Please submit a report from https://camoufox-tester.vercel.app/ and paste the results below.
+
+
+Fingerprint report
+
+
+
+
+
+## Checklist
+
+- [ ] I have linked a related issue above
+- [ ] My changes are focused on a single logical change
+- [ ] I have added testing instructions which include the desired result
+- [ ] I have included a fingerprint report from https://camoufox-tester.vercel.app/
+- [ ] Service tests pass (`bash service_tests/run_tests.sh`)
diff --git a/.gitignore b/.gitignore
index 52a0d8a..3c04e0f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -71,3 +71,6 @@ closedsrc
/gmp-clearkey/
/windows-build/
+node_modules/
+proxies.txt
+checks-bundle.js
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..e5ba15f
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,82 @@
+# Contributing to Camoufox
+
+Thanks for your interest in contributing! Here's how to get started.
+
+## Ways to Contribute
+
+- **Bug reports** — Open an issue with steps to reproduce, expected behavior, and actual behavior.
+- **Feature requests** — Open an issue describing the use case and why it's useful.
+- **Code contributions** — Fork the repo, make your changes, and open a pull request.
+- **Documentation** — Fixes and improvements to docs are always welcome.
+
+## Development Setup
+See README.md
+
+## Pull Request Rules
+
+1. Each pull request must be associated with a Github issue
+2. Follow the pull request template
+3. Keep commits focused — one logical change per commit.
+4. Open a PR with a clear description of what you changed and why.
+5. All pull requests must pass both the **build-tester** and **service_tests** test suites before merging.
+
+## Testing Requirements
+
+**Both test suites are required for every PR.** They test different layers of the stack and catch different classes of bugs — passing one does not substitute for the other.
+
+### build-tester
+
+Tests the **raw binary** in isolation, bypassing the Python package entirely. Fingerprints are injected manually via `generate_context_fingerprint` + `addInitScript` (per-context mode) and via the `CAMOU_CONFIG` environment variable (global mode). It also validates that injected values actually appear in the page via match result checks.
+
+**Run this when you change:** browser patches, Firefox source modifications, WebGL/canvas/audio spoofing, WebRTC IP handling, or anything in the C++/JS browser layer.
+
+```bash
+cd build-tester
+npm install # first time only
+pip install -r requirements.txt
+python scripts/run_tests.py /path/to/camoufox-binary
+```
+
+See [`build-tester/README.md`](build-tester/README.md) for full details.
+
+---
+
+### service_tests
+
+Tests the **full stack** — the binary and the Python package together — using only the public `AsyncNewContext` API. Fingerprints are generated entirely by camoufox/browserforge with no manual injection. Real proxies are required; the WebRTC IP and timezone are auto-derived from each proxy's exit IP. This is a black-box trust test: if it fails, the fix belongs in the Python package, not in the test.
+
+**Run this when you change:** `pythonlib/` (fingerprint generation, `AsyncNewContext`, `NewContext`), proxy handling, or any behaviour that affects how the Python package interacts with the binary.
+
+```bash
+cd service_tests
+# Add proxies (one per line, format: user:pass@domain:port)
+cp proxies.txt.example proxies.txt # or create manually
+./run_tests.sh
+```
+
+See [`service_tests/README.md`](service_tests/README.md) for full details.
+
+---
+
+### Key differences
+
+| | build-tester | service_tests |
+|---|---|---|
+| Entry point | Raw binary path | `pip install camoufox` |
+| Fingerprint injection | Manual | Via `AsyncNewContext` API |
+| Global mode (`CAMOU_CONFIG`) | ✓ | ✗ |
+| Match result validation | ✓ | ✗ |
+| Proxy required | ✗ | ✓ |
+| Profiles | 8 (6 per-context + 2 global) | 6 (per-context) |
+| Fix target on failure | Browser source | Python package |
+
+## Reporting Issues
+
+Please search existing issues before opening a new one. Include:
+- Camoufox version
+- OS and Python version
+- A minimal reproducible example
+
+## Questions
+
+For usage questions, check the [documentation](https://camoufox.com) first. For anything else, open an issue.
diff --git a/README.md b/README.md
index c78b394..0f53f66 100644
--- a/README.md
+++ b/README.md
@@ -20,18 +20,12 @@ Camoufox is an open source anti-detect browser for robust fingerprint injection
> [!NOTE]
> Browser development is active at [github.com/CloverLabsAI/camoufox](https://github.com/CloverLabsAI/camoufox). ([See activity](https://github.com/CloverLabsAI/camoufox/activity))
-> This repo hosts Python library updates and mirrors upstream browser releases.
+
+> [!NOTE]
+> To make use of the alpha Camoufox releases, use the [`cloverlabs-camoufox`](https://pypi.org/project/cloverlabs-camoufox/) pip package.
-
+
|
@@ -198,6 +192,36 @@ async with AsyncCamoufox() as browser:
[[Installation & usage](https://camoufox.com/python/)]
+### Making Full use of Hardware Spoofing
+
+For stable releases, you should always use the main [`camoufox`](https://pypi.org/project/camoufox/) pip package. However, if you want to make use of per-context fingerprints and hardware spoofing, use the [`cloverlabs-camoufox`](https://pypi.org/project/cloverlabs-camoufox/) package. This package is updated with each releases, whereas the official package is released on delay.
+
+Make sure you are using a virtual env to avoid conflicts between the two packages.
+
+**Installation**
+
+```bash
+pip install cloverlabs-camoufox
+```
+
+**Fetch the latest prerelease browser** (recommended for newest patches)
+
+```bash
+python -m camoufox sync
+python -m camoufox set official/prerelease
+python -m camoufox fetch
+```
+
+**Usage** — the API is identical to the upstream package:
+
+```python
+from camoufox.sync_api import Camoufox
+
+with Camoufox() as browser:
+ page = browser.new_page()
+ page.goto("https://example.com")
+```
+
---
## Capabilities
diff --git a/build-tester/README.md b/build-tester/README.md
new file mode 100644
index 0000000..dabd568
--- /dev/null
+++ b/build-tester/README.md
@@ -0,0 +1,94 @@
+# Camoufox Build Tester
+
+Tests a raw Camoufox binary (Firefox) directly against the same antibot-detection checks used in the service tests. Use this to validate a binary before packaging/releasing — it bypasses the Python package entirely.
+
+## Prerequisites
+
+- Python 3.9+
+- Node.js (for building the TypeScript checks bundle via `esbuild`, first run only)
+
+## Setup
+
+```bash
+# Install npm deps (once — needed to build the checks bundle)
+npm install
+
+# Install Python deps
+pip install -r requirements.txt
+```
+
+## Usage
+
+```bash
+python scripts/run_tests.py [options]
+```
+
+**Example:**
+```bash
+python scripts/run_tests.py /path/to/camoufox-bin/camoufox
+```
+
+## Options
+
+```
+ binary_path Path to the Camoufox (Firefox) binary
+ --profile-count N Number of profiles to test (1-8, default: 8)
+ --secret KEY HMAC signing key for certificate
+ --save-cert PATH Save certificate text to a file
+ --no-cert Skip certificate generation
+```
+
+## What It Tests
+
+8 profiles total, run in two phases:
+
+**Per-context phase (6 profiles)** — 3 macOS + 3 Linux profiles open simultaneously in a single browser instance, each with an isolated fingerprint injected via `addInitScript`. Tests that fingerprints are unique and don't leak between contexts.
+
+**Global phase (2 profiles)** — 1 macOS + 1 Linux profile launched with fingerprint config passed via the `CAMOU_CONFIG` environment variable. Tests that browser-level fingerprint injection works correctly.
+
+Each profile is scored across:
+
+| Category | What it checks |
+|---|---|
+| Automation Detection | Playwright/CDP artefacts |
+| JS Engine | V8 vs SpiderMonkey signals |
+| Lie Detection | Inconsistent property overrides |
+| Firefox APIs | Firefox-specific API presence |
+| Cross-Signal | Consistency across navigator, screen, etc. |
+| CSS Fingerprint | CSS rendering fingerprint |
+| Canvas Noise | Canvas hash uniqueness and stability |
+| WebGL Render | WebGL rendering hash |
+| Audio Integrity | AudioContext fingerprint |
+| Font Platform | OS-consistent font availability |
+| Speech Voices | Voice list matches declared OS |
+| WebRTC | IP spoofing (test IP injected) |
+| Stability | Fingerprint stable over time |
+| Headless Detection | No headless mode signals |
+| Match Results | Injected values actually appear in page |
+
+## How It Differs from the Service Tests
+
+| | Build Tester | Service Tests |
+|---|---|---|
+| Entry point | Raw binary path | `pip install camoufox` |
+| Fingerprint injection | Manual (`generate_context_fingerprint` + init script) | Via `AsyncNewContext` API |
+| Global mode | Yes (`CAMOU_CONFIG` env var) | No |
+| Match validation | Yes (checks injected values match page) | No |
+| Proxy support | No | Yes |
+| Profile count | 8 (6 per-context + 2 global) | 6 (per-context only) |
+
+## The Checks Bundle
+
+`scripts/checks-bundle.js` is a compiled artifact built from the TypeScript sources in `src/lib/checks/`. It is built automatically on first run. To force a rebuild, delete it:
+
+```bash
+rm scripts/checks-bundle.js
+python scripts/run_tests.py
+```
+
+Source files:
+- `src/lib/checks/index.ts` — entry point
+- `src/lib/checks/core.ts` — automation, JS engine, lie detection, etc.
+- `src/lib/checks/extended.ts` — canvas, WebGL, fonts, audio, etc.
+- `src/lib/checks/workers.ts` — worker thread consistency
+- `src/lib/checks/collectors.ts` — fingerprint data collectors (hashes, WebRTC, stability)
diff --git a/build-tester/package-lock.json b/build-tester/package-lock.json
new file mode 100644
index 0000000..1ca1601
--- /dev/null
+++ b/build-tester/package-lock.json
@@ -0,0 +1,496 @@
+{
+ "name": "camoufox-build-tester",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "camoufox-build-tester",
+ "version": "0.1.0",
+ "devDependencies": {
+ "esbuild": "^0.24",
+ "typescript": "^5"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
+ "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
+ "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
+ "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
+ "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
+ "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
+ "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
+ "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
+ "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
+ "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
+ "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
+ "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
+ "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
+ "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
+ "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
+ "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
+ "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
+ "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
+ "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
+ "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
+ "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
+ "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
+ "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
+ "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
+ "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
+ "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
+ "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.24.2",
+ "@esbuild/android-arm": "0.24.2",
+ "@esbuild/android-arm64": "0.24.2",
+ "@esbuild/android-x64": "0.24.2",
+ "@esbuild/darwin-arm64": "0.24.2",
+ "@esbuild/darwin-x64": "0.24.2",
+ "@esbuild/freebsd-arm64": "0.24.2",
+ "@esbuild/freebsd-x64": "0.24.2",
+ "@esbuild/linux-arm": "0.24.2",
+ "@esbuild/linux-arm64": "0.24.2",
+ "@esbuild/linux-ia32": "0.24.2",
+ "@esbuild/linux-loong64": "0.24.2",
+ "@esbuild/linux-mips64el": "0.24.2",
+ "@esbuild/linux-ppc64": "0.24.2",
+ "@esbuild/linux-riscv64": "0.24.2",
+ "@esbuild/linux-s390x": "0.24.2",
+ "@esbuild/linux-x64": "0.24.2",
+ "@esbuild/netbsd-arm64": "0.24.2",
+ "@esbuild/netbsd-x64": "0.24.2",
+ "@esbuild/openbsd-arm64": "0.24.2",
+ "@esbuild/openbsd-x64": "0.24.2",
+ "@esbuild/sunos-x64": "0.24.2",
+ "@esbuild/win32-arm64": "0.24.2",
+ "@esbuild/win32-ia32": "0.24.2",
+ "@esbuild/win32-x64": "0.24.2"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ }
+ }
+}
diff --git a/build-tester/package.json b/build-tester/package.json
new file mode 100644
index 0000000..fa3f948
--- /dev/null
+++ b/build-tester/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "camoufox-build-tester",
+ "version": "0.1.0",
+ "private": true,
+ "devDependencies": {
+ "esbuild": "^0.24",
+ "typescript": "^5"
+ }
+}
diff --git a/build-tester/requirements.txt b/build-tester/requirements.txt
new file mode 100644
index 0000000..783fe58
--- /dev/null
+++ b/build-tester/requirements.txt
@@ -0,0 +1,2 @@
+cloverlabs-camoufox
+playwright
diff --git a/build-tester/scripts/bundle.py b/build-tester/scripts/bundle.py
new file mode 100644
index 0000000..06062dc
--- /dev/null
+++ b/build-tester/scripts/bundle.py
@@ -0,0 +1,48 @@
+"""
+Manages the esbuild bundle of the checks library.
+Builds checks-bundle.js from TypeScript source on first run.
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+
+def ensure_bundle(project_dir: Path) -> Path:
+ bundle_path = project_dir / "scripts" / "checks-bundle.js"
+ if bundle_path.exists():
+ return bundle_path
+
+ node_modules = project_dir / "node_modules"
+ if not node_modules.exists():
+ print("ERROR: node_modules not found. Run 'npm install' first.", file=sys.stderr)
+ sys.exit(1)
+
+ esbuild = project_dir / "node_modules" / ".bin" / "esbuild"
+ if sys.platform == "win32":
+ esbuild_cmd_path = project_dir / "node_modules" / ".bin" / "esbuild.cmd"
+ if esbuild_cmd_path.exists():
+ esbuild = esbuild_cmd_path
+
+ print("Building checks bundle (first run)...")
+ entry = project_dir / "src" / "lib" / "checks" / "index.ts"
+ result = subprocess.run(
+ [
+ str(esbuild),
+ str(entry),
+ "--bundle",
+ "--platform=browser",
+ "--target=es2017",
+ "--format=iife",
+ "--global-name=CamoufoxChecks",
+ f"--outfile={bundle_path}",
+ ],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ print(f"ERROR: esbuild failed:\n{result.stderr}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Bundle built: {bundle_path}")
+ return bundle_path
diff --git a/build-tester/scripts/certificate.py b/build-tester/scripts/certificate.py
new file mode 100644
index 0000000..5a4174f
--- /dev/null
+++ b/build-tester/scripts/certificate.py
@@ -0,0 +1,334 @@
+"""
+Certificate generation (scoring + HMAC signing) and all ASCII output/display.
+"""
+
+import hashlib
+import hmac as hmac_module
+import json
+import re
+import uuid
+
+from constants import (
+ BOLD, CATEGORY_LABELS, CYAN, GREEN, RED, RESET, YELLOW,
+ grade_color, strip_ansi,
+)
+
+# ─── Box Drawing ──────────────────────────────────────────────────────────────
+
+CAT_ART = r""" /\_____/\
+ / o o \
+ ( == ^ == )
+ ) (
+ ( ) ( )
+ ( ( ) ( ) )
+(__(__)___(__)__)"""
+
+BOX_W = 60 # inner visible width of the certificate box
+
+
+def box_line(inner: str) -> str:
+ """Pad inner content to BOX_W visible characters and wrap in box borders."""
+ visible = len(strip_ansi(inner))
+ return f"║{inner}{' ' * max(0, BOX_W - visible)}║"
+
+
+def box_sep() -> str:
+ return f"╠{'═' * BOX_W}╣"
+
+
+def box_top() -> str:
+ return f"╔{'═' * BOX_W}╗"
+
+
+def box_bot() -> str:
+ return f"╚{'═' * BOX_W}╝"
+
+
+def format_section_line(name: str, passed: int, total: int) -> str:
+ ok = passed == total
+ score = f"{passed}/{total}"
+ status_visible = "[PASS]" if ok else f"[{total - passed} FAIL]"
+ status_ansi = f"{GREEN}{status_visible}{RESET}" if ok else f"{RED}{status_visible}{RESET}"
+
+ prefix_vis = f" {name} "
+ suffix_vis = f" {score} {status_visible} "
+ dots_len = max(1, BOX_W - len(prefix_vis) - len(suffix_vis))
+
+ inner = f" {name} {'.' * dots_len} {score} {status_ansi} "
+ return box_line(inner)
+
+
+# ─── Profile Result Printing ──────────────────────────────────────────────────
+
+def print_profile_result(pr: dict) -> None:
+ profile = pr["profile"]
+ grade = pr.get("grade", "F")
+ pass_count = pr.get("passCount", 0)
+ total_checks = pr.get("totalChecks", 0)
+ error = pr.get("error")
+
+ gc = grade_color(grade)
+
+ if error:
+ print(f" {RED}✗{RESET} {profile['name']}: {RED}ERROR{RESET} — {error}")
+ return
+
+ tick = "✓" if grade in ("A", "B") else "✗"
+ print(f" {tick} {profile['name']}: {gc}{BOLD}[{grade}]{RESET} {pass_count}/{total_checks}")
+
+ for m in pr.get("matchResults", []):
+ if not m.get("passed"):
+ print(f" {RED}✗ {m['name']}: expected {m.get('expected', '?')}, got {m.get('actual', '?')}{RESET}")
+
+
+# ─── Certificate Generation ───────────────────────────────────────────────────
+
+def compute_section_results(results: dict) -> list:
+ sections = []
+ all_categories = {**results.get("core", {}), **results.get("extended", {}), **results.get("workers", {})}
+
+ for key, checks in all_categories.items():
+ if key == "webglExtended":
+ continue
+ if not isinstance(checks, dict):
+ continue
+ passed = total = 0
+ for check in checks.values():
+ if check and isinstance(check.get("passed"), bool):
+ total += 1
+ if check["passed"]:
+ passed += 1
+ if total > 0:
+ sections.append({"name": CATEGORY_LABELS.get(key, key), "passed": passed, "total": total})
+
+ webrtc = results.get("webrtc", {})
+ stability = results.get("stability", {})
+ sections.append({"name": "WebRTC", "passed": 1 if webrtc.get("passed") else 0, "total": 1})
+ sections.append({"name": "Stability", "passed": 1 if stability.get("stable") else 0, "total": 1})
+ return sections
+
+
+def generate_certificate(full_result: dict, secret: str) -> dict:
+ all_section_results: list = []
+ all_failed_tests: list = []
+
+ for pr in full_result["profiles"]:
+ if not pr.get("results"):
+ all_failed_tests.append(f"{pr['profile']['name']}: Error — {pr.get('error', 'unknown')}")
+ continue
+
+ sections = compute_section_results(pr["results"])
+ for s in sections:
+ existing = next((e for e in all_section_results if e["name"] == s["name"]), None)
+ if existing:
+ existing["passed"] += s["passed"]
+ existing["total"] += s["total"]
+ else:
+ all_section_results.append(dict(s))
+
+ results = pr["results"]
+ all_cats = {**results.get("core", {}), **results.get("extended", {}), **results.get("workers", {})}
+ for cat_key, checks in all_cats.items():
+ if not isinstance(checks, dict):
+ continue
+ for check_name, check in checks.items():
+ if check and isinstance(check.get("passed"), bool) and not check["passed"]:
+ label = CATEGORY_LABELS.get(cat_key, cat_key)
+ all_failed_tests.append(f"{pr['profile']['name']}: {label}: {check_name} — {check.get('detail', '')}")
+
+ webrtc = results.get("webrtc", {})
+ stability = results.get("stability", {})
+ if not webrtc.get("passed"):
+ all_failed_tests.append(f"{pr['profile']['name']}: WebRTC: {webrtc.get('detail', '')}")
+ if not stability.get("stable"):
+ all_failed_tests.append(f"{pr['profile']['name']}: Stability: Fingerprints changed between runs (unstable)")
+
+ for m in pr.get("matchResults", []):
+ if not m.get("passed"):
+ all_failed_tests.append(f"{pr['profile']['name']}: {m['name']} expected {m.get('expected')}, got {m.get('actual')}")
+
+ # Cross-profile uniqueness sections
+ cp = full_result["crossProfile"]
+ mac = cp.get("macPerContext", {})
+ linux = cp.get("linuxPerContext", {})
+
+ if mac.get("total", 0) > 0:
+ mac_unique = (
+ (1 if mac.get("uniqueAudio") == mac["total"] else 0)
+ + (1 if mac.get("uniqueCanvas") == mac["total"] else 0)
+ + (1 if mac.get("uniqueTimezones") == mac["total"] else 0)
+ + (1 if mac.get("uniqueScreens") == mac["total"] else 0)
+ )
+ all_section_results.append({"name": "Mac Uniqueness", "passed": mac_unique, "total": 4})
+
+ if linux.get("total", 0) > 0:
+ linux_unique = (
+ (1 if linux.get("uniqueAudio") == linux["total"] else 0)
+ + (1 if linux.get("uniqueCanvas") == linux["total"] else 0)
+ + (1 if linux.get("uniqueTimezones") == linux["total"] else 0)
+ + (1 if linux.get("uniqueScreens") == linux["total"] else 0)
+ )
+ all_section_results.append({"name": "Linux Uniqueness", "passed": linux_unique, "total": 4})
+
+ # Compute results hash (matching Certificate.tsx)
+ hash_data = {
+ "profiles": [
+ {
+ "name": p["profile"]["name"],
+ "grade": p["grade"],
+ "passCount": p["passCount"],
+ "totalChecks": p["totalChecks"],
+ }
+ for p in full_result["profiles"]
+ ],
+ "crossProfile": full_result["crossProfile"],
+ "timestamp": full_result["timestamp"],
+ }
+ results_hash = hashlib.sha256(json.dumps(hash_data, separators=(",", ":")).encode()).hexdigest()
+
+ # HMAC-SHA256 signature
+ signature = hmac_module.new(secret.encode(), results_hash.encode(), hashlib.sha256).hexdigest()
+
+ # Extract user agent
+ ua = ""
+ for pr in full_result["profiles"]:
+ if pr.get("results"):
+ ua = pr["results"].get("fingerprints", {}).get("navigator", {}).get("userAgent", "")
+ break
+
+ fx_match = re.search(r"Firefox/(\d+\.\d+)", ua)
+ camoufox_version = f"Firefox {fx_match.group(1)}" if fx_match else ua[:60]
+
+ return {
+ "id": str(uuid.uuid4()),
+ "signature": signature,
+ "resultsHash": results_hash,
+ "timestamp": full_result["timestamp"],
+ "platform": "Multi-OS",
+ "camoufoxVersion": camoufox_version,
+ "passCount": full_result["totalPassed"],
+ "totalTests": full_result["totalChecks"],
+ "overallPass": full_result["totalPassed"] == full_result["totalChecks"],
+ "sectionResults": all_section_results,
+ "failedTests": all_failed_tests[:20],
+ "profileCount": len(full_result["profiles"]),
+ }
+
+
+# ─── ASCII Certificate Display ────────────────────────────────────────────────
+
+def build_certificate_text(cert: dict, cross_profile: dict, overall_grade: str) -> str:
+ """Build the full ASCII certificate as a plain-text string (no ANSI)."""
+ lines = []
+ w = BOX_W
+
+ lines.append(CAT_ART)
+ lines.append("")
+ lines.append(f"╔{'═' * w}╗")
+
+ title = "CAMOUFOX BUILD VERIFICATION CERTIFICATE"
+ lines.append(f"║{title:^{w}}║")
+ lines.append(f"╠{'═' * w}╣")
+
+ grade_line = f" Grade: {overall_grade} Score: {cert['passCount']}/{cert['totalTests']} Profiles: {cert['profileCount']}"
+ lines.append(f"║{grade_line:<{w}}║")
+ ts = cert["timestamp"]
+ lines.append(f"║ Issued: {ts:<{w-10}}║")
+ status_text = "ALL PASS" if cert["overallPass"] else "FAILURES DETECTED"
+ lines.append(f"║ Status: {status_text:<{w-10}}║")
+
+ lines.append(f"╠{'═' * w}╣")
+ lines.append(f"║{' SECTION RESULTS':<{w}}║")
+
+ for s in cert.get("sectionResults", []):
+ name = s["name"]
+ passed = s["passed"]
+ total = s["total"]
+ score = f"{passed}/{total}"
+ status = "[PASS]" if passed == total else f"[{total - passed} FAIL]"
+ prefix = f" {name} "
+ suffix = f" {score} {status} "
+ dots = "." * max(1, w - len(prefix) - len(suffix))
+ line = f"{prefix}{dots}{suffix}"
+ lines.append(f"║{line:<{w}}║")
+
+ lines.append(f"╠{'═' * w}╣")
+ lines.append(f"║{' CROSS-PROFILE UNIQUENESS':<{w}}║")
+
+ mac = cross_profile.get("macPerContext", {})
+ linux = cross_profile.get("linuxPerContext", {})
+ if mac.get("total", 0) > 0:
+ t = mac["total"]
+ line = f" macOS Audio:{mac.get('uniqueAudio', 0)}/{t} Canvas:{mac.get('uniqueCanvas', 0)}/{t} TZ:{mac.get('uniqueTimezones', 0)}/{t} Screen:{mac.get('uniqueScreens', 0)}/{t}"
+ lines.append(f"║{line:<{w}}║")
+ if linux.get("total", 0) > 0:
+ t = linux["total"]
+ line = f" Linux Audio:{linux.get('uniqueAudio', 0)}/{t} Canvas:{linux.get('uniqueCanvas', 0)}/{t} TZ:{linux.get('uniqueTimezones', 0)}/{t} Screen:{linux.get('uniqueScreens', 0)}/{t}"
+ lines.append(f"║{line:<{w}}║")
+
+ lines.append(f"╠{'═' * w}╣")
+ cert_id = cert["id"]
+ results_hash = cert["resultsHash"]
+ signature = cert["signature"]
+ lines.append(f"║ ID: {cert_id:<{w-8}}║")
+ lines.append(f"║ Hash: {results_hash[:48]}...{'':<{w - 56}}║")
+ lines.append(f"║ Sig: {signature[:48]}...{'':<{w - 56}}║")
+ lines.append(f"╚{'═' * w}╝")
+
+ return "\n".join(lines)
+
+
+def print_certificate(cert: dict, cross_profile: dict, overall_grade: str) -> None:
+ gc = grade_color(overall_grade)
+ w = BOX_W
+
+ print()
+ print(CYAN + CAT_ART + RESET)
+ print()
+ print(BOLD + box_top() + RESET)
+
+ title = "CAMOUFOX BUILD VERIFICATION CERTIFICATE"
+ print(BOLD + box_line(f"{title:^{w}}") + RESET)
+ print(BOLD + box_sep() + RESET)
+
+ grade_inner = f" {gc}{BOLD}Grade: {overall_grade}{RESET} Score: {cert['passCount']}/{cert['totalTests']} Profiles: {cert['profileCount']}"
+ print(box_line(grade_inner))
+
+ ts = cert["timestamp"]
+ print(box_line(f" Issued: {ts}"))
+
+ if cert["overallPass"]:
+ status_inner = f" Status: {GREEN}ALL PASS{RESET}"
+ else:
+ status_inner = f" Status: {RED}FAILURES DETECTED{RESET}"
+ print(box_line(status_inner))
+
+ print(BOLD + box_sep() + RESET)
+ print(box_line(f" {BOLD}SECTION RESULTS{RESET}"))
+
+ for s in cert.get("sectionResults", []):
+ print(format_section_line(s["name"], s["passed"], s["total"]))
+
+ print(BOLD + box_sep() + RESET)
+ print(box_line(f" {BOLD}CROSS-PROFILE UNIQUENESS{RESET}"))
+
+ mac = cross_profile.get("macPerContext", {})
+ linux = cross_profile.get("linuxPerContext", {})
+ if mac.get("total", 0) > 0:
+ t = mac["total"]
+ line = f" macOS Audio:{mac.get('uniqueAudio', 0)}/{t} Canvas:{mac.get('uniqueCanvas', 0)}/{t} TZ:{mac.get('uniqueTimezones', 0)}/{t} Screen:{mac.get('uniqueScreens', 0)}/{t}"
+ print(box_line(line))
+ if linux.get("total", 0) > 0:
+ t = linux["total"]
+ line = f" Linux Audio:{linux.get('uniqueAudio', 0)}/{t} Canvas:{linux.get('uniqueCanvas', 0)}/{t} TZ:{linux.get('uniqueTimezones', 0)}/{t} Screen:{linux.get('uniqueScreens', 0)}/{t}"
+ print(box_line(line))
+
+ print(BOLD + box_sep() + RESET)
+ cert_id = cert["id"]
+ results_hash = cert["resultsHash"]
+ signature = cert["signature"]
+ print(box_line(f" ID: {cert_id}"))
+ print(box_line(f" Hash: {results_hash[:48]}..."))
+ print(box_line(f" Sig: {signature[:48]}..."))
+ print(BOLD + box_bot() + RESET)
+ print()
diff --git a/build-tester/scripts/constants.py b/build-tester/scripts/constants.py
new file mode 100644
index 0000000..6e9c652
--- /dev/null
+++ b/build-tester/scripts/constants.py
@@ -0,0 +1,72 @@
+"""
+Shared constants, ANSI color codes, and simple formatting helpers.
+"""
+
+import re
+
+# ─── Test Configuration ───────────────────────────────────────────────────────
+
+TEST_TIMEZONES = [
+ "America/New_York",
+ "America/Chicago",
+ "America/Los_Angeles",
+ "Europe/London",
+ "Europe/Berlin",
+ "Asia/Tokyo",
+ "America/Denver",
+ "Australia/Sydney",
+]
+
+WEBRTC_TEST_IP = "203.0.113.1"
+
+FIREFOX_WEBGL_PREFS = {
+ "webgl.force-enabled": True,
+ "webgl.enable-webgl2": True,
+}
+
+CATEGORY_LABELS = {
+ "automation": "Automation Detection",
+ "jsEngine": "JS Engine",
+ "lieDetection": "Lie Detection",
+ "firefoxAPIs": "Firefox APIs",
+ "crossSignal": "Cross-Signal",
+ "cssFingerprint": "CSS Fingerprint",
+ "mathEngine": "Math Engine",
+ "permissionsAPI": "Permissions",
+ "speechVoices": "Speech Voices",
+ "performanceAPI": "Performance",
+ "intlConsistency": "Intl Consistency",
+ "emojiFingerprint": "Emoji",
+ "canvasNoiseDetection": "Canvas Noise",
+ "webglRenderHash": "WebGL Render",
+ "fontPlatformConsistency": "Font Platform",
+ "audioIntegrity": "Audio Integrity",
+ "iframeTesting": "Iframe Testing",
+ "workerConsistency": "Workers",
+ "headlessDetection": "Headless Detection",
+ "trashDetection": "Trash Detection",
+ "fontEnvironment": "Font Environment",
+}
+
+# ─── ANSI Colors ──────────────────────────────────────────────────────────────
+
+GREEN = "\033[92m"
+RED = "\033[91m"
+YELLOW = "\033[93m"
+CYAN = "\033[96m"
+BOLD = "\033[1m"
+RESET = "\033[0m"
+
+ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
+
+
+def strip_ansi(s: str) -> str:
+ return ANSI_ESCAPE_RE.sub("", s)
+
+
+def grade_color(g: str) -> str:
+ if g == "A":
+ return GREEN
+ if g in ("B", "C"):
+ return YELLOW
+ return RED
diff --git a/build-tester/scripts/generate-presets.py b/build-tester/scripts/generate-presets.py
new file mode 100644
index 0000000..9f15558
--- /dev/null
+++ b/build-tester/scripts/generate-presets.py
@@ -0,0 +1,81 @@
+"""
+Generates per-context + global fingerprint configs using the Camoufox Python API.
+Called by the camoufox-tester to get realistic fingerprint data.
+
+Output: JSON object to stdout with macPerContext, linuxPerContext, macGlobal, linuxGlobal
+"""
+import json
+import sys
+
+from camoufox.fingerprints import generate_context_fingerprint
+
+
+def convert_preset(ctx):
+ """Convert a generate_context_fingerprint() result to camelCase for TypeScript."""
+ preset = ctx['preset']
+ config = ctx['config']
+ nav = preset.get('navigator', {})
+ screen = preset.get('screen', {})
+ webgl = preset.get('webgl', {})
+
+ return {
+ 'initScript': ctx['init_script'],
+ 'contextOptions': {
+ 'userAgent': ctx['context_options'].get('user_agent'),
+ 'viewport': ctx['context_options'].get('viewport'),
+ 'deviceScaleFactor': ctx['context_options'].get('device_scale_factor'),
+ 'locale': ctx['context_options'].get('locale'),
+ 'timezoneId': ctx['context_options'].get('timezone_id'),
+ },
+ 'camouConfig': config,
+ 'profileConfig': {
+ 'fontSpacingSeed': config.get('fonts:spacing_seed', 0),
+ 'audioSeed': config.get('audio:seed', 0),
+ 'canvasSeed': config.get('canvas:seed', 0),
+ 'screenWidth': screen.get('width', 1920),
+ 'screenHeight': screen.get('height', 1080),
+ 'screenColorDepth': screen.get('colorDepth', 24),
+ 'navigatorPlatform': nav.get('platform', ''),
+ 'navigatorOscpu': config.get('navigator.oscpu', ''),
+ 'navigatorUserAgent': config.get('navigator.userAgent', ''),
+ 'hardwareConcurrency': nav.get('hardwareConcurrency', 0),
+ 'webglVendor': webgl.get('unmaskedVendor', ''),
+ 'webglRenderer': webgl.get('unmaskedRenderer', ''),
+ 'timezone': config.get('timezone', preset.get('timezone', '')),
+ 'fontList': config.get('fonts', preset.get('fonts', [])),
+ 'speechVoices': config.get('voices', preset.get('speechVoices', [])),
+ },
+ }
+
+
+def main():
+ results = {
+ 'macPerContext': [],
+ 'linuxPerContext': [],
+ 'macGlobal': None,
+ 'linuxGlobal': None,
+ }
+
+ # 3 macOS per-context profiles
+ for _ in range(3):
+ ctx = generate_context_fingerprint(os='macos')
+ results['macPerContext'].append(convert_preset(ctx))
+
+ # 3 Linux per-context profiles
+ for _ in range(3):
+ ctx = generate_context_fingerprint(os='linux')
+ results['linuxPerContext'].append(convert_preset(ctx))
+
+ # 1 macOS global profile
+ ctx = generate_context_fingerprint(os='macos')
+ results['macGlobal'] = convert_preset(ctx)
+
+ # 1 Linux global profile
+ ctx = generate_context_fingerprint(os='linux')
+ results['linuxGlobal'] = convert_preset(ctx)
+
+ json.dump(results, sys.stdout)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/build-tester/scripts/grading.py b/build-tester/scripts/grading.py
new file mode 100644
index 0000000..aa5a133
--- /dev/null
+++ b/build-tester/scripts/grading.py
@@ -0,0 +1,160 @@
+"""
+Grading, check counting, match verification, and cross-profile analysis.
+"""
+
+import sys
+
+
+# ─── Grading ──────────────────────────────────────────────────────────────────
+
+def compute_grade(pass_count: int, total_checks: int) -> str:
+ fail_count = total_checks - pass_count
+ if fail_count == 0:
+ return "A"
+ if fail_count <= 2:
+ return "B"
+ if fail_count <= 5:
+ return "C"
+ if fail_count <= 10:
+ return "D"
+ return "F"
+
+
+def count_checks(categories: dict) -> tuple:
+ passed = total = 0
+ for cat in categories.values():
+ if not isinstance(cat, dict):
+ continue
+ for check in cat.values():
+ if check and isinstance(check.get("passed"), bool):
+ total += 1
+ if check["passed"]:
+ passed += 1
+ return passed, total
+
+
+def count_all_checks(profile: dict, results: dict, match_results: list) -> tuple:
+ pass_count = total_checks = 0
+
+ for category_name in ("core", "extended", "workers"):
+ p, t = count_checks(results.get(category_name, {}))
+ pass_count += p
+ total_checks += t
+
+ # WebRTC
+ total_checks += 1
+ if results.get("webrtc", {}).get("passed"):
+ pass_count += 1
+
+ # Stability
+ total_checks += 1
+ if results.get("stability", {}).get("stable"):
+ pass_count += 1
+
+ # Match results
+ for m in match_results:
+ total_checks += 1
+ if m.get("passed"):
+ pass_count += 1
+
+ # Self-destruct (per-context only)
+ if profile.get("mode") == "per-context" and results.get("selfDestruct"):
+ for check in results["selfDestruct"].values():
+ if check and isinstance(check.get("passed"), bool):
+ total_checks += 1
+ if check["passed"]:
+ pass_count += 1
+
+ return pass_count, total_checks
+
+
+# ─── Match Verification ───────────────────────────────────────────────────────
+
+def adjust_cross_os_font_checks(profile: dict, results: dict) -> None:
+ host_os = "macos" if sys.platform == "darwin" else ("windows" if sys.platform == "win32" else "linux")
+ if profile["os"] == host_os:
+ return
+
+ font_env = results.get("extended", {}).get("fontEnvironment")
+ if not font_env:
+ return
+
+ for key in ("osDetection", "noWrongOSFonts"):
+ check = font_env.get(key)
+ if check and not check.get("passed"):
+ check["passed"] = True
+ check["detail"] = "[Cross-OS: expected] " + check.get("detail", "")
+
+
+def compute_match_results(profile: dict, results: dict) -> list:
+ matches = []
+ fp = results.get("fingerprints", {})
+ nav = fp.get("navigator", {})
+ tz = fp.get("timezone", {})
+ screen = fp.get("screen", {})
+ webgl = fp.get("webgl", {})
+
+ if profile["mode"] == "per-context":
+ matches.append({"name": "navigator.userAgent", "passed": nav.get("userAgent") == profile["userAgent"], "expected": profile["userAgent"], "actual": nav.get("userAgent", "")})
+ matches.append({"name": "navigator.platform", "passed": nav.get("platform") == profile["platform"], "expected": profile["platform"], "actual": nav.get("platform", "")})
+ matches.append({"name": "navigator.oscpu", "passed": nav.get("oscpu") == profile["oscpu"], "expected": profile["oscpu"], "actual": nav.get("oscpu", "")})
+ matches.append({"name": "navigator.hardwareConcurrency", "passed": nav.get("hardwareConcurrency") == profile["hardwareConcurrency"], "expected": str(profile["hardwareConcurrency"]), "actual": str(nav.get("hardwareConcurrency", ""))})
+ matches.append({"name": "timezone", "passed": tz.get("timezone") == profile["timezone"], "expected": profile["timezone"], "actual": tz.get("timezone", "")})
+ matches.append({"name": "screen.width", "passed": screen.get("width") == profile["screenWidth"], "expected": str(profile["screenWidth"]), "actual": str(screen.get("width", ""))})
+ matches.append({"name": "screen.height", "passed": screen.get("height") == profile["screenHeight"], "expected": str(profile["screenHeight"]), "actual": str(screen.get("height", ""))})
+ if profile.get("webglVendor") and webgl:
+ matches.append({"name": "webgl.vendor", "passed": webgl.get("unmaskedVendor") == profile["webglVendor"], "expected": profile["webglVendor"], "actual": webgl.get("unmaskedVendor", "(unavailable)")})
+ if profile.get("webglRenderer") and webgl:
+ matches.append({"name": "webgl.renderer", "passed": webgl.get("unmaskedRenderer") == profile["webglRenderer"], "expected": profile["webglRenderer"], "actual": webgl.get("unmaskedRenderer", "(unavailable)")})
+ else:
+ matches.append({"name": "navigator.userAgent (global)", "passed": nav.get("userAgent") == profile["userAgent"], "expected": profile["userAgent"], "actual": nav.get("userAgent", "")})
+ matches.append({"name": "navigator.platform (global)", "passed": nav.get("platform") == profile["platform"], "expected": profile["platform"], "actual": nav.get("platform", "")})
+ matches.append({"name": "navigator.oscpu (global)", "passed": nav.get("oscpu") == profile["oscpu"], "expected": profile["oscpu"], "actual": nav.get("oscpu", "")})
+ matches.append({"name": "hardwareConcurrency (global)", "passed": nav.get("hardwareConcurrency") == profile["hardwareConcurrency"], "expected": str(profile["hardwareConcurrency"]), "actual": str(nav.get("hardwareConcurrency", ""))})
+ matches.append({"name": "timezone (global)", "passed": tz.get("timezone") == profile["timezone"], "expected": profile["timezone"], "actual": tz.get("timezone", "")})
+
+ return matches
+
+
+# ─── Cross-Profile Analysis ───────────────────────────────────────────────────
+
+def compute_cross_profile(profile_results: list) -> dict:
+ mac_ctx = [p for p in profile_results if p["profile"]["os"] == "macos" and p["profile"]["mode"] == "per-context"]
+ linux_ctx = [p for p in profile_results if p["profile"]["os"] == "linux" and p["profile"]["mode"] == "per-context"]
+
+ def analyze(group: list) -> dict:
+ if not group:
+ return {"uniqueAudio": 0, "uniqueCanvas": 0, "uniqueFonts": 0, "uniqueTimezones": 0,
+ "uniqueScreens": 0, "uniqueVoices": 0, "uniqueWebGL": 0, "uniquePlatforms": 0, "total": 0}
+
+ audio, canvas, fonts, timezones, screens, voices, webgl_set, platforms = set(), set(), set(), set(), set(), set(), set(), set()
+
+ for p in group:
+ fp = (p.get("results") or {}).get("fingerprints") or {}
+ if fp.get("audio", {}).get("hash"):
+ audio.add(fp["audio"]["hash"])
+ if fp.get("canvas", {}).get("hash"):
+ canvas.add(fp["canvas"]["hash"])
+ if fp.get("fonts", {}).get("hash"):
+ fonts.add(fp["fonts"]["hash"])
+ if fp.get("timezone", {}).get("timezone"):
+ timezones.add(fp["timezone"]["timezone"])
+ s = fp.get("screen", {})
+ if s:
+ screens.add(f"{s.get('width')}x{s.get('height')}")
+ if fp.get("speechVoices", {}).get("hash"):
+ voices.add(fp["speechVoices"]["hash"])
+ w = fp.get("webgl", {})
+ if w:
+ webgl_set.add(f"{w.get('unmaskedVendor')}|{w.get('unmaskedRenderer')}")
+ if fp.get("navigator", {}).get("platform"):
+ platforms.add(fp["navigator"]["platform"])
+
+ return {
+ "uniqueAudio": len(audio), "uniqueCanvas": len(canvas), "uniqueFonts": len(fonts),
+ "uniqueTimezones": len(timezones), "uniqueScreens": len(screens),
+ "uniqueVoices": len(voices), "uniqueWebGL": len(webgl_set),
+ "uniquePlatforms": len(platforms), "total": len(group),
+ }
+
+ return {"macPerContext": analyze(mac_ctx), "linuxPerContext": analyze(linux_ctx)}
diff --git a/build-tester/scripts/presets.py b/build-tester/scripts/presets.py
new file mode 100644
index 0000000..4bef610
--- /dev/null
+++ b/build-tester/scripts/presets.py
@@ -0,0 +1,119 @@
+"""
+Fingerprint preset generation, injection, and profile config conversion.
+"""
+
+import json
+import re
+import sys
+
+from constants import TEST_TIMEZONES, WEBRTC_TEST_IP
+
+
+# ─── Preset Generation ────────────────────────────────────────────────────────
+
+def convert_preset(ctx: dict) -> dict:
+ """Convert generate_context_fingerprint() result to camelCase dict."""
+ preset = ctx["preset"]
+ config = ctx["config"]
+ nav = preset.get("navigator", {})
+ screen = preset.get("screen", {})
+ webgl = preset.get("webgl", {})
+
+ return {
+ "initScript": ctx["init_script"],
+ "contextOptions": {
+ "userAgent": ctx["context_options"].get("user_agent"),
+ "viewport": ctx["context_options"].get("viewport"),
+ "deviceScaleFactor": ctx["context_options"].get("device_scale_factor"),
+ "locale": ctx["context_options"].get("locale"),
+ "timezoneId": ctx["context_options"].get("timezone_id"),
+ },
+ "camouConfig": config,
+ "profileConfig": {
+ "fontSpacingSeed": config.get("fonts:spacing_seed", 0),
+ "audioSeed": config.get("audio:seed", 0),
+ "canvasSeed": config.get("canvas:seed", 0),
+ "screenWidth": screen.get("width", 1920),
+ "screenHeight": screen.get("height", 1080),
+ "screenColorDepth": screen.get("colorDepth", 24),
+ "navigatorPlatform": nav.get("platform", ""),
+ "navigatorOscpu": config.get("navigator.oscpu", ""),
+ "navigatorUserAgent": config.get("navigator.userAgent", ""),
+ "hardwareConcurrency": nav.get("hardwareConcurrency", 0),
+ "webglVendor": webgl.get("unmaskedVendor", ""),
+ "webglRenderer": webgl.get("unmaskedRenderer", ""),
+ "timezone": config.get("timezone", preset.get("timezone", "")),
+ "fontList": config.get("fonts", preset.get("fonts", [])),
+ "speechVoices": config.get("voices", preset.get("speechVoices", [])),
+ },
+ }
+
+
+def generate_presets() -> dict:
+ try:
+ from camoufox.fingerprints import generate_context_fingerprint
+ except ImportError:
+ print(
+ "ERROR: camoufox Python package not installed.\n"
+ " Run: pip install camoufox (or: bash scripts/setup.sh)",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+ print(" Generating 3 macOS per-context profiles...")
+ mac_per_context = [convert_preset(generate_context_fingerprint(os="macos")) for _ in range(3)]
+ print(" Generating 3 Linux per-context profiles...")
+ linux_per_context = [convert_preset(generate_context_fingerprint(os="linux")) for _ in range(3)]
+ print(" Generating macOS global profile...")
+ mac_global = convert_preset(generate_context_fingerprint(os="macos"))
+ print(" Generating Linux global profile...")
+ linux_global = convert_preset(generate_context_fingerprint(os="linux"))
+
+ return {
+ "macPerContext": mac_per_context,
+ "linuxPerContext": linux_per_context,
+ "macGlobal": mac_global,
+ "linuxGlobal": linux_global,
+ }
+
+
+# ─── Preset Injection ─────────────────────────────────────────────────────────
+
+def inject_timezone(preset: dict, timezone: str) -> None:
+ preset["initScript"] = re.sub(
+ r"w\.setTimezone\(Intl\.DateTimeFormat\(\)\.resolvedOptions\(\)\.timeZone\)",
+ f"w.setTimezone({json.dumps(timezone)})",
+ preset["initScript"],
+ )
+ preset["contextOptions"]["timezoneId"] = timezone
+ preset["profileConfig"]["timezone"] = timezone
+ preset["camouConfig"]["timezone"] = timezone
+
+
+def inject_webrtc_ip(preset: dict) -> None:
+ preset["initScript"] = re.sub(
+ r'w\.setWebRTCIPv4\(""\)',
+ f"w.setWebRTCIPv4({json.dumps(WEBRTC_TEST_IP)})",
+ preset["initScript"],
+ )
+
+
+# ─── Profile Config ───────────────────────────────────────────────────────────
+
+def preset_to_profile_config(preset: dict, name: str, os_type: str, mode: str) -> dict:
+ pc = preset["profileConfig"]
+ return {
+ "name": name,
+ "os": os_type,
+ "mode": mode,
+ "platform": pc["navigatorPlatform"],
+ "oscpu": pc["navigatorOscpu"],
+ "userAgent": pc["navigatorUserAgent"],
+ "hardwareConcurrency": pc["hardwareConcurrency"],
+ "screenWidth": pc["screenWidth"],
+ "screenHeight": pc["screenHeight"],
+ "colorDepth": pc["screenColorDepth"],
+ "timezone": pc["timezone"],
+ "webglVendor": pc["webglVendor"],
+ "webglRenderer": pc["webglRenderer"],
+ }
diff --git a/build-tester/scripts/run_tests.py b/build-tester/scripts/run_tests.py
new file mode 100755
index 0000000..8a4af41
--- /dev/null
+++ b/build-tester/scripts/run_tests.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""
+Camoufox Build Tester — Python CLI
+
+Runs the same antibot-detection checks as the Next.js web app,
+but as a standalone CLI with ASCII art certificate output.
+
+Usage:
+ python scripts/run_tests.py [options]
+
+Options:
+ --profile-count N Number of profiles to test (1-8, default: 8)
+ --secret KEY HMAC signing key for certificate
+ --save-cert PATH Save certificate text to this file
+ --no-cert Skip certificate generation
+"""
+
+import argparse
+import asyncio
+import os
+import sys
+
+from runner import run_tests
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Camoufox Build Tester — runs antibot-detection checks via Playwright",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("binary_path", help="Path to the Camoufox (Firefox) binary")
+ parser.add_argument(
+ "--profile-count", type=int, default=8, metavar="N",
+ help="Number of profiles to test, 1-8 (default: 8)",
+ )
+ parser.add_argument(
+ "--secret", default="camoufox-tester-dev-secret", metavar="KEY",
+ help="HMAC signing key for the certificate (default: dev secret)",
+ )
+ parser.add_argument(
+ "--save-cert", metavar="PATH",
+ help="Save the ASCII certificate to this file",
+ )
+ parser.add_argument(
+ "--no-cert", action="store_true",
+ help="Skip certificate generation",
+ )
+ args = parser.parse_args()
+
+ profile_count = max(1, min(8, args.profile_count))
+
+ binary_path = args.binary_path
+ # Resolve macOS .app bundle to internal binary
+ if sys.platform == "darwin" and binary_path.endswith(".app"):
+ candidate = os.path.join(binary_path, "Contents", "MacOS", "camoufox")
+ if os.path.isfile(candidate):
+ binary_path = candidate
+ else:
+ candidate2 = os.path.join(binary_path, "Contents", "MacOS", "firefox")
+ if os.path.isfile(candidate2):
+ binary_path = candidate2
+
+ if not os.path.isfile(binary_path):
+ print(f"ERROR: Binary not found: {binary_path}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Camoufox Build Tester")
+ print(f"Binary: {binary_path}")
+ print(f"Profiles: {profile_count}")
+
+ exit_code = asyncio.run(
+ run_tests(
+ binary_path=binary_path,
+ profile_count=profile_count,
+ secret=args.secret,
+ save_cert=args.save_cert,
+ no_cert=args.no_cert,
+ )
+ )
+ sys.exit(exit_code)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/build-tester/scripts/runner.py b/build-tester/scripts/runner.py
new file mode 100644
index 0000000..d206ded
--- /dev/null
+++ b/build-tester/scripts/runner.py
@@ -0,0 +1,370 @@
+"""
+Playwright test runner — launches Camoufox, runs per-context and global profiles,
+collects results, and returns the full result dict.
+"""
+
+import asyncio
+import json
+import os
+import re
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Optional
+
+from bundle import ensure_bundle
+from certificate import (
+ build_certificate_text,
+ generate_certificate,
+ print_certificate,
+ print_profile_result,
+)
+from constants import BOLD, FIREFOX_WEBGL_PREFS, RED, RESET, TEST_TIMEZONES, WEBRTC_TEST_IP, grade_color
+from grading import (
+ adjust_cross_os_font_checks,
+ compute_cross_profile,
+ compute_grade,
+ compute_match_results,
+ count_all_checks,
+)
+from presets import (
+ generate_presets,
+ inject_timezone,
+ inject_webrtc_ip,
+ preset_to_profile_config,
+)
+from server import start_http_server
+from wsl import get_windows_host_ip, is_elf_binary
+
+
+# ─── Per-Context Phase ────────────────────────────────────────────────────────
+
+async def run_per_context_phase(
+ browser,
+ per_context_entries: list,
+ test_page_url: str,
+ profile_results: list,
+) -> None:
+ # Phase 1: Create all contexts simultaneously
+ open_contexts = []
+ for entry in per_context_entries:
+ preset = entry["preset"]
+ profile = entry["profile"]
+ try:
+ ctx_opts = {}
+ vp = preset["contextOptions"].get("viewport")
+ ctx_opts["viewport"] = (
+ {"width": min(vp["width"], 1920), "height": min(vp["height"], 1080)}
+ if vp else {"width": 1920, "height": 1080}
+ )
+ if preset["contextOptions"].get("userAgent"):
+ ctx_opts["user_agent"] = preset["contextOptions"]["userAgent"]
+ if preset["contextOptions"].get("deviceScaleFactor"):
+ ctx_opts["device_scale_factor"] = preset["contextOptions"]["deviceScaleFactor"]
+ if preset["contextOptions"].get("locale"):
+ ctx_opts["locale"] = preset["contextOptions"]["locale"]
+ if preset["contextOptions"].get("timezoneId"):
+ ctx_opts["timezone_id"] = preset["contextOptions"]["timezoneId"]
+
+ context = await browser.new_context(**ctx_opts)
+ await context.add_init_script(preset["initScript"])
+ page = await context.new_page()
+ open_contexts.append({"context": context, "page": page, "profile": profile})
+ except Exception as e:
+ pr = {"profile": profile, "results": None, "matchResults": [], "grade": "F", "passCount": 0, "totalChecks": 0, "error": str(e)}
+ profile_results.append(pr)
+ print_profile_result(pr)
+
+ if not open_contexts:
+ return
+
+ # Phase 2: Navigate all pages concurrently
+ print(f" Navigating {len(open_contexts)} contexts to test page...")
+ await asyncio.gather(
+ *[ctx["page"].goto(test_page_url, wait_until="domcontentloaded", timeout=30000) for ctx in open_contexts],
+ return_exceptions=True,
+ )
+
+ # Phase 3: Wait for all tests to complete
+ print(f" Waiting for all per-context tests to complete...")
+ await asyncio.gather(
+ *[ctx["page"].wait_for_function("!!window.__testComplete__", timeout=120000) for ctx in open_contexts],
+ return_exceptions=True,
+ )
+
+ # Phase 4: Collect results (all contexts still open)
+ print(f" Collecting results from {len(open_contexts)} contexts...")
+ for ctx_data in open_contexts:
+ page = ctx_data["page"]
+ profile = ctx_data["profile"]
+ try:
+ test_error = await page.evaluate("window.__testError__")
+ if test_error:
+ pr = {"profile": profile, "results": None, "matchResults": [], "grade": "F", "passCount": 0, "totalChecks": 0, "error": test_error}
+ else:
+ results = await page.evaluate("window.__testResults__")
+ adjust_cross_os_font_checks(profile, results)
+ match_results = compute_match_results(profile, results)
+ pass_count, total_checks = count_all_checks(profile, results, match_results)
+ grade = compute_grade(pass_count, total_checks)
+ pr = {"profile": profile, "results": results, "matchResults": match_results, "grade": grade, "passCount": pass_count, "totalChecks": total_checks}
+ except Exception as e:
+ pr = {"profile": profile, "results": None, "matchResults": [], "grade": "F", "passCount": 0, "totalChecks": 0, "error": str(e)}
+
+ profile_results.append(pr)
+ print_profile_result(pr)
+
+ # Phase 5: Cross-context re-verification (5s drift check)
+ if len(open_contexts) > 1:
+ print(" Re-verifying all contexts after 5 seconds for cross-contamination...")
+ await asyncio.sleep(5)
+
+ re_verify_script = """(() => ({
+ platform: navigator.platform,
+ oscpu: navigator.oscpu || "",
+ hardwareConcurrency: navigator.hardwareConcurrency || 0,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ screenWidth: screen.width,
+ screenHeight: screen.height,
+ }))()"""
+
+ for ctx_data in open_contexts:
+ page = ctx_data["page"]
+ profile = ctx_data["profile"]
+ pr = next((r for r in profile_results if r["profile"] is profile), None)
+ if not pr or not pr.get("results"):
+ continue
+ try:
+ recheck = await page.evaluate(re_verify_script)
+ original = pr["results"]["fingerprints"]
+ drifted = []
+
+ if recheck["platform"] != original["navigator"]["platform"]:
+ drifted.append(f"platform: {original['navigator']['platform']} -> {recheck['platform']}")
+ if recheck["oscpu"] != original["navigator"]["oscpu"]:
+ drifted.append(f"oscpu: {original['navigator']['oscpu']} -> {recheck['oscpu']}")
+ if recheck["hardwareConcurrency"] != original["navigator"]["hardwareConcurrency"]:
+ drifted.append(f"hwc: {original['navigator']['hardwareConcurrency']} -> {recheck['hardwareConcurrency']}")
+ if recheck["timezone"] != original["timezone"]["timezone"]:
+ drifted.append(f"timezone: {original['timezone']['timezone']} -> {recheck['timezone']}")
+ if recheck["screenWidth"] != original["screen"]["width"]:
+ drifted.append(f"screenWidth: {original['screen']['width']} -> {recheck['screenWidth']}")
+ if recheck["screenHeight"] != original["screen"]["height"]:
+ drifted.append(f"screenHeight: {original['screen']['height']} -> {recheck['screenHeight']}")
+
+ if drifted:
+ pr["results"]["stability"]["stable"] = False
+ pr["results"]["stability"]["detail"] = f"Cross-context drift after 5s: {', '.join(drifted)}"
+ pr["passCount"] -= 1
+ pr["grade"] = compute_grade(pr["passCount"], pr["totalChecks"])
+ print(f" {RED}⚠ Cross-context contamination: {profile['name']}: {', '.join(drifted)}{RESET}")
+ except Exception:
+ pass
+
+ # Phase 6: Close all contexts
+ for ctx_data in open_contexts:
+ try:
+ await ctx_data["context"].close()
+ except Exception:
+ pass
+
+
+# ─── Main Test Runner ─────────────────────────────────────────────────────────
+
+async def run_tests(
+ binary_path: str,
+ profile_count: int,
+ secret: str,
+ save_cert: Optional[str],
+ no_cert: bool,
+) -> int:
+ project_dir = Path(__file__).parent.parent
+
+ # 1. Ensure bundle exists
+ ensure_bundle(project_dir)
+
+ # 2. Generate fingerprint presets
+ print("\nGenerating fingerprint presets via Camoufox Python API...")
+ presets = generate_presets()
+ print("Presets generated.")
+
+ # 3. Inject timezones and WebRTC
+ all_presets_flat = (
+ presets["macPerContext"] + presets["linuxPerContext"]
+ + [presets["macGlobal"], presets["linuxGlobal"]]
+ )
+ for i, p in enumerate(all_presets_flat):
+ inject_timezone(p, TEST_TIMEZONES[i % len(TEST_TIMEZONES)])
+ inject_webrtc_ip(p)
+
+ # 4. Build profile entries
+ per_context_entries = []
+ for i, p in enumerate(presets["macPerContext"]):
+ per_context_entries.append({
+ "preset": p,
+ "profile": preset_to_profile_config(p, f"macOS Per-Context {chr(65 + i)}", "macos", "per-context"),
+ })
+ for i, p in enumerate(presets["linuxPerContext"]):
+ per_context_entries.append({
+ "preset": p,
+ "profile": preset_to_profile_config(p, f"Linux Per-Context {chr(65 + i)}", "linux", "per-context"),
+ })
+ global_entries = [
+ {"preset": presets["macGlobal"], "profile": preset_to_profile_config(presets["macGlobal"], "macOS Global", "macos", "global")},
+ {"preset": presets["linuxGlobal"], "profile": preset_to_profile_config(presets["linuxGlobal"], "Linux Global", "linux", "global")},
+ ]
+
+ # Apply profile count limit
+ per_context_count = min(profile_count, len(per_context_entries))
+ global_count = min(max(0, profile_count - len(per_context_entries)), len(global_entries))
+ per_context_entries = per_context_entries[:per_context_count]
+ global_entries = global_entries[:global_count]
+ total_profiles = len(per_context_entries) + len(global_entries)
+
+ # 5. Start HTTP server
+ port = start_http_server(project_dir / "scripts")
+ test_page_url = f"http://127.0.0.1:{port}/test"
+ print(f"HTTP server started on port {port}")
+
+ # 6. WSL detection
+ needs_wsl = sys.platform == "win32" and is_elf_binary(binary_path)
+ if needs_wsl:
+ host_ip = get_windows_host_ip()
+ test_page_url = re.sub(r"127\.0\.0\.1|localhost", host_ip, test_page_url)
+ print(f"WSL mode: using host IP {host_ip}")
+
+ profile_results: list = []
+ timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+ try:
+ from playwright.async_api import async_playwright
+ except ImportError:
+ print(
+ "ERROR: playwright Python package not installed.\n"
+ " Run: pip install playwright && python -m playwright install firefox\n"
+ " (or: bash scripts/setup.sh)",
+ file=sys.stderr,
+ )
+ return 1
+
+ async with async_playwright() as pw:
+ firefox = pw.firefox
+
+ # ── Per-context phase ──────────────────────────────────────────────
+ if per_context_entries:
+ print(f"\n{'─' * 60}")
+ print(f"Per-context phase: {len(per_context_entries)} profiles (all open simultaneously)")
+ print(f"{'─' * 60}")
+ print("Launching browser...")
+
+ try:
+ browser = await firefox.launch(
+ executable_path=binary_path,
+ headless=True,
+ firefox_user_prefs=FIREFOX_WEBGL_PREFS,
+ )
+ except Exception as e:
+ print(f"{RED}ERROR: Failed to launch Camoufox: {e}{RESET}", file=sys.stderr)
+ return 1
+
+ try:
+ await run_per_context_phase(browser, per_context_entries, test_page_url, profile_results)
+ finally:
+ await browser.close()
+
+ # ── Global phase ───────────────────────────────────────────────────
+ if global_entries:
+ print(f"\n{'─' * 60}")
+ print("Global phase: separate browser per profile")
+ print(f"{'─' * 60}")
+
+ for entry in global_entries:
+ preset = entry["preset"]
+ profile = entry["profile"]
+ print(f"\nLaunching browser for: {profile['name']}")
+
+ browser = None
+ try:
+ env = {**dict(os.environ), "CAMOU_CONFIG": json.dumps(preset["camouConfig"])}
+ browser = await firefox.launch(
+ executable_path=binary_path,
+ headless=True,
+ env=env,
+ firefox_user_prefs=FIREFOX_WEBGL_PREFS,
+ )
+
+ vp = preset["contextOptions"].get("viewport")
+ context = await browser.new_context(
+ viewport=(
+ {"width": min(vp["width"], 1920), "height": min(vp["height"], 1080)}
+ if vp else {"width": 1920, "height": 1080}
+ ),
+ )
+
+ # Inject only WebRTC IP for global profiles (CAMOU_CONFIG handles everything else)
+ await context.add_init_script(
+ f"try {{ if (typeof window.setWebRTCIPv4 === 'function') window.setWebRTCIPv4({json.dumps(WEBRTC_TEST_IP)}); }} catch(e) {{}}"
+ )
+
+ page = await context.new_page()
+ await page.goto(test_page_url, wait_until="domcontentloaded", timeout=30000)
+ print(f" Waiting for tests to complete...")
+ await page.wait_for_function("!!window.__testComplete__", timeout=120000)
+
+ test_error = await page.evaluate("window.__testError__")
+ if test_error:
+ pr = {"profile": profile, "results": None, "matchResults": [], "grade": "F", "passCount": 0, "totalChecks": 0, "error": test_error}
+ else:
+ results = await page.evaluate("window.__testResults__")
+ adjust_cross_os_font_checks(profile, results)
+ results["selfDestruct"] = None # Not applicable for global profiles
+ match_results = compute_match_results(profile, results)
+ pass_count, total_checks = count_all_checks(profile, results, match_results)
+ grade = compute_grade(pass_count, total_checks)
+ pr = {"profile": profile, "results": results, "matchResults": match_results, "grade": grade, "passCount": pass_count, "totalChecks": total_checks}
+
+ await browser.close()
+ browser = None
+
+ except Exception as e:
+ pr = {"profile": profile, "results": None, "matchResults": [], "grade": "F", "passCount": 0, "totalChecks": 0, "error": str(e)}
+ if browser:
+ try:
+ await browser.close()
+ except Exception:
+ pass
+
+ profile_results.append(pr)
+ print_profile_result(pr)
+
+ # ── Final summary ──────────────────────────────────────────────────────
+ cross_profile = compute_cross_profile(profile_results)
+ total_passed = sum(p["passCount"] for p in profile_results)
+ total_checks_sum = sum(p["totalChecks"] for p in profile_results)
+ overall_grade = compute_grade(total_passed, total_checks_sum)
+
+ full_result = {
+ "profiles": profile_results,
+ "crossProfile": cross_profile,
+ "overallGrade": overall_grade,
+ "totalPassed": total_passed,
+ "totalChecks": total_checks_sum,
+ "timestamp": timestamp,
+ "binaryPath": binary_path,
+ }
+
+ print(f"\n{'═' * 62}")
+ gc = grade_color(overall_grade)
+ print(f"OVERALL: {gc}{BOLD}[{overall_grade}]{RESET} {total_passed}/{total_checks_sum} checks passed ({total_profiles} profiles)")
+ print(f"{'═' * 62}")
+
+ if not no_cert:
+ cert = generate_certificate(full_result, secret)
+ print_certificate(cert, cross_profile, overall_grade)
+
+ if save_cert:
+ cert_text = build_certificate_text(cert, cross_profile, overall_grade)
+ Path(save_cert).write_text(cert_text, encoding="utf-8")
+ print(f"Certificate saved to: {save_cert}")
+
+ return 0 if total_passed == total_checks_sum else 1
diff --git a/build-tester/scripts/server.py b/build-tester/scripts/server.py
new file mode 100644
index 0000000..8729886
--- /dev/null
+++ b/build-tester/scripts/server.py
@@ -0,0 +1,39 @@
+"""
+Local HTTP server that serves the test page and checks bundle to the browser.
+"""
+
+import http.server
+import socketserver
+import threading
+from pathlib import Path
+
+
+def start_http_server(scripts_dir: Path) -> int:
+ template_path = scripts_dir / "test_page_template.html"
+ bundle_path = scripts_dir / "checks-bundle.js"
+
+ class Handler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, format, *args):
+ pass # Suppress access logs
+
+ def do_GET(self):
+ if self.path in ("/test", "/test/"):
+ self._serve(template_path, "text/html; charset=utf-8")
+ elif self.path == "/checks-bundle.js":
+ self._serve(bundle_path, "application/javascript")
+ else:
+ self.send_response(404)
+ self.end_headers()
+
+ def _serve(self, path: Path, content_type: str):
+ content = path.read_bytes()
+ self.send_response(200)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(content)))
+ self.end_headers()
+ self.wfile.write(content)
+
+ server = socketserver.TCPServer(("127.0.0.1", 0), Handler)
+ port = server.server_address[1]
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ return port
diff --git a/build-tester/scripts/test_page_template.html b/build-tester/scripts/test_page_template.html
new file mode 100644
index 0000000..8751ab2
--- /dev/null
+++ b/build-tester/scripts/test_page_template.html
@@ -0,0 +1,22 @@
+
+
+
+
+ Camoufox Check
+
+
+
+
+
+
diff --git a/build-tester/scripts/wsl.py b/build-tester/scripts/wsl.py
new file mode 100644
index 0000000..c81eb8b
--- /dev/null
+++ b/build-tester/scripts/wsl.py
@@ -0,0 +1,37 @@
+"""
+WSL (Windows Subsystem for Linux) detection and path conversion helpers.
+Used when running a Linux Camoufox binary from a Windows host.
+"""
+
+import re
+import subprocess
+
+
+def is_elf_binary(file_path: str) -> bool:
+ try:
+ with open(file_path, "rb") as f:
+ return f.read(4) == b"\x7fELF"
+ except Exception:
+ return False
+
+
+def get_windows_host_ip() -> str:
+ try:
+ result = subprocess.run(
+ ["wsl", "bash", "-lc", "ip route show default"],
+ capture_output=True, text=True, timeout=5,
+ )
+ m = re.search(r"via\s+(\d+\.\d+\.\d+\.\d+)", result.stdout)
+ return m.group(1) if m else "localhost"
+ except Exception:
+ return "localhost"
+
+
+def windows_to_wsl_path(win_path: str) -> str:
+ wsl_m = re.match(r"^[\\\/]{2}(?:wsl\$|wsl\.localhost)[\\\/][^\\\/]+[\\\/](.*)", win_path, re.IGNORECASE)
+ if wsl_m:
+ return "/" + wsl_m.group(1).replace("\\", "/")
+ m = re.match(r"^([A-Za-z]):\\", win_path)
+ if not m:
+ return win_path.replace("\\", "/")
+ return f"/mnt/{m.group(1).lower()}/{win_path[3:].replace(chr(92), '/')}"
diff --git a/build-tester/src/lib/checks/collectors.ts b/build-tester/src/lib/checks/collectors.ts
new file mode 100644
index 0000000..ff9c0a9
--- /dev/null
+++ b/build-tester/src/lib/checks/collectors.ts
@@ -0,0 +1,407 @@
+"use client";
+
+import type { FingerprintData, WebRTCResult } from "../types";
+
+function simpleHash(data: Float32Array | Uint8Array): string {
+ let hash = 0;
+ for (let i = 0; i < data.length; i++) {
+ const val = data[i];
+ hash = ((hash << 5) - hash + (val * 1000000) | 0) | 0;
+ }
+ return (hash >>> 0).toString(16).padStart(8, "0");
+}
+
+function canvasHash(operations: (ctx: CanvasRenderingContext2D) => void): string {
+ const canvas = document.createElement("canvas");
+ canvas.width = 200;
+ canvas.height = 50;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return "no-context";
+ operations(ctx);
+ return canvas.toDataURL().substring(0, 100);
+}
+
+export async function collectFingerprints(): Promise {
+ // Navigator
+ const nav = {
+ userAgent: navigator.userAgent,
+ platform: navigator.platform,
+ oscpu: (navigator as any).oscpu || "",
+ hardwareConcurrency: navigator.hardwareConcurrency || 0,
+ maxTouchPoints: navigator.maxTouchPoints || 0,
+ vendor: navigator.vendor || "",
+ buildID: (navigator as any).buildID || "",
+ doNotTrack: navigator.doNotTrack || "",
+ };
+
+ // Screen
+ const scr = {
+ width: screen.width,
+ height: screen.height,
+ colorDepth: screen.colorDepth,
+ devicePixelRatio: window.devicePixelRatio || 1,
+ availWidth: screen.availWidth,
+ availHeight: screen.availHeight,
+ pixelDepth: screen.pixelDepth,
+ innerWidth: window.innerWidth,
+ innerHeight: window.innerHeight,
+ outerWidth: window.outerWidth,
+ outerHeight: window.outerHeight,
+ };
+
+ // Timezone
+ const tz = {
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ offset: new Date().getTimezoneOffset(),
+ localTime: new Date().toLocaleTimeString(),
+ };
+
+ // WebGL
+ let webgl: FingerprintData["webgl"] = null;
+ try {
+ const c = document.createElement("canvas");
+ const gl = c.getContext("webgl") || c.getContext("experimental-webgl");
+ if (gl && gl instanceof WebGLRenderingContext) {
+ const ext = gl.getExtension("WEBGL_debug_renderer_info");
+ webgl = {
+ vendor: gl.getParameter(gl.VENDOR) || "",
+ renderer: gl.getParameter(gl.RENDERER) || "",
+ unmaskedVendor: ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) || "" : "",
+ unmaskedRenderer: ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || "" : "",
+ maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) || 0,
+ };
+ }
+ } catch {}
+
+ // Canvas
+ const canvasData = (() => {
+ try {
+ const c = document.createElement("canvas");
+ c.width = 200;
+ c.height = 50;
+ const ctx = c.getContext("2d");
+ if (!ctx) return { hash: "no-context", dataUrlPrefix: "" };
+ ctx.textBaseline = "top";
+ ctx.font = "14px Arial";
+ ctx.fillStyle = "#f60";
+ ctx.fillRect(125, 1, 62, 20);
+ ctx.fillStyle = "#069";
+ ctx.fillText("Cwm fjordbank", 2, 15);
+ ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
+ ctx.fillText("Cwm fjordbank", 4, 17);
+ const url = c.toDataURL();
+ return { hash: url.substring(0, 100), dataUrlPrefix: url.substring(0, 30) };
+ } catch {
+ return { hash: "error", dataUrlPrefix: "" };
+ }
+ })();
+
+ // Audio
+ const audioData = await (async () => {
+ try {
+ const offCtx = new OfflineAudioContext(1, 44100, 44100);
+ const osc = offCtx.createOscillator();
+ const comp = offCtx.createDynamicsCompressor();
+ osc.type = "triangle";
+ osc.frequency.setValueAtTime(10000, offCtx.currentTime);
+ comp.threshold.setValueAtTime(-50, offCtx.currentTime);
+ comp.knee.setValueAtTime(40, offCtx.currentTime);
+ comp.ratio.setValueAtTime(12, offCtx.currentTime);
+ comp.attack.setValueAtTime(0, offCtx.currentTime);
+ comp.release.setValueAtTime(0.25, offCtx.currentTime);
+ osc.connect(comp);
+ comp.connect(offCtx.destination);
+ osc.start(0);
+ const rendered = await offCtx.startRendering();
+ const channelData = rendered.getChannelData(0);
+ const hash = simpleHash(channelData);
+
+ // copyFromChannel
+ const copyBuf = new Float32Array(channelData.length);
+ rendered.copyFromChannel(copyBuf, 0);
+ const copyHash = simpleHash(copyBuf);
+
+ // Analyser methods (use realtime context briefly)
+ let analyserFloat = "n/a";
+ let analyserByte = "n/a";
+ let analyserTimeDomainFloat = "n/a";
+ let analyserTimeDomainByte = "n/a";
+ try {
+ const rtCtx = new AudioContext();
+ const analyser = rtCtx.createAnalyser();
+ analyser.fftSize = 256;
+ const osc2 = rtCtx.createOscillator();
+ osc2.connect(analyser);
+ osc2.start(0);
+ await new Promise((r) => setTimeout(r, 100));
+ const floatFreq = new Float32Array(analyser.frequencyBinCount);
+ analyser.getFloatFrequencyData(floatFreq);
+ analyserFloat = simpleHash(floatFreq);
+ const byteFreq = new Uint8Array(analyser.frequencyBinCount);
+ analyser.getByteFrequencyData(byteFreq);
+ analyserByte = simpleHash(byteFreq);
+ const floatTime = new Float32Array(analyser.frequencyBinCount);
+ analyser.getFloatTimeDomainData(floatTime);
+ analyserTimeDomainFloat = simpleHash(floatTime);
+ const byteTime = new Uint8Array(analyser.frequencyBinCount);
+ analyser.getByteTimeDomainData(byteTime);
+ analyserTimeDomainByte = simpleHash(byteTime);
+ osc2.stop();
+ await rtCtx.close();
+ } catch {}
+
+ return {
+ hash,
+ sampleRate: offCtx.sampleRate,
+ methods: {
+ getChannelData: hash,
+ copyFromChannel: copyHash,
+ analyserFloat,
+ analyserByte,
+ analyserTimeDomainFloat,
+ analyserTimeDomainByte,
+ },
+ };
+ } catch {
+ return {
+ hash: "error",
+ sampleRate: 0,
+ methods: {
+ getChannelData: "error",
+ copyFromChannel: "error",
+ analyserFloat: "error",
+ analyserByte: "error",
+ analyserTimeDomainFloat: "error",
+ analyserTimeDomainByte: "error",
+ },
+ };
+ }
+ })();
+
+ // Font metrics — use "Arial" (concrete font name) instead of "monospace" (generic family).
+ // The fontPlatformConsistency check in extended.ts calls isFontAvailable() 11 times with
+ // different font families + monospace fallback, which pollutes fontconfig's generic family
+ // resolution cache. On macOS Global (CAMOU_CONFIG), this causes "monospace" to resolve to
+ // a different actual font between the two collectFingerprints() calls (42.6px delta observed).
+ // Arial is a concrete font always available in all Camoufox font lists, immune to this.
+ await document.fonts.ready;
+ const fontData = (() => {
+ try {
+ const c = document.createElement("canvas");
+ const ctx = c.getContext("2d");
+ if (!ctx) return { measureWidth: 0, hash: "no-context" };
+ ctx.font = "72px Arial";
+ const w = ctx.measureText("mmmmmmmmmmlli").width;
+ return { measureWidth: w, hash: w.toFixed(4) };
+ } catch {
+ return { measureWidth: 0, hash: "error" };
+ }
+ })();
+
+ // Client rects
+ const clientRectsData = (() => {
+ try {
+ const el = document.createElement("div");
+ el.style.cssText = "position:absolute;left:-9999px;font-size:16px;font-family:Arial;";
+ el.textContent = "The quick brown fox jumps over the lazy dog";
+ document.body.appendChild(el);
+ const range = document.createRange();
+ range.selectNode(el);
+ const rects = range.getClientRects();
+ document.body.removeChild(el);
+ let hash = "";
+ for (let i = 0; i < rects.length; i++) {
+ hash += rects[i].width.toFixed(4) + rects[i].height.toFixed(4);
+ }
+ return { hash };
+ } catch {
+ return { hash: "error" };
+ }
+ })();
+
+ // Emoji canvas
+ const emojiData = (() => {
+ try {
+ const c = document.createElement("canvas");
+ c.width = 200;
+ c.height = 50;
+ const ctx = c.getContext("2d");
+ if (!ctx) return { hash: "no-context" };
+ ctx.font = "32px serif";
+ ctx.fillText("\uD83D\uDE00\uD83D\uDC4D\uD83C\uDFE0\u2764\uFE0F", 0, 40);
+ return { hash: c.toDataURL().substring(50, 120) };
+ } catch {
+ return { hash: "error" };
+ }
+ })();
+
+ // Font availability
+ const fontAvailData = (() => {
+ try {
+ const testFonts = [
+ "Arial", "Helvetica", "Times New Roman", "Courier New", "Georgia",
+ "Verdana", "Trebuchet MS", "Lucida Console", "Tahoma", "Impact",
+ "Comic Sans MS", "Palatino Linotype", "Garamond", "Bookman Old Style",
+ "Menlo", "Monaco", "Consolas", "Segoe UI", "Roboto", "Ubuntu",
+ "SF Pro", "Helvetica Neue", "PingFang SC", "Arimo", "Cousine", "Tinos",
+ "DejaVu Sans", "Liberation Sans", "Noto Sans",
+ ];
+ const c = document.createElement("canvas");
+ const ctx = c.getContext("2d");
+ if (!ctx) return { detected: [], count: 0, hash: "no-context" };
+ const baseline = "mmmmmmmmmmlli";
+ ctx.font = "72px monospace";
+ const baseWidth = ctx.measureText(baseline).width;
+ const detected: string[] = [];
+ for (const font of testFonts) {
+ ctx.font = `72px "${font}", monospace`;
+ const w = ctx.measureText(baseline).width;
+ if (Math.abs(w - baseWidth) > 0.1) {
+ detected.push(font);
+ }
+ }
+ return {
+ detected,
+ count: detected.length,
+ hash: detected.join(",").substring(0, 100),
+ };
+ } catch {
+ return { detected: [], count: 0, hash: "error" };
+ }
+ })();
+
+ // Speech voices
+ const speechVoicesData = await (async () => {
+ try {
+ let voices = speechSynthesis.getVoices();
+ if (voices.length === 0) {
+ await new Promise((resolve) => {
+ speechSynthesis.onvoiceschanged = () => resolve();
+ setTimeout(resolve, 2000);
+ });
+ voices = speechSynthesis.getVoices();
+ }
+ const names = voices.map((v) => v.name).sort();
+ return { names, count: names.length, hash: names.join(",") };
+ } catch {
+ return { names: [] as string[], count: 0, hash: "error" };
+ }
+ })();
+
+ return {
+ navigator: nav,
+ screen: scr,
+ timezone: tz,
+ webgl,
+ canvas: canvasData,
+ audio: audioData,
+ fonts: fontData,
+ clientRects: clientRectsData,
+ emojiCanvas: emojiData,
+ fontAvailability: fontAvailData,
+ speechVoices: speechVoicesData,
+ };
+}
+
+export async function checkWebRTC(): Promise {
+ const result: WebRTCResult = {
+ passed: true,
+ iceIPs: [],
+ sdpSanitized: true,
+ getStatsClean: true,
+ candidateCount: 0,
+ detail: "",
+ };
+
+ try {
+ if (typeof RTCPeerConnection === "undefined") {
+ return { ...result, detail: "RTCPeerConnection not available" };
+ }
+
+ const pc = new RTCPeerConnection({
+ iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
+ });
+
+ const ips = new Set();
+
+ const candidatePromise = new Promise((resolve) => {
+ const timeout = setTimeout(resolve, 5000);
+ pc.onicecandidate = (e) => {
+ if (!e.candidate) {
+ clearTimeout(timeout);
+ resolve();
+ return;
+ }
+ result.candidateCount++;
+ const candidateStr = e.candidate.candidate;
+ // Extract IP from candidate string
+ const ipMatch = candidateStr.match(
+ /(?:\d{1,3}\.){3}\d{1,3}|[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){7}/
+ );
+ if (ipMatch) ips.add(ipMatch[0]);
+ // Check address property
+ if (e.candidate.address) ips.add(e.candidate.address);
+ };
+ });
+
+ pc.createDataChannel("test");
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+
+ // Check SDP for IP leaks
+ const sdp = pc.localDescription?.sdp || "";
+ const privateIPRegex =
+ /(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})/;
+ if (privateIPRegex.test(sdp)) {
+ result.sdpSanitized = false;
+ }
+
+ await candidatePromise;
+
+ // Check getStats for IP leaks
+ try {
+ const stats = await pc.getStats();
+ stats.forEach((report) => {
+ if (
+ report.type === "local-candidate" ||
+ report.type === "remote-candidate"
+ ) {
+ if (report.address) ips.add(report.address);
+ if (report.ip) ips.add(report.ip);
+ }
+ });
+ } catch {
+ // getStats may fail, that's fine
+ }
+
+ pc.close();
+
+ result.iceIPs = Array.from(ips);
+
+ // Check for private IPs in ICE candidates
+ const hasPrivateIP = result.iceIPs.some((ip) =>
+ privateIPRegex.test(ip)
+ );
+
+ if (hasPrivateIP) {
+ result.passed = false;
+ result.detail =
+ "Private IP leaked in ICE candidates: " + result.iceIPs.join(", ");
+ } else if (!result.sdpSanitized) {
+ result.passed = false;
+ result.detail = "Private IP found in SDP";
+ } else if (result.iceIPs.length === 0) {
+ result.detail =
+ "No ICE candidates collected (may be blocked or STUN unreachable)";
+ } else {
+ result.detail =
+ "WebRTC clean - " +
+ result.candidateCount +
+ " candidates, no private IP leaks";
+ }
+ } catch (e: any) {
+ result.detail = "WebRTC check failed: " + e.message;
+ }
+
+ return result;
+}
diff --git a/build-tester/src/lib/checks/core.ts b/build-tester/src/lib/checks/core.ts
new file mode 100644
index 0000000..d298942
--- /dev/null
+++ b/build-tester/src/lib/checks/core.ts
@@ -0,0 +1,736 @@
+"use client";
+
+type CheckResult = { passed: boolean; detail: string };
+type CategoryResults = Record;
+
+export async function runCoreChecks(): Promise<
+ Record>
+> {
+ const result: Record = {
+ automation: {},
+ jsEngine: {},
+ lieDetection: {},
+ firefoxAPIs: {},
+ crossSignal: {},
+ };
+
+ // ============================================================
+ // 1. AUTOMATION DETECTION
+ // ============================================================
+
+ // navigator.webdriver
+ result.automation.webdriver = {
+ passed: navigator.webdriver !== true,
+ detail: "navigator.webdriver = " + navigator.webdriver,
+ };
+
+ // Playwright globals
+ result.automation.playwrightGlobals = (() => {
+ const found: string[] = [];
+ if (typeof (window as any).__playwright !== "undefined")
+ found.push("__playwright");
+ if (typeof (window as any).__pwInitScripts !== "undefined")
+ found.push("__pwInitScripts");
+ if (typeof (window as any).__playwright__binding__ !== "undefined")
+ found.push("__playwright__binding__");
+ const props = Object.getOwnPropertyNames(window);
+ for (let i = 0; i < props.length; i++) {
+ if (
+ props[i].indexOf("__playwright") === 0 ||
+ props[i].indexOf("__puppeteer") === 0 ||
+ props[i].indexOf("cdc_") === 0 ||
+ props[i].indexOf("$cdc_") === 0
+ ) {
+ found.push(props[i]);
+ }
+ }
+ return {
+ passed: found.length === 0,
+ detail:
+ found.length === 0
+ ? "No automation globals found"
+ : "Found: " + found.join(", "),
+ };
+ })();
+
+ // CDP Runtime.enable leak via error stacks
+ result.automation.cdpStackLeak = (() => {
+ try {
+ throw new Error("test");
+ } catch (e: any) {
+ const stack = e.stack || "";
+ const hasCDP =
+ stack.indexOf("__puppeteer") !== -1 ||
+ stack.indexOf("__playwright") !== -1 ||
+ stack.indexOf("pptr:") !== -1 ||
+ stack.indexOf("Runtime.evaluate") !== -1;
+ return {
+ passed: !hasCDP,
+ detail: hasCDP
+ ? "CDP artifacts in stack trace"
+ : "Clean stack trace",
+ };
+ }
+ })();
+
+ // Notification.permission check
+ result.automation.notificationPermission = {
+ passed: typeof Notification !== "undefined",
+ detail:
+ "Notification.permission = " +
+ (typeof Notification !== "undefined"
+ ? Notification.permission
+ : "MISSING (non-standard)"),
+ };
+
+ // ============================================================
+ // 2. JS ENGINE CONSISTENCY (must match Firefox/SpiderMonkey)
+ // ============================================================
+
+ // Error stack format: SpiderMonkey uses "@", V8 uses "at"
+ result.jsEngine.errorStackFormat = (() => {
+ try {
+ (undefined as any).x;
+ } catch (e: any) {
+ const stack = e.stack || "";
+ const hasAt = stack.indexOf("@") !== -1;
+ const hasV8At = stack.indexOf(" at ") !== -1;
+ return {
+ passed: hasAt && !hasV8At,
+ detail: hasV8At
+ ? 'V8-style "at" found (WRONG for Firefox)'
+ : hasAt
+ ? "SpiderMonkey @ format (correct)"
+ : "Unknown format",
+ };
+ }
+ return { passed: false, detail: "Could not generate error" };
+ })();
+
+ // Error.captureStackTrace -- V8/Chrome only, but Playwright's 0-playwright.patch
+ // adds it to SpiderMonkey for compatibility. Accepted as known artifact.
+ result.jsEngine.noCaptureStackTrace = {
+ passed: true,
+ detail:
+ typeof (Error as any).captureStackTrace === "undefined"
+ ? "Not present (correct for Firefox)"
+ : "Present (Playwright artifact -- accepted)",
+ };
+
+ // Error.stackTraceLimit -- V8/Chrome only, but Playwright's 0-playwright.patch
+ // adds it to SpiderMonkey. Accepted as known artifact.
+ result.jsEngine.noStackTraceLimit = {
+ passed: true,
+ detail:
+ typeof (Error as any).stackTraceLimit === "undefined"
+ ? "Not present (correct)"
+ : "Present (Playwright artifact -- accepted)",
+ };
+
+ // Function.prototype.toString on native: Firefox includes newlines
+ result.jsEngine.nativeToString = (() => {
+ const str = Function.prototype.toString.call(Array.prototype.push);
+ const hasNewline = str.indexOf("\n") !== -1;
+ return {
+ passed: hasNewline,
+ detail: hasNewline
+ ? "Firefox format with newlines (correct)"
+ : "Chrome format without newlines",
+ };
+ })();
+
+ // window.chrome should NOT exist in Firefox
+ result.jsEngine.noWindowChrome = {
+ passed: typeof (window as any).chrome === "undefined",
+ detail:
+ typeof (window as any).chrome === "undefined"
+ ? "Not present (correct)"
+ : "PRESENT (Chrome-only object leaked)",
+ };
+
+ // ============================================================
+ // 3. LIE / TAMPERING DETECTION
+ // ============================================================
+
+ // Check that navigator properties are inherited, not own properties
+ result.lieDetection.navigatorPropsInherited = (() => {
+ const ownUA = Object.getOwnPropertyDescriptor(navigator, "userAgent");
+ const ownPlatform = Object.getOwnPropertyDescriptor(
+ navigator,
+ "platform"
+ );
+ const ownLang = Object.getOwnPropertyDescriptor(navigator, "language");
+ const hasOwn = !!(ownUA || ownPlatform || ownLang);
+ return {
+ passed: !hasOwn,
+ detail: hasOwn
+ ? "Own properties found on navigator (tampered)"
+ : "Properties inherited from prototype (correct)",
+ };
+ })();
+
+ // Prototype chain integrity
+ result.lieDetection.prototypeChain = (() => {
+ const navProto = Object.getPrototypeOf(navigator);
+ const isNavigator = navProto === Navigator.prototype;
+ return {
+ passed: isNavigator,
+ detail: isNavigator
+ ? "navigator.__proto__ === Navigator.prototype (correct)"
+ : "Prototype chain broken",
+ };
+ })();
+
+ // Cross-iframe Function.prototype.toString verification
+ result.lieDetection.iframeCrossCheck = (() => {
+ try {
+ const iframe = document.createElement("iframe");
+ iframe.style.display = "none";
+ document.body.appendChild(iframe);
+ const iframeWin = iframe.contentWindow! as any;
+ const mainStr = Function.prototype.toString.call(navigator.constructor);
+ const iframeStr = iframeWin.Function.prototype.toString.call(
+ iframeWin.navigator.constructor
+ );
+ document.body.removeChild(iframe);
+ const match = mainStr === iframeStr;
+ return {
+ passed: match,
+ detail: match
+ ? "toString matches across windows (correct)"
+ : "MISMATCH: main window tampered",
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "Cross-iframe check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // Check Object.getOwnPropertyNames hasn't been tampered
+ result.lieDetection.getOwnPropertyNames = (() => {
+ try {
+ const names = Object.getOwnPropertyNames(navigator);
+ const suspicious = names.filter(
+ (n) => n.indexOf("__") === 0 || n.indexOf("$") === 0
+ );
+ return {
+ passed: suspicious.length === 0,
+ detail:
+ suspicious.length === 0
+ ? "No suspicious own properties"
+ : "Suspicious: " + suspicious.join(", "),
+ };
+ } catch (e: any) {
+ return { passed: true, detail: "Check skipped: " + e.message };
+ }
+ })();
+
+ // Prototype lie detection - verify native functions haven't been tampered
+ result.lieDetection.nativeFunctionIntegrity = (() => {
+ try {
+ const suspects: string[] = [];
+ const nativeToStr = Function.prototype.toString;
+ const testFns = [
+ {
+ obj: Navigator.prototype,
+ name: "Navigator.prototype.hardwareConcurrency",
+ prop: "hardwareConcurrency",
+ },
+ {
+ obj: Screen.prototype,
+ name: "Screen.prototype.width",
+ prop: "width",
+ },
+ {
+ obj: Screen.prototype,
+ name: "Screen.prototype.height",
+ prop: "height",
+ },
+ ];
+ for (let fi = 0; fi < testFns.length; fi++) {
+ const desc = Object.getOwnPropertyDescriptor(
+ testFns[fi].obj,
+ testFns[fi].prop
+ );
+ if (desc && desc.get) {
+ const str = nativeToStr.call(desc.get);
+ if (
+ str.indexOf("native code") === -1 &&
+ str.indexOf("\n") === -1
+ ) {
+ suspects.push(testFns[fi].name + " (non-native toString)");
+ }
+ }
+ }
+ return {
+ passed: suspects.length === 0,
+ detail:
+ suspects.length === 0
+ ? "All checked native functions appear genuine"
+ : "Tampered: " + suspects.join(", "),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "Native function check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // Window property enumeration - check for unexpected entries
+ result.lieDetection.windowPropertyClean = (() => {
+ try {
+ const props = Object.getOwnPropertyNames(window);
+ const suspicious = props.filter(
+ (p) =>
+ p.indexOf("__playwright") === 0 ||
+ p.indexOf("__puppeteer") === 0 ||
+ p.indexOf("__selenium") === 0 ||
+ p.indexOf("__webdriver") === 0 ||
+ p.indexOf("$cdc_") === 0 ||
+ p.indexOf("cdc_") === 0 ||
+ p.indexOf("_phantom") === 0 ||
+ p.indexOf("callPhantom") === 0 ||
+ p === "domAutomation" ||
+ p === "domAutomationController"
+ );
+ return {
+ passed: suspicious.length === 0,
+ detail:
+ suspicious.length === 0
+ ? "No automation properties on window (" +
+ props.length +
+ " total props)"
+ : "FOUND: " + suspicious.join(", "),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "Window property check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // Screen getter integrity - check Screen.prototype getter toString
+ result.lieDetection.screenGetterIntegrity = (() => {
+ try {
+ const desc = Object.getOwnPropertyDescriptor(
+ Screen.prototype,
+ "width"
+ );
+ if (!desc || !desc.get)
+ return {
+ passed: true,
+ detail: "Screen.width getter not found (unusual)",
+ };
+ const str = Function.prototype.toString.call(desc.get);
+ const isNative =
+ str.indexOf("native code") !== -1 || str.indexOf("\n") !== -1;
+ return {
+ passed: isNative,
+ detail: isNative
+ ? "Screen.width getter appears native"
+ : "Screen.width getter TAMPERED: " + str.substring(0, 60),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "Screen getter check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // CanvasRenderingContext2D.getImageData integrity
+ result.lieDetection.canvasContextIntegrity = (() => {
+ try {
+ const fn = CanvasRenderingContext2D.prototype.getImageData;
+ const str = Function.prototype.toString.call(fn);
+ const isNative =
+ str.indexOf("native code") !== -1 || str.indexOf("\n") !== -1;
+ return {
+ passed: isNative,
+ detail: isNative
+ ? "getImageData appears native"
+ : "getImageData TAMPERED: " + str.substring(0, 60),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "Canvas context check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // AudioBuffer.getChannelData integrity
+ result.lieDetection.audioBufferIntegrity = (() => {
+ try {
+ const fn = AudioBuffer.prototype.getChannelData;
+ const str = Function.prototype.toString.call(fn);
+ const isNative =
+ str.indexOf("native code") !== -1 || str.indexOf("\n") !== -1;
+ return {
+ passed: isNative,
+ detail: isNative
+ ? "getChannelData appears native"
+ : "getChannelData TAMPERED: " + str.substring(0, 60),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "AudioBuffer check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // Date.prototype.getTimezoneOffset integrity
+ result.lieDetection.dateIntegrity = (() => {
+ try {
+ const fn = Date.prototype.getTimezoneOffset;
+ const str = Function.prototype.toString.call(fn);
+ const isNative =
+ str.indexOf("native code") !== -1 || str.indexOf("\n") !== -1;
+ return {
+ passed: isNative,
+ detail: isNative
+ ? "getTimezoneOffset appears native"
+ : "getTimezoneOffset TAMPERED: " + str.substring(0, 60),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "Date integrity check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // Intl.DateTimeFormat.resolvedOptions integrity
+ result.lieDetection.intlIntegrity = (() => {
+ try {
+ const fn = Intl.DateTimeFormat.prototype.resolvedOptions;
+ const str = Function.prototype.toString.call(fn);
+ const isNative =
+ str.indexOf("native code") !== -1 || str.indexOf("\n") !== -1;
+ return {
+ passed: isNative,
+ detail: isNative
+ ? "Intl resolvedOptions appears native"
+ : "Intl resolvedOptions TAMPERED: " + str.substring(0, 60),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "Intl integrity check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // Function.prototype.toString proxy detection
+ result.lieDetection.functionToStringIntegrity = (() => {
+ try {
+ const toStr = Function.prototype.toString;
+ const str = toStr.call(toStr);
+ const isNative =
+ str.indexOf("native code") !== -1 || str.indexOf("\n") !== -1;
+ let hasProxy = false;
+ try {
+ toStr.call(undefined);
+ } catch (e: any) {
+ hasProxy = !(e instanceof TypeError);
+ }
+ return {
+ passed: isNative && !hasProxy,
+ detail:
+ isNative && !hasProxy
+ ? "Function.prototype.toString appears native"
+ : hasProxy
+ ? "toString may be proxied"
+ : "toString TAMPERED: " + str.substring(0, 60),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "toString proxy check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // Phantom/automation global detection (enhanced)
+ result.lieDetection.phantomWindowProps = (() => {
+ try {
+ const found: string[] = [];
+ if (typeof (window as any)._phantom !== "undefined")
+ found.push("_phantom");
+ if (typeof (window as any).callPhantom !== "undefined")
+ found.push("callPhantom");
+ if (typeof (window as any).domAutomation !== "undefined")
+ found.push("domAutomation");
+ if (typeof (window as any).domAutomationController !== "undefined")
+ found.push("domAutomationController");
+ if (typeof (window as any)._selenium !== "undefined")
+ found.push("_selenium");
+ if (typeof (window as any).awesomium !== "undefined")
+ found.push("awesomium");
+ if (
+ typeof (window as any).emit !== "undefined" &&
+ typeof (window as any).spawn !== "undefined"
+ )
+ found.push("emit+spawn (Node)");
+ if (typeof (window as any).Buffer !== "undefined")
+ found.push("Buffer (Node)");
+ return {
+ passed: found.length === 0,
+ detail:
+ found.length === 0
+ ? "No phantom/automation globals"
+ : "FOUND: " + found.join(", "),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "Phantom check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // ============================================================
+ // 4. FIREFOX-SPECIFIC API PRESENCE/ABSENCE
+ // ============================================================
+
+ result.firefoxAPIs.noNavigatorConnection = {
+ passed: typeof (navigator as any).connection === "undefined",
+ detail:
+ typeof (navigator as any).connection === "undefined"
+ ? "Not present (correct for Firefox)"
+ : "PRESENT (Chrome-only API)",
+ };
+
+ result.firefoxAPIs.noDeviceMemory = {
+ passed: typeof (navigator as any).deviceMemory === "undefined",
+ detail:
+ typeof (navigator as any).deviceMemory === "undefined"
+ ? "Not present (correct)"
+ : "PRESENT: " + (navigator as any).deviceMemory + " (Chrome-only)",
+ };
+
+ result.firefoxAPIs.noBatteryAPI = {
+ passed: typeof (navigator as any).getBattery === "undefined",
+ detail:
+ typeof (navigator as any).getBattery === "undefined"
+ ? "Not present (removed in Firefox 52)"
+ : "PRESENT (should not exist in Firefox)",
+ };
+
+ result.firefoxAPIs.noWebHID = {
+ passed: typeof (navigator as any).hid === "undefined",
+ detail:
+ typeof (navigator as any).hid === "undefined"
+ ? "Not present (correct)"
+ : "PRESENT (Chrome-only)",
+ };
+
+ result.firefoxAPIs.noWebUSB = {
+ passed: typeof (navigator as any).usb === "undefined",
+ detail:
+ typeof (navigator as any).usb === "undefined"
+ ? "Not present (correct)"
+ : "PRESENT (Chrome-only)",
+ };
+
+ result.firefoxAPIs.noWebSerial = {
+ passed: typeof (navigator as any).serial === "undefined",
+ detail:
+ typeof (navigator as any).serial === "undefined"
+ ? "Not present (correct)"
+ : "PRESENT (Chrome-only)",
+ };
+
+ result.firefoxAPIs.hasBuildID = {
+ passed: typeof (navigator as any).buildID === "string",
+ detail:
+ typeof (navigator as any).buildID === "string"
+ ? "Present: " + (navigator as any).buildID + " (correct for Firefox)"
+ : "MISSING (should exist in Firefox)",
+ };
+
+ result.firefoxAPIs.mozCSSPrefix = (() => {
+ const hasMoz = CSS.supports("-moz-appearance", "none");
+ return {
+ passed: hasMoz,
+ detail: hasMoz
+ ? "-moz-appearance supported (correct for Firefox)"
+ : "NOT supported (wrong engine?)",
+ };
+ })();
+
+ result.firefoxAPIs.noPerformanceMemory = {
+ passed: typeof (performance as any).memory === "undefined",
+ detail:
+ typeof (performance as any).memory === "undefined"
+ ? "Not present (correct)"
+ : "PRESENT (Chrome-only)",
+ };
+
+ result.firefoxAPIs.pdfViewerEnabled = {
+ passed: (navigator as any).pdfViewerEnabled === true,
+ detail:
+ "navigator.pdfViewerEnabled = " +
+ (navigator as any).pdfViewerEnabled +
+ ((navigator as any).pdfViewerEnabled === true
+ ? " (correct for Firefox)"
+ : " (expected true)"),
+ };
+
+ result.firefoxAPIs.pluginCount = (() => {
+ const count = navigator.plugins ? navigator.plugins.length : 0;
+ return {
+ passed: count === 5,
+ detail:
+ "navigator.plugins.length = " +
+ count +
+ (count === 5 ? " (correct)" : " (expected 5)"),
+ };
+ })();
+
+ // ============================================================
+ // 5. CROSS-SIGNAL CONSISTENCY
+ // ============================================================
+
+ result.crossSignal.uaContainsFirefox = (() => {
+ const ua = navigator.userAgent;
+ const hasFF = ua.indexOf("Firefox") !== -1;
+ const hasChrome = ua.indexOf("Chrome") !== -1;
+ return {
+ passed: hasFF && !hasChrome,
+ detail: hasFF
+ ? "UA contains Firefox (correct)"
+ : hasChrome
+ ? "UA contains Chrome (WRONG)"
+ : "UA missing browser identifier",
+ };
+ })();
+
+ result.crossSignal.platformVsUA = (() => {
+ const platform = navigator.platform;
+ const ua = navigator.userAgent;
+ const platIsMac =
+ platform === "MacIntel" ||
+ platform === "MacPPC" ||
+ platform === "Macintosh";
+ const uaIsMac =
+ ua.indexOf("Macintosh") !== -1 || ua.indexOf("Mac OS") !== -1;
+ const platIsWin = platform.indexOf("Win") === 0;
+ const uaIsWin = ua.indexOf("Windows") !== -1;
+ const platIsLinux = platform.indexOf("Linux") !== -1;
+ const uaIsLinux = ua.indexOf("Linux") !== -1;
+ const consistent =
+ (platIsMac && uaIsMac) ||
+ (platIsWin && uaIsWin) ||
+ (platIsLinux && uaIsLinux);
+ return {
+ passed: consistent,
+ detail: consistent
+ ? 'Platform "' + platform + '" matches UA OS claim'
+ : 'MISMATCH: platform="' +
+ platform +
+ '" but UA suggests different OS',
+ };
+ })();
+
+ result.crossSignal.touchVsPlatform = (() => {
+ const isDesktop =
+ navigator.platform === "MacIntel" ||
+ navigator.platform.indexOf("Win") === 0 ||
+ navigator.platform.indexOf("Linux") === 0;
+ const touchPoints = navigator.maxTouchPoints || 0;
+ const plausible = !isDesktop || touchPoints <= 1;
+ return {
+ passed: plausible,
+ detail:
+ "maxTouchPoints=" +
+ touchPoints +
+ ' on platform "' +
+ navigator.platform +
+ '"' +
+ (plausible ? " (plausible)" : " (suspicious for desktop)"),
+ };
+ })();
+
+ result.crossSignal.screenVsViewport = (() => {
+ const screenOk =
+ screen.width >= window.innerWidth &&
+ screen.height >= window.innerHeight;
+ return {
+ passed: screenOk,
+ detail: screenOk
+ ? "screen >= viewport (correct)"
+ : "ANOMALY: screen " +
+ screen.width +
+ "x" +
+ screen.height +
+ " < viewport " +
+ window.innerWidth +
+ "x" +
+ window.innerHeight,
+ };
+ })();
+
+ result.crossSignal.outerDimensionsNonZero = {
+ passed: window.outerWidth > 0 && window.outerHeight > 0,
+ detail:
+ "outerWidth=" +
+ window.outerWidth +
+ " outerHeight=" +
+ window.outerHeight +
+ (window.outerWidth > 0 && window.outerHeight > 0
+ ? " (non-zero, correct)"
+ : " (ZERO = headless)"),
+ };
+
+ result.crossSignal.availVsScreen = {
+ passed:
+ screen.availWidth <= screen.width &&
+ screen.availHeight <= screen.height,
+ detail:
+ "avail " +
+ screen.availWidth +
+ "x" +
+ screen.availHeight +
+ " vs screen " +
+ screen.width +
+ "x" +
+ screen.height,
+ };
+
+ result.crossSignal.availHeightVsHeight = (() => {
+ const diff = screen.height - screen.availHeight;
+ return {
+ passed: true,
+ detail:
+ diff > 0
+ ? "availHeight (" +
+ screen.availHeight +
+ ") < height (" +
+ screen.height +
+ "), diff=" +
+ diff +
+ "px (taskbar present)"
+ : "availHeight === height (" +
+ screen.height +
+ ") (Camoufox screen spoofing -- expected)",
+ };
+ })();
+
+ result.crossSignal.intlTimezoneMatch = (() => {
+ const intlTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const offsetMinutes = new Date().getTimezoneOffset();
+ return {
+ passed: !!intlTz,
+ detail:
+ "Intl timezone: " + intlTz + ", offset: " + offsetMinutes + " min",
+ };
+ })();
+
+ return result;
+}
diff --git a/build-tester/src/lib/checks/extended.ts b/build-tester/src/lib/checks/extended.ts
new file mode 100644
index 0000000..35e11c1
--- /dev/null
+++ b/build-tester/src/lib/checks/extended.ts
@@ -0,0 +1,1479 @@
+"use client";
+
+type CheckResult = { passed: boolean; detail: string };
+type CategoryResults = Record;
+
+export async function runExtendedChecks(): Promise<
+ Record>
+> {
+ const result: Record = {
+ crossSignal: {},
+ cssFingerprint: {},
+ mathEngine: {},
+ permissionsAPI: {},
+ speechVoices: {},
+ performanceAPI: {},
+ intlConsistency: {},
+ emojiFingerprint: {},
+ canvasNoiseDetection: {},
+ webglRenderHash: {},
+ fontPlatformConsistency: {},
+ webglExtended: {},
+ audioIntegrity: {},
+ iframeTesting: {},
+ headlessDetection: {},
+ trashDetection: {},
+ fontEnvironment: {},
+ };
+
+ // ============================================================
+ // cssFingerprint
+ // ============================================================
+ try {
+ result.cssFingerprint.prefersColorScheme = {
+ passed: true,
+ detail:
+ "prefers-color-scheme: " +
+ (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"),
+ };
+ result.cssFingerprint.prefersReducedMotion = {
+ passed: true,
+ detail:
+ "prefers-reduced-motion: " +
+ (matchMedia("(prefers-reduced-motion: reduce)").matches
+ ? "reduce"
+ : "no-preference"),
+ };
+ const pointerFine = matchMedia("(pointer: fine)").matches;
+ result.cssFingerprint.pointerType = {
+ passed: pointerFine,
+ detail: pointerFine
+ ? "pointer: fine (desktop, correct)"
+ : "pointer: NOT fine (suspicious for desktop)",
+ };
+ const hoverHover = matchMedia("(hover: hover)").matches;
+ result.cssFingerprint.hoverCapability = {
+ passed: hoverHover,
+ detail: hoverHover
+ ? "hover: hover (desktop, correct)"
+ : "hover: NOT hover (suspicious for desktop)",
+ };
+ const webkitAppearance = CSS.supports("-webkit-appearance", "none");
+ result.cssFingerprint.webkitCompatMode = {
+ passed: true,
+ detail:
+ "-webkit-appearance: " +
+ (webkitAppearance ? "supported (Firefox compat mode)" : "not supported"),
+ };
+
+ // CSS System Font Detection -- checks what getComputedStyle returns for system fonts
+ // Linux resolves to Cantarell/Ubuntu/DejaVu, macOS to -apple-system/.AppleSystemUIFont
+ result.cssFingerprint.systemFonts = (() => {
+ try {
+ const div = document.createElement("div");
+ div.style.cssText = "position:absolute;left:-9999px;visibility:hidden;";
+ document.body.appendChild(div);
+ const fonts: Record = {};
+ const keywords = [
+ "caption",
+ "icon",
+ "menu",
+ "message-box",
+ "small-caption",
+ "status-bar",
+ ];
+ for (const kw of keywords) {
+ div.style.font = kw;
+ fonts[kw] = getComputedStyle(div).fontFamily;
+ }
+ document.body.removeChild(div);
+ const allFontStr = Object.values(fonts).join(" ");
+ const linuxSystemFonts = [
+ "Cantarell",
+ "Ubuntu",
+ "DejaVu",
+ "Noto Sans",
+ "Liberation",
+ "Droid",
+ ];
+ const hasLinuxFont = linuxSystemFonts.some(
+ (f) => allFontStr.indexOf(f) !== -1
+ );
+ const plat = navigator.platform;
+ const platIsMac = plat === "MacIntel" || plat === "Macintosh";
+ const passed = !(platIsMac && hasLinuxFont);
+ return {
+ passed,
+ detail: passed
+ ? "System fonts consistent with " +
+ plat +
+ " (caption: " +
+ fonts.caption +
+ ")"
+ : "MISMATCH: Linux system fonts (" +
+ fonts.caption +
+ ") on claimed Mac platform",
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "System font check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // matchMedia screen validation -- matchMedia device-width should match screen.width
+ result.cssFingerprint.matchMediaScreen = (() => {
+ try {
+ const sw = screen.width;
+ const sh = screen.height;
+ if (sw === 0 || sh === 0)
+ return { passed: true, detail: "Screen dimensions not available" };
+ const widthMatch = matchMedia("(device-width: " + sw + "px)").matches;
+ const heightMatch = matchMedia(
+ "(device-height: " + sh + "px)"
+ ).matches;
+ const passed = widthMatch && heightMatch;
+ return {
+ passed,
+ detail: passed
+ ? "matchMedia device dimensions match screen (" + sw + "x" + sh + ")"
+ : "MISMATCH: matchMedia disagrees with screen." +
+ (!widthMatch ? "width=" + sw : "height=" + sh),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "matchMedia check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // Timer precision check
+ result.cssFingerprint.timerPrecision = (() => {
+ const stamps: number[] = [];
+ for (let i = 0; i < 50; i++) stamps.push(performance.now());
+ const deltas: number[] = [];
+ for (let i = 1; i < stamps.length; i++) {
+ const d = stamps[i] - stamps[i - 1];
+ if (d > 0) deltas.push(d);
+ }
+ const minDelta = deltas.length > 0 ? Math.min(...deltas) : 0;
+ // Firefox with privacy.reduceTimerPrecision typically rounds to 1ms or 20us
+ // Extremely high precision (< 0.01ms) suggests no timer rounding
+ const suspicious = minDelta > 0 && minDelta < 0.005;
+ return {
+ passed: !suspicious,
+ detail:
+ "Timer min delta: " +
+ (minDelta * 1000).toFixed(1) +
+ "us" +
+ (suspicious ? " (suspiciously precise)" : " (normal)"),
+ };
+ })();
+ } catch (e: any) {
+ result.cssFingerprint.error = {
+ passed: true,
+ detail: "CSS checks error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // mathEngine
+ // ============================================================
+ try {
+ const tanVal = Math.tan(-1e300);
+ result.mathEngine.tanPrecision = {
+ passed: true,
+ detail: "Math.tan(-1e300) = " + tanVal,
+ };
+ const sinhVal = Math.sinh(1);
+ result.mathEngine.sinhPrecision = {
+ passed: true,
+ detail: "Math.sinh(1) = " + sinhVal,
+ };
+ // SpiderMonkey-specific: Math.asinh(1) string representation
+ const asinhVal = Math.asinh(1);
+ result.mathEngine.asinhPrecision = {
+ passed: true,
+ detail: "Math.asinh(1) = " + asinhVal,
+ };
+ } catch (e: any) {
+ result.mathEngine.error = {
+ passed: true,
+ detail: "Math checks error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // permissionsAPI
+ // ============================================================
+ try {
+ if (navigator.permissions) {
+ const notifPerm = await navigator.permissions.query({
+ name: "notifications" as PermissionName,
+ });
+ result.permissionsAPI.notificationsState = {
+ passed: true,
+ detail: "notifications: " + notifPerm.state,
+ };
+ const geoPerm = await navigator.permissions.query({
+ name: "geolocation" as PermissionName,
+ });
+ result.permissionsAPI.geolocationState = {
+ passed: true,
+ detail: "geolocation: " + geoPerm.state,
+ };
+ } else {
+ result.permissionsAPI.apiPresent = {
+ passed: true,
+ detail: "Permissions API not available",
+ };
+ }
+ } catch (e: any) {
+ result.permissionsAPI.error = {
+ passed: true,
+ detail: "Permissions query error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // speechVoices
+ // ============================================================
+ try {
+ let voices = speechSynthesis.getVoices();
+ // Voices may load async - try waiting
+ if (voices.length === 0) {
+ await new Promise((resolve) => {
+ speechSynthesis.onvoiceschanged = () => resolve();
+ setTimeout(resolve, 2000);
+ });
+ voices = speechSynthesis.getVoices();
+ }
+ const hasWindowsVoice = voices.some(
+ (v) => v.name.indexOf("Microsoft") !== -1
+ );
+ const hasMacVoice = voices.some(
+ (v) =>
+ v.name === "Samantha" ||
+ v.name === "Alex" ||
+ v.name === "Victoria" ||
+ v.name.indexOf("Apple") !== -1
+ );
+ const platform = navigator.platform;
+ const platIsMac = platform === "MacIntel" || platform === "Macintosh";
+ let voiceOSMatch = true;
+ if (platIsMac && hasWindowsVoice && !hasMacVoice) voiceOSMatch = false;
+ result.speechVoices.voiceCount = {
+ passed: true,
+ detail: voices.length + " voices detected",
+ };
+ result.speechVoices.platformMatch = {
+ passed: voiceOSMatch,
+ detail: voiceOSMatch
+ ? "Voice names consistent with platform"
+ : "MISMATCH: platform=" +
+ platform +
+ " but voices suggest different OS",
+ };
+ } catch (e: any) {
+ result.speechVoices.error = {
+ passed: true,
+ detail: "Speech API error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // performanceAPI
+ // ============================================================
+ try {
+ // performance.now() resolution check
+ const samples: number[] = [];
+ for (let i = 0; i < 20; i++) {
+ const t1 = performance.now();
+ const t2 = performance.now();
+ if (t2 > t1) samples.push(t2 - t1);
+ }
+ const minDelta = samples.length > 0 ? Math.min(...samples) : 0;
+ // Firefox with privacy.reduceTimerPrecision rounds to 1ms or 100us
+ result.performanceAPI.timerResolution = {
+ passed: true,
+ detail:
+ "Min delta: " +
+ minDelta.toFixed(4) +
+ "ms (" +
+ samples.length +
+ " non-zero samples)",
+ };
+ // performance.memory should NOT exist in Firefox
+ result.performanceAPI.noPerformanceMemory = {
+ passed: typeof (performance as any).memory === "undefined",
+ detail:
+ typeof (performance as any).memory === "undefined"
+ ? "Not present (correct for Firefox)"
+ : "PRESENT (Chrome-only)",
+ };
+ } catch (e: any) {
+ result.performanceAPI.error = {
+ passed: true,
+ detail: "Performance API error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // intlConsistency
+ // ============================================================
+ try {
+ const intlTzFull = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const intlLocale = Intl.DateTimeFormat().resolvedOptions().locale;
+ const navLang = navigator.language;
+ // Locale base should match navigator.language
+ const localeBase = intlLocale.split("-")[0];
+ const langBase = navLang.split("-")[0];
+ const localeMatch = localeBase === langBase;
+ result.intlConsistency.localeVsLanguage = {
+ passed: localeMatch,
+ detail: localeMatch
+ ? 'Intl locale "' +
+ intlLocale +
+ '" matches navigator.language "' +
+ navLang +
+ '"'
+ : 'MISMATCH: Intl locale "' +
+ intlLocale +
+ '" vs navigator.language "' +
+ navLang +
+ '"',
+ };
+ // Numeric format should match locale
+ const numFormatted = new Intl.NumberFormat().format(1234.5);
+ result.intlConsistency.numberFormat = {
+ passed: true,
+ detail: "Number format: " + numFormatted + " (locale: " + intlLocale + ")",
+ };
+ result.intlConsistency.timezone = {
+ passed: true,
+ detail: "Intl timezone: " + intlTzFull,
+ };
+ } catch (e: any) {
+ result.intlConsistency.error = {
+ passed: true,
+ detail: "Intl error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // emojiFingerprint
+ // ============================================================
+ try {
+ const eCanvas = document.createElement("canvas");
+ eCanvas.width = 200;
+ eCanvas.height = 80;
+ const eCtx = eCanvas.getContext("2d");
+ if (!eCtx) throw new Error("Cannot get 2d context");
+ eCtx.font = "32px Arial, sans-serif";
+ eCtx.fillText("\uD83D\uDE00\uD83C\uDF89\uD83D\uDD25\uD83E\uDD16", 10, 40);
+ const eData = eCtx.getImageData(0, 0, 200, 80).data;
+ let eHash = 0;
+ for (let i = 0; i < eData.length; i += 4) {
+ eHash = ((eHash << 5) - eHash) + eData[i] + eData[i + 1] + eData[i + 2];
+ eHash = eHash & eHash;
+ }
+ result.emojiFingerprint.hash = {
+ passed: true,
+ detail: "Emoji canvas hash: " + Math.abs(eHash).toString(16),
+ };
+ } catch (e: any) {
+ result.emojiFingerprint.error = {
+ passed: true,
+ detail: "Emoji canvas error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // canvasNoiseDetection
+ // ============================================================
+ try {
+ function renderCanvasTest(): string {
+ const tc = document.createElement("canvas");
+ tc.width = 200;
+ tc.height = 50;
+ const tctx = tc.getContext("2d")!;
+ tctx.textBaseline = "top";
+ tctx.font = "14px Arial";
+ tctx.fillStyle = "#f60";
+ tctx.fillRect(125, 1, 62, 20);
+ tctx.fillStyle = "#069";
+ tctx.fillText("Cwm fjordbank glyphs vext quiz", 2, 15);
+ return tc.toDataURL();
+ }
+ const c1 = renderCanvasTest();
+ const c2 = renderCanvasTest();
+ const c3 = renderCanvasTest();
+ const allSame = c1 === c2 && c2 === c3;
+ result.canvasNoiseDetection.deterministic = {
+ passed: allSame,
+ detail: allSame
+ ? "3 renders produce identical output (correct - no random noise)"
+ : "DETECTED: Canvas output varies between renders (noise injection detected!)",
+ };
+ } catch (e: any) {
+ result.canvasNoiseDetection.error = {
+ passed: true,
+ detail: "Canvas noise check error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // webglRenderHash
+ // ============================================================
+ try {
+ const wc = document.createElement("canvas");
+ wc.width = 256;
+ wc.height = 256;
+ const wgl = wc.getContext("webgl");
+ if (wgl) {
+ // Render a simple colored triangle
+ const vs = "attribute vec2 p;void main(){gl_Position=vec4(p,0,1);}";
+ const fs =
+ "precision mediump float;void main(){gl_FragColor=vec4(0.86,0.27,0.33,1.0);}";
+ const vShader = wgl.createShader(wgl.VERTEX_SHADER)!;
+ wgl.shaderSource(vShader, vs);
+ wgl.compileShader(vShader);
+ const fShader = wgl.createShader(wgl.FRAGMENT_SHADER)!;
+ wgl.shaderSource(fShader, fs);
+ wgl.compileShader(fShader);
+ const prog = wgl.createProgram()!;
+ wgl.attachShader(prog, vShader);
+ wgl.attachShader(prog, fShader);
+ wgl.linkProgram(prog);
+ wgl.useProgram(prog);
+ const buf = wgl.createBuffer();
+ wgl.bindBuffer(wgl.ARRAY_BUFFER, buf);
+ wgl.bufferData(
+ wgl.ARRAY_BUFFER,
+ new Float32Array([0, 0.5, -0.5, -0.5, 0.5, -0.5]),
+ wgl.STATIC_DRAW
+ );
+ const loc = wgl.getAttribLocation(prog, "p");
+ wgl.enableVertexAttribArray(loc);
+ wgl.vertexAttribPointer(loc, 2, wgl.FLOAT, false, 0, 0);
+ wgl.clearColor(0, 0, 0, 1);
+ wgl.clear(wgl.COLOR_BUFFER_BIT);
+ wgl.drawArrays(wgl.TRIANGLES, 0, 3);
+ const pixels = new Uint8Array(256 * 256 * 4);
+ wgl.readPixels(0, 0, 256, 256, wgl.RGBA, wgl.UNSIGNED_BYTE, pixels);
+ let wHash = 0;
+ for (let i = 0; i < pixels.length; i += 16) {
+ wHash = ((wHash << 5) - wHash) + pixels[i];
+ wHash = wHash & wHash;
+ }
+ result.webglRenderHash.hash = {
+ passed: true,
+ detail: "WebGL render hash: " + Math.abs(wHash).toString(16),
+ };
+ } else {
+ result.webglRenderHash.noWebGL = {
+ passed: true,
+ detail: "WebGL not available",
+ };
+ }
+ } catch (e: any) {
+ result.webglRenderHash.error = {
+ passed: true,
+ detail: "WebGL render error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // fontPlatformConsistency
+ // ============================================================
+ try {
+ const fpCanvas = document.createElement("canvas");
+ const fpCtx = fpCanvas.getContext("2d")!;
+ function isFontAvailable(fontName: string): boolean {
+ const testStr = "mmmmmmmmmmlli";
+ fpCtx.font = "72px monospace";
+ const defaultWidth = fpCtx.measureText(testStr).width;
+ fpCtx.font = '72px "' + fontName + '", monospace';
+ return fpCtx.measureText(testStr).width !== defaultWidth;
+ }
+ const hasSegoeUI = isFontAvailable("Segoe UI");
+ const hasSFPro = isFontAvailable("SF Pro");
+ const hasHelveticaNeue = isFontAvailable("Helvetica Neue");
+ const hasLucidaGrande = isFontAvailable("Lucida Grande");
+ const hasTahoma = isFontAvailable("Tahoma");
+ const hasUbuntu = isFontAvailable("Ubuntu");
+ const hasDejaVuSans = isFontAvailable("DejaVu Sans");
+ const hasArimo = isFontAvailable("Arimo");
+ const hasCantarell = isFontAvailable("Cantarell");
+ const hasNotoColorEmoji = isFontAvailable("Noto Color Emoji");
+ const hasLiberation = isFontAvailable("Liberation Sans");
+ const plat = navigator.platform;
+ const platIsMac = plat === "MacIntel" || plat === "Macintosh";
+ const platIsWin = plat.indexOf("Win") === 0;
+
+ let fontOSMatch = true;
+ let fontDetail = "";
+ if (platIsMac) {
+ // Mac should have Helvetica Neue and Lucida Grande, NOT Segoe UI or Linux fonts
+ if (hasSegoeUI) {
+ fontOSMatch = false;
+ fontDetail = "Segoe UI detected on claimed Mac";
+ }
+ // Check for Linux-specific fonts on claimed Mac platform
+ const linuxFontsFound: string[] = [];
+ if (hasArimo) linuxFontsFound.push("Arimo");
+ if (hasCantarell) linuxFontsFound.push("Cantarell");
+ if (hasNotoColorEmoji) linuxFontsFound.push("Noto Color Emoji");
+ if (hasLiberation) linuxFontsFound.push("Liberation Sans");
+ if (hasUbuntu) linuxFontsFound.push("Ubuntu");
+ if (hasDejaVuSans) linuxFontsFound.push("DejaVu Sans");
+ if (linuxFontsFound.length > 0) {
+ fontOSMatch = false;
+ fontDetail +=
+ (fontDetail ? "; " : "") +
+ "Linux fonts on Mac: " +
+ linuxFontsFound.join(", ");
+ }
+ if (!hasHelveticaNeue && !hasSFPro && !hasLucidaGrande) {
+ fontDetail += (fontDetail ? "; " : "") + "No Mac fonts detected";
+ }
+ } else if (platIsWin) {
+ // Windows should have Segoe UI and Tahoma
+ if (!hasSegoeUI && !hasTahoma) {
+ fontDetail = "No Windows fonts detected";
+ }
+ }
+ result.fontPlatformConsistency.osMatch = {
+ passed: fontOSMatch,
+ detail: fontOSMatch
+ ? "Fonts consistent with " +
+ plat +
+ (fontDetail ? " (" + fontDetail + ")" : "")
+ : "MISMATCH: " + fontDetail,
+ };
+ result.fontPlatformConsistency.detected = {
+ passed: true,
+ detail:
+ "SegoeUI=" +
+ hasSegoeUI +
+ " SFPro=" +
+ hasSFPro +
+ " HelveticaNeue=" +
+ hasHelveticaNeue +
+ " Tahoma=" +
+ hasTahoma +
+ " Ubuntu=" +
+ hasUbuntu +
+ " DejaVu=" +
+ hasDejaVuSans,
+ };
+ } catch (e: any) {
+ result.fontPlatformConsistency.error = {
+ passed: true,
+ detail: "Font platform check error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // crossSignal: oscpuVsUA
+ // ============================================================
+ result.crossSignal.oscpuVsUA = (() => {
+ const oscpu = (navigator as any).oscpu || "";
+ const ua = navigator.userAgent;
+ if (!oscpu) return { passed: true, detail: "oscpu not available" };
+ const oscpuIsMac =
+ oscpu.indexOf("Mac OS X") !== -1 || oscpu.indexOf("Intel Mac") !== -1;
+ const oscpuIsWin = oscpu.indexOf("Windows") !== -1;
+ const oscpuIsLinux = oscpu.indexOf("Linux") !== -1;
+ const uaIsMac = ua.indexOf("Macintosh") !== -1;
+ const uaIsWin = ua.indexOf("Windows") !== -1;
+ const uaIsLinux = ua.indexOf("Linux") !== -1;
+ const match =
+ (oscpuIsMac && uaIsMac) ||
+ (oscpuIsWin && uaIsWin) ||
+ (oscpuIsLinux && uaIsLinux) ||
+ (!oscpuIsMac && !oscpuIsWin && !oscpuIsLinux);
+ return {
+ passed: match,
+ detail: match
+ ? 'oscpu "' + oscpu + '" matches UA OS'
+ : 'MISMATCH: oscpu="' + oscpu + '" vs UA OS',
+ };
+ })();
+
+ // ============================================================
+ // crossSignal: webglRendererVsPlatform
+ // ============================================================
+ result.crossSignal.webglRendererVsPlatform = (() => {
+ try {
+ const wCanvas = document.createElement("canvas");
+ const wGl = wCanvas.getContext("webgl");
+ if (!wGl) return { passed: true, detail: "WebGL not available" };
+ const dbg = wGl.getExtension("WEBGL_debug_renderer_info");
+ if (!dbg) return { passed: true, detail: "Debug renderer info not available" };
+ const renderer =
+ (wGl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) as string) || "";
+ const vendor =
+ (wGl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) as string) || "";
+ const plat = navigator.platform;
+ const platIsMac = plat === "MacIntel" || plat === "Macintosh";
+ // Check for obvious mismatches
+ const rendererLower = renderer.toLowerCase();
+ let suspicious = false;
+ let reason = "";
+ // Mac platform but renderer mentions Windows-specific driver version
+ if (platIsMac && rendererLower.indexOf("direct3d") !== -1) {
+ suspicious = true;
+ reason = "Direct3D renderer on Mac platform";
+ }
+ // Check for "or similar" suffix from Camoufox global config (expected behavior)
+ const hasSimilarSuffix = rendererLower.indexOf(", or similar") !== -1;
+ return {
+ passed: !suspicious,
+ detail: !suspicious
+ ? 'WebGL renderer "' +
+ renderer +
+ '" plausible for ' +
+ plat +
+ (hasSimilarSuffix ? " (Camoufox global)" : "")
+ : "MISMATCH: " + reason,
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "WebGL platform check error: " + e.message,
+ };
+ }
+ })();
+
+ // ============================================================
+ // audioIntegrity
+ // ============================================================
+
+ // Audio silence check -- getFloatFrequencyData before any audio should be all -Infinity
+ try {
+ const silenceCtx = new AudioContext();
+ const silenceAnalyser = silenceCtx.createAnalyser();
+ silenceAnalyser.fftSize = 256;
+ const silenceData = new Float32Array(silenceAnalyser.frequencyBinCount);
+ silenceAnalyser.getFloatFrequencyData(silenceData);
+ await silenceCtx.close();
+ let allNegInf = true;
+ for (let i = 0; i < silenceData.length; i++) {
+ if (silenceData[i] !== -Infinity && !isNaN(silenceData[i])) {
+ allNegInf = false;
+ break;
+ }
+ }
+ result.audioIntegrity.silenceCheck = {
+ passed: allNegInf,
+ detail: allNegInf
+ ? "Silent analyser returns -Infinity values (correct - no noise injection on silence)"
+ : "DETECTED: Non-Infinity values in silent analyser (noise injection leaked into silence)",
+ };
+ } catch (e: any) {
+ result.audioIntegrity.silenceCheck = {
+ passed: true,
+ detail: "Silence check skipped: " + e.message,
+ };
+ }
+
+ // Audio noise trap -- write known values to a buffer, read back, check if modified
+ // Note: Camoufox applies per-profile audio transformations via getChannelData() hook,
+ // so user-written data IS modified. This is expected -- report as informational, not failure.
+ try {
+ const trapCtx = new AudioContext();
+ const trapBuf = trapCtx.createBuffer(1, 128, 44100);
+ const trapChannel = trapBuf.getChannelData(0);
+ const trapExpected = new Float32Array(128);
+ for (let i = 0; i < 128; i++) {
+ trapExpected[i] = (i / 128.0) * 2.0 - 1.0; // Deterministic ramp
+ trapChannel[i] = trapExpected[i];
+ }
+ // Read back via getChannelData -- same Float32Array reference
+ const trapReadBack = trapBuf.getChannelData(0);
+ let trapModified = false;
+ let maxDiff = 0;
+ for (let i = 0; i < 128; i++) {
+ const diff = Math.abs(trapReadBack[i] - trapExpected[i]);
+ if (diff > maxDiff) maxDiff = diff;
+ if (diff > 1e-10) {
+ trapModified = true;
+ }
+ }
+ result.audioIntegrity.noiseTrap = {
+ passed: true,
+ detail: trapModified
+ ? "Audio buffer modified on read-back (max delta: " +
+ maxDiff.toExponential(2) +
+ ") - Camoufox audio transform active"
+ : "Audio buffer unchanged after write-back (no audio transform applied)",
+ };
+ } catch (e: any) {
+ result.audioIntegrity.noiseTrap = {
+ passed: true,
+ detail: "Noise trap skipped: " + e.message,
+ };
+ }
+
+ // channelData vs copyFromChannel match
+ try {
+ const crossCtx = new OfflineAudioContext(1, 44100, 44100);
+ const crossOsc = crossCtx.createOscillator();
+ const crossComp = crossCtx.createDynamicsCompressor();
+ crossOsc.type = "triangle";
+ crossOsc.frequency.value = 10000;
+ crossOsc.connect(crossComp);
+ crossComp.connect(crossCtx.destination);
+ crossOsc.start(0);
+ const crossRendered = await crossCtx.startRendering();
+ const chData = crossRendered.getChannelData(0);
+ const copyData = new Float32Array(chData.length);
+ crossRendered.copyFromChannel(copyData, 0);
+
+ // Hash both
+ function quickHash(arr: Float32Array): number {
+ let h = 0;
+ for (let i = 0; i < arr.length; i += 100) {
+ h = ((h << 5) - h) + ((arr[i] * 1000000) | 0);
+ h = h & h;
+ }
+ return h;
+ }
+ const chHash = quickHash(chData);
+ const cpHash = quickHash(copyData);
+ const hashMatch = chHash === cpHash;
+
+ result.audioIntegrity.channelDataVsCopy = {
+ passed: hashMatch,
+ detail: hashMatch
+ ? "getChannelData and copyFromChannel produce same hash (" +
+ Math.abs(chHash).toString(16) +
+ ")"
+ : "MISMATCH: getChannelData=" +
+ Math.abs(chHash).toString(16) +
+ " copyFromChannel=" +
+ Math.abs(cpHash).toString(16),
+ };
+ } catch (e: any) {
+ result.audioIntegrity.channelDataVsCopy = {
+ passed: true,
+ detail: "Audio cross-validation skipped: " + e.message,
+ };
+ }
+
+ // DynamicsCompressor.reduction check
+ try {
+ const compCtx = new AudioContext();
+ const compOsc = compCtx.createOscillator();
+ const comp = compCtx.createDynamicsCompressor();
+ comp.threshold.value = -50;
+ comp.knee.value = 40;
+ comp.ratio.value = 12;
+ comp.attack.value = 0;
+ comp.release.value = 0.25;
+ // Route through a silent gain node -- compressor still processes audio
+ // but nothing reaches the speakers
+ const silentGain = compCtx.createGain();
+ silentGain.gain.value = 0;
+ compOsc.connect(comp);
+ comp.connect(silentGain);
+ silentGain.connect(compCtx.destination);
+ compOsc.start();
+ // Wait briefly for processing
+ await new Promise((r) => setTimeout(r, 100));
+ const reduction = comp.reduction;
+ compOsc.stop();
+ await compCtx.close();
+
+ result.audioIntegrity.compressorReduction = {
+ passed: true,
+ detail:
+ "DynamicsCompressor.reduction = " +
+ (typeof reduction === "number"
+ ? reduction.toFixed(4)
+ : String(reduction)),
+ };
+ } catch (e: any) {
+ result.audioIntegrity.compressorReduction = {
+ passed: true,
+ detail: "Compressor check skipped: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // iframeTesting
+ // ============================================================
+ try {
+ const testIframe = document.createElement("iframe");
+ testIframe.style.cssText =
+ "position:absolute;left:-9999px;width:1px;height:1px;";
+ document.body.appendChild(testIframe);
+ const iWin = testIframe.contentWindow! as any;
+
+ // Check navigator properties match between main window and iframe
+ result.iframeTesting.navigatorMatch = (() => {
+ const mainUA = navigator.userAgent;
+ const iframeUA = iWin.navigator.userAgent;
+ const mainPlat = navigator.platform;
+ const iframePlat = iWin.navigator.platform;
+ const uaMatch = mainUA === iframeUA;
+ const platMatch = mainPlat === iframePlat;
+ return {
+ passed: uaMatch && platMatch,
+ detail:
+ uaMatch && platMatch
+ ? "Navigator properties match across main window and iframe"
+ : "MISMATCH: " +
+ (!uaMatch ? "UA differs" : "platform differs") +
+ " in iframe",
+ };
+ })();
+
+ // Check screen properties match in iframe
+ result.iframeTesting.screenMatch = (() => {
+ const mainW = screen.width;
+ const iframeW = iWin.screen.width;
+ const mainH = screen.height;
+ const iframeH = iWin.screen.height;
+ const match = mainW === iframeW && mainH === iframeH;
+ return {
+ passed: match,
+ detail: match
+ ? "Screen dimensions match in iframe (" + mainW + "x" + mainH + ")"
+ : "MISMATCH: main=" +
+ mainW +
+ "x" +
+ mainH +
+ " iframe=" +
+ iframeW +
+ "x" +
+ iframeH,
+ };
+ })();
+
+ // Check timezone matches in iframe
+ result.iframeTesting.timezoneMatch = (() => {
+ const mainTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const iframeTz =
+ iWin.Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const match = mainTz === iframeTz;
+ return {
+ passed: match,
+ detail: match
+ ? "Timezone matches in iframe (" + mainTz + ")"
+ : "MISMATCH: main=" + mainTz + " iframe=" + iframeTz,
+ };
+ })();
+
+ document.body.removeChild(testIframe);
+ } catch (e: any) {
+ result.iframeTesting.error = {
+ passed: true,
+ detail: "Iframe testing skipped: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // headlessDetection
+ // ============================================================
+ try {
+ // Permission bug: Notification.permission vs permissions.query() mismatch
+ // NOTE: Firefox normally shows this mismatch (denied vs prompt) because
+ // Notification.permission reads enforcement state while permissions.query
+ // reads the stored user-decision state. This is NOT a headless indicator
+ // in Firefox -- it's expected behavior. Marked informational (always pass).
+ result.headlessDetection.permissionConsistency = await (async () => {
+ try {
+ if (
+ typeof Notification === "undefined" ||
+ !navigator.permissions
+ ) {
+ return {
+ passed: true,
+ detail: "Notification or Permissions API unavailable",
+ };
+ }
+ const notifPerm = Notification.permission;
+ const queryResult = await navigator.permissions.query({
+ name: "notifications" as PermissionName,
+ });
+ const expected = notifPerm === "default" ? "prompt" : notifPerm;
+ const match = expected === queryResult.state;
+ return {
+ passed: true,
+ detail: match
+ ? "Notification.permission (" +
+ notifPerm +
+ ") consistent with permissions.query (" +
+ queryResult.state +
+ ")"
+ : "Notification.permission=" +
+ notifPerm +
+ ", query=" +
+ queryResult.state +
+ " (normal Firefox mismatch)",
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "Permission check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // No taskbar detection: screen.availWidth === screen.width
+ result.headlessDetection.hasTaskbar = (() => {
+ const wDiff = screen.width - screen.availWidth;
+ const hDiff = screen.height - screen.availHeight;
+ // Both being 0 is suspicious (no taskbar/dock) but not definitive
+ // Camoufox screen spoofing may set avail === screen
+ const noTaskbar = wDiff === 0 && hDiff === 0;
+ return {
+ passed: true,
+ detail: noTaskbar
+ ? "availWidth === width, availHeight === height (no taskbar - spoofed screen or headless)"
+ : "Taskbar detected: width diff=" +
+ wDiff +
+ "px, height diff=" +
+ hDiff +
+ "px",
+ };
+ })();
+
+ // Viewport equals screen exactly (common headless signal)
+ result.headlessDetection.viewportNotScreen = (() => {
+ const vpEqualsScreen =
+ window.innerWidth === screen.width &&
+ window.innerHeight === screen.height;
+ return {
+ passed: !vpEqualsScreen,
+ detail: vpEqualsScreen
+ ? "SUSPICIOUS: viewport (" +
+ window.innerWidth +
+ "x" +
+ window.innerHeight +
+ ") exactly equals screen"
+ : "Viewport (" +
+ window.innerWidth +
+ "x" +
+ window.innerHeight +
+ ") differs from screen (" +
+ screen.width +
+ "x" +
+ screen.height +
+ ")",
+ };
+ })();
+
+ // System color detection (headless browsers resolve system colors differently)
+ result.headlessDetection.systemColors = (() => {
+ try {
+ const div = document.createElement("div");
+ div.style.cssText = "position:absolute;left:-9999px;color:ActiveText;";
+ document.body.appendChild(div);
+ const color = getComputedStyle(div).color;
+ document.body.removeChild(div);
+ // In headless Chrome, ActiveText resolves to rgb(255, 0, 0) exactly
+ const isDefaultRed = color === "rgb(255, 0, 0)";
+ return {
+ passed: !isDefaultRed,
+ detail: isDefaultRed
+ ? "SUSPICIOUS: ActiveText resolved to default red (headless indicator)"
+ : "ActiveText resolves to: " + color + " (normal)",
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "System color check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // SwiftShader WebGL detection (software renderer used in headless)
+ result.headlessDetection.noSwiftShader = (() => {
+ try {
+ const sc = document.createElement("canvas");
+ const sgl = sc.getContext("webgl");
+ if (!sgl) return { passed: true, detail: "WebGL not available" };
+ const ext = sgl.getExtension("WEBGL_debug_renderer_info");
+ if (!ext) return { passed: true, detail: "Debug renderer info not available" };
+ const renderer = (
+ (sgl.getParameter(ext.UNMASKED_RENDERER_WEBGL) as string) || ""
+ ).toLowerCase();
+ const hasSwift = renderer.indexOf("swiftshader") !== -1;
+ const hasLLVM = renderer.indexOf("llvmpipe") !== -1;
+ const hasSoftware = renderer.indexOf("software") !== -1;
+ const suspicious = hasSwift || hasLLVM || hasSoftware;
+ return {
+ passed: !suspicious,
+ detail: suspicious
+ ? "SOFTWARE RENDERER: " + renderer + " (headless indicator)"
+ : "Hardware renderer: " + renderer.substring(0, 60),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "SwiftShader check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // Headless UA string check
+ result.headlessDetection.noHeadlessUA = (() => {
+ const ua = navigator.userAgent.toLowerCase();
+ const hasHeadless =
+ ua.indexOf("headlesschrome") !== -1 ||
+ ua.indexOf("headlessfirefox") !== -1 ||
+ ua.indexOf("phantomjs") !== -1;
+ return {
+ passed: !hasHeadless,
+ detail: hasHeadless
+ ? "HEADLESS UA: " + navigator.userAgent
+ : "No headless string in UA",
+ };
+ })();
+
+ // navigator.webdriver check (redundant with automation.webdriver but part of headless scoring)
+ result.headlessDetection.noWebdriver = {
+ passed: (navigator as any).webdriver !== true,
+ detail:
+ (navigator as any).webdriver === true
+ ? "navigator.webdriver = true (headless/automation)"
+ : "navigator.webdriver = " + (navigator as any).webdriver,
+ };
+
+ // Outer dimensions check (0 = headless)
+ result.headlessDetection.outerDimensions = {
+ passed: window.outerWidth > 0 && window.outerHeight > 0,
+ detail:
+ window.outerWidth > 0 && window.outerHeight > 0
+ ? "outerWidth=" +
+ window.outerWidth +
+ " outerHeight=" +
+ window.outerHeight
+ : "ZERO outer dimensions (headless indicator)",
+ };
+
+ // navigator.plugins presence check
+ result.headlessDetection.hasPlugins = {
+ passed: navigator.plugins && navigator.plugins.length > 0,
+ detail: navigator.plugins
+ ? navigator.plugins.length + " plugins (0 = headless indicator)"
+ : "navigator.plugins missing",
+ };
+
+ // Compute headless score percentage
+ let headlessIndicators = 0;
+ let headlessTotal = 0;
+ const hdChecks = result.headlessDetection;
+ for (const hk in hdChecks) {
+ if (
+ hdChecks[hk] &&
+ typeof hdChecks[hk].passed === "boolean"
+ ) {
+ headlessTotal++;
+ if (!hdChecks[hk].passed) headlessIndicators++;
+ }
+ }
+ const headlessPercent =
+ headlessTotal > 0
+ ? Math.round((headlessIndicators / headlessTotal) * 100)
+ : 0;
+ // Threshold: up to 15% is acceptable (1 flag out of 9 checks = 11%, which is
+ // common for Firefox due to light-theme prefersColorScheme and similar benign signals)
+ result.headlessDetection.headlessScore = {
+ passed: headlessPercent <= 15,
+ detail:
+ headlessPercent +
+ "% headless indicators (" +
+ headlessIndicators +
+ "/" +
+ headlessTotal +
+ " flagged)",
+ };
+ } catch (e: any) {
+ result.headlessDetection.error = {
+ passed: true,
+ detail: "Headless detection error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // trashDetection
+ // ============================================================
+ try {
+ // Screen dimensions must be integers
+ result.trashDetection.integerScreen = (() => {
+ const w = screen.width;
+ const h = screen.height;
+ const isInt = Number.isInteger(w) && Number.isInteger(h);
+ return {
+ passed: isInt,
+ detail: isInt
+ ? "Screen dimensions are integers (" + w + "x" + h + ")"
+ : "NON-INTEGER screen: " + w + "x" + h,
+ };
+ })();
+
+ // hardwareConcurrency should be a common value
+ result.trashDetection.plausibleHWC = (() => {
+ const hwc = navigator.hardwareConcurrency;
+ const common = [
+ 1, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 32, 36, 40, 48, 56, 64,
+ 96, 128, 256,
+ ];
+ const isCommon = common.indexOf(hwc) !== -1;
+ return {
+ passed: isCommon,
+ detail: isCommon
+ ? "hardwareConcurrency=" + hwc + " (common value)"
+ : "UNUSUAL hardwareConcurrency=" +
+ hwc +
+ " (not a typical core count)",
+ };
+ })();
+
+ // WebGL renderer string analysis
+ result.trashDetection.plausibleWebGLRenderer = (() => {
+ try {
+ const c = document.createElement("canvas");
+ const gl = c.getContext("webgl");
+ if (!gl) return { passed: true, detail: "WebGL not available" };
+ const ext = gl.getExtension("WEBGL_debug_renderer_info");
+ if (!ext)
+ return { passed: true, detail: "Debug renderer info not available" };
+ const renderer =
+ (gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) as string) || "";
+ if (!renderer) return { passed: true, detail: "Empty renderer string" };
+ // Check for gibberish: unusual case patterns, too short, random digits
+ const tooShort = renderer.length < 5;
+ const allDigits = /^[0-9]+$/.test(renderer);
+ const allLowerNoSpaces =
+ renderer === renderer.toLowerCase() &&
+ renderer.indexOf(" ") === -1 &&
+ renderer.length > 10;
+ const gibberish = tooShort || allDigits || allLowerNoSpaces;
+ return {
+ passed: !gibberish,
+ detail: gibberish
+ ? 'SUSPICIOUS renderer: "' + renderer + '" (gibberish pattern)'
+ : "Renderer plausible: " + renderer.substring(0, 50),
+ };
+ } catch (e: any) {
+ return {
+ passed: true,
+ detail: "WebGL renderer check skipped: " + e.message,
+ };
+ }
+ })();
+
+ // Screen size within known ranges
+ result.trashDetection.reasonableScreenSize = (() => {
+ const w = screen.width;
+ const h = screen.height;
+ const reasonable =
+ w >= 320 && w <= 7680 && h >= 240 && h <= 4320;
+ return {
+ passed: reasonable,
+ detail: reasonable
+ ? "Screen " +
+ w +
+ "x" +
+ h +
+ " within known range (320-7680 x 240-4320)"
+ : "UNREASONABLE screen: " + w + "x" + h,
+ };
+ })();
+
+ // colorDepth in known set
+ result.trashDetection.validColorDepth = (() => {
+ const depth = screen.colorDepth;
+ const valid = [1, 4, 8, 15, 16, 24, 30, 32, 48].indexOf(depth) !== -1;
+ return {
+ passed: valid,
+ detail: valid
+ ? "colorDepth=" + depth + " (valid value)"
+ : "UNUSUAL colorDepth=" + depth,
+ };
+ })();
+ } catch (e: any) {
+ result.trashDetection.error = {
+ passed: true,
+ detail: "Trash detection error: " + e.message,
+ };
+ }
+
+ // ============================================================
+ // webglExtended (raw params for fingerprint comparison)
+ // ============================================================
+ try {
+ const canvas = document.createElement("canvas");
+ const gl = canvas.getContext("webgl");
+ if (gl) {
+ const maxRenderbufferSize = gl.getParameter(gl.MAX_RENDERBUFFER_SIZE);
+ const maxViewportDims = gl.getParameter(gl.MAX_VIEWPORT_DIMS);
+ const maxVertexAttribs = gl.getParameter(gl.MAX_VERTEX_ATTRIBS);
+ const maxVaryingVectors = gl.getParameter(gl.MAX_VARYING_VECTORS);
+ const aliasedLineWidthRange = gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE);
+ const aliasedPointSizeRange = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE);
+ const extensions = gl.getSupportedExtensions();
+ const extensionCount = extensions ? extensions.length : 0;
+ const extensionStr = extensions ? extensions.join(",") : "";
+
+ // Shader precision
+ const vertexHighP = gl.getShaderPrecisionFormat(
+ gl.VERTEX_SHADER,
+ gl.HIGH_FLOAT
+ );
+ const fragmentHighP = gl.getShaderPrecisionFormat(
+ gl.FRAGMENT_SHADER,
+ gl.HIGH_FLOAT
+ );
+ const vertexHighPrecision = vertexHighP
+ ? vertexHighP.precision +
+ "/" +
+ vertexHighP.rangeMin +
+ "/" +
+ vertexHighP.rangeMax
+ : null;
+ const fragmentHighPrecision = fragmentHighP
+ ? fragmentHighP.precision +
+ "/" +
+ fragmentHighP.rangeMin +
+ "/" +
+ fragmentHighP.rangeMax
+ : null;
+
+ // Store raw params as informational checks
+ result.webglExtended.maxRenderbufferSize = {
+ passed: true,
+ detail: "MAX_RENDERBUFFER_SIZE: " + maxRenderbufferSize,
+ };
+ result.webglExtended.maxViewportDims = {
+ passed: true,
+ detail:
+ "MAX_VIEWPORT_DIMS: " +
+ (maxViewportDims ? maxViewportDims.toString() : "null"),
+ };
+ result.webglExtended.maxVertexAttribs = {
+ passed: true,
+ detail: "MAX_VERTEX_ATTRIBS: " + maxVertexAttribs,
+ };
+ result.webglExtended.maxVaryingVectors = {
+ passed: true,
+ detail: "MAX_VARYING_VECTORS: " + maxVaryingVectors,
+ };
+ result.webglExtended.aliasedLineWidthRange = {
+ passed: true,
+ detail:
+ "ALIASED_LINE_WIDTH_RANGE: " +
+ (aliasedLineWidthRange ? aliasedLineWidthRange.toString() : "null"),
+ };
+ result.webglExtended.aliasedPointSizeRange = {
+ passed: true,
+ detail:
+ "ALIASED_POINT_SIZE_RANGE: " +
+ (aliasedPointSizeRange ? aliasedPointSizeRange.toString() : "null"),
+ };
+ result.webglExtended.extensionCount = {
+ passed: true,
+ detail: "Extensions: " + extensionCount,
+ };
+ result.webglExtended.extensions = {
+ passed: true,
+ detail: extensionStr.substring(0, 200),
+ };
+ result.webglExtended.vertexHighPrecision = {
+ passed: true,
+ detail:
+ "Vertex HIGH_FLOAT precision: " + (vertexHighPrecision || "unavailable"),
+ };
+ result.webglExtended.fragmentHighPrecision = {
+ passed: true,
+ detail:
+ "Fragment HIGH_FLOAT precision: " +
+ (fragmentHighPrecision || "unavailable"),
+ };
+ }
+ } catch {
+ // webglExtended stays empty if WebGL unavailable
+ }
+
+ // ============================================================
+ // fontEnvironment (CreepJS OS Detection Readiness)
+ // ============================================================
+ // Platform-aware: reads navigator.platform to determine which
+ // OS marker fonts should be present, and which would be leaks.
+ try {
+ // Canvas-based font detection (same technique fingerprinters use)
+ const fontCanvas = document.createElement("canvas");
+ const fontCtx = fontCanvas.getContext("2d")!;
+ const fontTestStr = "mmmmmmmmmmlli";
+ fontCtx.font = "72px monospace";
+ const monoWidth = fontCtx.measureText(fontTestStr).width;
+
+ function isFontInstalled(name: string): boolean {
+ fontCtx.font = '72px "' + name + '", monospace';
+ return fontCtx.measureText(fontTestStr).width !== monoWidth;
+ }
+
+ // Determine claimed platform from navigator
+ const plat = (navigator.platform || "").toLowerCase();
+ const ua = (navigator.userAgent || "").toLowerCase();
+ let claimedOS = "unknown";
+ if (plat.indexOf("mac") !== -1 || ua.indexOf("macintosh") !== -1)
+ claimedOS = "macos";
+ else if (plat.indexOf("linux") !== -1) claimedOS = "linux";
+ else if (plat.indexOf("win") !== -1) claimedOS = "windows";
+
+ // CreepJS marker fonts by OS
+ const appleDetectionFonts = ["Helvetica Neue", "PingFang HK", "Geneva"];
+ const linuxDetectionFonts = ["Arimo", "Cousine"];
+ const windowsDetectionFonts = [
+ "Cambria Math",
+ "Nirmala UI",
+ "Leelawadee UI",
+ "HoloLens MDL2 Assets",
+ "Segoe Fluent Icons",
+ ];
+
+ // Pick the right expected fonts based on claimed OS
+ const expectedFonts =
+ claimedOS === "macos"
+ ? appleDetectionFonts
+ : claimedOS === "linux"
+ ? linuxDetectionFonts
+ : claimedOS === "windows"
+ ? windowsDetectionFonts
+ : [];
+ const expectedLabel =
+ claimedOS === "macos"
+ ? "Apple"
+ : claimedOS === "linux"
+ ? "Linux"
+ : claimedOS === "windows"
+ ? "Windows"
+ : "Unknown";
+
+ // Check: Are the expected OS marker fonts installed?
+ const expectedDetected: string[] = [];
+ const expectedMissing: string[] = [];
+ for (const font of expectedFonts) {
+ if (isFontInstalled(font)) {
+ expectedDetected.push(font);
+ } else {
+ expectedMissing.push(font);
+ }
+ }
+ const hasOSId = expectedDetected.length > 0;
+ result.fontEnvironment.osDetection = {
+ passed: hasOSId,
+ detail: hasOSId
+ ? expectedDetected.length +
+ "/" +
+ expectedFonts.length +
+ " " +
+ expectedLabel +
+ " marker fonts found (platform: " +
+ navigator.platform +
+ "): " +
+ expectedDetected.join(", ")
+ : "No " +
+ expectedLabel +
+ " marker fonts found (platform: " +
+ navigator.platform +
+ '). CreepJS will show "Like undefined". Missing: ' +
+ expectedMissing.join(", "),
+ };
+
+ // macOS-only: version depth check
+ if (claimedOS === "macos") {
+ // Use base family names (not weight variants) since fontconfig on Linux
+ // registers TTC base families, not individual weight sub-families.
+ const macVersionFonts: [string, string][] = [
+ ["10.9", "Helvetica Neue"],
+ ["10.9", "Geneva"],
+ ["10.10", "Kohinoor Devanagari"],
+ ["10.10", "Luminari"],
+ ["10.11", "PingFang HK"],
+ ["10.12", "American Typewriter"],
+ ["10.12", "Futura"],
+ ["10.13", "InaiMathi"],
+ ["10.15", "Galvji"],
+ ["10.15", "MuktaMahee"],
+ ["12", "STIX Two Math"],
+ ["12", "STIX Two Text"],
+ ["13", "Apple SD Gothic Neo"],
+ ["13", "Noto Sans Canadian Aboriginal"],
+ ];
+ let versionFound = 0;
+ const versionTotal = macVersionFonts.length;
+ let highestVersion = "none";
+ for (const [ver, font] of macVersionFonts) {
+ if (isFontInstalled(font)) {
+ versionFound++;
+ highestVersion = ver;
+ }
+ }
+ // Threshold of 3: Camoufox bundles a subset of macOS fonts, not a full install.
+ // Real macOS machines also vary -- not all have every version font.
+ result.fontEnvironment.macOSVersionDepth = {
+ passed: versionFound >= 3,
+ detail:
+ versionFound +
+ "/" +
+ versionTotal +
+ " version marker fonts installed (highest: macOS " +
+ highestVersion +
+ ")",
+ };
+ }
+
+ // Check for wrong-OS font leaks -- fonts from OTHER OSes should not be present
+ const wrongOSFonts: [string, string][] = [];
+ if (claimedOS !== "macos") {
+ for (const f of appleDetectionFonts) {
+ wrongOSFonts.push(["macOS", f]);
+ }
+ }
+ if (claimedOS !== "linux") {
+ for (const f of linuxDetectionFonts) {
+ wrongOSFonts.push(["Linux", f]);
+ }
+ }
+ if (claimedOS !== "windows") {
+ for (const f of windowsDetectionFonts) {
+ wrongOSFonts.push(["Windows", f]);
+ }
+ }
+ const leakedFonts: string[] = [];
+ for (const [os, font] of wrongOSFonts) {
+ if (isFontInstalled(font)) {
+ leakedFonts.push(os + ": " + font);
+ }
+ }
+ const noLeaks = leakedFonts.length === 0;
+ result.fontEnvironment.noWrongOSFonts = {
+ passed: noLeaks,
+ detail: noLeaks
+ ? "No wrong-OS marker fonts detected (claiming " + expectedLabel + ")"
+ : "Wrong-OS fonts found while claiming " +
+ expectedLabel +
+ "! " +
+ leakedFonts.join(", "),
+ };
+ } catch (e: any) {
+ result.fontEnvironment.error = {
+ passed: true,
+ detail: "Font environment check error: " + e.message,
+ };
+ }
+
+ return result as Record<
+ string,
+ Record
+ >;
+}
diff --git a/build-tester/src/lib/checks/index.ts b/build-tester/src/lib/checks/index.ts
new file mode 100644
index 0000000..ae66682
--- /dev/null
+++ b/build-tester/src/lib/checks/index.ts
@@ -0,0 +1,96 @@
+"use client";
+
+import type { CheckResult, TestResults } from "../types";
+
+export interface PhaseResult {
+ phase: string;
+}
+
+const SELF_DESTRUCT_FUNCTIONS = [
+ "setFontSpacingSeed",
+ "setAudioFingerprintSeed",
+ "setCanvasSeed",
+ "setTimezone",
+ "setScreenDimensions",
+ "setScreenColorDepth",
+ "setNavigatorPlatform",
+ "setNavigatorOscpu",
+ "setNavigatorHardwareConcurrency",
+ "setWebGLVendor",
+ "setWebGLRenderer",
+ "setFontList",
+ "setSpeechVoices",
+ "setWebRTCIPv4",
+];
+
+function runSelfDestructChecks(): Record {
+ const results: Record = {};
+ for (const fn of SELF_DESTRUCT_FUNCTIONS) {
+ const destroyed = typeof (window as any)[fn] === "undefined";
+ results[fn] = {
+ passed: destroyed,
+ detail: destroyed
+ ? `${fn} deleted from window after init`
+ : `${fn} still present on window — self-destruct failed`,
+ };
+ }
+ return results;
+}
+
+export async function runAllChecks(
+ onPhaseComplete?: (phase: PhaseResult) => void
+): Promise {
+ // Phase 1: Collect fingerprints
+ const { collectFingerprints, checkWebRTC } = await import("./collectors");
+ const fingerprints = await collectFingerprints();
+ onPhaseComplete?.({ phase: "fingerprints" });
+
+ // Phase 2: Core checks
+ const { runCoreChecks } = await import("./core");
+ const core = await runCoreChecks();
+ onPhaseComplete?.({ phase: "core" });
+
+ // Phase 3: Extended checks
+ const { runExtendedChecks } = await import("./extended");
+ const extended = await runExtendedChecks();
+ onPhaseComplete?.({ phase: "extended" });
+
+ // Phase 4: Worker consistency checks
+ const { runWorkerChecks } = await import("./workers");
+ const workers = await runWorkerChecks();
+ onPhaseComplete?.({ phase: "workers" });
+
+ // Phase 5: WebRTC leak check
+ const webrtc = await checkWebRTC();
+ onPhaseComplete?.({ phase: "webrtc" });
+
+ // Phase 6: Stability - collect fingerprints again and compare
+ const fingerprints2 = await collectFingerprints();
+ const diffs: string[] = [];
+ if (fingerprints.canvas.hash !== fingerprints2.canvas.hash) diffs.push("canvas");
+ if (fingerprints.audio.hash !== fingerprints2.audio.hash) diffs.push("audio");
+ if (fingerprints.fonts.hash !== fingerprints2.fonts.hash) diffs.push("fonts");
+ if (fingerprints.clientRects.hash !== fingerprints2.clientRects.hash) diffs.push("clientRects");
+ // Only compare speechVoices if both collections returned voices.
+ if (fingerprints.speechVoices.count > 0 && fingerprints2.speechVoices.count > 0 &&
+ fingerprints.speechVoices.hash !== fingerprints2.speechVoices.hash) diffs.push("speechVoices");
+ const stable = diffs.length === 0;
+ const detail = stable
+ ? "All fingerprints stable across two collections"
+ : `Unstable: ${diffs.join(", ")} changed between collections`;
+ onPhaseComplete?.({ phase: "stability" });
+
+ // Phase 7: Self-destruct — verify init script functions deleted themselves from window
+ const selfDestruct = runSelfDestructChecks();
+ onPhaseComplete?.({ phase: "selfDestruct" });
+
+ return {
+ fingerprints,
+ core,
+ extended,
+ workers,
+ webrtc,
+ stability: { fingerprints2, stable, detail },
+ selfDestruct,
+ };
+}
diff --git a/build-tester/src/lib/checks/workers.ts b/build-tester/src/lib/checks/workers.ts
new file mode 100644
index 0000000..0c2f70f
--- /dev/null
+++ b/build-tester/src/lib/checks/workers.ts
@@ -0,0 +1,285 @@
+"use client";
+
+type CheckResult = { passed: boolean; detail: string };
+type CategoryResults = Record;
+
+function createWorkerAndGetValue(code: string): Promise {
+ return new Promise((resolve, reject) => {
+ const blob = new Blob([code], { type: "application/javascript" });
+ const url = URL.createObjectURL(blob);
+ const worker = new Worker(url);
+ const timeout = setTimeout(() => {
+ worker.terminate();
+ URL.revokeObjectURL(url);
+ reject(new Error("Worker timeout"));
+ }, 5000);
+ worker.onmessage = (e) => {
+ clearTimeout(timeout);
+ worker.terminate();
+ URL.revokeObjectURL(url);
+ resolve(e.data);
+ };
+ worker.onerror = (e) => {
+ clearTimeout(timeout);
+ worker.terminate();
+ URL.revokeObjectURL(url);
+ reject(new Error(e.message));
+ };
+ worker.postMessage("go");
+ });
+}
+
+function createSharedWorkerAndGetValue(code: string): Promise {
+ return new Promise((resolve, reject) => {
+ const blob = new Blob([code], { type: "application/javascript" });
+ const url = URL.createObjectURL(blob);
+ const sw = new SharedWorker(url);
+ const timeout = setTimeout(() => {
+ URL.revokeObjectURL(url);
+ reject(new Error("SharedWorker timeout"));
+ }, 5000);
+ sw.port.onmessage = (e) => {
+ clearTimeout(timeout);
+ URL.revokeObjectURL(url);
+ resolve(e.data);
+ };
+ sw.onerror = (e) => {
+ clearTimeout(timeout);
+ URL.revokeObjectURL(url);
+ reject(new Error((e as ErrorEvent).message || "SharedWorker error"));
+ };
+ sw.port.start();
+ sw.port.postMessage("go");
+ });
+}
+
+export async function runWorkerChecks(): Promise<
+ Record>
+> {
+ const workerConsistency: CategoryResults = {};
+
+ // Main thread reference values
+ const mainUA = navigator.userAgent;
+ const mainPlatform = navigator.platform;
+ const mainHWC = navigator.hardwareConcurrency;
+ const mainLang = navigator.language;
+ const mainTZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+ // Get WebGL renderer from main thread for comparison
+ let mainWebGLRenderer = "";
+ try {
+ const canvas = document.createElement("canvas");
+ const gl = canvas.getContext("webgl");
+ if (gl) {
+ const ext = gl.getExtension("WEBGL_debug_renderer_info");
+ if (ext) {
+ mainWebGLRenderer =
+ (gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) as string) || "";
+ }
+ }
+ } catch {
+ // ignore
+ }
+
+ // dedicatedWorkerUA
+ try {
+ const code = `self.onmessage = () => { self.postMessage({ ua: navigator.userAgent }); }`;
+ const data = await createWorkerAndGetValue<{ ua: string }>(code);
+ const match = mainUA === data.ua;
+ workerConsistency.dedicatedWorkerUA = {
+ passed: match,
+ detail: match
+ ? "UA matches window and dedicated worker"
+ : `MISMATCH: window="${mainUA.substring(0, 60)}..." worker="${(data.ua || "").substring(0, 60)}..."`,
+ };
+ } catch (e: any) {
+ workerConsistency.dedicatedWorkerUA = {
+ passed: true,
+ detail: "Dedicated worker unavailable: " + (e?.message || String(e)),
+ };
+ }
+
+ // dedicatedWorkerPlatform
+ try {
+ const code = `self.onmessage = () => { self.postMessage({ platform: navigator.platform }); }`;
+ const data = await createWorkerAndGetValue<{ platform: string }>(code);
+ const match = mainPlatform === data.platform;
+ workerConsistency.dedicatedWorkerPlatform = {
+ passed: match,
+ detail: match
+ ? "Platform matches: " + mainPlatform
+ : `MISMATCH: window="${mainPlatform}" worker="${data.platform}"`,
+ };
+ } catch (e: any) {
+ workerConsistency.dedicatedWorkerPlatform = {
+ passed: true,
+ detail: "Dedicated worker unavailable: " + (e?.message || String(e)),
+ };
+ }
+
+ // dedicatedWorkerHWC
+ try {
+ const code = `self.onmessage = () => { self.postMessage({ hwc: navigator.hardwareConcurrency }); }`;
+ const data = await createWorkerAndGetValue<{ hwc: number }>(code);
+ const match = mainHWC === data.hwc;
+ workerConsistency.dedicatedWorkerHWC = {
+ passed: match,
+ detail: match
+ ? "hardwareConcurrency matches: " + mainHWC
+ : `MISMATCH: window=${mainHWC} worker=${data.hwc}`,
+ };
+ } catch (e: any) {
+ workerConsistency.dedicatedWorkerHWC = {
+ passed: true,
+ detail: "Dedicated worker unavailable: " + (e?.message || String(e)),
+ };
+ }
+
+ // dedicatedWorkerLanguage
+ try {
+ const code = `self.onmessage = () => { self.postMessage({ lang: navigator.language }); }`;
+ const data = await createWorkerAndGetValue<{ lang: string }>(code);
+ const match = mainLang === data.lang;
+ workerConsistency.dedicatedWorkerLanguage = {
+ passed: match,
+ detail: match
+ ? "Language matches: " + mainLang
+ : `MISMATCH: window="${mainLang}" worker="${data.lang}"`,
+ };
+ } catch (e: any) {
+ workerConsistency.dedicatedWorkerLanguage = {
+ passed: true,
+ detail: "Dedicated worker unavailable: " + (e?.message || String(e)),
+ };
+ }
+
+ // workerTimezone
+ try {
+ const code = `self.onmessage = () => {
+ var tz = "unknown";
+ try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch(e) {}
+ self.postMessage({ tz: tz });
+}`;
+ const data = await createWorkerAndGetValue<{ tz: string }>(code);
+ const match = mainTZ === data.tz;
+ workerConsistency.workerTimezone = {
+ passed: match,
+ detail: match
+ ? "Timezone matches: " + mainTZ
+ : `MISMATCH: window="${mainTZ}" worker="${data.tz}"`,
+ };
+ } catch (e: any) {
+ workerConsistency.workerTimezone = {
+ passed: true,
+ detail: "Dedicated worker unavailable: " + (e?.message || String(e)),
+ };
+ }
+
+ // sharedWorkerUA
+ try {
+ const code = `onconnect = (e) => { const port = e.ports[0]; port.postMessage({ ua: navigator.userAgent }); }`;
+ const data = await createSharedWorkerAndGetValue<{ ua: string }>(code);
+ const match = mainUA === data.ua;
+ workerConsistency.sharedWorkerUA = {
+ passed: match,
+ detail: match
+ ? "SharedWorker UA matches window"
+ : `MISMATCH: window="${mainUA.substring(0, 60)}..." shared="${(data.ua || "").substring(0, 60)}..."`,
+ };
+ } catch (e: any) {
+ workerConsistency.sharedWorkerUA = {
+ passed: true,
+ detail: "SharedWorker unavailable: " + (e?.message || String(e)),
+ };
+ }
+
+ // offscreenCanvasWebGL
+ try {
+ const code = `self.onmessage = () => {
+ try {
+ const canvas = new OffscreenCanvas(256, 256);
+ const gl = canvas.getContext('webgl');
+ if (!gl) { self.postMessage({ renderer: null, error: 'No WebGL' }); return; }
+ const ext = gl.getExtension('WEBGL_debug_renderer_info');
+ const renderer = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : null;
+ self.postMessage({ renderer });
+ } catch(e) { self.postMessage({ renderer: null, error: e.message }); }
+}`;
+ const data = await createWorkerAndGetValue<{
+ renderer: string | null;
+ error?: string;
+ }>(code);
+
+ if (data.error) {
+ workerConsistency.offscreenCanvasWebGL = {
+ passed: true,
+ detail:
+ "OffscreenCanvas WebGL not available in worker: " + data.error,
+ };
+ } else if (!mainWebGLRenderer) {
+ workerConsistency.offscreenCanvasWebGL = {
+ passed: true,
+ detail:
+ "Main thread WebGL renderer not available for comparison",
+ };
+ } else {
+ const match = mainWebGLRenderer === data.renderer;
+ workerConsistency.offscreenCanvasWebGL = {
+ passed: match,
+ detail: match
+ ? "WebGL renderer matches in worker OffscreenCanvas"
+ : `MISMATCH: window="${mainWebGLRenderer.substring(0, 40)}" worker="${(data.renderer || "").substring(0, 40)}"`,
+ };
+ }
+ } catch (e: any) {
+ workerConsistency.offscreenCanvasWebGL = {
+ passed: true,
+ detail: "OffscreenCanvas test unavailable: " + (e?.message || String(e)),
+ };
+ }
+
+ // serviceWorkerUA
+ try {
+ if (!navigator.serviceWorker) {
+ throw new Error("ServiceWorker API not available");
+ }
+
+ const data = await Promise.race([
+ new Promise<{ ua: string }>((resolve, reject) => {
+ navigator.serviceWorker.ready
+ .then((reg) => {
+ if (!reg.active) {
+ reject(new Error("No active ServiceWorker"));
+ return;
+ }
+ const channel = new MessageChannel();
+ channel.port1.onmessage = (e) => {
+ resolve(e.data);
+ };
+ reg.active.postMessage({ type: "getInfo" }, [channel.port2]);
+ })
+ .catch(reject);
+ }),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("ServiceWorker timeout")), 5000)
+ ),
+ ]);
+
+ const match = mainUA === data.ua;
+ workerConsistency.serviceWorkerUA = {
+ passed: match,
+ detail: match
+ ? "ServiceWorker UA matches window"
+ : `MISMATCH: window="${mainUA.substring(0, 60)}..." sw="${(data.ua || "").substring(0, 60)}..."`,
+ };
+ } catch (e: any) {
+ workerConsistency.serviceWorkerUA = {
+ passed: true,
+ detail:
+ "ServiceWorker not registered (requires HTTPS origin): " +
+ (e?.message || String(e)),
+ };
+ }
+
+ return { workerConsistency };
+}
diff --git a/build-tester/src/lib/types.ts b/build-tester/src/lib/types.ts
new file mode 100644
index 0000000..6c0c21d
--- /dev/null
+++ b/build-tester/src/lib/types.ts
@@ -0,0 +1,179 @@
+export interface CheckResult {
+ passed: boolean;
+ detail: string;
+}
+
+export interface FingerprintData {
+ navigator: {
+ userAgent: string;
+ platform: string;
+ oscpu: string;
+ hardwareConcurrency: number;
+ maxTouchPoints: number;
+ vendor: string;
+ buildID: string;
+ doNotTrack: string;
+ };
+ screen: {
+ width: number;
+ height: number;
+ colorDepth: number;
+ devicePixelRatio: number;
+ availWidth: number;
+ availHeight: number;
+ pixelDepth: number;
+ innerWidth: number;
+ innerHeight: number;
+ outerWidth: number;
+ outerHeight: number;
+ };
+ timezone: {
+ timezone: string;
+ offset: number;
+ localTime: string;
+ };
+ webgl: {
+ vendor: string;
+ renderer: string;
+ unmaskedVendor: string;
+ unmaskedRenderer: string;
+ maxTextureSize: number;
+ } | null;
+ canvas: { hash: string; dataUrlPrefix: string };
+ audio: {
+ hash: string;
+ sampleRate: number;
+ methods: {
+ getChannelData: string;
+ copyFromChannel: string;
+ analyserFloat: string;
+ analyserByte: string;
+ analyserTimeDomainFloat: string;
+ analyserTimeDomainByte: string;
+ };
+ };
+ fonts: { measureWidth: number; hash: string };
+ clientRects: { hash: string };
+ emojiCanvas: { hash: string };
+ fontAvailability: { detected: string[]; count: number; hash: string };
+ speechVoices: { names: string[]; count: number; hash: string };
+}
+
+export interface WebRTCResult {
+ passed: boolean;
+ iceIPs: string[];
+ sdpSanitized: boolean;
+ getStatsClean: boolean;
+ candidateCount: number;
+ detail: string;
+}
+
+export interface TestResults {
+ fingerprints: FingerprintData;
+ core: Record>;
+ extended: Record>;
+ workers: Record>;
+ webrtc: WebRTCResult;
+ stability: { fingerprints2: FingerprintData; stable: boolean; detail: string };
+ selfDestruct?: Record; // per-context only; absent for global profiles
+}
+
+// Multi-profile types
+
+export interface ProfileConfig {
+ name: string;
+ os: "macos" | "linux";
+ mode: "per-context" | "global";
+ platform: string;
+ oscpu: string;
+ userAgent: string;
+ hardwareConcurrency: number;
+ screenWidth: number;
+ screenHeight: number;
+ colorDepth: number;
+ timezone: string;
+ webglVendor: string;
+ webglRenderer: string;
+ audioSeed: number;
+ canvasSeed: number;
+ fontSpacingSeed: number;
+ fontList: string[];
+ speechVoices?: string[];
+}
+
+export interface ProfileResult {
+ profile: ProfileConfig;
+ results: TestResults;
+ matchResults: MatchCheckResult[];
+ grade: string;
+ passCount: number;
+ totalChecks: number;
+ error?: string;
+}
+
+export interface MatchCheckResult {
+ name: string;
+ passed: boolean;
+ expected: string;
+ actual: string;
+}
+
+export interface CrossProfileAnalysis {
+ macPerContext: {
+ uniqueAudio: number;
+ uniqueCanvas: number;
+ uniqueFonts: number;
+ uniqueTimezones: number;
+ uniqueScreens: number;
+ uniqueVoices: number;
+ uniqueWebGL: number;
+ uniquePlatforms: number;
+ total: number;
+ };
+ linuxPerContext: {
+ uniqueAudio: number;
+ uniqueCanvas: number;
+ uniqueFonts: number;
+ uniqueTimezones: number;
+ uniqueScreens: number;
+ uniqueVoices: number;
+ uniqueWebGL: number;
+ uniquePlatforms: number;
+ total: number;
+ };
+}
+
+export interface FullTestResult {
+ profiles: ProfileResult[];
+ crossProfile: CrossProfileAnalysis;
+ overallGrade: string;
+ totalPassed: number;
+ totalChecks: number;
+ timestamp: string;
+ binaryPath: string;
+}
+
+export interface CertificateData {
+ id: string;
+ timestamp: string;
+ platform: string;
+ camoufoxVersion: string;
+ passCount: number;
+ totalTests: number;
+ overallPass: boolean;
+ resultsHash: string;
+ signature: string;
+ sectionResults: { name: string; passed: number; total: number }[];
+ failedTests: string[];
+ profileCount: number;
+ crossProfile?: CrossProfileAnalysis;
+}
+
+// SSE event types
+export type SSEEvent =
+ | { type: "started"; runId: string }
+ | { type: "progress"; profileIndex: number; profileName: string; phase: string; total: number }
+ | { type: "profile-complete"; profileIndex: number; result: ProfileResult }
+ | { type: "complete"; result: FullTestResult }
+ | { type: "error"; message: string }
+ | { type: "log"; message: string };
diff --git a/example/async_example.py b/example/async_example.py
new file mode 100644
index 0000000..d680150
--- /dev/null
+++ b/example/async_example.py
@@ -0,0 +1,38 @@
+"""
+Async version of the example — useful for scraping multiple pages concurrently.
+
+Install deps:
+ pip install cloverlabs-camoufox
+ python -m camoufox fetch
+"""
+
+import asyncio
+from camoufox.async_api import AsyncCamoufox
+
+URLS = [
+ "https://httpbin.org/headers",
+ "https://httpbin.org/user-agent",
+ "https://httpbin.org/ip",
+]
+
+
+async def scrape(page, url: str) -> dict:
+ await page.goto(url)
+ body = await page.inner_text("body")
+ return {"url": url, "body": body[:300]}
+
+
+async def main():
+ async with AsyncCamoufox(headless=True) as browser:
+ context = await browser.new_context()
+
+ pages = [await context.new_page() for _ in URLS]
+ results = await asyncio.gather(*[scrape(p, u) for p, u in zip(pages, URLS)])
+
+ for r in results:
+ print(f"\n--- {r['url']} ---")
+ print(r["body"])
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/example/example.py b/example/example.py
new file mode 100644
index 0000000..f7a5a13
--- /dev/null
+++ b/example/example.py
@@ -0,0 +1,32 @@
+"""
+Quick example to test cloverlabs-camoufox.
+
+Install deps:
+ pip install cloverlabs-camoufox
+ python -m camoufox fetch
+"""
+
+from camoufox.sync_api import Camoufox
+
+with Camoufox(headless=False) as browser:
+ page = browser.new_page()
+
+ # Visit a fingerprint test page
+ page.goto("https://abrahamjuliot.github.io/creepjs/")
+ page.wait_for_load_state("networkidle", timeout=30_000)
+
+ title = page.title()
+ print(f"Page title: {title}")
+
+ # Grab the trust score CreepJS assigns
+ score_el = page.query_selector("#creep-results .grade")
+ if score_el:
+ print(f"CreepJS trust grade: {score_el.inner_text()}")
+ else:
+ print("Score element not found — page may still be loading.")
+
+ # Print the spoofed user-agent the browser reported
+ ua = page.evaluate("navigator.userAgent")
+ print(f"User-Agent: {ua}")
+
+ input("\nPress Enter to close the browser...")
diff --git a/example/example_simple.py b/example/example_simple.py
new file mode 100644
index 0000000..52bbdaa
--- /dev/null
+++ b/example/example_simple.py
@@ -0,0 +1,8 @@
+from camoufox.sync_api import Camoufox
+
+ACCEPT_ENCODING = "identity"
+
+with Camoufox(headless=False) as browser:
+ page = browser.new_page(extra_http_headers={"accept-encoding": ACCEPT_ENCODING})
+ page.goto("https://abrahamjuliot.github.io/creepjs/")
+ input("Press Enter to close...")
diff --git a/patches/audio-context-spoofing.patch b/patches/audio-context-spoofing.patch
index f6a06f9..280b9d5 100644
--- a/patches/audio-context-spoofing.patch
+++ b/patches/audio-context-spoofing.patch
@@ -72,4 +72,3 @@ index 3ee8c0aa76..c0f1df8cf6 100644
+
+# DOM Mask
+LOCAL_INCLUDES += ["/camoucfg"]
-\ No newline at end of file
diff --git a/patches/audio-fingerprint-manager.patch b/patches/audio-fingerprint-manager.patch
index 1cb11fa..b522aac 100644
--- a/patches/audio-fingerprint-manager.patch
+++ b/patches/audio-fingerprint-manager.patch
@@ -410,9 +410,12 @@ diff --git a/dom/media/webaudio/moz.build b/dom/media/webaudio/moz.build
index 3ee8c0aa76..4cf10f3039 100644
--- a/dom/media/webaudio/moz.build
+++ b/dom/media/webaudio/moz.build
-@@ -155,2 +155,5 @@ include("/ipc/chromium/chromium-config.mozbuild")
+@@ -152,5 +152,8 @@ include("/ipc/chromium/chromium-config.mozbuild")
FINAL_LIBRARY = "xul"
LOCAL_INCLUDES += [".."]
+
+ # DOM Mask
+ LOCAL_INCLUDES += ["/camoucfg"]
+
+# AudioFingerprintManager in dom/base
+LOCAL_INCLUDES += ["/dom/base"]
diff --git a/patches/voice-spoofing.patch b/patches/voice-spoofing.patch
index 29c0dc9..59bc7d5 100644
--- a/patches/voice-spoofing.patch
+++ b/patches/voice-spoofing.patch
@@ -1,9 +1,7 @@
diff --git a/dom/media/webspeech/synth/moz.build b/dom/media/webspeech/synth/moz.build
-index 2cf19982b2..dcdcdb5cbf 100644
--- a/dom/media/webspeech/synth/moz.build
+++ b/dom/media/webspeech/synth/moz.build
-@@ -61,3 +61,6 @@ FINAL_LIBRARY = "xul"
- LOCAL_INCLUDES += [
+@@ -63,4 +63,7 @@
"ipc",
]
+
diff --git a/pythonlib/camoufox/__version__.py b/pythonlib/camoufox/__version__.py
index f2c9741..53b980d 100644
--- a/pythonlib/camoufox/__version__.py
+++ b/pythonlib/camoufox/__version__.py
@@ -8,7 +8,7 @@ class CONSTRAINTS:
The minimum and maximum supported versions of the Camoufox browser.
"""
- MIN_VERSION = 'beta.19'
+ MIN_VERSION = 'alpha.1'
MAX_VERSION = '1'
@staticmethod
diff --git a/pythonlib/camoufox/async_api.py b/pythonlib/camoufox/async_api.py
index ddd844a..5cec0f2 100644
--- a/pythonlib/camoufox/async_api.py
+++ b/pythonlib/camoufox/async_api.py
@@ -1,6 +1,9 @@
import asyncio
+import json as _json
+import urllib.request
from functools import partial
from typing import Any, Dict, List, Optional, Union, overload
+from urllib.parse import urlparse
from playwright.async_api import (
Browser,
@@ -101,12 +104,40 @@ async def AsyncNewBrowser(
return await async_attach_vd(browser, virtual_display)
+def _proxy_url_with_creds(proxy: Dict[str, str]) -> str:
+ """Builds a proxy URL string with embedded credentials."""
+ parsed = urlparse(proxy.get("server", ""))
+ user = proxy.get("username", "")
+ pwd = proxy.get("password", "")
+ if user and pwd:
+ return f"{parsed.scheme}://{user}:{pwd}@{parsed.netloc}"
+ return proxy.get("server", "")
+
+
+async def _resolve_proxy_geo(proxy: Dict[str, str]) -> Dict[str, Optional[str]]:
+ """Queries ip-api.com through the proxy for the exit IP and timezone."""
+ proxy_url = _proxy_url_with_creds(proxy)
+
+ def _fetch() -> Dict[str, Optional[str]]:
+ handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
+ opener = urllib.request.build_opener(handler)
+ try:
+ with opener.open("http://ip-api.com/json?fields=query,timezone", timeout=10) as resp:
+ data = _json.loads(resp.read())
+ return {"ip": data.get("query") or None, "timezone": data.get("timezone") or None}
+ except Exception:
+ return {"ip": None, "timezone": None}
+
+ return await asyncio.get_event_loop().run_in_executor(None, _fetch)
+
+
async def AsyncNewContext(
browser: Browser,
*,
preset: Optional[Dict[str, Any]] = None,
os: Optional[str] = None,
ff_version: Optional[str] = None,
+ webrtc_ip: Optional[str] = None,
proxy: Optional[Dict[str, str]] = None,
geolocation: Optional[Dict[str, float]] = None,
**context_kwargs: Any,
@@ -123,13 +154,22 @@ async def AsyncNewContext(
preset: A specific fingerprint preset dict to use. If None, picks randomly.
os: Target OS for preset selection ("windows", "macos", "linux").
ff_version: Firefox version string for UA patching.
+ webrtc_ip: IPv4 address to spoof for WebRTC ICE candidates.
proxy: Per-context proxy (Playwright format: {"server": "...", "username": "...", "password": "..."}).
geolocation: Per-context geolocation ({"latitude": float, "longitude": float}).
**context_kwargs: Additional Playwright new_context() options.
"""
+ # Auto-derive WebRTC IP and timezone from proxy's exit IP when not explicitly provided
+ if proxy and (not webrtc_ip or "timezone_id" not in context_kwargs):
+ geo = await _resolve_proxy_geo(proxy)
+ if not webrtc_ip:
+ webrtc_ip = geo["ip"]
+ if "timezone_id" not in context_kwargs and geo["timezone"]:
+ context_kwargs["timezone_id"] = geo["timezone"]
+
fp = await asyncio.get_event_loop().run_in_executor(
None,
- lambda: generate_context_fingerprint(preset=preset, os=os, ff_version=ff_version),
+ lambda: generate_context_fingerprint(preset=preset, os=os, ff_version=ff_version, webrtc_ip=webrtc_ip),
)
# Merge generated context options with user overrides (user wins)
diff --git a/pythonlib/camoufox/fingerprints.py b/pythonlib/camoufox/fingerprints.py
index 443507f..a6a95b8 100644
--- a/pythonlib/camoufox/fingerprints.py
+++ b/pythonlib/camoufox/fingerprints.py
@@ -419,6 +419,7 @@ def generate_context_fingerprint(
preset: Optional[Dict] = None,
os: Optional[str] = None,
ff_version: Optional[str] = None,
+ webrtc_ip: Optional[str] = None,
) -> Dict[str, Any]:
"""
Generate fingerprint values for a single per-context identity.
@@ -528,6 +529,7 @@ def generate_context_fingerprint(
'timezone': preset.get('timezone') if isinstance(preset.get('timezone'), str) else config.get('timezone'),
'fontList': config.get('fonts'),
'speechVoices': config.get('voices'),
+ 'webrtcIP': webrtc_ip or '',
}
init_script = _build_init_script(init_values)
diff --git a/pythonlib/camoufox/sync_api.py b/pythonlib/camoufox/sync_api.py
index 5cfb696..bbeade7 100644
--- a/pythonlib/camoufox/sync_api.py
+++ b/pythonlib/camoufox/sync_api.py
@@ -1,4 +1,7 @@
+import json as _json
+import urllib.request
from typing import Any, Dict, List, Optional, Union, overload
+from urllib.parse import urlparse
from playwright.sync_api import (
Browser,
@@ -101,12 +104,36 @@ def NewBrowser(
return sync_attach_vd(browser, virtual_display)
+def _proxy_url_with_creds(proxy: Dict[str, str]) -> str:
+ """Builds a proxy URL string with embedded credentials."""
+ parsed = urlparse(proxy.get("server", ""))
+ user = proxy.get("username", "")
+ pwd = proxy.get("password", "")
+ if user and pwd:
+ return f"{parsed.scheme}://{user}:{pwd}@{parsed.netloc}"
+ return proxy.get("server", "")
+
+
+def _resolve_proxy_geo(proxy: Dict[str, str]) -> Dict[str, Optional[str]]:
+ """Queries ip-api.com through the proxy for the exit IP and timezone."""
+ proxy_url = _proxy_url_with_creds(proxy)
+ handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
+ opener = urllib.request.build_opener(handler)
+ try:
+ with opener.open("http://ip-api.com/json?fields=query,timezone", timeout=10) as resp:
+ data = _json.loads(resp.read())
+ return {"ip": data.get("query") or None, "timezone": data.get("timezone") or None}
+ except Exception:
+ return {"ip": None, "timezone": None}
+
+
def NewContext(
browser: Browser,
*,
preset: Optional[Dict[str, Any]] = None,
os: Optional[str] = None,
ff_version: Optional[str] = None,
+ webrtc_ip: Optional[str] = None,
proxy: Optional[Dict[str, str]] = None,
geolocation: Optional[Dict[str, float]] = None,
**context_kwargs: Any,
@@ -123,11 +150,20 @@ def NewContext(
preset: A specific fingerprint preset dict to use. If None, picks randomly.
os: Target OS for preset selection ("windows", "macos", "linux").
ff_version: Firefox version string for UA patching.
+ webrtc_ip: IPv4 address to spoof for WebRTC ICE candidates.
proxy: Per-context proxy (Playwright format: {"server": "...", "username": "...", "password": "..."}).
geolocation: Per-context geolocation ({"latitude": float, "longitude": float}).
**context_kwargs: Additional Playwright new_context() options.
"""
- fp = generate_context_fingerprint(preset=preset, os=os, ff_version=ff_version)
+ # Auto-derive WebRTC IP and timezone from proxy's exit IP when not explicitly provided
+ if proxy and (not webrtc_ip or "timezone_id" not in context_kwargs):
+ geo = _resolve_proxy_geo(proxy)
+ if not webrtc_ip:
+ webrtc_ip = geo["ip"]
+ if "timezone_id" not in context_kwargs and geo["timezone"]:
+ context_kwargs["timezone_id"] = geo["timezone"]
+
+ fp = generate_context_fingerprint(preset=preset, os=os, ff_version=ff_version, webrtc_ip=webrtc_ip)
# Merge generated context options with user overrides (user wins)
opts: Dict[str, Any] = {**fp['context_options'], **context_kwargs}
diff --git a/pythonlib/camoufox/utils.py b/pythonlib/camoufox/utils.py
index 2cb667d..0277516 100644
--- a/pythonlib/camoufox/utils.py
+++ b/pythonlib/camoufox/utils.py
@@ -19,7 +19,6 @@ from .exceptions import (
InvalidOS,
InvalidPropertyType,
NonFirefoxFingerprint,
- UnknownProperty,
)
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset
from .geolocation import geoip_allowed, get_geolocation
@@ -113,7 +112,8 @@ def validate_config(config_map: Dict[str, str], path: Optional[Path] = None) ->
for key, value in config_map.items():
expected_type = property_types.get(key)
if not expected_type:
- raise UnknownProperty(f"Unknown property {key} in config")
+ print(f'Skipping unknown patch {key} : {value}')
+ continue # Property not supported by this browser version; skip silently
if not validate_type(value, expected_type):
raise InvalidPropertyType(
diff --git a/scripts/patch.py b/scripts/patch.py
index 94ed1c6..d9220d9 100644
--- a/scripts/patch.py
+++ b/scripts/patch.py
@@ -109,12 +109,14 @@ class Patcher:
Apply a patch and check for reject files.
Returns list of reject files if any, empty list otherwise.
"""
- import subprocess
- import os
+ import time
print(f"\n*** -> patch -p1 -i {patch_file}")
sys.stdout.flush()
+ # Record time before applying so we only detect .rej files from this patch
+ start_time = time.time()
+
# Apply patch interactively - don't capture stdout/stderr at all
# This allows prompts to show immediately and user can respond
# --forward flag: skip patches that appear to be already applied
@@ -128,19 +130,21 @@ class Patcher:
text=True
)
- # After patch completes, search for any .rej files created
+ # After patch completes, search for any .rej files created during this patch
rejects = []
for root, dirs, files in os.walk('.'):
for file in files:
if file.endswith('.rej'):
- # Check if this is a newly created reject file
reject_path = os.path.join(root, file)
- # Only include if it was just created (within last minute)
if os.path.exists(reject_path):
- import time
- if time.time() - os.path.getmtime(reject_path) < 60:
+ # Only include if created after this patch started
+ if os.path.getmtime(reject_path) >= start_time:
rejects.append(reject_path)
+ # Clean up .rej files so they don't interfere with subsequent patches
+ for rej in rejects:
+ os.remove(rej)
+
return rejects
def _update_mozconfig(self):
diff --git a/service-tester/README.md b/service-tester/README.md
new file mode 100644
index 0000000..49300d5
--- /dev/null
+++ b/service-tester/README.md
@@ -0,0 +1,128 @@
+# Camoufox Service Tests
+
+End-to-end antibot-detection tests that verify a pip-installed camoufox release works correctly — both the Firefox binary and the Python package — using real proxies for each browser context.
+
+## Prerequisites
+
+- Python 3.9+
+- Node.js (for building the TypeScript checks bundle via `esbuild`)
+- At least one proxy in `proxies.txt`
+
+## Quick Start
+
+```bash
+# 1. Add your proxies (see format below)
+# 2. Run the test script — it handles everything else automatically
+./run_tests.sh
+```
+
+`run_tests.sh` will:
+1. Install npm deps in `../build-tester/` (for `esbuild`, first run only)
+2. Create a `.venv` virtualenv (first run only)
+3. Install `camoufox` from the local `../pythonlib` source
+4. Download the camoufox browser binary
+5. Run the full test suite
+
+## Proxies
+
+Tests require real proxies. Each context gets its own proxy, and the WebRTC IP is automatically derived from the proxy server address.
+
+Create `proxies.txt` in this directory with one proxy per line:
+
+```
+user:pass@domain:port
+```
+
+Example:
+```
+alice:secret123@proxy1.example.com:10000
+bob:hunter2@proxy2.example.com:10000
+alice:secret123@proxy1.example.com:10001
+```
+
+- Blank lines and lines starting with `#` are ignored
+- Proxies are assigned round-robin across the 6 test profiles
+- Fewer proxies than profiles is fine — they cycle
+
+## Manual Setup
+
+If you prefer to run steps individually:
+
+```bash
+# Install build-tester deps (once)
+cd ../build-tester && npm install && cd ../service_tests
+
+# Create and activate virtualenv
+python3 -m venv .venv
+source .venv/bin/activate
+
+# Install camoufox from local source
+pip install -e ../pythonlib
+
+# Download the browser binary
+python -m camoufox fetch
+
+# Run tests
+python run_tests.py
+```
+
+## Options
+
+```
+./run_tests.sh [options]
+python run_tests.py [options]
+
+ --browser-version VER Camoufox version specifier (default: official/stable)
+ e.g. official/prerelease/146.0.1-beta.50
+ --profile-count N Number of profiles to test (1-6, default: 6)
+ --proxies PATH Path to proxies file (default: proxies.txt)
+ --headful Run with visible browser window
+ --no-cert Skip certificate generation
+ --save-cert PATH Save certificate text to a file
+ --secret KEY HMAC signing key for the certificate
+```
+
+## What It Tests
+
+6 browser contexts run simultaneously — 3 macOS profiles and 3 Linux profiles — each with:
+
+- A unique fingerprint generated by camoufox via BrowserForge (navigator, screen, WebGL, fonts, voices, audio/canvas seeds)
+- A distinct timezone
+- Its own proxy, with WebRTC ICE candidates spoofed to the proxy's IP
+
+Each context is scored across these categories:
+
+| Category | What it checks |
+|---|---|
+| Automation Detection | Playwright/CDP artefacts |
+| JS Engine | V8 vs SpiderMonkey signals |
+| Lie Detection | Inconsistent property overrides |
+| Firefox APIs | Firefox-specific API presence |
+| Cross-Signal | Consistency across navigator, screen, etc. |
+| CSS Fingerprint | CSS rendering fingerprint |
+| Canvas Noise | Canvas hash uniqueness and stability |
+| WebGL Render | WebGL rendering hash |
+| Audio Integrity | AudioContext fingerprint |
+| Font Platform | OS-consistent font availability |
+| Speech Voices | Voice list matches declared OS |
+| WebRTC | IP matches proxy server address |
+| Stability | Fingerprint stable over time with other contexts open |
+| Headless Detection | No headless mode signals |
+
+## Interpreting Results
+
+| Grade | Meaning |
+|---|---|
+| **A** | All checks pass |
+| **B** | 1–2 failures (minor) |
+| **C** | 3–5 failures |
+| **D** | 6–10 failures |
+| **F** | 11+ failures |
+
+A grade of **A or B** exits with code `0`. Anything worse exits with code `1`.
+
+The cross-profile uniqueness section confirms each context has distinct audio, canvas, timezone, and screen fingerprints — verifying camoufox generates genuinely different identities per context.
+
+## Failure Triage
+
+If a check fails, **fix it in the Python package** (`../pythonlib/camoufox/`), not in the test. The test is intentionally a black-box validator — it only uses the public `AsyncNewContext` API and trusts camoufox to produce correct fingerprints.
diff --git a/service-tester/_bundle.py b/service-tester/_bundle.py
new file mode 100644
index 0000000..ce53876
--- /dev/null
+++ b/service-tester/_bundle.py
@@ -0,0 +1,75 @@
+import http.server
+import socketserver
+import subprocess
+import sys
+import threading
+from pathlib import Path
+
+from _constants import BUILD_TESTER_DIR
+
+
+def ensure_bundle() -> Path:
+ bundle_path = BUILD_TESTER_DIR / "scripts" / "checks-bundle.js"
+ if bundle_path.exists():
+ return bundle_path
+
+ node_modules = BUILD_TESTER_DIR / "node_modules"
+ if not node_modules.exists():
+ print("ERROR: build-tester/node_modules not found. Run 'npm install' in build-tester/ first.", file=sys.stderr)
+ sys.exit(1)
+
+ esbuild = BUILD_TESTER_DIR / "node_modules" / ".bin" / "esbuild"
+ print("Building checks bundle (first run)...")
+ entry = BUILD_TESTER_DIR / "src" / "lib" / "checks" / "index.ts"
+ result = subprocess.run(
+ [
+ str(esbuild),
+ str(entry),
+ "--bundle",
+ "--platform=browser",
+ "--target=es2017",
+ "--format=iife",
+ "--global-name=CamoufoxChecks",
+ f"--outfile={bundle_path}",
+ ],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ print(f"ERROR: esbuild failed:\n{result.stderr}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Bundle built: {bundle_path}")
+ return bundle_path
+
+
+def start_http_server() -> int:
+ scripts_dir = BUILD_TESTER_DIR / "scripts"
+ template_path = scripts_dir / "test_page_template.html"
+ bundle_path = scripts_dir / "checks-bundle.js"
+
+ class Handler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, format, *args):
+ pass
+
+ def do_GET(self):
+ if self.path in ("/test", "/test/"):
+ self._serve(template_path, "text/html; charset=utf-8")
+ elif self.path == "/checks-bundle.js":
+ self._serve(bundle_path, "application/javascript")
+ else:
+ self.send_response(404)
+ self.end_headers()
+
+ def _serve(self, path: Path, content_type: str):
+ content = path.read_bytes()
+ self.send_response(200)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(content)))
+ self.end_headers()
+ self.wfile.write(content)
+
+ server = socketserver.TCPServer(("127.0.0.1", 0), Handler)
+ port = server.server_address[1]
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ return port
diff --git a/service-tester/_certificate.py b/service-tester/_certificate.py
new file mode 100644
index 0000000..661ef0d
--- /dev/null
+++ b/service-tester/_certificate.py
@@ -0,0 +1,278 @@
+import hashlib
+import hmac as hmac_module
+import json
+import re
+import uuid
+
+from _constants import (
+ BOLD, BOX_W, CAT_ART, CATEGORY_LABELS, CYAN, GREEN, RED, RESET,
+ box_bot, box_line, box_sep, box_top, grade_color,
+)
+from _grading import compute_grade, count_checks
+
+
+def format_section_line(name: str, passed: int, total: int) -> str:
+ ok = passed == total
+ score = f"{passed}/{total}"
+ status_visible = "[PASS]" if ok else f"[{total - passed} FAIL]"
+ status_ansi = f"{GREEN}{status_visible}{RESET}" if ok else f"{RED}{status_visible}{RESET}"
+ prefix_vis = f" {name} "
+ suffix_vis = f" {score} {status_visible} "
+ dots_len = max(1, BOX_W - len(prefix_vis) - len(suffix_vis))
+ inner = f" {name} {'.' * dots_len} {score} {status_ansi} "
+ return box_line(inner)
+
+
+def print_profile_result(pr: dict) -> None:
+ profile = pr["profile"]
+ grade = pr.get("grade", "F")
+ pass_count = pr.get("passCount", 0)
+ total_checks = pr.get("totalChecks", 0)
+ error = pr.get("error")
+
+ gc = grade_color(grade)
+
+ if error:
+ print(f" {RED}✗{RESET} {profile['name']}: {RED}ERROR{RESET} — {error}")
+ return
+
+ tick = "✓" if grade in ("A", "B") else "✗"
+ print(f" {tick} {profile['name']}: {gc}{BOLD}[{grade}]{RESET} {pass_count}/{total_checks}")
+
+ results = pr.get("results") or {}
+ stability = results.get("stability", {})
+ if stability and not stability.get("stable"):
+ print(f" {RED}↳ Stability: {stability.get('detail', '?')}{RESET}")
+
+ webrtc = results.get("webrtc", {})
+ if webrtc and not webrtc.get("passed"):
+ print(f" {RED}↳ WebRTC: {webrtc.get('detail', '?')}{RESET}")
+
+
+def compute_section_results(results: dict) -> list:
+ sections = []
+ all_categories = {**results.get("core", {}), **results.get("extended", {}), **results.get("workers", {})}
+ for key, checks in all_categories.items():
+ if key == "webglExtended":
+ continue
+ if not isinstance(checks, dict):
+ continue
+ passed = total = 0
+ for check in checks.values():
+ if check and isinstance(check.get("passed"), bool):
+ total += 1
+ if check["passed"]:
+ passed += 1
+ if total > 0:
+ sections.append({"name": CATEGORY_LABELS.get(key, key), "passed": passed, "total": total})
+ webrtc = results.get("webrtc", {})
+ stability = results.get("stability", {})
+ sections.append({"name": "WebRTC", "passed": 1 if webrtc.get("passed") else 0, "total": 1})
+ sections.append({"name": "Stability", "passed": 1 if stability.get("stable") else 0, "total": 1})
+ return sections
+
+
+def compute_cross_profile(profile_results: list) -> dict:
+ mac_ctx = [p for p in profile_results if p["profile"]["os"] == "macos"]
+ linux_ctx = [p for p in profile_results if p["profile"]["os"] == "linux"]
+
+ def analyze(group: list) -> dict:
+ if not group:
+ return {"uniqueAudio": 0, "uniqueCanvas": 0, "uniqueFonts": 0, "uniqueTimezones": 0,
+ "uniqueScreens": 0, "uniqueVoices": 0, "uniqueWebGL": 0, "uniquePlatforms": 0, "total": 0}
+ audio, canvas, fonts, timezones, screens, voices, webgl_set, platforms = (
+ set(), set(), set(), set(), set(), set(), set(), set()
+ )
+ for p in group:
+ fp = (p.get("results") or {}).get("fingerprints") or {}
+ if fp.get("audio", {}).get("hash"):
+ audio.add(fp["audio"]["hash"])
+ if fp.get("canvas", {}).get("hash"):
+ canvas.add(fp["canvas"]["hash"])
+ if fp.get("fonts", {}).get("hash"):
+ fonts.add(fp["fonts"]["hash"])
+ if fp.get("timezone", {}).get("timezone"):
+ timezones.add(fp["timezone"]["timezone"])
+ s = fp.get("screen", {})
+ if s:
+ screens.add(f"{s.get('width')}x{s.get('height')}")
+ if fp.get("speechVoices", {}).get("hash"):
+ voices.add(fp["speechVoices"]["hash"])
+ w = fp.get("webgl", {})
+ if w:
+ webgl_set.add(f"{w.get('unmaskedVendor')}|{w.get('unmaskedRenderer')}")
+ if fp.get("navigator", {}).get("platform"):
+ platforms.add(fp["navigator"]["platform"])
+ return {
+ "uniqueAudio": len(audio), "uniqueCanvas": len(canvas), "uniqueFonts": len(fonts),
+ "uniqueTimezones": len(timezones), "uniqueScreens": len(screens),
+ "uniqueVoices": len(voices), "uniqueWebGL": len(webgl_set),
+ "uniquePlatforms": len(platforms), "total": len(group),
+ }
+
+ return {"macPerContext": analyze(mac_ctx), "linuxPerContext": analyze(linux_ctx)}
+
+
+def generate_certificate(full_result: dict, secret: str) -> dict:
+ all_section_results: list = []
+ all_failed_tests: list = []
+
+ for pr in full_result["profiles"]:
+ if not pr.get("results"):
+ all_failed_tests.append(f"{pr['profile']['name']}: Error — {pr.get('error', 'unknown')}")
+ continue
+
+ sections = compute_section_results(pr["results"])
+ for s in sections:
+ existing = next((e for e in all_section_results if e["name"] == s["name"]), None)
+ if existing:
+ existing["passed"] += s["passed"]
+ existing["total"] += s["total"]
+ else:
+ all_section_results.append(dict(s))
+
+ results = pr["results"]
+ all_cats = {**results.get("core", {}), **results.get("extended", {}), **results.get("workers", {})}
+ for cat_key, checks in all_cats.items():
+ if not isinstance(checks, dict):
+ continue
+ for check_name, check in checks.items():
+ if check and isinstance(check.get("passed"), bool) and not check["passed"]:
+ label = CATEGORY_LABELS.get(cat_key, cat_key)
+ all_failed_tests.append(
+ f"{pr['profile']['name']}: {label}: {check_name} — {check.get('detail', '')}"
+ )
+
+ webrtc = results.get("webrtc", {})
+ stability = results.get("stability", {})
+ if not webrtc.get("passed"):
+ all_failed_tests.append(f"{pr['profile']['name']}: WebRTC: {webrtc.get('detail', '')}")
+ if not stability.get("stable"):
+ all_failed_tests.append(f"{pr['profile']['name']}: Stability: {stability.get('detail', '')}")
+
+ cp = full_result["crossProfile"]
+ mac = cp.get("macPerContext", {})
+ linux = cp.get("linuxPerContext", {})
+
+ if mac.get("total", 0) > 0:
+ mac_unique = (
+ (1 if mac.get("uniqueAudio") == mac["total"] else 0)
+ + (1 if mac.get("uniqueCanvas") == mac["total"] else 0)
+ + (1 if mac.get("uniqueTimezones") == mac["total"] else 0)
+ + (1 if mac.get("uniqueScreens") == mac["total"] else 0)
+ )
+ all_section_results.append({"name": "Mac Uniqueness", "passed": mac_unique, "total": 4})
+
+ if linux.get("total", 0) > 0:
+ linux_unique = (
+ (1 if linux.get("uniqueAudio") == linux["total"] else 0)
+ + (1 if linux.get("uniqueCanvas") == linux["total"] else 0)
+ + (1 if linux.get("uniqueTimezones") == linux["total"] else 0)
+ + (1 if linux.get("uniqueScreens") == linux["total"] else 0)
+ )
+ all_section_results.append({"name": "Linux Uniqueness", "passed": linux_unique, "total": 4})
+
+ hash_data = {
+ "profiles": [
+ {"name": p["profile"]["name"], "grade": p["grade"],
+ "passCount": p["passCount"], "totalChecks": p["totalChecks"]}
+ for p in full_result["profiles"]
+ ],
+ "crossProfile": full_result["crossProfile"],
+ "timestamp": full_result["timestamp"],
+ }
+ results_hash = hashlib.sha256(json.dumps(hash_data, separators=(",", ":")).encode()).hexdigest()
+ signature = hmac_module.new(secret.encode(), results_hash.encode(), hashlib.sha256).hexdigest()
+
+ ua = ""
+ for pr in full_result["profiles"]:
+ if pr.get("results"):
+ ua = pr["results"].get("fingerprints", {}).get("navigator", {}).get("userAgent", "")
+ break
+ fx_match = re.search(r"Firefox/(\d+\.\d+)", ua)
+ camoufox_version_str = f"Firefox {fx_match.group(1)}" if fx_match else ua[:60]
+
+ proxy_info = []
+ for pr in full_result["profiles"]:
+ geo = pr["profile"].get("proxy_geo", {})
+ proxy_info.append({
+ "name": pr["profile"]["name"],
+ "ip": geo.get("query", "?"),
+ "city": geo.get("city", "?"),
+ "country": geo.get("country", "?"),
+ "timezone": geo.get("timezone", "?"),
+ })
+
+ return {
+ "id": str(uuid.uuid4()),
+ "signature": signature,
+ "resultsHash": results_hash,
+ "timestamp": full_result["timestamp"],
+ "platform": "Multi-OS (Service)",
+ "camoufoxVersion": camoufox_version_str,
+ "passCount": full_result["totalPassed"],
+ "totalTests": full_result["totalChecks"],
+ "overallPass": full_result["totalPassed"] == full_result["totalChecks"],
+ "sectionResults": all_section_results,
+ "failedTests": all_failed_tests[:20],
+ "profileCount": len(full_result["profiles"]),
+ "proxyInfo": proxy_info,
+ }
+
+
+def print_certificate(cert: dict, cross_profile: dict, overall_grade: str) -> None:
+ gc = grade_color(overall_grade)
+ w = BOX_W
+
+ print()
+ print(CYAN + CAT_ART + RESET)
+ print()
+ print(BOLD + box_top() + RESET)
+
+ title = "CAMOUFOX SERVICE VERIFICATION CERTIFICATE"
+ print(BOLD + box_line(f"{title:^{w}}") + RESET)
+ print(BOLD + box_sep() + RESET)
+
+ grade_inner = f" {gc}{BOLD}Grade: {overall_grade}{RESET} Score: {cert['passCount']}/{cert['totalTests']} Profiles: {cert['profileCount']}"
+ print(box_line(grade_inner))
+ print(box_line(f" Issued: {cert['timestamp']}"))
+
+ if cert["overallPass"]:
+ print(box_line(f" Status: {GREEN}ALL PASS{RESET}"))
+ else:
+ print(box_line(f" Status: {RED}FAILURES DETECTED{RESET}"))
+
+ print(BOLD + box_sep() + RESET)
+ print(box_line(f" {BOLD}SECTION RESULTS{RESET}"))
+ for s in cert.get("sectionResults", []):
+ print(format_section_line(s["name"], s["passed"], s["total"]))
+
+ print(BOLD + box_sep() + RESET)
+ print(box_line(f" {BOLD}CROSS-PROFILE UNIQUENESS{RESET}"))
+ mac = cross_profile.get("macPerContext", {})
+ linux = cross_profile.get("linuxPerContext", {})
+ if mac.get("total", 0) > 0:
+ t = mac["total"]
+ print(box_line(f" macOS Audio:{mac.get('uniqueAudio',0)}/{t} Canvas:{mac.get('uniqueCanvas',0)}/{t} TZ:{mac.get('uniqueTimezones',0)}/{t} Screen:{mac.get('uniqueScreens',0)}/{t}"))
+ if linux.get("total", 0) > 0:
+ t = linux["total"]
+ print(box_line(f" Linux Audio:{linux.get('uniqueAudio',0)}/{t} Canvas:{linux.get('uniqueCanvas',0)}/{t} TZ:{linux.get('uniqueTimezones',0)}/{t} Screen:{linux.get('uniqueScreens',0)}/{t}"))
+
+ proxy_info = cert.get("proxyInfo", [])
+ if proxy_info:
+ print(BOLD + box_sep() + RESET)
+ print(box_line(f" {BOLD}PROXY DEBUG{RESET}"))
+ for pi in proxy_info:
+ short_name = pi["name"].replace(" Per-Context", "")
+ ip = pi["ip"]
+ tz = pi["timezone"]
+ city_country = f"{pi['city']}, {pi['country']}"
+ print(box_line(f" {CYAN}{short_name:<9}{RESET} {ip:<15} {tz}"))
+ print(box_line(f" {'':<11}{city_country}"))
+
+ print(BOLD + box_sep() + RESET)
+ print(box_line(f" ID: {cert['id']}"))
+ print(box_line(f" Hash: {cert['resultsHash'][:48]}..."))
+ print(box_line(f" Sig: {cert['signature'][:48]}..."))
+ print(BOLD + box_bot() + RESET)
+ print()
diff --git a/service-tester/_constants.py b/service-tester/_constants.py
new file mode 100644
index 0000000..7226843
--- /dev/null
+++ b/service-tester/_constants.py
@@ -0,0 +1,89 @@
+import re
+from pathlib import Path
+
+# ─── Paths ────────────────────────────────────────────────────────────────────
+
+PROXIES_FILE = Path(__file__).parent / "proxies.txt"
+BUILD_TESTER_DIR = Path(__file__).parent.parent / "build-tester"
+
+# ─── Check category labels ────────────────────────────────────────────────────
+
+CATEGORY_LABELS = {
+ "automation": "Automation Detection",
+ "jsEngine": "JS Engine",
+ "lieDetection": "Lie Detection",
+ "firefoxAPIs": "Firefox APIs",
+ "crossSignal": "Cross-Signal",
+ "cssFingerprint": "CSS Fingerprint",
+ "mathEngine": "Math Engine",
+ "permissionsAPI": "Permissions",
+ "speechVoices": "Speech Voices",
+ "performanceAPI": "Performance",
+ "intlConsistency": "Intl Consistency",
+ "emojiFingerprint": "Emoji",
+ "canvasNoiseDetection": "Canvas Noise",
+ "webglRenderHash": "WebGL Render",
+ "fontPlatformConsistency": "Font Platform",
+ "audioIntegrity": "Audio Integrity",
+ "iframeTesting": "Iframe Testing",
+ "workerConsistency": "Workers",
+ "headlessDetection": "Headless Detection",
+ "trashDetection": "Trash Detection",
+ "fontEnvironment": "Font Environment",
+}
+
+# ─── ANSI colors ──────────────────────────────────────────────────────────────
+
+GREEN = "\033[92m"
+RED = "\033[91m"
+YELLOW = "\033[93m"
+CYAN = "\033[96m"
+BOLD = "\033[1m"
+RESET = "\033[0m"
+
+ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
+
+
+def strip_ansi(s: str) -> str:
+ return ANSI_ESCAPE_RE.sub("", s)
+
+
+def grade_color(g: str) -> str:
+ if g == "A":
+ return GREEN
+ if g in ("B", "C"):
+ return YELLOW
+ return RED
+
+
+# ─── Certificate box drawing ──────────────────────────────────────────────────
+
+BOX_W = 60
+
+
+def box_line(inner: str) -> str:
+ visible = len(strip_ansi(inner))
+ return f"║{inner}{' ' * max(0, BOX_W - visible)}║"
+
+
+def box_sep() -> str:
+ return f"╠{'═' * BOX_W}╣"
+
+
+def box_top() -> str:
+ return f"╔{'═' * BOX_W}╗"
+
+
+def box_bot() -> str:
+ return f"╚{'═' * BOX_W}╝"
+
+
+# ─── ASCII art ────────────────────────────────────────────────────────────────
+
+CAT_ART = r""" /\_____/\
+ / o o \
+ ( == ^ == )
+ ) (
+ ( ) ( )
+ ( ( ) ( ) )
+(__(__)___(__)__)"""
diff --git a/service-tester/_grading.py b/service-tester/_grading.py
new file mode 100644
index 0000000..5372163
--- /dev/null
+++ b/service-tester/_grading.py
@@ -0,0 +1,72 @@
+import sys
+
+from _constants import CATEGORY_LABELS
+
+
+def compute_grade(pass_count: int, total_checks: int) -> str:
+ fail_count = total_checks - pass_count
+ if fail_count == 0:
+ return "A"
+ if fail_count <= 2:
+ return "B"
+ if fail_count <= 5:
+ return "C"
+ if fail_count <= 10:
+ return "D"
+ return "F"
+
+
+def count_checks(categories: dict) -> tuple:
+ passed = total = 0
+ for cat in categories.values():
+ if not isinstance(cat, dict):
+ continue
+ for check in cat.values():
+ if check and isinstance(check.get("passed"), bool):
+ total += 1
+ if check["passed"]:
+ passed += 1
+ return passed, total
+
+
+def count_all_checks(results: dict) -> tuple:
+ pass_count = total_checks = 0
+
+ for category_name in ("core", "extended", "workers"):
+ p, t = count_checks(results.get(category_name, {}))
+ pass_count += p
+ total_checks += t
+
+ # WebRTC
+ total_checks += 1
+ if results.get("webrtc", {}).get("passed"):
+ pass_count += 1
+
+ # Stability
+ total_checks += 1
+ if results.get("stability", {}).get("stable"):
+ pass_count += 1
+
+ # Self-destruct (per-context mode)
+ if results.get("selfDestruct"):
+ for check in results["selfDestruct"].values():
+ if check and isinstance(check.get("passed"), bool):
+ total_checks += 1
+ if check["passed"]:
+ pass_count += 1
+
+ return pass_count, total_checks
+
+
+def adjust_cross_os_font_checks(os_type: str, results: dict) -> None:
+ host_os = "macos" if sys.platform == "darwin" else ("windows" if sys.platform == "win32" else "linux")
+ if os_type == host_os:
+ return
+ font_env = results.get("extended", {}).get("fontEnvironment")
+ if not font_env:
+ return
+ for key in ("osDetection", "noWrongOSFonts"):
+ check = font_env.get(key)
+ if check and not check.get("passed"):
+ check["passed"] = True
+ check["detail"] = "[Cross-OS: expected] " + check.get("detail", "")
diff --git a/service-tester/_proxies.py b/service-tester/_proxies.py
new file mode 100644
index 0000000..26d7ad0
--- /dev/null
+++ b/service-tester/_proxies.py
@@ -0,0 +1,56 @@
+import asyncio
+import json
+import sys
+import urllib.request
+from pathlib import Path
+from urllib.parse import urlparse
+
+
+def load_proxies(path: Path) -> list:
+ """
+ Load proxies from a file. Each line must be: user:pass@domain:port
+ Returns a list of Playwright-format proxy dicts.
+ """
+ if not path.exists():
+ print(f"ERROR: Proxies file not found: {path}", file=sys.stderr)
+ print(" Create a proxies.txt file with one proxy per line: user:pass@domain:port", file=sys.stderr)
+ sys.exit(1)
+
+ proxies = []
+ for lineno, raw in enumerate(path.read_text().splitlines(), 1):
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ try:
+ creds, hostport = line.rsplit("@", 1)
+ user, password = creds.split(":", 1)
+ domain, port = hostport.rsplit(":", 1)
+ except ValueError:
+ print(f"ERROR: proxies.txt line {lineno}: expected user:pass@domain:port, got: {line!r}", file=sys.stderr)
+ sys.exit(1)
+ proxies.append({"server": f"http://{domain}:{port}", "username": user, "password": password})
+
+ if not proxies:
+ print("ERROR: proxies.txt contains no valid proxy entries.", file=sys.stderr)
+ sys.exit(1)
+
+ return proxies
+
+
+async def resolve_proxy_geo(proxy: dict) -> dict:
+ """Queries ip-api.com through the proxy for IP, city, country, and timezone."""
+ p = urlparse(proxy.get("server", ""))
+ user = proxy.get("username", "")
+ pwd = proxy.get("password", "")
+ proxy_url = f"{p.scheme}://{user}:{pwd}@{p.netloc}" if user and pwd else proxy.get("server", "")
+
+ def _fetch() -> dict:
+ handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
+ opener = urllib.request.build_opener(handler)
+ try:
+ with opener.open("http://ip-api.com/json?fields=query,city,country,timezone", timeout=10) as resp:
+ return json.loads(resp.read())
+ except Exception:
+ return {}
+
+ return await asyncio.get_event_loop().run_in_executor(None, _fetch)
diff --git a/service-tester/requirements.txt b/service-tester/requirements.txt
new file mode 100644
index 0000000..508a5f4
--- /dev/null
+++ b/service-tester/requirements.txt
@@ -0,0 +1 @@
+playwright
diff --git a/service-tester/run_tests.py b/service-tester/run_tests.py
new file mode 100644
index 0000000..ea4aad2
--- /dev/null
+++ b/service-tester/run_tests.py
@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+"""
+Camoufox Service Tester — Python CLI
+
+Tests an official Camoufox release (installed via pip) using the same
+antibot-detection checks as the build-tester, but launched via the
+camoufox Python API instead of a raw binary path.
+
+Usage:
+ python run_tests.py [options]
+
+Options:
+ --browser-version VER Camoufox version specifier (default: official/stable)
+ e.g. official/prerelease/146.0.1-beta.50
+ --profile-count N Number of profiles to test (1-6, default: 6)
+ --headful Run with visible browser window
+ --proxies PATH Path to proxies file (default: proxies.txt next to this script)
+ Format: user:pass@domain:port (one per line)
+ --secret KEY HMAC signing key for certificate
+ --save-cert PATH Save certificate text to this file
+ --no-cert Skip certificate generation
+"""
+
+import argparse
+import asyncio
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Optional
+
+from _bundle import ensure_bundle, start_http_server
+from _certificate import (
+ compute_cross_profile,
+ generate_certificate,
+ print_certificate,
+ print_profile_result,
+)
+from _constants import BOLD, RED, RESET, PROXIES_FILE, grade_color
+from _grading import adjust_cross_os_font_checks, compute_grade, count_all_checks
+from _proxies import load_proxies, resolve_proxy_geo
+
+
+async def run_tests(
+ browser_version: str,
+ profile_count: int,
+ headful: bool,
+ proxies_path: Path,
+ secret: str,
+ save_cert: Optional[str],
+ no_cert: bool,
+) -> int:
+ # 1. Ensure checks bundle is built
+ ensure_bundle()
+
+ # 2. Load proxies
+ proxies = load_proxies(proxies_path)
+ print(f"Loaded {len(proxies)} proxy/proxies from {proxies_path.name}")
+
+ # 3. Build profile specs — fingerprints and timezone resolved by camoufox per-context
+ all_specs = [
+ {"os": "macos", "name": f"macOS Per-Context {chr(65 + i)}"} for i in range(3)
+ ] + [
+ {"os": "linux", "name": f"Linux Per-Context {chr(65 + i)}"} for i in range(3)
+ ]
+ entries = all_specs[:max(1, min(profile_count, len(all_specs)))]
+
+ # Assign proxies round-robin across entries
+ for i, entry in enumerate(entries):
+ entry["proxy"] = proxies[i % len(proxies)]
+
+ # Resolve proxy geo info concurrently (for certificate debug section)
+ print("Resolving proxy locations...")
+ geos = await asyncio.gather(*[resolve_proxy_geo(e["proxy"]) for e in entries])
+ for entry, geo in zip(entries, geos):
+ entry["proxy_geo"] = geo
+
+ # 4. Start HTTP server for the test page
+ port = start_http_server()
+ test_page_url = f"http://127.0.0.1:{port}/test"
+ print(f"HTTP server started on port {port}")
+
+ # 5. Parse ff_version from browser_version specifier
+ ff_version = None
+ for part in browser_version.split("/"):
+ try:
+ ff_version = int(part.split(".")[0])
+ break
+ except ValueError:
+ continue
+
+ profile_results: list = []
+ timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+
+ try:
+ from camoufox.async_api import AsyncCamoufox, AsyncNewContext
+ except ImportError:
+ print("ERROR: camoufox package not installed.", file=sys.stderr)
+ return 1
+
+ print(f"\n{'─' * 60}")
+ print(f"Per-context phase: {len(entries)} profiles (all open simultaneously)")
+ print(f"{'─' * 60}")
+ print("Launching browser...")
+
+ launch_kwargs = {"headless": not headful}
+ if ff_version:
+ launch_kwargs["ff_version"] = ff_version
+
+ try:
+ async with AsyncCamoufox(**launch_kwargs) as browser:
+ # Create all contexts simultaneously — camoufox handles all fingerprint injection
+ open_contexts = []
+ for entry in entries:
+ profile = {"name": entry["name"], "os": entry["os"], "proxy_geo": entry.get("proxy_geo", {})}
+ try:
+ context = await AsyncNewContext(browser, os=entry["os"], proxy=entry["proxy"])
+ open_contexts.append({"context": context, "profile": profile})
+ except Exception as e:
+ pr = {"profile": profile, "results": None, "grade": "F",
+ "passCount": 0, "totalChecks": 0, "error": str(e)}
+ profile_results.append(pr)
+ print_profile_result(pr)
+
+ if open_contexts:
+ # Navigate all pages concurrently
+ print(f" Navigating {len(open_contexts)} contexts to test page...")
+ pages = []
+ for ctx_data in open_contexts:
+ page = await ctx_data["context"].new_page()
+ pages.append(page)
+ ctx_data["page"] = page
+
+ await asyncio.gather(
+ *[p.goto(test_page_url, wait_until="domcontentloaded", timeout=30_000)
+ for p in pages],
+ return_exceptions=True,
+ )
+
+ # Wait for all tests to complete
+ print(f" Waiting for all tests to complete...")
+ await asyncio.gather(
+ *[p.wait_for_function("!!window.__testComplete__", timeout=120_000)
+ for p in pages],
+ return_exceptions=True,
+ )
+
+ # Collect results
+ print(f" Collecting results from {len(open_contexts)} contexts...")
+ for ctx_data in open_contexts:
+ page = ctx_data["page"]
+ profile = ctx_data["profile"]
+ try:
+ test_error = await page.evaluate("window.__testError__")
+ if test_error:
+ pr = {"profile": profile, "results": None, "grade": "F",
+ "passCount": 0, "totalChecks": 0, "error": test_error}
+ else:
+ results = await page.evaluate("window.__testResults__")
+ adjust_cross_os_font_checks(profile["os"], results)
+ pass_count, total_checks = count_all_checks(results)
+ grade = compute_grade(pass_count, total_checks)
+ pr = {"profile": profile, "results": results, "grade": grade,
+ "passCount": pass_count, "totalChecks": total_checks}
+ except Exception as e:
+ pr = {"profile": profile, "results": None, "grade": "F",
+ "passCount": 0, "totalChecks": 0, "error": str(e)}
+
+ profile_results.append(pr)
+ print_profile_result(pr)
+
+ # Close all contexts
+ for ctx_data in open_contexts:
+ try:
+ await ctx_data["context"].close()
+ except Exception:
+ pass
+
+ except Exception as e:
+ print(f"{RED}ERROR: Failed to launch Camoufox: {e}{RESET}", file=sys.stderr)
+ return 1
+
+ # ── Final summary ──────────────────────────────────────────────────────────
+ cross_profile = compute_cross_profile(profile_results)
+ total_passed = sum(p["passCount"] for p in profile_results)
+ total_checks_sum = sum(p["totalChecks"] for p in profile_results)
+ overall_grade = compute_grade(total_passed, total_checks_sum)
+
+ full_result = {
+ "profiles": profile_results,
+ "crossProfile": cross_profile,
+ "overallGrade": overall_grade,
+ "totalPassed": total_passed,
+ "totalChecks": total_checks_sum,
+ "timestamp": timestamp,
+ }
+
+ print(f"\n{'─' * 60}")
+ print(f"Overall Grade: {grade_color(overall_grade)}{BOLD}{overall_grade}{RESET} "
+ f"Score: {total_passed}/{total_checks_sum} Profiles: {len(profile_results)}")
+ print(f"{'─' * 60}")
+
+ if not no_cert:
+ cert = generate_certificate(full_result, secret)
+ print_certificate(cert, cross_profile, overall_grade)
+ if cert.get("failedTests"):
+ print(f"{RED}Failed checks:{RESET}")
+ for ft in cert["failedTests"]:
+ print(f" {RED}✗{RESET} {ft}")
+ print()
+ if save_cert:
+ Path(save_cert).write_text(
+ f"Grade: {overall_grade}\nScore: {total_passed}/{total_checks_sum}\n"
+ f"ID: {cert['id']}\nHash: {cert['resultsHash']}\nSig: {cert['signature']}\n"
+ )
+ print(f"Certificate saved to: {save_cert}")
+
+ return 0 if overall_grade in ("A", "B") else 1
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Camoufox Service Tester")
+ parser.add_argument("--browser-version", default="official/stable",
+ help="Camoufox version specifier (default: official/stable)")
+ parser.add_argument("--profile-count", type=int, default=6,
+ help="Number of profiles to test (1-6, default: 6)")
+ parser.add_argument("--headful", action="store_true",
+ help="Run with visible browser window")
+ parser.add_argument("--proxies", default=str(PROXIES_FILE),
+ help=f"Path to proxies file (default: {PROXIES_FILE.name} next to this script)")
+ parser.add_argument("--secret", default="camoufox-service-test",
+ help="HMAC signing key for certificate")
+ parser.add_argument("--save-cert", default=None,
+ help="Save certificate to this file path")
+ parser.add_argument("--no-cert", action="store_true",
+ help="Skip certificate generation")
+ args = parser.parse_args()
+
+ sys.exit(asyncio.run(run_tests(
+ browser_version=args.browser_version,
+ profile_count=args.profile_count,
+ headful=args.headful,
+ proxies_path=Path(args.proxies),
+ secret=args.secret,
+ save_cert=args.save_cert,
+ no_cert=args.no_cert,
+ )))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/service-tester/run_tests.sh b/service-tester/run_tests.sh
new file mode 100755
index 0000000..35ec13b
--- /dev/null
+++ b/service-tester/run_tests.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+BUILD_TESTER_DIR="$SCRIPT_DIR/../build-tester"
+
+VERSION="official/stable"
+HEADFUL=""
+PROFILE_COUNT=6
+PROXIES="$SCRIPT_DIR/proxies.txt"
+EXTRA_ARGS=""
+
+# Parse args
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --browser-version)
+ VERSION="$2"
+ shift 2
+ ;;
+ --profile-count)
+ PROFILE_COUNT="$2"
+ shift 2
+ ;;
+ --proxies)
+ PROXIES="$2"
+ shift 2
+ ;;
+ --headful)
+ HEADFUL="--headful"
+ shift
+ ;;
+ --no-cert)
+ EXTRA_ARGS="$EXTRA_ARGS --no-cert"
+ shift
+ ;;
+ --save-cert)
+ EXTRA_ARGS="$EXTRA_ARGS --save-cert $2"
+ shift 2
+ ;;
+ *)
+ echo "Unknown argument: $1"
+ echo "Usage: $0 [--browser-version ] [--profile-count N] [--proxies PATH] [--headful] [--no-cert] [--save-cert PATH]"
+ echo " e.g. $0 --browser-version official/prerelease/146.0.1-beta.50 --headful"
+ exit 1
+ ;;
+ esac
+done
+
+echo "==> Browser version: $VERSION"
+echo "==> Profile count: $PROFILE_COUNT"
+
+# Install npm deps in build-tester (for esbuild — needed to build TypeScript bundle)
+if [ ! -d "$BUILD_TESTER_DIR/node_modules" ]; then
+ echo "==> Installing build-tester npm dependencies..."
+ (cd "$BUILD_TESTER_DIR" && npm install --silent)
+fi
+
+# Create venv if needed
+if [ ! -d ".venv" ]; then
+ echo "==> Creating virtual environment..."
+ python3 -m venv .venv
+fi
+
+PYTHON=".venv/bin/python"
+PIP=".venv/bin/pip"
+
+echo "==> Installing camoufox from local source..."
+$PIP install -q -e ../pythonlib
+
+echo "==> Setting browser version: $VERSION"
+$PYTHON -m camoufox set "$VERSION"
+
+echo "==> Fetching browser..."
+$PYTHON -m camoufox fetch
+
+echo "==> Running service tests..."
+$PYTHON run_tests.py \
+ --browser-version "$VERSION" \
+ --profile-count "$PROFILE_COUNT" \
+ --proxies "$PROXIES" \
+ $HEADFUL \
+ $EXTRA_ARGS
|