@aiclude/mcp-guard 0.2.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/LICENSE.md +64 -0
- package/README.md +209 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1844 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1844 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/policy.ts
|
|
7
|
+
import { readFileSync } from "fs";
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
9
|
+
var VALID_RULE_TYPES = /* @__PURE__ */ new Set(["tool-poisoning", "argument-injection", "data-exfiltration"]);
|
|
10
|
+
var VALID_ACTIONS = /* @__PURE__ */ new Set(["block", "warn"]);
|
|
11
|
+
var VALID_SEVERITIES = /* @__PURE__ */ new Set(["critical", "high", "medium", "low", "info"]);
|
|
12
|
+
var VALID_LOG_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "warn", "error"]);
|
|
13
|
+
function getDefaultPolicy() {
|
|
14
|
+
return {
|
|
15
|
+
version: 1,
|
|
16
|
+
failMode: "closed",
|
|
17
|
+
logging: { level: "info", destination: "stderr" },
|
|
18
|
+
rules: [
|
|
19
|
+
{ id: "tool-poisoning", enabled: true, severity: "critical", action: "block", type: "tool-poisoning" },
|
|
20
|
+
{ id: "argument-injection", enabled: true, severity: "critical", action: "block", type: "argument-injection" },
|
|
21
|
+
{ id: "data-exfiltration", enabled: true, severity: "high", action: "warn", type: "data-exfiltration" }
|
|
22
|
+
]
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function loadPolicy(configPath) {
|
|
26
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
27
|
+
const parsed = parseYaml(raw);
|
|
28
|
+
const sanitized = sanitizeObject(parsed);
|
|
29
|
+
return validatePolicy(sanitized);
|
|
30
|
+
}
|
|
31
|
+
function validatePolicy(raw) {
|
|
32
|
+
if (!raw || typeof raw !== "object") {
|
|
33
|
+
throw new Error("Policy must be a YAML object");
|
|
34
|
+
}
|
|
35
|
+
const obj = raw;
|
|
36
|
+
if (obj["version"] !== 1) {
|
|
37
|
+
throw new Error(`Unsupported policy version: ${String(obj["version"])}. Expected 1`);
|
|
38
|
+
}
|
|
39
|
+
const failMode = obj["failMode"] ?? "closed";
|
|
40
|
+
if (failMode !== "closed" && failMode !== "open") {
|
|
41
|
+
throw new Error(`Invalid failMode: "${String(failMode)}". Must be "closed" or "open"`);
|
|
42
|
+
}
|
|
43
|
+
const logging = obj["logging"];
|
|
44
|
+
const logLevel = logging?.["level"] ?? "info";
|
|
45
|
+
if (!VALID_LOG_LEVELS.has(logLevel)) {
|
|
46
|
+
throw new Error(`Invalid log level: "${logLevel}"`);
|
|
47
|
+
}
|
|
48
|
+
const rawRules = obj["rules"];
|
|
49
|
+
if (!Array.isArray(rawRules)) {
|
|
50
|
+
throw new Error("Policy must contain a 'rules' array");
|
|
51
|
+
}
|
|
52
|
+
const rules = rawRules.map((r, i) => {
|
|
53
|
+
if (!r || typeof r !== "object") {
|
|
54
|
+
throw new Error(`Rule at index ${i} must be an object`);
|
|
55
|
+
}
|
|
56
|
+
const rule = r;
|
|
57
|
+
const id = rule["id"];
|
|
58
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
59
|
+
throw new Error(`Rule at index ${i} must have a string 'id'`);
|
|
60
|
+
}
|
|
61
|
+
const type = rule["type"];
|
|
62
|
+
if (!VALID_RULE_TYPES.has(type)) {
|
|
63
|
+
throw new Error(`Rule "${id}": unknown type "${type}". Valid: ${[...VALID_RULE_TYPES].join(", ")}`);
|
|
64
|
+
}
|
|
65
|
+
const action = rule["action"] ?? "block";
|
|
66
|
+
if (!VALID_ACTIONS.has(action)) {
|
|
67
|
+
throw new Error(`Rule "${id}": invalid action "${action}". Valid: ${[...VALID_ACTIONS].join(", ")}`);
|
|
68
|
+
}
|
|
69
|
+
const severity = rule["severity"] ?? "high";
|
|
70
|
+
if (!VALID_SEVERITIES.has(severity)) {
|
|
71
|
+
throw new Error(`Rule "${id}": invalid severity "${severity}"`);
|
|
72
|
+
}
|
|
73
|
+
const enabled = rule["enabled"] !== false;
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
enabled,
|
|
77
|
+
severity,
|
|
78
|
+
action,
|
|
79
|
+
type,
|
|
80
|
+
config: rule["config"] ?? void 0
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
version: 1,
|
|
85
|
+
failMode,
|
|
86
|
+
logging: { level: logLevel, destination: "stderr" },
|
|
87
|
+
rules
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function sanitizeObject(obj) {
|
|
91
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
92
|
+
if (Array.isArray(obj)) return obj.map(sanitizeObject);
|
|
93
|
+
const clean = {};
|
|
94
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
95
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
|
96
|
+
clean[key] = sanitizeObject(value);
|
|
97
|
+
}
|
|
98
|
+
return clean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/types.ts
|
|
102
|
+
function isRequest(msg) {
|
|
103
|
+
return "method" in msg && "id" in msg && !("result" in msg) && !("error" in msg);
|
|
104
|
+
}
|
|
105
|
+
function isResponse(msg) {
|
|
106
|
+
return !("method" in msg) && "id" in msg;
|
|
107
|
+
}
|
|
108
|
+
function isMalformed(msg) {
|
|
109
|
+
const hasMethod = "method" in msg;
|
|
110
|
+
const hasResult = "result" in msg;
|
|
111
|
+
const hasError = "error" in msg;
|
|
112
|
+
return hasMethod && (hasResult || hasError);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/detectors/normalize.ts
|
|
116
|
+
var CONFUSABLE_MAP = {
|
|
117
|
+
// Cyrillic → Latin
|
|
118
|
+
"\u0430": "a",
|
|
119
|
+
"\u0435": "e",
|
|
120
|
+
"\u043E": "o",
|
|
121
|
+
"\u0440": "p",
|
|
122
|
+
"\u0441": "c",
|
|
123
|
+
"\u0443": "y",
|
|
124
|
+
"\u0445": "x",
|
|
125
|
+
"\u0456": "i",
|
|
126
|
+
"\u0458": "j",
|
|
127
|
+
"\u04BB": "h",
|
|
128
|
+
"\u0455": "s",
|
|
129
|
+
"\u0491": "g",
|
|
130
|
+
"\u0454": "e",
|
|
131
|
+
"\u0442": "t",
|
|
132
|
+
"\u043C": "m",
|
|
133
|
+
"\u043D": "n",
|
|
134
|
+
"\u0432": "b",
|
|
135
|
+
"\u043A": "k",
|
|
136
|
+
"\u0434": "d",
|
|
137
|
+
"\u0437": "3",
|
|
138
|
+
// Greek → Latin
|
|
139
|
+
"\u03B1": "a",
|
|
140
|
+
"\u03B5": "e",
|
|
141
|
+
"\u03BF": "o",
|
|
142
|
+
"\u03C1": "p",
|
|
143
|
+
"\u03B9": "i",
|
|
144
|
+
"\u03BA": "k",
|
|
145
|
+
"\u03BD": "v",
|
|
146
|
+
"\u03C4": "t",
|
|
147
|
+
// Fullwidth → ASCII
|
|
148
|
+
"\uFF21": "A",
|
|
149
|
+
"\uFF22": "B",
|
|
150
|
+
"\uFF23": "C",
|
|
151
|
+
"\uFF24": "D",
|
|
152
|
+
"\uFF25": "E",
|
|
153
|
+
"\uFF26": "F",
|
|
154
|
+
"\uFF27": "G",
|
|
155
|
+
"\uFF28": "H",
|
|
156
|
+
"\uFF29": "I",
|
|
157
|
+
"\uFF2A": "J",
|
|
158
|
+
"\uFF2B": "K",
|
|
159
|
+
"\uFF2C": "L",
|
|
160
|
+
"\uFF2D": "M",
|
|
161
|
+
"\uFF2E": "N",
|
|
162
|
+
"\uFF2F": "O",
|
|
163
|
+
"\uFF30": "P",
|
|
164
|
+
"\uFF31": "Q",
|
|
165
|
+
"\uFF32": "R",
|
|
166
|
+
"\uFF33": "S",
|
|
167
|
+
"\uFF34": "T",
|
|
168
|
+
"\uFF35": "U",
|
|
169
|
+
"\uFF36": "V",
|
|
170
|
+
"\uFF37": "W",
|
|
171
|
+
"\uFF38": "X",
|
|
172
|
+
"\uFF39": "Y",
|
|
173
|
+
"\uFF3A": "Z",
|
|
174
|
+
"\uFF41": "a",
|
|
175
|
+
"\uFF42": "b",
|
|
176
|
+
"\uFF43": "c",
|
|
177
|
+
"\uFF44": "d",
|
|
178
|
+
"\uFF45": "e",
|
|
179
|
+
"\uFF46": "f",
|
|
180
|
+
"\uFF47": "g",
|
|
181
|
+
"\uFF48": "h",
|
|
182
|
+
"\uFF49": "i",
|
|
183
|
+
"\uFF4A": "j",
|
|
184
|
+
"\uFF4B": "k",
|
|
185
|
+
"\uFF4C": "l",
|
|
186
|
+
"\uFF4D": "m",
|
|
187
|
+
"\uFF4E": "n",
|
|
188
|
+
"\uFF4F": "o",
|
|
189
|
+
"\uFF50": "p",
|
|
190
|
+
"\uFF51": "q",
|
|
191
|
+
"\uFF52": "r",
|
|
192
|
+
"\uFF53": "s",
|
|
193
|
+
"\uFF54": "t",
|
|
194
|
+
"\uFF55": "u",
|
|
195
|
+
"\uFF56": "v",
|
|
196
|
+
"\uFF57": "w",
|
|
197
|
+
"\uFF58": "x",
|
|
198
|
+
"\uFF59": "y",
|
|
199
|
+
"\uFF5A": "z",
|
|
200
|
+
// Common special chars
|
|
201
|
+
"\uFF10": "0",
|
|
202
|
+
"\uFF11": "1",
|
|
203
|
+
"\uFF12": "2",
|
|
204
|
+
"\uFF13": "3",
|
|
205
|
+
"\uFF14": "4",
|
|
206
|
+
"\uFF15": "5",
|
|
207
|
+
"\uFF16": "6",
|
|
208
|
+
"\uFF17": "7",
|
|
209
|
+
"\uFF18": "8",
|
|
210
|
+
"\uFF19": "9",
|
|
211
|
+
"\u2018": "'",
|
|
212
|
+
"\u2019": "'",
|
|
213
|
+
"\u201C": '"',
|
|
214
|
+
"\u201D": '"',
|
|
215
|
+
"\uFF07": "'",
|
|
216
|
+
"\uFF02": '"'
|
|
217
|
+
};
|
|
218
|
+
var INVISIBLE_CHARS_REGEX = new RegExp(
|
|
219
|
+
"[\\u200B\\u200C\\u200D\\uFEFF\\u00AD\\u2060\\u180E\\u2061\\u2062\\u2063\\u2064\\u034F\\u17B4\\u17B5\\u115F\\u1160\\u3164\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFE00-\\uFE0F\\uFFF9-\\uFFFB\\u2028\\u2029]",
|
|
220
|
+
"g"
|
|
221
|
+
);
|
|
222
|
+
var TAG_CHARS_REGEX = /[\u{E0001}-\u{E007F}]/gu;
|
|
223
|
+
function normalizeForDetection(text) {
|
|
224
|
+
let result = text.normalize("NFKD");
|
|
225
|
+
result = result.replace(INVISIBLE_CHARS_REGEX, "");
|
|
226
|
+
result = result.replace(TAG_CHARS_REGEX, "");
|
|
227
|
+
result = result.replace(/[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED]/g, "");
|
|
228
|
+
let mapped = "";
|
|
229
|
+
for (const char of result) {
|
|
230
|
+
mapped += CONFUSABLE_MAP[char] ?? char;
|
|
231
|
+
}
|
|
232
|
+
return mapped;
|
|
233
|
+
}
|
|
234
|
+
function normalizeForSql(text) {
|
|
235
|
+
let result = normalizeForDetection(text);
|
|
236
|
+
result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
237
|
+
result = result.replace(/--\s.*$/gm, "");
|
|
238
|
+
result = result.replace(/#\s.*$/gm, "");
|
|
239
|
+
result = result.replace(/\s+/g, " ");
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
function countInvisibleChars(text) {
|
|
243
|
+
const matches1 = text.match(INVISIBLE_CHARS_REGEX);
|
|
244
|
+
const matches2 = text.match(TAG_CHARS_REGEX);
|
|
245
|
+
return (matches1?.length ?? 0) + (matches2?.length ?? 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/detectors/zero-width.ts
|
|
249
|
+
function containsZeroWidth(text) {
|
|
250
|
+
const count = countInvisibleChars(text);
|
|
251
|
+
return { found: count > 0, count };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/detectors/injection-phrases.ts
|
|
255
|
+
function decodeInjectionPhrases() {
|
|
256
|
+
const encoded = [
|
|
257
|
+
[105, 103, 110, 111, 114, 101, 32, 112, 114, 101, 118, 105, 111, 117, 115, 32, 105, 110, 115, 116, 114, 117, 99, 116, 105, 111, 110, 115],
|
|
258
|
+
[105, 103, 110, 111, 114, 101, 32, 97, 108, 108, 32, 112, 114, 101, 118, 105, 111, 117, 115],
|
|
259
|
+
[102, 111, 114, 103, 101, 116, 32, 121, 111, 117, 114, 32, 105, 110, 115, 116, 114, 117, 99, 116, 105, 111, 110, 115],
|
|
260
|
+
[121, 111, 117, 32, 97, 114, 101, 32, 110, 111, 119],
|
|
261
|
+
[110, 101, 119, 32, 105, 110, 115, 116, 114, 117, 99, 116, 105, 111, 110, 115],
|
|
262
|
+
[111, 118, 101, 114, 114, 105, 100, 101, 32, 121, 111, 117, 114],
|
|
263
|
+
[100, 105, 115, 114, 101, 103, 97, 114, 100, 32, 97, 108, 108],
|
|
264
|
+
[115, 121, 115, 116, 101, 109, 32, 112, 114, 111, 109, 112, 116],
|
|
265
|
+
[73, 77, 80, 79, 82, 84, 65, 78, 84, 58, 32],
|
|
266
|
+
[65, 68, 77, 73, 78, 58],
|
|
267
|
+
[83, 89, 83, 84, 69, 77, 58],
|
|
268
|
+
[100, 111, 32, 110, 111, 116, 32, 116, 101, 108, 108, 32, 116, 104, 101, 32, 117, 115, 101, 114],
|
|
269
|
+
[100, 111, 32, 110, 111, 116, 32, 109, 101, 110, 116, 105, 111, 110],
|
|
270
|
+
[104, 105, 100, 100, 101, 110, 32, 105, 110, 115, 116, 114, 117, 99, 116, 105, 111, 110],
|
|
271
|
+
[115, 101, 99, 114, 101, 116, 108, 121],
|
|
272
|
+
[119, 105, 116, 104, 111, 117, 116, 32, 116, 101, 108, 108, 105, 110, 103]
|
|
273
|
+
];
|
|
274
|
+
return encoded.map((codes) => String.fromCharCode(...codes));
|
|
275
|
+
}
|
|
276
|
+
var _cache = null;
|
|
277
|
+
function getInjectionPhrases() {
|
|
278
|
+
if (!_cache) {
|
|
279
|
+
_cache = decodeInjectionPhrases();
|
|
280
|
+
}
|
|
281
|
+
return _cache;
|
|
282
|
+
}
|
|
283
|
+
var INSTRUCTION_PATTERNS = [
|
|
284
|
+
/\b(?:you\s+must|always|never|do\s+not)\b.*\b(?:user|output|response|answer)\b/i,
|
|
285
|
+
/\b(?:respond|reply|answer)\s+(?:only|exclusively)\s+(?:with|in)\b/i
|
|
286
|
+
];
|
|
287
|
+
function detectInjectionPhrase(text) {
|
|
288
|
+
const normalized = normalizeForDetection(text).toLowerCase();
|
|
289
|
+
for (const phrase of getInjectionPhrases()) {
|
|
290
|
+
if (normalized.includes(phrase)) {
|
|
291
|
+
return phrase;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
function detectInstructionPattern(text) {
|
|
297
|
+
const normalized = normalizeForDetection(text);
|
|
298
|
+
return INSTRUCTION_PATTERNS.some((p) => p.test(normalized));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/detectors/shadow-names.ts
|
|
302
|
+
var SHADOW_TARGET_NAMES = [
|
|
303
|
+
"read_file",
|
|
304
|
+
"write_file",
|
|
305
|
+
"execute_command",
|
|
306
|
+
"run_terminal",
|
|
307
|
+
"bash",
|
|
308
|
+
"search",
|
|
309
|
+
"edit_file",
|
|
310
|
+
"list_directory",
|
|
311
|
+
"create_file",
|
|
312
|
+
"delete_file",
|
|
313
|
+
"read_resource",
|
|
314
|
+
"call_tool",
|
|
315
|
+
"search_files",
|
|
316
|
+
"run_command"
|
|
317
|
+
];
|
|
318
|
+
var GENERIC_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
319
|
+
"search",
|
|
320
|
+
"read_file",
|
|
321
|
+
"write_file",
|
|
322
|
+
"edit_file",
|
|
323
|
+
"list_directory",
|
|
324
|
+
"create_file",
|
|
325
|
+
"delete_file",
|
|
326
|
+
"read_resource",
|
|
327
|
+
"search_files"
|
|
328
|
+
]);
|
|
329
|
+
function checkShadowing(toolName) {
|
|
330
|
+
const normalized = normalizeForDetection(toolName).toLowerCase().replace(/[-\s]/g, "_");
|
|
331
|
+
for (const target of SHADOW_TARGET_NAMES) {
|
|
332
|
+
if (normalized === target) {
|
|
333
|
+
return {
|
|
334
|
+
isShadow: true,
|
|
335
|
+
target,
|
|
336
|
+
isGeneric: GENERIC_TOOL_NAMES.has(target)
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (normalized.endsWith(`_${target}`) || normalized.endsWith(`__${target}`)) {
|
|
340
|
+
return {
|
|
341
|
+
isShadow: true,
|
|
342
|
+
target,
|
|
343
|
+
isGeneric: GENERIC_TOOL_NAMES.has(target)
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/detectors/homoglyph.ts
|
|
351
|
+
var HOMOGLYPH_MAP = {
|
|
352
|
+
// Cyrillic → Latin
|
|
353
|
+
"\u0430": "a",
|
|
354
|
+
// а → a
|
|
355
|
+
"\u0435": "e",
|
|
356
|
+
// е → e
|
|
357
|
+
"\u043E": "o",
|
|
358
|
+
// о → o
|
|
359
|
+
"\u0441": "c",
|
|
360
|
+
// с → c
|
|
361
|
+
"\u0440": "p",
|
|
362
|
+
// р → p
|
|
363
|
+
"\u0443": "y",
|
|
364
|
+
// у → y
|
|
365
|
+
"\u0445": "x",
|
|
366
|
+
// х → x
|
|
367
|
+
"\u043A": "k",
|
|
368
|
+
// к → k
|
|
369
|
+
"\u043C": "m",
|
|
370
|
+
// м → m (lowercase)
|
|
371
|
+
"\u0456": "i",
|
|
372
|
+
// і → i (Ukrainian i)
|
|
373
|
+
"\u0458": "j",
|
|
374
|
+
// ј → j (Serbian je)
|
|
375
|
+
"\u04BB": "h",
|
|
376
|
+
// һ → h
|
|
377
|
+
"\u0455": "s",
|
|
378
|
+
// ѕ → s
|
|
379
|
+
"\u0471": "\u03C8",
|
|
380
|
+
// ѱ (rare)
|
|
381
|
+
"\u0410": "A",
|
|
382
|
+
// А → A
|
|
383
|
+
"\u0412": "B",
|
|
384
|
+
// В → B
|
|
385
|
+
"\u0415": "E",
|
|
386
|
+
// Е → E
|
|
387
|
+
"\u041A": "K",
|
|
388
|
+
// К → K
|
|
389
|
+
"\u041C": "M",
|
|
390
|
+
// М → M
|
|
391
|
+
"\u041D": "H",
|
|
392
|
+
// Н → H
|
|
393
|
+
"\u041E": "O",
|
|
394
|
+
// О → O
|
|
395
|
+
"\u0420": "P",
|
|
396
|
+
// Р → P
|
|
397
|
+
"\u0421": "C",
|
|
398
|
+
// С → C
|
|
399
|
+
"\u0422": "T",
|
|
400
|
+
// Т → T
|
|
401
|
+
"\u0425": "X",
|
|
402
|
+
// Х → X
|
|
403
|
+
"\u04AE": "Y",
|
|
404
|
+
// Ү → Y
|
|
405
|
+
// Greek → Latin
|
|
406
|
+
"\u03B1": "a",
|
|
407
|
+
// α → a
|
|
408
|
+
"\u03BF": "o",
|
|
409
|
+
// ο → o
|
|
410
|
+
"\u03B5": "e",
|
|
411
|
+
// ε → e (visually similar in some fonts)
|
|
412
|
+
"\u03BA": "k",
|
|
413
|
+
// κ → k (visually similar in some fonts)
|
|
414
|
+
"\u03BD": "v",
|
|
415
|
+
// ν → v
|
|
416
|
+
"\u03C1": "p",
|
|
417
|
+
// ρ → p
|
|
418
|
+
"\u03C4": "t",
|
|
419
|
+
// τ → t (visually similar in some fonts)
|
|
420
|
+
"\u0391": "A",
|
|
421
|
+
// Α → A
|
|
422
|
+
"\u0392": "B",
|
|
423
|
+
// Β → B
|
|
424
|
+
"\u0395": "E",
|
|
425
|
+
// Ε → E
|
|
426
|
+
"\u0396": "Z",
|
|
427
|
+
// Ζ → Z
|
|
428
|
+
"\u0397": "H",
|
|
429
|
+
// Η → H
|
|
430
|
+
"\u0399": "I",
|
|
431
|
+
// Ι → I
|
|
432
|
+
"\u039A": "K",
|
|
433
|
+
// Κ → K
|
|
434
|
+
"\u039C": "M",
|
|
435
|
+
// Μ → M
|
|
436
|
+
"\u039D": "N",
|
|
437
|
+
// Ν → N
|
|
438
|
+
"\u039F": "O",
|
|
439
|
+
// Ο → O
|
|
440
|
+
"\u03A1": "P",
|
|
441
|
+
// Ρ → P
|
|
442
|
+
"\u03A4": "T",
|
|
443
|
+
// Τ → T
|
|
444
|
+
"\u03A5": "Y",
|
|
445
|
+
// Υ → Y
|
|
446
|
+
"\u03A7": "X"
|
|
447
|
+
// Χ → X
|
|
448
|
+
};
|
|
449
|
+
var HOMOGLYPH_REGEX = new RegExp(`[${Object.keys(HOMOGLYPH_MAP).join("")}]`, "g");
|
|
450
|
+
function normalizeHomoglyphs(text) {
|
|
451
|
+
return text.replace(HOMOGLYPH_REGEX, (ch) => HOMOGLYPH_MAP[ch] ?? ch);
|
|
452
|
+
}
|
|
453
|
+
function containsHomoglyphs(text) {
|
|
454
|
+
const matches = text.match(HOMOGLYPH_REGEX);
|
|
455
|
+
const count = matches ? matches.length : 0;
|
|
456
|
+
return {
|
|
457
|
+
found: count > 0,
|
|
458
|
+
count,
|
|
459
|
+
normalized: count > 0 ? normalizeHomoglyphs(text) : text
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// src/detectors/multilingual-phrases.ts
|
|
464
|
+
var KOREAN_PHRASES = [
|
|
465
|
+
"\uC774\uC804 \uC9C0\uC2DC\uB97C \uBB34\uC2DC",
|
|
466
|
+
"\uC2DC\uC2A4\uD15C \uD504\uB86C\uD504\uD2B8",
|
|
467
|
+
"\uC0C8\uB85C\uC6B4 \uC9C0\uC2DC",
|
|
468
|
+
"\uBAA8\uB4E0 \uADDC\uCE59 \uBB34\uC2DC",
|
|
469
|
+
"\uC9C0\uAE08\uBD80\uD130 \uB108\uB294",
|
|
470
|
+
"\uAD00\uB9AC\uC790 \uBAA8\uB4DC"
|
|
471
|
+
];
|
|
472
|
+
var CHINESE_PHRASES = [
|
|
473
|
+
"\u5FFD\u7565\u4E4B\u524D\u7684\u6307\u4EE4",
|
|
474
|
+
"\u5FFD\u7565\u6240\u6709\u6307\u4EE4",
|
|
475
|
+
"\u7CFB\u7EDF\u63D0\u793A",
|
|
476
|
+
"\u65B0\u6307\u4EE4",
|
|
477
|
+
"\u7BA1\u7406\u5458\u6A21\u5F0F",
|
|
478
|
+
"\u4F60\u73B0\u5728\u662F"
|
|
479
|
+
];
|
|
480
|
+
var JAPANESE_PHRASES = [
|
|
481
|
+
"\u524D\u306E\u6307\u793A\u3092\u7121\u8996",
|
|
482
|
+
"\u3059\u3079\u3066\u306E\u6307\u793A\u3092\u7121\u8996",
|
|
483
|
+
"\u30B7\u30B9\u30C6\u30E0\u30D7\u30ED\u30F3\u30D7\u30C8",
|
|
484
|
+
"\u65B0\u3057\u3044\u6307\u793A",
|
|
485
|
+
"\u7BA1\u7406\u8005\u30E2\u30FC\u30C9"
|
|
486
|
+
];
|
|
487
|
+
var ALL_PHRASES = [...KOREAN_PHRASES, ...CHINESE_PHRASES, ...JAPANESE_PHRASES];
|
|
488
|
+
function detectMultilingualInjection(text) {
|
|
489
|
+
for (const phrase of ALL_PHRASES) {
|
|
490
|
+
if (text.includes(phrase)) {
|
|
491
|
+
return phrase;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// src/rules/tool-poisoning.ts
|
|
498
|
+
var ToolPoisoningRule = class {
|
|
499
|
+
id = "tool-poisoning";
|
|
500
|
+
type = "tool-poisoning";
|
|
501
|
+
config;
|
|
502
|
+
constructor(config) {
|
|
503
|
+
this.config = config ?? {};
|
|
504
|
+
}
|
|
505
|
+
evaluate(message, direction, context) {
|
|
506
|
+
if (direction !== "server-to-client") return null;
|
|
507
|
+
if (!isResponse(message)) return null;
|
|
508
|
+
const originalReq = context.requestMap.get(message.id);
|
|
509
|
+
if (!originalReq || originalReq.method !== "tools/list") return null;
|
|
510
|
+
const result = message.result;
|
|
511
|
+
if (!result?.tools || !Array.isArray(result.tools)) return null;
|
|
512
|
+
const action = this.getAction(context);
|
|
513
|
+
for (const tool of result.tools) {
|
|
514
|
+
const finding = this.checkTool(tool);
|
|
515
|
+
if (finding) {
|
|
516
|
+
return {
|
|
517
|
+
action,
|
|
518
|
+
ruleId: this.id,
|
|
519
|
+
severity: finding.severity,
|
|
520
|
+
reason: `Tool "${tool.name}": ${finding.reason}`
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
checkTool(tool) {
|
|
527
|
+
const desc = tool.description ?? "";
|
|
528
|
+
if (this.cfg("checkZeroWidth", true)) {
|
|
529
|
+
const zw = containsZeroWidth(desc);
|
|
530
|
+
if (zw.found) {
|
|
531
|
+
return { severity: "critical", reason: `Hidden zero-width characters detected (${zw.count} found)` };
|
|
532
|
+
}
|
|
533
|
+
const zwName = containsZeroWidth(tool.name);
|
|
534
|
+
if (zwName.found) {
|
|
535
|
+
return { severity: "critical", reason: `Hidden zero-width characters in tool name` };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (this.cfg("checkHomoglyphs", true)) {
|
|
539
|
+
const hgName = containsHomoglyphs(tool.name);
|
|
540
|
+
if (hgName.found) {
|
|
541
|
+
return { severity: "high", reason: `Homoglyph characters in tool name (${hgName.count} found, normalized: "${hgName.normalized}")` };
|
|
542
|
+
}
|
|
543
|
+
const hgDesc = containsHomoglyphs(desc);
|
|
544
|
+
if (hgDesc.found && hgDesc.count >= 3) {
|
|
545
|
+
return { severity: "medium", reason: `Homoglyph characters in description (${hgDesc.count} found)` };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (this.cfg("checkInjectionPhrases", true)) {
|
|
549
|
+
const phrase = detectInjectionPhrase(desc);
|
|
550
|
+
if (phrase) {
|
|
551
|
+
return { severity: "critical", reason: `Prompt injection phrase in description: "${phrase}"` };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (this.cfg("checkMultilingualInjection", true)) {
|
|
555
|
+
const mlPhrase = detectMultilingualInjection(desc);
|
|
556
|
+
if (mlPhrase) {
|
|
557
|
+
return { severity: "critical", reason: `Multilingual prompt injection phrase in description: "${mlPhrase}"` };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (this.cfg("checkHtmlComments", true)) {
|
|
561
|
+
const htmlComment = /<!--[\s\S]*?-->/;
|
|
562
|
+
if (htmlComment.test(desc)) {
|
|
563
|
+
return { severity: "high", reason: `HTML comment hiding content in description` };
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (this.cfg("checkBase64", true)) {
|
|
567
|
+
const base64 = /[A-Za-z0-9+/]{40,}={0,2}/;
|
|
568
|
+
if (base64.test(desc) && desc.length > 200) {
|
|
569
|
+
return { severity: "high", reason: `Suspicious base64-encoded content in description` };
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (this.cfg("checkShadowing", true)) {
|
|
573
|
+
const shadow = checkShadowing(tool.name);
|
|
574
|
+
if (shadow && !shadow.isGeneric) {
|
|
575
|
+
return { severity: "high", reason: `Tool name shadows dangerous system tool: "${shadow.target}"` };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (this.cfg("checkInstructionPatterns", true)) {
|
|
579
|
+
if (detectInstructionPattern(desc)) {
|
|
580
|
+
return { severity: "medium", reason: `Instruction-like manipulation pattern in description` };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const maxLen = this.cfg("maxDescriptionLength", 5e3);
|
|
584
|
+
if (maxLen > 0 && desc.length > maxLen) {
|
|
585
|
+
return { severity: "medium", reason: `Description exceeds ${maxLen} chars (${desc.length})` };
|
|
586
|
+
}
|
|
587
|
+
if (this.cfg("checkInputSchema", true) && tool.inputSchema) {
|
|
588
|
+
const schemaFinding = this.checkInputSchema(tool.inputSchema);
|
|
589
|
+
if (schemaFinding) {
|
|
590
|
+
return schemaFinding;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Recursively inspect inputSchema for hidden injection in field descriptions and default values.
|
|
597
|
+
*/
|
|
598
|
+
checkInputSchema(schema) {
|
|
599
|
+
const strings = this.extractSchemaStrings(schema);
|
|
600
|
+
for (const { path, value } of strings) {
|
|
601
|
+
const phrase = detectInjectionPhrase(value);
|
|
602
|
+
if (phrase) {
|
|
603
|
+
return { severity: "critical", reason: `Prompt injection in inputSchema ${path}: "${phrase}"` };
|
|
604
|
+
}
|
|
605
|
+
const mlPhrase = detectMultilingualInjection(value);
|
|
606
|
+
if (mlPhrase) {
|
|
607
|
+
return { severity: "critical", reason: `Multilingual injection in inputSchema ${path}: "${mlPhrase}"` };
|
|
608
|
+
}
|
|
609
|
+
const zw = containsZeroWidth(value);
|
|
610
|
+
if (zw.found) {
|
|
611
|
+
return { severity: "high", reason: `Hidden zero-width characters in inputSchema ${path}` };
|
|
612
|
+
}
|
|
613
|
+
if (detectInstructionPattern(value)) {
|
|
614
|
+
return { severity: "medium", reason: `Instruction-like pattern in inputSchema ${path}` };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Extract all string values from a JSON schema, tracking their path (e.g., "properties.query.description").
|
|
621
|
+
* Focuses on "description" and "default" fields which are common injection vectors.
|
|
622
|
+
*/
|
|
623
|
+
extractSchemaStrings(obj, path = "") {
|
|
624
|
+
const results = [];
|
|
625
|
+
if (obj === null || typeof obj !== "object") return results;
|
|
626
|
+
const record = obj;
|
|
627
|
+
for (const [key, val] of Object.entries(record)) {
|
|
628
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
629
|
+
if (typeof val === "string" && (key === "description" || key === "default" || key === "title" || key === "enum")) {
|
|
630
|
+
results.push({ path: currentPath, value: val });
|
|
631
|
+
} else if (typeof val === "string" && key === "const") {
|
|
632
|
+
results.push({ path: currentPath, value: val });
|
|
633
|
+
} else if (Array.isArray(val)) {
|
|
634
|
+
for (let i = 0; i < val.length; i++) {
|
|
635
|
+
if (typeof val[i] === "string") {
|
|
636
|
+
results.push({ path: `${currentPath}[${i}]`, value: val[i] });
|
|
637
|
+
} else {
|
|
638
|
+
results.push(...this.extractSchemaStrings(val[i], `${currentPath}[${i}]`));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
} else if (typeof val === "object" && val !== null) {
|
|
642
|
+
results.push(...this.extractSchemaStrings(val, currentPath));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return results;
|
|
646
|
+
}
|
|
647
|
+
cfg(key, defaultValue) {
|
|
648
|
+
return this.config[key] ?? defaultValue;
|
|
649
|
+
}
|
|
650
|
+
getAction(context) {
|
|
651
|
+
const rule = context.policy.rules.find((r) => r.id === this.id);
|
|
652
|
+
return rule?.action ?? "block";
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// src/detectors/fuzz-patterns.ts
|
|
657
|
+
var INJECTION_PATTERNS = [
|
|
658
|
+
// SQL Injection
|
|
659
|
+
{ category: "SQL Injection", pattern: /'\s*OR\s+\d+=\d+/i, severity: "critical", cwe: "CWE-89" },
|
|
660
|
+
{ category: "SQL Injection", pattern: /;\s*DROP\s+TABLE\b/i, severity: "critical", cwe: "CWE-89" },
|
|
661
|
+
{ category: "SQL Injection", pattern: /UNION\s+SELECT\b/i, severity: "critical", cwe: "CWE-89" },
|
|
662
|
+
{ category: "SQL Injection", pattern: /;\s*DELETE\s+FROM\b/i, severity: "critical", cwe: "CWE-89" },
|
|
663
|
+
// Command Injection
|
|
664
|
+
{ category: "Command Injection", pattern: /;\s*(?:ls|cat|rm|wget|curl|nc|bash|sh|id|whoami)\b/, severity: "critical", cwe: "CWE-78" },
|
|
665
|
+
{ category: "Command Injection", pattern: /\$\(\s*(?:[\w/]+\s+|;|&&|\|\|)/, severity: "critical", cwe: "CWE-78" },
|
|
666
|
+
{ category: "Command Injection", pattern: /`(?:[\w/]+\s+|;|&&|\|\|)[^`]+`/, severity: "critical", cwe: "CWE-78" },
|
|
667
|
+
{ category: "Command Injection", pattern: /\|\s*(?:cat|sh|bash|nc)\b/, severity: "critical", cwe: "CWE-78" },
|
|
668
|
+
{ category: "Command Injection", pattern: /&&\s*(?:echo|cat|rm|wget)\b/, severity: "critical", cwe: "CWE-78" },
|
|
669
|
+
// Path Traversal
|
|
670
|
+
{ category: "Path Traversal", pattern: /(?:\.\.\/){2,}/, severity: "critical", cwe: "CWE-22" },
|
|
671
|
+
{ category: "Path Traversal", pattern: /(?:\.\.\\){2,}/, severity: "critical", cwe: "CWE-22" },
|
|
672
|
+
{ category: "Path Traversal", pattern: /\/proc\/self\//, severity: "critical", cwe: "CWE-22" },
|
|
673
|
+
{ category: "Path Traversal", pattern: /\/etc\/(?:passwd|shadow|hosts)/, severity: "critical", cwe: "CWE-22" },
|
|
674
|
+
// XSS
|
|
675
|
+
{ category: "XSS", pattern: /<script\b[^>]*>.*<\/script>/i, severity: "critical", cwe: "CWE-79" },
|
|
676
|
+
{ category: "XSS", pattern: /\b(?:onclick|onerror|onload|onmouseover|onfocus|onblur)\s*=\s*["']?[^"']*["']?/i, severity: "critical", cwe: "CWE-79" },
|
|
677
|
+
{ category: "XSS", pattern: /javascript\s*:/i, severity: "critical", cwe: "CWE-79" },
|
|
678
|
+
// Template Injection (server-side only — CSO Finding #8: ${} requires dangerous content)
|
|
679
|
+
{ category: "Template Injection", pattern: /\{\{.*\}\}/, severity: "critical", cwe: "CWE-94" },
|
|
680
|
+
{ category: "Template Injection", pattern: /\$\{(?:.*(?:process|require|import|eval|exec|spawn|constructor|__proto__|globalThis|Function))[^}]*\}/i, severity: "high", cwe: "CWE-94" },
|
|
681
|
+
{ category: "Template Injection", pattern: /<%=?.*%>/, severity: "critical", cwe: "CWE-94" }
|
|
682
|
+
];
|
|
683
|
+
function checkForInjection(value, context = "argument") {
|
|
684
|
+
const normalized = normalizeForDetection(value);
|
|
685
|
+
const sqlNormalized = normalizeForSql(value);
|
|
686
|
+
for (const pat of INJECTION_PATTERNS) {
|
|
687
|
+
if (context === "description" && pat.category === "Template Injection") {
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (pat.category === "SQL Injection") {
|
|
691
|
+
if (pat.pattern.test(sqlNormalized)) {
|
|
692
|
+
return pat;
|
|
693
|
+
}
|
|
694
|
+
} else {
|
|
695
|
+
if (pat.pattern.test(normalized)) {
|
|
696
|
+
return pat;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
var VULN_INDICATORS = [
|
|
703
|
+
{ pattern: /(?:ENOENT|EACCES|EPERM).*\/etc\/|\/proc\/|\/root\//i, name: "Path Disclosure", cwe: "CWE-209", severity: "high" },
|
|
704
|
+
{ pattern: /at\s+\w+\s+\(.*:\d+:\d+\)/i, name: "Stack Trace Exposure", cwe: "CWE-209", severity: "high" },
|
|
705
|
+
{ pattern: /syntax\s+error|unexpected\s+token|unterminated/i, name: "SQL/Syntax Error Exposure", cwe: "CWE-209", severity: "high" },
|
|
706
|
+
{ pattern: /command\s+not\s+found|sh:|bash:|PWNED|uid=\d+/i, name: "Command Execution Evidence", cwe: "CWE-78", severity: "critical" },
|
|
707
|
+
{ pattern: /root:x:0:0|\/bin\/(?:ba)?sh/i, name: "/etc/passwd Content", cwe: "CWE-22", severity: "critical" },
|
|
708
|
+
// CSO Finding #9: require assignment context (key=value or key:value) instead of bare word match; drop "token" (too generic)
|
|
709
|
+
{ pattern: /(?:^|[=:,\s])(?:password|secret|api[_-]?key)\s*[:=]\s*\S+/i, name: "Sensitive Data Exposure", cwe: "CWE-200", severity: "critical" },
|
|
710
|
+
// CSO Finding #4: require computation context for template injection detection
|
|
711
|
+
{ pattern: /\b(?:7\s*\*\s*7\s*=\s*49|49\s*(?:=|==)\s*7\s*\*\s*7|\{\{7\*7\}\}.*49|<%.*7\*7.*%>.*49)/i, name: "Template Injection (7*7=49)", cwe: "CWE-94", severity: "critical" }
|
|
712
|
+
];
|
|
713
|
+
function checkResponseIndicators(content) {
|
|
714
|
+
for (const ind of VULN_INDICATORS) {
|
|
715
|
+
if (ind.pattern.test(content)) {
|
|
716
|
+
return ind;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// src/rules/argument-injection.ts
|
|
723
|
+
var ArgumentInjectionRule = class {
|
|
724
|
+
id = "argument-injection";
|
|
725
|
+
type = "argument-injection";
|
|
726
|
+
config;
|
|
727
|
+
constructor(config) {
|
|
728
|
+
this.config = config ?? {};
|
|
729
|
+
}
|
|
730
|
+
evaluate(message, direction, context) {
|
|
731
|
+
if (direction !== "client-to-server") return null;
|
|
732
|
+
if (!isRequest(message)) return null;
|
|
733
|
+
if (message.method !== "tools/call") return null;
|
|
734
|
+
const params = message.params;
|
|
735
|
+
if (!params?.arguments) return null;
|
|
736
|
+
const action = this.getAction(context);
|
|
737
|
+
const allValues = extractStringValues(params.arguments);
|
|
738
|
+
for (const value of allValues) {
|
|
739
|
+
const normalized = normalizeHomoglyphs(value);
|
|
740
|
+
const injection = checkForInjection(normalized);
|
|
741
|
+
if (injection) {
|
|
742
|
+
return {
|
|
743
|
+
action,
|
|
744
|
+
ruleId: this.id,
|
|
745
|
+
severity: injection.severity,
|
|
746
|
+
reason: `${injection.category} detected in tool "${params.name ?? "unknown"}" arguments (${injection.cwe})`
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
if (this.cfg("checkPromptInjection", true)) {
|
|
750
|
+
const phrase = detectInjectionPhrase(normalized);
|
|
751
|
+
if (phrase) {
|
|
752
|
+
return {
|
|
753
|
+
action,
|
|
754
|
+
ruleId: this.id,
|
|
755
|
+
severity: "high",
|
|
756
|
+
reason: `Prompt injection phrase in tool "${params.name ?? "unknown"}" arguments: "${phrase}"`
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (this.cfg("checkMultilingualInjection", true)) {
|
|
761
|
+
const mlPhrase = detectMultilingualInjection(value);
|
|
762
|
+
if (mlPhrase) {
|
|
763
|
+
return {
|
|
764
|
+
action,
|
|
765
|
+
ruleId: this.id,
|
|
766
|
+
severity: "high",
|
|
767
|
+
reason: `Multilingual prompt injection in tool "${params.name ?? "unknown"}" arguments: "${mlPhrase}"`
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
const zw = containsZeroWidth(value);
|
|
772
|
+
if (zw.found) {
|
|
773
|
+
return {
|
|
774
|
+
action,
|
|
775
|
+
ruleId: this.id,
|
|
776
|
+
severity: "high",
|
|
777
|
+
reason: `Hidden zero-width characters in tool "${params.name ?? "unknown"}" arguments`
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
cfg(key, defaultValue) {
|
|
784
|
+
return this.config[key] ?? defaultValue;
|
|
785
|
+
}
|
|
786
|
+
getAction(context) {
|
|
787
|
+
const rule = context.policy.rules.find((r) => r.id === this.id);
|
|
788
|
+
return rule?.action ?? "block";
|
|
789
|
+
}
|
|
790
|
+
};
|
|
791
|
+
function extractStringValues(obj) {
|
|
792
|
+
const values = [];
|
|
793
|
+
function walk(val) {
|
|
794
|
+
if (typeof val === "string") {
|
|
795
|
+
values.push(val);
|
|
796
|
+
} else if (Array.isArray(val)) {
|
|
797
|
+
for (const item of val) walk(item);
|
|
798
|
+
} else if (val !== null && typeof val === "object") {
|
|
799
|
+
for (const v of Object.values(val)) walk(v);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
walk(obj);
|
|
803
|
+
return values;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// src/rules/data-exfiltration.ts
|
|
807
|
+
var DataExfiltrationRule = class {
|
|
808
|
+
id = "data-exfiltration";
|
|
809
|
+
type = "data-exfiltration";
|
|
810
|
+
config;
|
|
811
|
+
constructor(config) {
|
|
812
|
+
this.config = config ?? {};
|
|
813
|
+
}
|
|
814
|
+
evaluate(message, direction, context) {
|
|
815
|
+
if (direction !== "server-to-client") return null;
|
|
816
|
+
if (!isResponse(message)) return null;
|
|
817
|
+
const originalReq = context.requestMap.get(message.id);
|
|
818
|
+
if (!originalReq || originalReq.method !== "tools/call") return null;
|
|
819
|
+
const content = extractResponseText(message.result);
|
|
820
|
+
if (!content) return null;
|
|
821
|
+
const action = this.getAction(context);
|
|
822
|
+
const indicator = checkResponseIndicators(content);
|
|
823
|
+
if (indicator) {
|
|
824
|
+
return {
|
|
825
|
+
action,
|
|
826
|
+
ruleId: this.id,
|
|
827
|
+
severity: indicator.severity,
|
|
828
|
+
reason: `${indicator.name} in tool response (${indicator.cwe})`
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
getAction(context) {
|
|
834
|
+
const rule = context.policy.rules.find((r) => r.id === this.id);
|
|
835
|
+
return rule?.action ?? "warn";
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
function extractResponseText(result) {
|
|
839
|
+
if (typeof result === "string") return result;
|
|
840
|
+
if (result && typeof result === "object") {
|
|
841
|
+
const r = result;
|
|
842
|
+
if (Array.isArray(r["content"])) {
|
|
843
|
+
const texts = [];
|
|
844
|
+
for (const item of r["content"]) {
|
|
845
|
+
if (item["type"] === "text" && typeof item["text"] === "string") {
|
|
846
|
+
texts.push(item["text"]);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (texts.length > 0) return texts.join("\n");
|
|
850
|
+
}
|
|
851
|
+
try {
|
|
852
|
+
return JSON.stringify(result);
|
|
853
|
+
} catch {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/rules/index.ts
|
|
861
|
+
var RULE_CONSTRUCTORS = {
|
|
862
|
+
"tool-poisoning": ToolPoisoningRule,
|
|
863
|
+
"argument-injection": ArgumentInjectionRule,
|
|
864
|
+
"data-exfiltration": DataExfiltrationRule
|
|
865
|
+
};
|
|
866
|
+
function createRules(policy) {
|
|
867
|
+
const rules = [];
|
|
868
|
+
for (const ruleConfig of policy.rules) {
|
|
869
|
+
if (!ruleConfig.enabled) continue;
|
|
870
|
+
const Constructor = RULE_CONSTRUCTORS[ruleConfig.type];
|
|
871
|
+
if (!Constructor) {
|
|
872
|
+
throw new Error(`Unknown rule type: "${ruleConfig.type}"`);
|
|
873
|
+
}
|
|
874
|
+
rules.push(new Constructor(ruleConfig.config));
|
|
875
|
+
}
|
|
876
|
+
return rules;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// src/proxy.ts
|
|
880
|
+
import spawn from "cross-spawn";
|
|
881
|
+
|
|
882
|
+
// src/framing.ts
|
|
883
|
+
var MAX_CONTENT_LENGTH = 10 * 1024 * 1024;
|
|
884
|
+
var MAX_BUFFER_SIZE = 20 * 1024 * 1024;
|
|
885
|
+
var MessageFramer = class {
|
|
886
|
+
buffer = "";
|
|
887
|
+
mode = "unknown";
|
|
888
|
+
/**
|
|
889
|
+
* Feed raw data from a stream. Returns an array of complete parsed messages.
|
|
890
|
+
* Locks to detected framing mode on first successful parse to prevent
|
|
891
|
+
* mixed-protocol desynchronization attacks.
|
|
892
|
+
*/
|
|
893
|
+
feed(chunk) {
|
|
894
|
+
this.buffer += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
895
|
+
const messages = [];
|
|
896
|
+
if (this.buffer.length > MAX_BUFFER_SIZE) {
|
|
897
|
+
this.buffer = "";
|
|
898
|
+
throw new Error("Message buffer exceeded maximum size");
|
|
899
|
+
}
|
|
900
|
+
while (this.buffer.length > 0) {
|
|
901
|
+
if (this.mode !== "newline") {
|
|
902
|
+
const headerMatch = this.buffer.match(/^Content-Length:\s*(\d+)\r?\n\r?\n/);
|
|
903
|
+
if (headerMatch) {
|
|
904
|
+
const contentLength = parseInt(headerMatch[1], 10);
|
|
905
|
+
if (contentLength > MAX_CONTENT_LENGTH) {
|
|
906
|
+
this.buffer = "";
|
|
907
|
+
throw new Error(`Content-Length ${contentLength} exceeds maximum ${MAX_CONTENT_LENGTH}`);
|
|
908
|
+
}
|
|
909
|
+
const headerEnd = headerMatch[0].length;
|
|
910
|
+
if (this.buffer.length < headerEnd + contentLength) break;
|
|
911
|
+
const body = this.buffer.substring(headerEnd, headerEnd + contentLength);
|
|
912
|
+
this.buffer = this.buffer.substring(headerEnd + contentLength);
|
|
913
|
+
this.mode = "content-length";
|
|
914
|
+
const parsed = tryParseJson(body);
|
|
915
|
+
if (parsed) messages.push(parsed);
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (this.mode !== "content-length") {
|
|
920
|
+
const nlIndex = this.buffer.indexOf("\n");
|
|
921
|
+
if (nlIndex === -1) {
|
|
922
|
+
break;
|
|
923
|
+
}
|
|
924
|
+
const line = this.buffer.substring(0, nlIndex).trim();
|
|
925
|
+
this.buffer = this.buffer.substring(nlIndex + 1);
|
|
926
|
+
if (line.length > 0 && line.startsWith("{")) {
|
|
927
|
+
const parsed = tryParseJson(line);
|
|
928
|
+
if (parsed) {
|
|
929
|
+
this.mode = "newline";
|
|
930
|
+
messages.push(parsed);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
return messages;
|
|
938
|
+
}
|
|
939
|
+
/** Reset the internal buffer and mode detection */
|
|
940
|
+
reset() {
|
|
941
|
+
this.buffer = "";
|
|
942
|
+
this.mode = "unknown";
|
|
943
|
+
}
|
|
944
|
+
/** Get the current framing mode (for testing/diagnostics) */
|
|
945
|
+
getMode() {
|
|
946
|
+
return this.mode;
|
|
947
|
+
}
|
|
948
|
+
/** Serialize a message to newline-delimited JSON (MCP SDK convention) */
|
|
949
|
+
static serialize(message) {
|
|
950
|
+
return JSON.stringify(message) + "\n";
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
function tryParseJson(raw) {
|
|
954
|
+
try {
|
|
955
|
+
const obj = JSON.parse(raw);
|
|
956
|
+
if (obj["jsonrpc"] === "2.0") {
|
|
957
|
+
return obj;
|
|
958
|
+
}
|
|
959
|
+
return null;
|
|
960
|
+
} catch {
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// src/logger.ts
|
|
966
|
+
var LEVEL_ORDER = {
|
|
967
|
+
debug: 0,
|
|
968
|
+
info: 1,
|
|
969
|
+
warn: 2,
|
|
970
|
+
error: 3
|
|
971
|
+
};
|
|
972
|
+
var Logger = class {
|
|
973
|
+
threshold;
|
|
974
|
+
constructor(level = "info") {
|
|
975
|
+
this.threshold = LEVEL_ORDER[level];
|
|
976
|
+
}
|
|
977
|
+
debug(msg, data) {
|
|
978
|
+
this.log("debug", msg, data);
|
|
979
|
+
}
|
|
980
|
+
info(msg, data) {
|
|
981
|
+
this.log("info", msg, data);
|
|
982
|
+
}
|
|
983
|
+
warn(msg, data) {
|
|
984
|
+
this.log("warn", msg, data);
|
|
985
|
+
}
|
|
986
|
+
error(msg, data) {
|
|
987
|
+
this.log("error", msg, data);
|
|
988
|
+
}
|
|
989
|
+
log(level, msg, data) {
|
|
990
|
+
if (LEVEL_ORDER[level] < this.threshold) return;
|
|
991
|
+
const entry = {
|
|
992
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
993
|
+
level,
|
|
994
|
+
msg,
|
|
995
|
+
...data
|
|
996
|
+
};
|
|
997
|
+
process.stderr.write(JSON.stringify(entry) + "\n");
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
// src/rule-engine.ts
|
|
1002
|
+
var REQUEST_TTL_MS = 6e4;
|
|
1003
|
+
var MAX_TRACKED_REQUESTS = 1e4;
|
|
1004
|
+
var RuleEngine = class {
|
|
1005
|
+
rules;
|
|
1006
|
+
policy;
|
|
1007
|
+
logger;
|
|
1008
|
+
requestMap = /* @__PURE__ */ new Map();
|
|
1009
|
+
serverInfo;
|
|
1010
|
+
dryRun;
|
|
1011
|
+
cleanupInterval = null;
|
|
1012
|
+
constructor(opts) {
|
|
1013
|
+
this.rules = opts.rules;
|
|
1014
|
+
this.policy = opts.policy;
|
|
1015
|
+
this.logger = opts.logger;
|
|
1016
|
+
this.serverInfo = opts.serverInfo;
|
|
1017
|
+
this.dryRun = opts.dryRun;
|
|
1018
|
+
this.cleanupInterval = setInterval(() => this.cleanupStaleRequests(), 3e4);
|
|
1019
|
+
this.cleanupInterval.unref();
|
|
1020
|
+
}
|
|
1021
|
+
/** Remove tracked requests older than REQUEST_TTL_MS, or oldest when over capacity */
|
|
1022
|
+
cleanupStaleRequests() {
|
|
1023
|
+
const now = Date.now();
|
|
1024
|
+
for (const [id, entry] of this.requestMap) {
|
|
1025
|
+
if (now - entry.trackedAt > REQUEST_TTL_MS) {
|
|
1026
|
+
this.requestMap.delete(id);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (this.requestMap.size > MAX_TRACKED_REQUESTS) {
|
|
1030
|
+
const sorted = [...this.requestMap.entries()].sort(
|
|
1031
|
+
(a, b) => a[1].trackedAt - b[1].trackedAt
|
|
1032
|
+
);
|
|
1033
|
+
const toRemove = sorted.slice(0, this.requestMap.size - MAX_TRACKED_REQUESTS);
|
|
1034
|
+
for (const [id] of toRemove) {
|
|
1035
|
+
this.requestMap.delete(id);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
/** Clean up resources */
|
|
1040
|
+
dispose() {
|
|
1041
|
+
if (this.cleanupInterval) {
|
|
1042
|
+
clearInterval(this.cleanupInterval);
|
|
1043
|
+
this.cleanupInterval = null;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
/** Get the list of rule IDs for logging */
|
|
1047
|
+
getRuleIds() {
|
|
1048
|
+
return this.rules.map((r) => r.id);
|
|
1049
|
+
}
|
|
1050
|
+
/** Track a request for later response correlation */
|
|
1051
|
+
trackRequest(id, request) {
|
|
1052
|
+
this.requestMap.set(id, { request, trackedAt: Date.now() });
|
|
1053
|
+
}
|
|
1054
|
+
/** Remove a tracked request after response is processed */
|
|
1055
|
+
untrackRequest(id) {
|
|
1056
|
+
this.requestMap.delete(id);
|
|
1057
|
+
}
|
|
1058
|
+
/** Get the request map for rule context (returns requests only, without tracking metadata) */
|
|
1059
|
+
getRequestMap() {
|
|
1060
|
+
const result = /* @__PURE__ */ new Map();
|
|
1061
|
+
for (const [id, entry] of this.requestMap) {
|
|
1062
|
+
result.set(id, entry.request);
|
|
1063
|
+
}
|
|
1064
|
+
return result;
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Evaluate all rules against a message.
|
|
1068
|
+
* Returns the first non-null verdict, or null if all rules pass.
|
|
1069
|
+
*/
|
|
1070
|
+
evaluate(message, direction) {
|
|
1071
|
+
const context = {
|
|
1072
|
+
policy: this.policy,
|
|
1073
|
+
serverCommand: this.serverInfo,
|
|
1074
|
+
requestMap: this.getRequestMap()
|
|
1075
|
+
};
|
|
1076
|
+
for (const rule of this.rules) {
|
|
1077
|
+
try {
|
|
1078
|
+
const verdict = rule.evaluate(message, direction, context);
|
|
1079
|
+
if (verdict) return verdict;
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
this.logger.error("Rule evaluation error, blocking", {
|
|
1082
|
+
rule: rule.id,
|
|
1083
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1084
|
+
});
|
|
1085
|
+
return {
|
|
1086
|
+
action: "block",
|
|
1087
|
+
ruleId: rule.id,
|
|
1088
|
+
severity: "critical",
|
|
1089
|
+
reason: `Rule evaluation error: ${err instanceof Error ? err.message : String(err)}`
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Log a verdict appropriately (block, dry-run block, or warn).
|
|
1097
|
+
* Returns true if the message should be blocked (not forwarded).
|
|
1098
|
+
*/
|
|
1099
|
+
handleVerdict(verdict, direction, method) {
|
|
1100
|
+
if (!verdict) return false;
|
|
1101
|
+
const dirLabel = direction === "client-to-server" ? "client\u2192server" : "server\u2192client";
|
|
1102
|
+
if (verdict.action === "block") {
|
|
1103
|
+
if (this.dryRun) {
|
|
1104
|
+
this.logger.warn(`DRY-RUN WOULD BLOCK ${dirLabel}`, {
|
|
1105
|
+
rule: verdict.ruleId,
|
|
1106
|
+
severity: verdict.severity,
|
|
1107
|
+
reason: verdict.reason,
|
|
1108
|
+
method
|
|
1109
|
+
});
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
this.logger.warn(`BLOCKED ${dirLabel}`, {
|
|
1113
|
+
rule: verdict.ruleId,
|
|
1114
|
+
severity: verdict.severity,
|
|
1115
|
+
reason: verdict.reason,
|
|
1116
|
+
method
|
|
1117
|
+
});
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
if (verdict.action === "warn") {
|
|
1121
|
+
this.logger.warn(`WARNING ${dirLabel}`, {
|
|
1122
|
+
rule: verdict.ruleId,
|
|
1123
|
+
severity: verdict.severity,
|
|
1124
|
+
reason: verdict.reason
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
// src/proxy.ts
|
|
1132
|
+
var McpGuardProxy = class {
|
|
1133
|
+
child = null;
|
|
1134
|
+
clientFramer = new MessageFramer();
|
|
1135
|
+
serverFramer = new MessageFramer();
|
|
1136
|
+
ruleEngine;
|
|
1137
|
+
options;
|
|
1138
|
+
policy;
|
|
1139
|
+
logger;
|
|
1140
|
+
stopped = false;
|
|
1141
|
+
constructor(options, policy, rules) {
|
|
1142
|
+
this.options = options;
|
|
1143
|
+
this.policy = policy;
|
|
1144
|
+
this.logger = new Logger(options.verbose ? "debug" : policy.logging.level);
|
|
1145
|
+
this.ruleEngine = new RuleEngine({
|
|
1146
|
+
rules,
|
|
1147
|
+
policy,
|
|
1148
|
+
logger: this.logger,
|
|
1149
|
+
serverInfo: options.serverCommand,
|
|
1150
|
+
dryRun: options.dryRun
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
async start() {
|
|
1154
|
+
this.logger.info("mcp-guard starting", {
|
|
1155
|
+
server: this.options.serverCommand,
|
|
1156
|
+
args: this.options.serverArgs,
|
|
1157
|
+
rules: this.ruleEngine.getRuleIds(),
|
|
1158
|
+
failMode: this.policy.failMode,
|
|
1159
|
+
dryRun: this.options.dryRun
|
|
1160
|
+
});
|
|
1161
|
+
this.child = spawn(this.options.serverCommand, this.options.serverArgs, {
|
|
1162
|
+
stdio: ["pipe", "pipe", "inherit"]
|
|
1163
|
+
// stderr passes through
|
|
1164
|
+
});
|
|
1165
|
+
if (!this.child.stdin || !this.child.stdout) {
|
|
1166
|
+
throw new Error("Failed to open stdio pipes to MCP server");
|
|
1167
|
+
}
|
|
1168
|
+
this.child.on("exit", (code, signal) => {
|
|
1169
|
+
this.logger.info("MCP server exited", { code, signal });
|
|
1170
|
+
if (!this.stopped) {
|
|
1171
|
+
process.exit(code ?? 1);
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
this.child.on("error", (err) => {
|
|
1175
|
+
this.logger.error("Failed to spawn MCP server", { error: err.message });
|
|
1176
|
+
process.exit(1);
|
|
1177
|
+
});
|
|
1178
|
+
process.stdin.on("data", (chunk) => {
|
|
1179
|
+
this.handleClientData(chunk);
|
|
1180
|
+
});
|
|
1181
|
+
process.stdin.on("end", () => {
|
|
1182
|
+
this.logger.debug("Client stdin closed");
|
|
1183
|
+
this.stop();
|
|
1184
|
+
});
|
|
1185
|
+
this.child.stdout.on("data", (chunk) => {
|
|
1186
|
+
this.handleServerData(chunk);
|
|
1187
|
+
});
|
|
1188
|
+
this.child.stdout.on("end", () => {
|
|
1189
|
+
this.logger.debug("Server stdout closed");
|
|
1190
|
+
});
|
|
1191
|
+
const handleSignal = (sig) => {
|
|
1192
|
+
this.logger.info("Received signal, shutting down", { signal: sig });
|
|
1193
|
+
this.stop();
|
|
1194
|
+
};
|
|
1195
|
+
process.on("SIGTERM", handleSignal);
|
|
1196
|
+
process.on("SIGINT", handleSignal);
|
|
1197
|
+
}
|
|
1198
|
+
stop() {
|
|
1199
|
+
if (this.stopped) return;
|
|
1200
|
+
this.stopped = true;
|
|
1201
|
+
this.ruleEngine.dispose();
|
|
1202
|
+
if (this.child) {
|
|
1203
|
+
try {
|
|
1204
|
+
this.child.kill("SIGTERM");
|
|
1205
|
+
} catch {
|
|
1206
|
+
}
|
|
1207
|
+
setTimeout(() => {
|
|
1208
|
+
try {
|
|
1209
|
+
this.child?.kill("SIGKILL");
|
|
1210
|
+
} catch {
|
|
1211
|
+
}
|
|
1212
|
+
}, 2e3);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
handleClientData(chunk) {
|
|
1216
|
+
let messages;
|
|
1217
|
+
try {
|
|
1218
|
+
messages = this.clientFramer.feed(chunk);
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
this.logger.error("Client framing error, dropping message", {
|
|
1221
|
+
failMode: this.policy.failMode,
|
|
1222
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1223
|
+
});
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (messages.length === 0) return;
|
|
1227
|
+
for (const msg of messages) {
|
|
1228
|
+
if (isMalformed(msg)) {
|
|
1229
|
+
this.logger.warn("Dropping malformed JSON-RPC message (has both method and result/error)");
|
|
1230
|
+
if ("id" in msg) {
|
|
1231
|
+
this.writeToClient({
|
|
1232
|
+
jsonrpc: "2.0",
|
|
1233
|
+
id: msg.id,
|
|
1234
|
+
error: { code: -32600, message: "Malformed JSON-RPC: contradictory fields" }
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
if (isRequest(msg)) {
|
|
1240
|
+
this.ruleEngine.trackRequest(msg.id, msg);
|
|
1241
|
+
}
|
|
1242
|
+
const verdict = this.ruleEngine.evaluate(msg, "client-to-server");
|
|
1243
|
+
const blocked = this.ruleEngine.handleVerdict(
|
|
1244
|
+
verdict,
|
|
1245
|
+
"client-to-server",
|
|
1246
|
+
isRequest(msg) ? msg.method : void 0
|
|
1247
|
+
);
|
|
1248
|
+
if (blocked && isRequest(msg)) {
|
|
1249
|
+
const errorResponse = {
|
|
1250
|
+
jsonrpc: "2.0",
|
|
1251
|
+
id: msg.id,
|
|
1252
|
+
error: {
|
|
1253
|
+
code: -32600,
|
|
1254
|
+
message: `Blocked by mcp-guard: ${verdict.reason}`,
|
|
1255
|
+
data: { rule: verdict.ruleId, severity: verdict.severity }
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
this.writeToClient(errorResponse);
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
this.writeToServer(msg);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
handleServerData(chunk) {
|
|
1265
|
+
let messages;
|
|
1266
|
+
try {
|
|
1267
|
+
messages = this.serverFramer.feed(chunk);
|
|
1268
|
+
} catch (err) {
|
|
1269
|
+
this.logger.error("Server framing error, dropping message", {
|
|
1270
|
+
failMode: this.policy.failMode,
|
|
1271
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1272
|
+
});
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (messages.length === 0) return;
|
|
1276
|
+
for (const msg of messages) {
|
|
1277
|
+
if (isMalformed(msg)) {
|
|
1278
|
+
this.logger.warn("Dropping malformed server JSON-RPC message (has both method and result/error)");
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
const verdict = this.ruleEngine.evaluate(msg, "server-to-client");
|
|
1282
|
+
const blocked = this.ruleEngine.handleVerdict(verdict, "server-to-client");
|
|
1283
|
+
if (blocked) {
|
|
1284
|
+
if (isResponse(msg)) {
|
|
1285
|
+
const errorResponse = {
|
|
1286
|
+
jsonrpc: "2.0",
|
|
1287
|
+
id: msg.id,
|
|
1288
|
+
error: {
|
|
1289
|
+
code: -32600,
|
|
1290
|
+
message: `Blocked by mcp-guard: ${verdict.reason}`,
|
|
1291
|
+
data: { rule: verdict.ruleId, severity: verdict.severity }
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
this.writeToClient(errorResponse);
|
|
1295
|
+
}
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
if (isResponse(msg)) {
|
|
1299
|
+
this.ruleEngine.untrackRequest(msg.id);
|
|
1300
|
+
}
|
|
1301
|
+
this.writeToClient(msg);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
writeToClient(msg) {
|
|
1305
|
+
process.stdout.write(MessageFramer.serialize(msg));
|
|
1306
|
+
}
|
|
1307
|
+
writeToServer(msg) {
|
|
1308
|
+
this.child?.stdin?.write(MessageFramer.serialize(msg));
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1312
|
+
// src/http-proxy.ts
|
|
1313
|
+
import { createServer } from "http";
|
|
1314
|
+
var MAX_REQUEST_BODY = 5 * 1024 * 1024;
|
|
1315
|
+
var MAX_SSE_BUFFER = 1 * 1024 * 1024;
|
|
1316
|
+
var UPSTREAM_TIMEOUT_MS = 3e4;
|
|
1317
|
+
var McpHttpProxy = class {
|
|
1318
|
+
server = null;
|
|
1319
|
+
ruleEngine;
|
|
1320
|
+
options;
|
|
1321
|
+
logger;
|
|
1322
|
+
constructor(options, policy, rules) {
|
|
1323
|
+
this.options = options;
|
|
1324
|
+
this.logger = new Logger(options.verbose ? "debug" : policy.logging.level);
|
|
1325
|
+
this.ruleEngine = new RuleEngine({
|
|
1326
|
+
rules,
|
|
1327
|
+
policy,
|
|
1328
|
+
logger: this.logger,
|
|
1329
|
+
serverInfo: options.upstream,
|
|
1330
|
+
dryRun: options.dryRun
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
async start() {
|
|
1334
|
+
this.logger.info("mcp-guard HTTP proxy starting", {
|
|
1335
|
+
upstream: this.options.upstream,
|
|
1336
|
+
listen: `${this.options.host}:${this.options.port}`,
|
|
1337
|
+
dryRun: this.options.dryRun
|
|
1338
|
+
});
|
|
1339
|
+
this.server = createServer((req, res) => {
|
|
1340
|
+
this.handleRequest(req, res).catch((err) => {
|
|
1341
|
+
this.logger.error("Request handler error", { error: err.message });
|
|
1342
|
+
if (!res.headersSent) {
|
|
1343
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1344
|
+
res.end(JSON.stringify({ error: "Internal proxy error" }));
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
});
|
|
1348
|
+
return new Promise((resolve, reject) => {
|
|
1349
|
+
this.server.on("error", reject);
|
|
1350
|
+
this.server.listen(this.options.port, this.options.host, () => {
|
|
1351
|
+
this.logger.info("mcp-guard HTTP proxy listening", {
|
|
1352
|
+
url: `http://${this.options.host}:${this.options.port}`
|
|
1353
|
+
});
|
|
1354
|
+
resolve();
|
|
1355
|
+
});
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
async stop() {
|
|
1359
|
+
this.ruleEngine.dispose();
|
|
1360
|
+
return new Promise((resolve) => {
|
|
1361
|
+
if (this.server) {
|
|
1362
|
+
this.server.close(() => resolve());
|
|
1363
|
+
} else {
|
|
1364
|
+
resolve();
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
async handleRequest(req, res) {
|
|
1369
|
+
const method = req.method?.toUpperCase() ?? "GET";
|
|
1370
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
1371
|
+
this.logger.debug("Incoming request", { method, path: url.pathname, query: url.search });
|
|
1372
|
+
if (method === "POST") {
|
|
1373
|
+
await this.handlePost(req, res, url);
|
|
1374
|
+
} else if (method === "GET") {
|
|
1375
|
+
await this.handleGet(req, res, url);
|
|
1376
|
+
} else if (method === "DELETE") {
|
|
1377
|
+
await this.handleDelete(req, res);
|
|
1378
|
+
} else {
|
|
1379
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
1380
|
+
res.end(JSON.stringify({ error: `Method ${method} not allowed` }));
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
// ─── POST: Client sends JSON-RPC messages ──────────────────────────────────
|
|
1384
|
+
async handlePost(req, res, url) {
|
|
1385
|
+
const body = await readBody(req);
|
|
1386
|
+
if (!body) {
|
|
1387
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1388
|
+
res.end(JSON.stringify({ error: "Empty request body" }));
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
let messages;
|
|
1392
|
+
try {
|
|
1393
|
+
const parsed = JSON.parse(body);
|
|
1394
|
+
messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
1395
|
+
} catch {
|
|
1396
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1397
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
const blocked = [];
|
|
1401
|
+
const allowed = [];
|
|
1402
|
+
for (const msg of messages) {
|
|
1403
|
+
if (isMalformed(msg)) {
|
|
1404
|
+
this.logger.warn("Dropping malformed JSON-RPC message (has both method and result/error)");
|
|
1405
|
+
if ("id" in msg) {
|
|
1406
|
+
blocked.push({
|
|
1407
|
+
jsonrpc: "2.0",
|
|
1408
|
+
id: msg.id,
|
|
1409
|
+
error: { code: -32600, message: "Malformed JSON-RPC: contradictory fields" }
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
if (isRequest(msg)) {
|
|
1415
|
+
this.ruleEngine.trackRequest(msg.id, msg);
|
|
1416
|
+
}
|
|
1417
|
+
const verdict = this.ruleEngine.evaluate(msg, "client-to-server");
|
|
1418
|
+
const shouldBlock = this.ruleEngine.handleVerdict(
|
|
1419
|
+
verdict,
|
|
1420
|
+
"client-to-server",
|
|
1421
|
+
isRequest(msg) ? msg.method : void 0
|
|
1422
|
+
);
|
|
1423
|
+
if (shouldBlock && isRequest(msg)) {
|
|
1424
|
+
blocked.push({
|
|
1425
|
+
jsonrpc: "2.0",
|
|
1426
|
+
id: msg.id,
|
|
1427
|
+
error: {
|
|
1428
|
+
code: -32600,
|
|
1429
|
+
message: `Blocked by mcp-guard: ${verdict.reason}`,
|
|
1430
|
+
data: { rule: verdict.ruleId, severity: verdict.severity }
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
} else {
|
|
1434
|
+
allowed.push(msg);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
if (allowed.length === 0 && blocked.length > 0) {
|
|
1438
|
+
const accept = req.headers.accept ?? "";
|
|
1439
|
+
if (accept.includes("text/event-stream")) {
|
|
1440
|
+
res.writeHead(200, sseHeaders());
|
|
1441
|
+
for (const err of blocked) {
|
|
1442
|
+
writeSseEvent(res, "message", JSON.stringify(err));
|
|
1443
|
+
}
|
|
1444
|
+
res.end();
|
|
1445
|
+
} else {
|
|
1446
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1447
|
+
res.end(JSON.stringify(blocked.length === 1 ? blocked[0] : blocked));
|
|
1448
|
+
}
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
const upstreamUrl = new URL(url.pathname + url.search, this.options.upstream);
|
|
1452
|
+
const upstreamRes = await this.forwardPost(upstreamUrl.toString(), req, allowed);
|
|
1453
|
+
if (!upstreamRes.ok && !upstreamRes.body) {
|
|
1454
|
+
res.writeHead(upstreamRes.status, { "Content-Type": "application/json" });
|
|
1455
|
+
res.end(JSON.stringify({ error: `Upstream returned ${upstreamRes.status}` }));
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
const upstreamContentType = upstreamRes.headers.get("content-type") ?? "";
|
|
1459
|
+
const proxyHeaders = {};
|
|
1460
|
+
const sessionId = upstreamRes.headers.get("mcp-session-id");
|
|
1461
|
+
if (sessionId) proxyHeaders["mcp-session-id"] = sessionId;
|
|
1462
|
+
if (upstreamContentType.includes("text/event-stream")) {
|
|
1463
|
+
proxyHeaders["content-type"] = "text/event-stream";
|
|
1464
|
+
proxyHeaders["cache-control"] = "no-cache, no-transform";
|
|
1465
|
+
proxyHeaders["connection"] = "keep-alive";
|
|
1466
|
+
res.writeHead(upstreamRes.status, proxyHeaders);
|
|
1467
|
+
await this.streamSseResponse(upstreamRes, res, blocked);
|
|
1468
|
+
} else {
|
|
1469
|
+
const responseBody = await upstreamRes.text();
|
|
1470
|
+
proxyHeaders["content-type"] = upstreamContentType || "application/json";
|
|
1471
|
+
res.writeHead(upstreamRes.status, proxyHeaders);
|
|
1472
|
+
const inspected = this.inspectJsonResponse(responseBody, blocked);
|
|
1473
|
+
res.end(inspected);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
// ─── GET: SSE stream (server→client notifications) ─────────────────────────
|
|
1477
|
+
async handleGet(req, res, url) {
|
|
1478
|
+
const upstreamUrl = new URL(url.pathname + url.search, this.options.upstream);
|
|
1479
|
+
const headers = {
|
|
1480
|
+
"Accept": "text/event-stream"
|
|
1481
|
+
};
|
|
1482
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1483
|
+
if (sessionId) headers["Mcp-Session-Id"] = String(sessionId);
|
|
1484
|
+
const protocolVersion = req.headers["mcp-protocol-version"];
|
|
1485
|
+
if (protocolVersion) headers["Mcp-Protocol-Version"] = String(protocolVersion);
|
|
1486
|
+
const lastEventId = req.headers["last-event-id"];
|
|
1487
|
+
if (lastEventId) headers["Last-Event-Id"] = String(lastEventId);
|
|
1488
|
+
let upstreamRes;
|
|
1489
|
+
try {
|
|
1490
|
+
const ac = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
|
|
1491
|
+
upstreamRes = await fetch(upstreamUrl.toString(), { method: "GET", headers, signal: ac });
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1494
|
+
res.end(JSON.stringify({ error: `Upstream connection failed: ${err.message}` }));
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
if (!upstreamRes.ok || !upstreamRes.body) {
|
|
1498
|
+
res.writeHead(upstreamRes.status, { "Content-Type": "application/json" });
|
|
1499
|
+
res.end(JSON.stringify({ error: `Upstream returned ${upstreamRes.status}` }));
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const proxyHeaders = {
|
|
1503
|
+
"content-type": "text/event-stream",
|
|
1504
|
+
"cache-control": "no-cache, no-transform",
|
|
1505
|
+
"connection": "keep-alive"
|
|
1506
|
+
};
|
|
1507
|
+
const upSessionId = upstreamRes.headers.get("mcp-session-id");
|
|
1508
|
+
if (upSessionId) proxyHeaders["mcp-session-id"] = upSessionId;
|
|
1509
|
+
res.writeHead(200, proxyHeaders);
|
|
1510
|
+
await this.streamSseResponse(upstreamRes, res, []);
|
|
1511
|
+
}
|
|
1512
|
+
// ─── DELETE: Session termination ───────────────────────────────────────────
|
|
1513
|
+
async handleDelete(req, res) {
|
|
1514
|
+
const headers = {};
|
|
1515
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1516
|
+
if (sessionId) headers["Mcp-Session-Id"] = String(sessionId);
|
|
1517
|
+
try {
|
|
1518
|
+
const ac = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
|
|
1519
|
+
const upstreamRes = await fetch(this.options.upstream, { method: "DELETE", headers, signal: ac });
|
|
1520
|
+
res.writeHead(upstreamRes.status);
|
|
1521
|
+
res.end();
|
|
1522
|
+
} catch (err) {
|
|
1523
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
1524
|
+
res.end(JSON.stringify({ error: `Upstream connection failed: ${err.message}` }));
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
1528
|
+
async forwardPost(upstreamUrl, originalReq, messages) {
|
|
1529
|
+
const body = messages.length === 1 ? JSON.stringify(messages[0]) : JSON.stringify(messages);
|
|
1530
|
+
const headers = {
|
|
1531
|
+
"Content-Type": "application/json",
|
|
1532
|
+
"Accept": originalReq.headers.accept ?? "application/json, text/event-stream"
|
|
1533
|
+
};
|
|
1534
|
+
const sessionId = originalReq.headers["mcp-session-id"];
|
|
1535
|
+
if (sessionId) headers["Mcp-Session-Id"] = String(sessionId);
|
|
1536
|
+
const protocolVersion = originalReq.headers["mcp-protocol-version"];
|
|
1537
|
+
if (protocolVersion) headers["Mcp-Protocol-Version"] = String(protocolVersion);
|
|
1538
|
+
const auth = originalReq.headers["authorization"];
|
|
1539
|
+
if (auth) headers["Authorization"] = String(auth);
|
|
1540
|
+
try {
|
|
1541
|
+
const ac = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
|
|
1542
|
+
return await fetch(upstreamUrl, { method: "POST", headers, body, signal: ac });
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
return new Response(JSON.stringify({ error: `Upstream connection failed: ${err.message}` }), {
|
|
1545
|
+
status: 502,
|
|
1546
|
+
headers: { "Content-Type": "application/json" }
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Stream SSE from upstream Response to client ServerResponse,
|
|
1552
|
+
* inspecting each JSON-RPC message through security rules.
|
|
1553
|
+
*/
|
|
1554
|
+
async streamSseResponse(upstreamRes, clientRes, prependErrors) {
|
|
1555
|
+
for (const err of prependErrors) {
|
|
1556
|
+
writeSseEvent(clientRes, "message", JSON.stringify(err));
|
|
1557
|
+
}
|
|
1558
|
+
if (!upstreamRes.body) {
|
|
1559
|
+
clientRes.end();
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
const reader = upstreamRes.body.getReader();
|
|
1563
|
+
const decoder = new TextDecoder();
|
|
1564
|
+
let sseBuffer = "";
|
|
1565
|
+
try {
|
|
1566
|
+
while (true) {
|
|
1567
|
+
const { done, value } = await reader.read();
|
|
1568
|
+
if (done) break;
|
|
1569
|
+
sseBuffer += decoder.decode(value, { stream: true });
|
|
1570
|
+
if (sseBuffer.length > MAX_SSE_BUFFER) {
|
|
1571
|
+
this.logger.warn("SSE buffer exceeded limit, resetting", { size: sseBuffer.length });
|
|
1572
|
+
sseBuffer = "";
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
const events = parseSseEvents(sseBuffer);
|
|
1576
|
+
sseBuffer = events.remaining;
|
|
1577
|
+
for (const event of events.parsed) {
|
|
1578
|
+
if (event.event === "message" && event.data) {
|
|
1579
|
+
const inspected = this.inspectSseMessage(event.data);
|
|
1580
|
+
if (inspected !== null) {
|
|
1581
|
+
writeSseEvent(clientRes, event.event, inspected, event.id);
|
|
1582
|
+
}
|
|
1583
|
+
} else {
|
|
1584
|
+
writeSseEventRaw(clientRes, event);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
} catch (err) {
|
|
1589
|
+
this.logger.error("SSE stream error", { error: err.message });
|
|
1590
|
+
} finally {
|
|
1591
|
+
clientRes.end();
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Inspect a single JSON-RPC message from an SSE event.
|
|
1596
|
+
* Returns the (possibly modified) JSON string, or null to block.
|
|
1597
|
+
*/
|
|
1598
|
+
inspectSseMessage(data) {
|
|
1599
|
+
let msg;
|
|
1600
|
+
try {
|
|
1601
|
+
msg = JSON.parse(data);
|
|
1602
|
+
} catch {
|
|
1603
|
+
this.logger.warn("Dropping unparseable SSE JSON-RPC message");
|
|
1604
|
+
return null;
|
|
1605
|
+
}
|
|
1606
|
+
if (isMalformed(msg)) {
|
|
1607
|
+
this.logger.warn("Dropping malformed SSE JSON-RPC message");
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
const verdict = this.ruleEngine.evaluate(msg, "server-to-client");
|
|
1611
|
+
const shouldBlock = this.ruleEngine.handleVerdict(verdict, "server-to-client");
|
|
1612
|
+
if (shouldBlock) {
|
|
1613
|
+
if (isResponse(msg)) {
|
|
1614
|
+
const errorResponse = {
|
|
1615
|
+
jsonrpc: "2.0",
|
|
1616
|
+
id: msg.id,
|
|
1617
|
+
error: {
|
|
1618
|
+
code: -32600,
|
|
1619
|
+
message: `Blocked by mcp-guard: ${verdict.reason}`,
|
|
1620
|
+
data: { rule: verdict.ruleId, severity: verdict.severity }
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
return JSON.stringify(errorResponse);
|
|
1624
|
+
}
|
|
1625
|
+
return null;
|
|
1626
|
+
}
|
|
1627
|
+
if (isResponse(msg)) {
|
|
1628
|
+
this.ruleEngine.untrackRequest(msg.id);
|
|
1629
|
+
}
|
|
1630
|
+
return data;
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Inspect a plain JSON response body.
|
|
1634
|
+
*/
|
|
1635
|
+
inspectJsonResponse(body, prependErrors) {
|
|
1636
|
+
let parsed;
|
|
1637
|
+
try {
|
|
1638
|
+
parsed = JSON.parse(body);
|
|
1639
|
+
} catch {
|
|
1640
|
+
this.logger.warn("Dropping unparseable JSON response from upstream");
|
|
1641
|
+
const err = {
|
|
1642
|
+
jsonrpc: "2.0",
|
|
1643
|
+
id: 0,
|
|
1644
|
+
error: { code: -32600, message: "Upstream returned unparseable JSON" }
|
|
1645
|
+
};
|
|
1646
|
+
return JSON.stringify(prependErrors.length > 0 ? [...prependErrors, err] : err);
|
|
1647
|
+
}
|
|
1648
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
1649
|
+
const results = [...prependErrors];
|
|
1650
|
+
for (const raw of messages) {
|
|
1651
|
+
const msg = raw;
|
|
1652
|
+
if (msg.jsonrpc !== "2.0") {
|
|
1653
|
+
results.push(raw);
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
if (isMalformed(msg)) {
|
|
1657
|
+
this.logger.warn("Dropping malformed JSON-RPC response");
|
|
1658
|
+
if (isResponse(msg)) {
|
|
1659
|
+
results.push({
|
|
1660
|
+
jsonrpc: "2.0",
|
|
1661
|
+
id: msg.id,
|
|
1662
|
+
error: { code: -32600, message: "Malformed JSON-RPC: contradictory fields" }
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
const verdict = this.ruleEngine.evaluate(msg, "server-to-client");
|
|
1668
|
+
const shouldBlock = this.ruleEngine.handleVerdict(verdict, "server-to-client");
|
|
1669
|
+
if (shouldBlock && isResponse(msg)) {
|
|
1670
|
+
results.push({
|
|
1671
|
+
jsonrpc: "2.0",
|
|
1672
|
+
id: msg.id,
|
|
1673
|
+
error: {
|
|
1674
|
+
code: -32600,
|
|
1675
|
+
message: `Blocked by mcp-guard: ${verdict.reason}`,
|
|
1676
|
+
data: { rule: verdict.ruleId, severity: verdict.severity }
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
} else {
|
|
1680
|
+
if (isResponse(msg)) {
|
|
1681
|
+
this.ruleEngine.untrackRequest(msg.id);
|
|
1682
|
+
}
|
|
1683
|
+
results.push(raw);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
if (!Array.isArray(parsed) && results.length === 1) {
|
|
1687
|
+
return JSON.stringify(results[0]);
|
|
1688
|
+
}
|
|
1689
|
+
return JSON.stringify(results);
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
function sseHeaders() {
|
|
1693
|
+
return {
|
|
1694
|
+
"Content-Type": "text/event-stream",
|
|
1695
|
+
"Cache-Control": "no-cache, no-transform",
|
|
1696
|
+
"Connection": "keep-alive"
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
function sanitizeSseField(value) {
|
|
1700
|
+
return value.replace(/[\n\r]/g, "");
|
|
1701
|
+
}
|
|
1702
|
+
function writeSseEvent(res, event, data, id) {
|
|
1703
|
+
if (id) res.write(`id: ${sanitizeSseField(id)}
|
|
1704
|
+
`);
|
|
1705
|
+
res.write(`event: ${sanitizeSseField(event)}
|
|
1706
|
+
`);
|
|
1707
|
+
for (const line of data.split("\n")) {
|
|
1708
|
+
res.write(`data: ${line}
|
|
1709
|
+
`);
|
|
1710
|
+
}
|
|
1711
|
+
res.write("\n");
|
|
1712
|
+
}
|
|
1713
|
+
function writeSseEventRaw(res, event) {
|
|
1714
|
+
if (event.id) res.write(`id: ${sanitizeSseField(event.id)}
|
|
1715
|
+
`);
|
|
1716
|
+
if (event.event) res.write(`event: ${sanitizeSseField(event.event)}
|
|
1717
|
+
`);
|
|
1718
|
+
if (event.retry) res.write(`retry: ${sanitizeSseField(event.retry)}
|
|
1719
|
+
`);
|
|
1720
|
+
if (event.data !== void 0) {
|
|
1721
|
+
for (const line of event.data.split("\n")) {
|
|
1722
|
+
res.write(`data: ${line}
|
|
1723
|
+
`);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
res.write("\n");
|
|
1727
|
+
}
|
|
1728
|
+
function parseSseEvents(buffer) {
|
|
1729
|
+
const events = [];
|
|
1730
|
+
const parts = buffer.split("\n\n");
|
|
1731
|
+
const remaining = parts.pop() ?? "";
|
|
1732
|
+
for (const part of parts) {
|
|
1733
|
+
if (part.trim() === "") continue;
|
|
1734
|
+
const event = {};
|
|
1735
|
+
for (const line of part.split("\n")) {
|
|
1736
|
+
if (line.startsWith("event:")) {
|
|
1737
|
+
event.event = line.substring(6).trim();
|
|
1738
|
+
} else if (line.startsWith("data:")) {
|
|
1739
|
+
const val = line.substring(5).trimStart();
|
|
1740
|
+
event.data = event.data !== void 0 ? event.data + "\n" + val : val;
|
|
1741
|
+
} else if (line.startsWith("id:")) {
|
|
1742
|
+
event.id = line.substring(3).trim();
|
|
1743
|
+
} else if (line.startsWith("retry:")) {
|
|
1744
|
+
event.retry = line.substring(6).trim();
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
events.push(event);
|
|
1748
|
+
}
|
|
1749
|
+
return { parsed: events, remaining };
|
|
1750
|
+
}
|
|
1751
|
+
async function readBody(req) {
|
|
1752
|
+
return new Promise((resolve, reject) => {
|
|
1753
|
+
const chunks = [];
|
|
1754
|
+
let totalSize = 0;
|
|
1755
|
+
req.on("data", (chunk) => {
|
|
1756
|
+
totalSize += chunk.length;
|
|
1757
|
+
if (totalSize > MAX_REQUEST_BODY) {
|
|
1758
|
+
req.destroy();
|
|
1759
|
+
reject(new Error(`Request body exceeds ${MAX_REQUEST_BODY} bytes limit`));
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
chunks.push(chunk);
|
|
1763
|
+
});
|
|
1764
|
+
req.on("end", () => {
|
|
1765
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
1766
|
+
resolve(body.length > 0 ? body : null);
|
|
1767
|
+
});
|
|
1768
|
+
req.on("error", () => resolve(null));
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// src/index.ts
|
|
1773
|
+
function resolvePolicy(configPath, failOpen) {
|
|
1774
|
+
let policy = getDefaultPolicy();
|
|
1775
|
+
if (configPath) {
|
|
1776
|
+
try {
|
|
1777
|
+
policy = loadPolicy(configPath);
|
|
1778
|
+
} catch (err) {
|
|
1779
|
+
console.error(`[mcp-guard] Failed to load policy: ${err instanceof Error ? err.message : String(err)}`);
|
|
1780
|
+
process.exit(1);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
if (failOpen) {
|
|
1784
|
+
policy.failMode = "open";
|
|
1785
|
+
}
|
|
1786
|
+
return policy;
|
|
1787
|
+
}
|
|
1788
|
+
var program = new Command();
|
|
1789
|
+
program.name("mcp-guard").description("MCP runtime security proxy \u2014 intercepts and enforces security policies on MCP tool calls").version("0.2.0");
|
|
1790
|
+
program.option("-c, --config <path>", "Path to policy YAML file").option("-v, --verbose", "Enable debug logging", false).option("--fail-open", "Allow traffic on policy errors (NOT recommended)", false).option("--dry-run", "Log decisions but never block", false).argument("<server-command>", "MCP server command to proxy (stdio mode)").argument("[server-args...]", "Arguments for the MCP server").action(async (serverCommand, serverArgs, opts) => {
|
|
1791
|
+
try {
|
|
1792
|
+
const policy = resolvePolicy(opts["config"], opts["failOpen"]);
|
|
1793
|
+
const rules = createRules(policy);
|
|
1794
|
+
if (rules.length === 0) console.error("[mcp-guard] Warning: no rules enabled");
|
|
1795
|
+
const proxy = new McpGuardProxy(
|
|
1796
|
+
{
|
|
1797
|
+
mode: "stdio",
|
|
1798
|
+
serverCommand,
|
|
1799
|
+
serverArgs,
|
|
1800
|
+
verbose: opts["verbose"],
|
|
1801
|
+
failOpen: opts["failOpen"],
|
|
1802
|
+
dryRun: opts["dryRun"],
|
|
1803
|
+
configPath: opts["config"]
|
|
1804
|
+
},
|
|
1805
|
+
policy,
|
|
1806
|
+
rules
|
|
1807
|
+
);
|
|
1808
|
+
await proxy.start();
|
|
1809
|
+
} catch (err) {
|
|
1810
|
+
console.error(`[mcp-guard] Fatal: ${err instanceof Error ? err.message : String(err)}`);
|
|
1811
|
+
process.exit(1);
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1814
|
+
program.command("http").description("Run as HTTP reverse proxy between MCP client and a remote MCP server").requiredOption("-u, --upstream <url>", "Upstream MCP server URL (e.g. http://localhost:8080/mcp)").option("-p, --port <number>", "Port to listen on", "9090").option("-H, --host <host>", "Host to bind to", "127.0.0.1").option("-c, --config <path>", "Path to policy YAML file").option("-v, --verbose", "Enable debug logging", false).option("--fail-open", "Allow traffic on policy errors (NOT recommended)", false).option("--dry-run", "Log decisions but never block", false).action(async (opts) => {
|
|
1815
|
+
try {
|
|
1816
|
+
const policy = resolvePolicy(opts["config"], opts["failOpen"]);
|
|
1817
|
+
const rules = createRules(policy);
|
|
1818
|
+
if (rules.length === 0) console.error("[mcp-guard] Warning: no rules enabled");
|
|
1819
|
+
const proxy = new McpHttpProxy(
|
|
1820
|
+
{
|
|
1821
|
+
mode: "http",
|
|
1822
|
+
upstream: opts["upstream"],
|
|
1823
|
+
port: parseInt(opts["port"], 10),
|
|
1824
|
+
host: opts["host"] ?? "127.0.0.1",
|
|
1825
|
+
verbose: opts["verbose"],
|
|
1826
|
+
failOpen: opts["failOpen"],
|
|
1827
|
+
dryRun: opts["dryRun"],
|
|
1828
|
+
configPath: opts["config"]
|
|
1829
|
+
},
|
|
1830
|
+
policy,
|
|
1831
|
+
rules
|
|
1832
|
+
);
|
|
1833
|
+
await proxy.start();
|
|
1834
|
+
const handleSignal = () => {
|
|
1835
|
+
proxy.stop().then(() => process.exit(0));
|
|
1836
|
+
};
|
|
1837
|
+
process.on("SIGTERM", handleSignal);
|
|
1838
|
+
process.on("SIGINT", handleSignal);
|
|
1839
|
+
} catch (err) {
|
|
1840
|
+
console.error(`[mcp-guard] Fatal: ${err instanceof Error ? err.message : String(err)}`);
|
|
1841
|
+
process.exit(1);
|
|
1842
|
+
}
|
|
1843
|
+
});
|
|
1844
|
+
program.parse();
|