Skip to content

sync-upstream: confirm the repository behind a release feed - #255

Open
firemanxbr wants to merge 1 commit into
omacom:masterfrom
firemanxbr:verify-upstream-identity
Open

sync-upstream: confirm the repository behind a release feed#255
firemanxbr wants to merge 1 commit into
omacom:masterfrom
firemanxbr:verify-upstream-identity

Conversation

@firemanxbr

Copy link
Copy Markdown

upstream.github names the repository a package's releases come from. It is
written once, in the pull request that adds the package, and there is no reason
for anyone to look at it again.

Two things change underneath it without anything in the sync noticing.

A repository gets renamed or transferred. GitHub keeps serving the old
owner/repo through a redirect, so the feed still resolves and every sync keeps
passing. Meanwhile the old name is released back into the namespace: anyone can
register an account under the old owner and recreate the repository, and from
that point the redirect stops and sync-upstream reads their releases instead.
The package keeps building, keeps getting signed, and nothing in the pipeline has
a reason to complain.

A repository gets archived. An archived repository cannot publish a release,
so a feed that appears to ship one from it is not the feed we think we are
reading — the same class of anomaly this provider already treats as a loud error
when a tag or a timestamp is unusable.

Both are one field away from being detectable, so this checks them:

  • the full_name GitHub returns must match what upstream.github declares
    (case-insensitively, since GitHub does not distinguish case)
  • the repository must not be archived
  • a repository response that cannot be read fails the sync, on the same grounds
    an unusable tag does

What this deliberately does not check

Repository age and release count. Neither is evidence about an artifact — a
project can be new and sound, or years old and never once reviewed — and a
threshold on either rejects sound young upstreams while stopping no attacker who
is willing to wait. Every check here reports a fact about the repository as it is
right now, which is the only kind of claim it can honestly make.

Cost

One GET /repos/:owner/:repo, on the path that already fetches the checksum
manifest, so it runs only when a release is actually about to be adopted rather
than once per package on a scheduled sync.

Testing

bin/sync-upstream self-test gains 8 cases covering the rename, the archive, a
response missing archived, an unreadable response, the case-insensitivity rule,
and an end-to-end run where the release feed is impeccable and the repository
behind it is the thing that is wrong. The fetch sits behind a function like the
existing two, so the path stays offline; an unset fixture reports the declared
repository live and unarchived, which leaves every existing case unchanged.

48 assertions pass, verified on Arch with bash 5.3.8 and pacman's vercmp.

Known limitation

The check runs when a new release is adopted, which means a repository that is
renamed while its feed is dormant is not noticed until the next release. Covering
that needs a periodic re-verification of every package rather than a check on the
adoption path, which seemed like a separate change rather than something to fold
in here. Happy to follow up with it if you want it.

upstream.github is metadata a person writes once, in a pull request, and has
no reason to revisit. Two things change underneath it without anything in the
sync noticing.

A renamed or transferred repository keeps resolving through GitHub's redirect,
so every sync keeps passing while the declared name sits abandoned and free
for anyone to register; whoever takes it inherits the package's release feed.
Comparing the full_name GitHub returns against what is declared is the whole
defense and costs one field. An archived repository cannot publish anything,
so a feed that appears to ship a new release from one is not the feed we
believe we are reading -- the same class of anomaly this provider already
treats as a loud error. A repository response that cannot be read fails too,
on the same grounds an unusable tag does.

Repository age and release count are deliberately not checked. Neither is
evidence about an artifact: a project can be new and sound, or years old and
never once reviewed, and an attacker willing to wait defeats any threshold on
either while sound young upstreams get rejected. Every check here reports a
fact about the repository as it is now, which is the only kind of claim this
can honestly make.

The call sits on the path that already fetches a checksum manifest, so a
scheduled run over every package pays for it only when a release is actually
about to be adopted.

Adds 8 self-test cases. The fetch sits behind a function as the existing ones
do, so the path stays offline-testable; an unset fixture reports the declared
repository live and unarchived, leaving existing cases untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant