Repository: google/osv-scanner
Pull request #510 (2023-08-28 02:27:19)

Refactor models
Pulled refactor out to a separate PR as suggested here: #505 

In this PR:
- Refactored models package to no longer depend on lockfile
- Moved functions that required lockfile types into separate internal package under `utility/vulns`
- Copied over OSV url from `osv` package to avoid dependency on `osv` from `output`/`reporter`

I went through and graphed out the dependencies, 

- `models` no longer depends on any internal dependencies
- `lockfile` doesn't depend on any internal dependencies apart from `cacheexp`
- `osv` brings in both `lockfiles` and `models`. This is probably the most problematic one. Ideally `osv` should only be referencing `models`, but it'll be a breaking change to change this API. This is one of the main things we should change for V2 (related to #442) (TODO: Make separate issue for this
- `reporter` depends on the internal `output`, which depends on `osv`. This dependency causes `lockfile` to be pulled into everywhere that needs to print out anything. This can be decoupled without too much trouble by copying in the OSV url. I did so in this PR.
- `config` package references `reporter`,  with the above change it also no longer depends on `lockfile`.



Patch:
diff --git a/internal/local/zip.go b/internal/local/zip.go
index 6b19120..cdd5027 100644
--- a/internal/local/zip.go
+++ b/internal/local/zip.go
@@ -16,6 +16,7 @@ import (
 	"path"
 	"strings"
 
+	"github.com/google/osv-scanner/internal/utility/vulns"
 	"github.com/google/osv-scanner/pkg/lockfile"
 	"github.com/google/osv-scanner/pkg/models"
 	"github.com/google/osv-scanner/pkg/osv"
@@ -235,7 +236,7 @@ func (db *ZipDB) VulnerabilitiesAffectingPackage(pkg lockfile.PackageDetails) mo
 	var vulnerabilities models.Vulnerabilities
 
 	for _, vulnerability := range db.Vulnerabilities(false) {
-		if vulnerability.IsAffected(pkg) && !vulnerabilities.Includes(vulnerability) {
+		if vulns.IsAffected(vulnerability, pkg) && !vulns.Include(vulnerabilities, vulnerability) {
 			vulnerabilities = append(vulnerabilities, vulnerability)
 		}
 	}
diff --git a/internal/output/table.go b/internal/output/table.go
index 871f9d6..69030bc 100644
--- a/internal/output/table.go
+++ b/internal/output/table.go
@@ -13,12 +13,15 @@ import (
 	v3_metric "github.com/goark/go-cvss/v3/metric"
 
 	"github.com/google/osv-scanner/pkg/models"
-	"github.com/google/osv-scanner/pkg/osv"
 
 	"github.com/jedib0t/go-pretty/v6/table"
 	"github.com/jedib0t/go-pretty/v6/text"
 )
 
+// OSVBaseVulnerabilityURL is the base URL for detailed vulnerability views.
+// Copied in from osv package to avoid referencing the osv package unnecessarily
+const OSVBaseVulnerabilityURL = "https://osv.dev/"
+
 // PrintTableResults prints the osv scan results into a human friendly table.
 func PrintTableResults(vulnResult *models.VulnerabilityResults, outputWriter io.Writer, terminalWidth int) {
 	outputTable := table.NewWriter()
@@ -96,9 +99,9 @@ func tableBuilderInner(vulnResult *models.VulnerabilityResults, addStyling bool,
 
 				for _, vuln := range group.IDs {
 					if addStyling {
-						links = append(links, osv.BaseVulnerabilityURL+text.Bold.EscapeSeq()+vuln+text.Reset.EscapeSeq())
+						links = append(links, OSVBaseVulnerabilityURL+text.Bold.EscapeSeq()+vuln+text.Reset.EscapeSeq())
 					} else {
-						links = append(links, osv.BaseVulnerabilityURL+vuln)
+						links = append(links, OSVBaseVulnerabilityURL+vuln)
 					}
 				}
 
diff --git a/internal/utility/vulns/vulnerabilities.go b/internal/utility/vulns/vulnerabilities.go
new file mode 100644
index 0000000..5e45e2d
--- /dev/null
+++ b/internal/utility/vulns/vulnerabilities.go
@@ -0,0 +1,20 @@
+package vulns
+
+import "github.com/google/osv-scanner/pkg/models"
+
+func Include(vs models.Vulnerabilities, vulnerability models.Vulnerability) bool {
+	for _, vuln := range vs {
+		if vuln.ID == vulnerability.ID {
+			return true
+		}
+
+		if isAliasOf(vuln, vulnerability) {
+			return true
+		}
+		if isAliasOf(vulnerability, vuln) {
+			return true
+		}
+	}
+
+	return false
+}
diff --git a/internal/utility/vulns/vulnerability.go b/internal/utility/vulns/vulnerability.go
new file mode 100644
index 0000000..25f103d
--- /dev/null
+++ b/internal/utility/vulns/vulnerability.go
@@ -0,0 +1,152 @@
+package vulns
+
+import (
+	"fmt"
+	"os"
+	"sort"
+
+	"github.com/google/osv-scanner/internal/semantic"
+	"github.com/google/osv-scanner/pkg/lockfile"
+	"github.com/google/osv-scanner/pkg/models"
+	"golang.org/x/exp/slices"
+)
+
+func eventVersion(e models.Event) string {
+	if e.Introduced != "" {
+		return e.Introduced
+	}
+
+	if e.Fixed != "" {
+		return e.Fixed
+	}
+
+	if e.Limit != "" {
+		return e.Limit
+	}
+
+	if e.LastAffected != "" {
+		return e.LastAffected
+	}
+
+	return ""
+}
+
+func rangeContainsVersion(ar models.Range, pkg lockfile.PackageDetails) bool {
+	if ar.Type != models.RangeEcosystem && ar.Type != models.RangeSemVer {
+		return false
+	}
+	// todo: we should probably warn here
+	if len(ar.Events) == 0 {
+		return false
+	}
+
+	vp := semantic.MustParse(pkg.Version, pkg.CompareAs)
+
+	sort.Slice(ar.Events, func(i, j int) bool {
+		a := ar.Events[i]
+		b := ar.Events[j]
+
+		if a.Introduced == "0" {
+			return true
+		}
+
+		if b.Introduced == "0" {
+			return false
+		}
+
+		return semantic.MustParse(eventVersion(a), pkg.CompareAs).CompareStr(eventVersion(b)) < 0
+	})
+
+	var affected bool
+	for _, e := range ar.Events {
+		if affected {
+			if e.Fixed != "" {
+				affected = vp.CompareStr(e.Fixed) < 0
+			} else if e.LastAffected != "" {
+				affected = e.LastAffected == pkg.Version || vp.CompareStr(e.LastAffected) <= 0
+			}
+		} else if e.Introduced != "" {
+			affected = e.Introduced == "0" || vp.CompareStr(e.Introduced) >= 0
+		}
+	}
+
+	return affected
+}
+
+// rangeAffectsVersion checks if the given version is within the range
+// specified by the events of any "Ecosystem" or "Semver" type ranges
+func rangeAffectsVersion(a []models.Range, pkg lockfile.PackageDetails) bool {
+	for _, r := range a {
+		if r.Type != models.RangeEcosystem && r.Type != models.RangeSemVer {
+			return false
+		}
+		if rangeContainsVersion(r, pkg) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func isAliasOfID(v models.Vulnerability, id string) bool {
+	for _, alias := range v.Aliases {
+		if alias == id {
+			return true
+		}
+	}
+
+	return false
+}
+
+func isAliasOf(v models.Vulnerability, vulnerability models.Vulnerability) bool {
+	for _, alias := range vulnerability.Aliases {
+		if v.ID == alias || isAliasOfID(v, alias) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func AffectsEcosystem(v models.Vulnerability, ecosystem lockfile.Ecosystem) bool {
+	for _, affected := range v.Affected {
+		if string(affected.Package.Ecosystem) == string(ecosystem) {
+			return true
+		}
+	}
+
+	return false
+}
+
+func IsAffected(v models.Vulnerability, pkg lockfile.PackageDetails) bool {
+	for _, affected := range v.Affected {
+		if string(affected.Package.Ecosystem) == string(pkg.Ecosystem) &&
+			affected.Package.Name == pkg.Name {
+			if len(affected.Ranges) == 0 && len(affected.Versions) == 0 {
+				_, _ = fmt.Fprintf(
+					os.Stderr,
+					"%s does not have any ranges or versions - this is probably a mistake!\n",
+					v.ID,
+				)
+
+				continue
+			}
+
+			if slices.Contains(affected.Versions, pkg.Version) {
+				return true
+			}
+
+			if rangeAffectsVersion(affected.Ranges, pkg) {
+				return true
+			}
+
+			// if a package does not have a version, assume it is vulnerable
+			// as false positives are better than false negatives here
+			if pkg.Version == "" {
+				return true
+			}
+		}
+	}
+
+	return false
+}
diff --git a/pkg/models/vulnerabilities.go b/pkg/models/vulnerabilities.go
index 53c9470..ea20967 100644
--- a/pkg/models/vulnerabilities.go
+++ b/pkg/models/vulnerabilities.go
@@ -7,23 +7,6 @@ import (
 
 type Vulnerabilities []Vulnerability
 
-func (vs Vulnerabilities) Includes(vulnerability Vulnerability) bool {
-	for _, vuln := range vs {
-		if vuln.ID == vulnerability.ID {
-			return true
-		}
-
-		if vuln.isAliasOf(vulnerability) {
-			return true
-		}
-		if vulnerability.isAliasOf(vuln) {
-			return true
-		}
-	}
-
-	return false
-}
-
 // MarshalJSON ensures that if there are no vulnerabilities,
 // an empty array is used as the value instead of "null"
 func (vs Vulnerabilities) MarshalJSON() ([]byte, error) {
diff --git a/pkg/models/vulnerability.go b/pkg/models/vulnerability.go
index 1d92217..db7a0e1 100644
--- a/pkg/models/vulnerability.go
+++ b/pkg/models/vulnerability.go
@@ -2,14 +2,7 @@ package models
 
 import (
 	"encoding/json"
-	"fmt"
-	"os"
-	"sort"
 	"time"
-
-	"github.com/google/osv-scanner/internal/semantic"
-	"github.com/google/osv-scanner/pkg/lockfile"
-	"golang.org/x/exp/slices"
 )
 
 // Package identifies the affected code library or command provided by the
@@ -174,143 +167,3 @@ func (v Vulnerability) MarshalYAML() (interface{}, error) {
 
 	return raw, nil
 }
-
-func (e Event) version() string {
-	if e.Introduced != "" {
-		return e.Introduced
-	}
-
-	if e.Fixed != "" {
-		return e.Fixed
-	}
-
-	if e.Limit != "" {
-		return e.Limit
-	}
-
-	if e.LastAffected != "" {
-		return e.LastAffected
-	}
-
-	return ""
-}
-
-func (ar Range) containsVersion(pkg lockfile.PackageDetails) bool {
-	if ar.Type != RangeEcosystem && ar.Type != RangeSemVer {
-		return false
-	}
-	// todo: we should probably warn here
-	if len(ar.Events) == 0 {
-		return false
-	}
-
-	vp := semantic.MustParse(pkg.Version, pkg.CompareAs)
-
-	sort.Slice(ar.Events, func(i, j int) bool {
-		a := ar.Events[i]
-		b := ar.Events[j]
-
-		if a.Introduced == "0" {
-			return true
-		}
-
-		if b.Introduced == "0" {
-			return false
-		}
-
-		return semantic.MustParse(a.version(), pkg.CompareAs).CompareStr(b.version()) < 0
-	})
-
-	var affected bool
-	for _, e := range ar.Events {
-		if affected {
-			if e.Fixed != "" {
-				affected = vp.CompareStr(e.Fixed) < 0
-			} else if e.LastAffected != "" {
-				affected = e.LastAffected == pkg.Version || vp.CompareStr(e.LastAffected) <= 0
-			}
-		} else if e.Introduced != "" {
-			affected = e.Introduced == "0" || vp.CompareStr(e.Introduced) >= 0
-		}
-	}
-
-	return affected
-}
-
-// affectsVersion checks if the given version is within the range
-// specified by the events of any "Ecosystem" or "Semver" type ranges
-func affectsVersion(a []Range, pkg lockfile.PackageDetails) bool {
-	for _, r := range a {
-		if r.Type != RangeEcosystem && r.Type != RangeSemVer {
-			return false
-		}
-		if r.containsVersion(pkg) {
-			return true
-		}
-	}
-
-	return false
-}
-
-func (v Vulnerability) isAliasOfID(id string) bool {
-	for _, alias := range v.Aliases {
-		if alias == id {
-			return true
-		}
-	}
-
-	return false
-}
-
-func (v Vulnerability) isAliasOf(vulnerability Vulnerability) bool {
-	for _, alias := range vulnerability.Aliases {
-		if v.ID == alias || v.isAliasOfID(alias) {
-			return true
-		}
-	}
-
-	return false
-}
-
-func (v Vulnerability) AffectsEcosystem(ecosystem lockfile.Ecosystem) bool {
-	for _, affected := range v.Affected {
-		if string(affected.Package.Ecosystem) == string(ecosystem) {
-			return true
-		}
-	}
-
-	return false
-}
-
-func (v Vulnerability) IsAffected(pkg lockfile.PackageDetails) bool {
-	for _, affected := range v.Affected {
-		if string(affected.Package.Ecosystem) == string(pkg.Ecosystem) &&
-			affected.Package.Name == pkg.Name {
-			if len(affected.Ranges) == 0 && len(affected.Versions) == 0 {
-				_, _ = fmt.Fprintf(
-					os.Stderr,
-					"%s does not have any ranges or versions - this is probably a mistake!\n",
-					v.ID,
-				)
-
-				continue
-			}
-
-			if slices.Contains(affected.Versions, pkg.Version) {
-				return true
-			}
-
-			if affectsVersion(affected.Ranges, pkg) {
-				return true
-			}
-
-			// if a package does not have a version, assume it is vulnerable
-			// as false positives are better than false negatives here
-			if pkg.Version == "" {
-				return true
-			}
-		}
-	}
-
-	return false
-}


Test patch:
diff --git a/internal/utility/vulns/vulnerabilities_test.go b/internal/utility/vulns/vulnerabilities_test.go
new file mode 100644
index 0000000..6a15ca1
--- /dev/null
+++ b/internal/utility/vulns/vulnerabilities_test.go
@@ -0,0 +1,129 @@
+package vulns_test
+
+import (
+	"testing"
+
+	"github.com/google/osv-scanner/internal/utility/vulns"
+	"github.com/google/osv-scanner/pkg/models"
+)
+
+func TestVulnerabilities_Includes(t *testing.T) {
+	t.Parallel()
+
+	type args struct {
+		osv models.Vulnerability
+	}
+	tests := []struct {
+		name string
+		vs   models.Vulnerabilities
+		args args
+		want bool
+	}{
+		{
+			name: "",
+			vs: models.Vulnerabilities{
+				models.Vulnerability{
+					ID:      "GHSA-1",
+					Aliases: []string{},
+				},
+			},
+			args: args{
+				osv: models.Vulnerability{
+					ID:      "GHSA-2",
+					Aliases: []string{},
+				},
+			},
+			want: false,
+		},
+		{
+			name: "",
+			vs: models.Vulnerabilities{
+				models.Vulnerability{
+					ID:      "GHSA-1",
+					Aliases: []string{},
+				},
+			},
+			args: args{
+				osv: models.Vulnerability{
+					ID:      "GHSA-1",
+					Aliases: []string{},
+				},
+			},
+			want: true,
+		},
+		{
+			name: "",
+			vs: models.Vulnerabilities{
+				models.Vulnerability{
+					ID:      "GHSA-1",
+					Aliases: []string{"GHSA-2"},
+				},
+			},
+			args: args{
+				osv: models.Vulnerability{
+					ID:      "GHSA-2",
+					Aliases: []string{},
+				},
+			},
+			want: true,
+		},
+		{
+			name: "",
+			vs: models.Vulnerabilities{
+				models.Vulnerability{
+					ID:      "GHSA-1",
+					Aliases: []string{},
+				},
+			},
+			args: args{
+				osv: models.Vulnerability{
+					ID:      "GHSA-2",
+					Aliases: []string{"GHSA-1"},
+				},
+			},
+			want: true,
+		},
+		{
+			name: "",
+			vs: models.Vulnerabilities{
+				models.Vulnerability{
+					ID:      "GHSA-1",
+					Aliases: []string{"CVE-1"},
+				},
+			},
+			args: args{
+				osv: models.Vulnerability{
+					ID:      "GHSA-2",
+					Aliases: []string{"CVE-1"},
+				},
+			},
+			want: true,
+		},
+		{
+			name: "",
+			vs: models.Vulnerabilities{
+				models.Vulnerability{
+					ID:      "GHSA-1",
+					Aliases: []string{"CVE-2"},
+				},
+			},
+			args: args{
+				osv: models.Vulnerability{
+					ID:      "GHSA-2",
+					Aliases: []string{"CVE-2"},
+				},
+			},
+			want: true,
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			if got := vulns.Include(tt.vs, tt.args.osv); got != tt.want {
+				t.Errorf("Includes() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/internal/utility/vulns/vulnerability_test.go b/internal/utility/vulns/vulnerability_test.go
new file mode 100644
index 0000000..9e64ee1
--- /dev/null
+++ b/internal/utility/vulns/vulnerability_test.go
@@ -0,0 +1,706 @@
+package vulns_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/google/osv-scanner/internal/utility/vulns"
+	"github.com/google/osv-scanner/pkg/lockfile"
+	"github.com/google/osv-scanner/pkg/models"
+)
+
+func expectIsAffected(t *testing.T, vuln models.Vulnerability, version string, expectAffected bool) {
+	t.Helper()
+
+	pkg := lockfile.PackageDetails{
+		Name:      "my-package",
+		Version:   version,
+		Ecosystem: lockfile.NpmEcosystem,
+		CompareAs: lockfile.NpmEcosystem,
+	}
+
+	if vulns.IsAffected(vuln, pkg) != expectAffected {
+		if expectAffected {
+			t.Errorf("Expected OSV to affect package version %s but it did not", version)
+		} else {
+			t.Errorf("Expected OSV not to affect package version %s but it did", version)
+		}
+	}
+}
+
+func buildOSVWithAffected(affected ...models.Affected) models.Vulnerability {
+	return models.Vulnerability{
+		ID:        "1",
+		Published: time.Time{},
+		Modified:  time.Time{},
+		Details:   "This is an open source vulnerability!",
+		Affected:  affected,
+	}
+}
+
+func buildEcosystemAffectsRange(events ...models.Event) models.Range {
+	return models.Range{Type: models.RangeEcosystem, Events: events}
+}
+
+func buildSemverAffectsRange(events ...models.Event) models.Range {
+	return models.Range{Type: models.RangeSemVer, Events: events}
+}
+
+func TestOSV_AffectsEcosystem(t *testing.T) {
+	t.Parallel()
+
+	type AffectsTest struct {
+		Affected  []models.Affected
+		Ecosystem lockfile.Ecosystem
+		Expected  bool
+	}
+
+	tests := []AffectsTest{
+		{Affected: nil, Ecosystem: "Go", Expected: false},
+		{Affected: nil, Ecosystem: "npm", Expected: false},
+		{Affected: nil, Ecosystem: "PyPI", Expected: false},
+		{Affected: nil, Ecosystem: "", Expected: false},
+		{
+			Affected: []models.Affected{
+				{Package: models.Package{Ecosystem: "crates.io"}},
+				{Package: models.Package{Ecosystem: "npm"}},
+				{Package: models.Package{Ecosystem: "PyPI"}},
+			},
+			Ecosystem: "Packagist",
+			Expected:  false,
+		},
+		{
+			Affected: []models.Affected{
+				{Package: models.Package{Ecosystem: "NuGet"}},
+			},
+			Ecosystem: "NuGet",
+			Expected:  true,
+		},
+		{
+			Affected: []models.Affected{
+				{Package: models.Package{Ecosystem: "npm"}},
+				{Package: models.Package{Ecosystem: "npm"}},
+			},
+			Ecosystem: "npm",
+			Expected:  true,
+		},
+	}
+
+	for i, test := range tests {
+		vuln := models.Vulnerability{
+			ID:        "1",
+			Published: time.Time{},
+			Modified:  time.Time{},
+			Details:   "This is an open source vulnerability!",
+			Affected:  test.Affected,
+		}
+
+		if vulns.AffectsEcosystem(vuln, test.Ecosystem) != test.Expected {
+			t.Errorf(
+				"Test #%d: Expected OSV to return %t but it returned %t",
+				i,
+				test.Expected,
+				!test.Expected,
+			)
+		}
+	}
+
+	// test when the OSV doesn't have an "Affected"
+	vuln := models.Vulnerability{
+		ID:        "1",
+		Published: time.Time{},
+		Modified:  time.Time{},
+		Details:   "This is an open source vulnerability!",
+		Affected:  nil,
+	}
+
+	if vulns.AffectsEcosystem(vuln, "npm") {
+		t.Errorf(
+			"Expected OSV to report 'false' when it doesn't have an Affected, but it reported true!",
+		)
+	}
+}
+
+func TestOSV_IsAffected_AffectsWithEcosystem_DifferentEcosystem(t *testing.T) {
+	t.Parallel()
+
+	vuln := buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemPyPI, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(models.Event{Introduced: "0"}),
+			},
+		},
+	)
+
+	for _, v := range []string{"1.0.0", "1.1.1", "2.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+}
+
+func TestOSV_IsAffected_AffectsWithEcosystem_SingleAffected(t *testing.T) {
+	t.Parallel()
+
+	var vuln models.Vulnerability
+
+	// "Introduced: 0" means everything is affected
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(models.Event{Introduced: "0"}),
+			},
+		},
+	)
+
+	for _, v := range []string{"1.0.0", "1.1.1", "2.0.0"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+
+	// "Fixed: 1" means all versions after this are not vulnerable
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{Fixed: "1"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+
+	// multiple fixes and introduced
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{Fixed: "1"},
+					models.Event{Introduced: "2.1.0"},
+					models.Event{Fixed: "3.2.0"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+
+	// "LastAffected: 1" means all versions after this are not vulnerable
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{LastAffected: "1"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc", "1.0.0"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.1", "1.1.0", "2.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+
+	// mix of fixes, last_known_affected, and introduced
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{Fixed: "1"},
+					models.Event{Introduced: "2.1.0"},
+					models.Event{LastAffected: "3.1.9"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc", "3.1.9"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+}
+
+func TestOSV_IsAffected_AffectsWithEcosystem_MultipleAffected(t *testing.T) {
+	t.Parallel()
+
+	vuln := buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{Fixed: "1"},
+				),
+			},
+		},
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(
+					models.Event{Introduced: "2.1.0"},
+					models.Event{Fixed: "3.2.0"},
+				),
+			},
+		},
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(
+					models.Event{Introduced: "3.3.0"},
+					models.Event{LastAffected: "3.5.0"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"3.3.1", "3.4.5"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+}
+
+func TestOSV_IsAffected_AffectsWithEcosystem_Unsorted(t *testing.T) {
+	t.Parallel()
+
+	vuln := buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{Introduced: "2.1.0"},
+					models.Event{Fixed: "1"},
+					models.Event{LastAffected: "3.1.9"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc", "3.1.9"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+
+	// zeros with build strings
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			// golang.org/x/sys
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(
+					models.Event{Fixed: "0.0.0-20220412211240-33da011f77ad"},
+					models.Event{Introduced: "0"},
+				),
+			},
+		},
+		models.Affected{
+			// golang.org/x/net
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildEcosystemAffectsRange(
+					models.Event{Introduced: "0.0.0-20180925071336-cf3bd585ca2a"},
+					models.Event{Fixed: "0"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.14.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"0.0.0-20180925071336-cf3bd585ca2a"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+}
+
+func TestOSV_IsAffected_AffectsWithSemver_DifferentEcosystem(t *testing.T) {
+	t.Parallel()
+
+	vuln := buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemPyPI, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(models.Event{Introduced: "0"}),
+			},
+		},
+	)
+
+	for _, v := range []string{"1.0.0", "1.1.1", "2.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+}
+
+func TestOSV_IsAffected_AffectsWithSemver_SingleAffected(t *testing.T) {
+	t.Parallel()
+
+	var vuln models.Vulnerability
+
+	// "Introduced: 0" means everything is affected
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(models.Event{Introduced: "0"}),
+			},
+		},
+	)
+
+	for _, v := range []string{"v1.0.0", "v1.1.1", "v2.0.0"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	// "Fixed: 1" means all versions after this are not vulnerable
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{Fixed: "1.0.0"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	// multiple fixes and introduced
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{Fixed: "1"},
+					models.Event{Introduced: "2.1.0"},
+					models.Event{Fixed: "3.2.0"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+
+	// "LastAffected: 1" means all versions after this are not vulnerable
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{LastAffected: "1.0.0"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc", "1.0.0"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.1", "1.1.0", "2.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	// mix of fixes, last_known_affected, and introduced
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{Fixed: "1"},
+					models.Event{Introduced: "2.1.0"},
+					models.Event{LastAffected: "3.1.9"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+}
+
+func TestOSV_IsAffected_AffectsWithSemver_MultipleAffected(t *testing.T) {
+	t.Parallel()
+
+	vuln := buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{Fixed: "1"},
+				),
+			},
+		},
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(
+					models.Event{Introduced: "2.1.0"},
+					models.Event{Fixed: "3.2.0"},
+				),
+			},
+		},
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(
+					models.Event{Introduced: "3.3.0"},
+					models.Event{LastAffected: "3.5.0"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"3.3.1", "3.4.5", "3.5.0"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+}
+
+func TestOSV_IsAffected_AffectsWithSemver_Unsorted(t *testing.T) {
+	t.Parallel()
+
+	// mix of fixes, last_known_affected, and introduced
+	vuln := buildOSVWithAffected(
+		models.Affected{
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(
+					models.Event{Introduced: "0"},
+					models.Event{Introduced: "2.1.0"},
+					models.Event{Fixed: "1"},
+					models.Event{LastAffected: "3.1.9"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+
+	// zeros with build strings
+	vuln = buildOSVWithAffected(
+		models.Affected{
+			// golang.org/x/sys
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(
+					models.Event{Fixed: "0.0.0-20220412211240-33da011f77ad"},
+					models.Event{Introduced: "0"},
+				),
+			},
+		},
+		models.Affected{
+			// golang.org/x/net
+			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Ranges: []models.Range{
+				buildSemverAffectsRange(
+					models.Event{Introduced: "0.0.0-20180925071336-cf3bd585ca2a"},
+					models.Event{Fixed: "0"},
+				),
+			},
+		},
+	)
+
+	for _, v := range []string{"0.0.0", "0.14.0"} {
+		expectIsAffected(t, vuln, v, false)
+	}
+
+	for _, v := range []string{"0.0.0-20180925071336-cf3bd585ca2a"} {
+		expectIsAffected(t, vuln, v, true)
+	}
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+}
+
+func TestOSV_IsAffected_OnlyVersions(t *testing.T) {
+	t.Parallel()
+
+	vuln := buildOSVWithAffected(
+		models.Affected{
+			Package:  models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
+			Versions: []string{"1.0.0"},
+		},
+	)
+
+	expectIsAffected(t, vuln, "0.0.0", false)
+	expectIsAffected(t, vuln, "1.0.0", true)
+	expectIsAffected(t, vuln, "1.0.0-beta1", false)
+	expectIsAffected(t, vuln, "1.1.0", false)
+
+	// an empty version should always be treated as affected
+	expectIsAffected(t, vuln, "", true)
+}
diff --git a/pkg/models/vulnerabilities_test.go b/pkg/models/vulnerabilities_test.go
index 2430283..2d06f2d 100644
--- a/pkg/models/vulnerabilities_test.go
+++ b/pkg/models/vulnerabilities_test.go
@@ -7,127 +7,6 @@ import (
 	"github.com/google/osv-scanner/pkg/models"
 )
 
-func TestVulnerabilities_Includes(t *testing.T) {
-	t.Parallel()
-
-	type args struct {
-		osv models.Vulnerability
-	}
-	tests := []struct {
-		name string
-		vs   models.Vulnerabilities
-		args args
-		want bool
-	}{
-		{
-			name: "",
-			vs: models.Vulnerabilities{
-				models.Vulnerability{
-					ID:      "GHSA-1",
-					Aliases: []string{},
-				},
-			},
-			args: args{
-				osv: models.Vulnerability{
-					ID:      "GHSA-2",
-					Aliases: []string{},
-				},
-			},
-			want: false,
-		},
-		{
-			name: "",
-			vs: models.Vulnerabilities{
-				models.Vulnerability{
-					ID:      "GHSA-1",
-					Aliases: []string{},
-				},
-			},
-			args: args{
-				osv: models.Vulnerability{
-					ID:      "GHSA-1",
-					Aliases: []string{},
-				},
-			},
-			want: true,
-		},
-		{
-			name: "",
-			vs: models.Vulnerabilities{
-				models.Vulnerability{
-					ID:      "GHSA-1",
-					Aliases: []string{"GHSA-2"},
-				},
-			},
-			args: args{
-				osv: models.Vulnerability{
-					ID:      "GHSA-2",
-					Aliases: []string{},
-				},
-			},
-			want: true,
-		},
-		{
-			name: "",
-			vs: models.Vulnerabilities{
-				models.Vulnerability{
-					ID:      "GHSA-1",
-					Aliases: []string{},
-				},
-			},
-			args: args{
-				osv: models.Vulnerability{
-					ID:      "GHSA-2",
-					Aliases: []string{"GHSA-1"},
-				},
-			},
-			want: true,
-		},
-		{
-			name: "",
-			vs: models.Vulnerabilities{
-				models.Vulnerability{
-					ID:      "GHSA-1",
-					Aliases: []string{"CVE-1"},
-				},
-			},
-			args: args{
-				osv: models.Vulnerability{
-					ID:      "GHSA-2",
-					Aliases: []string{"CVE-1"},
-				},
-			},
-			want: true,
-		},
-		{
-			name: "",
-			vs: models.Vulnerabilities{
-				models.Vulnerability{
-					ID:      "GHSA-1",
-					Aliases: []string{"CVE-2"},
-				},
-			},
-			args: args{
-				osv: models.Vulnerability{
-					ID:      "GHSA-2",
-					Aliases: []string{"CVE-2"},
-				},
-			},
-			want: true,
-		},
-	}
-	for _, tt := range tests {
-		tt := tt
-		t.Run(tt.name, func(t *testing.T) {
-			t.Parallel()
-
-			if got := tt.vs.Includes(tt.args.osv); got != tt.want {
-				t.Errorf("Includes() = %v, want %v", got, tt.want)
-			}
-		})
-	}
-}
-
 func TestVulnerabilities_MarshalJSON(t *testing.T) {
 	t.Parallel()
 
diff --git a/pkg/models/vulnerability_test.go b/pkg/models/vulnerability_test.go
index 1303e07..11171ca 100644
--- a/pkg/models/vulnerability_test.go
+++ b/pkg/models/vulnerability_test.go
@@ -5,7 +5,6 @@ import (
 	"testing"
 	"time"
 
-	"github.com/google/osv-scanner/pkg/lockfile"
 	"github.com/google/osv-scanner/pkg/models"
 	"gopkg.in/yaml.v3"
 )
@@ -232,699 +231,3 @@ func TestVulnerability_MarshalYAMLTimes(t *testing.T) {
 		})
 	}
 }
-
-func expectIsAffected(t *testing.T, vuln models.Vulnerability, version string, expectAffected bool) {
-	t.Helper()
-
-	pkg := lockfile.PackageDetails{
-		Name:      "my-package",
-		Version:   version,
-		Ecosystem: lockfile.NpmEcosystem,
-		CompareAs: lockfile.NpmEcosystem,
-	}
-
-	if vuln.IsAffected(pkg) != expectAffected {
-		if expectAffected {
-			t.Errorf("Expected OSV to affect package version %s but it did not", version)
-		} else {
-			t.Errorf("Expected OSV not to affect package version %s but it did", version)
-		}
-	}
-}
-
-func buildOSVWithAffected(affected ...models.Affected) models.Vulnerability {
-	return models.Vulnerability{
-		ID:        "1",
-		Published: time.Time{},
-		Modified:  time.Time{},
-		Details:   "This is an open source vulnerability!",
-		Affected:  affected,
-	}
-}
-
-func buildEcosystemAffectsRange(events ...models.Event) models.Range {
-	return models.Range{Type: models.RangeEcosystem, Events: events}
-}
-
-func buildSemverAffectsRange(events ...models.Event) models.Range {
-	return models.Range{Type: models.RangeSemVer, Events: events}
-}
-
-func TestOSV_AffectsEcosystem(t *testing.T) {
-	t.Parallel()
-
-	type AffectsTest struct {
-		Affected  []models.Affected
-		Ecosystem lockfile.Ecosystem
-		Expected  bool
-	}
-
-	tests := []AffectsTest{
-		{Affected: nil, Ecosystem: "Go", Expected: false},
-		{Affected: nil, Ecosystem: "npm", Expected: false},
-		{Affected: nil, Ecosystem: "PyPI", Expected: false},
-		{Affected: nil, Ecosystem: "", Expected: false},
-		{
-			Affected: []models.Affected{
-				{Package: models.Package{Ecosystem: "crates.io"}},
-				{Package: models.Package{Ecosystem: "npm"}},
-				{Package: models.Package{Ecosystem: "PyPI"}},
-			},
-			Ecosystem: "Packagist",
-			Expected:  false,
-		},
-		{
-			Affected: []models.Affected{
-				{Package: models.Package{Ecosystem: "NuGet"}},
-			},
-			Ecosystem: "NuGet",
-			Expected:  true,
-		},
-		{
-			Affected: []models.Affected{
-				{Package: models.Package{Ecosystem: "npm"}},
-				{Package: models.Package{Ecosystem: "npm"}},
-			},
-			Ecosystem: "npm",
-			Expected:  true,
-		},
-	}
-
-	for i, test := range tests {
-		vuln := models.Vulnerability{
-			ID:        "1",
-			Published: time.Time{},
-			Modified:  time.Time{},
-			Details:   "This is an open source vulnerability!",
-			Affected:  test.Affected,
-		}
-
-		if vuln.AffectsEcosystem(test.Ecosystem) != test.Expected {
-			t.Errorf(
-				"Test #%d: Expected OSV to return %t but it returned %t",
-				i,
-				test.Expected,
-				!test.Expected,
-			)
-		}
-	}
-
-	// test when the OSV doesn't have an "Affected"
-	vuln := models.Vulnerability{
-		ID:        "1",
-		Published: time.Time{},
-		Modified:  time.Time{},
-		Details:   "This is an open source vulnerability!",
-		Affected:  nil,
-	}
-
-	if vuln.AffectsEcosystem("npm") {
-		t.Errorf(
-			"Expected OSV to report 'false' when it doesn't have an Affected, but it reported true!",
-		)
-	}
-}
-
-func TestOSV_IsAffected_AffectsWithEcosystem_DifferentEcosystem(t *testing.T) {
-	t.Parallel()
-
-	vuln := buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemPyPI, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(models.Event{Introduced: "0"}),
-			},
-		},
-	)
-
-	for _, v := range []string{"1.0.0", "1.1.1", "2.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-}
-
-func TestOSV_IsAffected_AffectsWithEcosystem_SingleAffected(t *testing.T) {
-	t.Parallel()
-
-	var vuln models.Vulnerability
-
-	// "Introduced: 0" means everything is affected
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(models.Event{Introduced: "0"}),
-			},
-		},
-	)
-
-	for _, v := range []string{"1.0.0", "1.1.1", "2.0.0"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-
-	// "Fixed: 1" means all versions after this are not vulnerable
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{Fixed: "1"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-
-	// multiple fixes and introduced
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{Fixed: "1"},
-					models.Event{Introduced: "2.1.0"},
-					models.Event{Fixed: "3.2.0"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-
-	// "LastAffected: 1" means all versions after this are not vulnerable
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{LastAffected: "1"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc", "1.0.0"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.1", "1.1.0", "2.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-
-	// mix of fixes, last_known_affected, and introduced
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{Fixed: "1"},
-					models.Event{Introduced: "2.1.0"},
-					models.Event{LastAffected: "3.1.9"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc", "3.1.9"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-}
-
-func TestOSV_IsAffected_AffectsWithEcosystem_MultipleAffected(t *testing.T) {
-	t.Parallel()
-
-	vuln := buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{Fixed: "1"},
-				),
-			},
-		},
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(
-					models.Event{Introduced: "2.1.0"},
-					models.Event{Fixed: "3.2.0"},
-				),
-			},
-		},
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(
-					models.Event{Introduced: "3.3.0"},
-					models.Event{LastAffected: "3.5.0"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"3.3.1", "3.4.5"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-}
-
-func TestOSV_IsAffected_AffectsWithEcosystem_Unsorted(t *testing.T) {
-	t.Parallel()
-
-	vuln := buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{Introduced: "2.1.0"},
-					models.Event{Fixed: "1"},
-					models.Event{LastAffected: "3.1.9"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc", "3.1.9"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-
-	// zeros with build strings
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			// golang.org/x/sys
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(
-					models.Event{Fixed: "0.0.0-20220412211240-33da011f77ad"},
-					models.Event{Introduced: "0"},
-				),
-			},
-		},
-		models.Affected{
-			// golang.org/x/net
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildEcosystemAffectsRange(
-					models.Event{Introduced: "0.0.0-20180925071336-cf3bd585ca2a"},
-					models.Event{Fixed: "0"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.14.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"0.0.0-20180925071336-cf3bd585ca2a"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-}
-
-func TestOSV_IsAffected_AffectsWithSemver_DifferentEcosystem(t *testing.T) {
-	t.Parallel()
-
-	vuln := buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemPyPI, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(models.Event{Introduced: "0"}),
-			},
-		},
-	)
-
-	for _, v := range []string{"1.0.0", "1.1.1", "2.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-}
-
-func TestOSV_IsAffected_AffectsWithSemver_SingleAffected(t *testing.T) {
-	t.Parallel()
-
-	var vuln models.Vulnerability
-
-	// "Introduced: 0" means everything is affected
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(models.Event{Introduced: "0"}),
-			},
-		},
-	)
-
-	for _, v := range []string{"v1.0.0", "v1.1.1", "v2.0.0"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	// "Fixed: 1" means all versions after this are not vulnerable
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{Fixed: "1.0.0"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	// multiple fixes and introduced
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{Fixed: "1"},
-					models.Event{Introduced: "2.1.0"},
-					models.Event{Fixed: "3.2.0"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-
-	// "LastAffected: 1" means all versions after this are not vulnerable
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{LastAffected: "1.0.0"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc", "1.0.0"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.1", "1.1.0", "2.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	// mix of fixes, last_known_affected, and introduced
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{Fixed: "1"},
-					models.Event{Introduced: "2.1.0"},
-					models.Event{LastAffected: "3.1.9"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-}
-
-func TestOSV_IsAffected_AffectsWithSemver_MultipleAffected(t *testing.T) {
-	t.Parallel()
-
-	vuln := buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{Fixed: "1"},
-				),
-			},
-		},
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(
-					models.Event{Introduced: "2.1.0"},
-					models.Event{Fixed: "3.2.0"},
-				),
-			},
-		},
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(
-					models.Event{Introduced: "3.3.0"},
-					models.Event{LastAffected: "3.5.0"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"3.3.1", "3.4.5", "3.5.0"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-}
-
-func TestOSV_IsAffected_AffectsWithSemver_Unsorted(t *testing.T) {
-	t.Parallel()
-
-	// mix of fixes, last_known_affected, and introduced
-	vuln := buildOSVWithAffected(
-		models.Affected{
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(
-					models.Event{Introduced: "0"},
-					models.Event{Introduced: "2.1.0"},
-					models.Event{Fixed: "1"},
-					models.Event{LastAffected: "3.1.9"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.1.0", "0.0.0.1", "1.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"1.0.0", "1.1.0", "2.0.0rc2", "2.0.1"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"2.1.1", "2.3.4", "3.0.0", "3.0.0-rc"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	for _, v := range []string{"3.2.0", "3.2.1", "4.0.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-
-	// zeros with build strings
-	vuln = buildOSVWithAffected(
-		models.Affected{
-			// golang.org/x/sys
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(
-					models.Event{Fixed: "0.0.0-20220412211240-33da011f77ad"},
-					models.Event{Introduced: "0"},
-				),
-			},
-		},
-		models.Affected{
-			// golang.org/x/net
-			Package: models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Ranges: []models.Range{
-				buildSemverAffectsRange(
-					models.Event{Introduced: "0.0.0-20180925071336-cf3bd585ca2a"},
-					models.Event{Fixed: "0"},
-				),
-			},
-		},
-	)
-
-	for _, v := range []string{"0.0.0", "0.14.0"} {
-		expectIsAffected(t, vuln, v, false)
-	}
-
-	for _, v := range []string{"0.0.0-20180925071336-cf3bd585ca2a"} {
-		expectIsAffected(t, vuln, v, true)
-	}
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-}
-
-func TestOSV_IsAffected_OnlyVersions(t *testing.T) {
-	t.Parallel()
-
-	vuln := buildOSVWithAffected(
-		models.Affected{
-			Package:  models.Package{Ecosystem: models.EcosystemNPM, Name: "my-package"},
-			Versions: []string{"1.0.0"},
-		},
-	)
-
-	expectIsAffected(t, vuln, "0.0.0", false)
-	expectIsAffected(t, vuln, "1.0.0", true)
-	expectIsAffected(t, vuln, "1.0.0-beta1", false)
-	expectIsAffected(t, vuln, "1.1.0", false)
-
-	// an empty version should always be treated as affected
-	expectIsAffected(t, vuln, "", true)
-}