osv: use new multi-package format

An implementation of the specification change proposed by
https://github.com/ossf/osv-schema/pull/1. The significant change here
is that instead of generating multiple entries for reports with
multiple packages (in the additional_packages section), we instead
generate a single entry that covers all of the packages, and write the
same entry for each module path.

Change-Id: Ia9d8e0a82081ab7f5becd20c6adf976f4d6966db
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/340210
Reviewed-by: kokoro <noreply+kokoro@google.com>
Reviewed-by: Filippo Valsorda <filippo@golang.org>
Trust: Roland Shoemaker <roland@golang.org>
Run-TryBot: Roland Shoemaker <roland@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Vulndb-Deploy: Roland Shoemaker <bracewell@google.com>
diff --git a/cmd/gendb/main.go b/cmd/gendb/main.go
index e7f25e1..27c8ec6 100644
--- a/cmd/gendb/main.go
+++ b/cmd/gendb/main.go
@@ -77,8 +77,9 @@
 		// TODO(rolandshoemaker): once the HTML representation is ready this should be
 		// the link to the HTML page.
 		linkName := fmt.Sprintf("%s%s.yaml", dbURL, name)
-		for path, e := range osv.Generate(name, linkName, vuln) {
-			jsonVulns[path] = append(jsonVulns[path], e...)
+		entry, paths := osv.Generate(name, linkName, vuln)
+		for _, path := range paths {
+			jsonVulns[path] = append(jsonVulns[path], entry)
 		}
 	}
 
diff --git a/osv/json.go b/osv/json.go
index 590b230..fa7e5bd 100644
--- a/osv/json.go
+++ b/osv/json.go
@@ -2,23 +2,13 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// Package osv implements the <name-pending> shared vulnerability
-// format, with the Go specific extensions, as defined by
-// https://tinyurl.com/vuln-json.
+// Package osv implements the OSV shared vulnerability
+// format, as defined by https://github.com/ossf/osv-schema.
 //
 // As this package is intended for use with the Go vulnerability
 // database, only the subset of features which are used by that
 // database are implemented (for instance, only the SEMVER affected
 // range type is implemented).
-//
-// The format of the Go specific "extra" JSON object is as follows:
-//
-//   {
-//     "symbols": [ string ],
-//     "goos": [ string ],
-//     "goarch": [ string ],
-//     "url": string
-//   }
 package osv
 
 import (
@@ -53,10 +43,14 @@
 	Ecosystem Ecosystem `json:"ecosystem"`
 }
 
+type RangeEvent struct {
+	Introduced string `json:"introduced,omitempty"`
+	Fixed      string `json:"fixed,omitempty"`
+}
+
 type AffectsRange struct {
-	Type       AffectsRangeType `json:"type"`
-	Introduced string           `json:"introduced,omitempty"`
-	Fixed      string           `json:"fixed,omitempty"`
+	Type   AffectsRangeType `json:"type"`
+	Events []RangeEvent     `json:"events"`
 }
 
 // addSemverPrefix adds a 'v' prefix to s if it isn't already prefixed
@@ -73,18 +67,27 @@
 	if ar.Type != TypeSemver {
 		return false
 	}
+	if len(ar.Events) == 0 {
+		return true
+	}
 
 	// Strip and then add the semver prefix so we can support bare versions,
 	// versions prefixed with 'v', and versions prefixed with 'go'.
 	v = addSemverPrefix(removeSemverPrefix(v))
 
-	return (ar.Introduced == "" || semver.Compare(v, addSemverPrefix(ar.Introduced)) >= 0) &&
-		(ar.Fixed == "" || semver.Compare(v, addSemverPrefix(ar.Fixed)) < 0)
+	var affected bool
+	for _, e := range ar.Events {
+		if !affected && e.Introduced != "" {
+			affected = e.Introduced == "0" || semver.Compare(v, addSemverPrefix(e.Introduced)) >= 0
+		} else if e.Fixed != "" {
+			affected = semver.Compare(v, addSemverPrefix(e.Fixed)) < 0
+		}
+	}
+
+	return affected
 }
 
-type Affects struct {
-	Ranges []AffectsRange `json:"ranges,omitempty"`
-}
+type Affects []AffectsRange
 
 // removeSemverPrefix removes the 'v' or 'go' prefixes from go-style
 // SEMVER strings, for usage in the public vulnerability format.
@@ -94,31 +97,29 @@
 	return s
 }
 
-func generateAffects(versions []report.VersionRange) Affects {
-	a := Affects{}
+func generateAffectedRanges(versions []report.VersionRange) Affects {
+	a := AffectsRange{Type: TypeSemver}
+	if len(versions) == 0 || versions[0].Introduced == "" {
+		a.Events = append(a.Events, RangeEvent{Introduced: "0"})
+	}
 	for _, v := range versions {
-		a.Ranges = append(a.Ranges, AffectsRange{
-			Type:       TypeSemver,
-			Introduced: removeSemverPrefix(v.Introduced),
-			Fixed:      removeSemverPrefix(v.Fixed),
-		})
+		if v.Introduced != "" {
+			a.Events = append(a.Events, RangeEvent{Introduced: removeSemverPrefix(v.Introduced)})
+		}
+		if v.Fixed != "" {
+			a.Events = append(a.Events, RangeEvent{Fixed: removeSemverPrefix(v.Fixed)})
+		}
 	}
-	if len(a.Ranges) == 0 {
-		// If all versions are affected, as indicated by an empty versions slice,
-		// we need to include an empty TypeSemver AffectsRange in the JSON
-		// output.
-		a.Ranges = append(a.Ranges, AffectsRange{Type: TypeSemver})
-	}
-	return a
+	return Affects{a}
 }
 
 func (a Affects) AffectsSemver(v string) bool {
-	if len(a.Ranges) == 0 {
+	if len(a) == 0 {
 		// No ranges implies all versions are affected
 		return true
 	}
 	var semverRangePresent bool
-	for _, r := range a.Ranges {
+	for _, r := range a {
 		if r.Type != TypeSemver {
 			continue
 		}
@@ -134,38 +135,68 @@
 	return !semverRangePresent
 }
 
-type GoSpecific struct {
-	Symbols []string `json:"symbols,omitempty"`
-	GOOS    []string `json:"goos,omitempty"`
-	GOARCH  []string `json:"goarch,omitempty"`
-	URL     string   `json:"url"`
-}
-
 type Reference struct {
 	Type string `json:"type"`
 	URL  string `json:"url"`
 }
 
+type Affected struct {
+	Package           Package           `json:"package"`
+	Ranges            Affects           `json:"ranges,omitempty"`
+	DatabaseSpecific  DatabaseSpecific  `json:"database_specific"`
+	EcosystemSpecific EcosystemSpecific `json:"ecosystem_specific"`
+}
+
+type DatabaseSpecific struct {
+	URL string `json:"url"`
+}
+
+type EcosystemSpecific struct {
+	Symbols []string `json:"symbols,omitempty"`
+	GOOS    []string `json:"goos,omitempty"`
+	GOARCH  []string `json:"goarch,omitempty"`
+}
+
 // Entry represents a OSV style JSON vulnerability database
 // entry
 type Entry struct {
-	ID                string      `json:"id"`
-	Published         time.Time   `json:"published"`
-	Modified          time.Time   `json:"modified"`
-	Withdrawn         *time.Time  `json:"withdrawn,omitempty"`
-	Aliases           []string    `json:"aliases,omitempty"`
-	Package           Package     `json:"package"`
-	Details           string      `json:"details"`
-	Affects           Affects     `json:"affects"`
-	References        []Reference `json:"references,omitempty"`
-	EcosystemSpecific GoSpecific  `json:"ecosystem_specific"`
+	ID         string      `json:"id"`
+	Published  time.Time   `json:"published"`
+	Modified   time.Time   `json:"modified"`
+	Withdrawn  *time.Time  `json:"withdrawn,omitempty"`
+	Aliases    []string    `json:"aliases,omitempty"`
+	Details    string      `json:"details"`
+	Affected   []Affected  `json:"affected"`
+	References []Reference `json:"references,omitempty"`
 }
 
-func Generate(id string, url string, r report.Report) map[string][]Entry {
+func generateAffected(importPath string, versions []report.VersionRange, goos, goarch, symbols []string, url string) Affected {
+	return Affected{
+		Package: Package{
+			Name:      importPath,
+			Ecosystem: GoEcosystem,
+		},
+		Ranges:           generateAffectedRanges(versions),
+		DatabaseSpecific: DatabaseSpecific{URL: url},
+		EcosystemSpecific: EcosystemSpecific{
+			GOOS:    goos,
+			GOARCH:  goarch,
+			Symbols: symbols,
+		},
+	}
+}
+
+func Generate(id string, url string, r report.Report) (Entry, []string) {
 	importPath := r.Module
 	if r.Package != "" {
 		importPath = r.Package
 	}
+	moduleMap := make(map[string]bool)
+	if r.Stdlib {
+		moduleMap["stdlib"] = true
+	} else {
+		moduleMap[r.Module] = true
+	}
 	lastModified := r.Published
 	if r.LastModified != nil {
 		lastModified = *r.LastModified
@@ -175,18 +206,19 @@
 		Published: r.Published,
 		Modified:  lastModified,
 		Withdrawn: r.Withdrawn,
-		Package: Package{
-			Name:      importPath,
-			Ecosystem: GoEcosystem,
-		},
-		Details: r.Description,
-		Affects: generateAffects(r.Versions),
-		EcosystemSpecific: GoSpecific{
-			Symbols: r.Symbols,
-			GOOS:    r.OS,
-			GOARCH:  r.Arch,
-			URL:     url,
-		},
+		Details:   r.Description,
+		Affected:  []Affected{generateAffected(importPath, r.Versions, r.OS, r.Arch, r.Symbols, url)},
+	}
+
+	for _, additional := range r.AdditionalPackages {
+		additionalPath := additional.Module
+		if additional.Package != "" {
+			additionalPath = additional.Package
+		}
+		if !r.Stdlib {
+			moduleMap[additional.Module] = true
+		}
+		entry.Affected = append(entry.Affected, generateAffected(additionalPath, additional.Versions, r.OS, r.Arch, additional.Symbols, url))
 	}
 
 	if r.Links.PR != "" {
@@ -203,30 +235,10 @@
 		entry.Aliases = []string{r.CVE}
 	}
 
-	entries := map[string][]Entry{}
-	modulePath := r.Module
-	if r.Stdlib {
-		modulePath = "stdlib"
-	}
-	entries[modulePath] = []Entry{entry}
-
-	// It would be better if this was just a recursive thing maybe?
-	for _, additional := range r.AdditionalPackages {
-		entryCopy := entry
-		additionalImportPath := additional.Module
-		if additional.Package != "" {
-			additionalImportPath = additional.Package
-		}
-		entryCopy.Package.Name = additionalImportPath
-		entryCopy.EcosystemSpecific.Symbols = additional.Symbols
-		entryCopy.Affects = generateAffects(additional.Versions)
-
-		modulePath := additional.Module
-		if r.Stdlib {
-			modulePath = "stdlib"
-		}
-		entries[modulePath] = append(entries[modulePath], entryCopy)
+	var modules []string
+	for module := range moduleMap {
+		modules = append(modules, module)
 	}
 
-	return entries
+	return entry, modules
 }
diff --git a/osv/json_test.go b/osv/json_test.go
index 20c3026..d55d5ca 100644
--- a/osv/json_test.go
+++ b/osv/json_test.go
@@ -5,6 +5,8 @@
 package osv
 
 import (
+	"reflect"
+	"sort"
 	"testing"
 	"time"
 
@@ -26,6 +28,14 @@
 					{Introduced: "v2.5.0"},
 				},
 			},
+			{
+				Module:  "example.com/also-vulnerable",
+				Package: "example.com/also-vulnerable/package",
+				Symbols: []string{"z"},
+				Versions: []report.VersionRange{
+					{Fixed: "v2.1.1"},
+				},
+			},
 		},
 		Versions: []report.VersionRange{
 			{Fixed: "v2.1.1"},
@@ -45,92 +55,123 @@
 		},
 	}
 
-	want := map[string][]Entry{
-		"example.com/vulnerable/v2": []Entry{
+	url := "https://vulns.golang.org/GO-1991-0001.html"
+	wantEntry := Entry{
+		ID:      "GO-1991-0001",
+		Details: "It's a real bad one, I'll tell you that",
+		References: []Reference{
+			{Type: "FIX", URL: "pr"},
+			{Type: "FIX", URL: "commit"},
+			{Type: "WEB", URL: "issue-a"},
+			{Type: "WEB", URL: "issue-b"},
+		},
+		Aliases: []string{"CVE-0000-0000"},
+		Affected: []Affected{
 			{
-				ID: "GO-1991-0001",
 				Package: Package{
 					Name:      "example.com/vulnerable/v2",
 					Ecosystem: "Go",
 				},
-				Details: "It's a real bad one, I'll tell you that",
-				Affects: Affects{
-					Ranges: []AffectsRange{
-						{
-							Type:  TypeSemver,
-							Fixed: "2.1.1",
-						},
-						{
-							Type:       TypeSemver,
-							Introduced: "2.3.4",
-							Fixed:      "2.3.5",
-						},
-						{
-							Type:       TypeSemver,
-							Introduced: "2.5.0",
+				Ranges: []AffectsRange{
+					{
+						Type: TypeSemver,
+						Events: []RangeEvent{
+							{
+								Introduced: "0",
+							},
+							{
+								Fixed: "2.1.1",
+							},
+							{
+								Introduced: "2.3.4",
+							},
+							{
+								Fixed: "2.3.5",
+							},
+							{
+								Introduced: "2.5.0",
+							},
 						},
 					},
 				},
-				References: []Reference{
-					Reference{Type: "FIX", URL: "pr"},
-					Reference{Type: "FIX", URL: "commit"},
-					Reference{Type: "WEB", URL: "issue-a"},
-					Reference{Type: "WEB", URL: "issue-b"},
-				},
-				Aliases: []string{"CVE-0000-0000"},
-				EcosystemSpecific: GoSpecific{
+				DatabaseSpecific: DatabaseSpecific{URL: url},
+				EcosystemSpecific: EcosystemSpecific{
 					Symbols: []string{"A", "B.b"},
 					GOOS:    []string{"windows"},
 					GOARCH:  []string{"arm64"},
-					URL:     "https://vulns.golang.org/GO-1991-0001.html",
 				},
 			},
-		},
-		"vanity.host/vulnerable": []Entry{
 			{
-
-				ID: "GO-1991-0001",
 				Package: Package{
 					Name:      "vanity.host/vulnerable/package",
 					Ecosystem: "Go",
 				},
-				Details: "It's a real bad one, I'll tell you that",
-				Affects: Affects{
-					Ranges: []AffectsRange{
-						{
-							Type:  TypeSemver,
-							Fixed: "2.1.1",
-						},
-						{
-							Type:       TypeSemver,
-							Introduced: "2.3.4",
-							Fixed:      "2.3.5",
-						},
-						{
-							Type:       TypeSemver,
-							Introduced: "2.5.0",
+				Ranges: []AffectsRange{
+					{
+						Type: TypeSemver,
+						Events: []RangeEvent{
+							{
+								Introduced: "0",
+							},
+							{
+								Fixed: "2.1.1",
+							},
+							{
+								Introduced: "2.3.4",
+							},
+							{
+								Fixed: "2.3.5",
+							},
+							{
+								Introduced: "2.5.0",
+							},
 						},
 					},
 				},
-				References: []Reference{
-					Reference{Type: "FIX", URL: "pr"},
-					Reference{Type: "FIX", URL: "commit"},
-					Reference{Type: "WEB", URL: "issue-a"},
-					Reference{Type: "WEB", URL: "issue-b"},
-				},
-				Aliases: []string{"CVE-0000-0000"},
-				EcosystemSpecific: GoSpecific{
+				DatabaseSpecific: DatabaseSpecific{URL: url},
+				EcosystemSpecific: EcosystemSpecific{
 					Symbols: []string{"b", "A.b"},
 					GOOS:    []string{"windows"},
 					GOARCH:  []string{"arm64"},
-					URL:     "https://vulns.golang.org/GO-1991-0001.html",
+				},
+			},
+			{
+				Package: Package{
+					Name:      "example.com/also-vulnerable/package",
+					Ecosystem: "Go",
+				},
+				Ranges: []AffectsRange{
+					{
+						Type: TypeSemver,
+						Events: []RangeEvent{
+							{
+								Introduced: "0",
+							},
+							{
+								Fixed: "2.1.1",
+							},
+						},
+					},
+				},
+				DatabaseSpecific: DatabaseSpecific{URL: url},
+				EcosystemSpecific: EcosystemSpecific{
+					Symbols: []string{"z"},
+					GOOS:    []string{"windows"},
+					GOARCH:  []string{"arm64"},
 				},
 			},
 		},
 	}
-	got := Generate("GO-1991-0001", "https://vulns.golang.org/GO-1991-0001.html", r)
-	if diff := cmp.Diff(want, got, cmp.Comparer(func(_, _ time.Time) bool { return true })); diff != "" {
-		t.Errorf("Generate returned unexpected result (-want +got):\n%s", diff)
+	wantModules := []string{"example.com/vulnerable/v2", "vanity.host/vulnerable", "example.com/also-vulnerable"}
+	sort.Strings(wantModules)
+
+	gotEntry, gotModules := Generate("GO-1991-0001", url, r)
+	if diff := cmp.Diff(wantEntry, gotEntry, cmp.Comparer(func(a, b time.Time) bool { return a.Equal(b) })); diff != "" {
+		t.Errorf("Generate returned unexpected entry (-want +got):\n%s", diff)
+	}
+	sort.Strings(gotModules)
+	if !reflect.DeepEqual(gotModules, wantModules) {
+		t.Errorf("Generate returned unexpected modules: got %v, want %v", gotModules, wantModules)
 	}
 }
 
@@ -149,119 +190,87 @@
 		{
 			// Affects containing an empty SEMVER range also indicates
 			// everything is affected
-			affects: Affects{Ranges: []AffectsRange{{Type: TypeSemver}}},
+			affects: []AffectsRange{{Type: TypeSemver}},
+			version: "v0.0.0",
+			want:    true,
+		},
+		{
+			// Affects containing a SEMVER range with only an "introduced":"0"
+			// also indicates everything is affected
+			affects: []AffectsRange{{Type: TypeSemver, Events: []RangeEvent{{Introduced: "0"}}}},
 			version: "v0.0.0",
 			want:    true,
 		},
 		{
 			// v1.0.0 < v2.0.0
-			affects: Affects{
-				Ranges: []AffectsRange{
-					{Type: TypeSemver, Fixed: "2.0.0"},
-				},
-			},
+			affects: []AffectsRange{{Type: TypeSemver, Events: []RangeEvent{{Introduced: "0"}, {Fixed: "2.0.0"}}}},
 			version: "v1.0.0",
 			want:    true,
 		},
 		{
 			// v0.0.1 <= v1.0.0
-			affects: Affects{
-				Ranges: []AffectsRange{
-					{Type: TypeSemver, Introduced: "0.0.1"},
-				},
-			},
+			affects: []AffectsRange{{Type: TypeSemver, Events: []RangeEvent{{Introduced: "0.0.1"}}}},
 			version: "v1.0.0",
 			want:    true,
 		},
 		{
 			// v1.0.0 <= v1.0.0
-			affects: Affects{
-				Ranges: []AffectsRange{
-					{Type: TypeSemver, Introduced: "1.0.0"},
-				},
-			},
+			affects: []AffectsRange{{Type: TypeSemver, Events: []RangeEvent{{Introduced: "1.0.0"}}}},
 			version: "v1.0.0",
 			want:    true,
 		},
 		{
 			// v1.0.0 <= v1.0.0 < v2.0.0
-			affects: Affects{
-				Ranges: []AffectsRange{
-					{Type: TypeSemver, Introduced: "1.0.0", Fixed: "2.0.0"},
-				},
-			},
+			affects: []AffectsRange{{Type: TypeSemver, Events: []RangeEvent{{Introduced: "1.0.0"}, {Fixed: "2.0.0"}}}},
 			version: "v1.0.0",
 			want:    true,
 		},
 		{
 			// v0.0.1 <= v1.0.0 < v2.0.0
-			affects: Affects{
-				Ranges: []AffectsRange{
-					{Type: TypeSemver, Introduced: "0.0.1", Fixed: "2.0.0"},
-				},
-			},
+			affects: []AffectsRange{{Type: TypeSemver, Events: []RangeEvent{{Introduced: "0.0.1"}, {Fixed: "2.0.0"}}}},
 			version: "v1.0.0",
 			want:    true,
 		},
 		{
 			// v2.0.0 < v3.0.0
-			affects: Affects{
-				Ranges: []AffectsRange{
-					{Type: TypeSemver, Introduced: "1.0.0", Fixed: "2.0.0"},
-				},
-			},
+			affects: []AffectsRange{{Type: TypeSemver, Events: []RangeEvent{{Introduced: "1.0.0"}, {Fixed: "2.0.0"}}}},
 			version: "v3.0.0",
 			want:    false,
 		},
 		{
 			// Multiple ranges
-			affects: Affects{
-				Ranges: []AffectsRange{
-					{Type: TypeSemver, Introduced: "1.0.0", Fixed: "2.0.0"},
-					{Type: TypeSemver, Introduced: "3.0.0"},
-				},
-			},
+			affects: []AffectsRange{{Type: TypeSemver, Events: []RangeEvent{{Introduced: "1.0.0"}, {Fixed: "2.0.0"}, {Introduced: "3.0.0"}}}},
 			version: "v3.0.0",
 			want:    true,
 		},
 		{
 			// Wrong type range
-			affects: Affects{
-				Ranges: []AffectsRange{
-					{Type: TypeUnspecified, Introduced: "3.0.0"},
-				},
-			},
+			affects: []AffectsRange{{Type: TypeUnspecified, Events: []RangeEvent{{Introduced: "3.0.0"}}}},
 			version: "v3.0.0",
 			want:    true,
 		},
 		{
 			// Semver ranges don't match
-			affects: Affects{
-				Ranges: []AffectsRange{
-					{Type: TypeUnspecified, Introduced: "3.0.0"},
-					{Type: TypeSemver, Introduced: "4.0.0"},
-				},
+			affects: []AffectsRange{
+				{Type: TypeUnspecified, Events: []RangeEvent{{Introduced: "3.0.0"}}},
+				{Type: TypeSemver, Events: []RangeEvent{{Introduced: "4.0.0"}}},
 			},
 			version: "v3.0.0",
 			want:    false,
 		},
 		{
 			// Semver ranges do match
-			affects: Affects{
-				Ranges: []AffectsRange{
-					{Type: TypeUnspecified, Introduced: "3.0.0"},
-					{Type: TypeSemver, Introduced: "3.0.0"},
-				},
+			affects: []AffectsRange{
+				{Type: TypeUnspecified, Events: []RangeEvent{{Introduced: "3.0.0"}}},
+				{Type: TypeSemver, Events: []RangeEvent{{Introduced: "3.0.0"}}},
 			},
 			version: "v3.0.0",
 			want:    true,
 		},
 		{
 			// Semver ranges match (go prefix)
-			affects: Affects{
-				Ranges: []AffectsRange{
-					{Type: TypeSemver, Introduced: "3.0.0"},
-				},
+			affects: []AffectsRange{
+				{Type: TypeSemver, Events: []RangeEvent{{Introduced: "3.0.0"}}},
 			},
 			version: "go3.0.1",
 			want:    true,