Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/api/graphql/graph/baseResolver/vulnerability.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 4 additions & 3 deletions internal/app/issue/issue_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package issue_test

import (
"context"
"database/sql"
"errors"
"math"
"strconv"
Expand Down Expand Up @@ -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{
Expand All @@ -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
})
Expand All @@ -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)
Expand Down
4 changes: 0 additions & 4 deletions internal/database/mariadb/autopatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

}
}

Expand Down
8 changes: 7 additions & 1 deletion internal/database/mariadb/cursor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package mariadb

import (
"bytes"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
29 changes: 15 additions & 14 deletions internal/database/mariadb/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 33 additions & 7 deletions internal/database/mariadb/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package mariadb

import (
"context"
"database/sql"
"fmt"
"strings"

Expand Down Expand Up @@ -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
},
Expand All @@ -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")
}
}
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions internal/database/mariadb/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ var _ = Describe("Issue", Label("database", "Issue"), func() {
[]entity.Order{},
*entries[len(entries)-1].Issue,
0,
sql.NullTime{},
),
)
return after
Expand Down
2 changes: 2 additions & 0 deletions internal/database/mariadb/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
129 changes: 129 additions & 0 deletions internal/e2e/vulnerability_query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
})
})
})
1 change: 1 addition & 0 deletions internal/entity/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const (
IssueMatchId
IssueMatchRating
IssueMatchTargetRemediationDate
IssueEarliestTargetRemediationDate

CriticalCount
HighCount
Expand Down
Loading