internal/report: fix bug in CVE5 generation

Fixes a bug in which incorrect version ranges were sometimes generated
when converting reports to CVE5. The bug happens when operating on a report
with no fixed version.

The problem is that the CVE JSON 5.0 format only allows version ranges
of the form "versions X to Y are affected", "versions X to Y are NOT affected"
or "version X is affected".

It does not directly allow the statement "version X and above are affected" - this must be expressed as "version 0 through X are unaffected, all others are affected". This change allows that to be expressed.

This bug became clear when we published GO-2023-2328. The CVE for that report
is also re-generated as a part of this change.

Change-Id: I0c61168581d65b13850d3a763a3300c04594b84c
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/545295
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Damien Neil <dneil@google.com>
diff --git a/data/cve/v5/GO-2023-2328.json b/data/cve/v5/GO-2023-2328.json
index 8df4e2a..5679738 100644
--- a/data/cve/v5/GO-2023-2328.json
+++ b/data/cve/v5/GO-2023-2328.json
@@ -24,9 +24,9 @@
           "packageName": "github.com/go-resty/resty/v2",
           "versions": [
             {
-              "version": "2.10.0",
-              "lessThan": "",
-              "status": "affected",
+              "version": "0",
+              "lessThan": "2.10.0",
+              "status": "unaffected",
               "versionType": "semver"
             }
           ],
@@ -65,7 +65,7 @@
               "name": "Request.Send"
             }
           ],
-          "defaultStatus": "unaffected"
+          "defaultStatus": "affected"
         }
       ],
       "problemTypes": [
diff --git a/internal/report/cve5.go b/internal/report/cve5.go
index 28fa5cb..1fda9bc 100644
--- a/internal/report/cve5.go
+++ b/internal/report/cve5.go
@@ -65,12 +65,7 @@
 	}
 
 	for _, m := range r.Modules {
-		versions := versionRangeToVersionRange(m.Versions)
-		defaultStatus := cveschema5.StatusUnaffected
-		if len(versions) == 0 {
-			// If there are no recorded versions affected, we assume all versions are affected.
-			defaultStatus = cveschema5.StatusAffected
-		}
+		versions, defaultStatus := versionRangeToVersionRange(m.Versions)
 		for _, p := range m.Packages {
 			affected := cveschema5.Affected{
 				Vendor:        vendor(m.Module),
@@ -121,22 +116,60 @@
 	return filepath.Join(cve5Dir, r.ID+".json")
 }
 
-func versionRangeToVersionRange(versions []VersionRange) []cveschema5.VersionRange {
+const (
+	typeSemver  = "semver"
+	versionZero = "0"
+)
+
+func versionRangeToVersionRange(versions []VersionRange) ([]cveschema5.VersionRange, cveschema5.VersionStatus) {
+	if len(versions) == 0 {
+		// If there are no recorded versions affected, we assume all versions are affected.
+		return nil, cveschema5.StatusAffected
+	}
+
 	var cveVRs []cveschema5.VersionRange
+
+	// If there is no final fixed version, then the default status is
+	// "affected" and we express the versions in terms of which ranges
+	// are *unaffected*. This is due to the fact that the CVE schema
+	// does not allow us to express a range as "version X.X.X and above are affected".
+	if versions[len(versions)-1].Fixed == "" {
+		current := &cveschema5.VersionRange{}
+		for _, vr := range versions {
+			if vr.Introduced != "" {
+				if current.Introduced == "" {
+					current.Introduced = versionZero
+				}
+				current.Fixed = cveschema5.Version(vr.Introduced)
+				current.Status = cveschema5.StatusUnaffected
+				current.VersionType = typeSemver
+				cveVRs = append(cveVRs, *current)
+				current = &cveschema5.VersionRange{}
+			}
+			if vr.Fixed != "" {
+				current.Introduced = cveschema5.Version(vr.Fixed)
+			}
+		}
+		return cveVRs, cveschema5.StatusAffected
+	}
+
+	// Otherwise, express the version ranges normally as affected ranges,
+	// with a default status of "unaffected".
 	for _, vr := range versions {
 		cveVR := cveschema5.VersionRange{
 			Status:      cveschema5.StatusAffected,
-			VersionType: "semver",
+			VersionType: typeSemver,
 		}
 		if vr.Introduced != "" {
 			cveVR.Introduced = cveschema5.Version(vr.Introduced)
 		} else {
-			cveVR.Introduced = "0"
+			cveVR.Introduced = versionZero
 		}
 		if vr.Fixed != "" {
 			cveVR.Fixed = cveschema5.Version(vr.Fixed)
 		}
 		cveVRs = append(cveVRs, cveVR)
 	}
-	return cveVRs
+
+	return cveVRs, cveschema5.StatusUnaffected
 }
diff --git a/internal/report/cve5_test.go b/internal/report/cve5_test.go
index 4a306b2..bd415aa 100644
--- a/internal/report/cve5_test.go
+++ b/internal/report/cve5_test.go
@@ -6,6 +6,7 @@
 
 import (
 	"path/filepath"
+	"reflect"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
@@ -282,3 +283,165 @@
 		t.Errorf("got %s, want %s", got, want)
 	}
 }
+
+func TestVersionRangeToVersionRange(t *testing.T) {
+	tests := []struct {
+		name        string
+		versions    []VersionRange
+		wantRange   []cveschema5.VersionRange
+		wantDefault cveschema5.VersionStatus
+	}{
+		{
+			name:        "nil",
+			versions:    nil,
+			wantRange:   nil,
+			wantDefault: cveschema5.StatusAffected,
+		},
+		{
+			name:        "empty",
+			versions:    []VersionRange{},
+			wantRange:   nil,
+			wantDefault: cveschema5.StatusAffected,
+		},
+		{
+			name: "basic",
+			versions: []VersionRange{
+				{
+					Introduced: "1.0.0",
+					Fixed:      "1.0.1",
+				},
+				{
+					Introduced: "1.2.0",
+					Fixed:      "1.2.3",
+				},
+			},
+			wantRange: []cveschema5.VersionRange{
+				{
+					Introduced:  "1.0.0",
+					Fixed:       "1.0.1",
+					Status:      cveschema5.StatusAffected,
+					VersionType: typeSemver,
+				},
+				{
+					Introduced:  "1.2.0",
+					Fixed:       "1.2.3",
+					Status:      cveschema5.StatusAffected,
+					VersionType: typeSemver,
+				},
+			},
+			wantDefault: cveschema5.StatusUnaffected,
+		},
+		{
+			name: "no initial introduced",
+			versions: []VersionRange{
+				{
+					Fixed: "1.0.1",
+				},
+				{
+					Introduced: "1.2.0",
+					Fixed:      "1.2.3",
+				},
+			},
+			wantRange: []cveschema5.VersionRange{
+				{
+					Introduced:  "0",
+					Fixed:       "1.0.1",
+					Status:      cveschema5.StatusAffected,
+					VersionType: typeSemver,
+				},
+				{
+					Introduced:  "1.2.0",
+					Fixed:       "1.2.3",
+					Status:      cveschema5.StatusAffected,
+					VersionType: typeSemver,
+				},
+			},
+			wantDefault: cveschema5.StatusUnaffected,
+		},
+		{
+			name: "no fix",
+			versions: []VersionRange{
+				{
+					Introduced: "1.0.0",
+				},
+			},
+			wantRange: []cveschema5.VersionRange{
+				{
+					Introduced:  "0",
+					Fixed:       "1.0.0",
+					Status:      cveschema5.StatusUnaffected,
+					VersionType: typeSemver,
+				},
+			},
+			wantDefault: cveschema5.StatusAffected,
+		},
+		{
+			name: "no final fix",
+			versions: []VersionRange{
+				{
+					Introduced: "1.0.0",
+					Fixed:      "1.0.3",
+				},
+				{
+					Introduced: "1.1.0",
+				},
+			},
+			wantRange: []cveschema5.VersionRange{
+				{
+					Introduced:  "0",
+					Fixed:       "1.0.0",
+					Status:      cveschema5.StatusUnaffected,
+					VersionType: typeSemver,
+				},
+				{
+					Introduced:  "1.0.3",
+					Fixed:       "1.1.0",
+					Status:      cveschema5.StatusUnaffected,
+					VersionType: typeSemver,
+				},
+			},
+			wantDefault: cveschema5.StatusAffected,
+		},
+		{
+			name: "no initial introduced and no final fix",
+			versions: []VersionRange{
+				{
+					Fixed: "1.0.3",
+				},
+				{
+					Introduced: "1.0.5",
+					Fixed:      "1.0.7",
+				},
+				{
+					Introduced: "1.1.0",
+				},
+			},
+			wantRange: []cveschema5.VersionRange{
+				{
+					Introduced:  "1.0.3",
+					Fixed:       "1.0.5",
+					Status:      cveschema5.StatusUnaffected,
+					VersionType: typeSemver,
+				},
+				{
+					Introduced:  "1.0.7",
+					Fixed:       "1.1.0",
+					Status:      cveschema5.StatusUnaffected,
+					VersionType: typeSemver,
+				},
+			},
+			wantDefault: cveschema5.StatusAffected,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotRange, gotStatus := versionRangeToVersionRange(tt.versions)
+			if !reflect.DeepEqual(gotRange, tt.wantRange) {
+				t.Errorf("versionRangeToVersionRange() got version range = %v, want %v", gotRange, tt.wantRange)
+			}
+			if !reflect.DeepEqual(gotStatus, tt.wantDefault) {
+				t.Errorf("versionRangeToVersionRange() got default status = %v, want %v", gotStatus, tt.wantDefault)
+			}
+		})
+	}
+}