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