guard-scanner 5.0.4 → 5.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +15 -15
  2. package/SKILL.md +33 -39
  3. package/hooks/guard-scanner/HOOK.md +4 -4
  4. package/openclaw.plugin.json +3 -3
  5. package/package.json +7 -15
  6. package/src/patterns.js +11 -0
  7. package/src/scanner.js +1 -1
  8. package/dist/__tests__/runtime.test.d.ts +0 -2
  9. package/dist/__tests__/runtime.test.d.ts.map +0 -1
  10. package/dist/__tests__/runtime.test.js +0 -68
  11. package/dist/__tests__/runtime.test.js.map +0 -1
  12. package/dist/__tests__/scanner.test.d.ts +0 -10
  13. package/dist/__tests__/scanner.test.d.ts.map +0 -1
  14. package/dist/__tests__/scanner.test.js +0 -443
  15. package/dist/__tests__/scanner.test.js.map +0 -1
  16. package/dist/cli.d.ts +0 -10
  17. package/dist/cli.d.ts.map +0 -1
  18. package/dist/cli.js +0 -230
  19. package/dist/cli.js.map +0 -1
  20. package/dist/index.d.ts +0 -11
  21. package/dist/index.d.ts.map +0 -1
  22. package/dist/index.js +0 -25
  23. package/dist/index.js.map +0 -1
  24. package/dist/ioc-db.d.ts +0 -13
  25. package/dist/ioc-db.d.ts.map +0 -1
  26. package/dist/ioc-db.js +0 -130
  27. package/dist/ioc-db.js.map +0 -1
  28. package/dist/patterns.d.ts +0 -27
  29. package/dist/patterns.d.ts.map +0 -1
  30. package/dist/patterns.js +0 -92
  31. package/dist/patterns.js.map +0 -1
  32. package/dist/quarantine.d.ts +0 -18
  33. package/dist/quarantine.d.ts.map +0 -1
  34. package/dist/quarantine.js +0 -42
  35. package/dist/quarantine.js.map +0 -1
  36. package/dist/runtime.d.ts +0 -58
  37. package/dist/runtime.d.ts.map +0 -1
  38. package/dist/runtime.js +0 -198
  39. package/dist/runtime.js.map +0 -1
  40. package/dist/scanner.d.ts +0 -59
  41. package/dist/scanner.d.ts.map +0 -1
  42. package/dist/scanner.js +0 -1134
  43. package/dist/scanner.js.map +0 -1
  44. package/dist/types.d.ts +0 -167
  45. package/dist/types.d.ts.map +0 -1
  46. package/dist/types.js +0 -7
  47. package/dist/types.js.map +0 -1
  48. package/docs/OPENCLAW_DOCS_PR_READY_PATCH.md +0 -88
  49. package/docs/OPENCLAW_HOOK_SCHEMA_REFERENCE_DRAFT.md +0 -78
  50. package/docs/TASKLIST_RESEARCH_FIRST_V1.md +0 -47
  51. package/docs/html-report-preview.png +0 -0
  52. package/ts-src/__tests__/fixtures/clean-skill/SKILL.md +0 -9
  53. package/ts-src/__tests__/fixtures/compaction-skill/SKILL.md +0 -11
  54. package/ts-src/__tests__/fixtures/malicious-skill/SKILL.md +0 -11
  55. package/ts-src/__tests__/fixtures/malicious-skill/scripts/evil.js +0 -25
  56. package/ts-src/__tests__/fixtures/prompt-leakage-skill/SKILL.md +0 -20
  57. package/ts-src/__tests__/fixtures/prompt-leakage-skill/scripts/debug.js +0 -4
  58. package/ts-src/__tests__/scanner.test.ts +0 -609
  59. package/ts-src/cli.ts +0 -211
  60. package/ts-src/index.ts +0 -27
  61. package/ts-src/ioc-db.ts +0 -131
  62. package/ts-src/patterns.ts +0 -104
  63. package/ts-src/quarantine.ts +0 -48
  64. package/ts-src/runtime.ts +0 -240
  65. package/ts-src/scanner.ts +0 -1118
  66. package/ts-src/types.ts +0 -189
package/ts-src/scanner.ts DELETED
@@ -1,1118 +0,0 @@
1
- /**
2
- * guard-scanner v3.0.0 — Core Scanner (TypeScript)
3
- *
4
- * Full TypeScript rewrite of guard-scanner v2.1.0 + hbg-scan features.
5
- * Adds: Compaction Persistence check, Signature hash matching, typed interfaces.
6
- *
7
- * Zero dependencies. MIT License.
8
- */
9
-
10
- import * as fs from 'fs';
11
- import * as path from 'path';
12
- import * as crypto from 'crypto';
13
-
14
- import type {
15
- Severity, Finding, SkillResult, PatternRule, CustomRuleInput,
16
- ScannerOptions, ScanStats, Thresholds, Verdict, VerdictLabel, FileType,
17
- JSONReport, Recommendation, SARIFReport, SARIFRule, SARIFResult,
18
- ThreatSignature,
19
- } from './types.js';
20
-
21
- import { KNOWN_MALICIOUS, SIGNATURES_DB } from './ioc-db.js';
22
- import { PATTERNS } from './patterns.js';
23
-
24
- // ── Constants ───────────────────────────────────────────────────────────────
25
-
26
- export const VERSION = '5.0.3';
27
-
28
- const THRESHOLDS_MAP: Record<string, Thresholds> = {
29
- normal: { suspicious: 30, malicious: 80 },
30
- strict: { suspicious: 20, malicious: 60 },
31
- };
32
-
33
- const SEVERITY_WEIGHTS: Record<Severity, number> = {
34
- CRITICAL: 40, HIGH: 15, MEDIUM: 5, LOW: 2,
35
- };
36
-
37
- const CODE_EXTENSIONS = new Set([
38
- '.js', '.ts', '.mjs', '.cjs', '.py', '.sh', '.bash', '.ps1',
39
- '.rb', '.go', '.rs', '.php', '.pl',
40
- ]);
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([
44
- '.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf',
45
- '.eot', '.wasm', '.wav', '.mp3', '.mp4', '.webm', '.ogg', '.pdf',
46
- '.zip', '.tar', '.gz', '.bz2', '.7z', '.exe', '.dll', '.so', '.dylib',
47
- ]);
48
- const GENERATED_REPORT_FILES = new Set([
49
- 'guard-scanner-report.json', 'guard-scanner-report.html', 'guard-scanner.sarif',
50
- ]);
51
-
52
- // ── GuardScanner ────────────────────────────────────────────────────────────
53
-
54
- export class GuardScanner {
55
- readonly verbose: boolean;
56
- readonly selfExclude: boolean;
57
- readonly strict: boolean;
58
- readonly summaryOnly: boolean;
59
- /** Suppress all console.log output (v3.2.0: for --format stdout piping) */
60
- readonly quiet: boolean;
61
- readonly checkDeps: boolean;
62
- readonly thresholds: Thresholds;
63
-
64
- findings: SkillResult[] = [];
65
- stats: ScanStats = { scanned: 0, clean: 0, low: 0, suspicious: 0, malicious: 0 };
66
-
67
- private scannerDir: string;
68
- private ignoredSkills = new Set<string>();
69
- private ignoredPatterns = new Set<string>();
70
- private customRules: PatternRule[] = [];
71
-
72
- constructor(options: ScannerOptions = {}) {
73
- this.verbose = options.verbose ?? false;
74
- this.selfExclude = options.selfExclude ?? false;
75
- this.strict = options.strict ?? false;
76
- this.summaryOnly = options.summaryOnly ?? false;
77
- this.quiet = options.quiet ?? false;
78
- this.checkDeps = options.checkDeps ?? false;
79
- this.scannerDir = path.resolve(__dirname);
80
- this.thresholds = this.strict ? THRESHOLDS_MAP.strict : THRESHOLDS_MAP.normal;
81
-
82
- if (options.plugins) {
83
- for (const plugin of options.plugins) {
84
- this.loadPlugin(plugin);
85
- }
86
- }
87
- if (options.rulesFile) {
88
- this.loadCustomRules(options.rulesFile);
89
- }
90
- }
91
-
92
- // ── Plugin System ───────────────────────────────────────────────────────
93
-
94
- loadPlugin(pluginPath: string): void {
95
- try {
96
- // eslint-disable-next-line @typescript-eslint/no-var-requires
97
- const plugin = require(path.resolve(pluginPath));
98
- if (plugin.patterns && Array.isArray(plugin.patterns)) {
99
- for (const p of plugin.patterns) {
100
- if (p.id && p.regex && p.severity && p.cat && p.desc) {
101
- this.customRules.push(p as PatternRule);
102
- }
103
- }
104
- if (!this.summaryOnly) {
105
- console.log(`🔌 Plugin loaded: ${plugin.name || pluginPath} (${plugin.patterns.length} rule(s))`);
106
- }
107
- }
108
- } catch (e) {
109
- console.error(`⚠️ Failed to load plugin ${pluginPath}: ${(e as Error).message}`);
110
- }
111
- }
112
-
113
- loadCustomRules(rulesFile: string): void {
114
- try {
115
- const content = fs.readFileSync(rulesFile, 'utf-8');
116
- const rules: CustomRuleInput[] = JSON.parse(content);
117
- if (!Array.isArray(rules)) {
118
- console.error('⚠️ Custom rules file must be a JSON array');
119
- return;
120
- }
121
- for (const rule of rules) {
122
- if (!rule.id || !rule.pattern || !rule.severity || !rule.cat || !rule.desc) {
123
- console.error(`⚠️ Skipping invalid rule: ${JSON.stringify(rule).substring(0, 80)}`);
124
- continue;
125
- }
126
- try {
127
- const flags = rule.flags || 'gi';
128
- this.customRules.push({
129
- id: rule.id,
130
- cat: rule.cat,
131
- regex: new RegExp(rule.pattern, flags),
132
- severity: rule.severity,
133
- desc: rule.desc,
134
- codeOnly: rule.codeOnly ?? false,
135
- docOnly: rule.docOnly ?? false,
136
- all: !rule.codeOnly && !rule.docOnly,
137
- });
138
- } catch (e) {
139
- console.error(`⚠️ Invalid regex in rule ${rule.id}: ${(e as Error).message}`);
140
- }
141
- }
142
- if (!this.summaryOnly && this.customRules.length > 0) {
143
- console.log(`📏 Loaded ${this.customRules.length} custom rule(s) from ${rulesFile}`);
144
- }
145
- } catch (e) {
146
- console.error(`⚠️ Failed to load custom rules: ${(e as Error).message}`);
147
- }
148
- }
149
-
150
- // ── Ignore System ───────────────────────────────────────────────────────
151
-
152
- private loadIgnoreFile(scanDir: string): void {
153
- const ignorePaths = [
154
- path.join(scanDir, '.guard-scanner-ignore'),
155
- path.join(scanDir, '.guava-guard-ignore'),
156
- ];
157
- for (const ignorePath of ignorePaths) {
158
- if (!fs.existsSync(ignorePath)) continue;
159
- const lines = fs.readFileSync(ignorePath, 'utf-8').split('\n');
160
- for (const line of lines) {
161
- const trimmed = line.trim();
162
- if (!trimmed || trimmed.startsWith('#')) continue;
163
- if (trimmed.startsWith('pattern:')) {
164
- this.ignoredPatterns.add(trimmed.replace('pattern:', '').trim());
165
- } else {
166
- this.ignoredSkills.add(trimmed);
167
- }
168
- }
169
- if (this.verbose && (this.ignoredSkills.size || this.ignoredPatterns.size)) {
170
- console.log(`📋 Loaded ignore file: ${this.ignoredSkills.size} skills, ${this.ignoredPatterns.size} patterns`);
171
- }
172
- break;
173
- }
174
- }
175
-
176
- // ── Main Scan ─────────────────────────────────────────────────────────
177
-
178
- scanDirectory(dir: string): SkillResult[] {
179
- if (!fs.existsSync(dir)) {
180
- console.error(`❌ Directory not found: ${dir}`);
181
- process.exit(2);
182
- }
183
-
184
- this.loadIgnoreFile(dir);
185
-
186
- const skills = fs.readdirSync(dir).filter((f: string) => {
187
- const p = path.join(dir, f);
188
- // Ignore ONLY system dependencies and build outputs. DO NOT ignore 'test' globally.
189
- const low = f.toLowerCase();
190
- if (low === 'node_modules' || low === '.git' || low === 'dist' || low === 'build' || low === 'coverage') return false;
191
- return fs.statSync(p).isDirectory();
192
- });
193
-
194
- if (!this.quiet) {
195
- console.log(`\n🛡️ guard-scanner v${VERSION}`);
196
- console.log(`${'═'.repeat(54)}`);
197
- console.log(`📂 Scanning: ${dir}`);
198
- console.log(`📦 Skills found: ${skills.length}`);
199
- if (this.strict) console.log('⚡ Strict mode enabled');
200
- console.log();
201
- }
202
-
203
- for (const skill of skills) {
204
- const skillPath = path.join(dir, skill);
205
-
206
- if (this.selfExclude && path.resolve(skillPath) === this.scannerDir) {
207
- if (!this.summaryOnly && !this.quiet) console.log(`⏭️ ${skill} — SELF (excluded)`);
208
- continue;
209
- }
210
- if (this.ignoredSkills.has(skill)) {
211
- if (!this.summaryOnly && !this.quiet) console.log(`⏭️ ${skill} — IGNORED`);
212
- continue;
213
- }
214
-
215
- this.scanSkill(skillPath, skill);
216
- }
217
-
218
- if (!this.quiet) this.printSummary();
219
- return this.findings;
220
- }
221
-
222
- // ── Skill Scanner ─────────────────────────────────────────────────────
223
-
224
- scanSkill(skillPath: string, skillName: string): void {
225
- this.stats.scanned++;
226
- const skillFindings: Finding[] = [];
227
-
228
- // Check: Known typosquat
229
- if (KNOWN_MALICIOUS.typosquats.includes(skillName.toLowerCase())) {
230
- skillFindings.push({
231
- severity: 'CRITICAL', id: 'KNOWN_TYPOSQUAT', cat: 'malicious-code',
232
- desc: 'Known malicious/typosquat skill name', file: 'SKILL NAME',
233
- });
234
- }
235
-
236
- // Scan all files
237
- const files = this.getFiles(skillPath);
238
- for (const file of files) {
239
- const ext = path.extname(file).toLowerCase();
240
- const relFile = path.relative(skillPath, file);
241
-
242
- if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
243
- if (BINARY_EXTENSIONS.has(ext)) continue;
244
- if (this.isSelfNoisePath(skillName, relFile)) continue;
245
-
246
- let content: string;
247
- try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
248
- if (content.length > 500_000) continue;
249
-
250
- const fileType = this.classifyFile(ext, relFile);
251
- if (this.isSelfThreatCorpus(skillName, relFile)) continue;
252
-
253
- this.checkIoCs(content, relFile, skillFindings);
254
- this.checkSignatures(content, file, skillFindings); // NEW: hbg-scan compatible
255
- this.checkPatterns(content, relFile, fileType, skillFindings);
256
-
257
- if (this.customRules.length > 0) {
258
- this.checkPatterns(content, relFile, fileType, skillFindings, this.customRules);
259
- }
260
-
261
- // Secret detection (skip lock files)
262
- const baseName = path.basename(relFile).toLowerCase();
263
- const skipSecret = baseName.endsWith('-lock.json') || baseName === 'package-lock.json' ||
264
- baseName === 'yarn.lock' || baseName === 'pnpm-lock.yaml' ||
265
- baseName === '_meta.json' || baseName === '.package-lock.json';
266
- if (fileType === 'code' && !skipSecret) {
267
- this.checkHardcodedSecrets(content, relFile, skillFindings);
268
- }
269
-
270
- // JS data flow
271
- if (['.js', '.mjs', '.cjs', '.ts'].includes(ext) && content.length < 200_000) {
272
- this.checkJSDataFlow(content, relFile, skillFindings);
273
- }
274
- }
275
-
276
- // Structural checks
277
- this.checkStructure(skillPath, skillName, skillFindings);
278
- if (this.checkDeps) this.checkDependencies(skillPath, skillName, skillFindings);
279
- this.checkHiddenFiles(skillPath, skillName, skillFindings);
280
- this.checkCrossFile(skillPath, skillName, skillFindings);
281
- this.checkSkillManifest(skillPath, skillName, skillFindings);
282
- this.checkComplexity(skillPath, skillName, skillFindings);
283
- this.checkConfigImpact(skillPath, skillName, skillFindings);
284
- this.checkCompactionPersistence(skillPath, skillName, skillFindings); // NEW
285
-
286
- // Filter & score
287
- const filtered = skillFindings.filter(f => !this.ignoredPatterns.has(f.id));
288
- const risk = this.calculateRisk(filtered);
289
- const verdict = this.getVerdict(risk);
290
-
291
- this.stats[verdict.stat]++;
292
-
293
- if (!this.summaryOnly && !this.quiet) {
294
- console.log(`${verdict.icon} ${skillName} — ${verdict.label} (risk: ${risk})`);
295
- if (this.verbose && filtered.length > 0) {
296
- const byCat: Record<string, Finding[]> = {};
297
- for (const f of filtered) (byCat[f.cat] = byCat[f.cat] || []).push(f);
298
- for (const [cat, findings] of Object.entries(byCat)) {
299
- console.log(` 📁 ${cat}`);
300
- for (const f of findings) {
301
- const icon = f.severity === 'CRITICAL' ? '💀' : f.severity === 'HIGH' ? '🔴' : f.severity === 'MEDIUM' ? '🟡' : '⚪';
302
- const loc = f.line ? `${f.file}:${f.line}` : f.file;
303
- console.log(` ${icon} [${f.severity}] ${f.desc} — ${loc}`);
304
- if (f.sample) console.log(` └─ "${f.sample}"`);
305
- }
306
- }
307
- }
308
- }
309
-
310
- if (filtered.length > 0) {
311
- this.findings.push({ skill: skillName, risk, verdict: verdict.label, findings: filtered });
312
- }
313
- }
314
-
315
- private isSelfNoisePath(skillName: string, relFile: string): boolean {
316
- // Only apply this noise reduction if we are scanning the guard-scanner repository itself
317
- const isSelf = skillName === 'guard-scanner' || skillName === '.' || skillName === 'ts-src' || skillName === 'src' || skillName === 'test';
318
- if (!isSelf) return false;
319
-
320
- const p = relFile.replace(/\\/g, '/').toLowerCase();
321
-
322
- // Exclude our own tests, fixtures, and documentation where we intentionally write malicious patterns
323
- if (p.includes('__tests__/') ||
324
- p.includes('fixtures/') ||
325
- p.includes('docs/') ||
326
- p === 'roadmap-research.md' ||
327
- p === 'changelog.md') {
328
- return true;
329
- }
330
-
331
- // We do NOT exclude the entire src/ or test/ folders for normal skills.
332
- return false;
333
- }
334
-
335
- private isSelfThreatCorpus(skillName: string, relFile: string): boolean {
336
- const isSelf = skillName === 'guard-scanner' || skillName === '.' || skillName === 'ts-src' || skillName === 'src';
337
- if (!isSelf) return false;
338
- return /(^|\/)(ioc-db|patterns)\.(js|ts)$/.test(relFile);
339
- }
340
-
341
- // ── Check Methods ─────────────────────────────────────────────────────
342
-
343
- private classifyFile(ext: string, relFile: string): FileType {
344
- if (CODE_EXTENSIONS.has(ext)) return 'code';
345
- if (DOC_EXTENSIONS.has(ext)) return 'doc';
346
- if (DATA_EXTENSIONS.has(ext)) return 'data';
347
- const base = path.basename(relFile).toLowerCase();
348
- if (base === 'skill.md' || base === 'readme.md') return 'skill-doc';
349
- return 'other';
350
- }
351
-
352
- private checkIoCs(content: string, relFile: string, findings: Finding[]): void {
353
- const contentLower = content.toLowerCase();
354
-
355
- for (const ip of KNOWN_MALICIOUS.ips) {
356
- if (content.includes(ip)) {
357
- findings.push({ severity: 'CRITICAL', id: 'IOC_IP', cat: 'malicious-code', desc: `Known malicious IP: ${ip}`, file: relFile });
358
- }
359
- }
360
- for (const url of KNOWN_MALICIOUS.urls) {
361
- if (contentLower.includes(url.toLowerCase())) {
362
- findings.push({ severity: 'CRITICAL', id: 'IOC_URL', cat: 'malicious-code', desc: `Known malicious URL: ${url}`, file: relFile });
363
- }
364
- }
365
- for (const domain of KNOWN_MALICIOUS.domains) {
366
- const domainRegex = new RegExp(`(?:https?://|[\\s'"\`(]|^)${domain.replace(/\./g, '\\.')}`, 'gi');
367
- if (domainRegex.test(content)) {
368
- findings.push({ severity: 'HIGH', id: 'IOC_DOMAIN', cat: 'exfiltration', desc: `Suspicious domain: ${domain}`, file: relFile });
369
- }
370
- }
371
- for (const fname of KNOWN_MALICIOUS.filenames) {
372
- if (contentLower.includes(fname.toLowerCase())) {
373
- findings.push({ severity: 'CRITICAL', id: 'IOC_FILE', cat: 'suspicious-download', desc: `Known malicious filename: ${fname}`, file: relFile });
374
- }
375
- }
376
- for (const user of KNOWN_MALICIOUS.usernames) {
377
- if (contentLower.includes(user.toLowerCase())) {
378
- findings.push({ severity: 'HIGH', id: 'IOC_USER', cat: 'malicious-code', desc: `Known malicious username: ${user}`, file: relFile });
379
- }
380
- }
381
- }
382
-
383
- private checkPatterns(
384
- content: string, relFile: string, fileType: FileType,
385
- findings: Finding[], patterns: PatternRule[] = PATTERNS,
386
- ): void {
387
- for (const pattern of patterns) {
388
- if (pattern.codeOnly && fileType !== 'code') continue;
389
- if (pattern.docOnly && fileType !== 'doc' && fileType !== 'skill-doc') continue;
390
- if (!pattern.all && !pattern.codeOnly && !pattern.docOnly) continue;
391
-
392
- pattern.regex.lastIndex = 0;
393
- const matches = content.match(pattern.regex);
394
- if (!matches) continue;
395
-
396
- pattern.regex.lastIndex = 0;
397
- const idx = content.search(pattern.regex);
398
- const lineNum = idx >= 0 ? content.substring(0, idx).split('\n').length : undefined;
399
-
400
- let adjustedSeverity: Severity = pattern.severity;
401
- if ((fileType === 'doc' || fileType === 'skill-doc') && pattern.all && !pattern.docOnly) {
402
- if (adjustedSeverity === 'HIGH') adjustedSeverity = 'MEDIUM';
403
- else if (adjustedSeverity === 'MEDIUM') adjustedSeverity = 'LOW';
404
- }
405
-
406
- findings.push({
407
- severity: adjustedSeverity, id: pattern.id, cat: pattern.cat,
408
- desc: pattern.desc, file: relFile, line: lineNum,
409
- matchCount: matches.length, sample: matches[0].substring(0, 80),
410
- });
411
- }
412
- }
413
-
414
- /** NEW: hbg-scan compatible signature matching (hash + pattern + domain) */
415
- private checkSignatures(content: string, filePath: string, findings: Finding[]): void {
416
- const contentHash = crypto.createHash('sha256').update(content).digest('hex');
417
- const relFile = path.basename(filePath);
418
-
419
- for (const sig of SIGNATURES_DB.signatures) {
420
- // Hash match
421
- if (sig.hash && sig.hash === contentHash) {
422
- findings.push({
423
- severity: sig.severity, id: `SIG_${sig.id}`, cat: 'signature-match',
424
- desc: `[${sig.id}] ${sig.name} — exact hash match`, file: relFile,
425
- });
426
- continue;
427
- }
428
-
429
- // Pattern match
430
- if (sig.patterns) {
431
- for (const pat of sig.patterns) {
432
- if (content.includes(pat)) {
433
- const idx = content.indexOf(pat);
434
- const lineNum = content.substring(0, idx).split('\n').length;
435
- findings.push({
436
- severity: sig.severity, id: `SIG_${sig.id}`, cat: 'signature-match',
437
- desc: `[${sig.id}] ${sig.name}`, file: relFile, line: lineNum,
438
- sample: content.split('\n')[lineNum - 1]?.trim().substring(0, 120),
439
- });
440
- break; // One finding per sig per file
441
- }
442
- }
443
- }
444
-
445
- // Domain match
446
- if (sig.domains) {
447
- for (const domain of sig.domains) {
448
- if (content.includes(domain)) {
449
- const idx = content.indexOf(domain);
450
- const lineNum = content.substring(0, idx).split('\n').length;
451
- findings.push({
452
- severity: sig.severity, id: `SIG_${sig.id}`, cat: 'signature-match',
453
- desc: `[${sig.id}] Suspicious domain: ${domain}`, file: relFile, line: lineNum,
454
- });
455
- }
456
- }
457
- }
458
- }
459
- }
460
-
461
- /** NEW: Compaction Layer Persistence check (hbg-scan Check 5) */
462
- private checkCompactionPersistence(
463
- skillPath: string, skillName: string, findings: Finding[],
464
- ): void {
465
- const files = this.getFiles(skillPath);
466
- for (const file of files) {
467
- const ext = path.extname(file).toLowerCase();
468
- if (BINARY_EXTENSIONS.has(ext)) continue;
469
- const relFile = path.relative(skillPath, file);
470
- if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
471
-
472
- let content: string;
473
- try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
474
- if (content.length > 500_000) continue;
475
-
476
- // Post-compaction audit patterns
477
- const compactionPatterns: Array<{ regex: RegExp; label: string; severity: Severity }> = [
478
- { regex: /post-?compaction\s+audit/gi, label: 'Post-compaction audit trigger', severity: 'CRITICAL' },
479
- { regex: /WORKFLOW_AUTO/g, label: 'WORKFLOW_AUTO marker', severity: 'CRITICAL' },
480
- { regex: /⚠️\s*post-?compaction/gi, label: 'Post-compaction emoji warning', severity: 'CRITICAL' },
481
- { regex: /after\s+compaction/gi, label: 'After-compaction trigger', severity: 'HIGH' },
482
- { regex: /survive\s+compaction/gi, label: 'Compaction survival pattern', severity: 'HIGH' },
483
- { regex: /HEARTBEAT\.md/g, label: 'HEARTBEAT.md reference', severity: 'HIGH' },
484
- { regex: /BOOTSTRAP\.md/g, label: 'BOOTSTRAP.md reference', severity: 'HIGH' },
485
- { regex: /persistent\s+instructions/gi, label: 'Persistent instructions pattern', severity: 'HIGH' },
486
- { regex: /setTimeout\s*\([^)]*(?:86400|604800|2592000)/g, label: 'Very long timer delay (persistence)', severity: 'MEDIUM' },
487
- ];
488
-
489
- for (const pat of compactionPatterns) {
490
- pat.regex.lastIndex = 0;
491
- const match = pat.regex.exec(content);
492
- if (match) {
493
- const lineNum = content.substring(0, match.index).split('\n').length;
494
- findings.push({
495
- severity: pat.severity,
496
- id: 'COMPACTION_PERSISTENCE',
497
- cat: 'compaction-persistence',
498
- desc: pat.label,
499
- file: relFile,
500
- line: lineNum,
501
- sample: content.split('\n')[lineNum - 1]?.trim().substring(0, 80),
502
- });
503
- }
504
- }
505
- }
506
- }
507
-
508
- private checkHardcodedSecrets(content: string, relFile: string, findings: Finding[]): void {
509
- const assignmentRegex = /(?:api[_-]?key|secret|token|password|credential|auth)\s*[:=]\s*['"]([a-zA-Z0-9_\-+/=]{16,})['"]|['"]([a-zA-Z0-9_\-+/=]{32,})['"]/gi;
510
- let match: RegExpExecArray | null;
511
- while ((match = assignmentRegex.exec(content)) !== null) {
512
- const value = match[1] || match[2];
513
- if (!value) continue;
514
-
515
- if (/^[A-Z_]+$/.test(value)) continue;
516
- if (/^(true|false|null|undefined|none|default|example|test|placeholder|your[_-])/i.test(value)) continue;
517
- if (/^x{4,}|\.{4,}|_{4,}|0{8,}$/i.test(value)) continue;
518
- if (/^projects\/|^gs:\/\/|^https?:\/\//i.test(value)) continue;
519
- if (/^[a-z]+-[a-z]+-[a-z0-9]+$/i.test(value)) continue;
520
-
521
- const entropy = this.shannonEntropy(value);
522
- if (entropy > 3.5 && value.length >= 20) {
523
- const lineNum = content.substring(0, match.index).split('\n').length;
524
- findings.push({
525
- severity: 'HIGH', id: 'SECRET_ENTROPY', cat: 'secret-detection',
526
- desc: `High-entropy string (possible leaked secret, entropy=${entropy.toFixed(1)})`,
527
- file: relFile, line: lineNum,
528
- sample: value.substring(0, 8) + '...' + value.substring(value.length - 4),
529
- });
530
- }
531
- }
532
- }
533
-
534
- private shannonEntropy(str: string): number {
535
- const freq: Record<string, number> = {};
536
- for (const c of str) freq[c] = (freq[c] || 0) + 1;
537
- const len = str.length;
538
- let entropy = 0;
539
- for (const count of Object.values(freq)) {
540
- const p = count / len;
541
- if (p > 0) entropy -= p * Math.log2(p);
542
- }
543
- return entropy;
544
- }
545
-
546
- private checkStructure(skillPath: string, skillName: string, findings: Finding[]): void {
547
- const skillMd = path.join(skillPath, 'SKILL.md');
548
- if (!fs.existsSync(skillMd)) {
549
- findings.push({ severity: 'LOW', id: 'STRUCT_NO_SKILLMD', cat: 'structural', desc: 'No SKILL.md found', file: skillName });
550
- return;
551
- }
552
- const content = fs.readFileSync(skillMd, 'utf-8');
553
- if (content.length < 50) {
554
- findings.push({ severity: 'MEDIUM', id: 'STRUCT_TINY_SKILLMD', cat: 'structural', desc: 'Suspiciously short SKILL.md (< 50 chars)', file: 'SKILL.md' });
555
- }
556
- const scriptsDir = path.join(skillPath, 'scripts');
557
- if (fs.existsSync(scriptsDir)) {
558
- const scripts = fs.readdirSync(scriptsDir).filter((f: string) => CODE_EXTENSIONS.has(path.extname(f).toLowerCase()));
559
- if (scripts.length > 0 && !content.includes('scripts/')) {
560
- findings.push({ severity: 'MEDIUM', id: 'STRUCT_UNDOCUMENTED_SCRIPTS', cat: 'structural', desc: `${scripts.length} script(s) in scripts/ not referenced in SKILL.md`, file: 'scripts/' });
561
- }
562
- }
563
- }
564
-
565
- private checkDependencies(skillPath: string, skillName: string, findings: Finding[]): void {
566
- const pkgPath = path.join(skillPath, 'package.json');
567
- if (!fs.existsSync(pkgPath)) return;
568
-
569
- let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string>; optionalDependencies?: Record<string, string>; scripts?: Record<string, string> };
570
- try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); } catch { return; }
571
-
572
- const allDeps: Record<string, string> = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies };
573
-
574
- const RISKY_PACKAGES = new Set([
575
- 'node-ipc', 'colors', 'faker', 'event-stream', 'ua-parser-js', 'coa', 'rc',
576
- ]);
577
-
578
- for (const [dep, version] of Object.entries(allDeps)) {
579
- if (RISKY_PACKAGES.has(dep)) {
580
- findings.push({ severity: 'HIGH', id: 'DEP_RISKY', cat: 'dependency-chain', desc: `Known risky dependency: ${dep}@${version}`, file: 'package.json' });
581
- }
582
- if (typeof version === 'string' && (version.startsWith('git+') || version.startsWith('http') || version.startsWith('github:') || version.includes('.tar.gz'))) {
583
- findings.push({ severity: 'HIGH', id: 'DEP_REMOTE', cat: 'dependency-chain', desc: `Remote/git dependency: ${dep}@${version}`, file: 'package.json' });
584
- }
585
- if (version === '*' || version === 'latest') {
586
- findings.push({ severity: 'MEDIUM', id: 'DEP_WILDCARD', cat: 'dependency-chain', desc: `Wildcard version: ${dep}@${version}`, file: 'package.json' });
587
- }
588
- }
589
-
590
- const RISKY_SCRIPTS = ['preinstall', 'postinstall', 'preuninstall', 'postuninstall', 'prepare'];
591
- if (pkg.scripts) {
592
- for (const scriptName of RISKY_SCRIPTS) {
593
- const cmd = pkg.scripts[scriptName];
594
- if (cmd) {
595
- findings.push({ severity: 'HIGH', id: 'DEP_LIFECYCLE', cat: 'dependency-chain', desc: `Lifecycle script "${scriptName}": ${cmd.substring(0, 80)}`, file: 'package.json' });
596
- if (/curl|wget|node\s+-e|eval|exec|bash\s+-c/i.test(cmd)) {
597
- 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) });
598
- }
599
- }
600
- }
601
- }
602
- }
603
-
604
- private checkSkillManifest(skillPath: string, skillName: string, findings: Finding[]): void {
605
- const skillMd = path.join(skillPath, 'SKILL.md');
606
- if (!fs.existsSync(skillMd)) return;
607
- let content: string;
608
- try { content = fs.readFileSync(skillMd, 'utf-8'); } catch { return; }
609
-
610
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
611
- if (!fmMatch) return;
612
- const fm = fmMatch[1];
613
-
614
- const DANGEROUS_BINS = new Set([
615
- 'sudo', 'rm', 'rmdir', 'chmod', 'chown', 'kill', 'pkill',
616
- 'curl', 'wget', 'nc', 'ncat', 'socat', 'ssh', 'scp',
617
- 'dd', 'mkfs', 'fdisk', 'mount', 'umount',
618
- 'iptables', 'ufw', 'firewall-cmd', 'docker', 'kubectl', 'systemctl',
619
- ]);
620
-
621
- const binsMatch = fm.match(/bins:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
622
- if (binsMatch) {
623
- const bins = binsMatch[1].match(/- ([^\n]+)/g) || [];
624
- for (const binLine of bins) {
625
- const bin = binLine.replace(/^-\s*/, '').trim().toLowerCase();
626
- if (DANGEROUS_BINS.has(bin)) {
627
- findings.push({ severity: 'HIGH', id: 'MANIFEST_DANGEROUS_BIN', cat: 'sandbox-validation', desc: `SKILL.md requires dangerous binary: ${bin}`, file: 'SKILL.md' });
628
- }
629
- }
630
- }
631
-
632
- const filesMatch = fm.match(/files:\s*\[([^\]]+)\]/i) || fm.match(/files:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
633
- if (filesMatch && /\*\*\/\*|\*\.\*|"\*"/i.test(filesMatch[1])) {
634
- findings.push({ severity: 'HIGH', id: 'MANIFEST_BROAD_FILES', cat: 'sandbox-validation', desc: 'SKILL.md declares overly broad file scope (e.g. **/*)', file: 'SKILL.md' });
635
- }
636
-
637
- const SENSITIVE_ENV = /(?:SECRET|PASSWORD|CREDENTIAL|PRIVATE_KEY|AWS_SECRET|GITHUB_TOKEN)/i;
638
- const envMatch = fm.match(/env:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
639
- if (envMatch) {
640
- const envVars = envMatch[1].match(/- ([^\n]+)/g) || [];
641
- for (const envLine of envVars) {
642
- const envVar = envLine.replace(/^-\s*/, '').trim();
643
- if (SENSITIVE_ENV.test(envVar)) {
644
- findings.push({ severity: 'HIGH', id: 'MANIFEST_SENSITIVE_ENV', cat: 'sandbox-validation', desc: `SKILL.md requires sensitive env var: ${envVar}`, file: 'SKILL.md' });
645
- }
646
- }
647
- }
648
-
649
- if (/exec:\s*(?:true|yes|enabled|'\*'|"\*")/i.test(fm)) {
650
- findings.push({ severity: 'MEDIUM', id: 'MANIFEST_EXEC_DECLARED', cat: 'sandbox-validation', desc: 'SKILL.md declares exec capability', file: 'SKILL.md' });
651
- }
652
- if (/network:\s*(?:true|yes|enabled|'\*'|"\*"|all|any)/i.test(fm)) {
653
- findings.push({ severity: 'MEDIUM', id: 'MANIFEST_NETWORK_DECLARED', cat: 'sandbox-validation', desc: 'SKILL.md declares unrestricted network access', file: 'SKILL.md' });
654
- }
655
- }
656
-
657
- private checkComplexity(skillPath: string, skillName: string, findings: Finding[]): void {
658
- const files = this.getFiles(skillPath);
659
- const MAX_LINES = 1000;
660
- const MAX_NESTING = 5;
661
- const MAX_EVAL_DENSITY = 0.02;
662
-
663
- for (const file of files) {
664
- const ext = path.extname(file).toLowerCase();
665
- if (!CODE_EXTENSIONS.has(ext)) continue;
666
- const relFile = path.relative(skillPath, file);
667
- if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
668
-
669
- let content: string;
670
- try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
671
- const lines = content.split('\n');
672
-
673
- if (lines.length > MAX_LINES) {
674
- findings.push({ severity: 'MEDIUM', id: 'COMPLEXITY_LONG_FILE', cat: 'complexity', desc: `File exceeds ${MAX_LINES} lines (${lines.length} lines)`, file: relFile });
675
- }
676
-
677
- let maxDepth = 0, currentDepth = 0, deepestLine = 0;
678
- for (let i = 0; i < lines.length; i++) {
679
- for (const ch of lines[i]) {
680
- if (ch === '{') currentDepth++;
681
- if (ch === '}') currentDepth = Math.max(0, currentDepth - 1);
682
- }
683
- if (currentDepth > maxDepth) { maxDepth = currentDepth; deepestLine = i + 1; }
684
- }
685
- if (maxDepth > MAX_NESTING) {
686
- findings.push({ severity: 'MEDIUM', id: 'COMPLEXITY_DEEP_NESTING', cat: 'complexity', desc: `Deep nesting: ${maxDepth} levels (max: ${MAX_NESTING})`, file: relFile, line: deepestLine });
687
- }
688
-
689
- const evalMatches = content.match(/\b(?:eval|exec|execSync|spawn|Function)\s*\(/g) || [];
690
- const density = lines.length > 0 ? evalMatches.length / lines.length : 0;
691
- if (density > MAX_EVAL_DENSITY && evalMatches.length >= 3) {
692
- findings.push({ severity: 'HIGH', id: 'COMPLEXITY_EVAL_DENSITY', cat: 'complexity', desc: `High eval/exec density: ${evalMatches.length} calls in ${lines.length} lines (${(density * 100).toFixed(1)}%)`, file: relFile });
693
- }
694
- }
695
- }
696
-
697
- private checkConfigImpact(skillPath: string, skillName: string, findings: Finding[]): void {
698
- const files = this.getFiles(skillPath);
699
- for (const file of files) {
700
- const ext = path.extname(file).toLowerCase();
701
- if (!CODE_EXTENSIONS.has(ext) && ext !== '.json') continue;
702
- const relFile = path.relative(skillPath, file);
703
- if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
704
-
705
- let content: string;
706
- try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
707
-
708
- const hasConfigRef = /openclaw\.json/i.test(content);
709
- const hasWriteOp = /(?:writeFileSync|writeFile|fs\.write)\s*\(/i.test(content);
710
- if (hasConfigRef && hasWriteOp) {
711
- const clines = content.split('\n');
712
- let writeLine = 0;
713
- for (let i = 0; i < clines.length; i++) {
714
- if (/(?:writeFileSync|writeFile|fs\.write)\s*\(/i.test(clines[i])) { writeLine = i + 1; break; }
715
- }
716
- findings.push({ severity: 'CRITICAL', id: 'CFG_WRITE_DETECTED', cat: 'config-impact', desc: 'Code writes to openclaw.json', file: relFile, line: writeLine });
717
- }
718
-
719
- const DANGEROUS_CFG = [
720
- { regex: /exec\.approvals?\s*[:=]\s*['"]?(off|false|disabled|none)/gi, id: 'CFG_EXEC_APPROVAL_OFF', desc: 'Disables exec approval', severity: 'CRITICAL' as Severity },
721
- { regex: /tools\.exec\.host\s*[:=]\s*['"]gateway['"]/gi, id: 'CFG_EXEC_HOST_GATEWAY', desc: 'Sets exec host to gateway', severity: 'CRITICAL' as Severity },
722
- { regex: /hooks\s*\.\s*internal\s*\.\s*entries\s*[:=]/gi, id: 'CFG_HOOKS_INTERNAL', desc: 'Modifies internal hooks', severity: 'HIGH' as Severity },
723
- { regex: /network\.allowedDomains\s*[:=]\s*\[?\s*['"]\*['"]/gi, id: 'CFG_NET_WILDCARD', desc: 'Sets network domains to wildcard', severity: 'HIGH' as Severity },
724
- ];
725
- for (const check of DANGEROUS_CFG) {
726
- check.regex.lastIndex = 0;
727
- if (check.regex.test(content)) {
728
- findings.push({ severity: check.severity, id: check.id, cat: 'config-impact', desc: check.desc, file: relFile });
729
- }
730
- }
731
- }
732
- }
733
-
734
- private checkHiddenFiles(skillPath: string, skillName: string, findings: Finding[]): void {
735
- try {
736
- const entries = fs.readdirSync(skillPath);
737
- for (const entry of entries) {
738
- if (entry.startsWith('.') && entry !== '.guard-scanner-ignore' && entry !== '.guava-guard-ignore' && entry !== '.gitignore' && entry !== '.git') {
739
- const fullPath = path.join(skillPath, entry);
740
- const stat = fs.statSync(fullPath);
741
- if (stat.isFile()) {
742
- const ext = path.extname(entry).toLowerCase();
743
- if (CODE_EXTENSIONS.has(ext) || ext === '' || ext === '.sh') {
744
- findings.push({ severity: 'MEDIUM', id: 'STRUCT_HIDDEN_EXEC', cat: 'structural', desc: `Hidden executable file: ${entry}`, file: entry });
745
- }
746
- } else if (stat.isDirectory() && entry !== '.git') {
747
- findings.push({ severity: 'LOW', id: 'STRUCT_HIDDEN_DIR', cat: 'structural', desc: `Hidden directory: ${entry}/`, file: entry });
748
- }
749
- }
750
- }
751
- } catch { /* empty */ }
752
- }
753
-
754
- private checkJSDataFlow(content: string, relFile: string, findings: Finding[]): void {
755
- const lines = content.split('\n');
756
- const imports = new Map<string, string>();
757
- const sensitiveReads: Array<{ line: number; text: string }> = [];
758
- const networkCalls: Array<{ line: number; text: string }> = [];
759
- const execCalls: Array<{ line: number; text: string }> = [];
760
-
761
- for (let i = 0; i < lines.length; i++) {
762
- const line = lines[i];
763
- const lineNum = i + 1;
764
-
765
- const reqMatch = line.match(/(?:const|let|var)\s+(?:\{[^}]+\}|\w+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
766
- if (reqMatch) {
767
- const varMatch = line.match(/(?:const|let|var)\s+(\{[^}]+\}|\w+)/);
768
- if (varMatch) imports.set(varMatch[1].trim(), reqMatch[1]);
769
- }
770
-
771
- if (/(?:readFileSync|readFile)\s*\([^)]*(?:\.env|\.ssh|id_rsa|\.clawdbot|\.openclaw(?!\/workspace))/i.test(line)) {
772
- sensitiveReads.push({ line: lineNum, text: line.trim() });
773
- }
774
- if (/process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i.test(line)) {
775
- sensitiveReads.push({ line: lineNum, text: line.trim() });
776
- }
777
- if (/(?:fetch|axios|request|http\.request|https\.request|got)\s*\(/i.test(line) || /\.post\s*\(|\.put\s*\(|\.patch\s*\(/i.test(line)) {
778
- networkCalls.push({ line: lineNum, text: line.trim() });
779
- }
780
- if (/(?:exec|execSync|spawn|spawnSync|execFile)\s*\(/i.test(line)) {
781
- execCalls.push({ line: lineNum, text: line.trim() });
782
- }
783
- }
784
-
785
- if (sensitiveReads.length > 0 && networkCalls.length > 0) {
786
- findings.push({ severity: 'CRITICAL', id: 'AST_CRED_TO_NET', cat: 'data-flow', desc: `Data flow: secret read (L${sensitiveReads[0].line}) → network call (L${networkCalls[0].line})`, file: relFile, line: sensitiveReads[0].line, sample: sensitiveReads[0].text.substring(0, 60) });
787
- }
788
- if (sensitiveReads.length > 0 && execCalls.length > 0) {
789
- findings.push({ severity: 'HIGH', id: 'AST_CRED_TO_EXEC', cat: 'data-flow', desc: `Data flow: secret read (L${sensitiveReads[0].line}) → command exec (L${execCalls[0].line})`, file: relFile, line: sensitiveReads[0].line, sample: sensitiveReads[0].text.substring(0, 60) });
790
- }
791
-
792
- const importedModules = new Set([...imports.values()]);
793
- if (importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http') || importedModules.has('node-fetch'))) {
794
- findings.push({ severity: 'HIGH', id: 'AST_SUSPICIOUS_IMPORTS', cat: 'data-flow', desc: 'Suspicious: child_process + network module', file: relFile });
795
- }
796
- if (importedModules.has('fs') && importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http'))) {
797
- findings.push({ severity: 'CRITICAL', id: 'AST_EXFIL_TRIFECTA', cat: 'data-flow', desc: 'Exfiltration trifecta: fs + child_process + network', file: relFile });
798
- }
799
-
800
- for (let i = 0; i < lines.length; i++) {
801
- const line = lines[i];
802
- if (/`[^`]*\$\{.*(?:env|key|token|secret|password).*\}[^`]*`\s*(?:\)|,)/i.test(line) && /(?:fetch|request|axios|http|url)/i.test(line)) {
803
- findings.push({ severity: 'CRITICAL', id: 'AST_SECRET_IN_URL', cat: 'data-flow', desc: 'Secret interpolated into URL', file: relFile, line: i + 1, sample: line.trim().substring(0, 80) });
804
- }
805
- }
806
- }
807
-
808
- private checkCrossFile(skillPath: string, skillName: string, findings: Finding[]): void {
809
- const files = this.getFiles(skillPath);
810
- const allContent: Record<string, string> = {};
811
-
812
- for (const file of files) {
813
- const ext = path.extname(file).toLowerCase();
814
- if (BINARY_EXTENSIONS.has(ext)) continue;
815
- const relFile = path.relative(skillPath, file);
816
- if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
817
- try {
818
- const content = fs.readFileSync(file, 'utf-8');
819
- if (content.length < 500_000) allContent[relFile] = content;
820
- } catch { /* empty */ }
821
- }
822
-
823
- const skillMd = allContent['SKILL.md'] || '';
824
- const codeFileRefs = skillMd.match(/(?:scripts?\/|\.\/)[a-zA-Z0-9_\-.\/]+\.(js|py|sh|ts)/gi) || [];
825
- for (const ref of codeFileRefs) {
826
- const cleanRef = ref.replace(/^\.\//, '');
827
- if (!allContent[cleanRef] && !files.some(f => path.relative(skillPath, f) === cleanRef)) {
828
- findings.push({ severity: 'MEDIUM', id: 'XFILE_PHANTOM_REF', cat: 'structural', desc: `SKILL.md references non-existent: ${cleanRef}`, file: 'SKILL.md' });
829
- }
830
- }
831
-
832
- const b64Fragments: Array<{ file: string; fragment: string }> = [];
833
- for (const [file, content] of Object.entries(allContent)) {
834
- const matches = content.match(/[A-Za-z0-9+/]{20,}={0,2}/g) || [];
835
- for (const m of matches) {
836
- if (m.length > 40) b64Fragments.push({ file, fragment: m.substring(0, 30) });
837
- }
838
- }
839
- if (b64Fragments.length > 3 && new Set(b64Fragments.map(f => f.file)).size > 1) {
840
- findings.push({ severity: 'HIGH', id: 'XFILE_FRAGMENT_B64', cat: 'obfuscation', desc: `Base64 fragments across ${new Set(b64Fragments.map(f => f.file)).size} files`, file: skillName });
841
- }
842
-
843
- if (/(?:read|load|source|import)\s+(?:the\s+)?(?:script|file|code)\s+(?:from|at|in)\s+(?:scripts?\/)/gi.test(skillMd)) {
844
- const hasExec = Object.values(allContent).some(c => /(?:eval|exec|spawn)\s*\(/i.test(c));
845
- if (hasExec) {
846
- findings.push({ severity: 'MEDIUM', id: 'XFILE_LOAD_EXEC', cat: 'data-flow', desc: 'SKILL.md references scripts with exec/eval', file: 'SKILL.md' });
847
- }
848
- }
849
- }
850
-
851
- // ── Risk Scoring ──────────────────────────────────────────────────────
852
-
853
- private calculateRisk(findings: Finding[]): number {
854
- if (findings.length === 0) return 0;
855
-
856
- let score = 0;
857
- const catCounts: Record<string, number> = {};
858
-
859
- // Safe domain whitelist (減衰対象)
860
- const SAFE_DOMAINS = [
861
- 'openai.com', 'anthropic.com', 'google.com', 'microsoft.com',
862
- 'github.com', 'npmjs.com', 'openclaw.ai', 'guava-parity.org'
863
- ];
864
-
865
- for (const f of findings) {
866
- // Safe domain checking
867
- if (f.id === 'IOC_DOMAIN' || f.id === 'SHADOW_AI_OPENAI' || f.id === 'SHADOW_AI_ANTHROPIC') {
868
- if (SAFE_DOMAINS.some(d => f.desc.includes(d))) {
869
- score += 1; // ほぼ無視 (1点)
870
- continue;
871
- }
872
- }
873
-
874
- // Logarithmic decay per category
875
- catCounts[f.cat] = (catCounts[f.cat] || 0) + 1;
876
- const weight = SEVERITY_WEIGHTS[f.severity] || 0;
877
-
878
- if (catCounts[f.cat] === 1) {
879
- score += weight;
880
- } else if (catCounts[f.cat] === 2) {
881
- score += Math.round(weight * 0.5);
882
- } else {
883
- score += Math.round(weight * 0.2); // 3つ目以降はノイズとみなして大幅減退
884
- }
885
- }
886
-
887
- const ids = new Set(findings.map(f => f.id));
888
- const cats = new Set(findings.map(f => f.cat));
889
-
890
- // Amplifiers (相関分析) — 意味のある組み合わせのみ増幅
891
- if (cats.has('credential-handling') && cats.has('exfiltration')) score = Math.round(score * 1.5);
892
- if (cats.has('obfuscation') && cats.has('malicious-code')) score = Math.round(score * 1.5);
893
- if (ids.has('DEP_LIFECYCLE_EXEC')) score = Math.round(score * 2);
894
-
895
- // Critical override (Blacklist matches)
896
- if (ids.has('IOC_IP') || ids.has('IOC_URL') || ids.has('KNOWN_TYPOSQUAT')) {
897
- return 100;
898
- }
899
-
900
- return Math.min(100, score);
901
- }
902
-
903
- private getVerdict(risk: number): Verdict {
904
- if (risk >= this.thresholds.malicious) return { icon: '🔴', label: 'MALICIOUS', stat: 'malicious' };
905
- if (risk >= this.thresholds.suspicious) return { icon: '🟡', label: 'SUSPICIOUS', stat: 'suspicious' };
906
- if (risk > 0) return { icon: '🟢', label: 'LOW RISK', stat: 'low' };
907
- return { icon: '🟢', label: 'CLEAN', stat: 'clean' };
908
- }
909
-
910
- // ── File Discovery ────────────────────────────────────────────────────
911
-
912
- private getFiles(dir: string): string[] {
913
- const results: string[] = [];
914
- try {
915
- const entries = fs.readdirSync(dir, { withFileTypes: true });
916
- for (const entry of entries) {
917
- const fullPath = path.join(dir, entry.name);
918
- if (entry.isDirectory()) {
919
- if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build') continue;
920
- results.push(...this.getFiles(fullPath));
921
- } else {
922
- const base = entry.name.toLowerCase();
923
- if (GENERATED_REPORT_FILES.has(base)) continue;
924
- results.push(fullPath);
925
- }
926
- }
927
- } catch { /* empty */ }
928
- return results;
929
- }
930
-
931
- // ── Output ────────────────────────────────────────────────────────────
932
-
933
- printSummary(): void {
934
- const total = this.stats.scanned;
935
- const safe = this.stats.clean + this.stats.low;
936
- console.log(`\n${'═'.repeat(54)}`);
937
- console.log(`📊 guard-scanner v${VERSION} Scan Summary`);
938
- console.log(`${'─'.repeat(54)}`);
939
- console.log(` Scanned: ${total}`);
940
- console.log(` 🟢 Clean: ${this.stats.clean}`);
941
- console.log(` 🟢 Low Risk: ${this.stats.low}`);
942
- console.log(` 🟡 Suspicious: ${this.stats.suspicious}`);
943
- console.log(` 🔴 Malicious: ${this.stats.malicious}`);
944
- console.log(` Safety Rate: ${total ? Math.round(safe / total * 100) : 0}%`);
945
- console.log(`${'═'.repeat(54)}`);
946
-
947
- if (this.stats.malicious > 0) {
948
- console.log(`\n⚠️ CRITICAL: ${this.stats.malicious} malicious skill(s) detected!`);
949
- } else if (this.stats.suspicious > 0) {
950
- console.log(`\n⚡ ${this.stats.suspicious} suspicious skill(s) found — review recommended.`);
951
- } else {
952
- console.log('\n✅ All clear! No threats detected.');
953
- }
954
- }
955
-
956
- toJSON(): JSONReport {
957
- const recommendations: Recommendation[] = [];
958
- for (const sr of this.findings) {
959
- const actions: string[] = [];
960
- const cats = new Set(sr.findings.map(f => f.cat));
961
-
962
- if (cats.has('prompt-injection')) actions.push('🛑 Contains prompt injection patterns.');
963
- if (cats.has('malicious-code')) actions.push('🛑 Contains potentially malicious code.');
964
- if (cats.has('credential-handling') && cats.has('exfiltration')) actions.push('💀 CRITICAL: Credential + exfiltration. DO NOT INSTALL.');
965
- if (cats.has('dependency-chain')) actions.push('📦 Suspicious dependency chain.');
966
- if (cats.has('obfuscation')) actions.push('🔍 Code obfuscation detected.');
967
- if (cats.has('secret-detection')) actions.push('🔑 Possible hardcoded secrets.');
968
- if (cats.has('memory-poisoning')) actions.push('🧠 MEMORY POISONING: Agent memory modification.');
969
- if (cats.has('prompt-worm')) actions.push('🪱 PROMPT WORM: Self-replicating instructions.');
970
- if (cats.has('data-flow')) actions.push('🔀 Suspicious data flow patterns.');
971
- if (cats.has('identity-hijack')) actions.push('🔒 IDENTITY HIJACK: Agent soul file tampering.');
972
- if (cats.has('compaction-persistence')) actions.push('⏰ COMPACTION PERSISTENCE: Survives context compaction.');
973
- if (cats.has('signature-match')) actions.push('🎯 SIGNATURE MATCH: Known threat signature detected.');
974
- if (cats.has('config-impact')) actions.push('⚙️ CONFIG IMPACT: Modifies OpenClaw configuration.');
975
- if (cats.has('pii-exposure')) actions.push('🆔 PII EXPOSURE: Handles personal information.');
976
-
977
- if (actions.length > 0) recommendations.push({ skill: sr.skill, actions });
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-21',
989
- signaturesVersion: SIGNATURES_DB.version,
990
- };
991
- }
992
-
993
- toSARIF(scanDir: string): SARIFReport {
994
- const rules: SARIFRule[] = [];
995
- const ruleIndex: Record<string, number> = {};
996
- const results: SARIFResult[] = [];
997
-
998
- for (const sr of this.findings) {
999
- for (const f of sr.findings) {
1000
- if (!ruleIndex[f.id] && ruleIndex[f.id] !== 0) {
1001
- ruleIndex[f.id] = rules.length;
1002
- // Look up OWASP mapping from PATTERNS
1003
- const patternDef = PATTERNS.find((p) => p.id === f.id);
1004
- const owaspTag = patternDef?.owasp;
1005
- const tags = ['security', f.cat];
1006
- if (owaspTag) tags.push(`OWASP/${owaspTag}`);
1007
-
1008
- rules.push({
1009
- id: f.id, name: f.id,
1010
- shortDescription: { text: f.desc },
1011
- defaultConfiguration: { level: f.severity === 'CRITICAL' || f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note' },
1012
- properties: {
1013
- tags,
1014
- 'security-severity': f.severity === 'CRITICAL' ? '9.0' : f.severity === 'HIGH' ? '7.0' : f.severity === 'MEDIUM' ? '4.0' : '1.0',
1015
- },
1016
- });
1017
- }
1018
-
1019
- const normalizedFile = String(f.file || '').replaceAll('\\', '/').replace(/^\/+/, '');
1020
- const artifactUri = `${sr.skill}/${normalizedFile}`;
1021
- const seed = `${f.id}|${artifactUri}|${f.line || 0}|${(f.sample || '').slice(0, 200)}`;
1022
- const lineHash = crypto.createHash('sha256').update(seed).digest('hex').slice(0, 24);
1023
-
1024
- results.push({
1025
- ruleId: f.id, ruleIndex: ruleIndex[f.id],
1026
- level: f.severity === 'CRITICAL' || f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note',
1027
- message: { text: `[${sr.skill}] ${f.desc}${f.sample ? ` — "${f.sample}"` : ''}` },
1028
- partialFingerprints: { primaryLocationLineHash: lineHash },
1029
- locations: [{
1030
- physicalLocation: {
1031
- artifactLocation: { uri: artifactUri, uriBaseId: '%SRCROOT%' },
1032
- region: f.line ? { startLine: f.line } : undefined,
1033
- },
1034
- }],
1035
- });
1036
- }
1037
- }
1038
-
1039
- return {
1040
- version: '2.1.0',
1041
- $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
1042
- runs: [{
1043
- tool: { driver: { name: 'guard-scanner', version: VERSION, informationUri: 'https://github.com/koatora20/guard-scanner', rules } },
1044
- results,
1045
- invocations: [{ executionSuccessful: true, endTimeUtc: new Date().toISOString() }],
1046
- }],
1047
- };
1048
- }
1049
- toHTML(): string {
1050
- const report = this.toJSON();
1051
- const ts = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
1052
- const severityColor: Record<string, string> = {
1053
- CRITICAL: '#ff4444', HIGH: '#ff8800', MEDIUM: '#ffcc00', LOW: '#aaaaaa',
1054
- };
1055
- const verdictColor: Record<string, string> = {
1056
- MALICIOUS: '#ff4444', SUSPICIOUS: '#ffcc00', 'LOW RISK': '#44cc88', CLEAN: '#44cc88',
1057
- };
1058
-
1059
- const rows = report.findings.map(sr => {
1060
- const color = verdictColor[sr.verdict] || '#aaaaaa';
1061
- const findingRows = sr.findings.map(f => {
1062
- const c = severityColor[f.severity] || '#aaaaaa';
1063
- const loc = f.file ? `${f.file}${f.line ? ':' + f.line : ''}` : '—';
1064
- const sample = f.sample ? `<code>${f.sample.replace(/</g, '&lt;')}</code>` : '—';
1065
- return `<tr><td style="color:${c};font-weight:bold">${f.severity}</td><td>${f.id}</td><td>${f.desc}</td><td>${loc}</td><td>${sample}</td></tr>`;
1066
- }).join('');
1067
- const badge = `<span style="background:${color};color:#000;padding:2px 8px;border-radius:4px;font-weight:bold;font-size:0.85em">${sr.verdict}</span>`;
1068
- return `<tr><td colspan="5" style="background:#1a1a2e;padding:8px 12px;font-weight:bold">
1069
- 🛡️ ${sr.skill} — ${badge} (risk: ${sr.risk})</td></tr>${findingRows}`;
1070
- }).join('');
1071
-
1072
- const total = report.stats.scanned;
1073
- const safe = report.stats.clean + report.stats.low;
1074
- const safeRate = total ? Math.round(safe / total * 100) : 0;
1075
-
1076
- return `<!DOCTYPE html>
1077
- <html lang="en">
1078
- <head>
1079
- <meta charset="UTF-8">
1080
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1081
- <title>guard-scanner v${VERSION} Report</title>
1082
- <style>
1083
- body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0d0d1a; color: #e0e0e0; margin: 0; padding: 24px; }
1084
- h1 { color: #7ec8e3; margin: 0 0 4px; }
1085
- .meta { color: #888; font-size: 0.85em; margin-bottom: 24px; }
1086
- .stats { display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }
1087
- .stat { background: #1a1a2e; border: 1px solid #333; border-radius: 8px; padding: 12px 20px; text-align: center; min-width: 80px; }
1088
- .stat-label { font-size: 0.75em; color: #888; margin-bottom: 4px; }
1089
- .stat-val { font-size: 1.8em; font-weight: bold; }
1090
- table { width: 100%; border-collapse: collapse; margin-top: 8px; }
1091
- th { background: #1a1a2e; padding: 8px 12px; text-align: left; font-size: 0.85em; color: #888; border-bottom: 1px solid #333; }
1092
- td { padding: 6px 12px; border-bottom: 1px solid #222; font-size: 0.85em; vertical-align: top; }
1093
- tr:hover td { background: #13132a; }
1094
- code { background: #1e1e3a; padding: 1px 4px; border-radius: 3px; font-family: monospace; font-size: 0.9em; word-break: break-all; }
1095
- .clean { color: #44cc88; font-weight: bold; }
1096
- .footer { margin-top: 32px; color: #555; font-size: 0.8em; }
1097
- </style>
1098
- </head>
1099
- <body>
1100
- <h1>🛡️ guard-scanner v${VERSION}</h1>
1101
- <div class="meta">Generated: ${ts} | Mode: ${report.mode} | Thresholds: suspicious≥${report.thresholds.suspicious}, malicious≥${report.thresholds.malicious}</div>
1102
- <div class="stats">
1103
- <div class="stat"><div class="stat-label">Scanned</div><div class="stat-val">${report.stats.scanned}</div></div>
1104
- <div class="stat"><div class="stat-label">Clean</div><div class="stat-val" style="color:#44cc88">${report.stats.clean}</div></div>
1105
- <div class="stat"><div class="stat-label">Low Risk</div><div class="stat-val" style="color:#44cc88">${report.stats.low}</div></div>
1106
- <div class="stat"><div class="stat-label">Suspicious</div><div class="stat-val" style="color:#ffcc00">${report.stats.suspicious}</div></div>
1107
- <div class="stat"><div class="stat-label">Malicious</div><div class="stat-val" style="color:#ff4444">${report.stats.malicious}</div></div>
1108
- <div class="stat"><div class="stat-label">Safety Rate</div><div class="stat-val" style="color:${safeRate >= 80 ? '#44cc88' : '#ff8800'}">${safeRate}%</div></div>
1109
- </div>
1110
- ${report.findings.length === 0 ? '<p class="clean">✅ All clear — no threats detected.</p>' : `
1111
- <table>
1112
- <thead><tr><th>Severity</th><th>Pattern ID</th><th>Description</th><th>Location</th><th>Sample</th></tr></thead>
1113
- <tbody>${rows}</tbody>
1114
- </table>`}
1115
- <div class="footer">guard-scanner v${VERSION} | IoC DB: ${report.iocVersion} | Signatures: ${report.signaturesVersion} | <a href="https://github.com/koatora20/guard-scanner" style="color:#7ec8e3">GitHub</a></div>
1116
- </body></html>`;
1117
- }
1118
- }