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.
- package/bin/cli.mjs +219 -30
- 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
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`);
|