Password and Shadow Files Out of Step
Checks that every /etc/passwd account has a /etc/shadow entry, that no password hash sits in /etc/passwd, and that /etc/shadow has no orphan entries.
- Why It Matters
- A hash in the world-readable /etc/passwd can be cracked offline by any user. An account with 'x' in passwd but no shadow line has an undefined password state that differs between tools, and shadow lines for deleted accounts are leftovers of a removal that did not finish.
- How It Detects
- Reads /etc/passwd and /etc/shadow (root only) and reports accounts missing from shadow, passwd password fields other than x, *, ! or empty, and shadow entries with no passwd account.
- How to Fix
- pwconv moves hashes into /etc/shadow and creates missing entries; userdel or vipw/vigr removes orphan lines; run pwck and grpck to confirm the files are consistent.
- How to Verify
pwck -r reports no errors.- CVSS v3.1 Vector
CVSS:3.1/AV:L/AC:L/PR:L/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-256, CWE-522
- NIST SP 800-53 Rev. 5
- IA-5, AC-2
- Benchmark Topic
- passwd and shadow consistency (topic only, no benchmark text)
- References
- shadow(5), Linux man-pages
passwd(5), Linux man-pages
pwck(8), Linux man-pages
Source Code
The complete file that defines this check. Run it with openvapt scan --only HOST-USR-009, or read it offline with openvapt explain HOST-USR-009.
internal/checks/host/hygiene.go · Lines 1-567View on GitHub at This Commit
| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "io/fs" |
| 7 | "path/filepath" |
| 8 | "regexp" |
| 9 | "sort" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | |
| 13 | "github.com/openvapt/openvapt/internal/check" |
| 14 | "github.com/openvapt/openvapt/internal/checks/collect" |
| 15 | "github.com/openvapt/openvapt/internal/sys" |
| 16 | ) |
| 17 | |
| 18 | // Cross-cutting hygiene checks: secrets lying in world-readable files, SSH |
| 19 | // authorized_keys options, cron jobs that run writable programs, PATH |
| 20 | // hijacking for root's cron, group and account file consistency and home |
| 21 | // directory permissions. Every file read here may contain secrets, so the |
| 22 | // report only ever names the file and the kind of pattern that matched; the |
| 23 | // engine redacts evidence again before it is written. |
| 24 | |
| 25 | var ( |
| 26 | refNISTIA5 = check.Ref{Title: "NIST SP 800-53 Rev. 5, IA-5 Authenticator Management", URL: "https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final"} |
| 27 | refSecretsCheat = check.Ref{Title: "OWASP Secrets Management Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html"} |
| 28 | refSSHDAuthKeys = check.Ref{Title: "sshd(8), AUTHORIZED_KEYS FILE FORMAT, OpenBSD manual pages", URL: "https://man.openbsd.org/sshd.8#AUTHORIZED_KEYS_FILE_FORMAT"} |
| 29 | refCrontab5 = check.Ref{Title: "crontab(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/crontab.5.html"} |
| 30 | refAttackCron = check.Ref{Title: "MITRE ATT&CK T1053.003, Scheduled Task/Job: Cron", URL: "https://attack.mitre.org/techniques/T1053/003/"} |
| 31 | refAttackPath = 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/"} |
| 32 | refGroup5 = check.Ref{Title: "group(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/group.5.html"} |
| 33 | refPasswd5 = check.Ref{Title: "passwd(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/passwd.5.html"} |
| 34 | refShadow5 = check.Ref{Title: "shadow(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/shadow.5.html"} |
| 35 | refNIST800123Hyg = check.Ref{Title: "NIST SP 800-123, Guide to General Server Security", URL: "https://csrc.nist.gov/pubs/sp/800/123/final"} |
| 36 | ) |
| 37 | |
| 38 | // secretPatterns are matched against the contents of world-readable |
| 39 | // configuration files. Only the label is reported, never the match. |
| 40 | var secretPatterns = []struct { |
| 41 | label string |
| 42 | re *regexp.Regexp |
| 43 | }{ |
| 44 | {"private key", regexp.MustCompile(`-----BEGIN (RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY( BLOCK)?-----`)}, |
| 45 | {"AWS access key id", regexp.MustCompile(`\b(AKIA|ASIA)[0-9A-Z]{16}\b`)}, |
| 46 | {"credential assignment", regexp.MustCompile(`(?im)^\s*(export\s+)?[A-Za-z0-9_.-]*(password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)[A-Za-z0-9_.-]*[ \t]*[=:][ \t]*["']?[^\s"'#$%<{(]{6,}`)}, |
| 47 | } |
| 48 | |
| 49 | // secretCandidate reports whether a file name is worth reading for secrets. |
| 50 | func secretCandidate(name string) bool { |
| 51 | lower := strings.ToLower(name) |
| 52 | if strings.HasPrefix(lower, ".env") || strings.HasPrefix(lower, "id_") { |
| 53 | return true |
| 54 | } |
| 55 | for _, ext := range []string{".conf", ".cnf", ".ini", ".yml", ".yaml", ".json", ".toml", ".pem", ".key", ".properties", ".cfg", ".env", ".secret", ".secrets", ".credentials"} { |
| 56 | if strings.HasSuffix(lower, ext) { |
| 57 | return true |
| 58 | } |
| 59 | } |
| 60 | return false |
| 61 | } |
| 62 | |
| 63 | // cronCommands returns the command part of every job line in a crontab. |
| 64 | // system is true for files with a user field (/etc/crontab, /etc/cron.d). |
| 65 | func cronCommands(text string, system bool) (cmds []string, path string) { |
| 66 | for _, line := range strings.Split(text, "\n") { |
| 67 | t := strings.TrimSpace(line) |
| 68 | if t == "" || strings.HasPrefix(t, "#") { |
| 69 | continue |
| 70 | } |
| 71 | if k, v, ok := strings.Cut(t, "="); ok && !strings.ContainsAny(k, " \t") { |
| 72 | if strings.TrimSpace(k) == "PATH" { |
| 73 | path = strings.Trim(strings.TrimSpace(v), `"'`) |
| 74 | } |
| 75 | continue |
| 76 | } |
| 77 | f := strings.Fields(t) |
| 78 | n := 5 |
| 79 | if strings.HasPrefix(t, "@") { |
| 80 | n = 1 |
| 81 | } |
| 82 | if system { |
| 83 | n++ |
| 84 | } |
| 85 | if len(f) <= n { |
| 86 | continue |
| 87 | } |
| 88 | cmds = append(cmds, strings.Join(f[n:], " ")) |
| 89 | } |
| 90 | return cmds, path |
| 91 | } |
| 92 | |
| 93 | // worldWritableDir reports whether a directory is writable by others |
| 94 | // without the sticky bit (anyone could replace files inside it). |
| 95 | func worldWritableDir(st sys.FileInfo) bool { |
| 96 | return st.IsDir && st.Mode.Perm()&0o002 != 0 && st.Mode&fs.ModeSticky == 0 |
| 97 | } |
| 98 | |
| 99 | func init() { |
| 100 | check.Register(&check.Check{ |
| 101 | ID: "HOST-SEC-001", Title: "Secrets in World-Readable Files", Domain: check.DomainHost, |
| 102 | Severity: check.High, Category: check.CatExposure, Effort: check.EffortMedium, |
| 103 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N", |
| 104 | Summary: "Looks inside world-readable configuration files under /etc and the web roots for private keys, cloud access keys and password assignments.", |
| 105 | Rationale: "A password or key in a file every local account can read is available to every website, service account and compromised process on the machine. Secrets found this way are the usual first step from a small web compromise to the database, the cloud account or root.", |
| 106 | Detection: "Walks /etc and each web document root (metadata only, symlinks not followed), picks regular files that are readable by others and have a configuration-like name (.env*, *.conf, *.ini, *.yml, *.json, *.pem, *.key, id_*, up to 300 files of at most 256 KiB), reads them without following symlinks and matches private key headers, AWS access key ids and credential assignments (password, secret, API key or token settings with a literal value). The report names the file and the kind of match only; values are never copied.", |
| 107 | Fix: "chmod o-r each listed file (640 with the service's group, or 600). Move secrets into root-only files, systemd credentials or a secrets manager, and rotate every key or password that was readable.", |
| 108 | Verify: "find /etc /var/www -type f -perm -0004 -name '.env*' -o -type f -perm -0004 -name '*.key' then re-run this check.", |
| 109 | Refs: []check.Ref{refSecretsCheat, refNISTIA5, refNIST800123Hyg}, |
| 110 | Standards: check.Standards{CWE: []string{"CWE-312", "CWE-732", "CWE-798"}, OWASP: []string{"A02:2021", "A05:2021"}, NIST: []string{"IA-5", "SC-28", "AC-3"}, CISTopic: "Protect credentials and keys at rest"}, |
| 111 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 112 | roots := append([]string{"/etc"}, collect.DocRoots(ctx, s)...) |
| 113 | var cands []sys.FileInfo |
| 114 | complete := true |
| 115 | for _, root := range roots { |
| 116 | w, err := s.Walk(ctx, root, 20000) |
| 117 | if err != nil && len(w.Files) == 0 { |
| 118 | complete = false |
| 119 | continue |
| 120 | } |
| 121 | if !w.Complete { |
| 122 | complete = false |
| 123 | } |
| 124 | for _, f := range w.Files { |
| 125 | if f.Mode.Perm()&0o004 != 0 && !f.IsDir && f.Size <= 256<<10 && secretCandidate(filepath.Base(f.Path)) { |
| 126 | cands = append(cands, f) |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | sort.Slice(cands, func(i, j int) bool { return cands[i].Path < cands[j].Path }) |
| 131 | if len(cands) > 300 { |
| 132 | cands = cands[:300] |
| 133 | complete = false |
| 134 | } |
| 135 | var bad []string |
| 136 | for _, f := range cands { |
| 137 | r, err := sys.ReadFileNoFollow(ctx, s, f.Path) |
| 138 | if err != nil { |
| 139 | continue |
| 140 | } |
| 141 | var kinds []string |
| 142 | for _, p := range secretPatterns { |
| 143 | if p.re.Match(r.Data) { |
| 144 | kinds = append(kinds, p.label) |
| 145 | } |
| 146 | } |
| 147 | if len(kinds) > 0 { |
| 148 | bad = append(bad, fmt.Sprintf("%s (mode %s): %s", f.Path, perm(f.Mode), strings.Join(kinds, ", "))) |
| 149 | } |
| 150 | } |
| 151 | if len(bad) > 0 { |
| 152 | if len(bad) > 30 { |
| 153 | bad = append(bad[:30], fmt.Sprintf("... and %d more", len(bad)-30)) |
| 154 | } |
| 155 | return []check.Result{check.FailR("filesystem", fmt.Sprintf("%d world-readable file(s) contain secrets", len(bad)), bad...)} |
| 156 | } |
| 157 | if !complete { |
| 158 | return []check.Result{check.IncompleteR("filesystem", fmt.Sprintf("No credential patterns in %d world-readable files, but the walk was partial", len(cands)))} |
| 159 | } |
| 160 | return []check.Result{check.PassR("filesystem", fmt.Sprintf("No credential patterns in %d world-readable configuration files", len(cands)))} |
| 161 | }, |
| 162 | }) |
| 163 | |
| 164 | check.Register(&check.Check{ |
| 165 | ID: "HOST-SSH-013", Title: "SSH Authorized Keys Without Restrictions or Protection", Domain: check.DomainHost, |
| 166 | Severity: check.Low, Category: check.CatIdentity, Effort: check.EffortLow, |
| 167 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:L/I:L/A:N", |
| 168 | Summary: "Checks root's authorized_keys for keys without from= or command= options, and every authorized_keys file for permissive modes.", |
| 169 | Rationale: "A key in root's authorized_keys is a root password that never expires. Restricting it with from= (allowed source addresses) or command= (one fixed command) limits what a stolen key can do. An authorized_keys file that others can write lets them add their own key; one that others can read tells them which keys to steal.", |
| 170 | Detection: "Reads /root/.ssh/authorized_keys and lists key lines with neither from= nor command=; stats /root/.ssh/authorized_keys and /home/*/.ssh/authorized_keys (metadata only) and flags group or other write, and other read, permissions. Key material is never reported.", |
| 171 | Fix: "Prefix each root key with options, for example from=\"203.0.113.0/24\",no-port-forwarding,no-agent-forwarding or command=\"/usr/local/bin/backup\",restrict ; chmod 600 ~/.ssh/authorized_keys and 700 ~/.ssh for every account. Prefer sudo from a personal account over direct root keys.", |
| 172 | Verify: "cut -c1-40 /root/.ssh/authorized_keys ; stat -c '%a %n' /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys", |
| 173 | Refs: []check.Ref{refSSHDAuthKeys, {Title: "NIST IR 7966, Security of Interactive and Automated Access Management Using Secure Shell (SSH)", URL: "https://csrc.nist.gov/pubs/ir/7966/final"}}, |
| 174 | Standards: check.Standards{CWE: []string{"CWE-732", "CWE-250"}, NIST: []string{"IA-5", "AC-6", "AC-17"}, CISTopic: "SSH key management and file permissions"}, |
| 175 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 176 | files := []string{"/root/.ssh/authorized_keys"} |
| 177 | more, _ := s.Glob("/home/*/.ssh/authorized_keys") |
| 178 | sort.Strings(more) |
| 179 | files = append(files, more...) |
| 180 | var bad []string |
| 181 | seen := 0 |
| 182 | for _, p := range files { |
| 183 | st, err := s.Stat(p) |
| 184 | if err != nil || st.IsDir { |
| 185 | continue |
| 186 | } |
| 187 | seen++ |
| 188 | if st.Mode.Perm()&0o022 != 0 { |
| 189 | bad = append(bad, perm(st.Mode)+" "+p+" is writable by group or others") |
| 190 | } else if st.Mode.Perm()&0o004 != 0 { |
| 191 | bad = append(bad, perm(st.Mode)+" "+p+" is readable by others") |
| 192 | } |
| 193 | if p != "/root/.ssh/authorized_keys" { |
| 194 | continue |
| 195 | } |
| 196 | t, _, ok := readText(ctx, s, p) |
| 197 | if !ok { |
| 198 | bad = append(bad, p+" could not be read") |
| 199 | continue |
| 200 | } |
| 201 | open := 0 |
| 202 | for _, line := range strings.Split(t, "\n") { |
| 203 | l := strings.TrimSpace(line) |
| 204 | if l == "" || strings.HasPrefix(l, "#") { |
| 205 | continue |
| 206 | } |
| 207 | if !strings.Contains(l, "from=") && !strings.Contains(l, "command=") { |
| 208 | open++ |
| 209 | } |
| 210 | } |
| 211 | if open > 0 { |
| 212 | bad = append(bad, fmt.Sprintf("%s: %d root key(s) without from= or command= restrictions", p, open)) |
| 213 | } |
| 214 | } |
| 215 | if seen == 0 { |
| 216 | return []check.Result{check.SkipR("authorized_keys", "No authorized_keys file exists")} |
| 217 | } |
| 218 | if len(bad) > 0 { |
| 219 | return []check.Result{check.FailR("authorized_keys", "SSH authorized keys lack restrictions or protection", bad...)} |
| 220 | } |
| 221 | return []check.Result{check.PassR("authorized_keys", fmt.Sprintf("%d authorized_keys file(s) are protected and root keys carry restrictions", seen))} |
| 222 | }, |
| 223 | }) |
| 224 | |
| 225 | check.Register(&check.Check{ |
| 226 | ID: "HOST-CRON-001", Title: "Cron Jobs Run Programs Other Users Can Modify", Domain: check.DomainHost, |
| 227 | Severity: check.High, Category: check.CatAccess, Effort: check.EffortLow, |
| 228 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", |
| 229 | Summary: "Resolves the programs and scripts that system and root cron jobs run and checks whether any is world-writable or sits in a world-writable folder.", |
| 230 | Rationale: "HOST-FS-005 protects the cron tables themselves, but a root job that runs /usr/local/bin/backup.sh is only as trustworthy as that file. If any user can edit the script, or replace it in a writable folder, their code runs as root at the next scheduled minute.", |
| 231 | Detection: "Parses /etc/crontab, /etc/cron.d/* and root's crontab in /var/spool/cron for job lines, stats every absolute path in each command (up to three per job) and every script in cron.hourly, cron.daily, cron.weekly and cron.monthly, and fails on a file with the 'other write' bit or a parent folder that is world-writable without the sticky bit.", |
| 232 | Fix: "chmod o-w <program> and its folder; move scripts out of /tmp or user-writable folders into /usr/local/sbin owned by root; have cron run only files owned by root (chown root:root).", |
| 233 | Verify: "for p in $(awk '/^[^#]/ && NF>6 {print $7}' /etc/crontab /etc/cron.d/*); do stat -c '%a %U %n' $p; done", |
| 234 | Refs: []check.Ref{refCrontab5, refAttackCron}, |
| 235 | Standards: check.Standards{CWE: []string{"CWE-732", "CWE-426"}, NIST: []string{"AC-3", "AC-6", "CM-5"}, CISTopic: "Job schedulers: cron permissions"}, |
| 236 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 237 | type job struct { |
| 238 | src string |
| 239 | cmd string |
| 240 | } |
| 241 | var jobs []job |
| 242 | seen := 0 |
| 243 | sources := []string{"/etc/crontab"} |
| 244 | more, _ := s.Glob("/etc/cron.d/*") |
| 245 | sort.Strings(more) |
| 246 | sources = append(sources, more...) |
| 247 | for _, src := range sources { |
| 248 | t, _, ok := readText(ctx, s, src) |
| 249 | if !ok { |
| 250 | continue |
| 251 | } |
| 252 | cmds, _ := cronCommands(t, true) |
| 253 | for _, c := range cmds { |
| 254 | jobs = append(jobs, job{src, c}) |
| 255 | } |
| 256 | } |
| 257 | for _, src := range []string{"/var/spool/cron/crontabs/root", "/var/spool/cron/root"} { |
| 258 | t, _, ok := readText(ctx, s, src) |
| 259 | if !ok { |
| 260 | continue |
| 261 | } |
| 262 | cmds, _ := cronCommands(t, false) |
| 263 | for _, c := range cmds { |
| 264 | jobs = append(jobs, job{src, c}) |
| 265 | } |
| 266 | } |
| 267 | var targets []string |
| 268 | for _, j := range jobs { |
| 269 | n := 0 |
| 270 | for _, tok := range strings.Fields(j.cmd) { |
| 271 | if strings.HasPrefix(tok, "/") && len(tok) > 1 { |
| 272 | targets = append(targets, tok) |
| 273 | if n++; n == 3 { |
| 274 | break |
| 275 | } |
| 276 | } |
| 277 | } |
| 278 | } |
| 279 | for _, dir := range []string{"/etc/cron.hourly", "/etc/cron.daily", "/etc/cron.weekly", "/etc/cron.monthly"} { |
| 280 | m, _ := s.Glob(dir + "/*") |
| 281 | targets = append(targets, m...) |
| 282 | } |
| 283 | sort.Strings(targets) |
| 284 | var bad []string |
| 285 | checked := map[string]bool{} |
| 286 | for _, p := range targets { |
| 287 | if checked[p] { |
| 288 | continue |
| 289 | } |
| 290 | checked[p] = true |
| 291 | st, err := s.Stat(p) |
| 292 | if err != nil || st.IsDir { |
| 293 | continue |
| 294 | } |
| 295 | seen++ |
| 296 | if st.Mode.Perm()&0o002 != 0 { |
| 297 | bad = append(bad, perm(st.Mode)+" "+p+" is world-writable") |
| 298 | } |
| 299 | if d, err := s.Stat(filepath.Dir(p)); err == nil && worldWritableDir(d) { |
| 300 | bad = append(bad, perm(d.Mode)+" "+filepath.Dir(p)+" (folder of "+filepath.Base(p)+") is world-writable without the sticky bit") |
| 301 | } |
| 302 | } |
| 303 | if len(bad) > 0 { |
| 304 | return []check.Result{check.FailR("cron", "Cron jobs run programs that other users can modify", bad...)} |
| 305 | } |
| 306 | if seen == 0 { |
| 307 | return []check.Result{check.SkipR("cron", "No cron job programs with absolute paths were found")} |
| 308 | } |
| 309 | return []check.Result{check.PassR("cron", fmt.Sprintf("%d cron program(s) checked; none are modifiable by other users", seen))} |
| 310 | }, |
| 311 | }) |
| 312 | |
| 313 | check.Register(&check.Check{ |
| 314 | ID: "HOST-CRON-002", Title: "World-Writable Directory in Cron's PATH", Domain: check.DomainHost, |
| 315 | Severity: check.High, Category: check.CatAccess, Effort: check.EffortLow, |
| 316 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", |
| 317 | Summary: "Checks the PATH that system cron jobs search for commands for world-writable or relative entries.", |
| 318 | Rationale: "A cron job that calls backup-db rather than /usr/local/sbin/backup-db runs whichever file of that name PATH finds first. If a folder on that PATH is world-writable, or the PATH contains an empty or relative entry, any user can plant a program that root runs on schedule (PATH interception).", |
| 319 | Detection: "Reads PATH= from /etc/crontab (default /usr/bin:/bin when absent), /etc/cron.d/* and /etc/environment, stats every folder (metadata only) and fails on a world-writable folder without the sticky bit or on an empty, '.' or relative entry. Folders that cannot be stat'ed make the result incomplete.", |
| 320 | Fix: "Remove the writable folder from PATH in /etc/crontab (or chmod o-w it), delete relative entries, and call programs in cron jobs by absolute path.", |
| 321 | Verify: "grep ^PATH /etc/crontab /etc/cron.d/* ; for d in $(grep ^PATH /etc/crontab | cut -d= -f2 | tr : ' '); do stat -c '%a %n' $d; done", |
| 322 | Refs: []check.Ref{refAttackPath, refCrontab5}, |
| 323 | Standards: check.Standards{CWE: []string{"CWE-426", "CWE-427"}, NIST: []string{"CM-6", "AC-6", "CM-7"}, CISTopic: "Root PATH integrity"}, |
| 324 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 325 | paths := map[string]string{} // dir -> source |
| 326 | var order []string |
| 327 | add := func(src, p string) { |
| 328 | for _, d := range strings.Split(p, ":") { |
| 329 | if _, ok := paths[d]; !ok { |
| 330 | paths[d] = src |
| 331 | order = append(order, d) |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | found := false |
| 336 | if t, _, ok := readText(ctx, s, "/etc/crontab"); ok { |
| 337 | found = true |
| 338 | if _, p := cronCommands(t, true); p != "" { |
| 339 | add("/etc/crontab", p) |
| 340 | } else { |
| 341 | add("/etc/crontab (default)", "/usr/bin:/bin") |
| 342 | } |
| 343 | } |
| 344 | more, _ := s.Glob("/etc/cron.d/*") |
| 345 | sort.Strings(more) |
| 346 | for _, src := range more { |
| 347 | if t, _, ok := readText(ctx, s, src); ok { |
| 348 | found = true |
| 349 | if _, p := cronCommands(t, true); p != "" { |
| 350 | add(src, p) |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | if t, _, ok := readText(ctx, s, "/etc/environment"); ok { |
| 355 | for _, line := range strings.Split(t, "\n") { |
| 356 | if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok && strings.TrimSpace(k) == "PATH" { |
| 357 | add("/etc/environment", strings.Trim(strings.TrimSpace(v), `"'`)) |
| 358 | } |
| 359 | } |
| 360 | } |
| 361 | if !found { |
| 362 | return []check.Result{check.SkipR("cron", "No system crontab found")} |
| 363 | } |
| 364 | var bad []string |
| 365 | unknown := 0 |
| 366 | for _, d := range order { |
| 367 | if d == "" || d == "." || !strings.HasPrefix(d, "/") { |
| 368 | bad = append(bad, fmt.Sprintf("%s: relative PATH entry %q", paths[d], d)) |
| 369 | continue |
| 370 | } |
| 371 | st, err := s.Stat(d) |
| 372 | if err != nil { |
| 373 | unknown++ |
| 374 | continue |
| 375 | } |
| 376 | if worldWritableDir(st) { |
| 377 | bad = append(bad, fmt.Sprintf("%s: %s %s is world-writable without the sticky bit", paths[d], perm(st.Mode), d)) |
| 378 | } |
| 379 | } |
| 380 | if len(bad) > 0 { |
| 381 | return []check.Result{check.FailR("cron PATH", "Cron's PATH can be hijacked", bad...)} |
| 382 | } |
| 383 | if unknown > 0 { |
| 384 | return []check.Result{check.IncompleteR("cron PATH", fmt.Sprintf("%d of %d PATH folders could not be checked", unknown, len(order)))} |
| 385 | } |
| 386 | return []check.Result{check.PassR("cron PATH", fmt.Sprintf("%d PATH folders are absolute and not world-writable", len(order)))} |
| 387 | }, |
| 388 | }) |
| 389 | |
| 390 | check.Register(&check.Check{ |
| 391 | ID: "HOST-USR-008", Title: "Duplicate Group IDs or Account Names", Domain: check.DomainHost, |
| 392 | Severity: check.Medium, Category: check.CatIdentity, Effort: check.EffortMedium, |
| 393 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N", |
| 394 | Summary: "Checks /etc/group for duplicate GIDs and group names, and /etc/passwd for duplicate user names.", |
| 395 | Rationale: "Two groups with one GID are the same group to the kernel, so file permissions granted to one are granted to both. Duplicate names make tools and audit logs act on the wrong entry, and are a sign that the account files were edited by hand or by a broken script.", |
| 396 | Detection: "Reads /etc/group and /etc/passwd and reports any GID, group name or user name that appears more than once (HOST-USR-004 covers duplicate UIDs).", |
| 397 | Fix: "groupmod -g <new-gid> <group> for one of each duplicate pair, then fix file ownership (find / -xdev -gid <old-gid>); remove or rename the duplicate name entries with vigr and vipw, which lock the files while editing.", |
| 398 | Verify: "cut -d: -f3 /etc/group | sort | uniq -d ; cut -d: -f1 /etc/group /etc/passwd | sort | uniq -d print nothing.", |
| 399 | Refs: []check.Ref{refGroup5, refPasswd5}, |
| 400 | Standards: check.Standards{CWE: []string{"CWE-286"}, NIST: []string{"AC-2", "AC-3"}, CISTopic: "No duplicate GIDs, group names or user names"}, |
| 401 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 402 | gt, _, okG := readText(ctx, s, "/etc/group") |
| 403 | pt, _, okP := readText(ctx, s, "/etc/passwd") |
| 404 | if !okG && !okP { |
| 405 | return []check.Result{check.SkipR("/etc/group", "Account files not readable")} |
| 406 | } |
| 407 | dups := func(text string, field int, what string) []string { |
| 408 | byKey := map[string][]string{} |
| 409 | var order []string |
| 410 | for _, line := range strings.Split(text, "\n") { |
| 411 | f := strings.Split(line, ":") |
| 412 | if len(f) <= field || f[0] == "" || strings.HasPrefix(f[0], "+") { |
| 413 | continue |
| 414 | } |
| 415 | if _, seen := byKey[f[field]]; !seen { |
| 416 | order = append(order, f[field]) |
| 417 | } |
| 418 | byKey[f[field]] = append(byKey[f[field]], f[0]) |
| 419 | } |
| 420 | var out []string |
| 421 | for _, k := range order { |
| 422 | if len(byKey[k]) > 1 { |
| 423 | out = append(out, what+" "+k+": "+strings.Join(byKey[k], ", ")) |
| 424 | } |
| 425 | } |
| 426 | return out |
| 427 | } |
| 428 | var bad []string |
| 429 | if okG { |
| 430 | bad = append(bad, dups(gt, 2, "GID")...) |
| 431 | bad = append(bad, dups(gt, 0, "group name")...) |
| 432 | } |
| 433 | if okP { |
| 434 | bad = append(bad, dups(pt, 0, "user name")...) |
| 435 | } |
| 436 | if len(bad) > 0 { |
| 437 | return []check.Result{check.FailR("/etc/group", "Duplicate group ids or names in the account files", bad...)} |
| 438 | } |
| 439 | if !okG || !okP { |
| 440 | return []check.Result{check.IncompleteR("/etc/group", "Only one of /etc/group and /etc/passwd could be read")} |
| 441 | } |
| 442 | return []check.Result{check.PassR("/etc/group", "GIDs, group names and user names are unique")} |
| 443 | }, |
| 444 | }) |
| 445 | |
| 446 | check.Register(&check.Check{ |
| 447 | ID: "HOST-USR-009", Title: "Password and Shadow Files Out of Step", Domain: check.DomainHost, |
| 448 | Severity: check.Medium, Category: check.CatIdentity, Effort: check.EffortLow, |
| 449 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N", |
| 450 | Summary: "Checks that every /etc/passwd account has a /etc/shadow entry, that no password hash sits in /etc/passwd, and that /etc/shadow has no orphan entries.", |
| 451 | Rationale: "A hash in the world-readable /etc/passwd can be cracked offline by any user. An account with 'x' in passwd but no shadow line has an undefined password state that differs between tools, and shadow lines for deleted accounts are leftovers of a removal that did not finish.", |
| 452 | Detection: "Reads /etc/passwd and /etc/shadow (root only) and reports accounts missing from shadow, passwd password fields other than x, *, ! or empty, and shadow entries with no passwd account.", |
| 453 | Fix: "pwconv moves hashes into /etc/shadow and creates missing entries; userdel or vipw/vigr removes orphan lines; run pwck and grpck to confirm the files are consistent.", |
| 454 | Verify: "pwck -r reports no errors.", |
| 455 | Refs: []check.Ref{refShadow5, refPasswd5, {Title: "pwck(8), Linux man-pages", URL: "https://man7.org/linux/man-pages/man8/pwck.8.html"}}, |
| 456 | Standards: check.Standards{CWE: []string{"CWE-256", "CWE-522"}, NIST: []string{"IA-5", "AC-2"}, CISTopic: "passwd and shadow consistency"}, |
| 457 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 458 | pt, _, okP := readText(ctx, s, "/etc/passwd") |
| 459 | if !okP { |
| 460 | return []check.Result{check.SkipR("/etc/passwd", "Not readable")} |
| 461 | } |
| 462 | st, _, okS := readText(ctx, s, "/etc/shadow") |
| 463 | if !okS { |
| 464 | return []check.Result{check.IncompleteR("/etc/shadow", "/etc/shadow is not readable (run as root)")} |
| 465 | } |
| 466 | users := map[string]bool{} |
| 467 | var bad []string |
| 468 | for _, line := range strings.Split(pt, "\n") { |
| 469 | f := strings.Split(line, ":") |
| 470 | if len(f) < 2 || f[0] == "" || strings.HasPrefix(f[0], "+") { |
| 471 | continue |
| 472 | } |
| 473 | users[f[0]] = true |
| 474 | switch f[1] { |
| 475 | case "x", "*", "!", "!!", "": |
| 476 | default: |
| 477 | bad = append(bad, f[0]+": password field in /etc/passwd holds a hash or value instead of x") |
| 478 | } |
| 479 | } |
| 480 | shadow := map[string]bool{} |
| 481 | for _, line := range strings.Split(st, "\n") { |
| 482 | f := strings.Split(line, ":") |
| 483 | if len(f) < 2 || f[0] == "" || strings.HasPrefix(f[0], "+") { |
| 484 | continue |
| 485 | } |
| 486 | shadow[f[0]] = true |
| 487 | if !users[f[0]] { |
| 488 | bad = append(bad, f[0]+": /etc/shadow entry has no /etc/passwd account") |
| 489 | } |
| 490 | } |
| 491 | names := make([]string, 0, len(users)) |
| 492 | for u := range users { |
| 493 | names = append(names, u) |
| 494 | } |
| 495 | sort.Strings(names) |
| 496 | for _, u := range names { |
| 497 | if !shadow[u] { |
| 498 | bad = append(bad, u+": /etc/passwd account has no /etc/shadow entry") |
| 499 | } |
| 500 | } |
| 501 | if len(bad) > 0 { |
| 502 | return []check.Result{check.FailR("/etc/passwd", "Account files are inconsistent", bad...)} |
| 503 | } |
| 504 | return []check.Result{check.PassR("/etc/passwd", fmt.Sprintf("%d accounts match between passwd and shadow", len(users)))} |
| 505 | }, |
| 506 | }) |
| 507 | |
| 508 | check.Register(&check.Check{ |
| 509 | ID: "HOST-USR-010", Title: "Home Directories Accessible to Other Users", Domain: check.DomainHost, |
| 510 | Severity: check.Low, Category: check.CatAccess, Effort: check.EffortLow, |
| 511 | CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N", |
| 512 | Summary: "Checks that the home directory of each interactive account denies read, write and execute to others.", |
| 513 | Rationale: "Home directories hold shell history, SSH keys, application configuration and downloads. With others allowed in, any account on the machine can read another user's history and keys or, with write access, plant a .bashrc that runs when that user logs in.", |
| 514 | Detection: "Reads interactive accounts from /etc/passwd (UID at or above UID_MIN, a real shell), stats each home directory (metadata only) and fails when others have any permission bit. Missing home directories are noted but do not fail.", |
| 515 | Fix: "chmod 750 /home/<user> for each listed account (700 if group access is not needed), and set HOME_MODE 0750 in /etc/login.defs or DIR_MODE=0750 in /etc/adduser.conf so new accounts start that way.", |
| 516 | Verify: "stat -c '%a %n' /home/* shows 750 or 700.", |
| 517 | Refs: []check.Ref{refNIST800123Hyg, {Title: "login.defs(5), Linux man-pages", URL: "https://man7.org/linux/man-pages/man5/login.defs.5.html"}}, |
| 518 | Standards: check.Standards{CWE: []string{"CWE-732", "CWE-276"}, NIST: []string{"AC-3", "AC-6"}, CISTopic: "Home directory permissions"}, |
| 519 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 520 | text, _, ok := readText(ctx, s, "/etc/passwd") |
| 521 | if !ok { |
| 522 | return []check.Result{check.SkipR("/etc/passwd", "Not readable")} |
| 523 | } |
| 524 | uidMin := 1000 |
| 525 | if v, _, ok := collect.First(collect.ReadConfigs(ctx, s, "/etc/login.defs"), "uid_min"); ok { |
| 526 | if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { |
| 527 | uidMin = n |
| 528 | } |
| 529 | } |
| 530 | var bad, missing []string |
| 531 | seen := 0 |
| 532 | for _, line := range strings.Split(text, "\n") { |
| 533 | f := strings.Split(line, ":") |
| 534 | if len(f) < 7 { |
| 535 | continue |
| 536 | } |
| 537 | uid, err := strconv.Atoi(f[2]) |
| 538 | if err != nil || uid < uidMin || uid == 65534 || f[5] == "" || f[5] == "/" { |
| 539 | continue |
| 540 | } |
| 541 | sh := f[6] |
| 542 | if strings.HasSuffix(sh, "nologin") || strings.HasSuffix(sh, "/false") { |
| 543 | continue |
| 544 | } |
| 545 | st, err := s.Stat(f[5]) |
| 546 | if err != nil { |
| 547 | missing = append(missing, f[0]+": "+f[5]+" does not exist") |
| 548 | continue |
| 549 | } |
| 550 | seen++ |
| 551 | if st.Mode.Perm()&0o007 != 0 { |
| 552 | bad = append(bad, fmt.Sprintf("%s %s (%s) is accessible to others", perm(st.Mode), f[5], f[0])) |
| 553 | } |
| 554 | } |
| 555 | if len(bad) > 0 { |
| 556 | return []check.Result{check.FailR("/home", "Home directories are accessible to other users", bad...)} |
| 557 | } |
| 558 | if seen == 0 { |
| 559 | if len(missing) > 0 { |
| 560 | return []check.Result{check.IncompleteR("/home", "No home directory could be checked", missing...)} |
| 561 | } |
| 562 | return []check.Result{check.SkipR("/home", "No interactive accounts with home directories")} |
| 563 | } |
| 564 | return []check.Result{check.PassR("/home", fmt.Sprintf("%d home directories deny access to others", seen), missing...)} |
| 565 | }, |
| 566 | }) |
| 567 | } |
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-USR-009 Password and Shadow Files Out of Step (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-USR-009.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_usr_009,
author = {{OpenVAPT}},
title = {{HOST-USR-009 Password and Shadow Files Out of Step}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/HOST-USR-009.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.