coding-agent-audit 0.9.0__tar.gz → 0.11.0__tar.gz

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.
Files changed (24) hide show
  1. {coding_agent_audit-0.9.0/src/coding_agent_audit.egg-info → coding_agent_audit-0.11.0}/PKG-INFO +21 -1
  2. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/README.md +20 -0
  3. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/pyproject.toml +1 -1
  4. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/__init__.py +1 -1
  5. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/cli.py +19 -10
  6. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/configscan.py +185 -0
  7. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/mcp_server.py +25 -1
  8. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0/src/coding_agent_audit.egg-info}/PKG-INFO +21 -1
  9. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/tests/test_configscan.py +152 -0
  10. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/LICENSE +0 -0
  11. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/setup.cfg +0 -0
  12. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/checks.py +0 -0
  13. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/findings.py +0 -0
  14. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/iocs.py +0 -0
  15. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/phantomdeps.py +0 -0
  16. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/SOURCES.txt +0 -0
  17. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/dependency_links.txt +0 -0
  18. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/entry_points.txt +0 -0
  19. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/requires.txt +0 -0
  20. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/top_level.txt +0 -0
  21. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/tests/test_checks.py +0 -0
  22. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/tests/test_cli.py +0 -0
  23. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/tests/test_mcp_server.py +0 -0
  24. {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/tests/test_phantomdeps.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-agent-audit
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Scan your machine for known AI-coding-assistant supply-chain backdoor indicators (Claude Code / Cursor / Windsurf extensions, MCP servers) plus risky hook/permission config patterns. Usable as a CLI or as an MCP server.
5
5
  Author-email: StackScribe AI <survivalagent320@gmail.com>
6
6
  License: MIT
@@ -134,6 +134,16 @@ second look rather than proof of compromise:
134
134
  `authorized_keys`, use netcat in reverse-shell mode, etc.
135
135
  - MCP server definitions that run an unpinned package (`npx -y pkg` with
136
136
  no version pin)
137
+ - MCP server definitions explicitly pinned to a package version with a
138
+ disclosed, fixed RCE CVE (added in v0.10.0): `mcp-remote` below v0.1.16
139
+ (**CVE-2025-6514**, CVSS 9.6 — a malicious remote server's OAuth
140
+ `authorization_endpoint` response is passed unsanitized to the OS,
141
+ arbitrary command execution on just opening the client) and
142
+ `mcp-server-git` below v2025.12.18 (**CVE-2025-68143/68144/68145** —
143
+ Anthropic's own reference git MCP server, chainable via indirect prompt
144
+ injection into full RCE). Only an explicit old pin is flagged — an
145
+ unpinned install picks up the current, patched release and is already
146
+ covered by the generic unpinned-package finding above
137
147
  - Auto-approved permission rules that are unscoped wildcards (e.g.
138
148
  `Bash(*)`)
139
149
  - The "lethal trifecta" pattern: an untrusted-content-fetching tool
@@ -173,6 +183,16 @@ next `npm install` / `pip install` ("slopsquatting"). Network errors are
173
183
  treated as inconclusive, not as a finding, so a flaky connection can't
174
184
  produce a false positive.
175
185
 
186
+ **MCP phantom-package scan** (`scan_mcp_phantom_packages`, also opt-in via
187
+ `--check-deps`) — the same registry-lookup idea, applied to the MCP server
188
+ commands themselves: any `npx`/`uvx`/`pipx run` invocation in a scanned
189
+ config is checked against npm/PyPI. This is the "MCPhantom" pattern flagged
190
+ by Glow Labs in November 2026: a coding agent fills in a plausible but
191
+ nonexistent MCP server package name from training data (or copies one from
192
+ a long-archived tutorial whose package was since renamed/unpublished), and
193
+ nothing stops an attacker from registering that exact name and having it
194
+ run as the MCP server next time the config loads.
195
+
176
196
  New incidents can be added to `iocs.py` as data, without touching the
177
197
  check logic.
178
198
 
@@ -113,6 +113,16 @@ second look rather than proof of compromise:
113
113
  `authorized_keys`, use netcat in reverse-shell mode, etc.
114
114
  - MCP server definitions that run an unpinned package (`npx -y pkg` with
115
115
  no version pin)
116
+ - MCP server definitions explicitly pinned to a package version with a
117
+ disclosed, fixed RCE CVE (added in v0.10.0): `mcp-remote` below v0.1.16
118
+ (**CVE-2025-6514**, CVSS 9.6 — a malicious remote server's OAuth
119
+ `authorization_endpoint` response is passed unsanitized to the OS,
120
+ arbitrary command execution on just opening the client) and
121
+ `mcp-server-git` below v2025.12.18 (**CVE-2025-68143/68144/68145** —
122
+ Anthropic's own reference git MCP server, chainable via indirect prompt
123
+ injection into full RCE). Only an explicit old pin is flagged — an
124
+ unpinned install picks up the current, patched release and is already
125
+ covered by the generic unpinned-package finding above
116
126
  - Auto-approved permission rules that are unscoped wildcards (e.g.
117
127
  `Bash(*)`)
118
128
  - The "lethal trifecta" pattern: an untrusted-content-fetching tool
@@ -152,6 +162,16 @@ next `npm install` / `pip install` ("slopsquatting"). Network errors are
152
162
  treated as inconclusive, not as a finding, so a flaky connection can't
153
163
  produce a false positive.
154
164
 
165
+ **MCP phantom-package scan** (`scan_mcp_phantom_packages`, also opt-in via
166
+ `--check-deps`) — the same registry-lookup idea, applied to the MCP server
167
+ commands themselves: any `npx`/`uvx`/`pipx run` invocation in a scanned
168
+ config is checked against npm/PyPI. This is the "MCPhantom" pattern flagged
169
+ by Glow Labs in November 2026: a coding agent fills in a plausible but
170
+ nonexistent MCP server package name from training data (or copies one from
171
+ a long-archived tutorial whose package was since renamed/unpublished), and
172
+ nothing stops an attacker from registering that exact name and having it
173
+ run as the MCP server next time the config loads.
174
+
155
175
  New incidents can be added to `iocs.py` as data, without touching the
156
176
  check logic.
157
177
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "coding-agent-audit"
7
- version = "0.9.0"
7
+ version = "0.11.0"
8
8
  description = "Scan your machine for known AI-coding-assistant supply-chain backdoor indicators (Claude Code / Cursor / Windsurf extensions, MCP servers) plus risky hook/permission config patterns. Usable as a CLI or as an MCP server."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -3,4 +3,4 @@ for AI coding assistants (Claude Code, Cursor, Windsurf, VS Code forks, MCP
3
3
  servers).
4
4
  """
5
5
 
6
- __version__ = "0.9.0"
6
+ __version__ = "0.11.0"
@@ -7,7 +7,12 @@ from pathlib import Path
7
7
 
8
8
  from . import __version__
9
9
  from .checks import run_all_ioc_checks
10
- from .configscan import default_config_paths, scan_paths, scan_tool_descriptions
10
+ from .configscan import (
11
+ default_config_paths,
12
+ scan_paths,
13
+ scan_paths_for_phantom_mcp_packages,
14
+ scan_tool_descriptions,
15
+ )
11
16
  from .findings import Finding, worst_severity
12
17
  from .phantomdeps import check_phantom_dependencies
13
18
 
@@ -65,10 +70,12 @@ def build_parser() -> argparse.ArgumentParser:
65
70
  "--check-deps",
66
71
  action="store_true",
67
72
  help=(
68
- "with --project, also check package.json/requirements.txt/"
69
- "pyproject.toml dependencies against npm/PyPI and flag any "
70
- "that don't exist (possible AI-hallucinated/slopsquattable "
71
- "package name). Makes read-only network requests."
73
+ "check package names against npm/PyPI and flag any that don't "
74
+ "exist (possible AI-hallucinated/slopsquattable or 'MCPhantom' "
75
+ "package name): with --project, also covers package.json/"
76
+ "requirements.txt/pyproject.toml; always covers npx/uvx/pipx "
77
+ "MCP server commands in the scanned config files. Makes "
78
+ "read-only network requests."
72
79
  ),
73
80
  )
74
81
  parser.add_argument(
@@ -95,17 +102,19 @@ def main(argv: list[str] | None = None) -> int:
95
102
  parser = build_parser()
96
103
  args = parser.parse_args(argv)
97
104
 
105
+ config_paths = default_config_paths(Path.home(), args.project)
106
+
98
107
  findings: list[Finding] = []
99
108
  if not args.skip_ioc:
100
109
  project_dirs = [args.project] if args.project is not None else []
101
110
  findings += run_all_ioc_checks(project_dirs=project_dirs)
102
111
  if not args.skip_config:
103
- home = Path.home()
104
- paths = default_config_paths(home, args.project)
105
- findings += scan_paths(paths)
112
+ findings += scan_paths(config_paths)
106
113
 
107
- if args.check_deps and args.project is not None:
108
- findings += check_phantom_dependencies(args.project)
114
+ if args.check_deps:
115
+ if args.project is not None:
116
+ findings += check_phantom_dependencies(args.project)
117
+ findings += scan_paths_for_phantom_mcp_packages(config_paths)
109
118
 
110
119
  if args.tools_json is not None:
111
120
  try:
@@ -14,6 +14,7 @@ import re
14
14
  from pathlib import Path
15
15
  from typing import Iterable
16
16
 
17
+ from . import phantomdeps
17
18
  from .findings import Finding
18
19
 
19
20
  # Commands/strings that show up in real exfiltration or persistence attempts.
@@ -154,6 +155,95 @@ def scan_hooks(config: dict, source_path: str) -> list[Finding]:
154
155
  return findings
155
156
 
156
157
 
158
+ # Packages with a known, disclosed RCE/command-injection CVE in an old
159
+ # version range that still shows up pinned in copy-pasted configs/tutorials
160
+ # written before the fix shipped. `fixed_version` is the first version where
161
+ # the chain is fully closed; anything strictly older is still exploitable.
162
+ _KNOWN_VULNERABLE_PACKAGES = [
163
+ (
164
+ "mcp-remote",
165
+ (0, 1, 16),
166
+ ("CVE-2025-6514",),
167
+ "OS command injection (CVSS 9.6): a malicious/compromised remote MCP "
168
+ "server can return an `authorization_endpoint` URL during the OAuth "
169
+ "handshake that mcp-remote passes unsanitized to the OS, achieving "
170
+ "full remote code execution with no further user interaction beyond "
171
+ "opening the client. Affects v0.0.5-v0.1.15.",
172
+ ),
173
+ (
174
+ "mcp-server-git",
175
+ (2025, 12, 18),
176
+ ("CVE-2025-68143", "CVE-2025-68144", "CVE-2025-68145"),
177
+ "Chained path-validation bypass, unrestricted git_init, and argument "
178
+ "injection in Anthropic's reference git MCP server, usable via "
179
+ "indirect prompt injection (e.g. a planted README) to reach RCE "
180
+ "together with a filesystem MCP server. Fully fixed in v2025.12.18.",
181
+ ),
182
+ ]
183
+
184
+
185
+ def _parse_leading_version(text: str) -> tuple[int, ...] | None:
186
+ match = re.match(r"^(\d+(?:\.\d+)*)", text)
187
+ if not match:
188
+ return None
189
+ return tuple(int(part) for part in match.group(1).split("."))
190
+
191
+
192
+ def _version_lt(a: tuple[int, ...], b: tuple[int, ...]) -> bool:
193
+ n = max(len(a), len(b))
194
+ a_padded = a + (0,) * (n - len(a))
195
+ b_padded = b + (0,) * (n - len(b))
196
+ return a_padded < b_padded
197
+
198
+
199
+ def scan_known_cve_packages(config: dict, source_path: str) -> list[Finding]:
200
+ """Flag MCP server commands pinned to a package version with a
201
+ disclosed, fixed RCE/command-injection CVE (see
202
+ ``_KNOWN_VULNERABLE_PACKAGES``). Unpinned installs are not flagged here -
203
+ they pick up the current (patched) release and are already covered by
204
+ the generic 'unpinned package' info finding in `scan_mcp_servers`.
205
+ """
206
+ servers = config.get("mcpServers") or config.get("mcp_servers")
207
+ if not isinstance(servers, dict):
208
+ return []
209
+ findings: list[Finding] = []
210
+ for server_name, server_cfg in servers.items():
211
+ if not isinstance(server_cfg, dict):
212
+ continue
213
+ command = server_cfg.get("command", "")
214
+ args = server_cfg.get("args") or []
215
+ full_cmdline = " ".join([command, *[str(a) for a in args]])
216
+
217
+ for pkg_name, fixed_version, cve_ids, description in _KNOWN_VULNERABLE_PACKAGES:
218
+ pin_match = re.search(
219
+ rf"\b{re.escape(pkg_name)}(?:@|==)([0-9][0-9.]*)", full_cmdline
220
+ )
221
+ if not pin_match:
222
+ continue
223
+ pinned_version = _parse_leading_version(pin_match.group(1))
224
+ if pinned_version is None or not _version_lt(pinned_version, fixed_version):
225
+ continue
226
+ findings.append(
227
+ Finding(
228
+ check_id="mcp-known-cve-package",
229
+ severity="critical",
230
+ title=(
231
+ f"MCP server '{server_name}' pins {pkg_name}@"
232
+ f"{pin_match.group(1)}, vulnerable to {', '.join(cve_ids)}"
233
+ ),
234
+ detail=(
235
+ f"{description} The config explicitly pins a version "
236
+ f"older than the fixed {'.'.join(map(str, fixed_version))}, "
237
+ f"so upgrading `{pkg_name}` globally will not help "
238
+ f"until this pin is also updated."
239
+ ),
240
+ path=source_path,
241
+ references=list(cve_ids),
242
+ )
243
+ )
244
+ return findings
245
+
246
+
157
247
  def scan_mcp_servers(config: dict, source_path: str) -> list[Finding]:
158
248
  servers = config.get("mcpServers") or config.get("mcp_servers")
159
249
  if not isinstance(servers, dict):
@@ -198,6 +288,83 @@ def scan_mcp_servers(config: dict, source_path: str) -> list[Finding]:
198
288
  return findings
199
289
 
200
290
 
291
+ _MCP_RUNNER_RE = re.compile(r"\b(npx|uvx|pipx\s+run)\b", re.IGNORECASE)
292
+
293
+
294
+ def _extract_mcp_package_spec(full_cmdline: str) -> tuple[str, str] | None:
295
+ """Return ``(ecosystem, package_name)`` for the package a `npx`/`uvx`/
296
+ `pipx run` MCP server command would fetch, or ``None`` if no such
297
+ runner (or no package argument after it) is found."""
298
+ match = _MCP_RUNNER_RE.search(full_cmdline)
299
+ if not match:
300
+ return None
301
+ ecosystem = "npm" if match.group(1).lower() == "npx" else "pypi"
302
+ for token in full_cmdline[match.end():].split():
303
+ if token.startswith("-"):
304
+ continue
305
+ if token.startswith("@"):
306
+ # scoped npm package, optionally pinned: @scope/name[@version]
307
+ scoped, _, _version = token[1:].partition("@")
308
+ return ecosystem, f"@{scoped}"
309
+ name, _, _version = token.partition("@")
310
+ name = name.split("==", 1)[0]
311
+ return ecosystem, name
312
+ return None
313
+
314
+
315
+ def scan_mcp_phantom_packages(config: dict, source_path: str) -> list[Finding]:
316
+ """Flag MCP servers launched via `npx`/`uvx`/`pipx run` that point at a
317
+ package name which doesn't exist on the registry - the "MCPhantom"
318
+ pattern (Glow Labs research, Nov 2026): coding agents fill in a
319
+ plausible-sounding MCP server name from training data, or copy one
320
+ from a long-archived tutorial/repo whose package was since renamed or
321
+ unpublished. Either way, nothing stops an attacker from registering
322
+ that exact name and having it run with the user's own permissions on
323
+ the very next launch. Makes read-only registry lookups; network
324
+ errors are treated as "unknown", never as "phantom" (see phantomdeps).
325
+ """
326
+ servers = config.get("mcpServers") or config.get("mcp_servers")
327
+ if not isinstance(servers, dict):
328
+ return []
329
+ findings: list[Finding] = []
330
+ for server_name, server_cfg in servers.items():
331
+ if not isinstance(server_cfg, dict):
332
+ continue
333
+ command = server_cfg.get("command", "")
334
+ args = server_cfg.get("args") or []
335
+ full_cmdline = " ".join([command, *[str(a) for a in args]])
336
+ spec = _extract_mcp_package_spec(full_cmdline)
337
+ if spec is None:
338
+ continue
339
+ ecosystem, name = spec
340
+ if ecosystem == "npm":
341
+ if not phantomdeps._is_plausible_npm_name(name):
342
+ continue
343
+ exists = phantomdeps.check_npm_package_exists(name)
344
+ else:
345
+ exists = phantomdeps.check_pypi_package_exists(name)
346
+ if exists is not False:
347
+ continue
348
+ findings.append(
349
+ Finding(
350
+ check_id="mcp-phantom-package",
351
+ severity="critical",
352
+ title=f"MCP server '{server_name}' launches a package that doesn't exist ('{name}')",
353
+ detail=(
354
+ f"Command '{full_cmdline}' fetches '{name}' from "
355
+ f"{'npm' if ecosystem == 'npm' else 'PyPI'} on every launch, but no "
356
+ f"package by that name currently exists there (the "
357
+ f"'MCPhantom' pattern: a hallucinated or long-deprecated "
358
+ f"MCP server name). Anyone can register it and have "
359
+ f"their code run as this MCP server, with no update or "
360
+ f"confirmation step in between."
361
+ ),
362
+ path=source_path,
363
+ )
364
+ )
365
+ return findings
366
+
367
+
201
368
  def scan_permissions(config: dict, source_path: str) -> list[Finding]:
202
369
  permissions = config.get("permissions")
203
370
  if not isinstance(permissions, dict):
@@ -293,6 +460,7 @@ def scan_config_dict(config: dict, source_path: str) -> list[Finding]:
293
460
  findings: list[Finding] = []
294
461
  findings += scan_hooks(config, source_path)
295
462
  findings += scan_mcp_servers(config, source_path)
463
+ findings += scan_known_cve_packages(config, source_path)
296
464
  findings += scan_permissions(config, source_path)
297
465
  findings += scan_lethal_trifecta(config, source_path)
298
466
  return findings
@@ -339,3 +507,20 @@ def scan_paths(paths: Iterable[Path]) -> list[Finding]:
339
507
  if isinstance(config, dict):
340
508
  findings += scan_config_dict(config, str(path))
341
509
  return findings
510
+
511
+
512
+ def scan_paths_for_phantom_mcp_packages(paths: Iterable[Path]) -> list[Finding]:
513
+ """Like `scan_paths`, but only runs `scan_mcp_phantom_packages` (the
514
+ network-dependent check), for callers that opt in with `--check-deps`.
515
+ """
516
+ findings: list[Finding] = []
517
+ for path in paths:
518
+ if not path.is_file():
519
+ continue
520
+ try:
521
+ config = json.loads(path.read_text())
522
+ except (OSError, json.JSONDecodeError):
523
+ continue
524
+ if isinstance(config, dict):
525
+ findings += scan_mcp_phantom_packages(config, str(path))
526
+ return findings
@@ -18,7 +18,7 @@ from mcp.types import ToolAnnotations
18
18
 
19
19
  from . import __version__
20
20
  from .checks import run_all_ioc_checks
21
- from .configscan import default_config_paths, scan_paths
21
+ from .configscan import default_config_paths, scan_paths, scan_paths_for_phantom_mcp_packages
22
22
  from .findings import Finding, worst_severity
23
23
  from .phantomdeps import check_phantom_dependencies
24
24
 
@@ -113,6 +113,30 @@ def scan_phantom_dependencies(project_dir: str) -> str:
113
113
  return _render(check_phantom_dependencies(Path(project_dir)))
114
114
 
115
115
 
116
+ @server.tool(annotations=_NETWORK_READ_ONLY)
117
+ def scan_mcp_phantom_packages(project_dir: Optional[str] = None) -> str:
118
+ """Check the `npx`/`uvx`/`pipx run` commands in this machine's (and
119
+ optionally a project's) MCP server config against the npm/PyPI
120
+ registries, and flag any package name that doesn't actually exist
121
+ there - the "MCPhantom" pattern (Glow Labs, Nov 2026): a coding agent
122
+ fills in a plausible-sounding MCP server name from training data, or
123
+ copies one from a long-archived tutorial whose package was since
124
+ renamed or unpublished. An attacker can register that exact name and
125
+ have it run as the MCP server, with the user's own permissions, the
126
+ next time the config is loaded. Read-only HTTP lookups only; this
127
+ never connects to or executes the MCP server itself.
128
+
129
+ Args:
130
+ project_dir: optional path to a project whose .claude/ and
131
+ .mcp.json should also be scanned, in addition to the user's
132
+ home-directory config.
133
+ """
134
+ home = Path.home()
135
+ project = Path(project_dir) if project_dir else None
136
+ paths = default_config_paths(home, project)
137
+ return _render(scan_paths_for_phantom_mcp_packages(paths))
138
+
139
+
116
140
  @server.tool(annotations=_LOCAL_READ_ONLY)
117
141
  def scan_tool_descriptions(tools: list[dict]) -> str:
118
142
  """Scan a list of MCP tool definitions (the 'tools/list' response an
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-agent-audit
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Scan your machine for known AI-coding-assistant supply-chain backdoor indicators (Claude Code / Cursor / Windsurf extensions, MCP servers) plus risky hook/permission config patterns. Usable as a CLI or as an MCP server.
5
5
  Author-email: StackScribe AI <survivalagent320@gmail.com>
6
6
  License: MIT
@@ -134,6 +134,16 @@ second look rather than proof of compromise:
134
134
  `authorized_keys`, use netcat in reverse-shell mode, etc.
135
135
  - MCP server definitions that run an unpinned package (`npx -y pkg` with
136
136
  no version pin)
137
+ - MCP server definitions explicitly pinned to a package version with a
138
+ disclosed, fixed RCE CVE (added in v0.10.0): `mcp-remote` below v0.1.16
139
+ (**CVE-2025-6514**, CVSS 9.6 — a malicious remote server's OAuth
140
+ `authorization_endpoint` response is passed unsanitized to the OS,
141
+ arbitrary command execution on just opening the client) and
142
+ `mcp-server-git` below v2025.12.18 (**CVE-2025-68143/68144/68145** —
143
+ Anthropic's own reference git MCP server, chainable via indirect prompt
144
+ injection into full RCE). Only an explicit old pin is flagged — an
145
+ unpinned install picks up the current, patched release and is already
146
+ covered by the generic unpinned-package finding above
137
147
  - Auto-approved permission rules that are unscoped wildcards (e.g.
138
148
  `Bash(*)`)
139
149
  - The "lethal trifecta" pattern: an untrusted-content-fetching tool
@@ -173,6 +183,16 @@ next `npm install` / `pip install` ("slopsquatting"). Network errors are
173
183
  treated as inconclusive, not as a finding, so a flaky connection can't
174
184
  produce a false positive.
175
185
 
186
+ **MCP phantom-package scan** (`scan_mcp_phantom_packages`, also opt-in via
187
+ `--check-deps`) — the same registry-lookup idea, applied to the MCP server
188
+ commands themselves: any `npx`/`uvx`/`pipx run` invocation in a scanned
189
+ config is checked against npm/PyPI. This is the "MCPhantom" pattern flagged
190
+ by Glow Labs in November 2026: a coding agent fills in a plausible but
191
+ nonexistent MCP server package name from training data (or copies one from
192
+ a long-archived tutorial whose package was since renamed/unpublished), and
193
+ nothing stops an attacker from registering that exact name and having it
194
+ run as the MCP server next time the config loads.
195
+
176
196
  New incidents can be added to `iocs.py` as data, without touching the
177
197
  check logic.
178
198
 
@@ -1,12 +1,16 @@
1
1
  from pathlib import Path
2
+ from unittest.mock import patch
2
3
 
3
4
  from agentaudit.configscan import (
4
5
  default_config_paths,
5
6
  scan_config_dict,
6
7
  scan_hooks,
8
+ scan_known_cve_packages,
7
9
  scan_lethal_trifecta,
10
+ scan_mcp_phantom_packages,
8
11
  scan_mcp_servers,
9
12
  scan_paths,
13
+ scan_paths_for_phantom_mcp_packages,
10
14
  scan_permissions,
11
15
  scan_tool_descriptions,
12
16
  )
@@ -96,6 +100,154 @@ def test_scan_mcp_servers_not_a_dict():
96
100
  assert scan_mcp_servers({"mcpServers": "nope"}, "src") == []
97
101
 
98
102
 
103
+ def test_scan_known_cve_packages_vulnerable_mcp_remote_pin():
104
+ config = {
105
+ "mcpServers": {
106
+ "remote": {
107
+ "command": "npx",
108
+ "args": ["-y", "mcp-remote@0.1.9", "http://example.com/mcp"],
109
+ }
110
+ }
111
+ }
112
+ findings = scan_known_cve_packages(config, "src")
113
+ assert len(findings) == 1
114
+ assert findings[0].check_id == "mcp-known-cve-package"
115
+ assert findings[0].severity == "critical"
116
+ assert "CVE-2025-6514" in findings[0].references
117
+
118
+
119
+ def test_scan_known_cve_packages_vulnerable_git_server_pin():
120
+ config = {
121
+ "mcpServers": {
122
+ "git": {
123
+ "command": "uvx",
124
+ "args": ["mcp-server-git==2025.9.1", "--repository", "/repo"],
125
+ }
126
+ }
127
+ }
128
+ findings = scan_known_cve_packages(config, "src")
129
+ assert len(findings) == 1
130
+ assert "CVE-2025-68143" in findings[0].references[0]
131
+
132
+
133
+ def test_scan_known_cve_packages_fixed_version_ok():
134
+ config = {
135
+ "mcpServers": {
136
+ "remote": {"command": "npx", "args": ["-y", "mcp-remote@0.1.16", "https://example.com"]},
137
+ "git": {"command": "uvx", "args": ["mcp-server-git==2025.12.18", "--repository", "/repo"]},
138
+ }
139
+ }
140
+ assert scan_known_cve_packages(config, "src") == []
141
+
142
+
143
+ def test_scan_known_cve_packages_unpinned_not_flagged():
144
+ config = {
145
+ "mcpServers": {"remote": {"command": "npx", "args": ["-y", "mcp-remote", "https://example.com"]}}
146
+ }
147
+ assert scan_known_cve_packages(config, "src") == []
148
+
149
+
150
+ def test_scan_known_cve_packages_unrelated_package_ignored():
151
+ config = {
152
+ "mcpServers": {"fs": {"command": "npx", "args": ["-y", "@scope/pkg@0.0.1"]}}
153
+ }
154
+ assert scan_known_cve_packages(config, "src") == []
155
+
156
+
157
+ def test_scan_known_cve_packages_not_a_dict():
158
+ assert scan_known_cve_packages({"mcpServers": "nope"}, "src") == []
159
+
160
+
161
+ def test_scan_mcp_phantom_packages_flags_nonexistent_npm():
162
+ config = {
163
+ "mcpServers": {
164
+ "ghost": {"command": "npx", "args": ["-y", "mcp-server-totally-made-up-9x7z"]}
165
+ }
166
+ }
167
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=False):
168
+ findings = scan_mcp_phantom_packages(config, "src")
169
+ assert len(findings) == 1
170
+ assert findings[0].check_id == "mcp-phantom-package"
171
+ assert findings[0].severity == "critical"
172
+ assert "mcp-server-totally-made-up-9x7z" in findings[0].title
173
+
174
+
175
+ def test_scan_mcp_phantom_packages_flags_nonexistent_pypi():
176
+ config = {
177
+ "mcpServers": {"ghost": {"command": "uvx", "args": ["mcp-server-ghost-xyz"]}}
178
+ }
179
+ with patch("agentaudit.phantomdeps.check_pypi_package_exists", return_value=False):
180
+ findings = scan_mcp_phantom_packages(config, "src")
181
+ assert len(findings) == 1
182
+ assert "mcp-server-ghost-xyz" in findings[0].title
183
+
184
+
185
+ def test_scan_mcp_phantom_packages_real_package_not_flagged():
186
+ config = {
187
+ "mcpServers": {
188
+ "fs": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"]}
189
+ }
190
+ }
191
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=True):
192
+ findings = scan_mcp_phantom_packages(config, "src")
193
+ assert findings == []
194
+
195
+
196
+ def test_scan_mcp_phantom_packages_network_unknown_not_flagged():
197
+ config = {"mcpServers": {"ghost": {"command": "npx", "args": ["-y", "some-pkg"]}}}
198
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=None):
199
+ findings = scan_mcp_phantom_packages(config, "src")
200
+ assert findings == []
201
+
202
+
203
+ def test_scan_mcp_phantom_packages_versioned_pin_strips_version():
204
+ config = {
205
+ "mcpServers": {
206
+ "ghost": {"command": "npx", "args": ["-y", "mcp-server-ghost-xyz@1.2.3"]}
207
+ }
208
+ }
209
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=False) as mock_check:
210
+ findings = scan_mcp_phantom_packages(config, "src")
211
+ mock_check.assert_called_once_with("mcp-server-ghost-xyz")
212
+ assert len(findings) == 1
213
+
214
+
215
+ def test_scan_mcp_phantom_packages_scoped_versioned_pin():
216
+ config = {
217
+ "mcpServers": {
218
+ "ghost": {"command": "npx", "args": ["-y", "@scope/ghost-pkg@2.0.0"]}
219
+ }
220
+ }
221
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=False) as mock_check:
222
+ findings = scan_mcp_phantom_packages(config, "src")
223
+ mock_check.assert_called_once_with("@scope/ghost-pkg")
224
+ assert len(findings) == 1
225
+
226
+
227
+ def test_scan_mcp_phantom_packages_no_runner_not_flagged():
228
+ config = {"mcpServers": {"local": {"command": "/usr/local/bin/my-server"}}}
229
+ assert scan_mcp_phantom_packages(config, "src") == []
230
+
231
+
232
+ def test_scan_mcp_phantom_packages_not_a_dict():
233
+ assert scan_mcp_phantom_packages({"mcpServers": "nope"}, "src") == []
234
+
235
+
236
+ def test_scan_paths_for_phantom_mcp_packages(tmp_path: Path):
237
+ config_path = tmp_path / ".mcp.json"
238
+ config_path.write_text(
239
+ '{"mcpServers": {"ghost": {"command": "npx", "args": ["-y", "mcp-ghost-xyz"]}}}'
240
+ )
241
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", return_value=False):
242
+ findings = scan_paths_for_phantom_mcp_packages([config_path])
243
+ assert len(findings) == 1
244
+ assert findings[0].check_id == "mcp-phantom-package"
245
+
246
+
247
+ def test_scan_paths_for_phantom_mcp_packages_missing_file(tmp_path: Path):
248
+ assert scan_paths_for_phantom_mcp_packages([tmp_path / "missing.json"]) == []
249
+
250
+
99
251
  def test_scan_permissions_scoped_rule_ok():
100
252
  config = {"permissions": {"allow": ["Bash(git diff:*)", "Read(//workspace/**)"]}}
101
253
  assert scan_permissions(config, "src") == []