decoy-mcp 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/cli.mjs +219 -30
  2. package/package.json +1 -1
package/bin/cli.mjs CHANGED
@@ -57,6 +57,24 @@ function claudeCodeConfigPath() {
57
57
  return join(home, ".claude.json");
58
58
  }
59
59
 
60
+ function scanCachePath() {
61
+ return join(homedir(), ".decoy", "scan.json");
62
+ }
63
+
64
+ function saveScanResults(data) {
65
+ const p = scanCachePath();
66
+ mkdirSync(dirname(p), { recursive: true });
67
+ writeFileSync(p, JSON.stringify(data, null, 2) + "\n");
68
+ }
69
+
70
+ function loadScanResults() {
71
+ try {
72
+ return JSON.parse(readFileSync(scanCachePath(), "utf8"));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
60
78
  const HOSTS = {
61
79
  "claude-desktop": { name: "Claude Desktop", configPath: claudeDesktopConfigPath, format: "mcpServers" },
62
80
  "cursor": { name: "Cursor", configPath: cursorConfigPath, format: "mcpServers" },
@@ -218,6 +236,15 @@ async function init(flags) {
218
236
  try {
219
237
  data = await signup(email);
220
238
  } catch (e) {
239
+ if (e.message.includes("already exists")) {
240
+ log(` ${DIM}Account exists for ${email}. Log in with your token:${RESET}`);
241
+ log("");
242
+ log(` ${BOLD}npx decoy-mcp login --token=YOUR_TOKEN${RESET}`);
243
+ log("");
244
+ log(` ${DIM}Find your token in your welcome email or at${RESET}`);
245
+ log(` ${DIM}https://app.decoy.run/login${RESET}`);
246
+ process.exit(1);
247
+ }
221
248
  log(` ${RED}${e.message}${RESET}`);
222
249
  process.exit(1);
223
250
  }
@@ -331,11 +358,25 @@ async function status(flags) {
331
358
  }
332
359
 
333
360
  try {
334
- const res = await fetch(`${DECOY_URL}/api/triggers?token=${token}`);
335
- const data = await res.json();
361
+ const [triggerRes, configRes] = await Promise.all([
362
+ fetch(`${DECOY_URL}/api/triggers?token=${token}`),
363
+ fetch(`${DECOY_URL}/api/config?token=${token}`),
364
+ ]);
365
+ const data = await triggerRes.json();
366
+ const configData = await configRes.json().catch(() => ({}));
367
+ const isPro = (configData.plan || "free") !== "free";
368
+ const scanData = loadScanResults();
336
369
 
337
370
  if (flags.json) {
338
- log(JSON.stringify({ token: token.slice(0, 8) + "...", count: data.count, triggers: data.triggers?.slice(0, 5) || [], dashboard: `${DECOY_URL}/dashboard?token=${token}` }));
371
+ const jsonOut = { token: token.slice(0, 8) + "...", count: data.count, triggers: data.triggers?.slice(0, 5) || [], dashboard: `${DECOY_URL}/dashboard?token=${token}` };
372
+ if (isPro && scanData) {
373
+ jsonOut.triggers = jsonOut.triggers.map(t => {
374
+ const exposures = findExposures(t.tool, scanData);
375
+ return { ...t, exposed: exposures.length > 0, exposures };
376
+ });
377
+ jsonOut.scan_timestamp = scanData.timestamp;
378
+ }
379
+ log(JSON.stringify(jsonOut));
339
380
  return;
340
381
  }
341
382
 
@@ -349,7 +390,28 @@ async function status(flags) {
349
390
  const recent = data.triggers.slice(0, 5);
350
391
  for (const t of recent) {
351
392
  const severity = t.severity === "critical" ? `${RED}${t.severity}${RESET}` : `${DIM}${t.severity}${RESET}`;
352
- log(` ${DIM}${t.timestamp}${RESET} ${WHITE}${t.tool}${RESET} ${severity}`);
393
+
394
+ if (isPro && scanData) {
395
+ const exposures = findExposures(t.tool, scanData);
396
+ const tag = exposures.length > 0
397
+ ? ` ${RED}${BOLD}EXPOSED${RESET}`
398
+ : ` ${GREEN}no matching tools${RESET}`;
399
+ log(` ${DIM}${t.timestamp}${RESET} ${WHITE}${t.tool}${RESET} ${severity}${tag}`);
400
+ for (const e of exposures.slice(0, 2)) {
401
+ log(` ${DIM} ↳ ${e.server} → ${e.tool}${RESET}`);
402
+ }
403
+ } else {
404
+ log(` ${DIM}${t.timestamp}${RESET} ${WHITE}${t.tool}${RESET} ${severity}`);
405
+ }
406
+ }
407
+
408
+ if (!isPro) {
409
+ log("");
410
+ log(` ${ORANGE}!${RESET} ${WHITE}Exposure analysis${RESET} ${DIM}— see which triggers could have succeeded${RESET}`);
411
+ log(` ${DIM} Upgrade to Pro: ${ORANGE}${DECOY_URL}/dashboard?token=${token}${RESET}`);
412
+ } else if (!scanData) {
413
+ log("");
414
+ log(` ${DIM}Run ${BOLD}npx decoy-mcp scan${RESET}${DIM} to enable exposure analysis${RESET}`);
353
415
  }
354
416
  } else {
355
417
  log("");
@@ -564,7 +626,8 @@ async function login(flags) {
564
626
  }
565
627
 
566
628
  function pad(str, width) {
567
- return str.length >= width ? str : str + " ".repeat(width - str.length);
629
+ const s = String(str || "");
630
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
568
631
  }
569
632
 
570
633
  function timeAgo(isoString) {
@@ -724,14 +787,52 @@ async function watch(flags) {
724
787
  process.exit(1);
725
788
  }
726
789
 
790
+ // Load scan data + plan for exposure analysis
791
+ const scanData = loadScanResults();
792
+ let isPro = false;
793
+ try {
794
+ const configRes = await fetch(`${DECOY_URL}/api/config?token=${token}`);
795
+ const configData = await configRes.json();
796
+ isPro = (configData.plan || "free") !== "free";
797
+ } catch {}
798
+
727
799
  log("");
728
800
  log(` ${ORANGE}${BOLD}decoy${RESET} ${DIM}— watching for triggers${RESET}`);
801
+ if (isPro && scanData) {
802
+ log(` ${DIM}Exposure analysis active (scan: ${new Date(scanData.timestamp).toLocaleDateString()})${RESET}`);
803
+ }
729
804
  log(` ${DIM}Press Ctrl+C to stop${RESET}`);
730
805
  log("");
731
806
 
732
807
  let lastSeen = null;
733
808
  const interval = parseInt(flags.interval) || 5;
734
809
 
810
+ function formatTrigger(t) {
811
+ const severity = t.severity === "critical"
812
+ ? `${RED}${BOLD}CRITICAL${RESET}`
813
+ : t.severity === "high"
814
+ ? `${ORANGE}HIGH${RESET}`
815
+ : `${DIM}${t.severity}${RESET}`;
816
+
817
+ const time = new Date(t.timestamp).toLocaleTimeString();
818
+ let exposureTag = "";
819
+ if (isPro && scanData) {
820
+ const exposures = findExposures(t.tool, scanData);
821
+ exposureTag = exposures.length > 0
822
+ ? ` ${RED}${BOLD}EXPOSED${RESET} ${DIM}(${exposures.map(e => e.server + "→" + e.tool).join(", ")})${RESET}`
823
+ : ` ${GREEN}no matching tools${RESET}`;
824
+ }
825
+
826
+ log(` ${DIM}${time}${RESET} ${severity} ${WHITE}${t.tool}${RESET}${exposureTag}`);
827
+
828
+ if (t.arguments) {
829
+ const argStr = JSON.stringify(t.arguments);
830
+ if (argStr.length > 2) {
831
+ log(` ${DIM} ${argStr.length > 80 ? argStr.slice(0, 77) + "..." : argStr}${RESET}`);
832
+ }
833
+ }
834
+ }
835
+
735
836
  const poll = async () => {
736
837
  try {
737
838
  const res = await fetch(`${DECOY_URL}/api/triggers?token=${token}`);
@@ -741,22 +842,7 @@ async function watch(flags) {
741
842
 
742
843
  for (const t of data.triggers.slice().reverse()) {
743
844
  if (lastSeen && t.timestamp <= lastSeen) continue;
744
-
745
- const severity = t.severity === "critical"
746
- ? `${RED}${BOLD}CRITICAL${RESET}`
747
- : t.severity === "high"
748
- ? `${ORANGE}HIGH${RESET}`
749
- : `${DIM}${t.severity}${RESET}`;
750
-
751
- const time = new Date(t.timestamp).toLocaleTimeString();
752
- log(` ${DIM}${time}${RESET} ${severity} ${WHITE}${t.tool}${RESET}`);
753
-
754
- if (t.arguments) {
755
- const argStr = JSON.stringify(t.arguments);
756
- if (argStr.length > 2) {
757
- log(` ${DIM} ${argStr.length > 80 ? argStr.slice(0, 77) + "..." : argStr}${RESET}`);
758
- }
759
- }
845
+ formatTrigger(t);
760
846
  }
761
847
 
762
848
  lastSeen = data.triggers[0]?.timestamp || lastSeen;
@@ -770,16 +856,9 @@ async function watch(flags) {
770
856
  const res = await fetch(`${DECOY_URL}/api/triggers?token=${token}`);
771
857
  const data = await res.json();
772
858
  if (data.triggers?.length > 0) {
773
- // Show last 3 triggers as context
774
859
  const recent = data.triggers.slice(0, 3).reverse();
775
860
  for (const t of recent) {
776
- const severity = t.severity === "critical"
777
- ? `${RED}${BOLD}CRITICAL${RESET}`
778
- : t.severity === "high"
779
- ? `${ORANGE}HIGH${RESET}`
780
- : `${DIM}${t.severity}${RESET}`;
781
- const time = new Date(t.timestamp).toLocaleTimeString();
782
- log(` ${DIM}${time}${RESET} ${severity} ${WHITE}${t.tool}${RESET}`);
861
+ formatTrigger(t);
783
862
  }
784
863
  lastSeen = data.triggers[0].timestamp;
785
864
  log("");
@@ -937,6 +1016,90 @@ function classifyTool(tool) {
937
1016
  return "low";
938
1017
  }
939
1018
 
1019
+ // ─── Exposure analysis ───
1020
+ // Maps each tripwire tool to patterns that identify real tools with the same capability.
1021
+ // When a tripwire fires, we check if the user has a real tool that could fulfill the attack.
1022
+
1023
+ const CAPABILITY_PATTERNS = {
1024
+ execute_command: {
1025
+ names: [/exec/, /command/, /shell/, /bash/, /terminal/, /run_command/],
1026
+ descriptions: [/execut(e|ing)\s+(a\s+)?(shell|command|script|code)/i, /run\s+(shell|bash|system)\s+command/i, /terminal/i],
1027
+ },
1028
+ read_file: {
1029
+ names: [/read_file/, /get_file/, /file_read/, /read_content/, /cat$/],
1030
+ descriptions: [/read\s+(the\s+)?(contents?|file)/i, /file\s+contents?/i],
1031
+ },
1032
+ write_file: {
1033
+ names: [/write_file/, /create_file/, /file_write/, /save_file/, /put_file/],
1034
+ descriptions: [/write\s+(content\s+)?to\s+(a\s+)?file/i, /create\s+(a\s+)?file/i, /save.*file/i],
1035
+ },
1036
+ http_request: {
1037
+ names: [/http/, /fetch/, /curl/, /request/, /api_call/, /web_fetch/],
1038
+ descriptions: [/http\s+request/i, /fetch\s+(a\s+)?url/i, /make.*request/i],
1039
+ },
1040
+ database_query: {
1041
+ names: [/database/, /sql/, /query/, /db_/, /postgres/, /mysql/, /mongo/],
1042
+ descriptions: [/sql\s+query/i, /database/i, /execute.*query/i],
1043
+ },
1044
+ send_email: {
1045
+ names: [/send_email/, /email/, /mail/, /smtp/],
1046
+ descriptions: [/send\s+(an?\s+)?email/i, /smtp/i],
1047
+ },
1048
+ access_credentials: {
1049
+ names: [/credential/, /secret/, /vault/, /keychain/, /api_key/, /password/],
1050
+ descriptions: [/credential/i, /secret/i, /api[_\s]?key/i, /vault/i],
1051
+ },
1052
+ make_payment: {
1053
+ names: [/payment/, /pay/, /transfer/, /billing/, /charge/],
1054
+ descriptions: [/payment/i, /transfer\s+funds/i, /billing/i],
1055
+ },
1056
+ authorize_service: {
1057
+ names: [/authorize/, /oauth/, /grant/, /permission/],
1058
+ descriptions: [/grant\s+(trust|auth|permission)/i, /oauth/i, /authorize/i],
1059
+ },
1060
+ modify_dns: {
1061
+ names: [/dns/, /nameserver/, /route53/, /cloudflare.*record/],
1062
+ descriptions: [/dns\s+record/i, /modify\s+dns/i],
1063
+ },
1064
+ install_package: {
1065
+ names: [/install/, /pip_install/, /npm_install/, /package/],
1066
+ descriptions: [/install\s+(a\s+)?package/i],
1067
+ },
1068
+ get_environment_variables: {
1069
+ names: [/env/, /environment/, /getenv/],
1070
+ descriptions: [/environment\s+variable/i, /env.*var/i],
1071
+ },
1072
+ };
1073
+
1074
+ function findExposures(triggerToolName, scanData) {
1075
+ const patterns = CAPABILITY_PATTERNS[triggerToolName];
1076
+ if (!patterns || !scanData?.servers) return [];
1077
+
1078
+ const matches = [];
1079
+ for (const server of scanData.servers) {
1080
+ if (server.name === "system-tools") continue;
1081
+ for (const tool of (server.tools || [])) {
1082
+ const name = (tool.name || "").toLowerCase();
1083
+ const desc = tool.description || "";
1084
+
1085
+ let matched = false;
1086
+ for (const re of patterns.names) {
1087
+ if (re.test(name)) { matched = true; break; }
1088
+ }
1089
+ if (!matched) {
1090
+ for (const re of patterns.descriptions) {
1091
+ if (re.test(desc)) { matched = true; break; }
1092
+ }
1093
+ }
1094
+
1095
+ if (matched) {
1096
+ matches.push({ server: server.name, tool: tool.name, description: desc });
1097
+ }
1098
+ }
1099
+ }
1100
+ return matches;
1101
+ }
1102
+
940
1103
  function probeServer(serverName, entry, env) {
941
1104
  return new Promise((resolve) => {
942
1105
  const command = entry.command;
@@ -1185,6 +1348,32 @@ async function scan(flags) {
1185
1348
  log(` ${GREEN}\u2713${RESET} Low risk — no dangerous tools detected`);
1186
1349
  }
1187
1350
 
1351
+ // Save scan results locally for exposure analysis
1352
+ const scanData = {
1353
+ timestamp: new Date().toISOString(),
1354
+ servers: allFindings.filter(f => !f.error).map(f => ({
1355
+ name: f.server,
1356
+ hosts: f.hosts,
1357
+ tools: f.tools,
1358
+ })),
1359
+ };
1360
+ saveScanResults(scanData);
1361
+
1362
+ if (!flags.json) {
1363
+ log("");
1364
+ log(` ${GREEN}\u2713${RESET} Scan saved — triggers will now show exposure analysis`);
1365
+ }
1366
+
1367
+ // Upload to backend for enriched alerts (fire and forget)
1368
+ const token = findToken(flags);
1369
+ if (token) {
1370
+ fetch(`${DECOY_URL}/api/scan?token=${token}`, {
1371
+ method: "POST",
1372
+ headers: { "Content-Type": "application/json" },
1373
+ body: JSON.stringify(scanData),
1374
+ }).catch(() => {});
1375
+ }
1376
+
1188
1377
  log("");
1189
1378
  }
1190
1379
 
@@ -1242,7 +1431,7 @@ switch (cmd) {
1242
1431
  log(` ${ORANGE}${BOLD}decoy-mcp${RESET} ${DIM}— security tripwires for AI agents${RESET}`);
1243
1432
  log("");
1244
1433
  log(` ${WHITE}Commands:${RESET}`);
1245
- log(` ${BOLD}scan${RESET} Scan MCP servers for risky tools`);
1434
+ log(` ${BOLD}scan${RESET} Scan MCP servers for risky tools + enable exposure analysis`);
1246
1435
  log(` ${BOLD}init${RESET} Sign up and install tripwires`);
1247
1436
  log(` ${BOLD}login${RESET} Log in with an existing token`);
1248
1437
  log(` ${BOLD}doctor${RESET} Diagnose setup issues`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decoy-mcp",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Security tripwires for AI agents. Detect prompt injection in real time.",
5
5
  "bin": {
6
6
  "decoy-mcp": "bin/cli.mjs"