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
umbermark/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Umbermark — Linux server hardening scanner."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,88 @@
1
+ """Check registry — order is the display order in reports."""
2
+ from __future__ import annotations
3
+
4
+ from umbermark.checks.auto_updates_configured import AutoUpdatesConfiguredCheck
5
+ from umbermark.checks.dns_resolver_sanity import DnsResolverSanityCheck
6
+ from umbermark.checks.fail2ban_enabled import Fail2banEnabledCheck
7
+ from umbermark.checks.failed_login_anomaly import FailedLoginAnomalyCheck
8
+ from umbermark.checks.failed_systemd_units import FailedSystemdUnitsCheck
9
+ from umbermark.checks.firewall_protective import FirewallProtectiveCheck
10
+ from umbermark.checks.journald_persistence import JournaldPersistenceCheck
11
+ from umbermark.checks.kernel_age import KernelAgeCheck
12
+ from umbermark.checks.listen_vs_firewall_delta import ListenVsFirewallDeltaCheck
13
+ from umbermark.checks.mac_framework_enforcing import MacFrameworkEnforcingCheck
14
+ from umbermark.checks.pending_security_updates import PendingSecurityUpdatesCheck
15
+ from umbermark.checks.public_listen_anomalies import PublicListenAnomaliesCheck
16
+ from umbermark.checks.reboot_pending import RebootPendingCheck
17
+ from umbermark.checks.repo_gpg_trust import RepoGpgTrustCheck
18
+ from umbermark.checks.root_disk_free import RootDiskFreeCheck
19
+ from umbermark.checks.ssh_non_default_port import SSHNonDefaultPortCheck
20
+ from umbermark.checks.ssh_password_auth import SSHPasswordAuthCheck
21
+ from umbermark.checks.ssh_permit_root import SSHPermitRootLoginCheck
22
+ from umbermark.checks.ssh_weak_ciphers import SSHWeakCiphersCheck
23
+ from umbermark.checks.sudo_requires_authentication import SudoRequiresAuthenticationCheck
24
+ from umbermark.checks.sudoers_nopasswd import SudoersNopasswdCheck
25
+ from umbermark.checks.swap_pressure import SwapPressureCheck
26
+ from umbermark.checks.time_sync import TimeSyncCheck
27
+ from umbermark.checks.tmp_hardening import TmpHardeningCheck
28
+ from umbermark.checks.user_password_aging import UserPasswordAgingCheck
29
+ from umbermark.checks.world_writable_suid_audit import WorldWritableSuidAuditCheck
30
+
31
+ ALL_CHECKS = [
32
+ SSHPermitRootLoginCheck(),
33
+ SSHPasswordAuthCheck(),
34
+ SSHWeakCiphersCheck(),
35
+ SSHNonDefaultPortCheck(),
36
+ SudoersNopasswdCheck(),
37
+ SudoRequiresAuthenticationCheck(),
38
+ UserPasswordAgingCheck(),
39
+ FailedLoginAnomalyCheck(),
40
+ Fail2banEnabledCheck(),
41
+ KernelAgeCheck(),
42
+ PendingSecurityUpdatesCheck(),
43
+ RebootPendingCheck(),
44
+ AutoUpdatesConfiguredCheck(),
45
+ RepoGpgTrustCheck(),
46
+ FirewallProtectiveCheck(),
47
+ TimeSyncCheck(),
48
+ DnsResolverSanityCheck(),
49
+ RootDiskFreeCheck(),
50
+ TmpHardeningCheck(),
51
+ FailedSystemdUnitsCheck(),
52
+ SwapPressureCheck(),
53
+ JournaldPersistenceCheck(),
54
+ MacFrameworkEnforcingCheck(),
55
+ PublicListenAnomaliesCheck(),
56
+ ListenVsFirewallDeltaCheck(),
57
+ WorldWritableSuidAuditCheck(),
58
+ ]
59
+
60
+ __all__ = [
61
+ "ALL_CHECKS",
62
+ "SSHPermitRootLoginCheck",
63
+ "SSHPasswordAuthCheck",
64
+ "SSHWeakCiphersCheck",
65
+ "SSHNonDefaultPortCheck",
66
+ "SudoersNopasswdCheck",
67
+ "SudoRequiresAuthenticationCheck",
68
+ "UserPasswordAgingCheck",
69
+ "FailedLoginAnomalyCheck",
70
+ "Fail2banEnabledCheck",
71
+ "KernelAgeCheck",
72
+ "PendingSecurityUpdatesCheck",
73
+ "RebootPendingCheck",
74
+ "AutoUpdatesConfiguredCheck",
75
+ "RepoGpgTrustCheck",
76
+ "FirewallProtectiveCheck",
77
+ "TimeSyncCheck",
78
+ "DnsResolverSanityCheck",
79
+ "RootDiskFreeCheck",
80
+ "TmpHardeningCheck",
81
+ "FailedSystemdUnitsCheck",
82
+ "SwapPressureCheck",
83
+ "JournaldPersistenceCheck",
84
+ "MacFrameworkEnforcingCheck",
85
+ "PublicListenAnomaliesCheck",
86
+ "ListenVsFirewallDeltaCheck",
87
+ "WorldWritableSuidAuditCheck",
88
+ ]
@@ -0,0 +1,149 @@
1
+ """Automatic security updates configured (unattended-upgrades / dnf-automatic)."""
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
+ # apt.systemd.daily (apt 2.x) semantics, read from the script on real Ubuntu 24.04:
10
+ # Enable: numeric, 0 disables the whole job (absent = 1).
11
+ # Update-Package-Lists / Unattended-Upgrade: "always" or N days; check_stamp treats 0 as off.
12
+ _APT_KEY = r'APT::Periodic::{key}\s+"([^"]*)"'
13
+
14
+
15
+ def _apt_value(dump: str, key: str) -> str | None:
16
+ m = re.search(_APT_KEY.format(key=re.escape(key)), dump)
17
+ return m.group(1).strip() if m else None
18
+
19
+
20
+ def _interval_on(value: str | None) -> bool:
21
+ """Mirror apt.systemd.daily check_stamp, which compares CASE-SENSITIVELY as shell strings.
22
+
23
+ On: exactly "always", or digits with an optional lowercase s/m/h/d suffix. "Always", "1D", "12H"
24
+ never run in apt, so they are off here. Exactly "0" is off (`[ "$interval" = 0 ]` returns early);
25
+ "00" and "0d" are not that string, so apt proceeds and they run every time -> on."""
26
+ if value is None:
27
+ return False
28
+ v = value.strip()
29
+ if v == "always":
30
+ return True
31
+ if v == "0":
32
+ return False
33
+ return re.fullmatch(r"\d+[smhd]?", v) is not None
34
+
35
+
36
+ _TRUTHY = ("yes", "true", "1", "on")
37
+
38
+
39
+ def _dnf_applies_updates(conf: str) -> bool:
40
+ """dnf-automatic only *downloads* unless [commands] apply_updates is truthy (default: no).
41
+
42
+ Section-aware: only [commands] counts. The last apply_updates line there wins, with inline
43
+ ` #`/` ;` comments stripped; a value that is not a recognised yes is treated as NOT yes (never
44
+ skipped, so it cannot let an earlier `yes` win)."""
45
+ section = None
46
+ value = None
47
+ for line in conf.splitlines():
48
+ s = line.strip()
49
+ if not s or s[0] in "#;":
50
+ continue
51
+ m = re.fullmatch(r"\[([^\]]+)\]", s)
52
+ if m:
53
+ section = m.group(1).strip().lower()
54
+ continue
55
+ if section == "commands":
56
+ kv = re.match(r"apply_updates\s*[=:]\s*(.*)$", s, re.I)
57
+ if kv:
58
+ value = re.split(r"\s[#;]", kv.group(1), maxsplit=1)[0].strip().lower()
59
+ return value in _TRUTHY
60
+
61
+ class AutoUpdatesConfiguredCheck(Check):
62
+ id = "auto_updates_configured"
63
+ title = "Automatic security updates configured"
64
+ category = "patching"
65
+ severity = Severity.MEDIUM
66
+ weight = 6
67
+ applicable_distros = ("debian", "rhel")
68
+ remediation = (
69
+ "Enable automated security patching so you don't have to chase every CVE.\n"
70
+ "Debian/Ubuntu:\n"
71
+ " sudo apt install unattended-upgrades\n"
72
+ " sudo dpkg-reconfigure -plow unattended-upgrades\n"
73
+ "RHEL/Rocky:\n"
74
+ " sudo dnf install dnf-automatic\n"
75
+ " # dnf-automatic only DOWNLOADS by default; make it install:\n"
76
+ " sudo sed -i 's/^apply_updates *=.*/apply_updates = yes/' /etc/dnf/automatic.conf\n"
77
+ " sudo systemctl enable --now dnf-automatic.timer\n"
78
+ "Fedora 41+ (dnf5): sudo dnf install dnf5-plugin-automatic, set apply_updates = yes\n"
79
+ "under [commands] in /etc/dnf/automatic.conf, then\n"
80
+ " sudo systemctl enable --now dnf5-automatic.timer"
81
+ )
82
+
83
+ def run(self, runner: CommandRunner) -> Finding:
84
+ # Debian/Ubuntu stock images: unattended-upgrades runs from apt-daily-upgrade.timer ->
85
+ # apt.systemd.daily, which needs Enable != 0, Update-Package-Lists on (else it upgrades from
86
+ # stale lists) and Unattended-Upgrade on; plus the package. There is no
87
+ # unattended-upgrades.timer unit on them (verified on real Ubuntu 24.04 / Debian 12, UMBR-23).
88
+ timer = runner.run("systemctl is-enabled apt-daily-upgrade.timer 2>/dev/null", timeout=5)
89
+ lists_timer = runner.run("systemctl is-enabled apt-daily.timer 2>/dev/null", timeout=5)
90
+ dump = runner.run("apt-config dump APT::Periodic 2>/dev/null", timeout=5)
91
+ pkg = runner.run(
92
+ "dpkg-query -W -f='${db:Status-Abbrev}' unattended-upgrades 2>/dev/null", timeout=5
93
+ )
94
+ enable = _apt_value(dump.stdout, "Enable")
95
+ enable_on = enable is None or not (enable.isdigit() and int(enable) == 0)
96
+ if (
97
+ timer.ok and timer.stdout.strip() == "enabled"
98
+ # apt-daily.timer runs `apt.systemd.daily update` (refreshes lists); upgrade timer only installs.
99
+ and lists_timer.ok and lists_timer.stdout.strip() == "enabled"
100
+ and pkg.stdout.strip().startswith("ii")
101
+ and enable_on
102
+ and _interval_on(_apt_value(dump.stdout, "Update-Package-Lists"))
103
+ and _interval_on(_apt_value(dump.stdout, "Unattended-Upgrade"))
104
+ ):
105
+ return self._finding(
106
+ Verdict.PASS,
107
+ evidence=(
108
+ "apt-daily-upgrade.timer enabled, unattended-upgrades installed, "
109
+ "APT::Periodic Enable/Update-Package-Lists/Unattended-Upgrade all on"
110
+ ),
111
+ )
112
+ # Older/other layouts: a dedicated unattended-upgrades.timer
113
+ r = runner.run("systemctl is-enabled unattended-upgrades.timer 2>/dev/null", timeout=5)
114
+ if r.ok and r.stdout.strip() == "enabled":
115
+ return self._finding(
116
+ Verdict.PASS, evidence="unattended-upgrades.timer is enabled"
117
+ )
118
+ # RHEL: dnf-automatic.timer (dnf4) or dnf5-automatic.timer (dnf5: Fedora 41+, UMBR-33),
119
+ # but only counts if it APPLIES updates (both default to download only).
120
+ r2 = runner.run("systemctl is-enabled dnf-automatic.timer 2>/dev/null", timeout=5)
121
+ r5 = runner.run("systemctl is-enabled dnf5-automatic.timer 2>/dev/null", timeout=5)
122
+ conf = runner.run("cat /etc/dnf/automatic.conf 2>/dev/null", timeout=5)
123
+ # dnf5-automatic(8): defaults from this file, then /etc/dnf/automatic.conf applied on top.
124
+ # Absent on dnf4 hosts, so prepending it is a no-op there; last apply_updates wins.
125
+ dnf5_defaults = runner.run(
126
+ "cat /usr/share/dnf5/dnf5-plugins/automatic.conf 2>/dev/null", timeout=5
127
+ )
128
+ merged = (dnf5_defaults.stdout if dnf5_defaults.ok else "") + "\n" + conf.stdout
129
+ for unit, timer in (("dnf5-automatic.timer", r5), ("dnf-automatic.timer", r2)):
130
+ if not (timer.ok and timer.stdout.strip() == "enabled"):
131
+ continue
132
+ if _dnf_applies_updates(merged):
133
+ return self._finding(
134
+ Verdict.PASS, evidence=f"{unit} enabled with apply_updates = yes"
135
+ )
136
+ return self._finding(
137
+ Verdict.WARN,
138
+ evidence=(
139
+ f"{unit} is enabled but the automatic.conf in effect does not set "
140
+ "apply_updates = yes (default: only downloads, never installs)"
141
+ ),
142
+ )
143
+ return self._finding(
144
+ Verdict.WARN,
145
+ evidence=(
146
+ "no automatic updates enabled (apt periodic/unattended-upgrades, "
147
+ "unattended-upgrades.timer and dnf-automatic/dnf5-automatic timers all off or incomplete)"
148
+ ),
149
+ )
@@ -0,0 +1,47 @@
1
+ """DNS resolver sanity — resolv.conf populated + a known name resolves."""
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 DnsResolverSanityCheck(Check):
9
+ id = "dns_resolver_sanity"
10
+ title = "DNS resolver is configured and reachable"
11
+ category = "network"
12
+ severity = Severity.MEDIUM
13
+ weight = 3
14
+ applicable_distros = ()
15
+ remediation = (
16
+ "Ensure DNS is configured:\n"
17
+ " cat /etc/resolv.conf # should have at least one 'nameserver <ip>' line\n"
18
+ "If managed by systemd-resolved, point /etc/resolv.conf at it:\n"
19
+ " sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf"
20
+ )
21
+
22
+ def run(self, runner: CommandRunner) -> Finding:
23
+ rr = runner.run("cat /etc/resolv.conf 2>/dev/null", timeout=5)
24
+ # Issue the lookup BEFORE any early return: the capture script is enumerated by running
25
+ # every check against an all-failing runner, so a command gated behind a successful
26
+ # resolv.conf read is never captured and replays as "lookup failed" (UMBR-23).
27
+ rg = runner.run("getent hosts deb.debian.org 2>/dev/null", timeout=8)
28
+ if not rr.ok or not rr.stdout:
29
+ return self._finding(
30
+ Verdict.SKIP, skip_reason="could not read /etc/resolv.conf"
31
+ )
32
+ nameservers = [
33
+ line for line in rr.stdout.splitlines() if line.strip().startswith("nameserver")
34
+ ]
35
+ if not nameservers:
36
+ return self._finding(
37
+ Verdict.FAIL, evidence="/etc/resolv.conf has 0 nameserver lines"
38
+ )
39
+ if rg.ok and rg.stdout.strip():
40
+ return self._finding(
41
+ Verdict.PASS,
42
+ evidence=f"{len(nameservers)} nameserver(s), test lookup succeeded",
43
+ )
44
+ return self._finding(
45
+ Verdict.WARN,
46
+ evidence=f"{len(nameservers)} nameserver(s) configured but test lookup failed",
47
+ )
@@ -0,0 +1,67 @@
1
+ """fail2ban — installed, enabled, and actively jailing things."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from umbermark.core.access import Reachability, get_access_context, is_nonpublic
7
+ from umbermark.core.check import Check, Finding, Severity, Verdict
8
+ from umbermark.core.ssh import CommandRunner
9
+
10
+
11
+ class Fail2banEnabledCheck(Check):
12
+ id = "fail2ban_enabled"
13
+ title = "fail2ban installed + enabled with active jails"
14
+ category = "identity_auth"
15
+ severity = Severity.HIGH
16
+ weight = 8
17
+ applicable_distros = ()
18
+ remediation = (
19
+ "Install + enable fail2ban with at least the sshd jail:\n"
20
+ " Debian/Ubuntu: sudo apt install fail2ban && sudo systemctl enable --now fail2ban\n"
21
+ " RHEL/Rocky: sudo dnf install epel-release && sudo dnf install fail2ban\n"
22
+ " sudo systemctl enable --now fail2ban\n"
23
+ "Confirm with: sudo fail2ban-client status sshd"
24
+ )
25
+
26
+ def run(self, runner: CommandRunner) -> Finding:
27
+ r = runner.run("systemctl is-active fail2ban 2>/dev/null", timeout=5)
28
+ # Issued before the early return so the capture enumerator (all-failing runner) sees it (UMBR-23).
29
+ r2 = runner.run("fail2ban-client status 2>/dev/null", sudo=True, timeout=5)
30
+ if not r.ok or r.stdout.strip() != "active":
31
+ evidence = f"fail2ban not active (systemctl is-active -> {r.stdout.strip() or 'n/a'})"
32
+ ctx = get_access_context(runner)
33
+ if is_nonpublic(ctx):
34
+ # Informational: fail2ban's job is throttling brute force against
35
+ # reachable services; SSH here is not reachable from the internet.
36
+ return self._finding(
37
+ Verdict.WARN,
38
+ evidence=(
39
+ f"{evidence}. Informational only: SSH is not publicly reachable "
40
+ f"({ctx.reachability.value}: {ctx.reachability_note}), so there is "
41
+ "little brute-force traffic for fail2ban to act on."
42
+ ),
43
+ severity=Severity.LOW,
44
+ weight=0,
45
+ )
46
+ if ctx.reachability == Reachability.UNKNOWN:
47
+ evidence += f". Exposure could not be determined ({ctx.reachability_note}); severity not reduced."
48
+ return self._finding(Verdict.FAIL, evidence=evidence)
49
+ if not r2.ok or not r2.stdout:
50
+ return self._finding(
51
+ Verdict.WARN,
52
+ evidence="fail2ban active but status query failed — daemon may be unhealthy",
53
+ )
54
+ m = re.search(r"Number of jail:\s*(\d+)", r2.stdout)
55
+ if m:
56
+ jails = int(m.group(1))
57
+ if jails == 0:
58
+ return self._finding(
59
+ Verdict.WARN,
60
+ evidence="fail2ban active but 0 jails configured",
61
+ )
62
+ return self._finding(
63
+ Verdict.PASS, evidence=f"fail2ban active, {jails} jail(s)"
64
+ )
65
+ return self._finding(
66
+ Verdict.WARN, evidence="fail2ban active but jail count unparseable"
67
+ )
@@ -0,0 +1,48 @@
1
+ """Failed SSH login attempts in the last 7 days — anomaly threshold."""
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
+ _CMD = (
8
+ "journalctl _COMM=sshd _COMM=sshd-session --since '7 days ago' --no-pager 2>/dev/null "
9
+ "| grep -cE 'Failed password|Invalid user'"
10
+ )
11
+
12
+
13
+ class FailedLoginAnomalyCheck(Check):
14
+ id = "failed_login_anomaly"
15
+ title = "Failed SSH login attempts (last 7 days)"
16
+ category = "identity_auth"
17
+ severity = Severity.MEDIUM
18
+ weight = 4
19
+ applicable_distros = ()
20
+ remediation = (
21
+ "Reduce brute-force exposure (drive-by scanners cause most failed logins):\n"
22
+ " - install + enable fail2ban (auto-bans repeat offenders)\n"
23
+ " - move SSH off port 22 to reduce scan traffic\n"
24
+ " - restrict SSH source IPs at the firewall when feasible\n"
25
+ " - confirm no real account is being targeted (look for repeated\n"
26
+ " Failed-password attempts against the SAME username)"
27
+ )
28
+
29
+ def run(self, runner: CommandRunner) -> Finding:
30
+ r = runner.run(_CMD, sudo=True, timeout=15)
31
+ if not r.ok or not r.stdout.strip():
32
+ return self._finding(
33
+ Verdict.SKIP,
34
+ skip_reason="could not parse journalctl sshd output (requires root + systemd-journald)",
35
+ )
36
+ try:
37
+ count = int(r.stdout.strip())
38
+ except ValueError:
39
+ return self._finding(
40
+ Verdict.SKIP,
41
+ skip_reason=f"unparseable count: {r.stdout.strip()!r}",
42
+ )
43
+ evidence = f"{count} failed SSH login attempts in last 7 days"
44
+ if count < 50:
45
+ return self._finding(Verdict.PASS, evidence=evidence)
46
+ if count < 500:
47
+ return self._finding(Verdict.WARN, evidence=evidence + " — elevated")
48
+ return self._finding(Verdict.FAIL, evidence=evidence + " — high volume")
@@ -0,0 +1,41 @@
1
+ """Failed systemd units."""
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 FailedSystemdUnitsCheck(Check):
9
+ id = "failed_systemd_units"
10
+ title = "No failed systemd units"
11
+ category = "services"
12
+ severity = Severity.MEDIUM
13
+ weight = 6
14
+ applicable_distros = ()
15
+ remediation = (
16
+ "Investigate each failed unit:\n"
17
+ " sudo systemctl --failed\n"
18
+ " sudo systemctl status <unit>\n"
19
+ " sudo journalctl -u <unit> -n 100\n"
20
+ "Common causes: misconfigured timer dependency, missing binary, OOM-killed."
21
+ )
22
+
23
+ def run(self, runner: CommandRunner) -> Finding:
24
+ r = runner.run("systemctl --failed --no-legend 2>/dev/null", timeout=5)
25
+ if not r.ok:
26
+ return self._finding(
27
+ Verdict.SKIP, skip_reason="systemctl --failed failed (non-systemd init?)"
28
+ )
29
+ units = [ln for ln in r.stdout.splitlines() if ln.strip()]
30
+ count = len(units)
31
+ if count == 0:
32
+ return self._finding(Verdict.PASS, evidence="0 failed units")
33
+ if count <= 2:
34
+ return self._finding(
35
+ Verdict.WARN,
36
+ evidence=f"{count} failed unit(s): {', '.join(u.split()[0] for u in units[:3])}",
37
+ )
38
+ return self._finding(
39
+ Verdict.FAIL,
40
+ evidence=f"{count} failed units (first 3: {', '.join(u.split()[0] for u in units[:3])})",
41
+ )