sentinelayer-cli 0.1.2 → 0.4.4

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 (129) hide show
  1. package/README.md +998 -996
  2. package/bin/create-sentinelayer.js +5 -5
  3. package/bin/sentinelayer-cli.js +4 -4
  4. package/bin/sl.js +5 -5
  5. package/package.json +63 -54
  6. package/src/agents/jules/config/definition.js +209 -209
  7. package/src/agents/jules/config/system-prompt.js +175 -175
  8. package/src/agents/jules/error-intake.js +51 -51
  9. package/src/agents/jules/fix-cycle.js +377 -377
  10. package/src/agents/jules/loop.js +367 -367
  11. package/src/agents/jules/pulse.js +327 -319
  12. package/src/agents/jules/stream.js +186 -186
  13. package/src/agents/jules/swarm/file-scanner.js +74 -74
  14. package/src/agents/jules/swarm/index.js +11 -11
  15. package/src/agents/jules/swarm/orchestrator.js +362 -362
  16. package/src/agents/jules/swarm/pattern-hunter.js +123 -123
  17. package/src/agents/jules/swarm/sub-agent.js +308 -308
  18. package/src/agents/jules/tools/auth-audit.js +557 -222
  19. package/src/agents/jules/tools/dispatch.js +327 -327
  20. package/src/agents/jules/tools/file-edit.js +180 -180
  21. package/src/agents/jules/tools/file-read.js +100 -100
  22. package/src/agents/jules/tools/frontend-analyze.js +570 -570
  23. package/src/agents/jules/tools/glob.js +168 -168
  24. package/src/agents/jules/tools/grep.js +228 -228
  25. package/src/agents/jules/tools/index.js +29 -29
  26. package/src/agents/jules/tools/path-guards.js +161 -161
  27. package/src/agents/jules/tools/runtime-audit.js +503 -493
  28. package/src/agents/jules/tools/shell.js +383 -383
  29. package/src/agents/jules/tools/url-policy.js +100 -0
  30. package/src/ai/aidenid.js +972 -945
  31. package/src/ai/client.js +508 -508
  32. package/src/ai/domain-target-store.js +268 -268
  33. package/src/ai/identity-store.js +270 -270
  34. package/src/ai/site-store.js +145 -145
  35. package/src/audit/agents/architecture.js +180 -180
  36. package/src/audit/agents/compliance.js +179 -179
  37. package/src/audit/agents/documentation.js +165 -165
  38. package/src/audit/agents/performance.js +145 -145
  39. package/src/audit/agents/security.js +215 -215
  40. package/src/audit/agents/testing.js +172 -172
  41. package/src/audit/orchestrator.js +557 -557
  42. package/src/audit/package.js +204 -204
  43. package/src/audit/registry.js +284 -284
  44. package/src/audit/replay.js +103 -103
  45. package/src/auth/gate.js +45 -11
  46. package/src/auth/http.js +270 -113
  47. package/src/auth/service.js +891 -848
  48. package/src/auth/session-store.js +359 -345
  49. package/src/cli.js +252 -252
  50. package/src/commands/ai/identity-lifecycle.js +1338 -1337
  51. package/src/commands/ai/provision-governance.js +1272 -1246
  52. package/src/commands/ai/shared.js +147 -147
  53. package/src/commands/ai.js +11 -11
  54. package/src/commands/apply.js +12 -12
  55. package/src/commands/audit.js +1166 -1166
  56. package/src/commands/auth.js +375 -366
  57. package/src/commands/chat.js +191 -191
  58. package/src/commands/config.js +184 -184
  59. package/src/commands/cost.js +311 -311
  60. package/src/commands/daemon/core.js +850 -850
  61. package/src/commands/daemon/extended.js +1048 -1048
  62. package/src/commands/daemon/shared.js +213 -213
  63. package/src/commands/daemon.js +11 -11
  64. package/src/commands/guide.js +174 -174
  65. package/src/commands/ingest.js +58 -58
  66. package/src/commands/init.js +55 -55
  67. package/src/commands/legacy-args.js +10 -10
  68. package/src/commands/mcp.js +461 -404
  69. package/src/commands/omargate.js +15 -15
  70. package/src/commands/persona.js +20 -20
  71. package/src/commands/plugin.js +260 -260
  72. package/src/commands/policy.js +132 -132
  73. package/src/commands/prompt.js +238 -238
  74. package/src/commands/review.js +704 -704
  75. package/src/commands/scan.js +866 -788
  76. package/src/commands/spec.js +716 -716
  77. package/src/commands/swarm.js +651 -651
  78. package/src/commands/telemetry.js +202 -202
  79. package/src/commands/watch.js +510 -510
  80. package/src/config/agent-dictionary.js +182 -182
  81. package/src/config/io.js +56 -56
  82. package/src/config/paths.js +18 -18
  83. package/src/config/schema.js +55 -55
  84. package/src/config/service.js +184 -184
  85. package/src/cost/budget.js +235 -235
  86. package/src/cost/history.js +188 -188
  87. package/src/cost/tracker.js +171 -171
  88. package/src/daemon/artifact-lineage.js +534 -534
  89. package/src/daemon/assignment-ledger.js +770 -770
  90. package/src/daemon/ast-parser-layer.js +258 -258
  91. package/src/daemon/budget-governor.js +633 -633
  92. package/src/daemon/callgraph-overlay.js +646 -646
  93. package/src/daemon/error-worker.js +626 -626
  94. package/src/daemon/hybrid-mapper.js +929 -929
  95. package/src/daemon/jira-lifecycle.js +632 -632
  96. package/src/daemon/operator-control.js +657 -657
  97. package/src/daemon/reliability-lane.js +471 -471
  98. package/src/daemon/watchdog.js +971 -971
  99. package/src/guide/generator.js +316 -316
  100. package/src/ingest/engine.js +918 -918
  101. package/src/legacy-cli.js +2592 -2435
  102. package/src/mcp/registry.js +695 -695
  103. package/src/memory/blackboard.js +301 -301
  104. package/src/memory/retrieval.js +581 -581
  105. package/src/plugin/manifest.js +553 -553
  106. package/src/policy/packs.js +144 -144
  107. package/src/prompt/generator.js +118 -106
  108. package/src/review/ai-review.js +669 -669
  109. package/src/review/local-review.js +1295 -1284
  110. package/src/review/replay.js +235 -235
  111. package/src/review/report.js +664 -664
  112. package/src/review/spec-binding.js +487 -487
  113. package/src/scaffold/generator.js +67 -0
  114. package/src/scaffold/templates.js +150 -0
  115. package/src/scan/generator.js +418 -351
  116. package/src/scan/gh-secrets.js +107 -0
  117. package/src/spec/generator.js +519 -519
  118. package/src/spec/regenerate.js +237 -237
  119. package/src/spec/templates.js +91 -91
  120. package/src/swarm/dashboard.js +247 -247
  121. package/src/swarm/factory.js +363 -363
  122. package/src/swarm/pentest.js +934 -934
  123. package/src/swarm/registry.js +419 -419
  124. package/src/swarm/report.js +158 -158
  125. package/src/swarm/runtime.js +576 -576
  126. package/src/swarm/scenario-dsl.js +272 -272
  127. package/src/telemetry/ledger.js +302 -302
  128. package/src/telemetry/sync.js +107 -61
  129. package/src/ui/markdown.js +220 -220
@@ -1,222 +1,557 @@
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";
6
-
7
- /**
8
- * Jules Tanaka Authenticated Page Audit
9
- *
10
- * Provisions an AIdenID ephemeral identity, uses Playwright to log in,
11
- * then inspects authenticated pages (DevTools console, DOM, headers).
12
- * Falls back gracefully when AIdenID or Playwright unavailable.
13
- */
14
-
15
- export function authAudit(input) {
16
- if (!AUTH_OPS.has(input.operation)) {
17
- throw new AuthAuditError("Unknown operation: " + input.operation + ". Valid: " + [...AUTH_OPS].join(", "));
18
- }
19
- return AUTH_DISPATCH[input.operation](input);
20
- }
21
-
22
- const AUTH_OPS = new Set([
23
- "provision_test_identity",
24
- "authenticated_page_check",
25
- "check_auth_flow_security",
26
- ]);
27
-
28
- const AUTH_DISPATCH = {
29
- provision_test_identity: provisionTestIdentity,
30
- authenticated_page_check: authenticatedPageCheck,
31
- check_auth_flow_security: checkAuthFlowSecurity,
32
- };
33
-
34
- async function provisionTestIdentity(input) {
35
- try {
36
- const { provisionEmailIdentity, resolveAidenIdCredentials } = await import("../../../ai/aidenid.js");
37
- const creds = resolveAidenIdCredentials();
38
- if (!creds.apiKey) {
39
- return { available: false, reason: "AIdenID API key not configured (set AIDENID_API_KEY)" };
40
- }
41
- const result = await provisionEmailIdentity({
42
- apiUrl: creds.apiUrl, apiKey: creds.apiKey,
43
- tags: ["jules-audit", "frontend-test"],
44
- ttlSeconds: 3600, dryRun: input.execute !== true,
45
- });
46
- return { available: true, dryRun: input.execute !== true, identity: result.identity || result };
47
- } catch (err) {
48
- return { available: false, reason: "AIdenID provisioning failed: " + err.message };
49
- }
50
- }
51
-
52
- /**
53
- * Run Playwright to authenticate and inspect the page.
54
- * - URLs and credentials passed ONLY via env vars (no string interpolation)
55
- * - Auth verification checks URL change + cookie presence (not just click success)
56
- * - Console errors redacted to prevent sensitive data leakage
57
- * - Cookie values never captured (names + flags only)
58
- * - Temp script cleanup in finally block (not just success path)
59
- */
60
- function authenticatedPageCheck(input) {
61
- const url = input.url;
62
- if (!url) throw new AuthAuditError("authenticated_page_check requires url");
63
- if (!isValidUrl(url)) throw new AuthAuditError("Invalid URL: " + url);
64
-
65
- const loginUrl = input.loginUrl || url + "/login";
66
- let scriptPath = null;
67
-
68
- try {
69
- scriptPath = secureTempFile("sl-auth-audit-" + randomUUID().slice(0, 8) + ".cjs");
70
- fs.writeFileSync(scriptPath, PLAYWRIGHT_AUTH_SCRIPT);
71
-
72
- const env = {
73
- ...process.env,
74
- SL_AUDIT_TARGET_URL: url,
75
- SL_AUDIT_LOGIN_URL: loginUrl,
76
- SL_AUDIT_TEST_EMAIL: input.email || "",
77
- SL_AUDIT_TEST_PASSWORD: input.password || "",
78
- SL_AUDIT_EMAIL_FIELD: input.emailField || "",
79
- SL_AUDIT_PASSWORD_FIELD: input.passwordField || "",
80
- SL_AUDIT_SUBMIT_SELECTOR: input.submitSelector || "",
81
- };
82
-
83
- const output = execFileSync("node", [scriptPath], {
84
- encoding: "utf-8", timeout: 60000,
85
- stdio: ["pipe", "pipe", "pipe"],
86
- env,
87
- });
88
-
89
- const result = JSON.parse(output.trim());
90
- const findings = [];
91
- for (const cookie of (result.cookies || [])) {
92
- if (cookie.sensitive && !cookie.httpOnly) {
93
- findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing httpOnly flag", file: url });
94
- }
95
- if (cookie.sensitive && !cookie.secure) {
96
- findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing Secure flag", file: url });
97
- }
98
- if (cookie.sensitive && cookie.sameSite === "None") {
99
- findings.push({ severity: "P2", title: "Sensitive cookie '" + cookie.name + "' has SameSite=None", file: url });
100
- }
101
- }
102
- return { available: true, method: "playwright", findings, ...result };
103
- } catch (err) {
104
- return { available: false, reason: "Playwright auth audit failed: " + err.message };
105
- } finally {
106
- // Clean up temp script AND its mkdtemp parent directory
107
- if (scriptPath) {
108
- try { fs.unlinkSync(scriptPath); } catch { /* best effort */ }
109
- try { fs.rmdirSync(path.dirname(scriptPath)); } catch { /* best effort — dir may not be empty */ }
110
- }
111
- }
112
- }
113
-
114
- // Playwright script as a constant — no string interpolation of URLs/credentials.
115
- // All dynamic values come from environment variables at runtime.
116
- const PLAYWRIGHT_AUTH_SCRIPT = `
117
- const { chromium } = require('playwright');
118
- (async () => {
119
- const targetUrl = process.env.SL_AUDIT_TARGET_URL;
120
- const loginUrl = process.env.SL_AUDIT_LOGIN_URL;
121
- const email = process.env.SL_AUDIT_TEST_EMAIL;
122
- const password = process.env.SL_AUDIT_TEST_PASSWORD;
123
- const emailSelector = process.env.SL_AUDIT_EMAIL_FIELD || 'input[type="email"]';
124
- const passwordSelector = process.env.SL_AUDIT_PASSWORD_FIELD || 'input[type="password"]';
125
- const submitSelector = process.env.SL_AUDIT_SUBMIT_SELECTOR || 'button[type="submit"]';
126
-
127
- const browser = await chromium.launch({ headless: true });
128
- const page = await browser.newPage();
129
- const results = { authenticated: false, errors: [], cookies: [], headers: {}, domStats: {} };
130
-
131
- try {
132
- if (email && password && loginUrl) {
133
- await page.goto(loginUrl, { waitUntil: 'networkidle', timeout: 30000 });
134
- await page.fill(emailSelector, email);
135
- await page.fill(passwordSelector, password);
136
- await page.click(submitSelector);
137
- await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
138
- // P2 fix: verify auth by checking URL change + session cookie presence
139
- const currentUrl = page.url();
140
- const postCookies = await page.context().cookies();
141
- results.authenticated = currentUrl !== loginUrl || postCookies.some(c => /session|token|auth/i.test(c.name));
142
- }
143
-
144
- await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 });
145
-
146
- // P2 fix: redact sensitive content from console errors
147
- page.on('console', msg => {
148
- if (msg.type() === 'error') {
149
- const text = (msg.text() || '').slice(0, 200).replace(/Bearer\\s+\\S+/gi, 'Bearer [REDACTED]').replace(/token[=:]\\S+/gi, 'token=[REDACTED]');
150
- results.errors.push({ text });
151
- }
152
- });
153
-
154
- // P2 fix: capture cookie names + flags only, never values
155
- const cookies = await page.context().cookies();
156
- results.cookies = cookies.map(c => ({
157
- name: c.name, domain: c.domain,
158
- httpOnly: c.httpOnly, secure: c.secure,
159
- sameSite: c.sameSite,
160
- sensitive: /session|token|auth|jwt/i.test(c.name),
161
- }));
162
-
163
- results.domStats = await page.evaluate(() => ({
164
- title: document.title,
165
- nodeCount: document.querySelectorAll('*').length,
166
- formCount: document.querySelectorAll('form').length,
167
- inputCount: document.querySelectorAll('input').length,
168
- }));
169
-
170
- const response = await page.goto(targetUrl, { waitUntil: 'commit', timeout: 10000 }).catch(() => null);
171
- if (response) {
172
- const h = response.headers();
173
- results.headers = {
174
- 'content-security-policy': h['content-security-policy'] || null,
175
- 'x-frame-options': h['x-frame-options'] || null,
176
- 'strict-transport-security': h['strict-transport-security'] || null,
177
- 'cache-control': h['cache-control'] || null,
178
- };
179
- }
180
- } catch (err) {
181
- results.errors.push({ text: 'Navigation error: ' + (err.message || '').slice(0, 100) });
182
- } finally {
183
- try { console.log(JSON.stringify(results)); } catch { /* output failure non-blocking */ }
184
- await browser.close();
185
- }
186
- })();
187
- `;
188
-
189
- function checkAuthFlowSecurity(input) {
190
- const loginUrl = input.loginUrl || input.url;
191
- if (!loginUrl) throw new AuthAuditError("check_auth_flow_security requires loginUrl or url");
192
- if (!isValidUrl(loginUrl)) throw new AuthAuditError("Invalid URL: " + loginUrl);
193
-
194
- const findings = [];
195
- try {
196
- const output = execFileSync("curl", ["-sI", "-L", "--max-time", "10", loginUrl], {
197
- encoding: "utf-8", timeout: 15000, stdio: ["pipe", "pipe", "pipe"],
198
- });
199
- const headers = {};
200
- for (const line of output.split("\n")) {
201
- const idx = line.indexOf(":");
202
- if (idx > 0) headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
203
- }
204
- if (!headers["strict-transport-security"]) findings.push({ severity: "P1", title: "Login page missing HSTS header", file: loginUrl });
205
- if (!headers["content-security-policy"]) findings.push({ severity: "P2", title: "Login page missing CSP header", file: loginUrl });
206
- if (headers["x-powered-by"]) findings.push({ severity: "P2", title: "Login page exposes X-Powered-By: " + headers["x-powered-by"], file: loginUrl });
207
- } catch { /* curl failed, non-blocking */ }
208
- return { available: true, loginUrl, findings };
209
- }
210
-
211
- function isValidUrl(url) {
212
- try { const p = new URL(url); return p.protocol === "http:" || p.protocol === "https:"; } catch { return false; }
213
- }
214
-
215
- function secureTempFile(name) {
216
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sl-auth-"));
217
- return path.join(dir, name);
218
- }
219
-
220
- export class AuthAuditError extends Error {
221
- constructor(message) { super(message); this.name = "AuthAuditError"; }
222
- }
1
+ import { execFileSync } from "node:child_process";
2
+ import { setTimeout as sleep } from "node:timers/promises";
3
+ import { assertPermittedAuditTarget } from "./url-policy.js";
4
+
5
+ /**
6
+ * Jules Tanaka — Authenticated Page Audit
7
+ *
8
+ * Provisions an AIdenID ephemeral identity, uses Playwright to log in,
9
+ * then inspects authenticated pages (DevTools console, DOM, headers).
10
+ * Falls back gracefully when AIdenID or Playwright unavailable.
11
+ */
12
+
13
+ export function authAudit(input) {
14
+ if (!AUTH_OPS.has(input.operation)) {
15
+ throw new AuthAuditError("Unknown operation: " + input.operation + ". Valid: " + [...AUTH_OPS].join(", "));
16
+ }
17
+ return AUTH_DISPATCH[input.operation](input);
18
+ }
19
+
20
+ const AUTH_OPS = new Set([
21
+ "provision_test_identity",
22
+ "authenticated_page_check",
23
+ "check_auth_flow_security",
24
+ ]);
25
+
26
+ const AUTH_DISPATCH = {
27
+ provision_test_identity: provisionTestIdentity,
28
+ authenticated_page_check: authenticatedPageCheck,
29
+ check_auth_flow_security: checkAuthFlowSecurity,
30
+ };
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
+
45
+ async function provisionTestIdentity(input) {
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
+
56
+ const { provisionEmailIdentity, resolveAidenIdCredentials } = await import("../../../ai/aidenid.js");
57
+ const creds = await resolveAidenIdCredentials();
58
+ if (!creds.apiKey) {
59
+ return { available: false, reason: "AIdenID API key not configured (set AIDENID_API_KEY)" };
60
+ }
61
+ const result = await provisionEmailIdentity({
62
+ apiUrl: creds.apiUrl, apiKey: creds.apiKey,
63
+ tags: ["jules-audit", "frontend-test"],
64
+ ttlSeconds: 3600, dryRun: !executeRequested,
65
+ });
66
+ return { available: true, dryRun: !executeRequested, identity: result.identity || result };
67
+ } catch (err) {
68
+ return { available: false, reason: "AIdenID provisioning failed: " + err.message };
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Run Playwright to authenticate and inspect the page.
74
+ * - Runtime values loaded from a secure temp context file (credentials not exposed in process env)
75
+ * - Auth verification checks URL change + cookie presence (not just click success)
76
+ * - Console errors redacted to prevent sensitive data leakage
77
+ * - Cookie values never captured (names + flags only)
78
+ * - Temp script/context cleanup in finally block (not just success path)
79
+ */
80
+ async function authenticatedPageCheck(input) {
81
+ const url = input.url;
82
+ if (!url) throw new AuthAuditError("authenticated_page_check requires url");
83
+ const targetUrl = resolveAuthAuditTarget(url, input, "authenticated_page_check.target");
84
+
85
+ const loginUrlCandidate = input.loginUrl || targetUrl + "/login";
86
+ const loginUrl = resolveAuthAuditTarget(loginUrlCandidate, input, "authenticated_page_check.login");
87
+
88
+ try {
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
+ });
96
+ // Use scrubbed env strip API keys/tokens from child process
97
+ const { buildScrubbedEnv } = await import("./shell.js");
98
+ const env = {
99
+ ...buildScrubbedEnv(),
100
+ SL_AUDIT_TARGET_URL: targetUrl,
101
+ SL_AUDIT_LOGIN_URL: loginUrl,
102
+ };
103
+
104
+ const output = await runPlaywrightAuditScriptWithRetry(null, env, {
105
+ scriptSource: PLAYWRIGHT_AUTH_SCRIPT,
106
+ stdinPayload: authContextJson,
107
+ });
108
+
109
+ const result = JSON.parse(output.trim());
110
+ const findings = [];
111
+ for (const cookie of (result.cookies || [])) {
112
+ if (cookie.sensitive && !cookie.httpOnly) {
113
+ findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing httpOnly flag", file: targetUrl });
114
+ }
115
+ if (cookie.sensitive && !cookie.secure) {
116
+ findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing Secure flag", file: targetUrl });
117
+ }
118
+ if (cookie.sensitive && cookie.sameSite === "None") {
119
+ findings.push({ severity: "P2", title: "Sensitive cookie '" + cookie.name + "' has SameSite=None", file: targetUrl });
120
+ }
121
+ }
122
+ return { available: true, method: "playwright", findings, ...result };
123
+ } catch (err) {
124
+ if (err instanceof AuthAuditError) {
125
+ return { available: false, reason: err.message };
126
+ }
127
+ return { available: false, reason: "Playwright auth audit failed: " + err.message };
128
+ }
129
+ }
130
+
131
+ // Playwright script as a constant — no string interpolation of URLs/credentials.
132
+ // Dynamic auth context is read from stdin at runtime to avoid local credential temp files.
133
+ const PLAYWRIGHT_AUTH_SCRIPT = `
134
+ const { chromium } = require('playwright');
135
+ const fs = require('node:fs');
136
+
137
+ (async () => {
138
+ const targetUrl = process.env.SL_AUDIT_TARGET_URL;
139
+ const loginUrl = process.env.SL_AUDIT_LOGIN_URL;
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
+ }
149
+
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
+ }
185
+
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
+
200
+ if (email && password && loginUrl) {
201
+ await page.goto(loginUrl, { waitUntil: 'networkidle', timeout: 30000 });
202
+ await page.fill(emailSelector, email);
203
+ await page.fill(passwordSelector, password);
204
+ await page.click(submitSelector);
205
+ await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
206
+ const currentUrl = page.url();
207
+ const postCookies = await page.context().cookies();
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);
215
+ }
216
+
217
+ const targetResponse = await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 });
218
+
219
+ const cookies = await page.context().cookies();
220
+ results.cookies = cookies.map(c => ({
221
+ name: c.name, domain: c.domain,
222
+ httpOnly: c.httpOnly, secure: c.secure,
223
+ sameSite: c.sameSite,
224
+ sensitive: /session|token|auth|jwt/i.test(c.name),
225
+ }));
226
+
227
+ results.domStats = await page.evaluate(() => ({
228
+ title: document.title,
229
+ nodeCount: document.querySelectorAll('*').length,
230
+ formCount: document.querySelectorAll('form').length,
231
+ inputCount: document.querySelectorAll('input').length,
232
+ }));
233
+
234
+ const response = targetResponse || null;
235
+ if (response) {
236
+ const h = response.headers();
237
+ results.headers = {
238
+ 'content-security-policy': h['content-security-policy'] || null,
239
+ 'x-frame-options': h['x-frame-options'] || null,
240
+ 'strict-transport-security': h['strict-transport-security'] || null,
241
+ 'cache-control': h['cache-control'] || null,
242
+ };
243
+ }
244
+ } catch (err) {
245
+ const text = sanitizeErrorText('Playwright error: ' + (err && err.message ? err.message : ''));
246
+ results.errors.push({ type: 'playwright', text });
247
+ } finally {
248
+ try { console.log(JSON.stringify(results)); } catch {}
249
+ if (browser) {
250
+ await browser.close().catch(() => {});
251
+ }
252
+ }
253
+ })();
254
+ `;
255
+
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"]);
282
+
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;
406
+ try {
407
+ parsed = assertPermittedAuditTarget(urlValue, {
408
+ operation: "check_auth_flow_security",
409
+ allowPrivateTargets: options.allowPrivateTargets === true,
410
+ });
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
+ }
492
+ } catch (err) {
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 };
501
+ }
502
+ return { available: true, loginUrl, findings };
503
+ }
504
+
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
+ }
541
+ }
542
+
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
+ }
553
+ }
554
+
555
+ export class AuthAuditError extends Error {
556
+ constructor(message) { super(message); this.name = "AuthAuditError"; }
557
+ }