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.
-## Firefox Version Upgrade Notice - -The current main branch is built for Firefox v146. It is an experimental change and may contain several bugs. If you are building from source and require a stable production version, use branch `releases/135`. - -FF146 only works for MacOS. Linux support is coming in the next week and windows support by the end of January. - -See the [Beta Testing Guide](docs/beta-testing-ff146.md) for instructions on testing FF146. - --- # Sponsors @@ -52,7 +46,7 @@ See the [Beta Testing Guide](docs/beta-testing-ff146.md) for instructions on tes - cloverlabs.ai + cloverlabs.ai @@ -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