Repository: antoinevastel/fpscanner Branch: master Commit: 598ac6a4c5e2 Files: 97 Total size: 201.7 KB Directory structure: gitextract_9uednx2t/ ├── .babelrc ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── bin/ │ └── cli.js ├── examples/ │ ├── nodejs/ │ │ ├── README.md │ │ ├── demo-server.js │ │ └── index.html │ └── python/ │ ├── README.md │ ├── demo-server.py │ └── index.html ├── package.json ├── playwright.config.ts ├── scripts/ │ ├── build-custom.js │ ├── safe-publish.js │ └── verify-publish.js ├── src/ │ ├── crypto-helpers.ts │ ├── detections/ │ │ ├── hasBotUserAgent.ts │ │ ├── hasCDP.ts │ │ ├── hasContextMismatch.ts │ │ ├── hasGPUMismatch.ts │ │ ├── hasHeadlessChromeScreenResolution.ts │ │ ├── hasHighCPUCount.ts │ │ ├── hasImpossibleDeviceMemory.ts │ │ ├── hasInconsistentEtsl.ts │ │ ├── hasMismatchLanguages.ts │ │ ├── hasMismatchPlatformIframe.ts │ │ ├── hasMismatchPlatformWorker.ts │ │ ├── hasMismatchWebGLInWorker.ts │ │ ├── hasMissingChromeObject.ts │ │ ├── hasPlatformMismatch.ts │ │ ├── hasPlaywright.ts │ │ ├── hasSeleniumProperty.ts │ │ ├── hasSwiftshaderRenderer.ts │ │ ├── hasUTCTimezone.ts │ │ ├── hasWebdriver.ts │ │ ├── hasWebdriverIframe.ts │ │ ├── hasWebdriverWorker.ts │ │ └── hasWebdriverWritable.ts │ ├── globals.d.ts │ ├── index.ts │ ├── signals/ │ │ ├── browserExtensions.ts │ │ ├── browserFeatures.ts │ │ ├── canvas.ts │ │ ├── cdp.ts │ │ ├── cpuCount.ts │ │ ├── etsl.ts │ │ ├── highEntropyValues.ts │ │ ├── iframe.ts │ │ ├── internationalization.ts │ │ ├── languages.ts │ │ ├── maths.ts │ │ ├── mediaCodecs.ts │ │ ├── mediaQueries.ts │ │ ├── memory.ts │ │ ├── multimediaDevices.ts │ │ ├── navigatorPropertyDescriptors.ts │ │ ├── nonce.ts │ │ ├── platform.ts │ │ ├── playwright.ts │ │ ├── plugins.ts │ │ ├── screenResolution.ts │ │ ├── seleniumProperties.ts │ │ ├── time.ts │ │ ├── toSourceError.ts │ │ ├── url.ts │ │ ├── userAgent.ts │ │ ├── utils.ts │ │ ├── webGL.ts │ │ ├── webdriver.ts │ │ ├── webdriverWritable.ts │ │ ├── webgpu.ts │ │ └── worker.ts │ └── types.ts ├── test/ │ ├── decrypt.js │ ├── detection/ │ │ ├── README.md │ │ ├── nodejs/ │ │ │ ├── package.json │ │ │ ├── playwright-android-headless.js │ │ │ ├── playwright-chromium-headless.js │ │ │ ├── playwright-firefox-headless.js │ │ │ ├── playwright-iphone-headless.js │ │ │ ├── playwright-webkit-headless.js │ │ │ ├── puppeteer-headless.js │ │ │ └── puppeteer-stealth.js │ │ └── python/ │ │ ├── camoufox_test.py │ │ ├── requirements.txt │ │ ├── selenium_headless_test.py │ │ └── undetected_chromedriver_test.py │ ├── dev-dist.html │ ├── dev-source.html │ ├── fingerprint.spec.ts │ ├── server.js │ └── test-page.html ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": ["es2015"] } ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [main, master, fpscanner-v2] pull_request: branches: [main, master] jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 'lts/*' cache: 'npm' - name: Install dependencies run: npm ci - name: Build (non-obfuscated) run: npm run build:dev - name: Build (obfuscated) run: npm run build:obfuscate - name: Verify dist files exist run: | test -f dist/fpScanner.es.js test -f dist/fpScanner.cjs.js test -f dist/index.d.ts test: runs-on: ubuntu-latest needs: build steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 'lts/*' cache: 'npm' - name: Install dependencies run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps chromium firefox webkit - name: Run Playwright tests run: npm run test:playwright - name: Upload test results uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/ retention-days: 7 package: runs-on: ubuntu-latest needs: build steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 'lts/*' cache: 'npm' - name: Install dependencies run: npm ci - name: Build for packaging run: npm run build:dev - name: Test npm pack run: | npm pack ls -la *.tgz - name: Test package installation run: | mkdir /tmp/test-install cd /tmp/test-install npm init -y npm install $GITHUB_WORKSPACE/fpscanner-*.tgz # Verify the package installed correctly test -f node_modules/fpscanner/dist/fpScanner.es.js test -f node_modules/fpscanner/bin/cli.js ================================================ FILE: .gitignore ================================================ .idea/ dist/ # Logs logs *.log # Runtime data pids *.pid *.seed # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directory # Commenting this out is preferred by some people, see # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- node_modules bower_components coverage tmp # Users Environment Variables .lock-wscript \.idea/dictionaries/avastel\.xml \.idea/fpscanner\.iml \.idea/workspace\.xml \.idea/vcs\.xml \.idea/modules\.xml \.idea/misc\.xml \.idea/jsLibraryMappings\.xml *.pyc __pycache__/* # Playwright test-results/ playwright-report/ playwright/.cache/ venv/ ================================================ FILE: .npmignore ================================================ # Backup files created by custom build script dist/*.original # Test files test/ test-results/ playwright-report/ .github/ # Development files *.spec.ts *.test.ts vite.config.ts playwright.config.ts tsconfig.json # Examples examples/ ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2017 antoinevastel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Fingerprint Scanner > **News:** After more than 7 years without any updates, the first release of the new FPScanner is here. This version combines fingerprinting and bot detection in a single library. Try the live demo at [fpscanner.com](https://fpscanner.com/). Expect new fingerprinting signals and more detection logic over time. [![CI](https://github.com/antoinevastel/fpscanner/actions/workflows/ci.yml/badge.svg)](https://github.com/antoinevastel/fpscanner/actions/workflows/ci.yml) ## Sponsor This project is sponsored by Castle. Castle This library focuses on self-hosted fingerprinting and bot detection primitives. In real-world fraud and bot prevention, teams often need additional capabilities such as traffic observability, historical analysis, rule iteration, and correlation across device, network, and behavioral signals. Castle provides a production-grade platform for bot and fraud detection, designed to operate at scale and handle these operational challenges end to end. For a deeper explanation of what this library intentionally does not cover, see the **“Limits and non-goals”** section at the end of this README. ## FPScanner: description A lightweight browser fingerprinting library for bot detection. Scraping has become mainstream. AI and LLM-driven companies now crawl the web at a scale that was previously limited to specialized actors, often without clearly respecting `robots.txt` or rate limits. At the same time, fraudsters do not need to rely solely on public frameworks like OpenBullet or generic automation stacks anymore. With LLMs, writing a custom bot tailored to a specific website has become significantly easier, faster, and cheaper. The result is a much broader and more diverse bot ecosystem: - More actors scraping content, training models, or extracting data - More custom automation, harder to fingerprint with outdated heuristics - More abuse at signup, login, and sensitive workflows, not just simple scraping On the defender side, the situation is much more constrained. You often have two options: - Very basic open source libraries that focus on naive or outdated signals - Expensive, black-box bot and fraud solutions that require routing traffic through third-party CDNs or vendors Not every website can afford enterprise-grade bot management products. And even when cost is not the main issue, you may not want to route all your traffic through a CDN or outsource all detection logic to a third party. This library exists to fill that gap. It is a **self-hosted, lightweight, and up-to-date** browser fingerprinting and bot detection library, designed with real-world constraints in mind. The goal is not to promise perfect detection, but to give you solid building blocks that reflect how bots actually behave today. This includes practical considerations that are often ignored in toy implementations: - Anti-replay protections (timestamp + nonce) - Payload encryption to prevent trivial forgery - Optional obfuscation to raise the cost of reverse-engineering - Focus on strong, low-noise signals rather than brittle tricks The design and trade-offs behind this library are directly inspired by real production experience and by the ideas discussed in these articles: - [Roll your own bot detection: fingerprinting (JavaScript)](https://blog.castle.io/roll-your-own-bot-detection-fingerprinting-javascript-part-1/) - [Roll your own bot detection: server-side detection](https://blog.castle.io/roll-your-own-bot-detection-server-side-detection-part-2/) Those articles are not documentation for this library, but they reflect the same philosophy: understand what attackers actually do, accept that no single signal is perfect, and build simple, composable primitives that you fully control. ### Open Source, Production-Ready This library is open source, but it is not naive about the implications of being open. In bot detection, openness cuts both ways. Publishing detection logic makes it easier for attackers to study how they are detected. At the same time, defenders routinely study open and closed automation frameworks, anti-detect browsers, and bot tooling to discover new signals and weaknesses. This asymmetry already exists in the ecosystem, regardless of whether this library is open source or not. The goal here is not to rely on obscurity. It is to acknowledge that attackers will read the code and still make abuse operationally expensive. This is why the library combines transparency with pragmatic hardening: - **Anti-replay mechanisms** ensure that a valid fingerprint cannot simply be captured once and reused at scale. - **Build-time key injection** means attackers cannot trivially generate valid encrypted payloads without access to your specific build. - **Optional obfuscation** raises the cost of reverse-engineering and makes automated payload forgery harder without executing the code in a real browser. These controls are not meant to be perfect or unbreakable. Their purpose is to remove the easy shortcuts. An attacker should not be able to look at the repository, reimplement a serializer, and start sending convincing fingerprints from a headless script. More importantly, detection does not stop at a single boolean flag. Even if an attacker focuses on bypassing individual bot detection checks, producing **fully consistent fingerprints** over time is significantly harder. Fingerprints encode relationships between signals, contexts, and environments. Maintaining that consistency across sessions, IPs, and accounts requires real execution, careful state management, and stable tooling. In practice, this creates leverage on the server side: - Fingerprints can be tracked over time - Reuse patterns and drift become visible - Inconsistencies surface when attackers partially emulate environments or rotate tooling incorrectly This is how fingerprinting is used in production systems: not as a one-shot verdict, but as a way to observe structure, reuse, and anomalies at scale. Open source does not weaken this approach. It makes the trade-offs explicit. Attackers are assumed to be capable and adaptive, not careless. The library is designed accordingly: to force real execution, limit replay, and preserve enough structure in the signals that automation leaves traces once you observe it over time. ## Features | Feature | Description | |---------|-------------| | **Fast bot detection** | Client-side detection of strong automation signals such as `navigator.webdriver`, CDP usage, Playwright markers, and other common automation artifacts | | **Browser fingerprinting** | Short-lived fingerprint designed for attack detection, clustering, and session correlation rather than long-term device tracking | | **Encrypted payloads** | Optional payload encryption to prevent trivial forgery, with the encryption key injected at build time | | **Obfuscation** | Optional code obfuscation to increase the cost of reverse-engineering and make it harder to forge valid fingerprints without actually executing the collection code | | **Cross-context validation** | Detects inconsistencies across different JavaScript execution contexts (main page, iframes, and web workers) | --- ## Quick Start ### Installation ```bash npm install fpscanner ``` > **Note**: Out of the box, fpscanner uses a default placeholder encryption key and no obfuscation. This is fine for development and testing, but for production deployments you should build with your own key and enable obfuscation. See [Advanced: Custom Builds](#advanced-custom-builds) for details. ### Basic Usage ```javascript import FingerprintScanner from 'fpscanner'; const scanner = new FingerprintScanner(); const payload = await scanner.collectFingerprint(); // Send payload to your server fetch('/api/fingerprint', { method: 'POST', body: JSON.stringify({ fingerprint: payload }), headers: { 'Content-Type': 'application/json' } }); ``` ### Server-Side (Node.js) ```javascript // Decrypt and validate the fingerprint // Use the same key you provided when building: npx fpscanner build --key=your-key const key = 'your-secret-key'; // Your custom key function decryptFingerprint(ciphertext, key) { const encrypted = Buffer.from(ciphertext, 'base64'); const keyBytes = Buffer.from(key, 'utf8'); const decrypted = Buffer.alloc(encrypted.length); for (let i = 0; i < encrypted.length; i++) { decrypted[i] = encrypted[i] ^ keyBytes[i % keyBytes.length]; } return JSON.parse(decrypted.toString('utf8')); } app.post('/api/fingerprint', (req, res) => { const fingerprint = decryptFingerprint(req.body.fingerprint, key); // Check bot detection if (fingerprint.fastBotDetection) { console.log('🤖 Bot detected!', fingerprint.fastBotDetectionDetails); return res.status(403).json({ error: 'Bot detected' }); } // Validate timestamp (prevent replay attacks) const ageMs = Date.now() - fingerprint.time; if (ageMs > 60000) { // 60 seconds return res.status(400).json({ error: 'Fingerprint expired' }); } // Use fingerprint.fsid for session correlation console.log('Fingerprint ID:', fingerprint.fsid); res.json({ ok: true }); }); ``` That's it! For most use cases, this is all you need. --- ## API Reference ### `collectFingerprint(options?)` Collects browser signals and returns a fingerprint. ```javascript const scanner = new FingerprintScanner(); // Default: returns encrypted base64 string const encrypted = await scanner.collectFingerprint(); // Explicit encryption const encrypted = await scanner.collectFingerprint({ encrypt: true }); // Raw object (no library encryption) const fingerprint = await scanner.collectFingerprint({ encrypt: false }); ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `encrypt` | `boolean` | `true` | Whether to encrypt the payload | | `skipWorker` | `boolean` | `false` | Skip Web Worker signals (use if CSP blocks blob: URLs) | ### Fingerprint Object When decrypted (or with `encrypt: false`), the fingerprint contains: ```typescript interface Fingerprint { // Bot detection fastBotDetection: boolean; // true if any bot signal detected fastBotDetectionDetails: { hasWebdriver: boolean; // navigator.webdriver === true hasWebdriverWritable: boolean; // webdriver property is writable hasSeleniumProperty: boolean; // Selenium-specific properties hasCDP: boolean; // Chrome DevTools Protocol signals hasPlaywright: boolean; // Playwright-specific signals hasWebdriverIframe: boolean; // webdriver in iframe context hasWebdriverWorker: boolean; // webdriver in worker context // ... more detection flags }; // Fingerprint fsid: string; // JA4-inspired fingerprint ID signals: { /* raw signal data */ }; // Anti-replay time: number; // Unix timestamp (ms) nonce: string; // Random value for replay detection } ``` --- ## What It Detects The library focuses on **strong, reliable signals** from major automation frameworks: | Detection | Signal | Frameworks | |-----------|--------|------------| | `hasWebdriver` | `navigator.webdriver === true` | Selenium, Puppeteer, Playwright | | `hasWebdriverWritable` | webdriver property descriptor | Puppeteer, Playwright | | `hasSeleniumProperty` | `document.$cdc_`, `$wdc_` | Selenium WebDriver | | `hasCDP` | CDP runtime markers | Chrome DevTools Protocol | | `hasPlaywright` | `__playwright`, `__pw_*` | Playwright | | `hasMissingChromeObject` | Missing `window.chrome` | Headless Chrome | | `headlessChromeScreenResolution` | 800x600 default | Headless browsers | | `hasHighCPUCount` | Unrealistic core count | VM/container environments | | `hasImpossibleDeviceMemory` | Unrealistic memory values | Spoofed environments | ### Cross-Context Validation Bots often fail to maintain consistency across execution contexts: | Detection | Description | |-----------|-------------| | `hasWebdriverIframe` | webdriver detected in iframe but not main | | `hasWebdriverWorker` | webdriver detected in web worker | | `hasMismatchPlatformIframe` | Platform differs between main and iframe | | `hasMismatchPlatformWorker` | Platform differs between main and worker | | `hasMismatchWebGLInWorker` | WebGL renderer differs in worker | --- ## Fingerprint ID (fsid) Format The `fsid` is a JA4-inspired, locality-preserving fingerprint identifier. Unlike a simple hash, it's structured into semantic sections, making it both human-readable and useful for partial matching. ### Format ``` FS1________ ``` ### Example ``` FS1_00000100000000_10010h3f2a_1728x1117c14m08b01011h4e7a9f_f1101011001e00000000p1100h2c8b1e_0h9d3f7a_1h6a2e4c_en4tEurope-Paris_hab12_0000h3e9f ``` ### Section Breakdown | # | Section | Format | Example | Description | |---|---------|--------|---------|-------------| | 1 | **Version** | `FS1` | `FS1` | Fingerprint Scanner version 1 | | 2 | **Detection** | n-bit bitmask (21 bits in FS1) | `000001000000000000000` | All fastBotDetectionDetails booleans (extensible) | | 3 | **Automation** | `<5-bit>h` | `10010h3f2a` | Automation booleans + hash | | 4 | **Device** | `xcmb<5-bit>h` | `1728x1117c14m08b01011h4e7a9f` | Screen, cpu, memory, device booleans + hash | | 5 | **Browser** | `f<10-bit>e<8-bit>p<4-bit>h` | `f1101011001e00000000p1100h2c8b1e` | Features + extensions + plugins bitmasks + hash | | 6 | **Graphics** | `<1-bit>h` | `0h9d3f7a` | hasModifiedCanvas + hash | | 7 | **Codecs** | `<1-bit>h` | `1h6a2e4c` | hasMediaSource + hash | | 8 | **Locale** | `t_h` | `en4tEurope-Paris_hab12` | Language code + count + timezone + hash | | 9 | **Contexts** | `<4-bit>h` | `0000h3e9f` | Mismatch + webdriver flags + hash | ### Why This Format? Inspired by [JA4+](https://github.com/FoxIO-LLC/ja4), this format enables: 1. **Partial Matching** — Compare specific sections across fingerprints (same GPU but different screen?) 2. **Human Readability** — `1728x1117c14m08` = 1728×1117 screen, 14 cores, 8GB RAM 3. **Extensibility** — Adding a new boolean check appends a bit without breaking existing positions 4. **Similarity Detection** — Bots from the same framework often share automation/browser hashes
Bitmask Reference #### Detection Bitmask (21 bits in FS1, extensible) > ⚠️ **Note**: The number of detection bits increases as new bot detection checks are added. Always check the fingerprint version (FS1, FS2, etc.) to know the exact bit count and meaning. New checks are appended to maintain backward compatibility. ``` Bit 0: headlessChromeScreenResolution Bit 1: hasWebdriver Bit 2: hasWebdriverWritable Bit 3: hasSeleniumProperty Bit 4: hasCDP Bit 5: hasPlaywright Bit 6: hasImpossibleDeviceMemory Bit 7: hasHighCPUCount Bit 8: hasMissingChromeObject Bit 9: hasWebdriverIframe Bit 10: hasWebdriverWorker Bit 11: hasMismatchWebGLInWorker Bit 12: hasMismatchPlatformIframe Bit 13: hasMismatchPlatformWorker Bit 14: hasSwiftshaderRenderer Bit 15: hasUTCTimezone Bit 16: hasMismatchLanguages Bit 17: hasInconsistentEtsl Bit 18: hasBotUserAgent Bit 19: hasGPUMismatch Bit 20: hasPlatformMismatch ``` #### Automation Bitmask (5 bits) ``` Bit 0: webdriver Bit 1: webdriverWritable Bit 2: selenium Bit 3: cdp Bit 4: playwright ``` #### Device Bitmask (5 bits) ``` Bit 0: hasMultipleDisplays Bit 1: prefersReducedMotion Bit 2: prefersReducedTransparency Bit 3: hover Bit 4: anyHover ``` #### Browser Features Bitmask (28 bits in FS1, extensible) ``` Bit 0: chrome Bit 1: brave Bit 2: applePaySupport Bit 3: opera Bit 4: serial Bit 5: attachShadow Bit 6: caches Bit 7: webAssembly Bit 8: buffer Bit 9: showModalDialog Bit 10: safari Bit 11: webkitPrefixedFunction Bit 12: mozPrefixedFunction Bit 13: usb Bit 14: browserCapture Bit 15: paymentRequestUpdateEvent Bit 16: pressureObserver Bit 17: audioSession Bit 18: selectAudioOutput Bit 19: barcodeDetector Bit 20: battery Bit 21: devicePosture Bit 22: documentPictureInPicture Bit 23: eyeDropper Bit 24: editContext Bit 25: fencedFrame Bit 26: sanitizer Bit 27: otpCredential ``` #### Browser Extensions Bitmask (8 bits) ``` Bit 0: grammarly Bit 1: metamask Bit 2: couponBirds Bit 3: deepL Bit 4: monicaAI Bit 5: siderAI Bit 6: requestly Bit 7: veepn ``` #### Plugins Bitmask (4 bits) ``` Bit 0: isValidPluginArray Bit 1: pluginConsistency1 Bit 2: pluginOverflow Bit 3: hasToSource ``` #### Contexts Bitmask (4 bits) ``` Bit 0: iframe mismatch Bit 1: worker mismatch Bit 2: iframe.webdriver Bit 3: webWorker.webdriver ```
--- ## Server-Side Decryption The library uses a simple XOR cipher with Base64 encoding. This is easy to implement in any language. ### Node.js ```javascript function decryptFingerprint(ciphertext, key) { const encrypted = Buffer.from(ciphertext, 'base64'); const keyBytes = Buffer.from(key, 'utf8'); const decrypted = Buffer.alloc(encrypted.length); for (let i = 0; i < encrypted.length; i++) { decrypted[i] = encrypted[i] ^ keyBytes[i % keyBytes.length]; } let fingerprint = JSON.parse(decrypted.toString('utf8')); // Handle double-JSON-encoding if present if (typeof fingerprint === 'string') { fingerprint = JSON.parse(fingerprint); } return fingerprint; } ``` ### Python ```python import base64 import json def decrypt_fingerprint(ciphertext: str, key: str) -> dict: encrypted = base64.b64decode(ciphertext) key_bytes = key.encode('utf-8') decrypted = bytearray(len(encrypted)) for i in range(len(encrypted)): decrypted[i] = encrypted[i] ^ key_bytes[i % len(key_bytes)] fingerprint = json.loads(decrypted.decode('utf-8')) # Handle double-JSON-encoding if present if isinstance(fingerprint, str): fingerprint = json.loads(fingerprint) return fingerprint ``` ### Other Languages The algorithm is straightforward to port: 1. **Base64 decode** the ciphertext to get raw bytes 2. **XOR each byte** with the corresponding key byte (cycling through the key) 3. **Decode** the result as UTF-8 to get the JSON string 4. **Parse** the JSON to get the fingerprint object See the [`examples/`](./examples/) folder for complete Node.js and Python server examples. --- ## Advanced: Custom Builds By default, fpscanner uses a placeholder key that gets replaced when you run the build command. For production, you should use your own encryption key and enable obfuscation to make it harder for attackers to forge payloads. ### Bring Your Own Encryption/Obfuscation The library provides built-in encryption and obfuscation, but **you're not required to use them**. If you prefer: - Use `collectFingerprint({ encrypt: false })` to get the raw fingerprint object - Apply your own encryption, signing, or encoding before sending to your server - Run your own obfuscation tool (Terser, JavaScript Obfuscator, etc.) on your bundle The recommended approach is to use **some form of** encryption + obfuscation — whether that's the library's built-in solution or your own. The key is to prevent attackers from easily forging payloads without executing the actual collection code. ### Why Custom Builds? | Threat | Without Protection | With Encryption + Obfuscation | |--------|---------------------|-------------------| | Payload forgery | Attacker can craft fake fingerprints | Key is hidden in obfuscated code | | Replay attacks | Attacker captures and replays fingerprints | Server validates timestamp + nonce | | Code inspection | Detection logic is readable | Control flow obfuscation makes analysis harder | > **Note**: Obfuscation is not encryption. A determined attacker can still reverse-engineer the code. The goal is to raise the bar and force attackers to invest significant effort, not to create an impenetrable system. ### Build with Your Key ```bash npx fpscanner build --key=your-secret-key-here ``` This will: 1. Create backups of original files (for idempotent rebuilds) 2. Inject your encryption key into the library 3. Obfuscate the output to protect the key 4. Overwrite the files in `node_modules/fpscanner/dist/` > **Note**: The build command is idempotent - you can run it multiple times safely. Original files are backed up as `.original` and restored before each build, preventing issues with re-obfuscating already-obfuscated code. This makes the build safe to use in watch mode or repeated CI/CD runs. ### Key Injection Methods The CLI supports multiple methods (in order of priority): ```bash # 1. Command line argument (highest priority) npx fpscanner build --key=your-secret-key # 2. Environment variable export FINGERPRINT_KEY=your-secret-key npx fpscanner build # 3. .env file echo "FINGERPRINT_KEY=your-secret-key" >> .env npx fpscanner build # 4. Custom env file npx fpscanner build --env-file=.env.production ``` ### CI/CD Integration Add a `postinstall` script to automatically build with your key: ```json { "scripts": { "postinstall": "fpscanner build" } } ``` Then set `FINGERPRINT_KEY` as a secret in your CI/CD: **GitHub Actions:** ```yaml env: FINGERPRINT_KEY: ${{ secrets.FINGERPRINT_KEY }} steps: - run: npm install # postinstall runs fpscanner build automatically ``` ### Build Options | Option | Description | |--------|-------------| | `--key=KEY` | Encryption key (highest priority) | | `--env-file=FILE` | Load key from custom env file | | `--no-obfuscate` | Skip obfuscation (faster, for development) | #### Skip Obfuscation Obfuscation is enabled by default. For faster builds during development: ```bash # Via CLI flag npx fpscanner build --key=dev-key --no-obfuscate # Via environment variable FINGERPRINT_OBFUSCATE=false npx fpscanner build # In .env file FINGERPRINT_OBFUSCATE=false ``` > ⚠️ **Warning**: Without obfuscation, the encryption key is visible in plain text in the source code. This means attackers can easily extract the key and forge fingerprint payloads without running the actual collection code. If you skip the library's obfuscation, make sure you apply your own obfuscation to the final bundle. --- ## Development ### Local Development Scripts ```bash # Quick build (default key, no obfuscation) npm run build # Build with dev-key, no obfuscation npm run build:dev # Build + serve test/dev-source.html at localhost:3000 npm run dev # Build with obfuscation npm run build:obfuscate # Production build (key from .env, with obfuscation) npm run build:prod # Watch mode (rebuilds on changes) npm run watch ``` ### Testing ```bash npm test ``` --- ## Security Best Practices 1. **Use a strong, random key and rotate it regularly** Use a high-entropy key (at least 32 random characters) and rotate it periodically. Because the encryption key is shipped client-side in the JavaScript bundle, long-lived keys give attackers more time to extract and reuse them. Rotating the key forces attackers to re-analyze and re-adapt, and requires rebuilding and redeploying the fingerprinting script. 2. **Use obfuscation in production** Enable the library’s built-in obfuscation or apply your own obfuscation step to the final bundle. Without obfuscation, the encryption key is visible in plain text in the client-side code, making it trivial to forge payloads without executing the fingerprinting logic. Obfuscation raises the cost of key extraction and payload forgery. 3. **Validate timestamps server-side** Reject fingerprints that are older than a reasonable threshold (for example, 60 seconds). This limits the usefulness of captured payloads and reduces the impact of replay attacks. 4. **Track nonces** Optionally store recently seen nonces and reject duplicates. This provides an additional layer of replay protection, especially for high-value or abuse-prone endpoints. 5. **Monitor fingerprint distributions over time** Do not treat fingerprinting as a one-shot decision. Monitor how fingerprints evolve and distribute over time. Sudden spikes, new dominant fingerprints, or unusual reuse patterns can indicate automated or malicious activity, even if individual requests do not trigger explicit bot detection flags. 6. **Defense in depth on sensitive endpoints** When protecting sensitive flows (signup, login, password reset, API access), combine this library with other controls such as fingerprint-based rate limiting, behavioral analysis, disposable emails detection and challenge mechanisms like CAPTCHAs or risk-based authentication. Fingerprinting works best as one layer in a broader detection and mitigation strategy. --- ## Troubleshooting ### "No encryption key found!" Provide a key via one of the supported methods: ```bash npx fpscanner build --key=your-key # or export FINGERPRINT_KEY=your-key && npx fpscanner build # or echo "FINGERPRINT_KEY=your-key" >> .env && npx fpscanner build ``` ### Decryption returns garbage Make sure you're using the **exact same key** on your server that you used when building. Keys must match exactly. ### Obfuscation is slow Use `--no-obfuscate` during development. Only enable obfuscation for production builds. ### Build fails with "heap out of memory" in watch mode If you're running the build command repeatedly (e.g., in a watch task), this is now fixed. The build script automatically backs up and restores original files to prevent re-obfuscating already-obfuscated code. If you still encounter this issue: 1. Update to the latest version of fpscanner 2. Use `--no-obfuscate` for development/watch mode (recommended) 3. Only enable obfuscation for production builds ### `postinstall` fails in CI Ensure `FINGERPRINT_KEY` is set as an environment variable before `npm install` runs. --- ## Limits and non-goals This library provides building blocks, not a complete bot or fraud detection system. It is important to understand its limits before using it in production. ### Open source and attacker adaptation The library is open source, which means attackers can inspect the code and adapt their tooling. This is expected and reflects how the ecosystem already works. Defenders routinely analyze automation frameworks and anti-detect browsers, and attackers do the same with detection logic. The goal is not secrecy, but to make abuse operationally expensive by forcing real execution, limiting replay, and preserving consistency constraints that are difficult to fake at scale. ### Obfuscation is not a silver bullet The optional obfuscation relies on an open source obfuscator, and some attackers maintain deobfuscation tooling for it. Obfuscation is a friction mechanism, not a guarantee. It slows down analysis and discourages low-effort abuse, but motivated attackers can still reverse-engineer the code. ### Limits of client-side detection All client-side fingerprinting and bot detection techniques can be spoofed or emulated. This library focuses on strong, low-noise signals, but no individual signal or fingerprint should be treated as definitive. Fingerprints are representations, not verdicts. Their value comes from observing how they behave over time, how often they appear, and how they correlate with actions, IPs, and accounts. ### Not an end-to-end solution Real-world bot and fraud detection requires server-side context, observability, and iteration: the ability to monitor traffic, build and test rules, and adapt over time. This library intentionally does not provide dashboards, rule engines, or managed mitigation. If you need a production-grade, end-to-end solution with observability and ongoing maintenance, consider using a dedicated platform like [Castle](https://castle.io/). --- ## License MIT ================================================ FILE: bin/cli.js ================================================ #!/usr/bin/env node const path = require('path'); const fs = require('fs'); /** * Load environment variables from a file * Supports .env format: KEY=value */ function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) { return {}; } const content = fs.readFileSync(filePath, 'utf8'); const env = {}; for (const line of content.split('\n')) { // Skip comments and empty lines const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const match = trimmed.match(/^([^=]+)=(.*)$/); if (match) { const key = match[1].trim(); let value = match[2].trim(); // Remove surrounding quotes if present if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } env[key] = value; } } return env; } /** * Resolve the encryption key from multiple sources * Priority: --key flag > environment variable > .env file */ function resolveKey(args, cwd) { // 1. Check for explicit --key=xxx argument (highest priority) const keyArg = args.find(a => a.startsWith('--key=')); if (keyArg) { const key = keyArg.split('=').slice(1).join('='); // Handle keys with = in them console.log('🔑 Using key from --key argument'); return key; } // 2. Check for environment variable (already loaded, e.g., from CI) if (process.env.FINGERPRINT_KEY) { console.log('🔑 Using FINGERPRINT_KEY from environment'); return process.env.FINGERPRINT_KEY; } // 3. Try to load from .env file (or custom file via --env-file) const envFileArg = args.find(a => a.startsWith('--env-file=')); const envFileName = envFileArg ? envFileArg.split('=')[1] : '.env'; const envFilePath = path.isAbsolute(envFileName) ? envFileName : path.join(cwd, envFileName); const envFromFile = loadEnvFile(envFilePath); if (envFromFile.FINGERPRINT_KEY) { console.log(`🔑 Using FINGERPRINT_KEY from ${path.basename(envFilePath)}`); return envFromFile.FINGERPRINT_KEY; } // 4. No key found return null; } function printHelp() { console.log(` 📦 fpscanner CLI Commands: build Build fpscanner with your custom encryption key Usage: npx fpscanner build [options] Options: --key=KEY Use KEY as the encryption key (highest priority) --env-file=FILE Load FINGERPRINT_KEY from FILE (default: .env) --no-obfuscate Skip obfuscation step (faster builds, readable output) Environment Variables: FINGERPRINT_KEY The encryption key (if not using --key) FINGERPRINT_OBFUSCATE Set to "false" to skip obfuscation (default: true) Key Resolution (in order of priority): 1. --key=xxx argument 2. FINGERPRINT_KEY environment variable 3. FINGERPRINT_KEY in .env file (or custom file via --env-file) Obfuscation Control: 1. --no-obfuscate flag (highest priority) 2. FINGERPRINT_OBFUSCATE=false environment variable 3. Default: obfuscation enabled Examples: # Using command line argument npx fpscanner build --key=my-secret-key # Using environment variable export FINGERPRINT_KEY=my-secret-key npx fpscanner build # Using .env file echo "FINGERPRINT_KEY=my-secret-key" >> .env npx fpscanner build # Using custom env file npx fpscanner build --env-file=.env.production # Skip obfuscation (CLI flag) npx fpscanner build --key=my-key --no-obfuscate # Skip obfuscation (environment variable) FINGERPRINT_OBFUSCATE=false npx fpscanner build # Production without obfuscation (postinstall compatible) FINGERPRINT_KEY=xxx FINGERPRINT_OBFUSCATE=false npm install Setup (add to your package.json): { "scripts": { "postinstall": "fpscanner build" } } Then install with your chosen options: FINGERPRINT_KEY=xxx npm install # With obfuscation FINGERPRINT_KEY=xxx FINGERPRINT_OBFUSCATE=false npm install # Without obfuscation `); } // Main const args = process.argv.slice(2); const command = args[0]; if (!command || command === 'help' || command === '--help' || command === '-h') { printHelp(); process.exit(0); } if (command === 'build') { const cwd = process.cwd(); const key = resolveKey(args, cwd); if (!key) { console.error(` ❌ No encryption key found! Provide a key using one of these methods: 1. Command line argument: npx fpscanner build --key=your-secret-key 2. Environment variable: export FINGERPRINT_KEY=your-secret-key npx fpscanner build 3. .env file in your project root: echo "FINGERPRINT_KEY=your-secret-key" >> .env npx fpscanner build 4. Custom env file: npx fpscanner build --env-file=.env.production Run 'npx fpscanner --help' for more information. `); process.exit(1); } // Check for --no-obfuscate flag OR FINGERPRINT_OBFUSCATE=false environment variable // Priority: CLI flag > environment variable > default (obfuscate) let skipObfuscation = args.includes('--no-obfuscate'); if (!skipObfuscation && process.env.FINGERPRINT_OBFUSCATE !== undefined) { const envValue = process.env.FINGERPRINT_OBFUSCATE.toLowerCase(); skipObfuscation = envValue === 'false' || envValue === '0' || envValue === 'no'; if (skipObfuscation) { console.log('⚙️ Obfuscation disabled via FINGERPRINT_OBFUSCATE environment variable'); } } // Run the build script const packageDir = path.dirname(__dirname); const buildScript = path.join(packageDir, 'scripts', 'build-custom.js'); // Pass arguments to build script const buildArgs = [ `--key=${key}`, `--package-dir=${packageDir}`, ]; if (skipObfuscation) { buildArgs.push('--no-obfuscate'); } require(buildScript)(buildArgs) .then(() => { // Build completed successfully }) .catch((err) => { console.error('❌ Build failed:', err.message); process.exit(1); }); } else { console.error(`Unknown command: ${command}`); console.log('Run "npx fpscanner --help" for usage information.'); process.exit(1); } ================================================ FILE: examples/nodejs/README.md ================================================ # FPScanner Node.js Demo This example demonstrates how to use fpscanner with a Node.js backend server. ## What it does 1. **Client side**: The HTML page loads the fpscanner library and collects an encrypted fingerprint 2. **Server side**: The Node.js server receives the encrypted fingerprint, decrypts it, and logs the result ## Prerequisites - Node.js installed - fpscanner built with the `dev-key` (or your custom key) ## Setup 1. First, build fpscanner with the dev key (from the fpscanner root directory): ```bash npm run build:obfuscate ``` This builds the library with the `dev-key` encryption key. 2. Navigate to this example directory: ```bash cd examples/nodejs ``` 3. Start the demo server: ```bash node demo-server.js ``` 4. Open your browser and navigate to: ``` http://localhost:3000 ``` ## Expected Output When you open the page, you should see: 1. **In the browser**: A success message showing the fingerprint was received 2. **In the terminal**: The decrypted fingerprint with a summary like: ``` ════════════════════════════════════════════════════════════ 📥 Received fingerprint from client ════════════════════════════════════════════════════════════ 🔓 Decrypted fingerprint: { "signals": { "webdriver": false, "userAgent": "Mozilla/5.0 ...", ... }, "fsid": "FS1_00000_abc123_...", ... } 📊 Summary: FSID: FS1_00000_abc123_... Platform: MacIntel CPU Count: 8 Memory: 16 GB Screen: 1920x1080 Bot Detection: ✓ OK ════════════════════════════════════════════════════════════ ``` ## Using a Custom Encryption Key To use your own encryption key: 1. Build fpscanner with your key: ```bash FINGERPRINT_KEY=your-secret-key npm run build:prod ``` 2. Set the same key when running the server: ```bash FINGERPRINT_KEY=your-secret-key node demo-server.js ``` ## Files - `index.html` - Client-side page that collects and sends the fingerprint - `demo-server.js` - Node.js server that decrypts and logs fingerprints - `README.md` - This file ================================================ FILE: examples/nodejs/demo-server.js ================================================ /** * FPScanner Demo Server (Node.js) * * This server demonstrates how to receive and decrypt fingerprints * collected by fpscanner on the client side. */ const http = require('http'); const fs = require('fs'); const path = require('path'); const PORT = 3000; // The encryption key - must match the key used when building fpscanner // In production, this should come from environment variables const ENCRYPTION_KEY = process.env.FINGERPRINT_KEY || 'dev-key'; /** * Decrypt an XOR-encrypted, base64-encoded string */ function decryptString(ciphertext, key) { const binaryString = Buffer.from(ciphertext, 'base64').toString('binary'); const encrypted = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { encrypted[i] = binaryString.charCodeAt(i); } const keyBytes = Buffer.from(key, 'utf8'); const decrypted = new Uint8Array(encrypted.length); for (let i = 0; i < encrypted.length; i++) { decrypted[i] = encrypted[i] ^ keyBytes[i % keyBytes.length]; } return Buffer.from(decrypted).toString('utf8'); } /** * Decrypt and parse a fingerprint payload */ function decryptFingerprint(encryptedFingerprint) { const decryptedJson = decryptString(encryptedFingerprint, ENCRYPTION_KEY); let parsed = JSON.parse(decryptedJson); // Handle double-JSON-encoding (string containing JSON) if (typeof parsed === 'string') { parsed = JSON.parse(parsed); } return parsed; } // MIME types for serving static files const MIME_TYPES = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', }; const server = http.createServer(async (req, res) => { // Handle fingerprint submission if (req.method === 'POST' && req.url === '/api/fingerprint') { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => { try { const { fingerprint: encryptedFingerprint } = JSON.parse(body); // Decrypt the fingerprint const fingerprint = decryptFingerprint(encryptedFingerprint); // Log the decrypted fingerprint console.log('\n' + '='.repeat(60)); console.log('📥 Received fingerprint from client'); console.log('='.repeat(60)); console.log('\n🔓 Decrypted fingerprint:'); console.log(JSON.stringify(fingerprint, null, 2)); console.log('\n📊 Summary:'); console.log(` FSID: ${fingerprint.fsid}`); console.log(` Platform: ${fingerprint.signals.device.platform}`); console.log(` User Agent: ${fingerprint.signals.browser.userAgent.substring(0, 50)}...`); console.log(` CPU Count: ${fingerprint.signals.device.cpuCount}`); console.log(` Memory: ${fingerprint.signals.device.memory} GB`); console.log(` Screen: ${fingerprint.signals.device.screenResolution.width}x${fingerprint.signals.device.screenResolution.height}`); console.log(` Bot Detection: ${fingerprint.fastBotDetection ? '⚠️ SUSPICIOUS' : '✓ OK'}`); console.log('='.repeat(60) + '\n'); // Send response with full fingerprint res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, fingerprint: fingerprint })); } catch (error) { console.error('❌ Error processing fingerprint:', error.message); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: error.message })); } }); return; } // Serve static files let filePath; if (req.url === '/' || req.url === '/index.html') { filePath = path.join(__dirname, 'index.html'); } else { // Serve files from the fpscanner root (for dist folder access) filePath = path.join(__dirname, '../..', req.url); } const ext = path.extname(filePath); const contentType = MIME_TYPES[ext] || 'text/plain'; fs.readFile(filePath, (err, content) => { if (err) { res.writeHead(404); res.end(`Not found: ${req.url}`); return; } res.writeHead(200, { 'Content-Type': contentType }); res.end(content); }); }); server.listen(PORT, () => { console.log(` ╔════════════════════════════════════════════════════════╗ ║ FPScanner Demo Server (Node.js) ║ ╠════════════════════════════════════════════════════════╣ ║ Server running at: http://localhost:${PORT} ║ ║ Open this URL in your browser to test ║ ╚════════════════════════════════════════════════════════╝ `); }); ================================================ FILE: examples/nodejs/index.html ================================================ FPScanner Demo - Node.js

🔍 FPScanner Demo

This page collects a browser fingerprint and sends it to the Node.js server for decryption.

Collecting fingerprint...
================================================ FILE: examples/python/README.md ================================================ # FPScanner Python Demo This example demonstrates how to use fpscanner with a Python backend server. ## What it does 1. **Client side**: The HTML page loads the fpscanner library and collects an encrypted fingerprint 2. **Server side**: The Python server receives the encrypted fingerprint, decrypts it, and logs the result ## Prerequisites - Python 3.6+ installed - fpscanner built with the `dev-key` (or your custom key) ## Setup 1. First, build fpscanner with the dev key (from the fpscanner root directory): ```bash npm run build:obfuscate ``` This builds the library with the `dev-key` encryption key. 2. Navigate to this example directory: ```bash cd examples/python ``` 3. Start the demo server: ```bash python3 demo-server.py ``` 4. Open your browser and navigate to: ``` http://localhost:3000 ``` ## Expected Output When you open the page, you should see: 1. **In the browser**: A success message showing the fingerprint was received 2. **In the terminal**: The decrypted fingerprint with a summary like: ``` ════════════════════════════════════════════════════════════ 📥 Received fingerprint from client ════════════════════════════════════════════════════════════ 🔓 Decrypted fingerprint: { "signals": { "webdriver": false, "userAgent": "Mozilla/5.0 ...", ... }, "fsid": "FS1_00000_abc123_...", ... } 📊 Summary: FSID: FS1_00000_abc123_... Platform: MacIntel CPU Count: 8 Memory: 16 GB Screen: 1920x1080 Bot Detection: ✓ OK ════════════════════════════════════════════════════════════ ``` ## Using a Custom Encryption Key To use your own encryption key: 1. Build fpscanner with your key: ```bash FINGERPRINT_KEY=your-secret-key npm run build:prod ``` 2. Set the same key when running the server: ```bash FINGERPRINT_KEY=your-secret-key python3 demo-server.py ``` ## Decryption Function The key part of the Python server is the decryption function: ```python import base64 def xor_decrypt(ciphertext_b64: str, key: str) -> str: """Decrypt an XOR-encrypted, base64-encoded string.""" # Decode from base64 encrypted = base64.b64decode(ciphertext_b64) key_bytes = key.encode('utf-8') # XOR decrypt decrypted = bytearray(len(encrypted)) for i in range(len(encrypted)): decrypted[i] = encrypted[i] ^ key_bytes[i % len(key_bytes)] return decrypted.decode('utf-8') ``` This function can be easily integrated into any Python web framework (Flask, Django, FastAPI, etc.). ## Files - `index.html` - Client-side page that collects and sends the fingerprint - `demo-server.py` - Python server that decrypts and logs fingerprints - `README.md` - This file ================================================ FILE: examples/python/demo-server.py ================================================ #!/usr/bin/env python3 """ FPScanner Demo Server (Python) This server demonstrates how to receive and decrypt fingerprints collected by fpscanner on the client side. """ import base64 import json import os from http.server import HTTPServer, SimpleHTTPRequestHandler from urllib.parse import urlparse PORT = 3010 # The encryption key - must match the key used when building fpscanner # In production, this should come from environment variables ENCRYPTION_KEY = os.environ.get('FINGERPRINT_KEY', 'dev-key') def xor_decrypt(ciphertext_b64: str, key: str) -> str: """ Decrypt an XOR-encrypted, base64-encoded string. Args: ciphertext_b64: Base64-encoded encrypted data key: The encryption key (must match the key used for encryption) Returns: Decrypted string """ # Decode from base64 encrypted = base64.b64decode(ciphertext_b64) key_bytes = key.encode('utf-8') # XOR decrypt decrypted = bytearray(len(encrypted)) for i in range(len(encrypted)): decrypted[i] = encrypted[i] ^ key_bytes[i % len(key_bytes)] return decrypted.decode('utf-8') def decrypt_fingerprint(encrypted_fingerprint: str) -> dict: """ Decrypt and parse a fingerprint payload. Args: encrypted_fingerprint: The encrypted fingerprint from the client Returns: Parsed fingerprint dictionary """ decrypted_json = xor_decrypt(encrypted_fingerprint, ENCRYPTION_KEY) parsed = json.loads(decrypted_json) # Handle double-JSON-encoding (string containing JSON) if isinstance(parsed, str): parsed = json.loads(parsed) return parsed class FingerprintHandler(SimpleHTTPRequestHandler): """HTTP request handler for the fingerprint demo.""" def __init__(self, *args, **kwargs): # Set the directory to serve static files from super().__init__(*args, directory=os.path.dirname(os.path.abspath(__file__)), **kwargs) def do_POST(self): """Handle POST requests (fingerprint submission).""" if self.path == '/api/fingerprint': try: # Read the request body content_length = int(self.headers['Content-Length']) body = self.rfile.read(content_length).decode('utf-8') data = json.loads(body) encrypted_fingerprint = data.get('fingerprint') if not encrypted_fingerprint: raise ValueError('No fingerprint provided') # Decrypt the fingerprint fingerprint = decrypt_fingerprint(encrypted_fingerprint) # Log the decrypted fingerprint print('\n' + '=' * 60) print('📥 Received fingerprint from client') print('=' * 60) print('\n🔓 Decrypted fingerprint:') print(json.dumps(fingerprint, indent=2)) print('\n📊 Summary:') print(f" FSID: {fingerprint['fsid']}") print(f" Platform: {fingerprint['signals']['device']['platform']}") print(f" User Agent: {fingerprint['signals']['browser']['userAgent'][:50]}...") print(f" CPU Count: {fingerprint['signals']['device']['cpuCount']}") print(f" Memory: {fingerprint['signals']['device']['memory']} GB") screen = fingerprint['signals']['device']['screenResolution'] print(f" Screen: {screen['width']}x{screen['height']}") bot_status = '⚠️ SUSPICIOUS' if fingerprint['fastBotDetection'] else '✓ OK' print(f' Bot Detection: {bot_status}') print('=' * 60 + '\n') # Send response with full fingerprint response = { 'success': True, 'fingerprint': fingerprint } self._send_json(200, response) except Exception as e: print(f'❌ Error processing fingerprint: {e}') self._send_json(400, {'success': False, 'error': str(e)}) else: self.send_error(404, 'Not Found') def do_GET(self): """Handle GET requests (serve static files).""" parsed = urlparse(self.path) path = parsed.path # Serve index.html for root if path == '/' or path == '/index.html': self.path = '/index.html' return super().do_GET() # Serve files from fpscanner root (for dist folder access) if path.startswith('/dist/'): # Construct path relative to fpscanner root file_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), '../..', path.lstrip('/') ) if os.path.exists(file_path): self.send_response(200) if path.endswith('.js'): self.send_header('Content-Type', 'application/javascript') else: self.send_header('Content-Type', 'application/octet-stream') self.end_headers() with open(file_path, 'rb') as f: self.wfile.write(f.read()) return return super().do_GET() def _send_json(self, status: int, data: dict): """Send a JSON response.""" self.send_response(status) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps(data).encode('utf-8')) def log_message(self, format, *args): """Suppress default logging for cleaner output.""" if 'POST /api/fingerprint' not in format % args: # Only log non-fingerprint requests pass def main(): server = HTTPServer(('', PORT), FingerprintHandler) print(f''' ╔════════════════════════════════════════════════════════╗ ║ FPScanner Demo Server (Python) ║ ╠════════════════════════════════════════════════════════╣ ║ Server running at: http://localhost:{PORT} ║ ║ Open this URL in your browser to test ║ ╚════════════════════════════════════════════════════════╝ ''') try: server.serve_forever() except KeyboardInterrupt: print('\nServer stopped.') if __name__ == '__main__': main() ================================================ FILE: examples/python/index.html ================================================ FPScanner Demo - Python

🐍 FPScanner Demo (Python)

This page collects a browser fingerprint and sends it to the Python server for decryption.

Collecting fingerprint...
================================================ FILE: package.json ================================================ { "name": "fpscanner", "version": "1.0.2", "description": "A lightweight browser fingerprinting and bot detection library with encryption, obfuscation, and cross-context validation", "main": "dist/fpScanner.cjs.js", "module": "dist/fpScanner.es.js", "types": "dist/index.d.ts", "bin": { "fpscanner": "bin/cli.js" }, "files": [ "dist", "src", "bin", "scripts" ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly", "build:vite": "vite build", "build:dev": "node bin/cli.js build --key=dev-key --no-obfuscate", "build:prod": "node bin/cli.js build", "build:prod:plain": "node bin/cli.js build --no-obfuscate", "build:obfuscate": "node bin/cli.js build --key=dev-key", "watch": "concurrently \"FP_ENCRYPTION_KEY=dev-key vite build --watch\" \"tsc --watch --emitDeclarationOnly\"", "dev": "vite", "dev:build": "npm run build:dev && vite", "dev:obfuscate": "npm run build:obfuscate && vite", "test": "npm run test:playwright", "test:vitest": "vitest", "test:playwright": "npm run build:obfuscate && npx playwright test", "test:playwright:headed": "npm run build:obfuscate && npx playwright test --headed", "prepublishOnly": "node scripts/verify-publish.js", "publish:beta": "node scripts/safe-publish.js beta", "publish:stable": "node scripts/safe-publish.js stable" }, "repository": { "type": "git", "url": "git+https://github.com/antoinevastel/fpscanner.git" }, "keywords": [ "fingerprinting", "browser-fingerprint", "bot-detection", "automation-detection", "fraud-detection", "selenium", "puppeteer", "playwright", "webdriver", "headless", "anti-bot", "device-fingerprint", "browser-detection", "security" ], "author": "antoinevastel ", "license": "MIT", "bugs": { "url": "https://github.com/antoinevastel/fpscanner/issues" }, "homepage": "https://github.com/antoinevastel/fpscanner", "dependencies": { "javascript-obfuscator": "^5.1.0", "terser": "^5.46.0", "ua-parser-js": "^0.7.18" }, "devDependencies": { "@playwright/test": "^1.57.0", "@vitest/ui": "^4.0.16", "chai": "^4.2.0", "concurrently": "^9.2.1", "fpcollect": "^1.0.4", "mocha": "^11.7.5", "puppeteer": "^1.9.0", "typescript": "^5.9.3", "vite": "^7.3.0", "vitest": "^4.0.16" } } ================================================ FILE: playwright.config.ts ================================================ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './test', testMatch: '**/*.spec.ts', timeout: 30000, retries: 0, fullyParallel: true, workers: process.env.CI ? 1 : 4, use: { headless: true, }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, { name: 'mobile-chromium', use: { ...devices['Pixel 5'] }, }, ], webServer: { command: 'node test/server.js', port: 3333, reuseExistingServer: !process.env.CI, timeout: 10000, }, }); ================================================ FILE: scripts/build-custom.js ================================================ #!/usr/bin/env node const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); /** * Build fpscanner with a custom encryption key and optional obfuscation. * This script: * 1. Runs Vite build with the key injected via environment variable * 2. Generates TypeScript declarations * 3. Optionally obfuscates the output * 4. Minifies with Terser (when obfuscating) * 5. Removes source maps (when obfuscating) */ module.exports = async function build(args) { // Parse arguments const keyArg = args.find(a => a.startsWith('--key=')); const packageDirArg = args.find(a => a.startsWith('--package-dir=')); const skipObfuscation = args.includes('--no-obfuscate'); if (!keyArg) { throw new Error('Missing --key argument'); } const key = keyArg.split('=').slice(1).join('='); const packageDir = packageDirArg ? packageDirArg.split('=')[1] : path.dirname(__dirname); const distDir = path.join(packageDir, 'dist'); const files = ['fpScanner.es.js', 'fpScanner.cjs.js']; const sentinel = '__DEFAULT_FPSCANNER_KEY__'; console.log(''); console.log('🔨 Building fpscanner with custom key...'); console.log(` Package: ${packageDir}`); console.log(` Output: ${distDir}`); console.log(` Obfuscation: ${skipObfuscation ? 'disabled' : 'enabled'}`); console.log(''); // Step 0: Backup/Restore mechanism to ensure idempotent builds // This allows running the build multiple times without re-obfuscating already obfuscated code console.log('🔄 Step 0/6: Ensuring clean build state...'); let restoredFromBackup = false; for (const file of files) { const filePath = path.join(distDir, file); const backupPath = filePath + '.original'; if (!fs.existsSync(filePath)) { continue; } if (fs.existsSync(backupPath)) { // Backup exists - restore from it to ensure clean state fs.copyFileSync(backupPath, filePath); restoredFromBackup = true; } else { // First run - create backup of pristine files fs.copyFileSync(filePath, backupPath); console.log(` 📦 Created backup: ${file}.original`); } } if (restoredFromBackup) { console.log(' ✓ Restored files from backups (clean state for build)'); } console.log(''); // Check if we can build from source (more reliable than string replacement) const viteConfigPath = path.join(packageDir, 'vite.config.ts'); const canBuildFromSource = fs.existsSync(viteConfigPath); if (canBuildFromSource) { // Preferred method: Build from source with key injected via environment variable // This is more reliable as Vite's define properly replaces the key during the build console.log('📦 Step 1/6: Building from source with injected key...'); console.log(' (This is more reliable than post-build string replacement)'); try { execSync('npm run build:vite', { cwd: packageDir, stdio: 'inherit', env: { ...process.env, FP_ENCRYPTION_KEY: key, }, }); console.log(''); console.log('📝 Generating TypeScript declarations...'); execSync('npx tsc --emitDeclarationOnly', { cwd: packageDir, stdio: 'inherit', }); console.log(''); console.log(' ✓ Key injected during build'); } catch (err) { throw new Error('Build from source failed. Make sure vite is installed (npm install)'); } } else { // Fallback method: String replacement in pre-built dist files // Used when vite.config.ts is not available (npm consumers without dev dependencies) console.log('📦 Step 1/6: Injecting encryption key via string replacement...'); console.log(' (Fallback method - vite.config.ts not found)'); let keyInjected = false; for (const file of files) { const filePath = path.join(distDir, file); if (!fs.existsSync(filePath)) { console.log(` ⚠️ ${file} not found, skipping`); continue; } let code = fs.readFileSync(filePath, 'utf8'); // Check if sentinel exists if (!code.includes(sentinel)) { console.log(` ⚠️ ${file} does not contain the default key sentinel`); console.log(` Key may have already been replaced, or dist needs to be rebuilt`); continue; } // Replace all occurrences of the sentinel with the actual key const escapedSentinel = sentinel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const newCode = code.replace(new RegExp(`"${escapedSentinel}"`, 'g'), JSON.stringify(key)); // Verify the replacement worked if (newCode === code) { console.log(` ⚠️ ${file} - replacement had no effect`); continue; } if (newCode.includes(sentinel)) { console.log(` ⚠️ ${file} - sentinel still present after replacement`); continue; } fs.writeFileSync(filePath, newCode); keyInjected = true; console.log(` ✓ ${file}`); } if (!keyInjected) { console.log(' ⚠️ Warning: No files were updated'); console.log(' The key may have already been injected, or dist files may need rebuilding'); } } // Step 2: Skip TypeScript declarations (already generated) console.log(''); console.log('⏭️ Step 2/6: TypeScript declarations already present, skipping...'); // Step 3: Obfuscate (optional) if (!skipObfuscation) { console.log(''); console.log('🔒 Step 3/6: Obfuscating output...'); let JavaScriptObfuscator; try { JavaScriptObfuscator = require('javascript-obfuscator'); } catch (err) { console.log(' ⚠️ javascript-obfuscator not installed, skipping obfuscation'); console.log(' To enable obfuscation, run: npm install --save-dev javascript-obfuscator'); skipObfuscation = true; } if (JavaScriptObfuscator) { const files = ['fpScanner.es.js', 'fpScanner.cjs.js']; for (const file of files) { const filePath = path.join(distDir, file); if (!fs.existsSync(filePath)) { console.log(` ⚠️ ${file} not found, skipping`); continue; } const code = fs.readFileSync(filePath, 'utf8'); const obfuscated = JavaScriptObfuscator.obfuscate(code, { compact: true, controlFlowFlattening: true, controlFlowFlatteningThreshold: 0.4, deadCodeInjection: true, deadCodeInjectionThreshold: 0.1, stringArray: true, stringArrayThreshold: 0.95, stringArrayEncoding: ['rc4'], transformObjectKeys: true, unicodeEscapeSequence: false, // Preserve functionality selfDefending: false, disableConsoleOutput: false, }); fs.writeFileSync(filePath, obfuscated.getObfuscatedCode()); console.log(` ✓ ${file}`); } // Step 4: Minify with Terser console.log(''); console.log('📦 Step 4/6: Minifying with Terser...'); let terser; try { terser = require('terser'); } catch (err) { console.log(' ⚠️ terser not installed, skipping minification'); console.log(' To enable minification, run: npm install --save-dev terser'); } if (terser) { for (const file of files) { const filePath = path.join(distDir, file); if (!fs.existsSync(filePath)) { continue; } const code = fs.readFileSync(filePath, 'utf8'); const minified = await terser.minify(code, { compress: { drop_console: false, dead_code: true, unused: true, }, mangle: { toplevel: true, }, format: { comments: false, }, }); if (minified.error) { console.log(` ⚠️ Failed to minify ${file}: ${minified.error}`); } else { fs.writeFileSync(filePath, minified.code); console.log(` ✓ ${file}`); } } } // Step 5: Delete all source map files so DevTools can't show original source console.log(''); console.log('🗑️ Step 5/6: Removing source maps...'); function deleteMapFiles(dir, prefix = '') { if (!fs.existsSync(dir)) { return; } const files = fs.readdirSync(dir); for (const file of files) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { deleteMapFiles(fullPath, prefix + file + '/'); } else if (file.endsWith('.map')) { fs.unlinkSync(fullPath); console.log(` ✓ Deleted ${prefix}${file}`); } } } deleteMapFiles(distDir); } } else { console.log(''); console.log('⏭️ Steps 3-5/6: Skipping obfuscation, minification, and source map removal (--no-obfuscate)'); } // Step 6: Note about backups console.log(''); console.log('💡 Note: Original files backed up as *.original for future rebuilds'); console.log(''); console.log('✅ Build complete!'); console.log(''); console.log(' Your custom fpscanner build is ready in:'); console.log(` ${distDir}`); console.log(''); console.log(' Import it in your code:'); console.log(" import FingerprintScanner from 'fpscanner';"); console.log(''); }; // Allow running directly if (require.main === module) { const args = process.argv.slice(2); module.exports(args) .catch((err) => { console.error('❌ Build failed:', err.message); process.exit(1); }); } ================================================ FILE: scripts/safe-publish.js ================================================ #!/usr/bin/env node const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const publishType = process.argv[2]; // 'beta' or 'stable' if (!publishType || !['beta', 'stable'].includes(publishType)) { console.error('❌ Usage: npm run publish:beta or npm run publish:stable'); process.exit(1); } const ROOT_DIR = path.resolve(__dirname, '..'); const DIST_DIR = path.join(ROOT_DIR, 'dist'); function run(command, description) { console.log(`\n🔄 ${description}...`); try { execSync(command, { cwd: ROOT_DIR, stdio: 'inherit' }); console.log(`✅ ${description} - Done`); } catch (error) { console.error(`❌ ${description} - Failed`); process.exit(1); } } function checkGitStatus() { console.log('\n🔍 Checking git status...'); try { const status = execSync('git status --porcelain', { cwd: ROOT_DIR, encoding: 'utf8' }); if (status.trim()) { console.error('❌ Git working directory is not clean. Please commit or stash changes first.'); console.error('\nUncommitted changes:'); console.error(status); process.exit(1); } console.log('✅ Git working directory is clean'); } catch (error) { console.error('❌ Failed to check git status'); process.exit(1); } } function getPackageVersion() { const packageJson = require(path.join(ROOT_DIR, 'package.json')); return packageJson.version; } console.log('═══════════════════════════════════════════════════════════'); console.log(`🚀 Safe Publish Script - ${publishType.toUpperCase()}`); console.log('═══════════════════════════════════════════════════════════'); // Step 0: Check git status checkGitStatus(); // Step 1: Clean dist directory console.log('\n🧹 Cleaning dist directory...'); if (fs.existsSync(DIST_DIR)) { fs.rmSync(DIST_DIR, { recursive: true, force: true }); console.log('✅ Dist directory cleaned'); } else { console.log('✅ Dist directory already clean'); } // Step 2: Build package with sentinel key (no key injection) run('npm run build', 'Building package with sentinel key'); // Step 3: Verify build run('node scripts/verify-publish.js', 'Verifying build integrity'); // Step 4: Get version and confirm const version = getPackageVersion(); console.log(`\n📦 Package version: ${version}`); if (publishType === 'beta' && !version.includes('beta')) { console.error(`❌ Version ${version} is not a beta version. Use publish:stable instead.`); process.exit(1); } if (publishType === 'stable' && version.includes('beta')) { console.error(`❌ Version ${version} is a beta version. Use publish:beta instead.`); process.exit(1); } // Step 5: Publish const publishTag = publishType === 'beta' ? '--tag beta' : ''; run(`npm publish ${publishTag}`, `Publishing to npm with ${publishType} tag`); // Step 6: Create git tag const gitTag = `v${version}`; console.log(`\n🏷️ Creating git tag: ${gitTag}...`); try { execSync(`git tag ${gitTag}`, { cwd: ROOT_DIR, stdio: 'inherit' }); console.log(`✅ Git tag created: ${gitTag}`); console.log('\n💡 Don\'t forget to push the tag:'); console.log(` git push origin ${gitTag}`); } catch (error) { console.log(`⚠️ Git tag might already exist: ${gitTag}`); } console.log('\n═══════════════════════════════════════════════════════════'); console.log('✅ PUBLISH SUCCESSFUL!'); console.log('═══════════════════════════════════════════════════════════'); console.log(`\n📦 Published: fpscanner@${version}`); console.log(`🏷️ Tag: ${publishType}`); console.log('\n📝 Next steps:'); console.log(` 1. Push git tag: git push origin ${gitTag}`); console.log(` 2. Test installation: npm install fpscanner@${publishType}`); console.log('═══════════════════════════════════════════════════════════\n'); ================================================ FILE: scripts/verify-publish.js ================================================ #!/usr/bin/env node /** * Pre-publish verification script * Ensures the dist files contain the sentinel key for npm consumers */ const fs = require('fs'); const path = require('path'); const distDir = path.join(__dirname, '..', 'dist'); const files = ['fpScanner.es.js', 'fpScanner.cjs.js']; const sentinel = '__DEFAULT_FPSCANNER_KEY__'; console.log('🔍 Verifying dist files before publish...\n'); let allPassed = true; for (const file of files) { const filePath = path.join(distDir, file); if (!fs.existsSync(filePath)) { console.log(`❌ ${file}: File not found`); allPassed = false; continue; } const content = fs.readFileSync(filePath, 'utf8'); const matches = content.match(new RegExp(`"${sentinel}"`, 'g')); if (!matches || matches.length === 0) { console.log(`❌ ${file}: Sentinel key "${sentinel}" NOT found`); console.log(` This file cannot be published - consumers won't be able to inject their keys!`); allPassed = false; } else { console.log(`✅ ${file}: Sentinel key found (${matches.length} occurrence${matches.length > 1 ? 's' : ''})`); } } console.log(''); if (allPassed) { console.log('✅ All checks passed! Safe to publish.'); process.exit(0); } else { console.log('❌ Verification failed!'); console.log(''); console.log('To fix:'); console.log(' 1. Make sure FP_ENCRYPTION_KEY is NOT set in your environment'); console.log(' 2. Run: rm -rf dist && npm run build'); console.log(' 3. Run this script again to verify'); console.log(''); process.exit(1); } ================================================ FILE: src/crypto-helpers.ts ================================================ /** * Simple and fast XOR-based encryption/decryption * Note: This is NOT cryptographically secure - use only for obfuscation */ /** * Encrypts a string using XOR cipher with the provided key * @param plaintext - The string to encrypt * @param key - The encryption key as a string * @returns Encrypted string (base64 encoded) */ export async function encryptString(plaintext: string, key: string): Promise { const keyBytes = new TextEncoder().encode(key); const textBytes = new TextEncoder().encode(plaintext); const encrypted = new Uint8Array(textBytes.length); for (let i = 0; i < textBytes.length; i++) { encrypted[i] = textBytes[i] ^ keyBytes[i % keyBytes.length]; } // Convert to base64 for safe string representation const binaryString = String.fromCharCode(...encrypted); return btoa(binaryString); } /** * Decrypts a string that was encrypted with encryptString * @param ciphertext - The encrypted string (base64 encoded) * @param key - The decryption key as a string (must match encryption key) * @returns Decrypted string */ export async function decryptString(ciphertext: string, key: string): Promise { // Decode from base64 const binaryString = atob(ciphertext); const encrypted = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { encrypted[i] = binaryString.charCodeAt(i); } const keyBytes = new TextEncoder().encode(key); const decrypted = new Uint8Array(encrypted.length); // XOR is symmetric, so decryption is the same as encryption for (let i = 0; i < encrypted.length; i++) { decrypted[i] = encrypted[i] ^ keyBytes[i % keyBytes.length]; } return new TextDecoder().decode(decrypted); } ================================================ FILE: src/detections/hasBotUserAgent.ts ================================================ import { Fingerprint } from "../types"; export function hasBotUserAgent(fingerprint: Fingerprint) { const userAgents = [ fingerprint.signals.browser.userAgent, fingerprint.signals.contexts.iframe.userAgent, fingerprint.signals.contexts.webWorker.userAgent, ]; return userAgents.some(userAgent => /bot|headless/i.test(userAgent.toLowerCase())); } ================================================ FILE: src/detections/hasCDP.ts ================================================ import { Fingerprint } from "../types"; export function hasCDP(fingerprint: Fingerprint) { return fingerprint.signals.automation.cdp === true; } ================================================ FILE: src/detections/hasContextMismatch.ts ================================================ import { Fingerprint } from "../types"; // Not used as a detection rule since, more like an indicator export function hasContextMismatch(fingerprint: Fingerprint, context: 'iframe' | 'worker'): boolean { const s = fingerprint.signals; if (context === 'iframe') { return s.contexts.iframe.webdriver !== s.automation.webdriver || s.contexts.iframe.userAgent !== s.browser.userAgent || s.contexts.iframe.platform !== s.device.platform || s.contexts.iframe.memory !== s.device.memory || s.contexts.iframe.cpuCount !== s.device.cpuCount; } else { return s.contexts.webWorker.webdriver !== s.automation.webdriver || s.contexts.webWorker.userAgent !== s.browser.userAgent || s.contexts.webWorker.platform !== s.device.platform || s.contexts.webWorker.memory !== s.device.memory || s.contexts.webWorker.cpuCount !== s.device.cpuCount; } } ================================================ FILE: src/detections/hasGPUMismatch.ts ================================================ import { Fingerprint } from "../types"; // For the moment, we only detect GPU mismatches related to Apple OS/GPU export function hasGPUMismatch(fingerprint: Fingerprint) { const gpu = fingerprint.signals.graphics.webgpu; const webGL = fingerprint.signals.graphics.webGL; const userAgent = fingerprint.signals.browser.userAgent; // Inconsistencies around Apple OS/GPU if ((webGL.vendor.includes('Apple') || webGL.renderer.includes('Apple')) && !userAgent.includes('Mac')) { return true; } if (gpu.vendor.includes('apple') && !userAgent.includes('Mac')) { return true; } if (gpu.vendor.includes('apple') && !webGL.renderer.includes('Apple')) { return true; } return false; } ================================================ FILE: src/detections/hasHeadlessChromeScreenResolution.ts ================================================ import { Fingerprint } from '../types'; export function hasHeadlessChromeScreenResolution(fingerprint: Fingerprint) { const screen = fingerprint.signals.device.screenResolution; return (screen.width === 800 && screen.height === 600) || (screen.availableWidth === 800 && screen.availableHeight === 600) || (screen.innerWidth === 800 && screen.innerHeight === 600); } ================================================ FILE: src/detections/hasHighCPUCount.ts ================================================ import { Fingerprint } from "../types"; export function hasHighCPUCount(fingerprint: Fingerprint) { if (typeof fingerprint.signals.device.cpuCount !== 'number') { return false; } return fingerprint.signals.device.cpuCount > 70; } ================================================ FILE: src/detections/hasImpossibleDeviceMemory.ts ================================================ import { Fingerprint } from "../types"; export function hasImpossibleDeviceMemory(fingerprint: Fingerprint) { if (typeof fingerprint.signals.device.memory !== 'number') { return false; } return (fingerprint.signals.device.memory > 32 || fingerprint.signals.device.memory < 0.25); } ================================================ FILE: src/detections/hasInconsistentEtsl.ts ================================================ import { Fingerprint } from "../types"; export function hasInconsistentEtsl(fingerprint: Fingerprint) { // On Chromium-based browsers, ETSL should be 33 if (fingerprint.signals.browser.features.chrome && fingerprint.signals.browser.etsl !== 33) { return true; } // On Safari, ETSL should be 37 if (fingerprint.signals.browser.features.safari && fingerprint.signals.browser.etsl !== 37) { return true; } // On Firefox, ETSL should be 37 if (fingerprint.signals.browser.userAgent.includes('Firefox') && fingerprint.signals.browser.etsl !== 37) { return true; } return false; } ================================================ FILE: src/detections/hasMismatchLanguages.ts ================================================ import { Fingerprint } from "../types"; export function hasMismatchLanguages(fingerprint: Fingerprint) { const languages = fingerprint.signals.locale.languages.languages; const language = fingerprint.signals.locale.languages.language; if (language && languages && Array.isArray(languages) && languages.length > 0) { return languages[0] !== language; } return false; } ================================================ FILE: src/detections/hasMismatchPlatformIframe.ts ================================================ import { Fingerprint } from "../types"; import { ERROR, NA } from "../signals/utils"; export function hasMismatchPlatformIframe(fingerprint: Fingerprint) { if (fingerprint.signals.contexts.iframe.platform === NA || fingerprint.signals.contexts.iframe.platform === ERROR) { return false; } return fingerprint.signals.device.platform !== fingerprint.signals.contexts.iframe.platform; } ================================================ FILE: src/detections/hasMismatchPlatformWorker.ts ================================================ import { Fingerprint } from "../types"; import { ERROR, NA, SKIPPED } from "../signals/utils"; export function hasMismatchPlatformWorker(fingerprint: Fingerprint) { if (fingerprint.signals.contexts.webWorker.platform === NA || fingerprint.signals.contexts.webWorker.platform === ERROR || fingerprint.signals.contexts.webWorker.platform === SKIPPED) { return false; } return fingerprint.signals.device.platform !== fingerprint.signals.contexts.webWorker.platform; } ================================================ FILE: src/detections/hasMismatchWebGLInWorker.ts ================================================ import { Fingerprint } from "../types"; import { ERROR, NA, SKIPPED } from "../signals/utils"; export function hasMismatchWebGLInWorker(fingerprint: Fingerprint) { const worker = fingerprint.signals.contexts.webWorker; const webGL = fingerprint.signals.graphics.webGL; if (worker.vendor === ERROR || worker.renderer === ERROR || webGL.vendor === NA || webGL.renderer === NA || worker.vendor === SKIPPED) { return false; } return worker.vendor !== webGL.vendor || worker.renderer !== webGL.renderer; } ================================================ FILE: src/detections/hasMissingChromeObject.ts ================================================ import { Fingerprint } from "../types"; export function hasMissingChromeObject(fingerprint: Fingerprint) { const userAgent = fingerprint.signals.browser.userAgent; return fingerprint.signals.browser.features.chrome === false && typeof userAgent === 'string' && userAgent.includes('Chrome'); } ================================================ FILE: src/detections/hasPlatformMismatch.ts ================================================ import { Fingerprint } from "../types"; import { ERROR, NA } from "../signals/utils"; export function hasPlatformMismatch(fingerprint: Fingerprint) { const platform = fingerprint.signals.device.platform; const userAgent = fingerprint.signals.browser.userAgent; const highEntropyPlatform = fingerprint.signals.browser.highEntropyValues.platform; if (userAgent.includes('Mac') && (platform.includes('Win') || platform.includes('Linux'))) { return true; } if (userAgent.includes('Windows') && (platform.includes('Mac') || platform.includes('Linux'))) { return true; } if (userAgent.includes('Linux') && (platform.includes('Mac') || platform.includes('Win'))) { return true; } // Check applied only if highEntropyPlatform is not ERROR or NA if (highEntropyPlatform !== ERROR && highEntropyPlatform !== NA) { if (highEntropyPlatform.includes('Mac') && (platform.includes('Win') || platform.includes('Linux'))) { return true; } if (highEntropyPlatform.includes('Windows') && (platform.includes('Mac') || platform.includes('Linux'))) { return true; } if (highEntropyPlatform.includes('Linux') && (platform.includes('Mac') || platform.includes('Win'))) { return true; } } return false; } ================================================ FILE: src/detections/hasPlaywright.ts ================================================ import { Fingerprint } from "../types"; export function hasPlaywright(fingerprint: Fingerprint) { return fingerprint.signals.automation.playwright === true; } ================================================ FILE: src/detections/hasSeleniumProperty.ts ================================================ import { Fingerprint } from "../types"; export function hasSeleniumProperty(fingerprint: Fingerprint) { return !!fingerprint.signals.automation.selenium; } ================================================ FILE: src/detections/hasSwiftshaderRenderer.ts ================================================ import { Fingerprint } from "../types"; export function hasSwiftshaderRenderer(fingerprint: Fingerprint) { return fingerprint.signals.graphics.webGL.renderer.includes('SwiftShader'); } ================================================ FILE: src/detections/hasUTCTimezone.ts ================================================ import { Fingerprint } from "../types"; export function hasUTCTimezone(fingerprint: Fingerprint) { return fingerprint.signals.locale.internationalization.timezone === 'UTC'; } ================================================ FILE: src/detections/hasWebdriver.ts ================================================ import { Fingerprint } from "../types"; export function hasWebdriver(fingerprint: Fingerprint) { return fingerprint.signals.automation.webdriver === true; } ================================================ FILE: src/detections/hasWebdriverIframe.ts ================================================ import { Fingerprint } from "../types"; export function hasWebdriverIframe(fingerprint: Fingerprint) { return fingerprint.signals.contexts.iframe.webdriver === true; } ================================================ FILE: src/detections/hasWebdriverWorker.ts ================================================ import { Fingerprint } from "../types"; export function hasWebdriverWorker(fingerprint: Fingerprint) { return fingerprint.signals.contexts.webWorker.webdriver === true; } ================================================ FILE: src/detections/hasWebdriverWritable.ts ================================================ import { Fingerprint } from "../types"; export function hasWebdriverWritable(fingerprint: Fingerprint) { return fingerprint.signals.automation.webdriverWritable === true; } ================================================ FILE: src/globals.d.ts ================================================ /** * Build-time constant injected via Vite's define option. * This is replaced with the actual encryption key during the build process. * * Customers provide their key via: * - npx fpscanner build --key=their-key * - FINGERPRINT_KEY environment variable * - .env file with FINGERPRINT_KEY=their-key */ declare const __FP_ENCRYPTION_KEY__: string; ================================================ FILE: src/index.ts ================================================ // Import all signals import { webdriver } from './signals/webdriver'; import { userAgent } from './signals/userAgent'; import { platform } from './signals/platform'; import { cdp } from './signals/cdp'; import { webGL } from './signals/webGL'; import { playwright } from './signals/playwright'; import { cpuCount } from './signals/cpuCount'; import { maths } from './signals/maths'; import { memory } from './signals/memory'; import { etsl } from './signals/etsl'; import { internationalization } from './signals/internationalization'; import { screenResolution } from './signals/screenResolution'; import { languages } from './signals/languages'; import { webgpu } from './signals/webgpu'; import { hasSeleniumProperties } from './signals/seleniumProperties'; import { webdriverWritable } from './signals/webdriverWritable'; import { highEntropyValues } from './signals/highEntropyValues'; import { plugins } from './signals/plugins'; import { multimediaDevices } from './signals/multimediaDevices'; import { iframe } from './signals/iframe'; import { worker } from './signals/worker'; import { toSourceError } from './signals/toSourceError'; import { mediaCodecs } from './signals/mediaCodecs'; import { canvas } from './signals/canvas'; import { navigatorPropertyDescriptors } from './signals/navigatorPropertyDescriptors'; import { nonce } from './signals/nonce'; import { time } from './signals/time'; import { pageURL } from './signals/url'; import { hasContextMismatch } from './detections/hasContextMismatch'; import { browserExtensions } from './signals/browserExtensions'; import { browserFeatures } from './signals/browserFeatures'; import { mediaQueries } from './signals/mediaQueries'; // Fast Bot Detection tests import { hasHeadlessChromeScreenResolution } from './detections/hasHeadlessChromeScreenResolution'; import { hasWebdriver } from './detections/hasWebdriver'; import { hasSeleniumProperty } from './detections/hasSeleniumProperty'; import { hasCDP } from './detections/hasCDP'; import { hasPlaywright } from './detections/hasPlaywright'; import { hasImpossibleDeviceMemory } from './detections/hasImpossibleDeviceMemory'; import { hasHighCPUCount } from './detections/hasHighCPUCount'; import { hasMissingChromeObject } from './detections/hasMissingChromeObject'; import { hasWebdriverIframe } from './detections/hasWebdriverIframe'; import { hasWebdriverWorker } from './detections/hasWebdriverWorker'; import { hasMismatchWebGLInWorker } from './detections/hasMismatchWebGLInWorker'; import { hasMismatchPlatformWorker } from './detections/hasMismatchPlatformWorker'; import { hasMismatchPlatformIframe } from './detections/hasMismatchPlatformIframe'; import { hasWebdriverWritable } from './detections/hasWebdriverWritable'; import { hasSwiftshaderRenderer } from './detections/hasSwiftshaderRenderer'; import { hasUTCTimezone } from './detections/hasUTCTimezone'; import { hasMismatchLanguages } from './detections/hasMismatchLanguages'; import { hasInconsistentEtsl } from './detections/hasInconsistentEtsl'; import { hasBotUserAgent } from './detections/hasBotUserAgent'; import { hasGPUMismatch } from './detections/hasGPUMismatch'; import { hasPlatformMismatch } from './detections/hasPlatformMismatch'; import { ERROR, HIGH, INIT, LOW, MEDIUM, SKIPPED, hashCode } from './signals/utils'; import { encryptString } from './crypto-helpers'; import { Fingerprint, FastBotDetectionDetails, DetectionRule, CollectFingerprintOptions } from './types'; class FingerprintScanner { private fingerprint: Fingerprint; constructor() { this.fingerprint = { signals: { // Automation/Bot detection signals automation: { webdriver: INIT, webdriverWritable: INIT, selenium: INIT, cdp: INIT, playwright: INIT, navigatorPropertyDescriptors: INIT, }, // Device hardware characteristics device: { cpuCount: INIT, memory: INIT, platform: INIT, screenResolution: { width: INIT, height: INIT, pixelDepth: INIT, colorDepth: INIT, availableWidth: INIT, availableHeight: INIT, innerWidth: INIT, innerHeight: INIT, hasMultipleDisplays: INIT, }, multimediaDevices: { speakers: INIT, microphones: INIT, webcams: INIT, }, mediaQueries: { prefersColorScheme: INIT, prefersReducedMotion: INIT, prefersReducedTransparency: INIT, colorGamut: INIT, pointer: INIT, anyPointer: INIT, hover: INIT, anyHover: INIT, colorDepth: INIT, }, }, // Browser identity & features browser: { userAgent: INIT, features: { bitmask: INIT, chrome: INIT, brave: INIT, applePaySupport: INIT, opera: INIT, serial: INIT, attachShadow: INIT, caches: INIT, webAssembly: INIT, buffer: INIT, showModalDialog: INIT, safari: INIT, webkitPrefixedFunction: INIT, mozPrefixedFunction: INIT, usb: INIT, browserCapture: INIT, paymentRequestUpdateEvent: INIT, pressureObserver: INIT, audioSession: INIT, selectAudioOutput: INIT, barcodeDetector: INIT, battery: INIT, devicePosture: INIT, documentPictureInPicture: INIT, eyeDropper: INIT, editContext: INIT, fencedFrame: INIT, sanitizer: INIT, otpCredential: INIT, }, plugins: { isValidPluginArray: INIT, pluginCount: INIT, pluginNamesHash: INIT, pluginConsistency1: INIT, pluginOverflow: INIT, }, extensions: { bitmask: INIT, extensions: INIT, }, highEntropyValues: { architecture: INIT, bitness: INIT, brands: INIT, mobile: INIT, model: INIT, platform: INIT, platformVersion: INIT, uaFullVersion: INIT, }, etsl: INIT, maths: INIT, toSourceError: { toSourceError: INIT, hasToSource: INIT, }, }, // Graphics & rendering graphics: { webGL: { vendor: INIT, renderer: INIT, }, webgpu: { vendor: INIT, architecture: INIT, device: INIT, description: INIT, }, canvas: { hasModifiedCanvas: INIT, canvasFingerprint: INIT, }, }, // Media codecs (at root level) codecs: { audioCanPlayTypeHash: INIT, videoCanPlayTypeHash: INIT, audioMediaSourceHash: INIT, videoMediaSourceHash: INIT, rtcAudioCapabilitiesHash: INIT, rtcVideoCapabilitiesHash: INIT, hasMediaSource: INIT, }, // Locale & internationalization locale: { internationalization: { timezone: INIT, localeLanguage: INIT, }, languages: { languages: INIT, language: INIT, }, }, // Isolated execution contexts contexts: { iframe: { webdriver: INIT, userAgent: INIT, platform: INIT, memory: INIT, cpuCount: INIT, language: INIT, }, webWorker: { webdriver: INIT, userAgent: INIT, platform: INIT, memory: INIT, cpuCount: INIT, language: INIT, vendor: INIT, renderer: INIT, }, }, }, fsid: INIT, nonce: INIT, time: INIT, url: INIT, fastBotDetection: false, fastBotDetectionDetails: { headlessChromeScreenResolution: { detected: false, severity: 'high' }, hasWebdriver: { detected: false, severity: 'high' }, hasWebdriverWritable: { detected: false, severity: 'high' }, hasSeleniumProperty: { detected: false, severity: 'high' }, hasCDP: { detected: false, severity: 'high' }, hasPlaywright: { detected: false, severity: 'high' }, hasImpossibleDeviceMemory: { detected: false, severity: 'high' }, hasHighCPUCount: { detected: false, severity: 'high' }, hasMissingChromeObject: { detected: false, severity: 'high' }, hasWebdriverIframe: { detected: false, severity: 'high' }, hasWebdriverWorker: { detected: false, severity: 'high' }, hasMismatchWebGLInWorker: { detected: false, severity: 'high' }, hasMismatchPlatformIframe: { detected: false, severity: 'high' }, hasMismatchPlatformWorker: { detected: false, severity: 'high' }, hasSwiftshaderRenderer: { detected: false, severity: 'low' }, hasUTCTimezone: { detected: false, severity: 'medium' }, hasMismatchLanguages: { detected: false, severity: 'low' }, hasInconsistentEtsl: { detected: false, severity: 'high' }, hasBotUserAgent: { detected: false, severity: 'high' }, hasGPUMismatch: { detected: false, severity: 'high' }, hasPlatformMismatch: { detected: false, severity: 'high' }, }, }; } private async collectSignal(signal: () => any) { try { return await signal(); } catch (e) { return ERROR; } } /** * Generate a JA4-inspired fingerprint scanner ID * Format: FS1________ * * Each section is delimited by '_', allowing partial matching. * Sections use the pattern: h where applicable. * Bitmasks are extensible - new boolean fields are appended without breaking existing positions. * * Sections: * - det: fastBotDetectionDetails bitmask (21 bits: headlessChromeScreenResolution, hasWebdriver, * hasWebdriverWritable, hasSeleniumProperty, hasCDP, hasPlaywright, hasImpossibleDeviceMemory, * hasHighCPUCount, hasMissingChromeObject, hasWebdriverIframe, hasWebdriverWorker, * hasMismatchWebGLInWorker, hasMismatchPlatformIframe, hasMismatchPlatformWorker, * hasMismatchLanguages, hasInconsistentEtsl, hasBotUserAgent, hasGPUMismatch, hasPlatformMismatch) * - auto: automation bitmask (5 bits: webdriver, webdriverWritable, selenium, cdp, playwright) + hash * - dev: WIDTHxHEIGHT + cpu + mem + device bitmask + hash of all device signals * - brw: features.bitmask + extensions.bitmask + plugins bitmask (3 bits) + hash of browser signals * - gfx: canvas bitmask (1 bit: hasModifiedCanvas) + hash of all graphics signals * - cod: codecs bitmask (1 bit: hasMediaSource) + hash of all codec hashes * - loc: language code (2 chars) + language count + hash of locale signals * - ctx: context mismatch bitmask (2 bits: iframe, worker) + hash of all context signals */ private generateFingerprintScannerId(): string { try { const s = this.fingerprint.signals; const det = this.fingerprint.fastBotDetectionDetails; // Section 1: Version const version = 'FS1'; // Section 2: Detection bitmask - all 21 fastBotDetectionDetails booleans // Order matches FastBotDetectionDetails interface for consistency const detBitmask = [ det.headlessChromeScreenResolution.detected, det.hasWebdriver.detected, det.hasWebdriverWritable.detected, det.hasSeleniumProperty.detected, det.hasCDP.detected, det.hasPlaywright.detected, det.hasImpossibleDeviceMemory.detected, det.hasHighCPUCount.detected, det.hasMissingChromeObject.detected, det.hasWebdriverIframe.detected, det.hasWebdriverWorker.detected, det.hasMismatchWebGLInWorker.detected, det.hasMismatchPlatformIframe.detected, det.hasMismatchPlatformWorker.detected, det.hasSwiftshaderRenderer.detected, det.hasUTCTimezone.detected, det.hasMismatchLanguages.detected, det.hasInconsistentEtsl.detected, det.hasBotUserAgent.detected, det.hasGPUMismatch.detected, det.hasPlatformMismatch.detected, // Add other detection rules output here ].map(b => b ? '1' : '0').join(''); const detSection = detBitmask; // Section 3: Automation - bitmask + hash of non-boolean fields const autoBitmask = [ s.automation.webdriver === true, s.automation.webdriverWritable === true, s.automation.selenium === true, s.automation.cdp === true, s.automation.playwright === true, ].map(b => b ? '1' : '0').join(''); const autoHash = hashCode(String(s.automation.navigatorPropertyDescriptors)).slice(0, 4); const autoSection = `${autoBitmask}h${autoHash}`; // Section 4: Device - screen dims + cpu + mem + bitmask + hash const width = typeof s.device.screenResolution.width === 'number' ? s.device.screenResolution.width : 0; const height = typeof s.device.screenResolution.height === 'number' ? s.device.screenResolution.height : 0; const cpu = typeof s.device.cpuCount === 'number' ? String(s.device.cpuCount).padStart(2, '0') : '00'; const mem = typeof s.device.memory === 'number' ? String(Math.round(s.device.memory)).padStart(2, '0') : '00'; const devBitmask = [ s.device.screenResolution.hasMultipleDisplays === true, s.device.mediaQueries.prefersReducedMotion === true, s.device.mediaQueries.prefersReducedTransparency === true, s.device.mediaQueries.hover === true, s.device.mediaQueries.anyHover === true, ].map(b => b ? '1' : '0').join(''); const devStr = [ s.device.platform, s.device.screenResolution.pixelDepth, s.device.screenResolution.colorDepth, s.device.multimediaDevices.speakers, s.device.multimediaDevices.microphones, s.device.multimediaDevices.webcams, s.device.mediaQueries.prefersColorScheme, s.device.mediaQueries.colorGamut, s.device.mediaQueries.pointer, s.device.mediaQueries.anyPointer, s.device.mediaQueries.colorDepth, ].map(v => String(v)).join('|'); const devHash = hashCode(devStr).slice(0, 6); const devSection = `${width}x${height}c${cpu}m${mem}b${devBitmask}h${devHash}`; // Section 5: Browser - use existing bitmasks + plugins bitmask + hash const featuresBitmask = typeof s.browser.features.bitmask === 'string' ? s.browser.features.bitmask : '0000000000'; const extensionsBitmask = typeof s.browser.extensions.bitmask === 'string' ? s.browser.extensions.bitmask : '00000000'; const pluginsBitmask = [ s.browser.plugins.isValidPluginArray === true, s.browser.plugins.pluginConsistency1 === true, s.browser.plugins.pluginOverflow === true, s.browser.toSourceError.hasToSource === true, ].map(b => b ? '1' : '0').join(''); const brwStr = [ s.browser.userAgent, s.browser.etsl, s.browser.maths, s.browser.plugins.pluginCount, s.browser.plugins.pluginNamesHash, s.browser.toSourceError.toSourceError, s.browser.highEntropyValues.architecture, s.browser.highEntropyValues.bitness, s.browser.highEntropyValues.platform, s.browser.highEntropyValues.platformVersion, s.browser.highEntropyValues.uaFullVersion, s.browser.highEntropyValues.mobile, ].map(v => String(v)).join('|'); const brwHash = hashCode(brwStr).slice(0, 6); const brwSection = `f${featuresBitmask}e${extensionsBitmask}p${pluginsBitmask}h${brwHash}`; // Section 6: Graphics - bitmask + hash const gfxBitmask = [ s.graphics.canvas.hasModifiedCanvas === true, ].map(b => b ? '1' : '0').join(''); const gfxStr = [ s.graphics.webGL.vendor, s.graphics.webGL.renderer, s.graphics.webgpu.vendor, s.graphics.webgpu.architecture, s.graphics.webgpu.device, s.graphics.webgpu.description, s.graphics.canvas.canvasFingerprint, ].map(v => String(v)).join('|'); const gfxHash = hashCode(gfxStr).slice(0, 6); const gfxSection = `${gfxBitmask}h${gfxHash}`; // Section 7: Codecs - bitmask + hash of all codec hashes const codBitmask = [ s.codecs.hasMediaSource === true, ].map(b => b ? '1' : '0').join(''); const codStr = [ s.codecs.audioCanPlayTypeHash, s.codecs.videoCanPlayTypeHash, s.codecs.audioMediaSourceHash, s.codecs.videoMediaSourceHash, s.codecs.rtcAudioCapabilitiesHash, s.codecs.rtcVideoCapabilitiesHash, ].map(v => String(v)).join('|'); const codHash = hashCode(codStr).slice(0, 6); const codSection = `${codBitmask}h${codHash}`; // Section 8: Locale - language code + count + timezone + hash const primaryLang = typeof s.locale.languages.language === 'string' ? s.locale.languages.language.slice(0, 2).toLowerCase() : 'xx'; const langCount = Array.isArray(s.locale.languages.languages) ? s.locale.languages.languages.length : 0; // Sanitize timezone: replace / and spaces with - for fingerprint compatibility const rawTimezone = typeof s.locale.internationalization.timezone === 'string' ? s.locale.internationalization.timezone : 'unknown'; const sanitizedTimezone = rawTimezone.replace(/[\/\s]/g, '-'); const locStr = [ s.locale.internationalization.timezone, s.locale.internationalization.localeLanguage, Array.isArray(s.locale.languages.languages) ? s.locale.languages.languages.join(',') : s.locale.languages.languages, s.locale.languages.language, ].map(v => String(v)).join('|'); const locHash = hashCode(locStr).slice(0, 4); const locSection = `${primaryLang}${langCount}t${sanitizedTimezone}_h${locHash}`; // Section 9: Contexts - mismatch bitmask + hash of all context signals const ctxBitmask = [ hasContextMismatch(this.fingerprint, 'iframe'), hasContextMismatch(this.fingerprint, 'worker'), s.contexts.iframe.webdriver === true, s.contexts.webWorker.webdriver === true, ].map(b => b ? '1' : '0').join(''); const ctxStr = [ s.contexts.iframe.userAgent, s.contexts.iframe.platform, s.contexts.iframe.memory, s.contexts.iframe.cpuCount, s.contexts.iframe.language, s.contexts.webWorker.userAgent, s.contexts.webWorker.platform, s.contexts.webWorker.memory, s.contexts.webWorker.cpuCount, s.contexts.webWorker.language, s.contexts.webWorker.vendor, s.contexts.webWorker.renderer, ].map(v => String(v)).join('|'); const ctxHash = hashCode(ctxStr).slice(0, 6); const ctxSection = `${ctxBitmask}h${ctxHash}`; return [ version, detSection, autoSection, devSection, brwSection, gfxSection, codSection, locSection, ctxSection, ].join('_'); } catch (e) { console.error('Error generating fingerprint scanner id', e); return ERROR; } } private async encryptFingerprint(fingerprint: string) { // Key is injected at build time via Vite's define option // Customers run: npx fpscanner build --key=their-key const key = __FP_ENCRYPTION_KEY__; // Runtime safety check: warn if using the default sentinel key // Use a dynamic check that prevents build-time optimization if (key.length > 20 && key.indexOf('DEFAULT') > 0 && key.indexOf('FPSCANNER') > 0) { console.warn( '[fpscanner] WARNING: Using default encryption key! ' + 'Run "npx fpscanner build --key=your-secret-key" to inject your own key. ' + 'See: https://github.com/antoinevastel/fpscanner#advanced-custom-builds' ); } const enc = await encryptString(JSON.stringify(fingerprint), key); return enc; } /** * Detection rules with name and severity. */ private getDetectionRules(): DetectionRule[] { return [ { name: 'headlessChromeScreenResolution', severity: HIGH, test: hasHeadlessChromeScreenResolution }, { name: 'hasWebdriver', severity: HIGH, test: hasWebdriver }, { name: 'hasWebdriverWritable', severity: HIGH, test: hasWebdriverWritable }, { name: 'hasSeleniumProperty', severity: HIGH, test: hasSeleniumProperty }, { name: 'hasCDP', severity: HIGH, test: hasCDP }, { name: 'hasPlaywright', severity: HIGH, test: hasPlaywright }, { name: 'hasImpossibleDeviceMemory', severity: HIGH, test: hasImpossibleDeviceMemory }, { name: 'hasHighCPUCount', severity: HIGH, test: hasHighCPUCount }, { name: 'hasMissingChromeObject', severity: HIGH, test: hasMissingChromeObject }, { name: 'hasWebdriverIframe', severity: HIGH, test: hasWebdriverIframe }, { name: 'hasWebdriverWorker', severity: HIGH, test: hasWebdriverWorker }, { name: 'hasMismatchWebGLInWorker', severity: HIGH, test: hasMismatchWebGLInWorker }, { name: 'hasMismatchPlatformIframe', severity: HIGH, test: hasMismatchPlatformIframe }, { name: 'hasMismatchPlatformWorker', severity: HIGH, test: hasMismatchPlatformWorker }, { name: 'hasSwiftshaderRenderer', severity: LOW, test: hasSwiftshaderRenderer }, { name: 'hasUTCTimezone', severity: MEDIUM, test: hasUTCTimezone }, { name: 'hasMismatchLanguages', severity: LOW, test: hasMismatchLanguages }, { name: 'hasInconsistentEtsl', severity: HIGH, test: hasInconsistentEtsl }, { name: 'hasBotUserAgent', severity: HIGH, test: hasBotUserAgent }, { name: 'hasGPUMismatch', severity: HIGH, test: hasGPUMismatch }, { name: 'hasPlatformMismatch', severity: HIGH, test: hasPlatformMismatch }, ]; } private runDetectionRules(): FastBotDetectionDetails { const rules = this.getDetectionRules(); const results: FastBotDetectionDetails = { headlessChromeScreenResolution: { detected: false, severity: 'high' }, hasWebdriver: { detected: false, severity: 'high' }, hasWebdriverWritable: { detected: false, severity: 'high' }, hasSeleniumProperty: { detected: false, severity: 'high' }, hasCDP: { detected: false, severity: 'high' }, hasPlaywright: { detected: false, severity: 'high' }, hasImpossibleDeviceMemory: { detected: false, severity: 'high' }, hasHighCPUCount: { detected: false, severity: 'high' }, hasMissingChromeObject: { detected: false, severity: 'high' }, hasWebdriverIframe: { detected: false, severity: 'high' }, hasWebdriverWorker: { detected: false, severity: 'high' }, hasMismatchWebGLInWorker: { detected: false, severity: 'high' }, hasMismatchPlatformIframe: { detected: false, severity: 'high' }, hasMismatchPlatformWorker: { detected: false, severity: 'high' }, hasSwiftshaderRenderer: { detected: false, severity: 'low' }, hasUTCTimezone: { detected: false, severity: 'medium' }, hasMismatchLanguages: { detected: false, severity: 'low' }, hasInconsistentEtsl: { detected: false, severity: 'high' }, hasBotUserAgent: { detected: false, severity: 'high' }, hasGPUMismatch: { detected: false, severity: 'high' }, hasPlatformMismatch: { detected: false, severity: 'high' }, }; for (const rule of rules) { try { const detected = rule.test(this.fingerprint); (results as any)[rule.name] = { detected, severity: rule.severity }; } catch (e) { (results as any)[rule.name] = { detected: false, severity: rule.severity }; } } return results; } async collectFingerprint(options: CollectFingerprintOptions = { encrypt: true }) { const { encrypt = true, skipWorker = false } = options; const s = this.fingerprint.signals; // Define all signal collection tasks to run in parallel const signalTasks = { // Automation signals webdriver: this.collectSignal(webdriver), webdriverWritable: this.collectSignal(webdriverWritable), selenium: this.collectSignal(hasSeleniumProperties), cdp: this.collectSignal(cdp), playwright: this.collectSignal(playwright), navigatorPropertyDescriptors: this.collectSignal(navigatorPropertyDescriptors), // Device signals cpuCount: this.collectSignal(cpuCount), memory: this.collectSignal(memory), platform: this.collectSignal(platform), screenResolution: this.collectSignal(screenResolution), multimediaDevices: this.collectSignal(multimediaDevices), mediaQueries: this.collectSignal(mediaQueries), // Browser signals userAgent: this.collectSignal(userAgent), browserFeatures: this.collectSignal(browserFeatures), plugins: this.collectSignal(plugins), browserExtensions: this.collectSignal(browserExtensions), highEntropyValues: this.collectSignal(highEntropyValues), etsl: this.collectSignal(etsl), maths: this.collectSignal(maths), toSourceError: this.collectSignal(toSourceError), // Graphics signals webGL: this.collectSignal(webGL), webgpu: this.collectSignal(webgpu), canvas: this.collectSignal(canvas), // Codecs mediaCodecs: this.collectSignal(mediaCodecs), // Locale signals internationalization: this.collectSignal(internationalization), languages: this.collectSignal(languages), // Context signals iframe: this.collectSignal(iframe), webWorker: skipWorker ? Promise.resolve({ webdriver: SKIPPED, userAgent: SKIPPED, platform: SKIPPED, memory: SKIPPED, cpuCount: SKIPPED, language: SKIPPED, vendor: SKIPPED, renderer: SKIPPED, }) : this.collectSignal(worker), // Meta signals nonce: this.collectSignal(nonce), time: this.collectSignal(time), url: this.collectSignal(pageURL), }; // Run all signal collections in parallel const keys = Object.keys(signalTasks) as (keyof typeof signalTasks)[]; const results = await Promise.all(Object.values(signalTasks)); const r = Object.fromEntries(keys.map((key, i) => [key, results[i]])) as Record; // Assign results to fingerprint structure // Automation s.automation.webdriver = r.webdriver; s.automation.webdriverWritable = r.webdriverWritable; s.automation.selenium = r.selenium; s.automation.cdp = r.cdp; s.automation.playwright = r.playwright; s.automation.navigatorPropertyDescriptors = r.navigatorPropertyDescriptors; // Device s.device.cpuCount = r.cpuCount; s.device.memory = r.memory; s.device.platform = r.platform; s.device.screenResolution = r.screenResolution; s.device.multimediaDevices = r.multimediaDevices; s.device.mediaQueries = r.mediaQueries; // Browser s.browser.userAgent = r.userAgent; s.browser.features = r.browserFeatures; s.browser.plugins = r.plugins; s.browser.extensions = r.browserExtensions; s.browser.highEntropyValues = r.highEntropyValues; s.browser.etsl = r.etsl; s.browser.maths = r.maths; s.browser.toSourceError = r.toSourceError; // Graphics s.graphics.webGL = r.webGL; s.graphics.webgpu = r.webgpu; s.graphics.canvas = r.canvas; // Codecs s.codecs = r.mediaCodecs; // Locale s.locale.internationalization = r.internationalization; s.locale.languages = r.languages; // Contexts s.contexts.iframe = r.iframe; s.contexts.webWorker = r.webWorker; // Meta this.fingerprint.nonce = r.nonce; this.fingerprint.time = r.time; this.fingerprint.url = r.url; // Run detection rules (needed for fsid generation) this.fingerprint.fastBotDetectionDetails = this.runDetectionRules(); // fastBotDetection = true if any detection rule was triggered this.fingerprint.fastBotDetection = Object.values(this.fingerprint.fastBotDetectionDetails) .some(result => result.detected); // Generate fsid after all signals and detections are collected this.fingerprint.fsid = this.generateFingerprintScannerId(); if (encrypt) { const encryptedFingerprint = await this.encryptFingerprint(JSON.stringify(this.fingerprint)); return encryptedFingerprint; } // Return the raw fingerprint if no encryption is requested return this.fingerprint; } } export default FingerprintScanner; export * from './types'; ================================================ FILE: src/signals/browserExtensions.ts ================================================ import { INIT } from "./utils"; export function browserExtensions() { const browserExtensionsData = { bitmask: INIT, extensions: [] as string[], }; const hasGrammarly = document.body.hasAttribute('data-gr-ext-installed'); const hasMetamask = typeof (window as any).ethereum !=='undefined'; const hasCouponBirds = document.getElementById('coupon-birds-drop-div') !== null; const hasDeepL = document.querySelector('deepl-input-controller') !== null; const hasMonicaAI = document.getElementById('monica-content-root') !== null; const hasSiderAI = document.querySelector('chatgpt-sidebar') !== null; const hasRequestly = typeof (window as any).__REQUESTLY__ !== 'undefined'; const hasVeepn = Array.from(document.querySelectorAll('*')) .filter(el => el.tagName.toLowerCase().startsWith('veepn-')).length > 0; browserExtensionsData.bitmask = [ hasGrammarly ? '1' : '0', hasMetamask ? '1' : '0', hasCouponBirds ? '1' : '0', hasDeepL ? '1' : '0', hasMonicaAI ? '1' : '0', hasSiderAI ? '1' : '0', hasRequestly ? '1' : '0', hasVeepn ? '1' : '0', ].join(''); if (hasGrammarly) { browserExtensionsData.extensions.push('grammarly'); } if (hasMetamask) { browserExtensionsData.extensions.push('metamask'); } if (hasCouponBirds) { browserExtensionsData.extensions.push('coupon-birds'); } if (hasDeepL) { browserExtensionsData.extensions.push('deepl'); } if (hasMonicaAI) { browserExtensionsData.extensions.push('monica-ai'); } if (hasSiderAI) { browserExtensionsData.extensions.push('sider-ai'); } if (hasRequestly) { browserExtensionsData.extensions.push('requestly'); } if (hasVeepn) { browserExtensionsData.extensions.push('veepn'); } return browserExtensionsData; } ================================================ FILE: src/signals/browserFeatures.ts ================================================ import { INIT } from "./utils"; function safeCheck(check: () => boolean): boolean { try { return check(); } catch { return false; } } export function browserFeatures() { const browserFeaturesData = { bitmask: INIT, chrome: safeCheck(() => 'chrome' in window), brave: safeCheck(() => 'brave' in navigator), applePaySupport: safeCheck(() => 'ApplePaySetup' in window), opera: safeCheck(() => (typeof (window as any).opr !== "undefined") || (typeof (window as any).onoperadetachedviewchange === "object")), serial: safeCheck(() => (window.navigator as any).serial !== undefined), attachShadow: safeCheck(() => !!Element.prototype.attachShadow), caches: safeCheck(() => !!window.caches), webAssembly: safeCheck(() => !!window.WebAssembly && !!window.WebAssembly.instantiate), buffer: safeCheck(() => 'Buffer' in window), showModalDialog: safeCheck(() => 'showModalDialog' in window), safari: safeCheck(() => 'safari' in window), webkitPrefixedFunction: safeCheck(() => 'webkitCancelAnimationFrame' in window), mozPrefixedFunction: safeCheck(() => 'mozGetUserMedia' in navigator), usb: safeCheck(() => typeof (window as any).USB === 'function'), browserCapture: safeCheck(() => typeof (window as any).BrowserCaptureMediaStreamTrack === 'function'), paymentRequestUpdateEvent: safeCheck(() => typeof (window as any).PaymentRequestUpdateEvent === 'function'), pressureObserver: safeCheck(() => typeof (window as any).PressureObserver === 'function'), audioSession: safeCheck(() => 'audioSession' in navigator), selectAudioOutput: safeCheck(() => typeof navigator !== 'undefined' && typeof navigator.mediaDevices !== 'undefined' && typeof (navigator.mediaDevices as any).selectAudioOutput === 'function'), barcodeDetector: safeCheck(() => 'BarcodeDetector' in window), battery: safeCheck(() => 'getBattery' in navigator), devicePosture: safeCheck(() => 'DevicePosture' in window), documentPictureInPicture: safeCheck(() => 'documentPictureInPicture' in window), eyeDropper: safeCheck(() => 'EyeDropper' in window), editContext: safeCheck(() => 'EditContext' in window), fencedFrame: safeCheck(() => 'FencedFrameConfig' in window), sanitizer: safeCheck(() => 'Sanitizer' in window), otpCredential: safeCheck(() => 'OTPCredential' in window), }; // set bitmask to 0/1 string based on browserFeaturesData, exclude bitmask property itself (you need to filter on the key) // use the filter function to exclude the bitmask property itself const bitmask = Object.keys(browserFeaturesData).filter((key) => key !== 'bitmask').map(key => (browserFeaturesData as any)[key] ? '1' : '0').join(''); browserFeaturesData.bitmask = bitmask; return browserFeaturesData; } ================================================ FILE: src/signals/canvas.ts ================================================ import { ERROR, INIT, hashCode } from './utils'; import { SignalValue } from '../types'; async function hasModifiedCanvas(): Promise> { return new Promise((resolve) => { try { const img = new Image(); const ctx = document.createElement('canvas').getContext('2d') as CanvasRenderingContext2D; img.onload = () => { ctx.drawImage(img, 0, 0); resolve(ctx.getImageData(0, 0, 1, 1).data.filter(x => x === 0).length != 4); }; img.onerror = () => { resolve(ERROR); }; img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQYV2NgAAIAAAUAAarVyFEAAAAASUVORK5CYII='; } catch (e) { resolve(ERROR); } }); } function getCanvasFingerprint(): SignalValue { var canvas = document.createElement('canvas'); canvas.width = 400; canvas.height = 200; canvas.style.display = "inline"; var context = canvas.getContext("2d") as CanvasRenderingContext2D; try { context.rect(0, 0, 10, 10); context.rect(2, 2, 6, 6); context.textBaseline = "alphabetic"; context.fillStyle = "#f60"; context.fillRect(125, 1, 62, 20); context.fillStyle = "#069"; context.font = "11pt no-real-font-123"; context.fillText("Cwm fjordbank glyphs vext quiz, 😃", 2, 15); context.fillStyle = "rgba(102, 204, 0, 0.2)"; context.font = "18pt Arial"; context.fillText("Cwm fjordbank glyphs vext quiz, 😃", 4, 45); context.globalCompositeOperation = "multiply"; context.fillStyle = "rgb(255,0,255)"; context.beginPath(); context.arc(50, 50, 50, 0, 2 * Math.PI, !0); context.closePath(); context.fill(); context.fillStyle = "rgb(0,255,255)"; context.beginPath(); context.arc(100, 50, 50, 0, 2 * Math.PI, !0); context.closePath(); context.fill(); context.fillStyle = "rgb(255,255,0)"; context.beginPath(); context.arc(75, 100, 50, 0, 2 * Math.PI, !0); context.closePath(); context.fill(); context.fillStyle = "rgb(255,0,255)"; context.arc(75, 75, 75, 0, 2 * Math.PI, !0); context.arc(75, 75, 25, 0, 2 * Math.PI, !0); context.fill("evenodd"); return hashCode(canvas.toDataURL()); } catch (e) { return ERROR; } } export async function canvas() { const canvasData = { hasModifiedCanvas: INIT as SignalValue, canvasFingerprint: INIT as SignalValue, }; canvasData.hasModifiedCanvas = await hasModifiedCanvas(); canvasData.canvasFingerprint = getCanvasFingerprint(); return canvasData; } ================================================ FILE: src/signals/cdp.ts ================================================ import { ERROR } from './utils'; export function cdp() { try { let wasAccessed = false; const originalPrepareStackTrace = (Error as any).prepareStackTrace; (Error as any).prepareStackTrace = function () { wasAccessed = true; return originalPrepareStackTrace; }; const err = new Error(''); console.log(err); return wasAccessed; } catch (e) { return ERROR; } } ================================================ FILE: src/signals/cpuCount.ts ================================================ import { NA } from './utils'; export function cpuCount() { return navigator.hardwareConcurrency || NA; } ================================================ FILE: src/signals/etsl.ts ================================================ export function etsl() { return eval.toString().length; } ================================================ FILE: src/signals/highEntropyValues.ts ================================================ import { ERROR, INIT, NA, setObjectValues } from "./utils"; export async function highEntropyValues() { const navigator = window.navigator as any; const highEntropyValues = { architecture: INIT, bitness: INIT, brands: INIT, mobile: INIT, model: INIT, platform: INIT, platformVersion: INIT, uaFullVersion: INIT, }; if ('userAgentData' in navigator) { try { const ua = await navigator.userAgentData.getHighEntropyValues([ "architecture", "bitness", "brands", "mobile", "model", "platform", "platformVersion", "uaFullVersion" ]); highEntropyValues.architecture = ua.architecture; highEntropyValues.bitness = ua.bitness; highEntropyValues.brands = ua.brands; highEntropyValues.mobile = ua.mobile; highEntropyValues.model = ua.model; highEntropyValues.platform = ua.platform; highEntropyValues.platformVersion = ua.platformVersion; highEntropyValues.uaFullVersion = ua.uaFullVersion; } catch (e) { setObjectValues(highEntropyValues, ERROR); } } else { setObjectValues(highEntropyValues, NA); } return highEntropyValues; } ================================================ FILE: src/signals/iframe.ts ================================================ import { ERROR, INIT, NA, setObjectValues } from './utils'; export function iframe() { const iframeData = { webdriver: INIT, userAgent: INIT, platform: INIT, memory: INIT, cpuCount: INIT, language: INIT, }; const iframe = document.createElement('iframe'); let iframeAdded = false; try { iframe.style.display = 'none'; iframe.src = 'about:blank'; document.body.appendChild(iframe); iframeAdded = true; const iframeWindowNavigator = (iframe.contentWindow?.navigator as any); iframeData.webdriver = iframeWindowNavigator.webdriver ?? false; iframeData.userAgent = iframeWindowNavigator.userAgent ?? NA; iframeData.platform = iframeWindowNavigator.platform ?? NA; iframeData.memory = iframeWindowNavigator.deviceMemory ?? NA; iframeData.cpuCount = iframeWindowNavigator.hardwareConcurrency ?? NA; iframeData.language = iframeWindowNavigator.language ?? NA; } catch (e) { setObjectValues(iframeData, ERROR); } finally { if (iframeAdded) { try { document.body.removeChild(iframe); } catch (_) { // Ignore removal errors } } } return iframeData; } ================================================ FILE: src/signals/internationalization.ts ================================================ import { INIT, ERROR, NA } from "./utils"; export function internationalization() { const internationalizationData = { timezone: INIT, localeLanguage: INIT, }; try { if (typeof Intl !== 'undefined' && typeof Intl.DateTimeFormat !== 'undefined') { const dtfOptions = Intl.DateTimeFormat().resolvedOptions(); internationalizationData.timezone = dtfOptions.timeZone; internationalizationData.localeLanguage = dtfOptions.locale; } else { internationalizationData.timezone = NA; internationalizationData.localeLanguage = NA; } } catch (e) { internationalizationData.timezone = ERROR; internationalizationData.localeLanguage = ERROR; } return internationalizationData; } ================================================ FILE: src/signals/languages.ts ================================================ export function languages() { return { languages: navigator.languages, language: navigator.language, } } ================================================ FILE: src/signals/maths.ts ================================================ import { hashCode } from './utils'; export function maths() { const results: number[] = []; const testValue = 0.123456789; // Math constants const constants = ["E", "LN10", "LN2", "LOG10E", "LOG2E", "PI", "SQRT1_2", "SQRT2"]; constants.forEach(function (name) { try { results.push((Math as any)[name]); } catch (e) { results.push(-1); } }); // Math functions (can reveal VM/browser differences) const mathFunctions = ["tan", "sin", "exp", "atan", "acosh", "asinh", "atanh", "expm1", "log1p", "sinh"]; mathFunctions.forEach(function (name) { try { results.push((Math as any)[name](testValue)); } catch (e) { results.push(-1); } }); return hashCode(results.map(String).join(",")); } ================================================ FILE: src/signals/mediaCodecs.ts ================================================ import { ERROR, NA, hashCode, setObjectValues } from './utils'; const AUDIO_CODECS = [ 'audio/mp4; codecs="mp4a.40.2"', 'audio/mpeg;', 'audio/webm; codecs="vorbis"', 'audio/ogg; codecs="vorbis"', 'audio/wav; codecs="1"', 'audio/ogg; codecs="speex"', 'audio/ogg; codecs="flac"', 'audio/3gpp; codecs="samr"', ]; const VIDEO_CODECS = [ 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"', 'video/mp4; codecs="avc1.42E01E"', 'video/mp4; codecs="avc1.58A01E"', 'video/mp4; codecs="avc1.4D401E"', 'video/mp4; codecs="avc1.64001E"', 'video/mp4; codecs="mp4v.20.8"', 'video/mp4; codecs="mp4v.20.240"', 'video/webm; codecs="vp8"', 'video/ogg; codecs="theora"', 'video/ogg; codecs="dirac"', 'video/3gpp; codecs="mp4v.20.8"', 'video/x-matroska; codecs="theora"', ]; function getCanPlayTypeSupport(codecs: string[], mediaType: 'audio' | 'video'): Record { const result: Record = {}; try { const element = document.createElement(mediaType); for (const codec of codecs) { try { result[codec] = element.canPlayType(codec) || null; } catch { result[codec] = null; } } } catch { for (const codec of codecs) { result[codec] = null; } } return result; } function getMediaSourceSupport(codecs: string[]): Record { const result: Record = {}; const MediaSource = window.MediaSource; if (!MediaSource || typeof MediaSource.isTypeSupported !== 'function') { for (const codec of codecs) { result[codec] = null; } return result; } for (const codec of codecs) { try { result[codec] = MediaSource.isTypeSupported(codec); } catch { result[codec] = null; } } return result; } function getRtcCapabilities(kind: 'audio' | 'video'): string | typeof NA | typeof ERROR { try { const RTCRtpReceiver = window.RTCRtpReceiver; if (RTCRtpReceiver && typeof RTCRtpReceiver.getCapabilities === 'function') { const capabilities = RTCRtpReceiver.getCapabilities(kind); return hashCode(JSON.stringify(capabilities)); } return NA; } catch (e) { return ERROR; } } export function mediaCodecs() { const mediaCodecsData = { audioCanPlayTypeHash: NA as string | typeof NA | typeof ERROR, videoCanPlayTypeHash: NA as string | typeof NA | typeof ERROR, audioMediaSourceHash: NA as string | typeof NA | typeof ERROR, videoMediaSourceHash: NA as string | typeof NA | typeof ERROR, rtcAudioCapabilitiesHash: NA as string | typeof NA | typeof ERROR, rtcVideoCapabilitiesHash: NA as string | typeof NA | typeof ERROR, hasMediaSource: false, }; try { // Check MediaSource availability mediaCodecsData.hasMediaSource = !!window.MediaSource; // canPlayType support - hash the results const audioCanPlayType = getCanPlayTypeSupport(AUDIO_CODECS, 'audio'); const videoCanPlayType = getCanPlayTypeSupport(VIDEO_CODECS, 'video'); mediaCodecsData.audioCanPlayTypeHash = hashCode(JSON.stringify(audioCanPlayType)); mediaCodecsData.videoCanPlayTypeHash = hashCode(JSON.stringify(videoCanPlayType)); // MediaSource.isTypeSupported - hash the results const audioMediaSource = getMediaSourceSupport(AUDIO_CODECS); const videoMediaSource = getMediaSourceSupport(VIDEO_CODECS); mediaCodecsData.audioMediaSourceHash = hashCode(JSON.stringify(audioMediaSource)); mediaCodecsData.videoMediaSourceHash = hashCode(JSON.stringify(videoMediaSource)); // RTCRtpReceiver.getCapabilities - already returns hash mediaCodecsData.rtcAudioCapabilitiesHash = getRtcCapabilities('audio'); mediaCodecsData.rtcVideoCapabilitiesHash = getRtcCapabilities('video'); } catch (e) { setObjectValues(mediaCodecsData, ERROR); } return mediaCodecsData; } ================================================ FILE: src/signals/mediaQueries.ts ================================================ import { ERROR, INIT, setObjectValues } from './utils'; export function mediaQueries() { const mediaQueriesData = { prefersColorScheme: INIT as string | null | typeof INIT | typeof ERROR, prefersReducedMotion: INIT as boolean | typeof INIT | typeof ERROR, prefersReducedTransparency: INIT as boolean | typeof INIT | typeof ERROR, colorGamut: INIT as string | null | typeof INIT | typeof ERROR, pointer: INIT as string | null | typeof INIT | typeof ERROR, anyPointer: INIT as string | null | typeof INIT | typeof ERROR, hover: INIT as boolean | typeof INIT | typeof ERROR, anyHover: INIT as boolean | typeof INIT | typeof ERROR, colorDepth: INIT as number | typeof INIT | typeof ERROR, }; try { // Prefers color scheme if (window.matchMedia('(prefers-color-scheme: dark)').matches) { mediaQueriesData.prefersColorScheme = 'dark'; } else if (window.matchMedia('(prefers-color-scheme: light)').matches) { mediaQueriesData.prefersColorScheme = 'light'; } else { mediaQueriesData.prefersColorScheme = null; } // Prefers reduced motion mediaQueriesData.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // Prefers reduced transparency mediaQueriesData.prefersReducedTransparency = window.matchMedia('(prefers-reduced-transparency: reduce)').matches; // Color gamut if (window.matchMedia('(color-gamut: rec2020)').matches) { mediaQueriesData.colorGamut = 'rec2020'; } else if (window.matchMedia('(color-gamut: p3)').matches) { mediaQueriesData.colorGamut = 'p3'; } else if (window.matchMedia('(color-gamut: srgb)').matches) { mediaQueriesData.colorGamut = 'srgb'; } else { mediaQueriesData.colorGamut = null; } // Pointer if (window.matchMedia('(pointer: fine)').matches) { mediaQueriesData.pointer = 'fine'; } else if (window.matchMedia('(pointer: coarse)').matches) { mediaQueriesData.pointer = 'coarse'; } else if (window.matchMedia('(pointer: none)').matches) { mediaQueriesData.pointer = 'none'; } else { mediaQueriesData.pointer = null; } // Any pointer if (window.matchMedia('(any-pointer: fine)').matches) { mediaQueriesData.anyPointer = 'fine'; } else if (window.matchMedia('(any-pointer: coarse)').matches) { mediaQueriesData.anyPointer = 'coarse'; } else if (window.matchMedia('(any-pointer: none)').matches) { mediaQueriesData.anyPointer = 'none'; } else { mediaQueriesData.anyPointer = null; } // Hover mediaQueriesData.hover = window.matchMedia('(hover: hover)').matches; // Any hover mediaQueriesData.anyHover = window.matchMedia('(any-hover: hover)').matches; // Color depth - find the maximum supported color depth let maxColorDepth = 0; for (let c = 0; c <= 16; c++) { if (window.matchMedia(`(color: ${c})`).matches) { maxColorDepth = c; } } mediaQueriesData.colorDepth = maxColorDepth; } catch (e) { setObjectValues(mediaQueriesData, ERROR); } return mediaQueriesData; } ================================================ FILE: src/signals/memory.ts ================================================ import { NA } from "./utils"; export function memory() { return (navigator as any).deviceMemory || NA; } ================================================ FILE: src/signals/multimediaDevices.ts ================================================ import { NA, setObjectValues } from "./utils"; export async function multimediaDevices() { return new Promise(async function (resolve) { var deviceToCount = { "audiooutput": 0, "audioinput": 0, "videoinput": 0 }; if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) { const devices = await navigator.mediaDevices.enumerateDevices(); if (typeof devices !== "undefined") { for (var i = 0; i < devices.length; i++) { var name = devices[i].kind as keyof typeof deviceToCount; deviceToCount[name] = deviceToCount[name] + 1; } return resolve({ speakers: deviceToCount.audiooutput, microphones: deviceToCount.audioinput, webcams: deviceToCount.videoinput }); } else { setObjectValues(deviceToCount, NA); return resolve(deviceToCount); } } else { setObjectValues(deviceToCount, NA); return resolve(deviceToCount); } }); } ================================================ FILE: src/signals/navigatorPropertyDescriptors.ts ================================================ export function navigatorPropertyDescriptors() { const properties = ['deviceMemory', 'hardwareConcurrency', 'language', 'languages', 'platform']; const results = []; for (const property of properties) { const res = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(navigator), property); if (res && res.value) { results.push('1'); } else { results.push('0'); } } return results.join(''); } ================================================ FILE: src/signals/nonce.ts ================================================ export function nonce() { return Math.random().toString(36).substring(2, 15); } ================================================ FILE: src/signals/platform.ts ================================================ export function platform() { return navigator.platform; } ================================================ FILE: src/signals/playwright.ts ================================================ export function playwright() { return '__pwInitScripts' in window || '__playwright__binding__' in window; } ================================================ FILE: src/signals/plugins.ts ================================================ import { SignalValue } from "../types"; import { INIT, NA, hashCode, ERROR, setObjectValues} from "./utils"; function isValidPluginArray() { if (!navigator.plugins) return false; const str = typeof navigator.plugins.toString === "function" ? navigator.plugins.toString() : navigator.plugins.constructor && typeof navigator.plugins.constructor.toString === "function" ? navigator.plugins.constructor.toString() : typeof navigator.plugins; return str === "[object PluginArray]" || str === "[object MSPluginsCollection]" || str === "[object HTMLPluginsCollection]"; } function getPluginNamesHash() { if (!navigator.plugins) return NA; const pluginNames = []; for (let i = 0; i < navigator.plugins.length; i++) { pluginNames.push(navigator.plugins[i].name); } return hashCode(pluginNames.join(",")); } function getPluginCount() { if (!navigator.plugins) return NA; return navigator.plugins.length; } function getPluginConsistency1() { if (!navigator.plugins) return NA; try { return navigator.plugins[0] === navigator.plugins[0][0].enabledPlugin; } catch (e) { return ERROR; } } function getPluginOverflow() { if (!navigator.plugins) return NA; try { return navigator.plugins.item(4294967296) !== navigator.plugins[0]; } catch (e) { return ERROR; } } export function plugins() { const pluginsData = { isValidPluginArray: INIT as SignalValue, pluginCount: INIT as SignalValue, pluginNamesHash: INIT as SignalValue, pluginConsistency1: INIT as SignalValue, pluginOverflow: INIT as SignalValue, } try { pluginsData.isValidPluginArray = isValidPluginArray(); pluginsData.pluginCount = getPluginCount(); pluginsData.pluginNamesHash = getPluginNamesHash(); pluginsData.pluginConsistency1 = getPluginConsistency1(); pluginsData.pluginOverflow = getPluginOverflow(); } catch (e) { setObjectValues(pluginsData, ERROR); } return pluginsData; } ================================================ FILE: src/signals/screenResolution.ts ================================================ import { NA } from './utils'; export function screenResolution() { return { width: window.screen.width, height: window.screen.height, pixelDepth: window.screen.pixelDepth, colorDepth: window.screen.colorDepth, availableWidth: window.screen.availWidth, availableHeight: window.screen.availHeight, innerWidth: window.innerWidth, innerHeight: window.innerHeight, hasMultipleDisplays: typeof (screen as any).isExtended !== 'undefined' ? (screen as any).isExtended : NA, }; } ================================================ FILE: src/signals/seleniumProperties.ts ================================================ export function hasSeleniumProperties() { const seleniumProps = [ "__driver_evaluate", "__webdriver_evaluate", "__selenium_evaluate", "__fxdriver_evaluate", "__driver_unwrapped", "__webdriver_unwrapped", "__selenium_unwrapped", "__fxdriver_unwrapped", "_Selenium_IDE_Recorder", "_selenium", "calledSelenium", "$cdc_asdjflasutopfhvcZLmcfl_", "$chrome_asyncScriptInfo", "__$webdriverAsyncExecutor", "webdriver", "__webdriverFunc", "domAutomation", "domAutomationController", "__lastWatirAlert", "__lastWatirConfirm", "__lastWatirPrompt", "__webdriver_script_fn", "_WEBDRIVER_ELEM_CACHE" ]; let hasSeleniumProperty = false; for (let i = 0; i < seleniumProps.length; i++) { if (seleniumProps[i] in window) { hasSeleniumProperty = true; break; } } hasSeleniumProperty = hasSeleniumProperty || !!(document as any).__webdriver_script_fn || !!(window as any).domAutomation || !!(window as any).domAutomationController return hasSeleniumProperty; } ================================================ FILE: src/signals/time.ts ================================================ export function time() { return new Date().getTime(); } ================================================ FILE: src/signals/toSourceError.ts ================================================ import { INIT } from './utils'; export function toSourceError() { const toSourceErrorData = { toSourceError: INIT, hasToSource: false, }; try { (null as any).usdfsh; } catch (e) { toSourceErrorData.toSourceError = (e as Error).toString(); } try { throw "xyz"; } catch (e: any) { try { e.toSource(); toSourceErrorData.hasToSource = true; } catch (e2) { toSourceErrorData.hasToSource = false; } } return toSourceErrorData; } ================================================ FILE: src/signals/url.ts ================================================ export function pageURL() { return window.location.href; } ================================================ FILE: src/signals/userAgent.ts ================================================ export function userAgent() { return navigator.userAgent; } ================================================ FILE: src/signals/utils.ts ================================================ export const ERROR = 'ERROR'; export const INIT = 'INIT'; export const NA = 'NA'; export const SKIPPED = 'SKIPPED'; export const HIGH = 'high' export const LOW = 'low' export const MEDIUM = 'medium' export function hashCode(str: string) { let hash = 0; for (let i = 0, len = str.length; i < len; i++) { let chr = str.charCodeAt(i); hash = (hash << 5) - hash + chr; hash |= 0; } return hash.toString(16).padStart(8, "0"); } export function setObjectValues(object: any, value: any) { for (const key in object) { object[key] = value; } } export function isFirefox() { return (navigator as any).buildID === "20181001000000"; } ================================================ FILE: src/signals/webGL.ts ================================================ import { ERROR, INIT, NA, isFirefox, setObjectValues } from './utils'; export function webGL() { const webGLData = { vendor: INIT, renderer: INIT, }; if (isFirefox()) { setObjectValues(webGLData, NA); return webGLData; } try { var canvas = document.createElement('canvas'); var ctx = (canvas.getContext("webgl") || canvas.getContext("experimental-webgl")) as any; if (ctx.getSupportedExtensions().indexOf("WEBGL_debug_renderer_info") >= 0) { webGLData.vendor = ctx.getParameter(ctx.getExtension('WEBGL_debug_renderer_info').UNMASKED_VENDOR_WEBGL); webGLData.renderer = ctx.getParameter(ctx.getExtension('WEBGL_debug_renderer_info').UNMASKED_RENDERER_WEBGL); } else { setObjectValues(webGLData, NA); } } catch (e) { setObjectValues(webGLData, ERROR); } return webGLData; } ================================================ FILE: src/signals/webdriver.ts ================================================ export function webdriver() { return navigator.webdriver; }; ================================================ FILE: src/signals/webdriverWritable.ts ================================================ export function webdriverWritable() { try { const prop = "webdriver"; const navigator = window.navigator as any; if (!navigator[prop] && !navigator.hasOwnProperty(prop)) { navigator[prop] = 1; const writable = navigator[prop] === 1; delete navigator[prop]; return writable; } return true; } catch (e) { return false; } } ================================================ FILE: src/signals/webgpu.ts ================================================ import { ERROR, INIT, NA, setObjectValues } from "./utils"; export async function webgpu() { const webGPUData = { vendor: INIT, architecture: INIT, device: INIT, description: INIT, }; if ('gpu' in navigator) { try { const adapter = await (navigator as any).gpu.requestAdapter(); if (adapter) { webGPUData.vendor = adapter.info.vendor; webGPUData.architecture = adapter.info.architecture; webGPUData.device = adapter.info.device; webGPUData.description = adapter.info.description; } } catch (e) { setObjectValues(webGPUData, ERROR); } } else { setObjectValues(webGPUData, NA); } return webGPUData; } ================================================ FILE: src/signals/worker.ts ================================================ import { ERROR, INIT, setObjectValues } from './utils'; export async function worker() { return new Promise((resolve) => { const workerData = { vendor: INIT, renderer: INIT, userAgent: INIT, language: INIT, platform: INIT, memory: INIT, cpuCount: INIT, }; let worker: Worker | null = null; let workerUrl: string | null = null; let timeoutId: number | null = null; const cleanup = () => { if (timeoutId) clearTimeout(timeoutId); if (worker) worker.terminate(); if (workerUrl) URL.revokeObjectURL(workerUrl); }; try { const workerCode = `try { var fingerprintWorker = {}; fingerprintWorker.userAgent = navigator.userAgent; fingerprintWorker.language = navigator.language; fingerprintWorker.cpuCount = navigator.hardwareConcurrency; fingerprintWorker.platform = navigator.platform; fingerprintWorker.memory = navigator.deviceMemory; var canvas = new OffscreenCanvas(1, 1); fingerprintWorker.vendor = 'INIT'; fingerprintWorker.renderer = 'INIT'; var gl = canvas.getContext('webgl'); const isFirefox = navigator.userAgent.includes('Firefox'); try { if (gl && !isFirefox) { var glExt = gl.getExtension('WEBGL_debug_renderer_info'); fingerprintWorker.vendor = gl.getParameter(glExt.UNMASKED_VENDOR_WEBGL); fingerprintWorker.renderer = gl.getParameter(glExt.UNMASKED_RENDERER_WEBGL); } else { fingerprintWorker.vendor = 'NA'; fingerprintWorker.renderer = 'NA'; } } catch (_) { fingerprintWorker.vendor = 'ERROR'; fingerprintWorker.renderer = 'ERROR'; } self.postMessage(fingerprintWorker); } catch (e) { self.postMessage(fingerprintWorker); }` const blob = new Blob([workerCode], { type: 'application/javascript' }); workerUrl = URL.createObjectURL(blob); worker = new Worker(workerUrl); // Set timeout to prevent infinite hang timeoutId = window.setTimeout(() => { cleanup(); setObjectValues(workerData, ERROR); resolve(workerData); }, 2000); worker.onmessage = function (e) { try { workerData.vendor = e.data.vendor; workerData.renderer = e.data.renderer; workerData.userAgent = e.data.userAgent; workerData.language = e.data.language; workerData.platform = e.data.platform; workerData.memory = e.data.memory; workerData.cpuCount = e.data.cpuCount; } catch (_) { setObjectValues(workerData, ERROR); } finally { cleanup(); resolve(workerData); } }; worker.onerror = function () { cleanup(); setObjectValues(workerData, ERROR); resolve(workerData); }; } catch (e) { cleanup(); setObjectValues(workerData, ERROR); resolve(workerData); } }); } ================================================ FILE: src/types.ts ================================================ import { ERROR, INIT, NA, SKIPPED } from './signals/utils'; export type SignalValue = T | typeof ERROR | typeof INIT | typeof NA | typeof SKIPPED; export interface WebGLSignal { vendor: SignalValue; renderer: SignalValue; } export interface InternationalizationSignal { timezone: SignalValue; localeLanguage: SignalValue; } export interface ScreenResolutionSignal { width: SignalValue; height: SignalValue; pixelDepth: SignalValue; colorDepth: SignalValue; availableWidth: SignalValue; availableHeight: SignalValue; innerWidth: SignalValue; innerHeight: SignalValue; hasMultipleDisplays: SignalValue; } export interface LanguagesSignal { languages: SignalValue; language: SignalValue; } export interface WebGPUSignal { vendor: SignalValue; architecture: SignalValue; device: SignalValue; description: SignalValue; } export interface IframeSignal { webdriver: SignalValue; userAgent: SignalValue; platform: SignalValue; memory: SignalValue; cpuCount: SignalValue; language: SignalValue; } export interface WebWorkerSignal { webdriver: SignalValue; userAgent: SignalValue; platform: SignalValue; memory: SignalValue; cpuCount: SignalValue; language: SignalValue; vendor: SignalValue; renderer: SignalValue; } export interface BrowserExtensionsSignal { bitmask: SignalValue; extensions: SignalValue; } export interface BrowserFeaturesSignal { bitmask: SignalValue; chrome: SignalValue; brave: SignalValue; applePaySupport: SignalValue; opera: SignalValue; serial: SignalValue; attachShadow: SignalValue; caches: SignalValue; webAssembly: SignalValue; buffer: SignalValue; showModalDialog: SignalValue; safari: SignalValue; webkitPrefixedFunction: SignalValue; mozPrefixedFunction: SignalValue; usb: SignalValue; browserCapture: SignalValue; paymentRequestUpdateEvent: SignalValue; pressureObserver: SignalValue; audioSession: SignalValue; selectAudioOutput: SignalValue; barcodeDetector: SignalValue; battery: SignalValue; devicePosture: SignalValue; documentPictureInPicture: SignalValue; eyeDropper: SignalValue; editContext: SignalValue; fencedFrame: SignalValue; sanitizer: SignalValue; otpCredential: SignalValue; } export interface MediaQueriesSignal { prefersColorScheme: SignalValue; prefersReducedMotion: SignalValue; prefersReducedTransparency: SignalValue; colorGamut: SignalValue; pointer: SignalValue; anyPointer: SignalValue; hover: SignalValue; anyHover: SignalValue; colorDepth: SignalValue; } export interface ToSourceErrorSignal { toSourceError: SignalValue; hasToSource: SignalValue; } export interface CanvasSignal { hasModifiedCanvas: SignalValue; canvasFingerprint: SignalValue; } export interface HighEntropyValuesSignal { architecture: SignalValue; bitness: SignalValue; brands: SignalValue; mobile: SignalValue; model: SignalValue; platform: SignalValue; platformVersion: SignalValue; uaFullVersion: SignalValue; } export interface PluginsSignal { isValidPluginArray: SignalValue; pluginCount: SignalValue; pluginNamesHash: SignalValue; pluginConsistency1: SignalValue; pluginOverflow: SignalValue; } export interface MultimediaDevicesSignal { speakers: SignalValue; microphones: SignalValue; webcams: SignalValue; } export interface MediaCodecsSignal { audioCanPlayTypeHash: SignalValue; videoCanPlayTypeHash: SignalValue; audioMediaSourceHash: SignalValue; videoMediaSourceHash: SignalValue; rtcAudioCapabilitiesHash: SignalValue; rtcVideoCapabilitiesHash: SignalValue; hasMediaSource: SignalValue; } // Grouped signal interfaces export interface AutomationSignals { webdriver: SignalValue; webdriverWritable: SignalValue; selenium: SignalValue; cdp: SignalValue; playwright: SignalValue; navigatorPropertyDescriptors: SignalValue; } export interface DeviceSignals { cpuCount: SignalValue; memory: SignalValue; platform: SignalValue; screenResolution: ScreenResolutionSignal; multimediaDevices: MultimediaDevicesSignal; mediaQueries: MediaQueriesSignal; } export interface BrowserSignals { userAgent: SignalValue; features: BrowserFeaturesSignal; plugins: PluginsSignal; extensions: BrowserExtensionsSignal; highEntropyValues: HighEntropyValuesSignal; etsl: SignalValue; maths: SignalValue; toSourceError: ToSourceErrorSignal; } export interface GraphicsSignals { webGL: WebGLSignal; webgpu: WebGPUSignal; canvas: CanvasSignal; } export interface LocaleSignals { internationalization: InternationalizationSignal; languages: LanguagesSignal; } export interface ContextsSignals { iframe: IframeSignal; webWorker: WebWorkerSignal; } export interface FingerprintSignals { automation: AutomationSignals; device: DeviceSignals; browser: BrowserSignals; graphics: GraphicsSignals; codecs: MediaCodecsSignal; locale: LocaleSignals; contexts: ContextsSignals; } export interface FastBotDetectionDetails { headlessChromeScreenResolution: DetectionRuleResult; hasWebdriver: DetectionRuleResult; hasWebdriverWritable: DetectionRuleResult; hasSeleniumProperty: DetectionRuleResult; hasCDP: DetectionRuleResult; hasPlaywright: DetectionRuleResult; hasImpossibleDeviceMemory: DetectionRuleResult; hasHighCPUCount: DetectionRuleResult; hasMissingChromeObject: DetectionRuleResult; hasWebdriverIframe: DetectionRuleResult; hasWebdriverWorker: DetectionRuleResult; hasMismatchWebGLInWorker: DetectionRuleResult; hasMismatchPlatformIframe: DetectionRuleResult; hasMismatchPlatformWorker: DetectionRuleResult; hasSwiftshaderRenderer: DetectionRuleResult; hasUTCTimezone: DetectionRuleResult; hasMismatchLanguages: DetectionRuleResult; hasInconsistentEtsl: DetectionRuleResult; hasBotUserAgent: DetectionRuleResult; hasGPUMismatch: DetectionRuleResult; hasPlatformMismatch: DetectionRuleResult; } export interface Fingerprint { signals: FingerprintSignals; fsid: string; nonce: string; time: SignalValue; url: string; fastBotDetection: boolean; fastBotDetectionDetails: FastBotDetectionDetails; } export type DetectionSeverity = 'low' | 'medium' | 'high'; export interface DetectionRuleResult { detected: boolean; severity: DetectionSeverity; } export interface DetectionRule { name: string; severity: DetectionSeverity; test: (fingerprint: Fingerprint) => boolean; } export interface CollectFingerprintOptions { encrypt?: boolean; timeout?: number; skipWorker?: boolean; } ================================================ FILE: test/decrypt.js ================================================ /** * Server-side decryption helper for tests * This mimics what a real server would do to decrypt fingerprints */ const TEST_KEY = 'dev-key'; /** * Decrypts a string that was encrypted with XOR cipher * @param {string} ciphertext - Base64 encoded encrypted string * @param {string} key - Decryption key * @returns {string} Decrypted string */ function decryptString(ciphertext, key = TEST_KEY) { // Decode from base64 const binaryString = Buffer.from(ciphertext, 'base64').toString('binary'); const encrypted = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { encrypted[i] = binaryString.charCodeAt(i); } const keyBytes = Buffer.from(key, 'utf8'); const decrypted = new Uint8Array(encrypted.length); // XOR is symmetric for (let i = 0; i < encrypted.length; i++) { decrypted[i] = encrypted[i] ^ keyBytes[i % keyBytes.length]; } return Buffer.from(decrypted).toString('utf8'); } /** * Decrypts and parses a fingerprint payload * @param {string} encryptedFingerprint - Base64 encoded encrypted fingerprint JSON * @param {string} key - Decryption key * @returns {object} Parsed fingerprint object */ function decryptFingerprint(encryptedFingerprint, key = TEST_KEY) { const decryptedJson = decryptString(encryptedFingerprint, key); // The fingerprint is double-JSON-stringified in the code (JSON.stringify(JSON.stringify(...))) // So we need to parse twice const parsed = JSON.parse(decryptedJson); // If it's still a string, parse again if (typeof parsed === 'string') { return JSON.parse(parsed); } return parsed; } module.exports = { decryptString, decryptFingerprint, TEST_KEY, }; ================================================ FILE: test/detection/README.md ================================================ # Detection Quality Tests These scripts are **not** part of the CI/CD pipeline. They are manual tools for evaluating how well fpscanner detects various automation frameworks. Each script navigates to the local dev page (`http://localhost:3000/test/dev-source.html`), waits for the fingerprint to be collected, then prints the `fastBotDetectionDetails` object along with a summary of which detections were triggered. ## Prerequisites Start the Vite dev server in the project root before running any of the scripts: ```bash npm run dev ``` --- ## Node.js tests Located in `nodejs/`. Two variants: | Script | Framework | Engine | Evasion | |---|---|---|---| | `puppeteer-headless.js` | Puppeteer | Chromium | None | | `puppeteer-stealth.js` | Puppeteer + stealth plugin | Chromium | Yes | | `playwright-chromium-headless.js` | Playwright | Chromium | None | | `playwright-firefox-headless.js` | Playwright | Firefox | None | | `playwright-webkit-headless.js` | Playwright | WebKit | None | | `playwright-iphone-headless.js` | Playwright | Chromium | None (iPhone 15 emulation) | | `playwright-android-headless.js` | Playwright | Chromium | None (Pixel 7 emulation) | ### Setup ```bash cd test/detection/nodejs npm install npx playwright install chromium firefox webkit # download browser binaries ``` ### Run ```bash node puppeteer-headless.js node puppeteer-stealth.js node playwright-chromium-headless.js node playwright-firefox-headless.js node playwright-webkit-headless.js node playwright-iphone-headless.js node playwright-android-headless.js ``` Or via npm scripts: ```bash npm run test:headless npm run test:stealth npm run test:chromium npm run test:firefox npm run test:webkit npm run test:iphone npm run test:android ``` --- ## Python tests Located in `python/`. Two variants: | Script | Framework | Evasion | |---|---|---| | `selenium_headless_test.py` | Selenium + headless Chrome | None | | `undetected_chromedriver_test.py` | undetected-chromedriver | Yes (Chromium) | | `camoufox_test.py` | Camoufox (patched Firefox) | Yes (Firefox, C++ level) | ### Setup ```bash cd test/detection/python pip install -r requirements.txt python -m camoufox fetch # one-time download of the Camoufox browser binary ``` ### Run ```bash # Plain headless Chrome via Selenium (expect many detections) python selenium_headless_test.py # With undetected-chromedriver patches (fewer detections expected) python undetected_chromedriver_test.py # Camoufox — patched Firefox, C++-level fingerprint spoofing via Playwright python camoufox_test.py ``` ================================================ FILE: test/detection/nodejs/package.json ================================================ { "name": "fpscanner-detection-tests-nodejs", "version": "1.0.0", "description": "Detection quality tests for fpscanner using Node.js automation tools", "private": true, "scripts": { "test:headless": "node puppeteer-headless.js", "test:stealth": "node puppeteer-stealth.js", "test:chromium": "node playwright-chromium-headless.js", "test:firefox": "node playwright-firefox-headless.js", "test:webkit": "node playwright-webkit-headless.js", "test:iphone": "node playwright-iphone-headless.js", "test:android": "node playwright-android-headless.js" }, "dependencies": { "playwright": "^1.50.0", "puppeteer": "^22.0.0", "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2" } } ================================================ FILE: test/detection/nodejs/playwright-android-headless.js ================================================ /** * Detection test: Playwright + Chromium + Pixel 7 (Android) device emulation (headless) * * Uses Playwright's built-in device descriptor for Pixel 7, which sets the * correct viewport, userAgent, deviceScaleFactor, and touch capabilities. * * Prerequisites: * npm install (inside test/detection/nodejs/) * npx playwright install chromium * npm run dev (in the project root, to start the Vite server on port 3000) * * Run: * node playwright-android-headless.js */ const { chromium, devices } = require('playwright'); const DEVICE = devices['Pixel 7']; const TARGET_URL = 'http://localhost:3000/test/dev-source.html'; const WAIT_TIMEOUT_MS = 15000; (async () => { console.log(`[playwright-android] Launching headless Chromium emulating Pixel 7...`); console.log(`[playwright-android] Viewport: ${DEVICE.viewport.width}x${DEVICE.viewport.height}, dpr: ${DEVICE.deviceScaleFactor}`); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ ...DEVICE }); const page = await context.newPage(); console.log(`[playwright-android] Navigating to ${TARGET_URL}`); await page.goto(TARGET_URL); console.log('[playwright-android] Waiting for fingerprint result...'); await page.waitForFunction(() => window.result !== undefined, { timeout: WAIT_TIMEOUT_MS }); const fastBotDetectionDetails = await page.evaluate(() => window.result.fastBotDetectionDetails); console.log('\n=== fastBotDetectionDetails ==='); console.log(JSON.stringify(fastBotDetectionDetails, null, 2)); const triggered = Object.entries(fastBotDetectionDetails) .filter(([, v]) => v.detected) .map(([k]) => k); console.log(`\n=== Triggered detections (${triggered.length}) ===`); if (triggered.length === 0) { console.log('None'); } else { triggered.forEach(name => console.log(` • ${name}`)); } await browser.close(); console.log('\n[playwright-android] Done.'); })(); ================================================ FILE: test/detection/nodejs/playwright-chromium-headless.js ================================================ /** * Detection test: Playwright + Chromium headless (no evasion) * * Prerequisites: * npm install (inside test/detection/nodejs/) * npx playwright install chromium * npm run dev (in the project root, to start the Vite server on port 3000) * * Run: * node playwright-chromium-headless.js */ const { chromium } = require('playwright'); const TARGET_URL = 'http://localhost:3000/test/dev-source.html'; const WAIT_TIMEOUT_MS = 15000; (async () => { console.log('[playwright-chromium] Launching headless Chromium...'); const browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); console.log(`[playwright-chromium] Navigating to ${TARGET_URL}`); await page.goto(TARGET_URL); console.log('[playwright-chromium] Waiting for fingerprint result...'); await page.waitForFunction(() => window.result !== undefined, { timeout: WAIT_TIMEOUT_MS }); const fastBotDetectionDetails = await page.evaluate(() => window.result.fastBotDetectionDetails); console.log('\n=== fastBotDetectionDetails ==='); console.log(JSON.stringify(fastBotDetectionDetails, null, 2)); const triggered = Object.entries(fastBotDetectionDetails) .filter(([, v]) => v.detected) .map(([k]) => k); console.log(`\n=== Triggered detections (${triggered.length}) ===`); if (triggered.length === 0) { console.log('None'); } else { triggered.forEach(name => console.log(` • ${name}`)); } await browser.close(); console.log('\n[playwright-chromium] Done.'); })(); ================================================ FILE: test/detection/nodejs/playwright-firefox-headless.js ================================================ /** * Detection test: Playwright + Firefox headless (no evasion) * * Prerequisites: * npm install (inside test/detection/nodejs/) * npx playwright install firefox * npm run dev (in the project root, to start the Vite server on port 3000) * * Run: * node playwright-firefox-headless.js */ const { firefox } = require('playwright'); const TARGET_URL = 'http://localhost:3000/test/dev-source.html'; const WAIT_TIMEOUT_MS = 15000; (async () => { console.log('[playwright-firefox] Launching headless Firefox...'); const browser = await firefox.launch({ headless: true }); const page = await browser.newPage(); console.log(`[playwright-firefox] Navigating to ${TARGET_URL}`); await page.goto(TARGET_URL); console.log('[playwright-firefox] Waiting for fingerprint result...'); await page.waitForFunction(() => window.result !== undefined, { timeout: WAIT_TIMEOUT_MS }); const fastBotDetectionDetails = await page.evaluate(() => window.result.fastBotDetectionDetails); console.log('\n=== fastBotDetectionDetails ==='); console.log(JSON.stringify(fastBotDetectionDetails, null, 2)); const triggered = Object.entries(fastBotDetectionDetails) .filter(([, v]) => v.detected) .map(([k]) => k); console.log(`\n=== Triggered detections (${triggered.length}) ===`); if (triggered.length === 0) { console.log('None'); } else { triggered.forEach(name => console.log(` • ${name}`)); } await browser.close(); console.log('\n[playwright-firefox] Done.'); })(); ================================================ FILE: test/detection/nodejs/playwright-iphone-headless.js ================================================ /** * Detection test: Playwright + Chromium + iPhone 15 device emulation (headless) * * Uses Playwright's built-in device descriptor for iPhone 15, which sets the * correct viewport, userAgent, deviceScaleFactor, and touch capabilities. * * Prerequisites: * npm install (inside test/detection/nodejs/) * npx playwright install chromium * npm run dev (in the project root, to start the Vite server on port 3000) * * Run: * node playwright-iphone-headless.js */ const { chromium, devices } = require('playwright'); const DEVICE = devices['iPhone 15']; const TARGET_URL = 'http://localhost:3000/test/dev-source.html'; const WAIT_TIMEOUT_MS = 15000; (async () => { console.log(`[playwright-iphone] Launching headless Chromium emulating "${DEVICE.userAgent.split(' ').slice(-1)[0]}"...`); console.log(`[playwright-iphone] Viewport: ${DEVICE.viewport.width}x${DEVICE.viewport.height}, dpr: ${DEVICE.deviceScaleFactor}`); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ ...DEVICE }); const page = await context.newPage(); console.log(`[playwright-iphone] Navigating to ${TARGET_URL}`); await page.goto(TARGET_URL); console.log('[playwright-iphone] Waiting for fingerprint result...'); await page.waitForFunction(() => window.result !== undefined, { timeout: WAIT_TIMEOUT_MS }); const fastBotDetectionDetails = await page.evaluate(() => window.result.fastBotDetectionDetails); console.log('\n=== fastBotDetectionDetails ==='); console.log(JSON.stringify(fastBotDetectionDetails, null, 2)); const triggered = Object.entries(fastBotDetectionDetails) .filter(([, v]) => v.detected) .map(([k]) => k); console.log(`\n=== Triggered detections (${triggered.length}) ===`); if (triggered.length === 0) { console.log('None'); } else { triggered.forEach(name => console.log(` • ${name}`)); } await browser.close(); console.log('\n[playwright-iphone] Done.'); })(); ================================================ FILE: test/detection/nodejs/playwright-webkit-headless.js ================================================ /** * Detection test: Playwright + WebKit headless (no evasion) * * Prerequisites: * npm install (inside test/detection/nodejs/) * npx playwright install webkit * npm run dev (in the project root, to start the Vite server on port 3000) * * Run: * node playwright-webkit-headless.js */ const { webkit } = require('playwright'); const TARGET_URL = 'http://localhost:3000/test/dev-source.html'; const WAIT_TIMEOUT_MS = 15000; (async () => { console.log('[playwright-webkit] Launching headless WebKit...'); const browser = await webkit.launch({ headless: true }); const page = await browser.newPage(); console.log(`[playwright-webkit] Navigating to ${TARGET_URL}`); await page.goto(TARGET_URL); console.log('[playwright-webkit] Waiting for fingerprint result...'); await page.waitForFunction(() => window.result !== undefined, { timeout: WAIT_TIMEOUT_MS }); const fastBotDetectionDetails = await page.evaluate(() => window.result.fastBotDetectionDetails); console.log('\n=== fastBotDetectionDetails ==='); console.log(JSON.stringify(fastBotDetectionDetails, null, 2)); const triggered = Object.entries(fastBotDetectionDetails) .filter(([, v]) => v.detected) .map(([k]) => k); console.log(`\n=== Triggered detections (${triggered.length}) ===`); if (triggered.length === 0) { console.log('None'); } else { triggered.forEach(name => console.log(` • ${name}`)); } await browser.close(); console.log('\n[playwright-webkit] Done.'); })(); ================================================ FILE: test/detection/nodejs/puppeteer-headless.js ================================================ /** * Detection test: Puppeteer with headless Chrome (no evasion) * * Prerequisites: * npm install (inside test/detection/nodejs/) * npm run dev (in the project root, to start the Vite server on port 3000) * * Run: * node puppeteer-headless.js */ const puppeteer = require('puppeteer'); const TARGET_URL = 'http://localhost:3000/test/dev-source.html'; const WAIT_TIMEOUT_MS = 15000; (async () => { console.log('[puppeteer-headless] Launching headless Chrome...'); const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); const page = await browser.newPage(); console.log(`[puppeteer-headless] Navigating to ${TARGET_URL}`); await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' }); console.log('[puppeteer-headless] Waiting for fingerprint result...'); await page.waitForFunction( () => window.result !== undefined, { timeout: WAIT_TIMEOUT_MS } ); const fastBotDetectionDetails = await page.evaluate( () => window.result.fastBotDetectionDetails ); console.log('\n=== fastBotDetectionDetails ==='); console.log(JSON.stringify(fastBotDetectionDetails, null, 2)); const triggered = Object.entries(fastBotDetectionDetails) .filter(([, v]) => v.detected) .map(([k]) => k); console.log(`\n=== Triggered detections (${triggered.length}) ===`); if (triggered.length === 0) { console.log('None'); } else { triggered.forEach(name => console.log(` • ${name}`)); } await browser.close(); console.log('\n[puppeteer-headless] Done.'); })(); ================================================ FILE: test/detection/nodejs/puppeteer-stealth.js ================================================ /** * Detection test: Puppeteer with puppeteer-extra-plugin-stealth * * The stealth plugin applies a collection of evasion techniques designed to * make headless Chrome look more like a real browser. * * Prerequisites: * npm install (inside test/detection/nodejs/) * npm run dev (in the project root, to start the Vite server on port 3000) * * Run: * node puppeteer-stealth.js */ const puppeteerExtra = require('puppeteer-extra'); const StealthPlugin = require('puppeteer-extra-plugin-stealth'); puppeteerExtra.use(StealthPlugin()); const TARGET_URL = 'http://localhost:3000/test/dev-source.html'; const WAIT_TIMEOUT_MS = 15000; (async () => { console.log('[puppeteer-stealth] Launching headless Chrome with stealth plugin...'); const browser = await puppeteerExtra.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); const page = await browser.newPage(); console.log(`[puppeteer-stealth] Navigating to ${TARGET_URL}`); await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' }); console.log('[puppeteer-stealth] Waiting for fingerprint result...'); await page.waitForFunction( () => window.result !== undefined, { timeout: WAIT_TIMEOUT_MS } ); const fastBotDetectionDetails = await page.evaluate( () => window.result.fastBotDetectionDetails ); console.log('\n=== fastBotDetectionDetails ==='); console.log(JSON.stringify(fastBotDetectionDetails, null, 2)); const triggered = Object.entries(fastBotDetectionDetails) .filter(([, v]) => v.detected) .map(([k]) => k); console.log(`\n=== Triggered detections (${triggered.length}) ===`); if (triggered.length === 0) { console.log('None'); } else { triggered.forEach(name => console.log(` • ${name}`)); } await browser.close(); console.log('\n[puppeteer-stealth] Done.'); })(); ================================================ FILE: test/detection/python/camoufox_test.py ================================================ """ Detection test: Camoufox (https://github.com/daijro/camoufox) Camoufox is a patched Firefox build that intercepts fingerprint calls at the C++ level, making spoofing undetectable through JavaScript inspection. It uses Playwright's sync API under the hood. Prerequisites: pip install -r requirements.txt python -m camoufox fetch # one-time download of the patched browser # Start the Vite dev server in the project root: npm run dev Run: python camoufox_test.py """ from camoufox.sync_api import Camoufox TARGET_URL = "http://localhost:3000/test/dev-source.html" WAIT_TIMEOUT_MS = 15000 def main(): print("[camoufox] Launching Camoufox (headless Firefox)...") with Camoufox(headless=True) as browser: page = browser.new_page() print(f"[camoufox] Navigating to {TARGET_URL}") page.goto(TARGET_URL) print("[camoufox] Waiting for fingerprint result...") page.wait_for_function("() => window.result !== undefined", timeout=WAIT_TIMEOUT_MS) fast_bot_detection_details = page.evaluate("() => window.result.fastBotDetectionDetails") import json print("\n=== fastBotDetectionDetails ===") print(json.dumps(fast_bot_detection_details, indent=2)) triggered = [ name for name, value in fast_bot_detection_details.items() if value.get("detected") ] print(f"\n=== Triggered detections ({len(triggered)}) ===") if not triggered: print("None") else: for name in triggered: print(f" • {name}") print("\n[camoufox] Done.") if __name__ == "__main__": main() ================================================ FILE: test/detection/python/requirements.txt ================================================ setuptools camoufox selenium undetected-chromedriver ================================================ FILE: test/detection/python/selenium_headless_test.py ================================================ """ Detection test: Selenium + headless Chrome (no evasion) Prerequisites: pip install -r requirements.txt # Start the Vite dev server in the project root: npm run dev Run: python selenium_headless_test.py """ import json import time from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service from selenium.webdriver.support.ui import WebDriverWait TARGET_URL = "http://localhost:3000/test/dev-source.html" WAIT_TIMEOUT_SECONDS = 15 POLL_INTERVAL_SECONDS = 0.5 def wait_for_result(driver, timeout=WAIT_TIMEOUT_SECONDS): """Poll until window.result is populated by the fingerprint scanner.""" deadline = time.time() + timeout while time.time() < deadline: ready = driver.execute_script("return window.result !== undefined;") if ready: return time.sleep(POLL_INTERVAL_SECONDS) raise TimeoutError( f"window.result was not set within {timeout}s. " "Make sure the Vite dev server is running on port 3000." ) def main(): print("[selenium-headless] Launching headless Chrome...") options = Options() options.add_argument("--headless=new") options.add_argument("--no-sandbox") options.add_argument("--disable-setuid-sandbox") driver = webdriver.Chrome(options=options) try: print(f"[selenium-headless] Navigating to {TARGET_URL}") driver.get(TARGET_URL) print("[selenium-headless] Waiting for fingerprint result...") wait_for_result(driver) fast_bot_detection_details = driver.execute_script( "return window.result.fastBotDetectionDetails;" ) print("\n=== fastBotDetectionDetails ===") print(json.dumps(fast_bot_detection_details, indent=2)) triggered = [ name for name, value in fast_bot_detection_details.items() if value.get("detected") ] print(f"\n=== Triggered detections ({len(triggered)}) ===") if not triggered: print("None") else: for name in triggered: print(f" • {name}") finally: driver.quit() print("\n[selenium-headless] Done.") if __name__ == "__main__": main() ================================================ FILE: test/detection/python/undetected_chromedriver_test.py ================================================ """ Detection test: undetected-chromedriver undetected-chromedriver patches the ChromeDriver binary to avoid triggering common bot-detection heuristics (navigator.webdriver removal, CDP fingerprint patches, etc.). Prerequisites: pip install -r requirements.txt # Start the Vite dev server in the project root: npm run dev Run: python undetected_chromedriver_test.py """ import sys # Python 3.12 removed distutils from the stdlib; undetected-chromedriver still # depends on it. Inject the setuptools shim before the import so the module # resolver finds distutils.version without touching the installed package. try: import distutils # noqa: F401 except ImportError: import setuptools # noqa: F401 – registers the distutils meta-path finder import setuptools._distutils as _distutils import setuptools._distutils.version as _distutils_version sys.modules.setdefault("distutils", _distutils) sys.modules.setdefault("distutils.version", _distutils_version) import json import time import undetected_chromedriver as uc TARGET_URL = "http://localhost:3000/test/dev-source.html" WAIT_TIMEOUT_SECONDS = 15 POLL_INTERVAL_SECONDS = 0.5 def wait_for_result(driver, timeout=WAIT_TIMEOUT_SECONDS): """Poll until window.result is populated by the fingerprint scanner.""" deadline = time.time() + timeout while time.time() < deadline: ready = driver.execute_script("return window.result !== undefined;") if ready: return time.sleep(POLL_INTERVAL_SECONDS) raise TimeoutError( f"window.result was not set within {timeout}s. " "Make sure the Vite dev server is running on port 3000." ) def main(): print("[undetected-chromedriver] Launching Chrome...") options = uc.ChromeOptions() # Run headless so it matches the puppeteer examples # options.add_argument("--headless=new") driver = uc.Chrome(options=options, use_subprocess=False) try: print(f"[undetected-chromedriver] Navigating to {TARGET_URL}") driver.get(TARGET_URL) print("[undetected-chromedriver] Waiting for fingerprint result...") wait_for_result(driver) fast_bot_detection_details = driver.execute_script( "return window.result.fastBotDetectionDetails;" ) print("\n=== fastBotDetectionDetails ===") print(json.dumps(fast_bot_detection_details, indent=2)) triggered = [ name for name, value in fast_bot_detection_details.items() if value.get("detected") ] print(f"\n=== Triggered detections ({len(triggered)}) ===") if not triggered: print("None") else: for name in triggered: print(f" • {name}") finally: driver.quit() print("\n[undetected-chromedriver] Done.") if __name__ == "__main__": main() ================================================ FILE: test/dev-dist.html ================================================ Fingerprint Scanner Test (Obfuscated Build)

Fingerprint Scanner Test (Obfuscated Build)

This page uses the pre-built, obfuscated version from dist/.

Open the browser console to see the fingerprint results.

Build with: npm run build:obfuscate or npm run build:prod

================================================ FILE: test/dev-source.html ================================================ Fingerprint Scanner Test

Fingerprint Scanner Test

Open the browser console to see the fingerprint results.

Loading fingerprint...
================================================ FILE: test/fingerprint.spec.ts ================================================ import { test, expect, Page } from '@playwright/test'; import { decryptFingerprint } from './decrypt.js'; let fingerprint: any; test.describe('FPScanner Obfuscated Build', () => { test.beforeAll(async ({ browser }) => { const page = await browser.newPage(); // Navigate to the test page await page.goto('http://localhost:3333/test/test-page.html'); // Wait for the fingerprint to be collected await page.waitForFunction(() => (window as any).__FINGERPRINT_READY__ === true, { timeout: 10000, }); // Check if there was an error const error = await page.evaluate(() => (window as any).__FINGERPRINT_ERROR__); if (error) { throw new Error(`Fingerprint collection failed: ${error}`); } // Get the encrypted fingerprint from the browser const encryptedFingerprint = await page.evaluate(() => (window as any).__ENCRYPTED_FINGERPRINT__); if (!encryptedFingerprint) { throw new Error('Encrypted fingerprint is empty'); } // Decrypt the fingerprint (server-side simulation) fingerprint = decryptFingerprint(encryptedFingerprint); await page.close(); }); // Structure validations test('fingerprint should have signals property', () => { expect(fingerprint).toHaveProperty('signals'); }); test('fingerprint should have fsid property', () => { expect(fingerprint).toHaveProperty('fsid'); }); test('fingerprint should have nonce property', () => { expect(fingerprint).toHaveProperty('nonce'); }); test('fingerprint should have time property', () => { expect(fingerprint).toHaveProperty('time'); }); // FSID format validation test('fsid should be a non-empty string starting with FS1_', () => { expect(typeof fingerprint.fsid).toBe('string'); expect(fingerprint.fsid.length).toBeGreaterThan(0); expect(fingerprint.fsid).toMatch(/^FS1_/); }); // Signal validations - using nested structure test('device.memory should be a number greater than 0 or NA', () => { const memory = fingerprint.signals.device.memory; if (memory === 'NA') { // Firefox and WebKit don't support navigator.deviceMemory expect(memory).toBe('NA'); } else { expect(typeof memory).toBe('number'); expect(memory).toBeGreaterThan(0); } }); test('device.cpuCount should be a number greater than 0', () => { const cpuCount = fingerprint.signals.device.cpuCount; expect(typeof cpuCount).toBe('number'); expect(cpuCount).toBeGreaterThan(0); }); test('browser.userAgent should be a non-empty string', () => { expect(fingerprint.signals.browser).toHaveProperty('userAgent'); expect(typeof fingerprint.signals.browser.userAgent).toBe('string'); expect(fingerprint.signals.browser.userAgent.length).toBeGreaterThan(0); }); test('device.platform should be a non-empty string', () => { expect(fingerprint.signals.device).toHaveProperty('platform'); expect(typeof fingerprint.signals.device.platform).toBe('string'); expect(fingerprint.signals.device.platform.length).toBeGreaterThan(0); }); test('automation.webdriver should be a boolean', () => { expect(typeof fingerprint.signals.automation.webdriver).toBe('boolean'); }); test('device.screenResolution should have valid dimensions', () => { const screen = fingerprint.signals.device.screenResolution; expect(screen).toHaveProperty('width'); expect(screen).toHaveProperty('height'); expect(typeof screen.width).toBe('number'); expect(typeof screen.height).toBe('number'); expect(screen.width).toBeGreaterThan(0); expect(screen.height).toBeGreaterThan(0); }); test('locale.languages should have language property', () => { expect(fingerprint.signals.locale.languages).toHaveProperty('language'); expect(typeof fingerprint.signals.locale.languages.language).toBe('string'); }); test('graphics.webGL should have vendor and renderer', () => { const webGL = fingerprint.signals.graphics.webGL; expect(typeof webGL.vendor).toBe('string'); expect(typeof webGL.renderer).toBe('string'); expect(webGL.vendor.length).toBeGreaterThan(0); expect(webGL.renderer.length).toBeGreaterThan(0); }); // Additional nested structure tests test('automation signals should exist', () => { expect(fingerprint.signals).toHaveProperty('automation'); expect(typeof fingerprint.signals.automation.cdp).toBe('boolean'); expect(typeof fingerprint.signals.automation.selenium).toBe('boolean'); expect(typeof fingerprint.signals.automation.playwright).toBe('boolean'); }); test('codecs signals should exist', () => { expect(fingerprint.signals).toHaveProperty('codecs'); expect(typeof fingerprint.signals.codecs.hasMediaSource).toBe('boolean'); }); test('contexts signals should exist', () => { expect(fingerprint.signals).toHaveProperty('contexts'); expect(fingerprint.signals.contexts).toHaveProperty('iframe'); expect(fingerprint.signals.contexts).toHaveProperty('webWorker'); }); }); ================================================ FILE: test/server.js ================================================ const http = require('http'); const fs = require('fs'); const path = require('path'); const PORT = 3333; const ROOT = path.resolve(__dirname, '..'); const MIME_TYPES = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', }; const server = http.createServer((req, res) => { let filePath = path.join(ROOT, req.url === '/' ? 'test/test-page.html' : req.url); // Security: prevent directory traversal if (!filePath.startsWith(ROOT)) { res.writeHead(403); res.end('Forbidden'); return; } const ext = path.extname(filePath); const contentType = MIME_TYPES[ext] || 'text/plain'; fs.readFile(filePath, (err, content) => { if (err) { if (err.code === 'ENOENT') { res.writeHead(404); res.end(`Not found: ${req.url}`); } else { res.writeHead(500); res.end(`Server error: ${err.message}`); } return; } res.writeHead(200, { 'Content-Type': contentType }); res.end(content); }); }); server.listen(PORT, () => { console.log(`Test server running at http://localhost:${PORT}`); }); ================================================ FILE: test/test-page.html ================================================ FPScanner Test Page

FPScanner Test Page

This page is used for automated testing with Playwright.

Loading...
================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "module": "ESNext", "lib": ["ES2020", "DOM"], "moduleResolution": "node", "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "test"] } ================================================ FILE: vite.config.ts ================================================ import { defineConfig } from 'vite'; import { resolve } from 'path'; export default defineConfig({ define: { // Inject encryption key from environment variable, fallback to sentinel for replacement __FP_ENCRYPTION_KEY__: JSON.stringify( process.env.FP_ENCRYPTION_KEY || '__DEFAULT_FPSCANNER_KEY__' ), }, server: { port: 3000, open: '/test/dev-source.html', }, build: { lib: { entry: resolve(__dirname, 'src/index.ts'), name: 'FingerprintScanner', fileName: (format) => `fpScanner.${format}.js`, formats: ['es', 'cjs'], }, rollupOptions: { output: { exports: 'named', }, }, outDir: 'dist', sourcemap: false, // Disable source maps for production builds }, test: { globals: true, environment: 'node', }, });