@guava-parity/guard-scanner 13.0.0 → 16.0.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 (96) hide show
  1. package/README.md +170 -215
  2. package/README_ja.md +252 -0
  3. package/SECURITY.md +12 -4
  4. package/SKILL.md +148 -57
  5. package/dist/cli.cjs +5997 -0
  6. package/dist/cli.d.mts +1 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.mjs +6003 -0
  9. package/dist/index.cjs +4825 -0
  10. package/dist/index.d.mts +17 -0
  11. package/dist/index.d.ts +17 -0
  12. package/dist/index.mjs +4798 -0
  13. package/dist/mcp-server.cjs +4756 -0
  14. package/dist/mcp-server.d.mts +1 -0
  15. package/dist/mcp-server.d.ts +1 -0
  16. package/dist/mcp-server.mjs +4767 -0
  17. package/dist/openclaw-plugin.cjs +4863 -0
  18. package/dist/openclaw-plugin.d.mts +11 -0
  19. package/dist/openclaw-plugin.d.ts +11 -0
  20. package/dist/openclaw-plugin.mjs +4854 -0
  21. package/dist/types.cjs +18 -0
  22. package/dist/types.d.mts +215 -0
  23. package/dist/types.d.ts +215 -0
  24. package/dist/types.mjs +1 -0
  25. package/docs/EVIDENCE_DRIVEN.md +182 -0
  26. package/docs/banner.png +0 -0
  27. package/docs/data/benchmark-ledger.json +1428 -0
  28. package/docs/data/corpus-metrics.json +11 -0
  29. package/docs/data/fp-ledger.json +18 -0
  30. package/docs/data/latest.json +25837 -2481
  31. package/docs/data/quality-contract.json +36 -0
  32. package/docs/generated/npm-audit-20260312.json +96 -0
  33. package/docs/generated/openclaw-upstream-status.json +25 -0
  34. package/docs/glossary.md +46 -0
  35. package/docs/index.html +1085 -496
  36. package/docs/logo.png +0 -0
  37. package/docs/openclaw-compatibility-audit.md +45 -0
  38. package/docs/openclaw-continuous-compatibility-plan.md +37 -0
  39. package/docs/rules/a2a-contagion.md +68 -0
  40. package/docs/rules/advanced-exfil.md +52 -0
  41. package/docs/rules/agent-protocol.md +108 -0
  42. package/docs/rules/api-abuse.md +68 -0
  43. package/docs/rules/autonomous-risk.md +92 -0
  44. package/docs/rules/config-impact.md +132 -0
  45. package/docs/rules/credential-handling.md +100 -0
  46. package/docs/rules/cve-patterns.md +332 -0
  47. package/docs/rules/data-exposure.md +84 -0
  48. package/docs/rules/exfiltration.md +36 -0
  49. package/docs/rules/financial-access.md +84 -0
  50. package/docs/rules/identity-hijack.md +140 -0
  51. package/docs/rules/inference-manipulation.md +60 -0
  52. package/docs/rules/leaky-skills.md +52 -0
  53. package/docs/rules/malicious-code.md +108 -0
  54. package/docs/rules/mcp-security.md +148 -0
  55. package/docs/rules/memory-poisoning.md +84 -0
  56. package/docs/rules/model-poisoning.md +44 -0
  57. package/docs/rules/obfuscation.md +60 -0
  58. package/docs/rules/persistence.md +108 -0
  59. package/docs/rules/pii-exposure.md +116 -0
  60. package/docs/rules/prompt-injection.md +148 -0
  61. package/docs/rules/prompt-worm.md +44 -0
  62. package/docs/rules/safeguard-bypass.md +44 -0
  63. package/docs/rules/sandbox-escape.md +100 -0
  64. package/docs/rules/secret-detection.md +44 -0
  65. package/docs/rules/supply-chain-v2.md +92 -0
  66. package/docs/rules/suspicious-download.md +60 -0
  67. package/docs/rules/trust-boundary.md +76 -0
  68. package/docs/rules/trust-exploitation.md +92 -0
  69. package/docs/rules/unverifiable-deps.md +84 -0
  70. package/docs/rules/vdb-injection.md +84 -0
  71. package/docs/security-vulnerability-report-20260312.md +53 -0
  72. package/docs/spec/PRD_V2_ARCHITECTURE.md +55 -0
  73. package/docs/spec/capabilities.json +174 -0
  74. package/docs/spec/finding.schema.json +104 -0
  75. package/docs/spec/integration-manifest.md +39 -0
  76. package/docs/spec/plugin-trust.json +11 -0
  77. package/docs/spec/sbom.json +33 -0
  78. package/docs/threat-model.md +65 -0
  79. package/docs/v13-architecture-manifest.md +55 -0
  80. package/hooks/context.ts +306 -0
  81. package/hooks/guard-scanner/plugin.ts +24 -1
  82. package/openclaw-plugin.mts +107 -0
  83. package/openclaw.plugin.json +30 -53
  84. package/package.json +66 -13
  85. package/src/asset-auditor.js +0 -508
  86. package/src/ci-reporter.js +0 -135
  87. package/src/cli.js +0 -294
  88. package/src/html-template.js +0 -239
  89. package/src/ioc-db.js +0 -54
  90. package/src/mcp-server.js +0 -702
  91. package/src/patterns.js +0 -611
  92. package/src/quarantine.js +0 -41
  93. package/src/runtime-guard.js +0 -346
  94. package/src/scanner.js +0 -1157
  95. package/src/vt-client.js +0 -202
  96. package/src/watcher.js +0 -170
package/src/scanner.js DELETED
@@ -1,1157 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * guard-scanner v2.1.0 — Agent Skill Security Scanner šŸ›”ļø
4
- *
5
- * @security-manifest
6
- * env-read: []
7
- * env-write: []
8
- * network: none
9
- * fs-read: [scan target directory (user-specified)]
10
- * fs-write: [JSON/SARIF/HTML reports to scan directory]
11
- * exec: none
12
- * purpose: Static analysis of agent skill files for threat patterns
13
- *
14
- * Based on GuavaGuard v9.0.0 (OSS extraction)
15
- * 20 threat categories • Snyk ToxicSkills + OWASP MCP Top 10
16
- * Zero dependencies • CLI + JSON + SARIF + HTML output
17
- * Plugin API for custom detection rules
18
- *
19
- * Born from a real 3-day agent identity hijack (2026-02-12)
20
- *
21
- * License: MIT
22
- */
23
-
24
- const fs = require('fs');
25
- const path = require('path');
26
- const os = require('os');
27
- const crypto = require('crypto');
28
-
29
- const { PATTERNS } = require('./patterns.js');
30
- const { KNOWN_MALICIOUS } = require('./ioc-db.js');
31
- const { generateHTML } = require('./html-template.js');
32
-
33
- // ===== CONFIGURATION =====
34
- const VERSION = '8.0.0';
35
-
36
- const THRESHOLDS = {
37
- normal: { suspicious: 30, malicious: 80 },
38
- strict: { suspicious: 20, malicious: 60 },
39
- };
40
-
41
- // File classification
42
- const CODE_EXTENSIONS = new Set(['.js', '.ts', '.mjs', '.cjs', '.py', '.sh', '.bash', '.ps1', '.rb', '.go', '.rs', '.php', '.pl']);
43
- const DOC_EXTENSIONS = new Set(['.md', '.txt', '.rst', '.adoc']);
44
- const DATA_EXTENSIONS = new Set(['.json', '.yaml', '.yml', '.toml', '.xml', '.csv']);
45
- const BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.wasm', '.wav', '.mp3', '.mp4', '.webm', '.ogg', '.pdf', '.zip', '.tar', '.gz', '.bz2', '.7z', '.exe', '.dll', '.so', '.dylib']);
46
- const GENERATED_REPORT_FILES = new Set(['guard-scanner-report.json', 'guard-scanner-report.html', 'guard-scanner.sarif']);
47
-
48
- // Severity weights for risk scoring
49
- const SEVERITY_WEIGHTS = { CRITICAL: 40, HIGH: 15, MEDIUM: 5, LOW: 2 };
50
-
51
- class GuardScanner {
52
- constructor(options = {}) {
53
- this.verbose = options.verbose || false;
54
- this.selfExclude = options.selfExclude || false;
55
- this.strict = options.strict || false;
56
- this.summaryOnly = options.summaryOnly || false;
57
- this.quiet = options.quiet || false;
58
- this.checkDeps = options.checkDeps || false;
59
- this.soulLock = options.soulLock || false;
60
- this.scannerDir = path.resolve(__dirname);
61
- this.thresholds = this.strict ? THRESHOLDS.strict : THRESHOLDS.normal;
62
- this.findings = [];
63
- this.stats = { scanned: 0, clean: 0, low: 0, suspicious: 0, malicious: 0 };
64
- this.ignoredSkills = new Set();
65
- this.ignoredPatterns = new Set();
66
- this.customRules = [];
67
-
68
- // Plugin API: load plugins
69
- if (options.plugins && Array.isArray(options.plugins)) {
70
- for (const plugin of options.plugins) {
71
- this.loadPlugin(plugin);
72
- }
73
- }
74
-
75
- // Custom rules file (legacy compat)
76
- if (options.rulesFile) {
77
- this.loadCustomRules(options.rulesFile);
78
- }
79
- }
80
-
81
- // Plugin API: load a plugin module
82
- loadPlugin(pluginPath) {
83
- try {
84
- const plugin = require(path.resolve(pluginPath));
85
- if (plugin.patterns && Array.isArray(plugin.patterns)) {
86
- for (const p of plugin.patterns) {
87
- if (p.id && p.regex && p.severity && p.cat && p.desc) {
88
- this.customRules.push(p);
89
- }
90
- }
91
- if (!this.summaryOnly) {
92
- console.log(`šŸ”Œ Plugin loaded: ${plugin.name || pluginPath} (${plugin.patterns.length} rule(s))`);
93
- }
94
- }
95
- } catch (e) {
96
- console.error(`āš ļø Failed to load plugin ${pluginPath}: ${e.message}`);
97
- }
98
- }
99
-
100
- // Custom rules from JSON file
101
- loadCustomRules(rulesFile) {
102
- try {
103
- const content = fs.readFileSync(rulesFile, 'utf-8');
104
- const rules = JSON.parse(content);
105
- if (!Array.isArray(rules)) {
106
- console.error(`āš ļø Custom rules file must be a JSON array`);
107
- return;
108
- }
109
- for (const rule of rules) {
110
- if (!rule.id || !rule.pattern || !rule.severity || !rule.cat || !rule.desc) {
111
- console.error(`āš ļø Skipping invalid rule: ${JSON.stringify(rule).substring(0, 80)}`);
112
- continue;
113
- }
114
- try {
115
- const flags = rule.flags || 'gi';
116
- this.customRules.push({
117
- id: rule.id,
118
- cat: rule.cat,
119
- regex: new RegExp(rule.pattern, flags),
120
- severity: rule.severity,
121
- desc: rule.desc,
122
- codeOnly: rule.codeOnly || false,
123
- docOnly: rule.docOnly || false,
124
- all: !rule.codeOnly && !rule.docOnly
125
- });
126
- } catch (e) {
127
- console.error(`āš ļø Invalid regex in rule ${rule.id}: ${e.message}`);
128
- }
129
- }
130
- if (!this.summaryOnly && this.customRules.length > 0) {
131
- console.log(`šŸ“ Loaded ${this.customRules.length} custom rule(s) from ${rulesFile}`);
132
- }
133
- } catch (e) {
134
- console.error(`āš ļø Failed to load custom rules: ${e.message}`);
135
- }
136
- }
137
-
138
- // Load .guava-guard-ignore / .guard-scanner-ignore from scan directory
139
- loadIgnoreFile(scanDir) {
140
- const ignorePaths = [
141
- path.join(scanDir, '.guard-scanner-ignore'),
142
- path.join(scanDir, '.guava-guard-ignore'),
143
- ];
144
- for (const ignorePath of ignorePaths) {
145
- if (!fs.existsSync(ignorePath)) continue;
146
- const lines = fs.readFileSync(ignorePath, 'utf-8').split('\n');
147
- for (const line of lines) {
148
- const trimmed = line.trim();
149
- if (!trimmed || trimmed.startsWith('#')) continue;
150
- if (trimmed.startsWith('pattern:')) {
151
- this.ignoredPatterns.add(trimmed.replace('pattern:', '').trim());
152
- } else {
153
- this.ignoredSkills.add(trimmed);
154
- }
155
- }
156
- if (this.verbose && (this.ignoredSkills.size || this.ignoredPatterns.size)) {
157
- console.log(`šŸ“‹ Loaded ignore file: ${this.ignoredSkills.size} skills, ${this.ignoredPatterns.size} patterns`);
158
- }
159
- break; // use first found
160
- }
161
- }
162
-
163
- /**
164
- * Scan raw text for threats (used for Discord incoming messages, etc.)
165
- * @param {string} text - Raw text to scan
166
- * @returns {{ safe: boolean, risk: number, detections: Array }}
167
- */
168
- scanText(text) {
169
- const findings = [];
170
- this.checkIoCs(text, 'raw_text', findings);
171
- this.checkPatterns(text, 'raw_text', 'code', findings); // use 'code' to run all patterns
172
- if (this.customRules.length > 0) {
173
- this.checkPatterns(text, 'raw_text', 'code', findings, this.customRules);
174
- }
175
-
176
- // Filter ignored patterns
177
- const filteredFindings = findings.filter(f => !this.ignoredPatterns.has(f.id));
178
- const risk = this.calculateRisk(filteredFindings);
179
-
180
- return {
181
- safe: risk < this.thresholds.suspicious,
182
- risk,
183
- detections: filteredFindings
184
- };
185
- }
186
-
187
- scanDirectory(dir) {
188
- if (!fs.existsSync(dir)) {
189
- console.error(`āŒ Directory not found: ${dir}`);
190
- process.exit(2);
191
- }
192
-
193
- this.loadIgnoreFile(dir);
194
-
195
- const skills = fs.readdirSync(dir).filter(f => {
196
- const p = path.join(dir, f);
197
- return fs.statSync(p).isDirectory();
198
- });
199
-
200
- if (!this.quiet) {
201
- console.log(`\nšŸ›”ļø guard-scanner v${VERSION}`);
202
- console.log(`${'═'.repeat(54)}`);
203
- console.log(`šŸ“‚ Scanning: ${dir}`);
204
- console.log(`šŸ“¦ Skills found: ${skills.length}`);
205
- if (this.strict) console.log(`⚔ Strict mode enabled`);
206
- console.log();
207
- }
208
-
209
- for (const skill of skills) {
210
- const skillPath = path.join(dir, skill);
211
-
212
- // Self-exclusion
213
- if (this.selfExclude && path.resolve(skillPath) === this.scannerDir) {
214
- if (!this.summaryOnly && !this.quiet) console.log(`ā­ļø ${skill} — SELF (excluded)`);
215
- continue;
216
- }
217
-
218
- // Ignore list
219
- if (this.ignoredSkills.has(skill)) {
220
- if (!this.summaryOnly && !this.quiet) console.log(`ā­ļø ${skill} — IGNORED`);
221
- continue;
222
- }
223
-
224
- this.scanSkill(skillPath, skill);
225
- }
226
-
227
- if (!this.quiet) this.printSummary();
228
- return this.findings;
229
- }
230
-
231
- scanSkill(skillPath, skillName) {
232
- this.stats.scanned++;
233
- const skillFindings = [];
234
-
235
- // Check 1: Known malicious skill name
236
- if (KNOWN_MALICIOUS.typosquats.includes(skillName.toLowerCase())) {
237
- skillFindings.push({
238
- severity: 'CRITICAL', id: 'KNOWN_TYPOSQUAT', cat: 'malicious-code',
239
- desc: `Known malicious/typosquat skill name`,
240
- file: 'SKILL NAME', line: 0
241
- });
242
- }
243
-
244
- // Check 2: Scan all files
245
- const files = this.getFiles(skillPath);
246
- for (const file of files) {
247
- const ext = path.extname(file).toLowerCase();
248
- const relFile = path.relative(skillPath, file);
249
-
250
- if (relFile.includes('node_modules/') || relFile.includes('node_modules\\')) continue;
251
- if (relFile.startsWith('.git/') || relFile.startsWith('.git\\')) continue;
252
- if (BINARY_EXTENSIONS.has(ext)) continue;
253
- if (this.isSelfNoisePath(skillName, relFile)) continue;
254
-
255
- let content;
256
- try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
257
- if (content.length > 500000) continue;
258
-
259
- const fileType = this.classifyFile(ext, relFile);
260
-
261
- // IoC checks
262
- if (!this.isSelfThreatCorpus(skillName, relFile)) {
263
- this.checkIoCs(content, relFile, skillFindings);
264
- }
265
-
266
- // Pattern checks (context-aware)
267
- this.checkPatterns(content, relFile, fileType, skillFindings);
268
-
269
- // Custom rules / plugins
270
- if (this.customRules.length > 0) {
271
- this.checkPatterns(content, relFile, fileType, skillFindings, this.customRules);
272
- }
273
-
274
- // Hardcoded secret detection
275
- const baseName = path.basename(relFile).toLowerCase();
276
- const skipSecretCheck = baseName.endsWith('-lock.json') || baseName === 'package-lock.json' ||
277
- baseName === 'yarn.lock' || baseName === 'pnpm-lock.yaml' ||
278
- baseName === '_meta.json' || baseName === '.package-lock.json';
279
- if (fileType === 'code' && !skipSecretCheck) {
280
- this.checkHardcodedSecrets(content, relFile, skillFindings);
281
- }
282
-
283
- // Lightweight JS data flow analysis
284
- if ((ext === '.js' || ext === '.mjs' || ext === '.cjs' || ext === '.ts') && content.length < 200000) {
285
- this.checkJSDataFlow(content, relFile, skillFindings);
286
- }
287
- }
288
-
289
- // Check 3: Structural checks
290
- this.checkStructure(skillPath, skillName, skillFindings);
291
-
292
- // Check 4: Dependency chain scanning
293
- if (this.checkDeps) {
294
- this.checkDependencies(skillPath, skillName, skillFindings);
295
- }
296
-
297
- // Check 5: Hidden files detection
298
- this.checkHiddenFiles(skillPath, skillName, skillFindings);
299
-
300
- // Check 6: Cross-file analysis
301
- this.checkCrossFile(skillPath, skillName, skillFindings);
302
-
303
- // Check 7: Skill manifest validation (v1.1)
304
- this.checkSkillManifest(skillPath, skillName, skillFindings);
305
-
306
- // Check 8: Code complexity metrics (v1.1)
307
- this.checkComplexity(skillPath, skillName, skillFindings);
308
-
309
- // Check 9: Config impact analysis (v1.1)
310
- this.checkConfigImpact(skillPath, skillName, skillFindings);
311
-
312
- // Filter ignored patterns
313
- const filteredFindings = skillFindings.filter(f => !this.ignoredPatterns.has(f.id));
314
-
315
- // Calculate risk
316
- const risk = this.calculateRisk(filteredFindings);
317
- const verdict = this.getVerdict(risk);
318
-
319
- this.stats[verdict.stat]++;
320
-
321
- if (!this.summaryOnly && !this.quiet) {
322
- console.log(`${verdict.icon} ${skillName} — ${verdict.label} (risk: ${risk})`);
323
-
324
- if (this.verbose && filteredFindings.length > 0) {
325
- const byCat = {};
326
- for (const f of filteredFindings) {
327
- (byCat[f.cat] = byCat[f.cat] || []).push(f);
328
- }
329
- for (const [cat, findings] of Object.entries(byCat)) {
330
- console.log(` šŸ“ ${cat}`);
331
- for (const f of findings) {
332
- const icon = f.severity === 'CRITICAL' ? 'šŸ’€' : f.severity === 'HIGH' ? 'šŸ”“' : f.severity === 'MEDIUM' ? '🟔' : '⚪';
333
- const loc = f.line ? `${f.file}:${f.line}` : f.file;
334
- console.log(` ${icon} [${f.severity}] ${f.desc} — ${loc}`);
335
- if (f.sample) console.log(` └─ "${f.sample}"`);
336
- }
337
- }
338
- }
339
- }
340
-
341
- if (filteredFindings.length > 0) {
342
- this.findings.push({ skill: skillName, risk, verdict: verdict.label, findings: filteredFindings });
343
- }
344
- }
345
-
346
- classifyFile(ext, relFile) {
347
- if (CODE_EXTENSIONS.has(ext)) return 'code';
348
- if (DOC_EXTENSIONS.has(ext)) return 'doc';
349
- if (DATA_EXTENSIONS.has(ext)) return 'data';
350
- const base = path.basename(relFile).toLowerCase();
351
- if (base === 'skill.md' || base === 'readme.md') return 'skill-doc';
352
- return 'other';
353
- }
354
-
355
- isSelfNoisePath(skillName, relFile) {
356
- if (skillName !== 'guard-scanner') return false;
357
- return /^test\//.test(relFile)
358
- || /^dist\/__tests__\//.test(relFile)
359
- || /^ts-src\/__tests__\//.test(relFile)
360
- || /^docs\//.test(relFile)
361
- || relFile === 'ROADMAP-RESEARCH.md'
362
- || relFile === 'CHANGELOG.md';
363
- }
364
-
365
- isSelfThreatCorpus(skillName, relFile) {
366
- if (skillName !== 'guard-scanner') return false;
367
- return /(^|\/)(ioc-db|patterns)\.(js|ts)$/.test(relFile);
368
- }
369
-
370
- checkIoCs(content, relFile, findings) {
371
- const contentLower = content.toLowerCase();
372
-
373
- for (const ip of KNOWN_MALICIOUS.ips) {
374
- if (content.includes(ip)) {
375
- findings.push({ severity: 'CRITICAL', id: 'IOC_IP', cat: 'malicious-code', desc: `Known malicious IP: ${ip}`, file: relFile });
376
- }
377
- }
378
-
379
- for (const url of KNOWN_MALICIOUS.urls) {
380
- if (contentLower.includes(url.toLowerCase())) {
381
- findings.push({ severity: 'CRITICAL', id: 'IOC_URL', cat: 'malicious-code', desc: `Known malicious URL: ${url}`, file: relFile });
382
- }
383
- }
384
-
385
- for (const domain of KNOWN_MALICIOUS.domains) {
386
- const domainRegex = new RegExp(`(?:https?://|[\\s'"\`(]|^)${domain.replace(/\./g, '\\.')}`, 'gi');
387
- if (domainRegex.test(content)) {
388
- findings.push({ severity: 'HIGH', id: 'IOC_DOMAIN', cat: 'exfiltration', desc: `Suspicious domain: ${domain}`, file: relFile });
389
- }
390
- }
391
-
392
- for (const fname of KNOWN_MALICIOUS.filenames) {
393
- if (contentLower.includes(fname.toLowerCase())) {
394
- findings.push({ severity: 'CRITICAL', id: 'IOC_FILE', cat: 'suspicious-download', desc: `Known malicious filename: ${fname}`, file: relFile });
395
- }
396
- }
397
-
398
- for (const user of KNOWN_MALICIOUS.usernames) {
399
- if (contentLower.includes(user.toLowerCase())) {
400
- findings.push({ severity: 'HIGH', id: 'IOC_USER', cat: 'malicious-code', desc: `Known malicious username: ${user}`, file: relFile });
401
- }
402
- }
403
- }
404
-
405
- checkPatterns(content, relFile, fileType, findings, patterns = PATTERNS) {
406
- // v9: Payload Unfurling (Base64 / Hex Decoders)
407
- let unfurledContent = content;
408
-
409
- // Unfurl Buffer.from('...', 'base64') and atob('...')
410
- const b64Regex = /(?:Buffer\.from\(\s*['"]([^'"]+)['"]\s*,\s*['"]base64['"]\)|atob\(\s*['"]([^'"]+)['"]\))/g;
411
- unfurledContent = unfurledContent.replace(b64Regex, (match, g1, g2) => {
412
- try {
413
- const b64 = g1 || g2;
414
- return Buffer.from(b64, 'base64').toString('utf8');
415
- } catch { return match; }
416
- });
417
-
418
- // Unfurl hex escaped strings like \x63\x61\x74 -> cat
419
- unfurledContent = unfurledContent.replace(/\\x([0-9a-fA-F]{2})/g, (match, hex) => {
420
- return String.fromCharCode(parseInt(hex, 16));
421
- });
422
-
423
- for (const pattern of patterns) {
424
- // Soul Lock: skip identity-hijack/memory-poisoning patterns unless --soul-lock is enabled
425
- if (pattern.soulLock && !this.soulLock) continue;
426
- if (pattern.codeOnly && fileType !== 'code') continue;
427
- if (pattern.docOnly && fileType !== 'doc' && fileType !== 'skill-doc') continue;
428
- if (!pattern.all && !pattern.codeOnly && !pattern.docOnly) continue;
429
-
430
- pattern.regex.lastIndex = 0;
431
- let matches = content.match(pattern.regex);
432
- let targetContent = content;
433
-
434
- // If no match on raw content, try unfurled content
435
- if (!matches && unfurledContent !== content) {
436
- pattern.regex.lastIndex = 0;
437
- matches = unfurledContent.match(pattern.regex);
438
- targetContent = unfurledContent;
439
- }
440
-
441
- if (!matches) continue;
442
-
443
- pattern.regex.lastIndex = 0;
444
- const idx = targetContent.search(pattern.regex);
445
- const lineNum = idx >= 0 ? targetContent.substring(0, idx).split('\n').length : null;
446
-
447
- let adjustedSeverity = pattern.severity;
448
- if ((fileType === 'doc' || fileType === 'skill-doc') && pattern.all && !pattern.docOnly) {
449
- if (adjustedSeverity === 'HIGH') adjustedSeverity = 'MEDIUM';
450
- else if (adjustedSeverity === 'MEDIUM') adjustedSeverity = 'LOW';
451
- }
452
-
453
- findings.push({
454
- severity: adjustedSeverity,
455
- id: pattern.id,
456
- cat: pattern.cat,
457
- desc: pattern.desc,
458
- file: relFile,
459
- line: lineNum,
460
- matchCount: matches.length,
461
- sample: matches[0].substring(0, 80)
462
- });
463
- }
464
- }
465
-
466
- // Entropy-based secret detection
467
- checkHardcodedSecrets(content, relFile, findings) {
468
- const assignmentRegex = /(?:api[_-]?key|secret|token|password|credential|auth)\s*[:=]\s*['"]([a-zA-Z0-9_\-+/=]{16,})['"]|['"]([a-zA-Z0-9_\-+/=]{32,})['"]/gi;
469
- let match;
470
- while ((match = assignmentRegex.exec(content)) !== null) {
471
- const value = match[1] || match[2];
472
- if (!value) continue;
473
-
474
- if (/^[A-Z_]+$/.test(value)) continue;
475
- if (/^(true|false|null|undefined|none|default|example|test|placeholder|your[_-])/i.test(value)) continue;
476
- if (/^x{4,}|\.{4,}|_{4,}|0{8,}$/i.test(value)) continue;
477
- if (/^projects\/|^gs:\/\/|^https?:\/\//i.test(value)) continue;
478
- if (/^[a-z]+-[a-z]+-[a-z0-9]+$/i.test(value)) continue;
479
-
480
- const entropy = this.shannonEntropy(value);
481
- if (entropy > 3.5 && value.length >= 20) {
482
- const lineNum = content.substring(0, match.index).split('\n').length;
483
- findings.push({
484
- severity: 'HIGH', id: 'SECRET_ENTROPY', cat: 'secret-detection',
485
- desc: `High-entropy string (possible leaked secret, entropy=${entropy.toFixed(1)})`,
486
- file: relFile, line: lineNum,
487
- sample: value.substring(0, 8) + '...' + value.substring(value.length - 4)
488
- });
489
- }
490
- }
491
- }
492
-
493
- shannonEntropy(str) {
494
- const freq = {};
495
- for (const c of str) freq[c] = (freq[c] || 0) + 1;
496
- const len = str.length;
497
- let entropy = 0;
498
- for (const count of Object.values(freq)) {
499
- const p = count / len;
500
- if (p > 0) entropy -= p * Math.log2(p);
501
- }
502
- return entropy;
503
- }
504
-
505
- checkStructure(skillPath, skillName, findings) {
506
- const skillMd = path.join(skillPath, 'SKILL.md');
507
- if (!fs.existsSync(skillMd)) {
508
- findings.push({ severity: 'LOW', id: 'STRUCT_NO_SKILLMD', cat: 'structural', desc: 'No SKILL.md found', file: skillName });
509
- return;
510
- }
511
- const content = fs.readFileSync(skillMd, 'utf-8');
512
- if (content.length < 50) {
513
- findings.push({ severity: 'MEDIUM', id: 'STRUCT_TINY_SKILLMD', cat: 'structural', desc: 'Suspiciously short SKILL.md (< 50 chars)', file: 'SKILL.md' });
514
- }
515
- const scriptsDir = path.join(skillPath, 'scripts');
516
- if (fs.existsSync(scriptsDir)) {
517
- const scripts = fs.readdirSync(scriptsDir).filter(f => CODE_EXTENSIONS.has(path.extname(f).toLowerCase()));
518
- if (scripts.length > 0 && !content.includes('scripts/')) {
519
- findings.push({ severity: 'MEDIUM', id: 'STRUCT_UNDOCUMENTED_SCRIPTS', cat: 'structural', desc: `${scripts.length} script(s) in scripts/ not referenced in SKILL.md`, file: 'scripts/' });
520
- }
521
- }
522
- }
523
-
524
- checkDependencies(skillPath, skillName, findings) {
525
- const pkgPath = path.join(skillPath, 'package.json');
526
- if (!fs.existsSync(pkgPath)) return;
527
-
528
- let pkg;
529
- try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); } catch { return; }
530
-
531
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies };
532
-
533
- const RISKY_PACKAGES = new Set([
534
- 'node-ipc', 'colors', 'faker', 'event-stream', 'ua-parser-js', 'coa', 'rc',
535
- ]);
536
-
537
- for (const [dep, version] of Object.entries(allDeps)) {
538
- if (RISKY_PACKAGES.has(dep)) {
539
- findings.push({ severity: 'HIGH', id: 'DEP_RISKY', cat: 'dependency-chain', desc: `Known risky dependency: ${dep}@${version}`, file: 'package.json' });
540
- }
541
- if (typeof version === 'string' && (version.startsWith('git+') || version.startsWith('http') || version.startsWith('github:') || version.includes('.tar.gz'))) {
542
- findings.push({ severity: 'HIGH', id: 'DEP_REMOTE', cat: 'dependency-chain', desc: `Remote/git dependency: ${dep}@${version}`, file: 'package.json' });
543
- }
544
- if (version === '*' || version === 'latest') {
545
- findings.push({ severity: 'MEDIUM', id: 'DEP_WILDCARD', cat: 'dependency-chain', desc: `Wildcard version: ${dep}@${version}`, file: 'package.json' });
546
- }
547
- }
548
-
549
- const RISKY_SCRIPTS = ['preinstall', 'postinstall', 'preuninstall', 'postuninstall', 'prepare'];
550
- if (pkg.scripts) {
551
- for (const scriptName of RISKY_SCRIPTS) {
552
- if (pkg.scripts[scriptName]) {
553
- const cmd = pkg.scripts[scriptName];
554
- findings.push({ severity: 'HIGH', id: 'DEP_LIFECYCLE', cat: 'dependency-chain', desc: `Lifecycle script "${scriptName}": ${cmd.substring(0, 80)}`, file: 'package.json' });
555
- if (/curl|wget|node\s+-e|eval|exec|bash\s+-c/i.test(cmd)) {
556
- 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) });
557
- }
558
- }
559
- }
560
- }
561
- }
562
-
563
- // ── v1.1: Skill Manifest Validation ──
564
- // Checks SKILL.md frontmatter for dangerous tool declarations,
565
- // overly broad file scope, and sensitive env requirements
566
- checkSkillManifest(skillPath, skillName, findings) {
567
- const skillMd = path.join(skillPath, 'SKILL.md');
568
- if (!fs.existsSync(skillMd)) return;
569
-
570
- let content;
571
- try { content = fs.readFileSync(skillMd, 'utf-8'); } catch { return; }
572
-
573
- // Parse YAML frontmatter (lightweight, no dependency)
574
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
575
- if (!fmMatch) return;
576
- const fm = fmMatch[1];
577
-
578
- // Check 1: Dangerous binary requirements
579
- const DANGEROUS_BINS = new Set([
580
- 'sudo', 'rm', 'rmdir', 'chmod', 'chown', 'kill', 'pkill',
581
- 'curl', 'wget', 'nc', 'ncat', 'socat', 'ssh', 'scp',
582
- 'dd', 'mkfs', 'fdisk', 'mount', 'umount',
583
- 'iptables', 'ufw', 'firewall-cmd',
584
- 'docker', 'kubectl', 'systemctl',
585
- ]);
586
- const binsMatch = fm.match(/bins:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
587
- if (binsMatch) {
588
- const bins = binsMatch[1].match(/- ([^\n]+)/g) || [];
589
- for (const binLine of bins) {
590
- const bin = binLine.replace(/^-\s*/, '').trim().toLowerCase();
591
- if (DANGEROUS_BINS.has(bin)) {
592
- findings.push({
593
- severity: 'HIGH', id: 'MANIFEST_DANGEROUS_BIN',
594
- cat: 'sandbox-validation',
595
- desc: `SKILL.md requires dangerous binary: ${bin}`,
596
- file: 'SKILL.md'
597
- });
598
- }
599
- }
600
- }
601
-
602
- // Check 2: Overly broad file scope
603
- const filesMatch = fm.match(/files:\s*\[([^\]]+)\]/i) || fm.match(/files:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
604
- if (filesMatch) {
605
- const filesStr = filesMatch[1];
606
- if (/\*\*\/\*|\*\.\*|\"\*\"/i.test(filesStr)) {
607
- findings.push({
608
- severity: 'HIGH', id: 'MANIFEST_BROAD_FILES',
609
- cat: 'sandbox-validation',
610
- desc: 'SKILL.md declares overly broad file scope (e.g. **/*)',
611
- file: 'SKILL.md'
612
- });
613
- }
614
- }
615
-
616
- // Check 3: Sensitive env requirements
617
- const SENSITIVE_ENV_PATTERNS = /(?:SECRET|PASSWORD|CREDENTIAL|PRIVATE_KEY|AWS_SECRET|GITHUB_TOKEN)/i;
618
- const envMatch = fm.match(/env:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
619
- if (envMatch) {
620
- const envVars = envMatch[1].match(/- ([^\n]+)/g) || [];
621
- for (const envLine of envVars) {
622
- const envVar = envLine.replace(/^-\s*/, '').trim();
623
- if (SENSITIVE_ENV_PATTERNS.test(envVar)) {
624
- findings.push({
625
- severity: 'HIGH', id: 'MANIFEST_SENSITIVE_ENV',
626
- cat: 'sandbox-validation',
627
- desc: `SKILL.md requires sensitive env var: ${envVar}`,
628
- file: 'SKILL.md'
629
- });
630
- }
631
- }
632
- }
633
-
634
- // Check 4: exec or network declared without justification
635
- if (/exec:\s*(?:true|yes|enabled|'\*'|"\*")/i.test(fm)) {
636
- findings.push({
637
- severity: 'MEDIUM', id: 'MANIFEST_EXEC_DECLARED',
638
- cat: 'sandbox-validation',
639
- desc: 'SKILL.md declares exec capability',
640
- file: 'SKILL.md'
641
- });
642
- }
643
- if (/network:\s*(?:true|yes|enabled|'\*'|"\*"|all|any)/i.test(fm)) {
644
- findings.push({
645
- severity: 'MEDIUM', id: 'MANIFEST_NETWORK_DECLARED',
646
- cat: 'sandbox-validation',
647
- desc: 'SKILL.md declares unrestricted network access',
648
- file: 'SKILL.md'
649
- });
650
- }
651
- }
652
-
653
- // ── v1.1: Code Complexity Metrics ──
654
- // Detects excessive file length, deep nesting, and eval/exec density
655
- checkComplexity(skillPath, skillName, findings) {
656
- const files = this.getFiles(skillPath);
657
- const MAX_LINES = 1000;
658
- const MAX_NESTING = 5;
659
- const MAX_EVAL_DENSITY = 0.02; // 2% of lines
660
-
661
- for (const file of files) {
662
- const ext = path.extname(file).toLowerCase();
663
- if (!CODE_EXTENSIONS.has(ext)) continue;
664
-
665
- const relFile = path.relative(skillPath, file);
666
- if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
667
-
668
- let content;
669
- try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
670
-
671
- const lines = content.split('\n');
672
-
673
- // Check 1: Excessive file length
674
- if (lines.length > MAX_LINES) {
675
- findings.push({
676
- severity: 'MEDIUM', id: 'COMPLEXITY_LONG_FILE',
677
- cat: 'complexity',
678
- desc: `File exceeds ${MAX_LINES} lines (${lines.length} lines)`,
679
- file: relFile
680
- });
681
- }
682
-
683
- // Check 2: Deep nesting (brace tracking)
684
- let maxDepth = 0;
685
- let currentDepth = 0;
686
- let deepestLine = 0;
687
- for (let i = 0; i < lines.length; i++) {
688
- const line = lines[i];
689
- // Count opening/closing braces outside strings (simplified)
690
- for (const ch of line) {
691
- if (ch === '{') currentDepth++;
692
- if (ch === '}') currentDepth = Math.max(0, currentDepth - 1);
693
- }
694
- if (currentDepth > maxDepth) {
695
- maxDepth = currentDepth;
696
- deepestLine = i + 1;
697
- }
698
- }
699
- if (maxDepth > MAX_NESTING) {
700
- findings.push({
701
- severity: 'MEDIUM', id: 'COMPLEXITY_DEEP_NESTING',
702
- cat: 'complexity',
703
- desc: `Deep nesting detected: ${maxDepth} levels (max recommended: ${MAX_NESTING})`,
704
- file: relFile, line: deepestLine
705
- });
706
- }
707
-
708
- // Check 3: eval/exec density
709
- const evalPattern = /\b(?:eval|exec|execSync|spawn|Function)\s*\(/g;
710
- const evalMatches = content.match(evalPattern) || [];
711
- const density = lines.length > 0 ? evalMatches.length / lines.length : 0;
712
- if (density > MAX_EVAL_DENSITY && evalMatches.length >= 3) {
713
- findings.push({
714
- severity: 'HIGH', id: 'COMPLEXITY_EVAL_DENSITY',
715
- cat: 'complexity',
716
- desc: `High eval/exec density: ${evalMatches.length} calls in ${lines.length} lines (${(density * 100).toFixed(1)}%)`,
717
- file: relFile
718
- });
719
- }
720
- }
721
- }
722
-
723
- // ── v1.1: Config Impact Analysis ──
724
- // Detects modifications to openclaw.json and dangerous configuration changes
725
- checkConfigImpact(skillPath, skillName, findings) {
726
- const files = this.getFiles(skillPath);
727
-
728
- for (const file of files) {
729
- const ext = path.extname(file).toLowerCase();
730
- if (!CODE_EXTENSIONS.has(ext) && ext !== '.json') continue;
731
-
732
- const relFile = path.relative(skillPath, file);
733
- if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
734
-
735
- let content;
736
- try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
737
-
738
- // Check 1: openclaw.json reference + write operation in same file
739
- // Handles both direct and variable-based patterns (e.g. writeFileSync(configPath))
740
- const hasConfigRef = /openclaw\.json/i.test(content);
741
- const hasWriteOp = /(?:writeFileSync|writeFile|fs\.write)\s*\(/i.test(content);
742
- if (hasConfigRef && hasWriteOp) {
743
- // Find the write line for location info
744
- const clines = content.split('\n');
745
- let writeLine = 0;
746
- for (let i = 0; i < clines.length; i++) {
747
- if (/(?:writeFileSync|writeFile|fs\.write)\s*\(/i.test(clines[i])) {
748
- writeLine = i + 1;
749
- break;
750
- }
751
- }
752
- findings.push({
753
- severity: 'CRITICAL', id: 'CFG_WRITE_DETECTED',
754
- cat: 'config-impact',
755
- desc: 'Code writes to openclaw.json',
756
- file: relFile, line: writeLine,
757
- sample: writeLine > 0 ? clines[writeLine - 1].trim().substring(0, 80) : ''
758
- });
759
- }
760
-
761
- // Check 2: Dangerous config key modifications
762
- const DANGEROUS_CONFIG_KEYS = [
763
- { regex: /exec\.approvals?\s*[:=]\s*['"]?(off|false|disabled|none)/gi, id: 'CFG_EXEC_APPROVAL_OFF', desc: 'Disables exec approval requirement', severity: 'CRITICAL' },
764
- { regex: /tools\.exec\.host\s*[:=]\s*['"]gateway['"]/gi, id: 'CFG_EXEC_HOST_GATEWAY', desc: 'Sets exec host to gateway (bypasses sandbox)', severity: 'CRITICAL' },
765
- { regex: /hooks\s*\.\s*internal\s*\.\s*entries\s*[:=]/gi, id: 'CFG_HOOKS_INTERNAL', desc: 'Modifies internal hook entries', severity: 'HIGH' },
766
- { regex: /network\.allowedDomains\s*[:=]\s*\[?\s*['"]\*['"]/gi, id: 'CFG_NET_WILDCARD', desc: 'Sets network allowedDomains to wildcard', severity: 'HIGH' },
767
- ];
768
-
769
- for (const check of DANGEROUS_CONFIG_KEYS) {
770
- check.regex.lastIndex = 0;
771
- if (check.regex.test(content)) {
772
- findings.push({
773
- severity: check.severity, id: check.id,
774
- cat: 'config-impact',
775
- desc: check.desc,
776
- file: relFile
777
- });
778
- }
779
- }
780
- }
781
- }
782
-
783
- checkHiddenFiles(skillPath, skillName, findings) {
784
- try {
785
- const entries = fs.readdirSync(skillPath);
786
- for (const entry of entries) {
787
- if (entry.startsWith('.') && entry !== '.guard-scanner-ignore' && entry !== '.guava-guard-ignore' && entry !== '.gitignore' && entry !== '.git') {
788
- const fullPath = path.join(skillPath, entry);
789
- const stat = fs.statSync(fullPath);
790
- if (stat.isFile()) {
791
- const ext = path.extname(entry).toLowerCase();
792
- if (CODE_EXTENSIONS.has(ext) || ext === '' || ext === '.sh') {
793
- findings.push({ severity: 'MEDIUM', id: 'STRUCT_HIDDEN_EXEC', cat: 'structural', desc: `Hidden executable file: ${entry}`, file: entry });
794
- }
795
- } else if (stat.isDirectory() && entry !== '.git') {
796
- findings.push({ severity: 'LOW', id: 'STRUCT_HIDDEN_DIR', cat: 'structural', desc: `Hidden directory: ${entry}/`, file: entry });
797
- }
798
- }
799
- }
800
- } catch { }
801
- }
802
-
803
- checkJSDataFlow(content, relFile, findings) {
804
- // v9: Pseudo-AST Semantic Unfurling & Alias Tracking
805
- // 1. Resolve string concatenations (e.g., '"f" + "etch"' -> '"fetch"')
806
- let unfurledContent = content.replace(/(["'`])([^"'`]*)\1\s*\+\s*(["'`])([^"'`]*)\3/g, '$1$2$4$1');
807
- for (let i = 0; i < 3; i++) { // Deep unfurl (up to 3 concats)
808
- unfurledContent = unfurledContent.replace(/(["'`])([^"'`]*)\1\s*\+\s*(["'`])([^"'`]*)\3/g, '$1$2$4$1');
809
- }
810
-
811
- const lines = unfurledContent.split('\n');
812
- const imports = new Map();
813
- const sensitiveReads = [];
814
- const networkCalls = [];
815
- const execCalls = [];
816
-
817
- // Alias Tracker for Sinks & Vars
818
- const activeAliases = {
819
- network: ['fetch', 'axios', 'request', 'http.request', 'https.request', 'got'],
820
- exec: ['exec', 'execSync', 'spawn', 'spawnSync', 'execFile', "require('child_process').execSync"],
821
- fsRead: ['readFileSync', 'readFile', 'fs.readFileSync', 'fs.readFile', "require('fs').readFileSync"]
822
- };
823
- const stringVars = new Map();
824
-
825
- const registerAlias = (alias, target) => {
826
- if (!alias || !target) return;
827
- for (const [key, sinks] of Object.entries(activeAliases)) {
828
- if (sinks.some(s => target.includes(s) || s.includes(target))) {
829
- activeAliases[key].push(alias);
830
- }
831
- }
832
- };
833
-
834
- // Pass 1: Extract Context & Aliases & Values
835
- for (let i = 0; i < lines.length; i++) {
836
- const line = lines[i];
837
-
838
- // Standard variable assignment: const getRemote = fetch;
839
- const aliasMatch = line.match(/(?:const|let|var)\s+([a-zA-Z0-9_$]+)\s*=\s*([a-zA-Z0-9_$.]+(?:\([^)]*\))?)\s*;/);
840
- if (aliasMatch) {
841
- registerAlias(aliasMatch[1], aliasMatch[2]);
842
- }
843
-
844
- // String literals: const target = ".env";
845
- const strMatch = line.match(/(?:const|let|var)\s+([a-zA-Z0-9_$]+)\s*=\s*(["'`])([^"'`]+)\2/);
846
- if (strMatch) {
847
- stringVars.set(strMatch[1], strMatch[3]); // target -> .env
848
- }
849
-
850
- // Require assignments: const fs = require('fs')
851
- const reqMatch = line.match(/(?:const|let|var)\s+(?:{[^}]+}|\w+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
852
- if (reqMatch) {
853
- const varMatch = line.match(/(?:const|let|var)\s+({[^}]+}|\w+)/);
854
- if (varMatch) {
855
- const aliasName = varMatch[1].trim();
856
- imports.set(aliasName, reqMatch[1]);
857
- registerAlias(`${aliasName}.readFileSync`, 'readFileSync'); // Link fs methods
858
- registerAlias(`${aliasName}.readFile`, 'readFile');
859
- registerAlias(`${aliasName}.exec`, 'exec');
860
- registerAlias(`${aliasName}.execSync`, 'execSync');
861
- }
862
- }
863
- }
864
-
865
- // Helper to create safe regex from dynamic aliases
866
- const escapeRegex = (arr) => arr.map(a => a.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')).join('|');
867
-
868
- // Pass 2: Data Flow Matching with Interpolation
869
- for (let i = 0; i < lines.length; i++) {
870
- const line = lines[i];
871
- const lineNum = i + 1;
872
-
873
- // Pseudo-AST: substitute known literal vars into the line to reveal logic
874
- let resolvedLine = line;
875
- for (const [k, v] of stringVars.entries()) {
876
- // replace var usage but only for whole words
877
- resolvedLine = resolvedLine.replace(new RegExp(`\\b${k}\\b`, 'g'), `"${v}"`);
878
- }
879
-
880
- const fsPattern = new RegExp(`(?:${escapeRegex(activeAliases.fsRead)})\\s*\\([^)]*(?:\\.env|\\.ssh|id_rsa|\\.clawdbot|\\.openclaw(?!\\/workspace))`, 'i');
881
- if (fsPattern.test(resolvedLine)) {
882
- sensitiveReads.push({ line: lineNum, text: resolvedLine.trim() });
883
- }
884
- if (/process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i.test(resolvedLine)) {
885
- sensitiveReads.push({ line: lineNum, text: resolvedLine.trim() });
886
- }
887
-
888
- const netPattern = new RegExp(`(?:${escapeRegex(activeAliases.network)})\\s*\\(`, 'i');
889
- if (netPattern.test(resolvedLine) || /\.post\s*\(|\.put\s*\(|\.patch\s*\(/.test(resolvedLine)) {
890
- networkCalls.push({ line: lineNum, text: resolvedLine.trim() });
891
- }
892
-
893
- const execPattern = new RegExp(`(?:${escapeRegex(activeAliases.exec)})\\s*\\(`, 'i');
894
- if (execPattern.test(resolvedLine)) {
895
- execCalls.push({ line: lineNum, text: resolvedLine.trim() });
896
- }
897
- }
898
-
899
- if (sensitiveReads.length > 0 && networkCalls.length > 0) {
900
- findings.push({
901
- severity: 'CRITICAL', id: 'AST_CRED_TO_NET', cat: 'data-flow',
902
- desc: `Data flow: secret read (L${sensitiveReads[0].line}) → network call (L${networkCalls[0].line})`,
903
- file: relFile, line: sensitiveReads[0].line,
904
- sample: sensitiveReads[0].text.substring(0, 60)
905
- });
906
- }
907
-
908
- if (sensitiveReads.length > 0 && execCalls.length > 0) {
909
- findings.push({
910
- severity: 'HIGH', id: 'AST_CRED_TO_EXEC', cat: 'data-flow',
911
- desc: `Data flow: secret read (L${sensitiveReads[0].line}) → command exec (L${execCalls[0].line})`,
912
- file: relFile, line: sensitiveReads[0].line,
913
- sample: sensitiveReads[0].text.substring(0, 60)
914
- });
915
- }
916
-
917
- const importedModules = new Set([...imports.values()]);
918
- if (importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http') || importedModules.has('node-fetch'))) {
919
- findings.push({ severity: 'HIGH', id: 'AST_SUSPICIOUS_IMPORTS', cat: 'data-flow', desc: 'Suspicious import combination: child_process + network module', file: relFile });
920
- }
921
- if (importedModules.has('fs') && importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http'))) {
922
- findings.push({ severity: 'CRITICAL', id: 'AST_EXFIL_TRIFECTA', cat: 'data-flow', desc: 'Exfiltration trifecta: fs + child_process + network', file: relFile });
923
- }
924
-
925
- for (let i = 0; i < lines.length; i++) {
926
- const line = lines[i];
927
- if (/`[^`]*\$\{.*(?:env|key|token|secret|password).*\}[^`]*`\s*(?:\)|,)/i.test(line) &&
928
- /(?:fetch|request|axios|http|url)/i.test(line)) {
929
- findings.push({ severity: 'CRITICAL', id: 'AST_SECRET_IN_URL', cat: 'data-flow', desc: 'Secret interpolated into URL/request', file: relFile, line: i + 1, sample: line.trim().substring(0, 80) });
930
- }
931
- }
932
- }
933
-
934
- checkCrossFile(skillPath, skillName, findings) {
935
- const files = this.getFiles(skillPath);
936
- const allContent = {};
937
-
938
- for (const file of files) {
939
- const ext = path.extname(file).toLowerCase();
940
- if (BINARY_EXTENSIONS.has(ext)) continue;
941
- const relFile = path.relative(skillPath, file);
942
- if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
943
- try {
944
- const content = fs.readFileSync(file, 'utf-8');
945
- if (content.length < 500000) allContent[relFile] = content;
946
- } catch { }
947
- }
948
-
949
- const skillMd = allContent['SKILL.md'] || '';
950
- const codeFileRefs = skillMd.match(/(?:scripts?\/|\.\/)[a-zA-Z0-9_\-./]+\.(js|py|sh|ts)/gi) || [];
951
- for (const ref of codeFileRefs) {
952
- const cleanRef = ref.replace(/^\.\//, '');
953
- if (!allContent[cleanRef] && !files.some(f => path.relative(skillPath, f) === cleanRef)) {
954
- findings.push({ severity: 'MEDIUM', id: 'XFILE_PHANTOM_REF', cat: 'structural', desc: `SKILL.md references non-existent file: ${cleanRef}`, file: 'SKILL.md' });
955
- }
956
- }
957
-
958
- const base64Fragments = [];
959
- for (const [file, content] of Object.entries(allContent)) {
960
- const matches = content.match(/[A-Za-z0-9+/]{20,}={0,2}/g) || [];
961
- for (const m of matches) {
962
- if (m.length > 40) base64Fragments.push({ file, fragment: m.substring(0, 30) });
963
- }
964
- }
965
- if (base64Fragments.length > 3 && new Set(base64Fragments.map(f => f.file)).size > 1) {
966
- findings.push({ severity: 'HIGH', id: 'XFILE_FRAGMENT_B64', cat: 'obfuscation', desc: `Base64 fragments across ${new Set(base64Fragments.map(f => f.file)).size} files`, file: skillName });
967
- }
968
-
969
- if (/(?:read|load|source|import)\s+(?:the\s+)?(?:script|file|code)\s+(?:from|at|in)\s+(?:scripts?\/)/gi.test(skillMd)) {
970
- const hasExec = Object.values(allContent).some(c => /(?:eval|exec|spawn)\s*\(/i.test(c));
971
- if (hasExec) {
972
- findings.push({ severity: 'MEDIUM', id: 'XFILE_LOAD_EXEC', cat: 'data-flow', desc: 'SKILL.md references script files that contain exec/eval', file: 'SKILL.md' });
973
- }
974
- }
975
- }
976
-
977
- calculateRisk(findings) {
978
- if (findings.length === 0) return 0;
979
-
980
- let score = 0;
981
- for (const f of findings) {
982
- score += SEVERITY_WEIGHTS[f.severity] || 0;
983
- }
984
-
985
- const ids = new Set(findings.map(f => f.id));
986
- const cats = new Set(findings.map(f => f.cat));
987
-
988
- if (cats.has('credential-handling') && cats.has('exfiltration')) score = Math.round(score * 2);
989
- if (cats.has('credential-handling') && findings.some(f => f.id === 'MAL_CHILD' || f.id === 'MAL_EXEC')) score = Math.round(score * 1.5);
990
- if (cats.has('obfuscation') && (cats.has('malicious-code') || cats.has('credential-handling'))) score = Math.round(score * 2);
991
- if (ids.has('DEP_LIFECYCLE_EXEC')) score = Math.round(score * 2);
992
- if (ids.has('PI_BIDI') && findings.length > 1) score = Math.round(score * 1.5);
993
- if (cats.has('leaky-skills') && (cats.has('exfiltration') || cats.has('malicious-code'))) score = Math.round(score * 2);
994
- if (cats.has('memory-poisoning')) score = Math.round(score * 1.5);
995
- if (cats.has('prompt-worm')) score = Math.round(score * 2);
996
- if (cats.has('cve-patterns')) score = Math.max(score, 70);
997
- if (cats.has('persistence') && (cats.has('malicious-code') || cats.has('credential-handling') || cats.has('memory-poisoning'))) score = Math.round(score * 1.5);
998
- if (cats.has('identity-hijack')) score = Math.round(score * 2);
999
- if (cats.has('identity-hijack') && (cats.has('persistence') || cats.has('memory-poisoning'))) score = Math.max(score, 90);
1000
- if (ids.has('IOC_IP') || ids.has('IOC_URL') || ids.has('KNOWN_TYPOSQUAT')) score = 100;
1001
-
1002
- // v1.1 categories
1003
- if (cats.has('config-impact')) score = Math.round(score * 2);
1004
- if (cats.has('config-impact') && cats.has('sandbox-validation')) score = Math.max(score, 70);
1005
- if (cats.has('complexity') && (cats.has('malicious-code') || cats.has('obfuscation'))) score = Math.round(score * 1.5);
1006
-
1007
- // v2.1 PII exposure amplifiers
1008
- if (cats.has('pii-exposure') && cats.has('exfiltration')) score = Math.round(score * 3);
1009
- if (cats.has('pii-exposure') && (ids.has('SHADOW_AI_OPENAI') || ids.has('SHADOW_AI_ANTHROPIC') || ids.has('SHADOW_AI_GENERIC'))) score = Math.round(score * 2.5);
1010
- if (cats.has('pii-exposure') && cats.has('credential-handling')) score = Math.round(score * 2);
1011
-
1012
- return Math.min(100, score);
1013
- }
1014
-
1015
- getVerdict(risk) {
1016
- if (risk >= this.thresholds.malicious) return { icon: 'šŸ”“', label: 'MALICIOUS', stat: 'malicious' };
1017
- if (risk >= this.thresholds.suspicious) return { icon: '🟔', label: 'SUSPICIOUS', stat: 'suspicious' };
1018
- if (risk > 0) return { icon: '🟢', label: 'LOW RISK', stat: 'low' };
1019
- return { icon: '🟢', label: 'CLEAN', stat: 'clean' };
1020
- }
1021
-
1022
- getFiles(dir) {
1023
- const results = [];
1024
- try {
1025
- const entries = fs.readdirSync(dir, { withFileTypes: true });
1026
- for (const entry of entries) {
1027
- const fullPath = path.join(dir, entry.name);
1028
- if (entry.isDirectory()) {
1029
- if (entry.name === '.git' || entry.name === 'node_modules') continue;
1030
- results.push(...this.getFiles(fullPath));
1031
- } else {
1032
- const baseName = entry.name.toLowerCase();
1033
- if (GENERATED_REPORT_FILES.has(baseName)) continue;
1034
- results.push(fullPath);
1035
- }
1036
- }
1037
- } catch { }
1038
- return results;
1039
- }
1040
-
1041
- printSummary() {
1042
- const total = this.stats.scanned;
1043
- const safe = this.stats.clean + this.stats.low;
1044
- console.log(`\n${'═'.repeat(54)}`);
1045
- console.log(`šŸ“Š guard-scanner v${VERSION} Scan Summary`);
1046
- console.log(`${'─'.repeat(54)}`);
1047
- console.log(` Scanned: ${total}`);
1048
- console.log(` 🟢 Clean: ${this.stats.clean}`);
1049
- console.log(` 🟢 Low Risk: ${this.stats.low}`);
1050
- console.log(` 🟔 Suspicious: ${this.stats.suspicious}`);
1051
- console.log(` šŸ”“ Malicious: ${this.stats.malicious}`);
1052
- console.log(` Safety Rate: ${total ? Math.round(safe / total * 100) : 0}%`);
1053
- console.log(`${'═'.repeat(54)}`);
1054
-
1055
- if (this.stats.malicious > 0) {
1056
- console.log(`\nāš ļø CRITICAL: ${this.stats.malicious} malicious skill(s) detected!`);
1057
- console.log(` Review findings with --verbose and remove if confirmed.`);
1058
- } else if (this.stats.suspicious > 0) {
1059
- console.log(`\n⚔ ${this.stats.suspicious} suspicious skill(s) found — review recommended.`);
1060
- } else {
1061
- console.log(`\nāœ… All clear! No threats detected.`);
1062
- }
1063
- }
1064
-
1065
- toJSON() {
1066
- const recommendations = [];
1067
- for (const skillResult of this.findings) {
1068
- const skillRecs = [];
1069
- const cats = new Set(skillResult.findings.map(f => f.cat));
1070
-
1071
- if (cats.has('prompt-injection')) skillRecs.push('šŸ›‘ Contains prompt injection patterns.');
1072
- if (cats.has('malicious-code')) skillRecs.push('šŸ›‘ Contains potentially malicious code.');
1073
- if (cats.has('credential-handling') && cats.has('exfiltration')) skillRecs.push('šŸ’€ CRITICAL: Credential access + exfiltration. DO NOT INSTALL.');
1074
- if (cats.has('dependency-chain')) skillRecs.push('šŸ“¦ Suspicious dependency chain.');
1075
- if (cats.has('obfuscation')) skillRecs.push('šŸ” Code obfuscation detected.');
1076
- if (cats.has('secret-detection')) skillRecs.push('šŸ”‘ Possible hardcoded secrets.');
1077
- if (cats.has('leaky-skills')) skillRecs.push('šŸ’§ LEAKY SKILL: Secrets pass through LLM context.');
1078
- if (cats.has('memory-poisoning')) skillRecs.push('🧠 MEMORY POISONING: Agent memory modification attempt.');
1079
- if (cats.has('prompt-worm')) skillRecs.push('🪱 PROMPT WORM: Self-replicating instructions.');
1080
- if (cats.has('data-flow')) skillRecs.push('šŸ”€ Suspicious data flow patterns.');
1081
- if (cats.has('persistence')) skillRecs.push('ā° PERSISTENCE: Creates scheduled tasks.');
1082
- if (cats.has('cve-patterns')) skillRecs.push('🚨 CVE PATTERN: Matches known exploits.');
1083
- if (cats.has('identity-hijack')) skillRecs.push('šŸ”’ IDENTITY HIJACK: Agent soul file tampering. DO NOT INSTALL.');
1084
- if (cats.has('sandbox-validation')) skillRecs.push('šŸ”’ SANDBOX: Skill requests dangerous capabilities.');
1085
- if (cats.has('complexity')) skillRecs.push('🧩 COMPLEXITY: Excessive code complexity may hide malicious behavior.');
1086
- if (cats.has('config-impact')) skillRecs.push('āš™ļø CONFIG IMPACT: Modifies OpenClaw configuration. DO NOT INSTALL.');
1087
- if (cats.has('pii-exposure')) skillRecs.push('šŸ†” PII EXPOSURE: Handles personally identifiable information. Review data handling.');
1088
-
1089
- if (skillRecs.length > 0) recommendations.push({ skill: skillResult.skill, actions: skillRecs });
1090
- }
1091
-
1092
- return {
1093
- timestamp: new Date().toISOString(),
1094
- scanner: `guard-scanner v${VERSION}`,
1095
- mode: this.strict ? 'strict' : 'normal',
1096
- stats: this.stats,
1097
- thresholds: this.thresholds,
1098
- findings: this.findings,
1099
- recommendations,
1100
- iocVersion: '2026-02-12',
1101
- };
1102
- }
1103
-
1104
- toSARIF(scanDir) {
1105
- const rules = [];
1106
- const ruleIndex = {};
1107
- const results = [];
1108
-
1109
- for (const skillResult of this.findings) {
1110
- for (const f of skillResult.findings) {
1111
- if (!ruleIndex[f.id]) {
1112
- ruleIndex[f.id] = rules.length;
1113
- rules.push({
1114
- id: f.id, name: f.id,
1115
- shortDescription: { text: f.desc },
1116
- defaultConfiguration: { level: f.severity === 'CRITICAL' ? 'error' : f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note' },
1117
- properties: { tags: ['security', f.cat], 'security-severity': f.severity === 'CRITICAL' ? '9.0' : f.severity === 'HIGH' ? '7.0' : f.severity === 'MEDIUM' ? '4.0' : '1.0' }
1118
- });
1119
- }
1120
- const normalizedFile = String(f.file || '')
1121
- .replaceAll('\\', '/')
1122
- .replace(/^\/+/, '');
1123
- const artifactUri = `${skillResult.skill}/${normalizedFile}`;
1124
- const fingerprintSeed = `${f.id}|${artifactUri}|${f.line || 0}|${(f.sample || '').slice(0, 200)}`;
1125
- const lineHash = crypto.createHash('sha256').update(fingerprintSeed).digest('hex').slice(0, 24);
1126
-
1127
- results.push({
1128
- ruleId: f.id, ruleIndex: ruleIndex[f.id],
1129
- level: f.severity === 'CRITICAL' ? 'error' : f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note',
1130
- message: { text: `[${skillResult.skill}] ${f.desc}${f.sample ? ` — "${f.sample}"` : ''}` },
1131
- partialFingerprints: {
1132
- primaryLocationLineHash: lineHash
1133
- },
1134
- locations: [{ physicalLocation: { artifactLocation: { uri: artifactUri, uriBaseId: '%SRCROOT%' }, region: f.line ? { startLine: f.line } : undefined } }]
1135
- });
1136
- }
1137
- }
1138
-
1139
- return {
1140
- version: '2.1.0',
1141
- $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
1142
- runs: [{
1143
- tool: { driver: { name: 'guard-scanner', version: VERSION, informationUri: 'https://github.com/koatora20/guard-scanner', rules } },
1144
- results,
1145
- invocations: [{ executionSuccessful: true, endTimeUtc: new Date().toISOString() }]
1146
- }]
1147
- };
1148
- }
1149
-
1150
- toHTML() {
1151
- return generateHTML(VERSION, this.stats, this.findings);
1152
- }
1153
- }
1154
-
1155
- const { scanToolCall, RUNTIME_CHECKS, getCheckStats, LAYER_NAMES } = require('./runtime-guard.js');
1156
-
1157
- module.exports = { GuardScanner, VERSION, THRESHOLDS, SEVERITY_WEIGHTS, scanToolCall, RUNTIME_CHECKS, getCheckStats, LAYER_NAMES };