guard-scanner 2.0.0 → 3.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.
Files changed (55) hide show
  1. package/README.md +107 -64
  2. package/dist/__tests__/scanner.test.d.ts +10 -0
  3. package/dist/__tests__/scanner.test.d.ts.map +1 -0
  4. package/dist/__tests__/scanner.test.js +374 -0
  5. package/dist/__tests__/scanner.test.js.map +1 -0
  6. package/dist/cli.d.ts +10 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +189 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +18 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/ioc-db.d.ts +13 -0
  15. package/dist/ioc-db.d.ts.map +1 -0
  16. package/dist/ioc-db.js +130 -0
  17. package/dist/ioc-db.js.map +1 -0
  18. package/dist/patterns.d.ts +27 -0
  19. package/dist/patterns.d.ts.map +1 -0
  20. package/dist/patterns.js +92 -0
  21. package/dist/patterns.js.map +1 -0
  22. package/dist/quarantine.d.ts +18 -0
  23. package/dist/quarantine.d.ts.map +1 -0
  24. package/dist/quarantine.js +42 -0
  25. package/dist/quarantine.js.map +1 -0
  26. package/dist/scanner.d.ts +54 -0
  27. package/dist/scanner.d.ts.map +1 -0
  28. package/dist/scanner.js +1043 -0
  29. package/dist/scanner.js.map +1 -0
  30. package/dist/types.d.ts +165 -0
  31. package/dist/types.d.ts.map +1 -0
  32. package/dist/types.js +7 -0
  33. package/dist/types.js.map +1 -0
  34. package/hooks/guard-scanner/plugin.ts +101 -32
  35. package/openclaw.plugin.json +60 -0
  36. package/package.json +25 -9
  37. package/ts-src/__tests__/fixtures/clean-skill/SKILL.md +9 -0
  38. package/ts-src/__tests__/fixtures/compaction-skill/SKILL.md +11 -0
  39. package/ts-src/__tests__/fixtures/malicious-skill/SKILL.md +11 -0
  40. package/ts-src/__tests__/fixtures/malicious-skill/scripts/evil.js +25 -0
  41. package/ts-src/__tests__/fixtures/prompt-leakage-skill/SKILL.md +20 -0
  42. package/ts-src/__tests__/fixtures/prompt-leakage-skill/scripts/debug.js +4 -0
  43. package/ts-src/__tests__/scanner.test.ts +525 -0
  44. package/ts-src/cli.ts +171 -0
  45. package/ts-src/index.ts +15 -0
  46. package/ts-src/ioc-db.ts +131 -0
  47. package/ts-src/patterns.ts +104 -0
  48. package/ts-src/quarantine.ts +48 -0
  49. package/{src/scanner.js → ts-src/scanner.ts} +376 -383
  50. package/ts-src/types.ts +187 -0
  51. package/hooks/guard-scanner/handler.ts +0 -207
  52. package/src/cli.js +0 -149
  53. package/src/html-template.js +0 -239
  54. package/src/ioc-db.js +0 -54
  55. package/src/patterns.js +0 -190
@@ -0,0 +1,1043 @@
1
+ "use strict";
2
+ /**
3
+ * guard-scanner v3.0.0 — Core Scanner (TypeScript)
4
+ *
5
+ * Full TypeScript rewrite of guard-scanner v2.1.0 + hbg-scan features.
6
+ * Adds: Compaction Persistence check, Signature hash matching, typed interfaces.
7
+ *
8
+ * Zero dependencies. MIT License.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.GuardScanner = exports.VERSION = void 0;
45
+ const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
47
+ const crypto = __importStar(require("crypto"));
48
+ const ioc_db_js_1 = require("./ioc-db.js");
49
+ const patterns_js_1 = require("./patterns.js");
50
+ // ── Constants ───────────────────────────────────────────────────────────────
51
+ exports.VERSION = '3.0.0';
52
+ const THRESHOLDS_MAP = {
53
+ normal: { suspicious: 30, malicious: 80 },
54
+ strict: { suspicious: 20, malicious: 60 },
55
+ };
56
+ const SEVERITY_WEIGHTS = {
57
+ CRITICAL: 40, HIGH: 15, MEDIUM: 5, LOW: 2,
58
+ };
59
+ const CODE_EXTENSIONS = new Set([
60
+ '.js', '.ts', '.mjs', '.cjs', '.py', '.sh', '.bash', '.ps1',
61
+ '.rb', '.go', '.rs', '.php', '.pl',
62
+ ]);
63
+ const DOC_EXTENSIONS = new Set(['.md', '.txt', '.rst', '.adoc']);
64
+ const DATA_EXTENSIONS = new Set(['.json', '.yaml', '.yml', '.toml', '.xml', '.csv']);
65
+ const BINARY_EXTENSIONS = new Set([
66
+ '.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf',
67
+ '.eot', '.wasm', '.wav', '.mp3', '.mp4', '.webm', '.ogg', '.pdf',
68
+ '.zip', '.tar', '.gz', '.bz2', '.7z', '.exe', '.dll', '.so', '.dylib',
69
+ ]);
70
+ const GENERATED_REPORT_FILES = new Set([
71
+ 'guard-scanner-report.json', 'guard-scanner-report.html', 'guard-scanner.sarif',
72
+ ]);
73
+ // ── GuardScanner ────────────────────────────────────────────────────────────
74
+ class GuardScanner {
75
+ verbose;
76
+ selfExclude;
77
+ strict;
78
+ summaryOnly;
79
+ checkDeps;
80
+ thresholds;
81
+ findings = [];
82
+ stats = { scanned: 0, clean: 0, low: 0, suspicious: 0, malicious: 0 };
83
+ scannerDir;
84
+ ignoredSkills = new Set();
85
+ ignoredPatterns = new Set();
86
+ customRules = [];
87
+ constructor(options = {}) {
88
+ this.verbose = options.verbose ?? false;
89
+ this.selfExclude = options.selfExclude ?? false;
90
+ this.strict = options.strict ?? false;
91
+ this.summaryOnly = options.summaryOnly ?? false;
92
+ this.checkDeps = options.checkDeps ?? false;
93
+ this.scannerDir = path.resolve(__dirname);
94
+ this.thresholds = this.strict ? THRESHOLDS_MAP.strict : THRESHOLDS_MAP.normal;
95
+ if (options.plugins) {
96
+ for (const plugin of options.plugins) {
97
+ this.loadPlugin(plugin);
98
+ }
99
+ }
100
+ if (options.rulesFile) {
101
+ this.loadCustomRules(options.rulesFile);
102
+ }
103
+ }
104
+ // ── Plugin System ───────────────────────────────────────────────────────
105
+ loadPlugin(pluginPath) {
106
+ try {
107
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
108
+ const plugin = require(path.resolve(pluginPath));
109
+ if (plugin.patterns && Array.isArray(plugin.patterns)) {
110
+ for (const p of plugin.patterns) {
111
+ if (p.id && p.regex && p.severity && p.cat && p.desc) {
112
+ this.customRules.push(p);
113
+ }
114
+ }
115
+ if (!this.summaryOnly) {
116
+ console.log(`🔌 Plugin loaded: ${plugin.name || pluginPath} (${plugin.patterns.length} rule(s))`);
117
+ }
118
+ }
119
+ }
120
+ catch (e) {
121
+ console.error(`⚠️ Failed to load plugin ${pluginPath}: ${e.message}`);
122
+ }
123
+ }
124
+ loadCustomRules(rulesFile) {
125
+ try {
126
+ const content = fs.readFileSync(rulesFile, 'utf-8');
127
+ const rules = JSON.parse(content);
128
+ if (!Array.isArray(rules)) {
129
+ console.error('⚠️ Custom rules file must be a JSON array');
130
+ return;
131
+ }
132
+ for (const rule of rules) {
133
+ if (!rule.id || !rule.pattern || !rule.severity || !rule.cat || !rule.desc) {
134
+ console.error(`⚠️ Skipping invalid rule: ${JSON.stringify(rule).substring(0, 80)}`);
135
+ continue;
136
+ }
137
+ try {
138
+ const flags = rule.flags || 'gi';
139
+ this.customRules.push({
140
+ id: rule.id,
141
+ cat: rule.cat,
142
+ regex: new RegExp(rule.pattern, flags),
143
+ severity: rule.severity,
144
+ desc: rule.desc,
145
+ codeOnly: rule.codeOnly ?? false,
146
+ docOnly: rule.docOnly ?? false,
147
+ all: !rule.codeOnly && !rule.docOnly,
148
+ });
149
+ }
150
+ catch (e) {
151
+ console.error(`⚠️ Invalid regex in rule ${rule.id}: ${e.message}`);
152
+ }
153
+ }
154
+ if (!this.summaryOnly && this.customRules.length > 0) {
155
+ console.log(`📏 Loaded ${this.customRules.length} custom rule(s) from ${rulesFile}`);
156
+ }
157
+ }
158
+ catch (e) {
159
+ console.error(`⚠️ Failed to load custom rules: ${e.message}`);
160
+ }
161
+ }
162
+ // ── Ignore System ───────────────────────────────────────────────────────
163
+ loadIgnoreFile(scanDir) {
164
+ const ignorePaths = [
165
+ path.join(scanDir, '.guard-scanner-ignore'),
166
+ path.join(scanDir, '.guava-guard-ignore'),
167
+ ];
168
+ for (const ignorePath of ignorePaths) {
169
+ if (!fs.existsSync(ignorePath))
170
+ continue;
171
+ const lines = fs.readFileSync(ignorePath, 'utf-8').split('\n');
172
+ for (const line of lines) {
173
+ const trimmed = line.trim();
174
+ if (!trimmed || trimmed.startsWith('#'))
175
+ continue;
176
+ if (trimmed.startsWith('pattern:')) {
177
+ this.ignoredPatterns.add(trimmed.replace('pattern:', '').trim());
178
+ }
179
+ else {
180
+ this.ignoredSkills.add(trimmed);
181
+ }
182
+ }
183
+ if (this.verbose && (this.ignoredSkills.size || this.ignoredPatterns.size)) {
184
+ console.log(`📋 Loaded ignore file: ${this.ignoredSkills.size} skills, ${this.ignoredPatterns.size} patterns`);
185
+ }
186
+ break;
187
+ }
188
+ }
189
+ // ── Main Scan ─────────────────────────────────────────────────────────
190
+ scanDirectory(dir) {
191
+ if (!fs.existsSync(dir)) {
192
+ console.error(`❌ Directory not found: ${dir}`);
193
+ process.exit(2);
194
+ }
195
+ this.loadIgnoreFile(dir);
196
+ const skills = fs.readdirSync(dir).filter((f) => {
197
+ const p = path.join(dir, f);
198
+ return fs.statSync(p).isDirectory();
199
+ });
200
+ console.log(`\n🛡️ guard-scanner v${exports.VERSION}`);
201
+ console.log(`${'═'.repeat(54)}`);
202
+ console.log(`📂 Scanning: ${dir}`);
203
+ console.log(`📦 Skills found: ${skills.length}`);
204
+ if (this.strict)
205
+ console.log('⚡ Strict mode enabled');
206
+ console.log();
207
+ for (const skill of skills) {
208
+ const skillPath = path.join(dir, skill);
209
+ if (this.selfExclude && path.resolve(skillPath) === this.scannerDir) {
210
+ if (!this.summaryOnly)
211
+ console.log(`⏭️ ${skill} — SELF (excluded)`);
212
+ continue;
213
+ }
214
+ if (this.ignoredSkills.has(skill)) {
215
+ if (!this.summaryOnly)
216
+ console.log(`⏭️ ${skill} — IGNORED`);
217
+ continue;
218
+ }
219
+ this.scanSkill(skillPath, skill);
220
+ }
221
+ this.printSummary();
222
+ return this.findings;
223
+ }
224
+ // ── Skill Scanner ─────────────────────────────────────────────────────
225
+ scanSkill(skillPath, skillName) {
226
+ this.stats.scanned++;
227
+ const skillFindings = [];
228
+ // Check: Known typosquat
229
+ if (ioc_db_js_1.KNOWN_MALICIOUS.typosquats.includes(skillName.toLowerCase())) {
230
+ skillFindings.push({
231
+ severity: 'CRITICAL', id: 'KNOWN_TYPOSQUAT', cat: 'malicious-code',
232
+ desc: 'Known malicious/typosquat skill name', file: 'SKILL NAME',
233
+ });
234
+ }
235
+ // Scan all files
236
+ const files = this.getFiles(skillPath);
237
+ for (const file of files) {
238
+ const ext = path.extname(file).toLowerCase();
239
+ const relFile = path.relative(skillPath, file);
240
+ if (relFile.includes('node_modules') || relFile.startsWith('.git'))
241
+ continue;
242
+ if (BINARY_EXTENSIONS.has(ext))
243
+ continue;
244
+ let content;
245
+ try {
246
+ content = fs.readFileSync(file, 'utf-8');
247
+ }
248
+ catch {
249
+ continue;
250
+ }
251
+ if (content.length > 500_000)
252
+ continue;
253
+ const fileType = this.classifyFile(ext, relFile);
254
+ this.checkIoCs(content, relFile, skillFindings);
255
+ this.checkPatterns(content, relFile, fileType, skillFindings);
256
+ this.checkSignatures(content, file, skillFindings); // NEW: hbg-scan compatible
257
+ if (this.customRules.length > 0) {
258
+ this.checkPatterns(content, relFile, fileType, skillFindings, this.customRules);
259
+ }
260
+ // Secret detection (skip lock files)
261
+ const baseName = path.basename(relFile).toLowerCase();
262
+ const skipSecret = baseName.endsWith('-lock.json') || baseName === 'package-lock.json' ||
263
+ baseName === 'yarn.lock' || baseName === 'pnpm-lock.yaml' ||
264
+ baseName === '_meta.json' || baseName === '.package-lock.json';
265
+ if (fileType === 'code' && !skipSecret) {
266
+ this.checkHardcodedSecrets(content, relFile, skillFindings);
267
+ }
268
+ // JS data flow
269
+ if (['.js', '.mjs', '.cjs', '.ts'].includes(ext) && content.length < 200_000) {
270
+ this.checkJSDataFlow(content, relFile, skillFindings);
271
+ }
272
+ }
273
+ // Structural checks
274
+ this.checkStructure(skillPath, skillName, skillFindings);
275
+ if (this.checkDeps)
276
+ this.checkDependencies(skillPath, skillName, skillFindings);
277
+ this.checkHiddenFiles(skillPath, skillName, skillFindings);
278
+ this.checkCrossFile(skillPath, skillName, skillFindings);
279
+ this.checkSkillManifest(skillPath, skillName, skillFindings);
280
+ this.checkComplexity(skillPath, skillName, skillFindings);
281
+ this.checkConfigImpact(skillPath, skillName, skillFindings);
282
+ this.checkCompactionPersistence(skillPath, skillName, skillFindings); // NEW
283
+ // Filter & score
284
+ const filtered = skillFindings.filter(f => !this.ignoredPatterns.has(f.id));
285
+ const risk = this.calculateRisk(filtered);
286
+ const verdict = this.getVerdict(risk);
287
+ this.stats[verdict.stat]++;
288
+ if (!this.summaryOnly) {
289
+ console.log(`${verdict.icon} ${skillName} — ${verdict.label} (risk: ${risk})`);
290
+ if (this.verbose && filtered.length > 0) {
291
+ const byCat = {};
292
+ for (const f of filtered)
293
+ (byCat[f.cat] = byCat[f.cat] || []).push(f);
294
+ for (const [cat, findings] of Object.entries(byCat)) {
295
+ console.log(` 📁 ${cat}`);
296
+ for (const f of findings) {
297
+ const icon = f.severity === 'CRITICAL' ? '💀' : f.severity === 'HIGH' ? '🔴' : f.severity === 'MEDIUM' ? '🟡' : '⚪';
298
+ const loc = f.line ? `${f.file}:${f.line}` : f.file;
299
+ console.log(` ${icon} [${f.severity}] ${f.desc} — ${loc}`);
300
+ if (f.sample)
301
+ console.log(` └─ "${f.sample}"`);
302
+ }
303
+ }
304
+ }
305
+ }
306
+ if (filtered.length > 0) {
307
+ this.findings.push({ skill: skillName, risk, verdict: verdict.label, findings: filtered });
308
+ }
309
+ }
310
+ // ── Check Methods ─────────────────────────────────────────────────────
311
+ classifyFile(ext, relFile) {
312
+ if (CODE_EXTENSIONS.has(ext))
313
+ return 'code';
314
+ if (DOC_EXTENSIONS.has(ext))
315
+ return 'doc';
316
+ if (DATA_EXTENSIONS.has(ext))
317
+ return 'data';
318
+ const base = path.basename(relFile).toLowerCase();
319
+ if (base === 'skill.md' || base === 'readme.md')
320
+ return 'skill-doc';
321
+ return 'other';
322
+ }
323
+ checkIoCs(content, relFile, findings) {
324
+ const contentLower = content.toLowerCase();
325
+ for (const ip of ioc_db_js_1.KNOWN_MALICIOUS.ips) {
326
+ if (content.includes(ip)) {
327
+ findings.push({ severity: 'CRITICAL', id: 'IOC_IP', cat: 'malicious-code', desc: `Known malicious IP: ${ip}`, file: relFile });
328
+ }
329
+ }
330
+ for (const url of ioc_db_js_1.KNOWN_MALICIOUS.urls) {
331
+ if (contentLower.includes(url.toLowerCase())) {
332
+ findings.push({ severity: 'CRITICAL', id: 'IOC_URL', cat: 'malicious-code', desc: `Known malicious URL: ${url}`, file: relFile });
333
+ }
334
+ }
335
+ for (const domain of ioc_db_js_1.KNOWN_MALICIOUS.domains) {
336
+ const domainRegex = new RegExp(`(?:https?://|[\\s'"\`(]|^)${domain.replace(/\./g, '\\.')}`, 'gi');
337
+ if (domainRegex.test(content)) {
338
+ findings.push({ severity: 'HIGH', id: 'IOC_DOMAIN', cat: 'exfiltration', desc: `Suspicious domain: ${domain}`, file: relFile });
339
+ }
340
+ }
341
+ for (const fname of ioc_db_js_1.KNOWN_MALICIOUS.filenames) {
342
+ if (contentLower.includes(fname.toLowerCase())) {
343
+ findings.push({ severity: 'CRITICAL', id: 'IOC_FILE', cat: 'suspicious-download', desc: `Known malicious filename: ${fname}`, file: relFile });
344
+ }
345
+ }
346
+ for (const user of ioc_db_js_1.KNOWN_MALICIOUS.usernames) {
347
+ if (contentLower.includes(user.toLowerCase())) {
348
+ findings.push({ severity: 'HIGH', id: 'IOC_USER', cat: 'malicious-code', desc: `Known malicious username: ${user}`, file: relFile });
349
+ }
350
+ }
351
+ }
352
+ checkPatterns(content, relFile, fileType, findings, patterns = patterns_js_1.PATTERNS) {
353
+ for (const pattern of patterns) {
354
+ if (pattern.codeOnly && fileType !== 'code')
355
+ continue;
356
+ if (pattern.docOnly && fileType !== 'doc' && fileType !== 'skill-doc')
357
+ continue;
358
+ if (!pattern.all && !pattern.codeOnly && !pattern.docOnly)
359
+ continue;
360
+ pattern.regex.lastIndex = 0;
361
+ const matches = content.match(pattern.regex);
362
+ if (!matches)
363
+ continue;
364
+ pattern.regex.lastIndex = 0;
365
+ const idx = content.search(pattern.regex);
366
+ const lineNum = idx >= 0 ? content.substring(0, idx).split('\n').length : undefined;
367
+ let adjustedSeverity = pattern.severity;
368
+ if ((fileType === 'doc' || fileType === 'skill-doc') && pattern.all && !pattern.docOnly) {
369
+ if (adjustedSeverity === 'HIGH')
370
+ adjustedSeverity = 'MEDIUM';
371
+ else if (adjustedSeverity === 'MEDIUM')
372
+ adjustedSeverity = 'LOW';
373
+ }
374
+ findings.push({
375
+ severity: adjustedSeverity, id: pattern.id, cat: pattern.cat,
376
+ desc: pattern.desc, file: relFile, line: lineNum,
377
+ matchCount: matches.length, sample: matches[0].substring(0, 80),
378
+ });
379
+ }
380
+ }
381
+ /** NEW: hbg-scan compatible signature matching (hash + pattern + domain) */
382
+ checkSignatures(content, filePath, findings) {
383
+ const contentHash = crypto.createHash('sha256').update(content).digest('hex');
384
+ const relFile = path.basename(filePath);
385
+ for (const sig of ioc_db_js_1.SIGNATURES_DB.signatures) {
386
+ // Hash match
387
+ if (sig.hash && sig.hash === contentHash) {
388
+ findings.push({
389
+ severity: sig.severity, id: `SIG_${sig.id}`, cat: 'signature-match',
390
+ desc: `[${sig.id}] ${sig.name} — exact hash match`, file: relFile,
391
+ });
392
+ continue;
393
+ }
394
+ // Pattern match
395
+ if (sig.patterns) {
396
+ for (const pat of sig.patterns) {
397
+ if (content.includes(pat)) {
398
+ const idx = content.indexOf(pat);
399
+ const lineNum = content.substring(0, idx).split('\n').length;
400
+ findings.push({
401
+ severity: sig.severity, id: `SIG_${sig.id}`, cat: 'signature-match',
402
+ desc: `[${sig.id}] ${sig.name}`, file: relFile, line: lineNum,
403
+ sample: content.split('\n')[lineNum - 1]?.trim().substring(0, 120),
404
+ });
405
+ break; // One finding per sig per file
406
+ }
407
+ }
408
+ }
409
+ // Domain match
410
+ if (sig.domains) {
411
+ for (const domain of sig.domains) {
412
+ if (content.includes(domain)) {
413
+ const idx = content.indexOf(domain);
414
+ const lineNum = content.substring(0, idx).split('\n').length;
415
+ findings.push({
416
+ severity: sig.severity, id: `SIG_${sig.id}`, cat: 'signature-match',
417
+ desc: `[${sig.id}] Suspicious domain: ${domain}`, file: relFile, line: lineNum,
418
+ });
419
+ }
420
+ }
421
+ }
422
+ }
423
+ }
424
+ /** NEW: Compaction Layer Persistence check (hbg-scan Check 5) */
425
+ checkCompactionPersistence(skillPath, skillName, findings) {
426
+ const files = this.getFiles(skillPath);
427
+ for (const file of files) {
428
+ const ext = path.extname(file).toLowerCase();
429
+ if (BINARY_EXTENSIONS.has(ext))
430
+ continue;
431
+ const relFile = path.relative(skillPath, file);
432
+ if (relFile.includes('node_modules') || relFile.startsWith('.git'))
433
+ continue;
434
+ let content;
435
+ try {
436
+ content = fs.readFileSync(file, 'utf-8');
437
+ }
438
+ catch {
439
+ continue;
440
+ }
441
+ if (content.length > 500_000)
442
+ continue;
443
+ // Post-compaction audit patterns
444
+ const compactionPatterns = [
445
+ { regex: /post-?compaction\s+audit/gi, label: 'Post-compaction audit trigger', severity: 'CRITICAL' },
446
+ { regex: /WORKFLOW_AUTO/g, label: 'WORKFLOW_AUTO marker', severity: 'CRITICAL' },
447
+ { regex: /⚠️\s*post-?compaction/gi, label: 'Post-compaction emoji warning', severity: 'CRITICAL' },
448
+ { regex: /after\s+compaction/gi, label: 'After-compaction trigger', severity: 'HIGH' },
449
+ { regex: /survive\s+compaction/gi, label: 'Compaction survival pattern', severity: 'HIGH' },
450
+ { regex: /HEARTBEAT\.md/g, label: 'HEARTBEAT.md reference', severity: 'HIGH' },
451
+ { regex: /BOOTSTRAP\.md/g, label: 'BOOTSTRAP.md reference', severity: 'HIGH' },
452
+ { regex: /persistent\s+instructions/gi, label: 'Persistent instructions pattern', severity: 'HIGH' },
453
+ { regex: /setTimeout\s*\([^)]*(?:86400|604800|2592000)/g, label: 'Very long timer delay (persistence)', severity: 'MEDIUM' },
454
+ ];
455
+ for (const pat of compactionPatterns) {
456
+ pat.regex.lastIndex = 0;
457
+ const match = pat.regex.exec(content);
458
+ if (match) {
459
+ const lineNum = content.substring(0, match.index).split('\n').length;
460
+ findings.push({
461
+ severity: pat.severity,
462
+ id: 'COMPACTION_PERSISTENCE',
463
+ cat: 'compaction-persistence',
464
+ desc: pat.label,
465
+ file: relFile,
466
+ line: lineNum,
467
+ sample: content.split('\n')[lineNum - 1]?.trim().substring(0, 80),
468
+ });
469
+ }
470
+ }
471
+ }
472
+ }
473
+ checkHardcodedSecrets(content, relFile, findings) {
474
+ const assignmentRegex = /(?:api[_-]?key|secret|token|password|credential|auth)\s*[:=]\s*['"]([a-zA-Z0-9_\-+/=]{16,})['"]|['"]([a-zA-Z0-9_\-+/=]{32,})['"]/gi;
475
+ let match;
476
+ while ((match = assignmentRegex.exec(content)) !== null) {
477
+ const value = match[1] || match[2];
478
+ if (!value)
479
+ continue;
480
+ if (/^[A-Z_]+$/.test(value))
481
+ continue;
482
+ if (/^(true|false|null|undefined|none|default|example|test|placeholder|your[_-])/i.test(value))
483
+ continue;
484
+ if (/^x{4,}|\.{4,}|_{4,}|0{8,}$/i.test(value))
485
+ continue;
486
+ if (/^projects\/|^gs:\/\/|^https?:\/\//i.test(value))
487
+ continue;
488
+ if (/^[a-z]+-[a-z]+-[a-z0-9]+$/i.test(value))
489
+ continue;
490
+ const entropy = this.shannonEntropy(value);
491
+ if (entropy > 3.5 && value.length >= 20) {
492
+ const lineNum = content.substring(0, match.index).split('\n').length;
493
+ findings.push({
494
+ severity: 'HIGH', id: 'SECRET_ENTROPY', cat: 'secret-detection',
495
+ desc: `High-entropy string (possible leaked secret, entropy=${entropy.toFixed(1)})`,
496
+ file: relFile, line: lineNum,
497
+ sample: value.substring(0, 8) + '...' + value.substring(value.length - 4),
498
+ });
499
+ }
500
+ }
501
+ }
502
+ shannonEntropy(str) {
503
+ const freq = {};
504
+ for (const c of str)
505
+ freq[c] = (freq[c] || 0) + 1;
506
+ const len = str.length;
507
+ let entropy = 0;
508
+ for (const count of Object.values(freq)) {
509
+ const p = count / len;
510
+ if (p > 0)
511
+ entropy -= p * Math.log2(p);
512
+ }
513
+ return entropy;
514
+ }
515
+ checkStructure(skillPath, skillName, findings) {
516
+ const skillMd = path.join(skillPath, 'SKILL.md');
517
+ if (!fs.existsSync(skillMd)) {
518
+ findings.push({ severity: 'LOW', id: 'STRUCT_NO_SKILLMD', cat: 'structural', desc: 'No SKILL.md found', file: skillName });
519
+ return;
520
+ }
521
+ const content = fs.readFileSync(skillMd, 'utf-8');
522
+ if (content.length < 50) {
523
+ findings.push({ severity: 'MEDIUM', id: 'STRUCT_TINY_SKILLMD', cat: 'structural', desc: 'Suspiciously short SKILL.md (< 50 chars)', file: 'SKILL.md' });
524
+ }
525
+ const scriptsDir = path.join(skillPath, 'scripts');
526
+ if (fs.existsSync(scriptsDir)) {
527
+ const scripts = fs.readdirSync(scriptsDir).filter((f) => CODE_EXTENSIONS.has(path.extname(f).toLowerCase()));
528
+ if (scripts.length > 0 && !content.includes('scripts/')) {
529
+ findings.push({ severity: 'MEDIUM', id: 'STRUCT_UNDOCUMENTED_SCRIPTS', cat: 'structural', desc: `${scripts.length} script(s) in scripts/ not referenced in SKILL.md`, file: 'scripts/' });
530
+ }
531
+ }
532
+ }
533
+ checkDependencies(skillPath, skillName, findings) {
534
+ const pkgPath = path.join(skillPath, 'package.json');
535
+ if (!fs.existsSync(pkgPath))
536
+ return;
537
+ let pkg;
538
+ try {
539
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
540
+ }
541
+ catch {
542
+ return;
543
+ }
544
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies };
545
+ const RISKY_PACKAGES = new Set([
546
+ 'node-ipc', 'colors', 'faker', 'event-stream', 'ua-parser-js', 'coa', 'rc',
547
+ ]);
548
+ for (const [dep, version] of Object.entries(allDeps)) {
549
+ if (RISKY_PACKAGES.has(dep)) {
550
+ findings.push({ severity: 'HIGH', id: 'DEP_RISKY', cat: 'dependency-chain', desc: `Known risky dependency: ${dep}@${version}`, file: 'package.json' });
551
+ }
552
+ if (typeof version === 'string' && (version.startsWith('git+') || version.startsWith('http') || version.startsWith('github:') || version.includes('.tar.gz'))) {
553
+ findings.push({ severity: 'HIGH', id: 'DEP_REMOTE', cat: 'dependency-chain', desc: `Remote/git dependency: ${dep}@${version}`, file: 'package.json' });
554
+ }
555
+ if (version === '*' || version === 'latest') {
556
+ findings.push({ severity: 'MEDIUM', id: 'DEP_WILDCARD', cat: 'dependency-chain', desc: `Wildcard version: ${dep}@${version}`, file: 'package.json' });
557
+ }
558
+ }
559
+ const RISKY_SCRIPTS = ['preinstall', 'postinstall', 'preuninstall', 'postuninstall', 'prepare'];
560
+ if (pkg.scripts) {
561
+ for (const scriptName of RISKY_SCRIPTS) {
562
+ const cmd = pkg.scripts[scriptName];
563
+ if (cmd) {
564
+ findings.push({ severity: 'HIGH', id: 'DEP_LIFECYCLE', cat: 'dependency-chain', desc: `Lifecycle script "${scriptName}": ${cmd.substring(0, 80)}`, file: 'package.json' });
565
+ if (/curl|wget|node\s+-e|eval|exec|bash\s+-c/i.test(cmd)) {
566
+ findings.push({ severity: 'CRITICAL', id: 'DEP_LIFECYCLE_EXEC', cat: 'dependency-chain', desc: `Lifecycle script "${scriptName}" downloads/executes code`, file: 'package.json', sample: cmd.substring(0, 80) });
567
+ }
568
+ }
569
+ }
570
+ }
571
+ }
572
+ checkSkillManifest(skillPath, skillName, findings) {
573
+ const skillMd = path.join(skillPath, 'SKILL.md');
574
+ if (!fs.existsSync(skillMd))
575
+ return;
576
+ let content;
577
+ try {
578
+ content = fs.readFileSync(skillMd, 'utf-8');
579
+ }
580
+ catch {
581
+ return;
582
+ }
583
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
584
+ if (!fmMatch)
585
+ return;
586
+ const fm = fmMatch[1];
587
+ const DANGEROUS_BINS = new Set([
588
+ 'sudo', 'rm', 'rmdir', 'chmod', 'chown', 'kill', 'pkill',
589
+ 'curl', 'wget', 'nc', 'ncat', 'socat', 'ssh', 'scp',
590
+ 'dd', 'mkfs', 'fdisk', 'mount', 'umount',
591
+ 'iptables', 'ufw', 'firewall-cmd', 'docker', 'kubectl', 'systemctl',
592
+ ]);
593
+ const binsMatch = fm.match(/bins:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
594
+ if (binsMatch) {
595
+ const bins = binsMatch[1].match(/- ([^\n]+)/g) || [];
596
+ for (const binLine of bins) {
597
+ const bin = binLine.replace(/^-\s*/, '').trim().toLowerCase();
598
+ if (DANGEROUS_BINS.has(bin)) {
599
+ findings.push({ severity: 'HIGH', id: 'MANIFEST_DANGEROUS_BIN', cat: 'sandbox-validation', desc: `SKILL.md requires dangerous binary: ${bin}`, file: 'SKILL.md' });
600
+ }
601
+ }
602
+ }
603
+ const filesMatch = fm.match(/files:\s*\[([^\]]+)\]/i) || fm.match(/files:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
604
+ if (filesMatch && /\*\*\/\*|\*\.\*|"\*"/i.test(filesMatch[1])) {
605
+ findings.push({ severity: 'HIGH', id: 'MANIFEST_BROAD_FILES', cat: 'sandbox-validation', desc: 'SKILL.md declares overly broad file scope (e.g. **/*)', file: 'SKILL.md' });
606
+ }
607
+ const SENSITIVE_ENV = /(?:SECRET|PASSWORD|CREDENTIAL|PRIVATE_KEY|AWS_SECRET|GITHUB_TOKEN)/i;
608
+ const envMatch = fm.match(/env:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
609
+ if (envMatch) {
610
+ const envVars = envMatch[1].match(/- ([^\n]+)/g) || [];
611
+ for (const envLine of envVars) {
612
+ const envVar = envLine.replace(/^-\s*/, '').trim();
613
+ if (SENSITIVE_ENV.test(envVar)) {
614
+ findings.push({ severity: 'HIGH', id: 'MANIFEST_SENSITIVE_ENV', cat: 'sandbox-validation', desc: `SKILL.md requires sensitive env var: ${envVar}`, file: 'SKILL.md' });
615
+ }
616
+ }
617
+ }
618
+ if (/exec:\s*(?:true|yes|enabled|'\*'|"\*")/i.test(fm)) {
619
+ findings.push({ severity: 'MEDIUM', id: 'MANIFEST_EXEC_DECLARED', cat: 'sandbox-validation', desc: 'SKILL.md declares exec capability', file: 'SKILL.md' });
620
+ }
621
+ if (/network:\s*(?:true|yes|enabled|'\*'|"\*"|all|any)/i.test(fm)) {
622
+ findings.push({ severity: 'MEDIUM', id: 'MANIFEST_NETWORK_DECLARED', cat: 'sandbox-validation', desc: 'SKILL.md declares unrestricted network access', file: 'SKILL.md' });
623
+ }
624
+ }
625
+ checkComplexity(skillPath, skillName, findings) {
626
+ const files = this.getFiles(skillPath);
627
+ const MAX_LINES = 1000;
628
+ const MAX_NESTING = 5;
629
+ const MAX_EVAL_DENSITY = 0.02;
630
+ for (const file of files) {
631
+ const ext = path.extname(file).toLowerCase();
632
+ if (!CODE_EXTENSIONS.has(ext))
633
+ continue;
634
+ const relFile = path.relative(skillPath, file);
635
+ if (relFile.includes('node_modules') || relFile.startsWith('.git'))
636
+ continue;
637
+ let content;
638
+ try {
639
+ content = fs.readFileSync(file, 'utf-8');
640
+ }
641
+ catch {
642
+ continue;
643
+ }
644
+ const lines = content.split('\n');
645
+ if (lines.length > MAX_LINES) {
646
+ findings.push({ severity: 'MEDIUM', id: 'COMPLEXITY_LONG_FILE', cat: 'complexity', desc: `File exceeds ${MAX_LINES} lines (${lines.length} lines)`, file: relFile });
647
+ }
648
+ let maxDepth = 0, currentDepth = 0, deepestLine = 0;
649
+ for (let i = 0; i < lines.length; i++) {
650
+ for (const ch of lines[i]) {
651
+ if (ch === '{')
652
+ currentDepth++;
653
+ if (ch === '}')
654
+ currentDepth = Math.max(0, currentDepth - 1);
655
+ }
656
+ if (currentDepth > maxDepth) {
657
+ maxDepth = currentDepth;
658
+ deepestLine = i + 1;
659
+ }
660
+ }
661
+ if (maxDepth > MAX_NESTING) {
662
+ findings.push({ severity: 'MEDIUM', id: 'COMPLEXITY_DEEP_NESTING', cat: 'complexity', desc: `Deep nesting: ${maxDepth} levels (max: ${MAX_NESTING})`, file: relFile, line: deepestLine });
663
+ }
664
+ const evalMatches = content.match(/\b(?:eval|exec|execSync|spawn|Function)\s*\(/g) || [];
665
+ const density = lines.length > 0 ? evalMatches.length / lines.length : 0;
666
+ if (density > MAX_EVAL_DENSITY && evalMatches.length >= 3) {
667
+ findings.push({ severity: 'HIGH', id: 'COMPLEXITY_EVAL_DENSITY', cat: 'complexity', desc: `High eval/exec density: ${evalMatches.length} calls in ${lines.length} lines (${(density * 100).toFixed(1)}%)`, file: relFile });
668
+ }
669
+ }
670
+ }
671
+ checkConfigImpact(skillPath, skillName, findings) {
672
+ const files = this.getFiles(skillPath);
673
+ for (const file of files) {
674
+ const ext = path.extname(file).toLowerCase();
675
+ if (!CODE_EXTENSIONS.has(ext) && ext !== '.json')
676
+ continue;
677
+ const relFile = path.relative(skillPath, file);
678
+ if (relFile.includes('node_modules') || relFile.startsWith('.git'))
679
+ continue;
680
+ let content;
681
+ try {
682
+ content = fs.readFileSync(file, 'utf-8');
683
+ }
684
+ catch {
685
+ continue;
686
+ }
687
+ const hasConfigRef = /openclaw\.json/i.test(content);
688
+ const hasWriteOp = /(?:writeFileSync|writeFile|fs\.write)\s*\(/i.test(content);
689
+ if (hasConfigRef && hasWriteOp) {
690
+ const clines = content.split('\n');
691
+ let writeLine = 0;
692
+ for (let i = 0; i < clines.length; i++) {
693
+ if (/(?:writeFileSync|writeFile|fs\.write)\s*\(/i.test(clines[i])) {
694
+ writeLine = i + 1;
695
+ break;
696
+ }
697
+ }
698
+ findings.push({ severity: 'CRITICAL', id: 'CFG_WRITE_DETECTED', cat: 'config-impact', desc: 'Code writes to openclaw.json', file: relFile, line: writeLine });
699
+ }
700
+ const DANGEROUS_CFG = [
701
+ { regex: /exec\.approvals?\s*[:=]\s*['"]?(off|false|disabled|none)/gi, id: 'CFG_EXEC_APPROVAL_OFF', desc: 'Disables exec approval', severity: 'CRITICAL' },
702
+ { regex: /tools\.exec\.host\s*[:=]\s*['"]gateway['"]/gi, id: 'CFG_EXEC_HOST_GATEWAY', desc: 'Sets exec host to gateway', severity: 'CRITICAL' },
703
+ { regex: /hooks\s*\.\s*internal\s*\.\s*entries\s*[:=]/gi, id: 'CFG_HOOKS_INTERNAL', desc: 'Modifies internal hooks', severity: 'HIGH' },
704
+ { regex: /network\.allowedDomains\s*[:=]\s*\[?\s*['"]\*['"]/gi, id: 'CFG_NET_WILDCARD', desc: 'Sets network domains to wildcard', severity: 'HIGH' },
705
+ ];
706
+ for (const check of DANGEROUS_CFG) {
707
+ check.regex.lastIndex = 0;
708
+ if (check.regex.test(content)) {
709
+ findings.push({ severity: check.severity, id: check.id, cat: 'config-impact', desc: check.desc, file: relFile });
710
+ }
711
+ }
712
+ }
713
+ }
714
+ checkHiddenFiles(skillPath, skillName, findings) {
715
+ try {
716
+ const entries = fs.readdirSync(skillPath);
717
+ for (const entry of entries) {
718
+ if (entry.startsWith('.') && entry !== '.guard-scanner-ignore' && entry !== '.guava-guard-ignore' && entry !== '.gitignore' && entry !== '.git') {
719
+ const fullPath = path.join(skillPath, entry);
720
+ const stat = fs.statSync(fullPath);
721
+ if (stat.isFile()) {
722
+ const ext = path.extname(entry).toLowerCase();
723
+ if (CODE_EXTENSIONS.has(ext) || ext === '' || ext === '.sh') {
724
+ findings.push({ severity: 'MEDIUM', id: 'STRUCT_HIDDEN_EXEC', cat: 'structural', desc: `Hidden executable file: ${entry}`, file: entry });
725
+ }
726
+ }
727
+ else if (stat.isDirectory() && entry !== '.git') {
728
+ findings.push({ severity: 'LOW', id: 'STRUCT_HIDDEN_DIR', cat: 'structural', desc: `Hidden directory: ${entry}/`, file: entry });
729
+ }
730
+ }
731
+ }
732
+ }
733
+ catch { /* empty */ }
734
+ }
735
+ checkJSDataFlow(content, relFile, findings) {
736
+ const lines = content.split('\n');
737
+ const imports = new Map();
738
+ const sensitiveReads = [];
739
+ const networkCalls = [];
740
+ const execCalls = [];
741
+ for (let i = 0; i < lines.length; i++) {
742
+ const line = lines[i];
743
+ const lineNum = i + 1;
744
+ const reqMatch = line.match(/(?:const|let|var)\s+(?:\{[^}]+\}|\w+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
745
+ if (reqMatch) {
746
+ const varMatch = line.match(/(?:const|let|var)\s+(\{[^}]+\}|\w+)/);
747
+ if (varMatch)
748
+ imports.set(varMatch[1].trim(), reqMatch[1]);
749
+ }
750
+ if (/(?:readFileSync|readFile)\s*\([^)]*(?:\.env|\.ssh|id_rsa|\.clawdbot|\.openclaw(?!\/workspace))/i.test(line)) {
751
+ sensitiveReads.push({ line: lineNum, text: line.trim() });
752
+ }
753
+ if (/process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i.test(line)) {
754
+ sensitiveReads.push({ line: lineNum, text: line.trim() });
755
+ }
756
+ if (/(?:fetch|axios|request|http\.request|https\.request|got)\s*\(/i.test(line) || /\.post\s*\(|\.put\s*\(|\.patch\s*\(/i.test(line)) {
757
+ networkCalls.push({ line: lineNum, text: line.trim() });
758
+ }
759
+ if (/(?:exec|execSync|spawn|spawnSync|execFile)\s*\(/i.test(line)) {
760
+ execCalls.push({ line: lineNum, text: line.trim() });
761
+ }
762
+ }
763
+ if (sensitiveReads.length > 0 && networkCalls.length > 0) {
764
+ findings.push({ severity: 'CRITICAL', id: 'AST_CRED_TO_NET', cat: 'data-flow', desc: `Data flow: secret read (L${sensitiveReads[0].line}) → network call (L${networkCalls[0].line})`, file: relFile, line: sensitiveReads[0].line, sample: sensitiveReads[0].text.substring(0, 60) });
765
+ }
766
+ if (sensitiveReads.length > 0 && execCalls.length > 0) {
767
+ findings.push({ severity: 'HIGH', id: 'AST_CRED_TO_EXEC', cat: 'data-flow', desc: `Data flow: secret read (L${sensitiveReads[0].line}) → command exec (L${execCalls[0].line})`, file: relFile, line: sensitiveReads[0].line, sample: sensitiveReads[0].text.substring(0, 60) });
768
+ }
769
+ const importedModules = new Set([...imports.values()]);
770
+ if (importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http') || importedModules.has('node-fetch'))) {
771
+ findings.push({ severity: 'HIGH', id: 'AST_SUSPICIOUS_IMPORTS', cat: 'data-flow', desc: 'Suspicious: child_process + network module', file: relFile });
772
+ }
773
+ if (importedModules.has('fs') && importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http'))) {
774
+ findings.push({ severity: 'CRITICAL', id: 'AST_EXFIL_TRIFECTA', cat: 'data-flow', desc: 'Exfiltration trifecta: fs + child_process + network', file: relFile });
775
+ }
776
+ for (let i = 0; i < lines.length; i++) {
777
+ const line = lines[i];
778
+ if (/`[^`]*\$\{.*(?:env|key|token|secret|password).*\}[^`]*`\s*(?:\)|,)/i.test(line) && /(?:fetch|request|axios|http|url)/i.test(line)) {
779
+ findings.push({ severity: 'CRITICAL', id: 'AST_SECRET_IN_URL', cat: 'data-flow', desc: 'Secret interpolated into URL', file: relFile, line: i + 1, sample: line.trim().substring(0, 80) });
780
+ }
781
+ }
782
+ }
783
+ checkCrossFile(skillPath, skillName, findings) {
784
+ const files = this.getFiles(skillPath);
785
+ const allContent = {};
786
+ for (const file of files) {
787
+ const ext = path.extname(file).toLowerCase();
788
+ if (BINARY_EXTENSIONS.has(ext))
789
+ continue;
790
+ const relFile = path.relative(skillPath, file);
791
+ if (relFile.includes('node_modules') || relFile.startsWith('.git'))
792
+ continue;
793
+ try {
794
+ const content = fs.readFileSync(file, 'utf-8');
795
+ if (content.length < 500_000)
796
+ allContent[relFile] = content;
797
+ }
798
+ catch { /* empty */ }
799
+ }
800
+ const skillMd = allContent['SKILL.md'] || '';
801
+ const codeFileRefs = skillMd.match(/(?:scripts?\/|\.\/)[a-zA-Z0-9_\-.\/]+\.(js|py|sh|ts)/gi) || [];
802
+ for (const ref of codeFileRefs) {
803
+ const cleanRef = ref.replace(/^\.\//, '');
804
+ if (!allContent[cleanRef] && !files.some(f => path.relative(skillPath, f) === cleanRef)) {
805
+ findings.push({ severity: 'MEDIUM', id: 'XFILE_PHANTOM_REF', cat: 'structural', desc: `SKILL.md references non-existent: ${cleanRef}`, file: 'SKILL.md' });
806
+ }
807
+ }
808
+ const b64Fragments = [];
809
+ for (const [file, content] of Object.entries(allContent)) {
810
+ const matches = content.match(/[A-Za-z0-9+/]{20,}={0,2}/g) || [];
811
+ for (const m of matches) {
812
+ if (m.length > 40)
813
+ b64Fragments.push({ file, fragment: m.substring(0, 30) });
814
+ }
815
+ }
816
+ if (b64Fragments.length > 3 && new Set(b64Fragments.map(f => f.file)).size > 1) {
817
+ findings.push({ severity: 'HIGH', id: 'XFILE_FRAGMENT_B64', cat: 'obfuscation', desc: `Base64 fragments across ${new Set(b64Fragments.map(f => f.file)).size} files`, file: skillName });
818
+ }
819
+ if (/(?:read|load|source|import)\s+(?:the\s+)?(?:script|file|code)\s+(?:from|at|in)\s+(?:scripts?\/)/gi.test(skillMd)) {
820
+ const hasExec = Object.values(allContent).some(c => /(?:eval|exec|spawn)\s*\(/i.test(c));
821
+ if (hasExec) {
822
+ findings.push({ severity: 'MEDIUM', id: 'XFILE_LOAD_EXEC', cat: 'data-flow', desc: 'SKILL.md references scripts with exec/eval', file: 'SKILL.md' });
823
+ }
824
+ }
825
+ }
826
+ // ── Risk Scoring ──────────────────────────────────────────────────────
827
+ calculateRisk(findings) {
828
+ if (findings.length === 0)
829
+ return 0;
830
+ let score = 0;
831
+ for (const f of findings) {
832
+ score += SEVERITY_WEIGHTS[f.severity] || 0;
833
+ }
834
+ const ids = new Set(findings.map(f => f.id));
835
+ const cats = new Set(findings.map(f => f.cat));
836
+ // Amplifiers
837
+ if (cats.has('credential-handling') && cats.has('exfiltration'))
838
+ score = Math.round(score * 2);
839
+ if (cats.has('credential-handling') && findings.some(f => f.id === 'MAL_CHILD' || f.id === 'MAL_EXEC'))
840
+ score = Math.round(score * 1.5);
841
+ if (cats.has('obfuscation') && (cats.has('malicious-code') || cats.has('credential-handling')))
842
+ score = Math.round(score * 2);
843
+ if (ids.has('DEP_LIFECYCLE_EXEC'))
844
+ score = Math.round(score * 2);
845
+ if (ids.has('PI_BIDI') && findings.length > 1)
846
+ score = Math.round(score * 1.5);
847
+ if (cats.has('leaky-skills') && (cats.has('exfiltration') || cats.has('malicious-code')))
848
+ score = Math.round(score * 2);
849
+ if (cats.has('memory-poisoning'))
850
+ score = Math.round(score * 1.5);
851
+ if (cats.has('prompt-worm'))
852
+ score = Math.round(score * 2);
853
+ if (cats.has('cve-patterns'))
854
+ score = Math.max(score, 70);
855
+ if (cats.has('persistence') && (cats.has('malicious-code') || cats.has('credential-handling') || cats.has('memory-poisoning')))
856
+ score = Math.round(score * 1.5);
857
+ if (cats.has('identity-hijack'))
858
+ score = Math.round(score * 2);
859
+ if (cats.has('identity-hijack') && (cats.has('persistence') || cats.has('memory-poisoning')))
860
+ score = Math.max(score, 90);
861
+ if (ids.has('IOC_IP') || ids.has('IOC_URL') || ids.has('KNOWN_TYPOSQUAT'))
862
+ score = 100;
863
+ // v1.1
864
+ if (cats.has('config-impact'))
865
+ score = Math.round(score * 2);
866
+ if (cats.has('config-impact') && cats.has('sandbox-validation'))
867
+ score = Math.max(score, 70);
868
+ if (cats.has('complexity') && (cats.has('malicious-code') || cats.has('obfuscation')))
869
+ score = Math.round(score * 1.5);
870
+ // v2.1 PII
871
+ if (cats.has('pii-exposure') && cats.has('exfiltration'))
872
+ score = Math.round(score * 3);
873
+ if (cats.has('pii-exposure') && (ids.has('SHADOW_AI_OPENAI') || ids.has('SHADOW_AI_ANTHROPIC') || ids.has('SHADOW_AI_GENERIC')))
874
+ score = Math.round(score * 2.5);
875
+ if (cats.has('pii-exposure') && cats.has('credential-handling'))
876
+ score = Math.round(score * 2);
877
+ // v3.0 Compaction persistence
878
+ if (cats.has('compaction-persistence'))
879
+ score = Math.round(score * 2);
880
+ if (cats.has('compaction-persistence') && cats.has('prompt-injection'))
881
+ score = Math.max(score, 90);
882
+ if (cats.has('signature-match'))
883
+ score = Math.max(score, 70);
884
+ return Math.min(100, score);
885
+ }
886
+ getVerdict(risk) {
887
+ if (risk >= this.thresholds.malicious)
888
+ return { icon: '🔴', label: 'MALICIOUS', stat: 'malicious' };
889
+ if (risk >= this.thresholds.suspicious)
890
+ return { icon: '🟡', label: 'SUSPICIOUS', stat: 'suspicious' };
891
+ if (risk > 0)
892
+ return { icon: '🟢', label: 'LOW RISK', stat: 'low' };
893
+ return { icon: '🟢', label: 'CLEAN', stat: 'clean' };
894
+ }
895
+ // ── File Discovery ────────────────────────────────────────────────────
896
+ getFiles(dir) {
897
+ const results = [];
898
+ try {
899
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
900
+ for (const entry of entries) {
901
+ const fullPath = path.join(dir, entry.name);
902
+ if (entry.isDirectory()) {
903
+ if (entry.name === '.git' || entry.name === 'node_modules')
904
+ continue;
905
+ results.push(...this.getFiles(fullPath));
906
+ }
907
+ else {
908
+ if (GENERATED_REPORT_FILES.has(entry.name.toLowerCase()))
909
+ continue;
910
+ results.push(fullPath);
911
+ }
912
+ }
913
+ }
914
+ catch { /* empty */ }
915
+ return results;
916
+ }
917
+ // ── Output ────────────────────────────────────────────────────────────
918
+ printSummary() {
919
+ const total = this.stats.scanned;
920
+ const safe = this.stats.clean + this.stats.low;
921
+ console.log(`\n${'═'.repeat(54)}`);
922
+ console.log(`📊 guard-scanner v${exports.VERSION} Scan Summary`);
923
+ console.log(`${'─'.repeat(54)}`);
924
+ console.log(` Scanned: ${total}`);
925
+ console.log(` 🟢 Clean: ${this.stats.clean}`);
926
+ console.log(` 🟢 Low Risk: ${this.stats.low}`);
927
+ console.log(` 🟡 Suspicious: ${this.stats.suspicious}`);
928
+ console.log(` 🔴 Malicious: ${this.stats.malicious}`);
929
+ console.log(` Safety Rate: ${total ? Math.round(safe / total * 100) : 0}%`);
930
+ console.log(`${'═'.repeat(54)}`);
931
+ if (this.stats.malicious > 0) {
932
+ console.log(`\n⚠️ CRITICAL: ${this.stats.malicious} malicious skill(s) detected!`);
933
+ }
934
+ else if (this.stats.suspicious > 0) {
935
+ console.log(`\n⚡ ${this.stats.suspicious} suspicious skill(s) found — review recommended.`);
936
+ }
937
+ else {
938
+ console.log('\n✅ All clear! No threats detected.');
939
+ }
940
+ }
941
+ toJSON() {
942
+ const recommendations = [];
943
+ for (const sr of this.findings) {
944
+ const actions = [];
945
+ const cats = new Set(sr.findings.map(f => f.cat));
946
+ if (cats.has('prompt-injection'))
947
+ actions.push('🛑 Contains prompt injection patterns.');
948
+ if (cats.has('malicious-code'))
949
+ actions.push('🛑 Contains potentially malicious code.');
950
+ if (cats.has('credential-handling') && cats.has('exfiltration'))
951
+ actions.push('💀 CRITICAL: Credential + exfiltration. DO NOT INSTALL.');
952
+ if (cats.has('dependency-chain'))
953
+ actions.push('📦 Suspicious dependency chain.');
954
+ if (cats.has('obfuscation'))
955
+ actions.push('🔍 Code obfuscation detected.');
956
+ if (cats.has('secret-detection'))
957
+ actions.push('🔑 Possible hardcoded secrets.');
958
+ if (cats.has('memory-poisoning'))
959
+ actions.push('🧠 MEMORY POISONING: Agent memory modification.');
960
+ if (cats.has('prompt-worm'))
961
+ actions.push('🪱 PROMPT WORM: Self-replicating instructions.');
962
+ if (cats.has('data-flow'))
963
+ actions.push('🔀 Suspicious data flow patterns.');
964
+ if (cats.has('identity-hijack'))
965
+ actions.push('🔒 IDENTITY HIJACK: Agent soul file tampering.');
966
+ if (cats.has('compaction-persistence'))
967
+ actions.push('⏰ COMPACTION PERSISTENCE: Survives context compaction.');
968
+ if (cats.has('signature-match'))
969
+ actions.push('🎯 SIGNATURE MATCH: Known threat signature detected.');
970
+ if (cats.has('config-impact'))
971
+ actions.push('⚙️ CONFIG IMPACT: Modifies OpenClaw configuration.');
972
+ if (cats.has('pii-exposure'))
973
+ actions.push('🆔 PII EXPOSURE: Handles personal information.');
974
+ if (actions.length > 0)
975
+ recommendations.push({ skill: sr.skill, actions });
976
+ }
977
+ return {
978
+ timestamp: new Date().toISOString(),
979
+ scanner: `guard-scanner v${exports.VERSION}`,
980
+ mode: this.strict ? 'strict' : 'normal',
981
+ stats: this.stats,
982
+ thresholds: this.thresholds,
983
+ findings: this.findings,
984
+ recommendations,
985
+ iocVersion: '2026-02-21',
986
+ signaturesVersion: ioc_db_js_1.SIGNATURES_DB.version,
987
+ };
988
+ }
989
+ toSARIF(scanDir) {
990
+ const rules = [];
991
+ const ruleIndex = {};
992
+ const results = [];
993
+ for (const sr of this.findings) {
994
+ for (const f of sr.findings) {
995
+ if (!ruleIndex[f.id] && ruleIndex[f.id] !== 0) {
996
+ ruleIndex[f.id] = rules.length;
997
+ // Look up OWASP mapping from PATTERNS
998
+ const patternDef = patterns_js_1.PATTERNS.find((p) => p.id === f.id);
999
+ const owaspTag = patternDef?.owasp;
1000
+ const tags = ['security', f.cat];
1001
+ if (owaspTag)
1002
+ tags.push(`OWASP/${owaspTag}`);
1003
+ rules.push({
1004
+ id: f.id, name: f.id,
1005
+ shortDescription: { text: f.desc },
1006
+ defaultConfiguration: { level: f.severity === 'CRITICAL' || f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note' },
1007
+ properties: {
1008
+ tags,
1009
+ 'security-severity': f.severity === 'CRITICAL' ? '9.0' : f.severity === 'HIGH' ? '7.0' : f.severity === 'MEDIUM' ? '4.0' : '1.0',
1010
+ },
1011
+ });
1012
+ }
1013
+ const normalizedFile = String(f.file || '').replaceAll('\\', '/').replace(/^\/+/, '');
1014
+ const artifactUri = `${sr.skill}/${normalizedFile}`;
1015
+ const seed = `${f.id}|${artifactUri}|${f.line || 0}|${(f.sample || '').slice(0, 200)}`;
1016
+ const lineHash = crypto.createHash('sha256').update(seed).digest('hex').slice(0, 24);
1017
+ results.push({
1018
+ ruleId: f.id, ruleIndex: ruleIndex[f.id],
1019
+ level: f.severity === 'CRITICAL' || f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note',
1020
+ message: { text: `[${sr.skill}] ${f.desc}${f.sample ? ` — "${f.sample}"` : ''}` },
1021
+ partialFingerprints: { primaryLocationLineHash: lineHash },
1022
+ locations: [{
1023
+ physicalLocation: {
1024
+ artifactLocation: { uri: artifactUri, uriBaseId: '%SRCROOT%' },
1025
+ region: f.line ? { startLine: f.line } : undefined,
1026
+ },
1027
+ }],
1028
+ });
1029
+ }
1030
+ }
1031
+ return {
1032
+ version: '2.1.0',
1033
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
1034
+ runs: [{
1035
+ tool: { driver: { name: 'guard-scanner', version: exports.VERSION, informationUri: 'https://github.com/koatora20/guard-scanner', rules } },
1036
+ results,
1037
+ invocations: [{ executionSuccessful: true, endTimeUtc: new Date().toISOString() }],
1038
+ }],
1039
+ };
1040
+ }
1041
+ }
1042
+ exports.GuardScanner = GuardScanner;
1043
+ //# sourceMappingURL=scanner.js.map