ghostpkg 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/CHANGELOG.md +65 -1
  2. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/PKG-INFO +80 -6
  3. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/README.en.md +79 -5
  4. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/README.md +53 -5
  5. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/SECURITY.md +21 -6
  6. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/ghostpkg/__init__.py +1 -1
  7. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/ghostpkg/assess.py +26 -2
  8. ghostpkg-0.5.0/ghostpkg/cache.py +165 -0
  9. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/ghostpkg/cli.py +57 -5
  10. ghostpkg-0.5.0/ghostpkg/inspection.py +214 -0
  11. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/ghostpkg/registries.py +19 -1
  12. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/pyproject.toml +1 -1
  13. ghostpkg-0.5.0/tests/test_cache.py +158 -0
  14. ghostpkg-0.5.0/tests/test_inspection.py +215 -0
  15. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  16. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  17. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  18. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  19. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/.github/workflows/ci.yml +0 -0
  20. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/.gitignore +0 -0
  21. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/CONTRIBUTING.md +0 -0
  22. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/LICENSE +0 -0
  23. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/assets/banner.html +0 -0
  24. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/assets/banner.png +0 -0
  25. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/assets/demo.gif +0 -0
  26. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/assets/make_demo.py +0 -0
  27. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/ghostpkg/__main__.py +0 -0
  28. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/ghostpkg/data.py +0 -0
  29. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/ghostpkg/manifests.py +0 -0
  30. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/tests/test_assess.py +0 -0
  31. {ghostpkg-0.3.0 → ghostpkg-0.5.0}/tests/test_manifests.py +0 -0
@@ -6,6 +6,68 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-09-01
10
+
11
+ ### Added
12
+ - **`--deep`: static inspection of install-time code.** This addresses the
13
+ project's main open problem ([#1]) — a hallucinated name an attacker has
14
+ *already registered*. Such a package exists, so the existence check passes,
15
+ and it is young with one release and no repository link, exactly like every
16
+ honest new package. Age cannot separate them; install-time behaviour can,
17
+ because a slopsquat has to run something when it is installed.
18
+ - Signals reported: reading environment variables together with a network call,
19
+ a network request, a shell command, decoding a hidden blob, and executing code
20
+ that was just decoded or downloaded.
21
+
22
+ ### How the policy was decided
23
+ The previous signal adopted on intuition — scoring packages by age — flagged
24
+ 100% of legitimate same-day publications. So this one was measured first:
25
+
26
+ | Group | Flagged |
27
+ |---|---|
28
+ | 27 established legitimate packages | 0% |
29
+ | 32 packages published to PyPI that day | 0% |
30
+ | 6 known malicious install-script shapes | 6 of 6 |
31
+
32
+ That is why a **young** package with install-time signals is **blocked** while
33
+ age alone still only warns. An established package with the same signals is
34
+ warned about, not blocked.
35
+
36
+ The first pattern set was far looser and flagged 37% of established packages,
37
+ mostly for reading environment variables — ordinary when inspecting build
38
+ flags. It also scanned `conftest.py`, which runs during testing and never on
39
+ install. Both were mistakes found by measuring rather than by reasoning.
40
+
41
+ ### Safety
42
+ Archives are read in memory and never extracted to disk; nothing is executed,
43
+ imported or compiled; downloads stop at 8 MB and members at 512 KB so a
44
+ decompression bomb cannot exhaust memory; any failure to fetch or parse means
45
+ "not inspected" rather than a pass.
46
+
47
+ [#1]: https://github.com/M1rwana12/ghostpkg/issues/1
48
+
49
+ ## [0.4.0] - 2026-09-01
50
+
51
+ ### Added
52
+ - **On-disk cache for registry lookups.** Scanning a 150-package manifest drops
53
+ from 4.7s to 0.4s on a warm cache. Previously every scan cost one request per
54
+ dependency, every run, which is slow in CI and rude to the registry.
55
+ - `--no-cache` to bypass it, and `ghostpkg clear-cache` to delete it.
56
+ - `GHOSTPKG_CACHE_DIR` to override the location. The default is
57
+ `%LOCALAPPDATA%\ghostpkg` on Windows, `~/Library/Caches/ghostpkg` on macOS and
58
+ `$XDG_CACHE_HOME/ghostpkg` elsewhere, worked out without a dependency.
59
+
60
+ ### Notes on the cache design
61
+ - **Time-to-live depends on the answer, and "does not exist" is held for only an
62
+ hour.** A free name can be registered at any moment — that is the whole attack
63
+ — so a negative result must not be trusted for long. Young packages are held
64
+ six hours, established ones a day.
65
+ - Cache failures are never fatal. A corrupt file, a wrong schema, a malformed
66
+ entry or an unwritable directory all degrade to no cache rather than breaking
67
+ a run. There are tests for each of those.
68
+ - Written atomically via a temporary file and `os.replace`, once per run, so a
69
+ killed process cannot leave a half-written cache behind.
70
+
9
71
  ## [0.3.0] - 2026-09-01
10
72
 
11
73
  ### Fixed
@@ -87,7 +149,9 @@ First release.
87
149
  - No corpus of hallucinated package names is shipped, following the decision of
88
150
  the USENIX'25 authors not to publish theirs.
89
151
 
90
- [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.3.0...HEAD
152
+ [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.5.0...HEAD
153
+ [0.5.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.5.0
154
+ [0.4.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.4.0
91
155
  [0.3.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.3.0
92
156
  [0.2.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.2.0
93
157
  [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.0
3
+ Version: 0.5.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
@@ -180,6 +180,8 @@ and report TOML keys as package names.
180
180
  | `--strict` | Promote warnings to blocks |
181
181
  | `--json` | Machine-readable output for scripts and CI |
182
182
  | `-q`, `--quiet` | Hide packages that passed |
183
+ | `--no-cache` | Neither read nor write the cache |
184
+ | `--deep` | Download recently published packages and statically inspect their install scripts |
183
185
  | `--version` | Print the version |
184
186
 
185
187
  ### Exit codes
@@ -190,6 +192,25 @@ and report TOML keys as package names.
190
192
  | `1` | At least one package blocked |
191
193
  | `2` | Usage error, unreadable manifest, or the registry was unreachable |
192
194
 
195
+ ### Caching
196
+
197
+ Lookups are cached on disk, so re-scanning a 150-package manifest takes 0.4s
198
+ instead of 4.7s.
199
+
200
+ Time-to-live depends on the answer, and the negative case is the one that
201
+ matters: **"does not exist" is held for only an hour**, because a free name can
202
+ be registered at any moment and that is the entire attack. Young packages are
203
+ held six hours, established ones a day.
204
+
205
+ ```bash
206
+ ghostpkg scan requirements.txt --no-cache # bypass it
207
+ ghostpkg clear-cache # delete it
208
+ GHOSTPKG_CACHE_DIR=/tmp/gp ghostpkg check x # move it
209
+ ```
210
+
211
+ A corrupt, unreadable or unwritable cache degrades to no cache rather than
212
+ breaking your run.
213
+
193
214
  ### JSON output
194
215
 
195
216
  ```console
@@ -328,11 +349,62 @@ roughly one round trip rather than one per dependency.
328
349
 
329
350
  ---
330
351
 
352
+ ## `--deep`: inspecting install scripts
353
+
354
+ The existence check cannot see the dangerous case — a name an attacker has
355
+ **already registered**. That package exists, so it passes; and it is young, with
356
+ one release and no repository link, which describes every honest new package
357
+ too. **Age cannot separate them.**
358
+
359
+ Install-time behaviour can. A slopsquat has to run something when it is
360
+ installed — that is the entire point of publishing it. An honest new library
361
+ almost never does.
362
+
363
+ ```bash
364
+ ghostpkg scan requirements.txt --deep
365
+ ```
366
+
367
+ `--deep` downloads the archive **only for recently published packages**, reads
368
+ only `setup.py` from it (or the install hooks out of `package.json`), and
369
+ pattern-matches the text. **Nothing is ever executed.** Archive and member sizes
370
+ are capped, so a decompression bomb cannot exhaust memory.
371
+
372
+ | Signal | What it means |
373
+ |---|---|
374
+ | `exfiltration` | Reads environment variables *and* contacts the network |
375
+ | `network` | Makes a network request during install |
376
+ | `subprocess` | Runs a shell command during install |
377
+ | `encoded-payload` | Decodes a hidden blob, or carries a large encoded string |
378
+ | `dynamic-exec` | Executes code it just decoded or downloaded |
379
+
380
+ ### It was measured before it was allowed to block
381
+
382
+ The last signal adopted on intuition — score packages by age — flagged 100% of
383
+ legitimate same-day publications. So this one was measured first:
384
+
385
+ | Group | Flagged |
386
+ |---|---|
387
+ | 27 established legitimate packages | **0%** |
388
+ | 32 packages published to PyPI that day | **0%** |
389
+ | 6 known malicious install-script shapes | **6 of 6** |
390
+
391
+ That is why a **young** package with install-time signals is **blocked**, while
392
+ age alone can only ever warn. An established package showing the same signals is
393
+ warned about rather than blocked: old packages do sometimes build things at
394
+ install time, and the sample behind that judgement is small.
395
+
396
+ **Limits, stated plainly:** a squat that waits until import time rather than
397
+ install time will not be caught, obfuscation beyond the listed patterns will not
398
+ be caught, and packages published without an sdist cannot be inspected at all.
399
+
400
+ ---
401
+
331
402
  ## Comparison
332
403
 
333
404
  | | `ghostpkg` | SCA scanners (Snyk, Socket) | `pip install` alone |
334
405
  |---|:---:|:---:|:---:|
335
406
  | Catches a name that doesn't exist | ✅ **before install** | after install / in a PR | ❌ |
407
+ | Inspects install scripts without running them | ✅ `--deep` | varies | ❌ |
336
408
  | Runs without an account | ✅ | ❌ | — |
337
409
  | Runtime dependencies | **0** | many | — |
338
410
  | Blocks legitimate new packages | ❌ **no** | varies | — |
@@ -348,16 +420,18 @@ earns its keep. Use both.
348
420
  ## Honest limitations
349
421
 
350
422
  > [!WARNING]
351
- > **The hard case is out of scope today.** A hallucinated name that an attacker has
352
- > **already registered** will pass the existence check. The warning signals are all
353
- > that stand between you and it, and they are advisory. Improving this is the main
354
- > open problem — see [issues](https://github.com/M1rwana12/ghostpkg/issues).
423
+ > **The hard case is partly addressed by `--deep`, not solved.** A hallucinated
424
+ > name an attacker has **already registered** passes the existence check.
425
+ > `--deep` looks at install-time behaviour and catches the usual shapes, but an
426
+ > attacker who does nothing during install will still get through. Discussion in
427
+ > [#1](https://github.com/M1rwana12/ghostpkg/issues/1).
355
428
 
356
429
  - Typo detection compares against the 2,000 most-downloaded projects in each
357
430
  ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
358
431
  - Names shorter than five characters are not compared at all: below that the name
359
432
  space is too dense for edit distance to mean anything.
360
- - Every check is a live registry request. There is no caching yet.
433
+ - The cache lives on disk. `ghostpkg clear-cache` removes it and
434
+ `GHOSTPKG_CACHE_DIR` moves it.
361
435
  - Registry outages surface as exit code `2` rather than a silent pass — deliberately,
362
436
  but it does mean a flaky network fails your build.
363
437
 
@@ -133,6 +133,8 @@ and report TOML keys as package names.
133
133
  | `--strict` | Promote warnings to blocks |
134
134
  | `--json` | Machine-readable output for scripts and CI |
135
135
  | `-q`, `--quiet` | Hide packages that passed |
136
+ | `--no-cache` | Neither read nor write the cache |
137
+ | `--deep` | Download recently published packages and statically inspect their install scripts |
136
138
  | `--version` | Print the version |
137
139
 
138
140
  ### Exit codes
@@ -143,6 +145,25 @@ and report TOML keys as package names.
143
145
  | `1` | At least one package blocked |
144
146
  | `2` | Usage error, unreadable manifest, or the registry was unreachable |
145
147
 
148
+ ### Caching
149
+
150
+ Lookups are cached on disk, so re-scanning a 150-package manifest takes 0.4s
151
+ instead of 4.7s.
152
+
153
+ Time-to-live depends on the answer, and the negative case is the one that
154
+ matters: **"does not exist" is held for only an hour**, because a free name can
155
+ be registered at any moment and that is the entire attack. Young packages are
156
+ held six hours, established ones a day.
157
+
158
+ ```bash
159
+ ghostpkg scan requirements.txt --no-cache # bypass it
160
+ ghostpkg clear-cache # delete it
161
+ GHOSTPKG_CACHE_DIR=/tmp/gp ghostpkg check x # move it
162
+ ```
163
+
164
+ A corrupt, unreadable or unwritable cache degrades to no cache rather than
165
+ breaking your run.
166
+
146
167
  ### JSON output
147
168
 
148
169
  ```console
@@ -281,11 +302,62 @@ roughly one round trip rather than one per dependency.
281
302
 
282
303
  ---
283
304
 
305
+ ## `--deep`: inspecting install scripts
306
+
307
+ The existence check cannot see the dangerous case — a name an attacker has
308
+ **already registered**. That package exists, so it passes; and it is young, with
309
+ one release and no repository link, which describes every honest new package
310
+ too. **Age cannot separate them.**
311
+
312
+ Install-time behaviour can. A slopsquat has to run something when it is
313
+ installed — that is the entire point of publishing it. An honest new library
314
+ almost never does.
315
+
316
+ ```bash
317
+ ghostpkg scan requirements.txt --deep
318
+ ```
319
+
320
+ `--deep` downloads the archive **only for recently published packages**, reads
321
+ only `setup.py` from it (or the install hooks out of `package.json`), and
322
+ pattern-matches the text. **Nothing is ever executed.** Archive and member sizes
323
+ are capped, so a decompression bomb cannot exhaust memory.
324
+
325
+ | Signal | What it means |
326
+ |---|---|
327
+ | `exfiltration` | Reads environment variables *and* contacts the network |
328
+ | `network` | Makes a network request during install |
329
+ | `subprocess` | Runs a shell command during install |
330
+ | `encoded-payload` | Decodes a hidden blob, or carries a large encoded string |
331
+ | `dynamic-exec` | Executes code it just decoded or downloaded |
332
+
333
+ ### It was measured before it was allowed to block
334
+
335
+ The last signal adopted on intuition — score packages by age — flagged 100% of
336
+ legitimate same-day publications. So this one was measured first:
337
+
338
+ | Group | Flagged |
339
+ |---|---|
340
+ | 27 established legitimate packages | **0%** |
341
+ | 32 packages published to PyPI that day | **0%** |
342
+ | 6 known malicious install-script shapes | **6 of 6** |
343
+
344
+ That is why a **young** package with install-time signals is **blocked**, while
345
+ age alone can only ever warn. An established package showing the same signals is
346
+ warned about rather than blocked: old packages do sometimes build things at
347
+ install time, and the sample behind that judgement is small.
348
+
349
+ **Limits, stated plainly:** a squat that waits until import time rather than
350
+ install time will not be caught, obfuscation beyond the listed patterns will not
351
+ be caught, and packages published without an sdist cannot be inspected at all.
352
+
353
+ ---
354
+
284
355
  ## Comparison
285
356
 
286
357
  | | `ghostpkg` | SCA scanners (Snyk, Socket) | `pip install` alone |
287
358
  |---|:---:|:---:|:---:|
288
359
  | Catches a name that doesn't exist | ✅ **before install** | after install / in a PR | ❌ |
360
+ | Inspects install scripts without running them | ✅ `--deep` | varies | ❌ |
289
361
  | Runs without an account | ✅ | ❌ | — |
290
362
  | Runtime dependencies | **0** | many | — |
291
363
  | Blocks legitimate new packages | ❌ **no** | varies | — |
@@ -301,16 +373,18 @@ earns its keep. Use both.
301
373
  ## Honest limitations
302
374
 
303
375
  > [!WARNING]
304
- > **The hard case is out of scope today.** A hallucinated name that an attacker has
305
- > **already registered** will pass the existence check. The warning signals are all
306
- > that stand between you and it, and they are advisory. Improving this is the main
307
- > open problem — see [issues](https://github.com/M1rwana12/ghostpkg/issues).
376
+ > **The hard case is partly addressed by `--deep`, not solved.** A hallucinated
377
+ > name an attacker has **already registered** passes the existence check.
378
+ > `--deep` looks at install-time behaviour and catches the usual shapes, but an
379
+ > attacker who does nothing during install will still get through. Discussion in
380
+ > [#1](https://github.com/M1rwana12/ghostpkg/issues/1).
308
381
 
309
382
  - Typo detection compares against the 2,000 most-downloaded projects in each
310
383
  ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
311
384
  - Names shorter than five characters are not compared at all: below that the name
312
385
  space is too dense for edit distance to mean anything.
313
- - Every check is a live registry request. There is no caching yet.
386
+ - The cache lives on disk. `ghostpkg clear-cache` removes it and
387
+ `GHOSTPKG_CACHE_DIR` moves it.
314
388
  - Registry outages surface as exit code `2` rather than a silent pass — deliberately,
315
389
  but it does mean a flaky network fails your build.
316
390
 
@@ -86,14 +86,24 @@ ghostpkg scan package.json
86
86
 
87
87
  # машинозчитуваний вивід
88
88
  ghostpkg check somepkg --json
89
+
90
+ # видалити кеш
91
+ ghostpkg clear-cache
89
92
  ```
90
93
 
94
+ Результати запитів кешуються на диск: повторна перевірка манифесту зі 150
95
+ залежностей займає 0,4 с замість 4,7 с. **Відповідь «пакета не існує»
96
+ зберігається лише годину** — вільне ім'я можуть зареєструвати будь-якої
97
+ миті, і саме в цьому вся атака.
98
+
91
99
  | Прапорець | Призначення |
92
100
  |---|---|
93
101
  | `-e`, `--ecosystem` | `pypi` (типово) або `npm` |
94
102
  | `--strict` | Підвищує попередження до блокувань |
95
103
  | `--json` | Вивід у JSON для скриптів і CI |
96
104
  | `-q`, `--quiet` | Ховає пакети, які пройшли перевірку |
105
+ | `--no-cache` | Не читати й не писати кеш |
106
+ | `--deep` | Завантажити свіжі пакети й статично перевірити їхні скрипти встановлення |
97
107
 
98
108
  `scan` розпізнає `requirements*.txt`, `pyproject.toml` (PEP 621 і Poetry)
99
109
  та `package.json`. Невідомий формат він **відхиляє з помилкою, а не вгадує**.
@@ -140,6 +150,42 @@ $ ghostpkg check react-router-dom-utils -e npm
140
150
 
141
151
  ---
142
152
 
153
+ ## `--deep`: перевірка скриптів встановлення
154
+
155
+ Перевірка існування не бачить найнебезпечнішого випадку — імені, яке зловмисник
156
+ **уже зареєстрував**. Такий пакет існує, тож проходить; він молодий, з одним
157
+ релізом і без репозиторію — як і будь-який чесний новий пакет. **Вік їх не розрізняє.**
158
+
159
+ Поведінка при встановленні — розрізняє. Слопсквот мусить щось виконати під час
160
+ встановлення, інакше в ньому немає сенсу. Чесна нова бібліотека цього майже ніколи
161
+ не робить.
162
+
163
+ ```bash
164
+ ghostpkg scan requirements.txt --deep
165
+ ```
166
+
167
+ `--deep` завантажує архів **лише для свіжих пакетів**, читає з нього тільки
168
+ `setup.py` (або скрипти встановлення з `package.json`) і зіставляє текст із
169
+ шаблонами. **Нічого не виконується.** Розмір архіва обмежений, тож бомба
170
+ розпакування не з'їсть пам'ять.
171
+
172
+ Що вважається сигналом: читання змінних середовища разом із мережевим запитом,
173
+ мережевий запит, запуск оболонки, розкодування прихованого блоба, виконання
174
+ щойно розкодованого коду.
175
+
176
+ **Виміряно перед тим, як вмикати блокування:**
177
+
178
+ | Група | Позначено |
179
+ |---|---|
180
+ | 27 усталених легітимних пакетів | **0 %** |
181
+ | 32 пакети, опубліковані того ж дня | **0 %** |
182
+ | 6 відомих шкідливих шаблонів | **6 з 6** |
183
+
184
+ Для порівняння: вік позначав **100 %** свіжих легітимних пакетів. Саме тому
185
+ молодий пакет із такими сигналами **блокується**, а вік — лише попереджає.
186
+
187
+ ---
188
+
143
189
  ## Порівняння
144
190
 
145
191
  | | `ghostpkg` | SCA-сканери (Snyk, Socket) | Просто `pip install` |
@@ -158,16 +204,18 @@ $ ghostpkg check react-router-dom-utils -e npm
158
204
  ## Чесні обмеження
159
205
 
160
206
  > [!WARNING]
161
- > **Складний випадок поки поза межами інструменту.** Вигадану назву, яку зловмисник
162
- > **уже зареєстрував**, перевірка існування пропустить. Між вами й нею стоять лише
163
- > попередження, і вони дорадчі. Це головна відкрита проблема —
164
- > див. [issues](https://github.com/M1rwana12/ghostpkg/issues).
207
+ > **Складний випадок частково закрито прапорцем `--deep`, але не повністю.**
208
+ > Вигадану назву, яку зловмисник **уже зареєстрував**, перевірка існування
209
+ > пропустить. `--deep` дивиться на поведінку при встановленні й ловить типові
210
+ > шаблони, але зловмисник, який нічого не робить під час встановлення, пройде.
211
+ > Обговорення — [#1](https://github.com/M1rwana12/ghostpkg/issues/1).
165
212
 
166
213
  - Виявлення опечаток порівнює з 2 000 найпопулярніших проєктів кожної екосистеми,
167
214
  тому підробка під менш популярний пакет як схожа назва не позначиться.
168
215
  - Імена коротші за 5 символів не порівнюються: там простір назв надто щільний,
169
216
  щоб відстань редагування щось означала.
170
- - Кожна перевірка — це живий запит до реєстру. Кешування ще немає.
217
+ - Кеш зберігається локально. `ghostpkg clear-cache` видаляє його,
218
+ `GHOSTPKG_CACHE_DIR` змінює розташування.
171
219
 
172
220
  ---
173
221
 
@@ -20,13 +20,17 @@ what it does and does not protect against.
20
20
 
21
21
  ### What it does not catch
22
22
 
23
- - **A hallucinated name an attacker has already registered.** The existence check
24
- passes. Only the advisory warnings stand between the user and it. This is the
25
- main open problem and it is stated plainly in the README rather than hidden.
23
+ - **A hallucinated name an attacker has already registered, when `--deep` is
24
+ off.** The existence check passes and only advisory warnings remain. With
25
+ `--deep`, install-time code is statically inspected and the usual malicious
26
+ shapes are caught, which measured 0 false positives across 27 established and
27
+ 32 same-day packages while catching all 6 test shapes.
28
+ - **Even with `--deep`:** a squat whose payload runs at *import* time rather than
29
+ install time, obfuscation beyond the documented patterns, and any package
30
+ published without an sdist, which cannot be inspected.
26
31
  - Malicious code in a package that is otherwise legitimate and established.
27
32
  - Compromise of an existing maintainer account.
28
- - Anything at install time. `ghostpkg` inspects registry metadata; it never
29
- downloads, unpacks or executes a package.
33
+ - Malicious behaviour at *runtime*. `--deep` reads install-time code only.
30
34
 
31
35
  ### Failure mode
32
36
 
@@ -34,9 +38,20 @@ If a registry is unreachable, `ghostpkg` exits with code `2` rather than passing
34
38
  silently. A network failure will fail your build. That is deliberate: a security
35
39
  check that quietly succeeds when it could not run is worse than no check.
36
40
 
41
+ ### How `--deep` handles untrusted archives
42
+
43
+ - Archives are read **in memory**, never extracted to disk, so a path-traversal
44
+ entry has nothing to write to.
45
+ - **Nothing is executed, imported or compiled.** Only named install-time files
46
+ are read, and only as text.
47
+ - Downloads stop at 8 MB and individual members at 512 KB, so a decompression
48
+ bomb cannot exhaust memory.
49
+ - Any failure to download or parse means "not inspected", never a pass.
50
+
37
51
  ### Trust boundaries
38
52
 
39
- - Requests go only to `pypi.org` and `registry.npmjs.org` over HTTPS.
53
+ - Requests go only to `pypi.org`, `files.pythonhosted.org` and
54
+ `registry.npmjs.org` over HTTPS.
40
55
  - No telemetry, no analytics, no phoning home.
41
56
  - No runtime dependencies, so the tool's own supply chain is the Python standard
42
57
  library.
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.5.0"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -134,7 +134,26 @@ def nearest_popular(name: str, ecosystem: str = "pypi") -> tuple[str, int] | Non
134
134
  return best
135
135
 
136
136
 
137
- def assess(facts: PackageFacts, strict: bool = False) -> Finding:
137
+ def assess(
138
+ facts: PackageFacts,
139
+ strict: bool = False,
140
+ signals: "list | None" = None,
141
+ ) -> Finding:
142
+ """Turn registry facts, and optionally --deep install-script signals, into
143
+ a verdict.
144
+
145
+ Install-time signals are treated differently from every other soft signal,
146
+ and the difference is measured rather than assumed. Age flags 100% of
147
+ legitimate same-day publications, so it can only ever warn. Install-time
148
+ behaviour flagged 0 of 27 established and 0 of 32 brand-new real packages
149
+ while catching all six known malicious shapes, so a *young* package that
150
+ reaches for the network, a subprocess or a decoded payload during install
151
+ is specific enough to block.
152
+
153
+ An established package doing the same is only warned about: legitimate
154
+ old packages do sometimes build things at install time, and the sample
155
+ behind that judgement is small.
156
+ """
138
157
  if not facts.exists:
139
158
  return Finding(
140
159
  name=facts.name,
@@ -169,7 +188,12 @@ def assess(facts: PackageFacts, strict: bool = False) -> Finding:
169
188
  if is_young and not facts.has_repo_url:
170
189
  reasons.append("no repository or homepage link")
171
190
 
172
- if not reasons:
191
+ install_reasons = [str(signal) for signal in (signals or [])]
192
+ reasons.extend(install_reasons)
193
+
194
+ if install_reasons and is_young:
195
+ verdict = Verdict.BLOCK
196
+ elif not reasons:
173
197
  verdict = Verdict.OK
174
198
  elif strict:
175
199
  verdict = Verdict.BLOCK