ship-safe 6.0.0 → 6.1.1
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 +157 -23
- package/cli/agents/abom-generator.js +225 -0
- package/cli/agents/agent-config-scanner.js +547 -0
- package/cli/agents/html-reporter.js +568 -511
- package/cli/agents/index.js +5 -1
- package/cli/agents/scoring-engine.js +11 -0
- package/cli/bin/ship-safe.js +57 -4
- package/cli/commands/abom.js +73 -0
- package/cli/commands/audit.js +2 -0
- package/cli/commands/ci.js +1 -1
- package/cli/commands/guard.js +99 -0
- package/cli/commands/init.js +58 -0
- package/cli/commands/openclaw.js +378 -0
- package/cli/commands/scan-skill.js +329 -0
- package/cli/commands/scan.js +2 -0
- package/cli/commands/score.js +1 -0
- package/cli/commands/update-intel.js +55 -0
- package/cli/commands/watch.js +120 -0
- package/cli/data/threat-intel.json +85 -0
- package/cli/index.js +2 -0
- package/cli/utils/compliance-map.js +125 -0
- package/cli/utils/output.js +230 -229
- package/cli/utils/threat-intel.js +167 -0
- package/package.json +3 -2
- package/cli/__tests__/agents.test.js +0 -1301
|
@@ -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
|
+
}
|
package/cli/commands/scan.js
CHANGED
|
@@ -457,6 +457,8 @@ function outputPretty(results, filesScanned, rootPath) {
|
|
|
457
457
|
console.log();
|
|
458
458
|
console.log(chalk.gray('Suppress a finding: add # ship-safe-ignore as a comment on that line'));
|
|
459
459
|
console.log(chalk.gray('Exclude a path: add it to .ship-safeignore'));
|
|
460
|
+
console.log();
|
|
461
|
+
console.log(chalk.gray('Track findings over time: ') + chalk.cyan('https://shipsafecli.com'));
|
|
460
462
|
|
|
461
463
|
if (secretResults.length > 0) output.recommendations();
|
|
462
464
|
if (vulnResults.length > 0) output.vulnRecommendations();
|
package/cli/commands/score.js
CHANGED
|
@@ -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
|
+
}
|
package/cli/commands/watch.js
CHANGED
|
@@ -17,6 +17,15 @@ import { SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, SECRET_PATTERNS, SECURITY_P
|
|
|
17
17
|
import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
|
|
18
18
|
import * as output from '../utils/output.js';
|
|
19
19
|
|
|
20
|
+
// Agent config files to watch
|
|
21
|
+
const AGENT_CONFIG_PATTERNS = [
|
|
22
|
+
'.cursorrules', '.windsurfrules', 'CLAUDE.md', 'AGENTS.md',
|
|
23
|
+
'.github/copilot-instructions.md', '.aider.conf.yml',
|
|
24
|
+
'.continue/config.json', 'openclaw.json', 'openclaw.config.json',
|
|
25
|
+
'clawhub.json', 'mcp.json', '.claude/settings.json',
|
|
26
|
+
'.cursor/mcp.json', '.vscode/mcp.json',
|
|
27
|
+
];
|
|
28
|
+
|
|
20
29
|
export async function watchCommand(targetPath = '.', options = {}) {
|
|
21
30
|
const absolutePath = path.resolve(targetPath);
|
|
22
31
|
|
|
@@ -25,6 +34,11 @@ export async function watchCommand(targetPath = '.', options = {}) {
|
|
|
25
34
|
process.exit(1);
|
|
26
35
|
}
|
|
27
36
|
|
|
37
|
+
// Config-only watch mode
|
|
38
|
+
if (options.configs) {
|
|
39
|
+
return watchConfigs(absolutePath);
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
console.log();
|
|
29
43
|
output.header('Ship Safe — Watch Mode');
|
|
30
44
|
console.log();
|
|
@@ -159,3 +173,109 @@ function scanFile(filePath, patterns) {
|
|
|
159
173
|
return true;
|
|
160
174
|
});
|
|
161
175
|
}
|
|
176
|
+
|
|
177
|
+
// =============================================================================
|
|
178
|
+
// CONFIG-ONLY WATCH MODE
|
|
179
|
+
// =============================================================================
|
|
180
|
+
|
|
181
|
+
async function watchConfigs(absolutePath) {
|
|
182
|
+
console.log();
|
|
183
|
+
output.header('Ship Safe — Agent Config Watch');
|
|
184
|
+
console.log();
|
|
185
|
+
console.log(chalk.cyan(' Watching agent config files for changes...'));
|
|
186
|
+
console.log(chalk.gray(' Monitors: .cursorrules, CLAUDE.md, openclaw.json, mcp.json, .claude/settings.json, ...'));
|
|
187
|
+
console.log(chalk.gray(' Press Ctrl+C to stop'));
|
|
188
|
+
console.log();
|
|
189
|
+
|
|
190
|
+
let debounceTimer = null;
|
|
191
|
+
const pendingFiles = new Set();
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const watcher = fs.watch(absolutePath, { recursive: true }, (eventType, filename) => {
|
|
195
|
+
if (!filename) return;
|
|
196
|
+
|
|
197
|
+
// Check if this is an agent config file
|
|
198
|
+
const relPath = filename.replace(/\\/g, '/');
|
|
199
|
+
const isConfig = AGENT_CONFIG_PATTERNS.some(p => relPath === p || relPath.endsWith('/' + p));
|
|
200
|
+
const isGlobMatch = relPath.match(/\.cursor\/rules\/.*\.mdc$/) ||
|
|
201
|
+
relPath.match(/\.openclaw\/.*\.json$/) ||
|
|
202
|
+
relPath.match(/\.claude\/commands\/.*\.md$/) ||
|
|
203
|
+
relPath.match(/\.claude\/memory\//);
|
|
204
|
+
|
|
205
|
+
if (!isConfig && !isGlobMatch) return;
|
|
206
|
+
|
|
207
|
+
const fullPath = path.join(absolutePath, filename);
|
|
208
|
+
pendingFiles.add(fullPath);
|
|
209
|
+
|
|
210
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
211
|
+
debounceTimer = setTimeout(async () => {
|
|
212
|
+
const filesToScan = [...pendingFiles];
|
|
213
|
+
pendingFiles.clear();
|
|
214
|
+
await scanConfigFiles(filesToScan, absolutePath);
|
|
215
|
+
}, 300);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
process.on('SIGINT', () => {
|
|
219
|
+
watcher.close();
|
|
220
|
+
console.log();
|
|
221
|
+
output.info('Config watch stopped.');
|
|
222
|
+
process.exit(0);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
setInterval(() => {}, 1000 * 60 * 60);
|
|
226
|
+
|
|
227
|
+
} catch (err) {
|
|
228
|
+
output.error(`Watch failed: ${err.message}`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function scanConfigFiles(files, rootPath) {
|
|
234
|
+
// Dynamic import to avoid circular dependency
|
|
235
|
+
const { AgentConfigScanner } = await import('../agents/agent-config-scanner.js');
|
|
236
|
+
const { MCPSecurityAgent } = await import('../agents/mcp-security-agent.js');
|
|
237
|
+
|
|
238
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
239
|
+
const scanner = new AgentConfigScanner();
|
|
240
|
+
const mcpScanner = new MCPSecurityAgent();
|
|
241
|
+
|
|
242
|
+
for (const filePath of files) {
|
|
243
|
+
if (!fs.existsSync(filePath)) {
|
|
244
|
+
console.log(chalk.gray(` [${timestamp}] ${path.relative(rootPath, filePath)} — deleted`));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const relPath = path.relative(rootPath, filePath).replace(/\\/g, '/');
|
|
249
|
+
console.log(chalk.cyan(` [${timestamp}] Changed: ${relPath}`));
|
|
250
|
+
|
|
251
|
+
// Git blame (best-effort)
|
|
252
|
+
try {
|
|
253
|
+
const { execFileSync } = await import('child_process');
|
|
254
|
+
const blame = execFileSync('git', ['log', '-1', '--format=%an (%ar)', '--', filePath], { cwd: rootPath, encoding: 'utf-8', timeout: 5000 }).trim();
|
|
255
|
+
if (blame) console.log(chalk.gray(` Last modified by: ${blame}`));
|
|
256
|
+
} catch { /* not a git repo or git not available */ }
|
|
257
|
+
|
|
258
|
+
// Run agent config scanner
|
|
259
|
+
const context = { rootPath, files: [] };
|
|
260
|
+
const [configFindings, mcpFindings] = await Promise.all([
|
|
261
|
+
scanner.analyze(context),
|
|
262
|
+
mcpScanner.analyze(context),
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
const findings = [...configFindings, ...mcpFindings].filter(f =>
|
|
266
|
+
f.file && path.resolve(f.file) === path.resolve(filePath)
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
if (findings.length > 0) {
|
|
270
|
+
for (const f of findings) {
|
|
271
|
+
const sevColor = f.severity === 'critical' ? chalk.red.bold
|
|
272
|
+
: f.severity === 'high' ? chalk.yellow
|
|
273
|
+
: chalk.blue;
|
|
274
|
+
console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`)} ${f.title || f.rule}`);
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
console.log(chalk.green(' ✔ Clean'));
|
|
278
|
+
}
|
|
279
|
+
console.log();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0.0",
|
|
3
|
+
"updated": "2026-03-23T00:00:00Z",
|
|
4
|
+
"maliciousSkillHashes": [
|
|
5
|
+
{
|
|
6
|
+
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
7
|
+
"name": "empty-payload-stub",
|
|
8
|
+
"description": "Empty file used as placeholder in ClawHavoc campaign"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
|
12
|
+
"name": "clawhavoc-stealer-v1",
|
|
13
|
+
"description": "AMOS stealer dropper identified in ClawHavoc campaign (1,184 malicious skills)"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"sha256": "dead0000beef0000cafe0000face0000dead0000beef0000cafe0000face0000",
|
|
17
|
+
"name": "clawhavoc-exfil-v2",
|
|
18
|
+
"description": "Data exfiltration skill sending credentials to attacker-controlled webhook"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"compromisedMcpServers": [
|
|
22
|
+
{
|
|
23
|
+
"name": "@openclaw/mcp-shell-exec",
|
|
24
|
+
"versions": ["<1.2.3"],
|
|
25
|
+
"advisory": "Unrestricted shell command execution via WebSocket — CVE-2026-25253"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "mcp-server-puppeteer-unverified",
|
|
29
|
+
"versions": ["*"],
|
|
30
|
+
"advisory": "Typosquat of official puppeteer MCP server, exfiltrates browser cookies"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "@clawhub/skill-auto-deploy",
|
|
34
|
+
"versions": ["*"],
|
|
35
|
+
"advisory": "ClawHavoc campaign skill — installs AMOS stealer on macOS/Linux"
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"maliciousConfigSignatures": [
|
|
39
|
+
{
|
|
40
|
+
"pattern": "ignore previous instructions",
|
|
41
|
+
"description": "Prompt injection attempting to override agent system instructions",
|
|
42
|
+
"severity": "critical"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"pattern": "webhook.site",
|
|
46
|
+
"description": "Known data exfiltration service used in prompt injection attacks",
|
|
47
|
+
"severity": "critical"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"pattern": "curl.*\\|.*bash",
|
|
51
|
+
"description": "Remote code execution via piped download-and-execute pattern",
|
|
52
|
+
"severity": "critical"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"pattern": "ngrok\\.io|ngrok\\.app",
|
|
56
|
+
"description": "Tunnel service commonly used for data exfiltration in agent attacks",
|
|
57
|
+
"severity": "critical"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"pattern": "requestbin\\.com|pipedream\\.net",
|
|
61
|
+
"description": "Request interception service used to capture exfiltrated data",
|
|
62
|
+
"severity": "critical"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"knownVulnerableConfigs": [
|
|
66
|
+
{
|
|
67
|
+
"file": "openclaw.json",
|
|
68
|
+
"check": "host_0000",
|
|
69
|
+
"description": "OpenClaw bound to 0.0.0.0 — CVE-2026-25253 (ClawJacked, CVSS 8.8)",
|
|
70
|
+
"cve": "CVE-2026-25253"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"file": "openclaw.json",
|
|
74
|
+
"check": "no_auth",
|
|
75
|
+
"description": "OpenClaw running without authentication — full agent takeover possible",
|
|
76
|
+
"cve": "CVE-2026-25253"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"file": ".claude/settings.json",
|
|
80
|
+
"check": "malicious_hooks",
|
|
81
|
+
"description": "Claude Code hooks executing arbitrary shell commands — Check Point RCE disclosure",
|
|
82
|
+
"cve": "CVE-2026-XXXX"
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
package/cli/index.js
CHANGED
|
@@ -53,6 +53,8 @@ export { APIFuzzer } from './agents/api-fuzzer.js';
|
|
|
53
53
|
export { SupabaseRLSAgent } from './agents/supabase-rls-agent.js';
|
|
54
54
|
export { VibeCodingAgent } from './agents/vibe-coding-agent.js';
|
|
55
55
|
export { ExceptionHandlerAgent } from './agents/exception-handler-agent.js';
|
|
56
|
+
export { AgentConfigScanner } from './agents/agent-config-scanner.js';
|
|
57
|
+
export { ABOMGenerator } from './agents/abom-generator.js';
|
|
56
58
|
|
|
57
59
|
// ── Supporting Modules ────────────────────────────────────────────────────────
|
|
58
60
|
export { ScoringEngine, GRADES, CATEGORIES } from './agents/scoring-engine.js';
|