diff --git a/README.md b/README.md index e34e5d230a5d..4a9294b2dd93 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,7 @@ Keys supported by image output: * `push-by-digest=true`: push unnamed image * `registry.insecure=true`: push to insecure HTTP registry * `oci-mediatypes=true`: use OCI mediatypes in configuration JSON instead of Docker's +* `oci-artifact=false`: use OCI artifact format for attestations * `unpack=true`: unpack image after creation (for use with containerd) * `dangling-name-prefix=`: name image with `prefix@`, used for anonymous images * `name-canonical=true`: add additional canonical name `name@` diff --git a/client/client_test.go b/client/client_test.go index 562d19d7bc59..eb511afc067b 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -192,7 +192,8 @@ var allTests = []func(t *testing.T, sb integration.Sandbox){ testPullWithLayerLimit, testExportAnnotations, testExportAnnotationsMediaTypes, - testExportAttestations, + testExportAttestationsOCIArtifact, + testExportAttestationsImageManifest, testExportedImageLabels, testAttestationDefaultSubject, testSourceDateEpochLayerTimestamps, @@ -8833,7 +8834,15 @@ func testExportAnnotationsMediaTypes(t *testing.T, sb integration.Sandbox) { require.Equal(t, ocispecs.MediaTypeImageIndex, imgs2.Index.MediaType) } -func testExportAttestations(t *testing.T, sb integration.Sandbox) { +func testExportAttestationsOCIArtifact(t *testing.T, sb integration.Sandbox) { + testExportAttestations(t, sb, true) +} + +func testExportAttestationsImageManifest(t *testing.T, sb integration.Sandbox) { + testExportAttestations(t, sb, false) +} + +func testExportAttestations(t *testing.T, sb integration.Sandbox, ociArtifact bool) { workers.CheckFeatureCompat(t, sb, workers.FeatureDirectPush) requiresLinux(t) c, err := New(sb.Context(), sb.Address()) @@ -8953,8 +8962,9 @@ func testExportAttestations(t *testing.T, sb integration.Sandbox) { { Type: ExporterImage, Attrs: map[string]string{ - "name": strings.Join(targets, ","), - "push": "true", + "name": strings.Join(targets, ","), + "push": "true", + "oci-artifact": strconv.FormatBool(ociArtifact), }, }, }, @@ -8984,12 +8994,25 @@ func testExportAttestations(t *testing.T, sb integration.Sandbox) { for i, att := range atts.Images { require.Equal(t, ocispecs.MediaTypeImageManifest, att.Desc.MediaType) require.Equal(t, "unknown/unknown", platforms.Format(*att.Desc.Platform)) - require.Equal(t, "unknown/unknown", att.Img.OS+"/"+att.Img.Architecture) require.Equal(t, attestation.DockerAnnotationReferenceTypeDefault, att.Desc.Annotations[attestation.DockerAnnotationReferenceType]) require.Equal(t, bases[i].Desc.Digest.String(), att.Desc.Annotations[attestation.DockerAnnotationReferenceDigest]) require.Equal(t, 2, len(att.Layers)) - require.Equal(t, len(att.Layers), len(att.Img.RootFS.DiffIDs)) - require.Equal(t, 0, len(att.Img.History)) + + if ociArtifact { + subject := att.Manifest.Subject + require.NotNil(t, subject) + require.Equal(t, bases[i].Desc, *subject) + require.Equal(t, "application/vnd.docker.attestation.manifest.v1+json", att.Manifest.ArtifactType) + require.Equal(t, ocispecs.DescriptorEmptyJSON, att.Manifest.Config) + } else { + require.Nil(t, att.Manifest.Subject) + require.Empty(t, att.Manifest.ArtifactType) + + // image config is not included in the OCI artifact + require.Equal(t, "unknown/unknown", att.Img.OS+"/"+att.Img.Architecture) + require.Equal(t, len(att.Layers), len(att.Img.RootFS.DiffIDs)) + require.Equal(t, 0, len(att.Img.History)) + } var attest intoto.Statement require.NoError(t, json.Unmarshal(att.LayersRaw[0], &attest)) diff --git a/exporter/containerimage/exptypes/keys.go b/exporter/containerimage/exptypes/keys.go index 722f099cf0ce..8eb2064e111a 100644 --- a/exporter/containerimage/exptypes/keys.go +++ b/exporter/containerimage/exptypes/keys.go @@ -44,6 +44,9 @@ var ( // Value: bool OptKeyOCITypes ImageExporterOptKey = "oci-mediatypes" + // Use OCI artifact format for the attestation manifest. + OptKeyOCIArtifact ImageExporterOptKey = "oci-artifact" + // Force attestation to be attached. // Value: bool OptKeyForceInlineAttestations ImageExporterOptKey = "attestation-inline" diff --git a/exporter/containerimage/opts.go b/exporter/containerimage/opts.go index 50ae5de31164..1ccadbd946d8 100644 --- a/exporter/containerimage/opts.go +++ b/exporter/containerimage/opts.go @@ -17,6 +17,7 @@ type ImageCommitOpts struct { ImageName string RefCfg cacheconfig.RefConfig OCITypes bool + OCIArtifact bool Annotations AnnotationsGroup Epoch *time.Time @@ -49,6 +50,8 @@ func (c *ImageCommitOpts) Load(ctx context.Context, opt map[string]string) (map[ c.ImageName = v case exptypes.OptKeyOCITypes: err = parseBoolWithDefault(&c.OCITypes, k, v, true) + case exptypes.OptKeyOCIArtifact: + err = parseBool(&c.OCIArtifact, k, v) case exptypes.OptKeyForceInlineAttestations: err = parseBool(&c.ForceInlineAttestations, k, v) case exptypes.OptKeyPreferNondistLayers: @@ -67,6 +70,9 @@ func (c *ImageCommitOpts) Load(ctx context.Context, opt map[string]string) (map[ if c.RefCfg.Compression.Type.OnlySupportOCITypes() { c.EnableOCITypes(ctx, c.RefCfg.Compression.Type.String()) } + if c.OCIArtifact && !c.OCITypes { + c.EnableOCITypes(ctx, "oci-artifact") + } c.Annotations = c.Annotations.Merge(as) diff --git a/exporter/containerimage/writer.go b/exporter/containerimage/writer.go index 4e3e678fd7e6..6493407f89dc 100644 --- a/exporter/containerimage/writer.go +++ b/exporter/containerimage/writer.go @@ -46,6 +46,8 @@ import ( "golang.org/x/sync/errgroup" ) +const attestationManifestArtifactType = "application/vnd.docker.attestation.manifest.v1+json" + type WriterOpt struct { Snapshotter snapshot.Snapshotter ContentStore content.Store @@ -312,7 +314,7 @@ func (ic *ImageWriter) Commit(ctx context.Context, inp *exporter.Source, session return nil, err } - desc, err := ic.commitAttestationsManifest(ctx, opts, desc.Digest.String(), stmts) + desc, err := ic.commitAttestationsManifest(ctx, opts, *desc, stmts, opts.OCIArtifact) if err != nil { return nil, err } @@ -553,7 +555,7 @@ func (ic *ImageWriter) commitDistributionManifest(ctx context.Context, opts *Ima }, &configDesc, nil } -func (ic *ImageWriter) commitAttestationsManifest(ctx context.Context, opts *ImageCommitOpts, target string, statements []intoto.Statement) (*ocispecs.Descriptor, error) { +func (ic *ImageWriter) commitAttestationsManifest(ctx context.Context, opts *ImageCommitOpts, target ocispecs.Descriptor, statements []intoto.Statement, ociArtifact bool) (*ocispecs.Descriptor, error) { var ( manifestType = ocispecs.MediaTypeImageManifest configType = ocispecs.MediaTypeImageConfig @@ -588,15 +590,21 @@ func (ic *ImageWriter) commitAttestationsManifest(ctx context.Context, opts *Ima layers[i] = desc } - config, err := attestationsConfig(layers) - if err != nil { - return nil, err - } - configDigest := digest.FromBytes(config) - configDesc := ocispecs.Descriptor{ - Digest: configDigest, - Size: int64(len(config)), - MediaType: configType, + configDesc := ocispecs.DescriptorEmptyJSON + config := configDesc.Data + + if !ociArtifact { + var err error + config, err = attestationsConfig(layers) + if err != nil { + return nil, err + } + configDigest := digest.FromBytes(config) + configDesc = ocispecs.Descriptor{ + Digest: configDigest, + Size: int64(len(config)), + MediaType: configType, + } } mfst := ocispecs.Manifest{ @@ -604,15 +612,16 @@ func (ic *ImageWriter) commitAttestationsManifest(ctx context.Context, opts *Ima Versioned: specs.Versioned{ SchemaVersion: 2, }, - Config: ocispecs.Descriptor{ - Digest: configDigest, - Size: int64(len(config)), - MediaType: configType, - }, + Config: configDesc, + } + + if ociArtifact { + mfst.ArtifactType = attestationManifestArtifactType + mfst.Subject = &target } labels := map[string]string{ - "containerd.io/gc.ref.content.0": configDigest.String(), + "containerd.io/gc.ref.content.0": configDesc.Digest.String(), } for i, desc := range layers { desc.Annotations = RemoveInternalLayerAnnotations(desc.Annotations, opts.OCITypes) @@ -635,7 +644,7 @@ func (ic *ImageWriter) commitAttestationsManifest(ctx context.Context, opts *Ima if err := content.WriteBlob(ctx, ic.opt.ContentStore, mfstDigest.String(), bytes.NewReader(mfstJSON), mfstDesc, content.WithLabels((labels))); err != nil { return nil, done(errors.Wrapf(err, "error writing manifest blob %s", mfstDigest)) } - if err := content.WriteBlob(ctx, ic.opt.ContentStore, configDigest.String(), bytes.NewReader(config), configDesc); err != nil { + if err := content.WriteBlob(ctx, ic.opt.ContentStore, configDesc.Digest.String(), bytes.NewReader(config), configDesc); err != nil { return nil, done(errors.Wrap(err, "error writing config blob")) } done(nil) @@ -646,7 +655,7 @@ func (ic *ImageWriter) commitAttestationsManifest(ctx context.Context, opts *Ima MediaType: manifestType, Annotations: map[string]string{ attestationTypes.DockerAnnotationReferenceType: attestationTypes.DockerAnnotationReferenceTypeDefault, - attestationTypes.DockerAnnotationReferenceDigest: target, + attestationTypes.DockerAnnotationReferenceDigest: string(target.Digest), }, }, nil }