diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index d9d31d95dc0..53ffa1ffe32 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -207,6 +207,11 @@ func scanVulnerabilities( ovalVulns = append(ovalVulns, osvVulns...) } + if config.OSVForVulnerabilities { + rhelOSVVulns := checkRHELOSVVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "") + ovalVulns = append(ovalVulns, rhelOSVVulns...) + } + govalDictVulns := checkGovalDictionaryVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "") macOfficeVulns := checkMacOfficeVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "") winOfficeVulns := checkWinOfficeVulnerabilities(ctx, ds, logger, vulnPath, config, vulnAutomationEnabled != "") @@ -417,14 +422,16 @@ func checkOvalVulnerabilities( return nil } - // If OSV feature flag is enabled, filter out platforms supported by OSV (OVAL will skip them) + // If OSV feature flag is enabled, filter out platforms handled by OSV (OVAL will skip them) processVersions := versions if config.OSVForVulnerabilities { var nonOSVPlatforms []fleet.OSVersion for _, v := range versions.OSVersions { - if !osv.IsPlatformSupported(v.Platform) { - nonOSVPlatforms = append(nonOSVPlatforms, v) + // Fedora reports platform "rhel" but Red Hat OSV doesn't cover it — keep in OVAL + if osv.IsPlatformSupported(v.Platform) && !strings.Contains(v.Name, "Fedora") { + continue } + nonOSVPlatforms = append(nonOSVPlatforms, v) } processVersions = &fleet.OSVersions{ @@ -471,6 +478,7 @@ func checkOvalVulnerabilities( analyzeSpan.End() cleanupStaleOSVVulnerabilities(ctx, ds, logger, config.OSVForVulnerabilities) + cleanupStaleRHELOSVVulnerabilities(ctx, ds, logger, config.OSVForVulnerabilities) return results } @@ -567,6 +575,89 @@ func cleanupStaleOVALVulnerabilities(ctx context.Context, ds fleet.Datastore, lo } } +func checkRHELOSVVulnerabilities( + ctx context.Context, + ds fleet.Datastore, + logger *slog.Logger, + vulnPath string, + config *config.VulnerabilitiesConfig, + collectVulns bool, +) []fleet.SoftwareVulnerability { + ctx, span := tracer.Start(ctx, "vuln.check_rhel_osv") + defer span.End() + + var results []fleet.SoftwareVulnerability + + versions, err := ds.OSVersions(ctx, nil, nil, nil, nil) + if err != nil { + errHandler(ctx, logger, "listing platforms for RHEL OSV", err) + return nil + } + + var now time.Time + if !config.DisableDataSync { + now = time.Now() + } + + if !config.DisableDataSync { + refreshCtx, refreshSpan := tracer.Start(ctx, "vuln.rhel_osv.refresh") + downloaded, err := osv.RefreshRHEL(refreshCtx, versions, vulnPath, now) + if err != nil { + errHandler(refreshCtx, logger, "updating RHEL OSV artifacts", err) + } + for _, d := range downloaded { + logger.DebugContext(refreshCtx, "", "rhel-osv-sync-downloaded", d) + } + refreshSpan.End() + } + + analyzeCtx, analyzeSpan := tracer.Start(ctx, "vuln.rhel_osv.analyze", + trace.WithAttributes(attribute.Int("os_count", len(versions.OSVersions)))) + for _, version := range versions.OSVersions { + start := time.Now() + r, err := osv.AnalyzeRHEL(analyzeCtx, ds, version, vulnPath, collectVulns, logger, now) + if err != nil && errors.Is(err, osv.ErrUnsupportedPlatform) { + logger.DebugContext(analyzeCtx, "rhel-osv-analysis-unsupported", "platform", version.Name) + continue + } + + elapsed := time.Since(start) + logger.DebugContext(analyzeCtx, "rhel-osv-analysis-done", + "platform", version.Name, + "elapsed", elapsed, + "found new", len(r)) + results = append(results, r...) + if err != nil { + errHandler(analyzeCtx, logger, "analyzing RHEL OSV artifacts", err) + } + } + analyzeSpan.End() + + cleanupStaleRHELOVALVulnerabilities(ctx, ds, logger) + + return results +} + +// cleanupStaleRHELOSVVulnerabilities removes RHEL OSV vulnerabilities when the flag is disabled. +func cleanupStaleRHELOSVVulnerabilities(ctx context.Context, ds fleet.Datastore, logger *slog.Logger, osvEnabled bool) { + if osvEnabled { + return + } + + logger.DebugContext(ctx, "cleaning up RHEL OSV vulnerabilities because RHEL OSV is disabled") + if err := ds.DeleteOutOfDateVulnerabilities(ctx, fleet.RHELOSVSource, time.Now().Add(deleteAllVulnerabilitiesTime)); err != nil { + errHandler(ctx, logger, "cleaning up RHEL OSV vulnerabilities", err) + } +} + +// cleanupStaleRHELOVALVulnerabilities removes RHEL OVAL vulnerabilities when RHEL OSV is enabled. +func cleanupStaleRHELOVALVulnerabilities(ctx context.Context, ds fleet.Datastore, logger *slog.Logger) { + logger.DebugContext(ctx, "cleaning up RHEL OVAL vulnerabilities because RHEL OSV is enabled") + if err := ds.DeleteOutOfDateVulnerabilities(ctx, fleet.RHELOVALSource, time.Now().Add(deleteAllVulnerabilitiesTime)); err != nil { + errHandler(ctx, logger, "cleaning up RHEL OVAL vulnerabilities", err) + } +} + func checkGovalDictionaryVulnerabilities( ctx context.Context, ds fleet.Datastore, @@ -604,6 +695,11 @@ func checkGovalDictionaryVulnerabilities( analyzeCtx, analyzeSpan := tracer.Start(ctx, "vuln.goval_dictionary.analyze", trace.WithAttributes(attribute.Int("os_count", len(versions.OSVersions)))) for _, version := range versions.OSVersions { + // Skip RHEL platforms when OSV is enabled (OSV handles both kernel and non-kernel). + // Fedora reports platform "rhel" but Red Hat OSV doesn't cover it — keep in goval-dictionary. + if config.OSVForVulnerabilities && osv.IsPlatformSupported(version.Platform) && !strings.Contains(version.Name, "Fedora") { + continue + } start := time.Now() r, err := goval_dictionary.Analyze(analyzeCtx, ds, version, vulnPath, collectVulns, logger) if err != nil && errors.Is(err, goval_dictionary.ErrUnsupportedPlatform) { diff --git a/server/fleet/vulnerabilities.go b/server/fleet/vulnerabilities.go index 8e48201eba8..6660726bb79 100644 --- a/server/fleet/vulnerabilities.go +++ b/server/fleet/vulnerabilities.go @@ -130,6 +130,7 @@ const ( GovalDictionarySource WinOfficeSource UbuntuOSVSource + RHELOSVSource ) type VulnerabilityWithMetadata struct { diff --git a/server/vulnerabilities/osv/analyzer.go b/server/vulnerabilities/osv/analyzer.go index 133f242519e..9937d8dbe38 100644 --- a/server/vulnerabilities/osv/analyzer.go +++ b/server/vulnerabilities/osv/analyzer.go @@ -24,9 +24,10 @@ const ( var ErrUnsupportedPlatform = errors.New("unsupported platform") -// IsPlatformSupported returns true if the given platform is supported by OSV +// IsPlatformSupported returns true if the given platform is supported by OSV. func IsPlatformSupported(platform string) bool { - return strings.ToLower(platform) == "ubuntu" + p := strings.ToLower(platform) + return p == "ubuntu" || p == "rhel" } // OSVArtifact represents the processed OSV data for a specific Ubuntu version @@ -380,3 +381,299 @@ func isVulnerable(softwareVersion string, vuln OSVVulnerability, isKernelPackage return true } + +// --- RHEL OSV support --- + +// RHELOSVArtifact represents the processed OSV data for a specific RHEL major version. +type RHELOSVArtifact struct { + SchemaVersion string `json:"schema_version"` + RHELVersion string `json:"rhel_version"` + Generated time.Time `json:"generated"` + TotalCVEs int `json:"total_cves"` + TotalPackages int `json:"total_packages"` + Vulnerabilities map[string][]OSVVulnerability `json:"vulnerabilities"` +} + +// extractRHELMajorVersion extracts the major RHEL version from an OSVersion.Version string. +// Uses Version (e.g., "9.4.0") rather than Name (e.g., "Red Hat Enterprise Linux Server 8.2.0") +// because Name varies inconsistently (the "Server" suffix appears only on some versions). +// +// Examples: +// +// "9.4.0" → "9" +// "8.10.0" → "8" +// "7.9.0" → "7" +func extractRHELMajorVersion(version string) string { + version = strings.TrimSpace(version) + parts := strings.Split(version, ".") + if len(parts) < 1 || parts[0] == "" { + return "" + } + return parts[0] +} + +// rhelKernelPackages maps installed kernel package variants to the "kernel" package name +// used in OSV artifacts. OSV lists vulnerabilities under "kernel", but hosts may have +// kernel-core, kernel-modules, etc. installed. +var rhelKernelPackages = map[string]struct{}{ + "kernel": {}, + "kernel-core": {}, + "kernel-modules": {}, + "kernel-modules-core": {}, + "kernel-modules-extra": {}, + "kernel-debug": {}, + "kernel-debug-core": {}, + "kernel-debug-modules": {}, + "kernel-debug-modules-extra": {}, + "kernel-devel": {}, + "kernel-debug-devel": {}, + "kernel-tools": {}, + "kernel-tools-libs": {}, + "kernel-headers": {}, +} + +// matchSoftwareToRHELOSV matches RPM software against RHEL OSV vulnerabilities. +func matchSoftwareToRHELOSV(software []fleet.Software, artifact *RHELOSVArtifact) []fleet.SoftwareVulnerability { + var result []fleet.SoftwareVulnerability + + for _, sw := range software { + packageName := sw.Name + + // Map kernel variants to "kernel" + if _, isKernel := rhelKernelPackages[sw.Name]; isKernel { + packageName = "kernel" + } + + vulns, ok := artifact.Vulnerabilities[packageName] + if !ok { + continue + } + + for _, vuln := range vulns { + if isVulnerableRPM(sw.Version, sw.Release, vuln) { + result = append(result, fleet.SoftwareVulnerability{ + SoftwareID: sw.ID, + CVE: vuln.CVE, + }) + } + } + } + + return result +} + +// isVulnerableRPM checks if an RPM software version is vulnerable based on OSV data. +// Uses Rpmvercmp for RPM epoch:version-release comparison. +func isVulnerableRPM(softwareVersion, softwareRelease string, vuln OSVVulnerability) bool { + // Build current version string: "version-release" + current := softwareVersion + if softwareRelease != "" { + current = softwareVersion + "-" + softwareRelease + } + + introduced := vuln.Introduced + if introduced == "" { + introduced = "0" + } + + if introduced != "0" { + if utils.Rpmvercmp(current, introduced) < 0 { + return false + } + } + + if vuln.Fixed != "" { + return utils.Rpmvercmp(current, vuln.Fixed) < 0 + } + + // No fixed version — still vulnerable if introduced + return true +} + +// AnalyzeRHEL scans all hosts for RHEL vulnerabilities based on OSV artifacts. +func AnalyzeRHEL( + ctx context.Context, + ds fleet.Datastore, + ver fleet.OSVersion, + vulnPath string, + collectVulns bool, + logger *slog.Logger, + date time.Time, +) ([]fleet.SoftwareVulnerability, error) { + if strings.ToLower(ver.Platform) != "rhel" { + return nil, ErrUnsupportedPlatform + } + + // Fedora reports platform "rhel" but Red Hat OSV data does not cover Fedora. + if strings.Contains(ver.Name, "Fedora") { + return nil, ErrUnsupportedPlatform + } + + artifact, err := loadRHELOSVArtifact(ctx, ver, vulnPath, logger, date) + if err != nil { + return nil, fmt.Errorf("loading RHEL OSV artifact: %w", err) + } + + source := fleet.RHELOSVSource + toInsertSet := make(map[string]fleet.SoftwareVulnerability) + toDeleteSet := make(map[string]fleet.SoftwareVulnerability) + totalHosts := 0 + + var offset int + for { + hostIDs, err := ds.HostIDsByOSVersion(ctx, ver, offset, hostsBatchSize) + if err != nil { + return nil, fmt.Errorf("getting host IDs: %w", err) + } + + if len(hostIDs) == 0 { + break + } + + totalHosts += len(hostIDs) + offset += hostsBatchSize + + foundInBatch := make(map[uint][]fleet.SoftwareVulnerability) + for _, hostID := range hostIDs { + software, err := ds.ListSoftwareForVulnDetection(ctx, fleet.VulnSoftwareFilter{ + HostID: &hostID, + }) + if err != nil { + return nil, fmt.Errorf("listing software for host %d: %w", hostID, err) + } + + foundInBatch[hostID] = matchSoftwareToRHELOSV(software, artifact) + } + + existingInBatch, err := ds.ListSoftwareVulnerabilitiesByHostIDsSource(ctx, hostIDs, source) + if err != nil { + return nil, fmt.Errorf("listing existing vulnerabilities: %w", err) + } + + for _, hostID := range hostIDs { + insrt, del := utils.VulnsDelta(foundInBatch[hostID], existingInBatch[hostID]) + for _, i := range insrt { + toInsertSet[i.Key()] = i + } + for _, d := range del { + toDeleteSet[d.Key()] = d + } + } + } + + if totalHosts == 0 { + logger.DebugContext(ctx, "no hosts found for os version", "platform", ver.Platform, "version", ver.Version) + return nil, nil + } + + logger.DebugContext(ctx, "processed hosts for rhel osv analysis", "platform", ver.Platform, "version", ver.Version, "host_count", totalHosts) + + err = utils.BatchProcess(toDeleteSet, func(v []fleet.SoftwareVulnerability) error { + return ds.DeleteSoftwareVulnerabilities(ctx, v) + }, vulnBatchSize) + if err != nil { + return nil, fmt.Errorf("deleting stale vulnerabilities: %w", err) + } + + allVulns := make([]fleet.SoftwareVulnerability, 0, len(toInsertSet)) + for _, v := range toInsertSet { + allVulns = append(allVulns, v) + } + + newVulns, err := ds.InsertSoftwareVulnerabilities(ctx, allVulns, source) + if err != nil { + return nil, fmt.Errorf("inserting software vulnerabilities: %w", err) + } + + if !collectVulns { + return nil, nil + } + + return newVulns, nil +} + +// findLatestRHELOSVArtifactForVersion finds the most recent RHEL OSV artifact for a major version. +func findLatestRHELOSVArtifactForVersion(vulnPath string, rhelVersion string) (string, error) { + files, err := os.ReadDir(vulnPath) + if err != nil { + return "", fmt.Errorf("reading vulnerability path: %w", err) + } + + prefix := fmt.Sprintf("osv-rhel-%s-", rhelVersion) + suffix := ".json.gz" + + var latestFile os.DirEntry + var latestModTime time.Time + + for _, f := range files { + if f.IsDir() { + continue + } + + name := f.Name() + if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, suffix) && !strings.Contains(name, "delta") { + info, err := f.Info() + if err != nil { + continue + } + + if latestFile == nil || info.ModTime().After(latestModTime) { + latestFile = f + latestModTime = info.ModTime() + } + } + } + + if latestFile == nil { + return "", fmt.Errorf("no RHEL OSV artifact found for RHEL %s", rhelVersion) + } + + return filepath.Join(vulnPath, latestFile.Name()), nil +} + +// loadRHELOSVArtifact loads the RHEL OSV artifact for the given OS version. +func loadRHELOSVArtifact(ctx context.Context, ver fleet.OSVersion, vulnPath string, logger *slog.Logger, date time.Time) (*RHELOSVArtifact, error) { + rhelVer := extractRHELMajorVersion(ver.Version) + if rhelVer == "" { + return nil, fmt.Errorf("could not extract RHEL version from %s", ver.Name) + } + + fileName := rhelOSVFilename(rhelVer, date) + artifactFile := filepath.Join(vulnPath, fileName) + + if _, err := os.Stat(artifactFile); err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("checking RHEL OSV artifact %s: %w", artifactFile, err) + } + + artifactFile, err = findLatestRHELOSVArtifactForVersion(vulnPath, rhelVer) + if err != nil { + return nil, fmt.Errorf("finding RHEL OSV artifact for RHEL %s: %w", rhelVer, err) + } + } + + f, err := os.Open(artifactFile) + if err != nil { + return nil, fmt.Errorf("opening RHEL OSV artifact: %w", err) + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return nil, fmt.Errorf("creating gzip reader: %w", err) + } + defer gz.Close() + + var artifact RHELOSVArtifact + if err := json.NewDecoder(gz).Decode(&artifact); err != nil { + return nil, fmt.Errorf("decoding RHEL OSV artifact: %w", err) + } + + logger.DebugContext(ctx, "loaded rhel osv artifact", + "file", filepath.Base(artifactFile), + "rhel_version", artifact.RHELVersion, + "total_packages", artifact.TotalPackages, + "total_cves", artifact.TotalCVEs) + + return &artifact, nil +} diff --git a/server/vulnerabilities/osv/analyzer_test.go b/server/vulnerabilities/osv/analyzer_test.go index 297b6152146..b4dc9e94269 100644 --- a/server/vulnerabilities/osv/analyzer_test.go +++ b/server/vulnerabilities/osv/analyzer_test.go @@ -37,9 +37,14 @@ func TestIsPlatformSupported(t *testing.T) { expected: true, }, { - name: "RHEL not supported", + name: "RHEL lowercase", platform: "rhel", - expected: false, + expected: true, + }, + { + name: "RHEL uppercase", + platform: "RHEL", + expected: true, }, { name: "Windows not supported", @@ -638,3 +643,164 @@ func TestLoadOSVArtifactZeroTimeUsesLatest(t *testing.T) { // Verify it loaded successfully (artifact should have schema_version) require.Equal(t, "1.0.0", artifact.SchemaVersion) } + +func TestExtractRHELMajorVersion(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"RHEL 9.4.0", "9.4.0", "9"}, + {"RHEL 8.10.0", "8.10.0", "8"}, + {"RHEL 7.9.0", "7.9.0", "7"}, + {"Major only", "9", "9"}, + {"Empty string", "", ""}, + {"Whitespace", " 9.4.0 ", "9"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, extractRHELMajorVersion(tt.input)) + }) + } +} + +func TestIsVulnerableRPM(t *testing.T) { + tests := []struct { + name string + version string + release string + vuln OSVVulnerability + expected bool + }{ + { + name: "vulnerable - older than fixed", + version: "2.1.3", release: "3.el9", + vuln: OSVVulnerability{Fixed: "0:2.1.3-4.el9_1", Introduced: "0"}, + expected: true, + }, + { + name: "not vulnerable - at fixed version", + version: "2.1.3", release: "4.el9_1", + vuln: OSVVulnerability{Fixed: "0:2.1.3-4.el9_1", Introduced: "0"}, + expected: false, + }, + { + name: "not vulnerable - newer than fixed", + version: "2.1.3", release: "5.el9_2", + vuln: OSVVulnerability{Fixed: "0:2.1.3-4.el9_1", Introduced: "0"}, + expected: false, + }, + { + name: "vulnerable - no release field", + version: "1.0.0", release: "", + vuln: OSVVulnerability{Fixed: "0:2.0.0-1.el9", Introduced: "0"}, + expected: true, + }, + { + name: "vulnerable - no fixed version (still affected)", + version: "1.0.0", release: "1.el9", + vuln: OSVVulnerability{Introduced: "0"}, + expected: true, + }, + { + name: "not vulnerable - below introduced", + version: "0.9.0", release: "1.el9", + vuln: OSVVulnerability{Fixed: "0:2.0.0-1.el9", Introduced: "0:1.0.0-1.el9"}, + expected: false, + }, + { + name: "vulnerable - kernel version with epoch", + version: "5.14.0", release: "503.26.1.el9_5", + vuln: OSVVulnerability{Fixed: "0:5.14.0-611.8.1.el9_7", Introduced: "0"}, + expected: true, + }, + { + name: "not vulnerable - kernel at fixed", + version: "5.14.0", release: "611.8.1.el9_7", + vuln: OSVVulnerability{Fixed: "0:5.14.0-611.8.1.el9_7", Introduced: "0"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, isVulnerableRPM(tt.version, tt.release, tt.vuln)) + }) + } +} + +func TestMatchSoftwareToRHELOSV(t *testing.T) { + artifact := &RHELOSVArtifact{ + RHELVersion: "9", + Vulnerabilities: map[string][]OSVVulnerability{ + "curl": { + {CVE: "CVE-2024-1234", Fixed: "0:7.76.1-29.el9_4.2", Introduced: "0"}, + }, + "kernel": { + {CVE: "CVE-2025-5678", Fixed: "0:5.14.0-611.8.1.el9_7", Introduced: "0"}, + }, + }, + } + + t.Run("regular package match", func(t *testing.T) { + software := []fleet.Software{ + {ID: 1, Name: "curl", Version: "7.76.1", Release: "26.el9_3.2"}, + } + result := matchSoftwareToRHELOSV(software, artifact) + require.Len(t, result, 1) + require.Equal(t, "CVE-2024-1234", result[0].CVE) + require.Equal(t, uint(1), result[0].SoftwareID) + }) + + t.Run("package not in artifact", func(t *testing.T) { + software := []fleet.Software{ + {ID: 2, Name: "nginx", Version: "1.0", Release: "1.el9"}, + } + result := matchSoftwareToRHELOSV(software, artifact) + require.Empty(t, result) + }) + + t.Run("kernel-core maps to kernel", func(t *testing.T) { + software := []fleet.Software{ + {ID: 3, Name: "kernel-core", Version: "5.14.0", Release: "503.26.1.el9_5"}, + } + result := matchSoftwareToRHELOSV(software, artifact) + require.Len(t, result, 1) + require.Equal(t, "CVE-2025-5678", result[0].CVE) + }) + + t.Run("kernel-modules maps to kernel", func(t *testing.T) { + software := []fleet.Software{ + {ID: 4, Name: "kernel-modules", Version: "5.14.0", Release: "503.26.1.el9_5"}, + } + result := matchSoftwareToRHELOSV(software, artifact) + require.Len(t, result, 1) + require.Equal(t, "CVE-2025-5678", result[0].CVE) + }) + + t.Run("kernel-debug-core maps to kernel", func(t *testing.T) { + software := []fleet.Software{ + {ID: 5, Name: "kernel-debug-core", Version: "5.14.0", Release: "503.26.1.el9_5"}, + } + result := matchSoftwareToRHELOSV(software, artifact) + require.Len(t, result, 1) + require.Equal(t, "CVE-2025-5678", result[0].CVE) + }) + + t.Run("patched kernel not vulnerable", func(t *testing.T) { + software := []fleet.Software{ + {ID: 6, Name: "kernel-core", Version: "5.14.0", Release: "611.8.1.el9_7"}, + } + result := matchSoftwareToRHELOSV(software, artifact) + require.Empty(t, result) + }) + + t.Run("patched curl not vulnerable", func(t *testing.T) { + software := []fleet.Software{ + {ID: 7, Name: "curl", Version: "7.76.1", Release: "29.el9_4.2"}, + } + result := matchSoftwareToRHELOSV(software, artifact) + require.Empty(t, result) + }) +} diff --git a/server/vulnerabilities/osv/downloader.go b/server/vulnerabilities/osv/downloader.go index f8a61edf81f..b715ca05ef3 100644 --- a/server/vulnerabilities/osv/downloader.go +++ b/server/vulnerabilities/osv/downloader.go @@ -83,7 +83,8 @@ func getLatestRelease(ctx context.Context) (*ReleaseInfo, error) { assets := make(map[string]*AssetInfo) for _, asset := range release.Assets { - if strings.HasPrefix(asset.Name, OSVFilePrefix) && !strings.Contains(asset.Name, "delta") { + isOSVAsset := strings.HasPrefix(asset.Name, OSVFilePrefix) || strings.HasPrefix(asset.Name, OSVRHELFilePrefix) + if isOSVAsset && !strings.Contains(asset.Name, "delta") { assets[asset.Name] = &AssetInfo{ Name: asset.Name, ID: asset.ID, @@ -183,26 +184,28 @@ type SyncResult struct { type downloadFunc func(ctx context.Context, assetID int64, dstPath string) error // SyncOSV downloads OSV artifacts for the specified Ubuntu versions -func SyncOSV(ctx context.Context, dstDir string, ubuntuVersions []string, date time.Time, release *ReleaseInfo) (*SyncResult, error) { - return syncOSVWithDownloader(ctx, dstDir, ubuntuVersions, date, release, downloadOSVArtifact) +func SyncOSV(ctx context.Context, dstDir string, versions []string, date time.Time, release *ReleaseInfo) (*SyncResult, error) { + return syncOSVWithDownloader(ctx, dstDir, versions, date, release, downloadOSVArtifact, osvFilename) } +type filenameFn func(version string, date time.Time) string + // syncOSVWithDownloader is the internal implementation that accepts a custom download function for testing -func syncOSVWithDownloader(ctx context.Context, dstDir string, ubuntuVersions []string, date time.Time, release *ReleaseInfo, download downloadFunc) (*SyncResult, error) { +func syncOSVWithDownloader(ctx context.Context, dstDir string, versions []string, date time.Time, release *ReleaseInfo, download downloadFunc, nameFn filenameFn) (*SyncResult, error) { result := &SyncResult{ Downloaded: make([]string, 0), Skipped: make([]string, 0), Failed: make([]string, 0), } - for _, ubuntuVersion := range ubuntuVersions { - filename := osvFilename(ubuntuVersion, date) + for _, version := range versions { + filename := nameFn(version, date) dstPath := filepath.Join(dstDir, filename) assetInfo, ok := release.Assets[filename] if !ok { // Artifact not available, skip - result.Skipped = append(result.Skipped, ubuntuVersion) + result.Skipped = append(result.Skipped, version) continue } @@ -215,7 +218,7 @@ func syncOSVWithDownloader(ctx context.Context, dstDir string, ubuntuVersions [] if err == nil && localDigest == assetInfo.Digest { // Checksums match, skip download needsDownload = false - result.Skipped = append(result.Skipped, ubuntuVersion) + result.Skipped = append(result.Skipped, version) } } } @@ -225,7 +228,7 @@ func syncOSVWithDownloader(ctx context.Context, dstDir string, ubuntuVersions [] if err != nil { // Download failed, skip os.Remove(dstPath) - result.Failed = append(result.Failed, ubuntuVersion) + result.Failed = append(result.Failed, version) continue } @@ -234,19 +237,19 @@ func syncOSVWithDownloader(ctx context.Context, dstDir string, ubuntuVersions [] if err != nil { // Failed to compute digest, clean up and mark failed os.Remove(dstPath) - result.Failed = append(result.Failed, ubuntuVersion) + result.Failed = append(result.Failed, version) continue } if downloadedDigest != assetInfo.Digest { // Checksum mismatch - corrupted download, clean up and mark failed os.Remove(dstPath) - result.Failed = append(result.Failed, ubuntuVersion) + result.Failed = append(result.Failed, version) continue } } - result.Downloaded = append(result.Downloaded, ubuntuVersion) + result.Downloaded = append(result.Downloaded, version) } } diff --git a/server/vulnerabilities/osv/sync.go b/server/vulnerabilities/osv/sync.go index b26bd0a552b..600076cc81f 100644 --- a/server/vulnerabilities/osv/sync.go +++ b/server/vulnerabilities/osv/sync.go @@ -12,8 +12,10 @@ import ( ) const ( - // OSVFilePrefix is the prefix for OSV artifact files + // OSVFilePrefix is the prefix for Ubuntu OSV artifact files OSVFilePrefix = "osv-ubuntu-" + // OSVRHELFilePrefix is the prefix for RHEL OSV artifact files + OSVRHELFilePrefix = "osv-rhel-" ) // Refresh checks all local OSV artifacts contained in 'vulnPath', deleting outdated artifacts and downloading the latest required ones. @@ -106,7 +108,7 @@ func getNeededUbuntuVersions(osVers *fleet.OSVersions) []string { var needed []string for _, os := range osVers.OSVersions { - if !IsPlatformSupported(os.Platform) { + if strings.ToLower(os.Platform) != "ubuntu" { continue } @@ -131,3 +133,131 @@ func osvFilename(ubuntuVersion string, date time.Time) string { return fmt.Sprintf("%s%s-%d-%02d-%02d.json.gz", OSVFilePrefix, ubuntuVersion, date.Year(), date.Month(), date.Day()) } + +// rhelOSVFilename generates the RHEL OSV artifact filename for a given major version and date. +// Format: osv-rhel-9-2026-04-08.json.gz +func rhelOSVFilename(rhelVersion string, date time.Time) string { + return fmt.Sprintf("%s%s-%d-%02d-%02d.json.gz", + OSVRHELFilePrefix, rhelVersion, date.Year(), date.Month(), date.Day()) +} + +// RefreshRHEL checks local RHEL OSV artifacts, deleting outdated ones and downloading the latest. +func RefreshRHEL( + ctx context.Context, + versions *fleet.OSVersions, + vulnPath string, + now time.Time, +) ([]string, error) { + neededVersions := getNeededRHELVersions(versions) + if len(neededVersions) == 0 { + return nil, nil + } + + release, err := getLatestRelease(ctx) + if err != nil { + return nil, fmt.Errorf("getting latest release: %w", err) + } + + syncResult, err := syncRHELOSV(ctx, vulnPath, neededVersions, now, release) + if err != nil { + return nil, fmt.Errorf("syncing RHEL OSV artifacts: %w", err) + } + + upToDateVersions := make([]string, 0, len(syncResult.Downloaded)+len(syncResult.Skipped)) + upToDateVersions = append(upToDateVersions, syncResult.Downloaded...) + upToDateVersions = append(upToDateVersions, syncResult.Skipped...) + if err := removeOldRHELOSVArtifacts(now, vulnPath, upToDateVersions); err != nil { + return syncResult.Downloaded, fmt.Errorf("warning: failed to clean up old RHEL OSV artifacts: %w", err) + } + + return syncResult.Downloaded, nil +} + +// syncRHELOSV downloads RHEL OSV artifacts for the given versions. +func syncRHELOSV( + ctx context.Context, + dstDir string, + rhelVersions []string, + date time.Time, + release *ReleaseInfo, +) (*SyncResult, error) { + return syncOSVWithDownloader(ctx, dstDir, rhelVersions, date, release, downloadOSVArtifact, rhelOSVFilename) +} + +// getNeededRHELVersions extracts unique RHEL major versions from OS versions. +func getNeededRHELVersions(osVers *fleet.OSVersions) []string { + seen := make(map[string]struct{}) + var needed []string + + for _, osVer := range osVers.OSVersions { + if strings.ToLower(osVer.Platform) != "rhel" { + continue + } + + // Fedora reports platform "rhel" but Red Hat OSV data does not cover Fedora. + // Fedora hosts will continue using OVAL for vulnerability scanning. + if strings.Contains(osVer.Name, "Fedora") { + continue + } + + rhelVer := extractRHELMajorVersion(osVer.Version) + if rhelVer == "" { + continue + } + + if _, exists := seen[rhelVer]; !exists { + seen[rhelVer] = struct{}{} + needed = append(needed, rhelVer) + } + } + + return needed +} + +// removeOldRHELOSVArtifacts removes old RHEL OSV artifacts that don't match today's date. +func removeOldRHELOSVArtifacts(date time.Time, rootPath string, successfulVersions []string) error { + dateSuffix := fmt.Sprintf("-%d-%02d-%02d.json.gz", date.Year(), date.Month(), date.Day()) + + successfulSet := make(map[string]struct{}, len(successfulVersions)) + for _, v := range successfulVersions { + successfulSet[v] = struct{}{} + } + + entries, err := os.ReadDir(rootPath) + if err != nil { + return fmt.Errorf("reading directory %s: %w", rootPath, err) + } + + for _, entry := range entries { + if entry.IsDir() || !entry.Type().IsRegular() { + continue + } + + baseName := entry.Name() + + if !strings.HasPrefix(baseName, OSVRHELFilePrefix) { + continue + } + + if strings.HasSuffix(baseName, ".json.gz") && !strings.Contains(baseName, "delta") { + if !strings.HasSuffix(baseName, dateSuffix) { + versionStart := len(OSVRHELFilePrefix) + versionEnd := strings.Index(baseName[versionStart:], "-") + if versionEnd == -1 { + continue + } + rhelVersion := baseName[versionStart : versionStart+versionEnd] + + if _, ok := successfulSet[rhelVersion]; ok { + filePath := filepath.Join(rootPath, baseName) + // #nosec G122 -- path is from ReadDir in Fleet-controlled vuln directory, checked IsRegular above + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing old RHEL OSV artifact %s: %w", baseName, err) + } + } + } + } + } + + return nil +} diff --git a/server/vulnerabilities/osv/sync_test.go b/server/vulnerabilities/osv/sync_test.go index 55727dda9c3..30e80ed149a 100644 --- a/server/vulnerabilities/osv/sync_test.go +++ b/server/vulnerabilities/osv/sync_test.go @@ -177,6 +177,140 @@ func TestGetNeededUbuntuVersions(t *testing.T) { } } +func TestGetNeededRHELVersions(t *testing.T) { + tests := []struct { + name string + osVers *fleet.OSVersions + expected []string + }{ + { + name: "multiple RHEL versions", + osVers: &fleet.OSVersions{ + OSVersions: []fleet.OSVersion{ + {Platform: "rhel", Name: "Red Hat Enterprise Linux 8.10.0", Version: "8.10.0"}, + {Platform: "rhel", Name: "Red Hat Enterprise Linux 9.4.0", Version: "9.4.0"}, + }, + }, + expected: []string{"8", "9"}, + }, + { + name: "duplicate major versions deduplicated", + osVers: &fleet.OSVersions{ + OSVersions: []fleet.OSVersion{ + {Platform: "rhel", Name: "Red Hat Enterprise Linux 9.2.0", Version: "9.2.0"}, + {Platform: "rhel", Name: "Red Hat Enterprise Linux 9.4.0", Version: "9.4.0"}, + }, + }, + expected: []string{"9"}, + }, + { + name: "Fedora skipped", + osVers: &fleet.OSVersions{ + OSVersions: []fleet.OSVersion{ + {Platform: "rhel", Name: "Red Hat Enterprise Linux 9.4.0", Version: "9.4.0"}, + {Platform: "rhel", Name: "Fedora Linux 36.0.0", Version: "36.0.0"}, + }, + }, + expected: []string{"9"}, + }, + { + name: "non-RHEL platforms ignored", + osVers: &fleet.OSVersions{ + OSVersions: []fleet.OSVersion{ + {Platform: "rhel", Name: "Red Hat Enterprise Linux 9.4.0", Version: "9.4.0"}, + {Platform: "ubuntu", Name: "Ubuntu 22.04.8 LTS", Version: "22.04.8 LTS"}, + {Platform: "windows", Name: "Windows 10", Version: "10.0.19041"}, + }, + }, + expected: []string{"9"}, + }, + { + name: "no RHEL platforms", + osVers: &fleet.OSVersions{ + OSVersions: []fleet.OSVersion{ + {Platform: "ubuntu", Name: "Ubuntu 22.04.8 LTS", Version: "22.04.8 LTS"}, + }, + }, + expected: []string{}, + }, + { + name: "only Fedora", + osVers: &fleet.OSVersions{ + OSVersions: []fleet.OSVersion{ + {Platform: "rhel", Name: "Fedora Linux 36.0.0", Version: "36.0.0"}, + }, + }, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getNeededRHELVersions(tt.osVers) + require.ElementsMatch(t, tt.expected, result) + }) + } +} + +func TestRemoveOldRHELOSVArtifacts(t *testing.T) { + tmpDir := t.TempDir() + today := time.Date(2026, 4, 8, 0, 0, 0, 0, time.UTC) + + files := []string{ + "osv-rhel-9-2026-04-08.json.gz", // today — keep + "osv-rhel-9-2026-04-07.json.gz", // yesterday — remove + "osv-rhel-8-2026-04-07.json.gz", // yesterday, different version, not in successful — keep + "osv-ubuntu-2204-2026-04-07.json.gz", // ubuntu — not touched + "some-other-file.json", // unrelated — not touched + } + + for _, file := range files { + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, file), []byte("test"), 0o644)) + } + + err := removeOldRHELOSVArtifacts(today, tmpDir, []string{"9"}) + require.NoError(t, err) + + // Today's RHEL 9 — kept + _, err = os.Stat(filepath.Join(tmpDir, "osv-rhel-9-2026-04-08.json.gz")) + require.NoError(t, err) + + // Yesterday's RHEL 9 — removed (successfully downloaded today) + _, err = os.Stat(filepath.Join(tmpDir, "osv-rhel-9-2026-04-07.json.gz")) + require.True(t, os.IsNotExist(err)) + + // Yesterday's RHEL 8 — kept (not in successful list, last-known-good) + _, err = os.Stat(filepath.Join(tmpDir, "osv-rhel-8-2026-04-07.json.gz")) + require.NoError(t, err) + + // Ubuntu artifact — not touched + _, err = os.Stat(filepath.Join(tmpDir, "osv-ubuntu-2204-2026-04-07.json.gz")) + require.NoError(t, err) + + // Other file — not touched + _, err = os.Stat(filepath.Join(tmpDir, "some-other-file.json")) + require.NoError(t, err) +} + +func TestRHELOSVFilename(t *testing.T) { + date := time.Date(2026, 4, 8, 0, 0, 0, 0, time.UTC) + + tests := []struct { + version string + expected string + }{ + {"9", "osv-rhel-9-2026-04-08.json.gz"}, + {"8", "osv-rhel-8-2026-04-08.json.gz"}, + {"10", "osv-rhel-10-2026-04-08.json.gz"}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + require.Equal(t, tt.expected, rhelOSVFilename(tt.version, date)) + }) + } +} + func TestOSVFilename(t *testing.T) { date := time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC) @@ -243,7 +377,7 @@ func TestSyncOSVFaultTolerance(t *testing.T) { return errors.New("mock download failure") } - result, err := syncOSVWithDownloader(context.Background(), tmpDir, versions, date, release, mockDownload) + result, err := syncOSVWithDownloader(context.Background(), tmpDir, versions, date, release, mockDownload, osvFilename) require.NoError(t, err) require.NotNil(t, result)