trustcheck 1.0.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.
- trustcheck-1.0.0/LICENSE +28 -0
- trustcheck-1.0.0/PKG-INFO +76 -0
- trustcheck-1.0.0/README.md +53 -0
- trustcheck-1.0.0/pyproject.toml +36 -0
- trustcheck-1.0.0/setup.cfg +4 -0
- trustcheck-1.0.0/src/trustcheck/__init__.py +7 -0
- trustcheck-1.0.0/src/trustcheck/__main__.py +6 -0
- trustcheck-1.0.0/src/trustcheck/cli.py +108 -0
- trustcheck-1.0.0/src/trustcheck/models.py +60 -0
- trustcheck-1.0.0/src/trustcheck/pypi.py +51 -0
- trustcheck-1.0.0/src/trustcheck/service.py +239 -0
- trustcheck-1.0.0/src/trustcheck.egg-info/PKG-INFO +76 -0
- trustcheck-1.0.0/src/trustcheck.egg-info/SOURCES.txt +15 -0
- trustcheck-1.0.0/src/trustcheck.egg-info/dependency_links.txt +1 -0
- trustcheck-1.0.0/src/trustcheck.egg-info/entry_points.txt +2 -0
- trustcheck-1.0.0/src/trustcheck.egg-info/top_level.txt +1 -0
- trustcheck-1.0.0/tests/test_service.py +85 -0
trustcheck-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Halfblood-Prince
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trustcheck
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Package trust and provenance verification for PyPI consumers.
|
|
5
|
+
License: BSD 3-Clause License
|
|
6
|
+
Keywords: pypi,security,supply-chain,provenance,attestations
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# trustcheck
|
|
25
|
+
|
|
26
|
+
`trustcheck` is a small Python package and CLI for checking PyPI package trust signals before installation.
|
|
27
|
+
|
|
28
|
+
The immediate goal is pragmatic: answer questions like these in one local command:
|
|
29
|
+
|
|
30
|
+
- Does this release expose PyPI provenance / attestation data?
|
|
31
|
+
- Was it published through a Trusted Publisher flow?
|
|
32
|
+
- Which repository URLs does the package claim?
|
|
33
|
+
- Do those URLs match the repository I expected?
|
|
34
|
+
- Are there known PyPI vulnerability records for the selected release?
|
|
35
|
+
- Are there obvious risk flags worth reviewing before install?
|
|
36
|
+
|
|
37
|
+
This MVP is intentionally conservative:
|
|
38
|
+
|
|
39
|
+
- It fetches metadata from PyPI's JSON API and provenance objects from PyPI's Integrity API.
|
|
40
|
+
- It surfaces publisher identity hints from provenance bundles when available.
|
|
41
|
+
- It does not yet perform full cryptographic attestation verification. That should be added in a later release via Sigstore / `pypi-attestations` style verification.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
trustcheck inspect requests
|
|
53
|
+
trustcheck inspect sampleproject --version 4.0.0
|
|
54
|
+
trustcheck inspect sampleproject --expected-repo https://github.com/pypa/sampleproject
|
|
55
|
+
trustcheck inspect sampleproject --format json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What the report includes
|
|
59
|
+
|
|
60
|
+
- Selected project and version
|
|
61
|
+
- Declared project URLs and likely repository URLs
|
|
62
|
+
- PyPI ownership metadata when exposed
|
|
63
|
+
- Known vulnerabilities from PyPI
|
|
64
|
+
- Distribution file hashes
|
|
65
|
+
- Provenance / attestation availability per file
|
|
66
|
+
- Trusted Publisher identity hints extracted from provenance bundles
|
|
67
|
+
- Risk flags and an overall recommendation tier
|
|
68
|
+
|
|
69
|
+
## Roadmap
|
|
70
|
+
|
|
71
|
+
- Full cryptographic verification of PyPI attestations
|
|
72
|
+
- Stronger repository canonicalization and source matching
|
|
73
|
+
- Policy files for CI and org-level enforcement
|
|
74
|
+
- Support for lockfiles and dependency trees
|
|
75
|
+
- Offline caching and SBOM-style export
|
|
76
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# trustcheck
|
|
2
|
+
|
|
3
|
+
`trustcheck` is a small Python package and CLI for checking PyPI package trust signals before installation.
|
|
4
|
+
|
|
5
|
+
The immediate goal is pragmatic: answer questions like these in one local command:
|
|
6
|
+
|
|
7
|
+
- Does this release expose PyPI provenance / attestation data?
|
|
8
|
+
- Was it published through a Trusted Publisher flow?
|
|
9
|
+
- Which repository URLs does the package claim?
|
|
10
|
+
- Do those URLs match the repository I expected?
|
|
11
|
+
- Are there known PyPI vulnerability records for the selected release?
|
|
12
|
+
- Are there obvious risk flags worth reviewing before install?
|
|
13
|
+
|
|
14
|
+
This MVP is intentionally conservative:
|
|
15
|
+
|
|
16
|
+
- It fetches metadata from PyPI's JSON API and provenance objects from PyPI's Integrity API.
|
|
17
|
+
- It surfaces publisher identity hints from provenance bundles when available.
|
|
18
|
+
- It does not yet perform full cryptographic attestation verification. That should be added in a later release via Sigstore / `pypi-attestations` style verification.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
trustcheck inspect requests
|
|
30
|
+
trustcheck inspect sampleproject --version 4.0.0
|
|
31
|
+
trustcheck inspect sampleproject --expected-repo https://github.com/pypa/sampleproject
|
|
32
|
+
trustcheck inspect sampleproject --format json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## What the report includes
|
|
36
|
+
|
|
37
|
+
- Selected project and version
|
|
38
|
+
- Declared project URLs and likely repository URLs
|
|
39
|
+
- PyPI ownership metadata when exposed
|
|
40
|
+
- Known vulnerabilities from PyPI
|
|
41
|
+
- Distribution file hashes
|
|
42
|
+
- Provenance / attestation availability per file
|
|
43
|
+
- Trusted Publisher identity hints extracted from provenance bundles
|
|
44
|
+
- Risk flags and an overall recommendation tier
|
|
45
|
+
|
|
46
|
+
## Roadmap
|
|
47
|
+
|
|
48
|
+
- Full cryptographic verification of PyPI attestations
|
|
49
|
+
- Stronger repository canonicalization and source matching
|
|
50
|
+
- Policy files for CI and org-level enforcement
|
|
51
|
+
- Support for lockfiles and dependency trees
|
|
52
|
+
- Offline caching and SBOM-style export
|
|
53
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trustcheck"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Package trust and provenance verification for PyPI consumers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "BSD 3-Clause License" }
|
|
12
|
+
keywords = ["pypi", "security", "supply-chain", "provenance", "attestations"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Security",
|
|
25
|
+
"Topic :: Software Development :: Build Tools",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
trustcheck = "trustcheck.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
package-dir = { "" = "src" }
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
from .service import inspect_package
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="trustcheck",
|
|
13
|
+
description="Inspect PyPI package trust and provenance signals.",
|
|
14
|
+
)
|
|
15
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
16
|
+
|
|
17
|
+
inspect_parser = subparsers.add_parser("inspect", help="Inspect a package on PyPI.")
|
|
18
|
+
inspect_parser.add_argument("project", help="Project name on PyPI.")
|
|
19
|
+
inspect_parser.add_argument("--version", help="Specific version to inspect.")
|
|
20
|
+
inspect_parser.add_argument(
|
|
21
|
+
"--expected-repo",
|
|
22
|
+
help="Repository URL you expect the package to come from.",
|
|
23
|
+
)
|
|
24
|
+
inspect_parser.add_argument(
|
|
25
|
+
"--format",
|
|
26
|
+
choices=("text", "json"),
|
|
27
|
+
default="text",
|
|
28
|
+
help="Output format.",
|
|
29
|
+
)
|
|
30
|
+
return parser
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
34
|
+
parser = build_parser()
|
|
35
|
+
args = parser.parse_args(argv)
|
|
36
|
+
|
|
37
|
+
if args.command == "inspect":
|
|
38
|
+
report = inspect_package(
|
|
39
|
+
args.project,
|
|
40
|
+
version=args.version,
|
|
41
|
+
expected_repository=args.expected_repo,
|
|
42
|
+
)
|
|
43
|
+
if args.format == "json":
|
|
44
|
+
print(json.dumps(report.to_dict(), indent=2, sort_keys=True))
|
|
45
|
+
else:
|
|
46
|
+
print(_render_text_report(report))
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
parser.error("unknown command")
|
|
50
|
+
return 2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _render_text_report(report) -> str:
|
|
54
|
+
lines: list[str] = [
|
|
55
|
+
f"trustcheck report for {report.project} {report.version}",
|
|
56
|
+
f"recommendation: {report.recommendation}",
|
|
57
|
+
f"package: {report.package_url}",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
if report.summary:
|
|
61
|
+
lines.append(f"summary: {report.summary}")
|
|
62
|
+
|
|
63
|
+
if report.repository_urls:
|
|
64
|
+
lines.append("repository urls:")
|
|
65
|
+
lines.extend(f" - {url}" for url in report.repository_urls)
|
|
66
|
+
|
|
67
|
+
if report.expected_repository:
|
|
68
|
+
lines.append(f"expected repository: {report.expected_repository}")
|
|
69
|
+
|
|
70
|
+
ownership = report.ownership or {}
|
|
71
|
+
roles = ownership.get("roles") or []
|
|
72
|
+
organization = ownership.get("organization")
|
|
73
|
+
if organization or roles:
|
|
74
|
+
lines.append("ownership:")
|
|
75
|
+
if organization:
|
|
76
|
+
lines.append(f" - organization: {organization}")
|
|
77
|
+
for role in roles:
|
|
78
|
+
lines.append(f" - {role.get('role')}: {role.get('user')}")
|
|
79
|
+
|
|
80
|
+
if report.vulnerabilities:
|
|
81
|
+
lines.append("vulnerabilities:")
|
|
82
|
+
for vuln in report.vulnerabilities:
|
|
83
|
+
lines.append(f" - {vuln.id}: {vuln.summary}")
|
|
84
|
+
|
|
85
|
+
lines.append("files:")
|
|
86
|
+
for file in report.files:
|
|
87
|
+
lines.append(f" - {file.filename}")
|
|
88
|
+
lines.append(f" provenance: {'yes' if file.has_provenance else 'no'}")
|
|
89
|
+
if file.sha256:
|
|
90
|
+
lines.append(f" sha256: {file.sha256}")
|
|
91
|
+
if file.publisher_identities:
|
|
92
|
+
for identity in file.publisher_identities:
|
|
93
|
+
lines.append(
|
|
94
|
+
f" publisher: kind={identity.kind} repository={identity.repository or '-'} workflow={identity.workflow or '-'}"
|
|
95
|
+
)
|
|
96
|
+
if file.error:
|
|
97
|
+
lines.append(f" note: {file.error}")
|
|
98
|
+
|
|
99
|
+
if report.risk_flags:
|
|
100
|
+
lines.append("risk flags:")
|
|
101
|
+
for flag in report.risk_flags:
|
|
102
|
+
lines.append(f" - [{flag.severity}] {flag.code}: {flag.message}")
|
|
103
|
+
else:
|
|
104
|
+
lines.append("risk flags: none")
|
|
105
|
+
|
|
106
|
+
lines.append("note: provenance presence is a useful signal, but this MVP does not yet perform cryptographic attestation verification.")
|
|
107
|
+
return "\n".join(lines)
|
|
108
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class RiskFlag:
|
|
9
|
+
code: str
|
|
10
|
+
severity: str
|
|
11
|
+
message: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class VulnerabilityRecord:
|
|
16
|
+
id: str
|
|
17
|
+
summary: str
|
|
18
|
+
aliases: list[str] = field(default_factory=list)
|
|
19
|
+
source: str | None = None
|
|
20
|
+
fixed_in: list[str] = field(default_factory=list)
|
|
21
|
+
link: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class PublisherIdentity:
|
|
26
|
+
kind: str
|
|
27
|
+
repository: str | None
|
|
28
|
+
workflow: str | None
|
|
29
|
+
environment: str | None
|
|
30
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class FileProvenance:
|
|
35
|
+
filename: str
|
|
36
|
+
url: str
|
|
37
|
+
sha256: str | None
|
|
38
|
+
has_provenance: bool
|
|
39
|
+
attestation_count: int = 0
|
|
40
|
+
publisher_identities: list[PublisherIdentity] = field(default_factory=list)
|
|
41
|
+
error: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class TrustReport:
|
|
46
|
+
project: str
|
|
47
|
+
version: str
|
|
48
|
+
summary: str | None
|
|
49
|
+
package_url: str
|
|
50
|
+
repository_urls: list[str] = field(default_factory=list)
|
|
51
|
+
expected_repository: str | None = None
|
|
52
|
+
ownership: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
vulnerabilities: list[VulnerabilityRecord] = field(default_factory=list)
|
|
54
|
+
files: list[FileProvenance] = field(default_factory=list)
|
|
55
|
+
risk_flags: list[RiskFlag] = field(default_factory=list)
|
|
56
|
+
recommendation: str = "review"
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, Any]:
|
|
59
|
+
return asdict(self)
|
|
60
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib import error, parse, request
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PYPI_BASE_URL = "https://pypi.org"
|
|
10
|
+
JSON_ACCEPT = "application/json"
|
|
11
|
+
INTEGRITY_ACCEPT = "application/vnd.pypi.integrity.v1+json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PypiClientError(RuntimeError):
|
|
15
|
+
"""Raised when PyPI cannot satisfy a request."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class PypiClient:
|
|
20
|
+
base_url: str = PYPI_BASE_URL
|
|
21
|
+
timeout: float = 10.0
|
|
22
|
+
|
|
23
|
+
def get_project(self, project: str) -> dict[str, Any]:
|
|
24
|
+
return self._get_json(f"/pypi/{parse.quote(project)}/json", accept=JSON_ACCEPT)
|
|
25
|
+
|
|
26
|
+
def get_release(self, project: str, version: str) -> dict[str, Any]:
|
|
27
|
+
project_q = parse.quote(project)
|
|
28
|
+
version_q = parse.quote(version)
|
|
29
|
+
return self._get_json(f"/pypi/{project_q}/{version_q}/json", accept=JSON_ACCEPT)
|
|
30
|
+
|
|
31
|
+
def get_provenance(self, project: str, version: str, filename: str) -> dict[str, Any]:
|
|
32
|
+
project_q = parse.quote(project)
|
|
33
|
+
version_q = parse.quote(version)
|
|
34
|
+
filename_q = parse.quote(filename)
|
|
35
|
+
path = f"/integrity/{project_q}/{version_q}/{filename_q}/provenance"
|
|
36
|
+
return self._get_json(path, accept=INTEGRITY_ACCEPT)
|
|
37
|
+
|
|
38
|
+
def _get_json(self, path: str, *, accept: str) -> dict[str, Any]:
|
|
39
|
+
url = f"{self.base_url}{path}"
|
|
40
|
+
req = request.Request(url, headers={"Accept": accept, "User-Agent": "trustcheck/0.1"})
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
with request.urlopen(req, timeout=self.timeout) as response:
|
|
44
|
+
return json.load(response)
|
|
45
|
+
except error.HTTPError as exc:
|
|
46
|
+
if exc.code == 404:
|
|
47
|
+
raise PypiClientError(f"resource not found: {url}") from exc
|
|
48
|
+
raise PypiClientError(f"PyPI returned HTTP {exc.code} for {url}") from exc
|
|
49
|
+
except error.URLError as exc:
|
|
50
|
+
raise PypiClientError(f"unable to reach PyPI: {exc.reason}") from exc
|
|
51
|
+
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from .models import FileProvenance, PublisherIdentity, RiskFlag, TrustReport, VulnerabilityRecord
|
|
7
|
+
from .pypi import PypiClient, PypiClientError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def inspect_package(
|
|
11
|
+
project: str,
|
|
12
|
+
*,
|
|
13
|
+
version: str | None = None,
|
|
14
|
+
expected_repository: str | None = None,
|
|
15
|
+
client: PypiClient | None = None,
|
|
16
|
+
) -> TrustReport:
|
|
17
|
+
client = client or PypiClient()
|
|
18
|
+
|
|
19
|
+
payload = client.get_release(project, version) if version else client.get_project(project)
|
|
20
|
+
info = payload.get("info", {})
|
|
21
|
+
selected_version = payload.get("info", {}).get("version") or version or "unknown"
|
|
22
|
+
project_urls = _extract_repository_urls(info.get("project_urls") or {})
|
|
23
|
+
vulnerabilities = _parse_vulnerabilities(payload.get("vulnerabilities") or [])
|
|
24
|
+
ownership = info.get("ownership") or {}
|
|
25
|
+
files = _collect_files(project, selected_version, payload, client)
|
|
26
|
+
|
|
27
|
+
report = TrustReport(
|
|
28
|
+
project=project,
|
|
29
|
+
version=selected_version,
|
|
30
|
+
summary=info.get("summary"),
|
|
31
|
+
package_url=f"https://pypi.org/project/{project}/{selected_version}/",
|
|
32
|
+
repository_urls=project_urls,
|
|
33
|
+
expected_repository=expected_repository,
|
|
34
|
+
ownership=ownership,
|
|
35
|
+
vulnerabilities=vulnerabilities,
|
|
36
|
+
files=files,
|
|
37
|
+
)
|
|
38
|
+
report.risk_flags = _build_risk_flags(report)
|
|
39
|
+
report.recommendation = _recommendation_for(report.risk_flags)
|
|
40
|
+
return report
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _collect_files(
|
|
44
|
+
project: str,
|
|
45
|
+
version: str,
|
|
46
|
+
payload: dict[str, Any],
|
|
47
|
+
client: PypiClient,
|
|
48
|
+
) -> list[FileProvenance]:
|
|
49
|
+
urls = payload.get("urls") or []
|
|
50
|
+
results: list[FileProvenance] = []
|
|
51
|
+
|
|
52
|
+
for item in urls:
|
|
53
|
+
filename = item.get("filename") or ""
|
|
54
|
+
provenance = FileProvenance(
|
|
55
|
+
filename=filename,
|
|
56
|
+
url=item.get("url") or "",
|
|
57
|
+
sha256=(item.get("digests") or {}).get("sha256"),
|
|
58
|
+
has_provenance=False,
|
|
59
|
+
)
|
|
60
|
+
try:
|
|
61
|
+
prov_payload = client.get_provenance(project, version, filename)
|
|
62
|
+
bundles = prov_payload.get("attestation_bundles") or []
|
|
63
|
+
provenance.has_provenance = bool(bundles)
|
|
64
|
+
provenance.attestation_count = sum(
|
|
65
|
+
len(bundle.get("attestations") or [])
|
|
66
|
+
for bundle in bundles
|
|
67
|
+
if isinstance(bundle, dict)
|
|
68
|
+
)
|
|
69
|
+
provenance.publisher_identities = _parse_publisher_identities(bundles)
|
|
70
|
+
except PypiClientError as exc:
|
|
71
|
+
provenance.error = str(exc)
|
|
72
|
+
results.append(provenance)
|
|
73
|
+
return results
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_vulnerabilities(items: list[dict[str, Any]]) -> list[VulnerabilityRecord]:
|
|
77
|
+
vulnerabilities: list[VulnerabilityRecord] = []
|
|
78
|
+
for item in items:
|
|
79
|
+
vulnerabilities.append(
|
|
80
|
+
VulnerabilityRecord(
|
|
81
|
+
id=item.get("id") or "unknown",
|
|
82
|
+
summary=item.get("summary") or item.get("details") or "No summary provided.",
|
|
83
|
+
aliases=list(item.get("aliases") or []),
|
|
84
|
+
source=item.get("source"),
|
|
85
|
+
fixed_in=list(item.get("fixed_in") or []),
|
|
86
|
+
link=item.get("link"),
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return vulnerabilities
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _extract_repository_urls(project_urls: dict[str, str]) -> list[str]:
|
|
93
|
+
repo_urls: list[str] = []
|
|
94
|
+
preferred_labels = ("source", "source code", "repository", "repo", "homepage", "home")
|
|
95
|
+
|
|
96
|
+
for label, url in project_urls.items():
|
|
97
|
+
label_norm = label.strip().lower()
|
|
98
|
+
if any(token in label_norm for token in preferred_labels):
|
|
99
|
+
repo_urls.append(url)
|
|
100
|
+
|
|
101
|
+
if not repo_urls:
|
|
102
|
+
repo_urls.extend(project_urls.values())
|
|
103
|
+
|
|
104
|
+
deduped: list[str] = []
|
|
105
|
+
seen: set[str] = set()
|
|
106
|
+
for url in repo_urls:
|
|
107
|
+
normalized = _normalize_repo_url(url)
|
|
108
|
+
if normalized not in seen:
|
|
109
|
+
deduped.append(url)
|
|
110
|
+
seen.add(normalized)
|
|
111
|
+
return deduped
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_publisher_identities(bundles: list[dict[str, Any]]) -> list[PublisherIdentity]:
|
|
115
|
+
identities: list[PublisherIdentity] = []
|
|
116
|
+
|
|
117
|
+
for bundle in bundles:
|
|
118
|
+
publisher = bundle.get("publisher") or bundle.get("verification_material", {}).get("publisher")
|
|
119
|
+
if not isinstance(publisher, dict):
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
kind = str(publisher.get("kind") or publisher.get("issuer") or "unknown")
|
|
123
|
+
repository = (
|
|
124
|
+
publisher.get("repository")
|
|
125
|
+
or publisher.get("repository_url")
|
|
126
|
+
or publisher.get("repo")
|
|
127
|
+
or publisher.get("sub")
|
|
128
|
+
)
|
|
129
|
+
workflow = (
|
|
130
|
+
publisher.get("workflow")
|
|
131
|
+
or publisher.get("workflow_ref")
|
|
132
|
+
or publisher.get("job_workflow_ref")
|
|
133
|
+
)
|
|
134
|
+
environment = publisher.get("environment")
|
|
135
|
+
identities.append(
|
|
136
|
+
PublisherIdentity(
|
|
137
|
+
kind=kind,
|
|
138
|
+
repository=repository,
|
|
139
|
+
workflow=workflow,
|
|
140
|
+
environment=environment,
|
|
141
|
+
raw=publisher,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return identities
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _build_risk_flags(report: TrustReport) -> list[RiskFlag]:
|
|
148
|
+
flags: list[RiskFlag] = []
|
|
149
|
+
|
|
150
|
+
if report.vulnerabilities:
|
|
151
|
+
flags.append(
|
|
152
|
+
RiskFlag(
|
|
153
|
+
code="known_vulnerabilities",
|
|
154
|
+
severity="high",
|
|
155
|
+
message=f"PyPI reports {len(report.vulnerabilities)} known vulnerability record(s) for this release.",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if not report.repository_urls:
|
|
160
|
+
flags.append(
|
|
161
|
+
RiskFlag(
|
|
162
|
+
code="missing_repository_url",
|
|
163
|
+
severity="medium",
|
|
164
|
+
message="The package does not expose an obvious repository URL in project metadata.",
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if report.expected_repository:
|
|
169
|
+
expected = _normalize_repo_url(report.expected_repository)
|
|
170
|
+
repo_matches = any(_normalize_repo_url(url) == expected for url in report.repository_urls)
|
|
171
|
+
publisher_matches = any(
|
|
172
|
+
_normalize_repo_url(identity.repository) == expected
|
|
173
|
+
for file in report.files
|
|
174
|
+
for identity in file.publisher_identities
|
|
175
|
+
if identity.repository
|
|
176
|
+
)
|
|
177
|
+
if not repo_matches and not publisher_matches:
|
|
178
|
+
flags.append(
|
|
179
|
+
RiskFlag(
|
|
180
|
+
code="expected_repository_mismatch",
|
|
181
|
+
severity="high",
|
|
182
|
+
message="The expected repository does not match declared project metadata or observed publisher identity hints.",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if report.files and all(not file.has_provenance for file in report.files):
|
|
187
|
+
flags.append(
|
|
188
|
+
RiskFlag(
|
|
189
|
+
code="no_provenance",
|
|
190
|
+
severity="medium",
|
|
191
|
+
message="No provenance bundles were found for the release files on PyPI.",
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if any(file.error for file in report.files) and not any(file.has_provenance for file in report.files):
|
|
196
|
+
flags.append(
|
|
197
|
+
RiskFlag(
|
|
198
|
+
code="provenance_lookup_failed",
|
|
199
|
+
severity="low",
|
|
200
|
+
message="PyPI provenance lookups failed for one or more files, so attestation coverage may be incomplete.",
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if report.files and not any(file.publisher_identities for file in report.files):
|
|
205
|
+
flags.append(
|
|
206
|
+
RiskFlag(
|
|
207
|
+
code="missing_publisher_identity",
|
|
208
|
+
severity="medium",
|
|
209
|
+
message="No Trusted Publisher identity hints were recovered from the provenance bundles.",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return flags
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _recommendation_for(flags: list[RiskFlag]) -> str:
|
|
217
|
+
if any(flag.severity == "high" for flag in flags):
|
|
218
|
+
return "do-not-trust-without-review"
|
|
219
|
+
if any(flag.severity == "medium" for flag in flags):
|
|
220
|
+
return "review"
|
|
221
|
+
return "looks-good"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _normalize_repo_url(url: str | None) -> str:
|
|
225
|
+
if not url:
|
|
226
|
+
return ""
|
|
227
|
+
|
|
228
|
+
parsed = urlparse(url)
|
|
229
|
+
scheme = parsed.scheme.lower() or "https"
|
|
230
|
+
netloc = parsed.netloc.lower()
|
|
231
|
+
path = parsed.path.rstrip("/")
|
|
232
|
+
|
|
233
|
+
if netloc.endswith("github.com") or netloc.endswith("gitlab.com"):
|
|
234
|
+
path = path.removesuffix(".git")
|
|
235
|
+
segments = [segment for segment in path.split("/") if segment]
|
|
236
|
+
path = "/" + "/".join(segments[:2])
|
|
237
|
+
|
|
238
|
+
normalized = parsed._replace(scheme=scheme, netloc=netloc, path=path, params="", query="", fragment="")
|
|
239
|
+
return normalized.geturl()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trustcheck
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Package trust and provenance verification for PyPI consumers.
|
|
5
|
+
License: BSD 3-Clause License
|
|
6
|
+
Keywords: pypi,security,supply-chain,provenance,attestations
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# trustcheck
|
|
25
|
+
|
|
26
|
+
`trustcheck` is a small Python package and CLI for checking PyPI package trust signals before installation.
|
|
27
|
+
|
|
28
|
+
The immediate goal is pragmatic: answer questions like these in one local command:
|
|
29
|
+
|
|
30
|
+
- Does this release expose PyPI provenance / attestation data?
|
|
31
|
+
- Was it published through a Trusted Publisher flow?
|
|
32
|
+
- Which repository URLs does the package claim?
|
|
33
|
+
- Do those URLs match the repository I expected?
|
|
34
|
+
- Are there known PyPI vulnerability records for the selected release?
|
|
35
|
+
- Are there obvious risk flags worth reviewing before install?
|
|
36
|
+
|
|
37
|
+
This MVP is intentionally conservative:
|
|
38
|
+
|
|
39
|
+
- It fetches metadata from PyPI's JSON API and provenance objects from PyPI's Integrity API.
|
|
40
|
+
- It surfaces publisher identity hints from provenance bundles when available.
|
|
41
|
+
- It does not yet perform full cryptographic attestation verification. That should be added in a later release via Sigstore / `pypi-attestations` style verification.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
trustcheck inspect requests
|
|
53
|
+
trustcheck inspect sampleproject --version 4.0.0
|
|
54
|
+
trustcheck inspect sampleproject --expected-repo https://github.com/pypa/sampleproject
|
|
55
|
+
trustcheck inspect sampleproject --format json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What the report includes
|
|
59
|
+
|
|
60
|
+
- Selected project and version
|
|
61
|
+
- Declared project URLs and likely repository URLs
|
|
62
|
+
- PyPI ownership metadata when exposed
|
|
63
|
+
- Known vulnerabilities from PyPI
|
|
64
|
+
- Distribution file hashes
|
|
65
|
+
- Provenance / attestation availability per file
|
|
66
|
+
- Trusted Publisher identity hints extracted from provenance bundles
|
|
67
|
+
- Risk flags and an overall recommendation tier
|
|
68
|
+
|
|
69
|
+
## Roadmap
|
|
70
|
+
|
|
71
|
+
- Full cryptographic verification of PyPI attestations
|
|
72
|
+
- Stronger repository canonicalization and source matching
|
|
73
|
+
- Policy files for CI and org-level enforcement
|
|
74
|
+
- Support for lockfiles and dependency trees
|
|
75
|
+
- Offline caching and SBOM-style export
|
|
76
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/trustcheck/__init__.py
|
|
5
|
+
src/trustcheck/__main__.py
|
|
6
|
+
src/trustcheck/cli.py
|
|
7
|
+
src/trustcheck/models.py
|
|
8
|
+
src/trustcheck/pypi.py
|
|
9
|
+
src/trustcheck/service.py
|
|
10
|
+
src/trustcheck.egg-info/PKG-INFO
|
|
11
|
+
src/trustcheck.egg-info/SOURCES.txt
|
|
12
|
+
src/trustcheck.egg-info/dependency_links.txt
|
|
13
|
+
src/trustcheck.egg-info/entry_points.txt
|
|
14
|
+
src/trustcheck.egg-info/top_level.txt
|
|
15
|
+
tests/test_service.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
trustcheck
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from trustcheck.pypi import PypiClientError
|
|
2
|
+
from trustcheck.service import inspect_package
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeClient:
|
|
7
|
+
def get_project(self, project):
|
|
8
|
+
assert project == "demo"
|
|
9
|
+
return {
|
|
10
|
+
"info": {
|
|
11
|
+
"version": "1.2.3",
|
|
12
|
+
"summary": "Demo package",
|
|
13
|
+
"project_urls": {
|
|
14
|
+
"Homepage": "https://github.com/example/demo",
|
|
15
|
+
"Documentation": "https://docs.example.com/demo",
|
|
16
|
+
},
|
|
17
|
+
"ownership": {
|
|
18
|
+
"organization": "example-org",
|
|
19
|
+
"roles": [{"role": "Owner", "user": "alice"}],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
"urls": [
|
|
23
|
+
{
|
|
24
|
+
"filename": "demo-1.2.3-py3-none-any.whl",
|
|
25
|
+
"url": "https://files.pythonhosted.org/packages/demo.whl",
|
|
26
|
+
"digests": {"sha256": "abc123"},
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"vulnerabilities": [],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def get_release(self, project, version):
|
|
33
|
+
raise AssertionError("not expected in this test")
|
|
34
|
+
|
|
35
|
+
def get_provenance(self, project, version, filename):
|
|
36
|
+
return {
|
|
37
|
+
"attestation_bundles": [
|
|
38
|
+
{
|
|
39
|
+
"publisher": {
|
|
40
|
+
"kind": "GitHub Actions",
|
|
41
|
+
"repository": "https://github.com/example/demo",
|
|
42
|
+
"workflow": "release.yml",
|
|
43
|
+
},
|
|
44
|
+
"attestations": [{"kind": "publish"}],
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NoProvClient(FakeClient):
|
|
51
|
+
def get_provenance(self, project, version, filename):
|
|
52
|
+
raise PypiClientError("resource not found")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InspectPackageTests(unittest.TestCase):
|
|
56
|
+
def test_inspect_package_happy_path(self):
|
|
57
|
+
report = inspect_package(
|
|
58
|
+
"demo",
|
|
59
|
+
expected_repository="https://github.com/example/demo",
|
|
60
|
+
client=FakeClient(),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self.assertEqual(report.project, "demo")
|
|
64
|
+
self.assertEqual(report.version, "1.2.3")
|
|
65
|
+
self.assertEqual(report.recommendation, "looks-good")
|
|
66
|
+
self.assertEqual(report.repository_urls, ["https://github.com/example/demo"])
|
|
67
|
+
self.assertTrue(report.files[0].has_provenance)
|
|
68
|
+
self.assertEqual(
|
|
69
|
+
report.files[0].publisher_identities[0].repository,
|
|
70
|
+
"https://github.com/example/demo",
|
|
71
|
+
)
|
|
72
|
+
self.assertEqual(report.risk_flags, [])
|
|
73
|
+
|
|
74
|
+
def test_inspect_package_flags_missing_provenance_and_repo_mismatch(self):
|
|
75
|
+
report = inspect_package(
|
|
76
|
+
"demo",
|
|
77
|
+
expected_repository="https://github.com/example/other",
|
|
78
|
+
client=NoProvClient(),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
flag_codes = {flag.code for flag in report.risk_flags}
|
|
82
|
+
self.assertIn("expected_repository_mismatch", flag_codes)
|
|
83
|
+
self.assertIn("no_provenance", flag_codes)
|
|
84
|
+
self.assertIn("provenance_lookup_failed", flag_codes)
|
|
85
|
+
self.assertEqual(report.recommendation, "do-not-trust-without-review")
|