@magield/collectors 0.0.0-stage → 0.7.1
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/LICENSE +27 -0
- package/dist/behavior/recorder.d.ts +33 -0
- package/dist/behavior/recorder.js +303 -0
- package/dist/hash.d.ts +15 -0
- package/dist/hash.js +27 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/native.d.ts +39 -0
- package/dist/native.js +142 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +108 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.js +128 -0
- package/dist/signals/adblock.d.ts +2 -0
- package/dist/signals/adblock.js +135 -0
- package/dist/signals/apps.d.ts +2 -0
- package/dist/signals/apps.js +191 -0
- package/dist/signals/audio.d.ts +16 -0
- package/dist/signals/audio.js +63 -0
- package/dist/signals/automation.d.ts +2 -0
- package/dist/signals/automation.js +346 -0
- package/dist/signals/behavior.d.ts +17 -0
- package/dist/signals/behavior.js +31 -0
- package/dist/signals/capabilities.d.ts +2 -0
- package/dist/signals/capabilities.js +235 -0
- package/dist/signals/devices.d.ts +2 -0
- package/dist/signals/devices.js +119 -0
- package/dist/signals/display.d.ts +2 -0
- package/dist/signals/display.js +101 -0
- package/dist/signals/errors.d.ts +2 -0
- package/dist/signals/errors.js +84 -0
- package/dist/signals/features.d.ts +2 -0
- package/dist/signals/features.js +951 -0
- package/dist/signals/fonts.d.ts +2 -0
- package/dist/signals/fonts.js +128 -0
- package/dist/signals/integrity.d.ts +2 -0
- package/dist/signals/integrity.js +216 -0
- package/dist/signals/math.d.ts +2 -0
- package/dist/signals/math.js +43 -0
- package/dist/signals/navigator.d.ts +10 -0
- package/dist/signals/navigator.js +81 -0
- package/dist/signals/pointer.d.ts +2 -0
- package/dist/signals/pointer.js +93 -0
- package/dist/signals/render.d.ts +12 -0
- package/dist/signals/render.js +214 -0
- package/dist/signals/screen.d.ts +9 -0
- package/dist/signals/screen.js +80 -0
- package/dist/signals/storage.d.ts +33 -0
- package/dist/signals/storage.js +100 -0
- package/dist/signals/timezone.d.ts +9 -0
- package/dist/signals/timezone.js +63 -0
- package/dist/signals/webrtc.d.ts +2 -0
- package/dist/signals/webrtc.js +168 -0
- package/dist/stable-core.d.ts +128 -0
- package/dist/stable-core.js +187 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +15 -0
- package/package.json +31 -3
- package/README.md +0 -3
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { hash } from '../hash.js';
|
|
2
|
+
import { applyTrapIsObservable, interposesApplyTrap, isNativeSource } from '../native.js';
|
|
3
|
+
/**
|
|
4
|
+
* Traces left by automation frameworks and the plugins that hide them.
|
|
5
|
+
*
|
|
6
|
+
* None of these is conclusive alone and all of them are defeatable — that is
|
|
7
|
+
* assumed, not a flaw. Their value is cumulative and, more importantly,
|
|
8
|
+
* *contradictory*: a stealth plugin that deletes `navigator.webdriver` usually
|
|
9
|
+
* leaves a patched `Function.prototype.toString` behind, and patching that
|
|
10
|
+
* leaves something else. The goal is to make a clean pass expensive.
|
|
11
|
+
*/
|
|
12
|
+
const DRIVER_GLOBALS = [
|
|
13
|
+
'__webdriver_evaluate',
|
|
14
|
+
'__selenium_evaluate',
|
|
15
|
+
'__webdriver_script_function',
|
|
16
|
+
'__webdriver_script_func',
|
|
17
|
+
'__webdriver_script_fn',
|
|
18
|
+
'__fxdriver_evaluate',
|
|
19
|
+
'__driver_unwrapped',
|
|
20
|
+
'__webdriver_unwrapped',
|
|
21
|
+
'__driver_evaluate',
|
|
22
|
+
'__selenium_unwrapped',
|
|
23
|
+
'__fxdriver_unwrapped',
|
|
24
|
+
'_phantom',
|
|
25
|
+
'__nightmare',
|
|
26
|
+
'callPhantom',
|
|
27
|
+
'_selenium',
|
|
28
|
+
'calledSelenium',
|
|
29
|
+
'$cdc_asdjflasutopfhvcZLmcfl_',
|
|
30
|
+
'$chrome_asyncScriptInfo',
|
|
31
|
+
'__$webdriverAsyncExecutor',
|
|
32
|
+
'__lastWatirAlert',
|
|
33
|
+
'__playwright_target__',
|
|
34
|
+
'__pw_manual',
|
|
35
|
+
'__puppeteer_evaluation_script__',
|
|
36
|
+
];
|
|
37
|
+
export const automationCollector = {
|
|
38
|
+
id: 'bot',
|
|
39
|
+
wave: 'instant',
|
|
40
|
+
collect({ window, document, navigator }) {
|
|
41
|
+
const out = {};
|
|
42
|
+
const found = DRIVER_GLOBALS.filter((name) => {
|
|
43
|
+
try {
|
|
44
|
+
return name in window || name in document;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
out['bot.driverGlobals'] = found.length;
|
|
51
|
+
// The names matter for triage: knowing it was Playwright rather than
|
|
52
|
+
// Selenium changes what a tenant does about it.
|
|
53
|
+
out['bot.driverNames'] = found.join(',') || null;
|
|
54
|
+
// Selenium and some drivers mark the document element.
|
|
55
|
+
out['bot.documentAttributes'] = attributeMarkers(document);
|
|
56
|
+
// Chrome exposes window.chrome to every page. Headless Chrome historically
|
|
57
|
+
// did not, and stealth plugins add a hollow stand-in whose shape is wrong.
|
|
58
|
+
const chrome = window.chrome;
|
|
59
|
+
out['bot.hasChromeObject'] = chrome !== undefined;
|
|
60
|
+
out['bot.chromeKeys'] = chrome === undefined ? null : Object.keys(chrome).sort().join(',');
|
|
61
|
+
// Native functions stringify to "[native code]". A patched one does not,
|
|
62
|
+
// which is how a stealth plugin gives itself away while hiding something else.
|
|
63
|
+
//
|
|
64
|
+
// Circular on its own, and deliberately kept anyway. The test below runs
|
|
65
|
+
// `Function.prototype.toString`, which is itself the first thing a serious
|
|
66
|
+
// stealth plugin replaces — after which every probe here reads clean. It
|
|
67
|
+
// still catches the plugins that do not bother, which is most of them.
|
|
68
|
+
// `integrity.ts` is what sees through the ones that do.
|
|
69
|
+
out['bot.patchedNatives'] = countPatched(window, navigator);
|
|
70
|
+
// Where the patch is, rather than whether it stringifies. A replaced getter
|
|
71
|
+
// is invisible above — `countPatched` only looks at functions — and
|
|
72
|
+
// replacing a getter is how `navigator.webdriver` is actually hidden.
|
|
73
|
+
const proto = describeAnomalies(window, navigator);
|
|
74
|
+
out['bot.protoAnomalies'] = proto.length;
|
|
75
|
+
out['bot.protoDetail'] = proto.join(',') || null;
|
|
76
|
+
// How it hides, as an identifier.
|
|
77
|
+
//
|
|
78
|
+
// A tool that conceals itself has to patch a particular set of properties
|
|
79
|
+
// in a particular way, and it does the same thing every session. So the
|
|
80
|
+
// shape of the concealment is a fingerprint of the tool — and unlike a
|
|
81
|
+
// device id, the attacker cannot rotate it without rebuilding their
|
|
82
|
+
// tooling. Rotating device ids per attempt defeats the fanout panel; it
|
|
83
|
+
// does not defeat this.
|
|
84
|
+
//
|
|
85
|
+
// Null when nothing was tampered with, so an ordinary browser contributes
|
|
86
|
+
// no identifier at all rather than a shared "clean" bucket that would be
|
|
87
|
+
// the largest cluster in the system.
|
|
88
|
+
out['bot.spoofHash'] = proto.length === 0 ? null : hash(proto.join(','));
|
|
89
|
+
// A spoofed permissions state and the real Notification state disagree —
|
|
90
|
+
// the classic headless tell that survived years of patching.
|
|
91
|
+
out['bot.notificationPermission'] = read(() => window.Notification?.permission);
|
|
92
|
+
// A `Proxy` around a native, which is the one thing a stealth plugin cannot
|
|
93
|
+
// hide by rewriting what it reports. See PROXY_PROBES for the measurement
|
|
94
|
+
// that made this necessary.
|
|
95
|
+
const proxied = countProxied(window);
|
|
96
|
+
out['bot.proxyProbes'] = proxied.probes;
|
|
97
|
+
out['bot.proxiedNatives'] = proxied.names.length;
|
|
98
|
+
out['bot.proxiedDetail'] = proxied.names.join(',') || null;
|
|
99
|
+
// Languages present but empty is not a configuration a real browser reaches.
|
|
100
|
+
out['bot.emptyLanguages'] = (navigator.languages?.length ?? 1) === 0;
|
|
101
|
+
return out;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
function attributeMarkers(document) {
|
|
105
|
+
try {
|
|
106
|
+
const element = document.documentElement;
|
|
107
|
+
if (element === null)
|
|
108
|
+
return null;
|
|
109
|
+
const markers = element
|
|
110
|
+
.getAttributeNames()
|
|
111
|
+
.filter((name) => name.startsWith('webdriver') || name.startsWith('selenium') || name.startsWith('driver'));
|
|
112
|
+
return markers.length > 0 ? markers.join(',') : null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Properties a stock browser defines as accessors on `Navigator.prototype`.
|
|
120
|
+
*
|
|
121
|
+
* All three are spoofed by automation, and spoofing them leaves a shape the
|
|
122
|
+
* real browser never has. `navigator.webdriver` reading `false` proves nothing
|
|
123
|
+
* on its own — that is what it reads on a real browser too — but `false`
|
|
124
|
+
* arriving from an own data property on the instance means someone wrote it
|
|
125
|
+
* there, because Chrome puts it on the prototype behind a getter.
|
|
126
|
+
*/
|
|
127
|
+
const NAVIGATOR_ACCESSORS = ['webdriver', 'plugins', 'languages'];
|
|
128
|
+
/**
|
|
129
|
+
* Descriptor shapes that disagree with a stock browser.
|
|
130
|
+
*
|
|
131
|
+
* Reads only: `getOwnPropertyDescriptor` and `hasOwnProperty` invoke nothing,
|
|
132
|
+
* so a getter that throws when read cannot be triggered from here.
|
|
133
|
+
*
|
|
134
|
+
* Returns the names rather than a bare count, for the same reason
|
|
135
|
+
* `bot.driverNames` exists — knowing it was `plugins` rather than `webdriver`
|
|
136
|
+
* changes what a tenant concludes, and an extension that spoofs the plugin list
|
|
137
|
+
* is a different animal from one hiding a driver flag.
|
|
138
|
+
*/
|
|
139
|
+
function describeAnomalies(window, navigator) {
|
|
140
|
+
const realm = window;
|
|
141
|
+
const found = [];
|
|
142
|
+
for (const name of NAVIGATOR_ACCESSORS) {
|
|
143
|
+
try {
|
|
144
|
+
// An own property on the instance shadows the prototype's getter. No
|
|
145
|
+
// stock browser ships one, and `defineProperty(navigator, ...)` is the
|
|
146
|
+
// single most common way these values are spoofed.
|
|
147
|
+
if (Object.prototype.hasOwnProperty.call(navigator, name)) {
|
|
148
|
+
found.push(`own:${name}`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const prototype = realm.Navigator?.prototype;
|
|
152
|
+
if (prototype === undefined)
|
|
153
|
+
continue;
|
|
154
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
|
|
155
|
+
if (descriptor === undefined) {
|
|
156
|
+
// Deleted outright. puppeteer-extra's webdriver evasion does exactly
|
|
157
|
+
// this, and it leaves `navigator.webdriver` reading `undefined` rather
|
|
158
|
+
// than the `false` a real browser reports.
|
|
159
|
+
found.push(`missing:${name}`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// Redefined as a value rather than a getter. A real browser has neither
|
|
163
|
+
// a `value` nor a writable data property here.
|
|
164
|
+
if (descriptor.get === undefined)
|
|
165
|
+
found.push(`data:${name}`);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Hardened or exotic environments throw on property access. Unknown is
|
|
169
|
+
// not an anomaly — saying otherwise would accuse a browser for being
|
|
170
|
+
// unusual rather than for lying.
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return found;
|
|
175
|
+
}
|
|
176
|
+
function countPatched(window, navigator) {
|
|
177
|
+
// Reached through the window rather than the ambient globals: a collector
|
|
178
|
+
// must inspect the page's realm, not the one this module was compiled into.
|
|
179
|
+
const realm = window;
|
|
180
|
+
const candidates = [
|
|
181
|
+
realm['Function']?.prototype?.['toString'],
|
|
182
|
+
navigator.permissions?.query,
|
|
183
|
+
navigator.mediaDevices?.enumerateDevices,
|
|
184
|
+
realm['HTMLCanvasElement']?.prototype?.['toDataURL'],
|
|
185
|
+
realm['HTMLCanvasElement']?.prototype?.['getContext'],
|
|
186
|
+
realm['WebGLRenderingContext']?.prototype?.['getParameter'],
|
|
187
|
+
];
|
|
188
|
+
let patched = 0;
|
|
189
|
+
for (const candidate of candidates) {
|
|
190
|
+
if (typeof candidate !== 'function')
|
|
191
|
+
continue;
|
|
192
|
+
try {
|
|
193
|
+
if (!isNativeSource(Function.prototype.toString.call(candidate)))
|
|
194
|
+
patched++;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
patched++;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return patched;
|
|
201
|
+
}
|
|
202
|
+
function read(fn) {
|
|
203
|
+
try {
|
|
204
|
+
const value = fn();
|
|
205
|
+
return value === undefined ? null : value;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Natives wrapped in a `Proxy`, found from the stack instead of by asking.
|
|
213
|
+
*
|
|
214
|
+
* This is the only probe in the package that catches
|
|
215
|
+
* `puppeteer-extra-plugin-stealth`, and it exists because everything else here
|
|
216
|
+
* was measured failing against it.
|
|
217
|
+
*
|
|
218
|
+
* The plugin wraps `WebGLRenderingContext.prototype.getParameter` in a `Proxy`
|
|
219
|
+
* to spoof the GPU strings. Measured against the real plugin on Chrome 141,
|
|
220
|
+
* that wrapper is invisible to every claim-based test — source, name, length,
|
|
221
|
+
* own keys and property descriptor are byte-identical to the untouched
|
|
222
|
+
* function, because V8 hands back a native stub for any callable proxy and the
|
|
223
|
+
* proxy forwards the rest:
|
|
224
|
+
*
|
|
225
|
+
* getParameter, main-realm toString function getParameter() { [native code] }
|
|
226
|
+
* getParameter, iframe-borrowed toString function getParameter() { [native code] }
|
|
227
|
+
* webgl.renderer, real Chrome ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Max)
|
|
228
|
+
* webgl.renderer, through the proxy Intel Iris OpenGL Engine
|
|
229
|
+
*
|
|
230
|
+
* `integrity.ts` does not see it either, and the reason is worth writing down:
|
|
231
|
+
* the plugin installs itself through CDP `addScriptToEvaluateOnNewDocument`,
|
|
232
|
+
* which runs in *every* new document — including the same-origin iframe that
|
|
233
|
+
* collector creates to borrow a clean realm. Measured: the iframe reports the
|
|
234
|
+
* same spoofed renderer as the page. There is no clean realm to borrow.
|
|
235
|
+
*
|
|
236
|
+
* What the plugin cannot reach is the stack, because the engine builds it at
|
|
237
|
+
* throw time from frames that no longer exist. Provoking a `TypeError` through
|
|
238
|
+
* each candidate, measured three ways on the same page:
|
|
239
|
+
*
|
|
240
|
+
* untouched native no trap frame — see TRAP_WINDOW in native.ts for why
|
|
241
|
+
* this is not the same as "no frame at all"
|
|
242
|
+
* plain function a frame named after the function — and its source no
|
|
243
|
+
* longer claims to be native, so `countPatched` has it
|
|
244
|
+
* Proxy a frame naming the `apply` trap, and the source still
|
|
245
|
+
* claims to be native
|
|
246
|
+
*
|
|
247
|
+
* Only the third case is counted, and that conjunction is the whole
|
|
248
|
+
* false-positive argument. A privacy extension that replaces canvas readback
|
|
249
|
+
* installs an ordinary function: it interposes a frame but stops claiming to be
|
|
250
|
+
* native, so it lands in `bot.patchedNatives` where its benign notes already
|
|
251
|
+
* live. Counting something here requires a wrapper that interposes *and* lies
|
|
252
|
+
* about being native, and nothing legitimate needs to do both.
|
|
253
|
+
*
|
|
254
|
+
* **Cost, measured rather than assumed**, because seven thrown exceptions in the
|
|
255
|
+
* instant wave is exactly the kind of thing that turns out to be expensive: 200
|
|
256
|
+
* runs on Chrome 141, median under 0.1 ms and p95 0.2 ms for the whole set. The
|
|
257
|
+
* instant wave budgets 5 ms across seven collectors, so this stays in it.
|
|
258
|
+
*/
|
|
259
|
+
const PROXY_PROBES = [
|
|
260
|
+
{ name: 'Function.toString', path: ['Function', 'prototype', 'toString'] },
|
|
261
|
+
{ name: 'WebGL.getParameter', path: ['WebGLRenderingContext', 'prototype', 'getParameter'] },
|
|
262
|
+
{ name: 'WebGL2.getParameter', path: ['WebGL2RenderingContext', 'prototype', 'getParameter'] },
|
|
263
|
+
{ name: 'Canvas.toDataURL', path: ['HTMLCanvasElement', 'prototype', 'toDataURL'] },
|
|
264
|
+
{ name: 'Canvas.getContext', path: ['HTMLCanvasElement', 'prototype', 'getContext'] },
|
|
265
|
+
{ name: 'Media.canPlayType', path: ['HTMLMediaElement', 'prototype', 'canPlayType'] },
|
|
266
|
+
// Listed and, on Chrome, never reached: `query` rejects a promise rather than
|
|
267
|
+
// throwing on a bad receiver, so the probe cannot provoke it and abstains.
|
|
268
|
+
// That is why a healthy Chrome reports 6 probes from 7 candidates, and it is
|
|
269
|
+
// left in because an engine that does throw here would be measured for free.
|
|
270
|
+
{ name: 'Permissions.query', path: ['Permissions', 'prototype', 'query'] },
|
|
271
|
+
];
|
|
272
|
+
/**
|
|
273
|
+
* Does this engine print proxy traps on the stack at all?
|
|
274
|
+
*
|
|
275
|
+
* A positive control, run on a proxy this function builds itself, so the answer
|
|
276
|
+
* is measured in the visitor's browser rather than assumed from V8's behaviour
|
|
277
|
+
* on the developer's. Firefox and Safari format stacks differently and may not
|
|
278
|
+
* name the trap; if this control fails, the technique does not work here and
|
|
279
|
+
* every probe abstains rather than reporting a clean bill of health nobody
|
|
280
|
+
* earned.
|
|
281
|
+
*
|
|
282
|
+
* The control proxies a function that is never installed anywhere, so no page
|
|
283
|
+
* behaviour changes and nothing else can observe it. `toDataURL` is the subject
|
|
284
|
+
* because it throws on an illegal receiver in every engine that has a canvas.
|
|
285
|
+
*/
|
|
286
|
+
function applyTrapIsVisible(window) {
|
|
287
|
+
const realm = window;
|
|
288
|
+
const native = realm['HTMLCanvasElement']?.prototype?.['toDataURL'];
|
|
289
|
+
if (typeof native !== 'function')
|
|
290
|
+
return false;
|
|
291
|
+
return applyTrapIsObservable(native);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The probe, as counts rather than a verdict.
|
|
295
|
+
*
|
|
296
|
+
* Reports how many candidates could be tested alongside how many were wrapped,
|
|
297
|
+
* following `bot.realmProbes` — a consistency check cannot tell "nothing is
|
|
298
|
+
* wrapped" from "nothing could be measured" unless the collector says which.
|
|
299
|
+
*/
|
|
300
|
+
function countProxied(window) {
|
|
301
|
+
if (!applyTrapIsVisible(window))
|
|
302
|
+
return { probes: 0, names: [] };
|
|
303
|
+
const names = [];
|
|
304
|
+
let probes = 0;
|
|
305
|
+
for (const probe of PROXY_PROBES) {
|
|
306
|
+
const target = resolvePath(window, probe.path);
|
|
307
|
+
if (typeof target !== 'function')
|
|
308
|
+
continue;
|
|
309
|
+
let source;
|
|
310
|
+
try {
|
|
311
|
+
source = Function.prototype.toString.call(target);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// A target that will not stringify is already an anomaly, and one that
|
|
315
|
+
// `countPatched` records. Not this probe's finding.
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// The conjunction. A wrapper that admits to being a wrapper belongs to
|
|
319
|
+
// `bot.patchedNatives`, whose benign notes cover privacy extensions; this
|
|
320
|
+
// probe is only for the ones whose source still claims to be native.
|
|
321
|
+
if (!isNativeSource(source))
|
|
322
|
+
continue;
|
|
323
|
+
const interposed = interposesApplyTrap(target);
|
|
324
|
+
if (interposed === undefined)
|
|
325
|
+
continue;
|
|
326
|
+
probes++;
|
|
327
|
+
if (interposed)
|
|
328
|
+
names.push(probe.name);
|
|
329
|
+
}
|
|
330
|
+
return { probes, names };
|
|
331
|
+
}
|
|
332
|
+
/** Walks a dotted path without invoking anything, as integrity.ts does. */
|
|
333
|
+
function resolvePath(window, path) {
|
|
334
|
+
let current = window;
|
|
335
|
+
for (const key of path) {
|
|
336
|
+
if (current === null || current === undefined)
|
|
337
|
+
return undefined;
|
|
338
|
+
try {
|
|
339
|
+
current = current[key];
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return current;
|
|
346
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BehaviorRecorder } from '../behavior/recorder.js';
|
|
2
|
+
import type { Collector } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reads whatever the recorder has accumulated.
|
|
5
|
+
*
|
|
6
|
+
* A collector rather than a direct call so behavioural signals travel the same
|
|
7
|
+
* path as every other signal — same wave budget, same disable switch, same
|
|
8
|
+
* skipped-and-reported handling when something throws.
|
|
9
|
+
*
|
|
10
|
+
* `instant`, because reading folded statistics is a few field accesses. The
|
|
11
|
+
* work happened as the user moved and typed; none of it is on the submit path.
|
|
12
|
+
*
|
|
13
|
+
* Optional, because an integrator who never calls `observe()` is not broken —
|
|
14
|
+
* they get no behavioural evidence, which the engine treats as unobserved
|
|
15
|
+
* rather than suspicious.
|
|
16
|
+
*/
|
|
17
|
+
export declare function behaviorCollector(recorder: () => BehaviorRecorder | undefined): Collector;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads whatever the recorder has accumulated.
|
|
3
|
+
*
|
|
4
|
+
* A collector rather than a direct call so behavioural signals travel the same
|
|
5
|
+
* path as every other signal — same wave budget, same disable switch, same
|
|
6
|
+
* skipped-and-reported handling when something throws.
|
|
7
|
+
*
|
|
8
|
+
* `instant`, because reading folded statistics is a few field accesses. The
|
|
9
|
+
* work happened as the user moved and typed; none of it is on the submit path.
|
|
10
|
+
*
|
|
11
|
+
* Optional, because an integrator who never calls `observe()` is not broken —
|
|
12
|
+
* they get no behavioural evidence, which the engine treats as unobserved
|
|
13
|
+
* rather than suspicious.
|
|
14
|
+
*/
|
|
15
|
+
export function behaviorCollector(recorder) {
|
|
16
|
+
return {
|
|
17
|
+
id: 'behavior',
|
|
18
|
+
wave: 'instant',
|
|
19
|
+
optional: true,
|
|
20
|
+
collect() {
|
|
21
|
+
const active = recorder();
|
|
22
|
+
// Nothing observing is not the same as observing nothing. The first is
|
|
23
|
+
// an integration that did not opt in; the second is a form submitted
|
|
24
|
+
// without a single input event, which is worth knowing about. Emitting
|
|
25
|
+
// no keys at all keeps them distinguishable downstream.
|
|
26
|
+
if (active === undefined)
|
|
27
|
+
return {};
|
|
28
|
+
return { ...active.summary() };
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { hash } from '../hash.js';
|
|
2
|
+
/**
|
|
3
|
+
* What the platform will admit to when asked directly.
|
|
4
|
+
*
|
|
5
|
+
* Five probes that share a property: each is an API that answers about the
|
|
6
|
+
* machine or its configuration rather than about the page, and each is one an
|
|
7
|
+
* anti-detect browser has to remember to fake separately. A profile that
|
|
8
|
+
* rewrites its user agent, its GPU string and its core count will usually
|
|
9
|
+
* still report the voice list of the machine it is actually running on.
|
|
10
|
+
*
|
|
11
|
+
* None of them prompts. Geolocation and camera access are deliberately absent
|
|
12
|
+
* from this file: both require permission, both are visible to the user, and a
|
|
13
|
+
* login page that asks is a login page people abandon.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Permissions worth asking the *state* of.
|
|
17
|
+
*
|
|
18
|
+
* `permissions.query` returns granted / denied / prompt without prompting, and
|
|
19
|
+
* the pattern across them is a configuration fingerprint: a fresh automation
|
|
20
|
+
* profile answers `prompt` to everything, where a machine somebody actually
|
|
21
|
+
* uses has a scatter of granted and denied.
|
|
22
|
+
*
|
|
23
|
+
* The list is the subset Chromium answers for. Querying a name an engine does
|
|
24
|
+
* not know throws, which is caught and recorded as unsupported — itself a
|
|
25
|
+
* difference between engines.
|
|
26
|
+
*/
|
|
27
|
+
const PERMISSIONS = [
|
|
28
|
+
'accelerometer', 'background-sync', 'camera', 'clipboard-read', 'clipboard-write',
|
|
29
|
+
'geolocation', 'gyroscope', 'idle-detection', 'local-fonts', 'magnetometer',
|
|
30
|
+
'microphone', 'midi', 'notifications', 'payment-handler', 'persistent-storage',
|
|
31
|
+
'push', 'screen-wake-lock', 'storage-access', 'window-management',
|
|
32
|
+
];
|
|
33
|
+
export const capabilitiesCollector = {
|
|
34
|
+
id: 'cap',
|
|
35
|
+
wave: 'deep',
|
|
36
|
+
optional: true,
|
|
37
|
+
async collect({ navigator, window }) {
|
|
38
|
+
const out = {};
|
|
39
|
+
// ── User-agent client hints, actually read ───────────────────────────
|
|
40
|
+
// The navigator surface reports whether this API exists and has never
|
|
41
|
+
// called it. Presence is one bit; the values are the platform version,
|
|
42
|
+
// the architecture, the bitness and the device model, and they are what a
|
|
43
|
+
// user agent string no longer carries since it was frozen.
|
|
44
|
+
try {
|
|
45
|
+
const uaData = navigator.userAgentData;
|
|
46
|
+
if (uaData?.getHighEntropyValues === undefined) {
|
|
47
|
+
out['cap.uaPlatformVersion'] = null;
|
|
48
|
+
out['cap.uaArchitecture'] = null;
|
|
49
|
+
out['cap.uaModel'] = null;
|
|
50
|
+
out['cap.uaBitness'] = null;
|
|
51
|
+
out['cap.uaFullVersion'] = null;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const values = await uaData.getHighEntropyValues([
|
|
55
|
+
'platformVersion', 'architecture', 'model', 'bitness', 'uaFullVersion', 'fullVersionList', 'wow64',
|
|
56
|
+
]);
|
|
57
|
+
out['cap.uaPlatformVersion'] = asString(values['platformVersion']);
|
|
58
|
+
out['cap.uaArchitecture'] = asString(values['architecture']);
|
|
59
|
+
out['cap.uaModel'] = asString(values['model']);
|
|
60
|
+
out['cap.uaBitness'] = asString(values['bitness']);
|
|
61
|
+
out['cap.uaFullVersion'] = asString(values['uaFullVersion']);
|
|
62
|
+
out['cap.uaWow64'] = typeof values['wow64'] === 'boolean' ? values['wow64'] : null;
|
|
63
|
+
// The brand list is ordered and padded with a deliberately absurd
|
|
64
|
+
// entry by design, so its shape carries more than its contents.
|
|
65
|
+
const brands = uaData.brands ?? [];
|
|
66
|
+
out['cap.uaBrands'] = brands.map((b) => `${b.brand}/${b.version}`).sort().join(',');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
out['cap.uaPlatformVersion'] = null;
|
|
71
|
+
}
|
|
72
|
+
// ── Speech synthesis voices ──────────────────────────────────────────
|
|
73
|
+
// A strong OS and locale signal: the set is installed by the operating
|
|
74
|
+
// system, differs between macOS, Windows and every Linux distribution,
|
|
75
|
+
// and grows with the languages a person has added.
|
|
76
|
+
try {
|
|
77
|
+
const synth = window.speechSynthesis;
|
|
78
|
+
if (synth === undefined) {
|
|
79
|
+
out['cap.voices'] = null;
|
|
80
|
+
out['cap.voicesHash'] = null;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Populated asynchronously, and the wait is not optional: a real
|
|
84
|
+
// Firefox reported zero voices through a flat 120 ms sleep where
|
|
85
|
+
// Chrome reported 199 through the same one. `voiceschanged` is the
|
|
86
|
+
// event that says when the list is actually ready, raced against a
|
|
87
|
+
// deadline so an engine that never fires it cannot hold up the wave.
|
|
88
|
+
let voices = synth.getVoices();
|
|
89
|
+
if (voices.length === 0) {
|
|
90
|
+
await new Promise((resolve) => {
|
|
91
|
+
let settled = false;
|
|
92
|
+
const done = () => {
|
|
93
|
+
if (settled)
|
|
94
|
+
return;
|
|
95
|
+
settled = true;
|
|
96
|
+
try {
|
|
97
|
+
synth.removeEventListener('voiceschanged', done);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Older engines expose only the onvoiceschanged property.
|
|
101
|
+
}
|
|
102
|
+
resolve();
|
|
103
|
+
};
|
|
104
|
+
try {
|
|
105
|
+
synth.addEventListener('voiceschanged', done, { once: true });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Nothing to listen to; the deadline below still applies.
|
|
109
|
+
}
|
|
110
|
+
setTimeout(done, 400);
|
|
111
|
+
});
|
|
112
|
+
voices = synth.getVoices();
|
|
113
|
+
}
|
|
114
|
+
out['cap.voices'] = voices.length;
|
|
115
|
+
// An empty list hashes to the FNV offset basis, which reads like a
|
|
116
|
+
// value and is not one. Firefox produced exactly that before the wait
|
|
117
|
+
// above was fixed, so the empty case reports null.
|
|
118
|
+
out['cap.voicesHash'] =
|
|
119
|
+
voices.length === 0
|
|
120
|
+
? null
|
|
121
|
+
: hash(voices.map((v) => `${v.name}|${v.lang}|${v.localService}`).sort().join(','));
|
|
122
|
+
out['cap.voiceDefault'] = voices.find((v) => v.default)?.lang ?? null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
out['cap.voices'] = null;
|
|
127
|
+
}
|
|
128
|
+
// ── WebGPU adapter ───────────────────────────────────────────────────
|
|
129
|
+
// A second, independent account of the GPU. It matters because it comes
|
|
130
|
+
// from a different subsystem than WebGL: a profile that rewrites the
|
|
131
|
+
// ANGLE renderer string has to rewrite this one too, and the two
|
|
132
|
+
// disagreeing is a contradiction nothing benign produces.
|
|
133
|
+
try {
|
|
134
|
+
const gpu = navigator.gpu;
|
|
135
|
+
if (gpu?.requestAdapter === undefined) {
|
|
136
|
+
out['cap.gpuVendor'] = null;
|
|
137
|
+
out['cap.gpuArchitecture'] = null;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const adapter = (await gpu.requestAdapter());
|
|
141
|
+
if (adapter === null) {
|
|
142
|
+
// Present but refusing: a real answer, and common in containers.
|
|
143
|
+
out['cap.gpuVendor'] = null;
|
|
144
|
+
out['cap.gpuArchitecture'] = null;
|
|
145
|
+
out['cap.gpuAdapter'] = 'unavailable';
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
out['cap.gpuVendor'] = adapter.info?.vendor ?? null;
|
|
149
|
+
out['cap.gpuArchitecture'] = adapter.info?.architecture ?? null;
|
|
150
|
+
// The limits are the driver's own numbers — the WebGPU equivalent of
|
|
151
|
+
// `webgl.parametersHash`, and the measurement that a renderer string
|
|
152
|
+
// rewritten by hand does not move.
|
|
153
|
+
//
|
|
154
|
+
// `GPUSupportedLimits` exposes them as prototype getters, so
|
|
155
|
+
// `Object.keys` returns nothing and an obvious implementation
|
|
156
|
+
// silently reports an empty hash. Measured on a real Chrome:
|
|
157
|
+
// 0 own keys, 36 enumerable through the chain.
|
|
158
|
+
const limits = adapter.limits;
|
|
159
|
+
const entries = [];
|
|
160
|
+
if (limits !== undefined && limits !== null) {
|
|
161
|
+
for (const key in limits) {
|
|
162
|
+
const value = limits[key];
|
|
163
|
+
if (typeof value === 'number')
|
|
164
|
+
entries.push(`${key}=${value}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
entries.sort();
|
|
168
|
+
out['cap.gpuLimits'] = entries.length;
|
|
169
|
+
out['cap.gpuLimitsHash'] = entries.length === 0 ? null : hash(entries.join('|'));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
out['cap.gpuVendor'] = null;
|
|
175
|
+
}
|
|
176
|
+
// ── Battery ──────────────────────────────────────────────────────────
|
|
177
|
+
// Presence separates desktops from laptops and both from most automation
|
|
178
|
+
// images. The level is deliberately rounded: it is enough to tell a
|
|
179
|
+
// plugged-in server from a phone at 12%, and not enough to follow anyone
|
|
180
|
+
// between sites as it drains.
|
|
181
|
+
try {
|
|
182
|
+
const getBattery = navigator.getBattery;
|
|
183
|
+
if (getBattery === undefined) {
|
|
184
|
+
out['cap.batteryPresent'] = false;
|
|
185
|
+
out['cap.batteryCharging'] = null;
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const battery = await getBattery.call(navigator);
|
|
189
|
+
out['cap.batteryPresent'] = true;
|
|
190
|
+
out['cap.batteryCharging'] = battery.charging;
|
|
191
|
+
out['cap.batteryLevel'] = Math.round(battery.level * 10) / 10;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
out['cap.batteryPresent'] = null;
|
|
196
|
+
}
|
|
197
|
+
// ── Permission states ────────────────────────────────────────────────
|
|
198
|
+
try {
|
|
199
|
+
const permissions = navigator.permissions;
|
|
200
|
+
if (permissions?.query === undefined) {
|
|
201
|
+
out['cap.permHash'] = null;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const states = [];
|
|
205
|
+
let granted = 0;
|
|
206
|
+
let denied = 0;
|
|
207
|
+
for (const name of PERMISSIONS) {
|
|
208
|
+
try {
|
|
209
|
+
const status = await permissions.query({ name });
|
|
210
|
+
states.push(`${name}=${status.state}`);
|
|
211
|
+
if (status.state === 'granted')
|
|
212
|
+
granted++;
|
|
213
|
+
else if (status.state === 'denied')
|
|
214
|
+
denied++;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// An engine that does not know the name. Recorded, because which
|
|
218
|
+
// names an engine knows differs as much as what it answers.
|
|
219
|
+
states.push(`${name}=unsupported`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
out['cap.permHash'] = hash(states.join('|'));
|
|
223
|
+
out['cap.permGranted'] = granted;
|
|
224
|
+
out['cap.permDenied'] = denied;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
out['cap.permHash'] = null;
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
function asString(value) {
|
|
234
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
235
|
+
}
|