diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index bff71287993b..213af8dcb32e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -158,6 +158,122 @@ template: pkgname@ubuntu2204: avahi-daemon # Platform-specific overrides ``` +## CEL Checking Engine (Kubernetes/OpenShift) + +CEL (Common Expression Language) provides native Kubernetes resource evaluation without requiring shell access or OVAL checks. Rules using the CEL checking engine are used by the compliance-operator for Kubernetes and OpenShift compliance scanning. + +**Important:** Rules with CEL checks are **excluded** from XCCDF/OVAL DataStreams and are generated as a separate `${PRODUCT}-cel-content.yaml` file. + +### Rule Structure for CEL Checks + +Rules using CEL checks use a **split-file structure** to separate metadata from CEL-specific content: + +- **`rule.yml`** - Contains metadata (title, description, rationale, severity, references, etc.) +- **`cel/shared.yml`** - Contains CEL-specific fields (check_type, inputs, expression) + +This allows rules to support **both CEL and OVAL** checks during migration from OVAL to CEL. + +**Example rule.yml:** +```yaml +documentation_complete: true + +title: 'Rule Title' + +description: |- + Description of what the rule checks. + +rationale: |- + Why this rule matters. + +severity: medium + +ocil: |- # Optional: Manual check instructions + Run the following command: +
$ oc get pods
+ +failure_reason: |- # Optional: Custom failure message + The resource is not properly configured. + +references: # Optional: Same as regular rules + cis@ocp4: 1.2.3 + nist: CM-6 +``` + +**Example cel/shared.yml:** +```yaml +check_type: Platform # Usually Platform for K8s checks + +expression: |- # REQUIRED: CEL expression (must evaluate to boolean) + resource.spec.enabled == true + +inputs: # REQUIRED: Kubernetes resources to evaluate + - name: resource + kubernetes_input_spec: + api_version: v1 + resource: pods + resource_name: my-pod # Optional: specific resource + resource_namespace: default # Optional: specific namespace +``` + +**Note:** The build system automatically detects rules with CEL checks by the presence of the `cel/` directory. + +### CEL Expression Examples + +Simple boolean check: +```yaml +expression: resource.spec.enabled == true +``` + +Check for field absence: +```yaml +expression: !has(resource.spec.insecureField) +``` + +Multiple conditions: +```yaml +expression: |- + resource.spec.replicas >= 3 && + has(resource.spec.securityContext) && + resource.spec.securityContext.runAsNonRoot == true +``` + +### CEL Profile Format + +Profiles that select rules using CEL checks must have `scanner_type: CEL`: + +```yaml +documentation_complete: true + +title: 'CIS VM Extension Benchmark' + +description: |- + Profile description. + +scanner_type: CEL # REQUIRED: Marks this as a CEL profile (excluded from XCCDF) + +selections: + - kubevirt-nonroot-feature-gate-is-enabled + - kubevirt-no-permitted-host-devices +``` + +**Note:** +- Profiles use `scanner_type: CEL` to indicate they target the CEL checking engine and should be excluded from XCCDF/datastream builds. +- A rule can have both `cel/` (for CEL checks) and `template:` (for OVAL checks) to support both checking engines during migration. + +**Important:** Use hyphens rule IDs (Kubernetes naming convention), not underscores. + +### Enabling CEL Content for a Product + +In `products/${PRODUCT}/CMakeLists.txt`: + +```cmake +set(PRODUCT "ocp4") +set(PRODUCT_CEL_ENABLED TRUE) # Enable CEL content generation +ssg_build_product(${PRODUCT}) +``` + +See `docs/manual/developer/12_cel_content.md` for complete CEL documentation. + ## Common Jinja2 Macros Used in rule descriptions, OCIL, fixtext, and warnings fields: @@ -279,19 +395,35 @@ Common selection patterns: ## Build Instructions ```bash -# Build a single product (full build) +# Build a single product (full build, includes CEL content if PRODUCT_CEL_ENABLED) ./build_product ocp4 -# Build data stream only (faster, skips guides and tables) +# Build data stream only (faster, skips guides, tables, and CEL content) +./build_product ocp4 --datastream +# Short form (only builds datastream): +./build_product ocp4 -d +# Legacy form (still supported): ./build_product ocp4 --datastream-only +# Build data stream and CEL content +./build_product ocp4 --datastream --cel-content=ocp4 + +# Build only CEL content (no data stream) +./build_product --cel-content=ocp4 + +# Build CEL content for multiple products +./build_product --cel-content=ocp4,rhel9 + # Build with only specific rules (fastest, for testing individual rules) -./build_product ocp4 --datastream-only --rule-id api_server_tls_security_profile +./build_product ocp4 --datastream --rule-id api_server_tls_security_profile ``` Build output goes to `build/`. The data stream file is at: `build/ssg--ds.xml` +For products with CEL content enabled, the CEL content file is at: +`build/-cel-content.yaml` + ## Guidelines for Claude 1. **Always show proposals before making changes.** Present the full content of any new or modified file and wait for explicit approval. diff --git a/.github/workflows/ocp-test-profiles.yaml b/.github/workflows/ocp-test-profiles.yaml index 5da0a67da393..673d0efc10f6 100644 --- a/.github/workflows/ocp-test-profiles.yaml +++ b/.github/workflows/ocp-test-profiles.yaml @@ -58,7 +58,7 @@ jobs: - name: Build product OCP and RHCOS content if: ${{ steps.ctf.outputs.CTF_OUTPUT_SIZE != '0' && (contains(steps.product.outputs.prop, 'ocp4') || contains(steps.product.outputs.prop, 'rhcos4')) }} - run: ./build_product -d ocp4 rhcos4 + run: ./build_product --datastream ocp4 rhcos4 --cel-content=ocp4 - name: Process list of rules into a list of product-profiles to test if: ${{ steps.ctf.outputs.CTF_OUTPUT_SIZE != '0' && (contains(steps.product.outputs.prop, 'ocp4') || contains(steps.product.outputs.prop, 'rhcos4')) }} diff --git a/Dockerfiles/compliance-operator-content-konflux.Containerfile b/Dockerfiles/compliance-operator-content-konflux.Containerfile index 1ad5605840c1..2dd1802a0c02 100644 --- a/Dockerfiles/compliance-operator-content-konflux.Containerfile +++ b/Dockerfiles/compliance-operator-content-konflux.Containerfile @@ -84,8 +84,8 @@ RUN grep -lr 'documentation_complete: false' ./products | xargs -I '{}' \ # Build the OpenShift and RHCOS content for x86, aarch64 and ppc64le architectures. # Only build OpenShift content for s390x architectures. RUN if [ "$(uname -m)" = "x86_64" ] || [ "$(uname -m)" = "aarch64" ] || [ "$(uname -m)" = "ppc64le" ]; then \ - ./build_product ocp4 rhcos4 --datastream-only; \ - else ./build_product ocp4 --datastream-only; \ + ./build_product ocp4 rhcos4 --datastream --cel-content=ocp4; \ + else ./build_product ocp4 --datastream --cel-content=ocp4; \ fi FROM registry.redhat.io/ubi9/ubi-minimal:latest @@ -110,3 +110,4 @@ LABEL \ WORKDIR / COPY --from=builder /go/src/github.com/ComplianceAsCode/content/LICENSE /licenses/LICENSE COPY --from=builder /go/src/github.com/ComplianceAsCode/content/build/ssg-*-ds.xml . +COPY --from=builder /go/src/github.com/ComplianceAsCode/content/build/*-cel-content.yaml . diff --git a/Dockerfiles/ocp4_content b/Dockerfiles/ocp4_content index 9ab62bf94882..7ec35c995f9f 100644 --- a/Dockerfiles/ocp4_content +++ b/Dockerfiles/ocp4_content @@ -20,6 +20,7 @@ RUN if [ "$(uname -m)" == "x86_64" ] || [ "$(uname -m)" == "aarch64" ]; then \ products/ocp4/profiles/pci-dss-node.profile \ products/ocp4/profiles/pci-dss.profile \ products/ocp4/profiles/cis-node.profile \ + products/ocp4/profiles/cis-vm-extension.profile \ products/ocp4/profiles/cis.profile \ products/ocp4/profiles/moderate-node.profile \ products/ocp4/profiles/moderate.profile \ @@ -41,13 +42,14 @@ RUN if [ "$(uname -m)" == "x86_64" ] || [ "$(uname -m)" == "aarch64" ]; then \ # OpenShift content for ppc64le and s390x architectures since we're not # including any RHCOS profiles on those architectures right now anyway. RUN if [ "$(uname -m)" = "x86_64" ] || [ "$(uname -m)" == "aarch64" ]; then \ - ./build_product ocp4 rhcos4 eks --datastream-only; \ + ./build_product ocp4 rhcos4 eks --datastream --cel-content=ocp4; \ elif [ "$(uname -m)" = "ppc64le" ]; then \ - ./build_product ocp4 rhcos4 --datastream-only; \ - else ./build_product ocp4 --datastream-only; \ + ./build_product ocp4 rhcos4 --datastream --cel-content=ocp4; \ + else ./build_product ocp4 --datastream --cel-content=ocp4; \ fi FROM registry.access.redhat.com/ubi8/ubi-micro:latest WORKDIR / COPY --from=builder /content/build/ssg-*-ds.xml . +COPY --from=builder /content/build/*-cel-content.yaml . diff --git a/Dockerfiles/quay_publish b/Dockerfiles/quay_publish index 0f13a96090b8..abbd579ca23d 100644 --- a/Dockerfiles/quay_publish +++ b/Dockerfiles/quay_publish @@ -3,10 +3,11 @@ FROM fedora:38 as builder RUN dnf -y install cmake make git /usr/bin/python3 python3-pyyaml python3-jinja2 openscap-utils RUN git clone --depth 1 https://github.com/ComplianceAsCode/content WORKDIR /content -RUN ./build_product --datastream-only --debug ocp4 rhcos4 eks +RUN ./build_product --datastream --debug ocp4 rhcos4 eks --cel-content=ocp4 FROM registry.access.redhat.com/ubi8/ubi-minimal WORKDIR / COPY --from=builder /content/build/ssg-ocp4-ds.xml . COPY --from=builder /content/build/ssg-rhcos4-ds.xml . COPY --from=builder /content/build/ssg-eks-ds.xml . +COPY --from=builder /content/build/ocp4-cel-content.yaml . diff --git a/README.md b/README.md index 3540958fa734..0c55f58224ea 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,12 @@ profiles. These are meant to be run on machines to put them into compliance. We recommend using other formats but understand that for some deployment scenarios bash is the only option. +*"CEL content"* refers to compliance content using the Common Expression Language (CEL) +for Kubernetes and OpenShift platforms. CEL content is generated as YAML files and is +designed for native Kubernetes resource evaluation through the compliance-operator, +without requiring shell access to nodes. This format is used for platform-level +compliance checks on container orchestration systems. + ### Why? We want multiple organizations to be able to efficiently develop security diff --git a/applications/openshift-virtualization/group.yml b/applications/openshift-virtualization/group.yml new file mode 100644 index 000000000000..30b60e8a5a15 --- /dev/null +++ b/applications/openshift-virtualization/group.yml @@ -0,0 +1,7 @@ +documentation_complete: true + +title: 'OpenShift Virtualization' + +description: |- + This section contains security recommendations for OpenShift Virtualization + (KubeVirt) configuration and virtual machine management. diff --git a/applications/openshift-virtualization/kubevirt-enforce-trusted-tls-registries/cel/shared.yml b/applications/openshift-virtualization/kubevirt-enforce-trusted-tls-registries/cel/shared.yml new file mode 100644 index 000000000000..8d27751abe5b --- /dev/null +++ b/applications/openshift-virtualization/kubevirt-enforce-trusted-tls-registries/cel/shared.yml @@ -0,0 +1,13 @@ +check_type: Platform + +inputs: + - name: hco + kubernetes_input_spec: + api_version: hco.kubevirt.io/v1beta1 + resource: hyperconvergeds + resource_name: kubevirt-hyperconverged + resource_namespace: openshift-cnv + +expression: |- + !has(hco.spec.storageImport) || + hco.spec.storageImport.insecureRegistries.size() == 0 diff --git a/applications/openshift-virtualization/kubevirt-enforce-trusted-tls-registries/rule.yml b/applications/openshift-virtualization/kubevirt-enforce-trusted-tls-registries/rule.yml new file mode 100644 index 000000000000..960c95ddb10d --- /dev/null +++ b/applications/openshift-virtualization/kubevirt-enforce-trusted-tls-registries/rule.yml @@ -0,0 +1,32 @@ +documentation_complete: true + +title: 'Only Trusted Registries Using TLS Can Be Used' + +description: |- + By only pulling container images from trusted registries using TLS, organizations + can reduce the risk of introducing unknown vulnerabilities or malicious + software into their systems. This helps ensure that their applications and systems + remain secure and stable. All container image registries used by KubeVirt should + require TLS connections to protect the integrity and authenticity of images. + +rationale: |- + When the .spec.storageImport.insecureRegistries field contains entries in + the kubevirt-hyperconverged resource, KubeVirt is configured to allow + connections to container registries that do not use TLS encryption. This creates + a significant security risk as images could be intercepted or tampered with during + transit. Man-in-the-middle attacks could result in malicious images being pulled + and executed within virtual machines. To maintain security, only registries using + TLS should be permitted, and the insecureRegistries list should be empty. + +failure_reason: |- + There are registries not using TLS in '.spec.storageImport.insecureRegistries' in + the 'kubevirt-hyperconverged' resource. + +severity: medium + +ocil_clause: 'insecure registries are configured' + +ocil: |- + Run the following command to check for insecure registries: +
$ oc get hyperconverged kubevirt-hyperconverged -n openshift-cnv -o jsonpath='{.spec.storageImport.insecureRegistries}'
+ The output should be empty or the field should not exist. diff --git a/applications/openshift-virtualization/kubevirt-no-permitted-host-devices/cel/shared.yml b/applications/openshift-virtualization/kubevirt-no-permitted-host-devices/cel/shared.yml new file mode 100644 index 000000000000..34519d674123 --- /dev/null +++ b/applications/openshift-virtualization/kubevirt-no-permitted-host-devices/cel/shared.yml @@ -0,0 +1,22 @@ +check_type: Platform + +inputs: + - name: hcoList + kubernetes_input_spec: + api_version: hco.kubevirt.io/v1beta1 + resource: hyperconvergeds + +expression: | + hcoList.items.filter(h, + h.metadata.name == 'kubevirt-hyperconverged' && + h.metadata.namespace == 'openshift-cnv' + ).size() == 1 && + hcoList.items.filter(h, + h.metadata.name == 'kubevirt-hyperconverged' && + h.metadata.namespace == 'openshift-cnv' + ).all(h, + !has(h.spec.permittedHostDevices) || + h.spec.permittedHostDevices == null || + (has(h.spec.permittedHostDevices.pciHostDevices) && size(h.spec.permittedHostDevices.pciHostDevices) == 0) && + (has(h.spec.permittedHostDevices.mediatedDevices) && size(h.spec.permittedHostDevices.mediatedDevices) == 0) + ) diff --git a/applications/openshift-virtualization/kubevirt-no-permitted-host-devices/rule.yml b/applications/openshift-virtualization/kubevirt-no-permitted-host-devices/rule.yml new file mode 100644 index 000000000000..fc3c8361620b --- /dev/null +++ b/applications/openshift-virtualization/kubevirt-no-permitted-host-devices/rule.yml @@ -0,0 +1,35 @@ +documentation_complete: true + +title: 'KubeVirt Must Not Permit Host Devices' + +description: |- + Host devices should not be permitted to virtualization workloads unless + absolutely necessary for workload execution. Allowing host devices provides + direct access to host hardware, which can introduce security risks including + unauthorized access to sensitive hardware resources, potential for privilege + escalation, and bypass of virtualization security boundaries. + + By default, no host devices should be trusted or permitted for use by + virtualization workloads. + +rationale: |- + The .spec.permittedHostDevices field in the kubevirt-hyperconverged + resource controls which host devices can be used by virtualization workloads. + Permitting host devices allows virtual machines to bypass virtualization boundaries + and directly access host hardware, which introduces significant security risks. + This can lead to unauthorized access to sensitive hardware resources, privilege + escalation opportunities, and potential compromise of the host system. Unless + explicitly required, no host devices should be permitted. + +failure_reason: |- + The '.spec.permittedHostDevices' field is set in the 'kubevirt-hyperconverged' + resource, allowing host devices to be used by virtualization workloads. + +severity: medium + +ocil_clause: 'permittedHostDevices are configured in kubevirt-hyperconverged' + +ocil: |- + Run the following command to check the HyperConverged configuration: +
$ oc get hyperconverged kubevirt-hyperconverged -n openshift-cnv -o jsonpath='{.spec.permittedHostDevices}'
+ The output should be empty or show empty lists for both pciHostDevices and mediatedDevices. diff --git a/applications/openshift-virtualization/kubevirt-no-vms-overcommitting-guest-memory/cel/shared.yml b/applications/openshift-virtualization/kubevirt-no-vms-overcommitting-guest-memory/cel/shared.yml new file mode 100644 index 000000000000..7c24e53fe1b7 --- /dev/null +++ b/applications/openshift-virtualization/kubevirt-no-vms-overcommitting-guest-memory/cel/shared.yml @@ -0,0 +1,15 @@ +check_type: Platform + +inputs: + - name: vms + kubernetes_input_spec: + api_version: kubevirt.io/v1 + resource: VirtualMachine + +expression: | + vms.all(h, + !has(h.spec.template.spec.domain.resources) || + !has(h.spec.template.spec.domain.resources.overcommitGuestOverhead) || + (has(h.spec.template.spec.domain.resources.overcommitGuestOverhead) && + h.spec.template.spec.domain.resources.overcommitGuestOverhead == false) + ) diff --git a/applications/openshift-virtualization/kubevirt-no-vms-overcommitting-guest-memory/rule.yml b/applications/openshift-virtualization/kubevirt-no-vms-overcommitting-guest-memory/rule.yml new file mode 100644 index 000000000000..ae5138f83dbf --- /dev/null +++ b/applications/openshift-virtualization/kubevirt-no-vms-overcommitting-guest-memory/rule.yml @@ -0,0 +1,34 @@ +documentation_complete: true + +title: 'VMs Must Not Overcommit Guest Memory' + +description: |- + The overcommitGuestOverhead configuration option enables the request for + additional virtual machine management memory inside the virt-launcher pod. + The overcommit feature is used to increase virtual machine density on the + node, as long as the virtual machine doesn't request all the memory that it + would need if fully loaded. However, if the VM were to use all of the + memory it could, this would lead to the OpenShift Scheduler killing the + workload. + +rationale: |- + When the .spec.template.spec.domain.resources.overcommitGuestOverhead field is + set to true in the VirtualMachine resource, VMs are allowed to + overcommit KubeVirt's memory which may lead to guests crashing and + interrupting workloads causing malfunctions. To prevent memory-related failures + and ensure workload stability, this setting should not be enabled. + +failure_reason: |- + The '.spec.template.spec.domain.resources.overcommitGuestOverhead' field exists and is + set to "true" in the 'VirtualMachine' resource, allowing VMs to + overcommit KubeVirt's memory which may lead to guests crashing and + interrupting workloads causing malfunctions. + +severity: medium + +ocil_clause: 'VMs have overcommitGuestOverhead set to true' + +ocil: |- + Run the following command to check VirtualMachine configurations: +
$ oc get virtualmachines -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{": "}{.spec.template.spec.domain.resources.overcommitGuestOverhead}{"\n"}{end}'
+ Make sure no VirtualMachine has overcommitGuestOverhead set to true. diff --git a/applications/openshift-virtualization/kubevirt-nonroot-feature-gate-is-enabled/cel/shared.yml b/applications/openshift-virtualization/kubevirt-nonroot-feature-gate-is-enabled/cel/shared.yml new file mode 100644 index 000000000000..d52f2d886ad3 --- /dev/null +++ b/applications/openshift-virtualization/kubevirt-nonroot-feature-gate-is-enabled/cel/shared.yml @@ -0,0 +1,21 @@ +check_type: Platform + +inputs: + - name: hcoList + kubernetes_input_spec: + api_version: hco.kubevirt.io/v1beta1 + resource: hyperconvergeds + +expression: | + hcoList.items.filter(h, + h.metadata.name == 'kubevirt-hyperconverged' && + h.metadata.namespace == 'openshift-cnv' + ).size() == 1 && + hcoList.items.filter(h, + h.metadata.name == 'kubevirt-hyperconverged' && + h.metadata.namespace == 'openshift-cnv' + ).all(h, + has(h.spec.featureGates) && + has(h.spec.featureGates.nonRoot) && + h.spec.featureGates.nonRoot == true + ) diff --git a/applications/openshift-virtualization/kubevirt-nonroot-feature-gate-is-enabled/rule.yml b/applications/openshift-virtualization/kubevirt-nonroot-feature-gate-is-enabled/rule.yml new file mode 100644 index 000000000000..e47c629149de --- /dev/null +++ b/applications/openshift-virtualization/kubevirt-nonroot-feature-gate-is-enabled/rule.yml @@ -0,0 +1,32 @@ +documentation_complete: true + +title: 'KubeVirt nonRoot Feature Gate Must Be Enabled' + +description: |- + The nonRoot feature gate in KubeVirt enables restrictions that prevent + virtual machines from running with root privileges. This feature enforces + security boundaries and helps prevent privilege escalation attacks. All virtual + machines should operate with the minimum necessary privileges, and the nonRoot + feature gate ensures this principle is enforced at the platform level. + +rationale: |- + Unauthorized access to a root account without restrictions implemented by + the nonRoot feature introduces the risk of unintended or unauthorized + access to privilege elevation and the ability to perform administrative + tasks. When the .spec.featureGates.nonRoot field is set to true + in the kubevirt-hyperconverged resource, KubeVirt enforces non-root + execution for virtual machine workloads, significantly reducing the attack + surface and limiting the potential impact of security vulnerabilities. + +failure_reason: |- + The '.spec.featureGates.nonRoot' field is missing or not set to 'true' in + the 'kubevirt-hyperconverged' resource. + +severity: medium + +ocil_clause: 'nonRoot feature gate is not set to true' + +ocil: |- + Run the following command to check the feature gate configuration: +
$ oc get hyperconverged kubevirt-hyperconverged -n openshift-cnv -o jsonpath='{.spec.featureGates.nonRoot}'
+ The output should be true. diff --git a/applications/openshift-virtualization/kubevirt-persistent-reservation-disabled/cel/shared.yml b/applications/openshift-virtualization/kubevirt-persistent-reservation-disabled/cel/shared.yml new file mode 100644 index 000000000000..774b0620dcf4 --- /dev/null +++ b/applications/openshift-virtualization/kubevirt-persistent-reservation-disabled/cel/shared.yml @@ -0,0 +1,21 @@ +check_type: Platform + +inputs: + - name: hcoList + kubernetes_input_spec: + api_version: hco.kubevirt.io/v1beta1 + resource: hyperconvergeds + +expression: | + hcoList.items.filter(h, + h.metadata.name == 'kubevirt-hyperconverged' && + h.metadata.namespace == 'openshift-cnv' + ).size() == 1 && + hcoList.items.filter(h, + h.metadata.name == 'kubevirt-hyperconverged' && + h.metadata.namespace == 'openshift-cnv' + ).all(h, + has(h.spec.featureGates) && + has(h.spec.featureGates.persistentReservation) && + h.spec.featureGates.persistentReservation == false + ) diff --git a/applications/openshift-virtualization/kubevirt-persistent-reservation-disabled/rule.yml b/applications/openshift-virtualization/kubevirt-persistent-reservation-disabled/rule.yml new file mode 100644 index 000000000000..7dcfcaecabff --- /dev/null +++ b/applications/openshift-virtualization/kubevirt-persistent-reservation-disabled/rule.yml @@ -0,0 +1,34 @@ +documentation_complete: true + +title: 'KubeVirt Persistent Reservation Feature Gate Must Be Disabled' + +description: |- + The persistent reservation feature gate in KubeVirt allows virtual machines + to use SCSI persistent reservations, which provide exclusive access to shared + storage. This feature should be disabled unless explicitly required for + workload operation, as it can introduce security risks by allowing VMs to + claim exclusive access to storage resources, potentially impacting availability + and enabling resource manipulation outside normal access controls. + +rationale: |- + The .spec.featureGates.persistentReservation field in the + kubevirt-hyperconverged resource controls whether virtual machines can + use SCSI persistent reservations. When enabled, this feature allows VMs to claim + exclusive access to shared storage resources, which can be exploited to cause + denial of service conditions or manipulate storage access in ways that bypass + normal Kubernetes access controls. Unless this capability is explicitly required + for specific workload requirements, it should remain disabled to minimize the + attack surface. + +failure_reason: |- + The '.spec.featureGates.persistentReservation' field is missing, not set, + or not set to 'false' in the 'kubevirt-hyperconverged' resource. + +severity: medium + +ocil_clause: 'persistentReservation feature gate is not explicitly set to false' + +ocil: |- + Run the following command to check the feature gate configuration: +
$ oc get hyperconverged kubevirt-hyperconverged -n openshift-cnv -o jsonpath='{.spec.featureGates.persistentReservation}'
+ The output should be false. diff --git a/build-scripts/build_cel_content.py b/build-scripts/build_cel_content.py new file mode 100755 index 000000000000..94fcb99633e3 --- /dev/null +++ b/build-scripts/build_cel_content.py @@ -0,0 +1,383 @@ +#!/usr/bin/python3 + +""" +Build CEL content YAML file for compliance scanning. + +This module generates a CEL content file containing rules that use +the Common Expression Language (CEL) scanner instead of OVAL checks. +""" + +import argparse +import logging +import os +import sys +import yaml + +import ssg.build_yaml +import ssg.products +import ssg.utils + +MESSAGE_FORMAT = "%(levelname)s: %(message)s" + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Generates CEL content YAML file from resolved rules" + ) + parser.add_argument( + "--resolved-rules-dir", required=True, + help="Directory containing resolved rule YAML files. " + "e.g.: ~/scap-security-guide/build/rhel9/rules" + ) + parser.add_argument( + "--profiles-dir", required=True, + help="Directory containing resolved profile YAML files. " + "e.g.: ~/scap-security-guide/build/ocp4/profiles" + ) + parser.add_argument( + "--product-yaml", required=True, + help="YAML file with information about the product we are building. " + "e.g.: ~/scap-security-guide/build/ocp4/product.yml" + ) + parser.add_argument( + "--output", required=True, + help="Output CEL content YAML file. " + "e.g.: ~/scap-security-guide/build/ocp4/ssg-ocp4-cel-content.yaml" + ) + parser.add_argument( + "--log", + action="store", + default="WARNING", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="write debug information to the log up to the LOG_LEVEL.", + ) + return parser.parse_args() + + +def setup_logging(log_level_str): + numeric_level = getattr(logging, log_level_str.upper(), None) + if not isinstance(numeric_level, int): + raise ValueError("Invalid log level: {}".format(log_level_str)) + logging.basicConfig(format=MESSAGE_FORMAT, level=numeric_level) + + +def load_cel_rules(rules_dir): + """ + Load all rules that use the CEL checking engine. + + Args: + rules_dir: Directory containing resolved rule JSON files + + Returns: + dict: Dictionary of rule_id -> rule object for rules with CEL checks + + Raises: + ValueError: If a rule with CEL checks is missing required fields + """ + cel_rules = {} + + if not os.path.isdir(rules_dir): + return cel_rules + + for rule_file in os.listdir(rules_dir): + rule_path = os.path.join(rules_dir, rule_file) + try: + rule = ssg.build_yaml.Rule.from_compiled_json(rule_path) + + # Check if this rule has CEL checks by looking for CEL-specific fields + # A rule uses CEL if it has both expression and inputs + # (loaded from cel/shared.yml during rule compilation) + has_expression = hasattr(rule, 'expression') and rule.expression + has_inputs = hasattr(rule, 'inputs') and rule.inputs + + if has_expression and has_inputs: + # Validate required CEL fields + rule_name = rule_id_to_name(rule.id_) + + if not hasattr(rule, 'check_type') or not rule.check_type: + logging.warning( + f"Rule '{rule_name}' with CEL checks in {rule_file} has no check_type, defaulting to 'Platform'" + ) + rule.check_type = 'Platform' + + cel_rules[rule.id_] = rule + except ssg.build_yaml.DocumentationNotComplete: + # Skip documentation-incomplete rules in non-debug builds + continue + except ValueError: + # Re-raise validation errors + raise + except Exception as e: + logging.warning("Failed to load rule from %s: %s", rule_file, e) + continue + + return cel_rules + + +def load_profiles(profiles_dir, cel_rule_ids): + """ + Load profiles that target the CEL checking engine (scanner_type: CEL). + + Args: + profiles_dir: Directory containing profile YAML files + cel_rule_ids: Set of rule IDs that have CEL checks + + Returns: + list: List of profile objects targeting CEL + + Raises: + ValueError: If a profile targeting CEL is missing required fields + """ + profiles = [] + + if not os.path.isdir(profiles_dir): + return profiles + + for profile_file in os.listdir(profiles_dir): + profile_path = os.path.join(profiles_dir, profile_file) + try: + profile = ssg.build_yaml.Profile.from_compiled_json(profile_path) + + # Only load profiles targeting the CEL checking engine + if hasattr(profile, 'scanner_type') and profile.scanner_type == 'CEL': + # Validate required profile fields + profile_name = rule_id_to_name(profile.id_) + + if not hasattr(profile, 'selected') or not profile.selected: + raise ValueError( + f"Profile '{profile_name}' targeting CEL in {profile_file} has no rules" + ) + + profiles.append(profile) + except ValueError: + # Re-raise validation errors + raise + except Exception as e: + logging.warning("Failed to load profile from %s: %s", profile_file, e) + continue + + return profiles + + +def rule_id_to_name(rule_id): + """Convert rule_id with underscores to name with hyphens.""" + return rule_id.replace('_', '-') + + +def extract_controls_from_references(references): + """ + Extract controls from references dict, keeping original keys. + + Args: + references: Dictionary of references like {"cis@ocp4": ["1.2.3"], "nist": ["AC-6"]} + + Returns: + dict: Controls dictionary grouped by framework + """ + if not references: + return {} + + controls = {} + for ref_key, ref_values in references.items(): + # Keep the original key format (e.g., "cis@ocp4", "nist") + if isinstance(ref_values, list): + controls[ref_key] = ref_values + elif isinstance(ref_values, str): + controls[ref_key] = [ref_values] + + return controls + + +def convert_inputs_to_camelcase(inputs): + """ + Convert kubernetes_input_spec fields from snake_case to camelCase for CRD compatibility. + + Args: + inputs: List of input dictionaries + + Returns: + list: Inputs with camelCase field names + """ + if not inputs: + return inputs + + converted_inputs = [] + for input_item in inputs: + converted_item = dict(input_item) + if 'kubernetes_input_spec' in converted_item: + spec = converted_item['kubernetes_input_spec'] + camel_spec = {} + + # Convert snake_case keys to camelCase + key_mapping = { + 'api_version': 'apiVersion', + 'resource_name': 'resourceName', + 'resource_namespace': 'resourceNamespace', + } + + for key, value in spec.items(): + camel_key = key_mapping.get(key, key) + camel_spec[camel_key] = value + + converted_item['kubernetesInputSpec'] = camel_spec + del converted_item['kubernetes_input_spec'] + + converted_inputs.append(converted_item) + + return converted_inputs + + +def rule_to_cel_dict(rule): + """ + Convert a Rule object to CEL content dictionary format. + + Args: + rule: Rule object + + Returns: + dict: Rule in CEL content format + """ + cel_rule = { + 'id': rule.id_, # Keep underscores for id + 'name': rule_id_to_name(rule.id_), # Convert to hyphens for name + 'title': rule.title, + 'description': rule.description, + 'rationale': rule.rationale, + 'severity': rule.severity, + 'checkType': rule.check_type if hasattr(rule, 'check_type') and rule.check_type else 'Platform', + } + + # Add instructions from ocil field + if hasattr(rule, 'ocil') and rule.ocil: + cel_rule['instructions'] = rule.ocil + + # Add failureReason if present + if hasattr(rule, 'failure_reason') and rule.failure_reason: + cel_rule['failureReason'] = rule.failure_reason + + # Add CEL expression + if hasattr(rule, 'expression') and rule.expression: + cel_rule['expression'] = rule.expression + + # Add inputs (convert to camelCase for CRD compatibility) + if hasattr(rule, 'inputs') and rule.inputs: + cel_rule['inputs'] = convert_inputs_to_camelcase(rule.inputs) + + # Add controls from references + controls = extract_controls_from_references(rule.references) + if controls: + cel_rule['controls'] = controls + + return cel_rule + + +def profile_to_cel_dict(profile, cel_rule_ids): + """ + Convert a Profile object to CEL content dictionary format. + + Args: + profile: Profile object + cel_rule_ids: Set of rule IDs that have CEL checks + + Returns: + dict: Profile in CEL content format + """ + # Filter selected rules to only include rules with CEL checks + profile_cel_rules = [rule_id_to_name(rid) for rid in profile.selected if rid in cel_rule_ids] + + if not profile_cel_rules: + return None + + cel_profile = { + 'id': profile.id_, + 'name': rule_id_to_name(profile.id_), + 'title': profile.title, + 'description': profile.description, + 'productType': 'Platform', # Default for OCP4 + 'rules': sorted(profile_cel_rules) + } + + return cel_profile + + +def generate_cel_content(cel_rules, profiles): + """ + Generate the complete CEL content structure. + + Args: + cel_rules: Dictionary of rules with CEL checks + profiles: List of profiles targeting the CEL checking engine + + Returns: + dict: Complete CEL content structure + + Raises: + ValueError: If duplicate rule names found or profile references unknown rules + """ + cel_rule_ids = set(cel_rules.keys()) + + # Generate rules section and check for duplicates + cel_rules_list = [] + rule_names_seen = set() + for rule_id in sorted(cel_rules.keys()): + rule = cel_rules[rule_id] + cel_rule = rule_to_cel_dict(rule) + + # Check for duplicate rule names + rule_name = cel_rule['name'] + if rule_name in rule_names_seen: + raise ValueError(f"duplicate rule name: {rule_name}") + rule_names_seen.add(rule_name) + + cel_rules_list.append(cel_rule) + + # Generate profiles section and validate rule references + cel_profiles = [] + for profile in profiles: + # Validate that all selected rules have CEL checks + profile_name = rule_id_to_name(profile.id_) + for rule_id in profile.selected: + if rule_id not in cel_rule_ids: + rule_name = rule_id_to_name(rule_id) + raise ValueError( + f"profile '{profile_name}' references unknown rule '{rule_name}'" + ) + + cel_profile = profile_to_cel_dict(profile, cel_rule_ids) + if cel_profile: + cel_profiles.append(cel_profile) + + # Build the complete structure + content = { + 'profiles': cel_profiles, + 'rules': cel_rules_list + } + + return content + + +def main(): + args = parse_args() + setup_logging(args.log) + + # Load rules with CEL checks + cel_rules = load_cel_rules(args.resolved_rules_dir) + + if not cel_rules: + content = {'profiles': [], 'rules': []} + else: + # Load profiles + profiles = load_profiles(args.profiles_dir, set(cel_rules.keys())) + + # Generate CEL content + content = generate_cel_content(cel_rules, profiles) + + # Write output YAML + os.makedirs(os.path.dirname(args.output), exist_ok=True) + + with open(args.output, 'w') as f: + yaml.dump(content, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + + +if __name__ == "__main__": + main() diff --git a/build_product b/build_product index 76e3d3a69854..0345346fcc73 100755 --- a/build_product +++ b/build_product @@ -9,6 +9,8 @@ # ARG_OPTIONAL_BOOLEAN([ansible-playbooks],[],[Build Ansible Playbooks for every profile],[on]) # ARG_OPTIONAL_BOOLEAN([bash-scripts],[],[Build Bash remediation scripts for every profile],[on]) # ARG_OPTIONAL_BOOLEAN([datastream-only],[d],[Build the data stream only. Do not build any of the guides, tables, etc],[off]) +# ARG_OPTIONAL_ACTION([datastream],[],[Build the data stream. Do not build any of the guides, tables, etc]) +# ARG_OPTIONAL_SINGLE([cel-content],[],[Product(s) to build CEL content for (comma-separated)],[off]) # ARG_OPTIONAL_BOOLEAN([profiling],[p],[Use ninja and call the build_profiler.sh util],[off]) # ARG_USE_ENV([ADDITIONAL_CMAKE_OPTIONS],[],[Whitespace-separated string of arguments to pass to CMake]) # ARG_POSITIONAL_INF([product],[Products to build, ALL means all products],[0],[ALL]) @@ -64,6 +66,7 @@ _arg_ansible_playbooks="on" _arg_playbook_per_rule="off" _arg_bash_scripts="on" _arg_datastream_only="off" +_arg_cel_content="off" _arg_profiling="off" _arg_log="off" _arg_thin_datastream="off" @@ -73,7 +76,7 @@ _arg_render_test_scenarios="off" print_help() { printf '%s\n' "Wipes out contents of the 'build' directory and builds only and only the given products." - printf 'Usage: %s [-b|--builder ] [-j|--jobs ] [--(no-)debug] [--(no-)derivatives] [--(no-)ansible-playbooks] [--(no-)bash-scripts] [-d|--(no-)datastream-only] [-p|--(no-)profiling] [-h|--help] [] ... [] ...\n' "$0" + printf 'Usage: %s [-b|--builder ] [-j|--jobs ] [--(no-)debug] [--(no-)derivatives] [--(no-)ansible-playbooks] [--(no-)bash-scripts] [-d|--(no-)datastream-only] [--datastream] [-p|--(no-)profiling] [-h|--help] [] ... [] ...\n' "$0" printf '\t%s\n' ": Products to build, ALL means all products (defaults for : 'ALL')" printf '\t%s\n' "-b, --builder: Builder engine. Can be one of: 'make', 'ninja' and 'auto' (default: 'auto')" printf '\t%s\n' "-j, --jobs: Count of simultaneous jobs (default: 'auto')" @@ -85,6 +88,8 @@ print_help() printf '\t%s\n' "-t, --thin, --no-thin: Build thin data streams for each rule. Do not build any of the guides, tables, etc (off by default)" printf '\t%s\n' "-r, --rule-id: Rule ID: Build a thin data stream with the specified rule. Do not build any of the guides, tables, etc (off by default)" printf '\t%s\n' "-d, --datastream-only, --no-datastream-only: Build the data stream only. Do not build any of the guides, tables, etc (off by default)" + printf '\t%s\n' "--datastream: Build the data stream. Do not build any of the guides, tables, etc" + printf '\t%s\n' "--cel-content: Product(s) to build CEL content for (comma-separated) (default: 'off')" printf '\t%s\n' "--render-test-scenarios: render Automatus test scenarios for specified product and put them into the build directory (off by default)" printf '\t%s\n' "-p, --profiling, --no-profiling: Use ninja and call the build_profiler.sh util (off by default)" printf '\t%s\n' "-l, --log, --no-log: Logs all debugging messages (off by default)" @@ -179,6 +184,17 @@ parse_commandline() { begins_with_short_option "$_next" && shift && set -- "-d" "-${_next}" "$@"; } || die "The short option '$_key' can't be decomposed to ${_key:0:2} and -${_key:2}, because ${_key:0:2} doesn't accept value and '-${_key:2:1}' doesn't correspond to a short option." fi ;; + --datastream) + _arg_datastream_only="on" + ;; + --cel-content) + test $# -lt 2 && die "Missing value for the optional argument '$_key'." 1 + _arg_cel_content="$2" + shift + ;; + --cel-content=*) + _arg_cel_content="${_key##--cel-content=}" + ;; -p|--no-profiling|--profiling) _arg_profiling="on" test "${1:0:5}" = "--no-" && _arg_profiling="off" @@ -322,6 +338,12 @@ set_explict_build_targets() { EXPLICIT_BUILD_TARGETS+=("generate-ssg-$(to_lowercase "$chosen_product")-ds.xml") done fi + if test "$_arg_cel_content" != off ; then + IFS=',' read -ra CEL_PRODUCTS <<< "$_arg_cel_content" + for cel_product in "${CEL_PRODUCTS[@]}"; do + EXPLICIT_BUILD_TARGETS+=("generate-$(to_lowercase "$cel_product")-cel-content.yaml") + done + fi } # Get this using diff --git a/cmake/SSGCommon.cmake b/cmake/SSGCommon.cmake index 9c28803eada0..ed6f76b9d736 100644 --- a/cmake/SSGCommon.cmake +++ b/cmake/SSGCommon.cmake @@ -528,6 +528,20 @@ macro(ssg_build_sds PRODUCT) endif() endmacro() +# Build CEL content YAML for products that support CEL scanning +macro(ssg_build_cel_content PRODUCT) + add_custom_command( + OUTPUT "${CMAKE_BINARY_DIR}/${PRODUCT}-cel-content.yaml" + COMMAND env "PYTHONPATH=$ENV{PYTHONPATH}" "${Python_EXECUTABLE}" "${SSG_BUILD_SCRIPTS}/build_cel_content.py" --resolved-rules-dir "${CMAKE_CURRENT_BINARY_DIR}/rules" --profiles-dir "${CMAKE_CURRENT_BINARY_DIR}/profiles" --product-yaml "${CMAKE_CURRENT_BINARY_DIR}/product.yml" --output "${CMAKE_BINARY_DIR}/${PRODUCT}-cel-content.yaml" + DEPENDS ${PRODUCT}-compile-all "${CMAKE_CURRENT_BINARY_DIR}/ssg_build_compile_all-${PRODUCT}" + COMMENT "[${PRODUCT}-content] generating CEL content YAML" + ) + add_custom_target( + generate-${PRODUCT}-cel-content.yaml + DEPENDS "${CMAKE_BINARY_DIR}/${PRODUCT}-cel-content.yaml" + ) +endmacro() + # Build per-product HTML guides to see the status of various profiles and # rules in the generated XCCDF guides. macro(ssg_build_html_guides PRODUCT) @@ -740,6 +754,11 @@ macro(ssg_build_product PRODUCT) ssg_build_xml_final(${PRODUCT} ocil) ssg_build_sds(${PRODUCT}) + # Build CEL content if enabled for this product + if(PRODUCT_CEL_ENABLED) + ssg_build_cel_content(${PRODUCT}) + endif() + define_validate_product("${PRODUCT}") if("${VALIDATE_PRODUCT}" OR "${FORCE_VALIDATE_EVERYTHING}") add_test( @@ -764,6 +783,15 @@ macro(ssg_build_product PRODUCT) add_dependencies(zipfile generate-ssg-${PRODUCT}-ds.xml) + # Add CEL content to dependencies if enabled + if(PRODUCT_CEL_ENABLED) + add_dependencies( + ${PRODUCT}-content + generate-${PRODUCT}-cel-content.yaml + ) + add_dependencies(zipfile generate-${PRODUCT}-cel-content.yaml) + endif() + if("${PRODUCT_ANSIBLE_REMEDIATION_ENABLED}" AND SSG_ANSIBLE_PLAYBOOKS_ENABLED) ssg_build_profile_playbooks(${PRODUCT}) add_custom_target( diff --git a/docs/manual/developer/02_building_complianceascode.md b/docs/manual/developer/02_building_complianceascode.md index 1032af1d51ef..dafdb6e14e05 100644 --- a/docs/manual/developer/02_building_complianceascode.md +++ b/docs/manual/developer/02_building_complianceascode.md @@ -313,6 +313,36 @@ The thin Datastream is stored under the normal Datastream name (for example, `ss ./build_product fedora --rule-id enable_fips_mode ``` +### Building CEL Content + +CEL (Common Expression Language) content is available for Kubernetes and OpenShift products. +When building products with `PRODUCT_CEL_ENABLED` set in their CMakeLists.txt, CEL content is generated automatically during a full build. + +```bash + # Build all content including CEL (for products with CEL enabled) + ./build_product ocp4 + + # Build data stream only (excludes CEL content) + ./build_product ocp4 --datastream + # Short form (only builds datastream): + ./build_product ocp4 -d + # Legacy form (still supported): + ./build_product ocp4 --datastream-only + + # Build data stream and CEL content + ./build_product ocp4 --datastream --cel-content=ocp4 + + # Build only CEL content (no data stream) + ./build_product --cel-content=ocp4 + + # Build CEL content for multiple products + ./build_product --cel-content=ocp4,rhel9 +``` + +CEL content files are generated as `build/-cel-content.yaml`. + +For more information about CEL content, see [CEL Content Documentation](12_cel_content.md). + ### Configuring CMake options using GUI Configure options before building using a GUI tool: diff --git a/docs/manual/developer/03_creating_content.md b/docs/manual/developer/03_creating_content.md index d1a1b0b25695..99f0901f0883 100644 --- a/docs/manual/developer/03_creating_content.md +++ b/docs/manual/developer/03_creating_content.md @@ -26,7 +26,7 @@ build files/configuration, etc.

applications

-

Contains security content for applications such as OpenShift or OpenStack. Contains rules, OVAL checks, Ansible tasks, Bash remediations, etc.

+

Contains security content for applications such as OpenShift or OpenStack. Contains rules, OVAL checks, CEL checks, Ansible tasks, Bash remediations, etc. For Kubernetes/OpenShift CEL rules, see CEL Content.

shared

diff --git a/docs/manual/developer/04_style_guide.md b/docs/manual/developer/04_style_guide.md index 7ba2c0dec31e..ff9ec518f888 100644 --- a/docs/manual/developer/04_style_guide.md +++ b/docs/manual/developer/04_style_guide.md @@ -274,6 +274,98 @@ Rules sections must be in the following order, if they are present. * Must be a valid rule id * `template` +#### CEL Rule Sections + +CEL (Common Expression Language) rules are used for Kubernetes/OpenShift compliance checks. +Rules with CEL checks use a split-file structure: + +* **`rule.yml`** - Contains metadata (same as any rule) +* **`cel/shared.yml`** - Contains CEL-specific fields (check_type, inputs, expression) + +This allows rules to support both CEL and OVAL checks during migration. + +##### rule.yml Sections + +Rule sections must be in the following order, if present: + +* `documentation_complete` +* `title` +* `description` (HTML Like) +* `rationale` (HTML Like) +* `severity` +* `identifiers` (Optional) + * Keys must be in alphabetical order +* `references` (Optional) + * Keys must be in alphabetical order +* `ocil_clause` (Optional) +* `ocil` (HTML Like, Optional) +* `failure_reason` (Optional) + * Must be a block + * Must describe the condition when the check fails + +##### cel/shared.yml Sections + +CEL-specific sections must be in the following order: + +* `check_type` + * Must be `Platform` for Kubernetes/OpenShift checks +* `expression` + * Must be a valid CEL expression that evaluates to boolean + * Must use variables defined in `inputs` + * Should use `has()` to check for field existence before accessing + * Should be formatted for readability using multi-line block syntax for complex expressions +* `inputs` + * Must be a list of at least one input + * Each input must have: + * `name` - Variable name used in the CEL expression + * `kubernetes_input_spec` - Kubernetes resource specification + * `api_version` - Kubernetes API version (e.g., `v1`, `apps/v1`) + * `resource` - Resource type in plural form (e.g., `pods`, `deployments`) + * `resource_name` (Optional) - Specific resource name to query + * `resource_namespace` (Optional) - Specific namespace to query + +Example rule with CEL checks: + +**rule.yml:** +```yaml +documentation_complete: true + +title: 'Rule Title in Title Case' + +description: |- + Description of what the rule checks. + +rationale: |- + Why this rule matters for security/compliance. + +severity: medium + +ocil: |- + Run the following command: +
$ oc get configmap my-config -n default
+ +failure_reason: |- + The resource is not properly configured. +``` + +**cel/shared.yml:** +```yaml +check_type: Platform + +expression: |- + resource.spec.enabled == true && + has(resource.spec.field) && + resource.spec.field == "expected_value" + +inputs: + - name: resource + kubernetes_input_spec: + api_version: v1 + resource: configmaps + resource_name: my-config + resource_namespace: default +``` + ### Group This section describes the style guide around the `group.yml` files. @@ -354,21 +446,27 @@ Control sections must be in the following order, if they are present. #### Profile Sections -Control sections must be in the following order, all sections are required unless otherwise noted. +Profile sections must be in the following order, all sections are required unless otherwise noted. * `documentation_complete` -* `id` -* `metadata` +* `metadata` (Optional) * `reference` * `version` * `SMEs` * `title` * Shall be short and descriptive * `description` (HTML-Like) +* `platform` (Optional) + * Must be a valid platform identifier (e.g., `ocp4`, `rhel9`) +* `scanner_type` (Optional) + * Must be `CEL` for profiles targeting the CEL checking engine + * Profiles with `scanner_type: CEL` can only select rules that have CEL checks (cel/ directory) + * Profiles with `scanner_type: CEL` are excluded from XCCDF/OVAL generation * `extends` (Optional) * Must be valid id of another profile id * `selections` * Must be valid rule ids + * For profiles with `scanner_type: CEL`, must only contain rule ids with CEL checks (using hyphens, not underscores) ## Remediation diff --git a/docs/manual/developer/06_contributing_with_content.md b/docs/manual/developer/06_contributing_with_content.md index 3e0427e88f68..ffeb3cb671d0 100644 --- a/docs/manual/developer/06_contributing_with_content.md +++ b/docs/manual/developer/06_contributing_with_content.md @@ -668,12 +668,19 @@ Tips: ### Checks -Checks are used to evaluate a Rule. There are two types of check content -supported by ComplianceAsCode: OVAL and SCE. Note that OVAL is standardized -by NIST and has better cross-scanner support than SCE does. However, because -SCE can use any language on the target system (Bash, Python, ...) it is much -more flexible and general-purpose than OVAL. This project generally encourages -OVAL unless it lacks support for certain features. +Checks are used to evaluate a Rule. There are three types of check content +supported by ComplianceAsCode: OVAL, CEL, and SCE. + +* **OVAL** (Open Vulnerability and Assessment Language) - Standardized by NIST with better cross-scanner support. Used for traditional operating system compliance checks (file system, processes, packages). Generally the preferred choice for OS-level checks. + +* **CEL** (Common Expression Language) - Used for Kubernetes and OpenShift platform compliance checks. CEL rules evaluate Kubernetes API resources without requiring shell access to nodes. See [CEL Content](12_cel_content.md) for complete documentation on creating CEL rules. + +* **SCE** (Script Check Engine) - Can use any language on the target system (Bash, Python, ...) making it more flexible and general-purpose than OVAL, but with less cross-scanner support. + +This project generally encourages using: +- OVAL for Linux/OS checks +- CEL for Kubernetes/OpenShift platform checks +- SCE only when OVAL lacks support for certain features #### OVAL Check Content @@ -946,6 +953,70 @@ means: +### CEL Check Content + +[CEL](https://github.com/google/cel-spec) (Common Expression Language) is a mechanism +for evaluating Kubernetes and OpenShift API resources for compliance checking. CEL checks +are used by the [compliance-operator](https://github.com/ComplianceAsCode/compliance-operator) +to perform platform-level compliance checks without requiring shell access to nodes. + +CEL rules are defined directly in the `rule.yml` file using specialized fields: + +* `scanner_type: CEL` - Marks the rule as a CEL rule +* `check_type: Platform` - Indicates this is a platform-level check +* `expression` - The CEL expression that evaluates to boolean (true=pass, false=fail) +* `inputs` - List of Kubernetes resources to evaluate +* `failure_reason` - Optional custom failure message + +Within a rule's `inputs` section, each input specifies a Kubernetes resource using `kubernetes_input_spec`: + +* `api_version` - Kubernetes API version (e.g., `v1`, `apps/v1`) +* `resource` - Resource type in plural form (e.g., `pods`, `deployments`) +* `resource_name` - Optional: specific resource name to query +* `resource_namespace` - Optional: specific namespace to query + +**Important notes:** + +* CEL rules are **excluded** from XCCDF/OVAL DataStreams +* CEL rules generate a separate `${PRODUCT}-cel-content.yaml` file +* CEL profiles can only select CEL rules +* Rule directory names should use hyphens (Kubernetes naming convention) + +For complete documentation on creating CEL rules, see [CEL Content](12_cel_content.md). + +Example CEL rule: + +```yaml +scanner_type: CEL +check_type: Platform + +expression: |- + resource.spec.replicas >= 3 && + has(resource.spec.template.spec.securityContext) && + resource.spec.template.spec.securityContext.runAsNonRoot == true + +inputs: + - name: resource + kubernetes_input_spec: + api_version: apps/v1 + resource: deployments + resource_name: my-app + resource_namespace: default +``` + +To build CEL content for a product, enable it in the product's `CMakeLists.txt`: + +```cmake +set(PRODUCT "ocp4") +set(PRODUCT_CEL_ENABLED TRUE) +ssg_build_product(${PRODUCT}) +``` + +## Remediations + +The following sections describe remediation content. Note that CEL rules do **not** support +remediation content - they are check-only. + ### Ansible > **Important** diff --git a/docs/manual/developer/07_understanding_build_system.md b/docs/manual/developer/07_understanding_build_system.md index 1b4d638c0286..5bb1aff9dbb3 100644 --- a/docs/manual/developer/07_understanding_build_system.md +++ b/docs/manual/developer/07_understanding_build_system.md @@ -83,6 +83,7 @@ of occurrence: - Load resolved rules, profiles, groups, collected remediations and the unlinked OVAL document and generate XCCDF, OVAL and OCIL documents from this data. - Generate CPE OVAL and CPE dictionary. - Combining the OVAL, OCIL, CPE and XCCDF documents into a single SCAP source data stream. +- Generate CEL content YAML for Kubernetes/OpenShift compliance checks (if enabled for the product). - Generate content for derived products (such as CentOS and Scientific Linux). - Generate HTML tables, Bash scripts, Ansible Playbooks and other secondary artifacts. @@ -93,6 +94,9 @@ refer to their help text for more information and usage: - `build_all_guides.py` -- generates separate HTML guides for every profile in an XCCDF document. +- `build_cel_content.py` -- generates CEL (Common Expression Language) content + YAML for Kubernetes/OpenShift compliance checks. See [CEL Content](12_cel_content.md) + for detailed information about CEL rules and profiles. - `build_rule_playbooks.py` -- generates per-rule per-profile playbooks in Ansible content. - `build_sce.py` -- outputs SCE content and combined metadata. @@ -167,3 +171,95 @@ Steps to link an OVAL document to an XCCDF document: 8. The OVAL Document object is stored as an XML file `build/ssg-${PRODUCT}-oval.xml`. 9. For each XCCDF rule, a minimal OVAL Documents document is generated as an artifact 10. For each reference of OVAL check in XCCDF, a link to the `check-content` and a `check-export` element is added. + +## How CEL Content is Built + +CEL (Common Expression Language) content provides an alternative scanning mechanism to OVAL specifically designed for Kubernetes and OpenShift API resource evaluation. Unlike OVAL which requires shell access and evaluates system state, CEL rules evaluate Kubernetes resources directly through the API server. + +CEL content generation is optional and must be explicitly enabled for each product. + +### Enabling CEL Content + +CEL content generation is enabled by setting `PRODUCT_CEL_ENABLED` in the product's `CMakeLists.txt`: + +```cmake +set(PRODUCT "ocp4") +set(PRODUCT_REMEDIATION_LANGUAGES "ignition;kubernetes") +set(PRODUCT_CEL_ENABLED TRUE) + +ssg_build_product(${PRODUCT}) +``` + +### Build Process + +When CEL content is enabled for a product, the build system performs the following steps: + +1. **Rule and Profile Resolution** - All rules and profiles are compiled to their product-specific resolved form (same as for XCCDF/OVAL). + +2. **CEL Rule Loading** - The `build_cel_content.py` script loads all rules with `scanner_type: CEL` from the `build/${PRODUCT}/rules/` directory. + +3. **CEL Profile Loading** - The script loads all profiles with `scanner_type: CEL` from the `build/${PRODUCT}/profiles/` directory. + +4. **Validation** - The build system validates CEL content: + - Rules must have `expression` field (non-empty CEL expression) + - Rules must have `inputs` field (non-empty list of Kubernetes resources) + - Profiles must have `selected` field with at least one rule + - No duplicate rule names (after conversion to hyphenated format) + - All profile rule references must exist in the CEL rules + +5. **Content Generation** - The script generates a single CEL content YAML file at `build/${PRODUCT}-cel-content.yaml`. + +### CEL Content Structure + +The generated CEL content YAML has two main sections: + +```yaml +profiles: + - id: cis_vm_extension # Profile ID (with underscores) + name: cis-vm-extension # Profile name (with hyphens) + title: Profile Title + description: Profile description + productType: Platform + rules: # List of rule names (hyphenated) + - rule-name-one + - rule-name-two + +rules: + - id: rule_name_one # Rule ID (with underscores) + name: rule-name-one # Rule name (with hyphens) + title: Rule Title + description: Rule description + rationale: Rule rationale + severity: medium + checkType: Platform + expression: | # CEL expression + resource.spec.enabled == true + inputs: # Kubernetes resource inputs + - name: resource + kubernetesInputSpec: + apiVersion: v1 + resource: pods + instructions: Manual check steps # From ocil field + controls: # From references field + cis@ocp4: + - 1.2.3 + nist: + - CM-6 +``` + +### Differences from XCCDF/OVAL + +CEL content is processed differently from traditional XCCDF/OVAL content: + +| Aspect | XCCDF/OVAL | CEL Content | +|--------|-----------|-------------| +| **Rules Included** | All rules except CEL | Only CEL rules | +| **Profiles Included** | All profiles except CEL | Only CEL profiles | +| **Output Format** | XML (DataStream) | YAML | +| **Output Location** | `build/ssg-${PRODUCT}-ds.xml` | `build/${PRODUCT}-cel-content.yaml` | +| **Scanner** | OpenSCAP | compliance-operator | +| **Evaluation** | Shell commands, file checks | Kubernetes API queries | + +Rules and profiles with `scanner_type: CEL` are **excluded** from XCCDF/OVAL generation and **only** appear in the CEL content YAML. + +For detailed information about creating CEL rules and profiles, see [CEL Content](12_cel_content.md). diff --git a/docs/manual/developer/12_cel_content.md b/docs/manual/developer/12_cel_content.md new file mode 100644 index 000000000000..a0f115d608f0 --- /dev/null +++ b/docs/manual/developer/12_cel_content.md @@ -0,0 +1,445 @@ +# CEL Content + +## Introduction + +CEL (Common Expression Language) is an alternative scanning mechanism to OVAL that provides native Kubernetes resource evaluation. CEL rules are used by the [compliance-operator](https://github.com/ComplianceAsCode/compliance-operator) to perform compliance checks on Kubernetes and OpenShift resources without requiring shell access or OVAL evaluation. + +This document describes how to create CEL rules and profiles, and how the build system generates CEL content. + +## When to Use CEL Rules + +Use CEL rules when: +- Checking Kubernetes or OpenShift API resources (Pods, Deployments, ConfigMaps, etc.) +- Evaluating Custom Resource Definitions (CRDs) +- Performing compliance checks that don't require shell access to nodes +- Building platform-level compliance checks for container orchestration systems + +Continue using OVAL/template-based rules for: +- File system checks +- Process checks +- Package installation verification +- Traditional operating system compliance checks + +## CEL Rule Format + +CEL rules are defined using the same `rule.yml` format as other rules, with additional CEL-specific fields. + +### Required Fields for CEL Rules + +```yaml +documentation_complete: true + +title: 'Short descriptive title' + +description: |- + Full description of what the rule checks. + +rationale: |- + Why this rule matters for security/compliance. + +severity: medium # low, medium, high + +scanner_type: CEL # REQUIRED: Marks this as a CEL rule + +check_type: Platform # Type of check (usually Platform for K8s checks) + +expression: |- + # REQUIRED: CEL expression that evaluates to boolean + resource.spec.enabled == true + +inputs: # REQUIRED: List of Kubernetes resources to evaluate + - name: resource + kubernetes_input_spec: + api_version: v1 + resource: pods + resource_name: my-pod # Optional: specific resource name + resource_namespace: default # Optional: specific namespace +``` + +### Optional Fields + +```yaml +ocil: |- + Manual verification instructions. + This becomes the "instructions" field in CEL content output. + +failure_reason: |- + Custom message displayed when the check fails. + +references: + cis@ocp4: 1.2.3 + nist: CM-6,CM-6(1) + srg: SRG-APP-000516-CTR-001325 +``` + +### CEL Expression + +The `expression` field contains a CEL expression that evaluates to a boolean value: +- `true` means the check passes (compliant) +- `false` means the check fails (non-compliant) + +CEL expressions can reference inputs by name and use standard CEL operators and functions. + +#### Example Expressions + +Simple boolean check: +```yaml +expression: resource.spec.enabled == true +``` + +Checking for absence: +```yaml +expression: !has(hco.spec.storageImport) || hco.spec.storageImport.insecureRegistries.size() == 0 +``` + +Multiple conditions: +```yaml +expression: |- + resource.spec.replicas >= 3 && + has(resource.spec.template.spec.securityContext) && + resource.spec.template.spec.securityContext.runAsNonRoot == true +``` + +### Input Specifications + +The `inputs` field lists Kubernetes resources that the CEL expression can reference. + +#### Kubernetes Input Spec + +```yaml +inputs: + - name: deployment # Name used in the expression + kubernetes_input_spec: + api_version: apps/v1 # Kubernetes API version + resource: deployments # Resource type (plural form) + resource_name: my-app # Optional: specific resource name + resource_namespace: kube-system # Optional: specific namespace +``` + +If `resourceName` is omitted, the check applies to all resources of that type. +If `resourceNamespace` is omitted, the check applies across all namespaces. + +## CEL Profile Format + +CEL profiles use the standard profile format with one additional field: + +```yaml +documentation_complete: true + +title: 'CIS Red Hat OpenShift Virtual Machine Extension Benchmark' + +description: |- + Profile description text. + +scanner_type: CEL # REQUIRED: Marks this as a CEL profile + +selections: + - kubevirt-nonroot-feature-gate-is-enabled + - kubevirt-no-permitted-host-devices + - kubevirt-persistent-reservation-disabled +``` + +**Important:** CEL profiles can only select CEL rules. If a profile includes both CEL and OVAL rules, only the CEL rules will be included in the generated CEL content file. + +## Creating a CEL Rule + +### 1. Choose the Correct Directory + +CEL rules for Kubernetes/OpenShift should go under `applications/openshift/` or `applications/openshift-virtualization/`, organized by component: + +``` +applications/openshift-virtualization/ +├── group.yml +├── kubevirt-nonroot-feature-gate-is-enabled/ +│ └── rule.yml +├── kubevirt-no-permitted-host-devices/ +│ └── rule.yml +└── kubevirt-enforce-trusted-tls-registries/ + └── rule.yml +``` + +**Important:** Directory names should use hyphens (e.g., `kubevirt-nonroot-enabled`), not underscores. This follows Kubernetes naming conventions. + +### 2. Create the group.yml + +Each component directory requires a `group.yml` file: + +```yaml +documentation_complete: true + +title: 'OpenShift Virtualization' + +description: |- + Security recommendations for OpenShift Virtualization (KubeVirt). +``` + +### 3. Create the rule.yml + +Follow the CEL rule format described above. Example: + +```yaml +documentation_complete: true + +title: 'Ensure NonRoot Feature Gate is Enabled' + +description: |- + The NonRoot feature gate restricts containers from running as root, + reducing the attack surface. + +rationale: |- + Running containers as non-root users is a security best practice + that limits the impact of container breakout vulnerabilities. + +severity: medium + +scanner_type: CEL + +check_type: Platform + +expression: |- + hco.spec.featureGates.nonRoot == true + +inputs: + - name: hco + kubernetes_input_spec: + api_version: hco.kubevirt.io/v1beta1 + resource: hyperconvergeds + resource_name: kubevirt-hyperconverged + resource_namespace: openshift-cnv + +ocil: |- + Run the following command to verify the NonRoot feature gate: +
oc get hyperconverged -n openshift-cnv kubevirt-hyperconverged -o jsonpath='{.spec.featureGates.nonRoot}'
+ The output should be true. + +references: + cis@ocp4: 5.7.1 +``` + +## Build System Integration + +### Enabling CEL Content for a Product + +CEL content generation is enabled per-product in the product's `CMakeLists.txt`: + +```cmake +set(PRODUCT "ocp4") +set(PRODUCT_REMEDIATION_LANGUAGES "ignition;kubernetes") +set(PRODUCT_CEL_ENABLED TRUE) # Enable CEL content generation + +ssg_build_product(${PRODUCT}) +``` + +### Build Process + +When `PRODUCT_CEL_ENABLED` is set to `TRUE`, the build system: + +1. **Compiles all rules** (including CEL rules) using `compile_all.py` +2. **Filters CEL rules** - Rules with `scanner_type: CEL` are identified +3. **Filters CEL profiles** - Profiles with `scanner_type: CEL` are identified +4. **Validates CEL content**: + - CEL rules must have `expression` field (non-empty) + - CEL rules must have `inputs` field (non-empty list) + - CEL profiles must have rules in `selected` field + - No duplicate rule names after conversion to hyphens + - Profiles can only reference existing CEL rules +5. **Generates CEL content YAML** at `build/${PRODUCT}-cel-content.yaml` + +### Build Script: build_cel_content.py + +The `build_cel_content.py` script is located in `build-scripts/` and performs the following: + +#### Input +- Resolved rules directory: `build/${PRODUCT}/rules/` +- Resolved profiles directory: `build/${PRODUCT}/profiles/` +- Product YAML: `build/${PRODUCT}/product.yml` + +#### Processing +1. Loads all rules with `scanner_type: CEL` +2. Validates required CEL fields (`expression`, `inputs`) +3. Loads all profiles with `scanner_type: CEL` +4. Validates profile rules are non-empty +5. Converts rule IDs (underscores) to rule names (hyphens) +6. Maps `ocil` field to `instructions` in output +7. Preserves reference keys like `cis@ocp4`, `nist`, etc. as `controls` +8. Validates no duplicate rule names +9. Validates all profile rule references exist + +#### Output +YAML file at `build/${PRODUCT}-cel-content.yaml` with structure: + +```yaml +profiles: + - id: cis_vm_extension + name: cis-vm-extension + title: CIS Red Hat OpenShift Virtual Machine Extension Benchmark + description: Profile description text + productType: Platform + rules: + - kubevirt-nonroot-feature-gate-is-enabled + - kubevirt-no-permitted-host-devices + +rules: + - id: kubevirt_nonroot_feature_gate_is_enabled + name: kubevirt-nonroot-feature-gate-is-enabled + title: Ensure NonRoot Feature Gate is Enabled + description: The NonRoot feature gate restricts containers... + rationale: Running containers as non-root... + severity: medium + checkType: Platform + expression: hco.spec.featureGates.nonRoot == true + inputs: + - name: hco + kubernetesInputSpec: + apiVersion: hco.kubevirt.io/v1beta1 + resource: hyperconvergeds + resourceName: kubevirt-hyperconverged + resourceNamespace: openshift-cnv + instructions: Run the following command... + controls: + cis@ocp4: + - 5.7.1 +``` + +### Build Targets + +```bash +# Build all content including CEL content (for products with PRODUCT_CEL_ENABLED) +./build_product ocp4 + +# Build data stream only (faster, excludes CEL content) +./build_product ocp4 --datastream +# Short form (only builds datastream): +./build_product ocp4 -d +# Legacy form (still supported): +./build_product ocp4 --datastream-only + +# Build data stream and CEL content +./build_product ocp4 --datastream --cel-content=ocp4 + +# Build only CEL content (no data stream) +./build_product --cel-content=ocp4 + +# Build CEL content for multiple products +./build_product --cel-content=ocp4,rhel9 + +# CEL content is generated as build/ocp4-cel-content.yaml +``` + +## Validation + +### Build-Time Validation + +The build system validates CEL content automatically: + +**Rule Validation:** +- `expression` field must be present and non-empty +- `inputs` field must be present and non-empty list +- Rule directory names must match rule IDs (with hyphens) + +**Profile Validation:** +- `selected` field must contain at least one rule +- All selected rules must exist in CEL rules +- Profile cannot reference OVAL rules + +**Content Validation:** +- No duplicate rule names (after underscore-to-hyphen conversion) +- All profile rule references must exist + +### Manual Validation + +Test CEL expressions using the CEL evaluator: + +```bash +# Using cel-go +cel-spec '{"resource": {"spec": {"enabled": true}}}' 'resource.spec.enabled == true' +``` + +## CEL vs OVAL Comparison + +| Aspect | CEL | OVAL | +|--------|-----|------| +| **Scope** | Kubernetes API resources | File system, processes, packages | +| **Access Required** | API server access | Node shell access | +| **Syntax** | CEL expressions (C-like) | XML definitions | +| **Performance** | Fast, API-level | Slower, requires node scanning | +| **Use Case** | Platform compliance | OS compliance | +| **Scanner** | compliance-operator | OpenSCAP | +| **Output Format** | YAML (cel-content.yaml) | XML (DataStream) | + +## Best Practices + +### Writing CEL Rules + +1. **Use specific resource names when possible** + ```yaml + inputs: + - name: config + kubernetes_input_spec: + api_version: v1 + resource: configmaps + resource_name: cluster-config # Specific resource + resource_namespace: openshift-config + ``` + +2. **Check for field existence before accessing** + ```yaml + expression: |- + !has(resource.spec.field) || resource.spec.field == "expected" + ``` + +3. **Keep expressions simple and readable** + - Break complex checks into multiple rules + - Use clear variable names in inputs + - Add comments for non-obvious logic + +4. **Test expressions thoroughly** + - Verify both pass and fail cases + - Test with missing fields + - Test with unexpected values + +### Organizing CEL Rules + +1. **Group related rules by component** + - Use meaningful directory names (e.g., `api-server/`, `kubelet/`) + - Create a `group.yml` for each component + +2. **Follow Kubernetes naming conventions** + - Use hyphens in directory names + - Keep names descriptive but concise + +3. **Document OCIL instructions** + - Provide manual verification commands + - Include expected output + - Use proper formatting with `
` and `` tags
+
+## Troubleshooting
+
+### Build Errors
+
+**Error: `CEL rule 'rule-name' has no expression`**
+- Add the `expression` field to your rule.yml
+
+**Error: `CEL rule 'rule-name' has no inputs`**
+- Add the `inputs` field with at least one Kubernetes input
+
+**Error: `CEL profile 'profile-name' has no rules`**
+- Add rules to the `selections` field in the profile
+
+**Error: `profile 'profile-name' references unknown rule 'rule-name'`**
+- Verify the rule exists and has `scanner_type: CEL`
+- Check the rule ID matches the profile selection
+
+### CEL Content Not Generated
+
+1. Verify `PRODUCT_CEL_ENABLED TRUE` is set in `products/${PRODUCT}/CMakeLists.txt`
+2. Check that rules have `scanner_type: CEL`
+3. Check that profiles have `scanner_type: CEL`
+4. Review build logs for validation errors
+
+## References
+
+- [CEL Language Specification](https://github.com/google/cel-spec)
+- [CEL Go Implementation](https://github.com/google/cel-go)
+- [Compliance Operator](https://github.com/ComplianceAsCode/compliance-operator)
+- [Kubernetes API Conventions](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md)
diff --git a/ocp-resources/ds-build-remote.yaml b/ocp-resources/ds-build-remote.yaml
index cb7d35841319..a2238e5c5cd0 100644
--- a/ocp-resources/ds-build-remote.yaml
+++ b/ocp-resources/ds-build-remote.yaml
@@ -25,12 +25,13 @@ spec:
 
       RUN microdnf -y install cmake make git /usr/bin/python3 python3-pyyaml python3-jinja2 openscap-utils
 
-      RUN ./build_product --datastream-only --debug ocp4 rhcos4
+      RUN ./build_product --datastream --debug ocp4 rhcos4 --cel-content=ocp4
 
       FROM registry.access.redhat.com/ubi8/ubi-minimal
       WORKDIR /
       COPY --from=builder /content/build/ssg-ocp4-ds.xml .
       COPY --from=builder /content/build/ssg-rhcos4-ds.xml .
+      COPY --from=builder /content/build/ocp4-cel-content.yaml .
   strategy: 
     dockerStrategy:
       noCache: true
diff --git a/ocp-resources/ds-from-local-build.yaml b/ocp-resources/ds-from-local-build.yaml
index 024e5d12a05e..93857fa3af50 100644
--- a/ocp-resources/ds-from-local-build.yaml
+++ b/ocp-resources/ds-from-local-build.yaml
@@ -20,6 +20,7 @@ spec:
       FROM registry.access.redhat.com/ubi8/ubi-minimal
       WORKDIR /
       COPY *-ds.xml .
+      COPY *-cel-content.yaml .
   strategy: 
     dockerStrategy:
       noCache: true
diff --git a/products/ocp4/CMakeLists.txt b/products/ocp4/CMakeLists.txt
index 31e9657b99ff..a0eaf870fea1 100644
--- a/products/ocp4/CMakeLists.txt
+++ b/products/ocp4/CMakeLists.txt
@@ -5,5 +5,6 @@ endif()
 
 set(PRODUCT "ocp4")
 set(PRODUCT_REMEDIATION_LANGUAGES "ignition;kubernetes")
+set(PRODUCT_CEL_ENABLED TRUE)
 
 ssg_build_product(${PRODUCT})
diff --git a/products/ocp4/profiles/cis-vm-extension.profile b/products/ocp4/profiles/cis-vm-extension.profile
new file mode 100644
index 000000000000..36c27d54190f
--- /dev/null
+++ b/products/ocp4/profiles/cis-vm-extension.profile
@@ -0,0 +1,30 @@
+---
+documentation_complete: true
+
+metadata:
+    version: 1.0.0
+    SMEs:
+        - rhmdnd
+        - Vincent056
+        - yuumasato
+
+title: 'CIS Red Hat Openshift Virtual Machine Extension Benchmark'
+
+description: |-
+    This profile defines a baseline that aligns to the Center for Internet Security®
+    Red Hat OpenShift Virtual Machine Extention Benchmark™, V1.0.0.
+
+    This profile includes Center for Internet Security®
+    Red Hat OpenShift Virtual Machine Extension Benchmarks™ content.
+
+    Note that this part of the profile is meant to run on the Platform that
+    Red Hat OpenShift Container Platform runs on top of.
+
+scanner_type: CEL
+
+selections:
+    - kubevirt-nonroot-feature-gate-is-enabled
+    - kubevirt-no-permitted-host-devices
+    - kubevirt-persistent-reservation-disabled
+    - kubevirt-no-vms-overcommitting-guest-memory
+    - kubevirt-enforce-trusted-tls-registries
diff --git a/shared/schemas/rule.json b/shared/schemas/rule.json
index e5b1a9305322..bc4d3e25409e 100644
--- a/shared/schemas/rule.json
+++ b/shared/schemas/rule.json
@@ -79,6 +79,68 @@
           "uniqueItems": true
         }
       }
+    },
+    "scanner_type": {
+      "type": "string",
+      "description": "Scanner type for compliance checking (e.g., 'CEL' for Common Expression Language)",
+      "enum": [
+        "CEL"
+      ]
+    },
+    "check_type": {
+      "type": "string",
+      "description": "Type of check being performed (usually 'Platform' for Kubernetes/OpenShift checks)"
+    },
+    "expression": {
+      "type": "string",
+      "description": "CEL expression that evaluates to boolean (true=pass, false=fail)"
+    },
+    "inputs": {
+      "type": "array",
+      "description": "List of Kubernetes resources to evaluate in the CEL expression",
+      "items": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string",
+            "description": "Variable name used in the CEL expression"
+          },
+          "kubernetes_input_spec": {
+            "type": "object",
+            "description": "Kubernetes resource specification",
+            "properties": {
+              "api_version": {
+                "type": "string",
+                "description": "Kubernetes API version (e.g., 'v1', 'apps/v1')"
+              },
+              "resource": {
+                "type": "string",
+                "description": "Resource type in plural form (e.g., 'pods', 'deployments')"
+              },
+              "resource_name": {
+                "type": "string",
+                "description": "Optional: specific resource name to query"
+              },
+              "resource_namespace": {
+                "type": "string",
+                "description": "Optional: specific namespace to query"
+              }
+            },
+            "required": [
+              "api_version",
+              "resource"
+            ]
+          }
+        },
+        "required": [
+          "name",
+          "kubernetes_input_spec"
+        ]
+      }
+    },
+    "failure_reason": {
+      "type": "string",
+      "description": "Custom message displayed when the CEL check fails"
     }
   },
   "required": [
diff --git a/ssg/build_yaml.py b/ssg/build_yaml.py
index ea834512228e..0b24a42a5791 100644
--- a/ssg/build_yaml.py
+++ b/ssg/build_yaml.py
@@ -890,6 +890,12 @@ def _add_rules_xml(self, root, rules_to_not_include, env_yaml=None):
         for rule in self.rules.values():
             if rule.id_ in rules_to_not_include:
                 continue
+            # Skip rules with CEL checks - they are not included in XCCDF/OVAL
+            # Rules with CEL checks are identified by having both expression and inputs
+            has_expression = hasattr(rule, 'expression') and rule.expression
+            has_inputs = hasattr(rule, 'inputs') and rule.inputs
+            if has_expression and has_inputs:
+                continue
             root.append(rule.to_xml_element(env_yaml))
 
     def _add_version_xml(self, root):
@@ -1283,6 +1289,12 @@ def _add_rules_xml(self, group, rules_to_not_include, env_yaml):
                 continue
             rule = self.rules.get(rule_id)
             if rule is not None:
+                # Skip rules with CEL checks - they are not included in XCCDF/OVAL
+                # Rules with CEL checks are identified by having both expression and inputs
+                has_expression = hasattr(rule, 'expression') and rule.expression
+                has_inputs = hasattr(rule, 'inputs') and rule.inputs
+                if has_expression and has_inputs:
+                    continue
                 group.append(rule.to_xml_element(env_yaml))
 
     def _add_sub_groups(self, group, components_to_not_include, env_yaml):
@@ -1664,6 +1676,11 @@ class Rule(XCCDFEntity, Templatable):
         inherited_cpe_platform_names=lambda: set(),
         bash_conditional=lambda: None,
         fixes=lambda: dict(),
+        # CEL checking engine fields (loaded from cel/shared.yml if present)
+        check_type=lambda: None,
+        inputs=lambda: list(),
+        expression=lambda: None,
+        failure_reason=lambda: None,
         **XCCDFEntity.KEYS
     )
     KEYS.update(**Templatable.KEYS)
@@ -1782,6 +1799,28 @@ def from_yaml(cls, yaml_file, env_yaml=None, product_cpes=None, sce_metadata=Non
         """
         rule = super(Rule, cls).from_yaml(yaml_file, env_yaml, product_cpes)
 
+        # Load CEL-specific content if a cel/ directory exists alongside the rule.yml
+        # This allows rules to support both CEL and OVAL checks during migration:
+        # - cel/shared.yml contains CEL expression, inputs, and check_type
+        # - template/OVAL in rule.yml provides traditional checks
+        # The build context (--cel-content vs --datastream) determines which is used.
+        rule_dir = os.path.dirname(yaml_file)
+        cel_dir = os.path.join(rule_dir, "cel")
+        cel_shared_file = os.path.join(cel_dir, "shared.yml")
+
+        if os.path.isdir(cel_dir) and os.path.isfile(cel_shared_file):
+            # Load CEL-specific YAML file
+            cel_data = open_and_expand(cel_shared_file, env_yaml)
+
+            # Merge CEL fields into the rule object
+            # These fields are only used when building CEL content
+            if "check_type" in cel_data:
+                rule.check_type = cel_data["check_type"]
+            if "inputs" in cel_data:
+                rule.inputs = cel_data["inputs"]
+            if "expression" in cel_data:
+                rule.expression = cel_data["expression"]
+
         # platforms are read as list from the yaml file
         # we need them to convert to set again
         rule.platforms = set(rule.platforms)
@@ -3188,6 +3227,9 @@ def get_benchmark_xml_by_profile(self, rule_and_variables_dict):
             )
 
         for profile in self.benchmark.profiles:
+            # Skip profiles targeting the CEL checking engine - they are not included in XCCDF/OVAL
+            if hasattr(profile, 'scanner_type') and profile.scanner_type == 'CEL':
+                continue
             if profile.single_rule_profile:
                 profiles_ids, benchmark = self.benchmark.get_benchmark_xml_for_profiles(
                     self.env_yaml, [profile], rule_and_variables_dict, include_contributors=False
@@ -3253,10 +3295,15 @@ def export_benchmark_to_xml(self, rule_and_variables_dict, ignore_single_rule_pr
         Returns:
             str: The benchmark data in XML format.
         """
+        profiles = self.benchmark.profiles
+
+        # Filter out single rule profiles if requested
         if ignore_single_rule_profiles:
-            profiles = [p for p in self.benchmark.profiles if not p.single_rule_profile]
-        else:
-            profiles = self.benchmark.profiles
+            profiles = [p for p in profiles if not p.single_rule_profile]
+
+        # Filter out profiles targeting the CEL checking engine - they are not included in XCCDF/OVAL
+        profiles = [p for p in profiles if not (hasattr(p, 'scanner_type') and p.scanner_type == 'CEL')]
+
         _, benchmark = self.benchmark.get_benchmark_xml_for_profiles(
             self.env_yaml, profiles, rule_and_variables_dict
         )
diff --git a/ssg/entities/profile_base.py b/ssg/entities/profile_base.py
index 9cbb67920b8f..230d835f969c 100644
--- a/ssg/entities/profile_base.py
+++ b/ssg/entities/profile_base.py
@@ -54,6 +54,7 @@ class Profile(XCCDFEntity, SelectionHandler):
         filter_rules=lambda: "",
         policies=lambda: list(),
         single_rule_profile=lambda: False,
+        scanner_type=lambda: None,
         ** XCCDFEntity.KEYS
     )
 
diff --git a/tests/unit/ssg-module/test_build_cel_content.py b/tests/unit/ssg-module/test_build_cel_content.py
new file mode 100644
index 000000000000..c0fbbe67fa85
--- /dev/null
+++ b/tests/unit/ssg-module/test_build_cel_content.py
@@ -0,0 +1,750 @@
+import os
+import sys
+import tempfile
+import pytest
+import json
+
+import ssg.build_yaml
+
+# Add build-scripts to path to import the module
+BUILD_SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "build-scripts"))
+sys.path.insert(0, BUILD_SCRIPTS_DIR)
+
+import build_cel_content
+
+DATADIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "data"))
+
+
+@pytest.fixture
+def cel_rule_data():
+    """Sample rule data with CEL checks matching rule.yml + cel/shared.yml format."""
+    return {
+        'documentation_complete': True,
+        'title': 'Ensure NonRoot Feature Gate is Enabled',
+        'description': 'The NonRoot feature gate restricts containers from running as root.',
+        'rationale': 'Running containers as non-root reduces security risks.',
+        'severity': 'medium',
+        'check_type': 'Platform',
+        'ocil': 'Verify that the NonRoot feature gate is enabled.',
+        'expression': 'hco.spec.featureGates.nonRoot == true',
+        'inputs': [
+            {
+                'name': 'hco',
+                'kubernetes_input_spec': {
+                    'api_version': 'hco.kubevirt.io/v1beta1',
+                    'resource': 'hyperconvergeds',
+                    'resource_name': 'kubevirt-hyperconverged',
+                    'resource_namespace': 'openshift-cnv'
+                }
+            }
+        ],
+        'references': {
+            'cis@ocp4': ['1.2.3'],
+            'nist': ['AC-6', 'CM-6']
+        }
+    }
+
+
+@pytest.fixture
+def oval_rule_data():
+    """Sample OVAL rule data (should be excluded from CEL content)."""
+    return {
+        'documentation_complete': True,
+        'title': 'Some OVAL Rule',
+        'description': 'This rule uses OVAL checks.',
+        'rationale': 'OVAL rules should not appear in CEL content.',
+        'severity': 'high',
+        'template': {
+            'name': 'yamlfile_value',
+            'vars': {
+                'filepath': '/api/test',
+                'yamlpath': '.spec.value'
+            }
+        }
+    }
+
+
+@pytest.fixture
+def cel_profile_data():
+    """Sample CEL profile data."""
+    return {
+        'documentation_complete': True,
+        'title': 'CIS Virtual Machine Extension Benchmark',
+        'description': 'Profile for virtual machine security.',
+        'scanner_type': 'CEL',
+        'selections': [
+            'kubevirt_nonroot_feature_gate_is_enabled',
+            'kubevirt_no_permitted_host_devices'
+        ]
+    }
+
+
+@pytest.fixture
+def oval_profile_data():
+    """Sample OVAL profile data (should be excluded from CEL content)."""
+    return {
+        'documentation_complete': True,
+        'title': 'Standard Profile',
+        'description': 'Standard OVAL-based profile.',
+        'selections': [
+            'some_oval_rule',
+            'another_oval_rule'
+        ]
+    }
+
+
+@pytest.fixture
+def temp_rules_dir(cel_rule_data, oval_rule_data):
+    """Create temporary directory with test rules."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        # Write rule with CEL checks - create dict with required structure
+        cel_rule_dict = dict(cel_rule_data)
+        cel_rule_dict['platforms'] = []
+        cel_rule_dict['platform'] = None
+        cel_rule_dict['inherited_platforms'] = []
+        cel_rule_dict['cpe_platform_names'] = []
+        cel_rule_path = os.path.join(tmpdir, 'kubevirt_nonroot_feature_gate_is_enabled.json')
+
+        with open(cel_rule_path, 'w') as f:
+            json.dump(cel_rule_dict, f)
+
+        # Write OVAL rule
+        oval_rule_dict = dict(oval_rule_data)
+        oval_rule_dict['platforms'] = []
+        oval_rule_dict['platform'] = None
+        oval_rule_dict['inherited_platforms'] = []
+        oval_rule_dict['cpe_platform_names'] = []
+        oval_rule_path = os.path.join(tmpdir, 'some_oval_rule.json')
+
+        with open(oval_rule_path, 'w') as f:
+            json.dump(oval_rule_dict, f)
+
+        yield tmpdir
+
+
+@pytest.fixture
+def temp_profiles_dir(cel_profile_data, oval_profile_data):
+    """Create temporary directory with test profiles."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        # Write CEL profile - create dict with required structure
+        cel_profile_dict = dict(cel_profile_data)
+        cel_profile_dict['selected'] = ['kubevirt_nonroot_feature_gate_is_enabled']
+        cel_profile_dict['platforms'] = []
+        cel_profile_dict['cpe_names'] = []
+        cel_profile_path = os.path.join(tmpdir, 'cis-vm-extension.json')
+
+        with open(cel_profile_path, 'w') as f:
+            json.dump(cel_profile_dict, f)
+
+        # Write OVAL profile
+        oval_profile_dict = dict(oval_profile_data)
+        oval_profile_dict['selected'] = ['some_oval_rule']
+        oval_profile_dict['platforms'] = []
+        oval_profile_dict['cpe_names'] = []
+        oval_profile_path = os.path.join(tmpdir, 'standard.json')
+
+        with open(oval_profile_path, 'w') as f:
+            json.dump(oval_profile_dict, f)
+
+        yield tmpdir
+
+
+def test_rule_id_to_name():
+    """Test conversion of rule IDs with underscores to names with hyphens."""
+    assert build_cel_content.rule_id_to_name('kubevirt_nonroot_enabled') == 'kubevirt-nonroot-enabled'
+    assert build_cel_content.rule_id_to_name('api_server_tls') == 'api-server-tls'
+    assert build_cel_content.rule_id_to_name('no_underscores') == 'no-underscores'
+    assert build_cel_content.rule_id_to_name('already-hyphens') == 'already-hyphens'
+
+
+def test_convert_inputs_to_camelcase():
+    """Test conversion of inputs from snake_case to camelCase for CRD compatibility."""
+    # Test with full kubernetes_input_spec
+    inputs_snake = [
+        {
+            'name': 'hco',
+            'kubernetes_input_spec': {
+                'api_version': 'hco.kubevirt.io/v1beta1',
+                'resource': 'hyperconvergeds',
+                'resource_name': 'kubevirt-hyperconverged',
+                'resource_namespace': 'openshift-cnv'
+            }
+        }
+    ]
+
+    converted = build_cel_content.convert_inputs_to_camelcase(inputs_snake)
+
+    assert len(converted) == 1
+    assert converted[0]['name'] == 'hco'
+    assert 'kubernetesInputSpec' in converted[0]
+    assert 'kubernetes_input_spec' not in converted[0]
+
+    spec = converted[0]['kubernetesInputSpec']
+    assert spec['apiVersion'] == 'hco.kubevirt.io/v1beta1'
+    assert spec['resource'] == 'hyperconvergeds'
+    assert spec['resourceName'] == 'kubevirt-hyperconverged'
+    assert spec['resourceNamespace'] == 'openshift-cnv'
+
+    # Verify snake_case keys are not in output
+    assert 'api_version' not in spec
+    assert 'resource_name' not in spec
+    assert 'resource_namespace' not in spec
+
+    # Test with minimal spec (no optional fields)
+    inputs_minimal = [
+        {
+            'name': 'pods',
+            'kubernetes_input_spec': {
+                'api_version': 'v1',
+                'resource': 'pods'
+            }
+        }
+    ]
+
+    converted_minimal = build_cel_content.convert_inputs_to_camelcase(inputs_minimal)
+    spec_minimal = converted_minimal[0]['kubernetesInputSpec']
+    assert spec_minimal['apiVersion'] == 'v1'
+    assert spec_minimal['resource'] == 'pods'
+    assert 'resourceName' not in spec_minimal
+    assert 'resourceNamespace' not in spec_minimal
+
+
+def test_extract_controls_from_references():
+    """Test extraction of controls from references dictionary."""
+    # Test with list values
+    refs = {
+        'cis@ocp4': ['1.2.3', '4.5.6'],
+        'nist': ['AC-6', 'CM-6']
+    }
+    controls = build_cel_content.extract_controls_from_references(refs)
+    assert controls == refs
+
+    # Test with string values (should be converted to list)
+    refs_str = {
+        'cis@ocp4': '1.2.3',
+        'nist': 'AC-6'
+    }
+    controls_str = build_cel_content.extract_controls_from_references(refs_str)
+    assert controls_str == {
+        'cis@ocp4': ['1.2.3'],
+        'nist': ['AC-6']
+    }
+
+    # Test with None
+    controls_none = build_cel_content.extract_controls_from_references(None)
+    assert controls_none == {}
+
+    # Test with empty dict
+    controls_empty = build_cel_content.extract_controls_from_references({})
+    assert controls_empty == {}
+
+
+def test_load_cel_rules(temp_rules_dir):
+    """Test loading rules with CEL checks from directory."""
+    cel_rules = build_cel_content.load_cel_rules(temp_rules_dir)
+
+    # Should load only the rule with CEL checks (identified by presence of expression + inputs)
+    assert len(cel_rules) == 1
+    assert 'kubevirt_nonroot_feature_gate_is_enabled' in cel_rules
+
+    rule = cel_rules['kubevirt_nonroot_feature_gate_is_enabled']
+    # Rules with CEL checks are identified by presence of expression and inputs
+    assert hasattr(rule, 'expression') and rule.expression
+    assert hasattr(rule, 'inputs') and rule.inputs
+    assert rule.title == 'Ensure NonRoot Feature Gate is Enabled'
+
+
+def test_load_cel_rules_nonexistent_dir():
+    """Test loading rules with CEL checks from nonexistent directory."""
+    cel_rules = build_cel_content.load_cel_rules('/nonexistent/path')
+    assert cel_rules == {}
+
+
+def test_load_profiles(temp_profiles_dir):
+    """Test loading CEL profiles from directory."""
+    cel_rule_ids = {'kubevirt_nonroot_feature_gate_is_enabled'}
+    profiles = build_cel_content.load_profiles(temp_profiles_dir, cel_rule_ids)
+
+    # Should load only the CEL profile
+    assert len(profiles) == 1
+    assert profiles[0].scanner_type == 'CEL'
+    assert profiles[0].title == 'CIS Virtual Machine Extension Benchmark'
+
+
+def test_load_profiles_nonexistent_dir():
+    """Test loading profiles from nonexistent directory."""
+    profiles = build_cel_content.load_profiles('/nonexistent/path', set())
+    assert profiles == []
+
+
+def test_rule_to_cel_dict(cel_rule_data):
+    """Test conversion of Rule object to CEL content dictionary."""
+    rule = ssg.build_yaml.Rule('kubevirt_nonroot_feature_gate_is_enabled')
+    for key, value in cel_rule_data.items():
+        setattr(rule, key, value)
+    rule.id_ = 'kubevirt_nonroot_feature_gate_is_enabled'
+
+    cel_dict = build_cel_content.rule_to_cel_dict(rule)
+
+    assert cel_dict['id'] == 'kubevirt_nonroot_feature_gate_is_enabled'
+    assert cel_dict['name'] == 'kubevirt-nonroot-feature-gate-is-enabled'
+    assert cel_dict['title'] == 'Ensure NonRoot Feature Gate is Enabled'
+    assert cel_dict['description'] == 'The NonRoot feature gate restricts containers from running as root.'
+    assert cel_dict['rationale'] == 'Running containers as non-root reduces security risks.'
+    assert cel_dict['severity'] == 'medium'
+    assert cel_dict['checkType'] == 'Platform'  # camelCase for output
+    assert cel_dict['instructions'] == 'Verify that the NonRoot feature gate is enabled.'
+    assert cel_dict['expression'] == 'hco.spec.featureGates.nonRoot == true'
+    assert 'inputs' in cel_dict
+    assert len(cel_dict['inputs']) == 1
+    assert cel_dict['inputs'][0]['name'] == 'hco'
+    # Check that inputs were converted to camelCase
+    assert 'kubernetesInputSpec' in cel_dict['inputs'][0]
+    assert cel_dict['inputs'][0]['kubernetesInputSpec']['apiVersion'] == 'hco.kubevirt.io/v1beta1'
+    assert cel_dict['inputs'][0]['kubernetesInputSpec']['resourceName'] == 'kubevirt-hyperconverged'
+    assert cel_dict['inputs'][0]['kubernetesInputSpec']['resourceNamespace'] == 'openshift-cnv'
+    assert 'controls' in cel_dict
+    assert cel_dict['controls']['cis@ocp4'] == ['1.2.3']
+    assert cel_dict['controls']['nist'] == ['AC-6', 'CM-6']
+
+
+def test_rule_to_cel_dict_minimal():
+    """Test conversion with minimal rule data."""
+    rule = ssg.build_yaml.Rule('minimal_rule')
+    rule.id_ = 'minimal_rule'
+    rule.title = 'Minimal Rule'
+    rule.description = 'Description'
+    rule.rationale = 'Rationale'
+    rule.severity = 'low'
+    rule.expression = 'true'
+    rule.inputs = [{'name': 'test'}]  # Required for rules with CEL checks
+    rule.references = {}
+
+    cel_dict = build_cel_content.rule_to_cel_dict(rule)
+
+    assert cel_dict['id'] == 'minimal_rule'
+    assert cel_dict['name'] == 'minimal-rule'
+    assert cel_dict['checkType'] == 'Platform'  # default, camelCase for output
+    assert 'instructions' not in cel_dict  # ocil not provided
+    assert 'failureReason' not in cel_dict  # not provided, camelCase for output
+    assert 'controls' not in cel_dict  # no references
+
+
+def test_rule_to_cel_dict_with_failure_reason():
+    """Test conversion with failure_reason field (snake_case input, camelCase output)."""
+    rule = ssg.build_yaml.Rule('test_rule')
+    rule.id_ = 'test_rule'
+    rule.title = 'Test Rule'
+    rule.description = 'Description'
+    rule.rationale = 'Rationale'
+    rule.severity = 'medium'
+    rule.check_type = 'Platform'
+    rule.expression = 'true'
+    rule.inputs = [{'name': 'test'}]  # Required for rules with CEL checks
+    rule.failure_reason = 'The configuration is not compliant'  # snake_case input
+    rule.references = {}
+
+    cel_dict = build_cel_content.rule_to_cel_dict(rule)
+
+    assert cel_dict['id'] == 'test_rule'
+    assert cel_dict['checkType'] == 'Platform'  # camelCase output
+    assert cel_dict['failureReason'] == 'The configuration is not compliant'  # camelCase output
+
+
+def test_profile_to_cel_dict(cel_profile_data):
+    """Test conversion of Profile object to CEL content dictionary."""
+    profile = ssg.build_yaml.Profile('cis_vm_extension')
+    for key, value in cel_profile_data.items():
+        setattr(profile, key, value)
+    profile.id_ = 'cis_vm_extension'
+    profile.selected = ['kubevirt_nonroot_feature_gate_is_enabled', 'kubevirt_no_permitted_host_devices']
+
+    cel_rule_ids = {'kubevirt_nonroot_feature_gate_is_enabled', 'kubevirt_no_permitted_host_devices'}
+    cel_dict = build_cel_content.profile_to_cel_dict(profile, cel_rule_ids)
+
+    assert cel_dict['id'] == 'cis_vm_extension'
+    assert cel_dict['name'] == 'cis-vm-extension'
+    assert cel_dict['title'] == 'CIS Virtual Machine Extension Benchmark'
+    assert cel_dict['description'] == 'Profile for virtual machine security.'
+    assert cel_dict['productType'] == 'Platform'
+    assert len(cel_dict['rules']) == 2
+    assert 'kubevirt-nonroot-feature-gate-is-enabled' in cel_dict['rules']
+    assert 'kubevirt-no-permitted-host-devices' in cel_dict['rules']
+    assert cel_dict['rules'] == sorted(cel_dict['rules'])  # should be sorted
+
+
+def test_profile_to_cel_dict_no_cel_rules():
+    """Test profile conversion when no rules with CEL checks are selected."""
+    profile = ssg.build_yaml.Profile('test_profile')
+    profile.id_ = 'test_profile'
+    profile.title = 'Test Profile'
+    profile.description = 'Test'
+    profile.selected = ['oval_rule_1', 'oval_rule_2']
+
+    cel_rule_ids = set()  # No rules with CEL checks
+    cel_dict = build_cel_content.profile_to_cel_dict(profile, cel_rule_ids)
+
+    assert cel_dict is None  # Should return None when no rules with CEL checks
+
+
+def test_generate_cel_content():
+    """Test generation of complete CEL content structure."""
+    # Create mock rules
+    rule1 = ssg.build_yaml.Rule('rule_one')
+    rule1.id_ = 'rule_one'
+    rule1.title = 'Rule One'
+    rule1.description = 'Description 1'
+    rule1.rationale = 'Rationale 1'
+    rule1.severity = 'high'
+    rule1.expression = 'true'
+    rule1.inputs = [{'name': 'test1'}]
+    rule1.references = {}
+
+    rule2 = ssg.build_yaml.Rule('rule_two')
+    rule2.id_ = 'rule_two'
+    rule2.title = 'Rule Two'
+    rule2.description = 'Description 2'
+    rule2.rationale = 'Rationale 2'
+    rule2.severity = 'medium'
+    rule2.expression = 'false'
+    rule2.inputs = [{'name': 'test2'}]
+    rule2.references = {}
+
+    cel_rules = {
+        'rule_one': rule1,
+        'rule_two': rule2
+    }
+
+    # Create mock profile
+    profile = ssg.build_yaml.Profile('test_profile')
+    profile.id_ = 'test_profile'
+    profile.title = 'Test Profile'
+    profile.description = 'Test Description'
+    profile.selected = ['rule_one', 'rule_two']
+
+    profiles = [profile]
+
+    content = build_cel_content.generate_cel_content(cel_rules, profiles)
+
+    assert 'profiles' in content
+    assert 'rules' in content
+    assert len(content['profiles']) == 1
+    assert len(content['rules']) == 2
+
+    # Check profile
+    assert content['profiles'][0]['id'] == 'test_profile'
+    assert len(content['profiles'][0]['rules']) == 2
+
+    # Check rules are sorted
+    rule_ids = [r['id'] for r in content['rules']]
+    assert rule_ids == sorted(rule_ids)
+
+
+def test_generate_cel_content_empty():
+    """Test generation with no rules with CEL checks or profiles."""
+    content = build_cel_content.generate_cel_content({}, [])
+
+    assert content == {'profiles': [], 'rules': []}
+
+
+def test_load_cel_rules_missing_expression():
+    """Test that rule without expression is skipped."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        # Create rule without expression (but with inputs - incomplete for CEL checks)
+        rule_dict = {
+            'documentation_complete': True,
+            'title': 'Test Rule',
+            'description': 'Test',
+            'rationale': 'Test',
+            'severity': 'medium',
+            'inputs': [{'name': 'test'}],  # Has inputs but no expression
+            'platforms': [],
+            'platform': None,
+            'inherited_platforms': [],
+            'cpe_platform_names': []
+        }
+        rule_path = os.path.join(tmpdir, 'test_rule.json')
+        with open(rule_path, 'w') as f:
+            json.dump(rule_dict, f)
+
+        # Should not raise error - rule is not identified as CEL without both expression and inputs
+        # This rule will be skipped since it doesn't have both fields
+        cel_rules = build_cel_content.load_cel_rules(tmpdir)
+        assert len(cel_rules) == 0  # Rule should be skipped
+
+
+def test_load_cel_rules_missing_inputs():
+    """Test that rule without inputs is skipped."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        # Create rule without inputs (but with expression - incomplete for CEL checks)
+        rule_dict = {
+            'documentation_complete': True,
+            'title': 'Test Rule',
+            'description': 'Test',
+            'rationale': 'Test',
+            'severity': 'medium',
+            'expression': 'true',  # Has expression but no inputs
+            'platforms': [],
+            'platform': None,
+            'inherited_platforms': [],
+            'cpe_platform_names': []
+        }
+        rule_path = os.path.join(tmpdir, 'test_rule.json')
+        with open(rule_path, 'w') as f:
+            json.dump(rule_dict, f)
+
+        # Should not raise error - rule is not identified as CEL without both expression and inputs
+        # This rule will be skipped since it doesn't have both fields
+        cel_rules = build_cel_content.load_cel_rules(tmpdir)
+        assert len(cel_rules) == 0  # Rule should be skipped
+
+
+def test_load_profiles_no_rules():
+    """Test that loading CEL profile without rules raises error."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        # Create profile without rules
+        profile_dict = {
+            'documentation_complete': True,
+            'title': 'Test Profile',
+            'description': 'Test',
+            'scanner_type': 'CEL',
+            'selections': [],
+            'selected': [],
+            'platforms': [],
+            'cpe_names': []
+        }
+        profile_path = os.path.join(tmpdir, 'test_profile.json')
+        with open(profile_path, 'w') as f:
+            json.dump(profile_dict, f)
+
+        with pytest.raises(ValueError, match="has no rules"):
+            build_cel_content.load_profiles(tmpdir, set())
+
+
+def test_generate_cel_content_duplicate_rule_names():
+    """Test that duplicate rule names are detected."""
+    # Create two rules that will have the same name after conversion to hyphens
+    # Rule IDs are different, but they convert to the same hyphenated name
+    rule1 = ssg.build_yaml.Rule('test_rule_one')
+    rule1.id_ = 'test_rule_one'
+    rule1.title = 'Test Rule 1'
+    rule1.description = 'Description 1'
+    rule1.rationale = 'Rationale 1'
+    rule1.severity = 'high'
+    rule1.expression = 'true'
+    rule1.inputs = [{'name': 'test'}]
+    rule1.references = {}
+
+    # This will convert to 'test-rule-one' - same as rule1
+    rule2 = ssg.build_yaml.Rule('test_rule_one')  # Same ID after underscore conversion
+    rule2.id_ = 'test_rule_one'
+    rule2.title = 'Test Rule 2'
+    rule2.description = 'Description 2'
+    rule2.rationale = 'Rationale 2'
+    rule2.severity = 'medium'
+    rule2.expression = 'false'
+    rule2.inputs = [{'name': 'test2'}]
+    rule2.references = {}
+
+    # Can't have duplicate keys in dict, so this test validates
+    # that if somehow we had duplicates, they'd be caught
+    # The real protection is that rule IDs themselves must be unique
+    # But the validation still checks for duplicate names after conversion
+
+    # For this test, we just verify the dict prevents duplicates at load time
+    # The build system itself prevents duplicate rule IDs
+    # So this test just documents the behavior
+    assert rule1.id_ == rule2.id_  # They're actually the same
+
+
+def test_generate_cel_content_unknown_rule_reference():
+    """Test that profile referencing unknown rule is detected."""
+    # Create a rule
+    rule1 = ssg.build_yaml.Rule('existing_rule')
+    rule1.id_ = 'existing_rule'
+    rule1.title = 'Existing Rule'
+    rule1.description = 'Description'
+    rule1.rationale = 'Rationale'
+    rule1.severity = 'high'
+    rule1.expression = 'true'
+    rule1.inputs = [{'name': 'test'}]
+    rule1.references = {}
+
+    cel_rules = {
+        'existing_rule': rule1
+    }
+
+    # Create a profile that references a non-existent rule
+    profile = ssg.build_yaml.Profile('test_profile')
+    profile.id_ = 'test_profile'
+    profile.title = 'Test Profile'
+    profile.description = 'Test'
+    profile.selected = ['existing_rule', 'nonexistent_rule']
+
+    profiles = [profile]
+
+    with pytest.raises(ValueError, match="references unknown rule 'nonexistent-rule'"):
+        build_cel_content.generate_cel_content(cel_rules, profiles)
+
+
+def test_validation_empty_expression():
+    """Test that rule with empty expression is skipped."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        # Create rule with empty expression
+        rule_dict = {
+            'documentation_complete': True,
+            'title': 'Test Rule',
+            'description': 'Test',
+            'rationale': 'Test',
+            'severity': 'medium',
+            'expression': '',  # Empty string is falsy, won't be identified as CEL
+            'inputs': [{'name': 'test'}],
+            'platforms': [],
+            'platform': None,
+            'inherited_platforms': [],
+            'cpe_platform_names': []
+        }
+        rule_path = os.path.join(tmpdir, 'test_rule.json')
+        with open(rule_path, 'w') as f:
+            json.dump(rule_dict, f)
+
+        # Empty expression means rule is not identified as CEL and is skipped
+        cel_rules = build_cel_content.load_cel_rules(tmpdir)
+        assert len(cel_rules) == 0
+
+
+def test_validation_empty_inputs():
+    """Test that rule with empty inputs list is skipped."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        # Create rule with empty inputs
+        rule_dict = {
+            'documentation_complete': True,
+            'title': 'Test Rule',
+            'description': 'Test',
+            'rationale': 'Test',
+            'severity': 'medium',
+            'expression': 'true',
+            'inputs': [],  # Empty list is falsy, won't be identified as CEL
+            'platforms': [],
+            'platform': None,
+            'inherited_platforms': [],
+            'cpe_platform_names': []
+        }
+        rule_path = os.path.join(tmpdir, 'test_rule.json')
+        with open(rule_path, 'w') as f:
+            json.dump(rule_dict, f)
+
+        # Empty inputs means rule is not identified as CEL and is skipped
+        cel_rules = build_cel_content.load_cel_rules(tmpdir)
+        assert len(cel_rules) == 0
+
+
+def test_validation_profile_with_empty_selections():
+    """Test that profile with empty selections is caught."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        # Create profile with empty selected list
+        # Note: from_compiled_json uses 'selections' to populate 'selected'
+        profile_dict = {
+            'documentation_complete': True,
+            'title': 'Test Profile',
+            'description': 'Test',
+            'scanner_type': 'CEL',
+            'selections': [],  # Empty selections
+            'selected': [],  # This gets populated from selections
+            'platforms': [],
+            'cpe_names': []
+        }
+        profile_path = os.path.join(tmpdir, 'test_profile.json')
+        with open(profile_path, 'w') as f:
+            json.dump(profile_dict, f)
+
+        with pytest.raises(ValueError, match="has no rules"):
+            build_cel_content.load_profiles(tmpdir, set())
+
+
+def test_validation_mixed_oval_and_cel_in_profile():
+    """Test that profile with both OVAL and CEL checks only includes rules with CEL checks."""
+    # Create rule with CEL checks
+    cel_rule = ssg.build_yaml.Rule('cel_rule')
+    cel_rule.id_ = 'cel_rule'
+    cel_rule.title = 'CEL Rule'
+    cel_rule.description = 'Description'
+    cel_rule.rationale = 'Rationale'
+    cel_rule.severity = 'high'
+    cel_rule.expression = 'true'
+    cel_rule.inputs = [{'name': 'test'}]
+    cel_rule.references = {}
+
+    cel_rules = {
+        'cel_rule': cel_rule
+    }
+
+    # Create a CEL profile that references both CEL and OVAL rules
+    # (OVAL rules won't be in cel_rule_ids)
+    profile = ssg.build_yaml.Profile('mixed_profile')
+    profile.id_ = 'mixed_profile'
+    profile.title = 'Mixed Profile'
+    profile.description = 'Test'
+    profile.selected = ['cel_rule', 'oval_rule']  # oval_rule doesn't have CEL checks
+
+    profiles = [profile]
+
+    # This should fail because oval_rule doesn't have CEL checks
+    with pytest.raises(ValueError, match="references unknown rule 'oval-rule'"):
+        build_cel_content.generate_cel_content(cel_rules, profiles)
+
+
+def test_validation_integration_full_flow():
+    """Integration test: validate full flow from directories to content generation."""
+    with tempfile.TemporaryDirectory() as rules_dir, tempfile.TemporaryDirectory() as profiles_dir:
+        # Create valid rule with CEL checks
+        rule_dict = {
+            'documentation_complete': True,
+            'title': 'Valid Rule with CEL Checks',
+            'description': 'This is a valid rule using the CEL checking engine',
+            'rationale': 'Security is important',
+            'severity': 'high',
+            'expression': 'resource.spec.enabled == true',
+            'inputs': [{'name': 'resource', 'kubernetes_input_spec': {'resource': 'pods'}}],
+            'platforms': [],
+            'platform': None,
+            'inherited_platforms': [],
+            'cpe_platform_names': [],
+            'references': {'cis@ocp4': ['1.2.3']}
+        }
+        rule_path = os.path.join(rules_dir, 'valid_cel_rule.json')
+        with open(rule_path, 'w') as f:
+            json.dump(rule_dict, f)
+
+        # Create valid profile targeting CEL
+        profile_dict = {
+            'documentation_complete': True,
+            'title': 'Valid Profile Targeting CEL',
+            'description': 'This is a valid profile targeting the CEL checking engine',
+            'scanner_type': 'CEL',
+            'selections': ['valid_cel_rule'],
+            'selected': ['valid_cel_rule'],
+            'platforms': [],
+            'cpe_names': []
+        }
+        profile_path = os.path.join(profiles_dir, 'valid_profile.json')
+        with open(profile_path, 'w') as f:
+            json.dump(profile_dict, f)
+
+        # Load and validate
+        cel_rules = build_cel_content.load_cel_rules(rules_dir)
+        assert len(cel_rules) == 1
+        assert 'valid_cel_rule' in cel_rules
+
+        cel_rule_ids = set(cel_rules.keys())
+        profiles = build_cel_content.load_profiles(profiles_dir, cel_rule_ids)
+        assert len(profiles) == 1
+
+        # Generate content
+        content = build_cel_content.generate_cel_content(cel_rules, profiles)
+        assert len(content['rules']) == 1
+        assert len(content['profiles']) == 1
+        assert content['rules'][0]['name'] == 'valid-cel-rule'
+        assert content['rules'][0]['expression'] == 'resource.spec.enabled == true'
+        assert content['profiles'][0]['name'] == 'valid-profile'
+        assert 'valid-cel-rule' in content['profiles'][0]['rules']
diff --git a/utils/add_kubernetes_rule.py b/utils/add_kubernetes_rule.py
index bb79b469c939..30ea08ed7b5b 100755
--- a/utils/add_kubernetes_rule.py
+++ b/utils/add_kubernetes_rule.py
@@ -406,7 +406,7 @@ def testFunc(args):
 
     if not args.skip_build:
         createTestProfile(args.rule)
-        ret_code, out = subprocess.getstatusoutput('./build_product --datastream-only ocp4')
+        ret_code, out = subprocess.getstatusoutput('./build_product --datastream ocp4 --cel-content=ocp4')
         if ret_code != 0:
             print('build failed: %s' % out)
             return 1
diff --git a/utils/build_ds_container.py b/utils/build_ds_container.py
index 896488250ab0..8382819701a0 100755
--- a/utils/build_ds_container.py
+++ b/utils/build_ds_container.py
@@ -207,7 +207,7 @@ def copy_build_files_to_output_directory(output_directory):
     build_directory = os.path.join(REPO_PATH, 'build')
     for f in os.listdir(build_directory):
         filepath = os.path.join(build_directory, f)
-        if os.path.isfile(filepath) and filepath.endswith('-ds.xml'):
+        if os.path.isfile(filepath) and (filepath.endswith('-ds.xml') or filepath.endswith('-cel-content.yaml')):
             shutil.copy(filepath, output_directory)
 
 
@@ -235,17 +235,34 @@ def create_profile_bundles(products, content_image=None):
             product_name = product
         else:
             product_name = 'upstream-' + product
+
+        # Check if CEL content exists for this product
+        cel_content_file = product + '-cel-content.yaml'
+        build_directory = os.path.join(REPO_PATH, 'build')
+        cel_content_path = os.path.join(build_directory, cel_content_file)
+
+        profile_bundle_spec = {
+            'contentImage': content_image or 'openscap-ocp4-ds:latest',
+            'contentFile': content_file
+        }
+
+        # Add celContentFile if CEL content exists
+        if os.path.isfile(cel_content_path):
+            profile_bundle_spec['celContentFile'] = cel_content_file
+            log.debug(f'Including CEL content for {product}: {cel_content_file}')
+
         profile_bundle_update = {
             'apiVersion': 'compliance.openshift.io/v1alpha1',
             'kind': 'ProfileBundle',
             'metadata': {'name': product_name},
-            'spec': {
-                'contentImage': content_image or 'openscap-ocp4-ds:latest',
-                'contentFile': content_file}}
+            'spec': profile_bundle_spec
+        }
+
         with tempfile.NamedTemporaryFile() as f:
             yaml.dump(profile_bundle_update, f, encoding='utf-8')
             command = ['kubectl', 'apply', '-n', args.namespace, '-f', f.name]
             subprocess.run(command, check=True, capture_output=CAPTURE_OUTPUT)
+
     log.info(f'Created profile bundles for {", ".join(products)}')