internal/vulns: add affected symbols

Only exported symbols are reported.
In case there are no exported symbols, we present as if all symbols in
the package are completely vulnerable.

Updates golang/go#54812

Change-Id: I4555af8f27ae50fcb1a9e3b9e1c2ec29e750a9ad
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/429678
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Jamal Carvalho <jamal@golang.org>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/internal/vulns/vulns.go b/internal/vulns/vulns.go
index 2190269..9885196 100644
--- a/internal/vulns/vulns.go
+++ b/internal/vulns/vulns.go
@@ -8,6 +8,7 @@
 import (
 	"context"
 	"fmt"
+	"go/token"
 	"strings"
 
 	"golang.org/x/mod/semver"
@@ -65,6 +66,9 @@
 type AffectedPackage struct {
 	PackagePath string
 	Versions    string
+	// List of exported affected symbols. Empty list
+	// implies all symbols in the package are affected.
+	Symbols []string
 }
 
 // OSVEntry holds an OSV entry and provides additional methods.
@@ -203,8 +207,26 @@
 			affs = append(affs, &AffectedPackage{
 				PackagePath: p.Path,
 				Versions:    strings.Join(vs, ", "),
+				Symbols:     exportedSymbols(p.Symbols),
+				// TODO(hyangah): where to place GOOS/GOARCH info
 			})
 		}
 	}
 	return affs
 }
+
+func exportedSymbols(in []string) []string {
+	var out []string
+	for _, s := range in {
+		exported := true
+		for _, part := range strings.Split(s, ".") {
+			if !token.IsExported(part) {
+				exported = false // exported only all parts in the symbol name are exported.
+			}
+		}
+		if exported {
+			out = append(out, s)
+		}
+	}
+	return out
+}
diff --git a/internal/vulns/vulns_test.go b/internal/vulns/vulns_test.go
index 2b3d249..1007c7d 100644
--- a/internal/vulns/vulns_test.go
+++ b/internal/vulns/vulns_test.go
@@ -11,6 +11,7 @@
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
 	"golang.org/x/vuln/osv"
 )
 
@@ -86,7 +87,7 @@
 
 }
 
-func TestAffectedPackages(t *testing.T) {
+func TestAffectedPackages_Versions(t *testing.T) {
 	for _, test := range []struct {
 		name string
 		in   []osv.RangeEvent
@@ -139,3 +140,109 @@
 		})
 	}
 }
+
+func TestAffectedPackagesPackagesSymbols(t *testing.T) {
+	tests := []struct {
+		name string
+		in   *osv.Entry
+		want []*AffectedPackage
+	}{
+		{
+			name: "one symbol",
+			in: &osv.Entry{
+				ID: "GO-2022-01",
+				Affected: []osv.Affected{{
+					Package: osv.Package{Name: "example.com/mod"},
+					EcosystemSpecific: osv.EcosystemSpecific{
+						Imports: []osv.EcosystemSpecificImport{{
+							Path:    "example.com/mod/pkg",
+							Symbols: []string{"F"},
+						}},
+					},
+				}},
+			},
+			want: []*AffectedPackage{{
+				PackagePath: "example.com/mod/pkg",
+				Symbols:     []string{"F"},
+			}},
+		},
+		{
+			name: "multiple symbols",
+			in: &osv.Entry{
+				ID: "GO-2022-02",
+				Affected: []osv.Affected{{
+					Package: osv.Package{Name: "example.com/mod"},
+					EcosystemSpecific: osv.EcosystemSpecific{
+						Imports: []osv.EcosystemSpecificImport{{
+							Path:    "example.com/mod/pkg",
+							Symbols: []string{"F", "g", "S.f", "S.F", "s.F", "s.f"},
+						}},
+					},
+				}},
+			},
+			want: []*AffectedPackage{{
+				PackagePath: "example.com/mod/pkg",
+				Symbols:     []string{"F", "S.F"}, // unexported symbols are excluded.
+			}},
+		},
+		{
+			name: "no symbol",
+			in: &osv.Entry{
+				ID: "GO-2022-03",
+				Affected: []osv.Affected{{
+					Package: osv.Package{Name: "example.com/mod"},
+					EcosystemSpecific: osv.EcosystemSpecific{
+						Imports: []osv.EcosystemSpecificImport{{
+							Path: "example.com/mod/pkg",
+						}},
+					},
+				}},
+			},
+			want: []*AffectedPackage{{
+				PackagePath: "example.com/mod/pkg",
+			}},
+		},
+		{
+			name: "multiple pkgs and modules",
+			in: &osv.Entry{
+				ID: "GO-2022-04",
+				Affected: []osv.Affected{{
+					Package: osv.Package{Name: "example.com/mod1"},
+					EcosystemSpecific: osv.EcosystemSpecific{
+						Imports: []osv.EcosystemSpecificImport{{
+							Path: "example.com/mod1/pkg1",
+						}, {
+							Path:    "example.com/mod1/pkg2",
+							Symbols: []string{"F"},
+						}},
+					},
+				}, {
+					Package: osv.Package{Name: "example.com/mod2"},
+					EcosystemSpecific: osv.EcosystemSpecific{
+						Imports: []osv.EcosystemSpecificImport{{
+							Path:    "example.com/mod2/pkg3",
+							Symbols: []string{"g", "H"},
+						}},
+					},
+				}},
+			},
+			want: []*AffectedPackage{{
+				PackagePath: "example.com/mod1/pkg1",
+			}, {
+				PackagePath: "example.com/mod1/pkg2",
+				Symbols:     []string{"F"},
+			}, {
+				PackagePath: "example.com/mod2/pkg3",
+				Symbols:     []string{"H"},
+			}},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := AffectedPackages(tt.in)
+			if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreUnexported(AffectedPackage{})); diff != "" {
+				t.Errorf("mismatch (-want, +got):\n%s", diff)
+			}
+		})
+	}
+}
diff --git a/static/frontend/vuln/entry/entry.css b/static/frontend/vuln/entry/entry.css
index 394bbed..46eab8e 100644
--- a/static/frontend/vuln/entry/entry.css
+++ b/static/frontend/vuln/entry/entry.css
@@ -17,26 +17,68 @@
 .VulnEntry h2 {
   font-size: 1.25rem;
 }
-.VulnEntry-table {
-  margin-bottom: 0.5rem;
-  text-align: left;
-  width: 100%;
+.VulnEntryPackages-attr {
+  /* package and symbol names can be pretty long */
+  overflow-wrap: anywhere;
 }
-.VulnEntry-table thead {
-  background-color: var(--color-background-accented);
+/* One column by default */
+.VulnEntryPackages-container {
+  display: grid;
+  grid-template-columns: 1fr;
+  grid-gap: 0.5rem;
 }
-.VulnEntry-table tbody {
-  word-break: break-all;
+/* Don't display the first item - the headers for multi-col layout */
+.VulnEntryPackages-container>li:first-child {
+  display: none;
 }
-.VulnEntry-table td,
-.VulnEntry-table th {
-  padding: 0.75rem 1rem;
+.VulnEntryPackages-attr::before {
+  content: attr(data-name);
+  color: var(--color-text-subtle);
 }
-.VulnEntry-table tbody > tr {
-  border-bottom: var(--border);
+/* Attribute name for first column, and attribute value for second column. */
+.VulnEntryPackages-attr {
+  display: grid;
+  grid-template-columns: minmax(5em, 10%) 1fr;
+  padding: 0.2rem;
+}
+/* Three columns for wider screen */
+@media screen and (min-width: 46rem) {
+  /* Undo what's done by default */
+  .VulnEntryPackages-container {
+    grid-gap: 0;
+  }
+  .VulnEntryPackages-item {
+    padding: inherit;
+  }
+  .VulnEntryPackages-container>li:first-child {
+    display: grid;  /* undo display: none setfor default */
+  }
+  .VulnEntryPackages-attr::before {
+    content: none;
+  }
+  .VulnEntryPackages-attr {
+    grid-template-columns: 1fr;
+  }
+  .VulnEntryPackages-item-container {
+    display: grid;
+    grid-template-columns: minmax(10em, 50%) minmax(5em, 20%) 1fr;
+    padding: 0.5rem;
+  }
+  /* Header */
+  .VulnEntryPackages-item-container:first-child {
+    background-color: var(--color-background-accented);
+  }
+  /* Header text */
+  .VulnEntryPackages-item-container:first-child .VulnEntryPackages-attr {
+    display: flex;
+    text-overflow: initial;
+    overflow: auto;
+    white-space: normal;
+    font-weight: bold;
+  }
 }
 .VulnEntry-referenceList,
 .VulnEntry-aliases {
   line-height: 1.75rem;
   word-break: break-all;
-}
+}
\ No newline at end of file
diff --git a/static/frontend/vuln/entry/entry.min.css b/static/frontend/vuln/entry/entry.min.css
index 5b6deda..ae4a973 100644
--- a/static/frontend/vuln/entry/entry.min.css
+++ b/static/frontend/vuln/entry/entry.min.css
@@ -3,5 +3,5 @@
  * Use of this source code is governed by a BSD-style
  * license that can be found in the LICENSE file.
  */
-.Vuln-alias{display:none}.VulnEntry{display:flex;flex-direction:column;gap:1rem;margin-top:.5rem}.VulnEntry h2{font-size:1.25rem}.VulnEntry-table{margin-bottom:.5rem;text-align:left;width:100%}.VulnEntry-table thead{background-color:var(--color-background-accented)}.VulnEntry-table tbody{word-break:break-all}.VulnEntry-table td,.VulnEntry-table th{padding:.75rem 1rem}.VulnEntry-table tbody>tr{border-bottom:var(--border)}.VulnEntry-referenceList,.VulnEntry-aliases{line-height:1.75rem;word-break:break-all}
+.Vuln-alias{display:none}.VulnEntry{display:flex;flex-direction:column;gap:1rem;margin-top:.5rem}.VulnEntry h2{font-size:1.25rem}.VulnEntryPackages-attr{overflow-wrap:anywhere}.VulnEntryPackages-container{display:grid;grid-template-columns:1fr;grid-gap:.5rem}.VulnEntryPackages-container>li:first-child{display:none}.VulnEntryPackages-attr:before{content:attr(data-name);color:var(--color-text-subtle)}.VulnEntryPackages-attr{display:grid;grid-template-columns:minmax(5em,10%) 1fr;padding:.2rem}@media screen and (min-width: 46rem){.VulnEntryPackages-container{grid-gap:0}.VulnEntryPackages-item{padding:inherit}.VulnEntryPackages-container>li:first-child{display:grid}.VulnEntryPackages-attr:before{content:none}.VulnEntryPackages-attr{grid-template-columns:1fr}.VulnEntryPackages-item-container{display:grid;grid-template-columns:minmax(10em,50%) minmax(5em,20%) 1fr;padding:.5rem}.VulnEntryPackages-item-container:first-child{background-color:var(--color-background-accented)}.VulnEntryPackages-item-container:first-child .VulnEntryPackages-attr{display:flex;text-overflow:initial;overflow:auto;white-space:normal;font-weight:700}}.VulnEntry-referenceList,.VulnEntry-aliases{line-height:1.75rem;word-break:break-all}
 /*# sourceMappingURL=entry.min.css.map */
diff --git a/static/frontend/vuln/entry/entry.min.css.map b/static/frontend/vuln/entry/entry.min.css.map
index eb9273d..26204a2 100644
--- a/static/frontend/vuln/entry/entry.min.css.map
+++ b/static/frontend/vuln/entry/entry.min.css.map
@@ -1,7 +1,7 @@
 {
   "version": 3,
   "sources": ["entry.css"],
-  "sourcesContent": ["/*\n * Copyright 2021 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n.Vuln-alias {\n  display: none;\n}\n\n.VulnEntry {\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n  margin-top: 0.5rem;\n}\n.VulnEntry h2 {\n  font-size: 1.25rem;\n}\n.VulnEntry-table {\n  margin-bottom: 0.5rem;\n  text-align: left;\n  width: 100%;\n}\n.VulnEntry-table thead {\n  background-color: var(--color-background-accented);\n}\n.VulnEntry-table tbody {\n  word-break: break-all;\n}\n.VulnEntry-table td,\n.VulnEntry-table th {\n  padding: 0.75rem 1rem;\n}\n.VulnEntry-table tbody > tr {\n  border-bottom: var(--border);\n}\n.VulnEntry-referenceList,\n.VulnEntry-aliases {\n  line-height: 1.75rem;\n  word-break: break-all;\n}\n"],
-  "mappings": ";;;;;AAMA,YACE,aAGF,WACE,aACA,sBACA,SACA,iBAEF,cACE,kBAEF,iBACE,oBACA,gBACA,WAEF,uBACE,kDAEF,uBACE,qBAEF,wCA9BA,oBAkCA,0BACE,4BAEF,4CAEE,oBACA",
+  "sourcesContent": ["/*\n * Copyright 2021 The Go Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style\n * license that can be found in the LICENSE file.\n */\n\n.Vuln-alias {\n  display: none;\n}\n\n.VulnEntry {\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n  margin-top: 0.5rem;\n}\n.VulnEntry h2 {\n  font-size: 1.25rem;\n}\n.VulnEntryPackages-attr {\n  /* package and symbol names can be pretty long */\n  overflow-wrap: anywhere;\n}\n/* One column by default */\n.VulnEntryPackages-container {\n  display: grid;\n  grid-template-columns: 1fr;\n  grid-gap: 0.5rem;\n}\n/* Don't display the first item - the headers for multi-col layout */\n.VulnEntryPackages-container>li:first-child {\n  display: none;\n}\n.VulnEntryPackages-attr::before {\n  content: attr(data-name);\n  color: var(--color-text-subtle);\n}\n/* Attribute name for first column, and attribute value for second column. */\n.VulnEntryPackages-attr {\n  display: grid;\n  grid-template-columns: minmax(5em, 10%) 1fr;\n  padding: 0.2rem;\n}\n/* Three columns for wider screen */\n@media screen and (min-width: 46rem) {\n  /* Undo what's done by default */\n  .VulnEntryPackages-container {\n    grid-gap: 0;\n  }\n  .VulnEntryPackages-item {\n    padding: inherit;\n  }\n  .VulnEntryPackages-container>li:first-child {\n    display: grid;  /* undo display: none setfor default */\n  }\n  .VulnEntryPackages-attr::before {\n    content: none;\n  }\n  .VulnEntryPackages-attr {\n    grid-template-columns: 1fr;\n  }\n  .VulnEntryPackages-item-container {\n    display: grid;\n    grid-template-columns: minmax(10em, 50%) minmax(5em, 20%) 1fr;\n    padding: 0.5rem;\n  }\n  /* Header */\n  .VulnEntryPackages-item-container:first-child {\n    background-color: var(--color-background-accented);\n  }\n  /* Header text */\n  .VulnEntryPackages-item-container:first-child .VulnEntryPackages-attr {\n    display: flex;\n    text-overflow: initial;\n    overflow: auto;\n    white-space: normal;\n    font-weight: bold;\n  }\n}\n.VulnEntry-referenceList,\n.VulnEntry-aliases {\n  line-height: 1.75rem;\n  word-break: break-all;\n}"],
+  "mappings": ";;;;;AAMA,YACE,aAGF,WACE,aACA,sBACA,SACA,iBAEF,cACE,kBAEF,wBAEE,uBAGF,6BACE,aACA,0BACA,eAGF,4CACE,aAEF,+BACE,wBACA,+BAGF,wBACE,aACA,0CAxCF,cA4CA,qCAEE,6BACE,WAEF,wBACE,gBAEF,4CACE,aAEF,+BACE,aAEF,wBACE,0BAEF,kCACE,aACA,2DA/DJ,cAmEE,8CACE,kDAGF,sEACE,aACA,sBACA,cACA,mBACA,iBAGJ,4CAEE,oBACA",
   "names": []
 }
diff --git a/static/frontend/vuln/entry/entry.tmpl b/static/frontend/vuln/entry/entry.tmpl
index 7ffdd15..6250707 100644
--- a/static/frontend/vuln/entry/entry.tmpl
+++ b/static/frontend/vuln/entry/entry.tmpl
@@ -45,22 +45,26 @@
 
 {{define "affected"}}
   <h2>Affected Packages</h2>
-  <table class="VulnEntry-table">
-    <thead>
-      <tr>
-	<th>Package</th>
-	<th>Affected Versions</th>
-      </tr>
-    </thead>
-    <tbody>
-      {{range .}}
-	<tr>
-	  <td><a href="/{{.PackagePath}}">{{.PackagePath}}</a></td>
-	  <td>{{if .Versions}}{{.Versions}}{{else}}all versions, no known fixed{{end}}</td>
-	</tr>
-      {{end}}
-    </tbody>
-  </table>
+  <ul class="VulnEntryPackages VulnEntryPackages-container">
+    <li class="VulnEntryPackages-item VulnEntryPackages-item-container">
+      <div class="VulnEntryPackages-attr">Path</div>
+      <div class="VulnEntryPackages-attr">Versions</div>
+      <div class="VulnEntryPackages-attr">Symbols</div>
+    </li>
+    {{range .}}
+    <li class="VulnEntryPackages-item VulnEntryPackages-item-container">
+      <div class="VulnEntryPackages-attr" data-name="Path"><a href="/{{.PackagePath}}">{{.PackagePath}}</a></div>
+      <div class="VulnEntryPackages-attr" data-name="Versions">{{if .Versions}}{{.Versions}}{{else}}all versions, no known fixed{{end}}</div>
+      <div class="VulnEntryPackages-attr VulnEntryPackages-symbols" data-name="Symbols">
+      {{if .Symbols}}{{ $length := len .Symbols}}
+         {{if lt $length 5}}<ul>{{range .Symbols}}<li>{{.}}</li>{{end}}</ul>
+         {{else}}<details><summary>{{len .Symbols}} affected symbols</summary><ul>{{range .Symbols}}<li>{{.}}</li>{{end}}</ul></details>
+         {{end}}
+      {{else}}all symbols{{end}}
+      </div>
+    </li>
+    {{end}}
+  </ul>
 {{end}}
 
 {{define "entry"}}
@@ -90,4 +94,4 @@
       Suggest an edit to this report.
     </a>
   </div>
-{{end}}
+{{end}}
\ No newline at end of file