@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.
@@ -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', () => { server.close(); process.exit(0); });
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(4, 1fr); gap: 16px; }
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, .guard-cards { grid-template-columns: repeat(2, 1fr); }
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 &middot; <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-server-only" onchange="renderGuardPage('skill')"> View only server sent events</label>
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-server-only" onchange="renderGuardPage('bash')"> View only server sent events</label>
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-server-only" onchange="renderGuardPage('pi')"> View only server sent events</label>
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-server-only" onchange="renderGuardPage('secrets')"> View only server sent events</label>
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 local events filtered to this guard
416
- const filterFn = GUARD_EVENTS[guard] || (() => false);
417
- const filtered = localEvents.filter(e => filterFn(e));
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.</div>';
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}\n`);
118
- console.log(` Press Ctrl+C to stop.\n`);
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
+ };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contextfort-ai/openclaw-secure",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Runtime security guard for OpenClaw — blocks malicious commands before they execute",
5
5
  "bin": {
6
6
  "openclaw-secure": "./bin/openclaw-secure.js"
@@ -1,9 +0,0 @@
1
- node_modules/
2
- \.git/
3
- __pycache__/
4
- \.pyc$
5
- \.next/
6
- dist/
7
- build/
8
- \.venv/
9
- venv/