jaku.sh 1.0.0

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 (69) hide show
  1. package/LICENSE +52 -0
  2. package/README.md +636 -0
  3. package/action.yml +264 -0
  4. package/bin/jaku +2 -0
  5. package/package.json +62 -0
  6. package/src/agents/ai-agent.js +175 -0
  7. package/src/agents/api-agent.js +95 -0
  8. package/src/agents/base-agent.js +158 -0
  9. package/src/agents/crawl-agent.js +175 -0
  10. package/src/agents/event-bus.js +59 -0
  11. package/src/agents/findings-ledger.js +410 -0
  12. package/src/agents/logic-agent.js +144 -0
  13. package/src/agents/orchestrator.js +323 -0
  14. package/src/agents/qa-agent.js +149 -0
  15. package/src/agents/security-agent.js +211 -0
  16. package/src/cli.js +423 -0
  17. package/src/core/accessibility-checker.js +171 -0
  18. package/src/core/ai/ai-endpoint-detector.js +227 -0
  19. package/src/core/ai/guardrail-prober.js +362 -0
  20. package/src/core/ai/indirect-injector.js +106 -0
  21. package/src/core/ai/jailbreak-tester.js +212 -0
  22. package/src/core/ai/model-dos-tester.js +174 -0
  23. package/src/core/ai/model-fingerprinter.js +246 -0
  24. package/src/core/ai/multi-turn-attacker.js +297 -0
  25. package/src/core/ai/output-analyzer.js +182 -0
  26. package/src/core/ai/prompt-injector.js +543 -0
  27. package/src/core/ai/system-prompt-extractor.js +244 -0
  28. package/src/core/api/api-key-auditor.js +266 -0
  29. package/src/core/api/auth-flow-tester.js +430 -0
  30. package/src/core/api/cors-ws-tester.js +263 -0
  31. package/src/core/api/graphql-tester.js +287 -0
  32. package/src/core/api/oauth-prober.js +343 -0
  33. package/src/core/auth-manager.js +902 -0
  34. package/src/core/broken-flow-detector.js +207 -0
  35. package/src/core/browser-manager.js +119 -0
  36. package/src/core/console-monitor.js +111 -0
  37. package/src/core/crawler.js +430 -0
  38. package/src/core/csr-waiter.js +410 -0
  39. package/src/core/form-validator.js +240 -0
  40. package/src/core/logic/abuse-pattern-scanner.js +291 -0
  41. package/src/core/logic/access-boundary-tester.js +448 -0
  42. package/src/core/logic/business-rule-inferrer.js +196 -0
  43. package/src/core/logic/graphql-auditor.js +298 -0
  44. package/src/core/logic/parameter-polluter.js +212 -0
  45. package/src/core/logic/pricing-exploiter.js +299 -0
  46. package/src/core/logic/race-condition-detector.js +222 -0
  47. package/src/core/logic/workflow-enforcer.js +284 -0
  48. package/src/core/performance-checker.js +204 -0
  49. package/src/core/responsive-checker.js +228 -0
  50. package/src/core/security/cors-prober.js +150 -0
  51. package/src/core/security/csrf-prober.js +217 -0
  52. package/src/core/security/dependency-auditor.js +182 -0
  53. package/src/core/security/file-upload-tester.js +340 -0
  54. package/src/core/security/header-analyzer.js +324 -0
  55. package/src/core/security/infra-scanner.js +391 -0
  56. package/src/core/security/path-traversal.js +112 -0
  57. package/src/core/security/prototype-pollution.js +147 -0
  58. package/src/core/security/secret-detector.js +517 -0
  59. package/src/core/security/sqli-prober.js +257 -0
  60. package/src/core/security/tls-checker.js +223 -0
  61. package/src/core/security/xss-scanner.js +225 -0
  62. package/src/core/test-generator.js +339 -0
  63. package/src/core/test-runner.js +398 -0
  64. package/src/reporting/diff-reporter.js +172 -0
  65. package/src/reporting/report-generator.js +408 -0
  66. package/src/reporting/sarif-generator.js +190 -0
  67. package/src/utils/config.js +57 -0
  68. package/src/utils/finding.js +67 -0
  69. package/src/utils/logger.js +50 -0
@@ -0,0 +1,902 @@
1
+ import { BrowserManager } from './browser-manager.js';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import readline from 'readline';
5
+ import { Writable } from 'stream';
6
+
7
+ /**
8
+ * AuthManager — Handles authenticated scanning.
9
+ *
10
+ * Supports 3 login strategies:
11
+ * 1. Form-based: Auto-detects login forms, fills credentials, submits
12
+ * 2. API-based: POSTs JSON to login endpoint, extracts tokens
13
+ * 3. Cookie injection: Uses pre-configured session cookies
14
+ *
15
+ * Stores Playwright storageState (cookies + localStorage) per role.
16
+ * All downstream agents inherit auth context automatically.
17
+ */
18
+ export class AuthManager {
19
+ constructor(config, logger) {
20
+ this.config = config;
21
+ this.logger = logger;
22
+ this.authStates = new Map(); // role → { state, postLoginUrl, discoveredLinks }
23
+ this.authTokens = new Map(); // role → { token, type }
24
+ this._browser = null;
25
+ }
26
+
27
+ /**
28
+ * Authenticate all configured credentials.
29
+ * If no credentials are configured but a login form is detected,
30
+ * interactively prompts the user in the terminal.
31
+ * Returns a Map of role → storageState for Playwright contexts.
32
+ */
33
+ async authenticate() {
34
+ let credentials = this.config.credentials || [];
35
+
36
+ // If no credentials configured, check for login form and prompt interactively
37
+ if (credentials.filter(c => c.username && c.password).length === 0) {
38
+ const loginUrl = this.config.auth?.login_url || await this._discoverLoginPage();
39
+ if (loginUrl) {
40
+ const prompted = await this._promptForCredentials(loginUrl);
41
+ if (!prompted) {
42
+ this.logger?.info?.('No credentials provided — scanning unauthenticated');
43
+ return this.authStates;
44
+ }
45
+ // _promptForCredentials already verified + stored the auth state
46
+ // so we can return directly
47
+ this.logger?.info?.(`Auth complete: ${this.authStates.size} role(s) authenticated`);
48
+ return this.authStates;
49
+ } else {
50
+ this.logger?.info?.('No login form detected — scanning unauthenticated');
51
+ return this.authStates;
52
+ }
53
+ }
54
+
55
+ // CLI flags or config provided credentials — authenticate them
56
+ const authConfig = this.config.auth || {};
57
+ this._browser = await BrowserManager.launch({ headless: true });
58
+
59
+ for (const cred of credentials) {
60
+ if (!cred.username || !cred.password) {
61
+ this.logger?.debug?.(`Skipping role "${cred.role}" — no username/password`);
62
+ continue;
63
+ }
64
+
65
+ try {
66
+ const strategy = authConfig.strategy || 'auto';
67
+ this.logger?.info?.(`Authenticating as "${cred.role}" via ${strategy}...`);
68
+
69
+ let state;
70
+ switch (strategy) {
71
+ case 'form':
72
+ state = await this._loginViaForm(cred, authConfig);
73
+ break;
74
+ case 'api':
75
+ state = await this._loginViaAPI(cred, authConfig);
76
+ break;
77
+ case 'cookie':
78
+ state = await this._injectCookies(cred, authConfig);
79
+ break;
80
+ case 'auto':
81
+ default:
82
+ state = await this._autoLogin(cred, authConfig);
83
+ break;
84
+ }
85
+
86
+ if (state) {
87
+ this.authStates.set(cred.role, state);
88
+ const cookieCount = state.state?.cookies?.length || state.cookies?.length || 0;
89
+ this.logger?.info?.(`✔ Authenticated as "${cred.role}" — ${cookieCount} cookies stored`);
90
+ } else {
91
+ this.logger?.warn?.(`✘ Authentication failed for "${cred.role}"`);
92
+ }
93
+ } catch (err) {
94
+ this.logger?.error?.(`Auth error for "${cred.role}": ${err.message}`);
95
+ }
96
+ }
97
+
98
+ await this._browser.close();
99
+ this._browser = null;
100
+
101
+ this.logger?.info?.(`Auth complete: ${this.authStates.size}/${credentials.filter(c => c.username).length} roles authenticated`);
102
+ return this.authStates;
103
+ }
104
+
105
+ /**
106
+ * Auto-detect login strategy: try form first, fall back to API.
107
+ */
108
+ async _autoLogin(cred, authConfig) {
109
+ // Step 1: Find login page
110
+ const loginUrl = authConfig.login_url || await this._discoverLoginPage();
111
+ if (!loginUrl) {
112
+ this.logger?.debug?.('No login page found — trying API login');
113
+ return this._loginViaAPI(cred, authConfig);
114
+ }
115
+
116
+ // Step 2: Try form-based login
117
+ const state = await this._loginViaForm(cred, { ...authConfig, login_url: loginUrl });
118
+ if (state) return state;
119
+
120
+ // Step 3: Fall back to API login
121
+ return this._loginViaAPI(cred, authConfig);
122
+ }
123
+
124
+ /**
125
+ * Form-based login: navigate to login page, fill form, submit.
126
+ */
127
+ async _loginViaForm(cred, authConfig) {
128
+ const loginUrl = authConfig.login_url || `${this.config.target_url}/login`;
129
+ const context = await this._browser.newContext({
130
+ viewport: { width: 1440, height: 900 },
131
+ ignoreHTTPSErrors: true,
132
+ });
133
+ const page = await context.newPage();
134
+
135
+ try {
136
+ await page.goto(loginUrl, { waitUntil: 'networkidle', timeout: 15000 });
137
+
138
+ // Find the login form
139
+ const usernameSelector = authConfig.username_selector || await this._findUsernameField(page);
140
+ const passwordSelector = authConfig.password_selector || await this._findPasswordField(page);
141
+ const submitSelector = authConfig.submit_selector || await this._findSubmitButton(page);
142
+
143
+ if (!usernameSelector || !passwordSelector) {
144
+ this.logger?.debug?.('Could not find login form fields');
145
+ await page.close();
146
+ await context.close();
147
+ return null;
148
+ }
149
+
150
+ // Fill credentials
151
+ await page.fill(usernameSelector, cred.username);
152
+ await page.fill(passwordSelector, cred.password);
153
+
154
+ // Submit and wait for navigation
155
+ const [response] = await Promise.all([
156
+ page.waitForNavigation({ waitUntil: 'networkidle', timeout: 10000 }).catch(() => null),
157
+ submitSelector
158
+ ? page.click(submitSelector)
159
+ : page.keyboard.press('Enter'),
160
+ ]);
161
+
162
+ // Verify login succeeded
163
+ const loggedIn = await this._verifyLogin(page, loginUrl);
164
+ if (!loggedIn) {
165
+ this.logger?.debug?.(`Form login failed for "${cred.role}" — still on login page`);
166
+ await page.close();
167
+ await context.close();
168
+ return null;
169
+ }
170
+
171
+ // Capture post-login URL (e.g., /dashboard, /app, /home)
172
+ const postLoginUrl = page.url();
173
+ this.logger?.debug?.(`Post-login URL: ${postLoginUrl}`);
174
+
175
+ // Discover links on the post-login page (authenticated pages to crawl)
176
+ const discoveredLinks = await page.evaluate(() => {
177
+ const anchors = Array.from(document.querySelectorAll('a[href]'));
178
+ return anchors.map(a => a.href)
179
+ .filter(href => href && !href.startsWith('javascript:') && !href.startsWith('mailto:'));
180
+ }).catch(() => []);
181
+
182
+ this.logger?.debug?.(`Post-login page has ${discoveredLinks.length} links`);
183
+
184
+ // Extract storage state (cookies + localStorage)
185
+ const state = await context.storageState();
186
+ await page.close();
187
+ await context.close();
188
+ return { state, postLoginUrl, discoveredLinks };
189
+ } catch (err) {
190
+ this.logger?.debug?.(`Form login error: ${err.message}`);
191
+ await page.close().catch(() => { });
192
+ await context.close().catch(() => { });
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * API-based login: POST credentials to login endpoint.
199
+ */
200
+ async _loginViaAPI(cred, authConfig) {
201
+ const loginEndpoints = authConfig.api_login_url
202
+ ? [authConfig.api_login_url]
203
+ : [
204
+ `${this.config.target_url}/api/auth/login`,
205
+ `${this.config.target_url}/api/login`,
206
+ `${this.config.target_url}/auth/login`,
207
+ `${this.config.target_url}/api/v1/auth/login`,
208
+ `${this.config.target_url}/api/sessions`,
209
+ ];
210
+
211
+ for (const endpoint of loginEndpoints) {
212
+ try {
213
+ const resp = await fetch(endpoint, {
214
+ method: 'POST',
215
+ headers: { 'Content-Type': 'application/json' },
216
+ body: JSON.stringify({
217
+ email: cred.username,
218
+ username: cred.username,
219
+ password: cred.password,
220
+ }),
221
+ signal: AbortSignal.timeout(10000),
222
+ });
223
+
224
+ if (resp.ok) {
225
+ const data = await resp.json().catch(() => ({}));
226
+ const token = data.token || data.access_token || data.accessToken
227
+ || data.jwt || data.session_token || data.data?.token;
228
+
229
+ if (token) {
230
+ this.authTokens.set(cred.role, {
231
+ token,
232
+ type: data.token_type || 'Bearer',
233
+ });
234
+
235
+ // Create a browser context with the token as a cookie
236
+ const context = await this._browser.newContext({
237
+ ignoreHTTPSErrors: true,
238
+ });
239
+
240
+ // Set cookies from response
241
+ const setCookieHeaders = resp.headers.getSetCookie?.() || [];
242
+ if (setCookieHeaders.length > 0) {
243
+ const cookies = this._parseSetCookieHeaders(setCookieHeaders);
244
+ await context.addCookies(cookies);
245
+ }
246
+
247
+ // Also add token as localStorage
248
+ const page = await context.newPage();
249
+ await page.goto(this.config.target_url, { waitUntil: 'domcontentloaded', timeout: 10000 });
250
+ await page.evaluate((tkn) => {
251
+ localStorage.setItem('token', tkn);
252
+ localStorage.setItem('access_token', tkn);
253
+ localStorage.setItem('auth_token', tkn);
254
+ }, token);
255
+
256
+ const state = await context.storageState();
257
+ await page.close();
258
+ await context.close();
259
+
260
+ this.logger?.debug?.(`API login succeeded at ${endpoint}`);
261
+ return state;
262
+ }
263
+
264
+ // No token but 200 — might use cookies only
265
+ const setCookieHeaders = resp.headers.getSetCookie?.() || [];
266
+ if (setCookieHeaders.length > 0) {
267
+ const context = await this._browser.newContext({ ignoreHTTPSErrors: true });
268
+ const cookies = this._parseSetCookieHeaders(setCookieHeaders);
269
+ await context.addCookies(cookies);
270
+ const state = await context.storageState();
271
+ await context.close();
272
+ this.logger?.debug?.(`API login succeeded at ${endpoint} (cookie-based)`);
273
+ return state;
274
+ }
275
+ }
276
+ } catch {
277
+ // Try next endpoint
278
+ }
279
+ }
280
+
281
+ return null;
282
+ }
283
+
284
+ /**
285
+ * Cookie injection: use pre-configured cookies from config.
286
+ */
287
+ async _injectCookies(cred, authConfig) {
288
+ const cookies = cred.cookies || authConfig.cookies || [];
289
+ if (cookies.length === 0) return null;
290
+
291
+ const context = await this._browser.newContext({ ignoreHTTPSErrors: true });
292
+ const targetUrl = new URL(this.config.target_url);
293
+
294
+ const playwrightCookies = cookies.map(c => ({
295
+ name: c.name,
296
+ value: c.value,
297
+ domain: c.domain || targetUrl.hostname,
298
+ path: c.path || '/',
299
+ httpOnly: c.httpOnly ?? true,
300
+ secure: c.secure ?? targetUrl.protocol === 'https:',
301
+ sameSite: c.sameSite || 'Lax',
302
+ }));
303
+
304
+ await context.addCookies(playwrightCookies);
305
+ const state = await context.storageState();
306
+ await context.close();
307
+ return state;
308
+ }
309
+
310
+ // ═══ Helper Methods ═══
311
+
312
+ /**
313
+ * Discover login page by probing common login URLs.
314
+ * Uses Playwright to render pages (SPAs render login forms via JS).
315
+ */
316
+ async _discoverLoginPage() {
317
+ const paths = ['/login', '/signin', '/auth/login', '/auth/signin', '/sign-in',
318
+ '/account/login', '/user/login', '/admin/login', '/api/auth/signin',
319
+ '/sign_in', '/users/sign_in', '/session/new'];
320
+ const baseUrl = this.config.target_url;
321
+
322
+ // Launch a browser to render pages (SPA login forms are JS-rendered)
323
+ let browser;
324
+ try {
325
+ browser = await BrowserManager.launch({ headless: true });
326
+ } catch {
327
+ // Fallback to fetch if browser can't launch
328
+ return this._discoverLoginPageViaFetch();
329
+ }
330
+
331
+ const context = await browser.newContext({
332
+ viewport: { width: 1440, height: 900 },
333
+ ignoreHTTPSErrors: true,
334
+ });
335
+
336
+ try {
337
+ for (const p of paths) {
338
+ try {
339
+ const url = new URL(p, baseUrl).href;
340
+ const page = await context.newPage();
341
+
342
+ const response = await page.goto(url, {
343
+ waitUntil: 'networkidle',
344
+ timeout: 10000,
345
+ });
346
+
347
+ if (!response || response.status() >= 400) {
348
+ await page.close();
349
+ continue;
350
+ }
351
+
352
+ // Check the rendered DOM for password fields
353
+ const hasLoginForm = await page.evaluate(() => {
354
+ const passwordField = document.querySelector('input[type="password"]');
355
+ if (passwordField) return true;
356
+
357
+ // Check for login-related text in the page
358
+ const bodyText = document.body?.textContent?.toLowerCase() || '';
359
+ const hasLoginText = /sign\s*in|log\s*in|authenticate|enter.*password/i.test(bodyText);
360
+ const hasEmailField = !!document.querySelector('input[type="email"], input[name="email"], input[name="username"]');
361
+ return hasLoginText && hasEmailField;
362
+ });
363
+
364
+ await page.close();
365
+
366
+ if (hasLoginForm) {
367
+ this.logger?.info?.(`Discovered login page: ${url}`);
368
+ await context.close();
369
+ await browser.close();
370
+ return url;
371
+ }
372
+ } catch {
373
+ // Skip this path
374
+ }
375
+ }
376
+
377
+ // Also check the homepage itself — some SPAs have login right on the main page
378
+ try {
379
+ const page = await context.newPage();
380
+ await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 10000 });
381
+
382
+ // Check if login form is directly on the homepage
383
+ const hasPasswordOnHome = await page.$('input[type="password"]');
384
+ if (hasPasswordOnHome) {
385
+ this.logger?.info?.(`Discovered login form on homepage: ${baseUrl}`);
386
+ await page.close();
387
+ await context.close();
388
+ await browser.close();
389
+ return baseUrl;
390
+ }
391
+
392
+ // Check for login links that might lead to a login form
393
+ const loginLink = await page.$('a:has-text("Log in"), a:has-text("Sign in"), a:has-text("Login"), a:has-text("Sign In")');
394
+ if (loginLink) {
395
+ await loginLink.click();
396
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { });
397
+ const loginUrl = page.url();
398
+ const hasPasswordNow = await page.$('input[type="password"]');
399
+ await page.close();
400
+ if (hasPasswordNow) {
401
+ this.logger?.info?.(`Discovered login page via navigation: ${loginUrl}`);
402
+ await context.close();
403
+ await browser.close();
404
+ return loginUrl;
405
+ }
406
+ }
407
+ await page.close();
408
+ } catch { /* skip */ }
409
+ } finally {
410
+ await context.close().catch(() => { });
411
+ await browser.close().catch(() => { });
412
+ }
413
+
414
+ return null;
415
+ }
416
+
417
+ /**
418
+ * Fallback login page discovery using fetch (for non-SPA sites).
419
+ */
420
+ async _discoverLoginPageViaFetch() {
421
+ const paths = ['/login', '/signin', '/auth/login', '/auth/signin', '/sign-in',
422
+ '/account/login', '/user/login', '/admin/login'];
423
+ const baseUrl = this.config.target_url;
424
+
425
+ for (const p of paths) {
426
+ try {
427
+ const url = new URL(p, baseUrl).href;
428
+ const resp = await fetch(url, {
429
+ redirect: 'follow',
430
+ signal: AbortSignal.timeout(5000),
431
+ });
432
+ if (resp.ok) {
433
+ const body = await resp.text();
434
+ if (body.match(/type=["']password["']/i) ||
435
+ body.match(/login|sign.?in|authenticate/i)) {
436
+ this.logger?.debug?.(`Discovered login page via fetch: ${url}`);
437
+ return url;
438
+ }
439
+ }
440
+ } catch { /* skip */ }
441
+ }
442
+ return null;
443
+ }
444
+
445
+ /**
446
+ * Interactively prompt the user for credentials in the terminal.
447
+ * Retries up to 3 times on auth failure.
448
+ * Returns { role, username, password } or null if skipped.
449
+ */
450
+ async _promptForCredentials(loginUrl) {
451
+ // Don't prompt in CI/CD or non-interactive environments
452
+ if (!process.stdin.isTTY) return null;
453
+
454
+ // Stop the spinner before prompting (hook set by CLI)
455
+ this._onBeforePrompt?.();
456
+
457
+ const chalk = await import('chalk').then(m => m.default).catch(() => ({ hex: () => s => s, dim: s => s, yellow: s => s, green: s => s, red: s => s, bold: s => s }));
458
+
459
+ console.log();
460
+ console.log(chalk.yellow(' 🔑 Login form detected at: ') + chalk.dim(loginUrl));
461
+ console.log(chalk.dim(' Enter credentials for authenticated scanning, or press Enter to skip.'));
462
+
463
+ const MAX_RETRIES = 3;
464
+
465
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
466
+ console.log();
467
+
468
+ if (attempt > 1) {
469
+ console.log(chalk.red(` ✘ Login failed. Attempt ${attempt}/${MAX_RETRIES} — try again or press Enter to skip.`));
470
+ console.log();
471
+ }
472
+
473
+ const username = await this._ask(chalk.dim(' Username/Email: '));
474
+ if (!username) {
475
+ console.log(chalk.dim(' Skipped — scanning unauthenticated.'));
476
+ console.log();
477
+ return null;
478
+ }
479
+
480
+ const password = await this._askSecret(chalk.dim(' Password: '));
481
+ if (!password) {
482
+ console.log(chalk.dim(' Skipped — scanning unauthenticated.'));
483
+ console.log();
484
+ return null;
485
+ }
486
+
487
+ const role = attempt === 1
488
+ ? (await this._ask(chalk.dim(' Role name (default: user): ')) || 'user')
489
+ : 'user';
490
+
491
+ const cred = { role, username, password };
492
+
493
+ // Try to actually authenticate with these credentials
494
+ console.log(chalk.dim(' Verifying credentials...'));
495
+
496
+ this._browser = this._browser || await BrowserManager.launch({ headless: true });
497
+ const authConfig = this.config.auth || {};
498
+ const state = await this._autoLogin(cred, { ...authConfig, login_url: loginUrl });
499
+
500
+ if (state) {
501
+ this.authStates.set(role, state);
502
+ console.log(chalk.green(` ✔ Login successful as "${role}"`));
503
+ console.log();
504
+ await this._browser.close();
505
+ this._browser = null;
506
+ return cred;
507
+ }
508
+
509
+ // Last attempt failed
510
+ if (attempt === MAX_RETRIES) {
511
+ console.log(chalk.red(` ✘ Login failed after ${MAX_RETRIES} attempts — scanning unauthenticated.`));
512
+ console.log();
513
+ await this._browser.close();
514
+ this._browser = null;
515
+ return null;
516
+ }
517
+ }
518
+
519
+ return null;
520
+ }
521
+
522
+ /**
523
+ * Prompt for regular text input.
524
+ */
525
+ _ask(prompt) {
526
+ return new Promise((resolve) => {
527
+ const rl = readline.createInterface({
528
+ input: process.stdin,
529
+ output: process.stdout,
530
+ });
531
+ rl.question(prompt, (answer) => {
532
+ rl.close();
533
+ resolve(answer.trim());
534
+ });
535
+ });
536
+ }
537
+
538
+ /**
539
+ * Prompt for password input with masking (shows * for each character).
540
+ * Uses a muted output stream to prevent raw character echo.
541
+ */
542
+ _askSecret(prompt) {
543
+ return new Promise((resolve) => {
544
+ // Write the prompt
545
+ process.stdout.write(prompt);
546
+
547
+ let secret = '';
548
+
549
+ // Create a muted writable stream that swallows all output
550
+ const mutedOut = new Writable({
551
+ write(_chunk, _encoding, callback) {
552
+ callback(); // swallow all output — we handle display manually
553
+ },
554
+ });
555
+
556
+ const rl = readline.createInterface({
557
+ input: process.stdin,
558
+ output: mutedOut,
559
+ terminal: true,
560
+ });
561
+
562
+ // Listen for keypress events
563
+ process.stdin.setRawMode(true);
564
+
565
+ const onData = (buf) => {
566
+ const c = buf.toString();
567
+
568
+ if (c === '\n' || c === '\r' || c === '\u0004') {
569
+ // Enter — done
570
+ process.stdin.setRawMode(false);
571
+ process.stdin.removeListener('data', onData);
572
+ process.stdout.write('\n');
573
+ rl.close();
574
+ resolve(secret);
575
+ } else if (c === '\u0003') {
576
+ // Ctrl+C
577
+ process.stdin.setRawMode(false);
578
+ process.stdin.removeListener('data', onData);
579
+ rl.close();
580
+ process.exit(0);
581
+ } else if (c === '\u007F' || c === '\b') {
582
+ // Backspace
583
+ if (secret.length > 0) {
584
+ secret = secret.slice(0, -1);
585
+ process.stdout.write('\b \b');
586
+ }
587
+ } else if (c.charCodeAt(0) >= 32) {
588
+ // Printable character only
589
+ secret += c;
590
+ process.stdout.write('•');
591
+ }
592
+ };
593
+
594
+ process.stdin.on('data', onData);
595
+ process.stdin.resume();
596
+ });
597
+ }
598
+
599
+ /**
600
+ * Auto-detect username field selector.
601
+ */
602
+ async _findUsernameField(page) {
603
+ const selectors = [
604
+ 'input[type="email"]',
605
+ 'input[name="email"]',
606
+ 'input[name="username"]',
607
+ 'input[name="user"]',
608
+ 'input[name="login"]',
609
+ 'input[id="email"]',
610
+ 'input[id="username"]',
611
+ 'input[autocomplete="email"]',
612
+ 'input[autocomplete="username"]',
613
+ 'input[type="text"]:first-of-type',
614
+ ];
615
+ for (const sel of selectors) {
616
+ const el = await page.$(sel);
617
+ if (el) return sel;
618
+ }
619
+ return null;
620
+ }
621
+
622
+ /**
623
+ * Auto-detect password field selector.
624
+ */
625
+ async _findPasswordField(page) {
626
+ const selectors = [
627
+ 'input[type="password"]',
628
+ 'input[name="password"]',
629
+ 'input[name="passwd"]',
630
+ 'input[name="pass"]',
631
+ 'input[id="password"]',
632
+ 'input[autocomplete="current-password"]',
633
+ ];
634
+ for (const sel of selectors) {
635
+ const el = await page.$(sel);
636
+ if (el) return sel;
637
+ }
638
+ return null;
639
+ }
640
+
641
+ /**
642
+ * Auto-detect submit button selector.
643
+ */
644
+ async _findSubmitButton(page) {
645
+ const selectors = [
646
+ 'button[type="submit"]',
647
+ 'input[type="submit"]',
648
+ 'button:has-text("Log in")',
649
+ 'button:has-text("Sign in")',
650
+ 'button:has-text("Login")',
651
+ 'button:has-text("Submit")',
652
+ 'form button',
653
+ ];
654
+ for (const sel of selectors) {
655
+ const el = await page.$(sel);
656
+ if (el) return sel;
657
+ }
658
+ return null;
659
+ }
660
+
661
+ /**
662
+ * Verify login succeeded using multiple strict signals.
663
+ * Returns true only when strong evidence of successful login exists.
664
+ */
665
+ async _verifyLogin(page, loginUrl) {
666
+ // Wait a moment for SPAs to settle (toasts, redirects, state changes)
667
+ await page.waitForTimeout(1500);
668
+
669
+ const currentUrl = page.url();
670
+
671
+ // ── FAIL FAST: Check for error indicators ──
672
+ const hasErrors = await page.evaluate(() => {
673
+ const body = document.body?.textContent?.toLowerCase() || '';
674
+ const errorPatterns = [
675
+ 'invalid password', 'incorrect password', 'wrong password',
676
+ 'invalid credentials', 'invalid email', 'invalid username',
677
+ 'authentication failed', 'login failed', 'sign in failed',
678
+ 'account not found', 'user not found', 'no account',
679
+ 'too many attempts', 'account locked', 'access denied',
680
+ 'incorrect email', 'email or password', 'check your credentials',
681
+ ];
682
+
683
+ // Check text content for error messages
684
+ if (errorPatterns.some(p => body.includes(p))) return true;
685
+
686
+ // Check for visible error/alert elements
687
+ const errorSelectors = [
688
+ '[role="alert"]', '.error', '.error-message', '.alert-error',
689
+ '.alert-danger', '.toast-error', '.notification-error',
690
+ '[class*="error"]', '[class*="Error"]',
691
+ '[data-testid*="error"]', '[aria-invalid="true"]',
692
+ ];
693
+ for (const sel of errorSelectors) {
694
+ const el = document.querySelector(sel);
695
+ if (el && el.offsetParent !== null && el.textContent.trim().length > 0) {
696
+ return true;
697
+ }
698
+ }
699
+ return false;
700
+ });
701
+
702
+ if (hasErrors) {
703
+ this.logger?.debug?.('Login verification: error indicators detected on page');
704
+ return false;
705
+ }
706
+
707
+ // ── CHECK 1: Auth cookies/tokens were set ──
708
+ const cookies = await page.context().cookies();
709
+ const authCookieNames = [
710
+ 'session', 'sess', 'sid', 'token', 'auth', 'jwt',
711
+ 'access_token', 'refresh_token', '_session', 'connect.sid',
712
+ 'sb-access-token', 'sb-refresh-token', // Supabase
713
+ 'next-auth', '__session', // Next.js/Firebase
714
+ ];
715
+ const hasAuthCookies = cookies.some(c =>
716
+ authCookieNames.some(name =>
717
+ c.name.toLowerCase().includes(name)
718
+ )
719
+ );
720
+
721
+ // Check localStorage for tokens
722
+ const hasLocalStorageAuth = await page.evaluate(() => {
723
+ const tokenKeys = ['token', 'access_token', 'auth_token', 'jwt', 'session',
724
+ 'sb-access-token', 'supabase.auth.token'];
725
+ for (const key of tokenKeys) {
726
+ if (localStorage.getItem(key)) return true;
727
+ }
728
+ // Check all keys for auth-related values
729
+ for (let i = 0; i < localStorage.length; i++) {
730
+ const k = localStorage.key(i);
731
+ if (k && (k.includes('auth') || k.includes('token') || k.includes('session'))) {
732
+ const v = localStorage.getItem(k);
733
+ if (v && v.length > 10) return true;
734
+ }
735
+ }
736
+ return false;
737
+ }).catch(() => false);
738
+
739
+ if (hasAuthCookies || hasLocalStorageAuth) {
740
+ this.logger?.debug?.('Login verification: auth cookies/tokens detected');
741
+ return true;
742
+ }
743
+
744
+ // ── CHECK 2: URL navigated away from login page ──
745
+ const loginPaths = ['/login', '/signin', '/sign-in', '/auth/login', '/auth/signin'];
746
+ const wasOnLoginPath = loginPaths.some(p => loginUrl.includes(p));
747
+ const isStillOnLoginPath = loginPaths.some(p => currentUrl.includes(p));
748
+
749
+ if (wasOnLoginPath && !isStillOnLoginPath && currentUrl !== loginUrl) {
750
+ this.logger?.debug?.(`Login verification: URL changed from ${loginUrl} to ${currentUrl}`);
751
+ return true;
752
+ }
753
+
754
+ // ── CHECK 3: Page no longer has password field AND has logged-in UI ──
755
+ const hasPasswordField = await page.$('input[type="password"]');
756
+ if (!hasPasswordField) {
757
+ // Only trust this if we also see logged-in indicators
758
+ const hasLoggedInUI = await page.evaluate(() => {
759
+ // Look for logout buttons/links (text-based search — :has-text isn't valid CSS)
760
+ const buttons = Array.from(document.querySelectorAll('button, a'));
761
+ const hasLogoutBtn = buttons.some(el => {
762
+ const text = el.textContent?.trim().toLowerCase() || '';
763
+ return text === 'log out' || text === 'sign out' ||
764
+ text === 'logout' || text === 'signout' ||
765
+ text === 'disconnect' || text === 'exit';
766
+ });
767
+ if (hasLogoutBtn) return true;
768
+
769
+ // Check for aria-label based logout
770
+ const ariaLogout = document.querySelector(
771
+ '[aria-label*="logout"], [aria-label*="sign out"], [aria-label*="log out"]'
772
+ );
773
+ if (ariaLogout) return true;
774
+
775
+ // Check for avatar/profile menu (common logged-in indicator)
776
+ const avatar = document.querySelector(
777
+ '[class*="avatar"], [class*="Avatar"], ' +
778
+ '[data-testid*="avatar"], [data-testid*="user-menu"]'
779
+ );
780
+ if (avatar) return true;
781
+
782
+ return false;
783
+ });
784
+
785
+ if (hasLoggedInUI) {
786
+ this.logger?.debug?.('Login verification: password field gone + logged-in UI detected');
787
+ return true;
788
+ }
789
+ }
790
+
791
+ this.logger?.debug?.('Login verification: no strong evidence of successful login');
792
+ return false;
793
+ }
794
+
795
+ /**
796
+ * Parse Set-Cookie headers into Playwright cookie format.
797
+ */
798
+ _parseSetCookieHeaders(headers) {
799
+ const targetUrl = new URL(this.config.target_url);
800
+ return headers.map(header => {
801
+ const parts = header.split(';').map(p => p.trim());
802
+ const [nameValue, ...attrs] = parts;
803
+ const eqIdx = nameValue.indexOf('=');
804
+ const name = nameValue.substring(0, eqIdx);
805
+ const value = nameValue.substring(eqIdx + 1);
806
+
807
+ const cookie = {
808
+ name,
809
+ value,
810
+ domain: targetUrl.hostname,
811
+ path: '/',
812
+ };
813
+
814
+ for (const attr of attrs) {
815
+ const [key, val] = attr.split('=').map(s => s.trim());
816
+ switch (key.toLowerCase()) {
817
+ case 'domain': cookie.domain = val; break;
818
+ case 'path': cookie.path = val; break;
819
+ case 'httponly': cookie.httpOnly = true; break;
820
+ case 'secure': cookie.secure = true; break;
821
+ case 'samesite': cookie.sameSite = val; break;
822
+ }
823
+ }
824
+
825
+ return cookie;
826
+ }).filter(c => c.name && c.value);
827
+ }
828
+
829
+ /**
830
+ * Get Playwright storageState for a specific role.
831
+ */
832
+ getAuthState(role) {
833
+ const entry = this.authStates.get(role);
834
+ if (!entry) return null;
835
+ // Handle both old (plain state) and new ({ state, postLoginUrl }) shapes
836
+ return entry.state || entry;
837
+ }
838
+
839
+ /**
840
+ * Get post-login URL for a specific role (where the user lands after login).
841
+ */
842
+ getPostLoginUrl(role) {
843
+ const entry = this.authStates.get(role);
844
+ return entry?.postLoginUrl || null;
845
+ }
846
+
847
+ /**
848
+ * Get links discovered on the post-login page for a specific role.
849
+ */
850
+ getDiscoveredLinks(role) {
851
+ const entry = this.authStates.get(role);
852
+ return entry?.discoveredLinks || [];
853
+ }
854
+
855
+ /**
856
+ * Get auth token for a specific role (for API-based agents).
857
+ */
858
+ getAuthToken(role) {
859
+ return this.authTokens.get(role) || null;
860
+ }
861
+
862
+ /**
863
+ * Get all authenticated roles.
864
+ */
865
+ get roles() {
866
+ return [...this.authStates.keys()];
867
+ }
868
+
869
+ /**
870
+ * Check if any auth is available.
871
+ */
872
+ get isAuthenticated() {
873
+ return this.authStates.size > 0;
874
+ }
875
+
876
+ /**
877
+ * Create a Playwright browser context with auth for a given role.
878
+ */
879
+ async createAuthContext(browser, role) {
880
+ const state = this.getAuthState(role);
881
+ if (!state) return null;
882
+
883
+ return browser.newContext({
884
+ storageState: state,
885
+ viewport: { width: 1440, height: 900 },
886
+ ignoreHTTPSErrors: true,
887
+ });
888
+ }
889
+
890
+ /**
891
+ * Get fetch headers with auth token for a given role.
892
+ */
893
+ getFetchHeaders(role) {
894
+ const tokenInfo = this.authTokens.get(role);
895
+ if (!tokenInfo) return {};
896
+ return {
897
+ 'Authorization': `${tokenInfo.type} ${tokenInfo.token}`,
898
+ };
899
+ }
900
+ }
901
+
902
+ export default AuthManager;