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.
- umbermark/__init__.py +3 -0
- umbermark/checks/__init__.py +88 -0
- umbermark/checks/auto_updates_configured.py +149 -0
- umbermark/checks/dns_resolver_sanity.py +47 -0
- umbermark/checks/fail2ban_enabled.py +67 -0
- umbermark/checks/failed_login_anomaly.py +48 -0
- umbermark/checks/failed_systemd_units.py +41 -0
- umbermark/checks/firewall_protective.py +247 -0
- umbermark/checks/journald_persistence.py +55 -0
- umbermark/checks/kernel_age.py +74 -0
- umbermark/checks/listen_vs_firewall_delta.py +136 -0
- umbermark/checks/mac_framework_enforcing.py +97 -0
- umbermark/checks/pending_security_updates.py +188 -0
- umbermark/checks/public_listen_anomalies.py +200 -0
- umbermark/checks/reboot_pending.py +71 -0
- umbermark/checks/repo_gpg_trust.py +97 -0
- umbermark/checks/root_disk_free.py +46 -0
- umbermark/checks/ssh_non_default_port.py +62 -0
- umbermark/checks/ssh_password_auth.py +71 -0
- umbermark/checks/ssh_permit_root.py +59 -0
- umbermark/checks/ssh_weak_ciphers.py +85 -0
- umbermark/checks/sudo_requires_authentication.py +41 -0
- umbermark/checks/sudoers_nopasswd.py +111 -0
- umbermark/checks/swap_pressure.py +51 -0
- umbermark/checks/time_sync.py +55 -0
- umbermark/checks/tmp_hardening.py +45 -0
- umbermark/checks/user_password_aging.py +95 -0
- umbermark/checks/world_writable_suid_audit.py +171 -0
- umbermark/cli/__init__.py +4 -0
- umbermark/cli/app.py +23 -0
- umbermark/cli/auth.py +44 -0
- umbermark/cli/commands/__init__.py +0 -0
- umbermark/cli/commands/doctor.py +16 -0
- umbermark/cli/commands/login.py +80 -0
- umbermark/cli/commands/push.py +54 -0
- umbermark/cli/commands/report.py +26 -0
- umbermark/cli/commands/scan.py +77 -0
- umbermark/cli/commands/scan_local.py +72 -0
- umbermark/cli/commands/scan_wsl.py +35 -0
- umbermark/cli/commands/version.py +11 -0
- umbermark/cli/config.py +13 -0
- umbermark/core/__init__.py +0 -0
- umbermark/core/accepted.py +61 -0
- umbermark/core/access.py +896 -0
- umbermark/core/capture.py +228 -0
- umbermark/core/check.py +82 -0
- umbermark/core/checkin.py +89 -0
- umbermark/core/crypto.py +15 -0
- umbermark/core/duration.py +45 -0
- umbermark/core/engine.py +74 -0
- umbermark/core/external/__init__.py +6 -0
- umbermark/core/external/check.py +54 -0
- umbermark/core/external/checks/__init__.py +31 -0
- umbermark/core/external/checks/exposed_datastores.py +55 -0
- umbermark/core/external/checks/http_security_headers.py +249 -0
- umbermark/core/external/checks/legacy_services.py +52 -0
- umbermark/core/external/checks/open_management_ports.py +65 -0
- umbermark/core/external/checks/ssh_alt_ports.py +77 -0
- umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
- umbermark/core/external/checks/tls_hygiene.py +107 -0
- umbermark/core/external/engine.py +89 -0
- umbermark/core/external/ownership.py +607 -0
- umbermark/core/external/probe.py +103 -0
- umbermark/core/external/socket_probe.py +400 -0
- umbermark/core/firewall.py +713 -0
- umbermark/core/fleet.py +92 -0
- umbermark/core/history.py +235 -0
- umbermark/core/html_report.py +306 -0
- umbermark/core/keyenc.py +42 -0
- umbermark/core/labels.py +29 -0
- umbermark/core/lifecycle.py +100 -0
- umbermark/core/logredact.py +44 -0
- umbermark/core/partial.py +57 -0
- umbermark/core/pdf/__init__.py +0 -0
- umbermark/core/pdf/renderer.py +26 -0
- umbermark/core/pdf/weasyprint_renderer.py +18 -0
- umbermark/core/platform.py +73 -0
- umbermark/core/redact.py +75 -0
- umbermark/core/report.py +25 -0
- umbermark/core/scoring.py +105 -0
- umbermark/core/settings.py +238 -0
- umbermark/core/short_id.py +27 -0
- umbermark/core/signing.py +24 -0
- umbermark/core/ssh.py +156 -0
- umbermark/core/ssh_fingerprint.py +16 -0
- umbermark/core/tiers.py +128 -0
- umbermark/core/webhooks.py +177 -0
- umbermark/core/wording.py +371 -0
- umbermark_cli-1.0.0rc1.dist-info/METADATA +11 -0
- umbermark_cli-1.0.0rc1.dist-info/RECORD +93 -0
- umbermark_cli-1.0.0rc1.dist-info/WHEEL +5 -0
- umbermark_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|