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(``)
- printSubtree(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(``)
+ renderNode(cID, children[cID])
+ 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, ``, template.HTMLEscapeString(g.prefix), len(g.rootIDs))
+ for _, rootID := range g.rootIDs {
+ renderNode(rootID, tree[g.prefix][rootID])
+ }
+ sb.WriteString(`
`)
+ }
+ } else {
sb.WriteString(``)
- printSubtree(root)
+ for _, g := range groups {
+ for _, rootID := range g.rootIDs {
+ renderNode(rootID, tree[g.prefix][rootID])
+ }
+ }
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 }}
-
-
+ {{ .RelatedHTML }}
{{ 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 }}