@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.
Files changed (59) hide show
  1. package/LICENSE +27 -0
  2. package/dist/behavior/recorder.d.ts +33 -0
  3. package/dist/behavior/recorder.js +303 -0
  4. package/dist/hash.d.ts +15 -0
  5. package/dist/hash.js +27 -0
  6. package/dist/index.d.ts +7 -0
  7. package/dist/index.js +7 -0
  8. package/dist/native.d.ts +39 -0
  9. package/dist/native.js +142 -0
  10. package/dist/registry.d.ts +57 -0
  11. package/dist/registry.js +108 -0
  12. package/dist/run.d.ts +10 -0
  13. package/dist/run.js +128 -0
  14. package/dist/signals/adblock.d.ts +2 -0
  15. package/dist/signals/adblock.js +135 -0
  16. package/dist/signals/apps.d.ts +2 -0
  17. package/dist/signals/apps.js +191 -0
  18. package/dist/signals/audio.d.ts +16 -0
  19. package/dist/signals/audio.js +63 -0
  20. package/dist/signals/automation.d.ts +2 -0
  21. package/dist/signals/automation.js +346 -0
  22. package/dist/signals/behavior.d.ts +17 -0
  23. package/dist/signals/behavior.js +31 -0
  24. package/dist/signals/capabilities.d.ts +2 -0
  25. package/dist/signals/capabilities.js +235 -0
  26. package/dist/signals/devices.d.ts +2 -0
  27. package/dist/signals/devices.js +119 -0
  28. package/dist/signals/display.d.ts +2 -0
  29. package/dist/signals/display.js +101 -0
  30. package/dist/signals/errors.d.ts +2 -0
  31. package/dist/signals/errors.js +84 -0
  32. package/dist/signals/features.d.ts +2 -0
  33. package/dist/signals/features.js +951 -0
  34. package/dist/signals/fonts.d.ts +2 -0
  35. package/dist/signals/fonts.js +128 -0
  36. package/dist/signals/integrity.d.ts +2 -0
  37. package/dist/signals/integrity.js +216 -0
  38. package/dist/signals/math.d.ts +2 -0
  39. package/dist/signals/math.js +43 -0
  40. package/dist/signals/navigator.d.ts +10 -0
  41. package/dist/signals/navigator.js +81 -0
  42. package/dist/signals/pointer.d.ts +2 -0
  43. package/dist/signals/pointer.js +93 -0
  44. package/dist/signals/render.d.ts +12 -0
  45. package/dist/signals/render.js +214 -0
  46. package/dist/signals/screen.d.ts +9 -0
  47. package/dist/signals/screen.js +80 -0
  48. package/dist/signals/storage.d.ts +33 -0
  49. package/dist/signals/storage.js +100 -0
  50. package/dist/signals/timezone.d.ts +9 -0
  51. package/dist/signals/timezone.js +63 -0
  52. package/dist/signals/webrtc.d.ts +2 -0
  53. package/dist/signals/webrtc.js +168 -0
  54. package/dist/stable-core.d.ts +128 -0
  55. package/dist/stable-core.js +187 -0
  56. package/dist/types.d.ts +54 -0
  57. package/dist/types.js +15 -0
  58. package/package.json +31 -3
  59. 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,2 @@
1
+ import type { Collector } from '../types.js';
2
+ export declare const capabilitiesCollector: Collector;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ import type { Collector } from '../types.js';
2
+ export declare const devicesCollector: Collector;