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/__init__.py +16 -0
- opskit/__main__.py +8 -0
- opskit/cli.py +49 -0
- opskit/core/__init__.py +8 -0
- opskit/core/cliutils.py +171 -0
- opskit/core/errors.py +37 -0
- opskit/core/exit_codes.py +39 -0
- opskit/core/output.py +19 -0
- opskit/core/result.py +41 -0
- opskit/dns/README.md +205 -0
- opskit/dns/__init__.py +61 -0
- opskit/dns/api.py +415 -0
- opskit/dns/cli.py +627 -0
- opskit/dns/errors.py +47 -0
- opskit/dns/models.py +197 -0
- opskit/dns/output.py +83 -0
- opskit/dns/resolver.py +249 -0
- opskit/net/__init__.py +21 -0
- opskit/net/errors.py +37 -0
- opskit/net/tcp.py +164 -0
- opskit/py.typed +0 -0
- opskit/tls/README.md +150 -0
- opskit/tls/__init__.py +39 -0
- opskit/tls/api.py +151 -0
- opskit/tls/cli.py +267 -0
- opskit/tls/errors.py +65 -0
- opskit/tls/handshake.py +243 -0
- opskit/tls/inspect.py +273 -0
- opskit/tls/models.py +240 -0
- opskit/tls/output.py +85 -0
- opskit-0.1.0.dist-info/METADATA +171 -0
- opskit-0.1.0.dist-info/RECORD +35 -0
- opskit-0.1.0.dist-info/WHEEL +4 -0
- opskit-0.1.0.dist-info/entry_points.txt +2 -0
- opskit-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
opskit/tls/handshake.py
ADDED
|
@@ -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
|