From 97c10f4c048baf7b4bff34aeb5ef0c2573e6f653 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Fri, 1 Mar 2024 16:16:20 -0700 Subject: [PATCH] move artifact package over Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/artifact/artifact.go | 74 ++++++++++++ .../artifact/artifact_posix_test.go | 99 ++++++++++++++++ .../artifact/artifact_windows_test.go | 59 ++++++++++ pkg/cmd/attestation/artifact/digest/digest.go | 50 ++++++++ .../artifact/digest/digest_test.go | 38 ++++++ pkg/cmd/attestation/artifact/file.go | 25 ++++ pkg/cmd/attestation/artifact/image.go | 28 +++++ pkg/cmd/attestation/artifact/image_test.go | 41 +++++++ pkg/cmd/attestation/artifact/oci/oci.go | 110 ++++++++++++++++++ pkg/cmd/attestation/verify/options.go | 2 +- 10 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/attestation/artifact/artifact.go create mode 100644 pkg/cmd/attestation/artifact/artifact_posix_test.go create mode 100644 pkg/cmd/attestation/artifact/artifact_windows_test.go create mode 100644 pkg/cmd/attestation/artifact/digest/digest.go create mode 100644 pkg/cmd/attestation/artifact/digest/digest_test.go create mode 100644 pkg/cmd/attestation/artifact/file.go create mode 100644 pkg/cmd/attestation/artifact/image.go create mode 100644 pkg/cmd/attestation/artifact/image_test.go create mode 100644 pkg/cmd/attestation/artifact/oci/oci.go diff --git a/pkg/cmd/attestation/artifact/artifact.go b/pkg/cmd/attestation/artifact/artifact.go new file mode 100644 index 000000000..cb69ac1e3 --- /dev/null +++ b/pkg/cmd/attestation/artifact/artifact.go @@ -0,0 +1,74 @@ +package artifact + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/github/gh-attestation/pkg/artifact/oci" +) + +type artifactType int + +const ( + ociArtifactType artifactType = iota + fileArtifactType +) + +// DigestedArtifact abstracts the software artifact being verified +type DigestedArtifact struct { + URL string + digest string + digestAlg string +} + +func normalizeReference(reference string, pathSeparator rune) (normalized string, artifactType artifactType, err error) { + switch { + case strings.HasPrefix(reference, "oci://"): + return reference[6:], ociArtifactType, nil + case strings.HasPrefix(reference, "file://"): + uri, err := url.ParseRequestURI(reference) + if err != nil { + return "", 0, fmt.Errorf("failed to parse reference URI: %w", err) + } + var path string + if pathSeparator == '/' { + // Unix paths use forward slashes like URIs, so no need to modify + path = uri.Path + } else { + // Windows paths should be normalized to use backslashes + path = strings.ReplaceAll(uri.Path, "/", string(pathSeparator)) + // Remove leading slash from Windows paths if present + if strings.HasPrefix(path, string(pathSeparator)) { + path = path[1:] + } + } + return filepath.Clean(path), fileArtifactType, nil + } + // Treat any other reference as a local file path + return filepath.Clean(reference), fileArtifactType, nil +} + +func NewDigestedArtifact(client oci.Client, reference, digestAlg string) (artifact *DigestedArtifact, err error) { + normalized, artifactType, err := normalizeReference(reference, os.PathSeparator) + if err != nil { + return nil, err + } + if artifactType == ociArtifactType { + // TODO: should we allow custom digestAlg for OCI artifacts? + return digestContainerImageArtifact(normalized, client) + } + return digestLocalFileArtifact(normalized, digestAlg) +} + +// Digest returns the digest of the artifact +func (a *DigestedArtifact) Digest() string { + return a.digest +} + +// DigestWithAlg returns the digest:algorithm of the artifact +func (a *DigestedArtifact) DigestWithAlg() string { + return fmt.Sprintf("%s:%s", a.digestAlg, a.digest) +} diff --git a/pkg/cmd/attestation/artifact/artifact_posix_test.go b/pkg/cmd/attestation/artifact/artifact_posix_test.go new file mode 100644 index 000000000..5a81d40ed --- /dev/null +++ b/pkg/cmd/attestation/artifact/artifact_posix_test.go @@ -0,0 +1,99 @@ +//go:build !windows +// +build !windows + +package artifact + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeReference(t *testing.T) { + testCases := []struct { + name string + reference string + pathSeparator rune + expectedResult string + expectedType artifactType + expectedError bool + }{ + { + name: "file reference without scheme", + reference: "/path/to/file", + pathSeparator: '/', + expectedResult: "/path/to/file", + expectedType: fileArtifactType, + expectedError: false, + }, + { + name: "file scheme uri with %20", + reference: "file:///path/to/file%20with%20spaces", + pathSeparator: '/', + expectedResult: "/path/to/file with spaces", + expectedType: fileArtifactType, + expectedError: false, + }, + { + name: "windows file reference without scheme", + reference: `c:\path\to\file`, + pathSeparator: '\\', + expectedResult: `c:\path\to\file`, + expectedType: fileArtifactType, + expectedError: false, + }, + { + name: "file reference with scheme", + reference: "file:///path/to/file", + pathSeparator: '/', + expectedResult: "/path/to/file", + expectedType: fileArtifactType, + expectedError: false, + }, + { + name: "windows path", + reference: "file:///C:/path/to/file", + pathSeparator: '\\', + expectedResult: `C:\path\to\file`, + expectedType: fileArtifactType, + expectedError: false, + }, + { + name: "windows path with backslashes", + reference: "file:///C:\\path\\to\\file", + pathSeparator: '\\', + expectedResult: `C:\path\to\file`, + expectedType: fileArtifactType, + expectedError: false, + }, + { + name: "oci reference", + reference: "oci://example.com/repo:tag", + pathSeparator: '/', + expectedResult: "example.com/repo:tag", + expectedType: ociArtifactType, + expectedError: false, + }, + { + name: "oci reference with digest", + reference: "oci://example.com/repo@sha256:abcdef1234567890", + pathSeparator: '/', + expectedResult: "example.com/repo@sha256:abcdef1234567890", + expectedType: ociArtifactType, + expectedError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, artifactType, err := normalizeReference(tc.reference, tc.pathSeparator) + if tc.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + assert.Equal(t, tc.expectedType, artifactType) + } + }) + } +} diff --git a/pkg/cmd/attestation/artifact/artifact_windows_test.go b/pkg/cmd/attestation/artifact/artifact_windows_test.go new file mode 100644 index 000000000..1621da8b9 --- /dev/null +++ b/pkg/cmd/attestation/artifact/artifact_windows_test.go @@ -0,0 +1,59 @@ +//go:build windows +// +build windows + +package artifact + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeReference(t *testing.T) { + testCases := []struct { + name string + reference string + pathSeparator rune + expectedResult string + expectedType artifactType + expectedError bool + }{ + { + name: "windows file reference without scheme", + reference: `c:\path\to\file`, + pathSeparator: '\\', + expectedResult: `c:\path\to\file`, + expectedType: fileArtifactType, + expectedError: false, + }, + { + name: "windows path", + reference: "file:///C:/path/to/file", + pathSeparator: '\\', + expectedResult: `C:\path\to\file`, + expectedType: fileArtifactType, + expectedError: false, + }, + { + name: "windows path with backslashes", + reference: "file:///C:\\path\\to\\file", + pathSeparator: '\\', + expectedResult: `C:\path\to\file`, + expectedType: fileArtifactType, + expectedError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, artifactType, err := normalizeReference(tc.reference, tc.pathSeparator) + if tc.expectedError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + assert.Equal(t, tc.expectedType, artifactType) + } + }) + } +} diff --git a/pkg/cmd/attestation/artifact/digest/digest.go b/pkg/cmd/attestation/artifact/digest/digest.go new file mode 100644 index 000000000..f90884c1e --- /dev/null +++ b/pkg/cmd/attestation/artifact/digest/digest.go @@ -0,0 +1,50 @@ +package digest + +import ( + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "fmt" + "hash" + "io" +) + +const ( + SHA256DigestAlgorithm = "sha256" + SHA512DigestAlgorithm = "sha512" +) + +var validDigestAlgorithms = [...]string{SHA256DigestAlgorithm, SHA512DigestAlgorithm} + +// IsValidDigestAlgorithm returns true if the provided algorithm is supported +func IsValidDigestAlgorithm(alg string) bool { + for _, a := range validDigestAlgorithms { + if a == alg { + return true + } + } + return false +} + +// ValidDigestAlgorithms returns a list of supported digest algorithms +func ValidDigestAlgorithms() []string { + return validDigestAlgorithms[:] +} + +func CalculateDigestWithAlgorithm(r io.Reader, alg string) (string, error) { + var h hash.Hash + switch alg { + case SHA256DigestAlgorithm: + h = sha256.New() + case SHA512DigestAlgorithm: + h = sha512.New() + default: + h = sha256.New() + } + + if _, err := io.Copy(h, r); err != nil { + return "", fmt.Errorf("failed to calculate digest: %w", err) + } + digest := h.Sum(nil) + return hex.EncodeToString(digest[:]), nil +} diff --git a/pkg/cmd/attestation/artifact/digest/digest_test.go b/pkg/cmd/attestation/artifact/digest/digest_test.go new file mode 100644 index 000000000..fb611b318 --- /dev/null +++ b/pkg/cmd/attestation/artifact/digest/digest_test.go @@ -0,0 +1,38 @@ +package digest + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestArtifactDigestWithAlgorithm(t *testing.T) { + testString := "deadbeef" + sha512TestDigest := "113a3bc783d851fc0373214b19ea7be9fa3de541ecb9fe026d52c603e8ea19c174cc0e9705f8b90d312212c0c3a6d8453ddfb3e3141409cf4bedc8ef033590b4" + sha256TestDigest := "2baf1f40105d9501fe319a8ec463fdf4325a2a5df445adf3f572f626253678c9" + + t.Run("sha256", func(t *testing.T) { + reader := strings.NewReader(testString) + digest, err := CalculateDigestWithAlgorithm(reader, "sha256") + assert.Nil(t, err) + assert.Equal(t, sha256TestDigest, digest) + }) + + t.Run("sha512", func(t *testing.T) { + reader := strings.NewReader(testString) + digest, err := CalculateDigestWithAlgorithm(reader, "sha512") + assert.Nil(t, err) + assert.Equal(t, sha512TestDigest, digest) + }) +} + +func TestValidDigestAlgorithms(t *testing.T) { + t.Run("includes sha256", func(t *testing.T) { + assert.Contains(t, ValidDigestAlgorithms(), "sha256") + }) + + t.Run("includes sha512", func(t *testing.T) { + assert.Contains(t, ValidDigestAlgorithms(), "sha512") + }) +} diff --git a/pkg/cmd/attestation/artifact/file.go b/pkg/cmd/attestation/artifact/file.go new file mode 100644 index 000000000..9b1632135 --- /dev/null +++ b/pkg/cmd/attestation/artifact/file.go @@ -0,0 +1,25 @@ +package artifact + +import ( + "fmt" + "os" + + "github.com/github/gh-attestation/pkg/artifact/digest" +) + +func digestLocalFileArtifact(filename, digestAlg string) (*DigestedArtifact, error) { + data, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("failed to get open local artifact: %w", err) + } + defer data.Close() + digest, err := digest.CalculateDigestWithAlgorithm(data, digestAlg) + if err != nil { + return nil, fmt.Errorf("failed to calculate local artifact digest: %w", err) + } + return &DigestedArtifact{ + URL: fmt.Sprintf("file://%s", filename), + digest: digest, + digestAlg: digestAlg, + }, nil +} diff --git a/pkg/cmd/attestation/artifact/image.go b/pkg/cmd/attestation/artifact/image.go new file mode 100644 index 000000000..3b848d5be --- /dev/null +++ b/pkg/cmd/attestation/artifact/image.go @@ -0,0 +1,28 @@ +package artifact + +import ( + "fmt" + + "github.com/distribution/reference" + "github.com/github/gh-attestation/pkg/artifact/oci" +) + +func digestContainerImageArtifact(url string, client oci.Client) (*DigestedArtifact, error) { + // try to parse the url as a valid registry reference + named, err := reference.Parse(url) + if err != nil { + // cannot be parsed as a registry reference + return nil, fmt.Errorf("artifact %s is not a valid registry reference", url) + } + + digest, err := client.GetImageDigest(named.String()) + if err != nil { + return nil, err + } + + return &DigestedArtifact{ + URL: fmt.Sprintf("oci://%s", named.String()), + digest: digest.Hex, + digestAlg: digest.Algorithm, + }, nil +} diff --git a/pkg/cmd/attestation/artifact/image_test.go b/pkg/cmd/attestation/artifact/image_test.go new file mode 100644 index 000000000..a69893fc8 --- /dev/null +++ b/pkg/cmd/attestation/artifact/image_test.go @@ -0,0 +1,41 @@ +package artifact + +import ( + "fmt" + "testing" + + "github.com/github/gh-attestation/pkg/artifact/oci" + "github.com/stretchr/testify/assert" +) + +func TestDigestContainerImageArtifact(t *testing.T) { + expectedDigest := "1234567890abcdef" + client := oci.NewMockClient() + url := "example.com/repo:tag" + digestedArtifact, err := digestContainerImageArtifact(url, client) + assert.NoError(t, err) + assert.Equal(t, fmt.Sprintf("oci://%s", url), digestedArtifact.URL) + assert.Equal(t, expectedDigest, digestedArtifact.digest) + assert.Equal(t, "sha256", digestedArtifact.digestAlg) +} + +func TestFetchImageFailure(t *testing.T) { + client := oci.NewReferenceFailClient() + url := "example.com/repo:tag" + _, err := digestContainerImageArtifact(url, client) + assert.Error(t, err) +} + +func TestRegistryAuthFailure(t *testing.T) { + client := oci.NewAuthFailClient() + url := "example.com/repo:tag" + _, err := digestContainerImageArtifact(url, client) + assert.ErrorIs(t, err, oci.ErrRegistryAuthz) +} + +func TestDeniedFailure(t *testing.T) { + client := oci.NewDeniedClient() + url := "example.com/repo:tag" + _, err := digestContainerImageArtifact(url, client) + assert.ErrorIs(t, err, oci.ErrDenied) +} diff --git a/pkg/cmd/attestation/artifact/oci/oci.go b/pkg/cmd/attestation/artifact/oci/oci.go new file mode 100644 index 000000000..a12b70ab9 --- /dev/null +++ b/pkg/cmd/attestation/artifact/oci/oci.go @@ -0,0 +1,110 @@ +package oci + +import ( + "errors" + "fmt" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" +) + +var ErrDenied = errors.New("the provided token was denied access to the requested resource, please check the token's expiration and repository access") +var ErrRegistryAuthz = errors.New("remote registry authorization failed, please authenticate with the registry and try again") + +type Client struct { + ParseReference func(string, ...name.Option) (name.Reference, error) + Get func(name.Reference, ...remote.Option) (*remote.Descriptor, error) +} + +func checkForUnauthorizedOrDeniedErr(err transport.Error) error { + for _, diagnostic := range err.Errors { + switch diagnostic.Code { + case transport.UnauthorizedErrorCode: + return ErrRegistryAuthz + case transport.DeniedErrorCode: + return ErrDenied + } + } + return nil +} + +// where name is formed like ghcr.io/github/my-image-repo +func (c Client) GetImageDigest(imgName string) (*v1.Hash, error) { + name, err := c.ParseReference(imgName) + if err != nil { + return nil, fmt.Errorf("failed to create image tag: %w", err) + } + + desc, err := c.Get(name, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + var transportErr *transport.Error + if errors.As(err, &transportErr) { + if accessErr := checkForUnauthorizedOrDeniedErr(*transportErr); accessErr != nil { + return nil, accessErr + } + } + return nil, fmt.Errorf("failed to fetch remote image: %w", err) + } + + return &desc.Digest, nil +} + +func NewLiveClient() Client { + return Client{ + ParseReference: name.ParseReference, + Get: remote.Get, + } +} + +func NewMockClient() Client { + return Client{ + ParseReference: func(string, ...name.Option) (name.Reference, error) { + return name.Tag{}, nil + }, + Get: func(name.Reference, ...remote.Option) (*remote.Descriptor, error) { + d := remote.Descriptor{} + d.Digest = v1.Hash{ + Hex: "1234567890abcdef", + Algorithm: "sha256", + } + + return &d, nil + }, + } +} + +func NewReferenceFailClient() Client { + return Client{ + ParseReference: func(string, ...name.Option) (name.Reference, error) { + return nil, fmt.Errorf("failed to parse reference") + }, + Get: func(name.Reference, ...remote.Option) (*remote.Descriptor, error) { + return nil, nil + }, + } +} + +func NewAuthFailClient() Client { + return Client{ + ParseReference: func(string, ...name.Option) (name.Reference, error) { + return name.Tag{}, nil + }, + Get: func(name.Reference, ...remote.Option) (*remote.Descriptor, error) { + return nil, &transport.Error{Errors: []transport.Diagnostic{{Code: transport.UnauthorizedErrorCode}}} + }, + } +} + +func NewDeniedClient() Client { + return Client{ + ParseReference: func(string, ...name.Option) (name.Reference, error) { + return name.Tag{}, nil + }, + Get: func(name.Reference, ...remote.Option) (*remote.Descriptor, error) { + return nil, &transport.Error{Errors: []transport.Diagnostic{{Code: transport.DeniedErrorCode}}} + }, + } +} diff --git a/pkg/cmd/attestation/verify/options.go b/pkg/cmd/attestation/verify/options.go index 2af3a9d6b..d8c22c34b 100644 --- a/pkg/cmd/attestation/verify/options.go +++ b/pkg/cmd/attestation/verify/options.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/github/gh-attestation/pkg/artifact/digest" + "github.com/cli/cli/v2/pkg/cmd/artifact/digest" "github.com/github/gh-attestation/pkg/artifact/oci" "github.com/github/gh-attestation/pkg/github" "github.com/github/gh-attestation/pkg/output"