<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #34500 and Resolves #33758 Video demo: https://www.youtube.com/watch?v=4HZlKG0G1B0 - Added a new aggregation table `operating_system_version_vulnerabilities` for faster queries. The table is currently used only for Linux vulnerabilities, but could be used for other OS vulnerabilities. - Added `max_vulnerabilities` parameter per [API doc](https://github.com/fleetdm/fleet/pull/33533) - Also added `max_vulnerabilities` parameter to `os_versions/{id}` endpoint, but not making it public since that endpoint is still slow and needs other API changes. bug #34974 - Removed `"kernels": []` from `os_versions` endpoint result # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added ability to limit the number of vulnerabilities displayed for operating system versions via an optional parameter. * Introduced vulnerability count tracking for operating system versions, now visible in API responses and UI displays. * Enhanced operating system vulnerability visualization with improved count-based rendering. * **Tests** * Added comprehensive test coverage for vulnerability limiting behavior across multiple operating system versions and architectures. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
862 lines
30 KiB
Go
862 lines
30 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestListVulnsByMultipleOSVersions(t *testing.T) {
|
|
ds := CreateMySQLDS(t)
|
|
|
|
cases := []struct {
|
|
name string
|
|
fn func(t *testing.T, ds *Datastore)
|
|
}{
|
|
{"BatchQuery", testListVulnsByMultipleOSVersionsBatchQuery},
|
|
{"EmptyInput", testListVulnsByMultipleOSVersionsEmptyInput},
|
|
{"WithCVSS", testListVulnsByMultipleOSVersionsWithCVSS},
|
|
{"WithTeamFilter", testListVulnsByMultipleOSVersionsWithTeamFilter},
|
|
{"NonExistentOS", testListVulnsByMultipleOSVersionsNonExistentOS},
|
|
{"MixedPlatforms", testListVulnsByMultipleOSVersionsMixedPlatforms},
|
|
{"MultipleLinuxOSWithManyKernels", testListVulnsByMultipleLinuxOSWithManyKernels},
|
|
{"WithMaxVulnerabilities", testListVulnsByMultipleOSVersionsWithMaxVulnerabilities},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
defer TruncateTables(t, ds)
|
|
c.fn(t, ds)
|
|
})
|
|
}
|
|
}
|
|
|
|
func testListVulnsByMultipleOSVersionsBatchQuery(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
// Create test OS versions
|
|
osVersions := setupTestOSVersionsWithVulns(t, ds, ctx)
|
|
|
|
vulnsMap, err := ds.ListVulnsByMultipleOSVersions(ctx, osVersions, false, nil, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, vulnsMap)
|
|
|
|
// Check we got results for all OS versions
|
|
assert.Len(t, vulnsMap, 3)
|
|
|
|
for i, osV := range osVersions {
|
|
key := osV.NameOnly + "-" + osV.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have vulnerabilities for %s", key)
|
|
assert.Len(t, vulnData.Vulnerabilities, 2, "Should have 2 vulnerabilities for %s", key)
|
|
assert.Equal(t, 2, vulnData.Count, "Count should match vulnerabilities length")
|
|
|
|
// Check CVE values
|
|
expectedCVE1 := "CVE-2024-000" + string(rune('1'+i))
|
|
expectedCVE2 := "CVE-2024-100" + string(rune('1'+i))
|
|
|
|
cves := []string{vulnData.Vulnerabilities[0].CVE, vulnData.Vulnerabilities[1].CVE}
|
|
assert.Contains(t, cves, expectedCVE1)
|
|
assert.Contains(t, cves, expectedCVE2)
|
|
}
|
|
}
|
|
|
|
func testListVulnsByMultipleOSVersionsEmptyInput(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
vulnsMap, err := ds.ListVulnsByMultipleOSVersions(ctx, []fleet.OSVersion{}, false, nil, nil)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, vulnsMap)
|
|
}
|
|
|
|
func testListVulnsByMultipleOSVersionsWithCVSS(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
// Create test OS versions
|
|
osVersions := setupTestOSVersionsWithVulns(t, ds, ctx)
|
|
|
|
// Add CVE metadata for one vulnerability
|
|
_, err := ds.writer(ctx).ExecContext(ctx, `
|
|
INSERT INTO cve_meta (cve, cvss_score, epss_probability, cisa_known_exploit, published, description)
|
|
VALUES ('CVE-2024-0001', 7.5, 0.05, 0, NOW(), 'Test vulnerability')
|
|
ON DUPLICATE KEY UPDATE cvss_score = VALUES(cvss_score)
|
|
`)
|
|
require.NoError(t, err)
|
|
|
|
vulnsMap, err := ds.ListVulnsByMultipleOSVersions(ctx, osVersions[:1], true, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
key := osVersions[0].NameOnly + "-" + osVersions[0].Version
|
|
vulnData := vulnsMap[key]
|
|
require.NotEmpty(t, vulnData.Vulnerabilities)
|
|
|
|
// Find the vulnerability with metadata
|
|
foundMetadata := false
|
|
for _, vuln := range vulnData.Vulnerabilities {
|
|
if vuln.CVE == "CVE-2024-0001" {
|
|
foundMetadata = true
|
|
assert.NotNil(t, vuln.CVSSScore)
|
|
if vuln.CVSSScore != nil {
|
|
assert.Equal(t, 7.5, **vuln.CVSSScore)
|
|
}
|
|
assert.NotNil(t, vuln.EPSSProbability)
|
|
if vuln.EPSSProbability != nil {
|
|
assert.InDelta(t, 0.05, **vuln.EPSSProbability, 0.001)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, foundMetadata, "Should find vulnerability with CVSS metadata")
|
|
}
|
|
|
|
func testListVulnsByMultipleOSVersionsWithTeamFilter(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
// Create a Linux OS version for testing kernel vulnerabilities
|
|
linuxOS := setupLinuxOSWithKernelVulns(t, ds, ctx)
|
|
|
|
teamID := uint(1)
|
|
|
|
// Test with team filter
|
|
vulnsMap, err := ds.ListVulnsByMultipleOSVersions(ctx, []fleet.OSVersion{linuxOS}, false, &teamID, nil)
|
|
require.NoError(t, err)
|
|
|
|
key := linuxOS.NameOnly + "-" + linuxOS.Version
|
|
vulnData := vulnsMap[key]
|
|
|
|
// Should have kernel vulnerabilities for the team
|
|
assert.GreaterOrEqual(t, len(vulnData.Vulnerabilities), 1)
|
|
}
|
|
|
|
func testListVulnsByMultipleOSVersionsNonExistentOS(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
// Create OS versions that don't exist in the database
|
|
// Don't set ID so it will be looked up by name/version
|
|
nonExistentOS := []fleet.OSVersion{
|
|
{
|
|
NameOnly: "NonExistent",
|
|
Version: "1.0.0",
|
|
Platform: "unknown",
|
|
},
|
|
}
|
|
|
|
vulnsMap, err := ds.ListVulnsByMultipleOSVersions(ctx, nonExistentOS, false, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Should return empty map for non-existent OS
|
|
assert.Empty(t, vulnsMap)
|
|
}
|
|
|
|
func testListVulnsByMultipleOSVersionsMixedPlatforms(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
// Create OS versions for different platforms
|
|
osVersions := []fleet.OSVersion{}
|
|
platforms := []string{"linux", "darwin", "windows"}
|
|
|
|
for i, platform := range platforms {
|
|
osName := fmt.Sprintf("OS_%s", platform)
|
|
osVersion := fmt.Sprintf("1.0.%d", i)
|
|
|
|
os := fleet.OperatingSystem{
|
|
Name: osName,
|
|
Version: osVersion,
|
|
Platform: platform,
|
|
KernelVersion: "test-kernel",
|
|
}
|
|
|
|
err := ds.UpdateHostOperatingSystem(ctx, uint(100+i), os) //nolint:gosec
|
|
require.NoError(t, err)
|
|
|
|
// Get the created OS
|
|
stmt := `SELECT id, os_version_id FROM operating_systems WHERE name = ? AND version = ? LIMIT 1`
|
|
var result struct {
|
|
ID uint `db:"id"`
|
|
OSVersionID uint `db:"os_version_id"`
|
|
}
|
|
err = sqlx.GetContext(ctx, ds.writer(ctx), &result, stmt, osName, osVersion)
|
|
require.NoError(t, err)
|
|
|
|
osVersionObj := fleet.OSVersion{
|
|
ID: result.ID,
|
|
OSVersionID: result.OSVersionID,
|
|
NameOnly: osName,
|
|
Version: osVersion,
|
|
Platform: platform,
|
|
}
|
|
osVersions = append(osVersions, osVersionObj)
|
|
|
|
// Add vulnerabilities based on platform type
|
|
if platform == "linux" {
|
|
// For Linux, add kernel vulnerabilities
|
|
setupKernelVulnsWithTeam(t, ds, ctx, osVersionObj)
|
|
} else {
|
|
// For non-Linux, add OS vulnerabilities to operating_system_vulnerabilities table
|
|
vulns := []fleet.OSVulnerability{
|
|
{
|
|
OSID: result.ID,
|
|
CVE: fmt.Sprintf("CVE-2024-PLAT-%d", i),
|
|
ResolvedInVersion: ptr.String(osVersion + ".1"),
|
|
},
|
|
}
|
|
_, err = ds.InsertOSVulnerabilities(ctx, vulns, fleet.UbuntuOVALSource)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
// Test batch query with mixed platforms
|
|
vulnsMap, err := ds.ListVulnsByMultipleOSVersions(ctx, osVersions, false, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Should get results for all platforms
|
|
assert.Len(t, vulnsMap, 3)
|
|
|
|
for i, osV := range osVersions {
|
|
key := osV.NameOnly + "-" + osV.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have key in map for %s", key)
|
|
|
|
// All platforms should have vulnerabilities
|
|
assert.GreaterOrEqual(t, len(vulnData.Vulnerabilities), 1, "Platform %s should have at least 1 vulnerability", osV.Platform)
|
|
|
|
if osV.Platform == "linux" {
|
|
// Linux should have kernel vulnerability
|
|
foundKernelCVE := false
|
|
for _, vuln := range vulnData.Vulnerabilities {
|
|
if vuln.CVE == "CVE-2024-KERNEL-001" {
|
|
foundKernelCVE = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, foundKernelCVE, "Linux should have kernel vulnerability CVE-2024-KERNEL-001")
|
|
} else {
|
|
// Non-Linux should have OS vulnerability
|
|
assert.Len(t, vulnData.Vulnerabilities, 1, "Non-Linux platforms should have 1 vulnerability")
|
|
assert.Equal(t, fmt.Sprintf("CVE-2024-PLAT-%d", i), vulnData.Vulnerabilities[0].CVE)
|
|
}
|
|
}
|
|
|
|
// Test with maxVulnerabilities
|
|
maxVulns := 1
|
|
vulnsMapWithMax, err := ds.ListVulnsByMultipleOSVersions(ctx, osVersions, false, nil, &maxVulns)
|
|
require.NoError(t, err)
|
|
|
|
for i, osV := range osVersions {
|
|
key := osV.NameOnly + "-" + osV.Version
|
|
vulnData, ok := vulnsMapWithMax[key]
|
|
assert.True(t, ok, "Should have key in map for %s", key)
|
|
|
|
if osV.Platform == "linux" {
|
|
// Linux should have count from kernel vulnerabilities
|
|
assert.Greater(t, vulnData.Count, 0, "Linux platform count should be > 0 with maxVulnerabilities")
|
|
} else {
|
|
// Non-Linux should also have correct count, not 0
|
|
assert.Equal(t, 1, vulnData.Count, "Non-Linux platform %s should have count=1", osV.Platform)
|
|
assert.Len(t, vulnData.Vulnerabilities, 1, "Non-Linux should have 1 limited vulnerability")
|
|
assert.Equal(t, fmt.Sprintf("CVE-2024-PLAT-%d", i), vulnData.Vulnerabilities[0].CVE)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to set up test OS versions with vulnerabilities
|
|
// Uses non-Linux platforms since operating_system_vulnerabilities table doesn't contain Linux vulns
|
|
func setupTestOSVersionsWithVulns(t *testing.T, ds *Datastore, ctx context.Context) []fleet.OSVersion {
|
|
var osVersions []fleet.OSVersion
|
|
|
|
// Use non-Linux platforms for testing operating_system_vulnerabilities
|
|
platforms := []string{"darwin", "windows", "chrome"}
|
|
osNames := []string{"macOS", "Microsoft Windows 11 Enterprise", "Google Chrome OS"}
|
|
|
|
for i := 0; i < 3; i++ {
|
|
osName := osNames[i]
|
|
osVersion := "1.0." + string(rune('0'+i))
|
|
|
|
os := fleet.OperatingSystem{
|
|
Name: osName,
|
|
Version: osVersion,
|
|
Platform: platforms[i],
|
|
KernelVersion: "test-kernel",
|
|
}
|
|
|
|
err := ds.UpdateHostOperatingSystem(ctx, uint(i+1), os) //nolint:gosec
|
|
require.NoError(t, err)
|
|
|
|
// Get the created OS
|
|
stmt := `SELECT id, os_version_id FROM operating_systems WHERE name = ? AND version = ? LIMIT 1`
|
|
var osID uint
|
|
var osVersionID uint
|
|
err = ds.writer(ctx).QueryRowContext(ctx, stmt, osName, osVersion).Scan(&osID, &osVersionID)
|
|
require.NoError(t, err)
|
|
|
|
osVersionObj := fleet.OSVersion{
|
|
ID: osID,
|
|
OSVersionID: osVersionID,
|
|
NameOnly: osName,
|
|
Version: osVersion,
|
|
Platform: platforms[i],
|
|
}
|
|
osVersions = append(osVersions, osVersionObj)
|
|
|
|
// Add vulnerabilities for this OS
|
|
vulns := []fleet.OSVulnerability{
|
|
{
|
|
OSID: osID,
|
|
CVE: "CVE-2024-000" + string(rune('1'+i)),
|
|
ResolvedInVersion: ptr.String(osVersion + ".1"),
|
|
},
|
|
{
|
|
OSID: osID,
|
|
CVE: "CVE-2024-100" + string(rune('1'+i)),
|
|
ResolvedInVersion: nil,
|
|
},
|
|
}
|
|
_, err = ds.InsertOSVulnerabilities(ctx, vulns, fleet.UbuntuOVALSource)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
return osVersions
|
|
}
|
|
|
|
// Helper function to set up a Linux OS with kernel vulnerabilities
|
|
func setupLinuxOSWithKernelVulns(t *testing.T, ds *Datastore, ctx context.Context) fleet.OSVersion {
|
|
osName := "Ubuntu"
|
|
osVersion := "22.04.0"
|
|
|
|
os := fleet.OperatingSystem{
|
|
Name: osName,
|
|
Version: osVersion,
|
|
Platform: "linux",
|
|
KernelVersion: "5.15.0-generic",
|
|
}
|
|
|
|
err := ds.UpdateHostOperatingSystem(ctx, 1, os)
|
|
require.NoError(t, err)
|
|
|
|
// Get the created OS
|
|
stmt := `SELECT id, os_version_id FROM operating_systems WHERE name = ? AND version = ? LIMIT 1`
|
|
var osID uint
|
|
var osVersionID uint
|
|
err = ds.writer(ctx).QueryRowContext(ctx, stmt, osName, osVersion).Scan(&osID, &osVersionID)
|
|
require.NoError(t, err)
|
|
|
|
osVersionObj := fleet.OSVersion{
|
|
ID: osID,
|
|
OSVersionID: osVersionID,
|
|
NameOnly: osName,
|
|
Version: osVersion,
|
|
Platform: "linux",
|
|
}
|
|
|
|
// Set up kernel vulnerabilities
|
|
setupKernelVulnsWithTeam(t, ds, ctx, osVersionObj)
|
|
|
|
return osVersionObj
|
|
}
|
|
|
|
// Helper function to set up kernel vulnerabilities with team-specific data
|
|
func setupKernelVulnsWithTeam(t *testing.T, ds *Datastore, ctx context.Context, osVersion fleet.OSVersion) {
|
|
// Create a software title and software entry
|
|
_, err := ds.writer(ctx).ExecContext(ctx, `
|
|
INSERT INTO software_titles (name, source, is_kernel)
|
|
VALUES ('linux-kernel', 'deb_packages', TRUE)
|
|
ON DUPLICATE KEY UPDATE is_kernel = TRUE
|
|
`)
|
|
require.NoError(t, err)
|
|
|
|
var titleID uint
|
|
err = sqlx.GetContext(ctx, ds.writer(ctx), &titleID,
|
|
`SELECT id FROM software_titles WHERE name = 'linux-kernel'`)
|
|
require.NoError(t, err)
|
|
|
|
_, err = ds.writer(ctx).ExecContext(ctx, `
|
|
INSERT INTO software (name, version, source, title_id, checksum)
|
|
VALUES ('linux-kernel', '5.15.0', 'deb_packages', ?, 'test-checksum')
|
|
ON DUPLICATE KEY UPDATE id = id
|
|
`, titleID)
|
|
require.NoError(t, err)
|
|
|
|
var softwareID uint
|
|
err = sqlx.GetContext(ctx, ds.writer(ctx), &softwareID,
|
|
`SELECT id FROM software WHERE name = 'linux-kernel' AND version = '5.15.0'`)
|
|
require.NoError(t, err)
|
|
|
|
// Add kernel CVE
|
|
_, err = ds.writer(ctx).ExecContext(ctx, `
|
|
INSERT INTO software_cve (software_id, cve, resolved_in_version)
|
|
VALUES (?, 'CVE-2024-KERNEL-001', '5.15.1')
|
|
ON DUPLICATE KEY UPDATE resolved_in_version = VALUES(resolved_in_version)
|
|
`, softwareID)
|
|
require.NoError(t, err)
|
|
|
|
// Add kernel_host_counts for team 1
|
|
_, err = ds.writer(ctx).ExecContext(ctx, `
|
|
INSERT INTO kernel_host_counts (software_title_id, software_id, os_version_id, hosts_count, team_id)
|
|
VALUES (?, ?, ?, 100, 1)
|
|
ON DUPLICATE KEY UPDATE hosts_count = VALUES(hosts_count)
|
|
`, titleID, softwareID, osVersion.OSVersionID)
|
|
require.NoError(t, err)
|
|
|
|
// Refresh the pre-aggregated table
|
|
err = ds.refreshOSVersionVulnerabilities(ctx)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Test comprehensive scenario: multiple Linux OS versions with multiple kernels and shared vulnerabilities
|
|
func testListVulnsByMultipleLinuxOSWithManyKernels(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
// Create software title for kernels
|
|
_, err := ds.writer(ctx).ExecContext(ctx, `
|
|
INSERT INTO software_titles (name, source, is_kernel)
|
|
VALUES ('linux-kernel', 'deb_packages', TRUE)
|
|
ON DUPLICATE KEY UPDATE is_kernel = TRUE
|
|
`)
|
|
require.NoError(t, err)
|
|
|
|
var titleID uint
|
|
err = sqlx.GetContext(ctx, ds.writer(ctx), &titleID,
|
|
`SELECT id FROM software_titles WHERE name = 'linux-kernel'`)
|
|
require.NoError(t, err)
|
|
|
|
// Create 3 Linux OS versions: Ubuntu 22.04.0, 22.04.1, 22.04.2
|
|
var osVersions []fleet.OSVersion
|
|
osVersionNames := []string{"22.04.0", "22.04.1", "22.04.2"}
|
|
|
|
for idx, osVersionName := range osVersionNames {
|
|
os := fleet.OperatingSystem{
|
|
Name: "Ubuntu",
|
|
Version: osVersionName,
|
|
Platform: "linux",
|
|
KernelVersion: "5.15.0-generic",
|
|
}
|
|
|
|
err := ds.UpdateHostOperatingSystem(ctx, uint(1000+idx), os) //nolint:gosec
|
|
require.NoError(t, err)
|
|
|
|
// Get the created OS
|
|
stmt := `SELECT id, os_version_id FROM operating_systems WHERE name = ? AND version = ? LIMIT 1`
|
|
var osID uint
|
|
var osVersionID uint
|
|
err = ds.writer(ctx).QueryRowContext(ctx, stmt, "Ubuntu", osVersionName).Scan(&osID, &osVersionID)
|
|
require.NoError(t, err)
|
|
|
|
osVersions = append(osVersions, fleet.OSVersion{
|
|
ID: osID,
|
|
OSVersionID: osVersionID,
|
|
NameOnly: "Ubuntu",
|
|
Version: osVersionName,
|
|
Platform: "linux",
|
|
})
|
|
|
|
// Create 3-4 different kernels for each OS version
|
|
// Make kernel versions unique per OS to avoid sharing software entries
|
|
baseKernelNum := idx * 10 // OS 0: 0-2, OS 1: 10-13, OS 2: 20-22
|
|
kernelVersions := []string{
|
|
fmt.Sprintf("5.15.0-%d", 100+baseKernelNum),
|
|
fmt.Sprintf("5.15.0-%d", 101+baseKernelNum),
|
|
fmt.Sprintf("5.15.0-%d", 102+baseKernelNum),
|
|
}
|
|
if idx == 1 {
|
|
// Second OS version gets an extra kernel
|
|
kernelVersions = append(kernelVersions, fmt.Sprintf("5.15.0-%d", 103+baseKernelNum))
|
|
}
|
|
|
|
for kernelIdx, kernelVersion := range kernelVersions {
|
|
// Create kernel software entry
|
|
_, err = ds.writer(ctx).ExecContext(ctx, `
|
|
INSERT INTO software (name, version, source, title_id, checksum)
|
|
VALUES ('linux-kernel', ?, 'deb_packages', ?, ?)
|
|
ON DUPLICATE KEY UPDATE id = id
|
|
`, kernelVersion, titleID, fmt.Sprintf("cs%d%d", idx, kernelIdx))
|
|
require.NoError(t, err)
|
|
|
|
var softwareID uint
|
|
err = sqlx.GetContext(ctx, ds.writer(ctx), &softwareID,
|
|
`SELECT id FROM software WHERE name = 'linux-kernel' AND version = ?`, kernelVersion)
|
|
require.NoError(t, err)
|
|
|
|
// Define CVEs for this kernel
|
|
// Some CVEs are shared across kernels (CVE-2024-SHARED-*), some are unique
|
|
cves := []string{
|
|
"CVE-2024-SHARED-1", // Shared across all kernels
|
|
"CVE-2024-SHARED-2", // Shared across all kernels
|
|
fmt.Sprintf("CVE-2024-OS%d-KERNEL%d-1", idx, kernelIdx), // Unique to this OS+kernel
|
|
}
|
|
|
|
// Kernel 0 and 1 share an additional CVE
|
|
if kernelIdx <= 1 {
|
|
cves = append(cves, "CVE-2024-SHARED-K0-K1")
|
|
}
|
|
|
|
// Add CVEs for this kernel
|
|
for _, cve := range cves {
|
|
_, err = ds.writer(ctx).ExecContext(ctx, `
|
|
INSERT INTO software_cve (software_id, cve, resolved_in_version)
|
|
VALUES (?, ?, NULL)
|
|
ON DUPLICATE KEY UPDATE resolved_in_version = VALUES(resolved_in_version)
|
|
`, softwareID, cve)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Add kernel_host_counts linking this kernel to this OS version
|
|
_, err = ds.writer(ctx).ExecContext(ctx, `
|
|
INSERT INTO kernel_host_counts (software_title_id, software_id, os_version_id, hosts_count, team_id)
|
|
VALUES (?, ?, ?, 50, 0)
|
|
ON DUPLICATE KEY UPDATE hosts_count = VALUES(hosts_count)
|
|
`, titleID, softwareID, osVersionID)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
// Refresh the pre-aggregated table
|
|
err = ds.refreshOSVersionVulnerabilities(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Now query for vulnerabilities
|
|
vulnsMap, err := ds.ListVulnsByMultipleOSVersions(ctx, osVersions, false, nil, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, vulnsMap)
|
|
|
|
// Should have results for all 3 OS versions
|
|
assert.Len(t, vulnsMap, 3)
|
|
|
|
// Verify deduplication: each OS version should have the shared CVEs plus unique ones
|
|
for idx, osV := range osVersions {
|
|
key := osV.NameOnly + "-" + osV.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have vulnerabilities for %s", key)
|
|
|
|
// Count expected CVEs
|
|
// - 2 shared across all kernels (CVE-2024-SHARED-1, CVE-2024-SHARED-2)
|
|
// - 1 shared between kernel 0 and 1 (CVE-2024-SHARED-K0-K1)
|
|
// - N unique CVEs (one per kernel)
|
|
numKernels := 3
|
|
if idx == 1 {
|
|
numKernels = 4 // Second OS version has 4 kernels
|
|
}
|
|
|
|
expectedUniqueCVEs := numKernels // One unique CVE per kernel
|
|
expectedTotalCVEs := 2 + 1 + expectedUniqueCVEs // 2 fully shared + 1 k0-k1 shared + unique
|
|
|
|
assert.Len(t, vulnData.Vulnerabilities, expectedTotalCVEs, "OS version %s should have %d CVEs", key, expectedTotalCVEs)
|
|
|
|
// Verify the shared CVEs are present
|
|
cveSet := make(map[string]bool)
|
|
for _, vuln := range vulnData.Vulnerabilities {
|
|
cveSet[vuln.CVE] = true
|
|
}
|
|
|
|
assert.True(t, cveSet["CVE-2024-SHARED-1"], "Should have CVE-2024-SHARED-1")
|
|
assert.True(t, cveSet["CVE-2024-SHARED-2"], "Should have CVE-2024-SHARED-2")
|
|
assert.True(t, cveSet["CVE-2024-SHARED-K0-K1"], "Should have CVE-2024-SHARED-K0-K1")
|
|
|
|
// Verify at least one unique CVE is present
|
|
foundUnique := false
|
|
expectedPrefix := fmt.Sprintf("CVE-2024-OS%d-KERNEL", idx)
|
|
for cve := range cveSet {
|
|
if len(cve) > len(expectedPrefix) && cve[:len(expectedPrefix)] == expectedPrefix {
|
|
foundUnique = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, foundUnique, "Should have at least one unique CVE for OS %d", idx)
|
|
}
|
|
}
|
|
|
|
func testListVulnsByMultipleOSVersionsWithMaxVulnerabilities(t *testing.T, ds *Datastore) {
|
|
ctx := t.Context()
|
|
|
|
// Create test OS versions with vulnerabilities
|
|
osVersions := setupTestOSVersionsWithVulns(t, ds, ctx)
|
|
linuxOS := setupLinuxOSWithKernelVulns(t, ds, ctx)
|
|
multiArchOS := setupMultiArchOSWithVulns(t, ds, ctx)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
maxVulns *int
|
|
osVersions []fleet.OSVersion
|
|
expectError bool
|
|
errorMessage string
|
|
validateResult func(t *testing.T, vulnsMap map[string]fleet.OSVulnerabilitiesWithCount)
|
|
}{
|
|
{
|
|
name: "negative value returns error",
|
|
maxVulns: ptr.Int(-1),
|
|
osVersions: osVersions,
|
|
expectError: true,
|
|
errorMessage: "max_vulnerabilities must be >= 0",
|
|
},
|
|
{
|
|
name: "max = 0 returns empty array with count",
|
|
maxVulns: ptr.Int(0),
|
|
osVersions: osVersions,
|
|
validateResult: func(t *testing.T, vulnsMap map[string]fleet.OSVulnerabilitiesWithCount) {
|
|
assert.Len(t, vulnsMap, 3)
|
|
for _, osV := range osVersions {
|
|
key := osV.NameOnly + "-" + osV.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have entry for %s", key)
|
|
assert.Len(t, vulnData.Vulnerabilities, 0, "Should have 0 vulnerabilities when max=0")
|
|
assert.Equal(t, 2, vulnData.Count, "Count should still show total of 2 vulnerabilities")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "max = 1 limits to 1 vulnerability",
|
|
maxVulns: ptr.Int(1),
|
|
osVersions: osVersions,
|
|
validateResult: func(t *testing.T, vulnsMap map[string]fleet.OSVulnerabilitiesWithCount) {
|
|
assert.Len(t, vulnsMap, 3)
|
|
for _, osV := range osVersions {
|
|
key := osV.NameOnly + "-" + osV.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have vulnerabilities for %s", key)
|
|
assert.Len(t, vulnData.Vulnerabilities, 1, "Should have exactly 1 vulnerability (limited)")
|
|
assert.Equal(t, 2, vulnData.Count, "Count should show total of 2 vulnerabilities")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "max exceeds total returns all vulnerabilities",
|
|
maxVulns: ptr.Int(10),
|
|
osVersions: osVersions,
|
|
validateResult: func(t *testing.T, vulnsMap map[string]fleet.OSVulnerabilitiesWithCount) {
|
|
assert.Len(t, vulnsMap, 3)
|
|
for _, osV := range osVersions {
|
|
key := osV.NameOnly + "-" + osV.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have vulnerabilities for %s", key)
|
|
assert.Len(t, vulnData.Vulnerabilities, 2, "Should have all 2 vulnerabilities when max exceeds total")
|
|
assert.Equal(t, 2, vulnData.Count, "Count should show 2 vulnerabilities")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "max = 1 for Linux kernel vulnerabilities",
|
|
maxVulns: ptr.Int(1),
|
|
osVersions: []fleet.OSVersion{linuxOS},
|
|
validateResult: func(t *testing.T, vulnsMap map[string]fleet.OSVulnerabilitiesWithCount) {
|
|
key := linuxOS.NameOnly + "-" + linuxOS.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have vulnerabilities for Linux OS")
|
|
assert.Len(t, vulnData.Vulnerabilities, 1, "Should have exactly 1 vulnerability (limited)")
|
|
assert.GreaterOrEqual(t, vulnData.Count, 1, "Count should be at least 1")
|
|
if vulnData.Count > 1 {
|
|
assert.Equal(t, 1, len(vulnData.Vulnerabilities), "Should be limited to 1 vulnerability even though total is %d", vulnData.Count)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "multi-arch with max returns correct count",
|
|
maxVulns: ptr.Int(3),
|
|
osVersions: []fleet.OSVersion{multiArchOS},
|
|
validateResult: func(t *testing.T, vulnsMap map[string]fleet.OSVulnerabilitiesWithCount) {
|
|
key := multiArchOS.NameOnly + "-" + multiArchOS.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have vulnerabilities for multi-arch OS")
|
|
|
|
// Should return at most 3 vulnerabilities (application-level limit)
|
|
assert.LessOrEqual(t, len(vulnData.Vulnerabilities), 3, "Should have at most 3 vulnerabilities (limited)")
|
|
|
|
// Count should be 15 (total unique CVEs across both architectures)
|
|
// - arm64 arch has: CVE-2024-0001 through CVE-2024-0010 (10 CVEs)
|
|
// - x86_64 arch has: CVE-2024-0006 through CVE-2024-0015 (10 CVEs)
|
|
// - Total unique: 15 CVEs (CVE-2024-0001 through CVE-2024-0015)
|
|
assert.Equal(t, 15, vulnData.Count, "Count should be 15 total unique CVEs across both architectures")
|
|
},
|
|
},
|
|
{
|
|
name: "multi-arch with max=0 returns correct count",
|
|
maxVulns: ptr.Int(0),
|
|
osVersions: []fleet.OSVersion{multiArchOS},
|
|
validateResult: func(t *testing.T, vulnsMap map[string]fleet.OSVulnerabilitiesWithCount) {
|
|
key := multiArchOS.NameOnly + "-" + multiArchOS.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have entry for %s", key)
|
|
assert.Len(t, vulnData.Vulnerabilities, 0, "Should have 0 vulnerabilities when max=0")
|
|
assert.Equal(t, 15, vulnData.Count, "Count should be 15 total unique CVEs across both architectures")
|
|
},
|
|
},
|
|
{
|
|
name: "combined osVersions + linuxOS with max=1",
|
|
maxVulns: ptr.Int(1),
|
|
osVersions: append(osVersions, linuxOS),
|
|
validateResult: func(t *testing.T, vulnsMap map[string]fleet.OSVulnerabilitiesWithCount) {
|
|
// Should have entries for all 3 non-Linux OS + 1 Linux OS = 4 total
|
|
assert.Len(t, vulnsMap, 4)
|
|
|
|
// Check non-Linux OS versions
|
|
for _, osV := range osVersions {
|
|
key := osV.NameOnly + "-" + osV.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have vulnerabilities for %s", key)
|
|
assert.Len(t, vulnData.Vulnerabilities, 1, "Should have exactly 1 vulnerability (limited)")
|
|
assert.Equal(t, 2, vulnData.Count, "Count should show total of 2 vulnerabilities")
|
|
}
|
|
|
|
// Check Linux OS
|
|
linuxKey := linuxOS.NameOnly + "-" + linuxOS.Version
|
|
linuxVulnData, ok := vulnsMap[linuxKey]
|
|
assert.True(t, ok, "Should have vulnerabilities for Linux OS")
|
|
assert.Len(t, linuxVulnData.Vulnerabilities, 1, "Should have exactly 1 vulnerability (limited)")
|
|
assert.GreaterOrEqual(t, linuxVulnData.Count, 1, "Count should be at least 1 for kernel vulnerabilities")
|
|
},
|
|
},
|
|
{
|
|
name: "combined osVersions + linuxOS with max=0",
|
|
maxVulns: ptr.Int(0),
|
|
osVersions: append(osVersions, linuxOS),
|
|
validateResult: func(t *testing.T, vulnsMap map[string]fleet.OSVulnerabilitiesWithCount) {
|
|
assert.Len(t, vulnsMap, 4)
|
|
|
|
// All OS versions should return empty vulnerability arrays but correct counts
|
|
for _, osV := range osVersions {
|
|
key := osV.NameOnly + "-" + osV.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have entry for %s", key)
|
|
assert.Len(t, vulnData.Vulnerabilities, 0, "Should have 0 vulnerabilities when max=0")
|
|
assert.Equal(t, 2, vulnData.Count, "Count should still show total of 2 vulnerabilities")
|
|
}
|
|
|
|
linuxKey := linuxOS.NameOnly + "-" + linuxOS.Version
|
|
linuxVulnData, ok := vulnsMap[linuxKey]
|
|
assert.True(t, ok, "Should have entry for Linux OS")
|
|
assert.Len(t, linuxVulnData.Vulnerabilities, 0, "Should have 0 vulnerabilities when max=0")
|
|
assert.GreaterOrEqual(t, linuxVulnData.Count, 1, "Count should be at least 1 for kernel vulnerabilities")
|
|
},
|
|
},
|
|
{
|
|
name: "combined all OS types with max=2",
|
|
maxVulns: ptr.Int(2),
|
|
osVersions: append(append(osVersions, linuxOS), multiArchOS),
|
|
validateResult: func(t *testing.T, vulnsMap map[string]fleet.OSVulnerabilitiesWithCount) {
|
|
// Should have 3 non-Linux + 1 Linux + 1 multi-arch = 5 total
|
|
assert.Len(t, vulnsMap, 5)
|
|
|
|
// Check non-Linux OS versions
|
|
for _, osV := range osVersions {
|
|
key := osV.NameOnly + "-" + osV.Version
|
|
vulnData, ok := vulnsMap[key]
|
|
assert.True(t, ok, "Should have vulnerabilities for %s", key)
|
|
assert.LessOrEqual(t, len(vulnData.Vulnerabilities), 2, "Should have at most 2 vulnerabilities (limited)")
|
|
assert.Equal(t, 2, vulnData.Count, "Count should show total of 2 vulnerabilities")
|
|
}
|
|
|
|
// Check Linux OS
|
|
linuxKey := linuxOS.NameOnly + "-" + linuxOS.Version
|
|
linuxVulnData, ok := vulnsMap[linuxKey]
|
|
assert.True(t, ok, "Should have vulnerabilities for Linux OS")
|
|
assert.LessOrEqual(t, len(linuxVulnData.Vulnerabilities), 2, "Should have at most 2 vulnerabilities (limited)")
|
|
assert.GreaterOrEqual(t, linuxVulnData.Count, 1, "Count should be at least 1 for kernel vulnerabilities")
|
|
|
|
// Check multi-arch OS
|
|
multiArchKey := multiArchOS.NameOnly + "-" + multiArchOS.Version
|
|
multiArchVulnData, ok := vulnsMap[multiArchKey]
|
|
assert.True(t, ok, "Should have vulnerabilities for multi-arch OS")
|
|
assert.LessOrEqual(t, len(multiArchVulnData.Vulnerabilities), 2, "Should have at most 2 vulnerabilities (limited)")
|
|
assert.Equal(t, 15, multiArchVulnData.Count, "Count should be 15 total unique CVEs across both architectures")
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
vulnsMap, err := ds.ListVulnsByMultipleOSVersions(ctx, tc.osVersions, false, nil, tc.maxVulns)
|
|
|
|
if tc.expectError {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tc.errorMessage)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, vulnsMap)
|
|
tc.validateResult(t, vulnsMap)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper function to set up a multi-architecture OS with vulnerabilities
|
|
// Creates macOS 14.7.1 with two architectures (x86_64 and ARM) that map to the same key
|
|
// This simulates the scenario where multiple operating_system_id values share name+version
|
|
func setupMultiArchOSWithVulns(t *testing.T, ds *Datastore, ctx context.Context) fleet.OSVersion {
|
|
osName := "macOS"
|
|
osVersion := "14.7.1"
|
|
|
|
// Create first OS entry (x86_64 architecture)
|
|
os1 := fleet.OperatingSystem{
|
|
Name: osName,
|
|
Version: osVersion,
|
|
Arch: "x86_64",
|
|
Platform: "darwin",
|
|
KernelVersion: "23.6.0",
|
|
}
|
|
err := ds.UpdateHostOperatingSystem(ctx, 2000, os1)
|
|
require.NoError(t, err)
|
|
|
|
// Create second OS entry (ARM architecture) - same name and version, different arch
|
|
os2 := fleet.OperatingSystem{
|
|
Name: osName,
|
|
Version: osVersion,
|
|
Arch: "arm64",
|
|
Platform: "darwin",
|
|
KernelVersion: "23.6.0",
|
|
}
|
|
err = ds.UpdateHostOperatingSystem(ctx, 2001, os2)
|
|
require.NoError(t, err)
|
|
|
|
// Get both OS IDs
|
|
stmt := `SELECT id, os_version_id FROM operating_systems WHERE name = ? AND version = ? ORDER BY arch`
|
|
var osResults []struct {
|
|
ID uint `db:"id"`
|
|
OSVersionID uint `db:"os_version_id"`
|
|
}
|
|
err = ds.writer(ctx).SelectContext(ctx, &osResults, stmt, osName, osVersion)
|
|
require.NoError(t, err)
|
|
require.Len(t, osResults, 2, "Should have 2 OS entries (x86_64 and arm64)")
|
|
|
|
osID1 := osResults[0].ID // arm64
|
|
osID2 := osResults[1].ID // x86_64
|
|
|
|
// Add 10 CVEs to first architecture (CVE-2024-0001 through CVE-2024-0010)
|
|
var vulns1 []fleet.OSVulnerability
|
|
for i := 1; i <= 10; i++ {
|
|
vulns1 = append(vulns1, fleet.OSVulnerability{
|
|
OSID: osID1,
|
|
CVE: fmt.Sprintf("CVE-2024-%04d", i),
|
|
ResolvedInVersion: ptr.String(osVersion + ".1"),
|
|
})
|
|
}
|
|
_, err = ds.InsertOSVulnerabilities(ctx, vulns1, fleet.NVDSource)
|
|
require.NoError(t, err)
|
|
|
|
// Add 10 CVEs to second architecture (CVE-2024-0006 through CVE-2024-0015)
|
|
// This creates 5 overlapping CVEs (0006-0010) and 5 unique CVEs (0011-0015)
|
|
var vulns2 []fleet.OSVulnerability
|
|
for i := 6; i <= 15; i++ {
|
|
vulns2 = append(vulns2, fleet.OSVulnerability{
|
|
OSID: osID2,
|
|
CVE: fmt.Sprintf("CVE-2024-%04d", i),
|
|
ResolvedInVersion: ptr.String(osVersion + ".1"),
|
|
})
|
|
}
|
|
_, err = ds.InsertOSVulnerabilities(ctx, vulns2, fleet.NVDSource)
|
|
require.NoError(t, err)
|
|
|
|
// Return an OSVersion representing the merged key
|
|
// Note: We use the first OS's IDs, but the key "macOS-14.7.1" will aggregate both
|
|
return fleet.OSVersion{
|
|
ID: osID1,
|
|
OSVersionID: osResults[0].OSVersionID,
|
|
NameOnly: osName,
|
|
Version: osVersion,
|
|
Platform: "darwin",
|
|
}
|
|
}
|