internal/worker: add report template to GHSA issues

Add a partially populated report to an issue filed
for a GitHub Security Advisory.

Change-Id: I6d16a02fe318604f2f74f5555609fd8fec1b611d
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/389275
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Damien Neil <dneil@google.com>
diff --git a/internal/worker/worker.go b/internal/worker/worker.go
index e3c0b63..02fc986 100644
--- a/internal/worker/worker.go
+++ b/internal/worker/worker.go
@@ -22,6 +22,7 @@
 	"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"
@@ -359,19 +360,110 @@
 func newGHSABody(sr storeRecord) (string, error) {
 	sa := sr.(*store.GHSARecord).GHSA
 
+	r := ghsaToReport(sa)
+	rs, err := r.ToString()
+	if err != nil {
+		return "", err
+	}
+
 	var b strings.Builder
 	intro := fmt.Sprintf(
 		"In GitHub Security Advisory [%s](%s), there is a vulnerability in the following Go packages or modules:",
 		sr.GetPrettyID(), sa.Permalink)
 	intro += "\n\n" + vulnTable(sa.Vulns)
 	if err := issueTemplate.Execute(&b, issueTemplateData{
-		Intro: intro,
+		Intro:  intro,
+		Report: rs,
+		Pre:    "```",
 	}); err != nil {
 		return "", err
 	}
 	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 4391c95..73ceb72 100644
--- a/internal/worker/worker_test.go
+++ b/internal/worker/worker_test.go
@@ -24,6 +24,7 @@
 	"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"
 )
@@ -269,7 +270,17 @@
 
 See [doc/triage.md](https://github.com/golang/vulndb/blob/master/doc/triage.md) for instructions on how to triage this report.
 
-`
+` + "```" + `
+package: aPackage
+versions:
+  - introduced: v0.0.0
+    fixed: v1.2.3
+description: a description
+ghsas:
+  - G1
+
+` + "```"
+
 	if diff := cmp.Diff(unindent(want), got); diff != "" {
 		t.Errorf("mismatch (-want, +got):\n%s", diff)
 	}
@@ -410,3 +421,51 @@
 		}
 	}
 }
+
+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)
+		}
+	}
+}