capjs-core 0.1.6 → 0.1.7
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 +5 -2
- package/package.json +5 -2
- package/src/agents.js +700 -0
- package/src/behavior.js +437 -0
- package/src/detect.js +189 -8
- package/src/hashwx-wasm.js +1 -5
- package/src/index.d.ts +198 -43
- package/src/index.js +298 -67
- package/src/instrumentation.js +742 -116
- package/src/rsw.js +13 -5
package/src/behavior.js
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const BEHAVIOR_VERSION = 1;
|
|
4
|
+
export const WEAK_CAP = 0.6;
|
|
5
|
+
export const DEFAULT_BEHAVIOR_THRESHOLD = 0.75;
|
|
6
|
+
export const SUSPICIOUS_THRESHOLD = 0.4;
|
|
7
|
+
|
|
8
|
+
const MAX_MOVES = 320;
|
|
9
|
+
const MAX_KEYS = 64;
|
|
10
|
+
const MAX_TIME = 6 * 60 * 60 * 1000;
|
|
11
|
+
const MAX_COORD = 100_000;
|
|
12
|
+
|
|
13
|
+
const FINE_CLOCK_MS = 2;
|
|
14
|
+
|
|
15
|
+
export const BEHAVIOR_WEIGHTS = {
|
|
16
|
+
bh_malformed: 0.9,
|
|
17
|
+
bh_replayed: 0.95,
|
|
18
|
+
bh_untrusted_activation: 0.9,
|
|
19
|
+
bh_linear_path: 0.85,
|
|
20
|
+
bh_polynomial_path: 0.85,
|
|
21
|
+
bh_hidden_activation: 0.85,
|
|
22
|
+
bh_uniform_typing: 0.75,
|
|
23
|
+
bh_superhuman_typing: 0.5,
|
|
24
|
+
bh_activation_unseen: 0.7,
|
|
25
|
+
bh_too_fast: 0.6,
|
|
26
|
+
bh_screen_coords_leak: 0.5,
|
|
27
|
+
bh_warp_click: 0.35,
|
|
28
|
+
bh_instant_press: 0.3,
|
|
29
|
+
bh_center_hit: 0.3,
|
|
30
|
+
bh_instant_key: 0.2,
|
|
31
|
+
bh_idle_then_act: 0.15,
|
|
32
|
+
bh_touch_geometry: 0.15,
|
|
33
|
+
bh_unexplained_scroll: 0.3,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const STRONG = new Set([
|
|
37
|
+
"bh_malformed",
|
|
38
|
+
"bh_replayed",
|
|
39
|
+
"bh_untrusted_activation",
|
|
40
|
+
"bh_linear_path",
|
|
41
|
+
"bh_polynomial_path",
|
|
42
|
+
"bh_hidden_activation",
|
|
43
|
+
"bh_uniform_typing",
|
|
44
|
+
"bh_superhuman_typing",
|
|
45
|
+
"bh_activation_unseen",
|
|
46
|
+
"bh_too_fast",
|
|
47
|
+
"bh_screen_coords_leak",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const PT_MOUSE = 0;
|
|
51
|
+
const PT_PEN = 1;
|
|
52
|
+
const PT_TOUCH = 2;
|
|
53
|
+
|
|
54
|
+
const ACT_POINTER = 0;
|
|
55
|
+
const ACT_KEY = 1;
|
|
56
|
+
const ACT_CLICK = 2;
|
|
57
|
+
|
|
58
|
+
const isNum = (x) => typeof x === "number" && Number.isFinite(x);
|
|
59
|
+
const inRange = (x, lo, hi) => isNum(x) && x >= lo && x <= hi;
|
|
60
|
+
const isTime = (x) => inRange(x, -10_000, MAX_TIME);
|
|
61
|
+
const isCoord = (x) => inRange(x, -MAX_COORD, MAX_COORD);
|
|
62
|
+
|
|
63
|
+
function mean(a) {
|
|
64
|
+
let s = 0;
|
|
65
|
+
for (const x of a) s += x;
|
|
66
|
+
return a.length ? s / a.length : 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cv(a) {
|
|
70
|
+
if (a.length < 2) return null;
|
|
71
|
+
const m = mean(a);
|
|
72
|
+
if (m === 0) return 0;
|
|
73
|
+
let v = 0;
|
|
74
|
+
for (const x of a) v += (x - m) ** 2;
|
|
75
|
+
return Math.sqrt(v / a.length) / Math.abs(m);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function median(a) {
|
|
79
|
+
if (!a.length) return null;
|
|
80
|
+
const s = [...a].sort((x, y) => x - y);
|
|
81
|
+
const mid = s.length >> 1;
|
|
82
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function nearMedian(a, tol) {
|
|
86
|
+
const m = median(a);
|
|
87
|
+
if (!m) return null;
|
|
88
|
+
let k = 0;
|
|
89
|
+
for (const x of a) if (Math.abs(x - m) <= tol * m) k++;
|
|
90
|
+
return k / a.length;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const round = (x, d = 3) => (x === null ? null : Number(x.toFixed(d)));
|
|
94
|
+
|
|
95
|
+
export function validateTrace(t) {
|
|
96
|
+
if (!t || typeof t !== "object" || Array.isArray(t)) return "type";
|
|
97
|
+
if (t.v !== BEHAVIOR_VERSION) return "version";
|
|
98
|
+
if (!inRange(t.res, 0, 1000)) return "res";
|
|
99
|
+
if (!isTime(t.now) || !isTime(t.vis) || !isTime(t.last)) return "times";
|
|
100
|
+
if (!isTime(t.us)) return "times";
|
|
101
|
+
|
|
102
|
+
if (!Array.isArray(t.env) || t.env.length !== 8) return "env";
|
|
103
|
+
if (!t.env.every((x) => isNum(x) && Math.abs(x) <= MAX_COORD)) return "env";
|
|
104
|
+
|
|
105
|
+
if (!Array.isArray(t.n) || (t.n.length !== 10 && t.n.length !== 11))
|
|
106
|
+
return "counters";
|
|
107
|
+
if (!t.n.every((x) => Number.isInteger(x) && x >= 0 && x <= 1e7))
|
|
108
|
+
return "counters";
|
|
109
|
+
|
|
110
|
+
if (!Array.isArray(t.m) || t.m.length > MAX_MOVES) return "moves";
|
|
111
|
+
let prevT = -Infinity;
|
|
112
|
+
for (const p of t.m) {
|
|
113
|
+
if (!Array.isArray(p) || p.length !== 5) return "move";
|
|
114
|
+
const [pt, x, y, c, ptype] = p;
|
|
115
|
+
if (!isTime(pt) || !isCoord(x) || !isCoord(y)) return "move";
|
|
116
|
+
if (!Number.isInteger(c) || c < 0 || c > 1000) return "move";
|
|
117
|
+
if (ptype !== PT_MOUSE && ptype !== PT_PEN && ptype !== PT_TOUCH)
|
|
118
|
+
return "move";
|
|
119
|
+
if (pt < prevT - 1000) return "move_order";
|
|
120
|
+
prevT = Math.max(prevT, pt);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!Array.isArray(t.k) || t.k.length > MAX_KEYS) return "keys";
|
|
124
|
+
for (const k of t.k) {
|
|
125
|
+
if (!Array.isArray(k) || k.length !== 2) return "key";
|
|
126
|
+
if (!isTime(k[0]) || !inRange(k[1], -1, 60_000)) return "key";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (t.a !== null) {
|
|
130
|
+
const a = t.a;
|
|
131
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) return "activation";
|
|
132
|
+
if (![ACT_POINTER, ACT_KEY, ACT_CLICK].includes(a.k)) return "activation";
|
|
133
|
+
if (a.tr !== 0 && a.tr !== 1) return "activation";
|
|
134
|
+
if (a.hid !== 0 && a.hid !== 1) return "activation";
|
|
135
|
+
if (!isTime(a.t) || !isTime(a.u)) return "activation";
|
|
136
|
+
if (a.k === ACT_POINTER) {
|
|
137
|
+
if (![PT_MOUSE, PT_PEN, PT_TOUCH].includes(a.pt)) return "activation";
|
|
138
|
+
for (const f of ["x", "y", "cx", "cy", "w", "h", "sox", "soy"]) {
|
|
139
|
+
if (!isCoord(a[f])) return "activation";
|
|
140
|
+
}
|
|
141
|
+
if (!inRange(a.cw, 0, 10_000) || !inRange(a.ch, 0, 10_000))
|
|
142
|
+
return "activation";
|
|
143
|
+
if (!inRange(a.pr, 0, 1)) return "activation";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function traceDigest(t) {
|
|
150
|
+
const q = (x, s) => Math.round(x / s);
|
|
151
|
+
const parts = [];
|
|
152
|
+
for (const p of t.m) parts.push(q(p[0], 4), q(p[1], 2), q(p[2], 2));
|
|
153
|
+
parts.push("|");
|
|
154
|
+
for (const k of t.k) parts.push(q(k[1], 4));
|
|
155
|
+
if (t.a) parts.push("|", t.a.k, q(t.a.cx ?? 0, 2), q(t.a.cy ?? 0, 2));
|
|
156
|
+
return createHash("sha256").update(parts.join(",")).digest("hex");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function runStats(win) {
|
|
160
|
+
const x0 = win[0][1];
|
|
161
|
+
const y0 = win[0][2];
|
|
162
|
+
const x1 = win[win.length - 1][1];
|
|
163
|
+
const y1 = win[win.length - 1][2];
|
|
164
|
+
const dx = x1 - x0;
|
|
165
|
+
const dy = y1 - y0;
|
|
166
|
+
const disp = Math.hypot(dx, dy);
|
|
167
|
+
const steps = [];
|
|
168
|
+
let len = 0;
|
|
169
|
+
let maxDev = 0;
|
|
170
|
+
for (let i = 1; i < win.length; i++) {
|
|
171
|
+
const s = Math.hypot(win[i][1] - win[i - 1][1], win[i][2] - win[i - 1][2]);
|
|
172
|
+
steps.push(s);
|
|
173
|
+
len += s;
|
|
174
|
+
if (disp > 0) {
|
|
175
|
+
const dev =
|
|
176
|
+
Math.abs(dy * win[i][1] - dx * win[i][2] + x1 * y0 - y1 * x0) / disp;
|
|
177
|
+
if (dev > maxDev) maxDev = dev;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
samples: win.length,
|
|
182
|
+
displacement: disp,
|
|
183
|
+
straightness: disp > 0 ? len / disp : null,
|
|
184
|
+
maxDeviation: maxDev,
|
|
185
|
+
stepCv: cv(steps),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function diffs(run, axis, order) {
|
|
190
|
+
let d = run.map((p) => p[axis]);
|
|
191
|
+
for (let k = 0; k < order; k++) {
|
|
192
|
+
const next = [];
|
|
193
|
+
for (let i = 1; i < d.length; i++) next.push(d[i] - d[i - 1]);
|
|
194
|
+
d = next;
|
|
195
|
+
}
|
|
196
|
+
return d;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const spread = (a) => Math.max(...a) - Math.min(...a);
|
|
200
|
+
|
|
201
|
+
const RUN = 8;
|
|
202
|
+
const LINEAR_TOL = 0.05;
|
|
203
|
+
const POLY_TOL = 0.1;
|
|
204
|
+
|
|
205
|
+
function polynomialRuns(win) {
|
|
206
|
+
let linear = false;
|
|
207
|
+
let polynomial = false;
|
|
208
|
+
for (let i = 0; i + RUN <= win.length; i++) {
|
|
209
|
+
const run = win.slice(i, i + RUN);
|
|
210
|
+
let moving = true;
|
|
211
|
+
for (let j = 1; j < run.length; j++) {
|
|
212
|
+
if (
|
|
213
|
+
Math.hypot(run[j][1] - run[j - 1][1], run[j][2] - run[j - 1][2]) < 0.5
|
|
214
|
+
) {
|
|
215
|
+
moving = false;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!moving) continue;
|
|
220
|
+
const first = run[0];
|
|
221
|
+
const last = run[run.length - 1];
|
|
222
|
+
if (Math.hypot(last[1] - first[1], last[2] - first[2]) < 60) continue;
|
|
223
|
+
|
|
224
|
+
const d2x = diffs(run, 1, 2);
|
|
225
|
+
const d2y = diffs(run, 2, 2);
|
|
226
|
+
const flat = (a) => a.every((v) => Math.abs(v) <= LINEAR_TOL);
|
|
227
|
+
if (flat(d2x) && flat(d2y)) {
|
|
228
|
+
linear = true;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const onGrid = run.every(
|
|
232
|
+
(p) => Number.isInteger(p[1] * 2) && Number.isInteger(p[2] * 2),
|
|
233
|
+
);
|
|
234
|
+
if (
|
|
235
|
+
!onGrid &&
|
|
236
|
+
spread(diffs(run, 1, 3)) <= POLY_TOL &&
|
|
237
|
+
spread(diffs(run, 2, 3)) <= POLY_TOL
|
|
238
|
+
) {
|
|
239
|
+
polynomial = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { linear, polynomial };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function approachPath(moves, a) {
|
|
246
|
+
const win = moves.filter(
|
|
247
|
+
(p) => p[4] === PT_MOUSE && p[0] <= a.t && p[0] >= a.t - 1500,
|
|
248
|
+
);
|
|
249
|
+
if (win.length < 6) return null;
|
|
250
|
+
return { ...runStats(win), ...polynomialRuns(win) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function typing(keys) {
|
|
254
|
+
if (keys.length < 7) return null;
|
|
255
|
+
const downs = keys.map((k) => k[0]);
|
|
256
|
+
const dd = [];
|
|
257
|
+
for (let i = 1; i < downs.length; i++) {
|
|
258
|
+
const d = downs[i] - downs[i - 1];
|
|
259
|
+
if (d > 0 && d < 2000) dd.push(d);
|
|
260
|
+
}
|
|
261
|
+
const dwell = keys.map((k) => k[1]).filter((x) => x >= 0);
|
|
262
|
+
return {
|
|
263
|
+
keys: keys.length,
|
|
264
|
+
intervalMedian: dd.length >= 6 ? median(dd) : null,
|
|
265
|
+
intervalCv: dd.length >= 7 ? cv(dd) : null,
|
|
266
|
+
intervalRegular: dd.length >= 7 ? nearMedian(dd, 0.08) : null,
|
|
267
|
+
dwellCv: dwell.length >= 7 ? cv(dwell) : null,
|
|
268
|
+
dwellRegular: dwell.length >= 7 ? nearMedian(dwell, 0.12) : null,
|
|
269
|
+
dwellMedian: median(dwell),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function analyzeBehavior(trace, ctx = {}) {
|
|
274
|
+
if (trace === undefined || trace === null) {
|
|
275
|
+
return {
|
|
276
|
+
score: null,
|
|
277
|
+
verdict: "missing",
|
|
278
|
+
flags: [],
|
|
279
|
+
signals: {},
|
|
280
|
+
digest: null,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const invalid = validateTrace(trace);
|
|
284
|
+
if (invalid) {
|
|
285
|
+
return finish(["bh_malformed"], { malformed: invalid }, null, ctx);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const flags = [];
|
|
289
|
+
const signals = {};
|
|
290
|
+
const t = trace;
|
|
291
|
+
const a = t.a;
|
|
292
|
+
const fineClock = t.res <= FINE_CLOCK_MS;
|
|
293
|
+
const [, , , , fullscreen, chromeH] = t.env;
|
|
294
|
+
signals.clockRes = t.res;
|
|
295
|
+
signals.moves = t.m.length;
|
|
296
|
+
signals.untrustedPageEvents = t.n[0] + t.n[1];
|
|
297
|
+
signals.unexplainedScrolls = t.n[7];
|
|
298
|
+
|
|
299
|
+
if (ctx.replayed) flags.push("bh_replayed");
|
|
300
|
+
|
|
301
|
+
const kb = typing(t.k);
|
|
302
|
+
if (kb) {
|
|
303
|
+
signals.typing = {
|
|
304
|
+
keys: kb.keys,
|
|
305
|
+
intervalMedian: round(kb.intervalMedian, 1),
|
|
306
|
+
intervalCv: round(kb.intervalCv),
|
|
307
|
+
intervalRegular: round(kb.intervalRegular),
|
|
308
|
+
dwellCv: round(kb.dwellCv),
|
|
309
|
+
dwellRegular: round(kb.dwellRegular),
|
|
310
|
+
dwellMedian: round(kb.dwellMedian, 1),
|
|
311
|
+
};
|
|
312
|
+
if (
|
|
313
|
+
fineClock &&
|
|
314
|
+
kb.intervalRegular !== null &&
|
|
315
|
+
kb.intervalRegular >= 0.75 &&
|
|
316
|
+
(kb.dwellRegular === null || kb.dwellRegular >= 0.6)
|
|
317
|
+
) {
|
|
318
|
+
flags.push("bh_uniform_typing");
|
|
319
|
+
}
|
|
320
|
+
if (fineClock && kb.intervalMedian !== null && kb.intervalMedian < 25) {
|
|
321
|
+
flags.push("bh_superhuman_typing");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!a) {
|
|
326
|
+
signals.activation = "none";
|
|
327
|
+
return finish(flags, signals, t, ctx, true);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
signals.activation = ["pointer", "key", "click"][a.k];
|
|
331
|
+
|
|
332
|
+
if (a.tr === 0) flags.push("bh_untrusted_activation");
|
|
333
|
+
if (a.hid === 1) flags.push("bh_hidden_activation");
|
|
334
|
+
|
|
335
|
+
const dwell = a.u >= 0 ? a.u - a.t : null;
|
|
336
|
+
signals.pressMs = dwell === null ? null : round(dwell, 1);
|
|
337
|
+
signals.sinceVisibleMs = t.vis >= 0 ? round(a.t - t.vis, 0) : null;
|
|
338
|
+
signals.idleMs = t.last >= 0 ? round(a.t - t.last, 0) : null;
|
|
339
|
+
|
|
340
|
+
if (a.k === ACT_POINTER) {
|
|
341
|
+
signals.pointerType = ["mouse", "pen", "touch"][a.pt];
|
|
342
|
+
|
|
343
|
+
if (t.vis < 0) flags.push("bh_activation_unseen");
|
|
344
|
+
else if (fineClock && a.t - t.vis < 120) flags.push("bh_too_fast");
|
|
345
|
+
|
|
346
|
+
if (a.sox === 0 && a.soy === 0 && !fullscreen && chromeH >= 40) {
|
|
347
|
+
flags.push("bh_screen_coords_leak");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const centered =
|
|
351
|
+
a.w >= 16 && a.h >= 16 && Math.abs(a.cx) < 0.75 && Math.abs(a.cy) < 0.75;
|
|
352
|
+
if (centered) flags.push("bh_center_hit");
|
|
353
|
+
|
|
354
|
+
if (t.us >= 0 && a.t >= t.us && a.t - t.us < 2000) {
|
|
355
|
+
flags.push("bh_unexplained_scroll");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (fineClock && dwell !== null && dwell < 8) {
|
|
359
|
+
flags.push("bh_instant_press");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (a.pt === PT_MOUSE) {
|
|
363
|
+
const before = t.m.filter((p) => p[4] === PT_MOUSE && p[0] <= a.t);
|
|
364
|
+
const isNear = (p) => Math.hypot(p[1] - a.x, p[2] - a.y) <= 30;
|
|
365
|
+
let i = before.length;
|
|
366
|
+
while (i > 0 && isNear(before[i - 1])) i--;
|
|
367
|
+
let warped;
|
|
368
|
+
if (i === 0) {
|
|
369
|
+
warped = before.length <= 2;
|
|
370
|
+
} else {
|
|
371
|
+
const prev = before[i - 1];
|
|
372
|
+
const first = before[i] ?? null;
|
|
373
|
+
const jump = first
|
|
374
|
+
? Math.hypot(first[1] - prev[1], first[2] - prev[2])
|
|
375
|
+
: Math.hypot(a.x - prev[1], a.y - prev[2]);
|
|
376
|
+
warped = jump > 150 && (first ? first[3] <= 1 : true);
|
|
377
|
+
}
|
|
378
|
+
signals.approachSamples = before.length;
|
|
379
|
+
if (warped) {
|
|
380
|
+
flags.push("bh_warp_click");
|
|
381
|
+
if (signals.idleMs !== null && signals.idleMs >= 3000) {
|
|
382
|
+
flags.push("bh_idle_then_act");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const path = approachPath(t.m, a);
|
|
387
|
+
if (path) {
|
|
388
|
+
signals.path = {
|
|
389
|
+
samples: path.samples,
|
|
390
|
+
displacement: round(path.displacement, 1),
|
|
391
|
+
straightness: round(path.straightness, 4),
|
|
392
|
+
maxDeviation: round(path.maxDeviation, 2),
|
|
393
|
+
stepCv: round(path.stepCv),
|
|
394
|
+
};
|
|
395
|
+
if (path.linear) flags.push("bh_linear_path");
|
|
396
|
+
else if (path.polynomial) flags.push("bh_polynomial_path");
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (a.pt === PT_TOUCH) {
|
|
401
|
+
if (a.cw <= 2 && a.ch <= 2 && (a.pr === 1 || a.pr === 0.5)) {
|
|
402
|
+
flags.push("bh_touch_geometry");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
} else if (a.k === ACT_KEY) {
|
|
406
|
+
if (fineClock && dwell !== null && dwell < 8) flags.push("bh_instant_key");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return finish(flags, signals, t, ctx);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function finish(flags, signals, trace, ctx, noActivation = false) {
|
|
413
|
+
let keep = 1;
|
|
414
|
+
let strong = false;
|
|
415
|
+
for (const f of flags) {
|
|
416
|
+
keep *= 1 - (BEHAVIOR_WEIGHTS[f] ?? 0);
|
|
417
|
+
if (STRONG.has(f)) strong = true;
|
|
418
|
+
}
|
|
419
|
+
let score = 1 - keep;
|
|
420
|
+
if (!strong && !ctx.strict) score = Math.min(score, WEAK_CAP);
|
|
421
|
+
score = round(score, 3);
|
|
422
|
+
|
|
423
|
+
const threshold = ctx.threshold ?? DEFAULT_BEHAVIOR_THRESHOLD;
|
|
424
|
+
let verdict;
|
|
425
|
+
if (score >= threshold) verdict = "automated";
|
|
426
|
+
else if (score >= SUSPICIOUS_THRESHOLD) verdict = "suspicious";
|
|
427
|
+
else verdict = noActivation ? "no_interaction" : "ok";
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
score,
|
|
431
|
+
verdict,
|
|
432
|
+
flags,
|
|
433
|
+
signals,
|
|
434
|
+
digest:
|
|
435
|
+
trace && trace.m.length + trace.k.length >= 8 ? traceDigest(trace) : null,
|
|
436
|
+
};
|
|
437
|
+
}
|
package/src/detect.js
CHANGED
|
@@ -22,12 +22,15 @@ const PROBE_FIELD_TYPES = {
|
|
|
22
22
|
outerWH: "array",
|
|
23
23
|
isExtended: "nullableBoolean",
|
|
24
24
|
fontWidths: "array",
|
|
25
|
+
maxTouchPoints: "number",
|
|
26
|
+
mainNav: "object",
|
|
25
27
|
tamper: "nullableObject",
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
const typeOk = (value, kind) => {
|
|
29
31
|
if (kind === "string") return typeof value === "string";
|
|
30
32
|
if (kind === "boolean") return typeof value === "boolean";
|
|
33
|
+
if (kind === "number") return isNum(value);
|
|
31
34
|
if (kind === "object") return isPlainObject(value);
|
|
32
35
|
if (kind === "array") return Array.isArray(value);
|
|
33
36
|
if (kind === "nullableObject") return value === null || isPlainObject(value);
|
|
@@ -38,7 +41,7 @@ const typeOk = (value, kind) => {
|
|
|
38
41
|
|
|
39
42
|
export function isMobileVector(S) {
|
|
40
43
|
if (S?.uaData?.mobile === true) return true;
|
|
41
|
-
return MOBILE_UA.test(
|
|
44
|
+
return MOBILE_UA.test(typeof S?.ua === "string" ? S.ua : "");
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
export function probeCompleteness(S, fontStackCount) {
|
|
@@ -63,14 +66,16 @@ export function geometryStats(fontWidths) {
|
|
|
63
66
|
.filter(isNum);
|
|
64
67
|
if (w.length < 6) return null;
|
|
65
68
|
let count = 0;
|
|
69
|
+
let blank = 0;
|
|
66
70
|
const distinct = new Set();
|
|
67
71
|
for (const x of w) {
|
|
72
|
+
if (x <= 0) blank++;
|
|
68
73
|
if (Math.trunc(x) === x) {
|
|
69
74
|
count++;
|
|
70
75
|
distinct.add(x);
|
|
71
76
|
}
|
|
72
77
|
}
|
|
73
|
-
return { count, distinct: distinct.size, n: w.length };
|
|
78
|
+
return { count, blank, distinct: distinct.size, n: w.length };
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
export function detectAutomation(S, opts = {}) {
|
|
@@ -83,6 +88,7 @@ export function detectAutomation(S, opts = {}) {
|
|
|
83
88
|
};
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
const ua = typeof S.ua === "string" ? S.ua : "";
|
|
86
92
|
const checks = [];
|
|
87
93
|
const add = (id, passed, detail, gating = true) =>
|
|
88
94
|
checks.push({ id, passed: passed !== false, detail, gating });
|
|
@@ -113,10 +119,16 @@ export function detectAutomation(S, opts = {}) {
|
|
|
113
119
|
: "not measured",
|
|
114
120
|
);
|
|
115
121
|
|
|
122
|
+
add(
|
|
123
|
+
"geometry_unmeasured",
|
|
124
|
+
!geo || geo.blank < geo.n,
|
|
125
|
+
geo ? `${geo.blank}/${geo.n} widths are zero` : "not measured",
|
|
126
|
+
);
|
|
127
|
+
|
|
116
128
|
add(
|
|
117
129
|
"webdriver_true",
|
|
118
130
|
S.webdriver !== true,
|
|
119
|
-
`webdriver=${
|
|
131
|
+
`webdriver=${typeof S.webdriver === "boolean" ? S.webdriver : typeof S.webdriver}`,
|
|
120
132
|
);
|
|
121
133
|
|
|
122
134
|
const stripped = chromium && S.webdriver === undefined;
|
|
@@ -164,19 +176,90 @@ export function detectAutomation(S, opts = {}) {
|
|
|
164
176
|
);
|
|
165
177
|
|
|
166
178
|
const brands = Array.isArray(S.uaData?.brands)
|
|
167
|
-
? S.uaData.brands.join(" ")
|
|
179
|
+
? S.uaData.brands.filter((b) => typeof b === "string").join(" ")
|
|
168
180
|
: "";
|
|
169
|
-
const headless =
|
|
170
|
-
/headlesschrome/i.test(brands) ||
|
|
171
|
-
/headlesschrome/i.test(String(S.ua || ""));
|
|
181
|
+
const headless = /headlesschrome/i.test(brands) || /headlesschrome/i.test(ua);
|
|
172
182
|
add(
|
|
173
183
|
"headless_token",
|
|
174
184
|
!headless,
|
|
175
185
|
headless ? "HeadlessChrome present" : "absent",
|
|
176
186
|
);
|
|
177
187
|
|
|
188
|
+
const hev = isPlainObject(S.hev) ? S.hev : null;
|
|
189
|
+
const blankHints =
|
|
190
|
+
chromium &&
|
|
191
|
+
!mobile &&
|
|
192
|
+
S.uaDataPresent === true &&
|
|
193
|
+
((Array.isArray(S.uaData?.brands) && S.uaData.brands.length === 0) ||
|
|
194
|
+
(!!hev && hev.fv === "" && hev.fl === ""));
|
|
195
|
+
add(
|
|
196
|
+
"ua_hints_blank",
|
|
197
|
+
!blankHints,
|
|
198
|
+
blankHints ? "user agent overridden without client hints" : "ok",
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const st = isPlainObject(S.stealth) ? S.stealth : {};
|
|
202
|
+
const stealthHits = ["iframe", "runtime"].filter((k) => st[k] === true);
|
|
203
|
+
add(
|
|
204
|
+
"stealth_patches",
|
|
205
|
+
stealthHits.length === 0,
|
|
206
|
+
stealthHits.length ? `patched: ${stealthHits.join(", ")}` : "ok",
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const juggler = gecko && S.juggler === true;
|
|
210
|
+
add(
|
|
211
|
+
"juggler_package",
|
|
212
|
+
!juggler,
|
|
213
|
+
gecko
|
|
214
|
+
? juggler
|
|
215
|
+
? "chrome://juggler registered"
|
|
216
|
+
: "absent"
|
|
217
|
+
: "not a gecko engine",
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const chromeMissing =
|
|
221
|
+
S.uaDataPresent === true &&
|
|
222
|
+
!mobile &&
|
|
223
|
+
S.maxTouchPoints === 0 &&
|
|
224
|
+
S.engine?.hasChrome === false &&
|
|
225
|
+
!/QtWebEngine/i.test(ua);
|
|
226
|
+
add(
|
|
227
|
+
"chrome_object_missing",
|
|
228
|
+
!chromeMissing,
|
|
229
|
+
chromeMissing ? "chromium without window.chrome" : "ok",
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const mn = isPlainObject(S.mainNav) ? S.mainNav : null;
|
|
233
|
+
const wn = isPlainObject(S.workerNav) ? S.workerNav : null;
|
|
234
|
+
const mismatched = [];
|
|
235
|
+
if (mn && wn) {
|
|
236
|
+
const fields = ["ua", "platform", "tz", "brands"];
|
|
237
|
+
if (mn.brave !== true) fields.push("languages", "hc", "gl");
|
|
238
|
+
for (const f of fields) {
|
|
239
|
+
const a = mn[f];
|
|
240
|
+
const b = wn[f];
|
|
241
|
+
if (a === null || a === undefined || b === null || b === undefined)
|
|
242
|
+
continue;
|
|
243
|
+
if (a !== b) mismatched.push(f);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
add(
|
|
247
|
+
"worker_mismatch",
|
|
248
|
+
mismatched.length === 0,
|
|
249
|
+
mn && wn
|
|
250
|
+
? mismatched.length
|
|
251
|
+
? `differs in worker: ${mismatched.join(", ")}`
|
|
252
|
+
: "consistent"
|
|
253
|
+
: "not measured",
|
|
254
|
+
);
|
|
255
|
+
|
|
178
256
|
const t = isPlainObject(S.tamper) ? S.tamper : {};
|
|
179
|
-
const
|
|
257
|
+
const lies = Array.isArray(S.lies)
|
|
258
|
+
? S.lies.filter((k) => typeof k === "string")
|
|
259
|
+
: [];
|
|
260
|
+
const tampered = [
|
|
261
|
+
...new Set([...Object.keys(t).filter((k) => t[k] === false), ...lies]),
|
|
262
|
+
];
|
|
180
263
|
add(
|
|
181
264
|
"native_tamper",
|
|
182
265
|
tampered.length === 0,
|
|
@@ -189,6 +272,104 @@ export function detectAutomation(S, opts = {}) {
|
|
|
189
272
|
false,
|
|
190
273
|
);
|
|
191
274
|
|
|
275
|
+
const claimsGecko = /Firefox\//.test(ua) && !/Seamonkey/i.test(ua);
|
|
276
|
+
const claimsBlink = /Chrome\/|CriOS\//.test(ua);
|
|
277
|
+
const claimsWebkit = /Safari\//.test(ua) && !claimsBlink;
|
|
278
|
+
const evalLen = isNum(S.evalLen) ? S.evalLen : null;
|
|
279
|
+
const engineLie =
|
|
280
|
+
!mobile &&
|
|
281
|
+
evalLen !== null &&
|
|
282
|
+
((evalLen === 33 && (claimsGecko || claimsWebkit)) ||
|
|
283
|
+
(evalLen === 37 && claimsBlink));
|
|
284
|
+
add(
|
|
285
|
+
"engine_mismatch",
|
|
286
|
+
!engineLie,
|
|
287
|
+
evalLen === null
|
|
288
|
+
? "not measured"
|
|
289
|
+
: engineLie
|
|
290
|
+
? `eval source length ${evalLen} contradicts the user agent`
|
|
291
|
+
: "consistent",
|
|
292
|
+
false,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const at = Array.isArray(S.activeText) ? S.activeText : [];
|
|
296
|
+
const major = Number(/Chrome\/(\d+)/.exec(ua)?.[1] ?? 0);
|
|
297
|
+
const osLie =
|
|
298
|
+
/Windows NT/.test(ua) &&
|
|
299
|
+
major >= 138 &&
|
|
300
|
+
at[0] === "rgb(255, 0, 0)" &&
|
|
301
|
+
at[1] === false;
|
|
302
|
+
add(
|
|
303
|
+
"os_mismatch",
|
|
304
|
+
!osLie,
|
|
305
|
+
osLie ? "claims Windows but renders like Linux or macOS" : "ok",
|
|
306
|
+
false,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const lg = Array.isArray(S.langs) ? S.langs : [];
|
|
310
|
+
const langOdd = lg[0] === 0 || (isNum(lg[0]) && lg[0] > 0 && lg[1] === false);
|
|
311
|
+
add(
|
|
312
|
+
"languages_inconsistent",
|
|
313
|
+
!langOdd,
|
|
314
|
+
langOdd
|
|
315
|
+
? lg[0] === 0
|
|
316
|
+
? "navigator.languages is empty"
|
|
317
|
+
: "languages[0] differs from language"
|
|
318
|
+
: "ok",
|
|
319
|
+
false,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const host = isPlainObject(S.host) ? S.host : null;
|
|
323
|
+
const hn = isPlainObject(host?.nav) ? host.nav : null;
|
|
324
|
+
const frameDiff = [];
|
|
325
|
+
if (hn && mn) {
|
|
326
|
+
const fields = ["platform", "brands"];
|
|
327
|
+
if (mn.brave !== true) fields.push("languages", "hc");
|
|
328
|
+
for (const f of fields) {
|
|
329
|
+
const a = mn[f];
|
|
330
|
+
const b = hn[f];
|
|
331
|
+
if (a === null || a === undefined || b === null || b === undefined)
|
|
332
|
+
continue;
|
|
333
|
+
if (a !== b) frameDiff.push(f);
|
|
334
|
+
}
|
|
335
|
+
if (hn.webdriver === true && S.webdriver !== true)
|
|
336
|
+
frameDiff.push("webdriver");
|
|
337
|
+
}
|
|
338
|
+
add(
|
|
339
|
+
"frame_mismatch",
|
|
340
|
+
frameDiff.length === 0,
|
|
341
|
+
hn
|
|
342
|
+
? frameDiff.length
|
|
343
|
+
? `page and Cap frame differ in: ${frameDiff.join(", ")}`
|
|
344
|
+
: "consistent"
|
|
345
|
+
: "not measured",
|
|
346
|
+
false,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const intercepted = host?.doc === true;
|
|
350
|
+
add(
|
|
351
|
+
"document_intercepted",
|
|
352
|
+
!intercepted,
|
|
353
|
+
intercepted ? "the page was served by an automation tool" : "ok",
|
|
354
|
+
false,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const trap = S.cdp?.trap === true;
|
|
358
|
+
add(
|
|
359
|
+
"cdp_console_trap",
|
|
360
|
+
!trap,
|
|
361
|
+
trap ? "a DevTools client formatted logged errors" : "not attached",
|
|
362
|
+
false,
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const shadowPatched = S.cdp?.shadow === false;
|
|
366
|
+
add(
|
|
367
|
+
"attach_shadow_patched",
|
|
368
|
+
!shadowPatched,
|
|
369
|
+
shadowPatched ? "attachShadow is not native" : "native",
|
|
370
|
+
false,
|
|
371
|
+
);
|
|
372
|
+
|
|
192
373
|
const failed = checks.filter((c) => c.gating && !c.passed);
|
|
193
374
|
return {
|
|
194
375
|
pass: failed.length === 0,
|
package/src/hashwx-wasm.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
// Source: https://github.com/tevador/hashwx @ 74b567a31276a4c5d7cb232a5b7639467f49f961
|
|
3
|
-
// Built with emcc, lowered to WebAssembly 1.0 (MVP).
|
|
4
|
-
// sha256: b1a0dbb3ef444d3c7069e0a5e0a0273ffa4cf8fef62cbbe43761c02f7cd6aff5
|
|
5
|
-
// bytes: 12541
|
|
1
|
+
export const HASHWX_SOURCE_COMMIT = "74b567a31276a4c5d7cb232a5b7639467f49f961";
|
|
6
2
|
|
|
7
3
|
export const HASHWX_WASM_SHA256 =
|
|
8
4
|
"b1a0dbb3ef444d3c7069e0a5e0a0273ffa4cf8fef62cbbe43761c02f7cd6aff5";
|