scripts/generate-model-checksums.py downloads the URLs that come from remote model listings (the VOSK model-list.json and Hugging Face responses), and urlopen would follow a file: URL there. _https() now refuses anything but https before each request. The md5 digest is only compared against the published listing, not used for security, so it is marked usedforsecurity=False. The remaining findings are false positives for this codebase, and each gets a narrow nosemgrep on its own line with the reason: - text_injector.py: argv lists without a shell; dictated text sits after "--" so it cannot be parsed as an option. - layout_key_map.py: the KDE layout_memory.xml in the user's own session directory, not remote input. - check_runtime.py: module names come from the literal tuples above. - sync_flatpak_deps.py, verify_release.py: fixed https://pypi.org endpoint. - web tests: walks this repo's own src/app tree; loopback dev-server probe in a test.
264 lines
10 KiB
Python
Executable File
264 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Check that a published release verifies as published.
|
|
|
|
The guards in tests/ read workflow files, so none of them can see what a
|
|
release looks like once its run has finished. This reads the release:
|
|
|
|
manifest SHA256SUMS is attached, lists every other asset, and its digests
|
|
match the bytes GitHub stores
|
|
provenance every artifact the manifest lists resolves in the attestations API
|
|
notes the body still tells users how to check both
|
|
pypi the wheel and the sdist on PyPI are the bytes on the release
|
|
|
|
Nothing is downloaded: GitHub reports a sha256 for every asset it stores, and an
|
|
asset it reports none for fails rather than being skipped.
|
|
|
|
Usage: scripts/verify_release.py [tag] [--if-published]
|
|
Default tag: the latest stable release. --if-published passes a tag that
|
|
published nothing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
MANIFEST = "SHA256SUMS"
|
|
PYPI_PROJECT = "voculinux"
|
|
PYPI_SUFFIXES = (".whl", ".tar.gz")
|
|
PYPI_TIMEOUT = 30
|
|
|
|
#: Both commands the notes should hand the user, plus the file they act on.
|
|
NOTES_MUST_MENTION = ("sha256sum -c", "gh attestation verify", MANIFEST)
|
|
|
|
#: What `gh attestation verify` asks for, so this asks for the same. Pre-encoded:
|
|
#: `gh api` reads an unescaped `://` in a query as a protocol and refuses.
|
|
SLSA_PROVENANCE = "https%3A%2F%2Fslsa.dev%2Fprovenance%2Fv1"
|
|
|
|
|
|
class ReleaseNotFound(SystemExit):
|
|
"""The API confirmed no release for the tag, with a 404.
|
|
|
|
Only this is forgivable. A rate limit or an auth failure stays a plain
|
|
SystemExit, or --if-published would pass a release nothing has read.
|
|
"""
|
|
|
|
|
|
def _gh(*args: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(["gh", *args], capture_output=True, text=True)
|
|
|
|
|
|
def _gh_ok(*args: str) -> str:
|
|
done = _gh(*args)
|
|
if done.returncode != 0:
|
|
raise SystemExit(f"gh {' '.join(args)} failed:\n{done.stderr.strip()}")
|
|
return done.stdout
|
|
|
|
|
|
def repo_slug() -> str:
|
|
slug = os.environ.get("GITHUB_REPOSITORY")
|
|
return slug or json.loads(_gh_ok("repo", "view", "--json", "nameWithOwner"))["nameWithOwner"]
|
|
|
|
|
|
def _release_from_api(payload: dict) -> dict:
|
|
"""Map a Releases REST payload onto the field names the rest of this script uses."""
|
|
return {
|
|
"tagName": payload["tag_name"],
|
|
"body": payload.get("body"),
|
|
"isDraft": payload["draft"],
|
|
"isPrerelease": payload["prerelease"],
|
|
"assets": payload.get("assets") or [],
|
|
}
|
|
|
|
|
|
def fetch_release(tag: str | None) -> dict:
|
|
"""The latest stable release, or the one named by tag. `0.16.2` is accepted
|
|
as well as `v0.16.2`, since that is how the version is usually written.
|
|
|
|
Digests come from the Releases REST API (`gh api`), not `gh release view
|
|
--json assets`. On gh 2.46 (common in distro packages, and what
|
|
`just verify-release` often runs) view's asset objects have no digest
|
|
field at all, so every asset would fail as undigested. The REST payload
|
|
includes `digest` for assets GitHub stores a hash for.
|
|
"""
|
|
slug = repo_slug()
|
|
candidates = [tag] + ([f"v{tag}"] if tag and tag[0].isdigit() else [])
|
|
errors: list[str] = []
|
|
absent = True
|
|
for candidate in candidates:
|
|
path = (
|
|
f"/repos/{slug}/releases/tags/{candidate}"
|
|
if candidate
|
|
else f"/repos/{slug}/releases/latest"
|
|
)
|
|
done = _gh("api", path)
|
|
if done.returncode == 0:
|
|
return _release_from_api(json.loads(done.stdout))
|
|
detail = (done.stderr or done.stdout or "").strip()
|
|
absent = absent and "HTTP 404" in detail
|
|
errors.append(f"{path}: {detail or f'exit {done.returncode}'}")
|
|
named = " or ".join(c for c in candidates if c) or "the latest stable release"
|
|
hint = "\n".join(errors)
|
|
raise (ReleaseNotFound if absent else SystemExit)(
|
|
f"no release found for {named}\n{hint}\nusage: verify_release.py [tag], e.g. v0.16.2"
|
|
)
|
|
|
|
|
|
def parse_manifest(text: str) -> dict[str, str]:
|
|
"""`<digest> <name>` per line, as sha256sum writes and reads it."""
|
|
entries = {}
|
|
for number, line in enumerate(text.splitlines(), 1):
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split(None, 1)
|
|
if len(parts) != 2:
|
|
raise ValueError(f"{MANIFEST} line {number} is not `<digest> <name>`: {line!r}")
|
|
# Binary mode marks the name with a leading star, which is not the name.
|
|
entries[parts[1].strip().lstrip("*")] = parts[0].strip().lower()
|
|
return entries
|
|
|
|
|
|
def asset_digests(assets: list[dict]) -> tuple[dict[str, str], list[str]]:
|
|
"""(name -> sha256, names GitHub reports no digest for)."""
|
|
digests: dict[str, str] = {}
|
|
undigested: list[str] = []
|
|
for asset in assets:
|
|
raw = (asset.get("digest") or "").strip().lower()
|
|
if raw.startswith("sha256:"):
|
|
digests[asset["name"]] = raw.split(":", 1)[1]
|
|
else:
|
|
undigested.append(asset["name"])
|
|
return digests, undigested
|
|
|
|
|
|
def check_manifest(stored: dict, manifest: dict, undigested: list) -> list[str]:
|
|
"""Both directions: a missing line reads as tampering to anyone running
|
|
`sha256sum -c`, and an extra one is a promise nothing keeps."""
|
|
covered, listed = set(stored) - {MANIFEST}, set(manifest)
|
|
return (
|
|
[f"GitHub reports no digest for {name}" for name in undigested if name != MANIFEST]
|
|
+ [f"{name} is published but absent from {MANIFEST}" for name in sorted(covered - listed)]
|
|
+ [f"{MANIFEST} lists {name}, which is not an asset" for name in sorted(listed - covered)]
|
|
+ [
|
|
f"{name}: {MANIFEST} says {manifest[name]}, GitHub stores {stored[name]}"
|
|
for name in sorted(covered & listed)
|
|
if manifest[name] != stored[name]
|
|
]
|
|
)
|
|
|
|
|
|
def check_provenance(slug: str, manifest: dict[str, str]) -> list[str]:
|
|
"""The manifest is the subject-checksums input, so it is not a subject itself."""
|
|
problems = []
|
|
for name, digest in sorted(manifest.items()):
|
|
query = f"/repos/{slug}/attestations/sha256:{digest}"
|
|
done = _gh("api", f"{query}?predicate_type={SLSA_PROVENANCE}")
|
|
try:
|
|
bundles = json.loads(done.stdout).get("attestations") if done.returncode == 0 else None
|
|
except json.JSONDecodeError:
|
|
bundles = None
|
|
if not bundles:
|
|
problems.append(f"{name} has no build provenance")
|
|
return problems
|
|
|
|
|
|
def check_notes(body: str | None) -> list[str]:
|
|
# GitHub returns JSON null for an empty body; treat that as missing notes.
|
|
text = body or ""
|
|
return [f"the notes never mention `{p}`" for p in NOTES_MUST_MENTION if p not in text]
|
|
|
|
|
|
def check_pypi(version: str, stored: dict[str, str]) -> list[str]:
|
|
expected = {n: d for n, d in stored.items() if n.endswith(PYPI_SUFFIXES)}
|
|
# Per suffix: a release that lost its sdist would otherwise pass on the wheel.
|
|
problems = [
|
|
f"the release carries no {suffix} to compare against PyPI"
|
|
for suffix in PYPI_SUFFIXES
|
|
if not any(name.endswith(suffix) for name in expected)
|
|
]
|
|
if not expected:
|
|
return problems
|
|
|
|
# Past the early return, and not at module scope: urllib.error reaches
|
|
# tempfile, which two tests here leave as a MagicMock in sys.modules.
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
try:
|
|
url = f"https://pypi.org/pypi/{PYPI_PROJECT}/{version}/json"
|
|
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- fixed https://pypi.org host; version is the release being verified
|
|
with urllib.request.urlopen(url, timeout=PYPI_TIMEOUT) as response:
|
|
payload = json.load(response)
|
|
except urllib.error.HTTPError as error:
|
|
if error.code == 404:
|
|
return problems + [f"PyPI has no {PYPI_PROJECT} {version}"]
|
|
raise
|
|
published = {i["filename"]: i["digests"]["sha256"].lower() for i in payload["urls"]}
|
|
|
|
for name, digest in sorted(expected.items()):
|
|
if name not in published:
|
|
problems.append(f"{name} is on the release but not on PyPI")
|
|
elif published[name] != digest:
|
|
problems.append(f"{name}: PyPI serves {published[name]}, the release serves {digest}")
|
|
for name in sorted(published):
|
|
if name.endswith(PYPI_SUFFIXES) and name not in expected:
|
|
problems.append(f"{name} is on PyPI but not on the release")
|
|
return problems
|
|
|
|
|
|
def report(results: list[tuple[str, list[str]]]) -> bool:
|
|
width = max(len(label) for label, _ in results)
|
|
for label, problems in results:
|
|
print(f" {label.ljust(width)} {'FAIL' if problems else 'PASS'}")
|
|
for problem in problems:
|
|
print(f" {' ' * width} {problem}")
|
|
return not any(problems for _, problems in results)
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
args = [arg for arg in argv[1:] if arg]
|
|
if_published = "--if-published" in args
|
|
positional = [arg for arg in args if arg != "--if-published"]
|
|
tag = positional[0] if positional else None
|
|
slug = repo_slug()
|
|
try:
|
|
release = fetch_release(tag)
|
|
except ReleaseNotFound:
|
|
if not if_published:
|
|
raise
|
|
# Nothing published, nothing to be wrong about, and the Release run
|
|
# has already gone red.
|
|
print(f"no release published for {tag or 'the latest stable tag'}, nothing to verify")
|
|
return 0
|
|
|
|
if release["isDraft"] or release["isPrerelease"]:
|
|
# Nightlies never go through publish-checksums and carry no manifest.
|
|
print(f"{release['tagName']} is not a published stable release")
|
|
return 1
|
|
|
|
tag = release["tagName"]
|
|
stored, undigested = asset_digests(release["assets"])
|
|
print(f"Verifying {slug} {tag}, {len(release['assets'])} assets\n")
|
|
|
|
if MANIFEST not in stored and MANIFEST not in undigested:
|
|
results = [("manifest", [f"{tag} has no {MANIFEST} attached"])]
|
|
else:
|
|
text = _gh_ok("release", "download", tag, "--pattern", MANIFEST, "--output", "-")
|
|
manifest = parse_manifest(text)
|
|
results = [
|
|
("manifest", check_manifest(stored, manifest, undigested)),
|
|
("provenance", check_provenance(slug, manifest)),
|
|
("notes", check_notes(release["body"])),
|
|
("pypi", check_pypi(tag.removeprefix("v"), stored)),
|
|
]
|
|
|
|
ok = report(results)
|
|
print(f"\n{tag} {'verifies' if ok else 'does not verify'} as published")
|
|
return 0 if ok else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|