Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 129 additions & 61 deletions cmd/kosli/attestInToto.go

Large diffs are not rendered by default.

335 changes: 310 additions & 25 deletions cmd/kosli/attestInToto_test.go

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions cmd/kosli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,8 +358,8 @@ Paths the list already matches stay excluded whatever is later added there, so k
intotoRepoRootFlag = "[defaulted] The git checkout to read the commit's details from when they cannot be looked up. Provenance only."
intotoGithubTokenFlag = "[conditional] A GitHub token. Required with --source github, to read the repository's attestations, which needs the 'attestations: read' permission. Also used to look up the commit's message, author and time when the provenance names a GitHub repository."
intotoGithubBaseURLFlag = "[optional] The GitHub Enterprise Server to read attestations from with --source github, and to look the commit up on when the provenance names a repository there, such as https://github.example.com. Without it github.com is used, never a host taken from the provenance. With --source github the signer has no default on such a server and must be pinned."
intotoGithubOrgFlag = "[conditional] The GitHub organization or user owning the repository to read attestations from, with --source github. (defaulted if you are running in GitHub Actions: https://docs.kosli.com/integrations/ci_cd )."
intotoRepositoryFlag = "[conditional] The GitHub repository to read attestations from, with --source github. (defaulted if you are running in GitHub Actions: https://docs.kosli.com/integrations/ci_cd )."
intotoGithubOrgFlag = "[conditional] The GitHub organization or user owning the repository to read attestations from, with --source github. Not needed when --repository names the owner; when both do, they must name the same one. (defaulted if you are running in GitHub Actions: https://docs.kosli.com/integrations/ci_cd )."
intotoRepositoryFlag = "[conditional] The GitHub repository to read attestations from, with --source github: its name, or its owner and name as owner/name. (defaulted if you are running in GitHub Actions: https://docs.kosli.com/integrations/ci_cd )."
excludeScalingFlag = "[optional] Exclude scaling events for snapshots. Snapshots with scaling changes will not result in new environment records."
includeScalingFlag = "[optional] Include scaling events for snapshots. Snapshots with scaling changes will result in new environment records."
includedEnvironments = "[optional] Comma separated list of environments to include in logical environment"
Expand Down
20 changes: 15 additions & 5 deletions docs/sample-workflows/attest-in-toto-github.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,19 @@
# --source defaults to github
# --github-org defaults to this repository's owner
# --repository defaults to this repository
# the signer defaults to a workflow in this repository
# the signer defaults to a workflow in this repository, for an
# attestation this run made
#
# A reusable workflow in another repository signs as that repository, so pin it:
# --certificate-identity-regexp '^https://github.com/yourOrg/shared-workflows/' \
# The default goes no further than this run, because a workflow file signs as
# this repository on any branch or pull request. To record the attestation from
# another run, such as a deployment workflow, pin the workflow that signed it
# and the ref it ran on:
# --certificate-identity https://github.com/yourOrg/yourRepo/.github/workflows/build.yml@refs/heads/main \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
#
# A reusable workflow in another repository signs as that repository, so pin it
# too:
# --certificate-identity https://github.com/yourOrg/shared-workflows/.github/workflows/build.yml@refs/heads/main \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
#
# The flow's template is expected to declare an artifact named `app` with an
Expand Down Expand Up @@ -98,8 +107,9 @@ jobs:
run: kosli begin trail "${KOSLI_TRAIL}"

# The commit, branch, repository and build URL recorded are the ones the
# provenance states, so this step could equally run in a later job or in
# another workflow, such as a deployment.
# provenance states, so this step could equally run in a later job of
# this run. In another workflow, such as a deployment, it also needs the
# signer pinned, as described at the top of this file.
- name: Record the provenance in Kosli, as the artifact named app
env:
DIGEST: ${{ steps.build.outputs.digest }}
Expand Down
52 changes: 34 additions & 18 deletions internal/intoto/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import (
// Source supplies the attestations held about an artifact. A source with its
// own rules for choosing among them is also a Selector.
type Source interface {
// Attestations returns every attestation the source holds that may be
// about the artifact with SHA-256 digest. Find checks each one.
Attestations(digest string) ([]*Attestation, error)
// Attestations returns the attestations the source holds that may be of
// kind and about the artifact with SHA-256 digest. Find checks each one,
// so a source that cannot narrow by either returns everything it holds.
Attestations(kind PredicateKind, digest string) ([]*Attestation, error)
// String names the source in messages.
String() string
}
Expand All @@ -24,9 +25,9 @@ type BundleFile struct {
Path string
}

// Attestations reads every attestation in the file; the digest selects
// nothing here.
func (f BundleFile) Attestations(string) ([]*Attestation, error) {
// Attestations reads every attestation in the file; the kind and the digest
// select nothing here.
func (f BundleFile) Attestations(PredicateKind, string) ([]*Attestation, error) {
content, err := os.ReadFile(f.Path)
if err != nil {
return nil, err
Expand All @@ -43,10 +44,10 @@ func (f BundleFile) String() string {
// file, where every attestation is expected to verify.
type Selection struct {
// SkipFailed passes over an attestation that fails verification, whose
// signature cannot be checked, or that the source marked Unreadable, when
// another one verifies. It suits a store that several signers write to,
// where an attestation by another workflow says nothing against the one
// the policy pins.
// signature cannot be checked, that a run other than the policy's made,
// or that the source marked Unreadable, when another one verifies. It
// suits a store that several signers write to, where an attestation by
// another workflow says nothing against the one the policy pins.
SkipFailed bool
// PreferredRun is the run-invocation URI of the CI run the caller is part
// of. An attestation whose certificate names it is chosen ahead of a newer
Expand All @@ -70,7 +71,8 @@ type Found struct {
// Rejected holds why other attestations of the same kind about the same
// artifact were not chosen, such as being unsigned or, from a source that
// skips them, failing verification, and why any the source holds could not
// be read, so a caller can report them.
// be read, so a caller can report them. One passed over only because
// another run made it is left out.
Rejected []error
}

Expand All @@ -83,17 +85,18 @@ type Found struct {
// If any signed one fails verification, or its signature cannot be checked,
// Find returns that and chooses none: a signature that is bad, or that nobody
// could check, is a stronger signal than a missing one, so an unsigned or
// verified alternative is not recorded in its place. An attestation of any
// kind that the source marked Unreadable counts the same way. A source whose
// Selection skips failures relaxes this only when another attestation
// verified alternative is not recorded in its place. One made by a run other
// than the one the verifier's policy is tied to counts the same way, and so
// does an attestation of any kind that the source marked Unreadable. A source
// whose Selection skips failures relaxes this only when another attestation
// verifies; an unsigned one still does not stand in for one that failed.
func Find(source Source, kind PredicateKind, digest string, verifier *Verifier) (*Found, error) {
var selection Selection
if selector, ok := source.(Selector); ok {
selection = selector.Selection()
}

attestations, err := source.Attestations(digest)
attestations, err := source.Attestations(kind, digest)
if err != nil {
return nil, fmt.Errorf("%s: %w", source, err)
}
Expand Down Expand Up @@ -124,7 +127,7 @@ func Find(source Source, kind PredicateKind, digest string, verifier *Verifier)
for _, candidate := range candidates {
evidence, err := verifier.Verify(candidate, digest)
switch {
case errors.Is(err, ErrFailedVerification), errors.Is(err, ErrCannotVerify):
case errors.Is(err, ErrFailedVerification), errors.Is(err, ErrCannotVerify), errors.Is(err, ErrOtherRun):
unverifiable = append(unverifiable, err)
case err != nil:
rejected = append(rejected, err)
Expand All @@ -134,7 +137,13 @@ func Find(source Source, kind PredicateKind, digest string, verifier *Verifier)
}
}
if selection.SkipFailed && anyVerified {
rejected = append(rejected, unverifiable...)
for _, err := range unverifiable {
// A store holds what every earlier run attested about the artifact
// too, which is not worth a report on each later run.
if !errors.Is(err, ErrOtherRun) {
rejected = append(rejected, err)
}
}
unverifiable = nil
}
if len(unverifiable) == 1 {
Expand All @@ -156,7 +165,7 @@ func Find(source Source, kind PredicateKind, digest string, verifier *Verifier)
return 0
case e.RunInvocationURI == selection.PreferredRun:
return 2
case withoutAttempt(e.RunInvocationURI) == withoutAttempt(selection.PreferredRun):
case sameRun(e.RunInvocationURI, selection.PreferredRun):
return 1
}
return 0
Expand Down Expand Up @@ -186,6 +195,13 @@ func withoutAttempt(runURI string) string {
return runURI
}

// sameRun reports whether two run-invocation URIs name the same GitHub
// Actions run, in whichever attempts. GitHub treats the owner and repository
// names in them as case-insensitive.
func sameRun(runURI, otherRunURI string) bool {
return strings.EqualFold(withoutAttempt(runURI), withoutAttempt(otherRunURI))
}

// joinedErrors reports several errors on one line, which reads best on a
// terminal, while errors.Is still finds each of them.
type joinedErrors []error
Expand Down
10 changes: 10 additions & 0 deletions internal/intoto/fixtures_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ const (
fixtureOtherIdentity = "https://github.com/mallory/widget/.github/workflows/build.yml@refs/heads/main"
fixtureRunURI = "https://github.com/acme/widget/actions/runs/1234567890/attempts/1"
fixtureRerunURI = "https://github.com/acme/widget/actions/runs/1234567890/attempts/2"
// A workflow file on a branch or a pull request signs as the repository
// too, in a run of its own.
fixtureBranchIdentity = "https://github.com/acme/widget/.github/workflows/build.yml@refs/heads/feature-x"
fixtureBranchRunURI = "https://github.com/acme/widget/actions/runs/2222222222/attempts/1"
fixturePullRequestIdentity = "https://github.com/acme/widget/.github/workflows/build.yml@refs/pull/12/merge"
fixturePullRequestRunURI = "https://github.com/acme/widget/actions/runs/3333333333/attempts/1"
)

var (
Expand Down Expand Up @@ -269,6 +275,10 @@ func TestRegenerateFixtures(t *testing.T) {
indent(t, s.bundle(statement("github-provenance.json"), fixtureOtherIdentity, time.Minute)))
writeFixture(t, "rerun-provenance.sigstore.json",
indent(t, s.bundleFromRun(statement("rerun-provenance.json"), fixtureIdentity, fixtureRerunURI, 2*time.Minute)))
writeFixture(t, "branch-provenance.sigstore.json",
indent(t, s.bundleFromRun(statement("github-provenance.json"), fixtureBranchIdentity, fixtureBranchRunURI, 3*time.Minute)))
writeFixture(t, "pull-request-provenance.sigstore.json",
indent(t, s.bundleFromRun(statement("github-provenance.json"), fixturePullRequestIdentity, fixturePullRequestRunURI, 4*time.Minute)))
writeFixture(t, "tampered-provenance.sigstore.json",
indent(t, tamper(t, s.bundle(statement("github-provenance.json"), fixtureIdentity, time.Minute),
"4f9d2a7c1e6b8a3d5f0e2c9b7a1d4e6f8c3b5a70", "0000000000000000000000000000000000000000")))
Expand Down
Loading
Loading