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
umbermark/__init__.py
ADDED
|
@@ -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
|
+
)
|