internal/frontend: search vulns by alias

In vuln mode search, accept aliases like CVE and GHSA identifiers.

If there is only one matching Go report, redirect to it.

If there are more than one, show the vuln/list page for the matches.

Change-Id: I2586cc61e9372e314f0757d4311ed09f12d7807b
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/430281
Reviewed-by: Julie Qiu <julieqiu@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Jamal Carvalho <jamal@golang.org>
diff --git a/internal/frontend/search.go b/internal/frontend/search.go
index d0c97e0..cfa22cd 100644
--- a/internal/frontend/search.go
+++ b/internal/frontend/search.go
@@ -28,6 +28,7 @@
 	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/pkgsite/internal/version"
 	"golang.org/x/text/message"
+	vulnc "golang.org/x/vuln/client"
 )
 
 // serveSearch applies database data to the search template. Handles endpoint
@@ -95,6 +96,23 @@
 		return nil
 	}
 
+	vulnListPage, redirectURL, err := searchVulnAlias(ctx, mode, cq, s.vulnClient)
+	if err != nil {
+		return err
+	}
+	if redirectURL != "" {
+		http.Redirect(w, r, redirectURL, http.StatusFound)
+		return nil
+	}
+	if vulnListPage != nil {
+		vulnListPage.basePage = s.newBasePage(r, fmt.Sprintf("%s - Vulnerability Reports", cq))
+		if s.shouldServeJSON(r) {
+			return s.serveJSONPage(w, r, vulnListPage)
+		}
+		s.servePage(ctx, w, "vuln/list", vulnListPage)
+		return nil
+	}
+
 	var symbol string
 	if len(filters) > 0 {
 		symbol = filters[0]
@@ -324,8 +342,7 @@
 	if urlSchemeIdx > -1 {
 		query = query[urlSchemeIdx+3:]
 	}
-	// TODO(go.dev/issue/54465): add support for searching by alias.
-	if goVulnIDRegexp.MatchString(query) || mode == searchModeVuln {
+	if goVulnIDRegexp.MatchString(query) {
 		return fmt.Sprintf("/vuln/%s?q", query)
 	}
 	requestedPath := path.Clean(query)
@@ -342,6 +359,40 @@
 	return fmt.Sprintf("/%s", requestedPath)
 }
 
+func searchVulnAlias(ctx context.Context, mode, cq string, vulnClient vulnc.Client) (_ *VulnListPage, redirectURL string, err error) {
+	defer derrors.Wrap(&err, "searchVulnAlias(%q, %q)", mode, cq)
+
+	if mode != searchModeVuln || !isVulnAlias(cq) {
+		return nil, "", nil
+	}
+	aliasEntries, err := vulnClient.GetByAlias(ctx, cq)
+	if err != nil {
+		return nil, "", err
+	}
+	switch len(aliasEntries) {
+	case 0:
+		return nil, "", &serverError{status: http.StatusNotFound}
+	case 1: // redirect
+		return nil, "/vuln/" + aliasEntries[0].ID, nil
+	default:
+		var entries []OSVEntry
+		for _, e := range aliasEntries {
+			entries = append(entries, OSVEntry{e})
+		}
+		return &VulnListPage{Entries: entries}, "", nil
+	}
+}
+
+// Regexps that match aliases for Go vulns.
+var (
+	cveRegexp  = regexp.MustCompile("^CVE-[0-9]{4}-[0-9]+$")
+	ghsaRegexp = regexp.MustCompile("^GHSA-.{4}-.{4}-.{4}$")
+)
+
+func isVulnAlias(s string) bool {
+	return cveRegexp.MatchString(s) || ghsaRegexp.MatchString(s)
+}
+
 // searchMode reports whether the search performed should be in package or
 // symbol search mode.
 func searchMode(r *http.Request) string {
@@ -357,6 +408,9 @@
 	case searchModeVuln:
 		return searchModeVuln
 	default:
+		if isVulnAlias(q) {
+			return searchModeVuln
+		}
 		if shouldDefaultToSymbolSearch(q) {
 			return searchModeSymbol
 		}
@@ -383,13 +437,13 @@
 	return strings.TrimSpace(r.FormValue("q"))
 }
 
-// rawSearchQuery returns the exact search mode from the URL request.
+// rawSearchMode returns the exact search mode from the URL request.
 func rawSearchMode(r *http.Request) string {
 	return strings.TrimSpace(r.FormValue("m"))
 }
 
-// shouldDefaultToSymbolSearch reports whether the symbol search mode should
-// default to symbol search mode based on the input.
+// shouldDefaultToSymbolSearch reports whether the search mode should
+// default to symbol based on the input.
 func shouldDefaultToSymbolSearch(q string) bool {
 	if len(strings.Fields(q)) != 1 {
 		return false
diff --git a/internal/frontend/search_test.go b/internal/frontend/search_test.go
index ae6034f..5d6c4ac 100644
--- a/internal/frontend/search_test.go
+++ b/internal/frontend/search_test.go
@@ -384,7 +384,9 @@
 		{"trim URL scheme from query", "https://golang.org/x/tools", "/golang.org/x/tools", ""},
 		{"Go vuln redirects", "GO-1969-0720", "/vuln/GO-1969-0720?q", ""},
 		{"not a Go vuln", "somepkg/GO-1969-0720", "", ""},
-		{"search mode is vuln", "searchmodevuln", "/vuln/searchmodevuln?q", searchModeVuln},
+		// Just setting the search mode to vuln does not cause a redirect.
+		{"search mode is vuln", "searchmodevuln", "", searchModeVuln},
+		{"CVE alias", "CVE-2022-32190", "", searchModePackage},
 	} {
 		t.Run(test.name, func(t *testing.T) {
 			if got := searchRequestRedirectPath(ctx, testDB, test.query, test.mode); got != test.want {
@@ -394,6 +396,71 @@
 	}
 }
 
+func TestSearchVulnAlias(t *testing.T) {
+	vc := newVulndbTestClient(testEntries)
+	for _, test := range []struct {
+		name     string
+		mode     string
+		query    string
+		wantPage *VulnListPage
+		wantURL  string
+		wantErr  bool
+	}{
+		{
+			name:     "not vuln mode",
+			mode:     searchModePackage,
+			query:    "doesn't matter",
+			wantPage: nil,
+			wantURL:  "",
+			wantErr:  false,
+		},
+		{
+			name:     "not alias",
+			mode:     searchModeVuln,
+			query:    "CVE-not-really",
+			wantPage: nil,
+			wantURL:  "",
+			wantErr:  false,
+		},
+		{
+			name:     "no match",
+			mode:     searchModeVuln,
+			query:    "CVE-1999-1",
+			wantPage: nil,
+			wantURL:  "",
+			wantErr:  true,
+		},
+		{
+			name:    "one match",
+			mode:    searchModeVuln,
+			query:   "GHSA-aaaa-bbbb-cccc",
+			wantURL: "/vuln/GO-1990-01",
+		},
+		{
+			name:  "multiple matches",
+			mode:  searchModeVuln,
+			query: "CVE-2000-1",
+			wantPage: &VulnListPage{Entries: []OSVEntry{
+				OSVEntry{testEntries[0]},
+				OSVEntry{testEntries[1]},
+			}},
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			gotPage, gotURL, err := searchVulnAlias(context.Background(), test.mode, test.query, vc)
+			if (err != nil) != test.wantErr {
+				t.Fatalf("got %v, want error %t", err, test.wantErr)
+			}
+			if !cmp.Equal(gotPage, test.wantPage, cmpopts.IgnoreUnexported(VulnListPage{})) {
+				t.Errorf("page:\ngot  %+v\nwant %+v", gotPage, test.wantPage)
+			}
+			if gotURL != test.wantURL {
+				t.Errorf("redirect: got %q, want %q", gotURL, test.wantURL)
+			}
+		})
+	}
+}
+
 func TestElapsedTime(t *testing.T) {
 	now := sample.NowTruncated()
 	testCases := []struct {
diff --git a/internal/frontend/vulns.go b/internal/frontend/vulns.go
index e6c9eda..358617f 100644
--- a/internal/frontend/vulns.go
+++ b/internal/frontend/vulns.go
@@ -71,7 +71,7 @@
 	return vulns, nil
 }
 
-// VulnListPage holds the information for a page that lists all vuln entries.
+// VulnListPage holds the information for a page that lists vuln entries.
 type VulnListPage struct {
 	basePage
 	Entries []OSVEntry
diff --git a/internal/frontend/vulns_test.go b/internal/frontend/vulns_test.go
index cff7812..e278626 100644
--- a/internal/frontend/vulns_test.go
+++ b/internal/frontend/vulns_test.go
@@ -64,8 +64,8 @@
 }
 
 var testEntries = []*osv.Entry{
-	{ID: "GO-1990-01", Details: "a"},
-	{ID: "GO-1990-02", Details: "b"},
+	{ID: "GO-1990-01", Details: "a", Aliases: []string{"CVE-2000-1", "GHSA-aaaa-bbbb-cccc"}},
+	{ID: "GO-1990-02", Details: "b", Aliases: []string{"CVE-2000-1", "GHSA-1111-2222-3333"}},
 	{ID: "GO-1990-10", Details: "c"},
 	{ID: "GO-1991-01", Details: "d"},
 	{ID: "GO-1991-05", Details: "e"},
@@ -75,7 +75,7 @@
 
 func TestNewVulnListPage(t *testing.T) {
 	ctx := context.Background()
-	c := &vulndbTestClient{entries: testEntries}
+	c := newVulndbTestClient(testEntries)
 	got, err := newVulnListPage(ctx, c)
 	if err != nil {
 		t.Fatal(err)
@@ -98,7 +98,10 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	want := &VulnPage{Entry: OSVEntry{testEntries[1]}}
+	want := &VulnPage{
+		Entry:      OSVEntry{testEntries[1]},
+		AliasLinks: aliasLinks(testEntries[1]),
+	}
 	if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(VulnPage{})); diff != "" {
 		t.Errorf("mismatch (-want, +got):\n%s", diff)
 	}
@@ -106,7 +109,21 @@
 
 type vulndbTestClient struct {
 	vulnc.Client
-	entries []*osv.Entry
+	entries    []*osv.Entry
+	aliasToIDs map[string][]string
+}
+
+func newVulndbTestClient(entries []*osv.Entry) *vulndbTestClient {
+	c := &vulndbTestClient{
+		entries:    entries,
+		aliasToIDs: map[string][]string{},
+	}
+	for _, e := range entries {
+		for _, a := range e.Aliases {
+			c.aliasToIDs[a] = append(c.aliasToIDs[a], e.ID)
+		}
+	}
+	return c
 }
 
 func (c *vulndbTestClient) GetByModule(context.Context, string) ([]*osv.Entry, error) {
@@ -130,6 +147,19 @@
 	return ids, nil
 }
 
+func (c *vulndbTestClient) GetByAlias(ctx context.Context, alias string) ([]*osv.Entry, error) {
+	ids := c.aliasToIDs[alias]
+	if len(ids) == 0 {
+		return nil, nil
+	}
+	var es []*osv.Entry
+	for _, id := range ids {
+		e, _ := c.GetByID(ctx, id)
+		es = append(es, e)
+	}
+	return es, nil
+}
+
 func Test_aliasLinks(t *testing.T) {
 	type args struct {
 		e *osv.Entry