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,400 @@
1
+ """SocketProbe — the real outside-in network probe.
2
+
3
+ The concrete `ExternalProbe`: given a hostname/IP it answers the same questions
4
+ `FakeProbe` does, but over real sockets. This is the only Tier-1 component that
5
+ touches the network. Every method is best-effort and timeout-bounded — any
6
+ failure (host down, port filtered, handshake refused) returns a falsy value so
7
+ the checks SKIP/PASS rather than crashing the scan. It probes only the public
8
+ surface and never authenticates.
9
+
10
+ The socket/TLS I/O glue is intentionally thin and is not unit-tested against
11
+ live servers (mirrors ParamikoRunner). The deterministic logic that could hide
12
+ bugs — the SSH KEXINIT parser and the certificate-facts derivation — is factored
13
+ out as pure functions and tested directly.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import http.client
18
+ import socket
19
+ import ssl
20
+ import warnings
21
+ from datetime import datetime, timezone
22
+ from typing import Optional
23
+
24
+ from umbermark.core.external.ownership import dial_address
25
+ from umbermark.core.external.probe import SshAlgorithms, TlsInfo
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Pure logic (unit-tested)
29
+ # ---------------------------------------------------------------------------
30
+
31
+ _SSH_MSG_KEXINIT = 20
32
+
33
+
34
+ def _read_name_list(buf: bytes, off: int) -> tuple[list[str], int]:
35
+ """Decode one SSH name-list (uint32 length + comma-separated ASCII)."""
36
+ length = int.from_bytes(buf[off : off + 4], "big")
37
+ off += 4
38
+ raw = buf[off : off + length].decode("ascii", errors="replace")
39
+ off += length
40
+ names = [n for n in raw.split(",") if n] if raw else []
41
+ return names, off
42
+
43
+
44
+ def parse_ssh_kexinit(payload: bytes) -> Optional[SshAlgorithms]:
45
+ """Parse an SSH KEXINIT message payload (RFC 4253 §7.1) into the algorithm
46
+ lists the server offers. Returns None if ``payload`` isn't a KEXINIT.
47
+
48
+ We surface the server->client encryption/MAC lists (what the server will use
49
+ to send us data) as the canonical ``ciphers``/``macs`` offer.
50
+ """
51
+ if not payload or payload[0] != _SSH_MSG_KEXINIT:
52
+ return None
53
+ try:
54
+ off = 1 + 16 # message type byte + 16-byte cookie
55
+ kex, off = _read_name_list(payload, off)
56
+ host_keys, off = _read_name_list(payload, off)
57
+ _enc_cs, off = _read_name_list(payload, off)
58
+ enc_sc, off = _read_name_list(payload, off)
59
+ _mac_cs, off = _read_name_list(payload, off)
60
+ mac_sc, off = _read_name_list(payload, off)
61
+ except Exception:
62
+ return None
63
+ return SshAlgorithms(kex=kex, ciphers=enc_sc, macs=mac_sc, host_keys=host_keys)
64
+
65
+
66
+ def _cert_facts(cert: dict, now: datetime) -> tuple[int, bool]:
67
+ """Derive (days_until_expiry, self_signed) from an ssl ``getpeercert``-style
68
+ dict. ``days_until_expiry`` is negative once the cert has expired; a cert
69
+ whose subject equals its issuer is treated as self-signed."""
70
+ not_after_raw = cert.get("notAfter", "")
71
+ not_after = datetime.strptime(not_after_raw, "%b %d %H:%M:%S %Y %Z").replace(
72
+ tzinfo=timezone.utc
73
+ )
74
+ days = (not_after - now).days
75
+ subject = cert.get("subject")
76
+ issuer = cert.get("issuer")
77
+ self_signed = subject is not None and subject == issuer
78
+ return days, self_signed
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # I/O helper
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ class _SockReader:
87
+ """Buffered reader so banner bytes and the following binary packet can be
88
+ read from the same stream without losing pipelined data."""
89
+
90
+ def __init__(self, sock: socket.socket) -> None:
91
+ self._sock = sock
92
+ self._buf = b""
93
+
94
+ def read_exact(self, n: int) -> Optional[bytes]:
95
+ while len(self._buf) < n:
96
+ chunk = self._sock.recv(4096)
97
+ if not chunk:
98
+ return None
99
+ self._buf += chunk
100
+ out, self._buf = self._buf[:n], self._buf[n:]
101
+ return out
102
+
103
+ def read_line(self, limit: int = 8192) -> Optional[bytes]:
104
+ while b"\n" not in self._buf:
105
+ if len(self._buf) > limit:
106
+ return None
107
+ chunk = self._sock.recv(4096)
108
+ if not chunk:
109
+ return None
110
+ self._buf += chunk
111
+ line, self._buf = self._buf.split(b"\n", 1)
112
+ return line + b"\n"
113
+
114
+
115
+ def _der_to_cert_dict(der: bytes) -> Optional[dict]:
116
+ """Build an ssl-style cert dict from DER using cryptography (a hard dep via
117
+ paramiko). Returns None if the cert can't be parsed."""
118
+ try:
119
+ from cryptography import x509
120
+
121
+ cert = x509.load_der_x509_certificate(der)
122
+ try:
123
+ not_after = cert.not_valid_after_utc
124
+ except AttributeError: # cryptography < 42
125
+ not_after = cert.not_valid_after.replace(tzinfo=timezone.utc)
126
+ subject = cert.subject.rfc4514_string()
127
+ issuer = cert.issuer.rfc4514_string()
128
+ return {
129
+ "notAfter": not_after.strftime("%b %d %H:%M:%S %Y GMT"),
130
+ "subject": subject,
131
+ "issuer": issuer,
132
+ }
133
+ except Exception:
134
+ return None
135
+
136
+
137
+ # Legacy/modern TLS versions to probe, newest first. Probing the deprecated
138
+ # versions is the whole point of the check, so silence the stdlib deprecation
139
+ # warnings their enum members emit (Python 3.12+).
140
+ with warnings.catch_warnings():
141
+ warnings.simplefilter("ignore", DeprecationWarning)
142
+ _TLS_VERSIONS = [
143
+ ("TLSv1.3", ssl.TLSVersion.TLSv1_3),
144
+ ("TLSv1.2", ssl.TLSVersion.TLSv1_2),
145
+ ("TLSv1.1", ssl.TLSVersion.TLSv1_1),
146
+ ("TLSv1", ssl.TLSVersion.TLSv1),
147
+ ]
148
+
149
+
150
+ # TLS 1.0/1.1 handshakes need SHA-1/MD5 signatures, which OpenSSL 3 only allows
151
+ # at security level 0 -- at the default level it refuses to send the ClientHello.
152
+ _LEGACY_TLS = {"TLSv1.1", "TLSv1"}
153
+ _LEGACY_CIPHERS = "ALL:@SECLEVEL=0"
154
+
155
+
156
+ def _probe_context(name: str, version: ssl.TLSVersion) -> Optional[ssl.SSLContext]:
157
+ """A non-verifying client context pinned to exactly ``version``, or None if
158
+ this ssl build cannot pin it. Legacy versions get security level 0 so a
159
+ modern OpenSSL can still *ask* the server whether it accepts them."""
160
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
161
+ ctx.check_hostname = False
162
+ ctx.verify_mode = ssl.CERT_NONE
163
+ try:
164
+ with warnings.catch_warnings():
165
+ warnings.simplefilter("ignore", DeprecationWarning)
166
+ ctx.minimum_version = version
167
+ ctx.maximum_version = version
168
+ except (ValueError, OSError):
169
+ return None
170
+ if name in _LEGACY_TLS:
171
+ try:
172
+ ctx.set_ciphers(_LEGACY_CIPHERS)
173
+ except ssl.SSLError:
174
+ pass # _client_can_offer() reports whether it still works
175
+ return ctx
176
+
177
+
178
+ def _client_can_offer(ctx: ssl.SSLContext) -> bool:
179
+ """True iff the local TLS stack can emit a ClientHello for ``ctx``.
180
+
181
+ Runs the first handshake step in memory, before any connection: a stack
182
+ that will not offer the pinned version (build flags, security level, crypto
183
+ policy) raises NO_PROTOCOLS_AVAILABLE here, whereas a usable context writes
184
+ its ClientHello and waits for the server (SSLWantReadError).
185
+ """
186
+ try:
187
+ obj = ctx.wrap_bio(ssl.MemoryBIO(), ssl.MemoryBIO())
188
+ obj.do_handshake()
189
+ except ssl.SSLWantReadError:
190
+ return True
191
+ except Exception:
192
+ return False
193
+ return True
194
+
195
+
196
+ def _trust_context() -> ssl.SSLContext:
197
+ """A verifying client context over the Mozilla CA bundle (certifi, pulled in
198
+ by httpx), falling back to the system store. Hostname matching is off: the
199
+ question here is "does the chain lead to a public CA" (UMBR-40); targets are
200
+ often bare IPs, which no public certificate names."""
201
+ try:
202
+ import certifi
203
+
204
+ ctx = ssl.create_default_context(cafile=certifi.where())
205
+ except Exception:
206
+ ctx = ssl.create_default_context()
207
+ ctx.check_hostname = False
208
+ # Python 3.13+ turns on VERIFY_X509_STRICT, which rejects some chains OpenSSL and browsers
209
+ # accept (non-critical CA basicConstraints, missing key identifiers). The question here is
210
+ # only "does this lead to a public CA", so judge it the way clients do.
211
+ ctx.verify_flags &= ~ssl.VERIFY_X509_STRICT
212
+ return ctx
213
+
214
+
215
+ def _utcnow() -> datetime:
216
+ return datetime.now(timezone.utc)
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # SocketProbe
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ class SocketProbe:
225
+ """Real ExternalProbe over sockets. ``host`` is a hostname or IP."""
226
+
227
+ def __init__(self, host: str, default_timeout: float = 3.0) -> None:
228
+ self.host = host
229
+ self.default_timeout = default_timeout
230
+ self._ips: list[str] = []
231
+
232
+ def pin(self, ips: list[str]) -> None:
233
+ """Connect only to these pre-vetted IPs; ``host`` stays the SNI/Host name.
234
+
235
+ Once pinned the probe never re-resolves the hostname (UMBR-26: the
236
+ worker vets the addresses once, so a flipped A record cannot redirect
237
+ probes to loopback / private / metadata / third-party addresses).
238
+ """
239
+ # IPv4 first: a worker without IPv6 egress would otherwise fail on a
240
+ # dual-stack host's v6 address and read every port as closed.
241
+ self._ips = sorted(ips, key=lambda ip: (":" in ip, ip))
242
+
243
+ def _connect(self, port: int, timeout: float) -> socket.socket:
244
+ if not self._ips:
245
+ return socket.create_connection((self.host, port), timeout=timeout)
246
+ last: Exception = OSError("no pinned addresses")
247
+ for ip in self._ips: # first successful connection wins
248
+ try:
249
+ # dial_address: identity, except the scanner's own host (UMBR-65).
250
+ return socket.create_connection(
251
+ (dial_address(ip, port), port), timeout=timeout
252
+ )
253
+ except OSError as exc:
254
+ last = exc
255
+ raise last
256
+
257
+ def tcp_connect(self, port: int, timeout: float = 3.0) -> bool:
258
+ try:
259
+ with self._connect(port, timeout):
260
+ return True
261
+ except Exception:
262
+ return False
263
+
264
+ def ssh_algorithms(
265
+ self, port: int = 22, timeout: float = 3.0
266
+ ) -> Optional[SshAlgorithms]:
267
+ try:
268
+ with self._connect(port, timeout) as s:
269
+ s.settimeout(timeout)
270
+ reader = _SockReader(s)
271
+ # Read lines until the server's "SSH-..." identification string.
272
+ banner = None
273
+ for _ in range(10):
274
+ line = reader.read_line()
275
+ if line is None:
276
+ return None
277
+ if line.startswith(b"SSH-"):
278
+ banner = line
279
+ break
280
+ if banner is None:
281
+ return None
282
+ s.sendall(b"SSH-2.0-Umbermark-Probe\r\n")
283
+ header = reader.read_exact(4)
284
+ if header is None:
285
+ return None
286
+ packet_len = int.from_bytes(header, "big")
287
+ if packet_len < 2 or packet_len > 100_000:
288
+ return None
289
+ body = reader.read_exact(packet_len)
290
+ if body is None:
291
+ return None
292
+ padding_len = body[0]
293
+ payload = body[1 : 1 + (packet_len - padding_len - 1)]
294
+ return parse_ssh_kexinit(payload)
295
+ except Exception:
296
+ return None
297
+
298
+ def tls_info(self, port: int = 443, timeout: float = 3.0) -> Optional[TlsInfo]:
299
+ protocols: list[str] = []
300
+ unprobed: list[str] = []
301
+ cert_dict: Optional[dict] = None
302
+ for name, version in _TLS_VERSIONS:
303
+ ctx = _probe_context(name, version)
304
+ if ctx is None or not _client_can_offer(ctx):
305
+ # Our TLS stack cannot even emit this ClientHello: the server's
306
+ # answer is unknown, which is NOT the same as "not offered".
307
+ unprobed.append(name)
308
+ continue
309
+ try:
310
+ raw = self._connect(port, timeout)
311
+ except Exception:
312
+ # Could not even reach the port for this attempt (rate limit,
313
+ # fail2ban, transient loss): the server was never asked.
314
+ unprobed.append(name)
315
+ continue
316
+ try:
317
+ with raw, ctx.wrap_socket(raw, server_hostname=self.host) as ss:
318
+ protocols.append(name)
319
+ if cert_dict is None:
320
+ der = ss.getpeercert(binary_form=True)
321
+ if der:
322
+ cert_dict = _der_to_cert_dict(der)
323
+ except Exception:
324
+ continue # handshake refused / alert / reset: not offered
325
+ if not protocols:
326
+ return None
327
+ chain_trusted, trust_error = self._verify_chain(port, timeout)
328
+ # An unreadable leaf is reported as such (UMBR-35) -- never defaulted to
329
+ # "valid for years, CA-issued", which the hygiene check would PASS.
330
+ days, self_signed, cert_readable = 0, False, False
331
+ if cert_dict is not None:
332
+ try:
333
+ days, self_signed = _cert_facts(cert_dict, _utcnow())
334
+ cert_readable = True
335
+ except Exception:
336
+ days, self_signed = 0, False
337
+ return TlsInfo(
338
+ protocols=protocols, days_until_expiry=days, self_signed=self_signed,
339
+ unprobed=unprobed, cert_readable=cert_readable,
340
+ chain_trusted=chain_trusted, trust_error=trust_error,
341
+ )
342
+
343
+ def _verify_chain(self, port: int, timeout: float) -> tuple[Optional[bool], str]:
344
+ """One verifying handshake (UMBR-40). The protocol probes above use
345
+ CERT_NONE, so they cannot tell a public-CA chain from a self-signed or
346
+ private-CA one (e.g. mod_ssl's sscg localhost.crt, whose issuer is a
347
+ throwaway local CA, so issuer != subject). Returns (True, "") when the
348
+ chain verifies, (False, reason) when verification fails, and (None, "")
349
+ when the handshake could not complete for any other reason."""
350
+ try:
351
+ raw = self._connect(port, timeout)
352
+ except Exception:
353
+ return None, ""
354
+ try:
355
+ with raw, _trust_context().wrap_socket(raw, server_hostname=self.host):
356
+ return True, ""
357
+ except ssl.SSLCertVerificationError as exc:
358
+ return False, str(exc.verify_message or exc.reason or exc)
359
+ except Exception:
360
+ return None, ""
361
+
362
+ def http_headers(
363
+ self, port: int = 443, tls: bool = True, timeout: float = 3.0
364
+ ) -> Optional[dict]:
365
+ conn = None
366
+ try:
367
+ if tls:
368
+ conn = http.client.HTTPSConnection(
369
+ self.host,
370
+ port,
371
+ timeout=timeout,
372
+ context=ssl._create_unverified_context(),
373
+ )
374
+ else:
375
+ conn = http.client.HTTPConnection(self.host, port, timeout=timeout)
376
+ if self._ips:
377
+ # Pre-connect to the vetted IP so http.client never re-resolves.
378
+ raw = self._connect(port, timeout)
379
+ if tls:
380
+ raw = ssl._create_unverified_context().wrap_socket(
381
+ raw, server_hostname=self.host
382
+ )
383
+ conn.sock = raw
384
+ conn.request("HEAD", "/", headers={"User-Agent": "Umbermark-Probe/1.0"})
385
+ resp = conn.getresponse()
386
+ # A repeated header (app + proxy) is folded into one comma-joined
387
+ # value, as RFC 9110 allows, so none of the copies are dropped.
388
+ out: dict = {}
389
+ for k, v in resp.getheaders():
390
+ k = k.lower()
391
+ out[k] = f"{out[k]}, {v}" if k in out else v
392
+ return out
393
+ except Exception:
394
+ return None
395
+ finally:
396
+ if conn is not None:
397
+ try:
398
+ conn.close()
399
+ except Exception:
400
+ pass