ghostpkg 0.2.0__tar.gz → 0.3.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 (28) hide show
  1. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/CHANGELOG.md +29 -1
  2. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/PKG-INFO +6 -6
  3. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/README.en.md +5 -5
  4. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/README.md +4 -4
  5. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/__init__.py +1 -1
  6. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/assess.py +53 -12
  7. ghostpkg-0.3.0/ghostpkg/data.py +820 -0
  8. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/pyproject.toml +1 -1
  9. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/tests/test_assess.py +63 -0
  10. ghostpkg-0.2.0/ghostpkg/data.py +0 -399
  11. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  12. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  13. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  14. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  15. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.github/workflows/ci.yml +0 -0
  16. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.gitignore +0 -0
  17. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/CONTRIBUTING.md +0 -0
  18. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/LICENSE +0 -0
  19. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/SECURITY.md +0 -0
  20. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/assets/banner.html +0 -0
  21. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/assets/banner.png +0 -0
  22. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/assets/demo.gif +0 -0
  23. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/assets/make_demo.py +0 -0
  24. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/__main__.py +0 -0
  25. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/cli.py +0 -0
  26. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/manifests.py +0 -0
  27. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/registries.py +0 -0
  28. {ghostpkg-0.2.0 → ghostpkg-0.3.0}/tests/test_manifests.py +0 -0
@@ -6,6 +6,33 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-09-01
10
+
11
+ ### Fixed
12
+ - **npm typo detection did not work at all.** Lookalike names were compared
13
+ against the 2,000 most-downloaded *PyPI* projects regardless of ecosystem, so
14
+ `expresss` was never flagged as a typo of `express` -- `express` was not in
15
+ the list being compared against.
16
+
17
+ ### Added
18
+ - A list of the 2,000 most-downloaded npm packages, built from the registry's
19
+ own download-count API. `nearest_popular()` now picks the list matching the
20
+ ecosystem.
21
+ - Scoped npm names are compared on the part after the slash, since that is what
22
+ a squat targets: `@evil/expresss` is flagged, `@types/node` and `@babel/core`
23
+ are not.
24
+
25
+ ### Changed
26
+ - Typo distance is now Damerau-Levenshtein: swapping two adjacent characters
27
+ counts as one edit rather than two. Transposition is the commonest typosquat
28
+ shape, and under plain Levenshtein `recat`, `lodahs` and `webpakc` all scored
29
+ two edits, which put them outside the budget for names that short. All three
30
+ are caught now, and the false-positive rate across both 2,000-name lists is
31
+ still zero -- there are tests asserting exactly that.
32
+ - Names shorter than five characters are no longer compared at all. Below that
33
+ the name space is too dense for edit distance to mean anything: `core` sits
34
+ one edit from `cors`.
35
+
9
36
  ## [0.2.0] - 2026-09-01
10
37
 
11
38
  ### Fixed
@@ -60,6 +87,7 @@ First release.
60
87
  - No corpus of hallucinated package names is shipped, following the decision of
61
88
  the USENIX'25 authors not to publish theirs.
62
89
 
63
- [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.2.0...HEAD
90
+ [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.3.0...HEAD
91
+ [0.3.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.3.0
64
92
  [0.2.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.2.0
65
93
  [0.1.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ghostpkg
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -217,7 +217,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
217
217
  | First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
218
218
  | Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
219
219
  | No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
220
- | 1–2 characters from a popular name, **and** recently published | 🟡 Warning | Classic typosquat shape. Age matters: an old lookalike is just a package with a similar name. |
220
+ | 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. |
221
221
 
222
222
  Warnings are advisory by default. Nothing but non-existence blocks unless you pass
223
223
  `--strict`.
@@ -353,10 +353,10 @@ earns its keep. Use both.
353
353
  > that stand between you and it, and they are advisory. Improving this is the main
354
354
  > open problem — see [issues](https://github.com/M1rwana12/ghostpkg/issues).
355
355
 
356
- - Typo detection compares against the 2,000 most-downloaded PyPI projects, so a squat
357
- on a less popular package won't be flagged as a lookalike.
358
- - npm scoped packages (`@scope/name`) are checked, but the popular-name list is
359
- PyPI-derived, so npm typosquat detection is weaker.
356
+ - Typo detection compares against the 2,000 most-downloaded projects in each
357
+ ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
358
+ - Names shorter than five characters are not compared at all: below that the name
359
+ space is too dense for edit distance to mean anything.
360
360
  - Every check is a live registry request. There is no caching yet.
361
361
  - Registry outages surface as exit code `2` rather than a silent pass — deliberately,
362
362
  but it does mean a flaky network fails your build.
@@ -170,7 +170,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
170
170
  | First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
171
171
  | Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
172
172
  | No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
173
- | 1–2 characters from a popular name, **and** recently published | 🟡 Warning | Classic typosquat shape. Age matters: an old lookalike is just a package with a similar name. |
173
+ | 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. |
174
174
 
175
175
  Warnings are advisory by default. Nothing but non-existence blocks unless you pass
176
176
  `--strict`.
@@ -306,10 +306,10 @@ earns its keep. Use both.
306
306
  > that stand between you and it, and they are advisory. Improving this is the main
307
307
  > open problem — see [issues](https://github.com/M1rwana12/ghostpkg/issues).
308
308
 
309
- - Typo detection compares against the 2,000 most-downloaded PyPI projects, so a squat
310
- on a less popular package won't be flagged as a lookalike.
311
- - npm scoped packages (`@scope/name`) are checked, but the popular-name list is
312
- PyPI-derived, so npm typosquat detection is weaker.
309
+ - Typo detection compares against the 2,000 most-downloaded projects in each
310
+ ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
311
+ - Names shorter than five characters are not compared at all: below that the name
312
+ space is too dense for edit distance to mean anything.
313
313
  - Every check is a live registry request. There is no caching yet.
314
314
  - Registry outages surface as exit code `2` rather than a silent pass — deliberately,
315
315
  but it does mean a flaky network fails your build.
@@ -163,10 +163,10 @@ $ ghostpkg check react-router-dom-utils -e npm
163
163
  > попередження, і вони дорадчі. Це головна відкрита проблема —
164
164
  > див. [issues](https://github.com/M1rwana12/ghostpkg/issues).
165
165
 
166
- - Виявлення опечаток порівнює з 2 000 найпопулярніших проєктів PyPI, тому підробка
167
- під менш популярний пакет як схожа назва не позначиться.
168
- - Пакети npm з областю (`@scope/name`) перевіряються, але список популярних назв
169
- побудований на PyPI — тож для npm виявлення опечаток слабше.
166
+ - Виявлення опечаток порівнює з 2 000 найпопулярніших проєктів кожної екосистеми,
167
+ тому підробка під менш популярний пакет як схожа назва не позначиться.
168
+ - Імена коротші за 5 символів не порівнюються: там простір назв надто щільний,
169
+ щоб відстань редагування щось означала.
170
170
  - Кожна перевірка — це живий запит до реєстру. Кешування ще немає.
171
171
 
172
172
  ---
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -18,7 +18,7 @@ from __future__ import annotations
18
18
  from dataclasses import dataclass, field
19
19
  from enum import Enum
20
20
 
21
- from .data import TOP_PYPI
21
+ from .data import TOP_NPM, TOP_PYPI
22
22
  from .registries import PackageFacts
23
23
 
24
24
  YOUNG_DAYS = 90
@@ -45,25 +45,44 @@ class Finding:
45
45
 
46
46
 
47
47
  def edit_distance(left: str, right: str, cutoff: int = 3) -> int:
48
- """Levenshtein distance, abandoning early once it exceeds `cutoff`."""
48
+ """Damerau-Levenshtein distance, abandoning early once it exceeds `cutoff`.
49
+
50
+ Swapping two adjacent characters counts as one edit, not two. That matters
51
+ here more than it looks: transposition is the commonest typosquat shape --
52
+ `recat` for `react`, `lodahs` for `lodash`, `webpakc` for `webpack`. Plain
53
+ Levenshtein scores all three as two edits, which put them outside the budget
54
+ for names of that length and let every one of them through.
55
+ """
49
56
  if left == right:
50
57
  return 0
51
58
  if abs(len(left) - len(right)) > cutoff:
52
59
  return cutoff + 1
53
60
 
61
+ before_previous: list[int] = []
54
62
  previous = list(range(len(right) + 1))
55
63
  for i, a in enumerate(left, 1):
56
64
  current = [i]
57
65
  for j, b in enumerate(right, 1):
58
- current.append(
59
- min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a != b))
60
- )
66
+ cost = min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a != b))
67
+ if (
68
+ i > 1
69
+ and j > 1
70
+ and a == right[j - 2]
71
+ and left[i - 2] == b
72
+ ):
73
+ cost = min(cost, before_previous[j - 2] + 1)
74
+ current.append(cost)
61
75
  if min(current) > cutoff:
62
76
  return cutoff + 1
63
- previous = current
77
+ before_previous, previous = previous, current
64
78
  return previous[-1]
65
79
 
66
80
 
81
+ POPULAR: dict[str, frozenset[str]] = {"pypi": TOP_PYPI, "npm": TOP_NPM}
82
+
83
+ MIN_COMPARABLE_LENGTH = 5
84
+
85
+
67
86
  def _typo_budget(name: str) -> int:
68
87
  """How many edits still count as a plausible typo of a popular name.
69
88
 
@@ -74,18 +93,40 @@ def _typo_budget(name: str) -> int:
74
93
  return 2 if len(name) >= 10 else 1
75
94
 
76
95
 
77
- def nearest_popular(name: str, popular: frozenset[str] = TOP_PYPI) -> tuple[str, int] | None:
96
+ def _comparable(name: str, ecosystem: str) -> str:
97
+ """The part of a name worth comparing.
98
+
99
+ An npm squat on a scoped package targets the part after the slash, since
100
+ the scope is usually owned by whoever it names.
101
+ """
102
+ lowered = name.lower()
103
+ if ecosystem == "npm" and lowered.startswith("@") and "/" in lowered:
104
+ return lowered.rsplit("/", 1)[1]
105
+ return lowered
106
+
107
+
108
+ def nearest_popular(name: str, ecosystem: str = "pypi") -> tuple[str, int] | None:
78
109
  """Closest popular package name within the typo budget, if any."""
110
+ popular = POPULAR.get(ecosystem)
111
+ if not popular:
112
+ return None
113
+
79
114
  lowered = name.lower()
80
- if lowered in popular:
115
+ target = _comparable(name, ecosystem)
116
+
117
+ if lowered in popular or target in popular:
118
+ return None
119
+ # Below this length the name space is too dense for edit distance to mean
120
+ # anything: 'core' sits one edit from 'cors'.
121
+ if len(target) < MIN_COMPARABLE_LENGTH:
81
122
  return None
82
123
 
83
- budget = _typo_budget(lowered)
124
+ budget = _typo_budget(target)
84
125
  best: tuple[str, int] | None = None
85
126
  for candidate in popular:
86
- if abs(len(candidate) - len(lowered)) > budget:
127
+ if abs(len(candidate) - len(target)) > budget:
87
128
  continue
88
- distance = edit_distance(lowered, candidate, cutoff=budget)
129
+ distance = edit_distance(target, candidate, cutoff=budget)
89
130
  if 0 < distance <= budget and (best is None or distance < best[1]):
90
131
  best = (candidate, distance)
91
132
  if distance == 1:
@@ -114,7 +155,7 @@ def assess(facts: PackageFacts, strict: bool = False) -> Finding:
114
155
  is_young = facts.age_days is not None and facts.age_days < NEW_DAYS
115
156
 
116
157
  if is_young:
117
- neighbour = nearest_popular(facts.name)
158
+ neighbour = nearest_popular(facts.name, facts.ecosystem)
118
159
  if neighbour is not None:
119
160
  popular_name, distance = neighbour
120
161
  reasons.append(