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.
- package/README.md +107 -64
- package/dist/__tests__/scanner.test.d.ts +10 -0
- package/dist/__tests__/scanner.test.d.ts.map +1 -0
- package/dist/__tests__/scanner.test.js +374 -0
- package/dist/__tests__/scanner.test.js.map +1 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +189 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/ioc-db.d.ts +13 -0
- package/dist/ioc-db.d.ts.map +1 -0
- package/dist/ioc-db.js +130 -0
- package/dist/ioc-db.js.map +1 -0
- package/dist/patterns.d.ts +27 -0
- package/dist/patterns.d.ts.map +1 -0
- package/dist/patterns.js +92 -0
- package/dist/patterns.js.map +1 -0
- package/dist/quarantine.d.ts +18 -0
- package/dist/quarantine.d.ts.map +1 -0
- package/dist/quarantine.js +42 -0
- package/dist/quarantine.js.map +1 -0
- package/dist/scanner.d.ts +54 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +1043 -0
- package/dist/scanner.js.map +1 -0
- package/dist/types.d.ts +165 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/hooks/guard-scanner/plugin.ts +101 -32
- package/openclaw.plugin.json +60 -0
- package/package.json +25 -9
- package/ts-src/__tests__/fixtures/clean-skill/SKILL.md +9 -0
- package/ts-src/__tests__/fixtures/compaction-skill/SKILL.md +11 -0
- package/ts-src/__tests__/fixtures/malicious-skill/SKILL.md +11 -0
- package/ts-src/__tests__/fixtures/malicious-skill/scripts/evil.js +25 -0
- package/ts-src/__tests__/fixtures/prompt-leakage-skill/SKILL.md +20 -0
- package/ts-src/__tests__/fixtures/prompt-leakage-skill/scripts/debug.js +4 -0
- package/ts-src/__tests__/scanner.test.ts +525 -0
- package/ts-src/cli.ts +171 -0
- package/ts-src/index.ts +15 -0
- package/ts-src/ioc-db.ts +131 -0
- package/ts-src/patterns.ts +104 -0
- package/ts-src/quarantine.ts +48 -0
- package/{src/scanner.js → ts-src/scanner.ts} +376 -383
- package/ts-src/types.ts +187 -0
- package/hooks/guard-scanner/handler.ts +0 -207
- package/src/cli.js +0 -149
- package/src/html-template.js +0 -239
- package/src/ioc-db.js +0 -54
- package/src/patterns.js +0 -190
|
@@ -1,89 +1,101 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
/**
|
|
3
|
-
* guard-scanner
|
|
2
|
+
* guard-scanner v3.0.0 — Core Scanner (TypeScript)
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
const { KNOWN_MALICIOUS } = require('./ioc-db.js');
|
|
31
|
-
const { generateHTML } = require('./html-template.js');
|
|
24
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
32
25
|
|
|
33
|
-
|
|
34
|
-
const VERSION = '1.1.0';
|
|
26
|
+
export const VERSION = '3.0.0';
|
|
35
27
|
|
|
36
|
-
const
|
|
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
|
-
|
|
42
|
-
|
|
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([
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 ?
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
80
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
121
|
-
docOnly: rule.docOnly
|
|
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
|
-
//
|
|
137
|
-
|
|
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;
|
|
169
|
+
break;
|
|
158
170
|
}
|
|
159
171
|
}
|
|
160
172
|
|
|
161
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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:
|
|
212
|
-
file: 'SKILL NAME', line: 0
|
|
224
|
+
desc: 'Known malicious/typosquat skill name', file: 'SKILL NAME',
|
|
213
225
|
});
|
|
214
226
|
}
|
|
215
227
|
|
|
216
|
-
//
|
|
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
|
|
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 >
|
|
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
|
-
//
|
|
251
|
+
// Secret detection (skip lock files)
|
|
244
252
|
const baseName = path.basename(relFile).toLowerCase();
|
|
245
|
-
const
|
|
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' && !
|
|
256
|
+
if (fileType === 'code' && !skipSecret) {
|
|
249
257
|
this.checkHardcodedSecrets(content, relFile, skillFindings);
|
|
250
258
|
}
|
|
251
259
|
|
|
252
|
-
//
|
|
253
|
-
if (
|
|
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
|
-
//
|
|
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
|
|
282
|
-
const
|
|
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
|
-
|
|
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 (
|
|
311
|
-
this.findings.push({ skill: skillName, risk, verdict: verdict.label, findings:
|
|
300
|
+
if (filtered.length > 0) {
|
|
301
|
+
this.findings.push({ skill: skillName, risk, verdict: verdict.label, findings: filtered });
|
|
312
302
|
}
|
|
313
303
|
}
|
|
314
304
|
|
|
315
|
-
|
|
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(
|
|
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 :
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
{ regex: /exec\.
|
|
690
|
-
{ regex: /
|
|
691
|
-
{ regex: /
|
|
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+(
|
|
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+({[^}]
|
|
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
|
|
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
|
-
|
|
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 <
|
|
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_
|
|
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
|
|
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
|
|
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)
|
|
800
|
+
if (m.length > 40) b64Fragments.push({ file, fragment: m.substring(0, 30) });
|
|
827
801
|
}
|
|
828
802
|
}
|
|
829
|
-
if (
|
|
830
|
-
findings.push({ severity: 'HIGH', id: 'XFILE_FRAGMENT_B64', cat: 'obfuscation', desc: `Base64 fragments across ${new Set(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
882
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
909
|
+
console.log('\n✅ All clear! No threats detected.');
|
|
921
910
|
}
|
|
922
911
|
}
|
|
923
912
|
|
|
924
|
-
toJSON() {
|
|
925
|
-
const recommendations = [];
|
|
926
|
-
for (const
|
|
927
|
-
const
|
|
928
|
-
const cats = new Set(
|
|
929
|
-
|
|
930
|
-
if (cats.has('prompt-injection'))
|
|
931
|
-
if (cats.has('malicious-code'))
|
|
932
|
-
if (cats.has('credential-handling') && cats.has('exfiltration'))
|
|
933
|
-
if (cats.has('dependency-chain'))
|
|
934
|
-
if (cats.has('obfuscation'))
|
|
935
|
-
if (cats.has('secret-detection'))
|
|
936
|
-
if (cats.has('
|
|
937
|
-
if (cats.has('
|
|
938
|
-
if (cats.has('
|
|
939
|
-
if (cats.has('
|
|
940
|
-
if (cats.has('persistence'))
|
|
941
|
-
if (cats.has('
|
|
942
|
-
if (cats.has('
|
|
943
|
-
if (cats.has('
|
|
944
|
-
|
|
945
|
-
if (
|
|
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-
|
|
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
|
|
968
|
-
for (const f of
|
|
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'
|
|
975
|
-
properties: {
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
const
|
|
982
|
-
const
|
|
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'
|
|
988
|
-
message: { text: `[${
|
|
989
|
-
partialFingerprints: {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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 };
|