Privileged Command Use Not Audited
Checks that the audit system records when ordinary users run privileged (setuid or setgid) programs.
- Why It Matters
- Programs such as sudo, su, passwd and mount run with elevated rights. Recording each execution by a real user (auid at or above the first normal user id) shows who used privilege and when, which is the core of a usable audit trail.
- How It Detects
- Requires at least one execution rule that combines -F perm=x with an -F auid>= filter (the usual form for privileged programs), or an execve rule filtered on euid=0. Reads the active lines of /etc/audit/rules.d/*.rules and /etc/audit/audit.rules; the audit log itself is never read. The loaded rule set is not read (auditctl is not on the read-only allow-list), so when the rules are in the files the allow-listed systemctl is-active auditd must report active for a Pass; otherwise the result is Incomplete, and the evidence says the loaded rules were not verified.
- How to Fix
- Generate rules for every setuid and setgid program: find / -xdev \( -perm -4000 -o -perm -2000 \) -type f | awk '{print "-a always,exit -F path=" $1 " -F perm=x -F auid>=1000 -F auid!=unset -k privileged"}' > /etc/audit/rules.d/50-privileged.rules then augenrules --load.
- How to Verify
auditctl -l | grep 'perm=x'- CVSS v3.1 Vector
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N- Control Category
- Logging and Monitoring: CIS Controls v8 8; NIST CSF 2.0 PR.PS, DE.CM
- Fix Effort
- Low
- CIS Controls v8 Safeguard
- 8.5
- NIST CSF 2.0 Subcategory
- PR.PS-04
- CWE
- CWE-778
- OWASP Top 10
- A09:2021
- NIST SP 800-53 Rev. 5
- AU-2, AU-12, AC-6(9)
- Benchmark Topic
- Audit use of privileged commands (CIS Controls v8 8.5) (topic only, no benchmark text)
- References
- auditctl(8), Linux man-pages
NIST SP 800-92, Guide to Computer Security Log Management
NIST SP 800-53 Rev. 5, Security and Privacy Controls
Source Code
The complete file that defines this check. Run it with openvapt scan --only HOST-AUD-006, or read it offline with openvapt explain HOST-AUD-006.
internal/checks/host/audit_journal.go · Lines 1-451View on GitHub at This Commit
| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "regexp" |
| 7 | "strings" |
| 8 | |
| 9 | "github.com/openvapt/openvapt/internal/check" |
| 10 | "github.com/openvapt/openvapt/internal/checks/collect" |
| 11 | "github.com/openvapt/openvapt/internal/sys" |
| 12 | ) |
| 13 | |
| 14 | // HOST-AUD: depth checks on the Linux audit daemon, its rule set and log |
| 15 | // files, and on journald and syslog forwarding. HOST-LOG-002 asks whether |
| 16 | // auditd is running; these checks ask whether what it records is useful and |
| 17 | // protected. Only configuration files are read; the audit log itself is |
| 18 | // never opened. |
| 19 | |
| 20 | var ( |
| 21 | refAuditctl = check.Ref{Title: "auditctl(8), Linux man-pages", URL: "https://man7.org/linux/man-pages/man8/auditctl.8.html"} |
| 22 | refAuditdConf = check.Ref{Title: "auditd.conf(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/auditd.conf.5.html"} |
| 23 | refNIST80092 = check.Ref{Title: "NIST SP 800-92, Guide to Computer Security Log Management", URL: "https://csrc.nist.gov/pubs/sp/800/92/final"} |
| 24 | refJournald = check.Ref{Title: "journald.conf(5), systemd", URL: "https://www.freedesktop.org/software/systemd/man/latest/journald.conf.html"} |
| 25 | refRsyslog = check.Ref{Title: "rsyslog.conf(5), Debian trixie manual pages (forwarding actions)", URL: "https://manpages.debian.org/trixie/rsyslog/rsyslog.conf.5.en.html"} |
| 26 | ) |
| 27 | |
| 28 | // auditState is the outcome of looking for audit rule files. |
| 29 | type auditState int |
| 30 | |
| 31 | const ( |
| 32 | auditRead auditState = iota // rule files read (possibly none exist) |
| 33 | auditAbsent // /etc/audit does not exist: auditd is not installed |
| 34 | auditUnreadble // rule files exist but could not be read |
| 35 | ) |
| 36 | |
| 37 | // auditRules returns the active lines of every audit rule file. |
| 38 | func auditRules(ctx context.Context, s sys.System) ([]string, auditState) { |
| 39 | if !exists(s, "/etc/audit") { |
| 40 | return nil, auditAbsent |
| 41 | } |
| 42 | files, unreadable := readAll(ctx, s, "/etc/audit/rules.d/*.rules", "/etc/audit/audit.rules") |
| 43 | if len(files) == 0 && len(unreadable) > 0 { |
| 44 | return nil, auditUnreadble |
| 45 | } |
| 46 | var lines []string |
| 47 | for _, f := range files { |
| 48 | lines = append(lines, activeLines(f.Text)...) |
| 49 | } |
| 50 | return lines, auditRead |
| 51 | } |
| 52 | |
| 53 | // ruleWatches reports whether any rule watches path (-w path, -F path=path |
| 54 | // or -F dir=path). A watch on a parent directory also counts. |
| 55 | func ruleWatches(lines []string, path string) bool { |
| 56 | for _, l := range lines { |
| 57 | f := strings.Fields(l) |
| 58 | for i, t := range f { |
| 59 | var p string |
| 60 | switch { |
| 61 | case t == "-w" && i+1 < len(f): |
| 62 | p = f[i+1] |
| 63 | case strings.HasPrefix(t, "path="): |
| 64 | p = strings.TrimPrefix(t, "path=") |
| 65 | case strings.HasPrefix(t, "dir="): |
| 66 | p = strings.TrimPrefix(t, "dir=") |
| 67 | default: |
| 68 | continue |
| 69 | } |
| 70 | if strings.TrimSuffix(p, "/") == strings.TrimSuffix(path, "/") || strings.HasPrefix(path, strings.TrimSuffix(p, "/")+"/") { |
| 71 | return true |
| 72 | } |
| 73 | } |
| 74 | } |
| 75 | return false |
| 76 | } |
| 77 | |
| 78 | // ruleSyscall reports whether any rule audits the named system call. |
| 79 | func ruleSyscall(lines []string, name string) bool { |
| 80 | for _, l := range lines { |
| 81 | f := strings.Fields(l) |
| 82 | for i, t := range f { |
| 83 | if t != "-S" || i+1 >= len(f) { |
| 84 | continue |
| 85 | } |
| 86 | for _, sc := range strings.Split(f[i+1], ",") { |
| 87 | if sc == name || sc == "all" { |
| 88 | return true |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | } |
| 93 | return false |
| 94 | } |
| 95 | |
| 96 | // auditRuleCheck registers one check that requires a set of audit rules. |
| 97 | // missing returns the rules that are absent, in plain words. |
| 98 | func auditRuleCheck(id, title, cvss string, sev check.Severity, summary, rationale, detection, fix, verify, cisTopic string, nist []string, missing func(lines []string) []string) { |
| 99 | check.Register(&check.Check{ |
| 100 | ID: id, Title: title, Domain: check.DomainHost, |
| 101 | Severity: sev, Category: check.CatLogging, Effort: check.EffortLow, |
| 102 | CVSS: cvss, |
| 103 | Summary: summary, |
| 104 | Rationale: rationale, |
| 105 | Detection: detection + " Reads the active lines of /etc/audit/rules.d/*.rules and /etc/audit/audit.rules; the audit log itself is never read. The loaded rule set is not read (auditctl is not on the read-only allow-list), so when the rules are in the files the allow-listed systemctl is-active auditd must report active for a Pass; otherwise the result is Incomplete, and the evidence says the loaded rules were not verified.", |
| 106 | Fix: fix, |
| 107 | Verify: verify, |
| 108 | Refs: []check.Ref{refAuditctl, refNIST80092, refNIST80053}, |
| 109 | Standards: check.Standards{CWE: []string{"CWE-778"}, OWASP: []string{"A09:2021"}, NIST: nist, CISTopic: cisTopic}, |
| 110 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 111 | lines, st := auditRules(ctx, s) |
| 112 | switch st { |
| 113 | case auditAbsent: |
| 114 | return []check.Result{check.SkipR("audit rules", "auditd is not installed (/etc/audit is missing); see HOST-LOG-002")} |
| 115 | case auditUnreadble: |
| 116 | return []check.Result{check.IncompleteR("audit rules", "Audit rule files exist but could not be read (run as root for this check)")} |
| 117 | } |
| 118 | if m := missing(lines); len(m) > 0 { |
| 119 | return []check.Result{check.FailR("audit rules", "Audit rules are missing", m...)} |
| 120 | } |
| 121 | // The loaded rule set (auditctl -l) is not read: auditctl is not |
| 122 | // on the allow-list. Rules on disk only take effect while auditd |
| 123 | // runs, so a Pass needs the daemon active. |
| 124 | const unverified = "loaded rules were not verified (auditctl -l is not run); the rule files on disk were read" |
| 125 | out, err := s.Run(ctx, "systemctl", "is-active", "auditd") |
| 126 | state := strings.TrimSpace(out) |
| 127 | if err != nil && state == "" { |
| 128 | return []check.Result{check.IncompleteR("audit rules", "The required rules are in the rule files, but whether auditd is running could not be confirmed (systemctl is-active auditd did not run)", unverified)} |
| 129 | } |
| 130 | if state != "active" { |
| 131 | return []check.Result{check.IncompleteR("audit rules", "The required rules are in the rule files, but auditd is "+state+", so they are not loaded", unverified, "systemctl is-active auditd: "+state)} |
| 132 | } |
| 133 | return []check.Result{check.PassR("audit rules", "The required audit rules are present in the rule files and auditd is active", unverified)} |
| 134 | }, |
| 135 | }) |
| 136 | } |
| 137 | |
| 138 | var lowAudit = "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N" |
| 139 | |
| 140 | // remoteLog matches an rsyslog forwarding action: a selector followed by @host |
| 141 | // or @@host, or the omfwd module. |
| 142 | var remoteLog = regexp.MustCompile(`^\S+\s+@{1,2}\S|omfwd|target=`) |
| 143 | |
| 144 | func init() { |
| 145 | check.Register(&check.Check{ |
| 146 | ID: "HOST-AUD-001", Title: "Audit Daemon Not Enabled at Boot", Domain: check.DomainHost, |
| 147 | Severity: check.Low, Category: check.CatLogging, Effort: check.EffortLow, |
| 148 | CVSS: lowAudit, |
| 149 | Summary: "Checks that the audit daemon (auditd) is set to start at every boot, not only running now.", |
| 150 | Rationale: "An audit daemon that was started by hand stops recording at the next reboot, leaving a gap exactly when an attacker who forced a restart would want one. HOST-LOG-002 reports whether auditd is running; this check reports whether it will come back.", |
| 151 | Detection: "Runs the allow-listed read-only command systemctl is-enabled auditd and reads the single word it prints: enabled, static, alias or indirect pass; disabled, masked or enabled-runtime fail. If the command cannot run and /etc/audit exists the result is Incomplete; if /etc/audit does not exist the check is skipped.", |
| 152 | Fix: "systemctl enable auditd (on some distributions: systemctl enable --now auditd). Then confirm the rules in /etc/audit/rules.d load at boot with augenrules --check.", |
| 153 | Verify: "systemctl is-enabled auditd should print enabled.", |
| 154 | Refs: []check.Ref{{Title: "auditd(8), Linux man-pages", URL: "https://man7.org/linux/man-pages/man8/auditd.8.html"}, refNIST80092}, |
| 155 | Standards: check.Standards{CWE: []string{"CWE-778"}, OWASP: []string{"A09:2021"}, NIST: []string{"AU-2", "AU-12"}, CISTopic: "Audit daemon enabled at boot (CIS Controls v8 8.2)"}, |
| 156 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 157 | out, err := s.Run(ctx, "systemctl", "is-enabled", "auditd") |
| 158 | state := strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]) |
| 159 | switch state { |
| 160 | case "enabled", "static", "alias", "indirect": |
| 161 | return []check.Result{check.PassR("auditd", "auditd is "+state)} |
| 162 | case "disabled", "masked", "masked-runtime", "enabled-runtime": |
| 163 | return []check.Result{check.FailR("auditd", "auditd is "+state+", so it will not record after the next reboot")} |
| 164 | } |
| 165 | if err != nil && !exists(s, "/etc/audit") { |
| 166 | return []check.Result{check.SkipR("auditd", "auditd is not installed (/etc/audit is missing); see HOST-LOG-002")} |
| 167 | } |
| 168 | return []check.Result{check.IncompleteR("auditd", "systemctl is-enabled auditd gave no usable answer")} |
| 169 | }, |
| 170 | }) |
| 171 | |
| 172 | auditRuleCheck("HOST-AUD-002", "Identity Files Not Audited", lowAudit, check.Low, |
| 173 | "Checks that changes to the account, group, password and sudo files are recorded by the audit system.", |
| 174 | "An attacker who adds an account, joins a privileged group or grants themselves sudo edits one of a handful of files. A watch rule on each makes that change an audit event with the user, process and time attached; without it the change leaves no trace beyond the file itself.", |
| 175 | "Requires a watch (-w or -F path=) on /etc/passwd, /etc/group, /etc/shadow, /etc/gshadow, /etc/sudoers and /etc/sudoers.d.", |
| 176 | "Add to /etc/audit/rules.d/50-identity.rules: -w /etc/passwd -p wa -k identity and the same for /etc/group, /etc/shadow, /etc/gshadow, /etc/sudoers and /etc/sudoers.d/ , then run augenrules --load.", |
| 177 | "auditctl -l | grep -E 'passwd|shadow|sudoers'", |
| 178 | "Audit changes to identity files (CIS Controls v8 8.5)", []string{"AU-2", "AU-12", "AC-2(4)"}, |
| 179 | func(lines []string) []string { |
| 180 | var m []string |
| 181 | for _, p := range []string{"/etc/passwd", "/etc/group", "/etc/shadow", "/etc/gshadow", "/etc/sudoers", "/etc/sudoers.d"} { |
| 182 | if !ruleWatches(lines, p) { |
| 183 | m = append(m, "no watch on "+p) |
| 184 | } |
| 185 | } |
| 186 | return m |
| 187 | }) |
| 188 | |
| 189 | auditRuleCheck("HOST-AUD-003", "Login and Logout Events Not Audited", lowAudit, check.Low, |
| 190 | "Checks that the files recording logins, failed logins and sessions are watched by the audit system.", |
| 191 | "Login records are the first thing an investigator asks for and the first thing an intruder cleans. Watching lastlog, the failed-login store and the wtmp and btmp session files makes tampering itself an audit event.", |
| 192 | "Requires a watch on /var/log/lastlog, on a failed-login store (/var/log/faillog, /var/log/faillock, /var/run/faillock or /var/log/tallylog), and on /var/log/wtmp and /var/log/btmp.", |
| 193 | "Add to /etc/audit/rules.d/50-login.rules: -w /var/log/lastlog -p wa -k logins , -w /var/run/faillock -p wa -k logins , -w /var/log/wtmp -p wa -k session , -w /var/log/btmp -p wa -k session , then augenrules --load.", |
| 194 | "auditctl -l | grep -E 'lastlog|faillock|wtmp|btmp'", |
| 195 | "Audit login, logout and session events (CIS Controls v8 8.5)", []string{"AU-2", "AU-12", "AC-7"}, |
| 196 | func(lines []string) []string { |
| 197 | var m []string |
| 198 | if !ruleWatches(lines, "/var/log/lastlog") { |
| 199 | m = append(m, "no watch on /var/log/lastlog") |
| 200 | } |
| 201 | if !ruleWatches(lines, "/var/log/faillog") && !ruleWatches(lines, "/var/log/faillock") && !ruleWatches(lines, "/var/run/faillock") && !ruleWatches(lines, "/var/log/tallylog") { |
| 202 | m = append(m, "no watch on the failed-login store (/var/run/faillock or /var/log/faillog)") |
| 203 | } |
| 204 | for _, p := range []string{"/var/log/wtmp", "/var/log/btmp"} { |
| 205 | if !ruleWatches(lines, p) { |
| 206 | m = append(m, "no watch on "+p) |
| 207 | } |
| 208 | } |
| 209 | return m |
| 210 | }) |
| 211 | |
| 212 | auditRuleCheck("HOST-AUD-004", "System Time Changes Not Audited", lowAudit, check.Low, |
| 213 | "Checks that the audit system records changes to the system clock and time zone.", |
| 214 | "Moving the clock is a classic way to confuse log timelines, forge timestamps or defeat certificate and token expiry. Auditing the time-setting system calls and /etc/localtime attributes every change to a user and process.", |
| 215 | "Requires a rule on the system calls adjtimex, settimeofday and clock_settime, and a watch on /etc/localtime.", |
| 216 | "Add to /etc/audit/rules.d/50-time.rules: -a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time-change (and the same with arch=b32), plus -w /etc/localtime -p wa -k time-change , then augenrules --load.", |
| 217 | "auditctl -l | grep -E 'settimeofday|localtime'", |
| 218 | "Audit date and time modification (CIS Controls v8 8.5)", []string{"AU-2", "AU-8", "AU-12"}, |
| 219 | func(lines []string) []string { |
| 220 | var m []string |
| 221 | for _, sc := range []string{"adjtimex", "settimeofday", "clock_settime"} { |
| 222 | if !ruleSyscall(lines, sc) { |
| 223 | m = append(m, "no rule for system call "+sc) |
| 224 | } |
| 225 | } |
| 226 | if !ruleWatches(lines, "/etc/localtime") { |
| 227 | m = append(m, "no watch on /etc/localtime") |
| 228 | } |
| 229 | return m |
| 230 | }) |
| 231 | |
| 232 | auditRuleCheck("HOST-AUD-005", "Kernel Module Loading Not Audited", lowAudit, check.Low, |
| 233 | "Checks that loading and unloading kernel modules is recorded by the audit system.", |
| 234 | "A rootkit is usually a kernel module. Auditing init_module, finit_module and delete_module (or the kmod program) records the moment one is loaded, which is often the only evidence that survives.", |
| 235 | "Requires a rule on the system calls init_module and delete_module (finit_module is recommended as well), or a watch or execution rule on the kmod, insmod, rmmod and modprobe programs.", |
| 236 | "Add to /etc/audit/rules.d/50-modules.rules: -a always,exit -F arch=b64 -S init_module,finit_module,delete_module -k modules (and arch=b32), or -a always,exit -F path=/usr/bin/kmod -F perm=x -F auid>=1000 -F auid!=unset -k modules , then augenrules --load.", |
| 237 | "auditctl -l | grep -E 'init_module|kmod'", |
| 238 | "Audit kernel module loading and unloading (CIS Controls v8 8.5)", []string{"AU-2", "AU-12", "CM-7"}, |
| 239 | func(lines []string) []string { |
| 240 | if ruleSyscall(lines, "init_module") && ruleSyscall(lines, "delete_module") { |
| 241 | return nil |
| 242 | } |
| 243 | for _, p := range []string{"/usr/bin/kmod", "/bin/kmod", "/sbin/insmod", "/sbin/modprobe", "/usr/sbin/modprobe"} { |
| 244 | if ruleWatches(lines, p) { |
| 245 | return nil |
| 246 | } |
| 247 | } |
| 248 | return []string{"no rule for init_module and delete_module, and no rule on the kmod program"} |
| 249 | }) |
| 250 | |
| 251 | auditRuleCheck("HOST-AUD-006", "Privileged Command Use Not Audited", lowAudit, check.Low, |
| 252 | "Checks that the audit system records when ordinary users run privileged (setuid or setgid) programs.", |
| 253 | "Programs such as sudo, su, passwd and mount run with elevated rights. Recording each execution by a real user (auid at or above the first normal user id) shows who used privilege and when, which is the core of a usable audit trail.", |
| 254 | "Requires at least one execution rule that combines -F perm=x with an -F auid>= filter (the usual form for privileged programs), or an execve rule filtered on euid=0.", |
| 255 | "Generate rules for every setuid and setgid program: find / -xdev \\( -perm -4000 -o -perm -2000 \\) -type f | awk '{print \"-a always,exit -F path=\" $1 \" -F perm=x -F auid>=1000 -F auid!=unset -k privileged\"}' > /etc/audit/rules.d/50-privileged.rules then augenrules --load.", |
| 256 | "auditctl -l | grep 'perm=x'", |
| 257 | "Audit use of privileged commands (CIS Controls v8 8.5)", []string{"AU-2", "AU-12", "AC-6(9)"}, |
| 258 | func(lines []string) []string { |
| 259 | for _, l := range lines { |
| 260 | if (strings.Contains(l, "perm=x") && strings.Contains(l, "auid>=")) || (strings.Contains(l, "-S execve") && strings.Contains(l, "euid=0")) { |
| 261 | return nil |
| 262 | } |
| 263 | } |
| 264 | return []string{"no execution rule with perm=x and an auid>= filter"} |
| 265 | }) |
| 266 | |
| 267 | auditRuleCheck("HOST-AUD-007", "Audit Configuration Not Immutable", lowAudit, check.Low, |
| 268 | "Checks that the audit rule set ends with -e 2, which locks the rules until the next reboot.", |
| 269 | "Without the immutable flag, anyone who gains root can delete the audit rules (auditctl -D) and work unobserved. With -e 2 the rules can only be changed by rebooting, which is itself a loud, logged event.", |
| 270 | "Requires the last active line of the audit rules to be -e 2.", |
| 271 | "Add -e 2 as the last line of the last file in /etc/audit/rules.d (for example 99-finalize.rules), then augenrules --load and reboot. Plan rule changes before enabling it: every later change needs a reboot.", |
| 272 | "auditctl -s | grep enabled should print enabled 2.", |
| 273 | "Audit configuration is immutable (CIS Controls v8 8.2)", []string{"AU-9", "AU-12"}, |
| 274 | func(lines []string) []string { |
| 275 | if len(lines) == 0 { |
| 276 | return []string{"no audit rules at all"} |
| 277 | } |
| 278 | if strings.Join(strings.Fields(lines[len(lines)-1]), " ") != "-e 2" { |
| 279 | return []string{"last rule is not -e 2 (found: " + lines[len(lines)-1] + ")"} |
| 280 | } |
| 281 | return nil |
| 282 | }) |
| 283 | |
| 284 | check.Register(&check.Check{ |
| 285 | ID: "HOST-AUD-008", Title: "Audit Log Files Readable by Others", Domain: check.DomainHost, |
| 286 | Severity: check.Low, Category: check.CatLogging, Effort: check.EffortLow, |
| 287 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N", |
| 288 | Summary: "Checks that the audit log directory and files can be read only by root.", |
| 289 | Rationale: "Audit logs record which user ran which command with which arguments, including file names and sometimes secrets passed on a command line. They are also the record an intruder most wants to edit. Both reasons call for root-only access.", |
| 290 | Detection: "Stats /var/log/audit (metadata only) and every file in it. The directory must not grant group write or any access to others (0750 or stricter); files must be owned by root with mode 0640 or stricter. Log contents are never read. If the directory does not exist the check is skipped; if it cannot be inspected the result is Incomplete.", |
| 291 | Fix: "chmod 0750 /var/log/audit && chmod 0600 /var/log/audit/* && chown -R root:root /var/log/audit , and set log_group = root in /etc/audit/auditd.conf so new files keep these modes.", |
| 292 | Verify: "stat -c '%a %U %n' /var/log/audit /var/log/audit/*", |
| 293 | Refs: []check.Ref{refAuditdConf, refNIST80092}, |
| 294 | Standards: check.Standards{CWE: []string{"CWE-532", "CWE-732"}, OWASP: []string{"A09:2021"}, NIST: []string{"AU-9", "AC-6"}, CISTopic: "Audit log files and directory are protected (CIS Controls v8 8.2)"}, |
| 295 | Run: func(_ context.Context, s sys.System) []check.Result { |
| 296 | dir, err := s.Stat("/var/log/audit") |
| 297 | if err != nil { |
| 298 | if !exists(s, "/etc/audit") { |
| 299 | return []check.Result{check.SkipR("/var/log/audit", "auditd is not installed")} |
| 300 | } |
| 301 | return []check.Result{check.IncompleteR("/var/log/audit", "The audit log directory could not be inspected (run as root for this check)")} |
| 302 | } |
| 303 | var bad []string |
| 304 | if dir.Mode.Perm()&0o027 != 0 || dir.UID != 0 { |
| 305 | bad = append(bad, fmt.Sprintf("directory /var/log/audit mode %04o uid %d (need 0750 or stricter, owned by root)", dir.Mode.Perm(), dir.UID)) |
| 306 | } |
| 307 | files, _ := s.Glob("/var/log/audit/*") |
| 308 | for _, p := range files { |
| 309 | st, err := s.Stat(p) |
| 310 | if err != nil || st.IsDir { |
| 311 | continue |
| 312 | } |
| 313 | if st.Mode.Perm()&0o137 != 0 || st.UID != 0 { |
| 314 | bad = append(bad, fmt.Sprintf("%s mode %04o uid %d (need 0640 or stricter, owned by root)", p, st.Mode.Perm(), st.UID)) |
| 315 | } |
| 316 | } |
| 317 | if len(bad) > 0 { |
| 318 | return []check.Result{check.FailR("/var/log/audit", "Audit logs are accessible beyond root", bad...)} |
| 319 | } |
| 320 | return []check.Result{check.PassR("/var/log/audit", "Audit log directory and files are restricted to root")} |
| 321 | }, |
| 322 | }) |
| 323 | |
| 324 | check.Register(&check.Check{ |
| 325 | ID: "HOST-AUD-009", Title: "Audit Daemon Ignores a Full Log Disk", Domain: check.DomainHost, |
| 326 | Severity: check.Low, Category: check.CatLogging, Effort: check.EffortLow, |
| 327 | CVSS: lowAudit, |
| 328 | Summary: "Checks that auditd is told what to do when its log file is full or the disk runs low, rather than silently dropping events.", |
| 329 | Rationale: "With max_log_file_action = ignore the log grows until the disk is full; with suspend auditing simply stops. An unset space_left_action means nobody is warned before that happens. Either way an attacker who fills the disk switches auditing off.", |
| 330 | Detection: "Reads max_log_file_action, space_left_action and admin_space_left_action from /etc/audit/auditd.conf. Each must be set and must not be ignore or suspend. Skipped when auditd is not installed; Incomplete when the file exists but cannot be read.", |
| 331 | Fix: "In /etc/audit/auditd.conf set max_log_file_action = keep_logs (or rotate with enough num_logs), space_left_action = email (or syslog or exec), and admin_space_left_action = halt or single as policy requires, then systemctl restart auditd. Caution: admin_space_left_action = halt or single stops the server or drops it to single-user mode when the audit disk fills; choose it only where losing audit records is worse than downtime, and give /var/log/audit room and monitoring first.", |
| 332 | Verify: "grep -E '^(max_log_file_action|space_left_action|admin_space_left_action)' /etc/audit/auditd.conf", |
| 333 | Refs: []check.Ref{refAuditdConf, refNIST80092}, |
| 334 | Standards: check.Standards{CWE: []string{"CWE-778"}, OWASP: []string{"A09:2021"}, NIST: []string{"AU-4", "AU-5"}, CISTopic: "Audit log storage and full-disk actions (CIS Controls v8 8.3)"}, |
| 335 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 336 | text, st := readState(ctx, s, "/etc/audit/auditd.conf") |
| 337 | switch st { |
| 338 | case fileMissing: |
| 339 | if !exists(s, "/etc/audit") { |
| 340 | return []check.Result{check.SkipR("/etc/audit/auditd.conf", "auditd is not installed")} |
| 341 | } |
| 342 | return []check.Result{check.IncompleteR("/etc/audit/auditd.conf", "auditd.conf is missing although /etc/audit exists")} |
| 343 | case fileUnreadable: |
| 344 | return []check.Result{check.IncompleteR("/etc/audit/auditd.conf", "Not readable (run as root for this check)")} |
| 345 | } |
| 346 | cfg := []collect.ConfigFile{{Path: "/etc/audit/auditd.conf", Values: collect.ParseKV(text), Complete: true}} |
| 347 | var bad []string |
| 348 | for _, k := range []string{"max_log_file_action", "space_left_action", "admin_space_left_action"} { |
| 349 | v, _, ok := collect.First(cfg, k) |
| 350 | v = strings.ToLower(strings.TrimSpace(v)) |
| 351 | switch { |
| 352 | case !ok || v == "": |
| 353 | bad = append(bad, k+" is not set") |
| 354 | case v == "ignore" || v == "suspend": |
| 355 | bad = append(bad, k+" = "+v) |
| 356 | } |
| 357 | } |
| 358 | if len(bad) > 0 { |
| 359 | return []check.Result{check.FailR("/etc/audit/auditd.conf", "auditd will drop or stop auditing when the log disk fills", bad...)} |
| 360 | } |
| 361 | return []check.Result{check.PassR("/etc/audit/auditd.conf", "Full-disk actions are configured")} |
| 362 | }, |
| 363 | }) |
| 364 | |
| 365 | check.Register(&check.Check{ |
| 366 | ID: "HOST-AUD-010", Title: "Journal Not Persistent or Not Compressed", Domain: check.DomainHost, |
| 367 | Severity: check.Low, Category: check.CatLogging, Effort: check.EffortLow, |
| 368 | CVSS: lowAudit, |
| 369 | Summary: "Checks that systemd-journald is configured to keep the journal on disk and to compress it.", |
| 370 | Rationale: "Storage=volatile keeps the journal only in memory, so a reboot (or a crash an attacker caused) erases the evidence. Compression keeps a persistent journal small enough to retain for weeks. HOST-LOG-001 looks for the journal directory; this check reads the configuration that decides what happens to it.", |
| 371 | Detection: "Reads Storage= and Compress= from /etc/systemd/journald.conf and /etc/systemd/journald.conf.d/*.conf. Storage=persistent passes; auto or unset passes only when /var/log/journal exists; volatile or none fails. Compress=no fails. Incomplete when the files exist but cannot be read; skipped when systemd is not present.", |
| 372 | Fix: "In /etc/systemd/journald.conf set Storage=persistent and Compress=yes , create the directory with mkdir -p /var/log/journal && systemd-tmpfiles --create --prefix /var/log/journal , then systemctl restart systemd-journald.", |
| 373 | Verify: "journalctl --header | head and ls -d /var/log/journal", |
| 374 | Refs: []check.Ref{refJournald, refNIST80092}, |
| 375 | Standards: check.Standards{CWE: []string{"CWE-778"}, OWASP: []string{"A09:2021"}, NIST: []string{"AU-4", "AU-9", "AU-11"}, CISTopic: "journald persistent storage and compression (CIS Controls v8 8.3)"}, |
| 376 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 377 | files, unreadable := readAll(ctx, s, "/etc/systemd/journald.conf.d/*.conf", "/etc/systemd/journald.conf") |
| 378 | if len(files) == 0 { |
| 379 | if len(unreadable) > 0 { |
| 380 | return []check.Result{check.IncompleteR("journald.conf", "journald configuration exists but could not be read")} |
| 381 | } |
| 382 | if !exists(s, "/etc/systemd") { |
| 383 | return []check.Result{check.SkipR("journald.conf", "systemd is not present")} |
| 384 | } |
| 385 | return []check.Result{check.IncompleteR("journald.conf", "No journald configuration file was found under /etc/systemd")} |
| 386 | } |
| 387 | var cfg []collect.ConfigFile |
| 388 | for _, f := range files { |
| 389 | cfg = append(cfg, collect.ConfigFile{Path: f.Path, Values: collect.ParseKV(f.Text), Complete: true}) |
| 390 | } |
| 391 | var bad []string |
| 392 | storage, path, ok := collect.First(cfg, "storage") |
| 393 | storage = strings.ToLower(strings.TrimSpace(storage)) |
| 394 | if !ok || storage == "" { |
| 395 | storage, path = "auto", "default" |
| 396 | } |
| 397 | switch storage { |
| 398 | case "persistent": |
| 399 | case "auto": |
| 400 | if st, err := s.Stat("/var/log/journal"); err != nil || !st.IsDir { |
| 401 | bad = append(bad, "Storage="+storage+" ("+path+") with no /var/log/journal directory: the journal is lost at reboot") |
| 402 | } |
| 403 | default: |
| 404 | bad = append(bad, "Storage="+storage+" ("+path+"): the journal is lost at reboot") |
| 405 | } |
| 406 | if v, p, ok := collect.First(cfg, "compress"); ok { |
| 407 | switch strings.ToLower(strings.TrimSpace(v)) { |
| 408 | case "no", "false", "0", "off": |
| 409 | bad = append(bad, "Compress="+v+" ("+p+")") |
| 410 | } |
| 411 | } |
| 412 | if len(bad) > 0 { |
| 413 | return []check.Result{check.FailR("journald.conf", "The journal is not kept persistently and compressed", bad...)} |
| 414 | } |
| 415 | return []check.Result{check.PassR("journald.conf", "Storage="+storage+", compression on")} |
| 416 | }, |
| 417 | }) |
| 418 | |
| 419 | check.Register(&check.Check{ |
| 420 | ID: "HOST-AUD-011", Title: "Logs Not Forwarded to a Remote Collector", Domain: check.DomainHost, |
| 421 | Severity: check.Low, Category: check.CatLogging, Effort: check.EffortMedium, |
| 422 | CVSS: lowAudit, |
| 423 | Summary: "Checks that system logs are sent to another machine, so an intruder with root cannot erase the only copy.", |
| 424 | Rationale: "Whoever gains root on a server can delete or edit its local logs. A copy that already left the machine, to a log server or a SIEM, is the record an investigation can trust. NIST SP 800-92 treats centralised log collection as a basic log management control.", |
| 425 | Detection: "Looks for a forwarding rule in /etc/rsyslog.conf and /etc/rsyslog.d/*.conf (a selector followed by @host or @@host, or an omfwd action), a network destination in /etc/syslog-ng/syslog-ng.conf, or a URL= in /etc/systemd/journal-upload.conf. The remote address is never copied into the report. Incomplete when a file exists but cannot be read.", |
| 426 | Fix: "rsyslog: create /etc/rsyslog.d/90-remote.conf with *.* action(type=\"omfwd\" target=\"logs.example.internal\" port=\"6514\" protocol=\"tcp\" StreamDriver=\"gtls\" StreamDriverMode=\"1\" StreamDriverAuthMode=\"x509/name\") and restart rsyslog. Or configure systemd-journal-upload with URL= pointing at a journal-remote collector. Prefer TLS so logs are not readable in transit.", |
| 427 | Verify: "grep -rE '^[^#].*@|omfwd' /etc/rsyslog.conf /etc/rsyslog.d/ or grep '^URL=' /etc/systemd/journal-upload.conf", |
| 428 | Refs: []check.Ref{refNIST80092, refRsyslog, {Title: "journal-upload.conf(5), systemd", URL: "https://www.freedesktop.org/software/systemd/man/latest/journal-upload.conf.html"}}, |
| 429 | Standards: check.Standards{CWE: []string{"CWE-778"}, OWASP: []string{"A09:2021"}, NIST: []string{"AU-9(2)", "AU-6", "SI-4"}, CISTopic: "Centralised log collection (CIS Controls v8 8.9)"}, |
| 430 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 431 | files, unreadable := readAll(ctx, s, "/etc/rsyslog.conf", "/etc/rsyslog.d/*.conf", "/etc/syslog-ng/syslog-ng.conf", "/etc/syslog-ng/conf.d/*.conf", "/etc/systemd/journal-upload.conf") |
| 432 | for _, f := range files { |
| 433 | for _, l := range activeLines(f.Text) { |
| 434 | isSyslogNG := strings.Contains(f.Path, "syslog-ng") |
| 435 | switch { |
| 436 | case !isSyslogNG && strings.HasSuffix(f.Path, "journal-upload.conf") && strings.HasPrefix(l, "URL=") && strings.TrimSpace(strings.TrimPrefix(l, "URL=")) != "": |
| 437 | return []check.Result{check.PassR(f.Path, "journal-upload forwards the journal to a remote collector")} |
| 438 | case isSyslogNG && strings.HasPrefix(l, "destination") && (strings.Contains(l, "network(") || strings.Contains(l, "syslog(") || strings.Contains(l, "tcp(") || strings.Contains(l, "udp(")): |
| 439 | return []check.Result{check.PassR(f.Path, "syslog-ng has a network destination")} |
| 440 | case !isSyslogNG && !strings.HasSuffix(f.Path, "journal-upload.conf") && remoteLog.MatchString(l): |
| 441 | return []check.Result{check.PassR(f.Path, "rsyslog forwards logs to a remote host")} |
| 442 | } |
| 443 | } |
| 444 | } |
| 445 | if len(unreadable) > 0 { |
| 446 | return []check.Result{check.IncompleteR("syslog", "Some logging configuration could not be read", unreadable...)} |
| 447 | } |
| 448 | return []check.Result{check.FailR("syslog", "No remote log destination is configured; the only copy of the logs is on this machine")} |
| 449 | }, |
| 450 | }) |
| 451 | } |
Also available as plain Markdown for notes, scripts and AI tools. Same facts, same source, no styling.
Cite This Page
If this page helped your work, please cite it. The version and web address are filled in; the access date is today's date in your browser. Every other style, and when credit is required, is on How to Cite OpenVAPT.
OpenVAPT. (2026). HOST-AUD-006 Privileged Command Use Not Audited (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-AUD-006.html
In text: (OpenVAPT, 2026). Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
@misc{openvapt_host_aud_006,
author = {{OpenVAPT}},
title = {{HOST-AUD-006 Privileged Command Use Not Audited}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/HOST-AUD-006.html}},
note = {OpenVAPT Check Catalogue, version 0.1.0-alpha.6. Accessed 16 October 2026}
}
Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
Citing OpenVAPT does not mean OpenVAPT endorses, approves or has reviewed your work.