Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 80 additions & 15 deletions collector/k8s.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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",
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
138 changes: 131 additions & 7 deletions collector/k8s_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion collector/testdata/expected-vulndb.json
Original file line number Diff line number Diff line change
@@ -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"}]}]
{"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"}]}
2 changes: 1 addition & 1 deletion vulns/CVE-2025-15566.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"fixed": "1.12.5"
},
{
"introduced": "0"
"introduced": "1.13.0"
},
{
"fixed": "1.13.1"
Expand Down