Showing preview only (226K chars total). Download the full file or copy to clipboard to get everything.
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 <antoine.vastel@gmail.com>
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.
[](https://github.com/antoinevastel/fpscanner/actions/workflows/ci.yml)
## Sponsor
This project is sponsored by <a href="https://castle.io/?utm_source=github&utm_medium=oss&utm_campaign=fpscanner">Castle.</a>
<a href="https://castle.io/?utm_source=github&utm_medium=oss&utm_campaign=fpscanner"><img src="assets/castle-logo.png" alt="Castle" height="48" style="vertical-align: middle;"></a>
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_<det>_<auto>_<dev>_<brw>_<gfx>_<cod>_<loc>_<ctx>
```
### 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<hash>` | `10010h3f2a` | Automation booleans + hash |
| 4 | **Device** | `<W>x<H>c<cpu>m<mem>b<5-bit>h<hash>` | `1728x1117c14m08b01011h4e7a9f` | Screen, cpu, memory, device booleans + hash |
| 5 | **Browser** | `f<10-bit>e<8-bit>p<4-bit>h<hash>` | `f1101011001e00000000p1100h2c8b1e` | Features + extensions + plugins bitmasks + hash |
| 6 | **Graphics** | `<1-bit>h<hash>` | `0h9d3f7a` | hasModifiedCanvas + hash |
| 7 | **Codecs** | `<1-bit>h<hash>` | `1h6a2e4c` | hasMediaSource + hash |
| 8 | **Locale** | `<lang><n>t<tz>_h<hash>` | `en4tEurope-Paris_hab12` | Language code + count + timezone + hash |
| 9 | **Contexts** | `<4-bit>h<hash>` | `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
<details>
<summary><strong>Bitmask Reference</strong></summary>
#### 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
```
</details>
---
## 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
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FPScanner Demo - Node.js</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.card {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 { color: #333; margin-top: 0; }
.status {
padding: 12px;
border-radius: 4px;
margin-top: 16px;
}
.status.loading { background: #fff3cd; color: #856404; }
.status.success { background: #d4edda; color: #155724; }
.status.error { background: #f8d7da; color: #721c24; }
pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
max-height: 500px;
overflow-y: auto;
}
.key { color: #9cdcfe; }
.string { color: #ce9178; }
.number { color: #b5cea8; }
.boolean { color: #569cd6; }
.null { color: #569cd6; }
h3 { margin-top: 20px; color: #333; }
</style>
</head>
<body>
<div class="card">
<h1>🔍 FPScanner Demo</h1>
<p>This page collects a browser fingerprint and sends it to the Node.js server for decryption.</p>
<div id="status" class="status loading">Collecting fingerprint...</div>
<div id="result"></div>
</div>
<script type="module">
// Import the fingerprint scanner from the dist folder
// In production, this would be your built fpscanner package
import FingerprintScanner from '../../dist/fpScanner.es.js';
const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
async function run() {
try {
// Collect the encrypted fingerprint
const scanner = new FingerprintScanner();
const encryptedFingerprint = await scanner.collectFingerprint({ encrypt: true });
statusEl.textContent = 'Sending to server...';
// Send to the server
const response = await fetch('/api/fingerprint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fingerprint: encryptedFingerprint })
});
const data = await response.json();
if (data.success) {
statusEl.className = 'status success';
statusEl.textContent = '✓ Fingerprint received and decrypted by server!';
resultEl.innerHTML = `
<h3>Decrypted Fingerprint:</h3>
<pre>${syntaxHighlight(data.fingerprint)}</pre>
`;
} else {
throw new Error(data.error || 'Server error');
}
} catch (error) {
statusEl.className = 'status error';
statusEl.textContent = '✗ Error: ' + error.message;
console.error(error);
}
}
// Syntax highlighting for JSON
function syntaxHighlight(obj) {
let json = JSON.stringify(obj, null, 2);
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
let cls = 'number';
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key';
} else {
cls = 'string';
}
} else if (/true|false/.test(match)) {
cls = 'boolean';
} else if (/null/.test(match)) {
cls = 'null';
}
return '<span class="' + cls + '">' + match + '</span>';
});
}
run();
</script>
</body>
</html>
================================================
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
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FPScanner Demo - Python</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.card {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 { color: #333; margin-top: 0; }
.status {
padding: 12px;
border-radius: 4px;
margin-top: 16px;
}
.status.loading { background: #fff3cd; color: #856404; }
.status.success { background: #d4edda; color: #155724; }
.status.error { background: #f8d7da; color: #721c24; }
pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
max-height: 500px;
overflow-y: auto;
}
.key { color: #9cdcfe; }
.string { color: #ce9178; }
.number { color: #b5cea8; }
.boolean { color: #569cd6; }
.null { color: #569cd6; }
h3 { margin-top: 20px; color: #333; }
</style>
</head>
<body>
<div class="card">
<h1>🐍 FPScanner Demo (Python)</h1>
<p>This page collects a browser fingerprint and sends it to the Python server for decryption.</p>
<div id="status" class="status loading">Collecting fingerprint...</div>
<div id="result"></div>
</div>
<script type="module">
// Import the fingerprint scanner from the dist folder
// In production, this would be your built fpscanner package
import FingerprintScanner from '../../dist/fpScanner.es.js';
const statusEl = document.getElementById('status');
const resultEl = document.getElementById('result');
async function run() {
try {
// Collect the encrypted fingerprint
const scanner = new FingerprintScanner();
const encryptedFingerprint = await scanner.collectFingerprint();
statusEl.textContent = 'Sending to server...';
// Send to the server
const response = await fetch('/api/fingerprint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fingerprint: encryptedFingerprint })
});
const data = await response.json();
if (data.success) {
statusEl.className = 'status success';
statusEl.textContent = '✓ Fingerprint received and decrypted by server!';
resultEl.innerHTML = `
<h3>Decrypted Fingerprint:</h3>
<pre>${syntaxHighlight(data.fingerprint)}</pre>
`;
} else {
throw new Error(data.error || 'Server error');
}
} catch (error) {
statusEl.className = 'status error';
statusEl.textContent = '✗ Error: ' + error.message;
console.error(error);
}
}
// Syntax highlighting for JSON
function syntaxHighlight(obj) {
let json = JSON.stringify(obj, null, 2);
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
let cls = 'number';
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key';
} else {
cls = 'string';
}
} else if (/true|false/.test(match)) {
cls = 'boolean';
} else if (/null/.test(match)) {
cls = 'null';
}
return '<span class="' + cls + '">' + match + '</span>';
});
}
run();
</script>
</body>
</html>
================================================
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 <antoine.vastel@gmail.com>",
"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<string> {
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<string> {
// 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_<det>_<auto>_<dev>_<brw>_<gfx>_<cod>_<loc>_<ctx>
*
* Each section is delimited by '_', allowing partial matching.
* Sections use the pattern: <bitmask>h<hash> 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<keyof typeof signalTasks, any>;
// 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<SignalValue<boolean>> {
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<string> {
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<boolean>,
canvasFingerprint: INIT as SignalValue<string>,
};
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<string, string | null> {
const result: Record<string, string | null> = {};
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<string, boolean | null> {
const result: Record<string, boolean | null> = {};
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<boolean>,
pluginCount: INIT as SignalValue<number>,
pluginNamesHash: INIT as SignalValue<string>,
pluginConsistency1: INIT as SignalValue<boolean>,
pluginOverflow: INIT as SignalValue<boolean>,
}
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> = T | typeof ERROR | typeof INIT | typeof NA | typeof SKIPPED;
export interface WebGLSignal {
vendor: SignalValue<string>;
renderer: SignalValue<string>;
}
export interface InternationalizationSignal {
timezone: SignalValue<string>;
localeLanguage: SignalValue<string>;
}
export interface ScreenResolutionSignal {
width: SignalValue<number>;
height: SignalValue<number>;
pixelDepth: SignalValue<number>;
colorDepth: SignalValue<number>;
availableWidth: SignalValue<number>;
availableHeight: SignalValue<number>;
innerWidth: SignalValue<number>;
innerHeight: SignalValue<number>;
hasMultipleDisplays: SignalValue<boolean>;
}
export interface LanguagesSignal {
languages: SignalValue<string[]>;
language: SignalValue<string>;
}
export interface WebGPUSignal {
vendor: SignalValue<string>;
architecture: SignalValue<string>;
device: SignalValue<string>;
description: SignalValue<string>;
}
export interface IframeSignal {
webdriver: SignalValue<boolean>;
userAgent: SignalValue<string>;
platform: SignalValue<string>;
memory: SignalValue<number>;
cpuCount: SignalValue<number>;
language: SignalValue<string>;
}
export interface WebWorkerSignal {
webdriver: SignalValue<boolean>;
userAgent: SignalValue<string>;
platform: SignalValue<string>;
memory: SignalValue<number>;
cpuCount: SignalValue<number>;
language: SignalValue<string>;
vendor: SignalValue<string>;
renderer: SignalValue<string>;
}
export interface BrowserExtensionsSignal {
bitmask: SignalValue<string>;
extensions: SignalValue<string[]>;
}
export interface BrowserFeaturesSignal {
bitmask: SignalValue<string>;
chrome: SignalValue<boolean>;
brave: SignalValue<boolean>;
applePaySupport: SignalValue<boolean>;
opera: SignalValue<boolean>;
serial: SignalValue<boolean>;
attachShadow: SignalValue<boolean>;
caches: SignalValue<boolean>;
webAssembly: SignalValue<boolean>;
buffer: SignalValue<boolean>;
showModalDialog: SignalValue<boolean>;
safari: SignalValue<boolean>;
webkitPrefixedFunction: SignalValue<boolean>;
mozPrefixedFunction: SignalValue<boolean>;
usb: SignalValue<boolean>;
browserCapture: SignalValue<boolean>;
paymentRequestUpdateEvent: SignalValue<boolean>;
pressureObserver: SignalValue<boolean>;
audioSession: SignalValue<boolean>;
selectAudioOutput: SignalValue<boolean>;
barcodeDetector: SignalValue<boolean>;
battery: SignalValue<boolean>;
devicePosture: SignalValue<boolean>;
documentPictureInPicture: SignalValue<boolean>;
eyeDropper: SignalValue<boolean>;
editContext: SignalValue<boolean>;
fencedFrame: SignalValue<boolean>;
sanitizer: SignalValue<boolean>;
otpCredential: SignalValue<boolean>;
}
export interface MediaQueriesSignal {
prefersColorScheme: SignalValue<string | null>;
prefersReducedMotion: SignalValue<boolean>;
prefersReducedTransparency: SignalValue<boolean>;
colorGamut: SignalValue<string | null>;
pointer: SignalValue<string | null>;
anyPointer: SignalValue<string | null>;
hover: SignalValue<boolean>;
anyHover: SignalValue<boolean>;
colorDepth: SignalValue<number>;
}
export interface ToSourceErrorSignal {
toSourceError: SignalValue<string>;
hasToSource: SignalValue<boolean>;
}
export interface CanvasSignal {
hasModifiedCanvas: SignalValue<boolean>;
canvasFingerprint: SignalValue<string>;
}
export interface HighEntropyValuesSignal {
architecture: SignalValue<string>;
bitness: SignalValue<string>;
brands: SignalValue<string[]>;
mobile: SignalValue<boolean>;
model: SignalValue<string>;
platform: SignalValue<string>;
platformVersion: SignalValue<string>;
uaFullVersion: SignalValue<string>;
}
export interface PluginsSignal {
isValidPluginArray: SignalValue<boolean>;
pluginCount: SignalValue<number>;
pluginNamesHash: SignalValue<string>;
pluginConsistency1: SignalValue<boolean>;
pluginOverflow: SignalValue<boolean>;
}
export interface MultimediaDevicesSignal {
speakers: SignalValue<number>;
microphones: SignalValue<number>;
webcams: SignalValue<number>;
}
export interface MediaCodecsSignal {
audioCanPlayTypeHash: SignalValue<string>;
videoCanPlayTypeHash: SignalValue<string>;
audioMediaSourceHash: SignalValue<string>;
videoMediaSourceHash: SignalValue<string>;
rtcAudioCapabilitiesHash: SignalValue<string>;
rtcVideoCapabilitiesHash: SignalValue<string>;
hasMediaSource: SignalValue<boolean>;
}
// Grouped signal interfaces
export interface AutomationSignals {
webdriver: SignalValue<boolean>;
webdriverWritable: SignalValue<boolean>;
selenium: SignalValue<boolean>;
cdp: SignalValue<boolean>;
playwright: SignalValue<boolean>;
navigatorPropertyDescriptors: SignalValue<string>;
}
export interface DeviceSignals {
cpuCount: SignalValue<number>;
memory: SignalValue<number>;
platform: SignalValue<string>;
screenResolution: ScreenResolutionSignal;
multimediaDevices: MultimediaDevicesSignal;
mediaQueries: MediaQueriesSignal;
}
export interface BrowserSignals {
userAgent: SignalValue<string>;
features: BrowserFeaturesSignal;
plugins: PluginsSignal;
extensions: BrowserExtensionsSignal;
highEntropyValues: HighEntropyValuesSignal;
etsl: SignalValue<number>;
maths: SignalValue<string>;
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<number>;
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)
Prerequ
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
SYMBOL INDEX (165 symbols across 74 files)
FILE: bin/cli.js
function loadEnvFile (line 10) | function loadEnvFile(filePath) {
function resolveKey (line 43) | function resolveKey(args, cwd) {
function printHelp (line 76) | function printHelp() {
FILE: examples/nodejs/demo-server.js
constant PORT (line 12) | const PORT = 3000;
constant ENCRYPTION_KEY (line 16) | const ENCRYPTION_KEY = process.env.FINGERPRINT_KEY || 'dev-key';
function decryptString (line 21) | function decryptString(ciphertext, key) {
function decryptFingerprint (line 41) | function decryptFingerprint(encryptedFingerprint) {
constant MIME_TYPES (line 52) | const MIME_TYPES = {
FILE: examples/python/demo-server.py
function xor_decrypt (line 22) | def xor_decrypt(ciphertext_b64: str, key: str) -> str:
function decrypt_fingerprint (line 45) | def decrypt_fingerprint(encrypted_fingerprint: str) -> dict:
class FingerprintHandler (line 63) | class FingerprintHandler(SimpleHTTPRequestHandler):
method __init__ (line 66) | def __init__(self, *args, **kwargs):
method do_POST (line 70) | def do_POST(self):
method do_GET (line 117) | def do_GET(self):
method _send_json (line 148) | def _send_json(self, status: int, data: dict):
method log_message (line 155) | def log_message(self, format, *args):
function main (line 162) | def main():
FILE: scripts/build-custom.js
function deleteMapFiles (line 249) | function deleteMapFiles(dir, prefix = '') {
FILE: scripts/safe-publish.js
constant ROOT_DIR (line 14) | const ROOT_DIR = path.resolve(__dirname, '..');
constant DIST_DIR (line 15) | const DIST_DIR = path.join(ROOT_DIR, 'dist');
function run (line 17) | function run(command, description) {
function checkGitStatus (line 28) | function checkGitStatus() {
function getPackageVersion (line 45) | function getPackageVersion() {
FILE: src/crypto-helpers.ts
function encryptString (line 12) | async function encryptString(plaintext: string, key: string): Promise<st...
function decryptString (line 32) | async function decryptString(ciphertext: string, key: string): Promise<s...
FILE: src/detections/hasBotUserAgent.ts
function hasBotUserAgent (line 3) | function hasBotUserAgent(fingerprint: Fingerprint) {
FILE: src/detections/hasCDP.ts
function hasCDP (line 3) | function hasCDP(fingerprint: Fingerprint) {
FILE: src/detections/hasContextMismatch.ts
function hasContextMismatch (line 4) | function hasContextMismatch(fingerprint: Fingerprint, context: 'iframe' ...
FILE: src/detections/hasGPUMismatch.ts
function hasGPUMismatch (line 5) | function hasGPUMismatch(fingerprint: Fingerprint) {
FILE: src/detections/hasHeadlessChromeScreenResolution.ts
function hasHeadlessChromeScreenResolution (line 3) | function hasHeadlessChromeScreenResolution(fingerprint: Fingerprint) {
FILE: src/detections/hasHighCPUCount.ts
function hasHighCPUCount (line 3) | function hasHighCPUCount(fingerprint: Fingerprint) {
FILE: src/detections/hasImpossibleDeviceMemory.ts
function hasImpossibleDeviceMemory (line 3) | function hasImpossibleDeviceMemory(fingerprint: Fingerprint) {
FILE: src/detections/hasInconsistentEtsl.ts
function hasInconsistentEtsl (line 3) | function hasInconsistentEtsl(fingerprint: Fingerprint) {
FILE: src/detections/hasMismatchLanguages.ts
function hasMismatchLanguages (line 3) | function hasMismatchLanguages(fingerprint: Fingerprint) {
FILE: src/detections/hasMismatchPlatformIframe.ts
function hasMismatchPlatformIframe (line 4) | function hasMismatchPlatformIframe(fingerprint: Fingerprint) {
FILE: src/detections/hasMismatchPlatformWorker.ts
function hasMismatchPlatformWorker (line 4) | function hasMismatchPlatformWorker(fingerprint: Fingerprint) {
FILE: src/detections/hasMismatchWebGLInWorker.ts
function hasMismatchWebGLInWorker (line 4) | function hasMismatchWebGLInWorker(fingerprint: Fingerprint) {
FILE: src/detections/hasMissingChromeObject.ts
function hasMissingChromeObject (line 3) | function hasMissingChromeObject(fingerprint: Fingerprint) {
FILE: src/detections/hasPlatformMismatch.ts
function hasPlatformMismatch (line 4) | function hasPlatformMismatch(fingerprint: Fingerprint) {
FILE: src/detections/hasPlaywright.ts
function hasPlaywright (line 3) | function hasPlaywright(fingerprint: Fingerprint) {
FILE: src/detections/hasSeleniumProperty.ts
function hasSeleniumProperty (line 3) | function hasSeleniumProperty(fingerprint: Fingerprint) {
FILE: src/detections/hasSwiftshaderRenderer.ts
function hasSwiftshaderRenderer (line 3) | function hasSwiftshaderRenderer(fingerprint: Fingerprint) {
FILE: src/detections/hasUTCTimezone.ts
function hasUTCTimezone (line 3) | function hasUTCTimezone(fingerprint: Fingerprint) {
FILE: src/detections/hasWebdriver.ts
function hasWebdriver (line 3) | function hasWebdriver(fingerprint: Fingerprint) {
FILE: src/detections/hasWebdriverIframe.ts
function hasWebdriverIframe (line 3) | function hasWebdriverIframe(fingerprint: Fingerprint) {
FILE: src/detections/hasWebdriverWorker.ts
function hasWebdriverWorker (line 3) | function hasWebdriverWorker(fingerprint: Fingerprint) {
FILE: src/detections/hasWebdriverWritable.ts
function hasWebdriverWritable (line 3) | function hasWebdriverWritable(fingerprint: Fingerprint) {
FILE: src/index.ts
class FingerprintScanner (line 63) | class FingerprintScanner {
method constructor (line 66) | constructor() {
method collectSignal (line 264) | private async collectSignal(signal: () => any) {
method generateFingerprintScannerId (line 294) | private generateFingerprintScannerId(): string {
method encryptFingerprint (line 486) | private async encryptFingerprint(fingerprint: string) {
method getDetectionRules (line 509) | private getDetectionRules(): DetectionRule[] {
method runDetectionRules (line 535) | private runDetectionRules(): FastBotDetectionDetails {
method collectFingerprint (line 573) | async collectFingerprint(options: CollectFingerprintOptions = { encryp...
FILE: src/signals/browserExtensions.ts
function browserExtensions (line 3) | function browserExtensions() {
FILE: src/signals/browserFeatures.ts
function safeCheck (line 3) | function safeCheck(check: () => boolean): boolean {
function browserFeatures (line 11) | function browserFeatures() {
FILE: src/signals/canvas.ts
function hasModifiedCanvas (line 4) | async function hasModifiedCanvas(): Promise<SignalValue<boolean>> {
function getCanvasFingerprint (line 26) | function getCanvasFingerprint(): SignalValue<string> {
function canvas (line 73) | async function canvas() {
FILE: src/signals/cdp.ts
function cdp (line 3) | function cdp() {
FILE: src/signals/cpuCount.ts
function cpuCount (line 3) | function cpuCount() {
FILE: src/signals/etsl.ts
function etsl (line 1) | function etsl() {
FILE: src/signals/highEntropyValues.ts
function highEntropyValues (line 3) | async function highEntropyValues() {
FILE: src/signals/iframe.ts
function iframe (line 3) | function iframe() {
FILE: src/signals/internationalization.ts
function internationalization (line 3) | function internationalization() {
FILE: src/signals/languages.ts
function languages (line 1) | function languages() {
FILE: src/signals/maths.ts
function maths (line 3) | function maths() {
FILE: src/signals/mediaCodecs.ts
constant AUDIO_CODECS (line 4) | const AUDIO_CODECS = [
constant VIDEO_CODECS (line 15) | const VIDEO_CODECS = [
function getCanPlayTypeSupport (line 31) | function getCanPlayTypeSupport(codecs: string[], mediaType: 'audio' | 'v...
function getMediaSourceSupport (line 50) | function getMediaSourceSupport(codecs: string[]): Record<string, boolean...
function getRtcCapabilities (line 71) | function getRtcCapabilities(kind: 'audio' | 'video'): string | typeof NA...
function mediaCodecs (line 84) | function mediaCodecs() {
FILE: src/signals/mediaQueries.ts
function mediaQueries (line 3) | function mediaQueries() {
FILE: src/signals/memory.ts
function memory (line 3) | function memory() {
FILE: src/signals/multimediaDevices.ts
function multimediaDevices (line 3) | async function multimediaDevices() {
FILE: src/signals/navigatorPropertyDescriptors.ts
function navigatorPropertyDescriptors (line 1) | function navigatorPropertyDescriptors() {
FILE: src/signals/nonce.ts
function nonce (line 1) | function nonce() {
FILE: src/signals/platform.ts
function platform (line 1) | function platform() {
FILE: src/signals/playwright.ts
function playwright (line 1) | function playwright() {
FILE: src/signals/plugins.ts
function isValidPluginArray (line 4) | function isValidPluginArray() {
function getPluginNamesHash (line 16) | function getPluginNamesHash() {
function getPluginCount (line 27) | function getPluginCount() {
function getPluginConsistency1 (line 32) | function getPluginConsistency1() {
function getPluginOverflow (line 41) | function getPluginOverflow() {
function plugins (line 51) | function plugins() {
FILE: src/signals/screenResolution.ts
function screenResolution (line 3) | function screenResolution() {
FILE: src/signals/seleniumProperties.ts
function hasSeleniumProperties (line 1) | function hasSeleniumProperties() {
FILE: src/signals/time.ts
function time (line 1) | function time() {
FILE: src/signals/toSourceError.ts
function toSourceError (line 3) | function toSourceError() {
FILE: src/signals/url.ts
function pageURL (line 1) | function pageURL() {
FILE: src/signals/userAgent.ts
function userAgent (line 1) | function userAgent() {
FILE: src/signals/utils.ts
constant ERROR (line 1) | const ERROR = 'ERROR';
constant INIT (line 2) | const INIT = 'INIT';
constant SKIPPED (line 4) | const SKIPPED = 'SKIPPED';
constant HIGH (line 5) | const HIGH = 'high'
constant LOW (line 6) | const LOW = 'low'
constant MEDIUM (line 7) | const MEDIUM = 'medium'
function hashCode (line 10) | function hashCode(str: string) {
function setObjectValues (line 20) | function setObjectValues(object: any, value: any) {
function isFirefox (line 27) | function isFirefox() {
FILE: src/signals/webGL.ts
function webGL (line 3) | function webGL() {
FILE: src/signals/webdriver.ts
function webdriver (line 1) | function webdriver() {
FILE: src/signals/webdriverWritable.ts
function webdriverWritable (line 1) | function webdriverWritable() {
FILE: src/signals/webgpu.ts
function webgpu (line 3) | async function webgpu() {
FILE: src/signals/worker.ts
function worker (line 3) | async function worker() {
FILE: src/types.ts
type SignalValue (line 3) | type SignalValue<T> = T | typeof ERROR | typeof INIT | typeof NA | typeo...
type WebGLSignal (line 5) | interface WebGLSignal {
type InternationalizationSignal (line 10) | interface InternationalizationSignal {
type ScreenResolutionSignal (line 15) | interface ScreenResolutionSignal {
type LanguagesSignal (line 27) | interface LanguagesSignal {
type WebGPUSignal (line 32) | interface WebGPUSignal {
type IframeSignal (line 39) | interface IframeSignal {
type WebWorkerSignal (line 48) | interface WebWorkerSignal {
type BrowserExtensionsSignal (line 59) | interface BrowserExtensionsSignal {
type BrowserFeaturesSignal (line 64) | interface BrowserFeaturesSignal {
type MediaQueriesSignal (line 96) | interface MediaQueriesSignal {
type ToSourceErrorSignal (line 108) | interface ToSourceErrorSignal {
type CanvasSignal (line 113) | interface CanvasSignal {
type HighEntropyValuesSignal (line 118) | interface HighEntropyValuesSignal {
type PluginsSignal (line 129) | interface PluginsSignal {
type MultimediaDevicesSignal (line 137) | interface MultimediaDevicesSignal {
type MediaCodecsSignal (line 143) | interface MediaCodecsSignal {
type AutomationSignals (line 154) | interface AutomationSignals {
type DeviceSignals (line 163) | interface DeviceSignals {
type BrowserSignals (line 172) | interface BrowserSignals {
type GraphicsSignals (line 183) | interface GraphicsSignals {
type LocaleSignals (line 189) | interface LocaleSignals {
type ContextsSignals (line 194) | interface ContextsSignals {
type FingerprintSignals (line 199) | interface FingerprintSignals {
type FastBotDetectionDetails (line 209) | interface FastBotDetectionDetails {
type Fingerprint (line 232) | interface Fingerprint {
type DetectionSeverity (line 242) | type DetectionSeverity = 'low' | 'medium' | 'high';
type DetectionRuleResult (line 244) | interface DetectionRuleResult {
type DetectionRule (line 249) | interface DetectionRule {
type CollectFingerprintOptions (line 255) | interface CollectFingerprintOptions {
FILE: test/decrypt.js
constant TEST_KEY (line 6) | const TEST_KEY = 'dev-key';
function decryptString (line 14) | function decryptString(ciphertext, key = TEST_KEY) {
function decryptFingerprint (line 39) | function decryptFingerprint(encryptedFingerprint, key = TEST_KEY) {
FILE: test/detection/nodejs/playwright-android-headless.js
constant DEVICE (line 18) | const DEVICE = devices['Pixel 7'];
constant TARGET_URL (line 19) | const TARGET_URL = 'http://localhost:3000/test/dev-source.html';
constant WAIT_TIMEOUT_MS (line 20) | const WAIT_TIMEOUT_MS = 15000;
FILE: test/detection/nodejs/playwright-chromium-headless.js
constant TARGET_URL (line 15) | const TARGET_URL = 'http://localhost:3000/test/dev-source.html';
constant WAIT_TIMEOUT_MS (line 16) | const WAIT_TIMEOUT_MS = 15000;
FILE: test/detection/nodejs/playwright-firefox-headless.js
constant TARGET_URL (line 15) | const TARGET_URL = 'http://localhost:3000/test/dev-source.html';
constant WAIT_TIMEOUT_MS (line 16) | const WAIT_TIMEOUT_MS = 15000;
FILE: test/detection/nodejs/playwright-iphone-headless.js
constant DEVICE (line 18) | const DEVICE = devices['iPhone 15'];
constant TARGET_URL (line 19) | const TARGET_URL = 'http://localhost:3000/test/dev-source.html';
constant WAIT_TIMEOUT_MS (line 20) | const WAIT_TIMEOUT_MS = 15000;
FILE: test/detection/nodejs/playwright-webkit-headless.js
constant TARGET_URL (line 15) | const TARGET_URL = 'http://localhost:3000/test/dev-source.html';
constant WAIT_TIMEOUT_MS (line 16) | const WAIT_TIMEOUT_MS = 15000;
FILE: test/detection/nodejs/puppeteer-headless.js
constant TARGET_URL (line 14) | const TARGET_URL = 'http://localhost:3000/test/dev-source.html';
constant WAIT_TIMEOUT_MS (line 15) | const WAIT_TIMEOUT_MS = 15000;
FILE: test/detection/nodejs/puppeteer-stealth.js
constant TARGET_URL (line 20) | const TARGET_URL = 'http://localhost:3000/test/dev-source.html';
constant WAIT_TIMEOUT_MS (line 21) | const WAIT_TIMEOUT_MS = 15000;
FILE: test/detection/python/camoufox_test.py
function main (line 24) | def main():
FILE: test/detection/python/selenium_headless_test.py
function wait_for_result (line 26) | def wait_for_result(driver, timeout=WAIT_TIMEOUT_SECONDS):
function main (line 40) | def main():
FILE: test/detection/python/undetected_chromedriver_test.py
function wait_for_result (line 41) | def wait_for_result(driver, timeout=WAIT_TIMEOUT_SECONDS):
function main (line 55) | def main():
FILE: test/server.js
constant PORT (line 5) | const PORT = 3333;
constant ROOT (line 6) | const ROOT = path.resolve(__dirname, '..');
constant MIME_TYPES (line 8) | const MIME_TYPES = {
Condensed preview — 97 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (221K chars).
[
{
"path": ".babelrc",
"chars": 27,
"preview": "{\n \"presets\": [\"es2015\"]\n}"
},
{
"path": ".github/workflows/ci.yml",
"chars": 2376,
"preview": "name: CI\n\non:\n push:\n branches: [main, master, fpscanner-v2]\n pull_request:\n branches: [main, master]\n\njobs:\n b"
},
{
"path": ".gitignore",
"chars": 891,
"preview": ".idea/\ndist/\n\n# Logs\nlogs\n*.log\n\n# Runtime data\npids\n*.pid\n*.seed\n\n# Directory for instrumented libs generated by jscove"
},
{
"path": ".npmignore",
"chars": 237,
"preview": "# Backup files created by custom build script\ndist/*.original\n\n# Test files\ntest/\ntest-results/\nplaywright-report/\n.gith"
},
{
"path": "LICENSE",
"chars": 1107,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2017 antoinevastel <antoine.vastel@gmail.com>\n\nPermission is hereby granted, free o"
},
{
"path": "README.md",
"chars": 28079,
"preview": "# Fingerprint Scanner\n\n> **News:** After more than 7 years without any updates, the first release of the new FPScanner i"
},
{
"path": "bin/cli.js",
"chars": 6083,
"preview": "#!/usr/bin/env node\n\nconst path = require('path');\nconst fs = require('fs');\n\n/**\n * Load environment variables from a f"
},
{
"path": "examples/nodejs/README.md",
"chars": 2014,
"preview": "# FPScanner Node.js Demo\n\nThis example demonstrates how to use fpscanner with a Node.js backend server.\n\n## What it does"
},
{
"path": "examples/nodejs/demo-server.js",
"chars": 5014,
"preview": "/**\n * FPScanner Demo Server (Node.js)\n * \n * This server demonstrates how to receive and decrypt fingerprints\n * collec"
},
{
"path": "examples/nodejs/index.html",
"chars": 4596,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width"
},
{
"path": "examples/python/README.md",
"chars": 2674,
"preview": "# FPScanner Python Demo\n\nThis example demonstrates how to use fpscanner with a Python backend server.\n\n## What it does\n\n"
},
{
"path": "examples/python/demo-server.py",
"chars": 6511,
"preview": "#!/usr/bin/env python3\n\"\"\"\nFPScanner Demo Server (Python)\n\nThis server demonstrates how to receive and decrypt fingerpri"
},
{
"path": "examples/python/index.html",
"chars": 4586,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width"
},
{
"path": "package.json",
"chars": 2436,
"preview": "{\n \"name\": \"fpscanner\",\n \"version\": \"1.0.2\",\n \"description\": \"A lightweight browser fingerprinting and bot detection "
},
{
"path": "playwright.config.ts",
"chars": 746,
"preview": "import { defineConfig, devices } from '@playwright/test';\n\nexport default defineConfig({\n testDir: './test',\n testMatc"
},
{
"path": "scripts/build-custom.js",
"chars": 9898,
"preview": "#!/usr/bin/env node\n\nconst { execSync } = require('child_process');\nconst fs = require('fs');\nconst path = require('path"
},
{
"path": "scripts/safe-publish.js",
"chars": 3860,
"preview": "#!/usr/bin/env node\n\nconst { execSync } = require('child_process');\nconst fs = require('fs');\nconst path = require('path"
},
{
"path": "scripts/verify-publish.js",
"chars": 1555,
"preview": "#!/usr/bin/env node\n\n/**\n * Pre-publish verification script\n * Ensures the dist files contain the sentinel key for npm c"
},
{
"path": "src/crypto-helpers.ts",
"chars": 1776,
"preview": "/**\n * Simple and fast XOR-based encryption/decryption\n * Note: This is NOT cryptographically secure - use only for obfu"
},
{
"path": "src/detections/hasBotUserAgent.ts",
"chars": 383,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasBotUserAgent(fingerprint: Fingerprint) {\n const userAgent"
},
{
"path": "src/detections/hasCDP.ts",
"chars": 149,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasCDP(fingerprint: Fingerprint) {\n return fingerprint.signa"
},
{
"path": "src/detections/hasContextMismatch.ts",
"chars": 983,
"preview": "import { Fingerprint } from \"../types\";\n\n// Not used as a detection rule since, more like an indicator\nexport function h"
},
{
"path": "src/detections/hasGPUMismatch.ts",
"chars": 751,
"preview": "import { Fingerprint } from \"../types\";\n\n// For the moment, we only detect GPU mismatches related to Apple OS/GPU\n\nexpor"
},
{
"path": "src/detections/hasHeadlessChromeScreenResolution.ts",
"chars": 375,
"preview": "import { Fingerprint } from '../types';\n\nexport function hasHeadlessChromeScreenResolution(fingerprint: Fingerprint) {\n "
},
{
"path": "src/detections/hasHighCPUCount.ts",
"chars": 251,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasHighCPUCount(fingerprint: Fingerprint) {\n if (typeof fing"
},
{
"path": "src/detections/hasImpossibleDeviceMemory.ts",
"chars": 303,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasImpossibleDeviceMemory(fingerprint: Fingerprint) {\n if (t"
},
{
"path": "src/detections/hasInconsistentEtsl.ts",
"chars": 643,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasInconsistentEtsl(fingerprint: Fingerprint) {\n\n // On Chro"
},
{
"path": "src/detections/hasMismatchLanguages.ts",
"chars": 399,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasMismatchLanguages(fingerprint: Fingerprint) {\n const lang"
},
{
"path": "src/detections/hasMismatchPlatformIframe.ts",
"chars": 406,
"preview": "import { Fingerprint } from \"../types\";\nimport { ERROR, NA } from \"../signals/utils\";\n\nexport function hasMismatchPlatfo"
},
{
"path": "src/detections/hasMismatchPlatformWorker.ts",
"chars": 487,
"preview": "import { Fingerprint } from \"../types\";\nimport { ERROR, NA, SKIPPED } from \"../signals/utils\";\n\nexport function hasMisma"
},
{
"path": "src/detections/hasMismatchWebGLInWorker.ts",
"chars": 536,
"preview": "import { Fingerprint } from \"../types\";\nimport { ERROR, NA, SKIPPED } from \"../signals/utils\";\n\nexport function hasMisma"
},
{
"path": "src/detections/hasMissingChromeObject.ts",
"chars": 301,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasMissingChromeObject(fingerprint: Fingerprint) {\n const us"
},
{
"path": "src/detections/hasPlatformMismatch.ts",
"chars": 1365,
"preview": "import { Fingerprint } from \"../types\";\nimport { ERROR, NA } from \"../signals/utils\";\n\nexport function hasPlatformMismat"
},
{
"path": "src/detections/hasPlaywright.ts",
"chars": 163,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasPlaywright(fingerprint: Fingerprint) {\n return fingerprin"
},
{
"path": "src/detections/hasSeleniumProperty.ts",
"chars": 161,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasSeleniumProperty(fingerprint: Fingerprint) {\n return !!fi"
},
{
"path": "src/detections/hasSwiftshaderRenderer.ts",
"chars": 190,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasSwiftshaderRenderer(fingerprint: Fingerprint) {\n return f"
},
{
"path": "src/detections/hasUTCTimezone.ts",
"chars": 180,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasUTCTimezone(fingerprint: Fingerprint) {\n return fingerpri"
},
{
"path": "src/detections/hasWebdriver.ts",
"chars": 161,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasWebdriver(fingerprint: Fingerprint) {\n return fingerprint"
},
{
"path": "src/detections/hasWebdriverIframe.ts",
"chars": 173,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasWebdriverIframe(fingerprint: Fingerprint) {\n return finge"
},
{
"path": "src/detections/hasWebdriverWorker.ts",
"chars": 176,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasWebdriverWorker(fingerprint: Fingerprint) {\n return finge"
},
{
"path": "src/detections/hasWebdriverWritable.ts",
"chars": 178,
"preview": "import { Fingerprint } from \"../types\";\n\nexport function hasWebdriverWritable(fingerprint: Fingerprint) {\n return fin"
},
{
"path": "src/globals.d.ts",
"chars": 363,
"preview": "/**\n * Build-time constant injected via Vite's define option.\n * This is replaced with the actual encryption key during "
},
{
"path": "src/index.ts",
"chars": 33533,
"preview": "// Import all signals\nimport { webdriver } from './signals/webdriver';\nimport { userAgent } from './signals/userAgent';\n"
},
{
"path": "src/signals/browserExtensions.ts",
"chars": 1928,
"preview": "import { INIT } from \"./utils\";\n\nexport function browserExtensions() {\n const browserExtensionsData = {\n bitma"
},
{
"path": "src/signals/browserFeatures.ts",
"chars": 2949,
"preview": "import { INIT } from \"./utils\";\n\nfunction safeCheck(check: () => boolean): boolean {\n try {\n return check();\n "
},
{
"path": "src/signals/canvas.ts",
"chars": 2817,
"preview": "import { ERROR, INIT, hashCode } from './utils';\nimport { SignalValue } from '../types';\n\nasync function hasModifiedCanv"
},
{
"path": "src/signals/cdp.ts",
"chars": 460,
"preview": "import { ERROR } from './utils';\n\nexport function cdp() {\n try {\n let wasAccessed = false;\n const origi"
},
{
"path": "src/signals/cpuCount.ts",
"chars": 109,
"preview": "import { NA } from './utils';\n\nexport function cpuCount() {\n return navigator.hardwareConcurrency || NA;\n}"
},
{
"path": "src/signals/etsl.ts",
"chars": 61,
"preview": "export function etsl() {\n return eval.toString().length;\n}"
},
{
"path": "src/signals/highEntropyValues.ts",
"chars": 1407,
"preview": "import { ERROR, INIT, NA, setObjectValues } from \"./utils\";\n\nexport async function highEntropyValues() {\n const navig"
},
{
"path": "src/signals/iframe.ts",
"chars": 1309,
"preview": "import { ERROR, INIT, NA, setObjectValues } from './utils';\n\nexport function iframe() {\n const iframeData = {\n "
},
{
"path": "src/signals/internationalization.ts",
"chars": 807,
"preview": "import { INIT, ERROR, NA } from \"./utils\";\n\nexport function internationalization() {\n const internationalizationData "
},
{
"path": "src/signals/languages.ts",
"chars": 128,
"preview": "export function languages() {\n return {\n languages: navigator.languages,\n language: navigator.language,"
},
{
"path": "src/signals/maths.ts",
"chars": 826,
"preview": "import { hashCode } from './utils';\n\nexport function maths() {\n const results: number[] = [];\n const testValue = 0"
},
{
"path": "src/signals/mediaCodecs.ts",
"chars": 4183,
"preview": "import { ERROR, NA, hashCode, setObjectValues } from './utils';\n\n\nconst AUDIO_CODECS = [\n 'audio/mp4; codecs=\"mp4a.40"
},
{
"path": "src/signals/mediaQueries.ts",
"chars": 3443,
"preview": "import { ERROR, INIT, setObjectValues } from './utils';\n\nexport function mediaQueries() {\n const mediaQueriesData = {"
},
{
"path": "src/signals/memory.ts",
"chars": 109,
"preview": "import { NA } from \"./utils\";\n\nexport function memory() {\n return (navigator as any).deviceMemory || NA;\n}"
},
{
"path": "src/signals/multimediaDevices.ts",
"chars": 1187,
"preview": "import { NA, setObjectValues } from \"./utils\";\n\nexport async function multimediaDevices() {\n return new Promise(async"
},
{
"path": "src/signals/navigatorPropertyDescriptors.ts",
"chars": 473,
"preview": "export function navigatorPropertyDescriptors() {\n const properties = ['deviceMemory', 'hardwareConcurrency', 'languag"
},
{
"path": "src/signals/nonce.ts",
"chars": 84,
"preview": "export function nonce() {\n return Math.random().toString(36).substring(2, 15);\n}\n"
},
{
"path": "src/signals/platform.ts",
"chars": 61,
"preview": "export function platform() {\n return navigator.platform;\n}"
},
{
"path": "src/signals/playwright.ts",
"chars": 111,
"preview": "export function playwright() {\n return '__pwInitScripts' in window || '__playwright__binding__' in window;\n}"
},
{
"path": "src/signals/plugins.ts",
"chars": 2180,
"preview": "import { SignalValue } from \"../types\";\nimport { INIT, NA, hashCode, ERROR, setObjectValues} from \"./utils\";\n\nfunction i"
},
{
"path": "src/signals/screenResolution.ts",
"chars": 551,
"preview": "import { NA } from './utils';\n\nexport function screenResolution() {\n return {\n width: window.screen.width,\n "
},
{
"path": "src/signals/seleniumProperties.ts",
"chars": 1211,
"preview": "export function hasSeleniumProperties() {\n const seleniumProps = [\n \"__driver_evaluate\",\n \"__webdriver_"
},
{
"path": "src/signals/time.ts",
"chars": 60,
"preview": "export function time() {\n return new Date().getTime();\n}\n"
},
{
"path": "src/signals/toSourceError.ts",
"chars": 562,
"preview": "import { INIT } from './utils';\n\nexport function toSourceError() {\n const toSourceErrorData = {\n toSourceError"
},
{
"path": "src/signals/url.ts",
"chars": 63,
"preview": "export function pageURL() {\n return window.location.href;\n}\n"
},
{
"path": "src/signals/userAgent.ts",
"chars": 63,
"preview": "export function userAgent() {\n return navigator.userAgent;\n}"
},
{
"path": "src/signals/utils.ts",
"chars": 688,
"preview": "export const ERROR = 'ERROR';\nexport const INIT = 'INIT';\nexport const NA = 'NA';\nexport const SKIPPED = 'SKIPPED';\nexpo"
},
{
"path": "src/signals/webGL.ts",
"chars": 923,
"preview": "import { ERROR, INIT, NA, isFirefox, setObjectValues } from './utils';\n\nexport function webGL() {\n const webGLData = "
},
{
"path": "src/signals/webdriver.ts",
"chars": 64,
"preview": "export function webdriver() {\n return navigator.webdriver;\n};"
},
{
"path": "src/signals/webdriverWritable.ts",
"chars": 428,
"preview": "export function webdriverWritable() {\n try {\n const prop = \"webdriver\";\n const navigator = window.navig"
},
{
"path": "src/signals/webgpu.ts",
"chars": 801,
"preview": "import { ERROR, INIT, NA, setObjectValues } from \"./utils\";\n\nexport async function webgpu() {\n const webGPUData = {\n "
},
{
"path": "src/signals/worker.ts",
"chars": 3711,
"preview": "import { ERROR, INIT, setObjectValues } from './utils';\n\nexport async function worker() {\n return new Promise((resolv"
},
{
"path": "src/types.ts",
"chars": 7875,
"preview": "import { ERROR, INIT, NA, SKIPPED } from './signals/utils';\n\nexport type SignalValue<T> = T | typeof ERROR | typeof INIT"
},
{
"path": "test/decrypt.js",
"chars": 1695,
"preview": "/**\n * Server-side decryption helper for tests\n * This mimics what a real server would do to decrypt fingerprints\n */\n\nc"
},
{
"path": "test/detection/README.md",
"chars": 2565,
"preview": "# Detection Quality Tests\n\nThese scripts are **not** part of the CI/CD pipeline. They are manual tools for evaluating ho"
},
{
"path": "test/detection/nodejs/package.json",
"chars": 754,
"preview": "{\n \"name\": \"fpscanner-detection-tests-nodejs\",\n \"version\": \"1.0.0\",\n \"description\": \"Detection quality tests for fpsc"
},
{
"path": "test/detection/nodejs/playwright-android-headless.js",
"chars": 2015,
"preview": "/**\n * Detection test: Playwright + Chromium + Pixel 7 (Android) device emulation (headless)\n *\n * Uses Playwright's bui"
},
{
"path": "test/detection/nodejs/playwright-chromium-headless.js",
"chars": 1581,
"preview": "/**\n * Detection test: Playwright + Chromium headless (no evasion)\n *\n * Prerequisites:\n * npm install (inside test/d"
},
{
"path": "test/detection/nodejs/playwright-firefox-headless.js",
"chars": 1571,
"preview": "/**\n * Detection test: Playwright + Firefox headless (no evasion)\n *\n * Prerequisites:\n * npm install (inside test/de"
},
{
"path": "test/detection/nodejs/playwright-iphone-headless.js",
"chars": 2043,
"preview": "/**\n * Detection test: Playwright + Chromium + iPhone 15 device emulation (headless)\n *\n * Uses Playwright's built-in de"
},
{
"path": "test/detection/nodejs/playwright-webkit-headless.js",
"chars": 1561,
"preview": "/**\n * Detection test: Playwright + WebKit headless (no evasion)\n *\n * Prerequisites:\n * npm install (inside test/det"
},
{
"path": "test/detection/nodejs/puppeteer-headless.js",
"chars": 1670,
"preview": "/**\n * Detection test: Puppeteer with headless Chrome (no evasion)\n *\n * Prerequisites:\n * npm install (inside test/d"
},
{
"path": "test/detection/nodejs/puppeteer-stealth.js",
"chars": 1941,
"preview": "/**\n * Detection test: Puppeteer with puppeteer-extra-plugin-stealth\n *\n * The stealth plugin applies a collection of ev"
},
{
"path": "test/detection/python/camoufox_test.py",
"chars": 1693,
"preview": "\"\"\"\nDetection test: Camoufox (https://github.com/daijro/camoufox)\n\nCamoufox is a patched Firefox build that intercepts f"
},
{
"path": "test/detection/python/requirements.txt",
"chars": 53,
"preview": "setuptools\ncamoufox\nselenium\nundetected-chromedriver\n"
},
{
"path": "test/detection/python/selenium_headless_test.py",
"chars": 2301,
"preview": "\"\"\"\nDetection test: Selenium + headless Chrome (no evasion)\n\nPrerequisites:\n pip install -r requirements.txt\n # St"
},
{
"path": "test/detection/python/undetected_chromedriver_test.py",
"chars": 2919,
"preview": "\"\"\"\nDetection test: undetected-chromedriver\n\nundetected-chromedriver patches the ChromeDriver binary to avoid triggering"
},
{
"path": "test/dev-dist.html",
"chars": 1052,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width"
},
{
"path": "test/dev-source.html",
"chars": 3426,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width"
},
{
"path": "test/fingerprint.spec.ts",
"chars": 5065,
"preview": "import { test, expect, Page } from '@playwright/test';\nimport { decryptFingerprint } from './decrypt.js';\n\nlet fingerpri"
},
{
"path": "test/server.js",
"chars": 1146,
"preview": "const http = require('http');\nconst fs = require('fs');\nconst path = require('path');\n\nconst PORT = 3333;\nconst ROOT = p"
},
{
"path": "test/test-page.html",
"chars": 1447,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width"
},
{
"path": "tsconfig.json",
"chars": 491,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2020\",\n \"module\": \"ESNext\",\n \"lib\": [\"ES2020\", \"DOM\"],\n \"moduleResolu"
},
{
"path": "vite.config.ts",
"chars": 826,
"preview": "import { defineConfig } from 'vite';\nimport { resolve } from 'path';\n\nexport default defineConfig({\n define: {\n // I"
}
]
About this extraction
This page contains the full source code of the antoinevastel/fpscanner GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 97 files (201.7 KB), approximately 49.8k tokens, and a symbol index with 165 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.