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

Quirin Gylstorff <[email protected]>
Newsgroups org.cip-project.lists.cip-dev
Message-ID <[email protected]>
From: Clara Kowalsky <[email protected]>

This adds the new recipe `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: Clara Kowalsky <[email protected]>
Signed-off-by: Quirin Gylstorff <[email protected]>
---
 .../cis-validation/cis-validation_1.0.bb      |  51 +++++++++
 .../files/cis-validation.service              |  13 +++
 .../cis-validation/files/cis-validation.tmpl  |  65 +++++++++++
 .../files/generate-overrides.py               | 103 ++++++++++++++++++
 .../cis-validation/files/postinst             |  52 +++++++++
 5 files changed, 284 insertions(+)
 create mode 100644 recipes-security/cis-validation/cis-validation_1.0.bb
 create mode 100644 recipes-security/cis-validation/files/cis-validation.service
 create mode 100644 recipes-security/cis-validation/files/cis-validation.tmpl
 create mode 100755 recipes-security/cis-validation/files/generate-overrides.py
 create mode 100644 recipes-security/cis-validation/files/postinst

diff --git a/recipes-security/cis-validation/cis-validation_1.0.bb b/recipes-security/cis-validation/cis-validation_1.0.bb
new file mode 100644
index 00000000..1cff3485
--- /dev/null
+++ b/recipes-security/cis-validation/cis-validation_1.0.bb
@@ -0,0 +1,51 @@
+# CIP Core, generic profile
+#
+# Copyright (c) Siemens AG, 2024
+#
+# Authors:
+#  Quirin Gylstorff <[email protected]>
+#
+# SPDX-License-Identifier: MIT
+#
+DESCRIPTION = "CIS validation for CIP based images"
+MAINTAINER = "Clara Kowalsky <[email protected]>"
+
+inherit dpkg-raw
+
+SRC_URI = "file://${BPN}.tmpl \
+           file://${BPN}.service \
+           file://generate-overrides.py \
+           file://postinst \
+           "
+
+RDEPENDS:${PN} += "cis-hardening cip-cis-rules-config-${MACHINE}"
+DEBIAN_DEPENDS += "cis-hardening, cip-cis-rules-config-${MACHINE},"
+
+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/bin \
+                          ${D}${OVERRIDES_DIR} \
+                         "
+
+do_install() {
+    install -m 755 ${WORKDIR}/${BPN} ${D}/usr/bin
+
+    if [ -d "${WORKDIR}/overrides" ]; then
+        install -m 644 "${WORKDIR}"/overrides/*.cfg "${D}${OVERRIDES_DIR}"
+    fi
+}
diff --git a/recipes-security/cis-validation/files/cis-validation.service b/recipes-security/cis-validation/files/cis-validation.service
new file mode 100644
index 00000000..21286be0
--- /dev/null
+++ b/recipes-security/cis-validation/files/cis-validation.service
@@ -0,0 +1,13 @@
+# Copyright (c) Siemens AG, 2026
+# SPDX-License-Identifier: MIT
+[Unit]
+Description=CIS validation
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/cis-validation
+
+[Install]
+WantedBy=multi-user.target
diff --git a/recipes-security/cis-validation/files/cis-validation.tmpl b/recipes-security/cis-validation/files/cis-validation.tmpl
new file mode 100644
index 00000000..e4efebb2
--- /dev/null
+++ b/recipes-security/cis-validation/files/cis-validation.tmpl
@@ -0,0 +1,65 @@
+#!/bin/bash
+# Copyright (c) Siemens AG, 2026
+# SPDX-License-Identifier: MIT
+
+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/cis-validation/ssh-overrides/*.cfg; do
+    [ -r "$ov" ] && cat "$ov" >> "$conf_dir/$(basename "$ov")"
+done
+
+# Apply CIS overrides
+for ov in /usr/share/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/cis-validation/files/generate-overrides.py b/recipes-security/cis-validation/files/generate-overrides.py
new file mode 100755
index 00000000..e45e2151
--- /dev/null
+++ b/recipes-security/cis-validation/files/generate-overrides.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+#
+# SPDX-FileCopyrightText: 2026 Siemens AG
+# SPDX-License-Identifier: MIT
+#
+# 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/cis-validation/files/postinst b/recipes-security/cis-validation/files/postinst
new file mode 100644
index 00000000..a9f706de
--- /dev/null
+++ b/recipes-security/cis-validation/files/postinst
@@ -0,0 +1,52 @@
+#!/bin/sh
+# Copyright (c) Siemens AG, 2026
+# SPDX-License-Identifier: MIT
+# 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/cis-validation/overrides
+SSH_RESOLVED=/usr/share/cis-validation/ssh-overrides
+CIS_RESOLVED=/usr/share/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 "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 "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
+
+#DEBHELPER#
+
+exit 0
-- 
2.55.0
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.