diff --git a/README.md b/README.md index 064008e3b..60ab7d6d9 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ For more information, see [Linux & BSD installation](./docs/install_linux.md). | ------------------- | --------------------| | `winget install --id GitHub.cli` | `winget upgrade --id GitHub.cli` | -> **Note** +> [!NOTE] > The Windows installer modifies your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take effect. (Simply opening a new tab will _not_ be sufficient.) #### scoop @@ -125,6 +125,38 @@ GitHub CLI comes pre-installed in all [GitHub-Hosted Runners](https://docs.githu Download packaged binaries from the [releases page][]. +#### Verification of binaries + +Since version 2.50.0 `gh` has been producing [Build Provenance Attestation](https://github.blog/changelog/2024-06-25-artifact-attestations-is-generally-available/) enabling a cryptographically verifiable paper-trail back to the origin GitHub repository, git revision and build instructions used. The build provenance attestations are signed and relies on Public Good [Sigstore](https://www.sigstore.dev/) for PKI. + +There are two common ways to verify a downloaded release, depending if `gh` is aready installed or not. If `gh` is installed, it's trivial to verify a new release: + +- **Option 1: Using `gh` if already installed:** + + ```shell + $ % gh at verify -R cli/cli gh_2.62.0_macOS_arm64.zip + Loaded digest sha256:fdb77f31b8a6dd23c3fd858758d692a45f7fc76383e37d475bdcae038df92afc for file://gh_2.62.0_macOS_arm64.zip + Loaded 1 attestation from GitHub API + ✓ Verification succeeded! + + sha256:fdb77f31b8a6dd23c3fd858758d692a45f7fc76383e37d475bdcae038df92afc was attested by: + REPO PREDICATE_TYPE WORKFLOW + cli/cli https://slsa.dev/provenance/v1 .github/workflows/deployment.yml@refs/heads/trunk + ``` + +- **Option 2: Using Sigstore [`cosign`](https://github.com/sigstore/cosign):** + + To perform this, download the [attestation](https://github.com/cli/cli/attestations) for the downloaded release and use cosign to verify the authenticity of the downloaded release: + + ```shell + $ cosign verify-blob-attestation --bundle cli-cli-attestation-3120304.sigstore.json \ + --new-bundle-format \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ + --certificate-identity-regexp="^https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk$" \ + gh_2.62.0_macOS_arm64.zip + Verified OK + ``` + ### Build from source See here on how to [build GitHub CLI from source][build from source]. diff --git a/api/queries_pr.go b/api/queries_pr.go index 370f2db24..aa493b5e9 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -33,6 +33,7 @@ type PullRequest struct { Closed bool URL string BaseRefName string + BaseRefOid string HeadRefName string HeadRefOid string Body string diff --git a/api/query_builder.go b/api/query_builder.go index dbf889273..2112367e3 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -285,6 +285,7 @@ var PullRequestFields = append(sharedIssuePRFields, "additions", "autoMergeRequest", "baseRefName", + "baseRefOid", "changedFiles", "commits", "deletions", diff --git a/pkg/cmd/attestation/verification/extensions.go b/pkg/cmd/attestation/verification/extensions.go index a0827e9ec..2958408d0 100644 --- a/pkg/cmd/attestation/verification/extensions.go +++ b/pkg/cmd/attestation/verification/extensions.go @@ -13,25 +13,31 @@ var ( GitHubTenantOIDCIssuer = "https://token.actions.%s.ghe.com" ) -func VerifyCertExtensions(results []*AttestationProcessingResult, ec EnforcementCriteria) error { +// VerifyCertExtensions allows us to perform case insensitive comparisons of certificate extensions +func VerifyCertExtensions(results []*AttestationProcessingResult, ec EnforcementCriteria) ([]*AttestationProcessingResult, error) { if len(results) == 0 { - return errors.New("no attestations proccessing results") + return nil, errors.New("no attestations processing results") } + verified := make([]*AttestationProcessingResult, 0, len(results)) var lastErr error for _, attestation := range results { - err := verifyCertExtensions(*attestation.VerificationResult.Signature.Certificate, ec.Certificate) - if err == nil { - // if at least one attestation is verified, we're good as verification - // is defined as successful if at least one attestation is verified - return nil + if err := verifyCertExtensions(*attestation.VerificationResult.Signature.Certificate, ec.Certificate); err != nil { + lastErr = err + // move onto the next attestation in the for loop if verification fails + continue } - lastErr = err + // otherwise, add the result to the results slice and increment verifyCount + verified = append(verified, attestation) } - // if we have exited the for loop without returning early due to successful - // verification, we need to return an error - return lastErr + // if we have exited the for loop without verifying any attestations, + // return the last error found + if len(verified) == 0 { + return nil, lastErr + } + + return verified, nil } func verifyCertExtensions(given, expected certificate.Summary) error { diff --git a/pkg/cmd/attestation/verification/extensions_test.go b/pkg/cmd/attestation/verification/extensions_test.go index b8ef2875f..73d808119 100644 --- a/pkg/cmd/attestation/verification/extensions_test.go +++ b/pkg/cmd/attestation/verification/extensions_test.go @@ -37,8 +37,9 @@ func TestVerifyCertExtensions(t *testing.T) { } t.Run("passes with one result", func(t *testing.T) { - err := VerifyCertExtensions(results, c) + verified, err := VerifyCertExtensions(results, c) require.NoError(t, err) + require.Len(t, verified, 1) }) t.Run("passes with 1/2 valid results", func(t *testing.T) { @@ -46,8 +47,9 @@ func TestVerifyCertExtensions(t *testing.T) { require.Len(t, twoResults, 2) twoResults[1].VerificationResult.Signature.Certificate.Extensions.SourceRepositoryOwnerURI = "https://github.com/wrong" - err := VerifyCertExtensions(twoResults, c) + verified, err := VerifyCertExtensions(twoResults, c) require.NoError(t, err) + require.Len(t, verified, 1) }) t.Run("fails when all results fail verification", func(t *testing.T) { @@ -56,35 +58,40 @@ func TestVerifyCertExtensions(t *testing.T) { twoResults[0].VerificationResult.Signature.Certificate.Extensions.SourceRepositoryOwnerURI = "https://github.com/wrong" twoResults[1].VerificationResult.Signature.Certificate.Extensions.SourceRepositoryOwnerURI = "https://github.com/wrong" - err := VerifyCertExtensions(twoResults, c) + verified, err := VerifyCertExtensions(twoResults, c) require.Error(t, err) + require.Nil(t, verified) }) t.Run("with wrong SourceRepositoryOwnerURI", func(t *testing.T) { expectedCriteria := c expectedCriteria.Certificate.SourceRepositoryOwnerURI = "https://github.com/wrong" - err := VerifyCertExtensions(results, expectedCriteria) + verified, err := VerifyCertExtensions(results, expectedCriteria) require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://github.com/wrong, got https://github.com/owner") + require.Nil(t, verified) }) t.Run("with wrong SourceRepositoryURI", func(t *testing.T) { expectedCriteria := c expectedCriteria.Certificate.SourceRepositoryURI = "https://github.com/foo/wrong" - err := VerifyCertExtensions(results, expectedCriteria) + verified, err := VerifyCertExtensions(results, expectedCriteria) require.ErrorContains(t, err, "expected SourceRepositoryURI to be https://github.com/foo/wrong, got https://github.com/owner/repo") + require.Nil(t, verified) }) t.Run("with wrong OIDCIssuer", func(t *testing.T) { expectedCriteria := c expectedCriteria.Certificate.Issuer = "wrong" - err := VerifyCertExtensions(results, expectedCriteria) + verified, err := VerifyCertExtensions(results, expectedCriteria) require.ErrorContains(t, err, "expected Issuer to be wrong, got https://token.actions.githubusercontent.com") + require.Nil(t, verified) }) t.Run("with partial OIDCIssuer match", func(t *testing.T) { expectedResults := results expectedResults[0].VerificationResult.Signature.Certificate.Extensions.Issuer = "https://token.actions.githubusercontent.com/foo-bar" - err := VerifyCertExtensions(expectedResults, c) + verified, err := VerifyCertExtensions(expectedResults, c) require.ErrorContains(t, err, "expected Issuer to be https://token.actions.githubusercontent.com, got https://token.actions.githubusercontent.com/foo-bar -- if you have a custom OIDC issuer") + require.Nil(t, verified) }) } diff --git a/pkg/cmd/attestation/verification/mock_verifier.go b/pkg/cmd/attestation/verification/mock_verifier.go index 41332dc62..be828e66f 100644 --- a/pkg/cmd/attestation/verification/mock_verifier.go +++ b/pkg/cmd/attestation/verification/mock_verifier.go @@ -6,6 +6,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" + "github.com/sigstore/sigstore-go/pkg/bundle" "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" in_toto "github.com/in-toto/attestation/go/v1" @@ -13,10 +14,15 @@ import ( ) type MockSigstoreVerifier struct { - t *testing.T + t *testing.T + mockResults []*AttestationProcessingResult } -func (v *MockSigstoreVerifier) Verify(attestations []*api.Attestation, policy verify.PolicyBuilder) ([]*AttestationProcessingResult, error) { +func (v *MockSigstoreVerifier) Verify([]*api.Attestation, verify.PolicyBuilder) ([]*AttestationProcessingResult, error) { + if v.mockResults != nil { + return v.mockResults, nil + } + statement := &in_toto.Statement{} statement.PredicateType = SLSAPredicateV1 @@ -45,11 +51,51 @@ func (v *MockSigstoreVerifier) Verify(attestations []*api.Attestation, policy ve } func NewMockSigstoreVerifier(t *testing.T) *MockSigstoreVerifier { - return &MockSigstoreVerifier{t} + result := BuildSigstoreJsMockResult(t) + results := []*AttestationProcessingResult{&result} + + return &MockSigstoreVerifier{t, results} +} + +func NewMockSigstoreVerifierWithMockResults(t *testing.T, mockResults []*AttestationProcessingResult) *MockSigstoreVerifier { + return &MockSigstoreVerifier{t, mockResults} } type FailSigstoreVerifier struct{} -func (v *FailSigstoreVerifier) Verify(attestations []*api.Attestation, policy verify.PolicyBuilder) ([]*AttestationProcessingResult, error) { +func (v *FailSigstoreVerifier) Verify([]*api.Attestation, verify.PolicyBuilder) ([]*AttestationProcessingResult, error) { return nil, fmt.Errorf("failed to verify attestations") } + +func BuildMockResult(b *bundle.Bundle, buildSignerURI, sourceRepoOwnerURI, sourceRepoURI, issuer string) AttestationProcessingResult { + statement := &in_toto.Statement{} + statement.PredicateType = SLSAPredicateV1 + + return AttestationProcessingResult{ + Attestation: &api.Attestation{ + Bundle: b, + }, + VerificationResult: &verify.VerificationResult{ + Statement: statement, + Signature: &verify.SignatureVerificationResult{ + Certificate: &certificate.Summary{ + Extensions: certificate.Extensions{ + BuildSignerURI: buildSignerURI, + SourceRepositoryOwnerURI: sourceRepoOwnerURI, + SourceRepositoryURI: sourceRepoURI, + Issuer: issuer, + }, + }, + }, + }, + } +} + +func BuildSigstoreJsMockResult(t *testing.T) AttestationProcessingResult { + bundle := data.SigstoreBundle(t) + buildSignerURI := "https://github.com/github/example/.github/workflows/release.yml@refs/heads/main" + sourceRepoOwnerURI := "https://github.com/sigstore" + sourceRepoURI := "https://github.com/sigstore/sigstore-js" + issuer := "https://token.actions.githubusercontent.com" + return BuildMockResult(bundle, buildSignerURI, sourceRepoOwnerURI, sourceRepoURI, issuer) +} diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go index f3f2792c4..bb96c9526 100644 --- a/pkg/cmd/attestation/verify/attestation.go +++ b/pkg/cmd/attestation/verify/attestation.go @@ -48,3 +48,26 @@ func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestatio msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation) return attestations, msg, nil } + +func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { + sgPolicy, err := buildSigstoreVerifyPolicy(ec, art) + if err != nil { + logMsg := "✗ Failed to build Sigstore verification policy" + return nil, logMsg, err + } + + sigstoreVerified, err := sgVerifier.Verify(att, sgPolicy) + if err != nil { + logMsg := "✗ Sigstore verification failed" + return nil, logMsg, err + } + + // Verify extensions + certExtVerified, err := verification.VerifyCertExtensions(sigstoreVerified, ec) + if err != nil { + logMsg := "✗ Policy verification failed" + return nil, logMsg, err + } + + return certExtVerified, "", nil +} diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go new file mode 100644 index 000000000..caa02281f --- /dev/null +++ b/pkg/cmd/attestation/verify/attestation_integration_test.go @@ -0,0 +1,117 @@ +//go:build integration + +package verify + +import ( + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/test" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/stretchr/testify/require" +) + +func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation { + t.Helper() + + attestations, err := verification.GetLocalAttestations(bundlePath) + require.NoError(t, err) + + return attestations +} + +func TestVerifyAttestations(t *testing.T) { + sgVerifier := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ + Logger: io.NewTestHandler(), + }) + + certSummary := certificate.Summary{} + certSummary.SourceRepositoryOwnerURI = "https://github.com/sigstore" + certSummary.SourceRepositoryURI = "https://github.com/sigstore/sigstore-js" + certSummary.Issuer = verification.GitHubOIDCIssuer + + ec := verification.EnforcementCriteria{ + Certificate: certSummary, + PredicateType: verification.SLSAPredicateV1, + SANRegex: "^https://github.com/sigstore/", + } + require.NoError(t, ec.Valid()) + + artifactPath := test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0.tgz") + a, err := artifact.NewDigestedArtifact(nil, artifactPath, "sha512") + require.NoError(t, err) + + t.Run("all attestations pass verification", func(t *testing.T) { + attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") + require.Len(t, attestations, 2) + results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec) + require.NoError(t, err) + require.Zero(t, errMsg) + require.Len(t, results, 2) + }) + + t.Run("passes verification with 2/3 attestations passing Sigstore verification", func(t *testing.T) { + invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json") + attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") + attestations = append(attestations, invalidBundle[0]) + require.Len(t, attestations, 3) + + results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec) + require.NoError(t, err) + require.Zero(t, errMsg) + require.Len(t, results, 2) + }) + + t.Run("fails verification when Sigstore verification fails", func(t *testing.T) { + invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json") + invalidBundle2 := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json") + attestations := append(invalidBundle, invalidBundle2...) + require.Len(t, attestations, 2) + + results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec) + require.Error(t, err) + require.Contains(t, errMsg, "✗ Sigstore verification failed") + require.Nil(t, results) + }) + + t.Run("attestations fail to verify when cert extensions don't match enforcement criteria", func(t *testing.T) { + sgjAttestation := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") + reusableWorkflowAttestations := getAttestationsFor(t, "../test/data/reusable-workflow-attestation.sigstore.json") + attestations := []*api.Attestation{sgjAttestation[0], reusableWorkflowAttestations[0], sgjAttestation[1]} + require.Len(t, attestations, 3) + + rwfResult := verification.BuildMockResult(reusableWorkflowAttestations[0].Bundle, "", "https://github.com/malancas", "", verification.GitHubOIDCIssuer) + sgjResult := verification.BuildSigstoreJsMockResult(t) + mockResults := []*verification.AttestationProcessingResult{&sgjResult, &rwfResult, &sgjResult} + mockSgVerifier := verification.NewMockSigstoreVerifierWithMockResults(t, mockResults) + + // we want to test that attestations that pass Sigstore verification but fail + // cert extension verification are filtered out properly in the second step + // in verifyAttestations. By using a mock Sigstore verifier, we can ensure + // that the call to verification.VerifyCertExtensions in verifyAttestations + // is filtering out attestations as expected + results, errMsg, err := verifyAttestations(*a, attestations, mockSgVerifier, ec) + require.NoError(t, err) + require.Zero(t, errMsg) + require.Len(t, results, 2) + for _, result := range results { + require.NotEqual(t, result.Attestation.Bundle, reusableWorkflowAttestations[0].Bundle) + } + }) + + t.Run("fails verification when cert extension verification fails", func(t *testing.T) { + attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") + require.Len(t, attestations, 2) + + expectedCriteria := ec + expectedCriteria.Certificate.SourceRepositoryOwnerURI = "https://github.com/wrong" + + results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, expectedCriteria) + require.Error(t, err) + require.Contains(t, errMsg, "✗ Policy verification failed") + require.Nil(t, results) + }) +} diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index 462640439..2dc6b9422 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -256,21 +256,9 @@ func runVerify(opts *Options) error { opts.Logger.Println(ec.BuildPolicyInformation()) } - sp, err := buildSigstoreVerifyPolicy(ec, *artifact) + verified, errMsg, err := verifyAttestations(*artifact, attestations, opts.SigstoreVerifier, ec) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build Sigstore verification policy")) - return err - } - - verifyResults, err := opts.SigstoreVerifier.Verify(attestations, sp) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Sigstore verification failed")) - return err - } - - // Verify extensions - if err := verification.VerifyCertExtensions(verifyResults, ec); err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Policy verification failed")) + opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) return err } @@ -279,7 +267,7 @@ func runVerify(opts *Options) error { // If an exporter is provided with the --json flag, write the results to the terminal in JSON format if opts.exporter != nil { // print the results to the terminal as an array of JSON objects - if err = opts.exporter.Write(opts.Logger.IO, verifyResults); err != nil { + if err = opts.exporter.Write(opts.Logger.IO, verified); err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to write JSON output")) return err } @@ -289,7 +277,7 @@ func runVerify(opts *Options) error { opts.Logger.Printf("%s was attested by:\n", artifact.DigestWithAlg()) // Otherwise print the results to the terminal in a table - tableContent, err := buildTableVerifyContent(opts.Tenant, verifyResults) + tableContent, err := buildTableVerifyContent(opts.Tenant, verified) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("failed to parse results")) return err diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index 470e3bd27..e7f572c76 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -32,6 +32,7 @@ func TestJSONFields(t *testing.T) { "author", "autoMergeRequest", "baseRefName", + "baseRefOid", "body", "changedFiles", "closed",