@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,108 @@
1
+ import { adblockCollector } from './signals/adblock.js';
2
+ import { appsCollector } from './signals/apps.js';
3
+ import { audioCollector } from './signals/audio.js';
4
+ import { capabilitiesCollector } from './signals/capabilities.js';
5
+ import { automationCollector } from './signals/automation.js';
6
+ import { devicesCollector } from './signals/devices.js';
7
+ import { displayCollector } from './signals/display.js';
8
+ import { fontsCollector } from './signals/fonts.js';
9
+ import { storageCollector } from './signals/storage.js';
10
+ import { webrtcCollector } from './signals/webrtc.js';
11
+ import { errorsCollector } from './signals/errors.js';
12
+ import { featuresCollector } from './signals/features.js';
13
+ import { integrityCollector } from './signals/integrity.js';
14
+ import { mathCollector } from './signals/math.js';
15
+ import { navigatorCollector } from './signals/navigator.js';
16
+ import { pointerCollector } from './signals/pointer.js';
17
+ import { canvasCollector, webglCollector } from './signals/render.js';
18
+ import { screenCollector } from './signals/screen.js';
19
+ import { timezoneCollector } from './signals/timezone.js';
20
+ /**
21
+ * Order within a wave is the order they run, so the cheapest and most
22
+ * universally available come first: when a budget runs out, what survives
23
+ * should be the signals present on every device rather than the exotic ones.
24
+ */
25
+ export const ALL_COLLECTORS = [
26
+ // instant
27
+ // Automation first. When a slow device runs out of budget, what is lost
28
+ // must be the ordinary fingerprint, never the detection: the published SDK
29
+ // ran this sixth, behind a 5 ms budget its obfuscated build could not meet,
30
+ // and shipped without bot detection for every integrator until 0.7.0.
31
+ automationCollector,
32
+ navigatorCollector,
33
+ screenCollector,
34
+ featuresCollector,
35
+ pointerCollector,
36
+ mathCollector,
37
+ errorsCollector,
38
+ // Last in the wave: the slowest instant collector measured (`Intl` calls,
39
+ // ~7 ms on an Apple M2 before obfuscation was removed).
40
+ timezoneCollector,
41
+ // warm
42
+ canvasCollector,
43
+ webglCollector,
44
+ displayCollector,
45
+ // Last in the wave: it is the only collector that attaches to the document,
46
+ // so if the budget runs out it is the one to lose.
47
+ integrityCollector,
48
+ // deep — every one is optional, so a tenant switching them off costs no
49
+ // confidence, and a budget overrun loses the expensive signals rather than
50
+ // the universal ones.
51
+ audioCollector,
52
+ devicesCollector,
53
+ storageCollector,
54
+ capabilitiesCollector,
55
+ // Last overall: it appends to the document and does the most DOM work of
56
+ // anything here, so it is the right thing to lose when the budget ends.
57
+ fontsCollector,
58
+ ];
59
+ export const COLLECTOR_IDS = ALL_COLLECTORS.map((c) => c.id);
60
+ /**
61
+ * Collectors a deployment must ask for by name.
62
+ *
63
+ * Deliberately absent from `ALL_COLLECTORS`, so no default enables them and no
64
+ * tenant gets them by forgetting to opt out. Everything here reads something
65
+ * about the person rather than about their browser, which makes enabling it a
66
+ * decision somebody has to make on purpose and be able to point at a consent
67
+ * for.
68
+ *
69
+ * Pass them explicitly:
70
+ *
71
+ * runCollectors(env, { collectors: [...ALL_COLLECTORS, ...INVASIVE_COLLECTORS] })
72
+ *
73
+ * The split is the product position, not a technical one. `docs/` says what
74
+ * Magic Shield collects by default, and this is the list that is not in it.
75
+ */
76
+ export const INVASIVE_COLLECTORS = [webrtcCollector, adblockCollector, appsCollector];
77
+ /**
78
+ * Removed: localPortsCollector.
79
+ *
80
+ * It probed nine loopback ports for automation drivers, remote-control tools
81
+ * and anti-detect control APIs, and it was measured from the wrong place. The
82
+ * fp-lab page is served from `127.0.0.1`, so the probe was local reaching
83
+ * local — the one case Chrome does not gate — and it reported nine ports
84
+ * cleanly probed.
85
+ *
86
+ * From a public origin, which is every real deployment, Chrome 153 answers
87
+ * `navigator.permissions.query({ name: 'local-network-access' })` with
88
+ * **prompt**. Measured from `https://example.com` against known ports:
89
+ *
90
+ * 8791 open hung until aborted at 1.5 s
91
+ * 9515 closed Failed to fetch in 10 ms
92
+ * 18123 closed hung until aborted at 2.0 s
93
+ *
94
+ * So it does not work, and the way it fails is worse than not working. The
95
+ * open port did not complete and two results were indistinguishable hangs, so
96
+ * the answers are noise; and a pending permission decision blocks, which on a
97
+ * login page means burning the deep wave and showing the visitor a prompt
98
+ * asking to access their local network. Nobody accepts that prompt, and asking
99
+ * is itself the harm.
100
+ *
101
+ * Not kept behind a flag. A collector that prompts cannot be enabled by a
102
+ * deployment without the person seeing it, which is the line this set does not
103
+ * cross — every other member runs silently.
104
+ *
105
+ * If it ever comes back it needs the permission requested honestly, at a
106
+ * moment the person understands, which is a product decision rather than a
107
+ * collector.
108
+ */
package/dist/run.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { type CollectionResult, type Collector, type CollectorEnv, type Wave } from './types.js';
2
+ export interface RunOptions {
3
+ readonly waves?: readonly Wave[];
4
+ /** Collector ids switched off by the tenant, for privacy or compatibility. */
5
+ readonly disabled?: readonly string[];
6
+ readonly budgets?: Partial<Record<Wave, number>>;
7
+ readonly collectors?: readonly Collector[];
8
+ readonly now?: () => number;
9
+ }
10
+ export declare function runCollectors(env: CollectorEnv, options?: RunOptions): Promise<CollectionResult>;
package/dist/run.js ADDED
@@ -0,0 +1,128 @@
1
+ import { ALL_COLLECTORS } from './registry.js';
2
+ import { WAVE_BUDGET_MS } from './types.js';
3
+ /**
4
+ * Runs collectors within their wave budgets.
5
+ *
6
+ * The budget is a hard promise to the integrator: a protected action is never
7
+ * delayed by collection. When a wave runs out of time the remaining collectors
8
+ * are skipped and reported, and confidence falls — a partial fingerprint is a
9
+ * normal outcome, not an error. On a slow device that is exactly the right
10
+ * trade: the user gets their login, and the server scores what it has.
11
+ */
12
+ /** Returned instead of a result when a collector outlives its budget. */
13
+ const TIMED_OUT = Symbol('timed-out');
14
+ /**
15
+ * Resolves with the collector's result, or `TIMED_OUT` when the deadline wins.
16
+ *
17
+ * The collector is not cancelled — nothing here can cancel a browser API — so
18
+ * a hung probe keeps its handle until the page goes away. What this bounds is
19
+ * how long anyone *waits* for it, which is the promise the budget actually
20
+ * makes.
21
+ */
22
+ async function withDeadline(work, ms) {
23
+ if (!(work instanceof Promise))
24
+ return work;
25
+ let timer;
26
+ try {
27
+ return await Promise.race([
28
+ work,
29
+ new Promise((resolve) => {
30
+ timer = setTimeout(() => resolve(TIMED_OUT), Math.max(0, ms));
31
+ }),
32
+ ]);
33
+ }
34
+ finally {
35
+ if (timer !== undefined)
36
+ clearTimeout(timer);
37
+ }
38
+ }
39
+ export async function runCollectors(env, options = {}) {
40
+ const now = options.now ?? (() => performance.now());
41
+ const waves = options.waves ?? ['instant', 'warm'];
42
+ const disabled = new Set(options.disabled ?? []);
43
+ const registry = options.collectors ?? ALL_COLLECTORS;
44
+ const signals = {};
45
+ const skipped = [];
46
+ const errors = [];
47
+ const startedAll = now();
48
+ let expected = 0;
49
+ let obtained = 0;
50
+ for (const wave of waves) {
51
+ const budget = options.budgets?.[wave] ?? WAVE_BUDGET_MS[wave];
52
+ const waveStarted = now();
53
+ // Set when a collector hits its deadline. A collector that outlived the
54
+ // remaining budget consumed it by definition, and saying so explicitly is
55
+ // both more honest and more reliable than inferring it from a clock —
56
+ // `setTimeout` and `performance.now()` are not the same clock, and a timer
57
+ // firing a fraction early left the comparison below reading as though
58
+ // there were budget left when there was not.
59
+ let exhausted = false;
60
+ for (const collector of registry.filter((c) => c.wave === wave)) {
61
+ if (disabled.has(collector.id)) {
62
+ skipped.push(collector.id);
63
+ // An optional collector the tenant switched off is a configuration
64
+ // choice, not missing evidence, so it does not count against
65
+ // confidence. Otherwise a tenant who disables canvas for privacy pays
66
+ // a permanently lower confidence on every request — the product
67
+ // penalising them for using the control it gave them.
68
+ //
69
+ // A collector that ran and found nothing still counts. That is a real
70
+ // absence, and `optional` says the tenant may turn it off, not that
71
+ // its silence is free.
72
+ if (collector.optional !== true)
73
+ expected++;
74
+ continue;
75
+ }
76
+ expected++;
77
+ // Checked before starting rather than after: overshooting the budget and
78
+ // then noticing is the failure this is meant to prevent.
79
+ if (exhausted || now() - waveStarted >= budget) {
80
+ skipped.push(collector.id);
81
+ continue;
82
+ }
83
+ try {
84
+ // Bounded by what is left of the wave, not merely checked before it
85
+ // starts. The budget is "a protected action is never delayed by
86
+ // collection", and an unbounded await cannot keep that promise: one
87
+ // collector whose promise never settles hangs the whole bundle, and
88
+ // the caller waits forever for a fingerprint that was supposed to be
89
+ // best-effort.
90
+ //
91
+ // Measured: a backgrounded tab never fires `requestAnimationFrame`,
92
+ // and a browser API that hangs rather than rejecting is an ordinary
93
+ // hostile-runtime event rather than an exotic one.
94
+ const remaining = budget - (now() - waveStarted);
95
+ const result = await withDeadline(collector.collect(env), remaining);
96
+ if (result === TIMED_OUT) {
97
+ skipped.push(collector.id);
98
+ errors.push(`${collector.id}: exceeded the ${wave} budget`);
99
+ exhausted = true;
100
+ continue;
101
+ }
102
+ Object.assign(signals, result);
103
+ // A collector that returns only nulls did not fail, but it did not
104
+ // contribute either — canvas in an environment with no canvas, for
105
+ // instance. Confidence has to mean "how much was actually learned",
106
+ // not "how many collectors avoided throwing".
107
+ if (Object.values(result).some((value) => value !== null))
108
+ obtained++;
109
+ else
110
+ skipped.push(collector.id);
111
+ }
112
+ catch (err) {
113
+ // One broken collector must never cost the whole bundle. Browsers are
114
+ // a hostile runtime: extensions remove APIs, hardening throws on
115
+ // property access, and a page can be closed mid-collection.
116
+ skipped.push(collector.id);
117
+ errors.push(`${collector.id}: ${err.message}`);
118
+ }
119
+ }
120
+ }
121
+ return {
122
+ signals,
123
+ durationMs: Math.round(now() - startedAll),
124
+ skipped,
125
+ errors,
126
+ confidence: expected === 0 ? 0 : Number((obtained / expected).toFixed(3)),
127
+ };
128
+ }
@@ -0,0 +1,2 @@
1
+ import type { Collector } from '../types.js';
2
+ export declare const adblockCollector: Collector;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Whether content blocking is active, measured without contacting anyone.
3
+ *
4
+ * In `INVASIVE_COLLECTORS` for an operational reason rather than a privacy one.
5
+ * It is not sensitive — running an ad blocker is not personal data in any
6
+ * meaningful sense — but the conventional implementation makes outbound
7
+ * requests to advertising domains from the integrator's login page, and that
8
+ * costs them CSP violations, latency, and an SDK that looks like adware in
9
+ * anyone's network tab. That is a decision for the integrator, not a default.
10
+ *
11
+ * ## Bait, not requests
12
+ *
13
+ * SEON's SDK fetches roughly fifteen real ad-network URLs and times the
14
+ * failures. This does not. Blockers work primarily from CSS selector and
15
+ * element-name rules, so the same answer comes from creating elements that
16
+ * *look* like advertising and asking whether they survived layout:
17
+ *
18
+ * an element with a blocked class name has its height collapsed to zero
19
+ * an element with a blocked id is hidden or removed outright
20
+ *
21
+ * Nothing leaves the page. No third party learns the visitor was here, no
22
+ * request appears in the network tab, and there is nothing for a CSP to refuse.
23
+ *
24
+ * ## What the answer is worth
25
+ *
26
+ * Little on its own, and that is the honest framing. Running a blocker is
27
+ * enormously common and says nothing about intent — it is weak positive
28
+ * evidence of a *person*, if anything, since most automation runs a stock
29
+ * profile with no extensions at all.
30
+ *
31
+ * Where it earns its place is as a contradiction: a client claiming a
32
+ * fully-configured consumer browser that has no blocker, no permissions ever
33
+ * decided, no touch history and a default everything is claiming a machine
34
+ * nobody uses. This is one of those, and it is collected for that combination
35
+ * rather than for itself.
36
+ */
37
+ /**
38
+ * Class and id names blockers act on.
39
+ *
40
+ * Long-standing entries from the common filter lists rather than anything
41
+ * exotic: the goal is to be caught by an ordinary blocker in its default
42
+ * configuration, not to defeat a clever one.
43
+ */
44
+ const BAIT_CLASSES = ['ad-banner', 'adsbox', 'pub_300x250', 'text-ad', 'sponsored-content'];
45
+ const BAIT_IDS = ['ad-container', 'banner-ad', 'google_ads_frame'];
46
+ export const adblockCollector = {
47
+ id: 'adblock',
48
+ wave: 'deep',
49
+ optional: true,
50
+ async collect({ document, window }) {
51
+ const out = {};
52
+ let holder;
53
+ try {
54
+ holder = document.createElement('div');
55
+ holder.setAttribute('style', 'position:absolute;left:-9999px;top:-9999px;width:1px;height:1px');
56
+ document.body.appendChild(holder);
57
+ const baits = [];
58
+ for (const name of BAIT_CLASSES) {
59
+ const bait = document.createElement('div');
60
+ bait.className = name;
61
+ // An explicit height, so a collapse to zero is the blocker's doing
62
+ // rather than an empty element being an empty element.
63
+ bait.setAttribute('style', 'width:300px;height:250px;position:absolute;left:-9999px');
64
+ bait.innerHTML = '&nbsp;';
65
+ holder.appendChild(bait);
66
+ baits.push(bait);
67
+ }
68
+ for (const name of BAIT_IDS) {
69
+ const bait = document.createElement('div');
70
+ bait.id = name;
71
+ bait.setAttribute('style', 'width:300px;height:250px;position:absolute;left:-9999px');
72
+ bait.innerHTML = '&nbsp;';
73
+ holder.appendChild(bait);
74
+ baits.push(bait);
75
+ }
76
+ // One frame, so a blocker acting on style has run. Raced against a
77
+ // deadline because `requestAnimationFrame` never fires in a background
78
+ // tab, and a collector that waits forever there is worse than one that
79
+ // answers from an unsettled layout.
80
+ await nextFrame(window);
81
+ let blocked = 0;
82
+ for (const bait of baits) {
83
+ // Removed outright, collapsed, or hidden — the three shapes a filter
84
+ // list produces. `offsetParent` is null for `display:none` and is the
85
+ // cheapest of the three to read.
86
+ const gone = bait.parentNode === null ||
87
+ bait.offsetHeight === 0 ||
88
+ bait.clientHeight === 0 ||
89
+ window.getComputedStyle(bait).display === 'none';
90
+ if (gone)
91
+ blocked++;
92
+ }
93
+ out['adblock.baits'] = baits.length;
94
+ out['adblock.blocked'] = blocked;
95
+ // A threshold rather than "any", because one bait collapsing can be a
96
+ // stylesheet on the host page rather than a blocker. Two agreeing is a
97
+ // filter list.
98
+ out['adblock.active'] = blocked >= 2;
99
+ }
100
+ catch {
101
+ out['adblock.baits'] = null;
102
+ out['adblock.blocked'] = null;
103
+ out['adblock.active'] = null;
104
+ }
105
+ finally {
106
+ try {
107
+ if (holder !== undefined && holder.parentNode !== null)
108
+ holder.parentNode.removeChild(holder);
109
+ }
110
+ catch {
111
+ // Offscreen and hidden either way.
112
+ }
113
+ }
114
+ return out;
115
+ },
116
+ };
117
+ /** One paint, or a short deadline — whichever arrives first. */
118
+ function nextFrame(window) {
119
+ return new Promise((resolve) => {
120
+ let settled = false;
121
+ const finish = () => {
122
+ if (settled)
123
+ return;
124
+ settled = true;
125
+ resolve();
126
+ };
127
+ try {
128
+ window.requestAnimationFrame(() => finish());
129
+ }
130
+ catch {
131
+ // No rAF; the deadline below covers it.
132
+ }
133
+ setTimeout(finish, 60);
134
+ });
135
+ }
@@ -0,0 +1,2 @@
1
+ import type { Collector } from '../types.js';
2
+ export declare const appsCollector: Collector;
@@ -0,0 +1,191 @@
1
+ import { hash } from '../hash.js';
2
+ /**
3
+ * Which applications are installed, probed through a filesystem leak.
4
+ *
5
+ * NOT part of `ALL_COLLECTORS`. It ships in `INVASIVE_COLLECTORS`, which a
6
+ * deployment must pass explicitly, because this reads something about a person
7
+ * rather than about their browser and that is not a decision a default should
8
+ * make quietly.
9
+ *
10
+ * ## What it is for
11
+ *
12
+ * Three entries are the reason it exists: GPS spoofers. A device running one
13
+ * is a device whose location cannot be believed, which in iGaming is the
14
+ * difference between a legal bet and an illegal one. The rest are here because
15
+ * presence *and absence* both carry signal — a machine with no browser but
16
+ * one, no messenger and no music player is not a person's laptop, it is a
17
+ * freshly imaged VM.
18
+ *
19
+ * ## What it costs, stated plainly
20
+ *
21
+ * It reveals which VPN somebody uses, whether they run Tor, and which
22
+ * encrypted messengers they have. Under GDPR that is ordinary personal data
23
+ * rather than a special category, but Article 5(1)(c) still asks whether it is
24
+ * necessary and proportionate for the purpose, and ePrivacy Article 5(3)
25
+ * treats reading from terminal equipment as requiring consent unless it is
26
+ * strictly necessary for a service the person asked for.
27
+ *
28
+ * A KYC-consented iGaming onboarding has an answer to both. A login page on a
29
+ * general-purpose site does not. That is the whole reason this is opt-in, and
30
+ * a deployment enabling it should be able to point at the consent covering it.
31
+ *
32
+ * The list is deliberately short. A longer one is more entropy and more
33
+ * exposure, and the marginal application tells you about a person without
34
+ * telling you anything about fraud.
35
+ *
36
+ * ## How it works
37
+ *
38
+ * A `File` is constructed whose *name* is an absolute path, added to a
39
+ * `DataTransfer`, and read back through a file input's `webkitEntries`. The
40
+ * resulting `FileSystemEntry` resolves against the real filesystem, so
41
+ * `getParent` succeeds for a path that exists and fails for one that does not.
42
+ *
43
+ * Chromium only, and a leak rather than an API. Every probe reports null
44
+ * rather than false when the mechanism is unavailable, so a browser without it
45
+ * reads as "could not look" instead of "nothing installed".
46
+ *
47
+ * ## It is already closed on current Chrome
48
+ *
49
+ * Measured on Chrome 153, 2026-09-29: `webkitEntries` is present on a file
50
+ * input and returns an **empty array** for a synthesised `DataTransfer`.
51
+ * Chrome now populates it only from a real user-initiated drop, so this
52
+ * collector returns null for every path on a current browser.
53
+ *
54
+ * It is kept rather than deleted for one reason: it costs a single probe to
55
+ * find that out, and older Chromium builds are still in use. It is not kept
56
+ * because it works here — it does not. Anyone enabling this should measure it
57
+ * on the browsers their users actually run before believing a negative, and
58
+ * `apps.probed` is the number that says whether anything was measured at all.
59
+ *
60
+ * Worth noting that SEON's SDK still ships this technique against 164 paths.
61
+ */
62
+ /** Per probe. Several run concurrently, so this is not the total. */
63
+ const PROBE_TIMEOUT_MS = 80;
64
+ const MACOS_APPS = [
65
+ // The point of the exercise: location cannot be trusted on a machine
66
+ // running any of these.
67
+ 'MockGo.app',
68
+ 'Tenorshare iAnyGo.app',
69
+ 'iMyFone AnyTo.app',
70
+ // Remote control. Legitimate constantly, and also how an account comes to be
71
+ // operated by somebody other than its owner.
72
+ 'AnyDesk.app',
73
+ 'TeamViewer.app',
74
+ 'Parsec.app',
75
+ // Anti-detect browsers, which say what kind of session this is.
76
+ 'ixBrowser.app',
77
+ 'Multilogin.app',
78
+ 'Dolphin Anty.app',
79
+ // Ordinary things. A machine with none of them is usually not a person's.
80
+ 'Google Chrome.app',
81
+ 'Firefox.app',
82
+ 'Slack.app',
83
+ 'Spotify.app',
84
+ 'zoom.us.app',
85
+ ];
86
+ export const appsCollector = {
87
+ id: 'apps',
88
+ wave: 'deep',
89
+ optional: true,
90
+ async collect({ document, window }) {
91
+ const out = {};
92
+ // The mechanism is Chromium-only and specific to synthesised entries.
93
+ // Probing for it once keeps every negative below honest.
94
+ if (!supportsEntryProbe(document, window)) {
95
+ out['apps.probed'] = null;
96
+ out['apps.found'] = null;
97
+ out['apps.hash'] = null;
98
+ return out;
99
+ }
100
+ try {
101
+ const results = await Promise.all(MACOS_APPS.map(async (name) => ({
102
+ name,
103
+ present: await pathExists(document, `/Applications/${name}`),
104
+ })));
105
+ const present = results.filter((r) => r.present === true).map((r) => r.name);
106
+ const measured = results.filter((r) => r.present !== null);
107
+ out['apps.probed'] = measured.length;
108
+ out['apps.found'] = present.length;
109
+ out['apps.hash'] = present.length === 0 ? null : hash([...present].sort().join('|'));
110
+ // Grouped rather than named individually. A rule that wants to act on a
111
+ // GPS spoofer should not need to know a hash, and a tenant reading its
112
+ // own console deserves to see what it acted on — but nothing downstream
113
+ // needs the specific application, so the specific application does not
114
+ // leave this file.
115
+ out['apps.locationSpoofer'] = present.some((n) => /MockGo|iAnyGo|AnyTo/.test(n));
116
+ out['apps.remoteControl'] = present.some((n) => /AnyDesk|TeamViewer|Parsec/.test(n));
117
+ out['apps.antiDetect'] = present.some((n) => /ixBrowser|Multilogin|Dolphin/.test(n));
118
+ }
119
+ catch {
120
+ out['apps.probed'] = null;
121
+ out['apps.found'] = null;
122
+ }
123
+ return out;
124
+ },
125
+ };
126
+ /**
127
+ * Whether the mechanism actually works, rather than whether the property
128
+ * exists.
129
+ *
130
+ * These are not the same and the difference is the whole collector. Measured
131
+ * on Chrome 153: `webkitEntries` is present on a file input and returns an
132
+ * **empty array** for a synthesised `DataTransfer`, so a check for the
133
+ * property passes and then every probe answers null. Chrome now populates it
134
+ * only from a real user-initiated drop.
135
+ *
136
+ * So this constructs one throwaway entry and checks that something came back.
137
+ * A browser that has closed the leak is detected in one step instead of
138
+ * fifteen timeouts.
139
+ */
140
+ function supportsEntryProbe(document, window) {
141
+ try {
142
+ if (typeof window.DataTransfer !== 'function')
143
+ return false;
144
+ const transfer = new DataTransfer();
145
+ transfer.items.add(new File([], 'probe', { type: 'text/plain' }));
146
+ const input = document.createElement('input');
147
+ input.type = 'file';
148
+ input.files = transfer.files;
149
+ const entries = input.webkitEntries;
150
+ return Array.isArray(entries) && entries.length > 0;
151
+ }
152
+ catch {
153
+ return false;
154
+ }
155
+ }
156
+ /**
157
+ * Whether a path exists, or null when it could not be determined.
158
+ *
159
+ * Never false on a failed mechanism. That is the difference between "this is
160
+ * not installed" and "this browser does not leak", and a caller conflating
161
+ * them reports an empty machine for every Firefox user.
162
+ */
163
+ async function pathExists(document, path) {
164
+ try {
165
+ const transfer = new DataTransfer();
166
+ transfer.items.add(new File([], `${path}/probe`, { type: 'text/plain' }));
167
+ const input = document.createElement('input');
168
+ input.type = 'file';
169
+ input.files = transfer.files;
170
+ const entry = input.webkitEntries?.[0];
171
+ if (entry === undefined || typeof entry.getParent !== 'function')
172
+ return null;
173
+ return await new Promise((resolve) => {
174
+ let settled = false;
175
+ const finish = (value) => {
176
+ if (settled)
177
+ return;
178
+ settled = true;
179
+ resolve(value);
180
+ };
181
+ // A resolved parent means the directory is really there; an error means
182
+ // it is not. The timeout means neither answer arrived, which is a third
183
+ // outcome and is reported as one.
184
+ entry.getParent(() => finish(true), () => finish(false));
185
+ setTimeout(() => finish(null), PROBE_TIMEOUT_MS);
186
+ });
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
@@ -0,0 +1,16 @@
1
+ import type { Collector } from '../types.js';
2
+ /**
3
+ * The audio stack, rendered offline.
4
+ *
5
+ * An oscillator through a compressor produces a buffer whose exact float
6
+ * values depend on the platform's audio implementation and its floating-point
7
+ * behaviour — stable for a machine, different across them. Nothing is played
8
+ * and no device is opened: `OfflineAudioContext` renders into memory, needs no
9
+ * permission, and makes no sound.
10
+ *
11
+ * Two values rather than one, because they fail differently. The sum survives
12
+ * small per-sample noise and identifies the platform; the hash does not, and a
13
+ * client whose sum is ordinary while its hash moves between reads is being
14
+ * farbled. Reporting only one of them would hide that.
15
+ */
16
+ export declare const audioCollector: Collector;
@@ -0,0 +1,63 @@
1
+ import { hash } from '../hash.js';
2
+ /**
3
+ * The audio stack, rendered offline.
4
+ *
5
+ * An oscillator through a compressor produces a buffer whose exact float
6
+ * values depend on the platform's audio implementation and its floating-point
7
+ * behaviour — stable for a machine, different across them. Nothing is played
8
+ * and no device is opened: `OfflineAudioContext` renders into memory, needs no
9
+ * permission, and makes no sound.
10
+ *
11
+ * Two values rather than one, because they fail differently. The sum survives
12
+ * small per-sample noise and identifies the platform; the hash does not, and a
13
+ * client whose sum is ordinary while its hash moves between reads is being
14
+ * farbled. Reporting only one of them would hide that.
15
+ */
16
+ export const audioCollector = {
17
+ id: 'audio',
18
+ wave: 'deep',
19
+ optional: true,
20
+ async collect({ window }) {
21
+ const out = {};
22
+ const Ctor = window.OfflineAudioContext ??
23
+ window.webkitOfflineAudioContext;
24
+ if (Ctor === undefined) {
25
+ // Present and null: the probe ran and the API is absent. An omitted key
26
+ // would say the collector never ran, which is a different fact.
27
+ out['audio.sum'] = null;
28
+ out['audio.hash'] = null;
29
+ return out;
30
+ }
31
+ try {
32
+ // 44.1 kHz and 5000 frames: long enough for the compressor to settle,
33
+ // short enough to render well inside the deep wave's budget.
34
+ const context = new Ctor(1, 5000, 44100);
35
+ const oscillator = context.createOscillator();
36
+ oscillator.type = 'triangle';
37
+ oscillator.frequency.value = 10000;
38
+ const compressor = context.createDynamicsCompressor();
39
+ compressor.threshold.value = -50;
40
+ compressor.knee.value = 40;
41
+ compressor.ratio.value = 12;
42
+ compressor.attack.value = 0;
43
+ compressor.release.value = 0.25;
44
+ oscillator.connect(compressor);
45
+ compressor.connect(context.destination);
46
+ oscillator.start(0);
47
+ const buffer = await context.startRendering();
48
+ const samples = buffer.getChannelData(0);
49
+ // The tail only. The attack is dominated by the ramp, which is the same
50
+ // everywhere; the settled region is where implementations diverge.
51
+ let sum = 0;
52
+ for (let i = 4500; i < samples.length; i++)
53
+ sum += Math.abs(samples[i] ?? 0);
54
+ out['audio.sum'] = Number(sum.toFixed(6));
55
+ out['audio.hash'] = hash(samples.slice(4500).join(','));
56
+ }
57
+ catch {
58
+ out['audio.sum'] = null;
59
+ out['audio.hash'] = null;
60
+ }
61
+ return out;
62
+ },
63
+ };
@@ -0,0 +1,2 @@
1
+ import type { Collector } from '../types.js';
2
+ export declare const automationCollector: Collector;