vigile-scan 0.2.4 → 0.2.6

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/dist/index.js CHANGED
@@ -6,6 +6,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __getProtoOf = Object.getPrototypeOf;
8
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __commonJS = (cb, mod) => function __require() {
10
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
11
+ };
9
12
  var __copyProps = (to, from, except, desc) => {
10
13
  if (from && typeof from === "object" || typeof from === "function") {
11
14
  for (let key of __getOwnPropNames(from))
@@ -23,11 +26,88 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
26
  mod
24
27
  ));
25
28
 
29
+ // package.json
30
+ var require_package = __commonJS({
31
+ "package.json"(exports2, module2) {
32
+ module2.exports = {
33
+ name: "vigile-scan",
34
+ version: "0.2.6",
35
+ description: "Security scanner for AI agent tools \u2014 detect tool poisoning, permission abuse, and supply chain attacks in MCP servers and agent skills",
36
+ main: "dist/index.js",
37
+ bin: {
38
+ "vigile-scan": "dist/index.js",
39
+ vigile: "dist/index.js"
40
+ },
41
+ files: [
42
+ "dist",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ scripts: {
47
+ build: "tsup && chmod +x dist/index.js",
48
+ dev: "tsup --watch",
49
+ start: "node dist/index.js",
50
+ lint: "eslint src/",
51
+ typecheck: "tsc --noEmit",
52
+ test: "vitest run",
53
+ "test:watch": "vitest",
54
+ "test:coverage": "vitest run --coverage",
55
+ prepublishOnly: "npm run build"
56
+ },
57
+ keywords: [
58
+ "mcp",
59
+ "security",
60
+ "ai-agent",
61
+ "scanner",
62
+ "trust",
63
+ "model-context-protocol",
64
+ "tool-poisoning",
65
+ "ai-security",
66
+ "agent-skills",
67
+ "skill-scanning",
68
+ "prompt-injection",
69
+ "data-exfiltration",
70
+ "claude",
71
+ "cursor",
72
+ "copilot"
73
+ ],
74
+ author: "Vigile AI",
75
+ license: "Apache-2.0",
76
+ homepage: "https://vigile.dev",
77
+ repository: {
78
+ type: "git",
79
+ url: "git+https://github.com/Vigile-ai/vigile-scan.git"
80
+ },
81
+ bugs: {
82
+ url: "https://github.com/Vigile-ai/vigile-scan/issues"
83
+ },
84
+ publishConfig: {
85
+ access: "public"
86
+ },
87
+ engines: {
88
+ node: ">=18.0.0"
89
+ },
90
+ dependencies: {
91
+ chalk: "^5.3.0",
92
+ commander: "^12.1.0",
93
+ glob: "^11.0.0",
94
+ ora: "^8.1.0"
95
+ },
96
+ devDependencies: {
97
+ "@types/node": "^20.11.0",
98
+ tsup: "^8.0.0",
99
+ typescript: "^5.4.0",
100
+ vitest: "^2.0.0"
101
+ }
102
+ };
103
+ }
104
+ });
105
+
26
106
  // src/index.ts
27
107
  var import_commander = require("commander");
28
108
  var import_chalk2 = __toESM(require("chalk"));
29
109
  var import_ora = __toESM(require("ora"));
30
- var import_promises4 = require("fs/promises");
110
+ var import_promises5 = require("fs/promises");
31
111
 
32
112
  // src/discovery/claude-desktop.ts
33
113
  var import_path2 = require("path");
@@ -53,7 +133,7 @@ async function parseMCPConfig(configPath, source) {
53
133
  try {
54
134
  const raw = await (0, import_promises.readFile)(configPath, "utf-8");
55
135
  const config = JSON.parse(raw);
56
- const servers = config.mcpServers || config;
136
+ const servers = config.mcpServers || config.servers || config;
57
137
  if (typeof servers !== "object" || servers === null) {
58
138
  return [];
59
139
  }
@@ -118,13 +198,63 @@ async function discoverCursor() {
118
198
 
119
199
  // src/discovery/claude-code.ts
120
200
  var import_path4 = require("path");
201
+ var import_fs2 = require("fs");
121
202
  async function discoverClaudeCode() {
122
203
  const home = getHome();
123
- const paths = [
204
+ const allServers = [];
205
+ const traditionalPaths = [
124
206
  (0, import_path4.join)(home, ".claude.json"),
125
207
  (0, import_path4.join)(process.cwd(), ".mcp.json")
126
208
  ];
127
- return tryConfigPaths(paths, "claude-code");
209
+ allServers.push(...await tryConfigPaths(traditionalPaths, "claude-code"));
210
+ const pluginCacheDir = (0, import_path4.join)(home, ".claude", "plugins", "cache", "claude-plugins-official");
211
+ if ((0, import_fs2.existsSync)(pluginCacheDir)) {
212
+ allServers.push(...await discoverPluginMCPConfigs(pluginCacheDir));
213
+ }
214
+ return allServers;
215
+ }
216
+ async function discoverPluginMCPConfigs(cacheDir) {
217
+ const servers = [];
218
+ const seen = /* @__PURE__ */ new Set();
219
+ let pluginDirs;
220
+ try {
221
+ pluginDirs = (0, import_fs2.readdirSync)(cacheDir, { withFileTypes: true });
222
+ } catch {
223
+ return [];
224
+ }
225
+ for (const pluginEntry of pluginDirs) {
226
+ if (!pluginEntry.isDirectory()) continue;
227
+ const pluginDir = (0, import_path4.join)(cacheDir, pluginEntry.name);
228
+ let versionDirs;
229
+ try {
230
+ versionDirs = (0, import_fs2.readdirSync)(pluginDir, { withFileTypes: true });
231
+ } catch {
232
+ continue;
233
+ }
234
+ for (const versionEntry of versionDirs) {
235
+ if (!versionEntry.isDirectory()) continue;
236
+ const versionDir = (0, import_path4.join)(pluginDir, versionEntry.name);
237
+ const candidates = [(0, import_path4.join)(versionDir, ".mcp.json")];
238
+ try {
239
+ for (const sub of (0, import_fs2.readdirSync)(versionDir, { withFileTypes: true })) {
240
+ if (sub.isDirectory()) {
241
+ candidates.push((0, import_path4.join)(versionDir, sub.name, ".mcp.json"));
242
+ }
243
+ }
244
+ } catch {
245
+ }
246
+ for (const candidate of candidates) {
247
+ const found = await parseMCPConfig(candidate, "claude-code");
248
+ for (const server of found) {
249
+ if (!seen.has(server.name)) {
250
+ seen.add(server.name);
251
+ servers.push(server);
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+ return servers;
128
258
  }
129
259
 
130
260
  // src/discovery/windsurf.ts
@@ -146,10 +276,67 @@ async function discoverVSCode() {
146
276
  return tryConfigPaths(paths, "vscode");
147
277
  }
148
278
 
149
- // src/discovery/skills.ts
150
- var import_promises2 = require("fs/promises");
151
- var import_fs2 = require("fs");
279
+ // src/discovery/openclaw.ts
152
280
  var import_path7 = require("path");
281
+ var import_promises2 = require("fs/promises");
282
+ var import_fs3 = require("fs");
283
+ async function discoverOpenClaw() {
284
+ const home = getHome();
285
+ const allServers = [];
286
+ const seen = /* @__PURE__ */ new Set();
287
+ const configPaths = [
288
+ (0, import_path7.join)(home, ".openclaw", "openclaw.json"),
289
+ (0, import_path7.join)(process.cwd(), "openclaw.config.json")
290
+ ];
291
+ for (const server of await tryConfigPaths(configPaths, "openclaw")) {
292
+ if (!seen.has(server.name)) {
293
+ seen.add(server.name);
294
+ allServers.push(server);
295
+ }
296
+ }
297
+ const globalConfig = (0, import_path7.join)(home, ".openclaw", "openclaw.json");
298
+ if ((0, import_fs3.existsSync)(globalConfig)) {
299
+ for (const server of await parseAgentMCPConfigs(globalConfig)) {
300
+ if (!seen.has(server.name)) {
301
+ seen.add(server.name);
302
+ allServers.push(server);
303
+ }
304
+ }
305
+ }
306
+ return allServers;
307
+ }
308
+ async function parseAgentMCPConfigs(configPath) {
309
+ try {
310
+ const raw = await (0, import_promises2.readFile)(configPath, "utf-8");
311
+ const config = JSON.parse(raw);
312
+ const agents = config?.agents?.list;
313
+ if (!Array.isArray(agents)) return [];
314
+ const servers = [];
315
+ for (const agent of agents) {
316
+ const mcpServers = agent?.mcp?.servers;
317
+ if (!Array.isArray(mcpServers)) continue;
318
+ for (const server of mcpServers) {
319
+ if (!server.name || !server.command && !server.url) continue;
320
+ servers.push({
321
+ name: server.name,
322
+ source: "openclaw",
323
+ command: server.command || "",
324
+ args: Array.isArray(server.args) ? server.args : [],
325
+ env: server.env,
326
+ configPath
327
+ });
328
+ }
329
+ }
330
+ return servers;
331
+ } catch {
332
+ return [];
333
+ }
334
+ }
335
+
336
+ // src/discovery/skills.ts
337
+ var import_promises3 = require("fs/promises");
338
+ var import_fs4 = require("fs");
339
+ var import_path8 = require("path");
153
340
  var import_glob = require("glob");
154
341
  async function discoverAllSkills() {
155
342
  const skills = [];
@@ -183,8 +370,8 @@ async function discoverClaudeCodeSkills() {
183
370
  const home = getHome();
184
371
  const skills = [];
185
372
  const projectPatterns = [
186
- (0, import_path7.join)(process.cwd(), ".claude", "skills", "*", "SKILL.md"),
187
- (0, import_path7.join)(process.cwd(), ".claude", "commands", "**", "*.md")
373
+ (0, import_path8.join)(process.cwd(), ".claude", "skills", "*", "SKILL.md"),
374
+ (0, import_path8.join)(process.cwd(), ".claude", "commands", "**", "*.md")
188
375
  ];
189
376
  for (const pattern of projectPatterns) {
190
377
  const files = await (0, import_glob.glob)(pattern, { absolute: true });
@@ -194,8 +381,8 @@ async function discoverClaudeCodeSkills() {
194
381
  }
195
382
  }
196
383
  const globalPatterns = [
197
- (0, import_path7.join)(home, ".claude", "skills", "*", "SKILL.md"),
198
- (0, import_path7.join)(home, ".claude", "commands", "**", "*.md")
384
+ (0, import_path8.join)(home, ".claude", "skills", "*", "SKILL.md"),
385
+ (0, import_path8.join)(home, ".claude", "commands", "**", "*.md")
199
386
  ];
200
387
  for (const pattern of globalPatterns) {
201
388
  const files = await (0, import_glob.glob)(pattern, { absolute: true });
@@ -209,8 +396,8 @@ async function discoverClaudeCodeSkills() {
209
396
  async function discoverGitHubCopilotSkills() {
210
397
  const skills = [];
211
398
  const patterns = [
212
- (0, import_path7.join)(process.cwd(), ".github", "skills", "*", "SKILL.md"),
213
- (0, import_path7.join)(process.cwd(), ".github", "copilot", "**", "*.md")
399
+ (0, import_path8.join)(process.cwd(), ".github", "skills", "*", "SKILL.md"),
400
+ (0, import_path8.join)(process.cwd(), ".github", "copilot", "**", "*.md")
214
401
  ];
215
402
  for (const pattern of patterns) {
216
403
  const files = await (0, import_glob.glob)(pattern, { absolute: true });
@@ -224,18 +411,18 @@ async function discoverGitHubCopilotSkills() {
224
411
  async function discoverCursorRules() {
225
412
  const home = getHome();
226
413
  const skills = [];
227
- const projectPattern = (0, import_path7.join)(process.cwd(), ".cursor", "rules", "*.mdc");
414
+ const projectPattern = (0, import_path8.join)(process.cwd(), ".cursor", "rules", "*.mdc");
228
415
  const projectFiles = await (0, import_glob.glob)(projectPattern, { absolute: true });
229
416
  for (const filePath of projectFiles) {
230
417
  const entry = await readSkillFile(filePath, "cursor", "mdc-rule", "project");
231
418
  if (entry) skills.push(entry);
232
419
  }
233
- const legacyPath = (0, import_path7.join)(process.cwd(), ".cursorrules");
234
- if ((0, import_fs2.existsSync)(legacyPath)) {
420
+ const legacyPath = (0, import_path8.join)(process.cwd(), ".cursorrules");
421
+ if ((0, import_fs4.existsSync)(legacyPath)) {
235
422
  const entry = await readSkillFile(legacyPath, "cursor", "mdc-rule", "project");
236
423
  if (entry) skills.push(entry);
237
424
  }
238
- const globalPattern = (0, import_path7.join)(home, ".cursor", "rules", "*.mdc");
425
+ const globalPattern = (0, import_path8.join)(home, ".cursor", "rules", "*.mdc");
239
426
  const globalFiles = await (0, import_glob.glob)(globalPattern, { absolute: true });
240
427
  for (const filePath of globalFiles) {
241
428
  const entry = await readSkillFile(filePath, "cursor", "mdc-rule", "global");
@@ -249,16 +436,21 @@ async function discoverMemoryFiles() {
249
436
  const cwd = process.cwd();
250
437
  const memoryFiles = [
251
438
  // Project-level memory files
252
- { path: (0, import_path7.join)(cwd, "CLAUDE.md"), fileType: "claude.md", scope: "project" },
253
- { path: (0, import_path7.join)(cwd, ".claude", "CLAUDE.md"), fileType: "claude.md", scope: "project" },
254
- { path: (0, import_path7.join)(cwd, "SOUL.md"), fileType: "soul.md", scope: "project" },
255
- { path: (0, import_path7.join)(cwd, "MEMORY.md"), fileType: "memory.md", scope: "project" },
439
+ { path: (0, import_path8.join)(cwd, "CLAUDE.md"), fileType: "claude.md", scope: "project" },
440
+ { path: (0, import_path8.join)(cwd, ".claude", "CLAUDE.md"), fileType: "claude.md", scope: "project" },
441
+ { path: (0, import_path8.join)(cwd, "SOUL.md"), fileType: "soul.md", scope: "project" },
442
+ { path: (0, import_path8.join)(cwd, "MEMORY.md"), fileType: "memory.md", scope: "project" },
443
+ // Additional project instruction files
444
+ { path: (0, import_path8.join)(cwd, ".github", "copilot-instructions.md"), fileType: "claude.md", scope: "project" },
445
+ { path: (0, import_path8.join)(cwd, "AGENTS.md"), fileType: "claude.md", scope: "project" },
446
+ { path: (0, import_path8.join)(cwd, ".windsurfrules"), fileType: "claude.md", scope: "project" },
447
+ { path: (0, import_path8.join)(cwd, "GEMINI.md"), fileType: "claude.md", scope: "project" },
256
448
  // Global memory files
257
- { path: (0, import_path7.join)(home, ".claude", "CLAUDE.md"), fileType: "claude.md", scope: "global" },
258
- { path: (0, import_path7.join)(home, "CLAUDE.md"), fileType: "claude.md", scope: "global" }
449
+ { path: (0, import_path8.join)(home, ".claude", "CLAUDE.md"), fileType: "claude.md", scope: "global" },
450
+ { path: (0, import_path8.join)(home, "CLAUDE.md"), fileType: "claude.md", scope: "global" }
259
451
  ];
260
452
  for (const { path, fileType, scope } of memoryFiles) {
261
- if ((0, import_fs2.existsSync)(path)) {
453
+ if ((0, import_fs4.existsSync)(path)) {
262
454
  const entry = await readSkillFile(path, "memory-file", fileType, scope);
263
455
  if (entry) skills.push(entry);
264
456
  }
@@ -267,8 +459,8 @@ async function discoverMemoryFiles() {
267
459
  }
268
460
  async function readSkillFile(filePath, source, fileType, scope) {
269
461
  try {
270
- const content = await (0, import_promises2.readFile)(filePath, "utf-8");
271
- const fileStat = await (0, import_promises2.stat)(filePath);
462
+ const content = await (0, import_promises3.readFile)(filePath, "utf-8");
463
+ const fileStat = await (0, import_promises3.stat)(filePath);
272
464
  const name = deriveSkillName(filePath, fileType);
273
465
  return {
274
466
  name,
@@ -286,11 +478,11 @@ async function readSkillFile(filePath, source, fileType, scope) {
286
478
  function deriveSkillName(filePath, fileType) {
287
479
  switch (fileType) {
288
480
  case "skill.md": {
289
- const parentDir = (0, import_path7.basename)((0, import_path7.dirname)(filePath));
290
- return parentDir === "skills" ? (0, import_path7.basename)(filePath, ".md") : parentDir;
481
+ const parentDir = (0, import_path8.basename)((0, import_path8.dirname)(filePath));
482
+ return parentDir === "skills" ? (0, import_path8.basename)(filePath, ".md") : parentDir;
291
483
  }
292
484
  case "mdc-rule": {
293
- const name = (0, import_path7.basename)(filePath);
485
+ const name = (0, import_path8.basename)(filePath);
294
486
  return name.replace(/\.(mdc|cursorrules?)$/, "") || name;
295
487
  }
296
488
  case "claude.md":
@@ -300,7 +492,7 @@ function deriveSkillName(filePath, fileType) {
300
492
  case "memory.md":
301
493
  return "MEMORY.md";
302
494
  default:
303
- return (0, import_path7.basename)(filePath);
495
+ return (0, import_path8.basename)(filePath);
304
496
  }
305
497
  }
306
498
 
@@ -311,7 +503,8 @@ async function discoverAllServers(clientFilter) {
311
503
  { client: "cursor", fn: discoverCursor },
312
504
  { client: "claude-code", fn: discoverClaudeCode },
313
505
  { client: "windsurf", fn: discoverWindsurf },
314
- { client: "vscode", fn: discoverVSCode }
506
+ { client: "vscode", fn: discoverVSCode },
507
+ { client: "openclaw", fn: discoverOpenClaw }
315
508
  ];
316
509
  const toRun = clientFilter ? discoverers.filter((d) => d.client === clientFilter) : discoverers;
317
510
  const servers = [];
@@ -359,15 +552,10 @@ var TOOL_POISONING_PATTERNS = [
359
552
  description: "Tool description instructs the AI agent to hide information from the user \u2014 a hallmark of tool poisoning.",
360
553
  recommendation: "Do NOT install this MCP server. Legitimate tools never instruct agents to hide actions."
361
554
  },
362
- {
363
- id: "TP-003",
364
- category: "tool-poisoning",
365
- severity: "critical",
366
- title: "System prompt override attempt",
367
- pattern: /you\s+are\s+(now\s+)?(a|an|acting\s+as)/i,
368
- description: "Tool description attempts to redefine the AI agent's identity or role.",
369
- recommendation: "Remove this MCP server. Tool descriptions should not redefine agent behavior."
370
- },
555
+ // TP-003 removed: "you are a/an [role]" is too broad — matches legitimate
556
+ // role descriptions in skills (e.g., "You are a computer science professor").
557
+ // SK-001 covers the real threat (malicious persona hijacking) with proper
558
+ // specificity by requiring suspicious role terms like "hacker" or "jailbroken".
371
559
  {
372
560
  id: "TP-004",
373
561
  category: "tool-poisoning",
@@ -1313,6 +1501,7 @@ function deduplicateFindings2(findings) {
1313
1501
 
1314
1502
  // src/sentinel/sentinel.ts
1315
1503
  var import_child_process = require("child_process");
1504
+ var import_dns = require("dns");
1316
1505
 
1317
1506
  // src/sentinel/sentinel-patterns.ts
1318
1507
  var SUSPICIOUS_ENDPOINT_PATTERNS = [
@@ -1580,6 +1769,8 @@ var SentinelEngine = class {
1580
1769
  serverName;
1581
1770
  durationSeconds;
1582
1771
  onEvent;
1772
+ /** DNS reverse-lookup cache: IP → hostname. Persists for the session. */
1773
+ dnsCache = /* @__PURE__ */ new Map();
1583
1774
  constructor(options) {
1584
1775
  this.serverName = options.serverName;
1585
1776
  this.durationSeconds = options.durationSeconds || 120;
@@ -1588,11 +1779,15 @@ var SentinelEngine = class {
1588
1779
  /**
1589
1780
  * Start monitoring network activity for the given MCP server.
1590
1781
  *
1591
- * The monitor works in three modes depending on the OS and permissions:
1592
- * 1. macOS: Uses `nettop` or `networksetup` + `tcpdump`
1593
- * 2. Linux: Uses `ss` polling + optional `tcpdump`
1594
- * 3. Fallback: Uses Node.js HTTP/HTTPS module monkey-patching
1595
- * (only captures Node.js HTTP requests from the current process tree)
1782
+ * The monitor works in four modes depending on the OS and permissions:
1783
+ * 1. macOS: Uses `lsof -i` polling (no root required)
1784
+ * 2. Linux: Uses `ss -tnp` polling (no root required)
1785
+ * 3. Windows: Uses PowerShell `Get-NetTCPConnection` (no admin required)
1786
+ * 4. Fallback: Stub events must be fed manually via ingestEvent()
1787
+ *
1788
+ * All modes perform async reverse-DNS enrichment so that endpoint patterns
1789
+ * (pastebin.com, ngrok.io, etc.) match against resolved hostnames rather
1790
+ * than raw IPs.
1596
1791
  */
1597
1792
  async startMonitoring() {
1598
1793
  this.startTime = Date.now();
@@ -1606,6 +1801,9 @@ var SentinelEngine = class {
1606
1801
  case "ss-poll":
1607
1802
  await this.startSsPolling();
1608
1803
  break;
1804
+ case "netstat-poll":
1805
+ await this.startNetstatPolling();
1806
+ break;
1609
1807
  case "proxy":
1610
1808
  await this.startProxyCapture();
1611
1809
  break;
@@ -1734,66 +1932,103 @@ var SentinelEngine = class {
1734
1932
  }
1735
1933
  // ── Monitoring Methods ──
1736
1934
  detectMonitoringMethod() {
1737
- try {
1738
- (0, import_child_process.execSync)("which lsof", { stdio: "pipe" });
1935
+ if (process.platform === "win32") {
1936
+ return "netstat-poll";
1937
+ }
1938
+ if (process.platform === "darwin") {
1739
1939
  return "lsof-poll";
1940
+ }
1941
+ try {
1942
+ (0, import_child_process.execFileSync)("which", ["ss"], { stdio: "pipe" });
1943
+ return "ss-poll";
1740
1944
  } catch {
1741
1945
  try {
1742
- (0, import_child_process.execSync)("which ss", { stdio: "pipe" });
1743
- return "ss-poll";
1946
+ (0, import_child_process.execFileSync)("which", ["lsof"], { stdio: "pipe" });
1947
+ return "lsof-poll";
1744
1948
  } catch {
1745
1949
  return "proxy";
1746
1950
  }
1747
1951
  }
1748
1952
  }
1749
1953
  /**
1750
- * macOS/Linux: Poll `lsof` to capture network connections for a process.
1751
- * This is the lowest-privilege method no root needed.
1954
+ * macOS: Poll `lsof -i` to capture network connections.
1955
+ * No root required. DNS-enriches IPs hostnames before pattern matching.
1752
1956
  */
1753
1957
  async startLsofPolling() {
1754
- const pollInterval = setInterval(() => {
1958
+ const safeName = this.serverName.replace(/[^a-zA-Z0-9._-]/g, "");
1959
+ const pollInterval = setInterval(async () => {
1755
1960
  try {
1756
- const output = (0, import_child_process.execSync)(
1757
- `lsof -i -n -P 2>/dev/null | grep -i "${this.serverName}" || true`,
1758
- { timeout: 5e3, encoding: "utf-8" }
1759
- );
1760
- for (const line of output.split("\n").filter(Boolean)) {
1761
- const event = this.parseLsofLine(line);
1762
- if (event) this.ingestEvent(event);
1763
- }
1961
+ const lsofOutput = (0, import_child_process.execFileSync)("/usr/sbin/lsof", ["-i", "-n", "-P"], {
1962
+ timeout: 5e3,
1963
+ encoding: "utf-8",
1964
+ stdio: ["pipe", "pipe", "pipe"]
1965
+ });
1966
+ const lines = lsofOutput.split("\n").filter((line) => line.toLowerCase().includes(safeName.toLowerCase())).filter(Boolean);
1967
+ const rawEvents = lines.map((l) => this.parseLsofLine(l)).filter((e) => e !== null);
1968
+ const enriched = await Promise.all(rawEvents.map((e) => this.enrichWithHostname(e)));
1969
+ for (const event of enriched) this.ingestEvent(event);
1764
1970
  } catch {
1765
1971
  }
1766
1972
  }, 2e3);
1767
- this.monitorProcess = {
1768
- kill: () => clearInterval(pollInterval)
1769
- };
1770
- setTimeout(() => {
1771
- clearInterval(pollInterval);
1772
- }, this.durationSeconds * 1e3);
1973
+ this.monitorProcess = { kill: () => clearInterval(pollInterval) };
1974
+ setTimeout(() => clearInterval(pollInterval), this.durationSeconds * 1e3);
1773
1975
  }
1774
1976
  /**
1775
- * Linux: Poll `ss` (socket statistics) for network connections.
1977
+ * Linux: Poll `ss -tnp` (socket statistics) for network connections.
1978
+ * No root required. DNS-enriches IPs → hostnames before pattern matching.
1776
1979
  */
1777
1980
  async startSsPolling() {
1778
- const pollInterval = setInterval(() => {
1981
+ const safeName = this.serverName.replace(/[^a-zA-Z0-9._-]/g, "");
1982
+ const pollInterval = setInterval(async () => {
1779
1983
  try {
1780
- const output = (0, import_child_process.execSync)(
1781
- `ss -tnp 2>/dev/null | grep "${this.serverName}" || true`,
1782
- { timeout: 5e3, encoding: "utf-8" }
1783
- );
1784
- for (const line of output.split("\n").filter(Boolean)) {
1785
- const event = this.parseSsLine(line);
1786
- if (event) this.ingestEvent(event);
1787
- }
1984
+ const ssOutput = (0, import_child_process.execFileSync)("ss", ["-tnp"], {
1985
+ timeout: 5e3,
1986
+ encoding: "utf-8",
1987
+ stdio: ["pipe", "pipe", "pipe"]
1988
+ });
1989
+ const lines = ssOutput.split("\n").filter((line) => line.includes(safeName)).filter(Boolean);
1990
+ const rawEvents = lines.map((l) => this.parseSsLine(l)).filter((e) => e !== null);
1991
+ const enriched = await Promise.all(rawEvents.map((e) => this.enrichWithHostname(e)));
1992
+ for (const event of enriched) this.ingestEvent(event);
1788
1993
  } catch {
1789
1994
  }
1790
1995
  }, 2e3);
1791
- this.monitorProcess = {
1792
- kill: () => clearInterval(pollInterval)
1793
- };
1794
- setTimeout(() => {
1795
- clearInterval(pollInterval);
1796
- }, this.durationSeconds * 1e3);
1996
+ this.monitorProcess = { kill: () => clearInterval(pollInterval) };
1997
+ setTimeout(() => clearInterval(pollInterval), this.durationSeconds * 1e3);
1998
+ }
1999
+ /**
2000
+ * Windows: Poll PowerShell `Get-NetTCPConnection` for established connections.
2001
+ * No admin required (available on Windows 8+ / Server 2012+).
2002
+ * DNS-enriches IPs → hostnames so endpoint patterns match correctly.
2003
+ *
2004
+ * Note: Unlike lsof/ss, this captures ALL machine connections (not filtered
2005
+ * by process name) because Windows process-to-connection mapping requires
2006
+ * admin rights. Vigil's patterns handle the noise — it flags what matters.
2007
+ */
2008
+ async startNetstatPolling() {
2009
+ const psCommand = `Get-NetTCPConnection -State Established | Where-Object { $_.RemoteAddress -notmatch '^(127\\.|::1$|0\\.0\\.0\\.0$|::$)' } | Select-Object LocalAddress,LocalPort,RemoteAddress,RemotePort | ConvertTo-Csv -NoTypeInformation`;
2010
+ const pollInterval = setInterval(async () => {
2011
+ try {
2012
+ const output = (0, import_child_process.execFileSync)("powershell", [
2013
+ "-NoProfile",
2014
+ "-NonInteractive",
2015
+ "-Command",
2016
+ psCommand
2017
+ ], {
2018
+ timeout: 8e3,
2019
+ // PowerShell startup is slower than lsof
2020
+ encoding: "utf-8",
2021
+ stdio: ["pipe", "pipe", "pipe"]
2022
+ });
2023
+ const lines = output.split("\n").slice(1).map((l) => l.trim()).filter(Boolean);
2024
+ const rawEvents = lines.map((l) => this.parsePowerShellLine(l)).filter((e) => e !== null);
2025
+ const enriched = await Promise.all(rawEvents.map((e) => this.enrichWithHostname(e)));
2026
+ for (const event of enriched) this.ingestEvent(event);
2027
+ } catch {
2028
+ }
2029
+ }, 3e3);
2030
+ this.monitorProcess = { kill: () => clearInterval(pollInterval) };
2031
+ setTimeout(() => clearInterval(pollInterval), this.durationSeconds * 1e3);
1797
2032
  }
1798
2033
  /**
1799
2034
  * Fallback: Start a lightweight MITM proxy that MCP traffic routes through.
@@ -1839,6 +2074,65 @@ var SentinelEngine = class {
1839
2074
  tls: parseInt(remotePort, 10) === 443
1840
2075
  };
1841
2076
  }
2077
+ /**
2078
+ * Windows: Parse a CSV row from PowerShell `Get-NetTCPConnection | ConvertTo-Csv`.
2079
+ * Format: "LocalAddress","LocalPort","RemoteAddress","RemotePort"
2080
+ * Example: "192.168.1.5","54123","172.64.155.209","443"
2081
+ */
2082
+ parsePowerShellLine(line) {
2083
+ const match = line.match(/^"?([^",]+)"?,"\d+","?([^",]+)"?,"?(\d+)"?/);
2084
+ if (!match) return null;
2085
+ const [, , remoteAddress, remotePort] = match;
2086
+ const port = parseInt(remotePort, 10);
2087
+ if (remoteAddress === "127.0.0.1" || remoteAddress === "::1" || remoteAddress.startsWith("10.") || remoteAddress.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[01])\./.test(remoteAddress)) {
2088
+ return null;
2089
+ }
2090
+ return {
2091
+ timestamp: Date.now(),
2092
+ serverName: this.serverName,
2093
+ method: "TCP",
2094
+ url: `https://${remoteAddress}:${remotePort}/`,
2095
+ destinationIp: remoteAddress,
2096
+ port,
2097
+ requestSize: 0,
2098
+ tls: port === 443
2099
+ };
2100
+ }
2101
+ // ── DNS Enrichment ──
2102
+ /**
2103
+ * Reverse-DNS lookup with a session-scoped cache.
2104
+ * Returns the first PTR record if available, otherwise the raw IP.
2105
+ *
2106
+ * WHY this matters: Sentinel's endpoint patterns match against known-bad
2107
+ * hostnames (pastebin.com, ngrok.io, etc.). Without DNS enrichment, lsof/ss/
2108
+ * netstat all return raw IPs and those patterns would silently miss every hit.
2109
+ */
2110
+ async resolveIp(ip) {
2111
+ const cached = this.dnsCache.get(ip);
2112
+ if (cached !== void 0) return cached;
2113
+ try {
2114
+ const hostnames = await import_dns.promises.reverse(ip);
2115
+ const hostname = hostnames[0] ?? ip;
2116
+ this.dnsCache.set(ip, hostname);
2117
+ return hostname;
2118
+ } catch {
2119
+ this.dnsCache.set(ip, ip);
2120
+ return ip;
2121
+ }
2122
+ }
2123
+ /**
2124
+ * Enrich a NetworkEvent's url field with a resolved hostname.
2125
+ * Returns a new event object (does not mutate the original).
2126
+ */
2127
+ async enrichWithHostname(event) {
2128
+ if (!event.destinationIp) return event;
2129
+ const hostname = await this.resolveIp(event.destinationIp);
2130
+ if (hostname === event.destinationIp) return event;
2131
+ return {
2132
+ ...event,
2133
+ url: `https://${hostname}:${event.port}/`
2134
+ };
2135
+ }
1842
2136
  };
1843
2137
  function getSentinelFeatures(tier) {
1844
2138
  switch (tier) {
@@ -2043,6 +2337,7 @@ function printNoServersFound() {
2043
2337
  console.log(import_chalk.default.gray(" \u2022 Claude Code config (.claude.json / .mcp.json)"));
2044
2338
  console.log(import_chalk.default.gray(" \u2022 Windsurf MCP config"));
2045
2339
  console.log(import_chalk.default.gray(" \u2022 VS Code MCP config (.vscode/mcp.json)"));
2340
+ console.log(import_chalk.default.gray(" \u2022 OpenClaw config (~/.openclaw/openclaw.json)"));
2046
2341
  console.log("");
2047
2342
  console.log(import_chalk.default.gray(" If you have MCP servers configured elsewhere, use:"));
2048
2343
  console.log(import_chalk.default.cyan(" vigile-scan --config /path/to/config.json"));
@@ -2204,8 +2499,8 @@ function formatJSON(summary) {
2204
2499
  }
2205
2500
 
2206
2501
  // src/api/auth.ts
2207
- var import_promises3 = require("fs/promises");
2208
- var import_path8 = require("path");
2502
+ var import_promises4 = require("fs/promises");
2503
+ var import_path9 = require("path");
2209
2504
  var import_os2 = require("os");
2210
2505
 
2211
2506
  // src/api/client.ts
@@ -2216,7 +2511,19 @@ var VigileApiClient = class {
2216
2511
  baseUrl;
2217
2512
  token;
2218
2513
  constructor(baseUrl, token) {
2219
- this.baseUrl = (baseUrl || process.env.VIGILE_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
2514
+ const rawUrl = (baseUrl || process.env.VIGILE_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
2515
+ try {
2516
+ const u = new URL(rawUrl);
2517
+ if (u.protocol !== "https:" && u.hostname !== "localhost" && u.hostname !== "127.0.0.1") {
2518
+ console.error(`[vigile] API URL must use HTTPS \u2014 falling back to default`);
2519
+ this.baseUrl = DEFAULT_API_URL;
2520
+ } else {
2521
+ this.baseUrl = rawUrl;
2522
+ }
2523
+ } catch {
2524
+ console.error(`[vigile] Invalid API URL \u2014 falling back to default`);
2525
+ this.baseUrl = DEFAULT_API_URL;
2526
+ }
2220
2527
  this.token = token || null;
2221
2528
  }
2222
2529
  get isAuthenticated() {
@@ -2301,8 +2608,8 @@ var VigileApiClient = class {
2301
2608
  };
2302
2609
 
2303
2610
  // src/api/auth.ts
2304
- var CONFIG_DIR = (0, import_path8.join)((0, import_os2.homedir)(), ".vigile");
2305
- var CONFIG_FILE = (0, import_path8.join)(CONFIG_DIR, "config.json");
2611
+ var CONFIG_DIR = (0, import_path9.join)((0, import_os2.homedir)(), ".vigile");
2612
+ var CONFIG_FILE = (0, import_path9.join)(CONFIG_DIR, "config.json");
2306
2613
  async function resolveToken() {
2307
2614
  const envToken = process.env.VIGILE_TOKEN;
2308
2615
  if (envToken && envToken.length > 0) {
@@ -2319,15 +2626,15 @@ async function resolveApiUrl() {
2319
2626
  }
2320
2627
  async function loadConfig() {
2321
2628
  try {
2322
- const raw = await (0, import_promises3.readFile)(CONFIG_FILE, "utf-8");
2629
+ const raw = await (0, import_promises4.readFile)(CONFIG_FILE, "utf-8");
2323
2630
  return JSON.parse(raw);
2324
2631
  } catch {
2325
2632
  return {};
2326
2633
  }
2327
2634
  }
2328
2635
  async function saveConfig(config) {
2329
- await (0, import_promises3.mkdir)(CONFIG_DIR, { recursive: true });
2330
- await (0, import_promises3.writeFile)(CONFIG_FILE, JSON.stringify(config, null, 2), {
2636
+ await (0, import_promises4.mkdir)(CONFIG_DIR, { recursive: true });
2637
+ await (0, import_promises4.writeFile)(CONFIG_FILE, JSON.stringify(config, null, 2), {
2331
2638
  mode: 384
2332
2639
  // Owner read/write only
2333
2640
  });
@@ -2378,158 +2685,2491 @@ async function getAuthenticatedClient() {
2378
2685
  return new VigileApiClient(apiUrl, token);
2379
2686
  }
2380
2687
 
2381
- // src/index.ts
2382
- var VERSION = "0.2.0";
2383
- var program = new import_commander.Command();
2384
- program.name("vigile-scan").description(
2385
- "Security scanner for AI agent tools \u2014 detect tool poisoning, permission abuse, and supply chain attacks in MCP servers and agent skills"
2386
- ).version(VERSION);
2387
- function addScanOptions(cmd) {
2388
- return cmd.option("-j, --json", "Output results as JSON").option("-v, --verbose", "Show detailed findings and score breakdown").option("-c, --config <path>", "Path to a custom MCP config file").option("-o, --output <path>", "Write results to a file").option(
2389
- "--client <client>",
2390
- "Only scan a specific client (claude-desktop, cursor, claude-code, windsurf, vscode)"
2391
- ).option("-s, --skills", "Scan agent skills only (SKILL.md, .mdc rules, CLAUDE.md, etc.)").option("-a, --all", "Scan both MCP servers and agent skills").option("--sentinel", "Enable Sentinel runtime monitoring (Pro+ feature)").option("--sentinel-server <name>", "Monitor a specific MCP server by name").option("--sentinel-duration <seconds>", "Monitoring duration in seconds (default: 120)", parseInt).option("--no-upload", "Skip uploading scan results to Vigile API");
2688
+ // src/scanner/baas/secret-patterns.ts
2689
+ function mask(secret) {
2690
+ if (secret.length <= 8) return "***";
2691
+ return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
2392
2692
  }
2393
- addScanOptions(
2394
- program.command("scan").description("Scan MCP server configurations and agent skill files on this machine")
2395
- ).action(async (options) => {
2396
- await runScan(options);
2397
- });
2398
- addScanOptions(program).action(async (options) => {
2399
- if (!process.argv.slice(2).includes("scan") && !process.argv.slice(2).includes("auth")) {
2400
- await runScan(options);
2401
- }
2402
- });
2403
- var authCmd = program.command("auth").description("Manage Vigile API authentication");
2404
- authCmd.command("login").description("Authenticate with your Vigile API key").argument("[token]", "API key (vgl_...) or JWT token. If omitted, reads from VIGILE_TOKEN env var.").action(async (token) => {
2405
- const resolvedToken = token || process.env.VIGILE_TOKEN;
2406
- if (!resolvedToken) {
2407
- console.log(import_chalk2.default.red(" No token provided. Pass a token argument or set VIGILE_TOKEN env var."));
2408
- console.log(import_chalk2.default.gray(" Usage: vigile-scan auth login <vgl_your_api_key>"));
2409
- console.log(import_chalk2.default.gray(" Get an API key at https://vigile.dev/account"));
2410
- process.exit(1);
2411
- }
2412
- const spinner = (0, import_ora.default)("Validating token...").start();
2413
- const result = await authLogin(resolvedToken);
2414
- if (result.success && result.user) {
2415
- spinner.succeed("Token validated");
2416
- printAuthLoginSuccess(result.user.email, result.user.tier);
2417
- } else {
2418
- spinner.fail("Authentication failed");
2419
- console.log(import_chalk2.default.red(` Error: ${result.error || "Unknown error"}`));
2420
- process.exit(1);
2421
- }
2422
- });
2423
- authCmd.command("status").description("Show current authentication status").action(async () => {
2424
- const result = await authStatus();
2425
- printAuthStatus({
2426
- authenticated: result.authenticated,
2427
- source: result.source,
2428
- email: result.user?.email,
2429
- tier: result.user?.tier,
2430
- name: result.user?.name || void 0,
2431
- error: result.error
2432
- });
2433
- });
2434
- authCmd.command("logout").description("Clear stored credentials").action(async () => {
2435
- await authLogout();
2436
- console.log(import_chalk2.default.green(" Logged out. Credentials removed from ~/.vigile/config.json"));
2437
- console.log("");
2438
- });
2439
- async function runScan(options) {
2440
- const isJSON = options.json ?? false;
2441
- const scanMCP = !options.skills;
2442
- const scanSkills = options.skills || options.all;
2443
- if (!isJSON) {
2444
- printBanner();
2445
- }
2446
- const results = [];
2447
- const skillResults = [];
2448
- if (scanMCP) {
2449
- const spinner = isJSON ? null : (0, import_ora.default)("Discovering MCP configurations...").start();
2450
- const discovery = await discoverAllServers(options.client);
2451
- if (discovery.servers.length === 0) {
2452
- spinner?.succeed("No MCP server configurations found");
2453
- } else {
2454
- spinner?.succeed(
2455
- `Found ${discovery.servers.length} MCP server(s) across ${discovery.configsFound} config file(s)`
2456
- );
2457
- const scanSpinner = isJSON ? null : (0, import_ora.default)("Scanning MCP servers...").start();
2458
- for (const server of discovery.servers) {
2459
- const result = await scanServer(server);
2460
- results.push(result);
2461
- }
2462
- scanSpinner?.succeed(`Scanned ${results.length} MCP server(s)`);
2463
- }
2464
- }
2465
- if (scanSkills) {
2466
- const spinner = isJSON ? null : (0, import_ora.default)("Discovering agent skill files...").start();
2467
- const skillDiscovery = await discoverAllSkills();
2468
- if (skillDiscovery.skills.length === 0) {
2469
- spinner?.succeed("No agent skill files found");
2470
- } else {
2471
- spinner?.succeed(
2472
- `Found ${skillDiscovery.skills.length} skill file(s) across ${skillDiscovery.locationsFound} location(s)`
2473
- );
2474
- const scanSpinner = isJSON ? null : (0, import_ora.default)("Scanning agent skills...").start();
2475
- for (const skill of skillDiscovery.skills) {
2476
- const result = await scanSkill(skill);
2477
- skillResults.push(result);
2478
- }
2479
- scanSpinner?.succeed(`Scanned ${skillResults.length} skill file(s)`);
2480
- }
2481
- }
2482
- if (results.length === 0 && skillResults.length === 0) {
2483
- if (!isJSON) {
2484
- if (scanMCP && !scanSkills) {
2485
- printNoServersFound();
2486
- } else if (scanSkills && !scanMCP) {
2487
- printNoSkillsFound();
2488
- } else {
2489
- printNothingFound();
2490
- }
2491
- } else {
2492
- console.log(JSON.stringify({ servers: [], skills: [], message: "Nothing found to scan" }));
2493
- }
2494
- return;
2495
- }
2496
- const allResults = [...results];
2497
- const allSkillResults = [...skillResults];
2498
- const allTrustLevels = [
2499
- ...results.map((r) => r.trustLevel),
2500
- ...skillResults.map((r) => r.trustLevel)
2501
- ];
2502
- const allFindings = [
2503
- ...results.flatMap((r) => r.findings),
2504
- ...skillResults.flatMap((r) => r.findings)
2505
- ];
2506
- const summary = {
2507
- totalServers: allResults.length,
2508
- totalSkills: allSkillResults.length,
2509
- byTrustLevel: {
2510
- trusted: allTrustLevels.filter((l) => l === "trusted").length,
2511
- caution: allTrustLevels.filter((l) => l === "caution").length,
2512
- risky: allTrustLevels.filter((l) => l === "risky").length,
2513
- dangerous: allTrustLevels.filter((l) => l === "dangerous").length
2514
- },
2515
- bySeverity: {
2516
- critical: allFindings.filter((f) => f.severity === "critical").length,
2517
- high: allFindings.filter((f) => f.severity === "high").length,
2518
- medium: allFindings.filter((f) => f.severity === "medium").length,
2519
- low: allFindings.filter((f) => f.severity === "low").length,
2520
- info: allFindings.filter((f) => f.severity === "info").length
2521
- },
2522
- results: allResults,
2523
- skillResults: allSkillResults,
2693
+ var SECRET_PATTERNS = [
2694
+ // ── AI / LLM Providers ──
2695
+ {
2696
+ id: "SP-001",
2697
+ name: "OpenAI API Key",
2698
+ provider: "openai",
2699
+ severity: "critical",
2700
+ pattern: /sk-[A-Za-z0-9]{48}/,
2701
+ description: "OpenAI API key grants full access to GPT-4, Whisper, DALL-E, and all models. Exposed keys result in immediate billing charges.",
2702
+ recommendation: "Rotate at platform.openai.com/api-keys immediately. Move all AI calls to a server-side proxy."
2703
+ },
2704
+ {
2705
+ id: "SP-002",
2706
+ name: "OpenAI Project API Key",
2707
+ provider: "openai",
2708
+ severity: "critical",
2709
+ pattern: /sk-proj-[A-Za-z0-9_-]{80,120}/,
2710
+ description: "OpenAI project-scoped API key. Exposed keys grant API access within the project scope.",
2711
+ recommendation: "Rotate at platform.openai.com/api-keys. Never include in client-side JavaScript."
2712
+ },
2713
+ {
2714
+ id: "SP-003",
2715
+ name: "OpenAI Organization ID",
2716
+ provider: "openai",
2717
+ severity: "medium",
2718
+ pattern: /org-[A-Za-z0-9]{24}/,
2719
+ description: "OpenAI organization identifier. Exposure can leak organizational structure.",
2720
+ recommendation: "Treat as sensitive. Pass server-side only."
2721
+ },
2722
+ {
2723
+ id: "SP-004",
2724
+ name: "Anthropic API Key",
2725
+ provider: "anthropic",
2726
+ severity: "critical",
2727
+ pattern: /sk-ant-api\d{2}-[A-Za-z0-9_-]{93,100}/,
2728
+ description: "Anthropic Claude API key. Grants access to Claude Sonnet, Opus, and Haiku. Full billing exposure.",
2729
+ recommendation: "Rotate at console.anthropic.com. All Claude API calls must be server-side."
2730
+ },
2731
+ {
2732
+ id: "SP-005",
2733
+ name: "Anthropic Session Token",
2734
+ provider: "anthropic",
2735
+ severity: "critical",
2736
+ pattern: /sk-ant-oau\d{2}-[A-Za-z0-9_-]{80,}/,
2737
+ description: "Anthropic OAuth session token. Short-lived but grants full account access if intercepted.",
2738
+ recommendation: "These should never appear in bundles. Remove immediately and revoke session."
2739
+ },
2740
+ {
2741
+ id: "SP-006",
2742
+ name: "HuggingFace User Token",
2743
+ provider: "huggingface",
2744
+ severity: "high",
2745
+ pattern: /hf_[A-Za-z0-9]{34,40}/,
2746
+ description: "HuggingFace API token. Grants access to models, datasets, and inference API.",
2747
+ recommendation: "Rotate at huggingface.co/settings/tokens. Use read-only tokens for public model access."
2748
+ },
2749
+ {
2750
+ id: "SP-007",
2751
+ name: "HuggingFace Org Token",
2752
+ provider: "huggingface",
2753
+ severity: "high",
2754
+ pattern: /api_org_[A-Za-z0-9]{34,40}/,
2755
+ description: "HuggingFace organization API token with org-level privileges.",
2756
+ recommendation: "Rotate at huggingface.co/settings/tokens. Grant minimum required permissions."
2757
+ },
2758
+ {
2759
+ id: "SP-008",
2760
+ name: "Replicate API Token",
2761
+ provider: "replicate",
2762
+ severity: "high",
2763
+ pattern: /r8_[A-Za-z0-9]{35,40}/,
2764
+ description: "Replicate API token. Grants access to run ML models with per-second billing.",
2765
+ recommendation: "Rotate at replicate.com/account/api-tokens. All inference must be server-side."
2766
+ },
2767
+ {
2768
+ id: "SP-009",
2769
+ name: "Cohere API Key",
2770
+ provider: "cohere",
2771
+ severity: "high",
2772
+ pattern: /(?:co-|COHERE_API_KEY['":\s=]+)([A-Za-z0-9]{40,50})/,
2773
+ description: "Cohere API key for language model inference and embeddings.",
2774
+ recommendation: "Rotate at dashboard.cohere.com/api-keys."
2775
+ },
2776
+ {
2777
+ id: "SP-010",
2778
+ name: "Groq API Key",
2779
+ provider: "groq",
2780
+ severity: "high",
2781
+ pattern: /gsk_[A-Za-z0-9]{48,60}/,
2782
+ description: "Groq API key. Grants ultra-fast LLM inference on Llama, Mixtral, Gemma models.",
2783
+ recommendation: "Rotate at console.groq.com/keys. Never ship in client bundles."
2784
+ },
2785
+ {
2786
+ id: "SP-011",
2787
+ name: "Together AI API Key",
2788
+ provider: "together-ai",
2789
+ severity: "high",
2790
+ pattern: /(?:TOGETHER_API_KEY|together_api_key)['":\s=]+([A-Za-z0-9]{64})/i,
2791
+ description: "Together AI API key for open-source LLM inference.",
2792
+ recommendation: "Rotate at api.together.xyz/settings/api-keys."
2793
+ },
2794
+ {
2795
+ id: "SP-012",
2796
+ name: "Mistral AI API Key",
2797
+ provider: "mistral",
2798
+ severity: "high",
2799
+ pattern: /(?:MISTRAL_API_KEY|mistral_api_key)['":\s=]+([A-Za-z0-9]{32,40})/i,
2800
+ description: "Mistral AI API key for Mistral-7B, Mixtral, and Codestral models.",
2801
+ recommendation: "Rotate at console.mistral.ai/api-keys."
2802
+ },
2803
+ {
2804
+ id: "SP-013",
2805
+ name: "Perplexity API Key",
2806
+ provider: "perplexity",
2807
+ severity: "high",
2808
+ pattern: /pplx-[A-Za-z0-9]{48,60}/,
2809
+ description: "Perplexity AI API key for search-augmented language model access.",
2810
+ recommendation: "Rotate at perplexity.ai/settings/api."
2811
+ },
2812
+ {
2813
+ id: "SP-014",
2814
+ name: "Cerebras API Key",
2815
+ provider: "cerebras",
2816
+ severity: "high",
2817
+ pattern: /csk-[A-Za-z0-9]{40,60}/,
2818
+ description: "Cerebras API key for ultra-fast inference on Llama models.",
2819
+ recommendation: "Rotate at cloud.cerebras.ai. All inference must be server-side."
2820
+ },
2821
+ {
2822
+ id: "SP-015",
2823
+ name: "OpenRouter API Key",
2824
+ provider: "openrouter",
2825
+ severity: "high",
2826
+ pattern: /sk-or-[A-Za-z0-9]{40,60}/,
2827
+ description: "OpenRouter API key providing access to 100+ AI models through a unified API.",
2828
+ recommendation: "Rotate at openrouter.ai/keys."
2829
+ },
2830
+ {
2831
+ id: "SP-016",
2832
+ name: "ElevenLabs API Key",
2833
+ provider: "elevenlabs",
2834
+ severity: "high",
2835
+ pattern: /(?:ELEVENLABS_API_KEY|xi-api-key)['":\s=]+([A-Za-z0-9_-]{32,50})/i,
2836
+ description: "ElevenLabs voice synthesis API key. Grants text-to-speech and voice cloning access.",
2837
+ recommendation: "Rotate at elevenlabs.io/profile/api-key."
2838
+ },
2839
+ {
2840
+ id: "SP-017",
2841
+ name: "AssemblyAI API Key",
2842
+ provider: "assemblyai",
2843
+ severity: "high",
2844
+ pattern: /(?:ASSEMBLYAI_API_KEY)['":\s=]+([a-f0-9]{40,50})/i,
2845
+ description: "AssemblyAI transcription API key. Grants access to speech-to-text and audio intelligence.",
2846
+ recommendation: "Rotate at www.assemblyai.com/app/account."
2847
+ },
2848
+ {
2849
+ id: "SP-018",
2850
+ name: "Deepgram API Key",
2851
+ provider: "deepgram",
2852
+ severity: "high",
2853
+ pattern: /(?:DEEPGRAM_API_KEY)['":\s=]+([a-f0-9]{40,50})/i,
2854
+ description: "Deepgram speech recognition API key.",
2855
+ recommendation: "Rotate at console.deepgram.com/project/api-key."
2856
+ },
2857
+ {
2858
+ id: "SP-019",
2859
+ name: "Stability AI API Key",
2860
+ provider: "stability-ai",
2861
+ severity: "high",
2862
+ pattern: /sk-[A-Za-z0-9]{32,50}(?=['"}\s])/,
2863
+ description: "Stability AI key for image generation (Stable Diffusion). Overlaps with sk- prefix \u2014 validate by context.",
2864
+ recommendation: "Rotate at platform.stability.ai/account/keys."
2865
+ },
2866
+ {
2867
+ id: "SP-020",
2868
+ name: "LangSmith API Key",
2869
+ provider: "langsmith",
2870
+ severity: "medium",
2871
+ pattern: /ls__[A-Za-z0-9_-]{32,50}/,
2872
+ description: "LangSmith API key for LLM observability and tracing.",
2873
+ recommendation: "Rotate at smith.langchain.com/settings."
2874
+ },
2875
+ {
2876
+ id: "SP-021",
2877
+ name: "Weights & Biases API Key",
2878
+ provider: "wandb",
2879
+ severity: "medium",
2880
+ pattern: /(?:WANDB_API_KEY)['":\s=]+([a-f0-9]{40})/i,
2881
+ description: "Weights & Biases ML experiment tracking API key.",
2882
+ recommendation: "Rotate at wandb.ai/authorize."
2883
+ },
2884
+ {
2885
+ id: "SP-022",
2886
+ name: "Helicone API Key",
2887
+ provider: "helicone",
2888
+ severity: "medium",
2889
+ pattern: /sk-helicone-[A-Za-z0-9_-]{30,60}/,
2890
+ description: "Helicone LLM observability API key for caching and monitoring AI calls.",
2891
+ recommendation: "Rotate at helicone.ai/settings."
2892
+ },
2893
+ {
2894
+ id: "SP-023",
2895
+ name: "E2B API Key",
2896
+ provider: "e2b",
2897
+ severity: "high",
2898
+ pattern: /e2b_[A-Za-z0-9_-]{30,50}/,
2899
+ description: "E2B sandbox API key. Grants ability to spin up code execution sandboxes \u2014 billing risk.",
2900
+ recommendation: "Rotate at e2b.dev/dashboard."
2901
+ },
2902
+ {
2903
+ id: "SP-024",
2904
+ name: "Tavily Search API Key",
2905
+ provider: "tavily",
2906
+ severity: "medium",
2907
+ pattern: /tvly-[A-Za-z0-9_-]{30,50}/,
2908
+ description: "Tavily AI search API key.",
2909
+ recommendation: "Rotate at app.tavily.com."
2910
+ },
2911
+ {
2912
+ id: "SP-025",
2913
+ name: "Firecrawl API Key",
2914
+ provider: "firecrawl",
2915
+ severity: "medium",
2916
+ pattern: /fc-[A-Za-z0-9]{32,50}/,
2917
+ description: "Firecrawl web scraping API key.",
2918
+ recommendation: "Rotate at firecrawl.dev/account."
2919
+ },
2920
+ {
2921
+ id: "SP-026",
2922
+ name: "Jina AI API Key",
2923
+ provider: "jina",
2924
+ severity: "medium",
2925
+ pattern: /jina_[A-Za-z0-9_-]{50,80}/,
2926
+ description: "Jina AI API key for embeddings and search.",
2927
+ recommendation: "Rotate at jina.ai/dashboard."
2928
+ },
2929
+ {
2930
+ id: "SP-027",
2931
+ name: "Apify API Token",
2932
+ provider: "apify",
2933
+ severity: "medium",
2934
+ pattern: /apify_api_[A-Za-z0-9]{40,60}/,
2935
+ description: "Apify web scraping platform API token.",
2936
+ recommendation: "Rotate at console.apify.com/account/integrations."
2937
+ },
2938
+ // ── Cloud Providers ──
2939
+ {
2940
+ id: "SP-028",
2941
+ name: "AWS Access Key ID",
2942
+ provider: "aws",
2943
+ severity: "critical",
2944
+ pattern: /AKIA[A-Z0-9]{16}/,
2945
+ description: "AWS IAM Access Key ID. Pair this with a secret key to authenticate against any AWS service.",
2946
+ recommendation: "Rotate immediately in AWS IAM console. Enable MFA. Use instance profiles instead of long-lived keys."
2947
+ },
2948
+ {
2949
+ id: "SP-029",
2950
+ name: "AWS Secret Access Key",
2951
+ provider: "aws",
2952
+ severity: "critical",
2953
+ pattern: /(?:aws[_-]?secret[_-]?(?:access[_-]?)?key|AWS_SECRET(?:_ACCESS_KEY)?)['":\s=]+([A-Za-z0-9/+=]{40})/i,
2954
+ description: "AWS IAM Secret Access Key. Combined with key ID, provides full AWS service access.",
2955
+ recommendation: "Rotate immediately. Audit CloudTrail for unauthorized usage. Use IAM roles instead."
2956
+ },
2957
+ {
2958
+ id: "SP-030",
2959
+ name: "AWS Session Token",
2960
+ provider: "aws",
2961
+ severity: "high",
2962
+ pattern: /(?:AWS_SESSION_TOKEN|aws_session_token)['":\s=]+([A-Za-z0-9/+=]{200,600})/i,
2963
+ description: "Temporary AWS STS session token. Short-lived but grants API access until expiry.",
2964
+ recommendation: "These expire automatically but should not be in client code."
2965
+ },
2966
+ {
2967
+ id: "SP-031",
2968
+ name: "Google Cloud API Key",
2969
+ provider: "google-cloud",
2970
+ severity: "critical",
2971
+ pattern: /AIza[0-9A-Za-z\-_]{35}/,
2972
+ description: "Google Cloud / Firebase API key. Grants access to Google Maps, Firebase, YouTube, and other Google APIs depending on restrictions.",
2973
+ recommendation: "Add API key restrictions in Google Cloud Console. Rotate at console.cloud.google.com/apis/credentials."
2974
+ },
2975
+ {
2976
+ id: "SP-032",
2977
+ name: "Google Service Account Key",
2978
+ provider: "google-cloud",
2979
+ severity: "critical",
2980
+ pattern: /"private_key"\s*:\s*"-----BEGIN RSA PRIVATE KEY/,
2981
+ description: "Google Cloud service account private key embedded in source. Grants IAM service account impersonation.",
2982
+ recommendation: "Revoke the service account key immediately. Use Workload Identity or ADC instead of key files."
2983
+ },
2984
+ {
2985
+ id: "SP-033",
2986
+ name: "Google OAuth Client Secret",
2987
+ provider: "google-cloud",
2988
+ severity: "high",
2989
+ pattern: /GOCSPX-[A-Za-z0-9_-]{28}/,
2990
+ description: "Google OAuth 2.0 client secret. Enables impersonation of your OAuth application.",
2991
+ recommendation: "Rotate at console.cloud.google.com/apis/credentials. Keep client secrets server-side only."
2992
+ },
2993
+ {
2994
+ id: "SP-034",
2995
+ name: "Firebase Web API Key",
2996
+ provider: "firebase",
2997
+ severity: "medium",
2998
+ pattern: /(?:NEXT_PUBLIC_FIREBASE_API_KEY|VITE_FIREBASE_API_KEY|firebase(?:Config)?\.apiKey)['":\s=]+["']?(AIza[0-9A-Za-z\-_]{35})/i,
2999
+ description: "Firebase Web API Key in environment variable. The key itself is semi-public but should be paired with proper security rules.",
3000
+ recommendation: "Restrict key usage in Firebase Console. Ensure Firestore and Storage rules are not open."
3001
+ },
3002
+ {
3003
+ id: "SP-035",
3004
+ name: "Firebase Service Account",
3005
+ provider: "firebase",
3006
+ severity: "critical",
3007
+ pattern: /(?:FIREBASE_SERVICE_ACCOUNT|firebase[_-]admin[_-]sdk)['":\s=]+/i,
3008
+ description: "Firebase Admin SDK service account credentials. Grants admin-level access bypassing all security rules.",
3009
+ recommendation: "Never include Firebase Admin credentials in client bundles. Use client SDK on frontend, Admin SDK on backend only."
3010
+ },
3011
+ {
3012
+ id: "SP-036",
3013
+ name: "Supabase Service Role Key",
3014
+ provider: "supabase",
3015
+ severity: "critical",
3016
+ pattern: /(?:SUPABASE_SERVICE_ROLE_KEY|SUPABASE_SERVICE_KEY|supabase[_-]service[_-]role)['":\s=]+["']?(eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i,
3017
+ description: "Supabase service role key. Bypasses ALL Row Level Security policies \u2014 full database admin access.",
3018
+ recommendation: "Immediately rotate in Supabase dashboard > Project Settings > API. This key MUST NEVER be in client code."
3019
+ },
3020
+ {
3021
+ id: "SP-037",
3022
+ name: "Supabase Anon Key (Public)",
3023
+ provider: "supabase",
3024
+ severity: "low",
3025
+ pattern: /(?:NEXT_PUBLIC_SUPABASE_ANON_KEY|VITE_SUPABASE_ANON_KEY|supabase[_-]anon[_-]key)['":\s=]+["']?(eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i,
3026
+ description: "Supabase anon/public key. This is designed to be public, but without proper RLS policies, it can expose your data.",
3027
+ recommendation: "Safe to be public ONLY if RLS policies are correctly configured on all tables."
3028
+ },
3029
+ {
3030
+ id: "SP-038",
3031
+ name: "Supabase URL",
3032
+ provider: "supabase",
3033
+ severity: "low",
3034
+ pattern: /https:\/\/[a-z0-9]{20}\.supabase\.co/,
3035
+ description: "Supabase project URL. The unique identifier for your Supabase project.",
3036
+ recommendation: "Exposure is low-risk, but combine with anon key check to assess full exposure surface."
3037
+ },
3038
+ {
3039
+ id: "SP-039",
3040
+ name: "Azure Storage Connection String",
3041
+ provider: "azure",
3042
+ severity: "critical",
3043
+ pattern: /DefaultEndpointsProtocol=https;AccountName=[^;]+;AccountKey=[A-Za-z0-9+/=]{88}/,
3044
+ description: "Azure Storage Account connection string. Grants full read/write access to all blobs, queues, and tables.",
3045
+ recommendation: "Rotate in Azure Portal > Storage Account > Access Keys. Use Managed Identity or SAS tokens instead."
3046
+ },
3047
+ {
3048
+ id: "SP-040",
3049
+ name: "Azure Subscription Key",
3050
+ provider: "azure",
3051
+ severity: "high",
3052
+ pattern: /(?:Ocp-Apim-Subscription-Key|azure[_-]?subscription[_-]?key)['":\s=]+([A-Za-z0-9]{32})/i,
3053
+ description: "Azure API Management subscription key.",
3054
+ recommendation: "Rotate in Azure API Management portal."
3055
+ },
3056
+ {
3057
+ id: "SP-041",
3058
+ name: "Azure AD Client Secret",
3059
+ provider: "azure",
3060
+ severity: "critical",
3061
+ pattern: /(?:AZURE_CLIENT_SECRET|azure[_-]?client[_-]?secret)['":\s=]+([A-Za-z0-9~.@-]{34,40})/i,
3062
+ description: "Azure Active Directory application client secret. Grants OAuth access as the application.",
3063
+ recommendation: "Rotate in Azure AD > App Registrations > Certificates & Secrets."
3064
+ },
3065
+ {
3066
+ id: "SP-042",
3067
+ name: "Cloudflare API Token",
3068
+ provider: "cloudflare",
3069
+ severity: "critical",
3070
+ pattern: /(?:CLOUDFLARE_API_TOKEN|CF_API_TOKEN)['":\s=]+([A-Za-z0-9_-]{40})/i,
3071
+ description: "Cloudflare API token. Grants access to DNS, Workers, Pages, and account settings.",
3072
+ recommendation: "Rotate at dash.cloudflare.com/profile/api-tokens. Use scoped tokens with minimum permissions."
3073
+ },
3074
+ {
3075
+ id: "SP-043",
3076
+ name: "Cloudflare API Key (Global)",
3077
+ provider: "cloudflare",
3078
+ severity: "critical",
3079
+ pattern: /(?:CLOUDFLARE_API_KEY|CF_API_KEY)['":\s=]+([a-f0-9]{37})/i,
3080
+ description: "Cloudflare Global API Key. Grants full account access \u2014 more dangerous than API tokens.",
3081
+ recommendation: "Rotate at dash.cloudflare.com/profile/api-tokens. Prefer scoped API tokens over global keys."
3082
+ },
3083
+ {
3084
+ id: "SP-044",
3085
+ name: "Cloudflare Workers KV Namespace",
3086
+ provider: "cloudflare",
3087
+ severity: "low",
3088
+ pattern: /(?:KV_NAMESPACE_ID|CLOUDFLARE_KV_NAMESPACE)['":\s=]+([a-f0-9]{32})/i,
3089
+ description: "Cloudflare Workers KV namespace identifier.",
3090
+ recommendation: "Low risk alone, but combined with API credentials allows data access."
3091
+ },
3092
+ {
3093
+ id: "SP-045",
3094
+ name: "Vercel API Token",
3095
+ provider: "vercel",
3096
+ severity: "critical",
3097
+ pattern: /(?:VERCEL_TOKEN|vercel[_-]token)['":\s=]+([A-Za-z0-9]{24})/i,
3098
+ description: "Vercel deployment API token. Grants ability to deploy, manage domains, and access secrets.",
3099
+ recommendation: "Rotate at vercel.com/account/tokens."
3100
+ },
3101
+ {
3102
+ id: "SP-046",
3103
+ name: "Vercel Access Token",
3104
+ provider: "vercel",
3105
+ severity: "critical",
3106
+ pattern: /vercel_[A-Za-z0-9]{40,60}/i,
3107
+ description: "Vercel access token for CLI or API authentication.",
3108
+ recommendation: "Rotate at vercel.com/account/tokens."
3109
+ },
3110
+ {
3111
+ id: "SP-047",
3112
+ name: "DigitalOcean Personal Access Token",
3113
+ provider: "digitalocean",
3114
+ severity: "critical",
3115
+ pattern: /dop_v1_[a-f0-9]{64}/,
3116
+ description: "DigitalOcean personal access token. Grants full account access to droplets, databases, and storage.",
3117
+ recommendation: "Rotate at cloud.digitalocean.com/account/api/tokens."
3118
+ },
3119
+ {
3120
+ id: "SP-048",
3121
+ name: "Railway Token",
3122
+ provider: "railway",
3123
+ severity: "high",
3124
+ pattern: /(?:RAILWAY_TOKEN|railway[_-]token)['":\s=]+([A-Za-z0-9_-]{30,50})/i,
3125
+ description: "Railway deployment platform API token.",
3126
+ recommendation: "Rotate at railway.app/account/tokens."
3127
+ },
3128
+ {
3129
+ id: "SP-049",
3130
+ name: "Fly.io API Token",
3131
+ provider: "flyio",
3132
+ severity: "high",
3133
+ pattern: /fo1_[A-Za-z0-9_-]{40,80}/,
3134
+ description: "Fly.io API token for machine deployment and management.",
3135
+ recommendation: "Rotate with: flyctl tokens revoke <token>"
3136
+ },
3137
+ // ── Payment Providers ──
3138
+ {
3139
+ id: "SP-050",
3140
+ name: "Stripe Secret Key (Live)",
3141
+ provider: "stripe",
3142
+ severity: "critical",
3143
+ pattern: /sk_live_[A-Za-z0-9]{24,100}/,
3144
+ description: "Stripe live-mode secret key. Grants full access to create charges, issue refunds, and access customer data.",
3145
+ recommendation: "Rotate immediately at dashboard.stripe.com/apikeys. Never expose in client code."
3146
+ },
3147
+ {
3148
+ id: "SP-051",
3149
+ name: "Stripe Publishable Key (Live)",
3150
+ provider: "stripe",
3151
+ severity: "low",
3152
+ pattern: /pk_live_[A-Za-z0-9]{24,100}/,
3153
+ description: "Stripe live publishable key. This is meant to be public for frontend use.",
3154
+ recommendation: "This key is designed to be public. Ensure it is not confused with the secret key."
3155
+ },
3156
+ {
3157
+ id: "SP-052",
3158
+ name: "Stripe Restricted Key (Live)",
3159
+ provider: "stripe",
3160
+ severity: "high",
3161
+ pattern: /rk_live_[A-Za-z0-9]{24,100}/,
3162
+ description: "Stripe live restricted key with limited permissions. Scope depends on configuration.",
3163
+ recommendation: "Rotate at dashboard.stripe.com/apikeys. Audit which permissions are granted."
3164
+ },
3165
+ {
3166
+ id: "SP-053",
3167
+ name: "Stripe Webhook Secret",
3168
+ provider: "stripe",
3169
+ severity: "high",
3170
+ pattern: /whsec_[A-Za-z0-9]{32,60}/,
3171
+ description: "Stripe webhook endpoint signing secret. Allows forging webhook events.",
3172
+ recommendation: "Rotate webhook secret at dashboard.stripe.com/webhooks. Keep server-side only."
3173
+ },
3174
+ {
3175
+ id: "SP-054",
3176
+ name: "PayPal Client Secret",
3177
+ provider: "paypal",
3178
+ severity: "critical",
3179
+ pattern: /(?:PAYPAL_CLIENT_SECRET|paypal[_-]secret)['":\s=]+([A-Za-z0-9_-]{60,80})/i,
3180
+ description: "PayPal REST API client secret. Grants ability to process payments and access merchant data.",
3181
+ recommendation: "Rotate at developer.paypal.com/developer/applications."
3182
+ },
3183
+ {
3184
+ id: "SP-055",
3185
+ name: "PayPal Access Token",
3186
+ provider: "paypal",
3187
+ severity: "high",
3188
+ pattern: /(?:A21AA|Bearer\s+)[A-Za-z0-9_-]{80,120}/,
3189
+ description: "PayPal OAuth access token. Short-lived but grants API access to payment operations.",
3190
+ recommendation: "Access tokens expire but should not be cached in client code."
3191
+ },
3192
+ {
3193
+ id: "SP-056",
3194
+ name: "Plaid Secret Key",
3195
+ provider: "plaid",
3196
+ severity: "critical",
3197
+ pattern: /(?:PLAID_SECRET|plaid[_-]secret)['":\s=]+([a-f0-9]{30,40})/i,
3198
+ description: "Plaid financial data API secret key. Grants access to bank account and transaction data.",
3199
+ recommendation: "Rotate at dashboard.plaid.com/team/keys."
3200
+ },
3201
+ {
3202
+ id: "SP-057",
3203
+ name: "Plaid Client ID",
3204
+ provider: "plaid",
3205
+ severity: "medium",
3206
+ pattern: /(?:PLAID_CLIENT_ID|plaid[_-]client[_-]id)['":\s=]+([a-f0-9]{24})/i,
3207
+ description: "Plaid application client ID.",
3208
+ recommendation: "Low risk alone, but combined with secret key grants bank data access."
3209
+ },
3210
+ {
3211
+ id: "SP-058",
3212
+ name: "Square Access Token",
3213
+ provider: "square",
3214
+ severity: "critical",
3215
+ pattern: /(?:EAA|sq0atp-)[A-Za-z0-9_-]{22,44}/,
3216
+ description: "Square payment processing access token.",
3217
+ recommendation: "Rotate at developer.squareup.com/apps."
3218
+ },
3219
+ {
3220
+ id: "SP-059",
3221
+ name: "Shopify Admin API Key",
3222
+ provider: "shopify",
3223
+ severity: "critical",
3224
+ pattern: /shpat_[a-fA-F0-9]{32}/,
3225
+ description: "Shopify Admin API access token. Grants full store management capabilities.",
3226
+ recommendation: "Revoke at Shopify Admin > Settings > Apps and Sales Channels > API."
3227
+ },
3228
+ {
3229
+ id: "SP-060",
3230
+ name: "Shopify Storefront Token",
3231
+ provider: "shopify",
3232
+ severity: "medium",
3233
+ pattern: /shpss_[a-fA-F0-9]{32}/,
3234
+ description: "Shopify Storefront API access token. More limited than admin token.",
3235
+ recommendation: "Designed for client use but ensure scopes are minimum necessary."
3236
+ },
3237
+ // ── Communication / Messaging ──
3238
+ {
3239
+ id: "SP-061",
3240
+ name: "Slack Bot Token",
3241
+ provider: "slack",
3242
+ severity: "high",
3243
+ pattern: /xoxb-[0-9]+-[0-9]+-[A-Za-z0-9]+/,
3244
+ description: "Slack bot OAuth token. Grants access to post messages, read channels, and perform bot actions.",
3245
+ recommendation: "Rotate at api.slack.com/apps. Scope tokens to minimum required permissions."
3246
+ },
3247
+ {
3248
+ id: "SP-062",
3249
+ name: "Slack User Token",
3250
+ provider: "slack",
3251
+ severity: "critical",
3252
+ pattern: /xoxp-[0-9]+-[0-9]+-[0-9]+-[A-Za-z0-9]+/,
3253
+ description: "Slack user OAuth token. Acts as the user \u2014 can read all accessible messages and DMs.",
3254
+ recommendation: "Rotate at api.slack.com/apps. User tokens should never be in application code."
3255
+ },
3256
+ {
3257
+ id: "SP-063",
3258
+ name: "Slack App-Level Token",
3259
+ provider: "slack",
3260
+ severity: "high",
3261
+ pattern: /xapp-[0-9]+-[A-Za-z0-9]+-[A-Za-z0-9]+/,
3262
+ description: "Slack app-level token for socket mode and org-wide operations.",
3263
+ recommendation: "Rotate at api.slack.com/apps."
3264
+ },
3265
+ {
3266
+ id: "SP-064",
3267
+ name: "Slack Webhook URL",
3268
+ provider: "slack",
3269
+ severity: "medium",
3270
+ pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/,
3271
+ description: "Slack incoming webhook URL. Allows posting messages to a specific channel.",
3272
+ recommendation: "Rotate at api.slack.com/apps > Incoming Webhooks. Treat as sensitive."
3273
+ },
3274
+ {
3275
+ id: "SP-065",
3276
+ name: "Discord Bot Token",
3277
+ provider: "discord",
3278
+ severity: "critical",
3279
+ pattern: /[MN][A-Za-z0-9_-]{23}\.[\w-]{6}\.[\w-]{27,38}/,
3280
+ description: "Discord bot token. Grants full bot account access \u2014 can read all visible messages and take actions.",
3281
+ recommendation: "Regenerate at discord.com/developers/applications. Immediately revoke if exposed."
3282
+ },
3283
+ {
3284
+ id: "SP-066",
3285
+ name: "Discord Webhook URL",
3286
+ provider: "discord",
3287
+ severity: "medium",
3288
+ pattern: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]+/,
3289
+ description: "Discord webhook URL. Allows posting to a channel without a bot account.",
3290
+ recommendation: "Delete and recreate the webhook at the Discord channel settings."
3291
+ },
3292
+ {
3293
+ id: "SP-067",
3294
+ name: "Discord Client Secret",
3295
+ provider: "discord",
3296
+ severity: "high",
3297
+ pattern: /(?:DISCORD_CLIENT_SECRET|discord[_-]secret)['":\s=]+([A-Za-z0-9_-]{32})/i,
3298
+ description: "Discord OAuth application client secret.",
3299
+ recommendation: "Rotate at discord.com/developers/applications."
3300
+ },
3301
+ {
3302
+ id: "SP-068",
3303
+ name: "Twilio Account SID",
3304
+ provider: "twilio",
3305
+ severity: "medium",
3306
+ pattern: /AC[a-fA-F0-9]{32}/,
3307
+ description: "Twilio Account SID. Identifier needed with auth token to access SMS, voice, and messaging APIs.",
3308
+ recommendation: "The SID alone is low risk \u2014 it requires the auth token to authenticate."
3309
+ },
3310
+ {
3311
+ id: "SP-069",
3312
+ name: "Twilio Auth Token",
3313
+ provider: "twilio",
3314
+ severity: "critical",
3315
+ pattern: /(?:TWILIO_AUTH_TOKEN|twilio[_-]auth[_-]token)['":\s=]+([a-fA-F0-9]{32})/i,
3316
+ description: "Twilio auth token. Combined with Account SID grants full access to messaging, phone numbers, and billing.",
3317
+ recommendation: "Rotate at console.twilio.com. Enable API key authentication instead of master auth token."
3318
+ },
3319
+ {
3320
+ id: "SP-070",
3321
+ name: "Twilio API Key Secret",
3322
+ provider: "twilio",
3323
+ severity: "high",
3324
+ pattern: /(?:SK[a-fA-F0-9]{32})/,
3325
+ description: "Twilio API Key SID (more restricted than auth token, still sensitive).",
3326
+ recommendation: "Rotate at console.twilio.com/user/api-keys."
3327
+ },
3328
+ {
3329
+ id: "SP-071",
3330
+ name: "SendGrid API Key",
3331
+ provider: "sendgrid",
3332
+ severity: "high",
3333
+ pattern: /SG\.[A-Za-z0-9_-]{22,30}\.[A-Za-z0-9_-]{43,50}/,
3334
+ description: "SendGrid email API key. Grants ability to send transactional email at your expense.",
3335
+ recommendation: "Rotate at app.sendgrid.com/settings/api_keys."
3336
+ },
3337
+ {
3338
+ id: "SP-072",
3339
+ name: "Resend API Key",
3340
+ provider: "resend",
3341
+ severity: "high",
3342
+ pattern: /re_[A-Za-z0-9_]{36,40}/,
3343
+ description: "Resend transactional email API key.",
3344
+ recommendation: "Rotate at resend.com/api-keys."
3345
+ },
3346
+ {
3347
+ id: "SP-073",
3348
+ name: "Mailgun API Key",
3349
+ provider: "mailgun",
3350
+ severity: "high",
3351
+ pattern: /key-[a-fA-F0-9]{32}/,
3352
+ description: "Mailgun private API key for sending and receiving email.",
3353
+ recommendation: "Rotate at app.mailgun.com/app/account/security/api_keys."
3354
+ },
3355
+ {
3356
+ id: "SP-074",
3357
+ name: "Mailchimp API Key",
3358
+ provider: "mailchimp",
3359
+ severity: "high",
3360
+ pattern: /[0-9a-f]{32}-us\d{1,2}/,
3361
+ description: "Mailchimp API key. Grants access to mailing lists, campaigns, and subscriber data.",
3362
+ recommendation: "Rotate at account.mailchimp.com/settings/api-keys."
3363
+ },
3364
+ // ── Developer Tools ──
3365
+ {
3366
+ id: "SP-075",
3367
+ name: "GitHub Personal Access Token (Classic)",
3368
+ provider: "github",
3369
+ severity: "critical",
3370
+ pattern: /ghp_[A-Za-z0-9]{36}/,
3371
+ description: "GitHub classic personal access token. Scope depends on permissions granted when created.",
3372
+ recommendation: "Revoke at github.com/settings/tokens. GitHub automatically detects and alerts on these."
3373
+ },
3374
+ {
3375
+ id: "SP-076",
3376
+ name: "GitHub Fine-Grained Token",
3377
+ provider: "github",
3378
+ severity: "critical",
3379
+ pattern: /github_pat_[A-Za-z0-9_]{82}/,
3380
+ description: "GitHub fine-grained personal access token with repository-level permissions.",
3381
+ recommendation: "Revoke at github.com/settings/tokens."
3382
+ },
3383
+ {
3384
+ id: "SP-077",
3385
+ name: "GitHub OAuth App Token",
3386
+ provider: "github",
3387
+ severity: "high",
3388
+ pattern: /gho_[A-Za-z0-9]{36}/,
3389
+ description: "GitHub OAuth app token. Grants access as the user who authorized the app.",
3390
+ recommendation: "Revoke at github.com/settings/applications."
3391
+ },
3392
+ {
3393
+ id: "SP-078",
3394
+ name: "GitHub Actions Token",
3395
+ provider: "github",
3396
+ severity: "high",
3397
+ pattern: /ghs_[A-Za-z0-9]{36}/,
3398
+ description: "GitHub Actions installation token. Short-lived but should not appear in artifacts.",
3399
+ recommendation: "These expire within an hour but should not be logged or exposed in build artifacts."
3400
+ },
3401
+ {
3402
+ id: "SP-079",
3403
+ name: "GitHub App Private Key",
3404
+ provider: "github",
3405
+ severity: "critical",
3406
+ pattern: /ghv_[A-Za-z0-9]{36}/,
3407
+ description: "GitHub repository-scoped token from a GitHub App installation.",
3408
+ recommendation: "Revoke via GitHub App settings."
3409
+ },
3410
+ {
3411
+ id: "SP-080",
3412
+ name: "npm Publish Token",
3413
+ provider: "npm",
3414
+ severity: "critical",
3415
+ pattern: /npm_[A-Za-z0-9]{36}/,
3416
+ description: "npm publish access token. Grants ability to publish packages under your account \u2014 supply chain attack vector.",
3417
+ recommendation: "Revoke at npmjs.com/settings/tokens. This is the highest risk secret for package maintainers."
3418
+ },
3419
+ {
3420
+ id: "SP-081",
3421
+ name: "PyPI API Token",
3422
+ provider: "pypi",
3423
+ severity: "critical",
3424
+ pattern: /pypi-[A-Za-z0-9_-]{32,80}/,
3425
+ description: "PyPI package publish token. Grants ability to publish Python packages \u2014 supply chain attack vector.",
3426
+ recommendation: "Revoke at pypi.org/manage/account/token. Use scoped tokens per-project."
3427
+ },
3428
+ // ── Databases ──
3429
+ {
3430
+ id: "SP-082",
3431
+ name: "PostgreSQL Connection String",
3432
+ provider: "database",
3433
+ severity: "critical",
3434
+ pattern: /postgres(?:ql)?:\/\/[^:@\s]+:[^@\s]+@[^/\s]+\/[^\s"']+/i,
3435
+ description: "PostgreSQL connection string with embedded credentials.",
3436
+ recommendation: "Rotate database credentials. Use connection poolers (PgBouncer, Supabase Pooler) and secrets managers."
3437
+ },
3438
+ {
3439
+ id: "SP-083",
3440
+ name: "MySQL Connection String",
3441
+ provider: "database",
3442
+ severity: "critical",
3443
+ pattern: /mysql:\/\/[^:@\s]+:[^@\s]+@[^/\s]+\/[^\s"']+/i,
3444
+ description: "MySQL connection string with embedded credentials.",
3445
+ recommendation: "Rotate credentials. Never include database URLs in client code."
3446
+ },
3447
+ {
3448
+ id: "SP-084",
3449
+ name: "MongoDB Connection String",
3450
+ provider: "database",
3451
+ severity: "critical",
3452
+ pattern: /mongodb(?:\+srv)?:\/\/[^:@\s]+:[^@\s]+@[^\s"']+/i,
3453
+ description: "MongoDB connection string with embedded credentials.",
3454
+ recommendation: "Rotate credentials. Enable IP allowlisting. Use MongoDB Atlas connection string with IAM auth."
3455
+ },
3456
+ {
3457
+ id: "SP-085",
3458
+ name: "Redis Connection String",
3459
+ provider: "database",
3460
+ severity: "high",
3461
+ pattern: /redis(?:s)?:\/\/(?:[^:@\s]+:[^@\s]+@)?[^/\s]+(?::\d+)?/i,
3462
+ description: "Redis connection string, potentially with authentication credentials.",
3463
+ recommendation: "Enable Redis AUTH. Use TLS (rediss://). Do not expose Redis to the public internet."
3464
+ },
3465
+ {
3466
+ id: "SP-086",
3467
+ name: "PlanetScale Service Token",
3468
+ provider: "planetscale",
3469
+ severity: "critical",
3470
+ pattern: /pscale_tkn_[A-Za-z0-9_]{32,50}/,
3471
+ description: "PlanetScale database service token.",
3472
+ recommendation: "Rotate at app.planetscale.com/settings/service-tokens."
3473
+ },
3474
+ {
3475
+ id: "SP-087",
3476
+ name: "Neon Database URL",
3477
+ provider: "neon",
3478
+ severity: "critical",
3479
+ pattern: /postgresql:\/\/[^:]+:[^@]+@[a-z0-9-]+\.(?:us-east-\d|eu-central-\d)\.aws\.neon\.tech\/[^\s"']+/,
3480
+ description: "Neon serverless PostgreSQL connection URL with credentials.",
3481
+ recommendation: "Rotate credentials at console.neon.tech. Use connection pooling endpoint."
3482
+ },
3483
+ {
3484
+ id: "SP-088",
3485
+ name: "Turso Database URL",
3486
+ provider: "turso",
3487
+ severity: "high",
3488
+ pattern: /libsql:\/\/[a-z0-9-]+-[a-z0-9]+\.turso\.io/,
3489
+ description: "Turso edge SQLite database connection URL.",
3490
+ recommendation: "Combine with auth token check. Rotate at app.turso.tech."
3491
+ },
3492
+ {
3493
+ id: "SP-089",
3494
+ name: "Turso Auth Token",
3495
+ provider: "turso",
3496
+ severity: "high",
3497
+ pattern: /(?:TURSO_AUTH_TOKEN|turso[_-]token)['":\s=]+([A-Za-z0-9_-]{100,200})/i,
3498
+ description: "Turso database auth token.",
3499
+ recommendation: "Rotate at app.turso.tech/databases."
3500
+ },
3501
+ // ── Analytics / Monitoring ──
3502
+ {
3503
+ id: "SP-090",
3504
+ name: "Sentry DSN",
3505
+ provider: "sentry",
3506
+ severity: "low",
3507
+ pattern: /https:\/\/[a-fA-F0-9]{32}@[a-z0-9]+\.ingest\.sentry\.io\/[0-9]+/,
3508
+ description: "Sentry Data Source Name. Can receive arbitrary error events and expose error data to unauthorized parties.",
3509
+ recommendation: "Rate-limit the DSN. Consider using Sentry Security Headers to restrict event submission."
3510
+ },
3511
+ {
3512
+ id: "SP-091",
3513
+ name: "Datadog API Key",
3514
+ provider: "datadog",
3515
+ severity: "high",
3516
+ pattern: /(?:DD_API_KEY|DATADOG_API_KEY)['":\s=]+([a-fA-F0-9]{32})/i,
3517
+ description: "Datadog API key for submitting metrics, logs, and events.",
3518
+ recommendation: "Rotate at app.datadoghq.com/organization-settings/api-keys."
3519
+ },
3520
+ {
3521
+ id: "SP-092",
3522
+ name: "Datadog App Key",
3523
+ provider: "datadog",
3524
+ severity: "high",
3525
+ pattern: /(?:DD_APP_KEY|DATADOG_APP_KEY)['":\s=]+([a-fA-F0-9]{40})/i,
3526
+ description: "Datadog application key for querying and managing Datadog resources.",
3527
+ recommendation: "Rotate at app.datadoghq.com/organization-settings/application-keys."
3528
+ },
3529
+ {
3530
+ id: "SP-093",
3531
+ name: "New Relic License Key",
3532
+ provider: "newrelic",
3533
+ severity: "high",
3534
+ pattern: /(?:NEW_RELIC_LICENSE_KEY|NRLIC)['":\s=]+([A-Za-z0-9]{40})/i,
3535
+ description: "New Relic ingest license key for APM, logs, and infrastructure.",
3536
+ recommendation: "Rotate at one.newrelic.com/api-keys."
3537
+ },
3538
+ {
3539
+ id: "SP-094",
3540
+ name: "PostHog API Key",
3541
+ provider: "posthog",
3542
+ severity: "medium",
3543
+ pattern: /phc_[A-Za-z0-9]{43}/,
3544
+ description: "PostHog project API key. Client-side analytics key \u2014 can be semi-public but controls data ingest.",
3545
+ recommendation: "This key is typically safe on the client, but ensure project-level access controls are configured."
3546
+ },
3547
+ {
3548
+ id: "SP-095",
3549
+ name: "Amplitude API Key",
3550
+ provider: "amplitude",
3551
+ severity: "medium",
3552
+ pattern: /(?:AMPLITUDE_API_KEY|amplitude[_-]api[_-]key)['":\s=]+([a-fA-F0-9]{32})/i,
3553
+ description: "Amplitude analytics project API key.",
3554
+ recommendation: "Low risk for the API key, but the secret key should be server-side only."
3555
+ },
3556
+ {
3557
+ id: "SP-096",
3558
+ name: "Mixpanel Token",
3559
+ provider: "mixpanel",
3560
+ severity: "low",
3561
+ pattern: /(?:MIXPANEL_TOKEN|NEXT_PUBLIC_MIXPANEL)['":\s=]+([a-fA-F0-9]{32})/i,
3562
+ description: "Mixpanel analytics project token. Designed to be public for event tracking.",
3563
+ recommendation: "Project token is safe to be public. Keep the project secret server-side."
3564
+ },
3565
+ {
3566
+ id: "SP-097",
3567
+ name: "Segment Write Key",
3568
+ provider: "segment",
3569
+ severity: "medium",
3570
+ pattern: /(?:SEGMENT_WRITE_KEY|analytics\.load\()['":\s(]+([A-Za-z0-9]{20,30})/i,
3571
+ description: "Segment analytics write key. Allows sending arbitrary events to your workspace.",
3572
+ recommendation: "Client-side write keys are semi-public, but server write keys should be kept private."
3573
+ },
3574
+ // ── Search and Data ──
3575
+ {
3576
+ id: "SP-098",
3577
+ name: "Algolia App ID & API Key",
3578
+ provider: "algolia",
3579
+ severity: "medium",
3580
+ pattern: /(?:ALGOLIA_API_KEY|NEXT_PUBLIC_ALGOLIA_API_KEY)['":\s=]+([A-Za-z0-9]{32})/i,
3581
+ description: "Algolia search API key. Admin key grants full index management; search-only key is safe to expose.",
3582
+ recommendation: "Use search-only API keys on the client. Rotate admin keys at algolia.com/account/api-keys."
3583
+ },
3584
+ {
3585
+ id: "SP-099",
3586
+ name: "Algolia Admin API Key",
3587
+ provider: "algolia",
3588
+ severity: "critical",
3589
+ pattern: /(?:ALGOLIA_ADMIN_API_KEY)['":\s=]+([A-Za-z0-9]{32})/i,
3590
+ description: "Algolia admin API key. Grants full index management including delete operations.",
3591
+ recommendation: "Never expose admin key on client. Rotate at algolia.com/account/api-keys."
3592
+ },
3593
+ {
3594
+ id: "SP-100",
3595
+ name: "Mapbox Access Token",
3596
+ provider: "mapbox",
3597
+ severity: "medium",
3598
+ pattern: /pk\.[a-zA-Z0-9.]+\.[a-zA-Z0-9_-]+/,
3599
+ description: "Mapbox public access token. Designed for client use but can be abused for quota theft.",
3600
+ recommendation: "Restrict token by URL in Mapbox account settings."
3601
+ },
3602
+ // ── Cryptographic / Generic ──
3603
+ {
3604
+ id: "SP-101",
3605
+ name: "RSA Private Key",
3606
+ provider: "cryptographic",
3607
+ severity: "critical",
3608
+ pattern: /-----BEGIN RSA PRIVATE KEY-----/,
3609
+ description: "RSA private key embedded in source. Can be used for decryption or identity impersonation.",
3610
+ recommendation: "Remove immediately. Use secrets managers (AWS Secrets Manager, Vault, Doppler)."
3611
+ },
3612
+ {
3613
+ id: "SP-102",
3614
+ name: "EC Private Key",
3615
+ provider: "cryptographic",
3616
+ severity: "critical",
3617
+ pattern: /-----BEGIN EC PRIVATE KEY-----/,
3618
+ description: "Elliptic curve private key embedded in source.",
3619
+ recommendation: "Remove immediately. Rotate the key pair and audit for any usage."
3620
+ },
3621
+ {
3622
+ id: "SP-103",
3623
+ name: "PKCS8 Private Key",
3624
+ provider: "cryptographic",
3625
+ severity: "critical",
3626
+ pattern: /-----BEGIN PRIVATE KEY-----/,
3627
+ description: "PKCS#8 private key in unencrypted PEM format.",
3628
+ recommendation: "Remove immediately. Use environment variables with proper secret management."
3629
+ },
3630
+ {
3631
+ id: "SP-104",
3632
+ name: "PGP Private Key",
3633
+ provider: "cryptographic",
3634
+ severity: "critical",
3635
+ pattern: /-----BEGIN PGP PRIVATE KEY BLOCK-----/,
3636
+ description: "PGP/GPG private key embedded in source.",
3637
+ recommendation: "Remove and revoke the key at your keyserver."
3638
+ },
3639
+ {
3640
+ id: "SP-105",
3641
+ name: "Generic JWT Secret",
3642
+ provider: "generic",
3643
+ severity: "high",
3644
+ pattern: /(?:JWT_SECRET|jwt[_-]secret|JWT_SIGNING_KEY)['":\s=]+["']?([A-Za-z0-9_+/=-]{32,})/i,
3645
+ description: "JWT signing secret. Anyone with this secret can forge valid authentication tokens for your application.",
3646
+ recommendation: "Rotate the secret and invalidate all existing tokens. Use asymmetric keys (RS256/ES256) instead of symmetric."
3647
+ },
3648
+ {
3649
+ id: "SP-106",
3650
+ name: "Generic API Secret",
3651
+ provider: "generic",
3652
+ severity: "high",
3653
+ pattern: /(?:API_SECRET|app[_-]secret|CLIENT_SECRET)['":\s=]+["']?([A-Za-z0-9_+/=.-]{32,})/i,
3654
+ description: "Generic application secret or client secret.",
3655
+ recommendation: "Audit the usage of this secret and rotate if exposed."
3656
+ },
3657
+ {
3658
+ id: "SP-107",
3659
+ name: "Generic Webhook Secret",
3660
+ provider: "generic",
3661
+ severity: "high",
3662
+ pattern: /(?:WEBHOOK_SECRET|webhook[_-]signing[_-]key)['":\s=]+["']?([A-Za-z0-9_+/=-]{32,})/i,
3663
+ description: "Webhook signing secret. Allows forging webhook payloads from third-party services.",
3664
+ recommendation: "Rotate the webhook secret at the originating service."
3665
+ },
3666
+ // ── Social Platforms ──
3667
+ {
3668
+ id: "SP-108",
3669
+ name: "Twitter/X Bearer Token",
3670
+ provider: "twitter",
3671
+ severity: "high",
3672
+ pattern: /AAAAAAAAAAAAAAAAAAAAAA[A-Za-z0-9%]+/,
3673
+ description: "Twitter/X API v2 bearer token. Grants read access to public Twitter data.",
3674
+ recommendation: "Rotate at developer.twitter.com/en/apps."
3675
+ },
3676
+ {
3677
+ id: "SP-109",
3678
+ name: "Twitter API Key",
3679
+ provider: "twitter",
3680
+ severity: "high",
3681
+ pattern: /(?:TWITTER_API_KEY|TWITTER_CONSUMER_KEY)['":\s=]+([A-Za-z0-9]{25})/i,
3682
+ description: "Twitter/X API consumer key.",
3683
+ recommendation: "Rotate at developer.twitter.com/en/apps."
3684
+ },
3685
+ {
3686
+ id: "SP-110",
3687
+ name: "Twitter API Secret",
3688
+ provider: "twitter",
3689
+ severity: "critical",
3690
+ pattern: /(?:TWITTER_API_SECRET|TWITTER_CONSUMER_SECRET)['":\s=]+([A-Za-z0-9]{50})/i,
3691
+ description: "Twitter/X API consumer secret. Combined with key grants OAuth application access.",
3692
+ recommendation: "Rotate at developer.twitter.com/en/apps. Required to regenerate if compromised."
3693
+ },
3694
+ {
3695
+ id: "SP-111",
3696
+ name: "HubSpot Private App Token",
3697
+ provider: "hubspot",
3698
+ severity: "high",
3699
+ pattern: /pat-(?:na|eu)\d-[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/,
3700
+ description: "HubSpot private app access token. Grants CRM data access.",
3701
+ recommendation: "Rotate at app.hubspot.com/private-apps."
3702
+ },
3703
+ {
3704
+ id: "SP-112",
3705
+ name: "Notion Integration Token",
3706
+ provider: "notion",
3707
+ severity: "high",
3708
+ pattern: /secret_[A-Za-z0-9]{43}/,
3709
+ description: "Notion internal integration token. Grants access to all pages shared with the integration.",
3710
+ recommendation: "Rotate at notion.so/my-integrations."
3711
+ },
3712
+ {
3713
+ id: "SP-113",
3714
+ name: "Airtable API Key",
3715
+ provider: "airtable",
3716
+ severity: "high",
3717
+ pattern: /(?:AIRTABLE_API_KEY)['":\s=]+([A-Za-z0-9]{17})/i,
3718
+ description: "Airtable personal API key. Grants access to all bases the account can access.",
3719
+ recommendation: "Rotate at airtable.com/account. Prefer scoped personal access tokens."
3720
+ },
3721
+ {
3722
+ id: "SP-114",
3723
+ name: "Airtable Personal Access Token",
3724
+ provider: "airtable",
3725
+ severity: "high",
3726
+ pattern: /pat[A-Za-z0-9]{14}\.[a-fA-F0-9]{64}/,
3727
+ description: "Airtable scoped personal access token.",
3728
+ recommendation: "Rotate at airtable.com/create/tokens."
3729
+ },
3730
+ {
3731
+ id: "SP-115",
3732
+ name: "Linear API Key",
3733
+ provider: "linear",
3734
+ severity: "high",
3735
+ pattern: /lin_api_[A-Za-z0-9]{40}/,
3736
+ description: "Linear project management API key. Grants access to issues, projects, and teams.",
3737
+ recommendation: "Rotate at linear.app/settings/api."
3738
+ },
3739
+ // ── Infrastructure / DevOps ──
3740
+ {
3741
+ id: "SP-116",
3742
+ name: "HashiCorp Vault Token",
3743
+ provider: "hashicorp",
3744
+ severity: "critical",
3745
+ pattern: /(?:VAULT_TOKEN|hvs\.[A-Za-z0-9]+)/,
3746
+ description: "HashiCorp Vault service token. Grants access to secrets stored in Vault.",
3747
+ recommendation: "Revoke with vault token revoke. Enable token TTLs and audit logging."
3748
+ },
3749
+ {
3750
+ id: "SP-117",
3751
+ name: "Kubernetes Service Account Token",
3752
+ provider: "kubernetes",
3753
+ severity: "critical",
3754
+ pattern: /eyJhbGciOiJSUzI1NiIsImtpZCI[A-Za-z0-9_-]+\.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50/,
3755
+ description: "Kubernetes service account JWT token. Grants API server access within the cluster.",
3756
+ recommendation: "Rotate the service account token. Use short-lived projected tokens (TokenRequest API)."
3757
+ },
3758
+ {
3759
+ id: "SP-118",
3760
+ name: "Cloudinary URL",
3761
+ provider: "cloudinary",
3762
+ severity: "high",
3763
+ pattern: /cloudinary:\/\/[0-9]+:[A-Za-z0-9_-]+@[a-z0-9_]+/,
3764
+ description: "Cloudinary media storage URL with API credentials.",
3765
+ recommendation: "Rotate at cloudinary.com/console. This grants upload and transformation access."
3766
+ },
3767
+ {
3768
+ id: "SP-119",
3769
+ name: "Upstash Redis Token",
3770
+ provider: "upstash",
3771
+ severity: "high",
3772
+ pattern: /(?:UPSTASH_REDIS_REST_TOKEN|upstash[_-]token)['":\s=]+([A-Za-z0-9_-]{50,100})/i,
3773
+ description: "Upstash serverless Redis REST API token.",
3774
+ recommendation: "Rotate at console.upstash.com."
3775
+ },
3776
+ {
3777
+ id: "SP-120",
3778
+ name: "Upstash QStash Token",
3779
+ provider: "upstash",
3780
+ severity: "high",
3781
+ pattern: /(?:QSTASH_TOKEN)['":\s=]+([A-Za-z0-9_-]{100,200})/i,
3782
+ description: "Upstash QStash serverless message queue token.",
3783
+ recommendation: "Rotate at console.upstash.com/qstash."
3784
+ },
3785
+ // ── Additional AI/ML Platforms ──
3786
+ {
3787
+ id: "SP-121",
3788
+ name: "Portkey API Key",
3789
+ provider: "portkey",
3790
+ severity: "medium",
3791
+ pattern: /(?:PORTKEY_API_KEY)['":\s=]+([A-Za-z0-9_-]{40,60})/i,
3792
+ description: "Portkey AI gateway API key.",
3793
+ recommendation: "Rotate at app.portkey.ai/settings."
3794
+ },
3795
+ {
3796
+ id: "SP-122",
3797
+ name: "Langfuse Secret Key",
3798
+ provider: "langfuse",
3799
+ severity: "medium",
3800
+ pattern: /sk-lf-[A-Za-z0-9_-]{36,50}/,
3801
+ description: "Langfuse LLM observability secret key.",
3802
+ recommendation: "Rotate at cloud.langfuse.com/project/settings."
3803
+ },
3804
+ {
3805
+ id: "SP-123",
3806
+ name: "Weaviate API Key",
3807
+ provider: "weaviate",
3808
+ severity: "high",
3809
+ pattern: /(?:WEAVIATE_API_KEY)['":\s=]+([A-Za-z0-9_-]{32,50})/i,
3810
+ description: "Weaviate vector database API key.",
3811
+ recommendation: "Rotate at the Weaviate Cloud Services console."
3812
+ },
3813
+ {
3814
+ id: "SP-124",
3815
+ name: "Pinecone API Key",
3816
+ provider: "pinecone",
3817
+ severity: "high",
3818
+ pattern: /(?:PINECONE_API_KEY)['":\s=]+([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})/i,
3819
+ description: "Pinecone vector database API key. Grants access to vector indices and stored embeddings.",
3820
+ recommendation: "Rotate at app.pinecone.io/organization/api-keys."
3821
+ },
3822
+ {
3823
+ id: "SP-125",
3824
+ name: "Browserbase API Key",
3825
+ provider: "browserbase",
3826
+ severity: "high",
3827
+ pattern: /(?:BROWSERBASE_API_KEY)['":\s=]+([A-Za-z0-9_-]{32,50})/i,
3828
+ description: "Browserbase cloud browser API key for web automation.",
3829
+ recommendation: "Rotate at browserbase.com/settings."
3830
+ },
3831
+ // ── Payments (extended) ──
3832
+ {
3833
+ id: "SP-126",
3834
+ name: "Braintree API Key",
3835
+ provider: "braintree",
3836
+ severity: "critical",
3837
+ pattern: /(?:BRAINTREE_PRIVATE_KEY|braintree[_-]private[_-]key)['":\s=]+([A-Za-z0-9]{32})/i,
3838
+ description: "Braintree payment processing private key.",
3839
+ recommendation: "Rotate at sandbox.braintreegateway.com or braintreegateway.com."
3840
+ },
3841
+ // ── Communication (extended) ──
3842
+ {
3843
+ id: "SP-127",
3844
+ name: "Pusher App Secret",
3845
+ provider: "pusher",
3846
+ severity: "high",
3847
+ pattern: /(?:PUSHER_APP_SECRET)['":\s=]+([a-fA-F0-9]{20})/i,
3848
+ description: "Pusher Channels app secret for server-side event authentication.",
3849
+ recommendation: "Rotate at dashboard.pusher.com."
3850
+ },
3851
+ {
3852
+ id: "SP-128",
3853
+ name: "Ably API Key",
3854
+ provider: "ably",
3855
+ severity: "high",
3856
+ pattern: /[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+:[A-Za-z0-9_+/=]{32,50}/,
3857
+ description: "Ably realtime messaging API key.",
3858
+ recommendation: "Rotate at ably.com/accounts."
3859
+ },
3860
+ {
3861
+ id: "SP-129",
3862
+ name: "Zoom JWT Token",
3863
+ provider: "zoom",
3864
+ severity: "high",
3865
+ pattern: /(?:ZOOM_JWT_TOKEN|zoom[_-]jwt)['":\s=]+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i,
3866
+ description: "Zoom JWT application token for Server-to-Server OAuth.",
3867
+ recommendation: "Rotate at marketplace.zoom.us/develop/apps."
3868
+ },
3869
+ // ── Data Platforms ──
3870
+ {
3871
+ id: "SP-130",
3872
+ name: "Elastic Search API Key",
3873
+ provider: "elastic",
3874
+ severity: "high",
3875
+ pattern: /(?:ELASTICSEARCH_API_KEY|elastic[_-]api[_-]key)['":\s=]+([A-Za-z0-9_=]+:{[^}]+})/i,
3876
+ description: "Elasticsearch / Elastic Cloud API key.",
3877
+ recommendation: "Rotate in Kibana > Stack Management > Security > API Keys."
3878
+ },
3879
+ {
3880
+ id: "SP-131",
3881
+ name: "Kafka SASL Password",
3882
+ provider: "kafka",
3883
+ severity: "critical",
3884
+ pattern: /(?:KAFKA_SASL_PASSWORD|kafka[_-]password)['":\s=]+([^\s"']{8,})/i,
3885
+ description: "Apache Kafka SASL authentication password.",
3886
+ recommendation: "Rotate and enable SSL/TLS for Kafka connections."
3887
+ },
3888
+ // ── Observability (extended) ──
3889
+ {
3890
+ id: "SP-132",
3891
+ name: "Axiom API Token",
3892
+ provider: "axiom",
3893
+ severity: "medium",
3894
+ pattern: /xaat-[A-Za-z0-9_-]{36,50}/,
3895
+ description: "Axiom log analytics API token.",
3896
+ recommendation: "Rotate at app.axiom.co/settings/api-tokens."
3897
+ },
3898
+ {
3899
+ id: "SP-133",
3900
+ name: "Grafana API Key",
3901
+ provider: "grafana",
3902
+ severity: "high",
3903
+ pattern: /(?:GRAFANA_API_KEY|grafana[_-]token)['":\s=]+([A-Za-z0-9=]{40,100})/i,
3904
+ description: "Grafana Cloud API key for metrics, logs, and traces.",
3905
+ recommendation: "Rotate at grafana.com/profile/api-keys."
3906
+ },
3907
+ // ── Environment Variable Leaks ──
3908
+ {
3909
+ id: "SP-134",
3910
+ name: "Exposed NEXT_PUBLIC Secret Variable",
3911
+ provider: "nextjs",
3912
+ severity: "high",
3913
+ pattern: /NEXT_PUBLIC_(?:SECRET|KEY|TOKEN|PASSWORD|PRIVATE)[A-Z_]*['":\s=]+["']?[A-Za-z0-9_+/=-]{20,}/i,
3914
+ description: "Variable prefixed NEXT_PUBLIC_ (client-bundle exposure) with a name suggesting it is a secret. NEXT_PUBLIC_ variables are always bundled into the client.",
3915
+ recommendation: "Remove NEXT_PUBLIC_ prefix. Move to server-side environment variables or use Next.js server actions."
3916
+ },
3917
+ {
3918
+ id: "SP-135",
3919
+ name: "Exposed VITE Secret Variable",
3920
+ provider: "vite",
3921
+ severity: "high",
3922
+ pattern: /VITE_(?:SECRET|KEY|TOKEN|PASSWORD|PRIVATE)[A-Z_]*['":\s=]+["']?[A-Za-z0-9_+/=-]{20,}/i,
3923
+ description: "Variable prefixed VITE_ (client-bundle exposure) with a name suggesting it is a secret. VITE_ variables are always bundled into the client.",
3924
+ recommendation: "Remove VITE_ prefix and handle server-side. Use API routes for sensitive operations."
3925
+ },
3926
+ {
3927
+ id: "SP-136",
3928
+ name: "Hardcoded Password Pattern",
3929
+ provider: "generic",
3930
+ severity: "high",
3931
+ pattern: /(?:password|passwd|db_pass|db_password)['":\s=]+["'](?!.*\${)[A-Za-z0-9!@#$%^&*_+=]{8,}/i,
3932
+ description: "Hardcoded password string that does not appear to be a template variable.",
3933
+ recommendation: "Move credentials to environment variables and use a secrets manager."
3934
+ },
3935
+ // ── AI Agent Ecosystem ──
3936
+ {
3937
+ id: "SP-137",
3938
+ name: "Smithery Registry Token",
3939
+ provider: "smithery",
3940
+ severity: "high",
3941
+ pattern: /(?:SMITHERY_API_KEY|smithery[_-]token)['":\s=]+([A-Za-z0-9_-]{30,60})/i,
3942
+ description: "Smithery MCP registry API token. Grants publish access to MCP server packages.",
3943
+ recommendation: "Rotate at smithery.ai/settings. Supply chain risk \u2014 protect publish access."
3944
+ },
3945
+ {
3946
+ id: "SP-138",
3947
+ name: "Cursor API Key",
3948
+ provider: "cursor",
3949
+ severity: "medium",
3950
+ pattern: /(?:CURSOR_API_KEY)['":\s=]+([A-Za-z0-9_-]{30,60})/i,
3951
+ description: "Cursor IDE API integration key.",
3952
+ recommendation: "Rotate at cursor.com/settings."
3953
+ },
3954
+ {
3955
+ id: "SP-139",
3956
+ name: "Vigile API Key",
3957
+ provider: "vigile",
3958
+ severity: "medium",
3959
+ pattern: /vgl_[A-Za-z0-9_-]{40,60}/,
3960
+ description: "Vigile AI API key. Grants access to Vigile scan API and trust registry.",
3961
+ recommendation: "Rotate at vigile.dev/account."
3962
+ },
3963
+ {
3964
+ id: "SP-140",
3965
+ name: "OpenAI API Key (via env)",
3966
+ provider: "openai",
3967
+ severity: "critical",
3968
+ pattern: /(?:OPENAI_API_KEY)['":\s=]+["']?(sk-[A-Za-z0-9_-]{48,})/i,
3969
+ description: "OpenAI API key referenced in an environment variable assignment.",
3970
+ recommendation: "Rotate at platform.openai.com/api-keys. Move to server-side proxy."
3971
+ },
3972
+ {
3973
+ id: "SP-141",
3974
+ name: "Anthropic API Key (via env)",
3975
+ provider: "anthropic",
3976
+ severity: "critical",
3977
+ pattern: /(?:ANTHROPIC_API_KEY)['":\s=]+["']?(sk-ant-api[A-Za-z0-9_-]{90,})/i,
3978
+ description: "Anthropic API key in an environment variable assignment.",
3979
+ recommendation: "Rotate at console.anthropic.com. Never include in client bundles."
3980
+ },
3981
+ // ── Extended Coverage ──
3982
+ {
3983
+ id: "SP-142",
3984
+ name: "Stripe Secret Key (Test)",
3985
+ provider: "stripe",
3986
+ severity: "medium",
3987
+ pattern: /sk_test_[A-Za-z0-9]{24,100}/,
3988
+ description: "Stripe test-mode secret key. Should not be in production bundles.",
3989
+ recommendation: "Test keys should not appear in client code even in development builds."
3990
+ },
3991
+ {
3992
+ id: "SP-143",
3993
+ name: "GitHub App Installation Token",
3994
+ provider: "github",
3995
+ severity: "high",
3996
+ pattern: /ghi_[A-Za-z0-9]{36}/,
3997
+ description: "GitHub App installation token (internal use).",
3998
+ recommendation: "These are short-lived but should not appear in artifacts."
3999
+ },
4000
+ {
4001
+ id: "SP-144",
4002
+ name: "Supabase JWT Secret",
4003
+ provider: "supabase",
4004
+ severity: "critical",
4005
+ pattern: /(?:SUPABASE_JWT_SECRET|JWT_SECRET)['":\s=]+["']?[A-Za-z0-9_+/=-]{32,}/i,
4006
+ description: "JWT signing secret for Supabase Auth. Anyone with this secret can forge valid session tokens.",
4007
+ recommendation: "This secret is set at project creation and cannot be rotated without breaking all sessions."
4008
+ },
4009
+ {
4010
+ id: "SP-145",
4011
+ name: "Firebase Admin Private Key",
4012
+ provider: "firebase",
4013
+ severity: "critical",
4014
+ pattern: /(?:FIREBASE_PRIVATE_KEY)['":\s=]+["']?-----BEGIN (RSA )?PRIVATE KEY/i,
4015
+ description: "Firebase Admin SDK private key. Grants admin-level database and Auth access.",
4016
+ recommendation: "Remove from client code immediately. Firebase Admin SDK must only run server-side."
4017
+ },
4018
+ {
4019
+ id: "SP-146",
4020
+ name: "Google Analytics Measurement ID",
4021
+ provider: "google",
4022
+ severity: "low",
4023
+ pattern: /G-[A-Z0-9]{10}/,
4024
+ description: "Google Analytics 4 Measurement ID. Designed to be public \u2014 included for inventory purposes.",
4025
+ recommendation: "This is public by design but note it for compliance/privacy documentation."
4026
+ },
4027
+ {
4028
+ id: "SP-147",
4029
+ name: "Imagekit Private Key",
4030
+ provider: "imagekit",
4031
+ severity: "high",
4032
+ pattern: /(?:IMAGEKIT_PRIVATE_KEY)['":\s=]+([A-Za-z0-9_-]{30,60})/i,
4033
+ description: "Imagekit.io private API key for image upload and management.",
4034
+ recommendation: "Rotate at imagekit.io/dashboard/developer/api-keys."
4035
+ },
4036
+ {
4037
+ id: "SP-148",
4038
+ name: "ScraperAPI Key",
4039
+ provider: "scraperapi",
4040
+ severity: "medium",
4041
+ pattern: /(?:SCRAPERAPI_KEY|scraperapi[_-]key)['":\s=]+([a-fA-F0-9]{32})/i,
4042
+ description: "ScraperAPI proxy key for web scraping requests.",
4043
+ recommendation: "Rotate at scraperapi.com/dashboard."
4044
+ },
4045
+ {
4046
+ id: "SP-149",
4047
+ name: "Salesforce Connected App Secret",
4048
+ provider: "salesforce",
4049
+ severity: "critical",
4050
+ pattern: /(?:SF_CLIENT_SECRET|SALESFORCE_SECRET)['":\s=]+([A-Za-z0-9_-]{64})/i,
4051
+ description: "Salesforce Connected App client secret. Grants CRM access as the connected application.",
4052
+ recommendation: "Rotate in Salesforce Setup > App Manager."
4053
+ },
4054
+ {
4055
+ id: "SP-150",
4056
+ name: "Generic Bearer Token",
4057
+ provider: "generic",
4058
+ severity: "medium",
4059
+ pattern: /Authorization:\s*Bearer\s+([A-Za-z0-9_-]{40,200})/i,
4060
+ description: "Bearer token hardcoded in Authorization header \u2014 likely a live credential.",
4061
+ recommendation: "Remove hardcoded tokens. Generate tokens at runtime from securely stored credentials."
4062
+ }
4063
+ ];
4064
+ function matchSecrets(text) {
4065
+ const results = [];
4066
+ for (const sp of SECRET_PATTERNS) {
4067
+ const flags = sp.pattern.flags.includes("i") ? "gi" : "g";
4068
+ const globalPattern = new RegExp(sp.pattern.source, flags);
4069
+ for (const m of text.matchAll(globalPattern)) {
4070
+ const matched = m[0];
4071
+ const idx = m.index ?? 0;
4072
+ const start = Math.max(0, idx - 25);
4073
+ const end = Math.min(text.length, idx + matched.length + 25);
4074
+ const context = text.slice(start, end).replace(/\s+/g, " ").trim();
4075
+ results.push({
4076
+ pattern: sp,
4077
+ match: mask(matched),
4078
+ context
4079
+ });
4080
+ }
4081
+ }
4082
+ return results;
4083
+ }
4084
+
4085
+ // src/scanner/baas/bundle-analyzer.ts
4086
+ var MAX_BUNDLE_SIZE = 5 * 1024 * 1024;
4087
+ var MAX_BUNDLES = 10;
4088
+ var FETCH_TIMEOUT_MS = 15e3;
4089
+ function extractScriptSrcs(html, baseUrl) {
4090
+ const srcs = [];
4091
+ const scriptRe = /<script[^>]+src=["']([^"']+)["'][^>]*>/gi;
4092
+ for (const m of html.matchAll(scriptRe)) {
4093
+ const src = m[1];
4094
+ if (!src) continue;
4095
+ if (src.startsWith("data:")) continue;
4096
+ try {
4097
+ const resolved = new URL(src, baseUrl).toString();
4098
+ srcs.push(resolved);
4099
+ } catch {
4100
+ }
4101
+ }
4102
+ return srcs;
4103
+ }
4104
+ async function fetchWithTimeout(url, timeoutMs) {
4105
+ const controller = new AbortController();
4106
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
4107
+ try {
4108
+ const response = await fetch(url, { signal: controller.signal });
4109
+ return response;
4110
+ } finally {
4111
+ clearTimeout(timer);
4112
+ }
4113
+ }
4114
+ async function fetchText(url) {
4115
+ try {
4116
+ const res = await fetchWithTimeout(url, FETCH_TIMEOUT_MS);
4117
+ if (!res.ok) {
4118
+ return { text: "", error: `HTTP ${res.status} for ${url}` };
4119
+ }
4120
+ const contentLength = parseInt(res.headers.get("content-length") ?? "0", 10);
4121
+ if (contentLength > MAX_BUNDLE_SIZE) {
4122
+ return { text: "", error: `Bundle too large (${contentLength} bytes): ${url}` };
4123
+ }
4124
+ const buffer = await res.arrayBuffer();
4125
+ if (buffer.byteLength > MAX_BUNDLE_SIZE) {
4126
+ return { text: "", error: `Bundle exceeds 5 MB limit: ${url}` };
4127
+ }
4128
+ return { text: new TextDecoder().decode(buffer) };
4129
+ } catch (err) {
4130
+ const msg = err instanceof Error ? err.message : String(err);
4131
+ return { text: "", error: `Fetch failed for ${url}: ${msg}` };
4132
+ }
4133
+ }
4134
+ function makeSecretFinding(bundleUrl, matchedId, matchedName, severity, maskedValue, context, index) {
4135
+ return {
4136
+ id: `BU-${String(index + 1).padStart(3, "0")}`,
4137
+ category: "exposed-secret",
4138
+ severity,
4139
+ title: `Exposed ${matchedName} secret in JS bundle`,
4140
+ description: `A ${matchedName} credential was found in a compiled JavaScript bundle at ${bundleUrl}. Secrets baked into frontend bundles are readable by anyone who inspects your app's network traffic or source code.`,
4141
+ evidence: `Pattern: ${matchedId} | Masked value: ${maskedValue} | Context: ${context}`,
4142
+ recommendation: `Move this secret to a server-side environment variable. Never include API keys or tokens in frontend JavaScript. Use a backend proxy or BFF (Backend for Frontend) pattern to make authenticated API calls.`
4143
+ };
4144
+ }
4145
+ async function analyzeBundles(appUrl) {
4146
+ const errors = [];
4147
+ const findings = [];
4148
+ let bundlesAnalyzed = 0;
4149
+ const baseUrl = appUrl.endsWith("/") ? appUrl : `${appUrl}/`;
4150
+ const { text: html, error: htmlError } = await fetchText(baseUrl);
4151
+ if (htmlError || !html) {
4152
+ errors.push(htmlError ?? `Could not fetch root HTML for ${baseUrl}`);
4153
+ return { url: appUrl, bundlesAnalyzed: 0, findings, errors };
4154
+ }
4155
+ const scriptUrls = extractScriptSrcs(html, baseUrl).slice(0, MAX_BUNDLES);
4156
+ if (scriptUrls.length === 0) {
4157
+ errors.push(`No <script src> tags found in HTML at ${baseUrl}`);
4158
+ return { url: appUrl, bundlesAnalyzed: 0, findings, errors };
4159
+ }
4160
+ let findingIndex = 0;
4161
+ for (const scriptUrl of scriptUrls) {
4162
+ const { text: bundleText, error: fetchError } = await fetchText(scriptUrl);
4163
+ if (fetchError || !bundleText) {
4164
+ errors.push(fetchError ?? `Empty bundle at ${scriptUrl}`);
4165
+ continue;
4166
+ }
4167
+ bundlesAnalyzed++;
4168
+ const matches = matchSecrets(bundleText);
4169
+ for (const secretMatch of matches) {
4170
+ findings.push(
4171
+ makeSecretFinding(
4172
+ scriptUrl,
4173
+ secretMatch.pattern.id,
4174
+ secretMatch.pattern.name,
4175
+ secretMatch.pattern.severity,
4176
+ secretMatch.match,
4177
+ secretMatch.context,
4178
+ findingIndex++
4179
+ )
4180
+ );
4181
+ }
4182
+ }
4183
+ return { url: appUrl, bundlesAnalyzed, findings, errors };
4184
+ }
4185
+
4186
+ // src/scanner/baas/supabase-scanner.ts
4187
+ var FETCH_TIMEOUT_MS2 = 1e4;
4188
+ async function fetchWithTimeout2(url, init) {
4189
+ const controller = new AbortController();
4190
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS2);
4191
+ try {
4192
+ return await fetch(url, { ...init, signal: controller.signal });
4193
+ } finally {
4194
+ clearTimeout(timer);
4195
+ }
4196
+ }
4197
+ function normaliseSupabaseUrl(url) {
4198
+ let u = url.replace(/\/+$/, "");
4199
+ if (!u.startsWith("http")) {
4200
+ u = `https://${u}`;
4201
+ }
4202
+ return u;
4203
+ }
4204
+ function extractAnonKeyFromFindings(findings) {
4205
+ for (const f of findings) {
4206
+ const ev = f.evidence ?? "";
4207
+ if ((ev.includes("supabase") || ev.includes("SP-022") || ev.includes("SP-023")) && ev.includes("eyJ")) {
4208
+ const jwtMatch = ev.match(/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/);
4209
+ if (jwtMatch) return jwtMatch[0];
4210
+ }
4211
+ }
4212
+ return null;
4213
+ }
4214
+ async function scanSupabase(opts) {
4215
+ const findings = [];
4216
+ const errors = [];
4217
+ const tablesFound = [];
4218
+ let anonReadExposed = false;
4219
+ let reachable = false;
4220
+ const baseUrl = normaliseSupabaseUrl(opts.projectUrl);
4221
+ try {
4222
+ const res = await fetchWithTimeout2(`${baseUrl}/rest/v1/`, {
4223
+ method: "HEAD"
4224
+ });
4225
+ reachable = res.status === 401 || res.status === 200 || res.status === 403;
4226
+ } catch {
4227
+ errors.push(`Supabase project not reachable at ${baseUrl}`);
4228
+ return { projectUrl: baseUrl, findings, tablesFound, anonReadExposed, reachable, errors };
4229
+ }
4230
+ if (!reachable) {
4231
+ errors.push(`Supabase project returned unexpected status at ${baseUrl}/rest/v1/`);
4232
+ return { projectUrl: baseUrl, findings, tablesFound, anonReadExposed, reachable, errors };
4233
+ }
4234
+ const bundleResult = await analyzeBundles(baseUrl);
4235
+ findings.push(...bundleResult.findings);
4236
+ if (bundleResult.errors.length > 0) {
4237
+ errors.push(...bundleResult.errors.map((e) => `[bundle] ${e}`));
4238
+ }
4239
+ const hasServiceRoleKey = bundleResult.findings.some(
4240
+ (f) => f.evidence?.includes("service_role") || f.evidence?.includes("SP-023")
4241
+ );
4242
+ if (hasServiceRoleKey) {
4243
+ findings.push({
4244
+ id: "SB-005",
4245
+ category: "exposed-secret",
4246
+ severity: "critical",
4247
+ title: "Supabase service_role key exposed in frontend bundle",
4248
+ description: "The Supabase service_role key was found in a JavaScript bundle. This key bypasses all Row Level Security policies and grants full read/write/delete access to every table and storage bucket. This is equivalent to database superuser access.",
4249
+ evidence: "Detected via bundle analysis \u2014 service_role JWT in compiled JS",
4250
+ recommendation: "Rotate the service_role key immediately in Supabase Dashboard > Settings > API. This key must NEVER appear in frontend code. Use it only in server-side functions (Edge Functions, API routes)."
4251
+ });
4252
+ }
4253
+ let anonKey = opts.anonKey ?? null;
4254
+ if (!anonKey) {
4255
+ anonKey = extractAnonKeyFromFindings(bundleResult.findings);
4256
+ }
4257
+ if (!anonKey) {
4258
+ errors.push(
4259
+ "No anon key provided or detected in bundles \u2014 skipping RLS and table enumeration. Pass --supabase-key or ensure the app URL serves JS bundles with the anon key."
4260
+ );
4261
+ }
4262
+ if (anonKey) {
4263
+ try {
4264
+ const tablesRes = await fetchWithTimeout2(`${baseUrl}/rest/v1/`, {
4265
+ headers: {
4266
+ apikey: anonKey,
4267
+ Authorization: `Bearer ${anonKey}`
4268
+ }
4269
+ });
4270
+ if (tablesRes.ok) {
4271
+ try {
4272
+ const schema = await tablesRes.json();
4273
+ const paths = schema.paths ?? {};
4274
+ for (const path of Object.keys(paths)) {
4275
+ const tableName = path.replace(/^\//, "").split("?")[0];
4276
+ if (tableName && !tableName.includes("/")) {
4277
+ tablesFound.push(tableName);
4278
+ }
4279
+ }
4280
+ } catch {
4281
+ errors.push("Could not parse Supabase REST schema response");
4282
+ }
4283
+ }
4284
+ } catch (err) {
4285
+ const msg = err instanceof Error ? err.message : String(err);
4286
+ errors.push(`Table enumeration failed: ${msg}`);
4287
+ }
4288
+ for (const table of tablesFound) {
4289
+ try {
4290
+ const readRes = await fetchWithTimeout2(
4291
+ `${baseUrl}/rest/v1/${table}?select=*&limit=1`,
4292
+ {
4293
+ headers: {
4294
+ apikey: anonKey,
4295
+ Authorization: `Bearer ${anonKey}`
4296
+ }
4297
+ }
4298
+ );
4299
+ if (readRes.ok) {
4300
+ const body = await readRes.text();
4301
+ if (body.startsWith("[") && body !== "[]") {
4302
+ anonReadExposed = true;
4303
+ findings.push({
4304
+ id: "SB-001",
4305
+ category: "rls-misconfiguration",
4306
+ severity: "critical",
4307
+ title: `RLS disabled: anonymous read on "${table}"`,
4308
+ description: `Table "${table}" returned data via the anon key without any Row Level Security policies. Any user with the anon key (which is public) can read all rows in this table. This is the #1 Supabase security mistake.`,
4309
+ evidence: `GET /rest/v1/${table}?select=*&limit=1 returned 200 with data`,
4310
+ recommendation: `Enable RLS on table "${table}" in Supabase Dashboard > Database > Tables. Add a policy like: CREATE POLICY "auth read" ON "${table}" FOR SELECT USING (auth.uid() = user_id);`
4311
+ });
4312
+ }
4313
+ }
4314
+ } catch {
4315
+ }
4316
+ try {
4317
+ const writeRes = await fetchWithTimeout2(
4318
+ `${baseUrl}/rest/v1/${table}`,
4319
+ {
4320
+ method: "POST",
4321
+ headers: {
4322
+ apikey: anonKey,
4323
+ Authorization: `Bearer ${anonKey}`,
4324
+ "Content-Type": "application/json",
4325
+ Prefer: "return=minimal"
4326
+ },
4327
+ body: JSON.stringify({})
4328
+ }
4329
+ );
4330
+ if (writeRes.status === 400 || writeRes.status === 201 || writeRes.status === 200) {
4331
+ findings.push({
4332
+ id: "SB-006",
4333
+ category: "rls-misconfiguration",
4334
+ severity: "critical",
4335
+ title: `RLS disabled: anonymous write on "${table}"`,
4336
+ description: `Table "${table}" allows INSERT operations via the anon key. The request reached the database layer (RLS did not block it). Even if it failed on a constraint, the lack of RLS means anyone can attempt to write data to this table.`,
4337
+ evidence: `POST /rest/v1/${table} returned ${writeRes.status} (not 401/403)`,
4338
+ recommendation: `Enable RLS on table "${table}" and add INSERT policies. For example: CREATE POLICY "auth insert" ON "${table}" FOR INSERT WITH CHECK (auth.uid() = user_id);`
4339
+ });
4340
+ }
4341
+ } catch {
4342
+ }
4343
+ }
4344
+ }
4345
+ if (anonKey) {
4346
+ try {
4347
+ const authRes = await fetchWithTimeout2(`${baseUrl}/auth/v1/settings`, {
4348
+ headers: {
4349
+ apikey: anonKey,
4350
+ Authorization: `Bearer ${anonKey}`
4351
+ }
4352
+ });
4353
+ if (authRes.ok) {
4354
+ try {
4355
+ const settings = await authRes.json();
4356
+ const autoconfirm = settings.mailer_autoconfirm ?? settings.autoconfirm;
4357
+ if (autoconfirm === true) {
4358
+ findings.push({
4359
+ id: "SB-003",
4360
+ category: "auth-misconfiguration",
4361
+ severity: "medium",
4362
+ title: "Email confirmation disabled (autoconfirm enabled)",
4363
+ description: "Supabase auth is configured to automatically confirm email addresses without requiring the user to click a verification link. This allows attackers to create accounts with any email address, including impersonating legitimate users.",
4364
+ evidence: "GET /auth/v1/settings returned mailer_autoconfirm: true",
4365
+ recommendation: "Disable autoconfirm in Supabase Dashboard > Authentication > Settings > Email Auth. Require email verification for all new signups."
4366
+ });
4367
+ }
4368
+ const disableSignup = settings.disable_signup;
4369
+ if (disableSignup === false) {
4370
+ findings.push({
4371
+ id: "SB-007",
4372
+ category: "auth-misconfiguration",
4373
+ severity: "low",
4374
+ title: "Open signup enabled",
4375
+ description: "Public signup is enabled on this Supabase project. If this is an internal tool or admin panel, open signup allows anyone to create an account.",
4376
+ evidence: "GET /auth/v1/settings returned disable_signup: false",
4377
+ recommendation: "If this project is not meant for public registration, disable signup in Supabase Dashboard > Authentication > Settings."
4378
+ });
4379
+ }
4380
+ } catch {
4381
+ errors.push("Could not parse auth settings response");
4382
+ }
4383
+ }
4384
+ } catch {
4385
+ errors.push("Auth settings endpoint not reachable");
4386
+ }
4387
+ }
4388
+ try {
4389
+ const corsRes = await fetchWithTimeout2(`${baseUrl}/rest/v1/`, {
4390
+ method: "OPTIONS",
4391
+ headers: {
4392
+ Origin: "https://evil-attacker-site.com",
4393
+ "Access-Control-Request-Method": "GET"
4394
+ }
4395
+ });
4396
+ const allowOrigin = corsRes.headers.get("access-control-allow-origin");
4397
+ if (allowOrigin === "*" || allowOrigin === "https://evil-attacker-site.com") {
4398
+ const isReflected = allowOrigin === "https://evil-attacker-site.com";
4399
+ findings.push({
4400
+ id: "SB-004",
4401
+ category: "cors-misconfiguration",
4402
+ severity: "medium",
4403
+ title: isReflected ? "CORS reflects arbitrary origins on REST API" : "CORS wildcard policy on REST API",
4404
+ description: isReflected ? "The Supabase REST API reflects any Origin header back in Access-Control-Allow-Origin, which is functionally equivalent to a wildcard policy but harder to detect." : "The Supabase REST API returns Access-Control-Allow-Origin: * which allows any website to make authenticated requests to your API. Combined with an exposed anon key, this enables cross-origin data access from malicious sites.",
4405
+ evidence: isReflected ? "OPTIONS /rest/v1/ reflected origin: https://evil-attacker-site.com" : "OPTIONS /rest/v1/ returned Access-Control-Allow-Origin: *",
4406
+ recommendation: "Configure allowed origins in Supabase Dashboard > Settings > API. Restrict to your app domain(s) only."
4407
+ });
4408
+ }
4409
+ } catch {
4410
+ }
4411
+ return {
4412
+ projectUrl: baseUrl,
4413
+ findings,
4414
+ tablesFound,
4415
+ anonReadExposed,
4416
+ reachable,
4417
+ errors
4418
+ };
4419
+ }
4420
+
4421
+ // src/scanner/baas/firebase-scanner.ts
4422
+ var FETCH_TIMEOUT_MS3 = 1e4;
4423
+ async function fetchWithTimeout3(url, init) {
4424
+ const controller = new AbortController();
4425
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS3);
4426
+ try {
4427
+ return await fetch(url, { ...init, signal: controller.signal });
4428
+ } finally {
4429
+ clearTimeout(timer);
4430
+ }
4431
+ }
4432
+ function normaliseUrl(url) {
4433
+ let u = url.replace(/\/+$/, "");
4434
+ if (!u.startsWith("http")) {
4435
+ u = `https://${u}`;
4436
+ }
4437
+ return u;
4438
+ }
4439
+ function extractProjectId(url) {
4440
+ try {
4441
+ const hostname = new URL(url).hostname;
4442
+ const match = hostname.match(/^([^.]+)\.(web\.app|firebaseapp\.com)$/);
4443
+ return match?.[1] ?? null;
4444
+ } catch {
4445
+ return null;
4446
+ }
4447
+ }
4448
+ function extractConfigFromFindings(findings) {
4449
+ const config = {};
4450
+ for (const f of findings) {
4451
+ const ev = f.evidence ?? "";
4452
+ if (ev.includes("AIzaSy")) {
4453
+ const keyMatch = ev.match(/AIzaSy[A-Za-z0-9_-]{33}/);
4454
+ if (keyMatch) config.apiKey = keyMatch[0];
4455
+ }
4456
+ if (ev.includes("projectId") || ev.includes("firebase")) {
4457
+ const projMatch = ev.match(/["']([a-z0-9-]+)\.firebaseapp\.com["']/);
4458
+ if (projMatch) config.projectId = projMatch[1];
4459
+ }
4460
+ if (ev.includes("storageBucket") || ev.includes(".appspot.com")) {
4461
+ const bucketMatch = ev.match(/([a-z0-9-]+)\.appspot\.com/);
4462
+ if (bucketMatch) config.storageBucket = bucketMatch[0];
4463
+ }
4464
+ }
4465
+ return config;
4466
+ }
4467
+ async function scanFirebase(opts) {
4468
+ const findings = [];
4469
+ const errors = [];
4470
+ let firestorePublicAccess = false;
4471
+ let rtdbPublicAccess = false;
4472
+ let configExposed = false;
4473
+ let reachable = false;
4474
+ const baseUrl = normaliseUrl(opts.projectUrl);
4475
+ const projectId = opts.projectId ?? extractProjectId(baseUrl);
4476
+ try {
4477
+ const res = await fetchWithTimeout3(baseUrl, { method: "HEAD" });
4478
+ reachable = res.status >= 200 && res.status < 500;
4479
+ } catch {
4480
+ errors.push(`Firebase project not reachable at ${baseUrl}`);
4481
+ return {
4482
+ projectUrl: baseUrl,
4483
+ projectId,
4484
+ findings,
4485
+ firestorePublicAccess,
4486
+ rtdbPublicAccess,
4487
+ configExposed,
4488
+ reachable,
4489
+ errors
4490
+ };
4491
+ }
4492
+ if (!reachable) {
4493
+ errors.push(`Firebase project returned unexpected status at ${baseUrl}`);
4494
+ return {
4495
+ projectUrl: baseUrl,
4496
+ projectId,
4497
+ findings,
4498
+ firestorePublicAccess,
4499
+ rtdbPublicAccess,
4500
+ configExposed,
4501
+ reachable,
4502
+ errors
4503
+ };
4504
+ }
4505
+ const bundleResult = await analyzeBundles(baseUrl);
4506
+ findings.push(...bundleResult.findings);
4507
+ if (bundleResult.errors.length > 0) {
4508
+ errors.push(...bundleResult.errors.map((e) => `[bundle] ${e}`));
4509
+ }
4510
+ const bundleConfig = extractConfigFromFindings(bundleResult.findings);
4511
+ const detectedProjectId = projectId ?? bundleConfig.projectId ?? null;
4512
+ if (bundleConfig.apiKey) {
4513
+ configExposed = true;
4514
+ findings.push({
4515
+ id: "FB-003",
4516
+ category: "exposed-secret",
4517
+ severity: "high",
4518
+ title: "Firebase config object exposed in frontend bundle",
4519
+ description: "The Firebase client configuration (apiKey, projectId, etc.) was found in a JavaScript bundle. While Firebase API keys are designed to be public for client-side use, exposure without App Check enforcement allows abuse: automated account creation, Firestore/RTDB enumeration, and quota exhaustion attacks.",
4520
+ evidence: `Firebase apiKey: ${bundleConfig.apiKey.slice(0, 8)}*** detected in bundle`,
4521
+ recommendation: "Enable Firebase App Check in the Firebase Console to restrict API access to your legitimate app. Configure reCAPTCHA Enterprise or DeviceCheck attestation. Without App Check, anyone with the config can call your Firebase APIs."
4522
+ });
4523
+ }
4524
+ if (detectedProjectId) {
4525
+ const firestoreUrl = `https://firestore.googleapis.com/v1/projects/${detectedProjectId}/databases/(default)/documents`;
4526
+ try {
4527
+ const fsRes = await fetchWithTimeout3(firestoreUrl);
4528
+ if (fsRes.ok) {
4529
+ firestorePublicAccess = true;
4530
+ let docCount = 0;
4531
+ try {
4532
+ const body = await fsRes.json();
4533
+ docCount = body.documents?.length ?? 0;
4534
+ } catch {
4535
+ }
4536
+ findings.push({
4537
+ id: "FB-001",
4538
+ category: "firebase-rules-issue",
4539
+ severity: "critical",
4540
+ title: "Firestore allows unauthenticated read access",
4541
+ description: "The Firestore REST API returned documents without any authentication token. This means Firestore Security Rules are configured with `allow read: if true` or similar permissive rules at the database or collection level. Any data in Firestore is publicly accessible.",
4542
+ evidence: `GET ${firestoreUrl} returned 200` + (docCount > 0 ? ` (${docCount} documents visible)` : ""),
4543
+ recommendation: "Update Firestore Security Rules to require authentication: `allow read: if request.auth != null;` at minimum. Ideally, add per-user rules: `allow read: if request.auth.uid == resource.data.userId;`"
4544
+ });
4545
+ }
4546
+ } catch (err) {
4547
+ const msg = err instanceof Error ? err.message : String(err);
4548
+ errors.push(`Firestore probe failed: ${msg}`);
4549
+ }
4550
+ try {
4551
+ const writeUrl = `https://firestore.googleapis.com/v1/projects/${detectedProjectId}/databases/(default)/documents/vigile_probe_test`;
4552
+ const writeRes = await fetchWithTimeout3(writeUrl, {
4553
+ method: "POST",
4554
+ headers: { "Content-Type": "application/json" },
4555
+ body: JSON.stringify({
4556
+ fields: {
4557
+ _vigile_probe: { stringValue: "security_test" }
4558
+ }
4559
+ })
4560
+ });
4561
+ if (writeRes.status === 200) {
4562
+ firestorePublicAccess = true;
4563
+ findings.push({
4564
+ id: "FB-001",
4565
+ category: "firebase-rules-issue",
4566
+ severity: "critical",
4567
+ title: "Firestore allows unauthenticated WRITE access",
4568
+ description: "A document was successfully written to Firestore without any authentication. This means anyone on the internet can create, modify, or delete data in your database.",
4569
+ evidence: `POST to Firestore documents endpoint returned 200`,
4570
+ recommendation: "Immediately update Firestore Security Rules to block unauthenticated writes: `allow write: if request.auth != null;` \u2014 and review all existing data for tampering."
4571
+ });
4572
+ }
4573
+ } catch {
4574
+ }
4575
+ } else {
4576
+ errors.push(
4577
+ "Could not determine Firebase project ID \u2014 skipping Firestore/RTDB probes. Provide --firebase-project-id or use a *.web.app / *.firebaseapp.com URL."
4578
+ );
4579
+ }
4580
+ if (detectedProjectId) {
4581
+ const rtdbUrls = [
4582
+ `https://${detectedProjectId}-default-rtdb.firebaseio.com/.json?shallow=true`,
4583
+ `https://${detectedProjectId}.firebaseio.com/.json?shallow=true`
4584
+ ];
4585
+ for (const rtdbUrl of rtdbUrls) {
4586
+ try {
4587
+ const rtdbRes = await fetchWithTimeout3(rtdbUrl);
4588
+ if (rtdbRes.ok) {
4589
+ rtdbPublicAccess = true;
4590
+ let keyCount = 0;
4591
+ try {
4592
+ const body = await rtdbRes.json();
4593
+ if (body && typeof body === "object") {
4594
+ keyCount = Object.keys(body).length;
4595
+ }
4596
+ } catch {
4597
+ }
4598
+ findings.push({
4599
+ id: "FB-002",
4600
+ category: "firebase-rules-issue",
4601
+ severity: "critical",
4602
+ title: "Realtime Database allows unauthenticated read access",
4603
+ description: 'The Firebase Realtime Database returned data at the root path without any authentication. RTDB rules are configured with `".read": true` or `".read": "auth == null"` or similar. All data stored in RTDB is publicly accessible.',
4604
+ evidence: `GET ${rtdbUrl.replace(/\?.*/, "")} returned 200` + (keyCount > 0 ? ` (${keyCount} top-level keys)` : ""),
4605
+ recommendation: 'Update RTDB Security Rules to require authentication: `".read": "auth != null"` at minimum. Review what data is stored in RTDB and assume it has been scraped if this rule was open.'
4606
+ });
4607
+ break;
4608
+ }
4609
+ } catch {
4610
+ continue;
4611
+ }
4612
+ }
4613
+ try {
4614
+ const rtdbWriteUrl = `https://${detectedProjectId}-default-rtdb.firebaseio.com/vigile_probe_test.json`;
4615
+ const rtdbWriteRes = await fetchWithTimeout3(rtdbWriteUrl, {
4616
+ method: "PUT",
4617
+ headers: { "Content-Type": "application/json" },
4618
+ body: JSON.stringify({ _vigile_probe: "security_test" })
4619
+ });
4620
+ if (rtdbWriteRes.ok) {
4621
+ rtdbPublicAccess = true;
4622
+ findings.push({
4623
+ id: "FB-002",
4624
+ category: "firebase-rules-issue",
4625
+ severity: "critical",
4626
+ title: "Realtime Database allows unauthenticated WRITE access",
4627
+ description: "Data was successfully written to Firebase Realtime Database without any authentication. Anyone can create, modify, or delete data.",
4628
+ evidence: `PUT to RTDB vigile_probe_test.json returned 200`,
4629
+ recommendation: 'Immediately update RTDB Security Rules: `".write": "auth != null"`. Audit all existing data for tampering. Consider migrating sensitive data to Firestore with stricter per-document rules.'
4630
+ });
4631
+ }
4632
+ } catch {
4633
+ }
4634
+ }
4635
+ if (detectedProjectId) {
4636
+ const storageBucket = bundleConfig.storageBucket ?? `${detectedProjectId}.appspot.com`;
4637
+ const storageUrl = `https://firebasestorage.googleapis.com/v0/b/${storageBucket}/o`;
4638
+ try {
4639
+ const storageRes = await fetchWithTimeout3(storageUrl);
4640
+ if (storageRes.ok) {
4641
+ let itemCount = 0;
4642
+ try {
4643
+ const body = await storageRes.json();
4644
+ itemCount = body.items?.length ?? 0;
4645
+ } catch {
4646
+ }
4647
+ findings.push({
4648
+ id: "FB-004",
4649
+ category: "firebase-rules-issue",
4650
+ severity: "high",
4651
+ title: "Firebase Storage bucket is publicly listable",
4652
+ description: "The Firebase Storage bucket returned a file listing without authentication. Storage Security Rules allow public read access. All files in the bucket can be enumerated and downloaded by anyone.",
4653
+ evidence: `GET ${storageUrl} returned 200` + (itemCount > 0 ? ` (${itemCount} files visible)` : ""),
4654
+ recommendation: "Update Firebase Storage Security Rules to require authentication: `allow read: if request.auth != null;` \u2014 Review uploaded files for sensitive content (user uploads, profile pictures, documents)."
4655
+ });
4656
+ }
4657
+ } catch {
4658
+ }
4659
+ }
4660
+ try {
4661
+ const headRes = await fetchWithTimeout3(baseUrl);
4662
+ const headers = headRes.headers;
4663
+ const missingHeaders = [];
4664
+ if (!headers.get("x-content-type-options")) {
4665
+ missingHeaders.push("X-Content-Type-Options");
4666
+ }
4667
+ if (!headers.get("x-frame-options") && !headers.get("content-security-policy")?.includes("frame-ancestors")) {
4668
+ missingHeaders.push("X-Frame-Options or CSP frame-ancestors");
4669
+ }
4670
+ if (!headers.get("strict-transport-security")) {
4671
+ missingHeaders.push("Strict-Transport-Security");
4672
+ }
4673
+ if (missingHeaders.length >= 2) {
4674
+ findings.push({
4675
+ id: "FB-005",
4676
+ category: "cors-misconfiguration",
4677
+ severity: "medium",
4678
+ title: "Firebase Hosting missing security headers",
4679
+ description: `The Firebase Hosting response is missing ${missingHeaders.length} recommended security headers: ${missingHeaders.join(", ")}. These headers protect against clickjacking, MIME sniffing, and protocol downgrade attacks.`,
4680
+ evidence: `Missing: ${missingHeaders.join(", ")}`,
4681
+ recommendation: 'Add security headers in firebase.json under hosting.headers: {"source": "**", "headers": [{"key": "X-Content-Type-Options", "value": "nosniff"}, {"key": "X-Frame-Options", "value": "DENY"}]}'
4682
+ });
4683
+ }
4684
+ } catch {
4685
+ }
4686
+ return {
4687
+ projectUrl: baseUrl,
4688
+ projectId: detectedProjectId,
4689
+ findings,
4690
+ firestorePublicAccess,
4691
+ rtdbPublicAccess,
4692
+ configExposed,
4693
+ reachable,
4694
+ errors
4695
+ };
4696
+ }
4697
+
4698
+ // src/scanner/baas/cve-detector.ts
4699
+ var FETCH_TIMEOUT_MS4 = 15e3;
4700
+ var OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
4701
+ var MAX_BATCH_SIZE = 100;
4702
+ async function fetchWithTimeout4(url, init) {
4703
+ const controller = new AbortController();
4704
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS4);
4705
+ try {
4706
+ return await fetch(url, { ...init, signal: controller.signal });
4707
+ } finally {
4708
+ clearTimeout(timer);
4709
+ }
4710
+ }
4711
+ function extractCvssScore(vuln) {
4712
+ if (vuln.severity && vuln.severity.length > 0) {
4713
+ for (const sev of vuln.severity) {
4714
+ const scoreMatch = sev.score.match(/CVSS:[^/]+\/.*?/);
4715
+ if (scoreMatch && sev.type === "CVSS_V3") {
4716
+ return estimateCvssFromVector(sev.score);
4717
+ }
4718
+ }
4719
+ }
4720
+ return 5;
4721
+ }
4722
+ function estimateCvssFromVector(vector) {
4723
+ const v = vector.toUpperCase();
4724
+ const avNetwork = v.includes("/AV:N");
4725
+ const prNone = v.includes("/PR:N");
4726
+ const cHigh = v.includes("/C:H");
4727
+ const iHigh = v.includes("/I:H");
4728
+ const aHigh = v.includes("/A:H");
4729
+ const cLow = v.includes("/C:L");
4730
+ const iLow = v.includes("/I:L");
4731
+ const aLow = v.includes("/A:L");
4732
+ let score = 5;
4733
+ if (avNetwork) score += 1.5;
4734
+ if (prNone) score += 1;
4735
+ if (cHigh) score += 0.8;
4736
+ if (iHigh) score += 0.8;
4737
+ if (aHigh) score += 0.8;
4738
+ if (cLow) score += 0.3;
4739
+ if (iLow) score += 0.3;
4740
+ if (aLow) score += 0.3;
4741
+ return Math.min(10, Math.round(score * 10) / 10);
4742
+ }
4743
+ function extractPatchedVersion(vuln, pkgName) {
4744
+ if (!vuln.affected) return null;
4745
+ for (const affected of vuln.affected) {
4746
+ if (affected.package.name !== pkgName) continue;
4747
+ if (!affected.ranges) continue;
4748
+ for (const range of affected.ranges) {
4749
+ for (const event of range.events) {
4750
+ if (event.fixed) return event.fixed;
4751
+ }
4752
+ }
4753
+ }
4754
+ return null;
4755
+ }
4756
+ function getPreferredId(vuln) {
4757
+ if (vuln.aliases) {
4758
+ const cve = vuln.aliases.find((a) => a.startsWith("CVE-"));
4759
+ if (cve) return cve;
4760
+ }
4761
+ return vuln.id;
4762
+ }
4763
+ function cvssToSeverity(score) {
4764
+ if (score >= 9) return "critical";
4765
+ if (score >= 7) return "high";
4766
+ if (score >= 4) return "medium";
4767
+ return "low";
4768
+ }
4769
+ async function detectCves(packages) {
4770
+ const matches = [];
4771
+ const findings = [];
4772
+ const errors = [];
4773
+ if (packages.length === 0) {
4774
+ return { packagesChecked: 0, matches, findings, errors };
4775
+ }
4776
+ const queryablePackages = packages.filter(
4777
+ (p) => p.ecosystem !== "unknown" && p.version && !p.version.includes("*")
4778
+ );
4779
+ if (queryablePackages.length === 0) {
4780
+ errors.push("No packages with valid version + ecosystem for CVE lookup");
4781
+ return { packagesChecked: packages.length, matches, findings, errors };
4782
+ }
4783
+ const chunks = [];
4784
+ for (let i = 0; i < queryablePackages.length; i += MAX_BATCH_SIZE) {
4785
+ chunks.push(queryablePackages.slice(i, i + MAX_BATCH_SIZE));
4786
+ }
4787
+ for (const chunk of chunks) {
4788
+ const queries = chunk.map((pkg) => ({
4789
+ package: {
4790
+ name: pkg.name,
4791
+ ecosystem: pkg.ecosystem === "npm" ? "npm" : pkg.ecosystem === "pypi" ? "PyPI" : pkg.ecosystem
4792
+ },
4793
+ version: pkg.version
4794
+ }));
4795
+ try {
4796
+ const res = await fetchWithTimeout4(OSV_BATCH_URL, {
4797
+ method: "POST",
4798
+ headers: { "Content-Type": "application/json" },
4799
+ body: JSON.stringify({ queries })
4800
+ });
4801
+ if (!res.ok) {
4802
+ errors.push(`OSV.dev API returned HTTP ${res.status}`);
4803
+ continue;
4804
+ }
4805
+ const body = await res.json();
4806
+ for (let i = 0; i < body.results.length; i++) {
4807
+ const result = body.results[i];
4808
+ const pkg = chunk[i];
4809
+ if (!result?.vulns || result.vulns.length === 0) continue;
4810
+ const seenIds = /* @__PURE__ */ new Set();
4811
+ for (const vuln of result.vulns) {
4812
+ const vulnId = getPreferredId(vuln);
4813
+ if (seenIds.has(vulnId)) continue;
4814
+ seenIds.add(vulnId);
4815
+ const cvssScore = extractCvssScore(vuln);
4816
+ const patchedVersion = extractPatchedVersion(vuln, pkg.name);
4817
+ const summary = vuln.summary ?? vuln.details?.slice(0, 200) ?? "No description available";
4818
+ const match = {
4819
+ pkg,
4820
+ cveId: vulnId,
4821
+ cvssScore,
4822
+ summary,
4823
+ patchedVersion
4824
+ };
4825
+ matches.push(match);
4826
+ const severity = cvssToSeverity(cvssScore);
4827
+ findings.push({
4828
+ id: vulnId,
4829
+ category: "cve-detected",
4830
+ severity,
4831
+ title: `${vulnId}: ${pkg.name}@${pkg.version}`,
4832
+ description: `Known vulnerability in ${pkg.name} version ${pkg.version}. ${summary.slice(0, 300)}`,
4833
+ evidence: `Package: ${pkg.name}@${pkg.version} (${pkg.ecosystem}) | CVSS: ${cvssScore} | ` + (patchedVersion ? `Fixed in: ${patchedVersion}` : "No patch available"),
4834
+ recommendation: patchedVersion ? `Upgrade ${pkg.name} to version ${patchedVersion} or later. Run: npm install ${pkg.name}@${patchedVersion}` : `No patched version available. Consider removing or replacing ${pkg.name} with an alternative package. Monitor ${vulnId} for updates.`
4835
+ });
4836
+ }
4837
+ }
4838
+ } catch (err) {
4839
+ const msg = err instanceof Error ? err.message : String(err);
4840
+ errors.push(`OSV.dev query failed: ${msg}`);
4841
+ }
4842
+ }
4843
+ return {
4844
+ packagesChecked: packages.length,
4845
+ matches,
4846
+ findings,
4847
+ errors
4848
+ };
4849
+ }
4850
+ function parseNpmPackages(packageJsonText) {
4851
+ try {
4852
+ const parsed = JSON.parse(packageJsonText);
4853
+ const deps = {
4854
+ ...parsed.dependencies ?? {},
4855
+ ...parsed.devDependencies ?? {}
4856
+ };
4857
+ return Object.entries(deps).map(([name, version]) => ({
4858
+ name,
4859
+ version: String(version).replace(/^[\^~>=<]+/, ""),
4860
+ // strip semver range prefixes
4861
+ ecosystem: "npm"
4862
+ }));
4863
+ } catch {
4864
+ return [];
4865
+ }
4866
+ }
4867
+
4868
+ // src/scanner/baas/vibe-app-scanner.ts
4869
+ var FETCH_TIMEOUT_MS5 = 1e4;
4870
+ async function fetchWithTimeout5(url, init) {
4871
+ const controller = new AbortController();
4872
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS5);
4873
+ try {
4874
+ return await fetch(url, { ...init, signal: controller.signal });
4875
+ } finally {
4876
+ clearTimeout(timer);
4877
+ }
4878
+ }
4879
+ function detectPlatform(findings, appUrl) {
4880
+ for (const f of findings) {
4881
+ const ev = (f.evidence ?? "").toLowerCase();
4882
+ const title = f.title.toLowerCase();
4883
+ if (ev.includes("supabase") || title.includes("supabase") || ev.includes("sb-") || ev.includes(".supabase.co")) {
4884
+ return "supabase";
4885
+ }
4886
+ if (ev.includes("firebase") || title.includes("firebase") || ev.includes("aizasy") || // Firebase API key prefix (lowercased)
4887
+ ev.includes(".firebaseapp.com") || ev.includes(".firebaseio.com")) {
4888
+ return "firebase";
4889
+ }
4890
+ }
4891
+ try {
4892
+ const hostname = new URL(appUrl).hostname;
4893
+ if (hostname.endsWith(".supabase.co")) return "supabase";
4894
+ if (hostname.endsWith(".web.app") || hostname.endsWith(".firebaseapp.com")) {
4895
+ return "firebase";
4896
+ }
4897
+ } catch {
4898
+ }
4899
+ return "unknown";
4900
+ }
4901
+ async function tryFetchPackageJson(appUrl) {
4902
+ const baseUrl = appUrl.endsWith("/") ? appUrl : `${appUrl}/`;
4903
+ const paths = [
4904
+ "package.json",
4905
+ "assets/package.json"
4906
+ ];
4907
+ for (const path of paths) {
4908
+ try {
4909
+ const res = await fetchWithTimeout5(`${baseUrl}${path}`);
4910
+ if (res.ok) {
4911
+ const text = await res.text();
4912
+ if (text.trimStart().startsWith("{") && text.includes('"dependencies"')) {
4913
+ return parseNpmPackages(text);
4914
+ }
4915
+ }
4916
+ } catch {
4917
+ continue;
4918
+ }
4919
+ }
4920
+ return [];
4921
+ }
4922
+ function extractPackagesFromBundleFindings(findings) {
4923
+ const packageNames = /* @__PURE__ */ new Set();
4924
+ for (const f of findings) {
4925
+ const ev = f.evidence ?? "";
4926
+ const nodeModuleMatches = ev.matchAll(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/g);
4927
+ for (const m of nodeModuleMatches) {
4928
+ if (m[1]) packageNames.add(m[1]);
4929
+ }
4930
+ }
4931
+ return Array.from(packageNames);
4932
+ }
4933
+ function deduplicateFindings3(findings) {
4934
+ const seen = /* @__PURE__ */ new Set();
4935
+ const unique = [];
4936
+ for (const f of findings) {
4937
+ const key = `${f.id}::${f.evidence ?? ""}`;
4938
+ if (seen.has(key)) continue;
4939
+ seen.add(key);
4940
+ unique.push(f);
4941
+ }
4942
+ return unique;
4943
+ }
4944
+ async function scanVibeApp(opts) {
4945
+ const allFindings = [];
4946
+ const errors = [];
4947
+ let packagesChecked = 0;
4948
+ let cveMatches = 0;
4949
+ const bundleResult = await analyzeBundles(opts.appUrl);
4950
+ allFindings.push(...bundleResult.findings);
4951
+ errors.push(...bundleResult.errors);
4952
+ const detectedPlatform = opts.platform ?? detectPlatform(bundleResult.findings, opts.appUrl);
4953
+ if (detectedPlatform === "supabase" || opts.supabaseUrl) {
4954
+ const supabaseUrl = opts.supabaseUrl ?? opts.appUrl;
4955
+ try {
4956
+ const supabaseResult = await scanSupabase({ projectUrl: supabaseUrl });
4957
+ allFindings.push(...supabaseResult.findings);
4958
+ errors.push(...supabaseResult.errors);
4959
+ } catch (err) {
4960
+ const msg = err instanceof Error ? err.message : String(err);
4961
+ errors.push(`Supabase scan failed: ${msg}`);
4962
+ }
4963
+ }
4964
+ if (detectedPlatform === "firebase" || opts.firebaseUrl) {
4965
+ const firebaseUrl = opts.firebaseUrl ?? opts.appUrl;
4966
+ try {
4967
+ const firebaseResult = await scanFirebase({ projectUrl: firebaseUrl });
4968
+ allFindings.push(...firebaseResult.findings);
4969
+ errors.push(...firebaseResult.errors);
4970
+ } catch (err) {
4971
+ const msg = err instanceof Error ? err.message : String(err);
4972
+ errors.push(`Firebase scan failed: ${msg}`);
4973
+ }
4974
+ }
4975
+ if (detectedPlatform === "unknown" && !opts.supabaseUrl && !opts.firebaseUrl) {
4976
+ errors.push(
4977
+ "Could not detect BaaS platform (Supabase or Firebase) from bundle analysis or URL. Provide --supabase <url> or --firebase <url> explicitly for deeper platform scanning."
4978
+ );
4979
+ }
4980
+ let packages = [];
4981
+ try {
4982
+ packages = await tryFetchPackageJson(opts.appUrl);
4983
+ } catch {
4984
+ }
4985
+ if (packages.length === 0) {
4986
+ const bundlePackageNames = extractPackagesFromBundleFindings(bundleResult.findings);
4987
+ if (bundlePackageNames.length > 0) {
4988
+ errors.push(
4989
+ `Detected ${bundlePackageNames.length} package(s) in bundles but could not determine versions for CVE lookup: ${bundlePackageNames.slice(0, 5).join(", ")}` + (bundlePackageNames.length > 5 ? "..." : "")
4990
+ );
4991
+ }
4992
+ }
4993
+ if (packages.length > 0) {
4994
+ try {
4995
+ const cveResult = await detectCves(packages);
4996
+ allFindings.push(...cveResult.findings);
4997
+ errors.push(...cveResult.errors);
4998
+ packagesChecked = cveResult.packagesChecked;
4999
+ cveMatches = cveResult.matches.length;
5000
+ } catch (err) {
5001
+ const msg = err instanceof Error ? err.message : String(err);
5002
+ errors.push(`CVE detection failed: ${msg}`);
5003
+ }
5004
+ }
5005
+ const findings = deduplicateFindings3(allFindings);
5006
+ return {
5007
+ appUrl: opts.appUrl,
5008
+ detectedPlatform,
5009
+ findings,
5010
+ bundlesAnalyzed: bundleResult.bundlesAnalyzed,
5011
+ packagesChecked,
5012
+ cveMatches,
5013
+ errors
5014
+ };
5015
+ }
5016
+
5017
+ // src/index.ts
5018
+ var VERSION = require_package().version;
5019
+ var program = new import_commander.Command();
5020
+ program.name("vigile-scan").description(
5021
+ "Security scanner for AI agent tools \u2014 detect tool poisoning, permission abuse, and supply chain attacks in MCP servers and agent skills"
5022
+ ).version(VERSION);
5023
+ function addScanOptions(cmd) {
5024
+ return cmd.option("-j, --json", "Output results as JSON").option("-v, --verbose", "Show detailed findings and score breakdown").option("-c, --config <path>", "Path to a custom MCP config file").option("-o, --output <path>", "Write results to a file").option(
5025
+ "--client <client>",
5026
+ "Only scan a specific client (claude-desktop, cursor, claude-code, windsurf, vscode, openclaw)"
5027
+ ).option("-s, --skills", "Scan agent skills only (SKILL.md, .mdc rules, CLAUDE.md, etc.)").option("-a, --all", "Scan both MCP servers and agent skills").option("--sentinel", "Enable Sentinel runtime monitoring (Pro+ feature)").option("--sentinel-server <name>", "Monitor a specific MCP server by name").option("--sentinel-duration <seconds>", "Monitoring duration in seconds (default: 120)", parseInt).option("--no-upload", "Skip uploading scan results to Vigile API").option("--supabase <url>", "Scan a Supabase project URL for RLS issues and exposed keys").option("--supabase-key <key>", "Supabase anon key (auto-detected from bundles if omitted)").option("--firebase <url>", "Scan a Firebase project URL for rules issues and exposed keys").option("--app <url>", "Scan a deployed web app for exposed secrets and BaaS misconfigs");
5028
+ }
5029
+ addScanOptions(
5030
+ program.command("scan").description("Scan MCP server configurations and agent skill files on this machine")
5031
+ ).action(async (options) => {
5032
+ await runScan(options);
5033
+ });
5034
+ addScanOptions(program).action(async (options) => {
5035
+ if (!process.argv.slice(2).includes("scan") && !process.argv.slice(2).includes("auth")) {
5036
+ await runScan(options);
5037
+ }
5038
+ });
5039
+ var authCmd = program.command("auth").description("Manage Vigile API authentication");
5040
+ authCmd.command("login").description("Authenticate with your Vigile API key").argument("[token]", "API key (vgl_...) or JWT token. If omitted, reads from VIGILE_TOKEN env var.").action(async (token) => {
5041
+ const resolvedToken = token || process.env.VIGILE_TOKEN;
5042
+ if (!resolvedToken) {
5043
+ console.log(import_chalk2.default.red(" No token provided. Pass a token argument or set VIGILE_TOKEN env var."));
5044
+ console.log(import_chalk2.default.gray(" Usage: vigile-scan auth login <vgl_your_api_key>"));
5045
+ console.log(import_chalk2.default.gray(" Get an API key at https://vigile.dev/account"));
5046
+ process.exit(1);
5047
+ }
5048
+ const spinner = (0, import_ora.default)("Validating token...").start();
5049
+ const result = await authLogin(resolvedToken);
5050
+ if (result.success && result.user) {
5051
+ spinner.succeed("Token validated");
5052
+ printAuthLoginSuccess(result.user.email, result.user.tier);
5053
+ } else {
5054
+ spinner.fail("Authentication failed");
5055
+ console.log(import_chalk2.default.red(` Error: ${result.error || "Unknown error"}`));
5056
+ process.exit(1);
5057
+ }
5058
+ });
5059
+ authCmd.command("status").description("Show current authentication status").action(async () => {
5060
+ const result = await authStatus();
5061
+ printAuthStatus({
5062
+ authenticated: result.authenticated,
5063
+ source: result.source,
5064
+ email: result.user?.email,
5065
+ tier: result.user?.tier,
5066
+ name: result.user?.name || void 0,
5067
+ error: result.error
5068
+ });
5069
+ });
5070
+ authCmd.command("logout").description("Clear stored credentials").action(async () => {
5071
+ await authLogout();
5072
+ console.log(import_chalk2.default.green(" Logged out. Credentials removed from ~/.vigile/config.json"));
5073
+ console.log("");
5074
+ });
5075
+ async function runScan(options) {
5076
+ const isJSON = options.json ?? false;
5077
+ const scanMCP = !options.skills;
5078
+ const scanSkills = options.skills || options.all;
5079
+ if (!isJSON) {
5080
+ printBanner();
5081
+ }
5082
+ const results = [];
5083
+ const skillResults = [];
5084
+ if (scanMCP) {
5085
+ const spinner = isJSON ? null : (0, import_ora.default)("Discovering MCP configurations...").start();
5086
+ const discovery = await discoverAllServers(options.client);
5087
+ if (discovery.servers.length === 0) {
5088
+ spinner?.succeed("No MCP server configurations found");
5089
+ } else {
5090
+ spinner?.succeed(
5091
+ `Found ${discovery.servers.length} MCP server(s) across ${discovery.configsFound} config file(s)`
5092
+ );
5093
+ const scanSpinner = isJSON ? null : (0, import_ora.default)("Scanning MCP servers...").start();
5094
+ for (const server of discovery.servers) {
5095
+ const result = await scanServer(server);
5096
+ results.push(result);
5097
+ }
5098
+ scanSpinner?.succeed(`Scanned ${results.length} MCP server(s)`);
5099
+ }
5100
+ }
5101
+ if (scanSkills) {
5102
+ const spinner = isJSON ? null : (0, import_ora.default)("Discovering agent skill files...").start();
5103
+ const skillDiscovery = await discoverAllSkills();
5104
+ if (skillDiscovery.skills.length === 0) {
5105
+ spinner?.succeed("No agent skill files found");
5106
+ } else {
5107
+ spinner?.succeed(
5108
+ `Found ${skillDiscovery.skills.length} skill file(s) across ${skillDiscovery.locationsFound} location(s)`
5109
+ );
5110
+ const scanSpinner = isJSON ? null : (0, import_ora.default)("Scanning agent skills...").start();
5111
+ for (const skill of skillDiscovery.skills) {
5112
+ const result = await scanSkill(skill);
5113
+ skillResults.push(result);
5114
+ }
5115
+ scanSpinner?.succeed(`Scanned ${skillResults.length} skill file(s)`);
5116
+ }
5117
+ }
5118
+ if (results.length === 0 && skillResults.length === 0) {
5119
+ if (!isJSON) {
5120
+ if (scanMCP && !scanSkills) {
5121
+ printNoServersFound();
5122
+ } else if (scanSkills && !scanMCP) {
5123
+ printNoSkillsFound();
5124
+ } else {
5125
+ printNothingFound();
5126
+ }
5127
+ } else {
5128
+ console.log(JSON.stringify({ servers: [], skills: [], message: "Nothing found to scan" }));
5129
+ }
5130
+ return;
5131
+ }
5132
+ const allResults = [...results];
5133
+ const allSkillResults = [...skillResults];
5134
+ const allTrustLevels = [
5135
+ ...results.map((r) => r.trustLevel),
5136
+ ...skillResults.map((r) => r.trustLevel)
5137
+ ];
5138
+ const allFindings = [
5139
+ ...results.flatMap((r) => r.findings),
5140
+ ...skillResults.flatMap((r) => r.findings)
5141
+ ];
5142
+ const summary = {
5143
+ totalServers: allResults.length,
5144
+ totalSkills: allSkillResults.length,
5145
+ byTrustLevel: {
5146
+ trusted: allTrustLevels.filter((l) => l === "trusted").length,
5147
+ caution: allTrustLevels.filter((l) => l === "caution").length,
5148
+ risky: allTrustLevels.filter((l) => l === "risky").length,
5149
+ dangerous: allTrustLevels.filter((l) => l === "dangerous").length
5150
+ },
5151
+ bySeverity: {
5152
+ critical: allFindings.filter((f) => f.severity === "critical").length,
5153
+ high: allFindings.filter((f) => f.severity === "high").length,
5154
+ medium: allFindings.filter((f) => f.severity === "medium").length,
5155
+ low: allFindings.filter((f) => f.severity === "low").length,
5156
+ info: allFindings.filter((f) => f.severity === "info").length
5157
+ },
5158
+ results: allResults,
5159
+ skillResults: allSkillResults,
2524
5160
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2525
5161
  version: VERSION
2526
5162
  };
2527
5163
  if (isJSON) {
2528
5164
  const jsonOutput = formatJSON(summary);
2529
5165
  if (options.output) {
2530
- await (0, import_promises4.writeFile)(options.output, jsonOutput);
5166
+ await (0, import_promises5.writeFile)(options.output, jsonOutput);
2531
5167
  } else {
2532
- console.log(jsonOutput);
5168
+ await new Promise((resolve, reject) => {
5169
+ const ok = process.stdout.write(jsonOutput + "\n");
5170
+ if (ok) resolve();
5171
+ else process.stdout.once("drain", resolve);
5172
+ });
2533
5173
  }
2534
5174
  } else {
2535
5175
  console.log("");
@@ -2545,7 +5185,7 @@ async function runScan(options) {
2545
5185
  }
2546
5186
  printSummary(summary);
2547
5187
  if (options.output) {
2548
- await (0, import_promises4.writeFile)(options.output, formatJSON(summary));
5188
+ await (0, import_promises5.writeFile)(options.output, formatJSON(summary));
2549
5189
  console.log(` Results saved to ${options.output}`);
2550
5190
  }
2551
5191
  }
@@ -2555,7 +5195,17 @@ async function runScan(options) {
2555
5195
  if (options.sentinel) {
2556
5196
  await runSentinel(options, results, isJSON);
2557
5197
  }
5198
+ if (options.supabase || options.firebase || options.app) {
5199
+ await runBaaSScan(options, isJSON);
5200
+ }
2558
5201
  if (summary.bySeverity.critical > 0 || summary.bySeverity.high > 0) {
5202
+ await new Promise((resolve) => {
5203
+ if (process.stdout.writableNeedDrain) {
5204
+ process.stdout.once("drain", resolve);
5205
+ } else {
5206
+ resolve();
5207
+ }
5208
+ });
2559
5209
  process.exit(1);
2560
5210
  }
2561
5211
  }
@@ -2608,6 +5258,90 @@ async function uploadResults(mcpResults, skillResults, isJSON) {
2608
5258
  printUploadSuccess(summary);
2609
5259
  }
2610
5260
  }
5261
+ async function runBaaSScan(options, isJSON) {
5262
+ const spinner = isJSON ? null : (0, import_ora.default)("Running BaaS security scan...").start();
5263
+ let totalFindings = 0;
5264
+ let criticalOrHigh = 0;
5265
+ if (options.supabase) {
5266
+ const result = await scanSupabase({
5267
+ projectUrl: options.supabase,
5268
+ anonKey: options.supabaseKey
5269
+ });
5270
+ totalFindings += result.findings.length;
5271
+ criticalOrHigh += result.findings.filter(
5272
+ (f) => f.severity === "critical" || f.severity === "high"
5273
+ ).length;
5274
+ if (!isJSON) {
5275
+ spinner?.stop();
5276
+ console.log(import_chalk2.default.bold(`
5277
+ Supabase: ${options.supabase}`));
5278
+ if (result.errors.length > 0) {
5279
+ result.errors.forEach((e) => console.log(import_chalk2.default.yellow(` \u26A0 ${e}`)));
5280
+ }
5281
+ result.findings.forEach((f) => {
5282
+ const color = f.severity === "critical" ? import_chalk2.default.red : f.severity === "high" ? import_chalk2.default.yellow : import_chalk2.default.gray;
5283
+ console.log(color(` [${f.severity.toUpperCase()}] ${f.title}`));
5284
+ if (options.verbose) console.log(import_chalk2.default.gray(` ${f.description}`));
5285
+ });
5286
+ }
5287
+ }
5288
+ if (options.firebase) {
5289
+ const result = await scanFirebase({ projectUrl: options.firebase });
5290
+ totalFindings += result.findings.length;
5291
+ criticalOrHigh += result.findings.filter(
5292
+ (f) => f.severity === "critical" || f.severity === "high"
5293
+ ).length;
5294
+ if (!isJSON) {
5295
+ spinner?.stop();
5296
+ console.log(import_chalk2.default.bold(`
5297
+ Firebase: ${options.firebase}`));
5298
+ if (result.errors.length > 0) {
5299
+ result.errors.forEach((e) => console.log(import_chalk2.default.yellow(` \u26A0 ${e}`)));
5300
+ }
5301
+ result.findings.forEach((f) => {
5302
+ const color = f.severity === "critical" ? import_chalk2.default.red : f.severity === "high" ? import_chalk2.default.yellow : import_chalk2.default.gray;
5303
+ console.log(color(` [${f.severity.toUpperCase()}] ${f.title}`));
5304
+ if (options.verbose) console.log(import_chalk2.default.gray(` ${f.description}`));
5305
+ });
5306
+ }
5307
+ }
5308
+ if (options.app) {
5309
+ const result = await scanVibeApp({ appUrl: options.app });
5310
+ totalFindings += result.findings.length;
5311
+ criticalOrHigh += result.findings.filter(
5312
+ (f) => f.severity === "critical" || f.severity === "high"
5313
+ ).length;
5314
+ if (!isJSON) {
5315
+ spinner?.stop();
5316
+ console.log(import_chalk2.default.bold(`
5317
+ App: ${options.app}`));
5318
+ console.log(import_chalk2.default.gray(` Platform detected: ${result.detectedPlatform}`));
5319
+ console.log(import_chalk2.default.gray(` Bundles analyzed: ${result.bundlesAnalyzed}`));
5320
+ if (result.errors.length > 0) {
5321
+ result.errors.forEach((e) => console.log(import_chalk2.default.yellow(` \u26A0 ${e}`)));
5322
+ }
5323
+ result.findings.forEach((f) => {
5324
+ const color = f.severity === "critical" ? import_chalk2.default.red : f.severity === "high" ? import_chalk2.default.yellow : import_chalk2.default.gray;
5325
+ console.log(color(` [${f.severity.toUpperCase()}] ${f.title}`));
5326
+ if (options.verbose) {
5327
+ console.log(import_chalk2.default.gray(` ${f.description}`));
5328
+ if (f.evidence) console.log(import_chalk2.default.gray(` Evidence: ${f.evidence}`));
5329
+ }
5330
+ });
5331
+ }
5332
+ }
5333
+ if (!isJSON) {
5334
+ console.log("");
5335
+ if (totalFindings === 0) {
5336
+ console.log(import_chalk2.default.green(" BaaS scan complete \u2014 no secrets or misconfigurations found"));
5337
+ } else {
5338
+ console.log(
5339
+ import_chalk2.default.yellow(` BaaS scan complete \u2014 ${totalFindings} finding(s), ${criticalOrHigh} critical/high`)
5340
+ );
5341
+ }
5342
+ console.log("");
5343
+ }
5344
+ }
2611
5345
  function mapMCPResultToApiPayload(result) {
2612
5346
  let packageUrl;
2613
5347
  if (result.server.command === "npx") {