ghostpkg 0.22.0__tar.gz → 0.24.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.24.0/.github/workflows/field.yml +71 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/CHANGELOG.md +84 -1
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/PKG-INFO +3 -3
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/README.en.md +2 -2
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/README.md +2 -2
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/__init__.py +1 -1
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/assess.py +47 -3
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/prose.py +41 -13
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/scanner.py +3 -1
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/pyproject.toml +1 -1
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/scripts/fieldtest.py +38 -7
- ghostpkg-0.24.0/tests/test_mutation_gaps.py +266 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_prose.py +56 -0
- ghostpkg-0.24.0/tests/test_trusted_scopes.py +140 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/ghostpkg-ignore.json +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/workflows/publish.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.gitignore +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.pre-commit-hooks.yaml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/2.0 +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/LICENSE +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/SECURITY.md +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/action.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/assets/banner.html +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/assets/banner.png +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/assets/demo.gif +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/assets/make_demo.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/cache.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/cli.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/data.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/discover.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/inspection.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/jslocks.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/manifests.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/policy.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/registries.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/report.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/rules.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_action.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_assess.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_cache.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_discover.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_field_findings.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_hostile_input.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_inspection.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_jslocks.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_lockfiles.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_manifests.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_not_a_package.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_policy.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_registries.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_regressions.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_report.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_review_findings.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_stated_sources.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_versions.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_withdrawn.py +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Scanning real repositories, on a schedule rather than on every push.
|
|
2
|
+
#
|
|
3
|
+
# This gate has found a defect the unit tests missed on three consecutive runs,
|
|
4
|
+
# including two that reported a package as non-existent while `pip` installed it
|
|
5
|
+
# without complaint. It is the most productive check the project has, and it is
|
|
6
|
+
# the one nobody would remember to run by hand.
|
|
7
|
+
#
|
|
8
|
+
# It is not on every push because a full pass is roughly 40,000 registry
|
|
9
|
+
# lookups. That is rude to PyPI and npm, and the answers barely move day to day.
|
|
10
|
+
# The push job runs the three smallest repositories, which is about 700 names
|
|
11
|
+
# and mostly served from cache.
|
|
12
|
+
#
|
|
13
|
+
# Runs on Linux specifically: `vercel/next.js` cannot be cloned on Windows,
|
|
14
|
+
# where a path in its test fixtures exceeds the limit, so the largest monorepo
|
|
15
|
+
# in the set is only reachable here.
|
|
16
|
+
|
|
17
|
+
name: field
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
schedule:
|
|
21
|
+
# 04:20 UTC, off the hour to avoid the stampede every cron service creates.
|
|
22
|
+
- cron: "20 4 * * *"
|
|
23
|
+
workflow_dispatch:
|
|
24
|
+
inputs:
|
|
25
|
+
quick:
|
|
26
|
+
description: Only the three smallest repositories
|
|
27
|
+
type: boolean
|
|
28
|
+
default: false
|
|
29
|
+
push:
|
|
30
|
+
branches: [main]
|
|
31
|
+
paths:
|
|
32
|
+
# Only when something that could change a verdict has changed.
|
|
33
|
+
- "ghostpkg/**"
|
|
34
|
+
- "scripts/fieldtest.py"
|
|
35
|
+
- ".github/workflows/field.yml"
|
|
36
|
+
|
|
37
|
+
permissions:
|
|
38
|
+
contents: read
|
|
39
|
+
|
|
40
|
+
jobs:
|
|
41
|
+
scan:
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
timeout-minutes: 45
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v7
|
|
46
|
+
|
|
47
|
+
- uses: actions/setup-python@v7
|
|
48
|
+
with:
|
|
49
|
+
python-version: "3.12"
|
|
50
|
+
|
|
51
|
+
- run: pip install -e .
|
|
52
|
+
|
|
53
|
+
- name: Scan real repositories
|
|
54
|
+
run: |
|
|
55
|
+
# A push gets the quick set; a schedule or a manual run gets all of
|
|
56
|
+
# them, unless the manual run asked for quick.
|
|
57
|
+
if [ "${{ github.event_name }}" = "push" ] || [ "${{ inputs.quick }}" = "true" ]; then
|
|
58
|
+
python scripts/fieldtest.py --quick
|
|
59
|
+
else
|
|
60
|
+
python scripts/fieldtest.py
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
- name: What a failure here means
|
|
64
|
+
if: failure()
|
|
65
|
+
run: |
|
|
66
|
+
echo "::error::A block was reported on a repository whose dependencies"
|
|
67
|
+
echo "::error::real people install every day. Treat it as a false"
|
|
68
|
+
echo "::error::positive until proven otherwise: find where the name"
|
|
69
|
+
echo "::error::came from, fix the parser, and re-run. If the block is"
|
|
70
|
+
echo "::error::genuinely correct, add it to EXPECTED in"
|
|
71
|
+
echo "::error::scripts/fieldtest.py with a reason a reader can check."
|
|
@@ -6,6 +6,87 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.24.0] - 2026-09-04
|
|
10
|
+
|
|
11
|
+
The gate now runs in CI, and it scanned **88,904 packages across sixteen
|
|
12
|
+
repositories**. Six blocks, and **all six are real** -- unsatisfiable pins in
|
|
13
|
+
Ray and a package next.js references that was never published. Zero false
|
|
14
|
+
positives at that scale is the number this project exists to hold.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **A scope the project already leans on is no longer treated as a stranger.**
|
|
18
|
+
Almost all remaining noise was "published under a year ago" on the dozen
|
|
19
|
+
platform binaries a compiled tool ships at once:
|
|
20
|
+
`@oxfmt/binding-darwin-arm64`, `@oxc-parser/binding-linux-riscv64-musl`.
|
|
21
|
+
|
|
22
|
+
The justification is structural rather than statistical: **an npm scope is
|
|
23
|
+
owned**. To publish `@oxfmt/anything` you must control `@oxfmt`, so a young
|
|
24
|
+
package in a scope the scan already depends on three times over is the same
|
|
25
|
+
publisher shipping another build.
|
|
26
|
+
|
|
27
|
+
Measured before it was built -- two members silences 41% of warnings, three
|
|
28
|
+
silences 40%, six silences 39%, so the threshold sits where it is defensible
|
|
29
|
+
rather than where it is largest. In practice: Sentry **114 warnings to 22**,
|
|
30
|
+
Vue **15 to 5**, and no change whatever to the block count. A squatted scope
|
|
31
|
+
(`@types-node` is not `@types/node`) appears once in a scan, not three times,
|
|
32
|
+
which is what the threshold separates. Nothing that blocks is affected, and
|
|
33
|
+
neither is the lookalike check.
|
|
34
|
+
|
|
35
|
+
- **`.github/workflows/field.yml`** runs the gate nightly and on any push that
|
|
36
|
+
touches the scanner. On Linux, because `vercel/next.js` cannot be cloned on
|
|
37
|
+
Windows -- a path in its test fixtures exceeds the limit, so the largest
|
|
38
|
+
monorepo in the set was unreachable until now.
|
|
39
|
+
|
|
40
|
+
- The repository list is **sixteen**, each carrying a note saying which defect
|
|
41
|
+
it found, so a future reader can tell why the list is what it is instead of
|
|
42
|
+
trimming it for being slow.
|
|
43
|
+
|
|
44
|
+
### Verified
|
|
45
|
+
`EXPECTED` now records the three names that genuinely do not exist, each
|
|
46
|
+
checked against the registry by hand: `jaxlib==0.4.17` (PyPI's oldest is
|
|
47
|
+
0.4.18), `tensorflow-macos==2.20.0` (Apple stopped at 2.16.2), and
|
|
48
|
+
`tsconfig-mod` (404 on npm).
|
|
49
|
+
|
|
50
|
+
707 tests.
|
|
51
|
+
|
|
52
|
+
## [0.23.0] - 2026-09-04
|
|
53
|
+
|
|
54
|
+
Mutation testing: 186 single-line changes to the source, each run against the
|
|
55
|
+
whole suite. **53 left it green.** The code was right in every case -- what was
|
|
56
|
+
missing was anything that would notice if it stopped being. Writing the tests to
|
|
57
|
+
kill the survivors turned up a real bug the suite had never had a chance to see.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
- **A version in an install command made prose extraction skip the whole
|
|
61
|
+
command.** `npm install react@18` and `pip install flask==3.0` both yielded
|
|
62
|
+
nothing: the `@` was read as a direct reference and the `==` stopped the token
|
|
63
|
+
matching the name pattern. A pinned install command is the commonest form
|
|
64
|
+
there is, so the feature built to read install commands was silently skipping
|
|
65
|
+
most of them.
|
|
66
|
+
|
|
67
|
+
The version is now split off and carried through, so a hallucinated *version*
|
|
68
|
+
in a README is checked as well as a hallucinated name. Re-measured on 22 real
|
|
69
|
+
READMEs, because widening this parser has cost false positives before: 19
|
|
70
|
+
names, **none that fail to exist**.
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
Tests for the survivors that carry a real consequence:
|
|
74
|
+
- **Each finding keeps its own file and line.** Reversing the thread pool's
|
|
75
|
+
result list left all 656 tests passing -- the printed names still looked
|
|
76
|
+
right, because a finding carries its own name, but the origin stamped on it
|
|
77
|
+
came from a different package. A CI annotation would have pointed at the
|
|
78
|
+
wrong line and marked the wrong one clean.
|
|
79
|
+
- **`--deep` and `--strict` reach the scan.** Both could be forced to `False`
|
|
80
|
+
in the call without a single failure: the flag would silently do nothing.
|
|
81
|
+
- **A name from prose keeps its own ecosystem**, so `npm install left-pad` in a
|
|
82
|
+
README is not looked up on PyPI.
|
|
83
|
+
- Chained commands (`cd app && npm install x`), unknown installers
|
|
84
|
+
(`brew install jq`), age measured from the first upload rather than the
|
|
85
|
+
latest, `cache.put` refusing a negative, `-r base.txt # comment` still
|
|
86
|
+
following the include, and the error counts in both output formats.
|
|
87
|
+
|
|
88
|
+
693 tests. Field gate: 4,461 packages, zero blocks.
|
|
89
|
+
|
|
9
90
|
## [0.22.0] - 2026-09-04
|
|
10
91
|
|
|
11
92
|
Four agents scanned **21 repositories and 78,000 package names**. They found
|
|
@@ -902,7 +983,9 @@ First release.
|
|
|
902
983
|
- No corpus of hallucinated package names is shipped, following the decision of
|
|
903
984
|
the USENIX'25 authors not to publish theirs.
|
|
904
985
|
|
|
905
|
-
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.
|
|
986
|
+
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.24.0...HEAD
|
|
987
|
+
[0.24.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.0
|
|
988
|
+
[0.23.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.23.0
|
|
906
989
|
[0.22.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.22.0
|
|
907
990
|
[0.21.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.21.0
|
|
908
991
|
[0.20.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.20.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ghostpkg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.24.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
|
|
@@ -441,7 +441,7 @@ length, and only applies to packages young enough to plausibly be a squat.
|
|
|
441
441
|
### In CI
|
|
442
442
|
|
|
443
443
|
```yaml
|
|
444
|
-
- uses: M1rwana12/ghostpkg@v0.
|
|
444
|
+
- uses: M1rwana12/ghostpkg@v0.24.0
|
|
445
445
|
```
|
|
446
446
|
|
|
447
447
|
That is the whole step. It searches the checkout, skips `node_modules` and
|
|
@@ -472,7 +472,7 @@ Or without the action, if you prefer:
|
|
|
472
472
|
```yaml
|
|
473
473
|
repos:
|
|
474
474
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
475
|
-
rev: v0.
|
|
475
|
+
rev: v0.24.0
|
|
476
476
|
hooks:
|
|
477
477
|
- id: ghostpkg
|
|
478
478
|
```
|
|
@@ -393,7 +393,7 @@ length, and only applies to packages young enough to plausibly be a squat.
|
|
|
393
393
|
### In CI
|
|
394
394
|
|
|
395
395
|
```yaml
|
|
396
|
-
- uses: M1rwana12/ghostpkg@v0.
|
|
396
|
+
- uses: M1rwana12/ghostpkg@v0.24.0
|
|
397
397
|
```
|
|
398
398
|
|
|
399
399
|
That is the whole step. It searches the checkout, skips `node_modules` and
|
|
@@ -424,7 +424,7 @@ Or without the action, if you prefer:
|
|
|
424
424
|
```yaml
|
|
425
425
|
repos:
|
|
426
426
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
427
|
-
rev: v0.
|
|
427
|
+
rev: v0.24.0
|
|
428
428
|
hooks:
|
|
429
429
|
- id: ghostpkg
|
|
430
430
|
```
|
|
@@ -190,7 +190,7 @@ ghostpkg scan ../інший-проєкт
|
|
|
190
190
|
### У CI
|
|
191
191
|
|
|
192
192
|
```yaml
|
|
193
|
-
- uses: M1rwana12/ghostpkg@v0.
|
|
193
|
+
- uses: M1rwana12/ghostpkg@v0.24.0
|
|
194
194
|
```
|
|
195
195
|
|
|
196
196
|
Це весь крок. Він шукає маніфести в чекауті, обходить `node_modules` і подібні
|
|
@@ -214,7 +214,7 @@ ghostpkg scan ../інший-проєкт
|
|
|
214
214
|
```yaml
|
|
215
215
|
repos:
|
|
216
216
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
217
|
-
rev: v0.
|
|
217
|
+
rev: v0.24.0
|
|
218
218
|
hooks:
|
|
219
219
|
- id: ghostpkg
|
|
220
220
|
```
|
|
@@ -216,11 +216,48 @@ def nearest_popular(name: str, ecosystem: str = "pypi") -> tuple[str, int] | Non
|
|
|
216
216
|
return best
|
|
217
217
|
|
|
218
218
|
|
|
219
|
+
#: How many packages a scope needs in one scan before its young members stop
|
|
220
|
+
#: being warned about. Measured across four repositories: two silences 41% of
|
|
221
|
+
#: all warnings, three silences 40%, six silences 39%. The effect saturates
|
|
222
|
+
#: immediately, so the threshold sits where it is defensible rather than where
|
|
223
|
+
#: it is largest.
|
|
224
|
+
TRUSTED_SCOPE_MEMBERS = 3
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def trusted_scopes(names: "list[str]") -> "set[str]":
|
|
228
|
+
"""npm scopes this scan depends on several times over.
|
|
229
|
+
|
|
230
|
+
The justification is structural rather than statistical: **an npm scope is
|
|
231
|
+
owned**. To publish `@oxfmt/binding-darwin-arm64` you must control
|
|
232
|
+
`@oxfmt`. So a young package in a scope the project already uses three
|
|
233
|
+
times is the same publisher shipping another build -- usually one of the
|
|
234
|
+
dozen platform binaries a compiled tool releases at once -- and not a
|
|
235
|
+
stranger who guessed a name.
|
|
236
|
+
|
|
237
|
+
A scope is itself squattable: `@types-node` is not `@types/node`. That is
|
|
238
|
+
the risk this has to survive, and the threshold is what does it -- a
|
|
239
|
+
squatted scope appears once in a scan, not three times. An attacker who
|
|
240
|
+
genuinely owned a scope a project leaned on that heavily would have far
|
|
241
|
+
better attacks available than guessing one more name inside it.
|
|
242
|
+
|
|
243
|
+
Measured: 106 of 268 warnings across four popular repositories, every one
|
|
244
|
+
of them a platform binary, and none of them something a person would want
|
|
245
|
+
to be told about.
|
|
246
|
+
"""
|
|
247
|
+
counts: dict[str, int] = {}
|
|
248
|
+
for name in names:
|
|
249
|
+
if name.startswith("@") and "/" in name:
|
|
250
|
+
scope = name.split("/", 1)[0]
|
|
251
|
+
counts[scope] = counts.get(scope, 0) + 1
|
|
252
|
+
return {scope for scope, n in counts.items() if n >= TRUSTED_SCOPE_MEMBERS}
|
|
253
|
+
|
|
254
|
+
|
|
219
255
|
def assess(
|
|
220
256
|
facts: PackageFacts,
|
|
221
257
|
strict: bool = False,
|
|
222
258
|
signals: "list | None" = None,
|
|
223
259
|
specifier: str | None = None,
|
|
260
|
+
known_scope: bool = False,
|
|
224
261
|
) -> Finding:
|
|
225
262
|
"""Turn registry facts, and optionally --deep install-script signals, into
|
|
226
263
|
a verdict.
|
|
@@ -304,6 +341,13 @@ def assess(
|
|
|
304
341
|
|
|
305
342
|
reasons: list[Reason] = []
|
|
306
343
|
|
|
344
|
+
# `known_scope` says the scan already depends on this scope several times,
|
|
345
|
+
# so "new and thin" describes a known publisher rather than a stranger.
|
|
346
|
+
# It quiets the soft signals only. Nothing that blocks is affected, and
|
|
347
|
+
# neither is the lookalike check -- a typo inside a trusted scope is still
|
|
348
|
+
# worth saying.
|
|
349
|
+
soft = not known_scope
|
|
350
|
+
|
|
307
351
|
# A withdrawn version exists, so it is not a block -- and pip installs one
|
|
308
352
|
# anyway when it is pinned explicitly, so blocking would be stricter than
|
|
309
353
|
# the package manager itself. But the maintainer said not to use it, and
|
|
@@ -320,7 +364,7 @@ def assess(
|
|
|
320
364
|
)
|
|
321
365
|
)
|
|
322
366
|
|
|
323
|
-
if facts.age_days is not None:
|
|
367
|
+
if soft and facts.age_days is not None:
|
|
324
368
|
if facts.age_days < YOUNG_DAYS:
|
|
325
369
|
reasons.append(
|
|
326
370
|
Reason(GP_RECENT, f"first published {facts.age_days} days ago")
|
|
@@ -361,10 +405,10 @@ def assess(
|
|
|
361
405
|
)
|
|
362
406
|
)
|
|
363
407
|
|
|
364
|
-
if is_young and facts.release_count <= 1:
|
|
408
|
+
if soft and is_young and facts.release_count <= 1:
|
|
365
409
|
reasons.append(Reason(GP_ONE_RELEASE, "only one release"))
|
|
366
410
|
|
|
367
|
-
if is_young and not facts.has_repo_url:
|
|
411
|
+
if soft and is_young and not facts.has_repo_url:
|
|
368
412
|
reasons.append(Reason(GP_NO_REPO, "no repository or homepage link"))
|
|
369
413
|
|
|
370
414
|
install_reasons = [
|
|
@@ -73,11 +73,19 @@ def looks_like_prose(filename: str) -> bool:
|
|
|
73
73
|
SENTENCE_END = ".,;:!?)"
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
#: Where a version starts in an install argument. npm writes `react@18`, pip
|
|
77
|
+
#: writes `flask==3.0`, and both were dropped entirely: the `@` was read as a
|
|
78
|
+
#: direct reference and the `==` made the token stop matching the name pattern.
|
|
79
|
+
#: A pinned install command is the commonest form there is, so prose extraction
|
|
80
|
+
#: was silently skipping most of what it existed to read.
|
|
81
|
+
PIN = re.compile(r"(===|==|>=|<=|~=|!=|[<>@])")
|
|
78
82
|
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
|
|
84
|
+
def _clean(token: str) -> "tuple[str | None, str | None, bool]":
|
|
85
|
+
"""(package name, version, whether the command ended here).
|
|
86
|
+
|
|
87
|
+
The third value matters more than it looks. Measured against ten real
|
|
88
|
+
READMEs, letting a command run past the end of its sentence produced a
|
|
81
89
|
**25% false-positive rate** -- `The`, `command`, `line`, `client`, `is` all
|
|
82
90
|
came back as package names from one httpx line.
|
|
83
91
|
"""
|
|
@@ -87,22 +95,41 @@ def _clean(token: str) -> "tuple[str | None, bool]":
|
|
|
87
95
|
token = token[:-1]
|
|
88
96
|
stop = True
|
|
89
97
|
if not token or token.startswith("-"):
|
|
90
|
-
return None, stop
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return None, stop
|
|
98
|
+
return None, None, stop
|
|
99
|
+
if "://" in token or token.startswith((".", "/", "~")):
|
|
100
|
+
return None, None, stop
|
|
94
101
|
if token.endswith((".whl", ".tar.gz", ".tgz", ".zip", ".txt", ".toml", ".json")):
|
|
95
|
-
return None, stop
|
|
102
|
+
return None, None, stop
|
|
103
|
+
|
|
104
|
+
# Split the version off. A scoped npm name starts with `@`, so the search
|
|
105
|
+
# begins at the second character.
|
|
106
|
+
specifier = None
|
|
107
|
+
match = PIN.search(token, 1)
|
|
108
|
+
if match:
|
|
109
|
+
rest = token[match.start() :]
|
|
110
|
+
token, specifier = token[: match.start()], token[match.end() :].strip() or None
|
|
111
|
+
# `pkg@https://...`, `pkg@file:../x`, `pkg@owner/repo` state their own
|
|
112
|
+
# source, and the registry has nothing to say about them.
|
|
113
|
+
if rest.startswith("@") and (
|
|
114
|
+
specifier is None
|
|
115
|
+
or "://" in specifier
|
|
116
|
+
or ":" in specifier
|
|
117
|
+
or "/" in specifier
|
|
118
|
+
):
|
|
119
|
+
return None, None, stop
|
|
120
|
+
|
|
96
121
|
# `fastapi[standard]` names fastapi; the extras are not part of it.
|
|
97
122
|
token = re.sub(r"\[[^\]]*\]$", "", token)
|
|
98
123
|
if not token:
|
|
99
|
-
return None, stop
|
|
124
|
+
return None, None, stop
|
|
100
125
|
match = NAME.match(token)
|
|
101
126
|
if not match or match.group(1) != token:
|
|
102
|
-
return None, stop
|
|
127
|
+
return None, None, stop
|
|
103
128
|
name = match.group(1)
|
|
104
129
|
# `npm install` with no argument, or a lone dot, installs the local project.
|
|
105
|
-
|
|
130
|
+
if name in (".", ".."):
|
|
131
|
+
return None, None, stop
|
|
132
|
+
return name, specifier, stop
|
|
106
133
|
|
|
107
134
|
|
|
108
135
|
def _split_commands(line: str) -> list[str]:
|
|
@@ -191,7 +218,7 @@ def extract(text: str, source: str | None = None) -> list[Requirement]:
|
|
|
191
218
|
# An ordinary flag like --upgrade. Not a name, but not the
|
|
192
219
|
# end of the command either.
|
|
193
220
|
continue
|
|
194
|
-
name, ends_here = _clean(token)
|
|
221
|
+
name, specifier, ends_here = _clean(token)
|
|
195
222
|
if name is None:
|
|
196
223
|
# Prose has begun. Anything further on this line is English.
|
|
197
224
|
break
|
|
@@ -200,6 +227,7 @@ def extract(text: str, source: str | None = None) -> list[Requirement]:
|
|
|
200
227
|
found.append(
|
|
201
228
|
Requirement(
|
|
202
229
|
name=name,
|
|
230
|
+
specifier=specifier,
|
|
203
231
|
line=number,
|
|
204
232
|
source=source,
|
|
205
233
|
ecosystem=ecosystem,
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import concurrent.futures
|
|
10
10
|
from dataclasses import replace
|
|
11
11
|
|
|
12
|
-
from .assess import NEW_DAYS, Finding, Verdict, assess, exact_pin
|
|
12
|
+
from .assess import NEW_DAYS, Finding, Verdict, assess, exact_pin, trusted_scopes
|
|
13
13
|
from .cache import Cache
|
|
14
14
|
from .inspection import InspectionError, inspect_package
|
|
15
15
|
from .manifests import Requirement
|
|
@@ -30,6 +30,7 @@ def evaluate(
|
|
|
30
30
|
items = [
|
|
31
31
|
r if isinstance(r, Requirement) else Requirement(name=r) for r in requirements
|
|
32
32
|
]
|
|
33
|
+
scopes = trusted_scopes([item.name for item in items])
|
|
33
34
|
|
|
34
35
|
def one(requirement: Requirement) -> Finding:
|
|
35
36
|
name = requirement.name
|
|
@@ -94,6 +95,7 @@ def evaluate(
|
|
|
94
95
|
# A constraints entry is a bound, not an install, so its pin is not
|
|
95
96
|
# a claim that such a version exists. The name is still checked.
|
|
96
97
|
specifier=None if requirement.constraint else requirement.specifier,
|
|
98
|
+
known_scope=name.split("/", 1)[0] in scopes if name.startswith("@") else False,
|
|
97
99
|
)
|
|
98
100
|
finding.source = requirement.source
|
|
99
101
|
finding.line = requirement.line
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ghostpkg"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.24.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"
|
|
@@ -29,20 +29,51 @@ import tempfile
|
|
|
29
29
|
import time
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
|
|
32
|
-
#: Chosen for the shapes they contain, not for popularity
|
|
33
|
-
#:
|
|
34
|
-
#:
|
|
32
|
+
#: Chosen for the shapes they contain, not for popularity. Every one of these
|
|
33
|
+
#: found a defect nothing else did, and the note says which -- so a future
|
|
34
|
+
#: reader can tell why the list is what it is rather than trimming it.
|
|
35
|
+
#:
|
|
36
|
+
#: The boolean is whether `--quick` includes it.
|
|
35
37
|
REPOS = [
|
|
36
|
-
("pallets/flask", "uv.lock
|
|
38
|
+
("pallets/flask", "uv.lock, example sub-projects", True),
|
|
37
39
|
("vuejs/core", "pnpm workspace, lockfile v9", True),
|
|
40
|
+
("expressjs/express", "small and plain npm; a fast canary", True),
|
|
41
|
+
|
|
38
42
|
("getsentry/sentry", "pnpm-lock and uv.lock in one tree", False),
|
|
39
|
-
("home-assistant/core", "
|
|
43
|
+
("home-assistant/core", "constraints pinned to 1000000000.0.0; calendar versions", False),
|
|
44
|
+
("vercel/turborepo", "pnpm v5 keys with peer suffixes -- 17 false blocks", False),
|
|
45
|
+
("sveltejs/kit", "pnpm `name@file:` workspace fixtures -- 5 false blocks", False),
|
|
46
|
+
("withastro/astro", "more `name@file:` fixtures, scoped -- 3 false blocks", False),
|
|
47
|
+
("storybookjs/storybook", "yarn berry `npm:v1.1.0` ranges -- 2 false blocks", False),
|
|
48
|
+
("apache/airflow", "127 checksum files, five named like constraints", False),
|
|
49
|
+
("ray-project/ray", "PEP 440 local versions, materialised symlinks", False),
|
|
50
|
+
("nrwl/nx", "pnpm monorepo, agent instruction files", False),
|
|
51
|
+
("angular/angular", "15,000 names, the widest single scan", False),
|
|
52
|
+
("jupyterlab/jupyterlab", "Python and npm in one tree", False),
|
|
53
|
+
("apache/superset", "6,000 names, heavy extras", False),
|
|
40
54
|
("vercel/next.js", "491 package.json files, internal deps by exact pin", False),
|
|
41
55
|
]
|
|
42
56
|
|
|
43
57
|
#: Blocks that are correct. A repository may genuinely depend on something that
|
|
44
|
-
#:
|
|
45
|
-
|
|
58
|
+
#: does not exist, and every entry here was verified against the registry by
|
|
59
|
+
#: hand -- the reason has to be checkable by the next reader.
|
|
60
|
+
#:
|
|
61
|
+
#: Across 88,904 packages in sixteen repositories these six are the only blocks,
|
|
62
|
+
#: and all six are real. That is the number the gate exists to hold.
|
|
63
|
+
EXPECTED = {
|
|
64
|
+
# PyPI's jaxlib release list begins at 0.4.18. Older wheels were served
|
|
65
|
+
# from Google storage and the PyPI records were removed, so
|
|
66
|
+
# `pip install -r` on that file fails today.
|
|
67
|
+
"jaxlib": "ray pins 0.4.17; PyPI's oldest jaxlib release is 0.4.18",
|
|
68
|
+
|
|
69
|
+
# Apple stopped publishing tensorflow-macos at 2.16.2. Ray copied the
|
|
70
|
+
# `tensorflow==2.20.0` pin onto the macOS package, guarded by a
|
|
71
|
+
# `sys_platform == 'darwin'` marker, so it only fails on a Mac.
|
|
72
|
+
"tensorflow-macos": "ray pins 2.20.0; PyPI's newest is 2.16.2",
|
|
73
|
+
|
|
74
|
+
# 404 on npmjs.
|
|
75
|
+
"tsconfig-mod": "referenced by next.js and not published on npm",
|
|
76
|
+
}
|
|
46
77
|
|
|
47
78
|
CACHE = Path(tempfile.gettempdir()) / "ghostpkg-fieldtest"
|
|
48
79
|
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Behaviour the suite was not defending.
|
|
2
|
+
|
|
3
|
+
Found by mutation testing: 186 single-line changes to the source, each run
|
|
4
|
+
against the whole suite. 53 left it green. These tests kill the survivors that
|
|
5
|
+
carry a real consequence — a wrong verdict, a dropped package, a flag that
|
|
6
|
+
silently does nothing.
|
|
7
|
+
|
|
8
|
+
The code was correct in every case. What was missing was anything that would
|
|
9
|
+
notice if it stopped being.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from ghostpkg.assess import Verdict
|
|
19
|
+
from ghostpkg.cli import main
|
|
20
|
+
from ghostpkg.manifests import parse_requirements
|
|
21
|
+
from ghostpkg.prose import extract
|
|
22
|
+
from ghostpkg.registries import PackageFacts
|
|
23
|
+
from ghostpkg.scanner import evaluate
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def facts(name, ecosystem="pypi", **kwargs):
|
|
27
|
+
base = dict(
|
|
28
|
+
name=name, ecosystem=ecosystem, exists=True,
|
|
29
|
+
age_days=4000, release_count=30, has_repo_url=True,
|
|
30
|
+
)
|
|
31
|
+
base.update(kwargs)
|
|
32
|
+
return PackageFacts(**base)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestEachFindingKeepsItsOwnOrigin:
|
|
36
|
+
"""The thread pool returns results in submission order and they are zipped
|
|
37
|
+
back onto the names. Reversing that list left all 656 tests passing: the
|
|
38
|
+
printed names still looked right, because a finding carries its own name —
|
|
39
|
+
but the file and line stamped onto it came from a different package. A CI
|
|
40
|
+
annotation would point at the wrong line and mark the wrong one clean."""
|
|
41
|
+
|
|
42
|
+
def test_the_finding_named_x_carries_the_line_x_was_written_on(self, monkeypatch):
|
|
43
|
+
from ghostpkg.manifests import Requirement
|
|
44
|
+
|
|
45
|
+
monkeypatch.setattr(
|
|
46
|
+
"ghostpkg.scanner.fetch",
|
|
47
|
+
lambda name, ecosystem: facts(name, ecosystem, exists=name != "ghost"),
|
|
48
|
+
)
|
|
49
|
+
findings = evaluate(
|
|
50
|
+
[
|
|
51
|
+
Requirement(name="flask", source="requirements.txt", line=1),
|
|
52
|
+
Requirement(name="ghost", source="requirements.txt", line=2),
|
|
53
|
+
Requirement(name="requests", source="other.txt", line=7),
|
|
54
|
+
],
|
|
55
|
+
"pypi",
|
|
56
|
+
cache=None,
|
|
57
|
+
)
|
|
58
|
+
placed = {f.name: (f.source, f.line) for f in findings}
|
|
59
|
+
assert placed["flask"] == ("requirements.txt", 1)
|
|
60
|
+
assert placed["ghost"] == ("requirements.txt", 2)
|
|
61
|
+
assert placed["requests"] == ("other.txt", 7)
|
|
62
|
+
|
|
63
|
+
def test_the_verdict_belongs_to_the_name_it_is_printed_against(self, monkeypatch):
|
|
64
|
+
monkeypatch.setattr(
|
|
65
|
+
"ghostpkg.scanner.fetch",
|
|
66
|
+
lambda name, ecosystem: facts(name, ecosystem, exists="ghost" not in name),
|
|
67
|
+
)
|
|
68
|
+
findings = evaluate(["a-real-one", "ghost-one", "b-real-one", "ghost-two"], "pypi", cache=None)
|
|
69
|
+
for finding in findings:
|
|
70
|
+
expected = Verdict.BLOCK if "ghost" in finding.name else Verdict.OK
|
|
71
|
+
assert finding.verdict is expected, finding.name
|
|
72
|
+
|
|
73
|
+
def test_order_is_preserved(self, monkeypatch):
|
|
74
|
+
monkeypatch.setattr("ghostpkg.scanner.fetch", lambda name, ecosystem: facts(name, ecosystem))
|
|
75
|
+
wanted = ["one", "two", "three", "four", "five"]
|
|
76
|
+
assert [f.name for f in evaluate(wanted, "pypi", cache=None)] == wanted
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestTheFlagsReachTheScan:
|
|
80
|
+
"""Every flag was tested at the function it configures and none end to end
|
|
81
|
+
from `main()`. Setting `args.deep` or `args.strict` to False in the call
|
|
82
|
+
left the suite green: the flag would silently do nothing."""
|
|
83
|
+
|
|
84
|
+
def test_deep_reaches_the_scanner(self, tmp_path, monkeypatch):
|
|
85
|
+
seen = {}
|
|
86
|
+
|
|
87
|
+
def fake_evaluate(requirements, ecosystem, strict=False, cache=None, deep=False, workers=8):
|
|
88
|
+
seen["deep"] = deep
|
|
89
|
+
seen["strict"] = strict
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
monkeypatch.setattr("ghostpkg.cli.evaluate", fake_evaluate)
|
|
93
|
+
path = tmp_path / "requirements.txt"
|
|
94
|
+
path.write_text("flask\n", encoding="utf-8")
|
|
95
|
+
main(["scan", str(path), "--deep", "--no-cache"])
|
|
96
|
+
assert seen["deep"] is True
|
|
97
|
+
|
|
98
|
+
def test_strict_reaches_the_scanner(self, tmp_path, monkeypatch):
|
|
99
|
+
seen = {}
|
|
100
|
+
|
|
101
|
+
def fake_evaluate(requirements, ecosystem, strict=False, cache=None, deep=False, workers=8):
|
|
102
|
+
seen["strict"] = strict
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
monkeypatch.setattr("ghostpkg.cli.evaluate", fake_evaluate)
|
|
106
|
+
path = tmp_path / "requirements.txt"
|
|
107
|
+
path.write_text("flask\n", encoding="utf-8")
|
|
108
|
+
main(["scan", str(path), "--strict", "--no-cache"])
|
|
109
|
+
assert seen["strict"] is True
|
|
110
|
+
|
|
111
|
+
def test_strict_changes_the_exit_code(self, tmp_path, monkeypatch):
|
|
112
|
+
"""The consequence, not just the wiring: a warning becomes a failure."""
|
|
113
|
+
monkeypatch.setattr(
|
|
114
|
+
"ghostpkg.scanner.fetch",
|
|
115
|
+
lambda name, ecosystem: facts(name, ecosystem, age_days=10, release_count=1, has_repo_url=False),
|
|
116
|
+
)
|
|
117
|
+
path = tmp_path / "requirements.txt"
|
|
118
|
+
path.write_text("something-new\n", encoding="utf-8")
|
|
119
|
+
assert main(["scan", str(path), "--no-cache"]) == 0
|
|
120
|
+
assert main(["scan", str(path), "--strict", "--no-cache"]) == 1
|
|
121
|
+
|
|
122
|
+
def test_an_error_exit_beats_a_clean_one(self, tmp_path, monkeypatch):
|
|
123
|
+
"""Swapping the BLOCK/ERROR precedence survived. An unchecked name must
|
|
124
|
+
never come out as a pass."""
|
|
125
|
+
from ghostpkg.registries import RegistryError
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr(
|
|
128
|
+
"ghostpkg.scanner.fetch",
|
|
129
|
+
lambda name, ecosystem: (_ for _ in ()).throw(RegistryError("offline")),
|
|
130
|
+
)
|
|
131
|
+
path = tmp_path / "requirements.txt"
|
|
132
|
+
path.write_text("flask\n", encoding="utf-8")
|
|
133
|
+
assert main(["scan", str(path), "--no-cache"]) == 2
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestProseKeepsItsOwnEcosystem:
|
|
137
|
+
"""A README can hold `pip install x` and `npm install y` in adjacent lines,
|
|
138
|
+
so each name carries the registry it belongs to. Dropping the per-item
|
|
139
|
+
override made every npm name from prose be looked up on PyPI -- a false
|
|
140
|
+
block on all of them, and the feature silently dead."""
|
|
141
|
+
|
|
142
|
+
def test_an_npm_name_from_prose_is_looked_up_on_npm(self, tmp_path, monkeypatch):
|
|
143
|
+
looked_up = []
|
|
144
|
+
|
|
145
|
+
def fake_fetch(name, ecosystem):
|
|
146
|
+
looked_up.append((name, ecosystem))
|
|
147
|
+
return facts(name, ecosystem)
|
|
148
|
+
|
|
149
|
+
monkeypatch.setattr("ghostpkg.scanner.fetch", fake_fetch)
|
|
150
|
+
path = tmp_path / "AGENTS.md"
|
|
151
|
+
path.write_text("Run `npm install left-pad` then `pip install flask`.\n", encoding="utf-8")
|
|
152
|
+
main(["scan", str(path), "--no-cache"])
|
|
153
|
+
assert ("left-pad", "npm") in looked_up
|
|
154
|
+
assert ("flask", "pypi") in looked_up
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TestProseExtraction:
|
|
158
|
+
"""Three survivors in one module, each verified to produce a wrong answer
|
|
159
|
+
on an ordinary README line."""
|
|
160
|
+
|
|
161
|
+
def test_a_chained_command_is_still_read(self):
|
|
162
|
+
"""`_split_commands` returning the whole line dropped this entirely and
|
|
163
|
+
the run reported all clean."""
|
|
164
|
+
assert [r.name for r in extract("cd app && npm install left-pad")] == ["left-pad"]
|
|
165
|
+
|
|
166
|
+
@pytest.mark.parametrize(
|
|
167
|
+
"line",
|
|
168
|
+
["brew install jq", "apt-get install curl nginx", "dnf install make", "cargo install ripgrep"],
|
|
169
|
+
)
|
|
170
|
+
def test_an_unknown_installer_yields_nothing(self, line):
|
|
171
|
+
"""Falling through to the pip branch turned `brew install jq` into a
|
|
172
|
+
PyPI lookup -- a false block on an ordinary README."""
|
|
173
|
+
assert extract(line) == []
|
|
174
|
+
|
|
175
|
+
def test_a_pinned_npm_name_keeps_only_the_name(self):
|
|
176
|
+
"""`npm install react@18` is the commonest install line there is.
|
|
177
|
+
Without the guard it was checked as the literal name `react@18`."""
|
|
178
|
+
assert [r.name for r in extract("npm install react@18")] == ["react"]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestAgeComesFromTheFirstUpload:
|
|
182
|
+
"""Measuring from the newest upload made every actively maintained package
|
|
183
|
+
read as days old: mass false warnings, and false blocks under `--strict`."""
|
|
184
|
+
|
|
185
|
+
def test_a_long_lived_package_is_not_young(self):
|
|
186
|
+
from ghostpkg.registries import parse_npm
|
|
187
|
+
|
|
188
|
+
payload = {
|
|
189
|
+
"name": "thing",
|
|
190
|
+
"time": {"created": "2015-01-01T00:00:00.000Z", "modified": "2026-09-01T00:00:00.000Z"},
|
|
191
|
+
"versions": {"1.0.0": {}, "2.0.0": {}},
|
|
192
|
+
"dist-tags": {"latest": "2.0.0"},
|
|
193
|
+
}
|
|
194
|
+
assert parse_npm("thing", payload).age_days > 4000
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class TestTheCacheNeverStoresAMiss:
|
|
198
|
+
"""`ttl_for` is tested, the guard inside `put` was not -- so the project's
|
|
199
|
+
stated rule was only enforced second-hand."""
|
|
200
|
+
|
|
201
|
+
def test_put_refuses_a_negative(self, tmp_path):
|
|
202
|
+
from ghostpkg.cache import Cache
|
|
203
|
+
|
|
204
|
+
cache = Cache(directory=tmp_path)
|
|
205
|
+
cache.put(PackageFacts(name="ghost", ecosystem="pypi", exists=False))
|
|
206
|
+
assert cache.get("pypi", "ghost") is None
|
|
207
|
+
|
|
208
|
+
def test_put_stores_a_positive(self, tmp_path):
|
|
209
|
+
from ghostpkg.cache import Cache
|
|
210
|
+
|
|
211
|
+
cache = Cache(directory=tmp_path)
|
|
212
|
+
cache.put(facts("flask"))
|
|
213
|
+
assert cache.get("pypi", "flask") is not None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TestAnIncludeWithATrailingComment:
|
|
217
|
+
"""Dropping the inline-comment strip stopped `-r base.txt # shared deps`
|
|
218
|
+
following the include -- every package in the included file lost in
|
|
219
|
+
silence, with the run still reporting success."""
|
|
220
|
+
|
|
221
|
+
def test_the_include_is_followed(self, tmp_path):
|
|
222
|
+
from ghostpkg.manifests import load_manifest
|
|
223
|
+
|
|
224
|
+
(tmp_path / "base.txt").write_text("flask\njinja2\n", encoding="utf-8")
|
|
225
|
+
(tmp_path / "requirements.txt").write_text(
|
|
226
|
+
"-r base.txt # shared deps\nrequests\n", encoding="utf-8"
|
|
227
|
+
)
|
|
228
|
+
found, _ = load_manifest(tmp_path / "requirements.txt")
|
|
229
|
+
assert set(r.name for r in found) == {"flask", "jinja2", "requests"}
|
|
230
|
+
|
|
231
|
+
def test_a_comment_after_a_name_is_stripped(self):
|
|
232
|
+
assert [r.name for r in parse_requirements("flask # web\n")] == ["flask"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestTheErrorSummaryIsReported:
|
|
236
|
+
"""The error count in `--json` and the "could not be checked" line both
|
|
237
|
+
survived mutation. A run that could not reach the registry must not read as
|
|
238
|
+
a clean one."""
|
|
239
|
+
|
|
240
|
+
def test_json_counts_the_errors(self, tmp_path, monkeypatch, capsys):
|
|
241
|
+
from ghostpkg.registries import RegistryError
|
|
242
|
+
|
|
243
|
+
monkeypatch.setattr(
|
|
244
|
+
"ghostpkg.scanner.fetch",
|
|
245
|
+
lambda name, ecosystem: (_ for _ in ()).throw(RegistryError("offline")),
|
|
246
|
+
)
|
|
247
|
+
path = tmp_path / "requirements.txt"
|
|
248
|
+
path.write_text("flask\n", encoding="utf-8")
|
|
249
|
+
main(["scan", str(path), "--no-cache", "--json"])
|
|
250
|
+
payload = json.loads(capsys.readouterr().out)
|
|
251
|
+
assert payload["summary"]["errored"] == 1
|
|
252
|
+
assert payload["summary"]["blocked"] == 0
|
|
253
|
+
|
|
254
|
+
def test_the_text_summary_says_so(self, tmp_path, monkeypatch, capsys):
|
|
255
|
+
from ghostpkg.registries import RegistryError
|
|
256
|
+
|
|
257
|
+
monkeypatch.setattr(
|
|
258
|
+
"ghostpkg.scanner.fetch",
|
|
259
|
+
lambda name, ecosystem: (_ for _ in ()).throw(RegistryError("offline")),
|
|
260
|
+
)
|
|
261
|
+
path = tmp_path / "requirements.txt"
|
|
262
|
+
path.write_text("flask\n", encoding="utf-8")
|
|
263
|
+
main(["scan", str(path), "--no-cache"])
|
|
264
|
+
out = capsys.readouterr().out
|
|
265
|
+
assert "could not be checked" in out
|
|
266
|
+
assert "look fine" not in out
|
|
@@ -198,3 +198,59 @@ class TestACommandInsideASentence:
|
|
|
198
198
|
|
|
199
199
|
def test_a_span_with_a_backtick_pair_but_no_installer(self):
|
|
200
200
|
assert self.names("The `requirements.txt` file lists them.") == []
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TestAVersionInAnInstallCommand:
|
|
204
|
+
"""`npm install react@18` and `pip install flask==3.0` both extracted
|
|
205
|
+
nothing at all. The `@` was read as a direct reference and the `==` made the
|
|
206
|
+
token stop matching the name pattern, so a pinned install command -- the
|
|
207
|
+
commonest form there is -- was silently skipped by the feature built to
|
|
208
|
+
read install commands.
|
|
209
|
+
|
|
210
|
+
Re-measured after the change, because widening this parser has cost false
|
|
211
|
+
positives before: 19 names across 22 real READMEs, none that fail to exist.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
@pytest.mark.parametrize(
|
|
215
|
+
"line, name, specifier",
|
|
216
|
+
[
|
|
217
|
+
("npm install react@18", "react", "18"),
|
|
218
|
+
("npm i lodash@^4.17.21", "lodash", "^4.17.21"),
|
|
219
|
+
("pnpm add vue@3", "vue", "3"),
|
|
220
|
+
("yarn add @babel/core@7", "@babel/core", "7"),
|
|
221
|
+
("bun add hono@4.0.0", "hono", "4.0.0"),
|
|
222
|
+
("pip install flask==3.0", "flask", "3.0"),
|
|
223
|
+
("pip install httpx>=0.27", "httpx", "0.27"),
|
|
224
|
+
("pip install django~=5.0", "django", "5.0"),
|
|
225
|
+
("uv add fastapi==0.115.0", "fastapi", "0.115.0"),
|
|
226
|
+
("npm install react", "react", None),
|
|
227
|
+
],
|
|
228
|
+
)
|
|
229
|
+
def test_the_name_and_the_version_are_both_read(self, line, name, specifier):
|
|
230
|
+
found = extract(line)
|
|
231
|
+
assert [(r.name, r.specifier) for r in found] == [(name, specifier)]
|
|
232
|
+
|
|
233
|
+
@pytest.mark.parametrize(
|
|
234
|
+
"line",
|
|
235
|
+
[
|
|
236
|
+
"npm install pkg@https://example.com/x.tgz",
|
|
237
|
+
"npm install pkg@file:../local",
|
|
238
|
+
"npm install pkg@acme/repo",
|
|
239
|
+
"npm install pkg@github:acme/repo",
|
|
240
|
+
],
|
|
241
|
+
)
|
|
242
|
+
def test_a_stated_source_after_the_at_is_still_skipped(self, line):
|
|
243
|
+
"""The `@` guard existed to catch these. Splitting the version off must
|
|
244
|
+
not let a direct reference through."""
|
|
245
|
+
assert extract(line) == []
|
|
246
|
+
|
|
247
|
+
def test_a_pinned_name_in_prose_keeps_its_line(self):
|
|
248
|
+
text = "intro\n\nRun `pip install ghost-thing==1.0` to begin.\n"
|
|
249
|
+
found = extract(text)
|
|
250
|
+
assert (found[0].name, found[0].specifier, found[0].line) == ("ghost-thing", "1.0", 3)
|
|
251
|
+
|
|
252
|
+
def test_the_sentence_still_ends_the_command(self):
|
|
253
|
+
"""The 25% false-positive rate this parser once had came from running
|
|
254
|
+
past the full stop. Splitting versions must not reopen that."""
|
|
255
|
+
text = "Run `pip install httpx==0.27` and the command line client is optional."
|
|
256
|
+
assert [r.name for r in extract(text)] == ["httpx"]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""A scope the project already leans on is not a stranger.
|
|
2
|
+
|
|
3
|
+
268 warnings across four popular repositories was the last barrier a reviewer
|
|
4
|
+
named: almost all of them "published under a year ago" on the dozen platform
|
|
5
|
+
binaries a compiled tool ships at once -- `@oxfmt/binding-darwin-arm64`,
|
|
6
|
+
`@oxc-parser/binding-linux-riscv64-musl`.
|
|
7
|
+
|
|
8
|
+
The justification is structural rather than statistical: **an npm scope is
|
|
9
|
+
owned**. To publish `@oxfmt/anything` you must control `@oxfmt`. So a young
|
|
10
|
+
package in a scope the scan already depends on three times is the same
|
|
11
|
+
publisher shipping another build.
|
|
12
|
+
|
|
13
|
+
Measured before it was built. Two members silences 41% of warnings, three
|
|
14
|
+
silences 40%, six silences 39% -- it saturates at once, so the threshold sits
|
|
15
|
+
where it is defensible rather than where it is largest. In practice: Sentry 114
|
|
16
|
+
warnings to 22, Vue 15 to 5, and no change at all to the block count.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
from ghostpkg.assess import TRUSTED_SCOPE_MEMBERS, Verdict, assess, trusted_scopes
|
|
24
|
+
from ghostpkg.registries import PackageFacts
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def young(name, **kwargs):
|
|
28
|
+
base = dict(
|
|
29
|
+
name=name, ecosystem="npm", exists=True,
|
|
30
|
+
age_days=40, release_count=1, has_repo_url=False,
|
|
31
|
+
)
|
|
32
|
+
base.update(kwargs)
|
|
33
|
+
return PackageFacts(**base)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestWhichScopesAreTrusted:
|
|
37
|
+
def test_a_scope_seen_often_enough(self):
|
|
38
|
+
names = [f"@oxfmt/binding-{i}" for i in range(TRUSTED_SCOPE_MEMBERS)]
|
|
39
|
+
assert trusted_scopes(names) == {"@oxfmt"}
|
|
40
|
+
|
|
41
|
+
def test_one_short_is_not_enough(self):
|
|
42
|
+
names = [f"@oxfmt/binding-{i}" for i in range(TRUSTED_SCOPE_MEMBERS - 1)]
|
|
43
|
+
assert trusted_scopes(names) == set()
|
|
44
|
+
|
|
45
|
+
def test_a_squatted_scope_appears_once(self):
|
|
46
|
+
"""`@types-node` is not `@types/node`. A squatted scope shows up a
|
|
47
|
+
single time in a scan, which is exactly what the threshold separates."""
|
|
48
|
+
names = ["@types/node", "@types/react", "@types/jest", "@types-node/core"]
|
|
49
|
+
assert trusted_scopes(names) == {"@types"}
|
|
50
|
+
|
|
51
|
+
def test_unscoped_names_never_count(self):
|
|
52
|
+
assert trusted_scopes(["react", "vue", "lodash", "express"]) == set()
|
|
53
|
+
|
|
54
|
+
def test_scopes_are_counted_separately(self):
|
|
55
|
+
names = ["@a/one", "@a/two", "@a/three", "@b/one", "@b/two"]
|
|
56
|
+
assert trusted_scopes(names) == {"@a"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestWhatBeingTrustedChanges:
|
|
60
|
+
def test_the_soft_signals_are_dropped(self):
|
|
61
|
+
finding = assess(young("@oxfmt/binding-darwin-arm64"), known_scope=True)
|
|
62
|
+
assert finding.verdict is Verdict.OK
|
|
63
|
+
assert finding.reasons == []
|
|
64
|
+
|
|
65
|
+
def test_the_same_package_outside_a_trusted_scope_is_warned_about(self):
|
|
66
|
+
finding = assess(young("@oxfmt/binding-darwin-arm64"), known_scope=False)
|
|
67
|
+
assert finding.verdict is Verdict.WARN
|
|
68
|
+
assert len(finding.reasons) == 3
|
|
69
|
+
|
|
70
|
+
def test_a_missing_package_is_still_blocked(self):
|
|
71
|
+
"""Nothing that blocks is affected. A trusted scope does not make a
|
|
72
|
+
name that does not exist acceptable."""
|
|
73
|
+
facts = PackageFacts(name="@oxfmt/ghost", ecosystem="npm", exists=False)
|
|
74
|
+
assert assess(facts, known_scope=True).verdict is Verdict.BLOCK
|
|
75
|
+
|
|
76
|
+
def test_a_bad_pin_is_still_blocked(self):
|
|
77
|
+
facts = young("@oxfmt/binding-x", versions=("1.0.0",), latest_version="1.0.0")
|
|
78
|
+
finding = assess(facts, specifier="99.9.9", known_scope=True)
|
|
79
|
+
assert finding.verdict is Verdict.BLOCK
|
|
80
|
+
|
|
81
|
+
def test_a_confiscated_name_is_still_blocked(self):
|
|
82
|
+
facts = young("@oxfmt/binding-x", security_hold=True)
|
|
83
|
+
assert assess(facts, known_scope=True).verdict is Verdict.BLOCK
|
|
84
|
+
|
|
85
|
+
def test_install_signals_still_block_a_young_package(self):
|
|
86
|
+
"""`--deep` findings are evidence about the package itself, not a guess
|
|
87
|
+
from its age, so the scope says nothing about them."""
|
|
88
|
+
from ghostpkg.inspection import Signal
|
|
89
|
+
|
|
90
|
+
signals = [Signal(kind="pipe-to-shell", detail="curl | sh", where="package.json")]
|
|
91
|
+
finding = assess(young("@oxfmt/binding-x"), signals=signals, known_scope=True)
|
|
92
|
+
assert finding.verdict is Verdict.BLOCK
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestEndToEnd:
|
|
96
|
+
def test_a_family_of_binaries_produces_no_warnings(self, tmp_path, monkeypatch):
|
|
97
|
+
import json
|
|
98
|
+
|
|
99
|
+
from ghostpkg.cli import main
|
|
100
|
+
|
|
101
|
+
monkeypatch.setattr(
|
|
102
|
+
"ghostpkg.scanner.fetch",
|
|
103
|
+
lambda name, ecosystem: young(name, ecosystem=ecosystem),
|
|
104
|
+
)
|
|
105
|
+
deps = {f"@oxfmt/binding-{p}": "1.0.0" for p in ("darwin-arm64", "linux-x64", "win32-x64")}
|
|
106
|
+
(tmp_path / "package.json").write_text(
|
|
107
|
+
json.dumps({"name": "app", "dependencies": deps}), encoding="utf-8"
|
|
108
|
+
)
|
|
109
|
+
assert main(["scan", str(tmp_path / "package.json"), "--no-cache"]) == 0
|
|
110
|
+
|
|
111
|
+
def test_a_lone_scoped_newcomer_is_still_warned_about(self, tmp_path, monkeypatch, capsys):
|
|
112
|
+
import json
|
|
113
|
+
|
|
114
|
+
from ghostpkg.cli import main
|
|
115
|
+
|
|
116
|
+
monkeypatch.setattr(
|
|
117
|
+
"ghostpkg.scanner.fetch",
|
|
118
|
+
lambda name, ecosystem: young(name, ecosystem=ecosystem),
|
|
119
|
+
)
|
|
120
|
+
(tmp_path / "package.json").write_text(
|
|
121
|
+
json.dumps({"name": "app", "dependencies": {"@stranger/thing": "1.0.0"}}),
|
|
122
|
+
encoding="utf-8",
|
|
123
|
+
)
|
|
124
|
+
main(["scan", str(tmp_path / "package.json"), "--no-cache"])
|
|
125
|
+
assert "WARNING" in capsys.readouterr().out
|
|
126
|
+
|
|
127
|
+
def test_strict_still_fails_on_a_lone_newcomer(self, tmp_path, monkeypatch):
|
|
128
|
+
import json
|
|
129
|
+
|
|
130
|
+
from ghostpkg.cli import main
|
|
131
|
+
|
|
132
|
+
monkeypatch.setattr(
|
|
133
|
+
"ghostpkg.scanner.fetch",
|
|
134
|
+
lambda name, ecosystem: young(name, ecosystem=ecosystem),
|
|
135
|
+
)
|
|
136
|
+
(tmp_path / "package.json").write_text(
|
|
137
|
+
json.dumps({"name": "app", "dependencies": {"@stranger/thing": "1.0.0"}}),
|
|
138
|
+
encoding="utf-8",
|
|
139
|
+
)
|
|
140
|
+
assert main(["scan", str(tmp_path / "package.json"), "--strict", "--no-cache"]) == 1
|
|
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
|
|
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
|
|
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
|