[RFC cip-dev][isar-cip-core][PATCH 05/14] Add cip-cis-validation to validate the hardening based on the CIS rules

Quirin Gylstorff <[email protected]> Fri, 24 Jul 2026 14:39:07 +0200
Newsgroups org.cip-project.lists.cip-dev
Message-ID <[email protected]>
From: Clara Kowalsky <[email protected]>

This adds the new recipe `cip-cis-validation` which uses cis-hardening
to validate the configuration applied by cip-cis-rules.

It uses the configuration from the recipe cip-cis-rules,
stored in HARDENING_DEPLOY_DIR to generate the necessary validation
scripts.

Co-Authored-by: Felix Moessbauer <[email protected]>
Co-Authored-by: Christoph Steiger <[email protected]>
Signed-off-by: Quirin Gylstorff <[email protected]>
---
 .../cip-cis-validation_1.0.bb                 |  44 ++++++++
 .../files/cip-cis-validation.service          |  11 ++
 .../files/cip-cis-validation.target           |   8 ++
 .../files/cip-cis-validation.timer            |   9 ++
 .../files/cip-cis-validation.tmpl             |  63 +++++++++++
 .../files/generate-overrides.py               | 100 ++++++++++++++++++
 .../cip-cis-validation/files/postinst         |  46 ++++++++
 7 files changed, 281 insertions(+)
 create mode 100644 recipes-security/cip-cis-validation/cip-cis-validation_1.0.bb
 create mode 100644 recipes-security/cip-cis-validation/files/cip-cis-validation.service
 create mode 100644 recipes-security/cip-cis-validation/files/cip-cis-validation.target
 create mode 100644 recipes-security/cip-cis-validation/files/cip-cis-validation.timer
 create mode 100644 recipes-security/cip-cis-validation/files/cip-cis-validation.tmpl
 create mode 100755 recipes-security/cip-cis-validation/files/generate-overrides.py
 create mode 100644 recipes-security/cip-cis-validation/files/postinst

diff --git a/recipes-security/cip-cis-validation/cip-cis-validation_1.0.bb b/recipes-security/cip-cis-validation/cip-cis-validation_1.0.bb
new file mode 100644
index 0000000..b6ac222
--- /dev/null
+++ b/recipes-security/cip-cis-validation/cip-cis-validation_1.0.bb
@@ -0,0 +1,44 @@
+DESCRIPTION = "CIS validation for CIP based images"
+MAINTAINER = "Clara Kowalsky <[email protected]>"
+
+inherit dpkg-raw
+
+SRC_URI = "file://${BPN}.tmpl \
+           file://${BPN}.service \
+           file://${BPN}.timer \
+           file://${BPN}.target \
+           file://generate-overrides.py \
+           file://postinst \
+           "
+
+RDEPENDS:${PN} += "cis-hardening cip-cis-rules"
+DEBIAN_DEPENDS += "cis-hardening, cip-cis-rules,"
+
+CIS ?= "/opt/cis-hardening"
+TEMPLATE_FILES += "${BPN}.tmpl"
+TEMPLATE_VARS += "CIS"
+
+do_generate_overrides[depends] += "cip-cis-rules-config:do_deploy_hardening"
+do_generate_overrides() {
+    if ! "${WORKDIR}/generate-overrides.py" \
+        --config-json "${DEPLOY_DIR_IMAGE}/${CIP_CIS_HARDENING_CONFIG}" \
+        --output "${WORKDIR}/overrides"; then
+        bberror "Failed to generate override files."
+    fi
+}
+addtask generate_overrides before do_install after do_unpack
+
+OVERRIDES_DIR ?= "/usr/share/${PN}/overrides"
+TEMPLATE_VARS += "OVERRIDES_DIR"
+
+do_install[cleandirs] += "${D}/usr/sbin \
+                          ${D}${OVERRIDES_DIR} \
+                         "
+
+do_install() {
+    install -m 755 ${WORKDIR}/${BPN} ${D}/usr/sbin
+
+    if [ -d "${WORKDIR}/overrides" ]; then
+        install -m 644 "${WORKDIR}"/overrides/*.cfg "${D}${OVERRIDES_DIR}"
+    fi
+}
diff --git a/recipes-security/cip-cis-validation/files/cip-cis-validation.service b/recipes-security/cip-cis-validation/files/cip-cis-validation.service
new file mode 100644
index 0000000..b476468
--- /dev/null
+++ b/recipes-security/cip-cis-validation/files/cip-cis-validation.service
@@ -0,0 +1,11 @@
+[Unit]
+Description=CIS validation
+After=network-online.target ssh.service
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart=/usr/sbin/cip-cis-validation
+
+[Install]
+WantedBy=cip-cis-validation.target
diff --git a/recipes-security/cip-cis-validation/files/cip-cis-validation.target b/recipes-security/cip-cis-validation/files/cip-cis-validation.target
new file mode 100644
index 0000000..4016b7a
--- /dev/null
+++ b/recipes-security/cip-cis-validation/files/cip-cis-validation.target
@@ -0,0 +1,8 @@
+[Unit]
+Description=CIS validation target
+Requires=cip-cis-validation.service
+After=cip-cis-validation.service
+After=multi-user.target
+
+[Install]
+WantedBy=multi-user.target
diff --git a/recipes-security/cip-cis-validation/files/cip-cis-validation.timer b/recipes-security/cip-cis-validation/files/cip-cis-validation.timer
new file mode 100644
index 0000000..9309d7a
--- /dev/null
+++ b/recipes-security/cip-cis-validation/files/cip-cis-validation.timer
@@ -0,0 +1,9 @@
+[Unit]
+Description=CIS validation timer
+
+[Timer]
+OnCalendar=*-*-* 05:00:00
+Unit=cip-cis-validation.service
+
+[Install]
+WantedBy=timers.target
diff --git a/recipes-security/cip-cis-validation/files/cip-cis-validation.tmpl b/recipes-security/cip-cis-validation/files/cip-cis-validation.tmpl
new file mode 100644
index 0000000..36beef7
--- /dev/null
+++ b/recipes-security/cip-cis-validation/files/cip-cis-validation.tmpl
@@ -0,0 +1,63 @@
+#!/bin/bash
+
+tmp_root=$(mktemp -d) || exit 1
+default_file_mounted=0
+tmp_root_mounted=0
+
+trap '[ "$default_file_mounted" -eq 1 ] && umount /etc/default/cis-hardening; [ "$tmp_root_mounted" -eq 1 ] && umount "$tmp_root"; rm -rf "$tmp_root"' EXIT
+
+mount -t tmpfs -o mode=700,nodev,nosuid,exec tmpfs "$tmp_root" || exit 1
+tmp_root_mounted=1
+
+# Work in a temporary CIS tree
+cp -a "$CIS/etc" "$CIS/versions" "$CIS/bin" "$tmp_root/" || exit 1
+
+# Redirect CIS_CONF_DIR and CIS_VERSIONS_DIR via /etc/default/cis-hardening
+cp /etc/default/cis-hardening "$tmp_root/cis-hardening" || exit 1
+sed -i \
+    -e "s#^CIS_CONF_DIR=.*#CIS_CONF_DIR='$tmp_root/etc'#" \
+    -e "s#^CIS_VERSIONS_DIR=.*#CIS_VERSIONS_DIR='$tmp_root/versions'#" \
+    "$tmp_root/cis-hardening" || exit 1
+mount --bind "$tmp_root/cis-hardening" /etc/default/cis-hardening || exit 1
+default_file_mounted=1
+
+conf_dir="$tmp_root/etc/conf.d"
+versions_dir="$tmp_root/versions"
+
+# Create missing rule cfg files in the temporary conf.d.
+"$CIS"/bin/hardening.sh --create-config-files-only --allow-unsupported-distribution >/dev/null 2>&1 || true
+
+# Apply SSH overrides
+for ov in /usr/share/cip-cis-validation/ssh-overrides/*.cfg; do
+    [ -r "$ov" ] && cat "$ov" >> "$conf_dir/$(basename "$ov")"
+done
+
+# Apply CIS overrides
+for ov in /usr/share/cip-cis-validation/cis-overrides/*.cfg; do
+    [ -r "$ov" ] && install -m 644 "$ov" "$conf_dir/$(basename "$ov")"
+done
+
+# Drop non-CIS rules (99.*)
+find "$versions_dir" -name "99.*.sh" -delete
+
+AUDIT=$(
+    if ! "${CIS}"/bin/hardening.sh --audit --batch --allow-unsupported-distribution 2>&1; then
+        echo "CIS hardening script failed to execute." >&2
+        exit 1
+    fi
+) || exit 1
+
+echo "$AUDIT"
+
+CONFORMITY_PERCENTAGE=$(echo "$AUDIT" | grep -oP 'CONFORMITY_PERCENTAGE:\K[0-9.]+' | tail -n 1)
+
+if [ -z "$CONFORMITY_PERCENTAGE" ]; then
+    echo "Error: CONFORMITY_PERCENTAGE not found in CIS validation output." >&2
+    exit 1
+elif awk -v p="$CONFORMITY_PERCENTAGE" 'BEGIN {exit !(p == 100.00)}'; then
+    echo "CIS validation conformity is 100%."
+    exit 0
+else
+    echo "CIS validation conformity is ${CONFORMITY_PERCENTAGE}%, which is less than 100%." >&2
+    exit 1
+fi
diff --git a/recipes-security/cip-cis-validation/files/generate-overrides.py b/recipes-security/cip-cis-validation/files/generate-overrides.py
new file mode 100755
index 0000000..6a12429
--- /dev/null
+++ b/recipes-security/cip-cis-validation/files/generate-overrides.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+#
+# Generate override files for disabled CIS hardening rules
+#
+# Usage:
+#   python3 generate_overrides.py --config-json .config.json --output overrides
+#
+
+import argparse
+import json
+import os
+import logging
+
+logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
+logger = logging.getLogger(__name__)
+
+class OverrideGenerator:
+    def __init__(self, config_json_path, output):
+        self.config_json_path = config_json_path
+        self.output = output
+        self.rules_data = []
+
+    def load_config_json(self):
+        try:
+            with open(self.config_json_path, 'r') as f:
+                self.rules_data = json.load(f).get('rules', [])
+        except FileNotFoundError:
+            raise FileNotFoundError(
+                f"Config file not found: '{self.config_json_path}'"
+            )
+        except json.JSONDecodeError as e:
+            raise ValueError(
+                f"Invalid JSON in config file '{self.config_json_path}': {e}"
+            )
+        logger.info(f"Successfully loaded configuration from '{self.config_json_path}'")
+
+    def generate_files(self):
+        if not self.rules_data:
+            logger.warning("No rules data loaded. Skipping file generation.")
+            return
+
+        errors_during_generation = []
+        generated_count = 0
+
+        for entry in self.rules_data:
+            rule = entry.get('rule', {})
+            if not rule.get('apply', True):
+                name = rule.get('name')
+                description = rule.get('description')
+                if name is None or description is None:
+                    raise ValueError(
+                        f"Rule entry missing required field(s): "
+                        f"name={name!r}, description={description!r}"
+                    )
+                description = description.replace(' ', '_')
+                justification = rule.get('justification', f"Rule '{name}' disabled by user configuration.")
+
+                os.makedirs(self.output, exist_ok=True)
+                logger.info(f"Ensured output directory exists: '{self.output}'")
+                # Sanitize path components so a description/name cannot escape output dir.
+                safe_name = os.path.basename(str(name))
+                safe_desc = os.path.basename(description)
+                filepath = os.path.join(self.output, f"{safe_name}_{safe_desc}.cfg")
+                content = f"# {justification}\nstatus=disabled\n"
+
+                try:
+                    with open(filepath, 'w') as f:
+                        f.write(content)
+                    logger.info(f"Generated '{os.path.basename(filepath)}' for disabled rule '{name}'")
+                    generated_count += 1
+                except IOError as e:
+                    errors_during_generation.append(f"Error writing file '{filepath}' for rule '{name}': {e}")
+            else:
+                logger.info(f"Rule '{rule.get('name')}' is applied, skipping override file generation.")
+
+        if errors_during_generation:
+            for err in errors_during_generation:
+                logger.error(err)
+            raise Exception(f"Completed generation with {len(errors_during_generation)} errors. Generated {generated_count} files.")
+        else:
+            logger.info(f"Generated {generated_count} override files in '{self.output}'")
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Generate override files for disabled CIS hardening rules from .config.json"
+    )
+    parser.add_argument("--config-json", help="Path to .config.json", default=".config.json")
+    parser.add_argument("--output", help="Directory for override files", default="overrides")
+    args = parser.parse_args()
+
+    generator = OverrideGenerator(args.config_json, args.output)
+    generator.load_config_json()
+    generator.generate_files()
+
+if __name__ == "__main__":
+    try:
+        main()
+    except Exception as e:
+        logger.error(e)
+        raise
diff --git a/recipes-security/cip-cis-validation/files/postinst b/recipes-security/cip-cis-validation/files/postinst
new file mode 100644
index 0000000..2a40a25
--- /dev/null
+++ b/recipes-security/cip-cis-validation/files/postinst
@@ -0,0 +1,46 @@
+#!/bin/sh
+# Resolve override mappings at package install time
+
+set -e
+
+CIS=/opt/cis-hardening
+SSH_OVERRIDES=/usr/share/cip-cis-rules/conf.d-overrides
+CIS_OVERRIDES=/usr/share/cip-cis-validation/overrides
+SSH_RESOLVED=/usr/share/cip-cis-validation/ssh-overrides
+CIS_RESOLVED=/usr/share/cip-cis-validation/cis-overrides
+
+mkdir -p "$SSH_RESOLVED" "$CIS_RESOLVED"
+# Remove stale mappings
+rm -f "$SSH_RESOLVED"/*.cfg "$CIS_RESOLVED"/*.cfg
+
+# Map SSH overrides to real cfg targets
+if [ -d "$SSH_OVERRIDES" ]; then
+    for ov in "$SSH_OVERRIDES"/cis-*.cfg; do
+        [ -r "$ov" ] || continue
+        rule_id=$(basename "$ov" .cfg | sed -E 's/^cis-([0-9.]+)-.*/\1/')
+        sym=$(find -L "$CIS/versions/default" -maxdepth 1 \
+              -name "${rule_id}_*.sh" 2>/dev/null | head -n1)
+        if [ -z "$sym" ]; then
+            echo "cip-cis-validation postinst: no rule for $rule_id, skipping $(basename "$ov")" >&2
+            continue
+        fi
+        real_cfg=$(basename "$(readlink -f "$sym")" .sh).cfg
+        ln -sf "$ov" "$SSH_RESOLVED/$real_cfg"
+    done
+fi
+
+# Map generated CIS overrides generated by generate-overrides.py to real cfg targets
+if [ -d "$CIS_OVERRIDES" ]; then
+    for ov in "$CIS_OVERRIDES"/*.cfg; do
+        [ -r "$ov" ] || continue
+        short=$(basename "$ov" .cfg)
+        sym=$(find -L "$CIS/versions/default" -maxdepth 1 \
+              -name "${short}.sh" 2>/dev/null | head -n1)
+        if [ -z "$sym" ]; then
+            echo "cip-cis-validation postinst: no rule for $short, skipping" >&2
+            continue
+        fi
+        real_cfg=$(basename "$(readlink -f "$sym")" .sh).cfg
+        ln -sf "$ov" "$CIS_RESOLVED/$real_cfg"
+    done
+fi
-- 
2.53.0