commit d777c1e97577de786a0af286bb13709a180b898b Author: Riedel Date: Wed Aug 19 09:51:43 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6688757 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.vault_pass +vault/secrets.yml +*.retry +*.pyc +__pycache__/ +.ansible/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8120804 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# Ansible Repository - Linux Server Infrastructure + +Automated provisioning and hardening of Linux servers, integrated with an +existing Active Directory environment. + +## Supported platforms + +- Rocky Linux 9 / RHEL 9 (minimal install) +- Ubuntu 22.04 / 24.04 LTS +- Debian 11 / 12 + +All roles detect `ansible_facts['os_family']` (`RedHat` / `Debian`) and +`ansible_facts['distribution']` automatically; a single inventory and +playbook run can target a mix of both families. Where the underlying +tooling differs fundamentally (SELinux vs. AppArmor, dnf-automatic vs. +unattended-upgrades), a role runs only on its matching family - see the +role table below. + +## Requirements + +```bash +ansible-galaxy collection install -r requirements.yml +``` + +Target hosts must be reachable via SSH from the Ansible control node with +an administrative sudo-capable account. + +## Getting started + +1. Update `inventories/production/hosts.ini` for your environment + (hostnames, IP addresses, internal/DMZ group membership). +2. Review and adjust `inventories/production/group_vars/all.yml` + (domain, AD groups, syslog target, admin subnet, etc.). +3. Set up secrets: + ```bash + cp vault/secrets.yml.example vault/secrets.yml + # fill in values (AD join account, CrowdStrike Falcon CID, ...) + ansible-vault encrypt vault/secrets.yml + ``` +4. Store the vault password in `.vault_pass` (chmod 600, **do not** commit + it) or wire it up to your organization's secret store. + +## Running + +```bash +# Dry run (recommended before every real run) +ansible-playbook playbooks/site.yml --check --diff + +# Full rollout +ansible-playbook playbooks/site.yml + +# Hardening roles only +ansible-playbook playbooks/site.yml --tags hardening + +# Add a new server +ansible-playbook playbooks/baseline.yml --limit + +# Targeted patch run (e.g. for scheduled execution) +ansible-playbook playbooks/patch_only.yml + +# Post-deployment sanity checks (read-only) +ansible-playbook playbooks/verify.yml +``` + +## Quality checks + +```bash +ansible-lint +ansible-playbook playbooks/site.yml --syntax-check +``` + +## Role overview + +| Role | Purpose | Platforms | +|---|---|---| +| `base_os` | Base packages, time sync, hostname, disable unneeded services | all | +| `repo_management` | Internal repos always; EPEL/CRB (RedHat) or universe/backports (Debian) only with internet access | all | +| `identity_ad` | sssd/realmd, domain join | all | +| `sudo_rbac` | sudoers.d per AD group | all | +| `local_accounts` | Break-glass account, optional local service accounts | all | +| `banners` | Pre-auth login warning banners (`/etc/issue`, `/etc/issue.net`) | all | +| `ssh_hardening` | sshd_config, public-key-only | all | +| `fail2ban` | SSH brute-force protection (requires EPEL on RedHat family) | all | +| `pam_hardening` | Password complexity (pwquality), account lockout (faillock) | all | +| `cis_hardening` | CIS-style baseline: sysctl, mounts, module blacklist, umask, TMOUT, Ctrl-Alt-Del, sticky bit, journald, auditd, GRUB password | all | +| `selinux_config` | SELinux enforcing mode, booleans, file contexts | RedHat only | +| `apparmor_config` | AppArmor enforce mode, complain-mode exceptions | Debian only | +| `firewall_config` | firewalld default-deny | all | +| `falcon_onboarding` | CrowdStrike Falcon sensor (rpm/deb) | all | +| `logging_rsyslog` | Log forwarding to SIEM, CA certificate distribution | all | +| `aide_integrity` | File integrity monitoring (AIDE) | all | +| `patch_mgmt` | dnf-automatic (RedHat) or unattended-upgrades (Debian) | all | +| `backup_agent` | Veeam agent (optional, per host) | all | + +## Open items before rollout + +- Finalize the IP scheme in `hosts.ini` (naming scheme: `-`, e.g. `GS-AP00015`). +- TLS syslog (6514) vs. plain syslog (514) - depends on the SIEM. +- Set `falcon_onboarding_sensor_package_url` or `falcon_onboarding_sensor_package_src` per host to the package matching that host's OS (rpm for RedHat family, deb for Debian family). +- Set `veeam_agent_repo_url` (RedHat) or `veeam_agent_apt_repo_line` (Debian). +- Set `syslog_ca_cert_enabled`/`syslog_ca_cert_src` once it's clear whether the SIEM certificate is signed by an internal CA (see `roles/logging_rsyslog/files/README.md`). +- Populate `custom_yum_repos`/`custom_apt_repos` with real internal repo URLs; only enable `epel_enabled`/`crb_enabled`/`ubuntu_universe_enabled`/`debian_backports_enabled` where genuinely needed. +- `fail2ban` requires EPEL on RedHat-family hosts - either enable `epel_enabled: true` or provide fail2ban via `custom_yum_repos`. +- Set `cis_grub_password_enabled` and `grub_bootloader_password_hash` (generate with `grub2-mkpasswd-pbkdf2`) if a GRUB bootloader password is desired. +- `admin_space_left_action = halt` in `cis_hardening` (auditd) is a strict setting - confirm this is acceptable, since it halts the system if the audit log volume fills up. +- `cis_boot_hardening_enabled` is off by default since it edits `/etc/fstab` for `/boot` and requires a reboot to take effect - review before enabling. +- The AIDE config-directory include mechanism and default database path/extension (`roles/aide_integrity/vars/Debian.yml`) should be verified against the actual installed `aide`/`aide-common` package version on first rollout - Debian/Ubuntu point releases have varied here. +- Coordinate AIDE database updates (`aide --update`) with the patch-management maintenance window, since scheduled patch runs will otherwise show up as AIDE findings. +- Define CIS Level 2 / AppArmor exceptions per application (see `selinux_config`, `apparmor_config`, and `firewall_config` defaults as a starting point). diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..6413cf2 --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,21 @@ +[defaults] +inventory = inventories/production/hosts.ini +roles_path = roles +remote_user = ansible_svc +host_key_checking = True +retry_files_enabled = False +interpreter_python = auto_silent +vault_password_file = .vault_pass +stdout_callback = default +result_format = yaml +forks = 10 +timeout = 30 + +[privilege_escalation] +become = True +become_method = sudo +become_ask_pass = False + +[ssh_connection] +pipelining = True +ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o PreferredAuthentications=publickey diff --git a/inventories/production/group_vars/all.yml b/inventories/production/group_vars/all.yml new file mode 100644 index 0000000..d698b02 --- /dev/null +++ b/inventories/production/group_vars/all.yml @@ -0,0 +1,156 @@ +--- +# Global variables for all managed Linux servers. +# Replace placeholder values (domain, hosts, URLs) before rollout. + +# base_os +# Base package lists and unwanted-package lists are OS-family-specific +# (see roles/base_os/vars/RedHat.yml and roles/base_os/vars/Debian.yml). +# Use base_extra_packages for additions that apply across all hosts. +timezone: "Europe/Berlin" +ntp_servers: + - "0.de.pool.ntp.org" + - "1.de.pool.ntp.org" +base_extra_packages: [] +base_unneeded_services: + - avahi-daemon + - cups + - rpcbind + - bluetooth + +# repo_management +repo_mgmt_check_internet: true +repo_mgmt_internet_check_url: "https://dl.rockylinux.org" +# RedHat family +epel_enabled: false +epel_major_version: "9" +crb_enabled: true +custom_yum_repos: [] +# custom_yum_repos: +# - name: "internal" +# description: "Internal repository" +# baseurl: "https://repo.example.corp/rocky9/internal/" +# gpgcheck: true +# gpgkey: "https://repo.example.corp/RPM-GPG-KEY-internal" +# Debian family +ubuntu_universe_enabled: false +debian_backports_enabled: false +custom_apt_repos: [] +# custom_apt_repos: +# - name: "internal" +# uris: "https://repo.example.corp/ubuntu" +# suites: "{{ ansible_facts['distribution_release'] }}" +# components: ["main"] +# signed_by: "https://repo.example.corp/gpg" + +# identity_ad +ad_domain: "example.corp" +ad_domain_realm: "EXAMPLE.CORP" +ad_ou: "OU=LinuxServers,DC=example,DC=corp" +ad_admin_group: "GG_Linux_Admins" +ad_operator_group: "GG_Linux_Operators" +sssd_use_fully_qualified_names: false + +# sudo_rbac +sudo_rbac_rules: + - group: "{{ ad_admin_group }}" + commands: "ALL" + nopasswd: false + - group: "{{ ad_operator_group }}" + commands: "/usr/bin/systemctl restart *, /usr/bin/systemctl status *, /usr/bin/journalctl *" + nopasswd: false + +# local_accounts +breakglass_username: "bglocal_admin" +breakglass_comment: "Local emergency (break-glass) account" +local_service_accounts: [] + +# ssh_hardening +ssh_allow_groups: "linux_admins linux_operators" +ssh_port: 22 +ssh_max_auth_tries: 3 +ssh_client_alive_interval: 300 +ssh_client_alive_count_max: 2 + +# pam_hardening +pam_pwquality_minlen: 14 +pam_pwquality_dcredit: -1 +pam_pwquality_ucredit: -1 +pam_pwquality_lcredit: -1 +pam_pwquality_ocredit: -1 +pam_pwquality_retry: 3 +pam_faillock_deny: 5 +pam_faillock_unlock_time: 900 + +# fail2ban +fail2ban_bantime: 3600 +fail2ban_findtime: 600 +fail2ban_maxretry: 5 +fail2ban_ignoreip: "127.0.0.1/8 ::1 {{ firewall_admin_subnet }}" + +# banners +banner_enabled: true + +# cis_hardening +cis_level: 1 # raised to 2 in group_vars/dmz.yml +cis_disable_filesystems: + - cramfs + - freevxfs + - jffs2 + - hfs + - hfsplus + - udf +cis_disable_modules_level2: + - usb-storage + - bluetooth + - firewire-core +cis_shell_tmout_seconds: 900 +cis_umask: "027" +cis_disable_ctrl_alt_del: true +cis_sticky_bit_enforce: true +cis_journald_persistent: true +cis_journald_max_use: "500M" +cis_boot_hardening_enabled: false # requires reboot to take effect, verify manually first +cis_unowned_files_report_enabled: true +cis_unowned_files_report_cron_hour: 4 +cis_grub_password_enabled: false +grub_bootloader_username: "grubadmin" +grub_bootloader_is_uefi: false + +# selinux_config +selinux_state: enforcing +selinux_policy: targeted + +# firewall_config +firewall_default_zone: "drop" +firewall_admin_subnet: "10.10.5.0/24" +firewall_allowed_services: [] +firewall_log_denied: "unicast" + +# falcon_onboarding +falcon_onboarding_cloud_region: "eu-1" +# falcon_onboarding_sensor_package_url / falcon_onboarding_sensor_package_src +# and falcon_cid are set per environment / in vault/secrets.yml + +# logging_rsyslog +syslog_collector_host: "siem.example.corp" +syslog_collector_port: 6514 +syslog_use_tls: true +syslog_ca_cert_enabled: false +syslog_ca_cert_src: "siem-ca.crt" # relative to roles/logging_rsyslog/files/ + +# aide_integrity +aide_check_cron_hour: 5 +aide_check_cron_minute: 0 +aide_notify_email: "root" + +# patch_mgmt +dnf_automatic_apply_security: true +dnf_automatic_apply_other: false +patch_reboot_window_hosts: [] +patch_reboot_day: "sun" +patch_reboot_time: "03:00" + +# backup_agent +veeam_agent_enabled: false +# veeam_agent_repo_url (RedHat family) / veeam_agent_apt_repo_line (Debian family) +# are set per environment, see roles/backup_agent/defaults/main.yml diff --git a/inventories/production/group_vars/dmz.yml b/inventories/production/group_vars/dmz.yml new file mode 100644 index 0000000..fd3fdca --- /dev/null +++ b/inventories/production/group_vars/dmz.yml @@ -0,0 +1,12 @@ +--- +# Overrides for the "dmz" group (see hosts.ini). + +cis_level: 2 + +firewall_allowed_services: + - service: "https" + source: "any" + - service: "ssh" + source: "{{ firewall_admin_subnet }}" + +firewall_default_zone: "drop" diff --git a/inventories/production/hosts.ini b/inventories/production/hosts.ini new file mode 100644 index 0000000..67a828c --- /dev/null +++ b/inventories/production/hosts.ini @@ -0,0 +1,25 @@ +; Example inventory - update hostnames/IPs before first run. +; Naming scheme: -<5-digit-number>, e.g. GS-AP00015. +; Groups may contain a mix of Rocky/RHEL and Ubuntu/Debian hosts - all roles +; detect the OS family automatically (ansible_facts['os_family']). + +[linux_internal] +GS-AP00021 ansible_host=10.10.20.11 +GS-AP00022 ansible_host=10.10.20.12 + +[linux_dmz] +GS-AP00031 ansible_host=10.10.99.11 +GS-AP00032 ansible_host=10.10.99.12 + +; Group "dmz" is used by group_vars/dmz.yml (CIS Level 2, stricter firewall) +[dmz:children] +linux_dmz + +; Umbrella group for all managed Linux servers +[linux_all:children] +linux_internal +linux_dmz + +[linux_all:vars] +ansible_user=ansible_svc +ansible_python_interpreter=/usr/bin/python3 diff --git a/playbooks/baseline.yml b/playbooks/baseline.yml new file mode 100644 index 0000000..2f79329 --- /dev/null +++ b/playbooks/baseline.yml @@ -0,0 +1,33 @@ +--- +# Onboarding process for a newly added server. +# Run scoped to exactly the new host: +# ansible-playbook playbooks/baseline.yml --limit GS-AP00023 --ask-vault-pass +# +# Prerequisite: the host has already been added to +# inventories/production/hosts.ini (and group_vars/host_vars as needed). + +- name: Baseline rollout for a newly added Linux server + hosts: "{{ target_host | default('linux_all') }}" + become: true + vars_files: + - ../vault/secrets.yml + roles: + - role: base_os + - role: repo_management + - role: identity_ad + - role: sudo_rbac + - role: local_accounts + - role: banners + - role: ssh_hardening + - role: fail2ban + - role: pam_hardening + - role: cis_hardening + - role: selinux_config + when: ansible_facts['os_family'] == "RedHat" + - role: apparmor_config + when: ansible_facts['os_family'] == "Debian" + - role: firewall_config + - role: falcon_onboarding + - role: logging_rsyslog + - role: aide_integrity + - role: patch_mgmt diff --git a/playbooks/patch_only.yml b/playbooks/patch_only.yml new file mode 100644 index 0000000..075209a --- /dev/null +++ b/playbooks/patch_only.yml @@ -0,0 +1,10 @@ +--- +# Targeted patch-management run without re-applying the other roles. +# Intended for scheduled execution (cron/AWX). +# Run: ansible-playbook playbooks/patch_only.yml + +- name: Apply patch management configuration only + hosts: linux_all + become: true + roles: + - role: patch_mgmt diff --git a/playbooks/site.yml b/playbooks/site.yml new file mode 100644 index 0000000..fc3a6ad --- /dev/null +++ b/playbooks/site.yml @@ -0,0 +1,51 @@ +--- +# Full rollout of all roles. +# Full run: ansible-playbook playbooks/site.yml --ask-vault-pass +# Dry run: ansible-playbook playbooks/site.yml --check --diff +# Subset only: ansible-playbook playbooks/site.yml --tags hardening + +- name: Deploy hardened Linux baseline configuration + hosts: linux_all + become: true + vars_files: + - ../vault/secrets.yml + roles: + - role: base_os + tags: [base_os] + - role: repo_management + tags: [repo_management] + - role: identity_ad + tags: [identity_ad, hardening] + - role: sudo_rbac + tags: [sudo_rbac, hardening] + - role: local_accounts + tags: [local_accounts, hardening] + - role: banners + tags: [banners, hardening] + - role: ssh_hardening + tags: [ssh_hardening, hardening] + - role: fail2ban + tags: [fail2ban, hardening] + - role: pam_hardening + tags: [pam_hardening, hardening] + - role: cis_hardening + tags: [cis_hardening, hardening] + - role: selinux_config + tags: [selinux_config, hardening] + when: ansible_facts['os_family'] == "RedHat" + - role: apparmor_config + tags: [apparmor_config, hardening] + when: ansible_facts['os_family'] == "Debian" + - role: firewall_config + tags: [firewall_config, hardening] + - role: falcon_onboarding + tags: [falcon_onboarding, hardening] + - role: logging_rsyslog + tags: [logging_rsyslog, monitoring] + - role: aide_integrity + tags: [aide_integrity, monitoring] + - role: patch_mgmt + tags: [patch_mgmt, patching] + - role: backup_agent + tags: [backup_agent] + when: veeam_agent_enabled | default(false) | bool diff --git a/playbooks/verify.yml b/playbooks/verify.yml new file mode 100644 index 0000000..903ba5b --- /dev/null +++ b/playbooks/verify.yml @@ -0,0 +1,78 @@ +--- +# Post-deployment sanity checks. Read-only, makes no changes. +# Run: ansible-playbook playbooks/verify.yml + +- name: Verify baseline hardening status + hosts: linux_all + become: true + gather_facts: true + tasks: + - name: Collect service facts + ansible.builtin.service_facts: + + - name: Check expected services are running + ansible.builtin.assert: + that: + - "'sshd.service' in ansible_facts.services" + - "ansible_facts.services['sshd.service'].state == 'running'" + - "'firewalld.service' in ansible_facts.services" + - "ansible_facts.services['firewalld.service'].state == 'running'" + - "'auditd.service' in ansible_facts.services" + - "ansible_facts.services['auditd.service'].state == 'running'" + fail_msg: "One or more expected core services are not running." + success_msg: "Core services (sshd, firewalld, auditd) are running." + + - name: Check SELinux is enforcing (RedHat family) + ansible.builtin.command: getenforce + register: verify_selinux_status + changed_when: false + when: ansible_facts['os_family'] == "RedHat" + + - name: Assert SELinux enforcing (RedHat family) + ansible.builtin.assert: + that: + - "verify_selinux_status.stdout == 'Enforcing'" + fail_msg: "SELinux is not in enforcing mode." + success_msg: "SELinux is enforcing." + when: ansible_facts['os_family'] == "RedHat" + + - name: Check AppArmor status (Debian family) + ansible.builtin.command: aa-status --enforced + register: verify_apparmor_status + changed_when: false + failed_when: false + when: ansible_facts['os_family'] == "Debian" + + - name: Report AppArmor enforced profile count (Debian family) + ansible.builtin.debug: + msg: "AppArmor enforced profiles on {{ inventory_hostname }}: {{ verify_apparmor_status.stdout | default('unavailable') }}" + when: ansible_facts['os_family'] == "Debian" + + - name: Check CrowdStrike Falcon sensor health + ansible.builtin.command: /opt/CrowdStrike/falconctl -g --rfm-state + register: verify_falcon_rfm + changed_when: false + failed_when: false + + - name: Report Falcon sensor status + ansible.builtin.debug: + msg: "Falcon sensor RFM state on {{ inventory_hostname }}: {{ verify_falcon_rfm.stdout | default('not installed / not reachable') }}" + + - name: Check AIDE database exists (RedHat family) + ansible.builtin.stat: + path: /var/lib/aide/aide.db.gz + register: verify_aide_db_redhat + when: ansible_facts['os_family'] == "RedHat" + + - name: Check AIDE database exists (Debian family) + ansible.builtin.stat: + path: /var/lib/aide/aide.db + register: verify_aide_db_debian + when: ansible_facts['os_family'] == "Debian" + + - name: Assert AIDE database is present + ansible.builtin.assert: + that: + - (verify_aide_db_redhat.stat.exists | default(false)) or (verify_aide_db_debian.stat.exists | default(false)) + fail_msg: "AIDE database has not been initialized." + success_msg: "AIDE database is present." diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 0000000..637721a --- /dev/null +++ b/requirements.yml @@ -0,0 +1,7 @@ +--- +# Install with: ansible-galaxy collection install -r requirements.yml +collections: + - name: ansible.posix + version: ">=1.5.0" + - name: community.general + version: ">=8.0.0" diff --git a/roles/aide_integrity/tasks/main.yml b/roles/aide_integrity/tasks/main.yml new file mode 100644 index 0000000..981f3a0 --- /dev/null +++ b/roles/aide_integrity/tasks/main.yml @@ -0,0 +1,59 @@ +--- +- name: Include OS-family-specific variables + ansible.builtin.include_vars: "{{ item }}" + with_first_found: + - "{{ ansible_facts['os_family'] }}.yml" + - "default.yml" + tags: [aide_integrity, always] + +- name: Install AIDE + ansible.builtin.package: + name: aide + state: present + tags: [aide_integrity] + +- name: Deploy custom AIDE configuration (additional watched paths) + ansible.builtin.template: + src: aide-custom.conf.j2 + dest: "{{ aide_integrity_config_dest }}" + owner: root + group: root + mode: "0640" + tags: [aide_integrity] + +- name: Check whether an AIDE database already exists + ansible.builtin.stat: + path: "{{ aide_integrity_db_path }}" + register: aide_integrity_db_stat + tags: [aide_integrity] + +- name: Initialize AIDE database (first run only) + ansible.builtin.command: aide --init + when: not aide_integrity_db_stat.stat.exists + changed_when: true + tags: [aide_integrity] + +- name: Activate the initial AIDE database + ansible.builtin.command: "mv {{ aide_integrity_db_new_path }} {{ aide_integrity_db_path }}" + when: not aide_integrity_db_stat.stat.exists + changed_when: true + tags: [aide_integrity] + +- name: Schedule regular AIDE check via cron + ansible.builtin.cron: + name: "Daily AIDE integrity check" + hour: "{{ aide_check_cron_hour }}" + minute: "{{ aide_check_cron_minute }}" + job: >- + {{ aide_integrity_check_cmd }} | /usr/bin/mail -s + "AIDE integrity report {{ inventory_hostname }}" {{ aide_notify_email }} + user: root + tags: [aide_integrity] + +- name: Note on updating the database after legitimate changes + ansible.builtin.debug: + msg: >- + After intended system changes (patches, config changes made outside + Ansible), update the AIDE database manually: + aide --update && mv {{ aide_integrity_db_new_path }} {{ aide_integrity_db_path }} + tags: [aide_integrity] diff --git a/roles/aide_integrity/templates/aide-custom.conf.j2 b/roles/aide_integrity/templates/aide-custom.conf.j2 new file mode 100644 index 0000000..6e8bf33 --- /dev/null +++ b/roles/aide_integrity/templates/aide-custom.conf.j2 @@ -0,0 +1,12 @@ +# {{ ansible_managed }} +/etc/ssh/sshd_config FIPSR +/etc/sudoers.d FIPSR +/etc/security/pwquality.conf FIPSR +/etc/security/faillock.conf FIPSR +/etc/audit/rules.d FIPSR +/etc/firewalld FIPSR + +!/var/log +!/var/cache +!/tmp +!/var/tmp diff --git a/roles/aide_integrity/vars/Debian.yml b/roles/aide_integrity/vars/Debian.yml new file mode 100644 index 0000000..404dfa1 --- /dev/null +++ b/roles/aide_integrity/vars/Debian.yml @@ -0,0 +1,5 @@ +--- +aide_integrity_config_dest: /etc/aide/aide.conf.d/99-custom.conf +aide_integrity_db_path: /var/lib/aide/aide.db +aide_integrity_db_new_path: /var/lib/aide/aide.db.new +aide_integrity_check_cmd: /usr/bin/aide.wrapper --check diff --git a/roles/aide_integrity/vars/RedHat.yml b/roles/aide_integrity/vars/RedHat.yml new file mode 100644 index 0000000..9d60ed4 --- /dev/null +++ b/roles/aide_integrity/vars/RedHat.yml @@ -0,0 +1,5 @@ +--- +aide_integrity_config_dest: /etc/aide.conf.d/99-custom.conf +aide_integrity_db_path: /var/lib/aide/aide.db.gz +aide_integrity_db_new_path: /var/lib/aide/aide.db.new.gz +aide_integrity_check_cmd: /usr/sbin/aide --check diff --git a/roles/apparmor_config/defaults/main.yml b/roles/apparmor_config/defaults/main.yml new file mode 100644 index 0000000..6cd8566 --- /dev/null +++ b/roles/apparmor_config/defaults/main.yml @@ -0,0 +1,4 @@ +--- +apparmor_config_complain_profiles: [] +# apparmor_config_complain_profiles: +# - /usr/sbin/nginx diff --git a/roles/apparmor_config/tasks/main.yml b/roles/apparmor_config/tasks/main.yml new file mode 100644 index 0000000..39bc5cf --- /dev/null +++ b/roles/apparmor_config/tasks/main.yml @@ -0,0 +1,42 @@ +--- +- name: Install AppArmor packages + ansible.builtin.package: + name: + - apparmor + - apparmor-utils + - apparmor-profiles + state: present + tags: [apparmor_config] + +- name: Enable and start AppArmor + ansible.builtin.systemd: + name: apparmor + enabled: true + state: started + tags: [apparmor_config] + +- name: Set enforce mode for all loaded profiles + ansible.builtin.shell: aa-enforce /etc/apparmor.d/* + register: apparmor_config_enforce_result + changed_when: "'Setting' in apparmor_config_enforce_result.stdout" + failed_when: false + tags: [apparmor_config] + +- name: Set application-specific profiles to complain mode (exceptions) + ansible.builtin.command: "aa-complain {{ item }}" + loop: "{{ apparmor_config_complain_profiles }}" + when: apparmor_config_complain_profiles | length > 0 + changed_when: true + tags: [apparmor_config] + +- name: Query AppArmor status + ansible.builtin.command: aa-status --verbose + register: apparmor_config_status + changed_when: false + failed_when: false + tags: [apparmor_config] + +- name: Report AppArmor status + ansible.builtin.debug: + msg: "AppArmor status on {{ inventory_hostname }}: {{ apparmor_config_status.stdout_lines[0] | default('unavailable') }}" + tags: [apparmor_config] diff --git a/roles/backup_agent/defaults/main.yml b/roles/backup_agent/defaults/main.yml new file mode 100644 index 0000000..000eb52 --- /dev/null +++ b/roles/backup_agent/defaults/main.yml @@ -0,0 +1,6 @@ +--- +# veeam_agent_repo_url: "https://repo.example.corp/veeam/veeam.repo" +# veeam_agent_apt_uri: "https://repo.example.corp/veeam/deb" +# veeam_agent_apt_suite: "stable" +# veeam_agent_apt_components: "main" +# veeam_agent_apt_signed_by: "https://repo.example.corp/veeam/gpg" diff --git a/roles/backup_agent/tasks/main.yml b/roles/backup_agent/tasks/main.yml new file mode 100644 index 0000000..5b6c836 --- /dev/null +++ b/roles/backup_agent/tasks/main.yml @@ -0,0 +1,48 @@ +--- +- name: Report that backup_agent is disabled for this host + ansible.builtin.debug: + msg: "veeam_agent_enabled is false - skipping backup_agent role for {{ inventory_hostname }}." + when: not veeam_agent_enabled | bool + tags: [backup_agent] + +- name: Configure Veeam repository (RedHat family, customer-specific URL required) + ansible.builtin.get_url: + url: "{{ veeam_agent_repo_url }}" + dest: /etc/yum.repos.d/veeam.repo + owner: root + group: root + mode: "0644" + when: + - veeam_agent_enabled | bool + - ansible_facts['os_family'] == "RedHat" + - veeam_agent_repo_url is defined + tags: [backup_agent] + +- name: Configure Veeam repository (Debian family, customer-specific values required) + ansible.builtin.deb822_repository: + name: veeam + types: deb + uris: "{{ veeam_agent_apt_uri }}" + suites: "{{ veeam_agent_apt_suite }}" + components: "{{ veeam_agent_apt_components }}" + signed_by: "{{ veeam_agent_apt_signed_by | default(omit) }}" + when: + - veeam_agent_enabled | bool + - ansible_facts['os_family'] == "Debian" + - veeam_agent_apt_uri is defined + tags: [backup_agent] + +- name: Install Veeam agent + ansible.builtin.package: + name: veeam + state: present + when: veeam_agent_enabled | bool + tags: [backup_agent] + +- name: Enable and start Veeam agent service + ansible.builtin.systemd: + name: veeamservice + enabled: true + state: started + when: veeam_agent_enabled | bool + tags: [backup_agent] diff --git a/roles/banners/handlers/main.yml b/roles/banners/handlers/main.yml new file mode 100644 index 0000000..707b4ad --- /dev/null +++ b/roles/banners/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart sshd (banners) + ansible.builtin.systemd: + name: sshd + state: restarted diff --git a/roles/banners/tasks/main.yml b/roles/banners/tasks/main.yml new file mode 100644 index 0000000..358cae7 --- /dev/null +++ b/roles/banners/tasks/main.yml @@ -0,0 +1,21 @@ +--- +- name: Deploy local console login banner + ansible.builtin.template: + src: issue.j2 + dest: /etc/issue + owner: root + group: root + mode: "0644" + when: banner_enabled | bool + tags: [banners] + +- name: Deploy network (pre-auth SSH) login banner + ansible.builtin.template: + src: issue.net.j2 + dest: /etc/issue.net + owner: root + group: root + mode: "0644" + when: banner_enabled | bool + notify: restart sshd (banners) + tags: [banners] diff --git a/roles/banners/templates/issue.j2 b/roles/banners/templates/issue.j2 new file mode 100644 index 0000000..153bb32 --- /dev/null +++ b/roles/banners/templates/issue.j2 @@ -0,0 +1,5 @@ +****************************************************************** + AUTHORIZED ACCESS ONLY. + All activity on this system is logged and monitored. + Unauthorized access is prohibited and may be prosecuted. +****************************************************************** diff --git a/roles/banners/templates/issue.net.j2 b/roles/banners/templates/issue.net.j2 new file mode 100644 index 0000000..153bb32 --- /dev/null +++ b/roles/banners/templates/issue.net.j2 @@ -0,0 +1,5 @@ +****************************************************************** + AUTHORIZED ACCESS ONLY. + All activity on this system is logged and monitored. + Unauthorized access is prohibited and may be prosecuted. +****************************************************************** diff --git a/roles/base_os/handlers/main.yml b/roles/base_os/handlers/main.yml new file mode 100644 index 0000000..a46e884 --- /dev/null +++ b/roles/base_os/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart chrony + ansible.builtin.systemd: + name: "{{ base_os_chrony_service_name }}" + state: restarted diff --git a/roles/base_os/tasks/main.yml b/roles/base_os/tasks/main.yml new file mode 100644 index 0000000..19c1a21 --- /dev/null +++ b/roles/base_os/tasks/main.yml @@ -0,0 +1,75 @@ +--- +- name: Include OS-family-specific variables + ansible.builtin.include_vars: "{{ item }}" + with_first_found: + - "{{ ansible_facts['os_family'] }}.yml" + - "default.yml" + tags: [base_os, always] + +- name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + when: ansible_facts['os_family'] == "Debian" + tags: [base_os] + +- name: Install base packages + ansible.builtin.package: + name: "{{ base_os_packages + (base_extra_packages | default([])) }}" + state: present + tags: [base_os] + +- name: Set timezone + community.general.timezone: + name: "{{ timezone }}" + tags: [base_os] + +- name: Deploy chrony configuration + ansible.builtin.template: + src: chrony.conf.j2 + dest: "{{ base_os_chrony_config_path }}" + owner: root + group: root + mode: "0644" + notify: restart chrony + tags: [base_os] + +- name: Enable and start chrony + ansible.builtin.systemd: + name: "{{ base_os_chrony_service_name }}" + enabled: true + state: started + tags: [base_os] + +- name: Set hostname from inventory + ansible.builtin.hostname: + name: "{{ inventory_hostname }}" + tags: [base_os] + +- name: Remove unwanted packages + ansible.builtin.package: + name: "{{ base_os_unwanted_packages }}" + state: absent + tags: [base_os, cis_hardening] + +- name: Gather service facts + ansible.builtin.service_facts: + tags: [base_os] + +- name: Disable unneeded services if installed + ansible.builtin.systemd: + name: "{{ item }}" + enabled: false + state: stopped + loop: "{{ base_unneeded_services }}" + when: (item + '.service') in ansible_facts.services + tags: [base_os] + +- name: Deploy post-login MOTD + ansible.builtin.template: + src: motd.j2 + dest: /etc/motd + owner: root + group: root + mode: "0644" + tags: [base_os] diff --git a/roles/base_os/templates/chrony.conf.j2 b/roles/base_os/templates/chrony.conf.j2 new file mode 100644 index 0000000..4cf39c2 --- /dev/null +++ b/roles/base_os/templates/chrony.conf.j2 @@ -0,0 +1,9 @@ +# {{ ansible_managed }} +{% for server in ntp_servers %} +server {{ server }} iburst +{% endfor %} + +driftfile /var/lib/chrony/drift +makestep 1.0 3 +rtcsync +logdir /var/log/chrony diff --git a/roles/base_os/templates/motd.j2 b/roles/base_os/templates/motd.j2 new file mode 100644 index 0000000..002d336 --- /dev/null +++ b/roles/base_os/templates/motd.j2 @@ -0,0 +1,6 @@ +****************************************************************** + AUTHORIZED ACCESS ONLY. + All activity on this system is logged and monitored. + Unauthorized access is prohibited and may be prosecuted. + Managed via Ansible ({{ inventory_hostname }}). +****************************************************************** diff --git a/roles/base_os/vars/Debian.yml b/roles/base_os/vars/Debian.yml new file mode 100644 index 0000000..8579fc9 --- /dev/null +++ b/roles/base_os/vars/Debian.yml @@ -0,0 +1,17 @@ +--- +base_os_packages: + - vim + - chrony + - auditd + - firewalld + - rsyslog + - sudo +base_os_unwanted_packages: + - sendmail + - telnet + - rsh-client + - rsh-redone-client + - nis + - tftp +base_os_chrony_config_path: /etc/chrony/chrony.conf +base_os_chrony_service_name: chrony diff --git a/roles/base_os/vars/RedHat.yml b/roles/base_os/vars/RedHat.yml new file mode 100644 index 0000000..7778a8e --- /dev/null +++ b/roles/base_os/vars/RedHat.yml @@ -0,0 +1,17 @@ +--- +base_os_packages: + - vim-enhanced + - chrony + - audit + - policycoreutils-python-utils + - firewalld + - rsyslog + - sudo +base_os_unwanted_packages: + - sendmail + - telnet + - rsh + - ypbind + - tftp +base_os_chrony_config_path: /etc/chrony.conf +base_os_chrony_service_name: chronyd diff --git a/roles/cis_hardening/handlers/main.yml b/roles/cis_hardening/handlers/main.yml new file mode 100644 index 0000000..909d176 --- /dev/null +++ b/roles/cis_hardening/handlers/main.yml @@ -0,0 +1,20 @@ +--- +- name: reload auditd rules + ansible.builtin.command: augenrules --load + changed_when: true + +- name: restart auditd + ansible.builtin.systemd: + name: auditd + state: restarted + +- name: restart systemd-journald + ansible.builtin.systemd: + name: systemd-journald + state: restarted + +- name: regenerate grub config + ansible.builtin.command: >- + {{ cis_hardening_grub_mkconfig_cmd }} + {{ ('-o ' + (cis_hardening_grub_cfg_path_uefi if grub_bootloader_is_uefi | default(false) else cis_hardening_grub_cfg_path_bios)) if (cis_hardening_grub_cfg_path_bios | default('')) | length > 0 else '' }} + changed_when: true diff --git a/roles/cis_hardening/tasks/main.yml b/roles/cis_hardening/tasks/main.yml new file mode 100644 index 0000000..8292ffc --- /dev/null +++ b/roles/cis_hardening/tasks/main.yml @@ -0,0 +1,255 @@ +--- +- name: Include OS-family-specific variables + ansible.builtin.include_vars: "{{ item }}" + with_first_found: + - "{{ ansible_facts['os_family'] }}.yml" + - "default.yml" + tags: [cis_hardening, always] + +- name: Disable unneeded filesystem kernel modules + ansible.builtin.copy: + dest: /etc/modprobe.d/cis-disable-filesystems.conf + owner: root + group: root + mode: "0644" + content: | + # {{ ansible_managed }} + {% for fs in cis_disable_filesystems %} + install {{ fs }} /bin/true + {% endfor %} + tags: [cis_hardening] + +- name: Set kernel/network sysctl hardening parameters + ansible.posix.sysctl: + name: "{{ item.name }}" + value: "{{ item.value }}" + sysctl_set: true + state: present + reload: true + loop: + - { name: "net.ipv4.ip_forward", value: "0" } + - { name: "net.ipv4.conf.all.send_redirects", value: "0" } + - { name: "net.ipv4.conf.all.accept_redirects", value: "0" } + - { name: "net.ipv4.conf.all.accept_source_route", value: "0" } + - { name: "net.ipv4.conf.all.log_martians", value: "1" } + - { name: "net.ipv4.icmp_echo_ignore_broadcasts", value: "1" } + - { name: "kernel.randomize_va_space", value: "2" } + - { name: "fs.suid_dumpable", value: "0" } + tags: [cis_hardening] + +- name: Enable strict reverse-path filtering (CIS Level 2) + ansible.posix.sysctl: + name: "net.ipv4.conf.all.rp_filter" + value: "1" + sysctl_set: true + state: present + reload: true + when: cis_level | int >= 2 + tags: [cis_hardening] + +- name: Harden /tmp mount options (noexec,nosuid,nodev) + ansible.posix.mount: + path: /tmp + src: tmpfs + fstype: tmpfs + opts: "defaults,rw,nosuid,nodev,noexec,relatime" + state: mounted + tags: [cis_hardening] + +- name: Bind and harden /var/tmp (CIS Level 2) + ansible.posix.mount: + path: /var/tmp + src: /tmp + fstype: none + opts: "bind,nosuid,nodev,noexec" + state: mounted + when: cis_level | int >= 2 + tags: [cis_hardening] + +- name: Disable additional kernel modules (CIS Level 2) + ansible.builtin.copy: + dest: /etc/modprobe.d/cis-disable-level2-modules.conf + owner: root + group: root + mode: "0644" + content: | + # {{ ansible_managed }} + {% for mod in cis_disable_modules_level2 %} + install {{ mod }} /bin/true + {% endfor %} + when: cis_level | int >= 2 + tags: [cis_hardening] + +- name: Disable core dumps + ansible.builtin.copy: + dest: /etc/security/limits.d/99-cis-disable-coredumps.conf + owner: root + group: root + mode: "0644" + content: | + # {{ ansible_managed }} + * hard core 0 + tags: [cis_hardening] + +- name: Enforce default umask + ansible.builtin.copy: + dest: /etc/profile.d/99-cis-umask.sh + owner: root + group: root + mode: "0644" + content: | + # {{ ansible_managed }} + umask {{ cis_umask }} + tags: [cis_hardening] + +- name: Set shell auto-logout timeout (TMOUT) + ansible.builtin.copy: + dest: /etc/profile.d/99-cis-tmout.sh + owner: root + group: root + mode: "0644" + content: | + # {{ ansible_managed }} + TMOUT={{ cis_shell_tmout_seconds }} + readonly TMOUT + export TMOUT + tags: [cis_hardening] + +- name: Mask Ctrl-Alt-Del reboot target + ansible.builtin.systemd: + name: ctrl-alt-del.target + masked: true + when: cis_disable_ctrl_alt_del | bool + tags: [cis_hardening] + +- name: Find world-writable directories missing the sticky bit + ansible.builtin.shell: > + set -o pipefail; + df --local -P | awk '{if (NR!=1) print $6}' | + xargs -I '{}' find '{}' -xdev -type d \( -perm -0002 -a ! -perm -1000 \) 2>/dev/null + register: cis_hardening_sticky_bit_dirs + changed_when: false + when: cis_sticky_bit_enforce | bool + tags: [cis_hardening] + +- name: Apply sticky bit to world-writable directories + ansible.builtin.file: + path: "{{ item }}" + mode: "a+t" + loop: "{{ cis_hardening_sticky_bit_dirs.stdout_lines | default([]) }}" + when: cis_sticky_bit_enforce | bool + tags: [cis_hardening] + +- name: Configure journald persistent storage and size cap + ansible.builtin.lineinfile: + path: /etc/systemd/journald.conf + regexp: "^#?{{ item.key }}=" + line: "{{ item.key }}={{ item.value }}" + loop: + - { key: "Storage", value: "persistent" } + - { key: "SystemMaxUse", value: "{{ cis_journald_max_use }}" } + when: cis_journald_persistent | bool + notify: restart systemd-journald + tags: [cis_hardening] + +- name: Schedule weekly report of unowned/ungrouped files + ansible.builtin.cron: + name: "Weekly unowned files report" + weekday: "0" + hour: "{{ cis_unowned_files_report_cron_hour }}" + minute: "0" + job: >- + /usr/bin/find / -xdev \( -nouser -o -nogroup \) 2>/dev/null | + /usr/bin/mail -s "Unowned files report {{ inventory_hostname }}" root + user: root + when: cis_unowned_files_report_enabled | bool + tags: [cis_hardening] + +- name: Harden /boot mount options in fstab (requires manual review and reboot) + ansible.builtin.replace: + path: /etc/fstab + regexp: '^(\S+\s+/boot\s+\S+\s+)(?!.*nodev)(\S+)(\s+.*)$' + replace: '\1\2,nodev,nosuid\3' + when: cis_boot_hardening_enabled | bool + tags: [cis_hardening] + +- name: Deploy baseline auditd rules (logins, time changes, account changes) + ansible.builtin.template: + src: audit-baseline.rules.j2 + dest: /etc/audit/rules.d/10-baseline.rules + owner: root + group: root + mode: "0640" + notify: reload auditd rules + tags: [cis_hardening] + +- name: Deploy extended auditd rules (CIS Level 2) + ansible.builtin.template: + src: audit-level2.rules.j2 + dest: /etc/audit/rules.d/20-level2.rules + owner: root + group: root + mode: "0640" + when: cis_level | int >= 2 + notify: reload auditd rules + tags: [cis_hardening] + +- name: Configure auditd log rotation and disk-space handling + ansible.builtin.lineinfile: + path: /etc/audit/auditd.conf + regexp: "^{{ item.key }}\\s*=" + line: "{{ item.key }} = {{ item.value }}" + loop: + - { key: "max_log_file", value: "50" } + - { key: "max_log_file_action", value: "rotate" } + - { key: "num_logs", value: "10" } + - { key: "space_left", value: "10%" } + - { key: "space_left_action", value: "email" } + - { key: "admin_space_left", value: "5%" } + - { key: "admin_space_left_action", value: "halt" } + notify: restart auditd + tags: [cis_hardening] + +- name: Set login.defs password aging policy + ansible.builtin.lineinfile: + path: /etc/login.defs + regexp: "^{{ item.key }}" + line: "{{ item.key }}\t{{ item.value }}" + loop: + - { key: "PASS_MAX_DAYS", value: "90" } + - { key: "PASS_MIN_DAYS", value: "1" } + - { key: "PASS_WARN_AGE", value: "7" } + tags: [cis_hardening] + +- name: Restrict cron/at to authorized users + ansible.builtin.file: + path: "{{ item }}" + state: touch + owner: root + group: root + mode: "0600" + loop: + - /etc/cron.allow + - /etc/at.allow + tags: [cis_hardening] + +- name: Configure GRUB bootloader password (pre-generated PBKDF2 hash) + ansible.builtin.blockinfile: + path: /etc/grub.d/40_custom + marker: "# {mark} ANSIBLE MANAGED BLOCK" + insertafter: "EOF" + block: | + set superusers="{{ grub_bootloader_username }}" + password_pbkdf2 {{ grub_bootloader_username }} {{ grub_bootloader_password_hash }} + when: + - cis_grub_password_enabled | bool + - grub_bootloader_password_hash | length > 0 + notify: regenerate grub config + tags: [cis_hardening] + +- name: Enable and start auditd + ansible.builtin.systemd: + name: auditd + enabled: true + state: started + tags: [cis_hardening] diff --git a/roles/cis_hardening/templates/audit-baseline.rules.j2 b/roles/cis_hardening/templates/audit-baseline.rules.j2 new file mode 100644 index 0000000..3866f82 --- /dev/null +++ b/roles/cis_hardening/templates/audit-baseline.rules.j2 @@ -0,0 +1,19 @@ +# {{ ansible_managed }} +-D +-b 8192 + +-w /var/log/lastlog -p wa -k logins +-w /var/run/faillock -p wa -k logins + +-a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time-change +-w /etc/localtime -p wa -k time-change + +-w /etc/passwd -p wa -k identity +-w /etc/group -p wa -k identity +-w /etc/shadow -p wa -k identity +-w /etc/sudoers -p wa -k identity +-w /etc/sudoers.d/ -p wa -k identity + +-w /home/{{ breakglass_username }} -p rwxa -k breakglass-usage + +-e 2 diff --git a/roles/cis_hardening/templates/audit-level2.rules.j2 b/roles/cis_hardening/templates/audit-level2.rules.j2 new file mode 100644 index 0000000..45406e6 --- /dev/null +++ b/roles/cis_hardening/templates/audit-level2.rules.j2 @@ -0,0 +1,9 @@ +# {{ ansible_managed }} +-a always,exit -F arch=b64 -S open,truncate,ftruncate,creat,openat -F exit=-EACCES -F auid>=1000 -F auid!=unset -k access-denied +-a always,exit -F arch=b64 -S open,truncate,ftruncate,creat,openat -F exit=-EPERM -F auid>=1000 -F auid!=unset -k access-denied + +-a always,exit -F arch=b64 -S mount -F auid>=1000 -F auid!=unset -k mounts + +-w /sbin/insmod -p x -k modules +-w /sbin/rmmod -p x -k modules +-w /sbin/modprobe -p x -k modules diff --git a/roles/cis_hardening/vars/Debian.yml b/roles/cis_hardening/vars/Debian.yml new file mode 100644 index 0000000..60c0169 --- /dev/null +++ b/roles/cis_hardening/vars/Debian.yml @@ -0,0 +1,4 @@ +--- +cis_hardening_grub_mkconfig_cmd: "update-grub" +cis_hardening_grub_cfg_path_bios: "" +cis_hardening_grub_cfg_path_uefi: "" diff --git a/roles/cis_hardening/vars/RedHat.yml b/roles/cis_hardening/vars/RedHat.yml new file mode 100644 index 0000000..5d4720e --- /dev/null +++ b/roles/cis_hardening/vars/RedHat.yml @@ -0,0 +1,4 @@ +--- +cis_hardening_grub_mkconfig_cmd: "grub2-mkconfig" +cis_hardening_grub_cfg_path_bios: "/boot/grub2/grub.cfg" +cis_hardening_grub_cfg_path_uefi: "/boot/efi/EFI/rocky/grub.cfg" diff --git a/roles/fail2ban/handlers/main.yml b/roles/fail2ban/handlers/main.yml new file mode 100644 index 0000000..6f6780a --- /dev/null +++ b/roles/fail2ban/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart fail2ban + ansible.builtin.systemd: + name: fail2ban + state: restarted diff --git a/roles/fail2ban/tasks/main.yml b/roles/fail2ban/tasks/main.yml new file mode 100644 index 0000000..e381e4a --- /dev/null +++ b/roles/fail2ban/tasks/main.yml @@ -0,0 +1,37 @@ +--- +- name: Gather package facts + ansible.builtin.package_facts: + manager: auto + when: ansible_facts['os_family'] == "RedHat" + tags: [fail2ban] + +- name: Fail with guidance if EPEL is missing on RedHat family + ansible.builtin.fail: + msg: "fail2ban requires EPEL on RedHat family. Set epel_enabled: true (repo_management role) or provide fail2ban via custom_yum_repos." + when: + - ansible_facts['os_family'] == "RedHat" + - "'epel-release' not in ansible_facts.packages" + tags: [fail2ban] + +- name: Install fail2ban + ansible.builtin.package: + name: fail2ban + state: present + tags: [fail2ban] + +- name: Deploy fail2ban local jail configuration + ansible.builtin.template: + src: jail.local.j2 + dest: /etc/fail2ban/jail.local + owner: root + group: root + mode: "0644" + notify: restart fail2ban + tags: [fail2ban] + +- name: Enable and start fail2ban + ansible.builtin.systemd: + name: fail2ban + enabled: true + state: started + tags: [fail2ban] diff --git a/roles/fail2ban/templates/jail.local.j2 b/roles/fail2ban/templates/jail.local.j2 new file mode 100644 index 0000000..fa6d409 --- /dev/null +++ b/roles/fail2ban/templates/jail.local.j2 @@ -0,0 +1,11 @@ +# {{ ansible_managed }} +[DEFAULT] +bantime = {{ fail2ban_bantime }} +findtime = {{ fail2ban_findtime }} +maxretry = {{ fail2ban_maxretry }} +ignoreip = {{ fail2ban_ignoreip }} +backend = systemd + +[sshd] +enabled = true +port = {{ ssh_port }} diff --git a/roles/falcon_onboarding/defaults/main.yml b/roles/falcon_onboarding/defaults/main.yml new file mode 100644 index 0000000..401f201 --- /dev/null +++ b/roles/falcon_onboarding/defaults/main.yml @@ -0,0 +1,9 @@ +--- +# The sensor can only be downloaded via an authenticated CrowdStrike API/ +# console session, so exactly one of these must be provided per environment: +# falcon_onboarding_sensor_package_url - internal hosted download URL +# falcon_onboarding_sensor_package_src - local path on the control node +falcon_onboarding_sensor_package_url: "" +falcon_onboarding_sensor_package_src: "" +falcon_onboarding_provisioning_token: "" +falcon_onboarding_cloud_region: "eu-1" diff --git a/roles/falcon_onboarding/handlers/main.yml b/roles/falcon_onboarding/handlers/main.yml new file mode 100644 index 0000000..65028f3 --- /dev/null +++ b/roles/falcon_onboarding/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart falcon-sensor + ansible.builtin.systemd: + name: falcon-sensor + state: restarted diff --git a/roles/falcon_onboarding/tasks/main.yml b/roles/falcon_onboarding/tasks/main.yml new file mode 100644 index 0000000..ff1427d --- /dev/null +++ b/roles/falcon_onboarding/tasks/main.yml @@ -0,0 +1,100 @@ +--- +- name: Include OS-family-specific variables + ansible.builtin.include_vars: "{{ item }}" + with_first_found: + - "{{ ansible_facts['os_family'] }}.yml" + - "default.yml" + tags: [falcon_onboarding, always] + +- name: Verify a sensor package source is defined + ansible.builtin.assert: + that: + - falcon_onboarding_sensor_package_url | length > 0 or falcon_onboarding_sensor_package_src | length > 0 + fail_msg: >- + Neither falcon_onboarding_sensor_package_url nor + falcon_onboarding_sensor_package_src is set. Provide the sensor + package matching this host's OS (see roles/falcon_onboarding/defaults/main.yml). + tags: [falcon_onboarding] + +- name: Download sensor package from internal URL + ansible.builtin.get_url: + url: "{{ falcon_onboarding_sensor_package_url }}" + dest: "/tmp/falcon-sensor.{{ falcon_onboarding_package_ext }}" + owner: root + group: root + mode: "0644" + when: falcon_onboarding_sensor_package_url | length > 0 + tags: [falcon_onboarding] + +- name: Copy sensor package from the Ansible control node + ansible.builtin.copy: + src: "{{ falcon_onboarding_sensor_package_src }}" + dest: "/tmp/falcon-sensor.{{ falcon_onboarding_package_ext }}" + owner: root + group: root + mode: "0644" + when: falcon_onboarding_sensor_package_url | length == 0 and falcon_onboarding_sensor_package_src | length > 0 + tags: [falcon_onboarding] + +- name: Install Falcon sensor (RedHat family) + ansible.builtin.dnf: + name: "/tmp/falcon-sensor.rpm" + state: present + disable_gpg_check: false + when: ansible_facts['os_family'] == "RedHat" + register: falcon_onboarding_install_result + tags: [falcon_onboarding] + +- name: Install Falcon sensor (Debian family) + ansible.builtin.apt: + deb: "/tmp/falcon-sensor.deb" + when: ansible_facts['os_family'] == "Debian" + register: falcon_onboarding_install_result + tags: [falcon_onboarding] + +- name: Remove downloaded/copied sensor package + ansible.builtin.file: + path: "/tmp/falcon-sensor.{{ falcon_onboarding_package_ext }}" + state: absent + tags: [falcon_onboarding] + +- name: Configure Customer ID (CID) + ansible.builtin.command: "/opt/CrowdStrike/falconctl -s --cid={{ falcon_cid }}" + changed_when: true + no_log: true + notify: restart falcon-sensor + tags: [falcon_onboarding] + +- name: Configure provisioning token if required + ansible.builtin.command: "/opt/CrowdStrike/falconctl -s --provisioning-token={{ falcon_onboarding_provisioning_token }}" + changed_when: true + no_log: true + when: falcon_onboarding_provisioning_token | length > 0 + notify: restart falcon-sensor + tags: [falcon_onboarding] + +- name: Enable and start the Falcon sensor service + ansible.builtin.systemd: + name: falcon-sensor + enabled: true + state: started + tags: [falcon_onboarding] + +- name: Check reduced-functionality mode (RFM) + ansible.builtin.command: /opt/CrowdStrike/falconctl -g --rfm-state + register: falcon_onboarding_rfm_state + changed_when: false + failed_when: false + tags: [falcon_onboarding] + +- name: Query agent ID (AID) for verification + ansible.builtin.command: /opt/CrowdStrike/falconctl -g --aid + register: falcon_onboarding_aid + changed_when: false + failed_when: false + tags: [falcon_onboarding] + +- name: Report Falcon onboarding status + ansible.builtin.debug: + msg: "CrowdStrike Falcon on {{ inventory_hostname }} - {{ falcon_onboarding_rfm_state.stdout }} | {{ falcon_onboarding_aid.stdout }}" + tags: [falcon_onboarding] diff --git a/roles/falcon_onboarding/vars/Debian.yml b/roles/falcon_onboarding/vars/Debian.yml new file mode 100644 index 0000000..1fccf98 --- /dev/null +++ b/roles/falcon_onboarding/vars/Debian.yml @@ -0,0 +1,2 @@ +--- +falcon_onboarding_package_ext: deb diff --git a/roles/falcon_onboarding/vars/RedHat.yml b/roles/falcon_onboarding/vars/RedHat.yml new file mode 100644 index 0000000..87524c2 --- /dev/null +++ b/roles/falcon_onboarding/vars/RedHat.yml @@ -0,0 +1,2 @@ +--- +falcon_onboarding_package_ext: rpm diff --git a/roles/firewall_config/tasks/main.yml b/roles/firewall_config/tasks/main.yml new file mode 100644 index 0000000..6c9a219 --- /dev/null +++ b/roles/firewall_config/tasks/main.yml @@ -0,0 +1,56 @@ +--- +- name: Install firewalld + ansible.builtin.package: + name: firewalld + state: present + tags: [firewall_config] + +- name: Enable and start firewalld + ansible.builtin.systemd: + name: firewalld + enabled: true + state: started + tags: [firewall_config] + +- name: Enable the restrictive default zone + ansible.posix.firewalld: + zone: "{{ firewall_default_zone }}" + state: enabled + permanent: true + immediate: true + tags: [firewall_config] + +- name: Set the restrictive zone as system default + ansible.builtin.command: "firewall-cmd --set-default-zone={{ firewall_default_zone }}" + changed_when: true + tags: [firewall_config] + +- name: Enable logging of denied packets + ansible.builtin.command: "firewall-cmd --set-log-denied={{ firewall_log_denied }}" + changed_when: true + tags: [firewall_config] + +- name: Allow SSH only from the administration subnet + ansible.posix.firewalld: + zone: "{{ firewall_default_zone }}" + rich_rule: >- + rule family="ipv4" source address="{{ firewall_admin_subnet }}" + port protocol="tcp" port="{{ ssh_port | default(22) }}" accept + permanent: true + immediate: true + state: enabled + tags: [firewall_config] + +- name: Allow defined application services/ports + ansible.posix.firewalld: + zone: "{{ firewall_default_zone }}" + rich_rule: >- + rule family="ipv4" + {{ 'source address="' + item.source + '"' if item.source is defined and item.source != 'any' else '' }} + service name="{{ item.service }}" accept + permanent: true + immediate: true + state: enabled + loop: "{{ firewall_allowed_services }}" + when: firewall_allowed_services | length > 0 + tags: [firewall_config] diff --git a/roles/identity_ad/handlers/main.yml b/roles/identity_ad/handlers/main.yml new file mode 100644 index 0000000..70b9d6f --- /dev/null +++ b/roles/identity_ad/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart sssd + ansible.builtin.systemd: + name: sssd + state: restarted diff --git a/roles/identity_ad/tasks/main.yml b/roles/identity_ad/tasks/main.yml new file mode 100644 index 0000000..1979f93 --- /dev/null +++ b/roles/identity_ad/tasks/main.yml @@ -0,0 +1,89 @@ +--- +- name: Include OS-family-specific variables + ansible.builtin.include_vars: "{{ item }}" + with_first_found: + - "{{ ansible_facts['os_family'] }}.yml" + - "default.yml" + tags: [identity_ad, always] + +- name: Install AD integration packages + ansible.builtin.package: + name: "{{ identity_ad_packages }}" + state: present + tags: [identity_ad] + +- name: Check whether host is already domain-joined + ansible.builtin.command: realm list + register: identity_ad_realm_status + changed_when: false + failed_when: false + tags: [identity_ad] + +- name: Join Active Directory domain + ansible.builtin.command: > + realm join --user={{ ad_join_username }} + --computer-ou="{{ ad_ou }}" + {{ ad_domain }} + args: + stdin: "{{ ad_join_password }}" + when: ad_domain not in identity_ad_realm_status.stdout + changed_when: true + no_log: true + tags: [identity_ad] + +- name: Deploy sssd.conf + ansible.builtin.template: + src: sssd.conf.j2 + dest: /etc/sssd/sssd.conf + owner: root + group: root + mode: "0600" + notify: restart sssd + tags: [identity_ad] + +- name: Enable oddjobd (required for pam_mkhomedir, RedHat family) + ansible.builtin.systemd: + name: oddjobd + enabled: true + state: started + when: ansible_facts['os_family'] == "RedHat" + tags: [identity_ad] + +- name: Enable automatic home directory creation (RedHat family) + ansible.builtin.command: authselect enable-feature with-mkhomedir + register: identity_ad_authselect_result + changed_when: "'already enabled' not in identity_ad_authselect_result.stderr" + failed_when: false + when: ansible_facts['os_family'] == "RedHat" + tags: [identity_ad] + +- name: Check whether pam_mkhomedir is already enabled (Debian family) + ansible.builtin.command: grep -q pam_mkhomedir.so /etc/pam.d/common-session + register: identity_ad_mkhomedir_check + changed_when: false + failed_when: false + when: ansible_facts['os_family'] == "Debian" + tags: [identity_ad] + +- name: Enable automatic home directory creation (Debian family) + ansible.builtin.command: pam-auth-update --enable mkhomedir + when: + - ansible_facts['os_family'] == "Debian" + - identity_ad_mkhomedir_check.rc != 0 + changed_when: true + tags: [identity_ad] + +- name: Enable and start sssd + ansible.builtin.systemd: + name: sssd + enabled: true + state: started + tags: [identity_ad] + +- name: Restrict login access to authorized AD groups + ansible.builtin.lineinfile: + path: /etc/sssd/sssd.conf + regexp: "^access_provider" + line: "access_provider = simple" + notify: restart sssd + tags: [identity_ad] diff --git a/roles/identity_ad/templates/sssd.conf.j2 b/roles/identity_ad/templates/sssd.conf.j2 new file mode 100644 index 0000000..e487c1d --- /dev/null +++ b/roles/identity_ad/templates/sssd.conf.j2 @@ -0,0 +1,23 @@ +# {{ ansible_managed }} +[sssd] +services = nss, pam +domains = {{ ad_domain }} + +[domain/{{ ad_domain }}] +id_provider = ad +access_provider = simple +simple_allow_groups = {{ ad_admin_group }}, {{ ad_operator_group }} +auth_provider = ad +chpass_provider = ad + +ad_domain = {{ ad_domain }} +krb5_realm = {{ ad_domain_realm }} + +use_fully_qualified_names = {{ sssd_use_fully_qualified_names | lower }} +fallback_homedir = /home/%d/%u +default_shell = /bin/bash + +cache_credentials = true +enumerate = false + +ldap_id_mapping = true diff --git a/roles/identity_ad/vars/Debian.yml b/roles/identity_ad/vars/Debian.yml new file mode 100644 index 0000000..a2baf31 --- /dev/null +++ b/roles/identity_ad/vars/Debian.yml @@ -0,0 +1,10 @@ +--- +identity_ad_packages: + - realmd + - sssd + - sssd-tools + - adcli + - samba-common-bin + - krb5-user + - libnss-sss + - libpam-sss diff --git a/roles/identity_ad/vars/RedHat.yml b/roles/identity_ad/vars/RedHat.yml new file mode 100644 index 0000000..17d6412 --- /dev/null +++ b/roles/identity_ad/vars/RedHat.yml @@ -0,0 +1,10 @@ +--- +identity_ad_packages: + - realmd + - sssd + - sssd-tools + - oddjob + - oddjob-mkhomedir + - adcli + - samba-common-tools + - krb5-workstation diff --git a/roles/local_accounts/handlers/main.yml b/roles/local_accounts/handlers/main.yml new file mode 100644 index 0000000..3d01939 --- /dev/null +++ b/roles/local_accounts/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart sshd (local_accounts) + ansible.builtin.systemd: + name: sshd + state: restarted diff --git a/roles/local_accounts/tasks/main.yml b/roles/local_accounts/tasks/main.yml new file mode 100644 index 0000000..85faf48 --- /dev/null +++ b/roles/local_accounts/tasks/main.yml @@ -0,0 +1,67 @@ +--- +- name: Create break-glass group + ansible.builtin.group: + name: "{{ breakglass_username }}" + state: present + tags: [local_accounts] + +- name: Create break-glass account + ansible.builtin.user: + name: "{{ breakglass_username }}" + comment: "{{ breakglass_comment }}" + group: "{{ breakglass_username }}" + shell: /bin/bash + create_home: true + home: "/home/{{ breakglass_username }}" + password: "{{ breakglass_password_hash | default('!', true) }}" + update_password: on_create + state: present + tags: [local_accounts] + +- name: Lock down break-glass home directory + ansible.builtin.file: + path: "/home/{{ breakglass_username }}" + owner: "{{ breakglass_username }}" + group: "{{ breakglass_username }}" + mode: "0700" + tags: [local_accounts] + +- name: Deploy unrestricted sudo drop-in for break-glass account + ansible.builtin.copy: + dest: "/etc/sudoers.d/00-{{ breakglass_username }}" + owner: root + group: root + mode: "0440" + content: "{{ breakglass_username }} ALL=(ALL) ALL\n" + validate: "visudo -cf %s" + tags: [local_accounts] + +- name: Block SSH access for the break-glass account + ansible.builtin.blockinfile: + path: /etc/ssh/sshd_config.d/00-breakglass-deny.conf + create: true + owner: root + group: root + mode: "0600" + marker: "# {mark} ANSIBLE MANAGED BLOCK" + block: | + Match User {{ breakglass_username }} + PasswordAuthentication no + PubkeyAuthentication no + validate: "/usr/sbin/sshd -t -f %s" + notify: restart sshd (local_accounts) + tags: [local_accounts] + +- name: Create optional additional local service accounts + ansible.builtin.user: + name: "{{ item.name }}" + comment: "{{ item.comment | default('Local service account, managed via Ansible') }}" + system: "{{ item.system | default(true) }}" + shell: "{{ item.shell | default('/sbin/nologin') }}" + create_home: "{{ item.create_home | default(false) }}" + state: present + loop: "{{ local_service_accounts }}" + loop_control: + label: "{{ item.name }}" + when: local_service_accounts | length > 0 + tags: [local_accounts] diff --git a/roles/logging_rsyslog/files/README.md b/roles/logging_rsyslog/files/README.md new file mode 100644 index 0000000..61682cc --- /dev/null +++ b/roles/logging_rsyslog/files/README.md @@ -0,0 +1,6 @@ +Place the customer's internal CA root/intermediate certificate here +(filename set via syslog_ca_cert_src in group_vars, default: siem-ca.crt), +unless the SIEM/syslog-collector certificate is already signed by a +publicly trusted CA already present in the system trust store. + +Only relevant when syslog_ca_cert_enabled: true. diff --git a/roles/logging_rsyslog/handlers/main.yml b/roles/logging_rsyslog/handlers/main.yml new file mode 100644 index 0000000..9a77de0 --- /dev/null +++ b/roles/logging_rsyslog/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: restart rsyslog + ansible.builtin.systemd: + name: rsyslog + state: restarted + +- name: update CA trust + ansible.builtin.command: "{{ logging_rsyslog_ca_trust_update_cmd }}" + changed_when: true diff --git a/roles/logging_rsyslog/tasks/main.yml b/roles/logging_rsyslog/tasks/main.yml new file mode 100644 index 0000000..b887c1a --- /dev/null +++ b/roles/logging_rsyslog/tasks/main.yml @@ -0,0 +1,60 @@ +--- +- name: Include OS-family-specific variables + ansible.builtin.include_vars: "{{ item }}" + with_first_found: + - "{{ ansible_facts['os_family'] }}.yml" + - "default.yml" + tags: [logging_rsyslog, always] + +- name: Install rsyslog with TLS support + ansible.builtin.package: + name: + - rsyslog + - rsyslog-gnutls + state: present + when: syslog_use_tls | bool + tags: [logging_rsyslog] + +- name: Install rsyslog without TLS support + ansible.builtin.package: + name: rsyslog + state: present + when: not syslog_use_tls | bool + tags: [logging_rsyslog] + +- name: Distribute internal CA certificate for SIEM TLS connection + ansible.builtin.copy: + src: "{{ syslog_ca_cert_src }}" + dest: "{{ logging_rsyslog_ca_cert_dest }}" + owner: root + group: root + mode: "0644" + when: syslog_ca_cert_enabled | bool + notify: update CA trust + tags: [logging_rsyslog] + +- name: Deploy forwarding configuration + ansible.builtin.template: + src: 60-forward-siem.conf.j2 + dest: /etc/rsyslog.d/60-forward-siem.conf + owner: root + group: root + mode: "0644" + notify: restart rsyslog + tags: [logging_rsyslog] + +- name: Deploy logrotate configuration + ansible.builtin.template: + src: logrotate-cis.j2 + dest: /etc/logrotate.d/rsyslog-cis + owner: root + group: root + mode: "0644" + tags: [logging_rsyslog] + +- name: Enable and start rsyslog + ansible.builtin.systemd: + name: rsyslog + enabled: true + state: started + tags: [logging_rsyslog] diff --git a/roles/logging_rsyslog/templates/60-forward-siem.conf.j2 b/roles/logging_rsyslog/templates/60-forward-siem.conf.j2 new file mode 100644 index 0000000..317f199 --- /dev/null +++ b/roles/logging_rsyslog/templates/60-forward-siem.conf.j2 @@ -0,0 +1,24 @@ +# {{ ansible_managed }} +{% if syslog_use_tls %} +$DefaultNetstreamDriver gtls +$DefaultNetstreamDriverCAFile /etc/pki/tls/certs/ca-bundle.crt +$ActionSendStreamDriverMode 1 +$ActionSendStreamDriverAuthMode x509/name +$ActionSendStreamDriverPermittedPeer {{ syslog_collector_host }} + +$ActionQueueType LinkedList +$ActionQueueFileName siem_fwd_queue +$ActionQueueMaxDiskSpace 1g +$ActionQueueSaveOnShutdown on +$ActionResumeRetryCount -1 + +auth,authpriv.*;kern.*;cron.* @@(o){{ syslog_collector_host }}:{{ syslog_collector_port }} +{% else %} +$ActionQueueType LinkedList +$ActionQueueFileName siem_fwd_queue +$ActionQueueMaxDiskSpace 1g +$ActionQueueSaveOnShutdown on +$ActionResumeRetryCount -1 + +auth,authpriv.*;kern.*;cron.* @{{ syslog_collector_host }}:{{ syslog_collector_port }} +{% endif %} diff --git a/roles/logging_rsyslog/templates/logrotate-cis.j2 b/roles/logging_rsyslog/templates/logrotate-cis.j2 new file mode 100644 index 0000000..cc8a890 --- /dev/null +++ b/roles/logging_rsyslog/templates/logrotate-cis.j2 @@ -0,0 +1,14 @@ +# {{ ansible_managed }} +/var/log/messages /var/log/secure /var/log/maillog /var/log/cron /var/log/spooler { + weekly + rotate 12 + compress + delaycompress + missingok + notifempty + create 0640 root root + sharedscripts + postrotate + /usr/bin/systemctl kill -s HUP rsyslog.service >/dev/null 2>&1 || true + endscript +} diff --git a/roles/logging_rsyslog/vars/Debian.yml b/roles/logging_rsyslog/vars/Debian.yml new file mode 100644 index 0000000..058d78c --- /dev/null +++ b/roles/logging_rsyslog/vars/Debian.yml @@ -0,0 +1,3 @@ +--- +logging_rsyslog_ca_cert_dest: /usr/local/share/ca-certificates/siem-ca.crt +logging_rsyslog_ca_trust_update_cmd: update-ca-certificates diff --git a/roles/logging_rsyslog/vars/RedHat.yml b/roles/logging_rsyslog/vars/RedHat.yml new file mode 100644 index 0000000..456aefa --- /dev/null +++ b/roles/logging_rsyslog/vars/RedHat.yml @@ -0,0 +1,3 @@ +--- +logging_rsyslog_ca_cert_dest: /etc/pki/ca-trust/source/anchors/siem-ca.crt +logging_rsyslog_ca_trust_update_cmd: update-ca-trust extract diff --git a/roles/pam_hardening/tasks/main.yml b/roles/pam_hardening/tasks/main.yml new file mode 100644 index 0000000..a232de5 --- /dev/null +++ b/roles/pam_hardening/tasks/main.yml @@ -0,0 +1,63 @@ +--- +- name: Include OS-family-specific variables + ansible.builtin.include_vars: "{{ item }}" + with_first_found: + - "{{ ansible_facts['os_family'] }}.yml" + - "default.yml" + tags: [pam_hardening, always] + +- name: Ensure pwquality library is installed + ansible.builtin.package: + name: "{{ pam_hardening_pwquality_package }}" + state: present + tags: [pam_hardening] + +- name: Set password complexity requirements + ansible.builtin.lineinfile: + path: /etc/security/pwquality.conf + regexp: "^#?\\s*{{ item.key }}\\s*=" + line: "{{ item.key }} = {{ item.value }}" + loop: + - { key: "minlen", value: "{{ pam_pwquality_minlen }}" } + - { key: "dcredit", value: "{{ pam_pwquality_dcredit }}" } + - { key: "ucredit", value: "{{ pam_pwquality_ucredit }}" } + - { key: "lcredit", value: "{{ pam_pwquality_lcredit }}" } + - { key: "ocredit", value: "{{ pam_pwquality_ocredit }}" } + - { key: "retry", value: "{{ pam_pwquality_retry }}" } + tags: [pam_hardening] + +- name: Enable pam_faillock via authselect (RedHat family) + ansible.builtin.command: authselect enable-feature with-faillock + register: pam_hardening_faillock_enable + changed_when: "'already enabled' not in pam_hardening_faillock_enable.stderr" + failed_when: false + when: ansible_facts['os_family'] == "RedHat" + tags: [pam_hardening] + +- name: Check whether pam_faillock is already enabled (Debian family) + ansible.builtin.command: grep -q pam_faillock.so /etc/pam.d/common-auth + register: pam_hardening_faillock_check + changed_when: false + failed_when: false + when: ansible_facts['os_family'] == "Debian" + tags: [pam_hardening] + +- name: Enable pam_faillock via pam-auth-update (Debian family) + ansible.builtin.command: pam-auth-update --enable faillock + register: pam_hardening_faillock_debian + changed_when: true + failed_when: false + when: + - ansible_facts['os_family'] == "Debian" + - pam_hardening_faillock_check.rc != 0 + tags: [pam_hardening] + +- name: Set account lockout thresholds + ansible.builtin.lineinfile: + path: /etc/security/faillock.conf + regexp: "^#?\\s*{{ item.key }}\\s*=" + line: "{{ item.key }} = {{ item.value }}" + loop: + - { key: "deny", value: "{{ pam_faillock_deny }}" } + - { key: "unlock_time", value: "{{ pam_faillock_unlock_time }}" } + tags: [pam_hardening] diff --git a/roles/pam_hardening/vars/Debian.yml b/roles/pam_hardening/vars/Debian.yml new file mode 100644 index 0000000..9ce8b29 --- /dev/null +++ b/roles/pam_hardening/vars/Debian.yml @@ -0,0 +1,2 @@ +--- +pam_hardening_pwquality_package: libpam-pwquality diff --git a/roles/pam_hardening/vars/RedHat.yml b/roles/pam_hardening/vars/RedHat.yml new file mode 100644 index 0000000..55406e6 --- /dev/null +++ b/roles/pam_hardening/vars/RedHat.yml @@ -0,0 +1,2 @@ +--- +pam_hardening_pwquality_package: libpwquality diff --git a/roles/patch_mgmt/tasks/main.yml b/roles/patch_mgmt/tasks/main.yml new file mode 100644 index 0000000..9204b2b --- /dev/null +++ b/roles/patch_mgmt/tasks/main.yml @@ -0,0 +1,90 @@ +--- +- name: Include OS-family-specific variables + ansible.builtin.include_vars: "{{ item }}" + with_first_found: + - "{{ ansible_facts['os_family'] }}.yml" + - "default.yml" + tags: [patch_mgmt, always] + +- name: Install patch management packages + ansible.builtin.package: + name: "{{ patch_mgmt_packages }}" + state: present + tags: [patch_mgmt] + +- name: Deploy dnf-automatic configuration (RedHat family) + ansible.builtin.template: + src: automatic.conf.j2 + dest: /etc/dnf/automatic.conf + owner: root + group: root + mode: "0644" + when: ansible_facts['os_family'] == "RedHat" + tags: [patch_mgmt] + +- name: Enable dnf-automatic download timer (RedHat family) + ansible.builtin.systemd: + name: dnf-automatic.timer + enabled: true + state: started + when: ansible_facts['os_family'] == "RedHat" + tags: [patch_mgmt] + +- name: Deploy unattended-upgrades allowed-origins configuration (Debian family) + ansible.builtin.template: + src: 50unattended-upgrades.j2 + dest: /etc/apt/apt.conf.d/50unattended-upgrades + owner: root + group: root + mode: "0644" + when: ansible_facts['os_family'] == "Debian" + tags: [patch_mgmt] + +- name: Deploy apt periodic update/upgrade configuration (Debian family) + ansible.builtin.template: + src: 20auto-upgrades.j2 + dest: /etc/apt/apt.conf.d/20auto-upgrades + owner: root + group: root + mode: "0644" + when: ansible_facts['os_family'] == "Debian" + tags: [patch_mgmt] + +- name: Enable apt-daily timers (Debian family) + ansible.builtin.systemd: + name: "{{ item }}" + enabled: true + state: started + loop: + - apt-daily.timer + - apt-daily-upgrade.timer + when: ansible_facts['os_family'] == "Debian" + tags: [patch_mgmt] + +- name: Deploy reboot-required check script + ansible.builtin.template: + src: check-reboot-needed.sh.j2 + dest: /usr/local/sbin/check-reboot-needed.sh + owner: root + group: root + mode: "0750" + tags: [patch_mgmt] + +- name: Schedule daily reboot-required check + ansible.builtin.cron: + name: "Check for pending reboot after patching" + special_time: daily + job: "/usr/local/sbin/check-reboot-needed.sh" + user: root + tags: [patch_mgmt] + +- name: Schedule automatic reboot in maintenance window (non-critical systems only) + ansible.builtin.cron: + name: "Automatic reboot in maintenance window" + weekday: "{{ patch_reboot_day }}" + hour: "{{ patch_reboot_time.split(':')[0] }}" + minute: "{{ patch_reboot_time.split(':')[1] }}" + job: "/usr/local/sbin/check-reboot-needed.sh --auto-reboot" + user: root + when: inventory_hostname in patch_reboot_window_hosts + tags: [patch_mgmt] diff --git a/roles/patch_mgmt/templates/20auto-upgrades.j2 b/roles/patch_mgmt/templates/20auto-upgrades.j2 new file mode 100644 index 0000000..77d9666 --- /dev/null +++ b/roles/patch_mgmt/templates/20auto-upgrades.j2 @@ -0,0 +1,5 @@ +// {{ ansible_managed }} +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Download-Upgradeable-Packages "1"; +APT::Periodic::Unattended-Upgrade "{{ '1' if dnf_automatic_apply_security else '0' }}"; +APT::Periodic::AutocleanInterval "7"; diff --git a/roles/patch_mgmt/templates/50unattended-upgrades.j2 b/roles/patch_mgmt/templates/50unattended-upgrades.j2 new file mode 100644 index 0000000..a07b832 --- /dev/null +++ b/roles/patch_mgmt/templates/50unattended-upgrades.j2 @@ -0,0 +1,11 @@ +// {{ ansible_managed }} +Unattended-Upgrade::Allowed-Origins { + "${distro_id}:${distro_codename}-security"; +{% if dnf_automatic_apply_other %} + "${distro_id}:${distro_codename}-updates"; +{% endif %} +}; +Unattended-Upgrade::Remove-Unused-Dependencies "true"; +Unattended-Upgrade::Automatic-Reboot "false"; +Unattended-Upgrade::Mail "root"; +Unattended-Upgrade::MailReport "on-change"; diff --git a/roles/patch_mgmt/templates/automatic.conf.j2 b/roles/patch_mgmt/templates/automatic.conf.j2 new file mode 100644 index 0000000..38c60a2 --- /dev/null +++ b/roles/patch_mgmt/templates/automatic.conf.j2 @@ -0,0 +1,17 @@ +# {{ ansible_managed }} +[commands] +upgrade_type = {{ 'security' if not dnf_automatic_apply_other else 'default' }} +random_sleep = 360 +download_updates = yes +apply_updates = {{ 'yes' if dnf_automatic_apply_security else 'no' }} + +[emitters] +emit_via = stdio + +[email] +email_from = dnf-automatic@{{ ansible_domain | default('example.corp') }} +email_to = root +email_host = localhost + +[base] +debuglevel = 1 diff --git a/roles/patch_mgmt/templates/check-reboot-needed.sh.j2 b/roles/patch_mgmt/templates/check-reboot-needed.sh.j2 new file mode 100644 index 0000000..801bea2 --- /dev/null +++ b/roles/patch_mgmt/templates/check-reboot-needed.sh.j2 @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# {{ ansible_managed }} +set -euo pipefail + +{% if ansible_facts['os_family'] == "RedHat" %} +reboot_required() { + ! /usr/bin/needs-restarting -r >/dev/null 2>&1 +} +{% else %} +reboot_required() { + test -f /var/run/reboot-required +} +{% endif %} + +if reboot_required; then + logger -t check-reboot-needed "Reboot required on {{ inventory_hostname }} after security update." + echo "Reboot required on {{ inventory_hostname }}." | mail -s "Reboot required: {{ inventory_hostname }}" root || true + + if [[ "${1:-}" == "--auto-reboot" ]]; then + logger -t check-reboot-needed "Performing automatic reboot (scheduled maintenance window)." + /usr/sbin/shutdown -r +1 "Automatic reboot after security update (maintenance window)" + fi +else + logger -t check-reboot-needed "No reboot required on {{ inventory_hostname }}." +fi diff --git a/roles/patch_mgmt/vars/Debian.yml b/roles/patch_mgmt/vars/Debian.yml new file mode 100644 index 0000000..6d2cdc6 --- /dev/null +++ b/roles/patch_mgmt/vars/Debian.yml @@ -0,0 +1,4 @@ +--- +patch_mgmt_packages: + - unattended-upgrades + - apt-listchanges diff --git a/roles/patch_mgmt/vars/RedHat.yml b/roles/patch_mgmt/vars/RedHat.yml new file mode 100644 index 0000000..4dc9caa --- /dev/null +++ b/roles/patch_mgmt/vars/RedHat.yml @@ -0,0 +1,4 @@ +--- +patch_mgmt_packages: + - dnf-automatic + - dnf-utils diff --git a/roles/repo_management/tasks/main.yml b/roles/repo_management/tasks/main.yml new file mode 100644 index 0000000..c1b677f --- /dev/null +++ b/roles/repo_management/tasks/main.yml @@ -0,0 +1,122 @@ +--- +- name: Ensure python3-debian is installed (required by deb822_repository) + ansible.builtin.package: + name: python3-debian + state: present + when: ansible_facts['os_family'] == "Debian" + tags: [repo_management] + +- name: Configure internal/custom yum repositories (RedHat family) + ansible.builtin.yum_repository: + name: "{{ item.name }}" + description: "{{ item.description | default(item.name) }}" + baseurl: "{{ item.baseurl }}" + gpgcheck: "{{ item.gpgcheck | default(true) }}" + gpgkey: "{{ item.gpgkey | default(omit) }}" + enabled: "{{ item.enabled | default(true) }}" + loop: "{{ custom_yum_repos }}" + loop_control: + label: "{{ item.name }}" + when: ansible_facts['os_family'] == "RedHat" and custom_yum_repos | length > 0 + tags: [repo_management] + +- name: Configure internal/custom apt repositories (Debian family) + ansible.builtin.deb822_repository: + name: "{{ item.name }}" + types: deb + uris: "{{ item.uris }}" + suites: "{{ item.suites }}" + components: "{{ item.components | default(omit) }}" + signed_by: "{{ item.signed_by | default(omit) }}" + loop: "{{ custom_apt_repos }}" + loop_control: + label: "{{ item.name }}" + when: ansible_facts['os_family'] == "Debian" and custom_apt_repos | length > 0 + tags: [repo_management] + +- name: Probe internet connectivity + ansible.builtin.uri: + url: "{{ repo_mgmt_internet_check_url }}" + method: HEAD + timeout: 5 + status_code: [200, 301, 302, 403] + register: repo_management_internet_probe + failed_when: false + when: repo_mgmt_check_internet | bool + tags: [repo_management] + +- name: Set internet availability fact + ansible.builtin.set_fact: + repo_management_internet_available: "{{ (repo_management_internet_probe is defined) and (repo_management_internet_probe.status is defined) and (repo_management_internet_probe.status | default(0) in [200, 301, 302, 403]) }}" + tags: [repo_management] + +- name: Report internet availability + ansible.builtin.debug: + msg: "Internet access on {{ inventory_hostname }}: {{ 'available' if repo_management_internet_available else 'not available / check disabled' }}" + tags: [repo_management] + +- name: Enable CRB repository (RedHat family, internet access required) + community.general.dnf_config_manager: + name: crb + state: enabled + when: + - ansible_facts['os_family'] == "RedHat" + - repo_management_internet_available | default(false) + - crb_enabled | bool + tags: [repo_management] + +- name: Install EPEL release package (RedHat family, internet access required) + ansible.builtin.dnf: + name: "https://dl.fedoraproject.org/pub/epel/epel-release-latest-{{ epel_major_version }}.noarch.rpm" + state: present + disable_gpg_check: false + when: + - ansible_facts['os_family'] == "RedHat" + - repo_management_internet_available | default(false) + - epel_enabled | bool + tags: [repo_management] + +- name: Warn if EPEL/CRB requested but no internet access + ansible.builtin.debug: + msg: "EPEL/CRB requested but no internet access detected on {{ inventory_hostname }}. Add an internal mirror via custom_yum_repos instead." + when: + - ansible_facts['os_family'] == "RedHat" + - not (repo_management_internet_available | default(false)) + - (epel_enabled | bool) or (crb_enabled | bool) + tags: [repo_management] + +- name: Enable universe/multiverse components (Ubuntu, internet access required) + ansible.builtin.deb822_repository: + name: ubuntu-universe-multiverse + types: deb + uris: "http://archive.ubuntu.com/ubuntu" + suites: + - "{{ ansible_facts['distribution_release'] }}" + - "{{ ansible_facts['distribution_release'] }}-updates" + components: + - universe + - multiverse + when: + - ansible_facts['distribution'] == "Ubuntu" + - repo_management_internet_available | default(false) + - ubuntu_universe_enabled | bool + tags: [repo_management] + +- name: Enable backports component (Debian, internet access required) + ansible.builtin.deb822_repository: + name: debian-backports + types: deb + uris: "http://deb.debian.org/debian" + suites: "{{ ansible_facts['distribution_release'] }}-backports" + components: main + when: + - ansible_facts['distribution'] == "Debian" + - repo_management_internet_available | default(false) + - debian_backports_enabled | bool + tags: [repo_management] + +- name: Refresh apt cache after repository changes (Debian family) + ansible.builtin.apt: + update_cache: true + when: ansible_facts['os_family'] == "Debian" + tags: [repo_management] diff --git a/roles/selinux_config/defaults/main.yml b/roles/selinux_config/defaults/main.yml new file mode 100644 index 0000000..47dfcbb --- /dev/null +++ b/roles/selinux_config/defaults/main.yml @@ -0,0 +1,8 @@ +--- +selinux_config_booleans: [] +# selinux_config_booleans: +# - { name: "httpd_can_network_connect", state: true } + +selinux_config_fcontexts: [] +# selinux_config_fcontexts: +# - { target: "/opt/myapp/data(/.*)?", setype: "httpd_sys_rw_content_t" } diff --git a/roles/selinux_config/handlers/main.yml b/roles/selinux_config/handlers/main.yml new file mode 100644 index 0000000..74497e3 --- /dev/null +++ b/roles/selinux_config/handlers/main.yml @@ -0,0 +1,2 @@ +--- +# No handlers needed - restorecon is applied directly in tasks/main.yml diff --git a/roles/selinux_config/tasks/main.yml b/roles/selinux_config/tasks/main.yml new file mode 100644 index 0000000..6551671 --- /dev/null +++ b/roles/selinux_config/tasks/main.yml @@ -0,0 +1,58 @@ +--- +- name: Verify this role only runs on RedHat-family hosts + ansible.builtin.assert: + that: + - ansible_facts['os_family'] == "RedHat" + fail_msg: "selinux_config only supports RedHat-family hosts. Use apparmor_config for Debian family." + tags: [selinux_config, always] + +- name: Install SELinux management tools + ansible.builtin.dnf: + name: + - policycoreutils-python-utils + - setroubleshoot-server + - checkpolicy + state: present + tags: [selinux_config] + +- name: Set SELinux mode and policy + ansible.posix.selinux: + state: "{{ selinux_state }}" + policy: "{{ selinux_policy }}" + tags: [selinux_config] + +- name: Set application-specific SELinux booleans + ansible.posix.seboolean: + name: "{{ item.name }}" + state: "{{ item.state }}" + persistent: true + loop: "{{ selinux_config_booleans }}" + when: selinux_config_booleans | length > 0 + tags: [selinux_config] + +- name: Set application-specific file contexts + community.general.sefcontext: + target: "{{ item.target }}" + setype: "{{ item.setype }}" + state: present + loop: "{{ selinux_config_fcontexts }}" + register: selinux_config_fcontext_result + tags: [selinux_config] + +- name: Apply restorecon to changed file context paths + ansible.builtin.command: "restorecon -Rv {{ item.item.target | regex_replace('\\(/\\.\\*\\)\\?$', '') }}" + loop: "{{ selinux_config_fcontext_result.results }}" + when: selinux_config_fcontext_result.changed and item.changed + changed_when: true + tags: [selinux_config] + +- name: Query current SELinux status + ansible.builtin.command: getenforce + register: selinux_config_current + changed_when: false + tags: [selinux_config] + +- name: Report SELinux status + ansible.builtin.debug: + msg: "SELinux status on {{ inventory_hostname }}: {{ selinux_config_current.stdout }}" + tags: [selinux_config] diff --git a/roles/ssh_hardening/handlers/main.yml b/roles/ssh_hardening/handlers/main.yml new file mode 100644 index 0000000..2000127 --- /dev/null +++ b/roles/ssh_hardening/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart sshd + ansible.builtin.systemd: + name: sshd + state: restarted diff --git a/roles/ssh_hardening/tasks/main.yml b/roles/ssh_hardening/tasks/main.yml new file mode 100644 index 0000000..2fa5ac2 --- /dev/null +++ b/roles/ssh_hardening/tasks/main.yml @@ -0,0 +1,27 @@ +--- +- name: Deploy sshd_config + ansible.builtin.template: + src: sshd_config.j2 + dest: /etc/ssh/sshd_config + owner: root + group: root + mode: "0600" + validate: "/usr/sbin/sshd -t -f %s" + notify: restart sshd + tags: [ssh_hardening] + +- name: Create sshd_config.d drop-in directory for future exceptions + ansible.builtin.file: + path: /etc/ssh/sshd_config.d + state: directory + owner: root + group: root + mode: "0755" + tags: [ssh_hardening] + +- name: Enable and start sshd + ansible.builtin.systemd: + name: sshd + enabled: true + state: started + tags: [ssh_hardening] diff --git a/roles/ssh_hardening/templates/sshd_config.j2 b/roles/ssh_hardening/templates/sshd_config.j2 new file mode 100644 index 0000000..476376d --- /dev/null +++ b/roles/ssh_hardening/templates/sshd_config.j2 @@ -0,0 +1,34 @@ +# {{ ansible_managed }} +Port {{ ssh_port }} +Protocol 2 + +PermitRootLogin no +PasswordAuthentication no +PubkeyAuthentication yes +ChallengeResponseAuthentication no +KbdInteractiveAuthentication no +UsePAM yes +MaxAuthTries {{ ssh_max_auth_tries }} +AllowGroups {{ ssh_allow_groups }} + +ClientAliveInterval {{ ssh_client_alive_interval }} +ClientAliveCountMax {{ ssh_client_alive_count_max }} +LoginGraceTime 30 +MaxSessions 4 +MaxStartups 10:30:60 + +X11Forwarding no +AllowTcpForwarding no +AllowAgentForwarding no +PermitTunnel no +GatewayPorts no + +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr +MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com +KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512 + +Banner /etc/issue.net +LogLevel VERBOSE +SyslogFacility AUTHPRIV + +Include /etc/ssh/sshd_config.d/*.conf diff --git a/roles/sudo_rbac/handlers/main.yml b/roles/sudo_rbac/handlers/main.yml new file mode 100644 index 0000000..bd65943 --- /dev/null +++ b/roles/sudo_rbac/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: reload auditd rules + ansible.builtin.command: augenrules --load + changed_when: true diff --git a/roles/sudo_rbac/tasks/main.yml b/roles/sudo_rbac/tasks/main.yml new file mode 100644 index 0000000..6314ec6 --- /dev/null +++ b/roles/sudo_rbac/tasks/main.yml @@ -0,0 +1,31 @@ +--- +- name: Deploy sudoers.d drop-in file per AD group + ansible.builtin.template: + src: sudoers_group.j2 + dest: "/etc/sudoers.d/10-{{ item.group | lower }}" + owner: root + group: root + mode: "0440" + validate: "visudo -cf %s" + loop: "{{ sudo_rbac_rules }}" + loop_control: + label: "{{ item.group }}" + tags: [sudo_rbac] + +- name: Restrict direct root console login + ansible.builtin.lineinfile: + path: /etc/securetty + state: absent + regexp: "^tty[1-9]$" + tags: [sudo_rbac, cis_hardening] + +- name: Audit rule for privileged command usage + ansible.builtin.lineinfile: + path: /etc/audit/rules.d/50-privileged.rules + create: true + owner: root + group: root + mode: "0640" + line: "-a exit,always -F arch=b64 -C euid!=uid -F auid!=unset -S execve -k privileged" + notify: reload auditd rules + tags: [sudo_rbac, cis_hardening] diff --git a/roles/sudo_rbac/templates/sudoers_group.j2 b/roles/sudo_rbac/templates/sudoers_group.j2 new file mode 100644 index 0000000..0574283 --- /dev/null +++ b/roles/sudo_rbac/templates/sudoers_group.j2 @@ -0,0 +1,2 @@ +# {{ ansible_managed }} - do not edit manually, changes will be overwritten +%{{ item.group }} ALL=(ALL) {{ 'NOPASSWD:' if item.nopasswd else '' }} {{ item.commands }} diff --git a/vault/secrets.yml.example b/vault/secrets.yml.example new file mode 100644 index 0000000..66a33d6 --- /dev/null +++ b/vault/secrets.yml.example @@ -0,0 +1,27 @@ +--- +# Template for sensitive variables. +# +# Usage: +# 1. cp secrets.yml.example secrets.yml +# 2. Fill in the values below +# 3. ansible-vault encrypt vault/secrets.yml +# 4. Store the vault password in .vault_pass (chmod 600, do NOT commit it) +# or wire it up to your organization's secret store. + +# identity_ad +ad_join_username: "svc-linuxjoin" +ad_join_password: "CHANGE_ME" + +# falcon_onboarding +falcon_cid: "CHANGE_ME-CID-FROM-FALCON-CONSOLE" +falcon_onboarding_provisioning_token: "" + +# cis_hardening: GRUB bootloader password +# Generate the hash beforehand with: grub2-mkpasswd-pbkdf2 +# Only relevant if cis_grub_password_enabled: true +grub_bootloader_password_hash: "" + +# local_accounts: the break-glass account password is intentionally NOT +# managed here. Ansible only creates the account with a locked password +# hash; set the real password manually on the host and store it in your +# organization's password safe.