[cip-dev][isar-cip-core][PATCH v2 04/12] Add cip-cis-rules-config to configure the hardening according to CIS

Quirin Gylstorff <[email protected]>
Newsgroups org.cip-project.lists.cip-dev
Message-ID <[email protected]>
The `cip-cis-rules-config` recipe allows to create a use-case specific
hardening configuration based on the CIS hardening rules.

To tailor a hardening config the command `bitbake -c menuconfig cip-cis-rules`
can be used to generate the hardening config interactively.

After the package build the generated config can be found at
`${DEPLOYDIR_HARDENING}/${CIP_CIS_HARDENING_CONFIG}`.

The package also provides a kernel config snippet
`${DEPLOYDIR_HARDENING}/${CIP_CIS_HARDENING_KCONFIG}`, which can be used
to apply kernel hardening during compile instead of a runtime
configuration. If the user changes the config.json all followup jobs are
tainted. The code for this is from
https://github.com/yoctoproject/poky/blob/077627338ac18aeca34bfe0c52777fab38e2e0c0/meta/classes-recipe/cml1.bbclass#L37 .

This packages generates the Kconfig out of cis-debian.

Co-Authored-by: Clara Kowalsky <[email protected]>
Co-Authored-by: Felix Moessbauer <[email protected]>
Co-Authored-by: Christoph Steiger <[email protected]>
Signed-off-by: Quirin Gylstorff <[email protected]>
---
 .../cip-cis-rules-config.bb                   | 162 +++++
 .../cip-cis-rules-config/files/postinst       |  32 +
 .../cip-cis-rules-config/files/rules.tmpl     |  19 +
 .../files/scripts/cis-hardening-to-kconfig.py | 350 ++++++++++
 .../files/scripts/config.schema.json          | 124 ++++
 .../files/scripts/enable-cis-rules.py         | 333 ++++++++++
 .../files/scripts/gen-kernel-config.py        | 165 +++++
 .../files/scripts/select-hardenings.py        | 618 ++++++++++++++++++
 8 files changed, 1803 insertions(+)
 create mode 100644 recipes-security/cip-cis-rules-config/cip-cis-rules-config.bb
 create mode 100644 recipes-security/cip-cis-rules-config/files/postinst
 create mode 100644 recipes-security/cip-cis-rules-config/files/rules.tmpl
 create mode 100755 recipes-security/cip-cis-rules-config/files/scripts/cis-hardening-to-kconfig.py
 create mode 100644 recipes-security/cip-cis-rules-config/files/scripts/config.schema.json
 create mode 100644 recipes-security/cip-cis-rules-config/files/scripts/enable-cis-rules.py
 create mode 100755 recipes-security/cip-cis-rules-config/files/scripts/gen-kernel-config.py
 create mode 100755 recipes-security/cip-cis-rules-config/files/scripts/select-hardenings.py

diff --git a/recipes-security/cip-cis-rules-config/cip-cis-rules-config.bb b/recipes-security/cip-cis-rules-config/cip-cis-rules-config.bb
new file mode 100644
index 00000000..c4f836d2
--- /dev/null
+++ b/recipes-security/cip-cis-rules-config/cip-cis-rules-config.bb
@@ -0,0 +1,162 @@
+# CIP Core, generic profile
+#
+# Copyright (c) Siemens AG, 2026
+#
+# Authors:
+#  Quirin Gylstorff <[email protected]>
+#
+# SPDX-License-Identifier: MIT
+#
+inherit dpkg-raw
+
+MAINTAINER = "Christoph Steiger <[email protected]>"
+DESCRIPTION = "Configuration files for CIS hardening"
+
+PROVIDES := "${PN}"
+DEBIAN_PROVIDES := "${PN}"
+
+DEPENDS = "cip-cis-rules cis-hardening"
+DEBIAN_BUILD_DEPENDS = "cip-cis-rules, cis-hardening, python3, python3-kconfiglib, python3-newt, python3-jsonschema"
+
+PN .= "-${MACHINE}"
+
+S = "${WORKDIR}/${BPN}"
+
+DEPLOYDIR_HARDENING = "${WORKDIR}/deploy-hardening"
+SSTATETASKS += "do_deploy_hardening"
+
+HARDENING_CFG ?= ""
+HARDENING_CFG_NAME = "${@ d.getVar('HARDENING_CFG') if d.getVar('HARDENING_CFG') else 'config.json'}"
+
+SRC_URI += " \
+    file://scripts;subdir=${S} \
+    file://rules.tmpl \
+    file://postinst \
+    "
+SRC_URI += "${@ 'file://' + d.getVar('HARDENING_CFG') if d.getVar('HARDENING_CFG') else ''}"
+
+TEMPLATE_VARS += "HARDENING_CFG_NAME"
+TEMPLATE_FILES += "rules.tmpl"
+
+do_deploy_hardening[cleandirs] += "${DEPLOYDIR_HARDENING}"
+do_deploy_hardening[sstate-inputdirs] = "${DEPLOYDIR_HARDENING}"
+do_deploy_hardening[sstate-outputdirs] = "${DEPLOY_DIR_IMAGE}"
+do_deploy_hardening() {
+    if [ -f "${S}/${HARDENING_CFG_NAME}" ]; then
+        cp "${S}/${HARDENING_CFG_NAME}" "${DEPLOYDIR_HARDENING}/${CIP_CIS_HARDENING_CONFIG}"
+    fi
+    if [ -f "${WORKDIR}/hardening.cfg" ]; then
+        cp "${WORKDIR}/hardening.cfg" "${DEPLOYDIR_HARDENING}/${CIP_CIS_HARDENING_KCONFIG}"
+    fi
+}
+
+python do_deploy_hardening_setscene () {
+    sstate_setscene(d)
+}
+addtask deploy_hardening_setscene
+
+addtask deploy_hardening after do_dpkg_build before do_deploy_deb
+
+do_check_config() {
+    if [ -f "${WORKDIR}/${HARDENING_CFG_NAME}" ]; then
+        install "${WORKDIR}/${HARDENING_CFG_NAME}" "${S}/${HARDENING_CFG_NAME}"
+    fi
+
+    if [ ! -s "${S}/${HARDENING_CFG_NAME}" ]; then
+        bbfatal "Hardening configuration is missing. Please run 'bitbake cip-cis-rules-config -c menuconfig' and include the generated ${WORKDIR}/${HARDENING_CFG_NAME} in your SRC_URI and HARDENING_CFG_NAME."
+    fi
+}
+
+addtask check_config after do_unpack before do_generate_kernel_config
+
+do_generate_kernel_config() {
+    if [ -f "${S}/${HARDENING_CFG_NAME}" ]; then
+        "${S}/scripts/gen-kernel-config.py" --config "${S}/${HARDENING_CFG_NAME}" --output "${WORKDIR}/hardening.cfg"
+    fi
+}
+
+addtask generate_kernel_config before do_install after do_transform_template
+
+do_install[cleandirs] = "${D}/usr/share/cip-cis-rules \
+                         ${D}/etc"
+do_install() {
+    if [ -f "${S}/${HARDENING_CFG_NAME}" ]; then
+        install -v -m 644 "${S}/${HARDENING_CFG_NAME}" "${D}/usr/share/cip-cis-rules/config.json"
+    fi
+    if [ -f "${WORKDIR}/hardening.cfg" ]; then
+        install -v -m 644 "${WORKDIR}/hardening.cfg" "${D}/usr/share/cip-cis-rules/hardening.cfg"
+    fi
+}
+
+do_menuconfig[nostamp] = "1"
+do_menuconfig[dirs] = "${DEVSHELL_STARTDIR}"
+do_menuconfig[network] = "${TASK_USE_SUDO}"
+do_menuconfig[depends] = "cip-cis-rules:do_deploy_deb cis-hardening:do_deploy_deb"
+python do_menuconfig() {
+    isar_export_proxies(d)
+    isar_export_ccache(d)
+    isar_export_build_settings(d)
+
+    bb.build.exec_func('devshell_chroot_prepare', d)
+
+    schroot = d.getVar('SBUILD_CHROOT')
+    pkg_arch = d.getVar('PACKAGE_ARCH')
+    build_arch = d.getVar('BUILD_ARCH')
+    pp_pps = os.path.join(d.getVar('PP'), d.getVar('PPS'))
+    srcdir = d.getVar('S')
+    config = d.getVar('HARDENING_CFG_NAME')
+    configfile = os.path.join(srcdir, config)
+
+    try:
+        mtime = os.path.getmtime(configfile)
+    except OSError:
+        mtime = 0
+
+    install_deps = ":" if d.getVar('BB_CURRENTTASK') == "devshell_nodeps" else f"mk-build-deps -i \
+        --host-arch {pkg_arch} --build-arch {build_arch}  \
+        -t \"apt-get -y -q -o Debug::pkgProblemResolver=yes --no-install-recommends --allow-downgrades\" \
+        debian/control"
+
+    termcmd = "cd {0}; \
+        apt-get -y -q update -o Dir::Etc::SourceList=\"sources.list.d/isar-apt.list\" -o Dir::Etc::SourceParts=\"-\" -o APT::Get::List-Cleanup=\"0\"; \
+        apt-get -y upgrade; \
+        {1}; \
+        if [ -n \"$PATH_PREPEND\" ]; then export PATH=$PATH_PREPEND:$PATH; fi; \
+        {0}/scripts/cis-hardening-to-kconfig.py /opt/cis-hardening/versions/default > {0}/Kconfig; \
+        {0}/scripts/select-hardenings.py --output {0}/{2} {0}/Kconfig".format(pp_pps, install_deps, config)
+
+    if d.getVar('ISAR_CHROOT_MODE') == 'unshare':
+        mounts = d.getVar('SCHROOT_MOUNTS')
+        mounts += ' {}:/home/builder/{}'.format(d.getVar('WORKDIR'), d.getVar('BPN'))
+
+        if bb.utils.to_boolean(d.getVar('USE_CCACHE')):
+            bb.build.exec_func('dpkg_prepare_unshare_ccache', d)
+            mounts += ' {}:/ccache'.format(d.getVar('CCACHE_DIR'))
+
+        termcmd = """{0} \
+sh -c "{1};cp /etc/resolv.conf {2}/etc;chroot {2} sh -c '{3}'"
+""".format(
+        run_privileged_cmd(d),
+        insert_isar_mounts(d, d.getVar('DEVSHELL_UNSHARE_ROOTFS'), mounts),
+        d.getVar('DEVSHELL_UNSHARE_ROOTFS'),
+        termcmd.replace('"', "\\\""))
+    else:
+        termcmd = "schroot -d / -c {0} -u root -- sh -c '{1}'".format(schroot, termcmd)
+    oe_terminal(termcmd, "Generate Kconfig", d)
+
+    bb.build.exec_func('devshell_chroot_finalize', d)
+
+    try:
+        newmtime = os.path.getmtime(configfile)
+    except OSError:
+        newmtime = 0
+
+    if newmtime > mtime:
+        bb.note("Configuration changed, recompile will be forced")
+        bb.build.write_taint('do_generate_kernel_config', d)
+        bb.build.write_taint('do_deploy_hardening', d)
+        bb.build.write_taint('do_install', d)
+        bb.build.write_taint('do_dpkg_build', d)
+
+}
+addtask menuconfig after do_prepare_build
diff --git a/recipes-security/cip-cis-rules-config/files/postinst b/recipes-security/cip-cis-rules-config/files/postinst
new file mode 100644
index 00000000..1768a539
--- /dev/null
+++ b/recipes-security/cip-cis-rules-config/files/postinst
@@ -0,0 +1,32 @@
+#!/bin/sh
+# Copyright (c) Siemens AG, 2026
+# SPDX-License-Identifier: MIT
+
+set -e
+
+CIP_CIS_CONF_DIR=/usr/share/cip-cis-rules/configuration
+
+case "$1" in
+    configure)
+        if [ -d "$CIP_CIS_CONF_DIR" ]; then
+            for script in "$CIP_CIS_CONF_DIR"/*.sh; do
+                [ -r "$script" ] || continue
+                if ! sh "$script"; then
+                    echo "cip-cis-rules: $(basename "$script") failed" >&2
+                    exit 1
+                fi
+            done
+        fi
+        ;;
+    abort-upgrade|abort-remove|abort-deconfigure)
+        ;;
+    *)
+        echo "postinst called with unknown argument '$1'" >&2
+        exit 1
+        ;;
+esac
+
+#DEBHELPER#
+
+exit 0
+
diff --git a/recipes-security/cip-cis-rules-config/files/rules.tmpl b/recipes-security/cip-cis-rules-config/files/rules.tmpl
new file mode 100644
index 00000000..811668be
--- /dev/null
+++ b/recipes-security/cip-cis-rules-config/files/rules.tmpl
@@ -0,0 +1,19 @@
+#!/usr/bin/make -f
+# Copyright (c) Siemens AG, 2026
+# SPDX-License-Identifier: MIT
+PACKAGE	       := cip-cis-rules-config
+CONFIG_JSON    := ${HARDENING_CFG_NAME}
+DESTDIR        := image/
+
+%:
+	dh $@
+
+# Copy enabled config snippets into the main package staging tree.
+# Install the rule-selection config and kernel fragment into the dev package
+# so that build systems and validation tools can consume them.
+override_dh_auto_install:
+	python3 scripts/enable-cis-rules.py \
+		--config $(CONFIG_JSON) \
+		--snippets-dir /usr/share/cip-cis-rules \
+		--output-dir $(DESTDIR)
+	dh_auto_install
diff --git a/recipes-security/cip-cis-rules-config/files/scripts/cis-hardening-to-kconfig.py b/recipes-security/cip-cis-rules-config/files/scripts/cis-hardening-to-kconfig.py
new file mode 100755
index 00000000..cb579f6a
--- /dev/null
+++ b/recipes-security/cip-cis-rules-config/files/scripts/cis-hardening-to-kconfig.py
@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+#
+# SPDX-FileCopyrightText: 2026 Siemens AG
+# SPDX-License-Identifier: MIT
+#
+
+import argparse
+from pathlib import Path
+import re
+
+SECTIONS = {
+    "1 Initial Setup": {
+        "1.1 Filesystem Configuration": {},
+        "1.2 Software Updates": {},
+        "1.3 Filesystem Integrity Checking": {},
+        "1.4 Secure Boot Settings": {},
+        "1.5 Additional Process Hardening": {},
+        "1.6 Mandatory Access Control": {},
+        "1.7 CLI Warning Banners": {},
+        "1.8 Gnome Display Manager": {},
+    },
+    "2 Services": {
+        "2.1 Time Synchronization": {},
+        "2.2 Special Purpose Services": {},
+        "2.3 Service Clients": {},
+    },
+    "3 Network Configuration": {
+        "3.1 Protocols and Devices": {},
+        "3.2 Parameters (Host Only)": {},
+        "3.3 Parameters (Host and Router)": {},
+        "3.5 Firewall": {},
+    },
+    "4 Logging and Auditing": {
+        "4.1 System Accounting (auditd)": {},
+        "4.2 Logging": {},
+        "4.4 Ensure logrotate assigns appropriate permissions": {},
+    },
+    "5 Access and Auth": {
+        "5.1 Time-based Job Schedulers": {},
+        "5.2 SSH Server": {},
+        "5.3 Privilege Escalation": {},
+        "5.4 PAM": {},
+        "5.5 User Accounts and Environment": {},
+        "5.6 Restrict Access to su": {}
+    },
+    "6 System Maintenance": {
+        "6.1 System File Permissions": {},
+        "6.2 Local User and Group Settings": {},
+    },
+}
+
+# Maps ID of a rule and the additional Kconfig dependencies that it requires
+SPECIAL_RULES = {
+    "1.1.6": "CIP_VAR_PART",
+    "1.1.6.1": "CIP_VAR_PART",
+    "1.1.6.2": "CIP_VAR_PART",
+    "1.1.7": "CIP_VAR_TMP_PART",
+    "1.1.8": "CIP_VAR_TMP_PART",
+    "1.1.9": "CIP_VAR_TMP_PART",
+    "1.1.10": "CIP_VAR_TMP_PART",
+    "1.1.11": "CIP_VAR_LOG_PART",
+    "1.1.11.1": "CIP_VAR_LOG_PART",
+    "1.1.11.2": "CIP_VAR_LOG_PART",
+    "1.1.11.3": "CIP_VAR_LOG_PART",
+    "1.1.12": "CIP_VAR_LOG_AUDIT_PART",
+    "1.1.12.1": "CIP_VAR_LOG_AUDIT_PART",
+    "1.1.12.2": "CIP_VAR_LOG_AUDIT_PART",
+    "1.1.12.3": "CIP_VAR_LOG_AUDIT_PART",
+    "1.1.13": "CIP_HOME_PART",
+    "1.1.14": "CIP_HOME_PART",
+    "1.1.14.1": "CIP_HOME_PART",
+    "1.5.1": "CIP_GRUB_BOOTLOADER",
+    "5.1.2": "CIP_CRON",
+    "5.1.3": "CIP_CRON",
+    "5.1.4": "CIP_CRON",
+    "5.1.5": "CIP_CRON",
+    "5.1.6": "CIP_CRON",
+    "5.1.7": "CIP_CRON",
+    "5.1.8": "CIP_CRON",
+}
+
+SPECIAL_RULE_JUSTIFICATIONS = {
+    "CIP_VAR_PART": "Not applicable: no separate /var partition is configured.",
+    "CIP_VAR_TMP_PART": "Not applicable: no separate /var/tmp partition is configured.",
+    "CIP_VAR_LOG_PART": "Not applicable: no separate /var/log partition is configured.",
+    "CIP_VAR_LOG_AUDIT_PART": "Not applicable: no separate /var/log/audit partition is configured.",
+    "CIP_HOME_PART": "Not applicable: no separate /home partition is configured.",
+    "CIP_GRUB_BOOTLOADER": "Not applicable: the GRUB bootloader is not used.",
+    "CIP_CRON": "Not applicable: cron is not used on this system.",
+}
+
+
+class Rule:
+    def __init__(self, rule: Path):
+        if "_" not in rule.name:
+            raise ValueError(
+                f"Rule filename '{rule.name}' does not match expected '<id>_<name>' format"
+            )
+        id, name = rule.name.split("_", 1)
+        self.id = [int(i) for i in id.split(".")]
+        self.name = name.split(".")[0].replace("_", " ")
+        self.description = None
+        self.hardening_level = 5
+        with open(rule, "r") as f:
+            for line in f:
+                line = line.strip()
+                if line.startswith("DESCRIPTION="):
+                    self.description = line.split("=", 1)[1].strip('"')
+                elif line.startswith("HARDENING_LEVEL="):
+                    raw = line.split("=", 1)[1].strip()
+                    try:
+                        self.hardening_level = int(raw)
+                    except ValueError:
+                        raise ValueError(
+                            f"Rule '{rule.name}' has non-integer HARDENING_LEVEL={raw!r}"
+                        )
+
+
+def setup_parser():
+    parser = argparse.ArgumentParser(
+        prog="cis-hardening-to-kconfig",
+        description="Convert the OVH CIS hardening rules to a KConfig",
+    )
+    parser.add_argument("rules-dir", type=Path)
+    return parser
+
+
+def rule_to_kconfig_entry(r: Rule, parent_symbol: str, levels: list[int]) -> str:
+    id_str = "_".join(str(i) for i in r.id)
+    id_dotted = ".".join(str(i) for i in r.id)
+    symbol = f"CIS_{id_str}"
+    applicable = [l for l in levels if l >= r.hardening_level]
+    default_cond = " || ".join(f"CIS_LEVEL_{l}" for l in applicable)
+    additional_cond = SPECIAL_RULES.get(id_dotted)
+    if additional_cond:
+        default_cond = f"({default_cond}) && {additional_cond}"
+    lines = [
+        f"config {symbol}",
+        f'\tbool "{id_dotted} - {r.name} ({r.hardening_level})"',
+        f"\tdefault y if {default_cond}",
+    ]
+    if r.description:
+        lines.append(f"\thelp")
+        lines.append(f"\t  {r.description}")
+    return "\n".join(lines) + "\n"
+
+
+def transform_prompt(bool_prompt):
+    """
+    Turn the bool prompt into a justification string prompt.
+    e.g. "1.1.1.1 - disable freevxfs (2)"
+      -> "justification to enable freevxfs (2)"
+    We strip the leading number and dash, remove leading verb, keep the rest.
+    """
+    # Remove the CIS rule number prefix  "X.X.X - "
+    m = re.match(r"^[\d.]+ - (.+)$", bool_prompt)
+    if m:
+        rest = m.group(1)
+    else:
+        rest = bool_prompt
+
+    # Drop leading verb ("disable", "enable", "install", "restrict", etc.)
+    rest_stripped = re.sub(
+        r"^(disable|enable|install|restrict|configure|use|set|remove|find|check|limit|lock|freeze|enforce|log|record|halt|keep|make|update)\s+",
+        "",
+        rest,
+        flags=re.IGNORECASE,
+    )
+
+    return f"justification to enable {rest_stripped}"
+
+
+def transform_help(help_text):
+    """
+    Build a generic justification help line from the parent help text.
+    Strip leading verb (same list as transform_prompt) before composing.
+    """
+    # Strip trailing period
+    stripped = help_text.rstrip(".")
+    # Drop leading verb
+    stripped = re.sub(
+        r"^(disable|enable|install|restrict|configure|use|set|remove|find|check|"
+        r"limit|lock|freeze|enforce|log|record|halt|keep|make|update|collect|"
+        r"ensure|verify|deactivate|activate|implement|implemet|create|"
+        r"checking|allow|disallow|do not allow)\s+",
+        "",
+        stripped,
+        flags=re.IGNORECASE,
+    )
+    return f"Provide a reason for enabling {stripped} for the current hardening level."
+
+
+def rule_to_justification_entry(r: Rule, parent_symbol: str, levels: list[int]) -> str:
+    id_str = "_".join(str(i) for i in r.id)
+    id_dotted = ".".join(str(i) for i in r.id)
+    symbol = f"CIS_{id_str}_JUSTIFICATION"
+    applicable = [l for l in levels if l >= r.hardening_level]
+    level_cond = " || ".join(f"CIS_LEVEL_{l}" for l in applicable)
+    additional_cond = SPECIAL_RULES.get(id_dotted)
+    depends_cond = f"!CIS_{id_str} && ({level_cond})"
+    prompt = transform_prompt(f"{id_dotted} - {r.name} ({r.hardening_level})")
+    lines = [
+        f"config {symbol}",
+        f'\tstring "{prompt}"',
+        f"\tdepends on {depends_cond}",
+    ]
+    if additional_cond:
+        preset = SPECIAL_RULE_JUSTIFICATIONS.get(additional_cond)
+        if preset:
+            lines.append(f'\tdefault "{preset}" if !{additional_cond}')
+    help_text = transform_help(r.description) if r.description else None
+    if r.description:
+        lines.append("\thelp")
+        lines.append(f"\t  {help_text}")
+    return "\n".join(lines) + "\n"
+
+
+def section_symbol(section_key: str) -> str:
+    num = section_key.split(" ", 1)[0]
+    return "CIS_SEC_" + num.replace(".", "_")
+
+
+def generate_kconfig(rules: list[Rule]) -> str:
+    # Collect all hardening levels used
+    levels = sorted({r.hardening_level for r in rules if r.hardening_level is not None})
+    if not levels:
+        raise ValueError("No rules with a hardening level found; cannot generate Kconfig")
+
+    # Build lookup: subsection number -> list of rules
+    subsection_rules: dict[str, list[Rule]] = {}
+    for rule in rules:
+        key = ".".join(str(i) for i in rule.id[:2])
+        subsection_rules.setdefault(key, []).append(rule)
+
+    output = []
+
+    # Hardening level selection
+    output.append('menu "Default hardening level"')
+    output.append("")
+    output.append("choice")
+    output.append('\tprompt "Hardening level"')
+    default_level = 2 if 2 in levels else levels[0]
+    output.append(f"\tdefault CIS_LEVEL_{default_level}")
+    output.append("")
+
+    for level in levels:
+        output.append(f"config CIS_LEVEL_{level}")
+        output.append(f'\tbool "Level {level}"')
+        output.append("")
+
+    output.append("endchoice")
+    output.append("")
+    output.append("endmenu")
+    output.append("")
+
+    # Other special rule menus
+    output.append('menu "Partitioning layout"')
+    output.append("")
+    output.append("config CIP_HOME_PART")
+    output.append('\tbool "Separate /home partition"')
+    output.append("\tdefault n")
+    output.append("\thelp")
+    output.append("\t  Enable this if you have a separate /home partition")
+    output.append("")
+    output.append("config CIP_VAR_PART")
+    output.append('\tbool "Separate /var partition"')
+    output.append("\tdefault n")
+    output.append("\thelp")
+    output.append("\t  Enable this if you have a separate /var partition")
+    output.append("")
+    output.append("config CIP_VAR_TMP_PART")
+    output.append('\tbool "Separate /var/tmp partition"')
+    output.append("\tdepends on CIP_VAR_PART")
+    output.append("\tdefault n")
+    output.append("\thelp")
+    output.append("\t  Enable this if you have a separate /var/tmp partition")
+    output.append("")
+    output.append("config CIP_VAR_LOG_PART")
+    output.append('\tbool "Separate /var/log partition"')
+    output.append("\tdepends on CIP_VAR_PART")
+    output.append("\tdefault n")
+    output.append("\thelp")
+    output.append("\t  Enable this if you have a separate /var/log partition")
+    output.append("")
+    output.append("config CIP_VAR_LOG_AUDIT_PART")
+    output.append('\tbool "Separate /var/log/audit partition"')
+    output.append("\tdepends on CIP_VAR_PART && CIP_VAR_LOG_PART")
+    output.append("\tdefault n")
+    output.append("\thelp")
+    output.append("\t  Enable this if you have a separate /var/log/audit partition")
+    output.append("")
+    output.append("endmenu")
+    output.append("")
+    output.append('menu "Additional options"')
+    output.append("config CIP_GRUB_BOOTLOADER")
+    output.append('\tbool "GRUB Bootloader"')
+    output.append("\tdefault n")
+    output.append("\thelp")
+    output.append("\t  Enable this if you use the GRUB bootloader")
+    output.append("")
+    output.append("config CIP_CRON")
+    output.append('\tbool "CRON"')
+    output.append("\tdefault n")
+    output.append("\thelp")
+    output.append("\t  Enable this if you use CRON")
+    output.append("")
+    output.append("endmenu")
+    output.append("")
+
+    for section_key, subsections in SECTIONS.items():
+        output.append(f'menu "{section_key}"')
+        output.append("")
+
+        for subsection_key in subsections:
+            sub_num = subsection_key.split(" ", 1)[0]
+            matching = subsection_rules.get(sub_num, [])
+            if not matching:
+                continue
+
+            output.append(f'menu "{subsection_key}"')
+            output.append("")
+
+            for rule in matching:
+                output.append(rule_to_kconfig_entry(rule, None, levels))
+                output.append(rule_to_justification_entry(rule, None, levels))
+
+            output.append("endmenu")
+            output.append("")
+
+        output.append("endmenu")
+        output.append("")
+
+    return "\n".join(output)
+
+
+def main():
+    parser = setup_parser()
+    args = parser.parse_args()
+    rulesdir: Path = getattr(args, "rules-dir")
+
+    rules = sorted(
+        (Rule(f) for f in rulesdir.iterdir() if f.is_file() and "_" in f.name),
+        key=lambda r: r.id,
+    )
+
+    print(generate_kconfig(rules))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/recipes-security/cip-cis-rules-config/files/scripts/config.schema.json b/recipes-security/cip-cis-rules-config/files/scripts/config.schema.json
new file mode 100644
index 00000000..74b020a7
--- /dev/null
+++ b/recipes-security/cip-cis-rules-config/files/scripts/config.schema.json
@@ -0,0 +1,124 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "config.schema.json",
+  "title": "CIP CIS Hardening Rules",
+  "description": "Schema for the config.json file produced by select-hardenings.py",
+  "type": "object",
+  "required": ["level", "rules"],
+  "additionalProperties": false,
+  "properties": {
+    "level": {
+      "type": "integer",
+      "description": "The selected CIS hardening level",
+      "enum": [1, 2, 3, 4, 5]
+    },
+    "rules": {
+      "type": "array",
+      "minItems": 1,
+      "items": { "$ref": "#/$defs/ruleWrapper" }
+    }
+  },
+  "$comment": "For each possible selected level L, when the selected level == L every rule whose minimum level is <= L must carry a justification if it is not applied (apply == false).",
+  "allOf": [
+    { "$ref": "#/$defs/requireJustificationL1" },
+    { "$ref": "#/$defs/requireJustificationL2" },
+    { "$ref": "#/$defs/requireJustificationL3" },
+    { "$ref": "#/$defs/requireJustificationL4" },
+    { "$ref": "#/$defs/requireJustificationL5" }
+  ],
+  "$defs": {
+    "ruleWrapper": {
+      "type": "object",
+      "required": ["rule"],
+      "additionalProperties": false,
+      "properties": {
+        "rule": {
+          "type": "object",
+          "required": ["name", "description", "level", "apply"],
+          "additionalProperties": false,
+          "properties": {
+            "name": {
+              "type": "string",
+              "description": "CIS rule identifier, e.g. '1.1.1.1'",
+              "pattern": "^[0-9]+(\\.[0-9]+)*$"
+            },
+            "description": {
+              "type": "string",
+              "description": "Short description of the hardening rule",
+              "minLength": 1
+            },
+            "level": {
+              "type": "integer",
+              "description": "Minimum CIS hardening level at which this rule is required",
+              "enum": [1, 2, 3, 4, 5]
+            },
+            "apply": {
+              "type": "boolean",
+              "description": "Whether the rule is currently enabled in the configuration"
+            },
+            "justification": {
+              "type": "string",
+              "description": "Reason for not applying a rule that is required by the selected hardening level",
+              "minLength": 1
+            },
+            "value": {
+              "type": "string",
+              "description": "Optional parameter value for rules that require site-specific configuration (e.g. group names)",
+              "minLength": 1
+            }
+          }
+        }
+      }
+    },
+    "requireJustificationL1": {
+      "if": { "required": ["level"], "properties": { "level": { "const": 1 } } },
+      "then": { "properties": { "rules": { "items": { "properties": { "rule": {
+        "if": {
+          "required": ["level", "apply"],
+          "properties": { "level": { "enum": [1] }, "apply": { "const": false } }
+        },
+        "then": { "required": ["justification"] }
+      } } } } } }
+    },
+    "requireJustificationL2": {
+      "if": { "required": ["level"], "properties": { "level": { "const": 2 } } },
+      "then": { "properties": { "rules": { "items": { "properties": { "rule": {
+        "if": {
+          "required": ["level", "apply"],
+          "properties": { "level": { "enum": [1, 2] }, "apply": { "const": false } }
+        },
+        "then": { "required": ["justification"] }
+      } } } } } }
+    },
+    "requireJustificationL3": {
+      "if": { "required": ["level"], "properties": { "level": { "const": 3 } } },
+      "then": { "properties": { "rules": { "items": { "properties": { "rule": {
+        "if": {
+          "required": ["level", "apply"],
+          "properties": { "level": { "enum": [1, 2, 3] }, "apply": { "const": false } }
+        },
+        "then": { "required": ["justification"] }
+      } } } } } }
+    },
+    "requireJustificationL4": {
+      "if": { "required": ["level"], "properties": { "level": { "const": 4 } } },
+      "then": { "properties": { "rules": { "items": { "properties": { "rule": {
+        "if": {
+          "required": ["level", "apply"],
+          "properties": { "level": { "enum": [1, 2, 3, 4] }, "apply": { "const": false } }
+        },
+        "then": { "required": ["justification"] }
+      } } } } } }
+    },
+    "requireJustificationL5": {
+      "if": { "required": ["level"], "properties": { "level": { "const": 5 } } },
+      "then": { "properties": { "rules": { "items": { "properties": { "rule": {
+        "if": {
+          "required": ["level", "apply"],
+          "properties": { "level": { "enum": [1, 2, 3, 4, 5] }, "apply": { "const": false } }
+        },
+        "then": { "required": ["justification"] }
+      } } } } } }
+    }
+  }
+}
diff --git a/recipes-security/cip-cis-rules-config/files/scripts/enable-cis-rules.py b/recipes-security/cip-cis-rules-config/files/scripts/enable-cis-rules.py
new file mode 100644
index 00000000..4d33bf21
--- /dev/null
+++ b/recipes-security/cip-cis-rules-config/files/scripts/enable-cis-rules.py
@@ -0,0 +1,333 @@
+#!/usr/bin/env python3
+# SPDX-FileCopyrightText: 2026 Siemens AG
+# SPDX-License-Identifier: MIT
+"""
+Package enabled CIS hardening configuration snippets into a partial root directory
+for Debian package creation.
+
+This script:
+1. Parses a .config.json file to find rules with apply=true
+2. Maps rule names to generated config file snippets  
+3. Copies enabled config files to the correct locations in a partial root directory
+4. Creates the directory structure that dpkg-raw expects for package building
+
+Usage:
+    python3 package-enabled-configs.py \\
+        --config /path/to/.config.json \\
+        --snippets-dir /path/to/cis-config-snippets \\
+        --output-dir /path/to/partial/root
+"""
+
+import argparse
+import logging
+import json
+import re
+import sys
+from pathlib import Path
+from typing import Dict, List, Set
+
+logger = logging.getLogger(__name__)
+
+
+def load_json(path: Path, logger: logging.Logger) -> dict:
+    """Load a JSON file. On error, log to stderr and exit with status 1."""
+    try:
+        with Path(path).open() as f:
+            return json.load(f)
+    except (json.JSONDecodeError, OSError) as exc:
+        logger.error(f"Error loading file '{path}': {exc}")
+        sys.exit(1)
+
+
+def get_enabled_rules(config: dict) -> set[str]:
+    """Return the set of rule names where rule.apply is true and the name is non-empty."""
+    enabled: set[str] = set()
+    for entry in config.get("rules", []):
+        rule = entry.get("rule", {}) if isinstance(entry, dict) else {}
+        if rule.get("apply", False) and rule.get("name"):
+            enabled.add(rule["name"])
+    return enabled
+
+
+# ---------------------------------------------------------------------------
+# Dynamic rule discovery
+# Discovers config files based on CIS rule IDs encoded in filenames
+# ---------------------------------------------------------------------------
+
+# ---------------------------------------------------------------------------
+# Destination path prefix overrides
+# By default snippets are placed under etc/ in the output root.
+# Entries here map a source subdirectory prefix to a different destination
+# prefix.  Keys are Path parts (as strings) relative to snippets_dir.
+# ---------------------------------------------------------------------------
+DEST_PREFIX_OVERRIDES: Dict[str, str] = {
+    "pam-configs": "usr/share/pam-configs",
+    "chrony": "etc/chrony",
+    "configuration": "usr/share/cip-cis-rules/configuration",
+    "conf.d-overrides": "usr/share/cip-cis-rules/conf.d-overrides",
+    "sshd_config.d": "etc/ssh/sshd_config.d",
+}
+
+# ---------------------------------------------------------------------------
+# Per-source-prefix file mode overrides.
+# By default snippets are written with the umask default (typically 0644).
+# Some destinations are rejected by the consuming tool unless mode is set
+# explicitly; in particular sudo refuses to read /etc/sudoers.d/* unless
+# mode is <= 0440 and owner is root.
+# Keys are the first component of the source path (relative to snippets_dir).
+# ---------------------------------------------------------------------------
+DEST_MODE_OVERRIDES: Dict[str, int] = {
+    "sudoers.d": 0o440,
+    "configuration": 0o755,
+}
+
+
+def dest_mode(source_rel: "Path") -> "int | None":
+    """
+    Return the explicit file mode for the destination of ``source_rel``,
+    or ``None`` if no override applies (caller should leave the file at the
+    umask default).
+    """
+    parts = source_rel.parts
+    first = parts[0] if parts else ""
+    return DEST_MODE_OVERRIDES.get(first)
+
+
+def dest_rel_path(source_rel: "Path") -> "Path":
+    """
+    Return the destination path relative to the output root for a given
+    source-relative path.
+
+    The first component of source_rel is matched against DEST_PREFIX_OVERRIDES.
+    If found the first component is replaced with the mapped prefix.
+    Otherwise 'etc' is prepended (legacy behaviour).
+    """
+    parts = source_rel.parts
+    first = parts[0] if parts else ""
+    if first in DEST_PREFIX_OVERRIDES:
+        rest = parts[1:]
+        if not rest:
+            raise ValueError(
+                f"dest_rel_path: snippet '{source_rel}' sits at snippets root "
+                f"with a prefix-override key as its name — cannot determine destination"
+            )
+        return Path(DEST_PREFIX_OVERRIDES[first]) / Path(*rest)
+    return Path("etc") / source_rel
+
+
+def discover_rule_files(
+    snippets_dir: Path, logger: logging.Logger
+) -> Dict[str, List[Path]]:
+    """
+    Dynamically discover config files by scanning snippets directory.
+
+    Looks for files matching pattern: cis-X.Y.Z-* or cis-X.Y.Z.W-* (dot-separated rule ID)
+
+    Returns a mapping of rule_id -> list of file paths relative to snippets_dir.
+    """
+    # Pattern to match CIS rule IDs in filenames (dot-separated)
+    pattern = re.compile(r"cis-(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?")
+
+    rule_files: Dict[str, List[Path]] = {}
+
+    # Recursively find all files in snippets directory
+    for file_path in snippets_dir.rglob("*"):
+        if not file_path.is_file():
+            continue
+
+        filename = file_path.name
+
+        # Try to match pattern
+        match = pattern.search(filename)
+        if match:
+            groups = match.groups()
+
+            # Build rule ID (X.Y.Z or X.Y.Z.W format)
+            if groups[3]:  # 4-part rule ID
+                rule_id = f"{groups[0]}.{groups[1]}.{groups[2]}.{groups[3]}"
+            else:  # 3-part rule ID
+                rule_id = f"{groups[0]}.{groups[1]}.{groups[2]}"
+
+            # Get relative path from snippets_dir
+            rel_path = file_path.relative_to(snippets_dir)
+
+            # Add to mapping
+            if rule_id not in rule_files:
+                rule_files[rule_id] = []
+            rule_files[rule_id].append(rel_path)
+
+            logger.debug(f"Discovered rule {rule_id}: {rel_path}")
+
+    logger.info(f"Discovered {len(rule_files)} rules with config files")
+    return rule_files
+
+
+def get_rule_values(config: dict) -> Dict[str, str]:
+    """Extract the optional value field for each rule."""
+    values = {}
+    for rule_entry in config.get("rules", []):
+        rule = rule_entry.get("rule", {})
+        name = rule.get("name", "")
+        value = rule.get("value")
+        if name and value is not None:
+            values[name] = value
+    return values
+
+
+def package_configs(
+    enabled_rules: Set[str],
+    rule_files: Dict[str, List[Path]],
+    rule_values: Dict[str, str],
+    snippets_dir: Path,
+    output_dir: Path,
+    logger: logging.Logger,
+) -> int:
+    """
+    Copy enabled config snippets to the partial root directory structure.
+    Returns the number of errors encountered.
+    """
+    errors = 0
+    processed_files = 0
+
+    for rule_name in sorted(enabled_rules):
+        if rule_name not in rule_files:
+            logger.debug(f"SKIP {rule_name}: no config files found for this rule")
+            continue
+
+        file_paths = rule_files[rule_name]
+
+        for source_rel in file_paths:
+            source_path = snippets_dir / source_rel
+            # Use new dest_rel_path function to determine destination
+            dest_rel = dest_rel_path(source_rel)
+            dest_path = output_dir / dest_rel
+
+            if not source_path.exists():
+                logger.error(f"{rule_name}: config file '{source_path}' not found")
+                errors += 1
+                continue
+
+            logger.debug(f"copy {rule_name}: {source_rel} → {dest_rel}")
+
+            # Create destination directory
+            dest_path.parent.mkdir(parents=True, exist_ok=True)
+            # Read source, substitute @@VALUE@@ if a value is configured
+            content = source_path.read_text()
+            if "@@VALUE@@" in content:
+                value = rule_values.get(rule_name)
+                if value is None:
+                    logger.error(
+                        f"{rule_name}: snippet contains @@VALUE@@ but no "
+                        f"'value' field is set in .config.json"
+                    )
+                    errors += 1
+                    continue
+                content = content.replace("@@VALUE@@", value)
+                logger.debug(f"substituted @@VALUE@@={value!r} in {source_rel}")
+            dest_path.write_text(content)
+            mode = dest_mode(source_rel)
+            if mode is not None:
+                dest_path.chmod(mode)
+                logger.debug(f"set mode {mode:#o} on {dest_rel}")
+
+            processed_files += 1
+
+    skipped_rules = len(enabled_rules) - len(
+        [r for r in enabled_rules if r in rule_files]
+    )
+    logger.debug(
+        f"Summary: {processed_files} config files processed, "
+        f"{skipped_rules} rules skipped (no config files found)"
+    )
+
+    return errors
+
+
+def setup_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        prog="package-enabled-configs",
+        description=(
+            "Package enabled CIS hardening configuration snippets into a partial "
+            "root directory for Debian package creation."
+        ),
+    )
+    parser.add_argument(
+        "--config",
+        type=Path,
+        required=True,
+        metavar="FILE",
+        help="Path to .config.json file containing rule selections",
+    )
+    parser.add_argument(
+        "--snippets-dir",
+        type=Path,
+        required=True,
+        metavar="DIR",
+        help="Directory containing generated config file snippets",
+    )
+    parser.add_argument(
+        "--output-dir",
+        type=Path,
+        required=True,
+        metavar="DIR",
+        help="Output directory for partial root filesystem structure",
+    )
+    return parser
+
+
+def main() -> None:
+    parser = setup_parser()
+    args = parser.parse_args()
+
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(levelname)s: %(message)s",
+    )
+
+    # Validate inputs
+    if not args.config.is_file():
+        logger.error(f"config file '{args.config}' does not exist")
+        sys.exit(1)
+
+    if not args.snippets_dir.is_dir():
+        logger.error(f"snippets directory '{args.snippets_dir}' does not exist")
+        sys.exit(1)
+
+    args.output_dir.mkdir(parents=True, exist_ok=True)
+
+    # Discover available config files
+    logger.debug(f"Discovering config files in: {args.snippets_dir}")
+    rule_files = discover_rule_files(args.snippets_dir, logger)
+
+    # Load configuration and get enabled rules
+    config = load_json(args.config, logger)
+    enabled_rules = get_enabled_rules(config)
+    rule_values = get_rule_values(config)
+
+    logger.debug(f"Loaded configuration from: {args.config}")
+    logger.info(f"Found {len(enabled_rules)} enabled rules")
+    if enabled_rules:
+        implementable_rules = enabled_rules & set(rule_files.keys())
+        logger.debug(f"Rules with config files available: {len(implementable_rules)}")
+
+    if not enabled_rules:
+        logger.error("No rules are enabled (apply=true) in the configuration")
+        sys.exit(1)
+
+    # Package the configs
+    errors = package_configs(
+        enabled_rules,
+        rule_files,
+        rule_values,
+        args.snippets_dir,
+        args.output_dir,
+        logger,
+    )
+
+    if errors:
+        logger.error(f"{errors} error(s) occurred during packaging")
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/recipes-security/cip-cis-rules-config/files/scripts/gen-kernel-config.py b/recipes-security/cip-cis-rules-config/files/scripts/gen-kernel-config.py
new file mode 100755
index 00000000..285151dd
--- /dev/null
+++ b/recipes-security/cip-cis-rules-config/files/scripts/gen-kernel-config.py
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+# SPDX-FileCopyrightText: 2026 Siemens AG
+# SPDX-License-Identifier: MIT
+"""
+Generate a Linux kernel config fragment for enabled CIS hardening rules
+from .config.json.
+
+Each rule maps kernel CONFIG symbols to explicit targets:
+    - y: built-in
+    - n: disabled (rendered as "# CONFIG_FOO is not set")
+
+The fragment can be dropped into a kernel build tree (e.g. via
+KCONFIG_ALLCONFIG or merge_config.sh) to enforce kernel option values at
+compile time.
+
+Usage:
+    python3 gen-kernel-config.py \\
+        --config generator/.config.json \\
+        --output hardening.cfg
+"""
+
+import json
+import logging
+import argparse
+from pathlib import Path
+import sys
+
+logger = logging.getLogger(__name__)
+
+
+def load_json(path: Path, logger: logging.Logger) -> dict:
+    """Load a JSON file. On error, log to stderr and exit with status 1."""
+    try:
+        with Path(path).open() as f:
+            return json.load(f)
+    except (json.JSONDecodeError, OSError) as exc:
+        logger.error(f"Error loading file '{path}': {exc}")
+        sys.exit(1)
+
+
+def get_enabled_rules(config: dict) -> set[str]:
+    """Return the set of rule names where rule.apply is true and the name is non-empty."""
+    enabled: set[str] = set()
+    for entry in config.get("rules", []):
+        rule = entry.get("rule", {}) if isinstance(entry, dict) else {}
+        if rule.get("apply", False) and rule.get("name"):
+            enabled.add(rule["name"])
+    return enabled
+
+# ---------------------------------------------------------------------------
+# Mapping of CIS rule IDs to kernel CONFIG symbols and their target values.
+# A rule may map to more than one symbol (e.g. FAT covers three options).
+# ---------------------------------------------------------------------------
+RULE_KERNEL_OPTIONS: dict[str, dict] = {
+    "1.1.1.1": {
+        "description": "Disable Veritas VxFS filesystem",
+        "configs": {"CONFIG_VXFS_FS": "n"},
+    },
+    "1.1.1.2": {
+        "description": "Disable JFFS2 filesystem",
+        "configs": {"CONFIG_JFFS2_FS": "n"},
+    },
+    "1.1.1.3": {
+        "description": "Disable HFS filesystem",
+        "configs": {"CONFIG_HFS_FS": "n"},
+    },
+    "1.1.1.4": {
+        "description": "Disable HFS+ filesystem",
+        "configs": {"CONFIG_HFSPLUS_FS": "n"},
+    },
+    "1.1.1.5": {
+        "description": "Disable SquashFS filesystem",
+        "configs": {"CONFIG_SQUASHFS": "n"},
+    },
+    "1.1.1.6": {
+        "description": "Disable UDF filesystem",
+        "configs": {"CONFIG_UDF_FS": "n"},
+    },
+    "1.1.1.7": {
+        "description": "Disable FAT filesystems (FAT, MSDOS, VFAT)",
+        "configs": {
+            "CONFIG_FAT_FS": "n",
+            "CONFIG_MSDOS_FS": "n",
+            "CONFIG_VFAT_FS": "n",
+        },
+    },
+    "1.1.1.8": {
+        "description": "Disable cramfs filesystem",
+        "configs": {"CONFIG_CRAMFS": "n"},
+    },
+    "1.1.23": {
+        "description": "Disable USB storage",
+        "configs": {"CONFIG_USB_STORAGE": "n"},
+    },
+}
+
+
+def setup_parser():
+    parser = argparse.ArgumentParser(
+        description="Generate a kernel config fragment for "
+                    "enabled CIS hardening rules."
+    )
+    parser.add_argument(
+        "--config",
+        type=Path,
+        required=True,
+        metavar="FILE",
+        help="Path to generator/.config.json",
+    )
+    parser.add_argument(
+        "--output",
+        type=Path,
+        required=True,
+        metavar="FILE",
+        help="Output kernel config fragment path",
+    )
+    return parser
+
+
+def main():
+    parser = setup_parser()
+    args = parser.parse_args()
+
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(levelname)s: %(message)s",
+    )
+
+    config = load_json(args.config, logger)
+    enabled_rules = get_enabled_rules(config)
+
+    active = [
+        (rule_id, RULE_KERNEL_OPTIONS[rule_id])
+        for rule_id in sorted(RULE_KERNEL_OPTIONS)
+        if rule_id in enabled_rules
+    ]
+
+    args.output.parent.mkdir(parents=True, exist_ok=True)
+    with args.output.open("w") as f:
+        f.write(
+            "# CIS hardening kernel config fragment\n"
+            "# Generated by generator/gen-kernel-config.py - do not edit.\n"
+            "# Values: y=built-in, n=disabled\n"
+            "\n"
+        )
+
+        for rule_id, entry in active:
+            f.write(f"# CIS {rule_id}: {entry['description']}\n")
+            for symbol, value in entry["configs"].items():
+                if value == "n":
+                    f.write(f"# {symbol} is not set\n")
+                else:
+                    f.write(f"{symbol}={value}\n")
+            f.write("\n")
+
+    logger.info(
+        f"Generated {args.output} with {len(active)} rule(s) "
+        f"({sum(len(e['configs']) for _, e in active)} CONFIG symbols)."
+    )
+    if not active:
+        logger.info("  No kernel-relevant rules are currently enabled.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/recipes-security/cip-cis-rules-config/files/scripts/select-hardenings.py b/recipes-security/cip-cis-rules-config/files/scripts/select-hardenings.py
new file mode 100755
index 00000000..b3b166a4
--- /dev/null
+++ b/recipes-security/cip-cis-rules-config/files/scripts/select-hardenings.py
@@ -0,0 +1,618 @@
+#!/usr/bin/env python3
+#
+# Based on the kas menu plugin
+# Copyright (c) Siemens AG, 2021-2023
+# Copyright (c) 2011-2019, Ulf Magnusson <[email protected]>
+# SPDX-License-Identifier: MIT
+#
+
+import argparse
+import json
+import logging
+import pathlib
+import re
+import sys
+import traceback
+
+from kconfiglib import (
+    Kconfig,
+    Symbol,
+    Choice,
+    expr_value,
+    TYPE_TO_STR,
+    MENU,
+    COMMENT,
+    STRING,
+    BOOL,
+    INT,
+    HEX,
+    UNKNOWN,
+)
+
+from snack import (
+    SnackScreen,
+    EntryWindow,
+    ButtonChoiceWindow,
+    ButtonBar,
+    Listbox,
+    GridFormHelp,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class Menuconfig:
+    def __init__(self, kconf):
+        self.kconf = kconf
+        self.screen = None
+
+    @staticmethod
+    def value_str(sym):
+        if sym.type in (STRING, INT, HEX):
+            return f"({sym.str_value})"
+
+        if sym.choice and sym.visibility == 2:
+            return "(*)" if sym.choice.selection is sym else "( )"
+
+        tri_val_str = (" ", None, "*")[sym.tri_value]
+
+        if len(sym.assignable) == 1:
+            return f"-{tri_val_str}-"
+
+        if sym.type == BOOL:
+            return f"[{tri_val_str}]"
+
+        raise RuntimeError()
+
+    @staticmethod
+    def node_str(node, indent):
+        if not node.prompt:
+            return ""
+
+        prompt, prompt_cond = node.prompt
+        if not expr_value(prompt_cond):
+            return ""
+
+        if node.item == MENU:
+            return f"    {indent * ' '}{prompt}  --->"
+
+        if type(node.item) is Choice:
+            return f"    {indent * ' '}{prompt}"
+
+        if node.item == COMMENT:
+            return f"    {indent * ' '}*** {prompt} ***"
+
+        sym = node.item
+
+        if sym.type == UNKNOWN:
+            return ""
+
+        res = f"{Menuconfig.value_str(sym):3} {indent * ' '}{prompt}"
+
+        if node.is_menuconfig:
+            res += f"  ---{'>' if sym.tri_value > 0 else '-'}"
+
+        return res
+
+    @staticmethod
+    def menu_node_strings(node, indent):
+        items = []
+
+        while node:
+            string = Menuconfig.node_str(node, indent)
+            if string:
+                items.append((string, node))
+
+            if (
+                node.list
+                and node.item != MENU
+                and (type(node.item) is Choice or not node.is_menuconfig)
+            ):
+                items.extend(Menuconfig.menu_node_strings(node.list, indent + 2))
+
+            node = node.next
+
+        return items
+
+    def show_menu(self, title, top_node, is_submenu=False):
+        selection = 0
+
+        while True:
+            items = Menuconfig.menu_node_strings(top_node, 0)
+
+            height = len(items)
+            window_width = 60
+
+            scroll = 0
+            if height > self.screen.height - 13:
+                height = self.screen.height - 13
+                scroll = 1
+
+            buttons = [
+                ("Save & Exit", "save", "S"),
+                (" Exit ", "exit", "E"),
+                (" Help ", "help", "h"),
+            ]
+            if is_submenu:
+                buttons.insert(0, (" Return ", "return", "ESC"))
+            buttonbar = ButtonBar(self.screen, buttons)
+            if not is_submenu:
+                buttonbar.hotkeys["ESC"] = "exit"
+            listbox = Listbox(height, width=window_width, scroll=scroll, returnExit=1)
+            count = 0
+            for string, _ in items:
+                listbox.append(string, count)
+                if selection == count:
+                    listbox.setCurrent(count)
+                count += 1
+
+            grid = GridFormHelp(self.screen, title, None, 1, 2)
+            grid.add(listbox, 0, 0, padding=(0, 0, 0, 1))
+            grid.add(buttonbar, 0, 1, growx=1)
+            grid.addHotKey(" ")
+
+            # Snapshot the active CIS level so we can detect level changes
+            # caused by the user action below and re-derive rule defaults.
+            level_before = _selected_level(self.kconf)
+
+            rc = grid.runOnce()
+
+            action = buttonbar.buttonPressed(rc)
+            if action and action != "help":
+                return action
+
+            if count == 0:
+                continue
+
+            selection = listbox.current()
+            _, selected_node = items[selection]
+            sym = selected_node.item
+
+            if action == "help":
+                prompt, _ = selected_node.prompt
+                if selected_node.help:
+                    help_text = selected_node.help
+                else:
+                    help_text = "No help available."
+                ButtonChoiceWindow(
+                    screen=self.screen,
+                    title=f"Help on '{prompt}'",
+                    text=help_text,
+                    width=window_width,
+                    buttons=["  Ok  "],
+                )
+                continue
+
+            show_submenu = False
+
+            if type(sym) is Symbol:
+                if rc == " ":
+                    if sym.type == BOOL:
+                        sym.set_value("n" if sym.tri_value > 0 else "y")
+                else:
+                    if selected_node.is_menuconfig:
+                        show_submenu = True
+                    elif sym.type in (STRING, INT, HEX):
+                        action, values = EntryWindow(
+                            screen=self.screen,
+                            title=sym.name,
+                            text=f"Enter a {TYPE_TO_STR[sym.type]} value:",
+                            prompts=[("", sym.str_value)],
+                            width=80,
+                            entryWidth=80,
+                            buttons=[("  Ok  ", "Ok"), ("Cancel", "", "ESC")],
+                        )
+                        if action == "Ok":
+                            self.kconf.warnings = []
+                            val = values[0]
+                            if sym.type == HEX and not val.startswith("0x"):
+                                val = "0x" + val
+                            sym.set_value(val)
+                            sym.str_value
+                            if len(self.kconf.warnings) > 0:
+                                ButtonChoiceWindow(
+                                    screen=self.screen,
+                                    title="Invalid entry",
+                                    text="\n".join(self.kconf.warnings),
+                                    width=window_width,
+                                    buttons=["  Ok  "],
+                                )
+                                self.kconf.warnings = []
+            elif selected_node.is_menuconfig and type(sym) is not Choice:
+                show_submenu = True
+
+            if show_submenu:
+                submenu_title, _ = selected_node.prompt
+                action = self.show_menu(
+                    submenu_title, selected_node.list, is_submenu=True
+                )
+                if action != "return":
+                    return action
+
+            # If the user just switched CIS_LEVEL_N, drop any user overrides
+            # on rule symbols so the new level's `default y if CIS_LEVEL_…`
+            # clauses take effect and every rule required by the new level
+            # is enabled by default.  Justifications and the level symbols
+            # themselves are preserved.
+            level_after = _selected_level(self.kconf)
+            if (
+                level_before is not None
+                and level_after is not None
+                and level_after != level_before
+            ):
+                cleared = _reset_rule_overrides(self.kconf)
+                ButtonChoiceWindow(
+                    screen=self.screen,
+                    title="Hardening level changed",
+                    text=(
+                        f"Switched from level {level_before} to level "
+                        f"{level_after}.\n\n"
+                        f"All rule selections were reset to the level "
+                        f"{level_after} defaults ({cleared} prior override"
+                        f"{'s' if cleared != 1 else ''} discarded).\n\n"
+                        "Per-rule justifications were kept."
+                    ),
+                    width=window_width,
+                    buttons=["  Ok  "],
+                )
+
+    def show(self):
+        self.screen = SnackScreen()
+
+        action = self.show_menu(self.kconf.mainmenu_text, self.kconf.top_node.list)
+
+        self.screen.finish()
+        return action
+
+
+def _collect_level_numbers(expr):
+    """Return the set of integer level numbers for all CIS_LEVEL_N symbols in a kconfiglib expr."""
+    from kconfiglib import Symbol as _Symbol
+
+    levels = set()
+    if isinstance(expr, _Symbol):
+        m = re.match(r"^CIS_LEVEL_(\d+)$", expr.name)
+        if m:
+            levels.add(int(m.group(1)))
+    elif isinstance(expr, tuple):
+        for sub in expr[1:]:
+            levels |= _collect_level_numbers(sub)
+    return levels
+
+
+_LEVEL_RE = re.compile(r"^CIS_LEVEL_(\d+)$")
+
+
+def _selected_level(kconf):
+    """Return the integer N of the currently selected CIS_LEVEL_N choice, or None."""
+    for n in range(1, 6):
+        sym = kconf.syms.get(f"CIS_LEVEL_{n}")
+        if sym is not None and sym.tri_value == 2:
+            return n
+    return None
+
+
+def _reset_rule_overrides(kconf):
+    """Clear user-set values on every CIS_<id> rule symbol so that the
+    Kconfig ``default y if CIS_LEVEL_N || …`` clauses take effect again.
+
+    This is invoked whenever the user picks a different CIS_LEVEL_N in the
+    menu so that switching level immediately enables the rules required by
+    the newly-selected level (and disables any that the old level required
+    but the new one does not).
+
+    CIS_LEVEL_N symbols themselves and the *_JUSTIFICATION string symbols
+    are intentionally left alone:
+
+      * resetting the level symbols would re-trigger us recursively;
+      * justifications belong to the operator, not the level, and would be
+        lost on every level change.
+
+    Site-specific string symbols (e.g. ``CIS_2_2_1_3_SERVER``,
+    ``CIS_5_2_18_GROUPS``, ``CIS_5_3_3_REMEMBER``) are likewise preserved
+    so the operator does not lose typed-in values when bumping levels.
+
+    Returns the number of rule symbols whose override was cleared.
+    """
+    cleared = 0
+    for name, sym in kconf.syms.items():
+        if not name.startswith("CIS_"):
+            continue
+        if _LEVEL_RE.match(name):
+            continue
+        if name.endswith("_JUSTIFICATION"):
+            continue
+        # Preserve user-supplied site-specific values (e.g. NTP server,
+        # SSH allowed groups, password-history depth) — these are not
+        # tied to a particular hardening level.
+        if sym.type == STRING:
+            continue
+        # ``user_value`` is None iff the symbol has no explicit user override.
+        if getattr(sym, "user_value", None) is not None:
+            sym.unset_value()
+            cleared += 1
+    return cleared
+
+
+def _find_value_symbol(kconf, rule_sym_name):
+    """Return the user-tunable string symbol associated with rule
+    *rule_sym_name* (e.g. ``CIS_5_3_3``), or ``None`` if the rule has no
+    site-specific parameter.
+
+    The convention is that a rule ``CIS_<id>`` may have one companion
+    string symbol named ``CIS_<id>_<SUFFIX>`` (e.g. ``CIS_5_3_3_REMEMBER``,
+    ``CIS_2_2_1_3_SERVER``, ``CIS_5_2_18_GROUPS``) whose value is
+    substituted for ``@@VALUE@@`` in the rule's snippet.  The
+    ``_JUSTIFICATION`` companion symbol is excluded — it is operator
+    bookkeeping, not a snippet value.
+    """
+    prefix = rule_sym_name + "_"
+    for name, sym in kconf.syms.items():
+        if (
+            name.startswith(prefix)
+            and not name.endswith("_JUSTIFICATION")
+            and sym.type == STRING
+        ):
+            return sym
+    return None
+
+
+_SCHEMA_PATH = pathlib.Path(__file__).parent / "config.schema.json"
+
+
+def _validate_json(rules):
+    """Validate *rules* against config.schema.json.
+
+    Requires the ``jsonschema`` package.  Raises ``ImportError`` when the
+    package is not installed, or ``jsonschema.ValidationError`` when the
+    data does not conform to the schema.
+    """
+    try:
+        import jsonschema
+    except ImportError as exc:
+        raise ImportError(
+            "jsonschema is required for schema validation: "
+            "install it with 'pip install jsonschema'"
+        ) from exc
+
+    with open(_SCHEMA_PATH) as f:
+        schema = json.load(f)
+
+    jsonschema.validate(instance=rules, schema=schema)
+
+
+def save_as_json(kconf, path):
+    """Export the current Kconfig selection state to a JSON file.
+
+    The output is an object with two keys:
+
+    * ``level`` — the currently selected CIS hardening level (1–5)
+    * ``rules`` — list of rule objects::
+
+          {"rule": {"name": "1.1.1.1", "description": "...", "level": 2,
+                    "apply": true, "justification": "..."}}
+
+    The ``justification`` key is only present when the corresponding
+    ``CIS_<ID>_JUSTIFICATION`` symbol has a non-empty string value.
+
+    After writing, the file is validated against config.schema.json located
+    in the same directory as this script.  A ``jsonschema.ValidationError``
+    is raised if the output does not conform to the schema.
+    """
+    selected_level = _selected_level(kconf)
+
+    rules = []
+    for name, sym in sorted(kconf.syms.items()):
+        if (
+            not name.startswith("CIS_")
+            or name.startswith("CIS_LEVEL_")
+            or name.endswith("_JUSTIFICATION")
+            or sym.type != BOOL
+            or not sym.nodes
+        ):
+            continue
+
+        node = sym.nodes[0]
+        if not node.prompt:
+            continue
+        prompt_text = node.prompt[0]
+
+        # Parse "1.1.1.1 - disable freevxfs (2)" -> cis_number, description
+        m = re.match(r"^([\d.]+)\s*-\s*(.+?)(?:\s*\(\d+\))?$", prompt_text)
+        cis_number = m.group(1).strip() if m else name
+        description = m.group(2).strip() if m else prompt_text
+
+        # Minimum level from the first `default y if` condition
+        level = None
+        for _val, cond in sym.defaults:
+            nums = _collect_level_numbers(cond)
+            if nums:
+                level = min(nums)
+                break
+
+        if level is None:
+            logger.warning(
+                "save_as_json: could not determine CIS level for symbol '%s', skipping",
+                name,
+            )
+            continue
+
+        rule = {
+            "name": cis_number,
+            "description": description,
+            "level": level,
+            "apply": sym.tri_value == 2,
+        }
+
+        # justification: only when the symbol exists and has a non-empty value
+        just_sym = kconf.syms.get(name + "_JUSTIFICATION")
+        if just_sym and just_sym.str_value:
+            rule["justification"] = just_sym.str_value
+
+        # value: only when the rule has a companion string symbol with a
+        # non-empty value — used for snippets containing @@VALUE@@.
+        val_sym = _find_value_symbol(kconf, name)
+        if val_sym and val_sym.str_value:
+            rule["value"] = val_sym.str_value
+
+        rules.append({"rule": rule})
+
+    if selected_level is None:
+        raise ValueError(
+            "save_as_json: no CIS level selected — set one of the CIS_LEVEL_N symbols"
+        )
+
+    output = {
+        "level": selected_level,
+        "rules": rules,
+    }
+
+    with open(path, "w") as f:
+        json.dump(output, f, indent=2)
+
+    _validate_json(output)
+
+
+def load_config_json(kconf, path):
+    """Load a previously saved JSON config file and apply it to *kconf*.
+
+    The file is validated against config.schema.json before any symbols are
+    touched.  The following is applied from the file:
+
+    * ``level`` — sets the matching ``CIS_LEVEL_N`` choice symbol to ``y``.
+    * For each rule in ``rules``:
+
+      - The corresponding ``CIS_<id>`` bool symbol is set to ``y`` or ``n``
+        according to the ``apply`` field.
+      - When a non-empty ``justification`` value is present, it is written to
+        the matching ``CIS_<id>_JUSTIFICATION`` string symbol.
+      - Unknown rule names (no matching symbol in *kconf*) are logged as
+        warnings and skipped.
+
+    Raises ``FileNotFoundError`` when *path* does not exist,
+    ``jsonschema.ValidationError`` when the file fails schema validation, and
+    ``json.JSONDecodeError`` when the file is not valid JSON.
+    """
+    with open(path) as f:
+        data = json.load(f)
+
+    _validate_json(data)
+
+    # Apply selected level
+    level_sym = kconf.syms.get(f"CIS_LEVEL_{data['level']}")
+    if level_sym is not None:
+        level_sym.set_value("y")
+    else:
+        raise ValueError(
+            f"load_config_json: unknown CIS level '{data['level']}' in '{path}'"
+        )
+
+    # Apply rules
+    for entry in data["rules"]:
+        rule = entry["rule"]
+        # "1.1.1.1" -> "CIS_1_1_1_1"
+        sym_name = "CIS_" + rule["name"].replace(".", "_")
+
+        sym = kconf.syms.get(sym_name)
+        if sym is None:
+            logger.warning("load_config_json: unknown rule '%s', skipping", sym_name)
+            continue
+
+        sym.set_value("y" if rule["apply"] else "n")
+
+        justification = rule.get("justification")
+        if justification:
+            just_sym = kconf.syms.get(sym_name + "_JUSTIFICATION")
+            if just_sym is not None:
+                just_sym.set_value(justification)
+            else:
+                logger.warning(
+                    "load_config_json: no JUSTIFICATION symbol for '%s', skipping",
+                    sym_name,
+                )
+
+        value = rule.get("value")
+        if value:
+            val_sym = _find_value_symbol(kconf, sym_name)
+            if val_sym is not None:
+                val_sym.set_value(value)
+            else:
+                logger.warning(
+                    "load_config_json: no value symbol for '%s', skipping",
+                    sym_name,
+                )
+
+    logger.info(
+        "Loaded level %d and %d rules from %s", data["level"], len(data["rules"]), path
+    )
+
+
+def setup_parser():
+    parser = argparse.ArgumentParser(
+        prog="select-hardenings",
+        description="Interactive menu for selecting CIS hardening rules",
+    )
+    parser.add_argument("kconfig", help="Kconfig file", nargs="?", default="Kconfig")
+    parser.add_argument(
+        "--output", help="Output .config.json file", default=".config.json"
+    )
+    parser.add_argument("--level", help="set the hardening level", default="2")
+    parser.add_argument(
+        "--non-interactive",
+        help="skip the menu-config and generate a configuration for the given level",
+        action="store_true",
+    )
+    return parser
+
+
+def main():
+    parser = setup_parser()
+    args = parser.parse_args()
+
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(levelname)s: %(message)s",
+    )
+
+    kconf = Kconfig(args.kconfig, warn=False)
+    json_path = args.output
+    try:
+        load_config_json(kconf, json_path)
+    except FileNotFoundError:
+        logger.warning("no configuration found")
+
+    save_config = False
+    if not args.non_interactive:
+        menu = Menuconfig(kconf)
+        action = menu.show()
+        if action != "exit":
+            save_config = True
+    else:
+        save_config = True
+        try:
+            requested_level = int(args.level)
+        except (TypeError, ValueError):
+            logger.error("Invalid hardening level '%s'. Valid levels: 1-5.", args.level)
+            sys.exit(1)
+        if _selected_level(kconf) != requested_level:
+            level_sym = kconf.syms.get(f"CIS_LEVEL_{requested_level}")
+            if level_sym is None:
+                logger.error("Unknown hardening level '%s'. Valid levels: 1-5.", args.level)
+                sys.exit(1)
+            level_sym.set_value("y")
+
+    if save_config:
+        kconf.write_config(args.output)
+        try:
+            save_as_json(kconf, json_path)
+            logger.info("Hardening rules written to: %s", json_path)
+        except Exception:
+            error_path = json_path + ".error"
+            with open(error_path, "w") as f:
+                traceback.print_exc(file=f)
+            logger.error("save_as_json failed — details in %s", error_path)
+            sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
-- 
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.