@shipsafe/cli 0.1.1 → 0.2.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.
@@ -0,0 +1,1015 @@
1
+ /**
2
+ * ShipSafe Built-in Vulnerability Pattern Scanner
3
+ *
4
+ * Pure TypeScript, zero external dependencies.
5
+ * Detects code-level vulnerabilities across TypeScript, JavaScript, and Python files.
6
+ */
7
+ import { readdir, readFile, stat } from 'node:fs/promises';
8
+ import { extname, join, resolve } from 'node:path';
9
+ // ── Ignored directories and file patterns ──
10
+ const IGNORED_DIRS = new Set([
11
+ 'node_modules',
12
+ 'dist',
13
+ 'build',
14
+ '.git',
15
+ '.next',
16
+ '.nuxt',
17
+ 'coverage',
18
+ '__pycache__',
19
+ '.venv',
20
+ 'venv',
21
+ 'env',
22
+ '.tox',
23
+ '.mypy_cache',
24
+ '.pytest_cache',
25
+ 'vendor',
26
+ '.turbo',
27
+ ]);
28
+ const SCANNABLE_EXTENSIONS = new Set([
29
+ '.ts',
30
+ '.tsx',
31
+ '.js',
32
+ '.jsx',
33
+ '.py',
34
+ ]);
35
+ // ── Severity ordering for sorting ──
36
+ const SEVERITY_ORDER = {
37
+ critical: 0,
38
+ high: 1,
39
+ medium: 2,
40
+ low: 3,
41
+ info: 4,
42
+ };
43
+ // ── Helper: Check if a line is inside a comment ──
44
+ function isCommentLine(line, fileType) {
45
+ const trimmed = line.trimStart();
46
+ if (fileType === '.py') {
47
+ return trimmed.startsWith('#');
48
+ }
49
+ return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*');
50
+ }
51
+ // ── Helper: Check if a line is likely inside a string literal ──
52
+ function isInsideStringLiteral(line, matchIndex) {
53
+ // Very rough heuristic: count unescaped quotes before the match position
54
+ const before = line.slice(0, matchIndex);
55
+ const singleQuotes = (before.match(/(?<!\\)'/g) || []).length;
56
+ const doubleQuotes = (before.match(/(?<!\\)"/g) || []).length;
57
+ const backticks = (before.match(/(?<!\\)`/g) || []).length;
58
+ // If an odd number of any quote type, we're likely inside a string
59
+ return singleQuotes % 2 === 1 || doubleQuotes % 2 === 1 || backticks % 2 === 1;
60
+ }
61
+ // ── Helper: Test file detection ──
62
+ function isTestFile(filePath) {
63
+ const lower = filePath.toLowerCase();
64
+ return (lower.includes('.test.') ||
65
+ lower.includes('.spec.') ||
66
+ lower.includes('__tests__') ||
67
+ lower.includes('/test/') ||
68
+ lower.includes('/tests/') ||
69
+ lower.includes('_test.py') ||
70
+ lower.includes('test_') ||
71
+ lower.endsWith('.test.ts') ||
72
+ lower.endsWith('.test.js') ||
73
+ lower.endsWith('.spec.ts') ||
74
+ lower.endsWith('.spec.js'));
75
+ }
76
+ // ── Pattern Rules ──
77
+ const RULES = [
78
+ // ════════════════════════════════════════════
79
+ // SQL Injection
80
+ // ════════════════════════════════════════════
81
+ {
82
+ id: 'SQL_INJECTION_CONCAT',
83
+ category: 'SQL Injection',
84
+ description: 'SQL query built with string concatenation — vulnerable to SQL injection.',
85
+ severity: 'critical',
86
+ fix_suggestion: 'Use parameterized queries (e.g., query("SELECT * FROM users WHERE id = $1", [id])) instead of string concatenation.',
87
+ auto_fixable: false,
88
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
89
+ skipCommentsAndStrings: false,
90
+ skipTestFiles: true,
91
+ detect: (line) => {
92
+ // Match patterns like query("SELECT ... " + variable) or execute("INSERT ... " + variable)
93
+ return /\b(?:query|execute|raw|prepare)\s*\(\s*(?:"|')(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^"']*(?:"|')\s*\+/i.test(line);
94
+ },
95
+ },
96
+ {
97
+ id: 'SQL_INJECTION_TEMPLATE',
98
+ category: 'SQL Injection',
99
+ description: 'SQL query built with template literals containing interpolated values — vulnerable to SQL injection.',
100
+ severity: 'critical',
101
+ fix_suggestion: 'Use parameterized queries or tagged template literals (e.g., sql`SELECT * FROM users WHERE id = ${id}`) that auto-escape values.',
102
+ auto_fixable: false,
103
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
104
+ skipCommentsAndStrings: false,
105
+ skipTestFiles: true,
106
+ detect: (line) => {
107
+ // Match query(`SELECT ... ${...}`) patterns, but NOT tagged template literals like sql`...`
108
+ return /\b(?:query|execute|raw|prepare)\s*\(\s*`(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^`]*\$\{/i.test(line);
109
+ },
110
+ },
111
+ {
112
+ id: 'SQL_INJECTION_RAW_FORMAT',
113
+ category: 'SQL Injection',
114
+ description: 'Raw SQL string built with formatting or concatenation — vulnerable to SQL injection.',
115
+ severity: 'critical',
116
+ fix_suggestion: 'Use parameterized queries with placeholders ($1, ?, :param) instead of string formatting.',
117
+ auto_fixable: false,
118
+ fileTypes: ['.py'],
119
+ skipCommentsAndStrings: false,
120
+ skipTestFiles: true,
121
+ detect: (line) => {
122
+ // Python: cursor.execute("SELECT ... " + var) or cursor.execute("SELECT ... %s" % var)
123
+ // or cursor.execute(f"SELECT ...")
124
+ return (/\b(?:execute|executemany)\s*\(\s*(?:"|')(?:SELECT|INSERT|UPDATE|DELETE)\b[^"']*(?:"|')\s*%/i.test(line) ||
125
+ /\b(?:execute|executemany)\s*\(\s*(?:"|')(?:SELECT|INSERT|UPDATE|DELETE)\b[^"']*(?:"|')\s*\+/i.test(line) ||
126
+ /\b(?:execute|executemany)\s*\(\s*f(?:"|')(?:SELECT|INSERT|UPDATE|DELETE)\b/i.test(line));
127
+ },
128
+ },
129
+ {
130
+ id: 'SQL_INJECTION_ORM_RAW',
131
+ category: 'SQL Injection',
132
+ description: 'ORM raw query without parameterization — vulnerable to SQL injection.',
133
+ severity: 'high',
134
+ fix_suggestion: 'Use the ORM\'s parameterized raw query API (e.g., sequelize.query(sql, { replacements: [...] }) or knex.raw("?", [value])).',
135
+ auto_fixable: false,
136
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
137
+ skipCommentsAndStrings: false,
138
+ skipTestFiles: true,
139
+ detect: (line) => {
140
+ // Sequelize, TypeORM, Knex, Django, SQLAlchemy raw queries with interpolation
141
+ return (/\b(?:sequelize|connection|entityManager|manager|knex)\s*\.\s*(?:query|raw)\s*\(\s*`[^`]*\$\{/i.test(line) ||
142
+ /\b(?:sequelize|connection|entityManager|manager|knex)\s*\.\s*(?:query|raw)\s*\(\s*(?:"|')[^"']*(?:"|')\s*\+/i.test(line) ||
143
+ /\bRawSQL\s*\(\s*f(?:"|')/i.test(line) ||
144
+ /\.raw\s*\(\s*f(?:"|')(?:SELECT|INSERT|UPDATE|DELETE)/i.test(line));
145
+ },
146
+ },
147
+ // ════════════════════════════════════════════
148
+ // Cross-Site Scripting (XSS)
149
+ // ════════════════════════════════════════════
150
+ {
151
+ id: 'XSS_INNERHTML',
152
+ category: 'Cross-Site Scripting (XSS)',
153
+ description: 'Direct innerHTML assignment allows injection of arbitrary HTML and scripts.',
154
+ severity: 'high',
155
+ fix_suggestion: 'Use textContent instead of innerHTML, or sanitize input with DOMPurify before assignment.',
156
+ auto_fixable: false,
157
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
158
+ skipCommentsAndStrings: true,
159
+ skipTestFiles: true,
160
+ detect: (line) => {
161
+ return /\.innerHTML\s*=\s*(?!["']<\/|["']\s*$|''\s*;|""\s*;)/.test(line);
162
+ },
163
+ },
164
+ {
165
+ id: 'XSS_DOCUMENT_WRITE',
166
+ category: 'Cross-Site Scripting (XSS)',
167
+ description: 'document.write() injects unescaped content into the DOM — vulnerable to XSS.',
168
+ severity: 'high',
169
+ fix_suggestion: 'Avoid document.write(). Use DOM APIs (createElement, appendChild) or a framework\'s rendering methods.',
170
+ auto_fixable: false,
171
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
172
+ skipCommentsAndStrings: true,
173
+ skipTestFiles: true,
174
+ detect: (line) => {
175
+ return /\bdocument\s*\.\s*write(?:ln)?\s*\(/.test(line);
176
+ },
177
+ },
178
+ {
179
+ id: 'XSS_DANGEROUSLY_SET_INNERHTML',
180
+ category: 'Cross-Site Scripting (XSS)',
181
+ description: 'dangerouslySetInnerHTML renders raw HTML — ensure content is sanitized before use.',
182
+ severity: 'high',
183
+ fix_suggestion: 'Sanitize HTML with DOMPurify or a server-side sanitizer before passing to dangerouslySetInnerHTML.',
184
+ auto_fixable: false,
185
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
186
+ skipCommentsAndStrings: true,
187
+ skipTestFiles: true,
188
+ detect: (line) => {
189
+ return /dangerouslySetInnerHTML\s*=/.test(line);
190
+ },
191
+ },
192
+ {
193
+ id: 'XSS_EVAL',
194
+ category: 'Cross-Site Scripting (XSS)',
195
+ description: 'eval() executes arbitrary code — severe XSS and code injection risk.',
196
+ severity: 'critical',
197
+ fix_suggestion: 'Remove eval(). Use JSON.parse() for data, or a safe expression parser if dynamic evaluation is truly needed.',
198
+ auto_fixable: false,
199
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
200
+ skipCommentsAndStrings: true,
201
+ skipTestFiles: true,
202
+ detect: (line) => {
203
+ // Match eval( but not "evaluate", "interval", etc.
204
+ return /\beval\s*\(/.test(line) && !/\bsetInterval\b/.test(line);
205
+ },
206
+ },
207
+ {
208
+ id: 'XSS_UNESCAPED_TEMPLATE',
209
+ category: 'Cross-Site Scripting (XSS)',
210
+ description: 'Unescaped template rendering ({{! or {{{ or |safe) may allow XSS.',
211
+ severity: 'medium',
212
+ fix_suggestion: 'Use escaped template syntax ({{ }}) and sanitize user-supplied content before rendering.',
213
+ auto_fixable: false,
214
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
215
+ skipCommentsAndStrings: true,
216
+ skipTestFiles: true,
217
+ detect: (line) => {
218
+ // Handlebars triple-stash {{{, Jinja2 |safe, EJS <%- (unescaped)
219
+ return /\{\{\{[^}]+\}\}\}/.test(line) || /\|\s*safe\b/.test(line) || /<%- /.test(line);
220
+ },
221
+ },
222
+ // ════════════════════════════════════════════
223
+ // Command Injection
224
+ // ════════════════════════════════════════════
225
+ {
226
+ id: 'CMD_INJECTION_EXEC_CONCAT',
227
+ category: 'Command Injection',
228
+ description: 'exec() with string concatenation allows arbitrary command injection.',
229
+ severity: 'critical',
230
+ fix_suggestion: 'Use execFile() or spawn() with an array of arguments instead of exec() with concatenated strings.',
231
+ auto_fixable: false,
232
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
233
+ skipCommentsAndStrings: true,
234
+ skipTestFiles: true,
235
+ detect: (line) => {
236
+ return /\bexec\s*\(\s*(?:"|')[^"']*(?:"|')\s*\+/.test(line) ||
237
+ /\bexec\s*\(\s*`[^`]*\$\{/.test(line);
238
+ },
239
+ },
240
+ {
241
+ id: 'CMD_INJECTION_EXECSYNC',
242
+ category: 'Command Injection',
243
+ description: 'execSync() with template literals or concatenation enables command injection.',
244
+ severity: 'critical',
245
+ fix_suggestion: 'Use execFileSync() with an array of arguments, or spawn/spawnSync with explicit argument arrays.',
246
+ auto_fixable: false,
247
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
248
+ skipCommentsAndStrings: true,
249
+ skipTestFiles: true,
250
+ detect: (line) => {
251
+ return /\bexecSync\s*\(\s*`[^`]*\$\{/.test(line) ||
252
+ /\bexecSync\s*\(\s*(?:"|')[^"']*(?:"|')\s*\+/.test(line);
253
+ },
254
+ },
255
+ {
256
+ id: 'CMD_INJECTION_SPAWN_SHELL',
257
+ category: 'Command Injection',
258
+ description: 'spawn() with shell: true passes the command through a shell, enabling injection via unsanitized input.',
259
+ severity: 'high',
260
+ fix_suggestion: 'Remove shell: true and pass the command and arguments as separate array elements to spawn().',
261
+ auto_fixable: false,
262
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
263
+ skipCommentsAndStrings: true,
264
+ skipTestFiles: true,
265
+ detect: (line) => {
266
+ return /\bspawn\s*\(.*shell\s*:\s*true/.test(line);
267
+ },
268
+ },
269
+ {
270
+ id: 'CMD_INJECTION_CHILD_PROCESS',
271
+ category: 'Command Injection',
272
+ description: 'child_process exec/execSync with variable input is vulnerable to command injection.',
273
+ severity: 'high',
274
+ fix_suggestion: 'Use child_process.execFile() or spawn() with arguments array. Never pass user input to exec().',
275
+ auto_fixable: false,
276
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
277
+ skipCommentsAndStrings: true,
278
+ skipTestFiles: true,
279
+ detect: (line, ctx) => {
280
+ // Detect import of child_process and usage with variable inputs
281
+ if (/require\s*\(\s*['"]child_process['"]\s*\)/.test(line))
282
+ return false; // just the import, not a finding
283
+ // exec(someVariable) — not a string literal
284
+ return /\bexec\s*\(\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*[,)]/.test(line) &&
285
+ !/\bexec\s*\(\s*(?:"|'|`)/.test(line);
286
+ },
287
+ },
288
+ {
289
+ id: 'CMD_INJECTION_OS_SYSTEM',
290
+ category: 'Command Injection',
291
+ description: 'os.system() passes the command through the shell — vulnerable to command injection.',
292
+ severity: 'critical',
293
+ fix_suggestion: 'Use subprocess.run() with a list of arguments and shell=False (the default).',
294
+ auto_fixable: false,
295
+ fileTypes: ['.py'],
296
+ skipCommentsAndStrings: true,
297
+ skipTestFiles: true,
298
+ detect: (line) => {
299
+ return /\bos\s*\.\s*system\s*\(/.test(line);
300
+ },
301
+ },
302
+ {
303
+ id: 'CMD_INJECTION_SUBPROCESS_SHELL',
304
+ category: 'Command Injection',
305
+ description: 'subprocess.call/run/Popen with shell=True passes commands through the shell — vulnerable to injection.',
306
+ severity: 'critical',
307
+ fix_suggestion: 'Use subprocess.run() with a list of arguments (shell=False). Pass arguments as a list, not a string.',
308
+ auto_fixable: false,
309
+ fileTypes: ['.py'],
310
+ skipCommentsAndStrings: true,
311
+ skipTestFiles: true,
312
+ detect: (line) => {
313
+ return /\bsubprocess\s*\.\s*(?:call|run|Popen|check_output|check_call)\s*\(.*shell\s*=\s*True/.test(line);
314
+ },
315
+ },
316
+ // ════════════════════════════════════════════
317
+ // Path Traversal
318
+ // ════════════════════════════════════════════
319
+ {
320
+ id: 'PATH_TRAVERSAL_USER_INPUT',
321
+ category: 'Path Traversal',
322
+ description: 'File system operation uses a path from user input (req.params, req.query, req.body) without validation.',
323
+ severity: 'high',
324
+ fix_suggestion: 'Validate and sanitize the file path. Use path.resolve() and verify the resolved path starts with your allowed base directory.',
325
+ auto_fixable: false,
326
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
327
+ skipCommentsAndStrings: true,
328
+ skipTestFiles: true,
329
+ detect: (line) => {
330
+ return /\b(?:readFile|readFileSync|createReadStream|writeFile|writeFileSync|access|accessSync|unlink|unlinkSync|readdir|readdirSync|stat|statSync)\s*\([^)]*\breq\s*\.\s*(?:params|query|body|headers)\b/.test(line);
331
+ },
332
+ },
333
+ {
334
+ id: 'PATH_TRAVERSAL_CONCAT',
335
+ category: 'Path Traversal',
336
+ description: 'File path built with string concatenation may allow directory traversal attacks.',
337
+ severity: 'medium',
338
+ fix_suggestion: 'Use path.join() with a validated base directory. Check that the resolved path is within the expected directory with path.resolve().',
339
+ auto_fixable: false,
340
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
341
+ skipCommentsAndStrings: true,
342
+ skipTestFiles: true,
343
+ detect: (line) => {
344
+ // Detect patterns like readFile("./" + filename) or readFile(basePath + "/" + filename)
345
+ return /\b(?:readFile|readFileSync|createReadStream|writeFile|writeFileSync|open)\s*\(\s*(?:[a-zA-Z_$][a-zA-Z0-9_$.]*\s*\+|["'][^"']*["']\s*\+)/.test(line);
346
+ },
347
+ },
348
+ {
349
+ id: 'PATH_TRAVERSAL_DOTDOT',
350
+ category: 'Path Traversal',
351
+ description: 'Path containing "../" detected in file operation — potential directory traversal.',
352
+ severity: 'medium',
353
+ fix_suggestion: 'Reject paths containing ".." or normalize with path.resolve() and validate against an allowed base path.',
354
+ auto_fixable: false,
355
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
356
+ skipCommentsAndStrings: true,
357
+ skipTestFiles: true,
358
+ detect: (line) => {
359
+ // Only flag if it's in a file operation context, not an import/require
360
+ if (/\b(?:require|import|from)\b/.test(line))
361
+ return false;
362
+ return /\b(?:readFile|readFileSync|createReadStream|writeFile|writeFileSync|open|access|unlink|stat)\s*\([^)]*\.\.\//.test(line);
363
+ },
364
+ },
365
+ // ════════════════════════════════════════════
366
+ // Insecure Cryptography
367
+ // ════════════════════════════════════════════
368
+ {
369
+ id: 'CRYPTO_MD5',
370
+ category: 'Insecure Cryptography',
371
+ description: 'MD5 is cryptographically broken — do not use for passwords, authentication, or integrity checks.',
372
+ severity: 'high',
373
+ fix_suggestion: 'Use bcrypt or argon2 for passwords, SHA-256 or SHA-3 for integrity checks.',
374
+ auto_fixable: false,
375
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
376
+ skipCommentsAndStrings: true,
377
+ skipTestFiles: true,
378
+ detect: (line) => {
379
+ return /createHash\s*\(\s*['"]md5['"]\s*\)/.test(line) ||
380
+ /\bhashlib\s*\.\s*md5\s*\(/.test(line) ||
381
+ /\bMD5\s*\(/.test(line);
382
+ },
383
+ },
384
+ {
385
+ id: 'CRYPTO_SHA1',
386
+ category: 'Insecure Cryptography',
387
+ description: 'SHA-1 is deprecated for security purposes — collision attacks are practical.',
388
+ severity: 'medium',
389
+ fix_suggestion: 'Use SHA-256 or SHA-3 instead of SHA-1 for security-sensitive operations.',
390
+ auto_fixable: true,
391
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
392
+ skipCommentsAndStrings: true,
393
+ skipTestFiles: true,
394
+ detect: (line) => {
395
+ return /createHash\s*\(\s*['"]sha1['"]\s*\)/.test(line) ||
396
+ /\bhashlib\s*\.\s*sha1\s*\(/.test(line);
397
+ },
398
+ },
399
+ {
400
+ id: 'CRYPTO_MATH_RANDOM',
401
+ category: 'Insecure Cryptography',
402
+ description: 'Math.random() is not cryptographically secure — do not use for tokens, keys, or security-sensitive values.',
403
+ severity: 'high',
404
+ fix_suggestion: 'Use crypto.randomBytes() (Node.js) or crypto.getRandomValues() (browser) for security-sensitive random values.',
405
+ auto_fixable: true,
406
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
407
+ skipCommentsAndStrings: true,
408
+ skipTestFiles: true,
409
+ detect: (line) => {
410
+ // Flag Math.random() in contexts that suggest security usage
411
+ if (!/\bMath\s*\.\s*random\s*\(\s*\)/.test(line))
412
+ return false;
413
+ // Check surrounding context for security-related terms
414
+ const lower = line.toLowerCase();
415
+ return (lower.includes('token') ||
416
+ lower.includes('secret') ||
417
+ lower.includes('key') ||
418
+ lower.includes('password') ||
419
+ lower.includes('session') ||
420
+ lower.includes('nonce') ||
421
+ lower.includes('salt') ||
422
+ lower.includes('uuid') ||
423
+ lower.includes('random'));
424
+ },
425
+ },
426
+ {
427
+ id: 'CRYPTO_WEAK_CIPHER',
428
+ category: 'Insecure Cryptography',
429
+ description: 'Weak cipher algorithm (DES, RC4, Blowfish) detected — these are broken and should not be used.',
430
+ severity: 'high',
431
+ fix_suggestion: 'Use AES-256-GCM or ChaCha20-Poly1305 for encryption.',
432
+ auto_fixable: false,
433
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
434
+ skipCommentsAndStrings: true,
435
+ skipTestFiles: true,
436
+ detect: (line) => {
437
+ return /createCipher(?:iv)?\s*\(\s*['"](?:des|des-ede|des-ede3|rc4|rc2|blowfish|bf)(?:-[a-z]+)?['"]/i.test(line) ||
438
+ /\bDES\b/.test(line) && /\b(?:cipher|encrypt|decrypt)\b/i.test(line) ||
439
+ /\bRC4\b/.test(line) && /\b(?:cipher|encrypt|decrypt)\b/i.test(line);
440
+ },
441
+ },
442
+ // ════════════════════════════════════════════
443
+ // Insecure Configuration
444
+ // ════════════════════════════════════════════
445
+ {
446
+ id: 'CONFIG_CORS_WILDCARD',
447
+ category: 'Insecure Configuration',
448
+ description: 'CORS with wildcard origin (*) allows any website to make requests — may expose sensitive data.',
449
+ severity: 'medium',
450
+ fix_suggestion: 'Specify explicit allowed origins instead of "*". Use an allowlist of trusted domains.',
451
+ auto_fixable: false,
452
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
453
+ skipCommentsAndStrings: false,
454
+ skipTestFiles: true,
455
+ detect: (line) => {
456
+ return (/['"]Access-Control-Allow-Origin['"]\s*[,:]\s*['"]\*['"]/.test(line) ||
457
+ /\bcors\s*\(\s*\{?\s*origin\s*:\s*(?:true|['"]\*['"])/.test(line) ||
458
+ /origin\s*:\s*['"]\*['"]/.test(line));
459
+ },
460
+ },
461
+ {
462
+ id: 'CONFIG_SSL_DISABLED',
463
+ category: 'Insecure Configuration',
464
+ description: 'SSL/TLS certificate verification is disabled (rejectUnauthorized: false) — vulnerable to MITM attacks.',
465
+ severity: 'critical',
466
+ fix_suggestion: 'Remove rejectUnauthorized: false. Fix the underlying SSL certificate issue instead of disabling verification.',
467
+ auto_fixable: false,
468
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
469
+ skipCommentsAndStrings: false,
470
+ skipTestFiles: true,
471
+ detect: (line) => {
472
+ return /rejectUnauthorized\s*:\s*false/.test(line) ||
473
+ /NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0/.test(line) ||
474
+ /process\s*\.\s*env\s*\.\s*NODE_TLS_REJECT_UNAUTHORIZED\s*=/.test(line);
475
+ },
476
+ },
477
+ {
478
+ id: 'CONFIG_DEBUG_PRODUCTION',
479
+ category: 'Insecure Configuration',
480
+ description: 'Debug mode or verbose error output may be enabled in production — could leak sensitive information.',
481
+ severity: 'medium',
482
+ fix_suggestion: 'Ensure debug mode is disabled in production. Use environment-specific configuration.',
483
+ auto_fixable: false,
484
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
485
+ skipCommentsAndStrings: true,
486
+ skipTestFiles: true,
487
+ detect: (line) => {
488
+ return /\bDEBUG\s*=\s*(?:True|true|1|['"]true['"])/.test(line) &&
489
+ !/\bif\b/.test(line) && !/\bprocess\.env\b/.test(line);
490
+ },
491
+ },
492
+ {
493
+ id: 'CONFIG_BIND_ALL_INTERFACES',
494
+ category: 'Insecure Configuration',
495
+ description: 'Binding to 0.0.0.0 exposes the service on all network interfaces — may be unintended in production.',
496
+ severity: 'low',
497
+ fix_suggestion: 'Bind to 127.0.0.1 or a specific interface unless external access is intentionally required.',
498
+ auto_fixable: true,
499
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
500
+ skipCommentsAndStrings: false,
501
+ skipTestFiles: true,
502
+ detect: (line) => {
503
+ // Match listen/bind calls with 0.0.0.0, but not in comments
504
+ return /\b(?:listen|bind|host)\s*[:=(]\s*['"]0\.0\.0\.0['"]/.test(line) ||
505
+ /['"]0\.0\.0\.0['"].*\b(?:listen|bind|host)\b/.test(line);
506
+ },
507
+ },
508
+ // ════════════════════════════════════════════
509
+ // Authentication Issues
510
+ // ════════════════════════════════════════════
511
+ {
512
+ id: 'AUTH_JWT_NO_EXPIRY',
513
+ category: 'Authentication Issues',
514
+ description: 'JWT signed without an expiration (expiresIn) — tokens remain valid indefinitely if compromised.',
515
+ severity: 'high',
516
+ fix_suggestion: 'Always set an expiration on JWTs: jwt.sign(payload, secret, { expiresIn: "1h" }).',
517
+ auto_fixable: true,
518
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
519
+ skipCommentsAndStrings: true,
520
+ skipTestFiles: true,
521
+ detect: (line, ctx) => {
522
+ if (!/\bjwt\s*\.\s*sign\s*\(/.test(line))
523
+ return false;
524
+ // Check if expiresIn is present in the same line or nearby lines
525
+ const lineIdx = ctx.lineNumber - 1;
526
+ const window = ctx.allLines.slice(Math.max(0, lineIdx - 1), Math.min(ctx.allLines.length, lineIdx + 4)).join(' ');
527
+ return !/expiresIn|exp\s*:/.test(window);
528
+ },
529
+ },
530
+ {
531
+ id: 'AUTH_JWT_HARDCODED_SECRET',
532
+ category: 'Authentication Issues',
533
+ description: 'JWT secret appears to be hardcoded — secrets should come from environment variables.',
534
+ severity: 'critical',
535
+ fix_suggestion: 'Store JWT secrets in environment variables (process.env.JWT_SECRET) and use a strong, randomly generated value.',
536
+ auto_fixable: false,
537
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
538
+ skipCommentsAndStrings: false,
539
+ skipTestFiles: true,
540
+ detect: (line) => {
541
+ // jwt.sign(payload, "hardcoded-secret") or jwt.sign(payload, 'secret')
542
+ return /\bjwt\s*\.\s*sign\s*\([^,]+,\s*['"][^'"]{1,}['"]/.test(line) ||
543
+ /\bjwt\s*\.\s*verify\s*\([^,]+,\s*['"][^'"]{1,}['"]/.test(line);
544
+ },
545
+ },
546
+ {
547
+ id: 'AUTH_WEAK_PASSWORD_VALIDATION',
548
+ category: 'Authentication Issues',
549
+ description: 'Password validation appears to only check length with a low minimum — weak passwords may be accepted.',
550
+ severity: 'medium',
551
+ fix_suggestion: 'Enforce a minimum password length of 12+ characters and require a mix of character types. Consider using zxcvbn for strength estimation.',
552
+ auto_fixable: false,
553
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
554
+ skipCommentsAndStrings: true,
555
+ skipTestFiles: true,
556
+ detect: (line) => {
557
+ // Match patterns like password.length >= 4 or len(password) < 6
558
+ return (/password\s*\.\s*length\s*(?:>=?|>|===?)\s*[1-7]\b/.test(line) ||
559
+ /\blen\s*\(\s*password\s*\)\s*(?:>=?|>)\s*[1-7]\b/.test(line) ||
560
+ /password\s*\.\s*length\s*<\s*[1-7]\b/.test(line));
561
+ },
562
+ },
563
+ {
564
+ id: 'AUTH_MISSING_AUTH_MIDDLEWARE',
565
+ category: 'Authentication Issues',
566
+ description: 'Route handler appears to lack authentication middleware — endpoint may be publicly accessible.',
567
+ severity: 'medium',
568
+ fix_suggestion: 'Add authentication middleware (e.g., requireAuth, isAuthenticated, authGuard) before route handlers that need protection.',
569
+ auto_fixable: false,
570
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
571
+ skipCommentsAndStrings: true,
572
+ skipTestFiles: true,
573
+ detect: (line) => {
574
+ // Match route definitions like app.get("/api/admin/...", handler) without auth middleware
575
+ if (!/\b(?:app|router)\s*\.\s*(?:get|post|put|patch|delete)\s*\(/.test(line))
576
+ return false;
577
+ // Check for sensitive route patterns
578
+ if (!/['"]\/(?:api\/)?(?:admin|user|account|dashboard|settings|billing|private)\b/.test(line))
579
+ return false;
580
+ // Check that no auth middleware is present
581
+ return !/\b(?:auth|authenticate|requireAuth|isAuthenticated|protect|guard|verify|middleware|passport)\b/i.test(line);
582
+ },
583
+ },
584
+ // ════════════════════════════════════════════
585
+ // Sensitive Data Exposure
586
+ // ════════════════════════════════════════════
587
+ {
588
+ id: 'DATA_CONSOLE_SENSITIVE',
589
+ category: 'Sensitive Data Exposure',
590
+ description: 'Logging potentially sensitive data (password, token, secret, key) to console.',
591
+ severity: 'medium',
592
+ fix_suggestion: 'Remove console.log of sensitive data, or redact the values before logging.',
593
+ auto_fixable: false,
594
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
595
+ skipCommentsAndStrings: true,
596
+ skipTestFiles: true,
597
+ detect: (line) => {
598
+ if (!/\bconsole\s*\.\s*(?:log|info|debug|warn|error)\s*\(/.test(line))
599
+ return false;
600
+ const lower = line.toLowerCase();
601
+ return (lower.includes('password') ||
602
+ lower.includes('secret') ||
603
+ lower.includes('apikey') ||
604
+ lower.includes('api_key') ||
605
+ lower.includes('accesstoken') ||
606
+ lower.includes('access_token') ||
607
+ lower.includes('private_key') ||
608
+ lower.includes('privatekey') ||
609
+ lower.includes('creditcard') ||
610
+ lower.includes('credit_card') ||
611
+ lower.includes('ssn'));
612
+ },
613
+ },
614
+ {
615
+ id: 'DATA_STACKTRACE_LEAK',
616
+ category: 'Sensitive Data Exposure',
617
+ description: 'Error stack trace sent in HTTP response — may leak internal implementation details to attackers.',
618
+ severity: 'medium',
619
+ fix_suggestion: 'Return generic error messages to clients. Log the full stack trace server-side only.',
620
+ auto_fixable: false,
621
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
622
+ skipCommentsAndStrings: true,
623
+ skipTestFiles: true,
624
+ detect: (line) => {
625
+ // Patterns like res.json({ error: err.stack }) or res.send(error.stack)
626
+ return (/\bres\s*\.\s*(?:json|send|status\s*\([^)]*\)\s*\.\s*(?:json|send))\s*\([^)]*(?:\.stack|\.message|err\b|error\b)/.test(line) &&
627
+ /\.stack/.test(line));
628
+ },
629
+ },
630
+ // ════════════════════════════════════════════
631
+ // Unvalidated Redirects
632
+ // ════════════════════════════════════════════
633
+ {
634
+ id: 'REDIRECT_UNVALIDATED',
635
+ category: 'Unvalidated Redirect',
636
+ description: 'Redirect using user-supplied URL without validation — can be abused for phishing.',
637
+ severity: 'medium',
638
+ fix_suggestion: 'Validate redirect URLs against an allowlist of domains. Never redirect to a raw user-supplied URL.',
639
+ auto_fixable: false,
640
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
641
+ skipCommentsAndStrings: true,
642
+ skipTestFiles: true,
643
+ detect: (line) => {
644
+ return (/\bres\s*\.\s*redirect\s*\(\s*req\s*\.\s*(?:query|params|body)\b/.test(line) ||
645
+ /\bres\s*\.\s*redirect\s*\(\s*(?:req\.query\.[a-zA-Z]+|req\.params\.[a-zA-Z]+|req\.body\.[a-zA-Z]+)/.test(line) ||
646
+ /\breturn\s+redirect\s*\(\s*request\s*\.\s*(?:GET|POST|args)\b/.test(line));
647
+ },
648
+ },
649
+ // ════════════════════════════════════════════
650
+ // Prototype Pollution
651
+ // ════════════════════════════════════════════
652
+ {
653
+ id: 'PROTO_POLLUTION_ASSIGN',
654
+ category: 'Prototype Pollution',
655
+ description: 'Object.assign() or spread with user-controlled input can lead to prototype pollution.',
656
+ severity: 'high',
657
+ fix_suggestion: 'Validate/sanitize user input before merging. Strip __proto__, constructor, and prototype keys, or use Object.create(null) as the target.',
658
+ auto_fixable: false,
659
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
660
+ skipCommentsAndStrings: true,
661
+ skipTestFiles: true,
662
+ detect: (line) => {
663
+ return /\bObject\s*\.\s*assign\s*\([^,]*,\s*req\s*\.\s*(?:body|query|params)\b/.test(line) ||
664
+ /\.\.\.\s*req\s*\.\s*(?:body|query|params)\b/.test(line);
665
+ },
666
+ },
667
+ {
668
+ id: 'PROTO_POLLUTION_BRACKET',
669
+ category: 'Prototype Pollution',
670
+ description: 'Dynamic property assignment with user input (obj[key] = value) can enable prototype pollution.',
671
+ severity: 'medium',
672
+ fix_suggestion: 'Validate that the key is not "__proto__", "constructor", or "prototype" before dynamic property assignment.',
673
+ auto_fixable: false,
674
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
675
+ skipCommentsAndStrings: true,
676
+ skipTestFiles: true,
677
+ detect: (line) => {
678
+ // Match obj[req.body.key] = or obj[userInput] = patterns
679
+ return /\[\s*req\s*\.\s*(?:body|query|params)\s*\.\s*[a-zA-Z_]+\s*\]\s*=/.test(line);
680
+ },
681
+ },
682
+ // ════════════════════════════════════════════
683
+ // Regex DoS
684
+ // ════════════════════════════════════════════
685
+ {
686
+ id: 'REGEX_DOS',
687
+ category: 'Regex DoS',
688
+ description: 'Regular expression with nested quantifiers may be vulnerable to catastrophic backtracking (ReDoS).',
689
+ severity: 'medium',
690
+ fix_suggestion: 'Simplify the regex to avoid nested quantifiers (e.g., (a+)+ or (a|a)*). Consider using a regex analysis tool or the RE2 engine.',
691
+ auto_fixable: false,
692
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
693
+ skipCommentsAndStrings: false,
694
+ skipTestFiles: true,
695
+ detect: (line) => {
696
+ // Detect nested quantifiers: (pattern+)+, (pattern*)+, (pattern+)*, etc.
697
+ // Also (a|b+)+ patterns
698
+ return /\([^)]*[+*][^)]*\)[+*]/.test(line) ||
699
+ /\([^)]*\|[^)]*[+*]\)[+*]/.test(line);
700
+ },
701
+ },
702
+ // ════════════════════════════════════════════
703
+ // Missing Rate Limiting
704
+ // ════════════════════════════════════════════
705
+ {
706
+ id: 'RATE_LIMIT_AUTH_ENDPOINT',
707
+ category: 'Missing Rate Limiting',
708
+ description: 'Authentication endpoint (login, register, reset-password) without apparent rate limiting.',
709
+ severity: 'medium',
710
+ fix_suggestion: 'Add rate limiting middleware (e.g., express-rate-limit) to authentication endpoints to prevent brute force attacks.',
711
+ auto_fixable: false,
712
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
713
+ skipCommentsAndStrings: true,
714
+ skipTestFiles: true,
715
+ detect: (line, ctx) => {
716
+ // Match route definitions for auth-related endpoints
717
+ if (!/\b(?:app|router)\s*\.\s*(?:post|put)\s*\(\s*['"]\/(?:api\/)?(?:auth\/)?(?:login|signin|register|signup|reset-password|forgot-password|verify)\b/.test(line)) {
718
+ return false;
719
+ }
720
+ // Check if rate limit middleware is in the same line or the surrounding context
721
+ const lineIdx = ctx.lineNumber - 1;
722
+ const window = ctx.allLines
723
+ .slice(Math.max(0, lineIdx - 3), Math.min(ctx.allLines.length, lineIdx + 3))
724
+ .join(' ');
725
+ return !/\b(?:rateLimit|rateLimiter|limiter|throttle|slowDown|brute)\b/i.test(window);
726
+ },
727
+ },
728
+ // ════════════════════════════════════════════
729
+ // Hardcoded Secrets (basic patterns)
730
+ // ════════════════════════════════════════════
731
+ {
732
+ id: 'SECRET_HARDCODED_KEY',
733
+ category: 'Hardcoded Secrets',
734
+ description: 'Potential hardcoded API key or secret detected — secrets should be stored in environment variables.',
735
+ severity: 'high',
736
+ fix_suggestion: 'Move secrets to environment variables or a secrets manager. Never commit secrets to source code.',
737
+ auto_fixable: false,
738
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx', '.py'],
739
+ skipCommentsAndStrings: false,
740
+ skipTestFiles: true,
741
+ detect: (line) => {
742
+ // Skip lines that reference environment variables
743
+ if (/process\s*\.\s*env\b/.test(line) || /os\s*\.\s*(?:environ|getenv)\b/.test(line))
744
+ return false;
745
+ // Skip import/require lines
746
+ if (/\b(?:import|require|from)\b/.test(line))
747
+ return false;
748
+ // Match common patterns: API_KEY = "abc123...", apiSecret: "..."
749
+ return /\b(?:api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|private[_-]?key|access[_-]?key)\s*[:=]\s*['"][a-zA-Z0-9+/=_-]{16,}['"]/i.test(line);
750
+ },
751
+ },
752
+ // ════════════════════════════════════════════
753
+ // Python-specific: Pickle deserialization
754
+ // ════════════════════════════════════════════
755
+ {
756
+ id: 'PYTHON_PICKLE_DESERIALIZE',
757
+ category: 'Insecure Deserialization',
758
+ description: 'pickle.loads() or pickle.load() with untrusted data can execute arbitrary code.',
759
+ severity: 'critical',
760
+ fix_suggestion: 'Avoid unpickling untrusted data. Use JSON or another safe serialization format.',
761
+ auto_fixable: false,
762
+ fileTypes: ['.py'],
763
+ skipCommentsAndStrings: true,
764
+ skipTestFiles: true,
765
+ detect: (line) => {
766
+ return /\bpickle\s*\.\s*(?:loads?|Unpickler)\s*\(/.test(line);
767
+ },
768
+ },
769
+ // ════════════════════════════════════════════
770
+ // Python-specific: YAML unsafe load
771
+ // ════════════════════════════════════════════
772
+ {
773
+ id: 'PYTHON_YAML_UNSAFE',
774
+ category: 'Insecure Deserialization',
775
+ description: 'yaml.load() without SafeLoader can execute arbitrary Python objects.',
776
+ severity: 'high',
777
+ fix_suggestion: 'Use yaml.safe_load() or yaml.load(data, Loader=yaml.SafeLoader).',
778
+ auto_fixable: true,
779
+ fileTypes: ['.py'],
780
+ skipCommentsAndStrings: true,
781
+ skipTestFiles: true,
782
+ detect: (line) => {
783
+ if (!/\byaml\s*\.\s*load\s*\(/.test(line))
784
+ return false;
785
+ // OK if SafeLoader or safe_load is used
786
+ return !/SafeLoader|safe_load/.test(line);
787
+ },
788
+ },
789
+ // ════════════════════════════════════════════
790
+ // Insecure SSL verification disabled (Python)
791
+ // ════════════════════════════════════════════
792
+ {
793
+ id: 'CONFIG_SSL_DISABLED_PYTHON',
794
+ category: 'Insecure Configuration',
795
+ description: 'SSL certificate verification is disabled (verify=False) — vulnerable to man-in-the-middle attacks.',
796
+ severity: 'critical',
797
+ fix_suggestion: 'Remove verify=False. Fix the underlying certificate issue instead of disabling verification.',
798
+ auto_fixable: false,
799
+ fileTypes: ['.py'],
800
+ skipCommentsAndStrings: true,
801
+ skipTestFiles: true,
802
+ detect: (line) => {
803
+ return /\brequests\s*\.\s*(?:get|post|put|patch|delete|head|options|request)\s*\([^)]*verify\s*=\s*False/.test(line) ||
804
+ /\bverify\s*=\s*False/.test(line) && /\b(?:requests|urllib|httpx|aiohttp)\b/.test(line);
805
+ },
806
+ },
807
+ // ════════════════════════════════════════════
808
+ // new Function() — similar to eval
809
+ // ════════════════════════════════════════════
810
+ {
811
+ id: 'XSS_NEW_FUNCTION',
812
+ category: 'Cross-Site Scripting (XSS)',
813
+ description: 'new Function() compiles and executes a string as code — similar to eval(), enables code injection.',
814
+ severity: 'high',
815
+ fix_suggestion: 'Avoid new Function(). Use safer alternatives like JSON.parse() for data or a sandboxed evaluator.',
816
+ auto_fixable: false,
817
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
818
+ skipCommentsAndStrings: true,
819
+ skipTestFiles: true,
820
+ detect: (line) => {
821
+ return /\bnew\s+Function\s*\(/.test(line);
822
+ },
823
+ },
824
+ // ════════════════════════════════════════════
825
+ // setTimeout/setInterval with string argument
826
+ // ════════════════════════════════════════════
827
+ {
828
+ id: 'XSS_SETTIMEOUT_STRING',
829
+ category: 'Cross-Site Scripting (XSS)',
830
+ description: 'setTimeout/setInterval with a string argument evaluates the string as code — similar to eval().',
831
+ severity: 'medium',
832
+ fix_suggestion: 'Pass a function reference to setTimeout/setInterval instead of a string.',
833
+ auto_fixable: true,
834
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
835
+ skipCommentsAndStrings: true,
836
+ skipTestFiles: true,
837
+ detect: (line) => {
838
+ // Match setTimeout("code...", ...) or setInterval('code...', ...)
839
+ return /\bset(?:Timeout|Interval)\s*\(\s*['"]/.test(line);
840
+ },
841
+ },
842
+ // ════════════════════════════════════════════
843
+ // Helmet / security headers missing
844
+ // ════════════════════════════════════════════
845
+ {
846
+ id: 'CONFIG_NO_SECURITY_HEADERS',
847
+ category: 'Insecure Configuration',
848
+ description: 'Express app created without security headers middleware (helmet) — missing important HTTP security headers.',
849
+ severity: 'low',
850
+ fix_suggestion: 'Install and use helmet: app.use(helmet()) to set security-related HTTP headers.',
851
+ auto_fixable: false,
852
+ fileTypes: ['.ts', '.tsx', '.js', '.jsx'],
853
+ skipCommentsAndStrings: true,
854
+ skipTestFiles: true,
855
+ detect: (_line, ctx) => {
856
+ // Only trigger once per file, on the line where express() is called
857
+ if (!/\bexpress\s*\(\s*\)/.test(_line))
858
+ return false;
859
+ // Check if helmet is used anywhere in the file
860
+ return !/\bhelmet\b/.test(ctx.fileContent);
861
+ },
862
+ },
863
+ ];
864
+ // ── File Discovery ──
865
+ async function discoverFiles(targetPath) {
866
+ const files = [];
867
+ const resolvedTarget = resolve(targetPath);
868
+ async function walk(dir) {
869
+ let entries;
870
+ try {
871
+ entries = await readdir(dir, { withFileTypes: true });
872
+ }
873
+ catch {
874
+ return; // skip unreadable directories
875
+ }
876
+ for (const entry of entries) {
877
+ if (entry.name.startsWith('.') && entry.name !== '.')
878
+ continue;
879
+ if (entry.isDirectory()) {
880
+ if (IGNORED_DIRS.has(entry.name))
881
+ continue;
882
+ await walk(join(dir, entry.name));
883
+ }
884
+ else if (entry.isFile()) {
885
+ const ext = extname(entry.name);
886
+ if (SCANNABLE_EXTENSIONS.has(ext)) {
887
+ files.push(join(dir, entry.name));
888
+ }
889
+ }
890
+ }
891
+ }
892
+ // Check if targetPath is a file or directory
893
+ const targetStat = await stat(resolvedTarget);
894
+ if (targetStat.isFile()) {
895
+ const ext = extname(resolvedTarget);
896
+ if (SCANNABLE_EXTENSIONS.has(ext)) {
897
+ files.push(resolvedTarget);
898
+ }
899
+ }
900
+ else {
901
+ await walk(resolvedTarget);
902
+ }
903
+ return files;
904
+ }
905
+ // ── Core Scanner ──
906
+ function getFileType(filePath) {
907
+ const ext = extname(filePath);
908
+ return SCANNABLE_EXTENSIONS.has(ext) ? ext : null;
909
+ }
910
+ function scanFileContent(filePath, content) {
911
+ const findings = [];
912
+ const fileType = getFileType(filePath);
913
+ if (!fileType)
914
+ return findings;
915
+ const lines = content.split('\n');
916
+ const fileIsTest = isTestFile(filePath);
917
+ // Filter rules applicable to this file type
918
+ const applicableRules = RULES.filter((rule) => {
919
+ if (!rule.fileTypes.includes(fileType))
920
+ return false;
921
+ if (rule.skipTestFiles && fileIsTest)
922
+ return false;
923
+ return true;
924
+ });
925
+ if (applicableRules.length === 0)
926
+ return findings;
927
+ const context = {
928
+ filePath,
929
+ lineNumber: 0,
930
+ fileContent: content,
931
+ allLines: lines,
932
+ isTestFile: fileIsTest,
933
+ };
934
+ // Track which rules have already flagged multi-line or file-level detections
935
+ const firedOnceRules = new Set();
936
+ for (let i = 0; i < lines.length; i++) {
937
+ const line = lines[i];
938
+ context.lineNumber = i + 1;
939
+ // Skip empty lines
940
+ if (line.trim().length === 0)
941
+ continue;
942
+ for (const rule of applicableRules) {
943
+ // For file-level rules (like CONFIG_NO_SECURITY_HEADERS), only fire once
944
+ if (firedOnceRules.has(rule.id))
945
+ continue;
946
+ // Skip comment lines for rules that request it
947
+ if (rule.skipCommentsAndStrings && isCommentLine(line, fileType)) {
948
+ continue;
949
+ }
950
+ try {
951
+ if (rule.detect(line, context)) {
952
+ findings.push({
953
+ id: rule.id,
954
+ engine: 'pattern',
955
+ severity: rule.severity,
956
+ type: rule.category,
957
+ file: filePath,
958
+ line: context.lineNumber,
959
+ description: rule.description,
960
+ fix_suggestion: rule.fix_suggestion,
961
+ auto_fixable: rule.auto_fixable,
962
+ });
963
+ // Mark file-level rules so they only fire once
964
+ if (rule.id === 'CONFIG_NO_SECURITY_HEADERS') {
965
+ firedOnceRules.add(rule.id);
966
+ }
967
+ }
968
+ }
969
+ catch {
970
+ // If a regex or detection function errors, skip this rule for this line
971
+ }
972
+ }
973
+ }
974
+ return findings;
975
+ }
976
+ // ── Public API ──
977
+ /**
978
+ * Scan files at targetPath for vulnerability patterns.
979
+ *
980
+ * @param targetPath - Directory or file path to scan.
981
+ * @param files - Optional pre-supplied list of file paths (skips discovery).
982
+ * @returns Array of findings sorted by severity (critical first).
983
+ */
984
+ export async function scanPatterns(targetPath, files) {
985
+ const filesToScan = files ?? (await discoverFiles(targetPath));
986
+ const allFindings = [];
987
+ // Process files in parallel batches for performance
988
+ const BATCH_SIZE = 50;
989
+ for (let i = 0; i < filesToScan.length; i += BATCH_SIZE) {
990
+ const batch = filesToScan.slice(i, i + BATCH_SIZE);
991
+ const results = await Promise.all(batch.map(async (filePath) => {
992
+ try {
993
+ const content = await readFile(filePath, 'utf-8');
994
+ return scanFileContent(filePath, content);
995
+ }
996
+ catch {
997
+ // Skip files that can't be read (permissions, binary, etc.)
998
+ return [];
999
+ }
1000
+ }));
1001
+ for (const result of results) {
1002
+ allFindings.push(...result);
1003
+ }
1004
+ }
1005
+ // Sort by severity: critical > high > medium > low > info
1006
+ allFindings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
1007
+ return allFindings;
1008
+ }
1009
+ /**
1010
+ * Returns the total number of vulnerability detection rules.
1011
+ */
1012
+ export function getPatternRuleCount() {
1013
+ return RULES.length;
1014
+ }
1015
+ //# sourceMappingURL=patterns.js.map