x/vulndb: support issues keyed by GHSA in `vulnreport create`

Command `vulnreport create` can now be run on Github issues of the form `x/vulndb: potential Go vuln in some/pkg: GHSA-some-ghsa-id`.

Introduces new function in `internal/ghsa` to fetch security advisories by GHSA id via Github API, which re-uses some factored out logic from existing `List` function.

Moves (and extends) functionality to convert security advisories into reports from `internal/worker` to `internal/report` so it can be used by both the worker and the vulnreport command.

Fixes golang/go#52361

Change-Id: I6902e8db4801245908b4a112b047ca5cc62db996
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/400495
Reviewed-by: kokoro <noreply+kokoro@google.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Julie Qiu <julieqiu@google.com>
diff --git a/cmd/vulnreport/main.go b/cmd/vulnreport/main.go
index 0470668..a594e95 100644
--- a/cmd/vulnreport/main.go
+++ b/cmd/vulnreport/main.go
@@ -142,7 +142,7 @@
 	if err != nil {
 		return err
 	}
-	// Parse CVE ID from GitHub issue.
+	// Parse CVE or GHSA ID from GitHub issue.
 	parts := strings.Fields(iss.Title)
 	var modulePath string
 	for _, p := range parts {
@@ -151,15 +151,24 @@
 			break
 		}
 	}
-	cveID := parts[len(parts)-1]
-	if !strings.HasPrefix(cveID, "CVE") {
-		return fmt.Errorf("expected last element of title to be the CVE ID; got %q", iss.Title)
+	id := parts[len(parts)-1]
+	var r *report.Report
+	switch {
+	case strings.HasPrefix(id, "CVE"):
+		cve, err := cvelistrepo.FetchCVE(ctx, repoPath, id)
+		if err != nil {
+			return err
+		}
+		r = report.CVEToReport(cve, modulePath)
+	case strings.HasPrefix(id, "GHSA"):
+		ghsa, err := ghsa.FetchGHSA(ctx, ghToken, id)
+		if err != nil {
+			return err
+		}
+		r = report.GHSAToReport(ghsa, modulePath)
+	default:
+		return fmt.Errorf("expected last element of title to be the CVE ID or GHSA ID; got %q", iss.Title)
 	}
-	cve, err := cvelistrepo.FetchCVE(ctx, repoPath, cveID)
-	if err != nil {
-		return err
-	}
-	r := report.CVEToReport(cve, modulePath)
 	addTODOs(r)
 	return r.Write(fmt.Sprintf("reports/GO-2021-%04d.yaml", issueNumber))
 }
@@ -189,6 +198,9 @@
 	if r.Links.Commit == "" {
 		r.Links.Commit = todo
 	}
+	if len(r.Links.Context) == 0 {
+		r.Links.Context = []string{todo}
+	}
 	if len(r.Versions) == 0 {
 		r.Versions = []report.VersionRange{{
 			Introduced: todo,
diff --git a/internal/ghsa/ghsa.go b/internal/ghsa/ghsa.go
index 5af39b9..47fdf5d 100644
--- a/internal/ghsa/ghsa.go
+++ b/internal/ghsa/ghsa.go
@@ -68,52 +68,96 @@
 	return s.ID
 }
 
+// A gqlSecurityAdvisory represents a GitHub security advisory structured for
+// GitHub's GraphQL schema. The fields must be exported to be populated by
+// Github's Client.Query function.
+type gqlSecurityAdvisory struct {
+	ID              string
+	Identifiers     []Identifier
+	Summary         string
+	Description     string
+	Origin          string
+	Permalink       githubv4.URI
+	PublishedAt     time.Time
+	UpdatedAt       time.Time
+	Vulnerabilities struct {
+		Nodes []struct {
+			Package struct {
+				Name      string
+				Ecosystem string
+			}
+			FirstPatchedVersion struct{ Identifier string }
+			// TODO(https://go.dev/issue/52550): uncomment when
+			// https://support.github.com/ticket/personal/0/1599280
+			// is fixed.
+			//Severity               githubv4.SecurityAdvisorySeverity
+			UpdatedAt              time.Time
+			VulnerableVersionRange string
+		}
+		PageInfo struct {
+			HasNextPage bool
+		}
+	} `graphql:"vulnerabilities(first: 100, ecosystem: $go)"` // include only Go vulns
+}
+
+// securityAdvisory converts a gqlSecurityAdvisory into a SecurityAdvisory.
+// Errors if the security advisory was updated before it was published, or if
+// there are more than 100 vulnerabilities associated with the advisory.
+func (sa *gqlSecurityAdvisory) securityAdvisory() (*SecurityAdvisory, error) {
+	if sa.PublishedAt.After(sa.UpdatedAt) {
+		return nil, fmt.Errorf("%s: published at %s, after updated at %s", sa.ID, sa.PublishedAt, sa.UpdatedAt)
+	}
+	if sa.Vulnerabilities.PageInfo.HasNextPage {
+		return nil, fmt.Errorf("%s has more than 100 vulns", sa.ID)
+	}
+	s := &SecurityAdvisory{
+		ID:          sa.ID,
+		Identifiers: sa.Identifiers,
+		Summary:     sa.Summary,
+		Description: sa.Description,
+		Origin:      sa.Origin,
+		Permalink:   sa.Permalink.URL.String(),
+		PublishedAt: sa.PublishedAt,
+		UpdatedAt:   sa.UpdatedAt,
+	}
+	for _, v := range sa.Vulnerabilities.Nodes {
+		s.Vulns = append(s.Vulns, &Vuln{
+			Package: v.Package.Name,
+			// TODO(https://go.dev/issue/52550): uncomment when
+			// https://support.github.com/ticket/personal/0/1599280
+			// is fixed.
+			//Severity:               v.Severity,
+			EarliestFixedVersion:   v.FirstPatchedVersion.Identifier,
+			VulnerableVersionRange: v.VulnerableVersionRange,
+			UpdatedAt:              v.UpdatedAt,
+		})
+	}
+	return s, nil
+}
+
+func newGitHubClient(ctx context.Context, accessToken string) *githubv4.Client {
+	ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken})
+	tc := oauth2.NewClient(ctx, ts)
+	return githubv4.NewClient(tc)
+}
+
 // List returns all SecurityAdvisories that affect Go,
 // published or updated since the given time.
-// The withCVE argument controls whether to select advisories that are
-// connected to CVEs.
+// If withCVE is true, selects only advisories that are
+// connected to CVEs, otherwise selects only advisories without CVEs.
 func List(ctx context.Context, accessToken string, since time.Time, withCVE bool) ([]*SecurityAdvisory, error) {
-	ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken})
-	tc := oauth2.NewClient(context.Background(), ts)
-	client := githubv4.NewClient(tc)
+	client := newGitHubClient(ctx, accessToken)
 
 	var query struct { // the GraphQL query
 		SAs struct {
-			Nodes []struct {
-				ID              string
-				Identifiers     []Identifier
-				Summary         string
-				Description     string
-				Origin          string
-				Permalink       githubv4.URI
-				PublishedAt     time.Time
-				UpdatedAt       time.Time
-				Vulnerabilities struct {
-					Nodes []struct {
-						Package struct {
-							Name      string
-							Ecosystem string
-						}
-						FirstPatchedVersion struct{ Identifier string }
-						// TODO(https://go.dev/issue/52550): uncomment when
-						// https://support.github.com/ticket/personal/0/1599280
-						// is fixed.
-						//Severity               githubv4.SecurityAdvisorySeverity
-						UpdatedAt              time.Time
-						VulnerableVersionRange string
-					}
-					PageInfo struct {
-						HasNextPage bool
-					}
-				} `graphql:"vulnerabilities(first: 100, ecosystem: $go)"` // include only Go vulns
-			}
+			Nodes    []gqlSecurityAdvisory
 			PageInfo struct {
 				EndCursor   githubv4.String
 				HasNextPage bool
 			}
 		} `graphql:"securityAdvisories(updatedSince: $since, first: 100, after: $cursor)"`
 	}
-	vars := map[string]interface{}{
+	vars := map[string]any{
 		"cursor": (*githubv4.String)(nil),
 		"go":     githubv4.SecurityAdvisoryEcosystemGo,
 		"since":  githubv4.DateTime{Time: since},
@@ -127,39 +171,15 @@
 			return nil, err
 		}
 		for _, sa := range query.SAs.Nodes {
-			if sa.PublishedAt.After(sa.UpdatedAt) {
-				return nil, fmt.Errorf("%s: published at %s, after updated at %s", sa.ID, sa.PublishedAt, sa.UpdatedAt)
-			}
 			if withCVE != isCVE(sa.Identifiers) {
 				continue
 			}
 			if len(sa.Vulnerabilities.Nodes) == 0 {
 				continue
 			}
-			if sa.Vulnerabilities.PageInfo.HasNextPage {
-				return nil, fmt.Errorf("%s has more than 100 vulns", sa.ID)
-			}
-			s := &SecurityAdvisory{
-				ID:          sa.ID,
-				Identifiers: sa.Identifiers,
-				Summary:     sa.Summary,
-				Description: sa.Description,
-				Origin:      sa.Origin,
-				Permalink:   sa.Permalink.URL.String(),
-				PublishedAt: sa.PublishedAt,
-				UpdatedAt:   sa.UpdatedAt,
-			}
-			for _, v := range sa.Vulnerabilities.Nodes {
-				s.Vulns = append(s.Vulns, &Vuln{
-					Package: v.Package.Name,
-					// TODO(https://go.dev/issue/52550): uncomment when
-					// https://support.github.com/ticket/personal/0/1599280
-					// is fixed.
-					//Severity:               v.Severity,
-					EarliestFixedVersion:   v.FirstPatchedVersion.Identifier,
-					VulnerableVersionRange: v.VulnerableVersionRange,
-					UpdatedAt:              v.UpdatedAt,
-				})
+			s, err := sa.securityAdvisory()
+			if err != nil {
+				return nil, err
 			}
 			sas = append(sas, s)
 		}
@@ -171,6 +191,26 @@
 	return sas, nil
 }
 
+// FetchGHSA returns the SecurityAdvisory for the given Github Security
+// Advisory ID.
+func FetchGHSA(ctx context.Context, accessToken, ghsaID string) (_ *SecurityAdvisory, err error) {
+	client := newGitHubClient(ctx, accessToken)
+
+	var query struct {
+		SA gqlSecurityAdvisory `graphql:"securityAdvisory(ghsaId: $id)"`
+	}
+	vars := map[string]any{
+		"id": githubv4.String(ghsaID),
+		"go": githubv4.SecurityAdvisoryEcosystemGo,
+	}
+
+	if err := client.Query(ctx, &query, vars); err != nil {
+		return nil, err
+	}
+
+	return query.SA.securityAdvisory()
+}
+
 func isCVE(ids []Identifier) bool {
 	for _, id := range ids {
 		if id.Type == "CVE" {
diff --git a/internal/ghsa/ghsa_test.go b/internal/ghsa/ghsa_test.go
index d147844..57af6fa 100644
--- a/internal/ghsa/ghsa_test.go
+++ b/internal/ghsa/ghsa_test.go
@@ -16,7 +16,7 @@
 var githubTokenFile = flag.String("ghtokenfile", "",
 	"path to file containing GitHub access token")
 
-func TestList(t *testing.T) {
+func mustGetAccessToken(t *testing.T) string {
 	if *githubTokenFile == "" {
 		t.Skip("-ghtokenfile not provided")
 	}
@@ -24,7 +24,11 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	accessToken := strings.TrimSpace(string(bytes))
+	return strings.TrimSpace(string(bytes))
+}
+
+func TestList(t *testing.T) {
+	accessToken := mustGetAccessToken(t)
 	// There were at least three relevant SAs since this date.
 	since := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
 	const withoutCVEs = false
@@ -42,3 +46,24 @@
 		}
 	}
 }
+
+func TestFetchGHSA(t *testing.T) {
+	accessToken := mustGetAccessToken(t)
+	// Real GHSA that should be found.
+	const ghsaID string = "GHSA-g9mp-8g3h-3c5c"
+	got, err := FetchGHSA(context.Background(), accessToken, ghsaID)
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := ghsaID
+	var gotID string
+	for _, id := range got.Identifiers {
+		if id.Type == "GHSA" {
+			gotID = id.Value
+			break
+		}
+	}
+	if gotID != want {
+		t.Errorf("got GHSA with id %q, want %q", got.ID, want)
+	}
+}
diff --git a/internal/report/ghsa.go b/internal/report/ghsa.go
new file mode 100644
index 0000000..8872e1d
--- /dev/null
+++ b/internal/report/ghsa.go
@@ -0,0 +1,122 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package report
+
+import (
+	"fmt"
+	"strings"
+
+	"golang.org/x/vulndb/internal"
+	"golang.org/x/vulndb/internal/ghsa"
+)
+
+// GHSAToReport creates a Report struct from a given GHSA SecurityAdvisory and modulePath.
+func GHSAToReport(sa *ghsa.SecurityAdvisory, modulePath string) *Report {
+	u := sa.UpdatedAt
+	r := &Report{
+		Module:       modulePath,
+		Description:  sa.Description,
+		Published:    sa.PublishedAt,
+		LastModified: &u,
+		Links:        Links{Context: []string{sa.Permalink}},
+	}
+	var cves, ghsas []string
+	for _, id := range sa.Identifiers {
+		switch id.Type {
+		case "CVE":
+			cves = append(cves, id.Value)
+		case "GHSA":
+			ghsas = append(ghsas, id.Value)
+		}
+	}
+	r.CVEs = cves
+	r.GHSAs = ghsas
+	if len(sa.Vulns) == 0 {
+		return r
+	}
+	r.Package = sa.Vulns[0].Package
+	r.Versions = versions(sa.Vulns[0].EarliestFixedVersion, sa.Vulns[0].VulnerableVersionRange)
+	for _, v := range sa.Vulns[1:] {
+		var a Additional
+		a.Package = v.Package
+		a.Versions = versions(v.EarliestFixedVersion, v.VulnerableVersionRange)
+		r.AdditionalPackages = append(r.AdditionalPackages, a)
+	}
+	r.Fix()
+	return r
+}
+
+// versions extracts the versions in which a vulnerability was introduced and
+// fixed from a Github Security Advisory's EarliestFixedVersion and
+// VulnerableVersionRange fields, and wraps them in a []VersionRange.
+//
+// If the vulnRange cannot be parsed, or the earliestFixed and vulnRange are
+// incompatible, populate the relevant fields with a TODO for a human to handle.
+func versions(earliestFixed, vulnRange string) []VersionRange {
+	// Don't try to be fully general here. Handle the common cases (which, as of
+	// March 2022, are the only cases), and let a person handle the others.
+	items, err := parseVulnRange(vulnRange)
+	if err != nil {
+		return []VersionRange{{
+			Introduced: fmt.Sprintf("TODO (got error %q)", err),
+		}}
+	}
+
+	var intro, fixed string
+
+	// Most common case: a single "<" item with a version that matches earliestFixed.
+	if len(items) == 1 && items[0].op == "<" && items[0].version == earliestFixed {
+		intro = "v0.0.0"
+		fixed = "v" + earliestFixed
+	}
+
+	// Two items, one >= and one <, with the latter matching earliestFixed.
+	if len(items) == 2 && items[0].op == ">=" && items[1].op == "<" && items[1].version == earliestFixed {
+		intro = "v" + items[0].version
+		fixed = "v" + earliestFixed
+	}
+
+	// A single "<=" item with no fixed version.
+	if len(items) == 1 && items[0].op == "<=" && earliestFixed == "" {
+		intro = "v0.0.0"
+	}
+
+	if intro == "" {
+		intro = fmt.Sprintf("TODO (earliest fixed %q, vuln range %q)", earliestFixed, vulnRange)
+	}
+
+	// Unset intro if vuln was always present.
+	if intro == "v0.0.0" {
+		intro = ""
+	}
+
+	return []VersionRange{{Introduced: intro, Fixed: fixed}}
+}
+
+type vulnRangeItem struct {
+	op, version string
+}
+
+// parseVulnRange splits the contents of a GitHub Security Advisory's
+// VulnerableVersionRange field into separate items.
+func parseVulnRange(s string) ([]vulnRangeItem, error) {
+	// A GHSA vuln range is a comma-separated list of items of the form "OP VERSION"
+	// where OP is one of "<", ">", "<=" or ">=" and VERSION is a semantic
+	// version.
+	var items []vulnRangeItem
+	parts := strings.Split(s, ",")
+	for _, p := range parts {
+		p = strings.TrimSpace(p)
+		if p == "" {
+			continue
+		}
+		before, after, found := internal.Cut(p, " ")
+		if !found {
+			return nil, fmt.Errorf("invalid vuln range item %q", p)
+		}
+		items = append(items, vulnRangeItem{strings.TrimSpace(before), strings.TrimSpace(after)})
+	}
+	return items, nil
+}
diff --git a/internal/report/ghsa_test.go b/internal/report/ghsa_test.go
new file mode 100644
index 0000000..d94f454
--- /dev/null
+++ b/internal/report/ghsa_test.go
@@ -0,0 +1,93 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package report
+
+import (
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/vulndb/internal/ghsa"
+)
+
+func TestGHSAToReport(t *testing.T) {
+	updatedTime := time.Date(2022, 01, 01, 01, 01, 00, 00, time.UTC)
+	sa := &ghsa.SecurityAdvisory{
+		ID:          "G1_blah",
+		Identifiers: []ghsa.Identifier{{Type: "GHSA", Value: "G1"}, {Type: "CVE", Value: "C1"}},
+		UpdatedAt:   updatedTime,
+		Permalink:   "https://github.com/permalink/to/G1",
+		Description: "a description",
+		Vulns: []*ghsa.Vuln{{
+			Package:                "aPackage",
+			EarliestFixedVersion:   "1.2.3",
+			VulnerableVersionRange: "< 1.2.3",
+		}},
+	}
+	got := GHSAToReport(sa, "aModule")
+	want := &Report{
+		Module:  "aModule",
+		Package: "aPackage",
+		Versions: []VersionRange{
+			{Fixed: "v1.2.3"},
+		},
+		LastModified: &updatedTime,
+		Description:  "a description",
+		GHSAs:        []string{"G1"},
+		CVEs:         []string{"C1"},
+		Links:        Links{Context: []string{"https://github.com/permalink/to/G1"}},
+	}
+
+	if diff := cmp.Diff(*got, *want); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+}
+func TestParseVulnRange(t *testing.T) {
+	for _, test := range []struct {
+		in   string
+		want []vulnRangeItem
+	}{
+		{"", nil},
+		{"< 1.2.3", []vulnRangeItem{{"<", "1.2.3"}}},
+		{"< 4.3.2, >= 1.2.3", []vulnRangeItem{
+			{"<", "4.3.2"},
+			{">=", "1.2.3"},
+		}},
+	} {
+		got, err := parseVulnRange(test.in)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !cmp.Equal(got, test.want, cmp.AllowUnexported(vulnRangeItem{})) {
+			t.Errorf("%q:\ngot  %+v\nwant %+v", test.in, got, test.want)
+		}
+	}
+}
+
+func TestVersions(t *testing.T) {
+	for _, test := range []struct {
+		earliestFixed string
+		vulnRange     string
+		intro, fixed  string
+	}{
+		{"1.0.0", "< 1.0.0", "", "v1.0.0"},
+		{"", "<= 1.4.2", "", ""},
+		{"1.1.3", ">= 1.1.0, < 1.1.3", "v1.1.0", "v1.1.3"},
+		{
+			"1.2.3", "<= 2.3.4",
+			`TODO (earliest fixed "1.2.3", vuln range "<= 2.3.4")`, "",
+		},
+	} {
+		got := versions(test.earliestFixed, test.vulnRange)
+		want := []VersionRange{{
+			Introduced: test.intro,
+			Fixed:      test.fixed,
+		}}
+		if !cmp.Equal(got, want) {
+			t.Errorf("%q, %q:\ngot  %+v\nwant %+v",
+				test.earliestFixed, test.vulnRange, got, want)
+		}
+	}
+}
diff --git a/internal/worker/worker.go b/internal/worker/worker.go
index f29175f..a2e9b7b 100644
--- a/internal/worker/worker.go
+++ b/internal/worker/worker.go
@@ -22,7 +22,6 @@
 	"golang.org/x/sync/errgroup"
 	"golang.org/x/time/rate"
 	vulnc "golang.org/x/vuln/client"
-	"golang.org/x/vulndb/internal"
 	"golang.org/x/vulndb/internal/cvelistrepo"
 	"golang.org/x/vulndb/internal/cveschema"
 	"golang.org/x/vulndb/internal/derrors"
@@ -363,7 +362,7 @@
 func newGHSABody(sr storeRecord) (string, error) {
 	sa := sr.(*store.GHSARecord).GHSA
 
-	r := ghsaToReport(sa)
+	r := report.GHSAToReport(sa, "")
 	rs, err := r.ToString()
 	if err != nil {
 		return "", err
@@ -384,89 +383,6 @@
 	return b.String(), nil
 }
 
-func ghsaToReport(sa *ghsa.SecurityAdvisory) *report.Report {
-	u := sa.UpdatedAt
-	r := &report.Report{
-		GHSAs:        []string{sa.PrettyID()},
-		Description:  sa.Description,
-		Published:    sa.PublishedAt,
-		LastModified: &u,
-	}
-	if len(sa.Vulns) == 0 {
-		return r
-	}
-	r.Package = sa.Vulns[0].Package
-	r.Versions = versions(sa.Vulns[0].EarliestFixedVersion, sa.Vulns[0].VulnerableVersionRange)
-	for _, v := range sa.Vulns[1:] {
-		var a report.Additional
-		a.Package = v.Package
-		a.Versions = versions(v.EarliestFixedVersion, v.VulnerableVersionRange)
-		r.AdditionalPackages = append(r.AdditionalPackages, a)
-	}
-	return r
-}
-
-func versions(earliestFixed, vulnRange string) []report.VersionRange {
-	// Don't try to be fully general here. Handle the common cases (which, as of
-	// March 2022, are the only cases), and let a person handle the others.
-	items, err := parseVulnRange(vulnRange)
-	if err != nil {
-		return []report.VersionRange{{
-			Introduced: fmt.Sprintf("TODO (got error %q)", err),
-		}}
-	}
-
-	var intro, fixed string
-
-	// Most common case: a single "<" item with a version that matches earliestFixed.
-	if len(items) == 1 && items[0].op == "<" && items[0].version == earliestFixed {
-		intro = "v0.0.0"
-		fixed = "v" + earliestFixed
-	}
-
-	// Two items, one >= and one <, with the latter matching earliestFixed.
-	if len(items) == 2 && items[0].op == ">=" && items[1].op == "<" && items[1].version == earliestFixed {
-		intro = "v" + items[0].version
-		fixed = "v" + earliestFixed
-	}
-
-	// A single "<=" item with no fixed version.
-	if len(items) == 1 && items[0].op == "<=" && earliestFixed == "" {
-		intro = "v0.0.0"
-	}
-
-	if intro == "" {
-		intro = fmt.Sprintf("TODO (earliest fixed %q, vuln range %q)", earliestFixed, vulnRange)
-	}
-	return []report.VersionRange{{Introduced: intro, Fixed: fixed}}
-}
-
-type vulnRangeItem struct {
-	op, version string
-}
-
-// parseVulnRange splits the contents of a GitHub Security Advisory's
-// VulnerableVersionRange field into separate items.
-func parseVulnRange(s string) ([]vulnRangeItem, error) {
-	// A GHSA vuln range is a comma-separated list of items of the form "OP VERSION"
-	// where OP is one of "<", ">", "<=" or ">=" and VERSION is a semantic
-	// version.
-	var items []vulnRangeItem
-	parts := strings.Split(s, ",")
-	for _, p := range parts {
-		p = strings.TrimSpace(p)
-		if p == "" {
-			continue
-		}
-		before, after, found := internal.Cut(p, " ")
-		if !found {
-			return nil, fmt.Errorf("invalid vuln range item %q", p)
-		}
-		items = append(items, vulnRangeItem{strings.TrimSpace(before), strings.TrimSpace(after)})
-	}
-	return items, nil
-}
-
 func vulnTable(vs []*ghsa.Vuln) string {
 	var b strings.Builder
 	fmt.Fprintf(&b, "| Unit | Fixed | Vulnerable Ranges |\n")
diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go
index 73ceb72..13531a7 100644
--- a/internal/worker/worker_test.go
+++ b/internal/worker/worker_test.go
@@ -24,7 +24,6 @@
 	"golang.org/x/vulndb/internal/ghsa"
 	"golang.org/x/vulndb/internal/gitrepo"
 	"golang.org/x/vulndb/internal/issues"
-	"golang.org/x/vulndb/internal/report"
 	"golang.org/x/vulndb/internal/worker/log"
 	"golang.org/x/vulndb/internal/worker/store"
 )
@@ -273,11 +272,13 @@
 ` + "```" + `
 package: aPackage
 versions:
-  - introduced: v0.0.0
-    fixed: v1.2.3
+  - fixed: v1.2.3
 description: a description
 ghsas:
   - G1
+links:
+    context:
+      - https://github.com/permalink/to/G1
 
 ` + "```"
 
@@ -421,51 +422,3 @@
 		}
 	}
 }
-
-func TestParseVulnRange(t *testing.T) {
-	for _, test := range []struct {
-		in   string
-		want []vulnRangeItem
-	}{
-		{"", nil},
-		{"< 1.2.3", []vulnRangeItem{{"<", "1.2.3"}}},
-		{"< 4.3.2, >= 1.2.3", []vulnRangeItem{
-			{"<", "4.3.2"},
-			{">=", "1.2.3"},
-		}},
-	} {
-		got, err := parseVulnRange(test.in)
-		if err != nil {
-			t.Fatal(err)
-		}
-		if !cmp.Equal(got, test.want, cmp.AllowUnexported(vulnRangeItem{})) {
-			t.Errorf("%q:\ngot  %+v\nwant %+v", test.in, got, test.want)
-		}
-	}
-}
-
-func TestVersions(t *testing.T) {
-	for _, test := range []struct {
-		earliestFixed string
-		vulnRange     string
-		intro, fixed  string
-	}{
-		{"1.0.0", "< 1.0.0", "v0.0.0", "v1.0.0"},
-		{"", "<= 1.4.2", "v0.0.0", ""},
-		{"1.1.3", ">= 1.1.0, < 1.1.3", "v1.1.0", "v1.1.3"},
-		{
-			"1.2.3", "<= 2.3.4",
-			`TODO (earliest fixed "1.2.3", vuln range "<= 2.3.4")`, "",
-		},
-	} {
-		got := versions(test.earliestFixed, test.vulnRange)
-		want := []report.VersionRange{{
-			Introduced: test.intro,
-			Fixed:      test.fixed,
-		}}
-		if !cmp.Equal(got, want) {
-			t.Errorf("%q, %q:\ngot  %+v\nwant %+v",
-				test.earliestFixed, test.vulnRange, got, want)
-		}
-	}
-}