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.
@@ -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
+ }