ghostpkg 0.1.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.3.0/CHANGELOG.md +93 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/PKG-INFO +18 -11
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/README.en.md +17 -10
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/README.md +10 -6
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/ghostpkg/__init__.py +1 -1
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/ghostpkg/assess.py +53 -12
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/ghostpkg/cli.py +12 -34
- ghostpkg-0.3.0/ghostpkg/data.py +820 -0
- ghostpkg-0.3.0/ghostpkg/manifests.py +193 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/pyproject.toml +1 -1
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/tests/test_assess.py +63 -0
- ghostpkg-0.3.0/tests/test_manifests.py +187 -0
- ghostpkg-0.1.0/CHANGELOG.md +0 -39
- ghostpkg-0.1.0/ghostpkg/data.py +0 -399
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.gitignore +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/LICENSE +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/SECURITY.md +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/assets/banner.html +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/assets/banner.png +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/assets/demo.gif +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/assets/make_demo.py +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.1.0 → ghostpkg-0.3.0}/ghostpkg/registries.py +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
|
5
|
+
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
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
|
+
|
|
36
|
+
## [0.2.0] - 2026-09-01
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
- **`scan` reported nonsense for `pyproject.toml`.** Every file that was not
|
|
40
|
+
`package.json` was handed to the requirements.txt parser, so TOML keys were
|
|
41
|
+
read as package names: `build-backend` came back "does not exist on pypi",
|
|
42
|
+
and `version` came back "ok" because a package of that name exists. For a
|
|
43
|
+
security tool, confidently reporting nonsense is the worst failure mode.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- `pyproject.toml` support in `scan`: PEP 621 `project.dependencies` and
|
|
47
|
+
`project.optional-dependencies`, plus Poetry's `tool.poetry` groups.
|
|
48
|
+
`[build-system] requires` is excluded, and Poetry's `python` constraint is
|
|
49
|
+
not treated as a package.
|
|
50
|
+
- Manifests are now detected explicitly. An unrecognised file is **refused with
|
|
51
|
+
an error rather than guessed at** — guessing was the root cause of the bug
|
|
52
|
+
above.
|
|
53
|
+
- A narrow TOML fallback parser for Python 3.9 and 3.10, which have no
|
|
54
|
+
`tomllib`. Adding a TOML dependency would contradict the zero-dependency rule,
|
|
55
|
+
and the fallback is tested by forcing the import to fail rather than by
|
|
56
|
+
trusting the interpreter the suite runs on.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
- `--version` now reads `__version__` instead of a hardcoded string.
|
|
60
|
+
|
|
61
|
+
## [0.1.0] - 2026-09-01
|
|
62
|
+
|
|
63
|
+
First release.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
- `ghostpkg check` — check one or more package names against PyPI or npm.
|
|
67
|
+
- `ghostpkg scan` — check every dependency in `requirements.txt` or `package.json`.
|
|
68
|
+
- Verdicts: `BLOCK` for names absent from the registry, `WARN` for weak signals
|
|
69
|
+
(recent publication, single release, missing repository link, small edit
|
|
70
|
+
distance to a popular name), `OK` otherwise.
|
|
71
|
+
- `--strict` to promote warnings to blocks, `--json` for machine-readable output,
|
|
72
|
+
`-q/--quiet` to hide passing packages.
|
|
73
|
+
- Exit codes: `0` nothing blocked, `1` something blocked, `2` usage or registry
|
|
74
|
+
error.
|
|
75
|
+
- Bundled list of the 2,000 most-downloaded PyPI projects, used only for typo
|
|
76
|
+
distance.
|
|
77
|
+
|
|
78
|
+
### Notes on the design
|
|
79
|
+
- The default profile blocks on **non-existence only**. An earlier version scored
|
|
80
|
+
packages and blocked anything suspicious; measured against the live feed of
|
|
81
|
+
newly published PyPI packages, it flagged 100% of legitimate same-day
|
|
82
|
+
publications. Age cannot separate a malicious registration from an honest new
|
|
83
|
+
release, so softer signals are warnings.
|
|
84
|
+
- The typo-distance budget scales with name length. A flat budget flagged `flask`,
|
|
85
|
+
`click` and `black` as typos of one another.
|
|
86
|
+
- Zero runtime dependencies, by policy.
|
|
87
|
+
- No corpus of hallucinated package names is shipped, following the decision of
|
|
88
|
+
the USENIX'25 authors not to publish theirs.
|
|
89
|
+
|
|
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
|
|
92
|
+
[0.2.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.2.0
|
|
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
|
|
@@ -59,8 +59,8 @@ Language models invent library names. Attackers register those names in advance.
|
|
|
59
59
|
**English** · [Українська](https://github.com/M1rwana12/ghostpkg/blob/main/README.md)
|
|
60
60
|
|
|
61
61
|
[](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
|
|
62
|
-
[](https://pypi.org/project/ghostpkg/)
|
|
63
|
+
[](https://pypi.org/project/ghostpkg/)
|
|
64
64
|
[](https://github.com/M1rwana12/ghostpkg/blob/main/pyproject.toml)
|
|
65
65
|
[](https://github.com/M1rwana12/ghostpkg/blob/main/LICENSE)
|
|
66
66
|
|
|
@@ -158,12 +158,19 @@ ghostpkg check react-router-dom-utils -e npm
|
|
|
158
158
|
|
|
159
159
|
```bash
|
|
160
160
|
ghostpkg scan requirements.txt
|
|
161
|
+
ghostpkg scan pyproject.toml
|
|
161
162
|
ghostpkg scan package.json
|
|
162
163
|
```
|
|
163
164
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
| Manifest | What is read |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `requirements*.txt` | Every requirement line. Comments, flags, VCS URLs and direct links are skipped. |
|
|
168
|
+
| `pyproject.toml` | PEP 621 `project.dependencies` and `project.optional-dependencies`, plus Poetry's `tool.poetry` groups. `[build-system] requires` is not included, and the `python` constraint is not treated as a package. |
|
|
169
|
+
| `package.json` | `dependencies`, `devDependencies`, `optionalDependencies`. |
|
|
170
|
+
|
|
171
|
+
Anything else is **refused with an error rather than guessed at**. Guessing is
|
|
172
|
+
what made an earlier version read `pyproject.toml` with the requirements parser
|
|
173
|
+
and report TOML keys as package names.
|
|
167
174
|
|
|
168
175
|
### Options
|
|
169
176
|
|
|
@@ -210,7 +217,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
|
|
|
210
217
|
| First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
|
|
211
218
|
| Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
|
|
212
219
|
| No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
|
|
213
|
-
| 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. |
|
|
214
221
|
|
|
215
222
|
Warnings are advisory by default. Nothing but non-existence blocks unless you pass
|
|
216
223
|
`--strict`.
|
|
@@ -346,10 +353,10 @@ earns its keep. Use both.
|
|
|
346
353
|
> that stand between you and it, and they are advisory. Improving this is the main
|
|
347
354
|
> open problem — see [issues](https://github.com/M1rwana12/ghostpkg/issues).
|
|
348
355
|
|
|
349
|
-
- Typo detection compares against the 2,000 most-downloaded
|
|
350
|
-
on a less popular package won't be flagged as a lookalike.
|
|
351
|
-
-
|
|
352
|
-
|
|
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.
|
|
353
360
|
- Every check is a live registry request. There is no caching yet.
|
|
354
361
|
- Registry outages surface as exit code `2` rather than a silent pass — deliberately,
|
|
355
362
|
but it does mean a flaky network fails your build.
|
|
@@ -12,8 +12,8 @@ Language models invent library names. Attackers register those names in advance.
|
|
|
12
12
|
**English** · [Українська](https://github.com/M1rwana12/ghostpkg/blob/main/README.md)
|
|
13
13
|
|
|
14
14
|
[](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
|
|
15
|
-
[](https://pypi.org/project/ghostpkg/)
|
|
16
|
+
[](https://pypi.org/project/ghostpkg/)
|
|
17
17
|
[](https://github.com/M1rwana12/ghostpkg/blob/main/pyproject.toml)
|
|
18
18
|
[](https://github.com/M1rwana12/ghostpkg/blob/main/LICENSE)
|
|
19
19
|
|
|
@@ -111,12 +111,19 @@ ghostpkg check react-router-dom-utils -e npm
|
|
|
111
111
|
|
|
112
112
|
```bash
|
|
113
113
|
ghostpkg scan requirements.txt
|
|
114
|
+
ghostpkg scan pyproject.toml
|
|
114
115
|
ghostpkg scan package.json
|
|
115
116
|
```
|
|
116
117
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
| Manifest | What is read |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `requirements*.txt` | Every requirement line. Comments, flags, VCS URLs and direct links are skipped. |
|
|
121
|
+
| `pyproject.toml` | PEP 621 `project.dependencies` and `project.optional-dependencies`, plus Poetry's `tool.poetry` groups. `[build-system] requires` is not included, and the `python` constraint is not treated as a package. |
|
|
122
|
+
| `package.json` | `dependencies`, `devDependencies`, `optionalDependencies`. |
|
|
123
|
+
|
|
124
|
+
Anything else is **refused with an error rather than guessed at**. Guessing is
|
|
125
|
+
what made an earlier version read `pyproject.toml` with the requirements parser
|
|
126
|
+
and report TOML keys as package names.
|
|
120
127
|
|
|
121
128
|
### Options
|
|
122
129
|
|
|
@@ -163,7 +170,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
|
|
|
163
170
|
| First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
|
|
164
171
|
| Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
|
|
165
172
|
| No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
|
|
166
|
-
| 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. |
|
|
167
174
|
|
|
168
175
|
Warnings are advisory by default. Nothing but non-existence blocks unless you pass
|
|
169
176
|
`--strict`.
|
|
@@ -299,10 +306,10 @@ earns its keep. Use both.
|
|
|
299
306
|
> that stand between you and it, and they are advisory. Improving this is the main
|
|
300
307
|
> open problem — see [issues](https://github.com/M1rwana12/ghostpkg/issues).
|
|
301
308
|
|
|
302
|
-
- Typo detection compares against the 2,000 most-downloaded
|
|
303
|
-
on a less popular package won't be flagged as a lookalike.
|
|
304
|
-
-
|
|
305
|
-
|
|
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.
|
|
306
313
|
- Every check is a live registry request. There is no caching yet.
|
|
307
314
|
- Registry outages surface as exit code `2` rather than a silent pass — deliberately,
|
|
308
315
|
but it does mean a flaky network fails your build.
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
[English](README.en.md) · **Українська**
|
|
13
13
|
|
|
14
14
|
[](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
|
|
15
|
-
[](https://pypi.org/project/ghostpkg/)
|
|
16
|
+
[](https://pypi.org/project/ghostpkg/)
|
|
17
17
|
[](pyproject.toml)
|
|
18
18
|
[](LICENSE)
|
|
19
19
|
|
|
@@ -81,6 +81,7 @@ ghostpkg check react-router-dom-utils -e npm
|
|
|
81
81
|
|
|
82
82
|
# перевірити всі залежності з маніфесту
|
|
83
83
|
ghostpkg scan requirements.txt
|
|
84
|
+
ghostpkg scan pyproject.toml
|
|
84
85
|
ghostpkg scan package.json
|
|
85
86
|
|
|
86
87
|
# машинозчитуваний вивід
|
|
@@ -94,6 +95,9 @@ ghostpkg check somepkg --json
|
|
|
94
95
|
| `--json` | Вивід у JSON для скриптів і CI |
|
|
95
96
|
| `-q`, `--quiet` | Ховає пакети, які пройшли перевірку |
|
|
96
97
|
|
|
98
|
+
`scan` розпізнає `requirements*.txt`, `pyproject.toml` (PEP 621 і Poetry)
|
|
99
|
+
та `package.json`. Невідомий формат він **відхиляє з помилкою, а не вгадує**.
|
|
100
|
+
|
|
97
101
|
---
|
|
98
102
|
|
|
99
103
|
## Що саме перевіряється
|
|
@@ -159,10 +163,10 @@ $ ghostpkg check react-router-dom-utils -e npm
|
|
|
159
163
|
> попередження, і вони дорадчі. Це головна відкрита проблема —
|
|
160
164
|
> див. [issues](https://github.com/M1rwana12/ghostpkg/issues).
|
|
161
165
|
|
|
162
|
-
- Виявлення опечаток порівнює з 2 000 найпопулярніших проєктів
|
|
163
|
-
під менш популярний пакет як схожа назва не позначиться.
|
|
164
|
-
-
|
|
165
|
-
|
|
166
|
+
- Виявлення опечаток порівнює з 2 000 найпопулярніших проєктів кожної екосистеми,
|
|
167
|
+
тому підробка під менш популярний пакет як схожа назва не позначиться.
|
|
168
|
+
- Імена коротші за 5 символів не порівнюються: там простір назв надто щільний,
|
|
169
|
+
щоб відстань редагування щось означала.
|
|
166
170
|
- Кожна перевірка — це живий запит до реєстру. Кешування ще немає.
|
|
167
171
|
|
|
168
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(
|
|
@@ -6,11 +6,12 @@ import argparse
|
|
|
6
6
|
import concurrent.futures
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
|
-
import re
|
|
10
9
|
import sys
|
|
11
10
|
from pathlib import Path
|
|
12
11
|
|
|
12
|
+
from . import __version__
|
|
13
13
|
from .assess import Finding, Verdict, assess
|
|
14
|
+
from .manifests import UnsupportedManifest, load_manifest
|
|
14
15
|
from .registries import RegistryError, fetch
|
|
15
16
|
|
|
16
17
|
EXIT_OK = 0
|
|
@@ -96,42 +97,14 @@ def evaluate(names: list[str], ecosystem: str, strict: bool) -> list[Finding]:
|
|
|
96
97
|
return list(pool.map(one, names))
|
|
97
98
|
|
|
98
99
|
|
|
99
|
-
REQUIREMENT_LINE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def parse_requirements(text: str) -> list[str]:
|
|
103
|
-
names = []
|
|
104
|
-
for raw in text.splitlines():
|
|
105
|
-
line = raw.strip()
|
|
106
|
-
if not line or line.startswith(("#", "-", "git+", "http")):
|
|
107
|
-
continue
|
|
108
|
-
match = REQUIREMENT_LINE.match(line)
|
|
109
|
-
if match:
|
|
110
|
-
names.append(match.group(1))
|
|
111
|
-
return names
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def parse_package_json(text: str) -> list[str]:
|
|
115
|
-
data = json.loads(text)
|
|
116
|
-
names: list[str] = []
|
|
117
|
-
for key in ("dependencies", "devDependencies", "optionalDependencies"):
|
|
118
|
-
names.extend((data.get(key) or {}).keys())
|
|
119
|
-
return names
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def load_manifest(path: Path) -> tuple[list[str], str]:
|
|
123
|
-
text = path.read_text(encoding="utf-8")
|
|
124
|
-
if path.name == "package.json":
|
|
125
|
-
return parse_package_json(text), "npm"
|
|
126
|
-
return parse_requirements(text), "pypi"
|
|
127
|
-
|
|
128
|
-
|
|
129
100
|
def build_parser() -> argparse.ArgumentParser:
|
|
130
101
|
parser = argparse.ArgumentParser(
|
|
131
102
|
prog="ghostpkg",
|
|
132
103
|
description="Catch package names that do not exist before you install them.",
|
|
133
104
|
)
|
|
134
|
-
parser.add_argument(
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--version", action="version", version=f"ghostpkg {__version__}"
|
|
107
|
+
)
|
|
135
108
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
136
109
|
|
|
137
110
|
check = sub.add_parser("check", help="check one or more package names")
|
|
@@ -139,7 +112,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
139
112
|
check.add_argument("-e", "--ecosystem", choices=("pypi", "npm"), default="pypi")
|
|
140
113
|
|
|
141
114
|
scan = sub.add_parser("scan", help="check every dependency in a manifest")
|
|
142
|
-
scan.add_argument(
|
|
115
|
+
scan.add_argument(
|
|
116
|
+
"path", type=Path, help="requirements*.txt, pyproject.toml or package.json"
|
|
117
|
+
)
|
|
143
118
|
|
|
144
119
|
for command in (check, scan):
|
|
145
120
|
command.add_argument(
|
|
@@ -162,7 +137,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
162
137
|
return EXIT_ERROR
|
|
163
138
|
try:
|
|
164
139
|
names, ecosystem = load_manifest(args.path)
|
|
165
|
-
except
|
|
140
|
+
except UnsupportedManifest as exc:
|
|
141
|
+
print(f"ghostpkg: {exc}", file=sys.stderr)
|
|
142
|
+
return EXIT_ERROR
|
|
143
|
+
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
|
|
166
144
|
print(f"ghostpkg: could not parse {args.path}: {exc}", file=sys.stderr)
|
|
167
145
|
return EXIT_ERROR
|
|
168
146
|
if not names:
|