[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