internal/{frontend,vuln}: make vuln search case-insensitive

Vulnerability search (by Go ID, CVE or GHSA) is now case-insensitive.

Also fixes the regular expression for GHSAs which was too permissive.

Change-Id: I2d53bfe41a1f8f78f6dd8774d3c9accc4461d101
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/506517
Run-TryBot: Tatiana Bradley <tatianabradley@google.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Jamal Carvalho <jamal@golang.org>
Reviewed-by: Damien Neil <dneil@google.com>
diff --git a/internal/frontend/search.go b/internal/frontend/search.go
index ffd6c30..28f168b 100644
--- a/internal/frontend/search.go
+++ b/internal/frontend/search.go
@@ -351,8 +351,8 @@
 	if urlSchemeIdx > -1 {
 		query = query[urlSchemeIdx+3:]
 	}
-	if vulnSupport && vuln.IsGoID(query) {
-		return fmt.Sprintf("/vuln/%s?q", query)
+	if id, ok := vuln.CanonicalGoID(query); vulnSupport && ok {
+		return fmt.Sprintf("/vuln/%s?q", id)
 	}
 	requestedPath := path.Clean(query)
 	if !strings.Contains(requestedPath, "/") || mode == searchModeVuln {
@@ -388,14 +388,15 @@
 func searchVulnAlias(ctx context.Context, mode, cq string, vc *vuln.Client) (_ *searchAction, err error) {
 	defer derrors.Wrap(&err, "searchVulnAlias(%q, %q)", mode, cq)
 
-	if mode != searchModeVuln || !vuln.IsAlias(cq) || vc == nil {
+	alias, ok := vuln.CanonicalAlias(cq)
+	if mode != searchModeVuln || !ok || vc == nil {
 		return nil, nil
 	}
-	id, err := vc.ByAlias(ctx, cq)
+	goID, err := vc.ByAlias(ctx, alias)
 	if err != nil {
 		return nil, &serverError{status: derrors.ToStatus(err)}
 	}
-	return &searchAction{redirectURL: "/vuln/" + id}, nil
+	return &searchAction{redirectURL: "/vuln/" + goID}, nil
 }
 
 // searchMode reports whether the search performed should be in package or
@@ -413,7 +414,7 @@
 	case searchModeVuln:
 		return searchModeVuln
 	default:
-		if vuln.IsAlias(q) {
+		if _, ok := vuln.CanonicalAlias(q); ok {
 			return searchModeVuln
 		}
 		if shouldDefaultToSymbolSearch(q) {
diff --git a/internal/frontend/search_test.go b/internal/frontend/search_test.go
index 0bf49e6..55441e5 100644
--- a/internal/frontend/search_test.go
+++ b/internal/frontend/search_test.go
@@ -108,7 +108,7 @@
 		// See TestSearchVulnAlias in this file for more tests.
 		{
 			name:         "vuln alias",
-			query:        "q=GHSA-aaaa-bbbb-cccc&m=vuln",
+			query:        "q=GHSA-cccc-ffff-gggg&m=vuln",
 			wantRedirect: "/vuln/GO-1990-0001",
 		},
 		{
@@ -119,18 +119,18 @@
 		{
 			// We turn on vuln mode if the query matches a vuln alias.
 			name:         "vuln alias not vuln mode",
-			query:        "q=GHSA-aaaa-bbbb-cccc",
+			query:        "q=GHSA-cccc-ffff-gggg",
 			wantRedirect: "/vuln/GO-1990-0001",
 		},
 		{
 			name:       "vuln alias with no match",
-			query:      "q=GHSA-aaaa-bbbb-dddd",
+			query:      "q=GHSA-cccc-ffff-xxxx",
 			wantStatus: http.StatusNotFound,
 		},
 		{
 			// An explicit mode overrides that.
 			name:         "vuln alias symbol mode",
-			query:        "q=GHSA-aaaa-bbbb-cccc?m=symbol",
+			query:        "q=GHSA-cccc-ffff-gggg?m=symbol",
 			wantTemplate: "search",
 		},
 		{
@@ -502,7 +502,7 @@
 			got := newSearchResult(&test.in, false, pr)
 			test.want.CommitTime = "unknown"
 			if diff := cmp.Diff(&test.want, got); diff != "" {
-				t.Errorf("mimatch (-want, +got):\n%s", diff)
+				t.Errorf("mismatch (-want, +got):\n%s", diff)
 			}
 		})
 	}
@@ -540,6 +540,7 @@
 		{"non-existent path does not redirect", "github.com/non-existent", "", ""},
 		{"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", ""},
+		{"Lower-case Go vuln redirects", "go-1969-0720", "/vuln/GO-1969-0720?q", ""},
 		{"not a Go vuln", "somepkg/GO-1969-0720", "", ""},
 		// Just setting the search mode to vuln does not cause a redirect.
 		{"search mode is vuln", "searchmodevuln", "", searchModeVuln},
@@ -593,7 +594,13 @@
 		{
 			name:    "one match",
 			mode:    searchModeVuln,
-			query:   "GHSA-aaaa-bbbb-cccc",
+			query:   "GHSA-cccc-ffff-gggg",
+			wantURL: "/vuln/GO-1990-0001",
+		},
+		{
+			name:    "one match - case insensitive",
+			mode:    searchModeVuln,
+			query:   "gHSa-ccCc-fFFF-gGgG",
 			wantURL: "/vuln/GO-1990-0001",
 		},
 	} {
diff --git a/internal/frontend/vulns.go b/internal/frontend/vulns.go
index 7dab33b..93aaf1d 100644
--- a/internal/frontend/vulns.go
+++ b/internal/frontend/vulns.go
@@ -89,8 +89,8 @@
 			template: "vuln/list",
 			title:    "Vulnerability Reports"}, nil
 	default: // the path should be "/<ID>", e.g. "/GO-2021-0001".
-		id := strings.TrimPrefix(path, "/")
-		if !vuln.IsGoID(id) {
+		id, ok := vuln.CanonicalGoID(strings.TrimPrefix(path, "/"))
+		if !ok {
 			if url.Query().Has("q") {
 				return nil, derrors.NotFound
 			}
diff --git a/internal/frontend/vulns_test.go b/internal/frontend/vulns_test.go
index 435b1bb..a9a2a1d 100644
--- a/internal/frontend/vulns_test.go
+++ b/internal/frontend/vulns_test.go
@@ -16,7 +16,7 @@
 )
 
 var testEntries = []*osv.Entry{
-	{ID: "GO-1990-0001", Details: "a", Aliases: []string{"CVE-2000-1", "GHSA-aaaa-bbbb-cccc"}},
+	{ID: "GO-1990-0001", Details: "a", Aliases: []string{"CVE-2000-1", "GHSA-cccc-ffff-gggg"}},
 	{ID: "GO-1990-0002", Details: "b", Aliases: []string{"CVE-2000-1", "GHSA-1111-2222-3333"}},
 	{ID: "GO-1990-0010", Details: "c"},
 	{ID: "GO-1991-0001", Details: "d"},
@@ -104,6 +104,18 @@
 				title:    "GO-1990-0002",
 			},
 		},
+		{
+			name: "vuln entry page - case insensitive",
+			url:  "https://pkg.go.dev/vuln/go-1990-0002",
+			want: &vulnPage{
+				page: &VulnEntryPage{
+					Entry:      testEntries[1],
+					AliasLinks: aliasLinks(testEntries[1]),
+				},
+				template: "vuln/entry",
+				title:    "GO-1990-0002",
+			},
+		},
 	}
 
 	for _, tc := range tcs {
diff --git a/internal/vuln/regexp.go b/internal/vuln/regexp.go
index 418bd65..5986075 100644
--- a/internal/vuln/regexp.go
+++ b/internal/vuln/regexp.go
@@ -4,21 +4,48 @@
 
 package vuln
 
-import "regexp"
-
-var (
-	goRegexp   = regexp.MustCompile("^GO-[0-9]{4}-[0-9]{4,}$")
-	cveRegexp  = regexp.MustCompile("^CVE-[0-9]{4}-[0-9]+$")
-	ghsaRegexp = regexp.MustCompile("^GHSA-.{4}-.{4}-.{4}$")
+import (
+	"regexp"
+	"strings"
 )
 
-// IsGoID returns whether s is a valid Go vulnerability ID.
-func IsGoID(s string) bool {
-	return goRegexp.MatchString(s)
+const (
+	ci    = "(?i)" // case-insensitive
+	goRE  = "^GO-[0-9]{4}-[0-9]{4,}$"
+	cveRE = "^CVE-[0-9]{4}-[0-9]+$"
+	// Regexp adapted from https://github.com/github/advisory-database.
+	ghsaRE = "^(GHSA)((-[23456789cfghjmpqrvwx]{4}){3})$"
+)
+
+// Case-insensitive regexps for vuln IDs/aliases.
+var (
+	goID   = regexp.MustCompile(ci + goRE)
+	cveID  = regexp.MustCompile(ci + cveRE)
+	ghsaID = regexp.MustCompile(ci + ghsaRE)
+)
+
+// Canonical returns the canonical form of the given Go ID string
+// by correcting the case.
+//
+// If no canonical form can be found, returns false.
+func CanonicalGoID(id string) (_ string, ok bool) {
+	if goID.MatchString(id) {
+		return strings.ToUpper(id), true
+	}
+	return "", false
 }
 
-// IsAlias returns whether s is a valid vulnerability alias
-// (CVE or GHSA).
-func IsAlias(s string) bool {
-	return cveRegexp.MatchString(s) || ghsaRegexp.MatchString(s)
+// Canonical returns the canonical form of the given alias ID string
+// (a CVE or GHSA id) by correcting the case.
+//
+// If no canonical form can be found, returns false.
+func CanonicalAlias(id string) (_ string, ok bool) {
+	if cveID.MatchString(id) {
+		return strings.ToUpper(id), true
+	}
+	parts := ghsaID.FindStringSubmatch(id)
+	if len(parts) != 4 {
+		return "", false
+	}
+	return strings.ToUpper(parts[1]) + strings.ToLower(parts[2]), true
 }
diff --git a/internal/vuln/regexp_test.go b/internal/vuln/regexp_test.go
index ca66b53..05ed435 100644
--- a/internal/vuln/regexp_test.go
+++ b/internal/vuln/regexp_test.go
@@ -6,65 +6,115 @@
 
 import "testing"
 
-func TestIsGoID(t *testing.T) {
+func TestCanonicalGoID(t *testing.T) {
 	tests := []struct {
-		id   string
-		want bool
+		id     string
+		wantID string
+		wantOK bool
 	}{
 		{
-			id:   "GO-1999-0001",
-			want: true,
+			id:     "GO-1999-0001",
+			wantID: "GO-1999-0001",
+			wantOK: true,
 		},
 		{
-			id:   "GO-2023-12345678",
-			want: true,
+			id:     "GO-1999-000111",
+			wantID: "GO-1999-000111",
+			wantOK: true,
 		},
 		{
-			id:   "GO-2023-123",
-			want: false,
+			id:     "go-1999-0001",
+			wantID: "GO-1999-0001",
+			wantOK: true,
 		},
 		{
-			id:   "GO-abcd-0001",
-			want: false,
+			id:     "GO-1999",
+			wantID: "",
+			wantOK: false,
 		},
 		{
-			id:   "CVE-1999-0001",
-			want: false,
+			id:     "GHSA-cfgh-2345-rwxq",
+			wantID: "",
+			wantOK: false,
+		},
+		{
+			id:     "CVE-1999-000123",
+			wantID: "",
+			wantOK: false,
+		},
+		{
+			id:     "ghsa-Cfgh-2345-Rwxq",
+			wantID: "",
+			wantOK: false,
+		},
+		{
+			id:     "cve-1999-000123",
+			wantID: "",
+			wantOK: false,
+		},
+		{
+			id:     "cve-ghsa-go",
+			wantID: "",
+			wantOK: false,
 		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.id, func(t *testing.T) {
-			got := IsGoID(tt.id)
-			if got != tt.want {
-				t.Errorf("IsGoID(%s) = %t, want %t", tt.id, got, tt.want)
+			gotID, gotOK := CanonicalGoID(tt.id)
+			if gotID != tt.wantID || gotOK != tt.wantOK {
+				t.Errorf("CanonicalGoID(%s) = (%s, %t), want (%s, %t)", tt.id, gotID, gotOK, tt.wantID, tt.wantOK)
 			}
 		})
 	}
 }
 
-func TestIsAlias(t *testing.T) {
+func TestCanonicalAlias(t *testing.T) {
 	tests := []struct {
-		id   string
-		want bool
+		id     string
+		wantID string
+		wantOK bool
 	}{
 		{
-			id:   "GO-1999-0001",
-			want: false,
+			id:     "GO-1999-0001",
+			wantID: "",
+			wantOK: false,
 		},
 		{
-			id:   "GHSA-abcd-1234-efgh",
-			want: true,
+			id:     "GHSA-cfgh-2345-rwxq",
+			wantID: "GHSA-cfgh-2345-rwxq",
+			wantOK: true,
 		},
 		{
-			id:   "CVE-1999-000123",
-			want: true,
+			id:     "CVE-1999-000123",
+			wantID: "CVE-1999-000123",
+			wantOK: true,
+		},
+		{
+			id:     "go-1999-0001",
+			wantID: "",
+			wantOK: false,
+		},
+		{
+			id:     "ghsa-Cfgh-2345-Rwxq",
+			wantID: "GHSA-cfgh-2345-rwxq",
+			wantOK: true,
+		},
+		{
+			id:     "cve-1999-000123",
+			wantID: "CVE-1999-000123",
+			wantOK: true,
+		},
+		{
+			id:     "abc-CVE-1999-0001",
+			wantID: "",
+			wantOK: false,
 		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.id, func(t *testing.T) {
-			got := IsAlias(tt.id)
-			if got != tt.want {
-				t.Errorf("IsAlias(%s) = %t, want %t", tt.id, got, tt.want)
+			gotID, gotOK := CanonicalAlias(tt.id)
+			if gotID != tt.wantID || gotOK != tt.wantOK {
+				t.Errorf("CanonicalAlias(%s) = (%s, %t), want (%s, %t)", tt.id, gotID, gotOK, tt.wantID, tt.wantOK)
 			}
 		})
 	}