diff --git a/daemons/daemon.go b/daemons/daemon.go index 6f2b1cd50..cdceade15 100644 --- a/daemons/daemon.go +++ b/daemons/daemon.go @@ -101,4 +101,16 @@ func (runner *DaemonRunner) runDaemons(ctx context.Context) { }); err != nil { slog.Error("could not resolve direct depend ency fixed versions", "err", err) } + + if err := runner.maybeRunAndMark(ctx, "openvex.updateSystemVEXRules", func() error { + return runner.UpdateSystemVEXRulesFromOpenVEXSources(ctx) + }); err != nil { + slog.Error("could not update system vex rules from openvex sources", "err", err) + } + + if err := runner.maybeRunAndMark(ctx, "systemvexrules.applySystemVEXRules", func() error { + return runner.ApplySystemVEXRules(ctx) + }); err != nil { + slog.Error("could not apply system vex rules to assets", "err", err) + } } diff --git a/daemons/openvex_daemon.go b/daemons/openvex_daemon.go new file mode 100644 index 000000000..26c53cfab --- /dev/null +++ b/daemons/openvex_daemon.go @@ -0,0 +1,43 @@ +package daemons + +import ( + "context" + "log/slog" + "os" + "strings" + + "github.com/l3montree-dev/devguard/normalize" +) + +func (runner *DaemonRunner) UpdateSystemVEXRulesFromOpenVEXSources(ctx context.Context) error { + enviromentSources := os.Getenv("OPENVEX_SOURCES") + if enviromentSources == "" { + slog.Info("no OpenVEX sources set in env variables, skipped fetching OpenVEX from static sources") + return nil + } + staticOpenVEXSources := strings.Split(os.Getenv("OPENVEX_SOURCES"), ",") + if len(staticOpenVEXSources) == 0 { + slog.Info("no OpenVEX sources set in env variables, skipped fetching OpenVEX from static sources") + return nil + } + + slog.Info("fetching OpenVEX from static sources") + var results []*normalize.VexReportOpenVEX + for _, source := range staticOpenVEXSources { + reports, err := runner.scanService.FetchOpenVexFromGitHub(ctx, source, "main") + if err != nil { + slog.Error("failed to fetch OpenVEX report from static source", "source", source, "error", err) + continue + } + results = append(results, reports...) + } + + err := runner.vexRuleService.UpdateSystemVEXRulesFromStaticSources(ctx, results) + + if err != nil { + slog.Error("failed to update VEX rules from static sources", "error", err) + return err + } + + return nil +} diff --git a/daemons/providers.go b/daemons/providers.go index 4a3ae012b..708dcb514 100644 --- a/daemons/providers.go +++ b/daemons/providers.go @@ -72,6 +72,7 @@ type DaemonRunner struct { maliciousPackageChecker shared.MaliciousPackageChecker vulnDBImportService shared.VulnDBService vexRuleService shared.VEXRuleService + systemVEXRuleRepository shared.SystemVEXRuleRepository debugOptions DebugOptions fixedVersionResolver shared.FixedVersionResolver @@ -125,6 +126,7 @@ func NewDaemonRunner( vulnDBImportService shared.VulnDBService, vexRuleService shared.VEXRuleService, fixedVersionResolver shared.FixedVersionResolver, + systemVEXRuleRepository shared.SystemVEXRuleRepository, ) *DaemonRunner { return &DaemonRunner{ db: db, @@ -156,6 +158,7 @@ func NewDaemonRunner( vulnDBImportService: vulnDBImportService, vexRuleService: vexRuleService, fixedVersionResolver: fixedVersionResolver, + systemVEXRuleRepository: systemVEXRuleRepository, } } diff --git a/daemons/system_vexrule_daemon.go b/daemons/system_vexrule_daemon.go new file mode 100644 index 000000000..03d16cdce --- /dev/null +++ b/daemons/system_vexrule_daemon.go @@ -0,0 +1,72 @@ +package daemons + +import ( + "context" + "log/slog" + + "github.com/l3montree-dev/devguard/database/models" +) + +func (runner *DaemonRunner) ApplySystemVEXRules(ctx context.Context) error { + tx := runner.systemVEXRuleRepository.GetDB(ctx, nil) + var err error + var assetVersions []models.AssetVersion + var systemVEXRules []models.SystemVEXRule + // Gather all AssetVersions + // Check paranoid mode or settings if auto apply is enabled (can be found in asset) + // Add application setting in asset model + // Change application setting when paranoid mode is changed + assetVersions, err = runner.assetVersionRepository.FindSystemVEXRuleApplicableAssetVersions(ctx, tx) + if err != nil { + slog.Error("failed to fetch assetVersions from database", "error", err) + return err + } + + if len(assetVersions) < 1 { + slog.Info("No assetversions in database yet, skipping SystemVEXRule application") + return nil + } + // Gather all system vexrules + systemVEXRules, err = runner.systemVEXRuleRepository.All(ctx, tx) + if err != nil { + slog.Error("failed to fetch systemVEXRules from database", "error", err) + return err + } + + if len(systemVEXRules) < 1 { + slog.Info("No SystemVEXRules in database yet, skipping SystemVEXRule application") + return nil + } + // Create VexRules from all AssetVersions and System vexrules + var applicableRules []models.VEXRule + for _, assetVersion := range assetVersions { + for _, systemVEXRule := range systemVEXRules { + // This VEXRule is only created temporarily for the execution of the daemon, because + // storing the VEXRule would interfere with the crowdsourced vexing + rule := models.VEXRule{ + AssetID: assetVersion.Asset.ID, + AssetVersionName: assetVersion.Name, + CVEID: systemVEXRule.CVEID, + VexSource: systemVEXRule.VexSource, + Asset: assetVersion.Asset, + CVE: systemVEXRule.CVE, + AssetVersion: assetVersion, + Justification: systemVEXRule.Justification, + EventType: systemVEXRule.EventType, + PathPattern: systemVEXRule.PathPattern, + MechanicalJustification: systemVEXRule.MechanicalJustification, + CreatedByID: "system", + Enabled: true, + } + rule.SetPathPattern(systemVEXRule.PathPattern) + applicableRules = append(applicableRules, rule) + } + } + // ApplyRulesToExistingVulns() + _, err = runner.vexRuleService.ApplyRulesToExistingVulns(ctx, tx, applicableRules) + if err != nil { + slog.Error("failed to apply system VEX rules", "error", err) + return err + } + return nil +} diff --git a/database/migrations/20260601085124_add_system_vex_rules.up.sql b/database/migrations/20260601085124_add_system_vex_rules.up.sql new file mode 100644 index 000000000..edc20dceb --- /dev/null +++ b/database/migrations/20260601085124_add_system_vex_rules.up.sql @@ -0,0 +1,36 @@ +-- Copyright (C) 2026 l3montree GmbH +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as +-- published by the Free Software Foundation, either version 3 of the +-- License, or (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Create system_vex_rules table +CREATE TABLE IF NOT EXISTS public.system_vex_rules ( + id TEXT PRIMARY KEY, + cve_id TEXT NOT NULL, + justification TEXT NOT NULL, + mechanical_justification TEXT, + path_pattern JSONB NOT NULL, + vex_source TEXT NOT NULL DEFAULT '', + event_type TEXT NOT NULL DEFAULT 'falsePositive', + created_by_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_system_vex_rules_cve + FOREIGN KEY (cve_id) + REFERENCES public.cves(cve) + ON DELETE CASCADE +); + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_vex_rule_cve ON public.system_vex_rules(cve_id); +CREATE INDEX IF NOT EXISTS idx_vex_rules_composite ON public.system_vex_rules (cve_id, vex_source); \ No newline at end of file diff --git a/database/models/system_vex_rule_model.go b/database/models/system_vex_rule_model.go new file mode 100644 index 000000000..37a96d3dd --- /dev/null +++ b/database/models/system_vex_rule_model.go @@ -0,0 +1,56 @@ +package models + +import ( + "fmt" + "strings" + "time" + + "github.com/l3montree-dev/devguard/dtos" + "github.com/l3montree-dev/devguard/utils" +) + +type SystemVEXRule struct { + // Single primary key - hash of composite components + ID string `json:"id" gorm:"primaryKey;not null;"` + + // Composite key components (for indexing and queries) + CVEID string `json:"cveId" gorm:"type:text;not null;index:,composite:vex_composite_key"` + VexSource string `json:"vexSource" gorm:"type:text;not null;index:,composite:vex_composite_key"` + + // Timestamps + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + + // Relationships# + CVE *CVE `json:"cve" gorm:"foreignKey:CVEID;references:CVE;"` + + // Rule data + Justification string `json:"justification" gorm:"type:text;not null"` + MechanicalJustification dtos.MechanicalJustificationType `json:"mechanicalJustification" gorm:"type:text;"` + EventType dtos.VulnEventType `json:"eventType" gorm:"type:text;not null;"` + + // PathPattern stores the path patterns for this VEX rule. + // Supports wildcards: "*" matches any element. + PathPattern []string `json:"pathPattern" gorm:"type:jsonb;not null;serializer:json"` + CreatedByID string `json:"createdById" gorm:"type:text;not null"` +} + +// CalculateID computes a SHA256 hash of CVEID, PathPattern, and VexSource for use as the primary key. +// This ensures a deterministic, unique ID for each VEX rule combination. +func CalculateSystemVEXRuleID(cveID string, pathPattern []string, vexSource string) string { + data := fmt.Sprintf("%s/%s/%s", cveID, strings.Join(pathPattern, ","), vexSource) + return utils.HashString(data) +} + +// SetPathPattern sets the PathPattern and recalculates the ID. +func (r *SystemVEXRule) SetPathPattern(pattern []string) { + r.PathPattern = pattern + r.ID = CalculateSystemVEXRuleID(r.CVEID, pattern, r.VexSource) +} + +// EnsureID calculates the ID if it hasn't been set yet. +func (r *SystemVEXRule) EnsureID() { + if r.ID == "" { + r.ID = CalculateSystemVEXRuleID(r.CVEID, r.PathPattern, r.VexSource) + } +} diff --git a/database/repositories/asset_version_repository.go b/database/repositories/asset_version_repository.go index 979805865..a859d339d 100644 --- a/database/repositories/asset_version_repository.go +++ b/database/repositories/asset_version_repository.go @@ -380,3 +380,19 @@ func (repository *assetVersionRepository) GetAmountOfAssetVersionsInOrg(ctx cont `, orgID).Find(&totalAmount).Error return totalAmount, err } + +func (repository *assetVersionRepository) FindSystemVEXRuleApplicableAssetVersions(ctx context.Context, tx *gorm.DB) ([]models.AssetVersion, error) { + var assetVersions []models.AssetVersion + + err := repository.GetDB(ctx, tx). + Model(&models.AssetVersion{}). + Joins("Asset"). + Where( + `"Asset"."paranoid_mode" = ?`, + false, + ). + Preload("Asset"). + Find(&assetVersions).Error + + return assetVersions, err +} diff --git a/database/repositories/cve_relationship_repository.go b/database/repositories/cve_relationship_repository.go index d211c8f10..d31de7ef7 100644 --- a/database/repositories/cve_relationship_repository.go +++ b/database/repositories/cve_relationship_repository.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/utils" "gorm.io/gorm" @@ -27,3 +28,43 @@ func (repository *cveRelationshipRepository) GetRelationshipsByTargetCVEBatch(ct } return relations, nil } + +func (repository *cveRelationshipRepository) FindCrossRelationshipsBatch( + ctx context.Context, + tx *gorm.DB, + associatedCVEIDs []string, +) ([]models.CVERelationship, error) { + + const chunkSize = 10000 + + lowerIDs := utils.ToLowerSlice(associatedCVEIDs) + + var result []models.CVERelationship + + for start := 0; start < len(lowerIDs); start += chunkSize { + end := start + chunkSize + if end > len(lowerIDs) { + end = len(lowerIDs) + } + + chunk := lowerIDs[start:end] + + var relationships []models.CVERelationship + + err := repository.GetDB(ctx, tx). + Where( + "LOWER(target_cve) IN ? OR LOWER(source_cve) IN ?", + chunk, + chunk, + ). + Find(&relationships).Error + + if err != nil { + return nil, err + } + + result = append(result, relationships...) + } + + return result, nil +} diff --git a/database/repositories/dependency_vuln_repository.go b/database/repositories/dependency_vuln_repository.go index 89baaed73..d73b2b48a 100644 --- a/database/repositories/dependency_vuln_repository.go +++ b/database/repositories/dependency_vuln_repository.go @@ -406,6 +406,36 @@ func (repository *dependencyVulnRepository) GetAllOpenVulnsByAssetVersionNameAnd } +func (repository *dependencyVulnRepository) GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch( + ctx context.Context, + tx *gorm.DB, + assetTuples []struct { + AssetID string + AssetVersionName string + }, +) ([]models.DependencyVuln, error) { + var vulns = []models.DependencyVuln{} + + var args []interface{} + var placeholders []string + + for _, key := range assetTuples { + placeholders = append(placeholders, "(?, ?)") + args = append(args, key.AssetID, key.AssetVersionName) + } + + query := fmt.Sprintf( + "(asset_id, asset_version_name) IN (%s)", + strings.Join(placeholders, ","), + ) + + if err := repository.Repository.GetDB(ctx, tx).Preload("CVE").Where(query, args...).Find(&vulns).Error; err != nil { + return nil, err + } + return vulns, nil + +} + // Override the base GetAllVulnsByAssetID method to preload artifacts func (repository *dependencyVulnRepository) GetAllVulnsByAssetID(ctx context.Context, tx *gorm.DB, assetID uuid.UUID) ([]models.DependencyVuln, error) { var vulns = []models.DependencyVuln{} diff --git a/database/repositories/providers.go b/database/repositories/providers.go index 84d540a31..442823b1a 100644 --- a/database/repositories/providers.go +++ b/database/repositories/providers.go @@ -55,6 +55,7 @@ var Module = fx.Options( fx.Provide(fx.Annotate(NewJiraIntegrationRepository, fx.As(new(shared.JiraIntegrationRepository)))), fx.Provide(fx.Annotate(NewCveRelationshipRepository, fx.As(new(shared.CVERelationshipRepository)))), fx.Provide(fx.Annotate(NewVEXRuleRepository, fx.As(new(shared.VEXRuleRepository)))), + fx.Provide(fx.Annotate(NewSystemVEXRuleRepository, fx.As(new(shared.SystemVEXRuleRepository)))), fx.Provide(fx.Annotate(NewExternalReferenceRepository, fx.As(new(shared.ExternalReferenceRepository)))), fx.Provide(fx.Annotate(NewTrustedEntityRepository, fx.As(new(shared.TrustedEntityRepository)))), fx.Provide(fx.Annotate(NewDependencyProxyRepository, fx.As(new(shared.DependencyProxySecretRepository)))), diff --git a/database/repositories/system_vexrule_repository.go b/database/repositories/system_vexrule_repository.go new file mode 100644 index 000000000..d8715621d --- /dev/null +++ b/database/repositories/system_vexrule_repository.go @@ -0,0 +1,63 @@ +package repositories + +import ( + "context" + "strings" + + "github.com/l3montree-dev/devguard/database/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type systemVEXRuleRepository struct { + db *gorm.DB +} + +func NewSystemVEXRuleRepository(db *gorm.DB) *systemVEXRuleRepository { + return &systemVEXRuleRepository{ + db: db, + } +} + +func (r *systemVEXRuleRepository) All(ctx context.Context, tx *gorm.DB) ([]models.SystemVEXRule, error) { + var result []models.SystemVEXRule + + err := r.GetDB(ctx, tx).Preload("CVE.Relationships").Find(&result).Error + return result, err +} + +func (r *systemVEXRuleRepository) GetDB(ctx context.Context, tx *gorm.DB) *gorm.DB { + if tx != nil { + return tx + } + return r.db.WithContext(ctx) +} + +func (r *systemVEXRuleRepository) FindByCVE(ctx context.Context, tx *gorm.DB, cveID string) ([]models.SystemVEXRule, error) { + var rules []models.SystemVEXRule + err := r.GetDB(ctx, tx).Preload("CVE").Where("LOWER(cve_id) = LOWER(?)", cveID).Find(&rules).Error + return rules, err +} + +func (r *systemVEXRuleRepository) FindByCVEBatch(ctx context.Context, tx *gorm.DB, cveIDs []string) ([]models.SystemVEXRule, error) { + var rules []models.SystemVEXRule + var lowercaseCVEs []string + for _, cve := range cveIDs { + lowercaseCVEs = append(lowercaseCVEs, strings.ToLower(cve)) + } + err := r.GetDB(ctx, tx).Preload("CVE").Where("LOWER(cve_id) IN ?", lowercaseCVEs).Find(&rules).Error + return rules, err +} + +func (r *systemVEXRuleRepository) UpsertBatch(ctx context.Context, tx *gorm.DB, rules []models.SystemVEXRule) error { + if len(rules) == 0 { + return nil + } + // Ensure IDs are calculated + for i := range rules { + rules[i].EnsureID() + } + return r.GetDB(ctx, tx).Clauses(clause.OnConflict{ + UpdateAll: true, + }).CreateInBatches(&rules, 1000).Error +} diff --git a/database/repositories/vex_rule_repository.go b/database/repositories/vex_rule_repository.go index b4143e7a9..49a773b16 100644 --- a/database/repositories/vex_rule_repository.go +++ b/database/repositories/vex_rule_repository.go @@ -17,6 +17,7 @@ package repositories import ( "context" + "strings" "github.com/google/uuid" "github.com/l3montree-dev/devguard/database/models" @@ -73,6 +74,16 @@ func (r *vexRuleRepository) FindByAssetVersionAndCVE(ctx context.Context, tx *go return rules, err } +func (r *vexRuleRepository) FindByAssetVersionAndCVEAliases(ctx context.Context, tx *gorm.DB, assetID uuid.UUID, assetVersionName string, cveIDs []string) ([]models.VEXRule, error) { + var rules []models.VEXRule + var lowercaseCVEs []string + for _, cve := range cveIDs { + lowercaseCVEs = append(lowercaseCVEs, strings.ToLower(cve)) + } + err := r.GetDB(ctx, tx).Where("asset_id = ? AND asset_version_name = ? AND LOWER(cve_id) IN ?", assetID, assetVersionName, lowercaseCVEs).Order("created_at DESC").Find(&rules).Error + return rules, err +} + func (r *vexRuleRepository) FindByAssetVersionPaged(ctx context.Context, tx *gorm.DB, assetID uuid.UUID, assetVersionName string, pageInfo shared.PageInfo, search string, filterQuery []shared.FilterQuery, sortQuery []shared.SortQuery) (shared.Paged[models.VEXRule], error) { var rules []models.VEXRule var total int64 diff --git a/mocks/mock_AssetVersionRepository.go b/mocks/mock_AssetVersionRepository.go index 953be143d..6fdd3e6d4 100644 --- a/mocks/mock_AssetVersionRepository.go +++ b/mocks/mock_AssetVersionRepository.go @@ -464,6 +464,74 @@ func (_c *AssetVersionRepository_FindOrCreate_Call) RunAndReturn(run func(ctx co return _c } +// FindSystemVEXRuleApplicableAssetVersions provides a mock function for the type AssetVersionRepository +func (_mock *AssetVersionRepository) FindSystemVEXRuleApplicableAssetVersions(ctx context.Context, tx shared.DB) ([]models.AssetVersion, error) { + ret := _mock.Called(ctx, tx) + + if len(ret) == 0 { + panic("no return value specified for FindSystemVEXRuleApplicableAssetVersions") + } + + var r0 []models.AssetVersion + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB) ([]models.AssetVersion, error)); ok { + return returnFunc(ctx, tx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB) []models.AssetVersion); ok { + r0 = returnFunc(ctx, tx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.AssetVersion) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB) error); ok { + r1 = returnFunc(ctx, tx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindSystemVEXRuleApplicableAssetVersions' +type AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call struct { + *mock.Call +} + +// FindSystemVEXRuleApplicableAssetVersions is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +func (_e *AssetVersionRepository_Expecter) FindSystemVEXRuleApplicableAssetVersions(ctx interface{}, tx interface{}) *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call { + return &AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call{Call: _e.mock.On("FindSystemVEXRuleApplicableAssetVersions", ctx, tx)} +} + +func (_c *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call) Run(run func(ctx context.Context, tx shared.DB)) *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call) Return(assetVersions []models.AssetVersion, err error) *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call { + _c.Call.Return(assetVersions, err) + return _c +} + +func (_c *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB) ([]models.AssetVersion, error)) *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call { + _c.Call.Return(run) + return _c +} + // GetAllTagsAndDefaultBranchForAsset provides a mock function for the type AssetVersionRepository func (_mock *AssetVersionRepository) GetAllTagsAndDefaultBranchForAsset(ctx context.Context, tx shared.DB, assetID uuid.UUID) ([]models.AssetVersion, error) { ret := _mock.Called(ctx, tx, assetID) diff --git a/mocks/mock_CVERelationshipRepository.go b/mocks/mock_CVERelationshipRepository.go index 33fe1720c..68917f629 100644 --- a/mocks/mock_CVERelationshipRepository.go +++ b/mocks/mock_CVERelationshipRepository.go @@ -527,6 +527,80 @@ func (_c *CVERelationshipRepository_DeleteBatch_Call) RunAndReturn(run func(ctx return _c } +// FindCrossRelationshipsBatch provides a mock function for the type CVERelationshipRepository +func (_mock *CVERelationshipRepository) FindCrossRelationshipsBatch(ctx context.Context, tx shared.DB, assiciatedCVEIDs []string) ([]models.CVERelationship, error) { + ret := _mock.Called(ctx, tx, assiciatedCVEIDs) + + if len(ret) == 0 { + panic("no return value specified for FindCrossRelationshipsBatch") + } + + var r0 []models.CVERelationship + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) ([]models.CVERelationship, error)); ok { + return returnFunc(ctx, tx, assiciatedCVEIDs) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) []models.CVERelationship); ok { + r0 = returnFunc(ctx, tx, assiciatedCVEIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CVERelationship) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, []string) error); ok { + r1 = returnFunc(ctx, tx, assiciatedCVEIDs) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// CVERelationshipRepository_FindCrossRelationshipsBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindCrossRelationshipsBatch' +type CVERelationshipRepository_FindCrossRelationshipsBatch_Call struct { + *mock.Call +} + +// FindCrossRelationshipsBatch is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - assiciatedCVEIDs []string +func (_e *CVERelationshipRepository_Expecter) FindCrossRelationshipsBatch(ctx interface{}, tx interface{}, assiciatedCVEIDs interface{}) *CVERelationshipRepository_FindCrossRelationshipsBatch_Call { + return &CVERelationshipRepository_FindCrossRelationshipsBatch_Call{Call: _e.mock.On("FindCrossRelationshipsBatch", ctx, tx, assiciatedCVEIDs)} +} + +func (_c *CVERelationshipRepository_FindCrossRelationshipsBatch_Call) Run(run func(ctx context.Context, tx shared.DB, assiciatedCVEIDs []string)) *CVERelationshipRepository_FindCrossRelationshipsBatch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []string + if args[2] != nil { + arg2 = args[2].([]string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *CVERelationshipRepository_FindCrossRelationshipsBatch_Call) Return(cVERelationships []models.CVERelationship, err error) *CVERelationshipRepository_FindCrossRelationshipsBatch_Call { + _c.Call.Return(cVERelationships, err) + return _c +} + +func (_c *CVERelationshipRepository_FindCrossRelationshipsBatch_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, assiciatedCVEIDs []string) ([]models.CVERelationship, error)) *CVERelationshipRepository_FindCrossRelationshipsBatch_Call { + _c.Call.Return(run) + return _c +} + // GetDB provides a mock function for the type CVERelationshipRepository func (_mock *CVERelationshipRepository) GetDB(ctx context.Context, tx shared.DB) shared.DB { ret := _mock.Called(ctx, tx) diff --git a/mocks/mock_CVERelationshipService.go b/mocks/mock_CVERelationshipService.go new file mode 100644 index 000000000..888769d00 --- /dev/null +++ b/mocks/mock_CVERelationshipService.go @@ -0,0 +1,176 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/l3montree-dev/devguard/shared" + mock "github.com/stretchr/testify/mock" +) + +// NewCVERelationshipService creates a new instance of CVERelationshipService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewCVERelationshipService(t interface { + mock.TestingT + Cleanup(func()) +}) *CVERelationshipService { + mock := &CVERelationshipService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// CVERelationshipService is an autogenerated mock type for the CVERelationshipService type +type CVERelationshipService struct { + mock.Mock +} + +type CVERelationshipService_Expecter struct { + mock *mock.Mock +} + +func (_m *CVERelationshipService) EXPECT() *CVERelationshipService_Expecter { + return &CVERelationshipService_Expecter{mock: &_m.Mock} +} + +// CreateAliasRelationshipMapBatch provides a mock function for the type CVERelationshipService +func (_mock *CVERelationshipService) CreateAliasRelationshipMapBatch(ctx context.Context, tx shared.DB, cveIDs []string) (map[string]map[string]struct{}, error) { + ret := _mock.Called(ctx, tx, cveIDs) + + if len(ret) == 0 { + panic("no return value specified for CreateAliasRelationshipMapBatch") + } + + var r0 map[string]map[string]struct{} + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) (map[string]map[string]struct{}, error)); ok { + return returnFunc(ctx, tx, cveIDs) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) map[string]map[string]struct{}); ok { + r0 = returnFunc(ctx, tx, cveIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]map[string]struct{}) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, []string) error); ok { + r1 = returnFunc(ctx, tx, cveIDs) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// CVERelationshipService_CreateAliasRelationshipMapBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateAliasRelationshipMapBatch' +type CVERelationshipService_CreateAliasRelationshipMapBatch_Call struct { + *mock.Call +} + +// CreateAliasRelationshipMapBatch is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - cveIDs []string +func (_e *CVERelationshipService_Expecter) CreateAliasRelationshipMapBatch(ctx interface{}, tx interface{}, cveIDs interface{}) *CVERelationshipService_CreateAliasRelationshipMapBatch_Call { + return &CVERelationshipService_CreateAliasRelationshipMapBatch_Call{Call: _e.mock.On("CreateAliasRelationshipMapBatch", ctx, tx, cveIDs)} +} + +func (_c *CVERelationshipService_CreateAliasRelationshipMapBatch_Call) Run(run func(ctx context.Context, tx shared.DB, cveIDs []string)) *CVERelationshipService_CreateAliasRelationshipMapBatch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []string + if args[2] != nil { + arg2 = args[2].([]string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *CVERelationshipService_CreateAliasRelationshipMapBatch_Call) Return(stringToStringToVal map[string]map[string]struct{}, err error) *CVERelationshipService_CreateAliasRelationshipMapBatch_Call { + _c.Call.Return(stringToStringToVal, err) + return _c +} + +func (_c *CVERelationshipService_CreateAliasRelationshipMapBatch_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, cveIDs []string) (map[string]map[string]struct{}, error)) *CVERelationshipService_CreateAliasRelationshipMapBatch_Call { + _c.Call.Return(run) + return _c +} + +// IsAlias provides a mock function for the type CVERelationshipService +func (_mock *CVERelationshipService) IsAlias(cveSource string, cveTarget string, cveMap map[string]map[string]struct{}) bool { + ret := _mock.Called(cveSource, cveTarget, cveMap) + + if len(ret) == 0 { + panic("no return value specified for IsAlias") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func(string, string, map[string]map[string]struct{}) bool); ok { + r0 = returnFunc(cveSource, cveTarget, cveMap) + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// CVERelationshipService_IsAlias_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAlias' +type CVERelationshipService_IsAlias_Call struct { + *mock.Call +} + +// IsAlias is a helper method to define mock.On call +// - cveSource string +// - cveTarget string +// - cveMap map[string]map[string]struct{} +func (_e *CVERelationshipService_Expecter) IsAlias(cveSource interface{}, cveTarget interface{}, cveMap interface{}) *CVERelationshipService_IsAlias_Call { + return &CVERelationshipService_IsAlias_Call{Call: _e.mock.On("IsAlias", cveSource, cveTarget, cveMap)} +} + +func (_c *CVERelationshipService_IsAlias_Call) Run(run func(cveSource string, cveTarget string, cveMap map[string]map[string]struct{})) *CVERelationshipService_IsAlias_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 map[string]map[string]struct{} + if args[2] != nil { + arg2 = args[2].(map[string]map[string]struct{}) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *CVERelationshipService_IsAlias_Call) Return(b bool) *CVERelationshipService_IsAlias_Call { + _c.Call.Return(b) + return _c +} + +func (_c *CVERelationshipService_IsAlias_Call) RunAndReturn(run func(cveSource string, cveTarget string, cveMap map[string]map[string]struct{}) bool) *CVERelationshipService_IsAlias_Call { + _c.Call.Return(run) + return _c +} diff --git a/mocks/mock_DependencyVulnRepository.go b/mocks/mock_DependencyVulnRepository.go index eaff948aa..3fe81a36d 100644 --- a/mocks/mock_DependencyVulnRepository.go +++ b/mocks/mock_DependencyVulnRepository.go @@ -857,6 +857,104 @@ func (_c *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetID_C return _c } +// GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch provides a mock function for the type DependencyVulnRepository +func (_mock *DependencyVulnRepository) GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch(ctx context.Context, tx shared.DB, assetTuples []struct { + AssetID string + AssetVersionName string +}) ([]models.DependencyVuln, error) { + ret := _mock.Called(ctx, tx, assetTuples) + + if len(ret) == 0 { + panic("no return value specified for GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch") + } + + var r0 []models.DependencyVuln + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []struct { + AssetID string + AssetVersionName string + }) ([]models.DependencyVuln, error)); ok { + return returnFunc(ctx, tx, assetTuples) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []struct { + AssetID string + AssetVersionName string + }) []models.DependencyVuln); ok { + r0 = returnFunc(ctx, tx, assetTuples) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.DependencyVuln) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, []struct { + AssetID string + AssetVersionName string + }) error); ok { + r1 = returnFunc(ctx, tx, assetTuples) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch' +type DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call struct { + *mock.Call +} + +// GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - assetTuples []struct{AssetID string; AssetVersionName string} +func (_e *DependencyVulnRepository_Expecter) GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch(ctx interface{}, tx interface{}, assetTuples interface{}) *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call { + return &DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call{Call: _e.mock.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", ctx, tx, assetTuples)} +} + +func (_c *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call) Run(run func(ctx context.Context, tx shared.DB, assetTuples []struct { + AssetID string + AssetVersionName string +})) *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []struct { + AssetID string + AssetVersionName string + } + if args[2] != nil { + arg2 = args[2].([]struct { + AssetID string + AssetVersionName string + }) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call) Return(dependencyVulns []models.DependencyVuln, err error) *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call { + _c.Call.Return(dependencyVulns, err) + return _c +} + +func (_c *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, assetTuples []struct { + AssetID string + AssetVersionName string +}) ([]models.DependencyVuln, error)) *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call { + _c.Call.Return(run) + return _c +} + // GetAllVulnsByArtifact provides a mock function for the type DependencyVulnRepository func (_mock *DependencyVulnRepository) GetAllVulnsByArtifact(ctx context.Context, tx shared.DB, artifact models.Artifact) ([]models.DependencyVuln, error) { ret := _mock.Called(ctx, tx, artifact) diff --git a/mocks/mock_ScanService.go b/mocks/mock_ScanService.go index 3bfb54272..ea10dae34 100644 --- a/mocks/mock_ScanService.go +++ b/mocks/mock_ScanService.go @@ -43,6 +43,80 @@ func (_m *ScanService) EXPECT() *ScanService_Expecter { return &ScanService_Expecter{mock: &_m.Mock} } +// FetchOpenVexFromGitHub provides a mock function for the type ScanService +func (_mock *ScanService) FetchOpenVexFromGitHub(ctx context.Context, targetURL string, targetBranch string) ([]*normalize.VexReportOpenVEX, error) { + ret := _mock.Called(ctx, targetURL, targetBranch) + + if len(ret) == 0 { + panic("no return value specified for FetchOpenVexFromGitHub") + } + + var r0 []*normalize.VexReportOpenVEX + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]*normalize.VexReportOpenVEX, error)); ok { + return returnFunc(ctx, targetURL, targetBranch) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []*normalize.VexReportOpenVEX); ok { + r0 = returnFunc(ctx, targetURL, targetBranch) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*normalize.VexReportOpenVEX) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, targetURL, targetBranch) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ScanService_FetchOpenVexFromGitHub_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FetchOpenVexFromGitHub' +type ScanService_FetchOpenVexFromGitHub_Call struct { + *mock.Call +} + +// FetchOpenVexFromGitHub is a helper method to define mock.On call +// - ctx context.Context +// - targetURL string +// - targetBranch string +func (_e *ScanService_Expecter) FetchOpenVexFromGitHub(ctx interface{}, targetURL interface{}, targetBranch interface{}) *ScanService_FetchOpenVexFromGitHub_Call { + return &ScanService_FetchOpenVexFromGitHub_Call{Call: _e.mock.On("FetchOpenVexFromGitHub", ctx, targetURL, targetBranch)} +} + +func (_c *ScanService_FetchOpenVexFromGitHub_Call) Run(run func(ctx context.Context, targetURL string, targetBranch string)) *ScanService_FetchOpenVexFromGitHub_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ScanService_FetchOpenVexFromGitHub_Call) Return(vexReports []*normalize.VexReportOpenVEX, err error) *ScanService_FetchOpenVexFromGitHub_Call { + _c.Call.Return(vexReports, err) + return _c +} + +func (_c *ScanService_FetchOpenVexFromGitHub_Call) RunAndReturn(run func(ctx context.Context, targetURL string, targetBranch string) ([]*normalize.VexReportOpenVEX, error)) *ScanService_FetchOpenVexFromGitHub_Call { + _c.Call.Return(run) + return _c +} + // FetchSbomsFromUpstream provides a mock function for the type ScanService func (_mock *ScanService) FetchSbomsFromUpstream(ctx context.Context, artifactName string, ref string, upstreamURLs []string, keepOriginalSbomRootComponent bool) ([]*normalize.SBOMGraph, []string, []dtos.ExternalReferenceError) { ret := _mock.Called(ctx, artifactName, ref, upstreamURLs, keepOriginalSbomRootComponent) diff --git a/mocks/mock_SystemVEXRuleRepository.go b/mocks/mock_SystemVEXRuleRepository.go new file mode 100644 index 000000000..c87de933d --- /dev/null +++ b/mocks/mock_SystemVEXRuleRepository.go @@ -0,0 +1,378 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/l3montree-dev/devguard/database/models" + "github.com/l3montree-dev/devguard/shared" + mock "github.com/stretchr/testify/mock" +) + +// NewSystemVEXRuleRepository creates a new instance of SystemVEXRuleRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSystemVEXRuleRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *SystemVEXRuleRepository { + mock := &SystemVEXRuleRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// SystemVEXRuleRepository is an autogenerated mock type for the SystemVEXRuleRepository type +type SystemVEXRuleRepository struct { + mock.Mock +} + +type SystemVEXRuleRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *SystemVEXRuleRepository) EXPECT() *SystemVEXRuleRepository_Expecter { + return &SystemVEXRuleRepository_Expecter{mock: &_m.Mock} +} + +// All provides a mock function for the type SystemVEXRuleRepository +func (_mock *SystemVEXRuleRepository) All(ctx context.Context, tx shared.DB) ([]models.SystemVEXRule, error) { + ret := _mock.Called(ctx, tx) + + if len(ret) == 0 { + panic("no return value specified for All") + } + + var r0 []models.SystemVEXRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB) ([]models.SystemVEXRule, error)); ok { + return returnFunc(ctx, tx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB) []models.SystemVEXRule); ok { + r0 = returnFunc(ctx, tx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.SystemVEXRule) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB) error); ok { + r1 = returnFunc(ctx, tx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SystemVEXRuleRepository_All_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'All' +type SystemVEXRuleRepository_All_Call struct { + *mock.Call +} + +// All is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +func (_e *SystemVEXRuleRepository_Expecter) All(ctx interface{}, tx interface{}) *SystemVEXRuleRepository_All_Call { + return &SystemVEXRuleRepository_All_Call{Call: _e.mock.On("All", ctx, tx)} +} + +func (_c *SystemVEXRuleRepository_All_Call) Run(run func(ctx context.Context, tx shared.DB)) *SystemVEXRuleRepository_All_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *SystemVEXRuleRepository_All_Call) Return(systemVEXRules []models.SystemVEXRule, err error) *SystemVEXRuleRepository_All_Call { + _c.Call.Return(systemVEXRules, err) + return _c +} + +func (_c *SystemVEXRuleRepository_All_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB) ([]models.SystemVEXRule, error)) *SystemVEXRuleRepository_All_Call { + _c.Call.Return(run) + return _c +} + +// FindByCVE provides a mock function for the type SystemVEXRuleRepository +func (_mock *SystemVEXRuleRepository) FindByCVE(ctx context.Context, tx shared.DB, cveID string) ([]models.SystemVEXRule, error) { + ret := _mock.Called(ctx, tx, cveID) + + if len(ret) == 0 { + panic("no return value specified for FindByCVE") + } + + var r0 []models.SystemVEXRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, string) ([]models.SystemVEXRule, error)); ok { + return returnFunc(ctx, tx, cveID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, string) []models.SystemVEXRule); ok { + r0 = returnFunc(ctx, tx, cveID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.SystemVEXRule) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, string) error); ok { + r1 = returnFunc(ctx, tx, cveID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SystemVEXRuleRepository_FindByCVE_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByCVE' +type SystemVEXRuleRepository_FindByCVE_Call struct { + *mock.Call +} + +// FindByCVE is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - cveID string +func (_e *SystemVEXRuleRepository_Expecter) FindByCVE(ctx interface{}, tx interface{}, cveID interface{}) *SystemVEXRuleRepository_FindByCVE_Call { + return &SystemVEXRuleRepository_FindByCVE_Call{Call: _e.mock.On("FindByCVE", ctx, tx, cveID)} +} + +func (_c *SystemVEXRuleRepository_FindByCVE_Call) Run(run func(ctx context.Context, tx shared.DB, cveID string)) *SystemVEXRuleRepository_FindByCVE_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *SystemVEXRuleRepository_FindByCVE_Call) Return(systemVEXRules []models.SystemVEXRule, err error) *SystemVEXRuleRepository_FindByCVE_Call { + _c.Call.Return(systemVEXRules, err) + return _c +} + +func (_c *SystemVEXRuleRepository_FindByCVE_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, cveID string) ([]models.SystemVEXRule, error)) *SystemVEXRuleRepository_FindByCVE_Call { + _c.Call.Return(run) + return _c +} + +// FindByCVEBatch provides a mock function for the type SystemVEXRuleRepository +func (_mock *SystemVEXRuleRepository) FindByCVEBatch(ctx context.Context, tx shared.DB, cveIDs []string) ([]models.SystemVEXRule, error) { + ret := _mock.Called(ctx, tx, cveIDs) + + if len(ret) == 0 { + panic("no return value specified for FindByCVEBatch") + } + + var r0 []models.SystemVEXRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) ([]models.SystemVEXRule, error)); ok { + return returnFunc(ctx, tx, cveIDs) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) []models.SystemVEXRule); ok { + r0 = returnFunc(ctx, tx, cveIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.SystemVEXRule) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, []string) error); ok { + r1 = returnFunc(ctx, tx, cveIDs) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SystemVEXRuleRepository_FindByCVEBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByCVEBatch' +type SystemVEXRuleRepository_FindByCVEBatch_Call struct { + *mock.Call +} + +// FindByCVEBatch is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - cveIDs []string +func (_e *SystemVEXRuleRepository_Expecter) FindByCVEBatch(ctx interface{}, tx interface{}, cveIDs interface{}) *SystemVEXRuleRepository_FindByCVEBatch_Call { + return &SystemVEXRuleRepository_FindByCVEBatch_Call{Call: _e.mock.On("FindByCVEBatch", ctx, tx, cveIDs)} +} + +func (_c *SystemVEXRuleRepository_FindByCVEBatch_Call) Run(run func(ctx context.Context, tx shared.DB, cveIDs []string)) *SystemVEXRuleRepository_FindByCVEBatch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []string + if args[2] != nil { + arg2 = args[2].([]string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *SystemVEXRuleRepository_FindByCVEBatch_Call) Return(systemVEXRules []models.SystemVEXRule, err error) *SystemVEXRuleRepository_FindByCVEBatch_Call { + _c.Call.Return(systemVEXRules, err) + return _c +} + +func (_c *SystemVEXRuleRepository_FindByCVEBatch_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, cveIDs []string) ([]models.SystemVEXRule, error)) *SystemVEXRuleRepository_FindByCVEBatch_Call { + _c.Call.Return(run) + return _c +} + +// GetDB provides a mock function for the type SystemVEXRuleRepository +func (_mock *SystemVEXRuleRepository) GetDB(ctx context.Context, db shared.DB) shared.DB { + ret := _mock.Called(ctx, db) + + if len(ret) == 0 { + panic("no return value specified for GetDB") + } + + var r0 shared.DB + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB) shared.DB); ok { + r0 = returnFunc(ctx, db) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(shared.DB) + } + } + return r0 +} + +// SystemVEXRuleRepository_GetDB_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDB' +type SystemVEXRuleRepository_GetDB_Call struct { + *mock.Call +} + +// GetDB is a helper method to define mock.On call +// - ctx context.Context +// - db shared.DB +func (_e *SystemVEXRuleRepository_Expecter) GetDB(ctx interface{}, db interface{}) *SystemVEXRuleRepository_GetDB_Call { + return &SystemVEXRuleRepository_GetDB_Call{Call: _e.mock.On("GetDB", ctx, db)} +} + +func (_c *SystemVEXRuleRepository_GetDB_Call) Run(run func(ctx context.Context, db shared.DB)) *SystemVEXRuleRepository_GetDB_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *SystemVEXRuleRepository_GetDB_Call) Return(v shared.DB) *SystemVEXRuleRepository_GetDB_Call { + _c.Call.Return(v) + return _c +} + +func (_c *SystemVEXRuleRepository_GetDB_Call) RunAndReturn(run func(ctx context.Context, db shared.DB) shared.DB) *SystemVEXRuleRepository_GetDB_Call { + _c.Call.Return(run) + return _c +} + +// UpsertBatch provides a mock function for the type SystemVEXRuleRepository +func (_mock *SystemVEXRuleRepository) UpsertBatch(ctx context.Context, tx shared.DB, rules []models.SystemVEXRule) error { + ret := _mock.Called(ctx, tx, rules) + + if len(ret) == 0 { + panic("no return value specified for UpsertBatch") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []models.SystemVEXRule) error); ok { + r0 = returnFunc(ctx, tx, rules) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// SystemVEXRuleRepository_UpsertBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertBatch' +type SystemVEXRuleRepository_UpsertBatch_Call struct { + *mock.Call +} + +// UpsertBatch is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - rules []models.SystemVEXRule +func (_e *SystemVEXRuleRepository_Expecter) UpsertBatch(ctx interface{}, tx interface{}, rules interface{}) *SystemVEXRuleRepository_UpsertBatch_Call { + return &SystemVEXRuleRepository_UpsertBatch_Call{Call: _e.mock.On("UpsertBatch", ctx, tx, rules)} +} + +func (_c *SystemVEXRuleRepository_UpsertBatch_Call) Run(run func(ctx context.Context, tx shared.DB, rules []models.SystemVEXRule)) *SystemVEXRuleRepository_UpsertBatch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []models.SystemVEXRule + if args[2] != nil { + arg2 = args[2].([]models.SystemVEXRule) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *SystemVEXRuleRepository_UpsertBatch_Call) Return(err error) *SystemVEXRuleRepository_UpsertBatch_Call { + _c.Call.Return(err) + return _c +} + +func (_c *SystemVEXRuleRepository_UpsertBatch_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, rules []models.SystemVEXRule) error) *SystemVEXRuleRepository_UpsertBatch_Call { + _c.Call.Return(run) + return _c +} diff --git a/mocks/mock_VEXRuleRepository.go b/mocks/mock_VEXRuleRepository.go index 8ece0430b..347421623 100644 --- a/mocks/mock_VEXRuleRepository.go +++ b/mocks/mock_VEXRuleRepository.go @@ -665,6 +665,92 @@ func (_c *VEXRuleRepository_FindByAssetVersionAndCVE_Call) RunAndReturn(run func return _c } +// FindByAssetVersionAndCVEAliases provides a mock function for the type VEXRuleRepository +func (_mock *VEXRuleRepository) FindByAssetVersionAndCVEAliases(ctx context.Context, tx shared.DB, assetID uuid.UUID, assetVersionName string, cveIDs []string) ([]models.VEXRule, error) { + ret := _mock.Called(ctx, tx, assetID, assetVersionName, cveIDs) + + if len(ret) == 0 { + panic("no return value specified for FindByAssetVersionAndCVEAliases") + } + + var r0 []models.VEXRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, uuid.UUID, string, []string) ([]models.VEXRule, error)); ok { + return returnFunc(ctx, tx, assetID, assetVersionName, cveIDs) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, uuid.UUID, string, []string) []models.VEXRule); ok { + r0 = returnFunc(ctx, tx, assetID, assetVersionName, cveIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.VEXRule) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, uuid.UUID, string, []string) error); ok { + r1 = returnFunc(ctx, tx, assetID, assetVersionName, cveIDs) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByAssetVersionAndCVEAliases' +type VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call struct { + *mock.Call +} + +// FindByAssetVersionAndCVEAliases is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - assetID uuid.UUID +// - assetVersionName string +// - cveIDs []string +func (_e *VEXRuleRepository_Expecter) FindByAssetVersionAndCVEAliases(ctx interface{}, tx interface{}, assetID interface{}, assetVersionName interface{}, cveIDs interface{}) *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call { + return &VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call{Call: _e.mock.On("FindByAssetVersionAndCVEAliases", ctx, tx, assetID, assetVersionName, cveIDs)} +} + +func (_c *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call) Run(run func(ctx context.Context, tx shared.DB, assetID uuid.UUID, assetVersionName string, cveIDs []string)) *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 uuid.UUID + if args[2] != nil { + arg2 = args[2].(uuid.UUID) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 []string + if args[4] != nil { + arg4 = args[4].([]string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call) Return(vEXRules []models.VEXRule, err error) *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call { + _c.Call.Return(vEXRules, err) + return _c +} + +func (_c *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, assetID uuid.UUID, assetVersionName string, cveIDs []string) ([]models.VEXRule, error)) *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call { + _c.Call.Return(run) + return _c +} + // FindByAssetVersionPaged provides a mock function for the type VEXRuleRepository func (_mock *VEXRuleRepository) FindByAssetVersionPaged(ctx context.Context, tx shared.DB, assetID uuid.UUID, assetVersionName string, pageInfo shared.PageInfo, search string, filterQuery []shared.FilterQuery, sortQuery []shared.SortQuery) (shared.Paged[models.VEXRule], error) { ret := _mock.Called(ctx, tx, assetID, assetVersionName, pageInfo, search, filterQuery, sortQuery) diff --git a/mocks/mock_VEXRuleService.go b/mocks/mock_VEXRuleService.go index 5133e4fa8..5b7ffd6d2 100644 --- a/mocks/mock_VEXRuleService.go +++ b/mocks/mock_VEXRuleService.go @@ -1319,6 +1319,77 @@ func (_c *VEXRuleService_IngestVexes_Call) RunAndReturn(run func(ctx context.Con return _c } +// MatchRulesToVulns provides a mock function for the type VEXRuleService +func (_mock *VEXRuleService) MatchRulesToVulns(ctx context.Context, tx shared.DB, rules []models.VEXRule, vulns []models.DependencyVuln) map[string][]models.DependencyVuln { + ret := _mock.Called(ctx, tx, rules, vulns) + + if len(ret) == 0 { + panic("no return value specified for MatchRulesToVulns") + } + + var r0 map[string][]models.DependencyVuln + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []models.VEXRule, []models.DependencyVuln) map[string][]models.DependencyVuln); ok { + r0 = returnFunc(ctx, tx, rules, vulns) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string][]models.DependencyVuln) + } + } + return r0 +} + +// VEXRuleService_MatchRulesToVulns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MatchRulesToVulns' +type VEXRuleService_MatchRulesToVulns_Call struct { + *mock.Call +} + +// MatchRulesToVulns is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - rules []models.VEXRule +// - vulns []models.DependencyVuln +func (_e *VEXRuleService_Expecter) MatchRulesToVulns(ctx interface{}, tx interface{}, rules interface{}, vulns interface{}) *VEXRuleService_MatchRulesToVulns_Call { + return &VEXRuleService_MatchRulesToVulns_Call{Call: _e.mock.On("MatchRulesToVulns", ctx, tx, rules, vulns)} +} + +func (_c *VEXRuleService_MatchRulesToVulns_Call) Run(run func(ctx context.Context, tx shared.DB, rules []models.VEXRule, vulns []models.DependencyVuln)) *VEXRuleService_MatchRulesToVulns_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []models.VEXRule + if args[2] != nil { + arg2 = args[2].([]models.VEXRule) + } + var arg3 []models.DependencyVuln + if args[3] != nil { + arg3 = args[3].([]models.DependencyVuln) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *VEXRuleService_MatchRulesToVulns_Call) Return(stringToDependencyVulns map[string][]models.DependencyVuln) *VEXRuleService_MatchRulesToVulns_Call { + _c.Call.Return(stringToDependencyVulns) + return _c +} + +func (_c *VEXRuleService_MatchRulesToVulns_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, rules []models.VEXRule, vulns []models.DependencyVuln) map[string][]models.DependencyVuln) *VEXRuleService_MatchRulesToVulns_Call { + _c.Call.Return(run) + return _c +} + // Update provides a mock function for the type VEXRuleService func (_mock *VEXRuleService) Update(ctx context.Context, tx shared.DB, rule *models.VEXRule) error { ret := _mock.Called(ctx, tx, rule) @@ -1381,3 +1452,60 @@ func (_c *VEXRuleService_Update_Call) RunAndReturn(run func(ctx context.Context, _c.Call.Return(run) return _c } + +// UpdateSystemVEXRulesFromStaticSources provides a mock function for the type VEXRuleService +func (_mock *VEXRuleService) UpdateSystemVEXRulesFromStaticSources(ctx context.Context, reports []*normalize.VexReportOpenVEX) error { + ret := _mock.Called(ctx, reports) + + if len(ret) == 0 { + panic("no return value specified for UpdateSystemVEXRulesFromStaticSources") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []*normalize.VexReportOpenVEX) error); ok { + r0 = returnFunc(ctx, reports) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateSystemVEXRulesFromStaticSources' +type VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call struct { + *mock.Call +} + +// UpdateSystemVEXRulesFromStaticSources is a helper method to define mock.On call +// - ctx context.Context +// - reports []*normalize.VexReportOpenVEX +func (_e *VEXRuleService_Expecter) UpdateSystemVEXRulesFromStaticSources(ctx interface{}, reports interface{}) *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call { + return &VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call{Call: _e.mock.On("UpdateSystemVEXRulesFromStaticSources", ctx, reports)} +} + +func (_c *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call) Run(run func(ctx context.Context, reports []*normalize.VexReportOpenVEX)) *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []*normalize.VexReportOpenVEX + if args[1] != nil { + arg1 = args[1].([]*normalize.VexReportOpenVEX) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call) Return(err error) *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call { + _c.Call.Return(err) + return _c +} + +func (_c *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call) RunAndReturn(run func(ctx context.Context, reports []*normalize.VexReportOpenVEX) error) *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call { + _c.Call.Return(run) + return _c +} diff --git a/normalize/sbom_graph.go b/normalize/sbom_graph.go index 59288291c..1a7a0e398 100644 --- a/normalize/sbom_graph.go +++ b/normalize/sbom_graph.go @@ -27,6 +27,7 @@ import ( cdx "github.com/CycloneDX/cyclonedx-go" "github.com/google/uuid" + ov "github.com/openvex/go-vex/pkg/vex" "github.com/package-url/packageurl-go" ) @@ -141,6 +142,41 @@ func NewVexReport(report *cdx.BOM, source string) (*VexReport, error) { }, nil } +type VexReportOpenVEX struct { + Report *ov.VEX + Source string +} + +func validateVexReportOpenVEX(report *ov.VEX) error { + if report.ID == "" { + return fmt.Errorf("invalid OpenVEX report: missing id") + } + if report.Context == "" { + return fmt.Errorf("invalid OpenVEX report: missing context") + } + if report.Author == "" { + return fmt.Errorf("invalid OpenVEX report: missing author") + } + if report.Timestamp == nil || report.Timestamp.IsZero() { + return fmt.Errorf("invalid OpenVEX report: missing timestamp") + } + if report.Version == 0 { + return fmt.Errorf("invalid OpenVEX report: missing version") + } + return nil +} + +func NewVexReportOpenVEX(report *ov.VEX, source string) (*VexReportOpenVEX, error) { + if err := validateVexReportOpenVEX(report); err != nil { + return nil, err + } + + return &VexReportOpenVEX{ + Report: report, + Source: source, + }, nil +} + func edgesToDepMap(edges map[string]map[string]struct{}) map[string][]string { depMap := make(map[string][]string) for parent, children := range edges { diff --git a/services/crowdsourced_vexing_service.go b/services/crowdsourced_vexing_service.go index dc701d271..cfc82d370 100644 --- a/services/crowdsourced_vexing_service.go +++ b/services/crowdsourced_vexing_service.go @@ -2,22 +2,27 @@ package services import ( "fmt" + "log/slog" "github.com/google/uuid" "github.com/l3montree-dev/devguard/crowdsourcevexing" "github.com/l3montree-dev/devguard/database/models" + "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/transformer" "github.com/l3montree-dev/devguard/utils" ) type CrowdsourcedVexingService struct { vexRuleRepository shared.VEXRuleRepository + systemVexRuleRepository shared.SystemVEXRuleRepository organisationRepository shared.OrganizationRepository projectRepository shared.ProjectRepository assetVersionRepository shared.AssetVersionRepository dependencyVulnRepository shared.DependencyVulnRepository trustedEntityRepository shared.TrustedEntityRepository rbacProvider shared.RBACProvider + cveRelationshipService shared.CVERelationshipService } func mapOrg(org models.Org, orgTrustscore float64, ownerID string, organizationMemberIDs []string) crowdsourcevexing.Organization { @@ -58,15 +63,26 @@ func mapAsset(asset models.Asset) crowdsourcevexing.Asset { } } -func NewCrowdsourcedVexingService(vexRuleRepository shared.VEXRuleRepository, organisationRepository shared.OrganizationRepository, projectRepository shared.ProjectRepository, assetVersionRepository shared.AssetVersionRepository, dependencyVulnRepository shared.DependencyVulnRepository, trustedEntityRepository shared.TrustedEntityRepository, rbacProvider shared.RBACProvider) *CrowdsourcedVexingService { +func NewCrowdsourcedVexingService(vexRuleRepository shared.VEXRuleRepository, + systemVexRuleRepository shared.SystemVEXRuleRepository, + organisationRepository shared.OrganizationRepository, + projectRepository shared.ProjectRepository, + assetVersionRepository shared.AssetVersionRepository, + dependencyVulnRepository shared.DependencyVulnRepository, + trustedEntityRepository shared.TrustedEntityRepository, + rbacProvider shared.RBACProvider, + cveRelationshipService shared.CVERelationshipService, +) *CrowdsourcedVexingService { return &CrowdsourcedVexingService{ vexRuleRepository: vexRuleRepository, + systemVexRuleRepository: systemVexRuleRepository, organisationRepository: organisationRepository, projectRepository: projectRepository, assetVersionRepository: assetVersionRepository, dependencyVulnRepository: dependencyVulnRepository, trustedEntityRepository: trustedEntityRepository, rbacProvider: rbacProvider, + cveRelationshipService: cveRelationshipService, } } @@ -82,6 +98,12 @@ func (s *CrowdsourcedVexingService) Recommend(ctx shared.Context, tx shared.DB, return models.VEXRule{}, fmt.Errorf("vuln does not belong to this asset") } + systemVexRule, err := s.RecommendSystemVEXRule(ctx, tx, vuln.CVEID, vuln.VulnerabilityPath) + if err == nil { + return transformer.SystemVEXRuleToVEXRule(systemVexRule), nil + } + slog.Info("no suitable system VEXRule for this vuln. continuing with crowdsourced vexing", "err", err) + vexRules, err := s.vexRuleRepository.FindByCVE(requestCtx, tx, vuln.CVEID) if err != nil { return models.VEXRule{}, err @@ -153,3 +175,39 @@ func (s *CrowdsourcedVexingService) Recommend(ctx shared.Context, tx shared.DB, } return rule, nil } + +func (s *CrowdsourcedVexingService) RecommendSystemVEXRule(ctx shared.Context, tx shared.DB, cveID string, dependencyPath []string) (models.SystemVEXRule, error) { + requestCtx := ctx.Request().Context() + cveAliasMap, err := s.cveRelationshipService.CreateAliasRelationshipMapBatch(requestCtx, tx, []string{cveID}) + if err != nil { + slog.Info("No aliases for CVE, continuing collection of systemvexrules without aliases") + } + cveAliasArray := []string{cveID} + for alias := range cveAliasMap[cveID] { + cveAliasArray = append(cveAliasArray, alias) + } + rules, err := s.systemVexRuleRepository.FindByCVEBatch(requestCtx, tx, cveAliasArray) + if err != nil { + return models.SystemVEXRule{}, err + } + validRules := utils.Filter(rules, func(rule models.SystemVEXRule) bool { + return dtos.PathPattern(rule.PathPattern).MatchesSuffix(dependencyPath) + }) + nonAliasDetected := false +outer: + for i := range validRules { + for j := range validRules { + if !s.cveRelationshipService.IsAlias(validRules[i].CVEID, validRules[j].CVEID, cveAliasMap) { + nonAliasDetected = true + break outer + } + } + } + if len(validRules) == 0 { + return models.SystemVEXRule{}, fmt.Errorf("no system VEX rules found for CVE: %s", cveID) + } + if len(validRules) > 1 && nonAliasDetected { + return models.SystemVEXRule{}, fmt.Errorf("multiple system VEX rules found for CVE: %s, cannot determine which one to recommend", cveID) + } + return validRules[0], nil +} diff --git a/services/cve_relationship_service.go b/services/cve_relationship_service.go new file mode 100644 index 000000000..d50bf4960 --- /dev/null +++ b/services/cve_relationship_service.go @@ -0,0 +1,55 @@ +package services + +import ( + "context" + + "github.com/l3montree-dev/devguard/dtos" + "github.com/l3montree-dev/devguard/shared" +) + +type CVERelationshipService struct { + cveRelationshipRepository shared.CVERelationshipRepository +} + +func NewCVERelationshipService(cveRelationshipRepository shared.CVERelationshipRepository) *CVERelationshipService { + return &CVERelationshipService{ + cveRelationshipRepository: cveRelationshipRepository, + } +} + +func (s *CVERelationshipService) CreateAliasRelationshipMapBatch(ctx context.Context, tx shared.DB, cveIDs []string) (map[string]map[string]struct{}, error) { + cveRelationships, err := s.cveRelationshipRepository.FindCrossRelationshipsBatch(ctx, tx, cveIDs) + if err != nil { + return nil, err + } + + cveAliasMap := make(map[string]map[string]struct{}) + + for _, rel := range cveRelationships { + if rel.RelationshipType != dtos.RelationshipTypeAlias { + continue + } + + if cveAliasMap[rel.SourceCVE] == nil { + cveAliasMap[rel.SourceCVE] = make(map[string]struct{}) + } + if cveAliasMap[rel.TargetCVE] == nil { + cveAliasMap[rel.TargetCVE] = make(map[string]struct{}) + } + + for alias := range cveAliasMap[rel.SourceCVE] { + cveAliasMap[alias][rel.TargetCVE] = struct{}{} + cveAliasMap[rel.TargetCVE][alias] = struct{}{} + } + + cveAliasMap[rel.SourceCVE][rel.TargetCVE] = struct{}{} + cveAliasMap[rel.TargetCVE][rel.SourceCVE] = struct{}{} + } + + return cveAliasMap, nil +} + +func (s *CVERelationshipService) IsAlias(cveSource, cveTarget string, cveMap map[string]map[string]struct{}) bool { + _, ok := cveMap[cveSource][cveTarget] + return ok +} diff --git a/services/providers.go b/services/providers.go index 54683f373..ac72b96a1 100644 --- a/services/providers.go +++ b/services/providers.go @@ -39,4 +39,5 @@ var ServiceModule = fx.Options( fx.Provide(fx.Annotate(NewAdminService, fx.As(new(shared.AdminService)))), fx.Provide(fx.Annotate(NewCrowdsourcedVexingService, fx.As(new(shared.CrowdSourcedVexingService)))), fx.Provide(fx.Annotate(NewDBEncryptionService, fx.As(new(shared.DBEncryptionService)))), + fx.Provide(fx.Annotate(NewCVERelationshipService, fx.As(new(shared.CVERelationshipService)))), ) diff --git a/services/scan_service.go b/services/scan_service.go index 49482b1be..98f0c235c 100644 --- a/services/scan_service.go +++ b/services/scan_service.go @@ -22,6 +22,8 @@ import ( "io" "log/slog" "net/http" + "net/url" + "path" "slices" "strings" "time" @@ -38,6 +40,7 @@ import ( "github.com/l3montree-dev/devguard/transformer" "github.com/l3montree-dev/devguard/utils" "github.com/l3montree-dev/devguard/vulndb/scan" + ov "github.com/openvex/go-vex/pkg/vex" "github.com/package-url/packageurl-go" "github.com/pkg/errors" "go.opentelemetry.io/otel/attribute" @@ -63,6 +66,8 @@ type scanService struct { utils.FireAndForgetSynchronizer } +var downloadRawFileFn = DownloadGithubRepoAsZip + var _ shared.ScanService = (*scanService)(nil) func NewScanService( @@ -922,3 +927,123 @@ func (s *scanService) ScanSBOMWithoutSaving(ctx context.Context, bom *cyclonedx. DependencyVulns: vulnDTOs, }, nil } + +func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetURL string, targetBranch string) (vexReports []*normalize.VexReportOpenVEX, err error) { + owner, repo, err := ParseGitHubURL(targetURL) + if err != nil { + return nil, err + } + + // Determine default branch + branch := targetBranch + if branch == "" { + branch = "main" + } + + resp, err := downloadRawFileFn(ctx, owner, repo, branch) + if err != nil { + return nil, err + } + + repoZip, err := utils.ZipReaderFromResponse(resp) + if err != nil { + return nil, fmt.Errorf("could not read obtained zip: %w", err) + } + defer resp.Body.Close() + + for _, fileEntry := range repoZip.File { + if fileEntry.FileInfo().IsDir() { + continue + } + filename := strings.ToLower(path.Base(fileEntry.Name)) + if !strings.HasSuffix(filename, ".json") { + continue + } + + fileRead, err := fileEntry.Open() + if err != nil { + slog.Info("openvex document could not be opened, skipping this file for parsing", "filename", fileEntry.Name, "err", err) + continue + } + data, err := io.ReadAll(fileRead) + fileRead.Close() + if err != nil { + slog.Info("openvex document could not be opened, skipping this file for parsing", "filename", fileEntry.Name, "err", err) + continue + } + + if !json.Valid(data) { + slog.Info("skipping non-JSON file in OpenVEX repo", "filename", fileEntry.Name) + continue + } + + var openVEX ov.VEX + err = json.Unmarshal(data, &openVEX) + if err != nil { + slog.Info("could not unmarshal openVEX failed", "err", err, "filename", filename) + continue + } + newVexReport, err := normalize.NewVexReportOpenVEX(&openVEX, targetURL) + if err != nil { + slog.Info("could not create openVEX report structure", "err", err, "filename", filename) + continue + } + vexReports = append(vexReports, newVexReport) + } + return vexReports, nil +} + +func ParseGitHubURL(rawURL string) (owner string, repo string, err error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", "", err + } + const githubDomain = "github.com" + const gitSuffix = ".git" + const trailingSlashSuffix = "/" + if u.Host != githubDomain { + return "", "", fmt.Errorf("invalid github repository url") + } + parts := strings.Split(strings.TrimSuffix(strings.Trim(u.Path, trailingSlashSuffix), gitSuffix), "/") + if len(parts) < 2 { + return "", "", fmt.Errorf("invalid github repository url path: expected /{owner}/{repo}, got %q", u.Path) + } + owner = parts[0] + repo = parts[1] + if owner == "" || repo == "" { + return "", "", fmt.Errorf("invalid github repository url path: expected non-empty owner and repo, got %q", u.Path) + } + return owner, repo, nil +} + +func DownloadGithubRepoAsZip(ctx context.Context, owner, repo, branch string) (*http.Response, error) { + url := fmt.Sprintf( + "https://github.com/%s/%s/archive/refs/heads/%s.zip", + owner, + repo, + branch, + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + switch resp.StatusCode { + case http.StatusOK: + return resp, nil + case http.StatusNotFound: + return nil, fmt.Errorf("404 Source not found") + case http.StatusUnauthorized: + return nil, fmt.Errorf("401 Unauthorized") + case http.StatusInternalServerError: + return nil, fmt.Errorf("500 Internal Server error") + default: + return nil, fmt.Errorf("Unexpected status: %d\n", resp.StatusCode) + } +} diff --git a/services/scan_service_test.go b/services/scan_service_test.go index a1b56e90b..55a220db7 100644 --- a/services/scan_service_test.go +++ b/services/scan_service_test.go @@ -15,10 +15,16 @@ package services import ( + "archive/zip" + "bytes" "context" + "encoding/json" + "io" "net/http" "net/http/httptest" + "sort" "testing" + "time" "github.com/google/uuid" "github.com/l3montree-dev/devguard/database/models" @@ -290,3 +296,135 @@ func TestFetchSbomsFromUpstream_PassesURLNotRef(t *testing.T) { assert.Equal(t, sbomURL, invalidURLs[0].URL) }) } + +func TestFetchOpenVexFromGitHub(t *testing.T) { + originalDownloadRawFileFn := downloadRawFileFn + t.Cleanup(func() { + downloadRawFileFn = originalDownloadRawFileFn + }) + + newZipResponse := func(t *testing.T, files map[string]string) *http.Response { + t.Helper() + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + paths := make([]string, 0, len(files)) + for filePath := range files { + paths = append(paths, filePath) + } + sort.Strings(paths) + for _, filePath := range paths { + content := files[filePath] + entry, err := zw.Create(filePath) + if err != nil { + t.Fatalf("failed to create zip entry %s: %v", filePath, err) + } + if _, err := entry.Write([]byte(content)); err != nil { + t.Fatalf("failed to write zip entry %s: %v", filePath, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(buf.Bytes())), + } + } + + t.Run("should fetch openvex reports from json files in the repository", func(t *testing.T) { + calls := 0 + downloadRawFileFn = func(ctx context.Context, owner, repo, branch string) (*http.Response, error) { + calls++ + assert.Equal(t, "octo-org", owner) + assert.Equal(t, "openvex-repo", repo) + assert.Equal(t, "main", branch) + + ts := time.Date(2026, time.May, 20, 12, 0, 0, 0, time.UTC) + return newZipResponse(t, map[string]string{ + "reports/openvex.json": mustMarshalJSON(t, map[string]any{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "openvex-1", + "author": "test-author", + "timestamp": ts, + "version": 1, + "statements": []any{}, + }), + "README.md": "# ignore me", + }), nil + } + + service := &scanService{} + reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://github.com/octo-org/openvex-repo", "") + assert.NoError(t, err) + assert.Len(t, reports, 1) + assert.Equal(t, "https://github.com/octo-org/openvex-repo", reports[0].Source) + assert.Equal(t, "openvex-1", reports[0].Report.ID) + assert.Equal(t, "test-author", reports[0].Report.Author) + assert.Equal(t, 1, reports[0].Report.Version) + assert.Equal(t, 1, calls) + }) + + t.Run("should fetch multiple openvex reports from multiple json files", func(t *testing.T) { + calls := 0 + downloadRawFileFn = func(ctx context.Context, owner, repo, branch string) (*http.Response, error) { + calls++ + assert.Equal(t, "octo-org", owner) + assert.Equal(t, "multi-vex-repo", repo) + assert.Equal(t, "develop", branch) + + ts := time.Date(2026, time.May, 20, 12, 0, 0, 0, time.UTC) + return newZipResponse(t, map[string]string{ + "vex/vex1.json": mustMarshalJSON(t, map[string]any{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "openvex-first", + "author": "author-one", + "timestamp": ts, + "version": 1, + "statements": []any{}, + }), + "vex/vex2.json": mustMarshalJSON(t, map[string]any{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "openvex-second", + "author": "author-two", + "timestamp": ts, + "version": 1, + "statements": []any{}, + }), + "README.md": "# ignore me", + }), nil + } + + service := &scanService{} + reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://github.com/octo-org/multi-vex-repo", "develop") + assert.NoError(t, err) + assert.Len(t, reports, 2) + assert.Equal(t, "https://github.com/octo-org/multi-vex-repo", reports[0].Source) + assert.Equal(t, "https://github.com/octo-org/multi-vex-repo", reports[1].Source) + assert.Equal(t, "openvex-first", reports[0].Report.ID) + assert.Equal(t, "openvex-second", reports[1].Report.ID) + assert.Equal(t, "author-one", reports[0].Report.Author) + assert.Equal(t, "author-two", reports[1].Report.Author) + assert.Equal(t, 1, calls) + }) + + t.Run("should reject non github urls", func(t *testing.T) { + service := &scanService{} + reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://example.com/repo", "") + assert.Error(t, err) + assert.Nil(t, reports) + assert.Contains(t, err.Error(), "invalid github repository url") + }) +} + +func mustMarshalJSON(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("failed to marshal json: %v", err) + } + return string(data) +} diff --git a/services/vex_rule_service.go b/services/vex_rule_service.go index 1b0ec64e1..9b0095092 100644 --- a/services/vex_rule_service.go +++ b/services/vex_rule_service.go @@ -24,6 +24,7 @@ import ( "strings" cdx "github.com/CycloneDX/cyclonedx-go" + ov "github.com/openvex/go-vex/pkg/vex" "github.com/package-url/packageurl-go" "github.com/google/uuid" @@ -32,26 +33,39 @@ import ( "github.com/l3montree-dev/devguard/normalize" "github.com/l3montree-dev/devguard/shared" "github.com/l3montree-dev/devguard/statemachine" + "github.com/l3montree-dev/devguard/transformer" "github.com/l3montree-dev/devguard/utils" ) type VEXRuleService struct { - vexRuleRepository shared.VEXRuleRepository - dependencyVulnRepository shared.DependencyVulnRepository - vulnEventRepository shared.VulnEventRepository + vexRuleRepository shared.VEXRuleRepository + systemVEXRuleRepository shared.SystemVEXRuleRepository + dependencyVulnRepository shared.DependencyVulnRepository + vulnEventRepository shared.VulnEventRepository + cveRepository shared.CveRepository + cveRelationshipRepository shared.CVERelationshipRepository + cveRelationshipService shared.CVERelationshipService } var _ shared.VEXRuleService = (*VEXRuleService)(nil) func NewVEXRuleService( vexRuleRepository shared.VEXRuleRepository, + systemVEXRuleRepository shared.SystemVEXRuleRepository, dependencyVulnRepository shared.DependencyVulnRepository, vulnEventRepository shared.VulnEventRepository, + cveRepository shared.CveRepository, + cveRelationshipRepository shared.CVERelationshipRepository, + cveRelationshipService shared.CVERelationshipService, ) *VEXRuleService { return &VEXRuleService{ - vexRuleRepository: vexRuleRepository, - dependencyVulnRepository: dependencyVulnRepository, - vulnEventRepository: vulnEventRepository, + vexRuleRepository: vexRuleRepository, + systemVEXRuleRepository: systemVEXRuleRepository, + dependencyVulnRepository: dependencyVulnRepository, + vulnEventRepository: vulnEventRepository, + cveRepository: cveRepository, + cveRelationshipRepository: cveRelationshipRepository, + cveRelationshipService: cveRelationshipService, } } @@ -101,8 +115,18 @@ func (s *VEXRuleService) FindByAssetVersionAndVulnID(ctx context.Context, tx sha return nil, fmt.Errorf("failed to find vulnerability: %w", err) } - // Find rules for this CVE - rules, err := s.vexRuleRepository.FindByAssetVersionAndCVE(ctx, tx, assetID, assetVersionName, vuln.CVEID) + cveAliasMap, err := s.cveRelationshipService.CreateAliasRelationshipMapBatch(ctx, tx, []string{vuln.CVEID}) + if err != nil { + slog.Info("Failed to find CVE Aliases, continuing without") + } + var cveAliases []string + for alias := range cveAliasMap[vuln.CVEID] { + cveAliases = append(cveAliases, alias) + } + cveAliases = append(cveAliases, vuln.CVEID) + + // Find rules for this CVE and aliases + rules, err := s.vexRuleRepository.FindByAssetVersionAndCVEAliases(ctx, tx, assetID, assetVersionName, cveAliases) if err != nil { return nil, err } @@ -129,7 +153,7 @@ func (s *VEXRuleService) CountMatchingVulns(ctx context.Context, tx shared.DB, r if err != nil { return 0, fmt.Errorf("failed to count matching vulns: %w", err) } - matching := matchRulesToVulns([]models.VEXRule{rule}, vulns) + matching := s.MatchRulesToVulns(ctx, tx, []models.VEXRule{rule}, vulns) return len(matching[rule.ID]), nil } @@ -147,7 +171,7 @@ func (s *VEXRuleService) CountMatchingVulnsForRules(ctx context.Context, tx shar vulns, err := s.dependencyVulnRepository.GetDependencyVulnsByAssetVersion(ctx, tx, assetVersionName, assetID, nil) - vulnsByRule := matchRulesToVulns(rules, vulns) + vulnsByRule := s.MatchRulesToVulns(ctx, tx, rules, vulns) if err != nil { return nil, fmt.Errorf("failed to count matching vulns: %w", err) } @@ -204,7 +228,7 @@ func (s *VEXRuleService) ApplyRulesToExistingForce(ctx context.Context, tx share } func (s *VEXRuleService) applyRulesToExistingInternal(ctx context.Context, tx shared.DB, rules []models.VEXRule, vulns []models.DependencyVuln, forceReapply bool) ([]models.DependencyVuln, error) { - vulnsByRule := matchRulesToVulns(rules, vulns) + vulnsByRule := s.MatchRulesToVulns(ctx, tx, rules, vulns) ruleMap := make(map[string]*models.VEXRule) for i := range rules { ruleMap[rules[i].ID] = &rules[i] @@ -278,8 +302,26 @@ func (s *VEXRuleService) ApplyRulesToExistingVulns(ctx context.Context, tx share if len(rules) == 0 { return nil, nil } + assetDeduplicationMap := make(map[string]bool) + assetTuples := []struct { + AssetID string + AssetVersionName string + }{} + + for _, rule := range rules { + assetIDString := rule.AssetID.String() + compositeKey := assetIDString + rule.AssetVersionName + if !assetDeduplicationMap[compositeKey] { + assetDeduplicationMap[compositeKey] = true + assetTuples = append(assetTuples, struct { + AssetID string + AssetVersionName string + }{AssetID: assetIDString, AssetVersionName: rule.AssetVersionName}) + } + } + // Find all vulns matching all rules at once - vulns, err := s.dependencyVulnRepository.GetAllOpenVulnsByAssetVersionNameAndAssetID(ctx, tx, nil, rules[0].AssetVersionName, rules[0].AssetID) + vulns, err := s.dependencyVulnRepository.GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch(ctx, tx, assetTuples) if err != nil { return nil, fmt.Errorf("failed to fetch existing vulns for asset: %w", err) @@ -492,18 +534,14 @@ func (s *VEXRuleService) parseVEXRulesInBOM(ctx context.Context, assetID uuid.UU var pattern dtos.PathPattern - if componentPurl.String() != "" { - componentPurlStr, err := normalize.PURLToString(componentPurl) - if err != nil { - slog.Info("failed to unescape component purl for path pattern, continuing anyway", "purl", componentPurl.String(), "error", err) - componentPurlStr = componentPurl.String() - } - pattern = dtos.PathPattern{componentPurlStr, dtos.PathPatternWildcard, purlString} - } else { - // If no metadata component PURL, use the affected package directly - pattern = dtos.PathPattern{purlString} + componentPurlStr, err := normalize.PURLToString(componentPurl) + if err != nil { + slog.Info("failed to unescape component purl for path pattern, continuing anyway", "purl", componentPurl.String(), "error", err) + componentPurlStr = componentPurl.String() } + pattern = dtos.PathPattern{componentPurlStr, dtos.PathPatternWildcard, purlString} + rule := models.VEXRule{ AssetID: assetID, AssetVersionName: assetVersionName, @@ -521,6 +559,141 @@ func (s *VEXRuleService) parseVEXRulesInBOM(ctx context.Context, assetID uuid.UU return rules, nil } +func (s *VEXRuleService) parseVEXRulesFromOpenVEXReport(ctx context.Context, assetID uuid.UUID, assetVersionName string, report *normalize.VexReportOpenVEX) ([]models.VEXRule, error) { + vex := report.Report + + if vex.Statements == nil { + return nil, fmt.Errorf("no statements inside OpenVex Report") + } + + rules := make([]models.VEXRule, 0, len(vex.Statements)) + for _, statement := range vex.Statements { + if statement.Status == "" { + slog.Info("statement does not status, skipping component for VEX rule creation", "openVEXReport", vex.ID) + continue + } + cveID := string(statement.Vulnerability.Name) + if cveID == "" { + slog.Info("statment does not contain vulnerability name or identifier, skipping component for VEX rule creation", "statement", statement.ID) + continue + } + if statement.Products == nil { + slog.Info("no products inside of statement, skipping component for VEX rule creation", "statement", statement.ID, "cveID", cveID) + continue + } + + for _, product := range statement.Products { + var err error + var componentPurl packageurl.PackageURL + if product.Identifiers != nil && product.Identifiers[ov.PURL] != "" { + componentPurl, err = packageurl.FromString(product.Identifiers[ov.PURL]) + } else if product.ID != "" { + componentPurl, err = packageurl.FromString(product.ID) + } else { + slog.Info("product identifier is not present, skipping VEX rule creation for this vuln", "statement", statement.ID, "cveID", cveID) + continue + } + + if componentPurl.Version == "" { + // Might SPAM logs + slog.Info("product identifier does not contain version, skipping VEX rule creation for this vuln", "statement", statement.ID, "cveID", cveID) + continue + } + + if err != nil { + slog.Info("failed to parse product identifier therefore no identifier present, skipping VEX rule creation for this vuln", "statement", statement.ID, "cveID", cveID) + continue + } + + var justification string + switch statement.Status { + case ov.StatusAffected: + justification = statement.ActionStatement + case ov.StatusNotAffected: + justification = statement.ImpactStatement + } + + mechanicalJustification := dtos.MechanicalJustificationType(statement.Justification) + + eventType, err := mapOpenVEXToEventType(&statement) + if err != nil { + slog.Info("unable to map OpenVEX Statement to event type, skipping VEX rule creation for this vuln", "cveID", cveID, "error", err) + continue + } + + componentPurlStr, err := normalize.PURLToString(componentPurl) + if err != nil { + slog.Info("failed to unescape component purl for path pattern, continuing anyway", "purl", componentPurl.String(), "error", err) + componentPurlStr = componentPurl.String() + } + + if len(product.Subcomponents) > 0 { + for _, subcomponent := range product.Subcomponents { + var subcomponentPurl packageurl.PackageURL + subcomponentPurl, err = packageurl.FromString(subcomponent.ID) + if err != nil { + slog.Info("failed to parse product subcomponent identifier therefore no identifier present, skipping VEX rule creation for this vuln", "statement", statement.ID, "cveID", cveID, "product", componentPurlStr) + continue + } + subcomponentPurlStr, err := normalize.PURLToString(subcomponentPurl) + if err != nil { + subcomponentPurlStr = subcomponentPurl.String() + slog.Info("failed to unescape subcomponent purl for path pattern, continuing anyway", "purl", subcomponentPurlStr, "error", err) + } + pattern := dtos.PathPattern{componentPurlStr, dtos.PathPatternWildcard, subcomponentPurlStr} + + rule := models.VEXRule{ + AssetID: assetID, + AssetVersionName: assetVersionName, + CVEID: cveID, + VexSource: report.Source, + Justification: justification, + EventType: eventType, + PathPattern: pattern, + MechanicalJustification: mechanicalJustification, + CreatedByID: "system", + } + rule.SetPathPattern(rule.PathPattern) + rules = append(rules, rule) + } + } else { + pattern := dtos.PathPattern{componentPurlStr} + rule := models.VEXRule{ + AssetID: assetID, + AssetVersionName: assetVersionName, + CVEID: cveID, + VexSource: report.Source, + Justification: justification, + MechanicalJustification: mechanicalJustification, + EventType: eventType, + PathPattern: pattern, + CreatedByID: "system", + } + rule.SetPathPattern(rule.PathPattern) + rules = append(rules, rule) + } + } + } + return rules, nil +} + +func mapOpenVEXToEventType(s *ov.Statement) (dtos.VulnEventType, error) { + if s == nil { + return "", fmt.Errorf("statement is nil") + } + switch s.Status { + case ov.StatusNotAffected: + return dtos.EventTypeFalsePositive, nil + case ov.StatusAffected: + if s.ActionStatement != "" { + return dtos.EventTypeComment, nil + } + return "", fmt.Errorf("vulnerability analysis state is exploitable, no event type mapping") + default: + return "", fmt.Errorf("unsupported OpenVEX vulnerability analysis state: %s", s.Status) + } +} + // SyncVEXRulesFromSource syncs VEX rules from a specific source. // It fetches existing rules for the given asset and vexSource, compares them with // the new rules, adds new ones and removes ones that no longer exist. @@ -635,8 +808,18 @@ func matchVulnsToRules(vulns []models.DependencyVuln, rules []models.VEXRule) ma return result } -func matchRulesToVulns(rules []models.VEXRule, vulns []models.DependencyVuln) map[string][]models.DependencyVuln { +func (s *VEXRuleService) MatchRulesToVulns(ctx context.Context, tx shared.DB, rules []models.VEXRule, vulns []models.DependencyVuln) map[string][]models.DependencyVuln { result := make(map[string][]models.DependencyVuln) + // Prepare aliases + // Relationship field of rules cannot be preloaded since the preload assumes that the CVEID is the source_cve in the relationship + // Therefore it cannot find the relationships + // We try to find the relationships manually and create a many-to-many crossreference so each CVE will always find each alias + ruleCVEIDs := utils.Map(rules, func(rule models.VEXRule) string { return rule.CVEID }) + + cveAliasMap, err := s.cveRelationshipService.CreateAliasRelationshipMapBatch(ctx, tx, ruleCVEIDs) + if err != nil { + slog.Info("could not find aliases to create cross relations", "err", err) + } // Filter by each rule's cve and path pattern - only match ENABLED rules // group by cve id m := make(map[string][]models.VEXRule) @@ -645,16 +828,110 @@ func matchRulesToVulns(rules []models.VEXRule, vulns []models.DependencyVuln) ma continue } m[rule.CVEID] = append(m[rule.CVEID], rule) + // Prepare for aliases + for aliasCVEID := range cveAliasMap[rule.CVEID] { + m[aliasCVEID] = append(m[aliasCVEID], rule) + } } for _, vuln := range vulns { rulesForCVE := m[vuln.CVEID] + for _, rule := range rulesForCVE { pattern := dtos.PathPattern(rule.PathPattern) - if pattern.MatchesSuffix(vuln.VulnerabilityPath) { + if vuln.Vulnerability.AssetID == rule.AssetID && + vuln.Vulnerability.AssetVersionName == rule.AssetVersionName && + pattern.MatchesSuffix(vuln.VulnerabilityPath) { result[rule.ID] = append(result[rule.ID], vuln) } } } return result } + +func (s *VEXRuleService) UpdateSystemVEXRulesFromStaticSources(ctx context.Context, reports []*normalize.VexReportOpenVEX) error { + systemVEXRulesMap := make(map[string]bool) + var systemVEXRules []models.SystemVEXRule + includedCVEsMap := make(map[string]bool) + var includedCVEs []string + + for _, report := range reports { + if report.Source == "" { + slog.Info("OpenVEX report contains no source. Skipping this report") + continue + } + if report.Report == nil { + slog.Info("OpenVEX report contains no report information. Skipping this report") + continue + } + + parsedVEXRules, err := s.parseVEXRulesFromOpenVEXReport(ctx, uuid.Nil, "main", report) + if err != nil { + slog.Info("Error while parsing OpenVEX report", "error", err, "report", report.Report.ID) + continue + } + for _, parsedRule := range parsedVEXRules { + // This clause uses a map for deduplication + if _, exists := systemVEXRulesMap[parsedRule.ID]; !exists { + systemVEXRule := transformer.VEXRuleToSystemVEXRule(parsedRule) + systemVEXRulesMap[parsedRule.ID] = true + systemVEXRules = append(systemVEXRules, systemVEXRule) + if _, cveExists := includedCVEsMap[parsedRule.CVEID]; !cveExists { + includedCVEs = append(includedCVEs, parsedRule.CVEID) + } + } + } + } + //Check if CVEs are already in database since database can take some time to be established + // If there are a lot of CVEs in a project, the lookup might fail for having + // more than 65535 keys + const cveBatchSize = 1000 + + existingCVEMap := make(map[string]models.CVE) + + for start := 0; start < len(includedCVEs); start += cveBatchSize { + end := start + cveBatchSize + if end > len(includedCVEs) { + end = len(includedCVEs) + } + + batch := includedCVEs[start:end] + found, err := s.cveRepository.FindCVEs(ctx, nil, batch) + if err != nil { + return fmt.Errorf("failed to fetch existing CVEs: %w", err) + } + + for _, cve := range found { + existingCVEMap[strings.ToLower(strings.TrimSpace(cve.CVE))] = cve + } + } + + filteredRules := make([]models.SystemVEXRule, 0, len(systemVEXRules)) + for _, rule := range systemVEXRules { + cveKey := strings.ToLower(strings.TrimSpace(rule.CVEID)) + if _, exists := existingCVEMap[cveKey]; !exists { + // Might SPAM logs + slog.Info("skipping system VEX rule because CVE does not exist in database yet", + "cveID", rule.CVEID, + "vexSource", rule.VexSource, + "ruleID", rule.ID, + ) + continue + } + filteredRules = append(filteredRules, rule) + } + + if len(filteredRules) == 0 { + slog.Info("no system VEX rules left after CVE filtering") + return nil + } + + //Bulk Upload of valid VEXRules + err := s.systemVEXRuleRepository.UpsertBatch(ctx, nil, filteredRules) + if err != nil { + return fmt.Errorf("Error while inserting extracted VEXRules into database: %s", err) + } + slog.Info("updated system VEXRules", "fetched", len(systemVEXRules), "filtered", len(filteredRules)) + + return nil +} diff --git a/services/vex_rule_service_test.go b/services/vex_rule_service_test.go index 30983be36..23d1a119c 100644 --- a/services/vex_rule_service_test.go +++ b/services/vex_rule_service_test.go @@ -3,6 +3,7 @@ package services import ( "context" "testing" + "time" cdx "github.com/CycloneDX/cyclonedx-go" "github.com/google/uuid" @@ -10,6 +11,8 @@ import ( "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/mocks" "github.com/l3montree-dev/devguard/normalize" + "github.com/l3montree-dev/devguard/utils" + ov "github.com/openvex/go-vex/pkg/vex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -215,10 +218,14 @@ func TestVEXRuleServiceUpdate(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.Update(context.Background(), nil, rule) assert.NoError(t, err) @@ -238,12 +245,17 @@ func TestVEXRuleServiceDelete(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Delete", mock.Anything, mock.Anything, mock.MatchedBy(func(r models.VEXRule) bool { return r.ID == "test-rule-1" })).Return(nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) + err := service.Delete(context.Background(), nil, rule) assert.NoError(t, err) @@ -257,10 +269,14 @@ func TestVEXRuleServiceDeleteByAssetVersion(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("DeleteByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.DeleteByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -288,10 +304,14 @@ func TestVEXRuleServiceFindByAssetVersion(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(rules, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) found, err := service.FindByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -314,10 +334,14 @@ func TestVEXRuleServiceFindByID(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByID", mock.Anything, mock.Anything, "test-rule-1").Return(rule, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) found, err := service.FindByID(context.Background(), nil, "test-rule-1") assert.NoError(t, err) @@ -366,6 +390,10 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -375,13 +403,27 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + ruleCVEIDs := utils.Map(rules, func(rule models.VEXRule) string { + return rule.CVEID + }) + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, ruleCVEIDs).Return(map[string]map[string]struct{}{ + "CVE-2024-1234": { + "GO-2024-1234": {}, + }, + "GO-2024-1234": { + "CVE-2024-1234": {}, + }, + }, nil) + + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) counts, err := service.CountMatchingVulnsForRules(context.Background(), nil, rules) assert.NoError(t, err) assert.NotNil(t, counts) assert.Len(t, counts, 2) depVulnRepo.AssertExpectations(t) + cveRelationshipService.AssertExpectations(t) } // TestVEXRuleServiceCountMatchingVulns tests counting matches for single rule @@ -416,6 +458,10 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -425,12 +471,24 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + ruleCVEIDs := []string{rule.CVEID} + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, ruleCVEIDs).Return(map[string]map[string]struct{}{ + "CVE-2024-1234": { + "GO-2024-1234": {}, + }, + "GO-2024-1234": { + "CVE-2024-1234": {}, + }, + }, nil) + + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) count, err := service.CountMatchingVulns(context.Background(), nil, rule) assert.NoError(t, err) assert.GreaterOrEqual(t, count, 0) depVulnRepo.AssertExpectations(t) + cveRelationshipService.AssertExpectations(t) } // TestVEXRuleEnabledBasedOnParanoidMode tests that VEX rules are enabled/disabled based on asset ParanoidMode @@ -467,6 +525,10 @@ func TestVEXRuleEnabledBasedOnParanoidMode(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) // Mock FindByAssetAndVexSource to return empty (no existing rules) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -477,10 +539,23 @@ func TestVEXRuleEnabledBasedOnParanoidMode(t *testing.T) { capturedRules = args.Get(2).([]models.VEXRule) }).Return(nil) - // Mock GetAllOpenVulnsByAssetVersionNameAndAssetID for ApplyRulesToExistingVulns - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) + // Mock GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch for ApplyRulesToExistingVulns + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{}, nil) + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{"CVE-2024-1234"}).Return(map[string]map[string]struct{}{}, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) // Create a minimal VEX report with one vulnerability vexReport := createTestVexReport() @@ -496,6 +571,7 @@ func TestVEXRuleEnabledBasedOnParanoidMode(t *testing.T) { } vexRuleRepo.AssertExpectations(t) + cveRelationshipService.AssertExpectations(t) }) } } @@ -503,6 +579,7 @@ func TestVEXRuleEnabledBasedOnParanoidMode(t *testing.T) { // TestMatchRulesToVulnsOnlyMatchesEnabledRules verifies that matchRulesToVulns only matches enabled rules func TestMatchRulesToVulnsOnlyMatchesEnabledRules(t *testing.T) { assetID := uuid.New() + vexRuleService := mocks.NewVEXRuleService(t) enabledRule := models.VEXRule{ ID: "enabled-rule", @@ -539,7 +616,13 @@ func TestMatchRulesToVulnsOnlyMatchesEnabledRules(t *testing.T) { for _, r := range rules { ruleMap[r.ID] = r } - result := matchRulesToVulns(rules, vulns) + vexRuleService.On("MatchRulesToVulns", mock.Anything, mock.Anything, rules, vulns).Return(map[string][]models.DependencyVuln{ + "enabled-rule": []models.DependencyVuln{ + models.DependencyVuln{CVEID: enabledRule.CVEID}, + }, + }, nil) + + result := vexRuleService.MatchRulesToVulns(context.Background(), nil, rules, vulns) // Only the enabled rule should have matches enabledMatches := 0 @@ -555,6 +638,8 @@ func TestMatchRulesToVulnsOnlyMatchesEnabledRules(t *testing.T) { assert.Equal(t, 1, enabledMatches, "enabled rule should match one vulnerability") assert.Equal(t, 0, disabledMatches, "disabled rule should not match any vulnerabilities") + + vexRuleService.AssertExpectations(t) } // createTestVexReport creates a minimal VEX report for testing @@ -647,10 +732,43 @@ func TestApplyRulesToExistingVulnsOnlyAppliesEnabledRules(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) // Mock GetAllOpenVulnsByAssetVersionNameAndAssetID to return both vulns - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, assetVersionName, assetID). - Return([]models.DependencyVuln{vulnForEnabledRule, vulnForDisabledRule}, nil) + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{vulnForEnabledRule, vulnForDisabledRule}, nil) + + ruleCVEIDs := utils.Map([]models.VEXRule{enabledRule, disabledRule}, func(rule models.VEXRule) string { + return rule.CVEID + }) + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, ruleCVEIDs).Return(map[string]map[string]struct{}{ + "CVE-2024-1234": { + "GO-2024-1234": {}, + }, + "GO-2024-1234": { + "CVE-2024-1234": {}, + }, + "CVE-2024-5678": { + "GO-2024-5678": {}, + }, + "GO-2024-5678": { + "CVE-2024-5678": {}, + }, + }, nil) // Track which vulns get saved - only the vuln matching the enabled rule should be updated var savedVulns []models.DependencyVuln @@ -664,7 +782,7 @@ func TestApplyRulesToExistingVulnsOnlyAppliesEnabledRules(t *testing.T) { savedEvents = args.Get(2).([]models.VulnEvent) }).Return(nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) // Apply both rules (one enabled, one disabled) _, err := service.ApplyRulesToExistingVulns(context.Background(), nil, []models.VEXRule{enabledRule, disabledRule}) @@ -717,12 +835,45 @@ func TestEnablingRuleAppliesItToVulns(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) // First, try to apply the disabled rule - should not save any events - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, assetVersionName, assetID). - Return([]models.DependencyVuln{matchingVuln}, nil).Once() + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{matchingVuln}, nil).Once() + + ruleCVEIDs := utils.Map([]models.VEXRule{rule}, func(rule models.VEXRule) string { + return rule.CVEID + }) + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, ruleCVEIDs).Return(map[string]map[string]struct{}{ + "CVE-2024-1234": { + "GO-2024-1234": {}, + }, + "GO-2024-1234": { + "CVE-2024-1234": {}, + }, + "CVE-2024-5678": { + "GO-2024-5678": {}, + }, + "GO-2024-5678": { + "CVE-2024-5678": {}, + }, + }, nil) // No SaveBatchBestEffort calls expected for disabled rule _, err := service.ApplyRulesToExistingVulns(context.Background(), nil, []models.VEXRule{rule}) @@ -731,8 +882,18 @@ func TestEnablingRuleAppliesItToVulns(t *testing.T) { // Now enable the rule and apply again - this time events should be saved rule.Enabled = true - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, assetVersionName, assetID). - Return([]models.DependencyVuln{matchingVuln}, nil).Once() + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{matchingVuln}, nil).Once() // Track saved events to verify rule was applied var savedEvents []models.VulnEvent @@ -752,6 +913,7 @@ func TestEnablingRuleAppliesItToVulns(t *testing.T) { depVulnRepo.AssertExpectations(t) vulnEventRepo.AssertExpectations(t) + cveRelationshipService.AssertExpectations(t) } // TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign tests that component PURLs @@ -802,6 +964,10 @@ func TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -810,9 +976,35 @@ func TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign(t *testing.T) { capturedRules = args.Get(2).([]models.VEXRule) }).Return(nil) - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{}, nil) + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{"CVE-2024-9999"}).Return(map[string]map[string]struct{}{ + "CVE-2024-1234": { + "GO-2024-1234": {}, + }, + "GO-2024-1234": { + "CVE-2024-1234": {}, + }, + "CVE-2024-5678": { + "GO-2024-5678": {}, + }, + "GO-2024-5678": { + "CVE-2024-5678": {}, + }, + }, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.IngestVEX(context.Background(), nil, asset, assetVersion, vexReport) assert.NoError(t, err) @@ -843,11 +1035,220 @@ func TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign(t *testing.T) { "middle element should be the wildcard") vexRuleRepo.AssertExpectations(t) + depVulnRepo.AssertExpectations(t) +} + +func TestParseVEXRulesFromOpenVEXReport_SelectValidProductID(t *testing.T) { + assetID := uuid.New() + service := NewVEXRuleService(nil, nil, nil, nil, nil, nil, nil) + + testCases := []struct { + name string + product ov.Product + wantPathPattern []string + }{ + { + name: "falls back to product id when identifiers are nil", + product: ov.Product{ + Component: ov.Component{ + ID: "pkg:npm/@myorg/myapp@1.0.0", + }, + }, + wantPathPattern: []string{"pkg:npm/@myorg/myapp@1.0.0"}, + }, + { + name: "uses purl identifier when present", + product: ov.Product{ + Component: ov.Component{ + ID: "pkg:npm/ignored@0.0.0", + Identifiers: map[ov.IdentifierType]string{ + ov.PURL: "pkg:npm/@myorg/myapp@1.0.0", + }, + }, + }, + wantPathPattern: []string{"pkg:npm/@myorg/myapp@1.0.0"}, + }, + } + ts := time.Now().UTC() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + report := &normalize.VexReportOpenVEX{ + Source: "test-source", + Report: &ov.VEX{ + Metadata: ov.Metadata{ + ID: "openvex-report-1", + Context: "https://openvex.dev/ns/v0.2.0", + Author: "test-author", + Version: 1, + Timestamp: &ts, + }, + Statements: []ov.Statement{ + { + ID: "stmt-1", + Vulnerability: ov.Vulnerability{ + Name: "CVE-2024-1234", + }, + Status: ov.StatusNotAffected, + ImpactStatement: "not affected", + Justification: "component_not_present", + Products: []ov.Product{tc.product}, + }, + }, + }, + } + + rules, err := service.parseVEXRulesFromOpenVEXReport(context.Background(), assetID, "v1.0", report) + assert.NoError(t, err) + assert.Len(t, rules, 1) + + rule := rules[0] + assert.Equal(t, assetID, rule.AssetID) + assert.Equal(t, "v1.0", rule.AssetVersionName) + assert.Equal(t, "CVE-2024-1234", rule.CVEID) + assert.Equal(t, dtos.EventTypeFalsePositive, rule.EventType) + assert.Equal(t, tc.wantPathPattern, []string(rule.PathPattern)) + assert.Equal(t, "not affected", rule.Justification) + assert.Equal(t, dtos.MechanicalJustificationType("component_not_present"), rule.MechanicalJustification) + }) + } +} + +// TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements verifies +// parsing a normal OpenVEX report with multiple statements produces one +// VEX rule per statement. +func TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements(t *testing.T) { + assetID := uuid.New() + service := NewVEXRuleService(nil, nil, nil, nil, nil, nil, nil) + + ts := time.Now().UTC() + report := &normalize.VexReportOpenVEX{ + Source: "test-source", + Report: &ov.VEX{ + Metadata: ov.Metadata{ + ID: "openvex-report-2", + Context: "https://openvex.dev/ns/v0.2.0", + Author: "test-author", + Version: 1, + Timestamp: &ts, + }, + Statements: []ov.Statement{ + { + ID: "stmt-1", + Vulnerability: ov.Vulnerability{ + Name: "CVE-2024-1111", + }, + Status: ov.StatusNotAffected, + Justification: ov.ComponentNotPresent, + Products: []ov.Product{ + { + Component: ov.Component{ + ID: "pkg:golang/app@1.0", + Identifiers: map[ov.IdentifierType]string{ + ov.PURL: "pkg:golang/app@1.0", + }, + }, + }, + }, + }, + { + ID: "stmt-2", + Vulnerability: ov.Vulnerability{ + Name: "CVE-2024-2222", + }, + Status: ov.StatusNotAffected, + Justification: ov.ComponentNotPresent, + Products: []ov.Product{ + { + Component: ov.Component{ + ID: "pkg:golang/lib@2.0", + Identifiers: map[ov.IdentifierType]string{}, + }, + Subcomponents: []ov.Subcomponent{ + { + Component: ov.Component{ + ID: "pkg:golang/lib/sub@2.0", + }, + }, + }, + }, + { + Component: ov.Component{ + ID: "pkg:golang/app@1.0", + Identifiers: map[ov.IdentifierType]string{ + ov.PURL: "pkg:golang/app@1.0", + }, + }, + }, + }, + }, + { + ID: "stmt-3", + Vulnerability: ov.Vulnerability{ + Name: "CVE-2024-3333", + }, + Status: ov.StatusAffected, + ActionStatement: "Update", + Products: []ov.Product{ + { + Component: ov.Component{ + ID: "pkg:golang/app@1.0", + Identifiers: map[ov.IdentifierType]string{ + ov.PURL: "pkg:golang/app@1.0", + }, + }, + }, + }, + }, + }, + }, + } + + rules, err := service.parseVEXRulesFromOpenVEXReport(context.Background(), assetID, "v1.0", report) + assert.NoError(t, err) + + expected := []struct { + cve string + path []string + mechanicalJustification string + eventType dtos.VulnEventType + }{ + {cve: "CVE-2024-1111", path: []string{"pkg:golang/app@1.0"}, mechanicalJustification: string(ov.ComponentNotPresent), eventType: dtos.EventTypeFalsePositive}, + {cve: "CVE-2024-2222", path: []string{"pkg:golang/lib@2.0", dtos.PathPatternWildcard, "pkg:golang/lib/sub@2.0"}, mechanicalJustification: string(ov.ComponentNotPresent), eventType: dtos.EventTypeFalsePositive}, + {cve: "CVE-2024-2222", path: []string{"pkg:golang/app@1.0"}, mechanicalJustification: string(ov.ComponentNotPresent), eventType: dtos.EventTypeFalsePositive}, + {cve: "CVE-2024-3333", path: []string{"pkg:golang/app@1.0"}, mechanicalJustification: "", eventType: dtos.EventTypeComment}, + } + + assert.Len(t, rules, len(expected), "number of generated rules should match expected") + + // We check by order, results and expected results have to line up for this test + for i, exp := range expected { + assert.Equal(t, exp.path, []string(rules[i].PathPattern), "path pattern for %s", exp.cve) + assert.Equal(t, exp.mechanicalJustification, string(rules[i].MechanicalJustification), "justification for %s", exp.cve) + assert.Equal(t, exp.eventType, rules[i].EventType, "eventType for %s", exp.cve) + } } // TestMatchRulesToVulns_ComponentPurlWithAtSign verifies that rules with properly // unescaped component PURLs (containing @) correctly match vulnerabilities. func TestMatchRulesToVulns_ComponentPurlWithAtSign(t *testing.T) { + vexRuleRepo := mocks.NewVEXRuleRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + dependencyVulnRepo := mocks.NewDependencyVulnRepository(t) + vulnEventRepo := mocks.NewVulnEventRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) + + vexRuleService := NewVEXRuleService( + vexRuleRepo, + systemVexRuleRepo, + dependencyVulnRepo, + vulnEventRepo, + cveRepo, + cveRelationshipRepo, + cveRelationshipService, + ) + rule := models.VEXRule{ ID: "rule-at-sign", CVEID: "CVE-2024-9999", @@ -863,7 +1264,12 @@ func TestMatchRulesToVulns_ComponentPurlWithAtSign(t *testing.T) { ComponentPurl: "pkg:npm/@myorg/vulnerable-lib@2.0.0", } - result := matchRulesToVulns([]models.VEXRule{rule}, []models.DependencyVuln{vuln}) + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{rule.CVEID}).Return(map[string]map[string]struct{}{ + "CVE-2024-9999": {"GO-2024-9999": {}}, + "GO-2024-9999": {"CVE-2024-9999": {}}, + }, nil) + + result := vexRuleService.MatchRulesToVulns(context.Background(), nil, []models.VEXRule{rule}, []models.DependencyVuln{vuln}) assert.Len(t, result[rule.ID], 1, "rule should match the vulnerability") assert.Equal(t, "CVE-2024-9999", result[rule.ID][0].CVEID) @@ -873,6 +1279,23 @@ func TestMatchRulesToVulns_ComponentPurlWithAtSign(t *testing.T) { // component PURL were still encoded with %40, it would NOT match vulnerability // paths that use the unescaped @ form. func TestMatchRulesToVulns_EncodedAtSignDoesNotMatch(t *testing.T) { + vexRuleRepo := mocks.NewVEXRuleRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + dependencyVulnRepo := mocks.NewDependencyVulnRepository(t) + vulnEventRepo := mocks.NewVulnEventRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) + + vexRuleService := NewVEXRuleService( + vexRuleRepo, + systemVexRuleRepo, + dependencyVulnRepo, + vulnEventRepo, + cveRepo, + cveRelationshipRepo, + cveRelationshipService, + ) // Simulate the old buggy behavior: %40 in the path pattern rule := models.VEXRule{ ID: "rule-encoded", @@ -889,12 +1312,68 @@ func TestMatchRulesToVulns_EncodedAtSignDoesNotMatch(t *testing.T) { ComponentPurl: "pkg:npm/@myorg/vulnerable-lib@2.0.0", } - result := matchRulesToVulns([]models.VEXRule{rule}, []models.DependencyVuln{vuln}) + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{rule.CVEID}).Return(map[string]map[string]struct{}{ + "CVE-2024-9999": {"GO-2024-9999": {}}, + "GO-2024-9999": {"CVE-2024-9999": {}}, + }, nil) + + result := vexRuleService.MatchRulesToVulns(context.Background(), nil, []models.VEXRule{rule}, []models.DependencyVuln{vuln}) assert.Empty(t, result[rule.ID], "encoded %%40 in path pattern should NOT match unescaped @ in vulnerability path — this demonstrates the bug") } +func TestMatchRulesToVulns_FindsAlias(t *testing.T) { + assetID := uuid.New() + assetVersionName := "main" + vexRuleRepo := mocks.NewVEXRuleRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + dependencyVulnRepo := mocks.NewDependencyVulnRepository(t) + vulnEventRepo := mocks.NewVulnEventRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) + + vexRuleService := NewVEXRuleService( + vexRuleRepo, + systemVexRuleRepo, + dependencyVulnRepo, + vulnEventRepo, + cveRepo, + cveRelationshipRepo, + cveRelationshipService, + ) + + rule := models.VEXRule{ + AssetID: assetID, + AssetVersionName: assetVersionName, + ID: "rule", + CVEID: "GO-2024-9999", + Enabled: true, + PathPattern: []string{"pkg:npm/@myorg/myapp@1.0.0", "*", "pkg:npm/@myorg/vulnerable-lib@2.0.0"}, + } + + vuln := models.DependencyVuln{ + CVEID: "CVE-2024-9999", + VulnerabilityPath: []string{"pkg:npm/@myorg/myapp@1.0.0", "pkg:npm/@myorg/vulnerable-lib@2.0.0"}, + ComponentPurl: "pkg:npm/@myorg/vulnerable-lib@2.0.0", + Vulnerability: models.Vulnerability{ + AssetID: assetID, + AssetVersionName: assetVersionName, + }, + } + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{rule.CVEID}).Return(map[string]map[string]struct{}{ + "CVE-2024-9999": {"GO-2024-9999": {}}, + "GO-2024-9999": {"CVE-2024-9999": {}}, + }, nil) + + result := vexRuleService.MatchRulesToVulns(context.Background(), nil, []models.VEXRule{rule}, []models.DependencyVuln{vuln}) + + assert.Len(t, result[rule.ID], 1, "rule should match the vulnerability") + assert.Equal(t, "CVE-2024-9999", result[rule.ID][0].CVEID) +} + // TestMatchVulnsToRules tests the matchVulnsToRules function which maps vulnerability IDs to matching enabled VEX rules func TestMatchVulnsToRules(t *testing.T) { t.Run("matches enabled rules by CVE and path pattern", func(t *testing.T) { @@ -1088,6 +1567,10 @@ func TestParseVEXRulesInBOM_PathPatternFromProperties(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -1096,9 +1579,21 @@ func TestParseVEXRulesInBOM_PathPatternFromProperties(t *testing.T) { capturedRules = args.Get(2).([]models.VEXRule) }).Return(nil) - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{}, nil) + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{"CVE-2024-1234"}).Return(map[string]map[string]struct{}{}, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.IngestVEX(context.Background(), nil, asset, assetVersion, vexReport) assert.NoError(t, err) @@ -1110,6 +1605,8 @@ func TestParseVEXRulesInBOM_PathPatternFromProperties(t *testing.T) { "path pattern should be parsed from the property value, not reconstructed from PURLs") vexRuleRepo.AssertExpectations(t) + depVulnRepo.AssertExpectations(t) + cveRelationshipService.AssertExpectations(t) } // TestParseVEXRulesInBOM_MultiplePathPatternProperties tests that multiple pathPattern @@ -1166,6 +1663,10 @@ func TestParseVEXRulesInBOM_MultiplePathPatternProperties(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -1174,9 +1675,21 @@ func TestParseVEXRulesInBOM_MultiplePathPatternProperties(t *testing.T) { capturedRules = args.Get(2).([]models.VEXRule) }).Return(nil) - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{}, nil) + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{"CVE-2024-1234", "CVE-2024-1234"}).Return(map[string]map[string]struct{}{}, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.IngestVEX(context.Background(), nil, asset, assetVersion, vexReport) assert.NoError(t, err) @@ -1208,10 +1721,14 @@ func TestVEXRuleServiceCreate(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.Create(context.Background(), nil, rule) assert.NoError(t, err) diff --git a/shared/common_interfaces.go b/shared/common_interfaces.go index c6ff223a3..7c2129b0f 100644 --- a/shared/common_interfaces.go +++ b/shared/common_interfaces.go @@ -269,6 +269,14 @@ type DependencyVulnRepository interface { // regardless of path. Used for applying status changes to all instances of a CVE+component combination. FindByCVEAndComponentPurl(ctx context.Context, tx DB, assetID uuid.UUID, cveID string, componentPurl string) ([]models.DependencyVuln, error) GetDirectDependencyFixedVersionByPackageName(ctx context.Context, tx DB, packageName string) (*string, error) + GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch( + ctx context.Context, + tx DB, + assetTuples []struct { + AssetID string + AssetVersionName string + }, + ) ([]models.DependencyVuln, error) } type FirstPartyVulnRepository interface { @@ -337,6 +345,15 @@ type VEXRuleRepository interface { DeleteByAssetVersion(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string) error Begin(ctx context.Context) DB FindByAssetVersionAndCVE(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string, cveID string) ([]models.VEXRule, error) + FindByAssetVersionAndCVEAliases(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string, cveIDs []string) ([]models.VEXRule, error) +} + +type SystemVEXRuleRepository interface { + All(ctx context.Context, tx DB) ([]models.SystemVEXRule, error) + GetDB(ctx context.Context, db DB) DB + FindByCVE(ctx context.Context, tx DB, cveID string) ([]models.SystemVEXRule, error) + FindByCVEBatch(ctx context.Context, tx DB, cveIDs []string) ([]models.SystemVEXRule, error) + UpsertBatch(ctx context.Context, tx DB, rules []models.SystemVEXRule) error } type OrganizationRepository interface { @@ -448,6 +465,7 @@ type AssetVersionRepository interface { DeleteOldAssetVersions(ctx context.Context, tx DB, day int) (int64, error) DeleteOldAssetVersionsOfAsset(ctx context.Context, tx DB, assetID uuid.UUID, day int) (int64, error) GetAmountOfAssetVersionsInOrg(ctx context.Context, tx DB, orgID uuid.UUID) (int, error) + FindSystemVEXRuleApplicableAssetVersions(ctx context.Context, tx DB) ([]models.AssetVersion, error) } type FirstPartyVulnService interface { @@ -468,6 +486,7 @@ type ScanService interface { RunArtifactSecurityLifecycle(ctx context.Context, tx DB, org models.Org, project models.Project, asset models.Asset, assetVersion models.AssetVersion, artifact models.Artifact, userID string, userAgent *string) (*normalize.SBOMGraph, []*normalize.VexReport, []models.DependencyVuln, error) ScanSBOMWithoutSaving(ctx context.Context, bom *cyclonedx.BOM) (dtos.ScanResponse, error) ScanSarifWithoutSaving(ctx context.Context, sarifScan sarif.SarifSchema210Json, scannerID string) (dtos.FirstPartyScanResponse, error) + FetchOpenVexFromGitHub(ctx context.Context, targetURL string, targetBranch string) (vexReports []*normalize.VexReportOpenVEX, err error) } type ConfigRepository interface { @@ -494,12 +513,19 @@ type VEXRuleService interface { FindByID(ctx context.Context, tx DB, id string) (models.VEXRule, error) FindByAssetVersionAndCVE(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string, cveID string) ([]models.VEXRule, error) FindByAssetVersionAndVulnID(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string, vulnID uuid.UUID) ([]models.VEXRule, error) + MatchRulesToVulns(ctx context.Context, tx DB, rules []models.VEXRule, vulns []models.DependencyVuln) map[string][]models.DependencyVuln + UpdateSystemVEXRulesFromStaticSources(ctx context.Context, reports []*normalize.VexReportOpenVEX) error } type CrowdSourcedVexingService interface { Recommend(ctx Context, tx DB, vulnID uuid.UUID) (models.VEXRule, error) } +type CVERelationshipService interface { + CreateAliasRelationshipMapBatch(ctx context.Context, tx DB, cveIDs []string) (map[string]map[string]struct{}, error) + IsAlias(cveSource, cveTarget string, cveMap map[string]map[string]struct{}) bool +} + type VulnEventRepository interface { SaveBatch(ctx context.Context, tx DB, events []models.VulnEvent) error SaveBatchBestEffort(ctx context.Context, tx DB, events []models.VulnEvent) error @@ -652,6 +678,7 @@ type ComponentService interface { type CVERelationshipRepository interface { utils.Repository[string, models.CVERelationship, DB] GetRelationshipsByTargetCVEBatch(ctx context.Context, tx DB, targetCVEIDs []string) ([]models.CVERelationship, error) + FindCrossRelationshipsBatch(ctx context.Context, tx DB, assiciatedCVEIDs []string) ([]models.CVERelationship, error) } type LicenseRiskService interface { diff --git a/tests/daemon_pipeline_test.go b/tests/daemon_pipeline_test.go index 94b59d45e..230346bea 100644 --- a/tests/daemon_pipeline_test.go +++ b/tests/daemon_pipeline_test.go @@ -12,6 +12,7 @@ import ( "github.com/l3montree-dev/devguard/normalize" "github.com/package-url/packageurl-go" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // createTestAffectedComponent creates a properly populated AffectedComponent for testing @@ -819,3 +820,550 @@ func TestDaemonPipelineRiskCalculation(t *testing.T) { }) }) } + +func TestDaemonPipelineApplySystemVEXRules(t *testing.T) { + + package1 := "pkg:npm/test-package@1.0.0" + package2 := "pkg:npm/test-package@2.0.0" + lib1 := "pkg:npm/test-lib@1.0.0" + lib2 := "pkg:npm/test-lib@2.0.0" + vulnLib1 := "pkg:npm/test-vulnerableLib@1.0.0" + vulnLib2 := "pkg:npm/test-vulnerableLib@2.0.0" + + WithTestApp(t, "../initdb.sql", func(f *TestFixture) { + t.Run("should apply systemVEXRules to existing vulns if path matches", func(t *testing.T) { + org1 := f.CreateOrg("test-org-1") + project1 := f.CreateProject(org1.ID, "test-project-1") + asset1 := f.CreateAsset(project1.ID, "test-asset-1") + assetVersion1 := f.CreateAssetVersion(asset1.ID, "main", true) + + cve1 := models.CVE{ + CVE: "CVE-2025-TEST-001", + CVSS: 7.5, + } + + err := f.DB.Create(&cve1).Error + assert.NoError(t, err) + + vulnerability1 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + + err = f.DB.Create(&vulnerability1).Error + assert.NoError(t, err) + + systemVEXRule1 := models.SystemVEXRule{ + // Composite key components + CVEID: cve1.CVE, + VexSource: "https://test-cve.com", + + // Rule data + EventType: dtos.EventTypeFalsePositive, + MechanicalJustification: dtos.ComponentNotPresent, + PathPattern: dtos.PathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}), + CreatedByID: "system", + } + systemVEXRule1.SetPathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}) + + err = f.DB.Create(&systemVEXRule1).Error + assert.NoError(t, err) + + runner := f.CreateDaemonRunner() + err = runner.ApplySystemVEXRules(context.Background()) + assert.NoError(t, err) + + var createdDependencyVuln models.DependencyVuln + err = f.DB.First(&createdDependencyVuln).Error + assert.NoError(t, err) + + // Idea is, if the SystemVEXRule is properly created/applied, there should be only one DependencyVuln with the corresponding CVEID + var createdVulnEvents []models.VulnEvent + err = f.DB.Find(&createdVulnEvents, "dependency_vuln_id = ?", createdDependencyVuln.Vulnerability.ID).Error + assert.NoError(t, err) + assert.Equal(t, 1, len(createdVulnEvents)) + assert.Equal(t, dtos.ComponentNotPresent, createdVulnEvents[0].MechanicalJustification) + assert.Equal(t, "system", createdVulnEvents[0].UserID) + }) + }) + + WithTestApp(t, "../initdb.sql", func(f *TestFixture) { + t.Run("should not apply systemVEXRules to existing vulns if paths don't match", func(t *testing.T) { + org1 := f.CreateOrg("test-org-1") + project1 := f.CreateProject(org1.ID, "test-project-1") + asset1 := f.CreateAsset(project1.ID, "test-asset-1") + assetVersion1 := f.CreateAssetVersion(asset1.ID, "main", true) + + cve1 := models.CVE{ + CVE: "CVE-2025-TEST-001", + CVSS: 7.5, + } + + err := f.DB.Create(&cve1).Error + assert.NoError(t, err) + + cve2 := models.CVE{ + CVE: "CVE-2025-TEST-002", + CVSS: 3.5, + } + + err = f.DB.Create(&cve2).Error + assert.NoError(t, err) + + vulnerability2 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve2.CVE, + ComponentPurl: vulnLib2, + VulnerabilityPath: []string{package2, lib2, vulnLib2}, + Artifacts: []models.Artifact{}, + } + + err = f.DB.Create(&vulnerability2).Error + assert.NoError(t, err) + + systemVEXRule1 := models.SystemVEXRule{ + // Composite key components + CVEID: cve1.CVE, + VexSource: "https://test-cve.com", + + // Rule data + EventType: dtos.EventTypeFalsePositive, + MechanicalJustification: dtos.ComponentNotPresent, + PathPattern: dtos.PathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}), + CreatedByID: "system", + } + systemVEXRule1.SetPathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}) + + err = f.DB.Create(&systemVEXRule1).Error + assert.NoError(t, err) + + runner := f.CreateDaemonRunner() + err = runner.ApplySystemVEXRules(context.Background()) + assert.NoError(t, err) + + var createdDependencyVuln models.DependencyVuln + err = f.DB.First(&createdDependencyVuln).Error + assert.NoError(t, err) + + // This should not be applied, so there should be no DependencyVulns here + var createdVulnEvents []models.VulnEvent + err = f.DB.Find(&createdVulnEvents, "dependency_vuln_id = ?", createdDependencyVuln.Vulnerability.ID).Error + assert.NoError(t, err) + assert.Equal(t, 0, len(createdVulnEvents)) + }) + }) + WithTestApp(t, "../initdb.sql", func(f *TestFixture) { + t.Run("should apply systemVEXRules to existing vulns even with cve alias", func(t *testing.T) { + org1 := f.CreateOrg("test-org-1") + project1 := f.CreateProject(org1.ID, "test-project-1") + asset1 := f.CreateAsset(project1.ID, "test-asset-1") + assetVersion1 := f.CreateAssetVersion(asset1.ID, "main", true) + + cve1 := models.CVE{ + CVE: "CVE-2025-TEST-001", + CVSS: 7.5, + } + + err := f.DB.Create(&cve1).Error + assert.NoError(t, err) + + cve1Alias := models.CVE{ + CVE: "CVE-2025-TEST-ALIAS-001", + CVSS: 7.5, + } + + err = f.DB.Create(&cve1Alias).Error + assert.NoError(t, err) + + cveRelationship1 := models.CVERelationship{ + SourceCVE: cve1.CVE, + TargetCVE: cve1Alias.CVE, + RelationshipType: dtos.RelationshipTypeAlias, + } + + err = f.DB.Create(&cveRelationship1).Error + assert.NoError(t, err) + + vulnerabilityWithAlias := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1Alias.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + + err = f.DB.Create(&vulnerabilityWithAlias).Error + assert.NoError(t, err) + + systemVEXRule := models.SystemVEXRule{ + // Composite key components + CVEID: cve1.CVE, + VexSource: "https://test-cve.com", + + // Rule data + EventType: dtos.EventTypeFalsePositive, + MechanicalJustification: dtos.ComponentNotPresent, + PathPattern: dtos.PathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}), + CreatedByID: "system", + } + systemVEXRule.SetPathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}) + + err = f.DB.Create(&systemVEXRule).Error + assert.NoError(t, err) + + runner := f.CreateDaemonRunner() + err = runner.ApplySystemVEXRules(context.Background()) + assert.NoError(t, err) + + var createdDependencyVuln models.DependencyVuln + err = f.DB.First(&createdDependencyVuln, "cve_id = ?", cve1Alias.CVE).Error + assert.NoError(t, err) + + // Idea is, if the SystemVEXRule is properly created/applied, there should be only one DependencyVuln with the corresponding CVEID + var createdVulnEvents []models.VulnEvent + err = f.DB.Find(&createdVulnEvents, "dependency_vuln_id = ?", createdDependencyVuln.Vulnerability.ID).Error + assert.NoError(t, err) + assert.Equal(t, 1, len(createdVulnEvents)) + assert.Equal(t, dtos.ComponentNotPresent, createdVulnEvents[0].MechanicalJustification) + assert.Equal(t, "system", createdVulnEvents[0].UserID) + }) + }) + WithTestApp(t, "../initdb.sql", func(f *TestFixture) { + + t.Run("should not apply systemVEXRules to existing vulns under paranoid mode", func(t *testing.T) { + org1 := f.CreateOrg("test-org-1") + project1 := f.CreateProject(org1.ID, "test-project-1") + asset1 := models.Asset{ + Name: "test-project-1", + ProjectID: project1.ID, + ParanoidMode: true, + } + err := f.DB.Create(&asset1).Error + require.NoError(f.T, err) + assetVersion1 := f.CreateAssetVersion(asset1.ID, "main", true) + + cve1 := models.CVE{ + CVE: "CVE-2025-TEST-001", + CVSS: 7.5, + } + + err = f.DB.Create(&cve1).Error + assert.NoError(t, err) + + vulnerability1 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + + err = f.DB.Create(&vulnerability1).Error + assert.NoError(t, err) + + systemVEXRule1 := models.SystemVEXRule{ + // Composite key components + CVEID: cve1.CVE, + VexSource: "https://test-cve.com", + + // Rule data + EventType: dtos.EventTypeFalsePositive, + MechanicalJustification: dtos.ComponentNotPresent, + PathPattern: dtos.PathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}), + CreatedByID: "system", + } + systemVEXRule1.SetPathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}) + + err = f.DB.Create(&systemVEXRule1).Error + assert.NoError(t, err) + + runner := f.CreateDaemonRunner() + err = runner.ApplySystemVEXRules(context.Background()) + assert.NoError(t, err) + + var createdDependencyVuln models.DependencyVuln + err = f.DB.First(&createdDependencyVuln).Error + assert.NoError(t, err) + + // This should not be applied, so there should be no DependencyVulns here + var createdVulnEvents []models.VulnEvent + err = f.DB.Find(&createdVulnEvents, "dependency_vuln_id = ?", createdDependencyVuln.Vulnerability.ID).Error + assert.NoError(t, err) + assert.Equal(t, 0, len(createdVulnEvents)) + }) + }) + WithTestApp(t, "../initdb.sql", func(f *TestFixture) { + t.Run("systemVEXRules should be applied for all assets that are not in paranoid mode and all vulns that are matching", func(t *testing.T) { + org1 := f.CreateOrg("test-org-1") + project1 := f.CreateProject(org1.ID, "test-project-1") + asset1 := f.CreateAsset(project1.ID, "test-asset-1") + assetVersion1 := f.CreateAssetVersion(asset1.ID, "main", true) + + org2 := f.CreateOrg("test-org-2") + project2 := f.CreateProject(org2.ID, "test-project-2") + asset2 := f.CreateAsset(project2.ID, "test-asset-2") + assetVersion2 := f.CreateAssetVersion(asset2.ID, "main", true) + + org3 := f.CreateOrg("test-org-3") + project3 := f.CreateProject(org3.ID, "test-project-3") + asset3 := models.Asset{ + Name: "test-project-3", + ProjectID: project3.ID, + ParanoidMode: true, + } + err := f.DB.Create(&asset3).Error + require.NoError(f.T, err) + assetVersion3 := f.CreateAssetVersion(asset3.ID, "main", true) + + org4 := f.CreateOrg("test-org-4") + project4 := f.CreateProject(org4.ID, "test-project-4") + asset4 := f.CreateAsset(project4.ID, "test-asset-4") + assetVersion4 := f.CreateAssetVersion(asset4.ID, "main", true) + + // Create CVEs + cve1 := models.CVE{ + CVE: "CVE-2025-TEST-001", + CVSS: 7.5, + } + err = f.DB.Create(&cve1).Error + assert.NoError(t, err) + + cve1Alias := models.CVE{ + CVE: "CVE-2025-TEST-ALIAS-001", + CVSS: 7.5, + } + err = f.DB.Create(&cve1Alias).Error + assert.NoError(t, err) + + cve2 := models.CVE{ + CVE: "CVE-2025-TEST-002", + CVSS: 3.5, + } + err = f.DB.Create(&cve2).Error + assert.NoError(t, err) + + cve2Alias1 := models.CVE{ + CVE: "CVE-2025-TEST-ALIAS-102", + CVSS: 4.9, + } + err = f.DB.Create(&cve2Alias1).Error + assert.NoError(t, err) + + cve2Alias2 := models.CVE{ + CVE: "CVE-2025-TEST-ALIAS-202", + CVSS: 4.9, + } + err = f.DB.Create(&cve2Alias2).Error + assert.NoError(t, err) + + // Create CVE Aliases + cveRelationship1 := models.CVERelationship{ + SourceCVE: "CVE-2025-TEST-001", + TargetCVE: "CVE-2025-TEST-ALIAS-001", + RelationshipType: dtos.RelationshipTypeAlias, + } + err = f.DB.Create(&cveRelationship1).Error + assert.NoError(t, err) + + cveRelationship2 := models.CVERelationship{ + SourceCVE: "CVE-2025-TEST-002", + TargetCVE: "CVE-2025-TEST-ALIAS-102", + RelationshipType: dtos.RelationshipTypeAlias, + } + err = f.DB.Create(&cveRelationship2).Error + assert.NoError(t, err) + + cveRelationship3 := models.CVERelationship{ + SourceCVE: "CVE-2025-TEST-002", + TargetCVE: "CVE-2025-TEST-ALIAS-202", + RelationshipType: dtos.RelationshipTypeAlias, + } + err = f.DB.Create(&cveRelationship3).Error + assert.NoError(t, err) + + vulnerability1 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability1).Error + assert.NoError(t, err) + + vulnerability2 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve2.CVE, + ComponentPurl: vulnLib2, + VulnerabilityPath: []string{package2, lib2, vulnLib2}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability2).Error + assert.NoError(t, err) + + vulnerability3 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset2.ID, + AssetVersionName: assetVersion2.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability3).Error + assert.NoError(t, err) + + // Exists in different asset as vulnnerabilty1 and has a different CVE + vulnerability4 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset2.ID, + AssetVersionName: assetVersion2.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve2.CVE, + ComponentPurl: vulnLib2, + VulnerabilityPath: []string{package2, lib2, vulnLib2}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability4).Error + assert.NoError(t, err) + + vulnerability5 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset2.ID, + AssetVersionName: assetVersion2.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve2Alias1.CVE, + ComponentPurl: vulnLib2, + VulnerabilityPath: []string{package2, lib2, vulnLib2}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability5).Error + assert.NoError(t, err) + + // Vuln created for asset3, this should not be matched since the asset is in paranoid mode + vulnerability6 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset3.ID, + AssetVersionName: assetVersion3.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability6).Error + assert.NoError(t, err) + + // Vuln created for asset4, alias test + vulnerability7 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset4.ID, + AssetVersionName: assetVersion4.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1Alias.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib2, vulnLib1}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability7).Error + assert.NoError(t, err) + + //Create SystemVEXRules + systemVEXRule1 := models.SystemVEXRule{ + // Composite key components + CVEID: cve1.CVE, + VexSource: "https://test-cve.com", + + // Rule data + EventType: dtos.EventTypeFalsePositive, + MechanicalJustification: dtos.ComponentNotPresent, + PathPattern: dtos.PathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}), + CreatedByID: "system", + } + systemVEXRule1.SetPathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}) + + err = f.DB.Create(&systemVEXRule1).Error + assert.NoError(t, err) + + runner := f.CreateDaemonRunner() + err = runner.ApplySystemVEXRules(context.Background()) + assert.NoError(t, err) + + var createdDependencyVulns []models.DependencyVuln + err = f.DB.Find(&createdDependencyVulns).Error + assert.NoError(t, err) + + var createdRels []models.CVERelationship + err = f.DB.Find(&createdRels).Error + assert.NoError(t, err) + + createdDependencyVulnsIDs := utils.Map(createdDependencyVulns, func(vuln models.DependencyVuln) uuid.UUID { + return vuln.ID + }) + + // This should not be applied, so there should be no DependencyVulns here + var createdVulnEvents []models.VulnEvent + err = f.DB.Find(&createdVulnEvents, "dependency_vuln_id IN (?)", createdDependencyVulnsIDs).Error + assert.NoError(t, err) + assert.Equal(t, 3, len(createdVulnEvents)) + + resultsMap := make(map[string]models.VulnEvent) + for _, ve := range createdVulnEvents { + resultsMap[ve.DependencyVulnID.String()] = ve + } + + for _, dv := range createdDependencyVulns { + if _, ok := resultsMap[dv.ID.String()]; !ok { + continue + } + assert.Equal(t, dtos.ComponentNotPresent, resultsMap[dv.ID.String()].MechanicalJustification) + assert.Equal(t, "system", resultsMap[dv.ID.String()].UserID) + } + }) + }) + +} diff --git a/tests/fx_test_app.go b/tests/fx_test_app.go index 1c40843f7..5057ab349 100644 --- a/tests/fx_test_app.go +++ b/tests/fx_test_app.go @@ -103,6 +103,7 @@ type TestApp struct { VexRuleRepository shared.VEXRuleRepository ExternalReferenceRepository shared.ExternalReferenceRepository VexRuleService shared.VEXRuleService + SystemVEXRuleRepository shared.SystemVEXRuleRepository // Access Control RBACProvider shared.RBACProvider diff --git a/tests/fx_test_helpers.go b/tests/fx_test_helpers.go index b3b0a49c6..340bc6aed 100644 --- a/tests/fx_test_helpers.go +++ b/tests/fx_test_helpers.go @@ -89,6 +89,7 @@ func (f *TestFixture) CreateOrg(name string) models.Org { org := models.Org{ Name: name, Description: "Test Organization", + Slug: name, } err := f.DB.Create(&org).Error require.NoError(f.T, err) @@ -172,6 +173,7 @@ func (f *TestFixture) CreateDaemonRunner() *daemons.DaemonRunner { f.App.VulnDBService, f.App.VexRuleService, f.App.FixedVersionResolver, + f.App.SystemVEXRuleRepository, ) } diff --git a/tests/vex_test.go b/tests/vex_test.go index 27e1ba17d..0f4228a3a 100644 --- a/tests/vex_test.go +++ b/tests/vex_test.go @@ -35,10 +35,14 @@ func TestVEXRuleServiceUpdate(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.Update(context.Background(), nil, rule) assert.NoError(t, err) @@ -59,12 +63,16 @@ func TestVEXRuleServiceDelete(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Delete", mock.Anything, mock.Anything, mock.MatchedBy(func(r models.VEXRule) bool { return r.ID == "test-rule-1" })).Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.Delete(context.Background(), nil, rule) assert.NoError(t, err) @@ -79,10 +87,14 @@ func TestVEXRuleServiceDeleteByAssetVersion(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("DeleteByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.DeleteByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -111,10 +123,14 @@ func TestVEXRuleServiceFindByAssetVersion(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(rules, nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) found, err := service.FindByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -138,10 +154,14 @@ func TestVEXRuleServiceFindByID(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByID", mock.Anything, mock.Anything, "test-rule-1").Return(rule, nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) found, err := service.FindByID(context.Background(), nil, "test-rule-1") assert.NoError(t, err) @@ -191,6 +211,10 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -200,7 +224,7 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) counts, err := service.CountMatchingVulnsForRules(context.Background(), nil, rules) assert.NoError(t, err) @@ -242,6 +266,10 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -251,7 +279,7 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) count, err := service.CountMatchingVulns(context.Background(), nil, rule) assert.NoError(t, err) @@ -275,10 +303,14 @@ func TestVEXRuleServiceCreate(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.Create(context.Background(), nil, rule) assert.NoError(t, err) @@ -318,6 +350,10 @@ func TestApplyRulesToExistingIdempotent(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) // Track how many events are saved across all calls var totalEventsSaved int @@ -329,7 +365,7 @@ func TestApplyRulesToExistingIdempotent(t *testing.T) { }). Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) // First call — should create 1 event vulns := []models.DependencyVuln{vuln} diff --git a/transformer/vex_rule_transformer.go b/transformer/vex_rule_transformer.go index 4e4f39f39..5ec4e4d29 100644 --- a/transformer/vex_rule_transformer.go +++ b/transformer/vex_rule_transformer.go @@ -53,3 +53,51 @@ func VEXRuleToRecommendationDTO(rule models.VEXRule) dtos.VexRuleRecommendation EventType: rule.EventType, } } + +func VEXRuleToSystemVEXRule(rule models.VEXRule) models.SystemVEXRule { + transformedRule := models.SystemVEXRule{ + ID: rule.ID, + + // Composite key components + CVEID: rule.CVEID, + VexSource: rule.VexSource, + + // Rule data + Justification: rule.Justification, + MechanicalJustification: rule.MechanicalJustification, + EventType: rule.EventType, + PathPattern: dtos.PathPattern(rule.PathPattern), + CreatedByID: rule.CreatedByID, + CreatedAt: rule.CreatedAt, + UpdatedAt: rule.UpdatedAt, + } + transformedRule.SetPathPattern(rule.PathPattern) + return transformedRule +} + +func SystemVEXRuleToVEXRule(systemRule models.SystemVEXRule) models.VEXRule { + transformedRule := models.VEXRule{ + ID: systemRule.ID, + + // Composite key components + CVEID: systemRule.CVEID, + VexSource: systemRule.VexSource, + + CreatedAt: systemRule.CreatedAt, + UpdatedAt: systemRule.UpdatedAt, + + CVE: systemRule.CVE, + + // Rule data + Justification: systemRule.Justification, + MechanicalJustification: systemRule.MechanicalJustification, + EventType: systemRule.EventType, + + PathPattern: dtos.PathPattern(systemRule.PathPattern), + CreatedByID: systemRule.CreatedByID, + + Enabled: false, + } + transformedRule.SetPathPattern(systemRule.PathPattern) + return transformedRule +}