diff --git a/internal/api/graphql/graph/baseResolver/vulnerability.go b/internal/api/graphql/graph/baseResolver/vulnerability.go index de0cb674..e38fd560 100644 --- a/internal/api/graphql/graph/baseResolver/vulnerability.go +++ b/internal/api/graphql/graph/baseResolver/vulnerability.go @@ -96,6 +96,11 @@ func VulnerabilityBaseResolver(app app.Heureka, ctx context.Context, Direction: entity.OrderDirectionDesc, }) + opt.Order = append(opt.Order, entity.Order{ + By: entity.IssueEarliestTargetRemediationDate, + Direction: entity.OrderDirectionAsc, + }) + opt.Order = append(opt.Order, entity.Order{ By: entity.IssuePrimaryName, Direction: entity.OrderDirectionAsc, diff --git a/internal/app/issue/issue_handler_test.go b/internal/app/issue/issue_handler_test.go index e9b6b995..9bc0840c 100644 --- a/internal/app/issue/issue_handler_test.go +++ b/internal/app/issue/issue_handler_test.go @@ -4,6 +4,7 @@ package issue_test import ( "context" + "database/sql" "errors" "math" "strconv" @@ -221,7 +222,7 @@ var _ = Describe("When listing Issues", Label("app", "ListIssues"), func() { filter.First = &pageSize issues := []entity.IssueResult{} for _, i := range test.NNewFakeIssueEntities(resElements) { - cursor, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, i, 0)) + cursor, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, i, 0, sql.NullTime{})) issues = append( issues, entity.IssueResult{ @@ -233,7 +234,7 @@ var _ = Describe("When listing Issues", Label("app", "ListIssues"), func() { cursors := lo.Map(issues, func(ir entity.IssueResult, _ int) string { cursor, _ := mariadb.EncodeCursor( - mariadb.WithIssue([]entity.Order{}, *ir.Issue, 0), + mariadb.WithIssue([]entity.Order{}, *ir.Issue, 0, sql.NullTime{}), ) return cursor }) @@ -242,7 +243,7 @@ var _ = Describe("When listing Issues", Label("app", "ListIssues"), func() { for len(cursors) < dbElements { i++ issue := test.NewFakeIssueEntity() - c, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, issue, 0)) + c, _ := mariadb.EncodeCursor(mariadb.WithIssue([]entity.Order{}, issue, 0, sql.NullTime{})) cursors = append(cursors, c) } db.On("GetIssues", mock.Anything, filter, []entity.Order{}).Return(issues, nil) diff --git a/internal/database/mariadb/autopatch.go b/internal/database/mariadb/autopatch.go index 96f641f6..b06e0850 100644 --- a/internal/database/mariadb/autopatch.go +++ b/internal/database/mariadb/autopatch.go @@ -281,19 +281,15 @@ func (s *SqlDatabase) deleteVersionsOfDisappearedInstances( } if len(res) == 0 { - for vIdDi := range versionIdsOfDisappearedInstances { - if err := s.RemoveAllIssuesFromComponentVersion(vIdDi); err != nil { return err } - } if err := s.DeleteComponentVersion(vIdDi, util.SystemUserId); err != nil { return err } - } } diff --git a/internal/database/mariadb/cursor.go b/internal/database/mariadb/cursor.go index d8135a8b..fb8d1d00 100644 --- a/internal/database/mariadb/cursor.go +++ b/internal/database/mariadb/cursor.go @@ -5,6 +5,7 @@ package mariadb import ( "bytes" + "database/sql" "encoding/base64" "encoding/json" "fmt" @@ -346,7 +347,7 @@ func WithComponentVersion( } } -func WithIssue(order []entity.Order, issue entity.Issue, ivRating int64) NewCursor { +func WithIssue(order []entity.Order, issue entity.Issue, ivRating int64, earliestTargetRemediation sql.NullTime) NewCursor { return func(cursors *cursors) error { order = GetDefaultOrder(order, entity.IssueId, entity.OrderDirectionAsc) for _, o := range order { @@ -370,6 +371,11 @@ func WithIssue(order []entity.Order, issue entity.Issue, ivRating int64) NewCurs cursors.fields, Field{Name: entity.IssueVariantRating, Value: ivRating, Order: o.Direction}, ) + case entity.IssueEarliestTargetRemediationDate: + cursors.fields = append( + cursors.fields, + Field{Name: entity.IssueEarliestTargetRemediationDate, Value: earliestTargetRemediation.Time, Order: o.Direction}, + ) default: continue } diff --git a/internal/database/mariadb/entity.go b/internal/database/mariadb/entity.go index d820eb10..004bccaa 100644 --- a/internal/database/mariadb/entity.go +++ b/internal/database/mariadb/entity.go @@ -533,20 +533,21 @@ func (irr IssueRepositoryRow) AsIssueRepository() entity.IssueRepository { } type IssueVariantRow struct { - Id sql.NullInt64 `db:"issuevariant_id" json:"id"` - IssueId sql.NullInt64 `db:"issuevariant_issue_id" json:"issue_id"` - IssueRepositoryId sql.NullInt64 `db:"issuevariant_repository_id" json:"issue_repository_id"` - SecondaryName sql.NullString `db:"issuevariant_secondary_name" json:"secondary_name"` - Vector sql.NullString `db:"issuevariant_vector" json:"vector"` - Rating sql.NullString `db:"issuevariant_rating" json:"rating"` - RatingNumerical sql.NullInt64 `db:"issuevariant_rating_num" json:"rating_numerical"` - Description sql.NullString `db:"issuevariant_description" json:"description"` - ExternalUrl sql.NullString `db:"issuevariant_external_url" json:"external_url"` - CreatedAt sql.NullTime `db:"issuevariant_created_at" json:"created_at"` - CreatedBy sql.NullInt64 `db:"issuevariant_created_by" json:"created_by"` - DeletedAt sql.NullTime `db:"issuevariant_deleted_at" json:"deleted_at"` - UpdatedAt sql.NullTime `db:"issuevariant_updated_at" json:"updated_at"` - UpdatedBy sql.NullInt64 `db:"issuevariant_updated_by" json:"updated_by"` + Id sql.NullInt64 `db:"issuevariant_id" json:"id"` + IssueId sql.NullInt64 `db:"issuevariant_issue_id" json:"issue_id"` + IssueRepositoryId sql.NullInt64 `db:"issuevariant_repository_id" json:"issue_repository_id"` + SecondaryName sql.NullString `db:"issuevariant_secondary_name" json:"secondary_name"` + Vector sql.NullString `db:"issuevariant_vector" json:"vector"` + Rating sql.NullString `db:"issuevariant_rating" json:"rating"` + RatingNumerical sql.NullInt64 `db:"issuevariant_rating_num" json:"rating_numerical"` + EarliestTargetRemediation sql.NullTime `db:"issue_earliest_target_remediation_date" json:"issue_earliest_target_remediation_date"` + Description sql.NullString `db:"issuevariant_description" json:"description"` + ExternalUrl sql.NullString `db:"issuevariant_external_url" json:"external_url"` + CreatedAt sql.NullTime `db:"issuevariant_created_at" json:"created_at"` + CreatedBy sql.NullInt64 `db:"issuevariant_created_by" json:"created_by"` + DeletedAt sql.NullTime `db:"issuevariant_deleted_at" json:"deleted_at"` + UpdatedAt sql.NullTime `db:"issuevariant_updated_at" json:"updated_at"` + UpdatedBy sql.NullInt64 `db:"issuevariant_updated_by" json:"updated_by"` } func (ivr IssueVariantRow) AsIssueVariant() entity.IssueVariant { diff --git a/internal/database/mariadb/issue.go b/internal/database/mariadb/issue.go index 34ba44de..6af478cf 100644 --- a/internal/database/mariadb/issue.go +++ b/internal/database/mariadb/issue.go @@ -5,6 +5,7 @@ package mariadb import ( "context" + "database/sql" "fmt" "strings" @@ -318,25 +319,40 @@ var issueObject = DbObject[*entity.Issue, *entity.IssueFilter, entity.IssueResul }, }, Attributes: []Attr{{Name: "primary_name", Order: entity.Order{By: entity.IssuePrimaryName, Direction: entity.OrderDirectionAsc}}}, - ExtraColumnsSelector: func(_ *entity.IssueFilter, order *Order) []string { + ExtraColumnsSelector: func(f *entity.IssueFilter, order *Order) []string { + var cols []string for _, o := range order.Sequence() { switch o.By { case entity.IssueVariantRating: - return []string{"MAX(CAST(IV.issuevariant_rating AS UNSIGNED)) AS issuevariant_rating_num"} + cols = append(cols, "MAX(CAST(IV.issuevariant_rating AS UNSIGNED)) AS issuevariant_rating_num") + case entity.IssueEarliestTargetRemediationDate: + if f != nil && f.UseMvVulnerabilityList { + // MV path: pre-aggregated by the materialized view. + // COALESCE ensures non-NULL so the cursor can always encode this field. + // Vulnerabilities with no date sort last (far-future sentinel > any real date). + cols = append(cols, "COALESCE(MVL.earliest_remediation_date, CAST('9999-12-31 23:59:59' AS DATETIME)) AS issue_earliest_target_remediation_date") + } else { + // Non-MV path (image/imageVersion child queries): IM is RIGHT-joined and + // the query groups by issue_id, so we must aggregate here too. + // MIN gives a single deterministic value per group, matching MV semantics. + cols = append(cols, "COALESCE(MIN(IM.issuematch_target_remediation_date), CAST('9999-12-31 23:59:59' AS DATETIME)) AS issue_earliest_target_remediation_date") + } } } - return []string{} + return cols }, RowToData: func(e RowComposite, order []entity.Order) (*entity.Issue, string) { issue := e.IssueRow.AsIssue() var ivRating int64 + var earliestTargetRemediation sql.NullTime if e.IssueVariantRow != nil { ivRating = e.RatingNumerical.Int64 + earliestTargetRemediation = e.EarliestTargetRemediation } - cursor, _ := EncodeCursor(WithIssue(order, issue, ivRating)) + cursor, _ := EncodeCursor(WithIssue(order, issue, ivRating, earliestTargetRemediation)) return &issue, cursor }, @@ -348,11 +364,17 @@ var issueObject = DbObject[*entity.Issue, *entity.IssueFilter, entity.IssueResul }, } -func appendIssueColumns(s []string, order []entity.Order) []string { +func appendIssueColumns(s []string, filter *entity.IssueFilter, order []entity.Order) []string { for _, o := range order { switch o.By { case entity.IssueVariantRating: s = append(s, "MAX(CAST(IV.issuevariant_rating AS UNSIGNED)) AS issuevariant_rating_num") + case entity.IssueEarliestTargetRemediationDate: + if filter != nil && filter.UseMvVulnerabilityList { + s = append(s, "COALESCE(MVL.earliest_remediation_date, CAST('9999-12-31 23:59:59' AS DATETIME)) AS issue_earliest_target_remediation_date") + } else { + s = append(s, "COALESCE(MIN(IM.issuematch_target_remediation_date), CAST('9999-12-31 23:59:59' AS DATETIME)) AS issue_earliest_target_remediation_date") + } } } @@ -432,7 +454,7 @@ func (s *SqlDatabase) GetIssuesWithAggregations(ctx context.Context, filter *ent return nil, err } - columns := strings.Join(appendIssueColumns([]string{}, order), ",") + columns := strings.Join(appendIssueColumns([]string{}, filter, order), ",") ord := NewOrder(order, entity.Order{By: entity.IssueId, Direction: entity.OrderDirectionAsc}) whereClause := issueObject.GetFilterWhereClause_tmp(filter, false) @@ -486,11 +508,15 @@ func (s *SqlDatabase) GetIssuesWithAggregations(ctx context.Context, filter *ent issue := gibr.AsIssueWithAggregations() var ivRating int64 + + var earliestTargetRemediation sql.NullTime + if e.IssueVariantRow != nil { ivRating = e.RatingNumerical.Int64 + earliestTargetRemediation = e.EarliestTargetRemediation } - cursor, _ := EncodeCursor(WithIssue(ord.Sequence(), issue.Issue, ivRating)) + cursor, _ := EncodeCursor(WithIssue(ord.Sequence(), issue.Issue, ivRating, earliestTargetRemediation)) sr := entity.IssueResult{ WithCursor: entity.WithCursor{ diff --git a/internal/database/mariadb/issue_test.go b/internal/database/mariadb/issue_test.go index b2e8efd3..911dee20 100644 --- a/internal/database/mariadb/issue_test.go +++ b/internal/database/mariadb/issue_test.go @@ -497,6 +497,7 @@ var _ = Describe("Issue", Label("database", "Issue"), func() { []entity.Order{}, *entries[len(entries)-1].Issue, 0, + sql.NullTime{}, ), ) return after diff --git a/internal/database/mariadb/order.go b/internal/database/mariadb/order.go index bf3a6b1b..237e3966 100644 --- a/internal/database/mariadb/order.go +++ b/internal/database/mariadb/order.go @@ -61,6 +61,8 @@ func ColumnName(f entity.OrderByField) string { return "issuematch_rating" case entity.IssueMatchTargetRemediationDate: return "issuematch_target_remediation_date" + case entity.IssueEarliestTargetRemediationDate: + return "issue_earliest_target_remediation_date" case entity.SupportGroupId: return "supportgroup_id" case entity.SupportGroupCcrn: diff --git a/internal/e2e/vulnerability_query_test.go b/internal/e2e/vulnerability_query_test.go index c55697ed..ee0827d5 100644 --- a/internal/e2e/vulnerability_query_test.go +++ b/internal/e2e/vulnerability_query_test.go @@ -4,6 +4,9 @@ package e2e_test import ( + "database/sql" + "time" + e2e_common "github.com/cloudoperators/heureka/internal/e2e/common" "github.com/cloudoperators/heureka/internal/entity" "github.com/cloudoperators/heureka/internal/util" @@ -206,4 +209,130 @@ var _ = Describe("Getting Vulnerabilities via API", Label("e2e", "Vulnerabilitie ).To(Equal(counts.Total), "Total count is correct") }) }) + + // Regression test for issue #1124: + // When two vulnerabilities share the same rating, the one with the earlier + // earliestTargetRemediationDate must appear first. Within the same rating AND + // the same date, ordering falls back to primaryName ascending. + When("the database has vulnerabilities with equal ratings", func() { + // Seed 5 vulnerabilities with deterministic names, ratings and remediation + // dates so the expected result order is unambiguous: + // + // pos | name | rating | earliestTargetRemediationDate + // ----+---------------+----------+------------------------------ + // 1 | CVE-2020-AAA | Critical | 2020-01-01 + // 2 | CVE-2021-BBB | Critical | 2021-06-15 + // 3 | CVE-2021-CCC | Critical | 2021-06-15 (same date → name tiebreak) + // 4 | CVE-2019-DDD | Medium | 2019-03-01 + // 5 | CVE-2023-EEE | Medium | 2023-09-01 + type vulnFixture struct { + name string + rating string + date time.Time + } + + fixtures := []vulnFixture{ + {"CVE-2020-AAA", entity.SeverityValuesCritical.String(), time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}, + {"CVE-2021-BBB", entity.SeverityValuesCritical.String(), time.Date(2021, 6, 15, 0, 0, 0, 0, time.UTC)}, + {"CVE-2021-CCC", entity.SeverityValuesCritical.String(), time.Date(2021, 6, 15, 0, 0, 0, 0, time.UTC)}, + {"CVE-2019-DDD", entity.SeverityValuesMedium.String(), time.Date(2019, 3, 1, 0, 0, 0, 0, time.UTC)}, + {"CVE-2023-EEE", entity.SeverityValuesMedium.String(), time.Date(2023, 9, 1, 0, 0, 0, 0, time.UTC)}, + } + + BeforeEach(func() { + repos := seeder.SeedIssueRepositories() + Expect(len(repos)).To(BeNumerically(">", 0), "need at least one issue repository") + + components := seeder.SeedComponents(1) + componentVersions := seeder.SeedComponentVersions(1, components) + Expect(len(componentVersions)).To(Equal(1)) + + services := seeder.SeedServices(1) + Expect(len(services)).To(Equal(1)) + + ci := test.NewFakeComponentInstance() + ci.ComponentVersionId = componentVersions[0].Id + ci.ServiceId = services[0].Id + ciId, err := seeder.InsertFakeComponentInstance(ci) + Expect(err).To(BeNil()) + + for _, f := range fixtures { + issue := test.NewFakeIssue() + issue.PrimaryName = sql.NullString{String: f.name, Valid: true} + issue.Type = sql.NullString{String: entity.IssueTypeVulnerability.String(), Valid: true} + issueId, err := seeder.InsertFakeIssue(issue) + Expect(err).To(BeNil()) + + iv := mariadb.IssueVariantRow{ + SecondaryName: sql.NullString{String: f.name, Valid: true}, + Rating: sql.NullString{String: f.rating, Valid: true}, + Vector: sql.NullString{String: "", Valid: true}, + IssueId: sql.NullInt64{Int64: issueId, Valid: true}, + IssueRepositoryId: repos[0].Id, + Description: sql.NullString{String: f.name, Valid: true}, + ExternalUrl: sql.NullString{String: "https://example.com/" + f.name, Valid: true}, + CreatedBy: sql.NullInt64{Int64: util.SystemUserId, Valid: true}, + UpdatedBy: sql.NullInt64{Int64: util.SystemUserId, Valid: true}, + } + _, err = seeder.InsertFakeIssueVariant(iv) + Expect(err).To(BeNil()) + + cvi := mariadb.ComponentVersionIssueRow{ + ComponentVersionId: componentVersions[0].Id, + IssueId: sql.NullInt64{Int64: issueId, Valid: true}, + } + _, err = seeder.InsertFakeComponentVersionIssue(cvi) + Expect(err).To(BeNil()) + + im := test.NewFakeIssueMatch() + im.IssueId = sql.NullInt64{Int64: issueId, Valid: true} + im.ComponentInstanceId = sql.NullInt64{Int64: ciId, Valid: true} + im.Status = sql.NullString{String: entity.IssueMatchStatusValuesNew.String(), Valid: true} + im.Rating = sql.NullString{String: f.rating, Valid: true} + im.Vector = sql.NullString{String: "", Valid: true} + im.UserId = sql.NullInt64{Int64: util.SystemUserId, Valid: true} + im.TargetRemediationDate = sql.NullTime{Time: f.date, Valid: true} + _, err = seeder.InsertFakeIssueMatch(im) + Expect(err).To(BeNil()) + } + + err = seeder.RefreshMvVulnerabilityList() + Expect(err).To(BeNil()) + }) + + It("returns vulnerabilities ordered by rating desc, earliestTargetRemediationDate asc, name asc", func() { + respData, err := e2e_common.ExecuteGqlQueryFromFileWithHeaders[struct { + Vulnerabilities model.VulnerabilityConnection `json:"Vulnerabilities"` + }]( + cfg.Port, + "../api/graphql/graph/queryCollection/vulnerability/query.graphql", + map[string]any{ + "filter": map[string]string{}, + "first": len(fixtures), + "after": "", + }, + nil, + ) + + Expect(err).ToNot(HaveOccurred()) + Expect(len(respData.Vulnerabilities.Edges)).To(Equal(len(fixtures))) + + expectedOrder := []struct { + name string + rating string + }{ + {"CVE-2020-AAA", entity.SeverityValuesCritical.String()}, + {"CVE-2021-BBB", entity.SeverityValuesCritical.String()}, + {"CVE-2021-CCC", entity.SeverityValuesCritical.String()}, + {"CVE-2019-DDD", entity.SeverityValuesMedium.String()}, + {"CVE-2023-EEE", entity.SeverityValuesMedium.String()}, + } + + for i, expected := range expectedOrder { + node := respData.Vulnerabilities.Edges[i].Node + Expect(*node.Name).To(Equal(expected.name), "position %d: name", i+1) + Expect(node.Severity.String()).To(Equal(expected.rating), "position %d: rating", i+1) + } + }) + }) }) diff --git a/internal/entity/order.go b/internal/entity/order.go index 956ed8ed..4d6320f2 100644 --- a/internal/entity/order.go +++ b/internal/entity/order.go @@ -37,6 +37,7 @@ const ( IssueMatchId IssueMatchRating IssueMatchTargetRemediationDate + IssueEarliestTargetRemediationDate CriticalCount HighCount