@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
package/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2026 MagicShield. All rights reserved.
2
+
3
+ MAGIC SHIELD SDK LICENCE
4
+
5
+ This package is published to a public registry so that licensed customers can
6
+ install it with ordinary tooling. Being able to download it does not grant any
7
+ right to use it.
8
+
9
+ You may install, use and redistribute this package, unmodified and only as part
10
+ of your own application, solely to integrate that application with a Magic
11
+ Shield deployment that you are licensed to use under a written agreement with
12
+ MagicShield ("your Agreement"), and only for the term of your Agreement.
13
+
14
+ Except as your Agreement expressly allows, you may not:
15
+
16
+ - use this package for any other purpose, including to build, benchmark or
17
+ assist a competing product;
18
+ - modify it, or reverse engineer, decompile, deobfuscate or disassemble it,
19
+ except to the extent this restriction is unenforceable under applicable law;
20
+ - remove or alter this notice or any copyright or attribution in it.
21
+
22
+ THE PACKAGE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXCEPT AS YOUR
23
+ AGREEMENT PROVIDES. IN NO EVENT SHALL MAGICSHIELD BE LIABLE FOR ANY CLAIM,
24
+ DAMAGES OR OTHER LIABILITY ARISING FROM THE PACKAGE OR ITS USE, EXCEPT AS YOUR
25
+ AGREEMENT PROVIDES.
26
+
27
+ Your Agreement governs if it conflicts with this notice.
@@ -0,0 +1,33 @@
1
+ import type { SignalValue } from '../types.js';
2
+ export interface BehaviorSummary {
3
+ readonly [key: string]: SignalValue;
4
+ }
5
+ export interface RecorderTarget {
6
+ addEventListener(type: string, listener: (event: Event) => void, options?: unknown): void;
7
+ removeEventListener(type: string, listener: (event: Event) => void, options?: unknown): void;
8
+ }
9
+ export interface RecorderOptions {
10
+ readonly now?: () => number;
11
+ }
12
+ /**
13
+ * Accumulates behavioural statistics from a target's input events.
14
+ *
15
+ * Constructed once per page and attached to the document; `stop()` detaches
16
+ * everything, so an SPA that mounts and unmounts a form does not leak listeners.
17
+ */
18
+ export declare class BehaviorRecorder {
19
+ #private;
20
+ constructor(options?: RecorderOptions);
21
+ /**
22
+ * Attaches to a target.
23
+ *
24
+ * Passive and capturing: passive so the page's scrolling is never blocked by
25
+ * us, capturing so a form that calls `stopPropagation` on its own inputs does
26
+ * not silently blind the recorder. A fingerprinting library that degrades a
27
+ * host page's scroll performance has failed at being a guest.
28
+ */
29
+ attach(target: RecorderTarget): void;
30
+ stop(): void;
31
+ /** Derived statistics only. Safe to call repeatedly. */
32
+ summary(): BehaviorSummary;
33
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Behavioural observation.
3
+ *
4
+ * Every other collector takes a snapshot: it reads a property and returns. This
5
+ * one cannot — human-ness is in how input arrives over time, so something has
6
+ * to be listening from page load, long before anyone submits a form.
7
+ *
8
+ * **Raw event streams never leave the page, and are never even retained.**
9
+ * Statistics are folded in as events arrive and the events are dropped, which
10
+ * is both the privacy position and the reason memory is bounded regardless of
11
+ * how long a tab stays open. A pointer trail is a biometric and a keystroke
12
+ * sequence leaks the text; neither is worth having on a server to decide
13
+ * whether one login looks automated.
14
+ *
15
+ * What is deliberately NOT captured, though the plan lists it: key identity of
16
+ * any kind, and `beh.key.digraph_profile`. Per-user typing rhythm at that
17
+ * resolution identifies people across sessions, which is a different product
18
+ * from deciding whether this request is a script.
19
+ */
20
+ /** Past this, the distribution is established and more samples buy nothing. */
21
+ const MAX_SAMPLES = 256;
22
+ /** Listeners we attach. All passive: the page's own handling is never affected. */
23
+ const POINTER_EVENTS = ['pointermove', 'pointerdown', 'pointerup'];
24
+ const KEY_EVENTS = ['keydown', 'keyup'];
25
+ function push(w, x) {
26
+ w.n += 1;
27
+ const delta = x - w.mean;
28
+ w.mean += delta / w.n;
29
+ w.m2 += delta * (x - w.mean);
30
+ }
31
+ function sd(w) {
32
+ return w.n < 2 ? null : Math.sqrt(w.m2 / (w.n - 1));
33
+ }
34
+ function mean(w) {
35
+ return w.n === 0 ? null : w.mean;
36
+ }
37
+ const DIRECTION_BUCKETS = 8;
38
+ /**
39
+ * Accumulates behavioural statistics from a target's input events.
40
+ *
41
+ * Constructed once per page and attached to the document; `stop()` detaches
42
+ * everything, so an SPA that mounts and unmounts a form does not leak listeners.
43
+ */
44
+ export class BehaviorRecorder {
45
+ #now;
46
+ #started;
47
+ // Pointer.
48
+ #moveInterval = { n: 0, mean: 0, m2: 0 };
49
+ #speed = { n: 0, mean: 0, m2: 0 };
50
+ #curvature = { n: 0, mean: 0, m2: 0 };
51
+ #directions = new Array(DIRECTION_BUCKETS).fill(0);
52
+ #pointerSamples = 0;
53
+ #lastPoint;
54
+ #lastAngle;
55
+ #lastMoveAt;
56
+ // Keyboard. Dwell is press-to-release of one key; flight is release-to-press
57
+ // between keys. Neither records which key.
58
+ #dwell = { n: 0, mean: 0, m2: 0 };
59
+ #flight = { n: 0, mean: 0, m2: 0 };
60
+ #downAt = new Map();
61
+ #lastUpAt;
62
+ #keySamples = 0;
63
+ // Everything else.
64
+ #pasteEvents = 0;
65
+ #scrollEvents = 0;
66
+ #wheelDeltaModes = new Set();
67
+ #touchPoints = 0;
68
+ #maxTouchRadius = 0;
69
+ #touchForceSeen = false;
70
+ #focusChanges = 0;
71
+ #fieldsFocused = new Set();
72
+ #focusOrder = [];
73
+ #trustedEvents = 0;
74
+ #untrustedEvents = 0;
75
+ #firstInteractionAt;
76
+ #lastPointerDownAt;
77
+ #dwellBeforeClick;
78
+ #detach = [];
79
+ constructor(options = {}) {
80
+ this.#now = options.now ?? (() => Date.now());
81
+ this.#started = this.#now();
82
+ }
83
+ /**
84
+ * Attaches to a target.
85
+ *
86
+ * Passive and capturing: passive so the page's scrolling is never blocked by
87
+ * us, capturing so a form that calls `stopPropagation` on its own inputs does
88
+ * not silently blind the recorder. A fingerprinting library that degrades a
89
+ * host page's scroll performance has failed at being a guest.
90
+ */
91
+ attach(target) {
92
+ const options = { passive: true, capture: true };
93
+ const on = (type, handler) => {
94
+ target.addEventListener(type, handler, options);
95
+ this.#detach.push(() => target.removeEventListener(type, handler, options));
96
+ };
97
+ for (const type of POINTER_EVENTS)
98
+ on(type, (e) => this.#onPointer(e, type));
99
+ for (const type of KEY_EVENTS)
100
+ on(type, (e) => this.#onKey(e, type));
101
+ on('paste', (e) => this.#onPaste(e));
102
+ on('wheel', (e) => this.#onWheel(e));
103
+ on('scroll', () => {
104
+ this.#scrollEvents += 1;
105
+ });
106
+ on('touchstart', (e) => this.#onTouch(e));
107
+ on('focusin', (e) => this.#onFocus(e));
108
+ }
109
+ stop() {
110
+ for (const off of this.#detach)
111
+ off();
112
+ this.#detach = [];
113
+ }
114
+ #mark(event) {
115
+ // `isTrusted` is false for anything a page dispatched itself, which catches
116
+ // naive `element.click()` automation. It is true for CDP-driven input, so a
117
+ // high ratio proves nothing — only a low one is informative.
118
+ if (event.isTrusted === true)
119
+ this.#trustedEvents += 1;
120
+ else
121
+ this.#untrustedEvents += 1;
122
+ this.#firstInteractionAt ??= this.#now();
123
+ }
124
+ #onPointer(event, type) {
125
+ this.#mark(event);
126
+ if (type === 'pointerdown') {
127
+ this.#lastPointerDownAt = this.#now();
128
+ // Zero movement before a click is the classic scripted submit. Recorded
129
+ // as the gap since the last observed move, not as a verdict.
130
+ this.#dwellBeforeClick =
131
+ this.#lastMoveAt === undefined ? undefined : Math.round(this.#lastPointerDownAt - this.#lastMoveAt);
132
+ return;
133
+ }
134
+ if (type === 'pointerup')
135
+ return;
136
+ if (this.#pointerSamples >= MAX_SAMPLES)
137
+ return;
138
+ const t = this.#now();
139
+ const x = event.clientX;
140
+ const y = event.clientY;
141
+ if (typeof x !== 'number' || typeof y !== 'number')
142
+ return;
143
+ const previous = this.#lastPoint;
144
+ this.#lastPoint = { x, y, t };
145
+ this.#lastMoveAt = t;
146
+ this.#pointerSamples += 1;
147
+ if (previous === undefined)
148
+ return;
149
+ const dx = x - previous.x;
150
+ const dy = y - previous.y;
151
+ const dt = t - previous.t;
152
+ const distance = Math.hypot(dx, dy);
153
+ if (dt > 0) {
154
+ push(this.#moveInterval, dt);
155
+ push(this.#speed, distance / dt);
156
+ }
157
+ if (distance === 0)
158
+ return;
159
+ const angle = Math.atan2(dy, dx);
160
+ // Direction histogram, for entropy. A straight programmatic path fills one
161
+ // bucket; a hand filling a form fills most of them.
162
+ const bucket = Math.min(DIRECTION_BUCKETS - 1, Math.max(0, Math.floor(((angle + Math.PI) / (2 * Math.PI)) * DIRECTION_BUCKETS)));
163
+ this.#directions[bucket] = (this.#directions[bucket] ?? 0) + 1;
164
+ if (this.#lastAngle !== undefined) {
165
+ let turn = angle - this.#lastAngle;
166
+ while (turn > Math.PI)
167
+ turn -= 2 * Math.PI;
168
+ while (turn < -Math.PI)
169
+ turn += 2 * Math.PI;
170
+ push(this.#curvature, Math.abs(turn));
171
+ }
172
+ this.#lastAngle = angle;
173
+ }
174
+ #onKey(event, type) {
175
+ this.#mark(event);
176
+ if (this.#keySamples >= MAX_SAMPLES)
177
+ return;
178
+ // `event.code` is the physical key position, used only as a correlation
179
+ // handle to pair a keyup with its keydown, and never recorded.
180
+ const handle = event.code || event.key || 'k';
181
+ const t = this.#now();
182
+ if (type === 'keydown') {
183
+ if (!this.#downAt.has(handle))
184
+ this.#downAt.set(handle, t);
185
+ if (this.#lastUpAt !== undefined)
186
+ push(this.#flight, t - this.#lastUpAt);
187
+ return;
188
+ }
189
+ const downAt = this.#downAt.get(handle);
190
+ if (downAt !== undefined) {
191
+ push(this.#dwell, t - downAt);
192
+ this.#downAt.delete(handle);
193
+ this.#keySamples += 1;
194
+ }
195
+ this.#lastUpAt = t;
196
+ }
197
+ #onPaste(event) {
198
+ this.#mark(event);
199
+ this.#pasteEvents += 1;
200
+ }
201
+ #onWheel(event) {
202
+ this.#mark(event);
203
+ this.#scrollEvents += 1;
204
+ // A trackpad emits pixel deltas, a wheel emits lines. Which one is present
205
+ // says something about the device that the user agent does not.
206
+ if (typeof event.deltaMode === 'number')
207
+ this.#wheelDeltaModes.add(event.deltaMode);
208
+ }
209
+ #onTouch(event) {
210
+ this.#mark(event);
211
+ const touches = event.touches;
212
+ if (touches === undefined || touches.length === 0)
213
+ return;
214
+ this.#touchPoints = Math.max(this.#touchPoints, touches.length);
215
+ for (let i = 0; i < touches.length; i++) {
216
+ const touch = touches.item(i);
217
+ if (touch === null)
218
+ continue;
219
+ const radius = Math.max(touch.radiusX ?? 0, touch.radiusY ?? 0);
220
+ if (radius > this.#maxTouchRadius)
221
+ this.#maxTouchRadius = radius;
222
+ // A real finger reports a varying force; a synthesised touch reports 0.
223
+ if (typeof touch.force === 'number' && touch.force > 0)
224
+ this.#touchForceSeen = true;
225
+ }
226
+ }
227
+ #onFocus(event) {
228
+ this.#mark(event);
229
+ this.#focusChanges += 1;
230
+ const target = event.target;
231
+ if (target === null)
232
+ return;
233
+ // A stable handle for the field, never its value and never its label. Used
234
+ // only to see whether fields were filled in DOM order.
235
+ const handle = target.id ?? target.name ?? target.tagName ?? '';
236
+ if (handle === '')
237
+ return;
238
+ this.#fieldsFocused.add(handle);
239
+ if (this.#focusOrder.length < 32)
240
+ this.#focusOrder.push(handle);
241
+ }
242
+ /** Derived statistics only. Safe to call repeatedly. */
243
+ summary() {
244
+ const now = this.#now();
245
+ const observed = this.#trustedEvents + this.#untrustedEvents;
246
+ const out = {};
247
+ out['beh.observed'] = observed > 0;
248
+ out['beh.pointer.samples'] = this.#pointerSamples;
249
+ out['beh.pointer.entropy'] = this.#pointerSamples === 0 ? null : round(entropy(this.#directions), 3);
250
+ out['beh.pointer.curvature'] = roundOrNull(mean(this.#curvature), 4);
251
+ out['beh.pointer.curvature_sd'] = roundOrNull(sd(this.#curvature), 4);
252
+ out['beh.pointer.speed_mean'] = roundOrNull(mean(this.#speed), 4);
253
+ out['beh.pointer.speed_sd'] = roundOrNull(sd(this.#speed), 4);
254
+ out['beh.pointer.interval_mean'] = roundOrNull(mean(this.#moveInterval), 2);
255
+ out['beh.pointer.interval_sd'] = roundOrNull(sd(this.#moveInterval), 2);
256
+ out['beh.pointer.dwell_before_click'] = this.#dwellBeforeClick ?? null;
257
+ out['beh.key.samples'] = this.#keySamples;
258
+ out['beh.key.dwell_mean'] = roundOrNull(mean(this.#dwell), 2);
259
+ out['beh.key.dwell_sd'] = roundOrNull(sd(this.#dwell), 2);
260
+ out['beh.key.flight_mean'] = roundOrNull(mean(this.#flight), 2);
261
+ out['beh.key.flight_sd'] = roundOrNull(sd(this.#flight), 2);
262
+ out['beh.paste_events'] = this.#pasteEvents;
263
+ out['beh.scroll.events'] = this.#scrollEvents;
264
+ out['beh.scroll.signature'] = this.#scrollEvents === 0 ? null : [...this.#wheelDeltaModes].sort().join(',');
265
+ out['beh.touch.points'] = this.#touchPoints;
266
+ out['beh.touch.radius_max'] = this.#maxTouchRadius === 0 ? null : round(this.#maxTouchRadius, 1);
267
+ out['beh.touch.force_seen'] = this.#touchPoints === 0 ? null : this.#touchForceSeen;
268
+ out['beh.focus_changes'] = this.#focusChanges;
269
+ out['beh.fields_focused'] = this.#fieldsFocused.size;
270
+ out['beh.focus_sequence'] = this.#focusOrder.length === 0 ? null : this.#focusOrder.join('>');
271
+ out['beh.is_trusted_ratio'] = observed === 0 ? null : round(this.#trustedEvents / observed, 3);
272
+ out['beh.time_to_first_interaction'] =
273
+ this.#firstInteractionAt === undefined ? null : Math.round(this.#firstInteractionAt - this.#started);
274
+ out['beh.total_dwell'] = Math.round(now - this.#started);
275
+ return out;
276
+ }
277
+ }
278
+ /**
279
+ * Shannon entropy over the direction histogram, normalised to 0..1.
280
+ *
281
+ * Normalised so the number means the same thing however many buckets there are:
282
+ * 0 is one direction only, 1 is uniform. A raw bit count would silently change
283
+ * meaning if the bucket count were ever tuned.
284
+ */
285
+ function entropy(counts) {
286
+ const total = counts.reduce((sum, n) => sum + n, 0);
287
+ if (total === 0)
288
+ return 0;
289
+ let h = 0;
290
+ for (const count of counts) {
291
+ if (count === 0)
292
+ continue;
293
+ const p = count / total;
294
+ h -= p * Math.log2(p);
295
+ }
296
+ return h / Math.log2(counts.length);
297
+ }
298
+ function round(value, places) {
299
+ return Number(value.toFixed(places));
300
+ }
301
+ function roundOrNull(value, places) {
302
+ return value === null ? null : round(value, places);
303
+ }
package/dist/hash.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * FNV-1a, 32-bit, rendered as 8 hex characters.
3
+ *
4
+ * Not a security hash and never used as one: these values identify a rendering
5
+ * stack, not a person, and they are re-hashed with a per-tenant salt server-side
6
+ * before they are stored. A cryptographic hash in the browser would cost bundle
7
+ * size and async plumbing for no benefit here.
8
+ */
9
+ export declare function hash(input: string): string;
10
+ /**
11
+ * Stable across key insertion order, so the same environment always produces
12
+ * the same hash. JSON of the sorted pairs also makes the separator
13
+ * unambiguous — a value containing the delimiter cannot forge a collision.
14
+ */
15
+ export declare function hashRecord(record: Record<string, unknown>): string;
package/dist/hash.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * FNV-1a, 32-bit, rendered as 8 hex characters.
3
+ *
4
+ * Not a security hash and never used as one: these values identify a rendering
5
+ * stack, not a person, and they are re-hashed with a per-tenant salt server-side
6
+ * before they are stored. A cryptographic hash in the browser would cost bundle
7
+ * size and async plumbing for no benefit here.
8
+ */
9
+ export function hash(input) {
10
+ let value = 0x811c9dc5;
11
+ for (let i = 0; i < input.length; i++) {
12
+ value ^= input.charCodeAt(i);
13
+ value = Math.imul(value, 0x01000193);
14
+ }
15
+ return (value >>> 0).toString(16).padStart(8, '0');
16
+ }
17
+ /**
18
+ * Stable across key insertion order, so the same environment always produces
19
+ * the same hash. JSON of the sorted pairs also makes the separator
20
+ * unambiguous — a value containing the delimiter cannot forge a collision.
21
+ */
22
+ export function hashRecord(record) {
23
+ const entries = Object.entries(record)
24
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
25
+ .map(([key, value]) => [key, String(value)]);
26
+ return hash(JSON.stringify(entries));
27
+ }
@@ -0,0 +1,7 @@
1
+ export { runCollectors, type RunOptions } from './run.js';
2
+ export { ALL_COLLECTORS, COLLECTOR_IDS, INVASIVE_COLLECTORS } from './registry.js';
3
+ export { hash, hashRecord } from './hash.js';
4
+ export { WAVE_BUDGET_MS, type CollectionResult, type Collector, type CollectorEnv, type SignalValue, type Wave, } from './types.js';
5
+ export { stableCore, hardwareCore, browserCore, STABLE_KEYS, HARDWARE_KEYS, BROWSER_KEYS, EXCLUDED_FROM_CORE, type StableCore, type CoreTier, } from './stable-core.js';
6
+ export { BehaviorRecorder, type BehaviorSummary, type RecorderOptions, type RecorderTarget, } from './behavior/recorder.js';
7
+ export { behaviorCollector } from './signals/behavior.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { runCollectors } from './run.js';
2
+ export { ALL_COLLECTORS, COLLECTOR_IDS, INVASIVE_COLLECTORS } from './registry.js';
3
+ export { hash, hashRecord } from './hash.js';
4
+ export { WAVE_BUDGET_MS, } from './types.js';
5
+ export { stableCore, hardwareCore, browserCore, STABLE_KEYS, HARDWARE_KEYS, BROWSER_KEYS, EXCLUDED_FROM_CORE, } from './stable-core.js';
6
+ export { BehaviorRecorder, } from './behavior/recorder.js';
7
+ export { behaviorCollector } from './signals/behavior.js';
@@ -0,0 +1,39 @@
1
+ export declare function isNativeSource(source: string): boolean;
2
+ /**
3
+ * Whether a native stub carries the name its function claims to have.
4
+ *
5
+ * A `Proxy` around a native function is still reported as native — V8 hands
6
+ * back a native stub for any callable proxy — so a source check alone cannot
7
+ * see a proxy-wrapped intrinsic. It does lose the *name*, which is the tell.
8
+ * Measured on Chrome 141:
9
+ *
10
+ * Function.prototype.toString function toString() { [native code] }
11
+ * new Proxy(Function.prototype.toString) function () { [native code] }
12
+ *
13
+ * `.name` reports `toString` in both cases, because the proxy forwards the
14
+ * property. So the contradiction is between the name the function claims and
15
+ * the name the engine prints for it, and every intrinsic this package probes
16
+ * prints its name in a clean realm — verified in a browser, not assumed.
17
+ *
18
+ * Bound functions print anonymously too, and legitimately. Their names start
19
+ * with `bound `, which is the documented exemption rather than a guess.
20
+ */
21
+ export declare function nameMatchesSource(name: string, source: string): boolean;
22
+ /**
23
+ * Whether a function has a proxy standing in front of it.
24
+ *
25
+ * `undefined` means the question could not be asked — the call did not throw, or
26
+ * the engine gave us no stack — and is never reported as clean.
27
+ */
28
+ export declare function interposesApplyTrap(target: unknown): boolean | undefined;
29
+ /**
30
+ * Whether this engine names a proxy's `apply` trap on a stack at all.
31
+ *
32
+ * The positive control for `interposesApplyTrap`, given a native that throws on
33
+ * an illegal receiver. Wraps it in a proxy of its own — never installed
34
+ * anywhere, so nothing observable changes — and checks that the technique sees
35
+ * it. A caller that gets `false` here must report "cannot tell" rather than
36
+ * "clean", because a silent failure of this technique looks exactly like a
37
+ * browser with nothing to hide.
38
+ */
39
+ export declare function applyTrapIsObservable(native: (...args: unknown[]) => unknown): boolean;
package/dist/native.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Whether a function's source is a genuine native stub.
3
+ *
4
+ * `String.includes('[native code]')` is the obvious test and it is wrong: it
5
+ * asks whether the text appears *anywhere*, so any replacement whose own source
6
+ * happens to contain those fifteen characters passes. That is not a theoretical
7
+ * evasion — a replacement only has to mention the string to be waved through:
8
+ *
9
+ * const patched = () => 'function () { [native code] }'
10
+ *
11
+ * stringifies to its own source, which contains `[native code]`, and a
12
+ * substring test calls it native.
13
+ *
14
+ * Engines emit a fixed shape instead, and it is worth matching properly:
15
+ *
16
+ * function max() { [native code] } V8, ordinary native
17
+ * function () { [native code] } V8, bound function
18
+ * function get webdriver() { [native code] } V8, accessor
19
+ * function max() {\n [native code]\n} SpiderMonkey / JavaScriptCore
20
+ *
21
+ * Anchored at both ends, so the text has to *be* the stub rather than contain
22
+ * it. Whitespace is loose because engines disagree about newlines, and the name
23
+ * is loose because it carries `get`/`set`/`bound` prefixes and can be empty.
24
+ */
25
+ const NATIVE_SOURCE = /^\s*function[\s\w$]*\([^)]*\)\s*\{\s*\[native code\]\s*\}\s*$/;
26
+ export function isNativeSource(source) {
27
+ return NATIVE_SOURCE.test(source);
28
+ }
29
+ /**
30
+ * Whether a native stub carries the name its function claims to have.
31
+ *
32
+ * A `Proxy` around a native function is still reported as native — V8 hands
33
+ * back a native stub for any callable proxy — so a source check alone cannot
34
+ * see a proxy-wrapped intrinsic. It does lose the *name*, which is the tell.
35
+ * Measured on Chrome 141:
36
+ *
37
+ * Function.prototype.toString function toString() { [native code] }
38
+ * new Proxy(Function.prototype.toString) function () { [native code] }
39
+ *
40
+ * `.name` reports `toString` in both cases, because the proxy forwards the
41
+ * property. So the contradiction is between the name the function claims and
42
+ * the name the engine prints for it, and every intrinsic this package probes
43
+ * prints its name in a clean realm — verified in a browser, not assumed.
44
+ *
45
+ * Bound functions print anonymously too, and legitimately. Their names start
46
+ * with `bound `, which is the documented exemption rather than a guess.
47
+ */
48
+ export function nameMatchesSource(name, source) {
49
+ if (name === '' || name.startsWith('bound '))
50
+ return true;
51
+ return new RegExp(`^\\s*function\\s+${escapeName(name)}\\s*\\(`).test(source);
52
+ }
53
+ /** A function name can contain `$`, which is a regex metacharacter. */
54
+ function escapeName(name) {
55
+ return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
56
+ }
57
+ /**
58
+ * The frame an engine prints for a proxy's `apply` trap.
59
+ *
60
+ * V8 describes the trap rather than the wrapper, so this text comes from the
61
+ * engine and not from anything the page controls — it survives minification of
62
+ * the probe and cannot be renamed by the plugin. Measured shapes:
63
+ *
64
+ * at Object.apply a bare Proxy handler
65
+ * at newHandler.<computed> [as apply] (eval at <anonymous>) stealth's own
66
+ *
67
+ * Deliberately not matched against the probe's own call frame: the probe invokes
68
+ * through `.call`, never `.apply`, so its own frames cannot contain this word.
69
+ */
70
+ const APPLY_TRAP = /\bapply\b/;
71
+ /**
72
+ * How many frames from the top may hold the trap.
73
+ *
74
+ * Not one, which a first version of this assumed and measurement disproved. V8
75
+ * prints the native's *own* frame for a JavaScript builtin and omits it for a
76
+ * Blink DOM method, so the trap sits one place lower in the first case:
77
+ *
78
+ * Map.prototype.size through a proxy [0] at get size [1] at Object.apply
79
+ * canvas.toDataURL through a proxy [0] at Object.apply
80
+ *
81
+ * Three covers both, plus a handler that calls through `Reflect.apply`. It stays
82
+ * small because the frames below belong to the caller, and a page that reached
83
+ * this collector through something of its own named `apply` would otherwise
84
+ * accuse itself.
85
+ *
86
+ * The window does not have to be right by argument, which is the point of
87
+ * `applyTrapIsObservable`: the control runs this same rule at this same stack
88
+ * depth, so an engine where the indexing does not hold fails the control and the
89
+ * caller abstains instead of reporting everyone clean.
90
+ */
91
+ const TRAP_WINDOW = 3;
92
+ /**
93
+ * Whether a function has a proxy standing in front of it.
94
+ *
95
+ * `undefined` means the question could not be asked — the call did not throw, or
96
+ * the engine gave us no stack — and is never reported as clean.
97
+ */
98
+ export function interposesApplyTrap(target) {
99
+ if (typeof target !== 'function')
100
+ return undefined;
101
+ try {
102
+ // An illegal receiver. Every candidate this is used on validates `this`
103
+ // before it looks at its arguments, so one call with no arguments is enough
104
+ // and nothing observable happens on the way.
105
+ ;
106
+ target.call(null);
107
+ return undefined;
108
+ }
109
+ catch (error) {
110
+ const stack = error instanceof Error ? error.stack : undefined;
111
+ if (typeof stack !== 'string' || stack === '')
112
+ return undefined;
113
+ const frames = stack
114
+ .split('\n')
115
+ .filter((line) => /^\s*at\s/.test(line))
116
+ .slice(0, TRAP_WINDOW);
117
+ if (frames.length === 0)
118
+ return undefined;
119
+ return frames.some((line) => APPLY_TRAP.test(line));
120
+ }
121
+ }
122
+ /**
123
+ * Whether this engine names a proxy's `apply` trap on a stack at all.
124
+ *
125
+ * The positive control for `interposesApplyTrap`, given a native that throws on
126
+ * an illegal receiver. Wraps it in a proxy of its own — never installed
127
+ * anywhere, so nothing observable changes — and checks that the technique sees
128
+ * it. A caller that gets `false` here must report "cannot tell" rather than
129
+ * "clean", because a silent failure of this technique looks exactly like a
130
+ * browser with nothing to hide.
131
+ */
132
+ export function applyTrapIsObservable(native) {
133
+ try {
134
+ const wrapped = new Proxy(native, {
135
+ apply: (fn, self, args) => Reflect.apply(fn, self, args),
136
+ });
137
+ return interposesApplyTrap(wrapped) === true;
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ }
@@ -0,0 +1,57 @@
1
+ import type { Collector } from './types.js';
2
+ /**
3
+ * Order within a wave is the order they run, so the cheapest and most
4
+ * universally available come first: when a budget runs out, what survives
5
+ * should be the signals present on every device rather than the exotic ones.
6
+ */
7
+ export declare const ALL_COLLECTORS: readonly Collector[];
8
+ export declare const COLLECTOR_IDS: readonly string[];
9
+ /**
10
+ * Collectors a deployment must ask for by name.
11
+ *
12
+ * Deliberately absent from `ALL_COLLECTORS`, so no default enables them and no
13
+ * tenant gets them by forgetting to opt out. Everything here reads something
14
+ * about the person rather than about their browser, which makes enabling it a
15
+ * decision somebody has to make on purpose and be able to point at a consent
16
+ * for.
17
+ *
18
+ * Pass them explicitly:
19
+ *
20
+ * runCollectors(env, { collectors: [...ALL_COLLECTORS, ...INVASIVE_COLLECTORS] })
21
+ *
22
+ * The split is the product position, not a technical one. `docs/` says what
23
+ * Magic Shield collects by default, and this is the list that is not in it.
24
+ */
25
+ export declare const INVASIVE_COLLECTORS: readonly Collector[];
26
+ /**
27
+ * Removed: localPortsCollector.
28
+ *
29
+ * It probed nine loopback ports for automation drivers, remote-control tools
30
+ * and anti-detect control APIs, and it was measured from the wrong place. The
31
+ * fp-lab page is served from `127.0.0.1`, so the probe was local reaching
32
+ * local — the one case Chrome does not gate — and it reported nine ports
33
+ * cleanly probed.
34
+ *
35
+ * From a public origin, which is every real deployment, Chrome 153 answers
36
+ * `navigator.permissions.query({ name: 'local-network-access' })` with
37
+ * **prompt**. Measured from `https://example.com` against known ports:
38
+ *
39
+ * 8791 open hung until aborted at 1.5 s
40
+ * 9515 closed Failed to fetch in 10 ms
41
+ * 18123 closed hung until aborted at 2.0 s
42
+ *
43
+ * So it does not work, and the way it fails is worse than not working. The
44
+ * open port did not complete and two results were indistinguishable hangs, so
45
+ * the answers are noise; and a pending permission decision blocks, which on a
46
+ * login page means burning the deep wave and showing the visitor a prompt
47
+ * asking to access their local network. Nobody accepts that prompt, and asking
48
+ * is itself the harm.
49
+ *
50
+ * Not kept behind a flag. A collector that prompts cannot be enabled by a
51
+ * deployment without the person seeing it, which is the line this set does not
52
+ * cross — every other member runs silently.
53
+ *
54
+ * If it ever comes back it needs the permission requested honestly, at a
55
+ * moment the person understands, which is a product decision rather than a
56
+ * collector.
57
+ */