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,896 @@
1
+ """AccessContext: *how can an attacker reach this host and how do they authenticate?*
2
+
3
+ Hardening verdicts are only meaningful relative to exposure. "SSH password
4
+ auth enabled" is critical on an internet-facing box and a minor note on a box
5
+ whose only SSH path is a VPN overlay. This module builds one `AccessContext`
6
+ per scan that checks consult to scale severity (and to avoid advice such as
7
+ "open port 22" that would make things worse).
8
+
9
+ The context is built by a registry of *detectors* (see `DETECTORS`). Each
10
+ detector declares the collector commands it needs; the builder issues all of
11
+ them up front, once per runner (cached on the runner), and then runs the
12
+ detectors in order. Because `detect_platform` builds the context, the
13
+ Tier-2 capture script (which enumerates commands from `detect_platform` +
14
+ the checks) includes every command, and bundle replay never asks for one
15
+ that was not captured. `tests/unit/test_capture_enumeration_guard.py`
16
+ enforces this.
17
+
18
+ Dimensions currently modelled
19
+ -----------------------------
20
+ * network reachability of SSH: PUBLIC / OVERLAY_ONLY (VPN overlay) /
21
+ PRIVATE_ONLY / UNKNOWN
22
+ * ssh authentication strength: PASSWORD_POSSIBLE / KEY_ONLY / KEY_PLUS_2FA /
23
+ CA_CERTS / UNKNOWN
24
+ * privilege-escalation authentication: SUDO_LOCAL_PASSWORD /
25
+ SUDO_DIRECTORY (PAM sss/krb5/winbind/ldap) / NONE (pam_permit) / UNKNOWN
26
+ * local account status (usable password vs locked vs no login shell). The
27
+ account command prints one character of the shadow password field, never
28
+ a hash.
29
+
30
+ Reachability honesty
31
+ --------------------
32
+ "No public address on any interface" does NOT prove a host is private: cloud
33
+ providers commonly map a public IP 1:1 onto a private NIC address (the public
34
+ address is never on the interface). So `PRIVATE_ONLY` is only chosen with
35
+ corroborating evidence (a firewall that drops the port from every public
36
+ interface, or sshd bound only to private/loopback addresses). Absent that the
37
+ answer is UNKNOWN, and checks do not reduce severity for UNKNOWN.
38
+
39
+ FUTURE detectors (not implemented; each would add collector commands and a
40
+ detector, nothing else needs to change)
41
+ ------------------------------------------------------------------------
42
+ * SSH certificate authorities: TrustedUserCAKeys / AuthorizedPrincipals files
43
+ * second factor in PAM: google-authenticator, Duo, pam_u2f, pam_oath
44
+ * AllowUsers / AllowGroups / DenyUsers / Match blocks (per-user and
45
+ per-source auth overrides)
46
+ * GSSAPI / Kerberos SSH authentication
47
+ * cloud session managers: SSM, IAP, OS Login, Azure Bastion, serial console
48
+ * ProxyJump-only bastion topologies (SSH reachable only from a jump host)
49
+ * other overlays: WireGuard peer configs, ZeroTier, Nebula, OpenVPN routes
50
+ * port knocking / single-packet authorisation
51
+ * provider-side security groups and cloud firewalls (invisible from the host)
52
+ * ip6tables / per-VRF / network-namespace firewalls
53
+ """
54
+ from __future__ import annotations
55
+
56
+ import ipaddress
57
+ import re
58
+ from dataclasses import dataclass, field
59
+ from enum import Enum
60
+
61
+ from umbermark.core.check import Severity
62
+ from umbermark.core.firewall import (
63
+ _DPORT_RE,
64
+ _NFT_COMMENT_RE,
65
+ _parse_port_spec,
66
+ Exposure,
67
+ Firewall,
68
+ classify_address,
69
+ is_loopback_iface,
70
+ is_overlay_iface,
71
+ sources_are_private,
72
+ )
73
+ from umbermark.core.ssh import CommandResult, CommandRunner
74
+
75
+
76
+ class Reachability(Enum):
77
+ PUBLIC = "public"
78
+ OVERLAY_ONLY = "overlay-only"
79
+ PRIVATE_ONLY = "private-only"
80
+ UNKNOWN = "unknown"
81
+
82
+
83
+ class SshAuth(Enum):
84
+ PASSWORD_POSSIBLE = "password-possible"
85
+ KEY_ONLY = "key-only"
86
+ KEY_PLUS_2FA = "key+2fa"
87
+ CA_CERTS = "ca-certs"
88
+ UNKNOWN = "unknown"
89
+
90
+
91
+ class SudoAuth(Enum):
92
+ LOCAL_PASSWORD = "sudo-local-password"
93
+ DIRECTORY = "sudo-directory"
94
+ NONE = "none"
95
+ UNKNOWN = "unknown"
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Collector:
100
+ command: str
101
+ sudo: bool = False
102
+ timeout: float = 10.0
103
+
104
+
105
+ @dataclass
106
+ class Interface:
107
+ name: str
108
+ addrs: list[tuple[str, str]] = field(default_factory=list) # (address, class)
109
+
110
+
111
+ @dataclass
112
+ class Account:
113
+ name: str
114
+ flag: str # first char of the shadow password field ('!', '*', '$', '' ...)
115
+ shell: str
116
+
117
+ @property
118
+ def usable_password(self) -> bool:
119
+ return self.flag not in ("!", "*")
120
+
121
+ @property
122
+ def can_login(self) -> bool:
123
+ return bool(self.shell) and not self.shell.endswith(("nologin", "/false"))
124
+
125
+
126
+ @dataclass
127
+ class AccessContext:
128
+ raw: dict[str, CommandResult] = field(default_factory=dict)
129
+ interfaces: list[Interface] = field(default_factory=list)
130
+ firewall: Firewall = field(default_factory=Firewall)
131
+ listeners: list[tuple[str, int]] = field(default_factory=list)
132
+ listen_readable: bool = False
133
+ ssh_ports: tuple[int, ...] = (22,)
134
+ ssh_facts_readable: bool = False
135
+ ssh_password_sources: list[str] = field(default_factory=list) # file:line where PasswordAuthentication yes
136
+ pam: dict[str, list[str]] = field(default_factory=dict) # service -> auth lines
137
+ accounts: dict[str, Account] = field(default_factory=dict)
138
+ accounts_readable: bool = False
139
+ has_global_ipv6: bool | None = None # None = interface data unreadable
140
+ dnat_ports: list[tuple[int, int]] = field(default_factory=list)
141
+ dnat_unknown: bool = False # DNAT / port publishing seen, ports not determinable
142
+ dnat_evidence: list[str] = field(default_factory=list)
143
+ ssh_match_password: list[tuple[str, str]] = field(default_factory=list) # (Match criteria, file:line)
144
+ reachability: Reachability = Reachability.UNKNOWN
145
+ reachability_note: str = "reachability not determined"
146
+ ssh_auth: SshAuth = SshAuth.UNKNOWN
147
+ ssh_auth_note: str = ""
148
+ sudo_auth: SudoAuth = SudoAuth.UNKNOWN
149
+ sudo_auth_note: str = ""
150
+
151
+ @property
152
+ def has_public_address(self) -> bool:
153
+ return any(c == "public" for i in self.interfaces if not is_overlay_iface(i.name)
154
+ for _, c in i.addrs)
155
+
156
+ def dnat_suspect(self, port: int) -> bool:
157
+ """True if `port` may be reachable through DNAT / published-port forwarding,
158
+ which bypasses the INPUT chain, so an INPUT drop proves nothing for it."""
159
+ return self.dnat_unknown or any(lo <= port <= hi for lo, hi in self.dnat_ports)
160
+
161
+ def account(self, name: str) -> Account | None:
162
+ return self.accounts.get(name)
163
+
164
+
165
+ # --------------------------------------------------------------------------- #
166
+ # Collectors (strings are shared verbatim with the checks so the capture
167
+ # enumerator deduplicates them)
168
+ # --------------------------------------------------------------------------- #
169
+ C_IP = Collector("ip -o addr show 2>/dev/null", timeout=5)
170
+ C_NFT = Collector("nft list ruleset 2>/dev/null", sudo=True, timeout=5)
171
+ C_IPT = Collector("iptables -S 2>/dev/null", sudo=True, timeout=5)
172
+ C_UFW = Collector("ufw status 2>/dev/null", sudo=True, timeout=5)
173
+ C_UFW_V = Collector("ufw status verbose 2>/dev/null", sudo=True, timeout=5)
174
+ C_SS = Collector("ss -tln 2>/dev/null", sudo=True, timeout=10)
175
+ C_SS_P = Collector("ss -tlnp 2>/dev/null", sudo=True, timeout=10)
176
+ C_IP6T = Collector("ip6tables -S 2>/dev/null", sudo=True, timeout=5)
177
+ C_IPT_NAT = Collector("iptables -t nat -S 2>/dev/null", sudo=True, timeout=5)
178
+ # Docker with the legacy iptables backend while `iptables` is the nft front-end: the DOCKER chain
179
+ # and DNAT rules live only in the legacy tables (UMBR-27). Absent binary -> rc != 0 -> ignored.
180
+ # Gated on the table already being loaded: running iptables-legacy otherwise makes the kernel
181
+ # autoload ip_tables/iptable_nat and create empty tables, which changes the host.
182
+ # `sh -c '...'` keeps each a single simple command, so the runner's `sudo -n ` prefix covers the
183
+ # grep AND iptables-legacy (a bare `a && b` would run only `a` as root and `b` would fail with
184
+ # "you must be root", silently dropping the result for every non-root sudo user).
185
+ C_IPT_LEGACY = Collector(
186
+ "sh -c 'grep -qx filter /proc/net/ip_tables_names 2>/dev/null && iptables-legacy -S 2>/dev/null'",
187
+ sudo=True, timeout=5)
188
+ C_IPT_LEGACY_NAT = Collector(
189
+ "sh -c 'grep -qx nat /proc/net/ip_tables_names 2>/dev/null && iptables-legacy -t nat -S 2>/dev/null'",
190
+ sudo=True, timeout=5)
191
+ C_SSHD = Collector("sshd -T 2>/dev/null", sudo=True, timeout=10)
192
+ # Effective per-context sshd config (UMBR-27): `sshd -T` alone shows only the non-Match values.
193
+ # Two contexts: root from a documentation-range address, and the first ordinary account
194
+ # (uid 1000-59999 with a login shell). The second fails harmlessly (rc != 0, ignored) if no
195
+ # such account exists or sshd is too old for -C.
196
+ C_SSHD_ROOT = Collector("sshd -T -C user=root,host=x,addr=203.0.113.1 2>/dev/null", sudo=True, timeout=10)
197
+ C_SSHD_USER = Collector(
198
+ "sshd -T -C \"user=$(awk -F: '$3>=1000 && $3<60000 && $7!~/(nologin|false)$/ {print $1; exit}' /etc/passwd),"
199
+ "host=x,addr=203.0.113.1\" 2>/dev/null",
200
+ sudo=True, timeout=10,
201
+ )
202
+ # File:line of PasswordAuthentication and Match lines (in order) from the main config and drop-ins.
203
+ # `sshd -T` shows only the non-Match values; a Match block can switch passwords back on.
204
+ # (Command string is unchanged on purpose: existing captured bundles are keyed by it.)
205
+ C_SSHD_SRC = Collector(
206
+ "grep -rEin '^[[:space:]]*(Match[[:space:]]|PasswordAuthentication)' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null",
207
+ sudo=True, timeout=5,
208
+ )
209
+ # Same shape for the other ways a Match block can re-enable a password-type login (UMBR-27):
210
+ # KbdInteractiveAuthentication (legacy ChallengeResponseAuthentication) and AuthenticationMethods.
211
+ C_SSHD_SRC_EXTRA = Collector(
212
+ "grep -rEin '^[[:space:]]*(Match[[:space:]]|KbdInteractiveAuthentication|ChallengeResponseAuthentication|"
213
+ "AuthenticationMethods)' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null",
214
+ sudo=True, timeout=5,
215
+ )
216
+ C_PAM = Collector(
217
+ "grep -HE '^[[:space:]]*-?(auth|@include)' /etc/pam.d/sshd /etc/pam.d/sudo "
218
+ "/etc/pam.d/common-auth /etc/pam.d/system-auth 2>/dev/null",
219
+ sudo=True, timeout=5,
220
+ )
221
+ # Prints "<user> [<first char of shadow pw field>] <shell>". One character only:
222
+ # '!' or '*' = locked/no password, '$' = a hash exists. NEVER the hash itself.
223
+ C_ACCOUNTS = Collector(
224
+ "awk -F: 'NR==FNR{s[$1]=$7;next}{print $1, \"[\" substr($2,1,1) \"]\", s[$1]}' "
225
+ "/etc/passwd /etc/shadow 2>/dev/null",
226
+ sudo=True, timeout=5,
227
+ )
228
+
229
+
230
+ def _ok(ctx: AccessContext, col: Collector) -> str | None:
231
+ r = ctx.raw.get(col.command)
232
+ return r.stdout if (r is not None and r.ok) else None
233
+
234
+
235
+ # --------------------------------------------------------------------------- #
236
+ # Detectors
237
+ # --------------------------------------------------------------------------- #
238
+ class Detector:
239
+ name = ""
240
+ collectors: tuple[Collector, ...] = ()
241
+
242
+ def detect(self, ctx: AccessContext) -> None: # pragma: no cover - interface
243
+ raise NotImplementedError
244
+
245
+
246
+ _IP_RE = re.compile(r"^\d+:\s+(\S+?)(?:@\S+)?\s+inet6?\s+([0-9a-fA-F:.]+)/\d+")
247
+
248
+
249
+ class InterfacesDetector(Detector):
250
+ name = "interfaces"
251
+ collectors = (C_IP,)
252
+
253
+ def detect(self, ctx: AccessContext) -> None:
254
+ out = _ok(ctx, C_IP) or ""
255
+ ifaces: dict[str, Interface] = {}
256
+ for line in out.splitlines():
257
+ m = _IP_RE.match(line.strip())
258
+ if not m:
259
+ continue
260
+ name, addr = m.group(1), m.group(2)
261
+ ifaces.setdefault(name, Interface(name)).addrs.append((addr, classify_address(addr)))
262
+ ctx.interfaces = list(ifaces.values())
263
+ if ifaces:
264
+ ctx.has_global_ipv6 = any(
265
+ ":" in a and c == "public"
266
+ for i in ifaces.values() if not is_overlay_iface(i.name) for a, c in i.addrs
267
+ )
268
+
269
+
270
+ class FirewallDetector(Detector):
271
+ name = "firewall"
272
+ collectors = (C_NFT, C_IPT, C_IP6T, C_UFW, C_UFW_V)
273
+
274
+ def detect(self, ctx: AccessContext) -> None:
275
+ ufw = _ok(ctx, C_UFW_V)
276
+ if not ufw:
277
+ ufw = _ok(ctx, C_UFW)
278
+ public = [i.name for i in ctx.interfaces
279
+ if not is_loopback_iface(i.name) and not is_overlay_iface(i.name)]
280
+ ctx.firewall = Firewall(
281
+ nft=_ok(ctx, C_NFT), iptables=_ok(ctx, C_IPT), ufw=ufw, public_ifaces=public,
282
+ ip6tables=_ok(ctx, C_IP6T), ipv6=ctx.has_global_ipv6,
283
+ )
284
+
285
+
286
+ _WILDCARDS = ("0.0.0.0", "*", "[::]", "::")
287
+
288
+
289
+ def parse_listeners(ss_output: str) -> list[tuple[str, int]]:
290
+ out: list[tuple[str, int]] = []
291
+ for line in ss_output.splitlines():
292
+ if not line.startswith("LISTEN"):
293
+ continue
294
+ cols = line.split()
295
+ if len(cols) < 4:
296
+ continue
297
+ host, _, port = cols[3].rpartition(":")
298
+ if not port.isdigit():
299
+ continue
300
+ host = host.strip("[]").split("%")[0] or "*"
301
+ out.append(("::" if host == "::" else host, int(port)))
302
+ return out
303
+
304
+
305
+ class ListenDetector(Detector):
306
+ name = "listeners"
307
+ collectors = (C_SS,)
308
+
309
+ def detect(self, ctx: AccessContext) -> None:
310
+ out = _ok(ctx, C_SS)
311
+ if out and out.strip():
312
+ ctx.listeners = parse_listeners(out)
313
+ ctx.listen_readable = True
314
+
315
+
316
+ _PROXY_PROCS = {"docker-proxy", "rootlessport"}
317
+ _SS_USERS_RE = re.compile(r'\("([^"]+)"')
318
+
319
+
320
+ class DnatDetector(Detector):
321
+ """Evidence that some ports are reachable via DNAT / published-port forwarding.
322
+
323
+ Docker, rootful podman and hand-written DNAT deliver traffic through
324
+ PREROUTING + FORWARD, so an INPUT-chain drop says nothing about those ports.
325
+ Rootless podman via pasta binds on the host and DOES traverse INPUT, so it is
326
+ deliberately not evidence. When DNAT exists but its ports cannot be
327
+ determined, every port is treated as suspect (dnat_unknown).
328
+ """
329
+
330
+ name = "dnat"
331
+ collectors = (C_IPT_NAT, C_IPT_LEGACY, C_IPT_LEGACY_NAT, C_SS_P)
332
+
333
+ def detect(self, ctx: AccessContext) -> None:
334
+ ev = ctx.dnat_evidence
335
+ for raw in (_ok(ctx, C_NFT) or "").splitlines():
336
+ line = _NFT_COMMENT_RE.sub(" ", raw).split("#", 1)[0]
337
+ if re.match(r"\s*chain\s+DOCKER\b", line):
338
+ ev.append("nft DOCKER chain")
339
+ ctx.dnat_unknown = True
340
+ if re.search(r"\bdnat\b", line):
341
+ ev.append("nft dnat rule")
342
+ mm = _DPORT_RE.search(line)
343
+ ports = _parse_port_spec(mm.group(3), {}) if mm else None
344
+ if ports:
345
+ ctx.dnat_ports += ports
346
+ else:
347
+ ctx.dnat_unknown = True
348
+ if re.search(r"^-N\s+DOCKER\b", _ok(ctx, C_IPT) or "", re.M):
349
+ ev.append("iptables DOCKER chain")
350
+ ctx.dnat_unknown = True
351
+ # Legacy-backend tables (same chain/DNAT evidence, labelled so reports show the source).
352
+ # When `iptables` is itself the legacy binary the output is byte-identical: skip it.
353
+ leg_f = _ok(ctx, C_IPT_LEGACY) or ""
354
+ leg_n = _ok(ctx, C_IPT_LEGACY_NAT) or ""
355
+ if leg_f and leg_f == (_ok(ctx, C_IPT) or ""):
356
+ leg_f = ""
357
+ if leg_n and leg_n == (_ok(ctx, C_IPT_NAT) or ""):
358
+ leg_n = ""
359
+ if re.search(r"^-N\s+DOCKER\b", leg_f, re.M) or re.search(r"^-N\s+DOCKER\b", leg_n, re.M):
360
+ ev.append("iptables-legacy DOCKER chain")
361
+ ctx.dnat_unknown = True
362
+ for label, text in (("iptables", _ok(ctx, C_IPT_NAT)), ("iptables-legacy", leg_n)):
363
+ self._scan_nat(ctx, label, text or "")
364
+ for line in (_ok(ctx, C_SS_P) or "").splitlines():
365
+ if not line.startswith("LISTEN"):
366
+ continue
367
+ if not _PROXY_PROCS & set(_SS_USERS_RE.findall(line)):
368
+ continue
369
+ cols = line.split()
370
+ port = cols[3].rpartition(":")[2] if len(cols) > 3 else ""
371
+ if port.isdigit():
372
+ ctx.dnat_ports.append((int(port), int(port)))
373
+ ev.append(f"port-publishing proxy listening on {port}")
374
+ else:
375
+ ctx.dnat_unknown = True
376
+
377
+ @staticmethod
378
+ def _scan_nat(ctx: AccessContext, label: str, text: str) -> None:
379
+ ev = ctx.dnat_evidence
380
+ for line in text.splitlines():
381
+ if re.search(r"-j\s+DNAT\b", line):
382
+ ev.append(f"{label} DNAT rule")
383
+ mm = re.search(r"--dport\s+([\d:]+)", line)
384
+ ports = _parse_port_spec(mm.group(1).replace(":", "-"), {}) if mm else None
385
+ if ports:
386
+ ctx.dnat_ports += ports
387
+ else:
388
+ ctx.dnat_unknown = True
389
+
390
+
391
+ _PAM_DIRECTORY = re.compile(r"pam_(sss|krb5|winbind|ldap|ccreds)\b")
392
+ _PAM_2FA = re.compile(r"pam_(google_authenticator|duo|u2f|oath|yubico|radius_auth)\b")
393
+
394
+
395
+ _AUTH_RE = re.compile(r"^-?auth\s+(\[[^\]]*\]|\S+)\s+(\S+)")
396
+ _INCLUDE_RE = re.compile(r"^(?:@include\s+(\S+)|-?auth\s+(?:include|substack)\s+(\S+))")
397
+
398
+
399
+ def effective_auth_stack(pam: dict[str, list[str]], service: str,
400
+ _seen: tuple[str, ...] = ()) -> list[tuple[str, str]]:
401
+ """Ordered (control, module) auth entries for `service`, following includes."""
402
+ out: list[tuple[str, str]] = []
403
+ for line in pam.get(service, []):
404
+ if line.startswith("#"):
405
+ continue
406
+ inc = _INCLUDE_RE.match(line)
407
+ if inc:
408
+ name = inc.group(1) or inc.group(2)
409
+ if name not in _seen and len(_seen) < 6:
410
+ out += effective_auth_stack(pam, name, _seen + (service,))
411
+ continue
412
+ m = _AUTH_RE.match(line)
413
+ if m:
414
+ out.append((m.group(1).lower(), m.group(2).rsplit("/", 1)[-1].lower()))
415
+ return out
416
+
417
+
418
+ def classify_sudo_auth(stack: list[tuple[str, str]]) -> tuple[SudoAuth, str]:
419
+ """Walk sudo's effective auth stack in order.
420
+
421
+ * a directory module (sss/krb5/winbind/ldap) reached BEFORE any non-optional
422
+ pam_unix -> DIRECTORY;
423
+ * a non-optional pam_unix first -> LOCAL_PASSWORD (a directory module after
424
+ it is only a fallback, so local credentials remain in play);
425
+ * `sufficient` pam_permit (or required pam_permit with no real auth module)
426
+ -> NONE: sudo asks for nothing.
427
+
428
+ Limitation: `substack` lines are flattened, so a done/die inside a substack ends the whole
429
+ simulated walk rather than only the substack; a substacked file ending in
430
+ `sufficient pam_permit` followed by a required pam_unix in the parent can be over-reported
431
+ as NONE.
432
+ """
433
+ if not stack:
434
+ return SudoAuth.UNKNOWN, "sudo PAM auth stack not readable"
435
+ # First: can someone with NO valid credential get through? Simulate the whole stack with
436
+ # every real module failing (UMBR-27). Only then classify which backend is in play.
437
+ if _pam_permits_without_credentials(stack):
438
+ return SudoAuth.NONE, (
439
+ "sudo PAM stack lets a failed authentication through: a pam_permit is reachable "
440
+ "after the real authentication module fails, so no credential is actually required"
441
+ )
442
+ for control, module in stack:
443
+ if "pam_permit" in module:
444
+ continue
445
+ if _PAM_DIRECTORY.search(module):
446
+ return SudoAuth.DIRECTORY, f"sudo authenticates via a directory backend ({module})"
447
+ if "pam_unix" in module and control != "optional":
448
+ return SudoAuth.LOCAL_PASSWORD, "sudo authenticates against local accounts (pam_unix)"
449
+ return SudoAuth.UNKNOWN, "sudo PAM stack not recognised"
450
+
451
+
452
+ # Linux-PAM simple controls expressed as [result=action ...] tables (pam.conf(5)).
453
+ _PAM_SIMPLE = {
454
+ "required": {"success": "ok", "default": "bad"},
455
+ "requisite": {"success": "ok", "default": "die"},
456
+ "sufficient": {"success": "done", "default": "ignore"},
457
+ "optional": {"success": "ignore", "default": "ignore"},
458
+ }
459
+ _PAM_KV = re.compile(r"(\w+)=(\w+)")
460
+
461
+
462
+ def _pam_actions(control: str) -> dict[str, str]:
463
+ control = control.strip()
464
+ if control.startswith("["):
465
+ return {k.lower(): v.lower() for k, v in _PAM_KV.findall(control)}
466
+ return _PAM_SIMPLE.get(control.lower(), _PAM_SIMPLE["optional"])
467
+
468
+
469
+ # Ways a real module can fail (pam_sss answers user_unknown for a local account and
470
+ # authinfo_unavail when the directory is offline), each simulated separately.
471
+ _PAM_FAIL_CODES = ("auth_err", "user_unknown", "authinfo_unavail", "cred_insufficient", "maxtries")
472
+
473
+
474
+ def _pam_walk(stack: list[tuple[str, str]], fail_code: str) -> bool:
475
+ """One libpam (pam_dispatch.c) auth-chain pass: pam_permit succeeds, pam_deny fails with
476
+ auth_err, real credential modules fail with `fail_code`, everything else is skipped.
477
+
478
+ State is (impression, status) as libpam keeps it: ok/done only take effect while
479
+ the impression is undefined (or positive with status still success) and they copy the
480
+ module's result into status, so `ok` on a failing module makes the stack fail. bad/die set
481
+ a negative impression. reset clears both. A `[...=N]` jump only skips N modules. A `done`
482
+ under a negative impression does not stop the walk (pam_dispatch.c only stops when the
483
+ impression is not negative), so a later `reset` can still revive the stack.
484
+ Starts failed (PAM_MUST_FAIL_CODE); True means the stack ends in PAM_SUCCESS.
485
+
486
+ Known limitation: substacks are flattened by effective_auth_stack, so a done/die inside a
487
+ substack ends the whole walk here, while libpam ends only the substack.
488
+ """
489
+ impression, ok = "undef", False # ok == (status == PAM_SUCCESS)
490
+ i = 0
491
+ while i < len(stack):
492
+ control, module = stack[i]
493
+ i += 1
494
+ if "pam_permit" in module:
495
+ result = "success"
496
+ elif "pam_deny" in module:
497
+ result = "auth_err"
498
+ elif "pam_unix" in module or _PAM_DIRECTORY.search(module):
499
+ result = fail_code
500
+ else:
501
+ continue
502
+ acts = _pam_actions(control)
503
+ action = acts.get(result) or acts.get("default") or "bad"
504
+ if action == "ignore":
505
+ continue
506
+ if action == "reset":
507
+ impression, ok = "undef", False
508
+ continue
509
+ if action.isdigit():
510
+ i += int(action) # jump: no change to impression or status
511
+ continue
512
+ if action in ("bad", "die"):
513
+ if impression != "negative":
514
+ impression, ok = "negative", False
515
+ if action == "die":
516
+ return False
517
+ continue
518
+ # ok / done
519
+ if impression == "undef" or (impression == "positive" and ok):
520
+ impression, ok = "positive", result == "success"
521
+ if action == "done" and impression != "negative":
522
+ return ok
523
+ return ok
524
+
525
+
526
+ def _pam_permits_without_credentials(stack: list[tuple[str, str]]) -> bool:
527
+ """True if some plausible credential failure still ends the stack in success (sudo with
528
+ no valid credential). Each failure code in _PAM_FAIL_CODES is simulated on the real
529
+ modules; one success is enough.
530
+
531
+ `[success=N]` jumps count entries in the flattened (include-expanded) stack, which
532
+ matches how distro stacks are written (the jump sits in the same file as its target).
533
+ """
534
+ return any(_pam_walk(stack, code) for code in _PAM_FAIL_CODES)
535
+
536
+
537
+ class PamDetector(Detector):
538
+ """Sudo credential backend (local passwd vs directory vs none) from the PAM stacks."""
539
+
540
+ name = "pam"
541
+ collectors = (C_PAM,)
542
+
543
+ def detect(self, ctx: AccessContext) -> None:
544
+ r = ctx.raw.get(C_PAM.command)
545
+ # grep exits 2 when one of the (distro-specific) files is missing even
546
+ # though others matched, so trust stdout rather than the exit code.
547
+ text = r.stdout if r is not None else ""
548
+ for line in text.splitlines():
549
+ svc, sep, rest = line.partition(":")
550
+ rest = rest.strip()
551
+ if not sep or not rest or rest.startswith("#"):
552
+ continue
553
+ ctx.pam.setdefault(svc.rsplit("/", 1)[-1], []).append(rest)
554
+ ctx.sudo_auth, ctx.sudo_auth_note = classify_sudo_auth(effective_auth_stack(ctx.pam, "sudo"))
555
+
556
+
557
+ def _sshd_value(text: str, key: str) -> str | None:
558
+ m = re.search(rf"^{key}\s+(.+)$", text, re.M | re.I)
559
+ return m.group(1).strip().lower() if m else None
560
+
561
+
562
+ _SRC_RE = re.compile(r"^(?P<file>[^:]+):(?P<line>\d+):(?P<text>.*)$")
563
+
564
+
565
+ def parse_sshd_sources(text: str, pam_2fa: bool = False,
566
+ usepam: bool = True) -> tuple[list[str], list[tuple[str, str]]]:
567
+ """Return (global 'PasswordAuthentication yes' sources, [(Match criteria, file:line)]).
568
+
569
+ A Match block runs to the next Match or the end of its file; `Match all` is global.
570
+ `pam_2fa` / `usepam` are the host's global facts, applied to Match hits the way the global
571
+ classification applies them: a Match KbdInteractiveAuthentication needs UsePAM, and a
572
+ key-first AuthenticationMethods with a PAM 2FA module is not a password path.
573
+ """
574
+ global_yes: list[str] = []
575
+ match_yes: list[tuple[str, str]] = []
576
+ cur_file, criteria = None, None
577
+ for raw in text.splitlines():
578
+ m = _SRC_RE.match(raw)
579
+ if not m:
580
+ continue
581
+ f, ln, body = m.group("file"), m.group("line"), m.group("text").strip()
582
+ if f != cur_file:
583
+ cur_file, criteria = f, None
584
+ mm = re.match(r"Match\s+(.*)$", body, re.I)
585
+ if mm:
586
+ crit = mm.group(1).strip()
587
+ criteria = None if crit.lower() == "all" else crit
588
+ continue
589
+ if re.match(r"PasswordAuthentication\s+yes\b", body, re.I):
590
+ if criteria is None:
591
+ global_yes.append(f"{f}:{ln}:{body}")
592
+ else:
593
+ match_yes.append((criteria, f"{f}:{ln}"))
594
+ elif criteria is not None:
595
+ # Other password-ish ways back in, inside a Match block only (the global values
596
+ # are read from `sshd -T`). `where` names the directive since it is not the password one.
597
+ if re.match(r"(KbdInteractiveAuthentication|ChallengeResponseAuthentication)\s+yes\b", body, re.I):
598
+ if usepam:
599
+ match_yes.append((criteria, f"{f}:{ln}: {body}"))
600
+ else:
601
+ am = re.match(r"AuthenticationMethods\s+(.+)$", body, re.I)
602
+ if am and _methods_allow_password(am.group(1), pam_2fa):
603
+ match_yes.append((criteria, f"{f}:{ln}: {body}"))
604
+ return global_yes, match_yes
605
+
606
+
607
+ def _methods_allow_password(value: str, pam_2fa: bool = False) -> bool:
608
+ """True if an AuthenticationMethods value lets a password-type factor in, consistent with
609
+ SshAuthDetector._classify: any alternative that needs no key but allows password or
610
+ keyboard-interactive, or a key followed by such a factor when no PAM 2FA module is known
611
+ (`publickey,password` is then reported, as it is for the global value). Callers pass
612
+ the host's real pam_2fa value (PamDetector runs before SshAuthDetector)."""
613
+ for alt in value.strip().strip('"').lower().split():
614
+ methods = [m.split(":")[0] for m in alt.split(",")]
615
+ if pam_2fa and methods[0] == "publickey" and "password" not in methods:
616
+ continue
617
+ if "password" in methods or "keyboard-interactive" in methods:
618
+ return True
619
+ return False
620
+
621
+
622
+ def _effective_weak_auth(sshd_t: str, pam_2fa: bool = False) -> str | None:
623
+ """Reason string if an `sshd -T` dump (global or `-C` context) accepts password-ish auth.
624
+ AuthenticationMethods is evaluated first, as _classify does: it overrides the
625
+ PasswordAuthentication / KbdInteractive flags."""
626
+ methods = _sshd_value(sshd_t, "authenticationmethods") or "any"
627
+ if methods != "any":
628
+ return f"AuthenticationMethods {methods}" if _methods_allow_password(methods, pam_2fa) else None
629
+ if _sshd_value(sshd_t, "passwordauthentication") == "yes":
630
+ return "PasswordAuthentication yes"
631
+ usepam = _sshd_value(sshd_t, "usepam") == "yes"
632
+ kbd = _sshd_value(sshd_t, "kbdinteractiveauthentication") == "yes" or \
633
+ _sshd_value(sshd_t, "challengeresponseauthentication") == "yes"
634
+ if kbd and usepam:
635
+ return "KbdInteractiveAuthentication yes with UsePAM"
636
+ return None
637
+
638
+
639
+ class SshAuthDetector(Detector):
640
+ name = "ssh-auth"
641
+ collectors = (C_SSHD, C_SSHD_SRC, C_SSHD_SRC_EXTRA, C_SSHD_ROOT, C_SSHD_USER)
642
+
643
+ def detect(self, ctx: AccessContext) -> None:
644
+ pam_2fa = bool(_PAM_2FA.search("\n".join(ctx.pam.get("sshd", []) + ctx.pam.get("common-auth", []))))
645
+ usepam = _sshd_value(_ok(ctx, C_SSHD) or "", "usepam") != "no" # unknown -> assume on
646
+ global_yes, ctx.ssh_match_password = parse_sshd_sources(_ok(ctx, C_SSHD_SRC) or "", pam_2fa, usepam)
647
+ ctx.ssh_password_sources = global_yes
648
+ ctx.ssh_match_password += parse_sshd_sources(_ok(ctx, C_SSHD_SRC_EXTRA) or "", pam_2fa, usepam)[1]
649
+ # Effective per-context config (sshd -T -C): catches Match blocks the grep cannot
650
+ # interpret (Match order, Include, defaults). Only added when the global config is clean.
651
+ # "Clean" is decided by _classify (ctx.ssh_auth), not by a second heuristic.
652
+ self._classify(ctx)
653
+ if ctx.ssh_auth not in (SshAuth.PASSWORD_POSSIBLE,):
654
+ for label, col in (("user=root addr=203.0.113.1", C_SSHD_ROOT),
655
+ ("first ordinary user addr=203.0.113.1", C_SSHD_USER)):
656
+ why = _effective_weak_auth(_ok(ctx, col) or "", pam_2fa)
657
+ if why:
658
+ ctx.ssh_match_password.append((label, f"sshd -T -C: {why}"))
659
+ if ctx.ssh_match_password and ctx.ssh_auth != SshAuth.PASSWORD_POSSIBLE:
660
+ crit, where = ctx.ssh_match_password[0]
661
+ ctx.ssh_auth = SshAuth.PASSWORD_POSSIBLE
662
+ ctx.ssh_auth_note = f"password auth enabled for Match {crit} ({where})"
663
+
664
+ def _classify(self, ctx: AccessContext) -> None:
665
+ text = _ok(ctx, C_SSHD)
666
+ if not text:
667
+ ctx.ssh_auth, ctx.ssh_auth_note = SshAuth.UNKNOWN, "sshd -T unavailable"
668
+ return
669
+ ctx.ssh_facts_readable = True
670
+ ports = tuple(int(p) for p in re.findall(r"^port\s+(\d+)", text, re.M | re.I))
671
+ ctx.ssh_ports = ports or (22,)
672
+ password = _sshd_value(text, "passwordauthentication") == "yes"
673
+ kbd = _sshd_value(text, "kbdinteractiveauthentication") == "yes" or \
674
+ _sshd_value(text, "challengeresponseauthentication") == "yes"
675
+ usepam = _sshd_value(text, "usepam") == "yes"
676
+ methods = _sshd_value(text, "authenticationmethods") or "any"
677
+ ca = (_sshd_value(text, "trustedusercakeys") or "none") not in ("none", "")
678
+ pam_2fa = bool(_PAM_2FA.search("\n".join(ctx.pam.get("sshd", []) + ctx.pam.get("common-auth", []))))
679
+
680
+ if methods != "any":
681
+ alts = [a.split(",") for a in methods.split()]
682
+ if all(a and a[0] == "publickey" for a in alts):
683
+ if any(len(a) > 1 and "keyboard-interactive" in a for a in alts) and pam_2fa and kbd:
684
+ ctx.ssh_auth, ctx.ssh_auth_note = SshAuth.KEY_PLUS_2FA, "AuthenticationMethods requires publickey + PAM 2FA"
685
+ elif all(len(a) == 1 for a in alts):
686
+ ctx.ssh_auth = SshAuth.CA_CERTS if ca else SshAuth.KEY_ONLY
687
+ ctx.ssh_auth_note = "AuthenticationMethods publickey only"
688
+ else:
689
+ ctx.ssh_auth, ctx.ssh_auth_note = SshAuth.PASSWORD_POSSIBLE, \
690
+ "publickey followed by a password-type factor (no 2FA module found)"
691
+ return
692
+ ctx.ssh_auth, ctx.ssh_auth_note = SshAuth.PASSWORD_POSSIBLE, \
693
+ f"AuthenticationMethods allows a non-key path ({methods})"
694
+ return
695
+ if password or (kbd and usepam):
696
+ ctx.ssh_auth = SshAuth.PASSWORD_POSSIBLE
697
+ ctx.ssh_auth_note = "PasswordAuthentication yes" if password else "keyboard-interactive via PAM"
698
+ return
699
+ ctx.ssh_auth = SshAuth.CA_CERTS if ca else SshAuth.KEY_ONLY
700
+ ctx.ssh_auth_note = "public keys" + (" and CA-signed certificates" if ca else "") + " only"
701
+
702
+
703
+ _ACCT_RE = re.compile(r"^(\S+)\s+\[(.?)\]\s*(\S*)\s*$")
704
+
705
+
706
+ class AccountsDetector(Detector):
707
+ name = "accounts"
708
+ collectors = (C_ACCOUNTS,)
709
+
710
+ def detect(self, ctx: AccessContext) -> None:
711
+ out = _ok(ctx, C_ACCOUNTS)
712
+ if not out or not out.strip():
713
+ return
714
+ for line in out.splitlines():
715
+ m = _ACCT_RE.match(line.strip())
716
+ if m:
717
+ ctx.accounts[m.group(1)] = Account(m.group(1), m.group(2), m.group(3))
718
+ ctx.accounts_readable = bool(ctx.accounts)
719
+
720
+
721
+ _TAILNET_ULA = ipaddress.ip_network("fd7a:115c:a1e0::/48")
722
+
723
+
724
+ def _is_overlay_addr(addr: str, overlay_addrs: set[str]) -> bool:
725
+ """Overlay evidence: bound to a VPN interface's own address, 100.64.0.0/10
726
+ (Tailscale and other CGNAT-range overlays) or the Tailscale IPv6 ULA range."""
727
+ if addr in overlay_addrs or classify_address(addr) == "cgnat":
728
+ return True
729
+ try:
730
+ return ipaddress.ip_address(addr) in _TAILNET_ULA
731
+ except ValueError:
732
+ return False
733
+
734
+
735
+ def classify_reachability(ctx: AccessContext) -> tuple[Reachability, str]:
736
+ ports = ctx.ssh_ports or (22,)
737
+ fw = ctx.firewall
738
+ port_s = "/".join(str(p) for p in ports)
739
+
740
+ listen_addrs = [a for a, p in ctx.listeners if p in ports] if ctx.listen_readable else []
741
+ wildcard_listen = any(a in _WILDCARDS for a in listen_addrs)
742
+ if listen_addrs and not wildcard_listen:
743
+ non_lo = [a for a in listen_addrs if classify_address(a) != "loopback"]
744
+ if not non_lo:
745
+ return Reachability.PRIVATE_ONLY, f"sshd (tcp/{port_s}) listens on loopback only"
746
+ overlay_addrs = {a for i in ctx.interfaces if is_overlay_iface(i.name) for a, _ in i.addrs}
747
+ if all(_is_overlay_addr(a, overlay_addrs) for a in non_lo):
748
+ return Reachability.OVERLAY_ONLY, f"sshd (tcp/{port_s}) is bound only to VPN-overlay addresses"
749
+ # Any other specific bind (RFC1918, link-local, a public address) proves nothing:
750
+ # a private NIC address can sit behind 1:1 NAT. Fall through to firewall evidence.
751
+
752
+ if fw.readable:
753
+ verdicts = [(p, fw.evaluate(p, "tcp")) for p in ports]
754
+ acc = [(p, v) for p, v in verdicts if v.state == Exposure.ACCEPTED]
755
+ if acc:
756
+ p, v = acc[0]
757
+ how = "an explicit rule" if v.explicit else "the default policy"
758
+ return Reachability.PUBLIC, f"firewall accepts tcp/{p} from public interfaces ({how})"
759
+ unknown = [(p, v) for p, v in verdicts if v.state == Exposure.UNKNOWN]
760
+ if unknown:
761
+ return Reachability.UNKNOWN, f"firewall evidence incomplete for tcp/{unknown[0][0]}: {unknown[0][1].detail}"
762
+ restricted = [(p, v) for p, v in verdicts if v.state == Exposure.RESTRICTED]
763
+ if restricted:
764
+ srcs = sorted({s for _, v in restricted for s in v.sources})
765
+ if srcs and sources_are_private(srcs):
766
+ return Reachability.PRIVATE_ONLY, \
767
+ f"firewall accepts tcp/{port_s} only from private sources ({', '.join(srcs[:4])})"
768
+ return Reachability.UNKNOWN, \
769
+ f"firewall accepts tcp/{port_s} only for specific sources ({', '.join(srcs[:4]) or 'unparsed condition'}); exposure not classified"
770
+ # Every SSH port is dropped from every public interface.
771
+ via = sorted({i for p in ports for i in fw.reachable_via_overlay(p)})
772
+ if via:
773
+ return Reachability.OVERLAY_ONLY, \
774
+ f"firewall drops tcp/{port_s} from public interfaces; it is accepted only on VPN overlay {', '.join(via)}"
775
+ return Reachability.PRIVATE_ONLY, \
776
+ f"firewall drops tcp/{port_s} from every public interface (no public path found)"
777
+
778
+ # No readable firewall: be honest about what is and is not proven.
779
+ if ctx.has_public_address and (wildcard_listen or not ctx.listen_readable):
780
+ return Reachability.PUBLIC, \
781
+ "a public address is configured on a network interface and no firewall rules were readable"
782
+ if ctx.interfaces and not ctx.has_public_address:
783
+ return Reachability.UNKNOWN, (
784
+ "no public address on any interface, but this does not prove the host is private "
785
+ "(cloud public IPs are often 1:1 NAT and never appear on the NIC) and no firewall or "
786
+ "listen evidence corroborates it"
787
+ )
788
+ return Reachability.UNKNOWN, "insufficient evidence (no readable firewall, interface or listen data)"
789
+
790
+
791
+ class ReachabilityDetector(Detector):
792
+ name = "reachability"
793
+ collectors = ()
794
+
795
+ def detect(self, ctx: AccessContext) -> None:
796
+ ctx.reachability, ctx.reachability_note = classify_reachability(ctx)
797
+
798
+
799
+ # Order matters: later detectors read what earlier ones wrote.
800
+ DETECTORS: list[Detector] = [
801
+ InterfacesDetector(),
802
+ FirewallDetector(),
803
+ ListenDetector(),
804
+ DnatDetector(),
805
+ PamDetector(),
806
+ SshAuthDetector(),
807
+ AccountsDetector(),
808
+ ReachabilityDetector(),
809
+ ]
810
+
811
+
812
+ def all_collectors() -> list[Collector]:
813
+ seen: dict[str, Collector] = {}
814
+ for det in DETECTORS:
815
+ for col in det.collectors:
816
+ prev = seen.get(col.command)
817
+ if prev is None:
818
+ seen[col.command] = col
819
+ elif col.sudo and not prev.sudo:
820
+ seen[col.command] = col
821
+ return list(seen.values())
822
+
823
+
824
+ def build_access_context(runner: CommandRunner) -> AccessContext:
825
+ ctx = AccessContext()
826
+ for col in all_collectors(): # all commands issued up front
827
+ ctx.raw[col.command] = runner.run(col.command, sudo=col.sudo, timeout=col.timeout)
828
+ for det in DETECTORS:
829
+ try:
830
+ det.detect(ctx)
831
+ except Exception: # a detector must never break a scan; its fields stay UNKNOWN
832
+ continue
833
+ return ctx
834
+
835
+
836
+ _CACHE_ATTR = "_umbermark_access_ctx"
837
+
838
+
839
+ def get_access_context(runner: CommandRunner) -> AccessContext:
840
+ """Build (once per runner) and return the AccessContext."""
841
+ cached = getattr(runner, _CACHE_ATTR, None)
842
+ if isinstance(cached, AccessContext):
843
+ return cached
844
+ ctx = build_access_context(runner)
845
+ try:
846
+ setattr(runner, _CACHE_ATTR, ctx)
847
+ except (AttributeError, TypeError):
848
+ pass
849
+ return ctx
850
+
851
+
852
+ # --------------------------------------------------------------------------- #
853
+ # Severity scaling helpers used by checks
854
+ # --------------------------------------------------------------------------- #
855
+ _STEP_DOWN = {
856
+ Severity.CRITICAL: Severity.MEDIUM,
857
+ Severity.HIGH: Severity.MEDIUM,
858
+ Severity.MEDIUM: Severity.LOW,
859
+ Severity.LOW: Severity.LOW,
860
+ }
861
+
862
+
863
+ def is_nonpublic(ctx: AccessContext) -> bool:
864
+ return ctx.reachability in (Reachability.OVERLAY_ONLY, Reachability.PRIVATE_ONLY)
865
+
866
+
867
+ def scale_for_reachability(
868
+ severity: Severity, weight: int, ctx: AccessContext
869
+ ) -> tuple[Severity, int, str]:
870
+ """Scale (severity, weight) by exposure. Returns (severity, weight, note).
871
+
872
+ PUBLIC: unchanged, empty note. OVERLAY_ONLY / PRIVATE_ONLY: one-step lower
873
+ severity and a quarter of the weight (min 1), with the reason. UNKNOWN:
874
+ unchanged, but says so (we never claim low exposure without evidence).
875
+ """
876
+ r = ctx.reachability
877
+ if r == Reachability.PUBLIC:
878
+ return severity, weight, ""
879
+ if r == Reachability.UNKNOWN:
880
+ return severity, weight, (
881
+ f"Exposure could not be determined ({ctx.reachability_note}); severity not reduced."
882
+ )
883
+ scaled = max(1, -(-weight // 4)) # ceil(weight / 4)
884
+ return _STEP_DOWN[severity], scaled, (
885
+ f"SSH is not publicly reachable ({r.value}: {ctx.reachability_note}); "
886
+ "severity reduced accordingly."
887
+ )
888
+
889
+
890
+ def maybe_context(runner: CommandRunner) -> AccessContext | None:
891
+ """get_access_context that tolerates exotic runners (never raises)."""
892
+ try:
893
+ return get_access_context(runner)
894
+ except Exception:
895
+ return None
896
+