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.
- package/README.md +39 -0
- package/bin/cli.mjs +289 -0
- 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}`);
|