diff --git a/go.mod b/go.mod index 3322afc8b..24896166c 100644 --- a/go.mod +++ b/go.mod @@ -44,7 +44,7 @@ require ( github.com/nais/pgrator/pkg/api v0.0.0-20260219115817-cf954d58c04e github.com/nais/tester v0.1.1 github.com/nais/unleasherator v0.0.0-20251216221129-efebc54203fe - github.com/nais/v13s/pkg/api v0.0.0-20260513133039-3688f23180a9 + github.com/nais/v13s/pkg/api v0.0.0-20260518075555-712972410146 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 diff --git a/go.sum b/go.sum index 19a9502c3..9c6f8a094 100644 --- a/go.sum +++ b/go.sum @@ -813,8 +813,8 @@ github.com/nais/tester v0.1.1 h1:tpJ5HKpu3mEIWX/mec0Yj0xLHEpt+MwTAsj282n0Py0= github.com/nais/tester v0.1.1/go.mod h1:NCQMcgftHz/EXorob1XwDTOqkQmImDqr51YQ2Uea9Pc= github.com/nais/unleasherator v0.0.0-20251216221129-efebc54203fe h1:CdRVopOihru4tXVwKZjhg6C8SbPLCQYOhJKpjBZYhjg= github.com/nais/unleasherator v0.0.0-20251216221129-efebc54203fe/go.mod h1:Tiz/1If3WgcfvNhmsO5DiQC+L+1XhBG3KWbIfbjx4EU= -github.com/nais/v13s/pkg/api v0.0.0-20260513133039-3688f23180a9 h1:ZCp9qXbxY37ZZQn0BD/l/OXOKBevDkEZ84hrWITmQiE= -github.com/nais/v13s/pkg/api v0.0.0-20260513133039-3688f23180a9/go.mod h1:Eafi4ZFv3tUGkhRnBtLRUii3S4kzLvdgDbYAvdiyauU= +github.com/nais/v13s/pkg/api v0.0.0-20260518075555-712972410146 h1:chxnrlBrrQ8ag7unA83LaFCxOjYYJou/ptUoyBXngaA= +github.com/nais/v13s/pkg/api v0.0.0-20260518075555-712972410146/go.mod h1:Eafi4ZFv3tUGkhRnBtLRUii3S4kzLvdgDbYAvdiyauU= github.com/ncruces/go-sqlite3 v0.32.0 h1:hNBUXp88LrfQCsuyXLqWTbTUG35sUuktDsqhhgHvU20= github.com/ncruces/go-sqlite3 v0.32.0/go.mod h1:MIWTK60ONDl0oVY073zYvJP21C3Dly6P9bxVpgkLwdQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= diff --git a/integration_tests/vulnerabilities.lua b/integration_tests/vulnerabilities.lua index 8133ad513..5f7ad1b0c 100644 --- a/integration_tests/vulnerabilities.lua +++ b/integration_tests/vulnerabilities.lua @@ -55,6 +55,10 @@ Test.gql("List vulnerability summaries for team", function(t) image{ name hasSBOM + sbom { + status + processingStartedAt + } vulnerabilitySummary{ total critical @@ -80,6 +84,10 @@ Test.gql("List vulnerability summaries for team", function(t) image = { name = "europe-north1-docker.pkg.dev/nais/navikt/app-name", hasSBOM = true, + sbom = { + status = "READY", + processingStartedAt = Ignore(), + }, vulnerabilitySummary = { total = NotNull(), critical = NotNull(), diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index a148e64c7..febe2aa28 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -67,6 +67,7 @@ type ResolverRoot interface { CVE() CVEResolver Config() ConfigResolver ContainerImage() ContainerImageResolver + ContainerImageSBOM() ContainerImageSBOMResolver ContainerImageWorkloadReference() ContainerImageWorkloadReferenceResolver CurrentUnitPrices() CurrentUnitPricesResolver DeleteApplicationPayload() DeleteApplicationPayloadResolver @@ -622,12 +623,19 @@ type ComplexityRoot struct { HasSbom func(childComplexity int) int ID func(childComplexity int) int Name func(childComplexity int) int + Sbom func(childComplexity int) int Tag func(childComplexity int) int Vulnerabilities func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *vulnerability.ImageVulnerabilityFilter, orderBy *vulnerability.ImageVulnerabilityOrder) int VulnerabilitySummary func(childComplexity int) int WorkloadReferences func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int } + ContainerImageSBOM struct { + ID func(childComplexity int) int + ProcessingStartedAt func(childComplexity int) int + Status func(childComplexity int) int + } + ContainerImageWorkloadReference struct { Workload func(childComplexity int) int } @@ -1013,14 +1021,15 @@ type ComplexityRoot struct { } ImageVulnerabilitySummary struct { - Critical func(childComplexity int) int - High func(childComplexity int) int - LastUpdated func(childComplexity int) int - Low func(childComplexity int) int - Medium func(childComplexity int) int - RiskScore func(childComplexity int) int - Total func(childComplexity int) int - Unassigned func(childComplexity int) int + Critical func(childComplexity int) int + High func(childComplexity int) int + LastUpdated func(childComplexity int) int + Low func(childComplexity int) int + Medium func(childComplexity int) int + RiskScore func(childComplexity int) int + StaleImageTag func(childComplexity int) int + Total func(childComplexity int) int + Unassigned func(childComplexity int) int } ImageVulnerabilitySuppression struct { @@ -5508,6 +5517,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.ContainerImage.Name(childComplexity), true + case "ContainerImage.sbom": + if e.ComplexityRoot.ContainerImage.Sbom == nil { + break + } + + return e.ComplexityRoot.ContainerImage.Sbom(childComplexity), true + case "ContainerImage.tag": if e.ComplexityRoot.ContainerImage.Tag == nil { break @@ -5546,6 +5562,27 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.ContainerImage.WorkloadReferences(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor)), true + case "ContainerImageSBOM.id": + if e.ComplexityRoot.ContainerImageSBOM.ID == nil { + break + } + + return e.ComplexityRoot.ContainerImageSBOM.ID(childComplexity), true + + case "ContainerImageSBOM.processingStartedAt": + if e.ComplexityRoot.ContainerImageSBOM.ProcessingStartedAt == nil { + break + } + + return e.ComplexityRoot.ContainerImageSBOM.ProcessingStartedAt(childComplexity), true + + case "ContainerImageSBOM.status": + if e.ComplexityRoot.ContainerImageSBOM.Status == nil { + break + } + + return e.ComplexityRoot.ContainerImageSBOM.Status(childComplexity), true + case "ContainerImageWorkloadReference.workload": if e.ComplexityRoot.ContainerImageWorkloadReference.Workload == nil { break @@ -6910,6 +6947,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.ImageVulnerabilitySummary.RiskScore(childComplexity), true + case "ImageVulnerabilitySummary.staleImageTag": + if e.ComplexityRoot.ImageVulnerabilitySummary.StaleImageTag == nil { + break + } + + return e.ComplexityRoot.ImageVulnerabilitySummary.StaleImageTag(childComplexity), true + case "ImageVulnerabilitySummary.total": if e.ComplexityRoot.ImageVulnerabilitySummary.Total == nil { break @@ -29422,9 +29466,33 @@ type ImageVulnerabilityHistory { samples: [ImageVulnerabilitySample!]! } +""" +SBOM metadata for a container image, including pipeline status and processing +information. This type is accessed through ContainerImage.sbom. +""" +type ContainerImageSBOM implements Node { + "The globally unique ID of the container image SBOM node." + id: ID! + + "The SBOM pipeline status." + status: SBOMStatus! + + """ + The timestamp when SBOM processing started for this image. + Useful as a progress indicator when status is PROCESSING. + """ + processingStartedAt: Time +} + extend type ContainerImage { - "Whether the image has a software bill of materials (SBOM) attached to it." + "SBOM pipeline status and processing information for this image." + sbom: ContainerImageSBOM! + + """ + Whether the image has a software bill of materials (SBOM) attached to it. + """ hasSBOM: Boolean! + @deprecated(reason: "Use sbom { status } == READY to check if an SBOM is attached.") "Get the vulnerabilities of the image." vulnerabilities( @@ -29567,6 +29635,13 @@ type ImageVulnerabilitySummary { "Timestamp of the last update of the vulnerability summary." lastUpdated: Time + + """ + When set, the vulnerability counts are sourced from this older image tag because + no summary exists yet for the requested tag (e.g. a newly deployed image that is + still being scanned). The value is the tag the stale data originates from. + """ + staleImageTag: String } type ImageVulnerabilityConnection { @@ -29775,6 +29850,7 @@ type WorkloadVulnerabilitySummary implements Node { "True if the workload has a software bill of materials (SBOM) attached." hasSBOM: Boolean! + @deprecated(reason: "Use workload { image { sbom { status } } } to check SBOM status.") "The vulnerability summary for the workload." summary: ImageVulnerabilitySummary! @@ -30017,6 +30093,21 @@ type VulnerabilityFixSample { "Total number of workloads with this severity of vulnerabilities." totalWorkloads: Int! } + +"The SBOM pipeline status for an image or workload." +enum SBOMStatus { + "SBOM generation is in progress." + PROCESSING + + "SBOM data is available." + READY + + "No SBOM is available for this image or workload." + NO_SBOM + + "SBOM generation failed." + FAILED +} `, BuiltIn: false}, {Name: "../schema/workloads.graphqls", Input: `extend type Team { """ @@ -31206,6 +31297,8 @@ func (ec *executionContext) childFields_ContainerImage(ctx context.Context, fiel return ec.fieldContext_ContainerImage_tag(ctx, field) case "activityLog": return ec.fieldContext_ContainerImage_activityLog(ctx, field) + case "sbom": + return ec.fieldContext_ContainerImage_sbom(ctx, field) case "hasSBOM": return ec.fieldContext_ContainerImage_hasSBOM(ctx, field) case "vulnerabilities": @@ -31218,6 +31311,18 @@ func (ec *executionContext) childFields_ContainerImage(ctx context.Context, fiel return nil, fmt.Errorf("no field named %q was found under type ContainerImage", field.Name) } +func (ec *executionContext) childFields_ContainerImageSBOM(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_ContainerImageSBOM_id(ctx, field) + case "status": + return ec.fieldContext_ContainerImageSBOM_status(ctx, field) + case "processingStartedAt": + return ec.fieldContext_ContainerImageSBOM_processingStartedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ContainerImageSBOM", field.Name) +} + func (ec *executionContext) childFields_ContainerImageWorkloadReference(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "workload": @@ -31826,6 +31931,8 @@ func (ec *executionContext) childFields_ImageVulnerabilitySummary(ctx context.Co return ec.fieldContext_ImageVulnerabilitySummary_unassigned(ctx, field) case "lastUpdated": return ec.fieldContext_ImageVulnerabilitySummary_lastUpdated(ctx, field) + case "staleImageTag": + return ec.fieldContext_ImageVulnerabilitySummary_staleImageTag(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type ImageVulnerabilitySummary", field.Name) } diff --git a/internal/graph/gengql/schema.generated.go b/internal/graph/gengql/schema.generated.go index 7b4a2c344..2a53e372c 100644 --- a/internal/graph/gengql/schema.generated.go +++ b/internal/graph/gengql/schema.generated.go @@ -6577,6 +6577,11 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj return graphql.Null } return ec._Deployment(ctx, sel, obj) + case *vulnerability.ContainerImageSBOM: + if obj == nil { + return graphql.Null + } + return ec._ContainerImageSBOM(ctx, sel, obj) case vulnerability.CVE: return ec._CVE(ctx, sel, &obj) case *vulnerability.CVE: diff --git a/internal/graph/gengql/vulnerability.generated.go b/internal/graph/gengql/vulnerability.generated.go index 1ec4b8fb1..56d6d724b 100644 --- a/internal/graph/gengql/vulnerability.generated.go +++ b/internal/graph/gengql/vulnerability.generated.go @@ -25,6 +25,10 @@ import ( type CVEResolver interface { Workloads(ctx context.Context, obj *vulnerability.CVE, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*vulnerability.WorkloadWithVulnerability], error) } +type ContainerImageSBOMResolver interface { + Status(ctx context.Context, obj *vulnerability.ContainerImageSBOM) (vulnerability.SBOMStatus, error) + ProcessingStartedAt(ctx context.Context, obj *vulnerability.ContainerImageSBOM) (*time.Time, error) +} type ContainerImageWorkloadReferenceResolver interface { Workload(ctx context.Context, obj *vulnerability.ContainerImageWorkloadReference) (workload.Workload, error) } @@ -441,6 +445,75 @@ func (ec *executionContext) fieldContext_CVEEdge_node(_ context.Context, field g return fc, nil } +func (ec *executionContext) _ContainerImageSBOM_id(ctx context.Context, field graphql.CollectedField, obj *vulnerability.ContainerImageSBOM) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_ContainerImageSBOM_id(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ID(), nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v ident.Ident) graphql.Marshaler { + return ec.marshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_ContainerImageSBOM_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("ContainerImageSBOM", field, true, false, errors.New("field of type ID does not have child fields")) +} + +func (ec *executionContext) _ContainerImageSBOM_status(ctx context.Context, field graphql.CollectedField, obj *vulnerability.ContainerImageSBOM) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_ContainerImageSBOM_status(ctx, field) + }, + func(ctx context.Context) (any, error) { + return ec.Resolvers.ContainerImageSBOM().Status(ctx, obj) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v vulnerability.SBOMStatus) graphql.Marshaler { + return ec.marshalNSBOMStatus2githubᚗcomᚋnaisᚋapiᚋinternalᚋvulnerabilityᚐSBOMStatus(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_ContainerImageSBOM_status(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("ContainerImageSBOM", field, true, true, errors.New("field of type SBOMStatus does not have child fields")) +} + +func (ec *executionContext) _ContainerImageSBOM_processingStartedAt(ctx context.Context, field graphql.CollectedField, obj *vulnerability.ContainerImageSBOM) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_ContainerImageSBOM_processingStartedAt(ctx, field) + }, + func(ctx context.Context) (any, error) { + return ec.Resolvers.ContainerImageSBOM().ProcessingStartedAt(ctx, obj) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *time.Time) graphql.Marshaler { + return ec.marshalOTime2ᚖtimeᚐTime(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_ContainerImageSBOM_processingStartedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("ContainerImageSBOM", field, true, true, errors.New("field of type Time does not have child fields")) +} + func (ec *executionContext) _ContainerImageWorkloadReference_workload(ctx context.Context, field graphql.CollectedField, obj *vulnerability.ContainerImageWorkloadReference) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1262,6 +1335,29 @@ func (ec *executionContext) fieldContext_ImageVulnerabilitySummary_lastUpdated(_ return graphql.NewScalarFieldContext("ImageVulnerabilitySummary", field, false, false, errors.New("field of type Time does not have child fields")) } +func (ec *executionContext) _ImageVulnerabilitySummary_staleImageTag(ctx context.Context, field graphql.CollectedField, obj *vulnerability.ImageVulnerabilitySummary) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_ImageVulnerabilitySummary_staleImageTag(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.StaleImageTag, 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_ImageVulnerabilitySummary_staleImageTag(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("ImageVulnerabilitySummary", field, false, false, errors.New("field of type String does not have child fields")) +} + func (ec *executionContext) _ImageVulnerabilitySuppression_state(ctx context.Context, field graphql.CollectedField, obj *vulnerability.ImageVulnerabilitySuppression) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -3238,6 +3334,114 @@ func (ec *executionContext) _CVEEdge(ctx context.Context, sel ast.SelectionSet, return out } +var containerImageSBOMImplementors = []string{"ContainerImageSBOM", "Node"} + +func (ec *executionContext) _ContainerImageSBOM(ctx context.Context, sel ast.SelectionSet, obj *vulnerability.ContainerImageSBOM) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, containerImageSBOMImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ContainerImageSBOM") + case "id": + out.Values[i] = ec._ContainerImageSBOM_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "status": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._ContainerImageSBOM_status(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "processingStartedAt": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._ContainerImageSBOM_processingStartedAt(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var containerImageWorkloadReferenceImplementors = []string{"ContainerImageWorkloadReference"} func (ec *executionContext) _ContainerImageWorkloadReference(ctx context.Context, sel ast.SelectionSet, obj *vulnerability.ContainerImageWorkloadReference) graphql.Marshaler { @@ -3695,6 +3899,8 @@ func (ec *executionContext) _ImageVulnerabilitySummary(ctx context.Context, sel } case "lastUpdated": out.Values[i] = ec._ImageVulnerabilitySummary_lastUpdated(ctx, field, obj) + case "staleImageTag": + out.Values[i] = ec._ImageVulnerabilitySummary_staleImageTag(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -4607,6 +4813,20 @@ func (ec *executionContext) marshalNCVEOrderField2githubᚗcomᚋnaisᚋapiᚋin return v } +func (ec *executionContext) marshalNContainerImageSBOM2githubᚗcomᚋnaisᚋapiᚋinternalᚋvulnerabilityᚐContainerImageSBOM(ctx context.Context, sel ast.SelectionSet, v vulnerability.ContainerImageSBOM) graphql.Marshaler { + return ec._ContainerImageSBOM(ctx, sel, &v) +} + +func (ec *executionContext) marshalNContainerImageSBOM2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋvulnerabilityᚐContainerImageSBOM(ctx context.Context, sel ast.SelectionSet, v *vulnerability.ContainerImageSBOM) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ContainerImageSBOM(ctx, sel, v) +} + func (ec *executionContext) marshalNContainerImageWorkloadReference2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋvulnerabilityᚐContainerImageWorkloadReferenceᚄ(ctx context.Context, sel ast.SelectionSet, v []*vulnerability.ContainerImageWorkloadReference) graphql.Marshaler { ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { fc := graphql.GetFieldContext(ctx) @@ -4807,6 +5027,16 @@ func (ec *executionContext) marshalNImageVulnerabilitySuppressionState2githubᚗ return v } +func (ec *executionContext) unmarshalNSBOMStatus2githubᚗcomᚋnaisᚋapiᚋinternalᚋvulnerabilityᚐSBOMStatus(ctx context.Context, v any) (vulnerability.SBOMStatus, error) { + var res vulnerability.SBOMStatus + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNSBOMStatus2githubᚗcomᚋnaisᚋapiᚋinternalᚋvulnerabilityᚐSBOMStatus(ctx context.Context, sel ast.SelectionSet, v vulnerability.SBOMStatus) graphql.Marshaler { + return v +} + func (ec *executionContext) unmarshalNTeamVulnerabilityRiskScoreTrend2githubᚗcomᚋnaisᚋapiᚋinternalᚋvulnerabilityᚐTeamVulnerabilityRiskScoreTrend(ctx context.Context, v any) (vulnerability.TeamVulnerabilityRiskScoreTrend, error) { var res vulnerability.TeamVulnerabilityRiskScoreTrend err := res.UnmarshalGQL(v) diff --git a/internal/graph/gengql/workloads.generated.go b/internal/graph/gengql/workloads.generated.go index 6a92a07ed..d51c65a2a 100644 --- a/internal/graph/gengql/workloads.generated.go +++ b/internal/graph/gengql/workloads.generated.go @@ -26,6 +26,7 @@ import ( type ContainerImageResolver interface { ActivityLog(ctx context.Context, obj *workload.ContainerImage, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *activitylog.ActivityLogFilter) (*activitylog.ActivityLogEntryConnection, error) + Sbom(ctx context.Context, obj *workload.ContainerImage) (*vulnerability.ContainerImageSBOM, error) HasSbom(ctx context.Context, obj *workload.ContainerImage) (bool, error) Vulnerabilities(ctx context.Context, obj *workload.ContainerImage, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, filter *vulnerability.ImageVulnerabilityFilter, orderBy *vulnerability.ImageVulnerabilityOrder) (*pagination.Connection[*vulnerability.ImageVulnerability], error) VulnerabilitySummary(ctx context.Context, obj *workload.ContainerImage) (*vulnerability.ImageVulnerabilitySummary, error) @@ -295,6 +296,38 @@ func (ec *executionContext) fieldContext_ContainerImage_activityLog(ctx context. return fc, nil } +func (ec *executionContext) _ContainerImage_sbom(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_sbom(ctx, field) + }, + func(ctx context.Context) (any, error) { + return ec.Resolvers.ContainerImage().Sbom(ctx, obj) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *vulnerability.ContainerImageSBOM) graphql.Marshaler { + return ec.marshalNContainerImageSBOM2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋvulnerabilityᚐContainerImageSBOM(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_ContainerImage_sbom(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ContainerImage", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_ContainerImageSBOM(ctx, field) + }, + } + return fc, nil +} + func (ec *executionContext) _ContainerImage_hasSBOM(ctx context.Context, field graphql.CollectedField, obj *workload.ContainerImage) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -1059,6 +1092,42 @@ func (ec *executionContext) _ContainerImage(ctx context.Context, sel ast.Selecti continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "sbom": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._ContainerImage_sbom(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "hasSBOM": field := field diff --git a/internal/graph/schema/vulnerability.graphqls b/internal/graph/schema/vulnerability.graphqls index 676968859..95306cccc 100644 --- a/internal/graph/schema/vulnerability.graphqls +++ b/internal/graph/schema/vulnerability.graphqls @@ -123,9 +123,33 @@ type ImageVulnerabilityHistory { samples: [ImageVulnerabilitySample!]! } +""" +SBOM metadata for a container image, including pipeline status and processing +information. This type is accessed through ContainerImage.sbom. +""" +type ContainerImageSBOM implements Node { + "The globally unique ID of the container image SBOM node." + id: ID! + + "The SBOM pipeline status." + status: SBOMStatus! + + """ + The timestamp when SBOM processing started for this image. + Useful as a progress indicator when status is PROCESSING. + """ + processingStartedAt: Time +} + extend type ContainerImage { - "Whether the image has a software bill of materials (SBOM) attached to it." + "SBOM pipeline status and processing information for this image." + sbom: ContainerImageSBOM! + + """ + Whether the image has a software bill of materials (SBOM) attached to it. + """ hasSBOM: Boolean! + @deprecated(reason: "Use sbom { status } == READY to check if an SBOM is attached.") "Get the vulnerabilities of the image." vulnerabilities( @@ -268,6 +292,13 @@ type ImageVulnerabilitySummary { "Timestamp of the last update of the vulnerability summary." lastUpdated: Time + + """ + When set, the vulnerability counts are sourced from this older image tag because + no summary exists yet for the requested tag (e.g. a newly deployed image that is + still being scanned). The value is the tag the stale data originates from. + """ + staleImageTag: String } type ImageVulnerabilityConnection { @@ -476,6 +507,7 @@ type WorkloadVulnerabilitySummary implements Node { "True if the workload has a software bill of materials (SBOM) attached." hasSBOM: Boolean! + @deprecated(reason: "Use workload { image { sbom { status } } } to check SBOM status.") "The vulnerability summary for the workload." summary: ImageVulnerabilitySummary! @@ -718,3 +750,18 @@ type VulnerabilityFixSample { "Total number of workloads with this severity of vulnerabilities." totalWorkloads: Int! } + +"The SBOM pipeline status for an image or workload." +enum SBOMStatus { + "SBOM generation is in progress." + PROCESSING + + "SBOM data is available." + READY + + "No SBOM is available for this image or workload." + NO_SBOM + + "SBOM generation failed." + FAILED +} diff --git a/internal/graph/vulnerability.resolvers.go b/internal/graph/vulnerability.resolvers.go index ea58ff2ad..bede2754b 100644 --- a/internal/graph/vulnerability.resolvers.go +++ b/internal/graph/vulnerability.resolvers.go @@ -2,6 +2,7 @@ package graph import ( "context" + "time" "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/environmentmapper" @@ -37,6 +38,10 @@ func (r *cVEResolver) Workloads(ctx context.Context, obj *vulnerability.CVE, fir return vulnerability.GetWorkloadsByCVE(ctx, obj.Identifier, page) } +func (r *containerImageResolver) Sbom(ctx context.Context, obj *workload.ContainerImage) (*vulnerability.ContainerImageSBOM, error) { + return &vulnerability.ContainerImageSBOM{ImageReference: obj.Ref()}, nil +} + func (r *containerImageResolver) HasSbom(ctx context.Context, obj *workload.ContainerImage) (bool, error) { return vulnerability.GetImageHasSBOM(ctx, obj.Ref()) } @@ -63,6 +68,14 @@ func (r *containerImageResolver) WorkloadReferences(ctx context.Context, obj *wo return vulnerability.ListWorkloadReferences(ctx, obj.Ref(), page) } +func (r *containerImageSBOMResolver) Status(ctx context.Context, obj *vulnerability.ContainerImageSBOM) (vulnerability.SBOMStatus, error) { + return vulnerability.GetSbomStatus(ctx, obj.ImageReference) +} + +func (r *containerImageSBOMResolver) ProcessingStartedAt(ctx context.Context, obj *vulnerability.ContainerImageSBOM) (*time.Time, error) { + return vulnerability.GetSbomProcessingStartedAt(ctx, obj.ImageReference) +} + func (r *containerImageWorkloadReferenceResolver) Workload(ctx context.Context, obj *vulnerability.ContainerImageWorkloadReference) (workload.Workload, error) { return getWorkload(ctx, obj.Reference, obj.TeamSlug, environmentmapper.EnvironmentName(obj.EnvironmentName)) } @@ -152,6 +165,10 @@ func (r *workloadVulnerabilitySummaryResolver) Workload(ctx context.Context, obj func (r *Resolver) CVE() gengql.CVEResolver { return &cVEResolver{r} } +func (r *Resolver) ContainerImageSBOM() gengql.ContainerImageSBOMResolver { + return &containerImageSBOMResolver{r} +} + func (r *Resolver) ContainerImageWorkloadReference() gengql.ContainerImageWorkloadReferenceResolver { return &containerImageWorkloadReferenceResolver{r} } @@ -166,6 +183,7 @@ func (r *Resolver) WorkloadVulnerabilitySummary() gengql.WorkloadVulnerabilitySu type ( cVEResolver struct{ *Resolver } + containerImageSBOMResolver struct{ *Resolver } containerImageWorkloadReferenceResolver struct{ *Resolver } teamVulnerabilitySummaryResolver struct{ *Resolver } workloadVulnerabilitySummaryResolver struct{ *Resolver } diff --git a/internal/issue/checker/workload_v13s.go b/internal/issue/checker/workload_v13s.go index 1e35480a7..1fc65db5d 100644 --- a/internal/issue/checker/workload_v13s.go +++ b/internal/issue/checker/workload_v13s.go @@ -38,10 +38,12 @@ func (f fakeV13sClient) ListVulnerabilitySummaries(ctx context.Context, opts ... ImageTag: "tag1", }, VulnerabilitySummary: &vulnerabilities.Summary{ - HasSbom: true, Critical: 5, RiskScore: 250, }, + SbomStatus: &vulnerabilities.SbomStatusInfo{ + Status: vulnerabilities.SbomStatus_SBOM_STATUS_READY, + }, }, { Id: "2", @@ -53,8 +55,8 @@ func (f fakeV13sClient) ListVulnerabilitySummaries(ctx context.Context, opts ... ImageName: "missing-sbom-image", ImageTag: "tag1", }, - VulnerabilitySummary: &vulnerabilities.Summary{ - HasSbom: false, + SbomStatus: &vulnerabilities.SbomStatusInfo{ + Status: vulnerabilities.SbomStatus_SBOM_STATUS_NO_SBOM, }, }, { @@ -68,10 +70,12 @@ func (f fakeV13sClient) ListVulnerabilitySummaries(ctx context.Context, opts ... ImageTag: "tag1", }, VulnerabilitySummary: &vulnerabilities.Summary{ - HasSbom: true, Critical: 5, RiskScore: 250, }, + SbomStatus: &vulnerabilities.SbomStatusInfo{ + Status: vulnerabilities.SbomStatus_SBOM_STATUS_READY, + }, }, { Id: "4", @@ -83,8 +87,8 @@ func (f fakeV13sClient) ListVulnerabilitySummaries(ctx context.Context, opts ... ImageName: "missing-sbom-image", ImageTag: "tag1", }, - VulnerabilitySummary: &vulnerabilities.Summary{ - HasSbom: false, + SbomStatus: &vulnerabilities.SbomStatusInfo{ + Status: vulnerabilities.SbomStatus_SBOM_STATUS_NO_SBOM, }, }, { @@ -97,8 +101,8 @@ func (f fakeV13sClient) ListVulnerabilitySummaries(ctx context.Context, opts ... ImageName: "some-image", ImageTag: "tag1", }, - VulnerabilitySummary: &vulnerabilities.Summary{ - HasSbom: false, + SbomStatus: &vulnerabilities.SbomStatusInfo{ + Status: vulnerabilities.SbomStatus_SBOM_STATUS_NO_SBOM, }, }, }, @@ -177,7 +181,7 @@ func (w Workload) vulnerabilities(ctx context.Context) []*Issue { continue } - if node.VulnerabilitySummary.Critical > 0 || node.VulnerabilitySummary.RiskScore > 100 { + if node.VulnerabilitySummary != nil && (node.VulnerabilitySummary.Critical > 0 || node.VulnerabilitySummary.RiskScore > 100) { ret = append(ret, &Issue{ IssueType: issue.IssueTypeVulnerableImage, ResourceType: workloadType, @@ -198,7 +202,10 @@ func (w Workload) vulnerabilities(ctx context.Context) []*Issue { }) } - if !node.VulnerabilitySummary.HasSbom { + sbomStatus := node.GetSbomStatus().GetStatus() + if sbomStatus != vulnerabilities.SbomStatus_SBOM_STATUS_READY && + sbomStatus != vulnerabilities.SbomStatus_SBOM_STATUS_PROCESSING && + sbomStatus != vulnerabilities.SbomStatus_SBOM_STATUS_UNSPECIFIED { ret = append(ret, &Issue{ IssueType: issue.IssueTypeMissingSBOM, ResourceType: workloadType, diff --git a/internal/vulnerability/dataloader.go b/internal/vulnerability/dataloader.go index 71dbfff0e..a4ba4f1f2 100644 --- a/internal/vulnerability/dataloader.go +++ b/internal/vulnerability/dataloader.go @@ -3,17 +3,73 @@ package vulnerability import ( "context" + "github.com/nais/api/internal/graph/loader" + "github.com/nais/v13s/pkg/api/vulnerabilities" "github.com/sirupsen/logrus" + "github.com/sourcegraph/conc/pool" + "github.com/vikstrous/dataloadgen" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) type ctxKey int -const managerKey ctxKey = iota +const loadersKey ctxKey = iota func NewLoaderContext(ctx context.Context, vulnMgr *Manager, logger logrus.FieldLogger) context.Context { - return context.WithValue(ctx, managerKey, vulnMgr) + return context.WithValue(ctx, loadersKey, newLoaders(vulnMgr, logger)) } -func fromContext(ctx context.Context) *Manager { - return ctx.Value(managerKey).(*Manager) +func fromContext(ctx context.Context) *loaders { + return ctx.Value(loadersKey).(*loaders) +} + +type loaders struct { + manager *Manager + imageSummaryLoader *dataloadgen.Loader[string, *vulnerabilities.GetVulnerabilitySummaryForImageResponse] +} + +func newLoaders(mgr *Manager, logger logrus.FieldLogger) *loaders { + dl := &dataloader{manager: mgr, log: logger} + return &loaders{ + manager: mgr, + imageSummaryLoader: dataloadgen.NewLoader(dl.batchGetImageSummaries, loader.DefaultDataLoaderOptions...), + } +} + +type dataloader struct { + manager *Manager + log logrus.FieldLogger +} + +func (l dataloader) batchGetImageSummaries(ctx context.Context, refs []string) ([]*vulnerabilities.GetVulnerabilitySummaryForImageResponse, []error) { + wg := pool.New().WithContext(ctx).WithMaxGoroutines(10) + rets := make([]*vulnerabilities.GetVulnerabilitySummaryForImageResponse, len(refs)) + errs := make([]error, len(refs)) + + for i, ref := range refs { + i, ref := i, ref + wg.Go(func(ctx context.Context) error { + name, tag, _ := splitImage(ref) + res, err := l.manager.Client.GetVulnerabilitySummaryForImage(ctx, name, tag) + if err != nil { + if status.Code(err) == codes.NotFound { + rets[i] = &vulnerabilities.GetVulnerabilitySummaryForImageResponse{} + } else { + errs[i] = err + } + } else { + rets[i] = res + } + return nil + }) + } + + wg.Wait() // #nosec G104 -- goroutines always return nil; per-key errors are collected in errs + + return rets, errs +} + +func loadImageSummary(ctx context.Context, ref string) (*vulnerabilities.GetVulnerabilitySummaryForImageResponse, error) { + return fromContext(ctx).imageSummaryLoader.Load(ctx, ref) } diff --git a/internal/vulnerability/fake/v13s.go b/internal/vulnerability/fake/v13s.go index 27bd8e3e5..68ed426c2 100644 --- a/internal/vulnerability/fake/v13s.go +++ b/internal/vulnerability/fake/v13s.go @@ -126,16 +126,22 @@ func (f *fakeVulnerabilitiesClient) GetVulnerabilitySummary(ctx context.Context, func (f *fakeVulnerabilitiesClient) GetVulnerabilitySummaryForImage(ctx context.Context, imageName, imageTag string) (*vulnerabilities.GetVulnerabilitySummaryForImageResponse, error) { f.createFakeDataIfNotSet(ctx) summary := &vulnerabilities.Summary{} - workloadRefs := make([]*vulnerabilities.Workload, 0) + workloads := make([]*vulnerabilities.Workload, 0) for _, s := range f.fakeData.workloadSummaries { if s.Workload.ImageName == imageName && s.Workload.ImageTag == imageTag { summary = s.VulnerabilitySummary - workloadRefs = append(workloadRefs, s.Workload) + workloads = append(workloads, s.Workload) } } + sbomStatus := vulnerabilities.SbomStatus_SBOM_STATUS_NO_SBOM + if summary.HasSbom { + sbomStatus = vulnerabilities.SbomStatus_SBOM_STATUS_READY + } + return &vulnerabilities.GetVulnerabilitySummaryForImageResponse{ VulnerabilitySummary: summary, - WorkloadRef: workloadRefs, + WorkloadRef: workloads, + SbomStatus: &vulnerabilities.SbomStatusInfo{Status: sbomStatus}, }, nil } diff --git a/internal/vulnerability/models.go b/internal/vulnerability/models.go index 6b4dc076a..4594d57f5 100644 --- a/internal/vulnerability/models.go +++ b/internal/vulnerability/models.go @@ -27,6 +27,15 @@ type ( CVEEdge = pagination.Edge[*CVE] ) +type ContainerImageSBOM struct { + ImageReference string +} + +func (c *ContainerImageSBOM) IsNode() {} +func (c *ContainerImageSBOM) ID() ident.Ident { + return newContainerImageSbomIdent(c.ImageReference) +} + type ContainerImageWorkloadReference struct { Reference *workload.Reference `json:"-"` TeamSlug slug.Slug `json:"-"` @@ -62,14 +71,15 @@ type ImageVulnerabilitySuppression struct { } type ImageVulnerabilitySummary struct { - Total int `json:"total"` - RiskScore int `json:"riskScore"` - Low int `json:"low"` - Medium int `json:"medium"` - High int `json:"high"` - Critical int `json:"critical"` - Unassigned int `json:"unassigned"` - LastUpdated *time.Time `json:"lastUpdated"` + Total int `json:"total"` + RiskScore int `json:"riskScore"` + Low int `json:"low"` + Medium int `json:"medium"` + High int `json:"high"` + Critical int `json:"critical"` + Unassigned int `json:"unassigned"` + LastUpdated *time.Time `json:"lastUpdated"` + StaleImageTag *string `json:"staleImageTag"` } type ImageVulnerabilityOrderField string @@ -486,3 +496,60 @@ func (e CVEOrderField) MarshalJSON() ([]byte, error) { e.MarshalGQL(&buf) return buf.Bytes(), nil } + +type SBOMStatus int32 + +const ( + SBOMStatusUnspecified SBOMStatus = 0 + SBOMStatusProcessing SBOMStatus = 2 + SBOMStatusReady SBOMStatus = 3 + SBOMStatusNoSbom SBOMStatus = 4 + SBOMStatusFailed SBOMStatus = 5 +) + +var sbomStatusNames = map[SBOMStatus]string{ + SBOMStatusUnspecified: "UNSPECIFIED", + SBOMStatusProcessing: "PROCESSING", + SBOMStatusReady: "READY", + SBOMStatusNoSbom: "NO_SBOM", + SBOMStatusFailed: "FAILED", +} + +var sbomStatusValues = map[string]SBOMStatus{ + "UNSPECIFIED": SBOMStatusUnspecified, + "PROCESSING": SBOMStatusProcessing, + "READY": SBOMStatusReady, + "NO_SBOM": SBOMStatusNoSbom, + "FAILED": SBOMStatusFailed, +} + +func (s SBOMStatus) String() string { + s = sanitiseSbomStatus(s) + if name, ok := sbomStatusNames[s]; ok { + return name + } + return sbomStatusNames[SBOMStatusProcessing] +} + +func (s SBOMStatus) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(s.String())) +} + +func sanitiseSbomStatus(s SBOMStatus) SBOMStatus { + if s == SBOMStatusUnspecified { + return SBOMStatusNoSbom + } + return s +} + +func (s *SBOMStatus) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("SBOMStatus must be a string") + } + if val, ok := sbomStatusValues[str]; ok { + *s = val + return nil + } + return fmt.Errorf("%s is not a valid SBOMStatus", str) +} diff --git a/internal/vulnerability/node.go b/internal/vulnerability/node.go index 1c7e89889..5bd68f057 100644 --- a/internal/vulnerability/node.go +++ b/internal/vulnerability/node.go @@ -1,6 +1,7 @@ package vulnerability import ( + "context" "fmt" "github.com/nais/api/internal/graph/ident" @@ -12,6 +13,7 @@ const ( identVulnerability identType = iota identWorkloadVulnerabilitySummary identCVE + identContainerImageSbom ) func init() { @@ -19,6 +21,7 @@ func init() { ident.RegisterIdentType(identVulnerability, "VUL", getVulnerabilityByIdent) ident.RegisterIdentType(identWorkloadVulnerabilitySummary, "WVS", getWorkloadVulnerabilitySummaryByIdent) ident.RegisterIdentType(identCVE, "CVE", getCVEByIdent) + ident.RegisterIdentType(identContainerImageSbom, "CIS", getContainerImageSbomByIdent) } func newWorkloadVulnerabilitySummaryIdent(w WorkloadReference) ident.Ident { @@ -60,3 +63,23 @@ func parseCVEIdent(id ident.Ident) (string, error) { return parts[0], nil } + +func newContainerImageSbomIdent(imageRef string) ident.Ident { + return ident.NewIdent(identContainerImageSbom, imageRef) +} + +func parseContainerImageSbomIdent(id ident.Ident) (string, error) { + parts := id.Parts() + if len(parts) != 1 { + return "", fmt.Errorf("invalid container image sbom ident") + } + return parts[0], nil +} + +func getContainerImageSbomByIdent(ctx context.Context, id ident.Ident) (*ContainerImageSBOM, error) { + imageRef, err := parseContainerImageSbomIdent(id) + if err != nil { + return nil, err + } + return &ContainerImageSBOM{ImageReference: imageRef}, nil +} diff --git a/internal/vulnerability/queries.go b/internal/vulnerability/queries.go index 7ed179aea..9f1e5eaf7 100644 --- a/internal/vulnerability/queries.go +++ b/internal/vulnerability/queries.go @@ -23,19 +23,18 @@ import ( ) func ListWorkloadReferences(ctx context.Context, image string, page *pagination.Pagination) (*pagination.Connection[*ContainerImageWorkloadReference], error) { - imageName, tag, _ := splitImage(image) - resp, err := fromContext(ctx).Client.GetVulnerabilitySummaryForImage(ctx, imageName, tag) + resp, err := loadImageSummary(ctx, image) if err != nil { return nil, err } workloadRefs := make([]*WorkloadReference, 0) - for _, ref := range resp.GetWorkloadRef() { + for _, w := range resp.GetWorkloadRef() { workloadRefs = append(workloadRefs, &WorkloadReference{ - Environment: environmentmapper.EnvironmentName(ref.GetCluster()), - Team: ref.GetNamespace(), - Name: ref.GetName(), - WorkloadType: ref.GetType(), + Environment: environmentmapper.EnvironmentName(w.GetCluster()), + Team: w.GetNamespace(), + Name: w.GetName(), + WorkloadType: w.GetType(), }) } @@ -89,7 +88,7 @@ func ListVulnerabilitySummaries(ctx context.Context, s slug.Slug, filter *TeamVu opts = append(opts, vulnerabilities.Order(o, direction)) } - resp, err := fromContext(ctx).Client.ListVulnerabilitySummaries(ctx, opts...) + resp, err := fromContext(ctx).manager.Client.ListVulnerabilitySummaries(ctx, opts...) if err != nil { return nil, apierror.Errorf("list vulnerability summaries: %v", err) } @@ -133,7 +132,7 @@ func ListImageVulnerabilities(ctx context.Context, ref string, filter *ImageVuln opts = append(opts, vulnerabilities.Order(o, direction)) imageName, imageTag, _ := splitImage(ref) - resp, err := fromContext(ctx).Client.ListVulnerabilitiesForImage( + resp, err := fromContext(ctx).manager.Client.ListVulnerabilitiesForImage( ctx, imageName, imageTag, @@ -152,12 +151,11 @@ func ListImageVulnerabilities(ctx context.Context, ref string, filter *ImageVuln } func GetImageHasSBOM(ctx context.Context, imageRef string) (bool, error) { - imageName, imageTag, _ := splitImage(imageRef) - resp, err := fromContext(ctx).Client.GetVulnerabilitySummaryForImage(ctx, imageName, imageTag) + status, err := GetSbomStatus(ctx, imageRef) if err != nil { - return false, apierror.Errorf("get vulnerability summary: %v", err) + return false, err } - return resp.GetVulnerabilitySummary().GetHasSbom(), nil + return status == SBOMStatusReady, nil } func GetTeamRiskScoreTrend(ctx context.Context, teamSlug slug.Slug) (TeamVulnerabilityRiskScoreTrend, error) { @@ -168,7 +166,7 @@ func GetTeamRiskScoreTrend(ctx context.Context, teamSlug slug.Slug) (TeamVulnera vulnerabilities.NamespaceFilter(teamSlug.String()), } - resp, err := fromContext(ctx).Client.GetVulnerabilitySummaryTimeSeries(ctx, opts...) + resp, err := fromContext(ctx).manager.Client.GetVulnerabilitySummaryTimeSeries(ctx, opts...) if err != nil { return "", fmt.Errorf("fetching vulnerability summary time series: %w", err) } @@ -247,7 +245,7 @@ func mean(vals []float64) float64 { } func ListWorkloadsForVulnerabilityByID(ctx context.Context, id string) ([]*WorkloadReference, error) { - resp, err := fromContext(ctx).Client.ListWorkloadsForVulnerabilityById(ctx, id) + resp, err := fromContext(ctx).manager.Client.ListWorkloadsForVulnerabilityById(ctx, id) if err != nil { return nil, apierror.Errorf("list workloads for vulnerability: %v", err) } @@ -304,12 +302,12 @@ func UpdateImageVulnerability(ctx context.Context, input UpdateImageVulnerabilit suppress = false } - err = fromContext(ctx).Client.SuppressVulnerability(ctx, input.VulnerabilityID.ID, reason, usr.Identity(), state, suppress) + err = fromContext(ctx).manager.Client.SuppressVulnerability(ctx, input.VulnerabilityID.ID, reason, usr.Identity(), state, suppress) if err != nil { return nil, err } - vuln, err := fromContext(ctx).Client.GetVulnerabilityById(ctx, input.VulnerabilityID.ID) + vuln, err := fromContext(ctx).manager.Client.GetVulnerabilityById(ctx, input.VulnerabilityID.ID) if err != nil { return nil, err } @@ -352,12 +350,14 @@ func UpdateImageVulnerability(ctx context.Context, input UpdateImageVulnerabilit } func GetImageVulnerabilitySummary(ctx context.Context, ref string) (*ImageVulnerabilitySummary, error) { - imageName, imageTag, _ := splitImage(ref) - resp, err := fromContext(ctx).Client.GetVulnerabilitySummaryForImage(ctx, imageName, imageTag) + resp, err := loadImageSummary(ctx, ref) if err != nil { return nil, apierror.Errorf("get vulnerability summary: %v", err) } sum := resp.GetVulnerabilitySummary() + if sum == nil { + return nil, nil + } var lastUpdated *time.Time if ts := sum.GetLastUpdated(); ts != nil { @@ -366,14 +366,15 @@ func GetImageVulnerabilitySummary(ctx context.Context, ref string) (*ImageVulner } return &ImageVulnerabilitySummary{ - Critical: int(sum.GetCritical()), - High: int(sum.GetHigh()), - Medium: int(sum.GetMedium()), - Low: int(sum.GetLow()), - Unassigned: int(sum.GetUnassigned()), - Total: int(sum.GetTotal()), - RiskScore: int(sum.GetRiskScore()), - LastUpdated: lastUpdated, + Critical: int(sum.GetCritical()), + High: int(sum.GetHigh()), + Medium: int(sum.GetMedium()), + Low: int(sum.GetLow()), + Unassigned: int(sum.GetUnassigned()), + Total: int(sum.GetTotal()), + RiskScore: int(sum.GetRiskScore()), + LastUpdated: lastUpdated, + StaleImageTag: sum.StaleImageTag, }, nil } @@ -395,7 +396,7 @@ func GetWorkloadVulnerabilityHistoryForTeam(ctx context.Context, slug slug.Slug, } func GetTenantVulnerabilitySummary(ctx context.Context) (*TenantVulnerabilitySummary, error) { - resp, err := fromContext(ctx).Client.GetVulnerabilitySummary(ctx) + resp, err := fromContext(ctx).manager.Client.GetVulnerabilitySummary(ctx) if err != nil { return nil, apierror.Errorf("get tenant vulnerability summary: %v", err) } @@ -476,7 +477,7 @@ func normalizeFromDate(from time.Time) time.Time { } func getVulnerabilityHistory(ctx context.Context, opts []vulnerabilities.Option) (*ImageVulnerabilityHistory, error) { - resp, err := fromContext(ctx).Client.GetVulnerabilitySummaryTimeSeries(ctx, opts...) + resp, err := fromContext(ctx).manager.Client.GetVulnerabilitySummaryTimeSeries(ctx, opts...) if err != nil { return nil, apierror.Errorf("get vulnerability history: %v", err) } @@ -518,7 +519,7 @@ func GetVulnerabilitySummary(ctx context.Context, s slug.Slug, filter *TeamVulne opts = append(opts, vulnerabilities.ClusterFilter(environmentmapper.ClusterName(*env))) } - resp, err := fromContext(ctx).Client.GetVulnerabilitySummary(ctx, opts...) + resp, err := fromContext(ctx).manager.Client.GetVulnerabilitySummary(ctx, opts...) if err != nil { return nil, apierror.Errorf("get vulnerability summary: %v", err) } @@ -617,7 +618,7 @@ func GetVulnerabilityMeanTimeToFixHistory(ctx context.Context, from time.Time) ( } func GetCVE(ctx context.Context, cve string) (*CVE, error) { - vuln, err := fromContext(ctx).Client.GetCve(ctx, cve) + vuln, err := fromContext(ctx).manager.Client.GetCve(ctx, cve) if err != nil { return nil, apierror.Errorf("get vulnerability by CVE: %v", err) } @@ -658,7 +659,7 @@ func ListCVEs(ctx context.Context, page *pagination.Pagination, orderBy *CVEOrde opts = append(opts, vulnerabilities.Order(field, direction)) } - resp, err := fromContext(ctx).Client.ListCveSummaries(ctx, opts...) + resp, err := fromContext(ctx).manager.Client.ListCveSummaries(ctx, opts...) if err != nil { return nil, apierror.Errorf("list CVE summaries: %v", err) } @@ -685,7 +686,7 @@ func GetWorkloadsByCVE(ctx context.Context, cve string, page *pagination.Paginat opts = append(opts, vulnerabilities.Order(vulnerabilities.OrderByNamespace, vulnerabilities.Direction_ASC)) - resp, err := fromContext(ctx).Client.ListWorkloadsForVulnerability( + resp, err := fromContext(ctx).manager.Client.ListWorkloadsForVulnerability( ctx, vulnerabilities.VulnerabilityFilter{ CveIds: []string{cve}, @@ -728,7 +729,7 @@ func getCVEByIdent(ctx context.Context, id ident.Ident) (*CVE, error) { } func getVulnerabilityMeanTimeToFixHistory(ctx context.Context, opts []vulnerabilities.Option) (*VulnerabilityFixHistory, error) { - resp, err := fromContext(ctx).Client.ListMeanTimeToFixTrendBySeverity(ctx, opts...) + resp, err := fromContext(ctx).manager.Client.ListMeanTimeToFixTrendBySeverity(ctx, opts...) if err != nil { return nil, apierror.Errorf("list mean time to fix trend by severity: %v", err) } @@ -785,7 +786,7 @@ func parseSeverity(s vulnerabilities.Severity) ImageVulnerabilitySeverity { } func getVulnerabilityByIdent(ctx context.Context, id ident.Ident) (*ImageVulnerability, error) { - resp, err := fromContext(ctx).Client.GetVulnerabilityById(ctx, id.ID) + resp, err := fromContext(ctx).manager.Client.GetVulnerabilityById(ctx, id.ID) if err != nil { return nil, apierror.Errorf("%v", err) } @@ -798,7 +799,7 @@ func getWorkloadVulnerabilitySummaryByIdent(ctx context.Context, id ident.Ident) return nil, err } - resp, err := fromContext(ctx).Client.ListVulnerabilitySummaries( + resp, err := fromContext(ctx).manager.Client.ListVulnerabilitySummaries( ctx, vulnerabilities.ClusterFilter(w.Environment), vulnerabilities.NamespaceFilter(w.Team), @@ -838,3 +839,25 @@ func splitImage(image string) (name, tag, sha string) { } return name, tag, sha } + +func GetSbomStatus(ctx context.Context, ref string) (SBOMStatus, error) { + resp, err := loadImageSummary(ctx, ref) + if err != nil { + return SBOMStatusUnspecified, apierror.Errorf("get vulnerability summary: %v", err) + } + s := SBOMStatus(resp.GetSbomStatus().GetStatus()) + return sanitiseSbomStatus(s), nil +} + +func GetSbomProcessingStartedAt(ctx context.Context, ref string) (*time.Time, error) { + resp, err := loadImageSummary(ctx, ref) + if err != nil { + return nil, apierror.Errorf("get vulnerability summary: %v", err) + } + ts := resp.GetSbomStatus().GetProcessingStartedAt() + if ts == nil { + return nil, nil + } + t := ts.AsTime() + return &t, nil +} diff --git a/internal/vulnerability/transform.go b/internal/vulnerability/transform.go index 1acfb5660..000f50ca4 100644 --- a/internal/vulnerability/transform.go +++ b/internal/vulnerability/transform.go @@ -61,24 +61,26 @@ func toWorkloadVulnerabilitySummary(w *vulnerabilities.WorkloadSummary) *Workloa wType = workload.TypeJob } - var lastUpdated *time.Time - if ts := w.GetVulnerabilitySummary().GetLastUpdated(); ts != nil { - t := ts.AsTime() - lastUpdated = &t + summary := &ImageVulnerabilitySummary{} + if s := w.GetVulnerabilitySummary(); s != nil { + var lastUpdated *time.Time + if ts := s.GetLastUpdated(); ts != nil { + t := ts.AsTime() + lastUpdated = &t + } + summary.Critical = int(s.Critical) + summary.High = int(s.High) + summary.Medium = int(s.Medium) + summary.Low = int(s.Low) + summary.Unassigned = int(s.Unassigned) + summary.Total = int(s.Total) + summary.RiskScore = int(s.RiskScore) + summary.LastUpdated = lastUpdated } return &WorkloadVulnerabilitySummary{ - Summary: &ImageVulnerabilitySummary{ - Critical: int(w.GetVulnerabilitySummary().Critical), - High: int(w.GetVulnerabilitySummary().High), - Medium: int(w.GetVulnerabilitySummary().Medium), - Low: int(w.GetVulnerabilitySummary().Low), - Unassigned: int(w.GetVulnerabilitySummary().Unassigned), - Total: int(w.GetVulnerabilitySummary().Total), - RiskScore: int(w.GetVulnerabilitySummary().RiskScore), - LastUpdated: lastUpdated, - }, - HasSbom: w.GetVulnerabilitySummary().GetHasSbom(), + Summary: summary, + HasSbom: w.GetSbomStatus().GetStatus() == vulnerabilities.SbomStatus_SBOM_STATUS_READY, TeamSlug: slug.Slug(w.GetWorkload().GetNamespace()), EnvironmentName: environmentmapper.EnvironmentName(w.GetWorkload().GetCluster()), WorkloadReference: &workload.Reference{