thuban 0.3.1 → 0.3.3

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,612 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const crypto = require('crypto');
4
+ const EventEmitter = require('events');
5
+ const { execFileSync } = require('child_process');
6
+
7
+ class SecretScanner extends EventEmitter {
8
+ constructor(config = {}) {
9
+ super();
10
+
11
+ this.config = {
12
+ rootPath: config.rootPath || process.cwd(),
13
+ ignorePatterns: config.ignorePatterns || [
14
+ 'node_modules/**',
15
+ '.git/**',
16
+ 'dist/**',
17
+ '.next/**',
18
+ 'coverage/**'
19
+ ],
20
+ maxFileSize: config.maxFileSize || 1024 * 1024,
21
+ entropyThreshold: config.entropyThreshold || 4.2,
22
+ minEntropyLength: config.minEntropyLength || 20,
23
+ includeGitHistory: config.includeGitHistory !== false,
24
+ ...config
25
+ };
26
+
27
+ this.scanExtensions = new Set([
28
+ '.env',
29
+ '.js',
30
+ '.jsx',
31
+ '.ts',
32
+ '.tsx',
33
+ '.py',
34
+ '.json',
35
+ '.yaml',
36
+ '.yml',
37
+ '.toml',
38
+ '.ini',
39
+ '.conf',
40
+ '.config',
41
+ '.properties',
42
+ '.sh',
43
+ '.bash',
44
+ '.zsh'
45
+ ]);
46
+
47
+ this.ignoredDirectories = [
48
+ 'node_modules',
49
+ '.git',
50
+ 'dist',
51
+ '.next',
52
+ 'coverage',
53
+ '.venv',
54
+ 'venv',
55
+ '__pycache__'
56
+ ];
57
+
58
+ this.safeLinePatterns = [
59
+ /process\.env\./i,
60
+ /process\.env\[/i,
61
+ /os\.environ/i,
62
+ /sample/i,
63
+ /placeholder/i,
64
+ /dummy/i,
65
+ /mock/i,
66
+ /example/i,
67
+ /replace[_-\s]?me/i,
68
+ /replace[_-\s]?with/i,
69
+ /local-dev-token/i,
70
+ /changeme/i,
71
+ /your[_-\s]?key/i,
72
+ /your[_-\s]?(secret|token|password)/i,
73
+ /<secret>/i,
74
+ /<token>/i,
75
+ /x{8,}/i
76
+ ];
77
+
78
+ this.secretPatterns = [
79
+ {
80
+ id: 'SECRET001',
81
+ type: 'Stripe Secret Key',
82
+ severity: 'critical',
83
+ recommendation: 'Move this key to a cloud secret manager and load it via environment variables.',
84
+ pattern: /\bsk_(live|test)_[0-9a-zA-Z]{16,}\b/gi
85
+ },
86
+ {
87
+ id: 'SECRET002',
88
+ type: 'Stripe Publishable Key',
89
+ severity: 'high',
90
+ recommendation: 'Verify this publishable key is intended to be public; if not, move it to managed secrets.',
91
+ pattern: /\bpk_(live|test)_[0-9a-zA-Z]{16,}\b/gi
92
+ },
93
+ {
94
+ id: 'SECRET003',
95
+ type: 'AWS Access Key',
96
+ severity: 'critical',
97
+ recommendation: 'Rotate the AWS credential immediately and store replacements in a cloud secret manager.',
98
+ pattern: /\b(AKIA|ASIA|AGPA|AIDA|AROA|AIPA)[A-Z0-9]{16}\b/g
99
+ },
100
+ {
101
+ id: 'SECRET004',
102
+ type: 'GitHub Token',
103
+ severity: 'critical',
104
+ recommendation: 'Revoke the GitHub token and replace it with a managed secret or GitHub App credential.',
105
+ pattern: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/g
106
+ },
107
+ {
108
+ id: 'SECRET005',
109
+ type: 'Slack Token',
110
+ severity: 'critical',
111
+ recommendation: 'Rotate the Slack token and move it into a cloud secret manager.',
112
+ pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g
113
+ },
114
+ {
115
+ id: 'SECRET006',
116
+ type: 'Twilio Credential',
117
+ severity: 'critical',
118
+ recommendation: 'Rotate the Twilio credential and store it in managed secrets.',
119
+ pattern: /\b(SK|AC)[0-9a-fA-F]{32}\b/g
120
+ },
121
+ {
122
+ id: 'SECRET007',
123
+ type: 'Google API Key',
124
+ severity: 'critical',
125
+ recommendation: 'Restrict and rotate the Google API key, then move it to a cloud secret manager.',
126
+ pattern: /\bAIza[0-9A-Za-z\-_]{35}\b/g
127
+ },
128
+ {
129
+ id: 'SECRET007B',
130
+ type: 'OpenAI Project Key',
131
+ severity: 'critical',
132
+ recommendation: 'Rotate the OpenAI project key and move it to a cloud secret manager.',
133
+ pattern: /\bsk-proj-[A-Za-z0-9\-_]{20,}\b/g
134
+ },
135
+ {
136
+ id: 'SECRET007C',
137
+ type: 'Cartesia API Key',
138
+ severity: 'critical',
139
+ recommendation: 'Rotate the Cartesia API key and move it to a cloud secret manager.',
140
+ pattern: /\bsk_car_[A-Za-z0-9]{16,}\b/g
141
+ },
142
+ {
143
+ id: 'SECRET008',
144
+ type: 'JWT Token',
145
+ severity: 'high',
146
+ recommendation: 'Do not hardcode JWTs; issue them dynamically and store signing secrets in managed secrets.',
147
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9._-]{8,}\.[A-Za-z0-9._-]{8,}\b/g
148
+ },
149
+ {
150
+ id: 'SECRET009',
151
+ type: 'Bearer Token',
152
+ severity: 'high',
153
+ recommendation: 'Move bearer tokens to a cloud secret manager and inject them at runtime.',
154
+ pattern: /\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b/g
155
+ },
156
+ {
157
+ id: 'SECRET010',
158
+ type: 'Private Key',
159
+ severity: 'critical',
160
+ recommendation: 'Remove the private key from source control, rotate it, and store it in a secret manager.',
161
+ pattern: /-----BEGIN (RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/g
162
+ },
163
+ {
164
+ id: 'SECRET011',
165
+ type: 'Database Connection String with Password',
166
+ severity: 'critical',
167
+ recommendation: 'Move the connection string to managed secrets and avoid embedding passwords in code or config.',
168
+ pattern: /\b(?:postgres(?:ql)?|mysql|mssql|mongodb(?:\+srv)?|redis):\/\/[^:\s]+:[^@\s]+@[^'"`\s]+\b/gi
169
+ },
170
+ {
171
+ id: 'SECRET012',
172
+ type: 'Webhook Secret',
173
+ severity: 'high',
174
+ recommendation: 'Store webhook secrets in a cloud secret manager and inject them via environment variables.',
175
+ pattern: /\b(?:whsec_[A-Za-z0-9]+|webhook[_-]?secret[_:=\s'"]+[A-Za-z0-9_\-]{12,})\b/gi
176
+ },
177
+ {
178
+ id: 'SECRET013',
179
+ type: 'Hardcoded Password or Secret Assignment',
180
+ severity: 'high',
181
+ recommendation: 'Replace hardcoded credentials with environment variables backed by a cloud secret manager.',
182
+ pattern: /\b(?:password|passwd|pwd|secret|client_secret|api[_-]?secret|signing[_-]?secret)\b\s*[:=]\s*['"`][^'"`\s][^'"`]{5,}['"`]/gi
183
+ },
184
+ {
185
+ id: 'SECRET014',
186
+ type: 'Hardcoded API Key Assignment',
187
+ severity: 'high',
188
+ recommendation: 'Move API keys to a cloud secret manager and reference them through environment variables.',
189
+ pattern: /\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|token)\b\s*[:=]\s*['"`][A-Za-z0-9_\-./+=]{12,}['"`]/gi
190
+ },
191
+ {
192
+ id: 'SECRET015',
193
+ type: '.env File Present',
194
+ severity: 'medium',
195
+ recommendation: 'Ensure .env files are excluded from git and move production secrets to a cloud secret manager.',
196
+ filePattern: /(^|[\\/])\.env(\.[^\\/]+)?$/i
197
+ },
198
+ {
199
+ id: 'SECRET016',
200
+ type: 'LiveKit API Secret',
201
+ severity: 'critical',
202
+ recommendation: 'Rotate the LiveKit API secret and store it in managed secrets.',
203
+ pattern: /\b(?:LIVEKIT_API_SECRET|livekit[_-]?api[_-]?secret)\b\s*[:=]\s*['"`]?([A-Za-z0-9]{24,})['"`]?/gi
204
+ }
205
+ ];
206
+ }
207
+
208
+ async scanAll() {
209
+ const startTime = Date.now();
210
+ const files = await this._discoverFiles(this.config.rootPath);
211
+ const issues = [];
212
+
213
+ for (const filePath of files) {
214
+ issues.push(...await this.scanFile(filePath));
215
+ }
216
+
217
+ if (this.config.includeGitHistory) {
218
+ issues.push(...this.scanGitHistory());
219
+ }
220
+
221
+ return {
222
+ filesScanned: files.length,
223
+ issues,
224
+ summary: this._summarizeIssues(issues),
225
+ duration: Date.now() - startTime
226
+ };
227
+ }
228
+
229
+ async scanFiles(filePaths) {
230
+ const issues = [];
231
+ for (const filePath of filePaths) {
232
+ issues.push(...await this.scanFile(filePath));
233
+ }
234
+
235
+ if (this.config.includeGitHistory) {
236
+ issues.push(...this.scanGitHistory());
237
+ }
238
+
239
+ return {
240
+ filesScanned: filePaths.length,
241
+ issues,
242
+ summary: this._summarizeIssues(issues)
243
+ };
244
+ }
245
+
246
+ async scanFile(filePath) {
247
+ const issues = [];
248
+ const relativePath = path.relative(this.config.rootPath, filePath).replace(/\\/g, '/');
249
+
250
+ try {
251
+ const stats = fs.statSync(filePath);
252
+ if (stats.size > this.config.maxFileSize) {
253
+ return issues;
254
+ }
255
+
256
+ const fileName = path.basename(filePath);
257
+ const content = fs.readFileSync(filePath, 'utf8');
258
+ const lines = content.split(/\r?\n/);
259
+
260
+ for (const pattern of this.secretPatterns) {
261
+ if (pattern.filePattern && pattern.filePattern.test(filePath)) {
262
+ if (this._shouldSkipLine(fileName, relativePath, pattern)) {
263
+ continue;
264
+ }
265
+
266
+ if (!this._isGitIgnored(relativePath)) {
267
+ issues.push(this._createIssue({
268
+ file: filePath,
269
+ line: 1,
270
+ type: pattern.type,
271
+ severity: pattern.severity,
272
+ recommendation: 'Add this file to .gitignore and move any production secrets to a cloud secret manager.',
273
+ id: pattern.id,
274
+ match: fileName,
275
+ category: 'secret'
276
+ }));
277
+ }
278
+ continue;
279
+ }
280
+
281
+ if (!pattern.pattern) {
282
+ continue;
283
+ }
284
+
285
+ for (let index = 0; index < lines.length; index++) {
286
+ const line = lines[index];
287
+ if (this._shouldSkipLine(line, relativePath, pattern)) {
288
+ continue;
289
+ }
290
+
291
+ pattern.pattern.lastIndex = 0;
292
+ let match;
293
+ while ((match = pattern.pattern.exec(line)) !== null) {
294
+ const matchedValue = match[1] || match[0];
295
+ if (this._isLikelyPlaceholder(matchedValue)) {
296
+ continue;
297
+ }
298
+
299
+ issues.push(this._createIssue({
300
+ file: filePath,
301
+ line: index + 1,
302
+ column: match.index,
303
+ type: pattern.type,
304
+ severity: pattern.severity,
305
+ recommendation: pattern.recommendation,
306
+ id: pattern.id,
307
+ match: match[0],
308
+ category: 'secret'
309
+ }));
310
+ }
311
+ }
312
+ }
313
+
314
+ issues.push(...this._scanEntropy(filePath, lines, relativePath));
315
+ return this._dedupeIssues(issues);
316
+ } catch (error) {
317
+ return [{
318
+ file: filePath,
319
+ line: 1,
320
+ category: 'secret',
321
+ severity: 'medium',
322
+ id: 'SECRET_SCAN_ERROR',
323
+ type: 'Secret Scan Error',
324
+ recommendation: 'Verify file permissions and encoding, then rerun the scan.',
325
+ message: `Failed to scan file for secrets: ${error.message}`
326
+ }];
327
+ }
328
+ }
329
+
330
+ scanGitHistory() {
331
+ try {
332
+ const output = execFileSync(
333
+ 'git',
334
+ ['log', '-p', '--all', '--full-history', '--', '.'],
335
+ { cwd: this.config.rootPath, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }
336
+ );
337
+
338
+ const issues = [];
339
+ const lines = output.split(/\r?\n/);
340
+ let currentCommit = null;
341
+ let currentFile = null;
342
+
343
+ for (const line of lines) {
344
+ if (line.startsWith('commit ')) {
345
+ currentCommit = line.replace('commit ', '').trim();
346
+ continue;
347
+ }
348
+
349
+ if (line.startsWith('+++ b/')) {
350
+ currentFile = line.replace('+++ b/', '').trim();
351
+ continue;
352
+ }
353
+
354
+ if (!line.startsWith('+') || line.startsWith('+++')) {
355
+ continue;
356
+ }
357
+
358
+ const addedLine = line.slice(1);
359
+ if (this._shouldSkipLine(addedLine, currentFile || '')) {
360
+ continue;
361
+ }
362
+
363
+ for (const pattern of this.secretPatterns) {
364
+ if (!pattern.pattern) {
365
+ continue;
366
+ }
367
+
368
+ pattern.pattern.lastIndex = 0;
369
+ let match;
370
+ while ((match = pattern.pattern.exec(addedLine)) !== null) {
371
+ const matchedValue = match[1] || match[0];
372
+ if (this._isLikelyPlaceholder(matchedValue)) {
373
+ continue;
374
+ }
375
+
376
+ issues.push({
377
+ file: currentFile || 'unknown',
378
+ line: null,
379
+ category: 'secret-history',
380
+ severity: pattern.severity === 'medium' ? 'high' : pattern.severity,
381
+ id: `${pattern.id}_HISTORY`,
382
+ type: `${pattern.type} in Git History`,
383
+ recommendation: 'Purge the secret from git history, rotate it, and move it to a cloud secret manager.',
384
+ message: `Potential secret remains in git history (${currentCommit || 'unknown commit'})`,
385
+ commit: currentCommit,
386
+ code: addedLine.trim().slice(0, 140)
387
+ });
388
+ }
389
+ }
390
+ }
391
+
392
+ return this._dedupeIssues(issues);
393
+ } catch (error) {
394
+ return [];
395
+ }
396
+ }
397
+
398
+ _scanEntropy(filePath, lines, relativePath) {
399
+ const issues = [];
400
+ const candidatePattern = /['"`]([A-Za-z0-9+/=_\-]{20,})['"`]/g;
401
+
402
+ for (let index = 0; index < lines.length; index++) {
403
+ const line = lines[index];
404
+ if (this._shouldSkipLine(line, relativePath)) {
405
+ continue;
406
+ }
407
+
408
+ candidatePattern.lastIndex = 0;
409
+ let match;
410
+ while ((match = candidatePattern.exec(line)) !== null) {
411
+ const candidate = match[1];
412
+ if (candidate.length < this.config.minEntropyLength) {
413
+ continue;
414
+ }
415
+
416
+ if (!/[A-Z]/.test(candidate) || !/[a-z]/.test(candidate) || !/[0-9]/.test(candidate)) {
417
+ continue;
418
+ }
419
+
420
+ const entropy = this._calculateEntropy(candidate);
421
+ if (entropy >= this.config.entropyThreshold) {
422
+ issues.push(this._createIssue({
423
+ file: filePath,
424
+ line: index + 1,
425
+ column: match.index,
426
+ type: 'High Entropy Secret Candidate',
427
+ severity: 'medium',
428
+ recommendation: 'Review this value, move it to a cloud secret manager if it is a credential, and reference it via environment variables.',
429
+ id: 'SECRET_ENTROPY',
430
+ match: candidate,
431
+ category: 'secret',
432
+ extraMessage: `High-entropy string detected (entropy ${entropy.toFixed(2)})`
433
+ }));
434
+ }
435
+ }
436
+ }
437
+
438
+ return issues;
439
+ }
440
+
441
+ _createIssue({ file, line, column, type, severity, recommendation, id, match, category, extraMessage }) {
442
+ return {
443
+ file,
444
+ line,
445
+ column,
446
+ category,
447
+ severity,
448
+ id,
449
+ type,
450
+ recommendation,
451
+ message: extraMessage || `${type} detected`,
452
+ code: typeof match === 'string' ? match.slice(0, 140) : ''
453
+ };
454
+ }
455
+
456
+ async _discoverFiles(dir, files = []) {
457
+ let entries = [];
458
+ try {
459
+ entries = fs.readdirSync(dir, { withFileTypes: true });
460
+ } catch {
461
+ return files;
462
+ }
463
+
464
+ for (const entry of entries) {
465
+ const fullPath = path.join(dir, entry.name);
466
+ const relativePath = path.relative(this.config.rootPath, fullPath).replace(/\\/g, '/');
467
+
468
+ if (this._shouldIgnore(relativePath)) {
469
+ continue;
470
+ }
471
+
472
+ if (entry.isDirectory()) {
473
+ await this._discoverFiles(fullPath, files);
474
+ continue;
475
+ }
476
+
477
+ if (!entry.isFile()) {
478
+ continue;
479
+ }
480
+
481
+ const ext = path.extname(entry.name).toLowerCase();
482
+ if (this.scanExtensions.has(ext) || entry.name.startsWith('.env')) {
483
+ files.push(fullPath);
484
+ }
485
+ }
486
+
487
+ return files;
488
+ }
489
+
490
+ _shouldIgnore(relativePath) {
491
+ const normalized = relativePath.replace(/\\/g, '/');
492
+ const alwaysIgnore = this.ignoredDirectories;
493
+
494
+ for (const dir of alwaysIgnore) {
495
+ if (normalized === dir || normalized.startsWith(`${dir}/`) || normalized.includes(`/${dir}/`)) {
496
+ return true;
497
+ }
498
+ }
499
+
500
+ return this.config.ignorePatterns.some((pattern) => {
501
+ const normalizedPattern = pattern.replace(/\\/g, '/');
502
+ const regexPattern = normalizedPattern
503
+ .replace(/\./g, '\\.')
504
+ .replace(/\*\*/g, '.*')
505
+ .replace(/\*/g, '[^/]*');
506
+ return new RegExp(`^${regexPattern}$`).test(normalized) || normalized.startsWith(normalizedPattern.replace('/**', ''));
507
+ });
508
+ }
509
+
510
+ _shouldSkipLine(line, relativePath, pattern = null) {
511
+ if (!line || !line.trim()) {
512
+ return true;
513
+ }
514
+
515
+ const normalizedPath = (relativePath || '').replace(/\\/g, '/').toLowerCase();
516
+ if (
517
+ normalizedPath.includes('__tests__') ||
518
+ normalizedPath.includes('/test/') ||
519
+ normalizedPath.includes('/tests/') ||
520
+ normalizedPath.endsWith('.spec.js') ||
521
+ normalizedPath.endsWith('.test.js') ||
522
+ normalizedPath.endsWith('.spec.ts') ||
523
+ normalizedPath.endsWith('.test.ts') ||
524
+ normalizedPath.endsWith('.example') ||
525
+ normalizedPath.endsWith('.env.example')
526
+ ) {
527
+ return true;
528
+ }
529
+
530
+ if (pattern && pattern.id === 'SECRET015') {
531
+ return false;
532
+ }
533
+
534
+ return this.safeLinePatterns.some((safePattern) => safePattern.test(line));
535
+ }
536
+
537
+ _calculateEntropy(value) {
538
+ const counts = new Map();
539
+ for (const char of value) {
540
+ counts.set(char, (counts.get(char) || 0) + 1);
541
+ }
542
+
543
+ let entropy = 0;
544
+ for (const count of counts.values()) {
545
+ const probability = count / value.length;
546
+ entropy -= probability * Math.log2(probability);
547
+ }
548
+ return entropy;
549
+ }
550
+
551
+ _isLikelyPlaceholder(value) {
552
+ if (!value) {
553
+ return false;
554
+ }
555
+
556
+ return [
557
+ /replace[_-\s]?me/i,
558
+ /replace[_-\s]?with/i,
559
+ /example/i,
560
+ /dummy/i,
561
+ /placeholder/i,
562
+ /changeme/i,
563
+ /your[_-\s]?(key|secret|token|password)/i,
564
+ /local-dev-token/i,
565
+ /x{8,}/i
566
+ ].some((pattern) => pattern.test(value));
567
+ }
568
+
569
+ _isGitIgnored(relativePath) {
570
+ try {
571
+ execFileSync('git', ['check-ignore', relativePath], {
572
+ cwd: this.config.rootPath,
573
+ stdio: 'ignore'
574
+ });
575
+ return true;
576
+ } catch {
577
+ return false;
578
+ }
579
+ }
580
+
581
+ _dedupeIssues(issues) {
582
+ const seen = new Set();
583
+ return issues.filter((issue) => {
584
+ const hash = crypto
585
+ .createHash('sha1')
586
+ .update(JSON.stringify([issue.file, issue.line, issue.id, issue.code, issue.commit || '']))
587
+ .digest('hex');
588
+ if (seen.has(hash)) {
589
+ return false;
590
+ }
591
+ seen.add(hash);
592
+ return true;
593
+ });
594
+ }
595
+
596
+ _summarizeIssues(issues) {
597
+ return {
598
+ total: issues.length,
599
+ bySeverity: {
600
+ critical: issues.filter((issue) => issue.severity === 'critical').length,
601
+ high: issues.filter((issue) => issue.severity === 'high').length,
602
+ medium: issues.filter((issue) => issue.severity === 'medium').length
603
+ },
604
+ byType: issues.reduce((accumulator, issue) => {
605
+ accumulator[issue.type] = (accumulator[issue.type] || 0) + 1;
606
+ return accumulator;
607
+ }, {})
608
+ };
609
+ }
610
+ }
611
+
612
+ module.exports = SecretScanner;