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.
- {coding_agent_audit-0.9.0/src/coding_agent_audit.egg-info → coding_agent_audit-0.11.0}/PKG-INFO +21 -1
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/README.md +20 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/pyproject.toml +1 -1
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/__init__.py +1 -1
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/cli.py +19 -10
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/configscan.py +185 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/mcp_server.py +25 -1
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0/src/coding_agent_audit.egg-info}/PKG-INFO +21 -1
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/tests/test_configscan.py +152 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/LICENSE +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/setup.cfg +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/checks.py +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/findings.py +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/iocs.py +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/agentaudit/phantomdeps.py +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/SOURCES.txt +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/dependency_links.txt +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/entry_points.txt +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/requires.txt +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/top_level.txt +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/tests/test_checks.py +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/tests/test_cli.py +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/tests/test_mcp_server.py +0 -0
- {coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/tests/test_phantomdeps.py +0 -0
{coding_agent_audit-0.9.0/src/coding_agent_audit.egg-info → coding_agent_audit-0.11.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coding-agent-audit
|
|
3
|
-
Version: 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.
|
|
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"
|
|
@@ -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
|
|
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
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
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
|
-
|
|
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
|
|
108
|
-
|
|
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
|
{coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0/src/coding_agent_audit.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coding-agent-audit
|
|
3
|
-
Version: 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") == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/requires.txt
RENAMED
|
File without changes
|
{coding_agent_audit-0.9.0 → coding_agent_audit-0.11.0}/src/coding_agent_audit.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|