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.
Files changed (56) hide show
  1. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/CHANGELOG.md +74 -1
  2. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/PKG-INFO +73 -4
  3. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/README.en.md +72 -3
  4. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/README.md +60 -1
  5. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/__init__.py +1 -1
  6. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/assess.py +26 -3
  7. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/jslocks.py +156 -156
  8. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/manifests.py +684 -664
  9. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/policy.py +181 -181
  10. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/prose.py +204 -204
  11. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/pyproject.toml +1 -1
  12. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_assess.py +36 -0
  13. ghostpkg-0.19.2/tests/test_hostile_input.py +140 -0
  14. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  15. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  16. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  17. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  18. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/ghostpkg-ignore.json +0 -0
  19. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/workflows/ci.yml +0 -0
  20. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.github/workflows/publish.yml +0 -0
  21. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.gitignore +0 -0
  22. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/.pre-commit-hooks.yaml +0 -0
  23. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/2.0 +0 -0
  24. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/CONTRIBUTING.md +0 -0
  25. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/LICENSE +0 -0
  26. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/SECURITY.md +0 -0
  27. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/action.yml +0 -0
  28. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/assets/banner.html +0 -0
  29. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/assets/banner.png +0 -0
  30. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/assets/demo.gif +0 -0
  31. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/assets/make_demo.py +0 -0
  32. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/__main__.py +0 -0
  33. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/cache.py +0 -0
  34. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/cli.py +0 -0
  35. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/data.py +0 -0
  36. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/discover.py +0 -0
  37. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/inspection.py +0 -0
  38. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/registries.py +0 -0
  39. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/report.py +0 -0
  40. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/rules.py +0 -0
  41. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/ghostpkg/scanner.py +0 -0
  42. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_action.py +0 -0
  43. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_cache.py +0 -0
  44. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_discover.py +0 -0
  45. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_inspection.py +0 -0
  46. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_jslocks.py +0 -0
  47. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_lockfiles.py +0 -0
  48. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_manifests.py +0 -0
  49. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_policy.py +0 -0
  50. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_prose.py +0 -0
  51. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_registries.py +0 -0
  52. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_regressions.py +0 -0
  53. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_report.py +0 -0
  54. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_stated_sources.py +0 -0
  55. {ghostpkg-0.19.0 → ghostpkg-0.19.2}/tests/test_versions.py +0 -0
  56. {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.0...HEAD
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.0
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`, plus Poetry's `tool.poetry` groups. `[build-system] requires` is not included, and the `python` constraint is not treated as a package. |
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`, plus Poetry's `tool.poetry` groups. `[build-system] requires` is not included, and the `python` constraint is not treated as a package. |
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
- 13 реальних README: справжні назви знаходяться, **хибних спрацювань 0%**.
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` |
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.19.0"
3
+ __version__ = "0.19.2"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -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
- PYPI_PIN = re.compile(r"^\s*==\s*([A-Za-z0-9][A-Za-z0-9.\-+!]*)\s*$")
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.lower()
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.lower()
196
+ lowered = _normalise(name, ecosystem)
174
197
  target = _comparable(name, ecosystem)
175
198
 
176
199
  if lowered in popular or target in popular: