Skip to Content
OpenVAPTAlpha
Pre-release. Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. Free for your own use, 100% at your own risk. This website collects no data. Read MoreRead Less
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
LowHOST-AUD-008Logging and MonitoringServer

Audit Log Files Readable by Others

Checks that the audit log directory and files can be read only by root.

Why It Matters
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.
How It Detects
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.
How to 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.
How to Verify
stat -c '%a %U %n' /var/log/audit /var/log/audit/*
CVSS v3.1 Vector
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/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.2
NIST CSF 2.0 Subcategory
PR.PS-04
CWE
CWE-532, CWE-732
OWASP Top 10
A09:2021
NIST SP 800-53 Rev. 5
AU-9, AC-6
Benchmark Topic
Audit log files and directory are protected (CIS Controls v8 8.2) (topic only, no benchmark text)
References
auditd.conf(5), Linux man-pages
NIST SP 800-92, Guide to Computer Security Log Management
Learn the theory behind this check: P21 Audit Logging and Monitoring Depth.

Source Code

The complete file that defines this check. Run it with openvapt scan --only HOST-AUD-008, or read it offline with openvapt explain HOST-AUD-008.

internal/checks/host/audit_journal.go · Lines 1-451View on GitHub at This Commit
1package host
2
3import (
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
20var (
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.
29type auditState int
30
31const (
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.
38func 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.
55func 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.
79func 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.
98func 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
138var 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.
142var remoteLog = regexp.MustCompile(`^\S+\s+@{1,2}\S|omfwd|target=`)
143
144func 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.

APA 7
OpenVAPT. (2026). HOST-AUD-008 Audit Log Files Readable by Others (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-AUD-008.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.

BibTeX
@misc{openvapt_host_aud_008,
  author       = {{OpenVAPT}},
  title        = {{HOST-AUD-008 Audit Log Files Readable by Others}},
  year         = {2026},
  howpublished = {\url{https://openvapt.com/checks/HOST-AUD-008.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.