clawarmor 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/LICENSE +21 -0
- package/README.md +163 -0
- package/SECURITY.md +40 -0
- package/SKILL.md +87 -0
- package/cli.js +155 -0
- package/lib/audit.js +257 -0
- package/lib/checks/allowfrom.js +104 -0
- package/lib/checks/auth.js +63 -0
- package/lib/checks/channels.js +69 -0
- package/lib/checks/filesystem.js +87 -0
- package/lib/checks/gateway.js +177 -0
- package/lib/checks/hooks.js +33 -0
- package/lib/checks/tools.js +75 -0
- package/lib/checks/version.js +61 -0
- package/lib/compare.js +96 -0
- package/lib/config.js +48 -0
- package/lib/discovery.js +92 -0
- package/lib/fix.js +175 -0
- package/lib/output/colors.js +24 -0
- package/lib/output/formatter.js +212 -0
- package/lib/output/progress.js +27 -0
- package/lib/probes/gateway-probe.js +260 -0
- package/lib/scan.js +129 -0
- package/lib/scanner/file-scanner.js +69 -0
- package/lib/scanner/patterns.js +74 -0
- package/lib/scanner/skill-finder.js +58 -0
- package/lib/scanner/skill-md-scanner.js +121 -0
- package/lib/trend.js +180 -0
- package/lib/verify.js +149 -0
- package/package.json +29 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// ClawArmor v0.6 — Live gateway behavioral probes
|
|
2
|
+
// Uses ONLY Node.js built-ins: net, http, os, crypto
|
|
3
|
+
// All probes timeout at 2000ms. Fails gracefully if gateway not running.
|
|
4
|
+
|
|
5
|
+
import net from 'net';
|
|
6
|
+
import http from 'http';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
|
|
9
|
+
const TIMEOUT = 2000;
|
|
10
|
+
|
|
11
|
+
function tcpProbe(host, port) {
|
|
12
|
+
return new Promise(resolve => {
|
|
13
|
+
const sock = net.createConnection({ host, port });
|
|
14
|
+
const timer = setTimeout(() => { sock.destroy(); resolve(false); }, TIMEOUT);
|
|
15
|
+
sock.on('connect', () => { clearTimeout(timer); sock.destroy(); resolve(true); });
|
|
16
|
+
sock.on('error', () => { clearTimeout(timer); resolve(false); });
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function httpGet(url) {
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
const timer = setTimeout(() => resolve(null), TIMEOUT);
|
|
23
|
+
try {
|
|
24
|
+
http.get(url, res => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
let body = '';
|
|
27
|
+
res.on('data', d => { body += d; if (body.length > 8192) res.destroy(); });
|
|
28
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
|
29
|
+
res.on('error', () => resolve(null));
|
|
30
|
+
}).on('error', () => { clearTimeout(timer); resolve(null); });
|
|
31
|
+
} catch { clearTimeout(timer); resolve(null); }
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function httpOptions(url, origin) {
|
|
36
|
+
return new Promise(resolve => {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
const timer = setTimeout(() => resolve(null), TIMEOUT);
|
|
39
|
+
const opts = {
|
|
40
|
+
hostname: parsed.hostname, port: parsed.port, path: parsed.pathname || '/',
|
|
41
|
+
method: 'OPTIONS',
|
|
42
|
+
headers: { Origin: origin, 'Access-Control-Request-Method': 'GET' },
|
|
43
|
+
};
|
|
44
|
+
try {
|
|
45
|
+
const req = http.request(opts, res => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolve({ status: res.statusCode, headers: res.headers });
|
|
48
|
+
});
|
|
49
|
+
req.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
50
|
+
req.end();
|
|
51
|
+
} catch { clearTimeout(timer); resolve(null); }
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Minimal WebSocket handshake without auth token — check if server accepts it
|
|
56
|
+
function wsProbeNoAuth(host, port) {
|
|
57
|
+
return new Promise(resolve => {
|
|
58
|
+
const timer = setTimeout(() => { sock.destroy(); resolve('timeout'); }, TIMEOUT);
|
|
59
|
+
const key = Buffer.from(Math.random().toString(36)).toString('base64');
|
|
60
|
+
const sock = net.createConnection({ host, port });
|
|
61
|
+
|
|
62
|
+
sock.on('error', () => { clearTimeout(timer); resolve('error'); });
|
|
63
|
+
|
|
64
|
+
sock.on('connect', () => {
|
|
65
|
+
const handshake = [
|
|
66
|
+
`GET / HTTP/1.1`,
|
|
67
|
+
`Host: ${host}:${port}`,
|
|
68
|
+
`Upgrade: websocket`,
|
|
69
|
+
`Connection: Upgrade`,
|
|
70
|
+
`Sec-WebSocket-Key: ${key}`,
|
|
71
|
+
`Sec-WebSocket-Version: 13`,
|
|
72
|
+
`\r\n`,
|
|
73
|
+
].join('\r\n');
|
|
74
|
+
sock.write(handshake);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
let buf = '';
|
|
78
|
+
sock.on('data', data => {
|
|
79
|
+
buf += data.toString();
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
sock.destroy();
|
|
82
|
+
// 101 = upgrade accepted (WS open without auth)
|
|
83
|
+
if (buf.includes('HTTP/1.1 101') || buf.includes('HTTP/1.0 101')) {
|
|
84
|
+
resolve('accepted');
|
|
85
|
+
} else if (buf.includes('401') || buf.includes('403') || buf.includes('400')) {
|
|
86
|
+
resolve('rejected');
|
|
87
|
+
} else {
|
|
88
|
+
resolve('rejected');
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function probeGatewayLive(config, { host = '127.0.0.1', port: portOverride = null } = {}) {
|
|
95
|
+
const port = portOverride || config?.gateway?.port || 18789;
|
|
96
|
+
const results = [];
|
|
97
|
+
|
|
98
|
+
// ── PROBE 1: Is gateway actually running? ─────────────────────────────────
|
|
99
|
+
const running = await tcpProbe(host, port);
|
|
100
|
+
results.push({
|
|
101
|
+
id: 'probe.gateway_running',
|
|
102
|
+
severity: 'INFO',
|
|
103
|
+
passed: true,
|
|
104
|
+
title: running ? `Gateway running on port ${port}` : `Gateway not running on port ${port}`,
|
|
105
|
+
passedMsg: running ? `Gateway running on port ${port}` : `Gateway not running on port ${port}`,
|
|
106
|
+
live: true,
|
|
107
|
+
gatewayRunning: running,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (!running) {
|
|
111
|
+
// Skip remaining probes — gateway is not up
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── PROBE 2: Is gateway reachable on non-loopback interfaces? ────────────
|
|
116
|
+
const ifaces = os.networkInterfaces();
|
|
117
|
+
const nonLoopback = [];
|
|
118
|
+
for (const [, addrs] of Object.entries(ifaces)) {
|
|
119
|
+
for (const a of (addrs || [])) {
|
|
120
|
+
if (!a.internal && a.family === 'IPv4') nonLoopback.push(a.address);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const exposedOn = [];
|
|
125
|
+
for (const ip of nonLoopback) {
|
|
126
|
+
const reachable = await tcpProbe(ip, port);
|
|
127
|
+
if (reachable) exposedOn.push(ip);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (exposedOn.length > 0) {
|
|
131
|
+
results.push({
|
|
132
|
+
id: 'probe.network_exposed',
|
|
133
|
+
severity: 'HIGH',
|
|
134
|
+
passed: false,
|
|
135
|
+
title: `Gateway reachable on network interface(s): ${exposedOn.join(', ')}`,
|
|
136
|
+
description: `Live probe: gateway responds on non-loopback IP(s).\nEven if config says "loopback", the process is listening on 0.0.0.0.\nAnyone on your network can connect.\nExposed on: ${exposedOn.join(', ')}`,
|
|
137
|
+
fix: `openclaw config set gateway.bind loopback\nopenctl gateway restart`,
|
|
138
|
+
live: true,
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
results.push({
|
|
142
|
+
id: 'probe.network_exposed',
|
|
143
|
+
severity: 'HIGH',
|
|
144
|
+
passed: true,
|
|
145
|
+
passedMsg: 'Not reachable on network interfaces (probed live)',
|
|
146
|
+
live: true,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── PROBE 3: Does gateway require auth? (WebSocket probe) ─────────────────
|
|
151
|
+
const wsResult = await wsProbeNoAuth(host, port);
|
|
152
|
+
if (wsResult === 'accepted') {
|
|
153
|
+
results.push({
|
|
154
|
+
id: 'probe.ws_auth',
|
|
155
|
+
severity: 'CRITICAL',
|
|
156
|
+
passed: false,
|
|
157
|
+
title: 'Gateway WebSocket accepts connections without authentication',
|
|
158
|
+
description: `Live probe: WebSocket upgrade accepted without an auth token.\nAny local process (malicious scripts, browser tabs) can connect and\nissue tool calls to your agent with no credentials.\nAttack: malicious web page uses ws://${host}:${port} to hijack agent.`,
|
|
159
|
+
fix: `openclaw config set gateway.auth.mode token\nopenctl config set gateway.auth.token $(node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))")\nopenctl gateway restart`,
|
|
160
|
+
live: true,
|
|
161
|
+
});
|
|
162
|
+
} else {
|
|
163
|
+
results.push({
|
|
164
|
+
id: 'probe.ws_auth',
|
|
165
|
+
severity: 'CRITICAL',
|
|
166
|
+
passed: true,
|
|
167
|
+
passedMsg: wsResult === 'timeout'
|
|
168
|
+
? 'Authentication required (WebSocket probe timed out — auth likely blocking)'
|
|
169
|
+
: 'Authentication required (WebSocket probe confirmed)',
|
|
170
|
+
live: true,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── PROBE 4: Does /health leak sensitive data? ────────────────────────────
|
|
175
|
+
const healthRes = await httpGet(`http://${host}:${port}/health`);
|
|
176
|
+
if (healthRes) {
|
|
177
|
+
const LEAK_KEYS = ['botToken', 'password', 'apiKey', 'token', 'secret', 'credential', 'privateKey'];
|
|
178
|
+
const leakedFields = [];
|
|
179
|
+
try {
|
|
180
|
+
const parsed = JSON.parse(healthRes.body);
|
|
181
|
+
const flat = JSON.stringify(parsed);
|
|
182
|
+
for (const k of LEAK_KEYS) {
|
|
183
|
+
const re = new RegExp(`"${k}"\\s*:\\s*"[^"]{8,}"`, 'i');
|
|
184
|
+
if (re.test(flat)) leakedFields.push(k);
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Raw text response — check for token-like values
|
|
188
|
+
for (const k of LEAK_KEYS) {
|
|
189
|
+
if (new RegExp(`${k}[\"':\\s]+[A-Za-z0-9+/]{16,}`, 'i').test(healthRes.body)) {
|
|
190
|
+
leakedFields.push(k);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (leakedFields.length > 0) {
|
|
196
|
+
results.push({
|
|
197
|
+
id: 'probe.health_leak',
|
|
198
|
+
severity: 'MEDIUM',
|
|
199
|
+
passed: false,
|
|
200
|
+
title: `/health endpoint leaks sensitive fields: ${leakedFields.join(', ')}`,
|
|
201
|
+
description: `Live probe: GET /health returned plaintext values for: ${leakedFields.join(', ')}.\nAny local process can read these credentials without authentication.`,
|
|
202
|
+
fix: `File issue with OpenClaw to redact sensitive keys from /health.\nWorkaround: firewall /health with a local reverse proxy.`,
|
|
203
|
+
live: true,
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
results.push({
|
|
207
|
+
id: 'probe.health_leak',
|
|
208
|
+
severity: 'MEDIUM',
|
|
209
|
+
passed: true,
|
|
210
|
+
passedMsg: '/health endpoint does not leak sensitive data',
|
|
211
|
+
live: true,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
results.push({
|
|
216
|
+
id: 'probe.health_leak',
|
|
217
|
+
severity: 'MEDIUM',
|
|
218
|
+
passed: true,
|
|
219
|
+
passedMsg: '/health endpoint not reachable or not present',
|
|
220
|
+
live: true,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── PROBE 5: CORS headers ─────────────────────────────────────────────────
|
|
225
|
+
const evilOrigin = 'https://evil.example.com';
|
|
226
|
+
const corsRes = await httpOptions(`http://${host}:${port}/`, evilOrigin);
|
|
227
|
+
if (corsRes) {
|
|
228
|
+
const acao = (corsRes.headers['access-control-allow-origin'] || '').trim();
|
|
229
|
+
const corsOpen = acao === '*' || acao === evilOrigin;
|
|
230
|
+
if (corsOpen) {
|
|
231
|
+
results.push({
|
|
232
|
+
id: 'probe.cors',
|
|
233
|
+
severity: 'HIGH',
|
|
234
|
+
passed: false,
|
|
235
|
+
title: `CORS misconfigured — allows arbitrary origins (${acao})`,
|
|
236
|
+
description: `Live probe: OPTIONS with Origin: ${evilOrigin}\nreturned Access-Control-Allow-Origin: ${acao}.\nAny web page can make cross-origin requests to your gateway.\nAttack: malicious site reads agent responses via CORS.`,
|
|
237
|
+
fix: `openclaw config set gateway.cors.allowedOrigins '["http://localhost"]'\nopenctl gateway restart`,
|
|
238
|
+
live: true,
|
|
239
|
+
});
|
|
240
|
+
} else {
|
|
241
|
+
results.push({
|
|
242
|
+
id: 'probe.cors',
|
|
243
|
+
severity: 'HIGH',
|
|
244
|
+
passed: true,
|
|
245
|
+
passedMsg: acao ? `CORS restricted (allowed: ${acao})` : 'CORS not open to arbitrary origins',
|
|
246
|
+
live: true,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
results.push({
|
|
251
|
+
id: 'probe.cors',
|
|
252
|
+
severity: 'HIGH',
|
|
253
|
+
passed: true,
|
|
254
|
+
passedMsg: 'CORS probe: no OPTIONS response (not exposed or not applicable)',
|
|
255
|
+
live: true,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return results;
|
|
260
|
+
}
|
package/lib/scan.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { paint, severityColor } from './output/colors.js';
|
|
2
|
+
import { scanFile } from './scanner/file-scanner.js';
|
|
3
|
+
import { findInstalledSkills } from './scanner/skill-finder.js';
|
|
4
|
+
import { scanSkillMdFiles } from './scanner/skill-md-scanner.js';
|
|
5
|
+
|
|
6
|
+
const SEP = paint.dim('─'.repeat(52));
|
|
7
|
+
const HOME = process.env.HOME || '';
|
|
8
|
+
|
|
9
|
+
function short(p) { return p.replace(HOME,'~'); }
|
|
10
|
+
|
|
11
|
+
function box(title) {
|
|
12
|
+
const W=52, pad=W-2-title.length, l=Math.floor(pad/2), r=pad-l;
|
|
13
|
+
return [paint.dim('╔'+'═'.repeat(W-2)+'╗'),
|
|
14
|
+
paint.dim('║')+' '.repeat(l)+paint.bold(title)+' '.repeat(r)+paint.dim('║'),
|
|
15
|
+
paint.dim('╚'+'═'.repeat(W-2)+'╝')].join('\n');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runScan() {
|
|
19
|
+
console.log(''); console.log(box('ClawArmor Skill Scan v0.6')); console.log('');
|
|
20
|
+
console.log(` ${paint.dim('Scanning:')} Installed OpenClaw skills (code + SKILL.md)`);
|
|
21
|
+
console.log(` ${paint.dim('Started:')} ${new Date().toLocaleString('en-US',{dateStyle:'medium',timeStyle:'short'})}`);
|
|
22
|
+
console.log('');
|
|
23
|
+
|
|
24
|
+
const skills = findInstalledSkills();
|
|
25
|
+
if (!skills.length) {
|
|
26
|
+
console.log(` ${paint.dim('No installed skills found.')}`); console.log(''); return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const userSkills = skills.filter(s => !s.isBuiltin);
|
|
30
|
+
const builtinSkills = skills.filter(s => s.isBuiltin);
|
|
31
|
+
console.log(` ${paint.dim('Found')} ${paint.bold(String(skills.length))} ${paint.dim('skills')} ${paint.dim(`(${userSkills.length} user-installed, ${builtinSkills.length} built-in)`)}`);
|
|
32
|
+
console.log('');
|
|
33
|
+
|
|
34
|
+
let totalCritical = 0, totalHigh = 0;
|
|
35
|
+
const flagged = [];
|
|
36
|
+
|
|
37
|
+
for (const skill of skills) {
|
|
38
|
+
process.stdout.write(` ${skill.isBuiltin ? paint.dim('⊙') : paint.cyan('▶')} ${paint.bold(skill.name)}${paint.dim(skill.isBuiltin?' [built-in]':' [user]')}...`);
|
|
39
|
+
|
|
40
|
+
// Code findings (JS, py, sh, etc.)
|
|
41
|
+
const codeFindings = skill.files.flatMap(f => scanFile(f, skill.isBuiltin));
|
|
42
|
+
|
|
43
|
+
// SKILL.md instruction findings
|
|
44
|
+
const mdResults = scanSkillMdFiles(skill.files, skill.isBuiltin);
|
|
45
|
+
const mdFindings = mdResults.flatMap(r => r.findings);
|
|
46
|
+
|
|
47
|
+
const allFindings = [...codeFindings, ...mdFindings];
|
|
48
|
+
|
|
49
|
+
const critical = allFindings.filter(f => f.severity==='CRITICAL');
|
|
50
|
+
const high = allFindings.filter(f => f.severity==='HIGH');
|
|
51
|
+
const medium = allFindings.filter(f => f.severity==='MEDIUM');
|
|
52
|
+
const info = allFindings.filter(f => f.severity==='INFO'||f.severity==='LOW');
|
|
53
|
+
|
|
54
|
+
totalCritical += critical.length; totalHigh += high.length;
|
|
55
|
+
|
|
56
|
+
if (!allFindings.length) { process.stdout.write(` ${paint.green('✓ clean')}\n`); continue; }
|
|
57
|
+
|
|
58
|
+
const parts = [];
|
|
59
|
+
if (critical.length) parts.push(paint.red(`${critical.length} critical`));
|
|
60
|
+
if (high.length) parts.push(paint.yellow(`${high.length} high`));
|
|
61
|
+
if (medium.length) parts.push(paint.cyan(`${medium.length} medium`));
|
|
62
|
+
if (info.length) parts.push(paint.dim(`${info.length} info`));
|
|
63
|
+
process.stdout.write(` ${parts.join(', ')}\n`);
|
|
64
|
+
|
|
65
|
+
if (critical.length || high.length || medium.length) {
|
|
66
|
+
flagged.push({ skill, codeFindings, mdResults });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Detailed report for flagged skills
|
|
71
|
+
for (const {skill, codeFindings, mdResults} of flagged) {
|
|
72
|
+
console.log(''); console.log(SEP);
|
|
73
|
+
console.log(` ${paint.bold(skill.name)} ${paint.dim(short(skill.path))}`);
|
|
74
|
+
if (skill.isBuiltin) console.log(` ${paint.dim('ℹ Built-in skill — review only if recently updated or unexpected')}`);
|
|
75
|
+
else console.log(` ${paint.yellow('⚠')} ${paint.bold('Third-party skill — review carefully')}`);
|
|
76
|
+
console.log(SEP);
|
|
77
|
+
|
|
78
|
+
// Code findings
|
|
79
|
+
const codeFlagged = codeFindings.filter(f => ['CRITICAL','HIGH','MEDIUM'].includes(f.severity));
|
|
80
|
+
if (codeFlagged.length) {
|
|
81
|
+
console.log(`\n ${paint.dim('── Code Findings ──')}`);
|
|
82
|
+
for (const sev of ['CRITICAL','HIGH','MEDIUM','INFO','LOW']) {
|
|
83
|
+
for (const f of codeFindings.filter(x=>x.severity===sev)) {
|
|
84
|
+
console.log('');
|
|
85
|
+
console.log(` ${paint.red('✗')} ${(severityColor[sev]||paint.dim)('['+sev+']')} ${paint.bold(f.title)}`);
|
|
86
|
+
console.log(` ${paint.dim(f.description)}`);
|
|
87
|
+
if (f.note) console.log(` ${paint.dim('Note: '+f.note)}`);
|
|
88
|
+
for (const m of f.matches) {
|
|
89
|
+
console.log(` ${paint.dim('→')} ${paint.cyan(short(f.file)+':'+m.line)}`);
|
|
90
|
+
console.log(` ${paint.dim(m.snippet)}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// SKILL.md findings
|
|
97
|
+
if (mdResults.length) {
|
|
98
|
+
console.log(`\n ${paint.dim('── SKILL.md Instruction Findings ──')}`);
|
|
99
|
+
for (const { filePath, findings } of mdResults) {
|
|
100
|
+
console.log(` ${paint.dim(short(filePath))}`);
|
|
101
|
+
for (const f of findings) {
|
|
102
|
+
const sc = severityColor[f.severity] || paint.dim;
|
|
103
|
+
console.log('');
|
|
104
|
+
console.log(` ${paint.red('✗')} ${sc('['+f.severity+']')} ${paint.bold(f.title)}`);
|
|
105
|
+
console.log(` ${paint.dim(f.description)}`);
|
|
106
|
+
if (f.note) console.log(` ${paint.dim('Note: '+f.note)}`);
|
|
107
|
+
for (const m of f.matches) {
|
|
108
|
+
console.log(` ${paint.dim('→')} ${paint.cyan(short(filePath)+':'+m.line)}`);
|
|
109
|
+
console.log(` ${paint.dim(m.snippet)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(''); console.log(SEP);
|
|
117
|
+
if (!totalCritical && !totalHigh) {
|
|
118
|
+
console.log(` ${paint.green('✓')} No critical or high findings across ${skills.length} skills.`);
|
|
119
|
+
} else {
|
|
120
|
+
if (totalCritical) console.log(` ${paint.red('✗')} ${paint.bold(String(totalCritical))} CRITICAL — review immediately`);
|
|
121
|
+
if (totalHigh) console.log(` ${paint.yellow('✗')} ${paint.bold(String(totalHigh))} HIGH — review before next session`);
|
|
122
|
+
console.log('');
|
|
123
|
+
console.log(` ${paint.dim('ClawArmor scans ALL skill files (.js .sh .py .ts) + SKILL.md')}`);
|
|
124
|
+
console.log(` ${paint.dim('not just code — dangerous natural language instructions caught too.')}`);
|
|
125
|
+
}
|
|
126
|
+
console.log(` ${paint.dim('Run')} ${paint.cyan('clawarmor audit')} ${paint.dim('to check your config.')}`);
|
|
127
|
+
console.log('');
|
|
128
|
+
return totalCritical > 0 ? 1 : 0;
|
|
129
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { extname, basename } from 'path';
|
|
3
|
+
import { CRITICAL_PATTERNS, HIGH_PATTERNS, MEDIUM_PATTERNS,
|
|
4
|
+
SCANNABLE_EXTENSIONS, SKIP_EXTENSIONS, isSpawnInBinaryWrapper } from './patterns.js';
|
|
5
|
+
|
|
6
|
+
function getExt(p) { return extname(p).replace('.','').toLowerCase(); }
|
|
7
|
+
|
|
8
|
+
function findMatches(content, pattern) {
|
|
9
|
+
const lines = content.split('\n');
|
|
10
|
+
const matches = [];
|
|
11
|
+
const regex = new RegExp(pattern.regex.source, 'gi');
|
|
12
|
+
let m;
|
|
13
|
+
while ((m = regex.exec(content)) !== null) {
|
|
14
|
+
const lineNum = content.substring(0, m.index).split('\n').length;
|
|
15
|
+
const line = lines[lineNum-1]?.trim() || '';
|
|
16
|
+
if (/^\s*(\/\/|#|\*)/.test(line)) continue; // skip comments
|
|
17
|
+
matches.push({ line: lineNum, snippet: line.substring(0,120) });
|
|
18
|
+
if (matches.length >= 3) break;
|
|
19
|
+
}
|
|
20
|
+
return matches;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function scanFile(filePath, isBuiltin = false) {
|
|
24
|
+
const ext = getExt(filePath);
|
|
25
|
+
if (SKIP_EXTENSIONS.has(ext)) return [];
|
|
26
|
+
if (!SCANNABLE_EXTENSIONS.has(ext) && ext !== 'md' && ext !== '') return [];
|
|
27
|
+
|
|
28
|
+
let content;
|
|
29
|
+
try { content = readFileSync(filePath, 'utf8'); }
|
|
30
|
+
catch { return []; }
|
|
31
|
+
if (content.length > 500_000) return [];
|
|
32
|
+
|
|
33
|
+
const findings = [];
|
|
34
|
+
const allPatterns = [
|
|
35
|
+
...CRITICAL_PATTERNS.map(p => ({...p, severity:'CRITICAL'})),
|
|
36
|
+
...HIGH_PATTERNS.map(p => ({...p, severity:'HIGH'})),
|
|
37
|
+
...MEDIUM_PATTERNS.map(p => ({...p, severity:'MEDIUM'})),
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
for (const pattern of allPatterns) {
|
|
41
|
+
const matches = findMatches(content, pattern);
|
|
42
|
+
if (!matches.length) continue;
|
|
43
|
+
|
|
44
|
+
// Context-aware severity reduction for built-in skills
|
|
45
|
+
let severity = pattern.severity;
|
|
46
|
+
let note = null;
|
|
47
|
+
|
|
48
|
+
if (isBuiltin && pattern.builtinOk) {
|
|
49
|
+
severity = 'INFO';
|
|
50
|
+
note = 'Built-in skill — pattern is likely legitimate. Review only if recently updated.';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// spawnSync/exec in binary wrapper = legitimate (TTS, image tools)
|
|
54
|
+
if (['eval','child-process','exec-spawn','vm-run'].includes(pattern.id)) {
|
|
55
|
+
if (isSpawnInBinaryWrapper(filePath, matches[0]?.snippet || '')) {
|
|
56
|
+
severity = isBuiltin ? 'INFO' : 'LOW';
|
|
57
|
+
note = 'Pattern detected in binary wrapper context — likely legitimate subprocess call.';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// built-in skills: cap max severity at LOW for most patterns
|
|
62
|
+
if (isBuiltin && !pattern.builtinOk && severity === 'CRITICAL') severity = 'LOW';
|
|
63
|
+
if (isBuiltin && !pattern.builtinOk && severity === 'HIGH') severity = 'INFO';
|
|
64
|
+
|
|
65
|
+
findings.push({ patternId: pattern.id, severity, title: pattern.title,
|
|
66
|
+
description: pattern.description, file: filePath, matches, note });
|
|
67
|
+
}
|
|
68
|
+
return findings;
|
|
69
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// v0.5 — context-aware patterns, adversarially reviewed
|
|
2
|
+
|
|
3
|
+
export const CRITICAL_PATTERNS = [
|
|
4
|
+
{ id: 'eval', regex: /\beval\s*\((?!\s*\/\/)/, title: 'eval() usage',
|
|
5
|
+
description: 'Executes arbitrary code strings. Classic injection vector.',
|
|
6
|
+
contextDeny: [/\bcomment\b/, /example/i] },
|
|
7
|
+
{ id: 'new-function', regex: /new\s+Function\s*\(/, title: 'new Function()',
|
|
8
|
+
description: 'Equivalent to eval() — executes arbitrary code.' },
|
|
9
|
+
{ id: 'child-process', regex: /require\(['"`]child_process['"`]\)|from\s+['"`]child_process['"`]/,
|
|
10
|
+
title: 'child_process imported', description: 'Allows shell command execution.' },
|
|
11
|
+
{ id: 'pipe-to-shell', regex: /[`'"]\s*\|\s*(sh|bash|zsh|fish)\b/,
|
|
12
|
+
title: 'Pipe-to-shell pattern', description: 'curl|bash or wget|sh — classic RCE.' },
|
|
13
|
+
{ id: 'vm-run', regex: /vm\.(runInNewContext|runInThisContext)\s*\(/,
|
|
14
|
+
title: 'vm module code execution', description: 'Executes code in Node.js VM.' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export const HIGH_PATTERNS = [
|
|
18
|
+
{ id: 'credential-file', regex: /agent-accounts|\/\.openclaw\/.*token|credentials\/.*\.json/i,
|
|
19
|
+
title: 'Credential file path referenced',
|
|
20
|
+
description: 'May attempt to read API keys or bot tokens.',
|
|
21
|
+
builtinOk: true }, // gh-issues legitimately reads this — flag as INFO for builtins
|
|
22
|
+
{ id: 'ssh-key', regex: /\.ssh\/(id_rsa|id_ed25519|id_ecdsa|authorized_keys)/,
|
|
23
|
+
title: 'SSH key path referenced', description: 'May attempt SSH credential theft.' },
|
|
24
|
+
{ id: 'known-bad-domains',
|
|
25
|
+
regex: /webhook\.site|requestbin\.|pipedream\.net|beeceptor\.com|hookbin\.com/,
|
|
26
|
+
title: 'Known data-collection domain', description: 'Used for data interception/exfiltration.' },
|
|
27
|
+
{ id: 'exfil-combo', regex: /process\.env[\s\S]{0,200}(fetch|axios|http|request)\s*\(/,
|
|
28
|
+
title: 'Env vars + network call (exfil pattern)',
|
|
29
|
+
description: 'Reading env vars then making network calls — credential exfiltration pattern.' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const MEDIUM_PATTERNS = [
|
|
33
|
+
{ id: 'dynamic-require', regex: /require\s*\(\s*(?!['"`])[a-zA-Z_$][a-zA-Z0-9_$]*\s*\)/,
|
|
34
|
+
title: 'Dynamic require()', description: 'Cannot be statically analyzed — may load arbitrary modules.' },
|
|
35
|
+
{ id: 'large-base64', regex: /[A-Za-z0-9+\/]{150,}={0,2}/,
|
|
36
|
+
title: 'Large base64 blob (>150 chars)', description: 'May be obfuscated payload.' },
|
|
37
|
+
{ id: 'http-cleartext', regex: /fetch\s*\(\s*['"`]http:\/\/(?!localhost|127\.)/,
|
|
38
|
+
title: 'Cleartext HTTP outbound', description: 'Data sent unencrypted.' },
|
|
39
|
+
{ id: 'settimeout-encoded', regex: /setTimeout\s*\([^,)]*(?:atob|fromCharCode|unescape)/,
|
|
40
|
+
title: 'setTimeout with encoded callback', description: 'Evasion technique.' },
|
|
41
|
+
{ id: 'fromcharcode-obfuscation', regex: /String\.fromCharCode\s*\(\s*\d{2,3}\s*,\s*\d/,
|
|
42
|
+
title: 'String.fromCharCode obfuscation', description: 'Classic string obfuscation used in malicious code.' },
|
|
43
|
+
{ id: 'hex-obfuscation', regex: /\\x[0-9a-f]{2}\\x[0-9a-f]{2}\\x[0-9a-f]{2}/i,
|
|
44
|
+
title: 'Hex-encoded string sequence', description: 'Multiple hex escapes may indicate obfuscated payload.' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// Built-in skills (node_modules/openclaw): these patterns are OK — lower severity
|
|
48
|
+
export const BUILTIN_SAFE_PATTERN_IDS = new Set([
|
|
49
|
+
'credential-file', // gh-issues reads config for API key — legitimate
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
// Spawning a subprocess is OK in binary wrappers (TTS, image tools, etc.)
|
|
53
|
+
export function isSpawnInBinaryWrapper(filePath, snippet) {
|
|
54
|
+
const isBinPath = /\/bin\/[^/]+$/.test(filePath);
|
|
55
|
+
const hasTtsContext = /tts|speech|audio|whisper|sherpa|onnx/i.test(filePath + snippet);
|
|
56
|
+
const hasImageContext = /image|vision|ffmpeg|convert/i.test(filePath + snippet);
|
|
57
|
+
return isBinPath && (hasTtsContext || hasImageContext);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const ALLOWLISTED_DOMAINS = new Set([
|
|
61
|
+
'api.anthropic.com', 'api.openai.com', 'api.github.com',
|
|
62
|
+
'registry.npmjs.org', 'raw.githubusercontent.com',
|
|
63
|
+
'api.telegram.org', 'discord.com', 'slack.com',
|
|
64
|
+
'127.0.0.1', 'localhost', '::1',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
export const SCANNABLE_EXTENSIONS = new Set([
|
|
68
|
+
'js','ts','mjs','cjs','jsx','tsx','py','rb','sh','bash','zsh',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
export const SKIP_EXTENSIONS = new Set([
|
|
72
|
+
'png','jpg','jpeg','gif','webp','ico','svg','ttf','woff','woff2',
|
|
73
|
+
'zip','gz','tar','mp3','mp4','pdf','lock','sum',
|
|
74
|
+
]);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const HOME = homedir();
|
|
6
|
+
const BUILTIN_PATHS = new Set([
|
|
7
|
+
join(HOME, '.npm-global', 'lib', 'node_modules', 'openclaw', 'skills'),
|
|
8
|
+
'/opt/homebrew/lib/node_modules/openclaw/skills',
|
|
9
|
+
'/usr/local/lib/node_modules/openclaw/skills',
|
|
10
|
+
]);
|
|
11
|
+
const USER_PATHS = [
|
|
12
|
+
join(HOME, '.openclaw', 'skills'),
|
|
13
|
+
join(HOME, '.openclaw', 'workspace', 'skills'),
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function getAllFiles(dir, files = []) {
|
|
17
|
+
try {
|
|
18
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__') continue;
|
|
20
|
+
const fp = join(dir, e.name);
|
|
21
|
+
if (e.isDirectory()) getAllFiles(fp, files);
|
|
22
|
+
else files.push(fp);
|
|
23
|
+
}
|
|
24
|
+
} catch { /* permission denied */ }
|
|
25
|
+
return files;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function findInstalledSkills() {
|
|
29
|
+
const skills = [];
|
|
30
|
+
const seenNames = new Set();
|
|
31
|
+
|
|
32
|
+
// User-installed skills first (higher priority, higher severity)
|
|
33
|
+
for (const searchPath of USER_PATHS) {
|
|
34
|
+
if (!existsSync(searchPath)) continue;
|
|
35
|
+
for (const e of readdirSync(searchPath, { withFileTypes: true })) {
|
|
36
|
+
if (!e.isDirectory() || seenNames.has(e.name)) continue;
|
|
37
|
+
seenNames.add(e.name);
|
|
38
|
+
const skillPath = join(searchPath, e.name);
|
|
39
|
+
skills.push({ name: e.name, path: skillPath, files: getAllFiles(skillPath), isBuiltin: false });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Built-in skills (lower severity findings)
|
|
44
|
+
for (const searchPath of BUILTIN_PATHS) {
|
|
45
|
+
if (!existsSync(searchPath)) continue;
|
|
46
|
+
try {
|
|
47
|
+
for (const e of readdirSync(searchPath, { withFileTypes: true })) {
|
|
48
|
+
if (!e.isDirectory() || seenNames.has(e.name)) continue;
|
|
49
|
+
seenNames.add(e.name);
|
|
50
|
+
const skillPath = join(searchPath, e.name);
|
|
51
|
+
skills.push({ name: e.name, path: skillPath, files: getAllFiles(skillPath), isBuiltin: true });
|
|
52
|
+
}
|
|
53
|
+
} catch { continue; }
|
|
54
|
+
break; // Only scan one built-in path (first found = deduped)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return skills;
|
|
58
|
+
}
|