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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/devguard-cli/hashmigrations/hash_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 0 additions & 22 deletions controllers/dependency_vuln_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 0 additions & 61 deletions database/repositories/dependency_vuln_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion integrations/commonint/integration_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 16 additions & 9 deletions integrations/commonint/integration_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)

})
}
Expand Down
140 changes: 0 additions & 140 deletions normalize/path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:")
}
})
}
21 changes: 19 additions & 2 deletions normalize/purl.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package normalize

import (
"fmt"
"log/slog"
"net/url"
"slices"
"strings"

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())

}
Loading
Loading