vendorcheck 0.2.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.
- vendorcheck-0.2.0/LICENSE +21 -0
- vendorcheck-0.2.0/PKG-INFO +110 -0
- vendorcheck-0.2.0/README.md +95 -0
- vendorcheck-0.2.0/pyproject.toml +29 -0
- vendorcheck-0.2.0/setup.cfg +4 -0
- vendorcheck-0.2.0/vendorcheck.egg-info/PKG-INFO +110 -0
- vendorcheck-0.2.0/vendorcheck.egg-info/SOURCES.txt +9 -0
- vendorcheck-0.2.0/vendorcheck.egg-info/dependency_links.txt +1 -0
- vendorcheck-0.2.0/vendorcheck.egg-info/entry_points.txt +2 -0
- vendorcheck-0.2.0/vendorcheck.egg-info/top_level.txt +1 -0
- vendorcheck-0.2.0/vendorcheck.py +494 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ferdi-krbk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vendorcheck
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Check whether embedded/vendored Python packages are identical to the official PyPI release.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Ferdi-krbk/vendorcheck
|
|
7
|
+
Keywords: supply-chain,security,vendoring,pypi,audit
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Security
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# vendorcheck
|
|
17
|
+
|
|
18
|
+
[](https://github.com/Ferdi-krbk/vendorcheck/actions/workflows/ci.yml)
|
|
19
|
+
[](LICENSE)
|
|
20
|
+
|
|
21
|
+
**Is the Python library bundled inside your app, add-on or extension byte-for-byte the official PyPI release?**
|
|
22
|
+
|
|
23
|
+
Point it at a folder or an archive (`.zip`, `.xpi`, `.vsix`, `.fda`, `.whl`, any app bundle that is a zip). It finds the embedded Python packages, downloads the official release from PyPI, verifies the download against PyPI's published SHA-256, and compares **file by file**.
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
$ vendorcheck lib/ -v # idna with one file edited and one file added
|
|
27
|
+
idna 3.7: 8 files identical, 1 modified, 1 extra.
|
|
28
|
+
[modified] idna/core.py
|
|
29
|
+
[extra] idna/evil.py
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Real run on a clean `pip install --target` of yt-dlp:
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
$ vendorcheck lib/
|
|
36
|
+
yt_dlp 2026.8.19: 1049 files identical, 0 modified, 0 extra.
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
Software supply-chain attacks often hide in *copies* of popular libraries: a vendored package that was quietly patched, or has an extra file dropped in. Lockfiles, `pip-audit` and signature checks look at what PyPI serves, not at the copy shipped inside your artifact. `vendorcheck` closes that gap.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install git+https://github.com/Ferdi-krbk/vendorcheck
|
|
47
|
+
# or just download vendorcheck.py - it is a single file, stdlib only, Python 3.9+
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
vendorcheck PATH [-v | -vv] [--json] [--lang en|tr] [--only NAME ...] [--ignore GLOB ...]
|
|
54
|
+
[--no-nested] [--cache DIR] [--allow-empty]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Option | Meaning |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `-v` | list every modified / extra / missing file |
|
|
60
|
+
| `-vv` | additionally print a unified diff (official → embedded) for modified text files |
|
|
61
|
+
| `--json` | machine-readable output (includes diffs with `-vv`) |
|
|
62
|
+
| `--only NAME ...` | check just these packages |
|
|
63
|
+
| `--ignore GLOB` | skip files matching the glob (relative to the package dir, e.g. `yt_dlp/version.py`); repeatable. For intentional local patches |
|
|
64
|
+
| `--no-nested` | don't open archives found inside the input |
|
|
65
|
+
| `--cache DIR` | reuse downloaded official releases |
|
|
66
|
+
| `--allow-empty` | exit 0 if no embedded package is found (default: exit 2, so CI can't pass by checking nothing) |
|
|
67
|
+
| `--lang tr` | Turkish output |
|
|
68
|
+
|
|
69
|
+
Archives nested inside the input (`.zip .whl .egg .xpi .vsix .fda .pyz`, up to 2 levels, 2 GiB guard) are opened automatically and reported as `pkg 1.0 [inner.xpi!/]`.
|
|
70
|
+
|
|
71
|
+
**Exit codes:** `0` identical · `1` differences found · `2` error / nothing checked.
|
|
72
|
+
|
|
73
|
+
In GitHub Actions the result is also written to the job's **step summary** as a table.
|
|
74
|
+
|
|
75
|
+
## GitHub Action
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
- uses: actions/checkout@v4
|
|
79
|
+
- uses: Ferdi-krbk/vendorcheck@v1
|
|
80
|
+
with:
|
|
81
|
+
path: build/addon # folder or .xpi/.vsix/.zip
|
|
82
|
+
# only: "yt-dlp requests"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The job fails when embedded code differs from the official release. The repo's own [CI](.github/workflows/ci.yml) proves it: a clean vendored tree must pass and a tampered one must fail.
|
|
86
|
+
|
|
87
|
+
## How it works
|
|
88
|
+
|
|
89
|
+
1. **Detect** packages through `*.dist-info/METADATA`, `*.egg-info/PKG-INFO`, or `<pkg>/version.py` (`__version__ = "..."`).
|
|
90
|
+
2. **Fetch** `https://pypi.org/pypi/<name>/<version>/json`, prefer a pure `py3-none-any` wheel, fall back to the sdist, and verify its SHA-256 against PyPI's digest.
|
|
91
|
+
3. **Compare** each file's SHA-256. Line-ending-only differences (CRLF vs LF) are tolerated; `__pycache__` and `.pyc` are ignored.
|
|
92
|
+
4. **Report** identical / modified / extra (and missing) files.
|
|
93
|
+
|
|
94
|
+
Archives are extracted with zip-slip protection; nothing from the audited artifact is ever executed.
|
|
95
|
+
|
|
96
|
+
## Limits (v1)
|
|
97
|
+
|
|
98
|
+
- Only packages with version metadata (see above). Guessing name/version for bare copies is out of scope.
|
|
99
|
+
- If PyPI only has platform wheels or an sdist, generated or compiled files show up as "extra".
|
|
100
|
+
- Archives nested deeper than 2 levels are not opened.
|
|
101
|
+
- It proves your copy equals what PyPI serves, not that the PyPI release itself is trustworthy. Pair it with `pip-audit` and provenance tools like `trustcheck`.
|
|
102
|
+
|
|
103
|
+
## Related
|
|
104
|
+
|
|
105
|
+
- [trustcheck](https://pypi.org/project/trustcheck/) verifies provenance/signatures of PyPI releases, not embedded code.
|
|
106
|
+
- [vendoring](https://pypi.org/project/vendoring/) automates *creating* vendored trees; `vendorcheck` *audits* them.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# vendorcheck
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Ferdi-krbk/vendorcheck/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
**Is the Python library bundled inside your app, add-on or extension byte-for-byte the official PyPI release?**
|
|
7
|
+
|
|
8
|
+
Point it at a folder or an archive (`.zip`, `.xpi`, `.vsix`, `.fda`, `.whl`, any app bundle that is a zip). It finds the embedded Python packages, downloads the official release from PyPI, verifies the download against PyPI's published SHA-256, and compares **file by file**.
|
|
9
|
+
|
|
10
|
+
```console
|
|
11
|
+
$ vendorcheck lib/ -v # idna with one file edited and one file added
|
|
12
|
+
idna 3.7: 8 files identical, 1 modified, 1 extra.
|
|
13
|
+
[modified] idna/core.py
|
|
14
|
+
[extra] idna/evil.py
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Real run on a clean `pip install --target` of yt-dlp:
|
|
18
|
+
|
|
19
|
+
```console
|
|
20
|
+
$ vendorcheck lib/
|
|
21
|
+
yt_dlp 2026.8.19: 1049 files identical, 0 modified, 0 extra.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
Software supply-chain attacks often hide in *copies* of popular libraries: a vendored package that was quietly patched, or has an extra file dropped in. Lockfiles, `pip-audit` and signature checks look at what PyPI serves, not at the copy shipped inside your artifact. `vendorcheck` closes that gap.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install git+https://github.com/Ferdi-krbk/vendorcheck
|
|
32
|
+
# or just download vendorcheck.py - it is a single file, stdlib only, Python 3.9+
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
vendorcheck PATH [-v | -vv] [--json] [--lang en|tr] [--only NAME ...] [--ignore GLOB ...]
|
|
39
|
+
[--no-nested] [--cache DIR] [--allow-empty]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Option | Meaning |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `-v` | list every modified / extra / missing file |
|
|
45
|
+
| `-vv` | additionally print a unified diff (official → embedded) for modified text files |
|
|
46
|
+
| `--json` | machine-readable output (includes diffs with `-vv`) |
|
|
47
|
+
| `--only NAME ...` | check just these packages |
|
|
48
|
+
| `--ignore GLOB` | skip files matching the glob (relative to the package dir, e.g. `yt_dlp/version.py`); repeatable. For intentional local patches |
|
|
49
|
+
| `--no-nested` | don't open archives found inside the input |
|
|
50
|
+
| `--cache DIR` | reuse downloaded official releases |
|
|
51
|
+
| `--allow-empty` | exit 0 if no embedded package is found (default: exit 2, so CI can't pass by checking nothing) |
|
|
52
|
+
| `--lang tr` | Turkish output |
|
|
53
|
+
|
|
54
|
+
Archives nested inside the input (`.zip .whl .egg .xpi .vsix .fda .pyz`, up to 2 levels, 2 GiB guard) are opened automatically and reported as `pkg 1.0 [inner.xpi!/]`.
|
|
55
|
+
|
|
56
|
+
**Exit codes:** `0` identical · `1` differences found · `2` error / nothing checked.
|
|
57
|
+
|
|
58
|
+
In GitHub Actions the result is also written to the job's **step summary** as a table.
|
|
59
|
+
|
|
60
|
+
## GitHub Action
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
- uses: actions/checkout@v4
|
|
64
|
+
- uses: Ferdi-krbk/vendorcheck@v1
|
|
65
|
+
with:
|
|
66
|
+
path: build/addon # folder or .xpi/.vsix/.zip
|
|
67
|
+
# only: "yt-dlp requests"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The job fails when embedded code differs from the official release. The repo's own [CI](.github/workflows/ci.yml) proves it: a clean vendored tree must pass and a tampered one must fail.
|
|
71
|
+
|
|
72
|
+
## How it works
|
|
73
|
+
|
|
74
|
+
1. **Detect** packages through `*.dist-info/METADATA`, `*.egg-info/PKG-INFO`, or `<pkg>/version.py` (`__version__ = "..."`).
|
|
75
|
+
2. **Fetch** `https://pypi.org/pypi/<name>/<version>/json`, prefer a pure `py3-none-any` wheel, fall back to the sdist, and verify its SHA-256 against PyPI's digest.
|
|
76
|
+
3. **Compare** each file's SHA-256. Line-ending-only differences (CRLF vs LF) are tolerated; `__pycache__` and `.pyc` are ignored.
|
|
77
|
+
4. **Report** identical / modified / extra (and missing) files.
|
|
78
|
+
|
|
79
|
+
Archives are extracted with zip-slip protection; nothing from the audited artifact is ever executed.
|
|
80
|
+
|
|
81
|
+
## Limits (v1)
|
|
82
|
+
|
|
83
|
+
- Only packages with version metadata (see above). Guessing name/version for bare copies is out of scope.
|
|
84
|
+
- If PyPI only has platform wheels or an sdist, generated or compiled files show up as "extra".
|
|
85
|
+
- Archives nested deeper than 2 levels are not opened.
|
|
86
|
+
- It proves your copy equals what PyPI serves, not that the PyPI release itself is trustworthy. Pair it with `pip-audit` and provenance tools like `trustcheck`.
|
|
87
|
+
|
|
88
|
+
## Related
|
|
89
|
+
|
|
90
|
+
- [trustcheck](https://pypi.org/project/trustcheck/) verifies provenance/signatures of PyPI releases, not embedded code.
|
|
91
|
+
- [vendoring](https://pypi.org/project/vendoring/) automates *creating* vendored trees; `vendorcheck` *audits* them.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vendorcheck"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Check whether embedded/vendored Python packages are identical to the official PyPI release."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
keywords = ["supply-chain", "security", "vendoring", "pypi", "audit"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Topic :: Security",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
vendorcheck = "vendorcheck:main"
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/Ferdi-krbk/vendorcheck"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
py-modules = ["vendorcheck"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.dynamic]
|
|
29
|
+
version = { attr = "vendorcheck.__version__" }
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vendorcheck
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Check whether embedded/vendored Python packages are identical to the official PyPI release.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Ferdi-krbk/vendorcheck
|
|
7
|
+
Keywords: supply-chain,security,vendoring,pypi,audit
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Security
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# vendorcheck
|
|
17
|
+
|
|
18
|
+
[](https://github.com/Ferdi-krbk/vendorcheck/actions/workflows/ci.yml)
|
|
19
|
+
[](LICENSE)
|
|
20
|
+
|
|
21
|
+
**Is the Python library bundled inside your app, add-on or extension byte-for-byte the official PyPI release?**
|
|
22
|
+
|
|
23
|
+
Point it at a folder or an archive (`.zip`, `.xpi`, `.vsix`, `.fda`, `.whl`, any app bundle that is a zip). It finds the embedded Python packages, downloads the official release from PyPI, verifies the download against PyPI's published SHA-256, and compares **file by file**.
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
$ vendorcheck lib/ -v # idna with one file edited and one file added
|
|
27
|
+
idna 3.7: 8 files identical, 1 modified, 1 extra.
|
|
28
|
+
[modified] idna/core.py
|
|
29
|
+
[extra] idna/evil.py
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Real run on a clean `pip install --target` of yt-dlp:
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
$ vendorcheck lib/
|
|
36
|
+
yt_dlp 2026.8.19: 1049 files identical, 0 modified, 0 extra.
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
Software supply-chain attacks often hide in *copies* of popular libraries: a vendored package that was quietly patched, or has an extra file dropped in. Lockfiles, `pip-audit` and signature checks look at what PyPI serves, not at the copy shipped inside your artifact. `vendorcheck` closes that gap.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install git+https://github.com/Ferdi-krbk/vendorcheck
|
|
47
|
+
# or just download vendorcheck.py - it is a single file, stdlib only, Python 3.9+
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
vendorcheck PATH [-v | -vv] [--json] [--lang en|tr] [--only NAME ...] [--ignore GLOB ...]
|
|
54
|
+
[--no-nested] [--cache DIR] [--allow-empty]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Option | Meaning |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `-v` | list every modified / extra / missing file |
|
|
60
|
+
| `-vv` | additionally print a unified diff (official → embedded) for modified text files |
|
|
61
|
+
| `--json` | machine-readable output (includes diffs with `-vv`) |
|
|
62
|
+
| `--only NAME ...` | check just these packages |
|
|
63
|
+
| `--ignore GLOB` | skip files matching the glob (relative to the package dir, e.g. `yt_dlp/version.py`); repeatable. For intentional local patches |
|
|
64
|
+
| `--no-nested` | don't open archives found inside the input |
|
|
65
|
+
| `--cache DIR` | reuse downloaded official releases |
|
|
66
|
+
| `--allow-empty` | exit 0 if no embedded package is found (default: exit 2, so CI can't pass by checking nothing) |
|
|
67
|
+
| `--lang tr` | Turkish output |
|
|
68
|
+
|
|
69
|
+
Archives nested inside the input (`.zip .whl .egg .xpi .vsix .fda .pyz`, up to 2 levels, 2 GiB guard) are opened automatically and reported as `pkg 1.0 [inner.xpi!/]`.
|
|
70
|
+
|
|
71
|
+
**Exit codes:** `0` identical · `1` differences found · `2` error / nothing checked.
|
|
72
|
+
|
|
73
|
+
In GitHub Actions the result is also written to the job's **step summary** as a table.
|
|
74
|
+
|
|
75
|
+
## GitHub Action
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
- uses: actions/checkout@v4
|
|
79
|
+
- uses: Ferdi-krbk/vendorcheck@v1
|
|
80
|
+
with:
|
|
81
|
+
path: build/addon # folder or .xpi/.vsix/.zip
|
|
82
|
+
# only: "yt-dlp requests"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The job fails when embedded code differs from the official release. The repo's own [CI](.github/workflows/ci.yml) proves it: a clean vendored tree must pass and a tampered one must fail.
|
|
86
|
+
|
|
87
|
+
## How it works
|
|
88
|
+
|
|
89
|
+
1. **Detect** packages through `*.dist-info/METADATA`, `*.egg-info/PKG-INFO`, or `<pkg>/version.py` (`__version__ = "..."`).
|
|
90
|
+
2. **Fetch** `https://pypi.org/pypi/<name>/<version>/json`, prefer a pure `py3-none-any` wheel, fall back to the sdist, and verify its SHA-256 against PyPI's digest.
|
|
91
|
+
3. **Compare** each file's SHA-256. Line-ending-only differences (CRLF vs LF) are tolerated; `__pycache__` and `.pyc` are ignored.
|
|
92
|
+
4. **Report** identical / modified / extra (and missing) files.
|
|
93
|
+
|
|
94
|
+
Archives are extracted with zip-slip protection; nothing from the audited artifact is ever executed.
|
|
95
|
+
|
|
96
|
+
## Limits (v1)
|
|
97
|
+
|
|
98
|
+
- Only packages with version metadata (see above). Guessing name/version for bare copies is out of scope.
|
|
99
|
+
- If PyPI only has platform wheels or an sdist, generated or compiled files show up as "extra".
|
|
100
|
+
- Archives nested deeper than 2 levels are not opened.
|
|
101
|
+
- It proves your copy equals what PyPI serves, not that the PyPI release itself is trustworthy. Pair it with `pip-audit` and provenance tools like `trustcheck`.
|
|
102
|
+
|
|
103
|
+
## Related
|
|
104
|
+
|
|
105
|
+
- [trustcheck](https://pypi.org/project/trustcheck/) verifies provenance/signatures of PyPI releases, not embedded code.
|
|
106
|
+
- [vendoring](https://pypi.org/project/vendoring/) automates *creating* vendored trees; `vendorcheck` *audits* them.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vendorcheck
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""vendorcheck: is the embedded Python library identical to the official PyPI release?
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python vendorcheck.py PATH [--json] [-v] [--lang en|tr] [--cache DIR]
|
|
6
|
+
[--only NAME ...] [--allow-empty]
|
|
7
|
+
|
|
8
|
+
PATH is a directory or a zip-like archive (.zip, .fda, .xpi, .vsix, .whl, ...).
|
|
9
|
+
v1 scope: only packages whose name+version can be read from metadata
|
|
10
|
+
(*.dist-info/METADATA, *.egg-info/PKG-INFO) or from <pkg>/version.py.
|
|
11
|
+
|
|
12
|
+
Exit code: 0 = everything identical, 1 = differences found,
|
|
13
|
+
2 = error or nothing to check.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import difflib
|
|
19
|
+
import fnmatch
|
|
20
|
+
import hashlib
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
import tarfile
|
|
28
|
+
import tempfile
|
|
29
|
+
import time
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.request
|
|
32
|
+
import zipfile
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
__version__ = "0.2.0"
|
|
37
|
+
IGNORED_DIRS = {"__pycache__"}
|
|
38
|
+
IGNORED_SUFFIXES = (".pyc", ".pyo")
|
|
39
|
+
UA = {"User-Agent": f"vendorcheck/{__version__} (+https://github.com/Ferdi-krbk/vendorcheck)"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ----------------------------------------------------------------- helpers
|
|
43
|
+
def norm_name(name: str) -> str:
|
|
44
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sha256_bytes(data: bytes) -> str:
|
|
48
|
+
return hashlib.sha256(data).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sha256_file(path: Path) -> str:
|
|
52
|
+
h = hashlib.sha256()
|
|
53
|
+
with open(path, "rb") as f:
|
|
54
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
55
|
+
h.update(chunk)
|
|
56
|
+
return h.hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def safe_extract_zip(zf: zipfile.ZipFile, dest: Path) -> None:
|
|
60
|
+
dest = dest.resolve()
|
|
61
|
+
for info in zf.infolist():
|
|
62
|
+
target = (dest / info.filename).resolve()
|
|
63
|
+
if dest != target and dest not in target.parents:
|
|
64
|
+
raise RuntimeError(f"unsafe path in archive: {info.filename}")
|
|
65
|
+
zf.extractall(dest)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def safe_extract_tar(tf: tarfile.TarFile, dest: Path) -> None:
|
|
69
|
+
dest = dest.resolve()
|
|
70
|
+
members = []
|
|
71
|
+
for m in tf.getmembers():
|
|
72
|
+
target = (dest / m.name).resolve()
|
|
73
|
+
if dest != target and dest not in target.parents:
|
|
74
|
+
raise RuntimeError(f"unsafe path in archive: {m.name}")
|
|
75
|
+
if m.isfile() or m.isdir(): # skip symlinks/devices
|
|
76
|
+
members.append(m)
|
|
77
|
+
tf.extractall(dest, members=members)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_ignored(rel: Path) -> bool:
|
|
81
|
+
return any(p in IGNORED_DIRS for p in rel.parts) or rel.name.endswith(IGNORED_SUFFIXES)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read_text(p: Path) -> str:
|
|
85
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------- discovery
|
|
89
|
+
@dataclass
|
|
90
|
+
class Embedded:
|
|
91
|
+
name: str # distribution name (best guess for version.py case)
|
|
92
|
+
version: str
|
|
93
|
+
container: Path # directory that holds the package dirs
|
|
94
|
+
tops: list[str] # top-level package dir names / module names
|
|
95
|
+
source: str # "dist-info" | "egg-info" | "version.py"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_metadata(text: str) -> tuple[str | None, str | None]:
|
|
99
|
+
name = version = None
|
|
100
|
+
for line in text.splitlines():
|
|
101
|
+
if not line.strip():
|
|
102
|
+
break # end of headers
|
|
103
|
+
if line.startswith("Name:") and name is None:
|
|
104
|
+
name = line.split(":", 1)[1].strip()
|
|
105
|
+
elif line.startswith("Version:") and version is None:
|
|
106
|
+
version = line.split(":", 1)[1].strip()
|
|
107
|
+
return name, version
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tops_from_info(info_dir: Path, fallback: str) -> list[str]:
|
|
111
|
+
container = info_dir.parent
|
|
112
|
+
tl = info_dir / "top_level.txt"
|
|
113
|
+
cands: list[str] = []
|
|
114
|
+
if tl.is_file():
|
|
115
|
+
cands = [l.strip() for l in read_text(tl).splitlines() if l.strip()]
|
|
116
|
+
elif (info_dir / "RECORD").is_file():
|
|
117
|
+
for line in read_text(info_dir / "RECORD").splitlines():
|
|
118
|
+
first = line.split(",", 1)[0].replace("\\", "/").split("/")[0]
|
|
119
|
+
if first and first not in ("..", ) and not first.endswith((".dist-info", ".data")):
|
|
120
|
+
if first.endswith(".py"):
|
|
121
|
+
first = first[:-3]
|
|
122
|
+
if first not in cands:
|
|
123
|
+
cands.append(first)
|
|
124
|
+
if not cands:
|
|
125
|
+
cands = [fallback.replace("-", "_")]
|
|
126
|
+
# keep only those that exist here
|
|
127
|
+
return [c for c in cands if (container / c).is_dir() or (container / f"{c}.py").is_file()]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def discover(root: Path) -> list[Embedded]:
|
|
131
|
+
found: list[Embedded] = []
|
|
132
|
+
covered: set[Path] = set()
|
|
133
|
+
for dirpath, dirnames, _ in os.walk(root):
|
|
134
|
+
d = Path(dirpath)
|
|
135
|
+
for sub in list(dirnames):
|
|
136
|
+
if sub.endswith((".dist-info", ".egg-info")):
|
|
137
|
+
info = d / sub
|
|
138
|
+
meta = info / ("METADATA" if sub.endswith(".dist-info") else "PKG-INFO")
|
|
139
|
+
if not meta.is_file():
|
|
140
|
+
continue
|
|
141
|
+
name, version = parse_metadata(read_text(meta))
|
|
142
|
+
if not name or not version:
|
|
143
|
+
continue
|
|
144
|
+
tops = tops_from_info(info, name)
|
|
145
|
+
if not tops:
|
|
146
|
+
continue
|
|
147
|
+
found.append(Embedded(name, version, d, tops,
|
|
148
|
+
"dist-info" if sub.endswith(".dist-info") else "egg-info"))
|
|
149
|
+
for t in tops:
|
|
150
|
+
covered.add((d / t).resolve())
|
|
151
|
+
# version.py fallback: top-level packages only
|
|
152
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
153
|
+
d = Path(dirpath)
|
|
154
|
+
if "version.py" in filenames and "__init__.py" in filenames:
|
|
155
|
+
if (d.parent / "__init__.py").exists() or d.resolve() in covered:
|
|
156
|
+
continue
|
|
157
|
+
if any(c in covered for c in [d.resolve(), *d.resolve().parents]):
|
|
158
|
+
continue
|
|
159
|
+
m = re.search(r"""^__version__\s*=\s*['"]([^'"]+)['"]""",
|
|
160
|
+
read_text(d / "version.py"), re.M)
|
|
161
|
+
if m:
|
|
162
|
+
found.append(Embedded(d.name.replace("_", "-"), m.group(1), d.parent,
|
|
163
|
+
[d.name], "version.py"))
|
|
164
|
+
return found
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# -------------------------------------------------------------------- PyPI
|
|
168
|
+
def http_get(url: str, attempts: int = 3) -> bytes:
|
|
169
|
+
"""GET with retry + exponential backoff on 429/5xx and connection errors."""
|
|
170
|
+
for i in range(attempts):
|
|
171
|
+
try:
|
|
172
|
+
req = urllib.request.Request(url, headers=UA)
|
|
173
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
174
|
+
return r.read()
|
|
175
|
+
except urllib.error.HTTPError as ex:
|
|
176
|
+
if ex.code not in (429, 500, 502, 503, 504) or i == attempts - 1:
|
|
177
|
+
raise
|
|
178
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError):
|
|
179
|
+
if i == attempts - 1:
|
|
180
|
+
raise
|
|
181
|
+
time.sleep(2 ** i)
|
|
182
|
+
raise RuntimeError("unreachable")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def pick_artifact(urls: list[dict]) -> dict | None:
|
|
186
|
+
wheels = [u for u in urls if u["packagetype"] == "bdist_wheel"
|
|
187
|
+
and u["filename"].endswith("-none-any.whl")]
|
|
188
|
+
if wheels:
|
|
189
|
+
return wheels[0]
|
|
190
|
+
sd = [u for u in urls if u["packagetype"] == "sdist"]
|
|
191
|
+
return sd[0] if sd else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _descend_sdist(out: Path, filename: str) -> Path:
|
|
195
|
+
"""An sdist unpacks into a single top directory; wheels do not."""
|
|
196
|
+
if filename.endswith((".whl", ".zip")):
|
|
197
|
+
return out
|
|
198
|
+
subs = [p for p in out.iterdir() if p.is_dir()]
|
|
199
|
+
return subs[0] if len(subs) == 1 else out
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def fetch_official(name: str, version: str, cache: Path) -> tuple[Path, str]:
|
|
203
|
+
"""Download + hash-verify official artifact, return (extracted dir, artifact filename)."""
|
|
204
|
+
meta = json.loads(http_get(f"https://pypi.org/pypi/{norm_name(name)}/{version}/json"))
|
|
205
|
+
art = pick_artifact(meta["urls"])
|
|
206
|
+
if not art:
|
|
207
|
+
raise RuntimeError("no pure wheel or sdist on PyPI")
|
|
208
|
+
out = cache / f"{norm_name(name)}-{version}"
|
|
209
|
+
if (out / ".ok").exists():
|
|
210
|
+
return _descend_sdist(out, art["filename"]), art["filename"]
|
|
211
|
+
if out.exists():
|
|
212
|
+
shutil.rmtree(out)
|
|
213
|
+
out.mkdir(parents=True)
|
|
214
|
+
data = http_get(art["url"])
|
|
215
|
+
if sha256_bytes(data) != art["digests"]["sha256"]:
|
|
216
|
+
raise RuntimeError("downloaded artifact sha256 does not match PyPI-published digest")
|
|
217
|
+
if art["filename"].endswith((".whl", ".zip")):
|
|
218
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
219
|
+
safe_extract_zip(zf, out)
|
|
220
|
+
else:
|
|
221
|
+
with tarfile.open(fileobj=io.BytesIO(data)) as tf:
|
|
222
|
+
safe_extract_tar(tf, out)
|
|
223
|
+
(out / ".ok").touch()
|
|
224
|
+
return _descend_sdist(out, art["filename"]), art["filename"]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ----------------------------------------------------------------- compare
|
|
228
|
+
def collect(container: Path, tops: list[str], ignore: tuple[str, ...] = ()) -> dict[str, Path]:
|
|
229
|
+
files: dict[str, Path] = {}
|
|
230
|
+
for t in tops:
|
|
231
|
+
for root in (container / t, container / f"{t}.py"):
|
|
232
|
+
if root.is_file():
|
|
233
|
+
candidates = [root]
|
|
234
|
+
elif root.is_dir():
|
|
235
|
+
candidates = [p for p in root.rglob("*") if p.is_file()]
|
|
236
|
+
else:
|
|
237
|
+
continue
|
|
238
|
+
for p in candidates:
|
|
239
|
+
rel = p.relative_to(container)
|
|
240
|
+
if is_ignored(rel) or matches_ignore(rel.as_posix(), ignore):
|
|
241
|
+
continue
|
|
242
|
+
files[rel.as_posix()] = p
|
|
243
|
+
return files
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def matches_ignore(rel: str, patterns: tuple[str, ...]) -> bool:
|
|
247
|
+
"""Glob match on the posix path relative to the package container (e.g. 'foo/*.orig')."""
|
|
248
|
+
return any(fnmatch.fnmatchcase(rel, p) for p in patterns)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def same_content(a: Path, b: Path) -> bool:
|
|
252
|
+
if sha256_file(a) == sha256_file(b):
|
|
253
|
+
return True
|
|
254
|
+
# tolerate CRLF/LF differences only (git autocrlf etc.)
|
|
255
|
+
try:
|
|
256
|
+
da, db = a.read_bytes(), b.read_bytes()
|
|
257
|
+
except OSError:
|
|
258
|
+
return False
|
|
259
|
+
if b"\0" in da[:8192] or b"\0" in db[:8192]:
|
|
260
|
+
return False
|
|
261
|
+
return da.replace(b"\r\n", b"\n") == db.replace(b"\r\n", b"\n")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def make_diff(official: Path, embedded: Path, rel: str, limit: int = 200) -> str:
|
|
265
|
+
"""Unified diff official -> embedded; empty string for binary files."""
|
|
266
|
+
try:
|
|
267
|
+
a, b = official.read_bytes(), embedded.read_bytes()
|
|
268
|
+
except OSError:
|
|
269
|
+
return ""
|
|
270
|
+
if b"\0" in a[:8192] or b"\0" in b[:8192]:
|
|
271
|
+
return ""
|
|
272
|
+
lines = list(difflib.unified_diff(
|
|
273
|
+
a.decode("utf-8", "replace").splitlines(), b.decode("utf-8", "replace").splitlines(),
|
|
274
|
+
f"official/{rel}", f"embedded/{rel}", lineterm=""))
|
|
275
|
+
if len(lines) > limit:
|
|
276
|
+
lines = lines[:limit] + [f"... ({len(lines) - limit} more diff lines)"]
|
|
277
|
+
return "\n".join(lines)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass
|
|
281
|
+
class Result:
|
|
282
|
+
name: str
|
|
283
|
+
version: str
|
|
284
|
+
source: str
|
|
285
|
+
location: str
|
|
286
|
+
status: str = "ok" # ok | error
|
|
287
|
+
error: str = ""
|
|
288
|
+
artifact: str = ""
|
|
289
|
+
same: int = 0
|
|
290
|
+
modified: list[str] = field(default_factory=list)
|
|
291
|
+
extra: list[str] = field(default_factory=list)
|
|
292
|
+
missing: list[str] = field(default_factory=list)
|
|
293
|
+
diffs: dict[str, str] = field(default_factory=dict)
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def clean(self) -> bool:
|
|
297
|
+
return self.status == "ok" and not (self.modified or self.extra or self.missing)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def find_official_container(off: Path, tops: list[str]) -> Path:
|
|
301
|
+
for cand in (off, off / "src", off / "lib"):
|
|
302
|
+
if any((cand / t).is_dir() or (cand / f"{t}.py").is_file() for t in tops):
|
|
303
|
+
return cand
|
|
304
|
+
return off
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def check_one(e: Embedded, cache: Path, root: Path, ignore: tuple[str, ...] = (),
|
|
308
|
+
want_diff: bool = False, prefix: str = "") -> Result:
|
|
309
|
+
loc = (e.container.relative_to(root).as_posix() or ".")
|
|
310
|
+
res = Result(e.name, e.version, e.source, f"{prefix}{loc}" if prefix else loc)
|
|
311
|
+
try:
|
|
312
|
+
off, res.artifact = fetch_official(e.name, e.version, cache)
|
|
313
|
+
except urllib.error.HTTPError as ex:
|
|
314
|
+
res.status, res.error = "error", f"PyPI HTTP {ex.code} (not on PyPI under this name/version?)"
|
|
315
|
+
return res
|
|
316
|
+
except Exception as ex: # noqa: BLE001
|
|
317
|
+
res.status, res.error = "error", str(ex)
|
|
318
|
+
return res
|
|
319
|
+
off_container = find_official_container(off, e.tops)
|
|
320
|
+
mine = collect(e.container, e.tops, ignore)
|
|
321
|
+
theirs = collect(off_container, e.tops, ignore)
|
|
322
|
+
for rel, p in sorted(mine.items()):
|
|
323
|
+
if rel not in theirs:
|
|
324
|
+
res.extra.append(rel)
|
|
325
|
+
elif same_content(p, theirs[rel]):
|
|
326
|
+
res.same += 1
|
|
327
|
+
else:
|
|
328
|
+
res.modified.append(rel)
|
|
329
|
+
if want_diff:
|
|
330
|
+
d = make_diff(theirs[rel], p, rel)
|
|
331
|
+
if d:
|
|
332
|
+
res.diffs[rel] = d
|
|
333
|
+
res.missing = sorted(set(theirs) - set(mine))
|
|
334
|
+
return res
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
NESTED_EXTS = (".zip", ".whl", ".egg", ".xpi", ".vsix", ".fda", ".pyz")
|
|
339
|
+
MAX_NESTED_BYTES = 2 * 1024**3 # zip-bomb guard for nested archive expansion
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def expand_nested(root: Path, tmp: Path, max_depth: int = 2) -> list[tuple[Path, str]]:
|
|
343
|
+
"""Return [(dir, label_prefix)] for root plus any zip-like archives found inside it."""
|
|
344
|
+
roots: list[tuple[Path, str]] = [(root, "")]
|
|
345
|
+
frontier = [(root, "")]
|
|
346
|
+
total, counter = 0, 0
|
|
347
|
+
for _ in range(max_depth):
|
|
348
|
+
nxt: list[tuple[Path, str]] = []
|
|
349
|
+
for base, prefix in frontier:
|
|
350
|
+
for p in sorted(base.rglob("*")):
|
|
351
|
+
if not (p.is_file() and p.suffix.lower() in NESTED_EXTS and zipfile.is_zipfile(p)):
|
|
352
|
+
continue
|
|
353
|
+
with zipfile.ZipFile(p) as zf:
|
|
354
|
+
total += sum(i.file_size for i in zf.infolist())
|
|
355
|
+
if total > MAX_NESTED_BYTES:
|
|
356
|
+
raise RuntimeError("nested archives too large (zip-bomb guard)")
|
|
357
|
+
counter += 1
|
|
358
|
+
dest = tmp / "nested" / str(counter)
|
|
359
|
+
safe_extract_zip(zf, dest)
|
|
360
|
+
label = f"{prefix}{p.relative_to(base).as_posix()}!/"
|
|
361
|
+
roots.append((dest, label))
|
|
362
|
+
nxt.append((dest, label))
|
|
363
|
+
frontier = nxt
|
|
364
|
+
return roots
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def write_step_summary(results: list[Result]) -> None:
|
|
368
|
+
"""Append a markdown table to $GITHUB_STEP_SUMMARY when running in GitHub Actions."""
|
|
369
|
+
path = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
370
|
+
if not path:
|
|
371
|
+
return
|
|
372
|
+
rows = ["### vendorcheck", "", "| Package | Version | Identical | Modified | Extra | Missing | Status |",
|
|
373
|
+
"|---|---|---:|---:|---:|---:|---|"]
|
|
374
|
+
for r in results:
|
|
375
|
+
status = "error" if r.status == "error" else ("clean" if r.clean else "DIFFERS")
|
|
376
|
+
rows.append(f"| `{r.name}` | {r.version} | {r.same} | {len(r.modified)} | {len(r.extra)} "
|
|
377
|
+
f"| {len(r.missing)} | {status} |")
|
|
378
|
+
for r in results:
|
|
379
|
+
for title, items in (("modified", r.modified), ("extra", r.extra), ("missing", r.missing)):
|
|
380
|
+
if items:
|
|
381
|
+
rows += ["", f"<details><summary>{r.name} {r.version}: {title} ({len(items)})</summary>", ""]
|
|
382
|
+
rows += [f"- `{i}`" for i in items[:100]] + ["", "</details>"]
|
|
383
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
384
|
+
f.write("\n".join(rows) + "\n")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
STRINGS = {
|
|
388
|
+
"en": {"err": "COULD NOT CHECK", "same": "files identical", "same1": "file identical",
|
|
389
|
+
"mod": "modified", "extra": "extra", "miss": "missing",
|
|
390
|
+
"tags": ("modified", "extra", "missing"),
|
|
391
|
+
"none": "No embedded packages found (v1 needs .dist-info / .egg-info / version.py)."},
|
|
392
|
+
"tr": {"err": "KONTROL EDİLEMEDİ", "same": "dosya aynı", "same1": "dosya aynı",
|
|
393
|
+
"mod": "değişmiş", "extra": "fazladan", "miss": "eksik",
|
|
394
|
+
"tags": ("değişmiş", "fazladan", "eksik"),
|
|
395
|
+
"none": "Gömülü paket bulunamadı (v1: .dist-info / .egg-info / version.py gerekir)."},
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def summarize(r: Result, lang: str = "en") -> str:
|
|
400
|
+
s = STRINGS[lang]
|
|
401
|
+
label = f"{r.name.replace('-', '_')} {r.version}"
|
|
402
|
+
if "!/" in r.location:
|
|
403
|
+
label += f" [{r.location}]"
|
|
404
|
+
if r.status == "error":
|
|
405
|
+
return f"{label}: {s['err']} ({r.error})"
|
|
406
|
+
same_word = s["same1"] if r.same == 1 else s["same"]
|
|
407
|
+
parts = [f"{r.same} {same_word}", f"{len(r.modified)} {s['mod']}", f"{len(r.extra)} {s['extra']}"]
|
|
408
|
+
if r.missing:
|
|
409
|
+
parts.append(f"{len(r.missing)} {s['miss']}")
|
|
410
|
+
return f"{label}: " + ", ".join(parts) + "."
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# -------------------------------------------------------------------- main
|
|
414
|
+
def main(argv: list[str] | None = None) -> int:
|
|
415
|
+
ap = argparse.ArgumentParser(prog="vendorcheck", description=__doc__.split("\n\n")[0])
|
|
416
|
+
ap.add_argument("path", help="directory or archive (.zip/.fda/.xpi/.vsix/...)")
|
|
417
|
+
ap.add_argument("--version", action="version", version=f"vendorcheck {__version__}")
|
|
418
|
+
ap.add_argument("--json", action="store_true", help="machine readable output")
|
|
419
|
+
ap.add_argument("-v", "--verbose", action="count", default=0,
|
|
420
|
+
help="-v list differing files, -vv also print unified diffs")
|
|
421
|
+
ap.add_argument("--cache", help="download cache dir (default: temp)")
|
|
422
|
+
ap.add_argument("--only", nargs="*", help="check only these package names")
|
|
423
|
+
ap.add_argument("--ignore", action="append", default=[], metavar="GLOB",
|
|
424
|
+
help="ignore files matching GLOB (relative to the package dir, e.g. "
|
|
425
|
+
"'yt_dlp/version.py'); repeatable. For intentional local patches.")
|
|
426
|
+
ap.add_argument("--no-nested", action="store_true", help="do not open archives inside the input")
|
|
427
|
+
ap.add_argument("--lang", choices=sorted(STRINGS), default="en", help="output language")
|
|
428
|
+
ap.add_argument("--allow-empty", action="store_true",
|
|
429
|
+
help="exit 0 even if no embedded packages were found")
|
|
430
|
+
args = ap.parse_args(argv)
|
|
431
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
432
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
433
|
+
|
|
434
|
+
src = Path(args.path)
|
|
435
|
+
if not src.exists():
|
|
436
|
+
print(f"not found: {src}", file=sys.stderr)
|
|
437
|
+
return 2
|
|
438
|
+
|
|
439
|
+
tmp = Path(tempfile.mkdtemp(prefix="vendorcheck-"))
|
|
440
|
+
try:
|
|
441
|
+
if src.is_dir():
|
|
442
|
+
root = src.resolve()
|
|
443
|
+
else:
|
|
444
|
+
if not zipfile.is_zipfile(src):
|
|
445
|
+
print("not a directory or zip-compatible archive", file=sys.stderr)
|
|
446
|
+
return 2
|
|
447
|
+
root = tmp / "pkg"
|
|
448
|
+
with zipfile.ZipFile(src) as zf:
|
|
449
|
+
safe_extract_zip(zf, root)
|
|
450
|
+
cache = Path(args.cache) if args.cache else tmp / "cache"
|
|
451
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
|
|
453
|
+
roots = [(root, "")] if args.no_nested else expand_nested(root, tmp)
|
|
454
|
+
wanted = {norm_name(n) for n in args.only} if args.only else None
|
|
455
|
+
ignore = tuple(args.ignore)
|
|
456
|
+
results: list[Result] = []
|
|
457
|
+
seen: set = set()
|
|
458
|
+
for rdir, prefix in roots:
|
|
459
|
+
for e in discover(rdir):
|
|
460
|
+
if wanted and norm_name(e.name) not in wanted:
|
|
461
|
+
continue
|
|
462
|
+
key = (norm_name(e.name), e.version, e.container)
|
|
463
|
+
if key in seen:
|
|
464
|
+
continue
|
|
465
|
+
seen.add(key)
|
|
466
|
+
results.append(check_one(e, cache, rdir, ignore, args.verbose >= 2, prefix))
|
|
467
|
+
|
|
468
|
+
if args.json:
|
|
469
|
+
print(json.dumps([r.__dict__ | {"clean": r.clean} for r in results],
|
|
470
|
+
indent=2, ensure_ascii=False))
|
|
471
|
+
else:
|
|
472
|
+
if not results:
|
|
473
|
+
print(STRINGS[args.lang]["none"])
|
|
474
|
+
for r in results:
|
|
475
|
+
print(summarize(r, args.lang))
|
|
476
|
+
if args.verbose:
|
|
477
|
+
tags = STRINGS[args.lang]["tags"]
|
|
478
|
+
for tag, items in zip(tags, (r.modified, r.extra, r.missing)):
|
|
479
|
+
for it in items:
|
|
480
|
+
print(f" [{tag}] {it}")
|
|
481
|
+
if tag == tags[0] and it in r.diffs:
|
|
482
|
+
print("\n".join(" " + l for l in r.diffs[it].splitlines()))
|
|
483
|
+
write_step_summary(results)
|
|
484
|
+
if not results:
|
|
485
|
+
return 0 if args.allow_empty else 2
|
|
486
|
+
if any(r.status == "error" for r in results):
|
|
487
|
+
return 2 if not any(not r.clean for r in results if r.status == "ok") else 1
|
|
488
|
+
return 0 if all(r.clean for r in results) else 1
|
|
489
|
+
finally:
|
|
490
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
if __name__ == "__main__":
|
|
494
|
+
sys.exit(main())
|