@guava-parity/guard-scanner 15.0.0 → 16.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +208 -42
- package/README_ja.md +252 -0
- package/SKILL.md +40 -11
- package/dist/cli.cjs +5997 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +6003 -0
- package/dist/index.cjs +4825 -0
- package/dist/index.d.mts +17 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.mjs +4798 -0
- package/dist/mcp-server.cjs +4756 -0
- package/dist/mcp-server.d.mts +1 -0
- package/dist/mcp-server.d.ts +1 -0
- package/dist/mcp-server.mjs +4767 -0
- package/dist/openclaw-plugin.cjs +4863 -0
- package/dist/openclaw-plugin.d.mts +11 -0
- package/dist/openclaw-plugin.d.ts +11 -0
- package/dist/openclaw-plugin.mjs +4847 -34
- package/dist/types.cjs +18 -0
- package/dist/types.d.mts +215 -0
- package/dist/types.d.ts +215 -0
- package/dist/types.mjs +1 -0
- package/docs/data/benchmark-ledger.json +1428 -0
- package/docs/data/corpus-metrics.json +3 -3
- package/docs/data/fp-ledger.json +18 -0
- package/docs/data/quality-contract.json +36 -0
- package/docs/generated/openclaw-upstream-status.json +13 -13
- package/docs/openclaw-compatibility-audit.md +3 -2
- package/docs/openclaw-continuous-compatibility-plan.md +2 -1
- package/docs/spec/capabilities.json +137 -5
- package/docs/spec/plugin-trust.json +11 -0
- package/hooks/{context.js → context.ts} +1 -0
- package/openclaw-plugin.mts +21 -5
- package/openclaw.plugin.json +2 -2
- package/package.json +58 -20
- package/src/asset-auditor.js +0 -508
- package/src/ci-reporter.js +0 -135
- package/src/cli.js +0 -434
- package/src/core/content-loader.js +0 -42
- package/src/core/inventory.js +0 -73
- package/src/core/report-adapters.js +0 -171
- package/src/core/risk-engine.js +0 -93
- package/src/core/rule-registry.js +0 -73
- package/src/core/semantic-validators.js +0 -85
- package/src/finding-schema.js +0 -191
- package/src/hooks/context.ts +0 -49
- package/src/html-template.js +0 -239
- package/src/ioc-db.js +0 -54
- package/src/mcp-server.js +0 -653
- package/src/openclaw-upstream.js +0 -128
- package/src/patterns.js +0 -629
- package/src/policy-engine.js +0 -32
- package/src/quarantine.js +0 -41
- package/src/runtime-guard.js +0 -384
- package/src/scanner.js +0 -1042
- package/src/skill-crawler.js +0 -254
- package/src/threat-model.js +0 -50
- package/src/validation-layer.js +0 -39
- package/src/vt-client.js +0 -202
- package/src/watcher.js +0 -170
package/src/scanner.js
DELETED
|
@@ -1,1042 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* guard-scanner v2.1.0 — Agent Skill Security Scanner 🛡️
|
|
4
|
-
*
|
|
5
|
-
* @security-manifest
|
|
6
|
-
* env-read: []
|
|
7
|
-
* env-write: []
|
|
8
|
-
* network: none
|
|
9
|
-
* fs-read: [scan target directory (user-specified)]
|
|
10
|
-
* fs-write: [JSON/SARIF/HTML reports to scan directory]
|
|
11
|
-
* exec: none
|
|
12
|
-
* purpose: Static analysis of agent skill files for threat patterns
|
|
13
|
-
*
|
|
14
|
-
* Based on GuavaGuard v9.0.0 (OSS extraction)
|
|
15
|
-
* 20 threat categories • Snyk ToxicSkills + OWASP MCP Top 10
|
|
16
|
-
* Lightweight runtime footprint • CLI + JSON + SARIF + HTML output
|
|
17
|
-
* Plugin API for custom detection rules
|
|
18
|
-
*
|
|
19
|
-
* Born from a real 3-day agent identity hijack (2026-02-12)
|
|
20
|
-
*
|
|
21
|
-
* License: MIT
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
const fs = require('fs');
|
|
25
|
-
const path = require('path');
|
|
26
|
-
const os = require('os');
|
|
27
|
-
|
|
28
|
-
const { PATTERNS } = require('./patterns.js');
|
|
29
|
-
const { KNOWN_MALICIOUS } = require('./ioc-db.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');
|
|
36
|
-
|
|
37
|
-
// ===== CONFIGURATION =====
|
|
38
|
-
const { version: VERSION } = require('../package.json');
|
|
39
|
-
|
|
40
|
-
const THRESHOLDS = {
|
|
41
|
-
normal: { suspicious: 30, malicious: 80 },
|
|
42
|
-
strict: { suspicious: 20, malicious: 60 },
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
class GuardScanner {
|
|
46
|
-
constructor(options = {}) {
|
|
47
|
-
this.verbose = options.verbose || false;
|
|
48
|
-
this.selfExclude = options.selfExclude || false;
|
|
49
|
-
this.strict = options.strict || false;
|
|
50
|
-
this.summaryOnly = options.summaryOnly || false;
|
|
51
|
-
this.quiet = options.quiet || false;
|
|
52
|
-
this.checkDeps = options.checkDeps || false;
|
|
53
|
-
this.soulLock = options.soulLock || false;
|
|
54
|
-
this.scannerDir = path.resolve(__dirname);
|
|
55
|
-
this.thresholds = this.strict ? THRESHOLDS.strict : THRESHOLDS.normal;
|
|
56
|
-
this.findings = [];
|
|
57
|
-
this.stats = { scanned: 0, clean: 0, low: 0, suspicious: 0, malicious: 0 };
|
|
58
|
-
this.ignoredSkills = new Set();
|
|
59
|
-
this.ignoredPatterns = new Set();
|
|
60
|
-
this.customRules = [];
|
|
61
|
-
|
|
62
|
-
// Plugin API: load plugins
|
|
63
|
-
if (options.plugins && Array.isArray(options.plugins)) {
|
|
64
|
-
for (const plugin of options.plugins) {
|
|
65
|
-
this.loadPlugin(plugin);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Custom rules file (legacy compat)
|
|
70
|
-
if (options.rulesFile) {
|
|
71
|
-
this.loadCustomRules(options.rulesFile);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
this.ruleRegistry = new RuleRegistry(PATTERNS, this.customRules);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Plugin API: load a plugin module
|
|
78
|
-
loadPlugin(pluginPath) {
|
|
79
|
-
try {
|
|
80
|
-
const plugin = require(path.resolve(pluginPath));
|
|
81
|
-
if (plugin.patterns && Array.isArray(plugin.patterns)) {
|
|
82
|
-
for (const p of plugin.patterns) {
|
|
83
|
-
if (p.id && p.regex && p.severity && p.cat && p.desc) {
|
|
84
|
-
this.customRules.push(p);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (!this.summaryOnly) {
|
|
88
|
-
console.log(`🔌 Plugin loaded: ${plugin.name || pluginPath} (${plugin.patterns.length} rule(s))`);
|
|
89
|
-
}
|
|
90
|
-
this.ruleRegistry = new RuleRegistry(PATTERNS, this.customRules);
|
|
91
|
-
}
|
|
92
|
-
} catch (e) {
|
|
93
|
-
console.error(`⚠️ Failed to load plugin ${pluginPath}: ${e.message}`);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Custom rules from JSON file
|
|
98
|
-
loadCustomRules(rulesFile) {
|
|
99
|
-
try {
|
|
100
|
-
const content = fs.readFileSync(rulesFile, 'utf-8');
|
|
101
|
-
const rules = JSON.parse(content);
|
|
102
|
-
if (!Array.isArray(rules)) {
|
|
103
|
-
console.error(`⚠️ Custom rules file must be a JSON array`);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
for (const rule of rules) {
|
|
107
|
-
if (!rule.id || !rule.pattern || !rule.severity || !rule.cat || !rule.desc) {
|
|
108
|
-
console.error(`⚠️ Skipping invalid rule: ${JSON.stringify(rule).substring(0, 80)}`);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
const flags = rule.flags || 'gi';
|
|
113
|
-
this.customRules.push({
|
|
114
|
-
id: rule.id,
|
|
115
|
-
cat: rule.cat,
|
|
116
|
-
regex: new RegExp(rule.pattern, flags),
|
|
117
|
-
severity: rule.severity,
|
|
118
|
-
desc: rule.desc,
|
|
119
|
-
codeOnly: rule.codeOnly || false,
|
|
120
|
-
docOnly: rule.docOnly || false,
|
|
121
|
-
all: !rule.codeOnly && !rule.docOnly
|
|
122
|
-
});
|
|
123
|
-
} catch (e) {
|
|
124
|
-
console.error(`⚠️ Invalid regex in rule ${rule.id}: ${e.message}`);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (!this.summaryOnly && this.customRules.length > 0) {
|
|
128
|
-
console.log(`📏 Loaded ${this.customRules.length} custom rule(s) from ${rulesFile}`);
|
|
129
|
-
}
|
|
130
|
-
this.ruleRegistry = new RuleRegistry(PATTERNS, this.customRules);
|
|
131
|
-
} catch (e) {
|
|
132
|
-
console.error(`⚠️ Failed to load custom rules: ${e.message}`);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Load .guava-guard-ignore / .guard-scanner-ignore from scan directory
|
|
137
|
-
loadIgnoreFile(scanDir) {
|
|
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`);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
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
|
-
|
|
171
|
-
scanDirectory(dir) {
|
|
172
|
-
if (!fs.existsSync(dir)) {
|
|
173
|
-
throw new Error(`Directory not found: ${dir}`);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
this.loadIgnoreFile(dir);
|
|
177
|
-
|
|
178
|
-
const skills = listSkills(dir);
|
|
179
|
-
|
|
180
|
-
if (!this.quiet) {
|
|
181
|
-
console.log(`\n🛡️ guard-scanner v${VERSION}`);
|
|
182
|
-
console.log(`${'═'.repeat(54)}`);
|
|
183
|
-
console.log(`📂 Scanning: ${dir}`);
|
|
184
|
-
console.log(`📦 Skills found: ${skills.length}`);
|
|
185
|
-
if (this.strict) console.log(`⚡ Strict mode enabled`);
|
|
186
|
-
console.log();
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
for (const skill of skills) {
|
|
190
|
-
const skillPath = path.join(dir, skill);
|
|
191
|
-
|
|
192
|
-
// Self-exclusion
|
|
193
|
-
if (this.selfExclude && path.resolve(skillPath) === this.scannerDir) {
|
|
194
|
-
if (!this.summaryOnly && !this.quiet) console.log(`⏭️ ${skill} — SELF (excluded)`);
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Ignore list
|
|
199
|
-
if (this.ignoredSkills.has(skill)) {
|
|
200
|
-
if (!this.summaryOnly && !this.quiet) console.log(`⏭️ ${skill} — IGNORED`);
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
this.scanSkill(skillPath, skill);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (!this.quiet) this.printSummary();
|
|
208
|
-
return this.findings;
|
|
209
|
-
}
|
|
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
|
-
|
|
218
|
-
scanSkill(skillPath, skillName) {
|
|
219
|
-
this.stats.scanned++;
|
|
220
|
-
const skillFindings = [];
|
|
221
|
-
|
|
222
|
-
// Check 1: Known malicious skill name
|
|
223
|
-
if (KNOWN_MALICIOUS.typosquats.includes(skillName.toLowerCase())) {
|
|
224
|
-
skillFindings.push({
|
|
225
|
-
severity: 'CRITICAL', id: 'KNOWN_TYPOSQUAT', cat: 'malicious-code',
|
|
226
|
-
desc: `Known malicious/typosquat skill name`,
|
|
227
|
-
file: 'SKILL NAME', line: 0
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Check 2: Scan all files
|
|
232
|
-
const files = this.getFiles(skillPath);
|
|
233
|
-
for (const file of files) {
|
|
234
|
-
const ext = path.extname(file).toLowerCase();
|
|
235
|
-
const relFile = path.relative(skillPath, file);
|
|
236
|
-
|
|
237
|
-
if (relFile.includes('node_modules/') || relFile.includes('node_modules\\')) continue;
|
|
238
|
-
if (relFile.startsWith('.git/') || relFile.startsWith('.git\\')) continue;
|
|
239
|
-
if (BINARY_EXTENSIONS.has(ext)) continue;
|
|
240
|
-
if (this.isSelfNoisePath(skillName, relFile)) continue;
|
|
241
|
-
|
|
242
|
-
const content = loadTextFile(file);
|
|
243
|
-
if (content === null) continue;
|
|
244
|
-
|
|
245
|
-
const fileType = this.classifyFile(ext, relFile);
|
|
246
|
-
|
|
247
|
-
// IoC checks
|
|
248
|
-
if (!this.isSelfThreatCorpus(skillName, relFile)) {
|
|
249
|
-
this.checkIoCs(content, relFile, skillFindings);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Pattern checks (context-aware)
|
|
253
|
-
this.checkPatterns(content, relFile, fileType, skillFindings);
|
|
254
|
-
|
|
255
|
-
// Custom rules / plugins
|
|
256
|
-
if (this.customRules.length > 0) {
|
|
257
|
-
this.checkPatterns(content, relFile, fileType, skillFindings, this.customRules);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Hardcoded secret detection
|
|
261
|
-
const baseName = path.basename(relFile).toLowerCase();
|
|
262
|
-
const skipSecretCheck = baseName.endsWith('-lock.json') || baseName === 'package-lock.json' ||
|
|
263
|
-
baseName === 'yarn.lock' || baseName === 'pnpm-lock.yaml' ||
|
|
264
|
-
baseName === '_meta.json' || baseName === '.package-lock.json';
|
|
265
|
-
if (fileType === 'code' && !skipSecretCheck) {
|
|
266
|
-
this.checkHardcodedSecrets(content, relFile, skillFindings);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Lightweight JS data flow analysis
|
|
270
|
-
if ((ext === '.js' || ext === '.mjs' || ext === '.cjs' || ext === '.ts') && content.length < 200000) {
|
|
271
|
-
this.checkJSDataFlow(content, relFile, skillFindings);
|
|
272
|
-
}
|
|
273
|
-
applySemanticValidators(content, relFile, skillFindings);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Check 3: Structural checks
|
|
277
|
-
this.checkStructure(skillPath, skillName, skillFindings);
|
|
278
|
-
|
|
279
|
-
// Check 4: Dependency chain scanning
|
|
280
|
-
if (this.checkDeps) {
|
|
281
|
-
this.checkDependencies(skillPath, skillName, skillFindings);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Check 5: Hidden files detection
|
|
285
|
-
this.checkHiddenFiles(skillPath, skillName, skillFindings);
|
|
286
|
-
|
|
287
|
-
// Check 6: Cross-file analysis
|
|
288
|
-
this.checkCrossFile(skillPath, skillName, skillFindings);
|
|
289
|
-
|
|
290
|
-
// Check 7: Skill manifest validation (v1.1)
|
|
291
|
-
this.checkSkillManifest(skillPath, skillName, skillFindings);
|
|
292
|
-
|
|
293
|
-
// Check 8: Code complexity metrics (v1.1)
|
|
294
|
-
this.checkComplexity(skillPath, skillName, skillFindings);
|
|
295
|
-
|
|
296
|
-
// Check 9: Config impact analysis (v1.1)
|
|
297
|
-
this.checkConfigImpact(skillPath, skillName, skillFindings);
|
|
298
|
-
|
|
299
|
-
// Filter ignored patterns
|
|
300
|
-
const filteredFindings = skillFindings.filter(f => !this.ignoredPatterns.has(f.id));
|
|
301
|
-
|
|
302
|
-
// Calculate risk
|
|
303
|
-
const risk = this.calculateRisk(filteredFindings);
|
|
304
|
-
const verdict = this.getVerdict(risk);
|
|
305
|
-
|
|
306
|
-
this.stats[verdict.stat]++;
|
|
307
|
-
|
|
308
|
-
if (!this.summaryOnly && !this.quiet) {
|
|
309
|
-
console.log(`${verdict.icon} ${skillName} — ${verdict.label} (risk: ${risk})`);
|
|
310
|
-
|
|
311
|
-
if (this.verbose && filteredFindings.length > 0) {
|
|
312
|
-
const byCat = {};
|
|
313
|
-
for (const f of filteredFindings) {
|
|
314
|
-
(byCat[f.cat] = byCat[f.cat] || []).push(f);
|
|
315
|
-
}
|
|
316
|
-
for (const [cat, findings] of Object.entries(byCat)) {
|
|
317
|
-
console.log(` 📁 ${cat}`);
|
|
318
|
-
for (const f of findings) {
|
|
319
|
-
const icon = f.severity === 'CRITICAL' ? '💀' : f.severity === 'HIGH' ? '🔴' : f.severity === 'MEDIUM' ? '🟡' : '⚪';
|
|
320
|
-
const loc = f.line ? `${f.file}:${f.line}` : f.file;
|
|
321
|
-
console.log(` ${icon} [${f.severity}] ${f.desc} — ${loc}`);
|
|
322
|
-
if (f.sample) console.log(` └─ "${f.sample}"`);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
if (filteredFindings.length > 0) {
|
|
329
|
-
this.findings.push({ skill: skillName, risk, verdict: verdict.label, findings: filteredFindings });
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
classifyFile(ext, relFile) {
|
|
334
|
-
return classifyFile(ext, relFile);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
isSelfNoisePath(skillName, relFile) {
|
|
338
|
-
return isSelfNoisePath(skillName, relFile);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
isSelfThreatCorpus(skillName, relFile) {
|
|
342
|
-
return isSelfThreatCorpus(skillName, relFile);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
checkIoCs(content, relFile, findings) {
|
|
346
|
-
const contentLower = content.toLowerCase();
|
|
347
|
-
|
|
348
|
-
for (const ip of KNOWN_MALICIOUS.ips) {
|
|
349
|
-
if (content.includes(ip)) {
|
|
350
|
-
findings.push({ severity: 'CRITICAL', id: 'IOC_IP', cat: 'malicious-code', desc: `Known malicious IP: ${ip}`, file: relFile });
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
for (const url of KNOWN_MALICIOUS.urls) {
|
|
355
|
-
if (contentLower.includes(url.toLowerCase())) {
|
|
356
|
-
findings.push({ severity: 'CRITICAL', id: 'IOC_URL', cat: 'malicious-code', desc: `Known malicious URL: ${url}`, file: relFile });
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
for (const domain of KNOWN_MALICIOUS.domains) {
|
|
361
|
-
const domainRegex = new RegExp(`(?:https?://|[\\s'"\`(]|^)${domain.replace(/\./g, '\\.')}`, 'gi');
|
|
362
|
-
if (domainRegex.test(content)) {
|
|
363
|
-
findings.push({ severity: 'HIGH', id: 'IOC_DOMAIN', cat: 'exfiltration', desc: `Suspicious domain: ${domain}`, file: relFile });
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
for (const fname of KNOWN_MALICIOUS.filenames) {
|
|
368
|
-
if (contentLower.includes(fname.toLowerCase())) {
|
|
369
|
-
findings.push({ severity: 'CRITICAL', id: 'IOC_FILE', cat: 'suspicious-download', desc: `Known malicious filename: ${fname}`, file: relFile });
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
for (const user of KNOWN_MALICIOUS.usernames) {
|
|
374
|
-
if (contentLower.includes(user.toLowerCase())) {
|
|
375
|
-
findings.push({ severity: 'HIGH', id: 'IOC_USER', cat: 'malicious-code', desc: `Known malicious username: ${user}`, file: relFile });
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
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) {
|
|
400
|
-
// Soul Lock: skip identity-hijack/memory-poisoning patterns unless --soul-lock is enabled
|
|
401
|
-
if (pattern.soulLock && !this.soulLock) continue;
|
|
402
|
-
if (pattern.codeOnly && fileType !== 'code') continue;
|
|
403
|
-
if (pattern.docOnly && fileType !== 'doc' && fileType !== 'skill-doc') continue;
|
|
404
|
-
if (!pattern.all && !pattern.codeOnly && !pattern.docOnly && pattern.scope !== 'skill-doc') continue;
|
|
405
|
-
|
|
406
|
-
pattern.regex.lastIndex = 0;
|
|
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
|
-
|
|
417
|
-
if (!matches) continue;
|
|
418
|
-
|
|
419
|
-
pattern.regex.lastIndex = 0;
|
|
420
|
-
const idx = targetContent.search(pattern.regex);
|
|
421
|
-
const lineNum = idx >= 0 ? targetContent.substring(0, idx).split('\n').length : null;
|
|
422
|
-
|
|
423
|
-
let adjustedSeverity = pattern.severity;
|
|
424
|
-
if ((fileType === 'doc' || fileType === 'skill-doc') && pattern.all && !pattern.docOnly) {
|
|
425
|
-
if (adjustedSeverity === 'HIGH') adjustedSeverity = 'MEDIUM';
|
|
426
|
-
else if (adjustedSeverity === 'MEDIUM') adjustedSeverity = 'LOW';
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
findings.push({
|
|
430
|
-
severity: adjustedSeverity,
|
|
431
|
-
id: pattern.id,
|
|
432
|
-
cat: pattern.cat || pattern.category,
|
|
433
|
-
desc: pattern.desc || pattern.description,
|
|
434
|
-
file: relFile,
|
|
435
|
-
line: lineNum,
|
|
436
|
-
matchCount: matches.length,
|
|
437
|
-
sample: matches[0].substring(0, 80)
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Entropy-based secret detection
|
|
443
|
-
checkHardcodedSecrets(content, relFile, findings) {
|
|
444
|
-
const assignmentRegex = /(?:api[_-]?key|secret|token|password|credential|auth)\s*[:=]\s*['"]([a-zA-Z0-9_\-+/=]{16,})['"]|['"]([a-zA-Z0-9_\-+/=]{32,})['"]/gi;
|
|
445
|
-
let match;
|
|
446
|
-
while ((match = assignmentRegex.exec(content)) !== null) {
|
|
447
|
-
const value = match[1] || match[2];
|
|
448
|
-
if (!value) continue;
|
|
449
|
-
|
|
450
|
-
if (/^[A-Z_]+$/.test(value)) continue;
|
|
451
|
-
if (/^(true|false|null|undefined|none|default|example|test|placeholder|your[_-])/i.test(value)) continue;
|
|
452
|
-
if (/^x{4,}|\.{4,}|_{4,}|0{8,}$/i.test(value)) continue;
|
|
453
|
-
if (/^projects\/|^gs:\/\/|^https?:\/\//i.test(value)) continue;
|
|
454
|
-
if (/^[a-z]+-[a-z]+-[a-z0-9]+$/i.test(value)) continue;
|
|
455
|
-
|
|
456
|
-
const entropy = this.shannonEntropy(value);
|
|
457
|
-
if (entropy > 3.5 && value.length >= 20) {
|
|
458
|
-
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
459
|
-
findings.push({
|
|
460
|
-
severity: 'HIGH', id: 'SECRET_ENTROPY', cat: 'secret-detection',
|
|
461
|
-
desc: `High-entropy string (possible leaked secret, entropy=${entropy.toFixed(1)})`,
|
|
462
|
-
file: relFile, line: lineNum,
|
|
463
|
-
sample: value.substring(0, 8) + '...' + value.substring(value.length - 4)
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
shannonEntropy(str) {
|
|
470
|
-
const freq = {};
|
|
471
|
-
for (const c of str) freq[c] = (freq[c] || 0) + 1;
|
|
472
|
-
const len = str.length;
|
|
473
|
-
let entropy = 0;
|
|
474
|
-
for (const count of Object.values(freq)) {
|
|
475
|
-
const p = count / len;
|
|
476
|
-
if (p > 0) entropy -= p * Math.log2(p);
|
|
477
|
-
}
|
|
478
|
-
return entropy;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
checkStructure(skillPath, skillName, findings) {
|
|
482
|
-
const skillMd = path.join(skillPath, 'SKILL.md');
|
|
483
|
-
if (!fs.existsSync(skillMd)) {
|
|
484
|
-
findings.push({ severity: 'LOW', id: 'STRUCT_NO_SKILLMD', cat: 'structural', desc: 'No SKILL.md found', file: skillName });
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
const content = fs.readFileSync(skillMd, 'utf-8');
|
|
488
|
-
if (content.length < 50) {
|
|
489
|
-
findings.push({ severity: 'MEDIUM', id: 'STRUCT_TINY_SKILLMD', cat: 'structural', desc: 'Suspiciously short SKILL.md (< 50 chars)', file: 'SKILL.md' });
|
|
490
|
-
}
|
|
491
|
-
const scriptsDir = path.join(skillPath, 'scripts');
|
|
492
|
-
if (fs.existsSync(scriptsDir)) {
|
|
493
|
-
const scripts = fs.readdirSync(scriptsDir).filter(f => CODE_EXTENSIONS.has(path.extname(f).toLowerCase()));
|
|
494
|
-
if (scripts.length > 0 && !content.includes('scripts/')) {
|
|
495
|
-
findings.push({ severity: 'MEDIUM', id: 'STRUCT_UNDOCUMENTED_SCRIPTS', cat: 'structural', desc: `${scripts.length} script(s) in scripts/ not referenced in SKILL.md`, file: 'scripts/' });
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
checkDependencies(skillPath, skillName, findings) {
|
|
501
|
-
const pkgPath = path.join(skillPath, 'package.json');
|
|
502
|
-
if (!fs.existsSync(pkgPath)) return;
|
|
503
|
-
|
|
504
|
-
let pkg;
|
|
505
|
-
try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); } catch { return; }
|
|
506
|
-
|
|
507
|
-
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies };
|
|
508
|
-
|
|
509
|
-
const RISKY_PACKAGES = new Set([
|
|
510
|
-
'node-ipc', 'colors', 'faker', 'event-stream', 'ua-parser-js', 'coa', 'rc',
|
|
511
|
-
]);
|
|
512
|
-
|
|
513
|
-
for (const [dep, version] of Object.entries(allDeps)) {
|
|
514
|
-
if (RISKY_PACKAGES.has(dep)) {
|
|
515
|
-
findings.push({ severity: 'HIGH', id: 'DEP_RISKY', cat: 'dependency-chain', desc: `Known risky dependency: ${dep}@${version}`, file: 'package.json' });
|
|
516
|
-
}
|
|
517
|
-
if (typeof version === 'string' && (version.startsWith('git+') || version.startsWith('http') || version.startsWith('github:') || version.includes('.tar.gz'))) {
|
|
518
|
-
findings.push({ severity: 'HIGH', id: 'DEP_REMOTE', cat: 'dependency-chain', desc: `Remote/git dependency: ${dep}@${version}`, file: 'package.json' });
|
|
519
|
-
}
|
|
520
|
-
if (version === '*' || version === 'latest') {
|
|
521
|
-
findings.push({ severity: 'MEDIUM', id: 'DEP_WILDCARD', cat: 'dependency-chain', desc: `Wildcard version: ${dep}@${version}`, file: 'package.json' });
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const RISKY_SCRIPTS = ['preinstall', 'postinstall', 'preuninstall', 'postuninstall', 'prepare'];
|
|
526
|
-
if (pkg.scripts) {
|
|
527
|
-
for (const scriptName of RISKY_SCRIPTS) {
|
|
528
|
-
if (pkg.scripts[scriptName]) {
|
|
529
|
-
const cmd = pkg.scripts[scriptName];
|
|
530
|
-
findings.push({ severity: 'HIGH', id: 'DEP_LIFECYCLE', cat: 'dependency-chain', desc: `Lifecycle script "${scriptName}": ${cmd.substring(0, 80)}`, file: 'package.json' });
|
|
531
|
-
if (/curl|wget|node\s+-e|eval|exec|bash\s+-c/i.test(cmd)) {
|
|
532
|
-
findings.push({ severity: 'CRITICAL', id: 'DEP_LIFECYCLE_EXEC', cat: 'dependency-chain', desc: `Lifecycle script "${scriptName}" downloads/executes code`, file: 'package.json', sample: cmd.substring(0, 80) });
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// ── v1.1: Skill Manifest Validation ──
|
|
540
|
-
// Checks SKILL.md frontmatter for dangerous tool declarations,
|
|
541
|
-
// overly broad file scope, and sensitive env requirements
|
|
542
|
-
checkSkillManifest(skillPath, skillName, findings) {
|
|
543
|
-
const skillMd = path.join(skillPath, 'SKILL.md');
|
|
544
|
-
if (!fs.existsSync(skillMd)) return;
|
|
545
|
-
|
|
546
|
-
let content;
|
|
547
|
-
try { content = fs.readFileSync(skillMd, 'utf-8'); } catch { return; }
|
|
548
|
-
|
|
549
|
-
// Parse YAML frontmatter (lightweight, no dependency)
|
|
550
|
-
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
551
|
-
if (!fmMatch) return;
|
|
552
|
-
const fm = fmMatch[1];
|
|
553
|
-
|
|
554
|
-
// Check 1: Dangerous binary requirements
|
|
555
|
-
const DANGEROUS_BINS = new Set([
|
|
556
|
-
'sudo', 'rm', 'rmdir', 'chmod', 'chown', 'kill', 'pkill',
|
|
557
|
-
'curl', 'wget', 'nc', 'ncat', 'socat', 'ssh', 'scp',
|
|
558
|
-
'dd', 'mkfs', 'fdisk', 'mount', 'umount',
|
|
559
|
-
'iptables', 'ufw', 'firewall-cmd',
|
|
560
|
-
'docker', 'kubectl', 'systemctl',
|
|
561
|
-
]);
|
|
562
|
-
const binsMatch = fm.match(/bins:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
|
|
563
|
-
if (binsMatch) {
|
|
564
|
-
const bins = binsMatch[1].match(/- ([^\n]+)/g) || [];
|
|
565
|
-
for (const binLine of bins) {
|
|
566
|
-
const bin = binLine.replace(/^-\s*/, '').trim().toLowerCase();
|
|
567
|
-
if (DANGEROUS_BINS.has(bin)) {
|
|
568
|
-
findings.push({
|
|
569
|
-
severity: 'HIGH', id: 'MANIFEST_DANGEROUS_BIN',
|
|
570
|
-
cat: 'sandbox-validation',
|
|
571
|
-
desc: `SKILL.md requires dangerous binary: ${bin}`,
|
|
572
|
-
file: 'SKILL.md'
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// Check 2: Overly broad file scope
|
|
579
|
-
const filesMatch = fm.match(/files:\s*\[([^\]]+)\]/i) || fm.match(/files:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
|
|
580
|
-
if (filesMatch) {
|
|
581
|
-
const filesStr = filesMatch[1];
|
|
582
|
-
if (/\*\*\/\*|\*\.\*|\"\*\"/i.test(filesStr)) {
|
|
583
|
-
findings.push({
|
|
584
|
-
severity: 'HIGH', id: 'MANIFEST_BROAD_FILES',
|
|
585
|
-
cat: 'sandbox-validation',
|
|
586
|
-
desc: 'SKILL.md declares overly broad file scope (e.g. **/*)',
|
|
587
|
-
file: 'SKILL.md'
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// Check 3: Sensitive env requirements
|
|
593
|
-
const SENSITIVE_ENV_PATTERNS = /(?:SECRET|PASSWORD|CREDENTIAL|PRIVATE_KEY|AWS_SECRET|GITHUB_TOKEN)/i;
|
|
594
|
-
const envMatch = fm.match(/env:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
|
|
595
|
-
if (envMatch) {
|
|
596
|
-
const envVars = envMatch[1].match(/- ([^\n]+)/g) || [];
|
|
597
|
-
for (const envLine of envVars) {
|
|
598
|
-
const envVar = envLine.replace(/^-\s*/, '').trim();
|
|
599
|
-
if (SENSITIVE_ENV_PATTERNS.test(envVar)) {
|
|
600
|
-
findings.push({
|
|
601
|
-
severity: 'HIGH', id: 'MANIFEST_SENSITIVE_ENV',
|
|
602
|
-
cat: 'sandbox-validation',
|
|
603
|
-
desc: `SKILL.md requires sensitive env var: ${envVar}`,
|
|
604
|
-
file: 'SKILL.md'
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// Check 4: exec or network declared without justification
|
|
611
|
-
if (/exec:\s*(?:true|yes|enabled|'\*'|"\*")/i.test(fm)) {
|
|
612
|
-
findings.push({
|
|
613
|
-
severity: 'MEDIUM', id: 'MANIFEST_EXEC_DECLARED',
|
|
614
|
-
cat: 'sandbox-validation',
|
|
615
|
-
desc: 'SKILL.md declares exec capability',
|
|
616
|
-
file: 'SKILL.md'
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
if (/network:\s*(?:true|yes|enabled|'\*'|"\*"|all|any)/i.test(fm)) {
|
|
620
|
-
findings.push({
|
|
621
|
-
severity: 'MEDIUM', id: 'MANIFEST_NETWORK_DECLARED',
|
|
622
|
-
cat: 'sandbox-validation',
|
|
623
|
-
desc: 'SKILL.md declares unrestricted network access',
|
|
624
|
-
file: 'SKILL.md'
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// ── v1.1: Code Complexity Metrics ──
|
|
630
|
-
// Detects excessive file length, deep nesting, and eval/exec density
|
|
631
|
-
checkComplexity(skillPath, skillName, findings) {
|
|
632
|
-
const files = this.getFiles(skillPath);
|
|
633
|
-
const MAX_LINES = 1000;
|
|
634
|
-
const MAX_NESTING = 5;
|
|
635
|
-
const MAX_EVAL_DENSITY = 0.02; // 2% of lines
|
|
636
|
-
|
|
637
|
-
for (const file of files) {
|
|
638
|
-
const ext = path.extname(file).toLowerCase();
|
|
639
|
-
if (!CODE_EXTENSIONS.has(ext)) continue;
|
|
640
|
-
|
|
641
|
-
const relFile = path.relative(skillPath, file);
|
|
642
|
-
if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
|
|
643
|
-
|
|
644
|
-
let content;
|
|
645
|
-
try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
|
|
646
|
-
|
|
647
|
-
const lines = content.split('\n');
|
|
648
|
-
|
|
649
|
-
// Check 1: Excessive file length
|
|
650
|
-
if (lines.length > MAX_LINES) {
|
|
651
|
-
findings.push({
|
|
652
|
-
severity: 'MEDIUM', id: 'COMPLEXITY_LONG_FILE',
|
|
653
|
-
cat: 'complexity',
|
|
654
|
-
desc: `File exceeds ${MAX_LINES} lines (${lines.length} lines)`,
|
|
655
|
-
file: relFile
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// Check 2: Deep nesting (brace tracking)
|
|
660
|
-
let maxDepth = 0;
|
|
661
|
-
let currentDepth = 0;
|
|
662
|
-
let deepestLine = 0;
|
|
663
|
-
for (let i = 0; i < lines.length; i++) {
|
|
664
|
-
const line = lines[i];
|
|
665
|
-
// Count opening/closing braces outside strings (simplified)
|
|
666
|
-
for (const ch of line) {
|
|
667
|
-
if (ch === '{') currentDepth++;
|
|
668
|
-
if (ch === '}') currentDepth = Math.max(0, currentDepth - 1);
|
|
669
|
-
}
|
|
670
|
-
if (currentDepth > maxDepth) {
|
|
671
|
-
maxDepth = currentDepth;
|
|
672
|
-
deepestLine = i + 1;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
if (maxDepth > MAX_NESTING) {
|
|
676
|
-
findings.push({
|
|
677
|
-
severity: 'MEDIUM', id: 'COMPLEXITY_DEEP_NESTING',
|
|
678
|
-
cat: 'complexity',
|
|
679
|
-
desc: `Deep nesting detected: ${maxDepth} levels (max recommended: ${MAX_NESTING})`,
|
|
680
|
-
file: relFile, line: deepestLine
|
|
681
|
-
});
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// Check 3: eval/exec density
|
|
685
|
-
const evalPattern = /\b(?:eval|exec|execSync|spawn|Function)\s*\(/g;
|
|
686
|
-
const evalMatches = content.match(evalPattern) || [];
|
|
687
|
-
const density = lines.length > 0 ? evalMatches.length / lines.length : 0;
|
|
688
|
-
if (density > MAX_EVAL_DENSITY && evalMatches.length >= 3) {
|
|
689
|
-
findings.push({
|
|
690
|
-
severity: 'HIGH', id: 'COMPLEXITY_EVAL_DENSITY',
|
|
691
|
-
cat: 'complexity',
|
|
692
|
-
desc: `High eval/exec density: ${evalMatches.length} calls in ${lines.length} lines (${(density * 100).toFixed(1)}%)`,
|
|
693
|
-
file: relFile
|
|
694
|
-
});
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
// ── v1.1: Config Impact Analysis ──
|
|
700
|
-
// Detects modifications to openclaw.json and dangerous configuration changes
|
|
701
|
-
checkConfigImpact(skillPath, skillName, findings) {
|
|
702
|
-
const files = this.getFiles(skillPath);
|
|
703
|
-
|
|
704
|
-
for (const file of files) {
|
|
705
|
-
const ext = path.extname(file).toLowerCase();
|
|
706
|
-
if (!CODE_EXTENSIONS.has(ext) && ext !== '.json') continue;
|
|
707
|
-
|
|
708
|
-
const relFile = path.relative(skillPath, file);
|
|
709
|
-
if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
|
|
710
|
-
|
|
711
|
-
let content;
|
|
712
|
-
try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
|
|
713
|
-
|
|
714
|
-
// Check 1: openclaw.json reference + write operation in same file
|
|
715
|
-
// Handles both direct and variable-based patterns (e.g. writeFileSync(configPath))
|
|
716
|
-
const hasConfigRef = /openclaw\.json/i.test(content);
|
|
717
|
-
const hasWriteOp = /(?:writeFileSync|writeFile|fs\.write)\s*\(/i.test(content);
|
|
718
|
-
if (hasConfigRef && hasWriteOp) {
|
|
719
|
-
// Find the write line for location info
|
|
720
|
-
const clines = content.split('\n');
|
|
721
|
-
let writeLine = 0;
|
|
722
|
-
for (let i = 0; i < clines.length; i++) {
|
|
723
|
-
if (/(?:writeFileSync|writeFile|fs\.write)\s*\(/i.test(clines[i])) {
|
|
724
|
-
writeLine = i + 1;
|
|
725
|
-
break;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
findings.push({
|
|
729
|
-
severity: 'CRITICAL', id: 'CFG_WRITE_DETECTED',
|
|
730
|
-
cat: 'config-impact',
|
|
731
|
-
desc: 'Code writes to openclaw.json',
|
|
732
|
-
file: relFile, line: writeLine,
|
|
733
|
-
sample: writeLine > 0 ? clines[writeLine - 1].trim().substring(0, 80) : ''
|
|
734
|
-
});
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Check 2: Dangerous config key modifications
|
|
738
|
-
const DANGEROUS_CONFIG_KEYS = [
|
|
739
|
-
{ regex: /exec\.approvals?\s*[:=]\s*['"]?(off|false|disabled|none)/gi, id: 'CFG_EXEC_APPROVAL_OFF', desc: 'Disables exec approval requirement', severity: 'CRITICAL' },
|
|
740
|
-
{ regex: /tools\.exec\.host\s*[:=]\s*['"]gateway['"]/gi, id: 'CFG_EXEC_HOST_GATEWAY', desc: 'Sets exec host to gateway (bypasses sandbox)', severity: 'CRITICAL' },
|
|
741
|
-
{ regex: /hooks\s*\.\s*internal\s*\.\s*entries\s*[:=]/gi, id: 'CFG_HOOKS_INTERNAL', desc: 'Modifies internal hook entries', severity: 'HIGH' },
|
|
742
|
-
{ regex: /network\.allowedDomains\s*[:=]\s*\[?\s*['"]\*['"]/gi, id: 'CFG_NET_WILDCARD', desc: 'Sets network allowedDomains to wildcard', severity: 'HIGH' },
|
|
743
|
-
];
|
|
744
|
-
|
|
745
|
-
for (const check of DANGEROUS_CONFIG_KEYS) {
|
|
746
|
-
check.regex.lastIndex = 0;
|
|
747
|
-
if (check.regex.test(content)) {
|
|
748
|
-
findings.push({
|
|
749
|
-
severity: check.severity, id: check.id,
|
|
750
|
-
cat: 'config-impact',
|
|
751
|
-
desc: check.desc,
|
|
752
|
-
file: relFile
|
|
753
|
-
});
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
checkHiddenFiles(skillPath, skillName, findings) {
|
|
760
|
-
try {
|
|
761
|
-
const entries = fs.readdirSync(skillPath);
|
|
762
|
-
for (const entry of entries) {
|
|
763
|
-
if (entry.startsWith('.') && entry !== '.guard-scanner-ignore' && entry !== '.guava-guard-ignore' && entry !== '.gitignore' && entry !== '.git') {
|
|
764
|
-
const fullPath = path.join(skillPath, entry);
|
|
765
|
-
const stat = fs.statSync(fullPath);
|
|
766
|
-
if (stat.isFile()) {
|
|
767
|
-
const ext = path.extname(entry).toLowerCase();
|
|
768
|
-
if (CODE_EXTENSIONS.has(ext) || ext === '' || ext === '.sh') {
|
|
769
|
-
findings.push({ severity: 'MEDIUM', id: 'STRUCT_HIDDEN_EXEC', cat: 'structural', desc: `Hidden executable file: ${entry}`, file: entry });
|
|
770
|
-
}
|
|
771
|
-
} else if (stat.isDirectory() && entry !== '.git') {
|
|
772
|
-
findings.push({ severity: 'LOW', id: 'STRUCT_HIDDEN_DIR', cat: 'structural', desc: `Hidden directory: ${entry}/`, file: entry });
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
} catch { }
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
checkJSDataFlow(content, relFile, findings) {
|
|
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');
|
|
788
|
-
const imports = new Map();
|
|
789
|
-
const sensitiveReads = [];
|
|
790
|
-
const networkCalls = [];
|
|
791
|
-
const execCalls = [];
|
|
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
|
|
811
|
-
for (let i = 0; i < lines.length; i++) {
|
|
812
|
-
const line = lines[i];
|
|
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')
|
|
827
|
-
const reqMatch = line.match(/(?:const|let|var)\s+(?:{[^}]+}|\w+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
|
|
828
|
-
if (reqMatch) {
|
|
829
|
-
const varMatch = line.match(/(?:const|let|var)\s+({[^}]+}|\w+)/);
|
|
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}"`);
|
|
854
|
-
}
|
|
855
|
-
|
|
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() });
|
|
859
|
-
}
|
|
860
|
-
if (/process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i.test(resolvedLine)) {
|
|
861
|
-
sensitiveReads.push({ line: lineNum, text: resolvedLine.trim() });
|
|
862
|
-
}
|
|
863
|
-
|
|
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() });
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
const execPattern = new RegExp(`(?:${escapeRegex(activeAliases.exec)})\\s*\\(`, 'i');
|
|
870
|
-
if (execPattern.test(resolvedLine)) {
|
|
871
|
-
execCalls.push({ line: lineNum, text: resolvedLine.trim() });
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
if (sensitiveReads.length > 0 && networkCalls.length > 0) {
|
|
876
|
-
findings.push({
|
|
877
|
-
severity: 'CRITICAL', id: 'AST_CRED_TO_NET', cat: 'data-flow',
|
|
878
|
-
desc: `Data flow: secret read (L${sensitiveReads[0].line}) → network call (L${networkCalls[0].line})`,
|
|
879
|
-
file: relFile, line: sensitiveReads[0].line,
|
|
880
|
-
sample: sensitiveReads[0].text.substring(0, 60)
|
|
881
|
-
});
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
if (sensitiveReads.length > 0 && execCalls.length > 0) {
|
|
885
|
-
findings.push({
|
|
886
|
-
severity: 'HIGH', id: 'AST_CRED_TO_EXEC', cat: 'data-flow',
|
|
887
|
-
desc: `Data flow: secret read (L${sensitiveReads[0].line}) → command exec (L${execCalls[0].line})`,
|
|
888
|
-
file: relFile, line: sensitiveReads[0].line,
|
|
889
|
-
sample: sensitiveReads[0].text.substring(0, 60)
|
|
890
|
-
});
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
const importedModules = new Set([...imports.values()]);
|
|
894
|
-
if (importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http') || importedModules.has('node-fetch'))) {
|
|
895
|
-
findings.push({ severity: 'HIGH', id: 'AST_SUSPICIOUS_IMPORTS', cat: 'data-flow', desc: 'Suspicious import combination: child_process + network module', file: relFile });
|
|
896
|
-
}
|
|
897
|
-
if (importedModules.has('fs') && importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http'))) {
|
|
898
|
-
findings.push({ severity: 'CRITICAL', id: 'AST_EXFIL_TRIFECTA', cat: 'data-flow', desc: 'Exfiltration trifecta: fs + child_process + network', file: relFile });
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
for (let i = 0; i < lines.length; i++) {
|
|
902
|
-
const line = lines[i];
|
|
903
|
-
if (/`[^`]*\$\{.*(?:env|key|token|secret|password).*\}[^`]*`\s*(?:\)|,)/i.test(line) &&
|
|
904
|
-
/(?:fetch|request|axios|http|url)/i.test(line)) {
|
|
905
|
-
findings.push({ severity: 'CRITICAL', id: 'AST_SECRET_IN_URL', cat: 'data-flow', desc: 'Secret interpolated into URL/request', file: relFile, line: i + 1, sample: line.trim().substring(0, 80) });
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
checkCrossFile(skillPath, skillName, findings) {
|
|
911
|
-
const files = this.getFiles(skillPath);
|
|
912
|
-
const allContent = {};
|
|
913
|
-
|
|
914
|
-
for (const file of files) {
|
|
915
|
-
const ext = path.extname(file).toLowerCase();
|
|
916
|
-
if (BINARY_EXTENSIONS.has(ext)) continue;
|
|
917
|
-
const relFile = path.relative(skillPath, file);
|
|
918
|
-
if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
|
|
919
|
-
try {
|
|
920
|
-
const content = fs.readFileSync(file, 'utf-8');
|
|
921
|
-
if (content.length < 500000) allContent[relFile] = content;
|
|
922
|
-
} catch { }
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
const skillMd = allContent['SKILL.md'] || '';
|
|
926
|
-
const codeFileRefs = skillMd.match(/(?:scripts?\/|\.\/)[a-zA-Z0-9_\-./]+\.(js|py|sh|ts)/gi) || [];
|
|
927
|
-
for (const ref of codeFileRefs) {
|
|
928
|
-
const cleanRef = ref.replace(/^\.\//, '');
|
|
929
|
-
if (!allContent[cleanRef] && !files.some(f => path.relative(skillPath, f) === cleanRef)) {
|
|
930
|
-
findings.push({ severity: 'MEDIUM', id: 'XFILE_PHANTOM_REF', cat: 'structural', desc: `SKILL.md references non-existent file: ${cleanRef}`, file: 'SKILL.md' });
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
const base64Fragments = [];
|
|
935
|
-
for (const [file, content] of Object.entries(allContent)) {
|
|
936
|
-
const matches = content.match(/[A-Za-z0-9+/]{20,}={0,2}/g) || [];
|
|
937
|
-
for (const m of matches) {
|
|
938
|
-
if (m.length > 40) base64Fragments.push({ file, fragment: m.substring(0, 30) });
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
if (base64Fragments.length > 3 && new Set(base64Fragments.map(f => f.file)).size > 1) {
|
|
942
|
-
findings.push({ severity: 'HIGH', id: 'XFILE_FRAGMENT_B64', cat: 'obfuscation', desc: `Base64 fragments across ${new Set(base64Fragments.map(f => f.file)).size} files`, file: skillName });
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
if (/(?:read|load|source|import)\s+(?:the\s+)?(?:script|file|code)\s+(?:from|at|in)\s+(?:scripts?\/)/gi.test(skillMd)) {
|
|
946
|
-
const hasExec = Object.values(allContent).some(c => /(?:eval|exec|spawn)\s*\(/i.test(c));
|
|
947
|
-
if (hasExec) {
|
|
948
|
-
findings.push({ severity: 'MEDIUM', id: 'XFILE_LOAD_EXEC', cat: 'data-flow', desc: 'SKILL.md references script files that contain exec/eval', file: 'SKILL.md' });
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
calculateRisk(findings) {
|
|
954
|
-
return calculateRisk(findings);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
getVerdict(risk) {
|
|
958
|
-
return getVerdict(risk, this.thresholds);
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
getFiles(dir) {
|
|
962
|
-
return getFiles(dir);
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
printSummary() {
|
|
966
|
-
return printSummary(this.stats, VERSION);
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
toJSON() {
|
|
970
|
-
return toJSONReport(this, VERSION);
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
toSARIF(scanDir) {
|
|
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;
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
return {
|
|
1032
|
-
timestamp: new Date().toISOString(),
|
|
1033
|
-
surface,
|
|
1034
|
-
summary: Object.keys(surface).filter(k => surface[k]).join(', ') || 'none'
|
|
1035
|
-
};
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
const { scanToolCall, RUNTIME_CHECKS, getCheckStats, LAYER_NAMES } = require('./runtime-guard.js');
|
|
1041
|
-
|
|
1042
|
-
module.exports = { GuardScanner, VERSION, THRESHOLDS, SEVERITY_WEIGHTS, scanToolCall, RUNTIME_CHECKS, getCheckStats, LAYER_NAMES };
|