umbermark-cli 1.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. umbermark/__init__.py +3 -0
  2. umbermark/checks/__init__.py +88 -0
  3. umbermark/checks/auto_updates_configured.py +149 -0
  4. umbermark/checks/dns_resolver_sanity.py +47 -0
  5. umbermark/checks/fail2ban_enabled.py +67 -0
  6. umbermark/checks/failed_login_anomaly.py +48 -0
  7. umbermark/checks/failed_systemd_units.py +41 -0
  8. umbermark/checks/firewall_protective.py +247 -0
  9. umbermark/checks/journald_persistence.py +55 -0
  10. umbermark/checks/kernel_age.py +74 -0
  11. umbermark/checks/listen_vs_firewall_delta.py +136 -0
  12. umbermark/checks/mac_framework_enforcing.py +97 -0
  13. umbermark/checks/pending_security_updates.py +188 -0
  14. umbermark/checks/public_listen_anomalies.py +200 -0
  15. umbermark/checks/reboot_pending.py +71 -0
  16. umbermark/checks/repo_gpg_trust.py +97 -0
  17. umbermark/checks/root_disk_free.py +46 -0
  18. umbermark/checks/ssh_non_default_port.py +62 -0
  19. umbermark/checks/ssh_password_auth.py +71 -0
  20. umbermark/checks/ssh_permit_root.py +59 -0
  21. umbermark/checks/ssh_weak_ciphers.py +85 -0
  22. umbermark/checks/sudo_requires_authentication.py +41 -0
  23. umbermark/checks/sudoers_nopasswd.py +111 -0
  24. umbermark/checks/swap_pressure.py +51 -0
  25. umbermark/checks/time_sync.py +55 -0
  26. umbermark/checks/tmp_hardening.py +45 -0
  27. umbermark/checks/user_password_aging.py +95 -0
  28. umbermark/checks/world_writable_suid_audit.py +171 -0
  29. umbermark/cli/__init__.py +4 -0
  30. umbermark/cli/app.py +23 -0
  31. umbermark/cli/auth.py +44 -0
  32. umbermark/cli/commands/__init__.py +0 -0
  33. umbermark/cli/commands/doctor.py +16 -0
  34. umbermark/cli/commands/login.py +80 -0
  35. umbermark/cli/commands/push.py +54 -0
  36. umbermark/cli/commands/report.py +26 -0
  37. umbermark/cli/commands/scan.py +77 -0
  38. umbermark/cli/commands/scan_local.py +72 -0
  39. umbermark/cli/commands/scan_wsl.py +35 -0
  40. umbermark/cli/commands/version.py +11 -0
  41. umbermark/cli/config.py +13 -0
  42. umbermark/core/__init__.py +0 -0
  43. umbermark/core/accepted.py +61 -0
  44. umbermark/core/access.py +896 -0
  45. umbermark/core/capture.py +228 -0
  46. umbermark/core/check.py +82 -0
  47. umbermark/core/checkin.py +89 -0
  48. umbermark/core/crypto.py +15 -0
  49. umbermark/core/duration.py +45 -0
  50. umbermark/core/engine.py +74 -0
  51. umbermark/core/external/__init__.py +6 -0
  52. umbermark/core/external/check.py +54 -0
  53. umbermark/core/external/checks/__init__.py +31 -0
  54. umbermark/core/external/checks/exposed_datastores.py +55 -0
  55. umbermark/core/external/checks/http_security_headers.py +249 -0
  56. umbermark/core/external/checks/legacy_services.py +52 -0
  57. umbermark/core/external/checks/open_management_ports.py +65 -0
  58. umbermark/core/external/checks/ssh_alt_ports.py +77 -0
  59. umbermark/core/external/checks/ssh_weak_algorithms.py +93 -0
  60. umbermark/core/external/checks/tls_hygiene.py +107 -0
  61. umbermark/core/external/engine.py +89 -0
  62. umbermark/core/external/ownership.py +607 -0
  63. umbermark/core/external/probe.py +103 -0
  64. umbermark/core/external/socket_probe.py +400 -0
  65. umbermark/core/firewall.py +713 -0
  66. umbermark/core/fleet.py +92 -0
  67. umbermark/core/history.py +235 -0
  68. umbermark/core/html_report.py +306 -0
  69. umbermark/core/keyenc.py +42 -0
  70. umbermark/core/labels.py +29 -0
  71. umbermark/core/lifecycle.py +100 -0
  72. umbermark/core/logredact.py +44 -0
  73. umbermark/core/partial.py +57 -0
  74. umbermark/core/pdf/__init__.py +0 -0
  75. umbermark/core/pdf/renderer.py +26 -0
  76. umbermark/core/pdf/weasyprint_renderer.py +18 -0
  77. umbermark/core/platform.py +73 -0
  78. umbermark/core/redact.py +75 -0
  79. umbermark/core/report.py +25 -0
  80. umbermark/core/scoring.py +105 -0
  81. umbermark/core/settings.py +238 -0
  82. umbermark/core/short_id.py +27 -0
  83. umbermark/core/signing.py +24 -0
  84. umbermark/core/ssh.py +156 -0
  85. umbermark/core/ssh_fingerprint.py +16 -0
  86. umbermark/core/tiers.py +128 -0
  87. umbermark/core/webhooks.py +177 -0
  88. umbermark/core/wording.py +371 -0
  89. umbermark_cli-1.0.0rc1.dist-info/METADATA +11 -0
  90. umbermark_cli-1.0.0rc1.dist-info/RECORD +93 -0
  91. umbermark_cli-1.0.0rc1.dist-info/WHEEL +5 -0
  92. umbermark_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
  93. umbermark_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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