diff --git a/cmd/devguard-cli/hashmigrations/hash_migration.go b/cmd/devguard-cli/hashmigrations/hash_migration.go index 991d366ed..41803ee11 100644 --- a/cmd/devguard-cli/hashmigrations/hash_migration.go +++ b/cmd/devguard-cli/hashmigrations/hash_migration.go @@ -620,7 +620,7 @@ func runVulnerabilityPathHashMigration(pool *pgxpool.Pool) error { sbom := normalize.SBOMGraphFromComponents(utils.MapType[normalize.GraphComponent](componentDeps), nil) for _, oldVuln := range vulns { - paths := sbom.FindAllPathsToPURL(oldVuln.ComponentPurl, 0) + paths := sbom.FindAllComponentOnlyPathsToPURL(oldVuln.ComponentPurl, 0) if len(paths) == 0 { slog.Warn("No SBOM paths found for vulnerable component, using empty path", diff --git a/controllers/dependency_vuln_controller.go b/controllers/dependency_vuln_controller.go index 2be0c3678..26b14420d 100644 --- a/controllers/dependency_vuln_controller.go +++ b/controllers/dependency_vuln_controller.go @@ -111,28 +111,6 @@ func (controller DependencyVulnController) ListByProjectPaged(ctx shared.Context })) } -func (controller DependencyVulnController) ListByAssetIDWithoutHandledExternalEventsPaged(ctx shared.Context) error { - asset := shared.GetAsset(ctx) - assetVersion := shared.GetAssetVersion(ctx) - - pagedResp, err := controller.dependencyVulnRepository.ListByAssetIDWithoutHandledExternalEvents( - asset.ID, - assetVersion.Name, - shared.GetPageInfo(ctx), - ctx.QueryParam("search"), - shared.GetFilterQuery(ctx), - shared.GetSortQuery(ctx), - ) - - if err != nil { - return echo.NewHTTPError(500, "could not get dependencyVulns").WithInternal(err) - } - - return ctx.JSON(200, pagedResp.Map(func(dependencyVuln models.DependencyVuln) any { - return transformer.DependencyVulnToDetailedDTO(dependencyVuln) - })) -} - // @Summary List dependency vulnerabilities // @Tags Vulnerabilities // @Security CookieAuth diff --git a/database/repositories/dependency_vuln_repository.go b/database/repositories/dependency_vuln_repository.go index b7187465e..295bcfacb 100644 --- a/database/repositories/dependency_vuln_repository.go +++ b/database/repositories/dependency_vuln_repository.go @@ -110,67 +110,6 @@ func (repository *dependencyVulnRepository) GetDependencyVulnsByDefaultAssetVers return dependencyVulns, nil } -func (repository *dependencyVulnRepository) ListByAssetIDWithoutHandledExternalEvents(assetID uuid.UUID, assetVersionName string, pageInfo shared.PageInfo, search string, filter []shared.FilterQuery, sort []shared.SortQuery) (shared.Paged[models.DependencyVuln], error) { - var dependencyVulns = []models.DependencyVuln{} - - // Get all dependency vulns that have events with upstream=2 but no events with upstream=1 - q := repository.Repository.GetDB(repository.db).Model(&models.DependencyVuln{}). - Preload("Artifacts"). - Preload("Events", func(db *gorm.DB) *gorm.DB { - return db.Order("created_at ASC") - }). - Joins("CVE"). - Preload("CVE.Exploits"). - Joins("LEFT JOIN artifact_dependency_vulns ON artifact_dependency_vulns.dependency_vuln_id = dependency_vulns.id"). - Where(`asset_id = ? AND asset_version_name = ? AND EXISTS ( - SELECT 1 FROM vuln_events ve1 - WHERE ve1.vuln_id = dependency_vulns.id - AND ve1.upstream = 2 AND NOT EXISTS ( - SELECT 1 FROM vuln_events ve2 - WHERE ve2.vuln_id = dependency_vulns.id - AND ve2.created_at > ve1.created_at - AND (ve2.upstream = 1 OR (ve2.upstream = 0 AND ve2.type IN ?)) - ) - )`, assetID, assetVersionName, []string{ - string(dtos.EventTypeAccepted), - string(dtos.EventTypeFalsePositive), - }) - - // apply filters - for _, f := range filter { - q = q.Where(f.SQL(), f.Value()) - } - - // apply search - if search != "" && len(search) > 2 { - q = q.Where("(\"CVE\".description ILIKE ? OR dependency_vulns.cve_id ILIKE ? OR component_purl ILIKE ?)", "%"+search+"%", "%"+search+"%", "%"+search+"%") - } - - // apply sorting - if len(sort) > 0 { - for _, s := range sort { - q = q.Order(s.SQL()) - } - } else { - q = q.Order("dependency_vulns.cve_id DESC") - } - - // count total results - var count int64 - err := q.Count(&count).Error - if err != nil { - return shared.Paged[models.DependencyVuln]{}, err - } - - // apply pagination - err = q.Limit(pageInfo.PageSize).Offset((pageInfo.Page - 1) * pageInfo.PageSize).Find(&dependencyVulns).Error - if err != nil { - return shared.Paged[models.DependencyVuln]{}, err - } - - return shared.NewPaged(pageInfo, count, dependencyVulns), nil -} - func (repository *dependencyVulnRepository) ListByAssetAndAssetVersion(assetVersionName string, assetID uuid.UUID) ([]models.DependencyVuln, error) { var dependencyVulns = []models.DependencyVuln{} if err := repository.Repository.GetDB(repository.db).Preload("Artifacts").Preload("CVE").Preload("CVE.Exploits").Preload("Events", func(db *gorm.DB) *gorm.DB { diff --git a/integrations/commonint/integration_helper.go b/integrations/commonint/integration_helper.go index e92cc4594..34771bccb 100644 --- a/integrations/commonint/integration_helper.go +++ b/integrations/commonint/integration_helper.go @@ -410,7 +410,7 @@ func RenderPathToComponent(componentRepository shared.ComponentRepository, asset bom := normalize.SBOMGraphFromComponents(utils.MapType[normalize.GraphComponent](components), nil) - paths := bom.FindAllPathsToPURL(pURL, 0) + paths := bom.FindAllComponentOnlyPathsToPURL(pURL, 0) // we want to show fake nodes in the mermaid graph (root, artifact, info sources) pathWithFakeNodes := make([][]string, 0, len(paths)) for _, path := range paths { diff --git a/integrations/commonint/integration_helper_test.go b/integrations/commonint/integration_helper_test.go index 7541baa42..99ffadae5 100644 --- a/integrations/commonint/integration_helper_test.go +++ b/integrations/commonint/integration_helper_test.go @@ -54,10 +54,12 @@ func TestRenderPathToComponent(t *testing.T) { }) t.Run("Everything works as expeted with a non empty component list", func(t *testing.T) { + // Create a chain of actual components (all with pkg: prefix) to have a path with edges components := []models.ComponentDependency{ - {ComponentID: nil, DependencyID: "artifact:test-artifact", Dependency: models.Component{ID: "artifact:test-artifact"}}, // root --> artifact - {ComponentID: utils.Ptr("artifact:test-artifact"), DependencyID: "sbom:test@test-artifact", Dependency: models.Component{ID: "sbom:test@test-artifact"}}, - {ComponentID: utils.Ptr("sbom:test@test-artifact"), DependencyID: "pkg:npm/test-package@1.0.0", Dependency: models.Component{ID: "pkg:npm/test-package@1.0.0"}}, + {ComponentID: nil, DependencyID: "artifact:test-artifact", Dependency: models.Component{ID: "artifact:test-artifact"}}, // root --> artifact + {ComponentID: utils.Ptr("artifact:test-artifact"), DependencyID: "sbom:test@test-artifact", Dependency: models.Component{ID: "sbom:test@test-artifact"}}, // artifact -> sbom + {ComponentID: utils.Ptr("sbom:test@test-artifact"), DependencyID: "pkg:npm/root-dep@1.0.0", Dependency: models.Component{ID: "pkg:npm/root-dep@1.0.0"}}, // sbom -> root-dep (component) + {ComponentID: utils.Ptr("pkg:npm/root-dep@1.0.0"), DependencyID: "pkg:npm/test-package@1.0.0", Dependency: models.Component{ID: "pkg:npm/test-package@1.0.0"}}, // root-dep -> test-package (component) } componentRepository := mocks.NewComponentRepository(t) componentRepository.On("LoadComponents", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(components, nil) @@ -72,15 +74,18 @@ func TestRenderPathToComponent(t *testing.T) { t.Fail() } - //String for the empty graph + 1 node being root with a linebreak - assert.Equal(t, "```mermaid \n %%{init: { 'theme':'base', 'themeVariables': {\n'primaryColor': '#F3F3F3',\n'primaryTextColor': '#0D1117',\n'primaryBorderColor': '#999999',\n'lineColor': '#999999',\n'secondaryColor': '#ffffff',\n'tertiaryColor': '#ffffff'\n} }}%%\n flowchart TD\nroot([\"Root\"]) --- artifact_test_artifact([\"test-artifact\"])\nartifact_test_artifact([\"test-artifact\"]) --- sbom_test_test_artifact([\"SBOM (test)\"])\nsbom_test_test_artifact([\"SBOM (test)\"]) --- pkg_npm_test_package_1_0_0([\"pkg:npm/test-package\\@1.0.0\"])\n\nclassDef default stroke-width:2px\n```\n", result) + // FindAllComponentOnlyPathsToPURL only returns component-only paths (nodes starting with pkg:) + // The path should be: root-dep -> test-package + assert.Equal(t, "```mermaid \n %%{init: { 'theme':'base', 'themeVariables': {\n'primaryColor': '#F3F3F3',\n'primaryTextColor': '#0D1117',\n'primaryBorderColor': '#999999',\n'lineColor': '#999999',\n'secondaryColor': '#ffffff',\n'tertiaryColor': '#ffffff'\n} }}%%\n flowchart TD\npkg_npm_root_dep_1_0_0([\"pkg:npm/root-dep\\@1.0.0\"]) --- pkg_npm_test_package_1_0_0([\"pkg:npm/test-package\\@1.0.0\"])\n\nclassDef default stroke-width:2px\n```\n", result) }) t.Run("should escape @ symbols", func(t *testing.T) { + // Create a chain of actual components to verify @ escaping in mermaid output components := []models.ComponentDependency{ - {ComponentID: nil, DependencyID: "artifact:test-artifact", Dependency: models.Component{ID: "artifact:test-artifact"}}, // root --> artifact - {ComponentID: utils.Ptr("artifact:test-artifact"), DependencyID: "sbom:test@test-artifact", Dependency: models.Component{ID: "sbom:test@test-artifact"}}, - {ComponentID: utils.Ptr("sbom:test@test-artifact"), DependencyID: "pkg:npm/test-package@1.0.0", Dependency: models.Component{ID: "pkg:npm/test-package@1.0.0"}}, + {ComponentID: nil, DependencyID: "artifact:test-artifact", Dependency: models.Component{ID: "artifact:test-artifact"}}, // root --> artifact + {ComponentID: utils.Ptr("artifact:test-artifact"), DependencyID: "sbom:test@test-artifact", Dependency: models.Component{ID: "sbom:test@test-artifact"}}, // artifact -> sbom + {ComponentID: utils.Ptr("sbom:test@test-artifact"), DependencyID: "pkg:npm/root-dep@1.0.0", Dependency: models.Component{ID: "pkg:npm/root-dep@1.0.0"}}, // sbom -> root-dep (component) + {ComponentID: utils.Ptr("pkg:npm/root-dep@1.0.0"), DependencyID: "pkg:npm/test-package@1.0.0", Dependency: models.Component{ID: "pkg:npm/test-package@1.0.0"}}, // root-dep -> test-package (component) } componentRepository := mocks.NewComponentRepository(t) componentRepository.On("LoadComponents", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(components, nil) @@ -95,7 +100,9 @@ func TestRenderPathToComponent(t *testing.T) { t.Fail() } - assert.Equal(t, "```mermaid \n %%{init: { 'theme':'base', 'themeVariables': {\n'primaryColor': '#F3F3F3',\n'primaryTextColor': '#0D1117',\n'primaryBorderColor': '#999999',\n'lineColor': '#999999',\n'secondaryColor': '#ffffff',\n'tertiaryColor': '#ffffff'\n} }}%%\n flowchart TD\nroot([\"Root\"]) --- artifact_test_artifact([\"test-artifact\"])\nartifact_test_artifact([\"test-artifact\"]) --- sbom_test_test_artifact([\"SBOM (test)\"])\nsbom_test_test_artifact([\"SBOM (test)\"]) --- pkg_npm_test_package_1_0_0([\"pkg:npm/test-package\\@1.0.0\"])\n\nclassDef default stroke-width:2px\n```\n", result) + // Verify @ symbols are escaped as \@ in the mermaid output + assert.Contains(t, result, "\\@1.0.0") + assert.Equal(t, "```mermaid \n %%{init: { 'theme':'base', 'themeVariables': {\n'primaryColor': '#F3F3F3',\n'primaryTextColor': '#0D1117',\n'primaryBorderColor': '#999999',\n'lineColor': '#999999',\n'secondaryColor': '#ffffff',\n'tertiaryColor': '#ffffff'\n} }}%%\n flowchart TD\npkg_npm_root_dep_1_0_0([\"pkg:npm/root-dep\\@1.0.0\"]) --- pkg_npm_test_package_1_0_0([\"pkg:npm/test-package\\@1.0.0\"])\n\nclassDef default stroke-width:2px\n```\n", result) }) } diff --git a/normalize/path_test.go b/normalize/path_test.go index 6e922d522..0b0d4d3be 100644 --- a/normalize/path_test.go +++ b/normalize/path_test.go @@ -63,143 +63,3 @@ func TestPathString(t *testing.T) { assert.Equal(t, "pkg:npm/express@4.18.0", result) }) } - -func TestPathToStringSliceComponentOnly(t *testing.T) { - t.Run("filters out ROOT node", func(t *testing.T) { - path := normalize.Path{"ROOT", "pkg:npm/lodash@4.17.21"} - - result := path.ToStringSliceComponentOnly() - - assert.Len(t, result, 1) - assert.Equal(t, "pkg:npm/lodash@4.17.21", result[0]) - }) - - t.Run("filters out root node (lowercase)", func(t *testing.T) { - path := normalize.Path{"root", "pkg:npm/lodash@4.17.21"} - - result := path.ToStringSliceComponentOnly() - - assert.Len(t, result, 1) - assert.Equal(t, "pkg:npm/lodash@4.17.21", result[0]) - }) - - t.Run("filters out artifact nodes", func(t *testing.T) { - path := normalize.Path{"ROOT", "artifact:my-app", "pkg:npm/lodash@4.17.21"} - - result := path.ToStringSliceComponentOnly() - - assert.Len(t, result, 1) - assert.Equal(t, "pkg:npm/lodash@4.17.21", result[0]) - }) - - t.Run("filters out sbom info source nodes", func(t *testing.T) { - path := normalize.Path{"ROOT", "artifact:my-app", "sbom:package-lock.json", "pkg:npm/lodash@4.17.21"} - - result := path.ToStringSliceComponentOnly() - - assert.Len(t, result, 1) - assert.Equal(t, "pkg:npm/lodash@4.17.21", result[0]) - }) - - t.Run("filters out vex info source nodes", func(t *testing.T) { - path := normalize.Path{"ROOT", "artifact:my-app", "vex:security-report.json", "pkg:npm/lodash@4.17.21"} - - result := path.ToStringSliceComponentOnly() - - assert.Len(t, result, 1) - assert.Equal(t, "pkg:npm/lodash@4.17.21", result[0]) - }) - - t.Run("filters out csaf info source nodes", func(t *testing.T) { - path := normalize.Path{"ROOT", "artifact:my-app", "csaf:advisory.json", "pkg:npm/lodash@4.17.21"} - - result := path.ToStringSliceComponentOnly() - - assert.Len(t, result, 1) - assert.Equal(t, "pkg:npm/lodash@4.17.21", result[0]) - }) - - t.Run("keeps only component PURLs in deep path", func(t *testing.T) { - path := normalize.Path{ - "ROOT", - "artifact:my-app", - "sbom:package-lock.json", - "pkg:npm/express@4.18.0", - "pkg:npm/body-parser@1.20.0", - "pkg:npm/lodash@4.17.21", - } - - result := path.ToStringSliceComponentOnly() - - assert.Len(t, result, 3) - assert.Equal(t, "pkg:npm/express@4.18.0", result[0]) - assert.Equal(t, "pkg:npm/body-parser@1.20.0", result[1]) - assert.Equal(t, "pkg:npm/lodash@4.17.21", result[2]) - }) - - t.Run("returns empty slice when path only contains fake nodes", func(t *testing.T) { - path := normalize.Path{"ROOT", "artifact:my-app", "sbom:package-lock.json"} - - result := path.ToStringSliceComponentOnly() - - assert.Len(t, result, 0) - }) - - t.Run("returns empty slice for empty path", func(t *testing.T) { - path := normalize.Path{} - - result := path.ToStringSliceComponentOnly() - - assert.Len(t, result, 0) - }) - - t.Run("handles mixed golang and npm purls", func(t *testing.T) { - path := normalize.Path{ - "ROOT", - "artifact:app", - "sbom:go.mod", - "pkg:golang/github.com/gin-gonic/gin@1.9.0", - "pkg:golang/golang.org/x/net@0.10.0", - } - - result := path.ToStringSliceComponentOnly() - - assert.Len(t, result, 2) - assert.Equal(t, "pkg:golang/github.com/gin-gonic/gin@1.9.0", result[0]) - assert.Equal(t, "pkg:golang/golang.org/x/net@0.10.0", result[1]) - }) -} - -func TestPathIntegration(t *testing.T) { - t.Run("full path lifecycle", func(t *testing.T) { - // Create a realistic path as it would be saved in the database - fullPath := normalize.Path{ - "ROOT", - "artifact:web-app", - "sbom:package-lock.json", - "pkg:npm/next@14.2.13", - "pkg:npm/react@18.2.0", - "pkg:npm/scheduler@0.23.0", - } - - // Get all nodes - allNodes := fullPath.ToStringSlice() - assert.Len(t, allNodes, 6) - assert.Contains(t, allNodes, "ROOT") - assert.Contains(t, allNodes, "artifact:web-app") - - // Get component-only nodes for hash calculation or depth - componentNodes := fullPath.ToStringSliceComponentOnly() - assert.Len(t, componentNodes, 3) - assert.Equal(t, "pkg:npm/next@14.2.13", componentNodes[0]) - assert.Equal(t, "pkg:npm/react@18.2.0", componentNodes[1]) - assert.Equal(t, "pkg:npm/scheduler@0.23.0", componentNodes[2]) - - // Verify fake nodes are filtered out - for _, node := range componentNodes { - assert.NotEqual(t, "ROOT", node) - assert.False(t, len(node) > 9 && node[:9] == "artifact:") - assert.False(t, len(node) > 5 && node[:5] == "sbom:") - } - }) -} diff --git a/normalize/purl.go b/normalize/purl.go index 4542d62f9..df7e6aa9f 100644 --- a/normalize/purl.go +++ b/normalize/purl.go @@ -2,6 +2,8 @@ package normalize import ( "fmt" + "log/slog" + "net/url" "slices" "strings" @@ -63,7 +65,11 @@ func ParsePurlForMatching(purl packageurl.PackageURL) *PurlMatchContext { // Create search key (purl without version) purl.Version = "" purl.Qualifiers = nil - searchPurl := purl.ToString() + searchPurl, err := PURLToString(purl) + if err != nil { + slog.Warn("failed to unescape purl for matching", "purl", purl.ToString(), "err", err) + searchPurl = purl.ToString() + } return &PurlMatchContext{ SearchPurl: searchPurl, @@ -91,7 +97,12 @@ func BeautifyPURL(pURL string) (string, error) { func ToPurlWithoutVersion(purl packageurl.PackageURL) string { purl.Version = "" purl.Qualifiers = nil - return purl.ToString() + purlString, err := PURLToString(purl) + if err != nil { + slog.Warn("failed to unescape purl without version", "purl", purl.ToString(), "err", err) + return purl.ToString() + } + return purlString } // ref: https://github.com/google/osv.dev/blob/a751ceb26522f093edf26c0ad167cfd0967716d9/osv/purl_helpers.py @@ -185,3 +196,9 @@ func QualifiersMapToString(qualifiers map[string]string) string { } return qualifiersStr } + +func PURLToString(purl packageurl.PackageURL) (string, error) { + + return url.PathUnescape(purl.ToString()) + +} diff --git a/normalize/sbom_graph.go b/normalize/sbom_graph.go index a1c0657cd..0d63c0327 100644 --- a/normalize/sbom_graph.go +++ b/normalize/sbom_graph.go @@ -50,18 +50,6 @@ func (p Path) String() string { return strings.Join(p, ",") } -// ToStringSliceComponentOnly returns only the component nodes (PURLs), -// filtering out structural nodes like "root", "artifact:...", and info sources. -func (p Path) ToStringSliceComponentOnly() []string { - filtered := make([]string, 0, len(p)) - for _, node := range p { - if isComponentNodeID(node) { - filtered = append(filtered, node) - } - } - return filtered -} - // ============================================================================= // NODE TYPES // ============================================================================= @@ -222,13 +210,19 @@ func (g *SBOMGraph) AddInfoSource(artifactID, sourceID string, sourceType InfoSo // AddComponent adds a component node. func (g *SBOMGraph) AddComponent(comp cdx.Component) string { + if comp.PackageURL != "" { + // Unescape URL-encoded characters (e.g., %2B -> +) to match the format stored in the database + unescapedPurl, err := url.PathUnescape(comp.PackageURL) + if err == nil { + comp.PackageURL = unescapedPurl + } + } if g.nodes[comp.BOMRef] == nil { g.nodes[comp.BOMRef] = &GraphNode{ BOMRef: comp.BOMRef, Type: GraphNodeTypeComponent, Component: &comp, } - g.edges[comp.BOMRef] = make(map[string]struct{}) } return comp.BOMRef } @@ -861,101 +855,13 @@ func (g *SBOMGraph) FindAllComponentOnlyPathsToPURL(purl string, limit int) []Pa } } } - - return paths -} - -// FindAllPathsToPURL finds all paths from root to a target component. -// Returns full paths including structural nodes (ROOT, artifacts, info sources). -// Each unique full path is returned separately, even if multiple paths lead to the same -// component through different artifacts or info sources. -// Uses BFS to find paths in order of increasing length (shortest first). -// If limit > 0, stops early once `limit` paths are found. -func (g *SBOMGraph) FindAllPathsToPURL(purl string, limit int) []Path { - // Find the target node ID - var targetID string - for node := range g.Components() { - if node.Component != nil && strings.EqualFold(node.Component.PackageURL, purl) { - targetID = node.BOMRef - break - } - } - - if targetID == "" { - return nil - } - - // Build reverse edge map (child -> parents) for backward traversal - // Sort parent IDs for deterministic traversal order - reverseEdges := make(map[string][]string) - for parent, children := range g.edges { - for child := range children { - reverseEdges[child] = append(reverseEdges[child], parent) - } - } - // Sort each parent list for deterministic order - for child := range reverseEdges { - slices.Sort(reverseEdges[child]) - } - - // Use BFS to find paths in order of increasing length - // This allows us to stop early once we have enough paths - var paths []Path - seen := make(map[string]bool) // For path deduplication - - // Queue holds partial paths (stored in reverse: target first, growing toward root) - type queueItem struct { - path []string - onPath map[string]bool // Track nodes in current path to detect cycles - } - queue := []queueItem{{ - path: []string{targetID}, - onPath: map[string]bool{targetID: true}, - }} - - for len(queue) > 0 { - // Check if we've reached the limit - if limit > 0 && len(paths) >= limit { - break - } - - current := queue[0] - queue = queue[1:] - - lastNode := current.path[len(current.path)-1] - - // Check if we've reached root (termination condition) - if lastNode == g.rootID { - // Build path in correct order (root to target) - result := make([]string, len(current.path)) - for i, j := 0, len(current.path)-1; j >= 0; i, j = i+1, j-1 { - result[i] = current.path[j] - } - key := strings.Join(result, "|") - if !seen[key] { - seen[key] = true - paths = append(paths, Path(result)) - } - continue - } - - // Get parents of the last node and extend paths - for _, parentID := range reverseEdges[lastNode] { - // Cycle detection - if current.onPath[parentID] { - continue - } - - // Extend path - newPath := make([]string, len(current.path)+1) - copy(newPath, current.path) - newPath[len(current.path)] = parentID - newOnPath := make(map[string]bool, len(current.onPath)+1) - for k, v := range current.onPath { - newOnPath[k] = v + // translate each path and path entry to the package purl of that component + for i, path := range paths { + for j, nodeID := range path { + node := g.nodes[nodeID] + if node != nil && node.Component != nil && node.Component.PackageURL != "" { + paths[i][j] = node.Component.PackageURL } - newOnPath[parentID] = true - queue = append(queue, queueItem{path: newPath, onPath: newOnPath}) } } diff --git a/normalize/sbom_graph_test.go b/normalize/sbom_graph_test.go index 4200bc155..c55aeffdf 100644 --- a/normalize/sbom_graph_test.go +++ b/normalize/sbom_graph_test.go @@ -596,7 +596,7 @@ func TestMergeComplex(t *testing.T) { } -func TestFindAllPathsToPURL(t *testing.T) { +func TestFindAllComponentOnlyPathsToPURL(t *testing.T) { t.Run("multiple information sources pointing to same component - should return multiple paths", func(t *testing.T) { g := NewSBOMGraph() @@ -622,17 +622,13 @@ func TestFindAllPathsToPURL(t *testing.T) { g.AddEdge(infoSource2, compID) // Find all paths to the component - paths := g.FindAllPathsToPURL("pkg:npm/lodash@4.17.21", 0) + paths := g.FindAllComponentOnlyPathsToPURL("pkg:npm/lodash@4.17.21", 0) - // Should return two paths (one through each info source) - assert.Len(t, paths, 2, "Should return separate paths for each info source") - - // Both paths should have the same component-only representation - assert.Equal(t, paths[0].ToStringSliceComponentOnly(), paths[1].ToStringSliceComponentOnly()) - assert.Equal(t, []string{"pkg:npm/lodash@4.17.21"}, paths[0].ToStringSliceComponentOnly()) + // Should return a single path (reachable through different info sources) + assert.Len(t, paths, 1, "Should return separate paths for each info source") }) - t.Run("multiple artifacts pointing to same component - should return multiple paths", func(t *testing.T) { + t.Run("multiple artifacts pointing to same component - should return a single same path", func(t *testing.T) { g := NewSBOMGraph() // Add two different artifacts @@ -658,14 +654,10 @@ func TestFindAllPathsToPURL(t *testing.T) { g.AddEdge(infoSource2, compID) // Find all paths to the component - paths := g.FindAllPathsToPURL("pkg:npm/express@4.18.0", 0) + paths := g.FindAllComponentOnlyPathsToPURL("pkg:npm/express@4.18.0", 0) // Should return two paths (one through each artifact) - assert.Len(t, paths, 2, "Should return separate paths through different artifacts") - - // Both paths should have the same component-only representation - assert.Equal(t, paths[0].ToStringSliceComponentOnly(), paths[1].ToStringSliceComponentOnly()) - assert.Equal(t, []string{"pkg:npm/express@4.18.0"}, paths[0].ToStringSliceComponentOnly()) + assert.Len(t, paths, 1, "Should return separate paths through different artifacts") }) t.Run("multiple dependency paths to same component - should return multiple paths", func(t *testing.T) { @@ -712,7 +704,7 @@ func TestFindAllPathsToPURL(t *testing.T) { g.AddEdge(depBID, targetID) // Find all paths to the target component - paths := g.FindAllPathsToPURL("pkg:npm/target@1.0.0", 0) + paths := g.FindAllComponentOnlyPathsToPURL("pkg:npm/target@1.0.0", 0) // Should return two different paths through different dependencies assert.Len(t, paths, 2, "Should return multiple paths when there are different dependency chains") @@ -721,8 +713,8 @@ func TestFindAllPathsToPURL(t *testing.T) { path1 := []string{"pkg:npm/dep-a@1.0.0", "pkg:npm/target@1.0.0"} path2 := []string{"pkg:npm/dep-b@1.0.0", "pkg:npm/target@1.0.0"} - assert.Contains(t, [][]string{paths[0].ToStringSliceComponentOnly(), paths[1].ToStringSliceComponentOnly()}, path1, "Should contain path through dep-a") - assert.Contains(t, [][]string{paths[0].ToStringSliceComponentOnly(), paths[1].ToStringSliceComponentOnly()}, path2, "Should contain path through dep-b") + assert.Contains(t, [][]string{paths[0], paths[1]}, path1, "Should contain path through dep-a") + assert.Contains(t, [][]string{paths[0], paths[1]}, path2, "Should contain path through dep-b") }) t.Run("component not found - should return empty paths", func(t *testing.T) { @@ -740,7 +732,7 @@ func TestFindAllPathsToPURL(t *testing.T) { g.AddEdge(infoSource, compID) // Search for non-existent component - paths := g.FindAllPathsToPURL("pkg:npm/non-existent@1.0.0", 0) + paths := g.FindAllComponentOnlyPathsToPURL("pkg:npm/non-existent@1.0.0", 0) assert.Len(t, paths, 0, "Should return empty paths for non-existent component") }) @@ -784,16 +776,11 @@ func TestFindAllPathsToPURL(t *testing.T) { g.AddEdge(compCID, compDID) // Find path to the deepest component - paths := g.FindAllPathsToPURL("pkg:npm/d@1.0.0", 0) + paths := g.FindAllComponentOnlyPathsToPURL("pkg:npm/d@1.0.0", 0) assert.Len(t, paths, 1) - expectedPath := []string{ - "pkg:npm/a@1.0.0", - "pkg:npm/b@1.0.0", - "pkg:npm/c@1.0.0", - "pkg:npm/d@1.0.0", - } - assert.Equal(t, expectedPath, paths[0].ToStringSliceComponentOnly(), "Should return complete dependency chain") + expectedPath := Path([]string{"pkg:npm/a@1.0.0", "pkg:npm/b@1.0.0", "pkg:npm/c@1.0.0", "pkg:npm/d@1.0.0"}) + assert.Equal(t, expectedPath, paths[0], "Should return the complete dependency chain") }) t.Run("limit should stop early and return shortest paths first", func(t *testing.T) { @@ -840,17 +827,17 @@ func TestFindAllPathsToPURL(t *testing.T) { g.AddEdge(dep3ID, targetID) // Without limit, should return all 3 paths - allPaths := g.FindAllPathsToPURL("pkg:npm/target@1.0.0", 0) + allPaths := g.FindAllComponentOnlyPathsToPURL("pkg:npm/target@1.0.0", 0) assert.Len(t, allPaths, 3, "Should return all 3 paths without limit") // With limit=1, should return only the shortest path - limitedPaths := g.FindAllPathsToPURL("pkg:npm/target@1.0.0", 1) + limitedPaths := g.FindAllComponentOnlyPathsToPURL("pkg:npm/target@1.0.0", 1) assert.Len(t, limitedPaths, 1, "Should return only 1 path with limit=1") // The shortest path is the direct one - assert.Equal(t, []string{"pkg:npm/target@1.0.0"}, limitedPaths[0].ToStringSliceComponentOnly()) + assert.Equal(t, Path([]string{"pkg:npm/target@1.0.0"}), limitedPaths[0]) // With limit=2, should return 2 shortest paths - limitedPaths2 := g.FindAllPathsToPURL("pkg:npm/target@1.0.0", 2) + limitedPaths2 := g.FindAllComponentOnlyPathsToPURL("pkg:npm/target@1.0.0", 2) assert.Len(t, limitedPaths2, 2, "Should return only 2 paths with limit=2") }) @@ -1238,3 +1225,143 @@ func TestToMinimalTree(t *testing.T) { assert.Empty(t, tree.Dependencies[""]) }) } + +func TestAddComponent_URLUnescaping(t *testing.T) { + t.Run("should unescape URL-encoded plus sign in version", func(t *testing.T) { + g := NewSBOMGraph() + + // PURL with URL-encoded + (%2B) in version + encodedPurl := "pkg:deb/debian/libpam0g@1.4.0-9%2Bdeb11u2?arch=amd64&distro=debian-11.11" + expectedPurl := "pkg:deb/debian/libpam0g@1.4.0-9+deb11u2?arch=amd64&distro=debian-11.11" + + comp := cdx.Component{ + BOMRef: encodedPurl, + Name: "libpam0g", + Version: "1.4.0-9+deb11u2", + PackageURL: encodedPurl, + Type: cdx.ComponentTypeLibrary, + } + + g.AddComponent(comp) + + // Verify the component was added with unescaped PURL + node := g.nodes[encodedPurl] + assert.NotNil(t, node) + assert.Equal(t, expectedPurl, node.Component.PackageURL) + }) + + t.Run("should preserve already unescaped plus sign", func(t *testing.T) { + g := NewSBOMGraph() + + // PURL with literal + in version (already unescaped) + purl := "pkg:deb/debian/libpam0g@1.4.0-9+deb11u2?arch=amd64&distro=debian-11.11" + + comp := cdx.Component{ + BOMRef: purl, + Name: "libpam0g", + Version: "1.4.0-9+deb11u2", + PackageURL: purl, + Type: cdx.ComponentTypeLibrary, + } + + g.AddComponent(comp) + + // Verify the component was added with the same PURL (no change) + node := g.nodes[purl] + assert.NotNil(t, node) + assert.Equal(t, purl, node.Component.PackageURL) + }) + + t.Run("should unescape multiple URL-encoded characters", func(t *testing.T) { + g := NewSBOMGraph() + + // PURL with multiple URL-encoded characters + encodedPurl := "pkg:deb/debian/libpam0g@1.4.0-9%2Bdeb11u2%2Bsecurity?arch=amd64&distro=debian-11.11" + expectedPurl := "pkg:deb/debian/libpam0g@1.4.0-9+deb11u2+security?arch=amd64&distro=debian-11.11" + + comp := cdx.Component{ + BOMRef: encodedPurl, + Name: "libpam0g", + Version: "1.4.0-9+deb11u2+security", + PackageURL: encodedPurl, + Type: cdx.ComponentTypeLibrary, + } + + g.AddComponent(comp) + + node := g.nodes[encodedPurl] + assert.NotNil(t, node) + assert.Equal(t, expectedPurl, node.Component.PackageURL) + }) + + t.Run("should handle empty PackageURL", func(t *testing.T) { + g := NewSBOMGraph() + + comp := cdx.Component{ + BOMRef: "some-ref", + Name: "test-component", + Version: "1.0.0", + PackageURL: "", + Type: cdx.ComponentTypeLibrary, + } + + g.AddComponent(comp) + + node := g.nodes["some-ref"] + assert.NotNil(t, node) + assert.Equal(t, "", node.Component.PackageURL) + }) + + t.Run("dependency vuln should have correct purl with plus sign", func(t *testing.T) { + g := NewSBOMGraph() + artifactID := g.AddArtifact("test-artifact") + infoSourceID := g.AddInfoSource(artifactID, "test-sbom", InfoSourceSBOM) + + // Add component with URL-encoded PURL + encodedPurl := "pkg:deb/debian/libpam0g@1.4.0-9%2Bdeb11u2?arch=amd64&distro=debian-11.11" + expectedPurl := "pkg:deb/debian/libpam0g@1.4.0-9+deb11u2?arch=amd64&distro=debian-11.11" + + comp := cdx.Component{ + BOMRef: encodedPurl, + Name: "libpam0g", + Version: "1.4.0-9+deb11u2", + PackageURL: encodedPurl, + Type: cdx.ComponentTypeLibrary, + } + + compID := g.AddComponent(comp) + g.AddEdge(infoSourceID, compID) + + // Add vulnerability affecting this component + vuln := cdx.Vulnerability{ + ID: "CVE-2024-1234", + Affects: &[]cdx.Affects{ + {Ref: encodedPurl}, + }, + } + g.AddVulnerability(vuln) + + // Verify component has correct unescaped PURL + var foundComponent *GraphNode + for node := range g.Components() { + if node.BOMRef == encodedPurl { + foundComponent = node + break + } + } + assert.NotNil(t, foundComponent) + assert.Equal(t, expectedPurl, foundComponent.Component.PackageURL) + + // Verify vulnerability is stored and component PURL matches what would be used for dependency vuln + var foundVuln *cdx.Vulnerability + for v := range g.Vulnerabilities() { + if v.ID == "CVE-2024-1234" { + foundVuln = v + break + } + } + assert.NotNil(t, foundVuln) + assert.NotNil(t, foundVuln.Affects) + assert.Len(t, *foundVuln.Affects, 1) + }) +} diff --git a/router/dependency_vuln_router.go b/router/dependency_vuln_router.go index 1b1eccd10..5ee89cf72 100644 --- a/router/dependency_vuln_router.go +++ b/router/dependency_vuln_router.go @@ -32,7 +32,6 @@ func NewDependencyVulnRouter( ) DependencyVulnRouter { dependencyVulnRouter := assetVersionGroup.Group.Group("/dependency-vulns") dependencyVulnRouter.GET("/", dependencyVulnController.ListPaged) - dependencyVulnRouter.GET("/sync/", dependencyVulnController.ListByAssetIDWithoutHandledExternalEventsPaged) dependencyVulnRouter.GET("/:dependencyVulnID/", dependencyVulnController.Read) dependencyVulnRouter.GET("/:dependencyVulnID/events/", vulnEventController.ReadAssetEventsByVulnID) dependencyVulnRouter.GET("/:dependencyVulnID/hints/", dependencyVulnController.Hints) diff --git a/services/csaf_service.go b/services/csaf_service.go index d734cb41a..f9476321a 100644 --- a/services/csaf_service.go +++ b/services/csaf_service.go @@ -274,6 +274,11 @@ func (service csafService) GetVexFromCsafProvider(purl packageurl.PackageURL, re }) })) + purlString, err := normalize.PURLToString(purl) + if err != nil { + slog.Warn("failed to unescape purl for cdx bom", "purl", purl.ToString(), "err", err) + purlString = purl.ToString() + } // now build a simple cyclonedx vex bom bom := &cyclonedx.BOM{ SpecVersion: cyclonedx.SpecVersion1_6, @@ -291,10 +296,10 @@ func (service csafService) GetVexFromCsafProvider(purl packageurl.PackageURL, re BOMRef: "root", }, { - BOMRef: purl.ToString(), + BOMRef: purlString, Type: cyclonedx.ComponentTypeApplication, - PackageURL: purl.ToString(), - Name: purl.ToString(), + PackageURL: purlString, + Name: purlString, Version: purl.Version, }, }, @@ -302,11 +307,11 @@ func (service csafService) GetVexFromCsafProvider(purl packageurl.PackageURL, re { Ref: "root", Dependencies: &[]string{ - purl.ToString(), + purlString, }, }, { - Ref: purl.ToString(), + Ref: purlString, Dependencies: &dependencyPurls, }, }, @@ -349,12 +354,16 @@ func convertCsafVulnToCdxVuln(productID gocsaf.ProductID, affectedPurl packageur } } } - + purlString, err := normalize.PURLToString(affectedPurl) + if err != nil { + slog.Warn("failed to unescape purl for cdx vuln", "purl", affectedPurl.ToString(), "err", err) + purlString = affectedPurl.ToString() + } cdxVuln := cyclonedx.Vulnerability{ ID: string(*vuln.CVE), Affects: utils.Ptr([]cyclonedx.Affects{ { - Ref: affectedPurl.ToString(), + Ref: purlString, }, }), Analysis: &cyclonedx.VulnerabilityAnalysis{ diff --git a/services/vex_rule_service.go b/services/vex_rule_service.go index ed0296913..ffb646db1 100644 --- a/services/vex_rule_service.go +++ b/services/vex_rule_service.go @@ -384,11 +384,18 @@ func (s *VEXRuleService) parseVEXRulesInBOM(assetID uuid.UUID, assetVersionName // now create the path pattern var pathPattern dtos.PathPattern + + purlString, err := normalize.PURLToString(purl) + if err != nil { + slog.Info("failed to unescape purl for path pattern, continuing anyway", "purl", purl.String(), "error", err) + purlString = purl.String() + } + if componentPurl.String() != "" { - pathPattern = dtos.PathPattern{componentPurl.String(), dtos.PathPatternWildcard, purl.ToString()} + pathPattern = dtos.PathPattern{componentPurl.String(), dtos.PathPatternWildcard, purlString} } else { // If no metadata component PURL, use the affected package directly - pathPattern = dtos.PathPattern{purl.ToString()} + pathPattern = dtos.PathPattern{purlString} } rule := models.VEXRule{ diff --git a/shared/common_interfaces.go b/shared/common_interfaces.go index 2ee1fafa0..0b40a5e2c 100644 --- a/shared/common_interfaces.go +++ b/shared/common_interfaces.go @@ -227,8 +227,6 @@ type DependencyVulnRepository interface { GetDependencyVulnsByOtherAssetVersions(tx DB, assetVersionName string, assetID uuid.UUID) ([]models.DependencyVuln, error) GetAllVulnsByArtifact(tx DB, artifact models.Artifact) ([]models.DependencyVuln, error) GetAllVulnsForTagsAndDefaultBranchInAsset(tx DB, assetID uuid.UUID, excludedStates []dtos.VulnState) ([]models.DependencyVuln, error) - ListByAssetIDWithoutHandledExternalEvents(assetID uuid.UUID, assetVersionName string, pageInfo PageInfo, search string, filter []FilterQuery, sort []SortQuery) (Paged[models.DependencyVuln], error) - // regardless of path. Used for applying status changes to all instances of a CVE+component combination. FindByCVEAndComponentPurl(tx DB, assetID uuid.UUID, cveID string, componentPurl string) ([]models.DependencyVuln, error) } diff --git a/transformer/dependency_vuln_transformer.go b/transformer/dependency_vuln_transformer.go index 1c113deb5..e9f92e3b1 100644 --- a/transformer/dependency_vuln_transformer.go +++ b/transformer/dependency_vuln_transformer.go @@ -16,7 +16,7 @@ package transformer import ( - "net/url" + "log/slog" "strings" "github.com/google/uuid" @@ -155,7 +155,11 @@ func VulnInPackageToDependencyVulns(vuln models.VulnInPackage, sbom *normalize.S func VulnInPackageToDependencyVulnsWithoutArtifact(vuln models.VulnInPackage, sbom *normalize.SBOMGraph, assetID uuid.UUID, assetVersionName string) []models.DependencyVuln { v := vuln // Unescape URL-encoded characters (e.g., %2B -> +) to match the format stored in the database - stringPurl, _ := url.PathUnescape(v.Purl.ToString()) + stringPurl, err := normalize.PURLToString(v.Purl) + if err != nil { + slog.Info("failed to unescape purl for dependency vuln transformer, continuing anyway", "purl", v.Purl.String(), "error", err) + stringPurl = v.Purl.String() + } fixedVersion := normalize.FixFixedVersion(stringPurl, v.FixedVersion) // Find all paths to this vulnerable component @@ -186,7 +190,7 @@ func VulnInPackageToDependencyVulnsWithoutArtifact(vuln models.VulnInPackage, sb // Create one DependencyVuln per path (pre-allocate with known capacity) result := make([]models.DependencyVuln, 0, len(paths)) for _, path := range paths { - componentPath := path.ToStringSliceComponentOnly() + componentPath := path key := strings.Join(componentPath, ",") if seen[key] { continue