ship-safe 5.0.0 → 6.0.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.
@@ -87,15 +87,34 @@ export async function depsCommand(targetPath = '.', options = {}) {
87
87
  process.exit(0);
88
88
  }
89
89
 
90
- // ── 4. Display findings ───────────────────────────────────────────────────
90
+ // ── 4. Enrich with EPSS scores ──────────────────────────────────────────
91
+ const cves = vulns.map(v => v.cve).filter(Boolean);
92
+ if (cves.length > 0) {
93
+ const epssSpinner = ora({ text: 'Fetching EPSS exploit probability scores...', color: 'cyan' }).start();
94
+ try {
95
+ const epssData = await fetchEPSS(cves);
96
+ for (const v of vulns) {
97
+ if (v.cve && epssData[v.cve]) {
98
+ v.epss = epssData[v.cve].epss;
99
+ v.percentile = epssData[v.cve].percentile;
100
+ }
101
+ }
102
+ epssSpinner.succeed(chalk.gray(`EPSS scores fetched for ${Object.keys(epssData).length} CVE(s)`));
103
+ } catch {
104
+ epssSpinner.stop();
105
+ // EPSS is optional — continue without it
106
+ }
107
+ }
108
+
109
+ // ── 5. Display findings ───────────────────────────────────────────────────
91
110
  printDepFindings(vulns, pm);
92
111
 
93
- // ── 5. Optionally fix ─────────────────────────────────────────────────────
112
+ // ── 6. Optionally fix ─────────────────────────────────────────────────────
94
113
  if (options.fix) {
95
114
  console.log();
96
115
  console.log(chalk.cyan(` Running: ${pm.fixCommand}`));
97
116
  try {
98
- execSync(pm.fixCommand, { cwd: absolutePath, stdio: 'inherit' });
117
+ execSync(pm.fixCommand, { cwd: absolutePath, stdio: 'inherit' }); // ship-safe-ignore — command is a hardcoded package manager command, not user input
99
118
  } catch {
100
119
  output.warning('Fix command exited with errors — some vulnerabilities may require manual updates.');
101
120
  }
@@ -178,7 +197,7 @@ function detectPackageManager(rootPath) {
178
197
  function runAudit(pm, cwd) {
179
198
  let stdout;
180
199
  try {
181
- stdout = execSync(pm.auditCommand, { cwd, stdio: ['pipe', 'pipe', 'pipe'] }).toString();
200
+ stdout = execSync(pm.auditCommand, { cwd, stdio: ['pipe', 'pipe', 'pipe'] }).toString(); // ship-safe-ignore — command is a hardcoded package manager audit command, not user input
182
201
  } catch (err) {
183
202
  // npm/yarn/pnpm exit with code 1 when vulns found — that's expected
184
203
  if (err.stdout) {
@@ -366,6 +385,46 @@ function parseBundlerAudit(text) {
366
385
  return vulns;
367
386
  }
368
387
 
388
+ // =============================================================================
389
+ // EPSS (Exploit Prediction Scoring System)
390
+ // =============================================================================
391
+
392
+ /**
393
+ * Fetch EPSS scores from FIRST.org API for a list of CVEs.
394
+ * Returns { 'CVE-2023-1234': { epss: 0.942, percentile: 0.99 }, ... }
395
+ * API docs: https://www.first.org/epss/api
396
+ */
397
+ async function fetchEPSS(cves) {
398
+ if (cves.length === 0) return {};
399
+
400
+ // API accepts up to 100 CVEs per request — batch if needed
401
+ const results = {};
402
+ const batches = [];
403
+ for (let i = 0; i < cves.length; i += 100) {
404
+ batches.push(cves.slice(i, i + 100));
405
+ }
406
+
407
+ for (const batch of batches) {
408
+ const url = `https://api.first.org/data/v1/epss?cve=${batch.join(',')}`; // ship-safe-ignore — hardcoded FIRST.org API endpoint, CVE IDs are from audit results not user input
409
+ const response = await fetch(url, { // ship-safe-ignore — EPSS API fetch with fixed base URL
410
+ headers: { 'Accept': 'application/json' },
411
+ signal: AbortSignal.timeout(10000),
412
+ });
413
+
414
+ if (!response.ok) continue;
415
+
416
+ const json = await response.json();
417
+ for (const entry of (json.data || [])) {
418
+ results[entry.cve] = {
419
+ epss: parseFloat(entry.epss),
420
+ percentile: parseFloat(entry.percentile),
421
+ };
422
+ }
423
+ }
424
+
425
+ return results;
426
+ }
427
+
369
428
  // =============================================================================
370
429
  // OUTPUT
371
430
  // =============================================================================
@@ -408,6 +467,16 @@ function printDepFindings(vulns, pm) {
408
467
  if (v.cve) {
409
468
  console.log(chalk.gray(` ${v.cve}`) + (v.url ? chalk.gray(` ${v.url}`) : ''));
410
469
  }
470
+ if (v.epss != null) {
471
+ const pct = (v.epss * 100).toFixed(1);
472
+ const epssColor = v.epss >= 0.5 ? chalk.red.bold
473
+ : v.epss >= 0.1 ? chalk.yellow
474
+ : chalk.gray;
475
+ const label = v.epss >= 0.5 ? ' — actively exploited in the wild'
476
+ : v.epss >= 0.1 ? ' — elevated exploit activity'
477
+ : '';
478
+ console.log(epssColor(` EPSS: ${pct}% exploit probability`) + chalk.gray(label));
479
+ }
411
480
  if (v.fix) {
412
481
  console.log(chalk.gray(' Fix: ') + chalk.cyan(v.fix));
413
482
  }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Diff Command
3
+ * =============
4
+ *
5
+ * Scan only changed files (git diff) for security issues.
6
+ * Much faster than a full audit — perfect for pre-commit hooks and PR reviews.
7
+ *
8
+ * USAGE:
9
+ * ship-safe diff Scan uncommitted changes (staged + unstaged)
10
+ * ship-safe diff --staged Scan only staged changes
11
+ * ship-safe diff HEAD~3 Scan changes in last 3 commits
12
+ * ship-safe diff main Scan changes since branching from main
13
+ */
14
+
15
+ import { execFileSync } from 'child_process';
16
+ import path from 'path';
17
+ import chalk from 'chalk';
18
+ import ora from 'ora';
19
+ import { SECRET_PATTERNS, SECURITY_PATTERNS, SKIP_EXTENSIONS, SKIP_FILENAMES } from '../utils/patterns.js';
20
+ import { buildOrchestrator } from '../agents/index.js';
21
+ import { ScoringEngine } from '../agents/scoring-engine.js';
22
+
23
+ // =============================================================================
24
+ // DIFF COMMAND
25
+ // =============================================================================
26
+
27
+ export async function diffCommand(ref, options) {
28
+ const targetPath = options.path || process.cwd();
29
+ const absolutePath = path.resolve(targetPath);
30
+
31
+ // ── Get changed files from git ────────────────────────────────────────────
32
+ const spinner = ora(chalk.white('Getting changed files from git...')).start();
33
+
34
+ let gitArgs;
35
+ if (options.staged) {
36
+ gitArgs = ['diff', '--cached', '--name-only', '--diff-filter=ACMR'];
37
+ } else if (ref) {
38
+ gitArgs = ['diff', '--name-only', '--diff-filter=ACMR', ref];
39
+ } else {
40
+ // Uncommitted changes: both staged and unstaged
41
+ gitArgs = ['diff', '--name-only', '--diff-filter=ACMR', 'HEAD'];
42
+ }
43
+
44
+ let changedFiles;
45
+ try {
46
+ const output = execFileSync('git', gitArgs, {
47
+ cwd: absolutePath,
48
+ encoding: 'utf-8',
49
+ timeout: 10_000,
50
+ }).trim();
51
+
52
+ if (!output) {
53
+ spinner.succeed(chalk.green('No changed files detected'));
54
+ console.log(chalk.gray('\n Nothing to scan. Your working tree is clean.\n'));
55
+ return;
56
+ }
57
+
58
+ changedFiles = output
59
+ .split('\n')
60
+ .map(f => f.trim())
61
+ .filter(f => f.length > 0)
62
+ .map(f => path.resolve(absolutePath, f))
63
+ .filter(f => {
64
+ const ext = path.extname(f).toLowerCase();
65
+ if (SKIP_EXTENSIONS.has(ext)) return false;
66
+ const basename = path.basename(f);
67
+ if (SKIP_FILENAMES.has(basename)) return false;
68
+ return true;
69
+ });
70
+
71
+ spinner.succeed(chalk.white(`${changedFiles.length} changed file(s) to scan`));
72
+ } catch (err) {
73
+ // Fallback for repos with no commits yet
74
+ if (err.message?.includes('unknown revision')) {
75
+ try {
76
+ const output = execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], {
77
+ cwd: absolutePath,
78
+ encoding: 'utf-8',
79
+ timeout: 10_000,
80
+ }).trim();
81
+
82
+ changedFiles = output
83
+ .split('\n')
84
+ .map(f => f.trim())
85
+ .filter(f => f.length > 0)
86
+ .map(f => path.resolve(absolutePath, f))
87
+ .filter(f => {
88
+ const ext = path.extname(f).toLowerCase();
89
+ if (SKIP_EXTENSIONS.has(ext)) return false;
90
+ const basename = path.basename(f);
91
+ if (SKIP_FILENAMES.has(basename)) return false;
92
+ return true;
93
+ });
94
+
95
+ if (!changedFiles.length) {
96
+ spinner.succeed(chalk.green('No changed files detected'));
97
+ return;
98
+ }
99
+ spinner.succeed(chalk.white(`${changedFiles.length} changed file(s) to scan`));
100
+ } catch {
101
+ spinner.fail(chalk.red('Not a git repository or git not available'));
102
+ process.exit(1);
103
+ }
104
+ } else {
105
+ spinner.fail(chalk.red('Failed to get changed files from git'));
106
+ console.error(chalk.gray(` ${err.message}`));
107
+ process.exit(1);
108
+ }
109
+ }
110
+
111
+ if (changedFiles.length === 0) {
112
+ console.log(chalk.gray('\n No scannable files in the diff.\n'));
113
+ return;
114
+ }
115
+
116
+ // ── Print header ──────────────────────────────────────────────────────────
117
+ console.log();
118
+ console.log(chalk.cyan('═'.repeat(60)));
119
+ console.log(chalk.cyan.bold(' Ship Safe — Diff Scan'));
120
+ console.log(chalk.cyan('═'.repeat(60)));
121
+ console.log();
122
+ console.log(chalk.gray(` Scanning ${changedFiles.length} changed file(s):`));
123
+ for (const f of changedFiles.slice(0, 10)) {
124
+ console.log(chalk.gray(` ${path.relative(absolutePath, f)}`));
125
+ }
126
+ if (changedFiles.length > 10) {
127
+ console.log(chalk.gray(` ... and ${changedFiles.length - 10} more`));
128
+ }
129
+ console.log();
130
+
131
+ // ── Run agents on changed files only ──────────────────────────────────────
132
+ const agentSpinner = ora(chalk.white('Running security agents on diff...')).start();
133
+
134
+ const orchestrator = buildOrchestrator();
135
+ const results = await orchestrator.runAll(absolutePath, {
136
+ timeout: options.timeout || 30_000,
137
+ changedFiles,
138
+ });
139
+
140
+ const findings = results.findings || [];
141
+ agentSpinner.succeed(
142
+ findings.length === 0
143
+ ? chalk.green('No security issues in changed files')
144
+ : chalk.yellow(`${findings.length} finding(s) in changed files`)
145
+ );
146
+
147
+ // ── Score ─────────────────────────────────────────────────────────────────
148
+ if (findings.length > 0) {
149
+ const scoringEngine = new ScoringEngine();
150
+ const scoreResult = scoringEngine.compute(findings, []);
151
+ scoreResult.score = Math.round(scoreResult.score * 10) / 10;
152
+
153
+ const scoreColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
154
+
155
+ console.log();
156
+ console.log(chalk.cyan(' ' + '─'.repeat(56)));
157
+
158
+ // Print findings
159
+ let shown = 0;
160
+ for (const f of findings) {
161
+ if (shown >= 20) {
162
+ console.log(chalk.gray(`\n ... and ${findings.length - 20} more findings`));
163
+ break;
164
+ }
165
+ const sevColor = f.severity === 'critical' ? chalk.red :
166
+ f.severity === 'high' ? chalk.yellow :
167
+ f.severity === 'medium' ? chalk.cyan : chalk.gray;
168
+ const relPath = path.relative(absolutePath, f.file);
169
+ console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`)} ${chalk.white(f.title)}`);
170
+ console.log(chalk.gray(` ${relPath}:${f.line} → ${f.fix || f.description}`));
171
+ shown++;
172
+ }
173
+
174
+ console.log();
175
+ console.log(chalk.cyan(' ' + '─'.repeat(56)));
176
+ console.log(
177
+ chalk.white.bold(' Diff Score: ') +
178
+ scoreColor(`${scoreResult.score}/100 ${scoreResult.grade.letter}`)
179
+ );
180
+ console.log(chalk.cyan(' ' + '─'.repeat(56)));
181
+ }
182
+
183
+ // ── JSON output ───────────────────────────────────────────────────────────
184
+ if (options.json) {
185
+ const output = {
186
+ command: 'diff',
187
+ ref: ref || (options.staged ? '--staged' : 'HEAD'),
188
+ changedFiles: changedFiles.map(f => path.relative(absolutePath, f)),
189
+ findings,
190
+ totalFindings: findings.length,
191
+ };
192
+ console.log(JSON.stringify(output, null, 2));
193
+ }
194
+
195
+ console.log();
196
+ console.log(chalk.cyan('═'.repeat(60)));
197
+ console.log();
198
+
199
+ process.exit(findings.length > 0 ? 1 : 0);
200
+ }
@@ -17,10 +17,20 @@ import { readFileSync } from 'fs';
17
17
  import { fileURLToPath } from 'url';
18
18
  import { dirname, join } from 'path';
19
19
 
20
- const __filename = fileURLToPath(import.meta.url);
20
+ const __filename = fileURLToPath(import.meta.url); // ship-safe-ignore — module's own path via import.meta.url, not user input
21
21
  const __dirname = dirname(__filename);
22
22
  const PACKAGE_VERSION = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')).version;
23
23
 
24
+ function isNewerVersion(latest, current) {
25
+ const a = latest.split('.').map(Number);
26
+ const b = current.split('.').map(Number);
27
+ for (let i = 0; i < 3; i++) {
28
+ if ((a[i] || 0) > (b[i] || 0)) return true;
29
+ if ((a[i] || 0) < (b[i] || 0)) return false;
30
+ }
31
+ return false;
32
+ }
33
+
24
34
  export async function doctorCommand() {
25
35
  console.log();
26
36
  console.log(chalk.cyan.bold(' Ship Safe Doctor'));
@@ -66,8 +76,8 @@ export async function doctorCommand() {
66
76
 
67
77
  // 4. API keys
68
78
  const apiKeys = [
69
- { name: 'Anthropic API key', env: 'ANTHROPIC_API_KEY', required: false },
70
- { name: 'OpenAI API key', env: 'OPENAI_API_KEY', required: false },
79
+ { name: 'Anthropic API key', env: 'ANTHROPIC_API_KEY', required: false }, // ship-safe-ignore — env var names in diagnostic check, no key values
80
+ { name: 'OpenAI API key', env: 'OPENAI_API_KEY', required: false }, // ship-safe-ignore — env var name in diagnostic check, no key value
71
81
  { name: 'Google AI API key', env: 'GOOGLE_API_KEY', required: false },
72
82
  ];
73
83
  for (const key of apiKeys) {
@@ -115,7 +125,7 @@ export async function doctorCommand() {
115
125
  const latest = execFileSync('npm', ['view', 'ship-safe', 'version'], {
116
126
  encoding: 'utf-8', timeout: 5000, shell: true,
117
127
  }).trim();
118
- if (latest && latest !== PACKAGE_VERSION) {
128
+ if (latest && latest !== PACKAGE_VERSION && isNewerVersion(latest, PACKAGE_VERSION)) {
119
129
  const msg = ['v', latest, ' available (current: v', PACKAGE_VERSION, ')'].join('');
120
130
  info(msg);
121
131
  } else if (latest) {