Account Lockout Releases Too Quickly
Checks that a locked account stays locked for at least 15 minutes (or until an administrator unlocks it).
- Why It Matters
- 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.
- How It Detects
- 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.
- How to 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.
- How to Verify
grep -E 'unlock_time' /etc/security/faillock.conf /etc/pam.d/common-auth /etc/pam.d/system-auth 2>/dev/null- 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-307
- OWASP Top 10
- A07:2021
- NIST SP 800-53 Rev. 5
- AC-7
- Benchmark Topic
- Lockout duration for failed password attempts (CIS Controls v8 5.2) (topic only, no benchmark text)
- References
- pam_faillock(8), Linux-PAM
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-PAM-002, or read it offline with openvapt explain HOST-PAM-002.
internal/checks/host/pam_login.go · Lines 1-441View on GitHub at This Commit
| 1 | package host |
| 2 | |
| 3 | import ( |
| 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 | |
| 22 | var ( |
| 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 | |
| 30 | var ( |
| 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. |
| 39 | func 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. |
| 45 | func 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. |
| 60 | func 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. |
| 70 | func 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. |
| 88 | func 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 | |
| 119 | var ( |
| 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 | |
| 124 | func 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.
OpenVAPT. (2026). HOST-PAM-002 Account Lockout Releases Too Quickly (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-PAM-002.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_pam_002,
author = {{OpenVAPT}},
title = {{HOST-PAM-002 Account Lockout Releases Too Quickly}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/HOST-PAM-002.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.