guard-scanner 2.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +107 -64
  2. package/dist/__tests__/scanner.test.d.ts +10 -0
  3. package/dist/__tests__/scanner.test.d.ts.map +1 -0
  4. package/dist/__tests__/scanner.test.js +374 -0
  5. package/dist/__tests__/scanner.test.js.map +1 -0
  6. package/dist/cli.d.ts +10 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +189 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +18 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/ioc-db.d.ts +13 -0
  15. package/dist/ioc-db.d.ts.map +1 -0
  16. package/dist/ioc-db.js +130 -0
  17. package/dist/ioc-db.js.map +1 -0
  18. package/dist/patterns.d.ts +27 -0
  19. package/dist/patterns.d.ts.map +1 -0
  20. package/dist/patterns.js +92 -0
  21. package/dist/patterns.js.map +1 -0
  22. package/dist/quarantine.d.ts +18 -0
  23. package/dist/quarantine.d.ts.map +1 -0
  24. package/dist/quarantine.js +42 -0
  25. package/dist/quarantine.js.map +1 -0
  26. package/dist/scanner.d.ts +54 -0
  27. package/dist/scanner.d.ts.map +1 -0
  28. package/dist/scanner.js +1043 -0
  29. package/dist/scanner.js.map +1 -0
  30. package/dist/types.d.ts +165 -0
  31. package/dist/types.d.ts.map +1 -0
  32. package/dist/types.js +7 -0
  33. package/dist/types.js.map +1 -0
  34. package/hooks/guard-scanner/plugin.ts +101 -32
  35. package/openclaw.plugin.json +60 -0
  36. package/package.json +25 -9
  37. package/ts-src/__tests__/fixtures/clean-skill/SKILL.md +9 -0
  38. package/ts-src/__tests__/fixtures/compaction-skill/SKILL.md +11 -0
  39. package/ts-src/__tests__/fixtures/malicious-skill/SKILL.md +11 -0
  40. package/ts-src/__tests__/fixtures/malicious-skill/scripts/evil.js +25 -0
  41. package/ts-src/__tests__/fixtures/prompt-leakage-skill/SKILL.md +20 -0
  42. package/ts-src/__tests__/fixtures/prompt-leakage-skill/scripts/debug.js +4 -0
  43. package/ts-src/__tests__/scanner.test.ts +525 -0
  44. package/ts-src/cli.ts +171 -0
  45. package/ts-src/index.ts +15 -0
  46. package/ts-src/ioc-db.ts +131 -0
  47. package/ts-src/patterns.ts +104 -0
  48. package/ts-src/quarantine.ts +48 -0
  49. package/{src/scanner.js → ts-src/scanner.ts} +376 -383
  50. package/ts-src/types.ts +187 -0
  51. package/hooks/guard-scanner/handler.ts +0 -207
  52. package/src/cli.js +0 -149
  53. package/src/html-template.js +0 -239
  54. package/src/ioc-db.js +0 -54
  55. package/src/patterns.js +0 -190
@@ -1,89 +1,101 @@
1
- #!/usr/bin/env node
2
1
  /**
3
- * guard-scanner v1.0.0 — Agent Skill Security Scanner 🛡️
2
+ * guard-scanner v3.0.0 — Core Scanner (TypeScript)
4
3
  *
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
4
+ * Full TypeScript rewrite of guard-scanner v2.1.0 + hbg-scan features.
5
+ * Adds: Compaction Persistence check, Signature hash matching, typed interfaces.
13
6
  *
14
- * Based on GuavaGuard v9.0.0 (OSS extraction)
15
- * 20 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
7
+ * Zero dependencies. MIT License.
22
8
  */
23
9
 
24
- const fs = require('fs');
25
- const path = require('path');
26
- const os = require('os');
27
- const crypto = require('crypto');
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';
28
23
 
29
- const { PATTERNS } = require('./patterns.js');
30
- const { KNOWN_MALICIOUS } = require('./ioc-db.js');
31
- const { generateHTML } = require('./html-template.js');
24
+ // ── Constants ───────────────────────────────────────────────────────────────
32
25
 
33
- // ===== CONFIGURATION =====
34
- const VERSION = '1.1.0';
26
+ export const VERSION = '3.0.0';
35
27
 
36
- const THRESHOLDS = {
28
+ const THRESHOLDS_MAP: Record<string, Thresholds> = {
37
29
  normal: { suspicious: 30, malicious: 80 },
38
30
  strict: { suspicious: 20, malicious: 60 },
39
31
  };
40
32
 
41
- // File classification
42
- const CODE_EXTENSIONS = new Set(['.js', '.ts', '.mjs', '.cjs', '.py', '.sh', '.bash', '.ps1', '.rb', '.go', '.rs', '.php', '.pl']);
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
+ ]);
43
41
  const DOC_EXTENSIONS = new Set(['.md', '.txt', '.rst', '.adoc']);
44
42
  const DATA_EXTENSIONS = new Set(['.json', '.yaml', '.yml', '.toml', '.xml', '.csv']);
45
- const BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.wasm', '.wav', '.mp3', '.mp4', '.webm', '.ogg', '.pdf', '.zip', '.tar', '.gz', '.bz2', '.7z', '.exe', '.dll', '.so', '.dylib']);
46
- const GENERATED_REPORT_FILES = new Set(['guard-scanner-report.json', 'guard-scanner-report.html', 'guard-scanner.sarif']);
47
-
48
- // Severity weights for risk scoring
49
- const SEVERITY_WEIGHTS = { CRITICAL: 40, HIGH: 15, MEDIUM: 5, LOW: 2 };
50
-
51
- class GuardScanner {
52
- constructor(options = {}) {
53
- this.verbose = options.verbose || false;
54
- this.selfExclude = options.selfExclude || false;
55
- this.strict = options.strict || false;
56
- this.summaryOnly = options.summaryOnly || false;
57
- this.checkDeps = options.checkDeps || false;
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
+ readonly checkDeps: boolean;
60
+ readonly thresholds: Thresholds;
61
+
62
+ findings: SkillResult[] = [];
63
+ stats: ScanStats = { scanned: 0, clean: 0, low: 0, suspicious: 0, malicious: 0 };
64
+
65
+ private scannerDir: string;
66
+ private ignoredSkills = new Set<string>();
67
+ private ignoredPatterns = new Set<string>();
68
+ private customRules: PatternRule[] = [];
69
+
70
+ constructor(options: ScannerOptions = {}) {
71
+ this.verbose = options.verbose ?? false;
72
+ this.selfExclude = options.selfExclude ?? false;
73
+ this.strict = options.strict ?? false;
74
+ this.summaryOnly = options.summaryOnly ?? false;
75
+ this.checkDeps = options.checkDeps ?? false;
58
76
  this.scannerDir = path.resolve(__dirname);
59
- this.thresholds = this.strict ? THRESHOLDS.strict : THRESHOLDS.normal;
60
- this.findings = [];
61
- this.stats = { scanned: 0, clean: 0, low: 0, suspicious: 0, malicious: 0 };
62
- this.ignoredSkills = new Set();
63
- this.ignoredPatterns = new Set();
64
- this.customRules = [];
65
-
66
- // Plugin API: load plugins
67
- if (options.plugins && Array.isArray(options.plugins)) {
77
+ this.thresholds = this.strict ? THRESHOLDS_MAP.strict : THRESHOLDS_MAP.normal;
78
+
79
+ if (options.plugins) {
68
80
  for (const plugin of options.plugins) {
69
81
  this.loadPlugin(plugin);
70
82
  }
71
83
  }
72
-
73
- // Custom rules file (legacy compat)
74
84
  if (options.rulesFile) {
75
85
  this.loadCustomRules(options.rulesFile);
76
86
  }
77
87
  }
78
88
 
79
- // Plugin API: load a plugin module
80
- loadPlugin(pluginPath) {
89
+ // ── Plugin System ───────────────────────────────────────────────────────
90
+
91
+ loadPlugin(pluginPath: string): void {
81
92
  try {
93
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
82
94
  const plugin = require(path.resolve(pluginPath));
83
95
  if (plugin.patterns && Array.isArray(plugin.patterns)) {
84
96
  for (const p of plugin.patterns) {
85
97
  if (p.id && p.regex && p.severity && p.cat && p.desc) {
86
- this.customRules.push(p);
98
+ this.customRules.push(p as PatternRule);
87
99
  }
88
100
  }
89
101
  if (!this.summaryOnly) {
@@ -91,17 +103,16 @@ class GuardScanner {
91
103
  }
92
104
  }
93
105
  } catch (e) {
94
- console.error(`⚠️ Failed to load plugin ${pluginPath}: ${e.message}`);
106
+ console.error(`⚠️ Failed to load plugin ${pluginPath}: ${(e as Error).message}`);
95
107
  }
96
108
  }
97
109
 
98
- // Custom rules from JSON file
99
- loadCustomRules(rulesFile) {
110
+ loadCustomRules(rulesFile: string): void {
100
111
  try {
101
112
  const content = fs.readFileSync(rulesFile, 'utf-8');
102
- const rules = JSON.parse(content);
113
+ const rules: CustomRuleInput[] = JSON.parse(content);
103
114
  if (!Array.isArray(rules)) {
104
- console.error(`⚠️ Custom rules file must be a JSON array`);
115
+ console.error('⚠️ Custom rules file must be a JSON array');
105
116
  return;
106
117
  }
107
118
  for (const rule of rules) {
@@ -117,24 +128,25 @@ class GuardScanner {
117
128
  regex: new RegExp(rule.pattern, flags),
118
129
  severity: rule.severity,
119
130
  desc: rule.desc,
120
- codeOnly: rule.codeOnly || false,
121
- docOnly: rule.docOnly || false,
122
- all: !rule.codeOnly && !rule.docOnly
131
+ codeOnly: rule.codeOnly ?? false,
132
+ docOnly: rule.docOnly ?? false,
133
+ all: !rule.codeOnly && !rule.docOnly,
123
134
  });
124
135
  } catch (e) {
125
- console.error(`⚠️ Invalid regex in rule ${rule.id}: ${e.message}`);
136
+ console.error(`⚠️ Invalid regex in rule ${rule.id}: ${(e as Error).message}`);
126
137
  }
127
138
  }
128
139
  if (!this.summaryOnly && this.customRules.length > 0) {
129
140
  console.log(`📏 Loaded ${this.customRules.length} custom rule(s) from ${rulesFile}`);
130
141
  }
131
142
  } catch (e) {
132
- console.error(`⚠️ Failed to load custom rules: ${e.message}`);
143
+ console.error(`⚠️ Failed to load custom rules: ${(e as Error).message}`);
133
144
  }
134
145
  }
135
146
 
136
- // Load .guava-guard-ignore / .guard-scanner-ignore from scan directory
137
- loadIgnoreFile(scanDir) {
147
+ // ── Ignore System ───────────────────────────────────────────────────────
148
+
149
+ private loadIgnoreFile(scanDir: string): void {
138
150
  const ignorePaths = [
139
151
  path.join(scanDir, '.guard-scanner-ignore'),
140
152
  path.join(scanDir, '.guava-guard-ignore'),
@@ -154,11 +166,13 @@ class GuardScanner {
154
166
  if (this.verbose && (this.ignoredSkills.size || this.ignoredPatterns.size)) {
155
167
  console.log(`📋 Loaded ignore file: ${this.ignoredSkills.size} skills, ${this.ignoredPatterns.size} patterns`);
156
168
  }
157
- break; // use first found
169
+ break;
158
170
  }
159
171
  }
160
172
 
161
- scanDirectory(dir) {
173
+ // ── Main Scan ─────────────────────────────────────────────────────────
174
+
175
+ scanDirectory(dir: string): SkillResult[] {
162
176
  if (!fs.existsSync(dir)) {
163
177
  console.error(`❌ Directory not found: ${dir}`);
164
178
  process.exit(2);
@@ -166,7 +180,7 @@ class GuardScanner {
166
180
 
167
181
  this.loadIgnoreFile(dir);
168
182
 
169
- const skills = fs.readdirSync(dir).filter(f => {
183
+ const skills = fs.readdirSync(dir).filter((f: string) => {
170
184
  const p = path.join(dir, f);
171
185
  return fs.statSync(p).isDirectory();
172
186
  });
@@ -175,19 +189,16 @@ class GuardScanner {
175
189
  console.log(`${'═'.repeat(54)}`);
176
190
  console.log(`📂 Scanning: ${dir}`);
177
191
  console.log(`📦 Skills found: ${skills.length}`);
178
- if (this.strict) console.log(`⚡ Strict mode enabled`);
192
+ if (this.strict) console.log('⚡ Strict mode enabled');
179
193
  console.log();
180
194
 
181
195
  for (const skill of skills) {
182
196
  const skillPath = path.join(dir, skill);
183
197
 
184
- // Self-exclusion
185
198
  if (this.selfExclude && path.resolve(skillPath) === this.scannerDir) {
186
199
  if (!this.summaryOnly) console.log(`⏭️ ${skill} — SELF (excluded)`);
187
200
  continue;
188
201
  }
189
-
190
- // Ignore list
191
202
  if (this.ignoredSkills.has(skill)) {
192
203
  if (!this.summaryOnly) console.log(`⏭️ ${skill} — IGNORED`);
193
204
  continue;
@@ -200,101 +211,80 @@ class GuardScanner {
200
211
  return this.findings;
201
212
  }
202
213
 
203
- scanSkill(skillPath, skillName) {
214
+ // ── Skill Scanner ─────────────────────────────────────────────────────
215
+
216
+ scanSkill(skillPath: string, skillName: string): void {
204
217
  this.stats.scanned++;
205
- const skillFindings = [];
218
+ const skillFindings: Finding[] = [];
206
219
 
207
- // Check 1: Known malicious skill name
220
+ // Check: Known typosquat
208
221
  if (KNOWN_MALICIOUS.typosquats.includes(skillName.toLowerCase())) {
209
222
  skillFindings.push({
210
223
  severity: 'CRITICAL', id: 'KNOWN_TYPOSQUAT', cat: 'malicious-code',
211
- desc: `Known malicious/typosquat skill name`,
212
- file: 'SKILL NAME', line: 0
224
+ desc: 'Known malicious/typosquat skill name', file: 'SKILL NAME',
213
225
  });
214
226
  }
215
227
 
216
- // Check 2: Scan all files
228
+ // Scan all files
217
229
  const files = this.getFiles(skillPath);
218
230
  for (const file of files) {
219
231
  const ext = path.extname(file).toLowerCase();
220
232
  const relFile = path.relative(skillPath, file);
221
233
 
222
- if (relFile.includes('node_modules/') || relFile.includes('node_modules\\')) continue;
223
- if (relFile.startsWith('.git/') || relFile.startsWith('.git\\')) continue;
234
+ if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
224
235
  if (BINARY_EXTENSIONS.has(ext)) continue;
225
236
 
226
- let content;
237
+ let content: string;
227
238
  try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
228
- if (content.length > 500000) continue;
239
+ if (content.length > 500_000) continue;
229
240
 
230
241
  const fileType = this.classifyFile(ext, relFile);
231
242
 
232
- // IoC checks
233
243
  this.checkIoCs(content, relFile, skillFindings);
234
-
235
- // Pattern checks (context-aware)
236
244
  this.checkPatterns(content, relFile, fileType, skillFindings);
245
+ this.checkSignatures(content, file, skillFindings); // NEW: hbg-scan compatible
237
246
 
238
- // Custom rules / plugins
239
247
  if (this.customRules.length > 0) {
240
248
  this.checkPatterns(content, relFile, fileType, skillFindings, this.customRules);
241
249
  }
242
250
 
243
- // Hardcoded secret detection
251
+ // Secret detection (skip lock files)
244
252
  const baseName = path.basename(relFile).toLowerCase();
245
- const skipSecretCheck = baseName.endsWith('-lock.json') || baseName === 'package-lock.json' ||
253
+ const skipSecret = baseName.endsWith('-lock.json') || baseName === 'package-lock.json' ||
246
254
  baseName === 'yarn.lock' || baseName === 'pnpm-lock.yaml' ||
247
255
  baseName === '_meta.json' || baseName === '.package-lock.json';
248
- if (fileType === 'code' && !skipSecretCheck) {
256
+ if (fileType === 'code' && !skipSecret) {
249
257
  this.checkHardcodedSecrets(content, relFile, skillFindings);
250
258
  }
251
259
 
252
- // Lightweight JS data flow analysis
253
- if ((ext === '.js' || ext === '.mjs' || ext === '.cjs' || ext === '.ts') && content.length < 200000) {
260
+ // JS data flow
261
+ if (['.js', '.mjs', '.cjs', '.ts'].includes(ext) && content.length < 200_000) {
254
262
  this.checkJSDataFlow(content, relFile, skillFindings);
255
263
  }
256
264
  }
257
265
 
258
- // Check 3: Structural checks
266
+ // Structural checks
259
267
  this.checkStructure(skillPath, skillName, skillFindings);
260
-
261
- // Check 4: Dependency chain scanning
262
- if (this.checkDeps) {
263
- this.checkDependencies(skillPath, skillName, skillFindings);
264
- }
265
-
266
- // Check 5: Hidden files detection
268
+ if (this.checkDeps) this.checkDependencies(skillPath, skillName, skillFindings);
267
269
  this.checkHiddenFiles(skillPath, skillName, skillFindings);
268
-
269
- // Check 6: Cross-file analysis
270
270
  this.checkCrossFile(skillPath, skillName, skillFindings);
271
-
272
- // Check 7: Skill manifest validation (v1.1)
273
271
  this.checkSkillManifest(skillPath, skillName, skillFindings);
274
-
275
- // Check 8: Code complexity metrics (v1.1)
276
272
  this.checkComplexity(skillPath, skillName, skillFindings);
277
-
278
- // Check 9: Config impact analysis (v1.1)
279
273
  this.checkConfigImpact(skillPath, skillName, skillFindings);
274
+ this.checkCompactionPersistence(skillPath, skillName, skillFindings); // NEW
280
275
 
281
- // Filter ignored patterns
282
- const filteredFindings = skillFindings.filter(f => !this.ignoredPatterns.has(f.id));
283
-
284
- // Calculate risk
285
- const risk = this.calculateRisk(filteredFindings);
276
+ // Filter & score
277
+ const filtered = skillFindings.filter(f => !this.ignoredPatterns.has(f.id));
278
+ const risk = this.calculateRisk(filtered);
286
279
  const verdict = this.getVerdict(risk);
287
280
 
288
281
  this.stats[verdict.stat]++;
289
282
 
290
283
  if (!this.summaryOnly) {
291
284
  console.log(`${verdict.icon} ${skillName} — ${verdict.label} (risk: ${risk})`);
292
-
293
- if (this.verbose && filteredFindings.length > 0) {
294
- const byCat = {};
295
- for (const f of filteredFindings) {
296
- (byCat[f.cat] = byCat[f.cat] || []).push(f);
297
- }
285
+ if (this.verbose && filtered.length > 0) {
286
+ const byCat: Record<string, Finding[]> = {};
287
+ for (const f of filtered) (byCat[f.cat] = byCat[f.cat] || []).push(f);
298
288
  for (const [cat, findings] of Object.entries(byCat)) {
299
289
  console.log(` 📁 ${cat}`);
300
290
  for (const f of findings) {
@@ -307,12 +297,14 @@ class GuardScanner {
307
297
  }
308
298
  }
309
299
 
310
- if (filteredFindings.length > 0) {
311
- this.findings.push({ skill: skillName, risk, verdict: verdict.label, findings: filteredFindings });
300
+ if (filtered.length > 0) {
301
+ this.findings.push({ skill: skillName, risk, verdict: verdict.label, findings: filtered });
312
302
  }
313
303
  }
314
304
 
315
- classifyFile(ext, relFile) {
305
+ // ── Check Methods ─────────────────────────────────────────────────────
306
+
307
+ private classifyFile(ext: string, relFile: string): FileType {
316
308
  if (CODE_EXTENSIONS.has(ext)) return 'code';
317
309
  if (DOC_EXTENSIONS.has(ext)) return 'doc';
318
310
  if (DATA_EXTENSIONS.has(ext)) return 'data';
@@ -321,7 +313,7 @@ class GuardScanner {
321
313
  return 'other';
322
314
  }
323
315
 
324
- checkIoCs(content, relFile, findings) {
316
+ private checkIoCs(content: string, relFile: string, findings: Finding[]): void {
325
317
  const contentLower = content.toLowerCase();
326
318
 
327
319
  for (const ip of KNOWN_MALICIOUS.ips) {
@@ -329,26 +321,22 @@ class GuardScanner {
329
321
  findings.push({ severity: 'CRITICAL', id: 'IOC_IP', cat: 'malicious-code', desc: `Known malicious IP: ${ip}`, file: relFile });
330
322
  }
331
323
  }
332
-
333
324
  for (const url of KNOWN_MALICIOUS.urls) {
334
325
  if (contentLower.includes(url.toLowerCase())) {
335
326
  findings.push({ severity: 'CRITICAL', id: 'IOC_URL', cat: 'malicious-code', desc: `Known malicious URL: ${url}`, file: relFile });
336
327
  }
337
328
  }
338
-
339
329
  for (const domain of KNOWN_MALICIOUS.domains) {
340
330
  const domainRegex = new RegExp(`(?:https?://|[\\s'"\`(]|^)${domain.replace(/\./g, '\\.')}`, 'gi');
341
331
  if (domainRegex.test(content)) {
342
332
  findings.push({ severity: 'HIGH', id: 'IOC_DOMAIN', cat: 'exfiltration', desc: `Suspicious domain: ${domain}`, file: relFile });
343
333
  }
344
334
  }
345
-
346
335
  for (const fname of KNOWN_MALICIOUS.filenames) {
347
336
  if (contentLower.includes(fname.toLowerCase())) {
348
337
  findings.push({ severity: 'CRITICAL', id: 'IOC_FILE', cat: 'suspicious-download', desc: `Known malicious filename: ${fname}`, file: relFile });
349
338
  }
350
339
  }
351
-
352
340
  for (const user of KNOWN_MALICIOUS.usernames) {
353
341
  if (contentLower.includes(user.toLowerCase())) {
354
342
  findings.push({ severity: 'HIGH', id: 'IOC_USER', cat: 'malicious-code', desc: `Known malicious username: ${user}`, file: relFile });
@@ -356,7 +344,10 @@ class GuardScanner {
356
344
  }
357
345
  }
358
346
 
359
- checkPatterns(content, relFile, fileType, findings, patterns = PATTERNS) {
347
+ private checkPatterns(
348
+ content: string, relFile: string, fileType: FileType,
349
+ findings: Finding[], patterns: PatternRule[] = PATTERNS,
350
+ ): void {
360
351
  for (const pattern of patterns) {
361
352
  if (pattern.codeOnly && fileType !== 'code') continue;
362
353
  if (pattern.docOnly && fileType !== 'doc' && fileType !== 'skill-doc') continue;
@@ -368,31 +359,119 @@ class GuardScanner {
368
359
 
369
360
  pattern.regex.lastIndex = 0;
370
361
  const idx = content.search(pattern.regex);
371
- const lineNum = idx >= 0 ? content.substring(0, idx).split('\n').length : null;
362
+ const lineNum = idx >= 0 ? content.substring(0, idx).split('\n').length : undefined;
372
363
 
373
- let adjustedSeverity = pattern.severity;
364
+ let adjustedSeverity: Severity = pattern.severity;
374
365
  if ((fileType === 'doc' || fileType === 'skill-doc') && pattern.all && !pattern.docOnly) {
375
366
  if (adjustedSeverity === 'HIGH') adjustedSeverity = 'MEDIUM';
376
367
  else if (adjustedSeverity === 'MEDIUM') adjustedSeverity = 'LOW';
377
368
  }
378
369
 
379
370
  findings.push({
380
- severity: adjustedSeverity,
381
- id: pattern.id,
382
- cat: pattern.cat,
383
- desc: pattern.desc,
384
- file: relFile,
385
- line: lineNum,
386
- matchCount: matches.length,
387
- sample: matches[0].substring(0, 80)
371
+ severity: adjustedSeverity, id: pattern.id, cat: pattern.cat,
372
+ desc: pattern.desc, file: relFile, line: lineNum,
373
+ matchCount: matches.length, sample: matches[0].substring(0, 80),
388
374
  });
389
375
  }
390
376
  }
391
377
 
392
- // Entropy-based secret detection
393
- checkHardcodedSecrets(content, relFile, findings) {
378
+ /** NEW: hbg-scan compatible signature matching (hash + pattern + domain) */
379
+ private checkSignatures(content: string, filePath: string, findings: Finding[]): void {
380
+ const contentHash = crypto.createHash('sha256').update(content).digest('hex');
381
+ const relFile = path.basename(filePath);
382
+
383
+ for (const sig of SIGNATURES_DB.signatures) {
384
+ // Hash match
385
+ if (sig.hash && sig.hash === contentHash) {
386
+ findings.push({
387
+ severity: sig.severity, id: `SIG_${sig.id}`, cat: 'signature-match',
388
+ desc: `[${sig.id}] ${sig.name} — exact hash match`, file: relFile,
389
+ });
390
+ continue;
391
+ }
392
+
393
+ // Pattern match
394
+ if (sig.patterns) {
395
+ for (const pat of sig.patterns) {
396
+ if (content.includes(pat)) {
397
+ const idx = content.indexOf(pat);
398
+ const lineNum = content.substring(0, idx).split('\n').length;
399
+ findings.push({
400
+ severity: sig.severity, id: `SIG_${sig.id}`, cat: 'signature-match',
401
+ desc: `[${sig.id}] ${sig.name}`, file: relFile, line: lineNum,
402
+ sample: content.split('\n')[lineNum - 1]?.trim().substring(0, 120),
403
+ });
404
+ break; // One finding per sig per file
405
+ }
406
+ }
407
+ }
408
+
409
+ // Domain match
410
+ if (sig.domains) {
411
+ for (const domain of sig.domains) {
412
+ if (content.includes(domain)) {
413
+ const idx = content.indexOf(domain);
414
+ const lineNum = content.substring(0, idx).split('\n').length;
415
+ findings.push({
416
+ severity: sig.severity, id: `SIG_${sig.id}`, cat: 'signature-match',
417
+ desc: `[${sig.id}] Suspicious domain: ${domain}`, file: relFile, line: lineNum,
418
+ });
419
+ }
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ /** NEW: Compaction Layer Persistence check (hbg-scan Check 5) */
426
+ private checkCompactionPersistence(
427
+ skillPath: string, skillName: string, findings: Finding[],
428
+ ): void {
429
+ const files = this.getFiles(skillPath);
430
+ for (const file of files) {
431
+ const ext = path.extname(file).toLowerCase();
432
+ if (BINARY_EXTENSIONS.has(ext)) continue;
433
+ const relFile = path.relative(skillPath, file);
434
+ if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
435
+
436
+ let content: string;
437
+ try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
438
+ if (content.length > 500_000) continue;
439
+
440
+ // Post-compaction audit patterns
441
+ const compactionPatterns: Array<{ regex: RegExp; label: string; severity: Severity }> = [
442
+ { regex: /post-?compaction\s+audit/gi, label: 'Post-compaction audit trigger', severity: 'CRITICAL' },
443
+ { regex: /WORKFLOW_AUTO/g, label: 'WORKFLOW_AUTO marker', severity: 'CRITICAL' },
444
+ { regex: /⚠️\s*post-?compaction/gi, label: 'Post-compaction emoji warning', severity: 'CRITICAL' },
445
+ { regex: /after\s+compaction/gi, label: 'After-compaction trigger', severity: 'HIGH' },
446
+ { regex: /survive\s+compaction/gi, label: 'Compaction survival pattern', severity: 'HIGH' },
447
+ { regex: /HEARTBEAT\.md/g, label: 'HEARTBEAT.md reference', severity: 'HIGH' },
448
+ { regex: /BOOTSTRAP\.md/g, label: 'BOOTSTRAP.md reference', severity: 'HIGH' },
449
+ { regex: /persistent\s+instructions/gi, label: 'Persistent instructions pattern', severity: 'HIGH' },
450
+ { regex: /setTimeout\s*\([^)]*(?:86400|604800|2592000)/g, label: 'Very long timer delay (persistence)', severity: 'MEDIUM' },
451
+ ];
452
+
453
+ for (const pat of compactionPatterns) {
454
+ pat.regex.lastIndex = 0;
455
+ const match = pat.regex.exec(content);
456
+ if (match) {
457
+ const lineNum = content.substring(0, match.index).split('\n').length;
458
+ findings.push({
459
+ severity: pat.severity,
460
+ id: 'COMPACTION_PERSISTENCE',
461
+ cat: 'compaction-persistence',
462
+ desc: pat.label,
463
+ file: relFile,
464
+ line: lineNum,
465
+ sample: content.split('\n')[lineNum - 1]?.trim().substring(0, 80),
466
+ });
467
+ }
468
+ }
469
+ }
470
+ }
471
+
472
+ private checkHardcodedSecrets(content: string, relFile: string, findings: Finding[]): void {
394
473
  const assignmentRegex = /(?:api[_-]?key|secret|token|password|credential|auth)\s*[:=]\s*['"]([a-zA-Z0-9_\-+/=]{16,})['"]|['"]([a-zA-Z0-9_\-+/=]{32,})['"]/gi;
395
- let match;
474
+ let match: RegExpExecArray | null;
396
475
  while ((match = assignmentRegex.exec(content)) !== null) {
397
476
  const value = match[1] || match[2];
398
477
  if (!value) continue;
@@ -410,14 +489,14 @@ class GuardScanner {
410
489
  severity: 'HIGH', id: 'SECRET_ENTROPY', cat: 'secret-detection',
411
490
  desc: `High-entropy string (possible leaked secret, entropy=${entropy.toFixed(1)})`,
412
491
  file: relFile, line: lineNum,
413
- sample: value.substring(0, 8) + '...' + value.substring(value.length - 4)
492
+ sample: value.substring(0, 8) + '...' + value.substring(value.length - 4),
414
493
  });
415
494
  }
416
495
  }
417
496
  }
418
497
 
419
- shannonEntropy(str) {
420
- const freq = {};
498
+ private shannonEntropy(str: string): number {
499
+ const freq: Record<string, number> = {};
421
500
  for (const c of str) freq[c] = (freq[c] || 0) + 1;
422
501
  const len = str.length;
423
502
  let entropy = 0;
@@ -428,7 +507,7 @@ class GuardScanner {
428
507
  return entropy;
429
508
  }
430
509
 
431
- checkStructure(skillPath, skillName, findings) {
510
+ private checkStructure(skillPath: string, skillName: string, findings: Finding[]): void {
432
511
  const skillMd = path.join(skillPath, 'SKILL.md');
433
512
  if (!fs.existsSync(skillMd)) {
434
513
  findings.push({ severity: 'LOW', id: 'STRUCT_NO_SKILLMD', cat: 'structural', desc: 'No SKILL.md found', file: skillName });
@@ -440,21 +519,21 @@ class GuardScanner {
440
519
  }
441
520
  const scriptsDir = path.join(skillPath, 'scripts');
442
521
  if (fs.existsSync(scriptsDir)) {
443
- const scripts = fs.readdirSync(scriptsDir).filter(f => CODE_EXTENSIONS.has(path.extname(f).toLowerCase()));
522
+ const scripts = fs.readdirSync(scriptsDir).filter((f: string) => CODE_EXTENSIONS.has(path.extname(f).toLowerCase()));
444
523
  if (scripts.length > 0 && !content.includes('scripts/')) {
445
524
  findings.push({ severity: 'MEDIUM', id: 'STRUCT_UNDOCUMENTED_SCRIPTS', cat: 'structural', desc: `${scripts.length} script(s) in scripts/ not referenced in SKILL.md`, file: 'scripts/' });
446
525
  }
447
526
  }
448
527
  }
449
528
 
450
- checkDependencies(skillPath, skillName, findings) {
529
+ private checkDependencies(skillPath: string, skillName: string, findings: Finding[]): void {
451
530
  const pkgPath = path.join(skillPath, 'package.json');
452
531
  if (!fs.existsSync(pkgPath)) return;
453
532
 
454
- let pkg;
533
+ let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string>; optionalDependencies?: Record<string, string>; scripts?: Record<string, string> };
455
534
  try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); } catch { return; }
456
535
 
457
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies };
536
+ const allDeps: Record<string, string> = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies };
458
537
 
459
538
  const RISKY_PACKAGES = new Set([
460
539
  'node-ipc', 'colors', 'faker', 'event-stream', 'ua-parser-js', 'coa', 'rc',
@@ -475,8 +554,8 @@ class GuardScanner {
475
554
  const RISKY_SCRIPTS = ['preinstall', 'postinstall', 'preuninstall', 'postuninstall', 'prepare'];
476
555
  if (pkg.scripts) {
477
556
  for (const scriptName of RISKY_SCRIPTS) {
478
- if (pkg.scripts[scriptName]) {
479
- const cmd = pkg.scripts[scriptName];
557
+ const cmd = pkg.scripts[scriptName];
558
+ if (cmd) {
480
559
  findings.push({ severity: 'HIGH', id: 'DEP_LIFECYCLE', cat: 'dependency-chain', desc: `Lifecycle script "${scriptName}": ${cmd.substring(0, 80)}`, file: 'package.json' });
481
560
  if (/curl|wget|node\s+-e|eval|exec|bash\s+-c/i.test(cmd)) {
482
561
  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) });
@@ -486,227 +565,137 @@ class GuardScanner {
486
565
  }
487
566
  }
488
567
 
489
- // ── v1.1: Skill Manifest Validation ──
490
- // Checks SKILL.md frontmatter for dangerous tool declarations,
491
- // overly broad file scope, and sensitive env requirements
492
- checkSkillManifest(skillPath, skillName, findings) {
568
+ private checkSkillManifest(skillPath: string, skillName: string, findings: Finding[]): void {
493
569
  const skillMd = path.join(skillPath, 'SKILL.md');
494
570
  if (!fs.existsSync(skillMd)) return;
495
-
496
- let content;
571
+ let content: string;
497
572
  try { content = fs.readFileSync(skillMd, 'utf-8'); } catch { return; }
498
573
 
499
- // Parse YAML frontmatter (lightweight, no dependency)
500
574
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
501
575
  if (!fmMatch) return;
502
576
  const fm = fmMatch[1];
503
577
 
504
- // Check 1: Dangerous binary requirements
505
578
  const DANGEROUS_BINS = new Set([
506
579
  'sudo', 'rm', 'rmdir', 'chmod', 'chown', 'kill', 'pkill',
507
580
  'curl', 'wget', 'nc', 'ncat', 'socat', 'ssh', 'scp',
508
581
  'dd', 'mkfs', 'fdisk', 'mount', 'umount',
509
- 'iptables', 'ufw', 'firewall-cmd',
510
- 'docker', 'kubectl', 'systemctl',
582
+ 'iptables', 'ufw', 'firewall-cmd', 'docker', 'kubectl', 'systemctl',
511
583
  ]);
584
+
512
585
  const binsMatch = fm.match(/bins:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
513
586
  if (binsMatch) {
514
587
  const bins = binsMatch[1].match(/- ([^\n]+)/g) || [];
515
588
  for (const binLine of bins) {
516
589
  const bin = binLine.replace(/^-\s*/, '').trim().toLowerCase();
517
590
  if (DANGEROUS_BINS.has(bin)) {
518
- findings.push({
519
- severity: 'HIGH', id: 'MANIFEST_DANGEROUS_BIN',
520
- cat: 'sandbox-validation',
521
- desc: `SKILL.md requires dangerous binary: ${bin}`,
522
- file: 'SKILL.md'
523
- });
591
+ findings.push({ severity: 'HIGH', id: 'MANIFEST_DANGEROUS_BIN', cat: 'sandbox-validation', desc: `SKILL.md requires dangerous binary: ${bin}`, file: 'SKILL.md' });
524
592
  }
525
593
  }
526
594
  }
527
595
 
528
- // Check 2: Overly broad file scope
529
596
  const filesMatch = fm.match(/files:\s*\[([^\]]+)\]/i) || fm.match(/files:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
530
- if (filesMatch) {
531
- const filesStr = filesMatch[1];
532
- if (/\*\*\/\*|\*\.\*|\"\*\"/i.test(filesStr)) {
533
- findings.push({
534
- severity: 'HIGH', id: 'MANIFEST_BROAD_FILES',
535
- cat: 'sandbox-validation',
536
- desc: 'SKILL.md declares overly broad file scope (e.g. **/*)',
537
- file: 'SKILL.md'
538
- });
539
- }
597
+ if (filesMatch && /\*\*\/\*|\*\.\*|"\*"/i.test(filesMatch[1])) {
598
+ findings.push({ severity: 'HIGH', id: 'MANIFEST_BROAD_FILES', cat: 'sandbox-validation', desc: 'SKILL.md declares overly broad file scope (e.g. **/*)', file: 'SKILL.md' });
540
599
  }
541
600
 
542
- // Check 3: Sensitive env requirements
543
- const SENSITIVE_ENV_PATTERNS = /(?:SECRET|PASSWORD|CREDENTIAL|PRIVATE_KEY|AWS_SECRET|GITHUB_TOKEN)/i;
601
+ const SENSITIVE_ENV = /(?:SECRET|PASSWORD|CREDENTIAL|PRIVATE_KEY|AWS_SECRET|GITHUB_TOKEN)/i;
544
602
  const envMatch = fm.match(/env:\s*\n((?:\s+-\s+[^\n]+\n?)*)/i);
545
603
  if (envMatch) {
546
604
  const envVars = envMatch[1].match(/- ([^\n]+)/g) || [];
547
605
  for (const envLine of envVars) {
548
606
  const envVar = envLine.replace(/^-\s*/, '').trim();
549
- if (SENSITIVE_ENV_PATTERNS.test(envVar)) {
550
- findings.push({
551
- severity: 'HIGH', id: 'MANIFEST_SENSITIVE_ENV',
552
- cat: 'sandbox-validation',
553
- desc: `SKILL.md requires sensitive env var: ${envVar}`,
554
- file: 'SKILL.md'
555
- });
607
+ if (SENSITIVE_ENV.test(envVar)) {
608
+ findings.push({ severity: 'HIGH', id: 'MANIFEST_SENSITIVE_ENV', cat: 'sandbox-validation', desc: `SKILL.md requires sensitive env var: ${envVar}`, file: 'SKILL.md' });
556
609
  }
557
610
  }
558
611
  }
559
612
 
560
- // Check 4: exec or network declared without justification
561
613
  if (/exec:\s*(?:true|yes|enabled|'\*'|"\*")/i.test(fm)) {
562
- findings.push({
563
- severity: 'MEDIUM', id: 'MANIFEST_EXEC_DECLARED',
564
- cat: 'sandbox-validation',
565
- desc: 'SKILL.md declares exec capability',
566
- file: 'SKILL.md'
567
- });
614
+ findings.push({ severity: 'MEDIUM', id: 'MANIFEST_EXEC_DECLARED', cat: 'sandbox-validation', desc: 'SKILL.md declares exec capability', file: 'SKILL.md' });
568
615
  }
569
616
  if (/network:\s*(?:true|yes|enabled|'\*'|"\*"|all|any)/i.test(fm)) {
570
- findings.push({
571
- severity: 'MEDIUM', id: 'MANIFEST_NETWORK_DECLARED',
572
- cat: 'sandbox-validation',
573
- desc: 'SKILL.md declares unrestricted network access',
574
- file: 'SKILL.md'
575
- });
617
+ findings.push({ severity: 'MEDIUM', id: 'MANIFEST_NETWORK_DECLARED', cat: 'sandbox-validation', desc: 'SKILL.md declares unrestricted network access', file: 'SKILL.md' });
576
618
  }
577
619
  }
578
620
 
579
- // ── v1.1: Code Complexity Metrics ──
580
- // Detects excessive file length, deep nesting, and eval/exec density
581
- checkComplexity(skillPath, skillName, findings) {
621
+ private checkComplexity(skillPath: string, skillName: string, findings: Finding[]): void {
582
622
  const files = this.getFiles(skillPath);
583
623
  const MAX_LINES = 1000;
584
624
  const MAX_NESTING = 5;
585
- const MAX_EVAL_DENSITY = 0.02; // 2% of lines
625
+ const MAX_EVAL_DENSITY = 0.02;
586
626
 
587
627
  for (const file of files) {
588
628
  const ext = path.extname(file).toLowerCase();
589
629
  if (!CODE_EXTENSIONS.has(ext)) continue;
590
-
591
630
  const relFile = path.relative(skillPath, file);
592
631
  if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
593
632
 
594
- let content;
633
+ let content: string;
595
634
  try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
596
-
597
635
  const lines = content.split('\n');
598
636
 
599
- // Check 1: Excessive file length
600
637
  if (lines.length > MAX_LINES) {
601
- findings.push({
602
- severity: 'MEDIUM', id: 'COMPLEXITY_LONG_FILE',
603
- cat: 'complexity',
604
- desc: `File exceeds ${MAX_LINES} lines (${lines.length} lines)`,
605
- file: relFile
606
- });
638
+ findings.push({ severity: 'MEDIUM', id: 'COMPLEXITY_LONG_FILE', cat: 'complexity', desc: `File exceeds ${MAX_LINES} lines (${lines.length} lines)`, file: relFile });
607
639
  }
608
640
 
609
- // Check 2: Deep nesting (brace tracking)
610
- let maxDepth = 0;
611
- let currentDepth = 0;
612
- let deepestLine = 0;
641
+ let maxDepth = 0, currentDepth = 0, deepestLine = 0;
613
642
  for (let i = 0; i < lines.length; i++) {
614
- const line = lines[i];
615
- // Count opening/closing braces outside strings (simplified)
616
- for (const ch of line) {
643
+ for (const ch of lines[i]) {
617
644
  if (ch === '{') currentDepth++;
618
645
  if (ch === '}') currentDepth = Math.max(0, currentDepth - 1);
619
646
  }
620
- if (currentDepth > maxDepth) {
621
- maxDepth = currentDepth;
622
- deepestLine = i + 1;
623
- }
647
+ if (currentDepth > maxDepth) { maxDepth = currentDepth; deepestLine = i + 1; }
624
648
  }
625
649
  if (maxDepth > MAX_NESTING) {
626
- findings.push({
627
- severity: 'MEDIUM', id: 'COMPLEXITY_DEEP_NESTING',
628
- cat: 'complexity',
629
- desc: `Deep nesting detected: ${maxDepth} levels (max recommended: ${MAX_NESTING})`,
630
- file: relFile, line: deepestLine
631
- });
650
+ findings.push({ severity: 'MEDIUM', id: 'COMPLEXITY_DEEP_NESTING', cat: 'complexity', desc: `Deep nesting: ${maxDepth} levels (max: ${MAX_NESTING})`, file: relFile, line: deepestLine });
632
651
  }
633
652
 
634
- // Check 3: eval/exec density
635
- const evalPattern = /\b(?:eval|exec|execSync|spawn|Function)\s*\(/g;
636
- const evalMatches = content.match(evalPattern) || [];
653
+ const evalMatches = content.match(/\b(?:eval|exec|execSync|spawn|Function)\s*\(/g) || [];
637
654
  const density = lines.length > 0 ? evalMatches.length / lines.length : 0;
638
655
  if (density > MAX_EVAL_DENSITY && evalMatches.length >= 3) {
639
- findings.push({
640
- severity: 'HIGH', id: 'COMPLEXITY_EVAL_DENSITY',
641
- cat: 'complexity',
642
- desc: `High eval/exec density: ${evalMatches.length} calls in ${lines.length} lines (${(density * 100).toFixed(1)}%)`,
643
- file: relFile
644
- });
656
+ 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 });
645
657
  }
646
658
  }
647
659
  }
648
660
 
649
- // ── v1.1: Config Impact Analysis ──
650
- // Detects modifications to openclaw.json and dangerous configuration changes
651
- checkConfigImpact(skillPath, skillName, findings) {
661
+ private checkConfigImpact(skillPath: string, skillName: string, findings: Finding[]): void {
652
662
  const files = this.getFiles(skillPath);
653
-
654
663
  for (const file of files) {
655
664
  const ext = path.extname(file).toLowerCase();
656
665
  if (!CODE_EXTENSIONS.has(ext) && ext !== '.json') continue;
657
-
658
666
  const relFile = path.relative(skillPath, file);
659
667
  if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
660
668
 
661
- let content;
669
+ let content: string;
662
670
  try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
663
671
 
664
- // Check 1: openclaw.json reference + write operation in same file
665
- // Handles both direct and variable-based patterns (e.g. writeFileSync(configPath))
666
672
  const hasConfigRef = /openclaw\.json/i.test(content);
667
673
  const hasWriteOp = /(?:writeFileSync|writeFile|fs\.write)\s*\(/i.test(content);
668
674
  if (hasConfigRef && hasWriteOp) {
669
- // Find the write line for location info
670
675
  const clines = content.split('\n');
671
676
  let writeLine = 0;
672
677
  for (let i = 0; i < clines.length; i++) {
673
- if (/(?:writeFileSync|writeFile|fs\.write)\s*\(/i.test(clines[i])) {
674
- writeLine = i + 1;
675
- break;
676
- }
678
+ if (/(?:writeFileSync|writeFile|fs\.write)\s*\(/i.test(clines[i])) { writeLine = i + 1; break; }
677
679
  }
678
- findings.push({
679
- severity: 'CRITICAL', id: 'CFG_WRITE_DETECTED',
680
- cat: 'config-impact',
681
- desc: 'Code writes to openclaw.json',
682
- file: relFile, line: writeLine,
683
- sample: writeLine > 0 ? clines[writeLine - 1].trim().substring(0, 80) : ''
684
- });
680
+ findings.push({ severity: 'CRITICAL', id: 'CFG_WRITE_DETECTED', cat: 'config-impact', desc: 'Code writes to openclaw.json', file: relFile, line: writeLine });
685
681
  }
686
682
 
687
- // Check 2: Dangerous config key modifications
688
- const DANGEROUS_CONFIG_KEYS = [
689
- { regex: /exec\.approvals?\s*[:=]\s*['"]?(off|false|disabled|none)/gi, id: 'CFG_EXEC_APPROVAL_OFF', desc: 'Disables exec approval requirement', severity: 'CRITICAL' },
690
- { regex: /tools\.exec\.host\s*[:=]\s*['"]gateway['"]/gi, id: 'CFG_EXEC_HOST_GATEWAY', desc: 'Sets exec host to gateway (bypasses sandbox)', severity: 'CRITICAL' },
691
- { regex: /hooks\s*\.\s*internal\s*\.\s*entries\s*[:=]/gi, id: 'CFG_HOOKS_INTERNAL', desc: 'Modifies internal hook entries', severity: 'HIGH' },
692
- { regex: /network\.allowedDomains\s*[:=]\s*\[?\s*['"]\*['"]/gi, id: 'CFG_NET_WILDCARD', desc: 'Sets network allowedDomains to wildcard', severity: 'HIGH' },
683
+ const DANGEROUS_CFG = [
684
+ { regex: /exec\.approvals?\s*[:=]\s*['"]?(off|false|disabled|none)/gi, id: 'CFG_EXEC_APPROVAL_OFF', desc: 'Disables exec approval', severity: 'CRITICAL' as Severity },
685
+ { regex: /tools\.exec\.host\s*[:=]\s*['"]gateway['"]/gi, id: 'CFG_EXEC_HOST_GATEWAY', desc: 'Sets exec host to gateway', severity: 'CRITICAL' as Severity },
686
+ { regex: /hooks\s*\.\s*internal\s*\.\s*entries\s*[:=]/gi, id: 'CFG_HOOKS_INTERNAL', desc: 'Modifies internal hooks', severity: 'HIGH' as Severity },
687
+ { regex: /network\.allowedDomains\s*[:=]\s*\[?\s*['"]\*['"]/gi, id: 'CFG_NET_WILDCARD', desc: 'Sets network domains to wildcard', severity: 'HIGH' as Severity },
693
688
  ];
694
-
695
- for (const check of DANGEROUS_CONFIG_KEYS) {
689
+ for (const check of DANGEROUS_CFG) {
696
690
  check.regex.lastIndex = 0;
697
691
  if (check.regex.test(content)) {
698
- findings.push({
699
- severity: check.severity, id: check.id,
700
- cat: 'config-impact',
701
- desc: check.desc,
702
- file: relFile
703
- });
692
+ findings.push({ severity: check.severity, id: check.id, cat: 'config-impact', desc: check.desc, file: relFile });
704
693
  }
705
694
  }
706
695
  }
707
696
  }
708
697
 
709
- checkHiddenFiles(skillPath, skillName, findings) {
698
+ private checkHiddenFiles(skillPath: string, skillName: string, findings: Finding[]): void {
710
699
  try {
711
700
  const entries = fs.readdirSync(skillPath);
712
701
  for (const entry of entries) {
@@ -723,23 +712,23 @@ class GuardScanner {
723
712
  }
724
713
  }
725
714
  }
726
- } catch { }
715
+ } catch { /* empty */ }
727
716
  }
728
717
 
729
- checkJSDataFlow(content, relFile, findings) {
718
+ private checkJSDataFlow(content: string, relFile: string, findings: Finding[]): void {
730
719
  const lines = content.split('\n');
731
- const imports = new Map();
732
- const sensitiveReads = [];
733
- const networkCalls = [];
734
- const execCalls = [];
720
+ const imports = new Map<string, string>();
721
+ const sensitiveReads: Array<{ line: number; text: string }> = [];
722
+ const networkCalls: Array<{ line: number; text: string }> = [];
723
+ const execCalls: Array<{ line: number; text: string }> = [];
735
724
 
736
725
  for (let i = 0; i < lines.length; i++) {
737
726
  const line = lines[i];
738
727
  const lineNum = i + 1;
739
728
 
740
- const reqMatch = line.match(/(?:const|let|var)\s+(?:{[^}]+}|\w+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
729
+ const reqMatch = line.match(/(?:const|let|var)\s+(?:\{[^}]+\}|\w+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
741
730
  if (reqMatch) {
742
- const varMatch = line.match(/(?:const|let|var)\s+({[^}]+}|\w+)/);
731
+ const varMatch = line.match(/(?:const|let|var)\s+(\{[^}]+\}|\w+)/);
743
732
  if (varMatch) imports.set(varMatch[1].trim(), reqMatch[1]);
744
733
  }
745
734
 
@@ -749,38 +738,24 @@ class GuardScanner {
749
738
  if (/process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i.test(line)) {
750
739
  sensitiveReads.push({ line: lineNum, text: line.trim() });
751
740
  }
752
-
753
- if (/(?:fetch|axios|request|http\.request|https\.request|got)\s*\(/i.test(line) ||
754
- /\.post\s*\(|\.put\s*\(|\.patch\s*\(/i.test(line)) {
741
+ if (/(?:fetch|axios|request|http\.request|https\.request|got)\s*\(/i.test(line) || /\.post\s*\(|\.put\s*\(|\.patch\s*\(/i.test(line)) {
755
742
  networkCalls.push({ line: lineNum, text: line.trim() });
756
743
  }
757
-
758
744
  if (/(?:exec|execSync|spawn|spawnSync|execFile)\s*\(/i.test(line)) {
759
745
  execCalls.push({ line: lineNum, text: line.trim() });
760
746
  }
761
747
  }
762
748
 
763
749
  if (sensitiveReads.length > 0 && networkCalls.length > 0) {
764
- findings.push({
765
- severity: 'CRITICAL', id: 'AST_CRED_TO_NET', cat: 'data-flow',
766
- desc: `Data flow: secret read (L${sensitiveReads[0].line}) → network call (L${networkCalls[0].line})`,
767
- file: relFile, line: sensitiveReads[0].line,
768
- sample: sensitiveReads[0].text.substring(0, 60)
769
- });
750
+ 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) });
770
751
  }
771
-
772
752
  if (sensitiveReads.length > 0 && execCalls.length > 0) {
773
- findings.push({
774
- severity: 'HIGH', id: 'AST_CRED_TO_EXEC', cat: 'data-flow',
775
- desc: `Data flow: secret read (L${sensitiveReads[0].line}) → command exec (L${execCalls[0].line})`,
776
- file: relFile, line: sensitiveReads[0].line,
777
- sample: sensitiveReads[0].text.substring(0, 60)
778
- });
753
+ 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) });
779
754
  }
780
755
 
781
756
  const importedModules = new Set([...imports.values()]);
782
757
  if (importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http') || importedModules.has('node-fetch'))) {
783
- findings.push({ severity: 'HIGH', id: 'AST_SUSPICIOUS_IMPORTS', cat: 'data-flow', desc: 'Suspicious import combination: child_process + network module', file: relFile });
758
+ findings.push({ severity: 'HIGH', id: 'AST_SUSPICIOUS_IMPORTS', cat: 'data-flow', desc: 'Suspicious: child_process + network module', file: relFile });
784
759
  }
785
760
  if (importedModules.has('fs') && importedModules.has('child_process') && (importedModules.has('https') || importedModules.has('http'))) {
786
761
  findings.push({ severity: 'CRITICAL', id: 'AST_EXFIL_TRIFECTA', cat: 'data-flow', desc: 'Exfiltration trifecta: fs + child_process + network', file: relFile });
@@ -788,16 +763,15 @@ class GuardScanner {
788
763
 
789
764
  for (let i = 0; i < lines.length; i++) {
790
765
  const line = lines[i];
791
- if (/`[^`]*\$\{.*(?:env|key|token|secret|password).*\}[^`]*`\s*(?:\)|,)/i.test(line) &&
792
- /(?:fetch|request|axios|http|url)/i.test(line)) {
793
- 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) });
766
+ if (/`[^`]*\$\{.*(?:env|key|token|secret|password).*\}[^`]*`\s*(?:\)|,)/i.test(line) && /(?:fetch|request|axios|http|url)/i.test(line)) {
767
+ 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) });
794
768
  }
795
769
  }
796
770
  }
797
771
 
798
- checkCrossFile(skillPath, skillName, findings) {
772
+ private checkCrossFile(skillPath: string, skillName: string, findings: Finding[]): void {
799
773
  const files = this.getFiles(skillPath);
800
- const allContent = {};
774
+ const allContent: Record<string, string> = {};
801
775
 
802
776
  for (const file of files) {
803
777
  const ext = path.extname(file).toLowerCase();
@@ -806,39 +780,41 @@ class GuardScanner {
806
780
  if (relFile.includes('node_modules') || relFile.startsWith('.git')) continue;
807
781
  try {
808
782
  const content = fs.readFileSync(file, 'utf-8');
809
- if (content.length < 500000) allContent[relFile] = content;
810
- } catch { }
783
+ if (content.length < 500_000) allContent[relFile] = content;
784
+ } catch { /* empty */ }
811
785
  }
812
786
 
813
787
  const skillMd = allContent['SKILL.md'] || '';
814
- const codeFileRefs = skillMd.match(/(?:scripts?\/|\.\/)[a-zA-Z0-9_\-./]+\.(js|py|sh|ts)/gi) || [];
788
+ const codeFileRefs = skillMd.match(/(?:scripts?\/|\.\/)[a-zA-Z0-9_\-.\/]+\.(js|py|sh|ts)/gi) || [];
815
789
  for (const ref of codeFileRefs) {
816
790
  const cleanRef = ref.replace(/^\.\//, '');
817
791
  if (!allContent[cleanRef] && !files.some(f => path.relative(skillPath, f) === cleanRef)) {
818
- findings.push({ severity: 'MEDIUM', id: 'XFILE_PHANTOM_REF', cat: 'structural', desc: `SKILL.md references non-existent file: ${cleanRef}`, file: 'SKILL.md' });
792
+ findings.push({ severity: 'MEDIUM', id: 'XFILE_PHANTOM_REF', cat: 'structural', desc: `SKILL.md references non-existent: ${cleanRef}`, file: 'SKILL.md' });
819
793
  }
820
794
  }
821
795
 
822
- const base64Fragments = [];
796
+ const b64Fragments: Array<{ file: string; fragment: string }> = [];
823
797
  for (const [file, content] of Object.entries(allContent)) {
824
798
  const matches = content.match(/[A-Za-z0-9+/]{20,}={0,2}/g) || [];
825
799
  for (const m of matches) {
826
- if (m.length > 40) base64Fragments.push({ file, fragment: m.substring(0, 30) });
800
+ if (m.length > 40) b64Fragments.push({ file, fragment: m.substring(0, 30) });
827
801
  }
828
802
  }
829
- if (base64Fragments.length > 3 && new Set(base64Fragments.map(f => f.file)).size > 1) {
830
- 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 });
803
+ if (b64Fragments.length > 3 && new Set(b64Fragments.map(f => f.file)).size > 1) {
804
+ 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 });
831
805
  }
832
806
 
833
807
  if (/(?:read|load|source|import)\s+(?:the\s+)?(?:script|file|code)\s+(?:from|at|in)\s+(?:scripts?\/)/gi.test(skillMd)) {
834
808
  const hasExec = Object.values(allContent).some(c => /(?:eval|exec|spawn)\s*\(/i.test(c));
835
809
  if (hasExec) {
836
- findings.push({ severity: 'MEDIUM', id: 'XFILE_LOAD_EXEC', cat: 'data-flow', desc: 'SKILL.md references script files that contain exec/eval', file: 'SKILL.md' });
810
+ findings.push({ severity: 'MEDIUM', id: 'XFILE_LOAD_EXEC', cat: 'data-flow', desc: 'SKILL.md references scripts with exec/eval', file: 'SKILL.md' });
837
811
  }
838
812
  }
839
813
  }
840
814
 
841
- calculateRisk(findings) {
815
+ // ── Risk Scoring ──────────────────────────────────────────────────────
816
+
817
+ private calculateRisk(findings: Finding[]): number {
842
818
  if (findings.length === 0) return 0;
843
819
 
844
820
  let score = 0;
@@ -849,6 +825,7 @@ class GuardScanner {
849
825
  const ids = new Set(findings.map(f => f.id));
850
826
  const cats = new Set(findings.map(f => f.cat));
851
827
 
828
+ // Amplifiers
852
829
  if (cats.has('credential-handling') && cats.has('exfiltration')) score = Math.round(score * 2);
853
830
  if (cats.has('credential-handling') && findings.some(f => f.id === 'MAL_CHILD' || f.id === 'MAL_EXEC')) score = Math.round(score * 1.5);
854
831
  if (cats.has('obfuscation') && (cats.has('malicious-code') || cats.has('credential-handling'))) score = Math.round(score * 2);
@@ -863,23 +840,35 @@ class GuardScanner {
863
840
  if (cats.has('identity-hijack') && (cats.has('persistence') || cats.has('memory-poisoning'))) score = Math.max(score, 90);
864
841
  if (ids.has('IOC_IP') || ids.has('IOC_URL') || ids.has('KNOWN_TYPOSQUAT')) score = 100;
865
842
 
866
- // v1.1 categories
843
+ // v1.1
867
844
  if (cats.has('config-impact')) score = Math.round(score * 2);
868
845
  if (cats.has('config-impact') && cats.has('sandbox-validation')) score = Math.max(score, 70);
869
846
  if (cats.has('complexity') && (cats.has('malicious-code') || cats.has('obfuscation'))) score = Math.round(score * 1.5);
870
847
 
848
+ // v2.1 PII
849
+ if (cats.has('pii-exposure') && cats.has('exfiltration')) score = Math.round(score * 3);
850
+ if (cats.has('pii-exposure') && (ids.has('SHADOW_AI_OPENAI') || ids.has('SHADOW_AI_ANTHROPIC') || ids.has('SHADOW_AI_GENERIC'))) score = Math.round(score * 2.5);
851
+ if (cats.has('pii-exposure') && cats.has('credential-handling')) score = Math.round(score * 2);
852
+
853
+ // v3.0 Compaction persistence
854
+ if (cats.has('compaction-persistence')) score = Math.round(score * 2);
855
+ if (cats.has('compaction-persistence') && cats.has('prompt-injection')) score = Math.max(score, 90);
856
+ if (cats.has('signature-match')) score = Math.max(score, 70);
857
+
871
858
  return Math.min(100, score);
872
859
  }
873
860
 
874
- getVerdict(risk) {
861
+ private getVerdict(risk: number): Verdict {
875
862
  if (risk >= this.thresholds.malicious) return { icon: '🔴', label: 'MALICIOUS', stat: 'malicious' };
876
863
  if (risk >= this.thresholds.suspicious) return { icon: '🟡', label: 'SUSPICIOUS', stat: 'suspicious' };
877
864
  if (risk > 0) return { icon: '🟢', label: 'LOW RISK', stat: 'low' };
878
865
  return { icon: '🟢', label: 'CLEAN', stat: 'clean' };
879
866
  }
880
867
 
881
- getFiles(dir) {
882
- const results = [];
868
+ // ── File Discovery ────────────────────────────────────────────────────
869
+
870
+ private getFiles(dir: string): string[] {
871
+ const results: string[] = [];
883
872
  try {
884
873
  const entries = fs.readdirSync(dir, { withFileTypes: true });
885
874
  for (const entry of entries) {
@@ -888,16 +877,17 @@ class GuardScanner {
888
877
  if (entry.name === '.git' || entry.name === 'node_modules') continue;
889
878
  results.push(...this.getFiles(fullPath));
890
879
  } else {
891
- const baseName = entry.name.toLowerCase();
892
- if (GENERATED_REPORT_FILES.has(baseName)) continue;
880
+ if (GENERATED_REPORT_FILES.has(entry.name.toLowerCase())) continue;
893
881
  results.push(fullPath);
894
882
  }
895
883
  }
896
- } catch { }
884
+ } catch { /* empty */ }
897
885
  return results;
898
886
  }
899
887
 
900
- printSummary() {
888
+ // ── Output ────────────────────────────────────────────────────────────
889
+
890
+ printSummary(): void {
901
891
  const total = this.stats.scanned;
902
892
  const safe = this.stats.clean + this.stats.low;
903
893
  console.log(`\n${'═'.repeat(54)}`);
@@ -913,38 +903,35 @@ class GuardScanner {
913
903
 
914
904
  if (this.stats.malicious > 0) {
915
905
  console.log(`\n⚠️ CRITICAL: ${this.stats.malicious} malicious skill(s) detected!`);
916
- console.log(` Review findings with --verbose and remove if confirmed.`);
917
906
  } else if (this.stats.suspicious > 0) {
918
907
  console.log(`\n⚡ ${this.stats.suspicious} suspicious skill(s) found — review recommended.`);
919
908
  } else {
920
- console.log(`\n✅ All clear! No threats detected.`);
909
+ console.log('\n✅ All clear! No threats detected.');
921
910
  }
922
911
  }
923
912
 
924
- toJSON() {
925
- const recommendations = [];
926
- for (const skillResult of this.findings) {
927
- const skillRecs = [];
928
- const cats = new Set(skillResult.findings.map(f => f.cat));
929
-
930
- if (cats.has('prompt-injection')) skillRecs.push('🛑 Contains prompt injection patterns.');
931
- if (cats.has('malicious-code')) skillRecs.push('🛑 Contains potentially malicious code.');
932
- if (cats.has('credential-handling') && cats.has('exfiltration')) skillRecs.push('💀 CRITICAL: Credential access + exfiltration. DO NOT INSTALL.');
933
- if (cats.has('dependency-chain')) skillRecs.push('📦 Suspicious dependency chain.');
934
- if (cats.has('obfuscation')) skillRecs.push('🔍 Code obfuscation detected.');
935
- if (cats.has('secret-detection')) skillRecs.push('🔑 Possible hardcoded secrets.');
936
- if (cats.has('leaky-skills')) skillRecs.push('💧 LEAKY SKILL: Secrets pass through LLM context.');
937
- if (cats.has('memory-poisoning')) skillRecs.push('🧠 MEMORY POISONING: Agent memory modification attempt.');
938
- if (cats.has('prompt-worm')) skillRecs.push('🪱 PROMPT WORM: Self-replicating instructions.');
939
- if (cats.has('data-flow')) skillRecs.push('🔀 Suspicious data flow patterns.');
940
- if (cats.has('persistence')) skillRecs.push('⏰ PERSISTENCE: Creates scheduled tasks.');
941
- if (cats.has('cve-patterns')) skillRecs.push('🚨 CVE PATTERN: Matches known exploits.');
942
- if (cats.has('identity-hijack')) skillRecs.push('🔒 IDENTITY HIJACK: Agent soul file tampering. DO NOT INSTALL.');
943
- if (cats.has('sandbox-validation')) skillRecs.push('🔒 SANDBOX: Skill requests dangerous capabilities.');
944
- if (cats.has('complexity')) skillRecs.push('🧩 COMPLEXITY: Excessive code complexity may hide malicious behavior.');
945
- if (cats.has('config-impact')) skillRecs.push('⚙️ CONFIG IMPACT: Modifies OpenClaw configuration. DO NOT INSTALL.');
946
-
947
- if (skillRecs.length > 0) recommendations.push({ skill: skillResult.skill, actions: skillRecs });
913
+ toJSON(): JSONReport {
914
+ const recommendations: Recommendation[] = [];
915
+ for (const sr of this.findings) {
916
+ const actions: string[] = [];
917
+ const cats = new Set(sr.findings.map(f => f.cat));
918
+
919
+ if (cats.has('prompt-injection')) actions.push('🛑 Contains prompt injection patterns.');
920
+ if (cats.has('malicious-code')) actions.push('🛑 Contains potentially malicious code.');
921
+ if (cats.has('credential-handling') && cats.has('exfiltration')) actions.push('💀 CRITICAL: Credential + exfiltration. DO NOT INSTALL.');
922
+ if (cats.has('dependency-chain')) actions.push('📦 Suspicious dependency chain.');
923
+ if (cats.has('obfuscation')) actions.push('🔍 Code obfuscation detected.');
924
+ if (cats.has('secret-detection')) actions.push('🔑 Possible hardcoded secrets.');
925
+ if (cats.has('memory-poisoning')) actions.push('🧠 MEMORY POISONING: Agent memory modification.');
926
+ if (cats.has('prompt-worm')) actions.push('🪱 PROMPT WORM: Self-replicating instructions.');
927
+ if (cats.has('data-flow')) actions.push('🔀 Suspicious data flow patterns.');
928
+ if (cats.has('identity-hijack')) actions.push('🔒 IDENTITY HIJACK: Agent soul file tampering.');
929
+ if (cats.has('compaction-persistence')) actions.push('⏰ COMPACTION PERSISTENCE: Survives context compaction.');
930
+ if (cats.has('signature-match')) actions.push('🎯 SIGNATURE MATCH: Known threat signature detected.');
931
+ if (cats.has('config-impact')) actions.push('⚙️ CONFIG IMPACT: Modifies OpenClaw configuration.');
932
+ if (cats.has('pii-exposure')) actions.push('🆔 PII EXPOSURE: Handles personal information.');
933
+
934
+ if (actions.length > 0) recommendations.push({ skill: sr.skill, actions });
948
935
  }
949
936
 
950
937
  return {
@@ -955,41 +942,53 @@ class GuardScanner {
955
942
  thresholds: this.thresholds,
956
943
  findings: this.findings,
957
944
  recommendations,
958
- iocVersion: '2026-02-12',
945
+ iocVersion: '2026-02-21',
946
+ signaturesVersion: SIGNATURES_DB.version,
959
947
  };
960
948
  }
961
949
 
962
- toSARIF(scanDir) {
963
- const rules = [];
964
- const ruleIndex = {};
965
- const results = [];
950
+ toSARIF(scanDir: string): SARIFReport {
951
+ const rules: SARIFRule[] = [];
952
+ const ruleIndex: Record<string, number> = {};
953
+ const results: SARIFResult[] = [];
966
954
 
967
- for (const skillResult of this.findings) {
968
- for (const f of skillResult.findings) {
969
- if (!ruleIndex[f.id]) {
955
+ for (const sr of this.findings) {
956
+ for (const f of sr.findings) {
957
+ if (!ruleIndex[f.id] && ruleIndex[f.id] !== 0) {
970
958
  ruleIndex[f.id] = rules.length;
959
+ // Look up OWASP mapping from PATTERNS
960
+ const patternDef = PATTERNS.find((p) => p.id === f.id);
961
+ const owaspTag = patternDef?.owasp;
962
+ const tags = ['security', f.cat];
963
+ if (owaspTag) tags.push(`OWASP/${owaspTag}`);
964
+
971
965
  rules.push({
972
966
  id: f.id, name: f.id,
973
967
  shortDescription: { text: f.desc },
974
- defaultConfiguration: { level: f.severity === 'CRITICAL' ? 'error' : f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note' },
975
- properties: { tags: ['security', f.cat], 'security-severity': f.severity === 'CRITICAL' ? '9.0' : f.severity === 'HIGH' ? '7.0' : f.severity === 'MEDIUM' ? '4.0' : '1.0' }
968
+ defaultConfiguration: { level: f.severity === 'CRITICAL' || f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note' },
969
+ properties: {
970
+ tags,
971
+ 'security-severity': f.severity === 'CRITICAL' ? '9.0' : f.severity === 'HIGH' ? '7.0' : f.severity === 'MEDIUM' ? '4.0' : '1.0',
972
+ },
976
973
  });
977
974
  }
978
- const normalizedFile = String(f.file || '')
979
- .replaceAll('\\', '/')
980
- .replace(/^\/+/, '');
981
- const artifactUri = `${skillResult.skill}/${normalizedFile}`;
982
- const fingerprintSeed = `${f.id}|${artifactUri}|${f.line || 0}|${(f.sample || '').slice(0, 200)}`;
983
- const lineHash = crypto.createHash('sha256').update(fingerprintSeed).digest('hex').slice(0, 24);
975
+
976
+ const normalizedFile = String(f.file || '').replaceAll('\\', '/').replace(/^\/+/, '');
977
+ const artifactUri = `${sr.skill}/${normalizedFile}`;
978
+ const seed = `${f.id}|${artifactUri}|${f.line || 0}|${(f.sample || '').slice(0, 200)}`;
979
+ const lineHash = crypto.createHash('sha256').update(seed).digest('hex').slice(0, 24);
984
980
 
985
981
  results.push({
986
982
  ruleId: f.id, ruleIndex: ruleIndex[f.id],
987
- level: f.severity === 'CRITICAL' ? 'error' : f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note',
988
- message: { text: `[${skillResult.skill}] ${f.desc}${f.sample ? ` — "${f.sample}"` : ''}` },
989
- partialFingerprints: {
990
- primaryLocationLineHash: lineHash
991
- },
992
- locations: [{ physicalLocation: { artifactLocation: { uri: artifactUri, uriBaseId: '%SRCROOT%' }, region: f.line ? { startLine: f.line } : undefined } }]
983
+ level: f.severity === 'CRITICAL' || f.severity === 'HIGH' ? 'error' : f.severity === 'MEDIUM' ? 'warning' : 'note',
984
+ message: { text: `[${sr.skill}] ${f.desc}${f.sample ? ` — "${f.sample}"` : ''}` },
985
+ partialFingerprints: { primaryLocationLineHash: lineHash },
986
+ locations: [{
987
+ physicalLocation: {
988
+ artifactLocation: { uri: artifactUri, uriBaseId: '%SRCROOT%' },
989
+ region: f.line ? { startLine: f.line } : undefined,
990
+ },
991
+ }],
993
992
  });
994
993
  }
995
994
  }
@@ -1000,14 +999,8 @@ class GuardScanner {
1000
999
  runs: [{
1001
1000
  tool: { driver: { name: 'guard-scanner', version: VERSION, informationUri: 'https://github.com/koatora20/guard-scanner', rules } },
1002
1001
  results,
1003
- invocations: [{ executionSuccessful: true, endTimeUtc: new Date().toISOString() }]
1004
- }]
1002
+ invocations: [{ executionSuccessful: true, endTimeUtc: new Date().toISOString() }],
1003
+ }],
1005
1004
  };
1006
1005
  }
1007
-
1008
- toHTML() {
1009
- return generateHTML(VERSION, this.stats, this.findings);
1010
- }
1011
1006
  }
1012
-
1013
- module.exports = { GuardScanner, VERSION, THRESHOLDS, SEVERITY_WEIGHTS };