From 1b59ec8ad07c07173df8da120b3ea82966a94ddc Mon Sep 17 00:00:00 2001 From: Fredrik Skogman Date: Thu, 29 Aug 2024 13:42:26 +0200 Subject: [PATCH] This commit introduces tenancy aware attestation policy building. This is done by inspecting the current hostname to determine if tenancy is enabled. The attestation commands also accepts a --hostname parameter, that is used to pick the current host, similar to how the GH_HOST variable can be used. Signed-off-by: Fredrik Skogman --- .gitignore | 3 + pkg/cmd/attestation/api/client.go | 24 ++++- pkg/cmd/attestation/api/client_test.go | 32 +++++++ .../attestation/api/mock_apiClient_test.go | 28 ++++++ pkg/cmd/attestation/api/mock_client.go | 5 ++ pkg/cmd/attestation/api/trust_domain.go | 15 ++++ pkg/cmd/attestation/auth/host.go | 5 +- pkg/cmd/attestation/auth/host_test.go | 5 +- pkg/cmd/attestation/download/download.go | 16 ++-- pkg/cmd/attestation/download/options.go | 1 + pkg/cmd/attestation/inspect/bundle.go | 30 ++++--- pkg/cmd/attestation/inspect/bundle_test.go | 14 ++- pkg/cmd/attestation/inspect/inspect.go | 33 ++++++- pkg/cmd/attestation/inspect/options.go | 2 + .../attestation/trustedroot/trustedroot.go | 88 ++++++++++++++----- .../attestation/verification/extensions.go | 54 ++++++++---- .../verification/extensions_test.go | 60 ++++++++++++- pkg/cmd/attestation/verification/sigstore.go | 20 ++++- pkg/cmd/attestation/verify/options.go | 15 +++- pkg/cmd/attestation/verify/policy.go | 50 +++-------- pkg/cmd/attestation/verify/policy_test.go | 47 ++++++---- pkg/cmd/attestation/verify/verify.go | 73 +++++++++++---- .../verify/verify_integration_test.go | 16 +++- pkg/cmd/attestation/verify/verify_test.go | 67 +++++++++++++- 24 files changed, 550 insertions(+), 153 deletions(-) create mode 100644 pkg/cmd/attestation/api/trust_domain.go diff --git a/.gitignore b/.gitignore index b2b66aaf7..272b7703d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,7 @@ # vim *.swp +# Emacs +*~ + vendor/ diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index b0cafa6e9..460ae3aad 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -8,7 +8,6 @@ import ( "github.com/cli/cli/v2/api" ioconfig "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/go-gh/v2/pkg/auth" ) const ( @@ -18,12 +17,14 @@ const ( ) type apiClient interface { + REST(hostname, method, p string, body io.Reader, data interface{}) error RESTWithNext(hostname, method, p string, body io.Reader, data interface{}) (string, error) } type Client interface { GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error) + GetTrustDomain() (string, error) } type LiveClient struct { @@ -32,9 +33,7 @@ type LiveClient struct { logger *ioconfig.Handler } -func NewLiveClient(hc *http.Client, l *ioconfig.Handler) *LiveClient { - host, _ := auth.DefaultHost() - +func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClient { return &LiveClient{ api: api.NewClientFromHTTP(hc), host: strings.TrimSuffix(host, "/"), @@ -64,6 +63,12 @@ func (c *LiveClient) GetByOwnerAndDigest(owner, digest string, limit int) ([]*At return c.getAttestations(url, owner, digest, limit) } +// GetTrustDomain returns the current trust domain. If the default is used +// the empty string is returned +func (c *LiveClient) GetTrustDomain() (string, error) { + return c.getTrustDomain(MetaPath) +} + func (c *LiveClient) getAttestations(url, name, digest string, limit int) ([]*Attestation, error) { c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest) @@ -102,3 +107,14 @@ func (c *LiveClient) getAttestations(url, name, digest string, limit int) ([]*At return attestations, nil } + +func (c *LiveClient) getTrustDomain(url string) (string, error) { + var resp MetaResponse + + err := c.api.REST(c.host, http.MethodGet, url, nil, &resp) + if err != nil { + return "", err + } + + return resp.Domains.ArtifactAttestations.TrustDomain, nil +} diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index 693e3266e..bfcb40f5a 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -172,3 +172,35 @@ func TestGetByDigest_Error(t *testing.T) { require.Error(t, err) require.Nil(t, attestations) } + +func TestGetTrustDomain(t *testing.T) { + fetcher := mockMetaGenerator{ + TrustDomain: "foo", + } + + t.Run("with returned trust domain", func(t *testing.T) { + c := LiveClient{ + api: mockAPIClient{ + OnREST: fetcher.OnREST, + }, + logger: io.NewTestHandler(), + } + td, err := c.GetTrustDomain() + require.Nil(t, err) + require.Equal(t, "foo", td) + + }) + + t.Run("with error", func(t *testing.T) { + c := LiveClient{ + api: mockAPIClient{ + OnREST: fetcher.OnRESTError, + }, + logger: io.NewTestHandler(), + } + td, err := c.GetTrustDomain() + require.Equal(t, "", td) + require.ErrorContains(t, err, "test error") + }) + +} diff --git a/pkg/cmd/attestation/api/mock_apiClient_test.go b/pkg/cmd/attestation/api/mock_apiClient_test.go index 1d4f61cd9..d58cdbc79 100644 --- a/pkg/cmd/attestation/api/mock_apiClient_test.go +++ b/pkg/cmd/attestation/api/mock_apiClient_test.go @@ -10,12 +10,17 @@ import ( type mockAPIClient struct { OnRESTWithNext func(hostname, method, p string, body io.Reader, data interface{}) (string, error) + OnREST func(hostname, method, p string, body io.Reader, data interface{}) error } func (m mockAPIClient) RESTWithNext(hostname, method, p string, body io.Reader, data interface{}) (string, error) { return m.OnRESTWithNext(hostname, method, p, body, data) } +func (m mockAPIClient) REST(hostname, method, p string, body io.Reader, data interface{}) error { + return m.OnREST(hostname, method, p, body, data) +} + type mockDataGenerator struct { NumAttestations int } @@ -87,3 +92,26 @@ func (m mockDataGenerator) OnRESTWithNextNoAttestations(hostname, method, p stri func (m mockDataGenerator) OnRESTWithNextError(hostname, method, p string, body io.Reader, data interface{}) (string, error) { return "", errors.New("failed to get attestations") } + +type mockMetaGenerator struct { + TrustDomain string +} + +func (m mockMetaGenerator) OnREST(hostname, method, p string, body io.Reader, data interface{}) error { + var template = ` +{ + "domains": { + "artifact_attestations": { + "trust_domain": "%s" + } + } +} +` + var jsonString = fmt.Sprintf(template, m.TrustDomain) + return json.Unmarshal([]byte(jsonString), &data) + +} + +func (m mockMetaGenerator) OnRESTError(hostname, method, p string, body io.Reader, data interface{}) error { + return errors.New("test error") +} diff --git a/pkg/cmd/attestation/api/mock_client.go b/pkg/cmd/attestation/api/mock_client.go index edb51ee6e..bcb51c414 100644 --- a/pkg/cmd/attestation/api/mock_client.go +++ b/pkg/cmd/attestation/api/mock_client.go @@ -9,6 +9,7 @@ import ( type MockClient struct { OnGetByRepoAndDigest func(repo, digest string, limit int) ([]*Attestation, error) OnGetByOwnerAndDigest func(owner, digest string, limit int) ([]*Attestation, error) + OnGetTrustDomain func() (string, error) } func (m MockClient) GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) { @@ -19,6 +20,10 @@ func (m MockClient) GetByOwnerAndDigest(owner, digest string, limit int) ([]*Att return m.OnGetByOwnerAndDigest(owner, digest, limit) } +func (m MockClient) GetTrustDomain() (string, error) { + return m.OnGetTrustDomain() +} + func makeTestAttestation() Attestation { return Attestation{Bundle: data.SigstoreBundle(nil)} } diff --git a/pkg/cmd/attestation/api/trust_domain.go b/pkg/cmd/attestation/api/trust_domain.go new file mode 100644 index 000000000..5cf9309ae --- /dev/null +++ b/pkg/cmd/attestation/api/trust_domain.go @@ -0,0 +1,15 @@ +package api + +const MetaPath = "meta" + +type ArtifactAttestations struct { + TrustDomain string `json:"trust_domain"` +} + +type Domain struct { + ArtifactAttestations ArtifactAttestations `json:"artifact_attestations"` +} + +type MetaResponse struct { + Domains Domain `json:"domains"` +} diff --git a/pkg/cmd/attestation/auth/host.go b/pkg/cmd/attestation/auth/host.go index 1b5a344ea..a40df8335 100644 --- a/pkg/cmd/attestation/auth/host.go +++ b/pkg/cmd/attestation/auth/host.go @@ -4,14 +4,11 @@ import ( "errors" "github.com/cli/cli/v2/internal/ghinstance" - "github.com/cli/go-gh/v2/pkg/auth" ) var ErrUnsupportedHost = errors.New("An unsupported host was detected. Note that gh attestation does not currently support GHES") -func IsHostSupported() error { - host, _ := auth.DefaultHost() - +func IsHostSupported(host string) error { // Note that this check is slightly redundant as Tenancy should not be considered Enterprise // but the ghinstance package has not been updated to reflect this yet. if ghinstance.IsEnterprise(host) && !ghinstance.IsTenancy(host) { diff --git a/pkg/cmd/attestation/auth/host_test.go b/pkg/cmd/attestation/auth/host_test.go index 1d84888c4..5d905bd04 100644 --- a/pkg/cmd/attestation/auth/host_test.go +++ b/pkg/cmd/attestation/auth/host_test.go @@ -3,6 +3,8 @@ package auth import ( "testing" + ghauth "github.com/cli/go-gh/v2/pkg/auth" + "github.com/stretchr/testify/require" ) @@ -38,7 +40,8 @@ func TestIsHostSupported(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Setenv("GH_HOST", tc.host) - err := IsHostSupported() + host, _ := ghauth.DefaultHost() + err := IsHostSupported(host) if tc.expectedErr { require.ErrorIs(t, err, ErrUnsupportedHost) } else { diff --git a/pkg/cmd/attestation/download/download.go b/pkg/cmd/attestation/download/download.go index 3af3b6200..770fa442b 100644 --- a/pkg/cmd/attestation/download/download.go +++ b/pkg/cmd/attestation/download/download.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmdutil" + ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" @@ -78,16 +79,18 @@ func NewDownloadCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Comman if err != nil { return err } - opts.APIClient = api.NewLiveClient(hc, opts.Logger) - opts.OCIClient = oci.NewLiveClient() - - opts.Store = NewLiveStore("") - - if err := auth.IsHostSupported(); err != nil { + if opts.Hostname == "" { + opts.Hostname, _ = ghauth.DefaultHost() + } + if err := auth.IsHostSupported(opts.Hostname); err != nil { return err } + opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger) + opts.OCIClient = oci.NewLiveClient() + opts.Store = NewLiveStore("") + if runF != nil { return runF(opts) } @@ -106,6 +109,7 @@ func NewDownloadCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Comman downloadCmd.Flags().StringVarP(&opts.PredicateType, "predicate-type", "", "", "Filter attestations by provided predicate type") cmdutil.StringEnumFlag(downloadCmd, &opts.DigestAlgorithm, "digest-alg", "d", "sha256", []string{"sha256", "sha512"}, "The algorithm used to compute a digest of the artifact") downloadCmd.Flags().IntVarP(&opts.Limit, "limit", "L", api.DefaultLimit, "Maximum number of attestations to fetch") + downloadCmd.Flags().StringVarP(&opts.Hostname, "hostname", "", "", "Configure host to use") return downloadCmd } diff --git a/pkg/cmd/attestation/download/options.go b/pkg/cmd/attestation/download/options.go index cc1c4d3b5..91f724853 100644 --- a/pkg/cmd/attestation/download/options.go +++ b/pkg/cmd/attestation/download/options.go @@ -24,6 +24,7 @@ type Options struct { Owner string PredicateType string Repo string + Hostname string } func (opts *Options) AreFlagsValid() error { diff --git a/pkg/cmd/attestation/inspect/bundle.go b/pkg/cmd/attestation/inspect/bundle.go index 283b2c14e..d6dc5a4bb 100644 --- a/pkg/cmd/attestation/inspect/bundle.go +++ b/pkg/cmd/attestation/inspect/bundle.go @@ -55,17 +55,27 @@ type AttestationDetail struct { WorkflowID string `json:"workflowId"` } -func getOrgAndRepo(repoURL string) (string, string, error) { - after, found := strings.CutPrefix(repoURL, "https://github.com/") - if !found { - return "", "", fmt.Errorf("failed to get org and repo from %s", repoURL) +func getOrgAndRepo(tenant, repoURL string) (string, string, error) { + var after string + var found bool + if tenant == "" { + after, found = strings.CutPrefix(repoURL, "https://github.com/") + if !found { + return "", "", fmt.Errorf("failed to get org and repo from %s", repoURL) + } + } else { + after, found = strings.CutPrefix(repoURL, + fmt.Sprintf("https://%s.ghe.com/", tenant)) + if !found { + return "", "", fmt.Errorf("failed to get org and repo from %s", repoURL) + } } parts := strings.Split(after, "/") return parts[0], parts[1], nil } -func getAttestationDetail(attr api.Attestation) (AttestationDetail, error) { +func getAttestationDetail(tenant string, attr api.Attestation) (AttestationDetail, error) { envelope, err := attr.Bundle.Envelope() if err != nil { return AttestationDetail{}, fmt.Errorf("failed to get envelope from bundle: %v", err) @@ -87,7 +97,7 @@ func getAttestationDetail(attr api.Attestation) (AttestationDetail, error) { return AttestationDetail{}, fmt.Errorf("failed to unmarshal predicate: %v", err) } - org, repo, err := getOrgAndRepo(predicate.BuildDefinition.ExternalParameters.Workflow.Repository) + org, repo, err := getOrgAndRepo(tenant, predicate.BuildDefinition.ExternalParameters.Workflow.Repository) if err != nil { return AttestationDetail{}, fmt.Errorf("failed to parse attestation content: %v", err) } @@ -101,11 +111,11 @@ func getAttestationDetail(attr api.Attestation) (AttestationDetail, error) { }, nil } -func getDetailsAsSlice(results []*verification.AttestationProcessingResult) ([][]string, error) { +func getDetailsAsSlice(tenant string, results []*verification.AttestationProcessingResult) ([][]string, error) { details := make([][]string, len(results)) for i, result := range results { - detail, err := getAttestationDetail(*result.Attestation) + detail, err := getAttestationDetail(tenant, *result.Attestation) if err != nil { return nil, fmt.Errorf("failed to get attestation detail: %v", err) } @@ -114,11 +124,11 @@ func getDetailsAsSlice(results []*verification.AttestationProcessingResult) ([][ return details, nil } -func getAttestationDetails(results []*verification.AttestationProcessingResult) ([]AttestationDetail, error) { +func getAttestationDetails(tenant string, results []*verification.AttestationProcessingResult) ([]AttestationDetail, error) { details := make([]AttestationDetail, len(results)) for i, result := range results { - detail, err := getAttestationDetail(*result.Attestation) + detail, err := getAttestationDetail(tenant, *result.Attestation) if err != nil { return nil, fmt.Errorf("failed to get attestation detail: %v", err) } diff --git a/pkg/cmd/attestation/inspect/bundle_test.go b/pkg/cmd/attestation/inspect/bundle_test.go index d7e9babd8..61b8d7bfc 100644 --- a/pkg/cmd/attestation/inspect/bundle_test.go +++ b/pkg/cmd/attestation/inspect/bundle_test.go @@ -12,7 +12,7 @@ import ( func TestGetOrgAndRepo(t *testing.T) { t.Run("with valid source URL", func(t *testing.T) { sourceURL := "https://github.com/github/gh-attestation" - org, repo, err := getOrgAndRepo(sourceURL) + org, repo, err := getOrgAndRepo("", sourceURL) require.Nil(t, err) require.Equal(t, "github", org) require.Equal(t, "gh-attestation", repo) @@ -20,11 +20,19 @@ func TestGetOrgAndRepo(t *testing.T) { t.Run("with invalid source URL", func(t *testing.T) { sourceURL := "hub.com/github/gh-attestation" - org, repo, err := getOrgAndRepo(sourceURL) + org, repo, err := getOrgAndRepo("", sourceURL) require.Error(t, err) require.Zero(t, org) require.Zero(t, repo) }) + + t.Run("with valid source tenant URL", func(t *testing.T) { + sourceURL := "https://foo.ghe.com/github/gh-attestation" + org, repo, err := getOrgAndRepo("foo", sourceURL) + require.Nil(t, err) + require.Equal(t, "github", org) + require.Equal(t, "gh-attestation", repo) + }) } func TestGetAttestationDetail(t *testing.T) { @@ -35,7 +43,7 @@ func TestGetAttestationDetail(t *testing.T) { require.NoError(t, err) attestation := attestations[0] - detail, err := getAttestationDetail(*attestation) + detail, err := getAttestationDetail("", *attestation) require.NoError(t, err) require.Equal(t, "sigstore", detail.OrgName) diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go index 2731ee7a4..867ff8c1b 100644 --- a/pkg/cmd/attestation/inspect/inspect.go +++ b/pkg/cmd/attestation/inspect/inspect.go @@ -3,13 +3,16 @@ package inspect import ( "fmt" + "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/tableprinter" + "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/artifact/oci" "github.com/cli/cli/v2/pkg/cmd/attestation/auth" "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmdutil" + ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" @@ -66,8 +69,11 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command }, RunE: func(cmd *cobra.Command, args []string) error { opts.OCIClient = oci.NewLiveClient() + if opts.Hostname == "" { + opts.Hostname, _ = ghauth.DefaultHost() + } - if err := auth.IsHostSupported(); err != nil { + if err := auth.IsHostSupported(opts.Hostname); err != nil { return err } @@ -78,6 +84,26 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command config := verification.SigstoreConfig{ Logger: opts.Logger, } + // Prepare for tenancy if detected + if ghinstance.IsTenancy(opts.Hostname) { + hc, err := f.HttpClient() + if err != nil { + return err + } + apiClient := api.NewLiveClient(hc, opts.Hostname, opts.Logger) + td, err := apiClient.GetTrustDomain() + if err != nil { + return err + } + tenant, found := ghinstance.TenantName(opts.Hostname) + if !found { + return fmt.Errorf("Invalid hostname provided: '%s'", + opts.Hostname) + } + + config.TrustDomain = td + opts.Tenant = tenant + } opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config) @@ -90,6 +116,7 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command inspectCmd.Flags().StringVarP(&opts.BundlePath, "bundle", "b", "", "Path to bundle on disk, either a single bundle in a JSON file or a JSON lines file with multiple bundles") inspectCmd.MarkFlagRequired("bundle") //nolint:errcheck + inspectCmd.Flags().StringVarP(&opts.Hostname, "hostname", "", "", "Configure host to use") cmdutil.StringEnumFlag(inspectCmd, &opts.DigestAlgorithm, "digest-alg", "d", "sha256", []string{"sha256", "sha512"}, "The algorithm used to compute a digest of the artifact") cmdutil.AddFormatFlags(inspectCmd, &opts.exporter) @@ -125,7 +152,7 @@ func runInspect(opts *Options) error { // If the user provides the --format=json flag, print the results in JSON format if opts.exporter != nil { - details, err := getAttestationDetails(res.VerifyResults) + details, err := getAttestationDetails(opts.Tenant, res.VerifyResults) if err != nil { return fmt.Errorf("failed to get attestation detail: %v", err) } @@ -138,7 +165,7 @@ func runInspect(opts *Options) error { } // otherwise, print results in a table - details, err := getDetailsAsSlice(res.VerifyResults) + details, err := getDetailsAsSlice(opts.Tenant, res.VerifyResults) if err != nil { return fmt.Errorf("failed to parse attestation details: %v", err) } diff --git a/pkg/cmd/attestation/inspect/options.go b/pkg/cmd/attestation/inspect/options.go index b9c8819c4..1a5a1b937 100644 --- a/pkg/cmd/attestation/inspect/options.go +++ b/pkg/cmd/attestation/inspect/options.go @@ -18,6 +18,8 @@ type Options struct { OCIClient oci.Client SigstoreVerifier verification.SigstoreVerifier exporter cmdutil.Exporter + Hostname string + Tenant string } // Clean cleans the file path option values diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go index e28ceab62..c9c3fdb04 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot.go @@ -6,9 +6,13 @@ import ( "fmt" "os" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/auth" + "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmdutil" + ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/MakeNowJust/heredoc" "github.com/sigstore/sigstore-go/pkg/tuf" @@ -19,6 +23,8 @@ type Options struct { TufUrl string TufRootPath string VerifyOnly bool + Hostname string + TrustDomain string } type tufClientInstantiator func(o *tuf.Options) (*tuf.Client, error) @@ -54,10 +60,28 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com gh attestation trusted-root `), RunE: func(cmd *cobra.Command, args []string) error { - if err := auth.IsHostSupported(); err != nil { + if opts.Hostname == "" { + opts.Hostname, _ = ghauth.DefaultHost() + } + + if err := auth.IsHostSupported(opts.Hostname); err != nil { return err } + if ghinstance.IsTenancy(opts.Hostname) { + hc, err := f.HttpClient() + if err != nil { + return err + } + logger := io.NewHandler(f.IOStreams) + apiClient := api.NewLiveClient(hc, opts.Hostname, logger) + td, err := apiClient.GetTrustDomain() + if err != nil { + return err + } + opts.TrustDomain = td + } + if runF != nil { return runF(opts) } @@ -74,12 +98,19 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com trustedRootCmd.Flags().StringVarP(&opts.TufRootPath, "tuf-root", "", "", "Path to the TUF root.json file on disk") trustedRootCmd.MarkFlagsRequiredTogether("tuf-url", "tuf-root") trustedRootCmd.Flags().BoolVarP(&opts.VerifyOnly, "verify-only", "", false, "Don't output trusted_root.jsonl contents") + trustedRootCmd.Flags().StringVarP(&opts.Hostname, "hostname", "", "", "Configure host to use") return &trustedRootCmd } +type tufConfig struct { + tufOptions *tuf.Options + targets []string +} + func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { - var tufOptions []*tuf.Options + var tufOptions []tufConfig + var defaultTR = "trusted_root.json" tufOpt := verification.DefaultOptionsWithCacheSetting() // Disable local caching, so we get up-to-date response from TUF repository @@ -93,37 +124,54 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { tufOpt.Root = tufRoot tufOpt.RepositoryBaseURL = opts.TufUrl - tufOptions = append(tufOptions, tufOpt) + tufOptions = append(tufOptions, tufConfig{ + tufOptions: tufOpt, + targets: []string{defaultTR}, + }) } else { // Get from both Sigstore public good and GitHub private instance - tufOptions = append(tufOptions, tufOpt) + tufOptions = append(tufOptions, tufConfig{ + tufOptions: tufOpt, + targets: []string{defaultTR}, + }) tufOpt = verification.GitHubTUFOptions() tufOpt.CacheValidity = 0 - tufOptions = append(tufOptions, tufOpt) + targets := []string{defaultTR} + if opts.TrustDomain != "" { + targets = append(targets, fmt.Sprintf("%s.%s", + opts.TrustDomain, defaultTR)) + } + tufOptions = append(tufOptions, tufConfig{ + tufOptions: tufOpt, + targets: targets, + }) } - for _, tufOpt = range tufOptions { - tufClient, err := makeTUF(tufOpt) + for _, tufOpt := range tufOptions { + tufClient, err := makeTUF(tufOpt.tufOptions) if err != nil { return fmt.Errorf("failed to create TUF client: %v", err) } - t, err := tufClient.GetTarget("trusted_root.json") - if err != nil { - return err - } + for _, target := range tufOpt.targets { + t, err := tufClient.GetTarget(target) + if err != nil { + return fmt.Errorf("failed to retrieve trusted root %s via TUF: %w", + target, err) + } - output := new(bytes.Buffer) - err = json.Compact(output, t) - if err != nil { - return err - } + output := new(bytes.Buffer) + err = json.Compact(output, t) + if err != nil { + return err + } - if !opts.VerifyOnly { - fmt.Println(output) - } else { - fmt.Printf("Local TUF repository for %s updated and verified\n", tufOpt.RepositoryBaseURL) + if !opts.VerifyOnly { + fmt.Println(output) + } else { + fmt.Printf("Local TUF repository for %s updated and verified\n", tufOpt.tufOptions.RepositoryBaseURL) + } } } diff --git a/pkg/cmd/attestation/verification/extensions.go b/pkg/cmd/attestation/verification/extensions.go index 1915152dc..727feb72c 100644 --- a/pkg/cmd/attestation/verification/extensions.go +++ b/pkg/cmd/attestation/verification/extensions.go @@ -1,28 +1,50 @@ package verification import ( + "errors" "fmt" "strings" ) -func VerifyCertExtensions(results []*AttestationProcessingResult, owner string, repo string) error { - for _, attestation := range results { - // TODO: handle proxima prefix - expectedSourceRepositoryOwnerURI := fmt.Sprintf("https://github.com/%s", owner) - sourceRepositoryOwnerURI := attestation.VerificationResult.Signature.Certificate.Extensions.SourceRepositoryOwnerURI - if !strings.EqualFold(expectedSourceRepositoryOwnerURI, sourceRepositoryOwnerURI) { - return fmt.Errorf("expected SourceRepositoryOwnerURI to be %s, got %s", expectedSourceRepositoryOwnerURI, sourceRepositoryOwnerURI) - } +func VerifyCertExtensions(results []*AttestationProcessingResult, tenant, owner, repo string) error { + if len(results) == 0 { + return errors.New("no attestations proccessing results") + } - // if repo is set, check the SourceRepositoryURI field - if repo != "" { - // TODO: handle proxima prefix - expectedSourceRepositoryURI := fmt.Sprintf("https://github.com/%s", repo) - sourceRepositoryURI := attestation.VerificationResult.Signature.Certificate.Extensions.SourceRepositoryURI - if !strings.EqualFold(expectedSourceRepositoryURI, sourceRepositoryURI) { - return fmt.Errorf("expected SourceRepositoryURI to be %s, got %s", expectedSourceRepositoryURI, sourceRepositoryURI) - } + for _, attestation := range results { + if err := verifyCertExtensions(attestation, tenant, owner, repo); err != nil { + return err } } return nil } + +func verifyCertExtensions(attestation *AttestationProcessingResult, tenant, owner, repo string) error { + var want string + + if tenant == "" { + want = fmt.Sprintf("https://github.com/%s", owner) + } else { + want = fmt.Sprintf("https://%s.ghe.com/%s", tenant, owner) + } + sourceRepositoryOwnerURI := attestation.VerificationResult.Signature.Certificate.Extensions.SourceRepositoryOwnerURI + if !strings.EqualFold(want, sourceRepositoryOwnerURI) { + return fmt.Errorf("expected SourceRepositoryOwnerURI to be %s, got %s", want, sourceRepositoryOwnerURI) + } + + // if repo is set, check the SourceRepositoryURI field + if repo != "" { + if tenant == "" { + want = fmt.Sprintf("https://github.com/%s", repo) + } else { + want = fmt.Sprintf("https://%s.ghe.com/%s", tenant, repo) + } + + sourceRepositoryURI := attestation.VerificationResult.Signature.Certificate.Extensions.SourceRepositoryURI + if !strings.EqualFold(want, sourceRepositoryURI) { + return fmt.Errorf("expected SourceRepositoryURI to be %s, got %s", want, sourceRepositoryURI) + } + } + + return nil +} diff --git a/pkg/cmd/attestation/verification/extensions_test.go b/pkg/cmd/attestation/verification/extensions_test.go index 7aec4ec45..c86f2030a 100644 --- a/pkg/cmd/attestation/verification/extensions_test.go +++ b/pkg/cmd/attestation/verification/extensions_test.go @@ -25,22 +25,74 @@ func TestVerifyCertExtensions(t *testing.T) { } t.Run("VerifyCertExtensions with owner and repo", func(t *testing.T) { - err := VerifyCertExtensions(results, "owner", "owner/repo") + err := VerifyCertExtensions(results, "", "owner", "owner/repo") require.NoError(t, err) }) + t.Run("VerifyCertExtensions with owner and repo, but wrong tenant", func(t *testing.T) { + err := VerifyCertExtensions(results, "foo", "owner", "owner/repo") + require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://foo.ghe.com/owner, got https://github.com/owner") + }) + t.Run("VerifyCertExtensions with owner", func(t *testing.T) { - err := VerifyCertExtensions(results, "owner", "") + err := VerifyCertExtensions(results, "", "owner", "") require.NoError(t, err) }) t.Run("VerifyCertExtensions with wrong owner", func(t *testing.T) { - err := VerifyCertExtensions(results, "wrong", "") + err := VerifyCertExtensions(results, "", "wrong", "") require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://github.com/wrong, got https://github.com/owner") }) t.Run("VerifyCertExtensions with wrong repo", func(t *testing.T) { - err := VerifyCertExtensions(results, "owner", "wrong") + err := VerifyCertExtensions(results, "", "owner", "wrong") require.ErrorContains(t, err, "expected SourceRepositoryURI to be https://github.com/wrong, got https://github.com/owner/repo") }) } + +func TestVerifyTenancyCertExtensions(t *testing.T) { + results := []*AttestationProcessingResult{ + { + VerificationResult: &verify.VerificationResult{ + Signature: &verify.SignatureVerificationResult{ + Certificate: &certificate.Summary{ + Extensions: certificate.Extensions{ + SourceRepositoryOwnerURI: "https://foo.ghe.com/owner", + SourceRepositoryURI: "https://foo.ghe.com/owner/repo", + }, + }, + }, + }, + }, + } + + t.Run("VerifyCertExtensions with owner and repo", func(t *testing.T) { + err := VerifyCertExtensions(results, "foo", "owner", "owner/repo") + require.NoError(t, err) + }) + + t.Run("VerifyCertExtensions with owner and repo, no tenant", func(t *testing.T) { + err := VerifyCertExtensions(results, "", "owner", "owner/repo") + require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://github.com/owner, got https://foo.ghe.com/owner") + }) + + t.Run("VerifyCertExtensions with owner and repo, wrong tenant", func(t *testing.T) { + err := VerifyCertExtensions(results, "bar", "owner", "owner/repo") + require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://bar.ghe.com/owner, got https://foo.ghe.com/owner") + }) + + t.Run("VerifyCertExtensions with owner", func(t *testing.T) { + err := VerifyCertExtensions(results, "foo", "owner", "") + require.NoError(t, err) + }) + + t.Run("VerifyCertExtensions with wrong owner", func(t *testing.T) { + err := VerifyCertExtensions(results, "foo", "wrong", "") + require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://foo.ghe.com/wrong, got https://foo.ghe.com/owner") + }) + + t.Run("VerifyCertExtensions with wrong repo", func(t *testing.T) { + err := VerifyCertExtensions(results, "foo", "owner", "wrong") + require.ErrorContains(t, err, "expected SourceRepositoryURI to be https://foo.ghe.com/wrong, got https://foo.ghe.com/owner/repo") + }) +} diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 9a4ac5194..927eacf49 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -36,6 +36,8 @@ type SigstoreConfig struct { TrustedRoot string Logger *io.Handler NoPublicGood bool + // If tenancy mode is not used, trust domain is empty + TrustDomain string } type SigstoreVerifier interface { @@ -144,7 +146,7 @@ func (v *LiveSigstoreVerifier) chooseVerifier(b *bundle.Bundle) (*verify.SignedE return publicGoodVerifier, issuer, nil } else if leafCert.Issuer.Organization[0] == GitHubIssuerOrg || v.config.NoPublicGood { - ghVerifier, err := newGitHubVerifier() + ghVerifier, err := newGitHubVerifier(v.config.TrustDomain) if err != nil { return nil, "", fmt.Errorf("failed to create GitHub Sigstore verifier: %v", err) } @@ -240,13 +242,25 @@ func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerif return gv, nil } -func newGitHubVerifier() (*verify.SignedEntityVerifier, error) { +func newGitHubVerifier(trustDomain string) (*verify.SignedEntityVerifier, error) { + var tr string + opts := GitHubTUFOptions() client, err := tuf.New(opts) if err != nil { return nil, fmt.Errorf("failed to create TUF client: %v", err) } - trustedRoot, err := root.GetTrustedRoot(client) + + if trustDomain == "" { + tr = "trusted_root.json" + } else { + tr = fmt.Sprintf("%s.trusted_root.json", trustDomain) + } + jsonBytes, err := client.GetTarget(tr) + if err != nil { + return nil, err + } + trustedRoot, err := root.NewTrustedRootFromJSON(jsonBytes) if err != nil { return nil, err } diff --git a/pkg/cmd/attestation/verify/options.go b/pkg/cmd/attestation/verify/options.go index 3ec2d49f1..126159023 100644 --- a/pkg/cmd/attestation/verify/options.go +++ b/pkg/cmd/attestation/verify/options.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" "github.com/cli/cli/v2/pkg/cmd/attestation/io" @@ -37,6 +38,9 @@ type Options struct { OCIClient oci.Client SigstoreVerifier verification.SigstoreVerifier exporter cmdutil.Exporter + Hostname string + // Tenant is only set when tenancy is used + Tenant string } // Clean cleans the file path option values @@ -57,12 +61,12 @@ func (opts *Options) SetPolicyFlags() { opts.Owner = splitRepo[0] if !isSignerIdentityProvided(opts) { - opts.SANRegex = expandToGitHubURL(opts.Repo) + opts.SANRegex = expandToGitHubURL(opts.Tenant, opts.Repo) } return } if !isSignerIdentityProvided(opts) { - opts.SANRegex = expandToGitHubURL(opts.Owner) + opts.SANRegex = expandToGitHubURL(opts.Tenant, opts.Owner) } } @@ -94,6 +98,13 @@ func (opts *Options) AreFlagsValid() error { return fmt.Errorf("bundle-from-oci flag cannot be used with bundle-path flag") } + // Verify provided hostname + if opts.Hostname != "" { + if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { + return fmt.Errorf("error parsing hostname: %w", err) + } + } + return nil } diff --git a/pkg/cmd/attestation/verify/policy.go b/pkg/cmd/attestation/verify/policy.go index 4d850ddcc..0c7a686c2 100644 --- a/pkg/cmd/attestation/verify/policy.go +++ b/pkg/cmd/attestation/verify/policy.go @@ -1,8 +1,8 @@ package verify import ( + "errors" "fmt" - "os" "regexp" "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" @@ -13,21 +13,23 @@ import ( ) const ( - GitHubOIDCIssuer = "https://token.actions.githubusercontent.com" + GitHubOIDCIssuer = "https://token.actions.githubusercontent.com" + GitHubTenantOIDCIssuer = "https://token.actions.%s.ghe.com" // represents the GitHub hosted runner in the certificate RunnerEnvironment extension GitHubRunner = "github-hosted" - githubHost = "github.com" hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` ) -func expandToGitHubURL(ownerOrRepo string) string { - // TODO: handle proxima prefix - return fmt.Sprintf("(?i)^https://github.com/%s/", ownerOrRepo) +func expandToGitHubURL(tenant, ownerOrRepo string) string { + if tenant == "" { + return fmt.Sprintf("(?i)^https://github.com/%s/", ownerOrRepo) + } + return fmt.Sprintf("(?i)^https://%s.ghe.com/%s/", tenant, ownerOrRepo) } func buildSANMatcher(opts *Options) (verify.SubjectAlternativeNameMatcher, error) { if opts.SignerRepo != "" { - signedRepoRegex := expandToGitHubURL(opts.SignerRepo) + signedRepoRegex := expandToGitHubURL(opts.Tenant, opts.SignerRepo) return verify.NewSANMatcher("", signedRepoRegex) } else if opts.SignerWorkflow != "" { validatedWorkflowRegex, err := validateSignerWorkflow(opts) @@ -118,37 +120,9 @@ func validateSignerWorkflow(opts *Options) (string, error) { return addSchemeToRegex(opts.SignerWorkflow), nil } - // if the provided workflow does not contain a host, check for a host - // and prepend it to the workflow - host, err := chooseHost(opts) - if err != nil { - return "", err + if opts.Hostname == "" { + return "", errors.New("unknown host") } - return addSchemeToRegex(fmt.Sprintf("%s/%s", host, opts.SignerWorkflow)), nil -} - -// if a host was not provided as part of a flag argument choose a host based -// on gh cli configuration -func chooseHost(opts *Options) (string, error) { - // check if GH_HOST is set and use that host if it is - host := os.Getenv("GH_HOST") - if host != "" { - return host, nil - } - - // check if the CLI is authenticated with any hosts - cfg, err := opts.Config() - if err != nil { - return "", err - } - - // if authenticated, return the authenticated host - authCfg := cfg.Authentication() - if host, _ := authCfg.DefaultHost(); host != "" { - return host, nil - } - - // if not authenticated, return the default host github.com - return githubHost, nil + return addSchemeToRegex(fmt.Sprintf("%s/%s", opts.Hostname, opts.SignerWorkflow)), nil } diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go index 40435d19e..89f989b45 100644 --- a/pkg/cmd/attestation/verify/policy_test.go +++ b/pkg/cmd/attestation/verify/policy_test.go @@ -1,7 +1,6 @@ package verify import ( - "os" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" @@ -32,13 +31,12 @@ func TestBuildPolicy(t *testing.T) { require.NoError(t, err) } -func ValidateSignerWorkflow(t *testing.T) { +func TestValidateSignerWorkflow(t *testing.T) { type testcase struct { name string providedSignerWorkflow string expectedWorkflowRegex string - ghHost string - authHost string + host string } testcases := []testcase{ @@ -56,13 +54,19 @@ func ValidateSignerWorkflow(t *testing.T) { name: "workflow with GH_HOST set", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", expectedWorkflowRegex: "^https://myhost.github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", - ghHost: "myhost.github.com", + host: "myhost.github.com", }, { name: "workflow with authenticated host", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", expectedWorkflowRegex: "^https://authedhost.github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", - authHost: "authedhost.github.com", + host: "authedhost.github.com", + }, + { + name: "workflow with authenticated host", + providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", + expectedWorkflowRegex: "^https://authedhost.github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", + host: "authedhost.github.com", }, } @@ -74,22 +78,29 @@ func ValidateSignerWorkflow(t *testing.T) { SignerWorkflow: tc.providedSignerWorkflow, } - if tc.ghHost != "" { - err := os.Setenv("GH_HOST", tc.ghHost) - require.NoError(t, err) - } - - if tc.authHost != "" { - cfg, err := opts.Config() - require.NoError(t, err) - - // if authenticated, return the authenticated host - authCfg := cfg.Authentication() - authCfg.SetDefaultHost(tc.authHost, "") + // All host resolution is done verify.go:RunE + if tc.host == "" { + // Set to default host + tc.host = "github.com" } + opts.Hostname = tc.host workflowRegex, err := validateSignerWorkflow(opts) require.NoError(t, err) require.Equal(t, tc.expectedWorkflowRegex, workflowRegex) + } } + +func TestValidateSignerWorkflowNoHost(t *testing.T) { + cmdFactory := factory.New("test") + opts := &Options{ + Config: cmdFactory.Config, + SignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", + } + + workflowRegex, err := validateSignerWorkflow(opts) + require.Error(t, err) + require.ErrorContains(t, err, "unknown host") + require.Equal(t, "", workflowRegex) +} diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index 055636ad5..9e508e994 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" + "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" @@ -13,6 +14,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmdutil" + ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" @@ -109,9 +111,6 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command // Clean file path options opts.Clean() - // set policy flags based on what has been provided - opts.SetPolicyFlags() - return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -119,17 +118,18 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command if err != nil { return err } - opts.APIClient = api.NewLiveClient(hc, opts.Logger) opts.OCIClient = oci.NewLiveClient() - if err := auth.IsHostSupported(); err != nil { + if opts.Hostname == "" { + opts.Hostname, _ = ghauth.DefaultHost() + } + err = auth.IsHostSupported(opts.Hostname) + if err != nil { return err } - if runF != nil { - return runF(opts) - } + opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger) config := verification.SigstoreConfig{ TrustedRoot: opts.TrustedRoot, @@ -137,6 +137,30 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command NoPublicGood: opts.NoPublicGood, } + // Prepare for tenancy if detected + if ghinstance.IsTenancy(opts.Hostname) { + td, err := opts.APIClient.GetTrustDomain() + if err != nil { + return fmt.Errorf("error getting trust domain, make sure you are authenticated against the host: %w", err) + } + + tenant, found := ghinstance.TenantName(opts.Hostname) + if !found { + return fmt.Errorf("invalid hostname provided: '%s'", + opts.Hostname) + } + config.TrustDomain = td + opts.Tenant = tenant + opts.OIDCIssuer = fmt.Sprintf(GitHubTenantOIDCIssuer, tenant) + } + + // set policy flags based on what has been provided + opts.SetPolicyFlags() + + if runF != nil { + return runF(opts) + } + opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config) opts.Config = f.Config @@ -169,6 +193,7 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command verifyCmd.Flags().StringVarP(&opts.SignerWorkflow, "signer-workflow", "", "", "Workflow that signed attestation in the format [host/]////") verifyCmd.MarkFlagsMutuallyExclusive("cert-identity", "cert-identity-regex", "signer-repo", "signer-workflow") verifyCmd.Flags().StringVarP(&opts.OIDCIssuer, "cert-oidc-issuer", "", GitHubOIDCIssuer, "Issuer of the OIDC token") + verifyCmd.Flags().StringVarP(&opts.Hostname, "hostname", "", "", "Configure host to use") return verifyCmd } @@ -244,7 +269,7 @@ func runVerify(opts *Options) error { } // Verify extensions - if err := verification.VerifyCertExtensions(sigstoreRes.VerifyResults, opts.Owner, opts.Repo); err != nil { + if err := verification.VerifyCertExtensions(sigstoreRes.VerifyResults, opts.Tenant, opts.Owner, opts.Repo); err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Verification failed")) return err } @@ -264,7 +289,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(sigstoreRes.VerifyResults) + tableContent, err := buildTableVerifyContent(opts.Tenant, sigstoreRes.VerifyResults) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("failed to parse results")) return err @@ -280,30 +305,44 @@ func runVerify(opts *Options) error { return nil } -func extractAttestationDetail(builderSignerURI string) (string, string, error) { +func extractAttestationDetail(tenant, builderSignerURI string) (string, string, error) { // If given a build signer URI like // https://github.com/foo/bar/.github/workflows/release.yml@refs/heads/main // We want to extract: // * foo/bar // * .github/workflows/release.yml@refs/heads/main - orgAndRepoRegexp := regexp.MustCompile(`https://github\.com/([^/]+/[^/]+)/`) + var orgAndRepoRegexp *regexp.Regexp + var workflowRegexp *regexp.Regexp + + if tenant == "" { + orgAndRepoRegexp = regexp.MustCompile(`https://github\.com/([^/]+/[^/]+)/`) + workflowRegexp = regexp.MustCompile(`https://github\.com/[^/]+/[^/]+/(.+)`) + } else { + var tr = regexp.QuoteMeta(tenant) + orgAndRepoRegexp = regexp.MustCompile(fmt.Sprintf( + `https://%s\.ghe\.com/([^/]+/[^/]+)/`, + tr)) + workflowRegexp = regexp.MustCompile(fmt.Sprintf( + `https://%s\.ghe\.com/[^/]+/[^/]+/(.+)`, + tr)) + } + match := orgAndRepoRegexp.FindStringSubmatch(builderSignerURI) if len(match) < 2 { return "", "", fmt.Errorf("no match found for org and repo") } - repoAndOrg := match[1] + orgAndRepo := match[1] - workflowRegexp := regexp.MustCompile(`https://github\.com/[^/]+/[^/]+/(.+)`) match = workflowRegexp.FindStringSubmatch(builderSignerURI) if len(match) < 2 { return "", "", fmt.Errorf("no match found for workflow") } workflow := match[1] - return repoAndOrg, workflow, nil + return orgAndRepo, workflow, nil } -func buildTableVerifyContent(results []*verification.AttestationProcessingResult) ([][]string, error) { +func buildTableVerifyContent(tenant string, results []*verification.AttestationProcessingResult) ([][]string, error) { content := make([][]string, len(results)) for i, res := range results { @@ -313,7 +352,7 @@ func buildTableVerifyContent(results []*verification.AttestationProcessingResult return nil, fmt.Errorf("bundle missing verification result fields") } builderSignerURI := res.VerificationResult.Signature.Certificate.Extensions.BuildSignerURI - repoAndOrg, workflow, err := extractAttestationDetail(builderSignerURI) + repoAndOrg, workflow, err := extractAttestationDetail(tenant, builderSignerURI) if err != nil { return nil, err } diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index 9ad7a87a3..611b1d89f 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmd/factory" + "github.com/cli/go-gh/v2/pkg/auth" "github.com/stretchr/testify/require" ) @@ -28,8 +29,10 @@ func TestVerifyIntegration(t *testing.T) { t.Fatal(err) } + host, _ := auth.DefaultHost() + publicGoodOpts := Options{ - APIClient: api.NewLiveClient(hc, logger), + APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: artifactPath, BundlePath: bundlePath, DigestAlgorithm: "sha512", @@ -99,8 +102,10 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { t.Fatal(err) } + host, _ := auth.DefaultHost() + baseOpts := Options{ - APIClient: api.NewLiveClient(hc, logger), + APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: artifactPath, BundlePath: bundlePath, DigestAlgorithm: "sha256", @@ -185,8 +190,10 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { t.Fatal(err) } + host, _ := auth.DefaultHost() + baseOpts := Options{ - APIClient: api.NewLiveClient(hc, logger), + APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: artifactPath, BundlePath: bundlePath, Config: cmdFactory.Config, @@ -203,6 +210,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { name string signerWorkflow string expectErr bool + host string } testcases := []testcase{ @@ -220,12 +228,14 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { name: "valid signer workflow without host (defaults to github.com)", signerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", expectErr: false, + host: "github.com", }, } for _, tc := range testcases { opts := baseOpts opts.SignerWorkflow = tc.signerWorkflow + opts.Hostname = tc.host err := runVerify(&opts) if tc.expectErr { diff --git a/pkg/cmd/attestation/verify/verify_test.go b/pkg/cmd/attestation/verify/verify_test.go index 89c5ae7c1..6f71b537f 100644 --- a/pkg/cmd/attestation/verify/verify_test.go +++ b/pkg/cmd/attestation/verify/verify_test.go @@ -35,10 +35,21 @@ var ( func TestNewVerifyCmd(t *testing.T) { testIO, _, _, _ := iostreams.Test() + var testReg httpmock.Registry + var metaResp = api.MetaResponse{ + Domains: api.Domain{ + ArtifactAttestations: api.ArtifactAttestations{ + TrustDomain: "foo", + }, + }, + } + testReg.Register(httpmock.REST(http.MethodGet, "api/v3/meta"), + httpmock.StatusJSONResponse(200, &metaResp)) + f := &cmdutil.Factory{ IOStreams: testIO, HttpClient: func() (*http.Client, error) { - reg := &httpmock.Registry{} + reg := &testReg client := &http.Client{} httpmock.ReplaceTripper(client, reg) return client, nil @@ -63,6 +74,7 @@ func TestNewVerifyCmd(t *testing.T) { OIDCIssuer: GitHubOIDCIssuer, Owner: "sigstore", SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "github.com", }, wantsErr: true, }, @@ -78,9 +90,42 @@ func TestNewVerifyCmd(t *testing.T) { Owner: "sigstore", SANRegex: "(?i)^https://github.com/sigstore/", SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "github.com", }, wantsErr: false, }, + { + name: "Custom host", + cli: fmt.Sprintf("%s --bundle %s --owner sigstore --hostname foo.ghe.com", artifactPath, bundlePath), + wants: Options{ + ArtifactPath: test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0.tgz"), + BundlePath: test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json"), + DigestAlgorithm: "sha256", + Limit: 30, + OIDCIssuer: "https://token.actions.foo.ghe.com", + Owner: "sigstore", + SANRegex: "(?i)^https://foo.ghe.com/sigstore/", + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "foo.ghe.com", + }, + wantsErr: false, + }, + { + name: "Invalid custom host", + cli: fmt.Sprintf("%s --bundle %s --owner sigstore --hostname foo.bar.com", artifactPath, bundlePath), + wants: Options{ + ArtifactPath: test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0.tgz"), + BundlePath: test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json"), + DigestAlgorithm: "sha256", + Limit: 30, + OIDCIssuer: GitHubOIDCIssuer, + Owner: "sigstore", + SANRegex: "(?i)^https://github.com/sigstore/", + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "foo.ghe.com", + }, + wantsErr: true, + }, { name: "Use custom digest-alg value", cli: fmt.Sprintf("%s --bundle %s --owner sigstore --digest-alg sha512", artifactPath, bundlePath), @@ -93,6 +138,7 @@ func TestNewVerifyCmd(t *testing.T) { Owner: "sigstore", SANRegex: "(?i)^https://github.com/sigstore/", SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "github.com", }, wantsErr: false, }, @@ -107,6 +153,7 @@ func TestNewVerifyCmd(t *testing.T) { Limit: 30, SANRegex: "(?i)^https://github.com/sigstore/", SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "github.com", }, wantsErr: true, }, @@ -121,6 +168,7 @@ func TestNewVerifyCmd(t *testing.T) { Repo: "sigstore/sigstore-js", Limit: 30, SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "github.com", }, wantsErr: true, }, @@ -135,6 +183,7 @@ func TestNewVerifyCmd(t *testing.T) { Owner: "sigstore", SANRegex: "(?i)^https://github.com/sigstore/", SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "github.com", }, wantsErr: false, }, @@ -149,6 +198,7 @@ func TestNewVerifyCmd(t *testing.T) { Limit: 101, SANRegex: "(?i)^https://github.com/sigstore/", SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "github.com", }, wantsErr: false, }, @@ -163,6 +213,7 @@ func TestNewVerifyCmd(t *testing.T) { Limit: 0, SANRegex: "(?i)^https://github.com/sigstore/", SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "github.com", }, wantsErr: true, }, @@ -178,6 +229,7 @@ func TestNewVerifyCmd(t *testing.T) { SAN: "https://github.com/sigstore/", SANRegex: "(?i)^https://github.com/sigstore/", SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "github.com", }, wantsErr: true, }, @@ -193,6 +245,7 @@ func TestNewVerifyCmd(t *testing.T) { Owner: "sigstore", SANRegex: "(?i)^https://github.com/sigstore/", SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + Hostname: "github.com", }, wantsExporter: true, }, @@ -230,6 +283,7 @@ func TestNewVerifyCmd(t *testing.T) { assert.Equal(t, tc.wants.Repo, opts.Repo) assert.Equal(t, tc.wants.SAN, opts.SAN) assert.Equal(t, tc.wants.SANRegex, opts.SANRegex) + assert.Equal(t, tc.wants.Hostname, opts.Hostname) assert.NotNil(t, opts.APIClient) assert.NotNil(t, opts.Logger) assert.NotNil(t, opts.OCIClient) @@ -357,6 +411,17 @@ func TestRunVerify(t *testing.T) { require.Nil(t, runVerify(&opts)) }) + // Test with bad tenancy + t.Run("with bad tenancy", func(t *testing.T) { + opts := publicGoodOpts + opts.BundlePath = "" + opts.Repo = "sigstore/sigstore-js" + opts.Tenant = "foo" + + err := runVerify(&opts) + require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://foo.ghe.com/sigstore, got https://github.com/sigstore") + }) + t.Run("with repo which not matches SourceRepositoryURI", func(t *testing.T) { opts := publicGoodOpts opts.BundlePath = ""