@streamblur/mcp 0.1.0 → 1.1.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 +76 -24
- package/dist/src/index.js +505 -70
- package/dist/src/index.js.map +1 -1
- package/dist/src/patterns.js +21 -1
- package/dist/src/patterns.js.map +1 -1
- package/package.json +12 -4
- package/src/index.ts +509 -74
- package/src/patterns.ts +24 -1
package/dist/src/index.js
CHANGED
|
@@ -2,68 +2,271 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const node_child_process_1 = require("node:child_process");
|
|
7
|
+
const node_os_1 = require("node:os");
|
|
5
8
|
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
6
9
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
7
10
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
8
11
|
const redact_1 = require("./redact");
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
// ─── Pro License Validation ────────────────────────────────────────────────
|
|
13
|
+
const LICENSE_KEY = process.env.STREAMBLUR_LICENSE_KEY ?? "";
|
|
14
|
+
let proValidated = null; // null = not yet checked
|
|
15
|
+
async function checkProLicense(email) {
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch("https://streamblur.com/.netlify/functions/check-pro", {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
body: JSON.stringify({ email }),
|
|
21
|
+
signal: AbortSignal.timeout(5000)
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok)
|
|
24
|
+
return false;
|
|
25
|
+
const data = await res.json();
|
|
26
|
+
return data.isPro === true;
|
|
15
27
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function isPro() {
|
|
33
|
+
if (proValidated !== null)
|
|
34
|
+
return proValidated;
|
|
35
|
+
if (!LICENSE_KEY) {
|
|
36
|
+
proValidated = false;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
// LICENSE_KEY can be either an email (Pro account) or a license key string
|
|
40
|
+
// Try email format first, then fall back to key-based check
|
|
41
|
+
const isEmail = LICENSE_KEY.includes("@");
|
|
42
|
+
if (isEmail) {
|
|
43
|
+
proValidated = await checkProLicense(LICENSE_KEY);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
// License key format: check against validate-license endpoint
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch("https://streamblur.com/.netlify/functions/validate-license", {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify({ licenseKey: LICENSE_KEY }),
|
|
52
|
+
signal: AbortSignal.timeout(5000)
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
proValidated = false;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
proValidated = data.valid === true || data.isPro === true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
proValidated = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return proValidated;
|
|
67
|
+
}
|
|
68
|
+
// ─── Directory Scanner (Pro) ───────────────────────────────────────────────
|
|
69
|
+
const SCANNABLE_EXTENSIONS = new Set([
|
|
70
|
+
".env", ".txt", ".js", ".ts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
|
|
71
|
+
".toml", ".ini", ".cfg", ".conf", ".sh", ".bash", ".zsh", ".py", ".rb",
|
|
72
|
+
".go", ".rs", ".java", ".kt", ".php", ".cs", ".cpp", ".c", ".h",
|
|
73
|
+
".tf", ".hcl", ".properties", ".xml", ".md", ".mdx"
|
|
74
|
+
]);
|
|
75
|
+
const IGNORED_DIRS = new Set([
|
|
76
|
+
"node_modules", ".git", ".next", "dist", "build", "out", ".cache",
|
|
77
|
+
"coverage", ".turbo", "vendor", "__pycache__", ".venv", "venv"
|
|
78
|
+
]);
|
|
79
|
+
function scanDirectory(dirPath, maxFiles = 500) {
|
|
80
|
+
const results = [];
|
|
81
|
+
let fileCount = 0;
|
|
82
|
+
function walk(current) {
|
|
83
|
+
if (fileCount >= maxFiles)
|
|
84
|
+
return;
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = (0, node_fs_1.readdirSync)(current, { withFileTypes: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (fileCount >= maxFiles)
|
|
94
|
+
break;
|
|
95
|
+
const fullPath = (0, node_path_1.join)(current, entry.name);
|
|
96
|
+
if (entry.isDirectory()) {
|
|
97
|
+
if (!IGNORED_DIRS.has(entry.name))
|
|
98
|
+
walk(fullPath);
|
|
99
|
+
}
|
|
100
|
+
else if (entry.isFile()) {
|
|
101
|
+
const ext = (0, node_path_1.extname)(entry.name).toLowerCase();
|
|
102
|
+
const isEnvFile = entry.name.startsWith(".env");
|
|
103
|
+
if (!SCANNABLE_EXTENSIONS.has(ext) && !isEnvFile)
|
|
104
|
+
continue;
|
|
105
|
+
let size = 0;
|
|
106
|
+
try {
|
|
107
|
+
size = (0, node_fs_1.statSync)(fullPath).size;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
continue;
|
|
33
111
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
112
|
+
if (size > 500000)
|
|
113
|
+
continue; // skip files > 500KB
|
|
114
|
+
fileCount++;
|
|
115
|
+
try {
|
|
116
|
+
const content = (0, node_fs_1.readFileSync)(fullPath, "utf8");
|
|
117
|
+
const detections = (0, redact_1.scanText)(content);
|
|
118
|
+
if (detections.length > 0) {
|
|
119
|
+
const lines = content.split("\n");
|
|
120
|
+
const mapped = detections.map(d => {
|
|
121
|
+
let chars = 0;
|
|
122
|
+
let lineNum = 1;
|
|
123
|
+
let col = d.start;
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (chars + line.length >= d.start) {
|
|
126
|
+
col = d.start - chars;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
chars += line.length + 1;
|
|
130
|
+
lineNum++;
|
|
131
|
+
}
|
|
132
|
+
return { type: d.type, line: lineNum, column: col };
|
|
133
|
+
});
|
|
134
|
+
results.push({ file: fullPath, detections: mapped });
|
|
135
|
+
}
|
|
48
136
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
name: "scan_text",
|
|
52
|
-
description: "Scans text and returns a list of detected secrets with type and position",
|
|
53
|
-
inputSchema: {
|
|
54
|
-
type: "object",
|
|
55
|
-
properties: {
|
|
56
|
-
text: {
|
|
57
|
-
type: "string",
|
|
58
|
-
description: "Text to scan"
|
|
59
|
-
}
|
|
60
|
-
},
|
|
61
|
-
required: ["text"],
|
|
62
|
-
additionalProperties: false
|
|
137
|
+
catch {
|
|
138
|
+
// skip unreadable files
|
|
63
139
|
}
|
|
64
140
|
}
|
|
65
|
-
|
|
66
|
-
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
walk(dirPath);
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
// ─── Server Setup ──────────────────────────────────────────────────────────
|
|
147
|
+
const server = new index_js_1.Server({ name: "streamblur-mcp", version: "1.1.0" }, { capabilities: { tools: {} } });
|
|
148
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
149
|
+
const proActive = await isPro();
|
|
150
|
+
const tools = [
|
|
151
|
+
{
|
|
152
|
+
name: "redact_text",
|
|
153
|
+
description: "Redacts API keys, tokens, passwords, and credentials from text. Replaces each match with [REDACTED:type].",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: {
|
|
157
|
+
text: { type: "string", description: "Text to redact" }
|
|
158
|
+
},
|
|
159
|
+
required: ["text"],
|
|
160
|
+
additionalProperties: false
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "scan_text",
|
|
165
|
+
description: "Scans text and returns detected secrets with type and character position. Use this to audit content before sharing.",
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: "object",
|
|
168
|
+
properties: {
|
|
169
|
+
text: { type: "string", description: "Text to scan" }
|
|
170
|
+
},
|
|
171
|
+
required: ["text"],
|
|
172
|
+
additionalProperties: false
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "redact_file",
|
|
177
|
+
description: proActive
|
|
178
|
+
? "Reads a file and returns redacted content. Supports .env, config files, source code, and more. File is not modified."
|
|
179
|
+
: "⚡ Pro feature — reads a file and returns redacted content. Set STREAMBLUR_LICENSE_KEY to your StreamBlur Pro email or license key to unlock. Get Pro at https://streamblur.com/pricing",
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: "object",
|
|
182
|
+
properties: {
|
|
183
|
+
path: { type: "string", description: "Absolute or relative path to file" }
|
|
184
|
+
},
|
|
185
|
+
required: ["path"],
|
|
186
|
+
additionalProperties: false
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "scan_directory",
|
|
191
|
+
description: proActive
|
|
192
|
+
? "Recursively scans a directory for exposed secrets across all source files. Returns file paths, secret types, and line numbers. Skips node_modules, .git, dist, and build folders."
|
|
193
|
+
: "⚡ Pro feature — recursively scans a directory for leaked secrets. Set STREAMBLUR_LICENSE_KEY to your StreamBlur Pro email or license key to unlock. Get Pro at https://streamblur.com/pricing",
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: "object",
|
|
196
|
+
properties: {
|
|
197
|
+
path: { type: "string", description: "Directory path to scan" },
|
|
198
|
+
max_files: { type: "number", description: "Max files to scan (default 500)" }
|
|
199
|
+
},
|
|
200
|
+
required: ["path"],
|
|
201
|
+
additionalProperties: false
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: "scan_repo",
|
|
206
|
+
description: proActive
|
|
207
|
+
? "Clones a GitHub repository to a temp directory, scans all source files for exposed secrets, returns findings with file paths and line numbers, then deletes the temp clone. Pro feature."
|
|
208
|
+
: "Clones a GitHub repo and scans all files for leaked secrets. Pro feature - set STREAMBLUR_LICENSE_KEY to unlock.",
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: "object",
|
|
211
|
+
properties: {
|
|
212
|
+
repo_url: { type: "string", description: "GitHub repo URL (e.g. https://github.com/owner/repo)" },
|
|
213
|
+
max_files: { type: "number", description: "Max files to scan (default 300)" }
|
|
214
|
+
},
|
|
215
|
+
required: ["repo_url"],
|
|
216
|
+
additionalProperties: false
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "audit_env_file",
|
|
221
|
+
description: "Reads a .env file and returns a full security report: detected secrets, formatting issues, placeholder values, and rotation recommendations. File is not modified.",
|
|
222
|
+
inputSchema: {
|
|
223
|
+
type: "object",
|
|
224
|
+
properties: {
|
|
225
|
+
path: { type: "string", description: "Path to .env file" }
|
|
226
|
+
},
|
|
227
|
+
required: ["path"],
|
|
228
|
+
additionalProperties: false
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: "check_gitignore",
|
|
233
|
+
description: "Checks a project directory .gitignore to verify that .env files, key files, and secret directories are properly excluded. Returns a security gap report.",
|
|
234
|
+
inputSchema: {
|
|
235
|
+
type: "object",
|
|
236
|
+
properties: {
|
|
237
|
+
path: { type: "string", description: "Project root directory path" }
|
|
238
|
+
},
|
|
239
|
+
required: ["path"],
|
|
240
|
+
additionalProperties: false
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: "explain_detection",
|
|
245
|
+
description: "Given a detected secret type (e.g. stripe_secret_live, aws_access_key), explains what it is, the blast radius if leaked, and exactly where to go to revoke and rotate it immediately.",
|
|
246
|
+
inputSchema: {
|
|
247
|
+
type: "object",
|
|
248
|
+
properties: {
|
|
249
|
+
type: { type: "string", description: "Secret type string from a scan result (e.g. openai_api_key)" }
|
|
250
|
+
},
|
|
251
|
+
required: ["type"],
|
|
252
|
+
additionalProperties: false
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: "generate_env_template",
|
|
257
|
+
description: "Generates a safe .env.example template with placeholder values and security comments for common project types.",
|
|
258
|
+
inputSchema: {
|
|
259
|
+
type: "object",
|
|
260
|
+
properties: {
|
|
261
|
+
project_type: { type: "string", description: "Project type: nextjs, rails, django, express, nuxt, sveltekit" },
|
|
262
|
+
services: { type: "array", items: { type: "string" }, description: "Additional services: stripe, openai, anthropic, supabase, firebase, aws, sendgrid, twilio" }
|
|
263
|
+
},
|
|
264
|
+
required: ["project_type"],
|
|
265
|
+
additionalProperties: false
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
];
|
|
269
|
+
return { tools };
|
|
67
270
|
});
|
|
68
271
|
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
69
272
|
const args = request.params.arguments ?? {};
|
|
@@ -73,33 +276,265 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
73
276
|
if (typeof text !== "string") {
|
|
74
277
|
throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "'text' must be a string");
|
|
75
278
|
}
|
|
76
|
-
return {
|
|
77
|
-
|
|
78
|
-
|
|
279
|
+
return { content: [{ type: "text", text: (0, redact_1.redactText)(text) }] };
|
|
280
|
+
}
|
|
281
|
+
case "scan_text": {
|
|
282
|
+
const text = args.text;
|
|
283
|
+
if (typeof text !== "string") {
|
|
284
|
+
throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "'text' must be a string");
|
|
285
|
+
}
|
|
286
|
+
const detections = (0, redact_1.scanText)(text).map(d => ({
|
|
287
|
+
type: d.type,
|
|
288
|
+
start: d.start,
|
|
289
|
+
end: d.end
|
|
290
|
+
}));
|
|
291
|
+
return { content: [{ type: "text", text: JSON.stringify(detections, null, 2) }] };
|
|
79
292
|
}
|
|
80
293
|
case "redact_file": {
|
|
294
|
+
const proActive = await isPro();
|
|
295
|
+
if (!proActive) {
|
|
296
|
+
return {
|
|
297
|
+
content: [{
|
|
298
|
+
type: "text",
|
|
299
|
+
text: "⚡ redact_file is a StreamBlur Pro feature.\n\nSet the STREAMBLUR_LICENSE_KEY environment variable to your StreamBlur Pro email or license key.\n\nGet Pro at https://streamblur.com/pricing — $2.99 one-time."
|
|
300
|
+
}]
|
|
301
|
+
};
|
|
302
|
+
}
|
|
81
303
|
const path = args.path;
|
|
82
304
|
if (typeof path !== "string") {
|
|
83
305
|
throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "'path' must be a string");
|
|
84
306
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
content: [{ type: "text", text: (0, redact_1.redactText)(content) }]
|
|
88
|
-
}
|
|
307
|
+
try {
|
|
308
|
+
const content = (0, node_fs_1.readFileSync)(path, "utf8");
|
|
309
|
+
return { content: [{ type: "text", text: (0, redact_1.redactText)(content) }] };
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
313
|
+
throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Could not read file: ${msg}`);
|
|
314
|
+
}
|
|
89
315
|
}
|
|
90
|
-
case "
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
|
|
316
|
+
case "scan_directory": {
|
|
317
|
+
const proActive = await isPro();
|
|
318
|
+
if (!proActive) {
|
|
319
|
+
return {
|
|
320
|
+
content: [{
|
|
321
|
+
type: "text",
|
|
322
|
+
text: "⚡ scan_directory is a StreamBlur Pro feature.\n\nSet the STREAMBLUR_LICENSE_KEY environment variable to your StreamBlur Pro email or license key.\n\nGet Pro at https://streamblur.com/pricing — $2.99 one-time."
|
|
323
|
+
}]
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const dirPath = args.path;
|
|
327
|
+
if (typeof dirPath !== "string") {
|
|
328
|
+
throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "'path' must be a string");
|
|
329
|
+
}
|
|
330
|
+
const maxFiles = typeof args.max_files === "number" ? args.max_files : 500;
|
|
331
|
+
try {
|
|
332
|
+
const results = scanDirectory(dirPath, maxFiles);
|
|
333
|
+
if (results.length === 0) {
|
|
334
|
+
return { content: [{ type: "text", text: "✅ No secrets detected in directory." }] };
|
|
335
|
+
}
|
|
336
|
+
const summary = results.map(r => `${r.file}\n${r.detections.map(d => ` Line ${d.line}:${d.column} — ${d.type}`).join("\n")}`).join("\n\n");
|
|
337
|
+
return {
|
|
338
|
+
content: [{
|
|
339
|
+
type: "text",
|
|
340
|
+
text: `⚠️ Found secrets in ${results.length} file(s):\n\n${summary}`
|
|
341
|
+
}]
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
346
|
+
throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Could not scan directory: ${msg}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
case "scan_repo": {
|
|
350
|
+
const proActive = await isPro();
|
|
351
|
+
if (!proActive) {
|
|
352
|
+
return {
|
|
353
|
+
content: [{
|
|
354
|
+
type: "text",
|
|
355
|
+
text: "scan_repo is a StreamBlur Pro feature.\n\nSet STREAMBLUR_LICENSE_KEY to your Pro email or license key.\nGet Pro at https://streamblur.com/pricing - $2.99 one-time."
|
|
356
|
+
}]
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const repoUrl = args.repo_url;
|
|
360
|
+
if (typeof repoUrl !== "string" || !repoUrl.startsWith("http")) {
|
|
361
|
+
throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "'repo_url' must be a valid GitHub URL");
|
|
362
|
+
}
|
|
363
|
+
const maxFiles = typeof args.max_files === "number" ? args.max_files : 300;
|
|
364
|
+
const tmpDir = (0, node_path_1.join)((0, node_os_1.tmpdir)(), "streamblur-scan-" + Date.now());
|
|
365
|
+
try {
|
|
366
|
+
(0, node_fs_1.mkdirSync)(tmpDir, { recursive: true });
|
|
367
|
+
(0, node_child_process_1.execSync)(`git clone --depth 1 "${repoUrl}" "${tmpDir}"`, { timeout: 60000, stdio: "pipe" });
|
|
368
|
+
const results = scanDirectory(tmpDir, maxFiles);
|
|
369
|
+
if (results.length === 0) {
|
|
370
|
+
return { content: [{ type: "text", text: `No secrets detected in ${repoUrl}` }] };
|
|
371
|
+
}
|
|
372
|
+
const summary = results.map(r => `${r.file.replace(tmpDir + "/", "")}\n${r.detections.map((d) => ` Line ${d.line}:${d.column} - ${d.type}`).join("\n")}`).join("\n\n");
|
|
373
|
+
return {
|
|
374
|
+
content: [{
|
|
375
|
+
type: "text",
|
|
376
|
+
text: `Found secrets in ${results.length} file(s) in ${repoUrl}:\n\n${summary}`
|
|
377
|
+
}]
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
382
|
+
throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Repo scan failed: ${msg}`);
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
try {
|
|
386
|
+
(0, node_fs_1.rmSync)(tmpDir, { recursive: true, force: true });
|
|
387
|
+
}
|
|
388
|
+
catch { /* cleanup */ }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
case "audit_env_file": {
|
|
392
|
+
const filePath = args.path;
|
|
393
|
+
if (typeof filePath !== "string") {
|
|
394
|
+
throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "'path' must be a string");
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const content = (0, node_fs_1.readFileSync)(filePath, "utf8");
|
|
398
|
+
const lines = content.split("\n");
|
|
399
|
+
const issues = [];
|
|
400
|
+
const secrets = [];
|
|
401
|
+
const placeholders = [];
|
|
402
|
+
for (let i = 0; i < lines.length; i++) {
|
|
403
|
+
const line = lines[i].trim();
|
|
404
|
+
if (!line || line.startsWith("#"))
|
|
405
|
+
continue;
|
|
406
|
+
const eqIdx = line.indexOf("=");
|
|
407
|
+
if (eqIdx === -1) {
|
|
408
|
+
issues.push(`Line ${i + 1}: Missing = sign: ${line}`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const key = line.slice(0, eqIdx).trim();
|
|
412
|
+
const val = line.slice(eqIdx + 1).trim();
|
|
413
|
+
if (/\s/.test(key))
|
|
414
|
+
issues.push(`Line ${i + 1}: Key has spaces: ${key}`);
|
|
415
|
+
if (val === "" || val === '""' || val === "''")
|
|
416
|
+
issues.push(`Line ${i + 1}: Empty value for ${key}`);
|
|
417
|
+
if (/^(your[_-]|xxx|placeholder|changeme|todo|replace|insert|example)/i.test(val.replace(/['"]/g, ""))) {
|
|
418
|
+
placeholders.push(`Line ${i + 1}: ${key} has placeholder value`);
|
|
419
|
+
}
|
|
420
|
+
const detected = (0, redact_1.scanText)(line);
|
|
421
|
+
if (detected.length > 0) {
|
|
422
|
+
secrets.push(`Line ${i + 1}: ${key} - detected as ${detected.map((d) => d.type).join(", ")}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
let report = `Env File Audit: ${filePath}\n${"=".repeat(40)}\n`;
|
|
426
|
+
report += `Lines: ${lines.length} | Variables: ${lines.filter(l => l.includes("=") && !l.startsWith("#")).length}\n\n`;
|
|
427
|
+
if (secrets.length > 0)
|
|
428
|
+
report += `SECRETS DETECTED (${secrets.length}):\n${secrets.join("\n")}\n\n`;
|
|
429
|
+
if (issues.length > 0)
|
|
430
|
+
report += `FORMAT ISSUES (${issues.length}):\n${issues.join("\n")}\n\n`;
|
|
431
|
+
if (placeholders.length > 0)
|
|
432
|
+
report += `PLACEHOLDERS (${placeholders.length}):\n${placeholders.join("\n")}\n\n`;
|
|
433
|
+
if (secrets.length === 0 && issues.length === 0)
|
|
434
|
+
report += `No issues found. File looks clean.\n`;
|
|
435
|
+
report += `\nReminder: ensure this file is in .gitignore and never committed to version control.`;
|
|
436
|
+
return { content: [{ type: "text", text: report }] };
|
|
437
|
+
}
|
|
438
|
+
catch (err) {
|
|
439
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
440
|
+
throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Could not read file: ${msg}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
case "check_gitignore": {
|
|
444
|
+
const dirPath = args.path;
|
|
445
|
+
if (typeof dirPath !== "string") {
|
|
446
|
+
throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "'path' must be a string");
|
|
447
|
+
}
|
|
448
|
+
const gitignorePath = (0, node_path_1.join)(dirPath, ".gitignore");
|
|
449
|
+
const mustIgnore = [".env", ".env.local", ".env.production", ".env.*.local", "*.pem", "*.key", ".secret"];
|
|
450
|
+
const shouldIgnore = [".env.development", ".env.staging", "secrets/", "credentials/"];
|
|
451
|
+
try {
|
|
452
|
+
if (!(0, node_fs_1.existsSync)(gitignorePath)) {
|
|
453
|
+
return {
|
|
454
|
+
content: [{
|
|
455
|
+
type: "text",
|
|
456
|
+
text: `No .gitignore found in ${dirPath}.\n\nCRITICAL: Create a .gitignore immediately and add:\n${mustIgnore.join("\n")}`
|
|
457
|
+
}]
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
const content = (0, node_fs_1.readFileSync)(gitignorePath, "utf8");
|
|
461
|
+
const missing = mustIgnore.filter(p => !content.includes(p));
|
|
462
|
+
const suggested = shouldIgnore.filter(p => !content.includes(p));
|
|
463
|
+
let report = `.gitignore Audit: ${gitignorePath}\n${"=".repeat(40)}\n`;
|
|
464
|
+
if (missing.length === 0) {
|
|
465
|
+
report += `All critical patterns are covered.\n`;
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
report += `CRITICAL - Missing patterns (add these NOW):\n${missing.map(p => ` ${p}`).join("\n")}\n\n`;
|
|
469
|
+
}
|
|
470
|
+
if (suggested.length > 0) {
|
|
471
|
+
report += `Recommended additions:\n${suggested.map(p => ` ${p}`).join("\n")}\n`;
|
|
472
|
+
}
|
|
473
|
+
return { content: [{ type: "text", text: report }] };
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
477
|
+
throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Could not check gitignore: ${msg}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
case "explain_detection": {
|
|
481
|
+
const detectionType = args.type;
|
|
482
|
+
if (typeof detectionType !== "string") {
|
|
483
|
+
throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, "'type' must be a string");
|
|
484
|
+
}
|
|
485
|
+
const explanations = {
|
|
486
|
+
openai_api_key: { name: "OpenAI API Key", risk: "HIGH - Full API access. Attacker can run unlimited GPT-4 requests at your expense. Costs can hit thousands of dollars within hours.", action: "1. Go to platform.openai.com/api-keys\n2. Delete the exposed key immediately\n3. Create a new key\n4. Update all services using the old key", url: "https://platform.openai.com/api-keys" },
|
|
487
|
+
anthropic_api_key: { name: "Anthropic API Key", risk: "HIGH - Full Claude API access. Unauthorized usage billed to your account.", action: "1. Go to console.anthropic.com/settings/keys\n2. Delete the exposed key\n3. Generate a new key\n4. Update environment variables", url: "https://console.anthropic.com/settings/keys" },
|
|
488
|
+
stripe_secret_live: { name: "Stripe Live Secret Key", risk: "CRITICAL - Full access to your Stripe account. Attacker can create charges, issue refunds, access customer data, and drain your balance.", action: "1. Go to dashboard.stripe.com/apikeys\n2. Roll (rotate) the key immediately\n3. Update all integrations", url: "https://dashboard.stripe.com/apikeys" },
|
|
489
|
+
aws_access_key: { name: "AWS Access Key ID", risk: "CRITICAL - Combined with secret key, gives full AWS account access. Can spin up infrastructure, access S3 data, and incur massive costs.", action: "1. Go to AWS IAM console\n2. Deactivate the key immediately\n3. Create new credentials\n4. Check CloudTrail for unauthorized activity", url: "https://console.aws.amazon.com/iam/home#/security_credentials" },
|
|
490
|
+
github_pat: { name: "GitHub Personal Access Token", risk: "HIGH - Can access private repos, commit code, read secrets in Actions, and depending on scope, full org access.", action: "1. Go to github.com/settings/tokens\n2. Delete the exposed token immediately\n3. Audit recent activity on affected repos", url: "https://github.com/settings/tokens" },
|
|
491
|
+
discord_bot_token: { name: "Discord Bot Token", risk: "MEDIUM-HIGH - Full control of the bot. Can read all messages the bot has access to, send messages as the bot, manage channels.", action: "1. Go to discord.com/developers/applications\n2. Select your app > Bot > Reset Token\n3. Update your deployment with the new token", url: "https://discord.com/developers/applications" },
|
|
492
|
+
stripe_secret_test: { name: "Stripe Test Secret Key", risk: "LOW - Test mode only, no real money. Best practice to rotate anyway.", action: "1. Go to dashboard.stripe.com/apikeys\n2. Roll the test key", url: "https://dashboard.stripe.com/apikeys" },
|
|
493
|
+
google_api_key: { name: "Google API Key", risk: "MEDIUM-HIGH - Depending on enabled APIs, can incur costs or expose data.", action: "1. Go to console.cloud.google.com/apis/credentials\n2. Delete or restrict the key\n3. Create a new restricted key", url: "https://console.cloud.google.com/apis/credentials" },
|
|
494
|
+
huggingface_token: { name: "Hugging Face Token", risk: "MEDIUM - Access to models, datasets, and spaces under your account.", action: "1. Go to huggingface.co/settings/tokens\n2. Delete the exposed token\n3. Create a new one", url: "https://huggingface.co/settings/tokens" },
|
|
495
|
+
supabase_service_role_key: { name: "Supabase Service Role Key", risk: "CRITICAL - Bypasses Row Level Security entirely. Full read/write access to all database tables.", action: "1. Go to supabase.com/dashboard > Project Settings > API\n2. Rotate the service role key\n3. Update all server-side integrations", url: "https://supabase.com/dashboard" },
|
|
496
|
+
};
|
|
497
|
+
const info = explanations[detectionType];
|
|
498
|
+
if (!info) {
|
|
499
|
+
return {
|
|
500
|
+
content: [{
|
|
501
|
+
type: "text",
|
|
502
|
+
text: `Detection type: ${detectionType}\n\nNo specific guidance available for this type yet.\n\nGeneral advice:\n- Treat it as potentially sensitive\n- Search your provider dashboard for API keys or tokens section\n- Revoke/rotate the value immediately\n- Check logs for unauthorized usage\n- Update all services using the old value`
|
|
503
|
+
}]
|
|
504
|
+
};
|
|
94
505
|
}
|
|
95
|
-
const detections = (0, redact_1.scanText)(text).map((detection) => ({
|
|
96
|
-
type: detection.type,
|
|
97
|
-
start: detection.start,
|
|
98
|
-
end: detection.end
|
|
99
|
-
}));
|
|
100
506
|
return {
|
|
101
|
-
content: [{
|
|
507
|
+
content: [{
|
|
508
|
+
type: "text",
|
|
509
|
+
text: `${info.name}\n${"=".repeat(info.name.length)}\n\nRisk: ${info.risk}\n\nImmediate action:\n${info.action}\n\nDashboard: ${info.url}`
|
|
510
|
+
}]
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
case "generate_env_template": {
|
|
514
|
+
const projectType = (args.project_type || "nextjs").toLowerCase();
|
|
515
|
+
const services = Array.isArray(args.services) ? args.services : [];
|
|
516
|
+
const templates = {
|
|
517
|
+
nextjs: `# Next.js Environment Variables\n# Copy to .env.local - NEVER commit .env.local to git\n\n# App\nNEXT_PUBLIC_APP_URL=http://localhost:3000\nNEXT_PUBLIC_APP_NAME=your-app-name\n\n# Database\nDATABASE_URL=postgresql://user:password@localhost:5432/dbname`,
|
|
518
|
+
express: `# Express Environment Variables\n# Copy to .env - NEVER commit .env to git\n\nNODE_ENV=development\nPORT=3000\n\n# Database\nDATABASE_URL=postgresql://user:password@localhost:5432/dbname\n\n# Session\nSESSION_SECRET=replace-with-random-32-char-string`,
|
|
519
|
+
django: `# Django Environment Variables\n# Copy to .env - NEVER commit .env to git\n\nDJANGO_SECRET_KEY=replace-with-50-char-random-string\nDEBUG=True\nALLOWED_HOSTS=localhost,127.0.0.1\n\n# Database\nDATABASE_URL=postgresql://user:password@localhost:5432/dbname`,
|
|
520
|
+
rails: `# Rails Environment Variables\n# Copy to .env - NEVER commit .env to git\n\nRAILS_ENV=development\nSECRET_KEY_BASE=replace-with-rails-credentials-output\n\n# Database\nDATABASE_URL=postgresql://user:password@localhost:5432/dbname`,
|
|
521
|
+
nuxt: `# Nuxt Environment Variables\n# Copy to .env - NEVER commit .env to git\n\nNUXT_PUBLIC_SITE_URL=http://localhost:3000\nNUXT_SECRET_KEY=replace-with-random-32-char-string`,
|
|
522
|
+
sveltekit: `# SvelteKit Environment Variables\n# Copy to .env - NEVER commit .env to git\n\nPUBLIC_APP_URL=http://localhost:5173\nPRIVATE_SECRET_KEY=replace-with-random-32-char-string`,
|
|
523
|
+
};
|
|
524
|
+
const serviceAdditions = {
|
|
525
|
+
stripe: `\n# Stripe\nSTRIPE_SECRET_KEY=sk_test_replace-with-your-stripe-test-key\nSTRIPE_PUBLISHABLE_KEY=pk_test_replace-with-your-stripe-publishable-key\nSTRIPE_WEBHOOK_SECRET=whsec_replace-with-your-webhook-secret`,
|
|
526
|
+
openai: `\n# OpenAI\nOPENAI_API_KEY=sk-proj-replace-with-your-openai-key`,
|
|
527
|
+
anthropic: `\n# Anthropic\nANTHROPIC_API_KEY=sk-ant-replace-with-your-anthropic-key`,
|
|
528
|
+
supabase: `\n# Supabase\nNEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co\nNEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ-replace-with-anon-key\nSUPABASE_SERVICE_ROLE_KEY=eyJ-replace-with-service-role-key-KEEP-SECRET`,
|
|
529
|
+
firebase: `\n# Firebase\nFIREBASE_PROJECT_ID=your-project-id\nFIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com\nFIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\\nreplace\\n-----END PRIVATE KEY-----\\n"`,
|
|
530
|
+
aws: `\n# AWS\nAWS_ACCESS_KEY_ID=AKIA-replace-with-your-key\nAWS_SECRET_ACCESS_KEY=replace-with-your-secret\nAWS_REGION=us-east-1`,
|
|
531
|
+
sendgrid: `\n# SendGrid\nSENDGRID_API_KEY=SG.replace-with-your-sendgrid-key`,
|
|
532
|
+
twilio: `\n# Twilio\nTWILIO_ACCOUNT_SID=AC-replace-with-your-account-sid\nTWILIO_AUTH_TOKEN=replace-with-your-auth-token`,
|
|
102
533
|
};
|
|
534
|
+
const base = templates[projectType] || templates["nextjs"];
|
|
535
|
+
const additions = services.map((s) => serviceAdditions[s] || "").join("");
|
|
536
|
+
const result = base + additions + "\n\n# Add this file to .gitignore:\n# echo '.env.local' >> .gitignore";
|
|
537
|
+
return { content: [{ type: "text", text: result }] };
|
|
103
538
|
}
|
|
104
539
|
default:
|
|
105
540
|
throw new types_js_1.McpError(types_js_1.ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
|