Make referrers attestations OCI compliant (#80)

* Single attestation when creating VSA

* Create single layer images for referrers attestations

* Move mock to test package. Add artifacts test

* Add test for envelope detection

* Add tests for image/index saving

* Add mirror tests

* Remove AttestationImage field from AttestationManifest

* Update naming. strictReferers != laxReferrers

* Add specific test for SaveReferrers
This commit is contained in:
James Carnegie
2024-07-16 10:05:17 +01:00
committed by GitHub
parent a4c3bd07fe
commit 065b354d3c
24 changed files with 809 additions and 340 deletions

View File

@@ -15,7 +15,7 @@ import (
// implementation of AttestationResolver that closes over attestations from an oci layout
type OCILayoutResolver struct {
*AttestationManifest
*attestation.AttestationManifest
*ImageSpec
}
@@ -30,7 +30,7 @@ func NewOCILayoutAttestationResolver(src *ImageSpec) (*OCILayoutResolver, error)
return r, nil
}
func (r *OCILayoutResolver) fetchAttestationManifest() (*AttestationManifest, error) {
func (r *OCILayoutResolver) fetchAttestationManifest() (*attestation.AttestationManifest, error) {
if r.AttestationManifest == nil {
m, err := attestationManifestFromOCILayout(r.Identifier, r.ImageSpec.Platform)
if err != nil {
@@ -43,19 +43,13 @@ func (r *OCILayoutResolver) fetchAttestationManifest() (*AttestationManifest, er
}
func (r *OCILayoutResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) {
attestationImage := r.AttestationManifest.Image
layers, err := attestationImage.Layers()
if err != nil {
return nil, fmt.Errorf("failed to extract layers from attestation image: %w", err)
}
var envs []*att.Envelope
manifest := r.AttestationManifest.Manifest
for i, l := range manifest.Layers {
if l.Annotations[attestation.InTotoPredicateType] != predicateType {
for _, attestationLayer := range r.AttestationManifest.OriginalLayers {
if attestationLayer.Annotations[attestation.InTotoPredicateType] != predicateType {
continue
}
layer := layers[i]
mt, err := layer.MediaType()
mt, err := attestationLayer.Layer.MediaType()
if err != nil {
return nil, fmt.Errorf("failed to get layer media type: %w", err)
}
@@ -65,7 +59,7 @@ func (r *OCILayoutResolver) Attestations(ctx context.Context, predicateType stri
}
var env = new(att.Envelope)
// parse layer blob as json
r, err := layer.Uncompressed()
r, err := attestationLayer.Layer.Uncompressed()
if err != nil {
return nil, fmt.Errorf("failed to get layer contents: %w", err)
@@ -81,18 +75,18 @@ func (r *OCILayoutResolver) Attestations(ctx context.Context, predicateType stri
}
func (r *OCILayoutResolver) ImageName(ctx context.Context) (string, error) {
return r.Name, nil
return r.SubjectName, nil
}
func (r *OCILayoutResolver) ImageDigest(ctx context.Context) (string, error) {
return r.Digest, nil
func (r *OCILayoutResolver) ImageDescriptor(ctx context.Context) (*v1.Descriptor, error) {
return r.SubjectDescriptor, nil
}
func (r *OCILayoutResolver) ImagePlatform(ctx context.Context) (*v1.Platform, error) {
return r.ImageSpec.Platform, nil
}
func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*AttestationManifest, error) {
func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*attestation.AttestationManifest, error) {
idx, err := layout.ImageIndexFromPath(path)
if err != nil {
return nil, err
@@ -115,10 +109,11 @@ func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*Atte
if err != nil {
return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err)
}
var imageDigest string
var subjectDescriptor *v1.Descriptor
for _, mf := range mfs2.Manifests {
if mf.Platform.Equals(*platform) {
imageDigest = mf.Digest.String()
subjectDescriptor = &mf
break
}
}
for _, mf := range mfs2.Manifests {
@@ -126,7 +121,7 @@ func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*Atte
continue
}
if mf.Annotations[att.DockerReferenceDigest] != imageDigest {
if mf.Annotations[att.DockerReferenceDigest] != subjectDescriptor.Digest.String() {
continue
}
@@ -134,17 +129,15 @@ func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*Atte
if err != nil {
return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", mf.Digest.String(), err)
}
manifest, err := attestationImage.Manifest()
layers, err := attestation.GetAttestationsFromImage(attestationImage)
if err != nil {
return nil, fmt.Errorf("failed to get manifest: %w", err)
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
}
attest := &AttestationManifest{
Name: name,
Image: attestationImage,
Manifest: manifest,
Descriptor: &mf,
Digest: imageDigest,
Platform: platform,
attest := &attestation.AttestationManifest{
OriginalLayers: layers,
OriginalDescriptor: &mf,
SubjectName: name,
SubjectDescriptor: subjectDescriptor,
}
return attest, nil
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/containerd/containerd/platforms"
"github.com/distribution/reference"
"github.com/docker/attest/pkg/attestation"
att "github.com/docker/attest/pkg/attestation"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
@@ -46,19 +47,17 @@ func WithOptions(ctx context.Context, platform *v1.Platform) []remote.Option {
return options
}
func ExtractEnvelopes(ia *AttestationManifest, predicateType string) ([]*att.Envelope, error) {
manifest := ia.Manifest
image := ia.Image
func ExtractEnvelopes(manifest *attestation.AttestationManifest, predicateType string) ([]*att.Envelope, error) {
var envs []*att.Envelope
layers, err := image.Layers()
if err != nil {
return nil, fmt.Errorf("failed to get layers: %w", err)
}
for i, l := range manifest.Layers {
if (strings.HasPrefix(string(l.MediaType), "application/vnd.in-toto.")) &&
strings.HasSuffix(string(l.MediaType), "+dsse") &&
l.Annotations[att.InTotoPredicateType] == predicateType {
reader, err := layers[i].Uncompressed()
for _, attestationLayer := range manifest.OriginalLayers {
mt, err := attestationLayer.Layer.MediaType()
if err != nil {
return nil, fmt.Errorf("failed to get layer media type: %w", err)
}
if (strings.HasPrefix(string(mt), "application/vnd.in-toto.")) &&
strings.HasSuffix(string(mt), "+dsse") &&
attestationLayer.Annotations[att.InTotoPredicateType] == predicateType {
reader, err := attestationLayer.Layer.Uncompressed()
if err != nil {
return nil, fmt.Errorf("failed to get layer contents: %w", err)
}
@@ -75,13 +74,13 @@ func ExtractEnvelopes(ia *AttestationManifest, predicateType string) ([]*att.Env
return envs, nil
}
func imageDigestForPlatform(ix *v1.IndexManifest, platform *v1.Platform) (string, error) {
func imageDescriptor(ix *v1.IndexManifest, platform *v1.Platform) (*v1.Descriptor, error) {
for _, m := range ix.Manifests {
if (m.MediaType == ocispec.MediaTypeImageManifest || m.MediaType == "application/vnd.docker.distribution.manifest.v2+json") && m.Platform.Equals(*platform) {
return m.Digest.String(), nil
return &m, nil
}
}
return "", errors.New(fmt.Sprintf("no image found for platform %v", platform))
return nil, errors.New(fmt.Sprintf("no image found for platform %v", platform))
}
func attestationDigestForDigest(ix *v1.IndexManifest, imageDigest string, attestType string) (string, error) {
@@ -147,3 +146,27 @@ func SplitDigest(digest string) (common.DigestSet, error) {
parts[0]: parts[1],
}, nil
}
func ReplaceTagInSpec(src *ImageSpec, digest v1.Hash) (*ImageSpec, error) {
newName, err := replaceTag(src.Identifier, digest)
if err != nil {
return nil, fmt.Errorf("failed to parse repo name: %w", err)
}
return &ImageSpec{
Identifier: newName,
Type: src.Type,
Platform: src.Platform,
}, nil
}
// so that the index tag is replaced with a tag unique to the image digest and doesn't overwrite it
func replaceTag(image string, digest v1.Hash) (string, error) {
if strings.HasPrefix(image, LocalPrefix) {
return image, nil
}
notag, err := WithoutTag(image)
if err != nil {
return "", nil
}
return fmt.Sprintf("%s:%s-%s.att", notag, digest.Algorithm, digest.Hex), nil
}

View File

@@ -4,6 +4,7 @@ import (
"path/filepath"
"testing"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/layout"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -75,14 +76,16 @@ func TestImageDigestForPlatform(t *testing.T) {
p, err := ParsePlatform("linux/amd64")
assert.NoError(t, err)
digest, err := imageDigestForPlatform(mfs2, p)
desc, err := imageDescriptor(mfs2, p)
assert.NoError(t, err)
digest := desc.Digest.String()
assert.Equal(t, "sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620", digest)
p, err = ParsePlatform("linux/arm64")
assert.NoError(t, err)
digest, err = imageDigestForPlatform(mfs2, p)
desc, err = imageDescriptor(mfs2, p)
assert.NoError(t, err)
digest = desc.Digest.String()
assert.Equal(t, "sha256:7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e", digest)
}
@@ -106,3 +109,31 @@ func TestWithoutTag(t *testing.T) {
})
}
}
func TestReplaceTag(t *testing.T) {
tc := []struct {
name string
expected string
}{
{name: "image:tag", expected: "index.docker.io/library/image:sha256-digest.att"},
{name: "image", expected: "index.docker.io/library/image:sha256-digest.att"},
{name: "image:sha256-digest.att", expected: "index.docker.io/library/image:sha256-digest.att"},
{name: "docker://image:tag", expected: "docker://index.docker.io/library/image:sha256-digest.att"},
{name: "image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: "index.docker.io/library/image:sha256-digest.att"},
{name: "oci://foobar", expected: "oci://foobar"},
{name: "docker://image@sha256:166710df254975d4a6c4c407c315951c22753dcaa829e020a3fd5d18fff70dd2", expected: "docker://index.docker.io/library/image:sha256-digest.att"},
{name: "docker://127.0.0.1:36555/repo:latest", expected: "docker://127.0.0.1:36555/repo:sha256-digest.att"},
}
digest := v1.Hash{
Algorithm: "sha256",
Hex: "digest",
}
for _, c := range tc {
t.Run(c.name, func(t *testing.T) {
replaced, err := replaceTag(c.name, digest)
require.NoError(t, err)
assert.Equal(t, c.expected, replaced)
})
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"github.com/docker/attest/pkg/attestation"
att "github.com/docker/attest/pkg/attestation"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
@@ -13,13 +14,13 @@ import (
type ReferrersResolver struct {
digest string
referrersRepo string
manifests []*AttestationManifest
*RegistryImageDetailsResolver
manifests []*attestation.AttestationManifest
ImageDetailsResolver
}
func NewReferrersAttestationResolver(src *RegistryImageDetailsResolver, options ...func(*ReferrersResolver) error) (*ReferrersResolver, error) {
func NewReferrersAttestationResolver(src ImageDetailsResolver, options ...func(*ReferrersResolver) error) (*ReferrersResolver, error) {
res := &ReferrersResolver{
RegistryImageDetailsResolver: src,
ImageDetailsResolver: src,
}
for _, opt := range options {
err := opt(res)
@@ -39,11 +40,19 @@ func WithReferrersRepo(repo string) func(*ReferrersResolver) error {
func (r *ReferrersResolver) resolveAttestations(ctx context.Context) error {
if r.manifests == nil {
subjectRef, err := name.ParseReference(r.Identifier)
imageName, err := r.ImageName(ctx)
if err != nil {
return fmt.Errorf("failed to get image name: %w", err)
}
subjectRef, err := name.ParseReference(imageName)
if err != nil {
return fmt.Errorf("failed to parse reference: %w", err)
}
subjectDigest, err := r.ImageDigest(ctx)
desc, err := r.ImageDescriptor(ctx)
if err != nil {
return fmt.Errorf("failed to get descriptor: %w", err)
}
subjectDigest := desc.Digest.String()
if err != nil {
return fmt.Errorf("failed to get digest: %w", err)
}
@@ -56,6 +65,7 @@ func (r *ReferrersResolver) resolveAttestations(ctx context.Context) error {
} else {
referrersSubjectRef = subjectRef.Context().Digest(subjectDigest)
}
// TODO - search for in-toto artifact type
referrersIndex, err := remote.Referrers(referrersSubjectRef)
if err != nil {
return fmt.Errorf("failed to get referrers: %w", err)
@@ -67,31 +77,22 @@ func (r *ReferrersResolver) resolveAttestations(ctx context.Context) error {
if len(referrersIndexManifest.Manifests) == 0 {
return errors.New("no referrers found")
}
aManifests := make([]*AttestationManifest, 0)
aManifests := make([]*attestation.AttestationManifest, 0)
for _, m := range referrersIndexManifest.Manifests {
remoteRef := referrersSubjectRef.Context().Digest(m.Digest.String())
attestationImage, err := remote.Image(remoteRef)
if err != nil {
return fmt.Errorf("failed to get referred image: %w", err)
}
manifest, err := attestationImage.Manifest()
layers, err := attestation.GetAttestationsFromImage(attestationImage)
if err != nil {
return fmt.Errorf("failed to get manifest: %w", err)
return fmt.Errorf("failed to get attestations from image: %w", err)
}
if manifest.Annotations[att.DockerReferenceType] != att.AttestationManifestType {
continue
}
if manifest.Annotations[att.DockerReferenceDigest] != subjectDigest {
continue
}
attest := &AttestationManifest{
Name: r.Identifier,
Image: attestationImage,
Manifest: manifest,
Descriptor: &m,
Digest: subjectDigest,
Platform: r.Platform,
attest := &attestation.AttestationManifest{
SubjectName: imageName,
OriginalLayers: layers,
OriginalDescriptor: &m,
SubjectDescriptor: desc,
}
aManifests = append(aManifests, attest)
}

View File

@@ -2,9 +2,9 @@ package oci
import (
"context"
"encoding/json"
"fmt"
"github.com/docker/attest/pkg/attestation"
att "github.com/docker/attest/pkg/attestation"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
@@ -13,12 +13,12 @@ import (
type RegistryResolver struct {
*RegistryImageDetailsResolver
*AttestationManifest
*attestation.AttestationManifest
}
type RegistryImageDetailsResolver struct {
*ImageSpec
digest string
descriptor *v1.Descriptor
}
func NewRegistryImageDetailsResolver(src *ImageSpec) (*RegistryImageDetailsResolver, error) {
@@ -41,24 +41,36 @@ func (r *RegistryImageDetailsResolver) ImagePlatform(ctx context.Context) (*v1.P
return r.Platform, nil
}
func (r *RegistryImageDetailsResolver) ImageDigest(ctx context.Context) (string, error) {
if r.digest == "" {
func (r *RegistryImageDetailsResolver) ImageDescriptor(ctx context.Context) (*v1.Descriptor, error) {
if r.descriptor == nil {
subjectRef, err := name.ParseReference(r.Identifier)
if err != nil {
return "", fmt.Errorf("failed to parse reference: %w", err)
return nil, fmt.Errorf("failed to parse reference: %w", err)
}
options := WithOptions(ctx, r.Platform)
desc, err := remote.Image(subjectRef, options...)
image, err := remote.Image(subjectRef, options...)
if err != nil {
return "", fmt.Errorf("failed to get image manifest: %w", err)
return nil, fmt.Errorf("failed to get image manifest: %w", err)
}
subjectDigest, err := desc.Digest()
digest, err := image.Digest()
if err != nil {
return "", fmt.Errorf("failed to get image digest: %w", err)
return nil, fmt.Errorf("failed to get image digest: %w", err)
}
size, err := image.Size()
if err != nil {
return nil, fmt.Errorf("failed to get image size: %w", err)
}
mediaType, err := image.MediaType()
if err != nil {
return nil, fmt.Errorf("failed to get image media type: %w", err)
}
r.descriptor = &v1.Descriptor{
Digest: digest,
Size: size,
MediaType: mediaType,
}
r.digest = subjectDigest.String()
}
return r.digest, nil
return r.descriptor, nil
}
func (r *RegistryResolver) Attestations(ctx context.Context, predicateType string) ([]*att.Envelope, error) {
@@ -72,7 +84,7 @@ func (r *RegistryResolver) Attestations(ctx context.Context, predicateType strin
return ExtractEnvelopes(r.AttestationManifest, predicateType)
}
func FetchAttestationManifest(ctx context.Context, image string, platform *v1.Platform) (*AttestationManifest, error) {
func FetchAttestationManifest(ctx context.Context, image string, platform *v1.Platform) (*attestation.AttestationManifest, error) {
// we want to get to the image index, so ignoring platform for now
options := WithOptions(ctx, nil)
ref, err := name.ParseReference(image)
@@ -87,10 +99,12 @@ func FetchAttestationManifest(ctx context.Context, image string, platform *v1.Pl
if err != nil {
return nil, fmt.Errorf("failed to get index manifest: %w", err)
}
digest, err := imageDigestForPlatform(indexManifest, platform)
subjectDescriptor, err := imageDescriptor(indexManifest, platform)
if err != nil {
return nil, fmt.Errorf("failed to obtain image for platform: %w", err)
}
digest := subjectDescriptor.Digest.String()
ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), digest))
if err != nil {
return nil, fmt.Errorf("failed to parse attestation reference: %w", err)
@@ -108,22 +122,20 @@ func FetchAttestationManifest(ctx context.Context, image string, platform *v1.Pl
if err != nil {
return nil, fmt.Errorf("failed to get attestation: %w", err)
}
manifest := new(v1.Manifest)
err = json.Unmarshal(remoteDescriptor.Manifest, manifest)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal attestation: %w", err)
}
attestationImage, err := remoteDescriptor.Image()
if err != nil {
return nil, fmt.Errorf("failed to get attestation image: %w", err)
}
attest := &AttestationManifest{
Name: image,
Image: attestationImage,
Manifest: manifest,
Descriptor: &remoteDescriptor.Descriptor,
Digest: digest,
Platform: platform,
layers, err := attestation.GetAttestationsFromImage(attestationImage)
if err != nil {
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
}
attest := &attestation.AttestationManifest{
OriginalLayers: layers,
OriginalDescriptor: &remoteDescriptor.Descriptor,
SubjectName: image,
SubjectDescriptor: subjectDescriptor,
}
return attest, nil
}

View File

@@ -25,16 +25,13 @@ func TestRegistry(t *testing.T) {
u, err := url.Parse(server.URL)
require.NoError(t, err)
opts := &attestation.SigningOptions{
Replace: true,
SkipSubject: true,
}
opts := &attestation.SigningOptions{}
attIdx, err := oci.IndexFromPath(oci.UnsignedTestImage)
require.NoError(t, err)
signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts)
require.NoError(t, err)
signedIndex := attIdx.Index
signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests)
signedIndex, err = attestation.UpdateIndexImages(signedIndex, signedManifests)
require.NoError(t, err)
indexName := fmt.Sprintf("%s/repo:root", u.Host)
@@ -47,7 +44,8 @@ func TestRegistry(t *testing.T) {
resolver, err := policy.CreateImageDetailsResolver(spec)
require.NoError(t, err)
digest, err := resolver.ImageDigest(ctx)
desc, err := resolver.ImageDescriptor(ctx)
require.NoError(t, err)
digest := desc.Digest.String()
assert.True(t, strings.Contains(digest, "sha256:"))
}

View File

@@ -7,21 +7,6 @@ import (
v1 "github.com/google/go-containerregistry/pkg/v1"
)
type AttestationManifests struct {
Manifests []*AttestationManifest
}
type AttestationManifest struct {
// attestation image details
Image v1.Image
Manifest *v1.Manifest
Descriptor *v1.Descriptor
// details of subect image
Name string
Digest string
Platform *v1.Platform
}
type AttestationResolver interface {
ImageDetailsResolver
Attestations(ctx context.Context, mediaType string) ([]*att.Envelope, error)
@@ -30,25 +15,5 @@ type AttestationResolver interface {
type ImageDetailsResolver interface {
ImageName(ctx context.Context) (string, error)
ImagePlatform(ctx context.Context) (*v1.Platform, error)
ImageDigest(ctx context.Context) (string, error)
}
type MockResolver struct {
Envs []*att.Envelope
}
func (r MockResolver) Attestations(ctx context.Context, mediaType string) ([]*att.Envelope, error) {
return r.Envs, nil
}
func (r MockResolver) ImageName(ctx context.Context) (string, error) {
return "library/alpine:latest", nil
}
func (r MockResolver) ImageDigest(ctx context.Context) (string, error) {
return "sha256:test-digest", nil
}
func (r MockResolver) ImagePlatform(ctx context.Context) (*v1.Platform, error) {
return ParsePlatform("linux/amd64")
ImageDescriptor(ctx context.Context) (*v1.Descriptor, error)
}