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,247 @@
|
|
|
1
|
+
"""Host firewall — must have a *protective* input policy, not just NAT rules.
|
|
2
|
+
|
|
3
|
+
Distinguishes Docker-installed NAT chains from real INPUT-chain protection.
|
|
4
|
+
The v0.2 implementation was naive — any nftables chains counted as 'firewalled',
|
|
5
|
+
which produced PASS verdicts on every Docker host. This version requires a
|
|
6
|
+
filter-hook input chain with drop/reject policy and at least one accept rule,
|
|
7
|
+
or an iptables INPUT policy of DROP/REJECT with explicit accept rules.
|
|
8
|
+
|
|
9
|
+
firewalld (RHEL/Rocky/Alma default, UMBR-30) is judged from its own zone model
|
|
10
|
+
rather than the ruleset it generates: firewalld's nft base chain has *policy
|
|
11
|
+
accept* and ends in an unconditional reject, and `iptables -S` on iptables-nft
|
|
12
|
+
shows only the empty compat table, so the ruleset heuristics below would call a
|
|
13
|
+
correctly firewalled RHEL host unprotected.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
from umbermark.core.check import Check, Finding, Severity, Verdict
|
|
21
|
+
from umbermark.core.firewall import is_loopback_iface, is_overlay_iface, sources_are_private
|
|
22
|
+
from umbermark.core.ssh import CommandRunner
|
|
23
|
+
|
|
24
|
+
# firewalld commands. Issued unconditionally once ufw is ruled out so the Tier-2
|
|
25
|
+
# capture enumeration (all-failing runner) records every one of them.
|
|
26
|
+
C_FWD_STATE = "firewall-cmd --state 2>/dev/null"
|
|
27
|
+
C_FWD_DEFAULT = "firewall-cmd --get-default-zone 2>/dev/null"
|
|
28
|
+
C_FWD_ZONES = "firewall-cmd --list-all-zones 2>/dev/null"
|
|
29
|
+
|
|
30
|
+
# Interfaces whose inbound traffic originates on this host (container / VM
|
|
31
|
+
# bridges). Docker >= 20.10 binds docker0 + br-<id> to an ACCEPT-target "docker"
|
|
32
|
+
# zone and libvirt binds virbr* to an ACCEPT-target "libvirt" zone by design.
|
|
33
|
+
_LOCAL_BRIDGE_RX = re.compile(r"^(docker\d*|br-[0-9a-f]{12}|virbr\d+|cni\d*|podman\d*|veth\w*)$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FirewallProtectiveCheck(Check):
|
|
37
|
+
id = "firewall_protective"
|
|
38
|
+
title = "Host firewall has protective input rules"
|
|
39
|
+
category = "network"
|
|
40
|
+
severity = Severity.CRITICAL
|
|
41
|
+
weight = 20
|
|
42
|
+
applicable_distros = () # universal
|
|
43
|
+
remediation = (
|
|
44
|
+
"Enable a host firewall that actually drops unsolicited inbound traffic.\n"
|
|
45
|
+
"For Ubuntu/Debian with ufw:\n"
|
|
46
|
+
" sudo ufw default deny incoming\n"
|
|
47
|
+
" sudo ufw allow OpenSSH\n"
|
|
48
|
+
" sudo ufw enable\n"
|
|
49
|
+
"For RHEL/Rocky with firewalld:\n"
|
|
50
|
+
" sudo systemctl enable --now firewalld\n"
|
|
51
|
+
" sudo firewall-cmd --set-default-zone=public\n"
|
|
52
|
+
"Docker-installed NAT/FORWARD rules do not constitute host protection;\n"
|
|
53
|
+
"the INPUT chain must have a DROP/REJECT policy with explicit accept\n"
|
|
54
|
+
"rules for the services you intend to expose."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def run(self, runner: CommandRunner) -> Finding:
|
|
58
|
+
# 1) Prefer ufw if it's active (Debian/Ubuntu default).
|
|
59
|
+
r = runner.run("ufw status 2>/dev/null", sudo=True)
|
|
60
|
+
if r.ok and r.stdout.strip():
|
|
61
|
+
head = r.stdout.strip().splitlines()[0].lower()
|
|
62
|
+
if re.search(r"\bactive\b", head):
|
|
63
|
+
return self._finding(Verdict.PASS, evidence="ufw status: active")
|
|
64
|
+
|
|
65
|
+
# Rulesets are read up front: the firewalld verdict is only trusted
|
|
66
|
+
# when they show firewalld's rules are actually loaded.
|
|
67
|
+
r2 = runner.run("nft list ruleset 2>/dev/null", sudo=True)
|
|
68
|
+
r3 = runner.run("iptables -S 2>/dev/null", sudo=True)
|
|
69
|
+
|
|
70
|
+
# 2) firewalld — judge the zone model it enforces, provided its rules are
|
|
71
|
+
# really in the kernel. `firewall-cmd --state` keeps saying "running"
|
|
72
|
+
# after the whole ruleset is wiped (e.g. nftables.service loading a config
|
|
73
|
+
# that starts by clearing everything), which would be a false PASS.
|
|
74
|
+
state = runner.run(C_FWD_STATE, sudo=True)
|
|
75
|
+
default = runner.run(C_FWD_DEFAULT, sudo=True)
|
|
76
|
+
zones = runner.run(C_FWD_ZONES, sudo=True)
|
|
77
|
+
if (state.ok and state.stdout.strip() == "running" and zones.ok
|
|
78
|
+
and _firewalld_rules_loaded(r2, r3)):
|
|
79
|
+
builder = _verdict_from_firewalld(
|
|
80
|
+
default.stdout.strip() if default.ok else "", zones.stdout
|
|
81
|
+
)
|
|
82
|
+
if builder is not None:
|
|
83
|
+
return builder(self)
|
|
84
|
+
|
|
85
|
+
# 3) nftables — protective input chain check.
|
|
86
|
+
if r2.ok and r2.stdout.strip():
|
|
87
|
+
builder = _verdict_from_nftables(r2.stdout)
|
|
88
|
+
if builder is not None:
|
|
89
|
+
return builder(self)
|
|
90
|
+
|
|
91
|
+
# 4) iptables (legacy).
|
|
92
|
+
if r3.ok and r3.stdout.strip():
|
|
93
|
+
builder = _verdict_from_iptables(r3.stdout)
|
|
94
|
+
if builder is not None:
|
|
95
|
+
return builder(self)
|
|
96
|
+
|
|
97
|
+
return self._finding(
|
|
98
|
+
Verdict.FAIL,
|
|
99
|
+
evidence="no protective host firewall detected (ufw inactive, no protective INPUT chain in nftables/iptables)",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _firewalld_rules_loaded(nft, ipt) -> bool:
|
|
104
|
+
"""Do the rulesets show firewalld's rules (nft backend: `table inet firewalld`;
|
|
105
|
+
iptables backend: *_ZONES chains)? If neither ruleset could be read at all we
|
|
106
|
+
cannot contradict firewall-cmd, so trust it; a readable ruleset without
|
|
107
|
+
firewalld's rules means they were wiped and nothing enforces the zones.
|
|
108
|
+
"""
|
|
109
|
+
nft_text = nft.stdout if nft.ok else ""
|
|
110
|
+
ipt_text = ipt.stdout if ipt.ok else ""
|
|
111
|
+
if "table inet firewalld" in nft_text or "INPUT_ZONES" in nft_text or "INPUT_ZONES" in ipt_text:
|
|
112
|
+
return True
|
|
113
|
+
return not nft.ok and not ipt.ok
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_firewalld_zones(text: str) -> dict[str, dict[str, str]]:
|
|
117
|
+
"""Parse `firewall-cmd --list-all-zones` into {zone: {key: value, ...}}.
|
|
118
|
+
|
|
119
|
+
Zone headers are unindented ("public (default, active)" on firewalld >= 0.9,
|
|
120
|
+
"public (active)" on 0.6); attributes are indented "key: value" lines. The
|
|
121
|
+
header flags are stored under the pseudo-key "_flags".
|
|
122
|
+
"""
|
|
123
|
+
zones: dict[str, dict[str, str]] = {}
|
|
124
|
+
current: dict[str, str] | None = None
|
|
125
|
+
for line in text.splitlines():
|
|
126
|
+
if not line.strip():
|
|
127
|
+
continue
|
|
128
|
+
if not line[0].isspace():
|
|
129
|
+
m = re.match(r"^(\S+)(?:\s+\(([^)]*)\))?\s*$", line)
|
|
130
|
+
if not m:
|
|
131
|
+
current = None
|
|
132
|
+
continue
|
|
133
|
+
current = {"_flags": (m.group(2) or "").replace(" ", "")}
|
|
134
|
+
zones[m.group(1)] = current
|
|
135
|
+
elif current is not None:
|
|
136
|
+
key, sep, value = line.strip().partition(":")
|
|
137
|
+
if sep and key not in current:
|
|
138
|
+
current[key] = value.strip()
|
|
139
|
+
return zones
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _verdict_from_firewalld(default_zone: str, text: str) -> Callable[[Check], Finding] | None:
|
|
143
|
+
"""Verdict from firewalld's zones, or None (fall through) if they're unreadable.
|
|
144
|
+
|
|
145
|
+
The default zone handles every interface not bound elsewhere, so its target
|
|
146
|
+
decides the public posture: "default" (reject), REJECT/%%REJECT%% and DROP
|
|
147
|
+
are protective; ACCEPT is "trusted" = no firewall. Any *other* zone with an
|
|
148
|
+
ACCEPT target that is bound to a public-looking interface, or to sources that
|
|
149
|
+
are not all private, also opens the host. ACCEPT zones bound only to loopback,
|
|
150
|
+
VPN overlays or local container/VM bridges are fine.
|
|
151
|
+
"""
|
|
152
|
+
zones = _parse_firewalld_zones(text)
|
|
153
|
+
if not zones:
|
|
154
|
+
return None
|
|
155
|
+
flagged_default = [n for n, z in zones.items() if "default" in z["_flags"].split(",")]
|
|
156
|
+
default_zone = default_zone or (flagged_default[0] if flagged_default else "")
|
|
157
|
+
if default_zone not in zones:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
problems: list[str] = []
|
|
161
|
+
for name, z in zones.items():
|
|
162
|
+
if z.get("target", "").upper() != "ACCEPT":
|
|
163
|
+
continue
|
|
164
|
+
if name == default_zone:
|
|
165
|
+
problems.append(f"default zone '{name}' has target ACCEPT (accepts all inbound)")
|
|
166
|
+
continue
|
|
167
|
+
ifaces = z.get("interfaces", "").split()
|
|
168
|
+
exposed = [i for i in ifaces if not (
|
|
169
|
+
is_loopback_iface(i) or is_overlay_iface(i) or _LOCAL_BRIDGE_RX.match(i))]
|
|
170
|
+
if exposed:
|
|
171
|
+
problems.append(f"zone '{name}' (target ACCEPT) bound to {', '.join(exposed)}")
|
|
172
|
+
sources = z.get("sources", "").split()
|
|
173
|
+
if sources and not sources_are_private(sources):
|
|
174
|
+
problems.append(f"zone '{name}' (target ACCEPT) bound to sources {' '.join(sources)}")
|
|
175
|
+
if problems:
|
|
176
|
+
return lambda self: self._finding(
|
|
177
|
+
Verdict.FAIL, evidence="firewalld running but " + "; ".join(problems),
|
|
178
|
+
)
|
|
179
|
+
dz = zones[default_zone]
|
|
180
|
+
services = dz.get("services", "") or "none"
|
|
181
|
+
ports = dz.get("ports", "")
|
|
182
|
+
detail = f"services: {services}" + (f"; ports: {ports}" if ports else "")
|
|
183
|
+
return lambda self: self._finding(
|
|
184
|
+
Verdict.PASS,
|
|
185
|
+
evidence=(f"firewalld running; default zone '{default_zone}' "
|
|
186
|
+
f"(target {dz.get('target', 'default')}, unmatched inbound rejected); {detail}"),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _verdict_from_nftables(text: str) -> Callable[[Check], Finding] | None:
|
|
191
|
+
"""Inspect nftables ruleset; return a Finding-builder, or None to fall through.
|
|
192
|
+
|
|
193
|
+
Looks for any 'chain X { type filter hook input ... }' with a drop/reject
|
|
194
|
+
policy and at least one non-policy rule. Distinguishes Docker-only NAT
|
|
195
|
+
output (where INPUT-hook chains either don't exist or have ACCEPT policy).
|
|
196
|
+
"""
|
|
197
|
+
# Match every chain block. Use DOTALL because nftables output is multi-line per chain.
|
|
198
|
+
chains = re.findall(r"chain\s+\S+\s*\{([^}]*?)\}", text, re.DOTALL)
|
|
199
|
+
has_protective_input = False
|
|
200
|
+
has_docker_table = "DOCKER" in text or "table ip nat" in text
|
|
201
|
+
for body in chains:
|
|
202
|
+
if not re.search(r"type\s+filter\s+hook\s+input", body):
|
|
203
|
+
continue
|
|
204
|
+
policy_match = re.search(r"policy\s+(\w+)", body)
|
|
205
|
+
policy = policy_match.group(1).lower() if policy_match else "accept"
|
|
206
|
+
# Count rules that aren't the policy or type lines.
|
|
207
|
+
rule_lines = [
|
|
208
|
+
ln.strip()
|
|
209
|
+
for ln in body.splitlines()
|
|
210
|
+
if ln.strip()
|
|
211
|
+
and not ln.strip().startswith(("type ", "policy", "#"))
|
|
212
|
+
]
|
|
213
|
+
if policy in ("drop", "reject") and len(rule_lines) >= 1:
|
|
214
|
+
has_protective_input = True
|
|
215
|
+
break
|
|
216
|
+
if has_protective_input:
|
|
217
|
+
return lambda self: self._finding(
|
|
218
|
+
Verdict.PASS,
|
|
219
|
+
evidence="nftables: protective input chain (drop/reject policy + explicit accepts)",
|
|
220
|
+
)
|
|
221
|
+
if has_docker_table:
|
|
222
|
+
return lambda self: self._finding(
|
|
223
|
+
Verdict.FAIL,
|
|
224
|
+
evidence="nftables: only docker NAT/forward rules present, no protective INPUT chain",
|
|
225
|
+
)
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _verdict_from_iptables(text: str) -> Callable[[Check], Finding] | None:
|
|
230
|
+
"""Inspect iptables -S output for INPUT policy + rules."""
|
|
231
|
+
policy_match = re.search(r"^-P\s+INPUT\s+(\w+)", text, re.M)
|
|
232
|
+
policy = policy_match.group(1).upper() if policy_match else "ACCEPT"
|
|
233
|
+
rule_count = len(re.findall(r"^-A\s+INPUT\s", text, re.M))
|
|
234
|
+
if policy in ("DROP", "REJECT") and rule_count >= 1:
|
|
235
|
+
return lambda self: self._finding(
|
|
236
|
+
Verdict.PASS,
|
|
237
|
+
evidence=f"iptables: INPUT policy {policy}, {rule_count} accept rules",
|
|
238
|
+
)
|
|
239
|
+
if rule_count == 0 and policy == "ACCEPT":
|
|
240
|
+
return lambda self: self._finding(
|
|
241
|
+
Verdict.FAIL,
|
|
242
|
+
evidence="iptables: INPUT policy ACCEPT with no rules",
|
|
243
|
+
)
|
|
244
|
+
return lambda self: self._finding(
|
|
245
|
+
Verdict.FAIL,
|
|
246
|
+
evidence=f"iptables: INPUT policy {policy}, {rule_count} rules (no protective config)",
|
|
247
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""journald persistence — logs survive reboot."""
|
|
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 JournaldPersistenceCheck(Check):
|
|
11
|
+
id = "journald_persistence"
|
|
12
|
+
title = "journald configured for persistent storage"
|
|
13
|
+
category = "logging"
|
|
14
|
+
severity = Severity.MEDIUM
|
|
15
|
+
weight = 4
|
|
16
|
+
applicable_distros = ()
|
|
17
|
+
remediation = (
|
|
18
|
+
"Enable persistent journald logs (survive reboots, valuable for incident response):\n"
|
|
19
|
+
" sudo mkdir -p /var/log/journal\n"
|
|
20
|
+
" sudo systemd-tmpfiles --create --prefix /var/log/journal\n"
|
|
21
|
+
" sudo systemctl restart systemd-journald\n"
|
|
22
|
+
"Or set explicitly in /etc/systemd/journald.conf:\n"
|
|
23
|
+
" [Journal]\n"
|
|
24
|
+
" Storage=persistent"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def run(self, runner: CommandRunner) -> Finding:
|
|
28
|
+
# /var/log/journal existing means journald is persistent regardless of conf
|
|
29
|
+
rdir = runner.run("[ -d /var/log/journal ] && echo yes || echo no", timeout=5)
|
|
30
|
+
if rdir.ok and rdir.stdout.strip() == "yes":
|
|
31
|
+
return self._finding(
|
|
32
|
+
Verdict.PASS, evidence="/var/log/journal exists (logs are persistent)"
|
|
33
|
+
)
|
|
34
|
+
# Otherwise inspect journald.conf for explicit Storage=persistent
|
|
35
|
+
rconf = runner.run(
|
|
36
|
+
"grep -E '^[[:space:]]*Storage' /etc/systemd/journald.conf 2>/dev/null",
|
|
37
|
+
timeout=5,
|
|
38
|
+
)
|
|
39
|
+
if rconf.ok and rconf.stdout:
|
|
40
|
+
m = re.search(r"Storage\s*=\s*(\w+)", rconf.stdout)
|
|
41
|
+
if m:
|
|
42
|
+
storage = m.group(1).lower()
|
|
43
|
+
if storage == "persistent":
|
|
44
|
+
return self._finding(
|
|
45
|
+
Verdict.PASS,
|
|
46
|
+
evidence="journald.conf Storage=persistent",
|
|
47
|
+
)
|
|
48
|
+
return self._finding(
|
|
49
|
+
Verdict.WARN,
|
|
50
|
+
evidence=f"journald.conf Storage={storage} (volatile/auto/none — logs lost on reboot)",
|
|
51
|
+
)
|
|
52
|
+
return self._finding(
|
|
53
|
+
Verdict.WARN,
|
|
54
|
+
evidence="journald storage unconfigured (default Storage=auto means volatile if /var/log/journal absent)",
|
|
55
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Kernel age — derived from build timestamp in /proc/version."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from umbermark.core.check import Check, Finding, Severity, Verdict
|
|
8
|
+
from umbermark.core.ssh import CommandRunner
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KernelAgeCheck(Check):
|
|
12
|
+
id = "kernel_age"
|
|
13
|
+
title = "Kernel age (running an up-to-date kernel)"
|
|
14
|
+
category = "patching"
|
|
15
|
+
severity = Severity.HIGH
|
|
16
|
+
weight = 12
|
|
17
|
+
remediation = (
|
|
18
|
+
"Update the kernel and reboot:\n"
|
|
19
|
+
" sudo apt update && sudo apt upgrade # Debian/Ubuntu\n"
|
|
20
|
+
" sudo dnf upgrade # RHEL-like\n"
|
|
21
|
+
" sudo reboot\n"
|
|
22
|
+
"Confirm afterward with `uname -r`."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def _parse_build_date(text: str) -> datetime | None:
|
|
27
|
+
# ctime-style: "Wed Apr 19 08:22:18 UTC 2023"
|
|
28
|
+
m = re.search(
|
|
29
|
+
r"\b([A-Z][a-z]{2})\s+([A-Z][a-z]{2})\s+(\d{1,2})\s+\d{2}:\d{2}:\d{2}\s+\S+\s+(\d{4})\b",
|
|
30
|
+
text,
|
|
31
|
+
)
|
|
32
|
+
if m:
|
|
33
|
+
try:
|
|
34
|
+
return datetime.strptime(
|
|
35
|
+
f"{m.group(2)} {m.group(3)} {m.group(4)}", "%b %d %Y"
|
|
36
|
+
).replace(tzinfo=timezone.utc)
|
|
37
|
+
except ValueError:
|
|
38
|
+
pass
|
|
39
|
+
# Debian-style: "(2024-11-01)"
|
|
40
|
+
m2 = re.search(r"\((\d{4})-(\d{2})-(\d{2})\)", text)
|
|
41
|
+
if m2:
|
|
42
|
+
try:
|
|
43
|
+
return datetime(
|
|
44
|
+
int(m2.group(1)),
|
|
45
|
+
int(m2.group(2)),
|
|
46
|
+
int(m2.group(3)),
|
|
47
|
+
tzinfo=timezone.utc,
|
|
48
|
+
)
|
|
49
|
+
except ValueError:
|
|
50
|
+
pass
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def run(self, runner: CommandRunner) -> Finding:
|
|
54
|
+
r = runner.run("cat /proc/version 2>/dev/null")
|
|
55
|
+
if not r.ok or not r.stdout.strip():
|
|
56
|
+
return self._finding(
|
|
57
|
+
Verdict.SKIP, skip_reason="could not read /proc/version"
|
|
58
|
+
)
|
|
59
|
+
text = r.stdout.strip()
|
|
60
|
+
build_date = self._parse_build_date(text)
|
|
61
|
+
if build_date is None:
|
|
62
|
+
return self._finding(
|
|
63
|
+
Verdict.SKIP,
|
|
64
|
+
skip_reason=f"could not parse kernel build date from /proc/version: {text[:120]!r}",
|
|
65
|
+
)
|
|
66
|
+
age_days = (datetime.now(timezone.utc) - build_date).days
|
|
67
|
+
evidence = f"kernel built {build_date.date()}, age {age_days} days"
|
|
68
|
+
if age_days > 540:
|
|
69
|
+
return self._finding(Verdict.FAIL, evidence=evidence + " (>18mo)")
|
|
70
|
+
if age_days > 365:
|
|
71
|
+
return self._finding(Verdict.FAIL, evidence=evidence + " (>12mo)")
|
|
72
|
+
if age_days > 180:
|
|
73
|
+
return self._finding(Verdict.WARN, evidence=evidence + " (>6mo)")
|
|
74
|
+
return self._finding(Verdict.PASS, evidence=evidence)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Public LISTEN sockets that the host firewall exposes without an explicit allow rule.
|
|
2
|
+
|
|
3
|
+
Catches the 'service is listening on 0.0.0.0 and the firewall is misconfigured
|
|
4
|
+
so it actually IS reachable' class of bug. Complements public_listen_anomalies
|
|
5
|
+
(which checks against a static allow-list).
|
|
6
|
+
|
|
7
|
+
Verdicts follow the firewall's real behaviour (core.firewall parses nft sets,
|
|
8
|
+
iifname scoping, jumps and base-chain policy, iptables and ufw):
|
|
9
|
+
|
|
10
|
+
* a LISTEN port the firewall drops (by policy or rule) is NOT exposed: PASS.
|
|
11
|
+
A default-drop policy is the desired state, never something to "fix".
|
|
12
|
+
* a port accepted by an explicit allow rule is deliberately exposed: PASS.
|
|
13
|
+
* a port accepted only because the chain policy is accept (no rule says so)
|
|
14
|
+
is drift: that is what this check flags.
|
|
15
|
+
|
|
16
|
+
When no firewall is readable, or it does nothing for any listening port,
|
|
17
|
+
SKIPs and points at firewall_protective.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from umbermark.core.access import C_SS, get_access_context
|
|
22
|
+
from umbermark.core.check import Check, Finding, Severity, Verdict
|
|
23
|
+
from umbermark.core.firewall import Exposure
|
|
24
|
+
from umbermark.core.ssh import CommandRunner
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _public_listen_ports(ss_output: str) -> set[int]:
|
|
28
|
+
"""Parse `ss -tln` LISTEN lines bound to 0.0.0.0 / * / :: -> port set."""
|
|
29
|
+
ports: set[int] = set()
|
|
30
|
+
for line in ss_output.splitlines():
|
|
31
|
+
if not line.startswith("LISTEN"):
|
|
32
|
+
continue
|
|
33
|
+
cols = line.split()
|
|
34
|
+
if len(cols) < 4:
|
|
35
|
+
continue
|
|
36
|
+
local = cols[3]
|
|
37
|
+
if local.startswith(("0.0.0.0:", "*:", "[::]:", ":::")):
|
|
38
|
+
tail = local.rsplit(":", 1)[-1]
|
|
39
|
+
if tail.isdigit():
|
|
40
|
+
ports.add(int(tail))
|
|
41
|
+
return ports
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ListenVsFirewallDeltaCheck(Check):
|
|
45
|
+
id = "listen_vs_firewall_delta"
|
|
46
|
+
title = "Public LISTEN sockets are not exposed by a default-accept firewall"
|
|
47
|
+
category = "network"
|
|
48
|
+
severity = Severity.HIGH
|
|
49
|
+
weight = 12
|
|
50
|
+
applicable_distros = ()
|
|
51
|
+
remediation = (
|
|
52
|
+
"Switch the firewall to default-deny. These public ports are open only because\n"
|
|
53
|
+
"the default policy is accept, not because a rule allows them:\n"
|
|
54
|
+
" 1. Set the input policy to drop/deny (ufw: `ufw default deny incoming`;\n"
|
|
55
|
+
" nftables: `policy drop;` on the input hook chain), then add explicit\n"
|
|
56
|
+
" allow rules ONLY for services meant to be public.\n"
|
|
57
|
+
" 2. Or stop the service listening publicly: bind it to 127.0.0.1\n"
|
|
58
|
+
" (e.g. postgresql `listen_addresses = '127.0.0.1'`).\n"
|
|
59
|
+
"Do not open ports to make this check pass; a LISTEN port that the firewall\n"
|
|
60
|
+
"drops is the desired state."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def run(self, runner: CommandRunner) -> Finding:
|
|
64
|
+
ctx = get_access_context(runner)
|
|
65
|
+
raw = ctx.raw.get(C_SS.command)
|
|
66
|
+
if raw is None or not raw.ok or not raw.stdout.strip():
|
|
67
|
+
return self._finding(
|
|
68
|
+
Verdict.SKIP, skip_reason="ss -tln produced no output"
|
|
69
|
+
)
|
|
70
|
+
listen_ports = _public_listen_ports(raw.stdout)
|
|
71
|
+
if not listen_ports:
|
|
72
|
+
return self._finding(
|
|
73
|
+
Verdict.PASS, evidence="no public LISTEN sockets to cross-check"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
fw = ctx.firewall
|
|
77
|
+
if not fw.readable:
|
|
78
|
+
return self._finding(
|
|
79
|
+
Verdict.SKIP,
|
|
80
|
+
skip_reason="no protective firewall to compare against (see firewall_protective)",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
unguarded: list[int] = []
|
|
84
|
+
allowed: list[int] = []
|
|
85
|
+
blocked: list[int] = []
|
|
86
|
+
dnat_risk: list[int] = []
|
|
87
|
+
for port in sorted(listen_ports):
|
|
88
|
+
v = fw.evaluate(port, "tcp")
|
|
89
|
+
if v.state == Exposure.BLOCKED:
|
|
90
|
+
if ctx.dnat_suspect(port):
|
|
91
|
+
dnat_risk.append(port) # INPUT drop is not proof: DNAT bypasses INPUT
|
|
92
|
+
else:
|
|
93
|
+
blocked.append(port)
|
|
94
|
+
elif v.state == Exposure.ACCEPTED and not v.explicit:
|
|
95
|
+
unguarded.append(port)
|
|
96
|
+
else: # explicit allow rule, or accepted only for specific sources
|
|
97
|
+
allowed.append(port)
|
|
98
|
+
|
|
99
|
+
if not (blocked or allowed or dnat_risk):
|
|
100
|
+
# The firewall demonstrably does nothing for any listening port
|
|
101
|
+
# (no rules, or accept-all): that is firewall_protective's finding.
|
|
102
|
+
return self._finding(
|
|
103
|
+
Verdict.SKIP,
|
|
104
|
+
skip_reason="no protective firewall to compare against (see firewall_protective)",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
summary = (
|
|
108
|
+
f"{len(allowed)} allowed by explicit rule {allowed}, "
|
|
109
|
+
f"{len(blocked)} blocked by firewall (not exposed) {blocked}"
|
|
110
|
+
)
|
|
111
|
+
if dnat_risk:
|
|
112
|
+
summary += (
|
|
113
|
+
f"; {dnat_risk} dropped by INPUT but DNAT / port-publishing evidence "
|
|
114
|
+
f"({'; '.join(ctx.dnat_evidence[:3]) or 'unknown'}) means they may still be reachable via FORWARD"
|
|
115
|
+
)
|
|
116
|
+
if not unguarded and dnat_risk:
|
|
117
|
+
return self._finding(
|
|
118
|
+
Verdict.WARN,
|
|
119
|
+
evidence=f"INPUT drop is not proof of non-exposure (DNAT): {summary}",
|
|
120
|
+
remediation=(
|
|
121
|
+
"Published container ports (Docker, rootful podman) are DNAT-ed and bypass the INPUT\n"
|
|
122
|
+
"chain. Restrict them where they are published: bind to 127.0.0.1 (-p 127.0.0.1:PORT:PORT),\n"
|
|
123
|
+
"or filter in the DOCKER-USER / forward hook chain."
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
if not unguarded:
|
|
127
|
+
return self._finding(Verdict.PASS, evidence=f"{len(listen_ports)} public LISTEN sockets: {summary}")
|
|
128
|
+
if len(unguarded) <= 2:
|
|
129
|
+
return self._finding(
|
|
130
|
+
Verdict.WARN,
|
|
131
|
+
evidence=f"public LISTEN ports exposed only by default-accept policy: {unguarded} ({summary})",
|
|
132
|
+
)
|
|
133
|
+
return self._finding(
|
|
134
|
+
Verdict.FAIL,
|
|
135
|
+
evidence=f"{len(unguarded)} public LISTEN ports exposed only by default-accept policy (first 6: {unguarded[:6]}) ({summary})",
|
|
136
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Mandatory Access Control framework — AppArmor (Debian) or SELinux (RHEL).
|
|
2
|
+
|
|
3
|
+
Both frameworks reduce the blast radius of a compromised process by constraining
|
|
4
|
+
which files / network sockets / capabilities it can touch. Disabling them is a
|
|
5
|
+
common 'just make it work' shortcut that drops the host's defense in depth.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from umbermark.core.check import Check, Finding, Severity, Verdict
|
|
10
|
+
from umbermark.core.ssh import CommandRunner
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MacFrameworkEnforcingCheck(Check):
|
|
14
|
+
id = "mac_framework_enforcing"
|
|
15
|
+
title = "Mandatory access control (AppArmor / SELinux) enforcing"
|
|
16
|
+
category = "hardening"
|
|
17
|
+
severity = Severity.HIGH
|
|
18
|
+
weight = 10
|
|
19
|
+
applicable_distros = ()
|
|
20
|
+
remediation = (
|
|
21
|
+
"Re-enable the distro's MAC framework. On Debian/Ubuntu:\n"
|
|
22
|
+
" sudo systemctl enable --now apparmor\n"
|
|
23
|
+
" sudo aa-enforce /etc/apparmor.d/*\n"
|
|
24
|
+
"On RHEL/Rocky/Alma:\n"
|
|
25
|
+
" sudo setenforce 1 # immediate\n"
|
|
26
|
+
" sudo sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config\n"
|
|
27
|
+
" sudo reboot # makes it persistent\n"
|
|
28
|
+
"Both frameworks have a 'complain' (AppArmor) / 'permissive' (SELinux)\n"
|
|
29
|
+
"mode that logs but doesn't block — useful for diagnosis, NOT for production."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def run(self, runner: CommandRunner) -> Finding:
|
|
33
|
+
# AppArmor: `aa-status --enabled` exits 0 if enabled. Parse `aa-status` text
|
|
34
|
+
# output for the profile counts.
|
|
35
|
+
rapp = runner.run("aa-status 2>/dev/null", sudo=True, timeout=5)
|
|
36
|
+
if rapp.ok and rapp.stdout:
|
|
37
|
+
txt = rapp.stdout
|
|
38
|
+
if "apparmor module is loaded" in txt.lower() or "profiles are loaded" in txt.lower():
|
|
39
|
+
# Look for "X profiles are in enforce mode" line
|
|
40
|
+
lines = [ln.strip() for ln in txt.splitlines()]
|
|
41
|
+
enforced = _extract_int(lines, "in enforce mode")
|
|
42
|
+
complain = _extract_int(lines, "in complain mode")
|
|
43
|
+
if enforced is None:
|
|
44
|
+
return self._finding(
|
|
45
|
+
Verdict.SKIP,
|
|
46
|
+
skip_reason="aa-status output unparseable",
|
|
47
|
+
)
|
|
48
|
+
if enforced == 0 and (complain or 0) == 0:
|
|
49
|
+
return self._finding(
|
|
50
|
+
Verdict.FAIL,
|
|
51
|
+
evidence="AppArmor loaded but 0 profiles in enforce or complain mode",
|
|
52
|
+
)
|
|
53
|
+
if enforced == 0 and (complain or 0) > 0:
|
|
54
|
+
return self._finding(
|
|
55
|
+
Verdict.WARN,
|
|
56
|
+
evidence=f"AppArmor: 0 enforcing, {complain} complaining (logs only, no block)",
|
|
57
|
+
)
|
|
58
|
+
return self._finding(
|
|
59
|
+
Verdict.PASS,
|
|
60
|
+
evidence=f"AppArmor: {enforced} profiles enforcing, {complain or 0} complaining",
|
|
61
|
+
)
|
|
62
|
+
if "apparmor module is not loaded" in txt.lower():
|
|
63
|
+
return self._finding(
|
|
64
|
+
Verdict.FAIL, evidence="AppArmor module not loaded"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# SELinux: `getenforce` outputs Enforcing / Permissive / Disabled.
|
|
68
|
+
rse = runner.run("getenforce 2>/dev/null", timeout=5)
|
|
69
|
+
if rse.ok and rse.stdout.strip():
|
|
70
|
+
state = rse.stdout.strip()
|
|
71
|
+
if state == "Enforcing":
|
|
72
|
+
return self._finding(Verdict.PASS, evidence="SELinux: Enforcing")
|
|
73
|
+
if state == "Permissive":
|
|
74
|
+
return self._finding(
|
|
75
|
+
Verdict.WARN, evidence="SELinux: Permissive (logs but does not block)"
|
|
76
|
+
)
|
|
77
|
+
if state == "Disabled":
|
|
78
|
+
return self._finding(Verdict.FAIL, evidence="SELinux: Disabled")
|
|
79
|
+
return self._finding(
|
|
80
|
+
Verdict.SKIP, skip_reason=f"unknown SELinux state: {state!r}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return self._finding(
|
|
84
|
+
Verdict.SKIP,
|
|
85
|
+
skip_reason="neither aa-status (AppArmor) nor getenforce (SELinux) responded",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _extract_int(lines: list[str], needle: str) -> int | None:
|
|
90
|
+
for ln in lines:
|
|
91
|
+
if needle in ln:
|
|
92
|
+
# Lines look like " N profiles are in enforce mode."
|
|
93
|
+
tokens = ln.split()
|
|
94
|
+
for t in tokens:
|
|
95
|
+
if t.isdigit():
|
|
96
|
+
return int(t)
|
|
97
|
+
return None
|