From 6f7bd7451b6ab2c2cd799f6fe3a15c90d77ff42a Mon Sep 17 00:00:00 2001 From: Sambhav Kothari <sambhavs.email@gmail.com> Date: Mon, 11 Apr 2022 10:30:38 +0100 Subject: [PATCH] Refactor image extraction to allow extracting custom resources (#3572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: image extraction Signed-off-by: Charles-Edouard Brétéché <charled.breteche@gmail.com> * Refactor image extraction to allow extracting custom resources Signed-off-by: Sambhav Kothari <skothari44@bloomberg.net> Co-authored-by: Charles-Edouard Brétéché <charled.breteche@gmail.com> --- pkg/engine/context/context.go | 22 ++-- pkg/engine/context/imageutils.go | 183 +------------------------- pkg/engine/context/imageutils_test.go | 125 ------------------ pkg/engine/imageVerify.go | 89 ++++++------- pkg/utils/image/infos.go | 77 +++++++++++ pkg/utils/image/infos_test.go | 73 ++++++++++ pkg/utils/kube/image.go | 119 +++++++++++++++++ pkg/utils/kube/image_test.go | 69 ++++++++++ 8 files changed, 401 insertions(+), 356 deletions(-) delete mode 100644 pkg/engine/context/imageutils_test.go create mode 100644 pkg/utils/image/infos.go create mode 100644 pkg/utils/image/infos_test.go create mode 100644 pkg/utils/kube/image.go create mode 100644 pkg/utils/kube/image_test.go diff --git a/pkg/engine/context/context.go b/pkg/engine/context/context.go index 59e24fe16c..4050f54f80 100644 --- a/pkg/engine/context/context.go +++ b/pkg/engine/context/context.go @@ -8,6 +8,8 @@ import ( jsonpatch "github.com/evanphx/json-patch/v5" kyverno "github.com/kyverno/kyverno/api/kyverno/v1" pkgcommon "github.com/kyverno/kyverno/pkg/common" + imageutils "github.com/kyverno/kyverno/pkg/utils/image" + kubeutils "github.com/kyverno/kyverno/pkg/utils/kube" "github.com/pkg/errors" admissionv1 "k8s.io/api/admission/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -58,13 +60,13 @@ type Interface interface { AddElement(data map[string]interface{}, index int) error // AddImageInfo adds image info to the context - AddImageInfo(info *ImageInfo) error + AddImageInfo(info imageutils.ImageInfo) error // AddImageInfos adds image infos to the context AddImageInfos(resource *unstructured.Unstructured) error // ImageInfo returns image infos present in the context - ImageInfo() *Images + ImageInfo() map[string]map[string]imageutils.ImageInfo // Checkpoint creates a copy of the current internal state and pushes it into a stack of stored states. Checkpoint() @@ -86,7 +88,7 @@ type context struct { mutex sync.RWMutex jsonRaw []byte jsonRawCheckpoints [][]byte - images *Images + images map[string]map[string]imageutils.ImageInfo } // NewContext returns a new context @@ -209,7 +211,7 @@ func (ctx *context) AddElement(data map[string]interface{}, index int) error { return addToContext(ctx, data) } -func (ctx *context) AddImageInfo(info *ImageInfo) error { +func (ctx *context) AddImageInfo(info imageutils.ImageInfo) error { data := map[string]interface{}{ "image": info.String(), "registry": info.Registry, @@ -222,16 +224,18 @@ func (ctx *context) AddImageInfo(info *ImageInfo) error { } func (ctx *context) AddImageInfos(resource *unstructured.Unstructured) error { - initContainersImgs, containersImgs, ephemeralContainersImgs := extractImageInfo(resource, logger) - if len(initContainersImgs) == 0 && len(containersImgs) == 0 && len(ephemeralContainersImgs) == 0 { + images, err := kubeutils.ExtractImagesFromResource(*resource) + if err != nil { + return err + } + if len(images) == 0 { return nil } - images := newImages(initContainersImgs, containersImgs, ephemeralContainersImgs) - ctx.images = &images + ctx.images = images return addToContext(ctx, images, "images") } -func (ctx *context) ImageInfo() *Images { +func (ctx *context) ImageInfo() map[string]map[string]imageutils.ImageInfo { return ctx.images } diff --git a/pkg/engine/context/imageutils.go b/pkg/engine/context/imageutils.go index 8d1ee2d505..c6859982cd 100644 --- a/pkg/engine/context/imageutils.go +++ b/pkg/engine/context/imageutils.go @@ -2,179 +2,10 @@ package context import ( "fmt" - "strconv" - "strings" - "github.com/distribution/distribution/reference" - "github.com/go-logr/logr" engineutils "github.com/kyverno/kyverno/pkg/engine/utils" - "github.com/pkg/errors" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) -type ImageInfo struct { - // Registry is the URL address of the image registry e.g. `docker.io` - Registry string `json:"registry,omitempty"` - - // Name is the image name portion e.g. `busybox` - Name string `json:"name"` - - // Path is the repository path and image name e.g. `some-repository/busybox` - Path string `json:"path"` - - // Tag is the image tag e.g. `v2` - Tag string `json:"tag,omitempty"` - - // Digest is the image digest portion e.g. `sha256:128c6e3534b842a2eec139999b8ce8aa9a2af9907e2b9269550809d18cd832a3` - Digest string `json:"digest,omitempty"` - - // JSONPointer is full JSON path to this image e.g. `/spec/containers/0/image` - JSONPointer string `json:"jsonPath,omitempty"` -} - -func (i *ImageInfo) String() string { - image := i.Registry + "/" + i.Path + ":" + i.Tag - // image that needs only digest and not the tag - if i.Digest != "" { - image = i.Registry + "/" + i.Path + "@" + i.Digest - } - return image -} - -type ContainerImage struct { - ImageInfo - Name string -} - -type Images struct { - InitContainers map[string]ImageInfo `json:"initContainers,omitempty"` - Containers map[string]ImageInfo `json:"containers"` - EphemeralContainers map[string]ImageInfo `json:"ephemeralContainers"` -} - -func newImages(initContainersImgs, containersImgs, ephemeralContainersImgs []ContainerImage) Images { - initContainers := make(map[string]ImageInfo) - for _, resource := range initContainersImgs { - initContainers[resource.Name] = resource.ImageInfo - } - containers := make(map[string]ImageInfo) - for _, resource := range containersImgs { - containers[resource.Name] = resource.ImageInfo - } - ephemeralContainers := make(map[string]ImageInfo) - for _, resource := range ephemeralContainersImgs { - ephemeralContainers[resource.Name] = resource.ImageInfo - } - return Images{ - InitContainers: initContainers, - Containers: containers, - EphemeralContainers: ephemeralContainers, - } -} - -type imageExtractor struct { - fields []string -} - -func (i imageExtractor) extract(tag string, resource *unstructured.Unstructured) []ContainerImage { - f := append(i.fields[:len(i.fields):len(i.fields)], tag) - if containers, ok, _ := unstructured.NestedSlice(resource.UnstructuredContent(), f...); ok { - return extractImageInfos(containers, "/"+strings.Join(f, "/")) - } - return nil -} - -var extractors = map[string]imageExtractor{ - "Pod": {[]string{"spec"}}, - "CronJob": {[]string{"spec", "jobTemplate", "spec", "template", "spec"}}, - "Deployment": {[]string{"spec", "template", "spec"}}, - "DaemonSet": {[]string{"spec", "template", "spec"}}, - "Job": {[]string{"spec", "template", "spec"}}, - "StatefulSet": {[]string{"spec", "template", "spec"}}, -} - -func extractImageInfo(resource *unstructured.Unstructured, log logr.Logger) (initContainersImgs, containersImgs, ephemeralContainersImgs []ContainerImage) { - extractor := extractors[resource.GetKind()] - initContainersImgs = extractor.extract("initContainers", resource) - containersImgs = extractor.extract("containers", resource) - ephemeralContainersImgs = extractor.extract("ephemeralContainers", resource) - return -} - -func extractImageInfos(containers []interface{}, jsonPath string) []ContainerImage { - img, err := convertToImageInfo(containers, jsonPath) - if err != nil { - logger.Error(err, "failed to extract image info", "element", containers) - } - return img -} - -func convertToImageInfo(containers []interface{}, jsonPath string) (images []ContainerImage, err error) { - var errs []string - var index = 0 - for _, ctr := range containers { - if container, ok := ctr.(map[string]interface{}); ok { - var name, image string - name = container["name"].(string) - if _, ok := container["image"]; ok { - image = container["image"].(string) - } - jp := strings.Join([]string{jsonPath, strconv.Itoa(index), "image"}, "/") - imageInfo, err := newImageInfo(image, jp) - if err != nil { - errs = append(errs, err.Error()) - continue - } - images = append(images, ContainerImage{*imageInfo, name}) - } - index++ - } - if len(errs) == 0 { - return images, nil - } - return images, errors.Errorf("%s", strings.Join(errs, ";")) -} - -func newImageInfo(image, jsonPointer string) (*ImageInfo, error) { - image = addDefaultDomain(image) - ref, err := reference.Parse(image) - if err != nil { - return nil, errors.Wrapf(err, "bad image: %s", image) - } - var registry, path, name, tag, digest string - if named, ok := ref.(reference.Named); ok { - registry = reference.Domain(named) - path = reference.Path(named) - name = path[strings.LastIndex(path, "/")+1:] - } - if tagged, ok := ref.(reference.Tagged); ok { - tag = tagged.Tag() - } - if digested, ok := ref.(reference.Digested); ok { - digest = digested.Digest().String() - } - // set default tag - the domain is set via addDefaultDomain before parsing - if digest == "" && tag == "" { - tag = "latest" - } - return &ImageInfo{ - Registry: registry, - Name: name, - Path: path, - Tag: tag, - Digest: digest, - JSONPointer: jsonPointer, - }, nil -} - -func addDefaultDomain(name string) string { - i := strings.IndexRune(name, '/') - if i == -1 || (!strings.ContainsAny(name[:i], ".:") && name[:i] != "localhost" && strings.ToLower(name[:i]) == name[:i]) { - return "docker.io/" + name - } - return name -} - // MutateResourceWithImageInfo will set images to their canonical form so that they can be compared // in a predictable manner. This sets the default registry as `docker.io` and the tag as `latest` if // these are missing. @@ -183,19 +14,15 @@ func MutateResourceWithImageInfo(raw []byte, ctx Interface) error { if images == nil { return nil } + var patches [][]byte buildJSONPatch := func(op, path, value string) []byte { p := fmt.Sprintf(`{ "op": "%s", "path": "%s", "value":"%s" }`, op, path, value) return []byte(p) } - var patches [][]byte - for _, info := range images.Containers { - patches = append(patches, buildJSONPatch("replace", info.JSONPointer, info.String())) - } - for _, info := range images.InitContainers { - patches = append(patches, buildJSONPatch("replace", info.JSONPointer, info.String())) - } - for _, info := range images.EphemeralContainers { - patches = append(patches, buildJSONPatch("replace", info.JSONPointer, info.String())) + for _, infoMaps := range images { + for _, info := range infoMaps { + patches = append(patches, buildJSONPatch("replace", info.Pointer, info.String())) + } } patchedResource, err := engineutils.ApplyPatches(raw, patches) if err != nil { diff --git a/pkg/engine/context/imageutils_test.go b/pkg/engine/context/imageutils_test.go deleted file mode 100644 index 1b43eb7f7a..0000000000 --- a/pkg/engine/context/imageutils_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package context - -import ( - "testing" - - "github.com/kyverno/kyverno/pkg/engine/utils" - "github.com/stretchr/testify/assert" - "sigs.k8s.io/controller-runtime/pkg/log" -) - -func Test_extractImageInfo(t *testing.T) { - tests := []struct { - raw []byte - containers []ContainerImage - initContainers []ContainerImage - ephemeralContainers []ContainerImage - }{ - { - raw: []byte(`{"apiVersion": "v1","kind": "Pod","metadata": {"name": "myapp"},"spec": {"initContainers": [{"name": "init","image": "index.docker.io/busybox:v1.2.3"}],"containers": [{"name": "nginx","image": "nginx:latest"}], "ephemeralContainers": [{"name": "ephemeral", "image":"test/nginx:latest"}]}}`), - initContainers: []ContainerImage{{Name: "init", ImageInfo: ImageInfo{Registry: "index.docker.io", Name: "busybox", Path: "busybox", Tag: "v1.2.3", JSONPointer: "/spec/initContainers/0/image"}}}, - containers: []ContainerImage{{Name: "nginx", ImageInfo: ImageInfo{Registry: "docker.io", Name: "nginx", Path: "nginx", Tag: "latest", JSONPointer: "/spec/containers/0/image"}}}, - ephemeralContainers: []ContainerImage{{Name: "ephemeral", ImageInfo: ImageInfo{Registry: "docker.io", Name: "nginx", Path: "test/nginx", Tag: "latest", JSONPointer: "/spec/ephemeralContainers/0/image"}}}, - }, - { - raw: []byte(`{"apiVersion": "v1","kind": "Pod","metadata": {"name": "myapp"},"spec": {"containers": [{"name": "nginx","image": "test/nginx:latest"}]}}`), - initContainers: []ContainerImage{}, - containers: []ContainerImage{{Name: "nginx", ImageInfo: ImageInfo{Registry: "docker.io", Name: "nginx", Path: "test/nginx", Tag: "latest", JSONPointer: "/spec/containers/0/image"}}}, - ephemeralContainers: []ContainerImage{}, - }, - { - raw: []byte(`{"apiVersion": "apps/v1","kind": "Deployment","metadata": {"name": "myapp"},"spec": {"selector": {"matchLabels": {"app": "myapp"}},"template": {"metadata": {"labels": {"app": "myapp"}},"spec": {"initContainers": [{"name": "init","image": "fictional.registry.example:10443/imagename:tag@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}],"containers": [{"name": "myapp","image": "fictional.registry.example:10443/imagename"}],"ephemeralContainers": [{"name": "ephemeral","image": "fictional.registry.example:10443/imagename:tag@sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}] }}}}`), - initContainers: []ContainerImage{{Name: "init", ImageInfo: ImageInfo{Registry: "fictional.registry.example:10443", Name: "imagename", Path: "imagename", Tag: "tag", JSONPointer: "/spec/template/spec/initContainers/0/image", Digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}}}, - containers: []ContainerImage{{Name: "myapp", ImageInfo: ImageInfo{Registry: "fictional.registry.example:10443", Name: "imagename", Path: "imagename", Tag: "latest", JSONPointer: "/spec/template/spec/containers/0/image"}}}, - ephemeralContainers: []ContainerImage{{Name: "ephemeral", ImageInfo: ImageInfo{Registry: "fictional.registry.example:10443", Name: "imagename", Path: "imagename", Tag: "tag", JSONPointer: "/spec/template/spec/ephemeralContainers/0/image", Digest: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}}}, - }, - { - raw: []byte(`{"apiVersion": "batch/v1beta1","kind": "CronJob","metadata": {"name": "hello"},"spec": {"schedule": "*/1 * * * *","jobTemplate": {"spec": {"template": {"spec": {"containers": [{"name": "hello","image": "test.example.com/test/my-app:v2"}]}}}}}}`), - containers: []ContainerImage{{Name: "hello", ImageInfo: ImageInfo{Registry: "test.example.com", Name: "my-app", Path: "test/my-app", Tag: "v2", JSONPointer: "/spec/jobTemplate/spec/template/spec/containers/0/image"}}}, - }, - } - - for _, test := range tests { - resource, err := utils.ConvertToUnstructured(test.raw) - assert.Nil(t, err) - - init, container, ephemeral := extractImageInfo(resource, log.Log.WithName("TestExtractImageInfo")) - if len(test.initContainers) > 0 { - assert.Equal(t, test.initContainers, init, "unexpected initContainers %s", resource.GetName()) - } - - if len(test.containers) > 0 { - assert.Equal(t, test.containers, container, "unexpected containers %s", resource.GetName()) - } - - if len(test.ephemeralContainers) > 0 { - assert.Equal(t, test.ephemeralContainers, ephemeral, "unexpected ephemeralContainers %s", resource.GetName()) - } - } -} - -func Test_ImageInfo_String(t *testing.T) { - validateImageInfo(t, - "nginx", - "nginx", - "nginx", - "docker.io", - "latest", - "", - "docker.io/nginx:latest") - - validateImageInfo(t, - "nginx:v10.3", - "nginx", - "nginx", - "docker.io", - "v10.3", - "", - "docker.io/nginx:v10.3") - - validateImageInfo(t, - "docker.io/test/nginx:v10.3", - "nginx", - "test/nginx", - "docker.io", - "v10.3", - "", - "docker.io/test/nginx:v10.3") - - validateImageInfo(t, - "test/nginx", - "nginx", - "test/nginx", - "docker.io", - "latest", - "", - "docker.io/test/nginx:latest") - - validateImageInfo(t, - "localhost:4443/test/nginx", - "nginx", - "test/nginx", - "localhost:4443", - "latest", - "", - "localhost:4443/test/nginx:latest") - validateImageInfo(t, - "docker.io/test/centos@sha256:dead07b4d8ed7e29e98de0f4504d87e8880d4347859d839686a31da35a3b532f", - "centos", - "test/centos", - "docker.io", - "", - "sha256:dead07b4d8ed7e29e98de0f4504d87e8880d4347859d839686a31da35a3b532f", - "docker.io/test/centos@sha256:dead07b4d8ed7e29e98de0f4504d87e8880d4347859d839686a31da35a3b532f") -} - -func validateImageInfo(t *testing.T, raw, name, path, registry, tag, digest, str string) { - i1, err := newImageInfo(raw, "/spec/containers/0/image") - assert.Nil(t, err) - assert.Equal(t, name, i1.Name) - assert.Equal(t, path, i1.Path) - assert.Equal(t, registry, i1.Registry) - assert.Equal(t, tag, i1.Tag) - assert.Equal(t, digest, i1.Digest) - assert.Equal(t, str, i1.String()) -} diff --git a/pkg/engine/imageVerify.go b/pkg/engine/imageVerify.go index 2ee6b198e8..0a96474879 100644 --- a/pkg/engine/imageVerify.go +++ b/pkg/engine/imageVerify.go @@ -5,18 +5,18 @@ import ( "fmt" "time" - v1 "github.com/kyverno/kyverno/api/kyverno/v1" - "github.com/kyverno/kyverno/pkg/autogen" - "github.com/kyverno/kyverno/pkg/engine/variables" - "github.com/kyverno/kyverno/pkg/registryclient" - "github.com/pkg/errors" - "github.com/go-logr/logr" wildcard "github.com/kyverno/go-wildcard" + v1 "github.com/kyverno/kyverno/api/kyverno/v1" + "github.com/kyverno/kyverno/pkg/autogen" "github.com/kyverno/kyverno/pkg/cosign" "github.com/kyverno/kyverno/pkg/engine/context" "github.com/kyverno/kyverno/pkg/engine/response" "github.com/kyverno/kyverno/pkg/engine/utils" + "github.com/kyverno/kyverno/pkg/engine/variables" + "github.com/kyverno/kyverno/pkg/registryclient" + imageutils "github.com/kyverno/kyverno/pkg/utils/image" + "github.com/pkg/errors" "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -81,9 +81,7 @@ func VerifyAndPatchImages(policyContext *PolicyContext) (resp *response.EngineRe } for _, imageVerify := range ruleCopy.VerifyImages { - iv.verify(imageVerify, images.Containers) - iv.verify(imageVerify, images.InitContainers) - iv.verify(imageVerify, images.EphemeralContainers) + iv.verify(imageVerify, images) } } @@ -125,36 +123,39 @@ type imageVerifier struct { resp *response.EngineResponse } -func (iv *imageVerifier) verify(imageVerify *v1.ImageVerification, images map[string]context.ImageInfo) { +func (iv *imageVerifier) verify(imageVerify *v1.ImageVerification, images map[string]map[string]imageutils.ImageInfo) { imagePattern := imageVerify.Image - for _, imageInfo := range images { - image := imageInfo.String() - jmespath := utils.JsonPointerToJMESPath(imageInfo.JSONPointer) - changed, err := iv.policyContext.JSONContext.HasChanged(jmespath) - if err == nil && !changed { - iv.logger.V(4).Info("no change in image, skipping check", "image", image) - continue - } - - if !wildcard.Match(imagePattern, image) { - iv.logger.V(4).Info("image does not match pattern", "image", image, "pattern", imagePattern) - continue - } - - var ruleResp *response.RuleResponse - if len(imageVerify.Attestations) == 0 { - var digest string - ruleResp, digest = iv.verifySignature(imageVerify, imageInfo) - if ruleResp.Status == response.RuleStatusPass { - iv.patchDigest(imageInfo, digest, ruleResp) + for _, infoMap := range images { + for _, imageInfo := range infoMap { + path := imageInfo.Path + image := imageInfo.String() + jmespath := utils.JsonPointerToJMESPath(path) + changed, err := iv.policyContext.JSONContext.HasChanged(jmespath) + if err == nil && !changed { + iv.logger.V(4).Info("no change in image, skipping check", "image", image) + continue } - } else { - ruleResp = iv.attestImage(imageVerify, imageInfo) - } - iv.resp.PolicyResponse.Rules = append(iv.resp.PolicyResponse.Rules, *ruleResp) - incrementAppliedCount(iv.resp) + if !wildcard.Match(imagePattern, image) { + iv.logger.V(4).Info("image does not match pattern", "image", image, "pattern", imagePattern) + continue + } + + var ruleResp *response.RuleResponse + if len(imageVerify.Attestations) == 0 { + var digest string + ruleResp, digest = iv.verifySignature(imageVerify, imageInfo) + if ruleResp.Status == response.RuleStatusPass { + iv.patchDigest(path, imageInfo, digest, ruleResp) + } + } else { + ruleResp = iv.attestImage(imageVerify, imageInfo) + } + + iv.resp.PolicyResponse.Rules = append(iv.resp.PolicyResponse.Rules, *ruleResp) + incrementAppliedCount(iv.resp) + } } } @@ -167,7 +168,7 @@ func getSignatureRepository(imageVerify *v1.ImageVerification) string { return repository } -func (iv *imageVerifier) verifySignature(imageVerify *v1.ImageVerification, imageInfo context.ImageInfo) (*response.RuleResponse, string) { +func (iv *imageVerifier) verifySignature(imageVerify *v1.ImageVerification, imageInfo imageutils.ImageInfo) (*response.RuleResponse, string) { image := imageInfo.String() iv.logger.Info("verifying image", "image", image) @@ -219,11 +220,11 @@ func (iv *imageVerifier) verifySignature(imageVerify *v1.ImageVerification, imag return ruleResp, digest } -func (iv *imageVerifier) patchDigest(imageInfo context.ImageInfo, digest string, ruleResp *response.RuleResponse) { +func (iv *imageVerifier) patchDigest(path string, imageInfo imageutils.ImageInfo, digest string, ruleResp *response.RuleResponse) { if imageInfo.Digest == "" { - patch, err := makeAddDigestPatch(imageInfo, digest) + patch, err := makeAddDigestPatch(path, imageInfo, digest) if err != nil { - iv.logger.Error(err, "failed to patch image with digest", "image", imageInfo.String(), "jsonPath", imageInfo.JSONPointer) + iv.logger.Error(err, "failed to patch image with digest", "image", imageInfo.String(), "jsonPath", path) } else { iv.logger.V(4).Info("patching verified image with digest", "patch", string(patch)) ruleResp.Patches = [][]byte{patch} @@ -231,15 +232,15 @@ func (iv *imageVerifier) patchDigest(imageInfo context.ImageInfo, digest string, } } -func makeAddDigestPatch(imageInfo context.ImageInfo, digest string) ([]byte, error) { +func makeAddDigestPatch(path string, imageInfo imageutils.ImageInfo, digest string) ([]byte, error) { var patch = make(map[string]interface{}) patch["op"] = "replace" - patch["path"] = imageInfo.JSONPointer + patch["path"] = path patch["value"] = imageInfo.String() + "@" + digest return json.Marshal(patch) } -func (iv *imageVerifier) attestImage(imageVerify *v1.ImageVerification, imageInfo context.ImageInfo) *response.RuleResponse { +func (iv *imageVerifier) attestImage(imageVerify *v1.ImageVerification, imageInfo imageutils.ImageInfo) *response.RuleResponse { image := imageInfo.String() start := time.Now() @@ -291,7 +292,7 @@ func buildStatementMap(statements []map[string]interface{}) map[string][]map[str return results } -func (iv *imageVerifier) checkAttestations(a *v1.Attestation, s map[string]interface{}, img context.ImageInfo) (bool, error) { +func (iv *imageVerifier) checkAttestations(a *v1.Attestation, s map[string]interface{}, img imageutils.ImageInfo) (bool, error) { if len(a.Conditions) == 0 { return true, nil } @@ -308,7 +309,7 @@ func (iv *imageVerifier) checkAttestations(a *v1.Attestation, s map[string]inter return false, errors.Wrapf(err, fmt.Sprintf("failed to add Statement to the context %v", s)) } - if err := iv.policyContext.JSONContext.AddImageInfo(&img); err != nil { + if err := iv.policyContext.JSONContext.AddImageInfo(img); err != nil { return false, errors.Wrapf(err, fmt.Sprintf("failed to add image to the context %v", s)) } diff --git a/pkg/utils/image/infos.go b/pkg/utils/image/infos.go new file mode 100644 index 0000000000..ee75393e94 --- /dev/null +++ b/pkg/utils/image/infos.go @@ -0,0 +1,77 @@ +package image + +import ( + "strings" + + "github.com/distribution/distribution/reference" + "github.com/pkg/errors" +) + +type ImageInfo struct { + // Registry is the URL address of the image registry e.g. `docker.io` + Registry string `json:"registry,omitempty"` + + // Name is the image name portion e.g. `busybox` + Name string `json:"name"` + + // Path is the repository path and image name e.g. `some-repository/busybox` + Path string `json:"path"` + + // Tag is the image tag e.g. `v2` + Tag string `json:"tag,omitempty"` + + // Digest is the image digest portion e.g. `sha256:128c6e3534b842a2eec139999b8ce8aa9a2af9907e2b9269550809d18cd832a3` + Digest string `json:"digest,omitempty"` + + // Pointer is the path to the image object in the resource + Pointer string `json:"-"` +} + +func (i *ImageInfo) String() string { + image := i.Registry + "/" + i.Path + if i.Digest != "" { + return image + "@" + i.Digest + } else { + return image + ":" + i.Tag + } +} + +func GetImageInfo(image string, pointer string) (*ImageInfo, error) { + image = addDefaultDomain(image) + ref, err := reference.Parse(image) + if err != nil { + return nil, errors.Wrapf(err, "bad image: %s", image) + } + var registry, path, name, tag, digest string + if named, ok := ref.(reference.Named); ok { + registry = reference.Domain(named) + path = reference.Path(named) + name = path[strings.LastIndex(path, "/")+1:] + } + if tagged, ok := ref.(reference.Tagged); ok { + tag = tagged.Tag() + } + if digested, ok := ref.(reference.Digested); ok { + digest = digested.Digest().String() + } + // set default tag - the domain is set via addDefaultDomain before parsing + if digest == "" && tag == "" { + tag = "latest" + } + return &ImageInfo{ + Registry: registry, + Name: name, + Path: path, + Tag: tag, + Digest: digest, + Pointer: pointer, + }, nil +} + +func addDefaultDomain(name string) string { + i := strings.IndexRune(name, '/') + if i == -1 || (!strings.ContainsAny(name[:i], ".:") && name[:i] != "localhost" && strings.ToLower(name[:i]) == name[:i]) { + return "docker.io/" + name + } + return name +} diff --git a/pkg/utils/image/infos_test.go b/pkg/utils/image/infos_test.go new file mode 100644 index 0000000000..f608e855c1 --- /dev/null +++ b/pkg/utils/image/infos_test.go @@ -0,0 +1,73 @@ +package image + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_GetImageInfo(t *testing.T) { + validateImageInfo(t, + "nginx", + "nginx", + "nginx", + "docker.io", + "latest", + "", + "docker.io/nginx:latest") + + validateImageInfo(t, + "nginx:v10.3", + "nginx", + "nginx", + "docker.io", + "v10.3", + "", + "docker.io/nginx:v10.3") + + validateImageInfo(t, + "docker.io/test/nginx:v10.3", + "nginx", + "test/nginx", + "docker.io", + "v10.3", + "", + "docker.io/test/nginx:v10.3") + + validateImageInfo(t, + "test/nginx", + "nginx", + "test/nginx", + "docker.io", + "latest", + "", + "docker.io/test/nginx:latest") + + validateImageInfo(t, + "localhost:4443/test/nginx", + "nginx", + "test/nginx", + "localhost:4443", + "latest", + "", + "localhost:4443/test/nginx:latest") + validateImageInfo(t, + "docker.io/test/centos@sha256:dead07b4d8ed7e29e98de0f4504d87e8880d4347859d839686a31da35a3b532f", + "centos", + "test/centos", + "docker.io", + "", + "sha256:dead07b4d8ed7e29e98de0f4504d87e8880d4347859d839686a31da35a3b532f", + "docker.io/test/centos@sha256:dead07b4d8ed7e29e98de0f4504d87e8880d4347859d839686a31da35a3b532f") +} + +func validateImageInfo(t *testing.T, raw, name, path, registry, tag, digest, str string) { + i1, err := GetImageInfo(raw, "") + assert.Nil(t, err) + assert.Equal(t, name, i1.Name) + assert.Equal(t, path, i1.Path) + assert.Equal(t, registry, i1.Registry) + assert.Equal(t, tag, i1.Tag) + assert.Equal(t, digest, i1.Digest) + assert.Equal(t, str, i1.String()) +} diff --git a/pkg/utils/kube/image.go b/pkg/utils/kube/image.go new file mode 100644 index 0000000000..93bf021ea1 --- /dev/null +++ b/pkg/utils/kube/image.go @@ -0,0 +1,119 @@ +package kube + +import ( + "fmt" + "strconv" + "strings" + + imageutils "github.com/kyverno/kyverno/pkg/utils/image" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +var ( + podExtractors = BuildStandardExtractors("spec") + podControllerExtractors = BuildStandardExtractors("spec", "template", "spec") + cronjobControllerExtractors = BuildStandardExtractors("spec", "jobTemplate", "spec", "template", "spec") + registeredExtractors = map[string][]ImageExtractor{ + "Pod": podExtractors, + "DaemonSet": podControllerExtractors, + "Deployment": podControllerExtractors, + "ReplicaSet": podControllerExtractors, + "StatefulSet": podControllerExtractors, + "CronJob": cronjobControllerExtractors, + "Job": podControllerExtractors, + } +) + +type ImageExtractor struct { + fields []string + key string + value string + name string +} + +func (i *ImageExtractor) ExtractFromResource(resource interface{}) (map[string]imageutils.ImageInfo, error) { + imageInfo := map[string]imageutils.ImageInfo{} + if err := extract(resource, []string{}, i.key, i.value, i.fields, &imageInfo); err != nil { + return nil, err + } + return imageInfo, nil +} + +func extract(obj interface{}, path []string, keyPath, valuePath string, fields []string, imageInfos *map[string]imageutils.ImageInfo) error { + if obj == nil { + return nil + } + if len(fields) > 0 && fields[0] == "*" { + switch typedObj := obj.(type) { + case []interface{}: + for i, v := range typedObj { + if err := extract(v, append(path, strconv.Itoa(i)), keyPath, valuePath, fields[1:], imageInfos); err != nil { + return err + } + } + case map[string]interface{}: + for i, v := range typedObj { + if err := extract(v, append(path, i), keyPath, valuePath, fields[1:], imageInfos); err != nil { + return err + } + } + case interface{}: + return fmt.Errorf("invalid type") + } + return nil + } + output, ok := obj.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid image config") + } + if len(fields) == 0 { + pointer := fmt.Sprintf("/%s/%s", strings.Join(path, "/"), valuePath) + key := pointer + if keyPath != "" { + key, ok = output[keyPath].(string) + if !ok { + return fmt.Errorf("invalid key") + } + } + value, ok := output[valuePath].(string) + if !ok { + return fmt.Errorf("invalid value") + } + if imageInfo, err := imageutils.GetImageInfo(value, pointer); err != nil { + return fmt.Errorf("invalid image %s", value) + } else { + (*imageInfos)[key] = *imageInfo + } + return nil + } + currentPath := fields[0] + return extract(output[currentPath], append(path, currentPath), keyPath, valuePath, fields[1:], imageInfos) +} + +func BuildStandardExtractors(tags ...string) []ImageExtractor { + var extractors []ImageExtractor + for _, tag := range []string{"initContainers", "containers", "ephemeralContainers"} { + var t []string + t = append(t, tags...) + t = append(t, tag) + t = append(t, "*") + extractors = append(extractors, ImageExtractor{fields: t, key: "name", value: "image", name: tag}) + } + return extractors +} + +func LookupImageExtractor(kind string) []ImageExtractor { + return registeredExtractors[kind] +} + +func ExtractImagesFromResource(resource unstructured.Unstructured) (map[string]map[string]imageutils.ImageInfo, error) { + infos := map[string]map[string]imageutils.ImageInfo{} + for _, extractor := range LookupImageExtractor(resource.GetKind()) { + if infoMap, err := extractor.ExtractFromResource(resource.Object); err != nil { + return nil, err + } else if infoMap != nil && len(infoMap) > 0 { + infos[extractor.name] = infoMap + } + } + return infos, nil +} diff --git a/pkg/utils/kube/image_test.go b/pkg/utils/kube/image_test.go new file mode 100644 index 0000000000..ae65a49fec --- /dev/null +++ b/pkg/utils/kube/image_test.go @@ -0,0 +1,69 @@ +package kube + +import ( + "testing" + + "github.com/kyverno/kyverno/pkg/engine/utils" + "github.com/kyverno/kyverno/pkg/utils/image" + imageutils "github.com/kyverno/kyverno/pkg/utils/image" + "gotest.tools/assert" +) + +func Test_extractImageInfo(t *testing.T) { + tests := []struct { + raw []byte + images map[string]map[string]imageutils.ImageInfo + }{ + { + raw: []byte(`{"apiVersion": "v1","kind": "Pod","metadata": {"name": "myapp"},"spec": {"initContainers": [{"name": "init","image": "index.docker.io/busybox:v1.2.3"}],"containers": [{"name": "nginx","image": "nginx:latest"}], "ephemeralContainers": [{"name": "ephemeral", "image":"test/nginx:latest"}]}}`), + images: map[string]map[string]image.ImageInfo{ + "initContainers": { + "init": {Registry: "index.docker.io", Name: "busybox", Path: "busybox", Tag: "v1.2.3", Pointer: "/spec/initContainers/0/image"}, + }, + "containers": { + "nginx": {Registry: "docker.io", Name: "nginx", Path: "nginx", Tag: "latest", Pointer: "/spec/containers/0/image"}, + }, + "ephemeralContainers": { + "ephemeral": {Registry: "docker.io", Name: "nginx", Path: "test/nginx", Tag: "latest", Pointer: "/spec/ephemeralContainers/0/image"}, + }, + }, + }, + { + raw: []byte(`{"apiVersion": "v1","kind": "Pod","metadata": {"name": "myapp"},"spec": {"containers": [{"name": "nginx","image": "test/nginx:latest"}]}}`), + images: map[string]map[string]imageutils.ImageInfo{ + "containers": { + "nginx": {Registry: "docker.io", Name: "nginx", Path: "test/nginx", Tag: "latest", Pointer: "/spec/containers/0/image"}, + }, + }, + }, + { + raw: []byte(`{"apiVersion": "apps/v1","kind": "Deployment","metadata": {"name": "myapp"},"spec": {"selector": {"matchLabels": {"app": "myapp"}},"template": {"metadata": {"labels": {"app": "myapp"}},"spec": {"initContainers": [{"name": "init","image": "fictional.registry.example:10443/imagename:tag@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}],"containers": [{"name": "myapp","image": "fictional.registry.example:10443/imagename"}],"ephemeralContainers": [{"name": "ephemeral","image": "fictional.registry.example:10443/imagename:tag@sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}] }}}}`), + images: map[string]map[string]imageutils.ImageInfo{ + "initContainers": { + "init": {Registry: "fictional.registry.example:10443", Name: "imagename", Path: "imagename", Tag: "tag", Digest: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", Pointer: "/spec/template/spec/initContainers/0/image"}, + }, + "containers": { + "myapp": {Registry: "fictional.registry.example:10443", Name: "imagename", Path: "imagename", Tag: "latest", Pointer: "/spec/template/spec/containers/0/image"}, + }, + "ephemeralContainers": { + "ephemeral": {Registry: "fictional.registry.example:10443", Name: "imagename", Path: "imagename", Tag: "tag", Digest: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", Pointer: "/spec/template/spec/ephemeralContainers/0/image"}, + }, + }, + }, + { + raw: []byte(`{"apiVersion": "batch/v1beta1","kind": "CronJob","metadata": {"name": "hello"},"spec": {"schedule": "*/1 * * * *","jobTemplate": {"spec": {"template": {"spec": {"containers": [{"name": "hello","image": "test.example.com/test/my-app:v2"}]}}}}}}`), + images: map[string]map[string]imageutils.ImageInfo{ + "containers": { + "hello": {Registry: "test.example.com", Name: "my-app", Path: "test/my-app", Tag: "v2", Pointer: "/spec/jobTemplate/spec/template/spec/containers/0/image"}, + }, + }, + }, + } + + for _, test := range tests { + resource, err := utils.ConvertToUnstructured(test.raw) + assert.NilError(t, err) + images, err := ExtractImagesFromResource(*resource) + assert.DeepEqual(t, test.images, images) + } +}