@niksbanna/bot-detector 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/bot-detector.cjs.js +115 -75
- package/dist/bot-detector.cjs.js.map +2 -2
- package/dist/bot-detector.esm.js +115 -75
- package/dist/bot-detector.esm.js.map +2 -2
- package/dist/bot-detector.iife.js +115 -75
- package/dist/bot-detector.iife.js.map +2 -2
- package/dist/bot-detector.iife.min.js +3 -1
- package/package.json +1 -2
- package/src/core/BotDetector.js +0 -284
- package/src/core/ScoringEngine.js +0 -134
- package/src/core/Signal.js +0 -181
- package/src/core/VerdictEngine.js +0 -132
- package/src/index.js +0 -273
- package/src/signals/automation/PhantomJSSignal.js +0 -137
- package/src/signals/automation/PlaywrightSignal.js +0 -129
- package/src/signals/automation/PuppeteerSignal.js +0 -122
- package/src/signals/automation/SeleniumSignal.js +0 -151
- package/src/signals/automation/index.js +0 -8
- package/src/signals/behavior/InteractionTimingSignal.js +0 -170
- package/src/signals/behavior/KeyboardPatternSignal.js +0 -235
- package/src/signals/behavior/MouseMovementSignal.js +0 -215
- package/src/signals/behavior/ScrollBehaviorSignal.js +0 -236
- package/src/signals/behavior/index.js +0 -8
- package/src/signals/environment/HeadlessSignal.js +0 -97
- package/src/signals/environment/NavigatorAnomalySignal.js +0 -117
- package/src/signals/environment/PermissionsSignal.js +0 -76
- package/src/signals/environment/WebDriverSignal.js +0 -58
- package/src/signals/environment/index.js +0 -8
- package/src/signals/fingerprint/AudioContextSignal.js +0 -158
- package/src/signals/fingerprint/CanvasSignal.js +0 -133
- package/src/signals/fingerprint/PluginsSignal.js +0 -106
- package/src/signals/fingerprint/ScreenSignal.js +0 -157
- package/src/signals/fingerprint/WebGLSignal.js +0 -146
- package/src/signals/fingerprint/index.js +0 -9
- package/src/signals/timing/DOMContentTimingSignal.js +0 -159
- package/src/signals/timing/PageLoadSignal.js +0 -165
- package/src/signals/timing/index.js +0 -6
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Detects AudioContext anomalies.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { Signal } from '../../core/Signal.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Checks for AudioContext anomalies.
|
|
9
|
-
* Bots and headless browsers often have unusual or missing audio capabilities.
|
|
10
|
-
*/
|
|
11
|
-
class AudioContextSignal extends Signal {
|
|
12
|
-
static id = 'audio-context';
|
|
13
|
-
static category = 'fingerprint';
|
|
14
|
-
static weight = 0.5;
|
|
15
|
-
static description = 'Detects AudioContext anomalies';
|
|
16
|
-
|
|
17
|
-
async detect() {
|
|
18
|
-
const anomalies = [];
|
|
19
|
-
let confidence = 0;
|
|
20
|
-
|
|
21
|
-
// Check if AudioContext exists
|
|
22
|
-
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
23
|
-
|
|
24
|
-
if (!AudioContext) {
|
|
25
|
-
// AudioContext not available
|
|
26
|
-
anomalies.push('audio-context-unavailable');
|
|
27
|
-
confidence = Math.max(confidence, 0.4);
|
|
28
|
-
return this.createResult(true, { anomalies }, confidence);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
let audioContext = null;
|
|
32
|
-
let oscillator = null;
|
|
33
|
-
let analyser = null;
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
audioContext = new AudioContext();
|
|
37
|
-
|
|
38
|
-
// Check sample rate - unusual values may indicate virtualization
|
|
39
|
-
const sampleRate = audioContext.sampleRate;
|
|
40
|
-
if (sampleRate !== 44100 && sampleRate !== 48000 && sampleRate !== 96000) {
|
|
41
|
-
anomalies.push('unusual-sample-rate');
|
|
42
|
-
confidence = Math.max(confidence, 0.3);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Check for suspended state (auto-play policy)
|
|
46
|
-
// This is normal, but combined with other factors can be suspicious
|
|
47
|
-
|
|
48
|
-
// Try to create an oscillator and check its properties
|
|
49
|
-
oscillator = audioContext.createOscillator();
|
|
50
|
-
analyser = audioContext.createAnalyser();
|
|
51
|
-
|
|
52
|
-
if (!oscillator || !analyser) {
|
|
53
|
-
anomalies.push('audio-nodes-unavailable');
|
|
54
|
-
confidence = Math.max(confidence, 0.5);
|
|
55
|
-
} else {
|
|
56
|
-
// Check analyser properties
|
|
57
|
-
const fftSize = analyser.fftSize;
|
|
58
|
-
if (fftSize !== 2048) {
|
|
59
|
-
// Non-default value might indicate tampering
|
|
60
|
-
// but this alone isn't conclusive
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Check destination
|
|
64
|
-
const destination = audioContext.destination;
|
|
65
|
-
if (!destination || destination.maxChannelCount === 0) {
|
|
66
|
-
anomalies.push('no-audio-destination');
|
|
67
|
-
confidence = Math.max(confidence, 0.6);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Check for channel count
|
|
71
|
-
if (destination && destination.maxChannelCount < 2) {
|
|
72
|
-
anomalies.push('mono-audio-only');
|
|
73
|
-
confidence = Math.max(confidence, 0.3);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Check for overridden AudioContext
|
|
78
|
-
try {
|
|
79
|
-
const audioCtxStr = AudioContext.toString();
|
|
80
|
-
if (!audioCtxStr.includes('[native code]')) {
|
|
81
|
-
anomalies.push('audio-context-overridden');
|
|
82
|
-
confidence = Math.max(confidence, 0.7);
|
|
83
|
-
}
|
|
84
|
-
} catch (e) {
|
|
85
|
-
// Some environments may throw
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Audio fingerprint test - create a noise signal and check output
|
|
89
|
-
try {
|
|
90
|
-
if (audioContext.state === 'suspended') {
|
|
91
|
-
// Try to resume (may not work without user gesture)
|
|
92
|
-
await audioContext.resume().catch(() => {});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Only perform if we can
|
|
96
|
-
if (audioContext.state === 'running') {
|
|
97
|
-
const oscillatorNode = audioContext.createOscillator();
|
|
98
|
-
const gainNode = audioContext.createGain();
|
|
99
|
-
const scriptProcessor = audioContext.createScriptProcessor
|
|
100
|
-
? audioContext.createScriptProcessor(4096, 1, 1)
|
|
101
|
-
: null;
|
|
102
|
-
|
|
103
|
-
if (scriptProcessor) {
|
|
104
|
-
oscillatorNode.type = 'triangle';
|
|
105
|
-
oscillatorNode.frequency.value = 10000;
|
|
106
|
-
gainNode.gain.value = 0;
|
|
107
|
-
|
|
108
|
-
oscillatorNode.connect(gainNode);
|
|
109
|
-
gainNode.connect(scriptProcessor);
|
|
110
|
-
scriptProcessor.connect(audioContext.destination);
|
|
111
|
-
|
|
112
|
-
// Brief test
|
|
113
|
-
oscillatorNode.start(0);
|
|
114
|
-
|
|
115
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
116
|
-
|
|
117
|
-
oscillatorNode.stop();
|
|
118
|
-
oscillatorNode.disconnect();
|
|
119
|
-
gainNode.disconnect();
|
|
120
|
-
scriptProcessor.disconnect();
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
} catch (e) {
|
|
124
|
-
// Audio fingerprinting blocked or failed
|
|
125
|
-
anomalies.push('audio-fingerprint-blocked');
|
|
126
|
-
confidence = Math.max(confidence, 0.4);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Check for OfflineAudioContext
|
|
130
|
-
const OfflineAudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
|
131
|
-
if (!OfflineAudioContext) {
|
|
132
|
-
anomalies.push('offline-audio-context-unavailable');
|
|
133
|
-
confidence = Math.max(confidence, 0.3);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
} catch (e) {
|
|
137
|
-
anomalies.push('audio-context-error');
|
|
138
|
-
confidence = Math.max(confidence, 0.4);
|
|
139
|
-
} finally {
|
|
140
|
-
// Clean up
|
|
141
|
-
if (oscillator) {
|
|
142
|
-
try { oscillator.disconnect(); } catch (e) {}
|
|
143
|
-
}
|
|
144
|
-
if (analyser) {
|
|
145
|
-
try { analyser.disconnect(); } catch (e) {}
|
|
146
|
-
}
|
|
147
|
-
if (audioContext) {
|
|
148
|
-
try { audioContext.close(); } catch (e) {}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const triggered = anomalies.length > 0;
|
|
153
|
-
|
|
154
|
-
return this.createResult(triggered, { anomalies }, confidence);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export { AudioContextSignal };
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Detects canvas fingerprint blocking or spoofing.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { Signal } from '../../core/Signal.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Detects canvas manipulation, blocking, or spoofing.
|
|
9
|
-
* Privacy tools and some bots modify canvas output.
|
|
10
|
-
*/
|
|
11
|
-
class CanvasSignal extends Signal {
|
|
12
|
-
static id = 'canvas';
|
|
13
|
-
static category = 'fingerprint';
|
|
14
|
-
static weight = 0.5;
|
|
15
|
-
static description = 'Detects canvas fingerprint anomalies';
|
|
16
|
-
|
|
17
|
-
async detect() {
|
|
18
|
-
const anomalies = [];
|
|
19
|
-
let confidence = 0;
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
const canvas = document.createElement('canvas');
|
|
23
|
-
canvas.width = 200;
|
|
24
|
-
canvas.height = 50;
|
|
25
|
-
|
|
26
|
-
const ctx = canvas.getContext('2d');
|
|
27
|
-
if (!ctx) {
|
|
28
|
-
anomalies.push('canvas-context-unavailable');
|
|
29
|
-
confidence = Math.max(confidence, 0.5);
|
|
30
|
-
return this.createResult(true, { anomalies }, confidence);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Draw a complex pattern for fingerprinting
|
|
34
|
-
ctx.textBaseline = 'alphabetic';
|
|
35
|
-
ctx.font = '14px Arial';
|
|
36
|
-
ctx.fillStyle = '#f60';
|
|
37
|
-
ctx.fillRect(0, 0, 200, 50);
|
|
38
|
-
ctx.fillStyle = '#069';
|
|
39
|
-
ctx.fillText('Bot Detection Test 🤖', 2, 15);
|
|
40
|
-
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
|
|
41
|
-
ctx.fillText('Canvas Fingerprint', 4, 30);
|
|
42
|
-
|
|
43
|
-
// Add some complex graphics
|
|
44
|
-
ctx.beginPath();
|
|
45
|
-
ctx.arc(100, 25, 10, 0, Math.PI * 2, true);
|
|
46
|
-
ctx.closePath();
|
|
47
|
-
ctx.fill();
|
|
48
|
-
|
|
49
|
-
// Get data URL
|
|
50
|
-
const dataUrl1 = canvas.toDataURL();
|
|
51
|
-
|
|
52
|
-
// Draw again - should produce same result
|
|
53
|
-
ctx.clearRect(0, 0, 200, 50);
|
|
54
|
-
ctx.fillStyle = '#f60';
|
|
55
|
-
ctx.fillRect(0, 0, 200, 50);
|
|
56
|
-
ctx.fillStyle = '#069';
|
|
57
|
-
ctx.fillText('Bot Detection Test 🤖', 2, 15);
|
|
58
|
-
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
|
|
59
|
-
ctx.fillText('Canvas Fingerprint', 4, 30);
|
|
60
|
-
ctx.beginPath();
|
|
61
|
-
ctx.arc(100, 25, 10, 0, Math.PI * 2, true);
|
|
62
|
-
ctx.closePath();
|
|
63
|
-
ctx.fill();
|
|
64
|
-
|
|
65
|
-
const dataUrl2 = canvas.toDataURL();
|
|
66
|
-
|
|
67
|
-
// If results differ, canvas is being randomized (privacy protection)
|
|
68
|
-
if (dataUrl1 !== dataUrl2) {
|
|
69
|
-
anomalies.push('canvas-randomized');
|
|
70
|
-
confidence = Math.max(confidence, 0.6);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Check for blank canvas (blocking)
|
|
74
|
-
if (dataUrl1.length < 1000) {
|
|
75
|
-
anomalies.push('canvas-possibly-blank');
|
|
76
|
-
confidence = Math.max(confidence, 0.4);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Check for common blocked canvas signature
|
|
80
|
-
const blankCanvas = document.createElement('canvas');
|
|
81
|
-
blankCanvas.width = 200;
|
|
82
|
-
blankCanvas.height = 50;
|
|
83
|
-
const blankUrl = blankCanvas.toDataURL();
|
|
84
|
-
|
|
85
|
-
if (dataUrl1 === blankUrl) {
|
|
86
|
-
anomalies.push('canvas-rendering-blocked');
|
|
87
|
-
confidence = Math.max(confidence, 0.7);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Check for toDataURL being overridden
|
|
91
|
-
try {
|
|
92
|
-
const toDataURLStr = canvas.toDataURL.toString();
|
|
93
|
-
if (!toDataURLStr.includes('[native code]')) {
|
|
94
|
-
anomalies.push('toDataURL-overridden');
|
|
95
|
-
confidence = Math.max(confidence, 0.8);
|
|
96
|
-
}
|
|
97
|
-
} catch (e) {
|
|
98
|
-
// Some environments may throw
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Check pixel data directly
|
|
102
|
-
const imageData = ctx.getImageData(0, 0, 200, 50);
|
|
103
|
-
const pixels = imageData.data;
|
|
104
|
-
|
|
105
|
-
// Check if all pixels are the same (completely blocked)
|
|
106
|
-
let allSame = true;
|
|
107
|
-
const firstPixel = [pixels[0], pixels[1], pixels[2], pixels[3]];
|
|
108
|
-
for (let i = 4; i < pixels.length; i += 4) {
|
|
109
|
-
if (pixels[i] !== firstPixel[0] ||
|
|
110
|
-
pixels[i+1] !== firstPixel[1] ||
|
|
111
|
-
pixels[i+2] !== firstPixel[2]) {
|
|
112
|
-
allSame = false;
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (allSame) {
|
|
118
|
-
anomalies.push('uniform-pixel-data');
|
|
119
|
-
confidence = Math.max(confidence, 0.6);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
} catch (e) {
|
|
123
|
-
anomalies.push('canvas-error');
|
|
124
|
-
confidence = Math.max(confidence, 0.4);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const triggered = anomalies.length > 0;
|
|
128
|
-
|
|
129
|
-
return this.createResult(triggered, { anomalies }, confidence);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export { CanvasSignal };
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Detects browser plugin anomalies.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { Signal } from '../../core/Signal.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Checks for empty or suspicious plugin configurations.
|
|
9
|
-
* Headless browsers and bots often have no plugins.
|
|
10
|
-
*/
|
|
11
|
-
class PluginsSignal extends Signal {
|
|
12
|
-
static id = 'plugins';
|
|
13
|
-
static category = 'fingerprint';
|
|
14
|
-
static weight = 0.6;
|
|
15
|
-
static description = 'Detects browser plugin anomalies';
|
|
16
|
-
|
|
17
|
-
async detect() {
|
|
18
|
-
const anomalies = [];
|
|
19
|
-
let confidence = 0;
|
|
20
|
-
|
|
21
|
-
const plugins = navigator.plugins;
|
|
22
|
-
const mimeTypes = navigator.mimeTypes;
|
|
23
|
-
|
|
24
|
-
// Check if plugins exists
|
|
25
|
-
if (!plugins) {
|
|
26
|
-
anomalies.push('no-plugins-object');
|
|
27
|
-
confidence = Math.max(confidence, 0.6);
|
|
28
|
-
return this.createResult(true, { anomalies }, confidence);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Check for empty plugins array
|
|
32
|
-
if (plugins.length === 0) {
|
|
33
|
-
anomalies.push('empty-plugins');
|
|
34
|
-
confidence = Math.max(confidence, 0.5);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Check for Chrome-specific plugins in Chrome browser
|
|
38
|
-
const ua = navigator.userAgent || '';
|
|
39
|
-
if (ua.includes('Chrome') && !ua.includes('Chromium')) {
|
|
40
|
-
// Real Chrome typically has at least these plugins
|
|
41
|
-
const hasChromePdf = Array.from(plugins).some(p =>
|
|
42
|
-
p.name.includes('PDF') || p.name.includes('Chromium PDF'));
|
|
43
|
-
|
|
44
|
-
if (!hasChromePdf && plugins.length === 0) {
|
|
45
|
-
anomalies.push('chrome-missing-pdf-plugin');
|
|
46
|
-
confidence = Math.max(confidence, 0.4);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Check for consistent plugin/mimeType relationship
|
|
51
|
-
if (plugins.length > 0 && mimeTypes) {
|
|
52
|
-
let totalMimeTypes = 0;
|
|
53
|
-
for (let i = 0; i < plugins.length; i++) {
|
|
54
|
-
totalMimeTypes += plugins[i].length || 0;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Plugins exist but no mimeTypes
|
|
58
|
-
if (mimeTypes.length === 0 && totalMimeTypes > 0) {
|
|
59
|
-
anomalies.push('mimetypes-mismatch');
|
|
60
|
-
confidence = Math.max(confidence, 0.5);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Check for identical plugin names (sign of spoofing)
|
|
65
|
-
if (plugins.length > 1) {
|
|
66
|
-
const names = Array.from(plugins).map(p => p.name);
|
|
67
|
-
const uniqueNames = new Set(names);
|
|
68
|
-
if (uniqueNames.size < names.length) {
|
|
69
|
-
anomalies.push('duplicate-plugins');
|
|
70
|
-
confidence = Math.max(confidence, 0.6);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Check for plugin array tampering
|
|
75
|
-
try {
|
|
76
|
-
const desc = Object.getOwnPropertyDescriptor(Navigator.prototype, 'plugins');
|
|
77
|
-
if (desc && desc.get) {
|
|
78
|
-
// Check if it's been overridden
|
|
79
|
-
const nativeToString = desc.get.toString();
|
|
80
|
-
if (!nativeToString.includes('[native code]')) {
|
|
81
|
-
anomalies.push('plugins-getter-overridden');
|
|
82
|
-
confidence = Math.max(confidence, 0.7);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
} catch (e) {
|
|
86
|
-
// Ignore errors during introspection
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Check for suspiciously few plugins in a desktop browser
|
|
90
|
-
const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
|
|
91
|
-
if (!isMobile && plugins.length === 1) {
|
|
92
|
-
anomalies.push('minimal-plugins');
|
|
93
|
-
confidence = Math.max(confidence, 0.3);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const triggered = anomalies.length > 0;
|
|
97
|
-
|
|
98
|
-
return this.createResult(triggered, {
|
|
99
|
-
anomalies,
|
|
100
|
-
pluginCount: plugins.length,
|
|
101
|
-
mimeTypeCount: mimeTypes?.length || 0,
|
|
102
|
-
}, confidence);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export { PluginsSignal };
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Detects unusual screen and window dimensions.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { Signal } from '../../core/Signal.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Detects screen dimension anomalies.
|
|
9
|
-
* Bots and headless browsers often have unusual screen configurations.
|
|
10
|
-
*/
|
|
11
|
-
class ScreenSignal extends Signal {
|
|
12
|
-
static id = 'screen';
|
|
13
|
-
static category = 'fingerprint';
|
|
14
|
-
static weight = 0.4;
|
|
15
|
-
static description = 'Detects unusual screen dimensions';
|
|
16
|
-
|
|
17
|
-
async detect() {
|
|
18
|
-
const anomalies = [];
|
|
19
|
-
let confidence = 0;
|
|
20
|
-
|
|
21
|
-
const screen = window.screen;
|
|
22
|
-
if (!screen) {
|
|
23
|
-
anomalies.push('no-screen-object');
|
|
24
|
-
confidence = Math.max(confidence, 0.6);
|
|
25
|
-
return this.createResult(true, { anomalies }, confidence);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const width = screen.width;
|
|
29
|
-
const height = screen.height;
|
|
30
|
-
const availWidth = screen.availWidth;
|
|
31
|
-
const availHeight = screen.availHeight;
|
|
32
|
-
const colorDepth = screen.colorDepth;
|
|
33
|
-
const pixelDepth = screen.pixelDepth;
|
|
34
|
-
const outerWidth = window.outerWidth;
|
|
35
|
-
const outerHeight = window.outerHeight;
|
|
36
|
-
const innerWidth = window.innerWidth;
|
|
37
|
-
const innerHeight = window.innerHeight;
|
|
38
|
-
|
|
39
|
-
// Check for zero dimensions (headless indicator)
|
|
40
|
-
if (outerWidth === 0 || outerHeight === 0) {
|
|
41
|
-
anomalies.push('zero-outer-dimensions');
|
|
42
|
-
confidence = Math.max(confidence, 0.8);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (innerWidth === 0 || innerHeight === 0) {
|
|
46
|
-
anomalies.push('zero-inner-dimensions');
|
|
47
|
-
confidence = Math.max(confidence, 0.7);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Check for very small screen (unrealistic for desktop)
|
|
51
|
-
const ua = navigator.userAgent || '';
|
|
52
|
-
const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
|
|
53
|
-
|
|
54
|
-
if (!isMobile && (width < 640 || height < 480)) {
|
|
55
|
-
anomalies.push('very-small-screen');
|
|
56
|
-
confidence = Math.max(confidence, 0.5);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Check for very large screen (unrealistic)
|
|
60
|
-
if (width > 7680 || height > 4320) { // Beyond 8K
|
|
61
|
-
anomalies.push('unrealistic-screen-size');
|
|
62
|
-
confidence = Math.max(confidence, 0.4);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Check for common headless default dimensions
|
|
66
|
-
const headlessDefaults = [
|
|
67
|
-
{ w: 800, h: 600 },
|
|
68
|
-
{ w: 1024, h: 768 },
|
|
69
|
-
{ w: 1920, h: 1080 },
|
|
70
|
-
];
|
|
71
|
-
|
|
72
|
-
for (const def of headlessDefaults) {
|
|
73
|
-
if (width === def.w && height === def.h &&
|
|
74
|
-
outerWidth === def.w && outerHeight === def.h) {
|
|
75
|
-
// Exact match with no browser chrome - suspicious
|
|
76
|
-
anomalies.push('headless-default-dimensions');
|
|
77
|
-
confidence = Math.max(confidence, 0.5);
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Check for screen larger than available (impossible)
|
|
83
|
-
if (availWidth > width || availHeight > height) {
|
|
84
|
-
anomalies.push('available-exceeds-total');
|
|
85
|
-
confidence = Math.max(confidence, 0.7);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Check for window larger than screen
|
|
89
|
-
if (outerWidth > width || outerHeight > height) {
|
|
90
|
-
anomalies.push('window-exceeds-screen');
|
|
91
|
-
confidence = Math.max(confidence, 0.6);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Check for unusual color depth
|
|
95
|
-
if (colorDepth !== 24 && colorDepth !== 32 && colorDepth !== 30 && colorDepth !== 48) {
|
|
96
|
-
anomalies.push('unusual-color-depth');
|
|
97
|
-
confidence = Math.max(confidence, 0.3);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Check for mismatched color/pixel depth
|
|
101
|
-
if (colorDepth !== pixelDepth) {
|
|
102
|
-
anomalies.push('depth-mismatch');
|
|
103
|
-
confidence = Math.max(confidence, 0.3);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Check for device pixel ratio anomalies
|
|
107
|
-
const dpr = window.devicePixelRatio;
|
|
108
|
-
if (dpr === 0 || dpr === undefined) {
|
|
109
|
-
anomalies.push('missing-device-pixel-ratio');
|
|
110
|
-
confidence = Math.max(confidence, 0.5);
|
|
111
|
-
} else if (dpr < 0.5 || dpr > 5) {
|
|
112
|
-
anomalies.push('unusual-device-pixel-ratio');
|
|
113
|
-
confidence = Math.max(confidence, 0.4);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Check for screen orientation API anomalies
|
|
117
|
-
if (screen.orientation) {
|
|
118
|
-
const orientationType = screen.orientation.type;
|
|
119
|
-
const orientationAngle = screen.orientation.angle;
|
|
120
|
-
|
|
121
|
-
// Landscape device with portrait dimensions
|
|
122
|
-
if (orientationType.includes('landscape') && width < height) {
|
|
123
|
-
anomalies.push('orientation-dimension-mismatch');
|
|
124
|
-
confidence = Math.max(confidence, 0.4);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Portrait device with landscape dimensions
|
|
128
|
-
if (orientationType.includes('portrait') && width > height) {
|
|
129
|
-
anomalies.push('orientation-dimension-mismatch');
|
|
130
|
-
confidence = Math.max(confidence, 0.4);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Check for innerWidth/Height being exactly equal to outer (no browser chrome)
|
|
135
|
-
if (innerWidth === outerWidth && innerHeight === outerHeight &&
|
|
136
|
-
outerWidth > 0 && outerHeight > 0) {
|
|
137
|
-
anomalies.push('no-browser-chrome');
|
|
138
|
-
confidence = Math.max(confidence, 0.5);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const triggered = anomalies.length > 0;
|
|
142
|
-
|
|
143
|
-
return this.createResult(triggered, {
|
|
144
|
-
anomalies,
|
|
145
|
-
dimensions: {
|
|
146
|
-
screen: { width, height },
|
|
147
|
-
available: { width: availWidth, height: availHeight },
|
|
148
|
-
window: { outer: { width: outerWidth, height: outerHeight },
|
|
149
|
-
inner: { width: innerWidth, height: innerHeight } },
|
|
150
|
-
colorDepth,
|
|
151
|
-
devicePixelRatio: dpr,
|
|
152
|
-
},
|
|
153
|
-
}, confidence);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export { ScreenSignal };
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Detects WebGL rendering anomalies.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { Signal } from '../../core/Signal.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Detects WebGL anomalies and spoofed renderer information.
|
|
9
|
-
* Bots often have missing, disabled, or fake WebGL contexts.
|
|
10
|
-
*/
|
|
11
|
-
class WebGLSignal extends Signal {
|
|
12
|
-
static id = 'webgl';
|
|
13
|
-
static category = 'fingerprint';
|
|
14
|
-
static weight = 0.7;
|
|
15
|
-
static description = 'Detects WebGL rendering anomalies';
|
|
16
|
-
|
|
17
|
-
async detect() {
|
|
18
|
-
const anomalies = [];
|
|
19
|
-
let confidence = 0;
|
|
20
|
-
|
|
21
|
-
// Try to get WebGL context
|
|
22
|
-
const canvas = document.createElement('canvas');
|
|
23
|
-
let gl = null;
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
|
27
|
-
} catch (e) {
|
|
28
|
-
anomalies.push('webgl-error');
|
|
29
|
-
confidence = Math.max(confidence, 0.5);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (!gl) {
|
|
33
|
-
// WebGL not available - could be disabled or blocked
|
|
34
|
-
anomalies.push('webgl-unavailable');
|
|
35
|
-
confidence = Math.max(confidence, 0.4);
|
|
36
|
-
return this.createResult(true, { anomalies }, confidence);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Get renderer info
|
|
40
|
-
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
|
41
|
-
let vendor = '';
|
|
42
|
-
let renderer = '';
|
|
43
|
-
|
|
44
|
-
if (debugInfo) {
|
|
45
|
-
vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) || '';
|
|
46
|
-
renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || '';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Check for empty renderer info (common in headless)
|
|
50
|
-
if (!vendor && !renderer) {
|
|
51
|
-
anomalies.push('no-webgl-renderer-info');
|
|
52
|
-
confidence = Math.max(confidence, 0.6);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Check for known headless/virtual renderer strings
|
|
56
|
-
const suspiciousRenderers = [
|
|
57
|
-
'swiftshader',
|
|
58
|
-
'llvmpipe',
|
|
59
|
-
'software',
|
|
60
|
-
'mesa',
|
|
61
|
-
'google swiftshader',
|
|
62
|
-
'vmware',
|
|
63
|
-
'virtualbox',
|
|
64
|
-
];
|
|
65
|
-
|
|
66
|
-
const rendererLower = renderer.toLowerCase();
|
|
67
|
-
for (const sus of suspiciousRenderers) {
|
|
68
|
-
if (rendererLower.includes(sus)) {
|
|
69
|
-
anomalies.push(`suspicious-renderer-${sus.replace(/\s+/g, '-')}`);
|
|
70
|
-
confidence = Math.max(confidence, 0.7);
|
|
71
|
-
break;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Check for mismatched vendor/renderer
|
|
76
|
-
if (vendor && renderer) {
|
|
77
|
-
// NVIDIA renderer should have NVIDIA vendor
|
|
78
|
-
if (rendererLower.includes('nvidia') && !vendor.toLowerCase().includes('nvidia')) {
|
|
79
|
-
anomalies.push('vendor-renderer-mismatch');
|
|
80
|
-
confidence = Math.max(confidence, 0.6);
|
|
81
|
-
}
|
|
82
|
-
// AMD renderer should have AMD/ATI vendor
|
|
83
|
-
if ((rendererLower.includes('amd') || rendererLower.includes('radeon')) &&
|
|
84
|
-
!vendor.toLowerCase().includes('amd') && !vendor.toLowerCase().includes('ati')) {
|
|
85
|
-
anomalies.push('vendor-renderer-mismatch');
|
|
86
|
-
confidence = Math.max(confidence, 0.6);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Check for supported extensions
|
|
91
|
-
const extensions = gl.getSupportedExtensions() || [];
|
|
92
|
-
|
|
93
|
-
// Suspiciously few extensions
|
|
94
|
-
if (extensions.length < 5) {
|
|
95
|
-
anomalies.push('few-webgl-extensions');
|
|
96
|
-
confidence = Math.max(confidence, 0.4);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Check for WebGL parameter consistency
|
|
100
|
-
const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
|
101
|
-
const maxViewportDims = gl.getParameter(gl.MAX_VIEWPORT_DIMS);
|
|
102
|
-
|
|
103
|
-
// Unrealistic values
|
|
104
|
-
if (maxTextureSize < 1024 || maxTextureSize > 65536) {
|
|
105
|
-
anomalies.push('unrealistic-max-texture');
|
|
106
|
-
confidence = Math.max(confidence, 0.5);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Check if WebGL rendering actually works
|
|
110
|
-
try {
|
|
111
|
-
// Simple render test
|
|
112
|
-
gl.clearColor(0.0, 0.0, 0.0, 1.0);
|
|
113
|
-
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
114
|
-
|
|
115
|
-
const pixels = new Uint8Array(4);
|
|
116
|
-
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
|
117
|
-
|
|
118
|
-
// If clear didn't work, something's wrong
|
|
119
|
-
if (pixels[3] !== 255) {
|
|
120
|
-
anomalies.push('webgl-render-failure');
|
|
121
|
-
confidence = Math.max(confidence, 0.6);
|
|
122
|
-
}
|
|
123
|
-
} catch (e) {
|
|
124
|
-
anomalies.push('webgl-render-error');
|
|
125
|
-
confidence = Math.max(confidence, 0.5);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Clean up
|
|
129
|
-
const loseContext = gl.getExtension('WEBGL_lose_context');
|
|
130
|
-
if (loseContext) {
|
|
131
|
-
loseContext.loseContext();
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const triggered = anomalies.length > 0;
|
|
135
|
-
|
|
136
|
-
return this.createResult(triggered, {
|
|
137
|
-
anomalies,
|
|
138
|
-
vendor,
|
|
139
|
-
renderer,
|
|
140
|
-
extensionCount: extensions.length,
|
|
141
|
-
maxTextureSize,
|
|
142
|
-
}, confidence);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export { WebGLSignal };
|