Auditd rules that matter for compliance

Watch the files and syscalls auditors actually ask about, tune rules by key, and keep signal high without drowning auditd in noise nobody reads.

Dec 17, 2024·Updated ·5 min readIntermediate·By SecOpsLog · documentation-verified

The audit subsystem records what the kernel did on behalf of whom: which uid opened /etc/shadow for writing, which process called init_module, who ran a setuid binary. Compliance frameworks name the objects they want watched, which makes the first ruleset easy, and say nothing about volume; a ruleset copied straight from a benchmark fills /var/log/audit in a week and gets disabled. The rules below are organised by the question they answer, tagged with a key per question, and filtered so the log contains events a person will read.

Rules by the question they answer

QuestionRuleKey
who changed a user, group or password?-w /etc/passwd -p wa, same for /etc/group, /etc/shadow, /etc/gshadowidentity
who changed who can become root?-w /etc/sudoers -p wa, -w /etc/sudoers.d/ -p wapriv_esc
who loaded or unloaded a kernel module?-a always,exit -F arch=b64 -S init_module,finit_module,delete_modulemodules
which human ran a privileged program?-a always,exit -F arch=b64 -S execve -C uid!=euid -F auid>=1000 -F auid!=unsetsetuid_exec
who changed the audit configuration itself?-w /etc/audit/ -p wa, -w /etc/audit/rules.d/ -p waaudit_config
who touched SSH trust?-w /etc/ssh/sshd_config.d/ -p wa, -w /etc/ssh/user_ca.pub -p wassh_trust

Two details in that table are the ones benchmarks tend to miss. finit_module is the syscall modprobe and insmod use on current kernels; a rule that watches only init_module records nothing when a module is loaded from a file. And the auid filters on the execution rule exclude system daemons (auid!=unset, which is the 4294967295 sentinel) and service accounts below 1000, so the events left are humans and the session they logged in from, the only audience a reviewer has time for.

/etc/audit/rules.d/50-controls.rules
## reset, then sizing: backlog for bursts, panic-free failure mode
-D
-b 8192
-f 1
## identity and privilege
-w /etc/passwd -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/sudoers -p wa -k priv_esc
-w /etc/sudoers.d/ -p wa -k priv_esc
## kernel modules: finit_module is what modprobe calls
-a always,exit -F arch=b64 -S init_module,finit_module,delete_module -k modules
-a always,exit -F arch=b32 -S init_module,finit_module,delete_module -k modules
## privileged execution by a logged-in human
-a always,exit -F arch=b64 -S execve -C uid!=euid -F auid>=1000 -F auid!=unset -k setuid_exec
## the audit system and ssh trust
-w /etc/audit/ -p wa -k audit_config
-w /etc/ssh/sshd_config.d/ -p wa -k ssh_trust
## last: make the configuration immutable until reboot (uncomment when the set is final)
# -e 2

Load, trigger, search, then persist

The rules directory is merged into /etc/audit/audit.rules by augenrules, the same step the service runs at boot; augenrules --load does it immediately. Testing a rule is three commands: load it, trigger the event it describes, search by its key and confirm exactly one event appears with the fields you expect. A rule that produces zero events is misaddressed; a rule that produces a hundred for one trigger needs a filter.

bash — prove a rule before the reboot proves it for you
augenrules --load && auditctl -l | grep -c .
16
sudo touch /etc/sudoers.d/probe && sudo rm /etc/sudoers.d/probe
ausearch -k priv_esc -ts recent -i | grep -E "^type=(SYSCALL|PATH)" | head -4
type=SYSCALL … syscall=openat success=yes … auid=alice uid=root … comm=touch exe=/usr/bin/touch key=priv_esc
type=PATH … name=/etc/sudoers.d/probe … nametype=CREATE
-i turns uids and syscall numbers into names; auid is the login user, uid is root through sudo
aureport -k --summary | head -8
1240 identity
38 priv_esc
0 modules

aureport -k --summary is the volume check to run after a day: a key with thousands of events on a host where nothing changed is a rule matching something routine (a configuration-management run rewriting /etc/passwd on every apply, for example), and the fix is a narrower rule or an exclusion rather than a bigger disk. -e 2 at the end of the last file makes the loaded configuration immutable until reboot, so an attacker with root cannot quietly remove the rule that would record them; it also means you cannot change rules without a reboot, so it goes in last and stays commented out until the set has settled.

The rule that fills the disk

-a always,exit -S execve with no filter records every process start on the host, which on a build server or a container node is millions of events a day, most of them from a health check. If execution auditing is required, filter it: by auid as above, by -F exe= for a specific binary, or with an exclusion rule (-a never,exit -F exe=/usr/bin/node-exporter) placed before the matching rule, because rules are evaluated in order and the first match decides. Measure with aureport on a staging host under production-like load before the rule reaches the fleet, and set max_log_file_action and space_left_action in auditd.conf so the failure mode when the disk fills is a rotated log, not a halted host.

Logs on the host are logs an attacker with root can delete
Forward audit events off the host as they are written: audisp-remote, the rsyslog or journald path, or a shipper reading /var/log/audit. With -e 2 the rules cannot be removed, but the log files can, so the copy that survives an intrusion is the one that already left. Keep local retention for the days when the collector is the thing that is broken.

Reading the raw log is the next problem: one logical event is several type= records sharing an id. ausearch -i joins them on a single host; a short Python parser joins them for a fleet. The journald side of host logging, and how the two streams end up in the same place, is in journald persistence and forwarding.

Related posts

Quick reference