Ubuntu Kernel Vulns Part 2: Matching (#19303)

This commit is contained in:
Tim Lee
2024-05-29 06:59:12 -06:00
committed by GitHub
parent 9ec92a601f
commit d1db2e3650
10 changed files with 454 additions and 0 deletions
@@ -0,0 +1 @@
- fleet now detects Ubuntu kernel vulnerabilities from the Canonical OVAL feed
+6
View File
@@ -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)
@@ -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
}
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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"},
})
}
@@ -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
}
@@ -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)
})
}
}