decoy-mcp 0.3.3 → 0.4.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 (3) hide show
  1. package/README.md +39 -0
  2. package/bin/cli.mjs +289 -0
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -40,9 +40,48 @@ This creates a free account, installs the MCP server locally, and configures you
40
40
 
41
41
  Every tool returns a realistic error response. The agent sees a timeout or permission denied — not a detection signal. Attackers don't know they've been caught.
42
42
 
43
+ ## Scan your attack surface
44
+
45
+ ```bash
46
+ npx decoy-mcp scan
47
+ ```
48
+
49
+ Probes every MCP server configured on your machine, discovers what tools they expose, and classifies each one by risk level. No account required.
50
+
51
+ ```
52
+ decoy — MCP security scan
53
+
54
+ Found 4 servers across 2 hosts. Probing for tools...
55
+
56
+ filesystem (Claude Desktop, Cursor)
57
+ CRITICAL execute_command
58
+ Execute a shell command on the host system.
59
+ HIGH read_file
60
+ Read the contents of a file from the filesystem.
61
+ + 3 more tools (1 medium, 2 low)
62
+
63
+ github (Claude Desktop)
64
+ ✓ 8 tools, all low risk
65
+
66
+ ──────────────────────────────────────────────────
67
+
68
+ Attack surface 14 tools across 2 servers
69
+
70
+ 1 critical — shell exec, file write, payments, DNS
71
+ 1 high — file read, HTTP, database, credentials
72
+ 1 medium — search, upload, download
73
+ 11 low
74
+
75
+ ! Decoy not installed. Add tripwires to detect prompt injection:
76
+ npx decoy-mcp init
77
+ ```
78
+
79
+ Use `--json` for machine-readable output.
80
+
43
81
  ## Commands
44
82
 
45
83
  ```bash
84
+ npx decoy-mcp scan # Scan MCP servers for risky tools
46
85
  npx decoy-mcp init # Sign up and install tripwires
47
86
  npx decoy-mcp login --token=xxx # Log in with existing token
48
87
  npx decoy-mcp doctor # Diagnose setup issues
package/bin/cli.mjs CHANGED
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync } from
5
5
  import { join, dirname } from "node:path";
6
6
  import { homedir, platform } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { spawn } from "node:child_process";
8
9
 
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
  const API_URL = "https://decoy.run/api/signup";
@@ -904,6 +905,289 @@ function printAlerts(alerts) {
904
905
  log(` ${DIM}Slack:${RESET} ${alerts.slack ? `${GREEN}${alerts.slack}${RESET}` : `${DIM}not set${RESET}`}`);
905
906
  }
906
907
 
908
+ // ─── Scan ───
909
+
910
+ const RISK_PATTERNS = {
911
+ critical: {
912
+ names: [/^execute/, /^run_command/, /^shell/, /^bash/, /^exec_/, /^write_file/, /^create_file/, /^delete_file/, /^remove_file/, /^make_payment/, /^transfer/, /^authorize_service/, /^modify_dns/, /^send_email/, /^send_message/],
913
+ descriptions: [/execut(e|ing)\s+(a\s+)?(shell|command|script|code)/i, /run\s+(shell|bash|system)\s+command/i, /write\s+(content\s+)?to\s+(a\s+)?file/i, /delete\s+(a\s+)?file/i, /payment|billing|transfer\s+funds/i, /modify\s+dns/i, /send\s+(an?\s+)?email/i, /grant\s+(trust|auth|permission)/i],
914
+ },
915
+ high: {
916
+ names: [/^read_file/, /^get_file/, /^http_request/, /^fetch/, /^curl/, /^database_query/, /^sql/, /^db_/, /^access_credential/, /^get_secret/, /^get_env/, /^get_environment/, /^install_package/, /^install$/],
917
+ descriptions: [/read\s+(the\s+)?(content|file)/i, /http\s+request/i, /fetch\s+(a\s+)?url/i, /sql\s+query/i, /execut.*\s+query/i, /credential|secret|api[_\s]?key|vault/i, /environment\s+variable/i, /install\s+(a\s+)?package/i],
918
+ },
919
+ medium: {
920
+ names: [/^list_dir/, /^search/, /^find_/, /^glob/, /^grep/, /^upload/, /^download/],
921
+ descriptions: [/list\s+(all\s+)?(files|director)/i, /search\s+(the\s+)?/i, /upload/i, /download/i],
922
+ },
923
+ };
924
+
925
+ function classifyTool(tool) {
926
+ const name = (tool.name || "").toLowerCase();
927
+ const desc = (tool.description || "").toLowerCase();
928
+
929
+ for (const [level, patterns] of Object.entries(RISK_PATTERNS)) {
930
+ for (const re of patterns.names) {
931
+ if (re.test(name)) return level;
932
+ }
933
+ for (const re of patterns.descriptions) {
934
+ if (re.test(desc)) return level;
935
+ }
936
+ }
937
+ return "low";
938
+ }
939
+
940
+ function probeServer(serverName, entry, env) {
941
+ return new Promise((resolve) => {
942
+ const command = entry.command;
943
+ const args = entry.args || [];
944
+ const serverEnv = { ...process.env, ...env, ...(entry.env || {}) };
945
+ const timeout = 10000;
946
+
947
+ let proc;
948
+ try {
949
+ proc = spawn(command, args, { env: serverEnv, stdio: ["pipe", "pipe", "pipe"] });
950
+ } catch (e) {
951
+ resolve({ server: serverName, error: `spawn failed: ${e.message}`, tools: [] });
952
+ return;
953
+ }
954
+
955
+ let stdout = "";
956
+ let stderr = "";
957
+ let done = false;
958
+ let toolsSent = false;
959
+
960
+ const finish = (result) => {
961
+ if (done) return;
962
+ done = true;
963
+ clearTimeout(timer);
964
+ try { proc.kill(); } catch {}
965
+ resolve(result);
966
+ };
967
+
968
+ const timer = setTimeout(() => {
969
+ finish({ server: serverName, error: "timeout (10s)", tools: [] });
970
+ }, timeout);
971
+
972
+ proc.stdout.on("data", (chunk) => {
973
+ stdout += chunk.toString();
974
+
975
+ // Parse newline-delimited JSON responses
976
+ const lines = stdout.split("\n");
977
+ stdout = lines.pop(); // keep incomplete line in buffer
978
+
979
+ for (const line of lines) {
980
+ if (!line.trim()) continue;
981
+ try {
982
+ const msg = JSON.parse(line.trim());
983
+
984
+ // After initialize response, send tools/list
985
+ if (msg.id === "init-1" && msg.result && !toolsSent) {
986
+ toolsSent = true;
987
+ const toolsReq = JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: "tools-1" }) + "\n";
988
+ try { proc.stdin.write(toolsReq); } catch {}
989
+ }
990
+
991
+ // Got tools list
992
+ if (msg.id === "tools-1" && msg.result) {
993
+ finish({ server: serverName, tools: msg.result.tools || [], error: null });
994
+ }
995
+ } catch {}
996
+ }
997
+ });
998
+
999
+ proc.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
1000
+
1001
+ proc.on("error", (e) => {
1002
+ finish({ server: serverName, error: e.message, tools: [] });
1003
+ });
1004
+
1005
+ proc.on("exit", (code) => {
1006
+ if (!done) {
1007
+ finish({ server: serverName, error: `exited with code ${code}`, tools: [] });
1008
+ }
1009
+ });
1010
+
1011
+ // Send initialize
1012
+ const initMsg = JSON.stringify({
1013
+ jsonrpc: "2.0",
1014
+ method: "initialize",
1015
+ params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "decoy-scan", version: "1.0.0" } },
1016
+ id: "init-1",
1017
+ }) + "\n";
1018
+
1019
+ try { proc.stdin.write(initMsg); } catch {}
1020
+ });
1021
+ }
1022
+
1023
+ function readHostConfigs() {
1024
+ const results = [];
1025
+
1026
+ for (const [hostId, host] of Object.entries(HOSTS)) {
1027
+ const configPath = host.configPath();
1028
+ if (!existsSync(configPath)) continue;
1029
+
1030
+ let config;
1031
+ try {
1032
+ config = JSON.parse(readFileSync(configPath, "utf8"));
1033
+ } catch { continue; }
1034
+
1035
+ const key = host.format === "mcp.servers" ? "mcp.servers" : "mcpServers";
1036
+ const servers = config[key];
1037
+ if (!servers || typeof servers !== "object") continue;
1038
+
1039
+ for (const [name, entry] of Object.entries(servers)) {
1040
+ results.push({ hostId, hostName: host.name, serverName: name, entry });
1041
+ }
1042
+ }
1043
+
1044
+ return results;
1045
+ }
1046
+
1047
+ async function scan(flags) {
1048
+ const YELLOW = "\x1b[33m";
1049
+
1050
+ if (!flags.json) {
1051
+ log("");
1052
+ log(` ${ORANGE}${BOLD}decoy${RESET} ${DIM}— MCP security scan${RESET}`);
1053
+ log("");
1054
+ }
1055
+
1056
+ // 1. Enumerate all configured servers across hosts
1057
+ const configs = readHostConfigs();
1058
+
1059
+ if (configs.length === 0) {
1060
+ if (flags.json) { log(JSON.stringify({ error: "No MCP hosts found" })); process.exit(1); }
1061
+ log(` ${RED}No MCP servers found.${RESET}`);
1062
+ log(` ${DIM}Decoy scans MCP configs for Claude Desktop, Cursor, Windsurf, VS Code, and Claude Code.${RESET}`);
1063
+ log("");
1064
+ process.exit(1);
1065
+ }
1066
+
1067
+ // Dedupe servers by name (same server may be in multiple hosts)
1068
+ const seen = new Map();
1069
+ for (const c of configs) {
1070
+ if (!seen.has(c.serverName)) {
1071
+ seen.set(c.serverName, { ...c, hosts: [c.hostName] });
1072
+ } else {
1073
+ seen.get(c.serverName).hosts.push(c.hostName);
1074
+ }
1075
+ }
1076
+
1077
+ const uniqueServers = [...seen.values()];
1078
+
1079
+ const hostCount = new Set(configs.map(c => c.hostId)).size;
1080
+ if (!flags.json) {
1081
+ log(` ${DIM}Found ${uniqueServers.length} server${uniqueServers.length === 1 ? "" : "s"} across ${hostCount} host${hostCount === 1 ? "" : "s"}. Probing for tools...${RESET}`);
1082
+ log("");
1083
+ }
1084
+
1085
+ // 2. Probe each server for its tool list
1086
+ const probes = uniqueServers.map(c => probeServer(c.serverName, c.entry, {}));
1087
+ const results = await Promise.all(probes);
1088
+
1089
+ // 3. Classify tools
1090
+ let totalTools = 0;
1091
+ const allFindings = [];
1092
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
1093
+
1094
+ for (const result of results) {
1095
+ const entry = seen.get(result.server);
1096
+ const hosts = entry?.hosts || [];
1097
+
1098
+ if (result.error) {
1099
+ allFindings.push({ server: result.server, error: result.error, tools: [], hosts });
1100
+ continue;
1101
+ }
1102
+
1103
+ const classified = result.tools.map(t => ({
1104
+ name: t.name,
1105
+ description: (t.description || "").slice(0, 100),
1106
+ risk: classifyTool(t),
1107
+ }));
1108
+
1109
+ classified.sort((a, b) => {
1110
+ const order = { critical: 0, high: 1, medium: 2, low: 3 };
1111
+ return order[a.risk] - order[b.risk];
1112
+ });
1113
+
1114
+ for (const t of classified) counts[t.risk]++;
1115
+ totalTools += classified.length;
1116
+
1117
+ allFindings.push({ server: result.server, tools: classified, error: null, hosts });
1118
+ }
1119
+
1120
+ // 4. JSON output
1121
+ if (flags.json) {
1122
+ log(JSON.stringify({ servers: allFindings, summary: { total_tools: totalTools, ...counts } }));
1123
+ return;
1124
+ }
1125
+
1126
+ // 5. Terminal output
1127
+ const riskColor = (r) => r === "critical" ? RED : r === "high" ? ORANGE : r === "medium" ? YELLOW : DIM;
1128
+ const riskBadge = (r) => `${riskColor(r)}${r.toUpperCase()}${RESET}`;
1129
+
1130
+ for (const finding of allFindings) {
1131
+ const hostStr = finding.hosts?.length > 0 ? ` ${DIM}(${finding.hosts.join(", ")})${RESET}` : "";
1132
+ log(` ${WHITE}${BOLD}${finding.server}${RESET}${hostStr}`);
1133
+
1134
+ if (finding.error) {
1135
+ log(` ${DIM}Could not probe: ${finding.error}${RESET}`);
1136
+ log("");
1137
+ continue;
1138
+ }
1139
+
1140
+ if (finding.tools.length === 0) {
1141
+ log(` ${DIM}No tools exposed${RESET}`);
1142
+ log("");
1143
+ continue;
1144
+ }
1145
+
1146
+ const dangerousTools = finding.tools.filter(t => t.risk === "critical" || t.risk === "high");
1147
+ const safeTools = finding.tools.filter(t => t.risk !== "critical" && t.risk !== "high");
1148
+
1149
+ for (const t of dangerousTools) {
1150
+ log(` ${riskBadge(t.risk)} ${WHITE}${t.name}${RESET}`);
1151
+ if (t.description) log(` ${DIM} ${t.description}${RESET}`);
1152
+ }
1153
+ if (safeTools.length > 0 && dangerousTools.length > 0) {
1154
+ log(` ${DIM}+ ${safeTools.length} more tool${safeTools.length === 1 ? "" : "s"} (${safeTools.filter(t => t.risk === "medium").length} medium, ${safeTools.filter(t => t.risk === "low").length} low)${RESET}`);
1155
+ } else if (safeTools.length > 0) {
1156
+ log(` ${GREEN}\u2713${RESET} ${DIM}${safeTools.length} tool${safeTools.length === 1 ? "" : "s"}, all low risk${RESET}`);
1157
+ }
1158
+
1159
+ log("");
1160
+ }
1161
+
1162
+ // 6. Summary
1163
+ const divider = ` ${DIM}${"─".repeat(50)}${RESET}`;
1164
+ log(divider);
1165
+ log("");
1166
+ log(` ${WHITE}${BOLD}Attack surface${RESET} ${totalTools} tool${totalTools === 1 ? "" : "s"} across ${allFindings.filter(f => !f.error).length} server${allFindings.filter(f => !f.error).length === 1 ? "" : "s"}`);
1167
+ log("");
1168
+
1169
+ if (counts.critical > 0) log(` ${RED}${BOLD}${counts.critical}${RESET} ${RED}critical${RESET} ${DIM}— shell exec, file write, payments, DNS${RESET}`);
1170
+ if (counts.high > 0) log(` ${ORANGE}${BOLD}${counts.high}${RESET} ${ORANGE}high${RESET} ${DIM}— file read, HTTP, database, credentials${RESET}`);
1171
+ if (counts.medium > 0) log(` ${YELLOW}${BOLD}${counts.medium}${RESET} ${YELLOW}medium${RESET} ${DIM}— search, upload, download${RESET}`);
1172
+ if (counts.low > 0) log(` ${DIM}${counts.low} low${RESET}`);
1173
+
1174
+ log("");
1175
+
1176
+ if (counts.critical > 0 || counts.high > 0) {
1177
+ const hasDecoy = allFindings.some(f => f.server === "system-tools" && !f.error);
1178
+ if (hasDecoy) {
1179
+ log(` ${GREEN}\u2713${RESET} Decoy tripwires active`);
1180
+ } else {
1181
+ log(` ${ORANGE}!${RESET} ${WHITE}Decoy not installed.${RESET} Add tripwires to detect prompt injection:`);
1182
+ log(` ${DIM}npx decoy-mcp init${RESET}`);
1183
+ }
1184
+ } else {
1185
+ log(` ${GREEN}\u2713${RESET} Low risk — no dangerous tools detected`);
1186
+ }
1187
+
1188
+ log("");
1189
+ }
1190
+
907
1191
  // ─── Command router ───
908
1192
 
909
1193
  const args = process.argv.slice(2);
@@ -950,11 +1234,15 @@ switch (cmd) {
950
1234
  case "doctor":
951
1235
  doctor(flags).catch(e => { log(` ${RED}Error: ${e.message}${RESET}`); process.exit(1); });
952
1236
  break;
1237
+ case "scan":
1238
+ scan(flags).catch(e => { log(` ${RED}Error: ${e.message}${RESET}`); process.exit(1); });
1239
+ break;
953
1240
  default:
954
1241
  log("");
955
1242
  log(` ${ORANGE}${BOLD}decoy-mcp${RESET} ${DIM}— security tripwires for AI agents${RESET}`);
956
1243
  log("");
957
1244
  log(` ${WHITE}Commands:${RESET}`);
1245
+ log(` ${BOLD}scan${RESET} Scan MCP servers for risky tools`);
958
1246
  log(` ${BOLD}init${RESET} Sign up and install tripwires`);
959
1247
  log(` ${BOLD}login${RESET} Log in with an existing token`);
960
1248
  log(` ${BOLD}doctor${RESET} Diagnose setup issues`);
@@ -976,6 +1264,7 @@ switch (cmd) {
976
1264
  log(` ${DIM}--json${RESET} Machine-readable output`);
977
1265
  log("");
978
1266
  log(` ${WHITE}Examples:${RESET}`);
1267
+ log(` ${DIM}npx decoy-mcp scan${RESET}`);
979
1268
  log(` ${DIM}npx decoy-mcp init${RESET}`);
980
1269
  log(` ${DIM}npx decoy-mcp login --token=abc123...${RESET}`);
981
1270
  log(` ${DIM}npx decoy-mcp doctor${RESET}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decoy-mcp",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Security tripwires for AI agents. Detect prompt injection attacks on your MCP tools.",
5
5
  "bin": {
6
6
  "decoy-mcp": "./bin/cli.mjs"