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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """trustcheck package."""
2
+
3
+ from .models import TrustReport
4
+ from .service import inspect_package
5
+
6
+ __all__ = ["TrustReport", "inspect_package"]
7
+
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ trustcheck = trustcheck.cli:main
@@ -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")