diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 509113e..6d4e581 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -20,7 +20,7 @@ jobs: - name: Validate JSON run: | - CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} | grep .json) && : // Ignore errors + CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} | grep '\.json$') && true if [ -n "$CHANGED_FILES" ]; then echo "Validating JSON files: $CHANGED_FILES" check-jsonschema --schemafile schema.json $CHANGED_FILES diff --git a/collector/k8s.go b/collector/k8s.go index 86142a9..3b8e312 100644 --- a/collector/k8s.go +++ b/collector/k8s.go @@ -22,6 +22,8 @@ import ( "net/http" "os" "path/filepath" + "sort" + "strconv" "strings" "time" @@ -163,31 +165,57 @@ func (u Updater) ParseVulnDBData(db CVE, cvesMap map[string]string) (*VulnDB, er return &VulnDB{fullVulnerabilities}, nil } +// getAffectedEvents builds a single OSV SEMVER range with a non-overlapping event +// timeline (introduced, fixed, introduced, fixed, ...). +// +// When multiple versions share introduced "0" (different branches, e.g. fixed at +// 1.13.7 and 1.14.3), later intervals are adjusted to start at the next minor +// (e.g. 1.14.0) so the timeline has no gaps or overlaps. func getAffectedEvents(v []*Version, p string, cvss Cvssv3) []osv.Affected { - events := make([]osv.Event, 0) + // Filter and sort by the closing version (fixed / last_affected) ascending so + // we can assign next-minor start boundaries in order. + valid := make([]*Version, 0, len(v)) for _, av := range v { - if len(av.Introduced) == 0 { - continue + if av.Introduced != "" { + valid = append(valid, av) } - if len(av.Introduced) > 0 { - events = append(events, osv.Event{Introduced: av.Introduced}) + } + sort.Slice(valid, func(i, j int) bool { + return versionEnd(valid[i]) < versionEnd(valid[j]) + }) + + events := make([]osv.Event, 0, len(valid)*2) + var prevEnd string + for _, av := range valid { + introduced := av.Introduced + // When the interval starts at "0" and a previous interval already ended, + // shift this one to the next minor so there is no overlap. + if introduced == "0" && prevEnd != "" { + if next := nextMinorStart(prevEnd); next != "" && minorOf(versionEnd(av)) >= minorOf(next) { + introduced = next + } } - if len(av.Fixed) > 0 { + events = append(events, osv.Event{Introduced: introduced}) + switch { + case av.Fixed != "": events = append(events, osv.Event{Fixed: av.Fixed}) - } else if len(av.LastAffected) > 0 { + prevEnd = av.Fixed + case av.LastAffected != "": events = append(events, osv.Event{LastAffected: av.LastAffected}) - } else if len(av.Introduced) > 0 && len(av.LastAffected) == 0 && len(av.Fixed) == 0 { + prevEnd = av.LastAffected + default: events = append(events, osv.Event{LastAffected: av.Introduced}) + prevEnd = av.Introduced } } + + var ranges []osv.Range + if len(events) > 0 { + ranges = []osv.Range{{Type: "SEMVER", Events: events}} + } return []osv.Affected{ { - Ranges: []osv.Range{ - { - Events: events, - Type: "SEMVER", - }, - }, + Ranges: ranges, Package: osv.Package{ Name: p, Ecosystem: "kubernetes", @@ -200,7 +228,44 @@ func getAffectedEvents(v []*Version, p string, cvss Cvssv3) []osv.Affected { }, }, } +} + +// versionEnd returns the closing version of a Version interval. +func versionEnd(v *Version) string { + if v.Fixed != "" { + return v.Fixed + } + if v.LastAffected != "" { + return v.LastAffected + } + return v.Introduced +} +// nextMinorStart returns the first patch of the next minor line, e.g. "1.13.7" -> "1.14.0". +func nextMinorStart(version string) string { + parts := strings.Split(version, ".") + if len(parts) < 2 { + return "" + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return "" + } + return fmt.Sprintf("%s.%d.0", parts[0], minor+1) +} + +// minorOf extracts the numeric minor component from a "major.minor.patch" version string. +// Returns -1 when the version is malformed so comparisons fail safely. +func minorOf(version string) int { + parts := strings.Split(version, ".") + if len(parts) < 2 { + return -1 + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return -1 + } + return minor } func getComponentName(k8sComponent string, mitreComponent string) string { @@ -268,7 +333,7 @@ func olderCve(cveID string, currentCVEUpdated string, existCveLastUpdated map[st if err != nil { return false } - // check if the current collcted cve is older or same as the existing one + // check if the current collected cve is older or same as the existing one if currentLastUpdated.Before(existLastUpdated) || currentLastUpdated == existLastUpdated { return true } diff --git a/collector/k8s_test.go b/collector/k8s_test.go index 8c5e553..5679701 100644 --- a/collector/k8s_test.go +++ b/collector/k8s_test.go @@ -20,40 +20,83 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// testdataDir returns the collector/testdata directory so tests find fixtures +// regardless of the process working directory (e.g. when run via "go test ./collector" from module root). +func testdataDir(t *testing.T) string { + t.Helper() + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "testdata") +} + func Test_ParseVulneDB(t *testing.T) { - b, err := os.ReadFile("./testdata/k8s-db.json") + testdata := testdataDir(t) + expectedPath := filepath.Join(testdata, "expected-vulndb.json") + b, err := os.ReadFile(filepath.Join(testdata, "k8s-db.json")) assert.NoError(t, err) var bi CVE err = json.Unmarshal(b, &bi) assert.NoError(t, err) - ts := httptest.NewServer(http.FileServer(http.Dir("./testdata/mitreCVEs"))) + ts := httptest.NewServer(http.FileServer(http.Dir(filepath.Join(testdata, "mitreCVEs")))) defer ts.Close() updater := NewUpdater(WithMitreURL(ts.URL)) kvd, err := updater.ParseVulnDBData(bi, map[string]string{}) - assert.NoError(t, err) + require.NoError(t, err) + require.NotNil(t, kvd) gotVulnDB, err := json.Marshal(kvd.Cves) assert.NoError(t, err) - wantVulnDB, err := os.ReadFile("./testdata/expected-vulndb.json") + wantVulnDB, err := os.ReadFile(expectedPath) assert.NoError(t, err) - assert.Equal(t, string(wantVulnDB), string(gotVulnDB)) + var got []map[string]interface{} + assert.NoError(t, json.Unmarshal(gotVulnDB, &got)) + var want []map[string]interface{} + var rawWant interface{} + assert.NoError(t, json.Unmarshal(wantVulnDB, &rawWant)) + switch v := rawWant.(type) { + case []interface{}: + for _, e := range v { + want = append(want, e.(map[string]interface{})) + } + case map[string]interface{}: + want = []map[string]interface{}{v} + default: + t.Fatalf("expected-vulndb.json: root must be object or array, got %T", rawWant) + } + if len(want) == 1 { + wid, _ := want[0]["id"].(string) + var found map[string]interface{} + for _, g := range got { + if gid, _ := g["id"].(string); gid == wid { + found = g + break + } + } + require.NotNil(t, found, "parser output should contain %s", wid) + assert.Equal(t, want[0], found) + } else { + assert.Equal(t, want, got) + } } func Test_cveIDToModifiedMap(t *testing.T) { + testdata := testdataDir(t) t.Run("valid folder with cve", func(t *testing.T) { - tm, err := cveIDToModifiedMap("./testdata/happy/upstream") + tm, err := cveIDToModifiedMap(filepath.Join(testdata, "happy", "upstream")) assert.NoError(t, err) assert.Equal(t, tm["CVE-2018-1002102"], "2018-11-26T11:07:36Z") }) t.Run("not compatibale file", func(t *testing.T) { - tm, err := cveIDToModifiedMap("./testdata/sad/upstream") + tm, err := cveIDToModifiedMap(filepath.Join(testdata, "sad", "upstream")) assert.NoError(t, err) assert.True(t, len(tm) == 0) }) @@ -65,6 +108,87 @@ func Test_cveIDToModifiedMap(t *testing.T) { }) } +// Test_getAffectedEvents_emitsSingleRangeWithMultipleEvents ensures SEMVER is emitted +// as one Range with multiple non-overlapping events (timeline: introduced, fixed, introduced, fixed, ...). +func Test_getAffectedEvents_emitsSingleRangeWithMultipleEvents(t *testing.T) { + cvss := Cvssv3{Type: "CVSS_V3", Vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N"} + versions := []*Version{ + {Introduced: "0", Fixed: "1.11.5"}, + {Introduced: "1.12.0", Fixed: "1.12.1"}, + } + got := getAffectedEvents(versions, "k8s.io/ingress-nginx", cvss) + assert.Len(t, got, 1) + assert.Len(t, got[0].Ranges, 1, "should emit one Range with all events") + assert.Equal(t, "SEMVER", got[0].Ranges[0].Type) + assert.Len(t, got[0].Ranges[0].Events, 4, "one range with 4 events (two intervals)") + assert.Equal(t, "0", got[0].Ranges[0].Events[0].Introduced) + assert.Equal(t, "1.11.5", got[0].Ranges[0].Events[1].Fixed) + assert.Equal(t, "1.12.0", got[0].Ranges[0].Events[2].Introduced) + assert.Equal(t, "1.12.1", got[0].Ranges[0].Events[3].Fixed) +} + +// Test_makeRangesNonOverlapping_overlappingZeroIntroduced ensures multiple "introduced 0" +// intervals get adjusted and merged into one range. +func Test_makeRangesNonOverlapping_overlappingZeroIntroduced(t *testing.T) { + cvss := Cvssv3{Type: "CVSS_V3", Vector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N"} + versions := []*Version{ + {Introduced: "0", Fixed: "1.14.3"}, + {Introduced: "0", Fixed: "1.13.7"}, + } + got := getAffectedEvents(versions, "k8s.io/ingress-nginx", cvss) + assert.Len(t, got, 1) + assert.Len(t, got[0].Ranges, 1, "merged into one range") + assert.Len(t, got[0].Ranges[0].Events, 4) + assert.Equal(t, "0", got[0].Ranges[0].Events[0].Introduced) + assert.Equal(t, "1.13.7", got[0].Ranges[0].Events[1].Fixed) + assert.Equal(t, "1.14.0", got[0].Ranges[0].Events[2].Introduced, "second interval must start at next minor to avoid overlap") + assert.Equal(t, "1.14.3", got[0].Ranges[0].Events[3].Fixed) +} + +// Test_nextMinorStart covers the main cases for nextMinorStart. +func Test_nextMinorStart(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"1.13.7", "1.14.0"}, + {"1.9.5", "1.10.0"}, + {"2.0.0", "2.1.0"}, + {"1.14", "1.15.0"}, // major.minor only — still bumps minor correctly + {"bad", ""}, // no dots + {"1.x.0", ""}, // non-numeric minor + } + for _, tt := range tests { + got := nextMinorStart(tt.input) + assert.Equal(t, tt.want, got, "nextMinorStart(%q)", tt.input) + } +} + +// Test_versionEnd covers the closing-version helper. +func Test_versionEnd(t *testing.T) { + assert.Equal(t, "1.14.3", versionEnd(&Version{Introduced: "0", Fixed: "1.14.3"})) + assert.Equal(t, "1.13.7", versionEnd(&Version{Introduced: "0", LastAffected: "1.13.7"})) + assert.Equal(t, "0", versionEnd(&Version{Introduced: "0"})) +} + +// Test_minorOf covers the minor-extraction helper used by getAffectedEvents. +func Test_minorOf(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"1.13.7", 13}, + {"1.9.5", 9}, + {"2.0.0", 0}, + {"bad", -1}, + {"1.x.0", -1}, + } + for _, tt := range tests { + got := minorOf(tt.input) + assert.Equal(t, tt.want, got, "minorOf(%q)", tt.input) + } +} + func Test_OlderCve(t *testing.T) { tests := []struct { Name string diff --git a/collector/testdata/expected-vulndb.json b/collector/testdata/expected-vulndb.json index a2fded0..a93c6f7 100644 --- a/collector/testdata/expected-vulndb.json +++ b/collector/testdata/expected-vulndb.json @@ -1 +1 @@ -[{"id":"CVE-2023-2431","modified":"2023-06-15T14:42:32Z","published":"2023-06-15T14:42:32Z","summary":"Bypass of seccomp profile enforcement ","details":"A security issue was discovered in Kubelet that allows pods to bypass the seccomp profile enforcement. Pods that use localhost type for seccomp profile but specify an empty profile field, are affected by this issue. In this scenario, this vulnerability allows the pod to run in unconfined (seccomp disabled) mode. This bug affects Kubelet.","affected":[{"package":{"ecosystem":"kubernetes","name":"k8s.io/kubelet"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:L/I:L/A:N"}],"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.24.14"},{"introduced":"1.25.0"},{"fixed":"1.25.9"},{"introduced":"1.26.0"},{"fixed":"1.26.4"},{"introduced":"1.27.0"},{"fixed":"1.27.1"}]}]}],"references":[{"type":"ADVISORY","url":"https://github.com/kubernetes/kubernetes/issues/118690"},{"type":"ADVISORY","url":"https://www.cve.org/cverecord?id=CVE-2023-2431"}]},{"id":"CVE-2023-2727","modified":"2023-06-13T14:42:06Z","published":"2023-06-13T14:42:06Z","summary":"Bypassing policies imposed by the ImagePolicyWebhook and bypassing mountable secrets policy imposed by the ServiceAccount admission plugin","details":"Users may be able to launch containers using images that are restricted by ImagePolicyWebhook when using ephemeral containers. Kubernetes clusters are only affected if the ImagePolicyWebhook admission plugin is used together with ephemeral containers.\n\n","affected":[{"package":{"ecosystem":"kubernetes","name":"k8s.io/apiserver"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:N"}],"ranges":[{"type":"SEMVER","events":[{"introduced":"1.24.0"},{"last_affected":"1.24.14"},{"introduced":"1.25.0"},{"last_affected":"1.25.10"},{"introduced":"1.26.0"},{"last_affected":"1.26.5"},{"introduced":"1.27.0"},{"last_affected":"1.27.2"}]}]}],"references":[{"type":"ADVISORY","url":"https://github.com/kubernetes/kubernetes/issues/118640"},{"type":"ADVISORY","url":"https://www.cve.org/cverecord?id=CVE-2023-2727, CVE-2023-2728"}]},{"id":"CVE-2023-2728","modified":"2023-06-13T14:42:06Z","published":"2023-06-13T14:42:06Z","summary":"Bypassing policies imposed by the ImagePolicyWebhook and bypassing mountable secrets policy imposed by the ServiceAccount admission plugin","details":"Users may be able to launch containers that bypass the mountable secrets policy enforced by the ServiceAccount admission plugin when using ephemeral containers. The policy ensures pods running with a service account may only reference secrets specified in the service account’s secrets field. Kubernetes clusters are only affected if the ServiceAccount admission plugin and the `kubernetes.io/enforce-mountable-secrets` annotation are used together with ephemeral containers.\n\n","affected":[{"package":{"ecosystem":"kubernetes","name":"k8s.io/apiserver"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:N"}],"ranges":[{"type":"SEMVER","events":[{"introduced":"1.24.0"},{"last_affected":"1.24.14"},{"introduced":"1.25.0"},{"last_affected":"1.25.10"},{"introduced":"1.26.0"},{"last_affected":"1.26.5"},{"introduced":"1.27.0"},{"last_affected":"1.27.2"}]}]}],"references":[{"type":"ADVISORY","url":"https://github.com/kubernetes/kubernetes/issues/118640"},{"type":"ADVISORY","url":"https://www.cve.org/cverecord?id=CVE-2023-2727, CVE-2023-2728"}]},{"id":"CVE-2023-2878","modified":"2023-06-02T19:03:54Z","published":"2023-06-02T19:03:54Z","summary":"secrets-store-csi-driver discloses service account tokens in logs","details":"Kubernetes secrets-store-csi-driver in versions before 1.3.3 discloses service account tokens in logs.\n","affected":[{"package":{"ecosystem":"kubernetes","name":"sigs.k8s.io/secrets-store-csi-driver"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N"}],"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.3.3"}]}]}],"references":[{"type":"ADVISORY","url":"https://github.com/kubernetes/kubernetes/issues/118419"},{"type":"ADVISORY","url":"https://www.cve.org/cverecord?id=CVE-2023-2878"}]},{"id":"CVE-2020-8557","modified":"2020-07-13T18:39:08Z","published":"2020-07-13T18:39:08Z","summary":"Node disk DOS by writing to container /etc/hosts","details":"The Kubernetes kubelet component in versions 1.1-1.16.12, 1.17.0-1.17.8 and 1.18.0-1.18.5 do not account for disk usage by a pod which writes to its own /etc/hosts file. The /etc/hosts file mounted in a pod by kubelet is not included by the kubelet eviction manager when calculating ephemeral storage usage by a pod. If a pod writes a large amount of data to the /etc/hosts file, it could fill the storage space of the node and cause the node to fail.","affected":[{"package":{"ecosystem":"kubernetes","name":"k8s.io/kubelet"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H"}],"ranges":[{"type":"SEMVER","events":[{"introduced":"1.1.0"},{"fixed":"1.16.13"},{"introduced":"1.17.0"},{"fixed":"1.17.9"},{"introduced":"1.18.0"},{"fixed":"1.18.6"}]}]}],"references":[{"type":"ADVISORY","url":"https://github.com/kubernetes/kubernetes/issues/93032"},{"type":"ADVISORY","url":"https://www.cve.org/cverecord?id=CVE-2020-8557"}]},{"id":"CVE-2017-1002102","modified":"2018-03-05T20:55:20Z","published":"2018-03-05T20:55:20Z","summary":"atomic writer volume handling allows arbitrary file deletion in host filesystem","details":"In Kubernetes versions 1.3.x, 1.4.x, 1.5.x, 1.6.x and prior to versions 1.7.14, 1.8.9 and 1.9.4 containers using a secret, configMap, projected or downwardAPI volume can trigger deletion of arbitrary files/directories from the nodes where they are running.","affected":[{"package":{"ecosystem":"kubernetes","name":"k8s.io/kubelet"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:H"}],"ranges":[{"type":"SEMVER","events":[{"introduced":"1.3.0"},{"fixed":"1.7.14"},{"introduced":"1.8.0"},{"fixed":"1.8.9"},{"introduced":"1.9.0"},{"fixed":"1.9.4"}]}]}],"references":[{"type":"ADVISORY","url":"https://github.com/kubernetes/kubernetes/issues/60814"},{"type":"ADVISORY","url":"https://www.cve.org/cverecord?id=CVE-2017-1002102"}]}] \ No newline at end of file +{"id":"CVE-2023-2431","modified":"2023-06-15T14:42:32Z","published":"2023-06-15T14:42:32Z","summary":"Bypass of seccomp profile enforcement ","details":"A security issue was discovered in Kubelet that allows pods to bypass the seccomp profile enforcement. Pods that use localhost type for seccomp profile but specify an empty profile field, are affected by this issue. In this scenario, this vulnerability allows the pod to run in unconfined (seccomp disabled) mode. This bug affects Kubelet.","affected":[{"package":{"ecosystem":"kubernetes","name":"k8s.io/kubelet"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:L/I:L/A:N"}],"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.24.14"},{"introduced":"1.25.0"},{"fixed":"1.25.9"},{"introduced":"1.26.0"},{"fixed":"1.26.4"},{"introduced":"1.27.0"},{"fixed":"1.27.1"}]}]}],"references":[{"type":"ADVISORY","url":"https://github.com/kubernetes/kubernetes/issues/118690"},{"type":"ADVISORY","url":"https://www.cve.org/cverecord?id=CVE-2023-2431"}]} diff --git a/vulns/CVE-2025-15566.json b/vulns/CVE-2025-15566.json index dd58267..debb9f1 100644 --- a/vulns/CVE-2025-15566.json +++ b/vulns/CVE-2025-15566.json @@ -27,7 +27,7 @@ "fixed": "1.12.5" }, { - "introduced": "0" + "introduced": "1.13.0" }, { "fixed": "1.13.1"