codex-plugin-doctor 1.22.0 → 1.23.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 +2 -2
- package/dist/rules/rule-catalog.js +18 -0
- package/dist/security/security-audit.js +109 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -370,9 +370,9 @@ jobs:
|
|
|
370
370
|
runs-on: ubuntu-latest
|
|
371
371
|
steps:
|
|
372
372
|
- uses: actions/checkout@v5
|
|
373
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
373
|
+
- uses: Esquetta/CodexPluginDoctor@v1.23.0
|
|
374
374
|
with:
|
|
375
|
-
version: "1.
|
|
375
|
+
version: "1.23.0"
|
|
376
376
|
path: .
|
|
377
377
|
runtime: "true"
|
|
378
378
|
policy: codex-publish
|
|
@@ -197,6 +197,24 @@ export const ruleCatalog = [
|
|
|
197
197
|
fix: "Pin dependencies through a package manager or check in a reviewed setup script.",
|
|
198
198
|
example: '{ "command": "npx", "args": ["-y", "@scope/server"] }'
|
|
199
199
|
},
|
|
200
|
+
{
|
|
201
|
+
id: "plugin.security.path_traversal_risk",
|
|
202
|
+
category: "security",
|
|
203
|
+
defaultSeverity: "fail",
|
|
204
|
+
summary: "An MCP server passes a package-external path to a path-like runtime argument.",
|
|
205
|
+
why: "Runtime file arguments outside the plugin root can load code or configuration that was not included in the reviewed package.",
|
|
206
|
+
fix: "Keep runtime file arguments inside the plugin package root, or package the referenced file with the plugin.",
|
|
207
|
+
example: '{ "command": "node", "args": ["server.js", "--config", "config/server.json"] }'
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "plugin.security.dangerous_env_usage",
|
|
211
|
+
category: "security",
|
|
212
|
+
defaultSeverity: "fail",
|
|
213
|
+
summary: "An MCP server sets an environment variable that can alter code loading.",
|
|
214
|
+
why: "Variables such as NODE_OPTIONS, NODE_PATH, PYTHONPATH, LD_PRELOAD, or DYLD_INSERT_LIBRARIES can inject imports, preload native libraries, or load modules outside the reviewed package.",
|
|
215
|
+
fix: "Remove code-loading environment overrides, or keep referenced modules and preload files inside the reviewed plugin package.",
|
|
216
|
+
example: '{ "env": { "OPENAI_API_KEY": "${OPENAI_API_KEY}" } }'
|
|
217
|
+
},
|
|
200
218
|
{
|
|
201
219
|
id: "plugin.security.cwd_outside_root",
|
|
202
220
|
category: "security",
|
|
@@ -41,6 +41,104 @@ function containsPipeInstaller(args) {
|
|
|
41
41
|
/\b(iwr|irm|invoke-webrequest|invoke-restmethod)\b[^|]*\|\s*(iex|invoke-expression)\b/.test(joinedArgs) ||
|
|
42
42
|
/\binvoke-expression\b/.test(joinedArgs));
|
|
43
43
|
}
|
|
44
|
+
const pathLikeArgFlags = new Set([
|
|
45
|
+
"--config",
|
|
46
|
+
"--config-path",
|
|
47
|
+
"--cwd",
|
|
48
|
+
"--dir",
|
|
49
|
+
"--directory",
|
|
50
|
+
"--file",
|
|
51
|
+
"--import",
|
|
52
|
+
"--loader",
|
|
53
|
+
"--path",
|
|
54
|
+
"--project",
|
|
55
|
+
"--require",
|
|
56
|
+
"--root",
|
|
57
|
+
"--script",
|
|
58
|
+
"--tsconfig",
|
|
59
|
+
"--workspace"
|
|
60
|
+
]);
|
|
61
|
+
const codeLoadingEnvKeys = new Set([
|
|
62
|
+
"LD_PRELOAD",
|
|
63
|
+
"DYLD_INSERT_LIBRARIES"
|
|
64
|
+
]);
|
|
65
|
+
const modulePathEnvKeys = new Set([
|
|
66
|
+
"NODE_PATH",
|
|
67
|
+
"PYTHONPATH",
|
|
68
|
+
"RUBYLIB"
|
|
69
|
+
]);
|
|
70
|
+
function looksLikeEnvReference(value) {
|
|
71
|
+
return /^\$\{?[A-Z0-9_]+\}?$/i.test(value.trim());
|
|
72
|
+
}
|
|
73
|
+
function looksLikePathValue(value) {
|
|
74
|
+
const trimmed = value.trim();
|
|
75
|
+
return (trimmed.includes("..") ||
|
|
76
|
+
trimmed.includes("/") ||
|
|
77
|
+
trimmed.includes("\\") ||
|
|
78
|
+
trimmed.startsWith(".") ||
|
|
79
|
+
trimmed.startsWith("~") ||
|
|
80
|
+
path.isAbsolute(trimmed));
|
|
81
|
+
}
|
|
82
|
+
function isEscapingPathValue(rootPath, value) {
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
if (!trimmed ||
|
|
85
|
+
looksLikeEnvReference(trimmed) ||
|
|
86
|
+
/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ||
|
|
87
|
+
!looksLikePathValue(trimmed)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return !isPathWithinRoot(rootPath, path.resolve(rootPath, trimmed));
|
|
91
|
+
}
|
|
92
|
+
function collectEscapingPathArgs(rootPath, args) {
|
|
93
|
+
if (!Array.isArray(args)) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const stringArgs = args.filter((arg) => typeof arg === "string");
|
|
97
|
+
const findings = [];
|
|
98
|
+
for (let index = 0; index < stringArgs.length; index += 1) {
|
|
99
|
+
const arg = stringArgs[index];
|
|
100
|
+
const equalsIndex = arg.indexOf("=");
|
|
101
|
+
if (equalsIndex > 0) {
|
|
102
|
+
const flag = arg.slice(0, equalsIndex).toLowerCase();
|
|
103
|
+
const value = arg.slice(equalsIndex + 1);
|
|
104
|
+
if (pathLikeArgFlags.has(flag) && isEscapingPathValue(rootPath, value)) {
|
|
105
|
+
findings.push({ flag, value });
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const flag = arg.toLowerCase();
|
|
110
|
+
const value = stringArgs[index + 1];
|
|
111
|
+
if (pathLikeArgFlags.has(flag) && value && isEscapingPathValue(rootPath, value)) {
|
|
112
|
+
findings.push({ flag, value });
|
|
113
|
+
index += 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
|
118
|
+
function splitModulePathEnvValue(value) {
|
|
119
|
+
const trimmed = value.trim();
|
|
120
|
+
if (/^[a-z]:[\\/]/i.test(trimmed)) {
|
|
121
|
+
return trimmed.includes(";") ? trimmed.split(";") : [trimmed];
|
|
122
|
+
}
|
|
123
|
+
return trimmed.includes(";") ? trimmed.split(";") : trimmed.split(":");
|
|
124
|
+
}
|
|
125
|
+
function hasEscapingModulePath(rootPath, value) {
|
|
126
|
+
return splitModulePathEnvValue(value).some((entry) => isEscapingPathValue(rootPath, entry));
|
|
127
|
+
}
|
|
128
|
+
function isDangerousEnvUsage(rootPath, envKey, envValue) {
|
|
129
|
+
if (typeof envValue !== "string" || looksLikeEnvReference(envValue)) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const normalizedKey = envKey.toUpperCase();
|
|
133
|
+
if (codeLoadingEnvKeys.has(normalizedKey)) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (normalizedKey === "NODE_OPTIONS" &&
|
|
137
|
+
/(?:^|\s)--(?:require|import|loader|experimental-loader)(?:=|\s+)/i.test(envValue)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
return modulePathEnvKeys.has(normalizedKey) && hasEscapingModulePath(rootPath, envValue);
|
|
141
|
+
}
|
|
44
142
|
const poisonScanExtensions = new Set([
|
|
45
143
|
".json",
|
|
46
144
|
".md",
|
|
@@ -84,12 +182,23 @@ export function auditMcpServerConfig(rootPath, parsedConfig) {
|
|
|
84
182
|
if (containsPipeInstaller(args)) {
|
|
85
183
|
findings.push(buildFinding("fail", "plugin.security.remote_pipe_install", `The MCP server \`${serverName}\` appears to pipe remote content into a shell.`, "Download-and-execute install patterns can run unreviewed remote code during plugin startup.", "Pin dependencies through the package manager or check in a reviewed setup script instead of piping remote content to a shell."));
|
|
86
184
|
}
|
|
185
|
+
for (const riskyArg of collectEscapingPathArgs(rootPath, args)) {
|
|
186
|
+
findings.push(buildFinding("fail", "plugin.security.path_traversal_risk", `The MCP server \`${serverName}\` passes \`${riskyArg.value}\` to path-like arg \`${riskyArg.flag}\`, which escapes the plugin root.`, "Path-like runtime arguments that point outside the package can make startup depend on unreviewed local files or load code outside the reviewed tarball.", "Keep runtime file arguments inside the plugin package root, or package the referenced file with the plugin."));
|
|
187
|
+
}
|
|
87
188
|
if (typeof cwd === "string") {
|
|
88
189
|
const cwdPath = path.resolve(rootPath, cwd);
|
|
89
190
|
if (!isPathWithinRoot(rootPath, cwdPath)) {
|
|
90
191
|
findings.push(buildFinding("fail", "plugin.security.cwd_outside_root", `The MCP server \`${serverName}\` sets cwd outside the plugin root.`, "A working directory outside the package root can make server startup depend on unreviewed local files.", "Keep MCP server `cwd` inside the plugin package root or remove it."));
|
|
91
192
|
}
|
|
92
193
|
}
|
|
194
|
+
if (isPlainObject(serverConfig.env)) {
|
|
195
|
+
for (const [envKey, envValue] of Object.entries(serverConfig.env)) {
|
|
196
|
+
if (!isDangerousEnvUsage(rootPath, envKey, envValue)) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
findings.push(buildFinding("fail", "plugin.security.dangerous_env_usage", `The MCP server \`${serverName}\` sets dangerous code-loading env variable \`${envKey}\`.`, "Environment variables that alter module lookup, preload native libraries, or inject runtime imports can execute code outside the reviewed package.", "Remove code-loading environment overrides, or keep referenced modules and preload files inside the reviewed plugin package."));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
93
202
|
if (typeof url === "string" && /^http:\/\//i.test(url)) {
|
|
94
203
|
findings.push(buildFinding("warn", "plugin.security.insecure_http_url", `The MCP server \`${serverName}\` uses an insecure HTTP URL.`, "Plain HTTP transports can expose MCP traffic on non-local networks and make endpoint identity harder to verify.", "Use HTTPS for remote MCP servers; reserve HTTP for explicit localhost development endpoints."));
|
|
95
204
|
}
|
package/package.json
CHANGED