agent-mcp-guard 0.1.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/src/rules.js ADDED
@@ -0,0 +1,270 @@
1
+ import path from "node:path";
2
+ import { isSecretLikeName, redactValue } from "./redact.js";
3
+
4
+ const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "pwsh", "powershell", "cmd", "cmd.exe"]);
5
+ const EVAL_COMMANDS = new Set(["node", "python", "python3", "ruby", "perl", "php", "deno", "bun"]);
6
+ const REMOTE_EXEC_COMMANDS = new Set(["npx", "bunx", "uvx", "pipx"]);
7
+ const PACKAGE_MANAGER_COMMANDS = new Set(["npm", "pnpm", "yarn"]);
8
+ const BROAD_DIR_NAMES = new Set(["Desktop", "Documents", "Downloads"]);
9
+
10
+ export function evaluateServer(server, context) {
11
+ const findings = [];
12
+
13
+ if (!server.command && !server.url) {
14
+ findings.push(finding({
15
+ id: "MCP001",
16
+ severity: "high",
17
+ title: "Server has no command or URL",
18
+ server,
19
+ evidence: "This MCP server entry cannot be executed or audited reliably.",
20
+ recommendation: "Remove the server or define an explicit command/url with a reviewed configuration."
21
+ }));
22
+ }
23
+
24
+ findings.push(...ruleShellExecution(server));
25
+ findings.push(...ruleEvalExecution(server));
26
+ findings.push(...ruleRemotePackageExecution(server));
27
+ findings.push(...ruleUnpinnedPackage(server));
28
+ findings.push(...ruleSecretEnvironment(server));
29
+ findings.push(...ruleBroadWorkingDirectory(server, context));
30
+ findings.push(...ruleBroadFilesystemArgs(server, context));
31
+ findings.push(...ruleDangerousCommandPattern(server));
32
+ findings.push(...ruleRemoteUrl(server));
33
+ findings.push(...ruleHeaders(server));
34
+
35
+ return findings;
36
+ }
37
+
38
+ function ruleShellExecution(server) {
39
+ const command = commandBase(server.command);
40
+ if (!SHELL_COMMANDS.has(command)) return [];
41
+
42
+ const hasInlineScript = server.args.some((arg) => arg === "-c" || arg === "/c");
43
+ return [finding({
44
+ id: "MCP010",
45
+ severity: hasInlineScript ? "critical" : "high",
46
+ title: hasInlineScript ? "Shell command executes inline script" : "MCP server runs through a shell",
47
+ server,
48
+ evidence: `command=${server.command} args=${server.args.join(" ")}`,
49
+ recommendation: "Use a direct, pinned executable instead of a shell wrapper. If a shell is required, place the script in source control and review it."
50
+ })];
51
+ }
52
+
53
+ function ruleEvalExecution(server) {
54
+ const command = commandBase(server.command);
55
+ if (!EVAL_COMMANDS.has(command)) return [];
56
+
57
+ const evalFlag = server.args.find((arg) => ["-e", "-c", "--eval"].includes(arg));
58
+ if (!evalFlag) return [];
59
+
60
+ return [finding({
61
+ id: "MCP011",
62
+ severity: "high",
63
+ title: "Interpreter eval mode is enabled",
64
+ server,
65
+ evidence: `command=${server.command} uses ${evalFlag}`,
66
+ recommendation: "Replace inline code with a reviewed package or checked-in script."
67
+ })];
68
+ }
69
+
70
+ function ruleRemotePackageExecution(server) {
71
+ const command = commandBase(server.command);
72
+ const firstArg = firstPackageArg(server.args);
73
+ const usesDlx = PACKAGE_MANAGER_COMMANDS.has(command) && server.args[0] === "dlx";
74
+ if (!REMOTE_EXEC_COMMANDS.has(command) && !usesDlx) return [];
75
+
76
+ return [finding({
77
+ id: "MCP020",
78
+ severity: "medium",
79
+ title: "MCP server is launched through a remote package runner",
80
+ server,
81
+ evidence: `command=${server.command} package=${firstArg || "<unknown>"}`,
82
+ recommendation: "Pin the package version, review the package source, and prefer a local lockfile or vendored executable for sensitive tools."
83
+ })];
84
+ }
85
+
86
+ function ruleUnpinnedPackage(server) {
87
+ const command = commandBase(server.command);
88
+ const usesRemoteRunner = REMOTE_EXEC_COMMANDS.has(command) || (PACKAGE_MANAGER_COMMANDS.has(command) && server.args[0] === "dlx");
89
+ if (!usesRemoteRunner) return [];
90
+
91
+ const packageArg = firstPackageArg(server.args);
92
+ if (!packageArg || isPinnedPackage(packageArg)) return [];
93
+
94
+ return [finding({
95
+ id: "MCP021",
96
+ severity: "high",
97
+ title: "Remote MCP package is not version pinned",
98
+ server,
99
+ evidence: `package=${packageArg}`,
100
+ recommendation: "Pin the package to an exact version such as package@1.2.3 and review updates before changing it."
101
+ })];
102
+ }
103
+
104
+ function ruleSecretEnvironment(server) {
105
+ const findings = [];
106
+ for (const [key, value] of Object.entries(server.env)) {
107
+ if (!isSecretLikeName(key)) continue;
108
+ findings.push(finding({
109
+ id: "MCP030",
110
+ severity: "high",
111
+ title: "Secret-like environment variable is exposed to MCP server",
112
+ server,
113
+ evidence: `${key}=${redactValue(value)}`,
114
+ recommendation: "Pass the least privileged token possible. Prefer scoped tokens, short-lived credentials, and a dedicated service account."
115
+ }));
116
+ }
117
+ return findings;
118
+ }
119
+
120
+ function ruleBroadWorkingDirectory(server, context) {
121
+ if (!server.cwd) return [];
122
+
123
+ const normalized = normalizePath(server.cwd, context);
124
+ const home = normalizePath(context.home, context);
125
+ const isHome = normalized === home;
126
+ const isRoot = normalized === path.parse(normalized).root;
127
+ const isSensitiveHomeChild = BROAD_DIR_NAMES.has(path.basename(normalized));
128
+
129
+ if (!isHome && !isRoot && !isSensitiveHomeChild) return [];
130
+
131
+ return [finding({
132
+ id: "MCP040",
133
+ severity: isRoot || isHome ? "high" : "medium",
134
+ title: "MCP server has a broad working directory",
135
+ server,
136
+ evidence: `cwd=${server.cwd}`,
137
+ recommendation: "Run the server in a narrow project directory or sandbox with only the files it needs."
138
+ })];
139
+ }
140
+
141
+ function ruleBroadFilesystemArgs(server, context) {
142
+ const findings = [];
143
+ const home = normalizePath(context.home, context);
144
+
145
+ for (const arg of server.args) {
146
+ const value = valueFromArg(arg);
147
+ if (!value) continue;
148
+
149
+ const normalized = normalizePath(value, context);
150
+ const base = path.basename(normalized);
151
+ const isRoot = normalized === path.parse(normalized).root;
152
+ const isHome = normalized === home;
153
+ const isHomePrefix = normalized.startsWith(`${home}${path.sep}`) && normalized.split(path.sep).length <= home.split(path.sep).length + 2;
154
+ const isSensitiveHomeChild = BROAD_DIR_NAMES.has(base);
155
+
156
+ if (isRoot || isHome || isHomePrefix || isSensitiveHomeChild || value === "~") {
157
+ findings.push(finding({
158
+ id: "MCP041",
159
+ severity: isRoot || isHome ? "high" : "medium",
160
+ title: "MCP server argument grants broad filesystem access",
161
+ server,
162
+ evidence: `arg=${arg}`,
163
+ recommendation: "Replace broad filesystem paths with a dedicated project folder or read-only sandbox path."
164
+ }));
165
+ }
166
+ }
167
+
168
+ return findings;
169
+ }
170
+
171
+ function ruleDangerousCommandPattern(server) {
172
+ const joined = [server.command, ...server.args].join(" ");
173
+ const patterns = [
174
+ { pattern: /\brm\s+-rf\b/, label: "rm -rf" },
175
+ { pattern: /\bsudo\b/, label: "sudo" },
176
+ { pattern: /\bchmod\s+777\b/, label: "chmod 777" },
177
+ { pattern: /\bgit\s+push\s+--force\b/, label: "git push --force" },
178
+ { pattern: /\bcurl\b.*\|\s*(sh|bash|zsh)\b/, label: "curl pipe to shell" },
179
+ { pattern: /\bwget\b.*\|\s*(sh|bash|zsh)\b/, label: "wget pipe to shell" }
180
+ ];
181
+
182
+ return patterns
183
+ .filter(({ pattern }) => pattern.test(joined))
184
+ .map(({ label }) => finding({
185
+ id: "MCP050",
186
+ severity: "critical",
187
+ title: "MCP server command includes a dangerous operation",
188
+ server,
189
+ evidence: label,
190
+ recommendation: "Remove the dangerous operation from MCP startup. Run destructive setup steps manually and review them separately."
191
+ }));
192
+ }
193
+
194
+ function ruleRemoteUrl(server) {
195
+ if (!server.url || !/^https?:\/\//i.test(server.url)) return [];
196
+
197
+ return [finding({
198
+ id: "MCP060",
199
+ severity: "medium",
200
+ title: "Remote MCP server URL is configured",
201
+ server,
202
+ evidence: `url=${server.url}`,
203
+ recommendation: "Verify the provider, use HTTPS, document the data sent to this server, and keep an allowlist of approved remote endpoints."
204
+ })];
205
+ }
206
+
207
+ function ruleHeaders(server) {
208
+ const findings = [];
209
+ for (const [key, value] of Object.entries(server.headers)) {
210
+ if (!isSecretLikeName(key) && !isSecretLikeName(value)) continue;
211
+ findings.push(finding({
212
+ id: "MCP061",
213
+ severity: "high",
214
+ title: "Secret-like header is configured for remote MCP server",
215
+ server,
216
+ evidence: `${key}=${redactValue(value)}`,
217
+ recommendation: "Use scoped, short-lived credentials and avoid placing long-lived secrets directly in MCP config files."
218
+ }));
219
+ }
220
+ return findings;
221
+ }
222
+
223
+ function finding({ id, severity, title, server, evidence, recommendation }) {
224
+ return {
225
+ id,
226
+ severity,
227
+ title,
228
+ serverName: server.name,
229
+ configPath: server.configPath,
230
+ evidence,
231
+ recommendation
232
+ };
233
+ }
234
+
235
+ function commandBase(command) {
236
+ if (!command) return "";
237
+ return path.basename(command).toLowerCase();
238
+ }
239
+
240
+ function firstPackageArg(args) {
241
+ const cleaned = args.filter((arg) => arg && !arg.startsWith("-"));
242
+ if (cleaned[0] === "dlx") return cleaned[1] || "";
243
+ return cleaned[0] || "";
244
+ }
245
+
246
+ function isPinnedPackage(packageName) {
247
+ if (packageName.startsWith("@")) {
248
+ const secondAt = packageName.indexOf("@", 1);
249
+ return secondAt > 1 && secondAt < packageName.length - 1;
250
+ }
251
+ const at = packageName.lastIndexOf("@");
252
+ return at > 0 && at < packageName.length - 1;
253
+ }
254
+
255
+ function valueFromArg(arg) {
256
+ if (!arg) return "";
257
+ const equalIndex = arg.indexOf("=");
258
+ if (equalIndex > -1) return arg.slice(equalIndex + 1);
259
+ if (arg.startsWith("/") || arg.startsWith("~")) return arg;
260
+ return "";
261
+ }
262
+
263
+ function normalizePath(value, context) {
264
+ if (!value) return "";
265
+ if (value === "~") return context.home;
266
+ if (value.startsWith("~/")) return path.join(context.home, value.slice(2));
267
+ if (path.isAbsolute(value)) return path.normalize(value);
268
+ return path.resolve(context.cwd, value);
269
+ }
270
+
package/src/scan.js ADDED
@@ -0,0 +1,107 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { extractServers, loadConfigFile } from "./config.js";
4
+ import { discoverConfigFiles } from "./discovery.js";
5
+ import { evaluateServer } from "./rules.js";
6
+ import { sortFindings } from "./severity.js";
7
+
8
+ export async function scan({ cwd, env, configPaths = [], includeDefaults = true, toolVersion = "0.0.0" }) {
9
+ const home = env.HOME || env.USERPROFILE || os.homedir();
10
+ const explicitPaths = configPaths.map((item) => path.resolve(item));
11
+ const shouldDiscover = explicitPaths.length === 0 && includeDefaults;
12
+ const discoveredPaths = shouldDiscover ? await discoverConfigFiles({ cwd, env }) : [];
13
+ const pathsToScan = uniquePaths([...explicitPaths, ...discoveredPaths]);
14
+
15
+ const servers = [];
16
+ const findings = [];
17
+ const scannedFiles = [];
18
+
19
+ if (pathsToScan.length === 0) {
20
+ findings.push({
21
+ id: "MCP000",
22
+ severity: "low",
23
+ title: "No MCP config files found",
24
+ serverName: "<workspace>",
25
+ configPath: cwd,
26
+ evidence: "mcp-guard checked common Claude Desktop, Cursor, and project config paths.",
27
+ recommendation: "Pass --config path/to/mcp.json if your configuration lives elsewhere."
28
+ });
29
+ }
30
+
31
+ for (const configPath of pathsToScan) {
32
+ scannedFiles.push(configPath);
33
+ try {
34
+ const config = await loadConfigFile(configPath);
35
+ const extracted = extractServers(config, configPath);
36
+ servers.push(...extracted);
37
+
38
+ if (extracted.length === 0) {
39
+ findings.push({
40
+ id: "MCP002",
41
+ severity: "medium",
42
+ title: "Config file has no MCP servers",
43
+ serverName: "<config>",
44
+ configPath,
45
+ evidence: "Expected an object at mcpServers or servers.",
46
+ recommendation: "Check whether this is the right file or update the config schema."
47
+ });
48
+ }
49
+ } catch (error) {
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ findings.push({
52
+ id: "MCP003",
53
+ severity: "high",
54
+ title: "Config file could not be parsed",
55
+ serverName: "<config>",
56
+ configPath,
57
+ evidence: message,
58
+ recommendation: "Fix the JSON syntax or remove the invalid config from the scan."
59
+ });
60
+ }
61
+ }
62
+
63
+ for (const server of servers) {
64
+ findings.push(...evaluateServer(server, { cwd, home }));
65
+ }
66
+
67
+ const sortedFindings = sortFindings(findings);
68
+ return {
69
+ metadata: {
70
+ generatedAt: new Date().toISOString(),
71
+ cwd,
72
+ home,
73
+ toolVersion
74
+ },
75
+ scannedFiles,
76
+ servers,
77
+ findings: sortedFindings,
78
+ summary: summarize(sortedFindings, servers, scannedFiles)
79
+ };
80
+ }
81
+
82
+ function summarize(findings, servers, scannedFiles) {
83
+ const counts = {
84
+ critical: 0,
85
+ high: 0,
86
+ medium: 0,
87
+ low: 0
88
+ };
89
+
90
+ for (const finding of findings) {
91
+ if (counts[finding.severity] != null) {
92
+ counts[finding.severity] += 1;
93
+ }
94
+ }
95
+
96
+ return {
97
+ scannedFileCount: scannedFiles.length,
98
+ serverCount: servers.length,
99
+ findingCount: findings.length,
100
+ counts,
101
+ riskScore: counts.critical * 20 + counts.high * 10 + counts.medium * 4 + counts.low
102
+ };
103
+ }
104
+
105
+ function uniquePaths(paths) {
106
+ return [...new Set(paths.map((item) => path.resolve(item)))];
107
+ }
@@ -0,0 +1,24 @@
1
+ export const SEVERITY = {
2
+ none: 0,
3
+ low: 1,
4
+ medium: 2,
5
+ high: 3,
6
+ critical: 4
7
+ };
8
+
9
+ export function severityRank(severity) {
10
+ return SEVERITY[severity] ?? 0;
11
+ }
12
+
13
+ export function compareSeverity(actual, thresholdRank) {
14
+ return severityRank(actual) - thresholdRank;
15
+ }
16
+
17
+ export function sortFindings(findings) {
18
+ return [...findings].sort((left, right) => {
19
+ const severityDiff = severityRank(right.severity) - severityRank(left.severity);
20
+ if (severityDiff !== 0) return severityDiff;
21
+ return left.id.localeCompare(right.id);
22
+ });
23
+ }
24
+