From d1db2e3650775eb1d2812a605e8a0dff4a337603 Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Wed, 29 May 2024 06:59:12 -0600 Subject: [PATCH] Ubuntu Kernel Vulns Part 2: Matching (#19303) --- changes/18053-ubuntu-kernel-vuln-detection | 1 + server/vulnerabilities/oval/analyzer.go | 6 + .../vulnerabilities/oval/parsed/definition.go | 96 ++++++++++ .../oval/parsed/definition_test.go | 180 ++++++++++++++++++ server/vulnerabilities/oval/parsed/result.go | 4 + .../oval/parsed/rhel_result.go | 5 + .../oval/parsed/ubuntu_result.go | 45 +++++ .../oval/parsed/ubuntu_result_test.go | 46 +++++ .../oval/parsed/unix_unameTest.go | 33 ++++ .../oval/parsed/unix_unameTest_test.go | 38 ++++ 10 files changed, 454 insertions(+) create mode 100644 changes/18053-ubuntu-kernel-vuln-detection create mode 100644 server/vulnerabilities/oval/parsed/ubuntu_result_test.go create mode 100644 server/vulnerabilities/oval/parsed/unix_unameTest_test.go diff --git a/changes/18053-ubuntu-kernel-vuln-detection b/changes/18053-ubuntu-kernel-vuln-detection new file mode 100644 index 0000000000..79d0bf7b5a --- /dev/null +++ b/changes/18053-ubuntu-kernel-vuln-detection @@ -0,0 +1 @@ +- fleet now detects Ubuntu kernel vulnerabilities from the Canonical OVAL feed \ No newline at end of file diff --git a/server/vulnerabilities/oval/analyzer.go b/server/vulnerabilities/oval/analyzer.go index 17900e426f..3a79b63e7b 100644 --- a/server/vulnerabilities/oval/analyzer.go +++ b/server/vulnerabilities/oval/analyzer.go @@ -72,6 +72,12 @@ func Analyze( return nil, err } foundInBatch[hostID] = evalR + + evalU, err := defs.EvalKernel(software) + if err != nil { + return nil, err + } + foundInBatch[hostID] = append(foundInBatch[hostID], evalU...) } existingInBatch, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, hostIDs, source) diff --git a/server/vulnerabilities/oval/parsed/definition.go b/server/vulnerabilities/oval/parsed/definition.go index c3e217a6c5..6d64eb95a5 100644 --- a/server/vulnerabilities/oval/parsed/definition.go +++ b/server/vulnerabilities/oval/parsed/definition.go @@ -102,3 +102,99 @@ func (d Definition) CveVulnerabilities() []string { } return r } + +// intersect returns the intersection of two slices of uints. +func intersect(a, b []uint) []uint { + m := make(map[uint]bool) + for _, v := range a { + m[v] = true + } + + var r []uint + for _, v := range b { + if m[v] { + r = append(r, v) + } + } + return r +} + +// unionAll returns the union of two slices of uints without duplicates. +func unionAll(a, b []uint) []uint { + m := make(map[uint]bool) + var result []uint + + for _, v := range a { + if !m[v] { + m[v] = true + result = append(result, v) + } + } + + for _, v := range b { + if !m[v] { + m[v] = true + result = append(result, v) + } + } + + return result +} + +// findMatchingSoftware returns the software IDs that match the given OVAL criteria. +func findMatchingSoftware(c Criteria, uTests map[int][]uint) []uint { + switch c.Operator { + case And: + return findAndMatch(c, uTests) + case Or: + return findOrMatch(c, uTests) + } + return nil +} + +// findAndMatch finds the software that matches all the criteria using the AND operator +func findAndMatch(c Criteria, uTests map[int][]uint) []uint { + if c.Criteriums != nil { + return intersectSoftware(c.Criteriums, uTests) + } + + matchingSoftware := make([]uint, 0) + for _, subCriteria := range c.Criterias { + subMatchingSoftware := findMatchingSoftware(*subCriteria, uTests) + if len(matchingSoftware) == 0 { + matchingSoftware = subMatchingSoftware + } else { + matchingSoftware = intersect(matchingSoftware, subMatchingSoftware) + } + } + return matchingSoftware +} + +// intersectSoftware returns the intersection of the software IDs for the given criteria. +func intersectSoftware(criteriums []int, uTests map[int][]uint) []uint { + if len(criteriums) == 0 { + return nil + } + + softwareSets := make([][]uint, 0, len(criteriums)) + for _, c := range criteriums { + softwareSets = append(softwareSets, uTests[c]) + } + + intersected := softwareSets[0] + for _, s := range softwareSets[1:] { + intersected = intersect(intersected, s) + } + + return intersected +} + +// findOrMatch finds the software that matches any of the criteria using the OR operator +func findOrMatch(c Criteria, uTests map[int][]uint) []uint { + matchingSoftware := make([]uint, 0) + for _, subCriteria := range c.Criterias { + subMatchingSoftware := findMatchingSoftware(*subCriteria, uTests) + matchingSoftware = unionAll(matchingSoftware, subMatchingSoftware) + } + return matchingSoftware +} diff --git a/server/vulnerabilities/oval/parsed/definition_test.go b/server/vulnerabilities/oval/parsed/definition_test.go index 4abd20c52b..ee71fe12ee 100644 --- a/server/vulnerabilities/oval/parsed/definition_test.go +++ b/server/vulnerabilities/oval/parsed/definition_test.go @@ -289,3 +289,183 @@ func TestOvalParsedDefinition(t *testing.T) { }) }) } + +func TestIntersect(t *testing.T) { + a := []uint{1, 2, 3, 4} + b := []uint{3, 4, 5, 6} + expected := []uint{3, 4} + result := intersect(a, b) + require.ElementsMatch(t, expected, result) +} + +func TestUnion(t *testing.T) { + a := []uint{1, 2, 3, 4} + b := []uint{3, 4, 5, 6} + expected := []uint{1, 2, 3, 4, 5, 6} + result := unionAll(a, b) + require.ElementsMatch(t, expected, result) + + // a has duplicates + a = []uint{1, 2, 3, 4, 4} + expected = []uint{1, 2, 3, 4, 5, 6} + result = unionAll(a, b) + require.ElementsMatch(t, expected, result) + + // b has duplicates + a = []uint{1, 2, 3, 4} + b = []uint{3, 4, 5, 6, 6} + expected = []uint{1, 2, 3, 4, 5, 6} + result = unionAll(a, b) + require.ElementsMatch(t, expected, result) +} + +func TestFindAndMatch(t *testing.T) { + // map of tests to softwareIDs + criterionToSoftware := map[int][]uint{ + 100: {1, 2, 3}, + 200: {3, 4, 5}, + 300: {5, 6, 7}, + } + + for _, tc := range []struct { + criteria Criteria + expected []uint + }{ + { + // Criteria: 100 AND 200 must match + criteria: Criteria{ + Operator: And, + Criteriums: []int{100, 200}, + }, + expected: []uint{3}, + }, + { + // Criteria: 100 and 200 and 300 must match + criteria: Criteria{ + Operator: And, + Criteriums: []int{100, 200, 300}, + }, + expected: []uint{}, + }, + { + // Criteria: 100 must match + criteria: Criteria{ + Operator: And, + Criteriums: []int{100}, + }, + expected: []uint{1, 2, 3}, + }, + } { + result := findAndMatch(tc.criteria, criterionToSoftware) + require.ElementsMatch(t, tc.expected, result) + } +} + +func TestFindOrMatch(t *testing.T) { + // map of tests to softwareIDs + criterionToSoftware := map[int][]uint{ + 100: {1, 2, 3}, + 200: {3, 4, 5}, + 300: {5, 6, 7}, + } + + for _, tc := range []struct { + criteria Criteria + expected []uint + }{ + { + // Criteria: 100 OR 200 must match + criteria: Criteria{ + Operator: Or, + Criteriums: nil, + Criterias: []*Criteria{ + { + Operator: And, + Criteriums: []int{100}, + }, + { + Operator: And, + Criteriums: []int{200}, + }, + }, + }, + expected: []uint{1, 2, 3, 4, 5}, + }, + { + // Criteria: 100 OR 200 OR 300 must match + criteria: Criteria{ + Operator: Or, + Criteriums: nil, + Criterias: []*Criteria{ + { + Operator: And, + Criteriums: []int{100}, + Criterias: nil, + }, + { + Operator: And, + Criteriums: []int{200}, + Criterias: nil, + }, + { + Operator: And, + Criteriums: []int{300}, + Criterias: nil, + }, + }, + }, + expected: []uint{1, 2, 3, 4, 5, 6, 7}, + }, + { + // Criteria: 100 OR 200 OR 300 must match + criteria: Criteria{ + Operator: Or, + Criteriums: nil, + Criterias: []*Criteria{ + { + Operator: And, + Criteriums: []int{100}, + Criterias: nil, + }, + }, + }, + expected: []uint{1, 2, 3}, + }, + } { + result := findOrMatch(tc.criteria, criterionToSoftware) + require.ElementsMatch(t, tc.expected, result) + } +} + +func TestFindMatchingSoftware(t *testing.T) { + criterionToSoftware := map[int][]uint{ + 100: {1, 2, 3}, + 200: {3, 4, 5}, + 300: {5, 6, 7}, + 400: {7, 8, 9}, + } + + criteria := Criteria{ + Operator: And, + Criteriums: nil, + Criterias: []*Criteria{ + { + Operator: Or, + Criteriums: nil, + Criterias: []*Criteria{ + { + Operator: And, + Criteriums: []int{100, 200}, + }, + { + Operator: And, + Criteriums: []int{300, 400}, + }, + }, + }, + }, + } + + matchingSoftware := findMatchingSoftware(criteria, criterionToSoftware) + require.ElementsMatch(t, []uint{3, 7}, matchingSoftware) +} diff --git a/server/vulnerabilities/oval/parsed/result.go b/server/vulnerabilities/oval/parsed/result.go index aa408b3b66..c32e13164e 100644 --- a/server/vulnerabilities/oval/parsed/result.go +++ b/server/vulnerabilities/oval/parsed/result.go @@ -6,4 +6,8 @@ type Result interface { // Eval evaluates the current OVAL definition against an OS version and a list of installed software, returns all software // vulnerabilities found. Eval(fleet.OSVersion, []fleet.Software) ([]fleet.SoftwareVulnerability, error) + + // EvalKernel evaluates the current OVAL definition against a list of installed kernel-image software, + // returns all kernel-image vulnerabilities found. Currently only used for Ubuntu. + EvalKernel([]fleet.Software) ([]fleet.SoftwareVulnerability, error) } diff --git a/server/vulnerabilities/oval/parsed/rhel_result.go b/server/vulnerabilities/oval/parsed/rhel_result.go index 06d986a2a9..c515519f44 100644 --- a/server/vulnerabilities/oval/parsed/rhel_result.go +++ b/server/vulnerabilities/oval/parsed/rhel_result.go @@ -59,3 +59,8 @@ func (r RhelResult) Eval(ver fleet.OSVersion, software []fleet.Software) ([]flee return vuln, nil } + +// EvalUname is not implemented for Rhel based distros +func (r RhelResult) EvalKernel(software []fleet.Software) ([]fleet.SoftwareVulnerability, error) { + return nil, nil +} diff --git a/server/vulnerabilities/oval/parsed/ubuntu_result.go b/server/vulnerabilities/oval/parsed/ubuntu_result.go index b7169dc7b0..0b9a806ca2 100644 --- a/server/vulnerabilities/oval/parsed/ubuntu_result.go +++ b/server/vulnerabilities/oval/parsed/ubuntu_result.go @@ -1,6 +1,10 @@ package oval_parsed import ( + "fmt" + "regexp" + "strings" + "github.com/fleetdm/fleet/v4/server/fleet" ) @@ -67,3 +71,44 @@ func (r UbuntuResult) Eval(ver fleet.OSVersion, software []fleet.Software) ([]fl return vuln, nil } + +var kernelImageRegex = regexp.MustCompile(`^linux-image-(\d+\.\d+\.\d+-\d+)-\w+`) + +func (r UbuntuResult) EvalKernel(software []fleet.Software) ([]fleet.SoftwareVulnerability, error) { + // Test Id => Matching software IDs + uTests := make(map[int][]uint) + for _, s := range software { + if kernelImageRegex.MatchString(s.Name) { + v, ok := strings.CutPrefix(s.Name, "linux-image-") + if !ok { + return nil, fmt.Errorf("linux kernel package %s does not match expected format:", s.Name) + } + + for i, u := range r.UnameTests { + isMatch, err := u.Eval(v) + if err != nil { + return nil, err + } + + if isMatch { + uTests[i] = append(uTests[i], s.ID) + } + } + } + } + + vuln := make([]fleet.SoftwareVulnerability, 0) + for _, d := range r.Definitions { + swIDs := findMatchingSoftware(*d.Criteria, uTests) + for _, v := range d.CveVulnerabilities() { + for _, swID := range swIDs { + vuln = append(vuln, fleet.SoftwareVulnerability{ + SoftwareID: swID, + CVE: v, + }) + } + } + } + + return vuln, nil +} diff --git a/server/vulnerabilities/oval/parsed/ubuntu_result_test.go b/server/vulnerabilities/oval/parsed/ubuntu_result_test.go new file mode 100644 index 0000000000..5b6fecb525 --- /dev/null +++ b/server/vulnerabilities/oval/parsed/ubuntu_result_test.go @@ -0,0 +1,46 @@ +package oval_parsed + +import ( + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +func TestEvalKernel(t *testing.T) { + r := NewUbuntuResult() + r.AddDefinition(Definition{ + Criteria: &Criteria{ + Operator: And, + Criteriums: []int{100, 200}, + }, + Vulnerabilities: []string{"CVE-2019-1234"}, + }) + r.AddUnameTest(100, &UnixUnameTest{ + States: []ObjectStateString{ + NewObjectStateString("less than", "0:5.15.0-1004"), + }, + }) + r.AddUnameTest(200, &UnixUnameTest{ + States: []ObjectStateString{ + NewObjectStateString("pattern match", `5.15.0-\d+(-generic|-generic-64k|-generic-lpae|-lowlatency|-lowlatency-64k)`), + }, + }) + + software := []fleet.Software{ + {ID: 1, Name: "linux-image-5.15.0-1003-generic"}, + {ID: 2, Name: "linux-image-5.15.0-1004-generic"}, + {ID: 3, Name: "linux-image-5.15.0-1005-generic"}, + {ID: 4, Name: "linux-image-5.15.0-1003-lowlatency"}, + {ID: 5, Name: "linux-image-5.15.0-1004-foo"}, + {ID: 6, Name: "linux-image-4.0.0-10-generic"}, + {ID: 7, Name: "linux-image-6.0.0-10-generic"}, + } + + vuln, err := r.EvalKernel(software) + require.NoError(t, err) + require.ElementsMatch(t, vuln, []fleet.SoftwareVulnerability{ + {SoftwareID: 1, CVE: "CVE-2019-1234"}, + {SoftwareID: 4, CVE: "CVE-2019-1234"}, + }) +} diff --git a/server/vulnerabilities/oval/parsed/unix_unameTest.go b/server/vulnerabilities/oval/parsed/unix_unameTest.go index 3a7d358466..645c5e179f 100644 --- a/server/vulnerabilities/oval/parsed/unix_unameTest.go +++ b/server/vulnerabilities/oval/parsed/unix_unameTest.go @@ -1,5 +1,38 @@ package oval_parsed +import ( + "fmt" + "regexp" + + "github.com/fleetdm/fleet/v4/server/vulnerabilities/utils" +) + type UnixUnameTest struct { States []ObjectStateString } + +// Eval evaluates a kernel version against a UnameTest. Returns true +// if the kernel version matches the test. Currently only used for Ubuntu. +func (t UnixUnameTest) Eval(version string) (bool, error) { + for _, s := range t.States { + op, val := s.unpack() + switch op { + case LessThan: + if utils.Rpmvercmp(version, val) != -1 { + return false, nil + } + case PatternMatch: + match, err := regexp.Compile(val) + if err != nil { + return false, err + } + if !match.MatchString(version) { + return false, nil + } + default: + return false, fmt.Errorf("operation %q not supported for uname test", op) + } + } + + return true, nil +} diff --git a/server/vulnerabilities/oval/parsed/unix_unameTest_test.go b/server/vulnerabilities/oval/parsed/unix_unameTest_test.go new file mode 100644 index 0000000000..44ffdde02e --- /dev/null +++ b/server/vulnerabilities/oval/parsed/unix_unameTest_test.go @@ -0,0 +1,38 @@ +package oval_parsed + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEval(t *testing.T) { + utest := UnixUnameTest{ + States: []ObjectStateString{ + NewObjectStateString("less than", "0:5.15.0-1004"), + NewObjectStateString("pattern match", `5.15.0-\d+(-generic|-generic-64k|-generic-lpae|-lowlatency|-lowlatency-64k)`), + }, + } + + testCases := []struct { + Name string + Input string + Expected bool + }{ + {Name: "less than", Input: "5.15.0-1003-generic", Expected: true}, + {Name: "greater than", Input: "5.15.0-1005-generic", Expected: false}, + {Name: "equal", Input: "5.15.0-1004-generic", Expected: false}, + {Name: "alt pattern match", Input: "5.15.0-1003-lowlatency", Expected: true}, + {Name: "suffix doesn't match", Input: "5.15.0-1004-foo", Expected: false}, + {Name: "lower version fails pattern match", Input: "4.0.0-10-generic", Expected: false}, + {Name: "higher version fails pattern match", Input: "6.0.0-10-generic", Expected: false}, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + matches, err := utest.Eval(tc.Input) + require.NoError(t, err) + require.Equal(t, tc.Expected, matches) + }) + } +}