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
MediumHOST-USR-004Identity and AuthenticationServer

Duplicate User IDs

Checks that no two accounts share the same numeric user id.

Why It Matters
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.
How It Detects
Reads /etc/passwd and reports any UID used by more than one account.
How to Fix
Give each account a unique UID (usermod -u <new-uid> <user>) and fix file ownership (find / -xdev -uid <old-uid>).
How to Verify
cut -d: -f3 /etc/passwd | sort | uniq -d should print nothing.
CVSS v3.1 Vector
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N
Control Category
Identity and Authentication: CIS Controls v8 5, 6; NIST CSF 2.0 PR.AA
Fix Effort
Medium
CIS Controls v8 Safeguard
5.2
NIST CSF 2.0 Subcategory
PR.AA-01
CWE
CWE-286
NIST SP 800-53 Rev. 5
AC-2, IA-2
Benchmark Topic
No duplicate UIDs (topic only, no benchmark text)
References
passwd(5), Linux man-pages
Learn the theory behind this check: P7 Identity, Passwords and Privilege, P15 Access Control: Accounts, Sudo and Logins.

Source Code

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

internal/checks/host/passwords.go · Lines 1-404View on GitHub at This Commit
1package host
2
3import (
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
14var (
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.
23func 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
31func 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).
345func 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.

APA 7
OpenVAPT. (2026). HOST-USR-004 Duplicate User IDs (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-USR-004.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_usr_004,
  author       = {{OpenVAPT}},
  title        = {{HOST-USR-004 Duplicate User IDs}},
  year         = {2026},
  howpublished = {\url{https://openvapt.com/checks/HOST-USR-004.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.