diff --git a/go/internal/website/vulnerability.go b/go/internal/website/vulnerability.go index c12d8e431ee..109b49e04e4 100644 --- a/go/internal/website/vulnerability.go +++ b/go/internal/website/vulnerability.go @@ -186,6 +186,7 @@ func (s *Server) handleVulnerabilityDetails(w http.ResponseWriter, r *http.Reque KnownIDs: knownIDs, UpstreamHierarchy: ConstructHierarchyHTML(vuln.GetId(), upstreamHierarchy, knownIDs), DownstreamHierarchy: ConstructHierarchyHTML(vuln.GetId(), downstreamHierarchy, knownIDs), + RelatedHTML: ConstructRelatedHTML(vuln.GetId(), vuln.GetRelated(), knownIDs), } s.render(w, r, "vulnerability.html", http.StatusOK, &data) diff --git a/go/internal/website/vulnerability_helpers.go b/go/internal/website/vulnerability_helpers.go index 3527a855cc2..8c68015b7a2 100644 --- a/go/internal/website/vulnerability_helpers.go +++ b/go/internal/website/vulnerability_helpers.go @@ -110,61 +110,199 @@ func ParseDatabaseSpecificKVs(s *structpb.Struct) []DatabaseSpecificKV { return kvs } -// ConstructHierarchyHTML formats a models.Hierarchy into a template.HTML tree string. -func ConstructHierarchyHTML(targetID string, hierarchy *models.Hierarchy, knownIDs map[string]struct{}) template.HTML { - if hierarchy == nil || len(hierarchy.Roots) == 0 { - return "" +// ExtractPrefix returns the prefix of an ID (everything before the first '-'). +// If the ID does not contain a '-', or starts with '-', it returns the entire ID. +func ExtractPrefix(id string) string { + prefix, _, found := strings.Cut(id, "-") + if !found || prefix == "" { + return id } - var sb strings.Builder - visited := make(map[string]bool) + return prefix +} + +func sortStringsCaseInsensitive(s []string) { + slices.SortFunc(s, func(a, b string) int { + if c := strings.Compare(strings.ToLower(a), strings.ToLower(b)); c != 0 { + return c + } + + return strings.Compare(a, b) + }) +} + +// VulnTree represents a hierarchical group of vulnerability IDs, +// where top-level keys are prefixes (everything before the first '-'), +// second-level keys are root vulnerability IDs, +// and further levels are recursive child vulnerability IDs. +type VulnTree map[string]VulnTree + +// BuildVulnTree builds a VulnTree grouped by prefix (everything before the first '-') +// at the top level, with root vulnerability IDs at the second level, and recursive +// child IDs underneath based on the provided graph. +// targetID is excluded from roots and children. +func BuildVulnTree(targetID string, roots []string, graph map[string][]string) VulnTree { + if len(roots) == 0 { + return nil + } + + seenRoots := make(map[string]struct{}, len(roots)) + var validRoots []string + for _, r := range roots { + if r == "" || r == targetID { + continue + } + if _, seen := seenRoots[r]; !seen { + seenRoots[r] = struct{}{} + validRoots = append(validRoots, r) + } + } + if len(validRoots) == 0 { + return nil + } - var printSubtree func(vulnID string) - printSubtree = func(vulnID string) { - if visited[vulnID] { - return + var buildSubtree func(id string, visited map[string]bool) VulnTree + buildSubtree = func(id string, visited map[string]bool) VulnTree { + children := VulnTree{} + if graph == nil { + return children } - visited[vulnID] = true - defer func() { - delete(visited, vulnID) - }() - - if vulnID != targetID { - escapedID := template.HTMLEscapeString(vulnID) - if _, known := knownIDs[vulnID]; known { - fmt.Fprintf(&sb, `
  • %s
  • `, escapedID, escapedID) - } else { - fmt.Fprintf(&sb, "
  • %s
  • ", escapedID) + for _, child := range graph[id] { + if child == "" || child == targetID || visited[child] { + continue + } + if _, exists := children[child]; exists { + continue } + visited[child] = true + children[child] = buildSubtree(child, visited) + delete(visited, child) + } + + return children + } + + tree := VulnTree{} + for _, root := range validRoots { + p := ExtractPrefix(root) + if tree[p] == nil { + tree[p] = VulnTree{} } + visited := map[string]bool{root: true} + tree[p][root] = buildSubtree(root, visited) + } + + if len(tree) == 0 { + return nil + } - if children, exists := hierarchy.Graph[vulnID]; exists && len(children) > 0 { - sortedChildren := slices.Clone(children) - slices.Sort(sortedChildren) + return tree +} + +// RenderVulnTreeHTML converts a VulnTree nested map into formatted template.HTML. +// If any prefix has >= 2 roots, all prefixes are rendered as collapsible
    elements. +// Otherwise, all entries are rendered as loose list items. +func RenderVulnTreeHTML(tree VulnTree, knownIDs map[string]struct{}) template.HTML { + if len(tree) == 0 { + return "" + } - for _, child := range sortedChildren { - if child != targetID && !visited[child] { - sb.WriteString(``) - } + shouldCollapseAll := false + for _, roots := range tree { + if len(roots) >= 2 { + shouldCollapseAll = true + break + } + } + + sortedPrefixes := make([]string, 0, len(tree)) + for p := range tree { + sortedPrefixes = append(sortedPrefixes, p) + } + sortStringsCaseInsensitive(sortedPrefixes) + + var sb strings.Builder + + var renderNode func(id string, children VulnTree) + renderNode = func(id string, children VulnTree) { + escapedID := template.HTMLEscapeString(id) + if _, known := knownIDs[id]; known { + fmt.Fprintf(&sb, `
  • %s
  • `, url.PathEscape(id), escapedID) + } else { + fmt.Fprintf(&sb, "
  • %s
  • ", escapedID) + } + + if len(children) > 0 { + childIDs := make([]string, 0, len(children)) + for cID := range children { + childIDs = append(childIDs, cID) + } + sortStringsCaseInsensitive(childIDs) + + for _, cID := range childIDs { + sb.WriteString(``) } } } - sortedRoots := slices.Clone(hierarchy.Roots) - slices.Sort(sortedRoots) + type prefixGroup struct { + prefix string + rootIDs []string + } + + groups := make([]prefixGroup, 0, len(sortedPrefixes)) + for _, p := range sortedPrefixes { + roots := tree[p] + if len(roots) == 0 { + continue + } - for _, root := range sortedRoots { + rootIDs := make([]string, 0, len(roots)) + for id := range roots { + rootIDs = append(rootIDs, id) + } + sortStringsCaseInsensitive(rootIDs) + groups = append(groups, prefixGroup{prefix: p, rootIDs: rootIDs}) + } + + if shouldCollapseAll { + for _, g := range groups { + fmt.Fprintf(&sb, `
    %s (%d)
    `) + } + } else { sb.WriteString(``) } - //nolint:gosec // Hierarchy IDs are explicitly HTML escaped via template.HTMLEscapeString + //nolint:gosec // IDs and prefixes are explicitly HTML escaped via template.HTMLEscapeString return template.HTML(sb.String()) } +// ConstructHierarchyHTML formats a models.Hierarchy into a template.HTML tree string. +func ConstructHierarchyHTML(targetID string, hierarchy *models.Hierarchy, knownIDs map[string]struct{}) template.HTML { + if hierarchy == nil { + return "" + } + + return RenderVulnTreeHTML(BuildVulnTree(targetID, hierarchy.Roots, hierarchy.Graph), knownIDs) +} + +// ConstructRelatedHTML formats a list of related vulnerability IDs into a template.HTML string. +func ConstructRelatedHTML(targetID string, related []string, knownIDs map[string]struct{}) template.HTML { + return RenderVulnTreeHTML(BuildVulnTree(targetID, related, nil), knownIDs) +} + // GitCommitLink converts a repository URL and commit hash or tag into a web viewer URL. func GitCommitLink(repoURL, commit string) string { if repoURL == "" || commit == "" || commit == "0" { diff --git a/go/internal/website/vulnerability_helpers_test.go b/go/internal/website/vulnerability_helpers_test.go new file mode 100644 index 00000000000..19232e57018 --- /dev/null +++ b/go/internal/website/vulnerability_helpers_test.go @@ -0,0 +1,198 @@ +package website + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestExtractPrefix(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"CVE-2024-1234", "CVE"}, + {"GHSA-pwqr-wmgm-9rr8", "GHSA"}, + {"CGA-22r2-246h-mrq8", "CGA"}, + {"openSUSE-SU-2024:1234", "openSUSE"}, + {"NO_HYPHEN", "NO_HYPHEN"}, + {"-LEADING", "-LEADING"}, + {"", ""}, + } + + for _, tt := range tests { + got := ExtractPrefix(tt.input) + if got != tt.want { + t.Errorf("ExtractPrefix(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestBuildVulnTree(t *testing.T) { + tests := []struct { + name string + targetID string + roots []string + graph map[string][]string + want VulnTree + }{ + { + name: "nil roots", + targetID: "CVE-1", + roots: nil, + want: nil, + }, + { + name: "empty roots", + targetID: "CVE-1", + roots: []string{}, + want: nil, + }, + { + name: "only targetID in roots", + targetID: "CVE-1", + roots: []string{"CVE-1"}, + want: nil, + }, + { + name: "only empty strings in roots", + targetID: "CVE-1", + roots: []string{"", ""}, + want: nil, + }, + { + name: "single root loose item", + targetID: "CVE-1", + roots: []string{"GHSA-1234"}, + want: VulnTree{ + "GHSA": { + "GHSA-1234": {}, + }, + }, + }, + { + name: "multiple roots under same prefix", + targetID: "CVE-1", + roots: []string{"CGA-2", "CGA-1", "CGA-3"}, + want: VulnTree{ + "CGA": { + "CGA-1": {}, + "CGA-2": {}, + "CGA-3": {}, + }, + }, + }, + { + name: "deduplicates root IDs", + targetID: "CVE-1", + roots: []string{"CGA-1", "CGA-1"}, + want: VulnTree{ + "CGA": { + "CGA-1": {}, + }, + }, + }, + { + name: "multiple prefixes", + targetID: "TARGET", + roots: []string{"GHSA-1", "CGA-1", "ALAS-1", "GHSA-2"}, + want: VulnTree{ + "ALAS": { + "ALAS-1": {}, + }, + "CGA": { + "CGA-1": {}, + }, + "GHSA": { + "GHSA-1": {}, + "GHSA-2": {}, + }, + }, + }, + { + name: "hierarchy with child nodes in graph", + targetID: "CVE-1", + roots: []string{"CGA-1", "CGA-2"}, + graph: map[string][]string{ + "CGA-1": {"CHILD-1", "CHILD-2"}, + "CHILD-1": {"GRANDCHILD-1"}, + }, + want: VulnTree{ + "CGA": { + "CGA-1": { + "CHILD-1": { + "GRANDCHILD-1": {}, + }, + "CHILD-2": {}, + }, + "CGA-2": {}, + }, + }, + }, + { + name: "filters targetID from roots and graph", + targetID: "TARGET", + roots: []string{"TARGET", "CGA-1"}, + graph: map[string][]string{ + "CGA-1": {"TARGET", "CHILD-1"}, + }, + want: VulnTree{ + "CGA": { + "CGA-1": { + "CHILD-1": {}, + }, + }, + }, + }, + { + name: "handles cycles in graph gracefully", + targetID: "TARGET", + roots: []string{"A-1"}, + graph: map[string][]string{ + "A-1": {"B-1"}, + "B-1": {"A-1"}, + }, + want: VulnTree{ + "A": { + "A-1": { + "B-1": {}, + }, + }, + }, + }, + { + name: "deduplicates child nodes in graph", + targetID: "TARGET", + roots: []string{"A-1"}, + graph: map[string][]string{ + "A-1": {"B-1", "B-1"}, + }, + want: VulnTree{ + "A": { + "A-1": { + "B-1": {}, + }, + }, + }, + }, + { + name: "id with no hyphen", + targetID: "TARGET", + roots: []string{"NO_HYPHEN"}, + want: VulnTree{ + "NO_HYPHEN": { + "NO_HYPHEN": {}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildVulnTree(tt.targetID, tt.roots, tt.graph) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("BuildVulnTree() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/go/internal/website/vulnerability_models.go b/go/internal/website/vulnerability_models.go index bd5a3f71251..0da3899e1af 100644 --- a/go/internal/website/vulnerability_models.go +++ b/go/internal/website/vulnerability_models.go @@ -85,4 +85,5 @@ type VulnerabilityPageData struct { KnownIDs map[string]struct{} UpstreamHierarchy template.HTML DownstreamHierarchy template.HTML + RelatedHTML template.HTML } diff --git a/website/frontend3/src/styles.scss b/website/frontend3/src/styles.scss index b9cc361177b..1cad9a90032 100644 --- a/website/frontend3/src/styles.scss +++ b/website/frontend3/src/styles.scss @@ -1007,6 +1007,30 @@ dl.vulnerability-details, padding: 0; } + .prefix-group { + margin-bottom: 4px; + + summary.prefix-header { + cursor: pointer; + font-weight: bold; + user-select: none; + padding: 2px 0; + + &:hover { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $osv-accent-color; + outline-offset: 2px; + } + } + + ul.aliases { + padding-inline-start: 20px; + } + } + ul.substream { padding-bottom: 2px; padding-top: 2px; diff --git a/website/frontend3/src/templates/vulnerability.html b/website/frontend3/src/templates/vulnerability.html index 8acfee4ebb6..e7c10050da6 100644 --- a/website/frontend3/src/templates/vulnerability.html +++ b/website/frontend3/src/templates/vulnerability.html @@ -61,27 +61,15 @@

    {{ end }} {{ if .UpstreamHierarchy }}
    Upstream
    -
    {{ .UpstreamHierarchy }}
    +
    {{ .UpstreamHierarchy }}
    {{ end }} {{ if .DownstreamHierarchy }}
    Downstream
    -
    {{ .DownstreamHierarchy }}
    +
    {{ .DownstreamHierarchy }}
    {{ end }} - {{ if .Vulnerability.Related }} + {{ if .RelatedHTML }}
    Related
    -
    -
      - {{ range $related := .Vulnerability.Related }} -
    • - {{ if $.IsKnownID $related }} - {{ $related }} - {{ else }} - {{ $related }} - {{ end }} -
    • - {{ end }} -
    -
    + {{ end }} {{ if .FormattedWithdrawn }}
    @@ -569,45 +557,6 @@

    if (document.querySelector('.vulnerability-packages.force-collapse')) { setupVerticalLayout(); } - - /** - * Sets up "Show More" / "Show Less" functionality for lists that might have - * many items. You can enable this feature by adding an 'expandible-list' or - * 'expandible-hierarchy-list' class to lists with long value. - * @param {string} listSelector The CSS selector for the list container. - * @param {string} itemSelector The tag name of the items within the list. - */ - function setupExpandibleList(listSelector, itemSelector) { - const EXPANDIBLE_LIST_DEFAULT_LENGTH = 6; - const lists = document.querySelectorAll(`${listSelector}:not(.expanded):not([data-has-toggle-btn])`); - lists.forEach((list) => { - const items = list.getElementsByTagName(itemSelector); - if (items.length <= EXPANDIBLE_LIST_DEFAULT_LENGTH) return; - - const expandibleItems = [...items].slice(EXPANDIBLE_LIST_DEFAULT_LENGTH); - expandibleItems.forEach((item) => { - item.style.display = 'none'; - }); - - const toggleButton = document.createElement('span'); - toggleButton.classList.add('showmore'); - toggleButton.textContent = 'Show More'; - list.append(toggleButton); - list.setAttribute('data-has-toggle-btn', 'true'); - - toggleButton.addEventListener('click', function() { - const isExpanded = list.classList.contains('expanded'); - expandibleItems.forEach((item) => { - item.style.display = isExpanded ? 'none' : 'block'; - }); - toggleButton.textContent = isExpanded ? 'Show More' : 'Show Less'; - list.classList.toggle('expanded'); - }); - }); - } - - setupExpandibleList('.expandible-hierarchy-list', 'ul'); - setupExpandibleList('.expandible-list', 'li'); }); {{ end }}