@guava-parity/guard-scanner 9.1.0 → 15.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 (79) hide show
  1. package/README.md +42 -253
  2. package/SECURITY.md +12 -4
  3. package/SKILL.md +121 -59
  4. package/dist/openclaw-plugin.mjs +41 -0
  5. package/docs/EVIDENCE_DRIVEN.md +182 -0
  6. package/docs/banner.png +0 -0
  7. package/docs/data/corpus-metrics.json +11 -0
  8. package/docs/data/latest.json +29845 -0
  9. package/docs/generated/npm-audit-20260312.json +96 -0
  10. package/docs/generated/openclaw-upstream-status.json +25 -0
  11. package/docs/glossary.md +46 -0
  12. package/docs/index.html +1119 -0
  13. package/docs/logo.png +0 -0
  14. package/docs/openclaw-compatibility-audit.md +44 -0
  15. package/docs/openclaw-continuous-compatibility-plan.md +36 -0
  16. package/docs/rules/a2a-contagion.md +68 -0
  17. package/docs/rules/advanced-exfil.md +52 -0
  18. package/docs/rules/agent-protocol.md +108 -0
  19. package/docs/rules/api-abuse.md +68 -0
  20. package/docs/rules/autonomous-risk.md +92 -0
  21. package/docs/rules/config-impact.md +132 -0
  22. package/docs/rules/credential-handling.md +100 -0
  23. package/docs/rules/cve-patterns.md +332 -0
  24. package/docs/rules/data-exposure.md +84 -0
  25. package/docs/rules/exfiltration.md +36 -0
  26. package/docs/rules/financial-access.md +84 -0
  27. package/docs/rules/identity-hijack.md +140 -0
  28. package/docs/rules/inference-manipulation.md +60 -0
  29. package/docs/rules/leaky-skills.md +52 -0
  30. package/docs/rules/malicious-code.md +108 -0
  31. package/docs/rules/mcp-security.md +148 -0
  32. package/docs/rules/memory-poisoning.md +84 -0
  33. package/docs/rules/model-poisoning.md +44 -0
  34. package/docs/rules/obfuscation.md +60 -0
  35. package/docs/rules/persistence.md +108 -0
  36. package/docs/rules/pii-exposure.md +116 -0
  37. package/docs/rules/prompt-injection.md +148 -0
  38. package/docs/rules/prompt-worm.md +44 -0
  39. package/docs/rules/safeguard-bypass.md +44 -0
  40. package/docs/rules/sandbox-escape.md +100 -0
  41. package/docs/rules/secret-detection.md +44 -0
  42. package/docs/rules/supply-chain-v2.md +92 -0
  43. package/docs/rules/suspicious-download.md +60 -0
  44. package/docs/rules/trust-boundary.md +76 -0
  45. package/docs/rules/trust-exploitation.md +92 -0
  46. package/docs/rules/unverifiable-deps.md +84 -0
  47. package/docs/rules/vdb-injection.md +84 -0
  48. package/docs/security-vulnerability-report-20260312.md +53 -0
  49. package/docs/spec/PRD_V2_ARCHITECTURE.md +55 -0
  50. package/docs/spec/capabilities.json +42 -0
  51. package/docs/spec/finding.schema.json +104 -0
  52. package/docs/spec/integration-manifest.md +39 -0
  53. package/docs/spec/sbom.json +33 -0
  54. package/docs/threat-model.md +65 -0
  55. package/docs/v13-architecture-manifest.md +55 -0
  56. package/hooks/context.js +305 -0
  57. package/hooks/guard-scanner/plugin.ts +24 -1
  58. package/openclaw-plugin.mts +91 -0
  59. package/openclaw.plugin.json +30 -53
  60. package/package.json +80 -57
  61. package/src/cli.js +174 -34
  62. package/src/core/content-loader.js +42 -0
  63. package/src/core/inventory.js +73 -0
  64. package/src/core/report-adapters.js +171 -0
  65. package/src/core/risk-engine.js +93 -0
  66. package/src/core/rule-registry.js +73 -0
  67. package/src/core/semantic-validators.js +85 -0
  68. package/src/finding-schema.js +191 -0
  69. package/src/hooks/context.ts +49 -0
  70. package/src/html-template.js +2 -2
  71. package/src/mcp-server.js +192 -5
  72. package/src/openclaw-upstream.js +128 -0
  73. package/src/patterns.js +519 -157
  74. package/src/policy-engine.js +32 -0
  75. package/src/runtime-guard.js +40 -2
  76. package/src/scanner.js +228 -231
  77. package/src/skill-crawler.js +254 -0
  78. package/src/threat-model.js +50 -0
  79. package/src/validation-layer.js +39 -0
package/src/scanner.js CHANGED
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Based on GuavaGuard v9.0.0 (OSS extraction)
15
15
  * 20 threat categories • Snyk ToxicSkills + OWASP MCP Top 10
16
- * Zero dependencies • CLI + JSON + SARIF + HTML output
16
+ * Lightweight runtime footprint • CLI + JSON + SARIF + HTML output
17
17
  * Plugin API for custom detection rules
18
18
  *
19
19
  * Born from a real 3-day agent identity hijack (2026-02-12)
@@ -24,30 +24,24 @@
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
26
  const os = require('os');
27
- const crypto = require('crypto');
28
27
 
29
28
  const { PATTERNS } = require('./patterns.js');
30
29
  const { KNOWN_MALICIOUS } = require('./ioc-db.js');
31
- const { generateHTML } = require('./html-template.js');
30
+ const { RuleRegistry } = require('./core/rule-registry.js');
31
+ const { loadIgnoreFile, loadTextFile } = require('./core/content-loader.js');
32
+ const { classifyFile, CODE_EXTENSIONS, BINARY_EXTENSIONS, isSelfNoisePath, isSelfThreatCorpus, getFiles, listSkills } = require('./core/inventory.js');
33
+ const { calculateRisk, getVerdict, SEVERITY_WEIGHTS } = require('./core/risk-engine.js');
34
+ const { applySemanticValidators, checkASTValidation } = require('./core/semantic-validators.js');
35
+ const { toJSONReport, toSARIFReport, toHTMLReport, printSummary } = require('./core/report-adapters.js');
32
36
 
33
37
  // ===== CONFIGURATION =====
34
- const VERSION = '8.0.0';
38
+ const { version: VERSION } = require('../package.json');
35
39
 
36
40
  const THRESHOLDS = {
37
41
  normal: { suspicious: 30, malicious: 80 },
38
42
  strict: { suspicious: 20, malicious: 60 },
39
43
  };
40
44
 
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
45
  class GuardScanner {
52
46
  constructor(options = {}) {
53
47
  this.verbose = options.verbose || false;
@@ -76,6 +70,8 @@ class GuardScanner {
76
70
  if (options.rulesFile) {
77
71
  this.loadCustomRules(options.rulesFile);
78
72
  }
73
+
74
+ this.ruleRegistry = new RuleRegistry(PATTERNS, this.customRules);
79
75
  }
80
76
 
81
77
  // Plugin API: load a plugin module
@@ -91,6 +87,7 @@ class GuardScanner {
91
87
  if (!this.summaryOnly) {
92
88
  console.log(`🔌 Plugin loaded: ${plugin.name || pluginPath} (${plugin.patterns.length} rule(s))`);
93
89
  }
90
+ this.ruleRegistry = new RuleRegistry(PATTERNS, this.customRules);
94
91
  }
95
92
  } catch (e) {
96
93
  console.error(`⚠️ Failed to load plugin ${pluginPath}: ${e.message}`);
@@ -130,6 +127,7 @@ class GuardScanner {
130
127
  if (!this.summaryOnly && this.customRules.length > 0) {
131
128
  console.log(`📏 Loaded ${this.customRules.length} custom rule(s) from ${rulesFile}`);
132
129
  }
130
+ this.ruleRegistry = new RuleRegistry(PATTERNS, this.customRules);
133
131
  } catch (e) {
134
132
  console.error(`⚠️ Failed to load custom rules: ${e.message}`);
135
133
  }
@@ -137,41 +135,47 @@ class GuardScanner {
137
135
 
138
136
  // Load .guava-guard-ignore / .guard-scanner-ignore from scan directory
139
137
  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
138
+ const ignored = loadIgnoreFile(scanDir);
139
+ this.ignoredSkills = ignored.ignoredSkills;
140
+ this.ignoredPatterns = ignored.ignoredPatterns;
141
+ if (this.verbose && (this.ignoredSkills.size || this.ignoredPatterns.size)) {
142
+ console.log(`📋 Loaded ignore file: ${this.ignoredSkills.size} skills, ${this.ignoredPatterns.size} patterns`);
160
143
  }
161
144
  }
162
145
 
146
+ /**
147
+ * Scan raw text for threats (used for Discord incoming messages, etc.)
148
+ * @param {string} text - Raw text to scan
149
+ * @returns {{ safe: boolean, risk: number, detections: Array }}
150
+ */
151
+ scanText(text) {
152
+ const findings = [];
153
+ this.checkIoCs(text, 'raw_text', findings);
154
+ this.checkPatterns(text, 'raw_text', 'code', findings); // use 'code' to run all patterns
155
+ if (this.customRules.length > 0) {
156
+ this.checkPatterns(text, 'raw_text', 'code', findings, this.customRules);
157
+ }
158
+ applySemanticValidators(text, 'raw_text', findings);
159
+
160
+ // Filter ignored patterns
161
+ const filteredFindings = findings.filter(f => !this.ignoredPatterns.has(f.id));
162
+ const risk = this.calculateRisk(filteredFindings);
163
+
164
+ return {
165
+ safe: risk < this.thresholds.suspicious,
166
+ risk,
167
+ detections: filteredFindings
168
+ };
169
+ }
170
+
163
171
  scanDirectory(dir) {
164
172
  if (!fs.existsSync(dir)) {
165
- console.error(`❌ Directory not found: ${dir}`);
166
- process.exit(2);
173
+ throw new Error(`Directory not found: ${dir}`);
167
174
  }
168
175
 
169
176
  this.loadIgnoreFile(dir);
170
177
 
171
- const skills = fs.readdirSync(dir).filter(f => {
172
- const p = path.join(dir, f);
173
- return fs.statSync(p).isDirectory();
174
- });
178
+ const skills = listSkills(dir);
175
179
 
176
180
  if (!this.quiet) {
177
181
  console.log(`\n🛡️ guard-scanner v${VERSION}`);
@@ -204,6 +208,13 @@ class GuardScanner {
204
208
  return this.findings;
205
209
  }
206
210
 
211
+ scanTarget(targetPath) {
212
+ this.findings = [];
213
+ this.stats = { scanned: 0, clean: 0, low: 0, suspicious: 0, malicious: 0 };
214
+ this.scanDirectory(targetPath);
215
+ return this.toJSON();
216
+ }
217
+
207
218
  scanSkill(skillPath, skillName) {
208
219
  this.stats.scanned++;
209
220
  const skillFindings = [];
@@ -228,9 +239,8 @@ class GuardScanner {
228
239
  if (BINARY_EXTENSIONS.has(ext)) continue;
229
240
  if (this.isSelfNoisePath(skillName, relFile)) continue;
230
241
 
231
- let content;
232
- try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
233
- if (content.length > 500000) continue;
242
+ const content = loadTextFile(file);
243
+ if (content === null) continue;
234
244
 
235
245
  const fileType = this.classifyFile(ext, relFile);
236
246
 
@@ -260,6 +270,7 @@ class GuardScanner {
260
270
  if ((ext === '.js' || ext === '.mjs' || ext === '.cjs' || ext === '.ts') && content.length < 200000) {
261
271
  this.checkJSDataFlow(content, relFile, skillFindings);
262
272
  }
273
+ applySemanticValidators(content, relFile, skillFindings);
263
274
  }
264
275
 
265
276
  // Check 3: Structural checks
@@ -320,27 +331,15 @@ class GuardScanner {
320
331
  }
321
332
 
322
333
  classifyFile(ext, relFile) {
323
- if (CODE_EXTENSIONS.has(ext)) return 'code';
324
- if (DOC_EXTENSIONS.has(ext)) return 'doc';
325
- if (DATA_EXTENSIONS.has(ext)) return 'data';
326
- const base = path.basename(relFile).toLowerCase();
327
- if (base === 'skill.md' || base === 'readme.md') return 'skill-doc';
328
- return 'other';
334
+ return classifyFile(ext, relFile);
329
335
  }
330
336
 
331
337
  isSelfNoisePath(skillName, relFile) {
332
- if (skillName !== 'guard-scanner') return false;
333
- return /^test\//.test(relFile)
334
- || /^dist\/__tests__\//.test(relFile)
335
- || /^ts-src\/__tests__\//.test(relFile)
336
- || /^docs\//.test(relFile)
337
- || relFile === 'ROADMAP-RESEARCH.md'
338
- || relFile === 'CHANGELOG.md';
338
+ return isSelfNoisePath(skillName, relFile);
339
339
  }
340
340
 
341
341
  isSelfThreatCorpus(skillName, relFile) {
342
- if (skillName !== 'guard-scanner') return false;
343
- return /(^|\/)(ioc-db|patterns)\.(js|ts)$/.test(relFile);
342
+ return isSelfThreatCorpus(skillName, relFile);
344
343
  }
345
344
 
346
345
  checkIoCs(content, relFile, findings) {
@@ -378,21 +377,48 @@ class GuardScanner {
378
377
  }
379
378
  }
380
379
 
381
- checkPatterns(content, relFile, fileType, findings, patterns = PATTERNS) {
382
- for (const pattern of patterns) {
380
+ checkPatterns(content, relFile, fileType, findings, patterns = null) {
381
+ const activePatterns = patterns || this.ruleRegistry.getRulesForFileType(fileType);
382
+ // v9: Payload Unfurling (Base64 / Hex Decoders)
383
+ let unfurledContent = content;
384
+
385
+ // Unfurl Buffer.from('...', 'base64') and atob('...')
386
+ const b64Regex = /(?:Buffer\.from\(\s*['"]([^'"]+)['"]\s*,\s*['"]base64['"]\)|atob\(\s*['"]([^'"]+)['"]\))/g;
387
+ unfurledContent = unfurledContent.replace(b64Regex, (match, g1, g2) => {
388
+ try {
389
+ const b64 = g1 || g2;
390
+ return Buffer.from(b64, 'base64').toString('utf8');
391
+ } catch { return match; }
392
+ });
393
+
394
+ // Unfurl hex escaped strings like \x63\x61\x74 -> cat
395
+ unfurledContent = unfurledContent.replace(/\\x([0-9a-fA-F]{2})/g, (match, hex) => {
396
+ return String.fromCharCode(parseInt(hex, 16));
397
+ });
398
+
399
+ for (const pattern of activePatterns) {
383
400
  // Soul Lock: skip identity-hijack/memory-poisoning patterns unless --soul-lock is enabled
384
401
  if (pattern.soulLock && !this.soulLock) continue;
385
402
  if (pattern.codeOnly && fileType !== 'code') continue;
386
403
  if (pattern.docOnly && fileType !== 'doc' && fileType !== 'skill-doc') continue;
387
- if (!pattern.all && !pattern.codeOnly && !pattern.docOnly) continue;
404
+ if (!pattern.all && !pattern.codeOnly && !pattern.docOnly && pattern.scope !== 'skill-doc') continue;
388
405
 
389
406
  pattern.regex.lastIndex = 0;
390
- const matches = content.match(pattern.regex);
407
+ let matches = content.match(pattern.regex);
408
+ let targetContent = content;
409
+
410
+ // If no match on raw content, try unfurled content
411
+ if (!matches && unfurledContent !== content) {
412
+ pattern.regex.lastIndex = 0;
413
+ matches = unfurledContent.match(pattern.regex);
414
+ targetContent = unfurledContent;
415
+ }
416
+
391
417
  if (!matches) continue;
392
418
 
393
419
  pattern.regex.lastIndex = 0;
394
- const idx = content.search(pattern.regex);
395
- const lineNum = idx >= 0 ? content.substring(0, idx).split('\n').length : null;
420
+ const idx = targetContent.search(pattern.regex);
421
+ const lineNum = idx >= 0 ? targetContent.substring(0, idx).split('\n').length : null;
396
422
 
397
423
  let adjustedSeverity = pattern.severity;
398
424
  if ((fileType === 'doc' || fileType === 'skill-doc') && pattern.all && !pattern.docOnly) {
@@ -403,8 +429,8 @@ class GuardScanner {
403
429
  findings.push({
404
430
  severity: adjustedSeverity,
405
431
  id: pattern.id,
406
- cat: pattern.cat,
407
- desc: pattern.desc,
432
+ cat: pattern.cat || pattern.category,
433
+ desc: pattern.desc || pattern.description,
408
434
  file: relFile,
409
435
  line: lineNum,
410
436
  matchCount: matches.length,
@@ -751,36 +777,98 @@ class GuardScanner {
751
777
  }
752
778
 
753
779
  checkJSDataFlow(content, relFile, findings) {
754
- const lines = content.split('\n');
780
+ // v9: Pseudo-AST Semantic Unfurling & Alias Tracking
781
+ // 1. Resolve string concatenations (e.g., '"f" + "etch"' -> '"fetch"')
782
+ let unfurledContent = content.replace(/(["'`])([^"'`]*)\1\s*\+\s*(["'`])([^"'`]*)\3/g, '$1$2$4$1');
783
+ for (let i = 0; i < 3; i++) { // Deep unfurl (up to 3 concats)
784
+ unfurledContent = unfurledContent.replace(/(["'`])([^"'`]*)\1\s*\+\s*(["'`])([^"'`]*)\3/g, '$1$2$4$1');
785
+ }
786
+
787
+ const lines = unfurledContent.split('\n');
755
788
  const imports = new Map();
756
789
  const sensitiveReads = [];
757
790
  const networkCalls = [];
758
791
  const execCalls = [];
759
792
 
793
+ // Alias Tracker for Sinks & Vars
794
+ const activeAliases = {
795
+ network: ['fetch', 'axios', 'request', 'http.request', 'https.request', 'got'],
796
+ exec: ['exec', 'execSync', 'spawn', 'spawnSync', 'execFile', "require('child_process').execSync"],
797
+ fsRead: ['readFileSync', 'readFile', 'fs.readFileSync', 'fs.readFile', "require('fs').readFileSync"]
798
+ };
799
+ const stringVars = new Map();
800
+
801
+ const registerAlias = (alias, target) => {
802
+ if (!alias || !target) return;
803
+ for (const [key, sinks] of Object.entries(activeAliases)) {
804
+ if (sinks.some(s => target.includes(s) || s.includes(target))) {
805
+ activeAliases[key].push(alias);
806
+ }
807
+ }
808
+ };
809
+
810
+ // Pass 1: Extract Context & Aliases & Values
760
811
  for (let i = 0; i < lines.length; i++) {
761
812
  const line = lines[i];
762
- const lineNum = i + 1;
763
813
 
814
+ // Standard variable assignment: const getRemote = fetch;
815
+ const aliasMatch = line.match(/(?:const|let|var)\s+([a-zA-Z0-9_$]+)\s*=\s*([a-zA-Z0-9_$.]+(?:\([^)]*\))?)\s*;/);
816
+ if (aliasMatch) {
817
+ registerAlias(aliasMatch[1], aliasMatch[2]);
818
+ }
819
+
820
+ // String literals: const target = ".env";
821
+ const strMatch = line.match(/(?:const|let|var)\s+([a-zA-Z0-9_$]+)\s*=\s*(["'`])([^"'`]+)\2/);
822
+ if (strMatch) {
823
+ stringVars.set(strMatch[1], strMatch[3]); // target -> .env
824
+ }
825
+
826
+ // Require assignments: const fs = require('fs')
764
827
  const reqMatch = line.match(/(?:const|let|var)\s+(?:{[^}]+}|\w+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
765
828
  if (reqMatch) {
766
829
  const varMatch = line.match(/(?:const|let|var)\s+({[^}]+}|\w+)/);
767
- if (varMatch) imports.set(varMatch[1].trim(), reqMatch[1]);
830
+ if (varMatch) {
831
+ const aliasName = varMatch[1].trim();
832
+ imports.set(aliasName, reqMatch[1]);
833
+ registerAlias(`${aliasName}.readFileSync`, 'readFileSync'); // Link fs methods
834
+ registerAlias(`${aliasName}.readFile`, 'readFile');
835
+ registerAlias(`${aliasName}.exec`, 'exec');
836
+ registerAlias(`${aliasName}.execSync`, 'execSync');
837
+ }
838
+ }
839
+ }
840
+
841
+ // Helper to create safe regex from dynamic aliases
842
+ const escapeRegex = (arr) => arr.map(a => a.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')).join('|');
843
+
844
+ // Pass 2: Data Flow Matching with Interpolation
845
+ for (let i = 0; i < lines.length; i++) {
846
+ const line = lines[i];
847
+ const lineNum = i + 1;
848
+
849
+ // Pseudo-AST: substitute known literal vars into the line to reveal logic
850
+ let resolvedLine = line;
851
+ for (const [k, v] of stringVars.entries()) {
852
+ // replace var usage but only for whole words
853
+ resolvedLine = resolvedLine.replace(new RegExp(`\\b${k}\\b`, 'g'), `"${v}"`);
768
854
  }
769
855
 
770
- if (/(?:readFileSync|readFile)\s*\([^)]*(?:\.env|\.ssh|id_rsa|\.clawdbot|\.openclaw(?!\/workspace))/i.test(line)) {
771
- sensitiveReads.push({ line: lineNum, text: line.trim() });
856
+ const fsPattern = new RegExp(`(?:${escapeRegex(activeAliases.fsRead)})\\s*\\([^)]*(?:\\.env|\\.ssh|id_rsa|\\.clawdbot|\\.openclaw(?!\\/workspace))`, 'i');
857
+ if (fsPattern.test(resolvedLine)) {
858
+ sensitiveReads.push({ line: lineNum, text: resolvedLine.trim() });
772
859
  }
773
- if (/process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i.test(line)) {
774
- sensitiveReads.push({ line: lineNum, text: line.trim() });
860
+ if (/process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i.test(resolvedLine)) {
861
+ sensitiveReads.push({ line: lineNum, text: resolvedLine.trim() });
775
862
  }
776
863
 
777
- if (/(?:fetch|axios|request|http\.request|https\.request|got)\s*\(/i.test(line) ||
778
- /\.post\s*\(|\.put\s*\(|\.patch\s*\(/i.test(line)) {
779
- networkCalls.push({ line: lineNum, text: line.trim() });
864
+ const netPattern = new RegExp(`(?:${escapeRegex(activeAliases.network)})\\s*\\(`, 'i');
865
+ if (netPattern.test(resolvedLine) || /\.post\s*\(|\.put\s*\(|\.patch\s*\(/.test(resolvedLine)) {
866
+ networkCalls.push({ line: lineNum, text: resolvedLine.trim() });
780
867
  }
781
868
 
782
- if (/(?:exec|execSync|spawn|spawnSync|execFile)\s*\(/i.test(line)) {
783
- execCalls.push({ line: lineNum, text: line.trim() });
869
+ const execPattern = new RegExp(`(?:${escapeRegex(activeAliases.exec)})\\s*\\(`, 'i');
870
+ if (execPattern.test(resolvedLine)) {
871
+ execCalls.push({ line: lineNum, text: resolvedLine.trim() });
784
872
  }
785
873
  }
786
874
 
@@ -863,181 +951,90 @@ class GuardScanner {
863
951
  }
864
952
 
865
953
  calculateRisk(findings) {
866
- if (findings.length === 0) return 0;
867
-
868
- let score = 0;
869
- for (const f of findings) {
870
- score += SEVERITY_WEIGHTS[f.severity] || 0;
871
- }
872
-
873
- const ids = new Set(findings.map(f => f.id));
874
- const cats = new Set(findings.map(f => f.cat));
875
-
876
- if (cats.has('credential-handling') && cats.has('exfiltration')) score = Math.round(score * 2);
877
- if (cats.has('credential-handling') && findings.some(f => f.id === 'MAL_CHILD' || f.id === 'MAL_EXEC')) score = Math.round(score * 1.5);
878
- if (cats.has('obfuscation') && (cats.has('malicious-code') || cats.has('credential-handling'))) score = Math.round(score * 2);
879
- if (ids.has('DEP_LIFECYCLE_EXEC')) score = Math.round(score * 2);
880
- if (ids.has('PI_BIDI') && findings.length > 1) score = Math.round(score * 1.5);
881
- if (cats.has('leaky-skills') && (cats.has('exfiltration') || cats.has('malicious-code'))) score = Math.round(score * 2);
882
- if (cats.has('memory-poisoning')) score = Math.round(score * 1.5);
883
- if (cats.has('prompt-worm')) score = Math.round(score * 2);
884
- if (cats.has('cve-patterns')) score = Math.max(score, 70);
885
- if (cats.has('persistence') && (cats.has('malicious-code') || cats.has('credential-handling') || cats.has('memory-poisoning'))) score = Math.round(score * 1.5);
886
- if (cats.has('identity-hijack')) score = Math.round(score * 2);
887
- if (cats.has('identity-hijack') && (cats.has('persistence') || cats.has('memory-poisoning'))) score = Math.max(score, 90);
888
- if (ids.has('IOC_IP') || ids.has('IOC_URL') || ids.has('KNOWN_TYPOSQUAT')) score = 100;
889
-
890
- // v1.1 categories
891
- if (cats.has('config-impact')) score = Math.round(score * 2);
892
- if (cats.has('config-impact') && cats.has('sandbox-validation')) score = Math.max(score, 70);
893
- if (cats.has('complexity') && (cats.has('malicious-code') || cats.has('obfuscation'))) score = Math.round(score * 1.5);
894
-
895
- // v2.1 PII exposure amplifiers
896
- if (cats.has('pii-exposure') && cats.has('exfiltration')) score = Math.round(score * 3);
897
- 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);
898
- if (cats.has('pii-exposure') && cats.has('credential-handling')) score = Math.round(score * 2);
899
-
900
- return Math.min(100, score);
954
+ return calculateRisk(findings);
901
955
  }
902
956
 
903
957
  getVerdict(risk) {
904
- if (risk >= this.thresholds.malicious) return { icon: '🔴', label: 'MALICIOUS', stat: 'malicious' };
905
- if (risk >= this.thresholds.suspicious) return { icon: '🟡', label: 'SUSPICIOUS', stat: 'suspicious' };
906
- if (risk > 0) return { icon: '🟢', label: 'LOW RISK', stat: 'low' };
907
- return { icon: '🟢', label: 'CLEAN', stat: 'clean' };
958
+ return getVerdict(risk, this.thresholds);
908
959
  }
909
960
 
910
961
  getFiles(dir) {
911
- const results = [];
912
- try {
913
- const entries = fs.readdirSync(dir, { withFileTypes: true });
914
- for (const entry of entries) {
915
- const fullPath = path.join(dir, entry.name);
916
- if (entry.isDirectory()) {
917
- if (entry.name === '.git' || entry.name === 'node_modules') continue;
918
- results.push(...this.getFiles(fullPath));
919
- } else {
920
- const baseName = entry.name.toLowerCase();
921
- if (GENERATED_REPORT_FILES.has(baseName)) continue;
922
- results.push(fullPath);
923
- }
924
- }
925
- } catch { }
926
- return results;
962
+ return getFiles(dir);
927
963
  }
928
964
 
929
965
  printSummary() {
930
- const total = this.stats.scanned;
931
- const safe = this.stats.clean + this.stats.low;
932
- console.log(`\n${'═'.repeat(54)}`);
933
- console.log(`📊 guard-scanner v${VERSION} Scan Summary`);
934
- console.log(`${'─'.repeat(54)}`);
935
- console.log(` Scanned: ${total}`);
936
- console.log(` 🟢 Clean: ${this.stats.clean}`);
937
- console.log(` 🟢 Low Risk: ${this.stats.low}`);
938
- console.log(` 🟡 Suspicious: ${this.stats.suspicious}`);
939
- console.log(` 🔴 Malicious: ${this.stats.malicious}`);
940
- console.log(` Safety Rate: ${total ? Math.round(safe / total * 100) : 0}%`);
941
- console.log(`${'═'.repeat(54)}`);
942
-
943
- if (this.stats.malicious > 0) {
944
- console.log(`\n⚠️ CRITICAL: ${this.stats.malicious} malicious skill(s) detected!`);
945
- console.log(` Review findings with --verbose and remove if confirmed.`);
946
- } else if (this.stats.suspicious > 0) {
947
- console.log(`\n⚡ ${this.stats.suspicious} suspicious skill(s) found — review recommended.`);
948
- } else {
949
- console.log(`\n✅ All clear! No threats detected.`);
950
- }
966
+ return printSummary(this.stats, VERSION);
951
967
  }
952
968
 
953
969
  toJSON() {
954
- const recommendations = [];
955
- for (const skillResult of this.findings) {
956
- const skillRecs = [];
957
- const cats = new Set(skillResult.findings.map(f => f.cat));
958
-
959
- if (cats.has('prompt-injection')) skillRecs.push('🛑 Contains prompt injection patterns.');
960
- if (cats.has('malicious-code')) skillRecs.push('🛑 Contains potentially malicious code.');
961
- if (cats.has('credential-handling') && cats.has('exfiltration')) skillRecs.push('💀 CRITICAL: Credential access + exfiltration. DO NOT INSTALL.');
962
- if (cats.has('dependency-chain')) skillRecs.push('📦 Suspicious dependency chain.');
963
- if (cats.has('obfuscation')) skillRecs.push('🔍 Code obfuscation detected.');
964
- if (cats.has('secret-detection')) skillRecs.push('🔑 Possible hardcoded secrets.');
965
- if (cats.has('leaky-skills')) skillRecs.push('💧 LEAKY SKILL: Secrets pass through LLM context.');
966
- if (cats.has('memory-poisoning')) skillRecs.push('🧠 MEMORY POISONING: Agent memory modification attempt.');
967
- if (cats.has('prompt-worm')) skillRecs.push('🪱 PROMPT WORM: Self-replicating instructions.');
968
- if (cats.has('data-flow')) skillRecs.push('🔀 Suspicious data flow patterns.');
969
- if (cats.has('persistence')) skillRecs.push('⏰ PERSISTENCE: Creates scheduled tasks.');
970
- if (cats.has('cve-patterns')) skillRecs.push('🚨 CVE PATTERN: Matches known exploits.');
971
- if (cats.has('identity-hijack')) skillRecs.push('🔒 IDENTITY HIJACK: Agent soul file tampering. DO NOT INSTALL.');
972
- if (cats.has('sandbox-validation')) skillRecs.push('🔒 SANDBOX: Skill requests dangerous capabilities.');
973
- if (cats.has('complexity')) skillRecs.push('🧩 COMPLEXITY: Excessive code complexity may hide malicious behavior.');
974
- if (cats.has('config-impact')) skillRecs.push('⚙️ CONFIG IMPACT: Modifies OpenClaw configuration. DO NOT INSTALL.');
975
- if (cats.has('pii-exposure')) skillRecs.push('🆔 PII EXPOSURE: Handles personally identifiable information. Review data handling.');
976
-
977
- if (skillRecs.length > 0) recommendations.push({ skill: skillResult.skill, actions: skillRecs });
978
- }
979
-
980
- return {
981
- timestamp: new Date().toISOString(),
982
- scanner: `guard-scanner v${VERSION}`,
983
- mode: this.strict ? 'strict' : 'normal',
984
- stats: this.stats,
985
- thresholds: this.thresholds,
986
- findings: this.findings,
987
- recommendations,
988
- iocVersion: '2026-02-12',
989
- };
970
+ return toJSONReport(this, VERSION);
990
971
  }
991
972
 
992
973
  toSARIF(scanDir) {
993
- const rules = [];
994
- const ruleIndex = {};
995
- const results = [];
996
-
997
- for (const skillResult of this.findings) {
998
- for (const f of skillResult.findings) {
999
- if (!ruleIndex[f.id]) {
1000
- ruleIndex[f.id] = rules.length;
1001
- rules.push({
1002
- id: f.id, name: f.id,
1003
- shortDescription: { text: f.desc },
1004
- defaultConfiguration: { level: f.severity === 'CRITICAL' ? 'error' : f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note' },
1005
- properties: { tags: ['security', f.cat], 'security-severity': f.severity === 'CRITICAL' ? '9.0' : f.severity === 'HIGH' ? '7.0' : f.severity === 'MEDIUM' ? '4.0' : '1.0' }
1006
- });
1007
- }
1008
- const normalizedFile = String(f.file || '')
1009
- .replaceAll('\\', '/')
1010
- .replace(/^\/+/, '');
1011
- const artifactUri = `${skillResult.skill}/${normalizedFile}`;
1012
- const fingerprintSeed = `${f.id}|${artifactUri}|${f.line || 0}|${(f.sample || '').slice(0, 200)}`;
1013
- const lineHash = crypto.createHash('sha256').update(fingerprintSeed).digest('hex').slice(0, 24);
1014
-
1015
- results.push({
1016
- ruleId: f.id, ruleIndex: ruleIndex[f.id],
1017
- level: f.severity === 'CRITICAL' ? 'error' : f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note',
1018
- message: { text: `[${skillResult.skill}] ${f.desc}${f.sample ? ` — "${f.sample}"` : ''}` },
1019
- partialFingerprints: {
1020
- primaryLocationLineHash: lineHash
1021
- },
1022
- locations: [{ physicalLocation: { artifactLocation: { uri: artifactUri, uriBaseId: '%SRCROOT%' }, region: f.line ? { startLine: f.line } : undefined } }]
1023
- });
974
+ return toSARIFReport(this, VERSION, scanDir);
975
+ }
976
+
977
+ toHTML() {
978
+ return toHTMLReport(this, VERSION);
979
+ }
980
+
981
+ /**
982
+ * Generate a Threat Model based on the scan findings.
983
+ * @param {Array<Object>} findings - The array of findings from the scan.
984
+ * @returns {Object} The generated threat model.
985
+ */
986
+
987
+ /**
988
+ * Check AST for contextual validation of high-risk chains.
989
+ * Separates heuristic-only matches from validated chains.
990
+ */
991
+ checkASTValidation(content, relFile, findings) {
992
+ return checkASTValidation(content, relFile, findings);
993
+ }
994
+
995
+ generateThreatModel(findings) {
996
+ const surface = {
997
+ network: false,
998
+ file_system: false,
999
+ code_execution: false,
1000
+ credential_exposure: false,
1001
+ external_ingestion: false,
1002
+ persistence: false
1003
+ };
1004
+
1005
+ for (const f of findings) {
1006
+ // Map pattern IDs or categories to capability surfaces
1007
+ const id = f.id || '';
1008
+ const cat = f.cat || '';
1009
+ const desc = (f.desc || '').toLowerCase();
1010
+
1011
+ if (id.includes('FETCH') || id.includes('CURL') || id.includes('SSRF') || id.includes('NETWORK') || id.includes('EXFIL') || id.includes('TRUST_WEB_EXEC') || desc.includes('fetch') || desc.includes('network') || desc.includes('web content')) {
1012
+ surface.network = true;
1013
+ }
1014
+ if (id.includes('FS_') || id.includes('WRITE') || id.includes('READ') || id.includes('FILE') || id.includes('TRUST_WEB_EXEC') || desc.includes('file system') || desc.includes('readfilesync') || desc.includes('fs.read')) {
1015
+ surface.file_system = true;
1016
+ }
1017
+ if (id.includes('EXEC') || id.includes('EVAL') || id.includes('SHELL') || id.includes('SPAWN') || id.includes('RCE') || desc.includes('exec') || desc.includes('shell')) {
1018
+ surface.code_execution = true;
1019
+ }
1020
+ if (id.includes('CRED') || id.includes('KEY') || id.includes('SECRET') || id.includes('TOKEN') || cat.includes('credential') || desc.includes('credential') || desc.includes('trust boundary')) {
1021
+ surface.credential_exposure = true;
1022
+ }
1023
+ if (id.includes('PI_') || id.includes('PROMPT_INJECT') || id.includes('POISON') || id.includes('TRUST_WEB_EXEC') || cat.includes('prompt-injection') || desc.includes('ignore all')) {
1024
+ surface.external_ingestion = true;
1025
+ }
1026
+ if (id.includes('PERSIST') || id.includes('CRON') || id.includes('STARTUP') || cat.includes('persistence') || desc.includes('cron') || id.includes('DEPS_PHANTOM_IMPORT')) {
1027
+ surface.persistence = true;
1024
1028
  }
1025
1029
  }
1026
1030
 
1027
1031
  return {
1028
- version: '2.1.0',
1029
- $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
1030
- runs: [{
1031
- tool: { driver: { name: 'guard-scanner', version: VERSION, informationUri: 'https://github.com/koatora20/guard-scanner', rules } },
1032
- results,
1033
- invocations: [{ executionSuccessful: true, endTimeUtc: new Date().toISOString() }]
1034
- }]
1032
+ timestamp: new Date().toISOString(),
1033
+ surface,
1034
+ summary: Object.keys(surface).filter(k => surface[k]).join(', ') || 'none'
1035
1035
  };
1036
1036
  }
1037
1037
 
1038
- toHTML() {
1039
- return generateHTML(VERSION, this.stats, this.findings);
1040
- }
1041
1038
  }
1042
1039
 
1043
1040
  const { scanToolCall, RUNTIME_CHECKS, getCheckStats, LAYER_NAMES } = require('./runtime-guard.js');