move artifact package over
Signed-off-by: Meredith Lancaster <malancas@github.com>
This commit is contained in:
parent
3a08c03cc7
commit
97c10f4c04
10 changed files with 525 additions and 1 deletions
74
pkg/cmd/attestation/artifact/artifact.go
Normal file
74
pkg/cmd/attestation/artifact/artifact.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
99
pkg/cmd/attestation/artifact/artifact_posix_test.go
Normal file
99
pkg/cmd/attestation/artifact/artifact_posix_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
59
pkg/cmd/attestation/artifact/artifact_windows_test.go
Normal file
59
pkg/cmd/attestation/artifact/artifact_windows_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
50
pkg/cmd/attestation/artifact/digest/digest.go
Normal file
50
pkg/cmd/attestation/artifact/digest/digest.go
Normal file
|
|
@ -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
|
||||
}
|
||||
38
pkg/cmd/attestation/artifact/digest/digest_test.go
Normal file
38
pkg/cmd/attestation/artifact/digest/digest_test.go
Normal file
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
25
pkg/cmd/attestation/artifact/file.go
Normal file
25
pkg/cmd/attestation/artifact/file.go
Normal file
|
|
@ -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
|
||||
}
|
||||
28
pkg/cmd/attestation/artifact/image.go
Normal file
28
pkg/cmd/attestation/artifact/image.go
Normal file
|
|
@ -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
|
||||
}
|
||||
41
pkg/cmd/attestation/artifact/image_test.go
Normal file
41
pkg/cmd/attestation/artifact/image_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
110
pkg/cmd/attestation/artifact/oci/oci.go
Normal file
110
pkg/cmd/attestation/artifact/oci/oci.go
Normal file
|
|
@ -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}}}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue