diff --git a/.ansible-lint-ignore b/.ansible-lint-ignore index 55f3bd74..cbc8485b 100644 --- a/.ansible-lint-ignore +++ b/.ansible-lint-ignore @@ -1,4 +1,5 @@ roles/acme_sh/defaults/main.yml jinja[spacing] +roles/aide/defaults/main.yml jinja[spacing] roles/ansible_init/defaults/main.yml jinja[spacing] roles/apache_httpd/defaults/main.yml jinja[spacing] roles/apache_solr/defaults/main.yml jinja[spacing] diff --git a/CHANGELOG.md b/CHANGELOG.md index a3fab7e0..ffda9c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:aide, playbook:aide**: Add a role and playbook to install AIDE on RHEL 8, 9 and 10 and run a daily file integrity check, which leaves `aide-check.service` failed on any finding. * **role:wordpress**: Entries in `wordpress__plugins` accept `enabled: false`, which keeps a plugin installed but deactivated. * **role:system_update**: The role's inventory variables are type-checked when it starts, so a mistyped value fails the run right away instead of surfacing further in as a confusing error. * **role:wordpress**: Several WordPress instances can share a host as pseudo hosts in the inventory, under different host names as well as under different paths of one host name, such as `https://example.com/blog`. @@ -57,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:system_update**: The daily security lane on Rocky refreshes the AIDE database after installing hot-fixes, like the weekly lane, so the next AIDE check no longer fails on every file a hot-fix touched. * **module:bitwarden_item**: The module works with the Mitogen strategy, where it aborted with `MODULE FAILURE` on every run, for example when the `grafana` role stores its service account tokens. * **plugin:bitwarden_item, module:bitwarden_item**: Running against several hosts in parallel no longer creates duplicates of a Bitwarden item, whether the item is new or has existed for a long time, so the next run no longer aborts with "Found multiple Bitwarden items". * **role:wordpress**: The installation no longer aborts at `wp core download` when Ansible connects as `root` without privilege escalation. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 3dc5a322..cf554abb 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -9,6 +9,7 @@ Which Ansible role is proven to run on which OS? | Role | Deb 12 | Deb 13 | RHEL 8 | RHEL 9 | RHEL 10 | Ubu 22.04 | Ubu 24.04 | Ubu 26.04 | Other | |---------------------------------------|:------:|:------:|:------:|:------:|:-------:|:---------:|:---------:|:---------:|----------------------------------------------| | acme_sh | x | x | x | x | x | (x) | x | (x) | | +| aide | | | x | x | x | | | | | | alternatives | x | x | x | x | (x) | x | x | (x) | | | ansible_init | | | | | | | | | Fedora 35+ | | apache_httpd | x | x | x | x | x | (x) | x | (x) | | diff --git a/extensions/molecule/aide/converge.yml b/extensions/molecule/aide/converge.yml new file mode 100644 index 00000000..15228b6c --- /dev/null +++ b/extensions/molecule/aide/converge.yml @@ -0,0 +1,2 @@ +- name: 'Converge aide playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.aide' diff --git a/extensions/molecule/aide/inventory/group_vars/systems_under_test.yml b/extensions/molecule/aide/inventory/group_vars/systems_under_test.yml new file mode 100644 index 00000000..d3c5ae8a --- /dev/null +++ b/extensions/molecule/aide/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,10 @@ +# /srv is not in the default rule list, so a finding below it can only come from the two +# rules added here. /opt is in the default list, and removing its rule has to take it out +# of the check. verify.yml relies on all three. +aide__rules__group_var: + - path: '/opt' + state: 'absent' + - path: '/srv/lfops-molecule' + attributes: 'NORMAL' + - path: '/srv/lfops-molecule/ignored' + type: 'negative' diff --git a/extensions/molecule/aide/inventory/hosts.yml b/extensions/molecule/aide/inventory/hosts.yml new file mode 100644 index 00000000..4bc289c1 --- /dev/null +++ b/extensions/molecule/aide/inventory/hosts.yml @@ -0,0 +1,13 @@ +# yamllint disable rule:empty-values + +# Map the playbook's target group (playbooks/aide.yml: hosts: lfops_aide) onto the +# shared systems_under_test host set. The role is Red Hat-family only. +lfops_aide: + children: + systems_under_test: + +systems_under_test: + hosts: + rocky8-vm: + rocky9-vm: + rocky10-vm: diff --git a/extensions/molecule/aide/molecule.yml b/extensions/molecule/aide/molecule.yml new file mode 100644 index 00000000..a2a970c2 --- /dev/null +++ b/extensions/molecule/aide/molecule.yml @@ -0,0 +1,2 @@ +# Molecule scenario marker. Inherits the VM backend and the full test_sequence +# from extensions/molecule/config.yml. diff --git a/extensions/molecule/aide/verify.yml b/extensions/molecule/aide/verify.yml new file mode 100644 index 00000000..591ff0a6 --- /dev/null +++ b/extensions/molecule/aide/verify.yml @@ -0,0 +1,213 @@ +# Verify aide on the running system: the check timer is armed, and a check against the +# database the role created runs clean, which proves that aide reads the deployed config +# and that the database matches it, including the role's own files. Then a file below the +# scenario's regular rule makes the check fail and shows up in the report, while a file +# below its negative rule does not, and neither does a file below /opt, whose default rule +# the scenario removes. This only works if the rules from the inventory are in effect, +# since /srv is not monitored otherwise and /opt is by default. +# In between, the role is run again twice, each time after a change it has to revert, to +# prove that its handler leaves the database alone in both cases where re-baselining would +# accept unvetted changes: a database found without the role's check unit, and a last check +# that reported changes. Finally the probe files are removed and the update-db tasks of the +# role accept the current state, after which the failed state is cleared and the check runs +# clean again, so the second verify after the idempotence step starts from the same state. +- name: 'Verify aide' + hosts: 'systems_under_test' + gather_facts: false + + tasks: + + - name: 'systemctl is-enabled aide-check.timer' # noqa command-instead-of-module (read-only state query) + ansible.builtin.command: 'systemctl is-enabled aide-check.timer' + register: '__molecule__aide_timer_enabled_result' + changed_when: false + failed_when: false + + - name: 'systemctl is-active aide-check.timer' # noqa command-instead-of-module (read-only state query) + ansible.builtin.command: 'systemctl is-active aide-check.timer' + register: '__molecule__aide_timer_active_result' + changed_when: false + failed_when: false + + - name: 'Assert that aide-check.timer is enabled and active' + ansible.builtin.assert: + that: + - '__molecule__aide_timer_enabled_result["stdout"] == "enabled"' + - '__molecule__aide_timer_active_result["stdout"] == "active"' + + - name: 'systemctl start aide-check.service (clean host)' # noqa command-instead-of-module (need to run the oneshot and read its Result) + ansible.builtin.command: 'systemctl start aide-check.service' + changed_when: false + failed_when: false + + - name: 'systemctl show --property=Result aide-check.service (clean host)' # noqa command-instead-of-module (no module reads a oneshot Result) + ansible.builtin.command: 'systemctl show --property=Result --value aide-check.service' + register: '__molecule__aide_clean_result' + changed_when: false + + - name: 'cat /var/log/aide/aide.log (clean host)' + ansible.builtin.command: 'cat /var/log/aide/aide.log' + register: '__molecule__aide_clean_log_result' + changed_when: false + + - name: 'Assert that the check on the unchanged host runs clean' + ansible.builtin.assert: + that: + - '__molecule__aide_clean_result["stdout"] == "success"' + fail_msg: '{{ __molecule__aide_clean_log_result["stdout"] }}' + + # a database the role finds without its own check unit must not be re-baselined. removing + # the unit makes the host look like one with a database from a previous AIDE setup, and + # the role run below deploys the unit again, which notifies the handler. + - name: 'sha256sum /var/lib/aide/aide.db.gz (before the run without a check unit)' + ansible.builtin.stat: + path: '/var/lib/aide/aide.db.gz' + checksum_algorithm: 'sha256' + register: '__molecule__aide_db_before_no_unit_result' + + - name: 'rm -f /etc/systemd/system/aide-check.service' + ansible.builtin.file: + path: '/etc/systemd/system/aide-check.service' + state: 'absent' + + - name: 'systemctl daemon-reload' + ansible.builtin.systemd: + daemon_reload: true + + - name: 'Reset the end-of-play messages (run without a check unit)' + ansible.builtin.set_fact: + __shared__end_of_play_messages: [] + + - name: 'Run the aide role (run without a check unit)' + ansible.builtin.include_role: + name: 'linuxfabrik.lfops.aide' + + - name: 'sha256sum /var/lib/aide/aide.db.gz (after the run without a check unit)' + ansible.builtin.stat: + path: '/var/lib/aide/aide.db.gz' + checksum_algorithm: 'sha256' + register: '__molecule__aide_db_after_no_unit_result' + + - name: 'Assert that the database was left alone and the operator was told' + ansible.builtin.assert: + that: + - '__molecule__aide_db_after_no_unit_result["stat"]["checksum"] == __molecule__aide_db_before_no_unit_result["stat"]["checksum"]' + - '__shared__end_of_play_messages | select("match", "aide: ") | list | length == 1' + + - name: 'mkdir -p /srv/lfops-molecule/ignored' + ansible.builtin.file: + path: '/srv/lfops-molecule/ignored' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + + - name: 'Create a probe file below the regular rule, the negative rule and /opt' + ansible.builtin.copy: + content: 'lfops-molecule' + dest: '{{ item }}' + owner: 'root' + group: 'root' + mode: 0o644 + loop: + - '/opt/lfops-molecule-probe' + - '/srv/lfops-molecule/ignored/probe' + - '/srv/lfops-molecule/probe' + + - name: 'systemctl start aide-check.service (changed host)' # noqa command-instead-of-module (need to run the oneshot and read its Result) + ansible.builtin.command: 'systemctl start aide-check.service' + changed_when: false + failed_when: false + + - name: 'systemctl show --property=Result aide-check.service (changed host)' # noqa command-instead-of-module (no module reads a oneshot Result) + ansible.builtin.command: 'systemctl show --property=Result --value aide-check.service' + register: '__molecule__aide_changed_result' + changed_when: false + + - name: 'cat /var/log/aide/aide.log (changed host)' + ansible.builtin.command: 'cat /var/log/aide/aide.log' + register: '__molecule__aide_changed_log_result' + changed_when: false + + - name: 'Assert that the check fails and reports only the file below the regular rule' + ansible.builtin.assert: + that: + - '__molecule__aide_changed_result["stdout"] == "exit-code"' + - '"/srv/lfops-molecule/probe" in __molecule__aide_changed_log_result["stdout"]' + - '"/srv/lfops-molecule/ignored" not in __molecule__aide_changed_log_result["stdout"]' + - '"/opt/lfops-molecule-probe" not in __molecule__aide_changed_log_result["stdout"]' + fail_msg: '{{ __molecule__aide_changed_log_result["stdout"] }}' + + # the check is failing now, so a config change must not re-baseline the database either. + # the role run below reverts the hand edit, which notifies the handler. + - name: 'sha256sum /var/lib/aide/aide.db.gz (before the run after a failed check)' + ansible.builtin.stat: + path: '/var/lib/aide/aide.db.gz' + checksum_algorithm: 'sha256' + register: '__molecule__aide_db_before_failed_result' + + - name: 'Edit /etc/aide.conf by hand' + ansible.builtin.lineinfile: + path: '/etc/aide.conf' + line: '# lfops-molecule' + + - name: 'Reset the end-of-play messages (run after a failed check)' + ansible.builtin.set_fact: + __shared__end_of_play_messages: [] + + - name: 'Run the aide role (run after a failed check)' + ansible.builtin.include_role: + name: 'linuxfabrik.lfops.aide' + + - name: 'sha256sum /var/lib/aide/aide.db.gz (after the run after a failed check)' + ansible.builtin.stat: + path: '/var/lib/aide/aide.db.gz' + checksum_algorithm: 'sha256' + register: '__molecule__aide_db_after_failed_result' + + - name: 'Assert that the database was left alone and the operator was told' + ansible.builtin.assert: + that: + - '__molecule__aide_db_after_failed_result["stat"]["checksum"] == __molecule__aide_db_before_failed_result["stat"]["checksum"]' + - '__shared__end_of_play_messages | select("match", "aide: ") | list | length == 1' + + - name: 'Remove the probe files' + ansible.builtin.file: + path: '{{ item }}' + state: 'absent' + loop: + - '/opt/lfops-molecule-probe' + - '/srv/lfops-molecule' + + # the same tasks `--tags aide:update_db` runs. they have to accept the unit and the config + # that the two role runs above redeployed, and clear the failed state of the last check. + - name: 'Run the update-db tasks of the aide role' + ansible.builtin.include_role: + name: 'linuxfabrik.lfops.aide' + tasks_from: 'update-db.yml' + + - name: 'systemctl is-failed aide-check.service (after update-db)' # noqa command-instead-of-module (read-only state query) + ansible.builtin.command: 'systemctl is-failed aide-check.service' + register: '__molecule__aide_failed_after_update_db_result' + changed_when: false + failed_when: false + + - name: 'Assert that update-db cleared the failed state' + ansible.builtin.assert: + that: + - '__molecule__aide_failed_after_update_db_result["rc"] != 0' + + - name: 'systemctl start aide-check.service (restored host)' # noqa command-instead-of-module (need to run the oneshot and read its Result) + ansible.builtin.command: 'systemctl start aide-check.service' + changed_when: false + failed_when: false + + - name: 'systemctl show --property=Result aide-check.service (restored host)' # noqa command-instead-of-module (no module reads a oneshot Result) + ansible.builtin.command: 'systemctl show --property=Result --value aide-check.service' + register: '__molecule__aide_restored_result' + changed_when: false + + - name: 'Assert that the check runs clean again once the current state was accepted' + ansible.builtin.assert: + that: + - '__molecule__aide_restored_result["stdout"] == "success"' diff --git a/playbooks/README.md b/playbooks/README.md index 5ca83bc3..d74a4b08 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -20,6 +20,13 @@ Calls the following roles (in order): * [acme_sh](https://github.com/Linuxfabrik/lfops/tree/main/roles/acme_sh) +## aide.yml + +Calls the following roles (in order): + +* [aide](https://github.com/Linuxfabrik/lfops/tree/main/roles/aide) + + ## alternatives.yml Calls the following roles (in order): diff --git a/playbooks/aide.yml b/playbooks/aide.yml new file mode 100644 index 00000000..04a84bbd --- /dev/null +++ b/playbooks/aide.yml @@ -0,0 +1,35 @@ +- name: 'Playbook linuxfabrik.lfops.aide' + hosts: + - 'lfops_aide' + + pre_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-start.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'global-variables.yml' + tags: + - 'always' + + + roles: + + - role: 'linuxfabrik.lfops.aide' + + + post_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-end.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'print-messages.yml' + tags: + - 'always' diff --git a/playbooks/all.yml b/playbooks/all.yml index cda263dd..f5a1194b 100644 --- a/playbooks/all.yml +++ b/playbooks/all.yml @@ -1,4 +1,5 @@ - import_playbook: 'acme_sh.yml' +- import_playbook: 'aide.yml' - import_playbook: 'alternatives.yml' - import_playbook: 'ansible_init.yml' - import_playbook: 'apache_httpd.yml' diff --git a/roles/aide/README.md b/roles/aide/README.md new file mode 100644 index 00000000..adb8ac3f --- /dev/null +++ b/roles/aide/README.md @@ -0,0 +1,153 @@ +# Ansible Role linuxfabrik.lfops.aide + +This role installs and configures [AIDE](https://aide.github.io/) (Advanced Intrusion Detection Environment), creates the AIDE database, and schedules a regular file integrity check with `aide-check.timer`. + +This role is Red Hat-family only. It does not run on Debian / Ubuntu. + +This role is compatible with the following aide versions: + +* 0.16 (RHEL 8) +* 0.19 (RHEL 9, RHEL 10) + + +*Available in the next LFOps release.* + + +## How the Role Behaves + +* `/etc/aide.conf` is fully templated. The options and the attribute groups (`NORMAL`, `CONTENT`, `PERMS`, ...) follow the installed aide version. The rules come from `aide__rules__*_var`, whose default is the rule list RHEL 9 and 10 ship, used on RHEL 8 as well. It additionally excludes `/root/.ansible/tmp`, where Ansible keeps a temporary directory while a task runs. +* On the first run the role creates the database (`/var/lib/aide/aide.db.gz`), after it has deployed the config and the systemd units. Depending on the size of the file system, `aide --init` can take several minutes. +* `aide-check.service` runs `aide --check` at a low CPU and IO priority. Any finding (added, removed or changed files) makes the check exit non-zero, which leaves `aide-check.service` in the failed state. Monitor failed systemd units to get alerted. The report is written to `/var/log/aide/aide.log` and to the journal (`journalctl --unit aide-check.service`). The role sends no mail. +* When the role changes `/etc/aide.conf`, its own units or whether `aide-check.timer` is enabled, it updates the database afterwards, so that the next check does not report every path the new rules add or drop. It only does so if the last check had not reported changes: re-baselining a failing check would silently accept whatever changed on the host, an intrusion included. The same applies to a database the role finds on a host where it has not deployed `aide-check.service` yet, for example one left by a previous AIDE setup: no check of this role has vouched for it. In both cases the database is left alone and the run tells you so. Review `/var/log/aide/aide.log` or the output of `aide --check`, and accept the current state with `--tags aide:update_db`. +* The re-baseline accepts everything that changed since the last check, not only the change the role made. The shorter the check interval, the smaller that window. +* Changes to monitored files made by anyone else, other LFOps roles and package updates included, are reported by the next check. The [system_update](https://github.com/Linuxfabrik/lfops/tree/main/roles/system_update) role knows about `aide-check.service` and updates the database after it has updated packages, provided the last check was clean. + + +## Known Limitations + +* Debian and Ubuntu are not supported. +* Non-recursive negative rules (`-/path`) are not available, since aide 0.16 on RHEL 8 does not know them. + + +## Tags + +`aide` + +* Installs aide. +* Deploys `/etc/aide.conf`. +* Deploys `aide-check.service` and `aide-check.timer`, and sets the state of the timer. +* Creates the AIDE database if it does not exist yet. +* Triggers: AIDE database update. + +`aide:configure` + +* Deploys `/etc/aide.conf`. +* Triggers: AIDE database update. + +`aide:cron` + +* Deploys `aide-check.service` and `aide-check.timer`. +* Triggers: AIDE database update. + +`aide:state` + +* Enables or disables `aide-check.timer` and sets its state. +* Triggers: AIDE database update. + +`aide:update_db` + +* Not run by default, only when the tag is given explicitly. +* Updates the AIDE database to the current state of the host and clears the failed state of `aide-check.service`. Use it after reviewing a finding, to accept the reported changes. +* Triggers: none. + + +## Optional Role Variables + +`aide__check_on_calendar` + +* When `aide-check.timer` runs the check. See [systemd.time(7)](https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html) for the format. +* Type: String. +* Default: `'*-*-* 06:{{ 59 | random(seed=inventory_hostname) }}:00'` + +`aide__rules__host_var` / `aide__rules__group_var` + +* The rules in `/etc/aide.conf`. See [aide.conf(5)](https://github.com/aide/aide/blob/master/doc/aide.conf.5) for the rule syntax. Items are identified by their `path`: an item with the `path` of a default rule changes that rule in place, `state: 'absent'` removes it, and a new `path` is appended at the end. The order matters, since within one directory aide applies the first rule that matches. A `negative` rule always wins, wherever it is. +* Type: List of dictionaries. +* Default: the rule list RHEL 9 and 10 ship, see `aide__rules__role_var` in [defaults/main.yml](https://github.com/Linuxfabrik/lfops/blob/main/roles/aide/defaults/main.yml). +* Deviates from the upstream default in two places: the role adds `!/root/\.ansible/tmp`, since Ansible creates and deletes a directory there for every task it runs as root, and on RHEL 8 it uses the RHEL 9 / 10 list instead of the one aide 0.16 ships, so that all hosts are checked against the same rules. +* Subkeys: + + * `path`: + + * Mandatory. The regular expression the rule matches, for example `/opt/app` or `/etc/app.conf$`. It always matches from the start of the path. + * Type: String. + + * `attributes`: + + * Mandatory for rules of type `regular` and `equal`. The attributes or group to check, for example `NORMAL`, `CONTENT`, `PERMS` or `p+u+g+sha512`. + * Type: String. + + * `state`: + + * Optional. `present` or `absent`. + * Type: String. + * Default: `'present'` + + * `type`: + + * Optional. `regular` monitors the path and everything below it. `equal` monitors only the path itself (`=`). `negative` excludes the path and everything below it from monitoring (`!`), also when another rule covers it. + * Type: String. One of `equal`, `negative`, `regular`. + * Default: `'regular'` + +`aide__timer_enabled` + +* Whether `aide-check.timer` is enabled at boot. +* Type: Bool. +* Default: `true` + +`aide__timer_state` + +* State of `aide-check.timer`. +* Type: String. One of `reloaded`, `restarted`, `started`, `stopped`. +* Default: `'started'` + +Example: +```yaml +# optional +aide__check_on_calendar: '*-*-* 03:30:00' +aide__rules__host_var: + - path: '/usr' + attributes: 'CONTENT' + - path: '/etc/cups' + state: 'absent' + - path: '/srv/app' + attributes: 'NORMAL' + - path: '/srv/app/cache' + type: 'negative' + - path: '/srv$' + type: 'equal' + attributes: 'DIR' +aide__timer_enabled: true +aide__timer_state: 'started' +``` + + +## Troubleshooting + +**The run aborts with `aide X.Y is not supported by this role`** + +* The enabled repositories offer an aide version the role has no config for. The role supports aide 0.16 (RHEL 8) and 0.19 (RHEL 9 / 10). Pin the host to a supported version, or add the version to `roles/aide/vars/main.yml` and to the version branches in `roles/aide/templates/etc/aide.conf.j2`. + +**`aide-check.service` is failed** + +* The last check found added, removed or changed files. Read the report in `/var/log/aide/aide.log`. If the changes are expected, accept them with `ansible-playbook --inventory inventory linuxfabrik.lfops.aide --limit myhost --tags aide:update_db`. + + +## License + +[The Unlicense](https://unlicense.org/) + + +## Author Information + +[Linuxfabrik GmbH, Zurich](https://www.linuxfabrik.ch) diff --git a/roles/aide/defaults/main.yml b/roles/aide/defaults/main.yml new file mode 100644 index 00000000..97b85a7a --- /dev/null +++ b/roles/aide/defaults/main.yml @@ -0,0 +1,401 @@ +# run after the system_update / schedule_reboot window (04:00 by default), so the check does +# not race an update that is still changing files. +aide__check_on_calendar: '*-*-* 06:{{ 59 | random(seed=inventory_hostname) }}:00' +aide__rules__combined_var: '{{ ( + aide__rules__role_var + + aide__rules__dependent_var + + aide__rules__group_var + + aide__rules__host_var + ) | linuxfabrik.lfops.combine_lod(unique_key="path") + }}' +aide__rules__dependent_var: [] +aide__rules__group_var: [] +aide__rules__host_var: [] +# the rule list shipped in /etc/aide.conf by aide 0.19.2 on Rocky 9 and 10, in its original order +# and with its comments. the order matters: within one directory aide applies the first rule +# that matches, and combine_lod keeps an item that the inventory overrides in its place. +aide__rules__role_var: + - path: '/boot' + attributes: 'NORMAL' + - path: '/bin' + attributes: 'NORMAL' + - path: '/sbin' + attributes: 'NORMAL' + - path: '/lib' + attributes: 'NORMAL' + - path: '/lib64' + attributes: 'NORMAL' + # Monitor /opt selectively to avoid noise from auto-updating applications + - path: '/opt' + attributes: 'CONTENT' + - path: '/usr' + attributes: 'NORMAL' + # These are too volatile + - path: '/usr/src' + type: 'negative' + - path: '/usr/tmp' + type: 'negative' + + # Admins dot files constantly change, just check perms + - path: '/root/\..*' + attributes: 'PERMS' + - path: '/root/.xauth*' + type: 'negative' + # Not in the shipped list. Ansible creates and deletes a directory here for every task it + # runs as root, `aide --init` from this role included, so the database would otherwise + # contain a directory that is gone by the next check. + - path: '/root/\.ansible/tmp' + type: 'negative' + - path: '/root' + attributes: 'NORMAL' + + # Check only permissions, inode, user and group for /etc, but + # cover some important files closely. + - path: '/etc/mtab' + type: 'negative' + # Ignore backup files + - path: '/etc/.*~' + type: 'negative' + + # trusted databases + - path: '/etc/hosts$' + attributes: 'NORMAL' + - path: '/etc/host.conf$' + attributes: 'NORMAL' + - path: '/etc/hostname$' + attributes: 'NORMAL' + - path: '/etc/issue$' + attributes: 'NORMAL' + - path: '/etc/issue.net$' + attributes: 'NORMAL' + - path: '/etc/protocols$' + attributes: 'NORMAL' + - path: '/etc/services$' + attributes: 'NORMAL' + - path: '/etc/localtime$' + attributes: 'NORMAL' + - path: '/etc/alternatives' + attributes: 'NORMAL' + - path: '/etc/mime.types$' + attributes: 'NORMAL' + - path: '/etc/terminfo' + attributes: 'NORMAL' + - path: '/etc/exports$' + attributes: 'NORMAL' + - path: '/etc/fstab$' + attributes: 'NORMAL' + - path: '/etc/passwd$' + attributes: 'NORMAL' + - path: '/etc/group$' + attributes: 'NORMAL' + - path: '/etc/gshadow$' + attributes: 'NORMAL' + - path: '/etc/shadow$' + attributes: 'NORMAL' + - path: '/etc/subgid$' + attributes: 'NORMAL' + - path: '/etc/subuid$' + attributes: 'NORMAL' + - path: '/etc/skel' + attributes: 'NORMAL' + - path: '/etc/sssd' + attributes: 'NORMAL' + - path: '/etc/swid' + attributes: 'NORMAL' + - path: '/etc/system-release-cpe$' + attributes: 'NORMAL' + - path: '/etc/tmux.conf$' + attributes: 'NORMAL' + - path: '/etc/xattr.conf$' + attributes: 'NORMAL' + + # networking + - path: '/etc/firewalld' + attributes: 'NORMAL' + - path: '/etc/NetworkManager/system-connections' + type: 'negative' + - path: '/etc/NetworkManager' + attributes: 'NORMAL' + - path: '/etc/networks$' + attributes: 'NORMAL' + - path: '/etc/dhcp' + attributes: 'NORMAL' + - path: '/etc/wpa_supplicant' + attributes: 'NORMAL' + - path: '/etc/resolv.conf$' + attributes: 'DATAONLY' + + # logins and accounts + - path: '/etc/login.defs$' + attributes: 'NORMAL' + - path: '/etc/libuser.conf$' + attributes: 'NORMAL' + - path: '/var/log/faillog$' + attributes: 'PERMS' + - path: '/var/log/lastlog$' + attributes: 'PERMS' + - path: '/var/run/faillock' + attributes: 'PERMS' + - path: '/etc/pam.d' + attributes: 'NORMAL' + - path: '/etc/security' + attributes: 'NORMAL' + - path: '/etc/securetty$' + attributes: 'NORMAL' + - path: '/etc/polkit-1' + attributes: 'NORMAL' + - path: '/etc/sudo.conf$' + attributes: 'NORMAL' + - path: '/etc/sudoers$' + attributes: 'NORMAL' + - path: '/etc/sudoers.d' + attributes: 'NORMAL' + + # Shell/X starting files + - path: '/etc/profile$' + attributes: 'NORMAL' + - path: '/etc/profile.d' + attributes: 'NORMAL' + - path: '/etc/bashrc$' + attributes: 'NORMAL' + - path: '/etc/bash_completion.d' + attributes: 'NORMAL' + - path: '/etc/zprofile$' + attributes: 'NORMAL' + - path: '/etc/zshrc$' + attributes: 'NORMAL' + - path: '/etc/zlogin$' + attributes: 'NORMAL' + - path: '/etc/zlogout$' + attributes: 'NORMAL' + - path: '/etc/X11' + attributes: 'NORMAL' + - path: '/etc/shells$' + attributes: 'NORMAL' + + # Pkg manager + - path: '/etc/dnf' + attributes: 'NORMAL' + - path: '/etc/yum.repos.d' + attributes: 'NORMAL' + + # auditing + # AIDE produces an audit record, so this becomes perpetual motion. + - path: '/var/log/audit' + attributes: 'PERMS' + - path: '/etc/audit' + attributes: 'NORMAL' + - path: '/etc/libaudit.conf$' + attributes: 'NORMAL' + - path: '/etc/aide.conf$' + attributes: 'NORMAL' + + # System logs with proper logrotate handling + - path: '/etc/rsyslog.conf$' + attributes: 'NORMAL' + - path: '/etc/rsyslog.d' + attributes: 'NORMAL' + - path: '/etc/logrotate.conf$' + attributes: 'NORMAL' + - path: '/etc/logrotate.d' + attributes: 'NORMAL' + - path: '/etc/systemd/journald.conf$' + attributes: 'NORMAL' + + # Log directory + - path: '/var/log' + attributes: 'LOG' + # Journal files - exclude xattrs and link count due to systemd journal's user.crtime_usec extended attribute changes and new directory creation + - path: '/var/log/journal' + attributes: 'LOG-xattrs-n' + + - path: '/var/run/utmp' + attributes: 'LOG' + + # secrets + - path: '/etc/pkcs11' + attributes: 'NORMAL' + - path: '/etc/pki' + attributes: 'NORMAL' + - path: '/etc/ssl' + attributes: 'NORMAL' + - path: '/etc/certmonger' + attributes: 'NORMAL' + - path: '/var/lib/systemd/random-seed$' + attributes: 'PERMS' + + # init system + - path: '/etc/systemd' + attributes: 'NORMAL' + - path: '/etc/sysconfig' + attributes: 'NORMAL' + - path: '/etc/rc.d' + attributes: 'NORMAL' + - path: '/etc/tmpfiles.d' + attributes: 'NORMAL' + - path: '/etc/machine-id$' + attributes: 'NORMAL' + + # boot config + - path: '/etc/default' + attributes: 'NORMAL' + - path: '/etc/grub.d' + attributes: 'NORMAL' + - path: '/etc/grub2.cfg$' + attributes: 'NORMAL' + - path: '/etc/dracut.conf$' + attributes: 'NORMAL' + - path: '/etc/dracut.conf.d' + attributes: 'NORMAL' + + # glibc linker + - path: '/etc/ld.so.cache$' + attributes: 'NORMAL' + - path: '/etc/ld.so.conf$' + attributes: 'NORMAL' + - path: '/etc/ld.so.conf.d' + attributes: 'NORMAL' + - path: '/etc/ld.so.preload$' + attributes: 'NORMAL' + + # kernel config + - path: '/etc/sysctl.conf$' + attributes: 'NORMAL' + - path: '/etc/sysctl.d' + attributes: 'NORMAL' + - path: '/etc/modprobe.d' + attributes: 'NORMAL' + - path: '/etc/modules-load.d' + attributes: 'NORMAL' + - path: '/etc/depmod.d' + attributes: 'NORMAL' + - path: '/etc/udev' + attributes: 'NORMAL' + - path: '/etc/crypttab$' + attributes: 'NORMAL' + + #### Daemons #### + + # cron jobs + - path: '/var/spool/at' + attributes: 'CONTENT' + - path: '/etc/at.allow$' + attributes: 'CONTENT' + - path: '/etc/at.deny$' + attributes: 'CONTENT' + - path: '/etc/anacrontab$' + attributes: 'NORMAL' + - path: '/etc/cron.allow$' + attributes: 'NORMAL' + - path: '/etc/cron.deny$' + attributes: 'NORMAL' + - path: '/etc/cron.d' + attributes: 'NORMAL' + - path: '/etc/cron.daily' + attributes: 'NORMAL' + - path: '/etc/cron.hourly' + attributes: 'NORMAL' + - path: '/etc/cron.monthly' + attributes: 'NORMAL' + - path: '/etc/cron.weekly' + attributes: 'NORMAL' + - path: '/etc/crontab$' + attributes: 'NORMAL' + - path: '/var/spool/cron/root' + attributes: 'CONTENT' + + # time keeping + - path: '/etc/chrony.conf$' + attributes: 'NORMAL' + - path: '/etc/chrony.keys$' + attributes: 'NORMAL' + + # mail + - path: '/etc/aliases$' + attributes: 'NORMAL' + - path: '/etc/aliases.db$' + attributes: 'NORMAL' + - path: '/etc/postfix' + attributes: 'NORMAL' + + # ssh + - path: '/etc/ssh/sshd_config$' + attributes: 'NORMAL' + - path: '/etc/ssh/ssh_config$' + attributes: 'NORMAL' + + # stunnel + - path: '/etc/stunnel' + attributes: 'NORMAL' + + # ftp + - path: '/etc/vsftpd' + attributes: 'CONTENT' + + # printing + - path: '/etc/cups' + attributes: 'NORMAL' + - path: '/etc/cupshelpers' + attributes: 'NORMAL' + - path: '/etc/avahi' + attributes: 'NORMAL' + + # web server + - path: '/etc/httpd' + attributes: 'NORMAL' + + # dns + - path: '/etc/named' + attributes: 'NORMAL' + - path: '/etc/named.conf$' + attributes: 'NORMAL' + - path: '/etc/named.iscdlv.key$' + attributes: 'NORMAL' + - path: '/etc/named.rfc1912.zones$' + attributes: 'NORMAL' + - path: '/etc/named.root.key$' + attributes: 'NORMAL' + + # xinetd + - path: '/etc/xinetd.conf$' + attributes: 'NORMAL' + - path: '/etc/xinetd.d' + attributes: 'NORMAL' + + # IPsec + - path: '/etc/ipsec.conf$' + attributes: 'NORMAL' + - path: '/etc/ipsec.secrets$' + attributes: 'NORMAL' + - path: '/etc/ipsec.d' + attributes: 'NORMAL' + + # USBGuard + - path: '/etc/usbguard' + attributes: 'NORMAL' + + # Now everything else + - path: '/etc' + attributes: 'PERMS' + + # This gets new/removes-old filenames daily + - path: '/var/log/sa' + type: 'negative' + # As we are checking it, we've truncated yesterdays size to zero. + - path: '/var/log/aide.log' + type: 'negative' + + # With AIDE's default verbosity level of 5, these would give lots of + # warnings upon tree traversal. It might change with future version. + # + # =/lost\+found DIR + # =/home DIR + + # Ditto /var/log/sa reason... + - path: '/var/log/httpd' + type: 'negative' + # /boot/grub2/grubenv's timestamp is getting modified continuously due to "boot_success" implementation + - path: '/boot/grub2/grubenv' + type: 'negative' +aide__timer_enabled: true +aide__timer_state: 'started' diff --git a/roles/aide/handlers/main.yml b/roles/aide/handlers/main.yml new file mode 100644 index 00000000..631718e1 --- /dev/null +++ b/roles/aide/handlers/main.yml @@ -0,0 +1,40 @@ +# re-baseline after the role changed /etc/aide.conf, its own units or the timer's enable +# symlink, otherwise the next check reports every path the new rules add or drop, and the +# role's own files. only when a database existed before this run (a new one is initialised +# from the new config anyway), the check unit existed as well (otherwise the database was not +# created by this role, see tasks/main.yml), and the last check had not reported changes: +# re-baselining then would silently accept whatever drifted, an intrusion included. this is +# the same guard the system_update role applies after an update. +# `aide --update` exits with the bitmask of what it found (1 added, 2 removed, 4 changed), +# errors start at 14. +- name: 'aide: aide --update' + ansible.builtin.command: 'aide --update' + register: '__aide__update_result' + changed_when: true + failed_when: '__aide__update_result["rc"] >= 8' + notify: 'aide: mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz' + listen: 'aide: update database' + when: + - '__aide__db_stat_result["stat"]["exists"] | bool' + - '__aide__check_unit_stat_result["stat"]["exists"] | bool' + - '__aide__check_failed_result["rc"] != 0' + +- name: 'aide: mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz' + ansible.builtin.command: 'mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz' + changed_when: true + +- name: 'aide: report that the database was left alone' + ansible.builtin.debug: + msg: '{{ __aide__end_of_play_message }}' + listen: 'aide: update database' + when: + - '__aide__db_stat_result["stat"]["exists"] | bool' + - 'not (__aide__check_unit_stat_result["stat"]["exists"] | bool) or __aide__check_failed_result["rc"] == 0' + +- name: 'aide: collect the message for the end of the play' + ansible.builtin.set_fact: + __shared__end_of_play_messages: '{{ __shared__end_of_play_messages | d([]) + [__aide__end_of_play_message] }}' + listen: 'aide: update database' + when: + - '__aide__db_stat_result["stat"]["exists"] | bool' + - 'not (__aide__check_unit_stat_result["stat"]["exists"] | bool) or __aide__check_failed_result["rc"] == 0' diff --git a/roles/aide/meta/argument_specs.yml b/roles/aide/meta/argument_specs.yml new file mode 100644 index 00000000..cef1e0ec --- /dev/null +++ b/roles/aide/meta/argument_specs.yml @@ -0,0 +1,46 @@ +argument_specs: + main: + options: + + aide__check_on_calendar: + type: 'str' + required: false + description: 'When aide-check.timer runs the AIDE check, in systemd.time(7) format.' + + aide__rules__dependent_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'The rules in /etc/aide.conf. Dependent-role injection.' + + aide__rules__group_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'The rules in /etc/aide.conf. Group-level override.' + + aide__rules__host_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'The rules in /etc/aide.conf. Host-level override.' + + aide__timer_enabled: + type: 'bool' + required: false + default: true + description: 'Whether aide-check.timer is enabled at boot.' + + aide__timer_state: + type: 'str' + required: false + default: 'started' + choices: + - 'reloaded' + - 'restarted' + - 'started' + - 'stopped' + description: 'State of aide-check.timer.' diff --git a/roles/aide/tasks/main.yml b/roles/aide/tasks/main.yml new file mode 100644 index 00000000..239b0d24 --- /dev/null +++ b/roles/aide/tasks/main.yml @@ -0,0 +1,224 @@ +- block: + + # a rule without `path` needs no assert here: combine_lod already aborts on an item that + # lacks its unique key, naming the keys the item sets. + - name: 'Check that every rule has a valid type' + ansible.builtin.assert: + that: + - 'item["type"] | d("regular") in ["equal", "negative", "regular"]' + quiet: true + fail_msg: 'aide__rules: the rule for "{{ item["path"] }}" has type "{{ item["type"] | d("regular") }}". Supported types: equal, negative, regular.' + loop: '{{ aide__rules__combined_var }}' + loop_control: + label: '{{ item["path"] }}' + + - name: 'Check that every regular and equal rule has attributes' + ansible.builtin.assert: + that: + - 'item["attributes"] is defined and item["attributes"] | length > 0' + quiet: true + fail_msg: 'aide__rules: the rule for "{{ item["path"] }}" needs `attributes`, since it is of type "{{ item["type"] | d("regular") }}".' + loop: '{{ aide__rules__combined_var }}' + loop_control: + label: '{{ item["path"] }}' + when: + - 'item["state"] | d("present") != "absent"' + - 'item["type"] | d("regular") in ["equal", "regular"]' + + tags: + - 'always' + + +- block: + + - name: 'Install aide' + ansible.builtin.package: + name: + - 'aide' + state: 'present' + + tags: + - 'aide' + + +- block: + + - name: 'Gather package facts' + ansible.builtin.package_facts: # yamllint disable-line rule:empty-values + check_mode: false + + - name: 'Set installed version fact' + ansible.builtin.set_fact: + __aide__installed_version: '{{ ansible_facts["packages"]["aide"][0]["version"] | regex_search("^\d+\.\d+") }}' + + - name: 'Assert that this role supports the installed version' + ansible.builtin.assert: + that: + - '__aide__installed_version in __aide__supported_versions' + quiet: true + fail_msg: 'aide {{ __aide__installed_version }} is not supported by this role. Supported versions: {{ __aide__supported_versions | join(", ") }}.' + + tags: + - 'aide' + - 'aide:configure' + + +- block: + + # these facts are taken before the role changes a monitored file (/etc/aide.conf, the units, + # the timer's enable symlink), since the handler has to know the state the host was in + # beforehand. without a database there is nothing to update: the init below creates it from + # the new config. + - name: 'stat /var/lib/aide/aide.db.gz' + ansible.builtin.stat: + path: '/var/lib/aide/aide.db.gz' + register: '__aide__db_stat_result' + + # a database without the check unit was not created by this role (CIS hardening, a manual + # cron job, ...), and no check of this role has ever vouched for it. re-baselining it would + # accept everything that changed since it was created, so the handler leaves it alone. + - name: 'stat /etc/systemd/system/aide-check.service' + ansible.builtin.stat: + path: '/etc/systemd/system/aide-check.service' + register: '__aide__check_unit_stat_result' + + # `aide --check` exits non-zero on any finding, so a failed aide-check.service is what "the + # last check reported changes" looks like. rc 0 means failed. a unit that does not exist yet + # reports rc 1, like one that never failed. + - name: 'systemctl is-failed aide-check.service' # noqa command-instead-of-module (read-only state query) + ansible.builtin.command: 'systemctl is-failed aide-check.service' + register: '__aide__check_failed_result' + changed_when: false + failed_when: false + check_mode: false + + tags: + - 'aide' + - 'aide:configure' + - 'aide:cron' + - 'aide:state' + + +- block: + + - name: 'Combined Rules' + ansible.builtin.debug: + var: 'aide__rules__combined_var' + + - name: 'Deploy /etc/aide.conf' + ansible.builtin.template: + backup: true + src: 'etc/aide.conf.j2' + dest: '/etc/aide.conf' + owner: 'root' + group: 'root' + mode: 0o600 + notify: 'aide: update database' + + - name: 'Remove rpmnew / rpmsave (and Debian equivalents)' + ansible.builtin.include_role: + name: 'shared' + tasks_from: 'remove-rpmnew-rpmsave.yml' + vars: + shared__remove_rpmnew_rpmsave_config_file: '/etc/aide.conf' + + tags: + - 'aide' + - 'aide:configure' + + +- block: + + - name: 'Deploy /etc/systemd/system/aide-check.service' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/aide-check.service.j2' + dest: '/etc/systemd/system/aide-check.service' + owner: 'root' + group: 'root' + mode: 0o644 + register: '__aide__service_unit_result' + notify: 'aide: update database' + + - name: 'Deploy /etc/systemd/system/aide-check.timer' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/aide-check.timer.j2' + dest: '/etc/systemd/system/aide-check.timer' + owner: 'root' + group: 'root' + mode: 0o644 + register: '__aide__timer_unit_result' + notify: 'aide: update database' + + - name: 'systemctl daemon-reload' # noqa no-handler (the timer tasks below need the new units) + ansible.builtin.systemd: + daemon_reload: true + when: + - '__aide__service_unit_result is changed or __aide__timer_unit_result is changed' + + tags: + - 'aide' + - 'aide:cron' + + +- block: + + # aide-check.service is not managed: it has no [Install] section and only runs when + # aide-check.timer triggers it. + # the enable symlink lives below /etc/systemd, which the check monitors, hence the notify. + - name: 'systemctl {{ aide__timer_enabled | bool | ternary("enable", "disable") }} aide-check.timer' + ansible.builtin.service: + name: 'aide-check.timer' + enabled: '{{ aide__timer_enabled | bool }}' + notify: 'aide: update database' + + - name: 'systemctl {{ aide__timer_state | regex_replace("p?ed$", "") }} aide-check.timer' + ansible.builtin.service: + name: 'aide-check.timer' + state: '{{ aide__timer_state }}' + + tags: + - 'aide' + - 'aide:state' + + +- block: + + # runs after everything else the role writes to monitored paths (/etc/aide.conf, the units + # and the timer's enable symlink under /etc/systemd), so the first check does not report the + # role's own files as added. aide --init writes database_out, not database_in, hence the mv. + - name: 'aide --init' + ansible.builtin.command: 'aide --init' + args: + creates: '/var/lib/aide/aide.db.gz' + register: '__aide__init_result' + + - name: 'mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz' # noqa no-handler (the database has to be in place before the handlers run) + ansible.builtin.command: 'mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz' + changed_when: true + when: + - '__aide__init_result is changed' + + tags: + - 'aide' + + +# in a file of its own, so that the Molecule scenario can run exactly this code. +- name: 'Update the AIDE database' + ansible.builtin.import_tasks: 'update-db.yml' + tags: + - 'aide:update_db' + - 'never' + + +- block: + + - name: 'Flush handlers' + ansible.builtin.meta: 'flush_handlers' + + tags: + - 'aide' + - 'aide:configure' + - 'aide:cron' + - 'aide:state' diff --git a/roles/aide/tasks/update-db.yml b/roles/aide/tasks/update-db.yml new file mode 100644 index 00000000..a0ddae9d --- /dev/null +++ b/roles/aide/tasks/update-db.yml @@ -0,0 +1,18 @@ +# the explicit "accept the current state" action, so it runs without the guard of the +# handler. `aide --update` exits with the bitmask of what it found (1 added, 2 removed, +# 4 changed), errors start at 14. +- name: 'aide --update' + ansible.builtin.command: 'aide --update' + register: '__aide__update_db_result' + changed_when: true + failed_when: '__aide__update_db_result["rc"] >= 8' + +- name: 'mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz' + ansible.builtin.command: 'mv /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz' + changed_when: true + +# the database now describes the host as it is, so the finding of the last check no longer +# applies. clearing the failed state is quicker than a full check. +- name: 'systemctl reset-failed aide-check.service' # noqa command-instead-of-module (no module resets a failed unit) + ansible.builtin.command: 'systemctl reset-failed aide-check.service' + changed_when: true diff --git a/roles/aide/templates/etc/aide.conf.j2 b/roles/aide/templates/etc/aide.conf.j2 new file mode 100644 index 00000000..5aa9495f --- /dev/null +++ b/roles/aide/templates/etc/aide.conf.j2 @@ -0,0 +1,116 @@ +# {{ ansible_managed }} +# 2026091801 +# Based on the /etc/aide.conf shipped with aide 0.19.2 on Rocky 9 and 10. The rule list is +# the same for every version, the options and groups follow the installed aide version. + +@@define DBDIR /var/lib/aide +@@define LOGDIR /var/log/aide + +{% if __aide__installed_version is ansible.builtin.version('0.17', '<') %} +# The location of the database to be read. +database=file:@@{DBDIR}/aide.db.gz +{% else %} +# The location of the database to be read. +database_in=file:@@{DBDIR}/aide.db.gz +{% endif %} + +# The location of the database to be written. +database_out=file:@@{DBDIR}/aide.db.new.gz + +# Whether to gzip the output to database +gzip_dbout=yes + +{% if __aide__installed_version is ansible.builtin.version('0.17', '<') %} +# Default. +verbose=5 +{% else %} +# Database attributes to include in report (H = all compiled hashsums, default) +database_attrs=H + +# Add metadata to database (version info, timestamps) +database_add_metadata=yes + +# Warn about unrestricted rules during config check (default: false) +config_check_warn_unrestricted_rules=false + +# Number of workers for parallel processing (default: 1, can use percentage) +num_workers=1 + +# Default. +log_level=warning +report_level=changed_attributes + +# Report format (plain or json) +report_format=plain + +# Group files in report by added/removed/changed +report_grouped=yes + +# Summarize changes in report +report_summarize_changes=yes + +# Don't report if no differences found +report_quiet=no + +# Report encoding (base64 is default, base16 available) +report_base16=no +{% endif %} + +report_url=file:@@{LOGDIR}/aide.log +report_url=stdout + +{% if __aide__installed_version is ansible.builtin.version('0.17', '<') %} +# aide 0.16 knows neither the sha3 / stribog hashsums nor the X group, and its R group +# still contains md5, so the groups are spelled out here with the same attributes the +# 0.19 definitions below resolve to. +# Verified against aide 0.16 on Rocky 8. +ALLXTRAHASHES = sha256+sha512 +EVERYTHING = R+ALLXTRAHASHES +NORMAL = p+ftype+i+l+n+u+g+s+acl+selinux+xattrs+sha512 +{% else %} +# Default groups in AIDE v0.19: +# R = p+ftype+i+l+n+u+g+s+m+c+sha3_256+X +# L = p+ftype+i+l+n+u+g+X +# > = Growing file p+ftype+l+u+g+i+n+s+growing+X +# H = all compiled in (and not deprecated) hashsums +# X = acl+selinux+xattrs+e2fsattrs+caps (if compiled in) +# E = Empty group +# Use 'aide --version' to list the default compound groups. + +ALLXTRAHASHES = sha256+sha512+sha512_256+sha3_256+sha3_512+stribog256+stribog512 +# Everything but access time (Ie. all changes) +EVERYTHING = R+ALLXTRAHASHES + +# Base + sha512 (strong) +NORMAL = R+sha512-m-c +{% endif %} + +# Content only - file type and strong hash +CONTENT = ftype+sha512 + +# For directories, don't bother doing hashes +DIR = ftype+p+i+l+n+u+g+acl+selinux+xattrs + +# Access control only +PERMS = ftype+p+u+g+acl+selinux+xattrs + +# Logfiles are special, in that they often change due to log rotation +# Track only: permissions, file type, user, group, number of links, SELinux context, extended attributes +# Allow new files (ANF) and allow removed files (ARF) due to log rotation techniques +LOG = p+ftype+u+g+n+ANF+ARF+selinux+xattrs + +# Some files get updated automatically, so the inode/ctime/mtime change +# but we want to know when the data inside them changes +DATAONLY = ftype+p+l+n+u+g+s+acl+selinux+xattrs+sha256 + +# Next decide what directories/files you want in the database. + +{% for rule in aide__rules__combined_var if rule['state'] | d('present') != 'absent' %} +{% if rule['type'] | d('regular') == 'negative' %} +!{{ rule['path'] }} +{% elif rule['type'] | d('regular') == 'equal' %} +={{ rule['path'] }} {{ rule['attributes'] }} +{% else %} +{{ rule['path'] }} {{ rule['attributes'] }} +{% endif %} +{% endfor %} diff --git a/roles/aide/templates/etc/systemd/system/aide-check.service.j2 b/roles/aide/templates/etc/systemd/system/aide-check.service.j2 new file mode 100644 index 00000000..717ed815 --- /dev/null +++ b/roles/aide/templates/etc/systemd/system/aide-check.service.j2 @@ -0,0 +1,15 @@ +# {{ ansible_managed }} +# 2026091801 + +[Unit] +Description=AIDE file integrity check +Documentation=man:aide(1) + +[Service] +Type=oneshot +# `aide --check` exits non-zero on any finding (1 added, 2 removed, 4 changed, 14+ errors), +# which leaves this unit in the failed state. monitoring and the system_update role rely on +# exactly that. +ExecStart=/usr/sbin/aide --check +IOSchedulingClass=idle +Nice=19 diff --git a/roles/aide/templates/etc/systemd/system/aide-check.timer.j2 b/roles/aide/templates/etc/systemd/system/aide-check.timer.j2 new file mode 100644 index 00000000..da26e367 --- /dev/null +++ b/roles/aide/templates/etc/systemd/system/aide-check.timer.j2 @@ -0,0 +1,11 @@ +# {{ ansible_managed }} +# 2026091801 + +[Unit] +Description=AIDE file integrity check timer + +[Timer] +OnCalendar={{ aide__check_on_calendar }} + +[Install] +WantedBy=timers.target diff --git a/roles/aide/vars/main.yml b/roles/aide/vars/main.yml new file mode 100644 index 00000000..7fcd69f1 --- /dev/null +++ b/roles/aide/vars/main.yml @@ -0,0 +1,9 @@ +# aide.conf.j2 branches on these major.minor versions: 0.16 (RHEL 8) and 0.19 (RHEL 9 and 10). +# 0.17 reworked the config options (log_level / report_level replace verbose) and 0.19 dropped +# `database` and the old hashsums, so a version outside this list would get a config it may +# not parse. +__aide__supported_versions: + - '0.16' + - '0.19' + +__aide__end_of_play_message: 'aide: /etc/aide.conf or the aide-check units have changed, but the AIDE database was not updated, since the last AIDE check had reported changes or no check deployed by this role has run against the database yet. Review /var/log/aide/aide.log or the output of `aide --check`, then run the playbook with `--tags aide:update_db` to accept the current state as the new baseline.' diff --git a/roles/system_update/README.md b/roles/system_update/README.md index e575d0a4..cc431c28 100644 --- a/roles/system_update/README.md +++ b/roles/system_update/README.md @@ -15,7 +15,7 @@ Reboots are not performed by the update scripts themselves. They are delegated t * **The security lane is enabled by default, but a no-op without the `security` repository.** That repository is provided by the [repo_baseos](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_baseos) role. On hosts where it is not present, the security lane installs nothing and requests no reboot. Turn the lane off entirely with `system_update__security_enabled: false`. * **The update hooks run in both lanes, but only around a real transaction.** `system_update__pre_update_code` and `system_update__post_update_code` are run by the regular lane and, by default, by the security lane too. The security lane fires daily and exits on most days without installing anything, so it reaches the hooks only once the `security` repository actually has hot-fixes pending. Code that aborts the update or reports a failed pre-condition therefore stays quiet on the days there is nothing to install. Where a daily hot-fix does not warrant what a weekly full update does, `system_update__security_pre_update_code` and `system_update__security_post_update_code` override the security lane's half, `''` included. * **A failed update stops the run.** A metadata refresh or an upgrade that exits non-zero sends a "System update failed" mail with the error output and ends the run, on both families. Nothing downstream happens: no AIDE re-baseline, no reboot request, and no success mail for a host that is left half-configured. -* **AIDE is re-baselined only when the check was clean beforehand.** If the host runs an AIDE check lane and the update changed packages, the regular lane refreshes the AIDE database afterwards and restarts the check, so the next check does not flag every file the update touched. If the check was already reporting changes when the update started, the database is left untouched and the log is kept at `/var/log/aide/aide.log-pre-system-update`: drift that predates the update, an intrusion included, is never accepted as the new baseline. +* **AIDE is re-baselined only when the check was clean beforehand.** If the host runs an AIDE check lane (role: [linuxfabrik.lfops.aide](https://github.com/Linuxfabrik/lfops/tree/main/roles/aide)) and the update changed packages, the lane that ran it refreshes the AIDE database afterwards and restarts the check, so the next check does not flag every file the update touched. This applies to the regular and the security lane alike. If the check was already reporting changes when the update started, the database is left untouched and the log is kept at `/var/log/aide/aide.log-pre-system-update` (regular lane) or `/var/log/aide/aide.log-pre-security-update` (security lane): drift that predates the update, an intrusion included, is never accepted as the new baseline. ## Dependent Roles diff --git a/roles/system_update/templates/usr/local/sbin/security-update.j2 b/roles/system_update/templates/usr/local/sbin/security-update.j2 index bc546109..f8480f73 100644 --- a/roles/system_update/templates/usr/local/sbin/security-update.j2 +++ b/roles/system_update/templates/usr/local/sbin/security-update.j2 @@ -1,6 +1,6 @@ #!/usr/bin/env bash # {{ ansible_managed }} -# 2026091801 +# 2026091802 # Apply Rocky Linux security hot-fixes from the dedicated `security` repository # only, then request a reboot if one is needed by dropping a file into the @@ -126,6 +126,37 @@ elif [ "$retc" -ne 100 ]; then exit 1 fi +# The aide rpm ships no check unit, so aide-check.timer / aide-check.service are the +# names the AIDE lane is deployed under here. aide defaults to /etc/aide.conf and keeps +# the database gzipped. The same values as the RedHat branch of update-and-reboot, so the +# AIDE code of both lanes stays identical. +# Verified against aide 0.19.2 on Rocky 9. +AIDE_CONFIG='/etc/aide.conf' +AIDE_DB='/var/lib/aide/aide.db.gz' +AIDE_DB_NEW='/var/lib/aide/aide.db.new.gz' +AIDE_UNIT='aide-check' + +# remember whether AIDE was already reporting changes before the transaction, the same +# way update-and-reboot does: re-baselining an already-failing check would silently +# accept whatever drifted since the last check, an intrusion included. Two deliberate +# differences to update-and-reboot: this sits below the gates above, since this lane runs +# daily and would otherwise mail a failing check every day, also when there is nothing to +# install. And the log is copied to a name of its own, since on a day both lanes run the +# second copy would otherwise overwrite the first with a log that already contains the +# changes of the first update. +# Verified against aide 0.19.2 on Rocky 9 and 10 (re-baseline after a hot-fix) and aide 0.16 +# on Rocky 8 (check already failing, database left alone). +AIDE_WAS_CLEAN=0 +if systemctl is-active --quiet "${AIDE_UNIT}.timer"; then + if systemctl is-failed --quiet "${AIDE_UNIT}.service"; then + log "${AIDE_UNIT} was already failing before the security update; the database will not be re-baselined" + cp /var/log/aide/aide.log /var/log/aide/aide.log-pre-security-update + send_msg "$SUBJECT_PREFIX - ${AIDE_UNIT}.service state was failed before Security Update" "Please check the logfile at /var/log/aide/aide.log-pre-security-update (saved before the update ran)." + else + AIDE_WAS_CLEAN=1 + fi +fi + {% if system_update__security_pre_update_code | length %} # below the gates above, not at the top of the script: this lane runs daily and exits # on most days without installing anything, while the pre-update code guards a @@ -160,6 +191,15 @@ else TXN_REPORT=$(yum history info "$TXN_AFTER") fi +# re-baseline AIDE so the database describes the patched filesystem and the next check +# does not flag every file the hot-fix touched. Only when this run changed packages and +# the check was passing beforehand, see above. +if [ "$AIDE_WAS_CLEAN" -eq 1 ] && [ "$UPDATED" -eq 1 ]; then + aide --config="$AIDE_CONFIG" --update 1> /dev/null + \mv "$AIDE_DB_NEW" "$AIDE_DB" + systemctl restart "${AIDE_UNIT}.service" +fi + # request a reboot if a core component (kernel, glibc, ...) needs it, or if running # services still use the pre-update libraries. needs-restarting --reboothint > /tmp/security-reboothint 2>&1