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/cli.py ADDED
@@ -0,0 +1,267 @@
1
+ """Thin Typer sub-app for TLS verification: parse args, delegate to the API, render.
2
+
3
+ Holds no business logic — it maps options onto :mod:`opskit.tls.api` and turns typed
4
+ results/exceptions into human or JSON output and structured exit codes. Supports bulk
5
+ targets via a positional argument and/or ``--input-file`` (one ``host[:port]`` per line).
6
+
7
+ .. note::
8
+ This module intentionally does **not** use ``from __future__ import annotations``. Typer
9
+ reads the ``Annotated[...]`` metadata off the concrete annotation objects; deferring them
10
+ to strings (PEP 563) makes Typer silently drop the metadata on Python 3.9. Keep
11
+ annotations eager here and use ``Optional[...]``.
12
+ """
13
+
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Annotated, Any, Optional
17
+
18
+ import typer
19
+ from rich.markup import escape
20
+
21
+ from opskit.core.cliutils import (
22
+ ActionResult,
23
+ aggregate_exit,
24
+ collect_outcomes,
25
+ collect_targets,
26
+ emit_envelopes,
27
+ run_or_watch,
28
+ )
29
+ from opskit.core.errors import OpskitError, UsageError
30
+ from opskit.core.exit_codes import ExitCode, exit_code_for
31
+ from opskit.core.output import make_console
32
+ from opskit.core.result import build_envelope
33
+ from opskit.tls import api
34
+ from opskit.tls.models import TlsCheckResult, TlsOutcome, parse_target
35
+ from opskit.tls.output import render_check
36
+
37
+ app = typer.Typer(
38
+ name="tls",
39
+ help="TLS verification (certificates, chains, protocol).",
40
+ no_args_is_help=True,
41
+ )
42
+
43
+ _CHECK_EPILOG = """\
44
+ [bold]Examples[/bold]
45
+
46
+ opskit tls check example.com
47
+ opskit tls check example.com:8443
48
+ opskit tls check 192.0.2.10 -p 8443 --sni internal.example.com
49
+ opskit tls check ldap.corp.example:636 --ca-file corp-root.pem
50
+ opskit tls check example.com --warn-days 14
51
+ opskit tls check -i endpoints.txt --jsonl
52
+ opskit tls check example.com --watch 30s
53
+ """
54
+
55
+ _OUTCOME_EXIT = {
56
+ TlsOutcome.OK: ExitCode.OK,
57
+ TlsOutcome.EXPIRING_SOON: ExitCode.CERT_EXPIRING,
58
+ TlsOutcome.CERT_INVALID: ExitCode.CERT_INVALID,
59
+ }
60
+
61
+
62
+ def _result_exit(result: TlsCheckResult) -> ExitCode:
63
+ return _OUTCOME_EXIT.get(result.outcome, ExitCode.ERROR)
64
+
65
+
66
+ def _envelope(
67
+ target: str,
68
+ result: Optional[TlsCheckResult],
69
+ error: Optional[OpskitError],
70
+ *,
71
+ controls: "dict[str, Any]",
72
+ ) -> "dict[str, Any]":
73
+ """Build the JSON envelope for one target (success or failure — never dropped)."""
74
+ if result is not None:
75
+ query = dict(result.target.to_dict(), **controls)
76
+ return build_envelope(
77
+ command="tls.check",
78
+ query=query,
79
+ result=result.to_dict(),
80
+ error=None,
81
+ elapsed_ms=result.elapsed_ms,
82
+ )
83
+ # Failure path: enrich the query with the parsed host/port/SNI when the target parses,
84
+ # so a failed target's envelope still identifies the endpoint (not just the raw string).
85
+ try:
86
+ error_query: dict[str, Any] = parse_target(target).to_dict()
87
+ except UsageError:
88
+ error_query = {"target": target}
89
+ return build_envelope(
90
+ command="tls.check",
91
+ query=dict(error_query, **controls),
92
+ result=None,
93
+ error=error,
94
+ elapsed_ms=0.0,
95
+ )
96
+
97
+
98
+ def _signature(
99
+ outcomes: "list[tuple[str, Optional[TlsCheckResult], Optional[OpskitError]]]",
100
+ ) -> str:
101
+ """Change-detection key: outcome class + leaf fingerprint + expiry + protocol (R8)."""
102
+ parts: list[object] = []
103
+ for target, result, error in outcomes:
104
+ if result is not None:
105
+ leaf = result.leaf
106
+ parts.append(
107
+ [
108
+ target,
109
+ result.outcome.value,
110
+ leaf.fingerprint_sha256 if leaf else None,
111
+ leaf.not_after if leaf else None,
112
+ result.tls_version,
113
+ ]
114
+ )
115
+ else:
116
+ parts.append([target, error.code if error else "error"])
117
+ return json.dumps(parts, sort_keys=True)
118
+
119
+
120
+ @app.command(epilog=_CHECK_EPILOG)
121
+ def check(
122
+ target: Annotated[
123
+ Optional[str],
124
+ typer.Argument(
125
+ help=r"host, host:port, IP, or \[ipv6]:port (or use --input-file)."
126
+ ),
127
+ ] = None,
128
+ port: Annotated[
129
+ Optional[int],
130
+ typer.Option(
131
+ "--port",
132
+ "-p",
133
+ help="Port to check (default 443; must agree with host:port shorthand).",
134
+ rich_help_panel="Query controls",
135
+ ),
136
+ ] = None,
137
+ sni: Annotated[
138
+ Optional[str],
139
+ typer.Option(
140
+ "--sni",
141
+ help="Server name to send (default: the hostname; omitted for IP targets).",
142
+ rich_help_panel="Query controls",
143
+ ),
144
+ ] = None,
145
+ ca_file: Annotated[
146
+ Optional[Path],
147
+ typer.Option(
148
+ "--ca-file",
149
+ help="PEM bundle replacing the platform trust store (private PKI).",
150
+ rich_help_panel="Query controls",
151
+ ),
152
+ ] = None,
153
+ warn_days: Annotated[
154
+ int,
155
+ typer.Option(
156
+ "--warn-days",
157
+ help="Warn when the certificate expires within N days (0 disables).",
158
+ rich_help_panel="Query controls",
159
+ ),
160
+ ] = 30,
161
+ timeout: Annotated[
162
+ float,
163
+ typer.Option(
164
+ "--timeout",
165
+ help="Per-attempt timeout, seconds.",
166
+ rich_help_panel="Query controls",
167
+ ),
168
+ ] = 5.0,
169
+ retries: Annotated[
170
+ int,
171
+ typer.Option(
172
+ "--retries", help="Retries on timeout.", rich_help_panel="Query controls"
173
+ ),
174
+ ] = 2,
175
+ input_file: Annotated[
176
+ Optional[Path],
177
+ typer.Option(
178
+ "--input-file",
179
+ "-i",
180
+ help="File of targets, one host[:port] per line (# comments allowed).",
181
+ rich_help_panel="Query",
182
+ ),
183
+ ] = None,
184
+ watch: Annotated[
185
+ Optional[str],
186
+ typer.Option(
187
+ "--watch",
188
+ help="Re-run every interval (e.g. 30s, 2m) until Ctrl-C.",
189
+ rich_help_panel="Modes",
190
+ ),
191
+ ] = None,
192
+ as_json: Annotated[
193
+ bool,
194
+ typer.Option(
195
+ "--json", help="Emit the versioned JSON envelope.", rich_help_panel="Output"
196
+ ),
197
+ ] = False,
198
+ jsonl: Annotated[
199
+ bool,
200
+ typer.Option(
201
+ "--jsonl",
202
+ help="Emit one JSON envelope per line (NDJSON).",
203
+ rich_help_panel="Output",
204
+ ),
205
+ ] = False,
206
+ no_color: Annotated[
207
+ bool,
208
+ typer.Option(
209
+ "--no-color", help="Disable colored output.", rich_help_panel="Output"
210
+ ),
211
+ ] = False,
212
+ ) -> None:
213
+ """Verify the TLS health of one or more endpoints (default port 443)."""
214
+ try:
215
+ targets = collect_targets(target, input_file)
216
+ except UsageError as error:
217
+ typer.echo(f"error: {error.message}", err=True)
218
+ raise typer.Exit(int(ExitCode.USAGE)) from error
219
+
220
+ controls: dict[str, Any] = {
221
+ "timeout": timeout,
222
+ "retries": retries,
223
+ "warn_days": warn_days,
224
+ }
225
+ if ca_file is not None:
226
+ controls["ca_file"] = str(ca_file)
227
+
228
+ def run_one(raw: str) -> TlsCheckResult:
229
+ return api.check(
230
+ raw,
231
+ port=port,
232
+ server_name=sni,
233
+ ca_file=ca_file,
234
+ warn_days=warn_days,
235
+ timeout=timeout,
236
+ retries=retries,
237
+ )
238
+
239
+ def action() -> ActionResult:
240
+ outcomes = collect_outcomes(targets, run_one)
241
+ if as_json or jsonl:
242
+ emit_envelopes(
243
+ [_envelope(t, r, e, controls=controls) for t, r, e in outcomes],
244
+ jsonl=jsonl,
245
+ )
246
+ else:
247
+ console = make_console(no_color=no_color)
248
+ batch = len(targets) > 1
249
+ for raw, result, error in outcomes:
250
+ if batch:
251
+ console.print(f"[bold];; {escape(raw)}[/bold]")
252
+ if result is not None:
253
+ render_check(result, console=console)
254
+ elif error is not None:
255
+ message = f"error: {raw}: {error.message}"
256
+ if error.hint:
257
+ message += f"\nhint: {error.hint}"
258
+ typer.echo(message, err=True)
259
+ codes: list[ExitCode] = []
260
+ for _, result, error in outcomes:
261
+ if result is not None:
262
+ codes.append(_result_exit(result))
263
+ elif error is not None:
264
+ codes.append(exit_code_for(error))
265
+ return aggregate_exit(codes), _signature(outcomes)
266
+
267
+ run_or_watch(action, watch_spec=watch, no_color=no_color)
opskit/tls/errors.py ADDED
@@ -0,0 +1,65 @@
1
+ """TLS-specific exception hierarchy (subclasses of :class:`opskit.core.errors.OpskitError`).
2
+
3
+ Connection-layer failures come from :mod:`opskit.net.errors`; these cover the handshake and
4
+ certificate-validation layers. Each type owns its exit code (constitution Art. VII).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from opskit.core.errors import OpskitError
12
+ from opskit.core.exit_codes import ExitCode
13
+
14
+ if TYPE_CHECKING:
15
+ from opskit.tls.models import ValidationFinding
16
+
17
+
18
+ class TlsError(OpskitError):
19
+ """Base class for TLS handshake/validation failures."""
20
+
21
+ code = "tls_error"
22
+ exit_code = ExitCode.HANDSHAKE_FAILED
23
+
24
+
25
+ class HandshakeError(TlsError):
26
+ """The TLS handshake failed (the service may not speak TLS on this port)."""
27
+
28
+ code = "handshake_failed"
29
+ exit_code = ExitCode.HANDSHAKE_FAILED
30
+
31
+
32
+ class CertificateInvalid(TlsError):
33
+ """The certificate failed validation (expired, name mismatch, untrusted, ...)."""
34
+
35
+ code = "cert_invalid"
36
+ exit_code = ExitCode.CERT_INVALID
37
+
38
+ def __init__(
39
+ self,
40
+ message: str,
41
+ *,
42
+ hint: str | None = None,
43
+ findings: tuple[ValidationFinding, ...] = (),
44
+ ) -> None:
45
+ """Initialize with the failing findings attached for programmatic access."""
46
+ super().__init__(message, hint=hint)
47
+ self.findings = findings
48
+
49
+
50
+ class CertificateExpiring(TlsError):
51
+ """The certificate is valid but expires within the warning threshold."""
52
+
53
+ code = "cert_expiring"
54
+ exit_code = ExitCode.CERT_EXPIRING
55
+
56
+ def __init__(
57
+ self,
58
+ message: str,
59
+ *,
60
+ hint: str | None = None,
61
+ days_remaining: int = 0,
62
+ ) -> None:
63
+ """Initialize with the days remaining until expiry."""
64
+ super().__init__(message, hint=hint)
65
+ self.days_remaining = days_remaining
@@ -0,0 +1,243 @@
1
+ """pyOpenSSL handshake with a recording verify callback.
2
+
3
+ The callback never aborts the handshake — it records every OpenSSL verify error so one
4
+ connection yields both the full presented chain *and* the precise validation findings
5
+ (FR-006). The trust store is sourced from the stdlib's platform defaults, or replaced by a
6
+ user CA bundle (research R2).
7
+
8
+ Security review (constitution Art. III)
9
+ ---------------------------------------
10
+ Static scanners (CodeQL ``py/insecure-protocol``, SonarCloud S4423/S4830/S5527) flag the
11
+ client below as an "insecure TLS client". That is **by design and reviewed as safe**, because
12
+ opskit is a read-only TLS *inspector*, not a data-transferring client:
13
+
14
+ * It MUST complete the handshake to a server presenting an **invalid** certificate in order to
15
+ report on it (FR-006) — so the verify callback *records* OpenSSL errors and returns success
16
+ instead of aborting. The recorded errors drive the verdict/exit code; nothing is trusted.
17
+ * Hostname/SAN validation is done in-tree (RFC 6125, :mod:`opskit.tls.inspect`) so the report
18
+ can say "requested X, certificate covers Y" — OpenSSL's built-in check only gives a boolean.
19
+ * The connection is anonymous and **no application data is ever sent**, so negotiating with an
20
+ untrusted peer carries no interception/exfiltration risk.
21
+ * The protocol floor is still TLS 1.2 (``set_min_proto_version`` + explicit ``OP_NO_*``).
22
+
23
+ Every scanner suppression in this module points back to this note.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import contextlib
29
+ import select
30
+ import socket
31
+ import ssl as stdlib_ssl
32
+ import time
33
+ from collections.abc import Sequence
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import cast
37
+
38
+ from cryptography import x509
39
+ from OpenSSL import SSL
40
+ from OpenSSL.crypto import X509
41
+
42
+ from opskit.net.errors import ConnectTimeout
43
+ from opskit.tls.errors import HandshakeError
44
+
45
+ _NON_TLS_MARKERS = (
46
+ "wrong version number",
47
+ "unknown protocol",
48
+ "packet length too long",
49
+ "record layer failure",
50
+ "unexpected message",
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class HandshakeOutcome:
56
+ """Everything one completed handshake yields."""
57
+
58
+ chain: tuple[x509.Certificate, ...] # as presented, leaf first
59
+ tls_version: str | None
60
+ cipher: str | None
61
+ verify_errors: tuple[tuple[int, int], ...] # (errno, depth) pairs
62
+
63
+
64
+ def _load_platform_store(context: SSL.Context) -> None:
65
+ """Fill the pyOpenSSL store with the platform's default CAs.
66
+
67
+ Two complementary sources (research R2): OpenSSL's own default paths (Linux/macOS
68
+ cafile/capath — covers distros where the stdlib loads a lazy capath that
69
+ ``get_ca_certs`` cannot enumerate) plus the stdlib's enumeration (which pulls from
70
+ the Windows system certificate stores).
71
+ """
72
+ with contextlib.suppress(SSL.Error):
73
+ context.set_default_verify_paths()
74
+ stdlib_context = stdlib_ssl.SSLContext(stdlib_ssl.PROTOCOL_TLS_CLIENT)
75
+ stdlib_context.load_default_certs(stdlib_ssl.Purpose.SERVER_AUTH)
76
+ store = context.get_cert_store()
77
+ if store is None: # pragma: no cover - pyOpenSSL always returns a store
78
+ return
79
+ for der in stdlib_context.get_ca_certs(binary_form=True):
80
+ try:
81
+ store.add_cert(X509.from_cryptography(x509.load_der_x509_certificate(der)))
82
+ except (ValueError, TypeError, SSL.Error):
83
+ continue # skip a malformed platform entry, keep loading the rest
84
+
85
+
86
+ def build_context(ca_file: str | Path | None = None) -> SSL.Context:
87
+ """Return a TLS client context whose trust store is the platform's (or ``ca_file``).
88
+
89
+ The client requires **TLS 1.2 or newer**: opskit is secure-by-default, so it will not
90
+ negotiate down to SSLv3/TLS 1.0/1.1. A server offering only a legacy protocol therefore
91
+ fails the handshake (with a hint) rather than being connected to.
92
+ """
93
+ # See the module "Security review" note: this inspector requires TLS 1.2+ (min-proto plus
94
+ # explicit OP_NO_* for scanners) but intentionally does not delegate the pass/fail decision
95
+ # to OpenSSL — validation is recorded and evaluated by opskit (FR-006).
96
+ context = SSL.Context(
97
+ SSL.TLS_CLIENT_METHOD
98
+ ) # NOSONAR - S4423; TLS>=1.2 enforced below
99
+ context.set_min_proto_version(SSL.TLS1_2_VERSION)
100
+ context.set_options(
101
+ SSL.OP_NO_SSLv2 | SSL.OP_NO_SSLv3 | SSL.OP_NO_TLSv1 | SSL.OP_NO_TLSv1_1
102
+ )
103
+ if ca_file is not None:
104
+ context.load_verify_locations(str(ca_file))
105
+ else:
106
+ _load_platform_store(context)
107
+ return context
108
+
109
+
110
+ def perform_handshake(
111
+ sock: socket.socket,
112
+ *,
113
+ server_name: str | None,
114
+ timeout: float,
115
+ ca_file: str | Path | None = None,
116
+ ) -> HandshakeOutcome:
117
+ """Run the TLS handshake over an already-connected socket; never sends app data.
118
+
119
+ Args:
120
+ sock: Connected TCP socket (consumed; caller still closes it).
121
+ server_name: SNI to send, or ``None`` to omit (IP targets).
122
+ timeout: Handshake timeout in seconds.
123
+ ca_file: Optional PEM bundle replacing the platform trust store.
124
+
125
+ Raises:
126
+ ConnectTimeout: If the peer stops responding mid-handshake.
127
+ HandshakeError: If the handshake fails (e.g. the service does not speak TLS).
128
+ """
129
+ verify_errors: list[tuple[int, int]] = []
130
+
131
+ def _record(
132
+ _conn: SSL.Connection,
133
+ _cert: object,
134
+ errno: int,
135
+ depth: int,
136
+ ok: int,
137
+ ) -> bool:
138
+ # Reviewed-safe (see module note): record the error and continue so an INVALID
139
+ # certificate can still be inspected (FR-006); opskit — not OpenSSL — decides.
140
+ if not ok:
141
+ verify_errors.append((errno, depth))
142
+ return True # NOSONAR - S4830; inspector records verify errors, never trusts blindly
143
+
144
+ context = build_context(ca_file)
145
+ # Verification is intentionally recorded (not delegated) and hostname matching is done
146
+ # in-tree (RFC 6125); see the module "Security review" note.
147
+ context.set_verify(SSL.VERIFY_PEER, _record) # NOSONAR - S4830/S5527, reviewed safe
148
+ # CodeQL py/insecure-protocol reports this as allowing SSLv3/TLS1.0/1.1: a false positive,
149
+ # since the context enforces TLS 1.2+ (set_min_proto_version + OP_NO_*) which CodeQL does
150
+ # not model for pyOpenSSL. Reviewed safe — dismissed on the default branch with this reason.
151
+ connection = SSL.Connection(context, sock)
152
+ if server_name:
153
+ try:
154
+ encoded = server_name.encode("idna")
155
+ except UnicodeError:
156
+ encoded = server_name.encode("ascii", errors="ignore")
157
+ connection.set_tlsext_host_name(encoded)
158
+ sock.settimeout(timeout)
159
+ connection.set_connect_state()
160
+ try:
161
+ _handshake_with_timeout(connection, sock, timeout)
162
+ except socket.timeout as exc:
163
+ raise ConnectTimeout(
164
+ f"TLS handshake timed out after {timeout}s",
165
+ hint="the service may be hanging mid-handshake; try a longer --timeout",
166
+ ) from exc
167
+ except SSL.SysCallError as exc:
168
+ raise HandshakeError(
169
+ f"connection closed during TLS handshake: {exc}",
170
+ hint="the service may not speak TLS on this port",
171
+ ) from exc
172
+ except SSL.Error as exc:
173
+ raise HandshakeError(
174
+ f"TLS handshake failed: {_ssl_error_text(exc)}",
175
+ hint=(
176
+ "the service may not speak TLS on this port, or may only offer TLS "
177
+ "below 1.2 which opskit refuses (STARTTLS upgrades are not supported)"
178
+ ),
179
+ ) from exc
180
+
181
+ chain = tuple(connection.get_peer_cert_chain(as_cryptography=True) or [])
182
+ tls_version: str | None = connection.get_protocol_version_name()
183
+ cipher: str | None = connection.get_cipher_name()
184
+ # Best-effort close; the data is already extracted.
185
+ with contextlib.suppress(SSL.Error, OSError):
186
+ connection.shutdown()
187
+ return HandshakeOutcome(
188
+ chain=chain,
189
+ tls_version=tls_version,
190
+ cipher=cipher,
191
+ verify_errors=tuple(verify_errors),
192
+ )
193
+
194
+
195
+ def _handshake_with_timeout(
196
+ connection: SSL.Connection, sock: socket.socket, timeout: float
197
+ ) -> None:
198
+ """Drive ``do_handshake`` to completion under a wall-clock deadline.
199
+
200
+ A Python socket with a timeout is internally non-blocking, so pyOpenSSL surfaces
201
+ ``WantRead/WantWrite`` — wait with ``select`` and retry until done or out of time.
202
+ """
203
+ deadline = time.monotonic() + timeout
204
+ while True:
205
+ try:
206
+ connection.do_handshake()
207
+ return
208
+ except (SSL.WantReadError, SSL.WantWriteError) as exc:
209
+ remaining = deadline - time.monotonic()
210
+ if remaining <= 0:
211
+ raise socket.timeout("handshake deadline exceeded") from exc
212
+ wants_read = isinstance(exc, SSL.WantReadError)
213
+ readable, writable, _ = select.select(
214
+ [sock] if wants_read else [],
215
+ [] if wants_read else [sock],
216
+ [],
217
+ remaining,
218
+ )
219
+ if not readable and not writable:
220
+ raise socket.timeout("handshake deadline exceeded") from exc
221
+
222
+
223
+ def _ssl_error_text(exc: SSL.Error) -> str:
224
+ """Flatten pyOpenSSL's ``[(lib, func, reason), ...]`` error args into one line."""
225
+ parts: list[str] = []
226
+ for entry in cast("tuple[object, ...]", exc.args):
227
+ if isinstance(entry, (list, tuple)):
228
+ for item in cast("Sequence[object]", entry):
229
+ if isinstance(item, (list, tuple)):
230
+ joined = ":".join(
231
+ str(piece) for piece in cast("Sequence[object]", item) if piece
232
+ )
233
+ if joined:
234
+ parts.append(joined)
235
+ elif str(item):
236
+ parts.append(str(item))
237
+ elif str(entry):
238
+ parts.append(str(entry))
239
+ text = "; ".join(parts) or exc.__class__.__name__
240
+ lowered = text.lower()
241
+ if any(marker in lowered for marker in _NON_TLS_MARKERS):
242
+ text += " (the response does not look like TLS)"
243
+ return text