leapfrog-mcp 0.6.2 → 0.6.3
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/dist/adaptive-wait.d.ts +72 -0
- package/dist/adaptive-wait.js +695 -0
- package/dist/api-intelligence.d.ts +82 -0
- package/dist/api-intelligence.js +575 -0
- package/dist/captcha-solver.d.ts +26 -0
- package/dist/captcha-solver.js +547 -0
- package/dist/cdp-connector.d.ts +33 -0
- package/dist/cdp-connector.js +176 -0
- package/dist/consent-dismiss.d.ts +33 -0
- package/dist/consent-dismiss.js +358 -0
- package/dist/crash-recovery.d.ts +74 -0
- package/dist/crash-recovery.js +242 -0
- package/dist/domain-knowledge.d.ts +149 -0
- package/dist/domain-knowledge.js +449 -0
- package/dist/harness-intelligence.d.ts +65 -0
- package/dist/harness-intelligence.js +432 -0
- package/dist/humanize-fingerprint.d.ts +42 -0
- package/dist/humanize-fingerprint.js +161 -0
- package/dist/humanize-mouse.d.ts +95 -0
- package/dist/humanize-mouse.js +275 -0
- package/dist/humanize-pause.d.ts +48 -0
- package/dist/humanize-pause.js +111 -0
- package/dist/humanize-scroll.d.ts +67 -0
- package/dist/humanize-scroll.js +185 -0
- package/dist/humanize-typing.d.ts +60 -0
- package/dist/humanize-typing.js +258 -0
- package/dist/humanize-utils.d.ts +62 -0
- package/dist/humanize-utils.js +100 -0
- package/dist/intervention.d.ts +65 -0
- package/dist/intervention.js +591 -0
- package/dist/logger.d.ts +13 -0
- package/dist/logger.js +47 -0
- package/dist/network-intelligence.d.ts +70 -0
- package/dist/network-intelligence.js +424 -0
- package/dist/page-classifier.d.ts +33 -0
- package/dist/page-classifier.js +1000 -0
- package/dist/paginate.d.ts +42 -0
- package/dist/paginate.js +693 -0
- package/dist/recording.d.ts +72 -0
- package/dist/recording.js +934 -0
- package/dist/script-executor.d.ts +31 -0
- package/dist/script-executor.js +249 -0
- package/dist/session-hud.d.ts +20 -0
- package/dist/session-hud.js +134 -0
- package/dist/snapshot-differ.d.ts +26 -0
- package/dist/snapshot-differ.js +225 -0
- package/dist/ssrf.d.ts +28 -0
- package/dist/ssrf.js +290 -0
- package/dist/stealth-audit.d.ts +27 -0
- package/dist/stealth-audit.js +719 -0
- package/dist/stealth.d.ts +195 -0
- package/dist/stealth.js +1157 -0
- package/dist/tab-manager.d.ts +14 -0
- package/dist/tab-manager.js +306 -0
- package/dist/tiles-coordinator.d.ts +106 -0
- package/dist/tiles-coordinator.js +358 -0
- package/package.json +1 -1
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
// ─── Stealth Self-Test CLI ─────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Usage: npx leapfrog --stealth-audit [--local-only] [--full] [--json] [--headed] [--mode=MODE]
|
|
4
|
+
//
|
|
5
|
+
// Launches a real stealth-patched browser (same as session_create) and runs
|
|
6
|
+
// automated checks against all 19 stealth patches. Returns pass/fail/warn
|
|
7
|
+
// for each check with exit code 0 (all pass) or 1 (any fail).
|
|
8
|
+
//
|
|
9
|
+
// Modes:
|
|
10
|
+
// --mode=off No stealth patches (baseline measurement)
|
|
11
|
+
// --mode=passive Automation removal only — no identity faking
|
|
12
|
+
// --mode=active Full stealth with fingerprint spoofing (default)
|
|
13
|
+
// --mode=compare Run all three modes and produce side-by-side comparison
|
|
14
|
+
//
|
|
15
|
+
// Tiers:
|
|
16
|
+
// Tier 1 — Local checks on about:blank (~2s, always run)
|
|
17
|
+
// Tier 2 — External bot-detection sites (~12s, default)
|
|
18
|
+
// Tier 3 — Extended checks (~45s, --full flag)
|
|
19
|
+
//
|
|
20
|
+
import { chromium } from "playwright-core";
|
|
21
|
+
import { stealth } from "./stealth.js";
|
|
22
|
+
import { generateFingerprint } from "./humanize-fingerprint.js";
|
|
23
|
+
import { createRequire } from "module";
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const pkg = require("../package.json");
|
|
26
|
+
/**
|
|
27
|
+
* Checks that are EXPECTED to fail in specific modes.
|
|
28
|
+
* These represent honest behavior, not bugs — passive mode doesn't fake
|
|
29
|
+
* identity, so headless defaults are correct, not defects.
|
|
30
|
+
*/
|
|
31
|
+
const EXPECTED_FAILURES = {
|
|
32
|
+
off: new Set([
|
|
33
|
+
"P0 navigator.webdriver typeof",
|
|
34
|
+
"P0 navigator.webdriver 'in' check",
|
|
35
|
+
"P0 Client Hints clean",
|
|
36
|
+
"P2 navigator.plugins count",
|
|
37
|
+
"P2 navigator.mimeTypes count",
|
|
38
|
+
"P2 outerHeight offset",
|
|
39
|
+
"P1 connection.rtt > 0",
|
|
40
|
+
"P1 WebGL vendor clean",
|
|
41
|
+
"P1 WebGL renderer clean",
|
|
42
|
+
"chrome.app emulation",
|
|
43
|
+
"chrome.runtime",
|
|
44
|
+
"chrome.loadTimes",
|
|
45
|
+
"Playwright global __pwInitScripts",
|
|
46
|
+
"Playwright global __playwright",
|
|
47
|
+
]),
|
|
48
|
+
passive: new Set([
|
|
49
|
+
"P2 navigator.plugins count",
|
|
50
|
+
"P2 navigator.mimeTypes count",
|
|
51
|
+
"P2 outerHeight offset",
|
|
52
|
+
"P1 connection.rtt > 0",
|
|
53
|
+
"P1 WebGL vendor clean",
|
|
54
|
+
"P1 WebGL renderer clean",
|
|
55
|
+
"chrome.app emulation",
|
|
56
|
+
"chrome.runtime",
|
|
57
|
+
"chrome.loadTimes",
|
|
58
|
+
]),
|
|
59
|
+
active: new Set([
|
|
60
|
+
// Active mode aims to pass everything — no expected failures
|
|
61
|
+
]),
|
|
62
|
+
};
|
|
63
|
+
// ── Tier 1: Local Checks ──────────────────────────────────────────────────
|
|
64
|
+
async function runLocalChecks(page) {
|
|
65
|
+
// Run one big evaluate to avoid round-trip overhead
|
|
66
|
+
const raw = await page.evaluate(() => {
|
|
67
|
+
const results = [];
|
|
68
|
+
// Helper
|
|
69
|
+
function check(label, pass, detail, priority) {
|
|
70
|
+
results.push({ label, pass, detail, priority });
|
|
71
|
+
}
|
|
72
|
+
// 1. P0 navigator.webdriver hidden (typeof)
|
|
73
|
+
check("P0 navigator.webdriver typeof", typeof navigator.webdriver === "undefined", `typeof = "${typeof navigator.webdriver}"`, "P0");
|
|
74
|
+
// 2. P0 navigator.webdriver hidden (in operator)
|
|
75
|
+
check("P0 navigator.webdriver 'in' check", !("webdriver" in navigator), `'webdriver' in navigator = ${("webdriver" in navigator)}`, "P0");
|
|
76
|
+
// 3. P0 Client Hints — no HeadlessChrome
|
|
77
|
+
let clientHintsClean = true;
|
|
78
|
+
let clientHintsDetail = "N/A";
|
|
79
|
+
if (navigator.userAgentData) {
|
|
80
|
+
const brands = navigator.userAgentData.brands || [];
|
|
81
|
+
const brandNames = brands.map((b) => b.brand).join(", ");
|
|
82
|
+
clientHintsClean = !brandNames.includes("HeadlessChrome");
|
|
83
|
+
clientHintsDetail = brandNames;
|
|
84
|
+
}
|
|
85
|
+
check("P0 Client Hints clean", clientHintsClean, clientHintsDetail, "P0");
|
|
86
|
+
// 4. P0 UA string — no HeadlessChrome
|
|
87
|
+
check("P0 UA string clean", !navigator.userAgent.includes("HeadlessChrome"), navigator.userAgent.substring(0, 60) + "...", "P0");
|
|
88
|
+
// 5. Plugins count >= 5
|
|
89
|
+
check("P2 navigator.plugins count", navigator.plugins.length >= 5, `${navigator.plugins.length} plugins`, "P2");
|
|
90
|
+
// 6. MimeTypes count >= 2
|
|
91
|
+
check("P2 navigator.mimeTypes count", navigator.mimeTypes.length >= 2, `${navigator.mimeTypes.length} mimeTypes`, "P2");
|
|
92
|
+
// 7. navigator.languages non-empty
|
|
93
|
+
check("navigator.languages", Array.isArray(navigator.languages) && navigator.languages.length > 0, JSON.stringify(navigator.languages));
|
|
94
|
+
// 8. navigator.platform matches UA
|
|
95
|
+
const uaHasWin = /Windows/i.test(navigator.userAgent);
|
|
96
|
+
const uaHasMac = /Macintosh|Mac OS X/i.test(navigator.userAgent);
|
|
97
|
+
const uaHasLinux = /Linux/i.test(navigator.userAgent) && !/Android/i.test(navigator.userAgent);
|
|
98
|
+
let platformMatch = true;
|
|
99
|
+
if (uaHasWin && navigator.platform !== "Win32")
|
|
100
|
+
platformMatch = false;
|
|
101
|
+
if (uaHasMac && navigator.platform !== "MacIntel")
|
|
102
|
+
platformMatch = false;
|
|
103
|
+
if (uaHasLinux && !navigator.platform.startsWith("Linux"))
|
|
104
|
+
platformMatch = false;
|
|
105
|
+
check("P2 platform matches UA", platformMatch, `platform="${navigator.platform}" UA=${uaHasWin ? "Win" : uaHasMac ? "Mac" : uaHasLinux ? "Linux" : "other"}`, "P2");
|
|
106
|
+
// 9. navigator.connection.rtt > 0
|
|
107
|
+
const conn = navigator.connection;
|
|
108
|
+
check("P1 connection.rtt > 0", conn ? conn.rtt > 0 : false, conn ? `rtt=${conn.rtt}` : "no connection API", "P1");
|
|
109
|
+
// 10. window.chrome exists
|
|
110
|
+
check("window.chrome exists", !!window.chrome, window.chrome ? "present" : "missing");
|
|
111
|
+
// 11. window.chrome.app exists
|
|
112
|
+
check("chrome.app emulation", !!window.chrome?.app, window.chrome?.app ? "present" : "missing");
|
|
113
|
+
// 12. outerHeight - innerHeight > 0
|
|
114
|
+
const heightOffset = window.outerHeight - window.innerHeight;
|
|
115
|
+
check("P2 outerHeight offset", heightOffset > 0, `expected > 0, got ${heightOffset}`, "P2");
|
|
116
|
+
// 13. document.hasFocus() === true
|
|
117
|
+
check("document.hasFocus()", document.hasFocus() === true, `${document.hasFocus()}`);
|
|
118
|
+
// 14. WebGL UNMASKED_VENDOR — no SwiftShader
|
|
119
|
+
let webglVendor = "N/A";
|
|
120
|
+
let webglRenderer = "N/A";
|
|
121
|
+
try {
|
|
122
|
+
const canvas = document.createElement("canvas");
|
|
123
|
+
const gl = canvas.getContext("webgl");
|
|
124
|
+
if (gl) {
|
|
125
|
+
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
|
|
126
|
+
if (dbg) {
|
|
127
|
+
webglVendor = gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) || "";
|
|
128
|
+
webglRenderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) || "";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch { /* no WebGL */ }
|
|
133
|
+
check("P1 WebGL vendor clean", !webglVendor.includes("SwiftShader"), webglVendor, "P1");
|
|
134
|
+
// 15. WebGL UNMASKED_RENDERER — no SwiftShader
|
|
135
|
+
check("P1 WebGL renderer clean", !webglRenderer.includes("SwiftShader"), webglRenderer, "P1");
|
|
136
|
+
// 16. Permissions.query resolves
|
|
137
|
+
// (can't await in sync evaluate — checked separately)
|
|
138
|
+
// We'll set a flag and handle it outside
|
|
139
|
+
results.push({
|
|
140
|
+
label: "P2 permissions.query",
|
|
141
|
+
pass: typeof navigator.permissions?.query === "function",
|
|
142
|
+
detail: typeof navigator.permissions?.query === "function" ? "function present" : "missing",
|
|
143
|
+
priority: "P2",
|
|
144
|
+
});
|
|
145
|
+
// 17. navigator.hardwareConcurrency > 0
|
|
146
|
+
check("hardwareConcurrency", navigator.hardwareConcurrency > 0, `${navigator.hardwareConcurrency}`);
|
|
147
|
+
// 18. navigator.deviceMemory > 0
|
|
148
|
+
check("deviceMemory", (navigator.deviceMemory ?? 0) > 0, `${navigator.deviceMemory ?? "undefined"}`);
|
|
149
|
+
// 19. No __pwInitScripts global
|
|
150
|
+
check("Playwright global __pwInitScripts", typeof window.__pwInitScripts === "undefined", `typeof = "${typeof window.__pwInitScripts}"`);
|
|
151
|
+
// 20. No __playwright global
|
|
152
|
+
check("Playwright global __playwright", typeof window.__playwright === "undefined", `typeof = "${typeof window.__playwright}"`);
|
|
153
|
+
// 21. Audio canPlayType('audio/mpeg')
|
|
154
|
+
let audioResult = "";
|
|
155
|
+
try {
|
|
156
|
+
audioResult = new Audio().canPlayType("audio/mpeg");
|
|
157
|
+
}
|
|
158
|
+
catch { /* */ }
|
|
159
|
+
check("Media codecs (audio/mpeg)", audioResult === "probably", `canPlayType = "${audioResult}"`);
|
|
160
|
+
// 22. document.fonts.check('12px Arial')
|
|
161
|
+
let fontCheck = false;
|
|
162
|
+
try {
|
|
163
|
+
fontCheck = document.fonts.check("12px Arial");
|
|
164
|
+
}
|
|
165
|
+
catch { /* */ }
|
|
166
|
+
check("P3 font enumeration (Arial)", fontCheck === true, `fonts.check = ${fontCheck}`, "P3");
|
|
167
|
+
// 23. Error stack — no __playwright or pptr:
|
|
168
|
+
let stackClean = true;
|
|
169
|
+
let stackDetail = "clean";
|
|
170
|
+
try {
|
|
171
|
+
const err = new Error("stealth-audit-probe");
|
|
172
|
+
const stack = err.stack || "";
|
|
173
|
+
if (stack.includes("__playwright") || stack.includes("pptr:")) {
|
|
174
|
+
stackClean = false;
|
|
175
|
+
stackDetail = "contains automation frames";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch { /* */ }
|
|
179
|
+
check("Error stack clean", stackClean, stackDetail);
|
|
180
|
+
// 24. No Selenium markers
|
|
181
|
+
check("Selenium markers absent", !window.cdc_adoQpoasnfa76pfcZLmcfl_ &&
|
|
182
|
+
!document.__selenium_unwrapped &&
|
|
183
|
+
!document.__fxdriver_unwrapped &&
|
|
184
|
+
!window.__webdriver_evaluate &&
|
|
185
|
+
!window.__driver_evaluate, "no markers found");
|
|
186
|
+
// 25. Notification.permission === 'default'
|
|
187
|
+
let notifPerm = "unknown";
|
|
188
|
+
try {
|
|
189
|
+
notifPerm = Notification.permission;
|
|
190
|
+
}
|
|
191
|
+
catch { /* */ }
|
|
192
|
+
check("Notification.permission", notifPerm === "default", notifPerm);
|
|
193
|
+
// 26. chrome.runtime present
|
|
194
|
+
check("chrome.runtime", !!window.chrome?.runtime, window.chrome?.runtime ? "present" : "missing");
|
|
195
|
+
// 27. chrome.loadTimes present
|
|
196
|
+
check("chrome.loadTimes", typeof window.chrome?.loadTimes === "function", typeof window.chrome?.loadTimes);
|
|
197
|
+
return results;
|
|
198
|
+
});
|
|
199
|
+
// Also run async permissions check
|
|
200
|
+
let permissionQueryWorks = false;
|
|
201
|
+
let permissionDetail = "query failed";
|
|
202
|
+
try {
|
|
203
|
+
const result = await page.evaluate(async () => {
|
|
204
|
+
const perm = await navigator.permissions.query({ name: "notifications" });
|
|
205
|
+
return perm.state;
|
|
206
|
+
});
|
|
207
|
+
permissionQueryWorks = result === "prompt" || result === "granted" || result === "denied";
|
|
208
|
+
permissionDetail = `state="${result}"`;
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
permissionDetail = e.message?.substring(0, 60) || "error";
|
|
212
|
+
}
|
|
213
|
+
const results = raw.map((r) => ({
|
|
214
|
+
label: r.label,
|
|
215
|
+
status: r.pass ? "pass" : "fail",
|
|
216
|
+
detail: r.detail,
|
|
217
|
+
tier: 1,
|
|
218
|
+
priority: r.priority,
|
|
219
|
+
}));
|
|
220
|
+
// Replace the placeholder permissions check with async result
|
|
221
|
+
const permIdx = results.findIndex((r) => r.label === "P2 permissions.query");
|
|
222
|
+
if (permIdx >= 0) {
|
|
223
|
+
results[permIdx] = {
|
|
224
|
+
label: "P2 permissions.query resolves",
|
|
225
|
+
status: permissionQueryWorks ? "pass" : "fail",
|
|
226
|
+
detail: permissionDetail,
|
|
227
|
+
tier: 1,
|
|
228
|
+
priority: "P2",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return results;
|
|
232
|
+
}
|
|
233
|
+
// ── Tier 2: External Sites ────────────────────────────────────────────────
|
|
234
|
+
async function runSannysoft(page) {
|
|
235
|
+
const results = [];
|
|
236
|
+
try {
|
|
237
|
+
await page.goto("https://bot.sannysoft.com", { waitUntil: "networkidle", timeout: 20000 });
|
|
238
|
+
// Wait for results table to populate
|
|
239
|
+
await page.waitForSelector("#fp2 td", { timeout: 10000 });
|
|
240
|
+
const rows = await page.evaluate(() => {
|
|
241
|
+
const table = document.getElementById("fp2");
|
|
242
|
+
if (!table)
|
|
243
|
+
return [];
|
|
244
|
+
const trs = table.querySelectorAll("tr");
|
|
245
|
+
const data = [];
|
|
246
|
+
trs.forEach((tr) => {
|
|
247
|
+
const tds = tr.querySelectorAll("td");
|
|
248
|
+
if (tds.length >= 2) {
|
|
249
|
+
const label = tds[0]?.textContent?.trim() ?? "";
|
|
250
|
+
const cell = tds[1];
|
|
251
|
+
const value = cell?.textContent?.trim() ?? "";
|
|
252
|
+
// Green = pass, red = fail (sannysoft uses inline styles)
|
|
253
|
+
const style = cell?.getAttribute("style") ?? "";
|
|
254
|
+
const className = cell?.className ?? "";
|
|
255
|
+
const passed = style.includes("green") ||
|
|
256
|
+
className.includes("passed") ||
|
|
257
|
+
(!style.includes("red") && !className.includes("failed"));
|
|
258
|
+
if (label)
|
|
259
|
+
data.push({ label, value, passed });
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
return data;
|
|
263
|
+
});
|
|
264
|
+
for (const row of rows) {
|
|
265
|
+
results.push({
|
|
266
|
+
label: row.label,
|
|
267
|
+
status: row.passed ? "pass" : "fail",
|
|
268
|
+
detail: row.value.substring(0, 80),
|
|
269
|
+
tier: 2,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (rows.length === 0) {
|
|
273
|
+
results.push({
|
|
274
|
+
label: "bot.sannysoft.com",
|
|
275
|
+
status: "warn",
|
|
276
|
+
detail: "Could not parse results table",
|
|
277
|
+
tier: 2,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
results.push({
|
|
283
|
+
label: "bot.sannysoft.com",
|
|
284
|
+
status: "warn",
|
|
285
|
+
detail: `Navigation failed: ${e.message?.substring(0, 60)}`,
|
|
286
|
+
tier: 2,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return results;
|
|
290
|
+
}
|
|
291
|
+
async function runRebrowserDetector(page) {
|
|
292
|
+
const results = [];
|
|
293
|
+
try {
|
|
294
|
+
await page.goto("https://bot-detector.rebrowser.net", { waitUntil: "networkidle", timeout: 25000 });
|
|
295
|
+
// Wait for test results — the page runs JS-heavy tests
|
|
296
|
+
await page.waitForTimeout(5000);
|
|
297
|
+
const data = await page.evaluate(() => {
|
|
298
|
+
const items = [];
|
|
299
|
+
// Look for result containers — rebrowser uses various selectors
|
|
300
|
+
const resultElements = document.querySelectorAll('[class*="result"], [class*="test"], [data-test]');
|
|
301
|
+
resultElements.forEach((el) => {
|
|
302
|
+
const text = el.textContent?.trim() ?? "";
|
|
303
|
+
if (text.length > 2 && text.length < 200) {
|
|
304
|
+
const passed = el.className?.includes("pass") ||
|
|
305
|
+
el.className?.includes("success") ||
|
|
306
|
+
el.className?.includes("green") ||
|
|
307
|
+
text.toLowerCase().includes("passed") ||
|
|
308
|
+
text.toLowerCase().includes("not detected");
|
|
309
|
+
items.push({ label: text.substring(0, 60), passed, detail: text.substring(0, 80) });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
// Fallback: try to get overall status text
|
|
313
|
+
if (items.length === 0) {
|
|
314
|
+
const bodyText = document.body?.innerText?.substring(0, 500) ?? "";
|
|
315
|
+
const hasDetection = bodyText.toLowerCase().includes("detected") &&
|
|
316
|
+
!bodyText.toLowerCase().includes("not detected");
|
|
317
|
+
items.push({
|
|
318
|
+
label: "CDP leak detection",
|
|
319
|
+
passed: !hasDetection,
|
|
320
|
+
detail: bodyText.substring(0, 80),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return items;
|
|
324
|
+
});
|
|
325
|
+
for (const item of data) {
|
|
326
|
+
results.push({
|
|
327
|
+
label: item.label,
|
|
328
|
+
status: item.passed ? "pass" : "fail",
|
|
329
|
+
detail: item.detail,
|
|
330
|
+
tier: 2,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
results.push({
|
|
336
|
+
label: "bot-detector.rebrowser.net",
|
|
337
|
+
status: "warn",
|
|
338
|
+
detail: `Navigation failed: ${e.message?.substring(0, 60)}`,
|
|
339
|
+
tier: 2,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return results;
|
|
343
|
+
}
|
|
344
|
+
// ── Tier 3: Extended Checks ───────────────────────────────────────────────
|
|
345
|
+
async function runBrowserLeaksWebRTC(page) {
|
|
346
|
+
const results = [];
|
|
347
|
+
try {
|
|
348
|
+
await page.goto("https://browserleaks.com/webrtc", { waitUntil: "networkidle", timeout: 30000 });
|
|
349
|
+
await page.waitForTimeout(5000);
|
|
350
|
+
const data = await page.evaluate(() => {
|
|
351
|
+
const bodyText = document.body?.innerText ?? "";
|
|
352
|
+
// Check for local IP leaks (192.168.x, 10.x, 172.16-31.x)
|
|
353
|
+
const localIpRegex = /(?:192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})/;
|
|
354
|
+
const leaksLocalIP = localIpRegex.test(bodyText);
|
|
355
|
+
return {
|
|
356
|
+
leaksLocalIP,
|
|
357
|
+
snippet: bodyText.substring(0, 120),
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
results.push({
|
|
361
|
+
label: "WebRTC local IP leak",
|
|
362
|
+
status: data.leaksLocalIP ? "fail" : "pass",
|
|
363
|
+
detail: data.leaksLocalIP ? "Local IP visible in WebRTC candidates" : "No local IP leak detected",
|
|
364
|
+
tier: 3,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
results.push({
|
|
369
|
+
label: "browserleaks.com/webrtc",
|
|
370
|
+
status: "warn",
|
|
371
|
+
detail: `Navigation failed: ${e.message?.substring(0, 60)}`,
|
|
372
|
+
tier: 3,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
return results;
|
|
376
|
+
}
|
|
377
|
+
async function runCreepJS(page) {
|
|
378
|
+
const results = [];
|
|
379
|
+
try {
|
|
380
|
+
await page.goto("https://abrahamjuliot.github.io/creepjs/", { waitUntil: "networkidle", timeout: 45000 });
|
|
381
|
+
// CreepJS takes a while to run all fingerprint tests
|
|
382
|
+
await page.waitForTimeout(10000);
|
|
383
|
+
const data = await page.evaluate(() => {
|
|
384
|
+
const items = [];
|
|
385
|
+
// CreepJS shows a trust score and various detection results
|
|
386
|
+
const bodyText = document.body?.innerText ?? "";
|
|
387
|
+
// Look for the trust score
|
|
388
|
+
const trustMatch = bodyText.match(/trust\s*score[:\s]*([0-9.]+%?)/i);
|
|
389
|
+
if (trustMatch) {
|
|
390
|
+
const score = parseFloat(trustMatch[1]);
|
|
391
|
+
items.push({
|
|
392
|
+
label: "CreepJS trust score",
|
|
393
|
+
passed: score >= 50,
|
|
394
|
+
detail: `${trustMatch[1]}`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// Check for lie/bot detection
|
|
398
|
+
const liesDetected = bodyText.toLowerCase().includes("lies detected") ||
|
|
399
|
+
bodyText.toLowerCase().includes("bot detected");
|
|
400
|
+
items.push({
|
|
401
|
+
label: "CreepJS bot detection",
|
|
402
|
+
passed: !liesDetected,
|
|
403
|
+
detail: liesDetected ? "Lies/bot detected" : "No bot signal",
|
|
404
|
+
});
|
|
405
|
+
if (items.length === 0) {
|
|
406
|
+
items.push({
|
|
407
|
+
label: "CreepJS",
|
|
408
|
+
passed: true,
|
|
409
|
+
detail: "Could not parse results",
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return items;
|
|
413
|
+
});
|
|
414
|
+
for (const item of data) {
|
|
415
|
+
results.push({
|
|
416
|
+
label: item.label,
|
|
417
|
+
status: item.passed ? "pass" : item.passed === false ? "fail" : "warn",
|
|
418
|
+
detail: item.detail,
|
|
419
|
+
tier: 3,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
results.push({
|
|
425
|
+
label: "CreepJS",
|
|
426
|
+
status: "warn",
|
|
427
|
+
detail: `Navigation failed: ${e.message?.substring(0, 60)}`,
|
|
428
|
+
tier: 3,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return results;
|
|
432
|
+
}
|
|
433
|
+
// ── Expected Failure Tagging ─────────────────────────────────────────────
|
|
434
|
+
function tagExpectedFailures(results, mode) {
|
|
435
|
+
const expectedSet = EXPECTED_FAILURES[mode];
|
|
436
|
+
return results.map((r) => ({
|
|
437
|
+
...r,
|
|
438
|
+
expected: r.status === "fail" && expectedSet.has(r.label) ? true : undefined,
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
// ── Output Formatting ─────────────────────────────────────────────────────
|
|
442
|
+
function formatTag(r) {
|
|
443
|
+
if (r.status === "pass")
|
|
444
|
+
return "[pass]";
|
|
445
|
+
if (r.status === "warn")
|
|
446
|
+
return "[warn]";
|
|
447
|
+
if (r.expected)
|
|
448
|
+
return "[exp.]"; // expected failure — not a bug
|
|
449
|
+
return "[FAIL]";
|
|
450
|
+
}
|
|
451
|
+
function printResults(results, durationMs, mode = "active") {
|
|
452
|
+
console.log(`\nLeapfrog Stealth Audit v${pkg.version} (mode: ${mode})\n`);
|
|
453
|
+
// Group by tier
|
|
454
|
+
const tier1 = results.filter((r) => r.tier === 1);
|
|
455
|
+
const tier2 = results.filter((r) => r.tier === 2);
|
|
456
|
+
const tier3 = results.filter((r) => r.tier === 3);
|
|
457
|
+
if (tier1.length > 0) {
|
|
458
|
+
console.log(`--- Local Checks (${tier1.length} tests) ---`);
|
|
459
|
+
for (const r of tier1) {
|
|
460
|
+
const tag = formatTag(r);
|
|
461
|
+
const priority = r.priority ? `${r.priority} ` : "";
|
|
462
|
+
const detail = r.detail ? ` ${r.detail}` : "";
|
|
463
|
+
console.log(` ${tag} ${priority}${r.label}${detail}`);
|
|
464
|
+
}
|
|
465
|
+
console.log();
|
|
466
|
+
}
|
|
467
|
+
if (tier2.length > 0) {
|
|
468
|
+
console.log(`--- External Sites (${tier2.length} tests) ---`);
|
|
469
|
+
for (const r of tier2) {
|
|
470
|
+
const tag = formatTag(r);
|
|
471
|
+
const detail = r.detail ? ` ${r.detail}` : "";
|
|
472
|
+
console.log(` ${tag} ${r.label}${detail}`);
|
|
473
|
+
}
|
|
474
|
+
console.log();
|
|
475
|
+
}
|
|
476
|
+
if (tier3.length > 0) {
|
|
477
|
+
console.log(`--- Extended Checks (${tier3.length} tests) ---`);
|
|
478
|
+
for (const r of tier3) {
|
|
479
|
+
const tag = formatTag(r);
|
|
480
|
+
const detail = r.detail ? ` ${r.detail}` : "";
|
|
481
|
+
console.log(` ${tag} ${r.label}${detail}`);
|
|
482
|
+
}
|
|
483
|
+
console.log();
|
|
484
|
+
}
|
|
485
|
+
const passed = results.filter((r) => r.status === "pass").length;
|
|
486
|
+
const failed = results.filter((r) => r.status === "fail" && !r.expected).length;
|
|
487
|
+
const expected = results.filter((r) => r.expected).length;
|
|
488
|
+
const warned = results.filter((r) => r.status === "warn").length;
|
|
489
|
+
const duration = (durationMs / 1000).toFixed(1);
|
|
490
|
+
let summary = `Summary: ${passed}/${results.length} passed, ${failed} failed`;
|
|
491
|
+
if (expected > 0)
|
|
492
|
+
summary += `, ${expected} expected`;
|
|
493
|
+
summary += `, ${warned} warning${warned !== 1 ? "s" : ""}`;
|
|
494
|
+
console.log(summary);
|
|
495
|
+
console.log(`Duration: ${duration}s\n`);
|
|
496
|
+
}
|
|
497
|
+
function printJSON(results, durationMs, mode = "active") {
|
|
498
|
+
const output = {
|
|
499
|
+
version: pkg.version,
|
|
500
|
+
mode,
|
|
501
|
+
timestamp: new Date().toISOString(),
|
|
502
|
+
durationMs,
|
|
503
|
+
summary: {
|
|
504
|
+
total: results.length,
|
|
505
|
+
passed: results.filter((r) => r.status === "pass").length,
|
|
506
|
+
failed: results.filter((r) => r.status === "fail" && !r.expected).length,
|
|
507
|
+
expected: results.filter((r) => r.expected).length,
|
|
508
|
+
warned: results.filter((r) => r.status === "warn").length,
|
|
509
|
+
},
|
|
510
|
+
results,
|
|
511
|
+
};
|
|
512
|
+
console.log(JSON.stringify(output, null, 2));
|
|
513
|
+
}
|
|
514
|
+
function printCompare(modeResults) {
|
|
515
|
+
console.log(`\nLeapfrog Stealth Audit v${pkg.version} — Mode Comparison\n`);
|
|
516
|
+
const modes = modeResults.map((m) => m.mode);
|
|
517
|
+
const colWidth = 10;
|
|
518
|
+
// Header
|
|
519
|
+
const header = " " + "".padEnd(40) + modes.map((m) => m.toUpperCase().padStart(colWidth)).join("");
|
|
520
|
+
console.log(header);
|
|
521
|
+
// Collect all unique labels across all modes, preserving order from first mode that has them
|
|
522
|
+
const labelOrder = [];
|
|
523
|
+
const labelTier = new Map();
|
|
524
|
+
for (const mr of modeResults) {
|
|
525
|
+
for (const r of mr.results) {
|
|
526
|
+
if (!labelTier.has(r.label)) {
|
|
527
|
+
labelOrder.push(r.label);
|
|
528
|
+
labelTier.set(r.label, r.tier);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// Build lookup: mode -> label -> result
|
|
533
|
+
const lookup = new Map();
|
|
534
|
+
for (const mr of modeResults) {
|
|
535
|
+
const map = new Map();
|
|
536
|
+
for (const r of mr.results) {
|
|
537
|
+
map.set(r.label, r);
|
|
538
|
+
}
|
|
539
|
+
lookup.set(mr.mode, map);
|
|
540
|
+
}
|
|
541
|
+
// Group by tier
|
|
542
|
+
const tiers = [
|
|
543
|
+
{ tier: 1, title: "Local Checks" },
|
|
544
|
+
{ tier: 2, title: "External Sites" },
|
|
545
|
+
{ tier: 3, title: "Extended Checks" },
|
|
546
|
+
];
|
|
547
|
+
for (const { tier, title } of tiers) {
|
|
548
|
+
const labels = labelOrder.filter((l) => labelTier.get(l) === tier);
|
|
549
|
+
if (labels.length === 0)
|
|
550
|
+
continue;
|
|
551
|
+
const divider = `--- ${title} ${"".padEnd(40 + colWidth * modes.length - title.length - 5, "-")}`;
|
|
552
|
+
console.log(divider);
|
|
553
|
+
for (const label of labels) {
|
|
554
|
+
const displayLabel = label.length > 38 ? label.substring(0, 35) + "..." : label;
|
|
555
|
+
let row = " " + displayLabel.padEnd(40);
|
|
556
|
+
for (const mode of modes) {
|
|
557
|
+
const r = lookup.get(mode)?.get(label);
|
|
558
|
+
if (!r) {
|
|
559
|
+
row += "---".padStart(colWidth);
|
|
560
|
+
}
|
|
561
|
+
else if (r.status === "pass") {
|
|
562
|
+
row += "pass".padStart(colWidth);
|
|
563
|
+
}
|
|
564
|
+
else if (r.status === "warn") {
|
|
565
|
+
row += "warn".padStart(colWidth);
|
|
566
|
+
}
|
|
567
|
+
else if (r.expected) {
|
|
568
|
+
row += "exp.".padStart(colWidth);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
row += "FAIL".padStart(colWidth);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
console.log(row);
|
|
575
|
+
}
|
|
576
|
+
console.log();
|
|
577
|
+
}
|
|
578
|
+
// Summary line per mode
|
|
579
|
+
console.log("Summary:");
|
|
580
|
+
for (const mr of modeResults) {
|
|
581
|
+
const passed = mr.results.filter((r) => r.status === "pass").length;
|
|
582
|
+
const failed = mr.results.filter((r) => r.status === "fail" && !r.expected).length;
|
|
583
|
+
const expected = mr.results.filter((r) => r.expected).length;
|
|
584
|
+
const total = mr.results.length;
|
|
585
|
+
const duration = (mr.durationMs / 1000).toFixed(1);
|
|
586
|
+
let line = ` ${mr.mode.toUpperCase().padEnd(10)} ${passed}/${total} passed, ${failed} failed`;
|
|
587
|
+
if (expected > 0)
|
|
588
|
+
line += `, ${expected} expected`;
|
|
589
|
+
line += ` (${duration}s)`;
|
|
590
|
+
console.log(line);
|
|
591
|
+
}
|
|
592
|
+
console.log();
|
|
593
|
+
}
|
|
594
|
+
function printCompareJSON(modeResults) {
|
|
595
|
+
const output = {
|
|
596
|
+
version: pkg.version,
|
|
597
|
+
mode: "compare",
|
|
598
|
+
timestamp: new Date().toISOString(),
|
|
599
|
+
modes: modeResults.map((mr) => ({
|
|
600
|
+
mode: mr.mode,
|
|
601
|
+
durationMs: mr.durationMs,
|
|
602
|
+
summary: {
|
|
603
|
+
total: mr.results.length,
|
|
604
|
+
passed: mr.results.filter((r) => r.status === "pass").length,
|
|
605
|
+
failed: mr.results.filter((r) => r.status === "fail" && !r.expected).length,
|
|
606
|
+
expected: mr.results.filter((r) => r.expected).length,
|
|
607
|
+
warned: mr.results.filter((r) => r.status === "warn").length,
|
|
608
|
+
},
|
|
609
|
+
results: mr.results,
|
|
610
|
+
})),
|
|
611
|
+
};
|
|
612
|
+
console.log(JSON.stringify(output, null, 2));
|
|
613
|
+
}
|
|
614
|
+
// ── Single-Mode Runner ───────────────────────────────────────────────────
|
|
615
|
+
/**
|
|
616
|
+
* Run the audit for a single stealth mode. Returns tagged results.
|
|
617
|
+
* Extracted from main so compare mode can call it 3 times.
|
|
618
|
+
*/
|
|
619
|
+
export async function runAuditForMode(stealthMode, options) {
|
|
620
|
+
const start = Date.now();
|
|
621
|
+
const allResults = [];
|
|
622
|
+
let browser = null;
|
|
623
|
+
try {
|
|
624
|
+
const headless = !options.headed;
|
|
625
|
+
// Launch args: use stealth args for passive/active, none for off.
|
|
626
|
+
// For passive mode, getLaunchArgs() returns reduced args (no GPU faking).
|
|
627
|
+
// But since we override at applyToPage level, using full launch args for
|
|
628
|
+
// passive is also fine — the GPU args don't hurt, and mode override on
|
|
629
|
+
// applyToPage controls which init scripts actually run.
|
|
630
|
+
const launchArgs = stealthMode !== "off" ? stealth.getLaunchArgs() : [];
|
|
631
|
+
browser = await chromium.launch({
|
|
632
|
+
headless,
|
|
633
|
+
args: launchArgs.length > 0 ? launchArgs : undefined,
|
|
634
|
+
});
|
|
635
|
+
// Generate fingerprint (same as session-manager does)
|
|
636
|
+
const fp = generateFingerprint();
|
|
637
|
+
const contextOpts = stealthMode !== "off" ? stealth.getContextOptions(undefined, fp) : {};
|
|
638
|
+
const context = await browser.newContext({
|
|
639
|
+
viewport: { width: 1280, height: 720 },
|
|
640
|
+
...contextOpts,
|
|
641
|
+
});
|
|
642
|
+
const page = await context.newPage();
|
|
643
|
+
// Apply stealth init scripts with mode override
|
|
644
|
+
if (stealthMode !== "off") {
|
|
645
|
+
await stealth.applyToPage(page, undefined, fp, stealthMode);
|
|
646
|
+
}
|
|
647
|
+
// ── Tier 1: Local checks ────────────────────────────────────────
|
|
648
|
+
await page.goto("about:blank");
|
|
649
|
+
const localResults = await runLocalChecks(page);
|
|
650
|
+
allResults.push(...localResults);
|
|
651
|
+
// ── Tier 2: External sites ──────────────────────────────────────
|
|
652
|
+
if (!options.localOnly) {
|
|
653
|
+
const sannysoftResults = await runSannysoft(page);
|
|
654
|
+
allResults.push(...sannysoftResults);
|
|
655
|
+
const rebrowserResults = await runRebrowserDetector(page);
|
|
656
|
+
allResults.push(...rebrowserResults);
|
|
657
|
+
}
|
|
658
|
+
// ── Tier 3: Full mode ───────────────────────────────────────────
|
|
659
|
+
if (options.full) {
|
|
660
|
+
const webrtcResults = await runBrowserLeaksWebRTC(page);
|
|
661
|
+
allResults.push(...webrtcResults);
|
|
662
|
+
const creepResults = await runCreepJS(page);
|
|
663
|
+
allResults.push(...creepResults);
|
|
664
|
+
}
|
|
665
|
+
await context.close();
|
|
666
|
+
}
|
|
667
|
+
catch (e) {
|
|
668
|
+
allResults.push({
|
|
669
|
+
label: "Browser launch",
|
|
670
|
+
status: "fail",
|
|
671
|
+
detail: e.message?.substring(0, 100),
|
|
672
|
+
tier: 1,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
if (browser) {
|
|
677
|
+
await browser.close().catch(() => { });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// Tag expected failures for this mode
|
|
681
|
+
const tagged = tagExpectedFailures(allResults, stealthMode);
|
|
682
|
+
return { results: tagged, durationMs: Date.now() - start };
|
|
683
|
+
}
|
|
684
|
+
// ── Main ──────────────────────────────────────────────────────────────────
|
|
685
|
+
export async function runStealthAudit(options = {}) {
|
|
686
|
+
const mode = options.mode ?? "active";
|
|
687
|
+
if (mode === "compare") {
|
|
688
|
+
// Run all three modes sequentially and produce comparison
|
|
689
|
+
const modesToRun = ["off", "passive", "active"];
|
|
690
|
+
const modeResults = [];
|
|
691
|
+
for (const m of modesToRun) {
|
|
692
|
+
if (!options.json) {
|
|
693
|
+
console.log(`Running audit with mode: ${m}...`);
|
|
694
|
+
}
|
|
695
|
+
const result = await runAuditForMode(m, options);
|
|
696
|
+
modeResults.push({ mode: m, ...result });
|
|
697
|
+
}
|
|
698
|
+
if (options.json) {
|
|
699
|
+
printCompareJSON(modeResults);
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
printCompare(modeResults);
|
|
703
|
+
}
|
|
704
|
+
// Exit 0 for compare mode — it's informational
|
|
705
|
+
process.exit(0);
|
|
706
|
+
}
|
|
707
|
+
// Single-mode run
|
|
708
|
+
const stealthMode = mode;
|
|
709
|
+
const { results, durationMs } = await runAuditForMode(stealthMode, options);
|
|
710
|
+
if (options.json) {
|
|
711
|
+
printJSON(results, durationMs, mode);
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
printResults(results, durationMs, mode);
|
|
715
|
+
}
|
|
716
|
+
// Exit code: unexpected failures only (expected failures are fine)
|
|
717
|
+
const hasUnexpectedFail = results.some((r) => r.status === "fail" && !r.expected);
|
|
718
|
+
process.exit(hasUnexpectedFail ? 1 : 0);
|
|
719
|
+
}
|