Skip to content

fix(intoto): tie the default GitHub signer for attest in-toto to the current run - #1377

Merged
tooky merged 11 commits into
mainfrom
fm/intoto-github-review
Nov 14, 2026
Merged

tooky merged 11 commits into
mainfrom
fm/intoto-github-review

Conversation

@tooky

@tooky tooky commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

kosli attest in-toto --source github could record an attestation made by anyone able to push a branch. With no signer flag in GitHub Actions, the signer defaulted to any workflow file in the repository on any ref, so build.yml@refs/heads/feature-x and build.yml@refs/pull/12/merge both passed. Run from a deployment job, the command took the newest attestation that verified, so a made-up SLSA predicate for a production digest could be recorded as verified: true.

The default signer now also requires that the certificate was issued to the run the command is part of, in any of its attempts.

 default signer (no signer flag, GitHub Actions on github.com)
   issuer     https://token.actions.githubusercontent.com
   identity   ^https://github.com/<owner>/<repository>/.github/workflows/
+  run        the certificate was issued to this run, in any attempt

This is point 1 of the review of #1372, which merged before the review's six points were dealt with; points 2 to 6 are below. --source github is not in a release yet (v2.56.0 does not include it), so no released behaviour changes.

What changes for a workflow using the default signer

  • In the run that attested the artifact (a later step or job, or a re-run): nothing. The same attestation is recorded with the same output.

  • In another run, such as a deployment workflow: the command fails until a signer is pinned. The error names each attestation with its run and signer, and the flags to pass:

    --certificate-identity https://github.com/<owner>/<repository>/.github/workflows/build.yml@refs/heads/main \
    --certificate-oidc-issuer https://token.actions.githubusercontent.com

A pinned signer behaves as before and still records an attestation another run made. The default is tied to the run instead of to protected refs.

Attestations that earlier runs made about the same artifact are passed over without a warning when this run's own is recorded. An image rebuilt from cache can keep its digest, and a warning per earlier run would grow with every build.

The run is part of the policy (Policy.RunInvocationURI), so the verifier enforces it: under the default, an attestation from another run is never reported as verified.

The help text, the example for reading from another run and the sample workflow now pin the workflow file with its ref. The old example pinned every workflow in the repository by regexp, which had the same weakness. Docs are generated from the help text.

2. --repository owner/name keeps its owner

The owner was always taken from --github-org, so --repository platform/app read <github-org>/app. The owner now comes from --repository when it names one, and a --github-org the caller gave that disagrees is refused.

--github-org --repository Repository read
acme widget acme/widget
not given platform/app platform/app
acme, defaulted by GitHub Actions platform/app platform/app (was acme/app)
acme, given by the caller platform/app refused as a mismatch
platform, given by the caller acme/widget, defaulted by GitHub Actions refused as a mismatch (was platform/widget); the error says the repository was defaulted from GITHUB_REPOSITORY, and to pass --repository as well
any https://github.com/acme/widget refused (was read as widget)

3. Attestations of other kinds are no longer downloaded

Recording provenance used to download every SBOM bundle too, each through a new retrying client. The listing is now filtered by predicate type, and every bundle downloads through one client.

--predicate-type predicate_type filters sent
provenance https://slsa.dev/provenance/v1, https://slsa.dev/provenance/v0.2
sbom https://spdx.dev/Document, https://cyclonedx.org/bom

GitHub does not document how the filter matches. On github.com it matches by prefix, and answers a filter that matches nothing with 404, the same answer a token that cannot read the repository gets (see Evidence). So when no filter finds anything, the command reads the whole listing as it did before. That keeps the error that names the predicate types GitHub holds instead, and tells "none of this kind" apart from "cannot read the repository".

GitHub also accepts the shorthand sbom. The command sends full predicate types because the shorthand could not be checked against a CycloneDX attestation.

A server that ignores the filter, as an older GitHub Enterprise Server may, answers each listing with everything it holds. An attestation that an earlier listing already gave is skipped, so each is still downloaded and reported once. Entries within one listing are never compared with each other.

One dependency remains. If GitHub ever matched the filter exactly, a versioned SPDX attestation (https://spdx.dev/Document/v2.3) would be missed whenever a CycloneDX one for the same digest matched.

4. --github-org and --repository default only in GitHub Actions

On GitLab CI, Bitbucket Pipelines, Azure DevOps and CircleCI, --repository defaulted to that CI's project name, so --github-org alone led to a 404 that blamed the token. There is no default there now, and the caller is told both flags are required.

5. The fallback attestation link is recorded without a fingerprint

An attestation with no public transparency log entry, as in a private repository, links GitHub's API listing, since that is where it is held. The listing holds every attestation of the artifact, so no fingerprint could match it, and the link is now recorded without one. The help text says the link needs a token. A transparency log entry is linked with the fingerprint as before.

6. The repository and the run are worked out once

They were worked out in three places. validate now does it once, and the default signer is built from the same repository that is read.

Commits

Each builds and passes the in-toto tests on its own.

  1. Fixtures for provenance attested from a branch and from a pull request. Test data only; regenerating makes new keys, so every signed fixture changes.
  2. Tie the default signer to the run (point 1)
  3. Settle the repository and the run once (point 6)
  4. Keep the owner in --repository owner/name (point 2)
  5. Default the two flags only in GitHub Actions (point 4)
  6. Read only the kind being recorded (point 3)
  7. Record the fallback link without a fingerprint (point 5)
  8. Say when the --repository that --github-org disagrees with was defaulted (from the review of this PR)
  9. Note that the fallback listing downloads every bundle on purpose (comment only, from the review of this PR)
  10. Read each attestation once when a server ignores the predicate type filter (from the review of this PR)
  11. Match duplicate attestations only against earlier listings (from the review of this PR)

Evidence

All tests are offline: a fake GitHub server and a throwaway Sigstore trust root.

Point 1. TestGitHubSourceDefaultSigner holds three attestations of one artifact, all signed by the repository's own build.yml: on main in the build, then on refs/heads/feature-x and on refs/pull/12/merge in later runs.

  • Before: with the run check taken out of the default signer, three cases fail, because a deployment run records an attestation where it should record nothing:
    records nothing from outside the run that attested, naming each attestation and the flags that pin a signer
    does not accept an attestation a branch made in another run
    does not accept an attestation a pull request made in another run
    
  • After: they pass. For the branch's attestation the deployment run prints:
    Error: GitHub repository acme/widget: the attestation was made by another run:
    https://github.com/acme/widget/actions/runs/2222222222/attempts/1, signed as
    https://github.com/acme/widget/.github/workflows/build.yml@refs/heads/feature-x.
    No signer is pinned, and the default only accepts an attestation made by the run this
    command is part of (https://github.com/acme/widget/actions/runs/77/attempts/1). To record
    one made by another run, pin its signer: pass --certificate-identity or
    --certificate-identity-regexp with --certificate-oidc-issuer, or --key
    
    The build's own run, and a later attempt of it, record the main attestation. With --certificate-identity ...build.yml@refs/heads/main the deployment run records it too, and warns that the other two failed verification.

Point 3. How github.com answered predicate_type on 13 November 2026 for ghcr.io/kosli-dev/cli:v2.56.0, whose digest holds one SPDX and one SLSA v1 attestation:

predicate_type Returned
none both
https://slsa.dev/provenance/v1 SLSA v1
https://slsa.dev/provenance SLSA v1
https://spdx.dev/Document SPDX v2.3
https://spdx.dev/Doc SPDX v2.3
https://cyclonedx.org/bom, https://slsa.dev/provenance/v0.2 404
sbom SPDX v2.3

The fake GitHub servers in the tests filter the same way, and TestGitHubSourceReadsOnlyThePredicateTypeAskedFor records an SBOM while the provenance beside it cannot be downloaded.

Points 2, 4 and 5 are covered by TestGitHubOwnerAndName, TestAttestInTotoGitHubOrgInARunOfAnotherOwnersRepository, TestAttestInTotoGitHubRepositoryDefaults and TestAttestationLink.

Merge Danger

Door: two-way. The change is confined to the CLI, the command is beta, and --source github has not been released.

Blast Radius:

  • A workflow that runs kosli attest in-toto with the default signer from a run other than the one that attested fails until it pins a signer. The error says what to pass.
  • --github-org given alone in GitHub Actions for another owner, and a --repository that is neither a name nor owner/name, are now refused.
  • Each command sends two listing requests where it sent one. When GitHub holds nothing of the kind asked for, it sends a third and downloads every bundle, as before.
  • A record of an attestation with no public log entry no longer carries a fingerprint on its attestation link.

@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update go code labels Nov 13, 2026
@tooky
tooky marked this pull request as draft November 13, 2026 15:09
@github-actions github-actions Bot added the fix label Nov 13, 2026
@claude

claude Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @tooky's task in 2m 17s —— View job


Follow-up review of new commit (5777dfe..1caa311)

  • Fetch prior review comments and new commit diff
  • Check status of previous findings
  • Review new commit for bugs/regressions within changed lines
  • Post inline comments for any new issues (none needed)
  • Post summary comment

Only one new commit since the last review: 1caa3119 "Match duplicate attestations only against earlier listings" (internal/intoto/github.go, internal/intoto/github_test.go).

Result: mbevc1's "New (low): duplicate check drops the whole download URL query" finding is resolved — the dedup logic now only matches identities against earlier listings, keeping same-listing entries that share an identity, with new test coverage for both the single-listing case and the filter-ignoring-server case. No new bugs or regressions found in the changed lines. The other open threads/findings were either already resolved before this commit or are not code issues (confirmed in the summary comment below).

Full details posted as a summary comment.

Comment thread cmd/kosli/attestInToto.go Resolved
@tooky
tooky requested a review from mbevc1 November 13, 2026 15:43
Comment thread cmd/kosli/attestInToto.go Resolved
@claude

claude Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Follow-up review of new commits (415a221..45d17c0)

Previous finding — resolved. The only prior inline finding (comment, on cmd/kosli/attestInToto.go:543) flagged a confusing error when an explicit --github-org disagrees with a --repository that GitHub Actions defaulted. Commit 2eb87d5a fixes it by tracking repositoryGiven and wording the error differently in that case ("...which --repository defaulted to from GITHUB_REPOSITORY in this GitHub Actions run. Pass --repository as well to read another repository"), with a new test (TestAttestInTotoGitHubOrgInARunOfAnotherOwnersRepository) covering both the defaulted and explicitly-given --repository cases. Thread marked resolved.

New commits reviewed:

  • 2eb87d5a — as above, no issues found.
  • 45d17c0c — comment-only addition to internal/intoto/github.go documenting that the fallback listing (used when no predicate-type filter matches) deliberately downloads every bundle; no behavior change, no issues found.

No new inline comments.

@mbevc1

mbevc1 commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

All six points from the #1372 review are fixed. Two minor findings, neither blocking:

1. Duplicate attestations when a server ignores predicate_type
internal/intoto/github.go:109: the two filtered listings per kind are appended without removing duplicates. A fake server that ignored the filter returned 4 attestations from the 2 it held. Each bundle_url is downloaded twice (SBOMs included) and warnings repeat; the chosen attestation is still correct. This could happen on an older GitHub Enterprise Server. Fix: remove duplicates by bundle URL or bytes.

2. SPDX filter only works if GitHub prefix-matches
internal/intoto/match.go:67: the filter sends https://spdx.dev/Document, but actions/attest writes https://spdx.dev/Document/v2.3. If GitHub matches exactly, every SPDX SBOM falls back to the full listing: still correct, but none of the download savings. Worth one check against a real SPDX attestation.

Nit: sameRun now compares run URIs case-insensitively, which also changes the existing run-preference check. Harmless.

tooky added 10 commits November 13, 2026 18:24
A workflow file signs as its repository whatever ref it runs on, so the
fixtures gain the same provenance statement signed from a branch and
from a pull request, each in a run of its own and logged after the
build's. They stand for what anyone who can push a branch can attest
about an artifact.

Regenerating makes new keys, so every signed fixture and the trust root
change with them.
With --source github in GitHub Actions and no signer flag, the signer
defaulted to any workflow file in the repository, on any ref. Run from
another workflow, such as a deployment, the newest attestation that
verified was recorded. Anyone able to push a branch could run
actions/attest with a predicate of their own for a production digest,
and Kosli recorded it as verified.

The default signer now also requires the certificate to have been
issued to the run the command is part of, in any of its attempts. The
restriction lives in the policy, so the verifier enforces it and an
attestation another run made is never reported as verified by default.
When the run made none, the command fails, names each attestation with
its run and signer, and names the flags that pin a signer.

An attestation recorded in the run that made it is chosen as before,
and one an earlier run made about the same artifact is passed over
without a warning. A pinned signer is unchanged: it still records an
attestation from another run.

The help text, the example for reading from another run and the sample
workflow now pin the workflow file with its ref rather than every
workflow in the repository.

Addresses point 1 of the review on #1372.
The repository to read and the run the command is part of were each
worked out in three places: when choosing the source, when pinning the
default signer and when building the source to read. validate now
settles both once and keeps them, so the default signer is pinned to
the repository and run that are actually read.

Addresses point 6 of the review on #1372.
--repository owner/name lost its owner: the name after the slash was
read under --github-org, so --repository platform/app in a run that
defaulted --github-org to acme read acme/app, and pinned the default
signer to it.

The owner now comes from --repository when it names one, and
--github-org is no longer needed with it. A --github-org the caller
gave, rather than one GitHub Actions defaulted, must name the same
owner; a mismatch is refused, since reading either repository would
ignore one of the two flags. A --repository that is neither a name nor
owner/name is refused too, rather than read as its last path segment.

Addresses point 2 of the review on #1372.
attest in-toto took its --repository default from whichever CI it ran
in, so on GitLab CI, Bitbucket Pipelines, Azure DevOps or CircleCI it
was that CI's project name. A caller there who gave --github-org but
not --repository had a repository of that name read on GitHub, and a
404 blaming the token, rather than being told --repository is
required.

Both flags now default only in GitHub Actions, where the run's
repository is a GitHub repository.

Addresses point 4 of the review on #1372.
The listing of an artifact's attestations was not filtered, so
recording provenance also downloaded every SBOM bundle, and each
download went through a new retrying client.

The listing is now asked for each predicate type of the kind wanted,
with GitHub's predicate_type filter, and every bundle is downloaded
through one client. A source is told the kind it is read for, so one
that can narrow what it returns does.

GitHub's documentation says little about the filter. On github.com in
November 2026 it matched an attestation whose predicate type starts
with it, so https://spdx.dev/Document finds .../v2.3, and it answered a
filter matching nothing with 404, as it does a token that cannot read
the repository. When no filter finds anything the whole listing is
read as before. That tells the two apart, keeps the error that names
the predicate types GitHub holds instead, and does not depend on how
the filter matches. The fake GitHub servers in the tests filter the
same way.

Addresses point 3 of the review on #1372.
An attestation read from GitHub that has no public transparency log
entry, as in a private repository, is linked to GitHub's API listing of
the artifact's attestations. That link was recorded with the SHA-256 of
the one attestation, which the listing can never match: it holds every
attestation of the artifact.

The listing is still linked, since it is where the attestation is held,
but without a fingerprint. The help text says so, and that reading it
needs a token. A transparency log entry is linked with the fingerprint
as before.

Addresses point 5 of the review on #1372.
In GitHub Actions, --github-org given alone for another owner is refused
because --repository defaults to the run's own repository. The error
named a --repository the caller never passed.

It now says the repository was defaulted from GITHUB_REPOSITORY, and
that passing --repository as well reads another one. The refusal
itself stays: reading <github-org>/<name> would be a guess. When the
caller gave both flags the error is as before.
When no predicate type filter finds anything, the whole listing is read
and every bundle in it downloaded, which is the cost the filter exists
to avoid. The comment now says that is deliberate: it is paid only on
that path, to name what GitHub holds instead.
…ilter

The listings for a kind's predicate types were appended as they came. A
server that ignores predicate_type, as an older GitHub Enterprise Server
may, answers each with everything it holds, so every attestation was
listed twice: each bundle was downloaded twice and each warning printed
twice, though the attestation chosen was still the right one.

An attestation already listed is now skipped. It is told apart by where
its bundle is downloaded from, without the query that signs the URL
afresh for each listing, or by the bundle itself when it is given
inline.
@tooky
tooky force-pushed the fm/intoto-github-review branch from 45d17c0 to 5777dfe Compare November 13, 2026 18:33
@tooky

tooky commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

@mbevc1 thanks.

  1. Fixed in 5777dfe. An attestation already listed is skipped, matched by its bundle URL (without the query that signs it) or by the bundle itself when it is inline. There is a test with a fake server that ignores the filter.
  2. GitHub prefix-matches predicate_type. I checked against the real API with the public ghcr.io/kosli-dev/cli:v2.56.0 image: https://spdx.dev/Document, and even https://spdx.dev/Doc, return the https://spdx.dev/Document/v2.3 SBOM, and a value nothing matches returns 404. So the SBOM filter does save the downloads.

The nit: agreed, intended and harmless.

@tooky
tooky marked this pull request as ready for review November 13, 2026 18:51
@mbevc1

mbevc1 commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Could you also please check the following:

New (low): duplicate check drops the whole download URL query
internal/intoto/github.go:94: identity() throws away the whole query of a bundle_url, not just the signature, and the check applies inside a single listing too. Two different attestations whose download URLs differ only in the query would collapse into one. Real GitHub download paths are probably unique per bundle, but that's unconfirmed. Matching only against earlier listings, not within the same one, removes the risk.

The duplicate check told attestations apart by their download URL
without its query, and applied inside a single listing too. Two
different attestations whose download URLs differ only in the query
would have been read as one.

An entry is now skipped only when an earlier listing gave it. Within
one listing every entry is kept, so the check still stops a server that
ignores the predicate type filter from having each attestation read
twice, and can no longer drop one.
@claude

claude Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Follow-up review of new commit (5777dfe..1caa311)

Only one new commit since the last review: 1caa3119 "Match duplicate attestations only against earlier listings", touching internal/intoto/github.go and internal/intoto/github_test.go.

Previous findings in this area:

  1. Duplicate attestations when a server ignores predicate_type (mbevc1) — already resolved in 5777dfe7 (prior to this range), unaffected by this commit.
  2. SPDX filter prefix matching (mbevc1) — no code change needed; confirmed against the real GitHub API that predicate_type prefix-matches. Not touched by this commit.
  3. sameRun case-insensitivity nit (mbevc1) — acknowledged as harmless, no action needed/expected.
  4. New (low): duplicate check drops the whole download URL query (mbevc1) — resolved by 1caa3119. The dedup logic (internal/intoto/github.go:131-148) now only skips an entry whose identity was seen in an earlier listing (inEarlierListing), via two passes over each listing's entries (append-if-not-seen-earlier, then mark). Two attestations that happen to share an identity within the same listing are both kept, exactly as suggested. New tests (TestGitHubRepositoryAttestations/reads_both_of_two_attestations_in_one_listing_whose_download_URLs_differ_only_in_the_query and the companion ...from_a_server_that_ignores_the_filter) cover this directly and trace through correctly.
  5. Inline thread on cmd/kosli/attestInToto.go:543 (owner/repository defaulting edge case) — already resolved and the GitHub review thread is marked resolved; not touched by this commit.

No new bugs, security issues, or regressions found in the changed lines.

View job run

@tooky

tooky commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

@mbevc1 done in 1caa311. An entry is now skipped only when an earlier listing gave it, so two attestations in the same listing are both read even when their download URLs differ only in the query. There is a test for that case.

Across listings the comparison still ignores the query, because each listing signs its download URLs afresh.

@tooky
tooky merged commit 5993e52 into main Nov 14, 2026
25 checks passed
@tooky
tooky deleted the fm/intoto-github-review branch November 14, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation fix go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants