ghostpkg 0.3.0__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.
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/CHANGELOG.md +24 -1
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/PKG-INFO +23 -2
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/README.en.md +22 -1
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/README.md +11 -1
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/__init__.py +1 -1
- ghostpkg-0.4.0/ghostpkg/cache.py +165 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/cli.py +30 -4
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/pyproject.toml +1 -1
- ghostpkg-0.4.0/tests/test_cache.py +158 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/.gitignore +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/LICENSE +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/SECURITY.md +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/assets/banner.html +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/assets/banner.png +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/assets/demo.gif +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/assets/make_demo.py +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/assess.py +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/data.py +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/manifests.py +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/ghostpkg/registries.py +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/tests/test_assess.py +0 -0
- {ghostpkg-0.3.0 → ghostpkg-0.4.0}/tests/test_manifests.py +0 -0
|
@@ -6,6 +6,28 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] - 2026-09-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **On-disk cache for registry lookups.** Scanning a 150-package manifest drops
|
|
13
|
+
from 4.7s to 0.4s on a warm cache. Previously every scan cost one request per
|
|
14
|
+
dependency, every run, which is slow in CI and rude to the registry.
|
|
15
|
+
- `--no-cache` to bypass it, and `ghostpkg clear-cache` to delete it.
|
|
16
|
+
- `GHOSTPKG_CACHE_DIR` to override the location. The default is
|
|
17
|
+
`%LOCALAPPDATA%\ghostpkg` on Windows, `~/Library/Caches/ghostpkg` on macOS and
|
|
18
|
+
`$XDG_CACHE_HOME/ghostpkg` elsewhere, worked out without a dependency.
|
|
19
|
+
|
|
20
|
+
### Notes on the cache design
|
|
21
|
+
- **Time-to-live depends on the answer, and "does not exist" is held for only an
|
|
22
|
+
hour.** A free name can be registered at any moment — that is the whole attack
|
|
23
|
+
— so a negative result must not be trusted for long. Young packages are held
|
|
24
|
+
six hours, established ones a day.
|
|
25
|
+
- Cache failures are never fatal. A corrupt file, a wrong schema, a malformed
|
|
26
|
+
entry or an unwritable directory all degrade to no cache rather than breaking
|
|
27
|
+
a run. There are tests for each of those.
|
|
28
|
+
- Written atomically via a temporary file and `os.replace`, once per run, so a
|
|
29
|
+
killed process cannot leave a half-written cache behind.
|
|
30
|
+
|
|
9
31
|
## [0.3.0] - 2026-09-01
|
|
10
32
|
|
|
11
33
|
### Fixed
|
|
@@ -87,7 +109,8 @@ First release.
|
|
|
87
109
|
- No corpus of hallucinated package names is shipped, following the decision of
|
|
88
110
|
the USENIX'25 authors not to publish theirs.
|
|
89
111
|
|
|
90
|
-
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.
|
|
112
|
+
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.4.0...HEAD
|
|
113
|
+
[0.4.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.4.0
|
|
91
114
|
[0.3.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.3.0
|
|
92
115
|
[0.2.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.2.0
|
|
93
116
|
[0.1.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ghostpkg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Catch package names that do not exist before you install them
|
|
5
5
|
Project-URL: Homepage, https://github.com/m1rwana12/ghostpkg
|
|
6
6
|
Project-URL: Issues, https://github.com/m1rwana12/ghostpkg/issues
|
|
@@ -180,6 +180,7 @@ and report TOML keys as package names.
|
|
|
180
180
|
| `--strict` | Promote warnings to blocks |
|
|
181
181
|
| `--json` | Machine-readable output for scripts and CI |
|
|
182
182
|
| `-q`, `--quiet` | Hide packages that passed |
|
|
183
|
+
| `--no-cache` | Neither read nor write the cache |
|
|
183
184
|
| `--version` | Print the version |
|
|
184
185
|
|
|
185
186
|
### Exit codes
|
|
@@ -190,6 +191,25 @@ and report TOML keys as package names.
|
|
|
190
191
|
| `1` | At least one package blocked |
|
|
191
192
|
| `2` | Usage error, unreadable manifest, or the registry was unreachable |
|
|
192
193
|
|
|
194
|
+
### Caching
|
|
195
|
+
|
|
196
|
+
Lookups are cached on disk, so re-scanning a 150-package manifest takes 0.4s
|
|
197
|
+
instead of 4.7s.
|
|
198
|
+
|
|
199
|
+
Time-to-live depends on the answer, and the negative case is the one that
|
|
200
|
+
matters: **"does not exist" is held for only an hour**, because a free name can
|
|
201
|
+
be registered at any moment and that is the entire attack. Young packages are
|
|
202
|
+
held six hours, established ones a day.
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
ghostpkg scan requirements.txt --no-cache # bypass it
|
|
206
|
+
ghostpkg clear-cache # delete it
|
|
207
|
+
GHOSTPKG_CACHE_DIR=/tmp/gp ghostpkg check x # move it
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
A corrupt, unreadable or unwritable cache degrades to no cache rather than
|
|
211
|
+
breaking your run.
|
|
212
|
+
|
|
193
213
|
### JSON output
|
|
194
214
|
|
|
195
215
|
```console
|
|
@@ -357,7 +377,8 @@ earns its keep. Use both.
|
|
|
357
377
|
ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
|
|
358
378
|
- Names shorter than five characters are not compared at all: below that the name
|
|
359
379
|
space is too dense for edit distance to mean anything.
|
|
360
|
-
-
|
|
380
|
+
- The cache lives on disk. `ghostpkg clear-cache` removes it and
|
|
381
|
+
`GHOSTPKG_CACHE_DIR` moves it.
|
|
361
382
|
- Registry outages surface as exit code `2` rather than a silent pass — deliberately,
|
|
362
383
|
but it does mean a flaky network fails your build.
|
|
363
384
|
|
|
@@ -133,6 +133,7 @@ and report TOML keys as package names.
|
|
|
133
133
|
| `--strict` | Promote warnings to blocks |
|
|
134
134
|
| `--json` | Machine-readable output for scripts and CI |
|
|
135
135
|
| `-q`, `--quiet` | Hide packages that passed |
|
|
136
|
+
| `--no-cache` | Neither read nor write the cache |
|
|
136
137
|
| `--version` | Print the version |
|
|
137
138
|
|
|
138
139
|
### Exit codes
|
|
@@ -143,6 +144,25 @@ and report TOML keys as package names.
|
|
|
143
144
|
| `1` | At least one package blocked |
|
|
144
145
|
| `2` | Usage error, unreadable manifest, or the registry was unreachable |
|
|
145
146
|
|
|
147
|
+
### Caching
|
|
148
|
+
|
|
149
|
+
Lookups are cached on disk, so re-scanning a 150-package manifest takes 0.4s
|
|
150
|
+
instead of 4.7s.
|
|
151
|
+
|
|
152
|
+
Time-to-live depends on the answer, and the negative case is the one that
|
|
153
|
+
matters: **"does not exist" is held for only an hour**, because a free name can
|
|
154
|
+
be registered at any moment and that is the entire attack. Young packages are
|
|
155
|
+
held six hours, established ones a day.
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
ghostpkg scan requirements.txt --no-cache # bypass it
|
|
159
|
+
ghostpkg clear-cache # delete it
|
|
160
|
+
GHOSTPKG_CACHE_DIR=/tmp/gp ghostpkg check x # move it
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A corrupt, unreadable or unwritable cache degrades to no cache rather than
|
|
164
|
+
breaking your run.
|
|
165
|
+
|
|
146
166
|
### JSON output
|
|
147
167
|
|
|
148
168
|
```console
|
|
@@ -310,7 +330,8 @@ earns its keep. Use both.
|
|
|
310
330
|
ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
|
|
311
331
|
- Names shorter than five characters are not compared at all: below that the name
|
|
312
332
|
space is too dense for edit distance to mean anything.
|
|
313
|
-
-
|
|
333
|
+
- The cache lives on disk. `ghostpkg clear-cache` removes it and
|
|
334
|
+
`GHOSTPKG_CACHE_DIR` moves it.
|
|
314
335
|
- Registry outages surface as exit code `2` rather than a silent pass — deliberately,
|
|
315
336
|
but it does mean a flaky network fails your build.
|
|
316
337
|
|
|
@@ -86,14 +86,23 @@ ghostpkg scan package.json
|
|
|
86
86
|
|
|
87
87
|
# машинозчитуваний вивід
|
|
88
88
|
ghostpkg check somepkg --json
|
|
89
|
+
|
|
90
|
+
# видалити кеш
|
|
91
|
+
ghostpkg clear-cache
|
|
89
92
|
```
|
|
90
93
|
|
|
94
|
+
Результати запитів кешуються на диск: повторна перевірка манифесту зі 150
|
|
95
|
+
залежностей займає 0,4 с замість 4,7 с. **Відповідь «пакета не існує»
|
|
96
|
+
зберігається лише годину** — вільне ім'я можуть зареєструвати будь-якої
|
|
97
|
+
миті, і саме в цьому вся атака.
|
|
98
|
+
|
|
91
99
|
| Прапорець | Призначення |
|
|
92
100
|
|---|---|
|
|
93
101
|
| `-e`, `--ecosystem` | `pypi` (типово) або `npm` |
|
|
94
102
|
| `--strict` | Підвищує попередження до блокувань |
|
|
95
103
|
| `--json` | Вивід у JSON для скриптів і CI |
|
|
96
104
|
| `-q`, `--quiet` | Ховає пакети, які пройшли перевірку |
|
|
105
|
+
| `--no-cache` | Не читати й не писати кеш |
|
|
97
106
|
|
|
98
107
|
`scan` розпізнає `requirements*.txt`, `pyproject.toml` (PEP 621 і Poetry)
|
|
99
108
|
та `package.json`. Невідомий формат він **відхиляє з помилкою, а не вгадує**.
|
|
@@ -167,7 +176,8 @@ $ ghostpkg check react-router-dom-utils -e npm
|
|
|
167
176
|
тому підробка під менш популярний пакет як схожа назва не позначиться.
|
|
168
177
|
- Імена коротші за 5 символів не порівнюються: там простір назв надто щільний,
|
|
169
178
|
щоб відстань редагування щось означала.
|
|
170
|
-
-
|
|
179
|
+
- Кеш зберігається локально. `ghostpkg clear-cache` видаляє його,
|
|
180
|
+
`GHOSTPKG_CACHE_DIR` змінює розташування.
|
|
171
181
|
|
|
172
182
|
---
|
|
173
183
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""On-disk cache for registry lookups.
|
|
2
|
+
|
|
3
|
+
Scanning a manifest costs one request per dependency, every time. A 200-line
|
|
4
|
+
requirements.txt in CI is 200 requests on every run, which is slow for the user
|
|
5
|
+
and rude to the registry.
|
|
6
|
+
|
|
7
|
+
Time-to-live depends on what was cached, and the important case is the negative
|
|
8
|
+
one. A name that does not exist today can be registered tomorrow -- that is the
|
|
9
|
+
entire attack this tool exists to catch -- so "does not exist" is held only
|
|
10
|
+
briefly. Established packages change slowly and are held far longer.
|
|
11
|
+
|
|
12
|
+
Cache failures are never fatal. An unwritable or corrupt cache degrades to no
|
|
13
|
+
cache at all rather than breaking a build.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import asdict
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .registries import PackageFacts
|
|
27
|
+
|
|
28
|
+
SCHEMA = 1
|
|
29
|
+
|
|
30
|
+
TTL_MISSING = 60 * 60 # 1 hour -- a free name can be taken any time
|
|
31
|
+
TTL_YOUNG = 6 * 60 * 60 # 6 hours -- facts still moving
|
|
32
|
+
TTL_ESTABLISHED = 24 * 60 * 60 # 1 day -- changes slowly
|
|
33
|
+
|
|
34
|
+
ESTABLISHED_DAYS = 365
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_dir() -> Path:
|
|
38
|
+
"""Per-platform cache directory, worked out by hand.
|
|
39
|
+
|
|
40
|
+
A dependency for this would contradict the zero-dependency rule for the
|
|
41
|
+
sake of three lines.
|
|
42
|
+
"""
|
|
43
|
+
override = os.environ.get("GHOSTPKG_CACHE_DIR")
|
|
44
|
+
if override:
|
|
45
|
+
return Path(override)
|
|
46
|
+
if sys.platform == "win32":
|
|
47
|
+
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
|
48
|
+
return Path(base) / "ghostpkg"
|
|
49
|
+
if sys.platform == "darwin":
|
|
50
|
+
return Path(os.path.expanduser("~/Library/Caches")) / "ghostpkg"
|
|
51
|
+
base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
|
|
52
|
+
return Path(base) / "ghostpkg"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ttl_for(facts: PackageFacts) -> int:
|
|
56
|
+
if not facts.exists:
|
|
57
|
+
return TTL_MISSING
|
|
58
|
+
if facts.age_days is not None and facts.age_days >= ESTABLISHED_DAYS:
|
|
59
|
+
return TTL_ESTABLISHED
|
|
60
|
+
return TTL_YOUNG
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Cache:
|
|
64
|
+
"""Read once, write once. Threads only read, so no locking is needed."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, directory: Path | None = None, enabled: bool = True) -> None:
|
|
67
|
+
self.enabled = enabled
|
|
68
|
+
self.directory = directory or default_dir()
|
|
69
|
+
self._entries: dict[str, dict] = {}
|
|
70
|
+
self._dirty = False
|
|
71
|
+
self.hits = 0
|
|
72
|
+
self.misses = 0
|
|
73
|
+
if self.enabled:
|
|
74
|
+
self._load()
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def path(self) -> Path:
|
|
78
|
+
return self.directory / "registry.json"
|
|
79
|
+
|
|
80
|
+
def _load(self) -> None:
|
|
81
|
+
try:
|
|
82
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
83
|
+
except (OSError, ValueError):
|
|
84
|
+
return
|
|
85
|
+
if not isinstance(raw, dict) or raw.get("schema") != SCHEMA:
|
|
86
|
+
return
|
|
87
|
+
entries = raw.get("entries")
|
|
88
|
+
if isinstance(entries, dict):
|
|
89
|
+
self._entries = entries
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _key(ecosystem: str, name: str) -> str:
|
|
93
|
+
return f"{ecosystem}:{name.lower()}"
|
|
94
|
+
|
|
95
|
+
def get(self, ecosystem: str, name: str) -> PackageFacts | None:
|
|
96
|
+
if not self.enabled:
|
|
97
|
+
return None
|
|
98
|
+
entry = self._entries.get(self._key(ecosystem, name))
|
|
99
|
+
if not isinstance(entry, dict):
|
|
100
|
+
self.misses += 1
|
|
101
|
+
return None
|
|
102
|
+
stored_at = entry.get("at")
|
|
103
|
+
ttl = entry.get("ttl")
|
|
104
|
+
facts = entry.get("facts")
|
|
105
|
+
if not isinstance(stored_at, (int, float)) or not isinstance(ttl, (int, float)):
|
|
106
|
+
self.misses += 1
|
|
107
|
+
return None
|
|
108
|
+
if time.time() - stored_at > ttl:
|
|
109
|
+
self.misses += 1
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
revived = PackageFacts(**facts)
|
|
113
|
+
except (TypeError, ValueError):
|
|
114
|
+
self.misses += 1
|
|
115
|
+
return None
|
|
116
|
+
self.hits += 1
|
|
117
|
+
# keep the caller's spelling of the name rather than the cached one
|
|
118
|
+
if revived.name != name:
|
|
119
|
+
revived = PackageFacts(**{**asdict(revived), "name": name})
|
|
120
|
+
return revived
|
|
121
|
+
|
|
122
|
+
def put(self, facts: PackageFacts) -> None:
|
|
123
|
+
if not self.enabled:
|
|
124
|
+
return
|
|
125
|
+
self._entries[self._key(facts.ecosystem, facts.name)] = {
|
|
126
|
+
"at": time.time(),
|
|
127
|
+
"ttl": ttl_for(facts),
|
|
128
|
+
"facts": asdict(facts),
|
|
129
|
+
}
|
|
130
|
+
self._dirty = True
|
|
131
|
+
|
|
132
|
+
def _prune(self) -> None:
|
|
133
|
+
now = time.time()
|
|
134
|
+
self._entries = {
|
|
135
|
+
key: entry
|
|
136
|
+
for key, entry in self._entries.items()
|
|
137
|
+
if isinstance(entry, dict)
|
|
138
|
+
and isinstance(entry.get("at"), (int, float))
|
|
139
|
+
and now - entry["at"] <= entry.get("ttl", 0)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
def save(self) -> None:
|
|
143
|
+
"""Write atomically. Any failure is silent -- a cache is a convenience."""
|
|
144
|
+
if not self.enabled or not self._dirty:
|
|
145
|
+
return
|
|
146
|
+
self._prune()
|
|
147
|
+
payload = {"schema": SCHEMA, "entries": self._entries}
|
|
148
|
+
try:
|
|
149
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
handle, temporary = tempfile.mkstemp(dir=str(self.directory), suffix=".tmp")
|
|
151
|
+
with os.fdopen(handle, "w", encoding="utf-8") as file:
|
|
152
|
+
json.dump(payload, file)
|
|
153
|
+
os.replace(temporary, self.path)
|
|
154
|
+
except OSError:
|
|
155
|
+
try:
|
|
156
|
+
os.unlink(temporary) # noqa: F821
|
|
157
|
+
except (OSError, NameError):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
def clear(self) -> bool:
|
|
161
|
+
try:
|
|
162
|
+
self.path.unlink()
|
|
163
|
+
return True
|
|
164
|
+
except OSError:
|
|
165
|
+
return False
|
|
@@ -11,6 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
|
|
12
12
|
from . import __version__
|
|
13
13
|
from .assess import Finding, Verdict, assess
|
|
14
|
+
from .cache import Cache
|
|
14
15
|
from .manifests import UnsupportedManifest, load_manifest
|
|
15
16
|
from .registries import RegistryError, fetch
|
|
16
17
|
|
|
@@ -89,12 +90,25 @@ def summarise(findings: list[Finding], palette: Palette) -> None:
|
|
|
89
90
|
print(palette.green(f" all {len(findings)} packages look fine"))
|
|
90
91
|
|
|
91
92
|
|
|
92
|
-
def evaluate(
|
|
93
|
+
def evaluate(
|
|
94
|
+
names: list[str],
|
|
95
|
+
ecosystem: str,
|
|
96
|
+
strict: bool,
|
|
97
|
+
cache: Cache | None = None,
|
|
98
|
+
) -> list[Finding]:
|
|
93
99
|
def one(name: str) -> Finding:
|
|
94
|
-
|
|
100
|
+
facts = cache.get(ecosystem, name) if cache else None
|
|
101
|
+
if facts is None:
|
|
102
|
+
facts = fetch(name, ecosystem)
|
|
103
|
+
if cache:
|
|
104
|
+
cache.put(facts)
|
|
105
|
+
return assess(facts, strict=strict)
|
|
95
106
|
|
|
96
107
|
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
|
97
|
-
|
|
108
|
+
findings = list(pool.map(one, names))
|
|
109
|
+
if cache:
|
|
110
|
+
cache.save()
|
|
111
|
+
return findings
|
|
98
112
|
|
|
99
113
|
|
|
100
114
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -124,6 +138,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
124
138
|
)
|
|
125
139
|
command.add_argument("--json", action="store_true", help="machine-readable output")
|
|
126
140
|
command.add_argument("-q", "--quiet", action="store_true", help="hide passing packages")
|
|
141
|
+
command.add_argument(
|
|
142
|
+
"--no-cache", action="store_true", help="ignore and do not write the cache"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
sub.add_parser("clear-cache", help="delete the cached registry lookups")
|
|
127
146
|
|
|
128
147
|
return parser
|
|
129
148
|
|
|
@@ -131,6 +150,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
131
150
|
def main(argv: list[str] | None = None) -> int:
|
|
132
151
|
args = build_parser().parse_args(argv)
|
|
133
152
|
|
|
153
|
+
if args.command == "clear-cache":
|
|
154
|
+
cache = Cache(enabled=True)
|
|
155
|
+
removed = cache.clear()
|
|
156
|
+
print(f"ghostpkg: {'removed ' + str(cache.path) if removed else 'nothing to remove'}")
|
|
157
|
+
return EXIT_OK
|
|
158
|
+
|
|
134
159
|
if args.command == "scan":
|
|
135
160
|
if not args.path.exists():
|
|
136
161
|
print(f"ghostpkg: no such file: {args.path}", file=sys.stderr)
|
|
@@ -149,8 +174,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
149
174
|
else:
|
|
150
175
|
names, ecosystem = args.names, args.ecosystem
|
|
151
176
|
|
|
177
|
+
cache = Cache(enabled=not args.no_cache)
|
|
152
178
|
try:
|
|
153
|
-
findings = evaluate(names, ecosystem, args.strict)
|
|
179
|
+
findings = evaluate(names, ecosystem, args.strict, cache)
|
|
154
180
|
except RegistryError as exc:
|
|
155
181
|
print(f"ghostpkg: {exc}", file=sys.stderr)
|
|
156
182
|
return EXIT_ERROR
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ghostpkg"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Catch package names that do not exist before you install them"
|
|
9
9
|
readme = "README.en.md" # English page for PyPI; the repo main page is Ukrainian
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Tests for the registry lookup cache.
|
|
2
|
+
|
|
3
|
+
The security-relevant part is the time-to-live on a negative result. A name that
|
|
4
|
+
does not exist today can be registered tomorrow, and that is precisely the attack
|
|
5
|
+
ghostpkg exists to catch, so "does not exist" must not be held for long.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from ghostpkg.cache import (
|
|
14
|
+
SCHEMA,
|
|
15
|
+
TTL_ESTABLISHED,
|
|
16
|
+
TTL_MISSING,
|
|
17
|
+
TTL_YOUNG,
|
|
18
|
+
Cache,
|
|
19
|
+
default_dir,
|
|
20
|
+
ttl_for,
|
|
21
|
+
)
|
|
22
|
+
from ghostpkg.registries import PackageFacts
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def facts(**overrides) -> PackageFacts:
|
|
26
|
+
base = dict(
|
|
27
|
+
name="example",
|
|
28
|
+
ecosystem="pypi",
|
|
29
|
+
exists=True,
|
|
30
|
+
age_days=2000,
|
|
31
|
+
release_count=25,
|
|
32
|
+
has_repo_url=True,
|
|
33
|
+
)
|
|
34
|
+
base.update(overrides)
|
|
35
|
+
return PackageFacts(**base)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def cache(tmp_path):
|
|
40
|
+
return Cache(directory=tmp_path, enabled=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestTimeToLive:
|
|
44
|
+
def test_missing_package_is_held_briefly(self):
|
|
45
|
+
"""The attack is registering a name that is free right now."""
|
|
46
|
+
assert ttl_for(facts(exists=False)) == TTL_MISSING
|
|
47
|
+
assert TTL_MISSING <= 60 * 60
|
|
48
|
+
|
|
49
|
+
def test_missing_is_shorter_than_anything_else(self):
|
|
50
|
+
assert TTL_MISSING < TTL_YOUNG < TTL_ESTABLISHED
|
|
51
|
+
|
|
52
|
+
def test_established_package_is_held_longest(self):
|
|
53
|
+
assert ttl_for(facts(age_days=2000)) == TTL_ESTABLISHED
|
|
54
|
+
|
|
55
|
+
def test_young_package_gets_the_middle_ttl(self):
|
|
56
|
+
assert ttl_for(facts(age_days=10)) == TTL_YOUNG
|
|
57
|
+
|
|
58
|
+
def test_unknown_age_is_treated_as_young(self):
|
|
59
|
+
assert ttl_for(facts(age_days=None)) == TTL_YOUNG
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestRoundTrip:
|
|
63
|
+
def test_stores_and_returns_facts(self, cache):
|
|
64
|
+
cache.put(facts(name="requests"))
|
|
65
|
+
got = cache.get("pypi", "requests")
|
|
66
|
+
assert got is not None
|
|
67
|
+
assert got.name == "requests"
|
|
68
|
+
assert got.release_count == 25
|
|
69
|
+
|
|
70
|
+
def test_survives_save_and_reload(self, cache, tmp_path):
|
|
71
|
+
cache.put(facts(name="requests"))
|
|
72
|
+
cache.save()
|
|
73
|
+
assert Cache(directory=tmp_path).get("pypi", "requests") is not None
|
|
74
|
+
|
|
75
|
+
def test_lookup_is_case_insensitive(self, cache):
|
|
76
|
+
cache.put(facts(name="Requests"))
|
|
77
|
+
assert cache.get("pypi", "requests") is not None
|
|
78
|
+
|
|
79
|
+
def test_returned_name_keeps_the_callers_spelling(self, cache):
|
|
80
|
+
cache.put(facts(name="Requests"))
|
|
81
|
+
assert cache.get("pypi", "requests").name == "requests"
|
|
82
|
+
|
|
83
|
+
def test_ecosystems_are_separate(self, cache):
|
|
84
|
+
cache.put(facts(name="express", ecosystem="npm"))
|
|
85
|
+
assert cache.get("npm", "express") is not None
|
|
86
|
+
assert cache.get("pypi", "express") is None
|
|
87
|
+
|
|
88
|
+
def test_expired_entry_is_a_miss(self, cache, tmp_path):
|
|
89
|
+
cache.put(facts(name="requests", exists=False))
|
|
90
|
+
cache.save()
|
|
91
|
+
raw = json.loads((tmp_path / "registry.json").read_text(encoding="utf-8"))
|
|
92
|
+
for entry in raw["entries"].values():
|
|
93
|
+
entry["at"] = time.time() - (TTL_MISSING + 60)
|
|
94
|
+
(tmp_path / "registry.json").write_text(json.dumps(raw), encoding="utf-8")
|
|
95
|
+
assert Cache(directory=tmp_path).get("pypi", "requests") is None
|
|
96
|
+
|
|
97
|
+
def test_expired_entries_are_dropped_on_save(self, cache, tmp_path):
|
|
98
|
+
cache.put(facts(name="stale"))
|
|
99
|
+
for entry in cache._entries.values():
|
|
100
|
+
entry["at"] = time.time() - (TTL_ESTABLISHED + 60)
|
|
101
|
+
cache.put(facts(name="fresh"))
|
|
102
|
+
cache.save()
|
|
103
|
+
raw = json.loads((tmp_path / "registry.json").read_text(encoding="utf-8"))
|
|
104
|
+
assert "pypi:fresh" in raw["entries"]
|
|
105
|
+
assert "pypi:stale" not in raw["entries"]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestDegradation:
|
|
109
|
+
"""A cache is a convenience. It must never break a run."""
|
|
110
|
+
|
|
111
|
+
def test_disabled_cache_stores_nothing(self, tmp_path):
|
|
112
|
+
cache = Cache(directory=tmp_path, enabled=False)
|
|
113
|
+
cache.put(facts())
|
|
114
|
+
cache.save()
|
|
115
|
+
assert cache.get("pypi", "example") is None
|
|
116
|
+
assert not (tmp_path / "registry.json").exists()
|
|
117
|
+
|
|
118
|
+
def test_corrupt_file_is_ignored(self, tmp_path):
|
|
119
|
+
(tmp_path / "registry.json").write_text("not json at all", encoding="utf-8")
|
|
120
|
+
assert Cache(directory=tmp_path).get("pypi", "requests") is None
|
|
121
|
+
|
|
122
|
+
def test_wrong_schema_is_ignored(self, tmp_path):
|
|
123
|
+
(tmp_path / "registry.json").write_text(
|
|
124
|
+
json.dumps({"schema": SCHEMA + 99, "entries": {"pypi:x": {}}}),
|
|
125
|
+
encoding="utf-8",
|
|
126
|
+
)
|
|
127
|
+
assert Cache(directory=tmp_path).get("pypi", "x") is None
|
|
128
|
+
|
|
129
|
+
def test_malformed_entry_is_ignored(self, tmp_path):
|
|
130
|
+
(tmp_path / "registry.json").write_text(
|
|
131
|
+
json.dumps({"schema": SCHEMA, "entries": {"pypi:x": {"at": "soon"}}}),
|
|
132
|
+
encoding="utf-8",
|
|
133
|
+
)
|
|
134
|
+
assert Cache(directory=tmp_path).get("pypi", "x") is None
|
|
135
|
+
|
|
136
|
+
def test_unwritable_directory_does_not_raise(self, tmp_path):
|
|
137
|
+
blocked = tmp_path / "afile"
|
|
138
|
+
blocked.write_text("", encoding="utf-8")
|
|
139
|
+
cache = Cache(directory=blocked / "under-a-file")
|
|
140
|
+
cache.put(facts())
|
|
141
|
+
cache.save() # must not raise
|
|
142
|
+
|
|
143
|
+
def test_counts_hits_and_misses(self, cache):
|
|
144
|
+
cache.put(facts(name="requests"))
|
|
145
|
+
cache.get("pypi", "requests")
|
|
146
|
+
cache.get("pypi", "nothing-here")
|
|
147
|
+
assert cache.hits == 1
|
|
148
|
+
assert cache.misses == 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TestLocation:
|
|
152
|
+
def test_environment_override_wins(self, monkeypatch, tmp_path):
|
|
153
|
+
monkeypatch.setenv("GHOSTPKG_CACHE_DIR", str(tmp_path / "custom"))
|
|
154
|
+
assert default_dir() == tmp_path / "custom"
|
|
155
|
+
|
|
156
|
+
def test_default_is_platform_specific(self, monkeypatch):
|
|
157
|
+
monkeypatch.delenv("GHOSTPKG_CACHE_DIR", raising=False)
|
|
158
|
+
assert default_dir().name == "ghostpkg"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|