hackmyagent-core 0.1.3 → 0.2.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/dist/hardening/scanner.d.ts +46 -0
- package/dist/hardening/scanner.d.ts.map +1 -1
- package/dist/hardening/scanner.js +1382 -0
- package/dist/hardening/scanner.js.map +1 -1
- package/dist/hardening/scanner.test.js +291 -0
- package/dist/hardening/scanner.test.js.map +1 -1
- package/dist/hardening/security-check.d.ts +2 -1
- package/dist/hardening/security-check.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -65,6 +65,12 @@ const CHECK_PROJECT_TYPES = {
|
|
|
65
65
|
'SESSION-': ['webapp', 'api'], // Session management
|
|
66
66
|
'NET-': ['webapp', 'api'], // Network security (HTTPS, etc.)
|
|
67
67
|
'IO-': ['webapp', 'api'], // Input/output (XSS, etc.)
|
|
68
|
+
// OpenClaw-specific checks
|
|
69
|
+
'SKILL-': ['openclaw', 'mcp'], // Skill file security
|
|
70
|
+
'HEARTBEAT-': ['openclaw'], // Heartbeat/periodic task security
|
|
71
|
+
'GATEWAY-': ['openclaw'], // Gateway configuration security
|
|
72
|
+
'CONFIG-': ['openclaw', 'mcp'], // Configuration file security
|
|
73
|
+
'SUPPLY-': ['openclaw', 'mcp'], // Supply chain security
|
|
68
74
|
'API-': ['api'], // API security headers
|
|
69
75
|
'RATE-': ['webapp', 'api'], // Rate limiting
|
|
70
76
|
'PROC-': ['webapp', 'api'], // Process security (headers, etc.)
|
|
@@ -104,6 +110,72 @@ const CREDENTIAL_PATTERNS = [
|
|
|
104
110
|
// SendGrid
|
|
105
111
|
{ name: 'SENDGRID_KEY', pattern: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/ },
|
|
106
112
|
];
|
|
113
|
+
// OpenClaw skill security patterns
|
|
114
|
+
const SKILL_REMOTE_FETCH_PATTERNS = [
|
|
115
|
+
/curl\s+(-[a-zA-Z]+\s+)*https?:\/\//gi,
|
|
116
|
+
/wget\s+(-[a-zA-Z]+\s+)*https?:\/\//gi,
|
|
117
|
+
/fetch\s*\(\s*['"`]https?:\/\//gi,
|
|
118
|
+
/\|\s*(ba)?sh/gi, // pipe to shell
|
|
119
|
+
/\|\s*sudo/gi, // pipe to sudo
|
|
120
|
+
];
|
|
121
|
+
const SKILL_CREDENTIAL_ACCESS_PATTERNS = [
|
|
122
|
+
/~\/\.ssh/gi,
|
|
123
|
+
/~\/\.aws/gi,
|
|
124
|
+
/~\/\.config\/solana/gi,
|
|
125
|
+
/~\/\.config\/gcloud/gi,
|
|
126
|
+
/~\/\.kube/gi,
|
|
127
|
+
/~\/\.gnupg/gi,
|
|
128
|
+
/keychain/gi,
|
|
129
|
+
/wallet.*\.json/gi,
|
|
130
|
+
/seed.*phrase/gi,
|
|
131
|
+
/private.*key/gi,
|
|
132
|
+
/\.env/gi,
|
|
133
|
+
/credentials\.json/gi,
|
|
134
|
+
];
|
|
135
|
+
const SKILL_EXFILTRATION_PATTERNS = [
|
|
136
|
+
/webhook\.site/gi,
|
|
137
|
+
/requestbin/gi,
|
|
138
|
+
/ngrok\.io/gi,
|
|
139
|
+
/curl\s+[^\n]*?-d\s/gi, // Non-greedy with newline boundary
|
|
140
|
+
/curl\s+[^\n]*?--data/gi,
|
|
141
|
+
/curl\s+[^\n]*?-X\s*POST/gi,
|
|
142
|
+
/fetch\s*\([^)]*method:\s*['"]POST/gi,
|
|
143
|
+
];
|
|
144
|
+
const SKILL_REVERSE_SHELL_PATTERNS = [
|
|
145
|
+
/nc\s+(-[a-zA-Z]+\s+)*.*-e/gi,
|
|
146
|
+
/bash\s+-i\s+/gi,
|
|
147
|
+
/\/dev\/tcp\//gi,
|
|
148
|
+
/\/dev\/udp\//gi,
|
|
149
|
+
/python.*socket.*connect/gi,
|
|
150
|
+
/perl.*socket.*connect/gi,
|
|
151
|
+
];
|
|
152
|
+
const SKILL_CLICKFIX_PATTERNS = [
|
|
153
|
+
/copy\s+(and\s+)?paste\s+(this\s+)?(into|in)\s+(your\s+)?terminal/gi,
|
|
154
|
+
/run\s+this\s+command/gi,
|
|
155
|
+
/execute\s+(the\s+following|this)/gi,
|
|
156
|
+
/curl.*\|\s*(ba)?sh/gi,
|
|
157
|
+
/wget.*\|\s*(ba)?sh/gi,
|
|
158
|
+
];
|
|
159
|
+
const HEARTBEAT_DANGEROUS_CAPS = [
|
|
160
|
+
'shell:*',
|
|
161
|
+
'shell:bash',
|
|
162
|
+
'shell:sh',
|
|
163
|
+
'filesystem:*',
|
|
164
|
+
'filesystem:~/*',
|
|
165
|
+
'filesystem:/',
|
|
166
|
+
'network:*',
|
|
167
|
+
];
|
|
168
|
+
const PROMPT_INJECTION_PATTERNS = [
|
|
169
|
+
/ignore\s+(all\s+)?(previous|prior|above)/gi,
|
|
170
|
+
/disregard\s+(all\s+)?(previous|prior)/gi,
|
|
171
|
+
/system:\s/gi,
|
|
172
|
+
/<\|.*\|>/gi, // special tokens
|
|
173
|
+
/\[INST\]/gi,
|
|
174
|
+
/\[\/INST\]/gi,
|
|
175
|
+
/<<SYS>>/gi,
|
|
176
|
+
/Human:/gi,
|
|
177
|
+
/Assistant:/gi,
|
|
178
|
+
];
|
|
107
179
|
// Severity weights for score calculation
|
|
108
180
|
const SEVERITY_WEIGHTS = {
|
|
109
181
|
critical: 25,
|
|
@@ -111,7 +183,17 @@ const SEVERITY_WEIGHTS = {
|
|
|
111
183
|
medium: 8,
|
|
112
184
|
low: 3,
|
|
113
185
|
};
|
|
186
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB max file size to prevent memory exhaustion
|
|
187
|
+
const MAX_LINE_LENGTH = 10000; // 10KB max line length for regex safety
|
|
114
188
|
class HardeningScanner {
|
|
189
|
+
/**
|
|
190
|
+
* Validate that a file path is within the target directory (no path traversal)
|
|
191
|
+
*/
|
|
192
|
+
isPathWithinDirectory(filePath, directory) {
|
|
193
|
+
const normalizedFile = path.resolve(filePath);
|
|
194
|
+
const normalizedDir = path.resolve(directory);
|
|
195
|
+
return normalizedFile.startsWith(normalizedDir + path.sep) || normalizedFile === normalizedDir;
|
|
196
|
+
}
|
|
115
197
|
async scan(options) {
|
|
116
198
|
const { targetDir, autoFix = false, dryRun = false, ignore = [] } = options;
|
|
117
199
|
// Normalize ignore list to uppercase for case-insensitive matching
|
|
@@ -223,6 +305,21 @@ class HardeningScanner {
|
|
|
223
305
|
// Tool boundary checks
|
|
224
306
|
const toolFindings = await this.checkToolBoundaries(targetDir, shouldFix);
|
|
225
307
|
findings.push(...toolFindings);
|
|
308
|
+
// OpenClaw skill checks
|
|
309
|
+
const skillFindings = await this.checkOpenclawSkills(targetDir, shouldFix);
|
|
310
|
+
findings.push(...skillFindings);
|
|
311
|
+
// OpenClaw heartbeat checks
|
|
312
|
+
const heartbeatFindings = await this.checkOpenclawHeartbeat(targetDir, shouldFix);
|
|
313
|
+
findings.push(...heartbeatFindings);
|
|
314
|
+
// OpenClaw gateway checks
|
|
315
|
+
const gatewayFindings = await this.checkOpenclawGateway(targetDir, shouldFix);
|
|
316
|
+
findings.push(...gatewayFindings);
|
|
317
|
+
// OpenClaw config checks
|
|
318
|
+
const configFindings = await this.checkOpenclawConfig(targetDir, shouldFix);
|
|
319
|
+
findings.push(...configFindings);
|
|
320
|
+
// OpenClaw supply chain checks
|
|
321
|
+
const supplyFindings = await this.checkOpenclawSupplyChain(targetDir, shouldFix);
|
|
322
|
+
findings.push(...supplyFindings);
|
|
226
323
|
// Filter findings to only show real, actionable issues:
|
|
227
324
|
// 1. Only failed checks (passed: false)
|
|
228
325
|
// 2. Only checks with a file path (concrete findings, not generic advice)
|
|
@@ -288,6 +385,46 @@ class HardeningScanner {
|
|
|
288
385
|
platforms.push('claude-code');
|
|
289
386
|
}
|
|
290
387
|
catch { }
|
|
388
|
+
// OpenClaw detection
|
|
389
|
+
try {
|
|
390
|
+
await fs.access(path.join(targetDir, '.openclaw'));
|
|
391
|
+
if (!platforms.includes('openclaw')) {
|
|
392
|
+
platforms.push('openclaw');
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch { }
|
|
396
|
+
try {
|
|
397
|
+
await fs.access(path.join(targetDir, '.moltbot'));
|
|
398
|
+
if (!platforms.includes('openclaw')) {
|
|
399
|
+
platforms.push('openclaw');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch { }
|
|
403
|
+
try {
|
|
404
|
+
await fs.access(path.join(targetDir, '.clawdbot'));
|
|
405
|
+
if (!platforms.includes('openclaw')) {
|
|
406
|
+
platforms.push('openclaw');
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch { }
|
|
410
|
+
// Check for openclaw.json
|
|
411
|
+
try {
|
|
412
|
+
await fs.access(path.join(targetDir, 'openclaw.json'));
|
|
413
|
+
if (!platforms.includes('openclaw')) {
|
|
414
|
+
platforms.push('openclaw');
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch { }
|
|
418
|
+
// Check for SKILL.md files (OpenClaw skill project)
|
|
419
|
+
try {
|
|
420
|
+
const files = await fs.readdir(targetDir);
|
|
421
|
+
if (files.some(f => f === 'SKILL.md' || f.endsWith('.skill.md'))) {
|
|
422
|
+
if (!platforms.includes('openclaw')) {
|
|
423
|
+
platforms.push('openclaw');
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch { }
|
|
291
428
|
if (platforms.length === 0) {
|
|
292
429
|
return 'generic';
|
|
293
430
|
}
|
|
@@ -297,6 +434,23 @@ class HardeningScanner {
|
|
|
297
434
|
* Detect the project type based on package.json and project structure
|
|
298
435
|
*/
|
|
299
436
|
async detectProjectType(targetDir) {
|
|
437
|
+
// Check for OpenClaw project indicators (check first as it's more specific)
|
|
438
|
+
const openclawIndicators = ['.openclaw', '.moltbot', '.clawdbot', 'SKILL.md', 'openclaw.json'];
|
|
439
|
+
for (const indicator of openclawIndicators) {
|
|
440
|
+
try {
|
|
441
|
+
await fs.access(path.join(targetDir, indicator));
|
|
442
|
+
return 'openclaw';
|
|
443
|
+
}
|
|
444
|
+
catch { }
|
|
445
|
+
}
|
|
446
|
+
// Check for *.skill.md files (OpenClaw skill project)
|
|
447
|
+
try {
|
|
448
|
+
const files = await fs.readdir(targetDir);
|
|
449
|
+
if (files.some(f => f.endsWith('.skill.md'))) {
|
|
450
|
+
return 'openclaw';
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch { }
|
|
300
454
|
try {
|
|
301
455
|
const pkgPath = path.join(targetDir, 'package.json');
|
|
302
456
|
const content = await fs.readFile(pkgPath, 'utf-8');
|
|
@@ -3519,6 +3673,1234 @@ dist/
|
|
|
3519
3673
|
// Remove the used backup
|
|
3520
3674
|
await fs.rm(backupDir, { recursive: true, force: true });
|
|
3521
3675
|
}
|
|
3676
|
+
/**
|
|
3677
|
+
* Recursively find SKILL.md and *.skill.md files
|
|
3678
|
+
* Skips node_modules and limits depth to 5
|
|
3679
|
+
*/
|
|
3680
|
+
async findSkillFiles(dir, depth = 0, rootDir) {
|
|
3681
|
+
if (depth > 5) {
|
|
3682
|
+
return [];
|
|
3683
|
+
}
|
|
3684
|
+
const baseDir = rootDir || dir;
|
|
3685
|
+
const skillFiles = [];
|
|
3686
|
+
try {
|
|
3687
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
3688
|
+
for (const entry of entries) {
|
|
3689
|
+
// Skip symlinks to prevent path traversal
|
|
3690
|
+
if (entry.isSymbolicLink()) {
|
|
3691
|
+
continue;
|
|
3692
|
+
}
|
|
3693
|
+
const fullPath = path.join(dir, entry.name);
|
|
3694
|
+
// Validate path is within directory (no path traversal)
|
|
3695
|
+
if (!this.isPathWithinDirectory(fullPath, baseDir)) {
|
|
3696
|
+
continue;
|
|
3697
|
+
}
|
|
3698
|
+
if (entry.isDirectory()) {
|
|
3699
|
+
// Skip node_modules and hidden directories (except .openclaw, .moltbot, .clawdbot)
|
|
3700
|
+
if (entry.name === 'node_modules')
|
|
3701
|
+
continue;
|
|
3702
|
+
if (entry.name.startsWith('.') &&
|
|
3703
|
+
!['openclaw', 'moltbot', 'clawdbot'].includes(entry.name.slice(1))) {
|
|
3704
|
+
continue;
|
|
3705
|
+
}
|
|
3706
|
+
const subFiles = await this.findSkillFiles(fullPath, depth + 1, baseDir);
|
|
3707
|
+
skillFiles.push(...subFiles);
|
|
3708
|
+
}
|
|
3709
|
+
else if (entry.isFile()) {
|
|
3710
|
+
// Match SKILL.md or *.skill.md
|
|
3711
|
+
if (entry.name === 'SKILL.md' || entry.name.endsWith('.skill.md')) {
|
|
3712
|
+
skillFiles.push(fullPath);
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
catch {
|
|
3718
|
+
// Directory not accessible, skip
|
|
3719
|
+
}
|
|
3720
|
+
return skillFiles;
|
|
3721
|
+
}
|
|
3722
|
+
/**
|
|
3723
|
+
* OpenClaw skill security checks (SKILL-001 to SKILL-006)
|
|
3724
|
+
*/
|
|
3725
|
+
async checkOpenclawSkills(targetDir, autoFix) {
|
|
3726
|
+
const findings = [];
|
|
3727
|
+
const skillFiles = await this.findSkillFiles(targetDir);
|
|
3728
|
+
for (const skillFile of skillFiles) {
|
|
3729
|
+
const relativePath = path.relative(targetDir, skillFile);
|
|
3730
|
+
let content;
|
|
3731
|
+
try {
|
|
3732
|
+
const stats = await fs.stat(skillFile);
|
|
3733
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
3734
|
+
findings.push({
|
|
3735
|
+
checkId: 'SCAN-001',
|
|
3736
|
+
name: 'Oversized File',
|
|
3737
|
+
description: 'File exceeds maximum scan size',
|
|
3738
|
+
category: 'scan',
|
|
3739
|
+
severity: 'medium',
|
|
3740
|
+
passed: false,
|
|
3741
|
+
message: `File ${relativePath} is ${Math.round(stats.size / 1024 / 1024)}MB - skipped (max ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
|
|
3742
|
+
file: relativePath,
|
|
3743
|
+
fixable: false,
|
|
3744
|
+
fix: 'Reduce file size or exclude from scan',
|
|
3745
|
+
});
|
|
3746
|
+
continue;
|
|
3747
|
+
}
|
|
3748
|
+
content = await fs.readFile(skillFile, 'utf-8');
|
|
3749
|
+
}
|
|
3750
|
+
catch {
|
|
3751
|
+
continue;
|
|
3752
|
+
}
|
|
3753
|
+
const lines = content.split('\n').map(line => line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) : line);
|
|
3754
|
+
// SKILL-001: Unsigned Skill
|
|
3755
|
+
const hasSignature = content.includes('opena2a_signature:') ||
|
|
3756
|
+
content.includes('-----BEGIN SIGNATURE-----');
|
|
3757
|
+
findings.push({
|
|
3758
|
+
checkId: 'SKILL-001',
|
|
3759
|
+
name: 'Unsigned Skill',
|
|
3760
|
+
description: 'Skill file lacks cryptographic signature for authenticity verification',
|
|
3761
|
+
category: 'skill',
|
|
3762
|
+
severity: 'medium',
|
|
3763
|
+
passed: hasSignature,
|
|
3764
|
+
message: hasSignature
|
|
3765
|
+
? 'Skill has cryptographic signature'
|
|
3766
|
+
: 'Skill is unsigned - cannot verify authenticity or integrity',
|
|
3767
|
+
file: relativePath,
|
|
3768
|
+
fixable: false,
|
|
3769
|
+
fix: 'Sign the skill using: openclaw sign skill.md --key ~/.openclaw/signing-key.pem',
|
|
3770
|
+
});
|
|
3771
|
+
// SKILL-002: Remote Fetch Pattern
|
|
3772
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3773
|
+
const line = lines[i];
|
|
3774
|
+
for (const pattern of SKILL_REMOTE_FETCH_PATTERNS) {
|
|
3775
|
+
// Reset regex lastIndex for global patterns
|
|
3776
|
+
pattern.lastIndex = 0;
|
|
3777
|
+
if (pattern.test(line)) {
|
|
3778
|
+
findings.push({
|
|
3779
|
+
checkId: 'SKILL-002',
|
|
3780
|
+
name: 'Remote Fetch Pattern',
|
|
3781
|
+
description: 'Skill contains pattern that fetches and executes remote code',
|
|
3782
|
+
category: 'skill',
|
|
3783
|
+
severity: 'critical',
|
|
3784
|
+
passed: false,
|
|
3785
|
+
message: `Remote fetch pattern detected: "${line.trim().substring(0, 80)}..."`,
|
|
3786
|
+
file: relativePath,
|
|
3787
|
+
line: i + 1,
|
|
3788
|
+
fixable: false,
|
|
3789
|
+
fix: 'Remove curl|sh, wget|sh, and other remote code execution patterns',
|
|
3790
|
+
});
|
|
3791
|
+
break; // One finding per line
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3795
|
+
// SKILL-003: Heartbeat Installation
|
|
3796
|
+
const heartbeatPattern = /heartbeat|cron|schedule|every\s+\d+\s*(min|hour|sec)/gi;
|
|
3797
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3798
|
+
const line = lines[i];
|
|
3799
|
+
heartbeatPattern.lastIndex = 0;
|
|
3800
|
+
if (heartbeatPattern.test(line)) {
|
|
3801
|
+
findings.push({
|
|
3802
|
+
checkId: 'SKILL-003',
|
|
3803
|
+
name: 'Heartbeat Installation',
|
|
3804
|
+
description: 'Skill attempts to install periodic/scheduled tasks',
|
|
3805
|
+
category: 'skill',
|
|
3806
|
+
severity: 'high',
|
|
3807
|
+
passed: false,
|
|
3808
|
+
message: `Heartbeat/scheduled task pattern detected: "${line.trim().substring(0, 80)}..."`,
|
|
3809
|
+
file: relativePath,
|
|
3810
|
+
line: i + 1,
|
|
3811
|
+
fixable: false,
|
|
3812
|
+
fix: 'Heartbeats should be configured separately with restricted permissions, not bundled in skills',
|
|
3813
|
+
});
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
// SKILL-004: Filesystem Write Outside Sandbox
|
|
3817
|
+
const filesystemWildcardPattern = /filesystem:\s*\*|filesystem:\s*~\/\*|filesystem:\s*\//gi;
|
|
3818
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3819
|
+
const line = lines[i];
|
|
3820
|
+
filesystemWildcardPattern.lastIndex = 0;
|
|
3821
|
+
if (filesystemWildcardPattern.test(line)) {
|
|
3822
|
+
findings.push({
|
|
3823
|
+
checkId: 'SKILL-004',
|
|
3824
|
+
name: 'Filesystem Write Outside Sandbox',
|
|
3825
|
+
description: 'Skill requests broad filesystem access outside sandbox',
|
|
3826
|
+
category: 'skill',
|
|
3827
|
+
severity: 'critical',
|
|
3828
|
+
passed: false,
|
|
3829
|
+
message: `Broad filesystem access requested: "${line.trim()}"`,
|
|
3830
|
+
file: relativePath,
|
|
3831
|
+
line: i + 1,
|
|
3832
|
+
fixable: false,
|
|
3833
|
+
fix: 'Restrict filesystem access to specific directories (e.g., filesystem:./data/*)',
|
|
3834
|
+
});
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
3837
|
+
// SKILL-005: Credential File Access
|
|
3838
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3839
|
+
const line = lines[i];
|
|
3840
|
+
for (const pattern of SKILL_CREDENTIAL_ACCESS_PATTERNS) {
|
|
3841
|
+
pattern.lastIndex = 0;
|
|
3842
|
+
if (pattern.test(line)) {
|
|
3843
|
+
findings.push({
|
|
3844
|
+
checkId: 'SKILL-005',
|
|
3845
|
+
name: 'Credential File Access',
|
|
3846
|
+
description: 'Skill attempts to access credential or sensitive configuration files',
|
|
3847
|
+
category: 'skill',
|
|
3848
|
+
severity: 'critical',
|
|
3849
|
+
passed: false,
|
|
3850
|
+
message: `Credential file access pattern detected: "${line.trim().substring(0, 80)}..."`,
|
|
3851
|
+
file: relativePath,
|
|
3852
|
+
line: i + 1,
|
|
3853
|
+
fixable: false,
|
|
3854
|
+
fix: 'Skills should never access credential files like ~/.ssh, ~/.aws, wallets, or .env files',
|
|
3855
|
+
});
|
|
3856
|
+
break; // One finding per line per check
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3860
|
+
// SKILL-006: Data Exfiltration Pattern
|
|
3861
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3862
|
+
const line = lines[i];
|
|
3863
|
+
for (const pattern of SKILL_EXFILTRATION_PATTERNS) {
|
|
3864
|
+
pattern.lastIndex = 0;
|
|
3865
|
+
if (pattern.test(line)) {
|
|
3866
|
+
findings.push({
|
|
3867
|
+
checkId: 'SKILL-006',
|
|
3868
|
+
name: 'Data Exfiltration Pattern',
|
|
3869
|
+
description: 'Skill contains patterns commonly used for data exfiltration',
|
|
3870
|
+
category: 'skill',
|
|
3871
|
+
severity: 'critical',
|
|
3872
|
+
passed: false,
|
|
3873
|
+
message: `Data exfiltration pattern detected: "${line.trim().substring(0, 80)}..."`,
|
|
3874
|
+
file: relativePath,
|
|
3875
|
+
line: i + 1,
|
|
3876
|
+
fixable: false,
|
|
3877
|
+
fix: 'Remove webhook.site, requestbin, ngrok, and suspicious POST patterns',
|
|
3878
|
+
});
|
|
3879
|
+
break; // One finding per line per check
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
// SKILL-007: ClickFix Social Engineering
|
|
3884
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3885
|
+
const line = lines[i];
|
|
3886
|
+
for (const pattern of SKILL_CLICKFIX_PATTERNS) {
|
|
3887
|
+
pattern.lastIndex = 0;
|
|
3888
|
+
if (pattern.test(line)) {
|
|
3889
|
+
findings.push({
|
|
3890
|
+
checkId: 'SKILL-007',
|
|
3891
|
+
name: 'ClickFix Social Engineering',
|
|
3892
|
+
description: 'Skill uses social engineering tactics to trick users into running commands',
|
|
3893
|
+
category: 'skill',
|
|
3894
|
+
severity: 'critical',
|
|
3895
|
+
passed: false,
|
|
3896
|
+
message: `ClickFix social engineering pattern detected: "${line.trim().substring(0, 80)}..."`,
|
|
3897
|
+
file: relativePath,
|
|
3898
|
+
line: i + 1,
|
|
3899
|
+
fixable: false,
|
|
3900
|
+
fix: 'Remove social engineering instructions that trick users into copying/pasting commands',
|
|
3901
|
+
});
|
|
3902
|
+
break; // One finding per line per check
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
// SKILL-008: Reverse Shell Pattern
|
|
3907
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3908
|
+
const line = lines[i];
|
|
3909
|
+
for (const pattern of SKILL_REVERSE_SHELL_PATTERNS) {
|
|
3910
|
+
pattern.lastIndex = 0;
|
|
3911
|
+
if (pattern.test(line)) {
|
|
3912
|
+
findings.push({
|
|
3913
|
+
checkId: 'SKILL-008',
|
|
3914
|
+
name: 'Reverse Shell Pattern',
|
|
3915
|
+
description: 'Skill contains patterns commonly used to establish reverse shells',
|
|
3916
|
+
category: 'skill',
|
|
3917
|
+
severity: 'critical',
|
|
3918
|
+
passed: false,
|
|
3919
|
+
message: `Reverse shell pattern detected: "${line.trim().substring(0, 80)}..."`,
|
|
3920
|
+
file: relativePath,
|
|
3921
|
+
line: i + 1,
|
|
3922
|
+
fixable: false,
|
|
3923
|
+
fix: 'Remove netcat, bash -i, /dev/tcp, and other reverse shell patterns',
|
|
3924
|
+
});
|
|
3925
|
+
break; // One finding per line per check
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
// SKILL-009: Typosquatting Name
|
|
3930
|
+
const popularSkills = [
|
|
3931
|
+
'filesystem',
|
|
3932
|
+
'github',
|
|
3933
|
+
'slack',
|
|
3934
|
+
'discord',
|
|
3935
|
+
'postgres',
|
|
3936
|
+
'sqlite',
|
|
3937
|
+
'fetch',
|
|
3938
|
+
'browser',
|
|
3939
|
+
'puppeteer',
|
|
3940
|
+
'playwright',
|
|
3941
|
+
];
|
|
3942
|
+
const skillBasename = path.basename(skillFile, path.extname(skillFile)).toLowerCase();
|
|
3943
|
+
for (const popular of popularSkills) {
|
|
3944
|
+
if (skillBasename !== popular && this.levenshteinDistance(skillBasename, popular) <= 2) {
|
|
3945
|
+
findings.push({
|
|
3946
|
+
checkId: 'SKILL-009',
|
|
3947
|
+
name: 'Typosquatting Name',
|
|
3948
|
+
description: 'Skill name is suspiciously similar to a popular skill (potential typosquatting)',
|
|
3949
|
+
category: 'skill',
|
|
3950
|
+
severity: 'high',
|
|
3951
|
+
passed: false,
|
|
3952
|
+
message: `Skill name "${skillBasename}" is similar to popular skill "${popular}" (potential typosquatting)`,
|
|
3953
|
+
file: relativePath,
|
|
3954
|
+
fixable: false,
|
|
3955
|
+
fix: 'Rename the skill to avoid confusion with popular skills, or verify this is intentional',
|
|
3956
|
+
});
|
|
3957
|
+
break; // One typosquatting finding per skill file
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3960
|
+
// SKILL-010: Env File Exfiltration
|
|
3961
|
+
const envFilePattern = /\.env|dotenv|process\.env|environ|getenv/gi;
|
|
3962
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3963
|
+
const line = lines[i];
|
|
3964
|
+
envFilePattern.lastIndex = 0;
|
|
3965
|
+
if (envFilePattern.test(line)) {
|
|
3966
|
+
findings.push({
|
|
3967
|
+
checkId: 'SKILL-010',
|
|
3968
|
+
name: 'Env File Exfiltration',
|
|
3969
|
+
description: 'Skill attempts to access environment files or variables',
|
|
3970
|
+
category: 'skill',
|
|
3971
|
+
severity: 'critical',
|
|
3972
|
+
passed: false,
|
|
3973
|
+
message: `Environment file/variable access detected: "${line.trim().substring(0, 80)}..."`,
|
|
3974
|
+
file: relativePath,
|
|
3975
|
+
line: i + 1,
|
|
3976
|
+
fixable: false,
|
|
3977
|
+
fix: 'Skills should not access .env files or environment variables containing secrets',
|
|
3978
|
+
});
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
// SKILL-011: Browser Data Access
|
|
3982
|
+
const browserDataPattern = /chrome|firefox|cookies|localStorage|sessionStorage|browser.*data|chromium|safari.*cookies/gi;
|
|
3983
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3984
|
+
const line = lines[i];
|
|
3985
|
+
browserDataPattern.lastIndex = 0;
|
|
3986
|
+
if (browserDataPattern.test(line)) {
|
|
3987
|
+
findings.push({
|
|
3988
|
+
checkId: 'SKILL-011',
|
|
3989
|
+
name: 'Browser Data Access',
|
|
3990
|
+
description: 'Skill attempts to access browser data, cookies, or local storage',
|
|
3991
|
+
category: 'skill',
|
|
3992
|
+
severity: 'critical',
|
|
3993
|
+
passed: false,
|
|
3994
|
+
message: `Browser data access pattern detected: "${line.trim().substring(0, 80)}..."`,
|
|
3995
|
+
file: relativePath,
|
|
3996
|
+
line: i + 1,
|
|
3997
|
+
fixable: false,
|
|
3998
|
+
fix: 'Skills should not access browser data, cookies, localStorage, or sessionStorage',
|
|
3999
|
+
});
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
// SKILL-012: Crypto Wallet Access
|
|
4003
|
+
const cryptoWalletPattern = /wallet|solana|phantom|metamask|ledger|seed\s*phrase|mnemonic|\.sol\b|\.eth\b|private\s*key/gi;
|
|
4004
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4005
|
+
const line = lines[i];
|
|
4006
|
+
cryptoWalletPattern.lastIndex = 0;
|
|
4007
|
+
if (cryptoWalletPattern.test(line)) {
|
|
4008
|
+
findings.push({
|
|
4009
|
+
checkId: 'SKILL-012',
|
|
4010
|
+
name: 'Crypto Wallet Access',
|
|
4011
|
+
description: 'Skill attempts to access cryptocurrency wallets or seed phrases',
|
|
4012
|
+
category: 'skill',
|
|
4013
|
+
severity: 'critical',
|
|
4014
|
+
passed: false,
|
|
4015
|
+
message: `Crypto wallet access pattern detected: "${line.trim().substring(0, 80)}..."`,
|
|
4016
|
+
file: relativePath,
|
|
4017
|
+
line: i + 1,
|
|
4018
|
+
fixable: false,
|
|
4019
|
+
fix: 'Skills should never access cryptocurrency wallets, seed phrases, or private keys',
|
|
4020
|
+
});
|
|
4021
|
+
}
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
return findings;
|
|
4025
|
+
}
|
|
4026
|
+
/**
|
|
4027
|
+
* Recursively find HEARTBEAT.md and *.heartbeat.md files
|
|
4028
|
+
* Skips node_modules and limits depth to 5
|
|
4029
|
+
*/
|
|
4030
|
+
async findHeartbeatFiles(dir, depth = 0, rootDir) {
|
|
4031
|
+
if (depth > 5) {
|
|
4032
|
+
return [];
|
|
4033
|
+
}
|
|
4034
|
+
const baseDir = rootDir || dir;
|
|
4035
|
+
const heartbeatFiles = [];
|
|
4036
|
+
try {
|
|
4037
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
4038
|
+
for (const entry of entries) {
|
|
4039
|
+
// Skip symlinks to prevent path traversal
|
|
4040
|
+
if (entry.isSymbolicLink()) {
|
|
4041
|
+
continue;
|
|
4042
|
+
}
|
|
4043
|
+
const fullPath = path.join(dir, entry.name);
|
|
4044
|
+
// Validate path is within directory (no path traversal)
|
|
4045
|
+
if (!this.isPathWithinDirectory(fullPath, baseDir)) {
|
|
4046
|
+
continue;
|
|
4047
|
+
}
|
|
4048
|
+
if (entry.isDirectory()) {
|
|
4049
|
+
// Skip node_modules and hidden directories (except .openclaw, .moltbot, .clawdbot)
|
|
4050
|
+
if (entry.name === 'node_modules')
|
|
4051
|
+
continue;
|
|
4052
|
+
if (entry.name.startsWith('.') &&
|
|
4053
|
+
!['openclaw', 'moltbot', 'clawdbot'].includes(entry.name.slice(1))) {
|
|
4054
|
+
continue;
|
|
4055
|
+
}
|
|
4056
|
+
const subFiles = await this.findHeartbeatFiles(fullPath, depth + 1, baseDir);
|
|
4057
|
+
heartbeatFiles.push(...subFiles);
|
|
4058
|
+
}
|
|
4059
|
+
else if (entry.isFile()) {
|
|
4060
|
+
// Match HEARTBEAT.md or *.heartbeat.md
|
|
4061
|
+
if (entry.name === 'HEARTBEAT.md' || entry.name.endsWith('.heartbeat.md')) {
|
|
4062
|
+
heartbeatFiles.push(fullPath);
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
catch {
|
|
4068
|
+
// Directory not accessible, skip
|
|
4069
|
+
}
|
|
4070
|
+
return heartbeatFiles;
|
|
4071
|
+
}
|
|
4072
|
+
/**
|
|
4073
|
+
* OpenClaw heartbeat security checks (HEARTBEAT-001 to HEARTBEAT-006)
|
|
4074
|
+
*/
|
|
4075
|
+
async checkOpenclawHeartbeat(targetDir, autoFix) {
|
|
4076
|
+
const findings = [];
|
|
4077
|
+
const heartbeatFiles = await this.findHeartbeatFiles(targetDir);
|
|
4078
|
+
for (const heartbeatFile of heartbeatFiles) {
|
|
4079
|
+
const relativePath = path.relative(targetDir, heartbeatFile);
|
|
4080
|
+
let content;
|
|
4081
|
+
try {
|
|
4082
|
+
const stats = await fs.stat(heartbeatFile);
|
|
4083
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
4084
|
+
findings.push({
|
|
4085
|
+
checkId: 'SCAN-001',
|
|
4086
|
+
name: 'Oversized File',
|
|
4087
|
+
description: 'File exceeds maximum scan size',
|
|
4088
|
+
category: 'scan',
|
|
4089
|
+
severity: 'medium',
|
|
4090
|
+
passed: false,
|
|
4091
|
+
message: `File ${relativePath} is ${Math.round(stats.size / 1024 / 1024)}MB - skipped (max ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
|
|
4092
|
+
file: relativePath,
|
|
4093
|
+
fixable: false,
|
|
4094
|
+
fix: 'Reduce file size or exclude from scan',
|
|
4095
|
+
});
|
|
4096
|
+
continue;
|
|
4097
|
+
}
|
|
4098
|
+
content = await fs.readFile(heartbeatFile, 'utf-8');
|
|
4099
|
+
}
|
|
4100
|
+
catch {
|
|
4101
|
+
continue;
|
|
4102
|
+
}
|
|
4103
|
+
const lines = content.split('\n').map(line => line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) : line);
|
|
4104
|
+
// HEARTBEAT-001: Unverified Heartbeat URL
|
|
4105
|
+
const urlPattern = /https?:\/\/[^\s]+/gi;
|
|
4106
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4107
|
+
const line = lines[i];
|
|
4108
|
+
urlPattern.lastIndex = 0;
|
|
4109
|
+
const match = urlPattern.exec(line);
|
|
4110
|
+
if (match) {
|
|
4111
|
+
findings.push({
|
|
4112
|
+
checkId: 'HEARTBEAT-001',
|
|
4113
|
+
name: 'Unverified Heartbeat URL',
|
|
4114
|
+
description: 'Heartbeat contacts external URL without verification',
|
|
4115
|
+
category: 'heartbeat',
|
|
4116
|
+
severity: 'critical',
|
|
4117
|
+
passed: false,
|
|
4118
|
+
message: `External URL detected in heartbeat: "${match[0].substring(0, 60)}..."`,
|
|
4119
|
+
file: relativePath,
|
|
4120
|
+
line: i + 1,
|
|
4121
|
+
fixable: false,
|
|
4122
|
+
fix: 'Verify the URL is from a trusted source and add hash pinning for integrity',
|
|
4123
|
+
});
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
4126
|
+
// HEARTBEAT-002: No Hash Pinning
|
|
4127
|
+
const hasHashPinning = content.includes('pinned_hash:') ||
|
|
4128
|
+
content.includes('sha256:') ||
|
|
4129
|
+
content.includes('hash:');
|
|
4130
|
+
findings.push({
|
|
4131
|
+
checkId: 'HEARTBEAT-002',
|
|
4132
|
+
name: 'No Hash Pinning',
|
|
4133
|
+
description: 'Heartbeat lacks hash pinning for content integrity verification',
|
|
4134
|
+
category: 'heartbeat',
|
|
4135
|
+
severity: 'high',
|
|
4136
|
+
passed: hasHashPinning,
|
|
4137
|
+
message: hasHashPinning
|
|
4138
|
+
? 'Heartbeat has hash pinning for integrity verification'
|
|
4139
|
+
: 'Heartbeat lacks hash pinning - content integrity cannot be verified',
|
|
4140
|
+
file: relativePath,
|
|
4141
|
+
fixable: false,
|
|
4142
|
+
fix: 'Add pinned_hash: sha256:<hash> to verify heartbeat content integrity',
|
|
4143
|
+
});
|
|
4144
|
+
// HEARTBEAT-003: Unsigned Heartbeat
|
|
4145
|
+
const hasSignature = content.includes('opena2a_signature:') ||
|
|
4146
|
+
content.includes('signature:') ||
|
|
4147
|
+
content.includes('-----BEGIN SIGNATURE-----');
|
|
4148
|
+
findings.push({
|
|
4149
|
+
checkId: 'HEARTBEAT-003',
|
|
4150
|
+
name: 'Unsigned Heartbeat',
|
|
4151
|
+
description: 'Heartbeat file lacks cryptographic signature',
|
|
4152
|
+
category: 'heartbeat',
|
|
4153
|
+
severity: 'high',
|
|
4154
|
+
passed: hasSignature,
|
|
4155
|
+
message: hasSignature
|
|
4156
|
+
? 'Heartbeat has cryptographic signature'
|
|
4157
|
+
: 'Heartbeat is unsigned - cannot verify authenticity or integrity',
|
|
4158
|
+
file: relativePath,
|
|
4159
|
+
fixable: false,
|
|
4160
|
+
fix: 'Sign the heartbeat using: openclaw sign heartbeat.md --key ~/.openclaw/signing-key.pem',
|
|
4161
|
+
});
|
|
4162
|
+
// HEARTBEAT-004: Dangerous Capabilities
|
|
4163
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4164
|
+
const line = lines[i].toLowerCase();
|
|
4165
|
+
for (const cap of HEARTBEAT_DANGEROUS_CAPS) {
|
|
4166
|
+
if (line.includes(cap.toLowerCase())) {
|
|
4167
|
+
findings.push({
|
|
4168
|
+
checkId: 'HEARTBEAT-004',
|
|
4169
|
+
name: 'Dangerous Capabilities',
|
|
4170
|
+
description: 'Heartbeat requests dangerous capabilities',
|
|
4171
|
+
category: 'heartbeat',
|
|
4172
|
+
severity: 'critical',
|
|
4173
|
+
passed: false,
|
|
4174
|
+
message: `Dangerous capability "${cap}" detected in heartbeat`,
|
|
4175
|
+
file: relativePath,
|
|
4176
|
+
line: i + 1,
|
|
4177
|
+
fixable: false,
|
|
4178
|
+
fix: 'Heartbeats should use minimal capabilities - avoid shell:*, filesystem:*, network:*',
|
|
4179
|
+
});
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
4183
|
+
// HEARTBEAT-005: Excessive Frequency
|
|
4184
|
+
// Match both "every: 30s" and "Every 30 minutes:" formats
|
|
4185
|
+
const frequencyPattern = /every[:\s]+(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hours?)/gi;
|
|
4186
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4187
|
+
const line = lines[i];
|
|
4188
|
+
frequencyPattern.lastIndex = 0;
|
|
4189
|
+
const match = frequencyPattern.exec(line);
|
|
4190
|
+
if (match) {
|
|
4191
|
+
const value = parseInt(match[1], 10);
|
|
4192
|
+
const unit = match[2].toLowerCase();
|
|
4193
|
+
// Calculate interval in minutes
|
|
4194
|
+
let intervalMinutes = value;
|
|
4195
|
+
if (unit.startsWith('s')) {
|
|
4196
|
+
intervalMinutes = value / 60;
|
|
4197
|
+
}
|
|
4198
|
+
else if (unit.startsWith('h')) {
|
|
4199
|
+
intervalMinutes = value * 60;
|
|
4200
|
+
}
|
|
4201
|
+
if (intervalMinutes < 5) {
|
|
4202
|
+
findings.push({
|
|
4203
|
+
checkId: 'HEARTBEAT-005',
|
|
4204
|
+
name: 'Excessive Frequency',
|
|
4205
|
+
description: 'Heartbeat runs too frequently (< 5 minutes)',
|
|
4206
|
+
category: 'heartbeat',
|
|
4207
|
+
severity: 'medium',
|
|
4208
|
+
passed: false,
|
|
4209
|
+
message: `Heartbeat interval of ${value}${unit} is less than 5 minutes`,
|
|
4210
|
+
file: relativePath,
|
|
4211
|
+
line: i + 1,
|
|
4212
|
+
fixable: false,
|
|
4213
|
+
fix: 'Increase heartbeat interval to at least 5 minutes to prevent resource exhaustion',
|
|
4214
|
+
});
|
|
4215
|
+
}
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
// HEARTBEAT-006: No Active Hours Limit
|
|
4219
|
+
const hasActiveHours = /activeHours:/i.test(content) ||
|
|
4220
|
+
/schedule:/i.test(content) ||
|
|
4221
|
+
/time_window:/i.test(content) ||
|
|
4222
|
+
/run_between:/i.test(content);
|
|
4223
|
+
findings.push({
|
|
4224
|
+
checkId: 'HEARTBEAT-006',
|
|
4225
|
+
name: 'No Active Hours Limit',
|
|
4226
|
+
description: 'Heartbeat lacks time-of-day restrictions',
|
|
4227
|
+
category: 'heartbeat',
|
|
4228
|
+
severity: 'medium',
|
|
4229
|
+
passed: hasActiveHours,
|
|
4230
|
+
message: hasActiveHours
|
|
4231
|
+
? 'Heartbeat has active hours restriction'
|
|
4232
|
+
: 'Heartbeat can run 24/7 without time restrictions',
|
|
4233
|
+
file: relativePath,
|
|
4234
|
+
fixable: false,
|
|
4235
|
+
fix: 'Add activeHours: or schedule: to limit when the heartbeat can run',
|
|
4236
|
+
});
|
|
4237
|
+
}
|
|
4238
|
+
return findings;
|
|
4239
|
+
}
|
|
4240
|
+
/**
|
|
4241
|
+
* Find OpenClaw gateway configuration files
|
|
4242
|
+
*/
|
|
4243
|
+
async findGatewayConfigFiles(dir) {
|
|
4244
|
+
const configFiles = [];
|
|
4245
|
+
const candidates = [
|
|
4246
|
+
'openclaw.json',
|
|
4247
|
+
'.openclaw/config.json',
|
|
4248
|
+
'moltbot.json',
|
|
4249
|
+
'.moltbot/config.json',
|
|
4250
|
+
];
|
|
4251
|
+
for (const candidate of candidates) {
|
|
4252
|
+
const fullPath = path.join(dir, candidate);
|
|
4253
|
+
try {
|
|
4254
|
+
// Validate path is within directory (no path traversal)
|
|
4255
|
+
if (!this.isPathWithinDirectory(fullPath, dir)) {
|
|
4256
|
+
continue;
|
|
4257
|
+
}
|
|
4258
|
+
// Check if it's a symlink
|
|
4259
|
+
const stats = await fs.lstat(fullPath);
|
|
4260
|
+
if (stats.isSymbolicLink()) {
|
|
4261
|
+
continue; // Skip symlinks to prevent path traversal
|
|
4262
|
+
}
|
|
4263
|
+
await fs.access(fullPath);
|
|
4264
|
+
configFiles.push(fullPath);
|
|
4265
|
+
}
|
|
4266
|
+
catch {
|
|
4267
|
+
// File doesn't exist
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
4270
|
+
return configFiles;
|
|
4271
|
+
}
|
|
4272
|
+
/**
|
|
4273
|
+
* OpenClaw gateway security checks (GATEWAY-001 to GATEWAY-006)
|
|
4274
|
+
*/
|
|
4275
|
+
async checkOpenclawGateway(targetDir, autoFix) {
|
|
4276
|
+
const findings = [];
|
|
4277
|
+
const configFiles = await this.findGatewayConfigFiles(targetDir);
|
|
4278
|
+
for (const configFile of configFiles) {
|
|
4279
|
+
const relativePath = path.relative(targetDir, configFile);
|
|
4280
|
+
let content;
|
|
4281
|
+
let config;
|
|
4282
|
+
try {
|
|
4283
|
+
const stats = await fs.stat(configFile);
|
|
4284
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
4285
|
+
findings.push({
|
|
4286
|
+
checkId: 'SCAN-001',
|
|
4287
|
+
name: 'Oversized File',
|
|
4288
|
+
description: 'File exceeds maximum scan size',
|
|
4289
|
+
category: 'scan',
|
|
4290
|
+
severity: 'medium',
|
|
4291
|
+
passed: false,
|
|
4292
|
+
message: `File ${relativePath} is ${Math.round(stats.size / 1024 / 1024)}MB - skipped (max ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
|
|
4293
|
+
file: relativePath,
|
|
4294
|
+
fixable: false,
|
|
4295
|
+
fix: 'Reduce file size or exclude from scan',
|
|
4296
|
+
});
|
|
4297
|
+
continue;
|
|
4298
|
+
}
|
|
4299
|
+
content = await fs.readFile(configFile, 'utf-8');
|
|
4300
|
+
config = JSON.parse(content);
|
|
4301
|
+
}
|
|
4302
|
+
catch {
|
|
4303
|
+
continue;
|
|
4304
|
+
}
|
|
4305
|
+
// GATEWAY-001: Bound to 0.0.0.0
|
|
4306
|
+
const gateway = config.gateway;
|
|
4307
|
+
if (gateway && gateway.host === '0.0.0.0') {
|
|
4308
|
+
findings.push({
|
|
4309
|
+
checkId: 'GATEWAY-001',
|
|
4310
|
+
name: 'Bound to 0.0.0.0',
|
|
4311
|
+
description: 'Gateway is bound to all interfaces (0.0.0.0)',
|
|
4312
|
+
category: 'gateway',
|
|
4313
|
+
severity: 'critical',
|
|
4314
|
+
passed: false,
|
|
4315
|
+
message: 'Gateway host is 0.0.0.0 - accessible from any network interface',
|
|
4316
|
+
file: relativePath,
|
|
4317
|
+
fixable: false,
|
|
4318
|
+
fix: 'Bind to 127.0.0.1 for local-only access or specific interface IP',
|
|
4319
|
+
});
|
|
4320
|
+
}
|
|
4321
|
+
// GATEWAY-002: Missing WebSocket Origin Validation
|
|
4322
|
+
const security = config.security;
|
|
4323
|
+
const hasWebSocketOrigins = security && security.websocketOrigins;
|
|
4324
|
+
findings.push({
|
|
4325
|
+
checkId: 'GATEWAY-002',
|
|
4326
|
+
name: 'Missing WebSocket Origin Validation',
|
|
4327
|
+
description: 'Gateway lacks WebSocket origin validation (GHSA-g8p2)',
|
|
4328
|
+
category: 'gateway',
|
|
4329
|
+
severity: 'critical',
|
|
4330
|
+
passed: Boolean(hasWebSocketOrigins),
|
|
4331
|
+
message: hasWebSocketOrigins
|
|
4332
|
+
? 'WebSocket origin validation is configured'
|
|
4333
|
+
: 'Missing security.websocketOrigins - vulnerable to GHSA-g8p2 cross-origin attacks',
|
|
4334
|
+
file: relativePath,
|
|
4335
|
+
fixable: false,
|
|
4336
|
+
fix: 'Add security.websocketOrigins array with allowed origins',
|
|
4337
|
+
});
|
|
4338
|
+
// GATEWAY-003: Token Exposed in Config
|
|
4339
|
+
const gatewayAuth = gateway?.auth;
|
|
4340
|
+
const hasPlaintextToken = (gatewayAuth && typeof gatewayAuth.token === 'string' && gatewayAuth.token.length > 0) ||
|
|
4341
|
+
(typeof config.token === 'string' && config.token.length > 0);
|
|
4342
|
+
if (hasPlaintextToken) {
|
|
4343
|
+
findings.push({
|
|
4344
|
+
checkId: 'GATEWAY-003',
|
|
4345
|
+
name: 'Token Exposed in Config',
|
|
4346
|
+
description: 'Plaintext authentication token stored in configuration file',
|
|
4347
|
+
category: 'gateway',
|
|
4348
|
+
severity: 'critical',
|
|
4349
|
+
passed: false,
|
|
4350
|
+
message: 'Plaintext token found in configuration - use environment variables instead',
|
|
4351
|
+
file: relativePath,
|
|
4352
|
+
fixable: false,
|
|
4353
|
+
fix: 'Move tokens to environment variables: OPENCLAW_AUTH_TOKEN',
|
|
4354
|
+
});
|
|
4355
|
+
}
|
|
4356
|
+
// GATEWAY-004: Approval Confirmations Disabled
|
|
4357
|
+
const exec = config.exec;
|
|
4358
|
+
const approvals = exec?.approvals;
|
|
4359
|
+
const configApprovals = config.approvals;
|
|
4360
|
+
const approvalsDisabled = approvals?.set === 'off' ||
|
|
4361
|
+
approvals?.enabled === false ||
|
|
4362
|
+
configApprovals?.enabled === false;
|
|
4363
|
+
if (approvalsDisabled) {
|
|
4364
|
+
findings.push({
|
|
4365
|
+
checkId: 'GATEWAY-004',
|
|
4366
|
+
name: 'Approval Confirmations Disabled',
|
|
4367
|
+
description: 'Execution approval confirmations are disabled',
|
|
4368
|
+
category: 'gateway',
|
|
4369
|
+
severity: 'critical',
|
|
4370
|
+
passed: false,
|
|
4371
|
+
message: 'Approval confirmations disabled - commands execute without user confirmation',
|
|
4372
|
+
file: relativePath,
|
|
4373
|
+
fixable: false,
|
|
4374
|
+
fix: 'Enable approvals: exec.approvals.set = "on" or approvals.enabled = true',
|
|
4375
|
+
});
|
|
4376
|
+
}
|
|
4377
|
+
// GATEWAY-005: Sandbox Disabled
|
|
4378
|
+
const sandbox = config.sandbox;
|
|
4379
|
+
if (sandbox && sandbox.enabled === false) {
|
|
4380
|
+
findings.push({
|
|
4381
|
+
checkId: 'GATEWAY-005',
|
|
4382
|
+
name: 'Sandbox Disabled',
|
|
4383
|
+
description: 'Sandbox execution environment is disabled',
|
|
4384
|
+
category: 'gateway',
|
|
4385
|
+
severity: 'critical',
|
|
4386
|
+
passed: false,
|
|
4387
|
+
message: 'Sandbox is disabled - code executes with full system access',
|
|
4388
|
+
file: relativePath,
|
|
4389
|
+
fixable: false,
|
|
4390
|
+
fix: 'Enable sandbox: sandbox.enabled = true',
|
|
4391
|
+
});
|
|
4392
|
+
}
|
|
4393
|
+
// GATEWAY-006: Container Escape Risk
|
|
4394
|
+
const docker = config.docker;
|
|
4395
|
+
const isPrivileged = docker?.privileged === true;
|
|
4396
|
+
const mounts = docker?.mounts;
|
|
4397
|
+
const hasDangerousMounts = mounts?.some((mount) => mount.includes('/var/run/docker.sock') ||
|
|
4398
|
+
mount.includes('/etc/passwd') ||
|
|
4399
|
+
mount.includes('/etc/shadow') ||
|
|
4400
|
+
mount.startsWith('/:/') ||
|
|
4401
|
+
mount.includes(':/host'));
|
|
4402
|
+
if (isPrivileged || hasDangerousMounts) {
|
|
4403
|
+
const issues = [];
|
|
4404
|
+
if (isPrivileged)
|
|
4405
|
+
issues.push('privileged mode');
|
|
4406
|
+
if (hasDangerousMounts)
|
|
4407
|
+
issues.push('sensitive host mounts');
|
|
4408
|
+
findings.push({
|
|
4409
|
+
checkId: 'GATEWAY-006',
|
|
4410
|
+
name: 'Container Escape Risk',
|
|
4411
|
+
description: 'Docker configuration allows container escape',
|
|
4412
|
+
category: 'gateway',
|
|
4413
|
+
severity: 'critical',
|
|
4414
|
+
passed: false,
|
|
4415
|
+
message: `Container escape risk: ${issues.join(', ')}`,
|
|
4416
|
+
file: relativePath,
|
|
4417
|
+
fixable: false,
|
|
4418
|
+
fix: 'Disable privileged mode and remove sensitive host mounts',
|
|
4419
|
+
});
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
return findings;
|
|
4423
|
+
}
|
|
4424
|
+
/**
|
|
4425
|
+
* Calculate Levenshtein distance between two strings
|
|
4426
|
+
*/
|
|
4427
|
+
levenshteinDistance(a, b) {
|
|
4428
|
+
const matrix = [];
|
|
4429
|
+
for (let i = 0; i <= b.length; i++) {
|
|
4430
|
+
matrix[i] = [i];
|
|
4431
|
+
}
|
|
4432
|
+
for (let j = 0; j <= a.length; j++) {
|
|
4433
|
+
matrix[0][j] = j;
|
|
4434
|
+
}
|
|
4435
|
+
for (let i = 1; i <= b.length; i++) {
|
|
4436
|
+
for (let j = 1; j <= a.length; j++) {
|
|
4437
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
4438
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
4439
|
+
}
|
|
4440
|
+
else {
|
|
4441
|
+
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
|
|
4442
|
+
}
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
return matrix[b.length][a.length];
|
|
4446
|
+
}
|
|
4447
|
+
/**
|
|
4448
|
+
* Find files matching a pattern recursively (max depth 3, skips node_modules/.git)
|
|
4449
|
+
*/
|
|
4450
|
+
async findFilesMatching(targetDir, patterns, maxDepth = 3) {
|
|
4451
|
+
const matchedFiles = [];
|
|
4452
|
+
const scanDir = async (dir, currentDepth) => {
|
|
4453
|
+
if (currentDepth > maxDepth)
|
|
4454
|
+
return;
|
|
4455
|
+
let entries;
|
|
4456
|
+
try {
|
|
4457
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
4458
|
+
}
|
|
4459
|
+
catch {
|
|
4460
|
+
return;
|
|
4461
|
+
}
|
|
4462
|
+
for (const entry of entries) {
|
|
4463
|
+
// Skip symlinks to prevent path traversal
|
|
4464
|
+
if (entry.isSymbolicLink()) {
|
|
4465
|
+
continue;
|
|
4466
|
+
}
|
|
4467
|
+
const entryName = entry.name;
|
|
4468
|
+
const fullPath = path.join(dir, entryName);
|
|
4469
|
+
// Validate path is within directory (no path traversal)
|
|
4470
|
+
if (!this.isPathWithinDirectory(fullPath, targetDir)) {
|
|
4471
|
+
continue;
|
|
4472
|
+
}
|
|
4473
|
+
// Skip node_modules and .git directories
|
|
4474
|
+
if (entryName === 'node_modules' || entryName === '.git') {
|
|
4475
|
+
continue;
|
|
4476
|
+
}
|
|
4477
|
+
let stat;
|
|
4478
|
+
try {
|
|
4479
|
+
stat = await fs.stat(fullPath);
|
|
4480
|
+
}
|
|
4481
|
+
catch {
|
|
4482
|
+
continue;
|
|
4483
|
+
}
|
|
4484
|
+
if (stat.isDirectory()) {
|
|
4485
|
+
await scanDir(fullPath, currentDepth + 1);
|
|
4486
|
+
}
|
|
4487
|
+
else if (stat.isFile()) {
|
|
4488
|
+
// Check if filename matches any pattern
|
|
4489
|
+
const lowerName = entryName.toLowerCase();
|
|
4490
|
+
for (const pattern of patterns) {
|
|
4491
|
+
if (lowerName.includes(pattern.toLowerCase())) {
|
|
4492
|
+
matchedFiles.push(fullPath);
|
|
4493
|
+
break;
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
};
|
|
4499
|
+
await scanDir(targetDir, 0);
|
|
4500
|
+
return matchedFiles;
|
|
4501
|
+
}
|
|
4502
|
+
/**
|
|
4503
|
+
* OpenClaw config security checks (CONFIG-001 to CONFIG-006)
|
|
4504
|
+
*/
|
|
4505
|
+
async checkOpenclawConfig(targetDir, autoFix) {
|
|
4506
|
+
const findings = [];
|
|
4507
|
+
// CONFIG-001: Session File Exposure
|
|
4508
|
+
const sessionPatterns = [
|
|
4509
|
+
'whatsapp-session',
|
|
4510
|
+
'discord-token',
|
|
4511
|
+
'telegram-session',
|
|
4512
|
+
'slack-token',
|
|
4513
|
+
'session.json',
|
|
4514
|
+
];
|
|
4515
|
+
const sessionFiles = await this.findFilesMatching(targetDir, sessionPatterns);
|
|
4516
|
+
for (const sessionFile of sessionFiles) {
|
|
4517
|
+
const relativePath = path.relative(targetDir, sessionFile);
|
|
4518
|
+
findings.push({
|
|
4519
|
+
checkId: 'CONFIG-001',
|
|
4520
|
+
name: 'Session File Exposure',
|
|
4521
|
+
description: 'Session/token file found that may contain sensitive credentials',
|
|
4522
|
+
category: 'config',
|
|
4523
|
+
severity: 'critical',
|
|
4524
|
+
passed: false,
|
|
4525
|
+
message: `Session/token file exposed: ${path.basename(sessionFile)}`,
|
|
4526
|
+
file: relativePath,
|
|
4527
|
+
fixable: false,
|
|
4528
|
+
fix: 'Move session files outside the project directory or add to .gitignore',
|
|
4529
|
+
});
|
|
4530
|
+
}
|
|
4531
|
+
// CONFIG-002: SOUL.md Injection Vectors
|
|
4532
|
+
const soulFiles = await this.findFilesMatching(targetDir, ['SOUL.md']);
|
|
4533
|
+
for (const soulFile of soulFiles) {
|
|
4534
|
+
const relativePath = path.relative(targetDir, soulFile);
|
|
4535
|
+
let content;
|
|
4536
|
+
try {
|
|
4537
|
+
const stats = await fs.stat(soulFile);
|
|
4538
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
4539
|
+
findings.push({
|
|
4540
|
+
checkId: 'SCAN-001',
|
|
4541
|
+
name: 'Oversized File',
|
|
4542
|
+
description: 'File exceeds maximum scan size',
|
|
4543
|
+
category: 'scan',
|
|
4544
|
+
severity: 'medium',
|
|
4545
|
+
passed: false,
|
|
4546
|
+
message: `File ${relativePath} is ${Math.round(stats.size / 1024 / 1024)}MB - skipped (max ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
|
|
4547
|
+
file: relativePath,
|
|
4548
|
+
fixable: false,
|
|
4549
|
+
fix: 'Reduce file size or exclude from scan',
|
|
4550
|
+
});
|
|
4551
|
+
continue;
|
|
4552
|
+
}
|
|
4553
|
+
content = await fs.readFile(soulFile, 'utf-8');
|
|
4554
|
+
}
|
|
4555
|
+
catch {
|
|
4556
|
+
continue;
|
|
4557
|
+
}
|
|
4558
|
+
for (const pattern of PROMPT_INJECTION_PATTERNS) {
|
|
4559
|
+
const match = content.match(pattern);
|
|
4560
|
+
if (match) {
|
|
4561
|
+
findings.push({
|
|
4562
|
+
checkId: 'CONFIG-002',
|
|
4563
|
+
name: 'SOUL.md Injection Vectors',
|
|
4564
|
+
description: 'SOUL.md contains potential prompt injection patterns',
|
|
4565
|
+
category: 'config',
|
|
4566
|
+
severity: 'high',
|
|
4567
|
+
passed: false,
|
|
4568
|
+
message: `Prompt injection pattern detected: "${match[0]}"`,
|
|
4569
|
+
file: relativePath,
|
|
4570
|
+
fixable: false,
|
|
4571
|
+
fix: 'Review and remove suspicious patterns from SOUL.md',
|
|
4572
|
+
});
|
|
4573
|
+
break; // Only report first match per file
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
}
|
|
4577
|
+
// CONFIG-003: Daemon Running as Root
|
|
4578
|
+
const daemonPatterns = ['daemon.sh', 'start.sh', 'run.sh'];
|
|
4579
|
+
const daemonFiles = await this.findFilesMatching(targetDir, daemonPatterns);
|
|
4580
|
+
const rootPatterns = [/\bsudo\b/gi, /User=root/gi, /uid=0/gi];
|
|
4581
|
+
for (const daemonFile of daemonFiles) {
|
|
4582
|
+
const relativePath = path.relative(targetDir, daemonFile);
|
|
4583
|
+
let content;
|
|
4584
|
+
try {
|
|
4585
|
+
const stats = await fs.stat(daemonFile);
|
|
4586
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
4587
|
+
findings.push({
|
|
4588
|
+
checkId: 'SCAN-001',
|
|
4589
|
+
name: 'Oversized File',
|
|
4590
|
+
description: 'File exceeds maximum scan size',
|
|
4591
|
+
category: 'scan',
|
|
4592
|
+
severity: 'medium',
|
|
4593
|
+
passed: false,
|
|
4594
|
+
message: `File ${relativePath} is ${Math.round(stats.size / 1024 / 1024)}MB - skipped (max ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
|
|
4595
|
+
file: relativePath,
|
|
4596
|
+
fixable: false,
|
|
4597
|
+
fix: 'Reduce file size or exclude from scan',
|
|
4598
|
+
});
|
|
4599
|
+
continue;
|
|
4600
|
+
}
|
|
4601
|
+
content = await fs.readFile(daemonFile, 'utf-8');
|
|
4602
|
+
}
|
|
4603
|
+
catch {
|
|
4604
|
+
continue;
|
|
4605
|
+
}
|
|
4606
|
+
for (const pattern of rootPatterns) {
|
|
4607
|
+
const match = content.match(pattern);
|
|
4608
|
+
if (match) {
|
|
4609
|
+
findings.push({
|
|
4610
|
+
checkId: 'CONFIG-003',
|
|
4611
|
+
name: 'Daemon Running as Root',
|
|
4612
|
+
description: 'Daemon script runs with root privileges',
|
|
4613
|
+
category: 'config',
|
|
4614
|
+
severity: 'critical',
|
|
4615
|
+
passed: false,
|
|
4616
|
+
message: `Root privilege pattern found: "${match[0]}"`,
|
|
4617
|
+
file: relativePath,
|
|
4618
|
+
fixable: false,
|
|
4619
|
+
fix: 'Run daemon as non-root user with minimal privileges',
|
|
4620
|
+
});
|
|
4621
|
+
break; // Only report first match per file
|
|
4622
|
+
}
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4625
|
+
// CONFIG-004: Plaintext API Keys
|
|
4626
|
+
const envFiles = await this.findFilesMatching(targetDir, ['.env']);
|
|
4627
|
+
for (const envFile of envFiles) {
|
|
4628
|
+
const relativePath = path.relative(targetDir, envFile);
|
|
4629
|
+
let content;
|
|
4630
|
+
try {
|
|
4631
|
+
const stats = await fs.stat(envFile);
|
|
4632
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
4633
|
+
findings.push({
|
|
4634
|
+
checkId: 'SCAN-001',
|
|
4635
|
+
name: 'Oversized File',
|
|
4636
|
+
description: 'File exceeds maximum scan size',
|
|
4637
|
+
category: 'scan',
|
|
4638
|
+
severity: 'medium',
|
|
4639
|
+
passed: false,
|
|
4640
|
+
message: `File ${relativePath} is ${Math.round(stats.size / 1024 / 1024)}MB - skipped (max ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
|
|
4641
|
+
file: relativePath,
|
|
4642
|
+
fixable: false,
|
|
4643
|
+
fix: 'Reduce file size or exclude from scan',
|
|
4644
|
+
});
|
|
4645
|
+
continue;
|
|
4646
|
+
}
|
|
4647
|
+
content = await fs.readFile(envFile, 'utf-8');
|
|
4648
|
+
}
|
|
4649
|
+
catch {
|
|
4650
|
+
continue;
|
|
4651
|
+
}
|
|
4652
|
+
for (const { name, pattern } of CREDENTIAL_PATTERNS) {
|
|
4653
|
+
const match = content.match(pattern);
|
|
4654
|
+
if (match) {
|
|
4655
|
+
findings.push({
|
|
4656
|
+
checkId: 'CONFIG-004',
|
|
4657
|
+
name: 'Plaintext API Keys',
|
|
4658
|
+
description: 'Plaintext API key found in environment file',
|
|
4659
|
+
category: 'config',
|
|
4660
|
+
severity: 'critical',
|
|
4661
|
+
passed: false,
|
|
4662
|
+
message: `${name} found in plaintext`,
|
|
4663
|
+
file: relativePath,
|
|
4664
|
+
fixable: false,
|
|
4665
|
+
fix: 'Use a secrets manager or ensure .env is in .gitignore',
|
|
4666
|
+
});
|
|
4667
|
+
break; // Only report first match per file
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
}
|
|
4671
|
+
// CONFIG-005: Memory Poisoning Patterns
|
|
4672
|
+
const memoryFiles = await this.findFilesMatching(targetDir, ['memory.json']);
|
|
4673
|
+
const memoryPoisonPatterns = [
|
|
4674
|
+
...PROMPT_INJECTION_PATTERNS,
|
|
4675
|
+
/\bbase64\b/gi,
|
|
4676
|
+
/\beval\s*\(/gi,
|
|
4677
|
+
/\bexec\s*\(/gi,
|
|
4678
|
+
];
|
|
4679
|
+
for (const memoryFile of memoryFiles) {
|
|
4680
|
+
const relativePath = path.relative(targetDir, memoryFile);
|
|
4681
|
+
let content;
|
|
4682
|
+
try {
|
|
4683
|
+
const stats = await fs.stat(memoryFile);
|
|
4684
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
4685
|
+
findings.push({
|
|
4686
|
+
checkId: 'SCAN-001',
|
|
4687
|
+
name: 'Oversized File',
|
|
4688
|
+
description: 'File exceeds maximum scan size',
|
|
4689
|
+
category: 'scan',
|
|
4690
|
+
severity: 'medium',
|
|
4691
|
+
passed: false,
|
|
4692
|
+
message: `File ${relativePath} is ${Math.round(stats.size / 1024 / 1024)}MB - skipped (max ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
|
|
4693
|
+
file: relativePath,
|
|
4694
|
+
fixable: false,
|
|
4695
|
+
fix: 'Reduce file size or exclude from scan',
|
|
4696
|
+
});
|
|
4697
|
+
continue;
|
|
4698
|
+
}
|
|
4699
|
+
content = await fs.readFile(memoryFile, 'utf-8');
|
|
4700
|
+
}
|
|
4701
|
+
catch {
|
|
4702
|
+
continue;
|
|
4703
|
+
}
|
|
4704
|
+
for (const pattern of memoryPoisonPatterns) {
|
|
4705
|
+
const match = content.match(pattern);
|
|
4706
|
+
if (match) {
|
|
4707
|
+
findings.push({
|
|
4708
|
+
checkId: 'CONFIG-005',
|
|
4709
|
+
name: 'Memory Poisoning Patterns',
|
|
4710
|
+
description: 'memory.json contains suspicious patterns that could poison agent memory',
|
|
4711
|
+
category: 'config',
|
|
4712
|
+
severity: 'high',
|
|
4713
|
+
passed: false,
|
|
4714
|
+
message: `Suspicious pattern in memory: "${match[0]}"`,
|
|
4715
|
+
file: relativePath,
|
|
4716
|
+
fixable: false,
|
|
4717
|
+
fix: 'Review and sanitize memory.json contents',
|
|
4718
|
+
});
|
|
4719
|
+
break; // Only report first match per file
|
|
4720
|
+
}
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4723
|
+
// CONFIG-006: Moltbook Integration Risk
|
|
4724
|
+
const openclawConfigFiles = await this.findFilesMatching(targetDir, ['openclaw.json']);
|
|
4725
|
+
for (const configFile of openclawConfigFiles) {
|
|
4726
|
+
const relativePath = path.relative(targetDir, configFile);
|
|
4727
|
+
let config;
|
|
4728
|
+
try {
|
|
4729
|
+
const stats = await fs.stat(configFile);
|
|
4730
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
4731
|
+
findings.push({
|
|
4732
|
+
checkId: 'SCAN-001',
|
|
4733
|
+
name: 'Oversized File',
|
|
4734
|
+
description: 'File exceeds maximum scan size',
|
|
4735
|
+
category: 'scan',
|
|
4736
|
+
severity: 'medium',
|
|
4737
|
+
passed: false,
|
|
4738
|
+
message: `File ${relativePath} is ${Math.round(stats.size / 1024 / 1024)}MB - skipped (max ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
|
|
4739
|
+
file: relativePath,
|
|
4740
|
+
fixable: false,
|
|
4741
|
+
fix: 'Reduce file size or exclude from scan',
|
|
4742
|
+
});
|
|
4743
|
+
continue;
|
|
4744
|
+
}
|
|
4745
|
+
const content = await fs.readFile(configFile, 'utf-8');
|
|
4746
|
+
config = JSON.parse(content);
|
|
4747
|
+
}
|
|
4748
|
+
catch {
|
|
4749
|
+
continue;
|
|
4750
|
+
}
|
|
4751
|
+
const moltbook = config.moltbook;
|
|
4752
|
+
if (moltbook && moltbook.enabled === true && moltbook.autoFollow === true) {
|
|
4753
|
+
findings.push({
|
|
4754
|
+
checkId: 'CONFIG-006',
|
|
4755
|
+
name: 'Moltbook Integration Risk',
|
|
4756
|
+
description: 'Moltbook auto-follow enabled, allowing automatic following of untrusted agents',
|
|
4757
|
+
category: 'config',
|
|
4758
|
+
severity: 'high',
|
|
4759
|
+
passed: false,
|
|
4760
|
+
message: 'Moltbook enabled with autoFollow - may auto-follow untrusted agents',
|
|
4761
|
+
file: relativePath,
|
|
4762
|
+
fixable: false,
|
|
4763
|
+
fix: 'Disable autoFollow or review moltbook security settings',
|
|
4764
|
+
});
|
|
4765
|
+
}
|
|
4766
|
+
}
|
|
4767
|
+
return findings;
|
|
4768
|
+
}
|
|
4769
|
+
/**
|
|
4770
|
+
* OpenClaw supply chain security checks (SUPPLY-001 to SUPPLY-004)
|
|
4771
|
+
*/
|
|
4772
|
+
async checkOpenclawSupplyChain(targetDir, autoFix) {
|
|
4773
|
+
const findings = [];
|
|
4774
|
+
const skillFiles = await this.findSkillFiles(targetDir);
|
|
4775
|
+
// Known malicious skill patterns from ClawHavoc campaign
|
|
4776
|
+
const clawHavocPatterns = [
|
|
4777
|
+
'polymarket',
|
|
4778
|
+
'better-polymarket',
|
|
4779
|
+
'crypto-tracker',
|
|
4780
|
+
'solana-tracker',
|
|
4781
|
+
'phantom-wallet',
|
|
4782
|
+
'youtube-downloader',
|
|
4783
|
+
'clawhub',
|
|
4784
|
+
];
|
|
4785
|
+
for (const skillFile of skillFiles) {
|
|
4786
|
+
const relativePath = path.relative(targetDir, skillFile);
|
|
4787
|
+
let content;
|
|
4788
|
+
try {
|
|
4789
|
+
const stats = await fs.stat(skillFile);
|
|
4790
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
4791
|
+
findings.push({
|
|
4792
|
+
checkId: 'SCAN-001',
|
|
4793
|
+
name: 'Oversized File',
|
|
4794
|
+
description: 'File exceeds maximum scan size',
|
|
4795
|
+
category: 'scan',
|
|
4796
|
+
severity: 'medium',
|
|
4797
|
+
passed: false,
|
|
4798
|
+
message: `File ${relativePath} is ${Math.round(stats.size / 1024 / 1024)}MB - skipped (max ${MAX_FILE_SIZE / 1024 / 1024}MB)`,
|
|
4799
|
+
file: relativePath,
|
|
4800
|
+
fixable: false,
|
|
4801
|
+
fix: 'Reduce file size or exclude from scan',
|
|
4802
|
+
});
|
|
4803
|
+
continue;
|
|
4804
|
+
}
|
|
4805
|
+
content = await fs.readFile(skillFile, 'utf-8');
|
|
4806
|
+
}
|
|
4807
|
+
catch {
|
|
4808
|
+
continue;
|
|
4809
|
+
}
|
|
4810
|
+
// Parse YAML frontmatter
|
|
4811
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
4812
|
+
const frontmatter = frontmatterMatch ? frontmatterMatch[1] : '';
|
|
4813
|
+
// Extract skill name from filename or frontmatter
|
|
4814
|
+
const skillNameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
4815
|
+
const skillName = skillNameMatch
|
|
4816
|
+
? skillNameMatch[1].trim().replace(/["']/g, '').toLowerCase()
|
|
4817
|
+
: path.basename(path.dirname(skillFile)).toLowerCase();
|
|
4818
|
+
// SUPPLY-001: Unverified Publisher
|
|
4819
|
+
const hasPublisher = /^publisher:\s*.+$/m.test(frontmatter);
|
|
4820
|
+
const hasPublisherVerified = /^publisher_verified:\s*true$/m.test(frontmatter);
|
|
4821
|
+
findings.push({
|
|
4822
|
+
checkId: 'SUPPLY-001',
|
|
4823
|
+
name: 'Unverified Publisher',
|
|
4824
|
+
description: 'Skill publisher identity has not been verified',
|
|
4825
|
+
category: 'supply',
|
|
4826
|
+
severity: 'high',
|
|
4827
|
+
passed: hasPublisher && hasPublisherVerified,
|
|
4828
|
+
message: hasPublisher && hasPublisherVerified
|
|
4829
|
+
? 'Skill publisher is verified'
|
|
4830
|
+
: hasPublisher
|
|
4831
|
+
? 'Skill has publisher but publisher_verified is not true'
|
|
4832
|
+
: 'Skill lacks publisher metadata - cannot verify source',
|
|
4833
|
+
file: relativePath,
|
|
4834
|
+
fixable: false,
|
|
4835
|
+
fix: 'Add publisher: and publisher_verified: true to skill frontmatter after verification',
|
|
4836
|
+
});
|
|
4837
|
+
// SUPPLY-002: Skill Not in Registry
|
|
4838
|
+
const hasRegistryAttestation = /^registry_attestation:\s*.+$/m.test(frontmatter);
|
|
4839
|
+
findings.push({
|
|
4840
|
+
checkId: 'SUPPLY-002',
|
|
4841
|
+
name: 'Skill Not in Registry',
|
|
4842
|
+
description: 'Skill has not been registered with a trusted skill registry',
|
|
4843
|
+
category: 'supply',
|
|
4844
|
+
severity: 'medium',
|
|
4845
|
+
passed: hasRegistryAttestation,
|
|
4846
|
+
message: hasRegistryAttestation
|
|
4847
|
+
? 'Skill has registry attestation'
|
|
4848
|
+
: 'Skill lacks registry_attestation - not listed in trusted registry',
|
|
4849
|
+
file: relativePath,
|
|
4850
|
+
fixable: false,
|
|
4851
|
+
fix: 'Register skill with a trusted registry (e.g., clawhub.io, skillregistry.openclaw.org)',
|
|
4852
|
+
});
|
|
4853
|
+
// SUPPLY-003: Known Malicious Skill Pattern (ClawHavoc campaign)
|
|
4854
|
+
let isMaliciousMatch = false;
|
|
4855
|
+
let matchedPattern = '';
|
|
4856
|
+
for (const pattern of clawHavocPatterns) {
|
|
4857
|
+
// Check for exact match or substring
|
|
4858
|
+
if (skillName.includes(pattern)) {
|
|
4859
|
+
isMaliciousMatch = true;
|
|
4860
|
+
matchedPattern = pattern;
|
|
4861
|
+
break;
|
|
4862
|
+
}
|
|
4863
|
+
// Check for typosquatting (Levenshtein distance <= 1)
|
|
4864
|
+
const distance = this.levenshteinDistance(skillName, pattern);
|
|
4865
|
+
if (distance <= 1 && distance > 0) {
|
|
4866
|
+
isMaliciousMatch = true;
|
|
4867
|
+
matchedPattern = `${skillName} (similar to ${pattern})`;
|
|
4868
|
+
break;
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4871
|
+
if (isMaliciousMatch) {
|
|
4872
|
+
findings.push({
|
|
4873
|
+
checkId: 'SUPPLY-003',
|
|
4874
|
+
name: 'Known Malicious Skill Pattern',
|
|
4875
|
+
description: 'Skill matches known malicious patterns from ClawHavoc campaign',
|
|
4876
|
+
category: 'supply',
|
|
4877
|
+
severity: 'critical',
|
|
4878
|
+
passed: false,
|
|
4879
|
+
message: `Skill matches known malicious pattern: "${matchedPattern}"`,
|
|
4880
|
+
file: relativePath,
|
|
4881
|
+
fixable: false,
|
|
4882
|
+
fix: 'Remove this skill immediately - it matches known malware from the ClawHavoc campaign',
|
|
4883
|
+
});
|
|
4884
|
+
}
|
|
4885
|
+
// SUPPLY-004: Version Drift Detection
|
|
4886
|
+
const hasInstalledHash = /^installed_hash:\s*.+$/m.test(frontmatter);
|
|
4887
|
+
findings.push({
|
|
4888
|
+
checkId: 'SUPPLY-004',
|
|
4889
|
+
name: 'Version Drift Detection',
|
|
4890
|
+
description: 'Skill lacks installed_hash for detecting unauthorized modifications',
|
|
4891
|
+
category: 'supply',
|
|
4892
|
+
severity: 'high',
|
|
4893
|
+
passed: hasInstalledHash,
|
|
4894
|
+
message: hasInstalledHash
|
|
4895
|
+
? 'Skill has installed_hash for integrity verification'
|
|
4896
|
+
: 'Skill lacks installed_hash - cannot detect version drift or tampering',
|
|
4897
|
+
file: relativePath,
|
|
4898
|
+
fixable: false,
|
|
4899
|
+
fix: 'Add installed_hash: with SHA-256 hash of the original skill content',
|
|
4900
|
+
});
|
|
4901
|
+
}
|
|
4902
|
+
return findings;
|
|
4903
|
+
}
|
|
3522
4904
|
}
|
|
3523
4905
|
exports.HardeningScanner = HardeningScanner;
|
|
3524
4906
|
// Files that may be created or modified during auto-fix
|