diff --git a/dtos/path_pattern_test.go b/dtos/path_pattern_test.go index 14fcad59..f5af206b 100644 --- a/dtos/path_pattern_test.go +++ b/dtos/path_pattern_test.go @@ -34,7 +34,7 @@ func TestIsWildcard(t *testing.T) { {"literal pkg:npm/foo", "pkg:npm/foo@1.0.0", false}, {"empty string", "", false}, {"triple star", "***", false}, - {"ROOT is wildcard", normalize.GraphRootNodeID, true}, + {"ROOT is not a wildcard", normalize.GraphRootNodeID, false}, } for _, tt := range tests { @@ -96,10 +96,15 @@ func TestRootPathPattern(t *testing.T) { path []string expected bool }{ - {"ROOT matches ROOT", PathPattern{normalize.GraphRootNodeID}, []string{normalize.GraphRootNodeID}, true}, - {"ROOT matches any path with ROOT at end", PathPattern{normalize.GraphRootNodeID}, []string{"A", "B", normalize.GraphRootNodeID}, true}, - {"ROOT DOES match path without ROOT", PathPattern{normalize.GraphRootNodeID}, []string{"A", "B", "C"}, true}, - {"ROOT does not lead to all paths matching", PathPattern{normalize.GraphRootNodeID, "X"}, []string{"A", "B", "C"}, false}, + // ROOT is a stop marker: [root, pkg:A] matches only direct dependencies. + // VulnerabilityPath never contains ROOT, so ROOT in the pattern consumes + // zero path elements and anchors the match to position 0 (no suffix scan). + {"root pkg:A matches direct dependency", PathPattern{normalize.GraphRootNodeID, "pkg:A"}, []string{"pkg:A"}, true}, + {"root pkg:A does not match transitive dependency", PathPattern{normalize.GraphRootNodeID, "pkg:A"}, []string{"pkg:B", "pkg:A"}, false}, + // [*, ROOT, pkg:A] is equivalent to [ROOT, pkg:A]: ROOT absorbs the wildcard prefix. + {"wildcard root pkg:A matches direct dependency", PathPattern{"*", normalize.GraphRootNodeID, "pkg:A"}, []string{"pkg:A"}, true}, + {"wildcard root pkg:A does not match transitive dependency", PathPattern{"*", normalize.GraphRootNodeID, "pkg:A"}, []string{"pkg:B", "pkg:A"}, false}, + {"wildcard ROOT pkg matches direct dependency path", PathPattern{"*", normalize.GraphRootNodeID, "pkg:golang/go-jose@v4"}, []string{"pkg:golang/go-jose@v4"}, true}, } for _, tt := range tests { diff --git a/dtos/vex_rule_dto.go b/dtos/vex_rule_dto.go index fcb33032..149fb338 100644 --- a/dtos/vex_rule_dto.go +++ b/dtos/vex_rule_dto.go @@ -37,10 +37,7 @@ type PathPattern []string // IsWildcard returns true if the element is a wildcard (*). func IsWildcard(elem string) bool { - // ROOT is a special element in the path - // this means, THE CURRENT application does not call the vulnerable code - // therefore, we can just replace it with a wildcard for matching purposes - return elem == PathPatternWildcard || elem == normalize.GraphRootNodeID + return elem == PathPatternWildcard } // MatchesSuffix checks if the given path's suffix matches this pattern using suffix matching. @@ -57,6 +54,14 @@ func (p PathPattern) MatchesSuffix(path []string) bool { return true } + // ROOT is a stop marker meaning "direct dependency only". When the pattern + // contains ROOT, skip suffix scanning and match the full path from position 0. + for _, elem := range p { + if elem == normalize.GraphRootNodeID { + return matchPatternExact(p, path) + } + } + // For suffix matching, we try increasingly longer suffixes // Count non-wildcard elements to determine minimum suffix length minLen := 0 @@ -98,6 +103,15 @@ func matchPatternExact(pattern, path []string) bool { return true } + // If the next pattern element is ROOT (stop marker), try a zero-length + // match for this wildcard. [*, ROOT, pkg:A] is equivalent to [ROOT, pkg:A]: + // ROOT anchors the remainder to the current path position. + if pattern[pIdx+1] == normalize.GraphRootNodeID { + if matchPatternExact(pattern[pIdx+1:], path[pathIdx:]) { + return true + } + } + // Try to find the next pattern element in the path nextPattern := pattern[pIdx+1] @@ -125,6 +139,13 @@ func matchPatternExact(pattern, path []string) bool { return pathIdx == len(path) } + // ROOT is a stop marker — it is never stored in VulnerabilityPath, + // so skip it in the pattern without consuming a path element. + if pattern[pIdx] == normalize.GraphRootNodeID { + pIdx++ + continue + } + // Literal match if pathIdx >= len(path) || pattern[pIdx] != path[pathIdx] { return false