ship-safe 3.0.0 → 3.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.
@@ -21,11 +21,12 @@
21
21
 
22
22
  import fs from 'fs';
23
23
  import path from 'path';
24
- import { glob } from 'glob';
24
+ import fg from 'fast-glob';
25
25
  import ora from 'ora';
26
26
  import chalk from 'chalk';
27
27
  import {
28
28
  SECRET_PATTERNS,
29
+ SECURITY_PATTERNS,
29
30
  SKIP_DIRS,
30
31
  SKIP_EXTENSIONS,
31
32
  TEST_FILE_PATTERNS,
@@ -94,7 +95,7 @@ export async function scanCommand(targetPath = '.', options = {}) {
94
95
 
95
96
  // Load custom patterns from .ship-safe.json
96
97
  const customPatterns = loadCustomPatterns(absolutePath);
97
- const allPatterns = [...SECRET_PATTERNS, ...customPatterns];
98
+ const allPatterns = [...SECRET_PATTERNS, ...SECURITY_PATTERNS, ...customPatterns];
98
99
 
99
100
  if (customPatterns.length > 0 && options.verbose) {
100
101
  output.info(`Loaded ${customPatterns.length} custom pattern(s) from .ship-safe.json`);
@@ -102,7 +103,7 @@ export async function scanCommand(targetPath = '.', options = {}) {
102
103
 
103
104
  // Start spinner
104
105
  const spinner = ora({
105
- text: 'Scanning for secrets...',
106
+ text: 'Scanning for secrets and vulnerabilities...',
106
107
  color: 'cyan'
107
108
  }).start();
108
109
 
@@ -204,10 +205,10 @@ async function findFiles(rootPath, ignorePatterns, options = {}) {
204
205
  const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
205
206
 
206
207
  // Find all files
207
- const files = await glob('**/*', {
208
+ const files = await fg('**/*', {
208
209
  cwd: rootPath,
209
210
  absolute: true,
210
- nodir: true,
211
+ onlyFiles: true,
211
212
  ignore: globIgnore,
212
213
  dot: true
213
214
  });
@@ -284,7 +285,8 @@ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
284
285
  patternName: pattern.name,
285
286
  severity: pattern.severity,
286
287
  confidence,
287
- description: pattern.description
288
+ description: pattern.description,
289
+ category: pattern.category || 'secret'
288
290
  });
289
291
  }
290
292
  }
@@ -293,7 +295,17 @@ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
293
295
  // Skip files that can't be read (binary, permissions, etc.)
294
296
  }
295
297
 
296
- return findings;
298
+ // Deduplicate: multiple patterns can match the same secret on the same line
299
+ // (e.g. Stripe and Clerk both match sk_live_...). Keep one finding per
300
+ // unique (line, matched-text) pair — first match wins (patterns are ordered
301
+ // by severity: critical → high → medium).
302
+ const seen = new Set();
303
+ return findings.filter(f => {
304
+ const key = `${f.line}:${f.matched}`;
305
+ if (seen.has(key)) return false;
306
+ seen.add(key);
307
+ return true;
308
+ });
297
309
  }
298
310
 
299
311
  // =============================================================================
@@ -301,11 +313,24 @@ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
301
313
  // =============================================================================
302
314
 
303
315
  function outputPretty(results, filesScanned, rootPath) {
316
+ // Separate findings into secrets and code vulnerabilities
317
+ const secretResults = [];
318
+ const vulnResults = [];
319
+
320
+ for (const { file, findings } of results) {
321
+ const secrets = findings.filter(f => f.category !== 'vulnerability');
322
+ const vulns = findings.filter(f => f.category === 'vulnerability');
323
+ if (secrets.length > 0) secretResults.push({ file, findings: secrets });
324
+ if (vulns.length > 0) vulnResults.push({ file, findings: vulns });
325
+ }
326
+
304
327
  const stats = {
305
328
  total: 0,
306
329
  critical: 0,
307
330
  high: 0,
308
331
  medium: 0,
332
+ secretsTotal: 0,
333
+ vulnsTotal: 0,
309
334
  filesScanned
310
335
  };
311
336
 
@@ -313,31 +338,45 @@ function outputPretty(results, filesScanned, rootPath) {
313
338
  for (const f of findings) {
314
339
  stats.total++;
315
340
  stats[f.severity] = (stats[f.severity] || 0) + 1;
341
+ if (f.category === 'vulnerability') stats.vulnsTotal++;
342
+ else stats.secretsTotal++;
316
343
  }
317
344
  }
318
345
 
319
346
  output.header('Scan Results');
320
347
 
321
348
  if (results.length === 0) {
322
- output.success('No secrets detected in your codebase!');
349
+ output.success('No secrets or vulnerabilities detected in your codebase!');
323
350
  console.log();
324
351
  console.log(chalk.gray('Note: Uses pattern matching + entropy scoring. Test files excluded by default.'));
325
352
  console.log(chalk.gray('Tip: Run with --include-tests to also scan test files.'));
326
353
  console.log(chalk.gray('Tip: Add a .ship-safeignore file to exclude paths.'));
327
354
  } else {
328
- for (const { file, findings } of results) {
329
- const relPath = path.relative(rootPath, file);
330
-
331
- for (const f of findings) {
332
- output.finding(
333
- relPath,
334
- f.line,
335
- f.patternName,
336
- f.severity,
337
- f.matched,
338
- f.description,
339
- f.confidence
340
- );
355
+ // ── Secrets section ────────────────────────────────────────────────────
356
+ if (secretResults.length > 0) {
357
+ console.log();
358
+ console.log(chalk.red.bold(` Secrets (${stats.secretsTotal})`));
359
+ console.log(chalk.red(' ' + '─'.repeat(58)));
360
+
361
+ for (const { file, findings } of secretResults) {
362
+ const relPath = path.relative(rootPath, file);
363
+ for (const f of findings) {
364
+ output.finding(relPath, f.line, f.patternName, f.severity, f.matched, f.description, f.confidence);
365
+ }
366
+ }
367
+ }
368
+
369
+ // ── Code Vulnerabilities section ───────────────────────────────────────
370
+ if (vulnResults.length > 0) {
371
+ console.log();
372
+ console.log(chalk.yellow.bold(` Code Vulnerabilities (${stats.vulnsTotal})`));
373
+ console.log(chalk.yellow(' ' + '─'.repeat(58)));
374
+
375
+ for (const { file, findings } of vulnResults) {
376
+ const relPath = path.relative(rootPath, file);
377
+ for (const f of findings) {
378
+ output.vulnerabilityFinding(relPath, f.line, f.patternName, f.severity, f.matched, f.description);
379
+ }
341
380
  }
342
381
  }
343
382
 
@@ -346,7 +385,8 @@ function outputPretty(results, filesScanned, rootPath) {
346
385
  console.log(chalk.gray('Suppress a finding: add # ship-safe-ignore as a comment on that line'));
347
386
  console.log(chalk.gray('Exclude a path: add it to .ship-safeignore'));
348
387
 
349
- output.recommendations();
388
+ if (secretResults.length > 0) output.recommendations();
389
+ if (vulnResults.length > 0) output.vulnRecommendations();
350
390
  }
351
391
 
352
392
  output.summary(stats);
@@ -367,10 +407,11 @@ function outputJSON(results, filesScanned) {
367
407
  file,
368
408
  line: f.line,
369
409
  column: f.column,
410
+ category: f.category || 'secret',
370
411
  severity: f.severity,
371
412
  confidence: f.confidence,
372
413
  type: f.patternName,
373
- matched: output.maskSecret(f.matched),
414
+ matched: f.category === 'vulnerability' ? f.matched : output.maskSecret(f.matched),
374
415
  description: f.description
375
416
  });
376
417
  }
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Score Command
3
+ * =============
4
+ *
5
+ * Compute a 0-100 security health score for your project.
6
+ * Combines secret detection, code vulnerability detection, and dependency auditing.
7
+ *
8
+ * USAGE:
9
+ * npx ship-safe score [path] Score the project in the current directory
10
+ * npx ship-safe score . --no-deps Skip dependency audit (faster)
11
+ *
12
+ * SCORING ALGORITHM (starts at 100):
13
+ * Secrets: critical −25, high −15, medium −5 (capped at −40)
14
+ * Code Vulns: critical −20, high −10, medium −3 (capped at −30)
15
+ * Dependencies: critical −20, high −10, moderate −5 (capped at −30)
16
+ *
17
+ * GRADES:
18
+ * A 90–100 Ship it!
19
+ * B 75–89 Minor issues to review
20
+ * C 60–74 Fix before shipping
21
+ * D 40–59 Significant security risks
22
+ * F 0–39 Not safe to ship
23
+ *
24
+ * EXIT CODES:
25
+ * 0 - Score is A or B (90+/75+)
26
+ * 1 - Score is C or below (< 75)
27
+ */
28
+
29
+ import fs from 'fs';
30
+ import path from 'path';
31
+ import fg from 'fast-glob';
32
+ import chalk from 'chalk';
33
+ import ora from 'ora';
34
+ import {
35
+ SECRET_PATTERNS,
36
+ SECURITY_PATTERNS,
37
+ SKIP_DIRS,
38
+ SKIP_EXTENSIONS,
39
+ TEST_FILE_PATTERNS,
40
+ MAX_FILE_SIZE
41
+ } from '../utils/patterns.js';
42
+ import { isHighEntropyMatch } from '../utils/entropy.js';
43
+ import { runDepsAudit } from './deps.js';
44
+ import * as output from '../utils/output.js';
45
+
46
+ // =============================================================================
47
+ // SCORING CONSTANTS
48
+ // =============================================================================
49
+
50
+ const SECRET_DEDUCTIONS = { critical: 25, high: 15, medium: 5 };
51
+ const SECRET_CAP = 40;
52
+
53
+ const VULN_DEDUCTIONS = { critical: 20, high: 10, medium: 3 };
54
+ const VULN_CAP = 30;
55
+
56
+ const DEP_DEDUCTIONS = { critical: 20, high: 10, moderate: 5, medium: 5 };
57
+ const DEP_CAP = 30;
58
+
59
+ const GRADES = [
60
+ { min: 90, letter: 'A', label: 'Ship it!' },
61
+ { min: 75, letter: 'B', label: 'Minor issues to review' },
62
+ { min: 60, letter: 'C', label: 'Fix before shipping' },
63
+ { min: 40, letter: 'D', label: 'Significant security risks' },
64
+ { min: 0, letter: 'F', label: 'Not safe to ship' },
65
+ ];
66
+
67
+ // =============================================================================
68
+ // MAIN COMMAND
69
+ // =============================================================================
70
+
71
+ export async function scoreCommand(targetPath = '.', options = {}) {
72
+ const absolutePath = path.resolve(targetPath);
73
+
74
+ if (!fs.existsSync(absolutePath)) {
75
+ output.error(`Path does not exist: ${absolutePath}`);
76
+ process.exit(1);
77
+ }
78
+
79
+ console.log();
80
+ output.header('Security Health Score');
81
+ console.log();
82
+
83
+ const runDeps = options.deps !== false; // --no-deps sets options.deps = false
84
+
85
+ // ── 1. Scan for secrets and code vulns ──────────────────────────────────────
86
+ const spinner = ora({ text: 'Scanning for secrets and vulnerabilities...', color: 'cyan' }).start();
87
+
88
+ let findings = [];
89
+ let filesScanned = 0;
90
+
91
+ try {
92
+ const files = await findFiles(absolutePath);
93
+ filesScanned = files.length;
94
+ spinner.text = `Scanning ${files.length} files...`;
95
+
96
+ for (const file of files) {
97
+ const fileFindings = scanFile(file);
98
+ findings = findings.concat(fileFindings);
99
+ }
100
+ spinner.stop();
101
+ } catch (err) {
102
+ spinner.fail('Scan failed');
103
+ output.error(err.message);
104
+ process.exit(1);
105
+ }
106
+
107
+ const secretFindings = findings.filter(f => f.category !== 'vulnerability');
108
+ const vulnFindings = findings.filter(f => f.category === 'vulnerability');
109
+
110
+ // ── 2. Dependency audit ──────────────────────────────────────────────────────
111
+ let depVulns = [];
112
+ let pm = null;
113
+
114
+ if (runDeps) {
115
+ const depSpinner = ora({ text: 'Auditing dependencies...', color: 'cyan' }).start();
116
+ try {
117
+ const result = await runDepsAudit(absolutePath);
118
+ pm = result.pm;
119
+ depVulns = result.vulns;
120
+ depSpinner.stop();
121
+ } catch {
122
+ depSpinner.stop();
123
+ // Dep audit failure doesn't block scoring — just skip
124
+ }
125
+ }
126
+
127
+ // ── 3. Compute score ─────────────────────────────────────────────────────────
128
+ const { score, secretDeduction, vulnDeduction, depDeduction, secretCounts, vulnCounts, depCounts } =
129
+ computeScore(secretFindings, vulnFindings, depVulns);
130
+
131
+ const grade = GRADES.find(g => score >= g.min);
132
+
133
+ // ── 4. Print results ─────────────────────────────────────────────────────────
134
+ printScore(score, grade, {
135
+ secretDeduction, vulnDeduction, depDeduction,
136
+ secretCounts, vulnCounts, depCounts,
137
+ filesScanned, pm, runDeps
138
+ });
139
+
140
+ // Exit 0 for A/B, exit 1 for C/D/F
141
+ process.exit(score >= 75 ? 0 : 1);
142
+ }
143
+
144
+ // =============================================================================
145
+ // SCORE COMPUTATION
146
+ // =============================================================================
147
+
148
+ function computeScore(secretFindings, vulnFindings, depVulns) {
149
+ // ── Count by severity ────────────────────────────────────────────────────────
150
+ const secretCounts = countBySeverity(secretFindings);
151
+ const vulnCounts = countBySeverity(vulnFindings);
152
+ const depCounts = countBySeverity(depVulns);
153
+
154
+ // ── Compute deductions ───────────────────────────────────────────────────────
155
+ let secretDeduction = 0;
156
+ for (const [sev, pts] of Object.entries(SECRET_DEDUCTIONS)) {
157
+ secretDeduction += (secretCounts[sev] || 0) * pts;
158
+ }
159
+ secretDeduction = Math.min(secretDeduction, SECRET_CAP);
160
+
161
+ let vulnDeduction = 0;
162
+ for (const [sev, pts] of Object.entries(VULN_DEDUCTIONS)) {
163
+ vulnDeduction += (vulnCounts[sev] || 0) * pts;
164
+ }
165
+ vulnDeduction = Math.min(vulnDeduction, VULN_CAP);
166
+
167
+ let depDeduction = 0;
168
+ for (const [sev, pts] of Object.entries(DEP_DEDUCTIONS)) {
169
+ depDeduction += (depCounts[sev] || 0) * pts;
170
+ }
171
+ depDeduction = Math.min(depDeduction, DEP_CAP);
172
+
173
+ const score = Math.max(0, 100 - secretDeduction - vulnDeduction - depDeduction);
174
+
175
+ return { score, secretDeduction, vulnDeduction, depDeduction, secretCounts, vulnCounts, depCounts };
176
+ }
177
+
178
+ function countBySeverity(findings) {
179
+ const counts = {};
180
+ for (const f of findings) {
181
+ const sev = f.severity || 'unknown';
182
+ counts[sev] = (counts[sev] || 0) + 1;
183
+ }
184
+ return counts;
185
+ }
186
+
187
+ // =============================================================================
188
+ // OUTPUT
189
+ // =============================================================================
190
+
191
+ const GRADE_COLOR = {
192
+ A: chalk.green.bold,
193
+ B: chalk.cyan.bold,
194
+ C: chalk.yellow.bold,
195
+ D: chalk.red,
196
+ F: chalk.red.bold,
197
+ };
198
+
199
+ function printScore(score, grade, ctx) {
200
+ const gradeColor = GRADE_COLOR[grade.letter] || chalk.white;
201
+ const scoreColor = score >= 75 ? chalk.green.bold : score >= 60 ? chalk.yellow.bold : chalk.red.bold;
202
+
203
+ // ── Score headline ───────────────────────────────────────────────────────────
204
+ console.log(
205
+ chalk.white.bold(' Ship Safe Score: ') +
206
+ scoreColor(`${score}/100`) +
207
+ chalk.gray(' ') +
208
+ gradeColor(`${grade.letter}`) +
209
+ chalk.gray(` — ${grade.label}`)
210
+ );
211
+ console.log(chalk.cyan(' ' + '─'.repeat(58)));
212
+ console.log();
213
+
214
+ // ── Row: Secrets ─────────────────────────────────────────────────────────────
215
+ const secretCount = Object.values(ctx.secretCounts).reduce((a, b) => a + b, 0);
216
+ const secretIcon = secretCount === 0 ? chalk.green('✔') : chalk.red('✘');
217
+ const secretStatus = secretCount === 0
218
+ ? chalk.green('0 found')
219
+ : chalk.red(`${secretCount} found`);
220
+ const secretDeductStr = ctx.secretDeduction === 0
221
+ ? chalk.gray('+0 deductions')
222
+ : chalk.red(`−${ctx.secretDeduction} points`) + chalk.gray(` (${formatCounts(ctx.secretCounts)})`);
223
+
224
+ console.log(
225
+ ` ${secretIcon} ${chalk.white.bold('Secrets ')} ${secretStatus.padEnd(18)} ${secretDeductStr}`
226
+ );
227
+
228
+ // ── Row: Code Vulns ───────────────────────────────────────────────────────────
229
+ const vulnCount = Object.values(ctx.vulnCounts).reduce((a, b) => a + b, 0);
230
+ const vulnIcon = vulnCount === 0 ? chalk.green('✔') : chalk.yellow('✘');
231
+ const vulnStatus = vulnCount === 0
232
+ ? chalk.green('0 found')
233
+ : chalk.yellow(`${vulnCount} found`);
234
+ const vulnDeductStr = ctx.vulnDeduction === 0
235
+ ? chalk.gray('+0 deductions')
236
+ : chalk.yellow(`−${ctx.vulnDeduction} points`) + chalk.gray(` (${formatCounts(ctx.vulnCounts)})`);
237
+
238
+ console.log(
239
+ ` ${vulnIcon} ${chalk.white.bold('Code Vulns ')} ${vulnStatus.padEnd(18)} ${vulnDeductStr}`
240
+ );
241
+
242
+ // ── Row: Dependencies ─────────────────────────────────────────────────────────
243
+ if (ctx.runDeps) {
244
+ const depCount = Object.values(ctx.depCounts).reduce((a, b) => a + b, 0);
245
+ const depIcon = depCount === 0 ? chalk.green('✔') : chalk.red('✘');
246
+ const depLabel = ctx.pm ? `Dependencies ` : 'Dependencies ';
247
+
248
+ let depStatus, depDeductStr;
249
+
250
+ if (!ctx.pm) {
251
+ depStatus = chalk.gray('no manifest');
252
+ depDeductStr = chalk.gray('+0 deductions');
253
+ } else if (depCount === 0) {
254
+ depStatus = chalk.green('0 CVEs');
255
+ depDeductStr = chalk.gray('+0 deductions');
256
+ } else {
257
+ depStatus = chalk.red(`${depCount} CVEs`);
258
+ depDeductStr = chalk.red(`−${ctx.depDeduction} points`) + chalk.gray(` (${formatCounts(ctx.depCounts)})`);
259
+ }
260
+
261
+ console.log(
262
+ ` ${depIcon} ${chalk.white.bold(depLabel)} ${depStatus.padEnd(18)} ${depDeductStr}`
263
+ );
264
+ } else {
265
+ console.log(
266
+ ` ${chalk.gray('–')} ${chalk.gray('Dependencies skipped (--no-deps)')}`
267
+ );
268
+ }
269
+
270
+ console.log();
271
+ console.log(chalk.cyan(' ' + '─'.repeat(58)));
272
+ console.log(chalk.gray(` Files scanned: ${ctx.filesScanned}`));
273
+
274
+ // ── Next steps ────────────────────────────────────────────────────────────────
275
+ if (score < 100) {
276
+ console.log();
277
+ const actions = [];
278
+ if (Object.values(ctx.secretCounts).some(n => n > 0)) {
279
+ actions.push(chalk.white(' npx ship-safe agent .') + chalk.gray(' # AI audit: classify + auto-fix secrets'));
280
+ }
281
+ if (Object.values(ctx.vulnCounts).some(n => n > 0)) {
282
+ actions.push(chalk.white(' npx ship-safe agent .') + chalk.gray(' # AI audit: classify + fix suggestions'));
283
+ }
284
+ if (ctx.runDeps && Object.values(ctx.depCounts).some(n => n > 0) && ctx.pm) {
285
+ actions.push(chalk.white(` npx ship-safe deps .`) + chalk.gray(' # See full dependency CVE details'));
286
+ }
287
+ if (actions.length > 0) {
288
+ console.log(chalk.gray(' Fix issues:'));
289
+ // Deduplicate (agent appears for both secrets and vulns)
290
+ const seen = new Set();
291
+ for (const a of actions) {
292
+ if (!seen.has(a)) { console.log(a); seen.add(a); }
293
+ }
294
+ }
295
+ } else {
296
+ console.log();
297
+ console.log(chalk.green(' All clear — safe to ship!'));
298
+ }
299
+
300
+ console.log(chalk.cyan('='.repeat(60)));
301
+ console.log();
302
+ }
303
+
304
+ function formatCounts(counts) {
305
+ const SEV_ORDER = ['critical', 'high', 'moderate', 'medium', 'low'];
306
+ return SEV_ORDER
307
+ .filter(s => counts[s] > 0)
308
+ .map(s => `${counts[s]} ${s}`)
309
+ .join(', ');
310
+ }
311
+
312
+ // =============================================================================
313
+ // INTERNAL SCAN (no subprocess — import patterns directly)
314
+ // =============================================================================
315
+
316
+ const ALL_PATTERNS = [...SECRET_PATTERNS, ...SECURITY_PATTERNS];
317
+
318
+ /**
319
+ * Find all scannable files (same logic as scan.js, without test-exclusion
320
+ * and without .ship-safeignore loading — score is a quick overview).
321
+ */
322
+ async function findFiles(rootPath) {
323
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
324
+
325
+ const files = await fg('**/*', {
326
+ cwd: rootPath,
327
+ absolute: true,
328
+ onlyFiles: true,
329
+ ignore: globIgnore,
330
+ dot: true
331
+ });
332
+
333
+ const filtered = [];
334
+
335
+ for (const file of files) {
336
+ const ext = path.extname(file).toLowerCase();
337
+ if (SKIP_EXTENSIONS.has(ext)) continue;
338
+
339
+ const basename = path.basename(file);
340
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
341
+
342
+ // Load and respect .ship-safeignore
343
+ if (isIgnoredByFile(file, rootPath)) continue;
344
+
345
+ try {
346
+ const stats = fs.statSync(file);
347
+ if (stats.size > MAX_FILE_SIZE) continue;
348
+ } catch {
349
+ continue;
350
+ }
351
+
352
+ filtered.push(file);
353
+ }
354
+
355
+ return filtered;
356
+ }
357
+
358
+ // Cache ignore patterns per root to avoid re-reading the file thousands of times
359
+ const _ignoreCache = new Map();
360
+
361
+ function loadIgnorePatterns(rootPath) {
362
+ if (_ignoreCache.has(rootPath)) return _ignoreCache.get(rootPath);
363
+
364
+ const ignorePath = path.join(rootPath, '.ship-safeignore');
365
+ let patterns = [];
366
+
367
+ if (fs.existsSync(ignorePath)) {
368
+ try {
369
+ patterns = fs.readFileSync(ignorePath, 'utf-8')
370
+ .split('\n')
371
+ .map(l => l.trim())
372
+ .filter(l => l && !l.startsWith('#'));
373
+ } catch {
374
+ // ignore read error
375
+ }
376
+ }
377
+
378
+ _ignoreCache.set(rootPath, patterns);
379
+ return patterns;
380
+ }
381
+
382
+ function isIgnoredByFile(filePath, rootPath) {
383
+ const patterns = loadIgnorePatterns(rootPath);
384
+ if (patterns.length === 0) return false;
385
+
386
+ const relPath = path.relative(rootPath, filePath).replace(/\\/g, '/');
387
+
388
+ return patterns.some(pattern => {
389
+ if (pattern.endsWith('/')) {
390
+ return relPath.startsWith(pattern) || relPath.includes('/' + pattern);
391
+ }
392
+ const escaped = pattern
393
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
394
+ .replace(/\*/g, '[^/]*')
395
+ .replace(/\?/g, '[^/]');
396
+ return new RegExp(`(^|/)${escaped}($|/)`).test(relPath);
397
+ });
398
+ }
399
+
400
+ /**
401
+ * Scan a single file and return normalized findings.
402
+ * Same algorithm as scan.js — inline here to avoid circular dependency
403
+ * (scan.js has process.exit() side effects).
404
+ */
405
+ function scanFile(filePath) {
406
+ const findings = [];
407
+
408
+ try {
409
+ const content = fs.readFileSync(filePath, 'utf-8');
410
+ const lines = content.split('\n');
411
+
412
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
413
+ const line = lines[lineNum];
414
+
415
+ if (/ship-safe-ignore/i.test(line)) continue;
416
+
417
+ for (const pattern of ALL_PATTERNS) {
418
+ pattern.pattern.lastIndex = 0;
419
+
420
+ let match;
421
+ while ((match = pattern.pattern.exec(line)) !== null) {
422
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) {
423
+ continue;
424
+ }
425
+
426
+ findings.push({
427
+ line: lineNum + 1,
428
+ severity: pattern.severity,
429
+ category: pattern.category || 'secret',
430
+ });
431
+ }
432
+ }
433
+ }
434
+ } catch {
435
+ // Skip unreadable files
436
+ }
437
+
438
+ // Deduplicate: same (line, severity, category)
439
+ const seen = new Set();
440
+ return findings.filter(f => {
441
+ const key = `${f.line}:${f.severity}:${f.category}`;
442
+ if (seen.has(key)) return false;
443
+ seen.add(key);
444
+ return true;
445
+ });
446
+ }
package/cli/index.js CHANGED
@@ -9,4 +9,7 @@
9
9
  export { scanCommand } from './commands/scan.js';
10
10
  export { checklistCommand } from './commands/checklist.js';
11
11
  export { initCommand } from './commands/init.js';
12
- export { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS } from './utils/patterns.js';
12
+ export { agentCommand } from './commands/agent.js';
13
+ export { depsCommand, runDepsAudit } from './commands/deps.js';
14
+ export { scoreCommand } from './commands/score.js';
15
+ export { SECRET_PATTERNS, SECURITY_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS } from './utils/patterns.js';
@@ -100,6 +100,12 @@ export function isHighEntropyMatch(matched) {
100
100
  /^(insert|replace|changeme|placeholder|todo|fixme)/i,
101
101
  /([-_]here|[-_]goes|[-_]key|[-_]token|[-_]secret)$/i,
102
102
  /^[a-z]+[-_][a-z]+[-_][a-z]+$/, // looks like-a-passphrase not a key
103
+ /^(add[-_]?your|put[-_]?your|enter[-_]?your|set[-_]?your)/i,
104
+ /^(secret|password|token|apikey|api_key|key|value)[-_]?[0-9]*$/i,
105
+ /^(n\/a|null|undefined|none|empty|blank)/i,
106
+ /^(demo|staging|dev|development|local)[-_]/i,
107
+ /^(abcdef|qwerty|asdfgh|123456|letmein)/i,
108
+ /(.)\1{5,}/, // 6+ repeated chars: aaaaaaa, 111111
103
109
  ];
104
110
 
105
111
  if (PLACEHOLDER_PATTERNS.some(p => p.test(value))) return false;