clawmoat 0.5.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +4 -2
- package/README.md +86 -3
- package/SECURITY.md +58 -10
- package/bin/clawmoat.js +298 -1
- package/clawmoat-0.8.0.tgz +0 -0
- package/docs/blog/386-malicious-skills.html +255 -0
- package/docs/blog/40000-exposed-openclaw-instances.html +194 -0
- package/docs/blog/agent-trust-protocol.html +197 -0
- package/docs/blog/clawmoat-vs-llamafirewall-nemo-guardrails.html +223 -0
- package/docs/blog/ibm-experts-agent-runtime-protection.html +238 -0
- package/docs/blog/index.html +168 -0
- package/docs/blog/mcp-30-cves-security-crisis.html +279 -0
- package/docs/blog/microsoft-openclaw-workstation-security.html +234 -0
- package/docs/blog/nist-ai-agent-standards-clawmoat.html +369 -0
- package/docs/blog/oasis-websocket-hijack.html +205 -0
- package/docs/blog/ollama-openclaw-security.html +154 -0
- package/docs/blog/openclaw-enterprise-readiness-claw10.html +198 -0
- package/docs/blog/openclaw-security-reckoning-2026.html +361 -0
- package/docs/blog/supply-chain-agents.html +166 -0
- package/docs/blog/supply-chain-agents.md +79 -0
- package/docs/business/index.html +530 -0
- package/docs/business/install.html +247 -0
- package/docs/checklist.html +168 -0
- package/docs/finance/index.html +217 -0
- package/docs/hall-of-fame.html +168 -0
- package/docs/index.html +328 -90
- package/docs/install.sh +557 -0
- package/docs/privacy-policy/index.html +122 -0
- package/docs/scan/index.html +214 -0
- package/docs/sitemap.xml +132 -2
- package/docs/support/index.html +124 -0
- package/docs/terms-of-service/index.html +122 -0
- package/examples/basic-usage.js +38 -0
- package/package.json +1 -1
- package/server/index.js +179 -14
- package/server/index.js.patch +1 -0
- package/src/finance/index.js +585 -0
- package/src/finance/mcp-firewall.js +486 -0
- package/src/guardian/cve-verify.js +129 -0
- package/src/guardian/gateway-monitor.js +590 -0
- package/src/guardian/index.js +3 -1
- package/src/guardian/insider-threat.js +498 -0
- package/src/index.js +3 -0
- package/src/middleware/openclaw.js +28 -1
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Tool Firewall — Intercepts MCP tool calls to financial services
|
|
3
|
+
*
|
|
4
|
+
* Sits between the AI agent and MCP servers (QuickBooks, Xero, Stripe, etc.)
|
|
5
|
+
* to enforce security policies on financial tool usage.
|
|
6
|
+
*
|
|
7
|
+
* Key insight: Companies won't trust AI agents to WRITE to financial systems yet.
|
|
8
|
+
* The real risk is READ-SIDE LEAKAGE — agent pulls sensitive financial data
|
|
9
|
+
* via MCP, then leaks it through prompt injection or exfiltration.
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - Read-only mode enforcement (block all write/create/update/delete operations)
|
|
13
|
+
* - Field-level redaction on MCP tool responses (SSN, account numbers, etc.)
|
|
14
|
+
* - Tool allowlisting (only approved MCP tools can be called)
|
|
15
|
+
* - Call rate limiting per tool
|
|
16
|
+
* - Full audit trail of every MCP tool invocation
|
|
17
|
+
* - Sensitive field masking in responses before they reach the agent
|
|
18
|
+
*
|
|
19
|
+
* @module clawmoat/finance/mcp-firewall
|
|
20
|
+
* @example
|
|
21
|
+
* const { McpFirewall } = require('clawmoat/finance/mcp-firewall');
|
|
22
|
+
* const firewall = new McpFirewall({
|
|
23
|
+
* mode: 'read-only',
|
|
24
|
+
* redactFields: ['ssn', 'tax_id', 'bank_account', 'routing_number'],
|
|
25
|
+
* allowedTools: ['get_invoices', 'get_profit_loss', 'get_balance_sheet'],
|
|
26
|
+
* onBlock: (event) => console.log('Blocked:', event),
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* // Wrap MCP tool call
|
|
30
|
+
* const result = firewall.intercept({
|
|
31
|
+
* tool: 'create_invoice',
|
|
32
|
+
* args: { amount: 5000, customer: 'Acme Corp' },
|
|
33
|
+
* server: 'quickbooks-mcp',
|
|
34
|
+
* });
|
|
35
|
+
* // result.blocked = true (write operation in read-only mode)
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const crypto = require('crypto');
|
|
39
|
+
|
|
40
|
+
// ─── Write Operation Patterns ───────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Tool name patterns that indicate write operations */
|
|
43
|
+
const WRITE_PATTERNS = [
|
|
44
|
+
/^create[_-]/i,
|
|
45
|
+
/^add[_-]/i,
|
|
46
|
+
/^update[_-]/i,
|
|
47
|
+
/^edit[_-]/i,
|
|
48
|
+
/^modify[_-]/i,
|
|
49
|
+
/^delete[_-]/i,
|
|
50
|
+
/^remove[_-]/i,
|
|
51
|
+
/^send[_-]/i,
|
|
52
|
+
/^post[_-]/i,
|
|
53
|
+
/^submit[_-]/i,
|
|
54
|
+
/^approve[_-]/i,
|
|
55
|
+
/^void[_-]/i,
|
|
56
|
+
/^cancel[_-]/i,
|
|
57
|
+
/^refund[_-]/i,
|
|
58
|
+
/^transfer[_-]/i,
|
|
59
|
+
/^pay[_-]/i,
|
|
60
|
+
/^charge[_-]/i,
|
|
61
|
+
/^issue[_-]/i,
|
|
62
|
+
/^record[_-]/i,
|
|
63
|
+
/^close[_-]/i,
|
|
64
|
+
/^batch[_-]/i,
|
|
65
|
+
/^import[_-]/i,
|
|
66
|
+
/^set[_-]/i,
|
|
67
|
+
/^assign[_-]/i,
|
|
68
|
+
/^link[_-]/i,
|
|
69
|
+
/^unlink[_-]/i,
|
|
70
|
+
/^archive[_-]/i,
|
|
71
|
+
/^restore[_-]/i,
|
|
72
|
+
/^merge[_-]/i,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Known financial MCP server identifiers */
|
|
76
|
+
const KNOWN_FINANCIAL_SERVERS = [
|
|
77
|
+
{ pattern: /quickbooks/i, label: 'QuickBooks', category: 'accounting' },
|
|
78
|
+
{ pattern: /xero/i, label: 'Xero', category: 'accounting' },
|
|
79
|
+
{ pattern: /freshbooks/i, label: 'FreshBooks', category: 'accounting' },
|
|
80
|
+
{ pattern: /stripe/i, label: 'Stripe', category: 'payment' },
|
|
81
|
+
{ pattern: /plaid/i, label: 'Plaid', category: 'banking' },
|
|
82
|
+
{ pattern: /square/i, label: 'Square', category: 'payment' },
|
|
83
|
+
{ pattern: /paypal/i, label: 'PayPal', category: 'payment' },
|
|
84
|
+
{ pattern: /braintree/i, label: 'Braintree', category: 'payment' },
|
|
85
|
+
{ pattern: /coinbase/i, label: 'Coinbase', category: 'crypto' },
|
|
86
|
+
{ pattern: /mercury/i, label: 'Mercury', category: 'banking' },
|
|
87
|
+
{ pattern: /wise/i, label: 'Wise', category: 'transfer' },
|
|
88
|
+
{ pattern: /wave/i, label: 'Wave', category: 'accounting' },
|
|
89
|
+
{ pattern: /gusto/i, label: 'Gusto', category: 'payroll' },
|
|
90
|
+
{ pattern: /rippling/i, label: 'Rippling', category: 'payroll' },
|
|
91
|
+
{ pattern: /bill\.com/i, label: 'Bill.com', category: 'payment' },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/** Sensitive field patterns to redact in MCP responses */
|
|
95
|
+
const DEFAULT_SENSITIVE_FIELDS = [
|
|
96
|
+
// Identity
|
|
97
|
+
{ pattern: /ssn|social_security|social_sec/i, label: 'SSN', replacement: '***-**-****' },
|
|
98
|
+
{ pattern: /tax_id|^tin$|^ein$|employer_id/i, label: 'Tax ID', replacement: '**-*******' },
|
|
99
|
+
{ pattern: /^sin$|social_insurance/i, label: 'SIN', replacement: '***-***-***' },
|
|
100
|
+
|
|
101
|
+
// Banking
|
|
102
|
+
{ pattern: /account_num|acct_num|bank_account|account_number/i, label: 'Account Number', replacement: '****XXXX' },
|
|
103
|
+
{ pattern: /routing|aba_num|routing_number/i, label: 'Routing Number', replacement: '*********' },
|
|
104
|
+
{ pattern: /iban/i, label: 'IBAN', replacement: '****XXXX' },
|
|
105
|
+
{ pattern: /swift|bic/i, label: 'SWIFT/BIC', replacement: '****XXXX' },
|
|
106
|
+
|
|
107
|
+
// Payment
|
|
108
|
+
{ pattern: /card_num|credit_card|cc_num|card_number/i, label: 'Card Number', replacement: '****-****-****-XXXX' },
|
|
109
|
+
{ pattern: /cvv|cvc|security_code/i, label: 'CVV', replacement: '***' },
|
|
110
|
+
{ pattern: /^pin$|^pin_code$/i, label: 'PIN', replacement: '****' },
|
|
111
|
+
|
|
112
|
+
// Auth
|
|
113
|
+
{ pattern: /api_key|secret_key|access_token|refresh_token/i, label: 'API Key', replacement: '[REDACTED]' },
|
|
114
|
+
{ pattern: /password|passwd/i, label: 'Password', replacement: '[REDACTED]' },
|
|
115
|
+
{ pattern: /oauth_token|bearer/i, label: 'OAuth Token', replacement: '[REDACTED]' },
|
|
116
|
+
|
|
117
|
+
// Personal
|
|
118
|
+
{ pattern: /date_of_birth|dob|birth_date/i, label: 'DOB', replacement: '****-**-**' },
|
|
119
|
+
{ pattern: /driver_license|dl_number/i, label: 'Driver License', replacement: '[REDACTED]' },
|
|
120
|
+
{ pattern: /passport_num/i, label: 'Passport', replacement: '[REDACTED]' },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
// ─── McpFirewall Class ──────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
class McpFirewall {
|
|
126
|
+
/**
|
|
127
|
+
* @param {Object} options
|
|
128
|
+
* @param {string} [options.mode='read-only'] - 'read-only' | 'read-write' | 'audit-only'
|
|
129
|
+
* @param {string[]} [options.allowedTools] - Allowlist of tool names (null = all allowed)
|
|
130
|
+
* @param {string[]} [options.blockedTools] - Explicit blocklist of tool names
|
|
131
|
+
* @param {Object[]} [options.redactFields] - Additional sensitive field patterns
|
|
132
|
+
* @param {boolean} [options.redactResponses=true] - Auto-redact sensitive fields in responses
|
|
133
|
+
* @param {number} [options.rateLimit=60] - Max calls per tool per minute
|
|
134
|
+
* @param {Function} [options.onBlock] - Called when a tool call is blocked
|
|
135
|
+
* @param {Function} [options.onRedact] - Called when fields are redacted
|
|
136
|
+
* @param {Function} [options.onCall] - Called on every tool call (audit)
|
|
137
|
+
*/
|
|
138
|
+
constructor(options = {}) {
|
|
139
|
+
this.mode = options.mode || 'read-only';
|
|
140
|
+
this.allowedTools = options.allowedTools ? new Set(options.allowedTools.map(t => t.toLowerCase())) : null;
|
|
141
|
+
this.blockedTools = new Set((options.blockedTools || []).map(t => t.toLowerCase()));
|
|
142
|
+
this.redactResponses = options.redactResponses !== false;
|
|
143
|
+
this.rateLimit = options.rateLimit ?? 60;
|
|
144
|
+
this.onBlock = options.onBlock || null;
|
|
145
|
+
this.onRedact = options.onRedact || null;
|
|
146
|
+
this.onCall = options.onCall || null;
|
|
147
|
+
|
|
148
|
+
// Merge custom sensitive fields
|
|
149
|
+
this.sensitiveFields = [...DEFAULT_SENSITIVE_FIELDS];
|
|
150
|
+
if (options.redactFields) {
|
|
151
|
+
for (const f of options.redactFields) {
|
|
152
|
+
if (typeof f === 'string') {
|
|
153
|
+
this.sensitiveFields.push({
|
|
154
|
+
pattern: new RegExp(`^${f}$`, 'i'),
|
|
155
|
+
label: f,
|
|
156
|
+
replacement: '[REDACTED]',
|
|
157
|
+
});
|
|
158
|
+
} else {
|
|
159
|
+
this.sensitiveFields.push(f);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// State
|
|
165
|
+
this.auditLog = [];
|
|
166
|
+
this.callCounts = new Map(); // tool -> [{timestamp}]
|
|
167
|
+
this.stats = { total: 0, allowed: 0, blocked: 0, redacted: 0 };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Core: Intercept a Tool Call ──────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Intercept and evaluate an MCP tool call before execution.
|
|
174
|
+
* @param {Object} call
|
|
175
|
+
* @param {string} call.tool - Tool name (e.g., 'get_invoices', 'create_invoice')
|
|
176
|
+
* @param {Object} [call.args] - Tool arguments
|
|
177
|
+
* @param {string} [call.server] - MCP server identifier
|
|
178
|
+
* @param {string} [call.initiator] - What triggered the call
|
|
179
|
+
* @returns {Object} { allowed, blocked, reason, callId, serverInfo }
|
|
180
|
+
*/
|
|
181
|
+
intercept(call) {
|
|
182
|
+
const callId = crypto.randomBytes(6).toString('hex');
|
|
183
|
+
const toolLower = (call.tool || '').toLowerCase();
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
|
|
186
|
+
this.stats.total++;
|
|
187
|
+
|
|
188
|
+
const result = {
|
|
189
|
+
callId,
|
|
190
|
+
tool: call.tool,
|
|
191
|
+
server: call.server || 'unknown',
|
|
192
|
+
allowed: false,
|
|
193
|
+
blocked: false,
|
|
194
|
+
reason: '',
|
|
195
|
+
serverInfo: this._identifyServer(call.server),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const auditEntry = {
|
|
199
|
+
callId,
|
|
200
|
+
timestamp: now,
|
|
201
|
+
tool: call.tool,
|
|
202
|
+
server: call.server,
|
|
203
|
+
args: this._sanitizeArgs(call.args),
|
|
204
|
+
initiator: call.initiator || 'agent',
|
|
205
|
+
decision: 'pending',
|
|
206
|
+
reason: '',
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// 1. Check explicit blocklist
|
|
210
|
+
if (this.blockedTools.has(toolLower)) {
|
|
211
|
+
result.blocked = true;
|
|
212
|
+
result.reason = `Tool "${call.tool}" is explicitly blocked`;
|
|
213
|
+
auditEntry.decision = 'blocked';
|
|
214
|
+
auditEntry.reason = result.reason;
|
|
215
|
+
this.stats.blocked++;
|
|
216
|
+
this._emitBlock(result, call);
|
|
217
|
+
this.auditLog.push(auditEntry);
|
|
218
|
+
if (this.onCall) this.onCall(auditEntry);
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 2. Check allowlist
|
|
223
|
+
if (this.allowedTools && !this.allowedTools.has(toolLower)) {
|
|
224
|
+
result.blocked = true;
|
|
225
|
+
result.reason = `Tool "${call.tool}" is not in allowlist`;
|
|
226
|
+
auditEntry.decision = 'blocked';
|
|
227
|
+
auditEntry.reason = result.reason;
|
|
228
|
+
this.stats.blocked++;
|
|
229
|
+
this._emitBlock(result, call);
|
|
230
|
+
this.auditLog.push(auditEntry);
|
|
231
|
+
if (this.onCall) this.onCall(auditEntry);
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 3. Check read-only mode
|
|
236
|
+
if (this.mode === 'read-only') {
|
|
237
|
+
const isWrite = WRITE_PATTERNS.some(p => p.test(call.tool));
|
|
238
|
+
if (isWrite) {
|
|
239
|
+
result.blocked = true;
|
|
240
|
+
result.reason = `Write operation "${call.tool}" blocked in read-only mode`;
|
|
241
|
+
auditEntry.decision = 'blocked';
|
|
242
|
+
auditEntry.reason = result.reason;
|
|
243
|
+
this.stats.blocked++;
|
|
244
|
+
this._emitBlock(result, call);
|
|
245
|
+
this.auditLog.push(auditEntry);
|
|
246
|
+
if (this.onCall) this.onCall(auditEntry);
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 4. Rate limiting
|
|
252
|
+
if (this.rateLimit > 0) {
|
|
253
|
+
const key = toolLower;
|
|
254
|
+
const calls = this.callCounts.get(key) || [];
|
|
255
|
+
const recent = calls.filter(t => t > now - 60000);
|
|
256
|
+
if (recent.length >= this.rateLimit) {
|
|
257
|
+
result.blocked = true;
|
|
258
|
+
result.reason = `Rate limit exceeded for "${call.tool}" (${recent.length}/${this.rateLimit} per minute)`;
|
|
259
|
+
auditEntry.decision = 'rate_limited';
|
|
260
|
+
auditEntry.reason = result.reason;
|
|
261
|
+
this.stats.blocked++;
|
|
262
|
+
this._emitBlock(result, call);
|
|
263
|
+
this.auditLog.push(auditEntry);
|
|
264
|
+
if (this.onCall) this.onCall(auditEntry);
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
recent.push(now);
|
|
268
|
+
this.callCounts.set(key, recent);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 5. Allowed
|
|
272
|
+
result.allowed = true;
|
|
273
|
+
result.reason = this.mode === 'audit-only' ? 'Audit-only mode (all calls allowed)' : 'Passed all checks';
|
|
274
|
+
auditEntry.decision = 'allowed';
|
|
275
|
+
auditEntry.reason = result.reason;
|
|
276
|
+
this.stats.allowed++;
|
|
277
|
+
|
|
278
|
+
this.auditLog.push(auditEntry);
|
|
279
|
+
if (this.onCall) this.onCall(auditEntry);
|
|
280
|
+
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─── Redact Response ──────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Redact sensitive fields from an MCP tool response before it reaches the agent.
|
|
288
|
+
* Works recursively on nested objects/arrays.
|
|
289
|
+
* @param {*} response - The MCP tool response (object, array, or primitive)
|
|
290
|
+
* @param {Object} [options]
|
|
291
|
+
* @param {string} [options.callId] - Link to the intercept call
|
|
292
|
+
* @returns {Object} { redacted, fieldCount, fields }
|
|
293
|
+
*/
|
|
294
|
+
redactResponse(response, options = {}) {
|
|
295
|
+
if (!this.redactResponses) {
|
|
296
|
+
return { redacted: response, fieldCount: 0, fields: [] };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const redactedFields = [];
|
|
300
|
+
const redacted = this._deepRedact(response, redactedFields, '');
|
|
301
|
+
|
|
302
|
+
if (redactedFields.length > 0) {
|
|
303
|
+
this.stats.redacted += redactedFields.length;
|
|
304
|
+
if (this.onRedact) {
|
|
305
|
+
this.onRedact({
|
|
306
|
+
callId: options.callId,
|
|
307
|
+
fieldCount: redactedFields.length,
|
|
308
|
+
fields: redactedFields,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
this.auditLog.push({
|
|
312
|
+
timestamp: Date.now(),
|
|
313
|
+
action: 'redact_response',
|
|
314
|
+
callId: options.callId,
|
|
315
|
+
fieldCount: redactedFields.length,
|
|
316
|
+
fields: redactedFields.map(f => f.path + ' → ' + f.label),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
redacted,
|
|
322
|
+
fieldCount: redactedFields.length,
|
|
323
|
+
fields: redactedFields,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** @private Recursively redact sensitive fields */
|
|
328
|
+
_deepRedact(obj, findings, path) {
|
|
329
|
+
if (obj === null || obj === undefined) return obj;
|
|
330
|
+
if (typeof obj !== 'object') return obj;
|
|
331
|
+
|
|
332
|
+
if (Array.isArray(obj)) {
|
|
333
|
+
return obj.map((item, i) => this._deepRedact(item, findings, `${path}[${i}]`));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const result = {};
|
|
337
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
338
|
+
const fieldPath = path ? `${path}.${key}` : key;
|
|
339
|
+
const match = this.sensitiveFields.find(f => f.pattern.test(key));
|
|
340
|
+
|
|
341
|
+
if (match && value !== null && value !== undefined) {
|
|
342
|
+
findings.push({ path: fieldPath, label: match.label, original_type: typeof value });
|
|
343
|
+
result[key] = match.replacement;
|
|
344
|
+
} else if (typeof value === 'object') {
|
|
345
|
+
result[key] = this._deepRedact(value, findings, fieldPath);
|
|
346
|
+
} else {
|
|
347
|
+
result[key] = value;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─── Convenience: Intercept + Redact ──────────────────────────
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Full pipeline: intercept call, if allowed execute callback, redact response.
|
|
357
|
+
* @param {Object} call - Same as intercept()
|
|
358
|
+
* @param {Function} executor - async fn(call) that executes the actual MCP call
|
|
359
|
+
* @returns {Object} { allowed, blocked, response, redaction, callId }
|
|
360
|
+
*/
|
|
361
|
+
async guard(call, executor) {
|
|
362
|
+
const decision = this.intercept(call);
|
|
363
|
+
if (decision.blocked) {
|
|
364
|
+
return { ...decision, response: null, redaction: null };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const rawResponse = await executor(call);
|
|
368
|
+
const redaction = this.redactResponse(rawResponse, { callId: decision.callId });
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
...decision,
|
|
372
|
+
response: redaction.redacted,
|
|
373
|
+
redaction: {
|
|
374
|
+
fieldCount: redaction.fieldCount,
|
|
375
|
+
fields: redaction.fields,
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Tool Discovery ───────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Analyze a list of MCP tools and classify them as read/write/dangerous.
|
|
384
|
+
* Useful for auto-configuring allowlists.
|
|
385
|
+
* @param {string[]} tools - Array of tool names from MCP server
|
|
386
|
+
* @returns {Object} { read, write, unknown, recommended_allowlist }
|
|
387
|
+
*/
|
|
388
|
+
classifyTools(tools) {
|
|
389
|
+
const read = [];
|
|
390
|
+
const write = [];
|
|
391
|
+
const unknown = [];
|
|
392
|
+
|
|
393
|
+
for (const tool of tools) {
|
|
394
|
+
const isWrite = WRITE_PATTERNS.some(p => p.test(tool));
|
|
395
|
+
const isRead = /^(get|list|query|search|find|fetch|read|show|describe|count|check|verify|report|export|download)/i.test(tool);
|
|
396
|
+
|
|
397
|
+
if (isWrite) {
|
|
398
|
+
write.push(tool);
|
|
399
|
+
} else if (isRead) {
|
|
400
|
+
read.push(tool);
|
|
401
|
+
} else {
|
|
402
|
+
unknown.push(tool);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
read,
|
|
408
|
+
write,
|
|
409
|
+
unknown,
|
|
410
|
+
recommended_allowlist: read,
|
|
411
|
+
summary: `${read.length} read, ${write.length} write, ${unknown.length} unknown of ${tools.length} total`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ─── Reports ──────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
/** Get audit summary */
|
|
418
|
+
getAuditSummary() {
|
|
419
|
+
return {
|
|
420
|
+
mode: this.mode,
|
|
421
|
+
...this.stats,
|
|
422
|
+
recentCalls: this.auditLog.slice(-20),
|
|
423
|
+
topTools: this._getTopTools(),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Get all blocked calls */
|
|
428
|
+
getBlockedCalls() {
|
|
429
|
+
return this.auditLog.filter(e => e.decision === 'blocked' || e.decision === 'rate_limited');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Reset state */
|
|
433
|
+
reset() {
|
|
434
|
+
this.auditLog = [];
|
|
435
|
+
this.callCounts.clear();
|
|
436
|
+
this.stats = { total: 0, allowed: 0, blocked: 0, redacted: 0 };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Private helpers ──────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
_identifyServer(server) {
|
|
442
|
+
if (!server) return null;
|
|
443
|
+
const match = KNOWN_FINANCIAL_SERVERS.find(s => s.pattern.test(server));
|
|
444
|
+
return match ? { label: match.label, category: match.category } : null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
_sanitizeArgs(args) {
|
|
448
|
+
if (!args || typeof args !== 'object') return args;
|
|
449
|
+
const sanitized = {};
|
|
450
|
+
for (const [k, v] of Object.entries(args)) {
|
|
451
|
+
const isSensitive = this.sensitiveFields.some(f => f.pattern.test(k));
|
|
452
|
+
sanitized[k] = isSensitive ? '[REDACTED]' : v;
|
|
453
|
+
}
|
|
454
|
+
return sanitized;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
_emitBlock(result, call) {
|
|
458
|
+
if (this.onBlock) {
|
|
459
|
+
this.onBlock({
|
|
460
|
+
callId: result.callId,
|
|
461
|
+
tool: call.tool,
|
|
462
|
+
server: call.server,
|
|
463
|
+
reason: result.reason,
|
|
464
|
+
timestamp: Date.now(),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
_getTopTools() {
|
|
470
|
+
const counts = {};
|
|
471
|
+
for (const entry of this.auditLog) {
|
|
472
|
+
if (entry.tool) counts[entry.tool] = (counts[entry.tool] || 0) + 1;
|
|
473
|
+
}
|
|
474
|
+
return Object.entries(counts)
|
|
475
|
+
.sort((a, b) => b[1] - a[1])
|
|
476
|
+
.slice(0, 10)
|
|
477
|
+
.map(([tool, count]) => ({ tool, count }));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
module.exports = {
|
|
482
|
+
McpFirewall,
|
|
483
|
+
WRITE_PATTERNS,
|
|
484
|
+
KNOWN_FINANCIAL_SERVERS,
|
|
485
|
+
DEFAULT_SENSITIVE_FIELDS,
|
|
486
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CVE Verifier — validates CVE IDs against GitHub Advisory Database
|
|
3
|
+
* No external dependencies; uses Node.js built-in https module.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const https = require('https');
|
|
7
|
+
|
|
8
|
+
class CVEVerifier {
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} [opts]
|
|
11
|
+
* @param {string} [opts.githubToken] - Optional GitHub PAT for higher rate limits
|
|
12
|
+
*/
|
|
13
|
+
constructor(opts = {}) {
|
|
14
|
+
this.githubToken = opts.githubToken || process.env.GITHUB_TOKEN || null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate CVE ID format
|
|
19
|
+
* @param {string} cveId
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
static isValidCVEFormat(cveId) {
|
|
23
|
+
return /^CVE-\d{4}-\d{4,}$/.test(cveId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fetch advisory data from GitHub Advisory Database API
|
|
28
|
+
* @param {string} cveId e.g. "CVE-2026-26960"
|
|
29
|
+
* @returns {Promise<object>} { valid, severity, summary, publishedAt, references, affectedPackages, raw }
|
|
30
|
+
*/
|
|
31
|
+
async lookup(cveId) {
|
|
32
|
+
if (!CVEVerifier.isValidCVEFormat(cveId)) {
|
|
33
|
+
return { valid: false, error: `Invalid CVE format: ${cveId}` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const url = `https://api.github.com/advisories?cve_id=${encodeURIComponent(cveId)}`;
|
|
37
|
+
let data;
|
|
38
|
+
try {
|
|
39
|
+
data = await this._fetch(url);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { valid: false, error: `API request failed: ${err.message}` };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!Array.isArray(data) || data.length === 0) {
|
|
45
|
+
return { valid: false, cveId, severity: null, summary: null, publishedAt: null, references: [], affectedPackages: [] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const advisory = data[0];
|
|
49
|
+
return {
|
|
50
|
+
valid: true,
|
|
51
|
+
cveId,
|
|
52
|
+
severity: advisory.severity || null,
|
|
53
|
+
summary: advisory.summary || null,
|
|
54
|
+
publishedAt: advisory.published_at || null,
|
|
55
|
+
references: (advisory.references || []).map(r => (typeof r === 'string' ? r : r.url)).filter(Boolean),
|
|
56
|
+
affectedPackages: (advisory.vulnerabilities || []).map(v => ({
|
|
57
|
+
ecosystem: v.package?.ecosystem || null,
|
|
58
|
+
name: v.package?.name || null,
|
|
59
|
+
vulnerableRange: v.vulnerable_version_range || null,
|
|
60
|
+
})),
|
|
61
|
+
ghsaId: advisory.ghsa_id || null,
|
|
62
|
+
htmlUrl: advisory.html_url || null,
|
|
63
|
+
raw: advisory,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check whether a URL is a legitimate GitHub advisory link
|
|
69
|
+
* @param {string} url
|
|
70
|
+
* @returns {{ legitimate: boolean, reason: string }}
|
|
71
|
+
*/
|
|
72
|
+
static checkAdvisoryUrl(url) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = new URL(url);
|
|
75
|
+
const isGitHub = parsed.hostname === 'github.com' && parsed.pathname.startsWith('/advisories/');
|
|
76
|
+
if (isGitHub) {
|
|
77
|
+
return { legitimate: true, reason: 'URL points to official GitHub Advisory Database' };
|
|
78
|
+
}
|
|
79
|
+
return { legitimate: false, reason: `Domain "${parsed.hostname}" is not github.com/advisories` };
|
|
80
|
+
} catch {
|
|
81
|
+
return { legitimate: false, reason: 'Invalid URL' };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verify a CVE + optional suspicious URL together
|
|
87
|
+
* @param {string} cveId
|
|
88
|
+
* @param {string} [suspiciousUrl]
|
|
89
|
+
* @returns {Promise<object>}
|
|
90
|
+
*/
|
|
91
|
+
async verify(cveId, suspiciousUrl) {
|
|
92
|
+
const result = await this.lookup(cveId);
|
|
93
|
+
if (suspiciousUrl) {
|
|
94
|
+
result.urlCheck = CVEVerifier.checkAdvisoryUrl(suspiciousUrl);
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @private */
|
|
100
|
+
_fetch(url) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const headers = {
|
|
103
|
+
'User-Agent': 'ClawMoat-CVE-Verifier',
|
|
104
|
+
'Accept': 'application/vnd.github+json',
|
|
105
|
+
};
|
|
106
|
+
if (this.githubToken) {
|
|
107
|
+
headers['Authorization'] = `Bearer ${this.githubToken}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
https.get(url, { headers }, (res) => {
|
|
111
|
+
let body = '';
|
|
112
|
+
res.on('data', chunk => body += chunk);
|
|
113
|
+
res.on('end', () => {
|
|
114
|
+
if (res.statusCode !== 200) {
|
|
115
|
+
return reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
resolve(JSON.parse(body));
|
|
119
|
+
} catch (e) {
|
|
120
|
+
reject(new Error(`JSON parse error: ${e.message}`));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
res.on('error', reject);
|
|
124
|
+
}).on('error', reject);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { CVEVerifier };
|