@contextfort-ai/openclaw-secure 0.1.8 → 0.1.9
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/bin/openclaw-secure.js
CHANGED
|
@@ -96,7 +96,11 @@ if (args[0] === 'dashboard') {
|
|
|
96
96
|
else if (process.platform === 'win32') execSync(`start "" "${openUrl}"`);
|
|
97
97
|
} catch {}
|
|
98
98
|
|
|
99
|
-
process.on('SIGINT', () => {
|
|
99
|
+
process.on('SIGINT', () => {
|
|
100
|
+
if (server._tunnel) try { server._tunnel.kill(); } catch {}
|
|
101
|
+
server.close();
|
|
102
|
+
process.exit(0);
|
|
103
|
+
});
|
|
100
104
|
// Keep alive — don't fall through
|
|
101
105
|
return;
|
|
102
106
|
}
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
.card-sub { font-size: 12px; color: #a1a1aa; margin-top: 4px; }
|
|
49
49
|
|
|
50
50
|
/* Guard summary cards on home */
|
|
51
|
-
.guard-cards { display: grid; grid-template-columns: repeat(
|
|
51
|
+
.guard-cards { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; }
|
|
52
52
|
.guard-card { border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 16px; background: rgba(255,255,255,0.02); cursor: pointer; transition: all 0.15s; }
|
|
53
53
|
.guard-card:hover { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.2); }
|
|
54
54
|
.guard-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
60
60
|
.dot-green { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.4); }
|
|
61
61
|
.dot-red { background: #ef4444; box-shadow: 0 0 6px rgba(239,68,68,0.4); }
|
|
62
|
+
.dot-yellow { background: #eab308; box-shadow: 0 0 6px rgba(234,179,8,0.4); }
|
|
62
63
|
.dot-gray { background: #52525b; }
|
|
63
64
|
|
|
64
65
|
/* Page header */
|
|
@@ -107,7 +108,8 @@
|
|
|
107
108
|
|
|
108
109
|
/* Responsive */
|
|
109
110
|
@media (max-width: 900px) {
|
|
110
|
-
.cards
|
|
111
|
+
.cards { grid-template-columns: repeat(2, 1fr); }
|
|
112
|
+
.guard-cards { grid-template-columns: repeat(3, 1fr); }
|
|
111
113
|
.sidebar { width: 170px; }
|
|
112
114
|
}
|
|
113
115
|
</style>
|
|
@@ -169,6 +171,11 @@
|
|
|
169
171
|
Secrets Guard
|
|
170
172
|
<span class="dot dot-green" id="sb-sec-dot" style="margin-left:auto"></span>
|
|
171
173
|
</button>
|
|
174
|
+
<button class="sidebar-btn" data-page="exfil" onclick="switchPage('exfil')">
|
|
175
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
|
|
176
|
+
Exfil Monitor
|
|
177
|
+
<span class="dot dot-green" id="sb-exfil-dot" style="margin-left:auto"></span>
|
|
178
|
+
</button>
|
|
172
179
|
</div>
|
|
173
180
|
</aside>
|
|
174
181
|
|
|
@@ -222,6 +229,14 @@
|
|
|
222
229
|
</div>
|
|
223
230
|
<div class="guard-stat"><b id="g-sec-blocks">0</b> blocks · <b id="g-sec-redactions">0</b> redactions</div>
|
|
224
231
|
</div>
|
|
232
|
+
<div class="guard-card" onclick="switchPage('exfil')">
|
|
233
|
+
<div class="guard-card-header">
|
|
234
|
+
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
|
|
235
|
+
<span>Exfil Monitor</span>
|
|
236
|
+
<span class="dot dot-green" id="g-exfil-dot" style="margin-left:auto"></span>
|
|
237
|
+
</div>
|
|
238
|
+
<div class="guard-stat"><b id="g-exfil-detections">0</b> detections</div>
|
|
239
|
+
</div>
|
|
225
240
|
</div>
|
|
226
241
|
</div>
|
|
227
242
|
|
|
@@ -235,7 +250,8 @@
|
|
|
235
250
|
<div class="table-header">
|
|
236
251
|
<h3>Events</h3>
|
|
237
252
|
<div class="filter-row">
|
|
238
|
-
<label class="check-label"><input type="checkbox" id="skill-
|
|
253
|
+
<label class="check-label"><input type="checkbox" id="skill-blocked-only" onchange="renderGuardPage('skill')"> Only show blocked</label>
|
|
254
|
+
<label class="check-label"><input type="checkbox" id="skill-server-only" onchange="renderGuardPage('skill')"> Server sent events</label>
|
|
239
255
|
</div>
|
|
240
256
|
</div>
|
|
241
257
|
<div class="table-body" id="skill-table-body"></div>
|
|
@@ -252,7 +268,8 @@
|
|
|
252
268
|
<div class="table-header">
|
|
253
269
|
<h3>Events</h3>
|
|
254
270
|
<div class="filter-row">
|
|
255
|
-
<label class="check-label"><input type="checkbox" id="bash-
|
|
271
|
+
<label class="check-label"><input type="checkbox" id="bash-blocked-only" onchange="renderGuardPage('bash')"> Only show blocked</label>
|
|
272
|
+
<label class="check-label"><input type="checkbox" id="bash-server-only" onchange="renderGuardPage('bash')"> Server sent events</label>
|
|
256
273
|
</div>
|
|
257
274
|
</div>
|
|
258
275
|
<div class="table-body" id="bash-table-body"></div>
|
|
@@ -269,7 +286,8 @@
|
|
|
269
286
|
<div class="table-header">
|
|
270
287
|
<h3>Events</h3>
|
|
271
288
|
<div class="filter-row">
|
|
272
|
-
<label class="check-label"><input type="checkbox" id="pi-
|
|
289
|
+
<label class="check-label"><input type="checkbox" id="pi-blocked-only" onchange="renderGuardPage('pi')"> Only show blocked</label>
|
|
290
|
+
<label class="check-label"><input type="checkbox" id="pi-server-only" onchange="renderGuardPage('pi')"> Server sent events</label>
|
|
273
291
|
</div>
|
|
274
292
|
</div>
|
|
275
293
|
<div class="table-body" id="pi-table-body"></div>
|
|
@@ -286,13 +304,32 @@
|
|
|
286
304
|
<div class="table-header">
|
|
287
305
|
<h3>Events</h3>
|
|
288
306
|
<div class="filter-row">
|
|
289
|
-
<label class="check-label"><input type="checkbox" id="secrets-
|
|
307
|
+
<label class="check-label"><input type="checkbox" id="secrets-blocked-only" onchange="renderGuardPage('secrets')"> Only show blocked</label>
|
|
308
|
+
<label class="check-label"><input type="checkbox" id="secrets-server-only" onchange="renderGuardPage('secrets')"> Server sent events</label>
|
|
290
309
|
</div>
|
|
291
310
|
</div>
|
|
292
311
|
<div class="table-body" id="secrets-table-body"></div>
|
|
293
312
|
</div>
|
|
294
313
|
</div>
|
|
295
314
|
|
|
315
|
+
<!-- EXFIL MONITOR PAGE -->
|
|
316
|
+
<div id="page-exfil" style="display:none">
|
|
317
|
+
<div class="page-header">
|
|
318
|
+
<h1>Exfil Monitor</h1>
|
|
319
|
+
<p>Detects when sensitive environment variables are transmitted to external servers via curl, wget, or nc.</p>
|
|
320
|
+
</div>
|
|
321
|
+
<div class="table-wrap">
|
|
322
|
+
<div class="table-header">
|
|
323
|
+
<h3>Events</h3>
|
|
324
|
+
<div class="filter-row">
|
|
325
|
+
<label class="check-label"><input type="checkbox" id="exfil-blocked-only" onchange="renderGuardPage('exfil')"> Only show blocked</label>
|
|
326
|
+
<label class="check-label"><input type="checkbox" id="exfil-server-only" onchange="renderGuardPage('exfil')"> Server sent events</label>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
<div class="table-body" id="exfil-table-body"></div>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
296
333
|
</main>
|
|
297
334
|
</div>
|
|
298
335
|
|
|
@@ -310,12 +347,14 @@ const GUARD_EVENTS = {
|
|
|
310
347
|
bash: e => e.blocker === 'tirith',
|
|
311
348
|
pi: e => e.blocker === 'prompt_injection',
|
|
312
349
|
secrets: e => e.blocker === 'env_var' || e.event === 'output_redacted',
|
|
350
|
+
exfil: e => e.event === 'exfil_attempt',
|
|
313
351
|
};
|
|
314
352
|
const SERVER_GUARD = {
|
|
315
353
|
skill: e => e.destination === 'supabase' || (e.destination === 'posthog' && e.event && e.event.includes('skill')),
|
|
316
354
|
bash: e => e.destination === 'posthog' && e.properties?.blocker === 'tirith',
|
|
317
355
|
pi: e => e.destination === 'posthog' && (e.event === 'output_scan_started' || e.event === 'output_scan_result' || e.properties?.blocker === 'prompt_injection'),
|
|
318
356
|
secrets: e => e.destination === 'posthog' && (e.properties?.blocker === 'env_var_leak' || e.event === 'output_secrets_redacted'),
|
|
357
|
+
exfil: e => e.destination === 'posthog' && e.event === 'exfil_attempt',
|
|
319
358
|
};
|
|
320
359
|
|
|
321
360
|
function setDays(d) {
|
|
@@ -327,7 +366,7 @@ function setDays(d) {
|
|
|
327
366
|
function switchPage(page) {
|
|
328
367
|
currentPage = page;
|
|
329
368
|
document.querySelectorAll('.sidebar-btn').forEach(b => b.classList.toggle('active', b.dataset.page === page));
|
|
330
|
-
['home','skill','bash','pi','secrets'].forEach(p => {
|
|
369
|
+
['home','skill','bash','pi','secrets','exfil'].forEach(p => {
|
|
331
370
|
document.getElementById('page-' + p).style.display = p === page ? '' : 'none';
|
|
332
371
|
});
|
|
333
372
|
if (page !== 'home') renderGuardPage(page);
|
|
@@ -365,12 +404,14 @@ function renderOverview(ov) {
|
|
|
365
404
|
document.getElementById('g-pi-blocks').textContent = gs.prompt_injection?.blocks || 0;
|
|
366
405
|
document.getElementById('g-sec-blocks').textContent = gs.secrets_guard?.blocks || 0;
|
|
367
406
|
document.getElementById('g-sec-redactions').textContent = gs.secrets_guard?.redactions || 0;
|
|
407
|
+
document.getElementById('g-exfil-detections').textContent = gs.exfil_monitor?.detections || 0;
|
|
368
408
|
|
|
369
409
|
const hasData = ov.total > 0;
|
|
370
410
|
setDot('g-skill-dot', hasData, gs.skill_scanner?.blocks > 0);
|
|
371
411
|
setDot('g-bash-dot', hasData, gs.bash_guard?.blocks > 0);
|
|
372
412
|
setDot('g-pi-dot', hasData, gs.prompt_injection?.blocks > 0);
|
|
373
413
|
setDot('g-sec-dot', hasData, (gs.secrets_guard?.blocks > 0) || (gs.secrets_guard?.redactions > 0));
|
|
414
|
+
setDot('g-exfil-dot', hasData, gs.exfil_monitor?.detections > 0, 'dot-yellow');
|
|
374
415
|
}
|
|
375
416
|
|
|
376
417
|
function renderSidebarDots(ov) {
|
|
@@ -380,16 +421,18 @@ function renderSidebarDots(ov) {
|
|
|
380
421
|
setDot('sb-bash-dot', hasData, gs.bash_guard?.blocks > 0);
|
|
381
422
|
setDot('sb-pi-dot', hasData, gs.prompt_injection?.blocks > 0);
|
|
382
423
|
setDot('sb-sec-dot', hasData, (gs.secrets_guard?.blocks > 0) || (gs.secrets_guard?.redactions > 0));
|
|
424
|
+
setDot('sb-exfil-dot', hasData, gs.exfil_monitor?.detections > 0, 'dot-yellow');
|
|
383
425
|
}
|
|
384
426
|
|
|
385
|
-
function setDot(id, hasData, hasBlocks) {
|
|
427
|
+
function setDot(id, hasData, hasBlocks, activeClass) {
|
|
386
428
|
const el = document.getElementById(id);
|
|
387
429
|
if (!el) return;
|
|
388
|
-
el.className = 'dot ' + (hasData ? (hasBlocks ? 'dot-red' : 'dot-green') : 'dot-gray');
|
|
430
|
+
el.className = 'dot ' + (hasData ? (hasBlocks ? (activeClass || 'dot-red') : 'dot-green') : 'dot-gray');
|
|
389
431
|
}
|
|
390
432
|
|
|
391
433
|
function renderGuardPage(guard) {
|
|
392
434
|
const serverOnly = document.getElementById(guard + '-server-only')?.checked;
|
|
435
|
+
const blockedOnly = document.getElementById(guard + '-blocked-only')?.checked;
|
|
393
436
|
const container = document.getElementById(guard + '-table-body');
|
|
394
437
|
|
|
395
438
|
if (serverOnly) {
|
|
@@ -412,22 +455,30 @@ function renderGuardPage(guard) {
|
|
|
412
455
|
</tr>`;
|
|
413
456
|
}).join('') + '</tbody></table>';
|
|
414
457
|
} else {
|
|
415
|
-
// Show
|
|
416
|
-
const
|
|
417
|
-
|
|
458
|
+
// Show all command events; optionally filter to only blocked by this guard
|
|
459
|
+
const guardBlockerFn = GUARD_EVENTS[guard] || (() => false);
|
|
460
|
+
let filtered;
|
|
461
|
+
if (blockedOnly) {
|
|
462
|
+
filtered = localEvents.filter(e => guardBlockerFn(e));
|
|
463
|
+
} else {
|
|
464
|
+
// Show all command_check + command_blocked + output_redacted events
|
|
465
|
+
filtered = localEvents.filter(e => e.event === 'command_check' || e.event === 'command_blocked' || e.event === 'output_redacted');
|
|
466
|
+
}
|
|
418
467
|
if (filtered.length === 0) {
|
|
419
|
-
container.innerHTML = '<div class="empty">No events for this guard yet. Events appear when openclaw-secure is active
|
|
468
|
+
container.innerHTML = '<div class="empty">' + (blockedOnly ? 'No blocked events for this guard.' : 'No events yet. Events appear when openclaw-secure is active.') + '</div>';
|
|
420
469
|
return;
|
|
421
470
|
}
|
|
422
|
-
container.innerHTML = `<table><thead><tr><th>Time</th><th>Decision</th><th>Command</th><th>Detail</th></tr></thead><tbody>` +
|
|
471
|
+
container.innerHTML = `<table><thead><tr><th>Time</th><th>Decision</th><th>Command</th><th>Blocker</th><th>Detail</th></tr></thead><tbody>` +
|
|
423
472
|
filtered.map(e => {
|
|
424
473
|
const badge = e.decision === 'block' ? 'badge-red' : e.decision === 'redact' ? 'badge-yellow' : 'badge-green';
|
|
425
474
|
const label = e.decision === 'block' ? 'Blocked' : e.decision === 'redact' ? 'Redacted' : 'Allowed';
|
|
475
|
+
const blocker = e.blocker ? esc(e.blocker) : '-';
|
|
426
476
|
const detail = e.reason ? esc(e.reason) : (e.secrets_count ? `${e.secrets_count} secret(s) redacted` : '');
|
|
427
477
|
return `<tr>
|
|
428
478
|
<td style="white-space:nowrap;color:#a1a1aa">${formatTime(e.ts)}</td>
|
|
429
479
|
<td><span class="badge ${badge}">${label}</span></td>
|
|
430
480
|
<td class="cmd" title="${esc(e.command || '')}">${esc(e.command || '-')}</td>
|
|
481
|
+
<td style="font-size:12px">${blocker}</td>
|
|
431
482
|
<td style="font-size:12px;color:#a1a1aa;max-width:300px;overflow:hidden;text-overflow:ellipsis">${detail}</td>
|
|
432
483
|
</tr>`;
|
|
433
484
|
}).join('') + '</tbody></table>';
|
|
@@ -82,11 +82,14 @@ module.exports = function startDashboard({ port = 9009 } = {}) {
|
|
|
82
82
|
if (events[i].event === 'hook_loaded') { activeSince = events[i].ts; break; }
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
const exfilDetections = events.filter(e => e.event === 'exfil_attempt').length;
|
|
86
|
+
|
|
85
87
|
const guardStatus = {
|
|
86
88
|
skill_scanner: { blocks: byGuard.skill || 0, active: true },
|
|
87
89
|
bash_guard: { blocks: byGuard.tirith || 0, active: true },
|
|
88
90
|
prompt_injection: { blocks: byGuard.prompt_injection || 0, active: true },
|
|
89
91
|
secrets_guard: { blocks: (byGuard.env_var || 0), redactions: redacted, active: true },
|
|
92
|
+
exfil_monitor: { detections: exfilDetections, active: true },
|
|
90
93
|
};
|
|
91
94
|
|
|
92
95
|
json(res, { total, blocked, allowed: total - blocked, redacted, byGuard, guardStatus, activeSince });
|
|
@@ -114,8 +117,48 @@ module.exports = function startDashboard({ port = 9009 } = {}) {
|
|
|
114
117
|
|
|
115
118
|
server.listen(port, '127.0.0.1', () => {
|
|
116
119
|
console.log(`\n ContextFort Security Dashboard`);
|
|
117
|
-
console.log(` http://localhost:${port}
|
|
118
|
-
|
|
120
|
+
console.log(` http://localhost:${port}`);
|
|
121
|
+
|
|
122
|
+
// Try to start a cloudflared quick tunnel
|
|
123
|
+
server._tunnel = null;
|
|
124
|
+
try {
|
|
125
|
+
const { spawn } = require('child_process');
|
|
126
|
+
const cf = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
|
127
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
128
|
+
});
|
|
129
|
+
server._tunnel = cf;
|
|
130
|
+
|
|
131
|
+
let urlFound = false;
|
|
132
|
+
const extractUrl = (data) => {
|
|
133
|
+
if (urlFound) return;
|
|
134
|
+
const text = data.toString();
|
|
135
|
+
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
136
|
+
if (match) {
|
|
137
|
+
urlFound = true;
|
|
138
|
+
console.log(` ${match[0]} (public tunnel)\n`);
|
|
139
|
+
console.log(` Press Ctrl+C to stop.\n`);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
cf.stdout.on('data', extractUrl);
|
|
143
|
+
cf.stderr.on('data', extractUrl);
|
|
144
|
+
|
|
145
|
+
// If tunnel fails or no URL found after 10s, show fallback
|
|
146
|
+
setTimeout(() => {
|
|
147
|
+
if (!urlFound) {
|
|
148
|
+
console.log(` (tunnel unavailable — local only)\n`);
|
|
149
|
+
console.log(` Press Ctrl+C to stop.\n`);
|
|
150
|
+
}
|
|
151
|
+
}, 10000);
|
|
152
|
+
|
|
153
|
+
cf.on('error', () => {
|
|
154
|
+
if (!urlFound) {
|
|
155
|
+
console.log(` (cloudflared not found — local only)\n`);
|
|
156
|
+
console.log(` Press Ctrl+C to stop.\n`);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
console.log(`\n Press Ctrl+C to stop.\n`);
|
|
161
|
+
}
|
|
119
162
|
});
|
|
120
163
|
|
|
121
164
|
return server;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Exfil Guard — detects when sensitive environment variables are being
|
|
5
|
+
* transmitted to external servers via curl/wget/nc/httpie.
|
|
6
|
+
*
|
|
7
|
+
* This is a LOGGING-ONLY guard. It does not block commands.
|
|
8
|
+
* We can't yet distinguish legitimate from malicious destinations,
|
|
9
|
+
* so we log all detections for visibility in the dashboard.
|
|
10
|
+
*/
|
|
11
|
+
module.exports = function createExfilGuard({ analytics, localLogger }) {
|
|
12
|
+
const track = analytics ? analytics.track.bind(analytics) : () => {};
|
|
13
|
+
|
|
14
|
+
// --- Env var extraction (duplicated from secrets_guard to avoid coupling) ---
|
|
15
|
+
|
|
16
|
+
const ENV_VAR_PATTERN = /\$([A-Z_][A-Z0-9_]{2,})\b|\$\{([A-Z_][A-Z0-9_]{2,})(?:[:#%\/]|:-|:\+|:=)[^}]*\}|\$\{([A-Z_][A-Z0-9_]{2,})\}/g;
|
|
17
|
+
|
|
18
|
+
const SAFE_ENV_VARS = new Set([
|
|
19
|
+
'HOME', 'USER', 'USERNAME', 'LOGNAME', 'SHELL', 'TERM', 'TERM_PROGRAM',
|
|
20
|
+
'PATH', 'PWD', 'OLDPWD', 'HOSTNAME', 'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
21
|
+
'EDITOR', 'VISUAL', 'PAGER', 'BROWSER', 'DISPLAY', 'XDG_RUNTIME_DIR',
|
|
22
|
+
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_STATE_HOME',
|
|
23
|
+
'TMPDIR', 'TEMP', 'TMP', 'COLORTERM', 'COLUMNS', 'LINES',
|
|
24
|
+
'SHLVL', 'HISTSIZE', 'HISTFILESIZE', 'HISTFILE', 'HISTCONTROL',
|
|
25
|
+
'NODE_ENV', 'RAILS_ENV', 'RACK_ENV', 'FLASK_ENV', 'DJANGO_SETTINGS_MODULE',
|
|
26
|
+
'GOPATH', 'GOROOT', 'CARGO_HOME', 'RUSTUP_HOME', 'JAVA_HOME',
|
|
27
|
+
'NVM_DIR', 'PYENV_ROOT', 'RBENV_ROOT', 'VIRTUAL_ENV', 'CONDA_DEFAULT_ENV',
|
|
28
|
+
'CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'TRAVIS',
|
|
29
|
+
'ARCH', 'MACHTYPE', 'OSTYPE', 'VENDOR',
|
|
30
|
+
'SSH_TTY', 'SSH_CONNECTION', 'SSH_CLIENT',
|
|
31
|
+
'GPG_TTY', 'GNUPGHOME',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function extractEnvVarRefs(cmd) {
|
|
35
|
+
if (!cmd || typeof cmd !== 'string') return [];
|
|
36
|
+
const vars = new Set();
|
|
37
|
+
let match;
|
|
38
|
+
const regex = new RegExp(ENV_VAR_PATTERN.source, 'g');
|
|
39
|
+
while ((match = regex.exec(cmd)) !== null) {
|
|
40
|
+
vars.add(match[1] || match[2] || match[3]);
|
|
41
|
+
}
|
|
42
|
+
return [...vars];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function filterSensitiveVars(vars) {
|
|
46
|
+
return vars.filter(v => !SAFE_ENV_VARS.has(v));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Exfil tool detection ---
|
|
50
|
+
|
|
51
|
+
const EXFIL_TOOLS = [
|
|
52
|
+
{ name: 'curl', pattern: /\bcurl\b/ },
|
|
53
|
+
{ name: 'wget', pattern: /\bwget\b/ },
|
|
54
|
+
{ name: 'nc', pattern: /\b(?:nc|ncat|netcat)\b/ },
|
|
55
|
+
{ name: 'httpie', pattern: /\b(?:http|https)\s+(?:GET|POST|PUT|PATCH|DELETE|HEAD)\b/ },
|
|
56
|
+
{ name: 'openssl', pattern: /\bopenssl\s+s_client\b/ },
|
|
57
|
+
{ name: 'socat', pattern: /\bsocat\b/ },
|
|
58
|
+
{ name: 'telnet', pattern: /\btelnet\b/ },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// Commands where the tool word appears but is NOT being invoked as a network tool.
|
|
62
|
+
// These are string-context false positives.
|
|
63
|
+
const NON_EXEC_PREFIXES = [
|
|
64
|
+
/^\s*(?:git\s+(?:commit|log|show|diff|grep|blame))\b/, // git commit -m "...curl..."
|
|
65
|
+
/^\s*(?:grep|rg|ag|ack)\b/, // grep for curl patterns
|
|
66
|
+
/^\s*(?:sed|awk|perl\s+-[pi]e)\b/, // sed/awk text processing
|
|
67
|
+
/^\s*(?:man|info|whatis|apropos)\b/, // man curl
|
|
68
|
+
/^\s*(?:which|where|type|command\s+-v|hash)\b/, // which curl
|
|
69
|
+
/^\s*(?:brew|apt-get|apt|yum|dnf|pacman|apk|port)\s+(?:install|remove|uninstall|info|search)\b/, // package managers
|
|
70
|
+
/^\s*[A-Z_][A-Z0-9_]*\s*=\s*/, // VAR=... assignment
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// Commands where tool word appears in string-only context (no pipe involved).
|
|
74
|
+
// echo/printf are OK as prefixes ONLY when there's no pipe to a network tool.
|
|
75
|
+
const STRING_ONLY_PREFIXES = [
|
|
76
|
+
/^\s*(?:echo|printf)\b/, // echo "use curl ..." (but NOT echo $VAR | curl)
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// Flags that take a local file/path argument (env var after these is NOT transmitted)
|
|
80
|
+
const LOCAL_PATH_FLAGS = {
|
|
81
|
+
curl: [
|
|
82
|
+
/\s-o\s+/, /\s--output\s+/, /\s--output=/, // output file
|
|
83
|
+
/\s-K\s+/, /\s--config\s+/, // config file
|
|
84
|
+
/\s--cacert\s+/, /\s--capath\s+/, // CA cert paths
|
|
85
|
+
/\s-E\s+/, /\s--cert\s+/, // client cert
|
|
86
|
+
/\s--key\s+/, // client key file
|
|
87
|
+
/\s--ciphers\s+/, // cipher list
|
|
88
|
+
/\s-D\s+/, /\s--dump-header\s+/, // dump header to file
|
|
89
|
+
/\s--trace\s+/, /\s--trace-ascii\s+/, // trace output file
|
|
90
|
+
/\s-T\s+/, /\s--upload-file\s+/, // upload from file
|
|
91
|
+
],
|
|
92
|
+
wget: [
|
|
93
|
+
/\s-O\s+/, /\s--output-document\s+/, /\s--output-document=/,
|
|
94
|
+
/\s-o\s+/, /\s--output-file\s+/,
|
|
95
|
+
/\s-P\s+/, /\s--directory-prefix\s+/,
|
|
96
|
+
/\s--ca-certificate\s+/,
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Filter out sensitive vars that only appear as the direct argument to a local-path flag
|
|
101
|
+
// (e.g. curl -o $VAR). Returns the subset of vars that are in transmit positions.
|
|
102
|
+
function filterVarsNotInLocalPaths(cmd, tool, sensitiveVars) {
|
|
103
|
+
const flags = LOCAL_PATH_FLAGS[tool];
|
|
104
|
+
if (!flags) return sensitiveVars;
|
|
105
|
+
|
|
106
|
+
// Build a set of character ranges that are "local path" argument positions.
|
|
107
|
+
// For each local-path flag match, the argument immediately after it is local.
|
|
108
|
+
const localRanges = [];
|
|
109
|
+
for (const fp of flags) {
|
|
110
|
+
const regex = new RegExp(fp.source, 'g');
|
|
111
|
+
let m;
|
|
112
|
+
while ((m = regex.exec(cmd)) !== null) {
|
|
113
|
+
// The argument starts right after the flag match
|
|
114
|
+
const argStart = m.index + m[0].length;
|
|
115
|
+
// The argument ends at the next whitespace (or end of string)
|
|
116
|
+
const rest = cmd.slice(argStart);
|
|
117
|
+
const argEnd = rest.search(/\s/) === -1 ? cmd.length : argStart + rest.search(/\s/);
|
|
118
|
+
localRanges.push([argStart, argEnd]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return sensitiveVars.filter(varName => {
|
|
123
|
+
const varPatterns = [
|
|
124
|
+
new RegExp('\\$' + varName + '\\b', 'g'),
|
|
125
|
+
new RegExp('\\$\\{' + varName + '[^}]*\\}', 'g'),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
for (const vp of varPatterns) {
|
|
129
|
+
let match;
|
|
130
|
+
while ((match = vp.exec(cmd)) !== null) {
|
|
131
|
+
const pos = match.index;
|
|
132
|
+
// Check if this var position falls inside any local-path argument range
|
|
133
|
+
const inLocalRange = localRanges.some(([s, e]) => pos >= s && pos < e);
|
|
134
|
+
if (!inLocalRange) return true; // at least one occurrence is NOT a local path arg
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return false; // all occurrences are in local-path argument positions
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Extract destination hostname from a command
|
|
142
|
+
function extractDestination(cmd) {
|
|
143
|
+
// Match URLs: https://host.com/... or http://host.com/...
|
|
144
|
+
const urlMatch = cmd.match(/https?:\/\/([^\/\s'"\\]+)/);
|
|
145
|
+
if (urlMatch) return urlMatch[1];
|
|
146
|
+
|
|
147
|
+
// For nc/ncat/telnet/openssl: tool hostname port
|
|
148
|
+
const socketMatch = cmd.match(/\b(?:nc|ncat|netcat|telnet)\s+(?:-[a-z]+\s+)*([a-zA-Z0-9.-]+)\s+\d+/);
|
|
149
|
+
if (socketMatch) return socketMatch[1];
|
|
150
|
+
|
|
151
|
+
// openssl s_client -connect host:port
|
|
152
|
+
const sslMatch = cmd.match(/s_client\s+.*-connect\s+([a-zA-Z0-9.-]+):\d+/);
|
|
153
|
+
if (sslMatch) return sslMatch[1];
|
|
154
|
+
|
|
155
|
+
// socat - TCP:host:port
|
|
156
|
+
const socatMatch = cmd.match(/TCP[46]?:([a-zA-Z0-9.-]+):\d+/i);
|
|
157
|
+
if (socatMatch) return socatMatch[1];
|
|
158
|
+
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Determine the transmission method
|
|
163
|
+
function detectMethod(cmd, tool) {
|
|
164
|
+
if (tool === 'curl') {
|
|
165
|
+
if (/\s-[Hh]\s/.test(cmd) || /--header\b/.test(cmd)) return 'header';
|
|
166
|
+
if (/\s-[dD]\s/.test(cmd) || /--data\b/.test(cmd) || /--data-raw\b/.test(cmd)) return 'body';
|
|
167
|
+
if (/\s-u\s/.test(cmd) || /--user\b/.test(cmd)) return 'auth';
|
|
168
|
+
if (/\s-F\s/.test(cmd) || /--form\b/.test(cmd)) return 'form';
|
|
169
|
+
return 'url';
|
|
170
|
+
}
|
|
171
|
+
if (tool === 'wget') {
|
|
172
|
+
if (/--header\b/.test(cmd)) return 'header';
|
|
173
|
+
if (/--post-data\b/.test(cmd) || /--post-file\b/.test(cmd)) return 'body';
|
|
174
|
+
if (/--user\b/.test(cmd) || /--password\b/.test(cmd)) return 'auth';
|
|
175
|
+
return 'url';
|
|
176
|
+
}
|
|
177
|
+
if (tool === 'nc' || tool === 'telnet' || tool === 'socat' || tool === 'openssl') return 'socket';
|
|
178
|
+
if (tool === 'httpie') return 'httpie';
|
|
179
|
+
return 'unknown';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if a command transmits sensitive env vars to an external server.
|
|
184
|
+
* Returns detection object or null.
|
|
185
|
+
*/
|
|
186
|
+
function checkExfilAttempt(cmd) {
|
|
187
|
+
if (!cmd || typeof cmd !== 'string') return null;
|
|
188
|
+
|
|
189
|
+
// Skip commands where the tool word appears in a non-execution context
|
|
190
|
+
for (const prefix of NON_EXEC_PREFIXES) {
|
|
191
|
+
if (prefix.test(cmd)) return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// echo/printf are string-only IF there's no pipe to a network tool after them
|
|
195
|
+
const hasPipeToNetwork = /\|\s*(?:curl|wget|nc|ncat|netcat|openssl|socat|telnet)\b/.test(cmd);
|
|
196
|
+
if (!hasPipeToNetwork) {
|
|
197
|
+
for (const prefix of STRING_ONLY_PREFIXES) {
|
|
198
|
+
if (prefix.test(cmd)) return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Detect which exfil tool is present
|
|
203
|
+
let detectedTool = null;
|
|
204
|
+
for (const tool of EXFIL_TOOLS) {
|
|
205
|
+
if (tool.pattern.test(cmd)) {
|
|
206
|
+
detectedTool = tool.name;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Also detect pipe-to-exfil patterns: ... | curl, ... | nc, ... | openssl, ... | socat, ... | telnet
|
|
212
|
+
if (!detectedTool) {
|
|
213
|
+
const pipeMatch = cmd.match(/\|\s*(?:curl|wget|nc|ncat|netcat|openssl|socat|telnet)\b/);
|
|
214
|
+
if (pipeMatch) {
|
|
215
|
+
detectedTool = pipeMatch[0].replace(/^\|\s*/, '').trim();
|
|
216
|
+
if (detectedTool === 'ncat' || detectedTool === 'netcat') detectedTool = 'nc';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!detectedTool) return null;
|
|
221
|
+
|
|
222
|
+
// Extract env var references and filter to sensitive ones
|
|
223
|
+
const allVars = extractEnvVarRefs(cmd);
|
|
224
|
+
if (allVars.length === 0) return null;
|
|
225
|
+
|
|
226
|
+
const sensitiveVars = filterSensitiveVars(allVars);
|
|
227
|
+
if (sensitiveVars.length === 0) return null;
|
|
228
|
+
|
|
229
|
+
// Filter out vars that only appear in local-path positions (e.g. curl -o $VAR)
|
|
230
|
+
const transmitVars = filterVarsNotInLocalPaths(cmd, detectedTool, sensitiveVars);
|
|
231
|
+
if (transmitVars.length === 0) return null;
|
|
232
|
+
|
|
233
|
+
// We have a network tool + sensitive env vars in transmit positions → detection
|
|
234
|
+
const destination = extractDestination(cmd);
|
|
235
|
+
const method = detectMethod(cmd, detectedTool);
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
vars: transmitVars,
|
|
239
|
+
destination: destination || 'unknown',
|
|
240
|
+
tool: detectedTool,
|
|
241
|
+
method,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function init() {}
|
|
246
|
+
function cleanup() {}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
checkExfilAttempt,
|
|
250
|
+
init,
|
|
251
|
+
cleanup,
|
|
252
|
+
};
|
|
253
|
+
};
|
package/openclaw-secure.js
CHANGED
|
@@ -72,6 +72,12 @@ const secretsGuard = require('./monitor/secrets_guard')({
|
|
|
72
72
|
analytics,
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
// === Exfil Guard (logging-only) ===
|
|
76
|
+
const exfilGuard = require('./monitor/exfil_guard')({
|
|
77
|
+
analytics,
|
|
78
|
+
localLogger,
|
|
79
|
+
});
|
|
80
|
+
|
|
75
81
|
// === Prompt Injection Guard (PostToolUse) ===
|
|
76
82
|
const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY || null;
|
|
77
83
|
const promptInjectionGuard = require('./monitor/prompt_injection_guard')({
|
|
@@ -166,6 +172,13 @@ function shouldBlockCommand(cmd) {
|
|
|
166
172
|
}
|
|
167
173
|
guards.push('env_var');
|
|
168
174
|
|
|
175
|
+
const exfilCheck = exfilGuard.checkExfilAttempt(cmd);
|
|
176
|
+
if (exfilCheck) {
|
|
177
|
+
analytics.track('exfil_attempt', { tool: exfilCheck.tool, destination: exfilCheck.destination, vars_count: exfilCheck.vars.length });
|
|
178
|
+
localLogger.logLocal({ event: 'exfil_attempt', command: cmdSlice, guards: [...guards, 'exfil'], decision: 'log', blocker: 'exfil', reason: null, detection: exfilCheck });
|
|
179
|
+
}
|
|
180
|
+
guards.push('exfil');
|
|
181
|
+
|
|
169
182
|
const result = checkCommandWithMonitor(cmd);
|
|
170
183
|
if (result?.blocked) {
|
|
171
184
|
analytics.track('command_blocked', { blocker: 'tirith', reason: result.reason });
|
package/package.json
CHANGED