ship-safe 2.0.0 → 3.1.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.
@@ -2,18 +2,21 @@
2
2
  * Scan Command
3
3
  * ============
4
4
  *
5
- * Scans a directory for leaked secrets using pattern matching.
5
+ * Scans a directory for leaked secrets using pattern matching + entropy scoring.
6
6
  *
7
7
  * USAGE:
8
- * ship-safe scan [path] Scan specified path (default: current directory)
9
- * ship-safe scan . -v Verbose mode (show files being scanned)
10
- * ship-safe scan . --json Output as JSON (for CI integration)
8
+ * ship-safe scan [path] Scan specified path (default: current directory)
9
+ * ship-safe scan . -v Verbose mode (show files being scanned)
10
+ * ship-safe scan . --json Output as JSON (for CI integration)
11
+ * ship-safe scan . --include-tests Also scan test files (excluded by default)
12
+ *
13
+ * SUPPRESSING FALSE POSITIVES:
14
+ * Add # ship-safe-ignore as a comment on the same line to suppress a finding.
15
+ * Create a .ship-safeignore file (same syntax as .gitignore) to exclude paths.
11
16
  *
12
17
  * EXIT CODES:
13
18
  * 0 - No secrets found
14
19
  * 1 - Secrets found (or error)
15
- *
16
- * This allows CI pipelines to fail builds when secrets are detected.
17
20
  */
18
21
 
19
22
  import fs from 'fs';
@@ -25,10 +28,54 @@ import {
25
28
  SECRET_PATTERNS,
26
29
  SKIP_DIRS,
27
30
  SKIP_EXTENSIONS,
31
+ TEST_FILE_PATTERNS,
28
32
  MAX_FILE_SIZE
29
33
  } from '../utils/patterns.js';
34
+ import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
30
35
  import * as output from '../utils/output.js';
31
36
 
37
+ // =============================================================================
38
+ // CUSTOM PATTERNS (.ship-safe.json)
39
+ // =============================================================================
40
+
41
+ /**
42
+ * Load custom patterns from .ship-safe.json in the project root.
43
+ *
44
+ * Format:
45
+ * {
46
+ * "patterns": [
47
+ * {
48
+ * "name": "My Internal Key",
49
+ * "pattern": "MYAPP_[A-Z0-9]{32}",
50
+ * "severity": "high",
51
+ * "description": "Internal API key for myapp services."
52
+ * }
53
+ * ]
54
+ * }
55
+ */
56
+ function loadCustomPatterns(rootPath) {
57
+ const configPath = path.join(rootPath, '.ship-safe.json');
58
+ if (!fs.existsSync(configPath)) return [];
59
+
60
+ try {
61
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
62
+ if (!Array.isArray(config.patterns)) return [];
63
+
64
+ return config.patterns
65
+ .filter(p => p.name && p.pattern)
66
+ .map(p => ({
67
+ name: `[custom] ${p.name}`,
68
+ pattern: new RegExp(p.pattern, 'g'),
69
+ severity: p.severity || 'high',
70
+ description: p.description || `Custom pattern: ${p.name}`,
71
+ custom: true,
72
+ }));
73
+ } catch (err) {
74
+ output.warning(`.ship-safe.json parse error: ${err.message}`);
75
+ return [];
76
+ }
77
+ }
78
+
32
79
  // =============================================================================
33
80
  // MAIN SCAN FUNCTION
34
81
  // =============================================================================
@@ -42,6 +89,17 @@ export async function scanCommand(targetPath = '.', options = {}) {
42
89
  process.exit(1);
43
90
  }
44
91
 
92
+ // Load .ship-safeignore patterns
93
+ const ignorePatterns = loadIgnoreFile(absolutePath);
94
+
95
+ // Load custom patterns from .ship-safe.json
96
+ const customPatterns = loadCustomPatterns(absolutePath);
97
+ const allPatterns = [...SECRET_PATTERNS, ...customPatterns];
98
+
99
+ if (customPatterns.length > 0 && options.verbose) {
100
+ output.info(`Loaded ${customPatterns.length} custom pattern(s) from .ship-safe.json`);
101
+ }
102
+
45
103
  // Start spinner
46
104
  const spinner = ora({
47
105
  text: 'Scanning for secrets...',
@@ -50,7 +108,7 @@ export async function scanCommand(targetPath = '.', options = {}) {
50
108
 
51
109
  try {
52
110
  // Find all files
53
- const files = await findFiles(absolutePath, options.verbose);
111
+ const files = await findFiles(absolutePath, ignorePatterns, options);
54
112
  spinner.text = `Scanning ${files.length} files...`;
55
113
 
56
114
  // Scan each file
@@ -58,7 +116,7 @@ export async function scanCommand(targetPath = '.', options = {}) {
58
116
  let scannedCount = 0;
59
117
 
60
118
  for (const file of files) {
61
- const findings = await scanFile(file);
119
+ const findings = await scanFile(file, allPatterns);
62
120
  if (findings.length > 0) {
63
121
  results.push({ file, findings });
64
122
  }
@@ -72,7 +130,9 @@ export async function scanCommand(targetPath = '.', options = {}) {
72
130
  spinner.stop();
73
131
 
74
132
  // Output results
75
- if (options.json) {
133
+ if (options.sarif) {
134
+ outputSARIF(results, absolutePath);
135
+ } else if (options.json) {
76
136
  outputJSON(results, files.length);
77
137
  } else {
78
138
  outputPretty(results, files.length, absolutePath);
@@ -89,48 +149,90 @@ export async function scanCommand(targetPath = '.', options = {}) {
89
149
  }
90
150
  }
91
151
 
152
+ // =============================================================================
153
+ // .SHIP-SAFEIGNORE LOADING
154
+ // =============================================================================
155
+
156
+ /**
157
+ * Load ignore patterns from .ship-safeignore file.
158
+ * Same syntax as .gitignore — glob patterns, one per line, # for comments.
159
+ */
160
+ function loadIgnoreFile(rootPath) {
161
+ const ignorePath = path.join(rootPath, '.ship-safeignore');
162
+
163
+ if (!fs.existsSync(ignorePath)) return [];
164
+
165
+ try {
166
+ return fs.readFileSync(ignorePath, 'utf-8')
167
+ .split('\n')
168
+ .map(line => line.trim())
169
+ .filter(line => line && !line.startsWith('#'));
170
+ } catch {
171
+ return [];
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Check if a file path matches any ignore pattern.
177
+ * Supports: exact paths, glob patterns, and directory prefixes.
178
+ */
179
+ function isIgnoredByFile(filePath, rootPath, ignorePatterns) {
180
+ if (ignorePatterns.length === 0) return false;
181
+
182
+ const relPath = path.relative(rootPath, filePath).replace(/\\/g, '/');
183
+
184
+ return ignorePatterns.some(pattern => {
185
+ // Directory prefix match: "tests/" ignores everything under tests/
186
+ if (pattern.endsWith('/')) {
187
+ return relPath.startsWith(pattern) || relPath.includes('/' + pattern);
188
+ }
189
+ // Simple glob: "**/fixtures/**" or "src/secrets.js"
190
+ const escaped = pattern
191
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
192
+ .replace(/\*/g, '[^/]*')
193
+ .replace(/\?/g, '[^/]');
194
+ return new RegExp(`(^|/)${escaped}($|/)`).test(relPath);
195
+ });
196
+ }
197
+
92
198
  // =============================================================================
93
199
  // FILE DISCOVERY
94
200
  // =============================================================================
95
201
 
96
- async function findFiles(rootPath, verbose = false) {
202
+ async function findFiles(rootPath, ignorePatterns, options = {}) {
97
203
  // Build ignore patterns from SKIP_DIRS
98
- const ignorePatterns = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
204
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
99
205
 
100
206
  // Find all files
101
207
  const files = await glob('**/*', {
102
208
  cwd: rootPath,
103
209
  absolute: true,
104
210
  nodir: true,
105
- ignore: ignorePatterns,
106
- dot: true // Include dotfiles (but not .git which is ignored)
211
+ ignore: globIgnore,
212
+ dot: true
107
213
  });
108
214
 
109
- // Filter by extension and size
110
215
  const filtered = [];
111
216
 
112
217
  for (const file of files) {
113
218
  // Skip by extension
114
219
  const ext = path.extname(file).toLowerCase();
115
- if (SKIP_EXTENSIONS.has(ext)) {
116
- continue;
117
- }
220
+ if (SKIP_EXTENSIONS.has(ext)) continue;
118
221
 
119
222
  // Handle compound extensions like .min.js
120
223
  const basename = path.basename(file);
121
- if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) {
122
- continue;
123
- }
224
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
225
+
226
+ // Skip test files by default (--include-tests to override)
227
+ if (!options.includeTests && isTestFile(file)) continue;
228
+
229
+ // Skip files matching .ship-safeignore
230
+ if (isIgnoredByFile(file, rootPath, ignorePatterns)) continue;
124
231
 
125
232
  // Skip by size
126
233
  try {
127
234
  const stats = fs.statSync(file);
128
- if (stats.size > MAX_FILE_SIZE) {
129
- if (verbose) {
130
- console.log(chalk.gray(` Skipping (too large): ${file}`));
131
- }
132
- continue;
133
- }
235
+ if (stats.size > MAX_FILE_SIZE) continue;
134
236
  } catch {
135
237
  continue;
136
238
  }
@@ -141,39 +243,53 @@ async function findFiles(rootPath, verbose = false) {
141
243
  return filtered;
142
244
  }
143
245
 
246
+ function isTestFile(filePath) {
247
+ return TEST_FILE_PATTERNS.some(pattern => pattern.test(filePath));
248
+ }
249
+
144
250
  // =============================================================================
145
251
  // FILE SCANNING
146
252
  // =============================================================================
147
253
 
148
- async function scanFile(filePath) {
254
+ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
149
255
  const findings = [];
150
256
 
151
257
  try {
152
258
  const content = fs.readFileSync(filePath, 'utf-8');
153
259
  const lines = content.split('\n');
154
260
 
155
- // Check each pattern against each line
156
261
  for (let lineNum = 0; lineNum < lines.length; lineNum++) {
157
262
  const line = lines[lineNum];
158
263
 
159
- for (const pattern of SECRET_PATTERNS) {
264
+ // Inline suppression: # ship-safe-ignore on the same line
265
+ if (/ship-safe-ignore/i.test(line)) continue;
266
+
267
+ for (const pattern of patterns) {
160
268
  // Reset regex state (important for global regexes)
161
269
  pattern.pattern.lastIndex = 0;
162
270
 
163
271
  let match;
164
272
  while ((match = pattern.pattern.exec(line)) !== null) {
273
+ // For generic patterns, apply entropy check to filter placeholders
274
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) {
275
+ continue;
276
+ }
277
+
278
+ const confidence = getConfidence(pattern, match[0]);
279
+
165
280
  findings.push({
166
281
  line: lineNum + 1,
167
282
  column: match.index + 1,
168
283
  matched: match[0],
169
284
  patternName: pattern.name,
170
285
  severity: pattern.severity,
286
+ confidence,
171
287
  description: pattern.description
172
288
  });
173
289
  }
174
290
  }
175
291
  }
176
- } catch (err) {
292
+ } catch {
177
293
  // Skip files that can't be read (binary, permissions, etc.)
178
294
  }
179
295
 
@@ -185,7 +301,6 @@ async function scanFile(filePath) {
185
301
  // =============================================================================
186
302
 
187
303
  function outputPretty(results, filesScanned, rootPath) {
188
- // Calculate stats
189
304
  const stats = {
190
305
  total: 0,
191
306
  critical: 0,
@@ -197,20 +312,19 @@ function outputPretty(results, filesScanned, rootPath) {
197
312
  for (const { findings } of results) {
198
313
  for (const f of findings) {
199
314
  stats.total++;
200
- stats[f.severity]++;
315
+ stats[f.severity] = (stats[f.severity] || 0) + 1;
201
316
  }
202
317
  }
203
318
 
204
- // Print header
205
319
  output.header('Scan Results');
206
320
 
207
321
  if (results.length === 0) {
208
322
  output.success('No secrets detected in your codebase!');
209
323
  console.log();
210
- console.log(chalk.gray('Note: This scanner uses pattern matching and may miss some secrets.'));
211
- console.log(chalk.gray('Consider also using: gitleaks, trufflehog, or detect-secrets'));
324
+ console.log(chalk.gray('Note: Uses pattern matching + entropy scoring. Test files excluded by default.'));
325
+ console.log(chalk.gray('Tip: Run with --include-tests to also scan test files.'));
326
+ console.log(chalk.gray('Tip: Add a .ship-safeignore file to exclude paths.'));
212
327
  } else {
213
- // Print findings grouped by file
214
328
  for (const { file, findings } of results) {
215
329
  const relPath = path.relative(rootPath, file);
216
330
 
@@ -221,16 +335,20 @@ function outputPretty(results, filesScanned, rootPath) {
221
335
  f.patternName,
222
336
  f.severity,
223
337
  f.matched,
224
- f.description
338
+ f.description,
339
+ f.confidence
225
340
  );
226
341
  }
227
342
  }
228
343
 
229
- // Print recommendations
344
+ // Remind about suppressions
345
+ console.log();
346
+ console.log(chalk.gray('Suppress a finding: add # ship-safe-ignore as a comment on that line'));
347
+ console.log(chalk.gray('Exclude a path: add it to .ship-safeignore'));
348
+
230
349
  output.recommendations();
231
350
  }
232
351
 
233
- // Print summary
234
352
  output.summary(stats);
235
353
  }
236
354
 
@@ -250,6 +368,7 @@ function outputJSON(results, filesScanned) {
250
368
  line: f.line,
251
369
  column: f.column,
252
370
  severity: f.severity,
371
+ confidence: f.confidence,
253
372
  type: f.patternName,
254
373
  matched: output.maskSecret(f.matched),
255
374
  description: f.description
@@ -259,3 +378,76 @@ function outputJSON(results, filesScanned) {
259
378
 
260
379
  console.log(JSON.stringify(jsonOutput, null, 2));
261
380
  }
381
+
382
+ // =============================================================================
383
+ // SARIF OUTPUT (GitHub Code Scanning compatible)
384
+ // =============================================================================
385
+
386
+ /**
387
+ * Output findings in SARIF 2.1.0 format.
388
+ * Feed this into GitHub's Security tab:
389
+ * npx ship-safe scan . --sarif > results.sarif
390
+ *
391
+ * Then upload via:
392
+ * github/codeql-action/upload-sarif@v3
393
+ */
394
+ function outputSARIF(results, rootPath) {
395
+ const rules = {};
396
+
397
+ // Build rules from findings
398
+ for (const { findings } of results) {
399
+ for (const f of findings) {
400
+ if (!rules[f.patternName]) {
401
+ rules[f.patternName] = {
402
+ id: f.patternName.replace(/\s+/g, '-').toLowerCase(),
403
+ name: f.patternName,
404
+ shortDescription: { text: f.patternName },
405
+ fullDescription: { text: f.description },
406
+ defaultConfiguration: {
407
+ level: f.severity === 'critical' ? 'error'
408
+ : f.severity === 'high' ? 'error'
409
+ : f.severity === 'medium' ? 'warning'
410
+ : 'note'
411
+ },
412
+ helpUri: 'https://github.com/asamassekou10/ship-safe',
413
+ };
414
+ }
415
+ }
416
+ }
417
+
418
+ const sarif = {
419
+ version: '2.1.0',
420
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
421
+ runs: [{
422
+ tool: {
423
+ driver: {
424
+ name: 'ship-safe',
425
+ version: '2.1.0',
426
+ informationUri: 'https://github.com/asamassekou10/ship-safe',
427
+ rules: Object.values(rules),
428
+ }
429
+ },
430
+ results: results.flatMap(({ file, findings }) =>
431
+ findings.map(f => ({
432
+ ruleId: f.patternName.replace(/\s+/g, '-').toLowerCase(),
433
+ level: f.severity === 'critical' || f.severity === 'high' ? 'error' : 'warning',
434
+ message: { text: f.description },
435
+ locations: [{
436
+ physicalLocation: {
437
+ artifactLocation: {
438
+ uri: path.relative(rootPath, file).replace(/\\/g, '/'),
439
+ uriBaseId: '%SRCROOT%'
440
+ },
441
+ region: {
442
+ startLine: f.line,
443
+ startColumn: f.column,
444
+ }
445
+ }
446
+ }]
447
+ }))
448
+ )
449
+ }]
450
+ };
451
+
452
+ console.log(JSON.stringify(sarif, null, 2));
453
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Shannon Entropy Scoring
3
+ * =======================
4
+ *
5
+ * Used to reduce false positives in secret detection.
6
+ *
7
+ * CONCEPT:
8
+ * Real secrets (API keys, tokens) are randomly generated and have HIGH entropy.
9
+ * Placeholder values like "your-api-key-here" or "example123" have LOW entropy
10
+ * because they follow predictable patterns or use common words.
11
+ *
12
+ * Shannon entropy measures the "randomness" of a string on a scale of 0-8.
13
+ * - 0: Completely uniform ("aaaaaaaaaaaaaaaa")
14
+ * - 2-3: Low entropy ("your-api-key-here", "example_value")
15
+ * - 3.5+: High entropy ("xK9mP2nQ8vL4jR7s") - likely a real secret
16
+ * - 5+: Very high entropy (random bytes, base64)
17
+ *
18
+ * We only apply entropy checks to "generic" patterns that lack specific prefixes.
19
+ * Patterns with known prefixes (sk-ant-, ghp_, AKIA...) are already precise enough.
20
+ */
21
+
22
+ // =============================================================================
23
+ // ENTROPY CALCULATION
24
+ // =============================================================================
25
+
26
+ /**
27
+ * Calculate Shannon entropy of a string.
28
+ * Returns a number between 0 and log2(charset size).
29
+ */
30
+ export function shannonEntropy(str) {
31
+ if (!str || str.length === 0) return 0;
32
+
33
+ const freq = {};
34
+ for (const char of str) {
35
+ freq[char] = (freq[char] || 0) + 1;
36
+ }
37
+
38
+ return Object.values(freq).reduce((sum, count) => {
39
+ const p = count / str.length;
40
+ return sum - p * Math.log2(p);
41
+ }, 0);
42
+ }
43
+
44
+ // Minimum entropy to consider a match a real secret
45
+ // Real secrets: >3.5 | Placeholders: <3.0 | Safe buffer: 3.5
46
+ export const ENTROPY_THRESHOLD = 3.5;
47
+
48
+ // Strings shorter than this are unreliable for entropy analysis
49
+ const MIN_ENTROPY_LENGTH = 16;
50
+
51
+ // =============================================================================
52
+ // VALUE EXTRACTION
53
+ // =============================================================================
54
+
55
+ /**
56
+ * Extract the actual secret value from a matched string.
57
+ *
58
+ * Patterns often match the full assignment, e.g.:
59
+ * apiKey = "abc123xyz..."
60
+ *
61
+ * We want to score just the value part, not the variable name,
62
+ * because variable names are low-entropy and would skew the score.
63
+ */
64
+ function extractSecretValue(matched) {
65
+ // Match: = "value" or : "value" or = value
66
+ const assignmentMatch = matched.match(/[:=]\s*["']?([a-zA-Z0-9_\-+/=.]{12,})["']?\s*$/);
67
+ if (assignmentMatch) return assignmentMatch[1];
68
+
69
+ // Match: Bearer <token>
70
+ const bearerMatch = matched.match(/Bearer\s+([a-zA-Z0-9_\-+/=.]{12,})/i);
71
+ if (bearerMatch) return bearerMatch[1];
72
+
73
+ // Match: quoted value anywhere
74
+ const quotedMatch = matched.match(/["']([a-zA-Z0-9_\-+/=.]{12,})["']/);
75
+ if (quotedMatch) return quotedMatch[1];
76
+
77
+ return matched;
78
+ }
79
+
80
+ // =============================================================================
81
+ // PUBLIC API
82
+ // =============================================================================
83
+
84
+ /**
85
+ * Determine if a regex match looks like a real secret based on entropy.
86
+ *
87
+ * Returns true → keep the finding (high entropy or can't determine)
88
+ * Returns false → filter it out (low entropy, likely a placeholder)
89
+ */
90
+ export function isHighEntropyMatch(matched) {
91
+ const value = extractSecretValue(matched);
92
+
93
+ // If we can't extract a meaningful value, keep the finding
94
+ if (!value || value.length < MIN_ENTROPY_LENGTH) return true;
95
+
96
+ // Common placeholder patterns - fast path rejection
97
+ const PLACEHOLDER_PATTERNS = [
98
+ /^(your[-_]?|my[-_]?|example[-_]?|test[-_]?|dummy[-_]?|fake[-_]?|sample[-_]?)/i,
99
+ /^(xxx+|yyy+|zzz+|aaa+|000+)/i,
100
+ /^(insert|replace|changeme|placeholder|todo|fixme)/i,
101
+ /([-_]here|[-_]goes|[-_]key|[-_]token|[-_]secret)$/i,
102
+ /^[a-z]+[-_][a-z]+[-_][a-z]+$/, // looks like-a-passphrase not a key
103
+ ];
104
+
105
+ if (PLACEHOLDER_PATTERNS.some(p => p.test(value))) return false;
106
+
107
+ const entropy = shannonEntropy(value);
108
+ return entropy >= ENTROPY_THRESHOLD;
109
+ }
110
+
111
+ /**
112
+ * Get a human-readable confidence label for a finding.
113
+ */
114
+ export function getConfidence(pattern, matched) {
115
+ // Strict prefix patterns (e.g. sk-ant-, ghp_, AKIA) are always high confidence
116
+ if (!pattern.requiresEntropyCheck) return 'high';
117
+
118
+ const value = extractSecretValue(matched);
119
+ if (!value || value.length < MIN_ENTROPY_LENGTH) return 'medium';
120
+
121
+ const entropy = shannonEntropy(value);
122
+
123
+ if (entropy >= 4.5) return 'high';
124
+ if (entropy >= ENTROPY_THRESHOLD) return 'medium';
125
+ return 'low'; // Should have been filtered, but just in case
126
+ }
@@ -78,17 +78,26 @@ export function info(text) {
78
78
  console.log(chalk.blue('\u2139 ') + text);
79
79
  }
80
80
 
81
+ const confidenceColors = {
82
+ high: chalk.red,
83
+ medium: chalk.yellow,
84
+ low: chalk.gray,
85
+ };
86
+
81
87
  /**
82
88
  * Print a finding (secret detected)
83
89
  */
84
- export function finding(file, line, patternName, severity, matched, description) {
90
+ export function finding(file, line, patternName, severity, matched, description, confidence) {
85
91
  const color = severityColors[severity] || chalk.white;
86
92
  const icon = severityIcons[severity] || '';
93
+ const confColor = confidenceColors[confidence] || chalk.gray;
94
+ const confLabel = confidence ? ` ${chalk.gray('Confidence:')} ${confColor(confidence)}` : '';
87
95
 
88
96
  console.log();
89
97
  console.log(chalk.white.bold(`${file}:${line}`));
90
98
  console.log(` ${icon}${color(`[${severity.toUpperCase()}]`)} ${chalk.white(patternName)}`);
91
99
  console.log(` ${chalk.gray('Found:')} ${chalk.yellow(maskSecret(matched))}`);
100
+ if (confLabel) console.log(confLabel);
92
101
  console.log(` ${chalk.gray('Why:')} ${description}`);
93
102
  }
94
103
 
@@ -484,48 +484,56 @@ export const SECRET_PATTERNS = [
484
484
  },
485
485
 
486
486
  // =========================================================================
487
- // MEDIUM: Generic patterns (may have false positives)
487
+ // MEDIUM: Generic patterns (entropy-checked to reduce false positives)
488
+ // requiresEntropyCheck: true → value is scored before reporting
488
489
  // =========================================================================
489
490
  {
490
491
  name: 'Generic API Key Assignment',
491
492
  pattern: /["']?(?:api[_-]?key|apikey)["']?\s*[:=]\s*["']([a-zA-Z0-9_\-]{20,})["']/gi,
492
493
  severity: 'medium',
494
+ requiresEntropyCheck: true,
493
495
  description: 'Hardcoded API keys should be moved to environment variables.'
494
496
  },
495
497
  {
496
498
  name: 'Generic Secret Assignment',
497
499
  pattern: /["']?(?:secret|secret[_-]?key)["']?\s*[:=]\s*["']([a-zA-Z0-9_\-]{20,})["']/gi,
498
500
  severity: 'medium',
501
+ requiresEntropyCheck: true,
499
502
  description: 'Hardcoded secrets should be moved to environment variables.'
500
503
  },
501
504
  {
502
505
  name: 'Password Assignment',
503
506
  pattern: /["']?password["']?\s*[:=]\s*["']([^"']{8,})["']/gi,
504
507
  severity: 'medium',
508
+ requiresEntropyCheck: true,
505
509
  description: 'Hardcoded passwords are a critical vulnerability.'
506
510
  },
507
511
  {
508
512
  name: 'Database URL with Credentials',
509
513
  pattern: /(mongodb|postgres|postgresql|mysql|redis):\/\/[^:]+:[^@]+@[^\s"']+/gi,
510
514
  severity: 'medium',
515
+ requiresEntropyCheck: true,
511
516
  description: 'Database URLs with embedded passwords expose your database.'
512
517
  },
513
518
  {
514
519
  name: 'Bearer Token in Code',
515
520
  pattern: /["']Bearer\s+[a-zA-Z0-9_\-\.=]{20,}["']/gi,
516
521
  severity: 'medium',
522
+ requiresEntropyCheck: true,
517
523
  description: 'Hardcoded bearer tokens should not be in source code.'
518
524
  },
519
525
  {
520
526
  name: 'Basic Auth Header',
521
527
  pattern: /["']Basic\s+[A-Za-z0-9+/=]{20,}["']/gi,
522
528
  severity: 'medium',
529
+ requiresEntropyCheck: true,
523
530
  description: 'Basic auth headers contain base64-encoded credentials.'
524
531
  },
525
532
  {
526
533
  name: 'Private Key in Environment Variable',
527
534
  pattern: /PRIVATE[_-]?KEY["']?\s*[:=]\s*["']([^"']+)["']/gi,
528
535
  severity: 'high',
536
+ requiresEntropyCheck: true,
529
537
  description: 'Private keys should be loaded from files, not hardcoded.'
530
538
  }
531
539
  ];
@@ -584,3 +592,26 @@ export const SKIP_EXTENSIONS = new Set([
584
592
 
585
593
  // Maximum file size to scan (1MB)
586
594
  export const MAX_FILE_SIZE = 1_000_000;
595
+
596
+ // =============================================================================
597
+ // TEST FILE PATTERNS (skipped by default, override with --include-tests)
598
+ // =============================================================================
599
+ // Test fixtures are the #1 source of false positives. They contain fake
600
+ // credentials, mock data, and example values that look like real secrets.
601
+
602
+ export const TEST_FILE_PATTERNS = [
603
+ /\.test\.[jt]sx?$/,
604
+ /\.spec\.[jt]sx?$/,
605
+ /\.test\.py$/,
606
+ /test_[^/]+\.py$/,
607
+ /__tests__[/\\]/,
608
+ /[/\\]tests?[/\\]/,
609
+ /[/\\]test[/\\]/,
610
+ /[/\\]fixtures?[/\\]/,
611
+ /[/\\]mocks?[/\\]/,
612
+ /[/\\]__mocks__[/\\]/,
613
+ /[/\\]stubs?[/\\]/,
614
+ /[/\\]fakes?[/\\]/,
615
+ /\.stories\.[jt]sx?$/, // Storybook story files
616
+ /\.mock\.[jt]sx?$/,
617
+ ];