umbermark-cli 1.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. umbermark/__init__.py +3 -0
  2. umbermark/checks/__init__.py +88 -0
  3. umbermark/checks/auto_updates_configured.py +149 -0
  4. umbermark/checks/dns_resolver_sanity.py +47 -0
  5. umbermark/checks/fail2ban_enabled.py +67 -0
  6. umbermark/checks/failed_login_anomaly.py +48 -0
  7. umbermark/checks/failed_systemd_units.py +41 -0
  8. umbermark/checks/firewall_protective.py +247 -0
  9. umbermark/checks/journald_persistence.py +55 -0
  10. umbermark/checks/kernel_age.py +74 -0
  11. umbermark/checks/listen_vs_firewall_delta.py +136 -0
  12. umbermark/checks/mac_framework_enforcing.py +97 -0
  13. umbermark/checks/pending_security_updates.py +188 -0
  14. umbermark/checks/public_listen_anomalies.py +200 -0
  15. umbermark/checks/reboot_pending.py +71 -0
  16. umbermark/checks/repo_gpg_trust.py +97 -0
  17. umbermark/checks/root_disk_free.py +46 -0
  18. umbermark/checks/ssh_non_default_port.py +62 -0
  19. umbermark/checks/ssh_password_auth.py +71 -0
  20. umbermark/checks/ssh_permit_root.py +59 -0
  21. umbermark/checks/ssh_weak_ciphers.py +85 -0
  22. umbermark/checks/sudo_requires_authentication.py +41 -0
  23. umbermark/checks/sudoers_nopasswd.py +111 -0
  24. umbermark/checks/swap_pressure.py +51 -0
  25. umbermark/checks/time_sync.py +55 -0
  26. umbermark/checks/tmp_hardening.py +45 -0
  27. umbermark/checks/user_password_aging.py +95 -0
  28. umbermark/checks/world_writable_suid_audit.py +171 -0
  29. umbermark/cli/__init__.py +4 -0
  30. umbermark/cli/app.py +23 -0
  31. umbermark/cli/auth.py +44 -0
  32. umbermark/cli/commands/__init__.py +0 -0
  33. umbermark/cli/commands/doctor.py +16 -0
  34. umbermark/cli/commands/login.py +80 -0
  35. umbermark/cli/commands/push.py +54 -0
  36. umbermark/cli/commands/report.py +26 -0
  37. umbermark/cli/commands/scan.py +77 -0
  38. umbermark/cli/commands/scan_local.py +72 -0
  39. umbermark/cli/commands/scan_wsl.py +35 -0
  40. umbermark/cli/commands/version.py +11 -0
  41. umbermark/cli/config.py +13 -0
  42. umbermark/core/__init__.py +0 -0
  43. umbermark/core/accepted.py +61 -0
  44. umbermark/core/access.py +896 -0
  45. umbermark/core/capture.py +228 -0
  46. umbermark/core/check.py +82 -0
  47. umbermark/core/checkin.py +89 -0
  48. umbermark/core/crypto.py +15 -0
  49. umbermark/core/duration.py +45 -0
  50. umbermark/core/engine.py +74 -0
  51. umbermark/core/external/__init__.py +6 -0
  52. umbermark/core/external/check.py +54 -0
  53. umbermark/core/external/checks/__init__.py +31 -0
  54. umbermark/core/external/checks/exposed_datastores.py +55 -0
  55. umbermark/core/external/checks/http_security_headers.py +249 -0
  56. umbermark/core/external/checks/legacy_services.py +52 -0
  57. umbermark/core/external/checks/open_management_ports.py +65 -0
  58. umbermark/core/external/checks/ssh_alt_ports.py +77 -0
  59. umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
  60. umbermark/core/external/checks/tls_hygiene.py +107 -0
  61. umbermark/core/external/engine.py +89 -0
  62. umbermark/core/external/ownership.py +607 -0
  63. umbermark/core/external/probe.py +103 -0
  64. umbermark/core/external/socket_probe.py +400 -0
  65. umbermark/core/firewall.py +713 -0
  66. umbermark/core/fleet.py +92 -0
  67. umbermark/core/history.py +235 -0
  68. umbermark/core/html_report.py +306 -0
  69. umbermark/core/keyenc.py +42 -0
  70. umbermark/core/labels.py +29 -0
  71. umbermark/core/lifecycle.py +100 -0
  72. umbermark/core/logredact.py +44 -0
  73. umbermark/core/partial.py +57 -0
  74. umbermark/core/pdf/__init__.py +0 -0
  75. umbermark/core/pdf/renderer.py +26 -0
  76. umbermark/core/pdf/weasyprint_renderer.py +18 -0
  77. umbermark/core/platform.py +73 -0
  78. umbermark/core/redact.py +75 -0
  79. umbermark/core/report.py +25 -0
  80. umbermark/core/scoring.py +105 -0
  81. umbermark/core/settings.py +238 -0
  82. umbermark/core/short_id.py +27 -0
  83. umbermark/core/signing.py +24 -0
  84. umbermark/core/ssh.py +156 -0
  85. umbermark/core/ssh_fingerprint.py +16 -0
  86. umbermark/core/tiers.py +128 -0
  87. umbermark/core/webhooks.py +177 -0
  88. umbermark/core/wording.py +371 -0
  89. umbermark_cli-1.0.0rc1.dist-info/METADATA +11 -0
  90. umbermark_cli-1.0.0rc1.dist-info/RECORD +93 -0
  91. umbermark_cli-1.0.0rc1.dist-info/WHEEL +5 -0
  92. umbermark_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
  93. umbermark_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,71 @@
1
+ """SSH: PasswordAuthentication should be `no` (keys only)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from umbermark.core.access import get_access_context, scale_for_reachability
7
+ from umbermark.core.check import Check, Finding, Severity, Verdict
8
+ from umbermark.core.ssh import CommandRunner
9
+
10
+
11
+ class SSHPasswordAuthCheck(Check):
12
+ id = "ssh_password_authentication"
13
+ title = "SSH password authentication disabled (keys only)"
14
+ category = "identity_auth"
15
+ severity = Severity.CRITICAL
16
+ weight = 16
17
+ remediation = (
18
+ "Disable password logins for SSH. IMPORTANT: sshd keeps the FIRST value it\n"
19
+ "reads for a keyword. /etc/ssh/sshd_config normally begins with\n"
20
+ "`Include /etc/ssh/sshd_config.d/*.conf`, so drop-ins are read first, in\n"
21
+ "lexical order, and a later `PasswordAuthentication no` in the main file\n"
22
+ "is ignored. Cloud images (cloud-init) ship\n"
23
+ "/etc/ssh/sshd_config.d/50-cloud-init.conf containing\n"
24
+ "`PasswordAuthentication yes`, which silently wins.\n"
25
+ "Fix: create a drop-in that sorts BEFORE it, e.g.\n"
26
+ " /etc/ssh/sshd_config.d/01-hardening.conf:\n"
27
+ " PasswordAuthentication no\n"
28
+ " KbdInteractiveAuthentication no\n"
29
+ "(or edit/remove the offending drop-in; for cloud-init set\n"
30
+ "`ssh_pwauth: false` so it is not regenerated).\n"
31
+ "Verify the effective value with `sudo sshd -T | grep -i passwordauthentication`,\n"
32
+ "confirm a key login works from a second session, then reload sshd."
33
+ )
34
+
35
+ def run(self, runner: CommandRunner) -> Finding:
36
+ r = runner.run("sshd -T 2>/dev/null", sudo=True, timeout=10)
37
+ if r.ok and r.stdout:
38
+ m = re.search(r"^passwordauthentication\s+(\S+)", r.stdout, re.M | re.I)
39
+ if m:
40
+ value = m.group(1).lower()
41
+ if value == "yes":
42
+ ctx = get_access_context(runner)
43
+ sev, weight, note = scale_for_reachability(self.severity, self.weight, ctx)
44
+ evidence = f"PasswordAuthentication = {value}"
45
+ if ctx.ssh_password_sources:
46
+ evidence += f" (set by: {'; '.join(ctx.ssh_password_sources[:3])})"
47
+ if note:
48
+ evidence += f". {note}"
49
+ return self._finding(
50
+ Verdict.FAIL, evidence=evidence, severity=sev, weight=weight
51
+ )
52
+ ctx = get_access_context(runner)
53
+ if ctx.ssh_match_password:
54
+ # `sshd -T` shows the non-Match value; a Match block re-enables passwords.
55
+ crit, where = ctx.ssh_match_password[0]
56
+ sev, weight, note = scale_for_reachability(self.severity, self.weight, ctx)
57
+ evidence = (
58
+ f"PasswordAuthentication = {value} globally, but password auth enabled for "
59
+ f"Match {crit} ({where})"
60
+ )
61
+ if note:
62
+ evidence += f". {note}"
63
+ return self._finding(Verdict.WARN, evidence=evidence, severity=sev, weight=weight)
64
+ return self._finding(
65
+ Verdict.PASS, evidence=f"PasswordAuthentication = {value}"
66
+ )
67
+
68
+ return self._finding(
69
+ Verdict.SKIP,
70
+ skip_reason="could not determine effective PasswordAuthentication (sshd -T failed)",
71
+ )
@@ -0,0 +1,59 @@
1
+ """SSH: PermitRootLogin must not be `yes`."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from umbermark.core.check import Check, Finding, Severity, Verdict
7
+ from umbermark.core.ssh import CommandRunner
8
+
9
+
10
+ class SSHPermitRootLoginCheck(Check):
11
+ id = "ssh_permit_root_login"
12
+ title = "SSH root login disabled"
13
+ category = "identity_auth"
14
+ severity = Severity.CRITICAL
15
+ weight = 18
16
+ remediation = (
17
+ "Disable root SSH login by setting in /etc/ssh/sshd_config (or a file "
18
+ "under sshd_config.d/):\n"
19
+ " PermitRootLogin no\n"
20
+ "Then reload sshd: `sudo systemctl reload ssh` (Debian/Ubuntu) or "
21
+ "`sudo systemctl reload sshd` (RHEL-like)."
22
+ )
23
+
24
+ def run(self, runner: CommandRunner) -> Finding:
25
+ # Effective config dump (requires root)
26
+ r = runner.run("sshd -T 2>/dev/null", sudo=True, timeout=10)
27
+ if r.ok and r.stdout:
28
+ m = re.search(r"^permitrootlogin\s+(\S+)", r.stdout, re.M | re.I)
29
+ if m:
30
+ value = m.group(1).lower()
31
+ if value == "yes":
32
+ return self._finding(Verdict.FAIL, evidence=f"PermitRootLogin = {value}")
33
+ return self._finding(Verdict.PASS, evidence=f"PermitRootLogin = {value}")
34
+
35
+ # Fallback: parse sshd_config (misses Include directives)
36
+ r2 = runner.run(
37
+ "grep -i '^[[:space:]]*PermitRootLogin' /etc/ssh/sshd_config 2>/dev/null"
38
+ )
39
+ if r2.ok and r2.stdout:
40
+ m2 = re.search(r"PermitRootLogin\s+(\S+)", r2.stdout, re.I)
41
+ if m2:
42
+ value = m2.group(1).lower()
43
+ if value == "yes":
44
+ return self._finding(
45
+ Verdict.FAIL,
46
+ evidence=f"sshd_config: PermitRootLogin {value}",
47
+ )
48
+ return self._finding(
49
+ Verdict.WARN,
50
+ evidence=(
51
+ f"sshd_config: PermitRootLogin {value} "
52
+ "(could not verify effective config; sshd -T requires root)"
53
+ ),
54
+ )
55
+
56
+ return self._finding(
57
+ Verdict.SKIP,
58
+ skip_reason="could not determine effective PermitRootLogin (sshd -T failed and no explicit config line)",
59
+ )
@@ -0,0 +1,85 @@
1
+ """SSH: weak ciphers / MACs / KexAlgorithms must not be enabled."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from umbermark.core.access import get_access_context, scale_for_reachability
7
+ from umbermark.core.check import Check, Finding, Severity, Verdict
8
+ from umbermark.core.ssh import CommandRunner
9
+
10
+ # Weak crypto: OpenSSH's deprecation history + Mozilla guidance (2025+).
11
+ _WEAK_CIPHERS = {
12
+ "3des-cbc", "blowfish-cbc", "cast128-cbc",
13
+ "arcfour", "arcfour128", "arcfour256",
14
+ "aes128-cbc", "aes192-cbc", "aes256-cbc",
15
+ }
16
+ _WEAK_MACS = {
17
+ "hmac-md5", "hmac-md5-96", "hmac-md5-etm@openssh.com",
18
+ "hmac-md5-96-etm@openssh.com",
19
+ "hmac-sha1-96", "hmac-sha1", "hmac-sha1-etm@openssh.com",
20
+ "hmac-sha1-96-etm@openssh.com",
21
+ "umac-64@openssh.com", "umac-64-etm@openssh.com",
22
+ }
23
+ _WEAK_KEX = {
24
+ "diffie-hellman-group1-sha1",
25
+ "diffie-hellman-group14-sha1",
26
+ "diffie-hellman-group-exchange-sha1",
27
+ }
28
+
29
+
30
+ def _parse_csv_value(text: str, key: str) -> list[str]:
31
+ m = re.search(rf"^{key}\s+(\S+)", text, re.M | re.I)
32
+ if not m:
33
+ return []
34
+ return m.group(1).split(",")
35
+
36
+
37
+ class SSHWeakCiphersCheck(Check):
38
+ id = "ssh_weak_ciphers"
39
+ title = "SSH disallows weak ciphers / MACs / key exchange"
40
+ category = "identity_auth"
41
+ severity = Severity.HIGH
42
+ weight = 10
43
+ applicable_distros = ()
44
+ remediation = (
45
+ "Restrict SSH to strong crypto. In /etc/ssh/sshd_config:\n"
46
+ " Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr\n"
47
+ " MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com\n"
48
+ " KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512\n"
49
+ "Then reload sshd."
50
+ )
51
+
52
+ def run(self, runner: CommandRunner) -> Finding:
53
+ r = runner.run("sshd -T 2>/dev/null", sudo=True, timeout=10)
54
+ if not r.ok or not r.stdout:
55
+ return self._finding(
56
+ Verdict.SKIP,
57
+ skip_reason="could not run sshd -T (requires root and openssh-server)",
58
+ )
59
+ ciphers = set(_parse_csv_value(r.stdout, "ciphers"))
60
+ macs = set(_parse_csv_value(r.stdout, "macs"))
61
+ kex = set(_parse_csv_value(r.stdout, "kexalgorithms"))
62
+ bad_c = ciphers & _WEAK_CIPHERS
63
+ bad_m = macs & _WEAK_MACS
64
+ bad_k = kex & _WEAK_KEX
65
+ problems = []
66
+ if bad_c:
67
+ problems.append(f"weak ciphers: {','.join(sorted(bad_c))}")
68
+ if bad_m:
69
+ problems.append(f"weak MACs: {','.join(sorted(bad_m))}")
70
+ if bad_k:
71
+ problems.append(f"weak KEX: {','.join(sorted(bad_k))}")
72
+ if problems:
73
+ ctx = get_access_context(runner)
74
+ sev, weight, note = scale_for_reachability(self.severity, self.weight, ctx)
75
+ evidence = "; ".join(problems) + (f". {note}" if note else "")
76
+ return self._finding(Verdict.FAIL, evidence=evidence, severity=sev, weight=weight)
77
+ if not (ciphers or macs or kex):
78
+ return self._finding(
79
+ Verdict.SKIP,
80
+ skip_reason="sshd -T did not emit ciphers/macs/kexalgorithms lines",
81
+ )
82
+ return self._finding(
83
+ Verdict.PASS,
84
+ evidence=f"{len(ciphers)} ciphers, {len(macs)} MACs, {len(kex)} KEX — none weak",
85
+ )
@@ -0,0 +1,41 @@
1
+ """sudo must ask for a credential: no pam_permit.so may be reachable after the real module fails.
2
+
3
+ A PAM stack that permits unconditionally turns every local account that can run
4
+ sudo into root with no authentication at all, regardless of sudoers. That includes a pam_permit reached once the real module
5
+ fails (e.g. `sufficient pam_sss.so` followed by `sufficient pam_permit.so`). This reads
6
+ the effective `auth` stack for sudo (following @include / include / substack),
7
+ see core.access.classify_sudo_auth.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from umbermark.core.access import SudoAuth, get_access_context
12
+ from umbermark.core.check import Check, Finding, Severity, Verdict
13
+ from umbermark.core.ssh import CommandRunner
14
+
15
+
16
+ class SudoRequiresAuthenticationCheck(Check):
17
+ id = "sudo_requires_authentication"
18
+ title = "sudo requires authentication (PAM)"
19
+ category = "identity_auth"
20
+ severity = Severity.HIGH
21
+ weight = 12
22
+ applicable_distros = ()
23
+ remediation = (
24
+ "Make sure a failed authentication can never reach pam_permit.so in sudo's PAM auth stack:\n"
25
+ " - delete any `auth sufficient pam_permit.so` or `auth [success=done ...] pam_permit.so`\n"
26
+ " line from /etc/pam.d/sudo and the files it includes (common-auth / system-auth);\n"
27
+ " a real module placed before it does not help, because a failing module just falls\n"
28
+ " through to the permit;\n"
29
+ " - if a trailing `auth required pam_permit.so` is needed (Debian's common-auth), put\n"
30
+ " `auth requisite pam_deny.so` directly before it, after the real module, so a\n"
31
+ " failed login stops there.\n"
32
+ "Debian: `pam-auth-update` regenerates /etc/pam.d/common-auth safely."
33
+ )
34
+
35
+ def run(self, runner: CommandRunner) -> Finding:
36
+ ctx = get_access_context(runner)
37
+ if ctx.sudo_auth == SudoAuth.NONE:
38
+ return self._finding(Verdict.FAIL, evidence=f"sudo requires no authentication: {ctx.sudo_auth_note}")
39
+ if ctx.sudo_auth in (SudoAuth.LOCAL_PASSWORD, SudoAuth.DIRECTORY):
40
+ return self._finding(Verdict.PASS, evidence=ctx.sudo_auth_note)
41
+ return self._finding(Verdict.SKIP, skip_reason=f"sudo PAM stack undetermined ({ctx.sudo_auth_note})")
@@ -0,0 +1,111 @@
1
+ """sudoers NOPASSWD audit — flags wide-scope passwordless sudo.
2
+
3
+ Account-aware: NOPASSWD only matters if someone can reach the account.
4
+ * shell is nologin/false, or the password is locked (no usable password):
5
+ the account can only get in with an SSH key, and passwordless sudo is then the
6
+ only way sudo can work at all. Ignored (noted in evidence).
7
+ * account has a usable password: anyone who learns/guesses it gets root with
8
+ no second step -> flagged (medium, scaled down when SSH is not publicly
9
+ reachable).
10
+ * groups (%sudo ...), directory/unknown users, or unreadable account data:
11
+ cannot be proven safe -> flagged as before (high when account data is
12
+ unavailable).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ from umbermark.core.access import get_access_context, scale_for_reachability
19
+ from umbermark.core.check import Check, Finding, Severity, Verdict
20
+ from umbermark.core.ssh import CommandRunner
21
+
22
+
23
+ def _is_wide_scope(rule: str) -> bool:
24
+ """A NOPASSWD line is 'wide scope' if it grants ALL commands OR applies
25
+ to a privileged group (%sudo, %wheel, %admin)."""
26
+ # Whole-group NOPASSWD line
27
+ if re.match(r"^\s*%(sudo|wheel|admin|root)\b", rule):
28
+ return True
29
+ # NOPASSWD: ALL (any user/group with no command restriction)
30
+ if re.search(r"NOPASSWD\s*:\s*ALL\b", rule, re.I):
31
+ return True
32
+ return False
33
+
34
+
35
+ class SudoersNopasswdCheck(Check):
36
+ id = "sudoers_nopasswd"
37
+ title = "sudoers NOPASSWD audit"
38
+ category = "identity_auth"
39
+ severity = Severity.HIGH
40
+ weight = 12
41
+ applicable_distros = ()
42
+ remediation = (
43
+ "Audit /etc/sudoers and /etc/sudoers.d/* for NOPASSWD entries.\n"
44
+ "Avoid blanket grants like:\n"
45
+ " %wheel ALL=(ALL) NOPASSWD: ALL\n"
46
+ "Prefer narrow, command-specific grants:\n"
47
+ " deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx\n"
48
+ "Accounts that log in with a password should require it for sudo.\n"
49
+ "Edit only with `sudo visudo` (validates syntax before save)."
50
+ )
51
+
52
+ def run(self, runner: CommandRunner) -> Finding:
53
+ # -h strips the filename prefix so the parser doesn't have to.
54
+ r = runner.run(
55
+ "grep -rEh '^[[:space:]]*[^#].*NOPASSWD' /etc/sudoers /etc/sudoers.d/ 2>/dev/null",
56
+ sudo=True,
57
+ timeout=10,
58
+ )
59
+ if not r.ok and r.exit_code != 1:
60
+ # exit_code 1 from grep = "no matches" — that's a PASS, not an error
61
+ return self._finding(
62
+ Verdict.SKIP,
63
+ skip_reason=f"grep over sudoers failed (exit {r.exit_code}); check requires root",
64
+ )
65
+ lines = [ln for ln in r.stdout.splitlines() if ln.strip()]
66
+ if not lines:
67
+ return self._finding(Verdict.PASS, evidence="no NOPASSWD entries in /etc/sudoers or /etc/sudoers.d/")
68
+ wide = [ln for ln in lines if _is_wide_scope(ln)]
69
+ if not wide:
70
+ return self._finding(
71
+ Verdict.WARN,
72
+ evidence=f"{len(lines)} narrow NOPASSWD rule(s) (specific commands, not blanket ALL)",
73
+ )
74
+
75
+ ctx = get_access_context(runner)
76
+ if not ctx.accounts_readable:
77
+ return self._finding(
78
+ Verdict.FAIL,
79
+ evidence=f"{len(wide)} wide-scope NOPASSWD rule(s): {'; '.join(wide[:3])}",
80
+ )
81
+
82
+ ignored: list[str] = []
83
+ flagged: list[str] = []
84
+ for ln in wide:
85
+ subject = ln.split()[0] if ln.split() else ""
86
+ acct = ctx.accounts.get(subject)
87
+ if acct is None or subject.startswith("%"):
88
+ flagged.append(ln) # group, directory user or unknown: cannot prove safe
89
+ elif not acct.can_login or not acct.usable_password:
90
+ why = "no login shell" if not acct.can_login else "account locked"
91
+ ignored.append(f"{subject} (ignored: {why})")
92
+ else:
93
+ flagged.append(ln)
94
+ if not flagged:
95
+ return self._finding(
96
+ Verdict.PASS,
97
+ evidence=(
98
+ f"{len(wide)} wide NOPASSWD rule(s) ignored: all for accounts without a usable password "
99
+ f"or login shell, so sudo can only be reached with an SSH key: {', '.join(ignored)}"
100
+ ),
101
+ )
102
+ sev, weight, note = scale_for_reachability(Severity.MEDIUM, 6, ctx)
103
+ evidence = (
104
+ f"{len(flagged)} wide-scope NOPASSWD rule(s) for accounts with a usable password: "
105
+ f"{'; '.join(flagged[:3])}"
106
+ )
107
+ if ignored:
108
+ evidence += f" ({len(ignored)} rule(s) for locked/no-login accounts ignored)"
109
+ if note:
110
+ evidence += f". {note}"
111
+ return self._finding(Verdict.FAIL, evidence=evidence, severity=sev, weight=weight)
@@ -0,0 +1,51 @@
1
+ """Sustained swap pressure — vmstat si/so columns."""
2
+ from __future__ import annotations
3
+
4
+ from umbermark.core.check import Check, Finding, Severity, Verdict
5
+ from umbermark.core.ssh import CommandRunner
6
+
7
+
8
+ class SwapPressureCheck(Check):
9
+ id = "swap_pressure"
10
+ title = "No sustained swap pressure"
11
+ category = "services"
12
+ severity = Severity.LOW
13
+ weight = 2
14
+ applicable_distros = ()
15
+ remediation = (
16
+ "Find out what is using the memory and relieve it:\n"
17
+ " - identify the memory-hungry process: ps aux --sort=-%mem | head\n"
18
+ " - tune swappiness lower: sudo sysctl vm.swappiness=10\n"
19
+ " - add RAM or shrink the workload\n"
20
+ " - confirm /proc/meminfo SwapCached isn't masking healthy use"
21
+ )
22
+
23
+ def run(self, runner: CommandRunner) -> Finding:
24
+ r = runner.run("vmstat 1 3 2>/dev/null | tail -1", timeout=10)
25
+ if not r.ok or not r.stdout.strip():
26
+ return self._finding(
27
+ Verdict.SKIP, skip_reason="vmstat not available or returned no data"
28
+ )
29
+ cols = r.stdout.strip().split()
30
+ # vmstat columns: r b swpd free buff cache si so bi bo in cs us sy id wa st
31
+ # si is index 6, so is index 7
32
+ if len(cols) < 8:
33
+ return self._finding(
34
+ Verdict.SKIP, skip_reason=f"vmstat output unparseable: {r.stdout!r}"
35
+ )
36
+ try:
37
+ si = int(cols[6])
38
+ so = int(cols[7])
39
+ except ValueError:
40
+ return self._finding(
41
+ Verdict.SKIP, skip_reason=f"vmstat si/so non-numeric: {cols[6]!r}, {cols[7]!r}"
42
+ )
43
+ evidence = f"vmstat si={si}, so={so} (KB/sec)"
44
+ if si == 0 and so == 0:
45
+ return self._finding(Verdict.PASS, evidence=evidence)
46
+ if si < 100 and so < 100:
47
+ return self._finding(Verdict.WARN, evidence=evidence + " — low but non-zero")
48
+ return self._finding(
49
+ Verdict.FAIL,
50
+ evidence=evidence + " — sustained pressure (RAM exhausted?)",
51
+ )
@@ -0,0 +1,55 @@
1
+ """Time sync — clock synchronized + NTP service active."""
2
+ from __future__ import annotations
3
+
4
+ from umbermark.core.check import Check, Finding, Severity, Verdict
5
+ from umbermark.core.ssh import CommandRunner
6
+
7
+
8
+ class TimeSyncCheck(Check):
9
+ id = "time_sync"
10
+ title = "System clock is synced + NTP service is active"
11
+ category = "network"
12
+ severity = Severity.MEDIUM
13
+ weight = 5
14
+ applicable_distros = ()
15
+ remediation = (
16
+ "Ensure a time-sync daemon is running:\n"
17
+ " sudo systemctl enable --now systemd-timesyncd # default on most modern distros\n"
18
+ " # OR for chrony:\n"
19
+ " sudo apt install chrony && sudo systemctl enable --now chrony\n"
20
+ "Cert validation, log correlation, and many auth flows fail badly\n"
21
+ "with clock drift > a few minutes."
22
+ )
23
+
24
+ def run(self, runner: CommandRunner) -> Finding:
25
+ r = runner.run("timedatectl status 2>/dev/null", timeout=5)
26
+ if not r.ok or not r.stdout:
27
+ return self._finding(
28
+ Verdict.SKIP, skip_reason="timedatectl unavailable or returned nothing"
29
+ )
30
+ # Use exact equality — "inactive" contains "active" as a substring.
31
+ synced = self._line_value(r.stdout, "System clock synchronized") == "yes"
32
+ active = self._line_value(r.stdout, "NTP service") == "active"
33
+ if synced and active:
34
+ return self._finding(
35
+ Verdict.PASS, evidence="clock synced and NTP service active"
36
+ )
37
+ if synced and not active:
38
+ return self._finding(
39
+ Verdict.WARN,
40
+ evidence="clock synced but NTP service inactive (drift possible)",
41
+ )
42
+ return self._finding(
43
+ Verdict.FAIL,
44
+ evidence=(
45
+ "clock not synced"
46
+ + (" (NTP service active but failing)" if active else " (no NTP)")
47
+ ),
48
+ )
49
+
50
+ @staticmethod
51
+ def _line_value(text: str, key: str) -> str:
52
+ for line in text.splitlines():
53
+ if key in line:
54
+ return line.split(":", 1)[-1].strip().lower()
55
+ return ""
@@ -0,0 +1,45 @@
1
+ """/tmp mounted with noexec + nosuid options."""
2
+ from __future__ import annotations
3
+
4
+ from umbermark.core.check import Check, Finding, Severity, Verdict
5
+ from umbermark.core.ssh import CommandRunner
6
+
7
+
8
+ class TmpHardeningCheck(Check):
9
+ id = "tmp_hardening"
10
+ title = "/tmp mounted with noexec + nosuid"
11
+ category = "filesystem"
12
+ severity = Severity.MEDIUM
13
+ weight = 4
14
+ applicable_distros = ()
15
+ remediation = (
16
+ "Mount /tmp with hardened options. In /etc/fstab:\n"
17
+ " tmpfs /tmp tmpfs defaults,noexec,nosuid,nodev,size=2G 0 0\n"
18
+ "Or for a disk-backed /tmp:\n"
19
+ " UUID=... /tmp ext4 defaults,noexec,nosuid,nodev 0 2\n"
20
+ "Then remount: sudo mount -o remount /tmp"
21
+ )
22
+
23
+ def run(self, runner: CommandRunner) -> Finding:
24
+ r = runner.run("findmnt /tmp -no OPTIONS 2>/dev/null", timeout=5)
25
+ if not r.ok or not r.stdout.strip():
26
+ return self._finding(
27
+ Verdict.WARN,
28
+ evidence="/tmp is not a separate mount (inherits root fs options — less hardening surface)",
29
+ )
30
+ opts = r.stdout.strip().split(",")
31
+ has_noexec = "noexec" in opts
32
+ has_nosuid = "nosuid" in opts
33
+ if has_noexec and has_nosuid:
34
+ return self._finding(
35
+ Verdict.PASS, evidence=f"/tmp options: {','.join(opts)}"
36
+ )
37
+ missing = []
38
+ if not has_noexec:
39
+ missing.append("noexec")
40
+ if not has_nosuid:
41
+ missing.append("nosuid")
42
+ return self._finding(
43
+ Verdict.WARN,
44
+ evidence=f"/tmp mounted but missing {', '.join(missing)} (current: {','.join(opts)})",
45
+ )
@@ -0,0 +1,95 @@
1
+ """Accounts that can log in with a password but have no password-aging policy.
2
+
3
+ Informational by design. NIST SP 800-63B section 5.1.1.2 advises verifiers
4
+ AGAINST forcing periodic password changes (it drives weaker, predictable
5
+ passwords); rotate on evidence of compromise instead. The check still reports
6
+ usable-password accounts without a max age so operators who must satisfy a
7
+ rotation policy (some compliance regimes) can see them. Locked accounts
8
+ (`passwd -S` = L: shadow field starting `!` or `*`) cannot authenticate with a
9
+ password and are ignored, as are accounts whose sudo/auth is delegated to a
10
+ directory (the directory owns the password policy).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from umbermark.core.access import SudoAuth, get_access_context
17
+ from umbermark.core.check import Check, Finding, Severity, Verdict
18
+ from umbermark.core.ssh import CommandRunner
19
+
20
+ # Iterate shell-having users and dump chage info per user, prefixed so the
21
+ # parser can attribute output back to a user.
22
+ _CMD = (
23
+ "for u in $(awk -F: '$7 ~ /[/](bash|zsh|sh|dash|ksh|csh|tcsh|fish)$/ {print $1}' /etc/passwd); do "
24
+ "echo \"USER: $u\"; "
25
+ "chage -l \"$u\" 2>&1; "
26
+ "done"
27
+ )
28
+
29
+
30
+ class UserPasswordAgingCheck(Check):
31
+ id = "user_password_aging"
32
+ title = "User accounts have a password-aging policy (informational)"
33
+ category = "identity_auth"
34
+ severity = Severity.LOW
35
+ weight = 1
36
+ applicable_distros = ()
37
+ remediation = (
38
+ "Only set a maximum password age if a policy you must meet requires it. NIST SP 800-63B section 5.1.1.2 recommends AGAINST forced\n"
39
+ "periodic rotation; prefer SSH keys / MFA and change passwords on evidence\n"
40
+ "of compromise. To set one:\n"
41
+ " sudo chage -M 90 <user>\n"
42
+ "or set system-wide defaults in /etc/login.defs (PASS_MAX_DAYS 90).\n"
43
+ "Accounts that do not need a password at all can be locked: `sudo passwd -l <user>`."
44
+ )
45
+
46
+ def run(self, runner: CommandRunner) -> Finding:
47
+ r = runner.run(_CMD, sudo=True, timeout=15)
48
+ if not r.ok or not r.stdout:
49
+ return self._finding(
50
+ Verdict.SKIP,
51
+ skip_reason="could not enumerate users (chage requires root)",
52
+ )
53
+ ctx = get_access_context(runner)
54
+ bad_users: list[str] = []
55
+ current_user: str | None = None
56
+ for line in r.stdout.splitlines():
57
+ mu = re.match(r"^USER:\s+(\S+)", line)
58
+ if mu:
59
+ current_user = mu.group(1)
60
+ continue
61
+ mm = re.search(
62
+ r"Maximum number of days between password change\s*:\s*(\S+)",
63
+ line,
64
+ )
65
+ if mm and current_user is not None:
66
+ value = mm.group(1)
67
+ if value in ("99999", "-1", "never"):
68
+ bad_users.append(current_user)
69
+ current_user = None
70
+
71
+ if ctx.sudo_auth == SudoAuth.DIRECTORY:
72
+ return self._finding(
73
+ Verdict.PASS,
74
+ evidence=f"{ctx.sudo_auth_note}; the directory owns the password policy, local aging is not authoritative",
75
+ )
76
+ locked: list[str] = []
77
+ if ctx.accounts_readable:
78
+ usable = [u for u in bad_users if u not in ctx.accounts or ctx.accounts[u].usable_password]
79
+ locked = [u for u in bad_users if u not in usable]
80
+ bad_users = usable
81
+ if not bad_users:
82
+ extra = f" ({len(locked)} locked account(s) ignored)" if locked else ""
83
+ return self._finding(
84
+ Verdict.PASS,
85
+ evidence="all password-capable shell users have a password-max-days policy" + extra,
86
+ )
87
+ extra = f"; {len(locked)} locked account(s) ignored" if locked else ""
88
+ return self._finding(
89
+ Verdict.WARN,
90
+ evidence=(
91
+ f"{len(bad_users)} account(s) with a usable password and no max-password-age: "
92
+ f"{', '.join(bad_users[:5])}{extra}. Informational: NIST SP 800-63B 5.1.1.2 advises "
93
+ "against forced periodic rotation."
94
+ ),
95
+ )