@contextfort-ai/openclaw-secure 0.1.9 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/bin/openclaw-secure.js +71 -0
- package/monitor/dashboard/public/index.html +1125 -42
- package/monitor/dashboard/scan-worker.js +21 -0
- package/monitor/dashboard/server.js +258 -7
- package/monitor/exfil_guard/index.js +79 -7
- package/monitor/plugin_guard/index.js +194 -0
- package/monitor/prompt_injection_guard/index.js +17 -2
- package/monitor/secrets_guard/index.js +148 -96
- package/monitor/skills_guard/index.js +170 -30
- package/openclaw-secure.js +178 -37
- package/package.json +1 -1
- package/test/guard-test-200.js +1312 -0
|
@@ -72,56 +72,89 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
|
|
|
72
72
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
73
73
|
for (const e of entries) {
|
|
74
74
|
if (e.isDirectory()) {
|
|
75
|
-
skills.push({ skillPath: path.join(dir, e.name), skillName: e.name });
|
|
75
|
+
skills.push({ skillPath: path.join(dir, e.name), skillName: e.name, type: 'skill' });
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
// If there are loose files directly in the skill dir (e.g. SKILL.md), treat as a skill
|
|
79
79
|
const hasLooseFiles = entries.some(e => e.isFile());
|
|
80
80
|
if (hasLooseFiles) {
|
|
81
|
-
skills.push({ skillPath: dir, skillName: path.basename(dir) });
|
|
81
|
+
skills.push({ skillPath: dir, skillName: path.basename(dir), type: 'skill' });
|
|
82
82
|
}
|
|
83
83
|
} catch {}
|
|
84
84
|
return skills;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function getPluginEntries() {
|
|
88
|
+
const pluginsDir = path.join(HOME, '.claude', 'plugins');
|
|
89
|
+
const entries = [];
|
|
90
|
+
try {
|
|
91
|
+
const dirents = fs.readdirSync(pluginsDir, { withFileTypes: true });
|
|
92
|
+
for (const e of dirents) {
|
|
93
|
+
if (e.isDirectory() && !e.name.startsWith('.')) {
|
|
94
|
+
entries.push({
|
|
95
|
+
skillPath: path.join(pluginsDir, e.name),
|
|
96
|
+
skillName: e.name,
|
|
97
|
+
type: 'plugin',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
return entries;
|
|
103
|
+
}
|
|
104
|
+
|
|
87
105
|
function readSkillFiles(skillPath) {
|
|
88
106
|
const files = [];
|
|
89
107
|
const binaryFiles = [];
|
|
108
|
+
const skippedFiles = []; // { file, reason }
|
|
90
109
|
const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB
|
|
91
110
|
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
|
|
92
111
|
let totalSize = 0;
|
|
112
|
+
let totalSizeExceeded = false;
|
|
93
113
|
|
|
94
114
|
function walk(dirPath, base) {
|
|
95
115
|
let entries;
|
|
96
116
|
try { entries = fs.readdirSync(dirPath, { withFileTypes: true }); } catch { return; }
|
|
97
117
|
for (const e of entries) {
|
|
98
|
-
if (e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
118
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
99
119
|
const full = path.join(dirPath, e.name);
|
|
120
|
+
const rel = path.join(base, e.name);
|
|
100
121
|
if (e.isDirectory()) {
|
|
101
|
-
walk(full,
|
|
122
|
+
walk(full, rel);
|
|
102
123
|
} else if (e.isFile()) {
|
|
103
124
|
try {
|
|
104
125
|
const stat = fs.statSync(full);
|
|
105
|
-
if (stat.size
|
|
106
|
-
|
|
126
|
+
if (stat.size === 0) {
|
|
127
|
+
skippedFiles.push({ file: rel, reason: 'empty file (0 bytes)' });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
131
|
+
skippedFiles.push({ file: rel, reason: `exceeds 1MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB)` });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (totalSize + stat.size > MAX_TOTAL_SIZE) {
|
|
135
|
+
if (!totalSizeExceeded) totalSizeExceeded = true;
|
|
136
|
+
skippedFiles.push({ file: rel, reason: `total size would exceed 5MB limit (already ${(totalSize / 1024 / 1024).toFixed(1)}MB)` });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
107
139
|
// Check for binaries: read first 512 bytes and check for null bytes
|
|
108
140
|
const buf = Buffer.alloc(Math.min(512, stat.size));
|
|
109
141
|
const fd = fs.openSync(full, 'r');
|
|
110
142
|
try { fs.readSync(fd, buf, 0, buf.length, 0); } finally { fs.closeSync(fd); }
|
|
111
143
|
if (buf.includes(0)) {
|
|
112
|
-
binaryFiles.push(
|
|
144
|
+
binaryFiles.push(rel);
|
|
145
|
+
skippedFiles.push({ file: rel, reason: 'binary file (contains null bytes)' });
|
|
113
146
|
continue;
|
|
114
147
|
}
|
|
115
148
|
|
|
116
149
|
const content = readFileSync(full, 'utf8');
|
|
117
150
|
totalSize += stat.size;
|
|
118
|
-
files.push({ relative_path:
|
|
151
|
+
files.push({ relative_path: rel, content });
|
|
119
152
|
} catch {}
|
|
120
153
|
}
|
|
121
154
|
}
|
|
122
155
|
}
|
|
123
156
|
walk(skillPath, '');
|
|
124
|
-
return { files, binaryFiles };
|
|
157
|
+
return { files, binaryFiles, skippedFiles, totalSize };
|
|
125
158
|
}
|
|
126
159
|
|
|
127
160
|
function hashSkillFiles(files) {
|
|
@@ -165,16 +198,21 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
|
|
|
165
198
|
} catch {}
|
|
166
199
|
}
|
|
167
200
|
|
|
168
|
-
function scanSkillAsync(skillPath, files, hash, installId) {
|
|
201
|
+
function scanSkillAsync(skillPath, files, hash, installId, type, skippedFiles) {
|
|
169
202
|
if (pendingScans.has(skillPath)) return;
|
|
170
203
|
pendingScans.add(skillPath);
|
|
171
|
-
track('skill_scan_started', { skill_name: path.basename(skillPath), file_count: files.length });
|
|
204
|
+
track('skill_scan_started', { skill_name: path.basename(skillPath), file_count: files.length, type: type || 'skill' });
|
|
205
|
+
if (localLogger) {
|
|
206
|
+
try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scanning', reason: `Scanning ${type || 'skill'}: ${path.basename(skillPath)} (${files.length} files)`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), file_contents: files.map(f => ({ path: f.relative_path, content: f.content.slice(0, 2000) })), type: type || 'skill' } }); } catch {}
|
|
207
|
+
}
|
|
172
208
|
|
|
173
209
|
const payload = JSON.stringify({
|
|
174
210
|
install_id: installId,
|
|
175
211
|
skill_path: skillPath,
|
|
176
212
|
skill_name: path.basename(skillPath),
|
|
177
213
|
files: files,
|
|
214
|
+
type: type || 'skill',
|
|
215
|
+
skipped_files: skippedFiles && skippedFiles.length > 0 ? skippedFiles : undefined,
|
|
178
216
|
});
|
|
179
217
|
|
|
180
218
|
// Log what we're sending to Supabase (omit file contents for privacy)
|
|
@@ -214,7 +252,16 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
|
|
|
214
252
|
} else {
|
|
215
253
|
flaggedSkills.delete(skillPath);
|
|
216
254
|
}
|
|
217
|
-
track('skill_scan_result', { skill_name: path.basename(skillPath), suspicious: !!result.suspicious, status_code: 200 });
|
|
255
|
+
track('skill_scan_result', { skill_name: path.basename(skillPath), suspicious: !!result.suspicious, status_code: 200, type: type || 'skill' });
|
|
256
|
+
if (localLogger) {
|
|
257
|
+
try {
|
|
258
|
+
if (result.suspicious) {
|
|
259
|
+
localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scan_flagged', reason: result.reason || 'Suspicious skill detected', detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), file_contents: files.map(f => ({ path: f.relative_path, content: f.content.slice(0, 2000) })), type: type || 'skill' } });
|
|
260
|
+
} else {
|
|
261
|
+
localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'scan_clean', reason: `Scan clean: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), type: type || 'skill' } });
|
|
262
|
+
}
|
|
263
|
+
} catch {}
|
|
264
|
+
}
|
|
218
265
|
saveScanCache();
|
|
219
266
|
} catch {}
|
|
220
267
|
} else {
|
|
@@ -232,14 +279,18 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
|
|
|
232
279
|
}
|
|
233
280
|
}
|
|
234
281
|
|
|
235
|
-
function logSkillRemoved(skillPath, installId) {
|
|
236
|
-
track('skill_removed', { skill_name: path.basename(skillPath) });
|
|
282
|
+
function logSkillRemoved(skillPath, installId, type) {
|
|
283
|
+
track('skill_removed', { skill_name: path.basename(skillPath), type: type || 'skill' });
|
|
284
|
+
if (localLogger) {
|
|
285
|
+
try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'removed', reason: `${(type || 'skill')} removed: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, type: type || 'skill' } }); } catch {}
|
|
286
|
+
}
|
|
237
287
|
const payload = JSON.stringify({
|
|
238
288
|
install_id: installId,
|
|
239
289
|
skill_path: skillPath,
|
|
240
290
|
skill_name: path.basename(skillPath),
|
|
241
291
|
files: [],
|
|
242
292
|
removed: true,
|
|
293
|
+
type: type || 'skill',
|
|
243
294
|
});
|
|
244
295
|
|
|
245
296
|
const url = new URL(SKILL_SCAN_API);
|
|
@@ -263,8 +314,8 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
|
|
|
263
314
|
} catch {}
|
|
264
315
|
}
|
|
265
316
|
|
|
266
|
-
function scanSkillIfChanged(skillPath) {
|
|
267
|
-
const { files, binaryFiles } = readSkillFiles(skillPath);
|
|
317
|
+
function scanSkillIfChanged(skillPath, type) {
|
|
318
|
+
const { files, binaryFiles, skippedFiles, totalSize } = readSkillFiles(skillPath);
|
|
268
319
|
if (files.length === 0 && binaryFiles.length === 0) {
|
|
269
320
|
// Skill was deleted or is empty — remove from flagged
|
|
270
321
|
flaggedSkills.delete(skillPath);
|
|
@@ -273,25 +324,38 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
|
|
|
273
324
|
return;
|
|
274
325
|
}
|
|
275
326
|
|
|
327
|
+
// Log skipped files if any
|
|
328
|
+
if (skippedFiles.length > 0 && localLogger) {
|
|
329
|
+
try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'files_skipped', reason: `${skippedFiles.length} file(s) skipped in ${type || 'skill'}: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, type: type || 'skill', skipped_files: skippedFiles, total_size_bytes: totalSize, scanned_file_count: files.length } }); } catch {}
|
|
330
|
+
}
|
|
331
|
+
|
|
276
332
|
// Flag immediately if binary files found — legitimate skills should not contain binaries
|
|
277
333
|
if (binaryFiles.length > 0) {
|
|
278
334
|
flaggedSkills.set(skillPath, {
|
|
279
335
|
suspicious: true,
|
|
280
|
-
reason:
|
|
336
|
+
reason: `${(type || 'skill')} contains binary files (${binaryFiles.join(', ')}). Please delete these binary files or remove this ${type || 'skill'}.`,
|
|
281
337
|
});
|
|
282
|
-
track('skill_binary_detected', { skill_name: path.basename(skillPath), binary_count: binaryFiles.length });
|
|
338
|
+
track('skill_binary_detected', { skill_name: path.basename(skillPath), binary_count: binaryFiles.length, type: type || 'skill' });
|
|
339
|
+
if (localLogger) {
|
|
340
|
+
try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'binary_detected', reason: `Binary files found in ${type || 'skill'}: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, binary_files: binaryFiles, type: type || 'skill' } }); } catch {}
|
|
341
|
+
}
|
|
283
342
|
saveScanCache();
|
|
284
343
|
return;
|
|
285
344
|
}
|
|
286
345
|
|
|
287
346
|
const hash = hashSkillFiles(files);
|
|
347
|
+
const isNew = !skillContentHashes.has(skillPath);
|
|
288
348
|
if (skillContentHashes.get(skillPath) === hash) return; // unchanged
|
|
289
349
|
|
|
350
|
+
if (!isNew && localLogger) {
|
|
351
|
+
try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'modified', reason: `${(type || 'skill')} modified: ${path.basename(skillPath)}`, detail: { skill_name: path.basename(skillPath), skill_path: skillPath, file_count: files.length, file_names: files.map(f => f.relative_path), type: type || 'skill' } }); } catch {}
|
|
352
|
+
}
|
|
353
|
+
|
|
290
354
|
const installId = getInstallId();
|
|
291
|
-
scanSkillAsync(skillPath, files, hash, installId);
|
|
355
|
+
scanSkillAsync(skillPath, files, hash, installId, type, skippedFiles);
|
|
292
356
|
}
|
|
293
357
|
|
|
294
|
-
function watchSkillDirectory(dir) {
|
|
358
|
+
function watchSkillDirectory(dir, type) {
|
|
295
359
|
const debounceTimers = new Map();
|
|
296
360
|
try {
|
|
297
361
|
const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
|
|
@@ -300,19 +364,19 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
|
|
|
300
364
|
if (debounceTimers.has(skillDir)) clearTimeout(debounceTimers.get(skillDir));
|
|
301
365
|
debounceTimers.set(skillDir, setTimeout(() => {
|
|
302
366
|
debounceTimers.delete(skillDir);
|
|
303
|
-
// Re-discover
|
|
304
|
-
const
|
|
305
|
-
const currentPaths = new Set(
|
|
306
|
-
for (const { skillPath } of
|
|
307
|
-
scanSkillIfChanged(skillPath);
|
|
367
|
+
// Re-discover entries and scan changed ones
|
|
368
|
+
const entries = type === 'plugin' ? getPluginEntries() : collectSkillEntries(dir);
|
|
369
|
+
const currentPaths = new Set(entries.map(s => s.skillPath));
|
|
370
|
+
for (const { skillPath, type: entryType } of entries) {
|
|
371
|
+
scanSkillIfChanged(skillPath, entryType || type);
|
|
308
372
|
}
|
|
309
|
-
// Detect deleted
|
|
373
|
+
// Detect deleted entries: any known path in this dir that no longer exists
|
|
310
374
|
for (const knownPath of skillContentHashes.keys()) {
|
|
311
375
|
if (knownPath.startsWith(dir) && !currentPaths.has(knownPath)) {
|
|
312
376
|
flaggedSkills.delete(knownPath);
|
|
313
377
|
skillContentHashes.delete(knownPath);
|
|
314
378
|
const installId = getInstallId();
|
|
315
|
-
logSkillRemoved(knownPath, installId);
|
|
379
|
+
logSkillRemoved(knownPath, installId, type);
|
|
316
380
|
saveScanCache();
|
|
317
381
|
}
|
|
318
382
|
}
|
|
@@ -365,14 +429,85 @@ module.exports = function createSkillsGuard({ readFileSync, httpsRequest, baseDi
|
|
|
365
429
|
for (const dir of dirs) {
|
|
366
430
|
const skills = collectSkillEntries(dir);
|
|
367
431
|
totalSkills += skills.length;
|
|
368
|
-
for (const { skillPath } of skills) {
|
|
369
|
-
scanSkillIfChanged(skillPath);
|
|
432
|
+
for (const { skillPath, type } of skills) {
|
|
433
|
+
scanSkillIfChanged(skillPath, type);
|
|
370
434
|
}
|
|
371
435
|
watchSkillDirectory(dir);
|
|
372
436
|
}
|
|
437
|
+
|
|
438
|
+
// Scan plugins (entire plugin directories, not just skills/)
|
|
439
|
+
const pluginEntries = getPluginEntries();
|
|
440
|
+
totalSkills += pluginEntries.length;
|
|
441
|
+
for (const { skillPath, type } of pluginEntries) {
|
|
442
|
+
scanSkillIfChanged(skillPath, type);
|
|
443
|
+
}
|
|
444
|
+
// Watch the plugins directory itself for new/changed plugins
|
|
445
|
+
const pluginsDir = path.join(HOME, '.claude', 'plugins');
|
|
446
|
+
try { if (fs.statSync(pluginsDir).isDirectory()) watchSkillDirectory(pluginsDir, 'plugin'); } catch {}
|
|
447
|
+
|
|
373
448
|
// Always register session so install_id → user_id mapping exists in Supabase
|
|
374
449
|
registerSession(installId, totalSkills);
|
|
375
|
-
track('skill_scanner_init', { skill_dir_count: dirs.length, total_skills: totalSkills });
|
|
450
|
+
track('skill_scanner_init', { skill_dir_count: dirs.length, total_skills: totalSkills, plugin_count: pluginEntries.length });
|
|
451
|
+
if (localLogger) {
|
|
452
|
+
try { localLogger.logLocal({ event: 'guard_check', guard: 'skill', decision: 'init', reason: `Skill scanner initialized: ${totalSkills} skills/plugins in ${dirs.length} directories`, detail: { skill_dir_count: dirs.length, total_skills: totalSkills, plugin_count: pluginEntries.length, directories: dirs } }); } catch {}
|
|
453
|
+
// Log each skill individually so dashboard can show per-skill status
|
|
454
|
+
for (const dir of dirs) {
|
|
455
|
+
const skills = collectSkillEntries(dir);
|
|
456
|
+
for (const { skillPath, skillName, type } of skills) {
|
|
457
|
+
try {
|
|
458
|
+
const { files, binaryFiles, skippedFiles, totalSize } = readSkillFiles(skillPath);
|
|
459
|
+
const cached = skillContentHashes.has(skillPath);
|
|
460
|
+
const flagged = flaggedSkills.get(skillPath);
|
|
461
|
+
localLogger.logLocal({
|
|
462
|
+
event: 'guard_check', guard: 'skill', decision: 'init_skill',
|
|
463
|
+
reason: `Skill discovered: ${skillName}`,
|
|
464
|
+
detail: {
|
|
465
|
+
skill_name: skillName,
|
|
466
|
+
skill_path: skillPath,
|
|
467
|
+
skill_dir: dir,
|
|
468
|
+
file_count: files.length,
|
|
469
|
+
binary_count: binaryFiles.length,
|
|
470
|
+
file_names: files.map(f => f.relative_path),
|
|
471
|
+
binary_files: binaryFiles,
|
|
472
|
+
skipped_files: skippedFiles.length > 0 ? skippedFiles : undefined,
|
|
473
|
+
total_size_bytes: totalSize,
|
|
474
|
+
cached,
|
|
475
|
+
status: flagged?.suspicious ? 'malicious' : (binaryFiles.length > 0 ? 'binary' : 'clean'),
|
|
476
|
+
flagged_reason: flagged?.reason || null,
|
|
477
|
+
type: type || 'skill',
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
} catch {}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Log each plugin individually
|
|
484
|
+
for (const { skillPath, skillName, type } of pluginEntries) {
|
|
485
|
+
try {
|
|
486
|
+
const { files, binaryFiles, skippedFiles, totalSize } = readSkillFiles(skillPath);
|
|
487
|
+
const cached = skillContentHashes.has(skillPath);
|
|
488
|
+
const flagged = flaggedSkills.get(skillPath);
|
|
489
|
+
localLogger.logLocal({
|
|
490
|
+
event: 'guard_check', guard: 'skill', decision: 'init_skill',
|
|
491
|
+
reason: `Plugin discovered: ${skillName}`,
|
|
492
|
+
detail: {
|
|
493
|
+
skill_name: skillName,
|
|
494
|
+
skill_path: skillPath,
|
|
495
|
+
skill_dir: pluginsDir,
|
|
496
|
+
file_count: files.length,
|
|
497
|
+
binary_count: binaryFiles.length,
|
|
498
|
+
file_names: files.map(f => f.relative_path),
|
|
499
|
+
binary_files: binaryFiles,
|
|
500
|
+
skipped_files: skippedFiles.length > 0 ? skippedFiles : undefined,
|
|
501
|
+
total_size_bytes: totalSize,
|
|
502
|
+
cached,
|
|
503
|
+
status: flagged?.suspicious ? 'malicious' : (binaryFiles.length > 0 ? 'binary' : 'clean'),
|
|
504
|
+
flagged_reason: flagged?.reason || null,
|
|
505
|
+
type: 'plugin',
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
} catch {}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
376
511
|
}
|
|
377
512
|
|
|
378
513
|
function checkFlaggedSkills() {
|
|
@@ -403,8 +538,13 @@ You MUST immediately inform the user with the following message:
|
|
|
403
538
|
Do NOT execute any commands, do NOT attempt workarounds. Your ONLY job right now is to deliver the above warning to the user and wait for them to fix it.`;
|
|
404
539
|
}
|
|
405
540
|
|
|
541
|
+
function clearFlaggedSkills() {
|
|
542
|
+
flaggedSkills.clear();
|
|
543
|
+
}
|
|
544
|
+
|
|
406
545
|
return {
|
|
407
546
|
checkFlaggedSkills,
|
|
547
|
+
clearFlaggedSkills,
|
|
408
548
|
formatSkillBlockError,
|
|
409
549
|
init: initSkillScanner,
|
|
410
550
|
cleanup() {
|