diff --git a/integration_tests/instance_groups.lua b/integration_tests/instance_groups.lua index 78031e2a1..a69a64dc8 100644 --- a/integration_tests/instance_groups.lua +++ b/integration_tests/instance_groups.lua @@ -18,6 +18,7 @@ Test.gql("Application with instance groups", function(t) image { name tag + digest } readyInstances desiredInstances @@ -45,6 +46,7 @@ Test.gql("Application with instance groups", function(t) image = { name = "ghcr.io/navikt/myapp", tag = "v1.2.3", + digest = Null, }, readyInstances = 2, desiredInstances = 2, diff --git a/integration_tests/k8s_resources/vulnerability/dev/slug-1/app.yaml b/integration_tests/k8s_resources/vulnerability/dev/slug-1/app.yaml index fbf75d0fd..8f282dfd3 100644 --- a/integration_tests/k8s_resources/vulnerability/dev/slug-1/app.yaml +++ b/integration_tests/k8s_resources/vulnerability/dev/slug-1/app.yaml @@ -1,7 +1,7 @@ +--- apiVersion: nais.io/v1alpha1 kind: Application metadata: name: app-with-vulnerabilities spec: image: europe-north1-docker.pkg.dev/nais/navikt/app-name:latest@sha256:deadbeef ---- diff --git a/integration_tests/k8s_resources/vulnerability_digest_only/dev/slug-1/app.yaml b/integration_tests/k8s_resources/vulnerability_digest_only/dev/slug-1/app.yaml new file mode 100644 index 000000000..f25944063 --- /dev/null +++ b/integration_tests/k8s_resources/vulnerability_digest_only/dev/slug-1/app.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: nais.io/v1alpha1 +kind: Application +metadata: + name: app-with-digest-only +spec: + image: europe-north1-docker.pkg.dev/nais/navikt/app-name@sha256:cafebabe diff --git a/integration_tests/vulnerabilities.lua b/integration_tests/vulnerabilities.lua index 5667b88f0..2ede770b0 100644 --- a/integration_tests/vulnerabilities.lua +++ b/integration_tests/vulnerabilities.lua @@ -55,6 +55,7 @@ Test.gql("List vulnerability summaries for team", function(t) image{ name tag + digest hasSBOM sbom { status @@ -84,7 +85,8 @@ Test.gql("List vulnerability summaries for team", function(t) { image = { name = "europe-north1-docker.pkg.dev/nais/navikt/app-name", - tag = "latest@sha256:deadbeef", + tag = "latest", + digest = "sha256:deadbeef", hasSBOM = true, sbom = { status = "READY", diff --git a/integration_tests/vulnerability_digest_only.lua b/integration_tests/vulnerability_digest_only.lua new file mode 100644 index 000000000..5d18d83e5 --- /dev/null +++ b/integration_tests/vulnerability_digest_only.lua @@ -0,0 +1,39 @@ +Helper.readK8sResources("k8s_resources/vulnerability_digest_only") + +local team = Team.new("slug-1", "purpose", "#channel") +local user = User.new("authenticated", "authenticated@example.com", "some-id") + +Test.gql("Digest-only image exposes empty tag and populated digest", function(t) + t.addHeader("x-user-email", user:email()) + t.query(string.format([[ + { + team(slug: "%s") { + environment(name: "%s") { + workload(name: "%s") { + image { + name + tag + digest + } + } + } + } + } + ]], team:slug(), "dev", "app-with-digest-only")) + + t.check { + data = { + team = { + environment = { + workload = { + image = { + name = "europe-north1-docker.pkg.dev/nais/navikt/app-name", + tag = "", + digest = "sha256:cafebabe", + }, + }, + }, + }, + }, + } +end) diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index dc7837338..b472fcc46 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -665,6 +665,7 @@ type ComplexityRoot struct { ContainerImage struct { ActivityLog func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) int + Digest func(childComplexity int) int HasSbom func(childComplexity int) int ID func(childComplexity int) int Name func(childComplexity int) int @@ -5859,6 +5860,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.ContainerImage.ActivityLog(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor), args["filter"].(*activitylog.ActivityLogFilter)), true + case "ContainerImage.digest": + if e.ComplexityRoot.ContainerImage.Digest == nil { + break + } + + return e.ComplexityRoot.ContainerImage.Digest(childComplexity), true + case "ContainerImage.hasSBOM": if e.ComplexityRoot.ContainerImage.HasSbom == nil { break @@ -32453,10 +32461,16 @@ type ContainerImage implements Node & ActivityLogger { name: String! """ - Tag of the container image. + Tag of the container image. Empty when the image reference has no explicit tag, + for example when it is referenced by digest only. """ tag: String! + """ + Digest of the container image, if present. + """ + digest: String + """ Activity log associated with the container image. """ @@ -33503,6 +33517,8 @@ func (ec *executionContext) childFields_ContainerImage(ctx context.Context, fiel return ec.fieldContext_ContainerImage_name(ctx, field) case "tag": return ec.fieldContext_ContainerImage_tag(ctx, field) + case "digest": + return ec.fieldContext_ContainerImage_digest(ctx, field) case "activityLog": return ec.fieldContext_ContainerImage_activityLog(ctx, field) case "sbom": diff --git a/internal/graph/gengql/workloads.generated.go b/internal/graph/gengql/workloads.generated.go index b03c7eba1..19c23c0f2 100644 --- a/internal/graph/gengql/workloads.generated.go +++ b/internal/graph/gengql/workloads.generated.go @@ -252,6 +252,29 @@ func (ec *executionContext) fieldContext_ContainerImage_tag(_ context.Context, f return graphql.NewScalarFieldContext("ContainerImage", field, false, false, errors.New("field of type String does not have child fields")) } +func (ec *executionContext) _ContainerImage_digest(ctx context.Context, field graphql.CollectedField, obj *workload.ContainerImage) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_ContainerImage_digest(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Digest, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *string) graphql.Marshaler { + return ec.marshalOString2áš–string(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_ContainerImage_digest(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("ContainerImage", field, false, false, errors.New("field of type String does not have child fields")) +} + func (ec *executionContext) _ContainerImage_activityLog(ctx context.Context, field graphql.CollectedField, obj *workload.ContainerImage) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1064,6 +1087,8 @@ func (ec *executionContext) _ContainerImage(ctx context.Context, sel ast.Selecti if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } + case "digest": + out.Values[i] = ec._ContainerImage_digest(ctx, field, obj) case "activityLog": field := field diff --git a/internal/graph/schema/workloads.graphqls b/internal/graph/schema/workloads.graphqls index 3e261b313..38eed2097 100644 --- a/internal/graph/schema/workloads.graphqls +++ b/internal/graph/schema/workloads.graphqls @@ -205,10 +205,16 @@ type ContainerImage implements Node & ActivityLogger { name: String! """ - Tag of the container image. + Tag of the container image. Empty when the image reference has no explicit tag, + for example when it is referenced by digest only. """ tag: String! + """ + Digest of the container image, if present. + """ + digest: String + """ Activity log associated with the container image. """ diff --git a/internal/vulnerability/fake/fakedata.go b/internal/vulnerability/fake/fakedata.go index e9f42d0a4..7ac12556b 100644 --- a/internal/vulnerability/fake/fakedata.go +++ b/internal/vulnerability/fake/fakedata.go @@ -75,9 +75,7 @@ func createFakeData(ctx context.Context) *fakeData { } func createWorkloadSummary(env, team, workloadType, name, image string, vulnFactor int32) *vulnerabilities.WorkloadSummary { - parsedImage := workload.ParseContainerImage(image) - imageName := parsedImage.Name - imageTag := parsedImage.Tag + imageName, imageTag := workload.FormatImageReferenceForLookup(image) summary := &vulnerabilities.Summary{ Critical: vulnFactor, High: vulnFactor * 2, diff --git a/internal/vulnerability/queries.go b/internal/vulnerability/queries.go index 70257b18e..79b8b493a 100644 --- a/internal/vulnerability/queries.go +++ b/internal/vulnerability/queries.go @@ -886,36 +886,19 @@ func normalizeWorkloadTypeForVulnerabilityIdent(workloadType string) string { // splitImage splits an image reference into name, tag, and sha components. // Tag defaults to "latest" if not specified. SHA is optional and will be empty if not present. func splitImage(image string) (name, tag, sha string, hasExplicitTag bool) { - before, after, ok := strings.Cut(image, "@") - if ok { - image = before - sha = after - } - // Only look for a tag colon in the final path segment to avoid splitting - // on a registry port (e.g. "myregistry.io:5000/myimage"). - lastSlash := strings.LastIndex(image, "/") - segment := image[lastSlash+1:] - if before, after, ok := strings.Cut(segment, ":"); ok { - name = image[:lastSlash+1] + before - tag = after - hasExplicitTag = true - } else { - name = image + parsed := workload.ParseImageReference(image) + name = parsed.Name + tag = parsed.Tag + sha = parsed.Digest + hasExplicitTag = parsed.HasExplicitTag + if tag == "" { tag = "latest" } return name, tag, sha, hasExplicitTag } func splitImageRefForLookup(image string) (name, tag string) { - name, tag, sha, hasExplicitTag := splitImage(image) - if sha != "" { - if hasExplicitTag && tag != "" { - return name, tag + "@" + sha - } - return name, sha - } - - return name, tag + return workload.FormatImageReferenceForLookup(image) } func GetSbomStatus(ctx context.Context, ref string) (SBOMStatus, error) { diff --git a/internal/vulnerability/queries_test.go b/internal/vulnerability/queries_test.go index dee34058c..f71d4af1e 100644 --- a/internal/vulnerability/queries_test.go +++ b/internal/vulnerability/queries_test.go @@ -59,6 +59,13 @@ func TestSplitImage(t *testing.T) { wantTag: "v2.0.0", wantSha: "sha256:deadbeef", }, + { + name: "image with nested path sha and no tag", + image: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app@sha256:deadbeef", + wantName: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app", + wantTag: "latest", + wantSha: "sha256:deadbeef", + }, { name: "simple image name only", image: "nginx", @@ -122,6 +129,13 @@ func TestSplitImage(t *testing.T) { wantTag: "latest", wantSha: "sha256:abc123", }, + { + name: "registry with port nested path tag and sha", + image: "myregistry.io:5000/org/repo/myimage:v2.0.0@sha256:abc123", + wantName: "myregistry.io:5000/org/repo/myimage", + wantTag: "v2.0.0", + wantSha: "sha256:abc123", + }, } for _, tt := range tests { @@ -183,6 +197,24 @@ func TestSplitImageRefForLookup(t *testing.T) { wantName: "myregistry.io/myimage", wantTag: "v1.2.3@sha256:abc123", }, + { + name: "image with nested path and no tag defaults to latest", + image: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app", + wantName: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app", + wantTag: "latest", + }, + { + name: "image with nested path sha and no tag", + image: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app@sha256:deadbeef", + wantName: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app", + wantTag: "sha256:deadbeef", + }, + { + name: "image with nested path tag and sha", + image: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app:v2.0.0@sha256:deadbeef", + wantName: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app", + wantTag: "v2.0.0@sha256:deadbeef", + }, { name: "registry with port and tag", image: "myregistry.io:5000/myimage:v1", @@ -201,6 +233,18 @@ func TestSplitImageRefForLookup(t *testing.T) { wantName: "myregistry.io:5000/myimage", wantTag: "v1@sha256:abc123", }, + { + name: "registry with port nested path and tag", + image: "myregistry.io:5000/org/repo/myimage:v2.0.0", + wantName: "myregistry.io:5000/org/repo/myimage", + wantTag: "v2.0.0", + }, + { + name: "registry with port nested path tag and sha", + image: "myregistry.io:5000/org/repo/myimage:v2.0.0@sha256:abc123", + wantName: "myregistry.io:5000/org/repo/myimage", + wantTag: "v2.0.0@sha256:abc123", + }, } for _, tt := range tests { diff --git a/internal/workload/models.go b/internal/workload/models.go index 1109ea6d2..7e9b833bb 100644 --- a/internal/workload/models.go +++ b/internal/workload/models.go @@ -76,21 +76,26 @@ func (b Base) GetType() Type { return b.Type } func (b Base) GetLogging() *nais_io_v1.Logging { return b.Logging } type ContainerImage struct { - Name string `json:"name"` - Tag string `json:"tag"` + Name string `json:"name"` + Tag string `json:"tag"` + Digest *string `json:"digest,omitempty"` } func (ContainerImage) IsNode() {} func (c ContainerImage) Ref() string { - if c.Tag == "" { + if c.Tag == "" && c.Digest == nil { return c.Name } - if !strings.Contains(c.Tag, "@") && strings.Contains(c.Tag, ":") { - return c.Name + "@" + c.Tag + if c.Tag == "" { + return c.Name + "@" + *c.Digest } - return c.Name + ":" + c.Tag + if c.Digest == nil { + return c.Name + ":" + c.Tag + } + + return c.Name + ":" + c.Tag + "@" + *c.Digest } func (c ContainerImage) ID() ident.Ident { @@ -100,34 +105,66 @@ func (c ContainerImage) ID() ident.Ident { func (ContainerImage) IsActivityLogger() {} func ParseContainerImage(image string) *ContainerImage { - name, tag := splitContainerImage(image) + parsed := ParseImageReference(image) return &ContainerImage{ - Name: name, - Tag: tag, + Name: parsed.Name, + Tag: parsed.Tag, + Digest: parsed.DigestPtr(), } } -func splitContainerImage(image string) (name, tag string) { - before, after, ok := strings.Cut(image, "@") - if ok { - image = before - if name, tag = splitContainerImageNameTag(image); tag != "" { - return name, tag + "@" + after - } - return name, after +type ParsedImageReference struct { + Name string + Tag string + Digest string + HasExplicitTag bool +} + +func (p ParsedImageReference) DigestPtr() *string { + if p.Digest == "" { + return nil } - return splitContainerImageNameTag(image) + return new(p.Digest) } -func splitContainerImageNameTag(image string) (name, tag string) { +func ParseImageReference(image string) ParsedImageReference { + before, digest, hasDigest := strings.Cut(image, "@") + if hasDigest { + image = before + } + lastSlash := strings.LastIndex(image, "/") segment := image[lastSlash+1:] if before, after, ok := strings.Cut(segment, ":"); ok { - return image[:lastSlash+1] + before, after + return ParsedImageReference{ + Name: image[:lastSlash+1] + before, + Tag: after, + Digest: digest, + HasExplicitTag: true, + } } - return image, "" + return ParsedImageReference{ + Name: image, + Digest: digest, + } +} + +func FormatImageReferenceForLookup(image string) (name, tag string) { + parsed := ParseImageReference(image) + name = parsed.Name + + switch { + case parsed.Digest != "" && parsed.HasExplicitTag && parsed.Tag != "": + return name, parsed.Tag + "@" + parsed.Digest + case parsed.Digest != "": + return name, parsed.Digest + case parsed.Tag == "": + return name, "latest" + default: + return name, parsed.Tag + } } type WorkloadResources interface { diff --git a/internal/workload/models_test.go b/internal/workload/models_test.go index 1556f61b4..51232a6e8 100644 --- a/internal/workload/models_test.go +++ b/internal/workload/models_test.go @@ -5,54 +5,142 @@ import ( "testing" ) +var ( + digestABC = new(string) + digestDeadbeef = new(string) +) + +func init() { + *digestABC = "sha256:abc" + *digestDeadbeef = "sha256:deadbeef" +} + func TestParseContainerImageRoundTrip(t *testing.T) { tests := []struct { - name string - image string - wantName string - wantTag string + name string + image string + wantName string + wantTag string + wantDigest *string }{ { - name: "no tag or digest", - image: "registry/repo/app", - wantName: "registry/repo/app", - wantTag: "", + name: "empty string", + image: "", + wantName: "", + wantTag: "", + wantDigest: nil, + }, + { + name: "simple image name only", + image: "nginx", + wantName: "nginx", + wantTag: "", + wantDigest: nil, + }, + { + name: "simple image name with tag", + image: "nginx:1.25", + wantName: "nginx", + wantTag: "1.25", + wantDigest: nil, + }, + { + name: "no tag or digest", + image: "registry/repo/app", + wantName: "registry/repo/app", + wantTag: "", + wantDigest: nil, + }, + { + name: "tag", + image: "registry/repo/app:1.2.3", + wantName: "registry/repo/app", + wantTag: "1.2.3", + wantDigest: nil, + }, + { + name: "digest only", + image: "registry/repo/app@sha256:abc", + wantName: "registry/repo/app", + wantTag: "", + wantDigest: digestABC, }, { - name: "tag", - image: "registry/repo/app:1.2.3", - wantName: "registry/repo/app", - wantTag: "1.2.3", + name: "tag and digest", + image: "registry/repo/app:1.2.3@sha256:abc", + wantName: "registry/repo/app", + wantTag: "1.2.3", + wantDigest: digestABC, }, { - name: "digest only", - image: "registry/repo/app@sha256:abc", - wantName: "registry/repo/app", - wantTag: "sha256:abc", + name: "explicit latest and digest", + image: "registry/repo/app:latest@sha256:abc", + wantName: "registry/repo/app", + wantTag: "latest", + wantDigest: digestABC, }, { - name: "tag and digest", - image: "registry/repo/app:1.2.3@sha256:abc", - wantName: "registry/repo/app", - wantTag: "1.2.3@sha256:abc", + name: "nested path no tag", + image: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app", + wantName: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app", + wantTag: "", + wantDigest: nil, }, { - name: "registry with port and tag", - image: "registry.com:443/image/name:tag", - wantName: "registry.com:443/image/name", - wantTag: "tag", + name: "nested path tag", + image: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app:v2.0.0", + wantName: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app", + wantTag: "v2.0.0", + wantDigest: nil, }, { - name: "registry with port and digest", - image: "registry.com:443/image/name@sha256:abc", - wantName: "registry.com:443/image/name", - wantTag: "sha256:abc", + name: "nested path digest only", + image: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app@sha256:deadbeef", + wantName: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app", + wantTag: "", + wantDigest: digestDeadbeef, }, { - name: "registry with port tag and digest", - image: "registry.com:443/image/name:tag@sha256:abc", - wantName: "registry.com:443/image/name", - wantTag: "tag@sha256:abc", + name: "nested path tag and digest", + image: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app:v2.0.0@sha256:deadbeef", + wantName: "europe-north1-docker.pkg.dev/my-project/my-repo/my-app", + wantTag: "v2.0.0", + wantDigest: digestDeadbeef, + }, + { + name: "registry with port and tag", + image: "registry.com:443/image/name:tag", + wantName: "registry.com:443/image/name", + wantTag: "tag", + wantDigest: nil, + }, + { + name: "registry with port and digest", + image: "registry.com:443/image/name@sha256:abc", + wantName: "registry.com:443/image/name", + wantTag: "", + wantDigest: digestABC, + }, + { + name: "registry with port and no tag", + image: "registry.com:443/image/name", + wantName: "registry.com:443/image/name", + wantTag: "", + wantDigest: nil, + }, + { + name: "registry with port nested path and tag", + image: "myregistry.io:5000/org/repo/myimage:v2.0.0", + wantName: "myregistry.io:5000/org/repo/myimage", + wantTag: "v2.0.0", + wantDigest: nil, + }, + { + name: "registry with port tag and digest", + image: "registry.com:443/image/name:tag@sha256:abc", + wantName: "registry.com:443/image/name", + wantTag: "tag", + wantDigest: digestABC, }, } @@ -67,6 +155,13 @@ func TestParseContainerImageRoundTrip(t *testing.T) { t.Fatalf("tag = %q, want %q", parsed.Tag, tt.wantTag) } + if (parsed.Digest == nil) != (tt.wantDigest == nil) { + t.Fatalf("digest nil mismatch = %v, want %v", parsed.Digest == nil, tt.wantDigest == nil) + } + if parsed.Digest != nil && *parsed.Digest != *tt.wantDigest { + t.Fatalf("digest = %q, want %q", *parsed.Digest, *tt.wantDigest) + } + if got := parsed.Ref(); got != tt.image { t.Fatalf("ref = %q, want %q", got, tt.image) } @@ -74,6 +169,76 @@ func TestParseContainerImageRoundTrip(t *testing.T) { } } +func TestParseImageReferenceMetadata(t *testing.T) { + tests := []struct { + name string + image string + wantName string + wantTag string + wantDigest string + wantHasExplicitTag bool + }{ + { + name: "empty string", + image: "", + wantName: "", + wantTag: "", + wantDigest: "", + wantHasExplicitTag: false, + }, + { + name: "image with no tag", + image: "registry/repo/app", + wantName: "registry/repo/app", + wantTag: "", + wantDigest: "", + wantHasExplicitTag: false, + }, + { + name: "image with digest only", + image: "registry/repo/app@sha256:abc", + wantName: "registry/repo/app", + wantTag: "", + wantDigest: "sha256:abc", + wantHasExplicitTag: false, + }, + { + name: "image with explicit latest and digest", + image: "registry/repo/app:latest@sha256:abc", + wantName: "registry/repo/app", + wantTag: "latest", + wantDigest: "sha256:abc", + wantHasExplicitTag: true, + }, + { + name: "registry with port nested path and tag", + image: "myregistry.io:5000/org/repo/myimage:v2.0.0", + wantName: "myregistry.io:5000/org/repo/myimage", + wantTag: "v2.0.0", + wantDigest: "", + wantHasExplicitTag: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed := ParseImageReference(tt.image) + if parsed.Name != tt.wantName { + t.Fatalf("name = %q, want %q", parsed.Name, tt.wantName) + } + if parsed.Tag != tt.wantTag { + t.Fatalf("tag = %q, want %q", parsed.Tag, tt.wantTag) + } + if parsed.Digest != tt.wantDigest { + t.Fatalf("digest = %q, want %q", parsed.Digest, tt.wantDigest) + } + if parsed.HasExplicitTag != tt.wantHasExplicitTag { + t.Fatalf("hasExplicitTag = %t, want %t", parsed.HasExplicitTag, tt.wantHasExplicitTag) + } + }) + } +} + func TestContainerImageIdentRoundTrip(t *testing.T) { image := "registry/repo/app@sha256:abc"