@adrata/adrata-mcp 1.0.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/README.md +548 -0
- package/access/auth.js +289 -0
- package/access/oauth.js +1059 -0
- package/access/resource-metadata.js +167 -0
- package/access/tiers.js +422 -0
- package/analytics.js +634 -0
- package/api-bridge.js +499 -0
- package/governance/money.js +141 -0
- package/output-formatter.js +589 -0
- package/package.json +68 -0
- package/resources.js +246 -0
- package/security.js +690 -0
- package/server.js +2139 -0
- package/server.json +55 -0
- package/skills/backlog-triage/SKILL.md +115 -0
- package/skills/board-review/SKILL.md +96 -0
- package/skills/incident-to-card/SKILL.md +126 -0
- package/skills/log-outreach.md +62 -0
- package/skills/ship-the-card/SKILL.md +155 -0
- package/tool-annotations.js +269 -0
- package/tools/billing.js +149 -0
- package/tools/email-tools.js +652 -0
- package/tools/enterprise-tools.js +651 -0
- package/tools/free-search.js +160 -0
- package/tools/memory.js +440 -0
- package/tools/morning-brief.js +551 -0
- package/tools/paper-tools.js +563 -0
- package/tools/scheduling.js +322 -0
- package/tools/work-board-tools.js +758 -0
- package/toolsets/communications.js +276 -0
- package/toolsets/crm.js +495 -0
- package/toolsets/extensibility.js +1131 -0
- package/toolsets/infrastructure.js +757 -0
- package/toolsets/intelligence.js +232 -0
- package/toolsets/knowledge.js +154 -0
- package/toolsets/matrix.js +217 -0
- package/toolsets/outreach.js +432 -0
- package/toolsets/prospecting.js +314 -0
- package/toolsets/revenue/always-loaded.js +341 -0
- package/toolsets/revenue/sloan-tools.js +81 -0
- package/transport-http.js +505 -0
package/security.js
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enterprise-grade security hardening for Adrata MCP Server.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* 1. Rate limiting (sliding window, per-tier)
|
|
6
|
+
* 2. Input validation (Zod-based sanitization)
|
|
7
|
+
* 3. Audit logging (enterprise tier, every non-read tool + mass-egress reads)
|
|
8
|
+
* 4. Token encryption at rest (AES-256-GCM)
|
|
9
|
+
* 5. CORS restrictions (production mode)
|
|
10
|
+
* 6. Transport security helpers
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import { buildToolAnnotations } from './tool-annotations.js';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Configuration
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const TELEMETRY_ENABLED = process.env.ADRATA_TELEMETRY !== 'off';
|
|
23
|
+
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// 1. Rate Limiting — Sliding Window
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sliding window rate limiter using in-memory Map with timestamps.
|
|
31
|
+
* Per-session for stdio transport, per-IP for HTTP/SSE transport.
|
|
32
|
+
*/
|
|
33
|
+
class SlidingWindowRateLimiter {
|
|
34
|
+
constructor() {
|
|
35
|
+
// Map<string, number[]> — key -> array of request timestamps (ms)
|
|
36
|
+
this.windows = new Map();
|
|
37
|
+
// Cleanup stale entries every 5 minutes
|
|
38
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
|
39
|
+
if (this.cleanupInterval.unref) this.cleanupInterval.unref();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if a request is allowed under rate limits.
|
|
44
|
+
* @param {string} key — session ID or IP address
|
|
45
|
+
* @param {number} maxRequests — max requests in the window
|
|
46
|
+
* @param {number} windowMs — window size in milliseconds
|
|
47
|
+
* @returns {{ allowed: boolean, remaining: number, retryAfterMs: number|null }}
|
|
48
|
+
*/
|
|
49
|
+
check(key, maxRequests, windowMs) {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
const cutoff = now - windowMs;
|
|
52
|
+
|
|
53
|
+
let timestamps = this.windows.get(key);
|
|
54
|
+
if (!timestamps) {
|
|
55
|
+
timestamps = [];
|
|
56
|
+
this.windows.set(key, timestamps);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Remove expired timestamps
|
|
60
|
+
while (timestamps.length > 0 && timestamps[0] <= cutoff) {
|
|
61
|
+
timestamps.shift();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (timestamps.length >= maxRequests) {
|
|
65
|
+
const oldestInWindow = timestamps[0];
|
|
66
|
+
const retryAfterMs = oldestInWindow + windowMs - now;
|
|
67
|
+
return {
|
|
68
|
+
allowed: false,
|
|
69
|
+
remaining: 0,
|
|
70
|
+
retryAfterMs: Math.max(retryAfterMs, 1000),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
timestamps.push(now);
|
|
75
|
+
return {
|
|
76
|
+
allowed: true,
|
|
77
|
+
remaining: maxRequests - timestamps.length,
|
|
78
|
+
retryAfterMs: null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
cleanup() {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
// Remove entries with no recent activity (older than 1 hour)
|
|
85
|
+
const oneHourAgo = now - 60 * 60 * 1000;
|
|
86
|
+
for (const [key, timestamps] of this.windows) {
|
|
87
|
+
if (timestamps.length === 0 || timestamps[timestamps.length - 1] < oneHourAgo) {
|
|
88
|
+
this.windows.delete(key);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
destroy() {
|
|
94
|
+
clearInterval(this.cleanupInterval);
|
|
95
|
+
this.windows.clear();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Tier-based rate limit configuration.
|
|
101
|
+
* Free tier: 1000 req/hour (abuse protection only)
|
|
102
|
+
* Pro tier: 10000 req/day
|
|
103
|
+
* Enterprise tier: 100000 req/day
|
|
104
|
+
*/
|
|
105
|
+
const TIER_RATE_LIMITS = {
|
|
106
|
+
free: { maxRequests: 1000, windowMs: 60 * 60 * 1000 }, // 1000/hour
|
|
107
|
+
pro: { maxRequests: 10000, windowMs: 24 * 60 * 60 * 1000 }, // 10000/day
|
|
108
|
+
enterprise: { maxRequests: 100000, windowMs: 24 * 60 * 60 * 1000 }, // 100000/day
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const rateLimiter = new SlidingWindowRateLimiter();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check rate limit for a request.
|
|
115
|
+
* @param {string} sessionKey — session ID (stdio) or IP (HTTP)
|
|
116
|
+
* @param {string} tier — 'free', 'pro', or 'enterprise'
|
|
117
|
+
* @returns {{ allowed: boolean, remaining: number, retryAfterMs: number|null }}
|
|
118
|
+
*/
|
|
119
|
+
export function checkRateLimit(sessionKey, tier) {
|
|
120
|
+
const config = TIER_RATE_LIMITS[tier] || TIER_RATE_LIMITS.free;
|
|
121
|
+
return rateLimiter.check(sessionKey, config.maxRequests, config.windowMs);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build a 429 rate-limit response for MCP.
|
|
126
|
+
*/
|
|
127
|
+
export function rateLimitResponse(retryAfterMs) {
|
|
128
|
+
const retryAfterSec = Math.ceil(retryAfterMs / 1000);
|
|
129
|
+
return {
|
|
130
|
+
content: [{
|
|
131
|
+
type: 'text',
|
|
132
|
+
text: JSON.stringify({
|
|
133
|
+
error: 'rate_limit_exceeded',
|
|
134
|
+
message: `Rate limit exceeded. Please retry after ${retryAfterSec} seconds.`,
|
|
135
|
+
retryAfterSeconds: retryAfterSec,
|
|
136
|
+
}, null, 2),
|
|
137
|
+
}],
|
|
138
|
+
isError: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// 2. Input Validation
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Common validation schemas for tool parameters.
|
|
148
|
+
* These wrap and tighten the existing Zod schemas.
|
|
149
|
+
*/
|
|
150
|
+
export const validators = {
|
|
151
|
+
/** Sanitize a string — strip control characters, limit length. */
|
|
152
|
+
sanitizeString(value, maxLength = 1000) {
|
|
153
|
+
if (typeof value !== 'string') return value;
|
|
154
|
+
// Strip control characters except newline/tab
|
|
155
|
+
// eslint-disable-next-line no-control-regex
|
|
156
|
+
const cleaned = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
157
|
+
return cleaned.slice(0, maxLength);
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/** Validate that a string doesn't contain obvious injection patterns. */
|
|
161
|
+
isSafe(value) {
|
|
162
|
+
if (typeof value !== 'string') return true;
|
|
163
|
+
// Check for common injection patterns.
|
|
164
|
+
// The event-handler check requires HTML tag or attribute-breakout context
|
|
165
|
+
// (`<tag ... onX=` or a quote directly before `onX=`). A bare /on\w+\s*=/
|
|
166
|
+
// rejected legitimate business strings like "phone=555" or "one=1".
|
|
167
|
+
const dangerous = [
|
|
168
|
+
/<script\b/i,
|
|
169
|
+
/javascript:/i,
|
|
170
|
+
/<[^>]*\bon\w+\s*=/i, // <img onerror=, <div onclick = ...
|
|
171
|
+
/["']\s*on\w+\s*=/i, // " onmouseover= (attribute breakout)
|
|
172
|
+
/data:\s*text\/html/i,
|
|
173
|
+
];
|
|
174
|
+
return !dangerous.some(pattern => pattern.test(value));
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
/** Validate an ID string (UUIDs, prefixed IDs like c-1, p-123). */
|
|
178
|
+
isValidId(value) {
|
|
179
|
+
if (typeof value !== 'string') return false;
|
|
180
|
+
// Allow UUIDs, prefixed IDs (c-1, p-123), and alphanumeric strings
|
|
181
|
+
return /^[a-zA-Z0-9_-]{1,128}$/.test(value);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/** Validate an email address format. */
|
|
185
|
+
isValidEmail(value) {
|
|
186
|
+
if (typeof value !== 'string') return false;
|
|
187
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && value.length <= 320;
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
/** Validate a URL format. */
|
|
191
|
+
isValidUrl(value) {
|
|
192
|
+
if (typeof value !== 'string') return false;
|
|
193
|
+
try {
|
|
194
|
+
const url = new URL(value);
|
|
195
|
+
return ['http:', 'https:'].includes(url.protocol);
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Validate and sanitize all arguments for a tool call.
|
|
204
|
+
* Rejects obviously malicious input, sanitizes strings.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} toolName — name of the tool being called
|
|
207
|
+
* @param {object} args — raw arguments from the client
|
|
208
|
+
* @returns {{ valid: boolean, args: object, error: string|null }}
|
|
209
|
+
*/
|
|
210
|
+
export function validateToolInput(toolName, args) {
|
|
211
|
+
if (!args || typeof args !== 'object') {
|
|
212
|
+
return { valid: true, args: args || {}, error: null };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const sanitized = {};
|
|
216
|
+
const errors = [];
|
|
217
|
+
|
|
218
|
+
for (const [key, value] of Object.entries(args)) {
|
|
219
|
+
if (typeof value === 'string') {
|
|
220
|
+
// Check for injection attempts
|
|
221
|
+
if (!validators.isSafe(value)) {
|
|
222
|
+
errors.push(`Parameter "${key}" contains potentially unsafe content`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Sanitize string values
|
|
227
|
+
const maxLen = key === 'body' || key === 'message' || key === 'description'
|
|
228
|
+
? 10000
|
|
229
|
+
: key === 'query' || key === 'search'
|
|
230
|
+
? 500
|
|
231
|
+
: 1000;
|
|
232
|
+
sanitized[key] = validators.sanitizeString(value, maxLen);
|
|
233
|
+
|
|
234
|
+
// Validate specific field types
|
|
235
|
+
if (key === 'id' || key.endsWith('Id')) {
|
|
236
|
+
if (!validators.isValidId(value)) {
|
|
237
|
+
errors.push(`Parameter "${key}" is not a valid ID format`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (key === 'email' || key === 'personEmail') {
|
|
243
|
+
if (value && !validators.isValidEmail(value)) {
|
|
244
|
+
errors.push(`Parameter "${key}" is not a valid email address`);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (key === 'website' || key === 'linkedinUrl' || key === 'twitterUrl') {
|
|
250
|
+
if (value && !validators.isValidUrl(value)) {
|
|
251
|
+
errors.push(`Parameter "${key}" is not a valid URL`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
sanitized[key] = value;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (errors.length > 0) {
|
|
261
|
+
return {
|
|
262
|
+
valid: false,
|
|
263
|
+
args: sanitized,
|
|
264
|
+
error: errors.join('; '),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { valid: true, args: sanitized, error: null };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Build an input validation error response for MCP.
|
|
273
|
+
*/
|
|
274
|
+
export function validationErrorResponse(error) {
|
|
275
|
+
return {
|
|
276
|
+
content: [{
|
|
277
|
+
type: 'text',
|
|
278
|
+
text: JSON.stringify({
|
|
279
|
+
error: 'validation_error',
|
|
280
|
+
message: `Input validation failed: ${error}`,
|
|
281
|
+
}, null, 2),
|
|
282
|
+
}],
|
|
283
|
+
isError: true,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// 3. Audit Logging
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* In-memory audit log buffer. Entries are flushed to the Rust API
|
|
293
|
+
* periodically or when the buffer reaches a threshold.
|
|
294
|
+
*/
|
|
295
|
+
class AuditLogger {
|
|
296
|
+
constructor(apiFn) {
|
|
297
|
+
this.buffer = [];
|
|
298
|
+
this.apiFn = apiFn;
|
|
299
|
+
this.flushThreshold = 50;
|
|
300
|
+
this.maxBufferedEntries = 1000;
|
|
301
|
+
this.flushIntervalMs = 30_000;
|
|
302
|
+
this.flushing = false;
|
|
303
|
+
this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs);
|
|
304
|
+
if (this.flushTimer.unref) this.flushTimer.unref();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Log an audit event.
|
|
309
|
+
* @param {{ tool: string, tier: string, action: string, args: object, userId: string|null, sessionKey: string, timestamp: string }} entry
|
|
310
|
+
*/
|
|
311
|
+
log(entry) {
|
|
312
|
+
if (!TELEMETRY_ENABLED) return;
|
|
313
|
+
|
|
314
|
+
this.buffer.push({
|
|
315
|
+
tool: entry.tool,
|
|
316
|
+
action: entry.action || 'tool_call',
|
|
317
|
+
tier: entry.tier,
|
|
318
|
+
userId: entry.userId || null,
|
|
319
|
+
sessionKey: entry.sessionKey,
|
|
320
|
+
timestamp: entry.timestamp || new Date().toISOString(),
|
|
321
|
+
// Redact sensitive fields from args
|
|
322
|
+
params: redactSensitiveFields(entry.args),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (this.buffer.length >= this.flushThreshold) {
|
|
326
|
+
this.flush();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Flush buffered entries to the Rust API.
|
|
332
|
+
* Silently drops entries if the API is unavailable (non-blocking).
|
|
333
|
+
*/
|
|
334
|
+
async flush() {
|
|
335
|
+
if (this.buffer.length === 0 || this.flushing) return;
|
|
336
|
+
this.flushing = true;
|
|
337
|
+
const entries = this.buffer.splice(0, this.buffer.length);
|
|
338
|
+
|
|
339
|
+
if (this.apiFn) {
|
|
340
|
+
try {
|
|
341
|
+
await this.apiFn('POST', '/api/v1/mcp/audit', { body: { entries } });
|
|
342
|
+
} catch {
|
|
343
|
+
// Retain a bounded retry buffer. Never retain arbitrary arguments: log()
|
|
344
|
+
// already redacts and truncates them before they enter this buffer.
|
|
345
|
+
this.buffer = [...entries, ...this.buffer].slice(-this.maxBufferedEntries);
|
|
346
|
+
console.error(`[audit] Failed to flush ${entries.length} audit entries; retained for retry`);
|
|
347
|
+
} finally {
|
|
348
|
+
this.flushing = false;
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
this.flushing = false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
destroy() {
|
|
356
|
+
clearInterval(this.flushTimer);
|
|
357
|
+
this.flush();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Redact fields that may contain secrets or PII from audit logs.
|
|
363
|
+
*/
|
|
364
|
+
function redactSensitiveFields(args) {
|
|
365
|
+
if (!args || typeof args !== 'object') return args;
|
|
366
|
+
const redacted = {};
|
|
367
|
+
// Compared against key.toLowerCase(), so every entry MUST be lowercase.
|
|
368
|
+
// 'apiKey'/'creditCard' were stored camelCase and could never match — an
|
|
369
|
+
// apiKey argument landed unredacted in the audit log.
|
|
370
|
+
const sensitiveKeys = new Set([
|
|
371
|
+
'password', 'token', 'apikey', 'api_key', 'secret', 'authorization',
|
|
372
|
+
'cookie', 'session', 'creditcard', 'credit_card', 'ssn',
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
for (const [key, value] of Object.entries(args)) {
|
|
376
|
+
if (sensitiveKeys.has(key.toLowerCase())) {
|
|
377
|
+
redacted[key] = '[REDACTED]';
|
|
378
|
+
} else if (typeof value === 'string' && value.length > 200) {
|
|
379
|
+
redacted[key] = value.slice(0, 200) + '...[truncated]';
|
|
380
|
+
} else {
|
|
381
|
+
redacted[key] = value;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return redacted;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// 4. Token Security — AES-256-GCM Encryption at Rest
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Encrypt a token for local storage using AES-256-GCM.
|
|
393
|
+
* The encryption key is derived from ADRATA_ENCRYPTION_KEY env var
|
|
394
|
+
* or a machine-specific fallback.
|
|
395
|
+
*
|
|
396
|
+
* @param {string} plaintext — the token to encrypt
|
|
397
|
+
* @returns {string} — base64-encoded encrypted payload (iv:authTag:ciphertext)
|
|
398
|
+
*/
|
|
399
|
+
export function encryptToken(plaintext) {
|
|
400
|
+
const key = getEncryptionKey();
|
|
401
|
+
const iv = crypto.randomBytes(12);
|
|
402
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
403
|
+
|
|
404
|
+
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
|
405
|
+
encrypted += cipher.final('base64');
|
|
406
|
+
const authTag = cipher.getAuthTag();
|
|
407
|
+
|
|
408
|
+
// Format: iv:authTag:ciphertext (all base64)
|
|
409
|
+
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Decrypt a token from local storage.
|
|
414
|
+
*
|
|
415
|
+
* @param {string} encryptedPayload — output from encryptToken()
|
|
416
|
+
* @returns {string} — the original plaintext token
|
|
417
|
+
*/
|
|
418
|
+
export function decryptToken(encryptedPayload) {
|
|
419
|
+
const key = getEncryptionKey();
|
|
420
|
+
const [ivB64, authTagB64, ciphertext] = encryptedPayload.split(':');
|
|
421
|
+
|
|
422
|
+
const iv = Buffer.from(ivB64, 'base64');
|
|
423
|
+
const authTag = Buffer.from(authTagB64, 'base64');
|
|
424
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
425
|
+
decipher.setAuthTag(authTag);
|
|
426
|
+
|
|
427
|
+
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
|
|
428
|
+
decrypted += decipher.final('utf8');
|
|
429
|
+
return decrypted;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Derive a 256-bit encryption key.
|
|
434
|
+
* Uses ADRATA_ENCRYPTION_KEY if set, otherwise derives from hostname + username.
|
|
435
|
+
*/
|
|
436
|
+
function getEncryptionKey() {
|
|
437
|
+
const source = process.env.ADRATA_ENCRYPTION_KEY
|
|
438
|
+
|| `adrata-mcp-${process.env.USER || 'default'}-${os.hostname()}`;
|
|
439
|
+
return crypto.createHash('sha256').update(source).digest();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// 5. CORS Restrictions
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Allowed origins for HTTP/SSE transport in production.
|
|
448
|
+
*/
|
|
449
|
+
const ALLOWED_ORIGINS = new Set([
|
|
450
|
+
'https://app.adrata.com',
|
|
451
|
+
'https://adrata.com',
|
|
452
|
+
'https://www.adrata.com',
|
|
453
|
+
'https://staging.adrata.com',
|
|
454
|
+
]);
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Check if an origin is allowed for CORS.
|
|
458
|
+
* In development mode, all origins are allowed.
|
|
459
|
+
*
|
|
460
|
+
* @param {string} origin — the Origin header value
|
|
461
|
+
* @returns {boolean}
|
|
462
|
+
*/
|
|
463
|
+
export function isOriginAllowed(origin) {
|
|
464
|
+
if (!IS_PRODUCTION) return true;
|
|
465
|
+
if (!origin) return false;
|
|
466
|
+
|
|
467
|
+
// Allow configured origins
|
|
468
|
+
if (ALLOWED_ORIGINS.has(origin)) return true;
|
|
469
|
+
|
|
470
|
+
// Allow custom origins from env
|
|
471
|
+
const customOrigins = process.env.ADRATA_ALLOWED_ORIGINS;
|
|
472
|
+
if (customOrigins) {
|
|
473
|
+
const extras = customOrigins.split(',').map(s => s.trim());
|
|
474
|
+
if (extras.includes(origin)) return true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Get CORS headers for an HTTP response.
|
|
482
|
+
*
|
|
483
|
+
* @param {string} origin — the request Origin header
|
|
484
|
+
* @returns {object} — headers to set on the response
|
|
485
|
+
*/
|
|
486
|
+
export function getCorsHeaders(origin) {
|
|
487
|
+
const headers = {
|
|
488
|
+
'X-Content-Type-Options': 'nosniff',
|
|
489
|
+
'X-Frame-Options': 'DENY',
|
|
490
|
+
'X-XSS-Protection': '1; mode=block',
|
|
491
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
if (IS_PRODUCTION) {
|
|
495
|
+
headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (origin && isOriginAllowed(origin)) {
|
|
499
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
|
500
|
+
headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS';
|
|
501
|
+
headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key';
|
|
502
|
+
headers['Access-Control-Max-Age'] = '86400';
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return headers;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// 6. Transport Security
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Check if the SSE transport is using TLS in production.
|
|
514
|
+
* Returns a warning if HTTP is detected in production mode.
|
|
515
|
+
*
|
|
516
|
+
* @param {string} url — the transport URL
|
|
517
|
+
* @returns {{ secure: boolean, warning: string|null }}
|
|
518
|
+
*/
|
|
519
|
+
export function checkTransportSecurity(url) {
|
|
520
|
+
if (!IS_PRODUCTION) {
|
|
521
|
+
return { secure: true, warning: null };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (url && url.startsWith('http://')) {
|
|
525
|
+
return {
|
|
526
|
+
secure: false,
|
|
527
|
+
warning: 'SSE transport requires TLS (HTTPS) in production. HTTP connections are rejected.',
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return { secure: true, warning: null };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// 7. IP Allowlisting (Enterprise)
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Check if an IP is in the enterprise allowlist.
|
|
540
|
+
* If no allowlist is configured, all IPs are allowed.
|
|
541
|
+
*
|
|
542
|
+
* @param {string} ip — the client IP
|
|
543
|
+
* @returns {boolean}
|
|
544
|
+
*/
|
|
545
|
+
export function isIpAllowed(ip) {
|
|
546
|
+
const allowlist = process.env.ADRATA_IP_ALLOWLIST;
|
|
547
|
+
if (!allowlist) return true;
|
|
548
|
+
|
|
549
|
+
const allowed = allowlist.split(',').map(s => s.trim());
|
|
550
|
+
return allowed.includes(ip);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// 8. Security Middleware — Wraps the tool registry
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Create the security layer that wraps the MCP server's tool registry.
|
|
559
|
+
*
|
|
560
|
+
* @param {object} server — the McpServer instance
|
|
561
|
+
* @param {object} auth — the auth context from authenticate()
|
|
562
|
+
* @param {Function} apiFn — the api() helper for making Rust API calls
|
|
563
|
+
* @returns {{ auditLogger: AuditLogger, rateLimiter: SlidingWindowRateLimiter }}
|
|
564
|
+
*/
|
|
565
|
+
/**
|
|
566
|
+
* Classify a tool call for the audit log.
|
|
567
|
+
*
|
|
568
|
+
* The composite `manage_*` tools carry their real verb in `args.action`, so a
|
|
569
|
+
* name-prefix guess logs every one of them as "execute" — including deletes.
|
|
570
|
+
* Read the action when there is one; fall back to the name otherwise.
|
|
571
|
+
*
|
|
572
|
+
* @param {string} name
|
|
573
|
+
* @param {object} args
|
|
574
|
+
* @returns {'create'|'read'|'update'|'delete'|'execute'}
|
|
575
|
+
*/
|
|
576
|
+
export function auditActionFor(name, args) {
|
|
577
|
+
const action = args && typeof args.action === 'string' ? args.action.toLowerCase() : null;
|
|
578
|
+
if (action) {
|
|
579
|
+
if (action === 'delete' || action.startsWith('remove')) return 'delete';
|
|
580
|
+
if (action === 'create') return 'create';
|
|
581
|
+
if (action === 'update') return 'update';
|
|
582
|
+
if (action === 'get' || action === 'list' || action.startsWith('list_')
|
|
583
|
+
|| action === 'search' || action === 'timeline') return 'read';
|
|
584
|
+
return 'execute';
|
|
585
|
+
}
|
|
586
|
+
if (/^(delete|remove|bulk_delete)/.test(name)) return 'delete';
|
|
587
|
+
if (name.startsWith('create')) return 'create';
|
|
588
|
+
if (name.startsWith('update')) return 'update';
|
|
589
|
+
return 'execute';
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Tools that must be audited even though their annotations say read-only.
|
|
594
|
+
*
|
|
595
|
+
* Mass-egress reads move whole datasets out of the workspace and belong in the
|
|
596
|
+
* audit trail; the extensibility inspection tools were audited under the old
|
|
597
|
+
* hand-kept list and stay audited (their headless-operation policy declares
|
|
598
|
+
* audit events for them).
|
|
599
|
+
*/
|
|
600
|
+
const AUDIT_ALWAYS = new Set([
|
|
601
|
+
'export_data', 'bulk_import',
|
|
602
|
+
'inspect_provider_catalog', 'list_provider_endpoints',
|
|
603
|
+
]);
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Whether a tool call must be written to the audit log.
|
|
607
|
+
*
|
|
608
|
+
* Derived from the tool's real behaviour annotations (anything that is not
|
|
609
|
+
* read-only gets audited), NOT from a hand-kept allowlist. The old `crudTools`
|
|
610
|
+
* set silently excluded the most dangerous tools on the server —
|
|
611
|
+
* adrata_api_request, export_data, bulk_import, enroll_contacts,
|
|
612
|
+
* purchase_domain, paper_send_email, every partner write, and the paper
|
|
613
|
+
* deletes — so none of them left an audit trail.
|
|
614
|
+
*
|
|
615
|
+
* @param {string} name
|
|
616
|
+
* @returns {boolean}
|
|
617
|
+
*/
|
|
618
|
+
export function mustAuditTool(name) {
|
|
619
|
+
if (AUDIT_ALWAYS.has(name)) return true;
|
|
620
|
+
return !buildToolAnnotations(name).readOnlyHint;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function applySecurityLayer(server, authOrProvider, apiFn) {
|
|
624
|
+
const getAuth = typeof authOrProvider === 'function' ? authOrProvider : () => authOrProvider;
|
|
625
|
+
const auditLogger = new AuditLogger(apiFn);
|
|
626
|
+
const localSessionKey = `stdio-${crypto.randomUUID()}`;
|
|
627
|
+
|
|
628
|
+
function securityContext() {
|
|
629
|
+
const auth = getAuth() || { tier: 'free' };
|
|
630
|
+
// Hosted transport supplies an opaque, process-keyed principal. Never use
|
|
631
|
+
// bearer tokens or API keys themselves as rate-limit map keys.
|
|
632
|
+
const sessionKey = auth.securityPrincipal || localSessionKey;
|
|
633
|
+
return { auth, sessionKey };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Wrap server.tool to add security layers around every handler
|
|
637
|
+
const _currentTool = server.tool.bind(server);
|
|
638
|
+
server.tool = function securedTool(name, ...rest) {
|
|
639
|
+
const handler = rest[rest.length - 1];
|
|
640
|
+
rest[rest.length - 1] = async function securedHandler(...handlerArgs) {
|
|
641
|
+
const { auth, sessionKey } = securityContext();
|
|
642
|
+
// --- Rate limiting ---
|
|
643
|
+
const rateCheck = checkRateLimit(sessionKey, auth.tier);
|
|
644
|
+
if (!rateCheck.allowed) {
|
|
645
|
+
return rateLimitResponse(rateCheck.retryAfterMs);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// --- Input validation ---
|
|
649
|
+
// handlerArgs[0] is the tool arguments object
|
|
650
|
+
const rawArgs = handlerArgs[0] || {};
|
|
651
|
+
const validation = validateToolInput(name, rawArgs);
|
|
652
|
+
if (!validation.valid) {
|
|
653
|
+
return validationErrorResponse(validation.error);
|
|
654
|
+
}
|
|
655
|
+
// Replace args with sanitized version
|
|
656
|
+
handlerArgs[0] = validation.args;
|
|
657
|
+
|
|
658
|
+
// --- Execute the tool ---
|
|
659
|
+
const result = await handler(...handlerArgs);
|
|
660
|
+
|
|
661
|
+
// --- Audit logging (enterprise tier; every non-read tool + mass egress) ---
|
|
662
|
+
if (auth.tier === 'enterprise' && mustAuditTool(name)) {
|
|
663
|
+
auditLogger.log({
|
|
664
|
+
tool: name,
|
|
665
|
+
action: auditActionFor(name, rawArgs),
|
|
666
|
+
tier: auth.tier,
|
|
667
|
+
userId: auth.userId || null,
|
|
668
|
+
sessionKey,
|
|
669
|
+
args: rawArgs,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return result;
|
|
674
|
+
};
|
|
675
|
+
return _currentTool(name, ...rest);
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
return { auditLogger, rateLimiter };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// Exports for testing
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
export {
|
|
686
|
+
SlidingWindowRateLimiter,
|
|
687
|
+
AuditLogger,
|
|
688
|
+
redactSensitiveFields,
|
|
689
|
+
TIER_RATE_LIMITS,
|
|
690
|
+
};
|