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 {