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.
Files changed (43) hide show
  1. package/README.md +110 -23
  2. package/cli/agents/abom-generator.js +225 -0
  3. package/cli/agents/agent-config-scanner.js +547 -0
  4. package/cli/agents/agentic-security-agent.js +1 -1
  5. package/cli/agents/api-fuzzer.js +1 -1
  6. package/cli/agents/auth-bypass-agent.js +2 -2
  7. package/cli/agents/config-auditor.js +3 -11
  8. package/cli/agents/exception-handler-agent.js +187 -0
  9. package/cli/agents/html-reporter.js +532 -370
  10. package/cli/agents/index.js +11 -1
  11. package/cli/agents/mcp-security-agent.js +182 -0
  12. package/cli/agents/pii-compliance-agent.js +4 -4
  13. package/cli/agents/scoring-engine.js +25 -6
  14. package/cli/agents/vibe-coding-agent.js +250 -0
  15. package/cli/bin/ship-safe.js +96 -6
  16. package/cli/commands/abom.js +73 -0
  17. package/cli/commands/agent.js +4 -4
  18. package/cli/commands/audit.js +15 -7
  19. package/cli/commands/baseline.js +1 -1
  20. package/cli/commands/benchmark.js +327 -0
  21. package/cli/commands/ci.js +81 -1
  22. package/cli/commands/deps.js +73 -4
  23. package/cli/commands/diff.js +200 -0
  24. package/cli/commands/doctor.js +14 -4
  25. package/cli/commands/fix.js +1 -1
  26. package/cli/commands/guard.js +99 -0
  27. package/cli/commands/init.js +407 -349
  28. package/cli/commands/openclaw.js +378 -0
  29. package/cli/commands/red-team.js +2 -2
  30. package/cli/commands/remediate.js +153 -7
  31. package/cli/commands/scan-skill.js +329 -0
  32. package/cli/commands/update-intel.js +55 -0
  33. package/cli/commands/vibe-check.js +276 -0
  34. package/cli/commands/watch.js +124 -4
  35. package/cli/data/threat-intel.json +85 -0
  36. package/cli/index.js +9 -0
  37. package/cli/utils/cache-manager.js +1 -1
  38. package/cli/utils/compliance-map.js +125 -0
  39. package/cli/utils/output.js +5 -2
  40. package/cli/utils/patterns.js +3 -0
  41. package/cli/utils/pdf-generator.js +1 -1
  42. package/cli/utils/threat-intel.js +167 -0
  43. package/package.json +2 -2
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Scan Skill Command
3
+ * ===================
4
+ *
5
+ * Downloads and analyzes an AI agent skill before installation.
6
+ * Checks for malicious patterns, permission abuse, typosquatting,
7
+ * and known threat intelligence indicators.
8
+ *
9
+ * USAGE:
10
+ * ship-safe scan-skill <url> Analyze a skill from URL
11
+ * ship-safe scan-skill <path> Analyze a local skill file
12
+ * ship-safe scan-skill . --all Scan all skills in openclaw.json
13
+ */
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import chalk from 'chalk';
18
+ import { createHash } from 'crypto';
19
+ import * as output from '../utils/output.js';
20
+ import { ThreatIntel } from '../utils/threat-intel.js';
21
+
22
+ // =============================================================================
23
+ // POPULAR SKILL NAMES (for typosquatting detection)
24
+ // =============================================================================
25
+
26
+ const POPULAR_SKILLS = [
27
+ 'web-search', 'web-browser', 'file-manager', 'code-runner',
28
+ 'git-helper', 'database-query', 'api-tester', 'image-gen',
29
+ 'text-to-speech', 'pdf-reader', 'email-sender', 'slack-bot',
30
+ 'github-helper', 'docker-manager', 'kubernetes-helper',
31
+ 'aws-helper', 'terraform-helper', 'memory-store',
32
+ 'calculator', 'translator', 'summarizer', 'code-review',
33
+ ];
34
+
35
+ // =============================================================================
36
+ // MALICIOUS PATTERNS
37
+ // =============================================================================
38
+
39
+ const SKILL_PATTERNS = [
40
+ { name: 'Shell execution', regex: /(?:child_process|exec|spawn|execSync|execFile|os\.system|subprocess|shell_exec|system\()/gi, severity: 'critical' },
41
+ { name: 'Outbound HTTP to non-localhost', regex: /(?:fetch|axios|http\.get|requests\.get|urllib|wget|curl)\s*\(\s*['"`]https?:\/\/(?!(?:localhost|127\.0\.0\.1|::1))/gi, severity: 'high' },
42
+ { name: 'Data exfiltration service', regex: /(?:webhook\.site|requestbin\.com|hookbin\.com|pipedream\.net|ngrok\.io|ngrok\.app|burpcollaborator|interact\.sh)/gi, severity: 'critical' },
43
+ { name: 'Environment variable access', regex: /(?:process\.env|os\.environ|os\.getenv|ENV\[|System\.getenv)/gi, severity: 'medium' },
44
+ { name: 'File system write', regex: /(?:fs\.writeFile|fs\.appendFile|writeFileSync|open\(.+['"]w['"]|fwrite|file_put_contents)/gi, severity: 'medium' },
45
+ { name: 'Base64 decode + execute', regex: /(?:atob|Buffer\.from|base64\.b64decode|base64_decode)\s*\([^)]*\)\s*(?:\.|\))\s*(?:eval|exec|Function)/gi, severity: 'critical' },
46
+ { name: 'Dynamic code evaluation', regex: /(?:eval\s*\(|new\s+Function\s*\(|exec\s*\(|compile\s*\()/gi, severity: 'high' },
47
+ { name: 'Crypto operations', regex: /(?:crypto\.createCipher|crypto\.createDecipher|CryptoJS|forge\.cipher)/gi, severity: 'medium' },
48
+ { name: 'Network listener', regex: /(?:createServer|listen\s*\(\s*\d|bind\s*\(\s*['"]0\.0\.0\.0)/gi, severity: 'high' },
49
+ { name: 'Encoded payload block', regex: /[A-Za-z0-9+\/]{60,}={0,2}/g, severity: 'medium' },
50
+ ];
51
+
52
+ // =============================================================================
53
+ // MAIN COMMAND
54
+ // =============================================================================
55
+
56
+ export async function scanSkillCommand(target, options = {}) {
57
+ if (!target) {
58
+ output.error('Usage: ship-safe scan-skill <url|path>');
59
+ output.info(' Analyze an AI agent skill for security issues before installing it.');
60
+ process.exit(1);
61
+ }
62
+
63
+ console.log();
64
+ output.header('Ship Safe — Skill Security Analysis');
65
+ console.log();
66
+
67
+ // If --all flag, scan all skills from openclaw.json
68
+ if (options.all) {
69
+ return scanAllSkills(path.resolve(target));
70
+ }
71
+
72
+ // Determine if URL or local file
73
+ let content, skillName, source;
74
+
75
+ if (target.startsWith('http://') || target.startsWith('https://')) {
76
+ console.log(chalk.gray(` Fetching skill from: ${target}`));
77
+ try {
78
+ const response = await fetch(target);
79
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
80
+ content = await response.text();
81
+ skillName = new URL(target).pathname.split('/').pop() || 'remote-skill';
82
+ source = target;
83
+ } catch (err) {
84
+ output.error(`Failed to fetch skill: ${err.message}`);
85
+ process.exit(1);
86
+ }
87
+ } else {
88
+ const filePath = path.resolve(target);
89
+ if (!fs.existsSync(filePath)) {
90
+ output.error(`File not found: ${filePath}`);
91
+ process.exit(1);
92
+ }
93
+ content = fs.readFileSync(filePath, 'utf-8');
94
+ skillName = path.basename(filePath);
95
+ source = filePath;
96
+ }
97
+
98
+ console.log(chalk.gray(` Skill: ${skillName}`));
99
+ console.log(chalk.gray(` Size: ${content.length} bytes`));
100
+ console.log();
101
+
102
+ const findings = analyzeSkill(content, skillName, source);
103
+
104
+ if (options.json) {
105
+ console.log(JSON.stringify({ skill: skillName, source, findings, summary: getSummary(findings) }, null, 2));
106
+ return;
107
+ }
108
+
109
+ printSkillFindings(findings, skillName);
110
+ }
111
+
112
+ // =============================================================================
113
+ // SKILL ANALYSIS
114
+ // =============================================================================
115
+
116
+ function analyzeSkill(content, skillName, source) {
117
+ const findings = [];
118
+
119
+ // 1. Static pattern analysis
120
+ const lines = content.split('\n');
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const line = lines[i];
123
+ for (const pattern of SKILL_PATTERNS) {
124
+ pattern.regex.lastIndex = 0;
125
+ if (pattern.regex.test(line)) {
126
+ findings.push({
127
+ check: 'static-analysis',
128
+ name: pattern.name,
129
+ severity: pattern.severity,
130
+ line: i + 1,
131
+ matched: line.trim().slice(0, 100),
132
+ });
133
+ }
134
+ }
135
+ }
136
+
137
+ // 2. Permission manifest audit (if JSON)
138
+ try {
139
+ const manifest = JSON.parse(content);
140
+ if (manifest.permissions) {
141
+ const dangerous = ['shell', 'exec', 'system', 'network', 'filesystem', 'admin', 'root'];
142
+ for (const perm of (Array.isArray(manifest.permissions) ? manifest.permissions : [])) {
143
+ const permStr = typeof perm === 'string' ? perm : perm.name || '';
144
+ if (dangerous.some(d => permStr.toLowerCase().includes(d))) {
145
+ findings.push({
146
+ check: 'permission-audit',
147
+ name: `Dangerous permission: ${permStr}`,
148
+ severity: 'high',
149
+ line: 0,
150
+ matched: `permissions: [${permStr}]`,
151
+ });
152
+ }
153
+ }
154
+ }
155
+
156
+ // Check for suspicious fields
157
+ if (manifest.postInstall || manifest.postinstall) {
158
+ findings.push({
159
+ check: 'permission-audit',
160
+ name: 'Post-install script defined',
161
+ severity: 'high',
162
+ line: 0,
163
+ matched: 'postInstall hook detected',
164
+ });
165
+ }
166
+ } catch { /* Not JSON, skip manifest audit */ }
167
+
168
+ // 3. Typosquatting detection
169
+ const typosquatResult = checkTyposquatting(skillName);
170
+ if (typosquatResult) {
171
+ findings.push({
172
+ check: 'typosquatting',
173
+ name: `Possible typosquat of "${typosquatResult.target}"`,
174
+ severity: 'high',
175
+ line: 0,
176
+ matched: `Levenshtein distance: ${typosquatResult.distance} from "${typosquatResult.target}"`,
177
+ });
178
+ }
179
+
180
+ // 4. Threat intel hash check
181
+ const hash = createHash('sha256').update(content).digest('hex');
182
+ const intelMatch = ThreatIntel.lookupHash(hash);
183
+ if (intelMatch) {
184
+ findings.push({
185
+ check: 'threat-intel',
186
+ name: `Known malicious skill: ${intelMatch.name}`,
187
+ severity: 'critical',
188
+ line: 0,
189
+ matched: `SHA-256: ${hash} — ${intelMatch.description}`,
190
+ });
191
+ }
192
+
193
+ // 5. Threat intel signature check
194
+ const sigMatches = ThreatIntel.matchSignatures(content);
195
+ for (const sig of sigMatches) {
196
+ findings.push({
197
+ check: 'threat-intel',
198
+ name: `Threat intel signature match: ${sig.description}`,
199
+ severity: sig.severity || 'critical',
200
+ line: 0,
201
+ matched: `Pattern: ${sig.pattern}`,
202
+ });
203
+ }
204
+
205
+ return findings;
206
+ }
207
+
208
+ // =============================================================================
209
+ // TYPOSQUATTING
210
+ // =============================================================================
211
+
212
+ function levenshtein(a, b) {
213
+ const matrix = [];
214
+ for (let i = 0; i <= b.length; i++) matrix[i] = [i];
215
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
216
+ for (let i = 1; i <= b.length; i++) {
217
+ for (let j = 1; j <= a.length; j++) {
218
+ matrix[i][j] = b[i - 1] === a[j - 1]
219
+ ? matrix[i - 1][j - 1]
220
+ : Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
221
+ }
222
+ }
223
+ return matrix[b.length][a.length];
224
+ }
225
+
226
+ function checkTyposquatting(skillName) {
227
+ const name = skillName.toLowerCase().replace(/[^a-z0-9-]/g, '');
228
+ for (const popular of POPULAR_SKILLS) {
229
+ const distance = levenshtein(name, popular);
230
+ if (distance > 0 && distance <= 2 && name !== popular) {
231
+ return { target: popular, distance };
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+
237
+ // =============================================================================
238
+ // SCAN ALL SKILLS IN PROJECT
239
+ // =============================================================================
240
+
241
+ async function scanAllSkills(rootPath) {
242
+ const openclawPath = path.join(rootPath, 'openclaw.json');
243
+ if (!fs.existsSync(openclawPath)) {
244
+ output.warning('No openclaw.json found. Nothing to scan.');
245
+ return;
246
+ }
247
+
248
+ try {
249
+ const config = JSON.parse(fs.readFileSync(openclawPath, 'utf-8'));
250
+ const skills = config.skills || [];
251
+
252
+ if (skills.length === 0) {
253
+ output.info('No skills defined in openclaw.json.');
254
+ return;
255
+ }
256
+
257
+ console.log(chalk.gray(` Found ${skills.length} skill(s) in openclaw.json`));
258
+ console.log();
259
+
260
+ for (const skill of skills) {
261
+ const url = typeof skill === 'string' ? skill : skill.source || skill.url;
262
+ const name = typeof skill === 'string' ? skill : skill.name || 'unnamed';
263
+
264
+ if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
265
+ console.log(chalk.cyan(` Scanning skill: ${name}`));
266
+ try {
267
+ const response = await fetch(url);
268
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
269
+ const content = await response.text();
270
+ const findings = analyzeSkill(content, name, url);
271
+ if (findings.length > 0) {
272
+ printSkillFindings(findings, name);
273
+ } else {
274
+ console.log(chalk.green(` ✔ Clean`));
275
+ }
276
+ } catch (err) {
277
+ console.log(chalk.yellow(` ⚠ Could not fetch: ${err.message}`));
278
+ }
279
+ } else {
280
+ console.log(chalk.gray(` → ${name}: local skill (static analysis only)`));
281
+ }
282
+ console.log();
283
+ }
284
+ } catch (err) {
285
+ output.error(`Failed to parse openclaw.json: ${err.message}`);
286
+ }
287
+ }
288
+
289
+ // =============================================================================
290
+ // OUTPUT
291
+ // =============================================================================
292
+
293
+ function printSkillFindings(findings, skillName) {
294
+ const summary = getSummary(findings);
295
+
296
+ if (findings.length === 0) {
297
+ console.log(chalk.green.bold(` ✔ ${skillName}: No security issues found.`));
298
+ console.log();
299
+ return;
300
+ }
301
+
302
+ console.log(chalk.red.bold(` ✘ ${skillName}: ${findings.length} issue(s) found`));
303
+ console.log();
304
+
305
+ for (const f of findings) {
306
+ const sevColor = f.severity === 'critical' ? chalk.red.bold
307
+ : f.severity === 'high' ? chalk.yellow
308
+ : chalk.blue;
309
+
310
+ console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`)} ${chalk.white(f.name)}`);
311
+ if (f.line > 0) console.log(chalk.gray(` Line ${f.line}: ${f.matched}`));
312
+ else if (f.matched) console.log(chalk.gray(` ${f.matched}`));
313
+ }
314
+ console.log();
315
+
316
+ if (summary.critical > 0) {
317
+ console.log(chalk.red.bold(' ⚠ DO NOT INSTALL this skill — critical security issues detected.'));
318
+ console.log();
319
+ }
320
+ }
321
+
322
+ function getSummary(findings) {
323
+ return {
324
+ total: findings.length,
325
+ critical: findings.filter(f => f.severity === 'critical').length,
326
+ high: findings.filter(f => f.severity === 'high').length,
327
+ medium: findings.filter(f => f.severity === 'medium').length,
328
+ };
329
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Update Intel Command
3
+ * =====================
4
+ *
5
+ * Updates the local threat intelligence feed from the remote source.
6
+ *
7
+ * USAGE:
8
+ * ship-safe update-intel Fetch latest threat intel
9
+ * ship-safe update-intel --url <url> Use custom feed URL
10
+ */
11
+
12
+ import chalk from 'chalk';
13
+ import * as output from '../utils/output.js';
14
+ import { ThreatIntel } from '../utils/threat-intel.js';
15
+
16
+ export async function updateIntelCommand(options = {}) {
17
+ console.log();
18
+ output.header('Ship Safe — Threat Intelligence Update');
19
+ console.log();
20
+
21
+ const currentStats = ThreatIntel.stats();
22
+ console.log(chalk.gray(` Current version: ${currentStats.version}`));
23
+ console.log(chalk.gray(` Last updated: ${currentStats.updated || 'unknown'}`));
24
+ console.log(chalk.gray(` Indicators: ${currentStats.hashes} hashes, ${currentStats.servers} servers, ${currentStats.signatures} signatures`));
25
+ console.log();
26
+
27
+ console.log(chalk.cyan(' Checking for updates...'));
28
+
29
+ const result = await ThreatIntel.update(options.url);
30
+
31
+ if (result.error) {
32
+ output.error('Update failed: ' + result.error); // ship-safe-ignore
33
+ console.log(chalk.gray(' The local seed data will still be used for scanning.'));
34
+ console.log(chalk.gray(' Check your network connection and try again.'));
35
+ console.log();
36
+ return;
37
+ }
38
+
39
+ if (!result.updated) {
40
+ console.log(chalk.green(' ✔ Already up to date.'));
41
+ console.log();
42
+ return;
43
+ }
44
+
45
+ console.log();
46
+ console.log(chalk.green.bold(' ✔ Threat intelligence updated!'));
47
+ console.log();
48
+ console.log(` ${chalk.gray('Version:')} ${result.oldVersion} → ${chalk.cyan(result.newVersion)}`);
49
+ if (result.stats) {
50
+ console.log(` ${chalk.gray('Malicious skill hashes:')} ${result.stats.hashes}`);
51
+ console.log(` ${chalk.gray('Compromised MCP servers:')} ${result.stats.servers}`);
52
+ console.log(` ${chalk.gray('Config signatures:')} ${result.stats.signatures}`);
53
+ }
54
+ console.log();
55
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Vibe Check Command
3
+ * ==================
4
+ *
5
+ * Fun, emoji-rich security check with shareable results.
6
+ * Same security scan as `audit`, but with personality.
7
+ *
8
+ * USAGE:
9
+ * npx ship-safe vibe-check [path] Run a vibe check
10
+ * npx ship-safe vibe-check . --badge Generate a markdown badge
11
+ *
12
+ * OUTPUT:
13
+ * Big ASCII art grade, emoji severity indicators,
14
+ * "vibes" rating, and a shareable one-liner.
15
+ */
16
+
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import chalk from 'chalk';
20
+ import ora from 'ora';
21
+ import { buildOrchestrator } from '../agents/index.js';
22
+ import { ScoringEngine } from '../agents/scoring-engine.js';
23
+ import { runDepsAudit } from './deps.js';
24
+ import {
25
+ SECRET_PATTERNS,
26
+ SKIP_DIRS,
27
+ SKIP_EXTENSIONS,
28
+ SKIP_FILENAMES,
29
+ MAX_FILE_SIZE,
30
+ loadGitignorePatterns
31
+ } from '../utils/patterns.js';
32
+ import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
33
+ import fg from 'fast-glob';
34
+
35
+ // =============================================================================
36
+ // VIBES DATA
37
+ // =============================================================================
38
+
39
+ const VIBE_GRADES = {
40
+ A: {
41
+ emoji: '🛡️',
42
+ vibe: 'immaculate',
43
+ ascii: `
44
+ ╔═══╗
45
+ ║ A ║
46
+ ╚═══╝`,
47
+ message: 'Your security vibes are IMMACULATE. Ship it! 🚀',
48
+ color: chalk.green.bold,
49
+ },
50
+ B: {
51
+ emoji: '✅',
52
+ vibe: 'solid',
53
+ ascii: `
54
+ ╔═══╗
55
+ ║ B ║
56
+ ╚═══╝`,
57
+ message: 'Solid vibes. A few things to tighten up, but you\'re in good shape. 💪',
58
+ color: chalk.cyan.bold,
59
+ },
60
+ C: {
61
+ emoji: '⚠️',
62
+ vibe: 'mid',
63
+ ascii: `
64
+ ╔═══╗
65
+ ║ C ║
66
+ ╚═══╝`,
67
+ message: 'Mid vibes. Some security gaps need attention before you ship. 🔧',
68
+ color: chalk.yellow.bold,
69
+ },
70
+ D: {
71
+ emoji: '🚨',
72
+ vibe: 'sketchy',
73
+ ascii: `
74
+ ╔═══╗
75
+ ║ D ║
76
+ ╚═══╝`,
77
+ message: 'Sketchy vibes. Serious issues found — fix these before deploying. 🛑',
78
+ color: chalk.red.bold,
79
+ },
80
+ F: {
81
+ emoji: '💀',
82
+ vibe: 'cooked',
83
+ ascii: `
84
+ ╔═══╗
85
+ ║ F ║
86
+ ╚═══╝`,
87
+ message: 'You are cooked. Critical vulnerabilities everywhere. DO NOT SHIP. 🔥',
88
+ color: chalk.red.bold,
89
+ },
90
+ };
91
+
92
+ const SEV_EMOJI = {
93
+ critical: '💀',
94
+ high: '🔴',
95
+ medium: '🟡',
96
+ low: '🔵',
97
+ };
98
+
99
+ // =============================================================================
100
+ // MAIN COMMAND
101
+ // =============================================================================
102
+
103
+ export async function vibeCheckCommand(targetPath = '.', options = {}) {
104
+ const absolutePath = path.resolve(targetPath);
105
+
106
+ if (!fs.existsSync(absolutePath)) {
107
+ console.error(chalk.red(`Path does not exist: ${absolutePath}`));
108
+ process.exit(1);
109
+ }
110
+
111
+ const projectName = path.basename(absolutePath);
112
+
113
+ console.log();
114
+ console.log(chalk.cyan.bold(' 🎵 VIBE CHECK 🎵'));
115
+ console.log(chalk.gray(` Scanning ${projectName}...`));
116
+ console.log();
117
+
118
+ const startTime = Date.now();
119
+
120
+ // ── Secret Scan ──────────────────────────────────────────────────────────
121
+ const spinner = ora({ text: 'Checking the vibes...', color: 'magenta' }).start();
122
+
123
+ const allFiles = await findFiles(absolutePath);
124
+ const secretFindings = [];
125
+
126
+ for (const file of allFiles) {
127
+ try {
128
+ const content = fs.readFileSync(file, 'utf-8');
129
+ const lines = content.split('\n');
130
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
131
+ const line = lines[lineNum];
132
+ if (/ship-safe-ignore/i.test(line)) continue;
133
+ for (const pattern of SECRET_PATTERNS) {
134
+ pattern.pattern.lastIndex = 0;
135
+ let match;
136
+ while ((match = pattern.pattern.exec(line)) !== null) {
137
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
138
+ secretFindings.push({
139
+ file, line: lineNum + 1, column: match.index + 1,
140
+ matched: match[0], severity: pattern.severity,
141
+ category: pattern.category || 'secrets',
142
+ rule: pattern.name, title: pattern.name.replace(/_/g, ' '),
143
+ description: pattern.description,
144
+ confidence: getConfidence(pattern, match[0]),
145
+ });
146
+ }
147
+ }
148
+ }
149
+ } catch { /* skip */ }
150
+ }
151
+
152
+ // ── Agent Scan ──────────────────────────────────────────────────────────
153
+ const orchestrator = buildOrchestrator();
154
+ const results = await orchestrator.runAll(absolutePath, { quiet: true });
155
+
156
+ // ── Dependency Audit ─────────────────────────────────────────────────────
157
+ let depVulns = [];
158
+ try {
159
+ const depResult = await runDepsAudit(absolutePath);
160
+ depVulns = depResult.vulns || [];
161
+ } catch { /* skip */ }
162
+
163
+ spinner.stop();
164
+
165
+ // ── Merge & Score ─────────────────────────────────────────────────────────
166
+ const seen = new Set();
167
+ const allFindings = [...secretFindings, ...results.findings].filter(f => {
168
+ const key = `${f.file}:${f.line}:${f.rule}`;
169
+ if (seen.has(key)) return false;
170
+ seen.add(key);
171
+ return true;
172
+ });
173
+
174
+ const scoringEngine = new ScoringEngine();
175
+ const scoreResult = scoringEngine.compute(allFindings, depVulns);
176
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
177
+
178
+ // ── Display ──────────────────────────────────────────────────────────────
179
+ const grade = VIBE_GRADES[scoreResult.grade.letter] || VIBE_GRADES.F;
180
+ const score = Math.round(scoreResult.score * 10) / 10;
181
+
182
+ const critical = allFindings.filter(f => f.severity === 'critical').length;
183
+ const high = allFindings.filter(f => f.severity === 'high').length;
184
+ const medium = allFindings.filter(f => f.severity === 'medium').length;
185
+ const low = allFindings.filter(f => f.severity === 'low').length;
186
+
187
+ // Big grade display
188
+ console.log(grade.color(grade.ascii));
189
+ console.log();
190
+ console.log(grade.color(` ${grade.emoji} Score: ${score}/100 | Vibes: ${grade.vibe.toUpperCase()}`));
191
+ console.log();
192
+ console.log(grade.color(` ${grade.message}`));
193
+ console.log();
194
+
195
+ // Severity breakdown
196
+ console.log(chalk.white.bold(' Breakdown:'));
197
+ if (critical > 0) console.log(` ${SEV_EMOJI.critical} Critical: ${critical}`);
198
+ if (high > 0) console.log(` ${SEV_EMOJI.high} High: ${high}`);
199
+ if (medium > 0) console.log(` ${SEV_EMOJI.medium} Medium: ${medium}`);
200
+ if (low > 0) console.log(` ${SEV_EMOJI.low} Low: ${low}`);
201
+ if (depVulns.length > 0) console.log(` 📦 Dep CVEs: ${depVulns.length}`);
202
+ if (allFindings.length === 0 && depVulns.length === 0) {
203
+ console.log(` ✨ Zero issues found!`);
204
+ }
205
+ console.log(chalk.gray(` ⏱️ ${duration}s`));
206
+ console.log();
207
+
208
+ // Top 3 issues
209
+ if (allFindings.length > 0) {
210
+ console.log(chalk.white.bold(' Top issues to fix:'));
211
+ const top = allFindings
212
+ .sort((a, b) => {
213
+ const order = { critical: 0, high: 1, medium: 2, low: 3 };
214
+ return (order[a.severity] ?? 4) - (order[b.severity] ?? 4);
215
+ })
216
+ .slice(0, 3);
217
+ for (const f of top) {
218
+ const rel = path.relative(absolutePath, f.file).replace(/\\/g, '/');
219
+ console.log(` ${SEV_EMOJI[f.severity] || '⚪'} ${f.title || f.rule} ${chalk.gray(`(${rel}:${f.line})`)}`);
220
+ }
221
+ console.log();
222
+ }
223
+
224
+ // ── Shareable one-liner ──────────────────────────────────────────────────
225
+ const shareLine = `${grade.emoji} ${projectName}: ${score}/100 (${scoreResult.grade.letter}) — ${grade.vibe} vibes | ${allFindings.length} findings | Scanned with Ship Safe`;
226
+ console.log(chalk.gray(' Share your vibes:'));
227
+ console.log(chalk.cyan(` ${shareLine}`));
228
+ console.log();
229
+
230
+ // ── Badge ─────────────────────────────────────────────────────────────────
231
+ if (options.badge) {
232
+ const badgeColor = {
233
+ A: 'brightgreen', B: 'blue', C: 'yellow', D: 'orange', F: 'red',
234
+ }[scoreResult.grade.letter] || 'lightgrey';
235
+ const badgeUrl = `https://img.shields.io/badge/ship--safe-${score}%2F100_${scoreResult.grade.letter}-${badgeColor}`;
236
+ const badgeMd = `[![Ship Safe Score](${badgeUrl})](https://shipsafecli.com)`;
237
+
238
+ console.log(chalk.white.bold(' Markdown badge:'));
239
+ console.log(chalk.cyan(` ${badgeMd}`));
240
+ console.log();
241
+
242
+ // Write badge to README if it exists and doesn't have one already
243
+ const readmePath = path.join(absolutePath, 'README.md');
244
+ if (fs.existsSync(readmePath)) {
245
+ const readme = fs.readFileSync(readmePath, 'utf-8');
246
+ if (!readme.includes('ship--safe')) {
247
+ console.log(chalk.gray(' Add this badge to your README.md to show off your security score!'));
248
+ }
249
+ }
250
+ }
251
+
252
+ process.exit(allFindings.length > 0 || depVulns.length > 0 ? 1 : 0);
253
+ }
254
+
255
+ // =============================================================================
256
+ // FILE FINDER (reused from CI)
257
+ // =============================================================================
258
+
259
+ async function findFiles(rootPath) {
260
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
261
+ const gitignoreGlobs = loadGitignorePatterns(rootPath);
262
+ globIgnore.push(...gitignoreGlobs);
263
+
264
+ const files = await fg('**/*', {
265
+ cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true,
266
+ });
267
+
268
+ return files.filter(file => {
269
+ const ext = path.extname(file).toLowerCase();
270
+ if (SKIP_EXTENSIONS.has(ext)) return false;
271
+ if (SKIP_FILENAMES.has(path.basename(file))) return false;
272
+ if (path.basename(file).endsWith('.min.js') || path.basename(file).endsWith('.min.css')) return false;
273
+ try { if (fs.statSync(file).size > MAX_FILE_SIZE) return false; } catch { return false; }
274
+ return true;
275
+ });
276
+ }