ghostpkg 0.5.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.5.0 → ghostpkg-0.7.0}/CHANGELOG.md +98 -1
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/PKG-INFO +2 -2
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/README.en.md +1 -1
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/README.md +1 -1
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/__init__.py +1 -1
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/assess.py +25 -3
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/cache.py +39 -10
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/cli.py +43 -15
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/inspection.py +14 -1
- ghostpkg-0.7.0/ghostpkg/manifests.py +294 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/registries.py +27 -7
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/pyproject.toml +1 -1
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/tests/test_assess.py +58 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/tests/test_inspection.py +2 -0
- ghostpkg-0.7.0/tests/test_regressions.py +244 -0
- ghostpkg-0.5.0/ghostpkg/manifests.py +0 -193
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.gitignore +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/LICENSE +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/SECURITY.md +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/assets/banner.html +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/assets/banner.png +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/assets/demo.gif +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/assets/make_demo.py +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/data.py +0 -0
- {ghostpkg-0.5.0 → ghostpkg-0.7.0}/tests/test_cache.py +0 -0
- {ghostpkg-0.5.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,101 @@ 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
|
+
|
|
69
|
+
## [0.6.0] - 2026-09-01
|
|
70
|
+
|
|
71
|
+
### Fixed
|
|
72
|
+
- **Typo detection missed parked lookalikes, because age was the wrong gate.**
|
|
73
|
+
The check only ran on packages published within the last year, on the
|
|
74
|
+
reasoning that "an old lookalike is just a package with a similar name". That
|
|
75
|
+
reasoning was wrong. `expresss` has sat on npm since **2016** with one
|
|
76
|
+
release, no repository link, and roughly **2,500 downloads a month** arriving
|
|
77
|
+
purely from other people's typos. Ten years old, and waved straight through.
|
|
78
|
+
|
|
79
|
+
### Changed
|
|
80
|
+
- The typo check now also runs on packages that look **abandoned**: two or fewer
|
|
81
|
+
releases *and* no repository link, at any age.
|
|
82
|
+
|
|
83
|
+
The pairing is not a guess. Measured against 120 real packages that sit within
|
|
84
|
+
the typo budget of a popular name:
|
|
85
|
+
|
|
86
|
+
| Rule | Wrong about |
|
|
87
|
+
|---|---|
|
|
88
|
+
| Two or fewer releases, alone | 10.0% |
|
|
89
|
+
| No repository link, alone | 5.8% |
|
|
90
|
+
| **Both together** | **0.0%** |
|
|
91
|
+
|
|
92
|
+
Either condition alone is too loose because sibling packages in a family sit
|
|
93
|
+
naturally close together — `dagster-k8s` is two edits from `dagster-aws`, and
|
|
94
|
+
`pulumi-tls` from `pulumi-aws`. Those are maintained, so they carry releases
|
|
95
|
+
and a repository, and requiring both conditions leaves them alone.
|
|
96
|
+
|
|
97
|
+
- Warning text now says which condition fired, rather than calling a
|
|
98
|
+
ten-year-old package "recently published".
|
|
99
|
+
|
|
100
|
+
### Note
|
|
101
|
+
A defensively parked name still passes, which is correct: npm holds `lodahs`
|
|
102
|
+
itself and points it at `npm/security-holder`, so it has a repository link.
|
|
103
|
+
|
|
9
104
|
## [0.5.0] - 2026-09-01
|
|
10
105
|
|
|
11
106
|
### Added
|
|
@@ -149,7 +244,9 @@ First release.
|
|
|
149
244
|
- No corpus of hallucinated package names is shipped, following the decision of
|
|
150
245
|
the USENIX'25 authors not to publish theirs.
|
|
151
246
|
|
|
152
|
-
[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
|
|
249
|
+
[0.6.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.6.0
|
|
153
250
|
[0.5.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.5.0
|
|
154
251
|
[0.4.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.4.0
|
|
155
252
|
[0.3.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.3.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
|
|
@@ -238,7 +238,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
|
|
|
238
238
|
| First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
|
|
239
239
|
| Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
|
|
240
240
|
| No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
|
|
241
|
-
| 1–2 edits from a popular name, **and**
|
|
241
|
+
| 1–2 edits from a popular name, **and** the package is either recent **or** abandoned (≤2 releases and no repository link) | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age alone was the wrong gate: `expresss` has sat on npm since 2016 with one release and ~2,500 typo-driven downloads a month. |
|
|
242
242
|
|
|
243
243
|
Warnings are advisory by default. Nothing but non-existence blocks unless you pass
|
|
244
244
|
`--strict`.
|
|
@@ -191,7 +191,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
|
|
|
191
191
|
| First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
|
|
192
192
|
| Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
|
|
193
193
|
| No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
|
|
194
|
-
| 1–2 edits from a popular name, **and**
|
|
194
|
+
| 1–2 edits from a popular name, **and** the package is either recent **or** abandoned (≤2 releases and no repository link) | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age alone was the wrong gate: `expresss` has sat on npm since 2016 with one release and ~2,500 typo-driven downloads a month. |
|
|
195
195
|
|
|
196
196
|
Warnings are advisory by default. Nothing but non-existence blocks unless you pass
|
|
197
197
|
`--strict`.
|
|
@@ -118,7 +118,7 @@ ghostpkg clear-cache
|
|
|
118
118
|
| Опубліковано днями тому | 🟡 Попередження. Зловмисники реєструють швидко — але й чесні автори теж. |
|
|
119
119
|
| Лише один реліз | 🟡 Попередження. |
|
|
120
120
|
| Немає посилання на репозиторій | 🟡 Попередження. |
|
|
121
|
-
| За один-два символи від популярної
|
|
121
|
+
| За один-два символи від популярної назви, **і** пакет свіжий **або** занедбаний (≤2 релізи й немає репозиторію) | 🟡 Попередження. Схоже на typosquat. |
|
|
122
122
|
|
|
123
123
|
---
|
|
124
124
|
|
|
@@ -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`.
|
|
@@ -173,13 +181,27 @@ def assess(
|
|
|
173
181
|
|
|
174
182
|
is_young = facts.age_days is not None and facts.age_days < NEW_DAYS
|
|
175
183
|
|
|
176
|
-
|
|
184
|
+
# A parked lookalike is not necessarily new. `expresss` has sat on npm since
|
|
185
|
+
# 2016 with one release, no repository link, and roughly 2,500 downloads a
|
|
186
|
+
# month arriving purely from other people's typos. Age was the wrong gate.
|
|
187
|
+
#
|
|
188
|
+
# Abandonment is the right one, but only as a pair. Measured against 120 real
|
|
189
|
+
# packages that sit within the typo budget of a popular name, "few releases"
|
|
190
|
+
# alone was wrong 10% of the time and "no repository link" alone 5.8%, while
|
|
191
|
+
# requiring both was wrong 0% of the time. That matters because sibling
|
|
192
|
+
# packages in a family are naturally close together -- dagster-k8s is two
|
|
193
|
+
# edits from dagster-aws, pulumi-tls from pulumi-aws -- and they are
|
|
194
|
+
# maintained, so they carry releases and a repository.
|
|
195
|
+
looks_abandoned = facts.release_count <= 2 and not facts.has_repo_url
|
|
196
|
+
|
|
197
|
+
if is_young or looks_abandoned:
|
|
177
198
|
neighbour = nearest_popular(facts.name, facts.ecosystem)
|
|
178
199
|
if neighbour is not None:
|
|
179
200
|
popular_name, distance = neighbour
|
|
201
|
+
character = "character" if distance == 1 else "characters"
|
|
202
|
+
context = "recently published" if is_young else "one release, no repository"
|
|
180
203
|
reasons.append(
|
|
181
|
-
f"{distance} character
|
|
182
|
-
f"'{popular_name}', and recently published"
|
|
204
|
+
f"{distance} {character} away from '{popular_name}', and {context}"
|
|
183
205
|
)
|
|
184
206
|
|
|
185
207
|
if is_young and facts.release_count <= 1:
|
|
@@ -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
|