sentinelayer-cli 0.3.0 → 0.4.5

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.
@@ -1,8 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import os from "node:os";
5
- import { randomUUID } from "node:crypto";
2
+ import { setTimeout as sleep } from "node:timers/promises";
3
+ import { assertPermittedAuditTarget } from "./url-policy.js";
6
4
 
7
5
  /**
8
6
  * Jules Tanaka — Authenticated Page Audit
@@ -31,8 +29,30 @@ const AUTH_DISPATCH = {
31
29
  check_auth_flow_security: checkAuthFlowSecurity,
32
30
  };
33
31
 
32
+ const AUTH_PLAYWRIGHT_EXEC_TIMEOUT_MS = 60_000;
33
+ const AUTH_PLAYWRIGHT_EXEC_MAX_RETRIES = 2;
34
+ const AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS = 250;
35
+ const RETRYABLE_PLAYWRIGHT_EXEC_ERROR_CODES = new Set([
36
+ "ETIMEDOUT",
37
+ "ECONNRESET",
38
+ "EPIPE",
39
+ "EAI_AGAIN",
40
+ "ECONNABORTED",
41
+ "UND_ERR_CONNECT_TIMEOUT",
42
+ "UND_ERR_HEADERS_TIMEOUT",
43
+ ]);
44
+
34
45
  async function provisionTestIdentity(input) {
35
46
  try {
47
+ const executeRequested = input.execute === true;
48
+ const allowLiveProvision = input.allowProvisioning === true || process.env.SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION === "1";
49
+ if (executeRequested && !allowLiveProvision) {
50
+ return {
51
+ available: false,
52
+ reason: "Live AIdenID provisioning requires explicit allowProvisioning=true (or SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION=1).",
53
+ };
54
+ }
55
+
36
56
  const { provisionEmailIdentity, resolveAidenIdCredentials } = await import("../../../ai/aidenid.js");
37
57
  const creds = await resolveAidenIdCredentials();
38
58
  if (!creds.apiKey) {
@@ -41,9 +61,9 @@ async function provisionTestIdentity(input) {
41
61
  const result = await provisionEmailIdentity({
42
62
  apiUrl: creds.apiUrl, apiKey: creds.apiKey,
43
63
  tags: ["jules-audit", "frontend-test"],
44
- ttlSeconds: 3600, dryRun: input.execute !== true,
64
+ ttlSeconds: 3600, dryRun: !executeRequested,
45
65
  });
46
- return { available: true, dryRun: input.execute !== true, identity: result.identity || result };
66
+ return { available: true, dryRun: !executeRequested, identity: result.identity || result };
47
67
  } catch (err) {
48
68
  return { available: false, reason: "AIdenID provisioning failed: " + err.message };
49
69
  }
@@ -51,109 +71,151 @@ async function provisionTestIdentity(input) {
51
71
 
52
72
  /**
53
73
  * Run Playwright to authenticate and inspect the page.
54
- * - URLs and credentials passed ONLY via env vars (no string interpolation)
74
+ * - Runtime values loaded from a secure temp context file (credentials not exposed in process env)
55
75
  * - Auth verification checks URL change + cookie presence (not just click success)
56
76
  * - Console errors redacted to prevent sensitive data leakage
57
77
  * - Cookie values never captured (names + flags only)
58
- * - Temp script cleanup in finally block (not just success path)
78
+ * - Temp script/context cleanup in finally block (not just success path)
59
79
  */
60
80
  async function authenticatedPageCheck(input) {
61
81
  const url = input.url;
62
82
  if (!url) throw new AuthAuditError("authenticated_page_check requires url");
63
- if (!isValidUrl(url)) throw new AuthAuditError("Invalid URL: " + url);
83
+ const targetUrl = resolveAuthAuditTarget(url, input, "authenticated_page_check.target");
64
84
 
65
- const loginUrl = input.loginUrl || url + "/login";
66
- let scriptPath = null;
85
+ const loginUrlCandidate = input.loginUrl || targetUrl + "/login";
86
+ const loginUrl = resolveAuthAuditTarget(loginUrlCandidate, input, "authenticated_page_check.login");
67
87
 
68
88
  try {
69
- scriptPath = secureTempFile("sl-auth-audit-" + randomUUID().slice(0, 8) + ".cjs");
70
- fs.writeFileSync(scriptPath, PLAYWRIGHT_AUTH_SCRIPT);
71
-
89
+ const authContextJson = JSON.stringify({
90
+ email: input.email || "",
91
+ password: input.password || "",
92
+ emailField: input.emailField || "",
93
+ passwordField: input.passwordField || "",
94
+ submitSelector: input.submitSelector || "",
95
+ });
72
96
  // Use scrubbed env — strip API keys/tokens from child process
73
97
  const { buildScrubbedEnv } = await import("./shell.js");
74
98
  const env = {
75
99
  ...buildScrubbedEnv(),
76
- SL_AUDIT_TARGET_URL: url,
100
+ SL_AUDIT_TARGET_URL: targetUrl,
77
101
  SL_AUDIT_LOGIN_URL: loginUrl,
78
- SL_AUDIT_TEST_EMAIL: input.email || "",
79
- SL_AUDIT_TEST_PASSWORD: input.password || "",
80
- SL_AUDIT_EMAIL_FIELD: input.emailField || "",
81
- SL_AUDIT_PASSWORD_FIELD: input.passwordField || "",
82
- SL_AUDIT_SUBMIT_SELECTOR: input.submitSelector || "",
83
102
  };
84
103
 
85
- const output = execFileSync("node", [scriptPath], {
86
- encoding: "utf-8", timeout: 60000,
87
- stdio: ["pipe", "pipe", "pipe"],
88
- env,
104
+ const output = await runPlaywrightAuditScriptWithRetry(null, env, {
105
+ scriptSource: PLAYWRIGHT_AUTH_SCRIPT,
106
+ stdinPayload: authContextJson,
89
107
  });
90
108
 
91
109
  const result = JSON.parse(output.trim());
92
110
  const findings = [];
93
111
  for (const cookie of (result.cookies || [])) {
94
112
  if (cookie.sensitive && !cookie.httpOnly) {
95
- findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing httpOnly flag", file: url });
113
+ findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing httpOnly flag", file: targetUrl });
96
114
  }
97
115
  if (cookie.sensitive && !cookie.secure) {
98
- findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing Secure flag", file: url });
116
+ findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing Secure flag", file: targetUrl });
99
117
  }
100
118
  if (cookie.sensitive && cookie.sameSite === "None") {
101
- findings.push({ severity: "P2", title: "Sensitive cookie '" + cookie.name + "' has SameSite=None", file: url });
119
+ findings.push({ severity: "P2", title: "Sensitive cookie '" + cookie.name + "' has SameSite=None", file: targetUrl });
102
120
  }
103
121
  }
104
122
  return { available: true, method: "playwright", findings, ...result };
105
123
  } catch (err) {
106
- return { available: false, reason: "Playwright auth audit failed: " + err.message };
107
- } finally {
108
- // Clean up temp script AND its mkdtemp parent directory
109
- if (scriptPath) {
110
- try { fs.unlinkSync(scriptPath); } catch { /* best effort */ }
111
- try { fs.rmdirSync(path.dirname(scriptPath)); } catch { /* best effort — dir may not be empty */ }
124
+ if (err instanceof AuthAuditError) {
125
+ return { available: false, reason: err.message };
112
126
  }
127
+ return { available: false, reason: "Playwright auth audit failed: " + err.message };
113
128
  }
114
129
  }
115
130
 
116
131
  // Playwright script as a constant — no string interpolation of URLs/credentials.
117
- // All dynamic values come from environment variables at runtime.
132
+ // Dynamic auth context is read from stdin at runtime to avoid local credential temp files.
118
133
  const PLAYWRIGHT_AUTH_SCRIPT = `
119
134
  const { chromium } = require('playwright');
135
+ const fs = require('node:fs');
136
+
120
137
  (async () => {
121
138
  const targetUrl = process.env.SL_AUDIT_TARGET_URL;
122
139
  const loginUrl = process.env.SL_AUDIT_LOGIN_URL;
123
- const email = process.env.SL_AUDIT_TEST_EMAIL;
124
- const password = process.env.SL_AUDIT_TEST_PASSWORD;
125
- const emailSelector = process.env.SL_AUDIT_EMAIL_FIELD || 'input[type="email"]';
126
- const passwordSelector = process.env.SL_AUDIT_PASSWORD_FIELD || 'input[type="password"]';
127
- const submitSelector = process.env.SL_AUDIT_SUBMIT_SELECTOR || 'button[type="submit"]';
140
+ let context = {};
141
+ try {
142
+ const stdinPayload = fs.readFileSync(0, 'utf-8');
143
+ if (stdinPayload) {
144
+ context = JSON.parse(stdinPayload) || {};
145
+ }
146
+ } catch {
147
+ context = {};
148
+ }
128
149
 
129
- const browser = await chromium.launch({ headless: true });
130
- const page = await browser.newPage();
131
- const results = { authenticated: false, errors: [], cookies: [], headers: {}, domStats: {} };
150
+ const email = context.email || '';
151
+ const password = context.password || '';
152
+ const emailSelector = context.emailField || 'input[type="email"]';
153
+ const passwordSelector = context.passwordField || 'input[type="password"]';
154
+ const submitSelector = context.submitSelector || 'button[type="submit"]';
155
+
156
+ let browser = null;
157
+ const results = { authenticated: false, authSignals: {}, errors: [], cookies: [], headers: {}, domStats: {} };
158
+ function normalizePath(value) {
159
+ const normalized = String(value || '/').replace(/\\/+$/, '');
160
+ return normalized || '/';
161
+ }
162
+ function didLeaveLoginSurface(currentValue, loginValue) {
163
+ try {
164
+ const currentUrl = new URL(currentValue);
165
+ const loginParsed = new URL(loginValue);
166
+ return (
167
+ currentUrl.origin !== loginParsed.origin ||
168
+ normalizePath(currentUrl.pathname) !== normalizePath(loginParsed.pathname)
169
+ );
170
+ } catch {
171
+ return String(currentValue || '') !== String(loginValue || '');
172
+ }
173
+ }
174
+ function sanitizeErrorText(value) {
175
+ return String(value || '')
176
+ .replace(/\\s+/g, ' ')
177
+ .replace(/Bearer\\s+[^\\s,;]+/gi, 'Bearer [REDACTED]')
178
+ .replace(/\\b(?:authorization|x-api-key|api-key|token|access_token|refresh_token|id_token|session|cookie|set-cookie|secret|password|passwd)\\b\\s*[:=]\\s*["']?[^"'\\s,;]+/gi, '$1=[REDACTED]')
179
+ .replace(/\\b[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{8,}\\b/g, '[REDACTED_JWT]')
180
+ .replace(/\\b(?:gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9]{16,}|AIza[0-9A-Za-z-_]{20,}|xox[baprs]-[0-9A-Za-z-]{10,})\\b/g, '[REDACTED_TOKEN]')
181
+ .replace(/\\b[A-Fa-f0-9]{32,}\\b/g, '[REDACTED_HEX]')
182
+ .replace(/\\b[A-Za-z0-9_-]{40,}\\b/g, '[REDACTED_TOKEN]')
183
+ .slice(0, 200);
184
+ }
132
185
 
133
186
  try {
187
+ browser = await chromium.launch({ headless: true });
188
+ const page = await browser.newPage();
189
+ page.on('console', msg => {
190
+ if (msg.type() === 'error') {
191
+ const text = sanitizeErrorText(msg.text());
192
+ results.errors.push({ type: 'console', text });
193
+ }
194
+ });
195
+ page.on('pageerror', err => {
196
+ const text = sanitizeErrorText(err && err.message ? err.message : String(err || ''));
197
+ results.errors.push({ type: 'pageerror', text });
198
+ });
199
+
134
200
  if (email && password && loginUrl) {
135
201
  await page.goto(loginUrl, { waitUntil: 'networkidle', timeout: 30000 });
136
202
  await page.fill(emailSelector, email);
137
203
  await page.fill(passwordSelector, password);
138
204
  await page.click(submitSelector);
139
205
  await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
140
- // P2 fix: verify auth by checking URL change + session cookie presence
141
206
  const currentUrl = page.url();
142
207
  const postCookies = await page.context().cookies();
143
- results.authenticated = currentUrl !== loginUrl || postCookies.some(c => /session|token|auth/i.test(c.name));
208
+ const urlChanged = didLeaveLoginSurface(currentUrl, loginUrl);
209
+ const authCookiePresent = postCookies.some(c => /(?:^|[-_])(session|token|auth|jwt)(?:$|[-_])/i.test(c.name) && (c.httpOnly || c.secure));
210
+ const loginFormVisible = await page.evaluate((emailSel, passwordSel) => (
211
+ Boolean(document.querySelector(emailSel) && document.querySelector(passwordSel))
212
+ ), emailSelector, passwordSelector).catch(() => false);
213
+ results.authSignals = { urlChanged, authCookiePresent, loginFormVisible };
214
+ results.authenticated = !loginFormVisible && (urlChanged || authCookiePresent);
144
215
  }
145
216
 
146
- await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 });
217
+ const targetResponse = await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 });
147
218
 
148
- // P2 fix: redact sensitive content from console errors
149
- page.on('console', msg => {
150
- if (msg.type() === 'error') {
151
- const text = (msg.text() || '').slice(0, 200).replace(/Bearer\\s+\\S+/gi, 'Bearer [REDACTED]').replace(/token[=:]\\S+/gi, 'token=[REDACTED]');
152
- results.errors.push({ text });
153
- }
154
- });
155
-
156
- // P2 fix: capture cookie names + flags only, never values
157
219
  const cookies = await page.context().cookies();
158
220
  results.cookies = cookies.map(c => ({
159
221
  name: c.name, domain: c.domain,
@@ -169,7 +231,7 @@ const { chromium } = require('playwright');
169
231
  inputCount: document.querySelectorAll('input').length,
170
232
  }));
171
233
 
172
- const response = await page.goto(targetUrl, { waitUntil: 'commit', timeout: 10000 }).catch(() => null);
234
+ const response = targetResponse || null;
173
235
  if (response) {
174
236
  const h = response.headers();
175
237
  results.headers = {
@@ -180,45 +242,314 @@ const { chromium } = require('playwright');
180
242
  };
181
243
  }
182
244
  } catch (err) {
183
- results.errors.push({ text: 'Navigation error: ' + (err.message || '').slice(0, 100) });
245
+ const text = sanitizeErrorText('Playwright error: ' + (err && err.message ? err.message : ''));
246
+ results.errors.push({ type: 'playwright', text });
184
247
  } finally {
185
- try { console.log(JSON.stringify(results)); } catch { /* output failure non-blocking */ }
186
- await browser.close();
248
+ try { console.log(JSON.stringify(results)); } catch {}
249
+ if (browser) {
250
+ await browser.close().catch(() => {});
251
+ }
187
252
  }
188
253
  })();
189
254
  `;
190
255
 
191
- function checkAuthFlowSecurity(input) {
192
- const loginUrl = input.loginUrl || input.url;
193
- if (!loginUrl) throw new AuthAuditError("check_auth_flow_security requires loginUrl or url");
194
- if (!isValidUrl(loginUrl)) throw new AuthAuditError("Invalid URL: " + loginUrl);
256
+ const MAX_AUTH_REDIRECT_HOPS = 5;
257
+ const AUTH_FLOW_FETCH_TIMEOUT_MS = 10_000;
258
+ const AUTH_FLOW_FETCH_MAX_RETRIES = 2;
259
+ const AUTH_FLOW_FETCH_BASE_BACKOFF_MS = 200;
260
+ const RETRYABLE_AUTH_FLOW_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
261
+ const RETRYABLE_AUTH_FLOW_ERROR_CODES = new Set([
262
+ "ECONNRESET",
263
+ "EAI_AGAIN",
264
+ "ENOTFOUND",
265
+ "ECONNREFUSED",
266
+ "ETIMEDOUT",
267
+ "ECONNABORTED",
268
+ "UND_ERR_CONNECT_TIMEOUT",
269
+ "UND_ERR_HEADERS_TIMEOUT",
270
+ "UND_ERR_BODY_TIMEOUT",
271
+ ]);
272
+ const RETRYABLE_AUTH_FLOW_MESSAGE_PATTERNS = [
273
+ /\bfetch failed\b/i,
274
+ /\bnetwork(?:\s+|-)error\b/i,
275
+ /\bsocket hang up\b/i,
276
+ /\btimed?\s*out\b/i,
277
+ /\b(?:econnreset|eai_again|enotfound|econnrefused|etimedout)\b/i,
278
+ /\btemporary(?:\s+|-)failure\b/i,
279
+ /\bconnection\b.*\b(?:reset|terminated|closed)\b/i,
280
+ ];
281
+ const AUTH_FLOW_LOCAL_TEST_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
195
282
 
196
- const findings = [];
283
+ function computePlaywrightBackoffMs(attempt, baseBackoffMs = AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS) {
284
+ const cappedBase = Math.max(1, Number.isFinite(baseBackoffMs) ? Math.trunc(baseBackoffMs) : AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS);
285
+ const exponential = Math.min(4000, cappedBase * Math.pow(2, Math.max(0, attempt)));
286
+ const deterministicJitter = ((Math.max(0, attempt) * 1103515245 + 12345) % 1000) / 1000;
287
+ const jitterFactor = 0.5 + (deterministicJitter * 0.5);
288
+ return Math.max(1, Math.trunc(exponential * jitterFactor));
289
+ }
290
+
291
+ function isRetryablePlaywrightExecutionError(error) {
292
+ if (!(error instanceof Error)) {
293
+ return false;
294
+ }
295
+ if (error.name === "AbortError" || error.name === "TimeoutError") {
296
+ return true;
297
+ }
298
+ const code = String(error.code || "").toUpperCase();
299
+ if (RETRYABLE_PLAYWRIGHT_EXEC_ERROR_CODES.has(code)) {
300
+ return true;
301
+ }
302
+ if (error.killed === true && (error.signal === "SIGTERM" || error.signal === "SIGKILL")) {
303
+ return true;
304
+ }
305
+ const causeCode = String(error.cause?.code || error.cause?.errno || "").toUpperCase();
306
+ return RETRYABLE_PLAYWRIGHT_EXEC_ERROR_CODES.has(causeCode);
307
+ }
308
+
309
+ function normalizeAuthAuditErrorMessage(error, fallbackMessage) {
310
+ if (error instanceof Error && error.message) {
311
+ return error.message;
312
+ }
313
+ const normalized = String(error || "").trim();
314
+ return normalized || fallbackMessage;
315
+ }
316
+
317
+ export async function runPlaywrightAuditScriptWithRetry(scriptPath, env, options = {}) {
318
+ const scriptSource = String(options.scriptSource || "");
319
+ const runArgs = scriptSource ? ["-e", scriptSource] : (scriptPath ? [scriptPath] : []);
320
+ if (runArgs.length === 0) {
321
+ throw new AuthAuditError("Playwright auth audit failed: missing script path");
322
+ }
323
+ const stdinPayload = String(options.stdinPayload || "");
324
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
325
+ ? Math.trunc(options.timeoutMs)
326
+ : AUTH_PLAYWRIGHT_EXEC_TIMEOUT_MS;
327
+ const maxRetries = Number.isInteger(options.maxRetries) && options.maxRetries >= 0
328
+ ? options.maxRetries
329
+ : AUTH_PLAYWRIGHT_EXEC_MAX_RETRIES;
330
+ const baseBackoffMs = Number.isFinite(options.baseBackoffMs) && options.baseBackoffMs > 0
331
+ ? Math.trunc(options.baseBackoffMs)
332
+ : AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS;
333
+ const execute = typeof options.exec === "function" ? options.exec : execFileSync;
334
+
335
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
336
+ try {
337
+ return execute(process.execPath, runArgs, {
338
+ encoding: "utf-8",
339
+ timeout: timeoutMs,
340
+ stdio: ["pipe", "pipe", "pipe"],
341
+ env,
342
+ input: stdinPayload,
343
+ });
344
+ } catch (error) {
345
+ if (!isRetryablePlaywrightExecutionError(error) || attempt >= maxRetries) {
346
+ const reason = normalizeAuthAuditErrorMessage(error, "Playwright execution failed");
347
+ throw new AuthAuditError(`Playwright auth audit failed after ${attempt + 1} attempt(s): ${reason}`);
348
+ }
349
+ }
350
+ await sleep(computePlaywrightBackoffMs(attempt, baseBackoffMs));
351
+ }
352
+
353
+ throw new AuthAuditError("Playwright auth audit failed after retry budget was exhausted");
354
+ }
355
+
356
+ function computeAuthFlowBackoffMs(attempt) {
357
+ const computed = AUTH_FLOW_FETCH_BASE_BACKOFF_MS * Math.pow(2, Math.max(0, attempt));
358
+ return Math.min(1000, computed);
359
+ }
360
+
361
+ function resolveAuthFlowErrorCode(error) {
362
+ if (!(error instanceof Error)) {
363
+ return "";
364
+ }
365
+ const directCode = String(error.code || "").toUpperCase();
366
+ if (directCode) {
367
+ return directCode;
368
+ }
369
+ const cause = error.cause;
370
+ if (!cause || typeof cause !== "object") {
371
+ return "";
372
+ }
373
+ return String(cause.code || cause.errno || "").toUpperCase();
374
+ }
375
+
376
+ function isRetryableAuthFlowError(error) {
377
+ if (!(error instanceof Error)) {
378
+ return false;
379
+ }
380
+ if (error.name === "AbortError" || error.name === "TimeoutError") {
381
+ return true;
382
+ }
383
+ const code = resolveAuthFlowErrorCode(error);
384
+ if (RETRYABLE_AUTH_FLOW_ERROR_CODES.has(code)) {
385
+ return true;
386
+ }
387
+ const normalized = `${error.name} ${error.message || ""}`.toLowerCase();
388
+ if (error.name === "TypeError") {
389
+ return RETRYABLE_AUTH_FLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
390
+ }
391
+ return RETRYABLE_AUTH_FLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
392
+ }
393
+
394
+ function isAllowedHttpAuthFlowTarget(urlObject) {
395
+ if (urlObject.protocol !== "http:") {
396
+ return true;
397
+ }
398
+ if (process.env.NODE_ENV !== "test") {
399
+ return false;
400
+ }
401
+ return AUTH_FLOW_LOCAL_TEST_HOSTS.has(urlObject.hostname);
402
+ }
403
+
404
+ function assertSecureAuthFlowTarget(urlValue, options = {}) {
405
+ let parsed;
197
406
  try {
198
- const output = execFileSync("curl", ["-sI", "-L", "--max-time", "10", loginUrl], {
199
- encoding: "utf-8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"],
407
+ parsed = assertPermittedAuditTarget(urlValue, {
408
+ operation: "check_auth_flow_security",
409
+ allowPrivateTargets: options.allowPrivateTargets === true,
200
410
  });
201
- const headers = {};
202
- for (const line of output.split("\n")) {
203
- const idx = line.indexOf(":");
204
- if (idx > 0) headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
205
- }
206
- if (!headers["strict-transport-security"]) findings.push({ severity: "P1", title: "Login page missing HSTS header", file: loginUrl });
207
- if (!headers["content-security-policy"]) findings.push({ severity: "P2", title: "Login page missing CSP header", file: loginUrl });
208
- if (headers["x-powered-by"]) findings.push({ severity: "P2", title: "Login page exposes X-Powered-By: " + headers["x-powered-by"], file: loginUrl });
411
+ } catch (error) {
412
+ throw new AuthAuditError(error.message);
413
+ }
414
+ if (!isAllowedHttpAuthFlowTarget(parsed)) {
415
+ throw new AuthAuditError(
416
+ `HTTPS downgrade detected in auth flow target: ${parsed.toString()}`
417
+ );
418
+ }
419
+ return parsed;
420
+ }
421
+
422
+ async function fetchWithTimeout(url, options, timeoutMs) {
423
+ const controller = new AbortController();
424
+ const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
425
+ try {
426
+ return await fetch(url, { ...options, signal: controller.signal });
427
+ } finally {
428
+ clearTimeout(timeoutHandle);
429
+ }
430
+ }
431
+
432
+ async function fetchLoginResponseWithRetry(currentUrl) {
433
+ for (let attempt = 0; attempt <= AUTH_FLOW_FETCH_MAX_RETRIES; attempt += 1) {
434
+ try {
435
+ const response = await fetchWithTimeout(currentUrl, {
436
+ method: "GET",
437
+ redirect: "manual",
438
+ }, AUTH_FLOW_FETCH_TIMEOUT_MS);
439
+ if (!RETRYABLE_AUTH_FLOW_STATUS_CODES.has(response.status)) {
440
+ return response;
441
+ }
442
+ if (attempt >= AUTH_FLOW_FETCH_MAX_RETRIES) {
443
+ throw new AuthAuditError(
444
+ `Auth flow header fetch failed after ${attempt + 1} attempt(s): HTTP ${response.status}`
445
+ );
446
+ }
447
+ } catch (error) {
448
+ if (error instanceof AuthAuditError) {
449
+ throw error;
450
+ }
451
+ if (!isRetryableAuthFlowError(error) || attempt >= AUTH_FLOW_FETCH_MAX_RETRIES) {
452
+ const message = error instanceof Error ? error.message : String(error || "request failed");
453
+ throw new AuthAuditError(`Auth flow header fetch failed after ${attempt + 1} attempt(s): ${message}`);
454
+ }
455
+ }
456
+ await sleep(computeAuthFlowBackoffMs(attempt));
457
+ }
458
+ throw new AuthAuditError("Auth flow header fetch failed after retry budget was exhausted");
459
+ }
460
+
461
+ async function checkAuthFlowSecurity(input) {
462
+ const loginUrlCandidate = input.loginUrl || input.url;
463
+ if (!loginUrlCandidate) throw new AuthAuditError("check_auth_flow_security requires loginUrl or url");
464
+ const allowPrivateTargets = input.allowPrivateTargets === true;
465
+ const loginUrl = assertSecureAuthFlowTarget(loginUrlCandidate, { allowPrivateTargets }).toString();
466
+
467
+ const findings = [];
468
+ try {
469
+ const { headers, finalUrl, crossOriginRedirect } = await fetchLoginHeaders(loginUrl, { allowPrivateTargets });
470
+
471
+ if (crossOriginRedirect) {
472
+ findings.push({
473
+ severity: "P1",
474
+ title: "Login flow redirects cross-origin before header checks",
475
+ file: loginUrl,
476
+ });
477
+ }
478
+
479
+ if (!headers["strict-transport-security"]) {
480
+ findings.push({ severity: "P1", title: "Login page missing HSTS header", file: finalUrl || loginUrl });
481
+ }
482
+ if (!headers["content-security-policy"]) {
483
+ findings.push({ severity: "P2", title: "Login page missing CSP header", file: finalUrl || loginUrl });
484
+ }
485
+ if (headers["x-powered-by"]) {
486
+ findings.push({
487
+ severity: "P2",
488
+ title: "Login page exposes X-Powered-By: " + headers["x-powered-by"],
489
+ file: finalUrl || loginUrl,
490
+ });
491
+ }
209
492
  } catch (err) {
210
- return { available: false, loginUrl, findings, reason: "curl failed: " + err.message };
493
+ if (err instanceof AuthAuditError && /HTTPS downgrade detected/.test(err.message)) {
494
+ findings.push({
495
+ severity: "P1",
496
+ title: err.message,
497
+ file: loginUrl,
498
+ });
499
+ }
500
+ return { available: false, loginUrl, findings, reason: "auth flow check failed: " + err.message };
211
501
  }
212
502
  return { available: true, loginUrl, findings };
213
503
  }
214
504
 
215
- function isValidUrl(url) {
216
- try { const p = new URL(url); return p.protocol === "http:" || p.protocol === "https:"; } catch { return false; }
505
+ async function fetchLoginHeaders(loginUrl, options = {}) {
506
+ let currentUrl = loginUrl;
507
+ const visitedUrls = new Set();
508
+ let redirectCount = 0;
509
+
510
+ while (true) {
511
+ if (redirectCount > MAX_AUTH_REDIRECT_HOPS) {
512
+ throw new AuthAuditError(
513
+ `Exceeded ${MAX_AUTH_REDIRECT_HOPS} redirects while checking auth flow (last=${currentUrl})`
514
+ );
515
+ }
516
+ const currentParsedUrl = assertSecureAuthFlowTarget(currentUrl, options);
517
+ if (visitedUrls.has(currentUrl)) {
518
+ throw new AuthAuditError("Redirect loop detected while checking auth headers");
519
+ }
520
+ visitedUrls.add(currentUrl);
521
+
522
+ const response = await fetchLoginResponseWithRetry(currentUrl);
523
+ const headers = Object.fromEntries(response.headers.entries());
524
+
525
+ if (response.status >= 300 && response.status < 400) {
526
+ const location = response.headers.get("location");
527
+ if (!location) {
528
+ return { headers, finalUrl: currentUrl, crossOriginRedirect: false };
529
+ }
530
+ const nextParsedUrl = assertSecureAuthFlowTarget(new URL(location, currentParsedUrl).toString(), options);
531
+ if (nextParsedUrl.origin !== currentParsedUrl.origin) {
532
+ return { headers, finalUrl: currentUrl, crossOriginRedirect: true };
533
+ }
534
+ currentUrl = nextParsedUrl.toString();
535
+ redirectCount += 1;
536
+ continue;
537
+ }
538
+
539
+ return { headers, finalUrl: currentUrl, crossOriginRedirect: false };
540
+ }
217
541
  }
218
542
 
219
- function secureTempFile(name) {
220
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sl-auth-"));
221
- return path.join(dir, name);
543
+ function resolveAuthAuditTarget(urlValue, input, operation) {
544
+ try {
545
+ const parsed = assertPermittedAuditTarget(urlValue, {
546
+ operation,
547
+ allowPrivateTargets: input.allowPrivateTargets === true,
548
+ });
549
+ return parsed.toString();
550
+ } catch (error) {
551
+ throw new AuthAuditError(error.message);
552
+ }
222
553
  }
223
554
 
224
555
  export class AuthAuditError extends Error {