diff --git a/cmd/devguard/main.go b/cmd/devguard/main.go index 7c18118f..cdf71f7d 100644 --- a/cmd/devguard/main.go +++ b/cmd/devguard/main.go @@ -103,7 +103,7 @@ func main() { } fx.New( - fx.NopLogger, + // fx.NopLogger, fx.Supply(db), fx.Provide(database.BrokerFactory), fx.Provide(api.NewServer), diff --git a/controllers/dependency_vuln_controller.go b/controllers/dependency_vuln_controller.go index 9027707e..93abbcf3 100644 --- a/controllers/dependency_vuln_controller.go +++ b/controllers/dependency_vuln_controller.go @@ -8,6 +8,7 @@ import ( "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/statemachine" "github.com/l3montree-dev/devguard/transformer" "github.com/l3montree-dev/devguard/vulndb" @@ -319,7 +320,7 @@ func (controller DependencyVulnController) SyncDependencyVulns(ctx shared.Contex } dependencyVuln.Events = events - events[len(events)-1].Apply(&dependencyVuln) + statemachine.Apply(&dependencyVuln, events[len(events)-1]) //update the dependencyVuln and its events err = controller.dependencyVulnRepository.Save(nil, &dependencyVuln) diff --git a/database/models/vulnerability_model.go b/database/models/vulnerability_model.go index 5d0000ab..cc2a6972 100644 --- a/database/models/vulnerability_model.go +++ b/database/models/vulnerability_model.go @@ -29,6 +29,8 @@ type Vuln interface { GetTicketID() *string GetTicketURL() *string GetManualTicketCreation() bool + AssetVersionIndependentHash() string + GetEvents() []VulnEvent } type Vulnerability struct { diff --git a/database/models/vulnevent_model.go b/database/models/vulnevent_model.go index b72e3ca5..de99a434 100644 --- a/database/models/vulnevent_model.go +++ b/database/models/vulnevent_model.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "log/slog" - "time" "github.com/l3montree-dev/devguard/dtos" ) @@ -82,61 +81,6 @@ func (event VulnEvent) TableName() string { return "vuln_events" } -func (event VulnEvent) Apply(vuln Vuln) { - if event.Upstream != dtos.UpstreamStateInternal && event.Type == dtos.EventTypeAccepted { - // its an external accepted event that should not modify state - return - } - - switch event.Type { - case dtos.EventTypeLicenseDecision: - finalLicenseDecision, ok := (event.GetArbitraryJSONData()["finalLicenseDecision"]).(string) - if !ok { - slog.Error("could not parse final license decision", "dependencyVulnID", - - event.VulnID) - return - } - v := vuln.(*LicenseRisk) - v.SetFinalLicenseDecision(finalLicenseDecision) - v.SetState(dtos.VulnStateFixed) - case dtos.EventTypeFixed: - vuln.SetState(dtos.VulnStateFixed) - case dtos.EventTypeReopened: - if event.Upstream == dtos.UpstreamStateExternal { - return - } - vuln.SetState(dtos.VulnStateOpen) - case dtos.EventTypeDetected: - // event type detected will always be applied! - f, ok := (event.GetArbitraryJSONData()["risk"]).(float64) - if !ok { - f = vuln.GetRawRiskAssessment() - } - vuln.SetRawRiskAssessment(f) - vuln.SetRiskRecalculatedAt(time.Now()) - vuln.SetState(dtos.VulnStateOpen) - case dtos.EventTypeAccepted: - vuln.SetState(dtos.VulnStateAccepted) - case dtos.EventTypeFalsePositive: - if event.Upstream == dtos.UpstreamStateExternal { - return - } - vuln.SetState(dtos.VulnStateFalsePositive) - case dtos.EventTypeMarkedForTransfer: - vuln.SetState(dtos.VulnStateMarkedForTransfer) - case dtos.EventTypeRawRiskAssessmentUpdated: - f, ok := (event.GetArbitraryJSONData()["risk"]).(float64) - if !ok { - slog.Error("could not parse risk assessment", "dependencyVulnID", event.VulnID) - return - } - vuln.SetRawRiskAssessment(f) - vuln.SetRiskRecalculatedAt(time.Now()) - } - -} - func NewAcceptedEvent(vulnID string, vulnType dtos.VulnType, userID, justification string, upstream dtos.UpstreamState) VulnEvent { return VulnEvent{ diff --git a/database/models/vulnevent_model_test.go b/database/models/vulnevent_model_test.go index d9ed8c8c..8362a58b 100644 --- a/database/models/vulnevent_model_test.go +++ b/database/models/vulnevent_model_test.go @@ -5,6 +5,7 @@ import ( "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/dtos" + "github.com/l3montree-dev/devguard/statemachine" "github.com/stretchr/testify/assert" ) @@ -36,7 +37,7 @@ func TestVulnEvent_Apply(t *testing.T) { vuln := models.DependencyVuln{} event := models.VulnEvent{Type: dtos.EventTypeFixed} - event.Apply(&vuln) + statemachine.Apply(&vuln, event) assert.Equal(t, dtos.VulnStateFixed, vuln.State) }) @@ -44,7 +45,7 @@ func TestVulnEvent_Apply(t *testing.T) { vuln := models.DependencyVuln{} event := models.VulnEvent{Type: dtos.EventTypeFalsePositive} - event.Apply(&vuln) + statemachine.Apply(&vuln, event) assert.Equal(t, dtos.VulnStateFalsePositive, vuln.State) }) @@ -55,7 +56,7 @@ func TestVulnEvent_Apply(t *testing.T) { ArbitraryJSONData: `{"risk": 0.5 }`, } - event.Apply(&vuln) + statemachine.Apply(&vuln, event) assert.Equal(t, 0.5, vuln.GetRawRiskAssessment()) }) @@ -67,7 +68,7 @@ func TestVulnEvent_Apply(t *testing.T) { ArbitraryJSONData: `{"risk": 0.5 }`, } - event.Apply(&vuln) + statemachine.Apply(&vuln, event) assert.NotZero(t, vuln.RiskRecalculatedAt) }) @@ -75,7 +76,7 @@ func TestVulnEvent_Apply(t *testing.T) { vuln := models.DependencyVuln{} event := models.VulnEvent{Type: dtos.EventTypeDetected} - event.Apply(&vuln) + statemachine.Apply(&vuln, event) assert.Equal(t, dtos.VulnStateOpen, vuln.State) }) @@ -87,7 +88,7 @@ func TestVulnEvent_Apply(t *testing.T) { ArbitraryJSONData: `{"risk": 0.5 }`, } - event.Apply(&vuln) + statemachine.Apply(&vuln, event) assert.NotZero(t, vuln.RiskRecalculatedAt) }) @@ -96,7 +97,7 @@ func TestVulnEvent_Apply(t *testing.T) { vuln := models.DependencyVuln{} event := models.VulnEvent{Type: dtos.EventTypeReopened} - event.Apply(&vuln) + statemachine.Apply(&vuln, event) assert.Equal(t, dtos.VulnStateOpen, vuln.State) }) @@ -104,7 +105,7 @@ func TestVulnEvent_Apply(t *testing.T) { vuln := models.DependencyVuln{} event := models.VulnEvent{Type: dtos.EventTypeAccepted} - event.Apply(&vuln) + statemachine.Apply(&vuln, event) assert.Equal(t, dtos.VulnStateAccepted, vuln.State) }) diff --git a/database/repositories/dependency_vuln_repository.go b/database/repositories/dependency_vuln_repository.go index f7093870..0a60faa4 100644 --- a/database/repositories/dependency_vuln_repository.go +++ b/database/repositories/dependency_vuln_repository.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/statemachine" "github.com/l3montree-dev/devguard/utils" "github.com/l3montree-dev/devguard/database/models" @@ -40,7 +41,7 @@ func (repository *dependencyVulnRepository) ApplyAndSave(tx *gorm.DB, dependency func (repository *dependencyVulnRepository) applyAndSave(tx *gorm.DB, dependencyVuln *models.DependencyVuln, ev *models.VulnEvent) (models.VulnEvent, error) { // apply the event on the dependencyVuln - ev.Apply(dependencyVuln) + statemachine.Apply(dependencyVuln, *ev) // run the updates in the transaction to keep a valid state err := repository.Save(tx, dependencyVuln) diff --git a/database/repositories/first_party_vuln_repository.go b/database/repositories/first_party_vuln_repository.go index 265b0fec..9b565b28 100644 --- a/database/repositories/first_party_vuln_repository.go +++ b/database/repositories/first_party_vuln_repository.go @@ -5,6 +5,7 @@ import ( "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/statemachine" "gorm.io/gorm" ) @@ -186,7 +187,7 @@ func (repository *firstPartyVulnerabilityRepository) ApplyAndSave(tx *gorm.DB, f func (repository *firstPartyVulnerabilityRepository) applyAndSave(tx *gorm.DB, firstPartyVuln *models.FirstPartyVuln, ev *models.VulnEvent) (models.VulnEvent, error) { // apply the event on the dependencyVuln - ev.Apply(firstPartyVuln) + statemachine.Apply(firstPartyVuln, *ev) // save the event if err := repository.Save(tx, firstPartyVuln); err != nil { return models.VulnEvent{}, err diff --git a/database/repositories/license_risk_repository.go b/database/repositories/license_risk_repository.go index 0d30040e..b211ae3b 100644 --- a/database/repositories/license_risk_repository.go +++ b/database/repositories/license_risk_repository.go @@ -5,6 +5,7 @@ import ( "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/statemachine" "github.com/l3montree-dev/devguard/utils" "github.com/package-url/packageurl-go" "gorm.io/gorm" @@ -133,8 +134,7 @@ func (repository *LicenseRiskRepository) ApplyAndSave(tx *gorm.DB, licenseRisk * } func (repository *LicenseRiskRepository) applyAndSave(tx *gorm.DB, licenseRisk *models.LicenseRisk, ev *models.VulnEvent) (models.VulnEvent, error) { - ev.Apply(licenseRisk) - + statemachine.Apply(licenseRisk, *ev) // run the updates in the transaction to keep a valid state err := repository.Save(tx, licenseRisk) if err != nil { diff --git a/database/repositories/statistics_repository.go b/database/repositories/statistics_repository.go index 3095527a..253f400e 100644 --- a/database/repositories/statistics_repository.go +++ b/database/repositories/statistics_repository.go @@ -8,6 +8,7 @@ import ( "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/dtos" + "github.com/l3montree-dev/devguard/statemachine" ) type statisticsRepository struct { @@ -56,7 +57,7 @@ func (r *statisticsRepository) TimeTravelDependencyVulnState(artifactName *strin events := dependencyVuln.Events // iterate through all events and apply them for _, event := range events { - event.Apply(&tmpDependencyVuln) + statemachine.Apply(&tmpDependencyVuln, event) } } return dependencyVulns, nil diff --git a/integrations/githubint/github_integration.go b/integrations/githubint/github_integration.go index a686d475..9c5732cc 100644 --- a/integrations/githubint/github_integration.go +++ b/integrations/githubint/github_integration.go @@ -29,6 +29,7 @@ import ( "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/integrations/commonint" "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/statemachine" "github.com/l3montree-dev/devguard/utils" "github.com/l3montree-dev/devguard/vulndb" ) @@ -363,7 +364,8 @@ func (githubIntegration *GithubIntegration) HandleWebhook(ctx shared.Context) er // create a new event based on the comment vulnEvent := commonint.CreateNewVulnEventBasedOnComment(vuln.GetID(), vuln.GetType(), fmt.Sprintf("github:%d", event.Comment.User.GetID()), comment, vuln.GetScannerIDsOrArtifactNames()) - vulnEvent.Apply(vuln) + statemachine.Apply(vuln, vulnEvent) + // save the vuln and the event in a transaction err = githubIntegration.aggregatedVulnRepository.Transaction(func(tx shared.DB) error { err := githubIntegration.aggregatedVulnRepository.Save(tx, &vuln) diff --git a/integrations/gitlabint/gitlab_webhook.go b/integrations/gitlabint/gitlab_webhook.go index af1342d6..487044f1 100644 --- a/integrations/gitlabint/gitlab_webhook.go +++ b/integrations/gitlabint/gitlab_webhook.go @@ -9,6 +9,7 @@ import ( "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/integrations/commonint" + "github.com/l3montree-dev/devguard/statemachine" "github.com/l3montree-dev/devguard/shared" "github.com/pkg/errors" @@ -223,7 +224,8 @@ func (g *GitlabIntegration) HandleWebhook(ctx shared.Context) error { // create a new event based on the comment vulnEvent = commonint.CreateNewVulnEventBasedOnComment(vuln.GetID(), vuln.GetType(), fmt.Sprintf("gitlab:%d", event.User.ID), comment, vuln.GetScannerIDsOrArtifactNames()) - vulnEvent.Apply(vuln) + statemachine.Apply(vuln, vulnEvent) + // save the dependencyVuln and the event in a transaction err = g.aggregatedVulnRepository.Transaction(func(tx shared.DB) error { err := g.aggregatedVulnRepository.Save(tx, &vuln) diff --git a/integrations/jiraint/jira_webhook.go b/integrations/jiraint/jira_webhook.go index 79107ed5..28d5a1ce 100644 --- a/integrations/jiraint/jira_webhook.go +++ b/integrations/jiraint/jira_webhook.go @@ -14,6 +14,7 @@ import ( "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/integrations/commonint" "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/statemachine" ) func (i *JiraIntegration) HandleWebhook(ctx shared.Context) error { @@ -136,8 +137,8 @@ func (i *JiraIntegration) HandleWebhook(ctx shared.Context) error { // create a new event based on the comment vulnEvent := commonint.CreateNewVulnEventBasedOnComment(vuln.GetID(), vuln.GetType(), fmt.Sprintf("jira:%s", userID), comment, vuln.GetScannerIDsOrArtifactNames()) + statemachine.Apply(vuln, vulnEvent) - vulnEvent.Apply(vuln) // save the vuln and the event in a transaction err = i.aggregatedVulnRepository.Transaction(func(tx shared.DB) error { err := i.aggregatedVulnRepository.Save(tx, &vuln) diff --git a/services/asset_version_service.go b/services/asset_version_service.go index 73e7eb8b..8f95bc13 100644 --- a/services/asset_version_service.go +++ b/services/asset_version_service.go @@ -21,6 +21,7 @@ import ( "github.com/l3montree-dev/devguard/dtos/sarif" "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" "github.com/l3montree-dev/devguard/vulndb" @@ -256,18 +257,18 @@ func (s *assetVersionService) handleFirstPartyVulnResult(userID string, scannerI return vuln.State == dtos.VulnStateOpen }) - newDetectedVulnsNotOnOtherBranch, newDetectedButOnOtherBranchExisting, existingEvents := diffVulnsBetweenBranches(newVulns, existingVulnsOnOtherBranch) + branchDiff := statemachine.DiffVulnsBetweenBranches(utils.Map(newVulns, utils.Ptr), utils.Map(existingVulnsOnOtherBranch, utils.Ptr)) // get a transaction if err := s.firstPartyVulnRepository.Transaction(func(tx shared.DB) error { // Process new vulnerabilities that exist on other branches with lifecycle management - if err := s.firstPartyVulnService.UserDetectedExistingFirstPartyVulnOnDifferentBranch(tx, scannerID, newDetectedButOnOtherBranchExisting, existingEvents, *assetVersion, asset); err != nil { + if err := s.firstPartyVulnService.UserDetectedExistingFirstPartyVulnOnDifferentBranch(tx, scannerID, branchDiff.ExistingOnOtherBranches, *assetVersion, asset); err != nil { slog.Error("error when trying to add events for existing first party vulnerability on different branch", "err", err) return err } // Process new vulnerabilities that don't exist on other branches - if err := s.firstPartyVulnService.UserDetectedFirstPartyVulns(tx, userID, scannerID, newDetectedVulnsNotOnOtherBranch); err != nil { + if err := s.firstPartyVulnService.UserDetectedFirstPartyVulns(tx, userID, scannerID, utils.DereferenceSlice(branchDiff.NewToAllBranches)); err != nil { return err } @@ -289,14 +290,14 @@ func (s *assetVersionService) handleFirstPartyVulnResult(userID string, scannerI return []models.FirstPartyVuln{}, []models.FirstPartyVuln{}, []models.FirstPartyVuln{}, err } - if len(newDetectedVulnsNotOnOtherBranch) > 0 && (assetVersion.DefaultBranch || assetVersion.Type == models.AssetVersionTag) { + if len(branchDiff.NewToAllBranches) > 0 && (assetVersion.DefaultBranch || assetVersion.Type == models.AssetVersionTag) { s.FireAndForget(func() { if err = s.thirdPartyIntegration.HandleEvent(shared.FirstPartyVulnsDetectedEvent{ AssetVersion: shared.ToAssetVersionObject(*assetVersion), Asset: shared.ToAssetObject(asset), Project: shared.ToProjectObject(project), Org: shared.ToOrgObject(org), - Vulns: utils.Map(newDetectedVulnsNotOnOtherBranch, transformer.FirstPartyVulnToDto), + Vulns: utils.Map(utils.DereferenceSlice(branchDiff.NewToAllBranches), transformer.FirstPartyVulnToDto), }); err != nil { slog.Error("could not handle first party vulnerabilities detected event", "err", err) } @@ -309,7 +310,7 @@ func (s *assetVersionService) handleFirstPartyVulnResult(userID string, scannerI return []models.FirstPartyVuln{}, []models.FirstPartyVuln{}, []models.FirstPartyVuln{}, err } - return newDetectedVulnsNotOnOtherBranch, fixedVulns, v, nil + return utils.DereferenceSlice(branchDiff.NewToAllBranches), fixedVulns, v, nil } func (s *assetVersionService) HandleScanResult(org models.Org, project models.Project, asset models.Asset, assetVersion *models.AssetVersion, vulns []models.VulnInPackage, artifactName string, userID string, upstream dtos.UpstreamState) (opened []models.DependencyVuln, closed []models.DependencyVuln, newState []models.DependencyVuln, err error) { @@ -406,106 +407,6 @@ func (s *assetVersionService) HandleScanResult(org models.Org, project models.Pr return opened, closed, newState, nil } -func diffScanResults(currentArtifactName string, foundVulnerabilities []models.DependencyVuln, existingDependencyVulns []models.DependencyVuln) ([]models.DependencyVuln, []models.DependencyVuln, []models.DependencyVuln, []models.DependencyVuln, []models.DependencyVuln) { - - var firstDetected []models.DependencyVuln - var fixedOnAll []models.DependencyVuln - var firstDetectedOnThisArtifactName []models.DependencyVuln - var fixedOnThisArtifactName []models.DependencyVuln - var nothingChanged []models.DependencyVuln - - var foundVulnsMappedByID = make(map[string]models.DependencyVuln) - for _, vuln := range foundVulnerabilities { - if _, ok := foundVulnsMappedByID[vuln.CalculateHash()]; !ok { - foundVulnsMappedByID[vuln.CalculateHash()] = vuln - } - } - - for _, existingVulns := range existingDependencyVulns { - if _, ok := foundVulnsMappedByID[existingVulns.CalculateHash()]; !ok { - if len(existingVulns.Artifacts) == 1 && existingVulns.Artifacts[0].ArtifactName == currentArtifactName { - fixedOnAll = append(fixedOnAll, existingVulns) - } else { - fixedOnThisArtifactName = append(fixedOnThisArtifactName, existingVulns) - } - } else { - // still exists and nothing changed - nothingChanged = append(nothingChanged, existingVulns) - } - } - var existingVulnsMappedByID = make(map[string]models.DependencyVuln) - for _, vuln := range existingDependencyVulns { - if _, ok := existingVulnsMappedByID[vuln.CalculateHash()]; !ok { - existingVulnsMappedByID[vuln.CalculateHash()] = vuln - } - } - - for _, foundVuln := range foundVulnerabilities { - if existingVuln, ok := existingVulnsMappedByID[foundVuln.CalculateHash()]; !ok { - firstDetected = append(firstDetected, foundVuln) - } else { - // existing vulnerability artifacts inspected instead of newly built vuln artifacts - alreadyDetectedOnThisArtifactName := false - for _, existingArtifact := range existingVuln.Artifacts { - if existingArtifact.ArtifactName == currentArtifactName { - alreadyDetectedOnThisArtifactName = true - break - } - } - if !alreadyDetectedOnThisArtifactName { - firstDetectedOnThisArtifactName = append(firstDetectedOnThisArtifactName, existingVuln) - } - } - } - - return firstDetected, fixedOnAll, firstDetectedOnThisArtifactName, fixedOnThisArtifactName, nothingChanged -} - -type Diffable interface { - AssetVersionIndependentHash() string - GetAssetVersionName() string - GetEvents() []models.VulnEvent -} - -func diffVulnsBetweenBranches[T Diffable](foundVulnerabilities []T, existingVulns []T) ([]T, []T, [][]models.VulnEvent) { - newDetectedVulnsNotOnOtherBranch := make([]T, 0) - newDetectedButOnOtherBranchExisting := make([]T, 0) - existingEvents := make([][]models.VulnEvent, 0) - - // Create a map of existing vulnerabilities by hash for quick lookup - existingVulnsMap := make(map[string][]T) - for _, vuln := range existingVulns { - hash := vuln.AssetVersionIndependentHash() - existingVulnsMap[hash] = append(existingVulnsMap[hash], vuln) - } - - for _, newDetectedVuln := range foundVulnerabilities { - hash := newDetectedVuln.AssetVersionIndependentHash() - if existingVulns, ok := existingVulnsMap[hash]; ok { - - newDetectedButOnOtherBranchExisting = append(newDetectedButOnOtherBranchExisting, newDetectedVuln) - - existingVulnEventsOnOtherBranch := make([]models.VulnEvent, 0) - for _, existingVuln := range existingVulns { - - events := utils.Filter(existingVuln.GetEvents(), func(ev models.VulnEvent) bool { - return ev.OriginalAssetVersionName == nil && ev.Type != dtos.EventTypeRawRiskAssessmentUpdated - }) - - existingVulnEventsOnOtherBranch = append(existingVulnEventsOnOtherBranch, utils.Map(events, func(event models.VulnEvent) models.VulnEvent { - event.OriginalAssetVersionName = utils.Ptr(existingVuln.GetAssetVersionName()) - return event - })...) - } - existingEvents = append(existingEvents, existingVulnEventsOnOtherBranch) - } else { - newDetectedVulnsNotOnOtherBranch = append(newDetectedVulnsNotOnOtherBranch, newDetectedVuln) - } - } - - return newDetectedVulnsNotOnOtherBranch, newDetectedButOnOtherBranchExisting, existingEvents -} - func (s *assetVersionService) migrateToPurlsWithQualifiers(newVulns []models.DependencyVuln, existingVulns []models.DependencyVuln, existingVulnsOnOtherBranch []models.DependencyVuln) ([]models.DependencyVuln, []models.DependencyVuln, error) { vulnsToUpdate := make([]models.DependencyVuln, 0) @@ -645,7 +546,7 @@ func (s *assetVersionService) handleScanResult(userID string, artifactName strin return dependencyVuln.State != dtos.VulnStateFixed }) - newDetectedVulns, fixedVulns, firstDetectedOnThisArtifactName, fixedOnThisArtifactName, nothingChanged := diffScanResults(artifactName, dependencyVulns, existingDependencyVulns) + diff := statemachine.DiffScanResults(artifactName, dependencyVulns, existingDependencyVulns) // remove from fixed vulns and fixed on this artifact name all vulns, that have more than a single path to them // this means, that another source is still saying, its part of this artifact unfixablePurls := sbom.InformationFromVexOrMultipleSBOMs() @@ -656,24 +557,25 @@ func (s *assetVersionService) handleScanResult(userID string, artifactName strin return !slices.Contains(unfixablePurls, *dv.ComponentPurl) } - fixedVulns = utils.Filter(fixedVulns, filterPredicate) - fixedOnThisArtifactName = utils.Filter(fixedOnThisArtifactName, filterPredicate) + fixedVulns := utils.Filter(diff.FixedEverywhere, filterPredicate) + fixedOnThisArtifactName := utils.Filter(diff.RemovedFromArtifact, filterPredicate) - newDetectedVulnsNotOnOtherBranch, newDetectedButOnOtherBranchExisting, existingEvents := diffVulnsBetweenBranches(newDetectedVulns, existingVulnsOnOtherBranch) + // newDetectedVulnsNotOnOtherBranch, newDetectedButOnOtherBranchExisting, existingEvents := diffVulnsBetweenBranches(diff.NewlyDiscovered, existingVulnsOnOtherBranch) + branchDiff := statemachine.DiffVulnsBetweenBranches(utils.Map(diff.NewlyDiscovered, utils.Ptr), utils.Map(existingVulnsOnOtherBranch, utils.Ptr)) if err := s.dependencyVulnRepository.Transaction(func(tx shared.DB) error { // make sure to first create a user detected event for vulnerabilities with just upstream events // this way we preserve the event history - if err := s.dependencyVulnService.UserDetectedExistingVulnOnDifferentBranch(tx, artifactName, newDetectedButOnOtherBranchExisting, existingEvents, *assetVersion, asset); err != nil { + if err := s.dependencyVulnService.UserDetectedExistingVulnOnDifferentBranch(tx, artifactName, branchDiff.ExistingOnOtherBranches, *assetVersion, asset); err != nil { slog.Error("error when trying to add events for existing vulnerability on different branch") return err // this will cancel the transaction } // We can create the newly found one without checking anything - if err := s.dependencyVulnService.UserDetectedDependencyVulns(tx, artifactName, newDetectedVulnsNotOnOtherBranch, *assetVersion, asset, upstream); err != nil { + if err := s.dependencyVulnService.UserDetectedDependencyVulns(tx, artifactName, utils.DereferenceSlice(branchDiff.NewToAllBranches), *assetVersion, asset, upstream); err != nil { return err // this will cancel the transaction } - err = s.dependencyVulnService.UserDetectedDependencyVulnInAnotherArtifact(tx, firstDetectedOnThisArtifactName, artifactName) + err = s.dependencyVulnService.UserDetectedDependencyVulnInAnotherArtifact(tx, diff.NewInArtifact, artifactName) if err != nil { slog.Error("error when trying to add events for adding scanner to vulnerability") return err @@ -690,9 +592,9 @@ func (s *assetVersionService) handleScanResult(userID string, artifactName strin return err } - if len(nothingChanged) > 0 { + if len(diff.Unchanged) > 0 { var valueClauses []string - for _, dv := range nothingChanged { + for _, dv := range diff.Unchanged { hash := dv.CalculateHash() depth := utils.OrDefault(dv.ComponentDepth, 1) valueClauses = append(valueClauses, fmt.Sprintf("('%s', %d)", hash, depth)) @@ -723,7 +625,7 @@ func (s *assetVersionService) handleScanResult(userID string, artifactName strin return []models.DependencyVuln{}, []models.DependencyVuln{}, []models.DependencyVuln{}, err } - return newDetectedVulnsNotOnOtherBranch, fixedVulns, v, nil + return utils.DereferenceSlice(branchDiff.NewToAllBranches), fixedVulns, v, nil } func buildBomRefMap(bom *normalize.CdxBom) map[string]cdx.Component { diff --git a/services/asset_version_service_test.go b/services/asset_version_service_test.go index 05e086f5..4a11696a 100644 --- a/services/asset_version_service_test.go +++ b/services/asset_version_service_test.go @@ -181,118 +181,6 @@ func TestFirstPartyVulnHash(t *testing.T) { }) } -func TestDiffScanResults(t *testing.T) { - - t.Run("should correctly identify a vulnerability which now gets found by another artifact", func(t *testing.T) { - currentArtifactName := "new-artifact" - - assetID := uuid.New() - assetVersionName := "asset-version-1" - - foundVulnerabilities := []models.DependencyVuln{ - {CVEID: utils.Ptr("CVE-1234"), Vulnerability: models.Vulnerability{AssetVersionName: assetVersionName, AssetID: assetID}}, - } - - artifact := models.Artifact{ArtifactName: "artifact1", AssetVersionName: assetVersionName, AssetID: assetID} - - existingDependencyVulns := []models.DependencyVuln{ - {CVEID: utils.Ptr("CVE-1234"), Vulnerability: models.Vulnerability{ - AssetVersionName: assetVersionName, AssetID: assetID, - }, Artifacts: []models.Artifact{artifact}}, - } - - firstDetected, fixedOnAll, firstDetectedOnThisArtifactName, fixedOnThisArtifactName, vulnsWithJustUpstreamEvents := diffScanResults(currentArtifactName, foundVulnerabilities, existingDependencyVulns) - - assert.Empty(t, firstDetected) - assert.Empty(t, fixedOnAll) - assert.Empty(t, fixedOnThisArtifactName) - assert.Equal(t, 1, len(vulnsWithJustUpstreamEvents)) - assert.Equal(t, 1, len(firstDetectedOnThisArtifactName)) - }) - - t.Run("should correctly identify a vulnerability which now is fixed, since it was not found by the artifact anymore", func(t *testing.T) { - - assetID := uuid.New() - - artifact := models.Artifact{ArtifactName: "artifact1", AssetVersionName: "asset-version-1", AssetID: assetID} - - foundVulnerabilities := []models.DependencyVuln{} - - existingDependencyVulns := []models.DependencyVuln{ - {CVEID: utils.Ptr("CVE-1234"), Vulnerability: models.Vulnerability{}, Artifacts: []models.Artifact{artifact}}, - } - - firstDetected, fixedOnAll, firstDetectedOnThisArtifactName, fixedOnThisArtifactName, vulnsWithJustUpstreamEvents := diffScanResults(artifact.ArtifactName, foundVulnerabilities, existingDependencyVulns) - - assert.Empty(t, firstDetected) - assert.Empty(t, vulnsWithJustUpstreamEvents) - assert.Equal(t, 1, len(fixedOnAll)) - assert.Empty(t, firstDetectedOnThisArtifactName) - assert.Empty(t, fixedOnThisArtifactName) - }) - - t.Run("should correctly identify a vulnerability which is not found in the current artifact anymore", func(t *testing.T) { - currentArtifactName := "new-artifact" - - artifact := models.Artifact{ArtifactName: "artifact1"} - - foundVulnerabilities := []models.DependencyVuln{} - - existingDependencyVulns := []models.DependencyVuln{ - {CVEID: utils.Ptr("CVE-1234"), Vulnerability: models.Vulnerability{}, Artifacts: []models.Artifact{artifact}}, - } - - firstDetected, fixedOnAll, firstDetectedOnThisArtifactName, fixedOnThisArtifactName, vulnsWithJustUpstreamEvents := diffScanResults(currentArtifactName, foundVulnerabilities, existingDependencyVulns) - - assert.Empty(t, firstDetected) - assert.Empty(t, fixedOnAll) - assert.Empty(t, vulnsWithJustUpstreamEvents) - assert.Empty(t, firstDetectedOnThisArtifactName) - assert.Equal(t, 1, len(fixedOnThisArtifactName)) - }) - - t.Run("should identify new vulnerabilities", func(t *testing.T) { - currentArtifactName := "new-artifact" - - foundVulnerabilities := []models.DependencyVuln{ - {CVEID: utils.Ptr("CVE-1234")}, - {CVEID: utils.Ptr("CVE-5678")}, - } - - existingDependencyVulns := []models.DependencyVuln{} - - firstDetected, fixedOnAll, firstDetectedOnThisArtifactName, fixedOnThisArtifactName, vulnsWithJustUpstreamEvents := diffScanResults(currentArtifactName, foundVulnerabilities, existingDependencyVulns) - - assert.Equal(t, 2, len(firstDetected)) - assert.Empty(t, fixedOnAll) - assert.Empty(t, vulnsWithJustUpstreamEvents) - assert.Empty(t, firstDetectedOnThisArtifactName) - assert.Empty(t, fixedOnThisArtifactName) - }) - - t.Run("BUG: should NOT incorrectly identify artifact removal when artifact ID contains colon and is substring of existing artifact", func(t *testing.T) { - - currentArtifactName := "container-scanning" - - artifact := models.Artifact{ArtifactName: "artifact1"} - - foundVulnerabilities := []models.DependencyVuln{ - {CVEID: utils.Ptr("CVE-1234")}, - } - - existingDependencyVulns := []models.DependencyVuln{ - {CVEID: utils.Ptr("CVE-1234"), Vulnerability: models.Vulnerability{}, Artifacts: []models.Artifact{artifact}}, - } - - firstDetected, fixedOnAll, firstDetectedOnThisArtifactName, fixedOnThisArtifactName, vulnsWithJustUpstreamEvents := diffScanResults(currentArtifactName, foundVulnerabilities, existingDependencyVulns) - - assert.Equal(t, 1, len(vulnsWithJustUpstreamEvents)) - assert.Empty(t, firstDetected, "Should be empty - this is a new detection by current artifact") - assert.Empty(t, fixedOnAll, "Should be empty - no vulnerabilities are fixed") - assert.Equal(t, 1, len(firstDetectedOnThisArtifactName), "Should detect that current artifact found existing vulnerability for first time") - assert.Empty(t, fixedOnThisArtifactName, "BUG: Should be empty - current artifact was never detecting this vulnerability before!") - }) -} func TestYamlMetadata(t *testing.T) { t.Run("Test the created yaml", func(t *testing.T) { @@ -377,254 +265,6 @@ func TestCreateProjectTitle(t *testing.T) { }) } -func TestDiffVulnsBetweenBranches(t *testing.T) { - - t.Run("should copy events when vuln exists on other branch", func(t *testing.T) { - assetID := uuid.New() - - foundVulnerabilities := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0001"), - Vulnerability: models.Vulnerability{ - ID: "vuln-1", - AssetVersionName: "feature-branch", - AssetID: assetID, - Events: []models.VulnEvent{}, - }, - }, - } - - existingDependencyVulns := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0001"), - Vulnerability: models.Vulnerability{ - ID: "vuln-2", - AssetVersionName: "main", - AssetID: assetID, - Events: []models.VulnEvent{{Type: dtos.EventTypeDetected}, - {Type: dtos.EventTypeComment}}, - }, - Artifacts: []models.Artifact{{ArtifactName: "artifact1", AssetVersionName: "feature-branch", AssetID: assetID}, - {ArtifactName: "artifact2", AssetVersionName: "feature-branch", AssetID: assetID}}, - }, - } - - newDetectedVulnsNotOnOtherBranch, newDetectedButOnOtherBranchExisting, existingEvents := diffVulnsBetweenBranches(foundVulnerabilities, existingDependencyVulns) - - assert.Empty(t, newDetectedVulnsNotOnOtherBranch) - assert.Len(t, newDetectedButOnOtherBranchExisting, 1) - assert.Len(t, existingEvents, 1) - fmt.Printf("Existing Events: %+v\n", existingEvents) - assert.Len(t, existingEvents[0], 2) - - }) - - t.Run("should identify new vulnerabilities not on other branch", func(t *testing.T) { - foundVulnerabilities := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0001"), - Vulnerability: models.Vulnerability{ - AssetVersionName: "feature-branch", - }, - }, - { - CVEID: utils.Ptr("CVE-2023-0002"), - Vulnerability: models.Vulnerability{ - AssetVersionName: "feature-branch", - }, - }, - } - - existingDependencyVulns := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0003"), - Vulnerability: models.Vulnerability{ - AssetVersionName: "main", - }, - }, - } - - newDetectedVulnsNotOnOtherBranch, newDetectedButOnOtherBranchExisting, existingEvents := diffVulnsBetweenBranches(foundVulnerabilities, existingDependencyVulns) - - assert.Len(t, newDetectedVulnsNotOnOtherBranch, 2) - assert.Empty(t, newDetectedButOnOtherBranchExisting) - assert.Empty(t, existingEvents) - assert.Equal(t, "CVE-2023-0001", *newDetectedVulnsNotOnOtherBranch[0].CVEID) - assert.Equal(t, "CVE-2023-0002", *newDetectedVulnsNotOnOtherBranch[1].CVEID) - }) - - t.Run("should identify vulnerabilities that exist on other branch", func(t *testing.T) { - foundVulnerabilities := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0001"), - Vulnerability: models.Vulnerability{ - AssetVersionName: "feature-branch", - }, - }, - } - - existingDependencyVulns := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0001"), - Vulnerability: models.Vulnerability{ - AssetVersionName: "main", - Events: []models.VulnEvent{ - { - Type: dtos.EventTypeAccepted, - }, - }, - }, - }, - } - - newDetectedVulnsNotOnOtherBranch, newDetectedButOnOtherBranchExisting, existingEvents := diffVulnsBetweenBranches(foundVulnerabilities, existingDependencyVulns) - - assert.Empty(t, newDetectedVulnsNotOnOtherBranch) - assert.Len(t, newDetectedButOnOtherBranchExisting, 1) - assert.Len(t, existingEvents, 1) - assert.Equal(t, "CVE-2023-0001", *newDetectedButOnOtherBranchExisting[0].CVEID) - assert.Len(t, existingEvents[0], 1) - assert.Equal(t, "main", *existingEvents[0][0].OriginalAssetVersionName) - }) - - t.Run("should handle multiple vulnerabilities with same CVE on other branch", func(t *testing.T) { - foundVulnerabilities := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0001"), - Vulnerability: models.Vulnerability{ - AssetVersionName: "feature-branch", - }, - }, - } - - existingDependencyVulns := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0001"), - Vulnerability: models.Vulnerability{ - AssetVersionName: "main", - Events: []models.VulnEvent{ - { - Type: dtos.EventTypeComment, - }, - }, - }, - }, - { - CVEID: utils.Ptr("CVE-2023-0001"), - Vulnerability: models.Vulnerability{ - AssetVersionName: "develop", - Events: []models.VulnEvent{ - { - Type: dtos.EventTypeComment, - }, - }, - }, - }, - } - - newDetectedVulnsNotOnOtherBranch, newDetectedButOnOtherBranchExisting, existingEvents := diffVulnsBetweenBranches(foundVulnerabilities, existingDependencyVulns) - - assert.Empty(t, newDetectedVulnsNotOnOtherBranch) - assert.Len(t, newDetectedButOnOtherBranchExisting, 1) - assert.Len(t, existingEvents, 1) - assert.Len(t, existingEvents[0], 2) // combined events from both existing vulns - assert.Equal(t, "main", *existingEvents[0][0].OriginalAssetVersionName) - assert.Equal(t, "develop", *existingEvents[0][1].OriginalAssetVersionName) - }) - - t.Run("should filter out events that were already copied", func(t *testing.T) { - foundVulnerabilities := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0001"), - Vulnerability: models.Vulnerability{ - AssetVersionName: "feature-branch", - }, - }, - } - - existingDependencyVulns := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0001"), - Vulnerability: models.Vulnerability{ - AssetVersionName: "main", - Events: []models.VulnEvent{ - { - Type: dtos.EventTypeDetected, - OriginalAssetVersionName: nil, // original event - }, - { - Type: dtos.EventTypeAccepted, - OriginalAssetVersionName: utils.Ptr("other-branch"), // already copied event - }, - }, - }, - }, - } - - newDetectedVulnsNotOnOtherBranch, newDetectedButOnOtherBranchExisting, existingEvents := diffVulnsBetweenBranches(foundVulnerabilities, existingDependencyVulns) - - assert.Empty(t, newDetectedVulnsNotOnOtherBranch) - assert.Len(t, newDetectedButOnOtherBranchExisting, 1) - assert.Len(t, existingEvents, 1) - assert.Len(t, existingEvents[0], 1) // only the original event, not the copied one - assert.Equal(t, dtos.EventTypeDetected, existingEvents[0][0].Type) - assert.Equal(t, "main", *existingEvents[0][0].OriginalAssetVersionName) - }) - - t.Run("should handle mixed scenario with new and existing vulnerabilities", func(t *testing.T) { - foundVulnerabilities := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0001"), // new vuln - Vulnerability: models.Vulnerability{ - AssetVersionName: "feature-branch", - }, - }, - { - CVEID: utils.Ptr("CVE-2023-0002"), // exists on other branch - Vulnerability: models.Vulnerability{ - AssetVersionName: "feature-branch", - }, - }, - { - CVEID: utils.Ptr("CVE-2023-0003"), // new vuln - Vulnerability: models.Vulnerability{ - AssetVersionName: "feature-branch", - }, - }, - } - - existingDependencyVulns := []models.DependencyVuln{ - { - CVEID: utils.Ptr("CVE-2023-0002"), - Vulnerability: models.Vulnerability{ - AssetVersionName: "main", - Events: []models.VulnEvent{ - { - Type: dtos.EventTypeDetected, - }, - }, - }, - }, - } - - newDetectedVulnsNotOnOtherBranch, newDetectedButOnOtherBranchExisting, existingEvents := diffVulnsBetweenBranches(foundVulnerabilities, existingDependencyVulns) - - assert.Len(t, newDetectedVulnsNotOnOtherBranch, 2) - assert.Len(t, newDetectedButOnOtherBranchExisting, 1) - assert.Len(t, existingEvents, 1) - - // check new vulnerabilities - newCVEs := []string{*newDetectedVulnsNotOnOtherBranch[0].CVEID, *newDetectedVulnsNotOnOtherBranch[1].CVEID} - assert.Contains(t, newCVEs, "CVE-2023-0001") - assert.Contains(t, newCVEs, "CVE-2023-0003") - - // check existing vulnerability - assert.Equal(t, "CVE-2023-0002", *newDetectedButOnOtherBranchExisting[0].CVEID) - assert.Len(t, existingEvents[0], 1) - assert.Equal(t, "main", *existingEvents[0][0].OriginalAssetVersionName) - }) -} - func TestMarkdownTableFromSBOM(t *testing.T) { t.Run("test an sbom with 3 components which have 2 , 1 and 0 licenses respectively ", func(t *testing.T) { bom := cdx.BOM{ diff --git a/services/config_service.go b/services/config_service.go index e663bf80..faeaa6ec 100644 --- a/services/config_service.go +++ b/services/config_service.go @@ -5,6 +5,7 @@ import ( "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/database/repositories" + "github.com/l3montree-dev/devguard/shared" ) diff --git a/services/dependency_vuln_service.go b/services/dependency_vuln_service.go index 6803ecd1..345264f3 100644 --- a/services/dependency_vuln_service.go +++ b/services/dependency_vuln_service.go @@ -19,7 +19,6 @@ import ( "context" "fmt" "log/slog" - "slices" "time" "github.com/google/uuid" @@ -27,6 +26,7 @@ import ( "github.com/l3montree-dev/devguard/integrations/commonint" "github.com/l3montree-dev/devguard/monitoring" "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/statemachine" "github.com/l3montree-dev/devguard/vulndb" "github.com/l3montree-dev/devguard/database/models" @@ -70,7 +70,7 @@ func (s *DependencyVulnService) UserFixedDependencyVulns(tx shared.DB, userID st for i, dependencyVuln := range dependencyVulns { ev := models.NewFixedEvent(dependencyVuln.CalculateHash(), dtos.VulnTypeDependencyVuln, userID, dependencyVuln.GetScannerIDsOrArtifactNames(), upstream) // apply the event on the dependencyVuln - ev.Apply(&dependencyVulns[i]) + statemachine.Apply(&dependencyVulns[i], ev) events[i] = ev } @@ -81,38 +81,20 @@ func (s *DependencyVulnService) UserFixedDependencyVulns(tx shared.DB, userID st return s.vulnEventRepository.SaveBatchBestEffort(tx, events) } -func (s *DependencyVulnService) UserDetectedExistingVulnOnDifferentBranch(tx shared.DB, scannerID string, dependencyVulns []models.DependencyVuln, alreadyExistingEvents [][]models.VulnEvent, assetVersion models.AssetVersion, asset models.Asset) error { +func (s *DependencyVulnService) UserDetectedExistingVulnOnDifferentBranch(tx shared.DB, scannerID string, dependencyVulns []statemachine.BranchVulnMatch[*models.DependencyVuln], assetVersion models.AssetVersion, asset models.Asset) error { if len(dependencyVulns) == 0 { return nil } - events := make([][]models.VulnEvent, len(dependencyVulns)) + vulns := utils.Map(dependencyVulns, func(el statemachine.BranchVulnMatch[*models.DependencyVuln]) models.DependencyVuln { + return *el.CurrentBranchVuln + }) - for i, dependencyVuln := range dependencyVulns { - // copy all events for this vulnerability - if len(alreadyExistingEvents[i]) != 0 { - events[i] = utils.Map(alreadyExistingEvents[i], func(el models.VulnEvent) models.VulnEvent { - el.VulnID = dependencyVuln.CalculateHash() - el.ID = uuid.Nil - return el - }) - } - // replay all events on the dependencyVuln - // but sort them by the time they were created ascending - slices.SortStableFunc(events[i], func(a, b models.VulnEvent) int { - if a.CreatedAt.Before(b.CreatedAt) { - return -1 - } else if a.CreatedAt.After(b.CreatedAt) { - return 1 - } - return 0 - }) - for _, ev := range events[i] { - ev.Apply(&dependencyVulns[i]) - } - } + events := utils.Map(dependencyVulns, func(el statemachine.BranchVulnMatch[*models.DependencyVuln]) []models.VulnEvent { + return el.EventsToCopy + }) - err := s.dependencyVulnRepository.SaveBatchBestEffort(tx, dependencyVulns) + err := s.dependencyVulnRepository.SaveBatchBestEffort(tx, vulns) if err != nil { return err } @@ -137,7 +119,7 @@ func (s *DependencyVulnService) UserDetectedDependencyVulns(tx shared.DB, artifa riskReport := vulndb.RawRisk(*dependencyVuln.CVE, e, *dependencyVuln.ComponentDepth) ev := models.NewDetectedEvent(dependencyVuln.CalculateHash(), dtos.VulnTypeDependencyVuln, "system", riskReport, artifactName, upstream) // apply the event on the dependencyVuln - ev.Apply(&dependencyVulns[i]) + statemachine.Apply(&dependencyVulns[i], ev) events[i] = ev } @@ -233,7 +215,7 @@ func (s *DependencyVulnService) RecalculateRawRiskAssessment(tx shared.DB, userI if oldRiskAssessment == nil || *oldRiskAssessment != newRiskAssessment.Risk { ev := models.NewRawRiskAssessmentUpdatedEvent(dependencyVuln.CalculateHash(), dtos.VulnTypeDependencyVuln, userID, justification, oldRiskAssessment, newRiskAssessment) // apply the event on the dependencyVuln - ev.Apply(&dependencyVulns[i]) + statemachine.Apply(&dependencyVulns[i], ev) events = append(events, ev) } else { // only update the last calculated time diff --git a/services/first_party_vuln_service.go b/services/first_party_vuln_service.go index 59c53b4a..76d1d8a9 100644 --- a/services/first_party_vuln_service.go +++ b/services/first_party_vuln_service.go @@ -4,14 +4,13 @@ import ( "context" "fmt" "log/slog" - "slices" "time" - "github.com/google/uuid" "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/monitoring" "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/statemachine" "github.com/l3montree-dev/devguard/utils" ) @@ -32,6 +31,8 @@ func NewFirstPartyVulnService(firstPartyVulnRepository shared.FirstPartyVulnRepo } } +var _ shared.FirstPartyVulnService = (*firstPartyVulnService)(nil) + func (s *firstPartyVulnService) UserFixedFirstPartyVulns(tx shared.DB, userID string, firstPartyVulns []models.FirstPartyVuln) error { if len(firstPartyVulns) == 0 { @@ -42,7 +43,7 @@ func (s *firstPartyVulnService) UserFixedFirstPartyVulns(tx shared.DB, userID st for i, vuln := range firstPartyVulns { ev := models.NewFixedEvent(vuln.CalculateHash(), dtos.VulnTypeFirstPartyVuln, userID, vuln.ScannerIDs, dtos.UpstreamStateInternal) - ev.Apply(&firstPartyVulns[i]) + statemachine.Apply(&firstPartyVulns[i], ev) events[i] = ev } @@ -63,7 +64,7 @@ func (s *firstPartyVulnService) UserDetectedFirstPartyVulns(tx shared.DB, userID for i, firstPartyVuln := range firstPartyVulns { ev := models.NewDetectedEvent(firstPartyVuln.CalculateHash(), dtos.VulnTypeFirstPartyVuln, userID, dtos.RiskCalculationReport{}, scannerID, dtos.UpstreamStateInternal) // apply the event on the dependencyVuln - ev.Apply(&firstPartyVulns[i]) + statemachine.Apply(&firstPartyVulns[i], ev) events[i] = ev } @@ -74,56 +75,25 @@ func (s *firstPartyVulnService) UserDetectedFirstPartyVulns(tx shared.DB, userID return s.vulnEventRepository.SaveBatch(tx, events) } -func (s *firstPartyVulnService) UserDetectedExistingFirstPartyVulnOnDifferentBranch(tx shared.DB, scannerID string, firstPartyVulns []models.FirstPartyVuln, alreadyExistingEvents [][]models.VulnEvent, assetVersion models.AssetVersion, asset models.Asset) error { +func (s *firstPartyVulnService) UserDetectedExistingFirstPartyVulnOnDifferentBranch(tx shared.DB, scannerID string, firstPartyVulns []statemachine.BranchVulnMatch[*models.FirstPartyVuln], assetVersion models.AssetVersion, asset models.Asset) error { if len(firstPartyVulns) == 0 { return nil } - events := make([][]models.VulnEvent, len(firstPartyVulns)) + vulns := utils.Map(firstPartyVulns, func(el statemachine.BranchVulnMatch[*models.FirstPartyVuln]) models.FirstPartyVuln { + return *el.CurrentBranchVuln + }) - for i, firstPartyVuln := range firstPartyVulns { - // copy all events for this vulnerability - if len(alreadyExistingEvents[i]) != 0 { - events[i] = utils.Map(alreadyExistingEvents[i], func(el models.VulnEvent) models.VulnEvent { - // Create a proper copy of the event - newEvent := models.VulnEvent{ - Model: models.Model{}, // New model with empty ID and timestamps - Type: el.Type, - VulnID: firstPartyVuln.CalculateHash(), - VulnType: el.VulnType, - UserID: el.UserID, - Justification: el.Justification, - MechanicalJustification: el.MechanicalJustification, - ArbitraryJSONData: el.ArbitraryJSONData, - OriginalAssetVersionName: el.OriginalAssetVersionName, - } - newEvent.ID = uuid.Nil - newEvent.CreatedAt = el.CreatedAt - newEvent.UpdatedAt = time.Now() - return newEvent - }) - } - // replay all events on the firstPartyVuln - // but sort them by the time they were created ascending - slices.SortStableFunc(events[i], func(a, b models.VulnEvent) int { - if a.CreatedAt.Before(b.CreatedAt) { - return -1 - } else if a.CreatedAt.After(b.CreatedAt) { - return 1 - } - return 0 - }) - for _, ev := range events[i] { - ev.Apply(&firstPartyVulns[i]) - } - } + events := utils.Map(firstPartyVulns, func(el statemachine.BranchVulnMatch[*models.FirstPartyVuln]) []models.VulnEvent { + return el.EventsToCopy + }) - err := s.firstPartyVulnRepository.SaveBatch(tx, firstPartyVulns) + err := s.firstPartyVulnRepository.SaveBatchBestEffort(tx, vulns) if err != nil { return err } - return s.vulnEventRepository.SaveBatch(tx, utils.Flat(events)) + return s.vulnEventRepository.SaveBatchBestEffort(tx, utils.Flat(events)) } func (s *firstPartyVulnService) UpdateFirstPartyVulnState(tx shared.DB, userID string, firstPartyVuln *models.FirstPartyVuln, statusType string, justification string, mechanicalJustification dtos.MechanicalJustificationType) (models.VulnEvent, error) { @@ -171,7 +141,7 @@ func (s *firstPartyVulnService) ApplyAndSave(tx shared.DB, firstPartyVuln *model func (s *firstPartyVulnService) applyAndSave(tx shared.DB, firstPartyVuln *models.FirstPartyVuln, ev *models.VulnEvent) (models.VulnEvent, error) { // apply the event on the first-party vuln - ev.Apply(firstPartyVuln) + statemachine.Apply(firstPartyVuln, *ev) // run the updates in the transaction to keep a valid state err := s.firstPartyVulnRepository.Save(tx, firstPartyVuln) diff --git a/services/license_risk_service.go b/services/license_risk_service.go index 4b030ca5..665248e1 100644 --- a/services/license_risk_service.go +++ b/services/license_risk_service.go @@ -10,6 +10,7 @@ import ( "github.com/l3montree-dev/devguard/dtos" component "github.com/l3montree-dev/devguard/licenses" "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/statemachine" "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/utils" @@ -191,7 +192,7 @@ func (s *LicenseRiskService) UserFixedLicenseRisks(tx shared.DB, userID string, events := make([]models.VulnEvent, len(licenseRisks)) for i := range licenseRisks { ev := models.NewFixedEvent(licenseRisks[i].CalculateHash(), dtos.VulnTypeLicenseRisk, userID, "", upstream) - ev.Apply(&licenseRisks[i]) + statemachine.Apply(&licenseRisks[i], ev) events[i] = ev } if err := s.licenseRiskRepository.SaveBatch(tx, licenseRisks); err != nil { @@ -210,7 +211,7 @@ func (s *LicenseRiskService) UserDetectedLicenseRisks(tx shared.DB, assetID uuid // ensure artifact association exists in the object licenseRisks[i].Artifacts = append(licenseRisks[i].Artifacts, models.Artifact{ArtifactName: artifactName, AssetID: assetID, AssetVersionName: assetVersionName}) ev := models.NewDetectedEvent(licenseRisks[i].CalculateHash(), dtos.VulnTypeLicenseRisk, "system", dtos.RiskCalculationReport{}, artifactName, upstream) - ev.Apply(&licenseRisks[i]) + statemachine.Apply(&licenseRisks[i], ev) events[i] = ev } if err := s.licenseRiskRepository.SaveBatch(tx, licenseRisks); err != nil { @@ -260,7 +261,7 @@ func (s *LicenseRiskService) UserDetectedExistingLicenseRiskOnDifferentBranch(tx return 0 }) for _, ev := range events[i] { - ev.Apply(&licenseRisks[i]) + statemachine.Apply(&licenseRisks[i], ev) } } @@ -322,7 +323,7 @@ func (s *LicenseRiskService) UserFixedLicenseRisksByAutomaticRefresh(tx shared.D ev := models.NewLicenseDecisionEvent(licenseRisks[i].CalculateHash(), dtos.VulnTypeLicenseRisk, userID, "Automatically fixed by license refresh", artifactName, licenseRisks[i].NewFinalLicense) events[i] = ev licenseRisksToSave[i] = licenseRisks[i].LicenseRisk - ev.Apply(&licenseRisks[i].LicenseRisk) + statemachine.Apply(&licenseRisks[i].LicenseRisk, ev) } if err := s.licenseRiskRepository.SaveBatch(tx, licenseRisksToSave); err != nil { return err diff --git a/shared/common_interfaces.go b/shared/common_interfaces.go index 2b50e24c..91dad231 100644 --- a/shared/common_interfaces.go +++ b/shared/common_interfaces.go @@ -26,6 +26,7 @@ import ( "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/dtos/sarif" "github.com/l3montree-dev/devguard/normalize" + "github.com/l3montree-dev/devguard/statemachine" "github.com/l3montree-dev/devguard/utils" "github.com/labstack/echo/v4" @@ -342,7 +343,7 @@ type DependencyVulnService interface { RecalculateRawRiskAssessment(tx DB, userID string, dependencyVulns []models.DependencyVuln, justification string, asset models.Asset) ([]models.DependencyVuln, error) UserFixedDependencyVulns(tx DB, userID string, dependencyVulns []models.DependencyVuln, assetVersion models.AssetVersion, asset models.Asset, upstream dtos.UpstreamState) error UserDetectedDependencyVulns(tx DB, artifactName string, dependencyVulns []models.DependencyVuln, assetVersion models.AssetVersion, asset models.Asset, upstream dtos.UpstreamState) error - UserDetectedExistingVulnOnDifferentBranch(tx DB, artifactName string, dependencyVulns []models.DependencyVuln, alreadyExistingEvents [][]models.VulnEvent, assetVersion models.AssetVersion, asset models.Asset) error + UserDetectedExistingVulnOnDifferentBranch(tx DB, artifactName string, dependencyVulns []statemachine.BranchVulnMatch[*models.DependencyVuln], assetVersion models.AssetVersion, asset models.Asset) error UserDetectedDependencyVulnInAnotherArtifact(tx DB, vulnerabilities []models.DependencyVuln, artifactName string) error UserDidNotDetectDependencyVulnInArtifactAnymore(tx DB, vulnerabilities []models.DependencyVuln, artifactName string) error CreateVulnEventAndApply(tx DB, assetID uuid.UUID, userID string, dependencyVuln *models.DependencyVuln, status dtos.VulnEventType, justification string, mechanicalJustification dtos.MechanicalJustificationType, assetVersionName string, upstream dtos.UpstreamState) (models.VulnEvent, error) @@ -382,7 +383,7 @@ type AssetVersionRepository interface { type FirstPartyVulnService interface { UserFixedFirstPartyVulns(tx DB, userID string, firstPartyVulns []models.FirstPartyVuln) error UserDetectedFirstPartyVulns(tx DB, userID string, scannerID string, firstPartyVulns []models.FirstPartyVuln) error - UserDetectedExistingFirstPartyVulnOnDifferentBranch(tx DB, scannerID string, firstPartyVulns []models.FirstPartyVuln, alreadyExistingEvents [][]models.VulnEvent, assetVersion models.AssetVersion, asset models.Asset) error + UserDetectedExistingFirstPartyVulnOnDifferentBranch(tx DB, scannerID string, firstPartyVulns []statemachine.BranchVulnMatch[*models.FirstPartyVuln], assetVersion models.AssetVersion, asset models.Asset) error UpdateFirstPartyVulnState(tx DB, userID string, firstPartyVuln *models.FirstPartyVuln, statusType string, justification string, mechanicalJustification dtos.MechanicalJustificationType) (models.VulnEvent, error) SyncIssues(org models.Org, project models.Project, asset models.Asset, assetVersion models.AssetVersion, vulnList []models.FirstPartyVuln) error SyncAllIssues(org models.Org, project models.Project, asset models.Asset, assetVersion models.AssetVersion) error diff --git a/statemachine/dependency_vuln_statemachine.go b/statemachine/dependency_vuln_statemachine.go new file mode 100644 index 00000000..4ab1229b --- /dev/null +++ b/statemachine/dependency_vuln_statemachine.go @@ -0,0 +1,307 @@ +// Copyright (C) 2025 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 . + +package statemachine + +import ( + "log/slog" + "slices" + "time" + + "github.com/l3montree-dev/devguard/database/models" + "github.com/l3montree-dev/devguard/dtos" + "github.com/l3montree-dev/devguard/utils" +) + +type DependencyVulnStateMachine struct { +} + +type ScanDiff struct { + // Newly discovered vulnerabilities (never seen before) + NewlyDiscovered []models.DependencyVuln + + // Fixed everywhere (no longer detected in any artifact) + FixedEverywhere []models.DependencyVuln + + // First time detected in this specific artifact (but exists elsewhere) + NewInArtifact []models.DependencyVuln + + // No longer detected in this artifact (but still exists elsewhere) + RemovedFromArtifact []models.DependencyVuln + + // Still detected, no changes + Unchanged []models.DependencyVuln +} + +type VulnSet struct { + byHash map[string]models.DependencyVuln +} + +// NewVulnSet creates a new vulnerability set +func NewVulnSet(vulns []models.DependencyVuln) *VulnSet { + set := &VulnSet{ + byHash: make(map[string]models.DependencyVuln, len(vulns)), + } + for _, vuln := range vulns { + set.Add(vuln) + } + return set +} + +// Add adds a vulnerability to the set (deduplicates by hash) +func (s *VulnSet) Add(vuln models.DependencyVuln) { + hash := vuln.CalculateHash() + if _, exists := s.byHash[hash]; !exists { + s.byHash[hash] = vuln + } +} + +// Contains checks if a vulnerability exists in the set +func (s *VulnSet) Contains(vuln models.DependencyVuln) bool { + _, exists := s.byHash[vuln.CalculateHash()] + return exists +} + +// Get retrieves a vulnerability from the set +func (s *VulnSet) Get(vuln models.DependencyVuln) (models.DependencyVuln, bool) { + v, exists := s.byHash[vuln.CalculateHash()] + return v, exists +} + +// isFoundInArtifact checks if a vulnerability is detected in a specific artifact +func isFoundInArtifact(vuln models.DependencyVuln, artifactName string) bool { + for _, artifact := range vuln.Artifacts { + if artifact.ArtifactName == artifactName { + return true + } + } + return false +} + +// isOnlyFoundInArtifact checks if a vulnerability is only detected in a single artifact +func isOnlyFoundInArtifact(vuln models.DependencyVuln, artifactName string) bool { + return len(vuln.Artifacts) == 1 && vuln.Artifacts[0].ArtifactName == artifactName +} + +func DiffScanResults( + artifactName string, + foundVulns []models.DependencyVuln, + existingVulns []models.DependencyVuln, +) ScanDiff { + diff := ScanDiff{ + NewlyDiscovered: make([]models.DependencyVuln, 0), + FixedEverywhere: make([]models.DependencyVuln, 0), + NewInArtifact: make([]models.DependencyVuln, 0), + RemovedFromArtifact: make([]models.DependencyVuln, 0), + Unchanged: make([]models.DependencyVuln, 0), + } + + foundSet := NewVulnSet(foundVulns) + existingSet := NewVulnSet(existingVulns) + + // Process existing vulnerabilities: what disappeared? + for _, existing := range existingVulns { + if !foundSet.Contains(existing) { + // This vulnerability was not found in current scan + if isOnlyFoundInArtifact(existing, artifactName) { + // Fixed everywhere (this was the only artifact reporting it) + diff.FixedEverywhere = append(diff.FixedEverywhere, existing) + } else { + // Fixed only in this artifact (still exists in others) + diff.RemovedFromArtifact = append(diff.RemovedFromArtifact, existing) + } + } else { + // Still exists, nothing changed + diff.Unchanged = append(diff.Unchanged, existing) + } + } + + // Process found vulnerabilities: what's new? + for _, found := range foundVulns { + if existing, wasKnown := existingSet.Get(found); !wasKnown { + // Never seen this vulnerability before + diff.NewlyDiscovered = append(diff.NewlyDiscovered, found) + } else { + // Known vulnerability - check if it's new to this artifact + if !isFoundInArtifact(existing, artifactName) { + // First time seeing it in this artifact + diff.NewInArtifact = append(diff.NewInArtifact, existing) + } + } + } + + return diff +} + +type BranchDiff[T models.Vuln] struct { + // Completely new vulnerabilities (not on any other branch) + NewToAllBranches []T + + // Vulnerabilities that exist on other branches (need event history copied) + ExistingOnOtherBranches []BranchVulnMatch[T] +} + +// BranchVulnMatch represents a vulnerability found on current branch that exists elsewhere +type BranchVulnMatch[T models.Vuln] struct { + // The vulnerability as detected on the current branch + CurrentBranchVuln T + + // The same vulnerability from other branches with their event history + OtherBranchVulns []T + + // Consolidated events from all other branches (ready to copy) + EventsToCopy []models.VulnEvent +} + +// Compare compares vulnerabilities on current branch with other branches +func DiffVulnsBetweenBranches[T models.Vuln]( + currentBranchVulns []T, + otherBranchesVulns []T, +) BranchDiff[T] { + diff := BranchDiff[T]{ + NewToAllBranches: make([]T, 0), + ExistingOnOtherBranches: make([]BranchVulnMatch[T], 0), + } + + // Index other branches' vulnerabilities by hash + otherBranchIndex := indexByHash(otherBranchesVulns) + + // Check each vulnerability on current branch + for _, currentVuln := range currentBranchVulns { + hash := currentVuln.AssetVersionIndependentHash() + + if matchingVulns, existsElsewhere := otherBranchIndex[hash]; existsElsewhere { + // This vuln exists on other branches - create a match + // make sure to update the state of the vulnerability accordingly. + // at least we are in the statemachine here. + events := extractRelevantEvents(matchingVulns) + slices.SortStableFunc(events, func(a, b models.VulnEvent) int { + if a.CreatedAt.Before(b.CreatedAt) { + return -1 + } else if a.CreatedAt.After(b.CreatedAt) { + return 1 + } + return 0 + }) + + for _, ev := range events { + Apply(currentVuln, ev) + } + + match := BranchVulnMatch[T]{ + CurrentBranchVuln: currentVuln, + OtherBranchVulns: matchingVulns, + EventsToCopy: events, + } + + diff.ExistingOnOtherBranches = append(diff.ExistingOnOtherBranches, match) + } else { + // Brand new vulnerability + diff.NewToAllBranches = append(diff.NewToAllBranches, currentVuln) + } + } + + return diff +} + +// indexByHash creates a map of vulnerabilities grouped by their hash +func indexByHash[T models.Vuln](vulns []T) map[string][]T { + index := make(map[string][]T) + + for _, vuln := range vulns { + hash := vuln.AssetVersionIndependentHash() + index[hash] = append(index[hash], vuln) + } + + return index +} + +// extractRelevantEvents consolidates events from all matching vulnerabilities +func extractRelevantEvents[T models.Vuln](vulns []T) []models.VulnEvent { + allEvents := make([]models.VulnEvent, 0) + + for _, vuln := range vulns { + // Filter events: exclude risk assessment updates and already-copied events + relevantEvents := utils.Filter(vuln.GetEvents(), func(ev models.VulnEvent) bool { + return ev.OriginalAssetVersionName == nil && + ev.Type != dtos.EventTypeRawRiskAssessmentUpdated + }) + + // Tag events with their source branch + taggedEvents := utils.Map(relevantEvents, func(event models.VulnEvent) models.VulnEvent { + event.OriginalAssetVersionName = utils.Ptr(vuln.GetAssetVersionName()) + return event + }) + + allEvents = append(allEvents, taggedEvents...) + } + + return allEvents +} + +func Apply(vuln models.Vuln, event models.VulnEvent) { + if event.Upstream != dtos.UpstreamStateInternal && event.Type == dtos.EventTypeAccepted { + // its an external accepted event that should not modify state + return + } + + switch event.Type { + case dtos.EventTypeLicenseDecision: + finalLicenseDecision, ok := (event.GetArbitraryJSONData()["finalLicenseDecision"]).(string) + if !ok { + slog.Error("could not parse final license decision", "dependencyVulnID", + + event.VulnID) + return + } + v := vuln.(*models.LicenseRisk) + v.SetFinalLicenseDecision(finalLicenseDecision) + v.SetState(dtos.VulnStateFixed) + case dtos.EventTypeFixed: + vuln.SetState(dtos.VulnStateFixed) + case dtos.EventTypeReopened: + if event.Upstream == dtos.UpstreamStateExternal { + return + } + vuln.SetState(dtos.VulnStateOpen) + case dtos.EventTypeDetected: + // event type detected will always be applied! + f, ok := (event.GetArbitraryJSONData()["risk"]).(float64) + if !ok { + f = vuln.GetRawRiskAssessment() + } + vuln.SetRawRiskAssessment(f) + vuln.SetRiskRecalculatedAt(time.Now()) + vuln.SetState(dtos.VulnStateOpen) + case dtos.EventTypeAccepted: + vuln.SetState(dtos.VulnStateAccepted) + case dtos.EventTypeFalsePositive: + if event.Upstream == dtos.UpstreamStateExternal { + return + } + vuln.SetState(dtos.VulnStateFalsePositive) + case dtos.EventTypeMarkedForTransfer: + vuln.SetState(dtos.VulnStateMarkedForTransfer) + case dtos.EventTypeRawRiskAssessmentUpdated: + f, ok := (event.GetArbitraryJSONData()["risk"]).(float64) + if !ok { + slog.Error("could not parse risk assessment", "dependencyVulnID", event.VulnID) + return + } + vuln.SetRawRiskAssessment(f) + vuln.SetRiskRecalculatedAt(time.Now()) + } + +} diff --git a/statemachine/dependency_vuln_statemachine_test.go b/statemachine/dependency_vuln_statemachine_test.go new file mode 100644 index 00000000..472b6aaf --- /dev/null +++ b/statemachine/dependency_vuln_statemachine_test.go @@ -0,0 +1,383 @@ +// Copyright (C) 2025 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 . +package statemachine + +import ( + "testing" + + "github.com/google/uuid" + "github.com/l3montree-dev/devguard/database/models" + "github.com/l3montree-dev/devguard/dtos" + "github.com/l3montree-dev/devguard/utils" + "github.com/stretchr/testify/assert" +) + +func TestDiffScanResults(t *testing.T) { + + t.Run("should correctly identify a vulnerability which now gets found by another artifact", func(t *testing.T) { + currentArtifactName := "new-artifact" + + assetID := uuid.New() + assetVersionName := "asset-version-1" + + foundVulnerabilities := []models.DependencyVuln{ + {CVEID: utils.Ptr("CVE-1234"), Vulnerability: models.Vulnerability{AssetVersionName: assetVersionName, AssetID: assetID}}, + } + + artifact := models.Artifact{ArtifactName: "artifact1", AssetVersionName: assetVersionName, AssetID: assetID} + + existingDependencyVulns := []models.DependencyVuln{ + {CVEID: utils.Ptr("CVE-1234"), Vulnerability: models.Vulnerability{ + AssetVersionName: assetVersionName, AssetID: assetID, + }, Artifacts: []models.Artifact{artifact}}, + } + + diff := DiffScanResults(currentArtifactName, foundVulnerabilities, existingDependencyVulns) + + assert.Empty(t, diff.NewlyDiscovered) + assert.Empty(t, diff.FixedEverywhere) + assert.Empty(t, diff.RemovedFromArtifact) + assert.Equal(t, 1, len(diff.Unchanged)) + assert.Equal(t, 1, len(diff.NewInArtifact)) + }) + + t.Run("should correctly identify a vulnerability which now is fixed, since it was not found by the artifact anymore", func(t *testing.T) { + + assetID := uuid.New() + + artifact := models.Artifact{ArtifactName: "artifact1", AssetVersionName: "asset-version-1", AssetID: assetID} + + foundVulnerabilities := []models.DependencyVuln{} + + existingDependencyVulns := []models.DependencyVuln{ + {CVEID: utils.Ptr("CVE-1234"), Vulnerability: models.Vulnerability{}, Artifacts: []models.Artifact{artifact}}, + } + + diff := DiffScanResults(artifact.ArtifactName, foundVulnerabilities, existingDependencyVulns) + + assert.Empty(t, diff.NewlyDiscovered) + assert.Empty(t, diff.Unchanged) + assert.Equal(t, 1, len(diff.FixedEverywhere)) + assert.Empty(t, diff.NewInArtifact) + assert.Empty(t, diff.RemovedFromArtifact) + }) + + t.Run("should correctly identify a vulnerability which is not found in the current artifact anymore", func(t *testing.T) { + currentArtifactName := "new-artifact" + + artifact := models.Artifact{ArtifactName: "artifact1"} + + foundVulnerabilities := []models.DependencyVuln{} + + existingDependencyVulns := []models.DependencyVuln{ + {CVEID: utils.Ptr("CVE-1234"), Vulnerability: models.Vulnerability{}, Artifacts: []models.Artifact{artifact}}, + } + + diff := DiffScanResults(currentArtifactName, foundVulnerabilities, existingDependencyVulns) + + assert.Empty(t, diff.NewlyDiscovered) + assert.Empty(t, diff.FixedEverywhere) + assert.Empty(t, diff.Unchanged) + assert.Empty(t, diff.NewInArtifact) + assert.Equal(t, 1, len(diff.RemovedFromArtifact)) + }) + + t.Run("should identify new vulnerabilities", func(t *testing.T) { + currentArtifactName := "new-artifact" + + foundVulnerabilities := []models.DependencyVuln{ + {CVEID: utils.Ptr("CVE-1234")}, + {CVEID: utils.Ptr("CVE-5678")}, + } + + existingDependencyVulns := []models.DependencyVuln{} + + diff := DiffScanResults(currentArtifactName, foundVulnerabilities, existingDependencyVulns) + + assert.Equal(t, 2, len(diff.NewlyDiscovered)) + assert.Empty(t, diff.FixedEverywhere) + assert.Empty(t, diff.Unchanged) + assert.Empty(t, diff.NewInArtifact) + assert.Empty(t, diff.RemovedFromArtifact) + }) + + t.Run("BUG: should NOT incorrectly identify artifact removal when artifact ID contains colon and is substring of existing artifact", func(t *testing.T) { + + currentArtifactName := "container-scanning" + + artifact := models.Artifact{ArtifactName: "artifact1"} + + foundVulnerabilities := []models.DependencyVuln{ + {CVEID: utils.Ptr("CVE-1234")}, + } + + existingDependencyVulns := []models.DependencyVuln{ + {CVEID: utils.Ptr("CVE-1234"), Vulnerability: models.Vulnerability{}, Artifacts: []models.Artifact{artifact}}, + } + + diff := DiffScanResults(currentArtifactName, foundVulnerabilities, existingDependencyVulns) + + assert.Equal(t, 1, len(diff.Unchanged)) + assert.Empty(t, diff.NewlyDiscovered, "Should be empty - this is a new detection by current artifact") + assert.Empty(t, diff.FixedEverywhere, "Should be empty - no vulnerabilities are fixed") + assert.Equal(t, 1, len(diff.NewInArtifact), "Should detect that current artifact found existing vulnerability for first time") + assert.Empty(t, diff.RemovedFromArtifact, "BUG: Should be empty - current artifact was never detecting this vulnerability before!") + }) +} + +func TestDiffVulnsBetweenBranches(t *testing.T) { + + t.Run("should copy events when vuln exists on other branch", func(t *testing.T) { + assetID := uuid.New() + + foundVulnerabilities := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0001"), + Vulnerability: models.Vulnerability{ + ID: "vuln-1", + AssetVersionName: "feature-branch", + AssetID: assetID, + Events: []models.VulnEvent{}, + }, + }, + } + + existingDependencyVulns := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0001"), + Vulnerability: models.Vulnerability{ + ID: "vuln-2", + AssetVersionName: "main", + AssetID: assetID, + Events: []models.VulnEvent{{Type: dtos.EventTypeDetected}, + {Type: dtos.EventTypeComment}}, + }, + Artifacts: []models.Artifact{{ArtifactName: "artifact1", AssetVersionName: "feature-branch", AssetID: assetID}, + {ArtifactName: "artifact2", AssetVersionName: "feature-branch", AssetID: assetID}}, + }, + } + + diffResult := DiffVulnsBetweenBranches(utils.Map(foundVulnerabilities, utils.Ptr), utils.Map(existingDependencyVulns, utils.Ptr)) + + assert.Empty(t, diffResult.NewToAllBranches) + assert.Len(t, diffResult.ExistingOnOtherBranches, 1) + assert.Len(t, diffResult.ExistingOnOtherBranches[0].EventsToCopy, 2) + }) + + t.Run("should identify new vulnerabilities not on other branch", func(t *testing.T) { + foundVulnerabilities := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0001"), + Vulnerability: models.Vulnerability{ + AssetVersionName: "feature-branch", + }, + }, + { + CVEID: utils.Ptr("CVE-2023-0002"), + Vulnerability: models.Vulnerability{ + AssetVersionName: "feature-branch", + }, + }, + } + + existingDependencyVulns := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0003"), + Vulnerability: models.Vulnerability{ + AssetVersionName: "main", + }, + }, + } + + diffResult := DiffVulnsBetweenBranches(utils.Map(foundVulnerabilities, utils.Ptr), utils.Map(existingDependencyVulns, utils.Ptr)) + + newDetectedVulnsNotOnOtherBranch := diffResult.NewToAllBranches + newDetectedButOnOtherBranchExisting := diffResult.ExistingOnOtherBranches + + assert.Len(t, newDetectedVulnsNotOnOtherBranch, 2) + assert.Empty(t, newDetectedButOnOtherBranchExisting) + assert.Equal(t, "CVE-2023-0001", *newDetectedVulnsNotOnOtherBranch[0].CVEID) + assert.Equal(t, "CVE-2023-0002", *newDetectedVulnsNotOnOtherBranch[1].CVEID) + }) + + t.Run("should identify vulnerabilities that exist on other branch", func(t *testing.T) { + foundVulnerabilities := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0001"), + Vulnerability: models.Vulnerability{ + AssetVersionName: "feature-branch", + }, + }, + } + + existingDependencyVulns := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0001"), + Vulnerability: models.Vulnerability{ + AssetVersionName: "main", + Events: []models.VulnEvent{ + { + Type: dtos.EventTypeAccepted, + }, + }, + }, + }, + } + + diffResult := DiffVulnsBetweenBranches(utils.Map(foundVulnerabilities, utils.Ptr), utils.Map(existingDependencyVulns, utils.Ptr)) + + assert.Empty(t, diffResult.NewToAllBranches) + assert.Len(t, diffResult.ExistingOnOtherBranches, 1) + assert.Len(t, diffResult.ExistingOnOtherBranches[0].EventsToCopy, 1) + assert.Equal(t, "CVE-2023-0001", *(*diffResult.ExistingOnOtherBranches[0].CurrentBranchVuln).CVEID) + assert.Equal(t, "main", *diffResult.ExistingOnOtherBranches[0].EventsToCopy[0].OriginalAssetVersionName) + }) + + t.Run("should handle multiple vulnerabilities with same CVE on other branch", func(t *testing.T) { + foundVulnerabilities := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0001"), + Vulnerability: models.Vulnerability{ + AssetVersionName: "feature-branch", + }, + }, + } + + existingDependencyVulns := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0001"), + Vulnerability: models.Vulnerability{ + AssetVersionName: "main", + Events: []models.VulnEvent{ + { + Type: dtos.EventTypeComment, + }, + }, + }, + }, + { + CVEID: utils.Ptr("CVE-2023-0001"), + Vulnerability: models.Vulnerability{ + AssetVersionName: "develop", + Events: []models.VulnEvent{ + { + Type: dtos.EventTypeComment, + }, + }, + }, + }, + } + + diffResult := DiffVulnsBetweenBranches(utils.Map(foundVulnerabilities, utils.Ptr), utils.Map(existingDependencyVulns, utils.Ptr)) + + assert.Empty(t, diffResult.NewToAllBranches) + assert.Len(t, diffResult.ExistingOnOtherBranches, 1) + + assert.Len(t, diffResult.ExistingOnOtherBranches[0].EventsToCopy, 2) // combined events from both existing vulns + assert.Equal(t, "main", *diffResult.ExistingOnOtherBranches[0].EventsToCopy[0].OriginalAssetVersionName) + assert.Equal(t, "develop", *diffResult.ExistingOnOtherBranches[0].EventsToCopy[1].OriginalAssetVersionName) + }) + + t.Run("should filter out events that were already copied", func(t *testing.T) { + foundVulnerabilities := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0001"), + Vulnerability: models.Vulnerability{ + AssetVersionName: "feature-branch", + }, + }, + } + + existingDependencyVulns := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0001"), + Vulnerability: models.Vulnerability{ + AssetVersionName: "main", + Events: []models.VulnEvent{ + { + Type: dtos.EventTypeDetected, + OriginalAssetVersionName: nil, // original event + }, + { + Type: dtos.EventTypeAccepted, + OriginalAssetVersionName: utils.Ptr("other-branch"), // already copied event + }, + }, + }, + }, + } + + diffResult := DiffVulnsBetweenBranches(utils.Map(foundVulnerabilities, utils.Ptr), utils.Map(existingDependencyVulns, utils.Ptr)) + + assert.Empty(t, diffResult.NewToAllBranches) + assert.Len(t, diffResult.ExistingOnOtherBranches, 1) + + assert.Len(t, diffResult.ExistingOnOtherBranches[0].EventsToCopy, 1) // only the original event, not the copied one + assert.Equal(t, dtos.EventTypeDetected, diffResult.ExistingOnOtherBranches[0].EventsToCopy[0].Type) + assert.Equal(t, "main", *diffResult.ExistingOnOtherBranches[0].EventsToCopy[0].OriginalAssetVersionName) + }) + + t.Run("should handle mixed scenario with new and existing vulnerabilities", func(t *testing.T) { + foundVulnerabilities := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0001"), // new vuln + Vulnerability: models.Vulnerability{ + AssetVersionName: "feature-branch", + }, + }, + { + CVEID: utils.Ptr("CVE-2023-0002"), // exists on other branch + Vulnerability: models.Vulnerability{ + AssetVersionName: "feature-branch", + }, + }, + { + CVEID: utils.Ptr("CVE-2023-0003"), // new vuln + Vulnerability: models.Vulnerability{ + AssetVersionName: "feature-branch", + }, + }, + } + + existingDependencyVulns := []models.DependencyVuln{ + { + CVEID: utils.Ptr("CVE-2023-0002"), + Vulnerability: models.Vulnerability{ + AssetVersionName: "main", + Events: []models.VulnEvent{ + { + Type: dtos.EventTypeDetected, + }, + }, + }, + }, + } + + diffResult := DiffVulnsBetweenBranches(utils.Map(foundVulnerabilities, utils.Ptr), utils.Map(existingDependencyVulns, utils.Ptr)) + + assert.Len(t, diffResult.NewToAllBranches, 2) + assert.Len(t, diffResult.ExistingOnOtherBranches, 1) + + // check new vulnerabilities + newCVEs := []string{*diffResult.NewToAllBranches[0].CVEID, *diffResult.NewToAllBranches[1].CVEID} + assert.Contains(t, newCVEs, "CVE-2023-0001") + assert.Contains(t, newCVEs, "CVE-2023-0003") + + // check existing vulnerability + assert.Equal(t, "CVE-2023-0002", *diffResult.ExistingOnOtherBranches[0].CurrentBranchVuln.CVEID) + assert.Len(t, diffResult.ExistingOnOtherBranches[0].EventsToCopy, 1) + assert.Equal(t, "main", *diffResult.ExistingOnOtherBranches[0].EventsToCopy[0].OriginalAssetVersionName) + }) +} diff --git a/utils/slice.go b/utils/slice.go index bb78a510..ab7dbfd2 100644 --- a/utils/slice.go +++ b/utils/slice.go @@ -43,6 +43,17 @@ func Map[T, U any](s []T, f func(T) U) []U { return r } +func DereferenceSlice[T any](s []*T) []T { + r := make([]T, 0, len(s)) + for _, v := range s { + if v == nil { + continue + } + r = append(r, *v) + } + return r +} + func Reduce[T, U any](s []T, f func(U, T) U, init U) U { r := init for _, v := range s {