Merge pull request #2298 from LaurentGoderre/imagetools-inspect-tests

Add tests for imagetools inspect
This commit is contained in:
Tõnis Tiigi 2024-03-15 13:04:06 -07:00 committed by GitHub
commit 520dc5968a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 595 additions and 2 deletions

2
go.mod
View File

@ -25,6 +25,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-cty-funcs v0.0.0-20230405223818-a090f58aa992 github.com/hashicorp/go-cty-funcs v0.0.0-20230405223818-a090f58aa992
github.com/hashicorp/hcl/v2 v2.19.1 github.com/hashicorp/hcl/v2 v2.19.1
github.com/in-toto/in-toto-golang v0.5.0
github.com/moby/buildkit v0.13.0 github.com/moby/buildkit v0.13.0
github.com/moby/sys/mountinfo v0.7.1 github.com/moby/sys/mountinfo v0.7.1
github.com/moby/sys/signal v0.7.0 github.com/moby/sys/signal v0.7.0
@ -107,7 +108,6 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/imdario/mergo v0.3.16 // indirect github.com/imdario/mergo v0.3.16 // indirect
github.com/in-toto/in-toto-golang v0.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect

View File

@ -0,0 +1,399 @@
package imagetools
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/containerd/containerd/remotes"
intoto "github.com/in-toto/in-toto-golang/in_toto"
slsa02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)
type attestationType int
const (
plainSpdx attestationType = 0
dsseEmbeded attestationType = 1
plainSpdxAndDSSEEmbed attestationType = 2
)
type mockFetcher struct {
}
type mockResolver struct {
fetcher remotes.Fetcher
pusher remotes.Pusher
}
var manifests = make(map[digest.Digest]manifest)
var indexes = make(map[digest.Digest]index)
func (f mockFetcher) Fetch(ctx context.Context, desc ocispec.Descriptor) (io.ReadCloser, error) {
switch desc.MediaType {
case ocispec.MediaTypeImageIndex:
reader := io.NopCloser(strings.NewReader(indexes[desc.Digest].desc.Annotations["test_content"]))
return reader, nil
case ocispec.MediaTypeImageManifest:
reader := io.NopCloser(strings.NewReader(manifests[desc.Digest].desc.Annotations["test_content"]))
return reader, nil
default:
reader := io.NopCloser(strings.NewReader(desc.Annotations["test_content"]))
return reader, nil
}
}
func (r mockResolver) Resolve(ctx context.Context, ref string) (name string, desc ocispec.Descriptor, err error) {
d := digest.Digest(strings.ReplaceAll(ref, "docker.io/library/test@", ""))
return string(d), indexes[d].desc, nil
}
func (r mockResolver) Fetcher(ctx context.Context, ref string) (remotes.Fetcher, error) {
return r.fetcher, nil
}
func (r mockResolver) Pusher(ctx context.Context, ref string) (remotes.Pusher, error) {
return r.pusher, nil
}
func getMockResolver() remotes.Resolver {
resolver := mockResolver{
fetcher: mockFetcher{},
}
return resolver
}
func getImageNoAttestation() *result {
return getImageFromManifests(getBaseManifests())
}
func getImageWithAttestation(t attestationType) *result {
manifestList := getBaseManifests()
objManifest := ocispec.Manifest{
MediaType: v1.MediaTypeImageManifest,
Layers: getAttestationLayers(t),
Annotations: map[string]string{
"platform": "linux/amd64",
},
}
jsonContent, _ := json.Marshal(objManifest)
jsonString := string(jsonContent)
d := digest.FromString(jsonString)
manifestList[d] = manifest{
desc: ocispec.Descriptor{
MediaType: v1.MediaTypeImageManifest,
Digest: d,
Size: int64(len(jsonString)),
Annotations: map[string]string{
"vnd.docker.reference.digest": string(getManifestDigestForArch(manifestList, "linux", "amd64")),
"vnd.docker.reference.type": "attestation-manifest",
"test_content": jsonString,
},
Platform: &v1.Platform{
Architecture: "unknown",
OS: "unknown",
},
},
manifest: objManifest,
}
objManifest = ocispec.Manifest{
MediaType: v1.MediaTypeImageManifest,
Layers: getAttestationLayers(t),
Annotations: map[string]string{
"platform": "linux/arm64",
},
}
jsonContent, _ = json.Marshal(objManifest)
jsonString = string(jsonContent)
d = digest.FromString(jsonString)
manifestList[d] = manifest{
desc: ocispec.Descriptor{
MediaType: v1.MediaTypeImageManifest,
Digest: d,
Size: int64(len(jsonString)),
Annotations: map[string]string{
"vnd.docker.reference.digest": string(getManifestDigestForArch(manifestList, "linux", "arm64")),
"vnd.docker.reference.type": "attestation-manifest",
"test_content": jsonString,
},
Platform: &v1.Platform{
Architecture: "unknown",
OS: "unknown",
},
},
}
return getImageFromManifests(manifestList)
}
func getImageFromManifests(manifests map[digest.Digest]manifest) *result {
r := &result{
indexes: make(map[digest.Digest]index),
manifests: manifests,
images: make(map[string]digest.Digest),
refs: make(map[digest.Digest][]digest.Digest),
assets: make(map[string]asset),
}
r.images["linux/amd64"] = getManifestDigestForArch(manifests, "linux", "amd64")
r.images["linux/arm64"] = getManifestDigestForArch(manifests, "linux", "arm64")
manifestsDesc := []v1.Descriptor{}
for _, val := range manifests {
manifestsDesc = append(manifestsDesc, val.desc)
}
objIndex := v1.Index{
MediaType: v1.MediaTypeImageIndex,
Manifests: manifestsDesc,
}
jsonContent, _ := json.Marshal(objIndex)
jsonString := string(jsonContent)
d := digest.FromString(jsonString)
if _, ok := indexes[d]; !ok {
indexes[d] = index{
desc: ocispec.Descriptor{
MediaType: v1.MediaTypeImageIndex,
Digest: d,
Size: int64(len(jsonString)),
Annotations: map[string]string{
"test_content": jsonString,
},
},
index: objIndex,
}
}
r.indexes[d] = indexes[d]
return r
}
func getManifestDigestForArch(manifests map[digest.Digest]manifest, os string, arch string) digest.Digest {
for d, m := range manifests {
if m.desc.Platform.OS == os && m.desc.Platform.Architecture == arch {
return d
}
}
return digest.Digest("")
}
func getBaseManifests() map[digest.Digest]manifest {
if len(manifests) == 0 {
config := getConfig()
content := "amd64-content"
objManifest := ocispec.Manifest{
MediaType: v1.MediaTypeImageManifest,
Config: config,
Layers: []v1.Descriptor{
{
MediaType: v1.MediaTypeImageLayerGzip,
Digest: digest.FromString(content),
Size: int64(len(content)),
},
},
}
jsonContent, _ := json.Marshal(objManifest)
jsonString := string(jsonContent)
d := digest.FromString(jsonString)
manifests[d] = manifest{
desc: ocispec.Descriptor{
MediaType: v1.MediaTypeImageManifest,
Digest: d,
Size: int64(len(jsonString)),
Platform: &v1.Platform{
Architecture: "amd64",
OS: "linux",
},
Annotations: map[string]string{
"test_content": jsonString,
},
},
manifest: objManifest,
}
content = "arm64-content"
objManifest = ocispec.Manifest{
MediaType: v1.MediaTypeImageManifest,
Config: config,
Layers: []v1.Descriptor{
{
MediaType: v1.MediaTypeImageLayerGzip,
Digest: digest.FromString(content),
Size: int64(len(content)),
},
},
}
jsonContent, _ = json.Marshal(objManifest)
jsonString = string(jsonContent)
d = digest.FromString(jsonString)
manifests[d] = manifest{
desc: ocispec.Descriptor{
MediaType: v1.MediaTypeImageManifest,
Digest: d,
Size: int64(len(jsonString)),
Platform: &v1.Platform{
Architecture: "arm64",
OS: "linux",
},
Annotations: map[string]string{
"test_content": jsonString,
},
},
manifest: objManifest,
}
}
return manifests
}
func getConfig() v1.Descriptor {
config := v1.ImageConfig{
Env: []string{
"config",
},
}
jsonContent, _ := json.Marshal(config)
jsonString := string(jsonContent)
d := digest.FromString(jsonString)
return v1.Descriptor{
MediaType: ocispec.MediaTypeImageConfig,
Digest: d,
Size: int64(len(jsonString)),
Annotations: map[string]string{
"test_content": jsonString,
},
}
}
func getAttestationLayers(t attestationType) []v1.Descriptor {
layers := []v1.Descriptor{}
if t == plainSpdx || t == plainSpdxAndDSSEEmbed {
layers = append(layers, v1.Descriptor{
MediaType: inTotoGenericMime,
Digest: digest.FromString(attestationContent),
Size: int64(len(attestationContent)),
Annotations: map[string]string{
"in-toto.io/predicate-type": intoto.PredicateSPDX,
"test_content": attestationContent,
},
})
layers = append(layers, v1.Descriptor{
MediaType: inTotoGenericMime,
Digest: digest.FromString(provenanceContent),
Size: int64(len(provenanceContent)),
Annotations: map[string]string{
"in-toto.io/predicate-type": slsa02.PredicateSLSAProvenance,
"test_content": provenanceContent,
},
})
}
if t == dsseEmbeded || t == plainSpdxAndDSSEEmbed {
dsseAttestation := fmt.Sprintf("{\"payload\":\"%s\"}", base64.StdEncoding.EncodeToString([]byte(attestationContent)))
dsseProvenance := fmt.Sprintf("{\"payload\":\"%s\"}", base64.StdEncoding.EncodeToString([]byte(provenanceContent)))
layers = append(layers, v1.Descriptor{
MediaType: inTotoSPDXDSSEMime,
Digest: digest.FromString(dsseAttestation),
Size: int64(len(dsseAttestation)),
Annotations: map[string]string{
"in-toto.io/predicate-type": intoto.PredicateSPDX,
"test_content": dsseAttestation,
},
})
layers = append(layers, v1.Descriptor{
MediaType: inTotoProvenanceDSSEMime,
Digest: digest.FromString(dsseProvenance),
Size: int64(len(dsseProvenance)),
Annotations: map[string]string{
"in-toto.io/predicate-type": slsa02.PredicateSLSAProvenance,
"test_content": dsseProvenance,
},
})
}
return layers
}
const attestationContent = `
{
"_type": "https://in-toto.io/Statement/v0.1",
"predicateType": "https://spdx.dev/Document",
"predicate": {
"name": "sbom",
"spdxVersion": "SPDX-2.3",
"SPDXID": "SPDXRef-DOCUMENT",
"creationInfo": {
"created": "2024-01-31T16:09:05Z",
"creators": [
"Tool: buildkit-v0.11.0"
],
"licenseListVersion": "3.22"
},
"dataLicense": "CC0-1.0",
"documentNamespace": "https://example.com",
"packages": [
{
"name": "sbom",
"SPDXID": "SPDXRef-DocumentRoot-Directory-sbom",
"copyrightText": "",
"downloadLocation": "NOASSERTION",
"primaryPackagePurpose": "FILE",
"supplier": "NOASSERTION"
}
],
"relationships": [
{
"relatedSpdxElement": "SPDXRef-DocumentRoot-Directory-sbom",
"relationshipType": "DESCRIBES",
"spdxElementId": "SPDXRef-DOCUMENT"
}
]
}
}
`
const provenanceContent = `
{
"_type": "https://in-toto.io/Statement/v0.1",
"predicateType": "https://slsa.dev/provenance/v0.2",
"predicate": {
"buildType": "https://example.com/Makefile",
"builder": {
"id": "mailto:person@example.com"
},
"invocation": {
"configSource": {
"uri": "https://example.com/example-1.2.3.tar.gz",
"digest": {"sha256": ""},
"entryPoint": "src:foo"
},
"parameters": {
"CFLAGS": "-O3"
},
"materials": [
{
"uri": "https://example.com/example-1.2.3.tar.gz",
"digest": {"sha256": ""}
}
]
}
}
}
`

View File

@ -16,6 +16,7 @@ import (
"github.com/containerd/containerd/platforms" "github.com/containerd/containerd/platforms"
"github.com/containerd/containerd/remotes" "github.com/containerd/containerd/remotes"
"github.com/distribution/reference" "github.com/distribution/reference"
intoto "github.com/in-toto/in-toto-golang/in_toto"
"github.com/moby/buildkit/util/contentutil" "github.com/moby/buildkit/util/contentutil"
"github.com/opencontainers/go-digest" "github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
@ -292,7 +293,7 @@ func (l *loader) scanSBOM(ctx context.Context, fetcher remotes.Fetcher, r *resul
} }
for _, layer := range mfst.manifest.Layers { for _, layer := range mfst.manifest.Layers {
if (layer.MediaType == inTotoGenericMime || isInTotoDSSE(layer.MediaType)) && if (layer.MediaType == inTotoGenericMime || isInTotoDSSE(layer.MediaType)) &&
layer.Annotations["in-toto.io/predicate-type"] == "https://spdx.dev/Document" { layer.Annotations["in-toto.io/predicate-type"] == intoto.PredicateSPDX {
_, err := remotes.FetchHandler(l.cache, fetcher)(ctx, layer) _, err := remotes.FetchHandler(l.cache, fetcher)(ctx, layer)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -0,0 +1,193 @@
package imagetools
import (
"context"
"encoding/base64"
"fmt"
"reflect"
"testing"
"github.com/opencontainers/go-digest"
"github.com/stretchr/testify/assert"
)
func TestLoad(t *testing.T) {
loader := newLoader(getMockResolver())
ctx := context.Background()
r := getImageNoAttestation()
indexDigest := reflect.ValueOf(r.indexes).MapKeys()[0].String()
result, err := loader.Load(ctx, fmt.Sprintf("test@%s", indexDigest))
assert.NoError(t, err)
if err == nil {
assert.Equal(t, 1, len(result.indexes))
assert.Equal(t, 2, len(result.images))
assert.Equal(t, 2, len(result.platforms))
assert.Equal(t, 2, len(result.manifests))
assert.Equal(t, 2, len(result.assets))
assert.Equal(t, 0, len(result.refs))
}
r = getImageWithAttestation(plainSpdx)
indexDigest = reflect.ValueOf(r.indexes).MapKeys()[0].String()
result, err = loader.Load(ctx, fmt.Sprintf("test@%s", indexDigest))
assert.NoError(t, err)
if err == nil {
assert.Equal(t, 1, len(result.indexes))
assert.Equal(t, 2, len(result.images))
assert.Equal(t, 2, len(result.platforms))
assert.Equal(t, 4, len(result.manifests))
assert.Equal(t, 2, len(result.assets))
assert.Equal(t, 2, len(result.refs))
for d1, m := range r.manifests {
if _, ok := m.desc.Annotations["vnd.docker.reference.digest"]; ok {
d2 := digest.Digest(m.desc.Annotations["vnd.docker.reference.digest"])
assert.Equal(t, d1, result.refs[d2][0])
}
}
}
}
func TestSBOM(t *testing.T) {
tests := []struct {
name string
contentType attestationType
}{
{
name: "Plain SPDX",
contentType: plainSpdx,
},
{
name: "SPDX in DSSE envelope",
contentType: dsseEmbeded,
},
{
name: "Plain SPDX and SPDX in DSSE envelope",
contentType: plainSpdxAndDSSEEmbed,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
loader := newLoader(getMockResolver())
ctx := context.Background()
fetcher, _ := loader.resolver.Fetcher(ctx, "")
r := getImageWithAttestation(test.contentType)
imageDigest := r.images["linux/amd64"]
// Manual mapping
for d, m := range r.manifests {
if m.desc.Annotations["vnd.docker.reference.digest"] == string(imageDigest) {
r.refs[imageDigest] = []digest.Digest{
d,
}
}
}
a := asset{}
loader.scanSBOM(ctx, fetcher, r, r.refs[imageDigest], &a)
r.assets["linux/amd64"] = a
actual, err := r.SBOM()
assert.NoError(t, err)
assert.Equal(t, 1, len(actual))
})
}
}
func TestProvenance(t *testing.T) {
tests := []struct {
name string
contentType attestationType
}{
{
name: "Plain SPDX",
contentType: plainSpdx,
},
{
name: "SPDX in DSSE envelope",
contentType: dsseEmbeded,
},
{
name: "Plain SPDX and SPDX in DSSE envelope",
contentType: plainSpdxAndDSSEEmbed,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
loader := newLoader(getMockResolver())
ctx := context.Background()
fetcher, _ := loader.resolver.Fetcher(ctx, "")
r := getImageWithAttestation(test.contentType)
imageDigest := r.images["linux/amd64"]
// Manual mapping
for d, m := range r.manifests {
if m.desc.Annotations["vnd.docker.reference.digest"] == string(imageDigest) {
r.refs[imageDigest] = []digest.Digest{
d,
}
}
}
a := asset{}
loader.scanProvenance(ctx, fetcher, r, r.refs[imageDigest], &a)
r.assets["linux/amd64"] = a
actual, err := r.Provenance()
assert.NoError(t, err)
assert.Equal(t, 1, len(actual))
})
}
}
func Test_isInTotoDSSE(t *testing.T) {
tests := []struct {
mime string
expected bool
}{
{
mime: "application/vnd.in-toto.spdx+dsse",
expected: true,
},
{
mime: "application/vnd.in-toto.provenance+dsse",
expected: true,
},
{
mime: "application/vnd.in-toto+json",
expected: false,
},
}
for _, test := range tests {
t.Run(test.mime, func(t *testing.T) {
assert.Equal(t, isInTotoDSSE(test.mime), test.expected)
})
}
}
func Test_decodeDSSE(t *testing.T) {
// Returns input when mime isn't a DSSE type
actual, err := decodeDSSE([]byte("foobar"), "application/vnd.in-toto+json")
assert.NoError(t, err)
assert.Equal(t, []byte("foobar"), actual)
// Returns the base64 decoded payload if is a DSSE
payload := base64.StdEncoding.EncodeToString([]byte("hello world"))
envelope := fmt.Sprintf("{\"payload\":\"%s\"}", payload)
actual, err = decodeDSSE([]byte(envelope), "application/vnd.in-toto.spdx+dsse")
assert.NoError(t, err)
assert.Equal(t, "hello world", string(actual))
_, err = decodeDSSE([]byte("not a json"), "application/vnd.in-toto.spdx+dsse")
assert.Error(t, err)
_, err = decodeDSSE([]byte("{\"payload\": \"not base64\"}"), "application/vnd.in-toto.spdx+dsse")
assert.Error(t, err)
}