@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/src/index.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, readdirSync, statSync, mkdirSync, rmSync, existsSync } from "node:fs";
4
+ import { join, extname } from "node:path";
5
+ import { execSync } from "node:child_process";
6
+ import { tmpdir } from "node:os";
4
7
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
9
  import {
@@ -11,112 +14,544 @@ import {
11
14
  } from "@modelcontextprotocol/sdk/types.js";
12
15
  import { redactText, scanText } from "./redact";
13
16
 
14
- const server = new Server(
15
- {
16
- name: "streamblur-mcp",
17
- version: "0.1.0"
18
- },
19
- {
20
- capabilities: {
21
- tools: {}
17
+ // ─── Pro License Validation ────────────────────────────────────────────────
18
+
19
+ const LICENSE_KEY = process.env.STREAMBLUR_LICENSE_KEY ?? "";
20
+ let proValidated: boolean | null = null; // null = not yet checked
21
+
22
+ async function checkProLicense(email: string): Promise<boolean> {
23
+ try {
24
+ const res = await fetch("https://streamblur.com/.netlify/functions/check-pro", {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({ email }),
28
+ signal: AbortSignal.timeout(5000)
29
+ });
30
+ if (!res.ok) return false;
31
+ const data = await res.json() as { isPro?: boolean };
32
+ return data.isPro === true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ async function isPro(): Promise<boolean> {
39
+ if (proValidated !== null) return proValidated;
40
+ if (!LICENSE_KEY) {
41
+ proValidated = false;
42
+ return false;
43
+ }
44
+ // LICENSE_KEY can be either an email (Pro account) or a license key string
45
+ // Try email format first, then fall back to key-based check
46
+ const isEmail = LICENSE_KEY.includes("@");
47
+ if (isEmail) {
48
+ proValidated = await checkProLicense(LICENSE_KEY);
49
+ } else {
50
+ // License key format: check against validate-license endpoint
51
+ try {
52
+ const res = await fetch("https://streamblur.com/.netlify/functions/validate-license", {
53
+ method: "POST",
54
+ headers: { "Content-Type": "application/json" },
55
+ body: JSON.stringify({ licenseKey: LICENSE_KEY }),
56
+ signal: AbortSignal.timeout(5000)
57
+ });
58
+ if (!res.ok) {
59
+ proValidated = false;
60
+ } else {
61
+ const data = await res.json() as { valid?: boolean; isPro?: boolean };
62
+ proValidated = data.valid === true || data.isPro === true;
63
+ }
64
+ } catch {
65
+ proValidated = false;
66
+ }
67
+ }
68
+ return proValidated;
69
+ }
70
+
71
+ // ─── Directory Scanner (Pro) ───────────────────────────────────────────────
72
+
73
+ const SCANNABLE_EXTENSIONS = new Set([
74
+ ".env", ".txt", ".js", ".ts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
75
+ ".toml", ".ini", ".cfg", ".conf", ".sh", ".bash", ".zsh", ".py", ".rb",
76
+ ".go", ".rs", ".java", ".kt", ".php", ".cs", ".cpp", ".c", ".h",
77
+ ".tf", ".hcl", ".properties", ".xml", ".md", ".mdx"
78
+ ]);
79
+
80
+ const IGNORED_DIRS = new Set([
81
+ "node_modules", ".git", ".next", "dist", "build", "out", ".cache",
82
+ "coverage", ".turbo", "vendor", "__pycache__", ".venv", "venv"
83
+ ]);
84
+
85
+ interface DirectoryScanResult {
86
+ file: string;
87
+ detections: Array<{ type: string; line: number; column: number }>;
88
+ }
89
+
90
+ function scanDirectory(dirPath: string, maxFiles = 500): DirectoryScanResult[] {
91
+ const results: DirectoryScanResult[] = [];
92
+ let fileCount = 0;
93
+
94
+ function walk(current: string) {
95
+ if (fileCount >= maxFiles) return;
96
+
97
+ let entries;
98
+ try {
99
+ entries = readdirSync(current, { withFileTypes: true });
100
+ } catch {
101
+ return;
102
+ }
103
+
104
+ for (const entry of entries) {
105
+ if (fileCount >= maxFiles) break;
106
+ const fullPath = join(current, entry.name);
107
+
108
+ if (entry.isDirectory()) {
109
+ if (!IGNORED_DIRS.has(entry.name)) walk(fullPath);
110
+ } else if (entry.isFile()) {
111
+ const ext = extname(entry.name).toLowerCase();
112
+ const isEnvFile = entry.name.startsWith(".env");
113
+ if (!SCANNABLE_EXTENSIONS.has(ext) && !isEnvFile) continue;
114
+
115
+ let size = 0;
116
+ try { size = statSync(fullPath).size; } catch { continue; }
117
+ if (size > 500_000) continue; // skip files > 500KB
118
+
119
+ fileCount++;
120
+ try {
121
+ const content = readFileSync(fullPath, "utf8");
122
+ const detections = scanText(content);
123
+ if (detections.length > 0) {
124
+ const lines = content.split("\n");
125
+ const mapped = detections.map(d => {
126
+ let chars = 0;
127
+ let lineNum = 1;
128
+ let col = d.start;
129
+ for (const line of lines) {
130
+ if (chars + line.length >= d.start) {
131
+ col = d.start - chars;
132
+ break;
133
+ }
134
+ chars += line.length + 1;
135
+ lineNum++;
136
+ }
137
+ return { type: d.type, line: lineNum, column: col };
138
+ });
139
+ results.push({ file: fullPath, detections: mapped });
140
+ }
141
+ } catch {
142
+ // skip unreadable files
143
+ }
144
+ }
22
145
  }
23
146
  }
147
+
148
+ walk(dirPath);
149
+ return results;
150
+ }
151
+
152
+ // ─── Server Setup ──────────────────────────────────────────────────────────
153
+
154
+ const server = new Server(
155
+ { name: "streamblur-mcp", version: "1.1.0" },
156
+ { capabilities: { tools: {} } }
24
157
  );
25
158
 
26
159
  server.setRequestHandler(ListToolsRequestSchema, async () => {
27
- return {
28
- tools: [
29
- {
30
- name: "redact_text",
31
- description: "Redacts secrets from input text and replaces them with [REDACTED:type]",
32
- inputSchema: {
33
- type: "object",
34
- properties: {
35
- text: {
36
- type: "string",
37
- description: "Text to redact"
38
- }
39
- },
40
- required: ["text"],
41
- additionalProperties: false
42
- }
43
- },
44
- {
45
- name: "redact_file",
46
- description: "Reads a file and returns redacted content without modifying the original file",
47
- inputSchema: {
48
- type: "object",
49
- properties: {
50
- path: {
51
- type: "string",
52
- description: "Path to file"
53
- }
54
- },
55
- required: ["path"],
56
- additionalProperties: false
57
- }
58
- },
59
- {
60
- name: "scan_text",
61
- description: "Scans text and returns a list of detected secrets with type and position",
62
- inputSchema: {
63
- type: "object",
64
- properties: {
65
- text: {
66
- type: "string",
67
- description: "Text to scan"
68
- }
69
- },
70
- required: ["text"],
71
- additionalProperties: false
72
- }
160
+ const proActive = await isPro();
161
+
162
+ const tools = [
163
+ {
164
+ name: "redact_text",
165
+ description: "Redacts API keys, tokens, passwords, and credentials from text. Replaces each match with [REDACTED:type].",
166
+ inputSchema: {
167
+ type: "object",
168
+ properties: {
169
+ text: { type: "string", description: "Text to redact" }
170
+ },
171
+ required: ["text"],
172
+ additionalProperties: false
173
+ }
174
+ },
175
+ {
176
+ name: "scan_text",
177
+ description: "Scans text and returns detected secrets with type and character position. Use this to audit content before sharing.",
178
+ inputSchema: {
179
+ type: "object",
180
+ properties: {
181
+ text: { type: "string", description: "Text to scan" }
182
+ },
183
+ required: ["text"],
184
+ additionalProperties: false
185
+ }
186
+ },
187
+ {
188
+ name: "redact_file",
189
+ description: proActive
190
+ ? "Reads a file and returns redacted content. Supports .env, config files, source code, and more. File is not modified."
191
+ : "⚡ 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",
192
+ inputSchema: {
193
+ type: "object",
194
+ properties: {
195
+ path: { type: "string", description: "Absolute or relative path to file" }
196
+ },
197
+ required: ["path"],
198
+ additionalProperties: false
199
+ }
200
+ },
201
+ {
202
+ name: "scan_directory",
203
+ description: proActive
204
+ ? "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."
205
+ : "⚡ 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",
206
+ inputSchema: {
207
+ type: "object",
208
+ properties: {
209
+ path: { type: "string", description: "Directory path to scan" },
210
+ max_files: { type: "number", description: "Max files to scan (default 500)" }
211
+ },
212
+ required: ["path"],
213
+ additionalProperties: false
214
+ }
215
+ },
216
+ {
217
+ name: "scan_repo",
218
+ description: proActive
219
+ ? "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."
220
+ : "Clones a GitHub repo and scans all files for leaked secrets. Pro feature - set STREAMBLUR_LICENSE_KEY to unlock.",
221
+ inputSchema: {
222
+ type: "object",
223
+ properties: {
224
+ repo_url: { type: "string", description: "GitHub repo URL (e.g. https://github.com/owner/repo)" },
225
+ max_files: { type: "number", description: "Max files to scan (default 300)" }
226
+ },
227
+ required: ["repo_url"],
228
+ additionalProperties: false
73
229
  }
74
- ]
75
- };
230
+ },
231
+ {
232
+ name: "audit_env_file",
233
+ description: "Reads a .env file and returns a full security report: detected secrets, formatting issues, placeholder values, and rotation recommendations. File is not modified.",
234
+ inputSchema: {
235
+ type: "object",
236
+ properties: {
237
+ path: { type: "string", description: "Path to .env file" }
238
+ },
239
+ required: ["path"],
240
+ additionalProperties: false
241
+ }
242
+ },
243
+ {
244
+ name: "check_gitignore",
245
+ description: "Checks a project directory .gitignore to verify that .env files, key files, and secret directories are properly excluded. Returns a security gap report.",
246
+ inputSchema: {
247
+ type: "object",
248
+ properties: {
249
+ path: { type: "string", description: "Project root directory path" }
250
+ },
251
+ required: ["path"],
252
+ additionalProperties: false
253
+ }
254
+ },
255
+ {
256
+ name: "explain_detection",
257
+ 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.",
258
+ inputSchema: {
259
+ type: "object",
260
+ properties: {
261
+ type: { type: "string", description: "Secret type string from a scan result (e.g. openai_api_key)" }
262
+ },
263
+ required: ["type"],
264
+ additionalProperties: false
265
+ }
266
+ },
267
+ {
268
+ name: "generate_env_template",
269
+ description: "Generates a safe .env.example template with placeholder values and security comments for common project types.",
270
+ inputSchema: {
271
+ type: "object",
272
+ properties: {
273
+ project_type: { type: "string", description: "Project type: nextjs, rails, django, express, nuxt, sveltekit" },
274
+ services: { type: "array", items: { type: "string" }, description: "Additional services: stripe, openai, anthropic, supabase, firebase, aws, sendgrid, twilio" }
275
+ },
276
+ required: ["project_type"],
277
+ additionalProperties: false
278
+ }
279
+ }
280
+ ];
281
+
282
+ return { tools };
76
283
  });
77
284
 
78
285
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
79
286
  const args = request.params.arguments ?? {};
80
287
 
81
288
  switch (request.params.name) {
289
+
82
290
  case "redact_text": {
83
291
  const text = args.text;
84
292
  if (typeof text !== "string") {
85
293
  throw new McpError(ErrorCode.InvalidParams, "'text' must be a string");
86
294
  }
295
+ return { content: [{ type: "text", text: redactText(text) }] };
296
+ }
87
297
 
88
- return {
89
- content: [{ type: "text", text: redactText(text) }]
90
- };
298
+ case "scan_text": {
299
+ const text = args.text;
300
+ if (typeof text !== "string") {
301
+ throw new McpError(ErrorCode.InvalidParams, "'text' must be a string");
302
+ }
303
+ const detections = scanText(text).map(d => ({
304
+ type: d.type,
305
+ start: d.start,
306
+ end: d.end
307
+ }));
308
+ return { content: [{ type: "text", text: JSON.stringify(detections, null, 2) }] };
91
309
  }
92
310
 
93
311
  case "redact_file": {
312
+ const proActive = await isPro();
313
+ if (!proActive) {
314
+ return {
315
+ content: [{
316
+ type: "text",
317
+ 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."
318
+ }]
319
+ };
320
+ }
94
321
  const path = args.path;
95
322
  if (typeof path !== "string") {
96
323
  throw new McpError(ErrorCode.InvalidParams, "'path' must be a string");
97
324
  }
325
+ try {
326
+ const content = readFileSync(path, "utf8");
327
+ return { content: [{ type: "text", text: redactText(content) }] };
328
+ } catch (err: unknown) {
329
+ const msg = err instanceof Error ? err.message : String(err);
330
+ throw new McpError(ErrorCode.InternalError, `Could not read file: ${msg}`);
331
+ }
332
+ }
98
333
 
99
- const content = readFileSync(path, "utf8");
100
- return {
101
- content: [{ type: "text", text: redactText(content) }]
102
- };
334
+ case "scan_directory": {
335
+ const proActive = await isPro();
336
+ if (!proActive) {
337
+ return {
338
+ content: [{
339
+ type: "text",
340
+ 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."
341
+ }]
342
+ };
343
+ }
344
+ const dirPath = args.path;
345
+ if (typeof dirPath !== "string") {
346
+ throw new McpError(ErrorCode.InvalidParams, "'path' must be a string");
347
+ }
348
+ const maxFiles = typeof args.max_files === "number" ? args.max_files : 500;
349
+ try {
350
+ const results = scanDirectory(dirPath, maxFiles);
351
+ if (results.length === 0) {
352
+ return { content: [{ type: "text", text: "✅ No secrets detected in directory." }] };
353
+ }
354
+ const summary = results.map(r =>
355
+ `${r.file}\n${r.detections.map(d => ` Line ${d.line}:${d.column} — ${d.type}`).join("\n")}`
356
+ ).join("\n\n");
357
+ return {
358
+ content: [{
359
+ type: "text",
360
+ text: `⚠️ Found secrets in ${results.length} file(s):\n\n${summary}`
361
+ }]
362
+ };
363
+ } catch (err: unknown) {
364
+ const msg = err instanceof Error ? err.message : String(err);
365
+ throw new McpError(ErrorCode.InternalError, `Could not scan directory: ${msg}`);
366
+ }
103
367
  }
104
368
 
105
- case "scan_text": {
106
- const text = args.text;
107
- if (typeof text !== "string") {
108
- throw new McpError(ErrorCode.InvalidParams, "'text' must be a string");
369
+ case "scan_repo": {
370
+ const proActive = await isPro();
371
+ if (!proActive) {
372
+ return {
373
+ content: [{
374
+ type: "text",
375
+ 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."
376
+ }]
377
+ };
378
+ }
379
+ const repoUrl = args.repo_url;
380
+ if (typeof repoUrl !== "string" || !repoUrl.startsWith("http")) {
381
+ throw new McpError(ErrorCode.InvalidParams, "'repo_url' must be a valid GitHub URL");
382
+ }
383
+ const maxFiles = typeof args.max_files === "number" ? args.max_files : 300;
384
+ const tmpDir = join(tmpdir(), "streamblur-scan-" + Date.now());
385
+ try {
386
+ mkdirSync(tmpDir, { recursive: true });
387
+ execSync(`git clone --depth 1 "${repoUrl}" "${tmpDir}"`, { timeout: 60000, stdio: "pipe" });
388
+ const results = scanDirectory(tmpDir, maxFiles);
389
+ if (results.length === 0) {
390
+ return { content: [{ type: "text", text: `No secrets detected in ${repoUrl}` }] };
391
+ }
392
+ const summary = results.map(r =>
393
+ `${r.file.replace(tmpDir + "/", "")}\n${r.detections.map((d: { line: number; column: number; type: string }) => ` Line ${d.line}:${d.column} - ${d.type}`).join("\n")}`
394
+ ).join("\n\n");
395
+ return {
396
+ content: [{
397
+ type: "text",
398
+ text: `Found secrets in ${results.length} file(s) in ${repoUrl}:\n\n${summary}`
399
+ }]
400
+ };
401
+ } catch (err: unknown) {
402
+ const msg = err instanceof Error ? err.message : String(err);
403
+ throw new McpError(ErrorCode.InternalError, `Repo scan failed: ${msg}`);
404
+ } finally {
405
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* cleanup */ }
109
406
  }
407
+ }
110
408
 
111
- const detections = scanText(text).map((detection) => ({
112
- type: detection.type,
113
- start: detection.start,
114
- end: detection.end
115
- }));
409
+ case "audit_env_file": {
410
+ const filePath = args.path;
411
+ if (typeof filePath !== "string") {
412
+ throw new McpError(ErrorCode.InvalidParams, "'path' must be a string");
413
+ }
414
+ try {
415
+ const content = readFileSync(filePath, "utf8");
416
+ const lines = content.split("\n");
417
+ const issues: string[] = [];
418
+ const secrets: string[] = [];
419
+ const placeholders: string[] = [];
420
+
421
+ for (let i = 0; i < lines.length; i++) {
422
+ const line = lines[i].trim();
423
+ if (!line || line.startsWith("#")) continue;
424
+ const eqIdx = line.indexOf("=");
425
+ if (eqIdx === -1) { issues.push(`Line ${i+1}: Missing = sign: ${line}`); continue; }
426
+ const key = line.slice(0, eqIdx).trim();
427
+ const val = line.slice(eqIdx + 1).trim();
428
+ if (/\s/.test(key)) issues.push(`Line ${i+1}: Key has spaces: ${key}`);
429
+ if (val === "" || val === '""' || val === "''") issues.push(`Line ${i+1}: Empty value for ${key}`);
430
+ if (/^(your[_-]|xxx|placeholder|changeme|todo|replace|insert|example)/i.test(val.replace(/['"]/g, ""))) {
431
+ placeholders.push(`Line ${i+1}: ${key} has placeholder value`);
432
+ }
433
+ const detected = scanText(line);
434
+ if (detected.length > 0) {
435
+ secrets.push(`Line ${i+1}: ${key} - detected as ${detected.map((d: { type: string }) => d.type).join(", ")}`);
436
+ }
437
+ }
116
438
 
439
+ let report = `Env File Audit: ${filePath}\n${"=".repeat(40)}\n`;
440
+ report += `Lines: ${lines.length} | Variables: ${lines.filter(l => l.includes("=") && !l.startsWith("#")).length}\n\n`;
441
+ if (secrets.length > 0) report += `SECRETS DETECTED (${secrets.length}):\n${secrets.join("\n")}\n\n`;
442
+ if (issues.length > 0) report += `FORMAT ISSUES (${issues.length}):\n${issues.join("\n")}\n\n`;
443
+ if (placeholders.length > 0) report += `PLACEHOLDERS (${placeholders.length}):\n${placeholders.join("\n")}\n\n`;
444
+ if (secrets.length === 0 && issues.length === 0) report += `No issues found. File looks clean.\n`;
445
+ report += `\nReminder: ensure this file is in .gitignore and never committed to version control.`;
446
+
447
+ return { content: [{ type: "text", text: report }] };
448
+ } catch (err: unknown) {
449
+ const msg = err instanceof Error ? err.message : String(err);
450
+ throw new McpError(ErrorCode.InternalError, `Could not read file: ${msg}`);
451
+ }
452
+ }
453
+
454
+ case "check_gitignore": {
455
+ const dirPath = args.path;
456
+ if (typeof dirPath !== "string") {
457
+ throw new McpError(ErrorCode.InvalidParams, "'path' must be a string");
458
+ }
459
+ const gitignorePath = join(dirPath, ".gitignore");
460
+ const mustIgnore = [".env", ".env.local", ".env.production", ".env.*.local", "*.pem", "*.key", ".secret"];
461
+ const shouldIgnore = [".env.development", ".env.staging", "secrets/", "credentials/"];
462
+
463
+ try {
464
+ if (!existsSync(gitignorePath)) {
465
+ return {
466
+ content: [{
467
+ type: "text",
468
+ text: `No .gitignore found in ${dirPath}.\n\nCRITICAL: Create a .gitignore immediately and add:\n${mustIgnore.join("\n")}`
469
+ }]
470
+ };
471
+ }
472
+ const content = readFileSync(gitignorePath, "utf8");
473
+ const missing = mustIgnore.filter(p => !content.includes(p));
474
+ const suggested = shouldIgnore.filter(p => !content.includes(p));
475
+ let report = `.gitignore Audit: ${gitignorePath}\n${"=".repeat(40)}\n`;
476
+ if (missing.length === 0) {
477
+ report += `All critical patterns are covered.\n`;
478
+ } else {
479
+ report += `CRITICAL - Missing patterns (add these NOW):\n${missing.map(p => ` ${p}`).join("\n")}\n\n`;
480
+ }
481
+ if (suggested.length > 0) {
482
+ report += `Recommended additions:\n${suggested.map(p => ` ${p}`).join("\n")}\n`;
483
+ }
484
+ return { content: [{ type: "text", text: report }] };
485
+ } catch (err: unknown) {
486
+ const msg = err instanceof Error ? err.message : String(err);
487
+ throw new McpError(ErrorCode.InternalError, `Could not check gitignore: ${msg}`);
488
+ }
489
+ }
490
+
491
+ case "explain_detection": {
492
+ const detectionType = args.type;
493
+ if (typeof detectionType !== "string") {
494
+ throw new McpError(ErrorCode.InvalidParams, "'type' must be a string");
495
+ }
496
+ const explanations: Record<string, { name: string; risk: string; action: string; url: string }> = {
497
+ 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" },
498
+ 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" },
499
+ 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" },
500
+ 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" },
501
+ 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" },
502
+ 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" },
503
+ 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" },
504
+ 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" },
505
+ 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" },
506
+ 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" },
507
+ };
508
+
509
+ const info = explanations[detectionType];
510
+ if (!info) {
511
+ return {
512
+ content: [{
513
+ type: "text",
514
+ 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`
515
+ }]
516
+ };
517
+ }
117
518
  return {
118
- content: [{ type: "text", text: JSON.stringify(detections, null, 2) }]
519
+ content: [{
520
+ type: "text",
521
+ text: `${info.name}\n${"=".repeat(info.name.length)}\n\nRisk: ${info.risk}\n\nImmediate action:\n${info.action}\n\nDashboard: ${info.url}`
522
+ }]
523
+ };
524
+ }
525
+
526
+ case "generate_env_template": {
527
+ const projectType = (args.project_type as string || "nextjs").toLowerCase();
528
+ const services = Array.isArray(args.services) ? args.services as string[] : [];
529
+
530
+ const templates: Record<string, string> = {
531
+ 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`,
532
+ 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`,
533
+ 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`,
534
+ 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`,
535
+ 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`,
536
+ 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`,
537
+ };
538
+
539
+ const serviceAdditions: Record<string, string> = {
540
+ 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`,
541
+ openai: `\n# OpenAI\nOPENAI_API_KEY=sk-proj-replace-with-your-openai-key`,
542
+ anthropic: `\n# Anthropic\nANTHROPIC_API_KEY=sk-ant-replace-with-your-anthropic-key`,
543
+ 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`,
544
+ 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"`,
545
+ 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`,
546
+ sendgrid: `\n# SendGrid\nSENDGRID_API_KEY=SG.replace-with-your-sendgrid-key`,
547
+ twilio: `\n# Twilio\nTWILIO_ACCOUNT_SID=AC-replace-with-your-account-sid\nTWILIO_AUTH_TOKEN=replace-with-your-auth-token`,
119
548
  };
549
+
550
+ const base = templates[projectType] || templates["nextjs"];
551
+ const additions = services.map((s: string) => serviceAdditions[s] || "").join("");
552
+ const result = base + additions + "\n\n# Add this file to .gitignore:\n# echo '.env.local' >> .gitignore";
553
+
554
+ return { content: [{ type: "text", text: result }] };
120
555
  }
121
556
 
122
557
  default:
package/src/patterns.ts CHANGED
@@ -87,5 +87,28 @@ export const credentialPatterns: CredentialPattern[] = [
87
87
  { type: "oauth_refresh_token", regex: /\b1\/\/[A-Za-z0-9_-]{20,}\b/g },
88
88
  { type: "mailgun_api_key", regex: /\bkey-[A-Za-z0-9]{32}\b/g },
89
89
  { type: "shopify_access_token", regex: /\bshpat_[A-Za-z0-9]{20,120}\b/g },
90
- { type: "gitlab_token", regex: /\bglpat-[A-Za-z0-9_-]{20,120}\b/g }
90
+ { type: "gitlab_token", regex: /\bglpat-[A-Za-z0-9_-]{20,120}\b/g },
91
+
92
+ // AI/ML platforms
93
+ { type: "huggingface_token", regex: /\bhf_[A-Za-z0-9]{20,120}\b/g },
94
+ { type: "replicate_token", regex: /\br8_[A-Za-z0-9]{20,120}\b/g },
95
+ { type: "together_ai_key", regex: /\b(?:TOGETHER_API_KEY|together_api_key)\s*[:=]\s*['"\\]?[A-Za-z0-9]{32,120}['"\\]?/g },
96
+ { type: "groq_api_key", regex: /\bgsk_[A-Za-z0-9]{20,120}\b/g },
97
+ { type: "cohere_api_key", regex: /\b(?:COHERE_API_KEY|co-[A-Za-z0-9]{32,120})\b/g },
98
+ { type: "elevenlabs_api_key", regex: /\b(?:ELEVENLABS_API_KEY|XI_API_KEY)\s*[:=]\s*['"\\]?[A-Za-z0-9]{32,120}['"\\]?/g },
99
+ { type: "pinecone_api_key", regex: /\b(?:PINECONE_API_KEY)\s*[:=]\s*['"\\]?[A-Za-z0-9-]{20,120}['"\\]?/g },
100
+
101
+ // Database/infra platforms
102
+ { type: "supabase_service_role_key", regex: /\b(?:SUPABASE_SERVICE_ROLE_KEY|supabase_service_role_key)\s*[:=]\s*['"\\]?eyJ[A-Za-z0-9_-]{50,}['"\\]?/g },
103
+ { type: "planetscale_token", regex: /\bpscale_tkn_[A-Za-z0-9]{20,120}\b/g },
104
+ { type: "railway_token", regex: /\b(?:RAILWAY_TOKEN|RAILWAY_API_KEY)\s*[:=]\s*['"\\]?[A-Za-z0-9-]{20,120}['"\\]?/g },
105
+
106
+ // Dev tools
107
+ { type: "resend_api_key", regex: /\bre_[A-Za-z0-9]{20,120}\b/g },
108
+ { type: "linear_api_key", regex: /\blin_api_[A-Za-z0-9]{20,120}\b/g },
109
+ { type: "notion_api_key", regex: /\bsecret_[A-Za-z0-9]{40,120}\b/g },
110
+ { type: "airtable_api_key", regex: /\bpat[A-Za-z0-9]{14}\.[A-Za-z0-9]{64}\b/g },
111
+ { type: "doppler_token", regex: /\bdp\.pt\.[A-Za-z0-9]{40,120}\b/g },
112
+ { type: "pulumi_access_token", regex: /\bpul-[A-Za-z0-9]{40,120}\b/g },
113
+ { type: "cursor_session_token", regex: /\b(?:CURSOR_SESSION_TOKEN|cursor_session)\s*[:=]\s*['"\\]?[A-Za-z0-9._-]{20,120}['"\\]?/g }
91
114
  ];