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>
| ... | ... |
@@ -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 |
+} |