umbermark-cli 1.0.0__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 +18 -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 +14 -0
- umbermark/core/__init__.py +0 -0
- umbermark/core/accepted.py +61 -0
- umbermark/core/access.py +896 -0
- umbermark/core/branding.py +71 -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/evidence.py +83 -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 +308 -0
- umbermark/core/keyenc.py +42 -0
- umbermark/core/labels.py +29 -0
- umbermark/core/lifecycle.py +100 -0
- umbermark/core/logredact.py +55 -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/remediation.py +121 -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 +138 -0
- umbermark/core/webhooks.py +177 -0
- umbermark/core/wording.py +371 -0
- umbermark_cli-1.0.0.dist-info/METADATA +11 -0
- umbermark_cli-1.0.0.dist-info/RECORD +96 -0
- umbermark_cli-1.0.0.dist-info/WHEEL +5 -0
- umbermark_cli-1.0.0.dist-info/entry_points.txt +2 -0
- umbermark_cli-1.0.0.dist-info/top_level.txt +1 -0
umbermark/core/access.py
ADDED
|
@@ -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
|
+
|