@niksbanna/bot-detector 1.0.0

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.
Files changed (38) hide show
  1. package/README.md +245 -0
  2. package/dist/bot-detector.cjs.js +2629 -0
  3. package/dist/bot-detector.cjs.js.map +7 -0
  4. package/dist/bot-detector.esm.js +2609 -0
  5. package/dist/bot-detector.esm.js.map +7 -0
  6. package/dist/bot-detector.iife.js +2631 -0
  7. package/dist/bot-detector.iife.js.map +7 -0
  8. package/dist/bot-detector.iife.min.js +1 -0
  9. package/package.json +57 -0
  10. package/src/core/BotDetector.js +284 -0
  11. package/src/core/ScoringEngine.js +134 -0
  12. package/src/core/Signal.js +181 -0
  13. package/src/core/VerdictEngine.js +132 -0
  14. package/src/index.js +273 -0
  15. package/src/signals/automation/PhantomJSSignal.js +137 -0
  16. package/src/signals/automation/PlaywrightSignal.js +129 -0
  17. package/src/signals/automation/PuppeteerSignal.js +117 -0
  18. package/src/signals/automation/SeleniumSignal.js +151 -0
  19. package/src/signals/automation/index.js +8 -0
  20. package/src/signals/behavior/InteractionTimingSignal.js +170 -0
  21. package/src/signals/behavior/KeyboardPatternSignal.js +235 -0
  22. package/src/signals/behavior/MouseMovementSignal.js +215 -0
  23. package/src/signals/behavior/ScrollBehaviorSignal.js +236 -0
  24. package/src/signals/behavior/index.js +8 -0
  25. package/src/signals/environment/HeadlessSignal.js +97 -0
  26. package/src/signals/environment/NavigatorAnomalySignal.js +117 -0
  27. package/src/signals/environment/PermissionsSignal.js +76 -0
  28. package/src/signals/environment/WebDriverSignal.js +58 -0
  29. package/src/signals/environment/index.js +8 -0
  30. package/src/signals/fingerprint/AudioContextSignal.js +158 -0
  31. package/src/signals/fingerprint/CanvasSignal.js +133 -0
  32. package/src/signals/fingerprint/PluginsSignal.js +106 -0
  33. package/src/signals/fingerprint/ScreenSignal.js +157 -0
  34. package/src/signals/fingerprint/WebGLSignal.js +146 -0
  35. package/src/signals/fingerprint/index.js +9 -0
  36. package/src/signals/timing/DOMContentTimingSignal.js +159 -0
  37. package/src/signals/timing/PageLoadSignal.js +165 -0
  38. package/src/signals/timing/index.js +6 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @fileoverview Detects permissions API anomalies.
3
+ */
4
+
5
+ import { Signal } from '../../core/Signal.js';
6
+
7
+ /**
8
+ * Detects anomalies in the Permissions API.
9
+ * Automation tools often have inconsistent permission states.
10
+ */
11
+ class PermissionsSignal extends Signal {
12
+ static id = 'permissions';
13
+ static category = 'environment';
14
+ static weight = 0.5;
15
+ static description = 'Detects Permissions API anomalies';
16
+
17
+ async detect() {
18
+ const anomalies = [];
19
+
20
+ // Check if Permissions API exists
21
+ if (!navigator.permissions) {
22
+ // Not an anomaly, just not supported
23
+ return this.createResult(false, { supported: false }, 0);
24
+ }
25
+
26
+ try {
27
+ // Check notification permission consistency
28
+ const notificationStatus = await navigator.permissions.query({ name: 'notifications' });
29
+
30
+ if (typeof Notification !== 'undefined') {
31
+ const directPermission = Notification.permission;
32
+
33
+ // Check for mismatch
34
+ if ((directPermission === 'granted' && notificationStatus.state !== 'granted') ||
35
+ (directPermission === 'denied' && notificationStatus.state !== 'denied') ||
36
+ (directPermission === 'default' && notificationStatus.state !== 'prompt')) {
37
+ anomalies.push('notification-permission-mismatch');
38
+ }
39
+ }
40
+
41
+ // Check geolocation permission if available
42
+ try {
43
+ const geoStatus = await navigator.permissions.query({ name: 'geolocation' });
44
+ // In automation, geolocation is often pre-denied without user action
45
+ if (geoStatus.state === 'denied' && window.outerWidth === 0) {
46
+ anomalies.push('geo-denied-headless');
47
+ }
48
+ } catch (e) {
49
+ // Geolocation permission query may not be supported
50
+ }
51
+
52
+ // Check if permission.query throws on valid permission
53
+ try {
54
+ await navigator.permissions.query({ name: 'camera' });
55
+ } catch (e) {
56
+ // Some headless browsers don't support camera permission
57
+ if (e.name === 'TypeError') {
58
+ anomalies.push('camera-permission-error');
59
+ }
60
+ }
61
+
62
+ } catch (e) {
63
+ // If permissions query throws unexpectedly, that's suspicious
64
+ if (e.name !== 'TypeError') {
65
+ anomalies.push('permissions-query-error');
66
+ }
67
+ }
68
+
69
+ const triggered = anomalies.length > 0;
70
+ const confidence = Math.min(1, anomalies.length * 0.4);
71
+
72
+ return this.createResult(triggered, { anomalies }, confidence);
73
+ }
74
+ }
75
+
76
+ export { PermissionsSignal };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @fileoverview Detects navigator.webdriver property.
3
+ * This is the most reliable indicator of WebDriver-controlled browsers.
4
+ */
5
+
6
+ import { Signal } from '../../core/Signal.js';
7
+
8
+ /**
9
+ * Detects the presence of navigator.webdriver property.
10
+ * This property is set to true by automation frameworks like Selenium, Puppeteer, and Playwright.
11
+ */
12
+ class WebDriverSignal extends Signal {
13
+ static id = 'webdriver';
14
+ static category = 'environment';
15
+ static weight = 1.0;
16
+ static description = 'Detects navigator.webdriver automation flag';
17
+
18
+ async detect() {
19
+ // Direct check
20
+ if (navigator.webdriver === true) {
21
+ return this.createResult(true, { webdriver: true }, 1.0);
22
+ }
23
+
24
+ // Check if property exists but is hidden/modified
25
+ const descriptor = Object.getOwnPropertyDescriptor(navigator, 'webdriver');
26
+ if (descriptor) {
27
+ // Property exists - check if it's been tampered with
28
+ if (descriptor.get || !descriptor.configurable) {
29
+ return this.createResult(true, {
30
+ webdriver: 'modified',
31
+ descriptor: {
32
+ configurable: descriptor.configurable,
33
+ enumerable: descriptor.enumerable,
34
+ hasGetter: !!descriptor.get,
35
+ }
36
+ }, 0.8);
37
+ }
38
+ }
39
+
40
+ // Check prototype chain for webdriver
41
+ try {
42
+ const proto = Object.getPrototypeOf(navigator);
43
+ const protoDescriptor = Object.getOwnPropertyDescriptor(proto, 'webdriver');
44
+ if (protoDescriptor && protoDescriptor.get) {
45
+ const value = protoDescriptor.get.call(navigator);
46
+ if (value === true) {
47
+ return this.createResult(true, { webdriver: true, source: 'prototype' }, 1.0);
48
+ }
49
+ }
50
+ } catch (e) {
51
+ // Some environments may throw on prototype access
52
+ }
53
+
54
+ return this.createResult(false);
55
+ }
56
+ }
57
+
58
+ export { WebDriverSignal };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @fileoverview Environment signals index.
3
+ */
4
+
5
+ export { WebDriverSignal } from './WebDriverSignal.js';
6
+ export { HeadlessSignal } from './HeadlessSignal.js';
7
+ export { NavigatorAnomalySignal } from './NavigatorAnomalySignal.js';
8
+ export { PermissionsSignal } from './PermissionsSignal.js';
@@ -0,0 +1,158 @@
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 };
@@ -0,0 +1,133 @@
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 };
@@ -0,0 +1,106 @@
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 };
@@ -0,0 +1,157 @@
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 };