internal/api: support package-level vulnerability filtering

Update the v1beta vulnerabilities endpoint to accept both module paths
and package paths.

When a package path is supplied, the handler resolves the containing
module path via resolveModulePath and then passes the package path as a
filter to vuln.VulnsForPackage.

When a module path is supplied, it returns all vulnerabilities for that
module.

Also expose a `module` query parameter in VulnParams to allow resolving
ambiguous package paths.

Change-Id: I8666f98d1d05d9426eef67deb0e28ca193f50111
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/780182
Reviewed-by: Jonathan Amsterdam <jba@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
kokoro-CI: kokoro <noreply+kokoro@google.com>
Auto-Submit: Ethan Lee <ethanalee@google.com>
diff --git a/internal/api/api.go b/internal/api/api.go
index b7c2f23..a0c2b5c 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -566,9 +566,9 @@
 	return serveJSON(w, http.StatusOK, resp, shortCacheDur)
 }
 
-// ServeVulnerabilities handles requests for the v1beta module vulnerabilities endpoint.
+// ServeVulnerabilities handles requests for the v1beta vulnerabilities endpoint.
 // api:route /v1beta/vulns/{path}
-// api:desc Vulnerabilities of the module at {path}, from
+// api:desc Vulnerabilities of the module or package at {path}, from
 // api:desc the Go vulnerability database (https://vuln.go.dev).
 // api:desc Only results whose ID or details
 // api:desc matches the regexp in the filter query parameter are returned.
@@ -577,10 +577,10 @@
 	return func(w http.ResponseWriter, r *http.Request, ds internal.DataSource) (err error) {
 		defer derrors.Wrap(&err, "ServeVulnerabilities")
 
-		modulePath := trimPath(r, "/v1beta/vulns/")
-		if modulePath == "" {
-			return BadRequest("missing module path",
-				"the module path must be provided after '/vulns/'")
+		path := trimPath(r, "/v1beta/vulns/")
+		if path == "" {
+			return BadRequest("missing path",
+				"the package or module path must be provided after '/vulns/'")
 		}
 
 		// api:params VulnParams
@@ -598,19 +598,20 @@
 			requestedVersion = version.Latest
 		}
 
-		// Verify module existence and resolve version.
-		um, err := ds.GetUnitMeta(r.Context(), modulePath, internal.UnknownModulePath, requestedVersion)
+		// Verify package or module existence and resolve containing module.
+		um, err := resolveModulePath(r, ds, path, params.Module, requestedVersion)
 		if err != nil {
 			return err
 		}
 
-		if err := checkModulePath(modulePath, um.ModulePath); err != nil {
-			return err
+		var pkgPath string
+		if path != um.ModulePath {
+			pkgPath = path
 		}
 
-		// Use VulnsForPackage from internal/vuln to get vulnerabilities for the module.
-		// Passing an empty packagePath gets all vulns for the module.
-		vulns := vuln.VulnsForPackage(r.Context(), um.ModulePath, um.Version, "", vc)
+		// Use VulnsForPackage from internal/vuln to get vulnerabilities.
+		// If pkgPath is non-empty, it filters vulnerabilities to only that package.
+		vulns := vuln.VulnsForPackage(r.Context(), um.ModulePath, um.Version, pkgPath, vc)
 
 		vulns, err = filter(vulns, params.Filter, func(v vuln.Vuln) []string {
 			return []string{v.ID, v.Details}
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index d6cd9d5..d99de33 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -33,6 +33,36 @@
 				},
 			},
 		},
+		{
+			ID:      "VULN-2",
+			Summary: "Vulnerability 2",
+			Affected: []osv.Affected{
+				{
+					Module: osv.Module{Path: "example.com"},
+					Ranges: []osv.Range{{Type: osv.RangeTypeSemver, Events: []osv.RangeEvent{{Introduced: "0"}, {Fixed: "1.1.0"}}}},
+					EcosystemSpecific: osv.EcosystemSpecific{
+						Packages: []osv.Package{
+							{Path: "example.com/pkg"},
+						},
+					},
+				},
+			},
+		},
+		{
+			ID:      "VULN-3",
+			Summary: "Vulnerability 3",
+			Affected: []osv.Affected{
+				{
+					Module: osv.Module{Path: "example.com"},
+					Ranges: []osv.Range{{Type: osv.RangeTypeSemver, Events: []osv.RangeEvent{{Introduced: "0"}, {Fixed: "1.1.0"}}}},
+					EcosystemSpecific: osv.EcosystemSpecific{
+						Packages: []osv.Package{
+							{Path: "example.com/other"},
+						},
+					},
+				},
+			},
+		},
 	})
 	if err != nil {
 		t.Fatal(err)
@@ -64,6 +94,15 @@
 						},
 					},
 				},
+				{
+					UnitMeta: internal.UnitMeta{
+						Path: "example.com/other",
+						ModuleInfo: internal.ModuleInfo{
+							ModulePath: "example.com",
+							Version:    v,
+						},
+					},
+				},
 			},
 		})
 	}
@@ -81,7 +120,7 @@
 			name:       "all vulns",
 			url:        "/v1beta/vulns/example.com?version=v1.0.0",
 			wantStatus: http.StatusOK,
-			wantCount:  1,
+			wantCount:  3,
 		},
 		{
 			name:       "no vulns",
@@ -92,7 +131,14 @@
 		{
 			name:       "package path in vulns endpoint",
 			url:        "/v1beta/vulns/example.com/pkg?version=v1.0.0",
-			wantStatus: http.StatusBadRequest,
+			wantStatus: http.StatusOK,
+			wantCount:  2,
+		},
+		{
+			name:       "another package path",
+			url:        "/v1beta/vulns/example.com/other?version=v1.0.0",
+			wantStatus: http.StatusOK,
+			wantCount:  2,
 		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml
index c9f07b1..2ec7eca 100644
--- a/internal/api/openapi.yaml
+++ b/internal/api/openapi.yaml
@@ -436,6 +436,14 @@
         "operationId": "getVulns",
         "parameters": [
           {
+            "description": "Module path.",
+            "in": "query",
+            "name": "module",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
             "description": "Module version: semantic version, 'latest', or default branches 'master' or 'main'.\n(Latest if empty).",
             "in": "query",
             "name": "version",
@@ -480,7 +488,7 @@
             "description": "Successful response"
           }
         },
-        "summary": "Vulnerabilities of the module at {path}, from\nthe Go vulnerability database (https://vuln.go.dev).\nOnly results whose ID or details\nmatches the regexp in the filter query parameter are returned."
+        "summary": "Vulnerabilities of the module or package at {path}, from\nthe Go vulnerability database (https://vuln.go.dev).\nOnly results whose ID or details\nmatches the regexp in the filter query parameter are returned."
       }
     }
   },
diff --git a/internal/api/params.go b/internal/api/params.go
index 2fc66ea..32b8403 100644
--- a/internal/api/params.go
+++ b/internal/api/params.go
@@ -101,8 +101,10 @@
 	ListParams
 }
 
-// VulnParams are query parameters for /v1beta/vulns/{module}.
+// VulnParams are query parameters for /v1beta/vulns/{path}.
 type VulnParams struct {
+	// Module path.
+	Module string `form:"module"`
 	// Module version: semantic version, 'latest', or default branches 'master' or 'main'.
 	// (Latest if empty).
 	Version string `form:"version"`