From 9d995a54272e65673158fe0dc8308cda160b40b1 Mon Sep 17 00:00:00 2001 From: Brad Lugo Date: Wed, 11 Mar 2026 01:12:11 -0700 Subject: [PATCH] node scanning wip --- .../compliance/node_inventory_handler_impl.go | 97 +++++++++--- .../compliance/node_inventory_handler_test.go | 144 +++++++++++++++++- sensor/common/compliance/node_matcher.go | 53 +++++-- 3 files changed, 258 insertions(+), 36 deletions(-) diff --git a/sensor/common/compliance/node_inventory_handler_impl.go b/sensor/common/compliance/node_inventory_handler_impl.go index 7c38e2463beb5..41b2a89bc1ebb 100644 --- a/sensor/common/compliance/node_inventory_handler_impl.go +++ b/sensor/common/compliance/node_inventory_handler_impl.go @@ -2,7 +2,9 @@ package compliance import ( "context" + "fmt" "strconv" + "strings" "github.com/pkg/errors" "github.com/quay/claircore/indexer/controller" @@ -31,7 +33,11 @@ var ( const ( rhcosFullName = "Red Hat Enterprise Linux CoreOS" + // Golden image repository for RHCOS < 4.19 (OCP-style versions like 418.94.xxx) goldenKey = rhcc.RepositoryKey + + // RHEL CPE repository for RHCOS 4.19+ (RHEL-style versions like 9.6.xxx) + rhelCPEKey = "rhel-cpe-repository" ) var ( @@ -419,12 +425,12 @@ func (c *nodeInventoryHandlerImpl) sendNodeIndex(toC chan<- *message.ExpiringMes return } - isRHCOS, version, err := c.nodeRHCOSMatcher.GetRHCOSVersion(indexWrap.NodeName) + isRHCOS, ocpVersion, version, err := c.nodeRHCOSMatcher.GetRHCOSVersion(indexWrap.NodeName) if err != nil { log.Warnf("Unable to determine RHCOS version for node %q: %v", indexWrap.NodeName, err) isRHCOS = false } - log.Debugf("Node=%q discovered RHCOS=%t rhcos-version=%q", indexWrap.NodeName, isRHCOS, version) + log.Debugf("Node=%q discovered RHCOS=%t ocp-version=%q rhcos-version=%q", indexWrap.NodeName, isRHCOS, ocpVersion, version) select { case <-c.stopper.Flow().StopRequested(): @@ -441,7 +447,7 @@ func (c *nodeInventoryHandlerImpl) sendNodeIndex(toC chan<- *message.ExpiringMes arch = extractArch(indexWrap.IndexReport) c.archCache[indexWrap.NodeName] = arch } - log.Debugf("Attaching OCI entry for 'rhcos' to index-report for node %s: version=%s, arch=%s", indexWrap.NodeName, version, arch) + log.Debugf("Attaching OCI entry for 'rhcos' to index-report for node %s: ocp-version=%s, version=%s, arch=%s", indexWrap.NodeName, ocpVersion, version, arch) irWrapperFunc = attachRPMtoRHCOS } toC <- message.New(¢ral.MsgFromSensor{ @@ -452,7 +458,7 @@ func (c *nodeInventoryHandlerImpl) sendNodeIndex(toC chan<- *message.ExpiringMes // This can be changed to CREATE or UPDATE for Sensor 4.8 or when Central 4.6 is out of support. Action: central.ResourceAction_UNSET_ACTION_RESOURCE, Resource: ¢ral.SensorEvent_IndexReport{ - IndexReport: irWrapperFunc(version, arch, indexWrap.IndexReport), + IndexReport: irWrapperFunc(ocpVersion, version, arch, indexWrap.IndexReport), }, }, }, @@ -472,7 +478,37 @@ func normalizeVersion(version string) []int32 { return []int32{v[0], v[1], 0, 0, 0, 0, 0, 0, 0, 0} } -func noop(_, _ string, rpm *v4.IndexReport) *v4.IndexReport { +// extractRHELMajorVersion extracts the major version from a RHEL-style version string. +// For example, "9.6.20260217-1" returns "9". +func extractRHELMajorVersion(version string) string { + parts := strings.SplitN(version, ".", 2) + if len(parts) >= 1 { + return parts[0] + } + return "9" // Default to RHEL 9 if parsing fails +} + +// isNewRHCOSVersionSchema returns true if the RHCOS version follows the RHEL-style +// (9.x, 10.x) rather than the OCP-style (4xx.x). +// RHCOS 4.19+ reports versions like "9.6.20260217-1" instead of "418.94.202501011408". +func isNewRHCOSVersionSchema(version string) bool { + if len(version) == 0 { + return false + } + parts := strings.SplitN(version, ".", 2) + if len(parts) < 2 { + return false + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return false + } + // RHEL versions are 8.x, 9.x, 10.x (single/double digit major) + // OCP-derived versions are 412.x, 413.x, 418.x (triple digit major) + return major < 100 +} + +func noop(_, _, _ string, rpm *v4.IndexReport) *v4.IndexReport { return rpm } @@ -506,7 +542,7 @@ func extractArch(rpm *v4.IndexReport) string { return "" } -func attachRPMtoRHCOS(version, arch string, rpm *v4.IndexReport) *v4.IndexReport { +func attachRPMtoRHCOS(ocpVersion, version, arch string, rpm *v4.IndexReport) *v4.IndexReport { idCandidate := 600 // Arbitrary selected. RHCOS has usually 520-560 rpm packages. envs := rpm.GetContents().GetEnvironments() if len(envs) == 0 { @@ -516,7 +552,7 @@ func attachRPMtoRHCOS(version, arch string, rpm *v4.IndexReport) *v4.IndexReport idCandidate++ } strID := strconv.Itoa(idCandidate) - oci := buildRHCOSIndexReport(strID, version, arch) + oci := buildRHCOSIndexReport(strID, ocpVersion, version, arch) for pkgID, pkg := range rpm.GetContents().GetPackages() { oci.Contents.Packages[pkgID] = pkg } @@ -536,7 +572,28 @@ func attachRPMtoRHCOS(version, arch string, rpm *v4.IndexReport) *v4.IndexReport return oci } -func buildRHCOSIndexReport(Id, version, arch string) *v4.IndexReport { +// buildRHCOSIndexReport creates an IndexReport for the synthetic "rhcos" package. +// For RHCOS 4.19+ (new schema with RHEL-style versions like "9.6.xxx"), it uses +// the RHEL CPE repository instead of the golden image to enable proper vulnerability +// matching against RHEL-based CVE data. +func buildRHCOSIndexReport(Id, ocpVersion, version, arch string) *v4.IndexReport { + // Determine repository configuration based on version schema + repoKey := goldenKey + repoName := goldenName + repoURI := goldenURI + repoCPE := "cpe:2.3:*" + + if isNewRHCOSVersionSchema(version) { + // RHCOS 4.19+ uses RHEL-style versions (e.g., "9.6.xxx") + // Use RHEL CPE repository for proper vulnerability matching + rhelMajor := extractRHELMajorVersion(version) + repoKey = rhelCPEKey + repoName = fmt.Sprintf("cpe:/o:redhat:enterprise_linux:%s::baseos", rhelMajor) + repoURI = "" // RHEL CPE repos don't have a URI + repoCPE = fmt.Sprintf("cpe:2.3:o:redhat:enterprise_linux:%s:*:baseos:*:*:*:*:*", rhelMajor) + log.Debugf("RHCOS 4.19+ detected (version=%s): using RHEL %s CPE repository for vulnerability matching", version, rhelMajor) + } + return &v4.IndexReport{ // This hashId is arbitrary. The value doesn't play a role for matcher, but must be valid sha256. HashId: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", @@ -559,10 +616,10 @@ func buildRHCOSIndexReport(Id, version, arch string) *v4.IndexReport { Name: "rhcos", Kind: "source", Version: version, - Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API + Cpe: repoCPE, }, Arch: arch, - Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API + Cpe: repoCPE, }, }, PackagesDEPRECATED: []*v4.Package{ @@ -580,28 +637,28 @@ func buildRHCOSIndexReport(Id, version, arch string) *v4.IndexReport { Name: "rhcos", Kind: "source", Version: version, - Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API + Cpe: repoCPE, }, Arch: arch, - Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API + Cpe: repoCPE, }, }, Repositories: map[string]*v4.Repository{ Id: { Id: Id, - Name: goldenName, - Key: goldenKey, - Uri: goldenURI, - Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API + Name: repoName, + Key: repoKey, + Uri: repoURI, + Cpe: repoCPE, }, }, RepositoriesDEPRECATED: []*v4.Repository{ { Id: Id, - Name: goldenName, - Key: goldenKey, - Uri: goldenURI, - Cpe: "cpe:2.3:*", // required to pass validation of scanner V4 API + Name: repoName, + Key: repoKey, + Uri: repoURI, + Cpe: repoCPE, }, }, // Environments must be present for the matcher to discover records diff --git a/sensor/common/compliance/node_inventory_handler_test.go b/sensor/common/compliance/node_inventory_handler_test.go index ef2884645dcb6..8a6c242f861f9 100644 --- a/sensor/common/compliance/node_inventory_handler_test.go +++ b/sensor/common/compliance/node_inventory_handler_test.go @@ -160,10 +160,109 @@ func (s *NodeInventoryHandlerTestSuite) TestExtractArch() { } } +func (s *NodeInventoryHandlerTestSuite) TestIsNewRHCOSVersionSchema() { + cases := map[string]struct { + version string + expected bool + }{ + "empty string": { + version: "", + expected: false, + }, + "old schema - 4.17 derived": { + version: "417.94.202501011408", + expected: false, + }, + "old schema - 4.18 derived": { + version: "418.94.202501011408-0", + expected: false, + }, + "old schema - 4.12 derived": { + version: "412.86.202212081411-0", + expected: false, + }, + "new schema - RHEL 9.6 based": { + version: "9.6.20260204", + expected: true, + }, + "new schema - RHEL 9.6 with suffix": { + version: "9.6.20260217-1", + expected: true, + }, + "new schema - RHEL 8.x based": { + version: "8.10.20250115", + expected: true, + }, + "new schema - RHEL 10.x based": { + version: "10.0.20260115", + expected: true, + }, + "malformed - no dots": { + version: "94176", + expected: false, + }, + "malformed - non-numeric": { + version: "abc.def.xyz", + expected: false, + }, + } + for name, tc := range cases { + s.Run(name, func() { + got := isNewRHCOSVersionSchema(tc.version) + s.Equal(tc.expected, got, "isNewRHCOSVersionSchema(%q) = %v, want %v", tc.version, got, tc.expected) + }) + } +} + +func (s *NodeInventoryHandlerTestSuite) TestBuildRHCOSIndexReportCPERepository() { + cases := map[string]struct { + ocpVersion string + rhcosVersion string + expectedVersion string + expectedRepoKey string + expectedRepoCPE string + }{ + "old schema - uses golden image": { + ocpVersion: "4.17", + rhcosVersion: "417.94.202501071621-0", + expectedVersion: "417.94.202501071621-0", + expectedRepoKey: goldenKey, + expectedRepoCPE: "cpe:2.3:*", + }, + "new schema RHEL 9 - uses RHEL CPE": { + ocpVersion: "4.21", + rhcosVersion: "9.6.20260217-1", + expectedVersion: "9.6.20260217-1", + expectedRepoKey: "rhel-cpe-repository", + expectedRepoCPE: "cpe:2.3:o:redhat:enterprise_linux:9:*:baseos:*:*:*:*:*", + }, + "new schema RHEL 10 - uses RHEL CPE": { + ocpVersion: "4.25", + rhcosVersion: "10.0.20270115", + expectedVersion: "10.0.20270115", + expectedRepoKey: "rhel-cpe-repository", + expectedRepoCPE: "cpe:2.3:o:redhat:enterprise_linux:10:*:baseos:*:*:*:*:*", + }, + } + for name, tc := range cases { + s.Run(name, func() { + got := buildRHCOSIndexReport("1", tc.ocpVersion, tc.rhcosVersion, "x86_64") + rhcosPkg := got.GetContents().GetPackages()["1"] + s.Require().NotNil(rhcosPkg, "rhcos package should exist") + s.Equal(tc.expectedVersion, rhcosPkg.GetVersion(), "rhcos package version should match expected") + + repo := got.GetContents().GetRepositories()["1"] + s.Require().NotNil(repo, "repository should exist") + s.Equal(tc.expectedRepoKey, repo.GetKey(), "repository key should match expected") + s.Equal(tc.expectedRepoCPE, repo.GetCpe(), "repository CPE should match expected") + }) + } +} + func (s *NodeInventoryHandlerTestSuite) TestAttachRPMtoRHCOS() { arch := "x86_64" rpmIR := fakeNodeIndex(arch) - got := attachRPMtoRHCOS("417.94.202501071621-0", arch, rpmIR) + got := attachRPMtoRHCOS("4.17", "417.94.202501071621-0", arch, rpmIR) s.Lenf(got.GetContents().GetPackages(), len(rpmIR.GetContents().GetPackages())+1, "IR should have 1 extra package") s.Lenf(got.GetContents().GetEnvironments(), len(rpmIR.GetContents().GetEnvironments())+1, "IR should have 1 extra envinronment") @@ -214,6 +313,33 @@ func (s *NodeInventoryHandlerTestSuite) TestAttachRPMtoRHCOS() { s.Equal(goldenURI, rhcosRepo.GetUri()) } +func (s *NodeInventoryHandlerTestSuite) TestAttachRPMtoRHCOS_NewSchema() { + arch := "x86_64" + rpmIR := fakeNodeIndex(arch) + // Test with RHCOS 4.21 which uses RHEL-style versioning (9.6.xxx) + got := attachRPMtoRHCOS("4.21", "9.6.20260217-1", arch, rpmIR) + + var rhcosPKG *v4.Package + for _, p := range got.GetContents().GetPackages() { + if p.GetName() == "rhcos" { + rhcosPKG = p + break + } + } + s.Require().NotNil(rhcosPKG, "the 'rhcos' pkg should exist in node index") + s.Equal("rhcos", rhcosPKG.GetName()) + // For new schema, version should NOT be prefixed - it uses RHEL CPE repository instead + s.Equal("9.6.20260217-1", rhcosPKG.GetVersion()) + // CPE should be RHEL 9 based + s.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:baseos:*:*:*:*:*", rhcosPKG.GetCpe()) + + // Verify the RHCOS repository uses RHEL CPE (ID "600" is the RHCOS package/repo ID) + rhcosRepo := got.GetContents().GetRepositories()["600"] + s.Require().NotNil(rhcosRepo, "RHCOS repository should exist") + s.Equal("rhel-cpe-repository", rhcosRepo.GetKey()) + s.Equal("cpe:/o:redhat:enterprise_linux:9::baseos", rhcosRepo.GetName()) +} + func (s *NodeInventoryHandlerTestSuite) TestCapabilities() { inventories := make(chan *storage.NodeInventory) defer close(inventories) @@ -876,10 +1002,18 @@ func (c *mockNeverHitNodeIDMatcher) GetNodeID(_ string) (string, error) { return "", errors.New("cannot find node") } -// mockNeverHitNodeIDMatcher simulates inability to find a node when GetNodeResource is called +// mockRHCOSNodeMatcher simulates RHCOS version detection type mockRHCOSNodeMatcher struct{} -// GetRHCOSVersion always identifies as RHCOS and provides a valid version -func (c *mockRHCOSNodeMatcher) GetRHCOSVersion(_ string) (bool, string, error) { - return true, "417.94.202412120651-0", nil +// GetRHCOSVersion always identifies as RHCOS and provides a valid OCP-style version +func (c *mockRHCOSNodeMatcher) GetRHCOSVersion(_ string) (bool, string, string, error) { + return true, "4.17", "417.94.202412120651-0", nil +} + +// mockRHCOSNewSchemaNodeMatcher simulates RHCOS 4.19+ version detection with RHEL-style version +type mockRHCOSNewSchemaNodeMatcher struct{} + +// GetRHCOSVersion identifies as RHCOS 4.19+ and provides a RHEL-style version +func (c *mockRHCOSNewSchemaNodeMatcher) GetRHCOSVersion(_ string) (bool, string, string, error) { + return true, "4.21", "9.6.20260217-1", nil } diff --git a/sensor/common/compliance/node_matcher.go b/sensor/common/compliance/node_matcher.go index eb49ce59d859c..b970f9dd8e770 100644 --- a/sensor/common/compliance/node_matcher.go +++ b/sensor/common/compliance/node_matcher.go @@ -8,7 +8,14 @@ import ( "github.com/stackrox/rox/sensor/common/store" ) -var rhcosOSImageRegexp = regexp.MustCompile(`(Red Hat Enterprise Linux) (CoreOS) ([0-9.-]+)`) +// rhcosOSImageRegexp captures: "Red Hat Enterprise Linux CoreOS .[.]" +// Groups: +// - r[1]: "Red Hat Enterprise Linux" +// - r[2]: "CoreOS" +// - r[3]: OCP major version (e.g., "4") +// - r[4]: OCP minor version (e.g., "19") +// - r[5]: RHCOS version suffix (optional, e.g., ".0" or empty) +var rhcosOSImageRegexp = regexp.MustCompile(`(Red Hat Enterprise Linux) (CoreOS) ([0-9]+)\.([0-9]+)(\.?[0-9.-]*)`) // NodeIDMatcher helps finding NodeWrap by name type NodeIDMatcher interface { @@ -16,8 +23,13 @@ type NodeIDMatcher interface { } // NodeRHCOSMatcher is used to check whether Node is RHCOS and obtain its version +// GetRHCOSVersion returns: +// - isRHCOS: true if the node is running RHCOS +// - ocpVersion: the OCP major.minor version (e.g., "4.19") +// - rhcosVersion: the RHCOS version to use for matching +// - err: any error encountered type NodeRHCOSMatcher interface { - GetRHCOSVersion(name string) (bool, string, error) + GetRHCOSVersion(name string) (isRHCOS bool, ocpVersion string, rhcosVersion string, err error) } // NodeIDMatcherImpl finds Node by name within NodeStore @@ -40,21 +52,40 @@ func (c *NodeIDMatcherImpl) GetNodeID(nodename string) (string, error) { return "", fmt.Errorf("cannot find node with name '%s'", nodename) } -// GetRHCOSVersion returns bool=true if node is running RHCOS, and it's version reported -// by the orchestrator. -func (c *NodeIDMatcherImpl) GetRHCOSVersion(name string) (bool, string, error) { +// GetRHCOSVersion returns information about the RHCOS version running on the node. +// It parses the osImage string to extract the OCP version and RHCOS version. +// For RHCOS 4.19+, the osImage format is "Red Hat Enterprise Linux CoreOS 4.19.0" +// and the actual RHCOS version (e.g., "9.6.20260217-1") comes from node inventory. +// For older RHCOS, the osImage contains the OCP-derived version directly (e.g., "418.94.xxx"). +func (c *NodeIDMatcherImpl) GetRHCOSVersion(name string) (isRHCOS bool, ocpVersion string, rhcosVersion string, err error) { n := c.nodeStore.GetNode(name) if n == nil { - return false, "", fmt.Errorf("cannot find node with name %q", name) + return false, "", "", fmt.Errorf("cannot find node with name %q", name) } osImageRef := n.GetOsImage() if !strings.HasPrefix(osImageRef, rhcosFullName) { - return false, osImageRef, nil + return false, "", osImageRef, nil } r := rhcosOSImageRegexp.FindStringSubmatch(osImageRef) - // r[0] contains the entire osImageRef - if len(r) < 4 { - return false, osImageRef, fmt.Errorf("valid RHCOS prefix found, but cannot parse version from: %s", osImageRef) + // r[0] = entire match + // r[1] = "Red Hat Enterprise Linux" + // r[2] = "CoreOS" + // r[3] = OCP major version (e.g., "4") + // r[4] = OCP minor version (e.g., "19") + // r[5] = RHCOS version suffix (e.g., ".0" or "") + if len(r) < 5 { + return false, "", osImageRef, fmt.Errorf("valid RHCOS prefix found, but cannot parse version from: %s", osImageRef) } - return true, r[3], nil + ocpVersion = fmt.Sprintf("%s.%s", r[3], r[4]) + + // For the RHCOS version from osImage, we combine what's in the osImage. + // For older nodes (pre-4.19), this contains the full version like "418.94.xxx". + // For 4.19+, this is just the OCP version like "4.19.0" - the actual RHCOS + // version from /etc/os-release (e.g., "9.6.xxx") is provided separately via node inventory. + rhcosVersionFromImage := r[3] + "." + r[4] + if len(r) > 5 && r[5] != "" { + // r[5] already includes the leading dot if present + rhcosVersionFromImage = r[3] + "." + r[4] + r[5] + } + return true, ocpVersion, rhcosVersionFromImage, nil }