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.
- package/cli/agents/agentic-security-agent.js +1 -1
- package/cli/agents/api-fuzzer.js +1 -1
- package/cli/agents/auth-bypass-agent.js +2 -2
- package/cli/agents/base-agent.js +2 -1
- package/cli/agents/config-auditor.js +3 -11
- package/cli/agents/exception-handler-agent.js +187 -0
- package/cli/agents/html-reporter.js +511 -370
- package/cli/agents/index.js +6 -0
- package/cli/agents/mcp-security-agent.js +182 -0
- package/cli/agents/pii-compliance-agent.js +301 -301
- package/cli/agents/scoring-engine.js +14 -6
- package/cli/agents/vibe-coding-agent.js +250 -0
- package/cli/bin/ship-safe.js +43 -6
- package/cli/commands/agent.js +6 -4
- package/cli/commands/audit.js +85 -14
- package/cli/commands/baseline.js +3 -2
- package/cli/commands/benchmark.js +327 -0
- package/cli/commands/ci.js +342 -260
- package/cli/commands/deps.js +73 -4
- package/cli/commands/diff.js +200 -0
- package/cli/commands/doctor.js +14 -4
- package/cli/commands/fix.js +218 -216
- package/cli/commands/init.js +349 -349
- package/cli/commands/mcp.js +304 -303
- package/cli/commands/red-team.js +2 -2
- package/cli/commands/remediate.js +155 -7
- package/cli/commands/scan.js +567 -565
- package/cli/commands/score.js +2 -0
- package/cli/commands/vibe-check.js +276 -0
- package/cli/commands/watch.js +161 -160
- package/cli/index.js +8 -1
- package/cli/utils/cache-manager.js +1 -1
- package/cli/utils/output.js +5 -2
- package/cli/utils/patterns.js +1121 -1104
- package/cli/utils/pdf-generator.js +1 -1
- package/package.json +2 -2
package/cli/commands/deps.js
CHANGED
|
@@ -87,15 +87,34 @@ export async function depsCommand(targetPath = '.', options = {}) {
|
|
|
87
87
|
process.exit(0);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
// ── 4.
|
|
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
|
-
// ──
|
|
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
|
+
}
|
package/cli/commands/doctor.js
CHANGED
|
@@ -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) {
|