Merge pull request #9542 from kommendorkapten/policy-tenancy
Added tenancy aware attestation commands
This commit is contained in:
commit
aa931c5aa7
24 changed files with 550 additions and 153 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -27,4 +27,7 @@
|
|||
# vim
|
||||
*.swp
|
||||
|
||||
# Emacs
|
||||
*~
|
||||
|
||||
vendor/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
}
|
||||
|
|
|
|||
15
pkg/cmd/attestation/api/trust_domain.go
Normal file
15
pkg/cmd/attestation/api/trust_domain.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type Options struct {
|
|||
Owner string
|
||||
PredicateType string
|
||||
Repo string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
func (opts *Options) AreFlagsValid() error {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/]<owner>/<repo>/<path>/<to>/<workflow>")
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue