guard-scanner 1.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/src/scanner.js ADDED
@@ -0,0 +1,808 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * guard-scanner v1.0.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
+ * 17 threat categories • Snyk ToxicSkills + OWASP MCP Top 10
16
+ * Zero dependencies • CLI + JSON + SARIF + HTML output
17
+ * Plugin API for custom detection rules
18
+ *
19
+ * Born from a real 3-day agent identity hijack (2026-02-12)
20
+ *
21
+ * License: MIT
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const os = require('os');
27
+
28
+ const { PATTERNS } = require('./patterns.js');
29
+ const { KNOWN_MALICIOUS } = require('./ioc-db.js');
30
+
31
+ // ===== CONFIGURATION =====
32
+ const VERSION = '1.0.0';
33
+
34
+ const THRESHOLDS = {
35
+ normal: { suspicious: 30, malicious: 80 },
36
+ strict: { suspicious: 20, malicious: 60 },
37
+ };
38
+
39
+ // File classification
40
+ const CODE_EXTENSIONS = new Set(['.js', '.ts', '.mjs', '.cjs', '.py', '.sh', '.bash', '.ps1', '.rb', '.go', '.rs', '.php', '.pl']);
41
+ const DOC_EXTENSIONS = new Set(['.md', '.txt', '.rst', '.adoc']);
42
+ const DATA_EXTENSIONS = new Set(['.json', '.yaml', '.yml', '.toml', '.xml', '.csv']);
43
+ 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']);
44
+
45
+ // Severity weights for risk scoring
46
+ const SEVERITY_WEIGHTS = { CRITICAL: 40, HIGH: 15, MEDIUM: 5, LOW: 2 };
47
+
48
+ class GuardScanner {
49
+ constructor(options = {}) {
50
+ this.verbose = options.verbose || false;
51
+ this.selfExclude = options.selfExclude || false;
52
+ this.strict = options.strict || false;
53
+ this.summaryOnly = options.summaryOnly || false;
54
+ this.checkDeps = options.checkDeps || false;
55
+ this.scannerDir = path.resolve(__dirname);
56
+ this.thresholds = this.strict ? THRESHOLDS.strict : THRESHOLDS.normal;
57
+ this.findings = [];
58
+ this.stats = { scanned: 0, clean: 0, low: 0, suspicious: 0, malicious: 0 };
59
+ this.ignoredSkills = new Set();
60
+ this.ignoredPatterns = new Set();
61
+ this.customRules = [];
62
+
63
+ // Plugin API: load plugins
64
+ if (options.plugins && Array.isArray(options.plugins)) {
65
+ for (const plugin of options.plugins) {
66
+ this.loadPlugin(plugin);
67
+ }
68
+ }
69
+
70
+ // Custom rules file (legacy compat)
71
+ if (options.rulesFile) {
72
+ this.loadCustomRules(options.rulesFile);
73
+ }
74
+ }
75
+
76
+ // Plugin API: load a plugin module
77
+ loadPlugin(pluginPath) {
78
+ try {
79
+ const plugin = require(path.resolve(pluginPath));
80
+ if (plugin.patterns && Array.isArray(plugin.patterns)) {
81
+ for (const p of plugin.patterns) {
82
+ if (p.id && p.regex && p.severity && p.cat && p.desc) {
83
+ this.customRules.push(p);
84
+ }
85
+ }
86
+ if (!this.summaryOnly) {
87
+ console.log(`šŸ”Œ Plugin loaded: ${plugin.name || pluginPath} (${plugin.patterns.length} rule(s))`);
88
+ }
89
+ }
90
+ } catch (e) {
91
+ console.error(`āš ļø Failed to load plugin ${pluginPath}: ${e.message}`);
92
+ }
93
+ }
94
+
95
+ // Custom rules from JSON file
96
+ loadCustomRules(rulesFile) {
97
+ try {
98
+ const content = fs.readFileSync(rulesFile, 'utf-8');
99
+ const rules = JSON.parse(content);
100
+ if (!Array.isArray(rules)) {
101
+ console.error(`āš ļø Custom rules file must be a JSON array`);
102
+ return;
103
+ }
104
+ for (const rule of rules) {
105
+ if (!rule.id || !rule.pattern || !rule.severity || !rule.cat || !rule.desc) {
106
+ console.error(`āš ļø Skipping invalid rule: ${JSON.stringify(rule).substring(0, 80)}`);
107
+ continue;
108
+ }
109
+ try {
110
+ const flags = rule.flags || 'gi';
111
+ this.customRules.push({
112
+ id: rule.id,
113
+ cat: rule.cat,
114
+ regex: new RegExp(rule.pattern, flags),
115
+ severity: rule.severity,
116
+ desc: rule.desc,
117
+ codeOnly: rule.codeOnly || false,
118
+ docOnly: rule.docOnly || false,
119
+ all: !rule.codeOnly && !rule.docOnly
120
+ });
121
+ } catch (e) {
122
+ console.error(`āš ļø Invalid regex in rule ${rule.id}: ${e.message}`);
123
+ }
124
+ }
125
+ if (!this.summaryOnly && this.customRules.length > 0) {
126
+ console.log(`šŸ“ Loaded ${this.customRules.length} custom rule(s) from ${rulesFile}`);
127
+ }
128
+ } catch (e) {
129
+ console.error(`āš ļø Failed to load custom rules: ${e.message}`);
130
+ }
131
+ }
132
+
133
+ // Load .guava-guard-ignore / .guard-scanner-ignore from scan directory
134
+ loadIgnoreFile(scanDir) {
135
+ const ignorePaths = [
136
+ path.join(scanDir, '.guard-scanner-ignore'),
137
+ path.join(scanDir, '.guava-guard-ignore'),
138
+ ];
139
+ for (const ignorePath of ignorePaths) {
140
+ if (!fs.existsSync(ignorePath)) continue;
141
+ const lines = fs.readFileSync(ignorePath, 'utf-8').split('\n');
142
+ for (const line of lines) {
143
+ const trimmed = line.trim();
144
+ if (!trimmed || trimmed.startsWith('#')) continue;
145
+ if (trimmed.startsWith('pattern:')) {
146
+ this.ignoredPatterns.add(trimmed.replace('pattern:', '').trim());
147
+ } else {
148
+ this.ignoredSkills.add(trimmed);
149
+ }
150
+ }
151
+ if (this.verbose && (this.ignoredSkills.size || this.ignoredPatterns.size)) {
152
+ console.log(`šŸ“‹ Loaded ignore file: ${this.ignoredSkills.size} skills, ${this.ignoredPatterns.size} patterns`);
153
+ }
154
+ break; // use first found
155
+ }
156
+ }
157
+
158
+ scanDirectory(dir) {
159
+ if (!fs.existsSync(dir)) {
160
+ console.error(`āŒ Directory not found: ${dir}`);
161
+ process.exit(2);
162
+ }
163
+
164
+ this.loadIgnoreFile(dir);
165
+
166
+ const skills = fs.readdirSync(dir).filter(f => {
167
+ const p = path.join(dir, f);
168
+ return fs.statSync(p).isDirectory();
169
+ });
170
+
171
+ console.log(`\nšŸ›”ļø guard-scanner v${VERSION}`);
172
+ console.log(`${'═'.repeat(54)}`);
173
+ console.log(`šŸ“‚ Scanning: ${dir}`);
174
+ console.log(`šŸ“¦ Skills found: ${skills.length}`);
175
+ if (this.strict) console.log(`⚔ Strict mode enabled`);
176
+ console.log();
177
+
178
+ for (const skill of skills) {
179
+ const skillPath = path.join(dir, skill);
180
+
181
+ // Self-exclusion
182
+ if (this.selfExclude && path.resolve(skillPath) === this.scannerDir) {
183
+ if (!this.summaryOnly) console.log(`ā­ļø ${skill} — SELF (excluded)`);
184
+ continue;
185
+ }
186
+
187
+ // Ignore list
188
+ if (this.ignoredSkills.has(skill)) {
189
+ if (!this.summaryOnly) console.log(`ā­ļø ${skill} — IGNORED`);
190
+ continue;
191
+ }
192
+
193
+ this.scanSkill(skillPath, skill);
194
+ }
195
+
196
+ this.printSummary();
197
+ return this.findings;
198
+ }
199
+
200
+ scanSkill(skillPath, skillName) {
201
+ this.stats.scanned++;
202
+ const skillFindings = [];
203
+
204
+ // Check 1: Known malicious skill name
205
+ if (KNOWN_MALICIOUS.typosquats.includes(skillName.toLowerCase())) {
206
+ skillFindings.push({
207
+ severity: 'CRITICAL', id: 'KNOWN_TYPOSQUAT', cat: 'malicious-code',
208
+ desc: `Known malicious/typosquat skill name`,
209
+ file: 'SKILL NAME', line: 0
210
+ });
211
+ }
212
+
213
+ // Check 2: Scan all files
214
+ const files = this.getFiles(skillPath);
215
+ for (const file of files) {
216
+ const ext = path.extname(file).toLowerCase();
217
+ const relFile = path.relative(skillPath, file);
218
+
219
+ if (relFile.includes('node_modules/') || relFile.includes('node_modules\\')) continue;
220
+ if (relFile.startsWith('.git/') || relFile.startsWith('.git\\')) continue;
221
+ if (BINARY_EXTENSIONS.has(ext)) continue;
222
+
223
+ let content;
224
+ try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
225
+ if (content.length > 500000) continue;
226
+
227
+ const fileType = this.classifyFile(ext, relFile);
228
+
229
+ // IoC checks
230
+ this.checkIoCs(content, relFile, skillFindings);
231
+
232
+ // Pattern checks (context-aware)
233
+ this.checkPatterns(content, relFile, fileType, skillFindings);
234
+
235
+ // Custom rules / plugins
236
+ if (this.customRules.length > 0) {
237
+ this.checkPatterns(content, relFile, fileType, skillFindings, this.customRules);
238
+ }
239
+
240
+ // Hardcoded secret detection
241
+ const baseName = path.basename(relFile).toLowerCase();
242
+ const skipSecretCheck = baseName.endsWith('-lock.json') || baseName === 'package-lock.json' ||
243
+ baseName === 'yarn.lock' || baseName === 'pnpm-lock.yaml' ||
244
+ baseName === '_meta.json' || baseName === '.package-lock.json';
245
+ if (fileType === 'code' && !skipSecretCheck) {
246
+ this.checkHardcodedSecrets(content, relFile, skillFindings);
247
+ }
248
+
249
+ // Lightweight JS data flow analysis
250
+ if ((ext === '.js' || ext === '.mjs' || ext === '.cjs' || ext === '.ts') && content.length < 200000) {
251
+ this.checkJSDataFlow(content, relFile, skillFindings);
252
+ }
253
+ }
254
+
255
+ // Check 3: Structural checks
256
+ this.checkStructure(skillPath, skillName, skillFindings);
257
+
258
+ // Check 4: Dependency chain scanning
259
+ if (this.checkDeps) {
260
+ this.checkDependencies(skillPath, skillName, skillFindings);
261
+ }
262
+
263
+ // Check 5: Hidden files detection
264
+ this.checkHiddenFiles(skillPath, skillName, skillFindings);
265
+
266
+ // Check 6: Cross-file analysis
267
+ this.checkCrossFile(skillPath, skillName, skillFindings);
268
+
269
+ // Filter ignored patterns
270
+ const filteredFindings = skillFindings.filter(f => !this.ignoredPatterns.has(f.id));
271
+
272
+ // Calculate risk
273
+ const risk = this.calculateRisk(filteredFindings);
274
+ const verdict = this.getVerdict(risk);
275
+
276
+ this.stats[verdict.stat]++;
277
+
278
+ if (!this.summaryOnly) {
279
+ console.log(`${verdict.icon} ${skillName} — ${verdict.label} (risk: ${risk})`);
280
+
281
+ if (this.verbose && filteredFindings.length > 0) {
282
+ const byCat = {};
283
+ for (const f of filteredFindings) {
284
+ (byCat[f.cat] = byCat[f.cat] || []).push(f);
285
+ }
286
+ for (const [cat, findings] of Object.entries(byCat)) {
287
+ console.log(` šŸ“ ${cat}`);
288
+ for (const f of findings) {
289
+ const icon = f.severity === 'CRITICAL' ? 'šŸ’€' : f.severity === 'HIGH' ? 'šŸ”“' : f.severity === 'MEDIUM' ? '🟔' : '⚪';
290
+ const loc = f.line ? `${f.file}:${f.line}` : f.file;
291
+ console.log(` ${icon} [${f.severity}] ${f.desc} — ${loc}`);
292
+ if (f.sample) console.log(` └─ "${f.sample}"`);
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ if (filteredFindings.length > 0) {
299
+ this.findings.push({ skill: skillName, risk, verdict: verdict.label, findings: filteredFindings });
300
+ }
301
+ }
302
+
303
+ classifyFile(ext, relFile) {
304
+ if (CODE_EXTENSIONS.has(ext)) return 'code';
305
+ if (DOC_EXTENSIONS.has(ext)) return 'doc';
306
+ if (DATA_EXTENSIONS.has(ext)) return 'data';
307
+ const base = path.basename(relFile).toLowerCase();
308
+ if (base === 'skill.md' || base === 'readme.md') return 'skill-doc';
309
+ return 'other';
310
+ }
311
+
312
+ checkIoCs(content, relFile, findings) {
313
+ const contentLower = content.toLowerCase();
314
+
315
+ for (const ip of KNOWN_MALICIOUS.ips) {
316
+ if (content.includes(ip)) {
317
+ findings.push({ severity: 'CRITICAL', id: 'IOC_IP', cat: 'malicious-code', desc: `Known malicious IP: ${ip}`, file: relFile });
318
+ }
319
+ }
320
+
321
+ for (const url of KNOWN_MALICIOUS.urls) {
322
+ if (contentLower.includes(url.toLowerCase())) {
323
+ findings.push({ severity: 'CRITICAL', id: 'IOC_URL', cat: 'malicious-code', desc: `Known malicious URL: ${url}`, file: relFile });
324
+ }
325
+ }
326
+
327
+ for (const domain of KNOWN_MALICIOUS.domains) {
328
+ const domainRegex = new RegExp(`(?:https?://|[\\s'"\`(]|^)${domain.replace(/\./g, '\\.')}`, 'gi');
329
+ if (domainRegex.test(content)) {
330
+ findings.push({ severity: 'HIGH', id: 'IOC_DOMAIN', cat: 'exfiltration', desc: `Suspicious domain: ${domain}`, file: relFile });
331
+ }
332
+ }
333
+
334
+ for (const fname of KNOWN_MALICIOUS.filenames) {
335
+ if (contentLower.includes(fname.toLowerCase())) {
336
+ findings.push({ severity: 'CRITICAL', id: 'IOC_FILE', cat: 'suspicious-download', desc: `Known malicious filename: ${fname}`, file: relFile });
337
+ }
338
+ }
339
+
340
+ for (const user of KNOWN_MALICIOUS.usernames) {
341
+ if (contentLower.includes(user.toLowerCase())) {
342
+ findings.push({ severity: 'HIGH', id: 'IOC_USER', cat: 'malicious-code', desc: `Known malicious username: ${user}`, file: relFile });
343
+ }
344
+ }
345
+ }
346
+
347
+ checkPatterns(content, relFile, fileType, findings, patterns = PATTERNS) {
348
+ for (const pattern of patterns) {
349
+ if (pattern.codeOnly && fileType !== 'code') continue;
350
+ if (pattern.docOnly && fileType !== 'doc' && fileType !== 'skill-doc') continue;
351
+ if (!pattern.all && !pattern.codeOnly && !pattern.docOnly) continue;
352
+
353
+ pattern.regex.lastIndex = 0;
354
+ const matches = content.match(pattern.regex);
355
+ if (!matches) continue;
356
+
357
+ pattern.regex.lastIndex = 0;
358
+ const idx = content.search(pattern.regex);
359
+ const lineNum = idx >= 0 ? content.substring(0, idx).split('\n').length : null;
360
+
361
+ let adjustedSeverity = pattern.severity;
362
+ if ((fileType === 'doc' || fileType === 'skill-doc') && pattern.all && !pattern.docOnly) {
363
+ if (adjustedSeverity === 'HIGH') adjustedSeverity = 'MEDIUM';
364
+ else if (adjustedSeverity === 'MEDIUM') adjustedSeverity = 'LOW';
365
+ }
366
+
367
+ findings.push({
368
+ severity: adjustedSeverity,
369
+ id: pattern.id,
370
+ cat: pattern.cat,
371
+ desc: pattern.desc,
372
+ file: relFile,
373
+ line: lineNum,
374
+ matchCount: matches.length,
375
+ sample: matches[0].substring(0, 80)
376
+ });
377
+ }
378
+ }
379
+
380
+ // Entropy-based secret detection
381
+ checkHardcodedSecrets(content, relFile, findings) {
382
+ const assignmentRegex = /(?:api[_-]?key|secret|token|password|credential|auth)\s*[:=]\s*['"]([a-zA-Z0-9_\-+/=]{16,})['"]|['"]([a-zA-Z0-9_\-+/=]{32,})['"]/gi;
383
+ let match;
384
+ while ((match = assignmentRegex.exec(content)) !== null) {
385
+ const value = match[1] || match[2];
386
+ if (!value) continue;
387
+
388
+ if (/^[A-Z_]+$/.test(value)) continue;
389
+ if (/^(true|false|null|undefined|none|default|example|test|placeholder|your[_-])/i.test(value)) continue;
390
+ if (/^x{4,}|\.{4,}|_{4,}|0{8,}$/i.test(value)) continue;
391
+ if (/^projects\/|^gs:\/\/|^https?:\/\//i.test(value)) continue;
392
+ if (/^[a-z]+-[a-z]+-[a-z0-9]+$/i.test(value)) continue;
393
+
394
+ const entropy = this.shannonEntropy(value);
395
+ if (entropy > 3.5 && value.length >= 20) {
396
+ const lineNum = content.substring(0, match.index).split('\n').length;
397
+ findings.push({
398
+ severity: 'HIGH', id: 'SECRET_ENTROPY', cat: 'secret-detection',
399
+ desc: `High-entropy string (possible leaked secret, entropy=${entropy.toFixed(1)})`,
400
+ file: relFile, line: lineNum,
401
+ sample: value.substring(0, 8) + '...' + value.substring(value.length - 4)
402
+ });
403
+ }
404
+ }
405
+ }
406
+
407
+ shannonEntropy(str) {
408
+ const freq = {};
409
+ for (const c of str) freq[c] = (freq[c] || 0) + 1;
410
+ const len = str.length;
411
+ let entropy = 0;
412
+ for (const count of Object.values(freq)) {
413
+ const p = count / len;
414
+ if (p > 0) entropy -= p * Math.log2(p);
415
+ }
416
+ return entropy;
417
+ }
418
+
419
+ checkStructure(skillPath, skillName, findings) {
420
+ const skillMd = path.join(skillPath, 'SKILL.md');
421
+ if (!fs.existsSync(skillMd)) {
422
+ findings.push({ severity: 'LOW', id: 'STRUCT_NO_SKILLMD', cat: 'structural', desc: 'No SKILL.md found', file: skillName });
423
+ return;
424
+ }
425
+ const content = fs.readFileSync(skillMd, 'utf-8');
426
+ if (content.length < 50) {
427
+ findings.push({ severity: 'MEDIUM', id: 'STRUCT_TINY_SKILLMD', cat: 'structural', desc: 'Suspiciously short SKILL.md (< 50 chars)', file: 'SKILL.md' });
428
+ }
429
+ const scriptsDir = path.join(skillPath, 'scripts');
430
+ if (fs.existsSync(scriptsDir)) {
431
+ const scripts = fs.readdirSync(scriptsDir).filter(f => CODE_EXTENSIONS.has(path.extname(f).toLowerCase()));
432
+ if (scripts.length > 0 && !content.includes('scripts/')) {
433
+ findings.push({ severity: 'MEDIUM', id: 'STRUCT_UNDOCUMENTED_SCRIPTS', cat: 'structural', desc: `${scripts.length} script(s) in scripts/ not referenced in SKILL.md`, file: 'scripts/' });
434
+ }
435
+ }
436
+ }
437
+
438
+ checkDependencies(skillPath, skillName, findings) {
439
+ const pkgPath = path.join(skillPath, 'package.json');
440
+ if (!fs.existsSync(pkgPath)) return;
441
+
442
+ let pkg;
443
+ try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); } catch { return; }
444
+
445
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies };
446
+
447
+ const RISKY_PACKAGES = new Set([
448
+ 'node-ipc', 'colors', 'faker', 'event-stream', 'ua-parser-js', 'coa', 'rc',
449
+ ]);
450
+
451
+ for (const [dep, version] of Object.entries(allDeps)) {
452
+ if (RISKY_PACKAGES.has(dep)) {
453
+ findings.push({ severity: 'HIGH', id: 'DEP_RISKY', cat: 'dependency-chain', desc: `Known risky dependency: ${dep}@${version}`, file: 'package.json' });
454
+ }
455
+ if (typeof version === 'string' && (version.startsWith('git+') || version.startsWith('http') || version.startsWith('github:') || version.includes('.tar.gz'))) {
456
+ findings.push({ severity: 'HIGH', id: 'DEP_REMOTE', cat: 'dependency-chain', desc: `Remote/git dependency: ${dep}@${version}`, file: 'package.json' });
457
+ }
458
+ if (version === '*' || version === 'latest') {
459
+ findings.push({ severity: 'MEDIUM', id: 'DEP_WILDCARD', cat: 'dependency-chain', desc: `Wildcard version: ${dep}@${version}`, file: 'package.json' });
460
+ }
461
+ }
462
+
463
+ const RISKY_SCRIPTS = ['preinstall', 'postinstall', 'preuninstall', 'postuninstall', 'prepare'];
464
+ if (pkg.scripts) {
465
+ for (const scriptName of RISKY_SCRIPTS) {
466
+ if (pkg.scripts[scriptName]) {
467
+ const cmd = pkg.scripts[scriptName];
468
+ findings.push({ severity: 'HIGH', id: 'DEP_LIFECYCLE', cat: 'dependency-chain', desc: `Lifecycle script "${scriptName}": ${cmd.substring(0, 80)}`, file: 'package.json' });
469
+ if (/curl|wget|node\s+-e|eval|exec|bash\s+-c/i.test(cmd)) {
470
+ 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) });
471
+ }
472
+ }
473
+ }
474
+ }
475
+ }
476
+
477
+ checkHiddenFiles(skillPath, skillName, findings) {
478
+ try {
479
+ const entries = fs.readdirSync(skillPath);
480
+ for (const entry of entries) {
481
+ if (entry.startsWith('.') && entry !== '.guard-scanner-ignore' && entry !== '.guava-guard-ignore' && entry !== '.gitignore' && entry !== '.git') {
482
+ const fullPath = path.join(skillPath, entry);
483
+ const stat = fs.statSync(fullPath);
484
+ if (stat.isFile()) {
485
+ const ext = path.extname(entry).toLowerCase();
486
+ if (CODE_EXTENSIONS.has(ext) || ext === '' || ext === '.sh') {
487
+ findings.push({ severity: 'MEDIUM', id: 'STRUCT_HIDDEN_EXEC', cat: 'structural', desc: `Hidden executable file: ${entry}`, file: entry });
488
+ }
489
+ } else if (stat.isDirectory() && entry !== '.git') {
490
+ findings.push({ severity: 'LOW', id: 'STRUCT_HIDDEN_DIR', cat: 'structural', desc: `Hidden directory: ${entry}/`, file: entry });
491
+ }
492
+ }
493
+ }
494
+ } catch { }
495
+ }
496
+
497
+ checkJSDataFlow(content, relFile, findings) {
498
+ const lines = content.split('\n');
499
+ const imports = new Map();
500
+ const sensitiveReads = [];
501
+ const networkCalls = [];
502
+ const execCalls = [];
503
+
504
+ for (let i = 0; i < lines.length; i++) {
505
+ const line = lines[i];
506
+ const lineNum = i + 1;
507
+
508
+ const reqMatch = line.match(/(?:const|let|var)\s+(?:{[^}]+}|\w+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
509
+ if (reqMatch) {
510
+ const varMatch = line.match(/(?:const|let|var)\s+({[^}]+}|\w+)/);
511
+ if (varMatch) imports.set(varMatch[1].trim(), reqMatch[1]);
512
+ }
513
+
514
+ if (/(?:readFileSync|readFile)\s*\([^)]*(?:\.env|\.ssh|id_rsa|\.clawdbot|\.openclaw(?!\/workspace))/i.test(line)) {
515
+ sensitiveReads.push({ line: lineNum, text: line.trim() });
516
+ }
517
+ if (/process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i.test(line)) {
518
+ sensitiveReads.push({ line: lineNum, text: line.trim() });
519
+ }
520
+
521
+ if (/(?:fetch|axios|request|http\.request|https\.request|got)\s*\(/i.test(line) ||
522
+ /\.post\s*\(|\.put\s*\(|\.patch\s*\(/i.test(line)) {
523
+ networkCalls.push({ line: lineNum, text: line.trim() });
524
+ }
525
+
526
+ if (/(?:exec|execSync|spawn|spawnSync|execFile)\s*\(/i.test(line)) {
527
+ execCalls.push({ line: lineNum, text: line.trim() });
528
+ }
529
+ }
530
+
531
+ if (sensitiveReads.length > 0 && networkCalls.length > 0) {
532
+ findings.push({
533
+ severity: 'CRITICAL', id: 'AST_CRED_TO_NET', cat: 'data-flow',
534
+ desc: `Data flow: secret read (L${sensitiveReads[0].line}) → network call (L${networkCalls[0].line})`,
535
+ file: relFile, line: sensitiveReads[0].line,
536
+ sample: sensitiveReads[0].text.substring(0, 60)
537
+ });
538
+ }
539
+
540
+ if (sensitiveReads.length > 0 && execCalls.length > 0) {
541
+ findings.push({
542
+ severity: 'HIGH', id: 'AST_CRED_TO_EXEC', cat: 'data-flow',
543
+ desc: `Data flow: secret read (L${sensitiveReads[0].line}) → command exec (L${execCalls[0].line})`,
544
+ file: relFile, line: sensitiveReads[0].line,
545
+ sample: sensitiveReads[0].text.substring(0, 60)
546
+ });
547
+ }
548
+
549
+ const importedModules = new Set([...imports.values()]);
550
+ if (importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http') || importedModules.has('node-fetch'))) {
551
+ findings.push({ severity: 'HIGH', id: 'AST_SUSPICIOUS_IMPORTS', cat: 'data-flow', desc: 'Suspicious import combination: child_process + network module', file: relFile });
552
+ }
553
+ if (importedModules.has('fs') && importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http'))) {
554
+ findings.push({ severity: 'CRITICAL', id: 'AST_EXFIL_TRIFECTA', cat: 'data-flow', desc: 'Exfiltration trifecta: fs + child_process + network', file: relFile });
555
+ }
556
+
557
+ for (let i = 0; i < lines.length; i++) {
558
+ const line = lines[i];
559
+ if (/`[^`]*\$\{.*(?:env|key|token|secret|password).*\}[^`]*`\s*(?:\)|,)/i.test(line) &&
560
+ /(?:fetch|request|axios|http|url)/i.test(line)) {
561
+ 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) });
562
+ }
563
+ }
564
+ }
565
+
566
+ checkCrossFile(skillPath, skillName, findings) {
567
+ const files = this.getFiles(skillPath);
568
+ const allContent = {};
569
+
570
+ for (const file of files) {
571
+ const ext = path.extname(file).toLowerCase();
572
+ if (BINARY_EXTENSIONS.has(ext)) continue;
573
+ const relFile = path.relative(skillPath, file);
574
+ if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
575
+ try {
576
+ const content = fs.readFileSync(file, 'utf-8');
577
+ if (content.length < 500000) allContent[relFile] = content;
578
+ } catch { }
579
+ }
580
+
581
+ const skillMd = allContent['SKILL.md'] || '';
582
+ const codeFileRefs = skillMd.match(/(?:scripts?\/|\.\/)[a-zA-Z0-9_\-./]+\.(js|py|sh|ts)/gi) || [];
583
+ for (const ref of codeFileRefs) {
584
+ const cleanRef = ref.replace(/^\.\//, '');
585
+ if (!allContent[cleanRef] && !files.some(f => path.relative(skillPath, f) === cleanRef)) {
586
+ findings.push({ severity: 'MEDIUM', id: 'XFILE_PHANTOM_REF', cat: 'structural', desc: `SKILL.md references non-existent file: ${cleanRef}`, file: 'SKILL.md' });
587
+ }
588
+ }
589
+
590
+ const base64Fragments = [];
591
+ for (const [file, content] of Object.entries(allContent)) {
592
+ const matches = content.match(/[A-Za-z0-9+/]{20,}={0,2}/g) || [];
593
+ for (const m of matches) {
594
+ if (m.length > 40) base64Fragments.push({ file, fragment: m.substring(0, 30) });
595
+ }
596
+ }
597
+ if (base64Fragments.length > 3 && new Set(base64Fragments.map(f => f.file)).size > 1) {
598
+ 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 });
599
+ }
600
+
601
+ if (/(?:read|load|source|import)\s+(?:the\s+)?(?:script|file|code)\s+(?:from|at|in)\s+(?:scripts?\/)/gi.test(skillMd)) {
602
+ const hasExec = Object.values(allContent).some(c => /(?:eval|exec|spawn)\s*\(/i.test(c));
603
+ if (hasExec) {
604
+ findings.push({ severity: 'MEDIUM', id: 'XFILE_LOAD_EXEC', cat: 'data-flow', desc: 'SKILL.md references script files that contain exec/eval', file: 'SKILL.md' });
605
+ }
606
+ }
607
+ }
608
+
609
+ calculateRisk(findings) {
610
+ if (findings.length === 0) return 0;
611
+
612
+ let score = 0;
613
+ for (const f of findings) {
614
+ score += SEVERITY_WEIGHTS[f.severity] || 0;
615
+ }
616
+
617
+ const ids = new Set(findings.map(f => f.id));
618
+ const cats = new Set(findings.map(f => f.cat));
619
+
620
+ if (cats.has('credential-handling') && cats.has('exfiltration')) score = Math.round(score * 2);
621
+ if (cats.has('credential-handling') && findings.some(f => f.id === 'MAL_CHILD' || f.id === 'MAL_EXEC')) score = Math.round(score * 1.5);
622
+ if (cats.has('obfuscation') && (cats.has('malicious-code') || cats.has('credential-handling'))) score = Math.round(score * 2);
623
+ if (ids.has('DEP_LIFECYCLE_EXEC')) score = Math.round(score * 2);
624
+ if (ids.has('PI_BIDI') && findings.length > 1) score = Math.round(score * 1.5);
625
+ if (cats.has('leaky-skills') && (cats.has('exfiltration') || cats.has('malicious-code'))) score = Math.round(score * 2);
626
+ if (cats.has('memory-poisoning')) score = Math.round(score * 1.5);
627
+ if (cats.has('prompt-worm')) score = Math.round(score * 2);
628
+ if (cats.has('cve-patterns')) score = Math.max(score, 70);
629
+ if (cats.has('persistence') && (cats.has('malicious-code') || cats.has('credential-handling') || cats.has('memory-poisoning'))) score = Math.round(score * 1.5);
630
+ if (cats.has('identity-hijack')) score = Math.round(score * 2);
631
+ if (cats.has('identity-hijack') && (cats.has('persistence') || cats.has('memory-poisoning'))) score = Math.max(score, 90);
632
+ if (ids.has('IOC_IP') || ids.has('IOC_URL') || ids.has('KNOWN_TYPOSQUAT')) score = 100;
633
+
634
+ return Math.min(100, score);
635
+ }
636
+
637
+ getVerdict(risk) {
638
+ if (risk >= this.thresholds.malicious) return { icon: 'šŸ”“', label: 'MALICIOUS', stat: 'malicious' };
639
+ if (risk >= this.thresholds.suspicious) return { icon: '🟔', label: 'SUSPICIOUS', stat: 'suspicious' };
640
+ if (risk > 0) return { icon: '🟢', label: 'LOW RISK', stat: 'low' };
641
+ return { icon: '🟢', label: 'CLEAN', stat: 'clean' };
642
+ }
643
+
644
+ getFiles(dir) {
645
+ const results = [];
646
+ try {
647
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
648
+ for (const entry of entries) {
649
+ const fullPath = path.join(dir, entry.name);
650
+ if (entry.isDirectory()) {
651
+ if (entry.name === '.git' || entry.name === 'node_modules') continue;
652
+ results.push(...this.getFiles(fullPath));
653
+ } else {
654
+ results.push(fullPath);
655
+ }
656
+ }
657
+ } catch { }
658
+ return results;
659
+ }
660
+
661
+ printSummary() {
662
+ const total = this.stats.scanned;
663
+ const safe = this.stats.clean + this.stats.low;
664
+ console.log(`\n${'═'.repeat(54)}`);
665
+ console.log(`šŸ“Š guard-scanner v${VERSION} Scan Summary`);
666
+ console.log(`${'─'.repeat(54)}`);
667
+ console.log(` Scanned: ${total}`);
668
+ console.log(` 🟢 Clean: ${this.stats.clean}`);
669
+ console.log(` 🟢 Low Risk: ${this.stats.low}`);
670
+ console.log(` 🟔 Suspicious: ${this.stats.suspicious}`);
671
+ console.log(` šŸ”“ Malicious: ${this.stats.malicious}`);
672
+ console.log(` Safety Rate: ${total ? Math.round(safe / total * 100) : 0}%`);
673
+ console.log(`${'═'.repeat(54)}`);
674
+
675
+ if (this.stats.malicious > 0) {
676
+ console.log(`\nāš ļø CRITICAL: ${this.stats.malicious} malicious skill(s) detected!`);
677
+ console.log(` Review findings with --verbose and remove if confirmed.`);
678
+ } else if (this.stats.suspicious > 0) {
679
+ console.log(`\n⚔ ${this.stats.suspicious} suspicious skill(s) found — review recommended.`);
680
+ } else {
681
+ console.log(`\nāœ… All clear! No threats detected.`);
682
+ }
683
+ }
684
+
685
+ toJSON() {
686
+ const recommendations = [];
687
+ for (const skillResult of this.findings) {
688
+ const skillRecs = [];
689
+ const cats = new Set(skillResult.findings.map(f => f.cat));
690
+
691
+ if (cats.has('prompt-injection')) skillRecs.push('šŸ›‘ Contains prompt injection patterns.');
692
+ if (cats.has('malicious-code')) skillRecs.push('šŸ›‘ Contains potentially malicious code.');
693
+ if (cats.has('credential-handling') && cats.has('exfiltration')) skillRecs.push('šŸ’€ CRITICAL: Credential access + exfiltration. DO NOT INSTALL.');
694
+ if (cats.has('dependency-chain')) skillRecs.push('šŸ“¦ Suspicious dependency chain.');
695
+ if (cats.has('obfuscation')) skillRecs.push('šŸ” Code obfuscation detected.');
696
+ if (cats.has('secret-detection')) skillRecs.push('šŸ”‘ Possible hardcoded secrets.');
697
+ if (cats.has('leaky-skills')) skillRecs.push('šŸ’§ LEAKY SKILL: Secrets pass through LLM context.');
698
+ if (cats.has('memory-poisoning')) skillRecs.push('🧠 MEMORY POISONING: Agent memory modification attempt.');
699
+ if (cats.has('prompt-worm')) skillRecs.push('🪱 PROMPT WORM: Self-replicating instructions.');
700
+ if (cats.has('data-flow')) skillRecs.push('šŸ”€ Suspicious data flow patterns.');
701
+ if (cats.has('persistence')) skillRecs.push('ā° PERSISTENCE: Creates scheduled tasks.');
702
+ if (cats.has('cve-patterns')) skillRecs.push('🚨 CVE PATTERN: Matches known exploits.');
703
+ if (cats.has('identity-hijack')) skillRecs.push('šŸ”’ IDENTITY HIJACK: Agent soul file tampering. DO NOT INSTALL.');
704
+
705
+ if (skillRecs.length > 0) recommendations.push({ skill: skillResult.skill, actions: skillRecs });
706
+ }
707
+
708
+ return {
709
+ timestamp: new Date().toISOString(),
710
+ scanner: `guard-scanner v${VERSION}`,
711
+ mode: this.strict ? 'strict' : 'normal',
712
+ stats: this.stats,
713
+ thresholds: this.thresholds,
714
+ findings: this.findings,
715
+ recommendations,
716
+ iocVersion: '2026-02-12',
717
+ };
718
+ }
719
+
720
+ toSARIF(scanDir) {
721
+ const rules = [];
722
+ const ruleIndex = {};
723
+ const results = [];
724
+
725
+ for (const skillResult of this.findings) {
726
+ for (const f of skillResult.findings) {
727
+ if (!ruleIndex[f.id]) {
728
+ ruleIndex[f.id] = rules.length;
729
+ rules.push({
730
+ id: f.id, name: f.id,
731
+ shortDescription: { text: f.desc },
732
+ defaultConfiguration: { level: f.severity === 'CRITICAL' ? 'error' : f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note' },
733
+ properties: { tags: ['security', f.cat], 'security-severity': f.severity === 'CRITICAL' ? '9.0' : f.severity === 'HIGH' ? '7.0' : f.severity === 'MEDIUM' ? '4.0' : '1.0' }
734
+ });
735
+ }
736
+ results.push({
737
+ ruleId: f.id, ruleIndex: ruleIndex[f.id],
738
+ level: f.severity === 'CRITICAL' ? 'error' : f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note',
739
+ message: { text: `[${skillResult.skill}] ${f.desc}${f.sample ? ` — "${f.sample}"` : ''}` },
740
+ locations: [{ physicalLocation: { artifactLocation: { uri: `${skillResult.skill}/${f.file}`, uriBaseId: '%SRCROOT%' }, region: f.line ? { startLine: f.line } : undefined } }]
741
+ });
742
+ }
743
+ }
744
+
745
+ return {
746
+ version: '2.1.0',
747
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
748
+ runs: [{
749
+ tool: { driver: { name: 'guard-scanner', version: VERSION, informationUri: 'https://github.com/koatora20/guard-scanner', rules } },
750
+ results,
751
+ invocations: [{ executionSuccessful: true, endTimeUtc: new Date().toISOString() }]
752
+ }]
753
+ };
754
+ }
755
+
756
+ toHTML() {
757
+ const stats = this.stats;
758
+ const sevColors = { CRITICAL: '#dc2626', HIGH: '#ea580c', MEDIUM: '#ca8a04', LOW: '#65a30d' };
759
+
760
+ let skillRows = '';
761
+ for (const sr of this.findings) {
762
+ const findingRows = sr.findings.map(f => {
763
+ const color = sevColors[f.severity] || '#666';
764
+ return `<tr><td style="color:${color};font-weight:bold">${f.severity}</td><td>${f.cat}</td><td>${f.desc}</td><td>${f.file}${f.line ? ':' + f.line : ''}</td></tr>`;
765
+ }).join('\n');
766
+
767
+ const verdictColor = sr.verdict === 'MALICIOUS' ? '#dc2626' : sr.verdict === 'SUSPICIOUS' ? '#ca8a04' : '#65a30d';
768
+ skillRows += `
769
+ <div class="skill-card">
770
+ <h3>${sr.skill} <span style="color:${verdictColor}">[${sr.verdict}]</span> <small>Risk: ${sr.risk}</small></h3>
771
+ <table><thead><tr><th>Severity</th><th>Category</th><th>Description</th><th>Location</th></tr></thead>
772
+ <tbody>${findingRows}</tbody></table>
773
+ </div>`;
774
+ }
775
+
776
+ return `<!DOCTYPE html>
777
+ <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
778
+ <title>guard-scanner v${VERSION} Report</title>
779
+ <style>
780
+ body{font-family:system-ui,sans-serif;max-width:1000px;margin:0 auto;padding:20px;background:#0f172a;color:#e2e8f0}
781
+ h1{color:#4ade80}h2{color:#86efac;border-bottom:1px solid #334155;padding-bottom:8px}h3{margin:0}
782
+ .stats{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:20px 0}
783
+ .stat{background:#1e293b;border-radius:8px;padding:16px;text-align:center}
784
+ .stat .num{font-size:2em;font-weight:bold}.stat .label{color:#94a3b8;font-size:0.85em}
785
+ .stat.clean .num{color:#4ade80}.stat.low .num{color:#86efac}.stat.suspicious .num{color:#fbbf24}.stat.malicious .num{color:#ef4444}
786
+ .skill-card{background:#1e293b;border-radius:8px;padding:16px;margin:12px 0;border-left:4px solid #334155}
787
+ table{width:100%;border-collapse:collapse;margin-top:8px;font-size:0.9em}
788
+ th,td{padding:6px 10px;text-align:left;border-bottom:1px solid #334155}
789
+ th{color:#94a3b8;font-weight:600}small{color:#64748b;margin-left:8px}
790
+ .footer{color:#475569;text-align:center;margin-top:40px;font-size:0.8em}
791
+ </style></head><body>
792
+ <h1>šŸ›”ļø guard-scanner v${VERSION}</h1>
793
+ <p>Scan completed: ${new Date().toISOString()}</p>
794
+ <div class="stats">
795
+ <div class="stat"><div class="num">${stats.scanned}</div><div class="label">Scanned</div></div>
796
+ <div class="stat clean"><div class="num">${stats.clean}</div><div class="label">Clean</div></div>
797
+ <div class="stat low"><div class="num">${stats.low}</div><div class="label">Low Risk</div></div>
798
+ <div class="stat suspicious"><div class="num">${stats.suspicious}</div><div class="label">Suspicious</div></div>
799
+ <div class="stat malicious"><div class="num">${stats.malicious}</div><div class="label">Malicious</div></div>
800
+ </div>
801
+ <h2>Findings</h2>
802
+ ${skillRows || '<p style="color:#4ade80">āœ… No threats detected.</p>'}
803
+ <div class="footer">guard-scanner v${VERSION} — Zero dependencies. Zero compromises. šŸ›”ļø</div>
804
+ </body></html>`;
805
+ }
806
+ }
807
+
808
+ module.exports = { GuardScanner, VERSION, THRESHOLDS, SEVERITY_WEIGHTS };