@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,119 @@
1
+ import { hash } from '../hash.js';
2
+ /**
3
+ * What is plugged in, what can be played, and how much room there is.
4
+ *
5
+ * Three probes that share a theme: each asks the platform about itself rather
6
+ * than asking the page, so an anti-detect browser has to fake a subsystem
7
+ * rather than a string.
8
+ *
9
+ * Media devices — counts only. `enumerateDevices` returns empty labels and
10
+ * empty ids without permission, and this deliberately never asks for it. The
11
+ * count is what carries the signal: a real laptop has a camera, a microphone
12
+ * and speakers, a headless container has nothing, and a datacentre VM
13
+ * usually has a lone dummy sink.
14
+ *
15
+ * DRM — which content-protection systems are available. Widevine is present
16
+ * on real consumer installs and absent from most automation images and
17
+ * stripped builds.
18
+ *
19
+ * Storage quota — the classic private-mode tell. Browsers report a smaller
20
+ * quota in a private window, so an implausibly small one is a strong hint
21
+ * without any API existing to ask the question directly.
22
+ *
23
+ * No permission prompt is triggered by any of them.
24
+ */
25
+ const KEY_SYSTEMS = [
26
+ 'com.widevine.alpha',
27
+ 'com.microsoft.playready',
28
+ 'com.apple.fps',
29
+ 'org.w3.clearkey',
30
+ ];
31
+ const VIDEO_CONFIG = [
32
+ {
33
+ initDataTypes: ['cenc'],
34
+ videoCapabilities: [{ contentType: 'video/mp4;codecs="avc1.42E01E"' }],
35
+ },
36
+ ];
37
+ export const devicesCollector = {
38
+ id: 'devices',
39
+ wave: 'deep',
40
+ optional: true,
41
+ async collect({ navigator }) {
42
+ const out = {};
43
+ // ── Media devices ────────────────────────────────────────────────────
44
+ try {
45
+ const media = navigator.mediaDevices;
46
+ if (media === undefined || typeof media.enumerateDevices !== 'function') {
47
+ out['devices.audioIn'] = null;
48
+ out['devices.audioOut'] = null;
49
+ out['devices.videoIn'] = null;
50
+ }
51
+ else {
52
+ const list = await media.enumerateDevices();
53
+ let audioIn = 0;
54
+ let audioOut = 0;
55
+ let videoIn = 0;
56
+ for (const device of list) {
57
+ if (device.kind === 'audioinput')
58
+ audioIn++;
59
+ else if (device.kind === 'audiooutput')
60
+ audioOut++;
61
+ else if (device.kind === 'videoinput')
62
+ videoIn++;
63
+ }
64
+ out['devices.audioIn'] = audioIn;
65
+ out['devices.audioOut'] = audioOut;
66
+ out['devices.videoIn'] = videoIn;
67
+ // `groupId` is deliberately not reported. It reads empty on every
68
+ // device without microphone permission — measured on a real Chrome
69
+ // with three devices attached, zero non-empty ids — so it would be a
70
+ // signal that is always the same number, which is worse than no signal
71
+ // because it looks like one.
72
+ }
73
+ }
74
+ catch {
75
+ out['devices.audioIn'] = null;
76
+ out['devices.audioOut'] = null;
77
+ out['devices.videoIn'] = null;
78
+ }
79
+ // ── DRM ──────────────────────────────────────────────────────────────
80
+ try {
81
+ const request = navigator.requestMediaKeySystemAccess?.bind(navigator);
82
+ if (request === undefined) {
83
+ out['devices.drm'] = null;
84
+ }
85
+ else {
86
+ const supported = [];
87
+ await Promise.all(KEY_SYSTEMS.map(async (system) => {
88
+ try {
89
+ await request(system, VIDEO_CONFIG);
90
+ supported.push(system);
91
+ }
92
+ catch {
93
+ // Unsupported is the ordinary answer, not an error.
94
+ }
95
+ }));
96
+ out['devices.drm'] = supported.sort().join(',');
97
+ out['devices.drmHash'] = hash(supported.sort().join(','));
98
+ }
99
+ }
100
+ catch {
101
+ out['devices.drm'] = null;
102
+ }
103
+ // ── Storage quota ────────────────────────────────────────────────────
104
+ try {
105
+ const estimate = navigator.storage?.estimate?.bind(navigator.storage);
106
+ if (estimate === undefined) {
107
+ out['devices.quotaMb'] = null;
108
+ }
109
+ else {
110
+ const { quota } = await estimate();
111
+ out['devices.quotaMb'] = quota === undefined ? null : Math.round(quota / (1024 * 1024));
112
+ }
113
+ }
114
+ catch {
115
+ out['devices.quotaMb'] = null;
116
+ }
117
+ return out;
118
+ },
119
+ };
@@ -0,0 +1,2 @@
1
+ import type { Collector } from '../types.js';
2
+ export declare const displayCollector: Collector;
@@ -0,0 +1,101 @@
1
+ import { hash } from '../hash.js';
2
+ /**
3
+ * How the operating system paints, and what it says about the person using it.
4
+ *
5
+ * CSS system colours resolve to the platform's own theme, so the set separates
6
+ * Windows from macOS from a Linux desktop, light from dark, and — through the
7
+ * forced-colours and contrast queries — identifies an accessibility
8
+ * configuration.
9
+ *
10
+ * That last part is the reason for the split below. A high-contrast or
11
+ * reduced-motion setting is a strong, stable signal and it is also a statement
12
+ * about a person's eyesight or vestibular health. It is collected because an
13
+ * anti-detect browser will not think to fake it, and it is reported as its own
14
+ * signal rather than folded into a hash so that a tenant can see what it is
15
+ * acting on, and switch this collector off without losing the rest.
16
+ */
17
+ /**
18
+ * The system colours still resolved by current engines.
19
+ *
20
+ * Most of CSS2's list was deprecated and now returns the same value
21
+ * everywhere, which is noise. These are the ones that still vary.
22
+ */
23
+ const SYSTEM_COLORS = [
24
+ 'ActiveText', 'ButtonBorder', 'ButtonFace', 'ButtonText', 'Canvas', 'CanvasText',
25
+ 'Field', 'FieldText', 'GrayText', 'Highlight', 'HighlightText', 'LinkText',
26
+ 'Mark', 'MarkText', 'SelectedItem', 'SelectedItemText', 'VisitedText',
27
+ 'AccentColor', 'AccentColorText',
28
+ ];
29
+ /** Queries that describe the configuration rather than the hardware. */
30
+ const MEDIA_QUERIES = {
31
+ 'display.colorScheme': '(prefers-color-scheme: dark)',
32
+ 'display.reducedMotion': '(prefers-reduced-motion: reduce)',
33
+ 'display.reducedTransparency': '(prefers-reduced-transparency: reduce)',
34
+ 'display.forcedColors': '(forced-colors: active)',
35
+ 'display.invertedColors': '(inverted-colors: inverted)',
36
+ 'display.hdr': '(dynamic-range: high)',
37
+ 'display.hover': '(hover: hover)',
38
+ 'display.anyPointerCoarse': '(any-pointer: coarse)',
39
+ 'display.standalone': '(display-mode: standalone)',
40
+ };
41
+ export const displayCollector = {
42
+ id: 'display',
43
+ wave: 'warm',
44
+ optional: true,
45
+ collect({ document, window }) {
46
+ const out = {};
47
+ try {
48
+ const probe = document.createElement('div');
49
+ probe.setAttribute('style', 'position:absolute;left:-9999px;top:-9999px;visibility:hidden');
50
+ document.body.appendChild(probe);
51
+ const resolved = [];
52
+ let distinct = 0;
53
+ const seen = new Set();
54
+ for (const name of SYSTEM_COLORS) {
55
+ try {
56
+ probe.style.color = '';
57
+ probe.style.color = name;
58
+ // A colour the engine does not know leaves the property empty, which
59
+ // is itself worth recording — the set of *recognised* names differs
60
+ // between engines as much as the values do.
61
+ const value = probe.style.color === '' ? '' : window.getComputedStyle(probe).color;
62
+ resolved.push(`${name}=${value}`);
63
+ if (value !== '')
64
+ seen.add(value);
65
+ }
66
+ catch {
67
+ resolved.push(`${name}=?`);
68
+ }
69
+ }
70
+ distinct = seen.size;
71
+ try {
72
+ if (probe.parentNode !== null)
73
+ probe.parentNode.removeChild(probe);
74
+ }
75
+ catch {
76
+ // Nothing to do; the node is offscreen and hidden either way.
77
+ }
78
+ out['display.colorsHash'] = hash(resolved.join('|'));
79
+ // A theme that collapses to one or two distinct colours is not a theme.
80
+ // It is forced colours, a stripped engine, or a headless default.
81
+ out['display.colorsDistinct'] = distinct;
82
+ }
83
+ catch {
84
+ out['display.colorsHash'] = null;
85
+ out['display.colorsDistinct'] = null;
86
+ }
87
+ for (const [key, query] of Object.entries(MEDIA_QUERIES)) {
88
+ try {
89
+ // `matches` is false both when the condition does not hold and when the
90
+ // engine does not know the query, so `media` is checked: an
91
+ // unrecognised query is normalised to 'not all'.
92
+ const result = window.matchMedia(query);
93
+ out[key] = result.media === 'not all' ? null : result.matches;
94
+ }
95
+ catch {
96
+ out[key] = null;
97
+ }
98
+ }
99
+ return out;
100
+ },
101
+ };
@@ -0,0 +1,2 @@
1
+ import type { Collector } from '../types.js';
2
+ export declare const errorsCollector: Collector;
@@ -0,0 +1,84 @@
1
+ import { hash } from '../hash.js';
2
+ /**
3
+ * The shape of a stack trace.
4
+ *
5
+ * Every engine formats stacks differently, and the details shift between
6
+ * versions: the header line, whether column numbers appear, how anonymous
7
+ * functions are named. It corroborates the engine claimed by the user agent,
8
+ * and instrumented environments perturb it in ways that are hard to predict.
9
+ */
10
+ /**
11
+ * Which engine is actually running, read from how it words one mistake.
12
+ *
13
+ * This exists because the engine cannot be inferred from the APIs it exposes.
14
+ * `Error.captureStackTrace` and `Error.stackTraceLimit` look V8-specific and
15
+ * are not — JavaScriptCore ships both, and a check built on that premise fired
16
+ * on every real iPhone before a capture from one disproved it.
17
+ *
18
+ * The message text does separate them, because it is prose each engine wrote
19
+ * independently rather than an API anyone had reason to copy:
20
+ *
21
+ * V8 Cannot read properties of null (reading 'x')
22
+ * JSC null is not an object (evaluating 'null.x')
23
+ *
24
+ * All three measured — V8 in Node and in six desktop captures, JavaScriptCore
25
+ * in macOS JavaScriptCore and in a capture from a real iPhone, SpiderMonkey in
26
+ * a capture from a real Firefox. Each hash matches what its capture reported.
27
+ *
28
+ * SM can't access property "x" of null
29
+ *
30
+ * SpiderMonkey was left unclassified until a capture from a real Firefox
31
+ * arrived, and the restraint paid: the guess would have been "null has no
32
+ * properties", which hashes to 6a0162a0 and is not what Firefox 150 reports.
33
+ * The measured string hashes to c5f95f17, exactly what the capture carries.
34
+ *
35
+ * Anything else stays `unknown`, and every check reading this stays
36
+ * inconclusive for it — the honest answer to a question nobody has measured.
37
+ */
38
+ function classifyEngine(message) {
39
+ if (message.includes('Cannot read properties of'))
40
+ return 'v8';
41
+ if (message.includes('is not an object'))
42
+ return 'javascriptcore';
43
+ if (message.includes('access property'))
44
+ return 'spidermonkey';
45
+ return 'unknown';
46
+ }
47
+ export const errorsCollector = {
48
+ id: 'err',
49
+ wave: 'instant',
50
+ collect() {
51
+ const out = {};
52
+ try {
53
+ const error = new Error('probe');
54
+ const stack = error.stack ?? '';
55
+ out['err.hasStack'] = stack.length > 0;
56
+ // The first line: "Error: probe" in V8, "probe@..." in SpiderMonkey.
57
+ out['err.headerShape'] = hash(stack.split('\n')[0]?.replace(/[^\w:@]/g, '') ?? '');
58
+ out['err.frameCount'] = stack.split('\n').length;
59
+ out['err.hasColumns'] = /:\d+:\d+/.test(stack);
60
+ // NOT V8 only, whatever this comment used to say. JavaScriptCore
61
+ // implements both for V8 compatibility — measured on a real iPhone
62
+ // running Chrome for iOS, and again against macOS JavaScriptCore
63
+ // directly. They are collected because instrumented environments perturb
64
+ // them, not because their presence names an engine.
65
+ out['err.hasCaptureStackTrace'] = typeof Error.captureStackTrace === 'function';
66
+ out['err.stackTraceLimit'] = typeof Error.stackTraceLimit === 'number';
67
+ }
68
+ catch {
69
+ out['err.hasStack'] = false;
70
+ }
71
+ try {
72
+ // Engines disagree on the message text for the same mistake.
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ ;
75
+ null.x;
76
+ }
77
+ catch (err) {
78
+ const message = err.message;
79
+ out['err.nullAccessMessage'] = hash(message);
80
+ out['err.engine'] = classifyEngine(message);
81
+ }
82
+ return out;
83
+ },
84
+ };
@@ -0,0 +1,2 @@
1
+ import type { Collector } from '../types.js';
2
+ export declare const featuresCollector: Collector;