Skip to Content
OpenVAPTAlpha
Pre-release. Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. Free for your own use, 100% at your own risk. This website collects no data. Read MoreRead Less
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
LowHOST-SSH-013Identity and AuthenticationServer

SSH Authorized Keys Without Restrictions or Protection

Checks root's authorized_keys for keys without from= or command= options, and every authorized_keys file for permissive modes.

Why It Matters
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.
How It Detects
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.
How to 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.
How to Verify
cut -c1-40 /root/.ssh/authorized_keys ; stat -c '%a %n' /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys
CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:H/PR:H/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
Low
CIS Controls v8 Safeguard
4.1
NIST CSF 2.0 Subcategory
PR.PS-01
CWE
CWE-732, CWE-250
NIST SP 800-53 Rev. 5
IA-5, AC-6, AC-17
Benchmark Topic
SSH key management and file permissions (topic only, no benchmark text)
References
sshd(8), AUTHORIZED_KEYS FILE FORMAT, OpenBSD manual pages
NIST IR 7966, Security of Interactive and Automated Access Management Using Secure Shell (SSH)
Learn the theory behind this check: P2 SSH Security, P20 Secrets, Cron and Account Hygiene.

Source Code

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

internal/checks/host/hygiene.go · Lines 1-567View on GitHub at This Commit
1package host
2
3import (
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
25var (
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.
40var 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.
50func 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).
65func 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).
95func worldWritableDir(st sys.FileInfo) bool {
96 return st.IsDir && st.Mode.Perm()&0o002 != 0 && st.Mode&fs.ModeSticky == 0
97}
98
99func 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.

APA 7
OpenVAPT. (2026). HOST-SSH-013 SSH Authorized Keys Without Restrictions or Protection (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-SSH-013.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_ssh_013,
  author       = {{OpenVAPT}},
  title        = {{HOST-SSH-013 SSH Authorized Keys Without Restrictions or Protection}},
  year         = {2026},
  howpublished = {\url{https://openvapt.com/checks/HOST-SSH-013.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.