@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.
- package/README.md +42 -253
- package/SECURITY.md +12 -4
- package/SKILL.md +121 -59
- package/dist/openclaw-plugin.mjs +41 -0
- package/docs/EVIDENCE_DRIVEN.md +182 -0
- package/docs/banner.png +0 -0
- package/docs/data/corpus-metrics.json +11 -0
- package/docs/data/latest.json +29845 -0
- package/docs/generated/npm-audit-20260312.json +96 -0
- package/docs/generated/openclaw-upstream-status.json +25 -0
- package/docs/glossary.md +46 -0
- package/docs/index.html +1119 -0
- package/docs/logo.png +0 -0
- package/docs/openclaw-compatibility-audit.md +44 -0
- package/docs/openclaw-continuous-compatibility-plan.md +36 -0
- package/docs/rules/a2a-contagion.md +68 -0
- package/docs/rules/advanced-exfil.md +52 -0
- package/docs/rules/agent-protocol.md +108 -0
- package/docs/rules/api-abuse.md +68 -0
- package/docs/rules/autonomous-risk.md +92 -0
- package/docs/rules/config-impact.md +132 -0
- package/docs/rules/credential-handling.md +100 -0
- package/docs/rules/cve-patterns.md +332 -0
- package/docs/rules/data-exposure.md +84 -0
- package/docs/rules/exfiltration.md +36 -0
- package/docs/rules/financial-access.md +84 -0
- package/docs/rules/identity-hijack.md +140 -0
- package/docs/rules/inference-manipulation.md +60 -0
- package/docs/rules/leaky-skills.md +52 -0
- package/docs/rules/malicious-code.md +108 -0
- package/docs/rules/mcp-security.md +148 -0
- package/docs/rules/memory-poisoning.md +84 -0
- package/docs/rules/model-poisoning.md +44 -0
- package/docs/rules/obfuscation.md +60 -0
- package/docs/rules/persistence.md +108 -0
- package/docs/rules/pii-exposure.md +116 -0
- package/docs/rules/prompt-injection.md +148 -0
- package/docs/rules/prompt-worm.md +44 -0
- package/docs/rules/safeguard-bypass.md +44 -0
- package/docs/rules/sandbox-escape.md +100 -0
- package/docs/rules/secret-detection.md +44 -0
- package/docs/rules/supply-chain-v2.md +92 -0
- package/docs/rules/suspicious-download.md +60 -0
- package/docs/rules/trust-boundary.md +76 -0
- package/docs/rules/trust-exploitation.md +92 -0
- package/docs/rules/unverifiable-deps.md +84 -0
- package/docs/rules/vdb-injection.md +84 -0
- package/docs/security-vulnerability-report-20260312.md +53 -0
- package/docs/spec/PRD_V2_ARCHITECTURE.md +55 -0
- package/docs/spec/capabilities.json +42 -0
- package/docs/spec/finding.schema.json +104 -0
- package/docs/spec/integration-manifest.md +39 -0
- package/docs/spec/sbom.json +33 -0
- package/docs/threat-model.md +65 -0
- package/docs/v13-architecture-manifest.md +55 -0
- package/hooks/context.js +305 -0
- package/hooks/guard-scanner/plugin.ts +24 -1
- package/openclaw-plugin.mts +91 -0
- package/openclaw.plugin.json +30 -53
- package/package.json +80 -57
- package/src/cli.js +174 -34
- package/src/core/content-loader.js +42 -0
- package/src/core/inventory.js +73 -0
- package/src/core/report-adapters.js +171 -0
- package/src/core/risk-engine.js +93 -0
- package/src/core/rule-registry.js +73 -0
- package/src/core/semantic-validators.js +85 -0
- package/src/finding-schema.js +191 -0
- package/src/hooks/context.ts +49 -0
- package/src/html-template.js +2 -2
- package/src/mcp-server.js +192 -5
- package/src/openclaw-upstream.js +128 -0
- package/src/patterns.js +519 -157
- package/src/policy-engine.js +32 -0
- package/src/runtime-guard.js +40 -2
- package/src/scanner.js +228 -231
- package/src/skill-crawler.js +254 -0
- package/src/threat-model.js +50 -0
- 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
|
-
*
|
|
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 {
|
|
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 = '
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
382
|
-
|
|
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
|
-
|
|
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 =
|
|
395
|
-
const lineNum = idx >= 0 ?
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
771
|
-
|
|
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(
|
|
774
|
-
sensitiveReads.push({ line: lineNum, text:
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
networkCalls.push({ line: lineNum, text:
|
|
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
|
-
|
|
783
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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');
|