ghostpkg 0.24.2__tar.gz → 0.24.4__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.2 → ghostpkg-0.24.4}/.github/workflows/publish.yml +15 -14
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.pre-commit-hooks.yaml +1 -1
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/2.0 +1 -1
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/CHANGELOG.md +34 -1
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/PKG-INFO +3 -3
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/README.en.md +2 -2
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/README.md +2 -2
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/__init__.py +1 -1
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/policy.py +181 -181
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/pyproject.toml +1 -1
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_action.py +27 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/ghostpkg-ignore.json +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/workflows/field.yml +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.gitignore +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/LICENSE +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/SECURITY.md +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/action.yml +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/assets/banner.html +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/assets/banner.png +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/assets/demo.gif +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/assets/make_demo.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/assess.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/cache.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/cli.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/data.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/discover.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/inspection.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/jslocks.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/manifests.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/prose.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/registries.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/report.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/rules.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/scanner.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/scripts/fieldtest.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_assess.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_cache.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_discover.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_field_findings.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_hostile_input.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_inspection.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_jslocks.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_lockfiles.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_manifests.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_mutation_gaps.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_not_a_package.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_policy.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_prose.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_registries.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_regressions.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_report.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_review_findings.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_stated_sources.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_trusted_scopes.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_versions.py +0 -0
- {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_withdrawn.py +0 -0
|
@@ -6,26 +6,24 @@
|
|
|
6
6
|
# whole subject is supply-chain trust, publishing from a laptop with a token
|
|
7
7
|
# pasted into the shell is the wrong example to set.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# Repository name: ghostpkg
|
|
12
|
-
# Workflow name: publish.yml
|
|
13
|
-
# Environment name: pypi
|
|
14
|
-
# Until that exists, this workflow will fail -- which is why it only runs when
|
|
15
|
-
# started by hand. Once a run has succeeded, add:
|
|
9
|
+
# The publisher is registered on PyPI, so this now runs on its own when a
|
|
10
|
+
# release is published:
|
|
16
11
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
12
|
+
# Owner: M1rwana12 Repository: ghostpkg
|
|
13
|
+
# Workflow: publish.yml Environment: pypi
|
|
19
14
|
#
|
|
20
|
-
#
|
|
15
|
+
# A manual run is still available for a tag that was released before this was
|
|
16
|
+
# wired up, or to retry one that failed after the release already existed.
|
|
21
17
|
|
|
22
18
|
name: publish
|
|
23
19
|
|
|
24
20
|
on:
|
|
21
|
+
release:
|
|
22
|
+
types: [published]
|
|
25
23
|
workflow_dispatch:
|
|
26
24
|
inputs:
|
|
27
25
|
tag:
|
|
28
|
-
description: Tag to build and publish, e.g. v0.
|
|
26
|
+
description: Tag to build and publish, e.g. v0.24.4
|
|
29
27
|
required: true
|
|
30
28
|
|
|
31
29
|
permissions:
|
|
@@ -37,7 +35,8 @@ jobs:
|
|
|
37
35
|
steps:
|
|
38
36
|
- uses: actions/checkout@v4
|
|
39
37
|
with:
|
|
40
|
-
|
|
38
|
+
# A release event carries its own tag; a manual run names one.
|
|
39
|
+
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
|
41
40
|
|
|
42
41
|
- uses: actions/setup-python@v5
|
|
43
42
|
with:
|
|
@@ -50,10 +49,12 @@ jobs:
|
|
|
50
49
|
python -m twine check dist/*
|
|
51
50
|
|
|
52
51
|
- name: Check the tag matches the version in the package
|
|
52
|
+
env:
|
|
53
|
+
TAG: ${{ inputs.tag || github.event.release.tag_name }}
|
|
53
54
|
run: |
|
|
54
55
|
version=$(python -c "import ghostpkg; print(ghostpkg.__version__)")
|
|
55
|
-
if [ "v$version" != "$
|
|
56
|
-
echo "::error::tag $
|
|
56
|
+
if [ "v$version" != "$TAG" ]; then
|
|
57
|
+
echo "::error::tag $TAG does not match __version__ $version"
|
|
57
58
|
exit 1
|
|
58
59
|
fi
|
|
59
60
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
[REAL PIP] install requests flask
|
|
1
|
+
[REAL PIP] install requests flask
|
|
@@ -6,6 +6,37 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.24.4] - 2026-09-04
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **Releases are published by CI through PyPI Trusted Publishing.** The
|
|
13
|
+
publisher is registered, so `publish.yml` now runs when a release is
|
|
14
|
+
published rather than only when started by hand. PyPI mints a short-lived
|
|
15
|
+
credential for this exact workflow in this exact repository, so there is no
|
|
16
|
+
long-lived token to leak.
|
|
17
|
+
|
|
18
|
+
This is the first release built and uploaded that way rather than from a
|
|
19
|
+
laptop with a token pasted into the shell. For a tool whose whole subject is
|
|
20
|
+
supply-chain trust, that was the wrong example to set.
|
|
21
|
+
|
|
22
|
+
A manual run is still available, for a tag released before this was wired up
|
|
23
|
+
or to retry one that failed after the release already existed.
|
|
24
|
+
|
|
25
|
+
## [0.24.3] - 2026-09-04
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- **The documented snippets pinned `@v0.24.0`, two releases behind.** Each
|
|
29
|
+
release script replaced the *previous* version string, so a snippet already
|
|
30
|
+
left on an older one was never touched again. The GitHub Marketplace listing
|
|
31
|
+
renders that snippet, so it told people to pin a version predating the fixes
|
|
32
|
+
it advertises.
|
|
33
|
+
|
|
34
|
+
Replacement is now by pattern rather than by exact previous string, and a
|
|
35
|
+
test fails if any documented pin disagrees with `__version__` -- with a
|
|
36
|
+
second test guarding against the patterns silently matching nothing.
|
|
37
|
+
|
|
38
|
+
711 tests.
|
|
39
|
+
|
|
9
40
|
## [0.24.2] - 2026-09-04
|
|
10
41
|
|
|
11
42
|
### Changed
|
|
@@ -1015,7 +1046,9 @@ First release.
|
|
|
1015
1046
|
- No corpus of hallucinated package names is shipped, following the decision of
|
|
1016
1047
|
the USENIX'25 authors not to publish theirs.
|
|
1017
1048
|
|
|
1018
|
-
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.24.
|
|
1049
|
+
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.24.4...HEAD
|
|
1050
|
+
[0.24.4]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.4
|
|
1051
|
+
[0.24.3]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.3
|
|
1019
1052
|
[0.24.2]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.2
|
|
1020
1053
|
[0.24.1]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.1
|
|
1021
1054
|
[0.24.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ghostpkg
|
|
3
|
-
Version: 0.24.
|
|
3
|
+
Version: 0.24.4
|
|
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
|
|
@@ -445,7 +445,7 @@ length, and only applies to packages young enough to plausibly be a squat.
|
|
|
445
445
|
### In CI
|
|
446
446
|
|
|
447
447
|
```yaml
|
|
448
|
-
- uses: M1rwana12/ghostpkg@v0.24.
|
|
448
|
+
- uses: M1rwana12/ghostpkg@v0.24.4
|
|
449
449
|
```
|
|
450
450
|
|
|
451
451
|
That is the whole step. It searches the checkout, skips `node_modules` and
|
|
@@ -476,7 +476,7 @@ Or without the action, if you prefer:
|
|
|
476
476
|
```yaml
|
|
477
477
|
repos:
|
|
478
478
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
479
|
-
rev: v0.24.
|
|
479
|
+
rev: v0.24.4
|
|
480
480
|
hooks:
|
|
481
481
|
- id: ghostpkg
|
|
482
482
|
```
|
|
@@ -397,7 +397,7 @@ length, and only applies to packages young enough to plausibly be a squat.
|
|
|
397
397
|
### In CI
|
|
398
398
|
|
|
399
399
|
```yaml
|
|
400
|
-
- uses: M1rwana12/ghostpkg@v0.24.
|
|
400
|
+
- uses: M1rwana12/ghostpkg@v0.24.4
|
|
401
401
|
```
|
|
402
402
|
|
|
403
403
|
That is the whole step. It searches the checkout, skips `node_modules` and
|
|
@@ -428,7 +428,7 @@ Or without the action, if you prefer:
|
|
|
428
428
|
```yaml
|
|
429
429
|
repos:
|
|
430
430
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
431
|
-
rev: v0.24.
|
|
431
|
+
rev: v0.24.4
|
|
432
432
|
hooks:
|
|
433
433
|
- id: ghostpkg
|
|
434
434
|
```
|
|
@@ -194,7 +194,7 @@ ghostpkg scan ../інший-проєкт
|
|
|
194
194
|
### У CI
|
|
195
195
|
|
|
196
196
|
```yaml
|
|
197
|
-
- uses: M1rwana12/ghostpkg@v0.24.
|
|
197
|
+
- uses: M1rwana12/ghostpkg@v0.24.4
|
|
198
198
|
```
|
|
199
199
|
|
|
200
200
|
Це весь крок. Він шукає маніфести в чекауті, обходить `node_modules` і подібні
|
|
@@ -218,7 +218,7 @@ ghostpkg scan ../інший-проєкт
|
|
|
218
218
|
```yaml
|
|
219
219
|
repos:
|
|
220
220
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
221
|
-
rev: v0.24.
|
|
221
|
+
rev: v0.24.4
|
|
222
222
|
hooks:
|
|
223
223
|
- id: ghostpkg
|
|
224
224
|
```
|
|
@@ -1,181 +1,181 @@
|
|
|
1
|
-
"""Suppressing findings you have already decided about.
|
|
2
|
-
|
|
3
|
-
One false positive is enough for a team to remove a security check from CI, so
|
|
4
|
-
there has to be a way to say "we know about this one". Three things make that
|
|
5
|
-
safe rather than a hole:
|
|
6
|
-
|
|
7
|
-
**The file is never read from the project directory.** ghostpkg is meant to sit
|
|
8
|
-
in front of a coding agent, and an agent with shell access can edit files in the
|
|
9
|
-
repository it is working on. A `.ghostpkgignore` next to the code would be a
|
|
10
|
-
suppression list the thing being guarded can rewrite. So the file comes from an
|
|
11
|
-
explicit path, an environment variable, or the user's own config directory --
|
|
12
|
-
never from the tree being scanned.
|
|
13
|
-
|
|
14
|
-
**A reason is required.** An entry nobody can justify in one sentence is an
|
|
15
|
-
entry nobody will dare remove later.
|
|
16
|
-
|
|
17
|
-
**A malformed file is an error, not a shrug.** Degrading quietly to "no
|
|
18
|
-
suppressions" would be the safe direction; degrading quietly to "no protection"
|
|
19
|
-
would not, and from the outside the two are indistinguishable. Failing loudly
|
|
20
|
-
is the only honest option.
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
from __future__ import annotations
|
|
24
|
-
|
|
25
|
-
import fnmatch
|
|
26
|
-
import json
|
|
27
|
-
import os
|
|
28
|
-
import sys
|
|
29
|
-
from dataclasses import dataclass
|
|
30
|
-
from datetime import date
|
|
31
|
-
from pathlib import Path
|
|
32
|
-
|
|
33
|
-
CONFIG_ENV = "GHOSTPKG_CONFIG"
|
|
34
|
-
CONFIG_NAME = "ignore.json"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class PolicyError(ValueError):
|
|
38
|
-
"""The ignore file exists but could not be used."""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@dataclass(frozen=True)
|
|
42
|
-
class Rule:
|
|
43
|
-
"""One suppression. `package` and `rule` accept glob patterns."""
|
|
44
|
-
|
|
45
|
-
package: str
|
|
46
|
-
reason: str
|
|
47
|
-
ecosystem: str = "*"
|
|
48
|
-
rule: str = "*"
|
|
49
|
-
expires: date | None = None
|
|
50
|
-
|
|
51
|
-
def expired(self, today: date | None = None) -> bool:
|
|
52
|
-
return self.expires is not None and self.expires < (today or date.today())
|
|
53
|
-
|
|
54
|
-
def matches(self, name: str, ecosystem: str, rule_id: str | None) -> bool:
|
|
55
|
-
if self.ecosystem != "*" and self.ecosystem != ecosystem:
|
|
56
|
-
return False
|
|
57
|
-
if self.rule != "*" and not fnmatch.fnmatch(rule_id or "", self.rule):
|
|
58
|
-
return False
|
|
59
|
-
return fnmatch.fnmatch(name.lower(), self.package.lower())
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def config_dir() -> Path:
|
|
63
|
-
if sys.platform == "win32":
|
|
64
|
-
base = os.environ.get("APPDATA") or os.path.expanduser("~")
|
|
65
|
-
return Path(base) / "ghostpkg"
|
|
66
|
-
if sys.platform == "darwin":
|
|
67
|
-
return Path(os.path.expanduser("~/Library/Application Support")) / "ghostpkg"
|
|
68
|
-
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
|
69
|
-
return Path(base) / "ghostpkg"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def locate(explicit: str | Path | None = None) -> Path | None:
|
|
73
|
-
"""Where the ignore file lives, or None. Never the project directory."""
|
|
74
|
-
if explicit:
|
|
75
|
-
return Path(explicit)
|
|
76
|
-
from_env = os.environ.get(CONFIG_ENV)
|
|
77
|
-
if from_env:
|
|
78
|
-
return Path(from_env)
|
|
79
|
-
candidate = config_dir() / CONFIG_NAME
|
|
80
|
-
return candidate if candidate.is_file() else None
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _parse_date(value, where: str) -> date | None:
|
|
84
|
-
if value is None:
|
|
85
|
-
return None
|
|
86
|
-
try:
|
|
87
|
-
return date.fromisoformat(str(value))
|
|
88
|
-
except ValueError:
|
|
89
|
-
raise PolicyError(
|
|
90
|
-
f"{where}: 'expires' must be a date like 2026-12-31, got {value!r}"
|
|
91
|
-
) from None
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def parse(text: str, origin: str = "ignore file") -> list[Rule]:
|
|
95
|
-
try:
|
|
96
|
-
data = json.loads(text)
|
|
97
|
-
except ValueError as exc:
|
|
98
|
-
raise PolicyError(f"{origin}: not valid JSON ({exc})") from None
|
|
99
|
-
|
|
100
|
-
entries = data.get("ignore") if isinstance(data, dict) else data
|
|
101
|
-
if not isinstance(entries, list):
|
|
102
|
-
raise PolicyError(f"{origin}: expected a list under 'ignore'")
|
|
103
|
-
|
|
104
|
-
rules: list[Rule] = []
|
|
105
|
-
for index, entry in enumerate(entries, 1):
|
|
106
|
-
where = f"{origin}, entry {index}"
|
|
107
|
-
if not isinstance(entry, dict):
|
|
108
|
-
raise PolicyError(f"{where}: expected an object")
|
|
109
|
-
package = entry.get("package")
|
|
110
|
-
reason = entry.get("reason")
|
|
111
|
-
if not package or not isinstance(package, str):
|
|
112
|
-
raise PolicyError(f"{where}: 'package' is required")
|
|
113
|
-
if not reason or not isinstance(reason, str):
|
|
114
|
-
raise PolicyError(
|
|
115
|
-
f"{where}: 'reason' is required -- a suppression nobody can "
|
|
116
|
-
f"justify is one nobody will dare remove"
|
|
117
|
-
)
|
|
118
|
-
rules.append(
|
|
119
|
-
Rule(
|
|
120
|
-
package=package,
|
|
121
|
-
reason=reason,
|
|
122
|
-
ecosystem=str(entry.get("ecosystem") or "*"),
|
|
123
|
-
rule=str(entry.get("rule") or "*"),
|
|
124
|
-
expires=_parse_date(entry.get("expires"), where),
|
|
125
|
-
)
|
|
126
|
-
)
|
|
127
|
-
return rules
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def load(explicit: str | Path | None = None) -> tuple[list[Rule], Path | None]:
|
|
131
|
-
"""Read the ignore file. Missing is fine; malformed is not."""
|
|
132
|
-
path = locate(explicit)
|
|
133
|
-
if path is None:
|
|
134
|
-
return [], None
|
|
135
|
-
try:
|
|
136
|
-
text = path.read_text(encoding="utf-8-sig")
|
|
137
|
-
except OSError as exc:
|
|
138
|
-
raise PolicyError(f"could not read {path}: {exc}") from None
|
|
139
|
-
return parse(text, str(path)), path
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def apply(finding, rules: list[Rule], today: date | None = None):
|
|
143
|
-
"""Drop suppressed reasons and re-derive the verdict.
|
|
144
|
-
|
|
145
|
-
Returns the finding and the rules that fired, so the caller can say what was
|
|
146
|
-
suppressed rather than silently showing a clean result.
|
|
147
|
-
"""
|
|
148
|
-
from .assess import Verdict # noqa: PLC0415 - avoids a circular import
|
|
149
|
-
|
|
150
|
-
if not rules:
|
|
151
|
-
return finding, []
|
|
152
|
-
|
|
153
|
-
kept, used = [], []
|
|
154
|
-
for reason in finding.reasons:
|
|
155
|
-
rule_id = getattr(reason, "rule", None)
|
|
156
|
-
match = next(
|
|
157
|
-
(
|
|
158
|
-
r
|
|
159
|
-
for r in rules
|
|
160
|
-
if not r.expired(today)
|
|
161
|
-
and r.matches(finding.name, finding.ecosystem, rule_id)
|
|
162
|
-
),
|
|
163
|
-
None,
|
|
164
|
-
)
|
|
165
|
-
if match is None:
|
|
166
|
-
kept.append(reason)
|
|
167
|
-
else:
|
|
168
|
-
used.append(match)
|
|
169
|
-
|
|
170
|
-
if not used:
|
|
171
|
-
return finding, []
|
|
172
|
-
|
|
173
|
-
finding.reasons = kept
|
|
174
|
-
if not kept:
|
|
175
|
-
finding.verdict = Verdict.OK
|
|
176
|
-
elif finding.verdict is Verdict.BLOCK and not any(
|
|
177
|
-
getattr(r, "rule", None) in finding.blocking for r in kept
|
|
178
|
-
):
|
|
179
|
-
# The reason that blocked is gone; what remains is only advisory.
|
|
180
|
-
finding.verdict = Verdict.WARN
|
|
181
|
-
return finding, used
|
|
1
|
+
"""Suppressing findings you have already decided about.
|
|
2
|
+
|
|
3
|
+
One false positive is enough for a team to remove a security check from CI, so
|
|
4
|
+
there has to be a way to say "we know about this one". Three things make that
|
|
5
|
+
safe rather than a hole:
|
|
6
|
+
|
|
7
|
+
**The file is never read from the project directory.** ghostpkg is meant to sit
|
|
8
|
+
in front of a coding agent, and an agent with shell access can edit files in the
|
|
9
|
+
repository it is working on. A `.ghostpkgignore` next to the code would be a
|
|
10
|
+
suppression list the thing being guarded can rewrite. So the file comes from an
|
|
11
|
+
explicit path, an environment variable, or the user's own config directory --
|
|
12
|
+
never from the tree being scanned.
|
|
13
|
+
|
|
14
|
+
**A reason is required.** An entry nobody can justify in one sentence is an
|
|
15
|
+
entry nobody will dare remove later.
|
|
16
|
+
|
|
17
|
+
**A malformed file is an error, not a shrug.** Degrading quietly to "no
|
|
18
|
+
suppressions" would be the safe direction; degrading quietly to "no protection"
|
|
19
|
+
would not, and from the outside the two are indistinguishable. Failing loudly
|
|
20
|
+
is the only honest option.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import fnmatch
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from datetime import date
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
CONFIG_ENV = "GHOSTPKG_CONFIG"
|
|
34
|
+
CONFIG_NAME = "ignore.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PolicyError(ValueError):
|
|
38
|
+
"""The ignore file exists but could not be used."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Rule:
|
|
43
|
+
"""One suppression. `package` and `rule` accept glob patterns."""
|
|
44
|
+
|
|
45
|
+
package: str
|
|
46
|
+
reason: str
|
|
47
|
+
ecosystem: str = "*"
|
|
48
|
+
rule: str = "*"
|
|
49
|
+
expires: date | None = None
|
|
50
|
+
|
|
51
|
+
def expired(self, today: date | None = None) -> bool:
|
|
52
|
+
return self.expires is not None and self.expires < (today or date.today())
|
|
53
|
+
|
|
54
|
+
def matches(self, name: str, ecosystem: str, rule_id: str | None) -> bool:
|
|
55
|
+
if self.ecosystem != "*" and self.ecosystem != ecosystem:
|
|
56
|
+
return False
|
|
57
|
+
if self.rule != "*" and not fnmatch.fnmatch(rule_id or "", self.rule):
|
|
58
|
+
return False
|
|
59
|
+
return fnmatch.fnmatch(name.lower(), self.package.lower())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def config_dir() -> Path:
|
|
63
|
+
if sys.platform == "win32":
|
|
64
|
+
base = os.environ.get("APPDATA") or os.path.expanduser("~")
|
|
65
|
+
return Path(base) / "ghostpkg"
|
|
66
|
+
if sys.platform == "darwin":
|
|
67
|
+
return Path(os.path.expanduser("~/Library/Application Support")) / "ghostpkg"
|
|
68
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
|
69
|
+
return Path(base) / "ghostpkg"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def locate(explicit: str | Path | None = None) -> Path | None:
|
|
73
|
+
"""Where the ignore file lives, or None. Never the project directory."""
|
|
74
|
+
if explicit:
|
|
75
|
+
return Path(explicit)
|
|
76
|
+
from_env = os.environ.get(CONFIG_ENV)
|
|
77
|
+
if from_env:
|
|
78
|
+
return Path(from_env)
|
|
79
|
+
candidate = config_dir() / CONFIG_NAME
|
|
80
|
+
return candidate if candidate.is_file() else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_date(value, where: str) -> date | None:
|
|
84
|
+
if value is None:
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
return date.fromisoformat(str(value))
|
|
88
|
+
except ValueError:
|
|
89
|
+
raise PolicyError(
|
|
90
|
+
f"{where}: 'expires' must be a date like 2026-12-31, got {value!r}"
|
|
91
|
+
) from None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse(text: str, origin: str = "ignore file") -> list[Rule]:
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(text)
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise PolicyError(f"{origin}: not valid JSON ({exc})") from None
|
|
99
|
+
|
|
100
|
+
entries = data.get("ignore") if isinstance(data, dict) else data
|
|
101
|
+
if not isinstance(entries, list):
|
|
102
|
+
raise PolicyError(f"{origin}: expected a list under 'ignore'")
|
|
103
|
+
|
|
104
|
+
rules: list[Rule] = []
|
|
105
|
+
for index, entry in enumerate(entries, 1):
|
|
106
|
+
where = f"{origin}, entry {index}"
|
|
107
|
+
if not isinstance(entry, dict):
|
|
108
|
+
raise PolicyError(f"{where}: expected an object")
|
|
109
|
+
package = entry.get("package")
|
|
110
|
+
reason = entry.get("reason")
|
|
111
|
+
if not package or not isinstance(package, str):
|
|
112
|
+
raise PolicyError(f"{where}: 'package' is required")
|
|
113
|
+
if not reason or not isinstance(reason, str):
|
|
114
|
+
raise PolicyError(
|
|
115
|
+
f"{where}: 'reason' is required -- a suppression nobody can "
|
|
116
|
+
f"justify is one nobody will dare remove"
|
|
117
|
+
)
|
|
118
|
+
rules.append(
|
|
119
|
+
Rule(
|
|
120
|
+
package=package,
|
|
121
|
+
reason=reason,
|
|
122
|
+
ecosystem=str(entry.get("ecosystem") or "*"),
|
|
123
|
+
rule=str(entry.get("rule") or "*"),
|
|
124
|
+
expires=_parse_date(entry.get("expires"), where),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return rules
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load(explicit: str | Path | None = None) -> tuple[list[Rule], Path | None]:
|
|
131
|
+
"""Read the ignore file. Missing is fine; malformed is not."""
|
|
132
|
+
path = locate(explicit)
|
|
133
|
+
if path is None:
|
|
134
|
+
return [], None
|
|
135
|
+
try:
|
|
136
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
137
|
+
except OSError as exc:
|
|
138
|
+
raise PolicyError(f"could not read {path}: {exc}") from None
|
|
139
|
+
return parse(text, str(path)), path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def apply(finding, rules: list[Rule], today: date | None = None):
|
|
143
|
+
"""Drop suppressed reasons and re-derive the verdict.
|
|
144
|
+
|
|
145
|
+
Returns the finding and the rules that fired, so the caller can say what was
|
|
146
|
+
suppressed rather than silently showing a clean result.
|
|
147
|
+
"""
|
|
148
|
+
from .assess import Verdict # noqa: PLC0415 - avoids a circular import
|
|
149
|
+
|
|
150
|
+
if not rules:
|
|
151
|
+
return finding, []
|
|
152
|
+
|
|
153
|
+
kept, used = [], []
|
|
154
|
+
for reason in finding.reasons:
|
|
155
|
+
rule_id = getattr(reason, "rule", None)
|
|
156
|
+
match = next(
|
|
157
|
+
(
|
|
158
|
+
r
|
|
159
|
+
for r in rules
|
|
160
|
+
if not r.expired(today)
|
|
161
|
+
and r.matches(finding.name, finding.ecosystem, rule_id)
|
|
162
|
+
),
|
|
163
|
+
None,
|
|
164
|
+
)
|
|
165
|
+
if match is None:
|
|
166
|
+
kept.append(reason)
|
|
167
|
+
else:
|
|
168
|
+
used.append(match)
|
|
169
|
+
|
|
170
|
+
if not used:
|
|
171
|
+
return finding, []
|
|
172
|
+
|
|
173
|
+
finding.reasons = kept
|
|
174
|
+
if not kept:
|
|
175
|
+
finding.verdict = Verdict.OK
|
|
176
|
+
elif finding.verdict is Verdict.BLOCK and not any(
|
|
177
|
+
getattr(r, "rule", None) in finding.blocking for r in kept
|
|
178
|
+
):
|
|
179
|
+
# The reason that blocked is gone; what remains is only advisory.
|
|
180
|
+
finding.verdict = Verdict.WARN
|
|
181
|
+
return finding, used
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ghostpkg"
|
|
7
|
-
version = "0.24.
|
|
7
|
+
version = "0.24.4"
|
|
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"
|
|
@@ -189,3 +189,30 @@ class TestTheHookIsWellFormed:
|
|
|
189
189
|
)
|
|
190
190
|
def test_unrelated_files_are_not_matched(self, path):
|
|
191
191
|
assert not re.search(hook()["files"], path)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class TestTheDocumentedVersionIsTheRealOne:
|
|
195
|
+
"""Every release script replaced the *previous* version string, so a
|
|
196
|
+
snippet left on an older one was never touched again. `README.md` sat on
|
|
197
|
+
`@v0.24.0` through two releases, and the GitHub Marketplace renders that
|
|
198
|
+
snippet -- so the listing told people to pin a version that predates the
|
|
199
|
+
fixes it advertises."""
|
|
200
|
+
|
|
201
|
+
@pytest.mark.parametrize(
|
|
202
|
+
"path", ["README.md", "README.en.md", ".pre-commit-hooks.yaml"]
|
|
203
|
+
)
|
|
204
|
+
def test_every_pinned_version_matches_the_package(self, path):
|
|
205
|
+
from ghostpkg import __version__
|
|
206
|
+
|
|
207
|
+
text = read(ROOT / path)
|
|
208
|
+
pinned = set(re.findall(r"M1rwana12/ghostpkg@v([0-9.]+)", text))
|
|
209
|
+
pinned |= set(re.findall(r"rev: v([0-9.]+)", text))
|
|
210
|
+
assert pinned <= {__version__}, f"{path} pins {pinned}, package is {__version__}"
|
|
211
|
+
|
|
212
|
+
def test_at_least_one_snippet_is_pinned(self):
|
|
213
|
+
"""A guard on the guard: if the patterns stop matching, the test above
|
|
214
|
+
passes for the wrong reason."""
|
|
215
|
+
from ghostpkg import __version__
|
|
216
|
+
|
|
217
|
+
text = read(ROOT / "README.en.md") + read(ROOT / "README.md")
|
|
218
|
+
assert f"M1rwana12/ghostpkg@v{__version__}" in text
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|