opskit 0.1.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.
opskit/tls/inspect.py ADDED
@@ -0,0 +1,273 @@
1
+ """Certificate parsing, RFC 6125 name matching, and validation-finding assembly.
2
+
3
+ Pure functions over :mod:`cryptography` certificate objects — no sockets, no printing —
4
+ so every rule here is unit-testable against generated certificates.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ from collections.abc import Sequence
11
+ from datetime import datetime, timezone
12
+
13
+ from cryptography import x509
14
+ from cryptography.hazmat.primitives import hashes
15
+ from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed448, ed25519, rsa
16
+
17
+ from opskit.tls.models import CertificateInfo, FindingCode, TlsTarget, ValidationFinding
18
+
19
+ # OpenSSL X509 verify-callback error codes we classify (see `man verify(1)`).
20
+ X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT = 2
21
+ X509_V_ERR_CERT_NOT_YET_VALID = 9
22
+ X509_V_ERR_CERT_HAS_EXPIRED = 10
23
+ X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT = 18
24
+ X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN = 19
25
+ X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY = 20
26
+ X509_V_ERR_UNABLE_TO_VERIFY_LEAF_SIGNATURE = 21
27
+
28
+ _LEGACY_PROTOCOLS = {"SSLv3", "TLSv1", "TLSv1.1"}
29
+
30
+
31
+ def parse_certificate(cert: x509.Certificate) -> CertificateInfo:
32
+ """Extract the reportable attributes (FR-011) from one certificate."""
33
+ dns_sans: list[str] = []
34
+ ip_sans: list[str] = []
35
+ try:
36
+ san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
37
+ dns_sans = list(san_ext.value.get_values_for_type(x509.DNSName))
38
+ ip_sans = [str(ip) for ip in san_ext.value.get_values_for_type(x509.IPAddress)]
39
+ except x509.ExtensionNotFound:
40
+ pass
41
+
42
+ not_before = cert.not_valid_before_utc
43
+ not_after = cert.not_valid_after_utc
44
+ now = datetime.now(timezone.utc)
45
+ days = (not_after - now).days
46
+
47
+ key = cert.public_key()
48
+ if isinstance(key, rsa.RSAPublicKey):
49
+ key_type, key_bits = "RSA", key.key_size
50
+ elif isinstance(key, ec.EllipticCurvePublicKey):
51
+ key_type, key_bits = "EC", key.curve.key_size
52
+ elif isinstance(key, ed25519.Ed25519PublicKey):
53
+ key_type, key_bits = "Ed25519", 256
54
+ elif isinstance(key, ed448.Ed448PublicKey):
55
+ key_type, key_bits = "Ed448", 456
56
+ elif isinstance(key, dsa.DSAPublicKey):
57
+ key_type, key_bits = "DSA", key.key_size
58
+ else: # pragma: no cover - exotic key types
59
+ key_type, key_bits = type(key).__name__, 0
60
+
61
+ oid = cert.signature_algorithm_oid
62
+ signature_algorithm = getattr(oid, "_name", None) or oid.dotted_string
63
+
64
+ return CertificateInfo(
65
+ subject=cert.subject.rfc4514_string(),
66
+ issuer=cert.issuer.rfc4514_string(),
67
+ sans=tuple(
68
+ [f"dns:{name}" for name in dns_sans] + [f"ip:{addr}" for addr in ip_sans]
69
+ ),
70
+ not_before=not_before.isoformat(),
71
+ not_after=not_after.isoformat(),
72
+ days_until_expiry=days,
73
+ serial=format(cert.serial_number, "x").upper(),
74
+ signature_algorithm=signature_algorithm,
75
+ key_type=key_type,
76
+ key_bits=key_bits,
77
+ fingerprint_sha256=cert.fingerprint(hashes.SHA256()).hex(),
78
+ is_self_signed=cert.subject == cert.issuer,
79
+ )
80
+
81
+
82
+ def effective_name(target: TlsTarget) -> str:
83
+ """The identity validation checks: the SNI override when given, else the host."""
84
+ return target.server_name or target.host
85
+
86
+
87
+ def match_hostname(target: TlsTarget, cert: x509.Certificate) -> bool:
88
+ """RFC 6125 name matching: exact DNS-SAN, single left-most wildcard label, IP SANs.
89
+
90
+ Validation targets :func:`effective_name` — an explicit ``--sni`` names the vhost
91
+ identity being verified. No CN fallback: a certificate without a matching SAN does
92
+ not cover the target.
93
+ """
94
+ try:
95
+ san_ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
96
+ except x509.ExtensionNotFound:
97
+ return False
98
+ name = effective_name(target)
99
+ try:
100
+ wanted = ipaddress.ip_address(name)
101
+ except ValueError:
102
+ pass
103
+ else:
104
+ return any(
105
+ ip == wanted for ip in san_ext.value.get_values_for_type(x509.IPAddress)
106
+ )
107
+ host = name.lower()
108
+ return any(
109
+ _dns_name_matches(pattern.lower(), host)
110
+ for pattern in san_ext.value.get_values_for_type(x509.DNSName)
111
+ )
112
+
113
+
114
+ def _dns_name_matches(pattern: str, host: str) -> bool:
115
+ pattern = pattern.rstrip(".")
116
+ host = host.rstrip(".")
117
+ if pattern == host:
118
+ return True
119
+ if not pattern.startswith("*."):
120
+ return False
121
+ pattern_labels = pattern.split(".")
122
+ host_labels = host.split(".")
123
+ # The wildcard covers exactly one left-most label; never the bare domain.
124
+ if len(pattern_labels) != len(host_labels):
125
+ return False
126
+ return pattern_labels[1:] == host_labels[1:]
127
+
128
+
129
+ def _cert_sans_summary(leaf: CertificateInfo) -> str:
130
+ return ", ".join(leaf.sans) if leaf.sans else "(none)"
131
+
132
+
133
+ def _classify_verify_error(
134
+ errno: int, depth: int, leaf: CertificateInfo, chain_length: int
135
+ ) -> tuple[FindingCode, str, str | None]:
136
+ """Map one OpenSSL verify error (errno, depth) to a finding triple."""
137
+ where = "certificate" if depth == 0 else f"chain certificate (depth {depth})"
138
+ ca_hint = "pass the issuing root via --ca-file if this is a private PKI"
139
+ finding: tuple[FindingCode, str, str | None]
140
+ if errno == X509_V_ERR_CERT_HAS_EXPIRED:
141
+ message = (
142
+ f"{where} expired on {leaf.not_after}"
143
+ if depth == 0
144
+ else f"{where} has expired"
145
+ )
146
+ finding = (FindingCode.EXPIRED, message, "renew the certificate")
147
+ elif errno == X509_V_ERR_CERT_NOT_YET_VALID:
148
+ message = (
149
+ f"{where} is not valid before {leaf.not_before}"
150
+ if depth == 0
151
+ else f"{where} is not yet valid"
152
+ )
153
+ finding = (
154
+ FindingCode.NOT_YET_VALID,
155
+ message,
156
+ "check the server clock and the certificate's validity window",
157
+ )
158
+ elif errno == X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT:
159
+ finding = (
160
+ FindingCode.SELF_SIGNED,
161
+ "certificate is self-signed",
162
+ "add it to a private CA bundle and pass --ca-file to trust it",
163
+ )
164
+ elif errno == X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN:
165
+ finding = (
166
+ FindingCode.UNTRUSTED_CHAIN,
167
+ "chain ends in a root that is not in the trust store",
168
+ ca_hint,
169
+ )
170
+ elif errno in (
171
+ X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT,
172
+ X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY,
173
+ X509_V_ERR_UNABLE_TO_VERIFY_LEAF_SIGNATURE,
174
+ ):
175
+ if chain_length <= 1 and not leaf.is_self_signed:
176
+ finding = (
177
+ FindingCode.INCOMPLETE_CHAIN,
178
+ "server did not present the intermediate certificate(s)",
179
+ "configure the server to serve the full chain",
180
+ )
181
+ else:
182
+ finding = (
183
+ FindingCode.UNTRUSTED_CHAIN,
184
+ "certificate does not chain to a trusted authority",
185
+ ca_hint,
186
+ )
187
+ else:
188
+ finding = (
189
+ FindingCode.UNTRUSTED_CHAIN,
190
+ f"chain validation failed (OpenSSL verify error {errno} at depth {depth})",
191
+ None,
192
+ )
193
+ return finding
194
+
195
+
196
+ def build_findings(
197
+ target: TlsTarget,
198
+ leaf: CertificateInfo,
199
+ *,
200
+ name_matched: bool,
201
+ verify_errors: Sequence[tuple[int, int]],
202
+ chain_length: int,
203
+ tls_version: str | None,
204
+ warn_days: int,
205
+ ) -> tuple[ValidationFinding, ...]:
206
+ """Turn the verify-callback errors + parsed leaf + match result into findings.
207
+
208
+ Args:
209
+ target: What was checked (drives the name-mismatch message).
210
+ leaf: Parsed leaf certificate.
211
+ name_matched: Result of :func:`match_hostname`.
212
+ verify_errors: (errno, depth) pairs recorded during the handshake.
213
+ chain_length: Number of certificates the server presented.
214
+ tls_version: Negotiated protocol name.
215
+ warn_days: Expiring-soon threshold (0 disables).
216
+ """
217
+ findings: list[ValidationFinding] = []
218
+ seen: set[FindingCode] = set()
219
+
220
+ def add(code: FindingCode, message: str, hint: str | None = None) -> None:
221
+ if code not in seen:
222
+ seen.add(code)
223
+ findings.append(ValidationFinding(code=code, message=message, hint=hint))
224
+
225
+ for errno, depth in verify_errors:
226
+ add(*_classify_verify_error(errno, depth, leaf, chain_length))
227
+
228
+ if not name_matched:
229
+ if not leaf.sans:
230
+ add(
231
+ FindingCode.NO_SANS,
232
+ "certificate has no subject alternative names "
233
+ "(legacy CN-only certificates fail modern validation)",
234
+ hint="reissue the certificate with SANs",
235
+ )
236
+ name = effective_name(target)
237
+ mode = "IP address" if target.is_ip and name == target.host else "name"
238
+ add(
239
+ FindingCode.NAME_MISMATCH,
240
+ f"requested {mode} {name!r} is not covered; "
241
+ f"certificate covers: {_cert_sans_summary(leaf)}",
242
+ hint="check you are hitting the intended vhost (see --sni)",
243
+ )
244
+
245
+ if tls_version in _LEGACY_PROTOCOLS:
246
+ add(
247
+ FindingCode.LEGACY_PROTOCOL,
248
+ f"negotiated {tls_version} is below TLS 1.2",
249
+ hint="enable TLS 1.2+ on the server",
250
+ )
251
+
252
+ invalid = {
253
+ FindingCode.EXPIRED,
254
+ FindingCode.NOT_YET_VALID,
255
+ FindingCode.NAME_MISMATCH,
256
+ FindingCode.SELF_SIGNED,
257
+ FindingCode.UNTRUSTED_CHAIN,
258
+ FindingCode.INCOMPLETE_CHAIN,
259
+ FindingCode.NO_SANS,
260
+ }
261
+ if (
262
+ warn_days > 0
263
+ and 0 <= leaf.days_until_expiry <= warn_days
264
+ and not (seen & invalid)
265
+ ):
266
+ add(
267
+ FindingCode.EXPIRING_SOON,
268
+ f"certificate expires in {leaf.days_until_expiry} day(s) "
269
+ f"(threshold {warn_days})",
270
+ hint="schedule renewal",
271
+ )
272
+
273
+ return tuple(findings)
opskit/tls/models.py ADDED
@@ -0,0 +1,240 @@
1
+ """Typed data model for TLS verification diagnostics.
2
+
3
+ Frozen stdlib dataclasses (no Pydantic) with ``to_dict()`` for the JSON envelope.
4
+ See specs/002-tls-verification/data-model.md.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Any
13
+
14
+ from opskit.core.errors import UsageError
15
+
16
+ DEFAULT_PORT = 443
17
+ _MAX_PORT = 65535
18
+
19
+
20
+ class TlsOutcome(str, Enum):
21
+ """Overall verdict class — the first failing layer wins (spec US3)."""
22
+
23
+ OK = "ok"
24
+ EXPIRING_SOON = "expiring_soon"
25
+ RESOLVE_FAILED = "resolve_failed"
26
+ CONNECT_REFUSED = "connect_refused"
27
+ CONNECT_TIMEOUT = "connect_timeout"
28
+ HANDSHAKE_FAILED = "handshake_failed"
29
+ CERT_INVALID = "cert_invalid"
30
+ USAGE_ERROR = "usage_error"
31
+
32
+
33
+ class FindingCode(str, Enum):
34
+ """One distinct validation condition (FR-007/009/010)."""
35
+
36
+ EXPIRED = "expired"
37
+ NOT_YET_VALID = "not_yet_valid"
38
+ NAME_MISMATCH = "name_mismatch"
39
+ SELF_SIGNED = "self_signed"
40
+ UNTRUSTED_CHAIN = "untrusted_chain"
41
+ INCOMPLETE_CHAIN = "incomplete_chain"
42
+ NO_SANS = "no_sans"
43
+ EXPIRING_SOON = "expiring_soon"
44
+ LEGACY_PROTOCOL = "legacy_protocol"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class TlsTarget:
49
+ """What the user asked to check (host, port, effective SNI)."""
50
+
51
+ host: str
52
+ port: int
53
+ server_name: str | None # SNI actually sent; None for IP targets
54
+ is_ip: bool
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ """Return a JSON-serializable mapping of this target."""
58
+ return {
59
+ "host": self.host,
60
+ "port": self.port,
61
+ "server_name": self.server_name,
62
+ "is_ip": self.is_ip,
63
+ }
64
+
65
+
66
+ def parse_target(
67
+ raw: str,
68
+ *,
69
+ port: int | None = None,
70
+ server_name: str | None = None,
71
+ ) -> TlsTarget:
72
+ """Parse ``host``, ``host:port``, IP literals, or ``[v6]:port`` into a TlsTarget.
73
+
74
+ Args:
75
+ raw: The user-supplied target string.
76
+ port: Explicit ``--port`` value; must agree with any shorthand port.
77
+ server_name: Explicit SNI override (``--sni``).
78
+
79
+ Raises:
80
+ UsageError: For empty targets, invalid ports, or a shorthand/option conflict.
81
+ """
82
+ text = raw.strip()
83
+ if not text:
84
+ raise UsageError("a target host is required")
85
+
86
+ host, shorthand_port = _split_host_port(text, raw)
87
+ host = host.strip().rstrip(".") # normalize trailing-dot hostnames
88
+ if not host:
89
+ raise UsageError(f"invalid target (empty host): {raw}")
90
+
91
+ if port is not None and shorthand_port is not None and port != shorthand_port:
92
+ raise UsageError(
93
+ f"conflicting ports: --port {port} vs target '{raw}'",
94
+ hint="give the port once (either --port or host:port)",
95
+ )
96
+ effective_port = shorthand_port if shorthand_port is not None else port
97
+ if effective_port is None:
98
+ effective_port = DEFAULT_PORT
99
+ elif not 1 <= effective_port <= _MAX_PORT:
100
+ raise UsageError(f"port must be between 1 and {_MAX_PORT}: {effective_port}")
101
+ is_ip = _is_ip_literal(host)
102
+
103
+ if server_name:
104
+ effective_sni: str | None = server_name
105
+ elif is_ip:
106
+ effective_sni = None # SNI does not apply to IP targets
107
+ else:
108
+ effective_sni = host
109
+ return TlsTarget(
110
+ host=host, port=effective_port, server_name=effective_sni, is_ip=is_ip
111
+ )
112
+
113
+
114
+ def _split_host_port(text: str, raw: str) -> tuple[str, int | None]:
115
+ """Split a target into (host, shorthand-port); handles `[v6]:port` and bare IPv6."""
116
+ if text.startswith("["): # [v6]:port or [v6]
117
+ closing = text.find("]")
118
+ if closing < 0:
119
+ raise UsageError(f"invalid target (unclosed '['): {raw}")
120
+ rest = text[closing + 1 :]
121
+ if rest.startswith(":"):
122
+ return text[1:closing], _parse_port(rest[1:], raw)
123
+ if rest:
124
+ raise UsageError(f"invalid target: {raw}")
125
+ return text[1:closing], None
126
+ if text.count(":") == 1: # host:port (a single colon cannot be bare IPv6)
127
+ host, _, port_text = text.partition(":")
128
+ return host, _parse_port(port_text, raw)
129
+ if text.count(":") > 1 and not _is_ip_literal(text):
130
+ # Multiple colons are only valid as a bare IPv6 literal; anything else is ambiguous.
131
+ raise UsageError(
132
+ f"invalid target: {raw}",
133
+ hint="use [ipv6]:port to add a port to an IPv6 address",
134
+ )
135
+ return text, None # bare hostname, IPv4, or bare IPv6 literal
136
+
137
+
138
+ def _is_ip_literal(host: str) -> bool:
139
+ try:
140
+ ipaddress.ip_address(host)
141
+ except ValueError:
142
+ return False
143
+ return True
144
+
145
+
146
+ def _parse_port(text: str, raw: str) -> int:
147
+ try:
148
+ value = int(text)
149
+ except ValueError as exc:
150
+ raise UsageError(f"invalid port in target: {raw}") from exc
151
+ if not 1 <= value <= _MAX_PORT:
152
+ raise UsageError(f"port must be between 1 and {_MAX_PORT}: {raw}")
153
+ return value
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class CertificateInfo:
158
+ """Descriptive attributes of one certificate plus derived facts."""
159
+
160
+ subject: str
161
+ issuer: str
162
+ sans: tuple[str, ...] # "dns:<name>" / "ip:<addr>"
163
+ not_before: str # ISO 8601 UTC
164
+ not_after: str # ISO 8601 UTC
165
+ days_until_expiry: int # negative when expired
166
+ serial: str # hex
167
+ signature_algorithm: str
168
+ key_type: str
169
+ key_bits: int
170
+ fingerprint_sha256: str
171
+ is_self_signed: bool
172
+
173
+ def to_dict(self) -> dict[str, Any]:
174
+ """Return a JSON-serializable mapping of this certificate."""
175
+ return {
176
+ "subject": self.subject,
177
+ "issuer": self.issuer,
178
+ "sans": list(self.sans),
179
+ "not_before": self.not_before,
180
+ "not_after": self.not_after,
181
+ "days_until_expiry": self.days_until_expiry,
182
+ "serial": self.serial,
183
+ "signature_algorithm": self.signature_algorithm,
184
+ "key_type": self.key_type,
185
+ "key_bits": self.key_bits,
186
+ "fingerprint_sha256": self.fingerprint_sha256,
187
+ "is_self_signed": self.is_self_signed,
188
+ }
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class ValidationFinding:
193
+ """One failed or warned validation condition, with explanation and hint."""
194
+
195
+ code: FindingCode
196
+ message: str
197
+ hint: str | None = None
198
+
199
+ def to_dict(self) -> dict[str, Any]:
200
+ """Return a JSON-serializable mapping of this finding."""
201
+ return {"code": self.code.value, "message": self.message, "hint": self.hint}
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class TlsCheckResult:
206
+ """The layered outcome of one TLS check (see data-model.md)."""
207
+
208
+ target: TlsTarget
209
+ outcome: TlsOutcome
210
+ connection: Any | None = (
211
+ None # opskit.net.TcpConnection (kept loose to avoid cycle)
212
+ )
213
+ tls_version: str | None = None
214
+ cipher: str | None = None
215
+ leaf: CertificateInfo | None = None
216
+ chain: tuple[CertificateInfo, ...] = ()
217
+ findings: tuple[ValidationFinding, ...] = field(default=())
218
+ elapsed_ms: float = 0.0
219
+
220
+ @property
221
+ def ok(self) -> bool:
222
+ """True when the check passed cleanly."""
223
+ return self.outcome is TlsOutcome.OK
224
+
225
+ def __bool__(self) -> bool:
226
+ """Truthiness mirrors :attr:`ok`."""
227
+ return self.ok
228
+
229
+ def to_dict(self) -> dict[str, Any]:
230
+ """Return a JSON-serializable mapping matching the CLI envelope's result."""
231
+ return {
232
+ "outcome": self.outcome.value,
233
+ "connection": self.connection.to_dict() if self.connection else None,
234
+ "tls_version": self.tls_version,
235
+ "cipher": self.cipher,
236
+ "leaf": self.leaf.to_dict() if self.leaf else None,
237
+ "chain": [cert.to_dict() for cert in self.chain],
238
+ "findings": [finding.to_dict() for finding in self.findings],
239
+ "elapsed_ms": round(self.elapsed_ms, 3),
240
+ }
opskit/tls/output.py ADDED
@@ -0,0 +1,85 @@
1
+ """Rendering of TLS check results to human-readable (rich) tables.
2
+
3
+ Category-owned so :mod:`opskit.core` stays free of TLS models. Certificate- and
4
+ server-derived strings are escaped as rich markup before printing.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from rich.console import Console
10
+ from rich.markup import escape
11
+ from rich.table import Table
12
+
13
+ from opskit.tls.models import TlsCheckResult, TlsOutcome
14
+
15
+ _VERDICT_STYLE = {
16
+ TlsOutcome.OK: "[green]OK[/green]",
17
+ TlsOutcome.EXPIRING_SOON: "[yellow]EXPIRING SOON[/yellow]",
18
+ TlsOutcome.CERT_INVALID: "[red]CERTIFICATE INVALID[/red]",
19
+ }
20
+
21
+
22
+ def render_check(result: TlsCheckResult, *, console: Console) -> None:
23
+ """Print the full layered report: verdict, leaf, chain, protocol, findings."""
24
+ verdict = _VERDICT_STYLE.get(result.outcome, result.outcome.value)
25
+ target = result.target
26
+ console.print(
27
+ f"{verdict} {escape(target.host)}:{target.port}"
28
+ + (
29
+ f" (sni: {escape(target.server_name)})"
30
+ if target.server_name and target.server_name != target.host
31
+ else ""
32
+ )
33
+ )
34
+ if result.connection is not None:
35
+ console.print(
36
+ f"[dim]connected to {escape(result.connection.address)} "
37
+ f"({result.connection.family}) in {result.connection.connect_ms:.0f} ms[/dim]"
38
+ )
39
+ if target.is_ip:
40
+ console.print("[dim]IP target: no SNI sent; matched against IP SANs[/dim]")
41
+
42
+ leaf = result.leaf
43
+ if leaf is not None:
44
+ table = Table(show_header=False, box=None, pad_edge=False)
45
+ table.add_column("field", style="bold", no_wrap=True)
46
+ table.add_column("value")
47
+ table.add_row("subject", escape(leaf.subject))
48
+ table.add_row("issuer", escape(leaf.issuer))
49
+ table.add_row("SANs", escape(", ".join(leaf.sans) or "(none)"))
50
+ table.add_row(
51
+ "validity",
52
+ escape(f"{leaf.not_before} -> {leaf.not_after}")
53
+ + f" ({leaf.days_until_expiry} days remaining)",
54
+ )
55
+ table.add_row("serial", escape(leaf.serial))
56
+ table.add_row("signature", escape(leaf.signature_algorithm))
57
+ table.add_row("public key", f"{escape(leaf.key_type)} {leaf.key_bits} bits")
58
+ table.add_row("sha256", escape(leaf.fingerprint_sha256))
59
+ console.print(table)
60
+
61
+ if len(result.chain) > 1:
62
+ chain_table = Table(show_header=True, header_style="bold", title="Chain")
63
+ chain_table.add_column("#", justify="right")
64
+ chain_table.add_column("SUBJECT")
65
+ chain_table.add_column("ISSUER")
66
+ chain_table.add_column("EXPIRES")
67
+ for index, cert in enumerate(result.chain):
68
+ chain_table.add_row(
69
+ str(index),
70
+ escape(cert.subject),
71
+ escape(cert.issuer),
72
+ escape(cert.not_after),
73
+ )
74
+ console.print(chain_table)
75
+
76
+ if result.tls_version or result.cipher:
77
+ console.print(
78
+ f"protocol: [bold]{escape(result.tls_version or '?')}[/bold]"
79
+ f" cipher: {escape(result.cipher or '?')}"
80
+ )
81
+
82
+ for finding in result.findings:
83
+ console.print(f"[red]![/red] {finding.code.value}: {escape(finding.message)}")
84
+ if finding.hint:
85
+ console.print(f" [dim]hint: {escape(finding.hint)}[/dim]")