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
|
@@ -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 = `[](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
|
+
}
|