@guava-parity/guard-scanner 13.0.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 +25837 -2481
  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 +1085 -496
  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 +23 -8
  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 +24 -73
  72. package/src/openclaw-upstream.js +128 -0
  73. package/src/patterns.js +371 -353
  74. package/src/policy-engine.js +32 -0
  75. package/src/runtime-guard.js +40 -2
  76. package/src/scanner.js +101 -216
  77. package/src/skill-crawler.js +254 -0
  78. package/src/threat-model.js +50 -0
  79. package/src/validation-layer.js +39 -0
@@ -0,0 +1,32 @@
1
+ class PolicyEngine {
2
+ constructor(config = { mode: 'enforce' }) {
3
+ this.mode = config.mode;
4
+ }
5
+
6
+ evaluate(toolName, args) {
7
+ if (this.mode === 'monitor') {
8
+ return { action: 'allow', reason: 'monitor mode' };
9
+ }
10
+
11
+ const argsStr = JSON.stringify(args).toLowerCase();
12
+
13
+ // Destructive FS operations
14
+ if (toolName === 'run_shell_command' && (argsStr.includes('rm -rf') || argsStr.includes('mkfs'))) {
15
+ return { action: 'block', reason: 'destructive fs operation' };
16
+ }
17
+
18
+ // Credential access
19
+ if (toolName === 'read_file' && (argsStr.includes('.env') || argsStr.includes('secret') || argsStr.includes('.aws'))) {
20
+ return { action: 'block', reason: 'credential read operation' };
21
+ }
22
+
23
+ // Unrestricted network
24
+ if (toolName === 'run_shell_command' && (argsStr.includes('curl') || argsStr.includes('wget')) && argsStr.includes('| bash')) {
25
+ return { action: 'block', reason: 'unrestricted network execution (curl|bash)' };
26
+ }
27
+
28
+ return { action: 'allow', reason: 'safe operation' };
29
+ }
30
+ }
31
+
32
+ module.exports = { PolicyEngine };
@@ -33,6 +33,7 @@
33
33
  const fs = require('fs');
34
34
  const path = require('path');
35
35
  const os = require('os');
36
+ const { normalizeFinding } = require('./finding-schema.js');
36
37
 
37
38
  // ── Runtime threat patterns (26 checks, 5 layers) ──
38
39
 
@@ -85,6 +86,11 @@ const RUNTIME_CHECKS = [
85
86
  desc: 'Download piped to shell',
86
87
  test: (s) => /(curl|wget)\s+[^\n]*\|\s*(sh|bash|zsh)/i.test(s),
87
88
  },
89
+ {
90
+ id: 'RT_ENV_CURL_EXFIL', severity: 'CRITICAL', layer: 1,
91
+ desc: 'Environment variable exfiltration piped to curl upload',
92
+ test: (s) => /env\s*\|\s*curl\b[^\n]*-d\s+@-/i.test(s),
93
+ },
88
94
  {
89
95
  id: 'RT_SSH_READ', severity: 'HIGH', layer: 1,
90
96
  desc: 'SSH private key access',
@@ -247,6 +253,9 @@ function shouldBlock(severity, mode) {
247
253
  * @param {string} [options.mode] - Override mode ('monitor' | 'enforce' | 'strict')
248
254
  * @param {boolean} [options.auditLog=true] - Enable audit logging
249
255
  * @param {string} [options.sessionKey] - Session identifier for audit
256
+ * @param {string} [options.sessionId] - Ephemeral OpenClaw session UUID for audit
257
+ * @param {string} [options.runId] - Stable OpenClaw run identifier for audit
258
+ * @param {string} [options.toolCallId] - Provider tool call identifier for audit
250
259
  * @param {string} [options.agentId] - Agent identifier for audit
251
260
  * @returns {{ blocked: boolean, detections: Array<{id: string, severity: string, layer: number, desc: string, action: string}> }}
252
261
  */
@@ -254,6 +263,9 @@ function scanToolCall(toolName, params, options = {}) {
254
263
  const mode = options.mode || loadMode();
255
264
  const enableAudit = options.auditLog !== false;
256
265
  const sessionKey = options.sessionKey || 'unknown';
266
+ const sessionId = options.sessionId || 'unknown';
267
+ const runId = options.runId || 'unknown';
268
+ const toolCallId = options.toolCallId || 'unknown';
257
269
  const agentId = options.agentId || 'unknown';
258
270
 
259
271
  const result = {
@@ -275,14 +287,29 @@ function scanToolCall(toolName, params, options = {}) {
275
287
  if (!check.test(serialized)) continue;
276
288
 
277
289
  const action = shouldBlock(check.severity, mode) ? 'blocked' : 'warned';
290
+ const paramsPreview = serialized.length > 200 ? `${serialized.slice(0, 200)}…` : serialized;
278
291
 
279
- const detection = {
292
+ const detection = normalizeFinding({
280
293
  id: check.id,
294
+ category: LAYER_CATEGORIES[check.layer] || 'runtime-guard',
281
295
  severity: check.severity,
282
296
  layer: check.layer,
283
297
  desc: check.desc,
284
298
  action,
285
- };
299
+ rationale: check.rationale || `Runtime guard matched ${check.desc.toLowerCase()} before the tool call executed.`,
300
+ preconditions: check.preconditions || 'The tool call arguments must reach the runtime enforcement hook with attacker-controlled or unsafe content.',
301
+ false_positive_scenarios: check.falsePositiveScenarios || [
302
+ 'The arguments are part of a security test, audit note, or documentation sample and are not actually executed.',
303
+ 'The command is legitimate but still requires explicit human review before execution.',
304
+ ],
305
+ remediation_hint: check.remediationHint || 'Review the tool arguments, remove the unsafe construct, and rerun only after an allowlisted human review.',
306
+ }, {
307
+ source: 'runtime',
308
+ toolName,
309
+ paramsPreview,
310
+ layer_name: LAYER_NAMES[check.layer],
311
+ ruleMetadata: check,
312
+ });
286
313
 
287
314
  result.detections.push(detection);
288
315
 
@@ -296,6 +323,9 @@ function scanToolCall(toolName, params, options = {}) {
296
323
  mode,
297
324
  action,
298
325
  session: sessionKey,
326
+ sessionId,
327
+ runId,
328
+ toolCallId,
299
329
  agent: agentId,
300
330
  });
301
331
  }
@@ -332,6 +362,14 @@ const LAYER_NAMES = {
332
362
  5: 'Trust Exploitation (ASI09)',
333
363
  };
334
364
 
365
+ const LAYER_CATEGORIES = {
366
+ 1: 'threat-detection',
367
+ 2: 'trust-defense',
368
+ 3: 'safety-judge',
369
+ 4: 'behavioral-guard',
370
+ 5: 'trust-exploitation',
371
+ };
372
+
335
373
  module.exports = {
336
374
  RUNTIME_CHECKS,
337
375
  DANGEROUS_TOOLS,
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,26 +135,11 @@ 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
 
@@ -172,6 +155,7 @@ class GuardScanner {
172
155
  if (this.customRules.length > 0) {
173
156
  this.checkPatterns(text, 'raw_text', 'code', findings, this.customRules);
174
157
  }
158
+ applySemanticValidators(text, 'raw_text', findings);
175
159
 
176
160
  // Filter ignored patterns
177
161
  const filteredFindings = findings.filter(f => !this.ignoredPatterns.has(f.id));
@@ -186,16 +170,12 @@ class GuardScanner {
186
170
 
187
171
  scanDirectory(dir) {
188
172
  if (!fs.existsSync(dir)) {
189
- console.error(`❌ Directory not found: ${dir}`);
190
- process.exit(2);
173
+ throw new Error(`Directory not found: ${dir}`);
191
174
  }
192
175
 
193
176
  this.loadIgnoreFile(dir);
194
177
 
195
- const skills = fs.readdirSync(dir).filter(f => {
196
- const p = path.join(dir, f);
197
- return fs.statSync(p).isDirectory();
198
- });
178
+ const skills = listSkills(dir);
199
179
 
200
180
  if (!this.quiet) {
201
181
  console.log(`\n🛡️ guard-scanner v${VERSION}`);
@@ -228,6 +208,13 @@ class GuardScanner {
228
208
  return this.findings;
229
209
  }
230
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
+
231
218
  scanSkill(skillPath, skillName) {
232
219
  this.stats.scanned++;
233
220
  const skillFindings = [];
@@ -252,9 +239,8 @@ class GuardScanner {
252
239
  if (BINARY_EXTENSIONS.has(ext)) continue;
253
240
  if (this.isSelfNoisePath(skillName, relFile)) continue;
254
241
 
255
- let content;
256
- try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
257
- if (content.length > 500000) continue;
242
+ const content = loadTextFile(file);
243
+ if (content === null) continue;
258
244
 
259
245
  const fileType = this.classifyFile(ext, relFile);
260
246
 
@@ -284,6 +270,7 @@ class GuardScanner {
284
270
  if ((ext === '.js' || ext === '.mjs' || ext === '.cjs' || ext === '.ts') && content.length < 200000) {
285
271
  this.checkJSDataFlow(content, relFile, skillFindings);
286
272
  }
273
+ applySemanticValidators(content, relFile, skillFindings);
287
274
  }
288
275
 
289
276
  // Check 3: Structural checks
@@ -344,27 +331,15 @@ class GuardScanner {
344
331
  }
345
332
 
346
333
  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';
334
+ return classifyFile(ext, relFile);
353
335
  }
354
336
 
355
337
  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';
338
+ return isSelfNoisePath(skillName, relFile);
363
339
  }
364
340
 
365
341
  isSelfThreatCorpus(skillName, relFile) {
366
- if (skillName !== 'guard-scanner') return false;
367
- return /(^|\/)(ioc-db|patterns)\.(js|ts)$/.test(relFile);
342
+ return isSelfThreatCorpus(skillName, relFile);
368
343
  }
369
344
 
370
345
  checkIoCs(content, relFile, findings) {
@@ -402,7 +377,8 @@ class GuardScanner {
402
377
  }
403
378
  }
404
379
 
405
- checkPatterns(content, relFile, fileType, findings, patterns = PATTERNS) {
380
+ checkPatterns(content, relFile, fileType, findings, patterns = null) {
381
+ const activePatterns = patterns || this.ruleRegistry.getRulesForFileType(fileType);
406
382
  // v9: Payload Unfurling (Base64 / Hex Decoders)
407
383
  let unfurledContent = content;
408
384
 
@@ -420,12 +396,12 @@ class GuardScanner {
420
396
  return String.fromCharCode(parseInt(hex, 16));
421
397
  });
422
398
 
423
- for (const pattern of patterns) {
399
+ for (const pattern of activePatterns) {
424
400
  // Soul Lock: skip identity-hijack/memory-poisoning patterns unless --soul-lock is enabled
425
401
  if (pattern.soulLock && !this.soulLock) continue;
426
402
  if (pattern.codeOnly && fileType !== 'code') continue;
427
403
  if (pattern.docOnly && fileType !== 'doc' && fileType !== 'skill-doc') continue;
428
- if (!pattern.all && !pattern.codeOnly && !pattern.docOnly) continue;
404
+ if (!pattern.all && !pattern.codeOnly && !pattern.docOnly && pattern.scope !== 'skill-doc') continue;
429
405
 
430
406
  pattern.regex.lastIndex = 0;
431
407
  let matches = content.match(pattern.regex);
@@ -453,8 +429,8 @@ class GuardScanner {
453
429
  findings.push({
454
430
  severity: adjustedSeverity,
455
431
  id: pattern.id,
456
- cat: pattern.cat,
457
- desc: pattern.desc,
432
+ cat: pattern.cat || pattern.category,
433
+ desc: pattern.desc || pattern.description,
458
434
  file: relFile,
459
435
  line: lineNum,
460
436
  matchCount: matches.length,
@@ -975,181 +951,90 @@ class GuardScanner {
975
951
  }
976
952
 
977
953
  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);
954
+ return calculateRisk(findings);
1013
955
  }
1014
956
 
1015
957
  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' };
958
+ return getVerdict(risk, this.thresholds);
1020
959
  }
1021
960
 
1022
961
  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;
962
+ return getFiles(dir);
1039
963
  }
1040
964
 
1041
965
  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
- }
966
+ return printSummary(this.stats, VERSION);
1063
967
  }
1064
968
 
1065
969
  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
- };
970
+ return toJSONReport(this, VERSION);
1102
971
  }
1103
972
 
1104
973
  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
- });
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;
1136
1028
  }
1137
1029
  }
1138
1030
 
1139
1031
  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
- }]
1032
+ timestamp: new Date().toISOString(),
1033
+ surface,
1034
+ summary: Object.keys(surface).filter(k => surface[k]).join(', ') || 'none'
1147
1035
  };
1148
1036
  }
1149
1037
 
1150
- toHTML() {
1151
- return generateHTML(VERSION, this.stats, this.findings);
1152
- }
1153
1038
  }
1154
1039
 
1155
1040
  const { scanToolCall, RUNTIME_CHECKS, getCheckStats, LAYER_NAMES } = require('./runtime-guard.js');