@magield/collectors 0.0.0-stage → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +27 -0
- package/dist/behavior/recorder.d.ts +33 -0
- package/dist/behavior/recorder.js +303 -0
- package/dist/hash.d.ts +15 -0
- package/dist/hash.js +27 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/native.d.ts +39 -0
- package/dist/native.js +142 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +108 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.js +128 -0
- package/dist/signals/adblock.d.ts +2 -0
- package/dist/signals/adblock.js +135 -0
- package/dist/signals/apps.d.ts +2 -0
- package/dist/signals/apps.js +191 -0
- package/dist/signals/audio.d.ts +16 -0
- package/dist/signals/audio.js +63 -0
- package/dist/signals/automation.d.ts +2 -0
- package/dist/signals/automation.js +346 -0
- package/dist/signals/behavior.d.ts +17 -0
- package/dist/signals/behavior.js +31 -0
- package/dist/signals/capabilities.d.ts +2 -0
- package/dist/signals/capabilities.js +235 -0
- package/dist/signals/devices.d.ts +2 -0
- package/dist/signals/devices.js +119 -0
- package/dist/signals/display.d.ts +2 -0
- package/dist/signals/display.js +101 -0
- package/dist/signals/errors.d.ts +2 -0
- package/dist/signals/errors.js +84 -0
- package/dist/signals/features.d.ts +2 -0
- package/dist/signals/features.js +951 -0
- package/dist/signals/fonts.d.ts +2 -0
- package/dist/signals/fonts.js +128 -0
- package/dist/signals/integrity.d.ts +2 -0
- package/dist/signals/integrity.js +216 -0
- package/dist/signals/math.d.ts +2 -0
- package/dist/signals/math.js +43 -0
- package/dist/signals/navigator.d.ts +10 -0
- package/dist/signals/navigator.js +81 -0
- package/dist/signals/pointer.d.ts +2 -0
- package/dist/signals/pointer.js +93 -0
- package/dist/signals/render.d.ts +12 -0
- package/dist/signals/render.js +214 -0
- package/dist/signals/screen.d.ts +9 -0
- package/dist/signals/screen.js +80 -0
- package/dist/signals/storage.d.ts +33 -0
- package/dist/signals/storage.js +100 -0
- package/dist/signals/timezone.d.ts +9 -0
- package/dist/signals/timezone.js +63 -0
- package/dist/signals/webrtc.d.ts +2 -0
- package/dist/signals/webrtc.js +168 -0
- package/dist/stable-core.d.ts +128 -0
- package/dist/stable-core.js +187 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +15 -0
- package/package.json +31 -3
- package/README.md +0 -3
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|
package/dist/native.d.ts
ADDED
|
@@ -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
|
+
*/
|