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-PAM-005Identity and AuthenticationServer

Minimum Password Age Not Set

Checks that a password cannot be changed again on the same day it was set.

Why It Matters
Password history (HOST-PAM-004) only works if users cannot cycle through several changes in a minute to get back to a favourite password. PASS_MIN_DAYS 1 closes that loop with no real cost to users.
How It Detects
Reads PASS_MIN_DAYS from /etc/login.defs; 1 or more passes. An unset value fails, because the built-in default does not meet the requirement. Skipped when the file does not exist; Incomplete when it cannot be read.
How to Fix
Set PASS_MIN_DAYS 1 in /etc/login.defs, and for existing accounts chage --mindays 1 <user> .
How to Verify
grep '^PASS_MIN_DAYS' /etc/login.defs
CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N
Control Category
Identity and Authentication: CIS Controls v8 5, 6; NIST CSF 2.0 PR.AA
Fix Effort
Low
CIS Controls v8 Safeguard
5.2
NIST CSF 2.0 Subcategory
PR.AA-01
CWE
CWE-262
OWASP Top 10
A07:2021
NIST SP 800-53 Rev. 5
IA-5(1)
Benchmark Topic
Minimum days between password changes (CIS Controls v8 5.2) (topic only, no benchmark text)
References
login.defs(5), Linux man-pages
NIST SP 800-63B-4, Digital Identity Guidelines: Authentication and Authenticator Management
Learn the theory behind this check: P22 Authentication Policy and Package Hygiene.

Source Code

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

internal/checks/host/pam_login.go · Lines 1-441View on GitHub at This Commit
1package host
2
3import (
4 "context"
5 "fmt"
6 "regexp"
7 "strconv"
8 "strings"
9
10 "github.com/openvapt/openvapt/internal/check"
11 "github.com/openvapt/openvapt/internal/checks/collect"
12 "github.com/openvapt/openvapt/internal/sys"
13)
14
15// HOST-PAM: authentication policy beyond what HOST-PWD covers. HOST-PWD
16// already checks the hashing algorithm (001), obsolete hashes (002),
17// minimum length (003), lockout after failures (004) and forced rotation
18// (005), and HOST-FS-009 checks UMASK, so none of those is repeated here.
19// Everything is read from PAM stacks, login.defs, useradd defaults and
20// shell start-up files; no command is run.
21
22var (
23 refPAMUnix = check.Ref{Title: "pam_unix(8), Linux-PAM", URL: "https://man7.org/linux/man-pages/man8/pam_unix.8.html"}
24 refPAMWheel = check.Ref{Title: "pam_wheel(8), Linux-PAM", URL: "https://man7.org/linux/man-pages/man8/pam_wheel.8.html"}
25 refPAMPwhistory = check.Ref{Title: "pam_pwhistory(8), Linux-PAM", URL: "https://man7.org/linux/man-pages/man8/pam_pwhistory.8.html"}
26 refUseradd = check.Ref{Title: "useradd(8), Linux man-pages (default INACTIVE value)", URL: "https://man7.org/linux/man-pages/man8/useradd.8.html"}
27 refBash = check.Ref{Title: "Bash Reference Manual, Bash Variables (TMOUT)", URL: "https://www.gnu.org/software/bash/manual/html_node/Bash-Variables.html"}
28)
29
30var (
31 passwordStacks = []string{"/etc/pam.d/common-password", "/etc/pam.d/system-auth", "/etc/pam.d/password-auth"}
32 authStacks = []string{"/etc/pam.d/common-auth", "/etc/pam.d/system-auth", "/etc/pam.d/password-auth"}
33 lowIdentity = "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N"
34 lowLocal = "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:N"
35)
36
37// pamStacks reads the given PAM files. It returns the files read, whether
38// any exists, and whether some exist but could not be read.
39func pamStacks(ctx context.Context, s sys.System, paths []string) (files []textFile, unreadable []string) {
40 return readAll(ctx, s, paths...)
41}
42
43// pamOption returns the value of option=... on the first active line that
44// names module, or "" and false.
45func pamOption(text, module, option string) (string, bool) {
46 for _, l := range activeLines(text) {
47 if !strings.Contains(l, module) {
48 continue
49 }
50 for _, a := range strings.Fields(l) {
51 if v, ok := strings.CutPrefix(a, option+"="); ok {
52 return v, true
53 }
54 }
55 }
56 return "", false
57}
58
59// pamUses reports whether an active line of the stack names the module.
60func pamUses(text, module string) bool {
61 for _, l := range activeLines(text) {
62 if strings.Contains(l, module) {
63 return true
64 }
65 }
66 return false
67}
68
69// loginDefsInt reads one numeric setting from /etc/login.defs.
70func loginDefsInt(ctx context.Context, s sys.System, key string) (n int, set bool, st fileState) {
71 text, st := readState(ctx, s, "/etc/login.defs")
72 if st != fileRead {
73 return 0, false, st
74 }
75 cfg := []collect.ConfigFile{{Path: "/etc/login.defs", Values: collect.ParseKV(text), Complete: true}}
76 v, _, ok := collect.First(cfg, key)
77 if !ok {
78 return 0, false, fileRead
79 }
80 n, err := strconv.Atoi(strings.TrimSpace(v))
81 if err != nil {
82 return 0, false, fileRead
83 }
84 return n, true, fileRead
85}
86
87// loginDefsCheck registers a check on one numeric login.defs setting.
88func loginDefsCheck(id, title, cvss string, sev check.Severity, key, summary, rationale, fix, cisTopic string, good func(int) bool, want string) {
89 check.Register(&check.Check{
90 ID: id, Title: title, Domain: check.DomainHost,
91 Severity: sev, Category: check.CatIdentity, Effort: check.EffortLow,
92 CVSS: cvss,
93 Summary: summary,
94 Rationale: rationale,
95 Detection: "Reads " + key + " from /etc/login.defs; " + want + " passes. An unset value fails, because the built-in default does not meet the requirement. Skipped when the file does not exist; Incomplete when it cannot be read.",
96 Fix: fix,
97 Verify: "grep '^" + key + "' /etc/login.defs",
98 Refs: []check.Ref{refLoginDefs, refNIST63B},
99 Standards: check.Standards{CWE: []string{"CWE-262"}, OWASP: []string{"A07:2021"}, NIST: []string{"IA-5(1)"}, CISTopic: cisTopic},
100 Run: func(ctx context.Context, s sys.System) []check.Result {
101 n, set, st := loginDefsInt(ctx, s, key)
102 switch st {
103 case fileMissing:
104 return []check.Result{check.SkipR("/etc/login.defs", "File not found")}
105 case fileUnreadable:
106 return []check.Result{check.IncompleteR("/etc/login.defs", "Not readable")}
107 }
108 if !set {
109 return []check.Result{check.FailR("/etc/login.defs", key+" is not set ("+want+")")}
110 }
111 if !good(n) {
112 return []check.Result{check.FailR("/etc/login.defs", fmt.Sprintf("%s %d (%s)", key, n, want))}
113 }
114 return []check.Result{check.PassR("/etc/login.defs", fmt.Sprintf("%s %d", key, n))}
115 },
116 })
117}
118
119var (
120 pathAssign = regexp.MustCompile(`(?m)^\s*(?:export\s+)?PATH=(\S*)`)
121 tmoutLine = regexp.MustCompile(`(?m)^\s*(?:readonly\s+|export\s+|typeset\s+-x\s+|declare\s+-x\s+)?TMOUT=(\d+)`)
122)
123
124func init() {
125 check.Register(&check.Check{
126 ID: "HOST-PAM-001", Title: "Password Quality Module Not Enforced", Domain: check.DomainHost,
127 Severity: check.Low, Category: check.CatIdentity, Effort: check.EffortLow,
128 CVSS: lowIdentity,
129 Summary: "Checks that new passwords pass through pam_pwquality with its dictionary check on, so dictionary words and user-name variants are rejected.",
130 Rationale: "NIST SP 800-63B-4 asks verifiers to reject passwords found in dictionaries and blocklists rather than to impose composition rules. pam_pwquality does that work on Linux, but only when the password stack actually calls it and its dictcheck and usercheck options are not switched off. A minlen in pwquality.conf (HOST-PWD-003) does nothing if the module is never invoked.",
131 Detection: "Reads /etc/pam.d/common-password, /etc/pam.d/system-auth and /etc/pam.d/password-auth for an active pam_pwquality (or pam_passwdqc or pam_cracklib) line, then reads dictcheck, usercheck and enforcing from /etc/security/pwquality.conf.d/*.conf and /etc/security/pwquality.conf and from the module line. A missing module, dictcheck=0, usercheck=0 or enforcing=0 fails. Skipped when no password stack exists; Incomplete when one exists but cannot be read.",
132 Fix: "Install libpam-pwquality (Debian/Ubuntu) or libpwquality (RHEL family). Make sure the password stack has password requisite pam_pwquality.so retry=3 before pam_unix (pam-auth-update or authselect enable-feature with-pwquality), and in /etc/security/pwquality.conf keep dictcheck = 1 and usercheck = 1 . Pair it with a breached-password blocklist where possible. Keep a root session open and test a login and a password change in a second session before closing it: a wrong PAM line can lock every user out.",
133 Verify: "grep pam_pwquality /etc/pam.d/common-password /etc/pam.d/system-auth 2>/dev/null; grep -rE '^(dictcheck|usercheck|enforcing)' /etc/security/pwquality.conf /etc/security/pwquality.conf.d/",
134 Refs: []check.Ref{refPwquality, refNIST63B},
135 Standards: check.Standards{CWE: []string{"CWE-521"}, OWASP: []string{"A07:2021"}, NIST: []string{"IA-5(1)"}, CISTopic: "Password creation requirements enforced through PAM (CIS Controls v8 5.2)"},
136 Run: func(ctx context.Context, s sys.System) []check.Result {
137 files, unreadable := pamStacks(ctx, s, passwordStacks)
138 if len(files) == 0 {
139 if len(unreadable) > 0 {
140 return []check.Result{check.IncompleteR("pam", "Password stack exists but could not be read", unreadable...)}
141 }
142 return []check.Result{check.SkipR("pam", "PAM password stack not found")}
143 }
144 var bad []string
145 var module textFile
146 for _, f := range files {
147 if pamUses(f.Text, "pam_pwquality") || pamUses(f.Text, "pam_passwdqc") || pamUses(f.Text, "pam_cracklib") {
148 module = f
149 break
150 }
151 }
152 if module.Path == "" {
153 bad = append(bad, "no pam_pwquality line in "+files[0].Path)
154 }
155 cfg := collect.ReadConfigs(ctx, s, "/etc/security/pwquality.conf.d/*.conf", "/etc/security/pwquality.conf")
156 for _, k := range []string{"dictcheck", "usercheck", "enforcing"} {
157 v, path, ok := collect.First(cfg, k)
158 if mv, mok := pamOption(module.Text, "pam_pwquality", k); mok {
159 v, path, ok = mv, module.Path, true
160 }
161 if ok && strings.TrimSpace(v) == "0" {
162 bad = append(bad, k+" = 0 ("+path+")")
163 }
164 }
165 if len(bad) > 0 {
166 return []check.Result{check.FailR("pam", "Password quality checks are not enforced", bad...)}
167 }
168 return []check.Result{check.PassR(module.Path, "pam_pwquality is active with dictionary and user-name checks")}
169 },
170 })
171
172 check.Register(&check.Check{
173 ID: "HOST-PAM-002", Title: "Account Lockout Releases Too Quickly", Domain: check.DomainHost,
174 Severity: check.Low, Category: check.CatIdentity, Effort: check.EffortLow,
175 CVSS: lowIdentity,
176 Summary: "Checks that a locked account stays locked for at least 15 minutes (or until an administrator unlocks it).",
177 Rationale: "This check and HOST-PWD-004 split one control so a single root cause is never counted twice: HOST-PWD-004 covers whether lockout exists and its deny threshold (how many failures lock the account); this check covers only unlock_time (how long the lock lasts). How long the lock lasts decides whether it slows an attacker: pam_faillock's default unlock_time of 600 seconds lets guessing resume every ten minutes. Fifteen minutes or more, or 0 (manual unlock), makes online guessing impractical.",
178 Detection: "Reads unlock_time from the pam_faillock lines of /etc/pam.d/common-auth, /etc/pam.d/system-auth and /etc/pam.d/password-auth, then from /etc/security/faillock.conf, else uses the module default of 600. A value of 0 or at least 900 passes. No pam_faillock at all is skipped, because HOST-PWD-004 already reports the missing lockout. Skipped when no authentication stack exists; Incomplete when one exists but cannot be read.",
179 Fix: "In /etc/security/faillock.conf set unlock_time = 900 (or 0 to require an administrator to run faillock --reset), keeping deny between 3 and 10. Keep a root session open and test in a second session before closing the first: a wrong PAM line can lock every user out, and unlock_time = 0 means locked accounts, administrators included, wait for faillock --reset.",
180 Verify: "grep -E 'unlock_time' /etc/security/faillock.conf /etc/pam.d/common-auth /etc/pam.d/system-auth 2>/dev/null",
181 Refs: []check.Ref{refFaillock, refNIST80053},
182 Standards: check.Standards{CWE: []string{"CWE-307"}, OWASP: []string{"A07:2021"}, NIST: []string{"AC-7"}, CISTopic: "Lockout duration for failed password attempts (CIS Controls v8 5.2)"},
183 Run: func(ctx context.Context, s sys.System) []check.Result {
184 files, unreadable := pamStacks(ctx, s, authStacks)
185 if len(files) == 0 {
186 if len(unreadable) > 0 {
187 return []check.Result{check.IncompleteR("pam", "Authentication stack exists but could not be read", unreadable...)}
188 }
189 return []check.Result{check.SkipR("pam", "PAM authentication stack not found")}
190 }
191 var stack textFile
192 for _, f := range files {
193 if pamUses(f.Text, "pam_faillock") {
194 stack = f
195 break
196 }
197 }
198 if stack.Path == "" {
199 // HOST-PWD-004 reports the missing lockout; failing here as
200 // well would count one root cause twice.
201 return []check.Result{check.SkipR("pam", "No pam_faillock in the authentication stack, so there is no lockout duration to check; HOST-PWD-004 reports the missing lockout")}
202 }
203 unlock, src := 600, "pam_faillock default"
204 if v, ok := pamOption(stack.Text, "pam_faillock", "unlock_time"); ok {
205 if n, err := strconv.Atoi(v); err == nil {
206 unlock, src = n, stack.Path
207 }
208 } else if v, _, ok := collect.First(collect.ReadConfigs(ctx, s, "/etc/security/faillock.conf"), "unlock_time"); ok {
209 if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
210 unlock, src = n, "/etc/security/faillock.conf"
211 }
212 }
213 if unlock == 0 || unlock >= 900 {
214 return []check.Result{check.PassR(src, fmt.Sprintf("unlock_time=%d", unlock))}
215 }
216 return []check.Result{check.FailR(src, fmt.Sprintf("unlock_time=%d seconds (need 900 or more, or 0 for manual unlock)", unlock))}
217 },
218 })
219
220 check.Register(&check.Check{
221 ID: "HOST-PAM-003", Title: "su Not Restricted to an Administrative Group", Domain: check.DomainHost,
222 Severity: check.Low, Category: check.CatIdentity, Effort: check.EffortLow,
223 CVSS: lowLocal,
224 Summary: "Checks that only members of a named group (wheel or sudo) may use su to become another user.",
225 Rationale: "Without pam_wheel, every local account, including a compromised web service user, can run su and start guessing the root password. Restricting su to an administrative group removes that path and leaves sudo, which logs each command, as the way to gain privilege.",
226 Detection: "Reads /etc/pam.d/su for an active line auth required pam_wheel.so (any control value, with or without use_uid and group=). Skipped when the file does not exist; Incomplete when it cannot be read.",
227 Fix: "In /etc/pam.d/su uncomment or add auth required pam_wheel.so use_uid group=sudo (group=wheel on the RHEL family), and make sure the group contains only administrators. Caution: add every administrator who uses su to that group first and test from a second session, or they lose su access.",
228 Verify: "grep -E '^auth\\s+required\\s+pam_wheel.so' /etc/pam.d/su",
229 Refs: []check.Ref{refPAMWheel, refNIST80053},
230 Standards: check.Standards{CWE: []string{"CWE-250", "CWE-862"}, NIST: []string{"AC-6(2)", "AC-3"}, CISTopic: "Access to the su command is restricted (CIS Controls v8 5.4)"},
231 Run: func(ctx context.Context, s sys.System) []check.Result {
232 text, st := readState(ctx, s, "/etc/pam.d/su")
233 switch st {
234 case fileMissing:
235 return []check.Result{check.SkipR("/etc/pam.d/su", "File not found")}
236 case fileUnreadable:
237 return []check.Result{check.IncompleteR("/etc/pam.d/su", "Not readable")}
238 }
239 for _, l := range activeLines(text) {
240 f := strings.Fields(l)
241 if len(f) >= 3 && f[0] == "auth" && strings.Contains(f[2], "pam_wheel.so") {
242 return []check.Result{check.PassR("/etc/pam.d/su", "su is restricted by pam_wheel: "+l)}
243 }
244 }
245 return []check.Result{check.FailR("/etc/pam.d/su", "No active pam_wheel line: any user may attempt su")}
246 },
247 })
248
249 check.Register(&check.Check{
250 ID: "HOST-PAM-004", Title: "Password History Not Enforced", Domain: check.DomainHost,
251 Severity: check.Low, Category: check.CatIdentity, Effort: check.EffortLow,
252 CVSS: lowIdentity,
253 Summary: "Checks that PAM remembers at least the last 5 passwords so a changed password cannot be changed straight back.",
254 Rationale: "When a password must be changed after a suspected compromise, the point is lost if the user can set the old one again. pam_pwhistory (or pam_unix remember=) keeps recent hashes and refuses reuse. Five is a common minimum.",
255 Detection: "Reads /etc/pam.d/common-password, /etc/pam.d/system-auth and /etc/pam.d/password-auth for remember=N on a pam_pwhistory or pam_unix line, or remember in /etc/security/pwhistory.conf. N of 5 or more passes. Skipped when no password stack exists; Incomplete when one exists but cannot be read.",
256 Fix: "Add password required pam_pwhistory.so remember=5 use_authtok before pam_unix in the password stack (pam-auth-update on Debian/Ubuntu, authselect enable-feature with-pwhistory on the RHEL family), or set remember = 5 in /etc/security/pwhistory.conf. Keep a root session open and test a password change in a second session before closing it: a wrong PAM line can block password changes or logins.",
257 Verify: "grep -E 'remember' /etc/pam.d/common-password /etc/pam.d/system-auth /etc/security/pwhistory.conf 2>/dev/null",
258 Refs: []check.Ref{refPAMPwhistory, refPAMUnix, refNIST63B},
259 Standards: check.Standards{CWE: []string{"CWE-262"}, OWASP: []string{"A07:2021"}, NIST: []string{"IA-5(1)"}, CISTopic: "Password reuse is limited (CIS Controls v8 5.2)"},
260 Run: func(ctx context.Context, s sys.System) []check.Result {
261 files, unreadable := pamStacks(ctx, s, passwordStacks)
262 if len(files) == 0 {
263 if len(unreadable) > 0 {
264 return []check.Result{check.IncompleteR("pam", "Password stack exists but could not be read", unreadable...)}
265 }
266 return []check.Result{check.SkipR("pam", "PAM password stack not found")}
267 }
268 best, src := -1, ""
269 for _, f := range files {
270 for _, mod := range []string{"pam_pwhistory", "pam_unix"} {
271 if v, ok := pamOption(f.Text, mod, "remember"); ok {
272 if n, err := strconv.Atoi(v); err == nil && n > best {
273 best, src = n, f.Path
274 }
275 }
276 }
277 }
278 if best < 0 {
279 if v, _, ok := collect.First(collect.ReadConfigs(ctx, s, "/etc/security/pwhistory.conf"), "remember"); ok {
280 if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
281 best, src = n, "/etc/security/pwhistory.conf"
282 }
283 }
284 }
285 switch {
286 case best < 0:
287 return []check.Result{check.FailR(files[0].Path, "No remember= setting: old passwords can be reused at once")}
288 case best < 5:
289 return []check.Result{check.FailR(src, fmt.Sprintf("remember=%d (recommended 5 or more)", best))}
290 }
291 return []check.Result{check.PassR(src, fmt.Sprintf("remember=%d", best))}
292 },
293 })
294
295 loginDefsCheck("HOST-PAM-005", "Minimum Password Age Not Set", lowIdentity, check.Low, "PASS_MIN_DAYS",
296 "Checks that a password cannot be changed again on the same day it was set.",
297 "Password history (HOST-PAM-004) only works if users cannot cycle through several changes in a minute to get back to a favourite password. PASS_MIN_DAYS 1 closes that loop with no real cost to users.",
298 "Set PASS_MIN_DAYS 1 in /etc/login.defs, and for existing accounts chage --mindays 1 <user> .",
299 "Minimum days between password changes (CIS Controls v8 5.2)",
300 func(n int) bool { return n >= 1 }, "1 or more")
301
302 loginDefsCheck("HOST-PAM-006", "Password Expiry Warning Too Short", "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:N", check.Info, "PASS_WARN_AGE",
303 "Reports whether users are warned at least 7 days before a password expires.",
304 "Where passwords do expire, a short or missing warning means users are locked out without notice and support staff reset passwords under pressure, which is when weak temporary passwords and social engineering succeed. This is informational: with no expiry (HOST-PWD-005) the warning never fires.",
305 "Set PASS_WARN_AGE 7 in /etc/login.defs, and for existing accounts chage --warndays 7 <user> .",
306 "Password expiration warning days (CIS Controls v8 5.2)",
307 func(n int) bool { return n >= 7 }, "7 or more")
308
309 check.Register(&check.Check{
310 ID: "HOST-PAM-007", Title: "Inactive Accounts Never Locked", Domain: check.DomainHost,
311 Severity: check.Low, Category: check.CatIdentity, Effort: check.EffortLow,
312 CVSS: lowIdentity,
313 Summary: "Checks that new accounts are set to lock automatically after a password has been expired and unused for at most 30 days.",
314 Rationale: "Accounts of people who left, or test accounts nobody remembers, stay usable for years unless something disables them. The INACTIVE default in /etc/default/useradd makes new accounts lock themselves a fixed number of days after their password expires.",
315 Detection: "Reads INACTIVE from /etc/default/useradd. A value from 0 to 30 passes; -1 (never) or an unset value fails, because useradd's built-in default is -1. A missing file also fails for the same reason; Incomplete when the file cannot be read.",
316 Fix: "useradd -D -f 30 (writes INACTIVE=30 to /etc/default/useradd), and for existing accounts chage --inactive 30 <user> . Caution: this locks accounts whose password has expired for 30 days, so exempt break-glass and rarely used administrator accounts or keep their passwords current.",
317 Verify: "useradd -D | grep INACTIVE",
318 Refs: []check.Ref{refUseradd, refNIST80053},
319 Standards: check.Standards{CWE: []string{"CWE-262"}, NIST: []string{"AC-2(3)"}, CISTopic: "Inactive password lock (CIS Controls v8 5.3)"},
320 Run: func(ctx context.Context, s sys.System) []check.Result {
321 text, st := readState(ctx, s, "/etc/default/useradd")
322 if st == fileUnreadable {
323 return []check.Result{check.IncompleteR("/etc/default/useradd", "Not readable")}
324 }
325 if st == fileMissing {
326 return []check.Result{check.FailR("/etc/default/useradd", "File not found, so useradd uses its built-in INACTIVE=-1 (never lock)")}
327 }
328 cfg := []collect.ConfigFile{{Path: "/etc/default/useradd", Values: collect.ParseKV(text), Complete: true}}
329 v, _, ok := collect.First(cfg, "inactive")
330 if !ok || strings.TrimSpace(v) == "" {
331 return []check.Result{check.FailR("/etc/default/useradd", "INACTIVE is not set (default -1: accounts never lock)")}
332 }
333 n, err := strconv.Atoi(strings.TrimSpace(v))
334 if err != nil {
335 return []check.Result{check.IncompleteR("/etc/default/useradd", fmt.Sprintf("INACTIVE %q is not a number", v))}
336 }
337 if n < 0 || n > 30 {
338 return []check.Result{check.FailR("/etc/default/useradd", fmt.Sprintf("INACTIVE=%d (need 0 to 30)", n))}
339 }
340 return []check.Result{check.PassR("/etc/default/useradd", fmt.Sprintf("INACTIVE=%d", n))}
341 },
342 })
343
344 check.Register(&check.Check{
345 ID: "HOST-PAM-008", Title: "Root PATH Contains Unsafe Entries", Domain: check.DomainHost,
346 Severity: check.Medium, Category: check.CatIdentity, Effort: check.EffortLow,
347 CVSS: "CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:H/I:H/A:H",
348 Summary: "Checks that the PATH set for root does not include the current directory, empty entries, relative paths or directories other users can write to.",
349 Rationale: "When root's PATH contains . or an empty element, typing ls in a directory an attacker controls runs the attacker's ls as root. A world-writable directory on the PATH has the same effect for every command name that is not found earlier. This is one of the oldest Unix privilege escalations and still common in copied dotfiles.",
350 Detection: "Reads PATH= assignments from /root/.bashrc, /root/.profile, /root/.bash_profile, /etc/environment, /etc/profile and /etc/profile.d/*.sh. Entries that are ., empty (a leading, trailing or doubled colon) or relative fail; absolute directories are stat-ed (metadata only) and fail if world-writable or not owned by root. Entries that expand variables ($PATH, $HOME) are not judged. Incomplete when no readable file sets PATH.",
351 Fix: "Edit the listed file so PATH holds only absolute, root-owned directories, for example PATH=\"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\" , remove any . or trailing colon, and chmod o-w any listed directory. Log out and in again.",
352 Verify: "sudo -i sh -c 'echo $PATH' should show no . or empty element; ls -ld $(echo $PATH | tr : ' ')",
353 Refs: []check.Ref{{Title: "MITRE ATT&CK T1574.007, Hijack Execution Flow: Path Interception by PATH Environment Variable", URL: "https://attack.mitre.org/techniques/T1574/007/"}, refNIST80053},
354 Standards: check.Standards{CWE: []string{"CWE-426", "CWE-427"}, NIST: []string{"CM-6", "AC-6"}, CISTopic: "Root PATH integrity (CIS Controls v8 4.1)"},
355 Run: func(ctx context.Context, s sys.System) []check.Result {
356 files, unreadable := readAll(ctx, s, "/root/.bashrc", "/root/.profile", "/root/.bash_profile", "/etc/environment", "/etc/profile", "/etc/profile.d/*.sh")
357 var bad []string
358 found := false
359 for _, f := range files {
360 for _, m := range pathAssign.FindAllStringSubmatch(f.Text, -1) {
361 found = true
362 val := strings.Trim(m[1], `"'`)
363 parts := strings.Split(val, ":")
364 for i, p := range parts {
365 switch {
366 case p == "":
367 where := "doubled colon"
368 if i == 0 {
369 where = "leading colon"
370 } else if i == len(parts)-1 {
371 where = "trailing colon"
372 }
373 bad = append(bad, f.Path+": empty entry ("+where+") means the current directory")
374 case p == ".":
375 bad = append(bad, f.Path+": entry . (current directory)")
376 case strings.HasPrefix(p, "$") || strings.HasPrefix(p, "~"):
377 // Variable or home expansion: value depends on the login, not judged.
378 case !strings.HasPrefix(p, "/"):
379 bad = append(bad, f.Path+": relative entry "+p)
380 default:
381 if st, err := s.Stat(p); err == nil {
382 if st.Mode.Perm()&0o002 != 0 {
383 bad = append(bad, fmt.Sprintf("%s: %s is world-writable (mode %04o)", f.Path, p, st.Mode.Perm()))
384 } else if st.UID != 0 {
385 bad = append(bad, fmt.Sprintf("%s: %s is owned by uid %d, not root", f.Path, p, st.UID))
386 }
387 }
388 }
389 }
390 }
391 }
392 if !found {
393 if len(unreadable) > 0 {
394 return []check.Result{check.IncompleteR("root PATH", "Shell start-up files could not be read", unreadable...)}
395 }
396 return []check.Result{check.IncompleteR("root PATH", "No PATH= assignment found in root's shell start-up files; the effective PATH could not be determined from files alone")}
397 }
398 if len(bad) > 0 {
399 return []check.Result{check.FailR("root PATH", "Root's PATH contains unsafe entries", bad...)}
400 }
401 return []check.Result{check.PassR("root PATH", "PATH assignments contain only absolute, root-owned directories")}
402 },
403 })
404
405 check.Register(&check.Check{
406 ID: "HOST-PAM-009", Title: "No Shell Idle Timeout", Domain: check.DomainHost,
407 Severity: check.Low, Category: check.CatIdentity, Effort: check.EffortLow,
408 CVSS: lowLocal,
409 Summary: "Checks that interactive shells log out after at most 15 minutes without input (TMOUT).",
410 Rationale: "A root shell left open on an unattended terminal, or a forgotten SSH session on a shared workstation, is free privilege for whoever sits down next. TMOUT makes bash and ksh close an idle session automatically; readonly stops users from switching it off.",
411 Detection: "Reads /etc/profile, /etc/profile.d/*.sh, /etc/bash.bashrc and /etc/bashrc for a TMOUT=N assignment (optionally readonly, export, typeset -x or declare -x). N from 1 to 900 passes; 0, a larger value or no assignment fails. Incomplete when none of the files can be read.",
412 Fix: "Create /etc/profile.d/99-tmout.sh with TMOUT=900 ; readonly TMOUT ; export TMOUT (mode 644, owned by root). Sessions that must stay open should use a multiplexer such as tmux with its own lock, not a longer TMOUT. Caution: a readonly TMOUT ends idle interactive shells, including foreground jobs left waiting in them; run long jobs under nohup, systemd-run or a multiplexer.",
413 Verify: "grep -rE '^\\s*(readonly |export )?TMOUT=' /etc/profile /etc/profile.d/ /etc/bash.bashrc /etc/bashrc 2>/dev/null",
414 Refs: []check.Ref{refBash, refNIST80053},
415 Standards: check.Standards{CWE: []string{"CWE-613"}, OWASP: []string{"A07:2021"}, NIST: []string{"AC-11", "AC-12"}, CISTopic: "Default user shell timeout (CIS Controls v8 4.3)"},
416 Run: func(ctx context.Context, s sys.System) []check.Result {
417 files, unreadable := readAll(ctx, s, "/etc/profile", "/etc/profile.d/*.sh", "/etc/bash.bashrc", "/etc/bashrc")
418 if len(files) == 0 {
419 if len(unreadable) > 0 {
420 return []check.Result{check.IncompleteR("TMOUT", "Shell start-up files could not be read", unreadable...)}
421 }
422 return []check.Result{check.IncompleteR("TMOUT", "No shell start-up files were found")}
423 }
424 best, src := -1, ""
425 for _, f := range files {
426 for _, m := range tmoutLine.FindAllStringSubmatch(f.Text, -1) {
427 if n, err := strconv.Atoi(m[1]); err == nil && (best < 0 || n > best) {
428 best, src = n, f.Path
429 }
430 }
431 }
432 switch {
433 case best < 0:
434 return []check.Result{check.FailR("TMOUT", "No TMOUT is set in the shell start-up files")}
435 case best == 0 || best > 900:
436 return []check.Result{check.FailR(src, fmt.Sprintf("TMOUT=%d (need 1 to 900 seconds)", best))}
437 }
438 return []check.Result{check.PassR(src, fmt.Sprintf("TMOUT=%d", best))}
439 },
440 })
441}

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-PAM-005 Minimum Password Age Not Set (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-PAM-005.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_pam_005,
  author       = {{OpenVAPT}},
  title        = {{HOST-PAM-005 Minimum Password Age Not Set}},
  year         = {2026},
  howpublished = {\url{https://openvapt.com/checks/HOST-PAM-005.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.