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/net/tcp.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Reusable TCP resolve/connect primitive (the seam the future net category builds on).
|
|
2
|
+
|
|
3
|
+
Pure stdlib sockets: :func:`resolve` orders candidates via ``getaddrinfo`` (dual-stack order is
|
|
4
|
+
the platform's), :func:`connect` walks them with timeout/retries and normalizes raw ``OSError``
|
|
5
|
+
into the :mod:`opskit.net.errors` hierarchy. Nothing here prints or exits (Art. VII).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import concurrent.futures
|
|
11
|
+
import socket
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from opskit.net.errors import ConnectRefused, ConnectTimeout, ResolutionError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AddressCandidate:
|
|
21
|
+
"""One resolved address a connection may be attempted against."""
|
|
22
|
+
|
|
23
|
+
address: str
|
|
24
|
+
family: str # "ipv4" | "ipv6"
|
|
25
|
+
sockaddr: tuple[Any, ...]
|
|
26
|
+
af: int # socket.AF_* constant
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, Any]:
|
|
29
|
+
"""Return a JSON-serializable mapping of this candidate."""
|
|
30
|
+
return {"address": self.address, "family": self.family}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class TcpConnection:
|
|
35
|
+
"""Facts about an established TCP connection (returned alongside the socket)."""
|
|
36
|
+
|
|
37
|
+
address: str
|
|
38
|
+
family: str # "ipv4" | "ipv6"
|
|
39
|
+
port: int
|
|
40
|
+
connect_ms: float
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, Any]:
|
|
43
|
+
"""Return a JSON-serializable mapping of this connection."""
|
|
44
|
+
return {
|
|
45
|
+
"address": self.address,
|
|
46
|
+
"family": self.family,
|
|
47
|
+
"port": self.port,
|
|
48
|
+
"connect_ms": round(self.connect_ms, 3),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _family_name(af: int) -> str:
|
|
53
|
+
return "ipv6" if af == socket.AF_INET6 else "ipv4"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve(host: str, port: int, *, timeout: float = 5.0) -> list[AddressCandidate]:
|
|
57
|
+
"""Resolve ``host`` to ordered address candidates (platform dual-stack order).
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
host: Hostname or IP literal.
|
|
61
|
+
port: Target port (carried into the socket addresses).
|
|
62
|
+
timeout: Wall-clock budget for the lookup. ``socket.getaddrinfo`` has no per-call
|
|
63
|
+
timeout, so it runs off-thread and is abandoned after ``timeout`` seconds.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Candidates in the order the platform recommends trying them.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ResolutionError: If the name does not resolve or resolution exceeds ``timeout``.
|
|
70
|
+
"""
|
|
71
|
+
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
72
|
+
future = executor.submit(socket.getaddrinfo, host, port, type=socket.SOCK_STREAM)
|
|
73
|
+
try:
|
|
74
|
+
infos = future.result(timeout=timeout)
|
|
75
|
+
except concurrent.futures.TimeoutError as exc:
|
|
76
|
+
raise ResolutionError(
|
|
77
|
+
f"resolving {host} timed out after {timeout}s",
|
|
78
|
+
hint="the resolver may be slow or unreachable; diagnose with: opskit dns lookup "
|
|
79
|
+
+ host,
|
|
80
|
+
) from exc
|
|
81
|
+
except OSError as exc:
|
|
82
|
+
raise ResolutionError(
|
|
83
|
+
f"cannot resolve {host}: {exc}",
|
|
84
|
+
hint="check the name for typos, or diagnose with: opskit dns lookup "
|
|
85
|
+
+ host,
|
|
86
|
+
) from exc
|
|
87
|
+
finally:
|
|
88
|
+
# A lookup that timed out keeps running in the worker; don't block on it (the OS
|
|
89
|
+
# resolver abandons it per resolv.conf), just release the executor.
|
|
90
|
+
executor.shutdown(wait=False)
|
|
91
|
+
candidates: list[AddressCandidate] = []
|
|
92
|
+
for af, _, _, _, sockaddr in infos:
|
|
93
|
+
candidates.append(
|
|
94
|
+
AddressCandidate(
|
|
95
|
+
address=str(sockaddr[0]),
|
|
96
|
+
family=_family_name(af),
|
|
97
|
+
sockaddr=tuple(sockaddr),
|
|
98
|
+
af=af,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
if not candidates:
|
|
102
|
+
raise ResolutionError(f"cannot resolve {host}: no addresses returned")
|
|
103
|
+
return candidates
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def connect(
|
|
107
|
+
host: str,
|
|
108
|
+
port: int,
|
|
109
|
+
*,
|
|
110
|
+
timeout: float = 5.0,
|
|
111
|
+
retries: int = 2,
|
|
112
|
+
) -> tuple[socket.socket, TcpConnection]:
|
|
113
|
+
"""Open a TCP connection to ``host:port``; the caller owns (and must close) the socket.
|
|
114
|
+
|
|
115
|
+
Candidates are tried in :func:`resolve` order; timeouts are retried up to ``retries``
|
|
116
|
+
times across the candidate list, while a refusal on every candidate fails immediately.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
The connected socket (timeout still set) and a :class:`TcpConnection` report.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ResolutionError: If the name does not resolve.
|
|
123
|
+
ConnectRefused: If every candidate refused / was unreachable.
|
|
124
|
+
ConnectTimeout: If no candidate answered before the timeout (after retries).
|
|
125
|
+
"""
|
|
126
|
+
candidates = resolve(host, port, timeout=timeout)
|
|
127
|
+
start = time.perf_counter()
|
|
128
|
+
saw_timeout = False
|
|
129
|
+
refused_exc: BaseException | None = None
|
|
130
|
+
timeout_exc: BaseException | None = None
|
|
131
|
+
for _ in range(retries + 1):
|
|
132
|
+
for candidate in candidates:
|
|
133
|
+
sock = socket.socket(candidate.af, socket.SOCK_STREAM)
|
|
134
|
+
sock.settimeout(timeout)
|
|
135
|
+
try:
|
|
136
|
+
sock.connect(candidate.sockaddr)
|
|
137
|
+
except socket.timeout as exc:
|
|
138
|
+
sock.close()
|
|
139
|
+
saw_timeout = True
|
|
140
|
+
timeout_exc = exc
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
sock.close()
|
|
143
|
+
refused_exc = exc
|
|
144
|
+
else:
|
|
145
|
+
connect_ms = (time.perf_counter() - start) * 1000.0
|
|
146
|
+
return sock, TcpConnection(
|
|
147
|
+
address=candidate.address,
|
|
148
|
+
family=candidate.family,
|
|
149
|
+
port=port,
|
|
150
|
+
connect_ms=connect_ms,
|
|
151
|
+
)
|
|
152
|
+
# A refusal is definitive (host reachable, port closed) — don't retry or wait for
|
|
153
|
+
# a slow sibling address; only pure-timeout runs are worth retrying.
|
|
154
|
+
if refused_exc is not None or not saw_timeout:
|
|
155
|
+
break
|
|
156
|
+
if refused_exc is not None:
|
|
157
|
+
raise ConnectRefused(
|
|
158
|
+
f"cannot connect to {host}:{port}: {refused_exc}",
|
|
159
|
+
hint="check that the service is listening on this port",
|
|
160
|
+
) from refused_exc
|
|
161
|
+
raise ConnectTimeout(
|
|
162
|
+
f"no response from {host}:{port} within {timeout}s",
|
|
163
|
+
hint="the port may be filtered; verify reachability and firewall rules",
|
|
164
|
+
) from timeout_exc
|
opskit/py.typed
ADDED
|
File without changes
|
opskit/tls/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# `opskit tls` — TLS verification
|
|
2
|
+
|
|
3
|
+
Read-only TLS/certificate verification that behaves **identically on Windows, macOS, and
|
|
4
|
+
Linux** — a single replacement for `openssl s_client` incantations. Available both as a CLI
|
|
5
|
+
command and as an importable Python API.
|
|
6
|
+
|
|
7
|
+
> Part of [**opskit**](../../../README.md). See the root README for install and project-wide docs.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Contents
|
|
12
|
+
|
|
13
|
+
- [Quick start](#quick-start)
|
|
14
|
+
- [`opskit tls check`](#opskit-tls-check)
|
|
15
|
+
- [Layered outcomes & exit codes](#layered-outcomes--exit-codes)
|
|
16
|
+
- [Trust & name validation](#trust--name-validation)
|
|
17
|
+
- [Expiry warnings & watch](#expiry-warnings--watch)
|
|
18
|
+
- [Bulk checks](#bulk-checks)
|
|
19
|
+
- [Output](#output)
|
|
20
|
+
- [Use as a Python library](#use-as-a-python-library)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
opskit tls check example.com # full verdict, port 443
|
|
28
|
+
opskit tls check example.com:8443 # non-standard port (or -p 8443)
|
|
29
|
+
opskit tls check 192.0.2.10 --sni internal.corp # IP endpoint, verify a vhost identity
|
|
30
|
+
opskit tls check ldap.corp.example:636 --ca-file corp-root.pem # private PKI
|
|
31
|
+
opskit tls check example.com --json # machine-readable envelope
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The report always shows the verdict, the leaf certificate (subject, issuer, SANs, validity,
|
|
35
|
+
days to expiry, serial, signature algorithm, key), the presented chain, the negotiated
|
|
36
|
+
protocol/cipher, and every failed condition with an actionable hint — **certificate details
|
|
37
|
+
are shown even when validation fails**, so you can inspect the bad certificate instead of
|
|
38
|
+
just being told it's bad.
|
|
39
|
+
|
|
40
|
+
## `opskit tls check`
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
opskit tls check [TARGET] [OPTIONS]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`TARGET` accepts `host`, `host:port`, IPv4/IPv6 literals, or `[ipv6]:port`.
|
|
47
|
+
|
|
48
|
+
| Option | Description | Default |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `-p, --port` | Port to check (must agree with a `host:port` shorthand) | `443` |
|
|
51
|
+
| `--sni` | Server name to send & verify (see below) | target hostname |
|
|
52
|
+
| `--ca-file` | PEM bundle replacing the platform trust store (private PKI) | platform store |
|
|
53
|
+
| `--warn-days` | Expiring-soon threshold in days (`0` disables) | `30` |
|
|
54
|
+
| `--timeout` | Per-attempt timeout (connect and handshake each), seconds | `5.0` |
|
|
55
|
+
| `--retries` | Retries on timeout | `2` |
|
|
56
|
+
| `-i, --input-file` | File of targets, one `host[:port]` per line (`#` comments allowed) | — |
|
|
57
|
+
| `--watch` | Re-run every interval (e.g. `30s`, `2m`) until Ctrl-C | off |
|
|
58
|
+
| `--json` / `--jsonl` | Versioned JSON envelope / NDJSON per target | off |
|
|
59
|
+
| `--no-color` | Disable colored output (`NO_COLOR` honored too) | off |
|
|
60
|
+
|
|
61
|
+
## Layered outcomes & exit codes
|
|
62
|
+
|
|
63
|
+
The check walks resolve → connect → handshake → validate and reports **which layer broke**:
|
|
64
|
+
|
|
65
|
+
| Code | Meaning | Layer |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `0` | TLS healthy | |
|
|
68
|
+
| `1` | generic error | |
|
|
69
|
+
| `2` | usage error (bad target/controls; before any network) | pre-flight |
|
|
70
|
+
| `3` | name resolution failure | resolve |
|
|
71
|
+
| `6` | timeout (connect or handshake, after retries) | connect/handshake |
|
|
72
|
+
| `7` | PARTIAL (batch with mixed outcomes) | aggregate |
|
|
73
|
+
| `8` | connection failed (refused / unreachable) | connect |
|
|
74
|
+
| `9` | handshake failed (incl. non-TLS service; STARTTLS is out of scope) | handshake |
|
|
75
|
+
| `10` | certificate invalid (expired, not-yet-valid, name mismatch, self-signed, untrusted/incomplete chain) | validate |
|
|
76
|
+
| `11` | certificate valid but expiring soon (within `--warn-days`) | validate |
|
|
77
|
+
|
|
78
|
+
Distinct certificate conditions are reported separately: **expired** vs **not yet valid**,
|
|
79
|
+
**self-signed** vs **untrusted chain** vs **incomplete chain** (server forgot the
|
|
80
|
+
intermediate), **name mismatch** (shows requested vs covered names), and **no SANs** (legacy
|
|
81
|
+
CN-only certificates).
|
|
82
|
+
|
|
83
|
+
**Protocol floor**: opskit is secure-by-default and requires **TLS 1.2 or newer**. A server
|
|
84
|
+
that offers only SSLv3 / TLS 1.0 / 1.1 fails the handshake (exit 9) with a hint, rather than
|
|
85
|
+
opskit negotiating down to a weak protocol.
|
|
86
|
+
|
|
87
|
+
## Trust & name validation
|
|
88
|
+
|
|
89
|
+
- Chain validation uses the **platform trust store** by default (Windows certificate stores,
|
|
90
|
+
macOS/Linux OpenSSL paths); `--ca-file` replaces it entirely for private PKI.
|
|
91
|
+
- Name matching follows RFC 6125: exact DNS-SAN match, a wildcard covers exactly one
|
|
92
|
+
left-most label (`*.example.com` matches `a.example.com`, never `example.com` or
|
|
93
|
+
`a.b.example.com`), IP targets match IP SANs, and there is no CN fallback.
|
|
94
|
+
- **SNI**: the target hostname is sent by default and omitted for IP targets. With `--sni`,
|
|
95
|
+
the given name is both sent *and* used for name validation — so
|
|
96
|
+
`opskit tls check 192.0.2.10 --sni internal.corp` verifies the `internal.corp` identity on
|
|
97
|
+
that IP (split-horizon / pre-DNS setups).
|
|
98
|
+
- Revocation (OCSP/CRL) is not checked: opskit connects **only** to the endpoint you name.
|
|
99
|
+
|
|
100
|
+
## Expiry warnings & watch
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
opskit tls check example.com --warn-days 14 # exit 11 if < 14 days remain
|
|
104
|
+
opskit tls check example.com --watch 30s # flag outcome/certificate changes
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`--watch` flags a change when the outcome class, the leaf fingerprint (rotation!), the
|
|
108
|
+
expiry date, or the negotiated protocol changes — timing jitter is ignored.
|
|
109
|
+
|
|
110
|
+
## Bulk checks
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
opskit tls check -i endpoints.txt --jsonl | jq .
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Every target is processed (one failure never aborts the batch); failed targets appear in the
|
|
117
|
+
JSON output with their error; the exit code is `0` only if all pass, the class code if all
|
|
118
|
+
failures share one class, else `7`.
|
|
119
|
+
|
|
120
|
+
## Output
|
|
121
|
+
|
|
122
|
+
`--json` emits the versioned envelope (`schema_version`, `command: "tls.check"`, `query`,
|
|
123
|
+
`result`, `error`, `elapsed_ms`); `--jsonl` emits one envelope per line. The `result` object
|
|
124
|
+
carries the outcome, connection (address/family/timing), `tls_version`, `cipher`, `leaf`,
|
|
125
|
+
`chain`, and `findings`.
|
|
126
|
+
|
|
127
|
+
## Use as a Python library
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from opskit.tls import check, CertificateInvalid
|
|
131
|
+
|
|
132
|
+
result = check("example.com:443", warn_days=14)
|
|
133
|
+
print(result.outcome.value, result.tls_version, result.cipher)
|
|
134
|
+
print(result.leaf.subject, result.leaf.days_until_expiry)
|
|
135
|
+
for cert in result.chain:
|
|
136
|
+
print(" ", cert.subject, "->", cert.issuer)
|
|
137
|
+
for finding in result.findings:
|
|
138
|
+
print("!", finding.code.value, finding.message)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
check("expired.badssl.com", raise_on_invalid=True)
|
|
142
|
+
except CertificateInvalid as exc:
|
|
143
|
+
print(exc.message, "—", exc.hint)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Completed handshakes **return** a result (even for bad certificates — details stay
|
|
147
|
+
inspectable); failures that preclude a report (resolve/connect/handshake) **raise** typed
|
|
148
|
+
errors from `opskit.net` / `opskit.tls`. `raise_on_invalid=True` opts into exceptions for
|
|
149
|
+
certificate conditions too. The TCP primitive is importable from `opskit.net`
|
|
150
|
+
(`resolve`/`connect`) for building your own network tooling.
|
opskit/tls/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""TLS verification diagnostics — importable API and CLI sub-app.
|
|
2
|
+
|
|
3
|
+
Public surface (SemVer-governed): :func:`check`, the typed models, and the TLS exception
|
|
4
|
+
hierarchy. Failures raise; nothing here prints or exits the process.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from opskit.tls.api import check
|
|
10
|
+
from opskit.tls.errors import (
|
|
11
|
+
CertificateExpiring,
|
|
12
|
+
CertificateInvalid,
|
|
13
|
+
HandshakeError,
|
|
14
|
+
TlsError,
|
|
15
|
+
)
|
|
16
|
+
from opskit.tls.models import (
|
|
17
|
+
CertificateInfo,
|
|
18
|
+
FindingCode,
|
|
19
|
+
TlsCheckResult,
|
|
20
|
+
TlsOutcome,
|
|
21
|
+
TlsTarget,
|
|
22
|
+
ValidationFinding,
|
|
23
|
+
parse_target,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"CertificateExpiring",
|
|
28
|
+
"CertificateInfo",
|
|
29
|
+
"CertificateInvalid",
|
|
30
|
+
"FindingCode",
|
|
31
|
+
"HandshakeError",
|
|
32
|
+
"TlsCheckResult",
|
|
33
|
+
"TlsError",
|
|
34
|
+
"TlsOutcome",
|
|
35
|
+
"TlsTarget",
|
|
36
|
+
"ValidationFinding",
|
|
37
|
+
"check",
|
|
38
|
+
"parse_target",
|
|
39
|
+
]
|
opskit/tls/api.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Public TLS verification API — the CLI is a thin client over this module.
|
|
2
|
+
|
|
3
|
+
:func:`check` orchestrates the layers (resolve → connect → handshake → validate). Failures
|
|
4
|
+
that preclude a report **raise** (usage, resolve, connect, handshake); completed handshakes
|
|
5
|
+
**return** a :class:`TlsCheckResult` whose findings carry certificate conditions, so a bad
|
|
6
|
+
certificate's details stay inspectable (FR-006). Nothing here prints or calls ``sys.exit``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from opskit.core.errors import UsageError
|
|
15
|
+
from opskit.net import connect as net_connect
|
|
16
|
+
from opskit.tls.errors import CertificateExpiring, CertificateInvalid
|
|
17
|
+
from opskit.tls.handshake import perform_handshake
|
|
18
|
+
from opskit.tls.inspect import build_findings, match_hostname, parse_certificate
|
|
19
|
+
from opskit.tls.models import (
|
|
20
|
+
FindingCode,
|
|
21
|
+
TlsCheckResult,
|
|
22
|
+
TlsOutcome,
|
|
23
|
+
parse_target,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_INVALID_FINDINGS = frozenset(
|
|
27
|
+
{
|
|
28
|
+
FindingCode.EXPIRED,
|
|
29
|
+
FindingCode.NOT_YET_VALID,
|
|
30
|
+
FindingCode.NAME_MISMATCH,
|
|
31
|
+
FindingCode.SELF_SIGNED,
|
|
32
|
+
FindingCode.UNTRUSTED_CHAIN,
|
|
33
|
+
FindingCode.INCOMPLETE_CHAIN,
|
|
34
|
+
FindingCode.NO_SANS,
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _validate_controls(timeout: float, retries: int, warn_days: int) -> None:
|
|
40
|
+
if timeout <= 0:
|
|
41
|
+
raise UsageError("timeout must be positive")
|
|
42
|
+
if retries < 0:
|
|
43
|
+
raise UsageError("retries must be >= 0")
|
|
44
|
+
if warn_days < 0:
|
|
45
|
+
raise UsageError("warn-days must be >= 0")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check(
|
|
49
|
+
target: str,
|
|
50
|
+
*,
|
|
51
|
+
port: int | None = None,
|
|
52
|
+
server_name: str | None = None,
|
|
53
|
+
ca_file: str | Path | None = None,
|
|
54
|
+
warn_days: int = 30,
|
|
55
|
+
timeout: float = 5.0,
|
|
56
|
+
retries: int = 2,
|
|
57
|
+
raise_on_invalid: bool = False,
|
|
58
|
+
) -> TlsCheckResult:
|
|
59
|
+
"""Verify the TLS health of ``target`` (``host``, ``host:port``, IP, ``[v6]:port``).
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
target: Endpoint to check; the port defaults to 443.
|
|
63
|
+
port: Explicit port (must agree with any ``host:port`` shorthand).
|
|
64
|
+
server_name: SNI override; defaults to the hostname, omitted for IP targets.
|
|
65
|
+
ca_file: PEM bundle replacing the platform trust store (private PKI).
|
|
66
|
+
warn_days: Expiring-soon threshold in days (0 disables the warning class).
|
|
67
|
+
timeout: Per-attempt timeout for connect and handshake, seconds.
|
|
68
|
+
retries: Retries on timeout.
|
|
69
|
+
raise_on_invalid: Raise :class:`CertificateInvalid`/:class:`CertificateExpiring`
|
|
70
|
+
instead of returning a result whose outcome carries those conditions.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A :class:`TlsCheckResult` with the layered outcome, negotiated protocol/cipher,
|
|
74
|
+
parsed leaf + chain, and validation findings.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
UsageError: Invalid target or controls (before any network I/O).
|
|
78
|
+
ResolutionError: The host does not resolve.
|
|
79
|
+
ConnectRefused: The port refused / was unreachable.
|
|
80
|
+
ConnectTimeout: Connect or handshake timed out (after retries).
|
|
81
|
+
HandshakeError: The TLS handshake failed (e.g. non-TLS service).
|
|
82
|
+
CertificateInvalid: Only with ``raise_on_invalid=True``.
|
|
83
|
+
CertificateExpiring: Only with ``raise_on_invalid=True``.
|
|
84
|
+
"""
|
|
85
|
+
parsed = parse_target(target, port=port, server_name=server_name)
|
|
86
|
+
_validate_controls(timeout, retries, warn_days)
|
|
87
|
+
|
|
88
|
+
start = time.perf_counter()
|
|
89
|
+
sock, connection = net_connect(
|
|
90
|
+
parsed.host, parsed.port, timeout=timeout, retries=retries
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
outcome = perform_handshake(
|
|
94
|
+
sock,
|
|
95
|
+
server_name=parsed.server_name,
|
|
96
|
+
timeout=timeout,
|
|
97
|
+
ca_file=ca_file,
|
|
98
|
+
)
|
|
99
|
+
finally:
|
|
100
|
+
sock.close()
|
|
101
|
+
|
|
102
|
+
chain = tuple(parse_certificate(cert) for cert in outcome.chain)
|
|
103
|
+
leaf_raw = outcome.chain[0] if outcome.chain else None
|
|
104
|
+
leaf = chain[0] if chain else None
|
|
105
|
+
if leaf is None or leaf_raw is None:
|
|
106
|
+
# A handshake without a peer certificate is not a usable TLS endpoint.
|
|
107
|
+
raise CertificateInvalid(
|
|
108
|
+
"server presented no certificate",
|
|
109
|
+
hint="the endpoint may require a protocol opskit does not speak",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
findings = build_findings(
|
|
113
|
+
parsed,
|
|
114
|
+
leaf,
|
|
115
|
+
name_matched=match_hostname(parsed, leaf_raw),
|
|
116
|
+
verify_errors=outcome.verify_errors,
|
|
117
|
+
chain_length=len(chain),
|
|
118
|
+
tls_version=outcome.tls_version,
|
|
119
|
+
warn_days=warn_days,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
codes = {finding.code for finding in findings}
|
|
123
|
+
if codes & _INVALID_FINDINGS:
|
|
124
|
+
verdict = TlsOutcome.CERT_INVALID
|
|
125
|
+
elif FindingCode.EXPIRING_SOON in codes:
|
|
126
|
+
verdict = TlsOutcome.EXPIRING_SOON
|
|
127
|
+
else:
|
|
128
|
+
verdict = TlsOutcome.OK
|
|
129
|
+
|
|
130
|
+
result = TlsCheckResult(
|
|
131
|
+
target=parsed,
|
|
132
|
+
outcome=verdict,
|
|
133
|
+
connection=connection,
|
|
134
|
+
tls_version=outcome.tls_version,
|
|
135
|
+
cipher=outcome.cipher,
|
|
136
|
+
leaf=leaf,
|
|
137
|
+
chain=chain,
|
|
138
|
+
findings=findings,
|
|
139
|
+
elapsed_ms=(time.perf_counter() - start) * 1000.0,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if raise_on_invalid and verdict is TlsOutcome.CERT_INVALID:
|
|
143
|
+
first = next(f for f in findings if f.code in _INVALID_FINDINGS)
|
|
144
|
+
raise CertificateInvalid(first.message, hint=first.hint, findings=findings)
|
|
145
|
+
if raise_on_invalid and verdict is TlsOutcome.EXPIRING_SOON:
|
|
146
|
+
raise CertificateExpiring(
|
|
147
|
+
f"certificate expires in {leaf.days_until_expiry} day(s)",
|
|
148
|
+
hint="schedule renewal",
|
|
149
|
+
days_remaining=leaf.days_until_expiry,
|
|
150
|
+
)
|
|
151
|
+
return result
|