coding-agent-audit 0.3.3__tar.gz → 0.4.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.3.3/src/coding_agent_audit.egg-info → coding_agent_audit-0.4.0}/PKG-INFO +16 -1
  2. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/README.md +15 -0
  3. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/pyproject.toml +1 -1
  4. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/agentaudit/__init__.py +1 -1
  5. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/agentaudit/cli.py +14 -0
  6. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/agentaudit/mcp_server.py +18 -0
  7. coding_agent_audit-0.4.0/src/agentaudit/phantomdeps.py +201 -0
  8. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0/src/coding_agent_audit.egg-info}/PKG-INFO +16 -1
  9. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/coding_agent_audit.egg-info/SOURCES.txt +3 -1
  10. coding_agent_audit-0.4.0/tests/test_phantomdeps.py +96 -0
  11. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/LICENSE +0 -0
  12. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/setup.cfg +0 -0
  13. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/agentaudit/checks.py +0 -0
  14. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/agentaudit/configscan.py +0 -0
  15. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/agentaudit/findings.py +0 -0
  16. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/agentaudit/iocs.py +0 -0
  17. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/coding_agent_audit.egg-info/dependency_links.txt +0 -0
  18. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/coding_agent_audit.egg-info/entry_points.txt +0 -0
  19. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/coding_agent_audit.egg-info/requires.txt +0 -0
  20. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/src/coding_agent_audit.egg-info/top_level.txt +0 -0
  21. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/tests/test_checks.py +0 -0
  22. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/tests/test_cli.py +0 -0
  23. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/tests/test_configscan.py +0 -0
  24. {coding_agent_audit-0.3.3 → coding_agent_audit-0.4.0}/tests/test_mcp_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-agent-audit
3
- Version: 0.3.3
3
+ Version: 0.4.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
@@ -80,6 +80,10 @@ agent-audit --json
80
80
  # only one half of the scan
81
81
  agent-audit --skip-config # IOC-only
82
82
  agent-audit --skip-ioc # config-heuristics only
83
+
84
+ # also check package.json/requirements.txt/pyproject.toml deps against
85
+ # npm/PyPI and flag any that don't exist (hallucinated/slopsquattable name)
86
+ agent-audit --project /path/to/repo --check-deps
83
87
  ```
84
88
 
85
89
  Exit codes: `0` nothing found, `1` only warning/info findings, `2` at least
@@ -111,6 +115,17 @@ second look rather than proof of compromise:
111
115
  - Auto-approved permission rules that are unscoped wildcards (e.g.
112
116
  `Bash(*)`)
113
117
 
118
+ **Phantom-dependency scan** (`phantomdeps.py`, opt-in via `--check-deps`) —
119
+ reads `package.json` / `requirements.txt` / `pyproject.toml` in the
120
+ `--project` directory and checks each listed package name against the
121
+ npm/PyPI registries (read-only HTTP lookups, nothing is installed). AI
122
+ coding assistants occasionally hallucinate a plausible-but-nonexistent
123
+ package name; if one ends up in your manifest, an attacker can register
124
+ that exact name with malicious code and have it silently installed on the
125
+ next `npm install` / `pip install` ("slopsquatting"). Network errors are
126
+ treated as inconclusive, not as a finding, so a flaky connection can't
127
+ produce a false positive.
128
+
114
129
  New incidents can be added to `iocs.py` as data, without touching the
115
130
  check logic.
116
131
 
@@ -59,6 +59,10 @@ agent-audit --json
59
59
  # only one half of the scan
60
60
  agent-audit --skip-config # IOC-only
61
61
  agent-audit --skip-ioc # config-heuristics only
62
+
63
+ # also check package.json/requirements.txt/pyproject.toml deps against
64
+ # npm/PyPI and flag any that don't exist (hallucinated/slopsquattable name)
65
+ agent-audit --project /path/to/repo --check-deps
62
66
  ```
63
67
 
64
68
  Exit codes: `0` nothing found, `1` only warning/info findings, `2` at least
@@ -90,6 +94,17 @@ second look rather than proof of compromise:
90
94
  - Auto-approved permission rules that are unscoped wildcards (e.g.
91
95
  `Bash(*)`)
92
96
 
97
+ **Phantom-dependency scan** (`phantomdeps.py`, opt-in via `--check-deps`) —
98
+ reads `package.json` / `requirements.txt` / `pyproject.toml` in the
99
+ `--project` directory and checks each listed package name against the
100
+ npm/PyPI registries (read-only HTTP lookups, nothing is installed). AI
101
+ coding assistants occasionally hallucinate a plausible-but-nonexistent
102
+ package name; if one ends up in your manifest, an attacker can register
103
+ that exact name with malicious code and have it silently installed on the
104
+ next `npm install` / `pip install` ("slopsquatting"). Network errors are
105
+ treated as inconclusive, not as a finding, so a flaky connection can't
106
+ produce a false positive.
107
+
93
108
  New incidents can be added to `iocs.py` as data, without touching the
94
109
  check logic.
95
110
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "coding-agent-audit"
7
- version = "0.3.3"
7
+ version = "0.4.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.3.3"
6
+ __version__ = "0.4.0"
@@ -9,6 +9,7 @@ from . import __version__
9
9
  from .checks import run_all_ioc_checks
10
10
  from .configscan import default_config_paths, scan_paths
11
11
  from .findings import Finding, worst_severity
12
+ from .phantomdeps import check_phantom_dependencies
12
13
 
13
14
  _SEVERITY_ICON = {"critical": "[CRITICAL]", "warning": "[WARNING]", "info": "[INFO]"}
14
15
 
@@ -60,6 +61,16 @@ def build_parser() -> argparse.ArgumentParser:
60
61
  action="store_true",
61
62
  help="skip the risky-config heuristic scan (IOC-only)",
62
63
  )
64
+ parser.add_argument(
65
+ "--check-deps",
66
+ action="store_true",
67
+ 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."
72
+ ),
73
+ )
63
74
  parser.add_argument("--json", action="store_true", help="output JSON instead of text")
64
75
  parser.add_argument(
65
76
  "--no-footer", action="store_true", help="suppress the closing promo line"
@@ -80,6 +91,9 @@ def main(argv: list[str] | None = None) -> int:
80
91
  paths = default_config_paths(home, args.project)
81
92
  findings += scan_paths(paths)
82
93
 
94
+ if args.check_deps and args.project is not None:
95
+ findings += check_phantom_dependencies(args.project)
96
+
83
97
  if args.json:
84
98
  print(json.dumps([f.to_dict() for f in findings], indent=2))
85
99
  else:
@@ -19,6 +19,7 @@ from . import __version__
19
19
  from .checks import run_all_ioc_checks
20
20
  from .configscan import default_config_paths, scan_paths
21
21
  from .findings import Finding, worst_severity
22
+ from .phantomdeps import check_phantom_dependencies
22
23
 
23
24
  server = MCPServer(
24
25
  name="coding-agent-audit",
@@ -79,6 +80,23 @@ def scan_mcp_config(project_dir: Optional[str] = None) -> str:
79
80
  return _render(scan_paths(paths))
80
81
 
81
82
 
83
+ @server.tool()
84
+ def scan_phantom_dependencies(project_dir: str) -> str:
85
+ """Check a project's package.json / requirements.txt / pyproject.toml
86
+ dependencies against the npm/PyPI registries and flag any package name
87
+ that doesn't actually exist there. AI coding assistants occasionally
88
+ hallucinate a plausible-but-nonexistent package name; if one ends up in
89
+ a manifest, an attacker can register that exact name with malicious
90
+ code and have it silently installed on the next install
91
+ ("slopsquatting"). Read-only HTTP lookups only; nothing is installed.
92
+
93
+ Args:
94
+ project_dir: path to the project directory containing the manifest
95
+ file(s) to check.
96
+ """
97
+ return _render(check_phantom_dependencies(Path(project_dir)))
98
+
99
+
82
100
  def main() -> None:
83
101
  server.run()
84
102
 
@@ -0,0 +1,201 @@
1
+ """Checks for dependency-manifest entries that reference a package which
2
+ does not actually exist on the relevant registry (npm / PyPI).
3
+
4
+ AI coding agents occasionally hallucinate plausible-but-nonexistent package
5
+ names. If a project's manifest references one, an attacker can register
6
+ that exact name on the registry with malicious code ("slopsquatting") and
7
+ the next `npm install` / `pip install` run pulls it in silently. This module
8
+ only reads local manifest files and makes read-only HTTP lookups against
9
+ the public registries - it never installs or modifies anything.
10
+
11
+ Network errors (offline, timeout, DNS failure, rate limiting) are treated
12
+ as "unknown" rather than "phantom", so a flaky connection never produces a
13
+ false positive.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import urllib.error
21
+ import urllib.request
22
+ from pathlib import Path
23
+ from typing import Optional
24
+ from urllib.parse import quote
25
+
26
+ from .findings import Finding
27
+
28
+ _REQUEST_TIMEOUT = 4
29
+ _USER_AGENT = "coding-agent-audit (+https://github.com/stackscribe-ai/coding-agent-audit)"
30
+
31
+ _NPM_NAME_RE = re.compile(r"^(@[a-z0-9][\w.-]*/)?[a-z0-9][\w.-]*$", re.IGNORECASE)
32
+ # Stdlib / common built-in module names that show up in requirements-style
33
+ # files but are never installed from PyPI under that name.
34
+ _PY_SKIP = {"python", "pip", "setuptools", "wheel"}
35
+
36
+
37
+ def _http_exists(url: str) -> Optional[bool]:
38
+ """Return True if `url` resolves (2xx), False if it 404s, None if the
39
+ check itself was inconclusive (network error, timeout, non-404 error)."""
40
+ req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": _USER_AGENT})
41
+ try:
42
+ with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp:
43
+ return 200 <= resp.status < 300
44
+ except urllib.error.HTTPError as exc:
45
+ if exc.code == 404:
46
+ return False
47
+ return None
48
+ except (urllib.error.URLError, OSError, TimeoutError):
49
+ return None
50
+
51
+
52
+ def check_npm_package_exists(name: str) -> Optional[bool]:
53
+ if "/" in name:
54
+ scope, _, rest = name.partition("/")
55
+ encoded = f"{quote(scope, safe='')}%2F{quote(rest, safe='')}"
56
+ else:
57
+ encoded = quote(name, safe="")
58
+ return _http_exists(f"https://registry.npmjs.org/{encoded}")
59
+
60
+
61
+ def check_pypi_package_exists(name: str) -> Optional[bool]:
62
+ return _http_exists(f"https://pypi.org/pypi/{quote(name, safe='')}/json")
63
+
64
+
65
+ def parse_npm_package_names(path: Path) -> list[str]:
66
+ try:
67
+ data = json.loads(path.read_text())
68
+ except (OSError, json.JSONDecodeError):
69
+ return []
70
+ if not isinstance(data, dict):
71
+ return []
72
+ names: list[str] = []
73
+ for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
74
+ deps = data.get(key)
75
+ if isinstance(deps, dict):
76
+ names.extend(n for n in deps if isinstance(n, str))
77
+ return names
78
+
79
+
80
+ _REQ_LINE_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
81
+
82
+
83
+ def parse_requirements_txt(path: Path) -> list[str]:
84
+ try:
85
+ lines = path.read_text().splitlines()
86
+ except OSError:
87
+ return []
88
+ names: list[str] = []
89
+ for line in lines:
90
+ line = line.split("#", 1)[0].strip()
91
+ if not line or line.startswith(("-", "git+", "http://", "https://")):
92
+ continue
93
+ match = _REQ_LINE_RE.match(line)
94
+ if not match:
95
+ continue
96
+ name = match.group(1).lower()
97
+ if name not in _PY_SKIP:
98
+ names.append(name)
99
+ return names
100
+
101
+
102
+ _TOML_DEP_LIST_RE = re.compile(r"(?m)^\s*dependencies\s*=\s*\[(.*?)\]", re.DOTALL)
103
+ _TOML_STRING_RE = re.compile(r'"([^"]+)"|\'([^\']+)\'')
104
+ _TOML_POETRY_TABLE_RE = re.compile(
105
+ r"(?m)^\[tool\.poetry\.(?:dependencies|dev-dependencies)\]\s*\n(.*?)(?=\n\[|\Z)", re.DOTALL
106
+ )
107
+ _TOML_POETRY_KEY_RE = re.compile(r"(?m)^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=")
108
+
109
+
110
+ def parse_pyproject_packages(path: Path) -> list[str]:
111
+ """Best-effort regex extraction (no TOML parser dependency, no need for
112
+ 3.11+ tomllib given this project's py3.9 floor). False negatives (a
113
+ dependency we fail to spot) are fine; this is a heuristic triage tool."""
114
+ try:
115
+ text = path.read_text()
116
+ except OSError:
117
+ return []
118
+ names: list[str] = []
119
+ for list_match in _TOML_DEP_LIST_RE.finditer(text):
120
+ for entry_match in _TOML_STRING_RE.finditer(list_match.group(1)):
121
+ entry = entry_match.group(1) or entry_match.group(2)
122
+ name = re.split(r"[<>=!~\[; ]", entry, 1)[0].strip()
123
+ if name:
124
+ names.append(name)
125
+ for table_match in _TOML_POETRY_TABLE_RE.finditer(text):
126
+ for key_match in _TOML_POETRY_KEY_RE.finditer(table_match.group(1)):
127
+ name = key_match.group(1)
128
+ if name.lower() != "python":
129
+ names.append(name)
130
+ return names
131
+
132
+
133
+ def _is_plausible_npm_name(name: str) -> bool:
134
+ return bool(_NPM_NAME_RE.match(name))
135
+
136
+
137
+ def check_phantom_dependencies(project_dir: Path) -> list[Finding]:
138
+ """Scan a project directory's manifest files for dependencies that
139
+ don't resolve on their registry."""
140
+ findings: list[Finding] = []
141
+
142
+ npm_names: dict[str, str] = {}
143
+ pkg_json = project_dir / "package.json"
144
+ if pkg_json.is_file():
145
+ for name in parse_npm_package_names(pkg_json):
146
+ if _is_plausible_npm_name(name):
147
+ npm_names.setdefault(name, str(pkg_json))
148
+
149
+ py_names: dict[str, str] = {}
150
+ requirements = project_dir / "requirements.txt"
151
+ if requirements.is_file():
152
+ for name in parse_requirements_txt(requirements):
153
+ py_names.setdefault(name, str(requirements))
154
+ pyproject = project_dir / "pyproject.toml"
155
+ if pyproject.is_file():
156
+ for name in parse_pyproject_packages(pyproject):
157
+ py_names.setdefault(name, str(pyproject))
158
+
159
+ for name, source in npm_names.items():
160
+ exists = check_npm_package_exists(name)
161
+ if exists is False:
162
+ findings.append(
163
+ Finding(
164
+ check_id="phantom-dependency",
165
+ severity="warning",
166
+ title=f"npm dependency '{name}' not found on the npm registry",
167
+ detail=(
168
+ f"'{name}' is listed as a dependency but does not "
169
+ f"exist on registry.npmjs.org. This can happen when "
170
+ f"an AI coding assistant hallucinates a plausible "
171
+ f"package name. Until it's removed or corrected, "
172
+ f"anyone could publish a malicious package under "
173
+ f"this exact name and have it silently installed on "
174
+ f"the next `npm install` (slopsquatting)."
175
+ ),
176
+ path=source,
177
+ )
178
+ )
179
+
180
+ for name, source in py_names.items():
181
+ exists = check_pypi_package_exists(name)
182
+ if exists is False:
183
+ findings.append(
184
+ Finding(
185
+ check_id="phantom-dependency",
186
+ severity="warning",
187
+ title=f"Python dependency '{name}' not found on PyPI",
188
+ detail=(
189
+ f"'{name}' is listed as a dependency but does not "
190
+ f"exist on pypi.org. This can happen when an AI "
191
+ f"coding assistant hallucinates a plausible package "
192
+ f"name. Until it's removed or corrected, anyone "
193
+ f"could publish a malicious package under this "
194
+ f"exact name and have it silently installed on the "
195
+ f"next `pip install` (slopsquatting)."
196
+ ),
197
+ path=source,
198
+ )
199
+ )
200
+
201
+ return findings
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coding-agent-audit
3
- Version: 0.3.3
3
+ Version: 0.4.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
@@ -80,6 +80,10 @@ agent-audit --json
80
80
  # only one half of the scan
81
81
  agent-audit --skip-config # IOC-only
82
82
  agent-audit --skip-ioc # config-heuristics only
83
+
84
+ # also check package.json/requirements.txt/pyproject.toml deps against
85
+ # npm/PyPI and flag any that don't exist (hallucinated/slopsquattable name)
86
+ agent-audit --project /path/to/repo --check-deps
83
87
  ```
84
88
 
85
89
  Exit codes: `0` nothing found, `1` only warning/info findings, `2` at least
@@ -111,6 +115,17 @@ second look rather than proof of compromise:
111
115
  - Auto-approved permission rules that are unscoped wildcards (e.g.
112
116
  `Bash(*)`)
113
117
 
118
+ **Phantom-dependency scan** (`phantomdeps.py`, opt-in via `--check-deps`) —
119
+ reads `package.json` / `requirements.txt` / `pyproject.toml` in the
120
+ `--project` directory and checks each listed package name against the
121
+ npm/PyPI registries (read-only HTTP lookups, nothing is installed). AI
122
+ coding assistants occasionally hallucinate a plausible-but-nonexistent
123
+ package name; if one ends up in your manifest, an attacker can register
124
+ that exact name with malicious code and have it silently installed on the
125
+ next `npm install` / `pip install` ("slopsquatting"). Network errors are
126
+ treated as inconclusive, not as a finding, so a flaky connection can't
127
+ produce a false positive.
128
+
114
129
  New incidents can be added to `iocs.py` as data, without touching the
115
130
  check logic.
116
131
 
@@ -8,6 +8,7 @@ src/agentaudit/configscan.py
8
8
  src/agentaudit/findings.py
9
9
  src/agentaudit/iocs.py
10
10
  src/agentaudit/mcp_server.py
11
+ src/agentaudit/phantomdeps.py
11
12
  src/coding_agent_audit.egg-info/PKG-INFO
12
13
  src/coding_agent_audit.egg-info/SOURCES.txt
13
14
  src/coding_agent_audit.egg-info/dependency_links.txt
@@ -17,4 +18,5 @@ src/coding_agent_audit.egg-info/top_level.txt
17
18
  tests/test_checks.py
18
19
  tests/test_cli.py
19
20
  tests/test_configscan.py
20
- tests/test_mcp_server.py
21
+ tests/test_mcp_server.py
22
+ tests/test_phantomdeps.py
@@ -0,0 +1,96 @@
1
+ from pathlib import Path
2
+ from unittest.mock import patch
3
+
4
+ from agentaudit.phantomdeps import (
5
+ check_phantom_dependencies,
6
+ parse_npm_package_names,
7
+ parse_pyproject_packages,
8
+ parse_requirements_txt,
9
+ )
10
+
11
+
12
+ def test_parse_npm_package_names(tmp_path: Path):
13
+ pkg = tmp_path / "package.json"
14
+ pkg.write_text(
15
+ '{"dependencies": {"left-pad": "1.0.0"}, '
16
+ '"devDependencies": {"@scope/tool": "2.0.0"}}'
17
+ )
18
+ names = parse_npm_package_names(pkg)
19
+ assert set(names) == {"left-pad", "@scope/tool"}
20
+
21
+
22
+ def test_parse_npm_package_names_invalid_json(tmp_path: Path):
23
+ pkg = tmp_path / "package.json"
24
+ pkg.write_text("{not json")
25
+ assert parse_npm_package_names(pkg) == []
26
+
27
+
28
+ def test_parse_requirements_txt(tmp_path: Path):
29
+ req = tmp_path / "requirements.txt"
30
+ req.write_text(
31
+ "requests==2.31.0\n"
32
+ "# a comment\n"
33
+ "\n"
34
+ "numpy>=1.20,<2.0\n"
35
+ "-e git+https://example.com/foo.git\n"
36
+ "pandas\n"
37
+ )
38
+ names = parse_requirements_txt(req)
39
+ assert names == ["requests", "numpy", "pandas"]
40
+
41
+
42
+ def test_parse_pyproject_packages_pep621(tmp_path: Path):
43
+ pp = tmp_path / "pyproject.toml"
44
+ pp.write_text(
45
+ "[project]\n"
46
+ 'dependencies = [\n'
47
+ ' "requests>=2.0",\n'
48
+ ' "totally-made-up-pkg-xyz",\n'
49
+ "]\n"
50
+ )
51
+ names = parse_pyproject_packages(pp)
52
+ assert set(names) == {"requests", "totally-made-up-pkg-xyz"}
53
+
54
+
55
+ def test_parse_pyproject_packages_poetry(tmp_path: Path):
56
+ pp = tmp_path / "pyproject.toml"
57
+ pp.write_text(
58
+ "[tool.poetry.dependencies]\n"
59
+ 'python = "^3.9"\n'
60
+ 'requests = "^2.0"\n'
61
+ "\n"
62
+ "[tool.poetry.group.dev.dependencies]\n"
63
+ 'pytest = "^7.0"\n'
64
+ )
65
+ names = parse_pyproject_packages(pp)
66
+ assert "requests" in names
67
+ assert "python" not in names
68
+
69
+
70
+ def test_check_phantom_dependencies_flags_nonexistent_npm(tmp_path: Path):
71
+ (tmp_path / "package.json").write_text(
72
+ '{"dependencies": {"left-pad": "1.0.0", "definitely-not-a-real-pkg-9x7z": "1.0.0"}}'
73
+ )
74
+
75
+ def fake_exists(name: str):
76
+ return name != "definitely-not-a-real-pkg-9x7z"
77
+
78
+ with patch("agentaudit.phantomdeps.check_npm_package_exists", side_effect=fake_exists):
79
+ findings = check_phantom_dependencies(tmp_path)
80
+
81
+ assert len(findings) == 1
82
+ assert findings[0].check_id == "phantom-dependency"
83
+ assert "definitely-not-a-real-pkg-9x7z" in findings[0].title
84
+
85
+
86
+ def test_check_phantom_dependencies_network_unknown_not_flagged(tmp_path: Path):
87
+ (tmp_path / "requirements.txt").write_text("some-package\n")
88
+
89
+ with patch("agentaudit.phantomdeps.check_pypi_package_exists", return_value=None):
90
+ findings = check_phantom_dependencies(tmp_path)
91
+
92
+ assert findings == []
93
+
94
+
95
+ def test_check_phantom_dependencies_no_manifests(tmp_path: Path):
96
+ assert check_phantom_dependencies(tmp_path) == []