@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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { hash } from '../hash.js';
|
|
2
|
+
/**
|
|
3
|
+
* Which fonts are installed, measured rather than asked.
|
|
4
|
+
*
|
|
5
|
+
* There is no API for this. The measurement is indirect: render a string in a
|
|
6
|
+
* candidate font with a known fallback behind it, and compare its width and
|
|
7
|
+
* height against the fallback alone. A different size means the candidate
|
|
8
|
+
* resolved; an identical one means it did not and the fallback drew.
|
|
9
|
+
*
|
|
10
|
+
* The set is a strong platform signal — a stock Windows install, a Mac with
|
|
11
|
+
* Office, and a Linux container each carry a distinct list — and it is one of
|
|
12
|
+
* the few things an anti-detect browser rarely bothers to fake, because faking
|
|
13
|
+
* it convincingly means faking per-glyph metrics rather than a string.
|
|
14
|
+
*
|
|
15
|
+
* Costs are real and the list is sized for them: each candidate is three
|
|
16
|
+
* measurements, and the DOM work is the reason this sits in the deep wave.
|
|
17
|
+
*/
|
|
18
|
+
/** The three generics every measurement is compared against. */
|
|
19
|
+
const BASELINES = ['monospace', 'sans-serif', 'serif'];
|
|
20
|
+
/**
|
|
21
|
+
* Candidates chosen to separate platforms rather than to be exhaustive.
|
|
22
|
+
*
|
|
23
|
+
* Each is shipped by default on at least one platform and absent from the
|
|
24
|
+
* others, so presence is informative in both directions. A longer list buys
|
|
25
|
+
* little: fonts correlate heavily, and the cost is linear.
|
|
26
|
+
*/
|
|
27
|
+
const CANDIDATES = [
|
|
28
|
+
// macOS
|
|
29
|
+
'Apple Chancery', 'Apple Color Emoji', 'AppleGothic', 'Avenir', 'Baskerville',
|
|
30
|
+
'Big Caslon', 'Chalkboard', 'Cochin', 'Copperplate', 'Didot', 'Futura',
|
|
31
|
+
'Geneva', 'Helvetica Neue', 'Herculanum', 'Hoefler Text', 'Luminari',
|
|
32
|
+
'Marker Felt', 'Menlo', 'Monaco', 'Optima', 'Papyrus', 'SF Pro', 'Skia', 'Zapfino',
|
|
33
|
+
// Windows
|
|
34
|
+
'Arial Black', 'Bahnschrift', 'Calibri', 'Cambria', 'Candara', 'Consolas',
|
|
35
|
+
'Constantia', 'Corbel', 'Ebrima', 'Franklin Gothic Medium', 'Gabriola',
|
|
36
|
+
'Gadugi', 'Georgia', 'HoloLens MDL2 Assets', 'Impact', 'Ink Free',
|
|
37
|
+
'Javanese Text', 'Leelawadee UI', 'Lucida Console', 'Lucida Sans Unicode',
|
|
38
|
+
'MS Gothic', 'MV Boli', 'Malgun Gothic', 'Marlett', 'Microsoft Himalaya',
|
|
39
|
+
'Microsoft JhengHei', 'Microsoft YaHei', 'MingLiU-ExtB', 'Mongolian Baiti',
|
|
40
|
+
'Myanmar Text', 'Nirmala UI', 'Palatino Linotype', 'Segoe UI', 'SimSun',
|
|
41
|
+
'Sitka', 'Sylfaen', 'Tahoma', 'Trebuchet MS', 'Yu Gothic',
|
|
42
|
+
// Office, which is itself a signal about the kind of machine
|
|
43
|
+
'Agency FB', 'Bookman Old Style', 'Bookshelf Symbol 7', 'Garamond',
|
|
44
|
+
'Goudy Old Style', 'Haettenschweiler', 'Rockwell',
|
|
45
|
+
// Linux and common free families
|
|
46
|
+
'Cantarell', 'DejaVu Sans', 'DejaVu Serif', 'FreeSans', 'Liberation Mono',
|
|
47
|
+
'Liberation Sans', 'Liberation Serif', 'Nimbus Roman', 'Noto Sans',
|
|
48
|
+
'Ubuntu', 'URW Bookman',
|
|
49
|
+
// Android
|
|
50
|
+
'Droid Sans', 'Roboto', 'sans-serif-thin',
|
|
51
|
+
];
|
|
52
|
+
/** Long enough to accumulate per-glyph differences, short enough to measure fast. */
|
|
53
|
+
const PROBE = 'mmmmmmmmmmlli WWWWWWW 0123456789';
|
|
54
|
+
export const fontsCollector = {
|
|
55
|
+
id: 'fonts',
|
|
56
|
+
wave: 'deep',
|
|
57
|
+
optional: true,
|
|
58
|
+
collect({ document, window }) {
|
|
59
|
+
const out = {};
|
|
60
|
+
let holder;
|
|
61
|
+
try {
|
|
62
|
+
// One offscreen holder, one span per baseline, reused across candidates.
|
|
63
|
+
// Creating an element per font is what makes naive versions of this cost
|
|
64
|
+
// hundreds of milliseconds.
|
|
65
|
+
holder = document.createElement('div');
|
|
66
|
+
holder.setAttribute('style', 'position:absolute;left:-9999px;top:-9999px;visibility:hidden;' +
|
|
67
|
+
'font-size:72px;line-height:normal;white-space:nowrap');
|
|
68
|
+
document.body.appendChild(holder);
|
|
69
|
+
const spans = {};
|
|
70
|
+
const base = {};
|
|
71
|
+
for (const baseline of BASELINES) {
|
|
72
|
+
const span = document.createElement('span');
|
|
73
|
+
span.style.fontFamily = baseline;
|
|
74
|
+
span.textContent = PROBE;
|
|
75
|
+
holder.appendChild(span);
|
|
76
|
+
spans[baseline] = span;
|
|
77
|
+
base[baseline] = { w: span.offsetWidth, h: span.offsetHeight };
|
|
78
|
+
}
|
|
79
|
+
const present = [];
|
|
80
|
+
for (const candidate of CANDIDATES) {
|
|
81
|
+
// A candidate counts as installed if it shifts the metrics against ANY
|
|
82
|
+
// baseline. Requiring all three misses fonts that happen to match one
|
|
83
|
+
// generic's metrics, which is common for the generic a platform maps
|
|
84
|
+
// to its own default.
|
|
85
|
+
let found = false;
|
|
86
|
+
for (const baseline of BASELINES) {
|
|
87
|
+
const span = spans[baseline];
|
|
88
|
+
const reference = base[baseline];
|
|
89
|
+
if (span === undefined || reference === undefined)
|
|
90
|
+
continue;
|
|
91
|
+
span.style.fontFamily = `'${candidate}',${baseline}`;
|
|
92
|
+
if (span.offsetWidth !== reference.w || span.offsetHeight !== reference.h) {
|
|
93
|
+
found = true;
|
|
94
|
+
}
|
|
95
|
+
span.style.fontFamily = baseline;
|
|
96
|
+
if (found)
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
if (found)
|
|
100
|
+
present.push(candidate);
|
|
101
|
+
}
|
|
102
|
+
out['fonts.count'] = present.length;
|
|
103
|
+
out['fonts.probed'] = CANDIDATES.length;
|
|
104
|
+
out['fonts.hash'] = hash(present.join('|'));
|
|
105
|
+
// A device with no candidate at all is not a device with unusual taste in
|
|
106
|
+
// typography — it is a headless container, or a client that broke the
|
|
107
|
+
// measurement. Distinguishing that from a short list needs the count,
|
|
108
|
+
// which is why it is reported beside the hash rather than folded in.
|
|
109
|
+
const ratio = present.length / CANDIDATES.length;
|
|
110
|
+
out['fonts.ratio'] = Number(ratio.toFixed(3));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
out['fonts.count'] = null;
|
|
114
|
+
out['fonts.hash'] = null;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
try {
|
|
118
|
+
if (holder !== undefined && holder.parentNode !== null)
|
|
119
|
+
holder.parentNode.removeChild(holder);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// A document that will not let us clean up is not worth a thrown signal.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
void window;
|
|
126
|
+
return out;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { hash } from '../hash.js';
|
|
2
|
+
import { isNativeSource, nameMatchesSource } from '../native.js';
|
|
3
|
+
/**
|
|
4
|
+
* Native integrity, checked from a realm the page did not get to patch.
|
|
5
|
+
*
|
|
6
|
+
* `bot.patchedNatives` asks `Function.prototype.toString` whether the natives
|
|
7
|
+
* have been replaced — including whether `Function.prototype.toString` itself
|
|
8
|
+
* has been replaced. That is circular, and it is the first thing a stealth
|
|
9
|
+
* plugin breaks: `puppeteer-extra-plugin-stealth` carries a `patchToString`
|
|
10
|
+
* helper whose whole purpose is to make replaced functions stringify as
|
|
11
|
+
* `[native code]`. Once it runs, every probe over there reads clean.
|
|
12
|
+
*
|
|
13
|
+
* A fresh same-origin iframe gets its own realm with its own untouched
|
|
14
|
+
* intrinsics. Stringifying the *main* realm's functions with the *iframe's*
|
|
15
|
+
* `toString` asks the question without using the answer, so a patch applied
|
|
16
|
+
* *by page script* does not survive the comparison.
|
|
17
|
+
*
|
|
18
|
+
* Specified as `iframe.native_recovery` in docs/signals.md section 4 and never
|
|
19
|
+
* built until now.
|
|
20
|
+
*
|
|
21
|
+
* **What this does not catch, measured rather than reasoned about.** A tool
|
|
22
|
+
* driving the browser over CDP does not patch from page script. It installs its
|
|
23
|
+
* evasions with `Page.addScriptToEvaluateOnNewDocument`, which runs in *every*
|
|
24
|
+
* new document — including this iframe. Measured against a real
|
|
25
|
+
* `puppeteer-extra-plugin-stealth` on Chrome 141: the iframe reports the same
|
|
26
|
+
* spoofed `Intel Iris OpenGL Engine` as the page, and all six probes read clean
|
|
27
|
+
* while `WebGLRenderingContext.prototype.getParameter` is demonstrably wrapped.
|
|
28
|
+
* There is no untouched realm to borrow, so the comparison agrees with itself.
|
|
29
|
+
*
|
|
30
|
+
* That is a real ceiling and not a bug to fix here: any realm this collector can
|
|
31
|
+
* create, the debugger can reach first. The probe that does see through it is
|
|
32
|
+
* `bot.proxiedNatives` in automation.ts, which reads the stack the engine builds
|
|
33
|
+
* rather than asking the page anything. This collector still earns its place
|
|
34
|
+
* against page-script patching — user-script managers, injected XSS, and the
|
|
35
|
+
* older plugins that patch without a debugger attached.
|
|
36
|
+
*
|
|
37
|
+
* **This is the only collector that touches the document.** Everything else
|
|
38
|
+
* creates elements without attaching them (see render.ts), but an iframe has no
|
|
39
|
+
* `contentWindow` until it is in the tree, so there is no version of this that
|
|
40
|
+
* does not. It is appended hidden, read, and removed in a `finally` — and it
|
|
41
|
+
* runs in the warm wave, because the instant wave's whole budget is 5 ms across
|
|
42
|
+
* seven collectors and nothing interrupts a collector that overruns.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Split by what patching each one would mean.
|
|
46
|
+
*
|
|
47
|
+
* Privacy extensions genuinely replace canvas and WebGL readback — that is
|
|
48
|
+
* their entire function, and treating it as concealment would flag the most
|
|
49
|
+
* privacy-conscious users on the web. Almost nothing legitimate replaces
|
|
50
|
+
* `Function.prototype.toString`: its only use is to make *other* replacements
|
|
51
|
+
* invisible. The exception is zone.js, which every Angular app loads, and it is
|
|
52
|
+
* recognised by name below rather than counted. So the two are counted separately and weighted separately, rather
|
|
53
|
+
* than summed into a number that cannot tell a Brave user from a stealth
|
|
54
|
+
* plugin.
|
|
55
|
+
*/
|
|
56
|
+
const CONCEALMENT = [
|
|
57
|
+
{ name: 'Function.toString', path: ['Function', 'prototype', 'toString'] },
|
|
58
|
+
{ name: 'Object.getOwnPropertyDescriptor', path: ['Object', 'getOwnPropertyDescriptor'] },
|
|
59
|
+
{ name: 'Reflect.get', path: ['Reflect', 'get'] },
|
|
60
|
+
];
|
|
61
|
+
const SURFACE = [
|
|
62
|
+
{ name: 'Canvas.toDataURL', path: ['HTMLCanvasElement', 'prototype', 'toDataURL'] },
|
|
63
|
+
{ name: 'Canvas.getContext', path: ['HTMLCanvasElement', 'prototype', 'getContext'] },
|
|
64
|
+
{ name: 'WebGL.getParameter', path: ['WebGLRenderingContext', 'prototype', 'getParameter'] },
|
|
65
|
+
];
|
|
66
|
+
export const integrityCollector = {
|
|
67
|
+
id: 'integrity',
|
|
68
|
+
wave: 'warm',
|
|
69
|
+
// Tenants whose CSP forbids frames, or who would rather not have one created
|
|
70
|
+
// at all, can turn this off by id without losing the rest of the bot family.
|
|
71
|
+
optional: true,
|
|
72
|
+
collect({ window, document }) {
|
|
73
|
+
const out = {};
|
|
74
|
+
const pristine = borrowToString(window, document);
|
|
75
|
+
if (pristine === undefined) {
|
|
76
|
+
// No realm to compare against — a `frame-src` CSP, a sandboxed document,
|
|
77
|
+
// a page torn down mid-collection. Zero probes is the honest report, and
|
|
78
|
+
// the consistency check abstains on it. Reporting zero *divergences*
|
|
79
|
+
// instead would be a clean bill of health nobody earned.
|
|
80
|
+
out['bot.realmProbes'] = 0;
|
|
81
|
+
out['bot.realmDivergence'] = 0;
|
|
82
|
+
out['bot.realmDetail'] = null;
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
const diverged = [];
|
|
86
|
+
const zoned = [];
|
|
87
|
+
let probes = 0;
|
|
88
|
+
for (const spec of [...CONCEALMENT, ...SURFACE]) {
|
|
89
|
+
const target = resolve(window, spec.path);
|
|
90
|
+
if (typeof target !== 'function')
|
|
91
|
+
continue;
|
|
92
|
+
probes++;
|
|
93
|
+
try {
|
|
94
|
+
if (isNativeIn(pristine, target))
|
|
95
|
+
continue;
|
|
96
|
+
// zone.js — loaded by every Angular app — replaces
|
|
97
|
+
// `Function.prototype.toString` so the functions it patches still read
|
|
98
|
+
// as native. That is the one legitimate concealment there is, and
|
|
99
|
+
// counting it put +38 on every visitor of every Angular site. Its
|
|
100
|
+
// wrapper keeps the original under `__zone_symbol__OriginalDelegate`;
|
|
101
|
+
// when that original is itself native, the replacement is zone's and
|
|
102
|
+
// nothing else. A tool that wraps again on top replaces the function
|
|
103
|
+
// and loses the marker, so this exempts zone and nothing layered on it.
|
|
104
|
+
// (Stealth plugins are not caught here anyway: they patch the borrowed
|
|
105
|
+
// realm too. `bot.proxiedNatives` is what sees them.)
|
|
106
|
+
if (isZoneWrapper(window, pristine, target)) {
|
|
107
|
+
zoned.push(spec.name);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
diverged.push(spec.name);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
diverged.push(spec.name);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Reported, never scored: which natives zone.js owns on this page, so an
|
|
117
|
+
// explanation can say why the realm looked touched and still read clean.
|
|
118
|
+
out['bot.realmZone'] = zoned.join(',') || null;
|
|
119
|
+
out['bot.realmProbes'] = probes;
|
|
120
|
+
out['bot.realmDivergence'] = diverged.length;
|
|
121
|
+
out['bot.realmDetail'] = diverged.join(',') || null;
|
|
122
|
+
// Counted apart so a check can weigh "hid the evidence" differently from
|
|
123
|
+
// "blocked fingerprinting", which is the difference between an attacker and
|
|
124
|
+
// a customer with an ad blocker.
|
|
125
|
+
out['bot.realmConcealment'] = diverged.filter((name) => CONCEALMENT.some((spec) => spec.name === name)).length;
|
|
126
|
+
// The companion to `bot.spoofHash`: which natives a tool had to wrap, as a
|
|
127
|
+
// cluster key. Kept separate from the navigator one rather than combined,
|
|
128
|
+
// because they identify different things — what a tool rewrites about the
|
|
129
|
+
// browser, and what it wraps to hide the rewriting — and a tool can do
|
|
130
|
+
// either without the other.
|
|
131
|
+
out['bot.realmHash'] = diverged.length === 0 ? null : hash(diverged.join(','));
|
|
132
|
+
return out;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Two ways a function fails to be native. The source is not a native stub at
|
|
137
|
+
* all — an ordinary replacement — or it is a native stub that has lost the name
|
|
138
|
+
* the function still claims, which is what a Proxy around an intrinsic looks
|
|
139
|
+
* like. The second matters because `patchToString` installs exactly that, and a
|
|
140
|
+
* source check alone reads it as clean.
|
|
141
|
+
*/
|
|
142
|
+
function isNativeIn(pristine, target) {
|
|
143
|
+
const source = pristine.call(target);
|
|
144
|
+
const claimed = typeof target.name === 'string' ? target.name : '';
|
|
145
|
+
return isNativeSource(source) && nameMatchesSource(claimed, source);
|
|
146
|
+
}
|
|
147
|
+
/** zone.js's own wrapper over a native, and only that. See the call site. */
|
|
148
|
+
function isZoneWrapper(window, pristine, target) {
|
|
149
|
+
const zone = window.Zone;
|
|
150
|
+
if (zone === undefined || typeof zone.__symbol__ !== 'function')
|
|
151
|
+
return false;
|
|
152
|
+
const original = target.__zone_symbol__OriginalDelegate;
|
|
153
|
+
if (typeof original !== 'function' || original === target)
|
|
154
|
+
return false;
|
|
155
|
+
try {
|
|
156
|
+
return isNativeIn(pristine, original);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A `Function.prototype.toString` from a realm the page has not touched.
|
|
164
|
+
*
|
|
165
|
+
* Every step can fail in an ordinary browser — CSP, a detached document, a
|
|
166
|
+
* sandbox without `allow-same-origin` — and every failure means "cannot tell",
|
|
167
|
+
* never "clean".
|
|
168
|
+
*/
|
|
169
|
+
function borrowToString(window, document) {
|
|
170
|
+
let frame;
|
|
171
|
+
try {
|
|
172
|
+
const host = document.body ?? document.documentElement;
|
|
173
|
+
if (host === null || host === undefined)
|
|
174
|
+
return undefined;
|
|
175
|
+
frame = document.createElement('iframe');
|
|
176
|
+
// Not `display: none`: a display:none frame does not load a document in
|
|
177
|
+
// every engine, and without a document there is no realm to borrow from.
|
|
178
|
+
frame.setAttribute('style', 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none;left:-9999px');
|
|
179
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
180
|
+
frame.setAttribute('tabindex', '-1');
|
|
181
|
+
// Left as about:blank. A same-origin frame inherits the origin without a
|
|
182
|
+
// network request, which is what makes this cheap enough for the warm wave.
|
|
183
|
+
host.appendChild(frame);
|
|
184
|
+
const realm = frame.contentWindow;
|
|
185
|
+
const borrowed = realm?.Function?.prototype?.toString;
|
|
186
|
+
// Bound to the iframe's own realm before the frame is removed, so the
|
|
187
|
+
// reference stays usable after the element is gone.
|
|
188
|
+
return typeof borrowed === 'function' ? borrowed : undefined;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
try {
|
|
195
|
+
frame?.remove();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// A frame we cannot remove is a leaked element, not a failed measurement.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Walks a dotted path without invoking anything, as features.ts does. */
|
|
203
|
+
function resolve(window, path) {
|
|
204
|
+
let current = window;
|
|
205
|
+
for (const key of path) {
|
|
206
|
+
if (current === null || current === undefined)
|
|
207
|
+
return undefined;
|
|
208
|
+
try {
|
|
209
|
+
current = current[key];
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return current;
|
|
216
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { hashRecord } from '../hash.js';
|
|
2
|
+
/**
|
|
3
|
+
* Floating-point quirks.
|
|
4
|
+
*
|
|
5
|
+
* Transcendental functions are not specified to the last bit, so their results
|
|
6
|
+
* differ between engines, libm implementations and CPU architectures. Nothing
|
|
7
|
+
* a spoofing tool normally thinks to patch, and cheap to collect — it is pure
|
|
8
|
+
* arithmetic with no DOM involved.
|
|
9
|
+
*/
|
|
10
|
+
const PROBES = {
|
|
11
|
+
acos: () => Math.acos(0.123456789),
|
|
12
|
+
acosh: () => Math.acosh(1e308),
|
|
13
|
+
asin: () => Math.asin(0.123456789),
|
|
14
|
+
asinh: () => Math.asinh(1),
|
|
15
|
+
atanh: () => Math.atanh(0.5),
|
|
16
|
+
cbrt: () => Math.cbrt(100),
|
|
17
|
+
cosh: () => Math.cosh(1),
|
|
18
|
+
expm1: () => Math.expm1(1),
|
|
19
|
+
log1p: () => Math.log1p(10),
|
|
20
|
+
sinh: () => Math.sinh(1),
|
|
21
|
+
tanh: () => Math.tanh(1),
|
|
22
|
+
pow: () => Math.pow(Math.PI, -100),
|
|
23
|
+
exp: () => Math.exp(1),
|
|
24
|
+
sin: () => Math.sin(1e10),
|
|
25
|
+
tan: () => Math.tan(1e10),
|
|
26
|
+
};
|
|
27
|
+
export const mathCollector = {
|
|
28
|
+
id: 'math',
|
|
29
|
+
wave: 'instant',
|
|
30
|
+
collect() {
|
|
31
|
+
const results = {};
|
|
32
|
+
for (const [name, probe] of Object.entries(PROBES)) {
|
|
33
|
+
try {
|
|
34
|
+
results[name] = String(probe());
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
results[name] = 'error';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const out = { 'math.quirksHash': hashRecord(results) };
|
|
41
|
+
return out;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Collector } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The navigator surface.
|
|
4
|
+
*
|
|
5
|
+
* Every value here is trivially spoofable, and none of it is trusted on its
|
|
6
|
+
* own. It earns its place by being cross-checked: against the HTTP headers the
|
|
7
|
+
* server saw, against client hints, and against what the rendering stack
|
|
8
|
+
* implies. Contradictions between these sources are the actual signal.
|
|
9
|
+
*/
|
|
10
|
+
export declare const navigatorCollector: Collector;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { hashRecord } from '../hash.js';
|
|
2
|
+
/**
|
|
3
|
+
* The navigator surface.
|
|
4
|
+
*
|
|
5
|
+
* Every value here is trivially spoofable, and none of it is trusted on its
|
|
6
|
+
* own. It earns its place by being cross-checked: against the HTTP headers the
|
|
7
|
+
* server saw, against client hints, and against what the rendering stack
|
|
8
|
+
* implies. Contradictions between these sources are the actual signal.
|
|
9
|
+
*/
|
|
10
|
+
export const navigatorCollector = {
|
|
11
|
+
id: 'nav',
|
|
12
|
+
wave: 'instant',
|
|
13
|
+
collect({ navigator }) {
|
|
14
|
+
const out = {
|
|
15
|
+
'nav.userAgent': navigator.userAgent ?? null,
|
|
16
|
+
'nav.platform': read(() => navigator.platform),
|
|
17
|
+
'nav.vendor': read(() => navigator.vendor),
|
|
18
|
+
'nav.language': navigator.language ?? null,
|
|
19
|
+
'nav.languages': (navigator.languages ?? []).join(','),
|
|
20
|
+
'nav.hardwareConcurrency': read(() => navigator.hardwareConcurrency),
|
|
21
|
+
'nav.deviceMemory': read(() => navigator.deviceMemory),
|
|
22
|
+
'nav.maxTouchPoints': read(() => navigator.maxTouchPoints),
|
|
23
|
+
'nav.cookieEnabled': read(() => navigator.cookieEnabled),
|
|
24
|
+
'nav.pdfViewerEnabled': read(() => navigator.pdfViewerEnabled),
|
|
25
|
+
'nav.doNotTrack': read(() => navigator.doNotTrack),
|
|
26
|
+
// Honest drivers set this. It is never enough on its own — every stealth
|
|
27
|
+
// plugin deletes it — but a `true` is close to conclusive.
|
|
28
|
+
'nav.webdriver': read(() => navigator.webdriver),
|
|
29
|
+
// Brave ships `navigator.brave` deliberately so that sites can identify
|
|
30
|
+
// it, which makes this the vendor's own answer rather than an inference.
|
|
31
|
+
// It is collected as a fact and scored at nothing: a privacy browser is
|
|
32
|
+
// a privacy-conscious person, and what a tenant does about that is the
|
|
33
|
+
// tenant's policy, not this engine's opinion.
|
|
34
|
+
//
|
|
35
|
+
// Anyone who deletes it is simply not identified as Brave, which is the
|
|
36
|
+
// correct outcome — they are then judged on everything else, like any
|
|
37
|
+
// other client.
|
|
38
|
+
'nav.brave': read(() => 'brave' in navigator),
|
|
39
|
+
};
|
|
40
|
+
// Plugin *shape* is browser-family specific and survives spoofing of the
|
|
41
|
+
// user agent, because faking it convincingly means faking a whole list.
|
|
42
|
+
const plugins = read(() => navigator.plugins?.length);
|
|
43
|
+
// Chromium-only. Firefox and Safari have declined to implement it, so a
|
|
44
|
+
// client claiming Gecko while exposing it is claiming two things that have
|
|
45
|
+
// never been true together.
|
|
46
|
+
out['nav.hasUserAgentData'] = navigator.userAgentData !== undefined;
|
|
47
|
+
out['nav.pluginCount'] = plugins;
|
|
48
|
+
if (typeof plugins === 'number' && plugins > 0) {
|
|
49
|
+
const names = [];
|
|
50
|
+
for (let i = 0; i < navigator.plugins.length; i++) {
|
|
51
|
+
const plugin = navigator.plugins.item(i);
|
|
52
|
+
if (plugin !== null)
|
|
53
|
+
names.push(`${plugin.name}:${plugin.filename}`);
|
|
54
|
+
}
|
|
55
|
+
out['nav.pluginsHash'] = hashRecord({ names: names.sort().join('|') });
|
|
56
|
+
}
|
|
57
|
+
// Emitted whether or not NetworkInformation exists, because its absence is
|
|
58
|
+
// itself a signal and an omitted key cannot carry it: a reader cannot tell
|
|
59
|
+
// "this browser has no such API" from "this collector never ran". Brave
|
|
60
|
+
// removes the API, and that difference is only legible if the probe says
|
|
61
|
+
// so out loud.
|
|
62
|
+
const connection = navigator
|
|
63
|
+
.connection;
|
|
64
|
+
out['nav.connectionType'] = connection?.effectiveType ?? null;
|
|
65
|
+
// Compared against the RTT the server actually measured. A claim of 4G
|
|
66
|
+
// from an address that pings like a datacenter is a contradiction.
|
|
67
|
+
out['nav.connectionRtt'] = connection?.rtt ?? null;
|
|
68
|
+
out['nav.connectionDownlink'] = connection?.downlink ?? null;
|
|
69
|
+
return out;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
/** Property access can throw in hardened or instrumented environments. */
|
|
73
|
+
function read(fn) {
|
|
74
|
+
try {
|
|
75
|
+
const value = fn();
|
|
76
|
+
return value === undefined ? null : value;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { hash } from '../hash.js';
|
|
2
|
+
/**
|
|
3
|
+
* What kind of input this device has, asked of the platform rather than the
|
|
4
|
+
* user agent.
|
|
5
|
+
*
|
|
6
|
+
* `nav.maxTouchPoints` was the only thing here, and it is one number that a
|
|
7
|
+
* profile rewrites in one edit. These ask the same question several ways: the
|
|
8
|
+
* event constructors that exist, the properties those constructors expose, and
|
|
9
|
+
* what the media queries say about the primary pointer.
|
|
10
|
+
*
|
|
11
|
+
* Contradiction is the point. A client claiming a phone should have coarse
|
|
12
|
+
* pointer, no hover, touch events and touch points; a desktop should have the
|
|
13
|
+
* inverse. Profiles that fake mobile usually set the user agent and the touch
|
|
14
|
+
* count and leave the rest, so the disagreement is what carries the signal —
|
|
15
|
+
* not any one value.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* PointerEvent properties, probed on the prototype.
|
|
19
|
+
*
|
|
20
|
+
* The set differs between engines and between versions, and unlike a feature
|
|
21
|
+
* hash built from global names these are specifically about input hardware.
|
|
22
|
+
*/
|
|
23
|
+
const POINTER_PROPS = [
|
|
24
|
+
'altitudeAngle', 'azimuthAngle', 'height', 'isPrimary', 'pointerId', 'pointerType',
|
|
25
|
+
'pressure', 'tangentialPressure', 'tiltX', 'tiltY', 'twist', 'width',
|
|
26
|
+
'getCoalescedEvents', 'getPredictedEvents', 'persistentDeviceId',
|
|
27
|
+
];
|
|
28
|
+
const TOUCH_PROPS = [
|
|
29
|
+
'force', 'identifier', 'pageX', 'pageY', 'radiusX', 'radiusY',
|
|
30
|
+
'rotationAngle', 'screenX', 'screenY', 'altitudeAngle', 'azimuthAngle', 'touchType',
|
|
31
|
+
];
|
|
32
|
+
const QUERIES = {
|
|
33
|
+
'pointer.primaryCoarse': '(pointer: coarse)',
|
|
34
|
+
'pointer.primaryFine': '(pointer: fine)',
|
|
35
|
+
'pointer.primaryNone': '(pointer: none)',
|
|
36
|
+
'pointer.anyFine': '(any-pointer: fine)',
|
|
37
|
+
'pointer.anyHover': '(any-hover: hover)',
|
|
38
|
+
};
|
|
39
|
+
export const pointerCollector = {
|
|
40
|
+
id: 'pointer',
|
|
41
|
+
wave: 'instant',
|
|
42
|
+
optional: true,
|
|
43
|
+
collect({ window, navigator }) {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [key, query] of Object.entries(QUERIES)) {
|
|
46
|
+
try {
|
|
47
|
+
const result = window.matchMedia(query);
|
|
48
|
+
// An engine that does not know the query normalises it to 'not all',
|
|
49
|
+
// where `matches` would otherwise report a confident false.
|
|
50
|
+
out[key] = result.media === 'not all' ? null : result.matches;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
out[key] = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
out['pointer.pointerEvent'] = 'PointerEvent' in window;
|
|
57
|
+
out['pointer.touchEvent'] = 'TouchEvent' in window;
|
|
58
|
+
out['pointer.ontouchstart'] = 'ontouchstart' in window;
|
|
59
|
+
out['pointer.propsHash'] = protoHash(window, 'PointerEvent', POINTER_PROPS);
|
|
60
|
+
out['pointer.touchPropsHash'] = protoHash(window, 'TouchEvent' in window ? 'Touch' : '', TOUCH_PROPS);
|
|
61
|
+
// Reported beside the media queries on purpose. A desktop that claims five
|
|
62
|
+
// touch points while every query says fine-pointer-with-hover is the
|
|
63
|
+
// contradiction this collector exists to make visible, and it is only
|
|
64
|
+
// visible when both sides are recorded.
|
|
65
|
+
try {
|
|
66
|
+
out['pointer.maxTouchPoints'] = navigator.maxTouchPoints;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
out['pointer.maxTouchPoints'] = null;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
/** Which of a set of properties the constructor's prototype actually carries. */
|
|
75
|
+
function protoHash(window, constructorName, props) {
|
|
76
|
+
try {
|
|
77
|
+
if (constructorName === '')
|
|
78
|
+
return null;
|
|
79
|
+
const ctor = window[constructorName];
|
|
80
|
+
if (typeof ctor !== 'function')
|
|
81
|
+
return null;
|
|
82
|
+
const proto = ctor.prototype;
|
|
83
|
+
if (proto === undefined)
|
|
84
|
+
return null;
|
|
85
|
+
let bits = '';
|
|
86
|
+
for (const prop of props)
|
|
87
|
+
bits += prop in proto ? '1' : '0';
|
|
88
|
+
return hash(bits);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Collector } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Canvas and WebGL: what the GPU and driver actually produce.
|
|
4
|
+
*
|
|
5
|
+
* These are the highest-entropy signals available to a page, because the output
|
|
6
|
+
* depends on the whole rendering stack — GPU, driver version, OS text
|
|
7
|
+
* rasteriser, font hinting. They are also the ones privacy browsers
|
|
8
|
+
* deliberately poison, so the *stability* of the result is collected alongside
|
|
9
|
+
* it: real hardware is deterministic, injected noise is not.
|
|
10
|
+
*/
|
|
11
|
+
export declare const canvasCollector: Collector;
|
|
12
|
+
export declare const webglCollector: Collector;
|