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.
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/CHANGELOG.md +29 -1
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/PKG-INFO +6 -6
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/README.en.md +5 -5
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/README.md +4 -4
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/__init__.py +1 -1
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/assess.py +53 -12
- ghostpkg-0.3.0/ghostpkg/data.py +820 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/pyproject.toml +1 -1
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/tests/test_assess.py +63 -0
- ghostpkg-0.2.0/ghostpkg/data.py +0 -399
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/.gitignore +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/LICENSE +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/SECURITY.md +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/assets/banner.html +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/assets/banner.png +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/assets/demo.gif +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/assets/make_demo.py +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/cli.py +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/manifests.py +0 -0
- {ghostpkg-0.2.0 → ghostpkg-0.3.0}/ghostpkg/registries.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
|
357
|
-
on a less popular package won't be flagged as a lookalike.
|
|
358
|
-
-
|
|
359
|
-
|
|
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
|
|
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
|
|
310
|
-
on a less popular package won't be flagged as a lookalike.
|
|
311
|
-
-
|
|
312
|
-
|
|
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 найпопулярніших проєктів
|
|
167
|
-
під менш популярний пакет як схожа назва не позначиться.
|
|
168
|
-
-
|
|
169
|
-
|
|
166
|
+
- Виявлення опечаток порівнює з 2 000 найпопулярніших проєктів кожної екосистеми,
|
|
167
|
+
тому підробка під менш популярний пакет як схожа назва не позначиться.
|
|
168
|
+
- Імена коротші за 5 символів не порівнюються: там простір назв надто щільний,
|
|
169
|
+
щоб відстань редагування щось означала.
|
|
170
170
|
- Кожна перевірка — це живий запит до реєстру. Кешування ще немає.
|
|
171
171
|
|
|
172
172
|
---
|
|
@@ -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
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
127
|
+
if abs(len(candidate) - len(target)) > budget:
|
|
87
128
|
continue
|
|
88
|
-
distance = edit_distance(
|
|
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(
|