internal/proxy, cmd/vulnreport: fix infinite loop on root module paths

Prevent FindModule from looping indefinitely when path is "/" or
contains a leading slash where path.Dir returns itself. Also ignore
standalone slashes in vulnreport issue title parsing.

Change-Id: I76f19ab826d4816a949686079fdd7f25009c0bba
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/821280
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Damien Neil <dneil@google.com>
diff --git a/cmd/vulnreport/creator.go b/cmd/vulnreport/creator.go
index e7d601d..d2fb67a 100644
--- a/cmd/vulnreport/creator.go
+++ b/cmd/vulnreport/creator.go
@@ -9,6 +9,7 @@
 	"fmt"
 	"net/http"
 	"os"
+	"regexp"
 	"slices"
 	"strings"
 
@@ -319,21 +320,22 @@
 	return ""
 }
 
+var modulePathRegexp = regexp.MustCompile(`^"?([[:alpha:]][\w./-]*[./][\w-]+)"?:?$`)
+
 func modulePath(iss *issues.Issue) string {
-	for _, p := range strings.Fields(iss.Title) {
+	for p := range strings.FieldsSeq(iss.Title) {
 		if p == "x/vulndb:" {
 			continue
 		}
-		if strings.HasSuffix(p, ":") || strings.Contains(p, "/") {
-			// Remove backslashes.
-			return strings.ReplaceAll(strings.TrimSuffix(p, ":"), "\"", "")
+		if m := modulePathRegexp.FindStringSubmatch(p); len(m) > 1 {
+			return m[1]
 		}
 	}
 	return ""
 }
 
 func aliases(iss *issues.Issue) (aliases []string) {
-	for _, p := range strings.Fields(iss.Title) {
+	for p := range strings.FieldsSeq(iss.Title) {
 		if idstr.IsAliasType(p) {
 			aliases = append(aliases, strings.TrimSuffix(p, ","))
 		}
@@ -417,7 +419,8 @@
 	todos := []*report.Reference{
 		{Type: osv.ReferenceTypeAdvisory, URL: "TODO: canonical security advisory"},
 		{Type: osv.ReferenceTypeReport, URL: "TODO: issue tracker link"},
-		{Type: osv.ReferenceTypeFix, URL: "TODO: PR or commit (commit preferred)"}}
+		{Type: osv.ReferenceTypeFix, URL: "TODO: PR or commit (commit preferred)"},
+	}
 
 	types := make(map[osv.ReferenceType]bool)
 	for _, r := range r.References {
diff --git a/cmd/vulnreport/vulnreport_test.go b/cmd/vulnreport/vulnreport_test.go
index 4414354..31ba3c8 100644
--- a/cmd/vulnreport/vulnreport_test.go
+++ b/cmd/vulnreport/vulnreport_test.go
@@ -6,6 +6,8 @@
 
 import (
 	"testing"
+
+	"golang.org/x/vulndb/internal/issues"
 )
 
 func TestCreate(t *testing.T) {
@@ -30,6 +32,51 @@
 	}
 }
 
+func TestModulePath(t *testing.T) {
+	testCases := []struct {
+		title string
+		want  string
+	}{
+		{
+			title: "x/vulndb: potential Go vuln in github.com/foo/bar: GHSA-xxxx",
+			want:  "github.com/foo/bar",
+		},
+		{
+			title: "x/vulndb: update fixed versions for GO-2026-4513 / duplicate GO-2026-4740",
+			want:  "",
+		},
+		{
+			title: "x/vulndb: potential Go vuln in crypto/tls: CVE-2025-0001",
+			want:  "crypto/tls",
+		},
+		{
+			title: `x/vulndb: potential Go vuln in "github.com/foo/bar": GHSA-xxxx`,
+			want:  "github.com/foo/bar",
+		},
+		{
+			title: "x/vulndb: potential Go vuln in collectd.org: CVE-2021-0000",
+			want:  "collectd.org",
+		},
+		{
+			title: "x/vulndb: potential Go vuln in 1234/foo: GHSA-xxxx",
+			want:  "",
+		},
+		{
+			title: "x/vulndb: potential Go vuln in 4.15.2/foo: GHSA-xxxx",
+			want:  "",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.title, func(t *testing.T) {
+			iss := &issues.Issue{Title: tc.title}
+			if got := modulePath(iss); got != tc.want {
+				t.Errorf("modulePath(%q) = %q, want %q", tc.title, got, tc.want)
+			}
+		})
+	}
+}
+
 func TestCreateExcluded(t *testing.T) {
 	for _, tc := range []*testCase{
 		// TODO(tatianabradley): add test cases
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
index 13d019a..ad22111 100644
--- a/internal/proxy/proxy.go
+++ b/internal/proxy/proxy.go
@@ -258,10 +258,15 @@
 func (c *Client) FindModule(path string) (modPath string, err error) {
 	derrors.Wrap(&err, "FindModule(%s)", path)
 
-	for candidate := path; candidate != "."; candidate = urlpath.Dir(candidate) {
+	for candidate := path; candidate != "." && candidate != "/"; {
 		if c.ModuleExists(candidate) {
 			return candidate, nil
 		}
+		next := urlpath.Dir(candidate)
+		if next == candidate {
+			break
+		}
+		candidate = next
 	}
 
 	return "", errNoModuleFound
diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go
index 4261a81..23f0702 100644
--- a/internal/proxy/proxy_test.go
+++ b/internal/proxy/proxy_test.go
@@ -286,6 +286,21 @@
 			path: "github.com/RobotsAndPencils/go-saml/util",
 			want: "github.com/RobotsAndPencils/go-saml",
 		},
+		{
+			name:    "slash path",
+			path:    "/",
+			wantErr: errNoModuleFound,
+		},
+		{
+			name:    "leading slash path",
+			path:    "/foo/bar",
+			wantErr: errNoModuleFound,
+		},
+		{
+			name:    "empty path",
+			path:    "",
+			wantErr: errNoModuleFound,
+		},
 	}
 
 	for _, tc := range tcs {