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.
Files changed (44) hide show
  1. package/CONTRIBUTING.md +4 -2
  2. package/README.md +86 -3
  3. package/SECURITY.md +58 -10
  4. package/bin/clawmoat.js +298 -1
  5. package/clawmoat-0.8.0.tgz +0 -0
  6. package/docs/blog/386-malicious-skills.html +255 -0
  7. package/docs/blog/40000-exposed-openclaw-instances.html +194 -0
  8. package/docs/blog/agent-trust-protocol.html +197 -0
  9. package/docs/blog/clawmoat-vs-llamafirewall-nemo-guardrails.html +223 -0
  10. package/docs/blog/ibm-experts-agent-runtime-protection.html +238 -0
  11. package/docs/blog/index.html +168 -0
  12. package/docs/blog/mcp-30-cves-security-crisis.html +279 -0
  13. package/docs/blog/microsoft-openclaw-workstation-security.html +234 -0
  14. package/docs/blog/nist-ai-agent-standards-clawmoat.html +369 -0
  15. package/docs/blog/oasis-websocket-hijack.html +205 -0
  16. package/docs/blog/ollama-openclaw-security.html +154 -0
  17. package/docs/blog/openclaw-enterprise-readiness-claw10.html +198 -0
  18. package/docs/blog/openclaw-security-reckoning-2026.html +361 -0
  19. package/docs/blog/supply-chain-agents.html +166 -0
  20. package/docs/blog/supply-chain-agents.md +79 -0
  21. package/docs/business/index.html +530 -0
  22. package/docs/business/install.html +247 -0
  23. package/docs/checklist.html +168 -0
  24. package/docs/finance/index.html +217 -0
  25. package/docs/hall-of-fame.html +168 -0
  26. package/docs/index.html +328 -90
  27. package/docs/install.sh +557 -0
  28. package/docs/privacy-policy/index.html +122 -0
  29. package/docs/scan/index.html +214 -0
  30. package/docs/sitemap.xml +132 -2
  31. package/docs/support/index.html +124 -0
  32. package/docs/terms-of-service/index.html +122 -0
  33. package/examples/basic-usage.js +38 -0
  34. package/package.json +1 -1
  35. package/server/index.js +179 -14
  36. package/server/index.js.patch +1 -0
  37. package/src/finance/index.js +585 -0
  38. package/src/finance/mcp-firewall.js +486 -0
  39. package/src/guardian/cve-verify.js +129 -0
  40. package/src/guardian/gateway-monitor.js +590 -0
  41. package/src/guardian/index.js +3 -1
  42. package/src/guardian/insider-threat.js +498 -0
  43. package/src/index.js +3 -0
  44. 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 };