From 2c3540951940f23401e0740374c7f91ad5af4615 Mon Sep 17 00:00:00 2001 From: Sandwich Date: Mon, 25 May 2026 04:37:33 +0200 Subject: [PATCH] feat(cis): add selectable profile and per-rule hardening toggles --- README.md | 73 +++--- main.yml | 24 +- roles/cis/defaults/main.yml | 109 +------- roles/cis/tasks/_normalize.yml | 27 +- roles/cis/tasks/aide.yml | 42 ++++ roles/cis/tasks/auditd.yml | 42 ++++ roles/cis/tasks/auth.yml | 21 ++ roles/cis/tasks/crypto.yml | 5 +- roles/cis/tasks/files.yml | 14 +- roles/cis/tasks/grub_password.yml | 31 +++ roles/cis/tasks/main.yml | 7 +- roles/cis/tasks/modules.yml | 3 + roles/cis/tasks/packages.yml | 29 +++ roles/cis/tasks/password_expiry.yml | 22 ++ roles/cis/tasks/permissions.yml | 7 +- roles/cis/tasks/security_lines.yml | 217 +++++++++++++--- roles/cis/tasks/sshd.yml | 3 + roles/cis/tasks/sysctl.yml | 13 +- roles/cis/tasks/warning_banners.yml | 11 + roles/cis/vars/main.yml | 235 +++++++++++++++++- roles/global_defaults/defaults/main.yml | 3 + .../tasks/_normalize_system.yml | 3 + vars_example.yml | 4 +- 23 files changed, 753 insertions(+), 192 deletions(-) create mode 100644 roles/cis/tasks/aide.yml create mode 100644 roles/cis/tasks/auditd.yml create mode 100644 roles/cis/tasks/grub_password.yml create mode 100644 roles/cis/tasks/packages.yml create mode 100644 roles/cis/tasks/password_expiry.yml create mode 100644 roles/cis/tasks/warning_banners.yml diff --git a/README.md b/README.md index 2c4ce77..e22c770 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Non-Arch targets require the appropriate package manager available from the ISO - 4.1 [Core Variables](#41-core-variables) - 4.2 [`system` Dictionary](#42-system-dictionary) - 4.3 [`hypervisor` Dictionary](#43-hypervisor-dictionary) - - 4.4 [`cis` Dictionary](#44-cis-dictionary) + - 4.4 [CIS Hardening](#44-cis-hardening) - 4.5 [VMware Guest Operations](#45-vmware-guest-operations) - 4.6 [Multi-Disk Schema](#46-multi-disk-schema) - 4.7 [Advanced Partitioning Overrides](#47-advanced-partitioning-overrides) @@ -59,12 +59,10 @@ Non-Arch targets require the appropriate package manager available from the ISO Two dict-based variables drive the entire configuration: -- **`system`** -- host, network, users, disk layout, encryption, and feature toggles +- **`system`** -- host, network, users, disk layout, encryption, and feature toggles (including CIS hardening under `system.features.cis`) - **`hypervisor`** -- virtualization backend credentials and targeting -An optional third dict **`cis`** overrides CIS hardening parameters when `system.features.cis.enabled: true`. - -All three are standard Ansible variables. Place them in `group_vars/`, `host_vars/`, or inline inventory. With `hash_behaviour = merge`, dictionaries merge across scopes, so shared values go in group vars and host-specific overrides go per-host. +Both are standard Ansible variables. Place them in `group_vars/`, `host_vars/`, or inline inventory. With `hash_behaviour = merge`, dictionaries merge across scopes, so shared values go in group vars and host-specific overrides go per-host. ### Variable Placement @@ -148,7 +146,7 @@ all: ### 4.1 Core Variables -Top-level variables outside `system`/`hypervisor`/`cis`. +Top-level variables outside `system`/`hypervisor`. | Variable | Type | Default | Description | | ---------------- | ------ | -------------------------- | ---------------------------------------------------- | @@ -268,7 +266,10 @@ The bootstrap auto-switches to dracut when `method: tpm2` is set. Override via ` | Key | Type | Default | Description | | ------------------ | ------ | -------------- | ------------------------------------ | -| `cis.enabled` | bool | `false` | Enable CIS hardening (see [4.4](#44-cis-dictionary)) | +| `cis.enabled` | bool | `false` | Enable CIS hardening (see [4.4](#44-cis-hardening)) | +| `cis.profile` | string | `default` | CIS profile: `default`, `l1`, or `l2` (see [4.4](#44-cis-hardening)) | +| `cis.rules` | dict | `{}` | Per-rule CIS overrides | +| `cis.params` | dict | `{}` | CIS parameter overrides | | `selinux.enabled` | bool | `true` | SELinux management | | `firewall.enabled` | bool | `true` | Firewall setup | | `firewall.backend` | string | `firewalld` | `firewalld` or `ufw` | @@ -457,44 +458,50 @@ system: | `certs` | bool | `false` | TLS certificate validation (VMware) | | `ssh` | bool | `false` | Enable SSH on guest and switch connection (VMware) | -### 4.4 `cis` Dictionary +### 4.4 CIS Hardening -When `system.features.cis.enabled: true`, the CIS role applies hardening. All values have sensible defaults; override specific keys via the `cis` dict. +When `system.features.cis.enabled: true`, the CIS role applies hardening. The behaviour is driven by three keys under `system.features.cis`: -| Key | Type | Default | Description | -| -------------------- | ------ | ------- | ------------------------------------------------ | -| `modules_blacklist` | list | see below | Kernel modules to blacklist via modprobe | -| `sysctl` | dict | see below | Sysctl key/value pairs written to `10-cis.conf` | -| `sshd_options` | list | see below | SSHD options applied via lineinfile | -| `pwquality_minlen` | int | `14` | Minimum password length | -| `tmout` | int | `900` | Shell timeout (seconds) | -| `umask` | string | `077` | Default umask in bashrc | -| `umask_profile` | string | `027` | Default umask in /etc/profile | -| `faillock_deny` | int | `5` | Failed login attempts before lockout | -| `faillock_unlock_time` | int | `900` | Lockout duration (seconds) | -| `password_remember` | int | `5` | Password history depth | +| Key | Type | Default | Description | +| --------- | ------ | ----------- | ----------------------------------------------------------------- | +| `enabled` | bool | `false` | Apply CIS hardening at all | +| `profile` | string | `default` | `default` (house baseline), `l1` (clean CIS Level 1), or `l2` | +| `rules` | dict | `{}` | Per-rule on/off overrides on top of the profile | +| `params` | dict | `{}` | Parameter overrides (deep-merged; list values replace wholesale) | -**Default modules blacklist:** `freevxfs`, `jffs2`, `hfs`, `hfsplus`, `cramfs`, `udf`, `usb-storage`, `dccp`, `sctp`, `rds`, `tipc`, `firewire-core`, `firewire-sbp2`, `thunderbolt`. `squashfs` is added automatically except on Ubuntu (snap dependency). +**Profiles.** `default` is the established house baseline (CIS Level 1 plus the USB lockdown, full module blacklist, and IPv6-disable extras, minus the usability-hostile controls). `l1` is a clean CIS Level 1: it drops the L2 extras and adds password aging, AIDE, and warning banners. `l2` is `l1` plus auditd and the L2 extras. -**Default sysctl settings** include: `kernel.yama.ptrace_scope=2`, `kernel.kptr_restrict=2`, `kernel.perf_event_paranoid=3`, `kernel.unprivileged_bpf_disabled=1`, IPv4/IPv6 hardening, ARP protection, and IPv6 disabled by default. Override individual keys: +**Per-rule overrides.** Toggle an individual rule without changing profile, e.g. keep the default profile but allow USB and IPv6 on a desktop: ```yaml -cis: - sysctl: - net.ipv6.conf.all.disable_ipv6: 0 # re-enable IPv6 - net.ipv4.ip_forward: 1 # enable for routers/containers +system: + features: + cis: + enabled: true + rules: + usb_lockdown: false + ipv6_disable: false ``` -**Default SSHD options** enforce: `PermitRootLogin no`, `PasswordAuthentication no`, `X11Forwarding no`, `AllowTcpForwarding no`, `MaxAuthTries 4`, and post-quantum KEX (mlkem768x25519-sha256 on OpenSSH 9.9+). Override per-option: +Rule keys: `module_blacklist`, `usb_lockdown`, `sysctl_hardening`, `ipv6_disable`, `umask_default`, `empty_password_login`, `pwquality`, `core_dumps`, `shell_timeout`, `journald_persistent`, `sudo_logfile`, `su_restriction`, `faillock`, `password_history`, `tcp_wrappers`, `crypto_policy`, `mask_services`, `cron_at_access`, `file_permissions`, `sshd_hardening`, `password_expiry`, `aide`, `warning_banners`, `auditd`, and the opt-in `grub_password` (set `rules.grub_password: true` with `params.grub_password_hash`). + +**Parameters.** Override baseline values under `params` (full list in `roles/cis/vars/main.yml`): ```yaml -cis: - sshd_options: - - { option: X11Forwarding, value: "yes" } - - { option: AllowTcpForwarding, value: "yes" } +system: + features: + cis: + enabled: true + profile: l1 + params: + pwquality_minlen: 16 + sysctl: # dict: deep-merged over the profile's set + net.ipv4.ip_forward: 1 + sshd_options: # list: REPLACES the entire default list + - {option: X11Forwarding, value: "yes"} ``` -Note: providing `sshd_options` replaces the entire list. Copy the defaults from `roles/cis/defaults/main.yml` and modify as needed. +Common params: `modules_blacklist` (list), `sysctl` (dict), `sshd_options` (list), `pwquality_minlen` (14), `tmout` (900), `umask` (077), `umask_profile` (027), `faillock_deny` (5), `faillock_unlock_time` (900), `password_remember` (5), `pass_max_days` (365), `aide_cron_hour`/`aide_cron_minute`, `banner_text`, `grub_password_hash`. ### 4.5 VMware Guest Operations diff --git a/main.yml b/main.yml index 0ba9d93..ec27052 100644 --- a/main.yml +++ b/main.yml @@ -62,6 +62,12 @@ name: configuration public: true + # Past this point the OS is installed and configured; a CIS hardening or + # cleanup failure must not delete an otherwise-good VM. + - name: Mark base system complete + ansible.builtin.set_fact: + _bootstrap_base_complete: true + - name: Apply CIS hardening when: system_cfg.features.cis.enabled | bool ansible.builtin.include_role: @@ -75,11 +81,16 @@ public: true rescue: + - name: Decide whether to delete the half-built VM + ansible.builtin.set_fact: + _delete_vm_on_rescue: >- + {{ _vm_absent_before_bootstrap | default(false) | bool + and virtualization_vm_created_in_run | default(false) | bool + and system_cfg.type == "virtual" + and not (_bootstrap_base_complete | default(false) | bool) }} + - name: Delete VM on bootstrap failure - when: - - _vm_absent_before_bootstrap | default(false) | bool - - virtualization_vm_created_in_run | default(false) | bool - - system_cfg.type == "virtual" + when: _delete_vm_on_rescue | bool ansible.builtin.include_role: name: virtualization tasks_from: delete @@ -93,9 +104,8 @@ ansible.builtin.fail: msg: >- Bootstrap failed for {{ hostname }}. - {{ 'VM was deleted to allow clean retry.' - if (virtualization_vm_created_in_run | default(false)) - else 'VM was not created in this run (kept).' }} + {{ 'VM was deleted to allow clean retry.' if (_delete_vm_on_rescue | bool) + else 'VM kept (base system installed or not created this run).' }} post_tasks: - name: Set post-reboot connection flags diff --git a/roles/cis/defaults/main.yml b/roles/cis/defaults/main.yml index 9adcbb0..829bf54 100644 --- a/roles/cis/defaults/main.yml +++ b/roles/cis/defaults/main.yml @@ -1,100 +1,13 @@ --- -# User-facing API: override via top-level `cis` dict in inventory. -# Merged with these defaults in _normalize.yml -> cis_cfg. -cis_defaults: - modules_blacklist: - - freevxfs - - jffs2 - - hfs - - hfsplus - - cramfs - - udf - - usb-storage - - dccp - - sctp - - rds - - tipc - - firewire-core - - firewire-sbp2 - - thunderbolt - sysctl: - fs.suid_dumpable: 0 - kernel.dmesg_restrict: 1 - kernel.kptr_restrict: 2 - kernel.perf_event_paranoid: 3 - kernel.unprivileged_bpf_disabled: 1 - kernel.yama.ptrace_scope: 2 - kernel.randomize_va_space: 2 - net.ipv4.ip_forward: 0 - net.ipv4.tcp_syncookies: 1 - net.ipv4.icmp_echo_ignore_broadcasts: 1 - net.ipv4.icmp_ignore_bogus_error_responses: 1 - net.ipv4.conf.all.log_martians: 1 - net.ipv4.conf.all.rp_filter: 1 - net.ipv4.conf.all.secure_redirects: 0 - net.ipv4.conf.all.send_redirects: 0 - net.ipv4.conf.all.accept_redirects: 0 - net.ipv4.conf.all.accept_source_route: 0 - net.ipv4.conf.all.arp_ignore: 1 - net.ipv4.conf.all.arp_announce: 2 - net.ipv4.conf.default.log_martians: 1 - net.ipv4.conf.default.rp_filter: 1 - net.ipv4.conf.default.secure_redirects: 0 - net.ipv4.conf.default.send_redirects: 0 - net.ipv4.conf.default.accept_redirects: 0 - net.ipv6.conf.all.accept_redirects: 0 - net.ipv6.conf.all.disable_ipv6: 1 - net.ipv6.conf.default.accept_redirects: 0 - net.ipv6.conf.default.disable_ipv6: 1 - net.ipv6.conf.lo.disable_ipv6: 1 - sshd_options: - - { option: LogLevel, value: VERBOSE } - - { option: LoginGraceTime, value: "60" } - - { option: PermitRootLogin, value: "no" } - - { option: StrictModes, value: "yes" } - - { option: MaxAuthTries, value: "4" } - - { option: MaxSessions, value: "10" } - - { option: MaxStartups, value: "10:30:60" } - - { option: PubkeyAuthentication, value: "yes" } - - { option: HostbasedAuthentication, value: "no" } - - { option: IgnoreRhosts, value: "yes" } - - { option: PasswordAuthentication, value: "no" } - - { option: PermitEmptyPasswords, value: "no" } - - { option: KerberosAuthentication, value: "no" } - - { option: GSSAPIAuthentication, value: "no" } - - { option: AllowAgentForwarding, value: "no" } - - { option: AllowTcpForwarding, value: "no" } - - { option: KbdInteractiveAuthentication, value: "no" } - - { option: GatewayPorts, value: "no" } - - { option: X11Forwarding, value: "no" } - - { option: PermitUserEnvironment, value: "no" } - - { option: ClientAliveInterval, value: "300" } - - { option: ClientAliveCountMax, value: "1" } - - { option: PermitTunnel, value: "no" } - - { option: Banner, value: /etc/issue.net } - pwquality_minlen: 14 - tmout: 900 - umask: "077" - umask_profile: "027" - faillock_deny: 5 - faillock_unlock_time: 900 - password_remember: 5 - -# Platform-specific binary names for CIS permission targets -cis_fusermount_binary: "{{ 'fusermount3' if is_rhel | default(false) | bool else 'fusermount' }}" -cis_write_binary: "{{ 'write' if is_rhel | default(false) | bool else 'wall' }}" - -cis: {} - cis_permission_targets: - - { path: "/mnt/etc/ssh/sshd_config", mode: "0600" } - - { path: "/mnt/etc/cron.hourly", mode: "0700" } - - { path: "/mnt/etc/cron.daily", mode: "0700" } - - { path: "/mnt/etc/cron.weekly", mode: "0700" } - - { path: "/mnt/etc/cron.monthly", mode: "0700" } - - { path: "/mnt/etc/cron.d", mode: "0700" } - - { path: "/mnt/etc/crontab", mode: "0600" } - - { path: "/mnt/etc/logrotate.conf", mode: "0644" } - - { path: "/mnt/usr/sbin/pppd", mode: "0754" } - - { path: "/mnt/usr/bin/{{ cis_fusermount_binary }}", mode: "0755" } - - { path: "/mnt/usr/bin/{{ cis_write_binary }}", mode: "0755" } + - {path: "/mnt/etc/ssh/sshd_config", mode: "0600"} + - {path: "/mnt/etc/cron.hourly", mode: "0700"} + - {path: "/mnt/etc/cron.daily", mode: "0700"} + - {path: "/mnt/etc/cron.weekly", mode: "0700"} + - {path: "/mnt/etc/cron.monthly", mode: "0700"} + - {path: "/mnt/etc/cron.d", mode: "0700"} + - {path: "/mnt/etc/crontab", mode: "0600"} + - {path: "/mnt/etc/logrotate.conf", mode: "0644"} + - {path: "/mnt/usr/sbin/pppd", mode: "0754"} + - {path: "/mnt/usr/bin/{{ cis_fusermount_binary }}", mode: "0755"} + - {path: "/mnt/usr/bin/{{ cis_write_binary }}", mode: "0755"} diff --git a/roles/cis/tasks/_normalize.yml b/roles/cis/tasks/_normalize.yml index 9ba731b..69212a4 100644 --- a/roles/cis/tasks/_normalize.yml +++ b/roles/cis/tasks/_normalize.yml @@ -1,10 +1,25 @@ --- -- name: Normalize CIS input +- name: Determine CIS profile ansible.builtin.set_fact: - cis_enabled: "{{ cis is defined and (cis is mapping or cis | bool) }}" - cis_input: "{{ cis if cis is mapping else {} }}" + cis_profile: "{{ system_cfg.features.cis.profile | default('default') }}" -- name: Normalize CIS configuration - when: cis_enabled and cis_cfg is not defined +- name: Validate CIS profile selection + ansible.builtin.assert: + that: cis_profile in cis_profiles + fail_msg: >- + system.features.cis.profile '{{ cis_profile }}' is unknown + (valid: {{ cis_profiles.keys() | list | join(', ') }}). + quiet: true + +- name: Resolve CIS rules and parameters + vars: + _cis: "{{ system_cfg.features.cis | default({}) }}" ansible.builtin.set_fact: - cis_cfg: "{{ cis_defaults | combine(cis_input, recursive=True) }}" + cis_effective_rules: "{{ cis_profiles[cis_profile] | combine(_cis.rules | default({})) }}" + cis_cfg: >- + {{ cis_param_defaults + | combine(cis_profile_params[cis_profile] | default({}), recursive=True) + | combine(_cis.params | default({}), recursive=True) }} + # l1/l2 add the stricter CIS-server controls on top of the legacy `default` + # baseline; gate those tasks on this so `default` stays byte-for-byte unchanged. + cis_strict: "{{ cis_profile in ['l1', 'l2'] }}" diff --git a/roles/cis/tasks/aide.yml b/roles/cis/tasks/aide.yml new file mode 100644 index 0000000..fdf0043 --- /dev/null +++ b/roles/cis/tasks/aide.yml @@ -0,0 +1,42 @@ +--- +- name: Install AIDE + when: cis_effective_rules.aide | default(false) + # Debian's aideinit lives in aide-common (only Recommended, so absent under + # the installer's --no-install-recommends); pull it explicitly. + ansible.builtin.command: "{{ cis_pkg_install }} {{ 'aide aide-common' if is_debian | bool else 'aide' }}" + register: cis_aide_install + changed_when: cis_aide_install.rc == 0 + +- name: Initialize the AIDE database + when: cis_effective_rules.aide | default(false) + # Absolute path: arch-chroot's PATH omits /usr/sbin, so bare aide/aideinit is rc127. + # Debian's aideinit assembles its split config; RHEL/Arch run --init on /etc/aide.conf. + ansible.builtin.command: "{{ chroot_command }} {{ '/usr/sbin/aideinit -y -f' if is_debian | bool else '/usr/sbin/aide --init' }}" + register: cis_aide_init + changed_when: cis_aide_init.rc == 0 + +- name: Locate the freshly built AIDE database + when: cis_effective_rules.aide | default(false) + ansible.builtin.find: + paths: /mnt/var/lib/aide + patterns: "aide.db.new*" + register: cis_aide_newdb + +- name: Activate the AIDE database + when: + - cis_effective_rules.aide | default(false) + - cis_aide_newdb.files | length > 0 + ansible.builtin.copy: + src: "{{ cis_aide_newdb.files[0].path }}" + dest: "{{ cis_aide_newdb.files[0].path | regex_replace('\\.new', '') }}" + remote_src: true + mode: "0600" + +- name: Schedule the daily AIDE integrity check + when: cis_effective_rules.aide | default(false) + ansible.builtin.copy: + dest: /mnt/etc/cron.d/cis-aide + mode: "0644" + content: | + PATH=/usr/sbin:/usr/bin:/sbin:/bin + {{ cis_cfg.aide_cron_minute }} {{ cis_cfg.aide_cron_hour }} * * * root aide --check diff --git a/roles/cis/tasks/auditd.yml b/roles/cis/tasks/auditd.yml new file mode 100644 index 0000000..81e7322 --- /dev/null +++ b/roles/cis/tasks/auditd.yml @@ -0,0 +1,42 @@ +--- +- name: Install the audit daemon + when: cis_effective_rules.auditd | default(false) + ansible.builtin.command: "{{ cis_pkg_install }} {{ 'auditd' if is_debian | bool else 'audit' }}" + register: cis_auditd_install + changed_when: cis_auditd_install.rc == 0 + +- name: Deploy the CIS audit rule set + when: cis_effective_rules.auditd | default(false) + ansible.builtin.copy: + dest: /mnt/etc/audit/rules.d/cis.rules + mode: "0640" + content: | + ## CIS baseline audit rules + -D + -b 8192 + -f 1 + -a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time-change + -w /etc/localtime -p wa -k time-change + -w /etc/group -p wa -k identity + -w /etc/passwd -p wa -k identity + -w /etc/shadow -p wa -k identity + -w /etc/gshadow -p wa -k identity + -w /etc/security/opasswd -p wa -k identity + -a always,exit -F arch=b64 -S sethostname,setdomainname -k system-locale + -w /etc/hosts -p wa -k system-locale + -w /var/log/lastlog -p wa -k logins + -w /var/run/faillock -p wa -k logins + -w /var/run/utmp -p wa -k session + -w /var/log/wtmp -p wa -k session + -w /var/log/btmp -p wa -k session + -a always,exit -F arch=b64 -S chmod,fchmod,fchmodat,chown,fchown,fchownat,lchown -F auid>=1000 -F auid!=4294967295 -k perm_mod + -w /etc/sudoers -p wa -k scope + -w /etc/sudoers.d -p wa -k scope + -a always,exit -F arch=b64 -S init_module,delete_module -k modules + -e 2 + +- name: Enable the audit daemon + when: cis_effective_rules.auditd | default(false) + ansible.builtin.command: "{{ chroot_command }} systemctl enable auditd" + register: cis_auditd_enable + changed_when: "'Created symlink' in cis_auditd_enable.stderr" diff --git a/roles/cis/tasks/auth.yml b/roles/cis/tasks/auth.yml index 588436c..ec47e59 100644 --- a/roles/cis/tasks/auth.yml +++ b/roles/cis/tasks/auth.yml @@ -1,12 +1,33 @@ --- - name: Ensure the Default UMASK is Set Correctly + when: cis_effective_rules.umask_default | default(false) ansible.builtin.lineinfile: path: "/mnt/etc/profile" regexp: "^(\\s*)umask\\s+\\d+" line: "umask {{ cis_cfg.umask_profile }}" +- name: Set the login.defs UMASK (CIS L1+) + when: + - cis_effective_rules.umask_default | default(false) + - cis_strict | default(false) + ansible.builtin.lineinfile: + path: /mnt/etc/login.defs + regexp: '^\s*#?\s*UMASK\b' + line: "UMASK\t\t{{ cis_cfg.umask_profile }}" + +# authselect regenerates system-auth from the profile, so a direct edit is lost +# on the next apply; without-nullok is the supported way to drop nullok there. +- name: Prevent Login to Accounts With Empty Password (authselect) + when: + - cis_effective_rules.empty_password_login | default(false) + - is_authselect | bool + ansible.builtin.command: "{{ chroot_command }} authselect enable-feature without-nullok" + register: cis_nullok_result + changed_when: cis_nullok_result.rc == 0 + # Non-RHEL/non-Debian distros: loop evaluates to [] (intentional skip) - name: Prevent Login to Accounts With Empty Password + when: cis_effective_rules.empty_password_login | default(false) ansible.builtin.replace: dest: "{{ item }}" regexp: "\\s*nullok" diff --git a/roles/cis/tasks/crypto.yml b/roles/cis/tasks/crypto.yml index 3e5fe5b..28bbdae 100644 --- a/roles/cis/tasks/crypto.yml +++ b/roles/cis/tasks/crypto.yml @@ -2,12 +2,15 @@ # Fedora ships its own crypto-policies preset and update-crypto-policies # behaves differently; applying DEFAULT:NO-SHA1 can break package signing. - name: Configure System Cryptography Policy - when: os in (os_family_rhel | difference(['fedora'])) + when: + - cis_effective_rules.crypto_policy | default(false) + - os in (os_family_rhel | difference(['fedora'])) ansible.builtin.command: "{{ chroot_command }} /usr/bin/update-crypto-policies --set DEFAULT:NO-SHA1" register: cis_crypto_policy_result changed_when: "'Setting system-wide crypto-policies to' in cis_crypto_policy_result.stdout" - name: Mask Systemd Services + when: cis_effective_rules.mask_services | default(false) ansible.builtin.command: > {{ chroot_command }} systemctl mask {{ 'nftables' if system_cfg.features.firewall.toolkit == 'iptables' else 'iptables' }} bluetooth rpcbind register: cis_mask_services_result diff --git a/roles/cis/tasks/files.yml b/roles/cis/tasks/files.yml index c51e138..48c960d 100644 --- a/roles/cis/tasks/files.yml +++ b/roles/cis/tasks/files.yml @@ -1,5 +1,6 @@ --- -- name: Ensure files exist +- name: Ensure cron and at access files exist + when: cis_effective_rules.cron_at_access | default(false) ansible.builtin.file: path: "{{ item }}" state: touch @@ -7,10 +8,19 @@ loop: - /mnt/etc/at.allow - /mnt/etc/cron.allow + +- name: Ensure TCP wrapper files exist + when: cis_effective_rules.tcp_wrappers | default(false) + ansible.builtin.file: + path: "{{ item }}" + state: touch + mode: "0600" + loop: - /mnt/etc/hosts.allow - /mnt/etc/hosts.deny -- name: Ensure files do not exist +- name: Ensure cron and at deny files do not exist + when: cis_effective_rules.cron_at_access | default(false) ansible.builtin.file: path: "{{ item }}" state: absent diff --git a/roles/cis/tasks/grub_password.yml b/roles/cis/tasks/grub_password.yml new file mode 100644 index 0000000..3dc01e5 --- /dev/null +++ b/roles/cis/tasks/grub_password.yml @@ -0,0 +1,31 @@ +--- +# Opt-in only: a GRUB superuser password blocks unattended menu edits; the default entry still boots. +- name: Assert a GRUB password hash is supplied + when: cis_effective_rules.grub_password | default(false) + ansible.builtin.assert: + that: cis_cfg.grub_password_hash | length > 0 + fail_msg: >- + system.features.cis.rules.grub_password is enabled but + system.features.cis.params.grub_password_hash is empty. Generate one with + grub2-mkpasswd-pbkdf2 and set it there. + quiet: true + +- name: Deploy the GRUB superuser password + when: cis_effective_rules.grub_password | default(false) + ansible.builtin.copy: + dest: /mnt/etc/grub.d/01_cis_password + mode: "0755" + content: | + #!/bin/sh + cat <<'EOF' + set superusers="root" + password_pbkdf2 root {{ cis_cfg.grub_password_hash }} + EOF + +- name: Regenerate the GRUB configuration + when: cis_effective_rules.grub_password | default(false) + ansible.builtin.command: >- + {{ chroot_command }} + {{ 'grub2-mkconfig -o /boot/grub2/grub.cfg' if is_rhel | bool else 'grub-mkconfig -o /boot/grub/grub.cfg' }} + register: cis_grub_regen + changed_when: cis_grub_regen.rc == 0 diff --git a/roles/cis/tasks/main.yml b/roles/cis/tasks/main.yml index 5f85e06..0776964 100644 --- a/roles/cis/tasks/main.yml +++ b/roles/cis/tasks/main.yml @@ -3,7 +3,6 @@ ansible.builtin.import_tasks: _normalize.yml - name: Apply CIS hardening - when: cis_enabled block: - name: Include CIS hardening tasks ansible.builtin.include_tasks: "{{ cis_task }}" @@ -16,5 +15,11 @@ - security_lines.yml - permissions.yml - sshd.yml + - warning_banners.yml + - password_expiry.yml + - aide.yml + - auditd.yml + - packages.yml + - grub_password.yml loop_control: loop_var: cis_task diff --git a/roles/cis/tasks/modules.yml b/roles/cis/tasks/modules.yml index 7b444a5..91f9ac6 100644 --- a/roles/cis/tasks/modules.yml +++ b/roles/cis/tasks/modules.yml @@ -1,5 +1,6 @@ --- - name: Disable Kernel Modules + when: cis_effective_rules.module_blacklist | default(false) vars: # Ubuntu uses squashfs for snap packages - blacklisting it breaks snap entirely cis_modules_squashfs: "{{ [] if os in ['ubuntu', 'ubuntu-lts'] else ['squashfs'] }}" @@ -14,11 +15,13 @@ {% endfor %} - name: Remove old USB rules file + when: cis_effective_rules.usb_lockdown | default(false) ansible.builtin.file: path: /mnt/etc/udev/rules.d/10-cis_usb_devices.sh state: absent - name: Create USB rules + when: cis_effective_rules.usb_lockdown | default(false) ansible.builtin.copy: dest: /mnt/etc/udev/rules.d/10-cis_usb_devices.rules mode: "0644" diff --git a/roles/cis/tasks/packages.yml b/roles/cis/tasks/packages.yml new file mode 100644 index 0000000..6ec0c76 --- /dev/null +++ b/roles/cis/tasks/packages.yml @@ -0,0 +1,29 @@ +--- +# CIS L1 names legacy cleartext clients (telnet) for removal. They are absent on +# a fresh minimal install; query first and remove only when present so the run +# stays idempotent (a chroot package-manager remove cannot use the package module). +- name: Check for insecure cleartext clients + when: cis_strict | default(false) + ansible.builtin.command: >- + {{ chroot_command }} + {{ 'dpkg -s' if is_debian | bool else 'pacman -Q' if os == 'archlinux' else 'rpm -q' }} + {{ item }} + loop: "{{ cis_cfg.insecure_packages }}" + register: cis_insecure_present + changed_when: false + failed_when: false + loop_control: + label: "{{ item }}" + +- name: Remove insecure cleartext clients (CIS L1+) + when: + - cis_strict | default(false) + - item.rc == 0 + ansible.builtin.command: >- + {{ chroot_command }} + {{ 'apt-get remove -y' if is_debian | bool else 'pacman -R --noconfirm' if os == 'archlinux' else 'dnf remove -y' }} + {{ item.item }} + loop: "{{ cis_insecure_present.results | default([]) }}" + changed_when: true + loop_control: + label: "{{ item.item }}" diff --git a/roles/cis/tasks/password_expiry.yml b/roles/cis/tasks/password_expiry.yml new file mode 100644 index 0000000..4ccc097 --- /dev/null +++ b/roles/cis/tasks/password_expiry.yml @@ -0,0 +1,22 @@ +--- +# login.defs sets policy for future accounts; existing service accounts are intentionally not chage-aged. +- name: Configure password aging defaults + when: cis_effective_rules.password_expiry | default(false) + ansible.builtin.lineinfile: + path: /mnt/etc/login.defs + regexp: '^#?\s*{{ item.key }}\b' + line: "{{ item.key }}\t{{ item.value }}" + loop: + - {key: PASS_MAX_DAYS, value: "{{ cis_cfg.pass_max_days }}"} + - {key: PASS_MIN_DAYS, value: "{{ cis_cfg.pass_min_days }}"} + - {key: PASS_WARN_AGE, value: "{{ cis_cfg.pass_warn_age }}"} + loop_control: + label: "{{ item.key }}" + +# account_disable_post_pw_expiration: lock accounts INACTIVE days after expiry. +- name: Set the default account inactivity lock period + when: cis_effective_rules.password_expiry | default(false) + ansible.builtin.lineinfile: + path: /mnt/etc/default/useradd + regexp: '^\s*#?\s*INACTIVE\s*=' + line: "INACTIVE={{ cis_cfg.pass_inactive }}" diff --git a/roles/cis/tasks/permissions.yml b/roles/cis/tasks/permissions.yml index b2645ed..4362fd6 100644 --- a/roles/cis/tasks/permissions.yml +++ b/roles/cis/tasks/permissions.yml @@ -1,5 +1,6 @@ --- - name: Check CIS permission targets + when: cis_effective_rules.file_permissions | default(false) ansible.builtin.stat: path: "{{ item.path }}" loop: "{{ cis_permission_targets }}" @@ -9,12 +10,14 @@ changed_when: false - name: Set permissions for existing targets + when: + - cis_effective_rules.file_permissions | default(false) + - item.stat.exists ansible.builtin.file: path: "{{ item.item.path }}" owner: "{{ item.item.owner | default(omit) }}" group: "{{ item.item.group | default(omit) }}" mode: "{{ item.item.mode }}" - loop: "{{ cis_permission_stats.results }}" + loop: "{{ cis_permission_stats.results | default([]) }}" loop_control: label: "{{ item.item.path }}" - when: item.stat.exists diff --git a/roles/cis/tasks/security_lines.yml b/roles/cis/tasks/security_lines.yml index dfa35f8..b140411 100644 --- a/roles/cis/tasks/security_lines.yml +++ b/roles/cis/tasks/security_lines.yml @@ -1,31 +1,138 @@ --- -- name: Add Security related lines into config files +- name: Restrict core dumps + when: cis_effective_rules.core_dumps | default(false) + ansible.builtin.lineinfile: + path: /mnt/etc/security/limits.conf + regexp: '^\*\s+hard\s+core\s+' + line: "* hard core 0" + +- name: Ensure the systemd coredump drop-in directory exists (CIS L1+) + when: + - cis_effective_rules.core_dumps | default(false) + - cis_strict | default(false) + ansible.builtin.file: + path: /mnt/etc/systemd/coredump.conf.d + state: directory + mode: "0755" + +- name: Disable systemd core dump storage and backtraces (CIS L1+) + when: + - cis_effective_rules.core_dumps | default(false) + - cis_strict | default(false) + ansible.builtin.copy: + dest: /mnt/etc/systemd/coredump.conf.d/10-cis.conf + mode: "0644" + content: | + [Coredump] + Storage=none + ProcessSizeMax=0 + +- name: Set password quality requirements + when: cis_effective_rules.pwquality | default(false) + ansible.builtin.lineinfile: + path: /mnt/etc/security/pwquality.conf + regexp: "{{ item.regexp }}" + line: "{{ item.line }}" + loop: + - {regexp: '^\s*#?\s*minlen\s*=', line: "minlen = {{ cis_cfg.pwquality_minlen }}"} + - {regexp: '^\s*#?\s*dcredit\s*=', line: "dcredit = -1"} + - {regexp: '^\s*#?\s*ucredit\s*=', line: "ucredit = -1"} + - {regexp: '^\s*#?\s*ocredit\s*=', line: "ocredit = -1"} + - {regexp: '^\s*#?\s*lcredit\s*=', line: "lcredit = -1"} + loop_control: + label: "{{ item.line }}" + +# Stricter complexity SSG cis_server_l1 checks; affects only new-password changes. +- name: Set strict password quality requirements (CIS L1+) + when: + - cis_effective_rules.pwquality | default(false) + - cis_strict | default(false) + ansible.builtin.lineinfile: + path: /mnt/etc/security/pwquality.conf + regexp: "{{ item.regexp }}" + line: "{{ item.line }}" + loop: + - {regexp: '^\s*#?\s*difok\s*=', line: "difok = {{ cis_cfg.pwquality_difok }}"} + - {regexp: '^\s*#?\s*maxrepeat\s*=', line: "maxrepeat = {{ cis_cfg.pwquality_maxrepeat }}"} + - {regexp: '^\s*#?\s*maxsequence\s*=', line: "maxsequence = {{ cis_cfg.pwquality_maxsequence }}"} + - {regexp: '^\s*#?\s*minclass\s*=', line: "minclass = {{ cis_cfg.pwquality_minclass }}"} + - {regexp: '^\s*#?\s*dictcheck\s*=', line: "dictcheck = {{ cis_cfg.pwquality_dictcheck }}"} + - {regexp: '^\s*#?\s*enforce_for_root\b', line: "enforce_for_root"} + loop_control: + label: "{{ item.line }}" + +- name: Set the default shell umask + when: cis_effective_rules.umask_default | default(false) + ansible.builtin.lineinfile: + path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}' + regexp: '^\s*umask\s+\d+' + line: "umask {{ cis_cfg.umask }}" + +- name: Set the shell idle timeout + when: cis_effective_rules.shell_timeout | default(false) + ansible.builtin.lineinfile: + path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}' + regexp: '^\s*(export\s+)?TMOUT=' + line: "export TMOUT={{ cis_cfg.tmout }}" + +# A drop-in survives systemd upgrades; the RHEL vendor journald.conf does not. +- name: Ensure the journald drop-in directory exists + when: cis_effective_rules.journald_persistent | default(false) + ansible.builtin.file: + path: /mnt/etc/systemd/journald.conf.d + state: directory + mode: "0755" + +- name: Enable persistent journald storage + when: cis_effective_rules.journald_persistent | default(false) + ansible.builtin.copy: + dest: /mnt/etc/systemd/journald.conf.d/10-cis.conf + mode: "0644" + content: | + [Journal] + Storage=persistent + +- name: Compress large journald log files (CIS L1+) + when: + - cis_effective_rules.journald_persistent | default(false) + - cis_strict | default(false) + ansible.builtin.copy: + dest: /mnt/etc/systemd/journald.conf.d/20-cis-compress.conf + mode: "0644" + content: | + [Journal] + Compress=yes + +- name: Log sudo commands + when: cis_effective_rules.sudo_logfile | default(false) + ansible.builtin.lineinfile: + path: /mnt/etc/sudoers + regexp: '^\s*Defaults\s+logfile=' + line: 'Defaults logfile="/var/log/sudo.log"' + +- name: Require a pty for sudo (CIS L1+) + when: + - cis_effective_rules.sudo_logfile | default(false) + - cis_strict | default(false) + ansible.builtin.lineinfile: + path: /mnt/etc/sudoers + regexp: '^\s*Defaults\s+use_pty\b' + line: "Defaults use_pty" + +- name: Restrict su to the wheel group + when: cis_effective_rules.su_restriction | default(false) + ansible.builtin.lineinfile: + path: /mnt/etc/pam.d/su + regexp: '^\s*#?\s*auth\s+required\s+pam_wheel\.so' + line: auth required pam_wheel.so + +- name: Configure account lockout + when: cis_effective_rules.faillock | default(false) ansible.builtin.lineinfile: path: "{{ item.path }}" regexp: "{{ item.regexp }}" - line: "{{ item.content }}" + line: "{{ item.line }}" loop: - - { path: /mnt/etc/security/limits.conf, regexp: '^\*\s+hard\s+core\s+', content: "* hard core 0" } - - { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*minlen\s*=', content: "minlen = {{ cis_cfg.pwquality_minlen }}" } - - { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*dcredit\s*=', content: dcredit = -1 } - - { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*ucredit\s*=', content: ucredit = -1 } - - { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*ocredit\s*=', content: ocredit = -1 } - - { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*lcredit\s*=', content: lcredit = -1 } - - path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}' - regexp: '^\s*umask\s+\d+' - content: "umask {{ cis_cfg.umask }}" - - path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}' - regexp: '^\s*(export\s+)?TMOUT=' - content: "export TMOUT={{ cis_cfg.tmout }}" - - path: '/mnt/{{ "usr/lib/systemd/journald.conf" if is_rhel | bool else "etc/systemd/journald.conf" }}' - regexp: '^\s*#?\s*Storage=' - content: Storage=persistent - - path: /mnt/etc/sudoers - regexp: '^\s*Defaults\s+logfile=' - content: Defaults logfile="/var/log/sudo.log" - - path: /mnt/etc/pam.d/su - regexp: '^\s*#?\s*auth\s+required\s+pam_wheel\.so' - content: auth required pam_wheel.so - path: >- /mnt/etc/{{ "pam.d/common-auth" @@ -35,7 +142,7 @@ else "pam.d/system-auth" }} regexp: '^\s*auth\s+required\s+pam_faillock\.so' - content: >- + line: >- auth required pam_faillock.so onerr=fail audit silent deny={{ cis_cfg.faillock_deny }} unlock_time={{ cis_cfg.faillock_unlock_time }} - path: >- /mnt/etc/{{ @@ -46,17 +153,53 @@ else "pam.d/system-auth" }} regexp: '^\s*account\s+required\s+pam_faillock\.so' - content: account required pam_faillock.so - - path: >- - /mnt/etc/pam.d/{{ - "common-password" - if is_debian | bool - else "passwd" - }} - regexp: '^\s*password\s+\[success=1.*\]\s+pam_unix\.so' - content: >- - password [success=1 default=ignore] pam_unix.so obscure sha512 remember={{ cis_cfg.password_remember }} - - { path: /mnt/etc/hosts.deny, regexp: '^ALL:\s*ALL', content: "ALL: ALL" } - - { path: /mnt/etc/hosts.allow, regexp: '^sshd:\s*ALL', content: "sshd: ALL" } + line: account required pam_faillock.so loop_control: - label: "{{ item.content }}" + label: "{{ item.regexp }}" + +- name: Enforce password history + when: cis_effective_rules.password_history | default(false) + ansible.builtin.lineinfile: + path: >- + /mnt/etc/pam.d/{{ + "common-password" + if is_debian | bool + else "passwd" + }} + regexp: '^\s*password\s+\[success=1.*\]\s+pam_unix\.so' + line: >- + password [success=1 default=ignore] pam_unix.so obscure sha512 remember={{ cis_cfg.password_remember }} + +# SSG cis_server_l1 checks pam_pwhistory (not pam_unix remember) in the auth-stack +# files; affects only password changes, so no login-lockout risk. EL9 has no +# authselect path here (same direct-edit the faillock rule above uses). +- name: Enforce password reuse limit via pam_pwhistory (CIS L1+) + when: + - cis_effective_rules.password_history | default(false) + - cis_strict | default(false) + ansible.builtin.lineinfile: + path: "{{ item }}" + regexp: '^\s*password\s+(requisite|required)\s+pam_pwhistory\.so' + line: "password requisite pam_pwhistory.so use_authtok remember={{ cis_cfg.pwhistory_remember }} enforce_for_root" + insertbefore: '^\s*password\s+.*pam_unix\.so' + loop: >- + {{ + ['/mnt/etc/pam.d/system-auth', '/mnt/etc/pam.d/password-auth'] + if is_rhel | bool + else (['/mnt/etc/pam.d/common-password'] if is_debian | bool else []) + }} + loop_control: + label: "{{ item }}" + + +- name: Configure TCP wrappers + when: cis_effective_rules.tcp_wrappers | default(false) + ansible.builtin.lineinfile: + path: "{{ item.path }}" + regexp: "{{ item.regexp }}" + line: "{{ item.line }}" + loop: + - {path: /mnt/etc/hosts.deny, regexp: '^ALL:\s*ALL', line: "ALL: ALL"} + - {path: /mnt/etc/hosts.allow, regexp: '^sshd:\s*ALL', line: "sshd: ALL"} + loop_control: + label: "{{ item.path }}" diff --git a/roles/cis/tasks/sshd.yml b/roles/cis/tasks/sshd.yml index 2f94341..82fcf8d 100644 --- a/roles/cis/tasks/sshd.yml +++ b/roles/cis/tasks/sshd.yml @@ -1,5 +1,6 @@ --- - name: Adjust SSHD config + when: cis_effective_rules.sshd_hardening | default(false) ansible.builtin.lineinfile: path: /mnt/etc/ssh/sshd_config regexp: ^\s*#?{{ item.option }}\s+.*$ @@ -9,6 +10,7 @@ label: "{{ item.option }}" - name: Detect target OpenSSH version + when: cis_effective_rules.sshd_hardening | default(false) ansible.builtin.shell: >- set -o pipefail && {{ chroot_command }} ssh -V 2>&1 | grep -oP 'OpenSSH_\K[0-9]+\.[0-9]+' args: @@ -18,6 +20,7 @@ failed_when: false - name: Append CIS specific configurations to sshd_config + when: cis_effective_rules.sshd_hardening | default(false) vars: cis_sshd_has_mlkem: "{{ (cis_sshd_openssh_version.stdout | default('0.0') is version('9.9', '>=')) }}" cis_sshd_kex: >- diff --git a/roles/cis/tasks/sysctl.yml b/roles/cis/tasks/sysctl.yml index 6822667..e862e4f 100644 --- a/roles/cis/tasks/sysctl.yml +++ b/roles/cis/tasks/sysctl.yml @@ -1,10 +1,19 @@ --- - name: Create a consolidated sysctl configuration file + when: cis_effective_rules.sysctl_hardening | default(false) + vars: + # ipv6_disable is a separate rule: when off, drop the disable_ipv6 keys but keep the rest. + _cis_sysctl: >- + {{ cis_cfg.sysctl + if (cis_effective_rules.ipv6_disable | default(false)) + else (cis_cfg.sysctl | dict2items | rejectattr('key', 'search', 'disable_ipv6') | items2dict) }} ansible.builtin.copy: - dest: /mnt/etc/sysctl.d/10-cis.conf + # 99- so CIS wins: a 10- name loses to vendor /usr/lib/sysctl.d/10-default-yama-scope.conf + # (later basename applies last), which reset kernel.yama.ptrace_scope back to 0. + dest: /mnt/etc/sysctl.d/99-cis.conf mode: "0644" content: | ## CIS Sysctl configurations - {% for key, value in cis_cfg.sysctl | dictsort %} + {% for key, value in _cis_sysctl | dictsort %} {{ key }}={{ value }} {% endfor %} diff --git a/roles/cis/tasks/warning_banners.yml b/roles/cis/tasks/warning_banners.yml new file mode 100644 index 0000000..d91be24 --- /dev/null +++ b/roles/cis/tasks/warning_banners.yml @@ -0,0 +1,11 @@ +--- +- name: Set login warning banners + when: cis_effective_rules.warning_banners | default(false) + ansible.builtin.copy: + dest: "/mnt/etc/{{ item }}" + content: "{{ cis_cfg.banner_text }}\n" + mode: "0644" + loop: + - issue + - issue.net + - motd diff --git a/roles/cis/vars/main.yml b/roles/cis/vars/main.yml index bf06761..c4cabd6 100644 --- a/roles/cis/vars/main.yml +++ b/roles/cis/vars/main.yml @@ -1,6 +1,5 @@ --- -# OS-specific binary names for CIS permission targets. -# fusermount3 is the modern name; older distros still use fusermount. +# fusermount3 is the modern name; older distros still ship fusermount. cis_fusermount_binary: >- {{ 'fusermount3' @@ -19,3 +18,235 @@ cis_write_binary: >- if (os == 'debian' and (os_version | string) == '11') else 'write' }} + +cis_pkg_install: >- + {{ chroot_command }} {{ + 'apt-get install -y' + if is_debian | bool + else 'pacman -S --noconfirm' + if os == 'archlinux' + else 'dnf install -y' + }} + +# Rule catalog: control -> CIS level + whether a task implements it. +# `default` enables only implemented rules; `l1`/`l2` add the level-tagged ones. +cis_rule_catalog: + module_blacklist: {level: l1, implemented: true} # fs/net modprobe blacklist (list per profile) + usb_lockdown: {level: l2, implemented: true} # udev authorized_default=0 (aggressive) + sysctl_hardening: {level: l1, implemented: true} + ipv6_disable: {level: l2, implemented: true} # disable_ipv6 subset of the sysctl set + umask_default: {level: l1, implemented: true} + empty_password_login: {level: l1, implemented: true} + pwquality: {level: l1, implemented: true} + core_dumps: {level: l1, implemented: true} + shell_timeout: {level: l1, implemented: true} + journald_persistent: {level: l1, implemented: true} + sudo_logfile: {level: l1, implemented: true} + su_restriction: {level: l1, implemented: true} + faillock: {level: l1, implemented: true} + password_history: {level: l1, implemented: true} + tcp_wrappers: {level: l1, implemented: true} + crypto_policy: {level: l1, implemented: true} # RedHat non-Fedora only + mask_services: {level: l1, implemented: true} + cron_at_access: {level: l1, implemented: true} + file_permissions: {level: l1, implemented: true} + sshd_hardening: {level: l1, implemented: true} + password_expiry: {level: l1, implemented: true} # login.defs aging policy + aide: {level: l1, implemented: true} # file-integrity db + daily check + warning_banners: {level: l1, implemented: true} # /etc/issue, issue.net, motd + auditd: {level: l2, implemented: true} # audit daemon + CIS rule set + grub_password: {level: l1, implemented: true} # opt-in only; needs params.grub_password_hash + +# Rules not listed are off. A per-host system.features.cis.rules map overlays this. +cis_profiles: + # default = established house behaviour, kept byte-for-byte unchanged. + default: + module_blacklist: true + usb_lockdown: true + sysctl_hardening: true + ipv6_disable: true + umask_default: true + empty_password_login: true + pwquality: true + core_dumps: true + shell_timeout: true + journald_persistent: true + sudo_logfile: true + su_restriction: true + faillock: true + password_history: true + tcp_wrappers: true + crypto_policy: true + mask_services: true + cron_at_access: true + file_permissions: true + sshd_hardening: true + # l1 = clean CIS Level 1: drops the L2 extras (usb_lockdown, ipv6_disable). + l1: + module_blacklist: true + sysctl_hardening: true + umask_default: true + empty_password_login: true + pwquality: true + core_dumps: true + shell_timeout: true + journald_persistent: true + sudo_logfile: true + su_restriction: true + faillock: true + password_history: true + tcp_wrappers: true + crypto_policy: true + mask_services: true + cron_at_access: true + file_permissions: true + sshd_hardening: true + password_expiry: true + aide: true + warning_banners: true + # l2 = l1 plus the defence-in-depth Level 2 controls. + l2: + module_blacklist: true + usb_lockdown: true + sysctl_hardening: true + ipv6_disable: true + umask_default: true + empty_password_login: true + pwquality: true + core_dumps: true + shell_timeout: true + journald_persistent: true + sudo_logfile: true + su_restriction: true + faillock: true + password_history: true + tcp_wrappers: true + crypto_policy: true + mask_services: true + cron_at_access: true + file_permissions: true + sshd_hardening: true + password_expiry: true + aide: true + warning_banners: true + auditd: true + +# Override per host via system.features.cis.params: dicts deep-merge, +# list-valued keys (e.g. sshd_options) replace wholesale. +cis_param_defaults: + modules_blacklist: + - freevxfs + - jffs2 + - hfs + - hfsplus + - cramfs + - udf + - usb-storage + - dccp + - sctp + - rds + - tipc + - firewire-core + - firewire-sbp2 + - thunderbolt + sysctl: + fs.suid_dumpable: 0 + kernel.dmesg_restrict: 1 + kernel.kptr_restrict: 2 + kernel.perf_event_paranoid: 3 + kernel.unprivileged_bpf_disabled: 1 + kernel.yama.ptrace_scope: 2 + kernel.randomize_va_space: 2 + net.ipv4.ip_forward: 0 + net.ipv4.tcp_syncookies: 1 + net.ipv4.icmp_echo_ignore_broadcasts: 1 + net.ipv4.icmp_ignore_bogus_error_responses: 1 + net.ipv4.conf.all.log_martians: 1 + net.ipv4.conf.all.rp_filter: 1 + net.ipv4.conf.all.secure_redirects: 0 + net.ipv4.conf.all.send_redirects: 0 + net.ipv4.conf.all.accept_redirects: 0 + net.ipv4.conf.all.accept_source_route: 0 + net.ipv4.conf.all.arp_ignore: 1 + net.ipv4.conf.all.arp_announce: 2 + net.ipv4.conf.default.log_martians: 1 + net.ipv4.conf.default.rp_filter: 1 + net.ipv4.conf.default.secure_redirects: 0 + net.ipv4.conf.default.send_redirects: 0 + net.ipv4.conf.default.accept_redirects: 0 + net.ipv6.conf.all.accept_redirects: 0 + net.ipv6.conf.all.disable_ipv6: 1 + net.ipv6.conf.default.accept_redirects: 0 + net.ipv6.conf.default.disable_ipv6: 1 + net.ipv6.conf.lo.disable_ipv6: 1 + sshd_options: + - {option: LogLevel, value: VERBOSE} + - {option: LoginGraceTime, value: "60"} + - {option: PermitRootLogin, value: "no"} + - {option: StrictModes, value: "yes"} + - {option: MaxAuthTries, value: "4"} + - {option: MaxSessions, value: "10"} + - {option: MaxStartups, value: "10:30:60"} + - {option: PubkeyAuthentication, value: "yes"} + - {option: HostbasedAuthentication, value: "no"} + - {option: IgnoreRhosts, value: "yes"} + - {option: PasswordAuthentication, value: "no"} + - {option: PermitEmptyPasswords, value: "no"} + - {option: KerberosAuthentication, value: "no"} + - {option: GSSAPIAuthentication, value: "no"} + - {option: AllowAgentForwarding, value: "no"} + - {option: AllowTcpForwarding, value: "no"} + - {option: KbdInteractiveAuthentication, value: "no"} + - {option: GatewayPorts, value: "no"} + - {option: X11Forwarding, value: "no"} + - {option: PermitUserEnvironment, value: "no"} + - {option: ClientAliveInterval, value: "300"} + - {option: ClientAliveCountMax, value: "1"} + - {option: PermitTunnel, value: "no"} + - {option: Banner, value: /etc/issue.net} + pwquality_minlen: 14 + # pwquality strict set (l1/l2 only, cis_strict): SSG cis_server_l1 values. + pwquality_difok: 2 + pwquality_maxrepeat: 3 + pwquality_maxsequence: 3 + pwquality_minclass: 4 + pwquality_dictcheck: 1 + tmout: 900 + umask: "077" + umask_profile: "027" + faillock_deny: 5 + faillock_unlock_time: 900 + password_remember: 5 + # pwhistory remember (l1/l2 only, cis_strict): SSG wants 24 via pam_pwhistory. + pwhistory_remember: 24 + # password_expiry (l1/l2): /etc/login.defs aging. + pass_max_days: 365 + pass_min_days: 1 + pass_warn_age: 7 + # account_disable_post_pw_expiration (l1/l2): days after expiry to lock (SSG=45). + pass_inactive: 45 + # aide (l1/l2): daily integrity-check schedule. + aide_cron_hour: "5" + aide_cron_minute: "0" + # warning_banners (l1/l2): login/MOTD text. + banner_text: "Authorized access only. All activity may be monitored and reported." + # grub_password (opt-in only): a grub2 pbkdf2 hash; empty unless opted in. + grub_password_hash: "" + # insecure_packages (l1/l2 only, cis_strict): legacy cleartext clients to remove. + insecure_packages: + - telnet + +# Only the module blacklist differs by profile: l1 trims to the L1 filesystem +# modules; default/l2 keep the full list. +cis_profile_params: + default: {} + l1: + modules_blacklist: + - cramfs + - freevxfs + - jffs2 + - hfs + - hfsplus + - udf + - usb-storage + l2: {} diff --git a/roles/global_defaults/defaults/main.yml b/roles/global_defaults/defaults/main.yml index d683057..a08cb6c 100644 --- a/roles/global_defaults/defaults/main.yml +++ b/roles/global_defaults/defaults/main.yml @@ -103,6 +103,9 @@ system_defaults: features: cis: enabled: false + profile: default # default|l1|l2 (default = current house behaviour) + rules: {} # per-rule overrides, e.g. {usb_lockdown: false} + params: {} # parameter overrides, e.g. {pwquality_minlen: 16} selinux: enabled: true firewall: diff --git a/roles/global_defaults/tasks/_normalize_system.yml b/roles/global_defaults/tasks/_normalize_system.yml index 7d4e2c3..f0bc798 100644 --- a/roles/global_defaults/tasks/_normalize_system.yml +++ b/roles/global_defaults/tasks/_normalize_system.yml @@ -142,6 +142,9 @@ features: cis: enabled: "{{ system_raw.features.cis.enabled | bool }}" + profile: "{{ system_raw.features.cis.profile | default('default') | string }}" + rules: "{{ system_raw.features.cis.rules | default({}) }}" + params: "{{ system_raw.features.cis.params | default({}) }}" selinux: enabled: "{{ system_raw.features.selinux.enabled | bool }}" firewall: diff --git a/vars_example.yml b/vars_example.yml index 4be133a..3595da8 100644 --- a/vars_example.yml +++ b/vars_example.yml @@ -1,5 +1,4 @@ --- -# Example variables for virtual provisioning. custom_iso: false hypervisor: @@ -85,6 +84,9 @@ system: features: cis: enabled: false + profile: default # default|l1|l2 + rules: {} # per-rule overrides, e.g. {usb_lockdown: false} + params: {} # parameter overrides, e.g. {pwquality_minlen: 16} selinux: enabled: true firewall: