ghostpkg 0.19.0__tar.gz → 0.19.2__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.19.0 → ghostpkg-0.19.2}/CHANGELOG.md +74 -1
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/PKG-INFO +73 -4
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/README.en.md +72 -3
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/README.md +60 -1
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/__init__.py +1 -1
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/assess.py +26 -3
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/jslocks.py +156 -156
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/manifests.py +684 -664
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/policy.py +181 -181
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/prose.py +204 -204
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/pyproject.toml +1 -1
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_assess.py +36 -0
- ghostpkg-0.19.2/tests/test_hostile_input.py +140 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/ghostpkg-ignore.json +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/workflows/publish.yml +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.gitignore +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.pre-commit-hooks.yaml +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/2.0 +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/LICENSE +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/SECURITY.md +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/action.yml +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/assets/banner.html +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/assets/banner.png +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/assets/demo.gif +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/assets/make_demo.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/cache.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/cli.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/data.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/discover.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/inspection.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/registries.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/report.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/rules.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/scanner.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_action.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_cache.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_discover.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_inspection.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_jslocks.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_lockfiles.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_manifests.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_policy.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_prose.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_registries.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_regressions.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_report.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_stated_sources.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_versions.py +0 -0
- {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_withdrawn.py +0 -0
|
@@ -6,6 +6,77 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.19.2] - 2026-09-02
|
|
10
|
+
|
|
11
|
+
Found by throwing hostile input at the parsers rather than by reading them.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **A byte-order mark broke seven of the eight manifest formats, four of them
|
|
15
|
+
silently.** A file saved by Notepad, PowerShell's `Out-File`, or an editor set
|
|
16
|
+
to "UTF-8 with BOM" starts with U+FEFF. `pyproject.toml`, `package.json` and
|
|
17
|
+
`package-lock.json` raised a parse error; `poetry.lock`, `pnpm-lock.yaml` and
|
|
18
|
+
prose files returned **zero** packages; `requirements.txt` dropped only its
|
|
19
|
+
first line and the run then reported that everything looked fine. Files are
|
|
20
|
+
now read as `utf-8-sig`, which strips the mark when present and is identical
|
|
21
|
+
to `utf-8` when it is not, and every parser that takes text strips it too.
|
|
22
|
+
- **An empty dependency key is no longer treated as a package name.**
|
|
23
|
+
`{"dependencies": {"": "^1"}}` produced an empty name, and the npm client then
|
|
24
|
+
requested `https://registry.npmjs.org/` -- the registry root -- and read
|
|
25
|
+
whatever came back as facts about it.
|
|
26
|
+
- **`===` is recognised as a pin.** PEP 440 arbitrary equality is the only way
|
|
27
|
+
to name a version that does not normalise, and such a pin was never checked.
|
|
28
|
+
- **A different spelling of a popular name is not a typo of it.** PyPI treats
|
|
29
|
+
`-`, `_` and `.` as one separator, so `typing_extensions` and
|
|
30
|
+
`typing-extensions` are the same project; comparing the raw spelling against
|
|
31
|
+
the popular-name list missed the match and made the package a one-edit typo of
|
|
32
|
+
itself. Unreachable in the product -- PyPI resolves both spellings, so the
|
|
33
|
+
name always exists -- but `nearest_popular` is public and the reasoning cost
|
|
34
|
+
more to reconstruct than the fix. npm names are still compared as written,
|
|
35
|
+
because `JSONStream` and `jsonstream` are two different real packages there.
|
|
36
|
+
|
|
37
|
+
### Considered and rejected
|
|
38
|
+
- **Reading `setup.py` with `ast`, without executing it.** Attractive on the
|
|
39
|
+
raw count -- 1.4M repositories have one. Measured across sixteen well-known
|
|
40
|
+
Python projects: **zero** had an `install_requires` an AST could read. Eight
|
|
41
|
+
have no `setup.py` at all, five have one with no `install_requires`, and three
|
|
42
|
+
compute it at runtime. The parser would read nothing on real projects.
|
|
43
|
+
- **Auditing the installed environment.** The idea was to catch a package that
|
|
44
|
+
was removed from the registry after it was installed. Across 103 distributions
|
|
45
|
+
in four environments, **none** was missing upstream, so the case cannot be
|
|
46
|
+
verified -- and everything else such a command could report is the soft
|
|
47
|
+
signals that already measured 100% false on age alone.
|
|
48
|
+
|
|
49
|
+
## [0.19.1] - 2026-09-02
|
|
50
|
+
|
|
51
|
+
### Documentation
|
|
52
|
+
Both READMEs had fallen behind the tool. The PyPI page is the English one, so
|
|
53
|
+
this is what people read before installing anything.
|
|
54
|
+
|
|
55
|
+
- **New section: monorepos and private packages.** The rule that a dependency
|
|
56
|
+
naming its own source is not the registry's business arrived in 0.15.0 and was
|
|
57
|
+
never written down -- so a monorepo user had no way to know it works. A table
|
|
58
|
+
now names every form: `workspace:`, `catalog:`, `file:`, `link:`, `portal:`,
|
|
59
|
+
git and URL specifiers, `npm:` aliases, `[tool.uv.sources]`, Poetry
|
|
60
|
+
`{ git = ... }`, `[package.source]`, and workspace members in a lockfile.
|
|
61
|
+
Private registries are covered too, with the reason they are left alone.
|
|
62
|
+
- **New section: measured, and not built.** Seven ideas that were prototyped,
|
|
63
|
+
measured and dropped, each with its number: the 100% false-positive scoring
|
|
64
|
+
design, `os.environ` at 37%, the age gate at 2.54%, cached negatives, PEP 740
|
|
65
|
+
attestations at a 5% overlap, npm `deprecated` at 5.78%, and the hallucinated-name
|
|
66
|
+
corpus rejected on principle. What a security tool refuses to do says more
|
|
67
|
+
about its judgement than its feature list.
|
|
68
|
+
- The signals table documents the `did you mean ...?` suggestion, and the
|
|
69
|
+
examples show the file and line a finding came from.
|
|
70
|
+
- The manifest table now states what `pyproject.toml` skips via
|
|
71
|
+
`[tool.uv.sources]`, what `poetry.lock` and `uv.lock` skip via their source
|
|
72
|
+
fields, and that prose extraction reads commands written inside a sentence.
|
|
73
|
+
- Prose numbers updated to the current measurement: 18 names across 22 READMEs,
|
|
74
|
+
0% that do not exist.
|
|
75
|
+
|
|
76
|
+
Rendering was checked with `readme_renderer`, the library PyPI itself uses:
|
|
77
|
+
41,576 characters, 11 tables, no relative links and no images that resolve only
|
|
78
|
+
on GitHub.
|
|
79
|
+
|
|
9
80
|
## [0.19.0] - 2026-09-02
|
|
10
81
|
|
|
11
82
|
Both of these came out of an acceptance pass against the published package
|
|
@@ -662,7 +733,9 @@ First release.
|
|
|
662
733
|
- No corpus of hallucinated package names is shipped, following the decision of
|
|
663
734
|
the USENIX'25 authors not to publish theirs.
|
|
664
735
|
|
|
665
|
-
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.19.
|
|
736
|
+
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.19.2...HEAD
|
|
737
|
+
[0.19.2]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.19.2
|
|
738
|
+
[0.19.1]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.19.1
|
|
666
739
|
[0.19.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.19.0
|
|
667
740
|
[0.18.1]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.18.1
|
|
668
741
|
[0.18.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.18.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ghostpkg
|
|
3
|
-
Version: 0.19.
|
|
3
|
+
Version: 0.19.2
|
|
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
|
|
@@ -75,10 +75,12 @@ Language models invent library names. Attackers register those names in advance.
|
|
|
75
75
|
- [What ghostpkg does](#what-ghostpkg-does)
|
|
76
76
|
- [Install](#install)
|
|
77
77
|
- [Usage](#usage)
|
|
78
|
+
- [Monorepos and private packages](#monorepos-and-private-packages)
|
|
78
79
|
- [Every signal it checks](#every-signal-it-checks)
|
|
79
80
|
- [Why it doesn't block on "suspicious"](#why-it-doesnt-block-on-suspicious)
|
|
80
81
|
- [Where it fits in your workflow](#where-it-fits-in-your-workflow)
|
|
81
82
|
- [How it works internally](#how-it-works-internally)
|
|
83
|
+
- [Measured, and not built](#measured-and-not-built)
|
|
82
84
|
- [Comparison](#comparison)
|
|
83
85
|
- [Honest limitations](#honest-limitations)
|
|
84
86
|
- [Prior art and credit](#prior-art-and-credit)
|
|
@@ -167,13 +169,13 @@ ghostpkg scan package.json
|
|
|
167
169
|
| Manifest | What is read |
|
|
168
170
|
|---|---|
|
|
169
171
|
| `requirements*.txt` | Every requirement line. Comments, flags, VCS URLs and direct links are skipped. |
|
|
170
|
-
| `pyproject.toml` | PEP 621 `project.dependencies` and `project.optional-dependencies`,
|
|
172
|
+
| `pyproject.toml` | PEP 621 `project.dependencies` and `project.optional-dependencies`, PEP 735 `dependency-groups`, and Poetry's `tool.poetry` groups. `[build-system] requires` is not included, the `python` constraint is not treated as a package, and anything redirected by `[tool.uv.sources]` is left alone. |
|
|
171
173
|
| `package.json` | `dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`. |
|
|
172
174
|
| `package-lock.json` | Every locked package, both the v1 nested and v2/v3 `packages` layouts. Workspace members and anything resolved from git, a path or a private registry are skipped. |
|
|
173
175
|
| `yarn.lock` | Every locked package, classic (v1) and berry (v2+). Aliases are checked under the name that actually gets installed. |
|
|
174
176
|
| `pnpm-lock.yaml` | Every locked package, lockfile versions 5, 6 and 9. |
|
|
175
|
-
| `poetry.lock`, `uv.lock` | Every locked package. |
|
|
176
|
-
| `README`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `*.md` | Package names out of install commands written in prose. |
|
|
177
|
+
| `poetry.lock`, `uv.lock` | Every locked package. Poetry's `[package.source]` and uv's `source = { git = ... }` mean the entry came from somewhere other than the index, so it is skipped. |
|
|
178
|
+
| `README`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `*.md` | Package names out of install commands written in prose — including commands written inside a sentence, in a backtick span. |
|
|
177
179
|
|
|
178
180
|
**Why prose matters:** the hallucination arrives before the manifest does. A
|
|
179
181
|
model writes `pip install foo-bar` into a README or an agent instruction file,
|
|
@@ -215,6 +217,36 @@ Anything else is **refused with an error rather than guessed at**. Guessing is
|
|
|
215
217
|
what made an earlier version read `pyproject.toml` with the requirements parser
|
|
216
218
|
and report TOML keys as package names.
|
|
217
219
|
|
|
220
|
+
### Monorepos and private packages
|
|
221
|
+
|
|
222
|
+
A dependency that **states where it comes from** is not the public registry's
|
|
223
|
+
business, and `ghostpkg` does not look it up. Without that rule a monorepo is a
|
|
224
|
+
wall of false blocks — measured on one real `package.json`, **six entries out of
|
|
225
|
+
nine**.
|
|
226
|
+
|
|
227
|
+
| Written as | What happens |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `"@acme/ui": "workspace:*"`, `"catalog:default"` | Skipped — resolved inside the repository |
|
|
230
|
+
| `"lib": "file:../lib"`, `"link:"`, `"portal:"`, `"../sibling"` | Skipped — a directory on disk |
|
|
231
|
+
| `"forked": "git+https://..."`, `"owner/repo"`, `"github:owner/repo"` | Skipped — a git host |
|
|
232
|
+
| `"dep": "https://.../x.tgz"` | Skipped — a URL |
|
|
233
|
+
| `"ui": "npm:@scope/real@^2"` | Checked as **`@scope/real`** — an alias installs a different name than the key |
|
|
234
|
+
| `internal @ git+https://...` in a requirements file | Skipped |
|
|
235
|
+
| `[tool.uv.sources]`, Poetry `{ git = ... }` / `{ path = ... }` / `{ workspace = true }` | Skipped |
|
|
236
|
+
| `[package.source]` in `poetry.lock`, `source = { git = ... }` in `uv.lock` | Skipped |
|
|
237
|
+
| A `package-lock.json` entry that is a workspace member, or resolved from git | Skipped |
|
|
238
|
+
|
|
239
|
+
The same rule covers a **private registry**. Artifactory, Nexus and Verdaccio
|
|
240
|
+
proxy public names *and* host private ones under the same host, and a lockfile
|
|
241
|
+
does not say which is which — so those entries are left alone rather than
|
|
242
|
+
guessed at. Known public mirrors (`registry.yarnpkg.com`,
|
|
243
|
+
`registry.npmmirror.com`) serve the public namespace, so they are still checked.
|
|
244
|
+
|
|
245
|
+
> This was found by pointing `ghostpkg` at `pydantic`'s own `pyproject.toml`,
|
|
246
|
+
> which it blocked. `pydantic-docs` is declared in a dependency group and
|
|
247
|
+
> redirected to a git repository by `[tool.uv.sources]`; it is not on PyPI, and
|
|
248
|
+
> reporting that as a missing dependency was simply wrong.
|
|
249
|
+
|
|
218
250
|
### Options
|
|
219
251
|
|
|
220
252
|
| Flag | Purpose |
|
|
@@ -328,6 +360,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
|
|
|
328
360
|
| Signal | Verdict | Why |
|
|
329
361
|
|---|---|---|
|
|
330
362
|
| Not in the registry | 🔴 **Blocked** | The name is a ghost. There is nothing to install, and this is exactly what a hallucination looks like. |
|
|
363
|
+
| ...and it is one or two edits from a popular name | 🔴 Blocked, **with a suggestion** | `did you mean requests?` The age gate that guards this comparison elsewhere exists to stop a legitimate published package being called a typo — a name that does not exist has none of that to protect and is already blocked, so naming the likely intent can only help. Correct on 11 of 11 plausible typos; silent on 6 of 6 invented names. |
|
|
331
364
|
| The registry confiscated this name over malware | 🔴 **Blocked** | npm replaces such a name with a placeholder it owns rather than deleting it, so the name still resolves — which is why this used to come back "ok". `crossenv` and `ffmepg` are real examples. |
|
|
332
365
|
| A pinned version the maintainer withdrew | 🟡 Warning | Reported with the reason they gave. It warns rather than blocks because pip installs a yanked version when it is pinned explicitly. PyPI only: npm's `deprecated` covers 5.78% of versions and 160 of `glob`'s 168, so it is noise. |
|
|
333
366
|
| A pinned version that does not exist | 🔴 **Blocked** | `requests==99.99.99`. A model invents versions as readily as names, and the registry lists every real one, so this is a lookup rather than a heuristic. Only exact pins are checked — a range like `>=2.31` or `^4.18.0` may be satisfied by some other version. |
|
|
@@ -368,6 +401,17 @@ $ ghostpkg check react-router-dom-utils -e npm
|
|
|
368
401
|
- no repository or homepage link
|
|
369
402
|
```
|
|
370
403
|
|
|
404
|
+
When the name does not exist at all, the block comes with the likely intent
|
|
405
|
+
attached, and — in a scan — the file and line it was written on:
|
|
406
|
+
|
|
407
|
+
```console
|
|
408
|
+
$ ghostpkg scan requirements.txt
|
|
409
|
+
|
|
410
|
+
BLOCKED reqeusts (requirements.txt:12)
|
|
411
|
+
- does not exist on pypi
|
|
412
|
+
- did you mean requests?
|
|
413
|
+
```
|
|
414
|
+
|
|
371
415
|
There is a second lesson baked into the code. A naive typosquat check using a flat
|
|
372
416
|
edit-distance budget flagged `flask`, `click` and `black` as typos **of each other** —
|
|
373
417
|
short popular names sit inherently close together. The budget now scales with name
|
|
@@ -518,6 +562,31 @@ be caught, and packages published without an sdist cannot be inspected at all.
|
|
|
518
562
|
|
|
519
563
|
---
|
|
520
564
|
|
|
565
|
+
## Measured, and not built
|
|
566
|
+
|
|
567
|
+
Each of these was proposed, prototyped, measured, and dropped. They are listed
|
|
568
|
+
because what a security tool **refuses** to do says more about its judgement
|
|
569
|
+
than its feature list does — every one of them would have looked reasonable in a
|
|
570
|
+
changelog.
|
|
571
|
+
|
|
572
|
+
| Idea | What the measurement showed | |
|
|
573
|
+
|---|---|---|
|
|
574
|
+
| Score packages by age and release count, block the suspicious ones | **100%** of legitimate same-day PyPI publications flagged | Rejected |
|
|
575
|
+
| Treat reading `os.environ` in an install script as a signal | **37%** of established packages do it | Rejected |
|
|
576
|
+
| Drop the age gate on typo detection | **2.54%** false positives; only the pair of conditions reaches 0% | Rejected |
|
|
577
|
+
| Cache "does not exist" for an hour | A **real false block** of a live package: PyPI's feed announces a name before the JSON API serves it | Negatives are never cached |
|
|
578
|
+
| PEP 740 attestations as a *suppressor* of warnings | Of 38 packages, 13 had an attestation and 8 lacked a repository link — **2 were in both groups** | 5% benefit for two extra requests. Rejected |
|
|
579
|
+
| npm's `deprecated` as an equivalent of PyPI's `yanked` | **5.78%** of all versions, and 160 of `glob`'s 168 | Noise. Rejected |
|
|
580
|
+
| Ship a corpus of known hallucinated names | — | A ready-made target list for attackers. Rejected on principle |
|
|
581
|
+
|
|
582
|
+
The attestation one is worth a sentence, because it was the most attractive
|
|
583
|
+
idea of the set: it would have *removed* warnings rather than adding signals.
|
|
584
|
+
It failed on the logic, not the numbers — an attestation proves **provenance,
|
|
585
|
+
not benevolence**. An attacker can publish a slopsquat through Trusted
|
|
586
|
+
Publishing from their own repository just as easily.
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
521
590
|
## Comparison
|
|
522
591
|
|
|
523
592
|
| | `ghostpkg` | SCA scanners (Snyk, Socket) | `pip install` alone |
|
|
@@ -27,10 +27,12 @@ Language models invent library names. Attackers register those names in advance.
|
|
|
27
27
|
- [What ghostpkg does](#what-ghostpkg-does)
|
|
28
28
|
- [Install](#install)
|
|
29
29
|
- [Usage](#usage)
|
|
30
|
+
- [Monorepos and private packages](#monorepos-and-private-packages)
|
|
30
31
|
- [Every signal it checks](#every-signal-it-checks)
|
|
31
32
|
- [Why it doesn't block on "suspicious"](#why-it-doesnt-block-on-suspicious)
|
|
32
33
|
- [Where it fits in your workflow](#where-it-fits-in-your-workflow)
|
|
33
34
|
- [How it works internally](#how-it-works-internally)
|
|
35
|
+
- [Measured, and not built](#measured-and-not-built)
|
|
34
36
|
- [Comparison](#comparison)
|
|
35
37
|
- [Honest limitations](#honest-limitations)
|
|
36
38
|
- [Prior art and credit](#prior-art-and-credit)
|
|
@@ -119,13 +121,13 @@ ghostpkg scan package.json
|
|
|
119
121
|
| Manifest | What is read |
|
|
120
122
|
|---|---|
|
|
121
123
|
| `requirements*.txt` | Every requirement line. Comments, flags, VCS URLs and direct links are skipped. |
|
|
122
|
-
| `pyproject.toml` | PEP 621 `project.dependencies` and `project.optional-dependencies`,
|
|
124
|
+
| `pyproject.toml` | PEP 621 `project.dependencies` and `project.optional-dependencies`, PEP 735 `dependency-groups`, and Poetry's `tool.poetry` groups. `[build-system] requires` is not included, the `python` constraint is not treated as a package, and anything redirected by `[tool.uv.sources]` is left alone. |
|
|
123
125
|
| `package.json` | `dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`. |
|
|
124
126
|
| `package-lock.json` | Every locked package, both the v1 nested and v2/v3 `packages` layouts. Workspace members and anything resolved from git, a path or a private registry are skipped. |
|
|
125
127
|
| `yarn.lock` | Every locked package, classic (v1) and berry (v2+). Aliases are checked under the name that actually gets installed. |
|
|
126
128
|
| `pnpm-lock.yaml` | Every locked package, lockfile versions 5, 6 and 9. |
|
|
127
|
-
| `poetry.lock`, `uv.lock` | Every locked package. |
|
|
128
|
-
| `README`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `*.md` | Package names out of install commands written in prose. |
|
|
129
|
+
| `poetry.lock`, `uv.lock` | Every locked package. Poetry's `[package.source]` and uv's `source = { git = ... }` mean the entry came from somewhere other than the index, so it is skipped. |
|
|
130
|
+
| `README`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `*.md` | Package names out of install commands written in prose — including commands written inside a sentence, in a backtick span. |
|
|
129
131
|
|
|
130
132
|
**Why prose matters:** the hallucination arrives before the manifest does. A
|
|
131
133
|
model writes `pip install foo-bar` into a README or an agent instruction file,
|
|
@@ -167,6 +169,36 @@ Anything else is **refused with an error rather than guessed at**. Guessing is
|
|
|
167
169
|
what made an earlier version read `pyproject.toml` with the requirements parser
|
|
168
170
|
and report TOML keys as package names.
|
|
169
171
|
|
|
172
|
+
### Monorepos and private packages
|
|
173
|
+
|
|
174
|
+
A dependency that **states where it comes from** is not the public registry's
|
|
175
|
+
business, and `ghostpkg` does not look it up. Without that rule a monorepo is a
|
|
176
|
+
wall of false blocks — measured on one real `package.json`, **six entries out of
|
|
177
|
+
nine**.
|
|
178
|
+
|
|
179
|
+
| Written as | What happens |
|
|
180
|
+
|---|---|
|
|
181
|
+
| `"@acme/ui": "workspace:*"`, `"catalog:default"` | Skipped — resolved inside the repository |
|
|
182
|
+
| `"lib": "file:../lib"`, `"link:"`, `"portal:"`, `"../sibling"` | Skipped — a directory on disk |
|
|
183
|
+
| `"forked": "git+https://..."`, `"owner/repo"`, `"github:owner/repo"` | Skipped — a git host |
|
|
184
|
+
| `"dep": "https://.../x.tgz"` | Skipped — a URL |
|
|
185
|
+
| `"ui": "npm:@scope/real@^2"` | Checked as **`@scope/real`** — an alias installs a different name than the key |
|
|
186
|
+
| `internal @ git+https://...` in a requirements file | Skipped |
|
|
187
|
+
| `[tool.uv.sources]`, Poetry `{ git = ... }` / `{ path = ... }` / `{ workspace = true }` | Skipped |
|
|
188
|
+
| `[package.source]` in `poetry.lock`, `source = { git = ... }` in `uv.lock` | Skipped |
|
|
189
|
+
| A `package-lock.json` entry that is a workspace member, or resolved from git | Skipped |
|
|
190
|
+
|
|
191
|
+
The same rule covers a **private registry**. Artifactory, Nexus and Verdaccio
|
|
192
|
+
proxy public names *and* host private ones under the same host, and a lockfile
|
|
193
|
+
does not say which is which — so those entries are left alone rather than
|
|
194
|
+
guessed at. Known public mirrors (`registry.yarnpkg.com`,
|
|
195
|
+
`registry.npmmirror.com`) serve the public namespace, so they are still checked.
|
|
196
|
+
|
|
197
|
+
> This was found by pointing `ghostpkg` at `pydantic`'s own `pyproject.toml`,
|
|
198
|
+
> which it blocked. `pydantic-docs` is declared in a dependency group and
|
|
199
|
+
> redirected to a git repository by `[tool.uv.sources]`; it is not on PyPI, and
|
|
200
|
+
> reporting that as a missing dependency was simply wrong.
|
|
201
|
+
|
|
170
202
|
### Options
|
|
171
203
|
|
|
172
204
|
| Flag | Purpose |
|
|
@@ -280,6 +312,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
|
|
|
280
312
|
| Signal | Verdict | Why |
|
|
281
313
|
|---|---|---|
|
|
282
314
|
| Not in the registry | 🔴 **Blocked** | The name is a ghost. There is nothing to install, and this is exactly what a hallucination looks like. |
|
|
315
|
+
| ...and it is one or two edits from a popular name | 🔴 Blocked, **with a suggestion** | `did you mean requests?` The age gate that guards this comparison elsewhere exists to stop a legitimate published package being called a typo — a name that does not exist has none of that to protect and is already blocked, so naming the likely intent can only help. Correct on 11 of 11 plausible typos; silent on 6 of 6 invented names. |
|
|
283
316
|
| The registry confiscated this name over malware | 🔴 **Blocked** | npm replaces such a name with a placeholder it owns rather than deleting it, so the name still resolves — which is why this used to come back "ok". `crossenv` and `ffmepg` are real examples. |
|
|
284
317
|
| A pinned version the maintainer withdrew | 🟡 Warning | Reported with the reason they gave. It warns rather than blocks because pip installs a yanked version when it is pinned explicitly. PyPI only: npm's `deprecated` covers 5.78% of versions and 160 of `glob`'s 168, so it is noise. |
|
|
285
318
|
| A pinned version that does not exist | 🔴 **Blocked** | `requests==99.99.99`. A model invents versions as readily as names, and the registry lists every real one, so this is a lookup rather than a heuristic. Only exact pins are checked — a range like `>=2.31` or `^4.18.0` may be satisfied by some other version. |
|
|
@@ -320,6 +353,17 @@ $ ghostpkg check react-router-dom-utils -e npm
|
|
|
320
353
|
- no repository or homepage link
|
|
321
354
|
```
|
|
322
355
|
|
|
356
|
+
When the name does not exist at all, the block comes with the likely intent
|
|
357
|
+
attached, and — in a scan — the file and line it was written on:
|
|
358
|
+
|
|
359
|
+
```console
|
|
360
|
+
$ ghostpkg scan requirements.txt
|
|
361
|
+
|
|
362
|
+
BLOCKED reqeusts (requirements.txt:12)
|
|
363
|
+
- does not exist on pypi
|
|
364
|
+
- did you mean requests?
|
|
365
|
+
```
|
|
366
|
+
|
|
323
367
|
There is a second lesson baked into the code. A naive typosquat check using a flat
|
|
324
368
|
edit-distance budget flagged `flask`, `click` and `black` as typos **of each other** —
|
|
325
369
|
short popular names sit inherently close together. The budget now scales with name
|
|
@@ -470,6 +514,31 @@ be caught, and packages published without an sdist cannot be inspected at all.
|
|
|
470
514
|
|
|
471
515
|
---
|
|
472
516
|
|
|
517
|
+
## Measured, and not built
|
|
518
|
+
|
|
519
|
+
Each of these was proposed, prototyped, measured, and dropped. They are listed
|
|
520
|
+
because what a security tool **refuses** to do says more about its judgement
|
|
521
|
+
than its feature list does — every one of them would have looked reasonable in a
|
|
522
|
+
changelog.
|
|
523
|
+
|
|
524
|
+
| Idea | What the measurement showed | |
|
|
525
|
+
|---|---|---|
|
|
526
|
+
| Score packages by age and release count, block the suspicious ones | **100%** of legitimate same-day PyPI publications flagged | Rejected |
|
|
527
|
+
| Treat reading `os.environ` in an install script as a signal | **37%** of established packages do it | Rejected |
|
|
528
|
+
| Drop the age gate on typo detection | **2.54%** false positives; only the pair of conditions reaches 0% | Rejected |
|
|
529
|
+
| Cache "does not exist" for an hour | A **real false block** of a live package: PyPI's feed announces a name before the JSON API serves it | Negatives are never cached |
|
|
530
|
+
| PEP 740 attestations as a *suppressor* of warnings | Of 38 packages, 13 had an attestation and 8 lacked a repository link — **2 were in both groups** | 5% benefit for two extra requests. Rejected |
|
|
531
|
+
| npm's `deprecated` as an equivalent of PyPI's `yanked` | **5.78%** of all versions, and 160 of `glob`'s 168 | Noise. Rejected |
|
|
532
|
+
| Ship a corpus of known hallucinated names | — | A ready-made target list for attackers. Rejected on principle |
|
|
533
|
+
|
|
534
|
+
The attestation one is worth a sentence, because it was the most attractive
|
|
535
|
+
idea of the set: it would have *removed* warnings rather than adding signals.
|
|
536
|
+
It failed on the logic, not the numbers — an attestation proves **provenance,
|
|
537
|
+
not benevolence**. An attacker can publish a slopsquat through Trusted
|
|
538
|
+
Publishing from their own repository just as easily.
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
473
542
|
## Comparison
|
|
474
543
|
|
|
475
544
|
| | `ghostpkg` | SCA scanners (Snyk, Socket) | `pip install` alone |
|
|
@@ -122,8 +122,12 @@ Lock-файли варто перевіряти навіть якщо ви вж
|
|
|
122
122
|
пише `pip install foo-bar` у README, людина копіює рядок — і встановлення вже відбулось,
|
|
123
123
|
коли назва тільки потрапляє в `requirements.txt`.
|
|
124
124
|
|
|
125
|
+
Читаються й команди, написані **всередині речення**, у зворотних лапках —
|
|
126
|
+
*«Install using `pip install -U pydantic`»*. Три з чотирнадцяти популярних README
|
|
127
|
+
пишуть їх лише так, і читання цілих рядків не знаходило в них нічого.
|
|
128
|
+
|
|
125
129
|
Витяг навмисно вузький, бо README повний слів, схожих на назви пакетів. Замір на
|
|
126
|
-
|
|
130
|
+
22 реальних README: **18 назв, хибних спрацювань 0%**.
|
|
127
131
|
|
|
128
132
|
Можна передати кілька файлів одразу, навіть із різних екосистем:
|
|
129
133
|
|
|
@@ -145,6 +149,37 @@ ghostpkg scan ../інший-проєкт
|
|
|
145
149
|
|
|
146
150
|
---
|
|
147
151
|
|
|
152
|
+
## Монорепо й приватні пакети
|
|
153
|
+
|
|
154
|
+
Залежність, яка **сама називає своє джерело**, публічного реєстру не стосується —
|
|
155
|
+
і `ghostpkg` її не шукає. Без цього правила монорепо перетворюється на стіну
|
|
156
|
+
хибних блокувань: замір на одному справжньому `package.json` дав **шість із дев'яти**.
|
|
157
|
+
|
|
158
|
+
| Як написано | Що відбувається |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `"@acme/ui": "workspace:*"`, `"catalog:default"` | Пропускаємо — вирішується всередині репозиторію |
|
|
161
|
+
| `"lib": "file:../lib"`, `"link:"`, `"portal:"`, `"../sibling"` | Пропускаємо — тека на диску |
|
|
162
|
+
| `"forked": "git+https://..."`, `"owner/repo"`, `"github:owner/repo"` | Пропускаємо — git-хост |
|
|
163
|
+
| `"dep": "https://.../x.tgz"` | Пропускаємо — URL |
|
|
164
|
+
| `"ui": "npm:@scope/real@^2"` | Перевіряємо **`@scope/real`** — псевдонім встановлює не те, що в ключі |
|
|
165
|
+
| `internal @ git+https://...` у requirements | Пропускаємо |
|
|
166
|
+
| `[tool.uv.sources]`, Poetry `{ git = ... }` / `{ path = ... }` | Пропускаємо |
|
|
167
|
+
| `[package.source]` у `poetry.lock`, `source = { git = ... }` в `uv.lock` | Пропускаємо |
|
|
168
|
+
| Учасник робочої області або git-джерело в `package-lock.json` | Пропускаємо |
|
|
169
|
+
|
|
170
|
+
Те саме правило закриває **приватний реєстр**. Artifactory, Nexus і Verdaccio
|
|
171
|
+
проксюють публічні імена **і** хостять власні під тим самим хостом, а lock-файл
|
|
172
|
+
не каже, що є що — тож такі записи ми лишаємо без судження, а не вгадуємо.
|
|
173
|
+
Публічні дзеркала (`registry.yarnpkg.com`, `registry.npmmirror.com`) перевіряємо
|
|
174
|
+
далі, бо вони віддають той самий публічний простір імен.
|
|
175
|
+
|
|
176
|
+
> Це знайшлося, коли `ghostpkg` навели на власний `pyproject.toml` проєкту
|
|
177
|
+
> `pydantic` — і він його заблокував. `pydantic-docs` оголошено в групі
|
|
178
|
+
> залежностей і перенаправлено на git через `[tool.uv.sources]`; на PyPI його
|
|
179
|
+
> немає, і називати це відсутньою залежністю було просто неправильно.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
148
183
|
## Куди це вбудовується
|
|
149
184
|
|
|
150
185
|
### У CI
|
|
@@ -187,6 +222,7 @@ repos:
|
|
|
187
222
|
| Сигнал | Вердикт |
|
|
188
223
|
|---|---|
|
|
189
224
|
| Немає в реєстрі | 🔴 **Заблоковано.** Назва — привид, встановлювати нічого. |
|
|
225
|
+
| ...і вона за один-два символи від популярної | 🔴 Заблоковано **з підказкою**: `did you mean requests?` Віковий фільтр, який стримує це порівняння в інших місцях, захищає легітимні пакети від звинувачення в опечатці — у неіснуючої назви захищати нічого, вона вже заблокована. Замір: правильно 11 з 11 на опечатках, мовчить 6 з 6 на вигаданих назвах. |
|
|
190
226
|
| Реєстр вилучив це ім'я через шкідливість | 🔴 **Заблоковано.** npm не видаляє таке ім'я, а замінює його заглушкою — тому воно досі відповідає, і раніше ми казали «ok». Приклади: `crossenv`, `ffmepg`. |
|
|
191
227
|
| Закріплену версію відкликав мейнтейнер | 🟡 Попередження з його ж поясненням. Не блокування: `pip` встановлює відкликану версію, якщо її закріпили явно. |
|
|
192
228
|
| Закріплена версія не існує (`requests==99.99.99`) | 🔴 **Заблоковано.** Вигадана версія — той самий клас помилки, що й вигадана назва. Діапазони (`>=2.31`, `^4.18`) не перевіряються: їх може задовольнити інша версія. |
|
|
@@ -291,6 +327,29 @@ ghostpkg scan requirements.txt --config ~/ghostpkg-ignore.json
|
|
|
291
327
|
|
|
292
328
|
---
|
|
293
329
|
|
|
330
|
+
## Заміряно — і не побудовано
|
|
331
|
+
|
|
332
|
+
Кожну з цих ідей запропоновано, зроблено прототип, заміряно й відкинуто. Вони тут
|
|
333
|
+
тому, що **відмови** інструменту безпеки кажуть про його судження більше, ніж
|
|
334
|
+
перелік можливостей: кожна з них добре виглядала б у чейнджлозі.
|
|
335
|
+
|
|
336
|
+
| Ідея | Що показав замір | |
|
|
337
|
+
|---|---|---|
|
|
338
|
+
| Оцінювати пакети за віком і кількістю релізів, блокувати підозрілі | **100%** легітимних публікацій того самого дня позначено | Відхилено |
|
|
339
|
+
| Вважати читання `os.environ` у скрипті встановлення сигналом | **37%** усталених пакетів це роблять | Відхилено |
|
|
340
|
+
| Прибрати віковий фільтр із детектора опечаток | **2,54%** хибних; нуля досягає лише пара умов | Відхилено |
|
|
341
|
+
| Кешувати «не існує» на годину | **Справжнє хибне блокування** живого пакета: стрічка PyPI оголошує ім'я раніше, ніж його віддає JSON API | Негативні відповіді не кешуються |
|
|
342
|
+
| Атестації PEP 740 як **гасник** попереджень | З 38 пакетів 13 мали атестацію, 8 не мали посилання на репозиторій — **в обох групах лише 2** | Користь 5% за два зайві запити. Відхилено |
|
|
343
|
+
| npm `deprecated` як аналог `yanked` у PyPI | **5,78%** усіх версій, і 160 зі 168 у `glob` | Шум. Відхилено |
|
|
344
|
+
| Постачати перелік відомих галюцинованих імен | — | Готовий перелік цілей для атакуючого. Відхилено принципово |
|
|
345
|
+
|
|
346
|
+
Про атестації варто окреме речення, бо це була найпривабливіша ідея набору: вона
|
|
347
|
+
**знімала** б попередження, а не додавала сигнали. Провалилась вона на логіці, не
|
|
348
|
+
на числах — атестація доводить **походження, а не добросовісність**. Зловмисник
|
|
349
|
+
так само може опублікувати слопсквот через Trusted Publishing зі свого репозиторію.
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
294
353
|
## Порівняння
|
|
295
354
|
|
|
296
355
|
| | `ghostpkg` | SCA-сканери (Snyk, Socket) | Просто `pip install` |
|
|
@@ -40,7 +40,10 @@ NEW_DAYS = 365
|
|
|
40
40
|
# An exact pin, and only an exact pin. `>=`, `^`, `~` and wildcards describe a
|
|
41
41
|
# range that the registry may satisfy with some other version, so there is
|
|
42
42
|
# nothing definite to check. `==1.2.3` either exists or it does not.
|
|
43
|
-
|
|
43
|
+
#: `===` is PEP 440 arbitrary equality -- a pin like any other, and the only
|
|
44
|
+
#: way to name a version that is not PEP 440 normalisable. Missing it meant
|
|
45
|
+
#: such a pin was never checked at all.
|
|
46
|
+
PYPI_PIN = re.compile(r"^\s*={2,3}\s*([A-Za-z0-9][A-Za-z0-9.\-+!]*)\s*$")
|
|
44
47
|
NPM_PIN = re.compile(r"^\s*v?(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.\-]+)?)\s*$")
|
|
45
48
|
|
|
46
49
|
|
|
@@ -48,6 +51,7 @@ def exact_pin(specifier: str | None, ecosystem: str) -> str | None:
|
|
|
48
51
|
"""The single version a specifier demands, if it demands exactly one."""
|
|
49
52
|
if not specifier:
|
|
50
53
|
return None
|
|
54
|
+
specifier = specifier.split(";", 1)[0]
|
|
51
55
|
pattern = NPM_PIN if ecosystem == "npm" else PYPI_PIN
|
|
52
56
|
match = pattern.match(specifier)
|
|
53
57
|
if not match:
|
|
@@ -152,13 +156,32 @@ def _typo_budget(name: str) -> int:
|
|
|
152
156
|
return 2 if len(name) >= 10 else 1
|
|
153
157
|
|
|
154
158
|
|
|
159
|
+
def _normalise(name: str, ecosystem: str) -> str:
|
|
160
|
+
"""The name as its own registry understands it.
|
|
161
|
+
|
|
162
|
+
PyPI treats `-`, `_` and `.` as one separator and ignores case (PEP 503),
|
|
163
|
+
so `typing_extensions` and `typing-extensions` are the same project. Without
|
|
164
|
+
this the popular-name list was missed on the underscore spelling and the
|
|
165
|
+
same package came back as a one-edit typo of itself. It is unreachable
|
|
166
|
+
today -- PyPI resolves both spellings, so such a name always exists and
|
|
167
|
+
never reaches the comparison -- but the function is public and the next
|
|
168
|
+
reader should not have to work that out.
|
|
169
|
+
|
|
170
|
+
npm does no such folding: `JSONStream` and `jsonstream` are two real and
|
|
171
|
+
different packages, so its names are compared as written.
|
|
172
|
+
"""
|
|
173
|
+
if ecosystem == "pypi":
|
|
174
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
175
|
+
return name.lower()
|
|
176
|
+
|
|
177
|
+
|
|
155
178
|
def _comparable(name: str, ecosystem: str) -> str:
|
|
156
179
|
"""The part of a name worth comparing.
|
|
157
180
|
|
|
158
181
|
An npm squat on a scoped package targets the part after the slash, since
|
|
159
182
|
the scope is usually owned by whoever it names.
|
|
160
183
|
"""
|
|
161
|
-
lowered = name
|
|
184
|
+
lowered = _normalise(name, ecosystem)
|
|
162
185
|
if ecosystem == "npm" and lowered.startswith("@") and "/" in lowered:
|
|
163
186
|
return lowered.rsplit("/", 1)[1]
|
|
164
187
|
return lowered
|
|
@@ -170,7 +193,7 @@ def nearest_popular(name: str, ecosystem: str = "pypi") -> tuple[str, int] | Non
|
|
|
170
193
|
if not popular:
|
|
171
194
|
return None
|
|
172
195
|
|
|
173
|
-
lowered = name
|
|
196
|
+
lowered = _normalise(name, ecosystem)
|
|
174
197
|
target = _comparable(name, ecosystem)
|
|
175
198
|
|
|
176
199
|
if lowered in popular or target in popular:
|