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.
Files changed (57) hide show
  1. package/dist/adaptive-wait.d.ts +72 -0
  2. package/dist/adaptive-wait.js +695 -0
  3. package/dist/api-intelligence.d.ts +82 -0
  4. package/dist/api-intelligence.js +575 -0
  5. package/dist/captcha-solver.d.ts +26 -0
  6. package/dist/captcha-solver.js +547 -0
  7. package/dist/cdp-connector.d.ts +33 -0
  8. package/dist/cdp-connector.js +176 -0
  9. package/dist/consent-dismiss.d.ts +33 -0
  10. package/dist/consent-dismiss.js +358 -0
  11. package/dist/crash-recovery.d.ts +74 -0
  12. package/dist/crash-recovery.js +242 -0
  13. package/dist/domain-knowledge.d.ts +149 -0
  14. package/dist/domain-knowledge.js +449 -0
  15. package/dist/harness-intelligence.d.ts +65 -0
  16. package/dist/harness-intelligence.js +432 -0
  17. package/dist/humanize-fingerprint.d.ts +42 -0
  18. package/dist/humanize-fingerprint.js +161 -0
  19. package/dist/humanize-mouse.d.ts +95 -0
  20. package/dist/humanize-mouse.js +275 -0
  21. package/dist/humanize-pause.d.ts +48 -0
  22. package/dist/humanize-pause.js +111 -0
  23. package/dist/humanize-scroll.d.ts +67 -0
  24. package/dist/humanize-scroll.js +185 -0
  25. package/dist/humanize-typing.d.ts +60 -0
  26. package/dist/humanize-typing.js +258 -0
  27. package/dist/humanize-utils.d.ts +62 -0
  28. package/dist/humanize-utils.js +100 -0
  29. package/dist/intervention.d.ts +65 -0
  30. package/dist/intervention.js +591 -0
  31. package/dist/logger.d.ts +13 -0
  32. package/dist/logger.js +47 -0
  33. package/dist/network-intelligence.d.ts +70 -0
  34. package/dist/network-intelligence.js +424 -0
  35. package/dist/page-classifier.d.ts +33 -0
  36. package/dist/page-classifier.js +1000 -0
  37. package/dist/paginate.d.ts +42 -0
  38. package/dist/paginate.js +693 -0
  39. package/dist/recording.d.ts +72 -0
  40. package/dist/recording.js +934 -0
  41. package/dist/script-executor.d.ts +31 -0
  42. package/dist/script-executor.js +249 -0
  43. package/dist/session-hud.d.ts +20 -0
  44. package/dist/session-hud.js +134 -0
  45. package/dist/snapshot-differ.d.ts +26 -0
  46. package/dist/snapshot-differ.js +225 -0
  47. package/dist/ssrf.d.ts +28 -0
  48. package/dist/ssrf.js +290 -0
  49. package/dist/stealth-audit.d.ts +27 -0
  50. package/dist/stealth-audit.js +719 -0
  51. package/dist/stealth.d.ts +195 -0
  52. package/dist/stealth.js +1157 -0
  53. package/dist/tab-manager.d.ts +14 -0
  54. package/dist/tab-manager.js +306 -0
  55. package/dist/tiles-coordinator.d.ts +106 -0
  56. package/dist/tiles-coordinator.js +358 -0
  57. 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
+ }