getdoorman 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/bin/doorman.js +444 -0
- package/package.json +74 -0
- package/src/ai-fixer.js +559 -0
- package/src/ast-scanner.js +434 -0
- package/src/auth.js +149 -0
- package/src/baseline.js +48 -0
- package/src/compliance.js +539 -0
- package/src/config.js +466 -0
- package/src/custom-rules.js +32 -0
- package/src/dashboard.js +202 -0
- package/src/detector.js +142 -0
- package/src/fix-engine.js +48 -0
- package/src/fix-registry-extra.js +95 -0
- package/src/fix-registry-go-rust.js +77 -0
- package/src/fix-registry-java-csharp.js +77 -0
- package/src/fix-registry-js.js +99 -0
- package/src/fix-registry-mcp-ai.js +57 -0
- package/src/fix-registry-python.js +87 -0
- package/src/fixer-ruby-php.js +608 -0
- package/src/fixer.js +2113 -0
- package/src/hooks.js +115 -0
- package/src/ignore.js +176 -0
- package/src/index.js +384 -0
- package/src/metrics.js +126 -0
- package/src/monorepo.js +65 -0
- package/src/presets.js +54 -0
- package/src/reporter.js +975 -0
- package/src/rule-worker.js +36 -0
- package/src/rules/ast-rules.js +756 -0
- package/src/rules/bugs/accessibility.js +235 -0
- package/src/rules/bugs/ai-codegen-fixable.js +172 -0
- package/src/rules/bugs/ai-codegen.js +365 -0
- package/src/rules/bugs/code-smell-bugs.js +247 -0
- package/src/rules/bugs/crypto-bugs.js +195 -0
- package/src/rules/bugs/docker-bugs.js +158 -0
- package/src/rules/bugs/general.js +361 -0
- package/src/rules/bugs/go-bugs.js +279 -0
- package/src/rules/bugs/index.js +73 -0
- package/src/rules/bugs/js-api.js +257 -0
- package/src/rules/bugs/js-array-object.js +210 -0
- package/src/rules/bugs/js-async-fixable.js +223 -0
- package/src/rules/bugs/js-async.js +211 -0
- package/src/rules/bugs/js-closure-scope.js +182 -0
- package/src/rules/bugs/js-database.js +203 -0
- package/src/rules/bugs/js-error-handling.js +148 -0
- package/src/rules/bugs/js-logic.js +261 -0
- package/src/rules/bugs/js-memory.js +214 -0
- package/src/rules/bugs/js-node.js +361 -0
- package/src/rules/bugs/js-react.js +373 -0
- package/src/rules/bugs/js-regex.js +200 -0
- package/src/rules/bugs/js-state.js +272 -0
- package/src/rules/bugs/js-type-coercion.js +318 -0
- package/src/rules/bugs/nextjs-bugs.js +242 -0
- package/src/rules/bugs/nextjs-fixable.js +120 -0
- package/src/rules/bugs/node-fixable.js +178 -0
- package/src/rules/bugs/python-advanced.js +245 -0
- package/src/rules/bugs/python-fixable.js +98 -0
- package/src/rules/bugs/python.js +284 -0
- package/src/rules/bugs/react-fixable.js +207 -0
- package/src/rules/bugs/ruby-bugs.js +182 -0
- package/src/rules/bugs/shell-bugs.js +181 -0
- package/src/rules/bugs/silent-failures.js +261 -0
- package/src/rules/bugs/ts-bugs.js +235 -0
- package/src/rules/bugs/unused-vars.js +65 -0
- package/src/rules/compliance/accessibility-ext.js +468 -0
- package/src/rules/compliance/education.js +322 -0
- package/src/rules/compliance/financial.js +421 -0
- package/src/rules/compliance/frameworks.js +507 -0
- package/src/rules/compliance/healthcare.js +520 -0
- package/src/rules/compliance/index.js +2714 -0
- package/src/rules/compliance/regional-eu.js +480 -0
- package/src/rules/compliance/regional-international.js +903 -0
- package/src/rules/cost/index.js +1993 -0
- package/src/rules/data/index.js +2503 -0
- package/src/rules/dependencies/index.js +1684 -0
- package/src/rules/deployment/index.js +2050 -0
- package/src/rules/index.js +71 -0
- package/src/rules/infrastructure/index.js +3048 -0
- package/src/rules/performance/index.js +3455 -0
- package/src/rules/quality/index.js +3175 -0
- package/src/rules/reliability/index.js +3040 -0
- package/src/rules/scope-rules.js +815 -0
- package/src/rules/security/ai-api.js +1177 -0
- package/src/rules/security/auth.js +1328 -0
- package/src/rules/security/cors.js +127 -0
- package/src/rules/security/crypto.js +527 -0
- package/src/rules/security/csharp.js +862 -0
- package/src/rules/security/csrf.js +193 -0
- package/src/rules/security/dart.js +835 -0
- package/src/rules/security/deserialization.js +291 -0
- package/src/rules/security/file-upload.js +187 -0
- package/src/rules/security/go.js +850 -0
- package/src/rules/security/headers.js +235 -0
- package/src/rules/security/index.js +65 -0
- package/src/rules/security/injection.js +1639 -0
- package/src/rules/security/mcp-server.js +71 -0
- package/src/rules/security/misconfiguration.js +660 -0
- package/src/rules/security/oauth-jwt.js +329 -0
- package/src/rules/security/path-traversal.js +295 -0
- package/src/rules/security/php.js +1054 -0
- package/src/rules/security/prototype-pollution.js +283 -0
- package/src/rules/security/rate-limiting.js +208 -0
- package/src/rules/security/ruby.js +1061 -0
- package/src/rules/security/rust.js +693 -0
- package/src/rules/security/secrets.js +747 -0
- package/src/rules/security/shell.js +647 -0
- package/src/rules/security/ssrf.js +298 -0
- package/src/rules/security/supply-chain-advanced.js +393 -0
- package/src/rules/security/supply-chain.js +734 -0
- package/src/rules/security/swift.js +835 -0
- package/src/rules/security/taint.js +27 -0
- package/src/rules/security/xss.js +520 -0
- package/src/scan-cache.js +71 -0
- package/src/scanner.js +710 -0
- package/src/scope-analyzer.js +685 -0
- package/src/share.js +88 -0
- package/src/taint.js +300 -0
- package/src/telemetry.js +183 -0
- package/src/tracer.js +190 -0
- package/src/upload.js +35 -0
- package/src/worker.js +31 -0
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doorman CLI - Authentication Security Rules
|
|
3
|
+
* SEC-AUTH-001 through SEC-AUTH-020
|
|
4
|
+
*
|
|
5
|
+
* Detects authentication and session-management weaknesses
|
|
6
|
+
* across source files using regex/pattern analysis.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const JS_EXT = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'];
|
|
10
|
+
const isJS = (f) => JS_EXT.some((ext) => f.endsWith(ext));
|
|
11
|
+
const isTest = (f) =>
|
|
12
|
+
f.includes('test') ||
|
|
13
|
+
f.includes('spec') ||
|
|
14
|
+
f.includes('mock') ||
|
|
15
|
+
f.includes('fixture');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Return true when a trimmed line is a comment and should be ignored.
|
|
19
|
+
*/
|
|
20
|
+
function isCommentLine(line) {
|
|
21
|
+
const trimmed = line.trim();
|
|
22
|
+
return (
|
|
23
|
+
trimmed.startsWith('//') ||
|
|
24
|
+
trimmed.startsWith('#') ||
|
|
25
|
+
trimmed.startsWith('*') ||
|
|
26
|
+
trimmed.startsWith('/*')
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Split file content into an array of { text, num } objects.
|
|
32
|
+
*/
|
|
33
|
+
function getLines(content) {
|
|
34
|
+
if (!content) return [];
|
|
35
|
+
return content.split('\n').map((text, i) => ({ text, num: i + 1 }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get a window of raw text around a given 1-based line number.
|
|
40
|
+
*/
|
|
41
|
+
function contextWindow(lines, lineNum, radius = 15) {
|
|
42
|
+
const start = Math.max(0, lineNum - 1 - radius);
|
|
43
|
+
const end = Math.min(lines.length, lineNum + radius);
|
|
44
|
+
return lines
|
|
45
|
+
.slice(start, end)
|
|
46
|
+
.map((l) => l.text)
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a single finding object that matches the expected shape.
|
|
52
|
+
*/
|
|
53
|
+
function mkFinding(rule, opts = {}) {
|
|
54
|
+
return {
|
|
55
|
+
ruleId: rule.id,
|
|
56
|
+
category: 'security',
|
|
57
|
+
severity: rule.severity,
|
|
58
|
+
title: rule.title,
|
|
59
|
+
description: opts.description || null,
|
|
60
|
+
confidence: opts.confidence || rule.confidence || null,
|
|
61
|
+
file: opts.file || null,
|
|
62
|
+
line: opts.line || null,
|
|
63
|
+
fix: null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Rules
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const rules = [
|
|
72
|
+
// -----------------------------------------------------------------------
|
|
73
|
+
// SEC-AUTH-001 (critical): Plaintext password storage
|
|
74
|
+
// -----------------------------------------------------------------------
|
|
75
|
+
{
|
|
76
|
+
id: 'SEC-AUTH-001',
|
|
77
|
+
category: 'security',
|
|
78
|
+
severity: 'critical',
|
|
79
|
+
confidence: 'likely',
|
|
80
|
+
title: 'Plaintext password storage detected',
|
|
81
|
+
check({ files }) {
|
|
82
|
+
const findings = [];
|
|
83
|
+
const savePattern =
|
|
84
|
+
/(?:save|create|insert|store|write|update|set)\s*\(.*password/i;
|
|
85
|
+
const assignPattern =
|
|
86
|
+
/(?:password|passwd|pwd)\s*[:=]\s*(?:req\.body|params|args|input)/i;
|
|
87
|
+
const hashNearby =
|
|
88
|
+
/(?:hash|bcrypt|argon2|scrypt|pbkdf2|crypto\.)/i;
|
|
89
|
+
|
|
90
|
+
for (const [filePath, content] of files) {
|
|
91
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
92
|
+
const lines = getLines(content);
|
|
93
|
+
for (const { text, num } of lines) {
|
|
94
|
+
if (isCommentLine(text)) continue;
|
|
95
|
+
if (savePattern.test(text) || assignPattern.test(text)) {
|
|
96
|
+
const ctx = contextWindow(lines, num, 20);
|
|
97
|
+
if (!hashNearby.test(ctx)) {
|
|
98
|
+
findings.push(
|
|
99
|
+
mkFinding(this, {
|
|
100
|
+
description:
|
|
101
|
+
'Password appears to be stored without hashing. Use bcrypt, argon2, or scrypt before persisting.',
|
|
102
|
+
file: filePath,
|
|
103
|
+
line: num,
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return findings;
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
// -----------------------------------------------------------------------
|
|
115
|
+
// SEC-AUTH-002 (critical): Weak password hashing (MD5/SHA1/SHA256)
|
|
116
|
+
// -----------------------------------------------------------------------
|
|
117
|
+
{
|
|
118
|
+
id: 'SEC-AUTH-002',
|
|
119
|
+
category: 'security',
|
|
120
|
+
severity: 'critical',
|
|
121
|
+
confidence: 'definite',
|
|
122
|
+
title: 'Weak password hashing algorithm',
|
|
123
|
+
check({ files }) {
|
|
124
|
+
const findings = [];
|
|
125
|
+
const weakHash =
|
|
126
|
+
/(?:createHash|\.hash)\s*\(\s*['"](?:md5|sha1|sha256)['"]/i;
|
|
127
|
+
const pwdContext = /password|passwd|pwd/i;
|
|
128
|
+
|
|
129
|
+
const strongHash = /bcrypt|argon2|scrypt|pbkdf2/i;
|
|
130
|
+
for (const [filePath, content] of files) {
|
|
131
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
132
|
+
const lines = getLines(content);
|
|
133
|
+
for (const { text, num } of lines) {
|
|
134
|
+
if (isCommentLine(text)) continue;
|
|
135
|
+
if (strongHash.test(text)) continue; // bcrypt/argon2/scrypt are safe
|
|
136
|
+
if (weakHash.test(text)) {
|
|
137
|
+
const ctx = contextWindow(lines, num, 10);
|
|
138
|
+
if (pwdContext.test(ctx)) {
|
|
139
|
+
findings.push(
|
|
140
|
+
mkFinding(this, {
|
|
141
|
+
description:
|
|
142
|
+
'MD5, SHA-1, or SHA-256 is not suitable for password hashing. Use bcrypt, argon2, or scrypt instead.',
|
|
143
|
+
file: filePath,
|
|
144
|
+
line: num,
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return findings;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
// -----------------------------------------------------------------------
|
|
156
|
+
// SEC-AUTH-003 (high): No password strength enforcement
|
|
157
|
+
// -----------------------------------------------------------------------
|
|
158
|
+
{
|
|
159
|
+
id: 'SEC-AUTH-003',
|
|
160
|
+
category: 'security',
|
|
161
|
+
severity: 'high',
|
|
162
|
+
confidence: 'suggestion',
|
|
163
|
+
title: 'No password strength validation',
|
|
164
|
+
check({ files }) {
|
|
165
|
+
const findings = [];
|
|
166
|
+
const registerPattern =
|
|
167
|
+
/(?:register|signup|sign_up|createUser|createAccount)/i;
|
|
168
|
+
const strengthCheck =
|
|
169
|
+
/(?:minLength|minlength|min_length|password.*\.length|password.*length|passwordStrength|zxcvbn|owasp-password|\.min\s*\(\s*\d|z\.string\(\).*\.min)/i;
|
|
170
|
+
|
|
171
|
+
for (const [filePath, content] of files) {
|
|
172
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
173
|
+
if (!registerPattern.test(content || '')) continue;
|
|
174
|
+
if (!strengthCheck.test(content || '')) {
|
|
175
|
+
findings.push(
|
|
176
|
+
mkFinding(this, {
|
|
177
|
+
description:
|
|
178
|
+
'Registration/signup handler found without password length or strength validation. Enforce minimum password requirements.',
|
|
179
|
+
file: filePath,
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return findings;
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// -----------------------------------------------------------------------
|
|
189
|
+
// SEC-AUTH-004 (critical): JWT secret hardcoded
|
|
190
|
+
// -----------------------------------------------------------------------
|
|
191
|
+
{
|
|
192
|
+
id: 'SEC-AUTH-004',
|
|
193
|
+
category: 'security',
|
|
194
|
+
severity: 'critical',
|
|
195
|
+
confidence: 'definite',
|
|
196
|
+
title: 'Hardcoded JWT secret',
|
|
197
|
+
check({ files }) {
|
|
198
|
+
const findings = [];
|
|
199
|
+
const jwtSignInline =
|
|
200
|
+
/jwt\.sign\s*\([^)]*,\s*['"][^'"]{2,}['"]/i;
|
|
201
|
+
const envRef =
|
|
202
|
+
/process\.env|config\.|Configuration|getEnv|env\[/i;
|
|
203
|
+
|
|
204
|
+
for (const [filePath, content] of files) {
|
|
205
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
206
|
+
const lines = getLines(content);
|
|
207
|
+
for (const { text, num } of lines) {
|
|
208
|
+
if (isCommentLine(text)) continue;
|
|
209
|
+
if (jwtSignInline.test(text) && !envRef.test(text)) {
|
|
210
|
+
findings.push(
|
|
211
|
+
mkFinding(this, {
|
|
212
|
+
description:
|
|
213
|
+
'JWT is signed with a hardcoded string secret. Store the secret in an environment variable or secrets manager.',
|
|
214
|
+
file: filePath,
|
|
215
|
+
line: num,
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return findings;
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
// -----------------------------------------------------------------------
|
|
226
|
+
// SEC-AUTH-005 (high): JWT without expiration
|
|
227
|
+
// -----------------------------------------------------------------------
|
|
228
|
+
{
|
|
229
|
+
id: 'SEC-AUTH-005',
|
|
230
|
+
category: 'security',
|
|
231
|
+
severity: 'high',
|
|
232
|
+
confidence: 'likely',
|
|
233
|
+
title: 'JWT signed without expiration',
|
|
234
|
+
check({ files }) {
|
|
235
|
+
const findings = [];
|
|
236
|
+
const jwtSign = /jwt\.sign\s*\(/i;
|
|
237
|
+
const expiryOption =
|
|
238
|
+
/expiresIn|exp\s*:|exp['"]|expiresAt/i;
|
|
239
|
+
|
|
240
|
+
for (const [filePath, content] of files) {
|
|
241
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
242
|
+
const lines = getLines(content);
|
|
243
|
+
for (let i = 0; i < lines.length; i++) {
|
|
244
|
+
const { text, num } = lines[i];
|
|
245
|
+
if (isCommentLine(text)) continue;
|
|
246
|
+
if (jwtSign.test(text)) {
|
|
247
|
+
// Check the jwt.sign call and next 5 lines for expiry
|
|
248
|
+
const window = lines
|
|
249
|
+
.slice(i, Math.min(lines.length, i + 6))
|
|
250
|
+
.map((l) => l.text)
|
|
251
|
+
.join('\n');
|
|
252
|
+
if (!expiryOption.test(window)) {
|
|
253
|
+
findings.push(
|
|
254
|
+
mkFinding(this, {
|
|
255
|
+
description:
|
|
256
|
+
'jwt.sign() called without an expiresIn option. Tokens without expiration remain valid indefinitely.',
|
|
257
|
+
file: filePath,
|
|
258
|
+
line: num,
|
|
259
|
+
})
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return findings;
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
// -----------------------------------------------------------------------
|
|
270
|
+
// SEC-AUTH-006 (critical): JWT algorithm none
|
|
271
|
+
// -----------------------------------------------------------------------
|
|
272
|
+
{
|
|
273
|
+
id: 'SEC-AUTH-006',
|
|
274
|
+
category: 'security',
|
|
275
|
+
severity: 'critical',
|
|
276
|
+
confidence: 'definite',
|
|
277
|
+
title: 'JWT "none" algorithm allowed',
|
|
278
|
+
check({ files }) {
|
|
279
|
+
const findings = [];
|
|
280
|
+
const noneAlgo =
|
|
281
|
+
/algorithms\s*:\s*\[.*['"]none['"].*\]/i;
|
|
282
|
+
const algoNone =
|
|
283
|
+
/algorithm\s*[:=]\s*['"]none['"]/i;
|
|
284
|
+
|
|
285
|
+
for (const [filePath, content] of files) {
|
|
286
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
287
|
+
const lines = getLines(content);
|
|
288
|
+
for (const { text, num } of lines) {
|
|
289
|
+
if (isCommentLine(text)) continue;
|
|
290
|
+
if (noneAlgo.test(text) || algoNone.test(text)) {
|
|
291
|
+
findings.push(
|
|
292
|
+
mkFinding(this, {
|
|
293
|
+
description:
|
|
294
|
+
'JWT verification allows the "none" algorithm, which disables signature verification entirely.',
|
|
295
|
+
file: filePath,
|
|
296
|
+
line: num,
|
|
297
|
+
})
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return findings;
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
// -----------------------------------------------------------------------
|
|
307
|
+
// SEC-AUTH-007 (critical): Missing auth middleware on API routes
|
|
308
|
+
// -----------------------------------------------------------------------
|
|
309
|
+
{
|
|
310
|
+
id: 'SEC-AUTH-007',
|
|
311
|
+
category: 'security',
|
|
312
|
+
severity: 'critical',
|
|
313
|
+
confidence: 'suggestion',
|
|
314
|
+
title: 'API route missing authentication middleware',
|
|
315
|
+
check({ files }) {
|
|
316
|
+
const findings = [];
|
|
317
|
+
const routeFile = /(?:route|router|api|endpoint)/i;
|
|
318
|
+
const routeDefinition =
|
|
319
|
+
/(?:router|app)\s*\.(?:get|post|put|patch|delete)\s*\(/i;
|
|
320
|
+
const authMiddleware =
|
|
321
|
+
/(?:auth|authenticate|verifyToken|protect|isAuthenticated|requireAuth|ensureAuth|passport\.authenticate|requireLogin|checkAuth)/i;
|
|
322
|
+
const publicRoute =
|
|
323
|
+
/(?:login|signup|register|health|ping|public|reset-password|verify-email|forgot|upload|webhook|static|assets)/i;
|
|
324
|
+
|
|
325
|
+
for (const [filePath, content] of files) {
|
|
326
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
327
|
+
if (!routeFile.test(filePath)) continue;
|
|
328
|
+
if (publicRoute.test(filePath)) continue;
|
|
329
|
+
|
|
330
|
+
if (!routeDefinition.test(content)) continue;
|
|
331
|
+
if (authMiddleware.test(content)) continue;
|
|
332
|
+
|
|
333
|
+
// Check if another file imports this router and applies auth middleware before it
|
|
334
|
+
const routeBasename = filePath.replace(/.*\//, '').replace(/\.[^.]+$/, '');
|
|
335
|
+
let coveredByParent = false;
|
|
336
|
+
for (const [otherPath, otherContent] of files) {
|
|
337
|
+
if (otherPath === filePath) continue;
|
|
338
|
+
if (!isJS(otherPath) || isTest(otherPath)) continue;
|
|
339
|
+
// Look for import of the current router file
|
|
340
|
+
const importsRouter = new RegExp(
|
|
341
|
+
'(?:import|require)[^;]*' + routeBasename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
|
342
|
+
'i'
|
|
343
|
+
);
|
|
344
|
+
if (importsRouter.test(otherContent) && authMiddleware.test(otherContent)) {
|
|
345
|
+
coveredByParent = true;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (coveredByParent) continue;
|
|
350
|
+
|
|
351
|
+
findings.push(
|
|
352
|
+
mkFinding(this, {
|
|
353
|
+
description:
|
|
354
|
+
'Route file defines API endpoints but does not reference any authentication middleware.',
|
|
355
|
+
file: filePath,
|
|
356
|
+
})
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return findings;
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
// -----------------------------------------------------------------------
|
|
364
|
+
// SEC-AUTH-008 (critical): No role check on admin routes
|
|
365
|
+
// -----------------------------------------------------------------------
|
|
366
|
+
{
|
|
367
|
+
id: 'SEC-AUTH-008',
|
|
368
|
+
category: 'security',
|
|
369
|
+
severity: 'critical',
|
|
370
|
+
confidence: 'suggestion',
|
|
371
|
+
title: 'Admin route missing role/permission check',
|
|
372
|
+
check({ files }) {
|
|
373
|
+
const findings = [];
|
|
374
|
+
const adminFile = /admin/i;
|
|
375
|
+
const routeDef =
|
|
376
|
+
/(?:router|app)\s*\.(?:get|post|put|patch|delete)\s*\(/i;
|
|
377
|
+
const roleCheck =
|
|
378
|
+
/(?:role|permission|isAdmin|authorize|requireRole|checkRole|hasPermission|rbac|can\(|ability)/i;
|
|
379
|
+
|
|
380
|
+
for (const [filePath, content] of files) {
|
|
381
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
382
|
+
if (!adminFile.test(filePath)) continue;
|
|
383
|
+
|
|
384
|
+
if (!routeDef.test(content)) continue;
|
|
385
|
+
if (roleCheck.test(content)) continue;
|
|
386
|
+
|
|
387
|
+
findings.push(
|
|
388
|
+
mkFinding(this, {
|
|
389
|
+
description:
|
|
390
|
+
'Admin route file does not include role or permission checks. Ensure admin endpoints verify user privileges.',
|
|
391
|
+
file: filePath,
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return findings;
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
// -----------------------------------------------------------------------
|
|
400
|
+
// SEC-AUTH-009 (medium): User enumeration via distinct error messages
|
|
401
|
+
// -----------------------------------------------------------------------
|
|
402
|
+
{
|
|
403
|
+
id: 'SEC-AUTH-009',
|
|
404
|
+
category: 'security',
|
|
405
|
+
severity: 'medium',
|
|
406
|
+
confidence: 'likely',
|
|
407
|
+
title: 'User enumeration via distinct error messages',
|
|
408
|
+
check({ files }) {
|
|
409
|
+
const findings = [];
|
|
410
|
+
const userNotFound =
|
|
411
|
+
/['"](?:user not found|no user|account not found|email not found|username not found|user does not exist|unknown user|no account)['"]/i;
|
|
412
|
+
const wrongPassword =
|
|
413
|
+
/['"](?:wrong password|incorrect password|invalid password|password is wrong|password mismatch|bad password)['"]/i;
|
|
414
|
+
|
|
415
|
+
for (const [filePath, content] of files) {
|
|
416
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
417
|
+
|
|
418
|
+
if (!userNotFound.test(content) || !wrongPassword.test(content)) continue;
|
|
419
|
+
|
|
420
|
+
// Find the first occurrence line
|
|
421
|
+
const lines = getLines(content);
|
|
422
|
+
let lineNum = null;
|
|
423
|
+
for (const { text, num } of lines) {
|
|
424
|
+
if (userNotFound.test(text) || wrongPassword.test(text)) {
|
|
425
|
+
lineNum = num;
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
findings.push(
|
|
431
|
+
mkFinding(this, {
|
|
432
|
+
description:
|
|
433
|
+
'Separate error messages for "user not found" and "wrong password" allow attackers to enumerate valid accounts. Use a generic message like "Invalid credentials".',
|
|
434
|
+
file: filePath,
|
|
435
|
+
line: lineNum,
|
|
436
|
+
})
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
return findings;
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
// -----------------------------------------------------------------------
|
|
444
|
+
// SEC-AUTH-010 (high): No brute force protection on login
|
|
445
|
+
// -----------------------------------------------------------------------
|
|
446
|
+
{
|
|
447
|
+
id: 'SEC-AUTH-010',
|
|
448
|
+
category: 'security',
|
|
449
|
+
severity: 'high',
|
|
450
|
+
confidence: 'suggestion',
|
|
451
|
+
title: 'Login handler missing brute force protection',
|
|
452
|
+
check({ files }) {
|
|
453
|
+
const findings = [];
|
|
454
|
+
const loginHandler =
|
|
455
|
+
/(?:login|signin|sign_in|authenticate)\s*(?:=|:|\()/i;
|
|
456
|
+
const routeLogin =
|
|
457
|
+
/\.(?:post|put)\s*\(\s*['"]\/(?:login|signin|auth\/login|auth\/signin)['"]/i;
|
|
458
|
+
const rateLimiting =
|
|
459
|
+
/(?:rateLimit|rateLimiter|RateLimit|rate_limit|throttle|slowDown|brute|express-rate-limit|express-brute|loginAttempts|maxAttempts|lockout)/i;
|
|
460
|
+
// Validation-only files (Joi/zod schemas) are not login handlers
|
|
461
|
+
const validationOnly =
|
|
462
|
+
/(?:Joi\.object|z\.object|Schema\s*=|schema\s*=)/i;
|
|
463
|
+
|
|
464
|
+
for (const [filePath, content] of files) {
|
|
465
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
466
|
+
|
|
467
|
+
if (!loginHandler.test(content) && !routeLogin.test(content)) continue;
|
|
468
|
+
// Skip files that only define validation schemas (no actual route/handler)
|
|
469
|
+
if (validationOnly.test(content) && !routeLogin.test(content)) continue;
|
|
470
|
+
if (rateLimiting.test(content)) continue;
|
|
471
|
+
|
|
472
|
+
const lines = getLines(content);
|
|
473
|
+
let lineNum = null;
|
|
474
|
+
for (const { text, num } of lines) {
|
|
475
|
+
if (loginHandler.test(text) || routeLogin.test(text)) {
|
|
476
|
+
lineNum = num;
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
findings.push(
|
|
482
|
+
mkFinding(this, {
|
|
483
|
+
description:
|
|
484
|
+
'Login handler does not reference rate limiting or brute force protection. Apply rate limiting to prevent credential stuffing.',
|
|
485
|
+
file: filePath,
|
|
486
|
+
line: lineNum,
|
|
487
|
+
})
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return findings;
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
// -----------------------------------------------------------------------
|
|
495
|
+
// SEC-AUTH-011 (high): Session fixation - no session regeneration
|
|
496
|
+
// -----------------------------------------------------------------------
|
|
497
|
+
{
|
|
498
|
+
id: 'SEC-AUTH-011',
|
|
499
|
+
category: 'security',
|
|
500
|
+
severity: 'high',
|
|
501
|
+
confidence: 'suggestion',
|
|
502
|
+
title: 'Session fixation vulnerability',
|
|
503
|
+
check({ files }) {
|
|
504
|
+
const findings = [];
|
|
505
|
+
const sessionUsage = /req\.session/i;
|
|
506
|
+
const loginContext =
|
|
507
|
+
/(?:login|signin|sign_in|authenticate)/i;
|
|
508
|
+
const regenerate =
|
|
509
|
+
/session\.regenerate|regenerateSession|req\.session\.regenerate/i;
|
|
510
|
+
// Must set user identity on the session (not just flash or other non-auth fields)
|
|
511
|
+
const sessionIdentitySet =
|
|
512
|
+
/req\.session\.\s*(?:userId|user(?:Id|_id)?|uid|account|memberId|sub|principal|identity)\s*=/i;
|
|
513
|
+
|
|
514
|
+
for (const [filePath, content] of files) {
|
|
515
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
516
|
+
|
|
517
|
+
if (!sessionUsage.test(content)) continue;
|
|
518
|
+
if (!loginContext.test(content)) continue;
|
|
519
|
+
if (regenerate.test(content)) continue;
|
|
520
|
+
// Only flag files that actually set a user identity in the session
|
|
521
|
+
if (!sessionIdentitySet.test(content)) continue;
|
|
522
|
+
|
|
523
|
+
findings.push(
|
|
524
|
+
mkFinding(this, {
|
|
525
|
+
description:
|
|
526
|
+
'Login handler uses sessions but does not call session.regenerate(). This may allow session fixation attacks.',
|
|
527
|
+
file: filePath,
|
|
528
|
+
})
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
return findings;
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
// -----------------------------------------------------------------------
|
|
536
|
+
// SEC-AUTH-012 (high): Insecure password reset token without expiry
|
|
537
|
+
// -----------------------------------------------------------------------
|
|
538
|
+
{
|
|
539
|
+
id: 'SEC-AUTH-012',
|
|
540
|
+
category: 'security',
|
|
541
|
+
severity: 'high',
|
|
542
|
+
confidence: 'suggestion',
|
|
543
|
+
title: 'Password reset token without expiration',
|
|
544
|
+
check({ files }) {
|
|
545
|
+
const findings = [];
|
|
546
|
+
const resetContext =
|
|
547
|
+
/(?:resetToken|reset_token|passwordReset|password_reset|forgotPassword|forgot_password)/i;
|
|
548
|
+
const expiryRef =
|
|
549
|
+
/(?:expires|expiresAt|expires_at|expiresIn|expires_in|ttl|expiry|expiration|validUntil|valid_until)/i;
|
|
550
|
+
|
|
551
|
+
for (const [filePath, content] of files) {
|
|
552
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
553
|
+
|
|
554
|
+
if (!resetContext.test(content)) continue;
|
|
555
|
+
if (expiryRef.test(content)) continue;
|
|
556
|
+
|
|
557
|
+
findings.push(
|
|
558
|
+
mkFinding(this, {
|
|
559
|
+
description:
|
|
560
|
+
'Password reset logic generates a token but does not set an expiration. Reset tokens should expire within a short window (e.g. 1 hour).',
|
|
561
|
+
file: filePath,
|
|
562
|
+
})
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
return findings;
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
// -----------------------------------------------------------------------
|
|
570
|
+
// SEC-AUTH-013 (high): OAuth without state parameter
|
|
571
|
+
// -----------------------------------------------------------------------
|
|
572
|
+
{
|
|
573
|
+
id: 'SEC-AUTH-013',
|
|
574
|
+
category: 'security',
|
|
575
|
+
severity: 'high',
|
|
576
|
+
confidence: 'likely',
|
|
577
|
+
title: 'OAuth flow missing state parameter',
|
|
578
|
+
check({ files }) {
|
|
579
|
+
const findings = [];
|
|
580
|
+
const oauthUrl =
|
|
581
|
+
/(?:\/authorize|\/oauth|oauth2|openid|authorization_endpoint)/i;
|
|
582
|
+
const stateParam =
|
|
583
|
+
/(?:state\s*[:=]|[&?]state=|state\s*:\s*|\.state\b)/i;
|
|
584
|
+
|
|
585
|
+
for (const [filePath, content] of files) {
|
|
586
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
587
|
+
|
|
588
|
+
if (!oauthUrl.test(content)) continue;
|
|
589
|
+
|
|
590
|
+
const lines = getLines(content);
|
|
591
|
+
for (const { text, num } of lines) {
|
|
592
|
+
if (isCommentLine(text)) continue;
|
|
593
|
+
if (oauthUrl.test(text)) {
|
|
594
|
+
const ctx = contextWindow(lines, num, 10);
|
|
595
|
+
if (!stateParam.test(ctx)) {
|
|
596
|
+
findings.push(
|
|
597
|
+
mkFinding(this, {
|
|
598
|
+
description:
|
|
599
|
+
'OAuth authorization request does not include a "state" parameter. This leaves the flow vulnerable to CSRF attacks.',
|
|
600
|
+
file: filePath,
|
|
601
|
+
line: num,
|
|
602
|
+
})
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return findings;
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
|
|
612
|
+
// -----------------------------------------------------------------------
|
|
613
|
+
// SEC-AUTH-014 (critical): OAuth open redirect
|
|
614
|
+
// -----------------------------------------------------------------------
|
|
615
|
+
{
|
|
616
|
+
id: 'SEC-AUTH-014',
|
|
617
|
+
category: 'security',
|
|
618
|
+
severity: 'critical',
|
|
619
|
+
confidence: 'likely',
|
|
620
|
+
title: 'OAuth open redirect via unvalidated redirect_uri',
|
|
621
|
+
check({ files }) {
|
|
622
|
+
const findings = [];
|
|
623
|
+
const redirectFromInput =
|
|
624
|
+
/redirect_uri\s*[:=]\s*(?:req\.query|req\.body|req\.params|request\.query|ctx\.query|ctx\.request)/i;
|
|
625
|
+
const validation =
|
|
626
|
+
/(?:whitelist|allowedRedirects|allowedUrls|validRedirect|validateRedirect|isAllowedRedirect|redirectWhitelist|allowedOrigins|safelist)/i;
|
|
627
|
+
|
|
628
|
+
for (const [filePath, content] of files) {
|
|
629
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
630
|
+
const lines = getLines(content);
|
|
631
|
+
for (const { text, num } of lines) {
|
|
632
|
+
if (isCommentLine(text)) continue;
|
|
633
|
+
if (redirectFromInput.test(text)) {
|
|
634
|
+
|
|
635
|
+
if (!validation.test(content)) {
|
|
636
|
+
findings.push(
|
|
637
|
+
mkFinding(this, {
|
|
638
|
+
description:
|
|
639
|
+
'redirect_uri is taken directly from user input without validation against an allow-list. This enables open redirect attacks.',
|
|
640
|
+
file: filePath,
|
|
641
|
+
line: num,
|
|
642
|
+
})
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return findings;
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
|
|
652
|
+
// -----------------------------------------------------------------------
|
|
653
|
+
// SEC-AUTH-015 (medium): No multi-factor authentication references
|
|
654
|
+
// -----------------------------------------------------------------------
|
|
655
|
+
{
|
|
656
|
+
id: 'SEC-AUTH-015',
|
|
657
|
+
category: 'security',
|
|
658
|
+
severity: 'medium',
|
|
659
|
+
confidence: 'suggestion',
|
|
660
|
+
title: 'No multi-factor authentication (MFA) implementation found',
|
|
661
|
+
check({ files }) {
|
|
662
|
+
const findings = [];
|
|
663
|
+
const mfaRef =
|
|
664
|
+
/(?:totp|mfa|2fa|two.?factor|authenticator|speakeasy|otplib|otp|one.?time.?password|google.?authenticator)/i;
|
|
665
|
+
|
|
666
|
+
// Only fire when the codebase has files containing actual login/signup
|
|
667
|
+
// route definitions — not on utility modules that merely verify tokens
|
|
668
|
+
// or hash passwords.
|
|
669
|
+
const authRoutePattern =
|
|
670
|
+
/\.(?:post|put)\s*\(\s*['"]\/(?:login|signin|auth\/login|signup|register)/i;
|
|
671
|
+
|
|
672
|
+
let hasAuthRoute = false;
|
|
673
|
+
let anyMfa = false;
|
|
674
|
+
for (const [filePath, content] of files) {
|
|
675
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
676
|
+
if (content && authRoutePattern.test(content)) hasAuthRoute = true;
|
|
677
|
+
if (content && mfaRef.test(content)) anyMfa = true;
|
|
678
|
+
}
|
|
679
|
+
if (!hasAuthRoute) return findings;
|
|
680
|
+
|
|
681
|
+
if (!anyMfa) {
|
|
682
|
+
findings.push(
|
|
683
|
+
mkFinding(this, {
|
|
684
|
+
description:
|
|
685
|
+
'No references to multi-factor authentication (TOTP, 2FA, etc.) found in the codebase. Consider adding MFA for sensitive accounts.',
|
|
686
|
+
})
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
return findings;
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
|
|
693
|
+
// -----------------------------------------------------------------------
|
|
694
|
+
// SEC-AUTH-016 (high): Credentials in URL / query string
|
|
695
|
+
// -----------------------------------------------------------------------
|
|
696
|
+
{
|
|
697
|
+
id: 'SEC-AUTH-016',
|
|
698
|
+
category: 'security',
|
|
699
|
+
severity: 'high',
|
|
700
|
+
confidence: 'likely',
|
|
701
|
+
title: 'Credentials passed in URL query string',
|
|
702
|
+
check({ files }) {
|
|
703
|
+
const findings = [];
|
|
704
|
+
const credInQuery =
|
|
705
|
+
/[?&](?:token|password|passwd|pwd|secret|api_key|apiKey|access_token|auth_token)=/i;
|
|
706
|
+
const templateCred =
|
|
707
|
+
/(?:\$\{.*(?:token|password|secret|api_key|apiKey).*\}|['"].*[?&](?:token|password|secret|api_key|apiKey)=['"]?\s*\+)/i;
|
|
708
|
+
|
|
709
|
+
for (const [filePath, content] of files) {
|
|
710
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
711
|
+
const lines = getLines(content);
|
|
712
|
+
for (const { text, num } of lines) {
|
|
713
|
+
if (isCommentLine(text)) continue;
|
|
714
|
+
if (credInQuery.test(text) || templateCred.test(text)) {
|
|
715
|
+
findings.push(
|
|
716
|
+
mkFinding(this, {
|
|
717
|
+
description:
|
|
718
|
+
'Credentials (token, password, API key) are passed via URL query string. Use headers or POST body instead to avoid logging exposure.',
|
|
719
|
+
file: filePath,
|
|
720
|
+
line: num,
|
|
721
|
+
})
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return findings;
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
// -----------------------------------------------------------------------
|
|
731
|
+
// SEC-AUTH-017 (critical): Default / well-known credentials
|
|
732
|
+
// -----------------------------------------------------------------------
|
|
733
|
+
{
|
|
734
|
+
id: 'SEC-AUTH-017',
|
|
735
|
+
category: 'security',
|
|
736
|
+
severity: 'critical',
|
|
737
|
+
confidence: 'definite',
|
|
738
|
+
title: 'Default or well-known credentials detected',
|
|
739
|
+
check({ files }) {
|
|
740
|
+
const findings = [];
|
|
741
|
+
const defaultCreds =
|
|
742
|
+
/(?:password|passwd|pwd|secret|token)\s*[:=]\s*['"](?:password123|admin123|changeme|letmein|qwerty|123456|passw0rd|welcome1|test1234)['"]/i;
|
|
743
|
+
const adminAdmin =
|
|
744
|
+
/(?:username|user|login)\s*[:=]\s*['"]admin['"]\s*[,;]?\s*(?:\n\s*)?(?:password|passwd|pwd)\s*[:=]\s*['"]admin['"]/i;
|
|
745
|
+
|
|
746
|
+
for (const [filePath, content] of files) {
|
|
747
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
748
|
+
const lines = getLines(content);
|
|
749
|
+
for (const { text, num } of lines) {
|
|
750
|
+
if (isCommentLine(text)) continue;
|
|
751
|
+
if (defaultCreds.test(text)) {
|
|
752
|
+
findings.push(
|
|
753
|
+
mkFinding(this, {
|
|
754
|
+
description:
|
|
755
|
+
'Default or well-known credentials found in source code. Remove hardcoded credentials and require secure values.',
|
|
756
|
+
file: filePath,
|
|
757
|
+
line: num,
|
|
758
|
+
})
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// Check for admin/admin across adjacent lines
|
|
763
|
+
|
|
764
|
+
if (adminAdmin.test(content)) {
|
|
765
|
+
findings.push(
|
|
766
|
+
mkFinding(this, {
|
|
767
|
+
description:
|
|
768
|
+
'Default admin/admin credential pair detected. Remove hardcoded credentials and require secure values.',
|
|
769
|
+
file: filePath,
|
|
770
|
+
})
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return findings;
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
|
|
778
|
+
// -----------------------------------------------------------------------
|
|
779
|
+
// SEC-AUTH-018 (medium): Insecure remember-me token
|
|
780
|
+
// -----------------------------------------------------------------------
|
|
781
|
+
{
|
|
782
|
+
id: 'SEC-AUTH-018',
|
|
783
|
+
category: 'security',
|
|
784
|
+
severity: 'medium',
|
|
785
|
+
confidence: 'likely',
|
|
786
|
+
title: 'Insecure remember-me token implementation',
|
|
787
|
+
check({ files }) {
|
|
788
|
+
const findings = [];
|
|
789
|
+
const rememberMe =
|
|
790
|
+
/(?:remember.?me|rememberMe|remember_me|keepLoggedIn|keep_logged_in|stayLoggedIn)/i;
|
|
791
|
+
const tokenExpiry =
|
|
792
|
+
/(?:maxAge|max_age|expires|expiresIn|expires_in|expiresAt|httpOnly|secure)/i;
|
|
793
|
+
|
|
794
|
+
for (const [filePath, content] of files) {
|
|
795
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
796
|
+
|
|
797
|
+
if (!rememberMe.test(content)) continue;
|
|
798
|
+
|
|
799
|
+
const lines = getLines(content);
|
|
800
|
+
for (const { text, num } of lines) {
|
|
801
|
+
if (isCommentLine(text)) continue;
|
|
802
|
+
if (rememberMe.test(text)) {
|
|
803
|
+
const ctx = contextWindow(lines, num, 10);
|
|
804
|
+
if (!tokenExpiry.test(ctx)) {
|
|
805
|
+
findings.push(
|
|
806
|
+
mkFinding(this, {
|
|
807
|
+
description:
|
|
808
|
+
'Remember-me token is set without maxAge/expires or security flags. Ensure the token has a bounded lifetime and is marked httpOnly/secure.',
|
|
809
|
+
file: filePath,
|
|
810
|
+
line: num,
|
|
811
|
+
})
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return findings;
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
|
|
821
|
+
// -----------------------------------------------------------------------
|
|
822
|
+
// SEC-AUTH-019 (medium): Logout without session invalidation
|
|
823
|
+
// -----------------------------------------------------------------------
|
|
824
|
+
{
|
|
825
|
+
id: 'SEC-AUTH-019',
|
|
826
|
+
category: 'security',
|
|
827
|
+
severity: 'medium',
|
|
828
|
+
confidence: 'suggestion',
|
|
829
|
+
title: 'Logout handler does not invalidate session/token',
|
|
830
|
+
check({ files }) {
|
|
831
|
+
const findings = [];
|
|
832
|
+
const logoutRoute =
|
|
833
|
+
/(?:\/logout|\/signout|\/sign-out|\/sign_out)/i;
|
|
834
|
+
const logoutHandler =
|
|
835
|
+
/(?:logout|signout|sign_out|signOut|logOut)\s*(?:=|:|\(|async)/i;
|
|
836
|
+
const invalidation =
|
|
837
|
+
/(?:destroy|delete|invalidate|revoke|remove|clear|expire|blacklist|blocklist|\.del\(|\.remove\(|clearCookie|removeToken)/i;
|
|
838
|
+
|
|
839
|
+
for (const [filePath, content] of files) {
|
|
840
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
841
|
+
|
|
842
|
+
if (!logoutRoute.test(content) && !logoutHandler.test(content)) continue;
|
|
843
|
+
if (invalidation.test(content)) continue;
|
|
844
|
+
|
|
845
|
+
const lines = getLines(content);
|
|
846
|
+
let lineNum = null;
|
|
847
|
+
for (const { text, num } of lines) {
|
|
848
|
+
if (logoutRoute.test(text) || logoutHandler.test(text)) {
|
|
849
|
+
lineNum = num;
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
findings.push(
|
|
855
|
+
mkFinding(this, {
|
|
856
|
+
description:
|
|
857
|
+
'Logout handler does not destroy, delete, or invalidate the session or token. Clients may remain authenticated after logout.',
|
|
858
|
+
file: filePath,
|
|
859
|
+
line: lineNum,
|
|
860
|
+
})
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
return findings;
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
|
|
867
|
+
// -----------------------------------------------------------------------
|
|
868
|
+
// SEC-AUTH-020 (critical): API key in client-side code
|
|
869
|
+
// -----------------------------------------------------------------------
|
|
870
|
+
{
|
|
871
|
+
id: 'SEC-AUTH-020',
|
|
872
|
+
category: 'security',
|
|
873
|
+
severity: 'critical',
|
|
874
|
+
confidence: 'definite',
|
|
875
|
+
title: 'API key exposed in client-side code',
|
|
876
|
+
check({ files }) {
|
|
877
|
+
const findings = [];
|
|
878
|
+
const clientDir =
|
|
879
|
+
/(?:^|\/)(?:pages|app|components|src\/components|src\/pages|src\/app|public|static|client|frontend|views)\//i;
|
|
880
|
+
const apiKeyPattern =
|
|
881
|
+
/(?:api[_-]?key|apiKey|API_KEY|secret[_-]?key|secretKey|SECRET_KEY|auth[_-]?token|AUTH_TOKEN)\s*[:=]\s*['"][A-Za-z0-9_\-/+=.]{8,}['"]/i;
|
|
882
|
+
const envRef =
|
|
883
|
+
/process\.env|import\.meta\.env|NEXT_PUBLIC_|REACT_APP_|VITE_/i;
|
|
884
|
+
|
|
885
|
+
for (const [filePath, content] of files) {
|
|
886
|
+
if (!isJS(filePath) || isTest(filePath)) continue;
|
|
887
|
+
if (!clientDir.test(filePath)) continue;
|
|
888
|
+
const lines = getLines(content);
|
|
889
|
+
for (const { text, num } of lines) {
|
|
890
|
+
if (isCommentLine(text)) continue;
|
|
891
|
+
if (apiKeyPattern.test(text) && !envRef.test(text)) {
|
|
892
|
+
findings.push(
|
|
893
|
+
mkFinding(this, {
|
|
894
|
+
description:
|
|
895
|
+
'Hardcoded API key or secret found in client-side code. This will be exposed to end users. Use environment variables with a public prefix (NEXT_PUBLIC_, REACT_APP_, etc.) or a backend proxy.',
|
|
896
|
+
file: filePath,
|
|
897
|
+
line: num,
|
|
898
|
+
})
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return findings;
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
];
|
|
907
|
+
|
|
908
|
+
export default rules;
|
|
909
|
+
|
|
910
|
+
// SEC-AUTH-021: JWT algorithm:none vulnerability
|
|
911
|
+
const _auth021 = {
|
|
912
|
+
id: 'SEC-AUTH-021', category: 'security', severity: 'critical', confidence: 'definite',
|
|
913
|
+
title: 'JWT algorithm:none — tokens accepted without signature',
|
|
914
|
+
check({ files }) {
|
|
915
|
+
const findings = [];
|
|
916
|
+
const noneAlg = /(?:algorithm|alg)\s*[:=]\s*['"]none['"]/i;
|
|
917
|
+
for (const [fp, c] of files) {
|
|
918
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
919
|
+
const lines = c.split('\n');
|
|
920
|
+
for (let i = 0; i < lines.length; i++) {
|
|
921
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
922
|
+
if (noneAlg.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-021', category: 'security', severity: 'critical', title: 'JWT alg:none disables signature verification — forge any token', description: 'Setting JWT algorithm to "none" disables signature verification, allowing attackers to forge tokens.', file: fp, line: i + 1, fix: null });
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return findings;
|
|
926
|
+
},
|
|
927
|
+
};
|
|
928
|
+
rules.push(_auth021);
|
|
929
|
+
|
|
930
|
+
// SEC-AUTH-022: JWT signed with weak hardcoded secret
|
|
931
|
+
rules.push({
|
|
932
|
+
id: 'SEC-AUTH-022', category: 'security', severity: 'critical', confidence: 'definite',
|
|
933
|
+
title: 'JWT signed with hardcoded weak secret',
|
|
934
|
+
check({ files }) {
|
|
935
|
+
const findings = [];
|
|
936
|
+
const p = /jwt\.sign\s*\([^,]+,\s*['"][^'"]{1,20}['"]/;
|
|
937
|
+
for (const [fp, c] of files) {
|
|
938
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
939
|
+
const lines = c.split('\n');
|
|
940
|
+
for (let i = 0; i < lines.length; i++) {
|
|
941
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
942
|
+
if (p.test(lines[i]) && !/process\.env/.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-022', category: 'security', severity: 'critical', title: 'JWT signed with short hardcoded secret — brute-forceable', description: 'Use a long random secret from environment variables for JWT signing.', file: fp, line: i + 1, fix: null });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return findings;
|
|
946
|
+
},
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
// SEC-AUTH-023: JWT without expiry
|
|
950
|
+
rules.push({
|
|
951
|
+
id: 'SEC-AUTH-023', category: 'security', severity: 'high', confidence: 'likely',
|
|
952
|
+
title: 'JWT issued without expiration (expiresIn not set)',
|
|
953
|
+
check({ files }) {
|
|
954
|
+
const findings = [];
|
|
955
|
+
for (const [fp, c] of files) {
|
|
956
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
957
|
+
const lines = c.split('\n');
|
|
958
|
+
for (let i = 0; i < lines.length; i++) {
|
|
959
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
960
|
+
if (/jwt\.sign\s*\(/.test(lines[i]) && !/expiresIn|exp\s*:/.test(lines[i])) {
|
|
961
|
+
const ctx = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 4)).join('\n');
|
|
962
|
+
if (!/expiresIn|exp\s*:/.test(ctx)) findings.push({ ruleId: 'SEC-AUTH-023', category: 'security', severity: 'high', title: 'JWT signed without expiresIn — tokens valid forever', description: 'Tokens without expiry cannot be revoked if stolen. Set expiresIn to the shortest practical duration.', file: fp, line: i + 1, fix: null });
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
return findings;
|
|
967
|
+
},
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
// SEC-AUTH-024: JWT verify without algorithm
|
|
971
|
+
rules.push({
|
|
972
|
+
id: 'SEC-AUTH-024', category: 'security', severity: 'high', confidence: 'likely',
|
|
973
|
+
title: 'JWT verify() without explicit algorithms option',
|
|
974
|
+
check({ files }) {
|
|
975
|
+
const findings = [];
|
|
976
|
+
for (const [fp, c] of files) {
|
|
977
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
978
|
+
const lines = c.split('\n');
|
|
979
|
+
for (let i = 0; i < lines.length; i++) {
|
|
980
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
981
|
+
if (/jwt\.verify\s*\(/.test(lines[i])) {
|
|
982
|
+
const ctx = lines.slice(Math.max(0, i - 1), Math.min(lines.length, i + 4)).join('\n');
|
|
983
|
+
if (!/algorithms\s*:|algorithm\s*:/.test(ctx)) findings.push({ ruleId: 'SEC-AUTH-024', category: 'security', severity: 'high', title: 'jwt.verify() without algorithms option — alg confusion attack', description: 'Pass { algorithms: ["RS256"] } to jwt.verify() to prevent algorithm confusion attacks.', file: fp, line: i + 1, fix: null });
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return findings;
|
|
988
|
+
},
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
// SEC-AUTH-025: No rate limiting on auth endpoints
|
|
992
|
+
rules.push({
|
|
993
|
+
id: 'SEC-AUTH-025', category: 'security', severity: 'high', confidence: 'suggestion',
|
|
994
|
+
title: 'No rate limiting on authentication endpoints',
|
|
995
|
+
check({ files }) {
|
|
996
|
+
const findings = [];
|
|
997
|
+
const authRoute = /(?:router|app)\s*\.post\s*\(\s*['"`][^'"`]*(?:login|signin|sign-in|auth\/token|\/token)[^'"`]*/i;
|
|
998
|
+
const rateLimit = /rateLimit|rate-limit|express-rate|throttle|slowDown/i;
|
|
999
|
+
for (const [fp, c] of files) {
|
|
1000
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1001
|
+
if (!authRoute.test(c)) continue;
|
|
1002
|
+
if (!rateLimit.test(c)) {
|
|
1003
|
+
const lines = c.split('\n');
|
|
1004
|
+
let ln = null;
|
|
1005
|
+
for (let i = 0; i < lines.length; i++) { if (authRoute.test(lines[i])) { ln = i + 1; break; } }
|
|
1006
|
+
findings.push({ ruleId: 'SEC-AUTH-025', category: 'security', severity: 'high', title: 'Auth endpoint without rate limiting — brute-force risk', description: 'Authentication endpoints without rate limiting allow unlimited password guesses. Use express-rate-limit.', file: fp, line: ln, fix: null });
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return findings;
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// SEC-AUTH-026: Session fixation
|
|
1014
|
+
rules.push({
|
|
1015
|
+
id: 'SEC-AUTH-026', category: 'security', severity: 'critical', confidence: 'likely',
|
|
1016
|
+
title: 'Session fixation: session not regenerated after login',
|
|
1017
|
+
check({ files }) {
|
|
1018
|
+
const findings = [];
|
|
1019
|
+
const loginPat = /(?:login|signin|sign_in|authenticate)\s*(?:=|:|\(|async)/i;
|
|
1020
|
+
const regenPat = /session\.regenerate|req\.session\.regenerate/i;
|
|
1021
|
+
for (const [fp, c] of files) {
|
|
1022
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1023
|
+
if (loginPat.test(c) && !regenPat.test(c) && /express-session|req\.session/.test(c)) {
|
|
1024
|
+
findings.push({ ruleId: 'SEC-AUTH-026', category: 'security', severity: 'critical', title: 'Session not regenerated after login — session fixation vulnerability', description: 'Call req.session.regenerate() after successful login to prevent session fixation attacks.', file: fp, fix: null });
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return findings;
|
|
1028
|
+
},
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
// SEC-AUTH-027: Cookie without SameSite
|
|
1032
|
+
rules.push({
|
|
1033
|
+
id: 'SEC-AUTH-027', category: 'security', severity: 'high', confidence: 'likely',
|
|
1034
|
+
title: 'Cookie set without SameSite attribute',
|
|
1035
|
+
check({ files }) {
|
|
1036
|
+
const findings = [];
|
|
1037
|
+
for (const [fp, c] of files) {
|
|
1038
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1039
|
+
const lines = c.split('\n');
|
|
1040
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1041
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1042
|
+
if (/res\.cookie\s*\(/.test(lines[i]) && !/sameSite|SameSite/.test(lines[i])) {
|
|
1043
|
+
// Scan ahead to the closing ) to capture multi-line options object
|
|
1044
|
+
let end = i + 1;
|
|
1045
|
+
let depth = (lines[i].match(/\(/g)||[]).length - (lines[i].match(/\)/g)||[]).length;
|
|
1046
|
+
while (end < lines.length && depth > 0) {
|
|
1047
|
+
depth += (lines[end].match(/\(/g)||[]).length - (lines[end].match(/\)/g)||[]).length;
|
|
1048
|
+
end++;
|
|
1049
|
+
}
|
|
1050
|
+
const ctx = lines.slice(i, Math.min(lines.length, end + 1)).join('\n');
|
|
1051
|
+
if (!/sameSite|SameSite/.test(ctx)) findings.push({ ruleId: 'SEC-AUTH-027', category: 'security', severity: 'high', title: 'Cookie without SameSite attribute — CSRF risk', description: 'Set sameSite: "strict" or "lax" on all cookies to prevent CSRF attacks.', file: fp, line: i + 1, fix: null });
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return findings;
|
|
1056
|
+
},
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// SEC-AUTH-028: Bearer token logged
|
|
1060
|
+
rules.push({
|
|
1061
|
+
id: 'SEC-AUTH-028', category: 'security', severity: 'high', confidence: 'likely',
|
|
1062
|
+
title: 'Auth token or Bearer header logged',
|
|
1063
|
+
check({ files }) {
|
|
1064
|
+
const findings = [];
|
|
1065
|
+
const p = /(?:console\.|logger\.)\w*\s*\([^)]*(?:Bearer|authorization|token|JWT)/i;
|
|
1066
|
+
for (const [fp, c] of files) {
|
|
1067
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1068
|
+
const lines = c.split('\n');
|
|
1069
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1070
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1071
|
+
if (p.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-028', category: 'security', severity: 'high', title: 'Auth token logged — credential exposure in logs', description: 'Logging Bearer tokens or auth headers exposes credentials. Redact before logging.', file: fp, line: i + 1, fix: null });
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return findings;
|
|
1075
|
+
},
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
// SEC-AUTH-029: OAuth missing state parameter
|
|
1079
|
+
rules.push({
|
|
1080
|
+
id: 'SEC-AUTH-029', category: 'security', severity: 'medium', confidence: 'suggestion',
|
|
1081
|
+
title: 'OAuth flow missing CSRF state parameter',
|
|
1082
|
+
check({ files }) {
|
|
1083
|
+
const findings = [];
|
|
1084
|
+
const oauthPat = /authorization_code|oauth.*redirect_uri/i;
|
|
1085
|
+
const statePat = /state\s*[:=]|&state=|\?state=/i;
|
|
1086
|
+
for (const [fp, c] of files) {
|
|
1087
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1088
|
+
if (oauthPat.test(c) && !statePat.test(c)) {
|
|
1089
|
+
findings.push({ ruleId: 'SEC-AUTH-029', category: 'security', severity: 'medium', title: 'OAuth flow without state parameter — CSRF possible', description: 'Include a random state parameter in OAuth flows and verify it on callback to prevent CSRF.', file: fp, fix: null });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return findings;
|
|
1093
|
+
},
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// SEC-AUTH-030: Auth token in localStorage
|
|
1097
|
+
rules.push({
|
|
1098
|
+
id: 'SEC-AUTH-030', category: 'security', severity: 'high', confidence: 'likely',
|
|
1099
|
+
title: 'Auth token stored in localStorage — XSS exposure',
|
|
1100
|
+
check({ files }) {
|
|
1101
|
+
const findings = [];
|
|
1102
|
+
const p = /localStorage\.setItem\s*\(\s*['"][^'"]*(?:token|jwt|auth|session)[^'"]*['"]/i;
|
|
1103
|
+
for (const [fp, c] of files) {
|
|
1104
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1105
|
+
const lines = c.split('\n');
|
|
1106
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1107
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1108
|
+
if (p.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-030', category: 'security', severity: 'high', title: 'Auth token in localStorage is accessible to XSS', description: 'Use httpOnly cookies for auth tokens. localStorage is accessible to any JavaScript running on the page.', file: fp, line: i + 1, fix: null });
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return findings;
|
|
1112
|
+
},
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
// SEC-AUTH-031: Hardcoded admin credentials
|
|
1116
|
+
rules.push({
|
|
1117
|
+
id: 'SEC-AUTH-031', category: 'security', severity: 'critical', confidence: 'definite',
|
|
1118
|
+
title: 'Hardcoded default/admin credentials',
|
|
1119
|
+
check({ files }) {
|
|
1120
|
+
const findings = [];
|
|
1121
|
+
const p = /(?:username|user|admin|password|passwd|pwd)\s*[:=]\s*['"]\s*(?:admin|password|123456|root|secret|admin123|pass|test)['"]/i;
|
|
1122
|
+
for (const [fp, c] of files) {
|
|
1123
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec') || fp.includes('fixture')) continue;
|
|
1124
|
+
const lines = c.split('\n');
|
|
1125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1126
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1127
|
+
if (p.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-031', category: 'security', severity: 'critical', title: 'Hardcoded credentials found — rotate immediately', description: 'Hardcoded credentials in source code will be committed to version control. Use environment variables.', file: fp, line: i + 1, fix: null });
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return findings;
|
|
1131
|
+
},
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// SEC-AUTH-032: Missing account lockout
|
|
1135
|
+
rules.push({
|
|
1136
|
+
id: 'SEC-AUTH-032', category: 'security', severity: 'medium', confidence: 'suggestion',
|
|
1137
|
+
title: 'No account lockout after failed login attempts',
|
|
1138
|
+
check({ files }) {
|
|
1139
|
+
const findings = [];
|
|
1140
|
+
const loginPat = /(?:router|app)\s*\.post\s*\(\s*['"`][^'"`]*(?:login|signin)[^'"`]*/i;
|
|
1141
|
+
const lockoutPat = /lockout|failed.*attempt|attemptCount|loginAttempt|failCount|account.*lock/i;
|
|
1142
|
+
for (const [fp, c] of files) {
|
|
1143
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1144
|
+
if (loginPat.test(c) && !lockoutPat.test(c)) {
|
|
1145
|
+
findings.push({ ruleId: 'SEC-AUTH-032', category: 'security', severity: 'medium', title: 'Login route without account lockout logic', description: 'Implement account lockout or exponential backoff after repeated failed login attempts to prevent brute-force.', file: fp, fix: null });
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
return findings;
|
|
1149
|
+
},
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
// SEC-AUTH-033: Insecure password reset via predictable token
|
|
1153
|
+
rules.push({
|
|
1154
|
+
id: 'SEC-AUTH-033', category: 'security', severity: 'high', confidence: 'likely',
|
|
1155
|
+
title: 'Password reset token using Math.random() — predictable',
|
|
1156
|
+
check({ files }) {
|
|
1157
|
+
const findings = [];
|
|
1158
|
+
const resetPat = /(?:reset|forgot).*password|password.*reset/i;
|
|
1159
|
+
const randPat = /Math\.random\(\)/;
|
|
1160
|
+
for (const [fp, c] of files) {
|
|
1161
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1162
|
+
const lines = c.split('\n');
|
|
1163
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1164
|
+
if (isCommentLine(lines[i])) continue;
|
|
1165
|
+
if (resetPat.test(lines[i]) && randPat.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-033', category: 'security', severity: 'high', title: 'Password reset using Math.random() — token is predictable', description: 'Use crypto.randomBytes() to generate cryptographically secure password reset tokens.', file: fp, line: i + 1, fix: null });
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
return findings;
|
|
1169
|
+
},
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
// SEC-AUTH-034: JWT decoded without verification
|
|
1173
|
+
rules.push({
|
|
1174
|
+
id: 'SEC-AUTH-034', category: 'security', severity: 'critical', confidence: 'likely',
|
|
1175
|
+
title: 'JWT decoded without signature verification',
|
|
1176
|
+
check({ files }) {
|
|
1177
|
+
const findings = [];
|
|
1178
|
+
const p = /jwt\.decode\s*\(/;
|
|
1179
|
+
for (const [fp, c] of files) {
|
|
1180
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1181
|
+
const lines = c.split('\n');
|
|
1182
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1183
|
+
if (isCommentLine(lines[i])) continue;
|
|
1184
|
+
if (p.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-034', category: 'security', severity: 'critical', title: 'jwt.decode() used without verification — forged tokens accepted', description: 'Use jwt.verify() instead of jwt.decode(). The decode function does not validate the signature.', file: fp, line: i + 1, fix: null });
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
return findings;
|
|
1188
|
+
},
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
// SEC-AUTH-035: CSRF token not verified
|
|
1192
|
+
rules.push({
|
|
1193
|
+
id: 'SEC-AUTH-035', category: 'security', severity: 'high', confidence: 'suggestion',
|
|
1194
|
+
title: 'POST/PUT/DELETE route without CSRF protection',
|
|
1195
|
+
check({ files, stack }) {
|
|
1196
|
+
const findings = [];
|
|
1197
|
+
// APIs using JWT/token auth or CORS are not vulnerable to CSRF
|
|
1198
|
+
const deps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1199
|
+
if (deps.cors || deps['@fastify/cors'] || deps['hono']) return findings;
|
|
1200
|
+
const routePat = /(?:router|app)\.(?:post|put|delete|patch)\s*\(/;
|
|
1201
|
+
const csrfPat = /csrf|csurf|xsrf|_csrf|csrfToken/i;
|
|
1202
|
+
const apiAuthPat = /Bearer|jwt|verifyToken|authenticate|passport|apiKey|api_key/i;
|
|
1203
|
+
for (const [fp, c] of files) {
|
|
1204
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1205
|
+
// Skip API files — token-based auth makes CSRF moot
|
|
1206
|
+
if (apiAuthPat.test(c)) continue;
|
|
1207
|
+
if (routePat.test(c) && !csrfPat.test(c)) {
|
|
1208
|
+
findings.push({ ruleId: 'SEC-AUTH-035', category: 'security', severity: 'high', title: 'Mutating routes without CSRF protection', description: 'Use csurf middleware or implement custom CSRF token validation for state-changing routes.', file: fp, fix: null });
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return findings;
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
// SEC-AUTH-036: Wildcard CORS origin
|
|
1216
|
+
rules.push({
|
|
1217
|
+
id: 'SEC-AUTH-036', category: 'security', severity: 'high', confidence: 'definite',
|
|
1218
|
+
title: 'CORS configured with wildcard origin (*)',
|
|
1219
|
+
check({ files }) {
|
|
1220
|
+
const findings = [];
|
|
1221
|
+
const p = /cors\s*\(\s*\{\s*origin\s*:\s*['"]\*['"]/;
|
|
1222
|
+
for (const [fp, c] of files) {
|
|
1223
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1224
|
+
const lines = c.split('\n');
|
|
1225
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1226
|
+
if (isCommentLine(lines[i])) continue;
|
|
1227
|
+
if (p.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-036', category: 'security', severity: 'high', title: 'CORS origin set to wildcard (*) — any domain can make requests', description: 'Restrict CORS to a specific list of trusted origins. Never use * in production.', file: fp, line: i + 1, fix: null });
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return findings;
|
|
1231
|
+
},
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
// SEC-AUTH-037: Authentication bypass via type coercion
|
|
1235
|
+
rules.push({
|
|
1236
|
+
id: 'SEC-AUTH-037', category: 'security', severity: 'critical', confidence: 'likely',
|
|
1237
|
+
title: 'Password comparison with == — type coercion bypass',
|
|
1238
|
+
check({ files }) {
|
|
1239
|
+
const findings = [];
|
|
1240
|
+
const p = /password\s*==\s*(?!==)|(?:pwd|pass|passwd)\s*==\s*(?!==)/i;
|
|
1241
|
+
for (const [fp, c] of files) {
|
|
1242
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1243
|
+
const lines = c.split('\n');
|
|
1244
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1245
|
+
if (isCommentLine(lines[i])) continue;
|
|
1246
|
+
if (p.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-037', category: 'security', severity: 'critical', title: 'Password compared with == instead of === — type coercion bypass', description: 'Use strict equality (===) or a constant-time comparison function like crypto.timingSafeEqual().', file: fp, line: i + 1, fix: null });
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return findings;
|
|
1250
|
+
},
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
// SEC-AUTH-038: Session secret hardcoded or too short
|
|
1254
|
+
rules.push({
|
|
1255
|
+
id: 'SEC-AUTH-038', category: 'security', severity: 'high', confidence: 'likely',
|
|
1256
|
+
title: 'Session secret is short or hardcoded string',
|
|
1257
|
+
check({ files }) {
|
|
1258
|
+
const findings = [];
|
|
1259
|
+
const p = /session\s*\(\s*\{[^}]*secret\s*:\s*['"][^'"]{1,15}['"]/;
|
|
1260
|
+
for (const [fp, c] of files) {
|
|
1261
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1262
|
+
const lines = c.split('\n');
|
|
1263
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1264
|
+
if (isCommentLine(lines[i])) continue;
|
|
1265
|
+
if (p.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-038', category: 'security', severity: 'high', title: 'Session secret is short or hardcoded — session hijacking risk', description: 'Use a randomly generated secret of at least 32 characters stored in an environment variable.', file: fp, line: i + 1, fix: null });
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return findings;
|
|
1269
|
+
},
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
// SEC-AUTH-039: API key in request header logged
|
|
1273
|
+
rules.push({
|
|
1274
|
+
id: 'SEC-AUTH-039', category: 'security', severity: 'high', confidence: 'likely',
|
|
1275
|
+
title: 'API key from request headers passed to logger',
|
|
1276
|
+
check({ files }) {
|
|
1277
|
+
const findings = [];
|
|
1278
|
+
const p = /(?:console\.log|logger\.\w+)\s*\([^)]*req\.headers/i;
|
|
1279
|
+
for (const [fp, c] of files) {
|
|
1280
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1281
|
+
const lines = c.split('\n');
|
|
1282
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1283
|
+
if (isCommentLine(lines[i])) continue;
|
|
1284
|
+
if (p.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-039', category: 'security', severity: 'high', title: 'Request headers logged — may expose API keys or auth tokens', description: 'Redact sensitive headers (Authorization, X-API-Key) before logging request objects.', file: fp, line: i + 1, fix: null });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return findings;
|
|
1288
|
+
},
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
// SEC-AUTH-040: Missing authentication on admin routes
|
|
1292
|
+
rules.push({
|
|
1293
|
+
id: 'SEC-AUTH-040', category: 'security', severity: 'high', confidence: 'suggestion',
|
|
1294
|
+
title: 'Admin route without authentication middleware',
|
|
1295
|
+
check({ files }) {
|
|
1296
|
+
const findings = [];
|
|
1297
|
+
const routePat = /(?:router|app)\.(?:get|post|put|delete|patch)\s*\(\s*['"`][^'"`]*admin[^'"`]*/i;
|
|
1298
|
+
const authPat = /authenticate|authorize|isAdmin|requireAuth|authMiddleware|passport\.authenticate|verifyToken|checkAuth/i;
|
|
1299
|
+
for (const [fp, c] of files) {
|
|
1300
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1301
|
+
const lines = c.split('\n');
|
|
1302
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1303
|
+
if (isCommentLine(lines[i])) continue;
|
|
1304
|
+
if (routePat.test(lines[i]) && !authPat.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-040', category: 'security', severity: 'critical', title: 'Admin route without visible auth middleware', description: 'Ensure admin routes are protected with authentication and authorization middleware.', file: fp, line: i + 1, fix: null });
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
return findings;
|
|
1308
|
+
},
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
// SEC-AUTH-041: Refresh token stored in localStorage
|
|
1312
|
+
rules.push({
|
|
1313
|
+
id: 'SEC-AUTH-041', category: 'security', severity: 'high', confidence: 'likely',
|
|
1314
|
+
title: 'Refresh token stored in localStorage — XSS theft risk',
|
|
1315
|
+
check({ files }) {
|
|
1316
|
+
const findings = [];
|
|
1317
|
+
const p = /localStorage\s*\.\s*setItem\s*\([^)]*(?:refresh|refreshToken|refresh_token)/i;
|
|
1318
|
+
for (const [fp, c] of files) {
|
|
1319
|
+
if (!JS_EXT.some(e => fp.endsWith(e)) || fp.includes('test') || fp.includes('spec')) continue;
|
|
1320
|
+
const lines = c.split('\n');
|
|
1321
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1322
|
+
if (isCommentLine(lines[i])) continue;
|
|
1323
|
+
if (p.test(lines[i])) findings.push({ ruleId: 'SEC-AUTH-041', category: 'security', severity: 'high', title: 'Refresh token in localStorage — accessible to XSS', description: 'Store refresh tokens in httpOnly, Secure cookies to prevent JavaScript access.', file: fp, line: i + 1, fix: null });
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return findings;
|
|
1327
|
+
},
|
|
1328
|
+
});
|