Skip to content
Merged
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
3 changes: 2 additions & 1 deletion internal/cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,8 @@ this is required to reach SLSA source level 2+.

%s
Opens a pull request in the repository to add the provenance generation workflow
after every push.
after every push. If the repository already has a workflow calling the SLSA
actions from a deprecated location, the pull request updates it instead.

%s
Opens a pull request on the SLSA policy repository to check in a SLSA Source
Expand Down
68 changes: 52 additions & 16 deletions internal/cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/slsa-framework/source-tool/pkg/policy"
"github.com/slsa-framework/source-tool/pkg/slsa"
"github.com/slsa-framework/source-tool/pkg/sourcetool"
"github.com/slsa-framework/source-tool/pkg/sourcetool/models"
)

var (
Expand Down Expand Up @@ -137,6 +138,12 @@ sourcetool status myorg/myrepo@mybranch
return nil
}

// Look for provenance workflows that need to be updated
workflows, err := srctool.FindProvenanceWorkflows(cmd.Context(), opts.GetBranch())
if err != nil {
return fmt.Errorf("checking provenance workflows: %w", err)
}

title := fmt.Sprintf(
"\nSLSA Source Status for %s/%s@%s", opts.owner, opts.repository,
ghcontrol.BranchToFullRef(opts.branch),
Expand Down Expand Up @@ -197,8 +204,11 @@ sourcetool status myorg/myrepo@mybranch

fmt.Println(w("Current SLSA Source level: " + verifiedLevel))
printLevelGap(toplevel, verifiedLevel, evalResult.Shortfall)
printLegacyWorkflows(workflows)
fmt.Println("")
titled := false

// Collect the recommended actions from the controls
actions := []*slsa.ControlRecommendedAction{}
for _, status := range controls.Controls {
if status.RecommendedAction == nil {
continue
Expand All @@ -208,28 +218,25 @@ sourcetool status myorg/myrepo@mybranch
if status.Name == slsa.PolicyAvailable && !slsa.IsLevelHigherOrEqualTo(toplevel, slsa.SlsaSourceLevel3) {
continue
}
actions = append(actions, status.RecommendedAction)
}

if !titled {
fmt.Println(w2("✨ Recommended actions:"))
titled = true
}

fmt.Printf(" - %s\n", status.RecommendedAction.Message)
if status.RecommendedAction.Command != "" {
fmt.Printf(" > %s\n", status.RecommendedAction.Command)
// ... from the provenance workflows
for _, wf := range workflows {
if wf.RecommendedAction != nil {
actions = append(actions, wf.RecommendedAction)
}
fmt.Println()
}

// ... and from the policy
if policyNeedsUpdate {
if !titled {
fmt.Println(w2("✨ Recommended actions:"))
}
fmt.Println(" - Update the repository source policy")
fmt.Printf(" > sourcetool policy create --update %s\n", opts.GetRepository().Path)
fmt.Println()
actions = append(actions, &slsa.ControlRecommendedAction{
Message: "Update the repository source policy",
Command: "sourcetool policy create --update " + opts.GetRepository().Path,
})
}

printRecommendedActions(actions)
return nil
},
}
Expand All @@ -248,6 +255,35 @@ func firstSourceLevel(levels slsa.SourceVerifiedLevels) string {
return string(slsa.SlsaSourceLevel0)
}

// printRecommendedActions prints the list of recommended actions, if any
func printRecommendedActions(actions []*slsa.ControlRecommendedAction) {
if len(actions) == 0 {
return
}
fmt.Println(w2("✨ Recommended actions:"))
for _, action := range actions {
fmt.Printf(" - %s\n", action.Message)
if action.Command != "" {
fmt.Printf(" > %s\n", action.Command)
}
fmt.Println()
}
}

// printLegacyWorkflows warns about provenance workflows still calling the
// SLSA actions from a deprecated repository.
func printLegacyWorkflows(workflows []*models.ProvenanceWorkflow) {
for _, wf := range workflows {
if !wf.IsLegacy() {
continue
}
fmt.Printf(
"%s The workflow %s calls the SLSA actions from the deprecated %s repository.\n",
w2("⚠️ "), wf.Path, strings.Join(wf.LegacyActionsRepos, " and "),
)
}
}

// printLevelGap explains when the policy-verified level is below the level the
// active controls would otherwise support.
func printLevelGap(eligible slsa.SlsaSourceLevel, verified string, shortfall *policy.PolicyShortfall) {
Expand Down
6 changes: 3 additions & 3 deletions pkg/attest/provenance.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,9 @@ func (a *Attester) GetRevisionVSA(ctx context.Context, branch *models.Branch, re
continue
}

// Check the verifier ID matches
if vsaPred.GetVerifier().GetId() != VsaVerifierId {
Debugf("VSA verfier ID does not match %s", VsaVerifierId)
// Check the verifier ID is one we accept
if !IsAcceptedVsaVerifierId(vsaPred.GetVerifier().GetId()) {
Debugf("VSA verifier ID %q is not one of %v", vsaPred.GetVerifier().GetId(), AcceptedVsaVerifierIds)
continue
}

Expand Down
150 changes: 111 additions & 39 deletions pkg/attest/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package attest
import (
"errors"
"fmt"
"regexp"

"github.com/carabiner-dev/attestation"
"github.com/carabiner-dev/signer"
Expand All @@ -15,12 +16,23 @@ import (
)

type VerificationOptions struct {
// ExpectedIssuer is the OIDC issuer of the certificates signing the
// attestations. It is required, no identity is accepted without it.
ExpectedIssuer string
ExpectedSan string

// AlternateSans lists additional signer identities accepted when
// verifying attestations. It carries the pre-rename workflow identity
// while repositories still have attestations signed with it.
// ExpectedSan pins the signer identity to an exact subject alternative
// name. When set, ExpectedSanPrefix is ignored.
ExpectedSan string

// ExpectedSanPrefix accepts any signer identity starting with the
// prefix. Users pin the provenance workflow to different tags and
// digests, so the git reference ending its identity varies.
ExpectedSanPrefix string

// AlternateSans lists additional signer identities accepted (exactly)
// when verifying attestations. It carries the identities of the
// workflows that signed attestations before the actions moved to their
// current repository.
//
// See https://github.com/slsa-framework/source-tool/issues/255
AlternateSans []string
Expand All @@ -30,24 +42,88 @@ const (
// ExpectedIssuer is the OIDC issuer found in the sigstore bundles
ExpectedIssuer = "https://token.actions.githubusercontent.com"

// Expected SAN is the expected identity of the workflow signing the
// provenance and VSAs.
ExpectedSan = "https://github.com/slsa-framework/source-actions/.github/workflows/compute_slsa_source.yml@refs/heads/main"
// ExpectedSanPrefix is the prefix of the identity of the reusable workflow
// signing the provenance and VSAs. The full identity ends with the git
// reference the workflow was pinned to, which varies across users and
// releases.
ExpectedSanPrefix = "https://github.com/slsa-framework/actions/.github/workflows/compute_slsa_source.yml@"

// OldExpectedSan is the old singer identity before splitting out the actions to their own repo
// this constant is part of a compatibility hack that should be reverted once the latests attestations
// of the repos are signed with the new identity.
// LegacySourceActionsSan is the identity of the workflow that signed
// attestations while the actions lived in slsa-framework/source-actions.
LegacySourceActionsSan = "https://github.com/slsa-framework/source-actions/.github/workflows/compute_slsa_source.yml@refs/heads/main"

// LegacyPocSan is the identity of the workflow that signed attestations
// before the actions were split out of the slsa-source-poc repository.
//
// See https://github.com/slsa-framework/source-tool/issues/255
OldExpectedSan = "https://github.com/slsa-framework/slsa-source-poc/.github/workflows/compute_slsa_source.yml@refs/heads/main"
LegacyPocSan = "https://github.com/slsa-framework/slsa-source-poc/.github/workflows/compute_slsa_source.yml@refs/heads/main"
)

// TODO: Update ExpectedSan to support regex so we can get the branches/tags we really think
// folks should be using (they won't all run from main).
// DefaultVerifierOptions accept attestations signed by the current provenance
// workflow, whatever reference it is pinned to, and by the legacy workflows
// while repositories still carry attestations signed by them.
var DefaultVerifierOptions = VerificationOptions{
ExpectedIssuer: ExpectedIssuer,
ExpectedSan: ExpectedSan,
AlternateSans: []string{OldExpectedSan},
ExpectedIssuer: ExpectedIssuer,
ExpectedSanPrefix: ExpectedSanPrefix,
AlternateSans: []string{LegacySourceActionsSan, LegacyPocSan},
}

// expectedIdentities returns the signer identities accepted by the options.
// Without an issuer no identity is accepted.
func (vo *VerificationOptions) expectedIdentities() []*sapi.Identity {
if vo.ExpectedIssuer == "" {
return nil
}

ids := []*sapi.Identity{}
switch {
case vo.ExpectedSan != "":
ids = append(ids, exactIdentity(vo.ExpectedIssuer, vo.ExpectedSan))
case vo.ExpectedSanPrefix != "":
ids = append(ids, &sapi.Identity{
Sigstore: &sapi.IdentitySigstore{
Issuer: vo.ExpectedIssuer,
IdentityMatch: &sapi.StringMatcher{
Kind: &sapi.StringMatcher_Prefix{Prefix: vo.ExpectedSanPrefix},
},
},
})
}

for _, san := range vo.AlternateSans {
if san == "" {
continue
}
ids = append(ids, exactIdentity(vo.ExpectedIssuer, san))
}
return ids
}

// exactIdentity builds a sigstore identity matching the issuer and SAN exactly
func exactIdentity(issuer, san string) *sapi.Identity {
return &sapi.Identity{
Sigstore: &sapi.IdentitySigstore{
Issuer: issuer,
Identity: san,
},
}
}

// String describes the accepted identities for error messages
func (vo *VerificationOptions) String() string {
sans := []string{}
switch {
case vo.ExpectedSan != "":
sans = append(sans, vo.ExpectedSan)
case vo.ExpectedSanPrefix != "":
sans = append(sans, vo.ExpectedSanPrefix+"*")
}
for _, san := range vo.AlternateSans {
if san != "" {
sans = append(sans, san)
}
}
return fmt.Sprintf("issuer %q identities %q", vo.ExpectedIssuer, sans)
}

type Verifier interface {
Expand All @@ -64,27 +140,32 @@ type BndVerifier struct {
Options VerificationOptions
}

// Verify checks a signed bundle, ensuring the signer matches the expected
// identity. Note that this method does not accept the alternate identities,
// only the expected SAN (or prefix) is checked.
func (bv *BndVerifier) Verify(data string) (*verify.VerificationResult, error) {
// TODO: There's more for us to do here... but what?
// Maybe check to make sure it's from the identity we expect (the workflow?)
verifier := signer.NewVerifier()

identityOpts := []options.VerificationOptFunc{
options.WithExpectedIdentity(bv.Options.ExpectedIssuer, bv.Options.ExpectedSan),
}
if bv.Options.ExpectedSan == "" && bv.Options.ExpectedSanPrefix != "" {
identityOpts = append(identityOpts, options.WithExpectedIdentityRegex(
"", "^"+regexp.QuoteMeta(bv.Options.ExpectedSanPrefix),
))
}

// Verify the signed bundle
vr, err := verifier.VerifyInlineBundle(
[]byte(data),
options.WithExpectedIdentity(
bv.Options.ExpectedIssuer, bv.Options.ExpectedSan,
),
)
vr, err := verifier.VerifyInlineBundle([]byte(data), identityOpts...)
if err != nil {
return nil, err
}
return vr, nil
}

// VerifyEnvelope verifies the signature of an attestation envelope fetched
// by the collector and checks that the signer matches the expected identity
// (issuer + SAN) or one of the accepted alternate identities.
// by the collector and checks that the signer matches one of the expected
// identities.
func (bv *BndVerifier) VerifyEnvelope(env attestation.Envelope) error {
if env == nil {
return errors.New("unable to verify, envelope is nil")
Expand All @@ -104,24 +185,15 @@ func (bv *BndVerifier) VerifyEnvelope(env attestation.Envelope) error {
return errors.New("envelope carries no verified signature")
}

// Check the signer identity against the expected SANs
for _, san := range append([]string{bv.Options.ExpectedSan}, bv.Options.AlternateSans...) {
if san == "" {
continue
}
if verification.MatchesIdentity(&sapi.Identity{
Sigstore: &sapi.IdentitySigstore{
Issuer: bv.Options.ExpectedIssuer,
Identity: san,
},
}) {
// Check the signer identity against the expected identities
for _, id := range bv.Options.expectedIdentities() {
if verification.MatchesIdentity(id) {
return nil
}
}

return fmt.Errorf(
"envelope signer does not match the expected identity (issuer %q identity %q)",
bv.Options.ExpectedIssuer, bv.Options.ExpectedSan,
"envelope signer does not match any expected identity (%s)", bv.Options.String(),
)
}

Expand Down
Loading
Loading