@contextfort-ai/openclaw-secure 0.1.8 → 0.1.11

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.
@@ -72,8 +72,20 @@ const secretsGuard = require('./monitor/secrets_guard')({
72
72
  analytics,
73
73
  });
74
74
 
75
+ // === Exfil Guard ===
76
+ const exfilGuard = require('./monitor/exfil_guard')({
77
+ analytics,
78
+ localLogger,
79
+ readFileSync: _originalReadFileSync,
80
+ });
81
+
75
82
  // === Prompt Injection Guard (PostToolUse) ===
76
- const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY || null;
83
+ function loadAnthropicKey() {
84
+ if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY;
85
+ try { const k = _originalReadFileSync(path.join(CONFIG_DIR, 'anthropic_key'), 'utf8').trim(); if (k) return k; } catch {}
86
+ return null;
87
+ }
88
+ const ANTHROPIC_KEY = loadAnthropicKey();
77
89
  const promptInjectionGuard = require('./monitor/prompt_injection_guard')({
78
90
  httpsRequest: _originalHttpsRequest,
79
91
  anthropicKey: ANTHROPIC_KEY,
@@ -81,6 +93,7 @@ const promptInjectionGuard = require('./monitor/prompt_injection_guard')({
81
93
  apiKey: API_KEY,
82
94
  baseDir: __dirname,
83
95
  analytics,
96
+ localLogger,
84
97
  });
85
98
 
86
99
  function callMonitor(toolName, toolInput) {
@@ -128,54 +141,89 @@ function extractShellCommand(command, args) {
128
141
 
129
142
  function shouldBlockCommand(cmd) {
130
143
  if (!cmd || typeof cmd !== 'string') return null;
131
- const cmdSlice = cmd.slice(0, 500);
132
144
  const guards = [];
133
145
 
146
+ // 1. API Key check
134
147
  const keyBlock = checkApiKey();
135
148
  if (keyBlock) {
136
149
  analytics.track('command_blocked', { blocker: 'api_key' });
137
- localLogger.logLocal({ event: 'command_blocked', command: cmdSlice, guards: ['api_key'], decision: 'block', blocker: 'api_key', reason: 'No API key' });
150
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'api_key', decision: 'block', blocker: 'api_key', reason: 'No API key', detail: { has_key: false } });
138
151
  return keyBlock;
139
152
  }
153
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'api_key', decision: 'allow', reason: 'API key present' });
140
154
  guards.push('api_key');
141
155
 
156
+ // 2. Check for unblock flag (set by dashboard "Remove Block" button)
157
+ // When found: clear all flagged state, then delete the file.
158
+ const unblockFile = path.join(CONFIG_DIR, 'unblock');
159
+ try {
160
+ if (_originalReadFileSync(unblockFile, 'utf8')) {
161
+ promptInjectionGuard.clearFlaggedOutput();
162
+ skillsGuard.clearFlaggedSkills();
163
+ try { require('fs').unlinkSync(unblockFile); } catch {}
164
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'unblock', decision: 'cleared', reason: 'Unblock: cleared all flagged state' });
165
+ }
166
+ } catch {}
167
+
168
+ // 3. Prompt Injection — check if any previous output was flagged
142
169
  const outputBlock = promptInjectionGuard.checkFlaggedOutput();
143
170
  if (outputBlock) {
144
171
  analytics.track('command_blocked', { blocker: 'prompt_injection' });
145
- localLogger.logLocal({ event: 'command_blocked', command: cmdSlice, guards: [...guards, 'prompt_injection'], decision: 'block', blocker: 'prompt_injection', reason: outputBlock.reason || 'Flagged output' });
172
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'prompt_injection', decision: 'block', blocker: 'prompt_injection', reason: outputBlock.reason || 'Flagged output', detail: { flagged_command: outputBlock.command, scan_id: outputBlock.id } });
146
173
  return { blocked: true, reason: promptInjectionGuard.formatOutputBlockError(outputBlock) };
147
174
  }
175
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'prompt_injection', decision: 'allow', reason: 'No flagged output' });
148
176
  guards.push('prompt_injection');
149
177
 
178
+ // 4. Skill Scanner — check if any skill files are flagged
150
179
  const skillBlock = skillsGuard.checkFlaggedSkills();
151
180
  if (skillBlock) {
152
181
  analytics.track('command_blocked', { blocker: 'skill' });
153
- localLogger.logLocal({ event: 'command_blocked', command: cmdSlice, guards: [...guards, 'skill'], decision: 'block', blocker: 'skill', reason: skillBlock.reason || 'Flagged skill' });
182
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'skill', decision: 'block', blocker: 'skill', reason: skillBlock.reason || 'Flagged skill', detail: { skill_path: skillBlock.skillPath } });
154
183
  return { blocked: true, reason: skillsGuard.formatSkillBlockError(skillBlock) };
155
184
  }
185
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'skill', decision: 'allow', reason: 'No flagged skills' });
156
186
  guards.push('skill');
157
187
 
188
+ // 4. Secrets Guard — check for env var leaks
158
189
  const envCheck = secretsGuard.checkEnvVarLeak(cmd);
159
190
  if (envCheck && envCheck.blocked) {
160
191
  analytics.track('command_blocked', { blocker: 'env_var_leak', vars: envCheck.vars, type: envCheck.type });
161
- localLogger.logLocal({ event: 'command_blocked', command: cmdSlice, guards: [...guards, 'env_var'], decision: 'block', blocker: 'env_var', reason: `Env var leak: ${envCheck.vars.join(', ')}` });
192
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'env_var', decision: 'block', blocker: 'env_var', reason: envCheck.reason, detail: { vars: envCheck.vars, type: envCheck.type, matched_pattern: envCheck.matched_pattern || null, pattern_category: envCheck.type === 'env_dump' ? 'env_dump_command' : envCheck.type === 'value_exposed' ? 'value_exposing_command' : envCheck.type === 'lang_env_access' ? 'language_env_api' : 'unknown' } });
162
193
  return { blocked: true, reason: secretsGuard.formatEnvVarBlockError(envCheck) };
163
194
  }
164
195
  if (envCheck && !envCheck.blocked && envCheck.vars.length > 0) {
165
- analytics.track('env_var_used', { vars: envCheck.vars, command_prefix: cmd.slice(0, 100) });
196
+ analytics.track('env_var_used', { vars: envCheck.vars, command: cmd });
197
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'env_var', decision: 'allow', reason: 'Env vars referenced but values not exposed to output', detail: { vars: envCheck.vars, type: envCheck.type, matched_pattern: envCheck.matched_pattern || null, explanation: 'Vars used as $VAR in command — shell resolves them without exposing values to AI agent output' } });
166
198
  }
167
199
  guards.push('env_var');
168
200
 
201
+ // 5. Exfil Guard — check for env var transmission to external servers
202
+ const exfilCheck = exfilGuard.checkExfilAttempt(cmd);
203
+ if (exfilCheck) {
204
+ if (exfilCheck.blocked) {
205
+ analytics.track('command_blocked', { blocker: 'exfil', tool: exfilCheck.tool, destination: exfilCheck.destination, vars_count: exfilCheck.vars.length });
206
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'exfil', decision: 'block', blocker: 'exfil', reason: `Blocked: ${exfilCheck.vars.join(', ')} via ${exfilCheck.tool} to ${exfilCheck.destination} (not in allowlist)`, detail: { vars: exfilCheck.vars, tool: exfilCheck.tool, destination: exfilCheck.destination, method: exfilCheck.method, allowlistActive: true } });
207
+ return { blocked: true, reason: formatExfilBlockError(exfilCheck) };
208
+ }
209
+ const decision = exfilCheck.allowlistActive ? 'allow' : 'log';
210
+ analytics.track('exfil_attempt', { tool: exfilCheck.tool, destination: exfilCheck.destination, vars_count: exfilCheck.vars.length, decision });
211
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'exfil', decision, reason: `Exfil detected: ${exfilCheck.vars.join(', ')} via ${exfilCheck.tool} to ${exfilCheck.destination}${exfilCheck.allowlistInfo ? ` (matched: ${exfilCheck.allowlistInfo.matchedRule})` : ''}`, detail: { vars: exfilCheck.vars, tool: exfilCheck.tool, destination: exfilCheck.destination, method: exfilCheck.method, allowlistActive: exfilCheck.allowlistActive, allowlistMatch: exfilCheck.allowlistInfo ? exfilCheck.allowlistInfo.matchedRule : null } });
212
+ }
213
+ guards.push('exfil');
214
+
215
+ // 6. Tirith Bash Guard — static command analysis
169
216
  const result = checkCommandWithMonitor(cmd);
170
217
  if (result?.blocked) {
171
218
  analytics.track('command_blocked', { blocker: 'tirith', reason: result.reason });
172
- localLogger.logLocal({ event: 'command_blocked', command: cmdSlice, guards: [...guards, 'tirith'], decision: 'block', blocker: 'tirith', reason: result.reason });
219
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'tirith', decision: 'block', blocker: 'tirith', reason: result.reason });
173
220
  return result;
174
221
  }
222
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'tirith', decision: 'allow', reason: 'All static checks passed' });
175
223
  guards.push('tirith');
176
224
 
177
- // All guards passed — log the allowed command
178
- localLogger.logLocal({ event: 'command_check', command: cmdSlice, guards, decision: 'allow', blocker: null, reason: null });
225
+ // All guards passed
226
+ localLogger.logLocal({ event: 'command_check', command: cmd, guards, decision: 'allow', blocker: null, reason: null });
179
227
  return null;
180
228
  }
181
229
 
@@ -199,6 +247,40 @@ You MUST immediately inform the user with the following message:
199
247
  Do NOT attempt to find a workaround or alternative way to execute this command. Do NOT continue with your current task until the user reviews and responds.`;
200
248
  }
201
249
 
250
+ function formatExfilBlockError(detection) {
251
+ const al = exfilGuard.getAllowlist();
252
+ const domainList = al && al.domains.length > 0
253
+ ? al.domains.map(d => ` - ${d}`).join('\n')
254
+ : ' (none configured)';
255
+ return `\u26a0\ufe0f SECURITY FIREWALL \u2014 Blocked: sensitive env vars sent to non-allowed destination.
256
+
257
+ This command sends ${detection.vars.join(', ')} via ${detection.tool} to ${detection.destination}, which is not in your allowed destinations list.
258
+
259
+ Allowed destinations:
260
+ ${domainList}
261
+
262
+ To allow this destination, run:
263
+ openclaw-secure exfil-allow add ${detection.destination}
264
+
265
+ Or manage the allowlist via the ContextFort dashboard.
266
+
267
+ You MUST immediately inform the user with the following message:
268
+
269
+ "A command was blocked because it sends sensitive environment variables (${detection.vars.join(', ')}) to ${detection.destination}, which is not in your allowed destinations. Run 'openclaw-secure exfil-allow add ${detection.destination}' to allow it."
270
+
271
+ Do NOT attempt to find a workaround or alternative way to execute this command.`;
272
+ }
273
+
274
+ // === PostToolUse scan helper ===
275
+ // Logs scanning event at hook level (always, when pattern matches), then delegates to scanOutput for Haiku call
276
+ function postToolUseScan(cmd, stdout, stderr) {
277
+ const matchedPattern = promptInjectionGuard.getMatchedPattern(cmd);
278
+ if (!matchedPattern) return;
279
+ const output = (stdout || '') + (stderr || '');
280
+ localLogger.logLocal({ event: 'guard_check', command: cmd, guard: 'prompt_injection', decision: 'scanning', reason: `Output scan — matched pattern: ${matchedPattern}`, detail: { matched_pattern: matchedPattern, output_length: output.length, model_input: output } });
281
+ try { promptInjectionGuard.scanOutput(cmd, stdout, stderr); } catch {}
282
+ }
283
+
202
284
  // === child_process hooks ===
203
285
 
204
286
  function hookAllSpawnMethods(cp) {
@@ -212,11 +294,23 @@ function hookAllSpawnMethods(cp) {
212
294
  if (block) { const e = new Error(formatBlockError(shellCmd, block)); e.code = 'EPERM'; throw e; }
213
295
  }
214
296
  const child = orig.apply(this, arguments);
215
- if (shellCmd && promptInjectionGuard.shouldScanCommand(shellCmd)) {
297
+ if (shellCmd) {
216
298
  let stdoutBuf = ''; let stderrBuf = '';
217
299
  if (child.stdout) child.stdout.on('data', (c) => { if (stdoutBuf.length < 50000) stdoutBuf += c; });
218
300
  if (child.stderr) child.stderr.on('data', (c) => { if (stderrBuf.length < 50000) stderrBuf += c; });
219
- child.on('close', () => { try { promptInjectionGuard.scanOutput(shellCmd, stdoutBuf, stderrBuf); } catch {} });
301
+ child.on('close', () => {
302
+ // Prompt injection scan (only for matching patterns)
303
+ postToolUseScan(shellCmd, stdoutBuf, stderrBuf);
304
+ // Secrets leak detection — log only, cannot redact streaming output
305
+ try {
306
+ const stdoutScan = secretsGuard.scanOutputForSecrets(stdoutBuf);
307
+ const stderrScan = secretsGuard.scanOutputForSecrets(stderrBuf);
308
+ if (stdoutScan.found || stderrScan.found) {
309
+ const allSecrets = [...(stdoutScan.secrets || []), ...(stderrScan.secrets || [])];
310
+ localLogger.logLocal({ event: 'guard_check', command: shellCmd, guard: 'secrets_leak', decision: 'log', blocker: 'secrets_leak', reason: 'Secrets detected in command output — leaked to bot (streaming output cannot be redacted)', secrets_count: allSecrets.length, detail: { secrets: allSecrets.map(s => s.name) } });
311
+ }
312
+ } catch {}
313
+ });
220
314
  }
221
315
  return child;
222
316
  };
@@ -233,7 +327,7 @@ function hookAllSpawnMethods(cp) {
233
327
  }
234
328
  const result = orig.apply(this, arguments);
235
329
  if (shellCmd) {
236
- try { promptInjectionGuard.scanOutput(shellCmd, (result.stdout || '').toString(), (result.stderr || '').toString()); } catch {}
330
+ postToolUseScan(shellCmd, (result.stdout || '').toString(), (result.stderr || '').toString());
237
331
  // Redact secrets from output before LLM sees them
238
332
  try {
239
333
  const stdoutStr = (result.stdout || '').toString();
@@ -243,7 +337,7 @@ function hookAllSpawnMethods(cp) {
243
337
  if (stdoutScan.found || stderrScan.found) {
244
338
  const allSecrets = [...(stdoutScan.secrets || []), ...(stderrScan.secrets || [])];
245
339
  const notice = secretsGuard.formatRedactionNotice({ secrets: allSecrets });
246
- localLogger.logLocal({ event: 'output_redacted', command: shellCmd.slice(0, 500), guards: ['secrets'], decision: 'redact', secrets_count: allSecrets.length });
340
+ localLogger.logLocal({ event: 'output_redacted', command: shellCmd, guard: 'env_var', decision: 'redact', secrets_count: allSecrets.length, detail: { secrets: allSecrets.map(s => s.name), matched_patterns: [...new Set(allSecrets.map(s => s.name))] } });
247
341
  if (stdoutScan.found) {
248
342
  const redacted = stdoutScan.redacted + notice;
249
343
  result.stdout = Buffer.isBuffer(result.stdout) ? Buffer.from(redacted) : redacted;
@@ -272,14 +366,16 @@ function hookAllSpawnMethods(cp) {
272
366
  }
273
367
  // Wrap callback to capture output for PostToolUse scan + redact secrets
274
368
  const wrapCb = (origCb) => function(err, stdout, stderr) {
275
- if (!err) { try { promptInjectionGuard.scanOutput(command, (stdout || '').toString(), (stderr || '').toString()); } catch {} }
369
+ if (!err) { postToolUseScan(command, (stdout || '').toString(), (stderr || '').toString()); }
276
370
  // Redact secrets from output before callback sees them
277
371
  try {
278
372
  let so = stdout, se = stderr;
279
373
  const stdoutScan = secretsGuard.scanOutputForSecrets((stdout || '').toString());
280
374
  const stderrScan = secretsGuard.scanOutputForSecrets((stderr || '').toString());
281
375
  if (stdoutScan.found || stderrScan.found) {
282
- const notice = secretsGuard.formatRedactionNotice({ secrets: [...(stdoutScan.secrets || []), ...(stderrScan.secrets || [])] });
376
+ const allSecrets = [...(stdoutScan.secrets || []), ...(stderrScan.secrets || [])];
377
+ const notice = secretsGuard.formatRedactionNotice({ secrets: allSecrets });
378
+ localLogger.logLocal({ event: 'output_redacted', command: command, guard: 'env_var', decision: 'redact', secrets_count: allSecrets.length, detail: { secrets: allSecrets.map(s => s.name), matched_patterns: [...new Set(allSecrets.map(s => s.name))] } });
283
379
  if (stdoutScan.found) so = stdoutScan.redacted + notice;
284
380
  if (stderrScan.found) se = stderrScan.redacted + notice;
285
381
  return origCb.call(this, err, so, se);
@@ -303,13 +399,13 @@ function hookAllSpawnMethods(cp) {
303
399
  const block = shouldBlockCommand(command);
304
400
  if (block) { const e = new Error(formatBlockError(command, block)); e.code = 'EPERM'; throw e; }
305
401
  const result = orig.apply(this, arguments);
306
- try { promptInjectionGuard.scanOutput(command, (result || '').toString(), ''); } catch {}
402
+ postToolUseScan(command, (result || '').toString(), '');
307
403
  // Redact secrets from output before LLM sees them
308
404
  try {
309
405
  const str = (result || '').toString();
310
406
  const scan = secretsGuard.scanOutputForSecrets(str);
311
407
  if (scan.found) {
312
- localLogger.logLocal({ event: 'output_redacted', command: command.slice(0, 500), guards: ['secrets'], decision: 'redact', secrets_count: scan.secrets.length });
408
+ localLogger.logLocal({ event: 'output_redacted', command: command, guard: 'env_var', decision: 'redact', secrets_count: scan.secrets.length, detail: { secrets: scan.secrets.map(s => s.name), matched_patterns: [...new Set(scan.secrets.map(s => s.name))] } });
313
409
  const redacted = scan.redacted + secretsGuard.formatRedactionNotice(scan);
314
410
  return Buffer.isBuffer(result) ? Buffer.from(redacted) : redacted;
315
411
  }
@@ -335,14 +431,16 @@ function hookAllSpawnMethods(cp) {
335
431
  // Wrap callback for PostToolUse scan + redact secrets
336
432
  if (shellCmd) {
337
433
  const wrapCb = (origCb) => function(err, stdout, stderr) {
338
- if (!err) { try { promptInjectionGuard.scanOutput(shellCmd, (stdout || '').toString(), (stderr || '').toString()); } catch {} }
434
+ if (!err) { postToolUseScan(shellCmd, (stdout || '').toString(), (stderr || '').toString()); }
339
435
  // Redact secrets from output before callback sees them
340
436
  try {
341
437
  let so = stdout, se = stderr;
342
438
  const stdoutScan = secretsGuard.scanOutputForSecrets((stdout || '').toString());
343
439
  const stderrScan = secretsGuard.scanOutputForSecrets((stderr || '').toString());
344
440
  if (stdoutScan.found || stderrScan.found) {
345
- const notice = secretsGuard.formatRedactionNotice({ secrets: [...(stdoutScan.secrets || []), ...(stderrScan.secrets || [])] });
441
+ const allSecrets = [...(stdoutScan.secrets || []), ...(stderrScan.secrets || [])];
442
+ const notice = secretsGuard.formatRedactionNotice({ secrets: allSecrets });
443
+ localLogger.logLocal({ event: 'output_redacted', command: shellCmd, guard: 'env_var', decision: 'redact', secrets_count: allSecrets.length, detail: { secrets: allSecrets.map(s => s.name), matched_patterns: [...new Set(allSecrets.map(s => s.name))] } });
346
444
  if (stdoutScan.found) so = stdoutScan.redacted + notice;
347
445
  if (stderrScan.found) se = stderrScan.redacted + notice;
348
446
  return origCb.call(this, err, so, se);
@@ -369,13 +467,13 @@ function hookAllSpawnMethods(cp) {
369
467
  }
370
468
  const result = orig.apply(this, arguments);
371
469
  if (shellCmd) {
372
- try { promptInjectionGuard.scanOutput(shellCmd, (result || '').toString(), ''); } catch {}
470
+ postToolUseScan(shellCmd, (result || '').toString(), '');
373
471
  // Redact secrets from output
374
472
  try {
375
473
  const str = (result || '').toString();
376
474
  const scan = secretsGuard.scanOutputForSecrets(str);
377
475
  if (scan.found) {
378
- localLogger.logLocal({ event: 'output_redacted', command: shellCmd.slice(0, 500), guards: ['secrets'], decision: 'redact', secrets_count: scan.secrets.length });
476
+ localLogger.logLocal({ event: 'output_redacted', command: shellCmd, guard: 'env_var', decision: 'redact', secrets_count: scan.secrets.length, detail: { secrets: scan.secrets.map(s => s.name), matched_patterns: [...new Set(scan.secrets.map(s => s.name))] } });
379
477
  const redacted = scan.redacted + secretsGuard.formatRedactionNotice(scan);
380
478
  return Buffer.isBuffer(result) ? Buffer.from(redacted) : redacted;
381
479
  }
@@ -470,5 +568,6 @@ Module.prototype.require = function(id) {
470
568
  setImmediate(() => {
471
569
  try { skillsGuard.init(); } catch {}
472
570
  try { promptInjectionGuard.init(); } catch {}
571
+ try { exfilGuard.init(); } catch {}
473
572
  });
474
573
  process.on('exit', () => { skillsGuard.cleanup(); });
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.11",
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"