security-mcp 1.1.4 → 1.3.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 (129) hide show
  1. package/README.md +116 -264
  2. package/defaults/checklists/ai.json +20 -1
  3. package/defaults/checklists/api.json +35 -1
  4. package/defaults/checklists/infra.json +34 -1
  5. package/defaults/checklists/mobile.json +23 -1
  6. package/defaults/checklists/payments.json +15 -1
  7. package/defaults/checklists/web.json +11 -1
  8. package/defaults/security-policy.json +2 -2
  9. package/dist/cli/index.js +0 -0
  10. package/dist/gate/baseline.js +82 -7
  11. package/dist/gate/catalog.js +10 -2
  12. package/dist/gate/checks/ai.js +757 -39
  13. package/dist/gate/checks/auth-deep.js +920 -216
  14. package/dist/gate/checks/business-logic.js +751 -0
  15. package/dist/gate/checks/ci-pipeline.js +399 -4
  16. package/dist/gate/checks/crypto.js +423 -2
  17. package/dist/gate/checks/dependencies.js +571 -15
  18. package/dist/gate/checks/graphql.js +201 -19
  19. package/dist/gate/checks/infra.js +246 -1
  20. package/dist/gate/checks/injection-deep.js +827 -184
  21. package/dist/gate/checks/k8s.js +114 -1
  22. package/dist/gate/checks/mobile-android.js +917 -3
  23. package/dist/gate/checks/mobile-ios.js +797 -5
  24. package/dist/gate/checks/required-artifacts.js +194 -0
  25. package/dist/gate/checks/runtime.js +178 -0
  26. package/dist/gate/checks/secrets.js +244 -13
  27. package/dist/gate/checks/supply-chain-deep.js +787 -0
  28. package/dist/gate/checks/web-nextjs.js +572 -48
  29. package/dist/gate/diff.js +17 -5
  30. package/dist/gate/evidence.js +8 -1
  31. package/dist/gate/exceptions.js +131 -9
  32. package/dist/gate/policy.js +280 -131
  33. package/dist/mcp/audit-chain.js +122 -28
  34. package/dist/mcp/auth.js +169 -0
  35. package/dist/mcp/learning.js +129 -4
  36. package/dist/mcp/model-router.js +158 -21
  37. package/dist/mcp/orchestration.js +186 -51
  38. package/dist/mcp/server.js +337 -53
  39. package/dist/repo/fs.js +24 -1
  40. package/dist/repo/search.js +31 -6
  41. package/dist/review/store.js +52 -1
  42. package/package.json +7 -7
  43. package/skills/_TEMPLATE/SKILL.md +99 -0
  44. package/skills/advanced-dos-tester/SKILL.md +109 -0
  45. package/skills/agentic-loop-exploiter/SKILL.md +368 -0
  46. package/skills/ai-llm-redteam/SKILL.md +104 -0
  47. package/skills/ai-model-supply-chain-agent/SKILL.md +103 -0
  48. package/skills/algorithm-implementation-reviewer/SKILL.md +98 -0
  49. package/skills/android-penetration-tester/SKILL.md +455 -46
  50. package/skills/anti-replay-tester/SKILL.md +106 -0
  51. package/skills/appsec-code-auditor/SKILL.md +85 -0
  52. package/skills/artifact-integrity-analyst/SKILL.md +441 -0
  53. package/skills/attack-navigator/SKILL.md +467 -8
  54. package/skills/auth-session-hacker/SKILL.md +102 -0
  55. package/skills/aws-penetration-tester/SKILL.md +456 -0
  56. package/skills/azure-penetration-tester/SKILL.md +490 -3
  57. package/skills/binary-auth-validator/SKILL.md +111 -0
  58. package/skills/bot-detection-specialist/SKILL.md +109 -0
  59. package/skills/business-logic-attacker/SKILL.md +231 -0
  60. package/skills/capec-code-mapper/SKILL.md +84 -0
  61. package/skills/cert-pin-rotation-specialist/SKILL.md +112 -0
  62. package/skills/cicd-pipeline-hijacker/SKILL.md +405 -0
  63. package/skills/ciso-orchestrator/SKILL.md +454 -43
  64. package/skills/cloud-infra-specialist/SKILL.md +118 -0
  65. package/skills/compliance-gap-analyst/SKILL.md +422 -0
  66. package/skills/compliance-grc/SKILL.md +85 -0
  67. package/skills/compliance-lifecycle-tracker/SKILL.md +84 -0
  68. package/skills/credential-stuffing-specialist/SKILL.md +102 -0
  69. package/skills/crypto-pki-specialist/SKILL.md +87 -0
  70. package/skills/csa-ccm-mapper/SKILL.md +84 -0
  71. package/skills/csf2-governance-mapper/SKILL.md +84 -0
  72. package/skills/deep-link-fuzzer/SKILL.md +109 -0
  73. package/skills/dependency-confusion-attacker/SKILL.md +415 -0
  74. package/skills/device-integrity-aggregator/SKILL.md +108 -0
  75. package/skills/dos-resilience-tester/SKILL.md +97 -0
  76. package/skills/dread-scorer/SKILL.md +84 -0
  77. package/skills/egress-policy-enforcer/SKILL.md +99 -0
  78. package/skills/evidence-collector/SKILL.md +98 -0
  79. package/skills/file-upload-attacker/SKILL.md +109 -0
  80. package/skills/gcp-penetration-tester/SKILL.md +459 -2
  81. package/skills/git-history-secret-scanner/SKILL.md +106 -0
  82. package/skills/iam-privesc-graph-builder/SKILL.md +152 -0
  83. package/skills/incident-responder/SKILL.md +111 -0
  84. package/skills/injection-specialist/SKILL.md +102 -0
  85. package/skills/ios-security-auditor/SKILL.md +282 -0
  86. package/skills/json-ambiguity-tester/SKILL.md +0 -0
  87. package/skills/k8s-container-escaper/SKILL.md +384 -0
  88. package/skills/key-management-lifecycle-analyst/SKILL.md +98 -0
  89. package/skills/kill-switch-engineer/SKILL.md +102 -0
  90. package/skills/linddun-privacy-analyst/SKILL.md +102 -0
  91. package/skills/logic-race-fuzzer/SKILL.md +443 -0
  92. package/skills/mobile-api-network-attacker/SKILL.md +421 -0
  93. package/skills/mobile-binary-hardener/SKILL.md +102 -0
  94. package/skills/mobile-security-specialist/SKILL.md +85 -0
  95. package/skills/mobile-webview-auditor/SKILL.md +96 -0
  96. package/skills/model-extraction-attacker/SKILL.md +219 -0
  97. package/skills/multipart-abuse-tester/SKILL.md +84 -0
  98. package/skills/oauth-pkce-specialist/SKILL.md +104 -0
  99. package/skills/parser-exhaustion-tester/SKILL.md +142 -0
  100. package/skills/pentest-infra/SKILL.md +98 -0
  101. package/skills/pentest-social/SKILL.md +201 -0
  102. package/skills/pentest-team/SKILL.md +87 -0
  103. package/skills/pentest-web-api/SKILL.md +98 -0
  104. package/skills/privacy-flow-analyst/SKILL.md +234 -0
  105. package/skills/prompt-injection-specialist/SKILL.md +394 -0
  106. package/skills/quantum-migration-planner/SKILL.md +96 -0
  107. package/skills/rag-poisoning-specialist/SKILL.md +358 -0
  108. package/skills/registry-mirror-enforcer/SKILL.md +84 -0
  109. package/skills/rotation-validation-agent/SKILL.md +112 -0
  110. package/skills/samm-assessor/SKILL.md +85 -0
  111. package/skills/secrets-mask-bypass-tester/SKILL.md +100 -0
  112. package/skills/senior-security-engineer/SKILL.md +167 -0
  113. package/skills/serialization-memory-attacker/SKILL.md +332 -0
  114. package/skills/session-timeout-tester/SKILL.md +161 -0
  115. package/skills/slsa-level3-enforcer/SKILL.md +112 -0
  116. package/skills/slsa-provenance-enforcer/SKILL.md +102 -0
  117. package/skills/ssrf-detection-validator/SKILL.md +108 -0
  118. package/skills/step-up-auth-enforcer/SKILL.md +84 -0
  119. package/skills/stride-pasta-analyst/SKILL.md +420 -0
  120. package/skills/supply-chain-devsecops/SKILL.md +98 -0
  121. package/skills/threat-infrastructure-analyst/SKILL.md +84 -0
  122. package/skills/threat-modeler/SKILL.md +85 -0
  123. package/skills/tls-certificate-auditor/SKILL.md +573 -18
  124. package/skills/token-reuse-detector/SKILL.md +95 -0
  125. package/skills/trike-risk-modeler/SKILL.md +84 -0
  126. package/skills/unicode-homograph-tester/SKILL.md +84 -0
  127. package/skills/waf-rule-lifecycle-agent/SKILL.md +97 -0
  128. package/skills/webhook-security-tester/SKILL.md +102 -0
  129. package/skills/zero-trust-architect/SKILL.md +109 -0
@@ -6,226 +6,930 @@
6
6
  import { sanitizeErrorMessage } from "../result.js";
7
7
  import { searchRepo } from "../../repo/search.js";
8
8
  const NON_CODE_RE = /\.(?:md|json|yaml|yml|txt|rst|toml|lock)$/i;
9
- export async function checkAuthDeep(_opts) {
9
+ function toEvidence(hits) {
10
+ return hits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`);
11
+ }
12
+ function toFiles(hits) {
13
+ return [...new Set(hits.slice(0, 10).map((m) => m.file))];
14
+ }
15
+ async function codeSearch(query) {
16
+ return (await searchRepo({ query, isRegex: true, maxMatches: 200 })).filter((h) => !NON_CODE_RE.test(h.file));
17
+ }
18
+ async function checkJwtAlgNone() {
19
+ const hits = await codeSearch(String.raw `jwt\.verify\s*\(`);
20
+ const findings = [];
21
+ // Missing algorithms array entirely
22
+ const missingAlg = hits.filter((h) => !/algorithms\s*:\s*\[/.test(h.preview));
23
+ if (missingAlg.length) {
24
+ findings.push({
25
+ id: "JWT_ALG_NONE_ACCEPTED",
26
+ title: "jwt.verify() called without explicit algorithms array — algorithm confusion attack possible (CWE-327)",
27
+ severity: "CRITICAL",
28
+ evidence: toEvidence(missingAlg),
29
+ files: toFiles(missingAlg),
30
+ requiredActions: [
31
+ "Always pass algorithms: ['RS256'] (or your actual algorithm) to jwt.verify().",
32
+ "CWE-327 — without algorithms pin, attacker can forge tokens using alg:none or switch RS256→HS256 using the public key as secret.",
33
+ "Fix: jwt.verify(token, publicKey, { algorithms: ['RS256'] })"
34
+ ]
35
+ });
36
+ }
37
+ // Explicit 'none' in algorithms array — case-insensitive to catch 'None', 'NONE', etc.
38
+ // The jsonwebtoken library lowercases the alg header before comparison, so 'None' and 'NONE'
39
+ // are functionally equivalent to 'none' (CVE-2022-23529 pattern). CWE-327.
40
+ const explicitNone = hits.filter((h) => /algorithms\s*:\s*\[.*['"]none['"].*\]/i.test(h.preview));
41
+ if (explicitNone.length) {
42
+ findings.push({
43
+ id: "JWT_ALG_NONE_EXPLICIT",
44
+ title: "jwt.verify() explicitly allows 'none' algorithm — unsigned tokens accepted (CWE-327)",
45
+ severity: "CRITICAL",
46
+ evidence: toEvidence(explicitNone),
47
+ files: toFiles(explicitNone),
48
+ requiredActions: [
49
+ "Remove 'none' from the algorithms array immediately.",
50
+ "CWE-327 — algorithms:['none'] allows any attacker to forge tokens by stripping the signature.",
51
+ "Fix: jwt.verify(token, secret, { algorithms: ['RS256'] }) // never include 'none'"
52
+ ]
53
+ });
54
+ }
55
+ // HS256 used with a key name suggesting RSA/public key material
56
+ const algConfusionExplicit = hits.filter((h) => /algorithms\s*:\s*\[/.test(h.preview) &&
57
+ /['"]HS256['"]/.test(h.preview) &&
58
+ /pub|public|cert|rsa/i.test(h.preview));
59
+ if (algConfusionExplicit.length) {
60
+ findings.push({
61
+ id: "JWT_ALG_CONFUSION_EXPLICIT",
62
+ title: "jwt.verify() uses HS256 with a key that appears to be an RSA/public key — algorithm confusion (CWE-327)",
63
+ severity: "CRITICAL",
64
+ evidence: toEvidence(algConfusionExplicit),
65
+ files: toFiles(algConfusionExplicit),
66
+ requiredActions: [
67
+ "Use RS256/ES256 when verifying with an RSA public key; HS256 is for symmetric secrets only.",
68
+ "CWE-327 — using HS256 with an RSA public key as the HMAC secret is the classic algorithm confusion exploit.",
69
+ "Fix: jwt.verify(token, publicKey, { algorithms: ['RS256'] })"
70
+ ]
71
+ });
72
+ }
73
+ return findings;
74
+ }
75
+ async function checkSessionFixation() {
76
+ const findings = [];
77
+ // Existing single-line check
78
+ const hits = await codeSearch(String.raw `(?:req\.session\.user|req\.session\.userId|req\.session\.account|req\.session\.authenticated)\s*=`);
79
+ const unsafeSingleLine = hits.filter((h) => !/req\.session\.regenerate|session\.regenerate\s*\(|req\.login\s*\(|lucia\.createSession|lucia\.invalidateSession/.test(h.preview));
80
+ if (unsafeSingleLine.length) {
81
+ findings.push({
82
+ id: "SESSION_FIXATION",
83
+ title: "Session identity set without session regeneration — session fixation risk (CWE-384)",
84
+ severity: "HIGH",
85
+ evidence: toEvidence(unsafeSingleLine),
86
+ files: toFiles(unsafeSingleLine),
87
+ requiredActions: [
88
+ "Call req.session.regenerate() before setting session identity after authentication.",
89
+ "CWE-384 — an attacker who fixes the session ID before login can hijack the authenticated session.",
90
+ "Fix: req.session.regenerate((err) => { req.session.userId = user.id; res.json({ ok: true }); });"
91
+ ]
92
+ });
93
+ }
94
+ // Multi-line check: session assignment without adjacent regeneration
95
+ const sessionAssignHits = await codeSearch(String.raw `req\.session\.\w+\s*=|session\.\w+\s*=`);
96
+ // Filter out hits that have passport req.login, lucia, or regenerate in the preview
97
+ const multiLineUnsafe = sessionAssignHits.filter((h) => !/req\.session\.regenerate|session\.regenerate\s*\(|req\.login\s*\(|lucia\.createSession|lucia\.invalidateSession|passport/.test(h.preview));
98
+ if (multiLineUnsafe.length) {
99
+ findings.push({
100
+ id: "SESSION_FIXATION_MULTILINE",
101
+ title: "Session property assigned without adjacent session regeneration — potential session fixation (CWE-384)",
102
+ severity: "HIGH",
103
+ evidence: toEvidence(multiLineUnsafe),
104
+ files: toFiles(multiLineUnsafe),
105
+ requiredActions: [
106
+ "Ensure req.session.regenerate() is called within 20 lines before any session identity assignment.",
107
+ "CWE-384 — session fixation allows an attacker who sets the session ID pre-login to hijack the post-login session.",
108
+ "Valid regeneration patterns: req.session.regenerate(), req.login() (Passport), lucia.createSession()."
109
+ ]
110
+ });
111
+ }
112
+ return findings;
113
+ }
114
+ async function checkOauthMissingState() {
115
+ const hits = await codeSearch(String.raw `(?:authorizationUrl|oauth\.authorize|passport\.authenticate\s*\(\s*['"]oauth|\.redirect\s*\(\s*['"]https:\/\/[^'"]*\/oauth\/authorize|\/oauth\/callback|\/auth\/callback)`);
116
+ const unsafe = hits.filter((h) => !/state\s*[:=]|generateState|crypto\.randomBytes|randomUUID|nonce/.test(h.preview));
117
+ if (!unsafe.length)
118
+ return null;
119
+ return {
120
+ id: "OAUTH_MISSING_STATE",
121
+ title: "OAuth flow without state parameter — CSRF on authorization callback (CWE-352)",
122
+ severity: "HIGH",
123
+ evidence: toEvidence(unsafe),
124
+ files: toFiles(unsafe),
125
+ requiredActions: [
126
+ "Generate a cryptographically random state parameter and verify it on the callback.",
127
+ "CWE-352 — without state, an attacker can inject their own authorization code into the victim's session.",
128
+ "Fix: const state = crypto.randomBytes(32).toString('hex'); session.oauthState = state; // verify on callback"
129
+ ]
130
+ };
131
+ }
132
+ async function checkOauthOpenRedirectUri() {
133
+ const hits = await codeSearch(String.raw `redirect_uri.*(?:\.includes\s*\(|\.startsWith\s*\(|\.match\s*\(|indexOf\s*\()|(?:\.includes\s*\(|\.startsWith\s*\().*redirect_uri`);
134
+ if (!hits.length)
135
+ return null;
136
+ return {
137
+ id: "OAUTH_OPEN_REDIRECT_URI",
138
+ title: "OAuth redirect_uri validated with includes/startsWith — open redirect via subdomain (CWE-601)",
139
+ severity: "HIGH",
140
+ evidence: toEvidence(hits),
141
+ files: toFiles(hits),
142
+ requiredActions: [
143
+ "Validate redirect_uri with exact string equality against a pre-registered allowlist.",
144
+ "CWE-601 — startsWith('https://example.com') allows https://example.com.evil.com/.",
145
+ "Fix: if (redirectUri !== REGISTERED_REDIRECT_URI) throw new Error('Invalid redirect_uri');"
146
+ ]
147
+ };
148
+ }
149
+ async function checkOauthImplicitFlow() {
150
+ const hits = await codeSearch(String.raw `response_type\s*[=:]\s*['"]token['"]|responseType\s*:\s*['"]token['"]`);
151
+ if (!hits.length)
152
+ return null;
153
+ return {
154
+ id: "OAUTH_IMPLICIT_FLOW",
155
+ title: "OAuth implicit flow (response_type=token) exposes tokens in URL fragments (CWE-319)",
156
+ severity: "HIGH",
157
+ evidence: toEvidence(hits),
158
+ files: toFiles(hits),
159
+ requiredActions: [
160
+ "Replace implicit flow with authorization code flow + PKCE for all public clients and SPAs.",
161
+ "OAuth 2.0 BCP (RFC 9700) — implicit flow exposes access tokens in URL fragments, browser history, and Referer headers.",
162
+ "Fix: response_type=code with code_challenge_method=S256; exchange code for tokens server-side or via PKCE."
163
+ ]
164
+ };
165
+ }
166
+ async function checkPkceNotEnforced() {
167
+ const hits = await codeSearch(String.raw `(?:authorization_code|grant_type.*authorization_code|code.*exchange|token.*endpoint.*code\b)`);
168
+ const unsafe = hits.filter((h) => !/code_challenge|code_verifier|pkce|PKCE/.test(h.preview));
169
+ if (!unsafe.length)
170
+ return null;
171
+ return {
172
+ id: "PKCE_NOT_ENFORCED",
173
+ title: "OAuth authorization code flow without PKCE — code interception attack (RFC 7636)",
174
+ severity: "HIGH",
175
+ evidence: toEvidence(unsafe),
176
+ files: toFiles(unsafe),
177
+ requiredActions: [
178
+ "Require PKCE (code_challenge_method=S256) for all public clients and SPAs.",
179
+ "RFC 7636 / ATT&CK T1528 — without PKCE, a stolen authorization code can be exchanged for tokens.",
180
+ "Fix: enforce code_challenge in the /authorize handler and verify code_verifier in /token exchange."
181
+ ]
182
+ };
183
+ }
184
+ async function checkHardcodedJwtSecret() {
185
+ const hits = await codeSearch(String.raw `jwt\.sign\s*\([^,]+,\s*['"][a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{1,32}['"]|jwt\.verify\s*\([^,]+,\s*['"][a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{1,32}['"]`);
186
+ if (!hits.length)
187
+ return null;
188
+ return {
189
+ id: "HARDCODED_JWT_SECRET",
190
+ title: "Hardcoded JWT secret literal — secret exposed in source code (CWE-798)",
191
+ severity: "CRITICAL",
192
+ evidence: toEvidence(hits),
193
+ files: toFiles(hits),
194
+ requiredActions: [
195
+ "Move JWT secrets to environment variables or a secrets manager; never commit them to source.",
196
+ "CWE-798 — hardcoded secrets are trivially extracted from git history and Docker images.",
197
+ "Fix: jwt.sign(payload, process.env.JWT_SECRET!, { algorithms: ['RS256'] })"
198
+ ]
199
+ };
200
+ }
201
+ async function checkJwtMissingExpiry() {
202
+ const findings = [];
203
+ // jwt.sign() without expiresIn in options
204
+ const signHits = await codeSearch(String.raw `jwt\.sign\s*\(`);
205
+ const missingExpiry = signHits.filter((h) => !/expiresIn\s*:/.test(h.preview));
206
+ if (missingExpiry.length) {
207
+ findings.push({
208
+ id: "JWT_MISSING_EXPIRY",
209
+ title: "jwt.sign() called without expiresIn — tokens never expire (CWE-613)",
210
+ severity: "HIGH",
211
+ evidence: toEvidence(missingExpiry),
212
+ files: toFiles(missingExpiry),
213
+ requiredActions: [
214
+ "Always set an expiry on JWTs: jwt.sign(payload, secret, { expiresIn: '1h' }).",
215
+ "CWE-613 — a JWT without expiresIn remains valid indefinitely, even after account compromise.",
216
+ "Fix: jwt.sign(payload, process.env.JWT_SECRET!, { algorithms: ['RS256'], expiresIn: '1h' })"
217
+ ]
218
+ });
219
+ }
220
+ // API key / token ORM creation without expiresAt / expiresIn
221
+ const tokenCreateHits = await codeSearch(String.raw `(?:apiToken|apiKey|personalToken|accessToken)\s*=.*\.create\s*\(\s*\{|\.create\s*\(\s*\{[^}]*(?:apiToken|apiKey|personalToken|accessToken)`);
222
+ const missingTokenExpiry = tokenCreateHits.filter((h) => !/expiresAt|expiresIn|expires_at|expires_in/i.test(h.preview));
223
+ if (missingTokenExpiry.length) {
224
+ findings.push({
225
+ id: "TOKEN_MISSING_EXPIRY",
226
+ title: "API token created without expiry field — long-lived credentials increase breach impact (CWE-613)",
227
+ severity: "MEDIUM",
228
+ evidence: toEvidence(missingTokenExpiry),
229
+ files: toFiles(missingTokenExpiry),
230
+ requiredActions: [
231
+ "Include an expiresAt or expiresIn field when creating API tokens and enforce it on every use.",
232
+ "CWE-613 — tokens without expiry remain valid indefinitely after a credential leak.",
233
+ "Fix: await db.apiTokens.create({ userId, token, expiresAt: new Date(Date.now() + 90 * 86400000) })"
234
+ ]
235
+ });
236
+ }
237
+ return findings;
238
+ }
239
+ async function checkMissingRateLimitLogin() {
240
+ const hits = await codeSearch(String.raw `(?:router|app)\.post\s*\(\s*['"][^'"]*(?:\/login|\/signin|\/auth|\/token|\/session|\/mfa|\/otp|\/totp|\/2fa|\/verify|\/reset|\/forgot|\/confirm|\/unlock|\/activate|\/resend)['"]\s*,`);
241
+ const unsafe = hits.filter((h) => !/rateLimit|rateLimiter|rate_limit|limiter|throttle|slowDown|expressRateLimit/.test(h.preview));
242
+ if (!unsafe.length)
243
+ return null;
244
+ return {
245
+ id: "MISSING_RATE_LIMIT_LOGIN",
246
+ title: "Authentication or MFA/OTP endpoint without rate limiting — brute force attack surface (CWE-307)",
247
+ severity: "HIGH",
248
+ evidence: toEvidence(unsafe),
249
+ files: toFiles(unsafe),
250
+ requiredActions: [
251
+ "Apply express-rate-limit or equivalent middleware to all authentication, MFA, OTP, and account-recovery endpoints.",
252
+ "CWE-307 — without rate limiting, brute force or credential stuffing attacks are unrestricted.",
253
+ "Fix: app.post('/login', loginRateLimiter, authHandler); // max: 5 attempts per 15 minutes"
254
+ ]
255
+ };
256
+ }
257
+ async function checkPasswordPlainCompare() {
258
+ const hits = await codeSearch(String.raw `password\s*===\s*(?:req\.|user\.|stored|db\.|record\.)|(?:req\.|body\.)password\s*===\s*|password\s*==\s*(?:req\.|user\.|stored|db\.)|compareSync\s*\(\s*(?:req\.|body\.)`);
259
+ const unsafe = hits.filter((h) => !/bcrypt|argon2|scrypt|pbkdf2|timingSafeEqual|compare\s*\(/i.test(h.preview));
260
+ if (!unsafe.length)
261
+ return null;
262
+ return {
263
+ id: "PASSWORD_PLAIN_COMPARE",
264
+ title: "Plaintext password comparison — no hashing or timing oracle (CWE-256)",
265
+ severity: "CRITICAL",
266
+ evidence: toEvidence(unsafe),
267
+ files: toFiles(unsafe),
268
+ requiredActions: [
269
+ "Use bcrypt.compare() or argon2.verify() for password verification — never === comparison.",
270
+ "CWE-256 — plaintext comparison leaks timing information and stores passwords without hashing.",
271
+ "Fix: const valid = await bcrypt.compare(password, user.passwordHash); if (!valid) throw new Error('Unauthorized');"
272
+ ]
273
+ };
274
+ }
275
+ async function checkSamlSignatureDisabled() {
276
+ const hits = await codeSearch(String.raw `(?:new\s+saml\.Strategy|passport-saml|samlify|node-saml|SAMLResponse|validateSignature\s*:\s*false|wantAssertionsSigned\s*:\s*false|signatureAlgorithm\s*:\s*['"]none['"])`);
277
+ const unsafe = hits.filter((h) => /validateSignature\s*:\s*false|wantAssertionsSigned\s*:\s*false|signatureAlgorithm\s*:\s*['"]none['"]/.test(h.preview));
278
+ if (!unsafe.length)
279
+ return null;
280
+ return {
281
+ id: "SAML_SIGNATURE_NOT_ENFORCED",
282
+ title: "SAML signature validation disabled — SAML response forgery (CWE-347)",
283
+ severity: "CRITICAL",
284
+ evidence: toEvidence(unsafe),
285
+ files: toFiles(unsafe),
286
+ requiredActions: [
287
+ "Set validateSignature:true and wantAssertionsSigned:true in all SAML strategy configurations.",
288
+ "CWE-347 — unsigned SAML responses allow any user to craft an assertion claiming to be any other user.",
289
+ "Fix: new SamlStrategy({ validateSignature: true, wantAssertionsSigned: true, cert: IDP_CERT }, ...)"
290
+ ]
291
+ };
292
+ }
293
+ async function checkSamlXsw() {
294
+ const findings = [];
295
+ // Detect SAML library usage
296
+ const samlLibHits = await codeSearch(String.raw `require\s*\(\s*['"](?:saml2-js|passport-saml|@node-saml\/passport-saml|@node-saml|samlify|saml-encoder)['"]`);
297
+ if (!samlLibHits.length)
298
+ return findings;
299
+ const samlFiles = toFiles(samlLibHits);
300
+ // Check for missing InResponseTo validation
301
+ const inResponseToHits = await codeSearch(String.raw `validateInResponseTo|InResponseToCheck`);
302
+ if (!inResponseToHits.length) {
303
+ findings.push({
304
+ id: "SAML_MISSING_INRESPONSETO",
305
+ title: "SAML library used without validateInResponseTo — open to unsolicited response injection (CWE-347)",
306
+ severity: "HIGH",
307
+ evidence: toEvidence(samlLibHits),
308
+ files: samlFiles,
309
+ requiredActions: [
310
+ "Enable validateInResponseTo: true in your SAML strategy configuration.",
311
+ "CWE-347 — without InResponseTo validation, an attacker can inject a valid SAML response from a different SP session.",
312
+ "Fix: new SamlStrategy({ validateInResponseTo: 'always', ... }, ...)"
313
+ ]
314
+ });
315
+ }
316
+ // Check for allowUnsolicitedResponses: true
317
+ const unsolicitedHits = await codeSearch(String.raw `allowUnsolicitedResponses\s*:\s*true`);
318
+ if (unsolicitedHits.length) {
319
+ findings.push({
320
+ id: "SAML_UNSOLICITED_RESPONSE_ALLOWED",
321
+ title: "SAML allowUnsolicitedResponses:true — IdP-initiated SSO enables XSW and session injection (CWE-347)",
322
+ severity: "CRITICAL",
323
+ evidence: toEvidence(unsolicitedHits),
324
+ files: toFiles(unsolicitedHits),
325
+ requiredActions: [
326
+ "Set allowUnsolicitedResponses: false and require InResponseTo validation.",
327
+ "CWE-347 — unsolicited SAML responses bypass InResponseTo checks, enabling XML Signature Wrapping attacks.",
328
+ "Fix: new SamlStrategy({ allowUnsolicitedResponses: false, validateInResponseTo: 'always' }, ...)"
329
+ ]
330
+ });
331
+ }
332
+ // Check for unsigned assertions/responses
333
+ const signedFalseHits = await codeSearch(String.raw `wantAuthnResponseSigned\s*:\s*false|wantAssertionsSigned\s*:\s*false`);
334
+ if (signedFalseHits.length) {
335
+ findings.push({
336
+ id: "SAML_RESPONSE_UNSIGNED",
337
+ title: "SAML wantAuthnResponseSigned or wantAssertionsSigned set to false — forged assertions accepted (CWE-347)",
338
+ severity: "CRITICAL",
339
+ evidence: toEvidence(signedFalseHits),
340
+ files: toFiles(signedFalseHits),
341
+ requiredActions: [
342
+ "Set wantAuthnResponseSigned: true and wantAssertionsSigned: true in all SAML configurations.",
343
+ "CWE-347 — disabling signature requirements allows an attacker to forge arbitrary SAML assertions.",
344
+ "Fix: new SamlStrategy({ wantAuthnResponseSigned: true, wantAssertionsSigned: true, ... }, ...)"
345
+ ]
346
+ });
347
+ }
348
+ // Check for XMLDOM xpath getElementsByTagName without signature verification
349
+ const xpathHits = await codeSearch(String.raw `getElementsByTagName\s*\(`);
350
+ const xpathUnsafe = xpathHits.filter((h) => !/validateSignature|verifySignature|checkSignature|SignedInfo|xmldsig/.test(h.preview));
351
+ if (xpathUnsafe.length) {
352
+ findings.push({
353
+ // Distinct ID from the aggregate SAML_XSW_RISK below to avoid dedup dropping
354
+ // the more actionable aggregate finding when both conditions fire simultaneously.
355
+ id: "SAML_XSW_XPATH_RISK",
356
+ title: "SAML XML parsed with getElementsByTagName without per-element signature verification — XSW attack vector (CWE-347)",
357
+ severity: "HIGH",
358
+ evidence: toEvidence(xpathUnsafe),
359
+ files: toFiles(xpathUnsafe),
360
+ requiredActions: [
361
+ "Verify the XML signature on the specific element returned by getElementsByTagName before trusting its content.",
362
+ "CWE-347 / XSW — XML Signature Wrapping attacks move the signed element to a different location; always verify after selection.",
363
+ "Fix: use xml-crypto or saml-validated methods that verify signature on the exact element before attribute extraction."
364
+ ]
365
+ });
366
+ }
367
+ // Aggregate XSW risk: SAML without full protection set
368
+ const hasInResponseTo = inResponseToHits.length > 0;
369
+ const hasWantResponseSigned = (await codeSearch(String.raw `wantAuthnResponseSigned\s*:\s*true`)).length > 0;
370
+ const hasWantAssertionsSigned = (await codeSearch(String.raw `wantAssertionsSigned\s*:\s*true`)).length > 0;
371
+ if (!hasInResponseTo || !hasWantResponseSigned || !hasWantAssertionsSigned) {
372
+ findings.push({
373
+ id: "SAML_XSW_RISK",
374
+ title: "SAML used without full XSW protection (validateInResponseTo + wantAuthnResponseSigned + wantAssertionsSigned) — XML Signature Wrapping risk (CWE-347)",
375
+ severity: "CRITICAL",
376
+ evidence: toEvidence(samlLibHits),
377
+ files: samlFiles,
378
+ requiredActions: [
379
+ "Ensure all three protections are enabled: validateInResponseTo: 'always', wantAuthnResponseSigned: true, wantAssertionsSigned: true.",
380
+ "CWE-347 — partial SAML protections leave XML Signature Wrapping (XSW) attack surface open.",
381
+ "Fix: new SamlStrategy({ validateInResponseTo: 'always', wantAuthnResponseSigned: true, wantAssertionsSigned: true, cert: IDP_CERT }, ...)"
382
+ ]
383
+ });
384
+ }
385
+ // Deduplicate by id, keeping first occurrence
386
+ const seen = new Set();
387
+ return findings.filter((f) => {
388
+ if (seen.has(f.id))
389
+ return false;
390
+ seen.add(f.id);
391
+ return true;
392
+ });
393
+ }
394
+ async function checkSamlReplay() {
395
+ // Detect SAML library usage
396
+ const samlLibHits = await codeSearch(String.raw `require\s*\(\s*['"](?:saml2-js|passport-saml|@node-saml\/passport-saml|@node-saml|samlify|saml-encoder)['"]`);
397
+ if (!samlLibHits.length)
398
+ return null;
399
+ // Check for replay prevention: assertion ID caching or NotOnOrAfter tracking
400
+ const replayPreventionHits = await codeSearch(String.raw `assertionId|NotOnOrAfter|InResponseTo.*cache|assertionCache|replayCache|usedAssertions|seenIds`);
401
+ if (replayPreventionHits.length)
402
+ return null;
403
+ return {
404
+ id: "SAML_REPLAY_NOT_PREVENTED",
405
+ title: "SAML library used without assertion replay prevention — replayed assertions accepted (CWE-294)",
406
+ severity: "HIGH",
407
+ evidence: toEvidence(samlLibHits),
408
+ files: toFiles(samlLibHits),
409
+ requiredActions: [
410
+ "Implement assertion ID caching: store each assertion's ID with a TTL matching the NotOnOrAfter window; reject duplicate IDs.",
411
+ "CWE-294 — without replay prevention, a captured SAML assertion can be replayed to authenticate as the victim until the assertion expires.",
412
+ "Fix: if (assertionCache.has(assertionId)) throw new Error('Replayed assertion'); assertionCache.set(assertionId, true, ttl);"
413
+ ]
414
+ };
415
+ }
416
+ async function checkJwtHsRsConfusion() {
10
417
  const findings = [];
11
- const codeSearch = async (query) => (await searchRepo({ query, isRegex: true, maxMatches: 200 })).filter(h => !NON_CODE_RE.test(h.file));
418
+ // Existing pattern: explicit public key variable names
419
+ const publicKeyHits = await codeSearch(String.raw `jwt\.verify\s*\(\s*[^,]+,\s*(?:publicKey|PUBLIC_KEY|pub_key|process\.env\.[A-Z_]*PUBLIC|fs\.readFileSync[^)]*\.pem)`);
420
+ const unsafePublicKey = publicKeyHits.filter((h) => !/algorithms\s*:\s*\[\s*['"](?:RS|ES|PS)/.test(h.preview));
421
+ if (unsafePublicKey.length) {
422
+ findings.push({
423
+ id: "JWT_HS_RS_CONFUSION",
424
+ title: "JWT verified with public key without algorithm pin — HS/RS confusion attack (CVE-2015-9235 pattern)",
425
+ severity: "CRITICAL",
426
+ evidence: toEvidence(unsafePublicKey),
427
+ files: toFiles(unsafePublicKey),
428
+ requiredActions: [
429
+ "Pin the algorithm to RS256/ES256 explicitly: jwt.verify(token, publicKey, { algorithms: ['RS256'] }).",
430
+ "Without algorithm pin: attacker signs token with HS256 using the RS256 public key as HMAC secret — library accepts it.",
431
+ "This is CVE-2015-9235 — still exploitable in jsonwebtoken < 9.0 without the algorithms option."
432
+ ]
433
+ });
434
+ }
435
+ // New pattern: any jwt.verify() without algorithms array locked to asymmetric algorithm
436
+ const allVerifyHits = await codeSearch(String.raw `jwt\.verify\s*\(`);
437
+ const notLocked = allVerifyHits.filter((h) => !/algorithms\s*:\s*\[\s*['"](?:RS|ES|PS)/.test(h.preview) &&
438
+ !/algorithms\s*:\s*\[/.test(h.preview));
439
+ if (notLocked.length) {
440
+ findings.push({
441
+ id: "JWT_ALG_NOT_LOCKED",
442
+ title: "jwt.verify() without algorithms array locked to an asymmetric algorithm — algorithm confusion vector (CWE-327)",
443
+ severity: "HIGH",
444
+ evidence: toEvidence(notLocked),
445
+ files: toFiles(notLocked),
446
+ requiredActions: [
447
+ "Explicitly set algorithms: ['RS256'] or ['ES256'] (or your asymmetric algorithm) in jwt.verify() options.",
448
+ "CWE-327 — without an algorithm pin, the library will accept whatever algorithm the token header specifies.",
449
+ "Fix: jwt.verify(token, publicKey, { algorithms: ['RS256'] })"
450
+ ]
451
+ });
452
+ }
453
+ // New pattern: jwt.verify() with process.env.* secret and no algorithm pin (confusion vector via env var holding public key)
454
+ const envVarHits = await codeSearch(String.raw `jwt\.verify\s*\(\s*\w+,\s*process\.env\.\w+`);
455
+ const envUnsafe = envVarHits.filter((h) => !/algorithms\s*:\s*\[\s*['"](?:RS|ES|PS)/.test(h.preview));
456
+ if (envUnsafe.length) {
457
+ findings.push({
458
+ id: "JWT_ALG_CONFUSION_RISK",
459
+ title: "jwt.verify() uses process.env secret without asymmetric algorithm pin — env var may hold public key (CWE-327)",
460
+ severity: "HIGH",
461
+ evidence: toEvidence(envUnsafe),
462
+ files: toFiles(envUnsafe),
463
+ requiredActions: [
464
+ "If the env var holds an RSA public key, pin to RS256: jwt.verify(token, process.env.PUBLIC_KEY, { algorithms: ['RS256'] }).",
465
+ "CWE-327 — when an RSA public key is stored in a generic env var, HS256 confusion attacks are possible without an algorithm pin.",
466
+ "Fix: jwt.verify(token, process.env.JWT_PUBLIC_KEY!, { algorithms: ['RS256'] })"
467
+ ]
468
+ });
469
+ }
470
+ // Deduplicate by id
471
+ const seen = new Set();
472
+ return findings.filter((f) => {
473
+ if (seen.has(f.id))
474
+ return false;
475
+ seen.add(f.id);
476
+ return true;
477
+ });
478
+ }
479
+ async function checkApiKeyInUrl() {
480
+ const hits = await codeSearch(String.raw `(?:req\.query\.|query\.\b)(?:api_key|apikey|access_token|token|key|secret|auth|authorization)\b`);
481
+ if (!hits.length)
482
+ return null;
483
+ return {
484
+ id: "API_KEY_IN_URL",
485
+ title: "API key or token transmitted in URL query parameter — logged in plaintext (CWE-598)",
486
+ severity: "HIGH",
487
+ evidence: toEvidence(hits),
488
+ files: toFiles(hits),
489
+ requiredActions: [
490
+ "Transmit API keys and tokens exclusively in the Authorization header or a POST body, never in query parameters.",
491
+ "CWE-598 — query parameters appear in server access logs, browser history, Referer headers, and CDN logs.",
492
+ "Fix: const token = req.headers['authorization']?.replace('Bearer ', ''); // never req.query.token"
493
+ ]
494
+ };
495
+ }
496
+ async function checkPasswordResetNoExpiry() {
497
+ const hits = await codeSearch(String.raw `(?:resetToken|reset_token|passwordResetToken|forgotToken|verificationToken)\s*(?:===|==)\s*(?:req\.|body\.|params\.|token\b)`);
498
+ const unsafe = hits.filter((h) => !/expir|ttl|expiresAt|Date\.now|createdAt.*<|isExpired|maxAge/i.test(h.preview));
499
+ if (!unsafe.length)
500
+ return null;
501
+ return {
502
+ id: "PASSWORD_RESET_NO_EXPIRY",
503
+ title: "Password reset token compared without expiry check — indefinitely valid tokens (CWE-640)",
504
+ severity: "HIGH",
505
+ evidence: toEvidence(unsafe),
506
+ files: toFiles(unsafe),
507
+ requiredActions: [
508
+ "Enforce a maximum reset token lifetime (≤ 1 hour) and invalidate the token after first use.",
509
+ "CWE-640 — an unexpired reset token from a breached database allows permanent account takeover.",
510
+ "Fix: if (user.resetTokenExpiry < Date.now()) throw new Error('Token expired'); // then delete token on use"
511
+ ]
512
+ };
513
+ }
514
+ async function checkAdminRouteNoAuthz() {
515
+ const hits = await codeSearch(String.raw `(?:router|app)\.(?:get|post|put|patch|delete)\s*\(\s*['"][^'"]*(?:\/admin|\/internal|\/debug|\/\_|\/__)/`);
516
+ const unsafe = hits.filter((h) => !/requireAdmin|isAdmin|adminAuth|checkAdmin|authorize.*admin|role.*admin|admin.*role|verifyAdmin|adminMiddleware/.test(h.preview));
517
+ if (!unsafe.length)
518
+ return null;
519
+ return {
520
+ id: "ADMIN_ROUTE_NO_AUTHZ",
521
+ title: "Admin or internal route without authorization middleware — broken function-level authorization (CWE-862)",
522
+ severity: "CRITICAL",
523
+ evidence: toEvidence(unsafe),
524
+ files: toFiles(unsafe),
525
+ requiredActions: [
526
+ "Apply an authorization middleware that verifies admin role before registering any /admin or /internal route.",
527
+ "CWE-862 / ATT&CK T1078 — routes without function-level authz are reachable by any authenticated user.",
528
+ "Fix: router.use('/admin', requireAdminRole); // placed BEFORE route handlers, not after"
529
+ ]
530
+ };
531
+ }
532
+ async function checkTimingOracle() {
533
+ const hits = await codeSearch(String.raw `(?:otp|pin|code|token|secret|apiKey|api_key)\s*===\s*(?:req\.|body\.|params\.|query\.|provided|input)|(?:req\.|body\.|params\.)(?:otp|pin|code|mfa|totp|hotp)\s*===`);
534
+ const unsafe = hits.filter((h) => !/timingSafeEqual|safeCompare|crypto\.timingSafeEqual|subtle\.timingSafeEqual/.test(h.preview));
535
+ if (!unsafe.length)
536
+ return null;
537
+ return {
538
+ id: "TIMING_ORACLE_COMPARISON",
539
+ title: "Security token compared with === — timing oracle leaks token length and prefix (CWE-208)",
540
+ severity: "HIGH",
541
+ evidence: toEvidence(unsafe),
542
+ files: toFiles(unsafe),
543
+ requiredActions: [
544
+ "Use crypto.timingSafeEqual() for all security-critical equality comparisons.",
545
+ "CWE-208 — string === short-circuits on the first differing byte, leaking token contents via response time.",
546
+ "Fix: const a = Buffer.from(provided); const b = Buffer.from(stored); a.length === b.length && timingSafeEqual(a, b);"
547
+ ]
548
+ };
549
+ }
550
+ async function checkCookieSecureFlags() {
551
+ const hits = await codeSearch(String.raw `res\.cookie\s*\(\s*['"][^'"]+['"]`);
552
+ const unsafe = hits.filter((h) => !/httpOnly\s*:\s*true/.test(h.preview) || !/secure\s*:\s*true/.test(h.preview));
553
+ if (!unsafe.length)
554
+ return null;
555
+ return {
556
+ id: "COOKIE_MISSING_SECURE_FLAGS",
557
+ title: "Cookie set without httpOnly and/or secure flags (CWE-1004 / CWE-614)",
558
+ severity: "HIGH",
559
+ evidence: toEvidence(unsafe),
560
+ files: toFiles(unsafe),
561
+ requiredActions: [
562
+ "Set httpOnly:true, secure:true, and sameSite:'Strict' on all authentication and session cookies.",
563
+ "CWE-1004/CWE-614 — missing httpOnly enables XSS cookie theft; missing secure sends cookie over HTTP.",
564
+ "Fix: res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 3600000 });"
565
+ ]
566
+ };
567
+ }
568
+ async function checkAccountLockout() {
569
+ const loginHits = await codeSearch(String.raw `(?:router|app)\.post\s*\(\s*['"][^'"]*(?:\/login|\/signin|\/auth\/local|\/session)['"]\s*,`);
570
+ if (!loginHits.length)
571
+ return null;
572
+ const lockoutHits = await codeSearch(String.raw `failedAttempts|loginAttempts|lockoutUntil|accountLocked|lockedAt|bruteForce|maxAttempts|attempt[Cc]ount`);
573
+ if (lockoutHits.length)
574
+ return null;
575
+ return {
576
+ id: "ACCOUNT_LOCKOUT_MISSING",
577
+ title: "Login endpoint found but no account lockout counter detected — brute-force persistence risk (CWE-307 / NIST IA-5(1))",
578
+ severity: "MEDIUM",
579
+ evidence: loginHits.slice(0, 5).map((h) => `${h.file}:${h.line}:${h.preview}`),
580
+ files: [...new Set(loginHits.slice(0, 5).map((h) => h.file))],
581
+ requiredActions: [
582
+ "Track failed login attempts per account and lock the account after a configurable threshold (e.g., 5 attempts).",
583
+ "CWE-307 / NIST IA-5(1) — rate limiting prevents brute-force per IP but does not prevent distributed credential stuffing across IPs.",
584
+ "Fix: increment failedAttempts on each failed login; if failedAttempts >= MAX_ATTEMPTS set lockoutUntil = Date.now() + 15 * 60 * 1000; reject logins when locked."
585
+ ]
586
+ };
587
+ }
588
+ async function checkRefreshTokenNotRotated() {
589
+ const hits = await codeSearch(String.raw `(?:refresh_token|refreshToken)\s*[:=](?:.*jwt\.sign|.*generateToken|.*createToken|.*sign\s*\()|(?:grantType|grant_type)\s*[:=]\s*['"]refresh_token['"]`);
590
+ const unsafe = hits.filter((h) => !/delete|revoke|invalidate|blacklist|rotateToken|revokeToken|tokenFamily|REFRESH_TOKEN_FAMILY/.test(h.preview));
591
+ if (!unsafe.length)
592
+ return null;
593
+ return {
594
+ id: "REFRESH_TOKEN_NOT_ROTATED",
595
+ title: "Refresh token issued without revoking previous token — replay attack surface (CWE-613)",
596
+ severity: "HIGH",
597
+ evidence: toEvidence(unsafe),
598
+ files: toFiles(unsafe),
599
+ requiredActions: [
600
+ "Implement refresh token rotation: invalidate the old token before issuing the new one.",
601
+ "CWE-613 — without rotation, a stolen refresh token remains valid indefinitely.",
602
+ "Fix: await db.refreshTokens.delete(oldToken); const newToken = issueRefreshToken(user);"
603
+ ]
604
+ };
605
+ }
606
+ // ---------------------------------------------------------------------------
607
+ // OWASP A09 / NIST AU-11 / PCI Req 10 — Observability checks
608
+ // ---------------------------------------------------------------------------
609
+ async function checkMissingStructuredLogging() {
610
+ const findings = [];
611
+ // Detect web framework usage
612
+ const webFrameworkHits = await codeSearch(String.raw `require\s*\(\s*['"](?:express|fastify|koa)['"]\s*\)|from\s+['"](?:express|fastify|koa)['"]`);
613
+ if (!webFrameworkHits.length)
614
+ return findings;
615
+ // Detect structured logger
616
+ const loggerHits = await codeSearch(String.raw `require\s*\(\s*['"](?:pino|morgan|winston|bunyan)['"]\s*\)|from\s+['"](?:pino|winston|morgan|bunyan)['"]`);
617
+ if (!loggerHits.length) {
618
+ findings.push({
619
+ id: "MISSING_STRUCTURED_LOGGING",
620
+ title: "No structured logging library detected (pino, winston, morgan, bunyan). OWASP A09 requires logging security events.",
621
+ severity: "HIGH",
622
+ evidence: toEvidence(webFrameworkHits),
623
+ files: toFiles(webFrameworkHits),
624
+ requiredActions: [
625
+ "Install a structured logging library (pino, winston, morgan, or bunyan) and integrate it with the web framework.",
626
+ "OWASP A09 — without structured logs, authentication failures, authorization errors, and anomalies cannot be detected or alerted on.",
627
+ "Fix: import pino from 'pino'; const logger = pino(); app.use(pinoHttp({ logger }));"
628
+ ]
629
+ });
630
+ return findings;
631
+ }
632
+ // Logger found — check for .error( or .warn( near auth endpoints
633
+ const authRouteHits = await codeSearch(String.raw `(?:router|app)\.(?:post|get|put|patch|delete)\s*\(\s*['"][^'"]*(?:\/login|\/auth|\/token)['"]\s*,`);
634
+ if (!authRouteHits.length)
635
+ return findings;
636
+ const authLogHits = await codeSearch(String.raw `\.(?:error|warn)\s*\([^)]*(?:login|auth|token|unauthorized|forbidden|invalid|fail|deny|reject)`);
637
+ if (!authLogHits.length) {
638
+ findings.push({
639
+ id: "AUTH_EVENTS_NOT_LOGGED",
640
+ title: "Auth endpoints detected but no .error()/.warn() calls found near login/auth/token routes — security events may not be logged",
641
+ severity: "MEDIUM",
642
+ evidence: toEvidence(authRouteHits),
643
+ files: toFiles(authRouteHits),
644
+ requiredActions: [
645
+ "Add structured log calls (logger.warn / logger.error) for authentication failures, authorization denials, and anomalous requests.",
646
+ "OWASP A09 — unlogged auth failures prevent detection of credential stuffing and brute-force attacks.",
647
+ "Fix: logger.warn({ userId, ip: req.ip }, 'Authentication failed — invalid credentials');"
648
+ ]
649
+ });
650
+ }
651
+ return findings;
652
+ }
653
+ async function checkLogRetentionConfig() {
654
+ const findings = [];
655
+ // Only run if a logger is configured
656
+ const loggerHits = await codeSearch(String.raw `require\s*\(\s*['"](?:pino|morgan|winston|bunyan)['"]\s*\)|from\s+['"](?:pino|winston|morgan|bunyan)['"]`);
657
+ if (!loggerHits.length)
658
+ return findings;
659
+ // Check for retention settings
660
+ const retentionHits = await codeSearch(String.raw `maxFiles|maxsize|tailable|retentionDays|retention|logRotation`);
661
+ if (retentionHits.length)
662
+ return findings; // retention configured — no finding needed
663
+ findings.push({
664
+ id: "LOG_RETENTION_NOT_CONFIGURED",
665
+ title: "No log retention policy found. PCI DSS Req 10.3 requires audit logs retained for 12 months; NIST AU-11 requires risk-aligned retention.",
666
+ severity: "MEDIUM",
667
+ evidence: toEvidence(loggerHits),
668
+ files: toFiles(loggerHits),
669
+ requiredActions: [
670
+ "Configure log rotation and retention: set maxFiles / maxsize / retentionDays in your logging configuration.",
671
+ "PCI DSS Req 10.3 — audit logs must be retained for at least 12 months, with 3 months immediately available.",
672
+ "NIST AU-11 — audit record retention must be aligned to organizational risk policy.",
673
+ "Fix (winston): new winston.transports.File({ filename: 'app.log', maxFiles: 365, maxsize: 10485760, tailable: true })"
674
+ ]
675
+ });
676
+ return findings;
677
+ }
678
+ async function checkJwtKidInjection() {
679
+ const headerKidHits = await codeSearch(String.raw `(?:header\.kid|token\.header\.kid|decoded\.header\.kid)`);
680
+ const unsafe1 = headerKidHits.filter((h) => !/allowlist|ALLOWED_KIDS|path\.join.*validateKid|parameterized|sanitize/.test(h.preview));
681
+ const rawQueryHits = await codeSearch(String.raw `SELECT[^;]*\$\{[^}]*kid|readFileSync[^)]*kid`);
682
+ const combined = [...unsafe1, ...rawQueryHits];
683
+ const seen = new Set();
684
+ const deduped = combined.filter((h) => {
685
+ const key = `${h.file}:${h.line}`;
686
+ if (seen.has(key))
687
+ return false;
688
+ seen.add(key);
689
+ return true;
690
+ });
691
+ if (!deduped.length)
692
+ return null;
693
+ return {
694
+ id: "JWT_KID_INJECTION",
695
+ title: "JWT kid header used for DB lookup or filesystem read without sanitization — SQL/path-traversal injection (CWE-89/CWE-22)",
696
+ severity: "CRITICAL",
697
+ evidence: toEvidence(deduped),
698
+ files: toFiles(deduped),
699
+ requiredActions: [
700
+ "Validate the kid header against a strict allowlist before using it in any DB query or filesystem read.",
701
+ "CWE-89 — unsanitized kid in SQL interpolation enables SQL injection; CWE-22 — unsanitized kid in readFileSync enables path traversal.",
702
+ "Fix: const key = ALLOWED_KIDS[decoded.header.kid]; if (!key) throw new Error('Unknown kid');"
703
+ ]
704
+ };
705
+ }
706
+ async function checkJwksUriOverride() {
707
+ const hits = await codeSearch(String.raw `(?:jwksUri|jwks_uri|JwksClient|createRemoteJWKSet|getSigningKey.*jwks)`);
708
+ const unsafe = hits.filter((h) => !/allowlist|JWKS_URI|staticKeys|hardcoded|process\.env\.JWKS/.test(h.preview));
709
+ if (!unsafe.length)
710
+ return null;
711
+ return {
712
+ id: "JWT_JWKS_URI_OVERRIDE",
713
+ title: "JWKS endpoint fetched dynamically — attacker can override jwks_uri to serve their own keys (CWE-295)",
714
+ severity: "CRITICAL",
715
+ evidence: toEvidence(unsafe),
716
+ files: toFiles(unsafe),
717
+ requiredActions: [
718
+ "Pin the JWKS URI to a hardcoded or environment-variable-controlled value; never derive it from the token or request.",
719
+ "CWE-295 — a dynamic jwks_uri allows an attacker to point key resolution at their own server and sign arbitrary tokens.",
720
+ "Fix: const client = new JwksClient({ jwksUri: process.env.JWKS_URI }); // JWKS_URI set at deploy time, never at runtime from user input"
721
+ ]
722
+ };
723
+ }
724
+ async function checkOauthClientSecretPublic() {
725
+ const hits = await codeSearch(String.raw `(?:client_secret|clientSecret)\s*[:=]\s*['"][a-zA-Z0-9_\-]{8,}['"]`);
726
+ if (!hits.length)
727
+ return null;
728
+ return {
729
+ id: "OAUTH_CLIENT_SECRET_HARDCODED",
730
+ title: "OAuth client_secret hardcoded in source — public client credentials extractable from bundle (CWE-798)",
731
+ severity: "CRITICAL",
732
+ evidence: toEvidence(hits),
733
+ files: toFiles(hits),
734
+ requiredActions: [
735
+ "Move client_secret to a server-side environment variable or secrets manager; never embed it in frontend bundles.",
736
+ "CWE-798 — hardcoded OAuth secrets are extractable from git history, Docker layers, and compiled bundles.",
737
+ "Fix: clientSecret: process.env.OAUTH_CLIENT_SECRET"
738
+ ]
739
+ };
740
+ }
741
+ async function checkSessionTokenInUrl() {
742
+ const hits = await codeSearch(String.raw `req\.query\.(?:sessionid|session_id|sid|jsessionid|auth_token|session_token)`);
743
+ if (!hits.length)
744
+ return null;
745
+ return {
746
+ id: "SESSION_TOKEN_IN_URL",
747
+ title: "Session token transmitted in URL query parameter — logged in server access logs and browser history (CWE-598)",
748
+ severity: "HIGH",
749
+ evidence: toEvidence(hits),
750
+ files: toFiles(hits),
751
+ requiredActions: [
752
+ "Transmit session tokens exclusively in cookies or the Authorization header, never in query parameters.",
753
+ "CWE-598 — query parameters appear in server access logs, browser history, Referer headers, and CDN logs in plaintext.",
754
+ "Fix: const sessionId = req.cookies['session']; // never req.query.session_id"
755
+ ]
756
+ };
757
+ }
758
+ async function checkTokenEntropyTooLow() {
759
+ const hits = await codeSearch(String.raw `crypto\.randomBytes\s*\(\s*([1-9]|1[0-5])\s*\)`);
760
+ if (!hits.length)
761
+ return null;
762
+ return {
763
+ id: "TOKEN_ENTROPY_TOO_LOW",
764
+ title: "crypto.randomBytes() called with fewer than 16 bytes (<128 bits entropy) — tokens brute-forceable (CWE-331)",
765
+ severity: "HIGH",
766
+ evidence: toEvidence(hits),
767
+ files: toFiles(hits),
768
+ requiredActions: [
769
+ "Use crypto.randomBytes(32) or larger to generate tokens with at least 256 bits of entropy.",
770
+ "CWE-331 — tokens generated with fewer than 128 bits of entropy are vulnerable to brute-force enumeration.",
771
+ "Fix: const token = crypto.randomBytes(32).toString('hex'); // 256-bit entropy"
772
+ ]
773
+ };
774
+ }
775
+ async function checkRememberMeNoRotation() {
776
+ const hits = await codeSearch(String.raw `(?:rememberMe|remember_me|persistent.*token|rememberToken|keepLoggedIn|staySignedIn)`);
777
+ const unsafe = hits.filter((h) => !/rotate|revoke|invalidate|delete.*token|tokenFamily/.test(h.preview));
778
+ if (!unsafe.length)
779
+ return null;
780
+ return {
781
+ id: "REMEMBER_ME_NO_ROTATION",
782
+ title: "Persistent remember-me token without rotation — stolen token grants indefinite access (CWE-613)",
783
+ severity: "HIGH",
784
+ evidence: toEvidence(unsafe),
785
+ files: toFiles(unsafe),
786
+ requiredActions: [
787
+ "Rotate remember-me tokens on each use: invalidate the presented token and issue a fresh one.",
788
+ "CWE-613 — a static persistent token that is never rotated or revoked grants indefinite access if stolen.",
789
+ "Fix: await db.rememberTokens.delete(oldToken); const newToken = crypto.randomBytes(32).toString('hex'); await db.rememberTokens.create({ userId, token: newToken, expiresAt });"
790
+ ]
791
+ };
792
+ }
793
+ async function checkPasswordResetSingleUse() {
794
+ const hits = await codeSearch(String.raw `(?:resetToken|reset_token|passwordResetToken|forgotToken)\s*(?:===|==)\s*(?:req\.|body\.|params\.)`);
795
+ const unsafe = hits.filter((h) => !/delete|update.*null|set.*null|invalidate|revoke|markUsed|usedAt/.test(h.preview));
796
+ if (!unsafe.length)
797
+ return null;
798
+ return {
799
+ id: "PASSWORD_RESET_NOT_SINGLE_USE",
800
+ title: "Password reset token validated but not deleted/invalidated after use — token reuse attack possible (CWE-640)",
801
+ severity: "HIGH",
802
+ evidence: toEvidence(unsafe),
803
+ files: toFiles(unsafe),
804
+ requiredActions: [
805
+ "Delete or null out the reset token immediately after successful verification.",
806
+ "CWE-640 — a reset token that remains valid after use can be replayed to reset the password again.",
807
+ "Fix: await db.users.update({ where: { id: user.id }, data: { resetToken: null, resetTokenExpiry: null } });"
808
+ ]
809
+ };
810
+ }
811
+ async function checkAccountEnumeration() {
812
+ const hits = await codeSearch(String.raw `(?:user|account|email).*not.*found|no.*user.*(?:found|exists)|User.*does.*not.*exist|unknown.*(?:email|user|account)`);
813
+ const safeRe = /\/\/|expect\s*\(|toBe|toEqual|console\.log|logger\.(debug|info|warn)|\.test\s*\(/;
814
+ const unsafe = hits.filter((h) => !safeRe.test(h.preview));
815
+ if (!unsafe.length)
816
+ return null;
817
+ return {
818
+ id: "ACCOUNT_ENUMERATION",
819
+ title: "Distinct error message reveals whether username/email exists — enables account enumeration (CWE-203)",
820
+ severity: "MEDIUM",
821
+ evidence: toEvidence(unsafe),
822
+ files: toFiles(unsafe),
823
+ requiredActions: [
824
+ "Return the same generic error message for both 'user not found' and 'wrong password' scenarios.",
825
+ "CWE-203 — distinct error messages for unknown vs wrong-password allow attackers to enumerate valid accounts.",
826
+ "Fix: throw new Error('Invalid credentials'); // same message regardless of whether user exists"
827
+ ]
828
+ };
829
+ }
830
+ async function checkBcryptCostFactor() {
831
+ const hashHits = await codeSearch(String.raw `bcrypt\.(?:hash|hashSync)\s*\([^,]+,\s*([1-9])\s*[,)]`);
832
+ const saltHits = await codeSearch(String.raw `genSalt\s*\(\s*([1-9])\s*\)`);
833
+ const combined = [...hashHits, ...saltHits];
834
+ const seen = new Set();
835
+ const deduped = combined.filter((h) => {
836
+ const key = `${h.file}:${h.line}`;
837
+ if (seen.has(key))
838
+ return false;
839
+ seen.add(key);
840
+ return true;
841
+ });
842
+ if (!deduped.length)
843
+ return null;
844
+ return {
845
+ id: "BCRYPT_COST_TOO_LOW",
846
+ title: "bcrypt cost factor below 10 — password hashes crackable with GPU (OWASP PBKDF guidance)",
847
+ severity: "HIGH",
848
+ evidence: toEvidence(deduped),
849
+ files: toFiles(deduped),
850
+ requiredActions: [
851
+ "Set the bcrypt cost factor to at least 10 (OWASP recommends 12 for new systems).",
852
+ "OWASP PBKDF guidance — a cost factor below 10 allows GPU-accelerated brute-force cracking of password hashes.",
853
+ "Fix: await bcrypt.hash(password, 12); // or bcrypt.genSalt(12)"
854
+ ]
855
+ };
856
+ }
857
+ export async function checkAuthDeep(_opts) {
12
858
  try {
13
- // 1. JWT verify without explicit algorithms array (algorithm confusion / none-attack)
14
- const jwtVerifyHits = await codeSearch(String.raw `jwt\.verify\s*\(`);
15
- const jwtAlgSafeRe = /algorithms\s*:\s*\[/;
16
- const jwtAlgUnsafe = jwtVerifyHits.filter((h) => !jwtAlgSafeRe.test(h.preview));
17
- if (jwtAlgUnsafe.length > 0) {
18
- findings.push({
19
- id: "JWT_ALG_NONE_ACCEPTED",
20
- title: "jwt.verify() called without explicit algorithms array — algorithm confusion attack possible (CWE-327)",
21
- severity: "CRITICAL",
22
- evidence: jwtAlgUnsafe.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
23
- files: [...new Set(jwtAlgUnsafe.slice(0, 10).map((m) => m.file))],
24
- requiredActions: [
25
- "Always pass algorithms: ['RS256'] (or your actual algorithm) to jwt.verify().",
26
- "CWE-327 — without algorithms pin, attacker can forge tokens using alg:none or switch RS256→HS256 using the public key as secret.",
27
- "Fix: jwt.verify(token, publicKey, { algorithms: ['RS256'] })"
28
- ]
29
- });
30
- }
31
- // 2. Session not regenerated after login (session fixation)
32
- const loginHandlerHits = await codeSearch(String.raw `(?:req\.session\.user|req\.session\.userId|req\.session\.account|req\.session\.authenticated)\s*=`);
33
- const sessionRegenerateRe = /req\.session\.regenerate|session\.regenerate\s*\(/;
34
- const sessionFixationRisk = loginHandlerHits.filter((h) => !sessionRegenerateRe.test(h.preview));
35
- if (sessionFixationRisk.length > 0) {
36
- findings.push({
37
- id: "SESSION_FIXATION",
38
- title: "Session identity set without session regeneration — session fixation risk (CWE-384)",
39
- severity: "HIGH",
40
- evidence: sessionFixationRisk.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
41
- files: [...new Set(sessionFixationRisk.slice(0, 10).map((m) => m.file))],
42
- requiredActions: [
43
- "Call req.session.regenerate() before setting session identity after authentication.",
44
- "CWE-384 — an attacker who fixes the session ID before login can hijack the authenticated session.",
45
- "Fix: req.session.regenerate((err) => { req.session.userId = user.id; res.json({ ok: true }); });"
46
- ]
47
- });
48
- }
49
- // 3. OAuth authorize endpoint without state parameter generation
50
- const oauthAuthHits = await codeSearch(String.raw `(?:authorizationUrl|oauth\.authorize|passport\.authenticate\s*\(\s*['"]oauth|\.redirect\s*\(\s*['"]https:\/\/[^'"]*\/oauth\/authorize|\/oauth\/callback|\/auth\/callback)`);
51
- const oauthStateSafeRe = /state\s*[:=]|generateState|crypto\.randomBytes|randomUUID|nonce/;
52
- const oauthStateUnsafe = oauthAuthHits.filter((h) => !oauthStateSafeRe.test(h.preview));
53
- if (oauthStateUnsafe.length > 0) {
54
- findings.push({
55
- id: "OAUTH_MISSING_STATE",
56
- title: "OAuth flow without state parameter — CSRF on authorization callback (CWE-352)",
57
- severity: "HIGH",
58
- evidence: oauthStateUnsafe.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
59
- files: [...new Set(oauthStateUnsafe.slice(0, 10).map((m) => m.file))],
60
- requiredActions: [
61
- "Generate a cryptographically random state parameter and verify it on the callback.",
62
- "CWE-352 — without state, an attacker can inject their own authorization code into the victim's session.",
63
- "Fix: const state = crypto.randomBytes(32).toString('hex'); session.oauthState = state; // verify on callback"
64
- ]
65
- });
66
- }
67
- // 4. OAuth redirect_uri validated with includes/startsWith (too broad)
68
- const redirectUriHits = await codeSearch(String.raw `redirect_uri.*(?:\.includes\s*\(|\.startsWith\s*\(|\.match\s*\(|indexOf\s*\()|(?:\.includes\s*\(|\.startsWith\s*\().*redirect_uri`);
69
- if (redirectUriHits.length > 0) {
70
- findings.push({
71
- id: "OAUTH_OPEN_REDIRECT_URI",
72
- title: "OAuth redirect_uri validated with includes/startsWith — open redirect via subdomain (CWE-601)",
73
- severity: "HIGH",
74
- evidence: redirectUriHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
75
- files: [...new Set(redirectUriHits.slice(0, 10).map((m) => m.file))],
76
- requiredActions: [
77
- "Validate redirect_uri with exact string equality against a pre-registered allowlist.",
78
- "CWE-601 — startsWith('https://example.com') allows https://example.com.evil.com/.",
79
- "Fix: if (redirectUri !== REGISTERED_REDIRECT_URI) throw new Error('Invalid redirect_uri');"
80
- ]
81
- });
82
- }
83
- // 5. PKCE not enforced — OAuth/OIDC flow without code_challenge
84
- const pkceHits = await codeSearch(String.raw `(?:authorization_code|grant_type.*authorization_code|code.*exchange|token.*endpoint.*code\b)`);
85
- const pkceSafeRe = /code_challenge|code_verifier|pkce|PKCE/;
86
- const pkceUnsafe = pkceHits.filter((h) => !pkceSafeRe.test(h.preview));
87
- if (pkceUnsafe.length > 0) {
88
- findings.push({
89
- id: "PKCE_NOT_ENFORCED",
90
- title: "OAuth authorization code flow without PKCE — code interception attack (RFC 7636)",
91
- severity: "HIGH",
92
- evidence: pkceUnsafe.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
93
- files: [...new Set(pkceUnsafe.slice(0, 10).map((m) => m.file))],
94
- requiredActions: [
95
- "Require PKCE (code_challenge_method=S256) for all public clients and SPAs.",
96
- "RFC 7636 / ATT&CK T1528 — without PKCE, a stolen authorization code can be exchanged for tokens.",
97
- "Fix: enforce code_challenge in the /authorize handler and verify code_verifier in /token exchange."
98
- ]
99
- });
100
- }
101
- // 6. Hardcoded JWT secret (short literal string)
102
- const hardcodedJwtHits = await codeSearch(String.raw `jwt\.sign\s*\([^,]+,\s*['"][a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{1,32}['"]|jwt\.verify\s*\([^,]+,\s*['"][a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{1,32}['"]`);
103
- if (hardcodedJwtHits.length > 0) {
104
- findings.push({
105
- id: "HARDCODED_JWT_SECRET",
106
- title: "Hardcoded JWT secret literal — secret exposed in source code (CWE-798)",
107
- severity: "CRITICAL",
108
- evidence: hardcodedJwtHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
109
- files: [...new Set(hardcodedJwtHits.slice(0, 10).map((m) => m.file))],
110
- requiredActions: [
111
- "Move JWT secrets to environment variables or a secrets manager; never commit them to source.",
112
- "CWE-798 — hardcoded secrets are trivially extracted from git history and Docker images.",
113
- "Fix: jwt.sign(payload, process.env.JWT_SECRET!, { algorithms: ['RS256'] })"
114
- ]
115
- });
116
- }
117
- // 7. Login/auth/token endpoints without rate limiting middleware
118
- const loginRouteHits = await codeSearch(String.raw `(?:router|app)\.post\s*\(\s*['"][^'"]*(?:\/login|\/signin|\/auth|\/token|\/session)['"]\s*,`);
119
- const rateLimitRe = /rateLimit|rateLimiter|rate_limit|limiter|throttle|slowDown|expressRateLimit/;
120
- const rateLimitMissing = loginRouteHits.filter((h) => !rateLimitRe.test(h.preview));
121
- if (rateLimitMissing.length > 0) {
122
- findings.push({
123
- id: "MISSING_RATE_LIMIT_LOGIN",
124
- title: "Authentication endpoint without rate limiting — brute force attack surface (CWE-307)",
125
- severity: "HIGH",
126
- evidence: rateLimitMissing.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
127
- files: [...new Set(rateLimitMissing.slice(0, 10).map((m) => m.file))],
128
- requiredActions: [
129
- "Apply express-rate-limit or equivalent middleware to all authentication endpoints.",
130
- "CWE-307 — without rate limiting, brute force or credential stuffing attacks are unrestricted.",
131
- "Fix: app.post('/login', loginRateLimiter, authHandler); // max: 5 attempts per 15 minutes"
132
- ]
133
- });
134
- }
135
- // 8. Plaintext password comparison (timing oracle / no hashing)
136
- const passwordCompareHits = await codeSearch(String.raw `password\s*===\s*(?:req\.|user\.|stored|db\.|record\.)|(?:req\.|body\.)password\s*===\s*|password\s*==\s*(?:req\.|user\.|stored|db\.)|compareSync\s*\(\s*(?:req\.|body\.)`);
137
- const passwordSafeRe = /bcrypt|argon2|scrypt|pbkdf2|timingSafeEqual|compare\s*\(/i;
138
- const passwordUnsafe = passwordCompareHits.filter((h) => !passwordSafeRe.test(h.preview));
139
- if (passwordUnsafe.length > 0) {
140
- findings.push({
141
- id: "PASSWORD_PLAIN_COMPARE",
142
- title: "Plaintext password comparison — no hashing or timing oracle (CWE-256)",
143
- severity: "CRITICAL",
144
- evidence: passwordUnsafe.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
145
- files: [...new Set(passwordUnsafe.slice(0, 10).map((m) => m.file))],
146
- requiredActions: [
147
- "Use bcrypt.compare() or argon2.verify() for password verification — never === comparison.",
148
- "CWE-256 — plaintext comparison leaks timing information and stores passwords without hashing.",
149
- "Fix: const valid = await bcrypt.compare(password, user.passwordHash); if (!valid) throw new Error('Unauthorized');"
150
- ]
151
- });
152
- }
153
- // 9. SAML signature validation disabled
154
- const samlHits = await codeSearch(String.raw `(?:new\s+saml\.Strategy|passport-saml|samlify|node-saml|SAMLResponse|validateSignature\s*:\s*false|wantAssertionsSigned\s*:\s*false|signatureAlgorithm\s*:\s*['"]none['"])`);
155
- const samlUnsafeRe = /validateSignature\s*:\s*false|wantAssertionsSigned\s*:\s*false|signatureAlgorithm\s*:\s*['"]none['"]/;
156
- const samlUnsafe = samlHits.filter((h) => samlUnsafeRe.test(h.preview));
157
- if (samlUnsafe.length > 0) {
158
- findings.push({
159
- id: "SAML_SIGNATURE_NOT_ENFORCED",
160
- title: "SAML signature validation disabled — SAML response forgery (CWE-347)",
161
- severity: "CRITICAL",
162
- evidence: samlUnsafe.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
163
- files: [...new Set(samlUnsafe.slice(0, 10).map((m) => m.file))],
164
- requiredActions: [
165
- "Set validateSignature:true and wantAssertionsSigned:true in all SAML strategy configurations.",
166
- "CWE-347 — unsigned SAML responses allow any user to craft an assertion claiming to be any other user.",
167
- "Fix: new SamlStrategy({ validateSignature: true, wantAssertionsSigned: true, cert: IDP_CERT }, ...)"
168
- ]
169
- });
170
- }
171
- // 10. Cookies without httpOnly/secure/sameSite flags
172
- const cookieHits = await codeSearch(String.raw `res\.cookie\s*\(\s*['"][^'"]+['"]`);
173
- const cookieHttpOnlyRe = /httpOnly\s*:\s*true/;
174
- const cookieSecureRe = /secure\s*:\s*true/;
175
- const cookieUnsafe = cookieHits.filter((h) => !cookieHttpOnlyRe.test(h.preview) || !cookieSecureRe.test(h.preview));
176
- if (cookieUnsafe.length > 0) {
177
- findings.push({
178
- id: "COOKIE_MISSING_SECURE_FLAGS",
179
- title: "Cookie set without httpOnly and/or secure flags (CWE-1004 / CWE-614)",
180
- severity: "HIGH",
181
- evidence: cookieUnsafe.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
182
- files: [...new Set(cookieUnsafe.slice(0, 10).map((m) => m.file))],
183
- requiredActions: [
184
- "Set httpOnly:true, secure:true, and sameSite:'Strict' on all authentication and session cookies.",
185
- "CWE-1004/CWE-614 — missing httpOnly enables XSS cookie theft; missing secure sends cookie over HTTP.",
186
- "Fix: res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 3600000 });"
187
- ]
188
- });
189
- }
190
- // 11. Refresh token issued but old token not invalidated (token rotation missing)
191
- const refreshTokenHits = await codeSearch(String.raw `(?:refresh_token|refreshToken)\s*[:=](?:.*jwt\.sign|.*generateToken|.*createToken|.*sign\s*\()|(?:grantType|grant_type)\s*[:=]\s*['"]refresh_token['"]`);
192
- const refreshRotateRe = /delete|revoke|invalidate|blacklist|rotateToken|revokeToken|tokenFamily|REFRESH_TOKEN_FAMILY/;
193
- const refreshUnsafe = refreshTokenHits.filter((h) => !refreshRotateRe.test(h.preview));
194
- if (refreshUnsafe.length > 0) {
195
- findings.push({
196
- id: "REFRESH_TOKEN_NOT_ROTATED",
197
- title: "Refresh token issued without revoking previous token — replay attack surface (CWE-613)",
198
- severity: "HIGH",
199
- evidence: refreshUnsafe.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
200
- files: [...new Set(refreshUnsafe.slice(0, 10).map((m) => m.file))],
201
- requiredActions: [
202
- "Implement refresh token rotation: invalidate the old token before issuing the new one.",
203
- "CWE-613 — without rotation, a stolen refresh token remains valid indefinitely.",
204
- "Fix: await db.refreshTokens.delete(oldToken); const newToken = issueRefreshToken(user); // token family detection for theft detection"
205
- ]
206
- });
207
- }
208
- // 12. JWT HS/RS confusion — jwt.verify called without algorithm pin on RS256 context
209
- const jwtHsRsHits = await codeSearch(String.raw `jwt\.verify\s*\(\s*[^,]+,\s*(?:publicKey|PUBLIC_KEY|pub_key|process\.env\.[A-Z_]*PUBLIC|fs\.readFileSync[^)]*\.pem)`);
210
- const jwtHsRsSafeRe = /algorithms\s*:\s*\[\s*['"](?:RS|ES|PS)/;
211
- const jwtHsRsUnsafe = jwtHsRsHits.filter((h) => !jwtHsRsSafeRe.test(h.preview));
212
- if (jwtHsRsUnsafe.length > 0) {
213
- findings.push({
214
- id: "JWT_HS_RS_CONFUSION",
215
- title: "JWT verified with public key without algorithm pin — HS/RS confusion attack (CVE-2015-9235 pattern)",
216
- severity: "CRITICAL",
217
- evidence: jwtHsRsUnsafe.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
218
- files: [...new Set(jwtHsRsUnsafe.slice(0, 10).map((m) => m.file))],
219
- requiredActions: [
220
- "Pin the algorithm to RS256/ES256 explicitly: jwt.verify(token, publicKey, { algorithms: ['RS256'] }).",
221
- "Without algorithm pin: attacker takes RS256 public key, signs token with HS256 using that key as the HMAC secret — library accepts it.",
222
- "This is CVE-2015-9235 — still exploitable in jsonwebtoken < 9.0 without the algorithms option."
223
- ]
224
- });
225
- }
859
+ const [jwtAlgNoneFindings, sessionFixationFindings, oauthMissingState, oauthOpenRedirectUri, oauthImplicitFlow, pkceNotEnforced, hardcodedJwtSecret, jwtMissingExpiryFindings, missingRateLimitLogin, passwordPlainCompare, samlSignatureDisabled, samlXswFindings, samlReplay, jwtHsRsConfusionFindings, apiKeyInUrl, passwordResetNoExpiry, adminRouteNoAuthz, timingOracle, cookieSecureFlags, refreshTokenNotRotated, accountLockout, missingStructuredLoggingFindings, logRetentionFindings, jwtKidInjection, jwksUriOverride, oauthClientSecretPublic, sessionTokenInUrl, tokenEntropyTooLow, rememberMeNoRotation, passwordResetSingleUse, accountEnumeration, bcryptCostFactor,] = await Promise.all([
860
+ checkJwtAlgNone(),
861
+ checkSessionFixation(),
862
+ checkOauthMissingState(),
863
+ checkOauthOpenRedirectUri(),
864
+ checkOauthImplicitFlow(),
865
+ checkPkceNotEnforced(),
866
+ checkHardcodedJwtSecret(),
867
+ checkJwtMissingExpiry(),
868
+ checkMissingRateLimitLogin(),
869
+ checkPasswordPlainCompare(),
870
+ checkSamlSignatureDisabled(),
871
+ checkSamlXsw(),
872
+ checkSamlReplay(),
873
+ checkJwtHsRsConfusion(),
874
+ checkApiKeyInUrl(),
875
+ checkPasswordResetNoExpiry(),
876
+ checkAdminRouteNoAuthz(),
877
+ checkTimingOracle(),
878
+ checkCookieSecureFlags(),
879
+ checkRefreshTokenNotRotated(),
880
+ checkAccountLockout(),
881
+ checkMissingStructuredLogging(),
882
+ checkLogRetentionConfig(),
883
+ checkJwtKidInjection(),
884
+ checkJwksUriOverride(),
885
+ checkOauthClientSecretPublic(),
886
+ checkSessionTokenInUrl(),
887
+ checkTokenEntropyTooLow(),
888
+ checkRememberMeNoRotation(),
889
+ checkPasswordResetSingleUse(),
890
+ checkAccountEnumeration(),
891
+ checkBcryptCostFactor(),
892
+ ]);
893
+ const singleFindings = [
894
+ oauthMissingState,
895
+ oauthOpenRedirectUri,
896
+ oauthImplicitFlow,
897
+ pkceNotEnforced,
898
+ hardcodedJwtSecret,
899
+ missingRateLimitLogin,
900
+ passwordPlainCompare,
901
+ samlSignatureDisabled,
902
+ samlReplay,
903
+ apiKeyInUrl,
904
+ passwordResetNoExpiry,
905
+ adminRouteNoAuthz,
906
+ timingOracle,
907
+ cookieSecureFlags,
908
+ refreshTokenNotRotated,
909
+ accountLockout,
910
+ jwtKidInjection,
911
+ jwksUriOverride,
912
+ oauthClientSecretPublic,
913
+ sessionTokenInUrl,
914
+ tokenEntropyTooLow,
915
+ rememberMeNoRotation,
916
+ passwordResetSingleUse,
917
+ accountEnumeration,
918
+ bcryptCostFactor,
919
+ ].filter((f) => f !== null);
920
+ return [
921
+ ...jwtAlgNoneFindings,
922
+ ...sessionFixationFindings,
923
+ ...singleFindings,
924
+ ...jwtMissingExpiryFindings,
925
+ ...samlXswFindings,
926
+ ...jwtHsRsConfusionFindings,
927
+ ...missingStructuredLoggingFindings,
928
+ ...logRetentionFindings,
929
+ ];
226
930
  }
227
931
  catch (err) {
228
932
  console.warn("[checkAuthDeep] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
933
+ return [];
229
934
  }
230
- return findings;
231
935
  }