Browse code

api: add GET /images/{name}/attestations endpoint

Add a new Engine API endpoint that returns the in-toto attestation
statements attached to an image for a given platform. The endpoint
locates the attestation manifest(s) referencing the requested platform's
image manifest, enumerates the statement layers, and returns each
layer's OCI descriptor (including media type, digest, size, and
annotations) together with its in-toto predicate type.

Query parameters:
- platform: JSON-encoded OCI platform; defaults to the daemon's host
platform if omitted.
- type: comma-separated list of in-toto predicate type URIs; if
omitted, all statements are returned.
- statement: boolean, defaults to false. When true, the daemon reads
each matching statement blob and includes the verbatim in-toto JSON
in the response. When false (or omitted), statement blobs are not
read and the Statement field is absent from each entry.

The manifest-chain walk (locating the platform image manifest and its
associated attestation manifest) is delegated to policy-helpers'
image.ResolveSignatureChain so that moby and BuildKit agree on how to
interpret the attestation storage format. The statement-layer iteration
and blob reading is inlined: when statement bodies are requested it
fails fast on the first unreadable blob and reads matching blobs
eagerly into memory; otherwise statement-layer blobs are never read
from the content store.

The endpoint is implemented for the containerd image store. The legacy
graphdriver store returns errdefs.NotImplemented (HTTP 501).

Signed-off-by: Sopho Merkviladze <smerkviladze@mirantis.com>

Sopho Merkviladze authored on 2026/05/16 02:34:12
Showing 19 changed files
... ...
@@ -13,6 +13,18 @@ keywords: "API, Docker, rcli, REST, documentation"
13 13
      will be rejected.
14 14
 -->
15 15
 
16
+## v1.55 API changes
17
+
18
+* `GET /images/{name}/attestations` is a new endpoint that returns the in-toto
19
+  attestation statements attached to an image. The `platform` query parameter
20
+  selects the image variant (defaults to the daemon's host platform) and the
21
+  `type` query parameter accepts a comma-separated list of predicate type URIs
22
+  to filter the returned statements. The `statement` query parameter (default
23
+  `false`) controls whether the verbatim statement body is included; when
24
+  omitted or `false`, each entry contains only the descriptor and predicate
25
+  type, and statement blobs are not read. The response is a JSON array of
26
+  `AttestationStatement` objects.
27
+
16 28
 ## v1.54 API changes
17 29
 
18 30
 * `GET /images/json` now supports an `identity` query parameter. When set,
... ...
@@ -8140,6 +8140,25 @@ definitions:
8140 8140
         additionalProperties:
8141 8141
           type: "string"
8142 8142
 
8143
+  AttestationStatement:
8144
+    x-go-name: "AttestationStatement"
8145
+    description: |
8146
+      AttestationStatement is a single in-toto statement attached to an image.
8147
+    type: "object"
8148
+    required: ["Descriptor", "PredicateType"]
8149
+    properties:
8150
+      Descriptor:
8151
+        $ref: "#/definitions/OCIDescriptor"
8152
+      PredicateType:
8153
+        description: The in-toto predicate type URI of this statement.
8154
+        type: "string"
8155
+        example: "https://slsa.dev/provenance/v0.2"
8156
+      Statement:
8157
+        description: |
8158
+          The verbatim in-toto statement JSON. Only included when the caller
8159
+          opts in via the `statement=true` query parameter; otherwise absent.
8160
+        type: "object"
8161
+        x-nullable: true
8143 8162
   ImageManifestSummary:
8144 8163
     x-go-name: "ManifestSummary"
8145 8164
     description: |
... ...
@@ -10157,6 +10176,82 @@ paths:
10157 10157
 
10158 10158
             Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
10159 10159
       tags: ["Image"]
10160
+  /images/{name}/attestations:
10161
+    get:
10162
+      summary: "Get attestation statements for an image"
10163
+      description: |
10164
+        Return the in-toto attestation statements attached to the image for the
10165
+        given platform. The daemon locates the attestation manifest(s) that
10166
+        reference the matching platform image manifest, reads their statement
10167
+        layers, and returns the verbatim statement JSON together with layer
10168
+        metadata.
10169
+
10170
+        If the image has no attestations an empty array is returned.
10171
+      operationId: "ImageAttestations"
10172
+      produces:
10173
+        - "application/json"
10174
+      responses:
10175
+        200:
10176
+          description: "No error"
10177
+          schema:
10178
+            type: "array"
10179
+            items:
10180
+              $ref: "#/definitions/AttestationStatement"
10181
+        400:
10182
+          description: "Bad parameter (e.g. malformed `platform` value)"
10183
+          schema:
10184
+            $ref: "#/definitions/ErrorResponse"
10185
+        404:
10186
+          description: "No such image, or no manifest found for the requested platform"
10187
+          schema:
10188
+            $ref: "#/definitions/ErrorResponse"
10189
+        500:
10190
+          description: "Server error"
10191
+          schema:
10192
+            $ref: "#/definitions/ErrorResponse"
10193
+        501:
10194
+          description: |
10195
+            The daemon's image backend does not support attestations. This
10196
+            is returned by the legacy (graphdriver) image store, which does
10197
+            not preserve OCI image indexes.
10198
+          schema:
10199
+            $ref: "#/definitions/ErrorResponse"
10200
+      parameters:
10201
+        - name: "name"
10202
+          in: "path"
10203
+          description: "Image name or id"
10204
+          type: "string"
10205
+          required: true
10206
+        - name: "platform"
10207
+          type: "string"
10208
+          in: "query"
10209
+          description: |-
10210
+            JSON-encoded OCI platform to select the image variant whose
10211
+            attestations to return.
10212
+            If omitted, the daemon's default (host) platform is used.
10213
+
10214
+            Example: `{"os": "linux", "architecture": "amd64"}`
10215
+          required: false
10216
+        - name: "type"
10217
+          type: "string"
10218
+          in: "query"
10219
+          description: |-
10220
+            Comma-separated list of in-toto predicate type URIs to filter
10221
+            returned statements. If omitted, all statements are returned.
10222
+
10223
+            Example: `https://slsa.dev/provenance/v0.2,https://spdx.dev/Document`
10224
+          required: false
10225
+        - name: "statement"
10226
+          type: "boolean"
10227
+          in: "query"
10228
+          description: |-
10229
+            Include the verbatim in-toto statement body in each returned
10230
+            entry. Defaults to false; when omitted or false, only the
10231
+            descriptor and predicate type are returned and statement blobs
10232
+            are not read.
10233
+          default: false
10234
+          required: false
10235
+      tags: ["Image"]
10160 10236
   /images/{name}/history:
10161 10237
     get:
10162 10238
       summary: "Get the history of an image"
10163 10239
new file mode 100644
... ...
@@ -0,0 +1,19 @@
0
+package image
1
+
2
+import (
3
+	"encoding/json"
4
+
5
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
6
+)
7
+
8
+// AttestationStatement is a single in-toto statement attached to an image.
9
+type AttestationStatement struct {
10
+	// Descriptor is the OCI descriptor of the statement blob (media type,
11
+	// digest, size, annotations).
12
+	Descriptor ocispec.Descriptor `json:"Descriptor"`
13
+	// PredicateType is the in-toto predicate type URI of this statement.
14
+	PredicateType string `json:"PredicateType"`
15
+	// Statement is the verbatim in-toto statement JSON. Omitted unless the
16
+	// caller opts in via the statement=true query parameter.
17
+	Statement *json.RawMessage `json:"Statement,omitempty"`
18
+}
... ...
@@ -133,6 +133,7 @@ type ImageAPIClient interface {
133 133
 
134 134
 	ImageInspect(ctx context.Context, image string, _ ...ImageInspectOption) (ImageInspectResult, error)
135 135
 	ImageHistory(ctx context.Context, image string, _ ...ImageHistoryOption) (ImageHistoryResult, error)
136
+	ImageAttestations(ctx context.Context, image string, _ ...ImageAttestationsOption) (ImageAttestationsResult, error)
136 137
 
137 138
 	ImageLoad(ctx context.Context, input io.Reader, _ ...ImageLoadOption) (ImageLoadResult, error)
138 139
 	ImageSave(ctx context.Context, images []string, _ ...ImageSaveOption) (ImageSaveResult, error)
139 140
new file mode 100644
... ...
@@ -0,0 +1,52 @@
0
+package client
1
+
2
+import (
3
+	"context"
4
+	"encoding/json"
5
+	"net/url"
6
+	"strings"
7
+)
8
+
9
+// ImageAttestations returns the in-toto attestation statements attached to an
10
+// image for the given platform. This requires API version 1.55 or higher.
11
+func (cli *Client) ImageAttestations(ctx context.Context, imageID string, opts ...ImageAttestationsOption) (ImageAttestationsResult, error) {
12
+	if imageID == "" {
13
+		return ImageAttestationsResult{}, objectNotFoundError{object: "image", id: imageID}
14
+	}
15
+
16
+	if err := cli.requiresVersion(ctx, "1.55", "attestations"); err != nil {
17
+		return ImageAttestationsResult{}, err
18
+	}
19
+
20
+	var o imageAttestationsOpts
21
+	for _, opt := range opts {
22
+		if err := opt.Apply(&o); err != nil {
23
+			return ImageAttestationsResult{}, err
24
+		}
25
+	}
26
+
27
+	query := url.Values{}
28
+	if o.platform != nil {
29
+		p, err := encodePlatform(o.platform)
30
+		if err != nil {
31
+			return ImageAttestationsResult{}, err
32
+		}
33
+		query.Set("platform", p)
34
+	}
35
+	if len(o.predicateTypes) > 0 {
36
+		query.Set("type", strings.Join(o.predicateTypes, ","))
37
+	}
38
+	if o.includeStatement {
39
+		query.Set("statement", "1")
40
+	}
41
+
42
+	resp, err := cli.get(ctx, "/images/"+imageID+"/attestations", query, nil)
43
+	defer ensureReaderClosed(resp)
44
+	if err != nil {
45
+		return ImageAttestationsResult{}, err
46
+	}
47
+
48
+	var result ImageAttestationsResult
49
+	err = json.NewDecoder(resp.Body).Decode(&result.Items)
50
+	return result, err
51
+}
0 52
new file mode 100644
... ...
@@ -0,0 +1,56 @@
0
+package client
1
+
2
+import (
3
+	"github.com/moby/moby/api/types/image"
4
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
5
+)
6
+
7
+// ImageAttestationsResult is the result of an ImageAttestations operation.
8
+type ImageAttestationsResult struct {
9
+	Items []image.AttestationStatement
10
+}
11
+
12
+// ImageAttestationsOption is a functional option for the ImageAttestations operation.
13
+type ImageAttestationsOption interface {
14
+	Apply(*imageAttestationsOpts) error
15
+}
16
+
17
+type imageAttestationsOptionFunc func(*imageAttestationsOpts) error
18
+
19
+func (f imageAttestationsOptionFunc) Apply(o *imageAttestationsOpts) error { return f(o) }
20
+
21
+type imageAttestationsOpts struct {
22
+	platform         *ocispec.Platform
23
+	predicateTypes   []string
24
+	includeStatement bool
25
+}
26
+
27
+// ImageAttestationsWithPlatform filters attestations to those for the given
28
+// platform variant. If omitted, the daemon's default platform is used.
29
+func ImageAttestationsWithPlatform(platform ocispec.Platform) ImageAttestationsOption {
30
+	return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
31
+		o.platform = &platform
32
+		return nil
33
+	})
34
+}
35
+
36
+// ImageAttestationsWithPredicateTypes filters returned statements to those
37
+// whose in-toto predicate type matches one of the given URIs.
38
+// If not set, all statements are returned.
39
+func ImageAttestationsWithPredicateTypes(types ...string) ImageAttestationsOption {
40
+	return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
41
+		o.predicateTypes = append(o.predicateTypes, types...)
42
+		return nil
43
+	})
44
+}
45
+
46
+// ImageAttestationsWithStatement asks the daemon to include the verbatim
47
+// in-toto statement body in each returned entry. Without this option, only
48
+// the descriptor and predicate type are returned and statement blobs are
49
+// not read.
50
+func ImageAttestationsWithStatement() ImageAttestationsOption {
51
+	return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
52
+		o.includeStatement = true
53
+		return nil
54
+	})
55
+}
0 56
new file mode 100644
... ...
@@ -0,0 +1,125 @@
0
+package containerd
1
+
2
+import (
3
+	"context"
4
+	"encoding/json"
5
+	"slices"
6
+
7
+	"github.com/containerd/containerd/v2/core/content"
8
+	"github.com/containerd/containerd/v2/core/remotes"
9
+	cerrdefs "github.com/containerd/errdefs"
10
+	imagetypes "github.com/moby/moby/api/types/image"
11
+	"github.com/moby/moby/v2/daemon/server/imagebackend"
12
+	"github.com/moby/moby/v2/errdefs"
13
+	policyimage "github.com/moby/policy-helpers/image"
14
+	"github.com/opencontainers/go-digest"
15
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
16
+)
17
+
18
+// inTotoPredicateTypeAnnotation is the OCI layer annotation key that BuildKit
19
+// uses to record the in-toto predicate type of a statement layer inside an
20
+// attestation manifest.
21
+const inTotoPredicateTypeAnnotation = "in-toto.io/predicate-type"
22
+
23
+// localReferrersProvider adapts a local content.Provider to the
24
+// policyimage.ReferrersProvider interface. FetchReferrers is a no-op because
25
+// moby's local content store cannot resolve OCI referrers; this means DHI image
26
+// resolution and Sigstore signature manifest discovery are not supported.
27
+// Standard BuildKit attestations are attached as sibling manifests in the
28
+// image index and do not require FetchReferrers.
29
+type localReferrersProvider struct {
30
+	content.Provider
31
+}
32
+
33
+func (p *localReferrersProvider) FetchReferrers(ctx context.Context, dgst digest.Digest, opts ...remotes.FetchReferrersOpt) ([]ocispec.Descriptor, error) {
34
+	return nil, nil
35
+}
36
+
37
+// ImageAttestations returns the in-toto attestation statements attached to the
38
+// given image for the specified platform.
39
+//
40
+// The chain walk (locating the image manifest for the platform and the
41
+// associated attestation manifest) is delegated to
42
+// policyimage.ResolveSignatureChain so that moby and BuildKit agree on how to
43
+// interpret the attestation storage format. The statement-layer iteration and
44
+// blob reading is inlined: when statement bodies are requested it fails fast
45
+// on the first unreadable blob, reads matching blobs eagerly into memory, and
46
+// produces AttestationStatement values directly for
47
+// GET /images/{name}/attestations.
48
+//
49
+// Behaviour:
50
+//   - If the image is not an OCI index (no possibility of sibling attestation
51
+//     manifests), returns (nil, nil).
52
+//   - If the requested platform has no matching image manifest, returns
53
+//     errdefs.NotFound.
54
+//   - If the platform image manifest has no associated attestation manifest,
55
+//     returns (nil, nil).
56
+//   - Layers without an in-toto.io/predicate-type annotation are skipped.
57
+//   - When predicateTypes is empty, all annotated statement layers are
58
+//     returned. When non-empty, only matching ones are returned. If no
59
+//     layers match, returns (nil, nil).
60
+//   - When IncludeStatement is true the statement blob is read and attached
61
+//     to each returned entry; a read failure propagates as an error. When
62
+//     false, statement blobs are never read and the Statement field is left
63
+//     nil.
64
+func (i *ImageService) ImageAttestations(ctx context.Context, refOrID string, opts imagebackend.AttestationOpts) ([]imagetypes.AttestationStatement, error) {
65
+	img, err := i.resolveImage(ctx, refOrID)
66
+	if err != nil {
67
+		return nil, err
68
+	}
69
+
70
+	// Only image indexes can carry sibling attestation manifests. This matches
71
+	// the entry check in policyimage.ResolveSignatureChain, which rejects any other
72
+	// media type (see github.com/moby/policy-helpers/blob/main/image/resolve.go).
73
+	if img.Target.MediaType != ocispec.MediaTypeImageIndex {
74
+		return nil, nil
75
+	}
76
+
77
+	prov := &localReferrersProvider{Provider: i.content}
78
+
79
+	sc, err := policyimage.ResolveSignatureChain(ctx, prov, img.Target, opts.Platform)
80
+	if err != nil {
81
+		if cerrdefs.IsNotFound(err) {
82
+			return nil, errdefs.NotFound(err)
83
+		}
84
+		return nil, err
85
+	}
86
+
87
+	if sc.AttestationManifest == nil {
88
+		return nil, nil
89
+	}
90
+
91
+	mfst, err := sc.OCIManifest(ctx, sc.AttestationManifest)
92
+	if err != nil {
93
+		return nil, err
94
+	}
95
+
96
+	var statements []imagetypes.AttestationStatement
97
+	for _, layer := range mfst.Layers {
98
+		predicateType := layer.Annotations[inTotoPredicateTypeAnnotation]
99
+		if predicateType == "" {
100
+			continue
101
+		}
102
+		if len(opts.PredicateTypes) > 0 && !slices.Contains(opts.PredicateTypes, predicateType) {
103
+			continue
104
+		}
105
+		stmt := imagetypes.AttestationStatement{
106
+			Descriptor:    layer,
107
+			PredicateType: predicateType,
108
+		}
109
+		if opts.IncludeStatement {
110
+			data, err := content.ReadBlob(ctx, i.content, layer)
111
+			if err != nil {
112
+				return nil, err
113
+			}
114
+			raw := json.RawMessage(data)
115
+			stmt.Statement = &raw
116
+		}
117
+		statements = append(statements, stmt)
118
+	}
119
+
120
+	if len(statements) == 0 {
121
+		return nil, nil
122
+	}
123
+	return statements, nil
124
+}
0 125
new file mode 100644
... ...
@@ -0,0 +1,458 @@
0
+package containerd
1
+
2
+import (
3
+	"encoding/json"
4
+	"os"
5
+	"path/filepath"
6
+	"testing"
7
+
8
+	c8dimages "github.com/containerd/containerd/v2/core/images"
9
+	"github.com/containerd/containerd/v2/pkg/namespaces"
10
+	"github.com/containerd/log/logtest"
11
+	"github.com/distribution/reference"
12
+	"github.com/moby/buildkit/util/attestation"
13
+	imagetypes "github.com/moby/moby/api/types/image"
14
+	"github.com/moby/moby/v2/daemon/server/imagebackend"
15
+	"github.com/opencontainers/go-digest"
16
+	"github.com/opencontainers/image-spec/specs-go"
17
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
18
+	"gotest.tools/v3/assert"
19
+	is "gotest.tools/v3/assert/cmp"
20
+)
21
+
22
+// normalizeRef normalizes an image reference to the canonical form that
23
+// resolveImage expects (e.g. "test:latest" → "docker.io/library/test:latest").
24
+func normalizeRef(t *testing.T, name string) string {
25
+	t.Helper()
26
+	ref, err := reference.ParseNormalizedNamed(name)
27
+	assert.NilError(t, err)
28
+	return reference.TagNameOnly(ref).String()
29
+}
30
+
31
+// provBlob writes content to dir/blobs/sha256/<digest> and returns its descriptor.
32
+func provBlob(t *testing.T, dir, mt string, data []byte) ocispec.Descriptor {
33
+	t.Helper()
34
+	sha256Dir := filepath.Join(dir, "blobs", "sha256")
35
+	assert.NilError(t, os.MkdirAll(sha256Dir, 0o755))
36
+	dgst := digest.FromBytes(data)
37
+	assert.NilError(t, os.WriteFile(filepath.Join(sha256Dir, dgst.Encoded()), data, 0o644))
38
+	return ocispec.Descriptor{MediaType: mt, Digest: dgst, Size: int64(len(data))}
39
+}
40
+
41
+// provJSON marshals v and writes it as a blob.
42
+func provJSON(t *testing.T, dir, mt string, v any) ocispec.Descriptor {
43
+	t.Helper()
44
+	b, err := json.Marshal(v)
45
+	assert.NilError(t, err)
46
+	return provBlob(t, dir, mt, b)
47
+}
48
+
49
+// attestationLayer describes one layer of an attestation manifest.
50
+// When predicateType is empty the layer carries no in-toto annotation.
51
+type attestationLayer struct {
52
+	predicateType string
53
+	content       []byte
54
+}
55
+
56
+// buildFullIndex writes a complete OCI image index containing a real platform
57
+// image manifest and an attestation manifest for it. The platform manifest is
58
+// always written to the content store; attestation layer blobs are written only
59
+// when writeLayerBlobs is true. Returns the index descriptor, the platform
60
+// image manifest descriptor, and the attestation manifest descriptor.
61
+func buildFullIndex(t *testing.T, dir string, platform ocispec.Platform, stmts []attestationLayer, writeLayerBlobs bool) (ocispec.Descriptor, ocispec.Descriptor) {
62
+	t.Helper()
63
+
64
+	// Minimal platform image manifest (config + no layers).
65
+	imgConfig := map[string]any{
66
+		"os":           platform.OS,
67
+		"architecture": platform.Architecture,
68
+	}
69
+	configDesc := provJSON(t, dir, ocispec.MediaTypeImageConfig, imgConfig)
70
+	imgMfst := ocispec.Manifest{
71
+		Versioned: specs.Versioned{SchemaVersion: 2},
72
+		MediaType: ocispec.MediaTypeImageManifest,
73
+		Config:    configDesc,
74
+		Layers:    []ocispec.Descriptor{},
75
+	}
76
+	imgMfstDesc := provJSON(t, dir, ocispec.MediaTypeImageManifest, imgMfst)
77
+	imgMfstDesc.Platform = &platform
78
+
79
+	// Build attestation layer descriptors.
80
+	var layerDescs []ocispec.Descriptor
81
+	for _, s := range stmts {
82
+		var desc ocispec.Descriptor
83
+		if writeLayerBlobs {
84
+			desc = provBlob(t, dir, "application/vnd.in-toto+json", s.content)
85
+		} else {
86
+			dgst := digest.FromBytes(s.content)
87
+			desc = ocispec.Descriptor{
88
+				MediaType: "application/vnd.in-toto+json",
89
+				Digest:    dgst,
90
+				Size:      int64(len(s.content)),
91
+			}
92
+		}
93
+		if s.predicateType != "" {
94
+			desc.Annotations = map[string]string{
95
+				"in-toto.io/predicate-type": s.predicateType,
96
+			}
97
+		}
98
+		layerDescs = append(layerDescs, desc)
99
+	}
100
+
101
+	// Attestation manifest.
102
+	attConfig := provBlob(t, dir, ocispec.MediaTypeImageConfig, []byte(`{}`))
103
+	attMfst := ocispec.Manifest{
104
+		Versioned: specs.Versioned{SchemaVersion: 2},
105
+		MediaType: ocispec.MediaTypeImageManifest,
106
+		Config:    attConfig,
107
+		Layers:    layerDescs,
108
+	}
109
+	attMfstDesc := provJSON(t, dir, ocispec.MediaTypeImageManifest, attMfst)
110
+	attMfstDesc.Annotations = map[string]string{
111
+		attestation.DockerAnnotationReferenceType:   attestation.DockerAnnotationReferenceTypeDefault,
112
+		attestation.DockerAnnotationReferenceDigest: imgMfstDesc.Digest.String(),
113
+	}
114
+	attMfstDesc.Platform = &ocispec.Platform{OS: "unknown", Architecture: "unknown"}
115
+
116
+	// Index.
117
+	idx := ocispec.Index{
118
+		Versioned: specs.Versioned{SchemaVersion: 2},
119
+		MediaType: ocispec.MediaTypeImageIndex,
120
+		Manifests: []ocispec.Descriptor{imgMfstDesc, attMfstDesc},
121
+	}
122
+	idxDesc := provJSON(t, dir, ocispec.MediaTypeImageIndex, idx)
123
+	return idxDesc, imgMfstDesc
124
+}
125
+
126
+// buildAttestationIndex writes a minimal OCI image index containing a single
127
+// attestation manifest whose layers are given by stmts. The index blob and the
128
+// attestation manifest blob are always written to dir; layer blobs are written
129
+// only when writeLayerBlobs is true (pass false to simulate unavailable content).
130
+// Returns the index descriptor (suitable for registering with an image store) and
131
+// the synthetic platform-image digest referenced by the attestation.
132
+//
133
+// This helper produces an index WITHOUT a real image manifest, which is useful
134
+// for verifying that AttestationData.For is set correctly in image listings.
135
+func buildAttestationIndex(t *testing.T, dir string, stmts []attestationLayer, writeLayerBlobs bool) (ocispec.Descriptor, digest.Digest) {
136
+	t.Helper()
137
+
138
+	// Minimal empty config for the attestation manifest.
139
+	configDesc := provBlob(t, dir, ocispec.MediaTypeImageConfig, []byte(`{}`))
140
+
141
+	// Build layer descriptors; blobs are written only when writeLayerBlobs is set.
142
+	var layerDescs []ocispec.Descriptor
143
+	for _, s := range stmts {
144
+		var desc ocispec.Descriptor
145
+		if writeLayerBlobs {
146
+			desc = provBlob(t, dir, "application/vnd.in-toto+json", s.content)
147
+		} else {
148
+			dgst := digest.FromBytes(s.content)
149
+			desc = ocispec.Descriptor{
150
+				MediaType: "application/vnd.in-toto+json",
151
+				Digest:    dgst,
152
+				Size:      int64(len(s.content)),
153
+			}
154
+		}
155
+		if s.predicateType != "" {
156
+			desc.Annotations = map[string]string{
157
+				"in-toto.io/predicate-type": s.predicateType,
158
+			}
159
+		}
160
+		layerDescs = append(layerDescs, desc)
161
+	}
162
+
163
+	// Write the attestation manifest blob.
164
+	attMfst := ocispec.Manifest{
165
+		Versioned: specs.Versioned{SchemaVersion: 2},
166
+		MediaType: ocispec.MediaTypeImageManifest,
167
+		Config:    configDesc,
168
+		Layers:    layerDescs,
169
+	}
170
+	attMfstDesc := provJSON(t, dir, ocispec.MediaTypeImageManifest, attMfst)
171
+
172
+	// Synthetic platform manifest digest — it need not be in the content store.
173
+	platformDigest := digest.FromString("platform-manifest-placeholder")
174
+
175
+	// Annotate the attestation manifest descriptor for the index.
176
+	attMfstDesc.Annotations = map[string]string{
177
+		attestation.DockerAnnotationReferenceType:   attestation.DockerAnnotationReferenceTypeDefault,
178
+		attestation.DockerAnnotationReferenceDigest: platformDigest.String(),
179
+	}
180
+	attMfstDesc.Platform = &ocispec.Platform{OS: "unknown", Architecture: "unknown"}
181
+
182
+	// Write the index blob.
183
+	idx := ocispec.Index{
184
+		Versioned: specs.Versioned{SchemaVersion: 2},
185
+		MediaType: ocispec.MediaTypeImageIndex,
186
+		Manifests: []ocispec.Descriptor{attMfstDesc},
187
+	}
188
+	idxDesc := provJSON(t, dir, ocispec.MediaTypeImageIndex, idx)
189
+	idxDesc.Annotations = map[string]string{
190
+		"io.containerd.image.name": "test:latest",
191
+	}
192
+
193
+	return idxDesc, platformDigest
194
+}
195
+
196
+// findAttestationManifest returns the first attestation-kind manifest summary in
197
+// manifests, or fails the test if none is found.
198
+func findAttestationManifest(t *testing.T, manifests []imagetypes.ManifestSummary) imagetypes.ManifestSummary {
199
+	t.Helper()
200
+	for _, m := range manifests {
201
+		if m.Kind == imagetypes.ManifestKindAttestation {
202
+			return m
203
+		}
204
+	}
205
+	t.Fatal("no attestation manifest found in image summary")
206
+	return imagetypes.ManifestSummary{}
207
+}
208
+
209
+// TestAttestationDataFor verifies that the AttestationData.For field in the
210
+// image list response is set to the digest of the image manifest the
211
+// attestation is for.
212
+func TestAttestationDataFor(t *testing.T) {
213
+	ctx := namespaces.WithNamespace(t.Context(), "testing")
214
+	ctx = logtest.WithT(ctx, t)
215
+
216
+	dir := t.TempDir()
217
+	idxDesc, platformDigest := buildAttestationIndex(t, dir, []attestationLayer{
218
+		{predicateType: "https://slsa.dev/provenance/v0.2", content: []byte(`{}`)},
219
+	}, true)
220
+
221
+	cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
222
+	svc := fakeImageService(t, ctx, cs)
223
+	_, err := svc.images.Create(ctx, c8dimages.Image{Name: "test:latest", Target: idxDesc})
224
+	assert.NilError(t, err)
225
+
226
+	all, err := svc.Images(ctx, imagebackend.ListOptions{Manifests: true})
227
+	assert.NilError(t, err)
228
+	assert.Assert(t, is.Len(all, 1))
229
+
230
+	m := findAttestationManifest(t, all[0].Manifests)
231
+	assert.Assert(t, m.AttestationData != nil)
232
+	assert.Check(t, is.Equal(m.AttestationData.For, platformDigest))
233
+}
234
+
235
+// TestImageAttestations exercises the ImageAttestations method with various
236
+// statement counts, predicate type filters, and error conditions.
237
+func TestImageAttestations(t *testing.T) {
238
+	linuxAMD64 := ocispec.Platform{OS: "linux", Architecture: "amd64"}
239
+
240
+	t.Run("single_statement", func(t *testing.T) {
241
+		ctx := namespaces.WithNamespace(t.Context(), "testing")
242
+		ctx = logtest.WithT(ctx, t)
243
+
244
+		dir := t.TempDir()
245
+		stmtData, _ := json.Marshal(map[string]any{
246
+			"predicateType": "https://slsa.dev/provenance/v0.2",
247
+			"predicate": map[string]any{
248
+				"materials": []map[string]any{
249
+					{"uri": "pkg:docker/library/alpine@3.18"},
250
+				},
251
+			},
252
+		})
253
+		idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
254
+			{predicateType: "https://slsa.dev/provenance/v0.2", content: stmtData},
255
+		}, true)
256
+
257
+		cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
258
+		svc := fakeImageService(t, ctx, cs)
259
+		imgName := normalizeRef(t, "test:latest")
260
+		_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
261
+		assert.NilError(t, err)
262
+
263
+		stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
264
+			Platform:         &linuxAMD64,
265
+			IncludeStatement: true,
266
+		})
267
+		assert.NilError(t, err)
268
+		assert.Assert(t, is.Len(stmts, 1))
269
+		assert.Check(t, is.Equal(stmts[0].PredicateType, "https://slsa.dev/provenance/v0.2"))
270
+		assert.Assert(t, stmts[0].Statement != nil)
271
+
272
+		var parsed struct {
273
+			PredicateType string `json:"predicateType"`
274
+			Predicate     struct {
275
+				Materials []struct {
276
+					URI string `json:"uri"`
277
+				} `json:"materials"`
278
+			} `json:"predicate"`
279
+		}
280
+		assert.NilError(t, json.Unmarshal(*stmts[0].Statement, &parsed))
281
+		assert.Check(t, is.Equal(parsed.PredicateType, "https://slsa.dev/provenance/v0.2"))
282
+		assert.Check(t, is.Equal(len(parsed.Predicate.Materials), 1))
283
+		assert.Check(t, is.Equal(parsed.Predicate.Materials[0].URI, "pkg:docker/library/alpine@3.18"))
284
+	})
285
+
286
+	t.Run("default_omits_statement_body", func(t *testing.T) {
287
+		ctx := namespaces.WithNamespace(t.Context(), "testing")
288
+		ctx = logtest.WithT(ctx, t)
289
+
290
+		dir := t.TempDir()
291
+		stmtData, _ := json.Marshal(map[string]any{"predicateType": "https://slsa.dev/provenance/v0.2"})
292
+		idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
293
+			{predicateType: "https://slsa.dev/provenance/v0.2", content: stmtData},
294
+		}, false /* layer blobs absent — proves we never read them */)
295
+
296
+		cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
297
+		svc := fakeImageService(t, ctx, cs)
298
+		imgName := normalizeRef(t, "test:latest")
299
+		_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
300
+		assert.NilError(t, err)
301
+
302
+		stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
303
+			Platform: &linuxAMD64,
304
+		})
305
+		assert.NilError(t, err)
306
+		assert.Assert(t, is.Len(stmts, 1))
307
+		assert.Check(t, is.Equal(stmts[0].PredicateType, "https://slsa.dev/provenance/v0.2"))
308
+		assert.Check(t, stmts[0].Descriptor.Digest != "", "descriptor should be populated")
309
+		assert.Check(t, stmts[0].Statement == nil, "statement body should be omitted by default")
310
+	})
311
+
312
+	t.Run("multiple_statements_order_preserved", func(t *testing.T) {
313
+		ctx := namespaces.WithNamespace(t.Context(), "testing")
314
+		ctx = logtest.WithT(ctx, t)
315
+
316
+		dir := t.TempDir()
317
+		v02Data, _ := json.Marshal(map[string]any{"predicateType": "https://slsa.dev/provenance/v0.2"})
318
+		v1Data, _ := json.Marshal(map[string]any{"predicateType": "https://slsa.dev/provenance/v1"})
319
+
320
+		idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
321
+			{predicateType: "https://slsa.dev/provenance/v0.2", content: v02Data},
322
+			{predicateType: "https://slsa.dev/provenance/v1", content: v1Data},
323
+		}, true)
324
+
325
+		cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
326
+		svc := fakeImageService(t, ctx, cs)
327
+		imgName := normalizeRef(t, "test:latest")
328
+		_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
329
+		assert.NilError(t, err)
330
+
331
+		stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
332
+			Platform: &linuxAMD64,
333
+		})
334
+		assert.NilError(t, err)
335
+		assert.Assert(t, is.Len(stmts, 2))
336
+		assert.Check(t, is.Equal(stmts[0].PredicateType, "https://slsa.dev/provenance/v0.2"))
337
+		assert.Check(t, is.Equal(stmts[1].PredicateType, "https://slsa.dev/provenance/v1"))
338
+	})
339
+
340
+	t.Run("predicate_type_filter", func(t *testing.T) {
341
+		ctx := namespaces.WithNamespace(t.Context(), "testing")
342
+		ctx = logtest.WithT(ctx, t)
343
+
344
+		dir := t.TempDir()
345
+		v02Data, _ := json.Marshal(map[string]any{"predicateType": "https://slsa.dev/provenance/v0.2"})
346
+		sbomData, _ := json.Marshal(map[string]any{"predicateType": "https://spdx.dev/Document"})
347
+
348
+		idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
349
+			{predicateType: "https://slsa.dev/provenance/v0.2", content: v02Data},
350
+			{predicateType: "https://spdx.dev/Document", content: sbomData},
351
+		}, true)
352
+
353
+		cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
354
+		svc := fakeImageService(t, ctx, cs)
355
+		imgName := normalizeRef(t, "test:latest")
356
+		_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
357
+		assert.NilError(t, err)
358
+
359
+		stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
360
+			Platform:       &linuxAMD64,
361
+			PredicateTypes: []string{"https://slsa.dev/provenance/v0.2"},
362
+		})
363
+		assert.NilError(t, err)
364
+		assert.Assert(t, is.Len(stmts, 1))
365
+		assert.Check(t, is.Equal(stmts[0].PredicateType, "https://slsa.dev/provenance/v0.2"))
366
+	})
367
+
368
+	t.Run("layer_without_predicate_type_skipped", func(t *testing.T) {
369
+		ctx := namespaces.WithNamespace(t.Context(), "testing")
370
+		ctx = logtest.WithT(ctx, t)
371
+
372
+		dir := t.TempDir()
373
+		annotatedData, _ := json.Marshal(map[string]any{"predicateType": "https://slsa.dev/provenance/v0.2"})
374
+
375
+		idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
376
+			{predicateType: "", content: []byte(`{"some":"other"}`)},
377
+			{predicateType: "https://slsa.dev/provenance/v0.2", content: annotatedData},
378
+		}, true)
379
+
380
+		cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
381
+		svc := fakeImageService(t, ctx, cs)
382
+		imgName := normalizeRef(t, "test:latest")
383
+		_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
384
+		assert.NilError(t, err)
385
+
386
+		stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
387
+			Platform: &linuxAMD64,
388
+		})
389
+		assert.NilError(t, err)
390
+		assert.Assert(t, is.Len(stmts, 1), "only layers with in-toto annotation should be returned")
391
+		assert.Check(t, is.Equal(stmts[0].PredicateType, "https://slsa.dev/provenance/v0.2"))
392
+	})
393
+
394
+	t.Run("unavailable_blobs_return_error", func(t *testing.T) {
395
+		ctx := namespaces.WithNamespace(t.Context(), "testing")
396
+		ctx = logtest.WithT(ctx, t)
397
+
398
+		dir := t.TempDir()
399
+		stmtData := []byte(`{"predicateType":"https://slsa.dev/provenance/v0.2"}`)
400
+
401
+		idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
402
+			{predicateType: "https://slsa.dev/provenance/v0.2", content: stmtData},
403
+		}, false /* layer blobs absent */)
404
+
405
+		cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
406
+		svc := fakeImageService(t, ctx, cs)
407
+		imgName := normalizeRef(t, "test:latest")
408
+		_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
409
+		assert.NilError(t, err)
410
+
411
+		// When a statement layer blob is not locally present we surface the
412
+		// error rather than silently skipping. This matches BuildKit's
413
+		// ResolveAttestations behavior via the shared policy-helpers library.
414
+		_, err = svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
415
+			Platform:         &linuxAMD64,
416
+			IncludeStatement: true,
417
+		})
418
+		assert.ErrorContains(t, err, "not found")
419
+	})
420
+
421
+	t.Run("no_attestations_returns_nil", func(t *testing.T) {
422
+		ctx := namespaces.WithNamespace(t.Context(), "testing")
423
+		ctx = logtest.WithT(ctx, t)
424
+
425
+		dir := t.TempDir()
426
+		// Index with only an image manifest, no attestation manifest.
427
+		imgConfig := map[string]any{"os": "linux", "architecture": "amd64"}
428
+		configDesc := provJSON(t, dir, ocispec.MediaTypeImageConfig, imgConfig)
429
+		imgMfst := ocispec.Manifest{
430
+			Versioned: specs.Versioned{SchemaVersion: 2},
431
+			MediaType: ocispec.MediaTypeImageManifest,
432
+			Config:    configDesc,
433
+			Layers:    []ocispec.Descriptor{},
434
+		}
435
+		imgMfstDesc := provJSON(t, dir, ocispec.MediaTypeImageManifest, imgMfst)
436
+		imgMfstDesc.Platform = &linuxAMD64
437
+
438
+		idx := ocispec.Index{
439
+			Versioned: specs.Versioned{SchemaVersion: 2},
440
+			MediaType: ocispec.MediaTypeImageIndex,
441
+			Manifests: []ocispec.Descriptor{imgMfstDesc},
442
+		}
443
+		idxDesc := provJSON(t, dir, ocispec.MediaTypeImageIndex, idx)
444
+
445
+		cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
446
+		svc := fakeImageService(t, ctx, cs)
447
+		imgName := normalizeRef(t, "test:latest")
448
+		_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
449
+		assert.NilError(t, err)
450
+
451
+		stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
452
+			Platform: &linuxAMD64,
453
+		})
454
+		assert.NilError(t, err)
455
+		assert.Check(t, is.Nil(stmts))
456
+	})
457
+}
... ...
@@ -44,6 +44,7 @@ type ImageService interface {
44 44
 	CommitImage(ctx context.Context, c backend.CommitConfig) (image.ID, error)
45 45
 	SquashImage(id, parent string) (string, error)
46 46
 	ImageInspect(ctx context.Context, refOrID string, opts imagebackend.ImageInspectOpts) (*imagebackend.InspectData, error)
47
+	ImageAttestations(ctx context.Context, refOrID string, opts imagebackend.AttestationOpts) ([]imagetype.AttestationStatement, error)
47 48
 	ImageDiskUsage(ctx context.Context) (int64, error)
48 49
 
49 50
 	// Layers
50 51
new file mode 100644
... ...
@@ -0,0 +1,15 @@
0
+package images
1
+
2
+import (
3
+	"context"
4
+	"errors"
5
+
6
+	imagetypes "github.com/moby/moby/api/types/image"
7
+	"github.com/moby/moby/v2/daemon/server/imagebackend"
8
+	"github.com/moby/moby/v2/errdefs"
9
+)
10
+
11
+// ImageAttestations is not supported by the legacy image store.
12
+func (i *ImageService) ImageAttestations(_ context.Context, _ string, _ imagebackend.AttestationOpts) ([]imagetypes.AttestationStatement, error) {
13
+	return nil, errdefs.NotImplemented(errors.New("the legacy image store does not support attestations"))
14
+}
... ...
@@ -63,6 +63,23 @@ type ImageInspectOpts struct {
63 63
 	Platform  *ocispec.Platform
64 64
 }
65 65
 
66
+// AttestationOpts holds parameters for retrieving image attestations.
67
+type AttestationOpts struct {
68
+	// Platform selects the image variant whose attestations to return.
69
+	// If nil, the daemon's default platform is used.
70
+	Platform *ocispec.Platform
71
+
72
+	// PredicateTypes filters returned statements to those with a matching
73
+	// in-toto predicate type. An empty slice returns all statements.
74
+	PredicateTypes []string
75
+
76
+	// IncludeStatement controls whether the verbatim in-toto statement
77
+	// body is read from the content store and returned. When false, only
78
+	// the descriptor and predicate type are populated and statement blobs
79
+	// are never read.
80
+	IncludeStatement bool
81
+}
82
+
66 83
 type InspectData struct {
67 84
 	imagetypes.InspectResponse
68 85
 
... ...
@@ -27,6 +27,7 @@ type imageBackend interface {
27 27
 	Images(ctx context.Context, opts imagebackend.ListOptions) ([]image.Summary, error)
28 28
 	GetImage(ctx context.Context, refOrID string, options imagebackend.GetImageOpts) (*dockerimage.Image, error)
29 29
 	ImageInspect(ctx context.Context, refOrID string, options imagebackend.ImageInspectOpts) (*imagebackend.InspectData, error)
30
+	ImageAttestations(ctx context.Context, refOrID string, opts imagebackend.AttestationOpts) ([]image.AttestationStatement, error)
30 31
 	TagImage(ctx context.Context, id dockerimage.ID, newRef reference.Named) error
31 32
 	ImagePrune(ctx context.Context, pruneFilters filters.Args) (*image.PruneReport, error)
32 33
 }
... ...
@@ -36,6 +36,7 @@ func (ir *imageRouter) initRoutes() {
36 36
 		router.NewGetRoute("/images/{name:.*}/get", ir.getImagesGet),
37 37
 		router.NewGetRoute("/images/{name:.*}/history", ir.getImagesHistory),
38 38
 		router.NewGetRoute("/images/{name:.*}/json", ir.getImagesByName),
39
+		router.NewGetRoute("/images/{name:.*}/attestations", ir.getImageAttestations, router.WithMinimumAPIVersion("1.55")),
39 40
 		// POST
40 41
 		router.NewPostRoute("/images/load", ir.postImagesLoad),
41 42
 		router.NewPostRoute("/images/create", ir.postImagesCreate),
... ...
@@ -13,6 +13,7 @@ import (
13 13
 	"github.com/containerd/platforms"
14 14
 	"github.com/distribution/reference"
15 15
 	"github.com/moby/moby/api/pkg/authconfig"
16
+	imagetypes "github.com/moby/moby/api/types/image"
16 17
 	"github.com/moby/moby/api/types/registry"
17 18
 	"github.com/moby/moby/v2/daemon/builder/remotecontext"
18 19
 	"github.com/moby/moby/v2/daemon/internal/compat"
... ...
@@ -657,6 +658,45 @@ func (ir *imageRouter) postImagesPrune(ctx context.Context, w http.ResponseWrite
657 657
 	return httputils.WriteJSON(w, http.StatusOK, pruneReport)
658 658
 }
659 659
 
660
+func (ir *imageRouter) getImageAttestations(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
661
+	if err := httputils.ParseForm(r); err != nil {
662
+		return err
663
+	}
664
+
665
+	var platform *ocispec.Platform
666
+	if p := r.Form.Get("platform"); p != "" {
667
+		decoded, err := httputils.DecodePlatform(p)
668
+		if err != nil {
669
+			return errdefs.InvalidParameter(err)
670
+		}
671
+		platform = decoded
672
+	}
673
+
674
+	var predicateTypes []string
675
+	if t := r.Form.Get("type"); t != "" {
676
+		for _, pt := range strings.Split(t, ",") {
677
+			if pt = strings.TrimSpace(pt); pt != "" {
678
+				predicateTypes = append(predicateTypes, pt)
679
+			}
680
+		}
681
+	}
682
+
683
+	statements, err := ir.backend.ImageAttestations(ctx, vars["name"], imagebackend.AttestationOpts{
684
+		Platform:         platform,
685
+		PredicateTypes:   predicateTypes,
686
+		IncludeStatement: httputils.BoolValue(r, "statement"),
687
+	})
688
+	if err != nil {
689
+		return err
690
+	}
691
+
692
+	if statements == nil {
693
+		statements = []imagetypes.AttestationStatement{}
694
+	}
695
+
696
+	return httputils.WriteJSON(w, http.StatusOK, statements)
697
+}
698
+
660 699
 // noBaseImageSpecifier is the symbol used by the FROM
661 700
 // command to specify that no base image is to be used.
662 701
 const noBaseImageSpecifier = "scratch"
663 702
new file mode 100644
... ...
@@ -0,0 +1,251 @@
0
+package image
1
+
2
+import (
3
+	"encoding/json"
4
+	"os"
5
+	"path/filepath"
6
+	"testing"
7
+
8
+	cerrdefs "github.com/containerd/errdefs"
9
+	"github.com/containerd/platforms"
10
+	"github.com/distribution/reference"
11
+	"github.com/moby/buildkit/util/attestation"
12
+	"github.com/moby/moby/client"
13
+	iimage "github.com/moby/moby/v2/integration/internal/image"
14
+	"github.com/opencontainers/go-digest"
15
+	"github.com/opencontainers/image-spec/specs-go"
16
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
17
+	"gotest.tools/v3/assert"
18
+	is "gotest.tools/v3/assert/cmp"
19
+	"gotest.tools/v3/skip"
20
+)
21
+
22
+// TestImageAttestations exercises GET /images/{name}/attestations against a
23
+// live daemon.
24
+func TestImageAttestations(t *testing.T) {
25
+	skip.If(t, !testEnv.UsingSnapshotter(), "attestation manifests require the containerd image store")
26
+
27
+	ctx := setupTest(t)
28
+	apiClient := testEnv.APIClient()
29
+
30
+	hostPlatform := platforms.DefaultSpec()
31
+	const imageRef = "attestation-test:latest"
32
+	const provenanceType = "https://slsa.dev/provenance/v0.2"
33
+	const sbomType = "https://spdx.dev/Document"
34
+
35
+	// Load an image that has both a SLSA provenance and an SPDX SBOM attestation.
36
+	iimage.Load(ctx, t, apiClient, func(dir string) (*ocispec.Index, error) {
37
+		return buildAttestationImage(t, dir, imageRef, hostPlatform, []statementLayer{
38
+			{
39
+				predicateType: provenanceType,
40
+				payload: mustMarshal(map[string]any{
41
+					"_type":         "https://in-toto.io/Statement/v0.1",
42
+					"predicateType": provenanceType,
43
+					"subject":       []map[string]any{{"name": imageRef}},
44
+					"predicate": map[string]any{
45
+						"builder":   map[string]any{"id": "https://github.com/moby/buildkit"},
46
+						"buildType": "https://mobyproject.org/buildkit@v1",
47
+					},
48
+				}),
49
+			},
50
+			{
51
+				predicateType: sbomType,
52
+				payload: mustMarshal(map[string]any{
53
+					"_type":         "https://in-toto.io/Statement/v0.1",
54
+					"predicateType": sbomType,
55
+					"subject":       []map[string]any{{"name": imageRef}},
56
+					"predicate":     map[string]any{"spdxVersion": "SPDX-2.3", "name": imageRef},
57
+				}),
58
+			},
59
+		})
60
+	})
61
+	defer func() {
62
+		_, _ = apiClient.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{Force: true})
63
+	}()
64
+
65
+	t.Run("default_omits_statement_body", func(t *testing.T) {
66
+		result, err := apiClient.ImageAttestations(ctx, imageRef)
67
+		assert.NilError(t, err)
68
+		assert.Assert(t, is.Len(result.Items, 2))
69
+
70
+		types := map[string]bool{}
71
+		for _, s := range result.Items {
72
+			types[s.PredicateType] = true
73
+			assert.Check(t, s.Statement == nil, "statement body should be omitted by default")
74
+			assert.Check(t, s.Descriptor.Digest != "", "statement digest must not be empty")
75
+			assert.Check(t, s.Descriptor.MediaType != "", "statement media type must not be empty")
76
+		}
77
+		assert.Check(t, types[provenanceType], "provenance statement not returned")
78
+		assert.Check(t, types[sbomType], "SBOM statement not returned")
79
+	})
80
+
81
+	t.Run("with_statement_returns_bodies", func(t *testing.T) {
82
+		result, err := apiClient.ImageAttestations(ctx, imageRef,
83
+			client.ImageAttestationsWithStatement())
84
+		assert.NilError(t, err)
85
+		assert.Assert(t, is.Len(result.Items, 2))
86
+
87
+		types := map[string]bool{}
88
+		for _, s := range result.Items {
89
+			types[s.PredicateType] = true
90
+			assert.Assert(t, s.Statement != nil, "statement body should be present when opted in")
91
+			var raw map[string]any
92
+			assert.NilError(t, json.Unmarshal(*s.Statement, &raw), "statement is not valid JSON")
93
+			assert.Check(t, s.Descriptor.Digest != "", "statement digest must not be empty")
94
+			assert.Check(t, s.Descriptor.MediaType != "", "statement media type must not be empty")
95
+		}
96
+		assert.Check(t, types[provenanceType], "provenance statement not returned")
97
+		assert.Check(t, types[sbomType], "SBOM statement not returned")
98
+	})
99
+
100
+	t.Run("filter_by_predicate_type", func(t *testing.T) {
101
+		result, err := apiClient.ImageAttestations(ctx, imageRef,
102
+			client.ImageAttestationsWithPredicateTypes(provenanceType))
103
+		assert.NilError(t, err)
104
+		assert.Assert(t, is.Len(result.Items, 1))
105
+		assert.Check(t, is.Equal(result.Items[0].PredicateType, provenanceType))
106
+	})
107
+
108
+	t.Run("filter_by_multiple_predicate_types", func(t *testing.T) {
109
+		result, err := apiClient.ImageAttestations(ctx, imageRef,
110
+			client.ImageAttestationsWithPredicateTypes(provenanceType, sbomType))
111
+		assert.NilError(t, err)
112
+		assert.Assert(t, is.Len(result.Items, 2))
113
+
114
+		types := map[string]bool{}
115
+		for _, s := range result.Items {
116
+			types[s.PredicateType] = true
117
+		}
118
+		assert.Check(t, types[provenanceType], "provenance statement missing from multi-type filter result")
119
+		assert.Check(t, types[sbomType], "SBOM statement missing from multi-type filter result")
120
+	})
121
+
122
+	t.Run("explicit_platform_matches", func(t *testing.T) {
123
+		result, err := apiClient.ImageAttestations(ctx, imageRef,
124
+			client.ImageAttestationsWithPlatform(hostPlatform))
125
+		assert.NilError(t, err)
126
+		assert.Assert(t, is.Len(result.Items, 2))
127
+	})
128
+
129
+	t.Run("wrong_platform_returns_not_found", func(t *testing.T) {
130
+		_, err := apiClient.ImageAttestations(ctx, imageRef,
131
+			client.ImageAttestationsWithPlatform(ocispec.Platform{OS: "linux", Architecture: "riscv64"}))
132
+		assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
133
+	})
134
+
135
+	t.Run("unknown_image_returns_not_found", func(t *testing.T) {
136
+		_, err := apiClient.ImageAttestations(ctx, "no-such-image:latest")
137
+		assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
138
+	})
139
+
140
+	t.Run("empty_filter_returns_all", func(t *testing.T) {
141
+		result, err := apiClient.ImageAttestations(ctx, imageRef,
142
+			client.ImageAttestationsWithPredicateTypes())
143
+		assert.NilError(t, err)
144
+		assert.Assert(t, is.Len(result.Items, 2))
145
+	})
146
+}
147
+
148
+// statementLayer is a single in-toto statement to embed in the attestation manifest.
149
+type statementLayer struct {
150
+	predicateType string
151
+	payload       []byte
152
+}
153
+
154
+// buildAttestationImage writes an OCI image layout to dir containing a minimal
155
+// platform image manifest and an attestation manifest pointing to it.
156
+func buildAttestationImage(t *testing.T, dir string, imageRef string, platform ocispec.Platform, stmts []statementLayer) (*ocispec.Index, error) {
157
+	t.Helper()
158
+
159
+	ref, err := reference.ParseNormalizedNamed(imageRef)
160
+	if err != nil {
161
+		return nil, err
162
+	}
163
+
164
+	// Minimal platform image: one layer + config.
165
+	layerDesc := writeBlob(t, dir, "application/vnd.oci.image.layer.v1.tar+gzip", []byte("layer"))
166
+	configDesc := writeJSON(t, dir, ocispec.MediaTypeImageConfig, ocispec.Image{
167
+		Platform: platform,
168
+		RootFS:   ocispec.RootFS{Type: "layers", DiffIDs: []digest.Digest{layerDesc.Digest}},
169
+	})
170
+	imgMfstDesc := writeJSON(t, dir, ocispec.MediaTypeImageManifest, ocispec.Manifest{
171
+		Versioned: specs.Versioned{SchemaVersion: 2},
172
+		MediaType: ocispec.MediaTypeImageManifest,
173
+		Config:    configDesc,
174
+		Layers:    []ocispec.Descriptor{layerDesc},
175
+	})
176
+	imgMfstDesc.Platform = &platform
177
+
178
+	// Attestation manifest.
179
+	var layerDescs []ocispec.Descriptor
180
+	for _, s := range stmts {
181
+		d := writeBlob(t, dir, "application/vnd.in-toto+json", s.payload)
182
+		d.Annotations = map[string]string{"in-toto.io/predicate-type": s.predicateType}
183
+		layerDescs = append(layerDescs, d)
184
+	}
185
+	attConfigDesc := writeJSON(t, dir, ocispec.MediaTypeImageConfig, struct{}{})
186
+	attMfstDesc := writeJSON(t, dir, ocispec.MediaTypeImageManifest, ocispec.Manifest{
187
+		Versioned: specs.Versioned{SchemaVersion: 2},
188
+		MediaType: ocispec.MediaTypeImageManifest,
189
+		Config:    attConfigDesc,
190
+		Layers:    layerDescs,
191
+	})
192
+	attMfstDesc.Annotations = map[string]string{
193
+		attestation.DockerAnnotationReferenceType:   attestation.DockerAnnotationReferenceTypeDefault,
194
+		attestation.DockerAnnotationReferenceDigest: imgMfstDesc.Digest.String(),
195
+	}
196
+	attMfstDesc.Platform = &ocispec.Platform{OS: "unknown", Architecture: "unknown"}
197
+
198
+	// Outer index.
199
+	innerIdx := ocispec.Index{
200
+		Versioned: specs.Versioned{SchemaVersion: 2},
201
+		MediaType: ocispec.MediaTypeImageIndex,
202
+		Manifests: []ocispec.Descriptor{imgMfstDesc, attMfstDesc},
203
+	}
204
+	innerIdxDesc := writeJSON(t, dir, ocispec.MediaTypeImageIndex, innerIdx)
205
+	innerIdxDesc.Annotations = map[string]string{
206
+		"io.containerd.image.name": ref.String(),
207
+		ocispec.AnnotationRefName:  ref.(reference.Tagged).Tag(),
208
+	}
209
+
210
+	outerIdx := &ocispec.Index{
211
+		Versioned: specs.Versioned{SchemaVersion: 2},
212
+		MediaType: ocispec.MediaTypeImageIndex,
213
+		Manifests: []ocispec.Descriptor{innerIdxDesc},
214
+	}
215
+	b, err := json.Marshal(outerIdx)
216
+	if err != nil {
217
+		return nil, err
218
+	}
219
+	if err := os.WriteFile(filepath.Join(dir, "index.json"), b, 0o644); err != nil {
220
+		return nil, err
221
+	}
222
+	if err := os.WriteFile(filepath.Join(dir, "oci-layout"), []byte(`{"imageLayoutVersion":"1.0.0"}`), 0o644); err != nil {
223
+		return nil, err
224
+	}
225
+	return outerIdx, nil
226
+}
227
+
228
+func writeBlob(t *testing.T, dir, mediaType string, data []byte) ocispec.Descriptor {
229
+	t.Helper()
230
+	dgst := digest.FromBytes(data)
231
+	blobsDir := filepath.Join(dir, "blobs", "sha256")
232
+	assert.NilError(t, os.MkdirAll(blobsDir, 0o755))
233
+	assert.NilError(t, os.WriteFile(filepath.Join(blobsDir, dgst.Encoded()), data, 0o644))
234
+	return ocispec.Descriptor{MediaType: mediaType, Digest: dgst, Size: int64(len(data))}
235
+}
236
+
237
+func writeJSON(t *testing.T, dir, mediaType string, v any) ocispec.Descriptor {
238
+	t.Helper()
239
+	b, err := json.Marshal(v)
240
+	assert.NilError(t, err)
241
+	return writeBlob(t, dir, mediaType, b)
242
+}
243
+
244
+func mustMarshal(v any) []byte {
245
+	b, err := json.Marshal(v)
246
+	if err != nil {
247
+		panic(err)
248
+	}
249
+	return b
250
+}
0 251
new file mode 100644
... ...
@@ -0,0 +1,19 @@
0
+package image
1
+
2
+import (
3
+	"encoding/json"
4
+
5
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
6
+)
7
+
8
+// AttestationStatement is a single in-toto statement attached to an image.
9
+type AttestationStatement struct {
10
+	// Descriptor is the OCI descriptor of the statement blob (media type,
11
+	// digest, size, annotations).
12
+	Descriptor ocispec.Descriptor `json:"Descriptor"`
13
+	// PredicateType is the in-toto predicate type URI of this statement.
14
+	PredicateType string `json:"PredicateType"`
15
+	// Statement is the verbatim in-toto statement JSON. Omitted unless the
16
+	// caller opts in via the statement=true query parameter.
17
+	Statement *json.RawMessage `json:"Statement,omitempty"`
18
+}
... ...
@@ -133,6 +133,7 @@ type ImageAPIClient interface {
133 133
 
134 134
 	ImageInspect(ctx context.Context, image string, _ ...ImageInspectOption) (ImageInspectResult, error)
135 135
 	ImageHistory(ctx context.Context, image string, _ ...ImageHistoryOption) (ImageHistoryResult, error)
136
+	ImageAttestations(ctx context.Context, image string, _ ...ImageAttestationsOption) (ImageAttestationsResult, error)
136 137
 
137 138
 	ImageLoad(ctx context.Context, input io.Reader, _ ...ImageLoadOption) (ImageLoadResult, error)
138 139
 	ImageSave(ctx context.Context, images []string, _ ...ImageSaveOption) (ImageSaveResult, error)
139 140
new file mode 100644
... ...
@@ -0,0 +1,52 @@
0
+package client
1
+
2
+import (
3
+	"context"
4
+	"encoding/json"
5
+	"net/url"
6
+	"strings"
7
+)
8
+
9
+// ImageAttestations returns the in-toto attestation statements attached to an
10
+// image for the given platform. This requires API version 1.55 or higher.
11
+func (cli *Client) ImageAttestations(ctx context.Context, imageID string, opts ...ImageAttestationsOption) (ImageAttestationsResult, error) {
12
+	if imageID == "" {
13
+		return ImageAttestationsResult{}, objectNotFoundError{object: "image", id: imageID}
14
+	}
15
+
16
+	if err := cli.requiresVersion(ctx, "1.55", "attestations"); err != nil {
17
+		return ImageAttestationsResult{}, err
18
+	}
19
+
20
+	var o imageAttestationsOpts
21
+	for _, opt := range opts {
22
+		if err := opt.Apply(&o); err != nil {
23
+			return ImageAttestationsResult{}, err
24
+		}
25
+	}
26
+
27
+	query := url.Values{}
28
+	if o.platform != nil {
29
+		p, err := encodePlatform(o.platform)
30
+		if err != nil {
31
+			return ImageAttestationsResult{}, err
32
+		}
33
+		query.Set("platform", p)
34
+	}
35
+	if len(o.predicateTypes) > 0 {
36
+		query.Set("type", strings.Join(o.predicateTypes, ","))
37
+	}
38
+	if o.includeStatement {
39
+		query.Set("statement", "1")
40
+	}
41
+
42
+	resp, err := cli.get(ctx, "/images/"+imageID+"/attestations", query, nil)
43
+	defer ensureReaderClosed(resp)
44
+	if err != nil {
45
+		return ImageAttestationsResult{}, err
46
+	}
47
+
48
+	var result ImageAttestationsResult
49
+	err = json.NewDecoder(resp.Body).Decode(&result.Items)
50
+	return result, err
51
+}
0 52
new file mode 100644
... ...
@@ -0,0 +1,56 @@
0
+package client
1
+
2
+import (
3
+	"github.com/moby/moby/api/types/image"
4
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
5
+)
6
+
7
+// ImageAttestationsResult is the result of an ImageAttestations operation.
8
+type ImageAttestationsResult struct {
9
+	Items []image.AttestationStatement
10
+}
11
+
12
+// ImageAttestationsOption is a functional option for the ImageAttestations operation.
13
+type ImageAttestationsOption interface {
14
+	Apply(*imageAttestationsOpts) error
15
+}
16
+
17
+type imageAttestationsOptionFunc func(*imageAttestationsOpts) error
18
+
19
+func (f imageAttestationsOptionFunc) Apply(o *imageAttestationsOpts) error { return f(o) }
20
+
21
+type imageAttestationsOpts struct {
22
+	platform         *ocispec.Platform
23
+	predicateTypes   []string
24
+	includeStatement bool
25
+}
26
+
27
+// ImageAttestationsWithPlatform filters attestations to those for the given
28
+// platform variant. If omitted, the daemon's default platform is used.
29
+func ImageAttestationsWithPlatform(platform ocispec.Platform) ImageAttestationsOption {
30
+	return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
31
+		o.platform = &platform
32
+		return nil
33
+	})
34
+}
35
+
36
+// ImageAttestationsWithPredicateTypes filters returned statements to those
37
+// whose in-toto predicate type matches one of the given URIs.
38
+// If not set, all statements are returned.
39
+func ImageAttestationsWithPredicateTypes(types ...string) ImageAttestationsOption {
40
+	return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
41
+		o.predicateTypes = append(o.predicateTypes, types...)
42
+		return nil
43
+	})
44
+}
45
+
46
+// ImageAttestationsWithStatement asks the daemon to include the verbatim
47
+// in-toto statement body in each returned entry. Without this option, only
48
+// the descriptor and predicate type are returned and statement blobs are
49
+// not read.
50
+func ImageAttestationsWithStatement() ImageAttestationsOption {
51
+	return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
52
+		o.includeStatement = true
53
+		return nil
54
+	})
55
+}