agent-security-scanner-mcp 3.9.0 → 3.10.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 +119 -4
- package/index.js +81 -1
- package/openclaw.plugin.json +41 -0
- package/package.json +4 -1
- package/regex_fallback.py +3 -1
- package/rules/clawhavoc.yaml +443 -0
- package/src/cli/audit.js +18 -0
- package/src/cli/harden.js +15 -0
- package/src/context.js +4 -0
- package/src/daemon-client.js +10 -0
- package/src/plugin-config.js +77 -0
- package/src/plugin-health.js +49 -0
- package/src/tools/scan-security.js +32 -5
- package/src/tools/scan-skill.js +743 -0
- package/src/utils.js +58 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
// src/tools/scan-skill.js — 6-layer deep scanner for OpenClaw skills
|
|
2
|
+
// Orchestrates prompt scanning, code analysis, ClawHavoc signatures,
|
|
3
|
+
// supply chain verification, and rug pull detection.
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
|
|
7
|
+
import { resolve, basename, dirname, extname, join, sep } from "path";
|
|
8
|
+
import { createHash } from "crypto";
|
|
9
|
+
import { tmpdir, homedir } from "os";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
import { scanAgentPrompt } from './scan-prompt.js';
|
|
12
|
+
import { scanAgentAction } from './scan-action.js';
|
|
13
|
+
import { runAnalyzerAsync } from '../utils.js';
|
|
14
|
+
import { isHallucinated } from './check-package.js';
|
|
15
|
+
|
|
16
|
+
// Handle both ESM and CJS bundling
|
|
17
|
+
let __dirname;
|
|
18
|
+
try {
|
|
19
|
+
__dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
} catch {
|
|
21
|
+
__dirname = process.cwd();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Schema
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export const scanSkillSchema = {
|
|
29
|
+
skill_path: z.string().describe("Path to skill directory or SKILL.md file"),
|
|
30
|
+
verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe("Response detail level"),
|
|
31
|
+
baseline: z.boolean().optional().describe("Save current scan as baseline for rug pull detection"),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Constants
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const LANG_EXT_MAP = {
|
|
39
|
+
javascript: 'js', js: 'js',
|
|
40
|
+
python: 'py', py: 'py',
|
|
41
|
+
typescript: 'ts', ts: 'ts',
|
|
42
|
+
ruby: 'rb', rb: 'rb',
|
|
43
|
+
go: 'go',
|
|
44
|
+
java: 'java',
|
|
45
|
+
php: 'php',
|
|
46
|
+
c: 'c',
|
|
47
|
+
cpp: 'cpp',
|
|
48
|
+
rust: 'rs', rs: 'rs',
|
|
49
|
+
csharp: 'cs', cs: 'cs',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const CODE_FILE_EXTENSIONS = new Set([
|
|
53
|
+
'.js', '.py', '.ts', '.tsx', '.jsx', '.rb', '.go',
|
|
54
|
+
'.java', '.php', '.c', '.cpp', '.rs', '.cs', '.h', '.hpp',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const MAX_FILE_SIZE = 500 * 1024; // 500 KB
|
|
58
|
+
const MAX_SUPPORTING_FILES = 20;
|
|
59
|
+
const SCAN_TIMEOUT_MS = 120_000; // 120s total scan timeout
|
|
60
|
+
|
|
61
|
+
const PYTHON_BUILTINS = new Set([
|
|
62
|
+
'os', 'sys', 'socket', 'json', 're', 'math', 'time', 'datetime',
|
|
63
|
+
'random', 'hashlib', 'base64', 'struct', 'io', 'collections',
|
|
64
|
+
'itertools', 'functools', 'operator', 'string', 'textwrap',
|
|
65
|
+
'unicodedata', 'difflib', 'typing', 'abc', 'contextlib',
|
|
66
|
+
'decimal', 'fractions', 'statistics', 'pathlib', 'tempfile',
|
|
67
|
+
'glob', 'fnmatch', 'shutil', 'pickle', 'shelve', 'sqlite3',
|
|
68
|
+
'csv', 'configparser', 'argparse', 'logging', 'warnings',
|
|
69
|
+
'traceback', 'threading', 'multiprocessing', 'subprocess',
|
|
70
|
+
'asyncio', 'concurrent', 'signal', 'copy', 'pprint', 'enum',
|
|
71
|
+
'dataclasses', 'inspect', 'dis', 'ast', 'token', 'tokenize',
|
|
72
|
+
'urllib', 'http', 'email', 'html', 'xml', 'webbrowser',
|
|
73
|
+
'unittest', 'doctest', 'pdb', 'profile', 'timeit',
|
|
74
|
+
'platform', 'errno', 'ctypes', 'array', 'queue', 'heapq',
|
|
75
|
+
'bisect', 'weakref', 'types', 'importlib', 'pkgutil',
|
|
76
|
+
'zipfile', 'tarfile', 'gzip', 'bz2', 'lzma', 'zlib',
|
|
77
|
+
'ssl', 'select', 'selectors', 'mmap', 'codecs',
|
|
78
|
+
'builtins', '__future__', 'site', 'sysconfig',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const NODE_BUILTINS = new Set([
|
|
82
|
+
'fs', 'path', 'crypto', 'http', 'https', 'net', 'os', 'url',
|
|
83
|
+
'util', 'stream', 'events', 'buffer', 'child_process', 'cluster',
|
|
84
|
+
'dgram', 'dns', 'domain', 'querystring', 'readline', 'repl',
|
|
85
|
+
'string_decoder', 'tls', 'tty', 'v8', 'vm', 'zlib', 'assert',
|
|
86
|
+
'async_hooks', 'console', 'constants', 'module', 'perf_hooks',
|
|
87
|
+
'process', 'punycode', 'timers', 'worker_threads', 'wasi',
|
|
88
|
+
'diagnostics_channel', 'inspector', 'trace_events',
|
|
89
|
+
'node:fs', 'node:path', 'node:crypto', 'node:http', 'node:https',
|
|
90
|
+
'node:net', 'node:os', 'node:url', 'node:util', 'node:stream',
|
|
91
|
+
'node:events', 'node:buffer', 'node:child_process', 'node:cluster',
|
|
92
|
+
'node:dgram', 'node:dns', 'node:querystring', 'node:readline',
|
|
93
|
+
'node:string_decoder', 'node:tls', 'node:tty', 'node:v8', 'node:vm',
|
|
94
|
+
'node:zlib', 'node:assert', 'node:async_hooks', 'node:console',
|
|
95
|
+
'node:module', 'node:perf_hooks', 'node:process', 'node:timers',
|
|
96
|
+
'node:worker_threads', 'node:diagnostics_channel', 'node:test',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const SOURCE_WEIGHTS = {
|
|
100
|
+
code_analysis: 3.0,
|
|
101
|
+
clawhavoc: 2.5,
|
|
102
|
+
rug_pull: 3.0,
|
|
103
|
+
prompt_scanner: 2.0,
|
|
104
|
+
supply_chain: 2.0,
|
|
105
|
+
action_scanner: 2.0,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const SEVERITY_MULTIPLIER = { CRITICAL: 4, HIGH: 2, MEDIUM: 1 };
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Layer 4: ClawHavoc YAML loader (cached)
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
let _clawHavocRules = null;
|
|
115
|
+
|
|
116
|
+
function loadClawHavocRules() {
|
|
117
|
+
if (_clawHavocRules !== null) return _clawHavocRules;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const rulesPath = join(__dirname, '..', '..', 'rules', 'clawhavoc.yaml');
|
|
121
|
+
if (!existsSync(rulesPath)) {
|
|
122
|
+
_clawHavocRules = [];
|
|
123
|
+
return _clawHavocRules;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const yaml = readFileSync(rulesPath, 'utf-8');
|
|
127
|
+
const rules = [];
|
|
128
|
+
const ruleBlocks = yaml.split(/^ - id:/m).slice(1);
|
|
129
|
+
|
|
130
|
+
for (const block of ruleBlocks) {
|
|
131
|
+
const lines = (' - id:' + block).split('\n');
|
|
132
|
+
const rule = {
|
|
133
|
+
id: '',
|
|
134
|
+
severity: 'WARNING',
|
|
135
|
+
message: '',
|
|
136
|
+
patterns: [],
|
|
137
|
+
metadata: {},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
let inPatterns = false;
|
|
141
|
+
let inMetadata = false;
|
|
142
|
+
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
if (line.match(/^\s+- id:\s*/)) {
|
|
145
|
+
rule.id = line.replace(/^\s+- id:\s*/, '').trim();
|
|
146
|
+
} else if (line.match(/^\s+severity:\s*/)) {
|
|
147
|
+
rule.severity = line.replace(/^\s+severity:\s*/, '').trim();
|
|
148
|
+
} else if (line.match(/^\s+message:\s*/)) {
|
|
149
|
+
rule.message = line.replace(/^\s+message:\s*["']?/, '').replace(/["']$/, '').trim();
|
|
150
|
+
} else if (line.match(/^\s+patterns:\s*$/)) {
|
|
151
|
+
inPatterns = true;
|
|
152
|
+
inMetadata = false;
|
|
153
|
+
} else if (line.match(/^\s+metadata:\s*$/)) {
|
|
154
|
+
inPatterns = false;
|
|
155
|
+
inMetadata = true;
|
|
156
|
+
} else if (inPatterns && line.match(/^\s+- /)) {
|
|
157
|
+
let pattern = line.replace(/^\s+- /, '').trim();
|
|
158
|
+
pattern = pattern.replace(/^["']|["']$/g, '');
|
|
159
|
+
pattern = pattern.replace(/^\(\?i\)/, '');
|
|
160
|
+
pattern = pattern.replace(/\\\\/g, '\\');
|
|
161
|
+
if (pattern) rule.patterns.push(pattern);
|
|
162
|
+
} else if (inMetadata && line.match(/^\s+\w+:/)) {
|
|
163
|
+
const match = line.match(/^\s+(\w+):\s*["']?([^"'\n]+)["']?/);
|
|
164
|
+
if (match) {
|
|
165
|
+
rule.metadata[match[1]] = match[2].trim();
|
|
166
|
+
}
|
|
167
|
+
} else if (line.match(/^\s+languages:/)) {
|
|
168
|
+
inPatterns = false;
|
|
169
|
+
inMetadata = false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (rule.id && rule.patterns.length > 0) {
|
|
174
|
+
rules.push(rule);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_clawHavocRules = rules;
|
|
179
|
+
return _clawHavocRules;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.error("Error loading ClawHavoc rules:", error.message);
|
|
182
|
+
_clawHavocRules = [];
|
|
183
|
+
return _clawHavocRules;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Layer 1: Prompt Scan
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
async function runPromptScan(content) {
|
|
192
|
+
try {
|
|
193
|
+
const result = await scanAgentPrompt({ prompt_text: content, verbosity: 'full' });
|
|
194
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
195
|
+
return (parsed.findings || []).map(f => ({
|
|
196
|
+
category: f.category || 'prompt_injection',
|
|
197
|
+
severity: f.severity === 'ERROR' ? 'CRITICAL' : f.severity === 'WARNING' ? 'HIGH' : 'MEDIUM',
|
|
198
|
+
message: f.message,
|
|
199
|
+
matched_text: (f.matched_text || '').substring(0, 200),
|
|
200
|
+
file: 'SKILL.md',
|
|
201
|
+
source: 'prompt_scanner',
|
|
202
|
+
rule_id: f.rule_id || '',
|
|
203
|
+
confidence: f.confidence || 'MEDIUM',
|
|
204
|
+
}));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.error("Layer 1 (prompt scan) failed:", error.message);
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Layer 2: Code Block Extraction + Scan
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
function extractCodeBlocks(content) {
|
|
216
|
+
const blocks = [];
|
|
217
|
+
const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g;
|
|
218
|
+
let match;
|
|
219
|
+
while ((match = codeBlockRegex.exec(content)) !== null) {
|
|
220
|
+
const lang = (match[1] || '').toLowerCase();
|
|
221
|
+
const code = match[2];
|
|
222
|
+
if (code.length < 10) continue;
|
|
223
|
+
blocks.push({ lang, code });
|
|
224
|
+
}
|
|
225
|
+
return blocks;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function runCodeBlockScan(blocks) {
|
|
229
|
+
const findings = [];
|
|
230
|
+
|
|
231
|
+
for (const { lang, code } of blocks) {
|
|
232
|
+
try {
|
|
233
|
+
// Shell blocks -> scanAgentAction
|
|
234
|
+
if (['bash', 'sh', 'shell', 'zsh'].includes(lang)) {
|
|
235
|
+
const result = await scanAgentAction({
|
|
236
|
+
action_type: 'bash',
|
|
237
|
+
action_value: code,
|
|
238
|
+
verbosity: 'full',
|
|
239
|
+
});
|
|
240
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
241
|
+
for (const f of (parsed.findings || [])) {
|
|
242
|
+
findings.push({
|
|
243
|
+
category: 'code_execution',
|
|
244
|
+
severity: f.severity || 'HIGH',
|
|
245
|
+
message: f.message,
|
|
246
|
+
matched_text: (code).substring(0, 200),
|
|
247
|
+
file: `code_block:${lang}`,
|
|
248
|
+
source: 'action_scanner',
|
|
249
|
+
rule_id: f.rule || '',
|
|
250
|
+
confidence: 'HIGH',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Programming language blocks -> runAnalyzerAsync via temp file
|
|
257
|
+
const ext = LANG_EXT_MAP[lang];
|
|
258
|
+
if (!ext) continue;
|
|
259
|
+
|
|
260
|
+
const tmpName = `skill-scan-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
|
261
|
+
const tmpPath = join(tmpdir(), tmpName);
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
writeFileSync(tmpPath, code, 'utf-8');
|
|
265
|
+
const issues = await runAnalyzerAsync(tmpPath);
|
|
266
|
+
if (Array.isArray(issues)) {
|
|
267
|
+
for (const issue of issues) {
|
|
268
|
+
findings.push({
|
|
269
|
+
category: issue.ruleId || 'code_vulnerability',
|
|
270
|
+
severity: issue.severity === 'error' ? 'HIGH' : issue.severity === 'warning' ? 'MEDIUM' : 'MEDIUM',
|
|
271
|
+
message: issue.message,
|
|
272
|
+
matched_text: (issue.line_content || '').substring(0, 200),
|
|
273
|
+
file: `code_block:${lang}`,
|
|
274
|
+
source: 'code_analysis',
|
|
275
|
+
rule_id: issue.ruleId || '',
|
|
276
|
+
confidence: 'HIGH',
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} finally {
|
|
281
|
+
try { unlinkSync(tmpPath); } catch { /* best effort cleanup */ }
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.error(`Layer 2 (code block scan) failed for ${lang}:`, error.message);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return findings;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Layer 3: Supporting Files
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
async function runSupportingFilesScan(skillDir, skillFile) {
|
|
296
|
+
const findings = [];
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const entries = readdirSync(skillDir);
|
|
300
|
+
let scannedCount = 0;
|
|
301
|
+
|
|
302
|
+
for (const entry of entries) {
|
|
303
|
+
if (scannedCount >= MAX_SUPPORTING_FILES) break;
|
|
304
|
+
|
|
305
|
+
const filePath = join(skillDir, entry);
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const stat = statSync(filePath);
|
|
309
|
+
if (!stat.isFile()) continue;
|
|
310
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
311
|
+
|
|
312
|
+
// Skip the SKILL.md itself — already scanned by L1/L2
|
|
313
|
+
if (resolve(filePath) === resolve(skillFile)) continue;
|
|
314
|
+
|
|
315
|
+
const ext = extname(entry).toLowerCase();
|
|
316
|
+
if (!CODE_FILE_EXTENSIONS.has(ext)) continue;
|
|
317
|
+
|
|
318
|
+
const issues = await runAnalyzerAsync(filePath);
|
|
319
|
+
scannedCount++;
|
|
320
|
+
if (Array.isArray(issues)) {
|
|
321
|
+
for (const issue of issues) {
|
|
322
|
+
findings.push({
|
|
323
|
+
category: issue.ruleId || 'code_vulnerability',
|
|
324
|
+
severity: issue.severity === 'error' ? 'HIGH' : issue.severity === 'warning' ? 'MEDIUM' : 'MEDIUM',
|
|
325
|
+
message: issue.message,
|
|
326
|
+
matched_text: (issue.line_content || '').substring(0, 200),
|
|
327
|
+
file: entry,
|
|
328
|
+
source: 'code_analysis',
|
|
329
|
+
rule_id: issue.ruleId || '',
|
|
330
|
+
confidence: 'HIGH',
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} catch (error) {
|
|
335
|
+
console.error(`Layer 3 (supporting file) failed for ${entry}:`, error.message);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
console.error("Layer 3 (supporting files scan) failed:", error.message);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return findings;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Layer 4: ClawHavoc Signature Matching
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
function runClawHavocScan(content, codeBlocks) {
|
|
350
|
+
const findings = [];
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
const rules = loadClawHavocRules();
|
|
354
|
+
// Concatenate all code block content for matching
|
|
355
|
+
const allCode = codeBlocks.map(b => b.code).join('\n');
|
|
356
|
+
const scanText = content + '\n' + allCode;
|
|
357
|
+
|
|
358
|
+
for (const rule of rules) {
|
|
359
|
+
let matched = false;
|
|
360
|
+
for (const pattern of rule.patterns) {
|
|
361
|
+
try {
|
|
362
|
+
const regex = new RegExp(pattern, 'im');
|
|
363
|
+
const match = scanText.match(regex);
|
|
364
|
+
if (match) {
|
|
365
|
+
findings.push({
|
|
366
|
+
category: rule.metadata.category || 'malware_signature',
|
|
367
|
+
severity: rule.severity || 'CRITICAL',
|
|
368
|
+
message: rule.message,
|
|
369
|
+
matched_text: match[0].substring(0, 200),
|
|
370
|
+
file: 'SKILL.md',
|
|
371
|
+
source: 'clawhavoc',
|
|
372
|
+
rule_id: rule.id,
|
|
373
|
+
confidence: rule.metadata.confidence || 'HIGH',
|
|
374
|
+
});
|
|
375
|
+
matched = true;
|
|
376
|
+
break; // One match per rule
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
// Skip invalid regex
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (matched) continue;
|
|
383
|
+
}
|
|
384
|
+
} catch (error) {
|
|
385
|
+
console.error("Layer 4 (ClawHavoc scan) failed:", error.message);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return findings;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Layer 5: Package Supply Chain
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
async function runSupplyChainScan(codeBlocks) {
|
|
396
|
+
const findings = [];
|
|
397
|
+
const checked = new Set();
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
for (const { lang, code } of codeBlocks) {
|
|
401
|
+
let packages = [];
|
|
402
|
+
let ecosystem = null;
|
|
403
|
+
|
|
404
|
+
// JS/TS imports
|
|
405
|
+
if (['javascript', 'js', 'typescript', 'ts'].includes(lang)) {
|
|
406
|
+
ecosystem = 'npm';
|
|
407
|
+
// require('pkg')
|
|
408
|
+
const requireMatches = code.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
|
|
409
|
+
for (const m of requireMatches) packages.push(m[1]);
|
|
410
|
+
// import ... from 'pkg'
|
|
411
|
+
const importFromMatches = code.matchAll(/import\s+(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g);
|
|
412
|
+
for (const m of importFromMatches) packages.push(m[1]);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Python imports
|
|
416
|
+
if (['python', 'py'].includes(lang)) {
|
|
417
|
+
ecosystem = 'pypi';
|
|
418
|
+
const importMatches = code.matchAll(/^\s*import\s+(\S+)/gm);
|
|
419
|
+
for (const m of importMatches) packages.push(m[1]);
|
|
420
|
+
const fromMatches = code.matchAll(/^\s*from\s+(\S+)\s+import/gm);
|
|
421
|
+
for (const m of fromMatches) packages.push(m[1]);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!ecosystem || packages.length === 0) continue;
|
|
425
|
+
|
|
426
|
+
for (let pkg of packages) {
|
|
427
|
+
// Skip relative imports
|
|
428
|
+
if (pkg.startsWith('.') || pkg.startsWith('/')) continue;
|
|
429
|
+
|
|
430
|
+
// Normalize package names
|
|
431
|
+
if (ecosystem === 'npm') {
|
|
432
|
+
// Scoped packages: @scope/name -> @scope/name
|
|
433
|
+
// Non-scoped: take first segment before /
|
|
434
|
+
if (pkg.startsWith('@')) {
|
|
435
|
+
const parts = pkg.split('/');
|
|
436
|
+
pkg = parts.length >= 2 ? `${parts[0]}/${parts[1]}` : pkg;
|
|
437
|
+
} else {
|
|
438
|
+
pkg = pkg.split('/')[0];
|
|
439
|
+
}
|
|
440
|
+
// Skip Node builtins
|
|
441
|
+
if (NODE_BUILTINS.has(pkg)) continue;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (ecosystem === 'pypi') {
|
|
445
|
+
// Take the top-level module name
|
|
446
|
+
pkg = pkg.split('.')[0];
|
|
447
|
+
// Skip Python builtins
|
|
448
|
+
if (PYTHON_BUILTINS.has(pkg)) continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const key = `${ecosystem}:${pkg}`;
|
|
452
|
+
if (checked.has(key)) continue;
|
|
453
|
+
checked.add(key);
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
const result = isHallucinated(pkg, ecosystem);
|
|
457
|
+
if (result.hallucinated) {
|
|
458
|
+
findings.push({
|
|
459
|
+
category: 'hallucinated_package',
|
|
460
|
+
severity: 'CRITICAL',
|
|
461
|
+
message: `Package "${pkg}" not found in ${ecosystem} registry — possible hallucinated or malicious dependency`,
|
|
462
|
+
matched_text: pkg,
|
|
463
|
+
file: `code_block:${lang}`,
|
|
464
|
+
source: 'supply_chain',
|
|
465
|
+
rule_id: `supply_chain.hallucinated.${ecosystem}`,
|
|
466
|
+
confidence: result.bloomFilter ? 'MEDIUM' : 'HIGH',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.error(`Layer 5 (supply chain) check failed for ${pkg}:`, error.message);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
} catch (error) {
|
|
475
|
+
console.error("Layer 5 (supply chain scan) failed:", error.message);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return findings;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
// Layer 6: Rug Pull Detection
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
function getBaselineDir() {
|
|
486
|
+
return join(homedir(), '.openclaw', '.scanner-baselines');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function getBaselinePath(skillDir) {
|
|
490
|
+
const name = basename(skillDir);
|
|
491
|
+
return join(getBaselineDir(), `${name}.json`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function computeHash(content) {
|
|
495
|
+
return createHash('sha256').update(content).digest('hex');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function runRugPullCheck(content, skillDir, saveBaseline) {
|
|
499
|
+
const findings = [];
|
|
500
|
+
const hash = computeHash(content);
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
const baselinePath = getBaselinePath(skillDir);
|
|
504
|
+
|
|
505
|
+
if (saveBaseline) {
|
|
506
|
+
// Save baseline
|
|
507
|
+
const baselineDir = getBaselineDir();
|
|
508
|
+
if (!existsSync(baselineDir)) {
|
|
509
|
+
mkdirSync(baselineDir, { recursive: true });
|
|
510
|
+
}
|
|
511
|
+
writeFileSync(baselinePath, JSON.stringify({
|
|
512
|
+
hash,
|
|
513
|
+
skill_path: skillDir,
|
|
514
|
+
saved_at: new Date().toISOString(),
|
|
515
|
+
content_length: content.length,
|
|
516
|
+
}, null, 2), 'utf-8');
|
|
517
|
+
} else if (existsSync(baselinePath)) {
|
|
518
|
+
// Compare against baseline
|
|
519
|
+
try {
|
|
520
|
+
const baseline = JSON.parse(readFileSync(baselinePath, 'utf-8'));
|
|
521
|
+
if (baseline.hash && baseline.hash !== hash) {
|
|
522
|
+
findings.push({
|
|
523
|
+
category: 'rug_pull',
|
|
524
|
+
severity: 'CRITICAL',
|
|
525
|
+
message: `SKILL.md content has changed since baseline was saved on ${baseline.saved_at || 'unknown date'} — possible rug pull attack`,
|
|
526
|
+
matched_text: `hash changed: ${baseline.hash.substring(0, 16)}... -> ${hash.substring(0, 16)}...`,
|
|
527
|
+
file: 'SKILL.md',
|
|
528
|
+
source: 'rug_pull',
|
|
529
|
+
rule_id: 'rug_pull.content_changed',
|
|
530
|
+
confidence: 'HIGH',
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
} catch {
|
|
534
|
+
// Corrupt baseline — ignore
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} catch (error) {
|
|
538
|
+
console.error("Layer 6 (rug pull check) failed:", error.message);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return { findings, hash };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// Deduplication
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
function deduplicateFindings(findings) {
|
|
549
|
+
const seen = new Set();
|
|
550
|
+
const unique = [];
|
|
551
|
+
|
|
552
|
+
for (const f of findings) {
|
|
553
|
+
const key = `${f.rule_id || f.message}::${f.file}`;
|
|
554
|
+
if (seen.has(key)) continue;
|
|
555
|
+
seen.add(key);
|
|
556
|
+
unique.push(f);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return unique;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Weighted Grade Calculation
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
function calculateGrade(findings) {
|
|
567
|
+
// Hard-fail: ClawHavoc, rug pull, critical prompt injection, or critical supply chain → F
|
|
568
|
+
for (const f of findings) {
|
|
569
|
+
if (f.source === 'clawhavoc') return 'F';
|
|
570
|
+
if (f.source === 'rug_pull') return 'F';
|
|
571
|
+
if (f.source === 'prompt_scanner' && f.severity === 'CRITICAL') return 'F';
|
|
572
|
+
if (f.source === 'supply_chain' && f.severity === 'CRITICAL') return 'F';
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Weighted model for remaining findings
|
|
576
|
+
let score = 0;
|
|
577
|
+
|
|
578
|
+
for (const f of findings) {
|
|
579
|
+
const weight = SOURCE_WEIGHTS[f.source] || 1.0;
|
|
580
|
+
const severityMul = SEVERITY_MULTIPLIER[f.severity] || 1;
|
|
581
|
+
const confidenceDiscount = f.confidence === 'LOW' ? 0.5 : 1.0;
|
|
582
|
+
score += weight * severityMul * confidenceDiscount;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (score === 0) return 'A';
|
|
586
|
+
if (score <= 2) return 'B';
|
|
587
|
+
if (score <= 6) return 'C';
|
|
588
|
+
if (score <= 12) return 'D';
|
|
589
|
+
return 'F';
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function generateRecommendation(grade) {
|
|
593
|
+
switch (grade) {
|
|
594
|
+
case 'F': return 'DO NOT INSTALL - This skill contains critical security threats that pose immediate risk';
|
|
595
|
+
case 'D': return 'CAUTION - This skill has notable security concerns that should be reviewed before installing';
|
|
596
|
+
case 'C': return 'MODERATE RISK - This skill has some findings that warrant review';
|
|
597
|
+
default: return 'OK to install';
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
// Main Orchestrator
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
export async function scanSkill({ skill_path, verbosity, baseline }) {
|
|
606
|
+
// Path resolution
|
|
607
|
+
const resolvedPath = resolve(skill_path);
|
|
608
|
+
|
|
609
|
+
// Path containment — only allow paths within cwd or ~/.openclaw/skills/
|
|
610
|
+
const cwd = process.cwd();
|
|
611
|
+
const openclawSkills = resolve(homedir(), '.openclaw', 'skills');
|
|
612
|
+
const isAllowed = resolvedPath === cwd || resolvedPath.startsWith(cwd + sep)
|
|
613
|
+
|| resolvedPath === openclawSkills || resolvedPath.startsWith(openclawSkills + sep);
|
|
614
|
+
if (!isAllowed) {
|
|
615
|
+
return {
|
|
616
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
617
|
+
error: "skill_path must be within the current working directory or ~/.openclaw/skills/",
|
|
618
|
+
skill_path: resolvedPath
|
|
619
|
+
}) }]
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (!existsSync(resolvedPath)) {
|
|
624
|
+
return {
|
|
625
|
+
content: [{ type: "text", text: JSON.stringify({ error: "Skill path not found", skill_path: resolvedPath }) }]
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const stat = statSync(resolvedPath);
|
|
630
|
+
let skillDir, skillFile;
|
|
631
|
+
|
|
632
|
+
if (stat.isDirectory()) {
|
|
633
|
+
skillDir = resolvedPath;
|
|
634
|
+
skillFile = resolve(resolvedPath, 'SKILL.md');
|
|
635
|
+
} else {
|
|
636
|
+
skillDir = dirname(resolvedPath);
|
|
637
|
+
skillFile = resolvedPath;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (!existsSync(skillFile)) {
|
|
641
|
+
return {
|
|
642
|
+
content: [{ type: "text", text: JSON.stringify({ error: "SKILL.md not found", checked: skillFile }) }]
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const content = readFileSync(skillFile, 'utf-8');
|
|
647
|
+
const codeBlocks = extractCodeBlocks(content);
|
|
648
|
+
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
// Execute layers with total timeout protection
|
|
651
|
+
// L1, L2, L3, L5 run in parallel. L4 and L6 are synchronous — run after.
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
const scanPromise = (async () => {
|
|
655
|
+
const [promptFindings, codeBlockFindings, supportingFindings, supplyChainFindings] =
|
|
656
|
+
await Promise.all([
|
|
657
|
+
runPromptScan(content), // L1
|
|
658
|
+
runCodeBlockScan(codeBlocks), // L2
|
|
659
|
+
runSupportingFilesScan(skillDir, skillFile), // L3
|
|
660
|
+
runSupplyChainScan(codeBlocks), // L5
|
|
661
|
+
]);
|
|
662
|
+
|
|
663
|
+
const clawHavocFindings = runClawHavocScan(content, codeBlocks); // L4 (sync)
|
|
664
|
+
const { findings: rugPullFindings, hash: contentHash } =
|
|
665
|
+
runRugPullCheck(content, skillDir, !!baseline); // L6 (sync)
|
|
666
|
+
|
|
667
|
+
return { promptFindings, codeBlockFindings, supportingFindings, clawHavocFindings, supplyChainFindings, rugPullFindings, contentHash };
|
|
668
|
+
})();
|
|
669
|
+
|
|
670
|
+
let timeoutId;
|
|
671
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
672
|
+
timeoutId = setTimeout(() => reject(new Error('Scan timed out after 120s')), SCAN_TIMEOUT_MS);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
let layerResults;
|
|
676
|
+
try {
|
|
677
|
+
layerResults = await Promise.race([scanPromise, timeoutPromise]);
|
|
678
|
+
} catch (error) {
|
|
679
|
+
clearTimeout(timeoutId);
|
|
680
|
+
return {
|
|
681
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
682
|
+
error: error.message,
|
|
683
|
+
skill_path: resolvedPath,
|
|
684
|
+
grade: 'F',
|
|
685
|
+
recommendation: 'Scan failed — could not complete analysis within time limit',
|
|
686
|
+
}, null, 2) }]
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
clearTimeout(timeoutId);
|
|
690
|
+
|
|
691
|
+
const { promptFindings, codeBlockFindings, supportingFindings, clawHavocFindings, supplyChainFindings, rugPullFindings, contentHash } = layerResults;
|
|
692
|
+
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// Merge, deduplicate, grade
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
const allFindings = deduplicateFindings([
|
|
698
|
+
...promptFindings,
|
|
699
|
+
...codeBlockFindings,
|
|
700
|
+
...supportingFindings,
|
|
701
|
+
...clawHavocFindings,
|
|
702
|
+
...supplyChainFindings,
|
|
703
|
+
...rugPullFindings,
|
|
704
|
+
]);
|
|
705
|
+
|
|
706
|
+
const grade = calculateGrade(allFindings);
|
|
707
|
+
const recommendation = generateRecommendation(grade);
|
|
708
|
+
|
|
709
|
+
const layersExecuted = {
|
|
710
|
+
prompt_scan: promptFindings.length,
|
|
711
|
+
code_blocks: codeBlockFindings.length,
|
|
712
|
+
supporting_files: supportingFindings.length,
|
|
713
|
+
clawhavoc: clawHavocFindings.length,
|
|
714
|
+
supply_chain: supplyChainFindings.length,
|
|
715
|
+
rug_pull: rugPullFindings.length,
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
// Build result based on verbosity
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
const level = verbosity || 'compact';
|
|
723
|
+
|
|
724
|
+
const result = {
|
|
725
|
+
skill_path: resolvedPath,
|
|
726
|
+
grade,
|
|
727
|
+
findings_count: allFindings.length,
|
|
728
|
+
recommendation,
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
if (level === 'full') {
|
|
732
|
+
result.content_hash = contentHash;
|
|
733
|
+
result.layers_executed = layersExecuted;
|
|
734
|
+
result.findings = allFindings;
|
|
735
|
+
} else if (level === 'compact') {
|
|
736
|
+
result.findings = allFindings;
|
|
737
|
+
}
|
|
738
|
+
// 'minimal' — omit findings array and layers_executed
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
742
|
+
};
|
|
743
|
+
}
|