test(e2e): run the real detectors (BotD + FingerprintJS OSS) on CI

Instead of only our hand-rolled signal checks, load the actual MIT detection
libraries against the patched binary and assert it isn't flagged:
- BotD (the client-side bot detector FingerprintJS Pro itself uses): detect()
  must return bot=false (no automation/headless tell).
- FingerprintJS OSS: visitorId present and stable across two fresh launches
  with the same seed (drift = per-session entropy = a bot tell).

Hermetic: the libs are vendored (tests/vendor/, pinned, MIT) and served from a
localhost server — no external CDN (Firefox tracking-protection blocks it
anyway), no IP/network dependency, runs identically on a dev box and the GitHub
runner. Both green locally against firefox-9.
This commit is contained in:
feder-cr 2026-06-09 17:53:11 +02:00
parent 8ba88958be
commit df4493d553
4 changed files with 1000 additions and 0 deletions

144
tests/test_detectors_e2e.py Normal file
View file

@ -0,0 +1,144 @@
"""E2E: run the REAL open-source detectors against the patched binary, on CI.
Instead of our own hand-rolled signal checks, this loads the actual detection
libraries and asserts the stealth build isn't flagged:
* BotD (@fingerprintjs/botd, MIT) the client-side bot detector that
FingerprintJS Pro itself uses. `detect()` must return ``bot == False``
(no automation/headless tell).
* FingerprintJS open-source (MIT) `get().visitorId` must be present and
STABLE across two fresh launches with the same seed (an over-randomized
spoof would drift; a real browser is stable).
Everything is hermetic: the libraries are vendored (tests/vendor/) and served
from a localhost HTTP server, so there is no external CDN call (Firefox
tracking-protection blocks the CDN anyway) and no IP/network dependency. It runs
identically on a dev box and on a GitHub runner.
NOT covered here: FingerprintJS *Pro* (commercial, server-side, IP/residential
analysis) that can't be self-hosted and stays the local/self-hosted realness
gate. CreepJS's full trust score needs its closed backend; only its client-side
signals are reachable offline.
"""
from __future__ import annotations
import http.server
import socketserver
import threading
from pathlib import Path
import pytest
from invisible_playwright import InvisiblePlaywright
_VENDOR = Path(__file__).parent / "vendor"
_BOTD = "botd-2.0.0.esm.js"
_FPJS = "fingerprintjs-5.2.0.umd.min.js"
_PAGE = f"""<!doctype html><html><head><meta charset="utf-8">
<title>detectors</title>
<script src="/{_FPJS}"></script>
</head><body><h1 id="state">loading</h1>
<script type="module">
window.__botd = null; window.__fp = null; window.__err = "";
(async () => {{
try {{
const Botd = await import("/{_BOTD}");
const botd = await Botd.load();
window.__botd = botd.detect(); // {{bot:false}} | {{bot:true,botKind}}
}} catch (e) {{ window.__err += " botd:" + e; }}
try {{
const fp = await FingerprintJS.load();
const r = await fp.get();
window.__fp = {{ visitorId: r.visitorId }};
}} catch (e) {{ window.__err += " fp:" + e; }}
document.getElementById("state").textContent = "done";
}})();
</script></body></html>"""
class _DetectorSite:
"""Localhost server: `/` → the page; `/<lib>` → the vendored bundle."""
def __init__(self):
page = _PAGE.encode()
vendor = _VENDOR
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
if self.path == "/" or self.path.startswith("/?"):
body, ctype = page, "text/html; charset=utf-8"
else:
f = vendor / Path(self.path.lstrip("/")).name
if not f.is_file():
self.send_error(404); return
body = f.read_bytes()
ctype = "text/javascript; charset=utf-8"
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
self._srv = socketserver.TCPServer(("127.0.0.1", 0), H)
self.port = self._srv.server_address[1]
threading.Thread(target=self._srv.serve_forever, daemon=True).start()
@property
def url(self):
return f"http://127.0.0.1:{self.port}/"
def close(self):
self._srv.shutdown()
@pytest.fixture(scope="module")
def detector_site():
s = _DetectorSite()
yield s
s.close()
def _run_detectors(firefox_binary, url):
"""Launch the binary, load the page, return (botd_result, fp_result, err)."""
with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser:
page = browser.new_page()
page.goto(url, wait_until="load", timeout=45000)
# The detectors run async; wait until both finished (or errored).
page.wait_for_function(
"() => document.getElementById('state').textContent === 'done'",
timeout=45000,
)
botd = page.evaluate("() => window.__botd")
fp = page.evaluate("() => window.__fp")
err = page.evaluate("() => window.__err")
return botd, fp, err
@pytest.mark.e2e
def test_botd_does_not_flag_automation(firefox_binary, detector_site):
"""The real BotD detector must NOT flag the stealth build as a bot."""
botd, _fp, err = _run_detectors(firefox_binary, detector_site.url)
assert botd is not None, f"BotD did not produce a result (err:{err!r})"
assert botd.get("bot") is False, (
f"BotD flagged the build as a bot: {botd!r} "
f"(botKind={botd.get('botKind')!r})"
)
@pytest.mark.e2e
def test_fingerprintjs_visitorid_stable_across_launches(firefox_binary, detector_site):
"""FingerprintJS visitorId must be present and identical across two fresh
launches with the same seed a real browser is stable; an over-randomized
spoof drifts (and a drifting fingerprint is itself a bot tell)."""
_b1, fp1, err1 = _run_detectors(firefox_binary, detector_site.url)
_b2, fp2, err2 = _run_detectors(firefox_binary, detector_site.url)
assert fp1 and fp1.get("visitorId"), f"no visitorId on run 1 (err:{err1!r})"
assert fp2 and fp2.get("visitorId"), f"no visitorId on run 2 (err:{err2!r})"
assert fp1["visitorId"] == fp2["visitorId"], (
f"FingerprintJS visitorId drifted across launches: "
f"{fp1['visitorId']!r} != {fp2['visitorId']!r} (per-session entropy = bot tell)"
)

18
tests/vendor/README.md vendored Normal file
View file

@ -0,0 +1,18 @@
# Vendored detection libraries (test-only)
These are upstream, unmodified, MIT-licensed browser-fingerprinting / bot-detection
libraries, vendored so the detector e2e tests run **hermetically and identically**
on a dev box and on a GitHub runner (no external CDN at test time — Firefox
tracking-protection blocks the openfpcdn.io CDN anyway, and we want CI offline).
They are served from a localhost HTTP server and loaded into the patched Firefox;
the tests assert the REAL detectors don't flag the stealth build (BotD: `bot===false`)
and that the fingerprint is stable (FingerprintJS: same `visitorId` across launches).
| File | Package | Version | Source | License |
|---|---|---|---|---|
| `botd-2.0.0.esm.js` | `@fingerprintjs/botd` | 2.0.0 | https://cdn.jsdelivr.net/npm/@fingerprintjs/botd@2.0.0/dist/botd.esm.js | MIT |
| `fingerprintjs-5.2.0.umd.min.js` | `@fingerprintjs/fingerprintjs` | 5.2.0 | https://cdn.jsdelivr.net/npm/@fingerprintjs/fingerprintjs@5.2.0/dist/fp.umd.min.js | MIT |
Both are MIT (Copyright © FingerprintJS, Inc.). To update: download the pinned
dist from jsdelivr, drop it here, and bump the version in the filename + this table.

811
tests/vendor/botd-2.0.0.esm.js vendored Normal file
View file

@ -0,0 +1,811 @@
/**
* Fingerprint BotD v2.0.0 - Copyright (c) FingerprintJS, Inc, 2025 (https://fingerprint.com)
* Licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) license.
*/
var version = "2.0.0";
/**
* Enum for types of bots.
* Specific types of bots come first, followed by automation technologies.
*
* @readonly
* @enum {string}
*/
const BotKind = {
// Object is used instead of Typescript enum to avoid emitting IIFE which might be affected by further tree-shaking.
// See example of compiled enums https://stackoverflow.com/q/47363996)
Awesomium: 'awesomium',
Cef: 'cef',
CefSharp: 'cefsharp',
CoachJS: 'coachjs',
Electron: 'electron',
FMiner: 'fminer',
Geb: 'geb',
NightmareJS: 'nightmarejs',
Phantomas: 'phantomas',
PhantomJS: 'phantomjs',
Rhino: 'rhino',
Selenium: 'selenium',
Sequentum: 'sequentum',
SlimerJS: 'slimerjs',
WebDriverIO: 'webdriverio',
WebDriver: 'webdriver',
HeadlessChrome: 'headless_chrome',
Unknown: 'unknown',
};
/**
* Bot detection error.
*/
class BotdError extends Error {
/**
* Creates a new BotdError.
*
* @class
*/
constructor(state, message) {
super(message);
this.state = state;
this.name = 'BotdError';
Object.setPrototypeOf(this, BotdError.prototype);
}
}
function detect(components, detectors) {
const detections = {};
let finalDetection = {
bot: false,
};
for (const detectorName in detectors) {
const detector = detectors[detectorName];
const detectorRes = detector(components);
let detection = { bot: false };
if (typeof detectorRes === 'string') {
detection = { bot: true, botKind: detectorRes };
}
else if (detectorRes) {
detection = { bot: true, botKind: BotKind.Unknown };
}
detections[detectorName] = detection;
if (detection.bot) {
finalDetection = detection;
}
}
return [detections, finalDetection];
}
async function collect(sources) {
const components = {};
const sourcesKeys = Object.keys(sources);
await Promise.all(sourcesKeys.map(async (sourceKey) => {
const res = sources[sourceKey];
try {
components[sourceKey] = {
value: await res(),
state: 0 /* State.Success */,
};
}
catch (error) {
if (error instanceof BotdError) {
components[sourceKey] = {
state: error.state,
error: `${error.name}: ${error.message}`,
};
}
else {
components[sourceKey] = {
state: -3 /* State.UnexpectedBehaviour */,
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
};
}
}
}));
return components;
}
function detectAppVersion({ appVersion }) {
if (appVersion.state !== 0 /* State.Success */)
return false;
if (/headless/i.test(appVersion.value))
return BotKind.HeadlessChrome;
if (/electron/i.test(appVersion.value))
return BotKind.Electron;
if (/slimerjs/i.test(appVersion.value))
return BotKind.SlimerJS;
}
function arrayIncludes(arr, value) {
return arr.indexOf(value) !== -1;
}
function strIncludes(str, value) {
return str.indexOf(value) !== -1;
}
function arrayFind(array, callback) {
if ('find' in array)
return array.find(callback);
for (let i = 0; i < array.length; i++) {
if (callback(array[i], i, array))
return array[i];
}
return undefined;
}
function getObjectProps(obj) {
return Object.getOwnPropertyNames(obj);
}
function includes(arr, ...keys) {
for (const key of keys) {
if (typeof key === 'string') {
if (arrayIncludes(arr, key))
return true;
}
else {
const match = arrayFind(arr, (value) => key.test(value));
if (match != null)
return true;
}
}
return false;
}
function countTruthy(values) {
return values.reduce((sum, value) => sum + (value ? 1 : 0), 0);
}
function detectDocumentAttributes({ documentElementKeys }) {
if (documentElementKeys.state !== 0 /* State.Success */)
return false;
if (includes(documentElementKeys.value, 'selenium', 'webdriver', 'driver')) {
return BotKind.Selenium;
}
}
function detectErrorTrace({ errorTrace }) {
if (errorTrace.state !== 0 /* State.Success */)
return false;
if (/PhantomJS/i.test(errorTrace.value))
return BotKind.PhantomJS;
}
function detectEvalLengthInconsistency({ evalLength, browserKind, browserEngineKind, }) {
if (evalLength.state !== 0 /* State.Success */ ||
browserKind.state !== 0 /* State.Success */ ||
browserEngineKind.state !== 0 /* State.Success */)
return;
const length = evalLength.value;
if (browserEngineKind.value === "unknown" /* BrowserEngineKind.Unknown */)
return false;
return ((length === 37 && !arrayIncludes(["webkit" /* BrowserEngineKind.Webkit */, "gecko" /* BrowserEngineKind.Gecko */], browserEngineKind.value)) ||
(length === 39 && !arrayIncludes(["internet_explorer" /* BrowserKind.IE */], browserKind.value)) ||
(length === 33 && !arrayIncludes(["chromium" /* BrowserEngineKind.Chromium */], browserEngineKind.value)));
}
function detectFunctionBind({ functionBind }) {
if (functionBind.state === -2 /* State.NotFunction */)
return BotKind.PhantomJS;
}
function detectLanguagesLengthInconsistency({ languages }) {
if (languages.state === 0 /* State.Success */ && languages.value.length === 0) {
return BotKind.HeadlessChrome;
}
}
function detectMimeTypesConsistent({ mimeTypesConsistent }) {
if (mimeTypesConsistent.state === 0 /* State.Success */ && !mimeTypesConsistent.value) {
return BotKind.Unknown;
}
}
function detectNotificationPermissions({ notificationPermissions, browserKind, }) {
if (browserKind.state !== 0 /* State.Success */ || browserKind.value !== "chrome" /* BrowserKind.Chrome */)
return false;
if (notificationPermissions.state === 0 /* State.Success */ && notificationPermissions.value) {
return BotKind.HeadlessChrome;
}
}
function detectPluginsArray({ pluginsArray }) {
if (pluginsArray.state === 0 /* State.Success */ && !pluginsArray.value)
return BotKind.HeadlessChrome;
}
function detectPluginsLengthInconsistency({ pluginsLength, android, browserKind, browserEngineKind, }) {
if (pluginsLength.state !== 0 /* State.Success */ ||
android.state !== 0 /* State.Success */ ||
browserKind.state !== 0 /* State.Success */ ||
browserEngineKind.state !== 0 /* State.Success */)
return;
if (browserKind.value !== "chrome" /* BrowserKind.Chrome */ ||
android.value ||
browserEngineKind.value !== "chromium" /* BrowserEngineKind.Chromium */)
return;
if (pluginsLength.value === 0)
return BotKind.HeadlessChrome;
}
function detectProcess({ process }) {
var _a;
if (process.state !== 0 /* State.Success */)
return false;
if (process.value.type === 'renderer' || ((_a = process.value.versions) === null || _a === void 0 ? void 0 : _a.electron) != null)
return BotKind.Electron;
}
function detectProductSub({ productSub, browserKind }) {
if (productSub.state !== 0 /* State.Success */ || browserKind.state !== 0 /* State.Success */)
return false;
if ((browserKind.value === "chrome" /* BrowserKind.Chrome */ ||
browserKind.value === "safari" /* BrowserKind.Safari */ ||
browserKind.value === "opera" /* BrowserKind.Opera */ ||
browserKind.value === "wechat" /* BrowserKind.WeChat */) &&
productSub.value !== '20030107')
return BotKind.Unknown;
}
function detectUserAgent({ userAgent }) {
if (userAgent.state !== 0 /* State.Success */)
return false;
if (/PhantomJS/i.test(userAgent.value))
return BotKind.PhantomJS;
if (/Headless/i.test(userAgent.value))
return BotKind.HeadlessChrome;
if (/Electron/i.test(userAgent.value))
return BotKind.Electron;
if (/slimerjs/i.test(userAgent.value))
return BotKind.SlimerJS;
}
function detectWebDriver({ webDriver }) {
if (webDriver.state === 0 /* State.Success */ && webDriver.value)
return BotKind.HeadlessChrome;
}
function detectWebGL({ webGL }) {
if (webGL.state === 0 /* State.Success */) {
const { vendor, renderer } = webGL.value;
if (vendor == 'Brian Paul' && renderer == 'Mesa OffScreen') {
return BotKind.HeadlessChrome;
}
}
}
function detectWindowExternal({ windowExternal }) {
if (windowExternal.state !== 0 /* State.Success */)
return false;
if (/Sequentum/i.test(windowExternal.value))
return BotKind.Sequentum;
}
function detectWindowSize({ windowSize, documentFocus }) {
if (windowSize.state !== 0 /* State.Success */ || documentFocus.state !== 0 /* State.Success */)
return false;
const { outerWidth, outerHeight } = windowSize.value;
// When a page is opened in a new tab without focusing it right away, the window outer size is 0x0
if (!documentFocus.value)
return;
if (outerWidth === 0 && outerHeight === 0)
return BotKind.HeadlessChrome;
}
function detectDistinctiveProperties({ distinctiveProps }) {
if (distinctiveProps.state !== 0 /* State.Success */)
return false;
const value = distinctiveProps.value;
let bot;
for (bot in value)
if (value[bot])
return bot;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
const detectors = {
detectAppVersion,
detectDocumentAttributes,
detectErrorTrace,
detectEvalLengthInconsistency,
detectFunctionBind,
detectLanguagesLengthInconsistency,
detectNotificationPermissions,
detectPluginsArray,
detectPluginsLengthInconsistency,
detectProcess,
detectUserAgent,
detectWebDriver,
detectWebGL,
detectWindowExternal,
detectWindowSize,
detectMimeTypesConsistent,
detectProductSub,
detectDistinctiveProperties,
};
function getAppVersion() {
const appVersion = navigator.appVersion;
if (appVersion == undefined) {
throw new BotdError(-1 /* State.Undefined */, 'navigator.appVersion is undefined');
}
return appVersion;
}
function getDocumentElementKeys() {
if (document.documentElement === undefined) {
throw new BotdError(-1 /* State.Undefined */, 'document.documentElement is undefined');
}
const { documentElement } = document;
if (typeof documentElement.getAttributeNames !== 'function') {
throw new BotdError(-2 /* State.NotFunction */, 'document.documentElement.getAttributeNames is not a function');
}
return documentElement.getAttributeNames();
}
function getErrorTrace() {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
null[0]();
}
catch (error) {
if (error instanceof Error && error['stack'] != null) {
return error.stack.toString();
}
}
throw new BotdError(-3 /* State.UnexpectedBehaviour */, 'errorTrace signal unexpected behaviour');
}
function getEvalLength() {
return eval.toString().length;
}
function getFunctionBind() {
if (Function.prototype.bind === undefined) {
throw new BotdError(-2 /* State.NotFunction */, 'Function.prototype.bind is undefined');
}
return Function.prototype.bind.toString();
}
function getBrowserEngineKind() {
var _a, _b;
// Based on research in October 2020. Tested to detect Chromium 42-86.
const w = window;
const n = navigator;
if (countTruthy([
'webkitPersistentStorage' in n,
'webkitTemporaryStorage' in n,
n.vendor.indexOf('Google') === 0,
'webkitResolveLocalFileSystemURL' in w,
'BatteryManager' in w,
'webkitMediaStream' in w,
'webkitSpeechGrammar' in w,
]) >= 5) {
return "chromium" /* BrowserEngineKind.Chromium */;
}
if (countTruthy([
'ApplePayError' in w,
'CSSPrimitiveValue' in w,
'Counter' in w,
n.vendor.indexOf('Apple') === 0,
'getStorageUpdates' in n,
'WebKitMediaKeys' in w,
]) >= 4) {
return "webkit" /* BrowserEngineKind.Webkit */;
}
if (countTruthy([
'buildID' in navigator,
'MozAppearance' in ((_b = (_a = document.documentElement) === null || _a === void 0 ? void 0 : _a.style) !== null && _b !== void 0 ? _b : {}),
'onmozfullscreenchange' in w,
'mozInnerScreenX' in w,
'CSSMozDocumentRule' in w,
'CanvasCaptureMediaStream' in w,
]) >= 4) {
return "gecko" /* BrowserEngineKind.Gecko */;
}
return "unknown" /* BrowserEngineKind.Unknown */;
}
function getBrowserKind() {
var _a;
const userAgent = (_a = navigator.userAgent) === null || _a === void 0 ? void 0 : _a.toLowerCase();
if (strIncludes(userAgent, 'edg/')) {
return "edge" /* BrowserKind.Edge */;
}
else if (strIncludes(userAgent, 'trident') || strIncludes(userAgent, 'msie')) {
return "internet_explorer" /* BrowserKind.IE */;
}
else if (strIncludes(userAgent, 'wechat')) {
return "wechat" /* BrowserKind.WeChat */;
}
else if (strIncludes(userAgent, 'firefox')) {
return "firefox" /* BrowserKind.Firefox */;
}
else if (strIncludes(userAgent, 'opera') || strIncludes(userAgent, 'opr')) {
return "opera" /* BrowserKind.Opera */;
}
else if (strIncludes(userAgent, 'chrome')) {
return "chrome" /* BrowserKind.Chrome */;
}
else if (strIncludes(userAgent, 'safari')) {
return "safari" /* BrowserKind.Safari */;
}
else {
return "unknown" /* BrowserKind.Unknown */;
}
}
// Source: https://github.com/fingerprintjs/fingerprintjs/blob/master/src/utils/browser.ts#L223
function isAndroid() {
const browserEngineKind = getBrowserEngineKind();
const isItChromium = browserEngineKind === "chromium" /* BrowserEngineKind.Chromium */;
const isItGecko = browserEngineKind === "gecko" /* BrowserEngineKind.Gecko */;
const w = window;
const n = navigator;
const c = 'connection';
// Chrome removes all words "Android" from `navigator` when desktop version is requested
// Firefox keeps "Android" in `navigator.appVersion` when desktop version is requested
if (isItChromium) {
return (countTruthy([
!('SharedWorker' in w),
// `typechange` is deprecated, but it's still present on Android (tested on Chrome Mobile 117)
// Removal proposal https://bugs.chromium.org/p/chromium/issues/detail?id=699892
// Note: this expression returns true on ChromeOS, so additional detectors are required to avoid false-positives
n[c] && 'ontypechange' in n[c],
!('sinkId' in new Audio()),
]) >= 2);
}
else if (isItGecko) {
return countTruthy(['onorientationchange' in w, 'orientation' in w, /android/i.test(n.appVersion)]) >= 2;
}
else {
// Only 2 browser engines are presented on Android.
// Actually, there is also Android 4.1 browser, but it's not worth detecting it at the moment.
return false;
}
}
function getDocumentFocus() {
if (document.hasFocus === undefined) {
return false;
}
return document.hasFocus();
}
function isChromium86OrNewer() {
// Checked in Chrome 85 vs Chrome 86 both on desktop and Android. Checked in macOS Chrome 128, Android Chrome 127.
const w = window;
return (countTruthy([
!('MediaSettingsRange' in w),
'RTCEncodedAudioFrame' in w,
'' + w.Intl === '[object Intl]',
'' + w.Reflect === '[object Reflect]',
]) >= 3);
}
function getLanguages() {
const n = navigator;
const result = [];
const language = n.language || n.userLanguage || n.browserLanguage || n.systemLanguage;
if (language !== undefined) {
result.push([language]);
}
if (Array.isArray(n.languages)) {
const browserEngine = getBrowserEngineKind();
// Starting from Chromium 86, there is only a single value in `navigator.language` in Incognito mode:
// the value of `navigator.language`. Therefore, the value is ignored in this browser.
if (!(browserEngine === "chromium" /* BrowserEngineKind.Chromium */ && isChromium86OrNewer())) {
result.push(n.languages);
}
}
else if (typeof n.languages === 'string') {
const languages = n.languages;
if (languages) {
result.push(languages.split(','));
}
}
return result;
}
function areMimeTypesConsistent() {
if (navigator.mimeTypes === undefined) {
throw new BotdError(-1 /* State.Undefined */, 'navigator.mimeTypes is undefined');
}
const { mimeTypes } = navigator;
let isConsistent = Object.getPrototypeOf(mimeTypes) === MimeTypeArray.prototype;
for (let i = 0; i < mimeTypes.length; i++) {
isConsistent && (isConsistent = Object.getPrototypeOf(mimeTypes[i]) === MimeType.prototype);
}
return isConsistent;
}
async function getNotificationPermissions() {
if (window.Notification === undefined) {
throw new BotdError(-1 /* State.Undefined */, 'window.Notification is undefined');
}
if (navigator.permissions === undefined) {
throw new BotdError(-1 /* State.Undefined */, 'navigator.permissions is undefined');
}
const { permissions } = navigator;
if (typeof permissions.query !== 'function') {
throw new BotdError(-2 /* State.NotFunction */, 'navigator.permissions.query is not a function');
}
try {
const permissionStatus = await permissions.query({ name: 'notifications' });
return window.Notification.permission === 'denied' && permissionStatus.state === 'prompt';
}
catch (e) {
throw new BotdError(-3 /* State.UnexpectedBehaviour */, 'notificationPermissions signal unexpected behaviour');
}
}
function getPluginsArray() {
if (navigator.plugins === undefined) {
throw new BotdError(-1 /* State.Undefined */, 'navigator.plugins is undefined');
}
if (window.PluginArray === undefined) {
throw new BotdError(-1 /* State.Undefined */, 'window.PluginArray is undefined');
}
return navigator.plugins instanceof PluginArray;
}
function getPluginsLength() {
if (navigator.plugins === undefined) {
throw new BotdError(-1 /* State.Undefined */, 'navigator.plugins is undefined');
}
if (navigator.plugins.length === undefined) {
throw new BotdError(-3 /* State.UnexpectedBehaviour */, 'navigator.plugins.length is undefined');
}
return navigator.plugins.length;
}
function getProcess() {
const { process } = window;
const errorPrefix = 'window.process is';
if (process === undefined) {
throw new BotdError(-1 /* State.Undefined */, `${errorPrefix} undefined`);
}
if (process && typeof process !== 'object') {
throw new BotdError(-3 /* State.UnexpectedBehaviour */, `${errorPrefix} not an object`);
}
return process;
}
function getProductSub() {
const { productSub } = navigator;
if (productSub === undefined) {
throw new BotdError(-1 /* State.Undefined */, 'navigator.productSub is undefined');
}
return productSub;
}
function getRTT() {
if (navigator.connection === undefined) {
throw new BotdError(-1 /* State.Undefined */, 'navigator.connection is undefined');
}
if (navigator.connection.rtt === undefined) {
throw new BotdError(-1 /* State.Undefined */, 'navigator.connection.rtt is undefined');
}
return navigator.connection.rtt;
}
function getUserAgent() {
return navigator.userAgent;
}
function getWebDriver() {
if (navigator.webdriver == undefined) {
throw new BotdError(-1 /* State.Undefined */, 'navigator.webdriver is undefined');
}
return navigator.webdriver;
}
function getWebGL() {
const canvasElement = document.createElement('canvas');
if (typeof canvasElement.getContext !== 'function') {
throw new BotdError(-2 /* State.NotFunction */, 'HTMLCanvasElement.getContext is not a function');
}
const webGLContext = canvasElement.getContext('webgl');
if (webGLContext === null) {
throw new BotdError(-4 /* State.Null */, 'WebGLRenderingContext is null');
}
if (typeof webGLContext.getParameter !== 'function') {
throw new BotdError(-2 /* State.NotFunction */, 'WebGLRenderingContext.getParameter is not a function');
}
const vendor = webGLContext.getParameter(webGLContext.VENDOR);
const renderer = webGLContext.getParameter(webGLContext.RENDERER);
return { vendor: vendor, renderer: renderer };
}
function getWindowExternal() {
if (window.external === undefined) {
throw new BotdError(-1 /* State.Undefined */, 'window.external is undefined');
}
const { external } = window;
if (typeof external.toString !== 'function') {
throw new BotdError(-2 /* State.NotFunction */, 'window.external.toString is not a function');
}
return external.toString();
}
function getWindowSize() {
return {
outerWidth: window.outerWidth,
outerHeight: window.outerHeight,
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
};
}
function checkDistinctiveProperties() {
// The order in the following list matters, because specific types of bots come first, followed by automation technologies.
const distinctivePropsList = {
[BotKind.Awesomium]: {
window: ['awesomium'],
},
[BotKind.Cef]: {
window: ['RunPerfTest'],
},
[BotKind.CefSharp]: {
window: ['CefSharp'],
},
[BotKind.CoachJS]: {
window: ['emit'],
},
[BotKind.FMiner]: {
window: ['fmget_targets'],
},
[BotKind.Geb]: {
window: ['geb'],
},
[BotKind.NightmareJS]: {
window: ['__nightmare', 'nightmare'],
},
[BotKind.Phantomas]: {
window: ['__phantomas'],
},
[BotKind.PhantomJS]: {
window: ['callPhantom', '_phantom'],
},
[BotKind.Rhino]: {
window: ['spawn'],
},
[BotKind.Selenium]: {
window: ['_Selenium_IDE_Recorder', '_selenium', 'calledSelenium', /^([a-z]){3}_.*_(Array|Promise|Symbol)$/],
document: ['__selenium_evaluate', 'selenium-evaluate', '__selenium_unwrapped'],
},
[BotKind.WebDriverIO]: {
window: ['wdioElectron'],
},
[BotKind.WebDriver]: {
window: [
'webdriver',
'__webdriverFunc',
'__lastWatirAlert',
'__lastWatirConfirm',
'__lastWatirPrompt',
'_WEBDRIVER_ELEM_CACHE',
'ChromeDriverw',
],
document: [
'__webdriver_script_fn',
'__driver_evaluate',
'__webdriver_evaluate',
'__fxdriver_evaluate',
'__driver_unwrapped',
'__webdriver_unwrapped',
'__fxdriver_unwrapped',
'__webdriver_script_fn',
'__webdriver_script_func',
'__webdriver_script_function',
'$cdc_asdjflasutopfhvcZLmcf',
'$cdc_asdjflasutopfhvcZLmcfl_',
'$chrome_asyncScriptInfo',
'__$webdriverAsyncExecutor',
],
},
[BotKind.HeadlessChrome]: {
window: ['domAutomation', 'domAutomationController'],
},
};
let botName;
const result = {};
const windowProps = getObjectProps(window);
let documentProps = [];
if (window.document !== undefined)
documentProps = getObjectProps(window.document);
for (botName in distinctivePropsList) {
const props = distinctivePropsList[botName];
if (props !== undefined) {
const windowContains = props.window === undefined ? false : includes(windowProps, ...props.window);
const documentContains = props.document === undefined || !documentProps.length ? false : includes(documentProps, ...props.document);
result[botName] = windowContains || documentContains;
}
}
return result;
}
const sources = {
android: isAndroid,
browserKind: getBrowserKind,
browserEngineKind: getBrowserEngineKind,
documentFocus: getDocumentFocus,
userAgent: getUserAgent,
appVersion: getAppVersion,
rtt: getRTT,
windowSize: getWindowSize,
pluginsLength: getPluginsLength,
pluginsArray: getPluginsArray,
errorTrace: getErrorTrace,
productSub: getProductSub,
windowExternal: getWindowExternal,
mimeTypesConsistent: areMimeTypesConsistent,
evalLength: getEvalLength,
webGL: getWebGL,
webDriver: getWebDriver,
languages: getLanguages,
notificationPermissions: getNotificationPermissions,
documentElementKeys: getDocumentElementKeys,
functionBind: getFunctionBind,
process: getProcess,
distinctiveProps: checkDistinctiveProperties,
};
/**
* Class representing a bot detector.
*
* @class
* @implements {BotDetectorInterface}
*/
class BotDetector {
constructor() {
this.components = undefined;
this.detections = undefined;
}
getComponents() {
return this.components;
}
getDetections() {
return this.detections;
}
/**
* @inheritdoc
*/
detect() {
if (this.components === undefined) {
throw new Error("BotDetector.detect can't be called before BotDetector.collect");
}
const [detections, finalDetection] = detect(this.components, detectors);
this.detections = detections;
return finalDetection;
}
/**
* @inheritdoc
*/
async collect() {
this.components = await collect(sources);
return this.components;
}
}
/**
* Sends an unpersonalized AJAX request to collect installation statistics
*/
function monitor() {
// The FingerprintJS CDN (https://github.com/fingerprintjs/cdn) replaces `window.__fpjs_d_m` with `true`
if (window.__fpjs_d_m || Math.random() >= 0.001) {
return;
}
try {
const request = new XMLHttpRequest();
request.open('get', `https://m1.openfpcdn.io/botd/v${version}/npm-monitoring`, true);
request.send();
}
catch (error) {
// console.error is ok here because it's an unexpected error handler
// eslint-disable-next-line no-console
console.error(error);
}
}
async function load({ monitoring = true } = {}) {
if (monitoring) {
monitor();
}
const detector = new BotDetector();
await detector.collect();
return detector;
}
var index = { load };
export { BotKind, BotdError, collect, index as default, detect, detectors, load, sources };

File diff suppressed because one or more lines are too long