Skip to content

Commit 0f34500

Browse files
committed
feat(detection): port FCaptcha detection engine into SDK (Phase 1)
In-process DetectionEngine scoring ~40 behavioral, environmental, and fingerprint signals into a weighted verdict (allow/challenge/block) — no remote call. Ported from FCaptcha server.js runVerification + detection.js. Includes: vision-AI, headless, automation, CDP, behavioral, mobile touch/sensor/kinematics, fingerprint correlation, rate abuse, header analysis, browser consistency, JA3/JA4 matching, form interaction, and keystroke-cadence detectors; confidence-weighted category scoring; pluggable fingerprint/rate stores. PoW outcome is supplied by the caller (real PoW crypto + token issuance land in Phase 2). 58 parity tests ported from the FCaptcha reference suite, all passing. IP enrichment/reputation continues to use api.webdecoy.com.
1 parent 4246361 commit 0f34500

27 files changed

Lines changed: 2842 additions & 0 deletions

packages/webdecoy/jest.config.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
roots: ['<rootDir>/src'],
6+
testMatch: ['**/*.test.ts'],
7+
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
8+
};
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* Advanced fingerprint detectors: WebRTC, Speech API, worker consistency, CSS
3+
* media queries, fonts, permissions, DOMRect. Ported from FCaptcha detection.js.
4+
*/
5+
6+
import type {
7+
Detection,
8+
Signals,
9+
WebRTCInfo,
10+
SpeechInfo,
11+
WorkerConsistencyInfo,
12+
CSSMediaQueriesInfo,
13+
FontsInfo,
14+
PermissionsInfo,
15+
DOMRectInfo,
16+
} from '../types';
17+
18+
export function analyzeWebRTC(webrtcInfo?: WebRTCInfo): Detection[] {
19+
if (!webrtcInfo || !webrtcInfo.supported) return [];
20+
const detections: Detection[] = [];
21+
const mediaDevices = webrtcInfo.mediaDevices ?? {};
22+
23+
if (mediaDevices.supported && mediaDevices.totalDevices === 0) {
24+
detections.push({
25+
category: 'headless',
26+
score: 0.7,
27+
confidence: 0.75,
28+
reason: 'No media devices detected (typical of headless browsers)',
29+
});
30+
}
31+
32+
if (mediaDevices.supported && (mediaDevices.videoInputs ?? 0) > 0 && mediaDevices.audioInputs === 0) {
33+
detections.push({
34+
category: 'bot',
35+
score: 0.4,
36+
confidence: 0.5,
37+
reason: 'Has video devices but no audio devices (unusual configuration)',
38+
});
39+
}
40+
41+
if (webrtcInfo.hasLocalIP === false && !webrtcInfo.localIPError) {
42+
detections.push({
43+
category: 'headless',
44+
score: 0.4,
45+
confidence: 0.5,
46+
reason: 'No local IP addresses detected via WebRTC',
47+
});
48+
}
49+
50+
return detections;
51+
}
52+
53+
export function analyzeSpeechAPI(speechInfo?: SpeechInfo): Detection[] {
54+
if (!speechInfo || !speechInfo.supported) return [];
55+
const detections: Detection[] = [];
56+
const totalVoices = speechInfo.totalVoices ?? 0;
57+
58+
if (totalVoices === 0) {
59+
detections.push({
60+
category: 'headless',
61+
score: 0.6,
62+
confidence: 0.7,
63+
reason: 'No speech synthesis voices available',
64+
});
65+
}
66+
67+
if (totalVoices > 0 && totalVoices < 5) {
68+
detections.push({
69+
category: 'headless',
70+
score: 0.3,
71+
confidence: 0.4,
72+
reason: `Very few speech voices available (${totalVoices})`,
73+
});
74+
}
75+
76+
if (speechInfo.localVoices === 0 && totalVoices > 0) {
77+
detections.push({
78+
category: 'bot',
79+
score: 0.3,
80+
confidence: 0.4,
81+
reason: 'No local speech synthesis voices',
82+
});
83+
}
84+
85+
return detections;
86+
}
87+
88+
export function analyzeWorkerConsistency(workerConsistency?: WorkerConsistencyInfo): Detection[] {
89+
if (!workerConsistency || !workerConsistency.supported) return [];
90+
const detections: Detection[] = [];
91+
92+
if (!workerConsistency.consistent && (workerConsistency.mismatchCount ?? 0) > 0) {
93+
const score = Math.min(0.9, 0.3 + (workerConsistency.mismatchCount ?? 0) * 0.15);
94+
detections.push({
95+
category: 'bot',
96+
score,
97+
confidence: 0.85,
98+
reason: `Worker/main thread mismatch detected: ${(workerConsistency.mismatches ?? []).join(', ')}`,
99+
});
100+
}
101+
102+
return detections;
103+
}
104+
105+
export function analyzeCSSMediaQueries(cssMedia: CSSMediaQueriesInfo | undefined, signals: Signals): Detection[] {
106+
if (!cssMedia || !cssMedia.supported) return [];
107+
const detections: Detection[] = [];
108+
const maxTouch = signals.environmental?.navigator?.maxTouchPoints ?? 0;
109+
110+
if (cssMedia.pointer === 'coarse' && maxTouch === 0) {
111+
detections.push({
112+
category: 'bot',
113+
score: 0.5,
114+
confidence: 0.6,
115+
reason: 'CSS reports coarse pointer but no touch support',
116+
});
117+
}
118+
119+
if (cssMedia.hover === false && cssMedia.pointer === 'fine') {
120+
detections.push({
121+
category: 'bot',
122+
score: 0.3,
123+
confidence: 0.4,
124+
reason: 'Fine pointer reported but no hover capability',
125+
});
126+
}
127+
128+
return detections;
129+
}
130+
131+
export function analyzeFonts(fontsInfo: FontsInfo | undefined, userAgent: string): Detection[] {
132+
if (!fontsInfo || !fontsInfo.supported) return [];
133+
const detections: Detection[] = [];
134+
const count = fontsInfo.count ?? 0;
135+
136+
if (count < 3) {
137+
detections.push({
138+
category: 'headless',
139+
score: 0.5,
140+
confidence: 0.5,
141+
reason: `Very few fonts detected (${count})`,
142+
});
143+
}
144+
145+
const ua = (userAgent || '').toLowerCase();
146+
147+
if (ua.includes('windows') && fontsInfo.hasSegoeUI === false && count > 5) {
148+
detections.push({
149+
category: 'bot',
150+
score: 0.5,
151+
confidence: 0.6,
152+
reason: 'Windows UA but Segoe UI font not detected',
153+
});
154+
}
155+
156+
if (
157+
(ua.includes('mac os x') || ua.includes('macintosh')) &&
158+
fontsInfo.hasSFPro === false &&
159+
!ua.includes('10_15') &&
160+
!ua.includes('10_14') &&
161+
count > 5
162+
) {
163+
detections.push({
164+
category: 'bot',
165+
score: 0.3,
166+
confidence: 0.4,
167+
reason: 'Modern macOS UA but SF Pro font not detected',
168+
});
169+
}
170+
171+
if (ua.includes('linux') && !ua.includes('android') && fontsInfo.hasDejaVuSans === false && count > 5) {
172+
detections.push({
173+
category: 'bot',
174+
score: 0.4,
175+
confidence: 0.5,
176+
reason: 'Linux UA but DejaVu Sans font not detected',
177+
});
178+
}
179+
180+
return detections;
181+
}
182+
183+
const PERMISSION_API_KEYS = [
184+
'hasPermissionsAPI', 'hasClipboard', 'hasShare', 'hasCredentials',
185+
'hasBluetooth', 'hasUsb', 'hasSerial', 'hasHid', 'hasXR',
186+
'hasGeolocation', 'hasMIDI',
187+
];
188+
189+
export function analyzePermissions(permissionsInfo?: PermissionsInfo): Detection[] {
190+
if (!permissionsInfo || !permissionsInfo.supported) return [];
191+
const detections: Detection[] = [];
192+
193+
const availableApis = PERMISSION_API_KEYS.filter((key) => permissionsInfo[key] === true).length;
194+
195+
if (availableApis < 3) {
196+
detections.push({
197+
category: 'headless',
198+
score: 0.4,
199+
confidence: 0.5,
200+
reason: `Very few navigator APIs available (${availableApis})`,
201+
});
202+
}
203+
204+
return detections;
205+
}
206+
207+
export function analyzeDOMRect(domRectInfo?: DOMRectInfo): Detection[] {
208+
if (!domRectInfo || !domRectInfo.supported) return [];
209+
const detections: Detection[] = [];
210+
211+
if (domRectInfo.rectAWidth === 0 || domRectInfo.rectBWidth === 0) {
212+
detections.push({
213+
category: 'headless',
214+
score: 0.6,
215+
confidence: 0.7,
216+
reason: 'DOMRect rendering returned zero-width elements',
217+
});
218+
}
219+
220+
if (
221+
domRectInfo.rectAWidth !== undefined &&
222+
domRectInfo.rectBWidth !== undefined &&
223+
domRectInfo.rangeWidth !== undefined &&
224+
domRectInfo.rectAWidth === Math.floor(domRectInfo.rectAWidth) &&
225+
domRectInfo.rectBWidth === Math.floor(domRectInfo.rectBWidth) &&
226+
domRectInfo.rangeWidth === Math.floor(domRectInfo.rangeWidth)
227+
) {
228+
detections.push({
229+
category: 'bot',
230+
score: 0.3,
231+
confidence: 0.4,
232+
reason: 'DOMRect measurements are all exact integers (unusual)',
233+
});
234+
}
235+
236+
return detections;
237+
}
238+
239+
/** Run every advanced-fingerprint detector present in the signals. */
240+
export function analyzeAdvancedSignals(signals: Signals, userAgent: string): Detection[] {
241+
const detections: Detection[] = [];
242+
const env = signals.environmental ?? {};
243+
244+
if (env.webrtcInfo) detections.push(...analyzeWebRTC(env.webrtcInfo));
245+
if (env.speechInfo) detections.push(...analyzeSpeechAPI(env.speechInfo));
246+
if (env.workerConsistency) detections.push(...analyzeWorkerConsistency(env.workerConsistency));
247+
if (env.cssMediaQueries) detections.push(...analyzeCSSMediaQueries(env.cssMediaQueries, signals));
248+
if (env.fontsInfo) detections.push(...analyzeFonts(env.fontsInfo, userAgent));
249+
if (env.permissionsInfo) detections.push(...analyzePermissions(env.permissionsInfo));
250+
if (env.domRectFingerprint) detections.push(...analyzeDOMRect(env.domRectFingerprint));
251+
252+
return detections;
253+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/** Generic automation timing detection (JS speed, RAF, mouse event cadence). */
2+
3+
import type { Detection, Signals } from '../types';
4+
5+
export function detectAutomation(signals: Signals): Detection[] {
6+
const detections: Detection[] = [];
7+
const env = signals.environmental ?? {};
8+
const b = signals.behavioral ?? {};
9+
10+
const jsTime = env.jsExecutionTime?.mathOps ?? 0;
11+
if (jsTime > 0) {
12+
if (jsTime < 0.1) {
13+
detections.push({
14+
category: 'automation',
15+
score: 0.4,
16+
confidence: 0.3,
17+
reason: 'JS execution unusually fast',
18+
});
19+
} else if (jsTime > 50) {
20+
detections.push({
21+
category: 'automation',
22+
score: 0.3,
23+
confidence: 0.3,
24+
reason: 'JS execution unusually slow',
25+
});
26+
}
27+
}
28+
29+
const raf = env.rafConsistency ?? {};
30+
if (raf.frameTimeVariance !== undefined && raf.frameTimeVariance < 0.1) {
31+
detections.push({
32+
category: 'automation',
33+
score: 0.5,
34+
confidence: 0.4,
35+
reason: 'RequestAnimationFrame timing too consistent',
36+
});
37+
}
38+
39+
const eventVar = b.eventDeltaVariance ?? 10;
40+
const totalPoints = b.totalPoints ?? 0;
41+
if (eventVar < 2 && totalPoints > 10) {
42+
detections.push({
43+
category: 'automation',
44+
score: 0.6,
45+
confidence: 0.6,
46+
reason: 'Mouse event timing unnaturally consistent',
47+
});
48+
}
49+
50+
return detections;
51+
}

0 commit comments

Comments
 (0)