diff --git a/bundle/manifests/compliance-operator.clusterserviceversion.yaml b/bundle/manifests/compliance-operator.clusterserviceversion.yaml index 4b1ebd023c..c70c15b649 100644 --- a/bundle/manifests/compliance-operator.clusterserviceversion.yaml +++ b/bundle/manifests/compliance-operator.clusterserviceversion.yaml @@ -1560,6 +1560,7 @@ spec: - watch - list - update + - patch - apiGroups: - compliance.openshift.io resources: diff --git a/config/rbac/profileparser_role.yaml b/config/rbac/profileparser_role.yaml index a276c3a1aa..8aa279107f 100644 --- a/config/rbac/profileparser_role.yaml +++ b/config/rbac/profileparser_role.yaml @@ -15,6 +15,7 @@ rules: - watch - list - update + - patch - apiGroups: - compliance.openshift.io resources: diff --git a/pkg/apis/compliance/v1alpha1/profilebundle_types.go b/pkg/apis/compliance/v1alpha1/profilebundle_types.go index e16eb45b76..468212e8a1 100644 --- a/pkg/apis/compliance/v1alpha1/profilebundle_types.go +++ b/pkg/apis/compliance/v1alpha1/profilebundle_types.go @@ -19,6 +19,10 @@ const ProfileImageDigestAnnotation = "compliance.openshift.io/image-digest" // ProfileStatusAnnotation is the parsed out status from the data stream const ProfileStatusAnnotation = "compliance.openshift.io/profile-status" +// XCCDFGroupsAnnotation stores a comma-separated list of all XCCDF Group IDs +// found in the datastream. Used for re-enabling groups in TailoredProfiles. +const XCCDFGroupsAnnotation = "compliance.openshift.io/xccdf-groups" + // DataStreamStatusType is the type for the data stream status type DataStreamStatusType string diff --git a/pkg/profileparser/profileparser.go b/pkg/profileparser/profileparser.go index 6ca259f659..fce0039f51 100644 --- a/pkg/profileparser/profileparser.go +++ b/pkg/profileparser/profileparser.go @@ -55,6 +55,12 @@ func GetPrefixedName(pbName, objName string) string { } func ParseBundle(contentDom *xmlquery.Node, pb *cmpv1alpha1.ProfileBundle, pcfg *ParserConfig) error { + // Extract all XCCDF Group IDs from the datastream and store in ProfileBundle annotation + if err := extractAndStoreXCCDFGroups(contentDom, pb, pcfg); err != nil { + log.Error(err, "Failed to extract XCCDF groups") + // Don't fail the whole parse if group extraction fails + } + // One go routine per type errChan := make(chan error) done := make(chan string) @@ -950,3 +956,44 @@ func appendKeyWithSep(annotations map[string]string, key, item, sep string) { } annotations[key] = strings.Join(append(curList, item), sep) } + +// extractAndStoreXCCDFGroups extracts all XCCDF Group IDs from the datastream +// and stores them as a comma-separated list in the ProfileBundle annotation. +// This allows TailoredProfiles to re-enable all groups when extending a parent profile. +func extractAndStoreXCCDFGroups(contentDom *xmlquery.Node, pb *cmpv1alpha1.ProfileBundle, pcfg *ParserConfig) error { + // Find all Group elements in the datastream + groupNodes := xmlquery.Find(contentDom, "//xccdf-1.2:Group") + if len(groupNodes) == 0 { + log.Info("No XCCDF groups found in datastream") + return nil + } + + groupIDs := make([]string, 0, len(groupNodes)) + for _, groupNode := range groupNodes { + id := groupNode.SelectAttr("id") + if id != "" { + groupIDs = append(groupIDs, id) + } + } + + if len(groupIDs) == 0 { + return nil + } + + // Store as comma-separated list in ProfileBundle annotation + patch := runtimeclient.MergeFrom(pb.DeepCopy()) + annotations := pb.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations[cmpv1alpha1.XCCDFGroupsAnnotation] = strings.Join(groupIDs, ",") + pb.SetAnnotations(annotations) + + // Patch the ProfileBundle with the new annotation + if err := pcfg.Client.Patch(context.TODO(), pb, patch); err != nil { + return fmt.Errorf("failed to patch ProfileBundle with XCCDF groups: %w", err) + } + + log.Info("Extracted XCCDF groups", "count", len(groupIDs), "profileBundle", pb.Name) + return nil +} diff --git a/pkg/profileparser/profileparser_test.go b/pkg/profileparser/profileparser_test.go index 950185810d..896127e7cb 100644 --- a/pkg/profileparser/profileparser_test.go +++ b/pkg/profileparser/profileparser_test.go @@ -3,7 +3,9 @@ package profileparser import ( "context" "os" + "strings" + compapis "github.com/ComplianceAsCode/compliance-operator/pkg/apis" cmpv1alpha1 "github.com/ComplianceAsCode/compliance-operator/pkg/apis/compliance/v1alpha1" "github.com/antchfx/xmlquery" "github.com/go-logr/zapr" @@ -19,6 +21,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/storage/names" runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) // FIXME: code duplication @@ -712,3 +715,60 @@ var _ = Describe("Testing CPE string parsing in isolation", func() { }) }) }) + +var _ = Describe("Testing extractAndStoreXCCDFGroups", func() { + var ( + testPb *cmpv1alpha1.ProfileBundle + testClient runtimeclient.Client + testPcfg *ParserConfig + ) + + BeforeEach(func() { + testScheme := k8sruntime.NewScheme() + err := compapis.AddToScheme(testScheme) + Expect(err).To(BeNil()) + + testPb = &cmpv1alpha1.ProfileBundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pb", + Namespace: testNamespace, + }, + } + + testClient = fake.NewClientBuilder().WithScheme(testScheme).WithObjects(testPb).Build() + testPcfg = &ParserConfig{Client: testClient} + }) + + It("Extracts and stores group IDs from datastream", func() { + xmlContent := ` + + + +` + contentDom, err := xmlquery.Parse(strings.NewReader(xmlContent)) + Expect(err).To(BeNil()) + + err = extractAndStoreXCCDFGroups(contentDom, testPb, testPcfg) + Expect(err).To(BeNil()) + + updated := &cmpv1alpha1.ProfileBundle{} + err = testClient.Get(context.TODO(), types.NamespacedName{Name: testPb.Name, Namespace: testPb.Namespace}, updated) + Expect(err).To(BeNil()) + + annotations := updated.GetAnnotations() + Expect(annotations).To(HaveKey(cmpv1alpha1.XCCDFGroupsAnnotation)) + Expect(annotations[cmpv1alpha1.XCCDFGroupsAnnotation]).To(Equal("group1,group2")) + }) + + It("Returns nil when no groups are found", func() { + xmlContent := ` + + +` + contentDom, err := xmlquery.Parse(strings.NewReader(xmlContent)) + Expect(err).To(BeNil()) + + err = extractAndStoreXCCDFGroups(contentDom, testPb, testPcfg) + Expect(err).To(BeNil()) + }) +}) diff --git a/pkg/xccdf/tailoring.go b/pkg/xccdf/tailoring.go index 7c9cf85528..a986276853 100644 --- a/pkg/xccdf/tailoring.go +++ b/pkg/xccdf/tailoring.go @@ -10,8 +10,11 @@ import ( "github.com/google/uuid" cmpv1alpha1 "github.com/ComplianceAsCode/compliance-operator/pkg/apis/compliance/v1alpha1" + logf "sigs.k8s.io/controller-runtime/pkg/log" ) +var log = logf.Log.WithName("xccdf") + const ( // XMLHeader is the header for the XML doc XMLHeader string = `` @@ -146,8 +149,19 @@ func getSelectElementFromCRRule(rule *cmpv1alpha1.Rule, enable bool) SelectEleme } } -func getSelections(tp *cmpv1alpha1.TailoredProfile, rules map[string]*cmpv1alpha1.Rule) []SelectElement { +func getSelections(tp *cmpv1alpha1.TailoredProfile, rules map[string]*cmpv1alpha1.Rule, groupIDs []string) []SelectElement { selections := []SelectElement{} + + // When extending a profile, enable all XCCDF groups first + // This allows individual rules within deselected groups to be enabled + // Groups are enabled before rules so OpenSCAP processes them in the correct order + for _, groupID := range groupIDs { + selections = append(selections, SelectElement{ + IDRef: groupID, + Selected: true, + }) + } + for _, selection := range tp.Spec.EnableRules { rule := rules[selection.Name] selections = append(selections, getSelectElementFromCRRule(rule, true)) @@ -200,6 +214,29 @@ func getValuesFromVariables(variables []*cmpv1alpha1.Variable) []SetValueElement // TailoredProfileToXML gets an XML string from a TailoredProfile and the corresponding Profile func TailoredProfileToXML(tp *cmpv1alpha1.TailoredProfile, p *cmpv1alpha1.Profile, pb *cmpv1alpha1.ProfileBundle, rules map[string]*cmpv1alpha1.Rule, variables []*cmpv1alpha1.Variable) (string, error) { + if pb == nil { + return "", fmt.Errorf("ProfileBundle cannot be nil") + } + + // Extract group IDs from ProfileBundle annotation if this TP extends a profile + var groupIDs []string + if p != nil { + if pb.Annotations != nil { + if groupsStr, ok := pb.Annotations[cmpv1alpha1.XCCDFGroupsAnnotation]; ok && groupsStr != "" { + groupIDs = strings.Split(groupsStr, ",") + } else { + log.Info("ProfileBundle is missing XCCDF groups annotation - groups will not be enabled in tailoring", + "profileBundle", pb.Name, + "tailoredProfile", tp.Name, + "annotation", cmpv1alpha1.XCCDFGroupsAnnotation) + } + } else { + log.Info("ProfileBundle has no annotations - groups will not be enabled in tailoring", + "profileBundle", pb.Name, + "tailoredProfile", tp.Name) + } + } + tailoring := TailoringElement{ XMLNamespaceURI: XCCDFURI, ID: getTailoringID(tp), @@ -215,7 +252,7 @@ func TailoredProfileToXML(tp *cmpv1alpha1.TailoredProfile, p *cmpv1alpha1.Profil }, Profile: ProfileElement{ ID: GetXCCDFProfileID(tp), - Selections: getSelections(tp, rules), + Selections: getSelections(tp, rules, groupIDs), Values: getValuesFromVariables(variables), }, } diff --git a/tests/e2e/parallel/main_test.go b/tests/e2e/parallel/main_test.go index 4f8ac3a31d..c7d27455d9 100644 --- a/tests/e2e/parallel/main_test.go +++ b/tests/e2e/parallel/main_test.go @@ -73,6 +73,50 @@ func TestProfileVersion(t *testing.T) { } } +func TestProfileBundleXCCDFGroupsAnnotation(t *testing.T) { + t.Parallel() + f := framework.Global + + pbName := framework.GetObjNameFromTest(t) + pb, err := f.CreateProfileBundle(pbName, contentImagePath, framework.RhcosContentFile) + if err != nil { + t.Fatalf("failed to create ProfileBundle: %s", err) + } + defer f.Client.Delete(context.TODO(), pb) + + if err := f.WaitForProfileBundleStatus(pbName, compv1alpha1.DataStreamValid); err != nil { + t.Fatalf("failed waiting for the ProfileBundle to become available: %s", err) + } + + // Get the updated ProfileBundle to check annotations + updatedPb := &compv1alpha1.ProfileBundle{} + if err := f.Client.Get(context.TODO(), types.NamespacedName{Name: pbName, Namespace: f.OperatorNamespace}, updatedPb); err != nil { + t.Fatalf("failed to get ProfileBundle %s: %s", pbName, err) + } + + annotations := updatedPb.GetAnnotations() + if annotations == nil { + t.Fatalf("ProfileBundle %s has no annotations", pbName) + } + + groupsAnnotation, exists := annotations[compv1alpha1.XCCDFGroupsAnnotation] + if !exists { + t.Fatalf("ProfileBundle %s is missing the %s annotation", pbName, compv1alpha1.XCCDFGroupsAnnotation) + } + + if groupsAnnotation == "" { + t.Fatalf("ProfileBundle %s has empty %s annotation", pbName, compv1alpha1.XCCDFGroupsAnnotation) + } + + // Verify it's a comma-separated list with at least one group + groups := strings.Split(groupsAnnotation, ",") + if len(groups) == 0 { + t.Fatalf("ProfileBundle %s has no groups in %s annotation", pbName, compv1alpha1.XCCDFGroupsAnnotation) + } + + t.Logf("ProfileBundle %s has %d XCCDF groups", pbName, len(groups)) +} + func TestProfileModification(t *testing.T) { t.Parallel() f := framework.Global