codex-plugin-doctor 1.22.0 → 1.24.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 +3 -3
- package/dist/core/validate-plugin.js +27 -7
- package/dist/domain/types.d.ts +3 -0
- package/dist/reporting/format-finding-evidence.d.ts +3 -0
- package/dist/reporting/format-finding-evidence.js +8 -0
- package/dist/reporting/render-markdown-report.js +5 -0
- package/dist/reporting/render-sarif-report.js +2 -1
- package/dist/reporting/render-text-report.js +5 -0
- package/dist/rules/rule-catalog.js +18 -0
- package/dist/security/security-audit.d.ts +3 -1
- package/dist/security/security-audit.js +150 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -314,7 +314,7 @@ codex-plugin-doctor check . --json --runtime --verbose-runtime
|
|
|
314
314
|
|
|
315
315
|
`init [path] --template ...` creates targeted starter packages. `skill-only` is the default minimal skill package, `mcp-stdio` adds a local stdio MCP config and mock server, `mcp-http` scaffolds a streamable HTTP MCP config, and `full-runtime` generates a stdio sample that passes the runtime protocol probes.
|
|
316
316
|
|
|
317
|
-
`security <path>` renders a focused package security scorecard. It reuses the existing package security findings, then adds deeper MCP command-surface checks for shell wrappers, encoded shell payloads, remote pipe-to-shell startup patterns, `cwd` values outside the plugin root, and plain HTTP URLs. Use `--json` for automation or `--scorecard` for a compact status view.
|
|
317
|
+
`security <path>` renders a focused package security scorecard. It reuses the existing package security findings, then adds deeper MCP command-surface checks for shell wrappers, encoded shell payloads, remote pipe-to-shell startup patterns, `cwd` values outside the plugin root, and plain HTTP URLs. Security findings include structured evidence where available, such as server names, config paths, env keys, arg flags, and escaped paths. Use `--json` for automation or `--scorecard` for a compact status view.
|
|
318
318
|
|
|
319
319
|
`compat --client claude-desktop` checks whether the MCP package can be added to the local Claude Desktop setup. On Windows it looks for `%APPDATA%\Claude\claude_desktop_config.json`; on macOS it looks for `~/Library/Application Support/Claude/claude_desktop_config.json`. A valid existing config returns `PASS`, a missing Claude Desktop install returns `WARN`, and a malformed local config returns `FAIL` so you do not add new servers into a broken config file. If the package server name already exists in Claude Desktop, the command returns `WARN` with the duplicate server name. Add `--install-preview` to print the JSON snippet that should be merged into `claude_desktop_config.json`; it does not modify files. Use `--apply --backup` only when you want the CLI to create a timestamped backup and merge the server config. Apply mode refuses to overwrite duplicate server names.
|
|
320
320
|
|
|
@@ -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.24.0
|
|
374
374
|
with:
|
|
375
|
-
version: "1.
|
|
375
|
+
version: "1.24.0"
|
|
376
376
|
path: .
|
|
377
377
|
runtime: "true"
|
|
378
378
|
policy: codex-publish
|
|
@@ -2,22 +2,24 @@ import { readFile, readdir, stat } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { discoverPackage } from "./discover-package.js";
|
|
4
4
|
import { probeRuntime } from "./runtime-probe.js";
|
|
5
|
-
function buildFailure(id, message, impact, suggestedFix) {
|
|
5
|
+
function buildFailure(id, message, impact, suggestedFix, evidence) {
|
|
6
6
|
return {
|
|
7
7
|
id,
|
|
8
8
|
severity: "fail",
|
|
9
9
|
message,
|
|
10
10
|
impact,
|
|
11
|
-
suggestedFix
|
|
11
|
+
suggestedFix,
|
|
12
|
+
...(evidence ? { evidence } : {})
|
|
12
13
|
};
|
|
13
14
|
}
|
|
14
|
-
function buildWarning(id, message, impact, suggestedFix) {
|
|
15
|
+
function buildWarning(id, message, impact, suggestedFix, evidence) {
|
|
15
16
|
return {
|
|
16
17
|
id,
|
|
17
18
|
severity: "warn",
|
|
18
19
|
message,
|
|
19
20
|
impact,
|
|
20
|
-
suggestedFix
|
|
21
|
+
suggestedFix,
|
|
22
|
+
...(evidence ? { evidence } : {})
|
|
21
23
|
};
|
|
22
24
|
}
|
|
23
25
|
async function directoryExists(targetPath) {
|
|
@@ -66,6 +68,9 @@ function isPathWithinRoot(rootPath, candidatePath) {
|
|
|
66
68
|
return (relativePath === "" ||
|
|
67
69
|
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
|
|
68
70
|
}
|
|
71
|
+
function relativePackagePath(rootPath, targetPath) {
|
|
72
|
+
return path.relative(rootPath, targetPath).replace(/\\/g, "/") || ".";
|
|
73
|
+
}
|
|
69
74
|
function isSensitiveKey(key) {
|
|
70
75
|
return /(token|secret|password|api[_-]?key|private[_-]?key)/i.test(key);
|
|
71
76
|
}
|
|
@@ -211,7 +216,12 @@ async function validateSkillsDirectory(discoveredPackage) {
|
|
|
211
216
|
const skillsPath = path.resolve(rootPath, manifest.skills);
|
|
212
217
|
if (!isPathWithinRoot(rootPath, skillsPath)) {
|
|
213
218
|
return [
|
|
214
|
-
buildFailure("plugin.security.path_traversal", "The plugin manifest points the skills path outside the package root.", "Paths that escape the package root make the package harder to audit and can expose unintended files during packaging or validation.", "Keep the `skills` path inside the plugin root."
|
|
219
|
+
buildFailure("plugin.security.path_traversal", "The plugin manifest points the skills path outside the package root.", "Paths that escape the package root make the package harder to audit and can expose unintended files during packaging or validation.", "Keep the `skills` path inside the plugin root.", {
|
|
220
|
+
manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
|
|
221
|
+
field: "skills",
|
|
222
|
+
configuredPath: manifest.skills,
|
|
223
|
+
resolvedPath: skillsPath
|
|
224
|
+
})
|
|
215
225
|
];
|
|
216
226
|
}
|
|
217
227
|
const exists = await directoryExists(skillsPath);
|
|
@@ -277,7 +287,12 @@ async function validateMcpConfig(discoveredPackage) {
|
|
|
277
287
|
const mcpConfigPath = path.resolve(rootPath, manifest.mcpServers);
|
|
278
288
|
if (!isPathWithinRoot(rootPath, mcpConfigPath)) {
|
|
279
289
|
return [
|
|
280
|
-
buildFailure("plugin.security.path_traversal", "The plugin manifest points the MCP config path outside the package root.", "Paths that escape the package root make the package harder to audit and can expose unintended files during packaging or validation.", "Keep the `mcpServers` path inside the plugin root."
|
|
290
|
+
buildFailure("plugin.security.path_traversal", "The plugin manifest points the MCP config path outside the package root.", "Paths that escape the package root make the package harder to audit and can expose unintended files during packaging or validation.", "Keep the `mcpServers` path inside the plugin root.", {
|
|
291
|
+
manifestPath: relativePackagePath(rootPath, discoveredPackage.manifestPath),
|
|
292
|
+
field: "mcpServers",
|
|
293
|
+
configuredPath: manifest.mcpServers,
|
|
294
|
+
resolvedPath: mcpConfigPath
|
|
295
|
+
})
|
|
281
296
|
];
|
|
282
297
|
}
|
|
283
298
|
const mcpConfigExists = await fileExists(mcpConfigPath);
|
|
@@ -323,7 +338,12 @@ async function validateMcpConfig(discoveredPackage) {
|
|
|
323
338
|
if (isSensitiveKey(envKey) &&
|
|
324
339
|
typeof envValue === "string" &&
|
|
325
340
|
looksLikeLiteralSecret(envValue)) {
|
|
326
|
-
findings.push(buildFailure("plugin.security.hard_coded_secret", `The MCP server \`${serverName}\` contains a hard-coded secret-like env value for \`${envKey}\`.`, "Hard-coded credentials inside plugin bundles increase leakage risk and make secure rotation difficult.", `Replace the literal value for \`${envKey}\` with an environment reference or injected secret outside the package
|
|
341
|
+
findings.push(buildFailure("plugin.security.hard_coded_secret", `The MCP server \`${serverName}\` contains a hard-coded secret-like env value for \`${envKey}\`.`, "Hard-coded credentials inside plugin bundles increase leakage risk and make secure rotation difficult.", `Replace the literal value for \`${envKey}\` with an environment reference or injected secret outside the package.`, {
|
|
342
|
+
serverName,
|
|
343
|
+
configPath: relativePackagePath(rootPath, mcpConfigPath),
|
|
344
|
+
envKey,
|
|
345
|
+
envValue: "[REDACTED]"
|
|
346
|
+
}));
|
|
327
347
|
}
|
|
328
348
|
}
|
|
329
349
|
}
|
package/dist/domain/types.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
export type FindingSeverity = "warn" | "fail";
|
|
2
|
+
export type FindingEvidenceValue = string | number | boolean | null;
|
|
3
|
+
export type FindingEvidence = Record<string, FindingEvidenceValue>;
|
|
2
4
|
export interface Finding {
|
|
3
5
|
id: string;
|
|
4
6
|
severity: FindingSeverity;
|
|
5
7
|
message: string;
|
|
6
8
|
impact: string;
|
|
7
9
|
suggestedFix: string;
|
|
10
|
+
evidence?: FindingEvidence;
|
|
8
11
|
}
|
|
9
12
|
export interface CheckResult {
|
|
10
13
|
targetPath: string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function formatFindingEvidence(evidence) {
|
|
2
|
+
return Object.entries(evidence)
|
|
3
|
+
.map(([key, value]) => `${key}=${String(value)}`)
|
|
4
|
+
.join(", ");
|
|
5
|
+
}
|
|
6
|
+
export function formatFindingEvidenceLine(finding) {
|
|
7
|
+
return finding.evidence ? formatFindingEvidence(finding.evidence) : null;
|
|
8
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatFindingEvidenceLine } from "./format-finding-evidence.js";
|
|
1
2
|
export function buildMarkdownReport(result, options) {
|
|
2
3
|
const failCount = result.findings.filter((finding) => finding.severity === "fail").length;
|
|
3
4
|
const warnCount = result.findings.filter((finding) => finding.severity === "warn").length;
|
|
@@ -38,6 +39,10 @@ export function buildMarkdownReport(result, options) {
|
|
|
38
39
|
lines.push(`- Message: ${finding.message}`);
|
|
39
40
|
lines.push(`- Impact: ${finding.impact}`);
|
|
40
41
|
lines.push(`- Suggested fix: ${finding.suggestedFix}`);
|
|
42
|
+
const evidence = formatFindingEvidenceLine(finding);
|
|
43
|
+
if (evidence) {
|
|
44
|
+
lines.push(`- Evidence: ${evidence}`);
|
|
45
|
+
}
|
|
41
46
|
lines.push("");
|
|
42
47
|
}
|
|
43
48
|
return lines.join("\n");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { findRuleDefinition } from "../rules/rule-catalog.js";
|
|
2
|
+
import { formatFindingEvidenceLine } from "./format-finding-evidence.js";
|
|
2
3
|
function getCounts(result) {
|
|
3
4
|
const failCount = result.findings.filter((finding) => finding.severity === "fail").length;
|
|
4
5
|
const warnCount = result.findings.filter((finding) => finding.severity === "warn").length;
|
|
@@ -60,6 +61,10 @@ export function renderTextReport(result, options = {}) {
|
|
|
60
61
|
lines.push(` Message: ${finding.message}`);
|
|
61
62
|
lines.push(` Impact: ${finding.impact}`);
|
|
62
63
|
lines.push(` Suggested fix: ${finding.suggestedFix}`);
|
|
64
|
+
const evidence = formatFindingEvidenceLine(finding);
|
|
65
|
+
if (evidence) {
|
|
66
|
+
lines.push(` Evidence: ${evidence}`);
|
|
67
|
+
}
|
|
63
68
|
if (explain) {
|
|
64
69
|
const rule = findRuleDefinition(finding.id);
|
|
65
70
|
if (rule) {
|
|
@@ -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",
|
|
@@ -10,7 +10,9 @@ export interface SecurityAudit {
|
|
|
10
10
|
};
|
|
11
11
|
findings: Finding[];
|
|
12
12
|
}
|
|
13
|
-
export declare function auditMcpServerConfig(rootPath: string, parsedConfig: unknown
|
|
13
|
+
export declare function auditMcpServerConfig(rootPath: string, parsedConfig: unknown, options?: {
|
|
14
|
+
configPath?: string;
|
|
15
|
+
}): Finding[];
|
|
14
16
|
export declare function buildSecurityAuditFromFindings(targetPath: string, findings: Finding[]): SecurityAudit;
|
|
15
17
|
export declare function buildSecurityAudit(targetPath: string): Promise<SecurityAudit>;
|
|
16
18
|
export declare function renderSecurityAuditJson(audit: SecurityAudit): string;
|
|
@@ -3,13 +3,15 @@ import path from "node:path";
|
|
|
3
3
|
import { discoverPackage } from "../core/discover-package.js";
|
|
4
4
|
import { readJsonFile } from "../core/read-json-file.js";
|
|
5
5
|
import { validatePlugin } from "../core/validate-plugin.js";
|
|
6
|
-
|
|
6
|
+
import { formatFindingEvidenceLine } from "../reporting/format-finding-evidence.js";
|
|
7
|
+
function buildFinding(severity, id, message, impact, suggestedFix, evidence) {
|
|
7
8
|
return {
|
|
8
9
|
id,
|
|
9
10
|
severity,
|
|
10
11
|
message,
|
|
11
12
|
impact,
|
|
12
|
-
suggestedFix
|
|
13
|
+
suggestedFix,
|
|
14
|
+
...(evidence ? { evidence } : {})
|
|
13
15
|
};
|
|
14
16
|
}
|
|
15
17
|
function isPlainObject(value) {
|
|
@@ -20,6 +22,9 @@ function isPathWithinRoot(rootPath, candidatePath) {
|
|
|
20
22
|
return (relativePath === "" ||
|
|
21
23
|
(!relativePath.startsWith("..") && !path.isAbsolute(relativePath)));
|
|
22
24
|
}
|
|
25
|
+
function relativePackagePath(rootPath, targetPath) {
|
|
26
|
+
return path.relative(rootPath, targetPath).replace(/\\/g, "/") || ".";
|
|
27
|
+
}
|
|
23
28
|
function normalizeCommandName(command) {
|
|
24
29
|
return path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat)$/i, "");
|
|
25
30
|
}
|
|
@@ -41,6 +46,104 @@ function containsPipeInstaller(args) {
|
|
|
41
46
|
/\b(iwr|irm|invoke-webrequest|invoke-restmethod)\b[^|]*\|\s*(iex|invoke-expression)\b/.test(joinedArgs) ||
|
|
42
47
|
/\binvoke-expression\b/.test(joinedArgs));
|
|
43
48
|
}
|
|
49
|
+
const pathLikeArgFlags = new Set([
|
|
50
|
+
"--config",
|
|
51
|
+
"--config-path",
|
|
52
|
+
"--cwd",
|
|
53
|
+
"--dir",
|
|
54
|
+
"--directory",
|
|
55
|
+
"--file",
|
|
56
|
+
"--import",
|
|
57
|
+
"--loader",
|
|
58
|
+
"--path",
|
|
59
|
+
"--project",
|
|
60
|
+
"--require",
|
|
61
|
+
"--root",
|
|
62
|
+
"--script",
|
|
63
|
+
"--tsconfig",
|
|
64
|
+
"--workspace"
|
|
65
|
+
]);
|
|
66
|
+
const codeLoadingEnvKeys = new Set([
|
|
67
|
+
"LD_PRELOAD",
|
|
68
|
+
"DYLD_INSERT_LIBRARIES"
|
|
69
|
+
]);
|
|
70
|
+
const modulePathEnvKeys = new Set([
|
|
71
|
+
"NODE_PATH",
|
|
72
|
+
"PYTHONPATH",
|
|
73
|
+
"RUBYLIB"
|
|
74
|
+
]);
|
|
75
|
+
function looksLikeEnvReference(value) {
|
|
76
|
+
return /^\$\{?[A-Z0-9_]+\}?$/i.test(value.trim());
|
|
77
|
+
}
|
|
78
|
+
function looksLikePathValue(value) {
|
|
79
|
+
const trimmed = value.trim();
|
|
80
|
+
return (trimmed.includes("..") ||
|
|
81
|
+
trimmed.includes("/") ||
|
|
82
|
+
trimmed.includes("\\") ||
|
|
83
|
+
trimmed.startsWith(".") ||
|
|
84
|
+
trimmed.startsWith("~") ||
|
|
85
|
+
path.isAbsolute(trimmed));
|
|
86
|
+
}
|
|
87
|
+
function isEscapingPathValue(rootPath, value) {
|
|
88
|
+
const trimmed = value.trim();
|
|
89
|
+
if (!trimmed ||
|
|
90
|
+
looksLikeEnvReference(trimmed) ||
|
|
91
|
+
/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ||
|
|
92
|
+
!looksLikePathValue(trimmed)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return !isPathWithinRoot(rootPath, path.resolve(rootPath, trimmed));
|
|
96
|
+
}
|
|
97
|
+
function collectEscapingPathArgs(rootPath, args) {
|
|
98
|
+
if (!Array.isArray(args)) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const stringArgs = args.filter((arg) => typeof arg === "string");
|
|
102
|
+
const findings = [];
|
|
103
|
+
for (let index = 0; index < stringArgs.length; index += 1) {
|
|
104
|
+
const arg = stringArgs[index];
|
|
105
|
+
const equalsIndex = arg.indexOf("=");
|
|
106
|
+
if (equalsIndex > 0) {
|
|
107
|
+
const flag = arg.slice(0, equalsIndex).toLowerCase();
|
|
108
|
+
const value = arg.slice(equalsIndex + 1);
|
|
109
|
+
if (pathLikeArgFlags.has(flag) && isEscapingPathValue(rootPath, value)) {
|
|
110
|
+
findings.push({ flag, value, resolvedPath: path.resolve(rootPath, value) });
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const flag = arg.toLowerCase();
|
|
115
|
+
const value = stringArgs[index + 1];
|
|
116
|
+
if (pathLikeArgFlags.has(flag) && value && isEscapingPathValue(rootPath, value)) {
|
|
117
|
+
findings.push({ flag, value, resolvedPath: path.resolve(rootPath, value) });
|
|
118
|
+
index += 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return findings;
|
|
122
|
+
}
|
|
123
|
+
function splitModulePathEnvValue(value) {
|
|
124
|
+
const trimmed = value.trim();
|
|
125
|
+
if (/^[a-z]:[\\/]/i.test(trimmed)) {
|
|
126
|
+
return trimmed.includes(";") ? trimmed.split(";") : [trimmed];
|
|
127
|
+
}
|
|
128
|
+
return trimmed.includes(";") ? trimmed.split(";") : trimmed.split(":");
|
|
129
|
+
}
|
|
130
|
+
function hasEscapingModulePath(rootPath, value) {
|
|
131
|
+
return splitModulePathEnvValue(value).some((entry) => isEscapingPathValue(rootPath, entry));
|
|
132
|
+
}
|
|
133
|
+
function isDangerousEnvUsage(rootPath, envKey, envValue) {
|
|
134
|
+
if (typeof envValue !== "string" || looksLikeEnvReference(envValue)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
const normalizedKey = envKey.toUpperCase();
|
|
138
|
+
if (codeLoadingEnvKeys.has(normalizedKey)) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
if (normalizedKey === "NODE_OPTIONS" &&
|
|
142
|
+
/(?:^|\s)--(?:require|import|loader|experimental-loader)(?:=|\s+)/i.test(envValue)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
return modulePathEnvKeys.has(normalizedKey) && hasEscapingModulePath(rootPath, envValue);
|
|
146
|
+
}
|
|
44
147
|
const poisonScanExtensions = new Set([
|
|
45
148
|
".json",
|
|
46
149
|
".md",
|
|
@@ -60,10 +163,13 @@ const promptInjectionPatterns = [
|
|
|
60
163
|
/\b(?:exfiltrate|steal|leak|upload|send)\b.{0,120}\b(?:secret|secrets|token|tokens|api\s*key|api\s*keys|credential|credentials|environment\s+variables?|env)\b/i,
|
|
61
164
|
/\bdo\s+not\s+(?:reveal|tell|mention|disclose)\b.{0,120}\b(?:instruction|instructions|prompt|prompts|system|developer)\b/i
|
|
62
165
|
];
|
|
63
|
-
export function auditMcpServerConfig(rootPath, parsedConfig) {
|
|
166
|
+
export function auditMcpServerConfig(rootPath, parsedConfig, options = {}) {
|
|
167
|
+
const configPath = options.configPath
|
|
168
|
+
? relativePackagePath(rootPath, options.configPath)
|
|
169
|
+
: ".mcp.json";
|
|
64
170
|
if (!isPlainObject(parsedConfig) || !isPlainObject(parsedConfig.mcpServers)) {
|
|
65
171
|
return [
|
|
66
|
-
buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not find a valid `mcpServers` object.", "Without server entries, the audit cannot evaluate command execution or remote transport risk.", "Define MCP servers under a top-level `mcpServers` object.")
|
|
172
|
+
buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not find a valid `mcpServers` object.", "Without server entries, the audit cannot evaluate command execution or remote transport risk.", "Define MCP servers under a top-level `mcpServers` object.", { configPath })
|
|
67
173
|
];
|
|
68
174
|
}
|
|
69
175
|
const findings = [];
|
|
@@ -76,22 +182,49 @@ export function auditMcpServerConfig(rootPath, parsedConfig) {
|
|
|
76
182
|
const cwd = serverConfig.cwd;
|
|
77
183
|
const url = serverConfig.url;
|
|
78
184
|
if (typeof command === "string" && isShellWrapperCommand(command)) {
|
|
79
|
-
findings.push(buildFinding("warn", "plugin.security.command_shell_wrapper", `The MCP server \`${serverName}\` starts through shell wrapper \`${command}\`.`, "Shell wrappers expand quoting, pipes, aliases, and platform-specific behavior, which makes the real execution path harder to audit.", "Prefer launching the concrete executable directly with explicit args."));
|
|
185
|
+
findings.push(buildFinding("warn", "plugin.security.command_shell_wrapper", `The MCP server \`${serverName}\` starts through shell wrapper \`${command}\`.`, "Shell wrappers expand quoting, pipes, aliases, and platform-specific behavior, which makes the real execution path harder to audit.", "Prefer launching the concrete executable directly with explicit args.", { serverName, configPath, command }));
|
|
80
186
|
}
|
|
81
187
|
if (containsEncodedCommandFlag(args)) {
|
|
82
|
-
findings.push(buildFinding("fail", "plugin.security.encoded_command", `The MCP server \`${serverName}\` uses an encoded shell command flag.`, "Encoded command payloads hide the executed script from reviewers and increase supply-chain risk.", "Replace encoded shell payloads with a checked-in script or direct executable plus readable args."));
|
|
188
|
+
findings.push(buildFinding("fail", "plugin.security.encoded_command", `The MCP server \`${serverName}\` uses an encoded shell command flag.`, "Encoded command payloads hide the executed script from reviewers and increase supply-chain risk.", "Replace encoded shell payloads with a checked-in script or direct executable plus readable args.", { serverName, configPath, command: typeof command === "string" ? command : null }));
|
|
83
189
|
}
|
|
84
190
|
if (containsPipeInstaller(args)) {
|
|
85
|
-
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."));
|
|
191
|
+
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.", { serverName, configPath, command: typeof command === "string" ? command : null }));
|
|
192
|
+
}
|
|
193
|
+
for (const riskyArg of collectEscapingPathArgs(rootPath, args)) {
|
|
194
|
+
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.", {
|
|
195
|
+
serverName,
|
|
196
|
+
configPath,
|
|
197
|
+
argFlag: riskyArg.flag,
|
|
198
|
+
argValue: riskyArg.value,
|
|
199
|
+
resolvedPath: riskyArg.resolvedPath
|
|
200
|
+
}));
|
|
86
201
|
}
|
|
87
202
|
if (typeof cwd === "string") {
|
|
88
203
|
const cwdPath = path.resolve(rootPath, cwd);
|
|
89
204
|
if (!isPathWithinRoot(rootPath, cwdPath)) {
|
|
90
|
-
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."
|
|
205
|
+
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.", {
|
|
206
|
+
serverName,
|
|
207
|
+
configPath,
|
|
208
|
+
cwd,
|
|
209
|
+
resolvedPath: cwdPath
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (isPlainObject(serverConfig.env)) {
|
|
214
|
+
for (const [envKey, envValue] of Object.entries(serverConfig.env)) {
|
|
215
|
+
if (!isDangerousEnvUsage(rootPath, envKey, envValue)) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
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.", {
|
|
219
|
+
serverName,
|
|
220
|
+
configPath,
|
|
221
|
+
envKey,
|
|
222
|
+
envValue: typeof envValue === "string" ? envValue : null
|
|
223
|
+
}));
|
|
91
224
|
}
|
|
92
225
|
}
|
|
93
226
|
if (typeof url === "string" && /^http:\/\//i.test(url)) {
|
|
94
|
-
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."));
|
|
227
|
+
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.", { serverName, configPath, url }));
|
|
95
228
|
}
|
|
96
229
|
}
|
|
97
230
|
return findings;
|
|
@@ -129,7 +262,7 @@ async function auditPromptPoisoningSurface(rootPath) {
|
|
|
129
262
|
continue;
|
|
130
263
|
}
|
|
131
264
|
const relativeFilePath = path.relative(rootPath, filePath).replace(/\\/g, "/");
|
|
132
|
-
findings.push(buildFinding("fail", "plugin.security.prompt_injection_text", `The packaged text file \`${relativeFilePath}\` contains prompt-injection or secret-exfiltration style instructions.`, "Malicious or poisoned tool, prompt, resource, or skill text can instruct an agent to ignore higher-priority instructions or leak secrets when loaded into context.", "Remove hidden override or exfiltration instructions, then keep tool/prompt/resource descriptions scoped to the legitimate user-facing behavior."));
|
|
265
|
+
findings.push(buildFinding("fail", "plugin.security.prompt_injection_text", `The packaged text file \`${relativeFilePath}\` contains prompt-injection or secret-exfiltration style instructions.`, "Malicious or poisoned tool, prompt, resource, or skill text can instruct an agent to ignore higher-priority instructions or leak secrets when loaded into context.", "Remove hidden override or exfiltration instructions, then keep tool/prompt/resource descriptions scoped to the legitimate user-facing behavior.", { filePath: relativeFilePath }));
|
|
133
266
|
}
|
|
134
267
|
return findings;
|
|
135
268
|
}
|
|
@@ -148,10 +281,10 @@ async function auditMcpCommandSurface(discoveredPackage) {
|
|
|
148
281
|
}
|
|
149
282
|
catch {
|
|
150
283
|
return [
|
|
151
|
-
buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not parse the referenced MCP config.", "Unreadable MCP configuration prevents review of server commands, URLs, and working directories before install.", "Fix the `.mcp.json` syntax, then rerun `codex-plugin-doctor security <path>`.")
|
|
284
|
+
buildFinding("fail", "plugin.security.audit_unavailable", "The MCP security audit could not parse the referenced MCP config.", "Unreadable MCP configuration prevents review of server commands, URLs, and working directories before install.", "Fix the `.mcp.json` syntax, then rerun `codex-plugin-doctor security <path>`.", { configPath: relativePackagePath(rootPath, mcpConfigPath) })
|
|
152
285
|
];
|
|
153
286
|
}
|
|
154
|
-
return auditMcpServerConfig(rootPath, parsedConfig);
|
|
287
|
+
return auditMcpServerConfig(rootPath, parsedConfig, { configPath: mcpConfigPath });
|
|
155
288
|
}
|
|
156
289
|
function dedupeFindings(findings) {
|
|
157
290
|
const seen = new Set();
|
|
@@ -196,7 +329,7 @@ export async function buildSecurityAudit(targetPath) {
|
|
|
196
329
|
const discoveredPackage = await discoverPackage(targetPath);
|
|
197
330
|
if (!discoveredPackage) {
|
|
198
331
|
const findings = [
|
|
199
|
-
buildFinding("fail", "plugin.security.audit_unavailable", "The target directory is missing `.codex-plugin/plugin.json`, so the package security audit cannot run.", "Without a Codex plugin manifest, the audit cannot resolve packaged skills or MCP server configuration safely.", "Run the audit against a Codex plugin package root.")
|
|
332
|
+
buildFinding("fail", "plugin.security.audit_unavailable", "The target directory is missing `.codex-plugin/plugin.json`, so the package security audit cannot run.", "Without a Codex plugin manifest, the audit cannot resolve packaged skills or MCP server configuration safely.", "Run the audit against a Codex plugin package root.", { targetPath: path.resolve(targetPath) })
|
|
200
333
|
];
|
|
201
334
|
const findingCounts = buildFindingCounts(findings);
|
|
202
335
|
return {
|
|
@@ -249,6 +382,10 @@ export function renderSecurityScorecard(audit, options = {}) {
|
|
|
249
382
|
lines.push(` Message: ${finding.message}`);
|
|
250
383
|
lines.push(` Impact: ${finding.impact}`);
|
|
251
384
|
lines.push(` Suggested fix: ${finding.suggestedFix}`);
|
|
385
|
+
const evidence = formatFindingEvidenceLine(finding);
|
|
386
|
+
if (evidence) {
|
|
387
|
+
lines.push(` Evidence: ${evidence}`);
|
|
388
|
+
}
|
|
252
389
|
}
|
|
253
390
|
};
|
|
254
391
|
appendSection("Failures", audit.findings.filter((finding) => finding.severity === "fail"), "x");
|
package/package.json
CHANGED