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.
@@ -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