sentinelayer-cli 0.6.2 → 0.8.1

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 (280) hide show
  1. package/README.md +1009 -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 +64 -63
  6. package/src/agents/ai-governance/index.js +12 -0
  7. package/src/agents/ai-governance/tools/base.js +171 -0
  8. package/src/agents/ai-governance/tools/eval-regression.js +47 -0
  9. package/src/agents/ai-governance/tools/hitl-audit.js +81 -0
  10. package/src/agents/ai-governance/tools/index.js +52 -0
  11. package/src/agents/ai-governance/tools/prompt-drift.js +42 -0
  12. package/src/agents/ai-governance/tools/provenance-check.js +69 -0
  13. package/src/agents/backend/index.js +12 -0
  14. package/src/agents/backend/tools/base.js +189 -0
  15. package/src/agents/backend/tools/circuit-breaker-check.js +123 -0
  16. package/src/agents/backend/tools/idempotency-audit.js +105 -0
  17. package/src/agents/backend/tools/index.js +87 -0
  18. package/src/agents/backend/tools/retry-audit.js +132 -0
  19. package/src/agents/backend/tools/timeout-audit.js +144 -0
  20. package/src/agents/code-quality/index.js +12 -0
  21. package/src/agents/code-quality/tools/base.js +159 -0
  22. package/src/agents/code-quality/tools/complexity-measure.js +197 -0
  23. package/src/agents/code-quality/tools/coupling-analysis.js +81 -0
  24. package/src/agents/code-quality/tools/cycle-detect.js +49 -0
  25. package/src/agents/code-quality/tools/dep-graph.js +196 -0
  26. package/src/agents/code-quality/tools/index.js +89 -0
  27. package/src/agents/data-layer/index.js +12 -0
  28. package/src/agents/data-layer/tools/base.js +181 -0
  29. package/src/agents/data-layer/tools/index-audit.js +165 -0
  30. package/src/agents/data-layer/tools/index.js +83 -0
  31. package/src/agents/data-layer/tools/migration-scan.js +135 -0
  32. package/src/agents/data-layer/tools/query-explain.js +120 -0
  33. package/src/agents/data-layer/tools/tenancy-scan.js +166 -0
  34. package/src/agents/documentation/index.js +12 -0
  35. package/src/agents/documentation/tools/api-diff.js +91 -0
  36. package/src/agents/documentation/tools/base.js +151 -0
  37. package/src/agents/documentation/tools/dead-link-check.js +58 -0
  38. package/src/agents/documentation/tools/docstring-coverage.js +78 -0
  39. package/src/agents/documentation/tools/index.js +52 -0
  40. package/src/agents/documentation/tools/readme-freshness.js +61 -0
  41. package/src/agents/envelope/fix-cycle.js +45 -0
  42. package/src/agents/envelope/index.js +31 -0
  43. package/src/agents/envelope/loop.js +150 -0
  44. package/src/agents/envelope/pulse.js +18 -0
  45. package/src/agents/envelope/stream.js +40 -0
  46. package/src/agents/infrastructure/index.js +12 -0
  47. package/src/agents/infrastructure/tools/base.js +171 -0
  48. package/src/agents/infrastructure/tools/checkov-run.js +32 -0
  49. package/src/agents/infrastructure/tools/drift-detect.js +59 -0
  50. package/src/agents/infrastructure/tools/iam-least-priv-check.js +78 -0
  51. package/src/agents/infrastructure/tools/index.js +52 -0
  52. package/src/agents/infrastructure/tools/tflint-run.js +31 -0
  53. package/src/agents/jules/config/definition.js +160 -160
  54. package/src/agents/jules/config/system-prompt.js +182 -182
  55. package/src/agents/jules/error-intake.js +51 -51
  56. package/src/agents/jules/fix-cycle.js +17 -17
  57. package/src/agents/jules/loop.js +460 -450
  58. package/src/agents/jules/pulse.js +10 -10
  59. package/src/agents/jules/stream.js +187 -186
  60. package/src/agents/jules/swarm/file-scanner.js +74 -74
  61. package/src/agents/jules/swarm/index.js +11 -11
  62. package/src/agents/jules/swarm/orchestrator.js +362 -362
  63. package/src/agents/jules/swarm/pattern-hunter.js +123 -123
  64. package/src/agents/jules/swarm/sub-agent.js +315 -309
  65. package/src/agents/jules/tools/aidenid-email.js +189 -189
  66. package/src/agents/jules/tools/auth-audit.js +1708 -1691
  67. package/src/agents/jules/tools/dispatch.js +340 -335
  68. package/src/agents/jules/tools/file-edit.js +2 -2
  69. package/src/agents/jules/tools/file-read.js +2 -2
  70. package/src/agents/jules/tools/frontend-analyze.js +570 -570
  71. package/src/agents/jules/tools/glob.js +2 -2
  72. package/src/agents/jules/tools/grep.js +2 -2
  73. package/src/agents/jules/tools/index.js +29 -29
  74. package/src/agents/jules/tools/path-guards.js +2 -2
  75. package/src/agents/jules/tools/runtime-audit.js +507 -507
  76. package/src/agents/jules/tools/shell.js +2 -2
  77. package/src/agents/jules/tools/url-policy.js +100 -100
  78. package/src/agents/mode.js +113 -0
  79. package/src/agents/observability/index.js +12 -0
  80. package/src/agents/observability/tools/alert-audit.js +39 -0
  81. package/src/agents/observability/tools/base.js +181 -0
  82. package/src/agents/observability/tools/dashboard-gap.js +42 -0
  83. package/src/agents/observability/tools/index.js +54 -0
  84. package/src/agents/observability/tools/log-schema-check.js +74 -0
  85. package/src/agents/observability/tools/span-coverage.js +74 -0
  86. package/src/agents/persona-visuals.js +102 -61
  87. package/src/agents/release/index.js +12 -0
  88. package/src/agents/release/tools/base.js +181 -0
  89. package/src/agents/release/tools/changelog-diff.js +86 -0
  90. package/src/agents/release/tools/feature-flag-audit.js +126 -0
  91. package/src/agents/release/tools/index.js +61 -0
  92. package/src/agents/release/tools/rollback-verify.js +129 -0
  93. package/src/agents/release/tools/semver-check.js +109 -0
  94. package/src/agents/reliability/index.js +12 -0
  95. package/src/agents/reliability/tools/backpressure-check.js +129 -0
  96. package/src/agents/reliability/tools/base.js +181 -0
  97. package/src/agents/reliability/tools/chaos-probe.js +109 -0
  98. package/src/agents/reliability/tools/graceful-degradation-check.js +114 -0
  99. package/src/agents/reliability/tools/health-check-audit.js +111 -0
  100. package/src/agents/reliability/tools/index.js +87 -0
  101. package/src/agents/run-persona.js +109 -0
  102. package/src/agents/security/index.js +12 -0
  103. package/src/agents/security/tools/authz-audit.js +134 -0
  104. package/src/agents/security/tools/base.js +190 -0
  105. package/src/agents/security/tools/crypto-review.js +175 -0
  106. package/src/agents/security/tools/index.js +97 -0
  107. package/src/agents/security/tools/sast-scan.js +175 -0
  108. package/src/agents/security/tools/secrets-scan.js +216 -0
  109. package/src/agents/shared-tools/dispatch-core.js +320 -315
  110. package/src/agents/shared-tools/file-edit.js +180 -180
  111. package/src/agents/shared-tools/file-read.js +100 -100
  112. package/src/agents/shared-tools/glob.js +168 -168
  113. package/src/agents/shared-tools/grep.js +228 -228
  114. package/src/agents/shared-tools/index.js +46 -46
  115. package/src/agents/shared-tools/path-guards.js +161 -161
  116. package/src/agents/shared-tools/shell.js +383 -383
  117. package/src/agents/supply-chain/index.js +12 -0
  118. package/src/agents/supply-chain/tools/attestation-check.js +42 -0
  119. package/src/agents/supply-chain/tools/base.js +151 -0
  120. package/src/agents/supply-chain/tools/index.js +52 -0
  121. package/src/agents/supply-chain/tools/lockfile-integrity.js +73 -0
  122. package/src/agents/supply-chain/tools/package-verify.js +56 -0
  123. package/src/agents/supply-chain/tools/sbom-diff.js +34 -0
  124. package/src/agents/testing/index.js +12 -0
  125. package/src/agents/testing/tools/base.js +202 -0
  126. package/src/agents/testing/tools/coverage-gap.js +144 -0
  127. package/src/agents/testing/tools/flake-detect.js +125 -0
  128. package/src/agents/testing/tools/index.js +85 -0
  129. package/src/agents/testing/tools/mutation-test.js +143 -0
  130. package/src/agents/testing/tools/snapshot-diff.js +103 -0
  131. package/src/ai/aidenid.js +1021 -1009
  132. package/src/ai/client.js +553 -553
  133. package/src/ai/domain-target-store.js +268 -268
  134. package/src/ai/identity-store.js +270 -270
  135. package/src/ai/proxy.js +137 -137
  136. package/src/ai/site-store.js +145 -145
  137. package/src/audit/agents/architecture.js +180 -180
  138. package/src/audit/agents/compliance.js +179 -179
  139. package/src/audit/agents/documentation.js +165 -165
  140. package/src/audit/agents/performance.js +145 -145
  141. package/src/audit/agents/security.js +215 -215
  142. package/src/audit/agents/testing.js +172 -172
  143. package/src/audit/orchestrator.js +557 -557
  144. package/src/audit/package.js +204 -204
  145. package/src/audit/registry.js +284 -284
  146. package/src/audit/replay.js +103 -103
  147. package/src/auth/gate.js +428 -371
  148. package/src/auth/http.js +681 -611
  149. package/src/auth/service.js +1106 -1106
  150. package/src/auth/session-store.js +813 -813
  151. package/src/cli.js +257 -252
  152. package/src/commands/ai/identity-lifecycle.js +1338 -1338
  153. package/src/commands/ai/provision-governance.js +1272 -1272
  154. package/src/commands/ai/shared.js +147 -147
  155. package/src/commands/ai.js +11 -11
  156. package/src/commands/apply.js +12 -12
  157. package/src/commands/audit.js +1171 -1166
  158. package/src/commands/auth.js +419 -419
  159. package/src/commands/chat.js +184 -191
  160. package/src/commands/config.js +184 -184
  161. package/src/commands/cost.js +311 -311
  162. package/src/commands/daemon/core.js +850 -850
  163. package/src/commands/daemon/extended.js +1048 -1048
  164. package/src/commands/daemon/shared.js +213 -213
  165. package/src/commands/daemon.js +11 -11
  166. package/src/commands/guide.js +174 -174
  167. package/src/commands/ingest.js +58 -58
  168. package/src/commands/init.js +55 -55
  169. package/src/commands/legacy-args.js +20 -10
  170. package/src/commands/mcp.js +461 -461
  171. package/src/commands/omargate.js +63 -29
  172. package/src/commands/persona.js +65 -20
  173. package/src/commands/plugin.js +260 -260
  174. package/src/commands/policy.js +132 -132
  175. package/src/commands/prompt.js +238 -238
  176. package/src/commands/review.js +704 -704
  177. package/src/commands/scan.js +865 -872
  178. package/src/commands/session.js +1238 -0
  179. package/src/commands/spec.js +771 -716
  180. package/src/commands/swarm.js +651 -651
  181. package/src/commands/telemetry.js +202 -202
  182. package/src/commands/watch.js +511 -511
  183. package/src/config/agent-dictionary.js +182 -182
  184. package/src/config/io.js +56 -56
  185. package/src/config/paths.js +18 -18
  186. package/src/config/schema.js +55 -55
  187. package/src/config/service.js +184 -184
  188. package/src/coord/events-log.js +141 -0
  189. package/src/coord/handshake.js +719 -0
  190. package/src/coord/index.js +35 -0
  191. package/src/coord/paths.js +84 -0
  192. package/src/coord/priority.js +62 -0
  193. package/src/coord/tarjan.js +157 -0
  194. package/src/cost/budget.js +235 -235
  195. package/src/cost/history.js +188 -188
  196. package/src/cost/tokenizer.js +160 -0
  197. package/src/cost/tracker.js +232 -171
  198. package/src/daemon/artifact-lineage.js +896 -534
  199. package/src/daemon/assignment-ledger.js +1083 -770
  200. package/src/daemon/ast-drift.js +496 -0
  201. package/src/daemon/ast-parser-layer.js +258 -258
  202. package/src/daemon/budget-governor.js +633 -633
  203. package/src/daemon/callgraph-overlay.js +646 -646
  204. package/src/daemon/error-worker.js +1209 -626
  205. package/src/daemon/fix-cycle.js +384 -377
  206. package/src/daemon/hybrid-mapper.js +929 -929
  207. package/src/daemon/ingest-refresh.js +79 -11
  208. package/src/daemon/jira-lifecycle.js +767 -632
  209. package/src/daemon/operator-control.js +657 -657
  210. package/src/daemon/pulse.js +327 -327
  211. package/src/daemon/reliability-lane.js +471 -471
  212. package/src/daemon/scope-engine.js +1068 -0
  213. package/src/daemon/watchdog.js +971 -971
  214. package/src/events/schema.js +190 -0
  215. package/src/guide/generator.js +316 -316
  216. package/src/ingest/engine.js +933 -918
  217. package/src/ingest/ownership.js +380 -0
  218. package/src/interactive/index.js +97 -97
  219. package/src/legacy-cli.js +3228 -2994
  220. package/src/mcp/registry.js +695 -695
  221. package/src/memory/blackboard.js +301 -301
  222. package/src/memory/retrieval.js +581 -581
  223. package/src/orchestrator/kai-chen.js +126 -0
  224. package/src/plugin/manifest.js +553 -553
  225. package/src/policy/packs.js +144 -144
  226. package/src/prompt/generator.js +136 -118
  227. package/src/review/ai-review.js +672 -679
  228. package/src/review/compliance-pack.js +389 -0
  229. package/src/review/investor-dd-config.js +54 -0
  230. package/src/review/investor-dd-file-loop.js +303 -0
  231. package/src/review/investor-dd-file-router.js +406 -0
  232. package/src/review/investor-dd-html-report.js +233 -0
  233. package/src/review/investor-dd-notification.js +120 -0
  234. package/src/review/investor-dd-orchestrator.js +405 -0
  235. package/src/review/investor-dd-persona-runner.js +275 -0
  236. package/src/review/live-validator.js +253 -0
  237. package/src/review/local-review.js +1351 -1305
  238. package/src/review/omargate-interactive.js +68 -68
  239. package/src/review/omargate-orchestrator.js +492 -300
  240. package/src/review/persona-prompts.js +484 -296
  241. package/src/review/reconciliation-rules.js +329 -0
  242. package/src/review/replay.js +235 -235
  243. package/src/review/report.js +664 -664
  244. package/src/review/reproducibility-chain.js +136 -0
  245. package/src/review/scan-modes.js +147 -42
  246. package/src/review/spec-binding.js +487 -487
  247. package/src/scaffold/generator.js +67 -67
  248. package/src/scaffold/templates.js +150 -150
  249. package/src/scan/generator.js +418 -418
  250. package/src/scan/gh-secrets.js +107 -107
  251. package/src/session/agent-registry.js +359 -0
  252. package/src/session/analytics.js +479 -0
  253. package/src/session/daemon.js +1396 -0
  254. package/src/session/file-locks.js +666 -0
  255. package/src/session/paths.js +37 -0
  256. package/src/session/recap.js +567 -0
  257. package/src/session/redact.js +82 -0
  258. package/src/session/runtime-bridge.js +762 -0
  259. package/src/session/scoring.js +406 -0
  260. package/src/session/setup-guides.js +304 -0
  261. package/src/session/store.js +704 -0
  262. package/src/session/stream.js +333 -0
  263. package/src/session/sync.js +753 -0
  264. package/src/session/tasks.js +1054 -0
  265. package/src/session/templates.js +188 -0
  266. package/src/spec/generator.js +619 -519
  267. package/src/spec/regenerate.js +237 -237
  268. package/src/spec/templates.js +91 -91
  269. package/src/swarm/dashboard.js +247 -247
  270. package/src/swarm/factory.js +363 -363
  271. package/src/swarm/pentest.js +934 -934
  272. package/src/swarm/registry.js +419 -419
  273. package/src/swarm/report.js +158 -158
  274. package/src/swarm/runtime.js +569 -576
  275. package/src/swarm/scenario-dsl.js +272 -272
  276. package/src/telemetry/ledger.js +302 -302
  277. package/src/telemetry/session-tracker.js +234 -234
  278. package/src/telemetry/sync.js +203 -203
  279. package/src/ui/command-hints.js +13 -13
  280. package/src/ui/markdown.js +220 -220
@@ -1,1691 +1,1708 @@
1
- import { execFileSync } from "node:child_process";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { createHash, randomBytes, randomUUID } from "node:crypto";
5
- import { setTimeout as sleep } from "node:timers/promises";
6
- import { assertPermittedAuditTarget } from "./url-policy.js";
7
-
8
- /**
9
- * Jules Tanaka — Authenticated Page Audit
10
- *
11
- * Provisions an AIdenID ephemeral identity, uses Playwright to log in,
12
- * then inspects authenticated pages (DevTools console, DOM, headers).
13
- * Falls back gracefully when AIdenID or Playwright unavailable.
14
- */
15
-
16
- export async function authAudit(input = {}) {
17
- const operation = String(input.operation || "").trim();
18
- const requestId = createAuditRequestId();
19
- if (!AUTH_OPS.has(operation)) {
20
- const message = "Unknown operation: " + (operation || "<empty>") + ". Valid: " + [...AUTH_OPS].join(", ");
21
- return finalizeAuditEnvelope(operation || "unknown", requestId, buildUnavailableAuditResponse(
22
- requestId,
23
- "AUTH_AUDIT_UNKNOWN_OPERATION",
24
- message
25
- ));
26
- }
27
- try {
28
- const result = await AUTH_DISPATCH[operation]({ ...input, requestId, operation });
29
- return finalizeAuditEnvelope(operation, requestId, result);
30
- } catch (error) {
31
- const code = error instanceof AuthAuditError ? "AUTH_AUDIT_VALIDATION_FAILED" : "AUTH_AUDIT_EXECUTION_FAILED";
32
- const message = normalizeErrorMessage(error, "Auth audit failed");
33
- const diagnostics = extractErrorDiagnostics(error, operation || "auth_audit");
34
- return finalizeAuditEnvelope(operation, requestId, buildUnavailableAuditResponse(requestId, code, message, diagnostics));
35
- }
36
- }
37
-
38
- const AUTH_OPS = new Set([
39
- "provision_test_identity",
40
- "authenticated_page_check",
41
- "check_auth_flow_security",
42
- ]);
43
-
44
- const AUTH_DISPATCH = {
45
- provision_test_identity: provisionTestIdentity,
46
- authenticated_page_check: authenticatedPageCheck,
47
- check_auth_flow_security: checkAuthFlowSecurity,
48
- };
49
-
50
- const AUTH_PLAYWRIGHT_EXEC_TIMEOUT_MS = 60_000;
51
- const AUTH_PLAYWRIGHT_EXEC_MAX_RETRIES = 3;
52
- const AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS = 300;
53
- const AUTH_PLAYWRIGHT_EXEC_TOTAL_BUDGET_MS = 180_000;
54
- const AUTH_PLAYWRIGHT_EXEC_MIN_ATTEMPT_TIMEOUT_MS = 2_000;
55
- const AUTH_AIDENID_PROVISION_TIMEOUT_MS = 12_000;
56
- const AUTH_AIDENID_PROVISION_MAX_RETRIES = 2;
57
- const AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS = 300;
58
- const AUTH_AIDENID_PROVISION_TOTAL_BUDGET_MS =
59
- (AUTH_AIDENID_PROVISION_TIMEOUT_MS * (AUTH_AIDENID_PROVISION_MAX_RETRIES + 1))
60
- + (AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS * AUTH_AIDENID_PROVISION_MAX_RETRIES * 2);
61
- const AUTH_AIDENID_PROVISION_MIN_ATTEMPT_TIMEOUT_MS = 1_500;
62
- const AUTH_AUDIT_PROVIDER_BREAKER_FAILURE_THRESHOLD = 3;
63
- const AUTH_AUDIT_PROVIDER_BREAKER_WINDOW_MS = 5 * 60 * 1000;
64
- const AUTH_AUDIT_PROVIDER_BREAKER_COOLDOWN_MS = 2 * 60 * 1000;
65
- const AUTH_AUDIT_PROVIDER_BREAKER_ENTRY_TTL_MS = 15 * 60 * 1000;
66
- const AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT = "default";
67
- const AUTH_AUDIT_PROVIDER_BREAKERS = new Map();
68
- const AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_ENV = "SENTINELAYER_AUTH_AUDIT_BREAKER_STATE_FILE";
69
- const AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_DEFAULT = "";
70
- const AUTH_AUDIT_PROVIDER_AIDENID = "aidenid";
71
- const AUTH_AUDIT_PROVIDER_PLAYWRIGHT_TARGET = "playwright-target";
72
- const AUTH_MUTATION_ALLOWED_ENV = "SENTINELAYER_ALLOW_AUTH_MUTATION";
73
- const AUTH_AUDIT_ENVELOPE_ENV = "SENTINELAYER_AUTH_AUDIT_ENVELOPE";
74
- const AUTH_AUDIT_ENVELOPE_VERSION = "v2";
75
- const RETRYABLE_PLAYWRIGHT_EXEC_ERROR_CODES = new Set([
76
- "ETIMEDOUT",
77
- "ECONNRESET",
78
- "EPIPE",
79
- "EAI_AGAIN",
80
- "ECONNABORTED",
81
- "UND_ERR_CONNECT_TIMEOUT",
82
- "UND_ERR_HEADERS_TIMEOUT",
83
- ]);
84
- const RETRYABLE_AIDENID_PROVISION_ERROR_CODES = new Set([
85
- "ETIMEDOUT",
86
- "ECONNRESET",
87
- "ECONNREFUSED",
88
- "EAI_AGAIN",
89
- "ENOTFOUND",
90
- "ECONNABORTED",
91
- "AIDENID_ATTEMPT_TIMEOUT",
92
- "UND_ERR_CONNECT_TIMEOUT",
93
- "UND_ERR_HEADERS_TIMEOUT",
94
- "UND_ERR_BODY_TIMEOUT",
95
- ]);
96
- const RETRYABLE_AIDENID_PROVISION_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
97
- const RETRYABLE_AIDENID_PROVISION_MESSAGE_PATTERNS = [
98
- /\bfetch failed\b/i,
99
- /\bnetwork(?:\s+|-)error\b/i,
100
- /\btimed?\s*out\b/i,
101
- /\b(?:econnreset|econnrefused|eai_again|enotfound|etimedout)\b/i,
102
- /\bconnection\b.*\b(?:reset|closed|terminated)\b/i,
103
- ];
104
-
105
- let AUTH_AUDIT_PROVIDER_BREAKERS_HYDRATED = false;
106
- const AUTH_AUDIT_JITTER_SECRET = randomBytes(16).toString("hex");
107
- let AUTH_AUDIT_JITTER_COUNTER = 0;
108
-
109
- function createAuditRequestId() {
110
- try {
111
- return randomUUID();
112
- } catch {
113
- const ts = Date.now().toString(36);
114
- const rand = randomBytes(16).toString("hex");
115
- return `authaudit-${ts}-${rand}`;
116
- }
117
- }
118
-
119
- function normalizeErrorMessage(error, fallback) {
120
- const fallbackMessage = String(fallback || "Auth audit failed");
121
- if (error instanceof Error && error.message) {
122
- return sanitizeAuditErrorMessage(error.message, fallbackMessage);
123
- }
124
- const normalized = String(error || "").trim();
125
- return sanitizeAuditErrorMessage(normalized || fallbackMessage, fallbackMessage);
126
- }
127
-
128
- function sanitizeAuditErrorMessage(message, fallback = "Auth audit failed") {
129
- const fallbackMessage = String(fallback || "Auth audit failed");
130
- const normalized = String(message || "").trim();
131
- const candidate = normalized || fallbackMessage;
132
- const sanitized = candidate
133
- .replace(/\bbearer\s+[a-z0-9._~+/=-]+\b/gi, "bearer [REDACTED]")
134
- .replace(/\b(token|secret|password|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token)\b\s*[:=]\s*["']?[^"'\s,;]+["']?/gi, "$1=[REDACTED]")
135
- .replace(/\b[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+\b/gi, "[REDACTED_JWT]")
136
- .replace(/\bhttps?:\/\/[^\s"'`]+/gi, (rawUrl) => sanitizeDiagnosticUrl(rawUrl))
137
- .replace(/\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b/gi, "<redacted-email>");
138
- if (sanitized.length <= 512) {
139
- return sanitized;
140
- }
141
- return `${sanitized.slice(0, 509)}...`;
142
- }
143
-
144
- function sanitizeDiagnosticUrl(rawUrl) {
145
- const candidate = String(rawUrl || "").trim();
146
- if (!candidate) {
147
- return "<redacted-url>";
148
- }
149
- try {
150
- const parsed = new URL(candidate);
151
- if (!parsed.hostname) {
152
- return "<redacted-url>";
153
- }
154
- return `${parsed.protocol}//${parsed.host}/<redacted-path>`;
155
- } catch {
156
- return "<redacted-url>";
157
- }
158
- }
159
-
160
- function buildUnavailableAuditResponse(requestId, code, message, options = {}) {
161
- const safeMessage = sanitizeAuditErrorMessage(message, "Auth audit unavailable");
162
- const errorPayload = {
163
- code,
164
- message: safeMessage,
165
- requestId,
166
- retryable: options.retryable === true,
167
- };
168
- const phase = String(options.phase || "").trim();
169
- if (phase) {
170
- errorPayload.phase = phase;
171
- }
172
- const parsedStatusCode = Number.parseInt(String(options.statusCode || ""), 10);
173
- if (Number.isInteger(parsedStatusCode) && parsedStatusCode > 0) {
174
- errorPayload.statusCode = parsedStatusCode;
175
- }
176
- const errorCode = String(options.errorCode || "").trim();
177
- if (errorCode) {
178
- errorPayload.errorCode = errorCode.toUpperCase();
179
- }
180
- if (options.retryTelemetry && typeof options.retryTelemetry === "object") {
181
- errorPayload.retryTelemetry = options.retryTelemetry;
182
- }
183
- if (options.providerBreaker && typeof options.providerBreaker === "object") {
184
- errorPayload.providerBreaker = options.providerBreaker;
185
- }
186
- return {
187
- available: false,
188
- requestId,
189
- reason: safeMessage,
190
- error: errorPayload,
191
- };
192
- }
193
-
194
- function extractErrorDiagnostics(error, phase = "auth_audit") {
195
- const diagnostics = {
196
- phase: String(phase || "auth_audit").trim().slice(0, 64) || "auth_audit",
197
- };
198
- const statusCode = resolveAidenidProvisionStatusCode(error);
199
- if (Number.isInteger(statusCode) && statusCode > 0) {
200
- diagnostics.statusCode = statusCode;
201
- }
202
- let errorCode = resolveAidenidProvisionErrorCode(error);
203
- if (!errorCode && error instanceof Error) {
204
- errorCode = String(error.name || "").trim().toUpperCase();
205
- }
206
- if (errorCode) {
207
- diagnostics.errorCode = errorCode.toUpperCase();
208
- }
209
- return diagnostics;
210
- }
211
-
212
- function isAuditEnvelopeV2Enabled() {
213
- const normalized = String(process.env[AUTH_AUDIT_ENVELOPE_ENV] || "").trim().toLowerCase();
214
- return normalized === "" || normalized === "true" || normalized === "1" || normalized === AUTH_AUDIT_ENVELOPE_VERSION;
215
- }
216
-
217
- function buildAuditDataEnvelope(payload) {
218
- const data = { ...(payload && typeof payload === "object" ? payload : {}) };
219
- delete data.ok;
220
- delete data.operation;
221
- delete data.envelope;
222
- delete data.data;
223
- return data;
224
- }
225
-
226
- function finalizeAuditEnvelope(operation, requestId, payload) {
227
- const normalizedPayload = payload && typeof payload === "object" ? { ...payload } : { result: payload };
228
- const normalizedRequestId = String(normalizedPayload.requestId || requestId || createAuditRequestId());
229
- normalizedPayload.requestId = normalizedRequestId;
230
- if (!Object.prototype.hasOwnProperty.call(normalizedPayload, "available")) {
231
- normalizedPayload.available = !normalizedPayload.error;
232
- }
233
- if (!isAuditEnvelopeV2Enabled()) {
234
- return normalizedPayload;
235
- }
236
- normalizedPayload.ok = normalizedPayload.available === true;
237
- normalizedPayload.operation = String(operation || normalizedPayload.operation || "unknown");
238
- normalizedPayload.envelope = AUTH_AUDIT_ENVELOPE_VERSION;
239
- if (normalizedPayload.ok) {
240
- normalizedPayload.data = buildAuditDataEnvelope(normalizedPayload);
241
- } else {
242
- if (!normalizedPayload.error || typeof normalizedPayload.error !== "object") {
243
- normalizedPayload.error = {
244
- code: "AUTH_AUDIT_FAILED",
245
- message: String(normalizedPayload.reason || "Auth audit failed"),
246
- requestId: normalizedRequestId,
247
- retryable: false,
248
- };
249
- } else if (!normalizedPayload.error.requestId) {
250
- normalizedPayload.error = {
251
- ...normalizedPayload.error,
252
- requestId: normalizedRequestId,
253
- };
254
- }
255
- normalizedPayload.reason = String(
256
- normalizedPayload.reason
257
- || normalizedPayload.error.message
258
- || "Auth audit failed"
259
- );
260
- normalizedPayload.data = null;
261
- }
262
- return normalizedPayload;
263
- }
264
-
265
- function resolveAidenidProvisionStatusCode(error) {
266
- if (!(error instanceof Error)) {
267
- return 0;
268
- }
269
- const directStatus = Number.parseInt(String(error.statusCode || error.status || ""), 10);
270
- if (Number.isInteger(directStatus) && directStatus > 0) {
271
- return directStatus;
272
- }
273
- const statusMatch = String(error.message || "").match(/\bstatus\s+(\d{3})\b/i);
274
- if (!statusMatch) {
275
- return 0;
276
- }
277
- const parsed = Number.parseInt(statusMatch[1], 10);
278
- return Number.isInteger(parsed) ? parsed : 0;
279
- }
280
-
281
- function resolveAidenidProvisionErrorCode(error) {
282
- if (!(error instanceof Error)) {
283
- return "";
284
- }
285
- const explicitCode = String(error.errorCode || "").toUpperCase();
286
- if (explicitCode) {
287
- return explicitCode;
288
- }
289
- const directCode = String(error.code || "").toUpperCase();
290
- if (directCode) {
291
- return directCode;
292
- }
293
- const cause = error.cause;
294
- if (!cause || typeof cause !== "object") {
295
- return "";
296
- }
297
- return String(cause.code || cause.errno || "").toUpperCase();
298
- }
299
-
300
- function classifyAidenidProvisionFailure(error) {
301
- const classification = {
302
- retryable: false,
303
- statusCode: 0,
304
- errorCode: "",
305
- };
306
- if (!(error instanceof Error)) {
307
- return classification;
308
- }
309
- classification.errorCode = resolveAidenidProvisionErrorCode(error);
310
- classification.statusCode = resolveAidenidProvisionStatusCode(error);
311
- if (typeof error.retryable === "boolean") {
312
- classification.retryable = error.retryable;
313
- return classification;
314
- }
315
- if (error.name === "AbortError" || error.name === "TimeoutError") {
316
- classification.retryable = true;
317
- return classification;
318
- }
319
- if (RETRYABLE_AIDENID_PROVISION_ERROR_CODES.has(classification.errorCode)) {
320
- classification.retryable = true;
321
- return classification;
322
- }
323
- if (RETRYABLE_AIDENID_PROVISION_STATUS_CODES.has(classification.statusCode)) {
324
- classification.retryable = true;
325
- return classification;
326
- }
327
- const normalized = `${error.name} ${error.message || ""}`.toLowerCase();
328
- classification.retryable = RETRYABLE_AIDENID_PROVISION_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
329
- return classification;
330
- }
331
-
332
- function isRetryableAidenidProvisionError(error) {
333
- return classifyAidenidProvisionFailure(error).retryable;
334
- }
335
-
336
- function deriveAidenidBackoffSeed(requestId) {
337
- const normalizedRequestId = String(requestId || "").trim();
338
- const counter = AUTH_AUDIT_JITTER_COUNTER++;
339
- const seedMaterial = `${AUTH_AUDIT_JITTER_SECRET}:${normalizedRequestId || "fallback"}:${counter}`;
340
- return createHash("sha256").update(seedMaterial).digest().readUInt32BE(0);
341
- }
342
-
343
- function computeAidenidProvisionBackoffMs(
344
- attempt,
345
- baseBackoffMs = AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS,
346
- jitterSeed = 0
347
- ) {
348
- const cappedBase = Math.max(1, Number.isFinite(baseBackoffMs) ? Math.trunc(baseBackoffMs) : AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS);
349
- const exponential = Math.min(2500, cappedBase * Math.pow(2, Math.max(0, attempt)));
350
- const normalizedSeed = Number.isFinite(jitterSeed) ? Math.abs(Math.trunc(jitterSeed)) : 0;
351
- const deterministicJitter = ((Math.max(0, attempt) * 1664525 + 1013904223 + normalizedSeed) % 1000) / 1000;
352
- const jitterFactor = 0.5 + (deterministicJitter * 0.5);
353
- return Math.max(1, Math.trunc(exponential * jitterFactor));
354
- }
355
-
356
- function normalizeAidenidTotalBudgetMs(value) {
357
- if (!Number.isFinite(value) || value <= 0) {
358
- return AUTH_AIDENID_PROVISION_TOTAL_BUDGET_MS;
359
- }
360
- return Math.max(1, Math.trunc(value));
361
- }
362
-
363
- function normalizeProviderBreakerScope(scope) {
364
- const normalized = String(scope || "").trim().toLowerCase();
365
- if (!normalized) {
366
- return AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT;
367
- }
368
- return normalized.replace(/[^a-z0-9._:/-]/g, "-").replace(/-+/g, "-").slice(0, 120) || AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT;
369
- }
370
-
371
- function deriveProviderBreakerScope(options = {}) {
372
- const explicitScope = normalizeProviderBreakerScope(
373
- options.contextId || options.scopeId || options.repoScope || ""
374
- );
375
- if (explicitScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
376
- return explicitScope;
377
- }
378
- const parts = [];
379
- const urlCandidate = String(options.targetUrl || options.apiUrl || "").trim();
380
- if (urlCandidate) {
381
- try {
382
- const parsed = new URL(urlCandidate);
383
- if (parsed.hostname) {
384
- parts.push(parsed.hostname.toLowerCase());
385
- }
386
- } catch {
387
- // Fall through to non-URL scoped dimensions.
388
- }
389
- }
390
- const orgScope = normalizeProviderBreakerScope(options.orgId || options.organizationId || "");
391
- if (orgScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
392
- parts.push(`org-${orgScope}`);
393
- }
394
- const projectScope = normalizeProviderBreakerScope(options.projectId || "");
395
- if (projectScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
396
- parts.push(`proj-${projectScope}`);
397
- }
398
- const repoScope = normalizeProviderBreakerScope(process.env.GITHUB_REPOSITORY || "");
399
- if (repoScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
400
- parts.push(`repo-${repoScope}`);
401
- }
402
- if (parts.length === 0) {
403
- const workspaceFallback = createHash("sha256")
404
- .update(process.cwd())
405
- .digest("hex")
406
- .slice(0, 20);
407
- parts.push(`ws-${workspaceFallback}`);
408
- }
409
- return normalizeProviderBreakerScope(parts.join(":"));
410
- }
411
-
412
- function getProviderBreakerKey(provider, scope) {
413
- return `${provider}:${scope}`;
414
- }
415
-
416
- function getProviderBreakerStatePath() {
417
- const configuredPath = String(
418
- process.env[AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_ENV] || AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_DEFAULT
419
- ).trim();
420
- if (!configuredPath) {
421
- return "";
422
- }
423
- const repoScope = String(process.env.GITHUB_REPOSITORY || "local").trim() || "local";
424
- const runScope = String(process.env.GITHUB_RUN_ID || process.pid || "0").trim();
425
- const scopeSuffix = createHash("sha256")
426
- .update(`${repoScope}:${runScope}`)
427
- .digest("hex")
428
- .slice(0, 12);
429
- const resolvedPath = path.isAbsolute(configuredPath)
430
- ? configuredPath
431
- : path.resolve(process.cwd(), configuredPath);
432
- const ext = path.extname(resolvedPath) || ".json";
433
- const base = resolvedPath.endsWith(ext) ? resolvedPath.slice(0, -ext.length) : resolvedPath;
434
- return `${base}.${scopeSuffix}${ext}`;
435
- }
436
-
437
- function hydrateProviderBreakerState() {
438
- if (AUTH_AUDIT_PROVIDER_BREAKERS_HYDRATED) {
439
- return;
440
- }
441
- AUTH_AUDIT_PROVIDER_BREAKERS_HYDRATED = true;
442
- const statePath = getProviderBreakerStatePath();
443
- if (!statePath || !fs.existsSync(statePath)) {
444
- return;
445
- }
446
- const nowMs = Date.now();
447
- try {
448
- const raw = fs.readFileSync(statePath, "utf-8");
449
- const parsed = JSON.parse(raw);
450
- const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
451
- for (const entry of entries) {
452
- const lastUpdatedAtMs = Number.isFinite(entry?.lastUpdatedAtMs)
453
- ? Math.max(0, Math.trunc(entry.lastUpdatedAtMs))
454
- : Number.isFinite(entry?.windowStartedAt)
455
- ? Math.max(0, Math.trunc(entry.windowStartedAt))
456
- : 0;
457
- if (lastUpdatedAtMs > 0 && (nowMs - lastUpdatedAtMs) > AUTH_AUDIT_PROVIDER_BREAKER_ENTRY_TTL_MS) {
458
- continue;
459
- }
460
- const provider = String(entry?.provider || "").trim().toLowerCase();
461
- if (!provider) {
462
- continue;
463
- }
464
- const scope = normalizeProviderBreakerScope(entry?.scope);
465
- const key = getProviderBreakerKey(provider, scope);
466
- AUTH_AUDIT_PROVIDER_BREAKERS.set(key, {
467
- key,
468
- provider,
469
- scope,
470
- consecutiveFailures: Number.isFinite(entry?.consecutiveFailures) ? Math.max(0, Math.trunc(entry.consecutiveFailures)) : 0,
471
- windowStartedAt: Number.isFinite(entry?.windowStartedAt) ? Math.max(0, Math.trunc(entry.windowStartedAt)) : 0,
472
- openUntilMs: Number.isFinite(entry?.openUntilMs) ? Math.max(0, Math.trunc(entry.openUntilMs)) : 0,
473
- lastFailureCode: String(entry?.lastFailureCode || "").trim().toUpperCase(),
474
- lastUpdatedAtMs,
475
- });
476
- }
477
- } catch {
478
- // Fall back to in-memory behavior if persisted state cannot be loaded.
479
- }
480
- }
481
-
482
- function persistProviderBreakerState(nowMs = Date.now()) {
483
- const statePath = getProviderBreakerStatePath();
484
- if (!statePath) {
485
- return;
486
- }
487
- try {
488
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
489
- const payload = {
490
- version: 1,
491
- updatedAtMs: nowMs,
492
- entries: [...AUTH_AUDIT_PROVIDER_BREAKERS.values()].map((state) => ({
493
- provider: state.provider,
494
- scope: state.scope,
495
- consecutiveFailures: state.consecutiveFailures,
496
- windowStartedAt: state.windowStartedAt,
497
- openUntilMs: state.openUntilMs,
498
- lastFailureCode: state.lastFailureCode,
499
- lastUpdatedAtMs: state.lastUpdatedAtMs || nowMs,
500
- })),
501
- };
502
- const tmpPath = `${statePath}.${process.pid}.tmp`;
503
- fs.writeFileSync(tmpPath, JSON.stringify(payload), "utf-8");
504
- fs.renameSync(tmpPath, statePath);
505
- } catch {
506
- // Persistence is best-effort; in-memory safeguards remain active.
507
- }
508
- }
509
-
510
- function sweepProviderBreakers(nowMs = Date.now()) {
511
- let mutated = false;
512
- for (const [breakerKey, state] of AUTH_AUDIT_PROVIDER_BREAKERS.entries()) {
513
- const isOpen = Number(state.openUntilMs || 0) > nowMs;
514
- const lastUpdatedAtMs = Number(state.lastUpdatedAtMs || state.windowStartedAt || 0);
515
- if (isOpen) {
516
- continue;
517
- }
518
- if (lastUpdatedAtMs > 0 && (nowMs - lastUpdatedAtMs) > AUTH_AUDIT_PROVIDER_BREAKER_ENTRY_TTL_MS) {
519
- AUTH_AUDIT_PROVIDER_BREAKERS.delete(breakerKey);
520
- mutated = true;
521
- }
522
- }
523
- if (mutated) {
524
- persistProviderBreakerState(nowMs);
525
- }
526
- }
527
-
528
- function getProviderBreakerState(provider, scope) {
529
- hydrateProviderBreakerState();
530
- const normalizedProvider = String(provider || "").trim().toLowerCase();
531
- if (!normalizedProvider) {
532
- return null;
533
- }
534
- const normalizedScope = normalizeProviderBreakerScope(scope);
535
- const breakerKey = getProviderBreakerKey(normalizedProvider, normalizedScope);
536
- const nowMs = Date.now();
537
- sweepProviderBreakers(nowMs);
538
- if (!AUTH_AUDIT_PROVIDER_BREAKERS.has(breakerKey)) {
539
- AUTH_AUDIT_PROVIDER_BREAKERS.set(breakerKey, {
540
- key: breakerKey,
541
- provider: normalizedProvider,
542
- scope: normalizedScope,
543
- consecutiveFailures: 0,
544
- windowStartedAt: 0,
545
- openUntilMs: 0,
546
- lastFailureCode: "",
547
- lastUpdatedAtMs: nowMs,
548
- });
549
- persistProviderBreakerState(nowMs);
550
- }
551
- return AUTH_AUDIT_PROVIDER_BREAKERS.get(breakerKey);
552
- }
553
-
554
- function getProviderBreakerSnapshot(provider, scope, nowMs = Date.now()) {
555
- const state = getProviderBreakerState(provider, scope);
556
- if (!state) {
557
- return null;
558
- }
559
- const remainingCooldownMs = state.openUntilMs > nowMs ? state.openUntilMs - nowMs : 0;
560
- return {
561
- key: state.key,
562
- provider: state.provider,
563
- scope: state.scope,
564
- consecutiveFailures: state.consecutiveFailures,
565
- windowStartedAt: state.windowStartedAt || 0,
566
- remainingCooldownMs,
567
- cooldownUntilMs: state.openUntilMs || 0,
568
- lastFailureCode: state.lastFailureCode || "",
569
- };
570
- }
571
-
572
- function enforceProviderBreaker(provider, scope, requestId) {
573
- const state = getProviderBreakerState(provider, scope);
574
- if (!state) {
575
- return;
576
- }
577
- const nowMs = Date.now();
578
- if (state.openUntilMs > nowMs) {
579
- const snapshot = getProviderBreakerSnapshot(provider, scope, nowMs);
580
- const blocked = new AuthAuditError(
581
- `Provider circuit is open for ${state.provider}/${state.scope}; retry after cooldown (requestId=${requestId}).`
582
- );
583
- blocked.errorCode = "AUTH_AUDIT_PROVIDER_CIRCUIT_OPEN";
584
- blocked.retryable = false;
585
- blocked.providerBreaker = snapshot;
586
- throw blocked;
587
- }
588
- if (state.openUntilMs > 0 && state.openUntilMs <= nowMs) {
589
- state.consecutiveFailures = 0;
590
- state.windowStartedAt = nowMs;
591
- state.openUntilMs = 0;
592
- state.lastFailureCode = "";
593
- state.lastUpdatedAtMs = nowMs;
594
- AUTH_AUDIT_PROVIDER_BREAKERS.set(state.key, state);
595
- persistProviderBreakerState(nowMs);
596
- }
597
- }
598
-
599
- function recordProviderBreakerSuccess(provider, scope) {
600
- const state = getProviderBreakerState(provider, scope);
601
- if (!state) {
602
- return;
603
- }
604
- state.consecutiveFailures = 0;
605
- state.windowStartedAt = 0;
606
- state.openUntilMs = 0;
607
- state.lastFailureCode = "";
608
- state.lastUpdatedAtMs = Date.now();
609
- AUTH_AUDIT_PROVIDER_BREAKERS.set(state.key, state);
610
- persistProviderBreakerState(state.lastUpdatedAtMs);
611
- }
612
-
613
- function recordProviderBreakerFailure(provider, scope, errorCode = "") {
614
- const state = getProviderBreakerState(provider, scope);
615
- if (!state) {
616
- return null;
617
- }
618
- const nowMs = Date.now();
619
- if (!state.windowStartedAt || (nowMs - state.windowStartedAt) > AUTH_AUDIT_PROVIDER_BREAKER_WINDOW_MS) {
620
- state.windowStartedAt = nowMs;
621
- state.consecutiveFailures = 0;
622
- }
623
- state.consecutiveFailures += 1;
624
- state.lastFailureCode = String(errorCode || "").trim().toUpperCase();
625
- if (state.consecutiveFailures >= AUTH_AUDIT_PROVIDER_BREAKER_FAILURE_THRESHOLD) {
626
- state.openUntilMs = nowMs + AUTH_AUDIT_PROVIDER_BREAKER_COOLDOWN_MS;
627
- }
628
- state.lastUpdatedAtMs = nowMs;
629
- AUTH_AUDIT_PROVIDER_BREAKERS.set(state.key, state);
630
- persistProviderBreakerState(nowMs);
631
- return getProviderBreakerSnapshot(provider, scope, nowMs);
632
- }
633
-
634
- function isAbortSignalLike(signal) {
635
- return Boolean(
636
- signal &&
637
- typeof signal === "object" &&
638
- typeof signal.aborted === "boolean" &&
639
- typeof signal.addEventListener === "function" &&
640
- typeof signal.removeEventListener === "function"
641
- );
642
- }
643
-
644
- function composeAbortSignals(primarySignal, secondarySignal) {
645
- const hasPrimary = isAbortSignalLike(primarySignal);
646
- const hasSecondary = isAbortSignalLike(secondarySignal);
647
- if (!hasPrimary && !hasSecondary) {
648
- return { signal: undefined, cleanup: () => {} };
649
- }
650
- if (!hasPrimary) {
651
- return { signal: secondarySignal, cleanup: () => {} };
652
- }
653
- if (!hasSecondary) {
654
- return { signal: primarySignal, cleanup: () => {} };
655
- }
656
- const mergedController = new AbortController();
657
- const forwardAbort = () => {
658
- if (!mergedController.signal.aborted) {
659
- mergedController.abort();
660
- }
661
- };
662
- if (primarySignal.aborted || secondarySignal.aborted) {
663
- forwardAbort();
664
- return { signal: mergedController.signal, cleanup: () => {} };
665
- }
666
- primarySignal.addEventListener("abort", forwardAbort, { once: true });
667
- secondarySignal.addEventListener("abort", forwardAbort, { once: true });
668
- return {
669
- signal: mergedController.signal,
670
- cleanup: () => {
671
- primarySignal.removeEventListener("abort", forwardAbort);
672
- secondarySignal.removeEventListener("abort", forwardAbort);
673
- },
674
- };
675
- }
676
-
677
- async function provisionEmailIdentityWithRetry(provisionEmailIdentity, options = {}) {
678
- const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
679
- ? Math.trunc(options.timeoutMs)
680
- : AUTH_AIDENID_PROVISION_TIMEOUT_MS;
681
- const maxRetries = Number.isInteger(options.maxRetries) && options.maxRetries >= 0
682
- ? options.maxRetries
683
- : AUTH_AIDENID_PROVISION_MAX_RETRIES;
684
- const baseBackoffMs = Number.isFinite(options.baseBackoffMs) && options.baseBackoffMs > 0
685
- ? Math.trunc(options.baseBackoffMs)
686
- : AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS;
687
- const requestOptions = options.requestOptions && typeof options.requestOptions === "object"
688
- ? { ...options.requestOptions }
689
- : {};
690
- const requestId = String(options.requestId || createAuditRequestId());
691
- const jitterSeed = Number.isFinite(options.jitterSeed)
692
- ? Math.abs(Math.trunc(options.jitterSeed))
693
- : deriveAidenidBackoffSeed(requestId);
694
- const totalBudgetMs = normalizeAidenidTotalBudgetMs(options.totalBudgetMs);
695
- const minAttemptTimeoutMs = Number.isFinite(options.minAttemptTimeoutMs) && options.minAttemptTimeoutMs > 0
696
- ? Math.trunc(options.minAttemptTimeoutMs)
697
- : AUTH_AIDENID_PROVISION_MIN_ATTEMPT_TIMEOUT_MS;
698
- const effectiveMinAttemptTimeoutMs = Math.max(1, Math.min(timeoutMs, minAttemptTimeoutMs));
699
- const attemptMetrics = [];
700
- const retryWindowStartedAt = Date.now();
701
- for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
702
- const elapsedMs = Date.now() - retryWindowStartedAt;
703
- const remainingBudgetMs = totalBudgetMs - elapsedMs;
704
- if (remainingBudgetMs <= 0) {
705
- const exhausted = new AuthAuditError(
706
- `AIdenID provisioning retry budget exhausted after ${attempt} attempt(s) (requestId=${requestId})`
707
- );
708
- exhausted.errorCode = "AIDENID_RETRY_BUDGET_EXHAUSTED";
709
- exhausted.retryable = false;
710
- exhausted.retryTelemetry = {
711
- requestId,
712
- attempts: attempt,
713
- totalBudgetMs,
714
- minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
715
- totalElapsedMs: elapsedMs,
716
- attemptMetrics,
717
- };
718
- throw exhausted;
719
- }
720
- if (remainingBudgetMs < effectiveMinAttemptTimeoutMs) {
721
- const exhausted = new AuthAuditError(
722
- `AIdenID provisioning remaining retry budget (${remainingBudgetMs}ms) fell below minimum attempt window (${effectiveMinAttemptTimeoutMs}ms) (requestId=${requestId})`
723
- );
724
- exhausted.errorCode = "AIDENID_RETRY_BUDGET_EXHAUSTED";
725
- exhausted.retryable = false;
726
- exhausted.retryTelemetry = {
727
- requestId,
728
- attempts: attempt,
729
- totalBudgetMs,
730
- minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
731
- totalElapsedMs: elapsedMs,
732
- attemptMetrics,
733
- };
734
- throw exhausted;
735
- }
736
- const attemptTimeoutMs = Math.max(effectiveMinAttemptTimeoutMs, Math.min(timeoutMs, remainingBudgetMs));
737
- const attemptStartedAt = Date.now();
738
- const attemptMetric = {
739
- attempt: attempt + 1,
740
- timeoutMs: attemptTimeoutMs,
741
- budgetBeforeAttemptMs: remainingBudgetMs,
742
- };
743
- try {
744
- const attemptPromise = provisionEmailIdentity({
745
- ...requestOptions,
746
- fetchImpl: async (resource, init = {}) => {
747
- const callerSignal = isAbortSignalLike(init.signal) ? init.signal : undefined;
748
- const controller = new AbortController();
749
- const timeoutHandle = setTimeout(() => controller.abort(), attemptTimeoutMs);
750
- const { signal: compositeSignal, cleanup: cleanupCompositeSignal } = composeAbortSignals(callerSignal, controller.signal);
751
- const nextInit = {
752
- ...init,
753
- ...(compositeSignal ? { signal: compositeSignal } : {}),
754
- };
755
- try {
756
- const response = await fetch(resource, nextInit);
757
- if (response && RETRYABLE_AIDENID_PROVISION_STATUS_CODES.has(Number(response.status || 0))) {
758
- if (response.body && typeof response.body.cancel === "function") {
759
- try {
760
- await response.body.cancel();
761
- } catch {
762
- // No-op: retry classification still applies if body drain fails.
763
- }
764
- }
765
- const transientHttpError = new AuthAuditError(`AIdenID transient HTTP ${response.status}`);
766
- transientHttpError.errorCode = "AIDENID_HTTP_RETRYABLE";
767
- transientHttpError.statusCode = Number(response.status || 0);
768
- transientHttpError.retryable = true;
769
- throw transientHttpError;
770
- }
771
- return response;
772
- } catch (error) {
773
- if (callerSignal && callerSignal.aborted === true) {
774
- const aborted = new AuthAuditError("AIdenID provisioning aborted by caller");
775
- aborted.errorCode = "AIDENID_ABORTED_BY_CALLER";
776
- aborted.retryable = false;
777
- throw aborted;
778
- }
779
- const failure = classifyAidenidProvisionFailure(error);
780
- if (failure.retryable) {
781
- const wrapped = new AuthAuditError(normalizeErrorMessage(error, "AIdenID provisioning transport failed"));
782
- wrapped.errorCode = failure.errorCode || "AIDENID_TRANSPORT_RETRYABLE";
783
- wrapped.statusCode = failure.statusCode || 0;
784
- wrapped.retryable = true;
785
- throw wrapped;
786
- }
787
- throw error;
788
- } finally {
789
- clearTimeout(timeoutHandle);
790
- cleanupCompositeSignal();
791
- }
792
- },
793
- });
794
- const result = await Promise.race([
795
- attemptPromise,
796
- new Promise((_, reject) => {
797
- const timer = setTimeout(() => {
798
- const timeoutError = new AuthAuditError(
799
- `AIdenID provisioning attempt ${attempt + 1} timed out after ${attemptTimeoutMs}ms (requestId=${requestId})`
800
- );
801
- timeoutError.errorCode = "AIDENID_ATTEMPT_TIMEOUT";
802
- timeoutError.retryable = true;
803
- reject(timeoutError);
804
- }, attemptTimeoutMs);
805
- attemptPromise.finally(() => clearTimeout(timer));
806
- }),
807
- ]);
808
- attemptMetric.durationMs = Date.now() - attemptStartedAt;
809
- attemptMetric.outcome = "success";
810
- attemptMetrics.push(attemptMetric);
811
- const retryTelemetry = {
812
- requestId,
813
- attempts: attempt + 1,
814
- totalBudgetMs,
815
- minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
816
- totalElapsedMs: Date.now() - retryWindowStartedAt,
817
- attemptMetrics,
818
- };
819
- if (result && typeof result === "object" && !Array.isArray(result)) {
820
- return {
821
- ...result,
822
- retryTelemetry,
823
- };
824
- }
825
- return {
826
- value: result,
827
- retryTelemetry,
828
- };
829
- } catch (error) {
830
- const failure = classifyAidenidProvisionFailure(error);
831
- attemptMetric.durationMs = Date.now() - attemptStartedAt;
832
- attemptMetric.outcome = failure.retryable ? "retryable_error" : "terminal_error";
833
- attemptMetric.errorCode = failure.errorCode || "AIDENID_PROVISION_FAILED";
834
- attemptMetric.statusCode = failure.statusCode || 0;
835
- attemptMetrics.push(attemptMetric);
836
- if (!failure.retryable || attempt >= maxRetries) {
837
- const reason = normalizeErrorMessage(error, "AIdenID provisioning failed");
838
- const terminal = new AuthAuditError(
839
- `AIdenID provisioning failed after ${attempt + 1} attempt(s) (requestId=${requestId}): ${reason}`
840
- );
841
- terminal.errorCode = failure.errorCode || "AIDENID_PROVISION_FAILED";
842
- terminal.retryable = false;
843
- terminal.retryTelemetry = {
844
- requestId,
845
- attempts: attempt + 1,
846
- totalBudgetMs,
847
- minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
848
- totalElapsedMs: Date.now() - retryWindowStartedAt,
849
- attemptMetrics,
850
- };
851
- throw terminal;
852
- }
853
- }
854
- const backoffMs = computeAidenidProvisionBackoffMs(attempt, baseBackoffMs, jitterSeed);
855
- const remainingAfterAttemptMs = totalBudgetMs - (Date.now() - retryWindowStartedAt);
856
- if (remainingAfterAttemptMs <= 0) {
857
- break;
858
- }
859
- await sleep(Math.min(backoffMs, remainingAfterAttemptMs));
860
- }
861
- const exhausted = new AuthAuditError(`AIdenID provisioning failed after retry budget was exhausted (requestId=${requestId})`);
862
- exhausted.errorCode = "AIDENID_RETRY_BUDGET_EXHAUSTED";
863
- exhausted.retryable = false;
864
- exhausted.retryTelemetry = {
865
- requestId,
866
- attempts: maxRetries + 1,
867
- totalBudgetMs,
868
- minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
869
- totalElapsedMs: Date.now() - retryWindowStartedAt,
870
- attemptMetrics,
871
- };
872
- throw exhausted;
873
- }
874
-
875
- function normalizeHeaderValue(value) {
876
- const normalized = String(value || "").trim();
877
- return normalized || "";
878
- }
879
-
880
- function evaluateAuthenticatedHeaderFindings(targetUrl, headers = {}, authSignals = {}) {
881
- const findings = [];
882
- const normalizedHeaders = headers && typeof headers === "object" ? headers : {};
883
- const csp = normalizeHeaderValue(normalizedHeaders["content-security-policy"]);
884
- const hsts = normalizeHeaderValue(normalizedHeaders["strict-transport-security"]);
885
- const xFrameOptions = normalizeHeaderValue(normalizedHeaders["x-frame-options"]);
886
- let requiresHsts = false;
887
- try {
888
- requiresHsts = new URL(targetUrl).protocol === "https:";
889
- } catch {
890
- requiresHsts = String(targetUrl || "").startsWith("https://");
891
- }
892
-
893
- if (!csp) {
894
- findings.push({
895
- severity: "P2",
896
- title: "Authenticated page missing Content-Security-Policy header",
897
- file: targetUrl,
898
- });
899
- }
900
- if (requiresHsts && !hsts) {
901
- findings.push({
902
- severity: "P1",
903
- title: "Authenticated page missing Strict-Transport-Security header",
904
- file: targetUrl,
905
- });
906
- }
907
- if (!xFrameOptions) {
908
- findings.push({
909
- severity: "P2",
910
- title: "Authenticated page missing X-Frame-Options header",
911
- file: targetUrl,
912
- });
913
- } else if (!/^(deny|sameorigin)$/i.test(xFrameOptions)) {
914
- findings.push({
915
- severity: "P2",
916
- title: "Authenticated page has weak X-Frame-Options policy: " + xFrameOptions,
917
- file: targetUrl,
918
- });
919
- }
920
-
921
- if (authSignals && typeof authSignals === "object") {
922
- authSignals.headerPolicyPassed = findings.length === 0;
923
- authSignals.headerPolicyFindingCount = findings.length;
924
- }
925
- return findings;
926
- }
927
-
928
- async function provisionTestIdentity(input) {
929
- const requestId = String(input.requestId || createAuditRequestId());
930
- const providerKey = AUTH_AUDIT_PROVIDER_AIDENID;
931
- const providerScope = deriveProviderBreakerScope({
932
- contextId: input.providerContextId || input.contextId || input.scopeId,
933
- apiUrl: input.apiUrl,
934
- orgId: input.orgId || input.aidenidOrgId,
935
- projectId: input.projectId || input.aidenidProjectId,
936
- });
937
- try {
938
- enforceProviderBreaker(providerKey, providerScope, requestId);
939
- const executeRequested = input.execute === true;
940
- const allowLiveProvision = input.allowProvisioning === true || process.env.SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION === "1";
941
- if (executeRequested && !allowLiveProvision) {
942
- return buildUnavailableAuditResponse(
943
- requestId,
944
- "AIDENID_PROVISION_APPROVAL_REQUIRED",
945
- "Live AIdenID provisioning requires explicit allowProvisioning=true (or SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION=1)."
946
- );
947
- }
948
-
949
- const { provisionEmailIdentity, resolveAidenIdCredentials } = await import("../../../ai/aidenid.js");
950
- const creds = await resolveAidenIdCredentials();
951
- if (!creds.apiKey) {
952
- return buildUnavailableAuditResponse(
953
- requestId,
954
- "AIDENID_API_KEY_MISSING",
955
- "AIdenID API key not configured (set AIDENID_API_KEY)"
956
- );
957
- }
958
- const result = await provisionEmailIdentityWithRetry(provisionEmailIdentity, {
959
- timeoutMs: AUTH_AIDENID_PROVISION_TIMEOUT_MS,
960
- maxRetries: AUTH_AIDENID_PROVISION_MAX_RETRIES,
961
- baseBackoffMs: AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS,
962
- totalBudgetMs: AUTH_AIDENID_PROVISION_TOTAL_BUDGET_MS,
963
- minAttemptTimeoutMs: AUTH_AIDENID_PROVISION_MIN_ATTEMPT_TIMEOUT_MS,
964
- requestId,
965
- requestOptions: {
966
- apiUrl: creds.apiUrl,
967
- apiKey: creds.apiKey,
968
- tags: ["jules-audit", "frontend-test"],
969
- ttlSeconds: 3600,
970
- dryRun: !executeRequested,
971
- },
972
- });
973
- const identity = result && typeof result === "object"
974
- ? (Object.prototype.hasOwnProperty.call(result, "identity") ? result.identity : (Object.prototype.hasOwnProperty.call(result, "value") ? result.value : result))
975
- : result;
976
- const retryTelemetry = result && typeof result === "object" && result.retryTelemetry
977
- ? result.retryTelemetry
978
- : null;
979
- recordProviderBreakerSuccess(providerKey, providerScope);
980
- return { available: true, requestId, dryRun: !executeRequested, identity, retryTelemetry };
981
- } catch (err) {
982
- const message = "AIdenID provisioning failed: " + normalizeErrorMessage(err, "unknown error");
983
- const retryable = isRetryableAidenidProvisionError(err);
984
- let providerBreaker = err && typeof err === "object" && err.providerBreaker ? err.providerBreaker : null;
985
- if (retryable) {
986
- providerBreaker = recordProviderBreakerFailure(providerKey, providerScope, err && typeof err === "object" ? err.errorCode : "") || providerBreaker;
987
- } else if (!providerBreaker) {
988
- providerBreaker = getProviderBreakerSnapshot(providerKey, providerScope);
989
- }
990
- return buildUnavailableAuditResponse(requestId, "AIDENID_PROVISION_FAILED", message, {
991
- retryable,
992
- retryTelemetry: err && typeof err === "object" && err.retryTelemetry ? err.retryTelemetry : null,
993
- providerBreaker,
994
- });
995
- }
996
- }
997
-
998
- /**
999
- * Run Playwright to authenticate and inspect the page.
1000
- * - Runtime values loaded from a secure temp context file (credentials not exposed in process env)
1001
- * - Auth verification checks URL change + cookie presence (not just click success)
1002
- * - Console errors redacted to prevent sensitive data leakage
1003
- * - Cookie values never captured (names + flags only)
1004
- * - Temp script/context cleanup in finally block (not just success path)
1005
- */
1006
- async function authenticatedPageCheck(input) {
1007
- const requestId = String(input.requestId || createAuditRequestId());
1008
- const providerKey = AUTH_AUDIT_PROVIDER_PLAYWRIGHT_TARGET;
1009
- const url = input.url;
1010
- if (!url) throw new AuthAuditError("authenticated_page_check requires url");
1011
- const targetUrl = resolveAuthAuditTarget(url, input, "authenticated_page_check.target");
1012
- const providerScope = deriveProviderBreakerScope({
1013
- contextId: input.providerContextId || input.contextId || input.scopeId,
1014
- targetUrl,
1015
- });
1016
-
1017
- const loginUrlCandidate = input.loginUrl || targetUrl + "/login";
1018
- const loginUrl = resolveAuthAuditTarget(loginUrlCandidate, input, "authenticated_page_check.login");
1019
- const allowAuthMutation = input.allowAuthMutation === true || process.env[AUTH_MUTATION_ALLOWED_ENV] === "1";
1020
-
1021
- try {
1022
- enforceProviderBreaker(providerKey, providerScope, requestId);
1023
- const authContextJson = JSON.stringify({
1024
- email: input.email || "",
1025
- password: input.password || "",
1026
- emailField: input.emailField || "",
1027
- passwordField: input.passwordField || "",
1028
- submitSelector: input.submitSelector || "",
1029
- });
1030
- // Use scrubbed env strip API keys/tokens from child process
1031
- const { buildScrubbedEnv } = await import("./shell.js");
1032
- const env = {
1033
- ...buildScrubbedEnv(),
1034
- SL_AUDIT_TARGET_URL: targetUrl,
1035
- SL_AUDIT_LOGIN_URL: loginUrl,
1036
- SL_AUDIT_ALLOW_AUTH_MUTATION: allowAuthMutation ? "1" : "0",
1037
- };
1038
-
1039
- const output = await runPlaywrightAuditScriptWithRetry(null, env, {
1040
- scriptSource: PLAYWRIGHT_AUTH_SCRIPT,
1041
- stdinPayload: authContextJson,
1042
- });
1043
-
1044
- const result = JSON.parse(output.trim());
1045
- const findings = [];
1046
- for (const cookie of (result.cookies || [])) {
1047
- if (cookie.sensitive && !cookie.httpOnly) {
1048
- findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing httpOnly flag", file: targetUrl });
1049
- }
1050
- if (cookie.sensitive && !cookie.secure) {
1051
- findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing Secure flag", file: targetUrl });
1052
- }
1053
- if (cookie.sensitive && cookie.sameSite === "None") {
1054
- findings.push({ severity: "P2", title: "Sensitive cookie '" + cookie.name + "' has SameSite=None", file: targetUrl });
1055
- }
1056
- }
1057
- findings.push(...evaluateAuthenticatedHeaderFindings(targetUrl, result.headers || {}, result.authSignals || {}));
1058
- recordProviderBreakerSuccess(providerKey, providerScope);
1059
- return { available: true, requestId, method: "playwright", mutationAllowed: allowAuthMutation, findings, ...result };
1060
- } catch (err) {
1061
- const code = err instanceof AuthAuditError ? "AUTH_AUDIT_VALIDATION_FAILED" : "AUTH_AUDIT_PLAYWRIGHT_FAILED";
1062
- const baseMessage = err instanceof AuthAuditError ? err.message : "Playwright auth audit failed: " + normalizeErrorMessage(err, "unknown error");
1063
- const retryable = isRetryablePlaywrightExecutionError(err);
1064
- let providerBreaker = err && typeof err === "object" && err.providerBreaker ? err.providerBreaker : null;
1065
- if (retryable) {
1066
- providerBreaker = recordProviderBreakerFailure(providerKey, providerScope, err && typeof err === "object" ? err.errorCode : "") || providerBreaker;
1067
- } else if (!providerBreaker) {
1068
- providerBreaker = getProviderBreakerSnapshot(providerKey, providerScope);
1069
- }
1070
- return buildUnavailableAuditResponse(requestId, code, baseMessage, {
1071
- retryable,
1072
- providerBreaker,
1073
- });
1074
- }
1075
- }
1076
-
1077
- // Playwright script as a constant — no string interpolation of URLs/credentials.
1078
- // Dynamic auth context is read from stdin at runtime to avoid local credential temp files.
1079
- const PLAYWRIGHT_AUTH_SCRIPT = `
1080
- const { chromium } = require('playwright');
1081
- const fs = require('node:fs');
1082
-
1083
- (async () => {
1084
- const targetUrl = process.env.SL_AUDIT_TARGET_URL;
1085
- const loginUrl = process.env.SL_AUDIT_LOGIN_URL;
1086
- const allowAuthMutation = process.env.SL_AUDIT_ALLOW_AUTH_MUTATION === '1';
1087
- let context = {};
1088
- try {
1089
- let stdinPayload = fs.readFileSync(0, 'utf-8');
1090
- if (stdinPayload) {
1091
- context = JSON.parse(stdinPayload) || {};
1092
- }
1093
- stdinPayload = '';
1094
- } catch {
1095
- context = {};
1096
- }
1097
-
1098
- let email = context.email || '';
1099
- let password = context.password || '';
1100
- const emailSelector = context.emailField || 'input[type="email"]';
1101
- const passwordSelector = context.passwordField || 'input[type="password"]';
1102
- const submitSelector = context.submitSelector || 'button[type="submit"]';
1103
- if (Object.prototype.hasOwnProperty.call(context, 'password')) delete context.password;
1104
- if (Object.prototype.hasOwnProperty.call(context, 'token')) delete context.token;
1105
- if (Object.prototype.hasOwnProperty.call(context, 'secret')) delete context.secret;
1106
-
1107
- let browser = null;
1108
- const results = { authenticated: false, authSignals: {}, errors: [], cookies: [], headers: {}, domStats: {}, executionFailed: false };
1109
- results.authSignals.mutationAllowed = allowAuthMutation;
1110
- function normalizePath(value) {
1111
- const normalized = String(value || '/').replace(/\\/+$/, '');
1112
- return normalized || '/';
1113
- }
1114
- function didLeaveLoginSurface(currentValue, loginValue) {
1115
- try {
1116
- const currentUrl = new URL(currentValue);
1117
- const loginParsed = new URL(loginValue);
1118
- return (
1119
- currentUrl.origin !== loginParsed.origin ||
1120
- normalizePath(currentUrl.pathname) !== normalizePath(loginParsed.pathname)
1121
- );
1122
- } catch {
1123
- return String(currentValue || '') !== String(loginValue || '');
1124
- }
1125
- }
1126
- function sanitizeErrorText(value) {
1127
- return String(value || '')
1128
- .replace(/\\s+/g, ' ')
1129
- .replace(/Bearer\\s+[^\\s,;]+/gi, 'Bearer [REDACTED]')
1130
- .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]')
1131
- .replace(/\\b[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{8,}\\b/g, '[REDACTED_JWT]')
1132
- .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]')
1133
- .replace(/\\b[A-Fa-f0-9]{32,}\\b/g, '[REDACTED_HEX]')
1134
- .replace(/\\b[A-Za-z0-9_-]{40,}\\b/g, '[REDACTED_TOKEN]')
1135
- .slice(0, 200);
1136
- }
1137
-
1138
- try {
1139
- browser = await chromium.launch({ headless: true });
1140
- const page = await browser.newPage();
1141
- page.on('console', msg => {
1142
- if (msg.type() === 'error') {
1143
- const text = sanitizeErrorText(msg.text());
1144
- results.errors.push({ type: 'console', text });
1145
- }
1146
- });
1147
- page.on('pageerror', err => {
1148
- const text = sanitizeErrorText(err && err.message ? err.message : String(err || ''));
1149
- results.errors.push({ type: 'pageerror', text });
1150
- });
1151
-
1152
- if (email && password && loginUrl) {
1153
- await page.goto(loginUrl, { waitUntil: 'networkidle', timeout: 30000 });
1154
- if (allowAuthMutation) {
1155
- await page.fill(emailSelector, email);
1156
- await page.fill(passwordSelector, password);
1157
- await page.click(submitSelector);
1158
- let navigationError = null;
1159
- await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch((err) => {
1160
- navigationError = sanitizeErrorText(err && err.message ? err.message : 'navigation timeout');
1161
- });
1162
- if (navigationError) {
1163
- results.authSignals.navigationTimeout = true;
1164
- results.errors.push({ type: 'navigation', text: navigationError });
1165
- }
1166
- results.authSignals.mutationPerformed = true;
1167
- } else {
1168
- results.authSignals.mutationPerformed = false;
1169
- }
1170
- const authVerificationMaxAttempts = allowAuthMutation ? 3 : 1;
1171
- let verificationAttemptsUsed = 0;
1172
- let urlChanged = false;
1173
- let authCookiePresent = false;
1174
- let loginFormVisible = true;
1175
- for (let verificationAttempt = 1; verificationAttempt <= authVerificationMaxAttempts; verificationAttempt += 1) {
1176
- verificationAttemptsUsed = verificationAttempt;
1177
- const currentUrl = page.url();
1178
- const postCookies = await page.context().cookies();
1179
- urlChanged = didLeaveLoginSurface(currentUrl, loginUrl);
1180
- authCookiePresent = postCookies.some(c => /(?:^|[-_])(session|token|auth|jwt)(?:$|[-_])/i.test(c.name) && (c.httpOnly || c.secure));
1181
- loginFormVisible = await page.evaluate((emailSel, passwordSel) => (
1182
- Boolean(document.querySelector(emailSel) && document.querySelector(passwordSel))
1183
- ), emailSelector, passwordSelector).catch(() => false);
1184
- const navigationSucceeded = results.authSignals.navigationTimeout !== true;
1185
- results.authenticated = navigationSucceeded && !loginFormVisible && urlChanged && authCookiePresent;
1186
- if (results.authenticated) {
1187
- break;
1188
- }
1189
- if (verificationAttempt < authVerificationMaxAttempts) {
1190
- await page.waitForTimeout(400 * verificationAttempt);
1191
- }
1192
- }
1193
- results.authSignals = {
1194
- urlChanged,
1195
- authCookiePresent,
1196
- loginFormVisible,
1197
- authVerificationAttemptsUsed: verificationAttemptsUsed,
1198
- authVerificationMaxAttempts,
1199
- };
1200
- results.authSignals.authVerificationRetried = verificationAttemptsUsed > 1;
1201
- results.authSignals.mutationAllowed = allowAuthMutation;
1202
- results.authSignals.mutationPerformed = allowAuthMutation ? true : false;
1203
- email = '';
1204
- password = '';
1205
- }
1206
-
1207
- const targetResponse = await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 });
1208
-
1209
- const cookies = await page.context().cookies();
1210
- results.cookies = cookies.map(c => ({
1211
- name: c.name, domain: c.domain,
1212
- httpOnly: c.httpOnly, secure: c.secure,
1213
- sameSite: c.sameSite,
1214
- sensitive: /session|token|auth|jwt/i.test(c.name),
1215
- }));
1216
-
1217
- results.domStats = await page.evaluate(() => ({
1218
- title: document.title,
1219
- nodeCount: document.querySelectorAll('*').length,
1220
- formCount: document.querySelectorAll('form').length,
1221
- inputCount: document.querySelectorAll('input').length,
1222
- }));
1223
-
1224
- const response = targetResponse || null;
1225
- const targetLoginFormVisible = await page.evaluate((emailSel, passwordSel) => (
1226
- Boolean(document.querySelector(emailSel) && document.querySelector(passwordSel))
1227
- ), emailSelector, passwordSelector).catch(() => true);
1228
- const targetStatus = response ? response.status() : null;
1229
- const targetStatusOk = typeof targetStatus === 'number' ? targetStatus < 400 : false;
1230
- results.authSignals.targetLoginFormVisible = targetLoginFormVisible;
1231
- results.authSignals.targetStatus = targetStatus;
1232
- results.authSignals.targetStatusOk = targetStatusOk;
1233
- if (results.authenticated) {
1234
- results.authenticated = !targetLoginFormVisible && targetStatusOk;
1235
- }
1236
- if (response) {
1237
- const h = response.headers();
1238
- results.headers = {
1239
- 'content-security-policy': h['content-security-policy'] || null,
1240
- 'x-frame-options': h['x-frame-options'] || null,
1241
- 'strict-transport-security': h['strict-transport-security'] || null,
1242
- 'cache-control': h['cache-control'] || null,
1243
- };
1244
- const normalizedFramePolicy = String(results.headers['x-frame-options'] || '').trim().toLowerCase();
1245
- const headerPolicyBreaches = [];
1246
- if (!results.headers['content-security-policy']) {
1247
- headerPolicyBreaches.push('missing_content_security_policy');
1248
- }
1249
- if (String(targetUrl || '').startsWith('https://') && !results.headers['strict-transport-security']) {
1250
- headerPolicyBreaches.push('missing_strict_transport_security');
1251
- }
1252
- if (!normalizedFramePolicy) {
1253
- headerPolicyBreaches.push('missing_x_frame_options');
1254
- } else if (!(normalizedFramePolicy === 'deny' || normalizedFramePolicy === 'sameorigin')) {
1255
- headerPolicyBreaches.push('weak_x_frame_options');
1256
- }
1257
- results.authSignals.headerPolicyBreaches = headerPolicyBreaches;
1258
- results.authSignals.headerPolicyPassed = headerPolicyBreaches.length === 0;
1259
- results.authSignals.headerPolicyFailed = headerPolicyBreaches.length > 0;
1260
- } else {
1261
- results.authSignals.headerPolicyBreaches = ['target_response_unavailable'];
1262
- results.authSignals.headerPolicyPassed = false;
1263
- results.authSignals.headerPolicyFailed = true;
1264
- }
1265
- } catch (err) {
1266
- results.executionFailed = true;
1267
- const text = sanitizeErrorText('Playwright error: ' + (err && err.message ? err.message : ''));
1268
- results.errors.push({ type: 'playwright', text });
1269
- } finally {
1270
- try { console.log(JSON.stringify(results)); } catch {}
1271
- if (browser) {
1272
- await browser.close().catch(() => {});
1273
- }
1274
- if (results.executionFailed) {
1275
- process.exitCode = 1;
1276
- }
1277
- }
1278
- })();
1279
- `;
1280
-
1281
- const MAX_AUTH_REDIRECT_HOPS = 5;
1282
- const AUTH_FLOW_FETCH_TIMEOUT_MS = 10_000;
1283
- const AUTH_FLOW_FETCH_MAX_RETRIES = 2;
1284
- const AUTH_FLOW_FETCH_BASE_BACKOFF_MS = 200;
1285
- const RETRYABLE_AUTH_FLOW_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
1286
- const RETRYABLE_AUTH_FLOW_ERROR_CODES = new Set([
1287
- "ECONNRESET",
1288
- "EAI_AGAIN",
1289
- "ENOTFOUND",
1290
- "ECONNREFUSED",
1291
- "ETIMEDOUT",
1292
- "ECONNABORTED",
1293
- "UND_ERR_CONNECT_TIMEOUT",
1294
- "UND_ERR_HEADERS_TIMEOUT",
1295
- "UND_ERR_BODY_TIMEOUT",
1296
- ]);
1297
- const RETRYABLE_AUTH_FLOW_MESSAGE_PATTERNS = [
1298
- /\bfetch failed\b/i,
1299
- /\bnetwork(?:\s+|-)error\b/i,
1300
- /\bsocket hang up\b/i,
1301
- /\btimed?\s*out\b/i,
1302
- /\b(?:econnreset|eai_again|enotfound|econnrefused|etimedout)\b/i,
1303
- /\btemporary(?:\s+|-)failure\b/i,
1304
- /\bconnection\b.*\b(?:reset|terminated|closed)\b/i,
1305
- ];
1306
- const AUTH_FLOW_LOCAL_TEST_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
1307
- const DEFAULT_APPROVED_AUTH_AUDIT_HOSTS = new Set(["example.com", "www.example.com"]);
1308
- const AUTH_AUDIT_ALLOWED_HOSTS_ENV = "SENTINELAYER_AUTH_AUDIT_ALLOWED_HOSTS";
1309
-
1310
- function computePlaywrightBackoffMs(attempt, baseBackoffMs = AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS) {
1311
- const cappedBase = Math.max(1, Number.isFinite(baseBackoffMs) ? Math.trunc(baseBackoffMs) : AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS);
1312
- const exponential = Math.min(4000, cappedBase * Math.pow(2, Math.max(0, attempt)));
1313
- const deterministicJitter = ((Math.max(0, attempt) * 1103515245 + 12345) % 1000) / 1000;
1314
- const jitterFactor = 0.5 + (deterministicJitter * 0.5);
1315
- return Math.max(1, Math.trunc(exponential * jitterFactor));
1316
- }
1317
-
1318
- function isRetryablePlaywrightExecutionError(error) {
1319
- if (!(error instanceof Error)) {
1320
- return false;
1321
- }
1322
- if (error.name === "AbortError" || error.name === "TimeoutError") {
1323
- return true;
1324
- }
1325
- const code = String(error.code || "").toUpperCase();
1326
- if (RETRYABLE_PLAYWRIGHT_EXEC_ERROR_CODES.has(code)) {
1327
- return true;
1328
- }
1329
- if (error.killed === true && (error.signal === "SIGTERM" || error.signal === "SIGKILL")) {
1330
- return true;
1331
- }
1332
- const causeCode = String(error.cause?.code || error.cause?.errno || "").toUpperCase();
1333
- return RETRYABLE_PLAYWRIGHT_EXEC_ERROR_CODES.has(causeCode);
1334
- }
1335
-
1336
- function normalizeAuthAuditErrorMessage(error, fallbackMessage) {
1337
- if (error instanceof Error && error.message) {
1338
- return error.message;
1339
- }
1340
- const normalized = String(error || "").trim();
1341
- return normalized || fallbackMessage;
1342
- }
1343
-
1344
- export async function runPlaywrightAuditScriptWithRetry(scriptPath, env, options = {}) {
1345
- const scriptSource = String(options.scriptSource || "");
1346
- const runArgs = scriptSource ? ["-e", scriptSource] : (scriptPath ? [scriptPath] : []);
1347
- if (runArgs.length === 0) {
1348
- throw new AuthAuditError("Playwright auth audit failed: missing script path");
1349
- }
1350
- const stdinPayload = String(options.stdinPayload || "");
1351
- const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
1352
- ? Math.trunc(options.timeoutMs)
1353
- : AUTH_PLAYWRIGHT_EXEC_TIMEOUT_MS;
1354
- const maxRetries = Number.isInteger(options.maxRetries) && options.maxRetries >= 0
1355
- ? options.maxRetries
1356
- : AUTH_PLAYWRIGHT_EXEC_MAX_RETRIES;
1357
- const baseBackoffMs = Number.isFinite(options.baseBackoffMs) && options.baseBackoffMs > 0
1358
- ? Math.trunc(options.baseBackoffMs)
1359
- : AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS;
1360
- const totalBudgetMs = Number.isFinite(options.totalBudgetMs) && options.totalBudgetMs > 0
1361
- ? Math.trunc(options.totalBudgetMs)
1362
- : AUTH_PLAYWRIGHT_EXEC_TOTAL_BUDGET_MS;
1363
- const minAttemptTimeoutMs = Number.isFinite(options.minAttemptTimeoutMs) && options.minAttemptTimeoutMs > 0
1364
- ? Math.trunc(options.minAttemptTimeoutMs)
1365
- : AUTH_PLAYWRIGHT_EXEC_MIN_ATTEMPT_TIMEOUT_MS;
1366
- const execute = typeof options.exec === "function" ? options.exec : execFileSync;
1367
- const now = typeof options.now === "function" ? options.now : Date.now;
1368
- const sleepFn = typeof options.sleep === "function" ? options.sleep : sleep;
1369
- const retryWindowStartedAt = now();
1370
-
1371
- for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
1372
- const elapsedMs = now() - retryWindowStartedAt;
1373
- const remainingBudgetMs = totalBudgetMs - elapsedMs;
1374
- if (remainingBudgetMs <= 0) {
1375
- throw new AuthAuditError(
1376
- `Playwright auth audit failed: retry budget exhausted after ${attempt} attempt(s) over ${totalBudgetMs}ms`
1377
- );
1378
- }
1379
- if (remainingBudgetMs < minAttemptTimeoutMs) {
1380
- throw new AuthAuditError(
1381
- `Playwright auth audit failed: remaining retry budget (${remainingBudgetMs}ms) below minimum attempt timeout (${minAttemptTimeoutMs}ms)`
1382
- );
1383
- }
1384
- const attemptTimeoutMs = Math.max(minAttemptTimeoutMs, Math.min(timeoutMs, remainingBudgetMs));
1385
- try {
1386
- return execute(process.execPath, runArgs, {
1387
- encoding: "utf-8",
1388
- timeout: attemptTimeoutMs,
1389
- stdio: ["pipe", "pipe", "pipe"],
1390
- env,
1391
- input: stdinPayload,
1392
- });
1393
- } catch (error) {
1394
- if (!isRetryablePlaywrightExecutionError(error) || attempt >= maxRetries) {
1395
- const reason = normalizeAuthAuditErrorMessage(error, "Playwright execution failed");
1396
- throw new AuthAuditError(`Playwright auth audit failed after ${attempt + 1} attempt(s): ${reason}`);
1397
- }
1398
- }
1399
- const backoffMs = computePlaywrightBackoffMs(attempt, baseBackoffMs);
1400
- const remainingAfterAttemptMs = totalBudgetMs - (now() - retryWindowStartedAt);
1401
- if (remainingAfterAttemptMs <= 0) {
1402
- throw new AuthAuditError(
1403
- `Playwright auth audit failed: retry budget exhausted after ${attempt + 1} attempt(s) over ${totalBudgetMs}ms`
1404
- );
1405
- }
1406
- await sleepFn(Math.min(backoffMs, remainingAfterAttemptMs));
1407
- }
1408
-
1409
- throw new AuthAuditError("Playwright auth audit failed after retry budget was exhausted");
1410
- }
1411
-
1412
- function computeAuthFlowBackoffMs(attempt) {
1413
- const computed = AUTH_FLOW_FETCH_BASE_BACKOFF_MS * Math.pow(2, Math.max(0, attempt));
1414
- return Math.min(1000, computed);
1415
- }
1416
-
1417
- function resolveAuthFlowErrorCode(error) {
1418
- if (!(error instanceof Error)) {
1419
- return "";
1420
- }
1421
- const directCode = String(error.code || "").toUpperCase();
1422
- if (directCode) {
1423
- return directCode;
1424
- }
1425
- const cause = error.cause;
1426
- if (!cause || typeof cause !== "object") {
1427
- return "";
1428
- }
1429
- return String(cause.code || cause.errno || "").toUpperCase();
1430
- }
1431
-
1432
- function isRetryableAuthFlowError(error) {
1433
- if (!(error instanceof Error)) {
1434
- return false;
1435
- }
1436
- if (error.name === "AbortError" || error.name === "TimeoutError") {
1437
- return true;
1438
- }
1439
- const code = resolveAuthFlowErrorCode(error);
1440
- if (RETRYABLE_AUTH_FLOW_ERROR_CODES.has(code)) {
1441
- return true;
1442
- }
1443
- const normalized = `${error.name} ${error.message || ""}`.toLowerCase();
1444
- if (error.name === "TypeError") {
1445
- return RETRYABLE_AUTH_FLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
1446
- }
1447
- return RETRYABLE_AUTH_FLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
1448
- }
1449
-
1450
- function isAllowedHttpAuthFlowTarget(urlObject) {
1451
- if (urlObject.protocol !== "http:") {
1452
- return true;
1453
- }
1454
- if (process.env.NODE_ENV !== "test") {
1455
- return false;
1456
- }
1457
- return AUTH_FLOW_LOCAL_TEST_HOSTS.has(urlObject.hostname);
1458
- }
1459
-
1460
- function isUnapprovedAuthAuditBypassEnabled() {
1461
- if (process.env.NODE_ENV === "test") {
1462
- return true;
1463
- }
1464
- if (process.env.SENTINELAYER_ALLOW_UNAPPROVED_AUTH_AUDIT_TARGETS === "1") {
1465
- return true;
1466
- }
1467
- return false;
1468
- }
1469
-
1470
- function normalizeHostEntry(value) {
1471
- return String(value || "").trim().toLowerCase();
1472
- }
1473
-
1474
- function resolveApprovedAuthAuditHosts(input) {
1475
- const approvedHosts = new Set(DEFAULT_APPROVED_AUTH_AUDIT_HOSTS);
1476
- const hostLists = [];
1477
- if (Array.isArray(input?.approvedHosts)) {
1478
- hostLists.push(input.approvedHosts);
1479
- }
1480
- if (Array.isArray(input?.approvedHostnames)) {
1481
- hostLists.push(input.approvedHostnames);
1482
- }
1483
- const envHosts = String(process.env[AUTH_AUDIT_ALLOWED_HOSTS_ENV] || "")
1484
- .split(",")
1485
- .map((entry) => normalizeHostEntry(entry))
1486
- .filter(Boolean);
1487
- hostLists.push(envHosts);
1488
- for (const list of hostLists) {
1489
- for (const host of list) {
1490
- const normalized = normalizeHostEntry(host);
1491
- if (normalized) {
1492
- approvedHosts.add(normalized);
1493
- }
1494
- }
1495
- }
1496
- return approvedHosts;
1497
- }
1498
-
1499
- function assertApprovedAuthAuditTarget(parsed, input, operation) {
1500
- if (isUnapprovedAuthAuditBypassEnabled()) {
1501
- return parsed;
1502
- }
1503
- const allowLiveProvision = input?.allowProvisioning === true || process.env.SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION === "1";
1504
- const approvedTargetId = String(input?.approvedTargetId || "").trim();
1505
- if (!allowLiveProvision || !approvedTargetId) {
1506
- throw new AuthAuditError(
1507
- `Live ${operation} requires allowProvisioning=true and approvedTargetId to prevent unapproved outbound probing.`
1508
- );
1509
- }
1510
- const approvedHosts = resolveApprovedAuthAuditHosts(input);
1511
- const normalizedHost = normalizeHostEntry(parsed.hostname);
1512
- if (!approvedHosts.has(normalizedHost)) {
1513
- throw new AuthAuditError(
1514
- `Blocked unapproved auth audit host for ${operation}: ${normalizedHost}. ` +
1515
- `Add host to approvedHosts or ${AUTH_AUDIT_ALLOWED_HOSTS_ENV}.`
1516
- );
1517
- }
1518
- return parsed;
1519
- }
1520
-
1521
- function assertSecureAuthFlowTarget(urlValue, options = {}) {
1522
- let parsed;
1523
- try {
1524
- parsed = assertPermittedAuditTarget(urlValue, {
1525
- operation: "check_auth_flow_security",
1526
- allowPrivateTargets: options.allowPrivateTargets === true,
1527
- });
1528
- } catch (error) {
1529
- throw new AuthAuditError(error.message);
1530
- }
1531
- assertApprovedAuthAuditTarget(parsed, options.auditInput || {}, "check_auth_flow_security");
1532
- if (!isAllowedHttpAuthFlowTarget(parsed)) {
1533
- throw new AuthAuditError(
1534
- `HTTPS downgrade detected in auth flow target: ${parsed.toString()}`
1535
- );
1536
- }
1537
- return parsed;
1538
- }
1539
-
1540
- async function fetchWithTimeout(url, options, timeoutMs) {
1541
- const callerSignal = isAbortSignalLike(options?.signal) ? options.signal : undefined;
1542
- const controller = new AbortController();
1543
- const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
1544
- const { signal: compositeSignal, cleanup: cleanupCompositeSignal } = composeAbortSignals(callerSignal, controller.signal);
1545
- try {
1546
- return await fetch(url, {
1547
- ...options,
1548
- ...(compositeSignal ? { signal: compositeSignal } : {}),
1549
- });
1550
- } finally {
1551
- clearTimeout(timeoutHandle);
1552
- cleanupCompositeSignal();
1553
- }
1554
- }
1555
-
1556
- async function fetchLoginResponseWithRetry(currentUrl) {
1557
- for (let attempt = 0; attempt <= AUTH_FLOW_FETCH_MAX_RETRIES; attempt += 1) {
1558
- try {
1559
- const response = await fetchWithTimeout(currentUrl, {
1560
- method: "GET",
1561
- redirect: "manual",
1562
- }, AUTH_FLOW_FETCH_TIMEOUT_MS);
1563
- if (!RETRYABLE_AUTH_FLOW_STATUS_CODES.has(response.status)) {
1564
- return response;
1565
- }
1566
- if (attempt >= AUTH_FLOW_FETCH_MAX_RETRIES) {
1567
- throw new AuthAuditError(
1568
- `Auth flow header fetch failed after ${attempt + 1} attempt(s): HTTP ${response.status}`
1569
- );
1570
- }
1571
- } catch (error) {
1572
- if (error instanceof AuthAuditError) {
1573
- throw error;
1574
- }
1575
- if (!isRetryableAuthFlowError(error) || attempt >= AUTH_FLOW_FETCH_MAX_RETRIES) {
1576
- const message = error instanceof Error ? error.message : String(error || "request failed");
1577
- throw new AuthAuditError(`Auth flow header fetch failed after ${attempt + 1} attempt(s): ${message}`);
1578
- }
1579
- }
1580
- await sleep(computeAuthFlowBackoffMs(attempt));
1581
- }
1582
- throw new AuthAuditError("Auth flow header fetch failed after retry budget was exhausted");
1583
- }
1584
-
1585
- async function checkAuthFlowSecurity(input) {
1586
- const requestId = String(input.requestId || createAuditRequestId());
1587
- const loginUrlCandidate = input.loginUrl || input.url;
1588
- if (!loginUrlCandidate) throw new AuthAuditError("check_auth_flow_security requires loginUrl or url");
1589
- const allowPrivateTargets = input.allowPrivateTargets === true;
1590
- const loginUrl = assertSecureAuthFlowTarget(loginUrlCandidate, { allowPrivateTargets, auditInput: input }).toString();
1591
-
1592
- const findings = [];
1593
- try {
1594
- const { headers, finalUrl, crossOriginRedirect } = await fetchLoginHeaders(loginUrl, { allowPrivateTargets, auditInput: input });
1595
-
1596
- if (crossOriginRedirect) {
1597
- findings.push({
1598
- severity: "P1",
1599
- title: "Login flow redirects cross-origin before header checks",
1600
- file: loginUrl,
1601
- });
1602
- }
1603
-
1604
- if (!headers["strict-transport-security"]) {
1605
- findings.push({ severity: "P1", title: "Login page missing HSTS header", file: finalUrl || loginUrl });
1606
- }
1607
- if (!headers["content-security-policy"]) {
1608
- findings.push({ severity: "P2", title: "Login page missing CSP header", file: finalUrl || loginUrl });
1609
- }
1610
- if (headers["x-powered-by"]) {
1611
- findings.push({
1612
- severity: "P2",
1613
- title: "Login page exposes X-Powered-By: " + headers["x-powered-by"],
1614
- file: finalUrl || loginUrl,
1615
- });
1616
- }
1617
- } catch (err) {
1618
- if (err instanceof AuthAuditError && /HTTPS downgrade detected/.test(err.message)) {
1619
- findings.push({
1620
- severity: "P1",
1621
- title: err.message,
1622
- file: loginUrl,
1623
- });
1624
- }
1625
- return {
1626
- ...buildUnavailableAuditResponse(
1627
- requestId,
1628
- "AUTH_FLOW_CHECK_FAILED",
1629
- "auth flow check failed: " + normalizeErrorMessage(err, "unknown error")
1630
- ),
1631
- loginUrl,
1632
- findings,
1633
- };
1634
- }
1635
- return { available: true, requestId, loginUrl, findings };
1636
- }
1637
-
1638
- async function fetchLoginHeaders(loginUrl, options = {}) {
1639
- let currentUrl = loginUrl;
1640
- const visitedUrls = new Set();
1641
- let redirectCount = 0;
1642
-
1643
- while (true) {
1644
- if (redirectCount > MAX_AUTH_REDIRECT_HOPS) {
1645
- throw new AuthAuditError(
1646
- `Exceeded ${MAX_AUTH_REDIRECT_HOPS} redirects while checking auth flow (last=${currentUrl})`
1647
- );
1648
- }
1649
- const currentParsedUrl = assertSecureAuthFlowTarget(currentUrl, options);
1650
- if (visitedUrls.has(currentUrl)) {
1651
- throw new AuthAuditError("Redirect loop detected while checking auth headers");
1652
- }
1653
- visitedUrls.add(currentUrl);
1654
-
1655
- const response = await fetchLoginResponseWithRetry(currentUrl);
1656
- const headers = Object.fromEntries(response.headers.entries());
1657
-
1658
- if (response.status >= 300 && response.status < 400) {
1659
- const location = response.headers.get("location");
1660
- if (!location) {
1661
- return { headers, finalUrl: currentUrl, crossOriginRedirect: false };
1662
- }
1663
- const nextParsedUrl = assertSecureAuthFlowTarget(new URL(location, currentParsedUrl).toString(), options);
1664
- if (nextParsedUrl.origin !== currentParsedUrl.origin) {
1665
- return { headers, finalUrl: currentUrl, crossOriginRedirect: true };
1666
- }
1667
- currentUrl = nextParsedUrl.toString();
1668
- redirectCount += 1;
1669
- continue;
1670
- }
1671
-
1672
- return { headers, finalUrl: currentUrl, crossOriginRedirect: false };
1673
- }
1674
- }
1675
-
1676
- function resolveAuthAuditTarget(urlValue, input, operation) {
1677
- try {
1678
- const parsed = assertPermittedAuditTarget(urlValue, {
1679
- operation,
1680
- allowPrivateTargets: input.allowPrivateTargets === true,
1681
- });
1682
- assertApprovedAuthAuditTarget(parsed, input, operation);
1683
- return parsed.toString();
1684
- } catch (error) {
1685
- throw new AuthAuditError(error.message);
1686
- }
1687
- }
1688
-
1689
- export class AuthAuditError extends Error {
1690
- constructor(message) { super(message); this.name = "AuthAuditError"; }
1691
- }
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
5
+ import { setTimeout as sleep } from "node:timers/promises";
6
+ import { assertPermittedAuditTarget } from "./url-policy.js";
7
+
8
+ /**
9
+ * Jules Tanaka — Authenticated Page Audit
10
+ *
11
+ * Provisions an AIdenID ephemeral identity, uses Playwright to log in,
12
+ * then inspects authenticated pages (DevTools console, DOM, headers).
13
+ * Falls back gracefully when AIdenID or Playwright unavailable.
14
+ */
15
+
16
+ export async function authAudit(input = {}) {
17
+ const operation = String(input.operation || "").trim();
18
+ const requestId = createAuditRequestId();
19
+ if (!AUTH_OPS.has(operation)) {
20
+ const message = "Unknown operation: " + (operation || "<empty>") + ". Valid: " + [...AUTH_OPS].join(", ");
21
+ return finalizeAuditEnvelope(operation || "unknown", requestId, buildUnavailableAuditResponse(
22
+ requestId,
23
+ "AUTH_AUDIT_UNKNOWN_OPERATION",
24
+ message
25
+ ));
26
+ }
27
+ try {
28
+ const result = await AUTH_DISPATCH[operation]({ ...input, requestId, operation });
29
+ return finalizeAuditEnvelope(operation, requestId, result);
30
+ } catch (error) {
31
+ const code = error instanceof AuthAuditError ? "AUTH_AUDIT_VALIDATION_FAILED" : "AUTH_AUDIT_EXECUTION_FAILED";
32
+ const message = normalizeErrorMessage(error, "Auth audit failed");
33
+ const diagnostics = extractErrorDiagnostics(error, operation || "auth_audit");
34
+ return finalizeAuditEnvelope(operation, requestId, buildUnavailableAuditResponse(requestId, code, message, diagnostics));
35
+ }
36
+ }
37
+
38
+ const AUTH_OPS = new Set([
39
+ "provision_test_identity",
40
+ "authenticated_page_check",
41
+ "check_auth_flow_security",
42
+ ]);
43
+
44
+ const AUTH_DISPATCH = {
45
+ provision_test_identity: provisionTestIdentity,
46
+ authenticated_page_check: authenticatedPageCheck,
47
+ check_auth_flow_security: checkAuthFlowSecurity,
48
+ };
49
+
50
+ const AUTH_PLAYWRIGHT_EXEC_TIMEOUT_MS = 60_000;
51
+ const AUTH_PLAYWRIGHT_EXEC_MAX_RETRIES = 3;
52
+ const AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS = 300;
53
+ const AUTH_PLAYWRIGHT_EXEC_TOTAL_BUDGET_MS = 180_000;
54
+ const AUTH_PLAYWRIGHT_EXEC_MIN_ATTEMPT_TIMEOUT_MS = 2_000;
55
+ const AUTH_AIDENID_PROVISION_TIMEOUT_MS = 12_000;
56
+ const AUTH_AIDENID_PROVISION_MAX_RETRIES = 2;
57
+ const AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS = 300;
58
+ const AUTH_AIDENID_PROVISION_TOTAL_BUDGET_MS =
59
+ (AUTH_AIDENID_PROVISION_TIMEOUT_MS * (AUTH_AIDENID_PROVISION_MAX_RETRIES + 1))
60
+ + (AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS * AUTH_AIDENID_PROVISION_MAX_RETRIES * 2);
61
+ const AUTH_AIDENID_PROVISION_MIN_ATTEMPT_TIMEOUT_MS = 1_500;
62
+ const AUTH_AUDIT_PROVIDER_BREAKER_FAILURE_THRESHOLD = 3;
63
+ const AUTH_AUDIT_PROVIDER_BREAKER_WINDOW_MS = 5 * 60 * 1000;
64
+ const AUTH_AUDIT_PROVIDER_BREAKER_COOLDOWN_MS = 2 * 60 * 1000;
65
+ const AUTH_AUDIT_PROVIDER_BREAKER_ENTRY_TTL_MS = 15 * 60 * 1000;
66
+ const AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT = "default";
67
+ const AUTH_AUDIT_PROVIDER_BREAKERS = new Map();
68
+ const AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_ENV = "SENTINELAYER_AUTH_AUDIT_BREAKER_STATE_FILE";
69
+ // Default to persisting in the user's sentinelayer state dir. Prior default
70
+ // (empty string) disabled persistence, letting provider failures recur
71
+ // across CLI invocations. Opt-out via SENTINELAYER_AUTH_AUDIT_BREAKER_
72
+ // STATE_FILE=off if needed.
73
+ const AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_DEFAULT = ".sentinelayer/auth-audit-breaker.json";
74
+ const AUTH_AUDIT_PROVIDER_AIDENID = "aidenid";
75
+ const AUTH_AUDIT_PROVIDER_PLAYWRIGHT_TARGET = "playwright-target";
76
+ const AUTH_MUTATION_ALLOWED_ENV = "SENTINELAYER_ALLOW_AUTH_MUTATION";
77
+ const AUTH_AUDIT_ENVELOPE_ENV = "SENTINELAYER_AUTH_AUDIT_ENVELOPE";
78
+ const AUTH_AUDIT_ENVELOPE_VERSION = "v2";
79
+ const RETRYABLE_PLAYWRIGHT_EXEC_ERROR_CODES = new Set([
80
+ "ETIMEDOUT",
81
+ "ECONNRESET",
82
+ "EPIPE",
83
+ "EAI_AGAIN",
84
+ "ECONNABORTED",
85
+ "UND_ERR_CONNECT_TIMEOUT",
86
+ "UND_ERR_HEADERS_TIMEOUT",
87
+ ]);
88
+ const RETRYABLE_AIDENID_PROVISION_ERROR_CODES = new Set([
89
+ "ETIMEDOUT",
90
+ "ECONNRESET",
91
+ "ECONNREFUSED",
92
+ "EAI_AGAIN",
93
+ "ENOTFOUND",
94
+ "ECONNABORTED",
95
+ "AIDENID_ATTEMPT_TIMEOUT",
96
+ "UND_ERR_CONNECT_TIMEOUT",
97
+ "UND_ERR_HEADERS_TIMEOUT",
98
+ "UND_ERR_BODY_TIMEOUT",
99
+ ]);
100
+ const RETRYABLE_AIDENID_PROVISION_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
101
+ const RETRYABLE_AIDENID_PROVISION_MESSAGE_PATTERNS = [
102
+ /\bfetch failed\b/i,
103
+ /\bnetwork(?:\s+|-)error\b/i,
104
+ /\btimed?\s*out\b/i,
105
+ /\b(?:econnreset|econnrefused|eai_again|enotfound|etimedout)\b/i,
106
+ /\bconnection\b.*\b(?:reset|closed|terminated)\b/i,
107
+ ];
108
+
109
+ let AUTH_AUDIT_PROVIDER_BREAKERS_HYDRATED = false;
110
+ const AUTH_AUDIT_JITTER_SECRET = randomBytes(16).toString("hex");
111
+ let AUTH_AUDIT_JITTER_COUNTER = 0;
112
+
113
+ function createAuditRequestId() {
114
+ try {
115
+ return randomUUID();
116
+ } catch {
117
+ const ts = Date.now().toString(36);
118
+ const rand = randomBytes(16).toString("hex");
119
+ return `authaudit-${ts}-${rand}`;
120
+ }
121
+ }
122
+
123
+ function normalizeErrorMessage(error, fallback) {
124
+ const fallbackMessage = String(fallback || "Auth audit failed");
125
+ if (error instanceof Error && error.message) {
126
+ return sanitizeAuditErrorMessage(error.message, fallbackMessage);
127
+ }
128
+ const normalized = String(error || "").trim();
129
+ return sanitizeAuditErrorMessage(normalized || fallbackMessage, fallbackMessage);
130
+ }
131
+
132
+ function sanitizeAuditErrorMessage(message, fallback = "Auth audit failed") {
133
+ const fallbackMessage = String(fallback || "Auth audit failed");
134
+ const normalized = String(message || "").trim();
135
+ const candidate = normalized || fallbackMessage;
136
+ const sanitized = candidate
137
+ .replace(/\bbearer\s+[a-z0-9._~+/=-]+\b/gi, "bearer [REDACTED]")
138
+ .replace(/\b(token|secret|password|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token)\b\s*[:=]\s*["']?[^"'\s,;]+["']?/gi, "$1=[REDACTED]")
139
+ .replace(/\b[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+\b/gi, "[REDACTED_JWT]")
140
+ .replace(/\bhttps?:\/\/[^\s"'`]+/gi, (rawUrl) => sanitizeDiagnosticUrl(rawUrl))
141
+ .replace(/\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b/gi, "<redacted-email>");
142
+ if (sanitized.length <= 512) {
143
+ return sanitized;
144
+ }
145
+ return `${sanitized.slice(0, 509)}...`;
146
+ }
147
+
148
+ function sanitizeDiagnosticUrl(rawUrl) {
149
+ const candidate = String(rawUrl || "").trim();
150
+ if (!candidate) {
151
+ return "<redacted-url>";
152
+ }
153
+ try {
154
+ const parsed = new URL(candidate);
155
+ if (!parsed.hostname) {
156
+ return "<redacted-url>";
157
+ }
158
+ return `${parsed.protocol}//${parsed.host}/<redacted-path>`;
159
+ } catch {
160
+ return "<redacted-url>";
161
+ }
162
+ }
163
+
164
+ function buildUnavailableAuditResponse(requestId, code, message, options = {}) {
165
+ const safeMessage = sanitizeAuditErrorMessage(message, "Auth audit unavailable");
166
+ const errorPayload = {
167
+ code,
168
+ message: safeMessage,
169
+ requestId,
170
+ retryable: options.retryable === true,
171
+ };
172
+ const phase = String(options.phase || "").trim();
173
+ if (phase) {
174
+ errorPayload.phase = phase;
175
+ }
176
+ const parsedStatusCode = Number.parseInt(String(options.statusCode || ""), 10);
177
+ if (Number.isInteger(parsedStatusCode) && parsedStatusCode > 0) {
178
+ errorPayload.statusCode = parsedStatusCode;
179
+ }
180
+ const errorCode = String(options.errorCode || "").trim();
181
+ if (errorCode) {
182
+ errorPayload.errorCode = errorCode.toUpperCase();
183
+ }
184
+ if (options.retryTelemetry && typeof options.retryTelemetry === "object") {
185
+ errorPayload.retryTelemetry = options.retryTelemetry;
186
+ }
187
+ if (options.providerBreaker && typeof options.providerBreaker === "object") {
188
+ errorPayload.providerBreaker = options.providerBreaker;
189
+ }
190
+ return {
191
+ available: false,
192
+ requestId,
193
+ reason: safeMessage,
194
+ error: errorPayload,
195
+ };
196
+ }
197
+
198
+ function extractErrorDiagnostics(error, phase = "auth_audit") {
199
+ const diagnostics = {
200
+ phase: String(phase || "auth_audit").trim().slice(0, 64) || "auth_audit",
201
+ };
202
+ const statusCode = resolveAidenidProvisionStatusCode(error);
203
+ if (Number.isInteger(statusCode) && statusCode > 0) {
204
+ diagnostics.statusCode = statusCode;
205
+ }
206
+ let errorCode = resolveAidenidProvisionErrorCode(error);
207
+ if (!errorCode && error instanceof Error) {
208
+ errorCode = String(error.name || "").trim().toUpperCase();
209
+ }
210
+ if (errorCode) {
211
+ diagnostics.errorCode = errorCode.toUpperCase();
212
+ }
213
+ return diagnostics;
214
+ }
215
+
216
+ function isAuditEnvelopeV2Enabled() {
217
+ const normalized = String(process.env[AUTH_AUDIT_ENVELOPE_ENV] || "").trim().toLowerCase();
218
+ return normalized === "" || normalized === "true" || normalized === "1" || normalized === AUTH_AUDIT_ENVELOPE_VERSION;
219
+ }
220
+
221
+ function buildAuditDataEnvelope(payload) {
222
+ const data = { ...(payload && typeof payload === "object" ? payload : {}) };
223
+ delete data.ok;
224
+ delete data.operation;
225
+ delete data.envelope;
226
+ delete data.data;
227
+ return data;
228
+ }
229
+
230
+ function finalizeAuditEnvelope(operation, requestId, payload) {
231
+ const normalizedPayload = payload && typeof payload === "object" ? { ...payload } : { result: payload };
232
+ const normalizedRequestId = String(normalizedPayload.requestId || requestId || createAuditRequestId());
233
+ normalizedPayload.requestId = normalizedRequestId;
234
+ if (!Object.prototype.hasOwnProperty.call(normalizedPayload, "available")) {
235
+ normalizedPayload.available = !normalizedPayload.error;
236
+ }
237
+ if (!isAuditEnvelopeV2Enabled()) {
238
+ return normalizedPayload;
239
+ }
240
+ normalizedPayload.ok = normalizedPayload.available === true;
241
+ normalizedPayload.operation = String(operation || normalizedPayload.operation || "unknown");
242
+ normalizedPayload.envelope = AUTH_AUDIT_ENVELOPE_VERSION;
243
+ if (normalizedPayload.ok) {
244
+ normalizedPayload.data = buildAuditDataEnvelope(normalizedPayload);
245
+ } else {
246
+ if (!normalizedPayload.error || typeof normalizedPayload.error !== "object") {
247
+ normalizedPayload.error = {
248
+ code: "AUTH_AUDIT_FAILED",
249
+ message: String(normalizedPayload.reason || "Auth audit failed"),
250
+ requestId: normalizedRequestId,
251
+ retryable: false,
252
+ };
253
+ } else if (!normalizedPayload.error.requestId) {
254
+ normalizedPayload.error = {
255
+ ...normalizedPayload.error,
256
+ requestId: normalizedRequestId,
257
+ };
258
+ }
259
+ normalizedPayload.reason = String(
260
+ normalizedPayload.reason
261
+ || normalizedPayload.error.message
262
+ || "Auth audit failed"
263
+ );
264
+ normalizedPayload.data = null;
265
+ }
266
+ return normalizedPayload;
267
+ }
268
+
269
+ function resolveAidenidProvisionStatusCode(error) {
270
+ if (!(error instanceof Error)) {
271
+ return 0;
272
+ }
273
+ const directStatus = Number.parseInt(String(error.statusCode || error.status || ""), 10);
274
+ if (Number.isInteger(directStatus) && directStatus > 0) {
275
+ return directStatus;
276
+ }
277
+ const statusMatch = String(error.message || "").match(/\bstatus\s+(\d{3})\b/i);
278
+ if (!statusMatch) {
279
+ return 0;
280
+ }
281
+ const parsed = Number.parseInt(statusMatch[1], 10);
282
+ return Number.isInteger(parsed) ? parsed : 0;
283
+ }
284
+
285
+ function resolveAidenidProvisionErrorCode(error) {
286
+ if (!(error instanceof Error)) {
287
+ return "";
288
+ }
289
+ const explicitCode = String(error.errorCode || "").toUpperCase();
290
+ if (explicitCode) {
291
+ return explicitCode;
292
+ }
293
+ const directCode = String(error.code || "").toUpperCase();
294
+ if (directCode) {
295
+ return directCode;
296
+ }
297
+ const cause = error.cause;
298
+ if (!cause || typeof cause !== "object") {
299
+ return "";
300
+ }
301
+ return String(cause.code || cause.errno || "").toUpperCase();
302
+ }
303
+
304
+ function classifyAidenidProvisionFailure(error) {
305
+ const classification = {
306
+ retryable: false,
307
+ statusCode: 0,
308
+ errorCode: "",
309
+ };
310
+ if (!(error instanceof Error)) {
311
+ return classification;
312
+ }
313
+ classification.errorCode = resolveAidenidProvisionErrorCode(error);
314
+ classification.statusCode = resolveAidenidProvisionStatusCode(error);
315
+ if (typeof error.retryable === "boolean") {
316
+ classification.retryable = error.retryable;
317
+ return classification;
318
+ }
319
+ if (error.name === "AbortError" || error.name === "TimeoutError") {
320
+ classification.retryable = true;
321
+ return classification;
322
+ }
323
+ if (RETRYABLE_AIDENID_PROVISION_ERROR_CODES.has(classification.errorCode)) {
324
+ classification.retryable = true;
325
+ return classification;
326
+ }
327
+ if (RETRYABLE_AIDENID_PROVISION_STATUS_CODES.has(classification.statusCode)) {
328
+ classification.retryable = true;
329
+ return classification;
330
+ }
331
+ const normalized = `${error.name} ${error.message || ""}`.toLowerCase();
332
+ classification.retryable = RETRYABLE_AIDENID_PROVISION_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
333
+ return classification;
334
+ }
335
+
336
+ function isRetryableAidenidProvisionError(error) {
337
+ return classifyAidenidProvisionFailure(error).retryable;
338
+ }
339
+
340
+ function deriveAidenidBackoffSeed(requestId) {
341
+ const normalizedRequestId = String(requestId || "").trim();
342
+ const counter = AUTH_AUDIT_JITTER_COUNTER++;
343
+ const seedMaterial = `${AUTH_AUDIT_JITTER_SECRET}:${normalizedRequestId || "fallback"}:${counter}`;
344
+ return createHash("sha256").update(seedMaterial).digest().readUInt32BE(0);
345
+ }
346
+
347
+ function computeAidenidProvisionBackoffMs(
348
+ attempt,
349
+ baseBackoffMs = AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS,
350
+ jitterSeed = 0
351
+ ) {
352
+ const cappedBase = Math.max(1, Number.isFinite(baseBackoffMs) ? Math.trunc(baseBackoffMs) : AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS);
353
+ const exponential = Math.min(2500, cappedBase * Math.pow(2, Math.max(0, attempt)));
354
+ const normalizedSeed = Number.isFinite(jitterSeed) ? Math.abs(Math.trunc(jitterSeed)) : 0;
355
+ const deterministicJitter = ((Math.max(0, attempt) * 1664525 + 1013904223 + normalizedSeed) % 1000) / 1000;
356
+ const jitterFactor = 0.5 + (deterministicJitter * 0.5);
357
+ return Math.max(1, Math.trunc(exponential * jitterFactor));
358
+ }
359
+
360
+ function normalizeAidenidTotalBudgetMs(value) {
361
+ if (!Number.isFinite(value) || value <= 0) {
362
+ return AUTH_AIDENID_PROVISION_TOTAL_BUDGET_MS;
363
+ }
364
+ return Math.max(1, Math.trunc(value));
365
+ }
366
+
367
+ function normalizeProviderBreakerScope(scope) {
368
+ const normalized = String(scope || "").trim().toLowerCase();
369
+ if (!normalized) {
370
+ return AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT;
371
+ }
372
+ return normalized.replace(/[^a-z0-9._:/-]/g, "-").replace(/-+/g, "-").slice(0, 120) || AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT;
373
+ }
374
+
375
+ function deriveProviderBreakerScope(options = {}) {
376
+ const explicitScope = normalizeProviderBreakerScope(
377
+ options.contextId || options.scopeId || options.repoScope || ""
378
+ );
379
+ if (explicitScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
380
+ return explicitScope;
381
+ }
382
+ const parts = [];
383
+ const urlCandidate = String(options.targetUrl || options.apiUrl || "").trim();
384
+ if (urlCandidate) {
385
+ try {
386
+ const parsed = new URL(urlCandidate);
387
+ if (parsed.hostname) {
388
+ parts.push(parsed.hostname.toLowerCase());
389
+ }
390
+ } catch {
391
+ // Fall through to non-URL scoped dimensions.
392
+ }
393
+ }
394
+ const orgScope = normalizeProviderBreakerScope(options.orgId || options.organizationId || "");
395
+ if (orgScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
396
+ parts.push(`org-${orgScope}`);
397
+ }
398
+ const projectScope = normalizeProviderBreakerScope(options.projectId || "");
399
+ if (projectScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
400
+ parts.push(`proj-${projectScope}`);
401
+ }
402
+ const repoScope = normalizeProviderBreakerScope(process.env.GITHUB_REPOSITORY || "");
403
+ if (repoScope !== AUTH_AUDIT_PROVIDER_SCOPE_DEFAULT) {
404
+ parts.push(`repo-${repoScope}`);
405
+ }
406
+ if (parts.length === 0) {
407
+ const workspaceFallback = createHash("sha256")
408
+ .update(process.cwd())
409
+ .digest("hex")
410
+ .slice(0, 20);
411
+ parts.push(`ws-${workspaceFallback}`);
412
+ }
413
+ return normalizeProviderBreakerScope(parts.join(":"));
414
+ }
415
+
416
+ function getProviderBreakerKey(provider, scope) {
417
+ return `${provider}:${scope}`;
418
+ }
419
+
420
+ function getProviderBreakerStatePath() {
421
+ const configuredPath = String(
422
+ process.env[AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_ENV] || AUTH_AUDIT_PROVIDER_BREAKER_STATE_FILE_DEFAULT
423
+ ).trim();
424
+ if (!configuredPath || configuredPath.toLowerCase() === "off" || configuredPath.toLowerCase() === "false") {
425
+ return "";
426
+ }
427
+ const repoScope = String(process.env.GITHUB_REPOSITORY || "local").trim() || "local";
428
+ const runScope = String(process.env.GITHUB_RUN_ID || process.pid || "0").trim();
429
+ const scopeSuffix = createHash("sha256")
430
+ .update(`${repoScope}:${runScope}`)
431
+ .digest("hex")
432
+ .slice(0, 12);
433
+ const resolvedPath = path.isAbsolute(configuredPath)
434
+ ? configuredPath
435
+ : path.resolve(process.cwd(), configuredPath);
436
+ const ext = path.extname(resolvedPath) || ".json";
437
+ const base = resolvedPath.endsWith(ext) ? resolvedPath.slice(0, -ext.length) : resolvedPath;
438
+ return `${base}.${scopeSuffix}${ext}`;
439
+ }
440
+
441
+ function hydrateProviderBreakerState() {
442
+ if (AUTH_AUDIT_PROVIDER_BREAKERS_HYDRATED) {
443
+ return;
444
+ }
445
+ AUTH_AUDIT_PROVIDER_BREAKERS_HYDRATED = true;
446
+ const statePath = getProviderBreakerStatePath();
447
+ if (!statePath || !fs.existsSync(statePath)) {
448
+ return;
449
+ }
450
+ const nowMs = Date.now();
451
+ try {
452
+ const raw = fs.readFileSync(statePath, "utf-8");
453
+ const parsed = JSON.parse(raw);
454
+ const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
455
+ for (const entry of entries) {
456
+ const lastUpdatedAtMs = Number.isFinite(entry?.lastUpdatedAtMs)
457
+ ? Math.max(0, Math.trunc(entry.lastUpdatedAtMs))
458
+ : Number.isFinite(entry?.windowStartedAt)
459
+ ? Math.max(0, Math.trunc(entry.windowStartedAt))
460
+ : 0;
461
+ if (lastUpdatedAtMs > 0 && (nowMs - lastUpdatedAtMs) > AUTH_AUDIT_PROVIDER_BREAKER_ENTRY_TTL_MS) {
462
+ continue;
463
+ }
464
+ const provider = String(entry?.provider || "").trim().toLowerCase();
465
+ if (!provider) {
466
+ continue;
467
+ }
468
+ const scope = normalizeProviderBreakerScope(entry?.scope);
469
+ const key = getProviderBreakerKey(provider, scope);
470
+ AUTH_AUDIT_PROVIDER_BREAKERS.set(key, {
471
+ key,
472
+ provider,
473
+ scope,
474
+ consecutiveFailures: Number.isFinite(entry?.consecutiveFailures) ? Math.max(0, Math.trunc(entry.consecutiveFailures)) : 0,
475
+ windowStartedAt: Number.isFinite(entry?.windowStartedAt) ? Math.max(0, Math.trunc(entry.windowStartedAt)) : 0,
476
+ openUntilMs: Number.isFinite(entry?.openUntilMs) ? Math.max(0, Math.trunc(entry.openUntilMs)) : 0,
477
+ lastFailureCode: String(entry?.lastFailureCode || "").trim().toUpperCase(),
478
+ lastUpdatedAtMs,
479
+ });
480
+ }
481
+ } catch {
482
+ // Fall back to in-memory behavior if persisted state cannot be loaded.
483
+ }
484
+ }
485
+
486
+ function persistProviderBreakerState(nowMs = Date.now()) {
487
+ const statePath = getProviderBreakerStatePath();
488
+ if (!statePath) {
489
+ return;
490
+ }
491
+ try {
492
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
493
+ const payload = {
494
+ version: 1,
495
+ updatedAtMs: nowMs,
496
+ entries: [...AUTH_AUDIT_PROVIDER_BREAKERS.values()].map((state) => ({
497
+ provider: state.provider,
498
+ scope: state.scope,
499
+ consecutiveFailures: state.consecutiveFailures,
500
+ windowStartedAt: state.windowStartedAt,
501
+ openUntilMs: state.openUntilMs,
502
+ lastFailureCode: state.lastFailureCode,
503
+ lastUpdatedAtMs: state.lastUpdatedAtMs || nowMs,
504
+ })),
505
+ };
506
+ const tmpPath = `${statePath}.${process.pid}.tmp`;
507
+ // Write with 0600 so the breaker state file (which can log provider
508
+ // identifiers and failure modes) is not world-readable on multi-user
509
+ // machines. renameSync preserves mode.
510
+ fs.writeFileSync(tmpPath, JSON.stringify(payload), { encoding: "utf-8", mode: 0o600 });
511
+ try {
512
+ fs.chmodSync(tmpPath, 0o600);
513
+ } catch {
514
+ // Windows FAT volumes may not honor chmod; mode from writeFileSync
515
+ // still applies on POSIX.
516
+ }
517
+ fs.renameSync(tmpPath, statePath);
518
+ } catch {
519
+ // Persistence is best-effort; in-memory safeguards remain active.
520
+ }
521
+ }
522
+
523
+ function sweepProviderBreakers(nowMs = Date.now()) {
524
+ let mutated = false;
525
+ for (const [breakerKey, state] of AUTH_AUDIT_PROVIDER_BREAKERS.entries()) {
526
+ const isOpen = Number(state.openUntilMs || 0) > nowMs;
527
+ const lastUpdatedAtMs = Number(state.lastUpdatedAtMs || state.windowStartedAt || 0);
528
+ if (isOpen) {
529
+ continue;
530
+ }
531
+ if (lastUpdatedAtMs > 0 && (nowMs - lastUpdatedAtMs) > AUTH_AUDIT_PROVIDER_BREAKER_ENTRY_TTL_MS) {
532
+ AUTH_AUDIT_PROVIDER_BREAKERS.delete(breakerKey);
533
+ mutated = true;
534
+ }
535
+ }
536
+ if (mutated) {
537
+ persistProviderBreakerState(nowMs);
538
+ }
539
+ }
540
+
541
+ function getProviderBreakerState(provider, scope) {
542
+ hydrateProviderBreakerState();
543
+ const normalizedProvider = String(provider || "").trim().toLowerCase();
544
+ if (!normalizedProvider) {
545
+ return null;
546
+ }
547
+ const normalizedScope = normalizeProviderBreakerScope(scope);
548
+ const breakerKey = getProviderBreakerKey(normalizedProvider, normalizedScope);
549
+ const nowMs = Date.now();
550
+ sweepProviderBreakers(nowMs);
551
+ if (!AUTH_AUDIT_PROVIDER_BREAKERS.has(breakerKey)) {
552
+ AUTH_AUDIT_PROVIDER_BREAKERS.set(breakerKey, {
553
+ key: breakerKey,
554
+ provider: normalizedProvider,
555
+ scope: normalizedScope,
556
+ consecutiveFailures: 0,
557
+ windowStartedAt: 0,
558
+ openUntilMs: 0,
559
+ lastFailureCode: "",
560
+ lastUpdatedAtMs: nowMs,
561
+ });
562
+ persistProviderBreakerState(nowMs);
563
+ }
564
+ return AUTH_AUDIT_PROVIDER_BREAKERS.get(breakerKey);
565
+ }
566
+
567
+ function getProviderBreakerSnapshot(provider, scope, nowMs = Date.now()) {
568
+ const state = getProviderBreakerState(provider, scope);
569
+ if (!state) {
570
+ return null;
571
+ }
572
+ const remainingCooldownMs = state.openUntilMs > nowMs ? state.openUntilMs - nowMs : 0;
573
+ return {
574
+ key: state.key,
575
+ provider: state.provider,
576
+ scope: state.scope,
577
+ consecutiveFailures: state.consecutiveFailures,
578
+ windowStartedAt: state.windowStartedAt || 0,
579
+ remainingCooldownMs,
580
+ cooldownUntilMs: state.openUntilMs || 0,
581
+ lastFailureCode: state.lastFailureCode || "",
582
+ };
583
+ }
584
+
585
+ function enforceProviderBreaker(provider, scope, requestId) {
586
+ const state = getProviderBreakerState(provider, scope);
587
+ if (!state) {
588
+ return;
589
+ }
590
+ const nowMs = Date.now();
591
+ if (state.openUntilMs > nowMs) {
592
+ const snapshot = getProviderBreakerSnapshot(provider, scope, nowMs);
593
+ const blocked = new AuthAuditError(
594
+ `Provider circuit is open for ${state.provider}/${state.scope}; retry after cooldown (requestId=${requestId}).`
595
+ );
596
+ blocked.errorCode = "AUTH_AUDIT_PROVIDER_CIRCUIT_OPEN";
597
+ blocked.retryable = false;
598
+ blocked.providerBreaker = snapshot;
599
+ throw blocked;
600
+ }
601
+ if (state.openUntilMs > 0 && state.openUntilMs <= nowMs) {
602
+ state.consecutiveFailures = 0;
603
+ state.windowStartedAt = nowMs;
604
+ state.openUntilMs = 0;
605
+ state.lastFailureCode = "";
606
+ state.lastUpdatedAtMs = nowMs;
607
+ AUTH_AUDIT_PROVIDER_BREAKERS.set(state.key, state);
608
+ persistProviderBreakerState(nowMs);
609
+ }
610
+ }
611
+
612
+ function recordProviderBreakerSuccess(provider, scope) {
613
+ const state = getProviderBreakerState(provider, scope);
614
+ if (!state) {
615
+ return;
616
+ }
617
+ state.consecutiveFailures = 0;
618
+ state.windowStartedAt = 0;
619
+ state.openUntilMs = 0;
620
+ state.lastFailureCode = "";
621
+ state.lastUpdatedAtMs = Date.now();
622
+ AUTH_AUDIT_PROVIDER_BREAKERS.set(state.key, state);
623
+ persistProviderBreakerState(state.lastUpdatedAtMs);
624
+ }
625
+
626
+ function recordProviderBreakerFailure(provider, scope, errorCode = "") {
627
+ const state = getProviderBreakerState(provider, scope);
628
+ if (!state) {
629
+ return null;
630
+ }
631
+ const nowMs = Date.now();
632
+ if (!state.windowStartedAt || (nowMs - state.windowStartedAt) > AUTH_AUDIT_PROVIDER_BREAKER_WINDOW_MS) {
633
+ state.windowStartedAt = nowMs;
634
+ state.consecutiveFailures = 0;
635
+ }
636
+ state.consecutiveFailures += 1;
637
+ state.lastFailureCode = String(errorCode || "").trim().toUpperCase();
638
+ if (state.consecutiveFailures >= AUTH_AUDIT_PROVIDER_BREAKER_FAILURE_THRESHOLD) {
639
+ state.openUntilMs = nowMs + AUTH_AUDIT_PROVIDER_BREAKER_COOLDOWN_MS;
640
+ }
641
+ state.lastUpdatedAtMs = nowMs;
642
+ AUTH_AUDIT_PROVIDER_BREAKERS.set(state.key, state);
643
+ persistProviderBreakerState(nowMs);
644
+ return getProviderBreakerSnapshot(provider, scope, nowMs);
645
+ }
646
+
647
+ function isAbortSignalLike(signal) {
648
+ return Boolean(
649
+ signal &&
650
+ typeof signal === "object" &&
651
+ typeof signal.aborted === "boolean" &&
652
+ typeof signal.addEventListener === "function" &&
653
+ typeof signal.removeEventListener === "function"
654
+ );
655
+ }
656
+
657
+ function composeAbortSignals(primarySignal, secondarySignal) {
658
+ const hasPrimary = isAbortSignalLike(primarySignal);
659
+ const hasSecondary = isAbortSignalLike(secondarySignal);
660
+ if (!hasPrimary && !hasSecondary) {
661
+ return { signal: undefined, cleanup: () => {} };
662
+ }
663
+ if (!hasPrimary) {
664
+ return { signal: secondarySignal, cleanup: () => {} };
665
+ }
666
+ if (!hasSecondary) {
667
+ return { signal: primarySignal, cleanup: () => {} };
668
+ }
669
+ const mergedController = new AbortController();
670
+ const forwardAbort = () => {
671
+ if (!mergedController.signal.aborted) {
672
+ mergedController.abort();
673
+ }
674
+ };
675
+ if (primarySignal.aborted || secondarySignal.aborted) {
676
+ forwardAbort();
677
+ return { signal: mergedController.signal, cleanup: () => {} };
678
+ }
679
+ primarySignal.addEventListener("abort", forwardAbort, { once: true });
680
+ secondarySignal.addEventListener("abort", forwardAbort, { once: true });
681
+ return {
682
+ signal: mergedController.signal,
683
+ cleanup: () => {
684
+ primarySignal.removeEventListener("abort", forwardAbort);
685
+ secondarySignal.removeEventListener("abort", forwardAbort);
686
+ },
687
+ };
688
+ }
689
+
690
+ async function provisionEmailIdentityWithRetry(provisionEmailIdentity, options = {}) {
691
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
692
+ ? Math.trunc(options.timeoutMs)
693
+ : AUTH_AIDENID_PROVISION_TIMEOUT_MS;
694
+ const maxRetries = Number.isInteger(options.maxRetries) && options.maxRetries >= 0
695
+ ? options.maxRetries
696
+ : AUTH_AIDENID_PROVISION_MAX_RETRIES;
697
+ const baseBackoffMs = Number.isFinite(options.baseBackoffMs) && options.baseBackoffMs > 0
698
+ ? Math.trunc(options.baseBackoffMs)
699
+ : AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS;
700
+ const requestOptions = options.requestOptions && typeof options.requestOptions === "object"
701
+ ? { ...options.requestOptions }
702
+ : {};
703
+ const requestId = String(options.requestId || createAuditRequestId());
704
+ const jitterSeed = Number.isFinite(options.jitterSeed)
705
+ ? Math.abs(Math.trunc(options.jitterSeed))
706
+ : deriveAidenidBackoffSeed(requestId);
707
+ const totalBudgetMs = normalizeAidenidTotalBudgetMs(options.totalBudgetMs);
708
+ const minAttemptTimeoutMs = Number.isFinite(options.minAttemptTimeoutMs) && options.minAttemptTimeoutMs > 0
709
+ ? Math.trunc(options.minAttemptTimeoutMs)
710
+ : AUTH_AIDENID_PROVISION_MIN_ATTEMPT_TIMEOUT_MS;
711
+ const effectiveMinAttemptTimeoutMs = Math.max(1, Math.min(timeoutMs, minAttemptTimeoutMs));
712
+ const attemptMetrics = [];
713
+ const retryWindowStartedAt = Date.now();
714
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
715
+ const elapsedMs = Date.now() - retryWindowStartedAt;
716
+ const remainingBudgetMs = totalBudgetMs - elapsedMs;
717
+ if (remainingBudgetMs <= 0) {
718
+ const exhausted = new AuthAuditError(
719
+ `AIdenID provisioning retry budget exhausted after ${attempt} attempt(s) (requestId=${requestId})`
720
+ );
721
+ exhausted.errorCode = "AIDENID_RETRY_BUDGET_EXHAUSTED";
722
+ exhausted.retryable = false;
723
+ exhausted.retryTelemetry = {
724
+ requestId,
725
+ attempts: attempt,
726
+ totalBudgetMs,
727
+ minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
728
+ totalElapsedMs: elapsedMs,
729
+ attemptMetrics,
730
+ };
731
+ throw exhausted;
732
+ }
733
+ if (remainingBudgetMs < effectiveMinAttemptTimeoutMs) {
734
+ const exhausted = new AuthAuditError(
735
+ `AIdenID provisioning remaining retry budget (${remainingBudgetMs}ms) fell below minimum attempt window (${effectiveMinAttemptTimeoutMs}ms) (requestId=${requestId})`
736
+ );
737
+ exhausted.errorCode = "AIDENID_RETRY_BUDGET_EXHAUSTED";
738
+ exhausted.retryable = false;
739
+ exhausted.retryTelemetry = {
740
+ requestId,
741
+ attempts: attempt,
742
+ totalBudgetMs,
743
+ minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
744
+ totalElapsedMs: elapsedMs,
745
+ attemptMetrics,
746
+ };
747
+ throw exhausted;
748
+ }
749
+ const attemptTimeoutMs = Math.max(effectiveMinAttemptTimeoutMs, Math.min(timeoutMs, remainingBudgetMs));
750
+ const attemptStartedAt = Date.now();
751
+ const attemptMetric = {
752
+ attempt: attempt + 1,
753
+ timeoutMs: attemptTimeoutMs,
754
+ budgetBeforeAttemptMs: remainingBudgetMs,
755
+ };
756
+ try {
757
+ const attemptPromise = provisionEmailIdentity({
758
+ ...requestOptions,
759
+ fetchImpl: async (resource, init = {}) => {
760
+ const callerSignal = isAbortSignalLike(init.signal) ? init.signal : undefined;
761
+ const controller = new AbortController();
762
+ const timeoutHandle = setTimeout(() => controller.abort(), attemptTimeoutMs);
763
+ const { signal: compositeSignal, cleanup: cleanupCompositeSignal } = composeAbortSignals(callerSignal, controller.signal);
764
+ const nextInit = {
765
+ ...init,
766
+ ...(compositeSignal ? { signal: compositeSignal } : {}),
767
+ };
768
+ try {
769
+ const response = await fetch(resource, nextInit);
770
+ if (response && RETRYABLE_AIDENID_PROVISION_STATUS_CODES.has(Number(response.status || 0))) {
771
+ if (response.body && typeof response.body.cancel === "function") {
772
+ try {
773
+ await response.body.cancel();
774
+ } catch {
775
+ // No-op: retry classification still applies if body drain fails.
776
+ }
777
+ }
778
+ const transientHttpError = new AuthAuditError(`AIdenID transient HTTP ${response.status}`);
779
+ transientHttpError.errorCode = "AIDENID_HTTP_RETRYABLE";
780
+ transientHttpError.statusCode = Number(response.status || 0);
781
+ transientHttpError.retryable = true;
782
+ throw transientHttpError;
783
+ }
784
+ return response;
785
+ } catch (error) {
786
+ if (callerSignal && callerSignal.aborted === true) {
787
+ const aborted = new AuthAuditError("AIdenID provisioning aborted by caller");
788
+ aborted.errorCode = "AIDENID_ABORTED_BY_CALLER";
789
+ aborted.retryable = false;
790
+ throw aborted;
791
+ }
792
+ const failure = classifyAidenidProvisionFailure(error);
793
+ if (failure.retryable) {
794
+ const wrapped = new AuthAuditError(normalizeErrorMessage(error, "AIdenID provisioning transport failed"));
795
+ wrapped.errorCode = failure.errorCode || "AIDENID_TRANSPORT_RETRYABLE";
796
+ wrapped.statusCode = failure.statusCode || 0;
797
+ wrapped.retryable = true;
798
+ throw wrapped;
799
+ }
800
+ throw error;
801
+ } finally {
802
+ clearTimeout(timeoutHandle);
803
+ cleanupCompositeSignal();
804
+ }
805
+ },
806
+ });
807
+ const result = await Promise.race([
808
+ attemptPromise,
809
+ new Promise((_, reject) => {
810
+ const timer = setTimeout(() => {
811
+ const timeoutError = new AuthAuditError(
812
+ `AIdenID provisioning attempt ${attempt + 1} timed out after ${attemptTimeoutMs}ms (requestId=${requestId})`
813
+ );
814
+ timeoutError.errorCode = "AIDENID_ATTEMPT_TIMEOUT";
815
+ timeoutError.retryable = true;
816
+ reject(timeoutError);
817
+ }, attemptTimeoutMs);
818
+ // `finally()` returns a new promise. Swallow its rejection path so late
819
+ // attempt failures never escape as unhandledRejection after timeout wins.
820
+ void attemptPromise
821
+ .finally(() => clearTimeout(timer))
822
+ .catch(() => {});
823
+ }),
824
+ ]);
825
+ attemptMetric.durationMs = Date.now() - attemptStartedAt;
826
+ attemptMetric.outcome = "success";
827
+ attemptMetrics.push(attemptMetric);
828
+ const retryTelemetry = {
829
+ requestId,
830
+ attempts: attempt + 1,
831
+ totalBudgetMs,
832
+ minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
833
+ totalElapsedMs: Date.now() - retryWindowStartedAt,
834
+ attemptMetrics,
835
+ };
836
+ if (result && typeof result === "object" && !Array.isArray(result)) {
837
+ return {
838
+ ...result,
839
+ retryTelemetry,
840
+ };
841
+ }
842
+ return {
843
+ value: result,
844
+ retryTelemetry,
845
+ };
846
+ } catch (error) {
847
+ const failure = classifyAidenidProvisionFailure(error);
848
+ attemptMetric.durationMs = Date.now() - attemptStartedAt;
849
+ attemptMetric.outcome = failure.retryable ? "retryable_error" : "terminal_error";
850
+ attemptMetric.errorCode = failure.errorCode || "AIDENID_PROVISION_FAILED";
851
+ attemptMetric.statusCode = failure.statusCode || 0;
852
+ attemptMetrics.push(attemptMetric);
853
+ if (!failure.retryable || attempt >= maxRetries) {
854
+ const reason = normalizeErrorMessage(error, "AIdenID provisioning failed");
855
+ const terminal = new AuthAuditError(
856
+ `AIdenID provisioning failed after ${attempt + 1} attempt(s) (requestId=${requestId}): ${reason}`
857
+ );
858
+ terminal.errorCode = failure.errorCode || "AIDENID_PROVISION_FAILED";
859
+ terminal.retryable = false;
860
+ terminal.retryTelemetry = {
861
+ requestId,
862
+ attempts: attempt + 1,
863
+ totalBudgetMs,
864
+ minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
865
+ totalElapsedMs: Date.now() - retryWindowStartedAt,
866
+ attemptMetrics,
867
+ };
868
+ throw terminal;
869
+ }
870
+ }
871
+ const backoffMs = computeAidenidProvisionBackoffMs(attempt, baseBackoffMs, jitterSeed);
872
+ const remainingAfterAttemptMs = totalBudgetMs - (Date.now() - retryWindowStartedAt);
873
+ if (remainingAfterAttemptMs <= 0) {
874
+ break;
875
+ }
876
+ await sleep(Math.min(backoffMs, remainingAfterAttemptMs));
877
+ }
878
+ const exhausted = new AuthAuditError(`AIdenID provisioning failed after retry budget was exhausted (requestId=${requestId})`);
879
+ exhausted.errorCode = "AIDENID_RETRY_BUDGET_EXHAUSTED";
880
+ exhausted.retryable = false;
881
+ exhausted.retryTelemetry = {
882
+ requestId,
883
+ attempts: maxRetries + 1,
884
+ totalBudgetMs,
885
+ minAttemptTimeoutMs: effectiveMinAttemptTimeoutMs,
886
+ totalElapsedMs: Date.now() - retryWindowStartedAt,
887
+ attemptMetrics,
888
+ };
889
+ throw exhausted;
890
+ }
891
+
892
+ function normalizeHeaderValue(value) {
893
+ const normalized = String(value || "").trim();
894
+ return normalized || "";
895
+ }
896
+
897
+ function evaluateAuthenticatedHeaderFindings(targetUrl, headers = {}, authSignals = {}) {
898
+ const findings = [];
899
+ const normalizedHeaders = headers && typeof headers === "object" ? headers : {};
900
+ const csp = normalizeHeaderValue(normalizedHeaders["content-security-policy"]);
901
+ const hsts = normalizeHeaderValue(normalizedHeaders["strict-transport-security"]);
902
+ const xFrameOptions = normalizeHeaderValue(normalizedHeaders["x-frame-options"]);
903
+ let requiresHsts = false;
904
+ try {
905
+ requiresHsts = new URL(targetUrl).protocol === "https:";
906
+ } catch {
907
+ requiresHsts = String(targetUrl || "").startsWith("https://");
908
+ }
909
+
910
+ if (!csp) {
911
+ findings.push({
912
+ severity: "P2",
913
+ title: "Authenticated page missing Content-Security-Policy header",
914
+ file: targetUrl,
915
+ });
916
+ }
917
+ if (requiresHsts && !hsts) {
918
+ findings.push({
919
+ severity: "P1",
920
+ title: "Authenticated page missing Strict-Transport-Security header",
921
+ file: targetUrl,
922
+ });
923
+ }
924
+ if (!xFrameOptions) {
925
+ findings.push({
926
+ severity: "P2",
927
+ title: "Authenticated page missing X-Frame-Options header",
928
+ file: targetUrl,
929
+ });
930
+ } else if (!/^(deny|sameorigin)$/i.test(xFrameOptions)) {
931
+ findings.push({
932
+ severity: "P2",
933
+ title: "Authenticated page has weak X-Frame-Options policy: " + xFrameOptions,
934
+ file: targetUrl,
935
+ });
936
+ }
937
+
938
+ if (authSignals && typeof authSignals === "object") {
939
+ authSignals.headerPolicyPassed = findings.length === 0;
940
+ authSignals.headerPolicyFindingCount = findings.length;
941
+ }
942
+ return findings;
943
+ }
944
+
945
+ async function provisionTestIdentity(input) {
946
+ const requestId = String(input.requestId || createAuditRequestId());
947
+ const providerKey = AUTH_AUDIT_PROVIDER_AIDENID;
948
+ const providerScope = deriveProviderBreakerScope({
949
+ contextId: input.providerContextId || input.contextId || input.scopeId,
950
+ apiUrl: input.apiUrl,
951
+ orgId: input.orgId || input.aidenidOrgId,
952
+ projectId: input.projectId || input.aidenidProjectId,
953
+ });
954
+ try {
955
+ enforceProviderBreaker(providerKey, providerScope, requestId);
956
+ const executeRequested = input.execute === true;
957
+ const allowLiveProvision = input.allowProvisioning === true || process.env.SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION === "1";
958
+ if (executeRequested && !allowLiveProvision) {
959
+ return buildUnavailableAuditResponse(
960
+ requestId,
961
+ "AIDENID_PROVISION_APPROVAL_REQUIRED",
962
+ "Live AIdenID provisioning requires explicit allowProvisioning=true (or SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION=1)."
963
+ );
964
+ }
965
+
966
+ const { provisionEmailIdentity, resolveAidenIdCredentials } = await import("../../../ai/aidenid.js");
967
+ const creds = await resolveAidenIdCredentials();
968
+ if (!creds.apiKey) {
969
+ return buildUnavailableAuditResponse(
970
+ requestId,
971
+ "AIDENID_API_KEY_MISSING",
972
+ "AIdenID API key not configured (set AIDENID_API_KEY)"
973
+ );
974
+ }
975
+ const result = await provisionEmailIdentityWithRetry(provisionEmailIdentity, {
976
+ timeoutMs: AUTH_AIDENID_PROVISION_TIMEOUT_MS,
977
+ maxRetries: AUTH_AIDENID_PROVISION_MAX_RETRIES,
978
+ baseBackoffMs: AUTH_AIDENID_PROVISION_BASE_BACKOFF_MS,
979
+ totalBudgetMs: AUTH_AIDENID_PROVISION_TOTAL_BUDGET_MS,
980
+ minAttemptTimeoutMs: AUTH_AIDENID_PROVISION_MIN_ATTEMPT_TIMEOUT_MS,
981
+ requestId,
982
+ requestOptions: {
983
+ apiUrl: creds.apiUrl,
984
+ apiKey: creds.apiKey,
985
+ tags: ["jules-audit", "frontend-test"],
986
+ ttlSeconds: 3600,
987
+ dryRun: !executeRequested,
988
+ },
989
+ });
990
+ const identity = result && typeof result === "object"
991
+ ? (Object.prototype.hasOwnProperty.call(result, "identity") ? result.identity : (Object.prototype.hasOwnProperty.call(result, "value") ? result.value : result))
992
+ : result;
993
+ const retryTelemetry = result && typeof result === "object" && result.retryTelemetry
994
+ ? result.retryTelemetry
995
+ : null;
996
+ recordProviderBreakerSuccess(providerKey, providerScope);
997
+ return { available: true, requestId, dryRun: !executeRequested, identity, retryTelemetry };
998
+ } catch (err) {
999
+ const message = "AIdenID provisioning failed: " + normalizeErrorMessage(err, "unknown error");
1000
+ const retryable = isRetryableAidenidProvisionError(err);
1001
+ let providerBreaker = err && typeof err === "object" && err.providerBreaker ? err.providerBreaker : null;
1002
+ if (retryable) {
1003
+ providerBreaker = recordProviderBreakerFailure(providerKey, providerScope, err && typeof err === "object" ? err.errorCode : "") || providerBreaker;
1004
+ } else if (!providerBreaker) {
1005
+ providerBreaker = getProviderBreakerSnapshot(providerKey, providerScope);
1006
+ }
1007
+ return buildUnavailableAuditResponse(requestId, "AIDENID_PROVISION_FAILED", message, {
1008
+ retryable,
1009
+ retryTelemetry: err && typeof err === "object" && err.retryTelemetry ? err.retryTelemetry : null,
1010
+ providerBreaker,
1011
+ });
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * Run Playwright to authenticate and inspect the page.
1017
+ * - Runtime values loaded from a secure temp context file (credentials not exposed in process env)
1018
+ * - Auth verification checks URL change + cookie presence (not just click success)
1019
+ * - Console errors redacted to prevent sensitive data leakage
1020
+ * - Cookie values never captured (names + flags only)
1021
+ * - Temp script/context cleanup in finally block (not just success path)
1022
+ */
1023
+ async function authenticatedPageCheck(input) {
1024
+ const requestId = String(input.requestId || createAuditRequestId());
1025
+ const providerKey = AUTH_AUDIT_PROVIDER_PLAYWRIGHT_TARGET;
1026
+ const url = input.url;
1027
+ if (!url) throw new AuthAuditError("authenticated_page_check requires url");
1028
+ const targetUrl = resolveAuthAuditTarget(url, input, "authenticated_page_check.target");
1029
+ const providerScope = deriveProviderBreakerScope({
1030
+ contextId: input.providerContextId || input.contextId || input.scopeId,
1031
+ targetUrl,
1032
+ });
1033
+
1034
+ const loginUrlCandidate = input.loginUrl || targetUrl + "/login";
1035
+ const loginUrl = resolveAuthAuditTarget(loginUrlCandidate, input, "authenticated_page_check.login");
1036
+ const allowAuthMutation = input.allowAuthMutation === true || process.env[AUTH_MUTATION_ALLOWED_ENV] === "1";
1037
+
1038
+ try {
1039
+ enforceProviderBreaker(providerKey, providerScope, requestId);
1040
+ const authContextJson = JSON.stringify({
1041
+ email: input.email || "",
1042
+ password: input.password || "",
1043
+ emailField: input.emailField || "",
1044
+ passwordField: input.passwordField || "",
1045
+ submitSelector: input.submitSelector || "",
1046
+ });
1047
+ // Use scrubbed env — strip API keys/tokens from child process
1048
+ const { buildScrubbedEnv } = await import("./shell.js");
1049
+ const env = {
1050
+ ...buildScrubbedEnv(),
1051
+ SL_AUDIT_TARGET_URL: targetUrl,
1052
+ SL_AUDIT_LOGIN_URL: loginUrl,
1053
+ SL_AUDIT_ALLOW_AUTH_MUTATION: allowAuthMutation ? "1" : "0",
1054
+ };
1055
+
1056
+ const output = await runPlaywrightAuditScriptWithRetry(null, env, {
1057
+ scriptSource: PLAYWRIGHT_AUTH_SCRIPT,
1058
+ stdinPayload: authContextJson,
1059
+ });
1060
+
1061
+ const result = JSON.parse(output.trim());
1062
+ const findings = [];
1063
+ for (const cookie of (result.cookies || [])) {
1064
+ if (cookie.sensitive && !cookie.httpOnly) {
1065
+ findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing httpOnly flag", file: targetUrl });
1066
+ }
1067
+ if (cookie.sensitive && !cookie.secure) {
1068
+ findings.push({ severity: "P1", title: "Sensitive cookie '" + cookie.name + "' missing Secure flag", file: targetUrl });
1069
+ }
1070
+ if (cookie.sensitive && cookie.sameSite === "None") {
1071
+ findings.push({ severity: "P2", title: "Sensitive cookie '" + cookie.name + "' has SameSite=None", file: targetUrl });
1072
+ }
1073
+ }
1074
+ findings.push(...evaluateAuthenticatedHeaderFindings(targetUrl, result.headers || {}, result.authSignals || {}));
1075
+ recordProviderBreakerSuccess(providerKey, providerScope);
1076
+ return { available: true, requestId, method: "playwright", mutationAllowed: allowAuthMutation, findings, ...result };
1077
+ } catch (err) {
1078
+ const code = err instanceof AuthAuditError ? "AUTH_AUDIT_VALIDATION_FAILED" : "AUTH_AUDIT_PLAYWRIGHT_FAILED";
1079
+ const baseMessage = err instanceof AuthAuditError ? err.message : "Playwright auth audit failed: " + normalizeErrorMessage(err, "unknown error");
1080
+ const retryable = isRetryablePlaywrightExecutionError(err);
1081
+ let providerBreaker = err && typeof err === "object" && err.providerBreaker ? err.providerBreaker : null;
1082
+ if (retryable) {
1083
+ providerBreaker = recordProviderBreakerFailure(providerKey, providerScope, err && typeof err === "object" ? err.errorCode : "") || providerBreaker;
1084
+ } else if (!providerBreaker) {
1085
+ providerBreaker = getProviderBreakerSnapshot(providerKey, providerScope);
1086
+ }
1087
+ return buildUnavailableAuditResponse(requestId, code, baseMessage, {
1088
+ retryable,
1089
+ providerBreaker,
1090
+ });
1091
+ }
1092
+ }
1093
+
1094
+ // Playwright script as a constant — no string interpolation of URLs/credentials.
1095
+ // Dynamic auth context is read from stdin at runtime to avoid local credential temp files.
1096
+ const PLAYWRIGHT_AUTH_SCRIPT = `
1097
+ const { chromium } = require('playwright');
1098
+ const fs = require('node:fs');
1099
+
1100
+ (async () => {
1101
+ const targetUrl = process.env.SL_AUDIT_TARGET_URL;
1102
+ const loginUrl = process.env.SL_AUDIT_LOGIN_URL;
1103
+ const allowAuthMutation = process.env.SL_AUDIT_ALLOW_AUTH_MUTATION === '1';
1104
+ let context = {};
1105
+ try {
1106
+ let stdinPayload = fs.readFileSync(0, 'utf-8');
1107
+ if (stdinPayload) {
1108
+ context = JSON.parse(stdinPayload) || {};
1109
+ }
1110
+ stdinPayload = '';
1111
+ } catch {
1112
+ context = {};
1113
+ }
1114
+
1115
+ let email = context.email || '';
1116
+ let password = context.password || '';
1117
+ const emailSelector = context.emailField || 'input[type="email"]';
1118
+ const passwordSelector = context.passwordField || 'input[type="password"]';
1119
+ const submitSelector = context.submitSelector || 'button[type="submit"]';
1120
+ if (Object.prototype.hasOwnProperty.call(context, 'password')) delete context.password;
1121
+ if (Object.prototype.hasOwnProperty.call(context, 'token')) delete context.token;
1122
+ if (Object.prototype.hasOwnProperty.call(context, 'secret')) delete context.secret;
1123
+
1124
+ let browser = null;
1125
+ const results = { authenticated: false, authSignals: {}, errors: [], cookies: [], headers: {}, domStats: {}, executionFailed: false };
1126
+ results.authSignals.mutationAllowed = allowAuthMutation;
1127
+ function normalizePath(value) {
1128
+ const normalized = String(value || '/').replace(/\\/+$/, '');
1129
+ return normalized || '/';
1130
+ }
1131
+ function didLeaveLoginSurface(currentValue, loginValue) {
1132
+ try {
1133
+ const currentUrl = new URL(currentValue);
1134
+ const loginParsed = new URL(loginValue);
1135
+ return (
1136
+ currentUrl.origin !== loginParsed.origin ||
1137
+ normalizePath(currentUrl.pathname) !== normalizePath(loginParsed.pathname)
1138
+ );
1139
+ } catch {
1140
+ return String(currentValue || '') !== String(loginValue || '');
1141
+ }
1142
+ }
1143
+ function sanitizeErrorText(value) {
1144
+ return String(value || '')
1145
+ .replace(/\\s+/g, ' ')
1146
+ .replace(/Bearer\\s+[^\\s,;]+/gi, 'Bearer [REDACTED]')
1147
+ .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]')
1148
+ .replace(/\\b[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{8,}\\b/g, '[REDACTED_JWT]')
1149
+ .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]')
1150
+ .replace(/\\b[A-Fa-f0-9]{32,}\\b/g, '[REDACTED_HEX]')
1151
+ .replace(/\\b[A-Za-z0-9_-]{40,}\\b/g, '[REDACTED_TOKEN]')
1152
+ .slice(0, 200);
1153
+ }
1154
+
1155
+ try {
1156
+ browser = await chromium.launch({ headless: true });
1157
+ const page = await browser.newPage();
1158
+ page.on('console', msg => {
1159
+ if (msg.type() === 'error') {
1160
+ const text = sanitizeErrorText(msg.text());
1161
+ results.errors.push({ type: 'console', text });
1162
+ }
1163
+ });
1164
+ page.on('pageerror', err => {
1165
+ const text = sanitizeErrorText(err && err.message ? err.message : String(err || ''));
1166
+ results.errors.push({ type: 'pageerror', text });
1167
+ });
1168
+
1169
+ if (email && password && loginUrl) {
1170
+ await page.goto(loginUrl, { waitUntil: 'networkidle', timeout: 30000 });
1171
+ if (allowAuthMutation) {
1172
+ await page.fill(emailSelector, email);
1173
+ await page.fill(passwordSelector, password);
1174
+ await page.click(submitSelector);
1175
+ let navigationError = null;
1176
+ await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 15000 }).catch((err) => {
1177
+ navigationError = sanitizeErrorText(err && err.message ? err.message : 'navigation timeout');
1178
+ });
1179
+ if (navigationError) {
1180
+ results.authSignals.navigationTimeout = true;
1181
+ results.errors.push({ type: 'navigation', text: navigationError });
1182
+ }
1183
+ results.authSignals.mutationPerformed = true;
1184
+ } else {
1185
+ results.authSignals.mutationPerformed = false;
1186
+ }
1187
+ const authVerificationMaxAttempts = allowAuthMutation ? 3 : 1;
1188
+ let verificationAttemptsUsed = 0;
1189
+ let urlChanged = false;
1190
+ let authCookiePresent = false;
1191
+ let loginFormVisible = true;
1192
+ for (let verificationAttempt = 1; verificationAttempt <= authVerificationMaxAttempts; verificationAttempt += 1) {
1193
+ verificationAttemptsUsed = verificationAttempt;
1194
+ const currentUrl = page.url();
1195
+ const postCookies = await page.context().cookies();
1196
+ urlChanged = didLeaveLoginSurface(currentUrl, loginUrl);
1197
+ authCookiePresent = postCookies.some(c => /(?:^|[-_])(session|token|auth|jwt)(?:$|[-_])/i.test(c.name) && (c.httpOnly || c.secure));
1198
+ loginFormVisible = await page.evaluate((emailSel, passwordSel) => (
1199
+ Boolean(document.querySelector(emailSel) && document.querySelector(passwordSel))
1200
+ ), emailSelector, passwordSelector).catch(() => false);
1201
+ const navigationSucceeded = results.authSignals.navigationTimeout !== true;
1202
+ results.authenticated = navigationSucceeded && !loginFormVisible && urlChanged && authCookiePresent;
1203
+ if (results.authenticated) {
1204
+ break;
1205
+ }
1206
+ if (verificationAttempt < authVerificationMaxAttempts) {
1207
+ await page.waitForTimeout(400 * verificationAttempt);
1208
+ }
1209
+ }
1210
+ results.authSignals = {
1211
+ urlChanged,
1212
+ authCookiePresent,
1213
+ loginFormVisible,
1214
+ authVerificationAttemptsUsed: verificationAttemptsUsed,
1215
+ authVerificationMaxAttempts,
1216
+ };
1217
+ results.authSignals.authVerificationRetried = verificationAttemptsUsed > 1;
1218
+ results.authSignals.mutationAllowed = allowAuthMutation;
1219
+ results.authSignals.mutationPerformed = allowAuthMutation ? true : false;
1220
+ email = '';
1221
+ password = '';
1222
+ }
1223
+
1224
+ const targetResponse = await page.goto(targetUrl, { waitUntil: 'networkidle', timeout: 30000 });
1225
+
1226
+ const cookies = await page.context().cookies();
1227
+ results.cookies = cookies.map(c => ({
1228
+ name: c.name, domain: c.domain,
1229
+ httpOnly: c.httpOnly, secure: c.secure,
1230
+ sameSite: c.sameSite,
1231
+ sensitive: /session|token|auth|jwt/i.test(c.name),
1232
+ }));
1233
+
1234
+ results.domStats = await page.evaluate(() => ({
1235
+ title: document.title,
1236
+ nodeCount: document.querySelectorAll('*').length,
1237
+ formCount: document.querySelectorAll('form').length,
1238
+ inputCount: document.querySelectorAll('input').length,
1239
+ }));
1240
+
1241
+ const response = targetResponse || null;
1242
+ const targetLoginFormVisible = await page.evaluate((emailSel, passwordSel) => (
1243
+ Boolean(document.querySelector(emailSel) && document.querySelector(passwordSel))
1244
+ ), emailSelector, passwordSelector).catch(() => true);
1245
+ const targetStatus = response ? response.status() : null;
1246
+ const targetStatusOk = typeof targetStatus === 'number' ? targetStatus < 400 : false;
1247
+ results.authSignals.targetLoginFormVisible = targetLoginFormVisible;
1248
+ results.authSignals.targetStatus = targetStatus;
1249
+ results.authSignals.targetStatusOk = targetStatusOk;
1250
+ if (results.authenticated) {
1251
+ results.authenticated = !targetLoginFormVisible && targetStatusOk;
1252
+ }
1253
+ if (response) {
1254
+ const h = response.headers();
1255
+ results.headers = {
1256
+ 'content-security-policy': h['content-security-policy'] || null,
1257
+ 'x-frame-options': h['x-frame-options'] || null,
1258
+ 'strict-transport-security': h['strict-transport-security'] || null,
1259
+ 'cache-control': h['cache-control'] || null,
1260
+ };
1261
+ const normalizedFramePolicy = String(results.headers['x-frame-options'] || '').trim().toLowerCase();
1262
+ const headerPolicyBreaches = [];
1263
+ if (!results.headers['content-security-policy']) {
1264
+ headerPolicyBreaches.push('missing_content_security_policy');
1265
+ }
1266
+ if (String(targetUrl || '').startsWith('https://') && !results.headers['strict-transport-security']) {
1267
+ headerPolicyBreaches.push('missing_strict_transport_security');
1268
+ }
1269
+ if (!normalizedFramePolicy) {
1270
+ headerPolicyBreaches.push('missing_x_frame_options');
1271
+ } else if (!(normalizedFramePolicy === 'deny' || normalizedFramePolicy === 'sameorigin')) {
1272
+ headerPolicyBreaches.push('weak_x_frame_options');
1273
+ }
1274
+ results.authSignals.headerPolicyBreaches = headerPolicyBreaches;
1275
+ results.authSignals.headerPolicyPassed = headerPolicyBreaches.length === 0;
1276
+ results.authSignals.headerPolicyFailed = headerPolicyBreaches.length > 0;
1277
+ } else {
1278
+ results.authSignals.headerPolicyBreaches = ['target_response_unavailable'];
1279
+ results.authSignals.headerPolicyPassed = false;
1280
+ results.authSignals.headerPolicyFailed = true;
1281
+ }
1282
+ } catch (err) {
1283
+ results.executionFailed = true;
1284
+ const text = sanitizeErrorText('Playwright error: ' + (err && err.message ? err.message : ''));
1285
+ results.errors.push({ type: 'playwright', text });
1286
+ } finally {
1287
+ try { console.log(JSON.stringify(results)); } catch {}
1288
+ if (browser) {
1289
+ await browser.close().catch(() => {});
1290
+ }
1291
+ if (results.executionFailed) {
1292
+ process.exitCode = 1;
1293
+ }
1294
+ }
1295
+ })();
1296
+ `;
1297
+
1298
+ const MAX_AUTH_REDIRECT_HOPS = 5;
1299
+ const AUTH_FLOW_FETCH_TIMEOUT_MS = 10_000;
1300
+ const AUTH_FLOW_FETCH_MAX_RETRIES = 2;
1301
+ const AUTH_FLOW_FETCH_BASE_BACKOFF_MS = 200;
1302
+ const RETRYABLE_AUTH_FLOW_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
1303
+ const RETRYABLE_AUTH_FLOW_ERROR_CODES = new Set([
1304
+ "ECONNRESET",
1305
+ "EAI_AGAIN",
1306
+ "ENOTFOUND",
1307
+ "ECONNREFUSED",
1308
+ "ETIMEDOUT",
1309
+ "ECONNABORTED",
1310
+ "UND_ERR_CONNECT_TIMEOUT",
1311
+ "UND_ERR_HEADERS_TIMEOUT",
1312
+ "UND_ERR_BODY_TIMEOUT",
1313
+ ]);
1314
+ const RETRYABLE_AUTH_FLOW_MESSAGE_PATTERNS = [
1315
+ /\bfetch failed\b/i,
1316
+ /\bnetwork(?:\s+|-)error\b/i,
1317
+ /\bsocket hang up\b/i,
1318
+ /\btimed?\s*out\b/i,
1319
+ /\b(?:econnreset|eai_again|enotfound|econnrefused|etimedout)\b/i,
1320
+ /\btemporary(?:\s+|-)failure\b/i,
1321
+ /\bconnection\b.*\b(?:reset|terminated|closed)\b/i,
1322
+ ];
1323
+ const AUTH_FLOW_LOCAL_TEST_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
1324
+ const DEFAULT_APPROVED_AUTH_AUDIT_HOSTS = new Set(["example.com", "www.example.com"]);
1325
+ const AUTH_AUDIT_ALLOWED_HOSTS_ENV = "SENTINELAYER_AUTH_AUDIT_ALLOWED_HOSTS";
1326
+
1327
+ function computePlaywrightBackoffMs(attempt, baseBackoffMs = AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS) {
1328
+ const cappedBase = Math.max(1, Number.isFinite(baseBackoffMs) ? Math.trunc(baseBackoffMs) : AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS);
1329
+ const exponential = Math.min(4000, cappedBase * Math.pow(2, Math.max(0, attempt)));
1330
+ const deterministicJitter = ((Math.max(0, attempt) * 1103515245 + 12345) % 1000) / 1000;
1331
+ const jitterFactor = 0.5 + (deterministicJitter * 0.5);
1332
+ return Math.max(1, Math.trunc(exponential * jitterFactor));
1333
+ }
1334
+
1335
+ function isRetryablePlaywrightExecutionError(error) {
1336
+ if (!(error instanceof Error)) {
1337
+ return false;
1338
+ }
1339
+ if (error.name === "AbortError" || error.name === "TimeoutError") {
1340
+ return true;
1341
+ }
1342
+ const code = String(error.code || "").toUpperCase();
1343
+ if (RETRYABLE_PLAYWRIGHT_EXEC_ERROR_CODES.has(code)) {
1344
+ return true;
1345
+ }
1346
+ if (error.killed === true && (error.signal === "SIGTERM" || error.signal === "SIGKILL")) {
1347
+ return true;
1348
+ }
1349
+ const causeCode = String(error.cause?.code || error.cause?.errno || "").toUpperCase();
1350
+ return RETRYABLE_PLAYWRIGHT_EXEC_ERROR_CODES.has(causeCode);
1351
+ }
1352
+
1353
+ function normalizeAuthAuditErrorMessage(error, fallbackMessage) {
1354
+ if (error instanceof Error && error.message) {
1355
+ return error.message;
1356
+ }
1357
+ const normalized = String(error || "").trim();
1358
+ return normalized || fallbackMessage;
1359
+ }
1360
+
1361
+ export async function runPlaywrightAuditScriptWithRetry(scriptPath, env, options = {}) {
1362
+ const scriptSource = String(options.scriptSource || "");
1363
+ const runArgs = scriptSource ? ["-e", scriptSource] : (scriptPath ? [scriptPath] : []);
1364
+ if (runArgs.length === 0) {
1365
+ throw new AuthAuditError("Playwright auth audit failed: missing script path");
1366
+ }
1367
+ const stdinPayload = String(options.stdinPayload || "");
1368
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
1369
+ ? Math.trunc(options.timeoutMs)
1370
+ : AUTH_PLAYWRIGHT_EXEC_TIMEOUT_MS;
1371
+ const maxRetries = Number.isInteger(options.maxRetries) && options.maxRetries >= 0
1372
+ ? options.maxRetries
1373
+ : AUTH_PLAYWRIGHT_EXEC_MAX_RETRIES;
1374
+ const baseBackoffMs = Number.isFinite(options.baseBackoffMs) && options.baseBackoffMs > 0
1375
+ ? Math.trunc(options.baseBackoffMs)
1376
+ : AUTH_PLAYWRIGHT_EXEC_BASE_BACKOFF_MS;
1377
+ const totalBudgetMs = Number.isFinite(options.totalBudgetMs) && options.totalBudgetMs > 0
1378
+ ? Math.trunc(options.totalBudgetMs)
1379
+ : AUTH_PLAYWRIGHT_EXEC_TOTAL_BUDGET_MS;
1380
+ const minAttemptTimeoutMs = Number.isFinite(options.minAttemptTimeoutMs) && options.minAttemptTimeoutMs > 0
1381
+ ? Math.trunc(options.minAttemptTimeoutMs)
1382
+ : AUTH_PLAYWRIGHT_EXEC_MIN_ATTEMPT_TIMEOUT_MS;
1383
+ const execute = typeof options.exec === "function" ? options.exec : execFileSync;
1384
+ const now = typeof options.now === "function" ? options.now : Date.now;
1385
+ const sleepFn = typeof options.sleep === "function" ? options.sleep : sleep;
1386
+ const retryWindowStartedAt = now();
1387
+
1388
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
1389
+ const elapsedMs = now() - retryWindowStartedAt;
1390
+ const remainingBudgetMs = totalBudgetMs - elapsedMs;
1391
+ if (remainingBudgetMs <= 0) {
1392
+ throw new AuthAuditError(
1393
+ `Playwright auth audit failed: retry budget exhausted after ${attempt} attempt(s) over ${totalBudgetMs}ms`
1394
+ );
1395
+ }
1396
+ if (remainingBudgetMs < minAttemptTimeoutMs) {
1397
+ throw new AuthAuditError(
1398
+ `Playwright auth audit failed: remaining retry budget (${remainingBudgetMs}ms) below minimum attempt timeout (${minAttemptTimeoutMs}ms)`
1399
+ );
1400
+ }
1401
+ const attemptTimeoutMs = Math.max(minAttemptTimeoutMs, Math.min(timeoutMs, remainingBudgetMs));
1402
+ try {
1403
+ return execute(process.execPath, runArgs, {
1404
+ encoding: "utf-8",
1405
+ timeout: attemptTimeoutMs,
1406
+ stdio: ["pipe", "pipe", "pipe"],
1407
+ env,
1408
+ input: stdinPayload,
1409
+ });
1410
+ } catch (error) {
1411
+ if (!isRetryablePlaywrightExecutionError(error) || attempt >= maxRetries) {
1412
+ const reason = normalizeAuthAuditErrorMessage(error, "Playwright execution failed");
1413
+ throw new AuthAuditError(`Playwright auth audit failed after ${attempt + 1} attempt(s): ${reason}`);
1414
+ }
1415
+ }
1416
+ const backoffMs = computePlaywrightBackoffMs(attempt, baseBackoffMs);
1417
+ const remainingAfterAttemptMs = totalBudgetMs - (now() - retryWindowStartedAt);
1418
+ if (remainingAfterAttemptMs <= 0) {
1419
+ throw new AuthAuditError(
1420
+ `Playwright auth audit failed: retry budget exhausted after ${attempt + 1} attempt(s) over ${totalBudgetMs}ms`
1421
+ );
1422
+ }
1423
+ await sleepFn(Math.min(backoffMs, remainingAfterAttemptMs));
1424
+ }
1425
+
1426
+ throw new AuthAuditError("Playwright auth audit failed after retry budget was exhausted");
1427
+ }
1428
+
1429
+ function computeAuthFlowBackoffMs(attempt) {
1430
+ const computed = AUTH_FLOW_FETCH_BASE_BACKOFF_MS * Math.pow(2, Math.max(0, attempt));
1431
+ return Math.min(1000, computed);
1432
+ }
1433
+
1434
+ function resolveAuthFlowErrorCode(error) {
1435
+ if (!(error instanceof Error)) {
1436
+ return "";
1437
+ }
1438
+ const directCode = String(error.code || "").toUpperCase();
1439
+ if (directCode) {
1440
+ return directCode;
1441
+ }
1442
+ const cause = error.cause;
1443
+ if (!cause || typeof cause !== "object") {
1444
+ return "";
1445
+ }
1446
+ return String(cause.code || cause.errno || "").toUpperCase();
1447
+ }
1448
+
1449
+ function isRetryableAuthFlowError(error) {
1450
+ if (!(error instanceof Error)) {
1451
+ return false;
1452
+ }
1453
+ if (error.name === "AbortError" || error.name === "TimeoutError") {
1454
+ return true;
1455
+ }
1456
+ const code = resolveAuthFlowErrorCode(error);
1457
+ if (RETRYABLE_AUTH_FLOW_ERROR_CODES.has(code)) {
1458
+ return true;
1459
+ }
1460
+ const normalized = `${error.name} ${error.message || ""}`.toLowerCase();
1461
+ if (error.name === "TypeError") {
1462
+ return RETRYABLE_AUTH_FLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
1463
+ }
1464
+ return RETRYABLE_AUTH_FLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
1465
+ }
1466
+
1467
+ function isAllowedHttpAuthFlowTarget(urlObject) {
1468
+ if (urlObject.protocol !== "http:") {
1469
+ return true;
1470
+ }
1471
+ if (process.env.NODE_ENV !== "test") {
1472
+ return false;
1473
+ }
1474
+ return AUTH_FLOW_LOCAL_TEST_HOSTS.has(urlObject.hostname);
1475
+ }
1476
+
1477
+ function isUnapprovedAuthAuditBypassEnabled() {
1478
+ if (process.env.NODE_ENV === "test") {
1479
+ return true;
1480
+ }
1481
+ if (process.env.SENTINELAYER_ALLOW_UNAPPROVED_AUTH_AUDIT_TARGETS === "1") {
1482
+ return true;
1483
+ }
1484
+ return false;
1485
+ }
1486
+
1487
+ function normalizeHostEntry(value) {
1488
+ return String(value || "").trim().toLowerCase();
1489
+ }
1490
+
1491
+ function resolveApprovedAuthAuditHosts(input) {
1492
+ const approvedHosts = new Set(DEFAULT_APPROVED_AUTH_AUDIT_HOSTS);
1493
+ const hostLists = [];
1494
+ if (Array.isArray(input?.approvedHosts)) {
1495
+ hostLists.push(input.approvedHosts);
1496
+ }
1497
+ if (Array.isArray(input?.approvedHostnames)) {
1498
+ hostLists.push(input.approvedHostnames);
1499
+ }
1500
+ const envHosts = String(process.env[AUTH_AUDIT_ALLOWED_HOSTS_ENV] || "")
1501
+ .split(",")
1502
+ .map((entry) => normalizeHostEntry(entry))
1503
+ .filter(Boolean);
1504
+ hostLists.push(envHosts);
1505
+ for (const list of hostLists) {
1506
+ for (const host of list) {
1507
+ const normalized = normalizeHostEntry(host);
1508
+ if (normalized) {
1509
+ approvedHosts.add(normalized);
1510
+ }
1511
+ }
1512
+ }
1513
+ return approvedHosts;
1514
+ }
1515
+
1516
+ function assertApprovedAuthAuditTarget(parsed, input, operation) {
1517
+ if (isUnapprovedAuthAuditBypassEnabled()) {
1518
+ return parsed;
1519
+ }
1520
+ const allowLiveProvision = input?.allowProvisioning === true || process.env.SENTINELAYER_ALLOW_LIVE_IDENTITY_PROVISION === "1";
1521
+ const approvedTargetId = String(input?.approvedTargetId || "").trim();
1522
+ if (!allowLiveProvision || !approvedTargetId) {
1523
+ throw new AuthAuditError(
1524
+ `Live ${operation} requires allowProvisioning=true and approvedTargetId to prevent unapproved outbound probing.`
1525
+ );
1526
+ }
1527
+ const approvedHosts = resolveApprovedAuthAuditHosts(input);
1528
+ const normalizedHost = normalizeHostEntry(parsed.hostname);
1529
+ if (!approvedHosts.has(normalizedHost)) {
1530
+ throw new AuthAuditError(
1531
+ `Blocked unapproved auth audit host for ${operation}: ${normalizedHost}. ` +
1532
+ `Add host to approvedHosts or ${AUTH_AUDIT_ALLOWED_HOSTS_ENV}.`
1533
+ );
1534
+ }
1535
+ return parsed;
1536
+ }
1537
+
1538
+ function assertSecureAuthFlowTarget(urlValue, options = {}) {
1539
+ let parsed;
1540
+ try {
1541
+ parsed = assertPermittedAuditTarget(urlValue, {
1542
+ operation: "check_auth_flow_security",
1543
+ allowPrivateTargets: options.allowPrivateTargets === true,
1544
+ });
1545
+ } catch (error) {
1546
+ throw new AuthAuditError(error.message);
1547
+ }
1548
+ assertApprovedAuthAuditTarget(parsed, options.auditInput || {}, "check_auth_flow_security");
1549
+ if (!isAllowedHttpAuthFlowTarget(parsed)) {
1550
+ throw new AuthAuditError(
1551
+ `HTTPS downgrade detected in auth flow target: ${parsed.toString()}`
1552
+ );
1553
+ }
1554
+ return parsed;
1555
+ }
1556
+
1557
+ async function fetchWithTimeout(url, options, timeoutMs) {
1558
+ const callerSignal = isAbortSignalLike(options?.signal) ? options.signal : undefined;
1559
+ const controller = new AbortController();
1560
+ const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
1561
+ const { signal: compositeSignal, cleanup: cleanupCompositeSignal } = composeAbortSignals(callerSignal, controller.signal);
1562
+ try {
1563
+ return await fetch(url, {
1564
+ ...options,
1565
+ ...(compositeSignal ? { signal: compositeSignal } : {}),
1566
+ });
1567
+ } finally {
1568
+ clearTimeout(timeoutHandle);
1569
+ cleanupCompositeSignal();
1570
+ }
1571
+ }
1572
+
1573
+ async function fetchLoginResponseWithRetry(currentUrl) {
1574
+ for (let attempt = 0; attempt <= AUTH_FLOW_FETCH_MAX_RETRIES; attempt += 1) {
1575
+ try {
1576
+ const response = await fetchWithTimeout(currentUrl, {
1577
+ method: "GET",
1578
+ redirect: "manual",
1579
+ }, AUTH_FLOW_FETCH_TIMEOUT_MS);
1580
+ if (!RETRYABLE_AUTH_FLOW_STATUS_CODES.has(response.status)) {
1581
+ return response;
1582
+ }
1583
+ if (attempt >= AUTH_FLOW_FETCH_MAX_RETRIES) {
1584
+ throw new AuthAuditError(
1585
+ `Auth flow header fetch failed after ${attempt + 1} attempt(s): HTTP ${response.status}`
1586
+ );
1587
+ }
1588
+ } catch (error) {
1589
+ if (error instanceof AuthAuditError) {
1590
+ throw error;
1591
+ }
1592
+ if (!isRetryableAuthFlowError(error) || attempt >= AUTH_FLOW_FETCH_MAX_RETRIES) {
1593
+ const message = error instanceof Error ? error.message : String(error || "request failed");
1594
+ throw new AuthAuditError(`Auth flow header fetch failed after ${attempt + 1} attempt(s): ${message}`);
1595
+ }
1596
+ }
1597
+ await sleep(computeAuthFlowBackoffMs(attempt));
1598
+ }
1599
+ throw new AuthAuditError("Auth flow header fetch failed after retry budget was exhausted");
1600
+ }
1601
+
1602
+ async function checkAuthFlowSecurity(input) {
1603
+ const requestId = String(input.requestId || createAuditRequestId());
1604
+ const loginUrlCandidate = input.loginUrl || input.url;
1605
+ if (!loginUrlCandidate) throw new AuthAuditError("check_auth_flow_security requires loginUrl or url");
1606
+ const allowPrivateTargets = input.allowPrivateTargets === true;
1607
+ const loginUrl = assertSecureAuthFlowTarget(loginUrlCandidate, { allowPrivateTargets, auditInput: input }).toString();
1608
+
1609
+ const findings = [];
1610
+ try {
1611
+ const { headers, finalUrl, crossOriginRedirect } = await fetchLoginHeaders(loginUrl, { allowPrivateTargets, auditInput: input });
1612
+
1613
+ if (crossOriginRedirect) {
1614
+ findings.push({
1615
+ severity: "P1",
1616
+ title: "Login flow redirects cross-origin before header checks",
1617
+ file: loginUrl,
1618
+ });
1619
+ }
1620
+
1621
+ if (!headers["strict-transport-security"]) {
1622
+ findings.push({ severity: "P1", title: "Login page missing HSTS header", file: finalUrl || loginUrl });
1623
+ }
1624
+ if (!headers["content-security-policy"]) {
1625
+ findings.push({ severity: "P2", title: "Login page missing CSP header", file: finalUrl || loginUrl });
1626
+ }
1627
+ if (headers["x-powered-by"]) {
1628
+ findings.push({
1629
+ severity: "P2",
1630
+ title: "Login page exposes X-Powered-By: " + headers["x-powered-by"],
1631
+ file: finalUrl || loginUrl,
1632
+ });
1633
+ }
1634
+ } catch (err) {
1635
+ if (err instanceof AuthAuditError && /HTTPS downgrade detected/.test(err.message)) {
1636
+ findings.push({
1637
+ severity: "P1",
1638
+ title: err.message,
1639
+ file: loginUrl,
1640
+ });
1641
+ }
1642
+ return {
1643
+ ...buildUnavailableAuditResponse(
1644
+ requestId,
1645
+ "AUTH_FLOW_CHECK_FAILED",
1646
+ "auth flow check failed: " + normalizeErrorMessage(err, "unknown error")
1647
+ ),
1648
+ loginUrl,
1649
+ findings,
1650
+ };
1651
+ }
1652
+ return { available: true, requestId, loginUrl, findings };
1653
+ }
1654
+
1655
+ async function fetchLoginHeaders(loginUrl, options = {}) {
1656
+ let currentUrl = loginUrl;
1657
+ const visitedUrls = new Set();
1658
+ let redirectCount = 0;
1659
+
1660
+ while (true) {
1661
+ if (redirectCount > MAX_AUTH_REDIRECT_HOPS) {
1662
+ throw new AuthAuditError(
1663
+ `Exceeded ${MAX_AUTH_REDIRECT_HOPS} redirects while checking auth flow (last=${currentUrl})`
1664
+ );
1665
+ }
1666
+ const currentParsedUrl = assertSecureAuthFlowTarget(currentUrl, options);
1667
+ if (visitedUrls.has(currentUrl)) {
1668
+ throw new AuthAuditError("Redirect loop detected while checking auth headers");
1669
+ }
1670
+ visitedUrls.add(currentUrl);
1671
+
1672
+ const response = await fetchLoginResponseWithRetry(currentUrl);
1673
+ const headers = Object.fromEntries(response.headers.entries());
1674
+
1675
+ if (response.status >= 300 && response.status < 400) {
1676
+ const location = response.headers.get("location");
1677
+ if (!location) {
1678
+ return { headers, finalUrl: currentUrl, crossOriginRedirect: false };
1679
+ }
1680
+ const nextParsedUrl = assertSecureAuthFlowTarget(new URL(location, currentParsedUrl).toString(), options);
1681
+ if (nextParsedUrl.origin !== currentParsedUrl.origin) {
1682
+ return { headers, finalUrl: currentUrl, crossOriginRedirect: true };
1683
+ }
1684
+ currentUrl = nextParsedUrl.toString();
1685
+ redirectCount += 1;
1686
+ continue;
1687
+ }
1688
+
1689
+ return { headers, finalUrl: currentUrl, crossOriginRedirect: false };
1690
+ }
1691
+ }
1692
+
1693
+ function resolveAuthAuditTarget(urlValue, input, operation) {
1694
+ try {
1695
+ const parsed = assertPermittedAuditTarget(urlValue, {
1696
+ operation,
1697
+ allowPrivateTargets: input.allowPrivateTargets === true,
1698
+ });
1699
+ assertApprovedAuthAuditTarget(parsed, input, operation);
1700
+ return parsed.toString();
1701
+ } catch (error) {
1702
+ throw new AuthAuditError(error.message);
1703
+ }
1704
+ }
1705
+
1706
+ export class AuthAuditError extends Error {
1707
+ constructor(message) { super(message); this.name = "AuthAuditError"; }
1708
+ }