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.
Files changed (34) hide show
  1. ghostpkg-0.7.0/2.0 +1 -0
  2. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/CHANGELOG.md +98 -1
  3. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/PKG-INFO +2 -2
  4. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/README.en.md +1 -1
  5. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/README.md +1 -1
  6. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/__init__.py +1 -1
  7. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/assess.py +25 -3
  8. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/cache.py +39 -10
  9. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/cli.py +43 -15
  10. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/inspection.py +14 -1
  11. ghostpkg-0.7.0/ghostpkg/manifests.py +294 -0
  12. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/registries.py +27 -7
  13. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/pyproject.toml +1 -1
  14. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/tests/test_assess.py +58 -0
  15. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/tests/test_inspection.py +2 -0
  16. ghostpkg-0.7.0/tests/test_regressions.py +244 -0
  17. ghostpkg-0.5.0/ghostpkg/manifests.py +0 -193
  18. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  19. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  20. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  21. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  22. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.github/workflows/ci.yml +0 -0
  23. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/.gitignore +0 -0
  24. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/CONTRIBUTING.md +0 -0
  25. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/LICENSE +0 -0
  26. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/SECURITY.md +0 -0
  27. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/assets/banner.html +0 -0
  28. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/assets/banner.png +0 -0
  29. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/assets/demo.gif +0 -0
  30. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/assets/make_demo.py +0 -0
  31. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/__main__.py +0 -0
  32. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/ghostpkg/data.py +0 -0
  33. {ghostpkg-0.5.0 → ghostpkg-0.7.0}/tests/test_cache.py +0 -0
  34. {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.5.0...HEAD
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.5.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** recently published | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age matters: an old lookalike is just a package with a similar name. |
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** recently published | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age matters: an old lookalike is just a package with a similar name. |
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
- | За один-два символи від популярної назви **і** свіжий | 🟡 Попередження. Схоже на typosquat. |
121
+ | За один-два символи від популярної назви, **і** пакет свіжий **або** занедбаний (≤2 релізи й немає репозиторію) | 🟡 Попередження. Схоже на typosquat. |
122
122
 
123
123
  ---
124
124
 
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.0"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -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
- if is_young:
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{'s' if distance > 1 else ''} away from "
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
- """Read once, write once. Threads only read, so no locking is needed."""
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
- return f"{ecosystem}:{name.lower()}"
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
- entry = self._entries.get(self._key(ecosystem, name))
119
+ with self._lock:
120
+ entry = self._entries.get(self._key(ecosystem, name))
99
121
  if not isinstance(entry, dict):
100
- self.misses += 1
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.misses += 1
128
+ self._miss()
107
129
  return None
108
130
  if time.time() - stored_at > ttl:
109
- self.misses += 1
131
+ self._miss()
110
132
  return None
111
133
  try:
112
134
  revived = PackageFacts(**facts)
113
135
  except (TypeError, ValueError):
114
- self.misses += 1
136
+ self._miss()
115
137
  return None
116
- self.hits += 1
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
- self._entries[self._key(facts.ecosystem, facts.name)] = {
152
+ entry = {
126
153
  "at": time.time(),
127
154
  "ttl": ttl_for(facts),
128
155
  "facts": asdict(facts),
129
156
  }
130
- self._dirty = True
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
- facts = cache.get(ecosystem, name) if cache else None
103
- if facts is None:
104
- facts = fetch(name, ecosystem)
105
- if cache:
106
- cache.put(facts)
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
- findings = list(pool.map(one, names))
149
+ results = dict(zip(unique, pool.map(one, unique)))
129
150
  if cache:
130
151
  cache.save()
131
- return findings
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
- try:
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
- return EXIT_BLOCKED if any(f.is_blocked for f in findings) else EXIT_OK
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
- name = path.rsplit("/", 1)[-1]
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