Source code for repomatic.deps.provenance

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""PEP 740 provenance of the PyPI releases a sync bumps to.

A project publishing through PyPI's trusted publishing uploads every release
from its own workflow, which attaches the provenance on its own. So a release
with no provenance, after one that carried it, was uploaded some other way:
by hand, or with a stolen API token, the shape most PyPI account takeovers
take. `sync-uv-lock` and `sync-tool-versions` hold such a bump back and report
it instead of adopting it unread.

The check fails open on a lookup PyPI does not answer: an unknown provenance
holds nothing, since a transient error is not evidence. A trusted publisher
that changes between two releases is flagged in the report and held neither:
a repository transfer or rename changes it legitimately.

The cooldown and the provenance check guard different things. The cooldown
waits for a compromise to be noticed and the release pulled. The provenance
check catches the upload that bypassed the project's pipeline, which a quiet
compromise can survive a cooldown with.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass

from click_extra import run_jobs

from ..pypi import ReleaseProvenance, release_provenance
from .report import ProvenanceHold

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Sequence


[docs] @dataclass(frozen=True) class PypiBump: """One version move of a PyPI package, as a sync is about to make it.""" name: str """Name of the report row: a tool name, or the package name.""" package: str """The PyPI project the versions belong to.""" old: str """Version in place, or empty for a package the run adds.""" new: str """Version the run moves to."""
[docs] @dataclass(frozen=True) class ProvenanceReview: """The provenance of a run's PyPI bumps.""" cells: dict[str, str] """Row name to the markdown of its "Provenance" cell.""" holds: list[ProvenanceHold] """The bumps to withhold, in the order of the bumps reviewed.""" @property def held(self) -> set[str]: """Row names of the withheld bumps.""" return {hold.name for hold in self.holds}
[docs] def provenance_cell(new: ReleaseProvenance, old: ReleaseProvenance | None) -> str: """Render the "Provenance" cell of a bump to *new* from *old*. :param new: Provenance of the release the bump moves to. :param old: Provenance of the release it replaces, `None` for an added package. """ if not new.known: return "_unknown_" if not new.attested: return "none" if not new.publisher: return "✅ attested" if old is not None and old.publisher and old.publisher != new.publisher: return f"⚠️ `{new.publisher}`, was `{old.publisher}`" return f"✅ `{new.publisher}`"
[docs] def is_regression(new: ReleaseProvenance, old: ReleaseProvenance | None) -> bool: """Whether a bump from *old* to *new* lost provenance. Both lookups must have answered: an unknown side proves nothing. """ return ( old is not None and old.known and old.attested and new.known and not new.attested )
[docs] def review_bumps(bumps: Sequence[PypiBump], *, jobs: int = 1) -> ProvenanceReview: """Look up the provenance of both ends of each bump, and pick those to hold. :param bumps: The PyPI version moves of the run. :param jobs: How many lookups run at once. :return: The cell of every bump, and the bumps that lost provenance. """ lookups = [ (bump.package, version) for bump in bumps for version in (bump.old, bump.new) if version ] answers = dict( zip( lookups, run_jobs(lambda item: release_provenance(*item), lookups, jobs=jobs), ) ) cells: dict[str, str] = {} holds: list[ProvenanceHold] = [] for bump in bumps: new = answers[bump.package, bump.new] old = answers[bump.package, bump.old] if bump.old else None if is_regression(new, old): assert old is not None holds.append(ProvenanceHold(bump.name, bump.old, bump.new, old.publisher)) logging.warning( f"Holding {bump.name} at {bump.old}: {bump.new} carries no" f" provenance, while {bump.old} was uploaded by trusted publishing" + (f" from {old.publisher}." if old.publisher else ".") ) continue cells[bump.name] = provenance_cell(new, old) return ProvenanceReview(cells=cells, holds=holds)