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.
- package/README.md +456 -357
- package/cli/bin/ship-safe.js +163 -104
- package/cli/commands/fix.js +216 -0
- package/cli/commands/guard.js +297 -0
- package/cli/commands/mcp.js +303 -0
- package/cli/commands/remediate.js +646 -0
- package/cli/commands/rotate.js +571 -0
- package/cli/commands/scan.js +231 -39
- package/cli/utils/entropy.js +126 -0
- package/cli/utils/output.js +10 -1
- package/cli/utils/patterns.js +32 -1
- package/configs/ship-safeignore-template +50 -0
- package/package.json +64 -63
package/cli/commands/scan.js
CHANGED
|
@@ -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]
|
|
9
|
-
* ship-safe scan . -v
|
|
10
|
-
* ship-safe scan . --json
|
|
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
|
|
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.
|
|
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,
|
|
202
|
+
async function findFiles(rootPath, ignorePatterns, options = {}) {
|
|
97
203
|
// Build ignore patterns from SKIP_DIRS
|
|
98
|
-
const
|
|
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:
|
|
106
|
-
dot: true
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
211
|
-
console.log(chalk.gray('
|
|
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
|
-
//
|
|
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
|
+
}
|
package/cli/utils/output.js
CHANGED
|
@@ -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
|
|
package/cli/utils/patterns.js
CHANGED
|
@@ -484,48 +484,56 @@ export const SECRET_PATTERNS = [
|
|
|
484
484
|
},
|
|
485
485
|
|
|
486
486
|
// =========================================================================
|
|
487
|
-
// MEDIUM: Generic patterns (
|
|
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
|
+
];
|