clawhub-guard 1.0.0 → 1.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/package.json +3 -3
- package/src/cli.js +194 -30
- package/src/install.js +12 -66
- package/src/report.js +54 -0
- package/src/scan.js +162 -19
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawhub-guard",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Pre-install security scanner for ClawHub skills — scan
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Pre-install security scanner for ClawHub skills — scan, audit, watch, and block risky installs.",
|
|
5
5
|
"main": "src/cli.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"clawhub-guard": "./
|
|
7
|
+
"clawhub-guard": "./src/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node src/cli.js scan --help"
|
package/src/cli.js
CHANGED
|
@@ -1,32 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// src/cli.js — Main CLI for clawhub-guard
|
|
2
|
+
// src/cli.js — Main CLI for clawhub-guard v1.1.0
|
|
3
3
|
|
|
4
|
-
const {
|
|
4
|
+
const {
|
|
5
|
+
scanLocal, scanUrl, auditAll, printAuditReport, saveReport,
|
|
6
|
+
getSkillInstallPath, isSkillInstalled
|
|
7
|
+
} = require('./scan.js');
|
|
5
8
|
const { installSkill } = require('./install.js');
|
|
9
|
+
const { printScanReport } = require('./report.js');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
6
13
|
|
|
7
14
|
const args = process.argv.slice(2);
|
|
8
15
|
const command = args[0];
|
|
9
16
|
|
|
10
17
|
function usage() {
|
|
11
18
|
console.log(`
|
|
12
|
-
🛡️ clawhub-guard — Pre-install security scanner for ClawHub skills
|
|
19
|
+
🛡️ clawhub-guard v1.1.0 — Pre-install security scanner for ClawHub skills
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
Commands:
|
|
22
|
+
install <name> Scan + install a ClawHub skill
|
|
23
|
+
scan <name> Scan an installed skill (fuzzy name match)
|
|
24
|
+
scan --local <path> Scan a local directory
|
|
25
|
+
scan --url <github> Scan a GitHub repo (clones to temp, scans, cleans)
|
|
26
|
+
audit Audit ALL installed skills with summary table
|
|
27
|
+
watch Watch skills dir and auto-scan new installs
|
|
28
|
+
history Show scan report history
|
|
19
29
|
|
|
20
30
|
Options:
|
|
21
31
|
--threshold <0-100> Minimum score to pass (default: 70)
|
|
32
|
+
--fail-under <score> CI mode: exit 1 if score below this
|
|
22
33
|
--force Skip security scan and install anyway
|
|
23
34
|
--json Output scan result as JSON
|
|
35
|
+
--no-log Skip saving to scan history
|
|
24
36
|
|
|
25
37
|
Examples:
|
|
26
38
|
clawhub-guard install summarize
|
|
27
|
-
clawhub-guard scan --
|
|
28
|
-
clawhub-guard
|
|
29
|
-
clawhub-guard
|
|
39
|
+
clawhub-guard scan --url https://github.com/user/skill-repo
|
|
40
|
+
clawhub-guard audit
|
|
41
|
+
clawhub-guard audit --fail-under 80
|
|
42
|
+
clawhub-guard watch
|
|
43
|
+
clawhub-guard history
|
|
30
44
|
`);
|
|
31
45
|
}
|
|
32
46
|
|
|
@@ -40,18 +54,31 @@ const options = {
|
|
|
40
54
|
threshold: 70,
|
|
41
55
|
force: false,
|
|
42
56
|
json: false,
|
|
57
|
+
local: false,
|
|
58
|
+
url: false,
|
|
59
|
+
log: true,
|
|
60
|
+
failUnder: null,
|
|
43
61
|
};
|
|
44
62
|
for (let i = 0; i < args.length; i++) {
|
|
45
63
|
if (args[i] === '--threshold' && args[i + 1]) {
|
|
46
64
|
options.threshold = parseInt(args[i + 1], 10);
|
|
47
65
|
i++;
|
|
48
66
|
}
|
|
67
|
+
if (args[i] === '--fail-under' && args[i + 1]) {
|
|
68
|
+
options.failUnder = parseInt(args[i + 1], 10);
|
|
69
|
+
options.threshold = options.failUnder;
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
49
72
|
if (args[i] === '--force') options.force = true;
|
|
50
73
|
if (args[i] === '--json') options.json = true;
|
|
51
74
|
if (args[i] === '--local') options.local = true;
|
|
75
|
+
if (args[i] === '--url') options.url = true;
|
|
76
|
+
if (args[i] === '--no-log') options.log = false;
|
|
52
77
|
}
|
|
53
78
|
|
|
54
79
|
switch (command) {
|
|
80
|
+
|
|
81
|
+
// ─── INSTALL ─────────────────────────────────────
|
|
55
82
|
case 'install': {
|
|
56
83
|
const skillName = args[1];
|
|
57
84
|
if (!skillName) {
|
|
@@ -59,72 +86,209 @@ switch (command) {
|
|
|
59
86
|
process.exit(1);
|
|
60
87
|
}
|
|
61
88
|
const result = installSkill(skillName, options);
|
|
89
|
+
if (options.log && result.scanResult) {
|
|
90
|
+
saveReport(result.scanResult, `install ${skillName}`);
|
|
91
|
+
}
|
|
62
92
|
process.exit(result.success ? 0 : 1);
|
|
63
93
|
}
|
|
64
94
|
|
|
95
|
+
// ─── SCAN ────────────────────────────────────────
|
|
65
96
|
case 'scan': {
|
|
66
97
|
let target = args[1];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
98
|
+
|
|
99
|
+
// --url mode
|
|
100
|
+
if (options.url) {
|
|
101
|
+
target = args.find((a, i) => args[i - 1] === '--url') || args[2] || target;
|
|
102
|
+
if (!target) {
|
|
103
|
+
console.error('❌ Error: URL required with --url flag.');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const result = scanUrl(target, options);
|
|
107
|
+
if (options.log) saveReport(result, `scan --url ${target}`);
|
|
108
|
+
if (options.json) {
|
|
109
|
+
console.log(JSON.stringify(result, null, 2));
|
|
110
|
+
} else {
|
|
111
|
+
printScanReport(result);
|
|
112
|
+
}
|
|
113
|
+
process.exit(result.passed ? 0 : 1);
|
|
70
114
|
}
|
|
71
115
|
|
|
72
|
-
// --local mode
|
|
116
|
+
// --local mode
|
|
73
117
|
if (options.local) {
|
|
74
118
|
target = args.find((a, i) => args[i - 1] === '--local') || args[2];
|
|
75
119
|
if (!target) {
|
|
76
|
-
|
|
77
|
-
|
|
120
|
+
console.error('❌ Error: path required with --local flag.');
|
|
121
|
+
process.exit(1);
|
|
78
122
|
}
|
|
123
|
+
const result = scanLocal(target, options);
|
|
124
|
+
if (options.log) saveReport(result, `scan --local ${target}`);
|
|
125
|
+
if (options.json) {
|
|
126
|
+
console.log(JSON.stringify(result, null, 2));
|
|
127
|
+
} else {
|
|
128
|
+
printScanReport(result);
|
|
129
|
+
}
|
|
130
|
+
process.exit(result.passed ? 0 : 1);
|
|
79
131
|
}
|
|
80
132
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
133
|
+
// Skill name mode (default)
|
|
134
|
+
if (!target) {
|
|
135
|
+
console.error('❌ Error: skill name, --local <path>, or --url <url> required.');
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
84
138
|
|
|
85
139
|
let scanTarget;
|
|
86
|
-
if (
|
|
140
|
+
if (target.includes('http://') || target.includes('https://')) {
|
|
141
|
+
// Auto-detect URL
|
|
142
|
+
scanTarget = target;
|
|
143
|
+
const result = scanUrl(scanTarget, options);
|
|
144
|
+
if (options.log) saveReport(result, `scan --url ${target}`);
|
|
145
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
146
|
+
else printScanReport(result);
|
|
147
|
+
process.exit(result.passed ? 0 : 1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (target.includes('/') || target.includes('\\')) {
|
|
87
151
|
scanTarget = target;
|
|
88
152
|
} else {
|
|
89
153
|
scanTarget = getSkillInstallPath(target);
|
|
90
154
|
if (!isSkillInstalled(target)) {
|
|
91
|
-
//
|
|
155
|
+
// Fuzzy match
|
|
92
156
|
const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
|
|
93
157
|
path.join(os.homedir(), '.openclaw', 'workspace');
|
|
94
158
|
const skillsDir = path.join(workspace, 'skills');
|
|
95
159
|
let installedList = [];
|
|
96
160
|
if (fs.existsSync(skillsDir)) {
|
|
97
161
|
installedList = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
98
|
-
.filter(d => d.isDirectory())
|
|
99
|
-
.map(d => d.name);
|
|
100
|
-
// Normalize names (strip hyphens, underscores) for fuzzy matching
|
|
162
|
+
.filter(d => d.isDirectory()).map(d => d.name);
|
|
101
163
|
const normalize = s => s.toLowerCase().replace(/[-_]/g, '');
|
|
102
164
|
const normTarget = normalize(target);
|
|
103
165
|
const match = installedList.find(e =>
|
|
104
166
|
normalize(e).includes(normTarget) || normTarget.includes(normalize(e))
|
|
105
167
|
);
|
|
106
|
-
if (match)
|
|
107
|
-
scanTarget = getSkillInstallPath(match);
|
|
108
|
-
}
|
|
168
|
+
if (match) scanTarget = getSkillInstallPath(match);
|
|
109
169
|
}
|
|
110
170
|
if (!scanTarget) {
|
|
111
171
|
console.error(`❌ Skill '${target}' not installed.`);
|
|
112
|
-
console.error(` Installed
|
|
172
|
+
console.error(` Installed: ${installedList.join(', ') || 'none'}`);
|
|
113
173
|
process.exit(1);
|
|
114
174
|
}
|
|
115
175
|
}
|
|
116
176
|
}
|
|
117
177
|
|
|
118
178
|
const result = scanLocal(scanTarget, options);
|
|
179
|
+
if (options.log) saveReport(result, `scan ${target}`);
|
|
119
180
|
if (options.json) {
|
|
120
181
|
console.log(JSON.stringify(result, null, 2));
|
|
121
182
|
} else {
|
|
122
|
-
const { printScanReport } = require('./install.js');
|
|
123
183
|
printScanReport(result);
|
|
124
184
|
}
|
|
125
185
|
process.exit(result.passed ? 0 : 1);
|
|
126
186
|
}
|
|
127
187
|
|
|
188
|
+
// ─── AUDIT ───────────────────────────────────────
|
|
189
|
+
case 'audit': {
|
|
190
|
+
const result = auditAll(options);
|
|
191
|
+
if (options.json) {
|
|
192
|
+
console.log(JSON.stringify(result, null, 2));
|
|
193
|
+
} else {
|
|
194
|
+
printAuditReport(result, options);
|
|
195
|
+
}
|
|
196
|
+
if (options.log && result.summary) {
|
|
197
|
+
saveReport({ score: result.summary.avgScore, verdict: 'AUDIT', target: 'all',
|
|
198
|
+
findings: result.skills.flatMap(s => s.findings), summary: result.summary,
|
|
199
|
+
passed: result.summary.blocked === 0 }, 'audit');
|
|
200
|
+
}
|
|
201
|
+
const exitCode = (options.failUnder && result.summary.avgScore < options.failUnder) ? 1 : 0;
|
|
202
|
+
process.exit(exitCode);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── WATCH ───────────────────────────────────────
|
|
206
|
+
case 'watch': {
|
|
207
|
+
console.log('👀 Watching skills directory for changes...\n');
|
|
208
|
+
console.log(' New skill installs will be auto-scanned.\n');
|
|
209
|
+
console.log(' Press Ctrl+C to stop.\n');
|
|
210
|
+
|
|
211
|
+
const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
|
|
212
|
+
path.join(os.homedir(), '.openclaw', 'workspace');
|
|
213
|
+
const skillsDir = path.join(workspace, 'skills');
|
|
214
|
+
|
|
215
|
+
let knownSkills = new Set();
|
|
216
|
+
if (fs.existsSync(skillsDir)) {
|
|
217
|
+
fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
218
|
+
.filter(d => d.isDirectory())
|
|
219
|
+
.forEach(d => knownSkills.add(d.name));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const interval = setInterval(() => {
|
|
223
|
+
if (!fs.existsSync(skillsDir)) return;
|
|
224
|
+
const current = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
225
|
+
.filter(d => d.isDirectory())
|
|
226
|
+
.map(d => d.name);
|
|
227
|
+
|
|
228
|
+
for (const skill of current) {
|
|
229
|
+
if (!knownSkills.has(skill)) {
|
|
230
|
+
knownSkills.add(skill);
|
|
231
|
+
const skillPath = path.join(skillsDir, skill);
|
|
232
|
+
console.log(`\n🔔 New skill detected: ${skill}`);
|
|
233
|
+
console.log(`🔍 Auto-scanning...\n`);
|
|
234
|
+
|
|
235
|
+
const result = scanLocal(skillPath, options);
|
|
236
|
+
printScanReport(result);
|
|
237
|
+
|
|
238
|
+
if (options.log) saveReport(result, `watch:${skill}`);
|
|
239
|
+
|
|
240
|
+
if (!result.passed && result.verdict === 'BLOCK') {
|
|
241
|
+
console.log(`❌ AUTO-BLOCKED: ${skill} (${result.score}/100)`);
|
|
242
|
+
console.log(` Run: openclaw skills uninstall ${skill}\n`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}, 3000); // Poll every 3 seconds
|
|
247
|
+
|
|
248
|
+
process.on('SIGINT', () => { clearInterval(interval); process.exit(0); });
|
|
249
|
+
process.on('SIGTERM', () => { clearInterval(interval); process.exit(0); });
|
|
250
|
+
|
|
251
|
+
// Keep alive
|
|
252
|
+
setInterval(() => {}, 60000);
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── HISTORY ─────────────────────────────────────
|
|
257
|
+
case 'history': {
|
|
258
|
+
const logFile = path.join(os.homedir(), '.clawhub-guard', 'scan-history.jsonl');
|
|
259
|
+
if (!fs.existsSync(logFile)) {
|
|
260
|
+
console.log('📭 No scan history yet. Run some scans first!');
|
|
261
|
+
process.exit(0);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const lines = fs.readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean);
|
|
265
|
+
const entries = lines.map(l => JSON.parse(l));
|
|
266
|
+
|
|
267
|
+
if (options.json) {
|
|
268
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
269
|
+
process.exit(0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log(`\n${'═'.repeat(70)}`);
|
|
273
|
+
console.log(` SCAN HISTORY — clawhub-guard (${entries.length} entries)`);
|
|
274
|
+
console.log(`${'═'.repeat(70)}`);
|
|
275
|
+
console.log(` ${'DATE'.padEnd(22)} ${'COMMAND'.padEnd(24)} ${'SCORE'.padEnd(8)} ${'VERDICT'.padEnd(8)} TARGET`);
|
|
276
|
+
console.log(` ${'─'.repeat(68)}`);
|
|
277
|
+
|
|
278
|
+
for (const e of entries.slice(-20)) {
|
|
279
|
+
const date = new Date(e.timestamp).toLocaleString('zh-TW', {
|
|
280
|
+
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
|
|
281
|
+
});
|
|
282
|
+
const icon = e.verdict === 'PASS' ? '✅' : e.verdict === 'WARN' ? '⚠️' :
|
|
283
|
+
e.verdict === 'BLOCK' ? '❌' : '📊';
|
|
284
|
+
const target = (e.target || '').split('/').slice(-1)[0] || e.target || '';
|
|
285
|
+
console.log(` ${date.padEnd(22)} ${e.command.padEnd(24)} ${String(e.score).padEnd(8)} ${icon} ${String(e.verdict).padEnd(6)} ${target}`);
|
|
286
|
+
}
|
|
287
|
+
console.log(`${'═'.repeat(70)}\n`);
|
|
288
|
+
process.exit(0);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── DEFAULT ─────────────────────────────────────
|
|
128
292
|
default:
|
|
129
293
|
console.error(`❌ Unknown command: ${command}`);
|
|
130
294
|
usage();
|
package/src/install.js
CHANGED
|
@@ -1,26 +1,22 @@
|
|
|
1
1
|
// src/install.js — Install logic with pre-scan guard
|
|
2
2
|
|
|
3
3
|
const { execSync } = require('node:child_process');
|
|
4
|
-
const
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const { scanLocal } = require('./scan.js');
|
|
8
|
+
const { printScanReport } = require('./report.js');
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* Install a ClawHub skill with pre-scan security check
|
|
8
|
-
* @param {string} skillName - Name of the ClawHub skill
|
|
9
|
-
* @param {object} options
|
|
10
|
-
* @param {number} options.threshold - Minimum security score (default 70)
|
|
11
|
-
* @param {boolean} options.force - Skip scan and install anyway
|
|
12
|
-
* @returns {object} Install result
|
|
13
12
|
*/
|
|
14
13
|
function installSkill(skillName, options = {}) {
|
|
15
14
|
const threshold = options.threshold ?? 70;
|
|
16
15
|
|
|
17
|
-
// Step 0: Force mode — skip scan
|
|
18
16
|
if (options.force) {
|
|
19
17
|
return doInstall(skillName);
|
|
20
18
|
}
|
|
21
19
|
|
|
22
|
-
// Step 1: Can't pre-scan before download, so install first →
|
|
23
|
-
// scan the installed copy, then warn
|
|
24
20
|
console.log(`\n📦 Installing ${skillName} for pre-scan...\n`);
|
|
25
21
|
|
|
26
22
|
let installResult = doInstall(skillName);
|
|
@@ -28,17 +24,13 @@ function installSkill(skillName, options = {}) {
|
|
|
28
24
|
return installResult;
|
|
29
25
|
}
|
|
30
26
|
|
|
31
|
-
// Step 2: Scan the just-installed skill
|
|
32
27
|
console.log(`\n🔍 Running security scan on ${skillName}...\n`);
|
|
33
28
|
|
|
34
29
|
const scanResult = scanLocal(installResult.installPath, { threshold });
|
|
35
|
-
|
|
36
|
-
// Step 3: Print report
|
|
37
30
|
printScanReport(scanResult);
|
|
38
31
|
|
|
39
|
-
// Step 4: If unsafe, offer to uninstall
|
|
40
32
|
if (!scanResult.passed) {
|
|
41
|
-
console.log(
|
|
33
|
+
console.log(`⚠️ RISK THRESHOLD NOT MET (${scanResult.score}/100, need ${threshold})`);
|
|
42
34
|
console.log(` Verdict: ${scanResult.verdict}`);
|
|
43
35
|
|
|
44
36
|
if (scanResult.verdict === 'BLOCK') {
|
|
@@ -49,31 +41,21 @@ function installSkill(skillName, options = {}) {
|
|
|
49
41
|
stdio: 'pipe'
|
|
50
42
|
});
|
|
51
43
|
} catch {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const path = require('node:path');
|
|
55
|
-
const skillPath = installResult.installPath;
|
|
56
|
-
if (fs.existsSync(skillPath)) {
|
|
57
|
-
fs.rmSync(skillPath, { recursive: true, force: true });
|
|
44
|
+
if (fs.existsSync(installResult.installPath)) {
|
|
45
|
+
fs.rmSync(installResult.installPath, { recursive: true, force: true });
|
|
58
46
|
}
|
|
59
47
|
}
|
|
60
|
-
console.log(`💡 Tip: Review the findings
|
|
48
|
+
console.log(`💡 Tip: Review the findings. Use --force to install anyway.\n`);
|
|
61
49
|
return { success: false, blocked: true, scanResult, installResult };
|
|
62
50
|
}
|
|
63
51
|
|
|
64
|
-
|
|
65
|
-
console.log(`⚠️ Installed with warnings — review the findings above.\n`);
|
|
66
|
-
}
|
|
52
|
+
console.log(`⚠️ Installed with warnings — review the findings above.\n`);
|
|
67
53
|
}
|
|
68
54
|
|
|
69
55
|
return { success: true, blocked: false, scanResult, installResult };
|
|
70
56
|
}
|
|
71
57
|
|
|
72
58
|
function doInstall(skillName) {
|
|
73
|
-
const path = require('node:path');
|
|
74
|
-
const fs = require('node:fs');
|
|
75
|
-
const os = require('node:os');
|
|
76
|
-
|
|
77
59
|
try {
|
|
78
60
|
const output = execSync(`openclaw.cmd skills install ${skillName}`, {
|
|
79
61
|
encoding: 'utf-8',
|
|
@@ -82,8 +64,7 @@ function doInstall(skillName) {
|
|
|
82
64
|
});
|
|
83
65
|
console.log(output);
|
|
84
66
|
|
|
85
|
-
|
|
86
|
-
const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
|
|
67
|
+
const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
|
|
87
68
|
path.join(os.homedir(), '.openclaw', 'workspace');
|
|
88
69
|
const installPath = path.join(workspace, 'skills', skillName);
|
|
89
70
|
|
|
@@ -95,39 +76,4 @@ function doInstall(skillName) {
|
|
|
95
76
|
}
|
|
96
77
|
}
|
|
97
78
|
|
|
98
|
-
|
|
99
|
-
console.log(`\n${'═'.repeat(55)}`);
|
|
100
|
-
console.log(` SECURITY SCAN REPORT — clawhub-guard`);
|
|
101
|
-
console.log(`${'═'.repeat(55)}`);
|
|
102
|
-
console.log(` Target: ${result.target || 'N/A'}`);
|
|
103
|
-
console.log(` Score: ${result.score}/100`);
|
|
104
|
-
console.log(` Threshold: ${result.threshold}/100`);
|
|
105
|
-
console.log(` Verdict: ${result.passed ? '✅ PASS' : result.verdict === 'BLOCK' ? '❌ BLOCK' : '⚠️ WARN'}`);
|
|
106
|
-
console.log(` Engines: ${result.availableEngines}/${result.totalEngines} available`);
|
|
107
|
-
console.log(` Findings: ${(result.findings || []).length}`);
|
|
108
|
-
console.log(`${'─'.repeat(55)}`);
|
|
109
|
-
|
|
110
|
-
if (result.findings && result.findings.length > 0) {
|
|
111
|
-
const bySeverity = {};
|
|
112
|
-
for (const f of result.findings) {
|
|
113
|
-
bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
|
|
114
|
-
}
|
|
115
|
-
for (const [sev, count] of Object.entries(bySeverity)) {
|
|
116
|
-
const icon = sev === 'critical' ? '🔴' : sev === 'high' ? '🟠' : sev === 'medium' ? '🟡' : '⚪';
|
|
117
|
-
console.log(` ${icon} ${sev}: ${count}`);
|
|
118
|
-
}
|
|
119
|
-
console.log(`${'─'.repeat(55)}`);
|
|
120
|
-
for (const f of (result.findings || []).slice(0, 5)) {
|
|
121
|
-
console.log(` • [${f.severity}] ${f.rule}: ${f.message}`);
|
|
122
|
-
}
|
|
123
|
-
if (result.findings.length > 5) {
|
|
124
|
-
console.log(` • ... and ${result.findings.length - 5} more`);
|
|
125
|
-
}
|
|
126
|
-
} else {
|
|
127
|
-
console.log(` ✅ No findings — clean!`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
console.log(`${'═'.repeat(55)}\n`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
module.exports = { installSkill, printScanReport };
|
|
79
|
+
module.exports = { installSkill, doInstall, printScanReport };
|
package/src/report.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/report.js — Report formatting for clawhub-guard
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Print formatted scan report for a single skill
|
|
5
|
+
*/
|
|
6
|
+
function printScanReport(result) {
|
|
7
|
+
console.log(`\n${'═'.repeat(55)}`);
|
|
8
|
+
console.log(` SECURITY SCAN REPORT — clawhub-guard`);
|
|
9
|
+
console.log(`${'═'.repeat(55)}`);
|
|
10
|
+
console.log(` Target: ${result.target || result.sourceUrl || 'N/A'}`);
|
|
11
|
+
console.log(` Score: ${result.score}/100`);
|
|
12
|
+
console.log(` Threshold: ${result.threshold}/100`);
|
|
13
|
+
|
|
14
|
+
const verdictIcon = result.passed ? '✅ PASS' :
|
|
15
|
+
result.verdict === 'BLOCK' ? '❌ BLOCK' : '⚠️ WARN';
|
|
16
|
+
console.log(` Verdict: ${verdictIcon}`);
|
|
17
|
+
console.log(` Engines: ${result.availableEngines}/${result.totalEngines} available`);
|
|
18
|
+
console.log(` Findings: ${(result.findings || []).length}`);
|
|
19
|
+
console.log(`${'─'.repeat(55)}`);
|
|
20
|
+
|
|
21
|
+
if (result.findings && result.findings.length > 0) {
|
|
22
|
+
const bySeverity = {};
|
|
23
|
+
for (const f of result.findings) {
|
|
24
|
+
bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
|
|
25
|
+
}
|
|
26
|
+
for (const [sev, count] of Object.entries(bySeverity)) {
|
|
27
|
+
const icon = sev === 'critical' ? '🔴' : sev === 'high' ? '🟠' :
|
|
28
|
+
sev === 'medium' ? '🟡' : '⚪';
|
|
29
|
+
console.log(` ${icon} ${sev}: ${count}`);
|
|
30
|
+
}
|
|
31
|
+
console.log(`${'─'.repeat(55)}`);
|
|
32
|
+
for (const f of (result.findings || []).slice(0, 8)) {
|
|
33
|
+
console.log(` • [${f.severity}] ${f.rule}: ${f.message}`);
|
|
34
|
+
}
|
|
35
|
+
if (result.findings.length > 8) {
|
|
36
|
+
console.log(` • ... and ${result.findings.length - 8} more`);
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
console.log(` ✅ No findings — clean!`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`${'═'.repeat(55)}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Print a section header
|
|
47
|
+
*/
|
|
48
|
+
function header(text) {
|
|
49
|
+
console.log(`\n${'─'.repeat(50)}`);
|
|
50
|
+
console.log(` ${text}`);
|
|
51
|
+
console.log(`${'─'.repeat(50)}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { printScanReport, header };
|
package/src/scan.js
CHANGED
|
@@ -7,14 +7,10 @@ const os = require('node:os');
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Scan a local directory for security issues using agent-shield
|
|
10
|
-
* @param {string} targetPath - Path to the skill directory
|
|
11
|
-
* @param {object} options
|
|
12
|
-
* @param {number} options.threshold - Minimum score to pass (0-100)
|
|
13
|
-
* @returns {object} Scan result with score, findings, and verdict
|
|
14
10
|
*/
|
|
15
11
|
function scanLocal(targetPath, options = {}) {
|
|
16
12
|
const threshold = options.threshold ?? 70;
|
|
17
|
-
|
|
13
|
+
|
|
18
14
|
if (!fs.existsSync(targetPath)) {
|
|
19
15
|
return {
|
|
20
16
|
success: false,
|
|
@@ -36,20 +32,16 @@ function scanLocal(targetPath, options = {}) {
|
|
|
36
32
|
}
|
|
37
33
|
);
|
|
38
34
|
} catch (err) {
|
|
39
|
-
// agent-shield exits non-zero if findings exist — still parse output
|
|
40
35
|
stdout = err.stdout || err.stderr || '';
|
|
41
36
|
}
|
|
42
37
|
|
|
43
|
-
// Try to extract JSON from agent-shield output (it may have noise)
|
|
44
38
|
let result;
|
|
45
39
|
try {
|
|
46
40
|
const jsonStart = stdout.indexOf('{');
|
|
47
41
|
if (jsonStart >= 0) {
|
|
48
42
|
result = JSON.parse(stdout.slice(jsonStart));
|
|
49
43
|
}
|
|
50
|
-
} catch {
|
|
51
|
-
// Fallback: parse manually
|
|
52
|
-
}
|
|
44
|
+
} catch { /* fall through */ }
|
|
53
45
|
|
|
54
46
|
if (!result) {
|
|
55
47
|
return {
|
|
@@ -61,7 +53,6 @@ function scanLocal(targetPath, options = {}) {
|
|
|
61
53
|
};
|
|
62
54
|
}
|
|
63
55
|
|
|
64
|
-
// Calculate score based on findings
|
|
65
56
|
const allFindings = result.allFindings || [];
|
|
66
57
|
const severityScores = { critical: 40, high: 25, medium: 10, low: 3 };
|
|
67
58
|
let penalty = 0;
|
|
@@ -85,20 +76,172 @@ function scanLocal(targetPath, options = {}) {
|
|
|
85
76
|
}
|
|
86
77
|
|
|
87
78
|
/**
|
|
88
|
-
*
|
|
79
|
+
* Scan a GitHub URL by cloning to temp and scanning
|
|
89
80
|
*/
|
|
90
|
-
function
|
|
91
|
-
const
|
|
81
|
+
function scanUrl(url, options = {}) {
|
|
82
|
+
const tempDir = path.join(os.tmpdir(), `clawhub-guard-${Date.now()}`);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
86
|
+
|
|
87
|
+
console.log(`\n📥 Cloning ${url}...\n`);
|
|
88
|
+
execSync(`git clone --depth 1 "${url}" "${tempDir}"`, {
|
|
89
|
+
encoding: 'utf-8',
|
|
90
|
+
timeout: 30000,
|
|
91
|
+
stdio: 'pipe',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
console.log(`🔍 Scanning...\n`);
|
|
95
|
+
const result = scanLocal(tempDir, options);
|
|
96
|
+
result.sourceUrl = url;
|
|
97
|
+
return result;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
error: `Failed to clone/scan URL: ${err.stderr || err.message}`,
|
|
102
|
+
score: 0,
|
|
103
|
+
verdict: 'ERROR',
|
|
104
|
+
sourceUrl: url,
|
|
105
|
+
};
|
|
106
|
+
} finally {
|
|
107
|
+
// Cleanup temp dir
|
|
108
|
+
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Audit all installed skills - scan everything and produce summary
|
|
114
|
+
*/
|
|
115
|
+
function auditAll(options = {}) {
|
|
116
|
+
const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
|
|
92
117
|
path.join(os.homedir(), '.openclaw', 'workspace');
|
|
93
|
-
|
|
118
|
+
const skillsDir = path.join(workspace, 'skills');
|
|
119
|
+
|
|
120
|
+
if (!fs.existsSync(skillsDir)) {
|
|
121
|
+
console.log('📦 No skills installed yet.');
|
|
122
|
+
return { skills: [], summary: { total: 0, passed: 0, warned: 0, blocked: 0 } };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const entries = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
126
|
+
.filter(d => d.isDirectory())
|
|
127
|
+
.map(d => d.name);
|
|
128
|
+
|
|
129
|
+
console.log(`\n🔍 Auditing ${entries.length} installed skill(s)...\n`);
|
|
130
|
+
|
|
131
|
+
const results = [];
|
|
132
|
+
for (const skill of entries) {
|
|
133
|
+
const skillPath = path.join(skillsDir, skill);
|
|
134
|
+
process.stdout.write(` Scanning ${skill}... `);
|
|
135
|
+
const result = scanLocal(skillPath, options);
|
|
136
|
+
|
|
137
|
+
const icon = result.verdict === 'PASS' ? '✅' :
|
|
138
|
+
result.verdict === 'WARN' ? '⚠️' : '❌';
|
|
139
|
+
console.log(`${icon} ${result.score}/100`);
|
|
140
|
+
|
|
141
|
+
results.push({
|
|
142
|
+
name: skill,
|
|
143
|
+
path: skillPath,
|
|
144
|
+
score: result.score,
|
|
145
|
+
verdict: result.verdict,
|
|
146
|
+
findingsCount: (result.findings || []).length,
|
|
147
|
+
findings: result.findings || [],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const summary = {
|
|
152
|
+
total: results.length,
|
|
153
|
+
passed: results.filter(r => r.verdict === 'PASS').length,
|
|
154
|
+
warned: results.filter(r => r.verdict === 'WARN').length,
|
|
155
|
+
blocked: results.filter(r => r.verdict === 'BLOCK').length,
|
|
156
|
+
avgScore: results.length > 0
|
|
157
|
+
? Math.round(results.reduce((s, r) => s + r.score, 0) / results.length)
|
|
158
|
+
: 0,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return { skills: results, summary, timestamp: new Date().toISOString() };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Print audit summary as a formatted table
|
|
166
|
+
*/
|
|
167
|
+
function printAuditReport(auditResult, options = {}) {
|
|
168
|
+
const { skills, summary } = auditResult;
|
|
169
|
+
|
|
170
|
+
console.log(`\n${'═'.repeat(65)}`);
|
|
171
|
+
console.log(` SKILL AUDIT SUMMARY — clawhub-guard`);
|
|
172
|
+
console.log(`${'═'.repeat(65)}`);
|
|
173
|
+
|
|
174
|
+
if (skills.length === 0) {
|
|
175
|
+
console.log(` No skills installed.\n`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Table header
|
|
180
|
+
console.log(` ${'SKILL'.padEnd(28)} ${'SCORE'.padEnd(8)} ${'VERDICT'.padEnd(8)} FINDINGS`);
|
|
181
|
+
console.log(` ${'─'.repeat(60)}`);
|
|
182
|
+
|
|
183
|
+
for (const r of skills) {
|
|
184
|
+
const icon = r.verdict === 'PASS' ? '✅' : r.verdict === 'WARN' ? '⚠️' : '❌';
|
|
185
|
+
console.log(` ${r.name.padEnd(28)} ${String(r.score).padEnd(8)} ${icon} ${String(r.verdict).padEnd(6)} ${r.findingsCount}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log(` ${'─'.repeat(60)}`);
|
|
189
|
+
console.log(` TOTAL: ${summary.total} | ✅ ${summary.passed} passed | ⚠️ ${summary.warned} warned | ❌ ${summary.blocked} blocked`);
|
|
190
|
+
console.log(` Average score: ${summary.avgScore}/100`);
|
|
191
|
+
console.log(`${'═'.repeat(65)}\n`);
|
|
192
|
+
|
|
193
|
+
// Highlight risky skills
|
|
194
|
+
const risky = skills.filter(r => r.verdict !== 'PASS');
|
|
195
|
+
if (risky.length > 0) {
|
|
196
|
+
console.log(`⚠️ Skills needing attention:\n`);
|
|
197
|
+
for (const r of risky) {
|
|
198
|
+
console.log(` ${r.name} (${r.score}/100):`);
|
|
199
|
+
for (const f of r.findings.slice(0, 3)) {
|
|
200
|
+
console.log(` - [${f.severity}] ${f.rule}: ${f.message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
console.log('');
|
|
204
|
+
}
|
|
94
205
|
}
|
|
95
206
|
|
|
96
207
|
/**
|
|
97
|
-
*
|
|
208
|
+
* Save scan report to log file
|
|
98
209
|
*/
|
|
210
|
+
function saveReport(result, command) {
|
|
211
|
+
const stateDir = path.join(os.homedir(), '.clawhub-guard');
|
|
212
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
213
|
+
|
|
214
|
+
const logFile = path.join(stateDir, 'scan-history.jsonl');
|
|
215
|
+
const entry = {
|
|
216
|
+
timestamp: new Date().toISOString(),
|
|
217
|
+
command,
|
|
218
|
+
score: result.score,
|
|
219
|
+
verdict: result.verdict,
|
|
220
|
+
target: result.target || result.sourceUrl || 'unknown',
|
|
221
|
+
findingsCount: (result.findings || []).length,
|
|
222
|
+
summary: result.summary || null,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
fs.appendFileSync(logFile, JSON.stringify(entry) + '\n', 'utf-8');
|
|
226
|
+
return logFile;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getSkillInstallPath(skillName) {
|
|
230
|
+
const workspace = process.env.OPENCLAW_WORKSPACE_DIR ||
|
|
231
|
+
path.join(os.homedir(), '.openclaw', 'workspace');
|
|
232
|
+
return path.join(workspace, 'skills', skillName);
|
|
233
|
+
}
|
|
234
|
+
|
|
99
235
|
function isSkillInstalled(skillName) {
|
|
100
|
-
|
|
101
|
-
return fs.existsSync(installPath);
|
|
236
|
+
return fs.existsSync(getSkillInstallPath(skillName));
|
|
102
237
|
}
|
|
103
238
|
|
|
104
|
-
module.exports = {
|
|
239
|
+
module.exports = {
|
|
240
|
+
scanLocal,
|
|
241
|
+
scanUrl,
|
|
242
|
+
auditAll,
|
|
243
|
+
printAuditReport,
|
|
244
|
+
saveReport,
|
|
245
|
+
getSkillInstallPath,
|
|
246
|
+
isSkillInstalled
|
|
247
|
+
};
|