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
|
@@ -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
|