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>
This commit is contained in:
Sopho Merkviladze committed 2026-06-12 13:40:58 +04:00
1 parent d244804641
commit a0b6dbc2fc
19 files changed
+1272

No files matched your search

+12
View File
@@ -13,6 +13,18 @@ keywords: "API, Docker, rcli, REST, documentation"
will be rejected.
-->
## v1.55 API changes
* `GET /images/{name}/attestations` is a new endpoint that returns the in-toto
attestation statements attached to an image. The `platform` query parameter
selects the image variant (defaults to the daemon's host platform) and the
`type` query parameter accepts a comma-separated list of predicate type URIs
to filter the returned statements. The `statement` query parameter (default
`false`) controls whether the verbatim statement body is included; when
omitted or `false`, each entry contains only the descriptor and predicate
type, and statement blobs are not read. The response is a JSON array of
`AttestationStatement` objects.
## v1.54 API changes
* `GET /images/json` now supports an `identity` query parameter. When set,
+95
View File
@@ -8140,6 +8140,25 @@ definitions:
additionalProperties:
type: "string"
AttestationStatement:
x-go-name: "AttestationStatement"
description: |
AttestationStatement is a single in-toto statement attached to an image.
type: "object"
required: ["Descriptor", "PredicateType"]
properties:
Descriptor:
$ref: "#/definitions/OCIDescriptor"
PredicateType:
description: The in-toto predicate type URI of this statement.
type: "string"
example: "https://slsa.dev/provenance/v0.2"
Statement:
description: |
The verbatim in-toto statement JSON. Only included when the caller
opts in via the `statement=true` query parameter; otherwise absent.
type: "object"
x-nullable: true
ImageManifestSummary:
x-go-name: "ManifestSummary"
description: |
@@ -10157,6 +10176,82 @@ paths:
Example: `{"os": "linux", "architecture": "arm", "variant": "v5"}`
tags: ["Image"]
/images/{name}/attestations:
get:
summary: "Get attestation statements for an image"
description: |
Return the in-toto attestation statements attached to the image for the
given platform. The daemon locates the attestation manifest(s) that
reference the matching platform image manifest, reads their statement
layers, and returns the verbatim statement JSON together with layer
metadata.
If the image has no attestations an empty array is returned.
operationId: "ImageAttestations"
produces:
- "application/json"
responses:
200:
description: "No error"
schema:
type: "array"
items:
$ref: "#/definitions/AttestationStatement"
400:
description: "Bad parameter (e.g. malformed `platform` value)"
schema:
$ref: "#/definitions/ErrorResponse"
404:
description: "No such image, or no manifest found for the requested platform"
schema:
$ref: "#/definitions/ErrorResponse"
500:
description: "Server error"
schema:
$ref: "#/definitions/ErrorResponse"
501:
description: |
The daemon's image backend does not support attestations. This
is returned by the legacy (graphdriver) image store, which does
not preserve OCI image indexes.
schema:
$ref: "#/definitions/ErrorResponse"
parameters:
- name: "name"
in: "path"
description: "Image name or id"
type: "string"
required: true
- name: "platform"
type: "string"
in: "query"
description: |-
JSON-encoded OCI platform to select the image variant whose
attestations to return.
If omitted, the daemon's default (host) platform is used.
Example: `{"os": "linux", "architecture": "amd64"}`
required: false
- name: "type"
type: "string"
in: "query"
description: |-
Comma-separated list of in-toto predicate type URIs to filter
returned statements. If omitted, all statements are returned.
Example: `https://slsa.dev/provenance/v0.2,https://spdx.dev/Document`
required: false
- name: "statement"
type: "boolean"
in: "query"
description: |-
Include the verbatim in-toto statement body in each returned
entry. Defaults to false; when omitted or false, only the
descriptor and predicate type are returned and statement blobs
are not read.
default: false
required: false
tags: ["Image"]
/images/{name}/history:
get:
summary: "Get the history of an image"
+19
View File
@@ -0,0 +1,19 @@
package image
import (
"encoding/json"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
// AttestationStatement is a single in-toto statement attached to an image.
type AttestationStatement struct {
// Descriptor is the OCI descriptor of the statement blob (media type,
// digest, size, annotations).
Descriptor ocispec.Descriptor `json:"Descriptor"`
// PredicateType is the in-toto predicate type URI of this statement.
PredicateType string `json:"PredicateType"`
// Statement is the verbatim in-toto statement JSON. Omitted unless the
// caller opts in via the statement=true query parameter.
Statement *json.RawMessage `json:"Statement,omitempty"`
}
+1
View File
@@ -133,6 +133,7 @@ type ImageAPIClient interface {
ImageInspect(ctx context.Context, image string, _ ...ImageInspectOption) (ImageInspectResult, error)
ImageHistory(ctx context.Context, image string, _ ...ImageHistoryOption) (ImageHistoryResult, error)
ImageAttestations(ctx context.Context, image string, _ ...ImageAttestationsOption) (ImageAttestationsResult, error)
ImageLoad(ctx context.Context, input io.Reader, _ ...ImageLoadOption) (ImageLoadResult, error)
ImageSave(ctx context.Context, images []string, _ ...ImageSaveOption) (ImageSaveResult, error)
+52
View File
@@ -0,0 +1,52 @@
package client
import (
"context"
"encoding/json"
"net/url"
"strings"
)
// ImageAttestations returns the in-toto attestation statements attached to an
// image for the given platform. This requires API version 1.55 or higher.
func (cli *Client) ImageAttestations(ctx context.Context, imageID string, opts ...ImageAttestationsOption) (ImageAttestationsResult, error) {
if imageID == "" {
return ImageAttestationsResult{}, objectNotFoundError{object: "image", id: imageID}
}
if err := cli.requiresVersion(ctx, "1.55", "attestations"); err != nil {
return ImageAttestationsResult{}, err
}
var o imageAttestationsOpts
for _, opt := range opts {
if err := opt.Apply(&o); err != nil {
return ImageAttestationsResult{}, err
}
}
query := url.Values{}
if o.platform != nil {
p, err := encodePlatform(o.platform)
if err != nil {
return ImageAttestationsResult{}, err
}
query.Set("platform", p)
}
if len(o.predicateTypes) > 0 {
query.Set("type", strings.Join(o.predicateTypes, ","))
}
if o.includeStatement {
query.Set("statement", "1")
}
resp, err := cli.get(ctx, "/images/"+imageID+"/attestations", query, nil)
defer ensureReaderClosed(resp)
if err != nil {
return ImageAttestationsResult{}, err
}
var result ImageAttestationsResult
err = json.NewDecoder(resp.Body).Decode(&result.Items)
return result, err
}
+56
View File
@@ -0,0 +1,56 @@
package client
import (
"github.com/moby/moby/api/types/image"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
// ImageAttestationsResult is the result of an ImageAttestations operation.
type ImageAttestationsResult struct {
Items []image.AttestationStatement
}
// ImageAttestationsOption is a functional option for the ImageAttestations operation.
type ImageAttestationsOption interface {
Apply(*imageAttestationsOpts) error
}
type imageAttestationsOptionFunc func(*imageAttestationsOpts) error
func (f imageAttestationsOptionFunc) Apply(o *imageAttestationsOpts) error { return f(o) }
type imageAttestationsOpts struct {
platform *ocispec.Platform
predicateTypes []string
includeStatement bool
}
// ImageAttestationsWithPlatform filters attestations to those for the given
// platform variant. If omitted, the daemon's default platform is used.
func ImageAttestationsWithPlatform(platform ocispec.Platform) ImageAttestationsOption {
return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
o.platform = &platform
return nil
})
}
// ImageAttestationsWithPredicateTypes filters returned statements to those
// whose in-toto predicate type matches one of the given URIs.
// If not set, all statements are returned.
func ImageAttestationsWithPredicateTypes(types ...string) ImageAttestationsOption {
return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
o.predicateTypes = append(o.predicateTypes, types...)
return nil
})
}
// ImageAttestationsWithStatement asks the daemon to include the verbatim
// in-toto statement body in each returned entry. Without this option, only
// the descriptor and predicate type are returned and statement blobs are
// not read.
func ImageAttestationsWithStatement() ImageAttestationsOption {
return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
o.includeStatement = true
return nil
})
}
+125
View File
@@ -0,0 +1,125 @@
package containerd
import (
"context"
"encoding/json"
"slices"
"github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/core/remotes"
cerrdefs "github.com/containerd/errdefs"
imagetypes "github.com/moby/moby/api/types/image"
"github.com/moby/moby/v2/daemon/server/imagebackend"
"github.com/moby/moby/v2/errdefs"
policyimage "github.com/moby/policy-helpers/image"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
// inTotoPredicateTypeAnnotation is the OCI layer annotation key that BuildKit
// uses to record the in-toto predicate type of a statement layer inside an
// attestation manifest.
const inTotoPredicateTypeAnnotation = "in-toto.io/predicate-type"
// localReferrersProvider adapts a local content.Provider to the
// policyimage.ReferrersProvider interface. FetchReferrers is a no-op because
// moby's local content store cannot resolve OCI referrers; this means DHI image
// resolution and Sigstore signature manifest discovery are not supported.
// Standard BuildKit attestations are attached as sibling manifests in the
// image index and do not require FetchReferrers.
type localReferrersProvider struct {
content.Provider
}
func (p *localReferrersProvider) FetchReferrers(ctx context.Context, dgst digest.Digest, opts ...remotes.FetchReferrersOpt) ([]ocispec.Descriptor, error) {
return nil, nil
}
// ImageAttestations returns the in-toto attestation statements attached to the
// given image for the specified platform.
//
// The chain walk (locating the image manifest for the platform and the
// associated attestation manifest) is delegated to
// policyimage.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, reads matching blobs eagerly into memory, and
// produces AttestationStatement values directly for
// GET /images/{name}/attestations.
//
// Behaviour:
// - If the image is not an OCI index (no possibility of sibling attestation
// manifests), returns (nil, nil).
// - If the requested platform has no matching image manifest, returns
// errdefs.NotFound.
// - If the platform image manifest has no associated attestation manifest,
// returns (nil, nil).
// - Layers without an in-toto.io/predicate-type annotation are skipped.
// - When predicateTypes is empty, all annotated statement layers are
// returned. When non-empty, only matching ones are returned. If no
// layers match, returns (nil, nil).
// - When IncludeStatement is true the statement blob is read and attached
// to each returned entry; a read failure propagates as an error. When
// false, statement blobs are never read and the Statement field is left
// nil.
func (i *ImageService) ImageAttestations(ctx context.Context, refOrID string, opts imagebackend.AttestationOpts) ([]imagetypes.AttestationStatement, error) {
img, err := i.resolveImage(ctx, refOrID)
if err != nil {
return nil, err
}
// Only image indexes can carry sibling attestation manifests. This matches
// the entry check in policyimage.ResolveSignatureChain, which rejects any other
// media type (see github.com/moby/policy-helpers/blob/main/image/resolve.go).
if img.Target.MediaType != ocispec.MediaTypeImageIndex {
return nil, nil
}
prov := &localReferrersProvider{Provider: i.content}
sc, err := policyimage.ResolveSignatureChain(ctx, prov, img.Target, opts.Platform)
if err != nil {
if cerrdefs.IsNotFound(err) {
return nil, errdefs.NotFound(err)
}
return nil, err
}
if sc.AttestationManifest == nil {
return nil, nil
}
mfst, err := sc.OCIManifest(ctx, sc.AttestationManifest)
if err != nil {
return nil, err
}
var statements []imagetypes.AttestationStatement
for _, layer := range mfst.Layers {
predicateType := layer.Annotations[inTotoPredicateTypeAnnotation]
if predicateType == "" {
continue
}
if len(opts.PredicateTypes) > 0 && !slices.Contains(opts.PredicateTypes, predicateType) {
continue
}
stmt := imagetypes.AttestationStatement{
Descriptor: layer,
PredicateType: predicateType,
}
if opts.IncludeStatement {
data, err := content.ReadBlob(ctx, i.content, layer)
if err != nil {
return nil, err
}
raw := json.RawMessage(data)
stmt.Statement = &raw
}
statements = append(statements, stmt)
}
if len(statements) == 0 {
return nil, nil
}
return statements, nil
}
+458
View File
@@ -0,0 +1,458 @@
package containerd
import (
"encoding/json"
"os"
"path/filepath"
"testing"
c8dimages "github.com/containerd/containerd/v2/core/images"
"github.com/containerd/containerd/v2/pkg/namespaces"
"github.com/containerd/log/logtest"
"github.com/distribution/reference"
"github.com/moby/buildkit/util/attestation"
imagetypes "github.com/moby/moby/api/types/image"
"github.com/moby/moby/v2/daemon/server/imagebackend"
"github.com/opencontainers/go-digest"
"github.com/opencontainers/image-spec/specs-go"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
// normalizeRef normalizes an image reference to the canonical form that
// resolveImage expects (e.g. "test:latest" → "docker.io/library/test:latest").
func normalizeRef(t *testing.T, name string) string {
t.Helper()
ref, err := reference.ParseNormalizedNamed(name)
assert.NilError(t, err)
return reference.TagNameOnly(ref).String()
}
// provBlob writes content to dir/blobs/sha256/<digest> and returns its descriptor.
func provBlob(t *testing.T, dir, mt string, data []byte) ocispec.Descriptor {
t.Helper()
sha256Dir := filepath.Join(dir, "blobs", "sha256")
assert.NilError(t, os.MkdirAll(sha256Dir, 0o755))
dgst := digest.FromBytes(data)
assert.NilError(t, os.WriteFile(filepath.Join(sha256Dir, dgst.Encoded()), data, 0o644))
return ocispec.Descriptor{MediaType: mt, Digest: dgst, Size: int64(len(data))}
}
// provJSON marshals v and writes it as a blob.
func provJSON(t *testing.T, dir, mt string, v any) ocispec.Descriptor {
t.Helper()
b, err := json.Marshal(v)
assert.NilError(t, err)
return provBlob(t, dir, mt, b)
}
// attestationLayer describes one layer of an attestation manifest.
// When predicateType is empty the layer carries no in-toto annotation.
type attestationLayer struct {
predicateType string
content []byte
}
// buildFullIndex writes a complete OCI image index containing a real platform
// image manifest and an attestation manifest for it. The platform manifest is
// always written to the content store; attestation layer blobs are written only
// when writeLayerBlobs is true. Returns the index descriptor, the platform
// image manifest descriptor, and the attestation manifest descriptor.
func buildFullIndex(t *testing.T, dir string, platform ocispec.Platform, stmts []attestationLayer, writeLayerBlobs bool) (ocispec.Descriptor, ocispec.Descriptor) {
t.Helper()
// Minimal platform image manifest (config + no layers).
imgConfig := map[string]any{
"os": platform.OS,
"architecture": platform.Architecture,
}
configDesc := provJSON(t, dir, ocispec.MediaTypeImageConfig, imgConfig)
imgMfst := ocispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageManifest,
Config: configDesc,
Layers: []ocispec.Descriptor{},
}
imgMfstDesc := provJSON(t, dir, ocispec.MediaTypeImageManifest, imgMfst)
imgMfstDesc.Platform = &platform
// Build attestation layer descriptors.
var layerDescs []ocispec.Descriptor
for _, s := range stmts {
var desc ocispec.Descriptor
if writeLayerBlobs {
desc = provBlob(t, dir, "application/vnd.in-toto+json", s.content)
} else {
dgst := digest.FromBytes(s.content)
desc = ocispec.Descriptor{
MediaType: "application/vnd.in-toto+json",
Digest: dgst,
Size: int64(len(s.content)),
}
}
if s.predicateType != "" {
desc.Annotations = map[string]string{
"in-toto.io/predicate-type": s.predicateType,
}
}
layerDescs = append(layerDescs, desc)
}
// Attestation manifest.
attConfig := provBlob(t, dir, ocispec.MediaTypeImageConfig, []byte(`{}`))
attMfst := ocispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageManifest,
Config: attConfig,
Layers: layerDescs,
}
attMfstDesc := provJSON(t, dir, ocispec.MediaTypeImageManifest, attMfst)
attMfstDesc.Annotations = map[string]string{
attestation.DockerAnnotationReferenceType: attestation.DockerAnnotationReferenceTypeDefault,
attestation.DockerAnnotationReferenceDigest: imgMfstDesc.Digest.String(),
}
attMfstDesc.Platform = &ocispec.Platform{OS: "unknown", Architecture: "unknown"}
// Index.
idx := ocispec.Index{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageIndex,
Manifests: []ocispec.Descriptor{imgMfstDesc, attMfstDesc},
}
idxDesc := provJSON(t, dir, ocispec.MediaTypeImageIndex, idx)
return idxDesc, imgMfstDesc
}
// buildAttestationIndex writes a minimal OCI image index containing a single
// attestation manifest whose layers are given by stmts. The index blob and the
// attestation manifest blob are always written to dir; layer blobs are written
// only when writeLayerBlobs is true (pass false to simulate unavailable content).
// Returns the index descriptor (suitable for registering with an image store) and
// the synthetic platform-image digest referenced by the attestation.
//
// This helper produces an index WITHOUT a real image manifest, which is useful
// for verifying that AttestationData.For is set correctly in image listings.
func buildAttestationIndex(t *testing.T, dir string, stmts []attestationLayer, writeLayerBlobs bool) (ocispec.Descriptor, digest.Digest) {
t.Helper()
// Minimal empty config for the attestation manifest.
configDesc := provBlob(t, dir, ocispec.MediaTypeImageConfig, []byte(`{}`))
// Build layer descriptors; blobs are written only when writeLayerBlobs is set.
var layerDescs []ocispec.Descriptor
for _, s := range stmts {
var desc ocispec.Descriptor
if writeLayerBlobs {
desc = provBlob(t, dir, "application/vnd.in-toto+json", s.content)
} else {
dgst := digest.FromBytes(s.content)
desc = ocispec.Descriptor{
MediaType: "application/vnd.in-toto+json",
Digest: dgst,
Size: int64(len(s.content)),
}
}
if s.predicateType != "" {
desc.Annotations = map[string]string{
"in-toto.io/predicate-type": s.predicateType,
}
}
layerDescs = append(layerDescs, desc)
}
// Write the attestation manifest blob.
attMfst := ocispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageManifest,
Config: configDesc,
Layers: layerDescs,
}
attMfstDesc := provJSON(t, dir, ocispec.MediaTypeImageManifest, attMfst)
// Synthetic platform manifest digest — it need not be in the content store.
platformDigest := digest.FromString("platform-manifest-placeholder")
// Annotate the attestation manifest descriptor for the index.
attMfstDesc.Annotations = map[string]string{
attestation.DockerAnnotationReferenceType: attestation.DockerAnnotationReferenceTypeDefault,
attestation.DockerAnnotationReferenceDigest: platformDigest.String(),
}
attMfstDesc.Platform = &ocispec.Platform{OS: "unknown", Architecture: "unknown"}
// Write the index blob.
idx := ocispec.Index{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageIndex,
Manifests: []ocispec.Descriptor{attMfstDesc},
}
idxDesc := provJSON(t, dir, ocispec.MediaTypeImageIndex, idx)
idxDesc.Annotations = map[string]string{
"io.containerd.image.name": "test:latest",
}
return idxDesc, platformDigest
}
// findAttestationManifest returns the first attestation-kind manifest summary in
// manifests, or fails the test if none is found.
func findAttestationManifest(t *testing.T, manifests []imagetypes.ManifestSummary) imagetypes.ManifestSummary {
t.Helper()
for _, m := range manifests {
if m.Kind == imagetypes.ManifestKindAttestation {
return m
}
}
t.Fatal("no attestation manifest found in image summary")
return imagetypes.ManifestSummary{}
}
// TestAttestationDataFor verifies that the AttestationData.For field in the
// image list response is set to the digest of the image manifest the
// attestation is for.
func TestAttestationDataFor(t *testing.T) {
ctx := namespaces.WithNamespace(t.Context(), "testing")
ctx = logtest.WithT(ctx, t)
dir := t.TempDir()
idxDesc, platformDigest := buildAttestationIndex(t, dir, []attestationLayer{
{predicateType: "https://slsa.dev/provenance/v0.2", content: []byte(`{}`)},
}, true)
cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
svc := fakeImageService(t, ctx, cs)
_, err := svc.images.Create(ctx, c8dimages.Image{Name: "test:latest", Target: idxDesc})
assert.NilError(t, err)
all, err := svc.Images(ctx, imagebackend.ListOptions{Manifests: true})
assert.NilError(t, err)
assert.Assert(t, is.Len(all, 1))
m := findAttestationManifest(t, all[0].Manifests)
assert.Assert(t, m.AttestationData != nil)
assert.Check(t, is.Equal(m.AttestationData.For, platformDigest))
}
// TestImageAttestations exercises the ImageAttestations method with various
// statement counts, predicate type filters, and error conditions.
func TestImageAttestations(t *testing.T) {
linuxAMD64 := ocispec.Platform{OS: "linux", Architecture: "amd64"}
t.Run("single_statement", func(t *testing.T) {
ctx := namespaces.WithNamespace(t.Context(), "testing")
ctx = logtest.WithT(ctx, t)
dir := t.TempDir()
stmtData, _ := json.Marshal(map[string]any{
"predicateType": "https://slsa.dev/provenance/v0.2",
"predicate": map[string]any{
"materials": []map[string]any{
{"uri": "pkg:docker/library/alpine@3.18"},
},
},
})
idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
{predicateType: "https://slsa.dev/provenance/v0.2", content: stmtData},
}, true)
cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
svc := fakeImageService(t, ctx, cs)
imgName := normalizeRef(t, "test:latest")
_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
assert.NilError(t, err)
stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
Platform: &linuxAMD64,
IncludeStatement: true,
})
assert.NilError(t, err)
assert.Assert(t, is.Len(stmts, 1))
assert.Check(t, is.Equal(stmts[0].PredicateType, "https://slsa.dev/provenance/v0.2"))
assert.Assert(t, stmts[0].Statement != nil)
var parsed struct {
PredicateType string `json:"predicateType"`
Predicate struct {
Materials []struct {
URI string `json:"uri"`
} `json:"materials"`
} `json:"predicate"`
}
assert.NilError(t, json.Unmarshal(*stmts[0].Statement, &parsed))
assert.Check(t, is.Equal(parsed.PredicateType, "https://slsa.dev/provenance/v0.2"))
assert.Check(t, is.Equal(len(parsed.Predicate.Materials), 1))
assert.Check(t, is.Equal(parsed.Predicate.Materials[0].URI, "pkg:docker/library/alpine@3.18"))
})
t.Run("default_omits_statement_body", func(t *testing.T) {
ctx := namespaces.WithNamespace(t.Context(), "testing")
ctx = logtest.WithT(ctx, t)
dir := t.TempDir()
stmtData, _ := json.Marshal(map[string]any{"predicateType": "https://slsa.dev/provenance/v0.2"})
idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
{predicateType: "https://slsa.dev/provenance/v0.2", content: stmtData},
}, false /* layer blobs absent — proves we never read them */)
cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
svc := fakeImageService(t, ctx, cs)
imgName := normalizeRef(t, "test:latest")
_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
assert.NilError(t, err)
stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
Platform: &linuxAMD64,
})
assert.NilError(t, err)
assert.Assert(t, is.Len(stmts, 1))
assert.Check(t, is.Equal(stmts[0].PredicateType, "https://slsa.dev/provenance/v0.2"))
assert.Check(t, stmts[0].Descriptor.Digest != "", "descriptor should be populated")
assert.Check(t, stmts[0].Statement == nil, "statement body should be omitted by default")
})
t.Run("multiple_statements_order_preserved", func(t *testing.T) {
ctx := namespaces.WithNamespace(t.Context(), "testing")
ctx = logtest.WithT(ctx, t)
dir := t.TempDir()
v02Data, _ := json.Marshal(map[string]any{"predicateType": "https://slsa.dev/provenance/v0.2"})
v1Data, _ := json.Marshal(map[string]any{"predicateType": "https://slsa.dev/provenance/v1"})
idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
{predicateType: "https://slsa.dev/provenance/v0.2", content: v02Data},
{predicateType: "https://slsa.dev/provenance/v1", content: v1Data},
}, true)
cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
svc := fakeImageService(t, ctx, cs)
imgName := normalizeRef(t, "test:latest")
_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
assert.NilError(t, err)
stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
Platform: &linuxAMD64,
})
assert.NilError(t, err)
assert.Assert(t, is.Len(stmts, 2))
assert.Check(t, is.Equal(stmts[0].PredicateType, "https://slsa.dev/provenance/v0.2"))
assert.Check(t, is.Equal(stmts[1].PredicateType, "https://slsa.dev/provenance/v1"))
})
t.Run("predicate_type_filter", func(t *testing.T) {
ctx := namespaces.WithNamespace(t.Context(), "testing")
ctx = logtest.WithT(ctx, t)
dir := t.TempDir()
v02Data, _ := json.Marshal(map[string]any{"predicateType": "https://slsa.dev/provenance/v0.2"})
sbomData, _ := json.Marshal(map[string]any{"predicateType": "https://spdx.dev/Document"})
idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
{predicateType: "https://slsa.dev/provenance/v0.2", content: v02Data},
{predicateType: "https://spdx.dev/Document", content: sbomData},
}, true)
cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
svc := fakeImageService(t, ctx, cs)
imgName := normalizeRef(t, "test:latest")
_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
assert.NilError(t, err)
stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
Platform: &linuxAMD64,
PredicateTypes: []string{"https://slsa.dev/provenance/v0.2"},
})
assert.NilError(t, err)
assert.Assert(t, is.Len(stmts, 1))
assert.Check(t, is.Equal(stmts[0].PredicateType, "https://slsa.dev/provenance/v0.2"))
})
t.Run("layer_without_predicate_type_skipped", func(t *testing.T) {
ctx := namespaces.WithNamespace(t.Context(), "testing")
ctx = logtest.WithT(ctx, t)
dir := t.TempDir()
annotatedData, _ := json.Marshal(map[string]any{"predicateType": "https://slsa.dev/provenance/v0.2"})
idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
{predicateType: "", content: []byte(`{"some":"other"}`)},
{predicateType: "https://slsa.dev/provenance/v0.2", content: annotatedData},
}, true)
cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
svc := fakeImageService(t, ctx, cs)
imgName := normalizeRef(t, "test:latest")
_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
assert.NilError(t, err)
stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
Platform: &linuxAMD64,
})
assert.NilError(t, err)
assert.Assert(t, is.Len(stmts, 1), "only layers with in-toto annotation should be returned")
assert.Check(t, is.Equal(stmts[0].PredicateType, "https://slsa.dev/provenance/v0.2"))
})
t.Run("unavailable_blobs_return_error", func(t *testing.T) {
ctx := namespaces.WithNamespace(t.Context(), "testing")
ctx = logtest.WithT(ctx, t)
dir := t.TempDir()
stmtData := []byte(`{"predicateType":"https://slsa.dev/provenance/v0.2"}`)
idxDesc, _ := buildFullIndex(t, dir, linuxAMD64, []attestationLayer{
{predicateType: "https://slsa.dev/provenance/v0.2", content: stmtData},
}, false /* layer blobs absent */)
cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
svc := fakeImageService(t, ctx, cs)
imgName := normalizeRef(t, "test:latest")
_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
assert.NilError(t, err)
// When a statement layer blob is not locally present we surface the
// error rather than silently skipping. This matches BuildKit's
// ResolveAttestations behavior via the shared policy-helpers library.
_, err = svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
Platform: &linuxAMD64,
IncludeStatement: true,
})
assert.ErrorContains(t, err, "not found")
})
t.Run("no_attestations_returns_nil", func(t *testing.T) {
ctx := namespaces.WithNamespace(t.Context(), "testing")
ctx = logtest.WithT(ctx, t)
dir := t.TempDir()
// Index with only an image manifest, no attestation manifest.
imgConfig := map[string]any{"os": "linux", "architecture": "amd64"}
configDesc := provJSON(t, dir, ocispec.MediaTypeImageConfig, imgConfig)
imgMfst := ocispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageManifest,
Config: configDesc,
Layers: []ocispec.Descriptor{},
}
imgMfstDesc := provJSON(t, dir, ocispec.MediaTypeImageManifest, imgMfst)
imgMfstDesc.Platform = &linuxAMD64
idx := ocispec.Index{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageIndex,
Manifests: []ocispec.Descriptor{imgMfstDesc},
}
idxDesc := provJSON(t, dir, ocispec.MediaTypeImageIndex, idx)
cs := &blobsDirContentStore{blobs: filepath.Join(dir, "blobs", "sha256")}
svc := fakeImageService(t, ctx, cs)
imgName := normalizeRef(t, "test:latest")
_, err := svc.images.Create(ctx, c8dimages.Image{Name: imgName, Target: idxDesc})
assert.NilError(t, err)
stmts, err := svc.ImageAttestations(ctx, "test:latest", imagebackend.AttestationOpts{
Platform: &linuxAMD64,
})
assert.NilError(t, err)
assert.Check(t, is.Nil(stmts))
})
}
+1
View File
@@ -44,6 +44,7 @@ type ImageService interface {
CommitImage(ctx context.Context, c backend.CommitConfig) (image.ID, error)
SquashImage(id, parent string) (string, error)
ImageInspect(ctx context.Context, refOrID string, opts imagebackend.ImageInspectOpts) (*imagebackend.InspectData, error)
ImageAttestations(ctx context.Context, refOrID string, opts imagebackend.AttestationOpts) ([]imagetype.AttestationStatement, error)
ImageDiskUsage(ctx context.Context) (int64, error)
// Layers
+15
View File
@@ -0,0 +1,15 @@
package images
import (
"context"
"errors"
imagetypes "github.com/moby/moby/api/types/image"
"github.com/moby/moby/v2/daemon/server/imagebackend"
"github.com/moby/moby/v2/errdefs"
)
// ImageAttestations is not supported by the legacy image store.
func (i *ImageService) ImageAttestations(_ context.Context, _ string, _ imagebackend.AttestationOpts) ([]imagetypes.AttestationStatement, error) {
return nil, errdefs.NotImplemented(errors.New("the legacy image store does not support attestations"))
}
+17
View File
@@ -63,6 +63,23 @@ type ImageInspectOpts struct {
Platform *ocispec.Platform
}
// AttestationOpts holds parameters for retrieving image attestations.
type AttestationOpts struct {
// Platform selects the image variant whose attestations to return.
// If nil, the daemon's default platform is used.
Platform *ocispec.Platform
// PredicateTypes filters returned statements to those with a matching
// in-toto predicate type. An empty slice returns all statements.
PredicateTypes []string
// IncludeStatement controls whether the verbatim in-toto statement
// body is read from the content store and returned. When false, only
// the descriptor and predicate type are populated and statement blobs
// are never read.
IncludeStatement bool
}
type InspectData struct {
imagetypes.InspectResponse
+1
View File
@@ -27,6 +27,7 @@ type imageBackend interface {
Images(ctx context.Context, opts imagebackend.ListOptions) ([]image.Summary, error)
GetImage(ctx context.Context, refOrID string, options imagebackend.GetImageOpts) (*dockerimage.Image, error)
ImageInspect(ctx context.Context, refOrID string, options imagebackend.ImageInspectOpts) (*imagebackend.InspectData, error)
ImageAttestations(ctx context.Context, refOrID string, opts imagebackend.AttestationOpts) ([]image.AttestationStatement, error)
TagImage(ctx context.Context, id dockerimage.ID, newRef reference.Named) error
ImagePrune(ctx context.Context, pruneFilters filters.Args) (*image.PruneReport, error)
}
+1
View File
@@ -36,6 +36,7 @@ func (ir *imageRouter) initRoutes() {
router.NewGetRoute("/images/{name:.*}/get", ir.getImagesGet),
router.NewGetRoute("/images/{name:.*}/history", ir.getImagesHistory),
router.NewGetRoute("/images/{name:.*}/json", ir.getImagesByName),
router.NewGetRoute("/images/{name:.*}/attestations", ir.getImageAttestations, router.WithMinimumAPIVersion("1.55")),
// POST
router.NewPostRoute("/images/load", ir.postImagesLoad),
router.NewPostRoute("/images/create", ir.postImagesCreate),
@@ -13,6 +13,7 @@ import (
"github.com/containerd/platforms"
"github.com/distribution/reference"
"github.com/moby/moby/api/pkg/authconfig"
imagetypes "github.com/moby/moby/api/types/image"
"github.com/moby/moby/api/types/registry"
"github.com/moby/moby/v2/daemon/builder/remotecontext"
"github.com/moby/moby/v2/daemon/internal/compat"
@@ -657,6 +658,45 @@ func (ir *imageRouter) postImagesPrune(ctx context.Context, w http.ResponseWrite
return httputils.WriteJSON(w, http.StatusOK, pruneReport)
}
func (ir *imageRouter) getImageAttestations(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
if err := httputils.ParseForm(r); err != nil {
return err
}
var platform *ocispec.Platform
if p := r.Form.Get("platform"); p != "" {
decoded, err := httputils.DecodePlatform(p)
if err != nil {
return errdefs.InvalidParameter(err)
}
platform = decoded
}
var predicateTypes []string
if t := r.Form.Get("type"); t != "" {
for _, pt := range strings.Split(t, ",") {
if pt = strings.TrimSpace(pt); pt != "" {
predicateTypes = append(predicateTypes, pt)
}
}
}
statements, err := ir.backend.ImageAttestations(ctx, vars["name"], imagebackend.AttestationOpts{
Platform: platform,
PredicateTypes: predicateTypes,
IncludeStatement: httputils.BoolValue(r, "statement"),
})
if err != nil {
return err
}
if statements == nil {
statements = []imagetypes.AttestationStatement{}
}
return httputils.WriteJSON(w, http.StatusOK, statements)
}
// noBaseImageSpecifier is the symbol used by the FROM
// command to specify that no base image is to be used.
const noBaseImageSpecifier = "scratch"
+251
View File
@@ -0,0 +1,251 @@
package image
import (
"encoding/json"
"os"
"path/filepath"
"testing"
cerrdefs "github.com/containerd/errdefs"
"github.com/containerd/platforms"
"github.com/distribution/reference"
"github.com/moby/buildkit/util/attestation"
"github.com/moby/moby/client"
iimage "github.com/moby/moby/v2/integration/internal/image"
"github.com/opencontainers/go-digest"
"github.com/opencontainers/image-spec/specs-go"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/skip"
)
// TestImageAttestations exercises GET /images/{name}/attestations against a
// live daemon.
func TestImageAttestations(t *testing.T) {
skip.If(t, !testEnv.UsingSnapshotter(), "attestation manifests require the containerd image store")
ctx := setupTest(t)
apiClient := testEnv.APIClient()
hostPlatform := platforms.DefaultSpec()
const imageRef = "attestation-test:latest"
const provenanceType = "https://slsa.dev/provenance/v0.2"
const sbomType = "https://spdx.dev/Document"
// Load an image that has both a SLSA provenance and an SPDX SBOM attestation.
iimage.Load(ctx, t, apiClient, func(dir string) (*ocispec.Index, error) {
return buildAttestationImage(t, dir, imageRef, hostPlatform, []statementLayer{
{
predicateType: provenanceType,
payload: mustMarshal(map[string]any{
"_type": "https://in-toto.io/Statement/v0.1",
"predicateType": provenanceType,
"subject": []map[string]any{{"name": imageRef}},
"predicate": map[string]any{
"builder": map[string]any{"id": "https://github.com/moby/buildkit"},
"buildType": "https://mobyproject.org/buildkit@v1",
},
}),
},
{
predicateType: sbomType,
payload: mustMarshal(map[string]any{
"_type": "https://in-toto.io/Statement/v0.1",
"predicateType": sbomType,
"subject": []map[string]any{{"name": imageRef}},
"predicate": map[string]any{"spdxVersion": "SPDX-2.3", "name": imageRef},
}),
},
})
})
defer func() {
_, _ = apiClient.ImageRemove(ctx, imageRef, client.ImageRemoveOptions{Force: true})
}()
t.Run("default_omits_statement_body", func(t *testing.T) {
result, err := apiClient.ImageAttestations(ctx, imageRef)
assert.NilError(t, err)
assert.Assert(t, is.Len(result.Items, 2))
types := map[string]bool{}
for _, s := range result.Items {
types[s.PredicateType] = true
assert.Check(t, s.Statement == nil, "statement body should be omitted by default")
assert.Check(t, s.Descriptor.Digest != "", "statement digest must not be empty")
assert.Check(t, s.Descriptor.MediaType != "", "statement media type must not be empty")
}
assert.Check(t, types[provenanceType], "provenance statement not returned")
assert.Check(t, types[sbomType], "SBOM statement not returned")
})
t.Run("with_statement_returns_bodies", func(t *testing.T) {
result, err := apiClient.ImageAttestations(ctx, imageRef,
client.ImageAttestationsWithStatement())
assert.NilError(t, err)
assert.Assert(t, is.Len(result.Items, 2))
types := map[string]bool{}
for _, s := range result.Items {
types[s.PredicateType] = true
assert.Assert(t, s.Statement != nil, "statement body should be present when opted in")
var raw map[string]any
assert.NilError(t, json.Unmarshal(*s.Statement, &raw), "statement is not valid JSON")
assert.Check(t, s.Descriptor.Digest != "", "statement digest must not be empty")
assert.Check(t, s.Descriptor.MediaType != "", "statement media type must not be empty")
}
assert.Check(t, types[provenanceType], "provenance statement not returned")
assert.Check(t, types[sbomType], "SBOM statement not returned")
})
t.Run("filter_by_predicate_type", func(t *testing.T) {
result, err := apiClient.ImageAttestations(ctx, imageRef,
client.ImageAttestationsWithPredicateTypes(provenanceType))
assert.NilError(t, err)
assert.Assert(t, is.Len(result.Items, 1))
assert.Check(t, is.Equal(result.Items[0].PredicateType, provenanceType))
})
t.Run("filter_by_multiple_predicate_types", func(t *testing.T) {
result, err := apiClient.ImageAttestations(ctx, imageRef,
client.ImageAttestationsWithPredicateTypes(provenanceType, sbomType))
assert.NilError(t, err)
assert.Assert(t, is.Len(result.Items, 2))
types := map[string]bool{}
for _, s := range result.Items {
types[s.PredicateType] = true
}
assert.Check(t, types[provenanceType], "provenance statement missing from multi-type filter result")
assert.Check(t, types[sbomType], "SBOM statement missing from multi-type filter result")
})
t.Run("explicit_platform_matches", func(t *testing.T) {
result, err := apiClient.ImageAttestations(ctx, imageRef,
client.ImageAttestationsWithPlatform(hostPlatform))
assert.NilError(t, err)
assert.Assert(t, is.Len(result.Items, 2))
})
t.Run("wrong_platform_returns_not_found", func(t *testing.T) {
_, err := apiClient.ImageAttestations(ctx, imageRef,
client.ImageAttestationsWithPlatform(ocispec.Platform{OS: "linux", Architecture: "riscv64"}))
assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
})
t.Run("unknown_image_returns_not_found", func(t *testing.T) {
_, err := apiClient.ImageAttestations(ctx, "no-such-image:latest")
assert.Check(t, is.ErrorType(err, cerrdefs.IsNotFound))
})
t.Run("empty_filter_returns_all", func(t *testing.T) {
result, err := apiClient.ImageAttestations(ctx, imageRef,
client.ImageAttestationsWithPredicateTypes())
assert.NilError(t, err)
assert.Assert(t, is.Len(result.Items, 2))
})
}
// statementLayer is a single in-toto statement to embed in the attestation manifest.
type statementLayer struct {
predicateType string
payload []byte
}
// buildAttestationImage writes an OCI image layout to dir containing a minimal
// platform image manifest and an attestation manifest pointing to it.
func buildAttestationImage(t *testing.T, dir string, imageRef string, platform ocispec.Platform, stmts []statementLayer) (*ocispec.Index, error) {
t.Helper()
ref, err := reference.ParseNormalizedNamed(imageRef)
if err != nil {
return nil, err
}
// Minimal platform image: one layer + config.
layerDesc := writeBlob(t, dir, "application/vnd.oci.image.layer.v1.tar+gzip", []byte("layer"))
configDesc := writeJSON(t, dir, ocispec.MediaTypeImageConfig, ocispec.Image{
Platform: platform,
RootFS: ocispec.RootFS{Type: "layers", DiffIDs: []digest.Digest{layerDesc.Digest}},
})
imgMfstDesc := writeJSON(t, dir, ocispec.MediaTypeImageManifest, ocispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageManifest,
Config: configDesc,
Layers: []ocispec.Descriptor{layerDesc},
})
imgMfstDesc.Platform = &platform
// Attestation manifest.
var layerDescs []ocispec.Descriptor
for _, s := range stmts {
d := writeBlob(t, dir, "application/vnd.in-toto+json", s.payload)
d.Annotations = map[string]string{"in-toto.io/predicate-type": s.predicateType}
layerDescs = append(layerDescs, d)
}
attConfigDesc := writeJSON(t, dir, ocispec.MediaTypeImageConfig, struct{}{})
attMfstDesc := writeJSON(t, dir, ocispec.MediaTypeImageManifest, ocispec.Manifest{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageManifest,
Config: attConfigDesc,
Layers: layerDescs,
})
attMfstDesc.Annotations = map[string]string{
attestation.DockerAnnotationReferenceType: attestation.DockerAnnotationReferenceTypeDefault,
attestation.DockerAnnotationReferenceDigest: imgMfstDesc.Digest.String(),
}
attMfstDesc.Platform = &ocispec.Platform{OS: "unknown", Architecture: "unknown"}
// Outer index.
innerIdx := ocispec.Index{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageIndex,
Manifests: []ocispec.Descriptor{imgMfstDesc, attMfstDesc},
}
innerIdxDesc := writeJSON(t, dir, ocispec.MediaTypeImageIndex, innerIdx)
innerIdxDesc.Annotations = map[string]string{
"io.containerd.image.name": ref.String(),
ocispec.AnnotationRefName: ref.(reference.Tagged).Tag(),
}
outerIdx := &ocispec.Index{
Versioned: specs.Versioned{SchemaVersion: 2},
MediaType: ocispec.MediaTypeImageIndex,
Manifests: []ocispec.Descriptor{innerIdxDesc},
}
b, err := json.Marshal(outerIdx)
if err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(dir, "index.json"), b, 0o644); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(dir, "oci-layout"), []byte(`{"imageLayoutVersion":"1.0.0"}`), 0o644); err != nil {
return nil, err
}
return outerIdx, nil
}
func writeBlob(t *testing.T, dir, mediaType string, data []byte) ocispec.Descriptor {
t.Helper()
dgst := digest.FromBytes(data)
blobsDir := filepath.Join(dir, "blobs", "sha256")
assert.NilError(t, os.MkdirAll(blobsDir, 0o755))
assert.NilError(t, os.WriteFile(filepath.Join(blobsDir, dgst.Encoded()), data, 0o644))
return ocispec.Descriptor{MediaType: mediaType, Digest: dgst, Size: int64(len(data))}
}
func writeJSON(t *testing.T, dir, mediaType string, v any) ocispec.Descriptor {
t.Helper()
b, err := json.Marshal(v)
assert.NilError(t, err)
return writeBlob(t, dir, mediaType, b)
}
func mustMarshal(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
+19
View File
@@ -0,0 +1,19 @@
package image
import (
"encoding/json"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
// AttestationStatement is a single in-toto statement attached to an image.
type AttestationStatement struct {
// Descriptor is the OCI descriptor of the statement blob (media type,
// digest, size, annotations).
Descriptor ocispec.Descriptor `json:"Descriptor"`
// PredicateType is the in-toto predicate type URI of this statement.
PredicateType string `json:"PredicateType"`
// Statement is the verbatim in-toto statement JSON. Omitted unless the
// caller opts in via the statement=true query parameter.
Statement *json.RawMessage `json:"Statement,omitempty"`
}
+1
View File
@@ -133,6 +133,7 @@ type ImageAPIClient interface {
ImageInspect(ctx context.Context, image string, _ ...ImageInspectOption) (ImageInspectResult, error)
ImageHistory(ctx context.Context, image string, _ ...ImageHistoryOption) (ImageHistoryResult, error)
ImageAttestations(ctx context.Context, image string, _ ...ImageAttestationsOption) (ImageAttestationsResult, error)
ImageLoad(ctx context.Context, input io.Reader, _ ...ImageLoadOption) (ImageLoadResult, error)
ImageSave(ctx context.Context, images []string, _ ...ImageSaveOption) (ImageSaveResult, error)
+52
View File
@@ -0,0 +1,52 @@
package client
import (
"context"
"encoding/json"
"net/url"
"strings"
)
// ImageAttestations returns the in-toto attestation statements attached to an
// image for the given platform. This requires API version 1.55 or higher.
func (cli *Client) ImageAttestations(ctx context.Context, imageID string, opts ...ImageAttestationsOption) (ImageAttestationsResult, error) {
if imageID == "" {
return ImageAttestationsResult{}, objectNotFoundError{object: "image", id: imageID}
}
if err := cli.requiresVersion(ctx, "1.55", "attestations"); err != nil {
return ImageAttestationsResult{}, err
}
var o imageAttestationsOpts
for _, opt := range opts {
if err := opt.Apply(&o); err != nil {
return ImageAttestationsResult{}, err
}
}
query := url.Values{}
if o.platform != nil {
p, err := encodePlatform(o.platform)
if err != nil {
return ImageAttestationsResult{}, err
}
query.Set("platform", p)
}
if len(o.predicateTypes) > 0 {
query.Set("type", strings.Join(o.predicateTypes, ","))
}
if o.includeStatement {
query.Set("statement", "1")
}
resp, err := cli.get(ctx, "/images/"+imageID+"/attestations", query, nil)
defer ensureReaderClosed(resp)
if err != nil {
return ImageAttestationsResult{}, err
}
var result ImageAttestationsResult
err = json.NewDecoder(resp.Body).Decode(&result.Items)
return result, err
}
+56
View File
@@ -0,0 +1,56 @@
package client
import (
"github.com/moby/moby/api/types/image"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)
// ImageAttestationsResult is the result of an ImageAttestations operation.
type ImageAttestationsResult struct {
Items []image.AttestationStatement
}
// ImageAttestationsOption is a functional option for the ImageAttestations operation.
type ImageAttestationsOption interface {
Apply(*imageAttestationsOpts) error
}
type imageAttestationsOptionFunc func(*imageAttestationsOpts) error
func (f imageAttestationsOptionFunc) Apply(o *imageAttestationsOpts) error { return f(o) }
type imageAttestationsOpts struct {
platform *ocispec.Platform
predicateTypes []string
includeStatement bool
}
// ImageAttestationsWithPlatform filters attestations to those for the given
// platform variant. If omitted, the daemon's default platform is used.
func ImageAttestationsWithPlatform(platform ocispec.Platform) ImageAttestationsOption {
return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
o.platform = &platform
return nil
})
}
// ImageAttestationsWithPredicateTypes filters returned statements to those
// whose in-toto predicate type matches one of the given URIs.
// If not set, all statements are returned.
func ImageAttestationsWithPredicateTypes(types ...string) ImageAttestationsOption {
return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
o.predicateTypes = append(o.predicateTypes, types...)
return nil
})
}
// ImageAttestationsWithStatement asks the daemon to include the verbatim
// in-toto statement body in each returned entry. Without this option, only
// the descriptor and predicate type are returned and statement blobs are
// not read.
func ImageAttestationsWithStatement() ImageAttestationsOption {
return imageAttestationsOptionFunc(func(o *imageAttestationsOpts) error {
o.includeStatement = true
return nil
})
}