ship-safe 5.0.1 → 6.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 +110 -23
- package/cli/agents/abom-generator.js +225 -0
- package/cli/agents/agent-config-scanner.js +547 -0
- 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/config-auditor.js +3 -11
- package/cli/agents/exception-handler-agent.js +187 -0
- package/cli/agents/html-reporter.js +532 -370
- package/cli/agents/index.js +11 -1
- package/cli/agents/mcp-security-agent.js +182 -0
- package/cli/agents/pii-compliance-agent.js +4 -4
- package/cli/agents/scoring-engine.js +25 -6
- package/cli/agents/vibe-coding-agent.js +250 -0
- package/cli/bin/ship-safe.js +96 -6
- package/cli/commands/abom.js +73 -0
- package/cli/commands/agent.js +4 -4
- package/cli/commands/audit.js +15 -7
- package/cli/commands/baseline.js +1 -1
- package/cli/commands/benchmark.js +327 -0
- package/cli/commands/ci.js +81 -1
- 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 +1 -1
- package/cli/commands/guard.js +99 -0
- package/cli/commands/init.js +407 -349
- package/cli/commands/openclaw.js +378 -0
- package/cli/commands/red-team.js +2 -2
- package/cli/commands/remediate.js +153 -7
- package/cli/commands/scan-skill.js +329 -0
- package/cli/commands/update-intel.js +55 -0
- package/cli/commands/vibe-check.js +276 -0
- package/cli/commands/watch.js +124 -4
- package/cli/data/threat-intel.json +85 -0
- package/cli/index.js +9 -0
- package/cli/utils/cache-manager.js +1 -1
- package/cli/utils/compliance-map.js +125 -0
- package/cli/utils/output.js +5 -2
- package/cli/utils/patterns.js +3 -0
- package/cli/utils/pdf-generator.js +1 -1
- package/cli/utils/threat-intel.js +167 -0
- package/package.json +2 -2
package/cli/commands/ci.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import fs from 'fs';
|
|
21
21
|
import path from 'path';
|
|
22
|
+
import { execFileSync } from 'child_process';
|
|
22
23
|
import { buildOrchestrator } from '../agents/index.js';
|
|
23
24
|
import { ScoringEngine } from '../agents/scoring-engine.js';
|
|
24
25
|
import { PolicyEngine } from '../agents/policy-engine.js';
|
|
@@ -85,7 +86,7 @@ export async function ciCommand(targetPath = '.', options = {}) {
|
|
|
85
86
|
|
|
86
87
|
// ── Agent Scan ───────────────────────────────────────────────────────────
|
|
87
88
|
const orchestrator = buildOrchestrator();
|
|
88
|
-
const results = await orchestrator.runAll(absolutePath, { quiet: true });
|
|
89
|
+
const results = await orchestrator.runAll(absolutePath, { quiet: true }); // ship-safe-ignore — orchestrator result, not LLM output triggering actions
|
|
89
90
|
const agentFindings = results.findings;
|
|
90
91
|
|
|
91
92
|
// ── Dependency Audit ─────────────────────────────────────────────────────
|
|
@@ -164,6 +165,15 @@ export async function ciCommand(targetPath = '.', options = {}) {
|
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
// ── GitHub PR Comment ──────────────────────────────────────────────────
|
|
169
|
+
if (options.githubPr) {
|
|
170
|
+
try {
|
|
171
|
+
postPRComment(scoreResult, allFindings, depVulns, absolutePath, duration);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.log(`[ship-safe] Warning: Could not post PR comment: ${err.message}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
167
177
|
// ── Exit Code ────────────────────────────────────────────────────────────
|
|
168
178
|
const pass = determinePass(scoreResult, allFindings, threshold, failOn);
|
|
169
179
|
if (!pass) {
|
|
@@ -242,6 +252,76 @@ function buildSARIF(findings, rootPath) {
|
|
|
242
252
|
};
|
|
243
253
|
}
|
|
244
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Post a summary comment on the current GitHub PR using the `gh` CLI.
|
|
257
|
+
* Requires: `gh` installed and authenticated, running in a PR context.
|
|
258
|
+
*/
|
|
259
|
+
function postPRComment(scoreResult, findings, depVulns, rootPath, duration) {
|
|
260
|
+
// Detect PR number from environment (GitHub Actions sets GITHUB_REF)
|
|
261
|
+
let prNumber = process.env.GITHUB_PR_NUMBER || '';
|
|
262
|
+
|
|
263
|
+
if (!prNumber) {
|
|
264
|
+
// Try to detect from GITHUB_REF (refs/pull/123/merge)
|
|
265
|
+
const ref = process.env.GITHUB_REF || '';
|
|
266
|
+
const match = ref.match(/refs\/pull\/(\d+)\//);
|
|
267
|
+
if (match) prNumber = match[1];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!prNumber) {
|
|
271
|
+
// Try gh pr view to get current PR
|
|
272
|
+
try {
|
|
273
|
+
const prJson = execFileSync('gh', ['pr', 'view', '--json', 'number'], { // ship-safe-ignore — execFileSync, not MCP
|
|
274
|
+
cwd: rootPath, stdio: ['pipe', 'pipe', 'pipe'], // ship-safe-ignore
|
|
275
|
+
}).toString();
|
|
276
|
+
const parsed = JSON.parse(prJson);
|
|
277
|
+
prNumber = String(parsed.number);
|
|
278
|
+
} catch {
|
|
279
|
+
console.log('[ship-safe] No PR detected — skipping PR comment');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const critical = findings.filter(f => f.severity === 'critical').length;
|
|
285
|
+
const high = findings.filter(f => f.severity === 'high').length;
|
|
286
|
+
const medium = findings.filter(f => f.severity === 'medium').length;
|
|
287
|
+
const low = findings.filter(f => f.severity === 'low').length;
|
|
288
|
+
|
|
289
|
+
const gradeEmoji = { A: '🟢', B: '🔵', C: '🟡', D: '🟠', F: '🔴' };
|
|
290
|
+
const emoji = gradeEmoji[scoreResult.grade.letter] || '⚪';
|
|
291
|
+
|
|
292
|
+
// Build markdown body
|
|
293
|
+
let body = `## ${emoji} Ship Safe Security Report\n\n`;
|
|
294
|
+
body += `| Metric | Value |\n|--------|-------|\n`;
|
|
295
|
+
body += `| **Score** | ${scoreResult.score}/100 (${scoreResult.grade.letter}) |\n`;
|
|
296
|
+
body += `| **Findings** | ${findings.length} total (${critical}C ${high}H ${medium}M ${low}L) |\n`;
|
|
297
|
+
body += `| **Dep CVEs** | ${depVulns.length} |\n`;
|
|
298
|
+
body += `| **Duration** | ${duration}s |\n\n`;
|
|
299
|
+
|
|
300
|
+
if (critical > 0 || high > 0) {
|
|
301
|
+
body += `### Critical & High Findings\n\n`;
|
|
302
|
+
body += `| Severity | File | Issue |\n|----------|------|-------|\n`;
|
|
303
|
+
for (const f of findings.filter(f => f.severity === 'critical' || f.severity === 'high').slice(0, 20)) {
|
|
304
|
+
const rel = path.relative(rootPath, f.file).replace(/\\/g, '/');
|
|
305
|
+
body += `| ${f.severity.toUpperCase()} | \`${rel}:${f.line}\` | ${(f.title || f.rule).slice(0, 60)} |\n`;
|
|
306
|
+
}
|
|
307
|
+
body += '\n';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (findings.length === 0 && depVulns.length === 0) {
|
|
311
|
+
body += '> No security issues found — looking good! 🎉\n\n';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
body += `<sub>Generated by <a href="https://shipsafecli.com">Ship Safe</a></sub>`;
|
|
315
|
+
|
|
316
|
+
// Post comment via gh CLI
|
|
317
|
+
execFileSync('gh', ['pr', 'comment', prNumber, '--body', body], { // ship-safe-ignore — execFileSync, not MCP
|
|
318
|
+
cwd: rootPath,
|
|
319
|
+
stdio: ['pipe', 'pipe', 'pipe'], // ship-safe-ignore
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
console.log(`[ship-safe] PR comment posted on #${prNumber}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
245
325
|
async function findFiles(rootPath) {
|
|
246
326
|
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
247
327
|
const gitignoreGlobs = loadGitignorePatterns(rootPath);
|
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) {
|
package/cli/commands/fix.js
CHANGED
|
@@ -148,7 +148,7 @@ function buildEnvVarSuggestions(results) {
|
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
150
|
* Convert a pattern name to a sensible env var name.
|
|
151
|
-
* e.g. "OpenAI API Key" → "OPENAI_API_KEY"
|
|
151
|
+
* e.g. "OpenAI API Key" → "OPENAI_API_KEY" // ship-safe-ignore — env var name in doc comment, not a secret value
|
|
152
152
|
*/
|
|
153
153
|
function patternToEnvVar(patternName) {
|
|
154
154
|
return patternName
|
package/cli/commands/guard.js
CHANGED
|
@@ -136,6 +136,10 @@ export async function guardCommand(action, options = {}) {
|
|
|
136
136
|
process.exit(1);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
if (options.generateHooks) {
|
|
140
|
+
return generateClaudeHooks(cwd);
|
|
141
|
+
}
|
|
142
|
+
|
|
139
143
|
if (action === 'remove') {
|
|
140
144
|
return removeHooks(gitDir, cwd);
|
|
141
145
|
}
|
|
@@ -278,6 +282,101 @@ function removeHooks(gitDir, cwd) {
|
|
|
278
282
|
}
|
|
279
283
|
}
|
|
280
284
|
|
|
285
|
+
// =============================================================================
|
|
286
|
+
// CLAUDE CODE DEFENSIVE HOOKS
|
|
287
|
+
// =============================================================================
|
|
288
|
+
|
|
289
|
+
function generateClaudeHooks(cwd) {
|
|
290
|
+
output.header('Generating Defensive Claude Code Hooks');
|
|
291
|
+
|
|
292
|
+
const claudeDir = path.join(cwd, '.claude');
|
|
293
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
294
|
+
|
|
295
|
+
// Defensive hooks that block common attack patterns
|
|
296
|
+
const preToolCmd = [
|
|
297
|
+
'node -e "',
|
|
298
|
+
'const c=process.argv[1]||String();',
|
|
299
|
+
'const bad=[/curl.*[|].*(?:bash|sh|node|python)/i,/wget.*[|].*(?:bash|sh)/i,',
|
|
300
|
+
'/rm\\s+-rf\\s+\\//,/webhook[.]site|requestbin|ngrok[.]io|pipedream/i,',
|
|
301
|
+
'/base64.*-d.*[|].*(?:bash|sh)/i];',
|
|
302
|
+
'const m=bad.find(r=>r.test(c));',
|
|
303
|
+
'if(m){console.error(String.fromCharCode(10060)+String.fromCharCode(32)+c.slice(0,80));process.exit(1)}',
|
|
304
|
+
'" "$INPUT"',
|
|
305
|
+
].join('');
|
|
306
|
+
|
|
307
|
+
const postToolCmd = [
|
|
308
|
+
'node -e "',
|
|
309
|
+
'const f=process.argv[1]||String();',
|
|
310
|
+
'if(/[.]env$|[.]env[.]/.test(f)){console.log(String.fromCharCode(9888)+String.fromCharCode(32)+f)}',
|
|
311
|
+
'" "$INPUT"',
|
|
312
|
+
].join('');
|
|
313
|
+
|
|
314
|
+
const defensiveHooks = {
|
|
315
|
+
hooks: {
|
|
316
|
+
PreToolUse: [
|
|
317
|
+
{
|
|
318
|
+
matcher: 'Bash',
|
|
319
|
+
command: preToolCmd,
|
|
320
|
+
description: 'Ship Safe: Block dangerous command patterns (curl|bash, rm -rf /, exfil domains)',
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
PostToolUse: [
|
|
324
|
+
{
|
|
325
|
+
matcher: 'Write',
|
|
326
|
+
command: postToolCmd,
|
|
327
|
+
description: 'Ship Safe: Alert when .env files are modified',
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Merge with existing settings
|
|
334
|
+
let existing = {};
|
|
335
|
+
if (fs.existsSync(settingsPath)) {
|
|
336
|
+
try {
|
|
337
|
+
existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
338
|
+
} catch { /* fresh start */ }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Merge hooks — don't overwrite existing hooks, append
|
|
342
|
+
if (!existing.hooks) existing.hooks = {};
|
|
343
|
+
if (!existing.hooks.PreToolUse) existing.hooks.PreToolUse = [];
|
|
344
|
+
if (!existing.hooks.PostToolUse) existing.hooks.PostToolUse = [];
|
|
345
|
+
|
|
346
|
+
// Check if ship-safe hooks already present
|
|
347
|
+
const hasPreHook = existing.hooks.PreToolUse.some(h => h.description?.includes('Ship Safe'));
|
|
348
|
+
const hasPostHook = existing.hooks.PostToolUse.some(h => h.description?.includes('Ship Safe'));
|
|
349
|
+
|
|
350
|
+
if (hasPreHook && hasPostHook) {
|
|
351
|
+
output.warning('Ship Safe hooks already installed in .claude/settings.json');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!hasPreHook) {
|
|
356
|
+
existing.hooks.PreToolUse.push(...defensiveHooks.hooks.PreToolUse);
|
|
357
|
+
}
|
|
358
|
+
if (!hasPostHook) {
|
|
359
|
+
existing.hooks.PostToolUse.push(...defensiveHooks.hooks.PostToolUse);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Write
|
|
363
|
+
if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
|
|
364
|
+
fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
|
365
|
+
|
|
366
|
+
output.success('Defensive hooks installed in .claude/settings.json');
|
|
367
|
+
console.log();
|
|
368
|
+
console.log(chalk.gray(' Hooks installed:'));
|
|
369
|
+
console.log(chalk.gray(' PreToolUse → Block curl|bash, rm -rf /, exfil domains'));
|
|
370
|
+
console.log(chalk.gray(' PostToolUse → Alert on .env file modifications'));
|
|
371
|
+
console.log();
|
|
372
|
+
console.log(chalk.gray(' These hooks protect against:'));
|
|
373
|
+
console.log(chalk.gray(' • Remote code execution via piped downloads'));
|
|
374
|
+
console.log(chalk.gray(' • Data exfiltration to webhook.site/ngrok/requestbin'));
|
|
375
|
+
console.log(chalk.gray(' • Destructive filesystem operations'));
|
|
376
|
+
console.log(chalk.gray(' • Unauthorized .env modifications'));
|
|
377
|
+
console.log();
|
|
378
|
+
}
|
|
379
|
+
|
|
281
380
|
// =============================================================================
|
|
282
381
|
// UTILITIES
|
|
283
382
|
// =============================================================================
|