Weak Password Hashing Algorithm Configured
Checks that new passwords are hashed with a modern, slow algorithm (SHA-512 or yescrypt).
- Why It Matters
- If the shadow file ever leaks, fast or obsolete hashes such as MD5 or DES can be cracked in minutes. Slow, salted algorithms make offline cracking far more expensive.
- How It Detects
- Reads ENCRYPT_METHOD from /etc/login.defs. SHA512 and YESCRYPT pass; MD5, DES, SHA256 or an unset value on systems that default to weaker hashing fail.
- How to Fix
- Set ENCRYPT_METHOD YESCRYPT (or SHA512 on older systems) in /etc/login.defs, and make sure pam_unix uses the same algorithm. Ask users to change passwords so existing hashes are upgraded.
- How to Verify
grep -i '^ENCRYPT_METHOD' /etc/login.defs- CVSS v3.1 Vector
CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/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-916, CWE-328
- OWASP Top 10
- A02:2021
- NIST SP 800-53 Rev. 5
- IA-5(1)
- Benchmark Topic
- Password hashing algorithm (topic only, no benchmark text)
- References
- login.defs(5), Linux man-pages
NIST SP 800-63B-4, Digital Identity Guidelines: Authentication and Authenticator Management
Source Code
The complete file that defines this check. Run it with openvapt scan --only HOST-PWD-001, or read it offline with openvapt explain HOST-PWD-001.
internal/checks/host/passwords.go · Lines 1-404View on GitHub at This Commit
| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strconv" |
| 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 | var ( |
| 15 | refLoginDefs = check.Ref{Title: "login.defs(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/login.defs.5.html"} |
| 16 | refNIST63B = check.Ref{Title: "NIST SP 800-63B-4, Digital Identity Guidelines: Authentication and Authenticator Management", URL: "https://pages.nist.gov/800-63-4/sp800-63b.html"} |
| 17 | refPwquality = check.Ref{Title: "pwquality.conf(5), libpwquality, Debian trixie manual pages", URL: "https://manpages.debian.org/trixie/libpwquality-common/pwquality.conf.5.en.html"} |
| 18 | refFaillock = check.Ref{Title: "pam_faillock(8), Linux-PAM", URL: "https://man7.org/linux/man-pages/man8/pam_faillock.8.html"} |
| 19 | ) |
| 20 | |
| 21 | // readText returns a file's contents, whether it was read completely, and |
| 22 | // whether it exists at all. |
| 23 | func readText(ctx context.Context, s sys.System, path string) (string, bool, bool) { |
| 24 | r, err := s.ReadFile(ctx, path) |
| 25 | if err != nil { |
| 26 | return "", false, false |
| 27 | } |
| 28 | return string(r.Data), r.Complete, true |
| 29 | } |
| 30 | |
| 31 | func init() { |
| 32 | check.Register(&check.Check{ |
| 33 | ID: "HOST-PWD-001", Title: "Weak Password Hashing Algorithm Configured", Domain: check.DomainHost, |
| 34 | Severity: check.Medium, Category: check.CatIdentity, Effort: check.EffortLow, |
| 35 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:N/A:N", |
| 36 | Summary: "Checks that new passwords are hashed with a modern, slow algorithm (SHA-512 or yescrypt).", |
| 37 | Rationale: "If the shadow file ever leaks, fast or obsolete hashes such as MD5 or DES can be cracked in minutes. Slow, salted algorithms make offline cracking far more expensive.", |
| 38 | Detection: "Reads ENCRYPT_METHOD from /etc/login.defs. SHA512 and YESCRYPT pass; MD5, DES, SHA256 or an unset value on systems that default to weaker hashing fail.", |
| 39 | Fix: "Set ENCRYPT_METHOD YESCRYPT (or SHA512 on older systems) in /etc/login.defs, and make sure pam_unix uses the same algorithm. Ask users to change passwords so existing hashes are upgraded.", |
| 40 | Verify: "grep -i '^ENCRYPT_METHOD' /etc/login.defs", |
| 41 | Refs: []check.Ref{refLoginDefs, refNIST63B}, |
| 42 | Standards: check.Standards{CWE: []string{"CWE-916", "CWE-328"}, OWASP: []string{"A02:2021"}, NIST: []string{"IA-5(1)"}, CISTopic: "Password hashing algorithm"}, |
| 43 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 44 | cfg := collect.ReadConfigs(ctx, s, "/etc/login.defs") |
| 45 | if len(cfg) == 0 { |
| 46 | return []check.Result{check.SkipR("/etc/login.defs", "File not found")} |
| 47 | } |
| 48 | v, _, ok := collect.First(cfg, "encrypt_method") |
| 49 | switch strings.ToUpper(v) { |
| 50 | case "SHA512", "YESCRYPT": |
| 51 | return []check.Result{check.PassR("/etc/login.defs", "ENCRYPT_METHOD "+strings.ToUpper(v))} |
| 52 | } |
| 53 | if !ok { |
| 54 | return []check.Result{check.FailR("/etc/login.defs", "ENCRYPT_METHOD is not set; set SHA512 or YESCRYPT explicitly")} |
| 55 | } |
| 56 | return []check.Result{check.FailR("/etc/login.defs", "ENCRYPT_METHOD "+v+" is weak")} |
| 57 | }, |
| 58 | }) |
| 59 | |
| 60 | check.Register(&check.Check{ |
| 61 | ID: "HOST-PWD-002", Title: "Accounts With Obsolete Password Hashes", Domain: check.DomainHost, |
| 62 | Severity: check.Medium, Category: check.CatIdentity, Effort: check.EffortLow, |
| 63 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:N", |
| 64 | Summary: "Checks /etc/shadow for passwords still stored with MD5 or traditional DES crypt.", |
| 65 | Rationale: "Old hashes survive configuration changes until the user changes the password. MD5-crypt and DES hashes can be cracked quickly with commodity hardware.", |
| 66 | Detection: "Reads /etc/shadow (needs root) and inspects only the hash prefix of each password field: $1$ (MD5) or a 13-character field without a $ prefix (DES) fail. The hash itself is never copied.", |
| 67 | Fix: "Configure a modern algorithm (HOST-PWD-001), then force a password change for each listed account (chage -d 0 <user>) or lock unused accounts (passwd -l <user>).", |
| 68 | Verify: "Re-run this check; no accounts should be listed.", |
| 69 | Refs: []check.Ref{{Title: "crypt(5), libxcrypt, Debian trixie manual pages (hashing methods)", URL: "https://manpages.debian.org/trixie/libcrypt-dev/crypt.5.en.html"}, refNIST63B}, |
| 70 | Standards: check.Standards{CWE: []string{"CWE-916", "CWE-328"}, OWASP: []string{"A02:2021"}, NIST: []string{"IA-5(1)"}}, |
| 71 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 72 | text, complete, ok := readText(ctx, s, "/etc/shadow") |
| 73 | if !ok { |
| 74 | return []check.Result{check.SkipR("/etc/shadow", "Not readable (run as root for this check)")} |
| 75 | } |
| 76 | var weak []string |
| 77 | for _, line := range strings.Split(text, "\n") { |
| 78 | f := strings.Split(line, ":") |
| 79 | if len(f) < 2 || f[0] == "" { |
| 80 | continue |
| 81 | } |
| 82 | h := f[1] |
| 83 | if strings.HasPrefix(h, "$1$") { |
| 84 | weak = append(weak, f[0]+" (MD5-crypt)") |
| 85 | } else if len(h) == 13 && !strings.ContainsAny(h, "$!*") { |
| 86 | weak = append(weak, f[0]+" (DES crypt)") |
| 87 | } |
| 88 | } |
| 89 | if len(weak) > 0 { |
| 90 | return []check.Result{check.FailR("/etc/shadow", "Accounts use obsolete password hashes", weak...)} |
| 91 | } |
| 92 | if !complete { |
| 93 | return []check.Result{check.IncompleteR("/etc/shadow", "File was only partly read")} |
| 94 | } |
| 95 | return []check.Result{check.PassR("/etc/shadow", "No obsolete password hashes")} |
| 96 | }, |
| 97 | }) |
| 98 | |
| 99 | check.Register(&check.Check{ |
| 100 | ID: "HOST-PWD-003", Title: "Short Minimum Password Length", Domain: check.DomainHost, |
| 101 | Severity: check.Low, Category: check.CatIdentity, Effort: check.EffortLow, |
| 102 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N", |
| 103 | Summary: "Checks that the password quality policy requires at least 15 characters.", |
| 104 | Rationale: "Length is the single most effective property against guessing. NIST SP 800-63B-4 (2025) requires at least 15 characters for a password used as the only authentication factor, and recommends allowing long passphrases.", |
| 105 | Detection: "Reads minlen from /etc/security/pwquality.conf and /etc/security/pwquality.conf.d/*.conf. A value of 15 or more passes; a value below 15, or no pwquality configuration, fails.", |
| 106 | Fix: "Install libpam-pwquality (Debian/Ubuntu) or libpwquality (RHEL family) and set minlen = 15 (or more) in /etc/security/pwquality.conf.", |
| 107 | Verify: "grep -r '^minlen' /etc/security/pwquality.conf /etc/security/pwquality.conf.d/", |
| 108 | Refs: []check.Ref{refPwquality, refNIST63B}, |
| 109 | Standards: check.Standards{CWE: []string{"CWE-521"}, OWASP: []string{"A07:2021"}, NIST: []string{"IA-5(1)"}, CISTopic: "Password creation requirements"}, |
| 110 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 111 | cfg := collect.ReadConfigs(ctx, s, "/etc/security/pwquality.conf.d/*.conf", "/etc/security/pwquality.conf") |
| 112 | v, path, ok := collect.First(cfg, "minlen") |
| 113 | if !ok { |
| 114 | return []check.Result{check.FailR("pwquality", "No minimum password length is configured (pwquality minlen)")} |
| 115 | } |
| 116 | n, err := strconv.Atoi(strings.TrimSpace(v)) |
| 117 | if err != nil || n < 15 { |
| 118 | return []check.Result{check.FailR(path, "minlen = "+v+" (NIST SP 800-63B-4 requires 15 or more for single-factor passwords)")} |
| 119 | } |
| 120 | return []check.Result{check.PassR(path, "minlen = "+v)} |
| 121 | }, |
| 122 | }) |
| 123 | |
| 124 | check.Register(&check.Check{ |
| 125 | ID: "HOST-PWD-004", Title: "No Account Lockout After Failed Logins", Domain: check.DomainHost, |
| 126 | Severity: check.Medium, Category: check.CatIdentity, Effort: check.EffortMedium, |
| 127 | CVSS: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N", |
| 128 | Summary: "Checks that PAM temporarily locks accounts after repeated failed logins.", |
| 129 | Rationale: "Without lockout, local and PAM-backed remote logins can be guessed indefinitely. A temporary lock after a few failures slows attacks and makes them visible. This check covers whether lockout exists and its deny threshold; HOST-PAM-002 covers only how long the lock lasts (unlock_time), so one missing lockout is reported once, here.", |
| 130 | Detection: "Reads the PAM authentication stacks (/etc/pam.d/common-auth, /etc/pam.d/system-auth, /etc/pam.d/password-auth) and requires pam_faillock in both the preauth and authfail positions (or pam_tally2) with deny set to 10 or fewer failures (pam_faillock defaults to 3; deny is read from the module line or /etc/security/faillock.conf).", |
| 131 | Fix: "Enable pam_faillock (authselect enable-feature with-faillock on RHEL family; pam-auth-update or a faillock profile on Debian/Ubuntu) with settings such as deny=5 unlock_time=900 in /etc/security/faillock.conf. Keep a root session open and test a login in a second session before closing it: a wrong PAM line can lock every user out, including administrators. Do not lock root (no even_deny_root) unless console access is assured.", |
| 132 | Verify: "grep -E 'pam_faillock|pam_tally2' /etc/pam.d/common-auth /etc/pam.d/system-auth", |
| 133 | Refs: []check.Ref{refFaillock, refNIST80053}, |
| 134 | Standards: check.Standards{CWE: []string{"CWE-307"}, OWASP: []string{"A07:2021"}, NIST: []string{"AC-7"}, CISTopic: "Lockout for failed password attempts"}, |
| 135 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 136 | found := false |
| 137 | var notes []string |
| 138 | for _, p := range []string{"/etc/pam.d/common-auth", "/etc/pam.d/system-auth", "/etc/pam.d/password-auth"} { |
| 139 | text, _, ok := readText(ctx, s, p) |
| 140 | if !ok { |
| 141 | continue |
| 142 | } |
| 143 | found = true |
| 144 | good, why := lockoutConfigured(ctx, s, text) |
| 145 | if good { |
| 146 | return []check.Result{check.PassR(p, "Account lockout configured: "+why)} |
| 147 | } |
| 148 | notes = append(notes, p+": "+why) |
| 149 | } |
| 150 | if !found { |
| 151 | return []check.Result{check.SkipR("pam", "PAM authentication stack not found")} |
| 152 | } |
| 153 | return []check.Result{check.FailR("pam", "No effective account lockout in the authentication stack", notes...)} |
| 154 | }, |
| 155 | }) |
| 156 | |
| 157 | check.Register(&check.Check{ |
| 158 | ID: "HOST-PWD-005", Title: "Forced Periodic Password Rotation Configured", Domain: check.DomainHost, |
| 159 | Severity: check.Info, Category: check.CatIdentity, Effort: check.EffortLow, |
| 160 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:N", |
| 161 | Summary: "Reports whether new accounts are forced to change passwords on a short fixed schedule.", |
| 162 | Rationale: "NIST SP 800-63B-4 (section 3.1.1.2) says verifiers should not require periodic password changes, and should force a change only on evidence of compromise. Frequent forced rotation pushes users toward predictable variations. CIS Controls v8 safeguard 5.2 asks for unique passwords with a minimum length (8 characters where multi-factor authentication is used, 14 where it is not) and does not ask for periodic changes. This finding is informational: a short PASS_MAX_DAYS is not a vulnerability, but it is worth reviewing against current guidance.", |
| 163 | Detection: "Reads PASS_MAX_DAYS from /etc/login.defs. A value below 365 days is reported. Unset, 99999 (no expiry) or 365 and above passes.", |
| 164 | Fix: "Prefer long passphrases, a breached-password blocklist and multi-factor authentication over forced rotation. If policy allows, set PASS_MAX_DAYS 365 or 99999 in /etc/login.defs and change passwords when compromise is suspected.", |
| 165 | Verify: "grep '^PASS_MAX_DAYS' /etc/login.defs", |
| 166 | Refs: []check.Ref{refLoginDefs, refNIST63B, {Title: "CIS Critical Security Controls v8, Control 5 (Account Management)", URL: "https://www.cisecurity.org/controls/account-management"}}, |
| 167 | Standards: check.Standards{CWE: []string{"CWE-262"}, NIST: []string{"IA-5(1)"}, CISTopic: "Password expiration"}, |
| 168 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 169 | cfg := collect.ReadConfigs(ctx, s, "/etc/login.defs") |
| 170 | if len(cfg) == 0 { |
| 171 | return []check.Result{check.SkipR("/etc/login.defs", "File not found")} |
| 172 | } |
| 173 | v, _, ok := collect.First(cfg, "pass_max_days") |
| 174 | if !ok { |
| 175 | return []check.Result{check.PassR("/etc/login.defs", "PASS_MAX_DAYS not set: no forced periodic rotation")} |
| 176 | } |
| 177 | n, err := strconv.Atoi(strings.TrimSpace(v)) |
| 178 | if err != nil { |
| 179 | return []check.Result{check.IncompleteR("/etc/login.defs", fmt.Sprintf("PASS_MAX_DAYS %q is not a number", v))} |
| 180 | } |
| 181 | if n >= 0 && n < 365 { |
| 182 | return []check.Result{check.FailR("/etc/login.defs", fmt.Sprintf("PASS_MAX_DAYS %d forces a password change every %d days; NIST SP 800-63B-4 advises against periodic rotation", n, n))} |
| 183 | } |
| 184 | return []check.Result{check.PassR("/etc/login.defs", "PASS_MAX_DAYS "+v+": no short forced rotation")} |
| 185 | }, |
| 186 | }) |
| 187 | |
| 188 | check.Register(&check.Check{ |
| 189 | ID: "HOST-USR-004", Title: "Duplicate User IDs", Domain: check.DomainHost, |
| 190 | Severity: check.Medium, Category: check.CatIdentity, Effort: check.EffortMedium, |
| 191 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N", |
| 192 | Summary: "Checks that no two accounts share the same numeric user id.", |
| 193 | Rationale: "Accounts with the same UID are the same user to the kernel: they own each other's files and processes, and audit logs cannot tell them apart.", |
| 194 | Detection: "Reads /etc/passwd and reports any UID used by more than one account.", |
| 195 | Fix: "Give each account a unique UID (usermod -u <new-uid> <user>) and fix file ownership (find / -xdev -uid <old-uid>).", |
| 196 | Verify: "cut -d: -f3 /etc/passwd | sort | uniq -d should print nothing.", |
| 197 | Refs: []check.Ref{{Title: "passwd(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/passwd.5.html"}}, |
| 198 | Standards: check.Standards{CWE: []string{"CWE-286"}, NIST: []string{"AC-2", "IA-2"}, CISTopic: "No duplicate UIDs"}, |
| 199 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 200 | text, _, ok := readText(ctx, s, "/etc/passwd") |
| 201 | if !ok { |
| 202 | return []check.Result{check.SkipR("/etc/passwd", "Not readable")} |
| 203 | } |
| 204 | byUID := map[string][]string{} |
| 205 | var order []string |
| 206 | for _, line := range strings.Split(text, "\n") { |
| 207 | f := strings.Split(line, ":") |
| 208 | if len(f) >= 3 && f[0] != "" && !strings.HasPrefix(f[0], "+") { |
| 209 | if _, seen := byUID[f[2]]; !seen { |
| 210 | order = append(order, f[2]) |
| 211 | } |
| 212 | byUID[f[2]] = append(byUID[f[2]], f[0]) |
| 213 | } |
| 214 | } |
| 215 | var dup []string |
| 216 | for _, uid := range order { |
| 217 | if len(byUID[uid]) > 1 { |
| 218 | dup = append(dup, "UID "+uid+": "+strings.Join(byUID[uid], ", ")) |
| 219 | } |
| 220 | } |
| 221 | if len(dup) > 0 { |
| 222 | return []check.Result{check.FailR("/etc/passwd", "Accounts share user ids", dup...)} |
| 223 | } |
| 224 | return []check.Result{check.PassR("/etc/passwd", "Every account has a unique UID")} |
| 225 | }, |
| 226 | }) |
| 227 | |
| 228 | check.Register(&check.Check{ |
| 229 | ID: "HOST-USR-005", Title: "System Accounts With Login Shells", Domain: check.DomainHost, |
| 230 | Severity: check.Low, Category: check.CatIdentity, Effort: check.EffortLow, |
| 231 | CVSS: "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:N", |
| 232 | Summary: "Checks that service accounts (UID below UID_MIN, usually 1000, other than root) cannot log in interactively.", |
| 233 | Rationale: "Service accounts should never need a shell. A login shell gives an attacker who compromises the service, or guesses its password, an interactive foothold.", |
| 234 | Detection: "Reads UID_MIN from /etc/login.defs (default 1000) and /etc/passwd, and lists accounts with UID 1 to UID_MIN minus 1 whose shell is not nologin, false, sync, halt or shutdown.", |
| 235 | Fix: "usermod -s /usr/sbin/nologin <account> for each listed account (use /sbin/nologin on RHEL family).", |
| 236 | Verify: "awk -F: '$3>0 && $3<1000 {print $1, $7}' /etc/passwd", |
| 237 | Refs: []check.Ref{{Title: "nologin(8), Linux man-pages", URL: "https://man7.org/linux/man-pages/man8/nologin.8.html"}}, |
| 238 | Standards: check.Standards{CWE: []string{"CWE-250"}, NIST: []string{"AC-2", "CM-7"}, CISTopic: "System accounts are secured"}, |
| 239 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 240 | text, _, ok := readText(ctx, s, "/etc/passwd") |
| 241 | if !ok { |
| 242 | return []check.Result{check.SkipR("/etc/passwd", "Not readable")} |
| 243 | } |
| 244 | uidMin := 1000 |
| 245 | if v, _, ok := collect.First(collect.ReadConfigs(ctx, s, "/etc/login.defs"), "uid_min"); ok { |
| 246 | if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { |
| 247 | uidMin = n |
| 248 | } |
| 249 | } |
| 250 | var bad []string |
| 251 | for _, line := range strings.Split(text, "\n") { |
| 252 | f := strings.Split(line, ":") |
| 253 | if len(f) < 7 { |
| 254 | continue |
| 255 | } |
| 256 | uid, err := strconv.Atoi(f[2]) |
| 257 | if err != nil || uid < 1 || uid >= uidMin { |
| 258 | continue |
| 259 | } |
| 260 | sh := f[6] |
| 261 | if strings.HasSuffix(sh, "nologin") || strings.HasSuffix(sh, "/false") || strings.HasSuffix(sh, "/sync") || strings.HasSuffix(sh, "/halt") || strings.HasSuffix(sh, "/shutdown") { |
| 262 | continue |
| 263 | } |
| 264 | bad = append(bad, f[0]+" ("+sh+")") |
| 265 | } |
| 266 | if len(bad) > 0 { |
| 267 | return []check.Result{check.FailR("/etc/passwd", "System accounts have login shells", bad...)} |
| 268 | } |
| 269 | return []check.Result{check.PassR("/etc/passwd", "System accounts have no login shell")} |
| 270 | }, |
| 271 | }) |
| 272 | |
| 273 | check.Register(&check.Check{ |
| 274 | ID: "HOST-USR-006", Title: "Legacy NIS Entries in Account Files", Domain: check.DomainHost, |
| 275 | Severity: check.Medium, Category: check.CatIdentity, Effort: check.EffortLow, |
| 276 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N", |
| 277 | Summary: "Checks /etc/passwd, /etc/shadow and /etc/group for legacy '+' NIS inclusion entries.", |
| 278 | Rationale: "'+' entries pull accounts from NIS, an unencrypted legacy directory service. If NIS is gone, the entry can still be abused to create unexpected accounts.", |
| 279 | Detection: "Reads the account files and lists lines beginning with '+'.", |
| 280 | Fix: "Remove the '+' lines (with vipw / vigr) and use a modern directory service such as SSSD with LDAPS if central accounts are needed.", |
| 281 | Verify: "grep '^+' /etc/passwd /etc/shadow /etc/group should print nothing.", |
| 282 | Refs: []check.Ref{{Title: "nsswitch.conf(5), Linux man-pages (compat mode)", URL: "https://man7.org/linux/man-pages/man5/nsswitch.conf.5.html"}}, |
| 283 | Standards: check.Standards{CWE: []string{"CWE-288"}, NIST: []string{"IA-2", "CM-7"}, CISTopic: "No legacy + entries"}, |
| 284 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 285 | var bad []string |
| 286 | for _, p := range []string{"/etc/passwd", "/etc/shadow", "/etc/group"} { |
| 287 | text, _, ok := readText(ctx, s, p) |
| 288 | if !ok { |
| 289 | continue |
| 290 | } |
| 291 | for _, line := range strings.Split(text, "\n") { |
| 292 | if strings.HasPrefix(line, "+") { |
| 293 | bad = append(bad, p+": "+strings.SplitN(line, ":", 2)[0]) |
| 294 | } |
| 295 | } |
| 296 | } |
| 297 | if len(bad) > 0 { |
| 298 | return []check.Result{check.FailR("account files", "Legacy NIS '+' entries found", bad...)} |
| 299 | } |
| 300 | return []check.Result{check.PassR("account files", "No legacy NIS entries")} |
| 301 | }, |
| 302 | }) |
| 303 | |
| 304 | check.Register(&check.Check{ |
| 305 | ID: "HOST-USR-007", Title: "Insecure Trust Files in Home Directories", Domain: check.DomainHost, |
| 306 | Severity: check.High, Category: check.CatIdentity, Effort: check.EffortLow, |
| 307 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N", |
| 308 | Summary: "Looks for .rhosts, .shosts and .netrc files in user home directories.", |
| 309 | Rationale: ".rhosts and .shosts grant password-less trust to other hosts. .netrc files usually hold plain-text passwords for FTP or other services.", |
| 310 | Detection: "Reads home directories from /etc/passwd and checks each for .rhosts, .shosts and .netrc (file existence only; contents are never read).", |
| 311 | Fix: "Delete .rhosts and .shosts files. Replace .netrc credentials with a secret manager or key-based authentication, then delete the file and rotate the passwords it contained.", |
| 312 | Verify: "Re-run this check; no files should be listed.", |
| 313 | Refs: []check.Ref{{Title: "MITRE ATT&CK T1552.001, Unsecured Credentials: Credentials In Files", URL: "https://attack.mitre.org/techniques/T1552/001/"}}, |
| 314 | Standards: check.Standards{CWE: []string{"CWE-256", "CWE-290"}, NIST: []string{"IA-5", "CM-6"}, CISTopic: "No .rhosts, .netrc files"}, |
| 315 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 316 | text, _, ok := readText(ctx, s, "/etc/passwd") |
| 317 | if !ok { |
| 318 | return []check.Result{check.SkipR("/etc/passwd", "Not readable")} |
| 319 | } |
| 320 | var bad []string |
| 321 | for _, line := range strings.Split(text, "\n") { |
| 322 | f := strings.Split(line, ":") |
| 323 | if len(f) < 6 || f[5] == "" || f[5] == "/" { |
| 324 | continue |
| 325 | } |
| 326 | for _, n := range []string{".rhosts", ".shosts", ".netrc"} { |
| 327 | if _, err := s.Stat(f[5] + "/" + n); err == nil { |
| 328 | bad = append(bad, f[5]+"/"+n) |
| 329 | } |
| 330 | } |
| 331 | } |
| 332 | if len(bad) > 0 { |
| 333 | return []check.Result{check.FailR("home directories", "Insecure trust or credential files found", bad...)} |
| 334 | } |
| 335 | return []check.Result{check.PassR("home directories", "No .rhosts, .shosts or .netrc files")} |
| 336 | }, |
| 337 | }) |
| 338 | } |
| 339 | |
| 340 | // lockoutConfigured reports whether a PAM auth stack locks accounts after at |
| 341 | // most 10 failures. pam_faillock must appear with both preauth and authfail; |
| 342 | // its deny value comes from the module line, then faillock.conf, then the |
| 343 | // built-in default of 3. pam_tally2 needs an explicit deny (it has no limit |
| 344 | // by default). |
| 345 | func lockoutConfigured(ctx context.Context, s sys.System, stack string) (bool, string) { |
| 346 | pre, fail := false, false |
| 347 | deny := -1 |
| 348 | tally := -1 |
| 349 | for _, line := range strings.Split(stack, "\n") { |
| 350 | t := strings.TrimSpace(line) |
| 351 | if t == "" || strings.HasPrefix(t, "#") { |
| 352 | continue |
| 353 | } |
| 354 | f := strings.Fields(t) |
| 355 | switch { |
| 356 | case strings.Contains(t, "pam_faillock"): |
| 357 | for _, a := range f { |
| 358 | switch { |
| 359 | case a == "preauth": |
| 360 | pre = true |
| 361 | case a == "authfail": |
| 362 | fail = true |
| 363 | case strings.HasPrefix(a, "deny="): |
| 364 | if n, err := strconv.Atoi(strings.TrimPrefix(a, "deny=")); err == nil { |
| 365 | deny = n |
| 366 | } |
| 367 | } |
| 368 | } |
| 369 | case strings.Contains(t, "pam_tally2"): |
| 370 | tally = 0 |
| 371 | for _, a := range f { |
| 372 | if strings.HasPrefix(a, "deny=") { |
| 373 | if n, err := strconv.Atoi(strings.TrimPrefix(a, "deny=")); err == nil { |
| 374 | tally = n |
| 375 | } |
| 376 | } |
| 377 | } |
| 378 | } |
| 379 | } |
| 380 | if pre || fail { |
| 381 | if !pre || !fail { |
| 382 | return false, "pam_faillock is missing its preauth or authfail line, so failures are not counted or not enforced" |
| 383 | } |
| 384 | if deny < 0 { |
| 385 | deny = 3 |
| 386 | if v, _, ok := collect.First(collect.ReadConfigs(ctx, s, "/etc/security/faillock.conf"), "deny"); ok { |
| 387 | if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { |
| 388 | deny = n |
| 389 | } |
| 390 | } |
| 391 | } |
| 392 | if deny < 1 || deny > 10 { |
| 393 | return false, fmt.Sprintf("pam_faillock deny=%d (need 1 to 10)", deny) |
| 394 | } |
| 395 | return true, fmt.Sprintf("pam_faillock preauth and authfail, deny=%d", deny) |
| 396 | } |
| 397 | if tally >= 0 { |
| 398 | if tally < 1 || tally > 10 { |
| 399 | return false, fmt.Sprintf("pam_tally2 deny=%d (need 1 to 10)", tally) |
| 400 | } |
| 401 | return true, fmt.Sprintf("pam_tally2 deny=%d", tally) |
| 402 | } |
| 403 | return false, "no pam_faillock or pam_tally2" |
| 404 | } |
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-PWD-001 Weak Password Hashing Algorithm Configured (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-PWD-001.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_pwd_001,
author = {{OpenVAPT}},
title = {{HOST-PWD-001 Weak Password Hashing Algorithm Configured}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/HOST-PWD-001.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.