internal,static: add module path search for vulnerabilities

Adds support for searching by mobule path prefix. For example a search
for 'net' will match with vulns for paths net, net/http, net/http/cgi.

For golang/go#54802.

Change-Id: I89543fd02d8861b8676fe4c552f7f57e436e945e
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/432418
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jamal Carvalho <jamal@golang.org>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/internal/frontend/search.go b/internal/frontend/search.go
index 1d3b8c1..01cf346 100644
--- a/internal/frontend/search.go
+++ b/internal/frontend/search.go
@@ -121,6 +121,10 @@
 	if action != nil || err != nil {
 		return action, err
 	}
+	action, err = searchVulnModule(ctx, mode, cq, vulnClient)
+	if action != nil || err != nil {
+		return action, err
+	}
 	var symbol string
 	if len(filters) > 0 {
 		symbol = filters[0]
@@ -353,7 +357,7 @@
 		return fmt.Sprintf("/vuln/%s?q", query)
 	}
 	requestedPath := path.Clean(query)
-	if !strings.Contains(requestedPath, "/") {
+	if !strings.Contains(requestedPath, "/") || mode == searchModeVuln {
 		return ""
 	}
 	_, err := ds.GetUnitMeta(ctx, requestedPath, internal.UnknownModulePath, version.Latest)
@@ -366,6 +370,36 @@
 	return fmt.Sprintf("/%s", requestedPath)
 }
 
+func searchVulnModule(ctx context.Context, mode, cq string, client vulnc.Client) (_ *searchAction, err error) {
+	if mode != searchModeVuln {
+		return nil, nil
+	}
+	allEntries, err := vulnList(ctx, client)
+	if err != nil {
+		return nil, err
+	}
+	prefix := cq + "/"
+	var entries []OSVEntry
+EntryLoop:
+	for _, entry := range allEntries {
+		for _, aff := range entry.Affected {
+			for _, imp := range aff.EcosystemSpecific.Imports {
+				if imp.Path == cq || strings.HasPrefix(imp.Path, prefix) {
+					entries = append(entries, entry)
+					continue EntryLoop
+				}
+			}
+		}
+	}
+	// Sort from most to least recent.
+	sort.Slice(entries, func(i, j int) bool { return entries[i].ID > entries[j].ID })
+	return &searchAction{
+		title:    fmt.Sprintf("%s - Vulnerability Reports", cq),
+		template: "vuln/list",
+		page:     &VulnListPage{Entries: entries},
+	}, nil
+}
+
 func searchVulnAlias(ctx context.Context, mode, cq string, vulnClient vulnc.Client) (_ *searchAction, err error) {
 	defer derrors.Wrap(&err, "searchVulnAlias(%q, %q)", mode, cq)
 
diff --git a/internal/frontend/search_test.go b/internal/frontend/search_test.go
index d1e9bbf..c335323 100644
--- a/internal/frontend/search_test.go
+++ b/internal/frontend/search_test.go
@@ -112,6 +112,11 @@
 			wantTemplate: "vuln/list",
 		},
 		{
+			name:         "vuln module path",
+			query:        "q=golang.org/x/net&m=vuln",
+			wantTemplate: "vuln/list",
+		},
+		{
 			// We turn on vuln mode if the query matches a vuln alias.
 			name:         "vuln alias not vuln mode",
 			query:        "q=GHSA-aaaa-bbbb-cccc",
@@ -617,6 +622,69 @@
 	}
 }
 
+func TestSearchVulnModulePath(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:     "no match",
+			mode:     searchModeVuln,
+			query:    "example",
+			wantPage: &VulnListPage{Entries: nil},
+		},
+		{
+			name:  "prefix match",
+			mode:  searchModeVuln,
+			query: "example.com/org",
+			wantPage: &VulnListPage{Entries: []OSVEntry{
+				{testEntries[7]},
+			}},
+		},
+		{
+			name:  "path match",
+			mode:  searchModeVuln,
+			query: "example.com/org/path",
+			wantPage: &VulnListPage{Entries: []OSVEntry{
+				{testEntries[7]},
+			}},
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			gotAction, err := searchVulnModule(context.Background(), test.mode, test.query, vc)
+			if (err != nil) != test.wantErr {
+				t.Fatalf("got %v, want error %t", err, test.wantErr)
+			}
+			var wantAction *searchAction
+			if test.wantURL != "" {
+				wantAction = &searchAction{redirectURL: test.wantURL}
+			} else if test.wantPage != nil {
+				wantAction = &searchAction{
+					title:    test.query + " - Vulnerability Reports",
+					template: "vuln/list",
+					page:     test.wantPage,
+				}
+			}
+			if !cmp.Equal(gotAction, wantAction, cmp.AllowUnexported(searchAction{}), cmpopts.IgnoreUnexported(VulnListPage{})) {
+				t.Errorf("\ngot  %+v\nwant %+v", gotAction, wantAction)
+			}
+		})
+	}
+}
+
 func TestElapsedTime(t *testing.T) {
 	now := sample.NowTruncated()
 	testCases := []struct {
diff --git a/internal/frontend/vulns.go b/internal/frontend/vulns.go
index e797a20..144dd6e 100644
--- a/internal/frontend/vulns.go
+++ b/internal/frontend/vulns.go
@@ -221,14 +221,22 @@
 }
 
 func newVulnListPage(ctx context.Context, client vulnc.Client) (*VulnListPage, error) {
+	entries, err := vulnList(ctx, client)
+	if err != nil {
+		return nil, err
+	}
+	// Sort from most to least recent.
+	sort.Slice(entries, func(i, j int) bool { return entries[i].ID > entries[j].ID })
+	return &VulnListPage{Entries: entries}, nil
+}
+
+func vulnList(ctx context.Context, client vulnc.Client) ([]OSVEntry, error) {
 	const concurrency = 4
 
 	ids, err := client.ListIDs(ctx)
 	if err != nil {
 		return nil, err
 	}
-	// Sort from most to least recent.
-	sort.Slice(ids, func(i, j int) bool { return ids[i] > ids[j] })
 
 	entries := make([]OSVEntry, len(ids))
 	sem := make(chan struct{}, concurrency)
@@ -250,7 +258,7 @@
 	if err := g.Wait(); err != nil {
 		return nil, err
 	}
-	return &VulnListPage{Entries: entries}, nil
+	return entries, nil
 }
 
 // aliasLinks generates links to reference pages for vuln aliases.
diff --git a/internal/frontend/vulns_test.go b/internal/frontend/vulns_test.go
index e278626..9614c07 100644
--- a/internal/frontend/vulns_test.go
+++ b/internal/frontend/vulns_test.go
@@ -71,6 +71,19 @@
 	{ID: "GO-1991-05", Details: "e"},
 	{ID: "GO-1991-23", Details: "f"},
 	{ID: "GO-1991-30", Details: "g"},
+	{
+		ID:      "GO-1991-31",
+		Details: "h",
+		Affected: []osv.Affected{{
+			EcosystemSpecific: osv.EcosystemSpecific{
+				Imports: []osv.EcosystemSpecificImport{
+					{
+						Path: "example.com/org/path",
+					},
+				},
+			},
+		}},
+	},
 }
 
 func TestNewVulnListPage(t *testing.T) {
diff --git a/static/frontend/vuln/list/list.css b/static/frontend/vuln/list/list.css
index 155f944..13a63a6 100644
--- a/static/frontend/vuln/list/list.css
+++ b/static/frontend/vuln/list/list.css
@@ -4,6 +4,10 @@
  * license that can be found in the LICENSE file.
  */
 
+/* Hide the search form in the header. */
+.go-SearchForm {
+  display: none;
+}
 .VulnList-title {
   font-size: 1.25rem;
   font-weight: 400;
@@ -11,3 +15,10 @@
 .VulnList-details {
   margin-bottom: 1.75rem;
 }
+.VulnList-details p {
+  word-break: break-word;
+}
+.VulnList-search {
+  max-width: 32rem;
+  margin-bottom: 1rem;
+}
diff --git a/static/frontend/vuln/list/list.min.css b/static/frontend/vuln/list/list.min.css
index 962b716..03cbced 100644
--- a/static/frontend/vuln/list/list.min.css
+++ b/static/frontend/vuln/list/list.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.
  */
-.VulnList-title{font-size:1.25rem;font-weight:400}.VulnList-details{margin-bottom:1.75rem}
+.go-SearchForm{display:none}.VulnList-title{font-size:1.25rem;font-weight:400}.VulnList-details{margin-bottom:1.75rem}.VulnList-details p{word-break:break-word}.VulnList-search{max-width:32rem;margin-bottom:1rem}
 /*# sourceMappingURL=list.min.css.map */
diff --git a/static/frontend/vuln/list/list.min.css.map b/static/frontend/vuln/list/list.min.css.map
index 610b42a..af6960b 100644
--- a/static/frontend/vuln/list/list.min.css.map
+++ b/static/frontend/vuln/list/list.min.css.map
@@ -1,7 +1,7 @@
 {
   "version": 3,
   "sources": ["list.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.VulnList-title {\n  font-size: 1.25rem;\n  font-weight: 400;\n}\n.VulnList-details {\n  margin-bottom: 1.75rem;\n}\n"],
-  "mappings": ";;;;;AAMA,gBACE,kBACA,gBAEF,kBACE",
+  "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/* Hide the search form in the header. */\n.go-SearchForm {\n  display: none;\n}\n.VulnList-title {\n  font-size: 1.25rem;\n  font-weight: 400;\n}\n.VulnList-details {\n  margin-bottom: 1.75rem;\n}\n.VulnList-details p {\n  word-break: break-word;\n}\n.VulnList-search {\n  max-width: 32rem;\n  margin-bottom: 1rem;\n}\n"],
+  "mappings": ";;;;;AAOA,eACE,aAEF,gBACE,kBACA,gBAEF,kBACE,sBAEF,oBACE,sBAEF,iBACE,gBACA",
   "names": []
 }
diff --git a/static/frontend/vuln/list/list.tmpl b/static/frontend/vuln/list/list.tmpl
index 913e731..4f90665 100644
--- a/static/frontend/vuln/list/list.tmpl
+++ b/static/frontend/vuln/list/list.tmpl
@@ -15,22 +15,47 @@
         <a href="/vuln" data-gtmc="breadcrumb link">Vulnerability Database</a>
       </li>
       <li>
-        <a href="/vuln/list" data-gtmc="breadcrumb link" aria-current="location">All Reports</a>
+        <a href="/vuln/list" data-gtmc="breadcrumb link" {{if not .Query}}aria-current="location"{{end}}>All Reports</a>
       </li>
+      {{with .Query}}
+        <li>
+          <a href="/search?q={{.}}&m=vuln" data-gtmc="breadcrumb link" aria-current="location">{{.}}</a>
+        </li>
+      {{end}}
     </ol>
   </nav>
-  <h1 class="Vuln-title">Vulnerability Reports</h1>
-  {{range .Entries}}
-    <h2 class="VulnList-title">
-      <a href="/vuln/{{.ID}}">{{.ID}}</a>
-    </h2>
-    <div class="VulnList-details">
-      {{template "vuln-details" .}}
-    </div>
+  <h1 class="Vuln-title">Vulnerability Reports{{with .Query}} – {{.}}{{end}}</h1>
+  <form
+    class="go-InputGroup VulnList-search"
+    action="/search"
+    data-gtmc="search vuln"
+    aria-label="Search by GO ID, alias, or import path"
+    role="search"
+  >
+    <input name="q" class="go-Input" placeholder="Search by GO ID, alias, or import path" value="{{.Query}}" />
+    <input name="m" value="vuln" hidden />
+    <button class="go-Button">Submit</button>
+  </form>
+  {{if not .Entries}}
+    <p>No reports found. <a href="/vuln/list">View all reports.</a></p>
+  {{else}}
+    {{range .Entries}}
+      <h2 class="VulnList-title">
+        <a href="/vuln/{{.ID}}">{{.ID}}</a>
+      </h2>
+      <div class="VulnList-details">
+        {{template "vuln-details" .}}
+      </div>
+    {{end}}
   {{end}}
-  <div><br>If you don't see an existing, public Go vulnerability in a publicly importable package in our database,
-    <a href="https://github.com/golang/vulndb/issues/new?assignees=&labels=Needs+Triage%2CDirect+External+Report&template=new_third_party_vuln.yml&title=x%2Fvulndb%3A+potential+Go+vuln+in+%3Cpackage%3E">
-      please let us know.
-    </a>
-  </div>
+  <p>
+    {{if and .Query .Entries}}
+      Didn't find what you were looking for? <a href="/vuln/list">View all reports.</a>
+    {{else}}
+      If you don't see an existing, public Go vulnerability in a publicly importable package in our database,
+      <a href="https://github.com/golang/vulndb/issues/new?assignees=&labels=Needs+Triage%2CDirect+External+Report&template=new_third_party_vuln.yml&title=x%2Fvulndb%3A+potential+Go+vuln+in+%3Cpackage%3E">
+        please let us know.
+      </a>
+    {{end}}
+  </p>
 {{end}}
diff --git a/static/frontend/vuln/main/main.css b/static/frontend/vuln/main/main.css
index 0e7c514..18fddf2 100644
--- a/static/frontend/vuln/main/main.css
+++ b/static/frontend/vuln/main/main.css
@@ -4,7 +4,11 @@
  * license that can be found in the LICENSE file.
  */
 
- .VulnMain-title {
+/* Hide the search form in the header. */
+.go-SearchForm {
+  display: none;
+}
+.VulnMain-title {
   font-size: 1.25rem;
   font-weight: 400;
   margin: 0 0 0.5rem;
@@ -15,6 +19,9 @@
 .VulnMain-details {
   margin-bottom: 1.75rem;
 }
+.VulnMain-details p {
+  word-break: break-word;
+}
 .VulnMain-search {
   max-width: 32rem;
 }
diff --git a/static/frontend/vuln/main/main.min.css b/static/frontend/vuln/main/main.min.css
index a7553d3..64c1740 100644
--- a/static/frontend/vuln/main/main.min.css
+++ b/static/frontend/vuln/main/main.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.
  */
-.VulnMain-title{font-size:1.25rem;font-weight:400;margin:0 0 .5rem}.VulnMain-recent{margin-top:.5rem}.VulnMain-details{margin-bottom:1.75rem}.VulnMain-search{max-width:32rem}.VulnMain h2{margin:1.75rem 0 .5rem}
+.go-SearchForm{display:none}.VulnMain-title{font-size:1.25rem;font-weight:400;margin:0 0 .5rem}.VulnMain-recent{margin-top:.5rem}.VulnMain-details{margin-bottom:1.75rem}.VulnMain-details p{word-break:break-word}.VulnMain-search{max-width:32rem}.VulnMain h2{margin:1.75rem 0 .5rem}
 /*# sourceMappingURL=main.min.css.map */
diff --git a/static/frontend/vuln/main/main.min.css.map b/static/frontend/vuln/main/main.min.css.map
index 45ad19d..776ea3b 100644
--- a/static/frontend/vuln/main/main.min.css.map
+++ b/static/frontend/vuln/main/main.min.css.map
@@ -1,7 +1,7 @@
 {
   "version": 3,
   "sources": ["main.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 .VulnMain-title {\n  font-size: 1.25rem;\n  font-weight: 400;\n  margin: 0 0 0.5rem;\n}\n.VulnMain-recent {\n  margin-top: 0.5rem;\n}\n.VulnMain-details {\n  margin-bottom: 1.75rem;\n}\n.VulnMain-search {\n  max-width: 32rem;\n}\n.VulnMain h2 {\n  margin: 1.75rem 0 0.5rem;\n}\n"],
-  "mappings": ";;;;;AAMC,gBACC,kBACA,gBARF,iBAWA,iBACE,iBAEF,kBACE,sBAEF,iBACE,gBAEF,aApBA",
+  "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/* Hide the search form in the header. */\n.go-SearchForm {\n  display: none;\n}\n.VulnMain-title {\n  font-size: 1.25rem;\n  font-weight: 400;\n  margin: 0 0 0.5rem;\n}\n.VulnMain-recent {\n  margin-top: 0.5rem;\n}\n.VulnMain-details {\n  margin-bottom: 1.75rem;\n}\n.VulnMain-details p {\n  word-break: break-word;\n}\n.VulnMain-search {\n  max-width: 32rem;\n}\n.VulnMain h2 {\n  margin: 1.75rem 0 0.5rem;\n}\n"],
+  "mappings": ";;;;;AAOA,eACE,aAEF,gBACE,kBACA,gBAZF,iBAeA,iBACE,iBAEF,kBACE,sBAEF,oBACE,sBAEF,iBACE,gBAEF,aA3BA",
   "names": []
 }
diff --git a/static/frontend/vuln/main/main.tmpl b/static/frontend/vuln/main/main.tmpl
index 6f17e1f..a4a9f70 100644
--- a/static/frontend/vuln/main/main.tmpl
+++ b/static/frontend/vuln/main/main.tmpl
@@ -21,10 +21,10 @@
       class="go-InputGroup VulnMain-search"
       action="/search"
       data-gtmc="search vuln"
-      aria-label="Search GO IDs"
+      aria-label="Search by GO ID, alias, or import path"
       role="search"
     >
-      <input name="q" class="go-Input" placeholder="Search GO IDs" />
+      <input name="q" class="go-Input" placeholder="Search by GO ID, alias, or import path" />
       <input name="m" value="vuln" hidden />
       <button class="go-Button">Submit</button>
     </form>
diff --git a/tests/screentest/testcases.ci.txt b/tests/screentest/testcases.ci.txt
index 723c107..36ab039 100644
--- a/tests/screentest/testcases.ci.txt
+++ b/tests/screentest/testcases.ci.txt
@@ -28,3 +28,13 @@
 pathname /cmd/go@go1.15.0
 capture viewport
 capture viewport 540x1080
+
+test vuln search
+pathname /search?q=github.com%2Fbeego&m=vuln
+capture viewport
+capture viewport 540x1080
+
+test vuln no results
+pathname /search?q=github.com%2Fnoresults&m=vuln
+capture viewport
+capture viewport 540x1080
diff --git a/tests/screentest/testdata/ci/vuln-540x1080.a.png b/tests/screentest/testdata/ci/vuln-540x1080.a.png
index c437c47..c02abe3 100644
--- a/tests/screentest/testdata/ci/vuln-540x1080.a.png
+++ b/tests/screentest/testdata/ci/vuln-540x1080.a.png
Binary files differ
diff --git a/tests/screentest/testdata/ci/vuln-list-540x1080.a.png b/tests/screentest/testdata/ci/vuln-list-540x1080.a.png
index e1498c7..48def1e 100644
--- a/tests/screentest/testdata/ci/vuln-list-540x1080.a.png
+++ b/tests/screentest/testdata/ci/vuln-list-540x1080.a.png
Binary files differ
diff --git a/tests/screentest/testdata/ci/vuln-list.a.png b/tests/screentest/testdata/ci/vuln-list.a.png
index eb8bf03..1c4d5ff 100644
--- a/tests/screentest/testdata/ci/vuln-list.a.png
+++ b/tests/screentest/testdata/ci/vuln-list.a.png
Binary files differ
diff --git a/tests/screentest/testdata/ci/vuln-no-results-540x1080.a.png b/tests/screentest/testdata/ci/vuln-no-results-540x1080.a.png
new file mode 100644
index 0000000..0c083ff
--- /dev/null
+++ b/tests/screentest/testdata/ci/vuln-no-results-540x1080.a.png
Binary files differ
diff --git a/tests/screentest/testdata/ci/vuln-no-results.a.png b/tests/screentest/testdata/ci/vuln-no-results.a.png
new file mode 100644
index 0000000..8b53be8
--- /dev/null
+++ b/tests/screentest/testdata/ci/vuln-no-results.a.png
Binary files differ
diff --git a/tests/screentest/testdata/ci/vuln-search-540x1080.a.png b/tests/screentest/testdata/ci/vuln-search-540x1080.a.png
new file mode 100644
index 0000000..c5dc810
--- /dev/null
+++ b/tests/screentest/testdata/ci/vuln-search-540x1080.a.png
Binary files differ
diff --git a/tests/screentest/testdata/ci/vuln-search.a.png b/tests/screentest/testdata/ci/vuln-search.a.png
new file mode 100644
index 0000000..d824138
--- /dev/null
+++ b/tests/screentest/testdata/ci/vuln-search.a.png
Binary files differ
diff --git a/tests/screentest/testdata/ci/vuln.a.png b/tests/screentest/testdata/ci/vuln.a.png
index 64dc206..6238360 100644
--- a/tests/screentest/testdata/ci/vuln.a.png
+++ b/tests/screentest/testdata/ci/vuln.a.png
Binary files differ
diff --git a/tests/screentest/testdata/vulndb/index.json b/tests/screentest/testdata/vulndb/index.json
index c197360..3fe0721 100644
--- a/tests/screentest/testdata/vulndb/index.json
+++ b/tests/screentest/testdata/vulndb/index.json
@@ -1,4 +1,6 @@
 {
     "github.com/BeeGo/beego": "2022-08-23T19:54:38Z",
-    "github.com/tidwall/gjson": "2022-08-23T19:54:38Z"
+    "github.com/tidwall/gjson": "2022-08-23T19:54:38Z",
+    "stdlib": "2022-08-23T19:54:38Z",
+    "toolchain": "2022-08-23T19:54:38Z"
 }