runspec-webops-core 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.
- runspec_webops_core/__init__.py +67 -0
- runspec_webops_core/dns.py +65 -0
- runspec_webops_core/errors.py +19 -0
- runspec_webops_core/http.py +224 -0
- runspec_webops_core/openapi.py +317 -0
- runspec_webops_core/tls.py +270 -0
- runspec_webops_core-0.1.0.dist-info/METADATA +13 -0
- runspec_webops_core-0.1.0.dist-info/RECORD +9 -0
- runspec_webops_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""runspec-webops-core — pure-Python HTTPS/TLS + web-endpoint helpers.
|
|
2
|
+
|
|
3
|
+
This package has **no dependency on runspec** and ships **no runspec.toml and no
|
|
4
|
+
entry points**, so installing it exposes the helper functions for import without
|
|
5
|
+
surfacing any runnables (it is invisible to ``runspec local`` / ``runspec serve``
|
|
6
|
+
discovery). ``runspec-linux`` and ``runspec-windows`` both depend on it and wrap
|
|
7
|
+
each helper in a cross-platform runnable; a private (e.g. Nexus-hosted) package
|
|
8
|
+
can instead import these helpers directly and ship its own runnables.
|
|
9
|
+
|
|
10
|
+
Everything is stdlib only (``ssl`` / ``socket`` / ``http.client``). Each function
|
|
11
|
+
returns plain data and *raises* on failure (see ``runspec_webops_core.errors``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from runspec_webops_core.dns import resolve
|
|
15
|
+
from runspec_webops_core.errors import ConnectError, ResolveError, WebopsCoreError
|
|
16
|
+
from runspec_webops_core.http import (
|
|
17
|
+
classify,
|
|
18
|
+
fetch_body,
|
|
19
|
+
fetch_headers,
|
|
20
|
+
probe,
|
|
21
|
+
trace_redirects,
|
|
22
|
+
)
|
|
23
|
+
from runspec_webops_core.openapi import (
|
|
24
|
+
SpecError,
|
|
25
|
+
describe_operation,
|
|
26
|
+
load_document,
|
|
27
|
+
parse_document,
|
|
28
|
+
summarize_spec,
|
|
29
|
+
)
|
|
30
|
+
from runspec_webops_core.tls import (
|
|
31
|
+
CertInfo,
|
|
32
|
+
TlsPeer,
|
|
33
|
+
cert_status,
|
|
34
|
+
chain_report,
|
|
35
|
+
connect_peer,
|
|
36
|
+
days_until,
|
|
37
|
+
leaf_report,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# errors
|
|
42
|
+
"WebopsCoreError",
|
|
43
|
+
"ConnectError",
|
|
44
|
+
"ResolveError",
|
|
45
|
+
"SpecError",
|
|
46
|
+
# tls
|
|
47
|
+
"CertInfo",
|
|
48
|
+
"TlsPeer",
|
|
49
|
+
"connect_peer",
|
|
50
|
+
"leaf_report",
|
|
51
|
+
"chain_report",
|
|
52
|
+
"days_until",
|
|
53
|
+
"cert_status",
|
|
54
|
+
# http
|
|
55
|
+
"probe",
|
|
56
|
+
"classify",
|
|
57
|
+
"fetch_headers",
|
|
58
|
+
"fetch_body",
|
|
59
|
+
"trace_redirects",
|
|
60
|
+
# dns
|
|
61
|
+
"resolve",
|
|
62
|
+
# openapi
|
|
63
|
+
"load_document",
|
|
64
|
+
"parse_document",
|
|
65
|
+
"summarize_spec",
|
|
66
|
+
"describe_operation",
|
|
67
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""DNS resolution — forward (A/AAAA) and reverse (PTR) via stdlib ``socket``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import socket
|
|
6
|
+
|
|
7
|
+
from runspec_webops_core.errors import ResolveError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_ip(value: str) -> bool:
|
|
11
|
+
for family in (socket.AF_INET, socket.AF_INET6):
|
|
12
|
+
try:
|
|
13
|
+
socket.inet_pton(family, value)
|
|
14
|
+
return True
|
|
15
|
+
except OSError:
|
|
16
|
+
continue
|
|
17
|
+
return False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _reverse(ip: str) -> list[str]:
|
|
21
|
+
"""PTR names for an address. Returns ``[]`` when there is no reverse record."""
|
|
22
|
+
try:
|
|
23
|
+
name, aliases, _ = socket.gethostbyaddr(ip)
|
|
24
|
+
except OSError:
|
|
25
|
+
return []
|
|
26
|
+
return [name, *aliases]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve(host: str, want_reverse: bool = False) -> dict:
|
|
30
|
+
"""Resolve ``host``.
|
|
31
|
+
|
|
32
|
+
A bare IP yields its PTR records. A name yields its A/AAAA addresses and
|
|
33
|
+
canonical name; with ``want_reverse`` each address is also reverse-resolved.
|
|
34
|
+
Raises :class:`ResolveError` when the name cannot be resolved.
|
|
35
|
+
"""
|
|
36
|
+
if _is_ip(host):
|
|
37
|
+
return {"host": host, "is_ip": True, "reverse": {host: _reverse(host)}}
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP, flags=socket.AI_CANONNAME)
|
|
41
|
+
except socket.gaierror as e:
|
|
42
|
+
raise ResolveError(f"could not resolve {host!r}: {e}") from e
|
|
43
|
+
|
|
44
|
+
addresses: list[dict] = []
|
|
45
|
+
seen: set[tuple[int, str]] = set()
|
|
46
|
+
canonical = ""
|
|
47
|
+
for family, _type, _proto, canonname, sockaddr in infos:
|
|
48
|
+
if canonname:
|
|
49
|
+
canonical = canonname
|
|
50
|
+
ip = str(sockaddr[0])
|
|
51
|
+
key = (int(family), ip)
|
|
52
|
+
if key in seen:
|
|
53
|
+
continue
|
|
54
|
+
seen.add(key)
|
|
55
|
+
addresses.append({"ip": ip, "family": "ipv6" if family == socket.AF_INET6 else "ipv4"})
|
|
56
|
+
|
|
57
|
+
result: dict = {
|
|
58
|
+
"host": host,
|
|
59
|
+
"is_ip": False,
|
|
60
|
+
"addresses": addresses,
|
|
61
|
+
"canonical_name": canonical or None,
|
|
62
|
+
}
|
|
63
|
+
if want_reverse:
|
|
64
|
+
result["reverse"] = {entry["ip"]: _reverse(entry["ip"]) for entry in addresses}
|
|
65
|
+
return result
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Exceptions raised by the webops helpers.
|
|
2
|
+
|
|
3
|
+
The wrappers in ``runspec-linux`` / ``runspec-windows`` catch these to reproduce
|
|
4
|
+
the CLI/agent behaviour (a connect failure exits differently from a bad result).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WebopsCoreError(Exception):
|
|
11
|
+
"""Base class for all runspec-webops-core failures."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConnectError(WebopsCoreError):
|
|
15
|
+
"""A TLS/HTTP connection could not be established (refused, timed out, reset)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ResolveError(WebopsCoreError):
|
|
19
|
+
"""A DNS name could not be resolved."""
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""HTTP(S) endpoint probing — stdlib ``http.client`` / ``ssl`` only.
|
|
2
|
+
|
|
3
|
+
A single request is issued and its status / timing / a few headers are captured;
|
|
4
|
+
the body is drained, not buffered. ``classify`` and the security-header analysis
|
|
5
|
+
are pure. Redirects are *not* followed by :func:`probe` — :func:`trace_redirects`
|
|
6
|
+
walks them explicitly so each hop is visible.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import http.client
|
|
12
|
+
import socket
|
|
13
|
+
import ssl
|
|
14
|
+
import time
|
|
15
|
+
from urllib.parse import urljoin, urlsplit
|
|
16
|
+
|
|
17
|
+
from runspec_webops_core.errors import ConnectError
|
|
18
|
+
|
|
19
|
+
_USER_AGENT = "runspec-webops"
|
|
20
|
+
|
|
21
|
+
# Response security headers worth surfacing — present value or flagged as missing.
|
|
22
|
+
SECURITY_HEADERS: dict[str, str] = {
|
|
23
|
+
"strict-transport-security": "strict_transport_security",
|
|
24
|
+
"content-security-policy": "content_security_policy",
|
|
25
|
+
"x-frame-options": "x_frame_options",
|
|
26
|
+
"x-content-type-options": "x_content_type_options",
|
|
27
|
+
"referrer-policy": "referrer_policy",
|
|
28
|
+
"permissions-policy": "permissions_policy",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def classify(status: int) -> str:
|
|
33
|
+
"""Bucket an HTTP status code. Pure."""
|
|
34
|
+
if 200 <= status < 300:
|
|
35
|
+
return "ok"
|
|
36
|
+
if 300 <= status < 400:
|
|
37
|
+
return "redirect"
|
|
38
|
+
if 400 <= status < 500:
|
|
39
|
+
return "client-error"
|
|
40
|
+
if 500 <= status < 600:
|
|
41
|
+
return "server-error"
|
|
42
|
+
return "unknown"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _request(url: str, timeout: float, method: str, insecure: bool) -> tuple[int, str, list[tuple[str, str]], float]:
|
|
46
|
+
"""Issue one request and return ``(status, reason, headers, elapsed_ms)``.
|
|
47
|
+
|
|
48
|
+
Raises :class:`ConnectError` on any transport-level failure.
|
|
49
|
+
"""
|
|
50
|
+
parts = urlsplit(url)
|
|
51
|
+
if parts.scheme not in ("http", "https"):
|
|
52
|
+
raise ConnectError(f"unsupported URL scheme: {parts.scheme!r} (expected http or https)")
|
|
53
|
+
if not parts.hostname:
|
|
54
|
+
raise ConnectError(f"no host in URL: {url!r}")
|
|
55
|
+
|
|
56
|
+
is_https = parts.scheme == "https"
|
|
57
|
+
port = parts.port or (443 if is_https else 80)
|
|
58
|
+
path = parts.path or "/"
|
|
59
|
+
if parts.query:
|
|
60
|
+
path = f"{path}?{parts.query}"
|
|
61
|
+
|
|
62
|
+
if is_https:
|
|
63
|
+
ctx = ssl.create_default_context()
|
|
64
|
+
if insecure:
|
|
65
|
+
ctx.check_hostname = False
|
|
66
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
67
|
+
conn: http.client.HTTPConnection = http.client.HTTPSConnection(parts.hostname, port, timeout=timeout, context=ctx)
|
|
68
|
+
else:
|
|
69
|
+
conn = http.client.HTTPConnection(parts.hostname, port, timeout=timeout)
|
|
70
|
+
|
|
71
|
+
start = time.monotonic()
|
|
72
|
+
try:
|
|
73
|
+
conn.request(method, path, headers={"User-Agent": _USER_AGENT, "Accept": "*/*"})
|
|
74
|
+
resp = conn.getresponse()
|
|
75
|
+
resp.read() # drain so the socket can be reused/closed cleanly
|
|
76
|
+
elapsed = round((time.monotonic() - start) * 1000, 1)
|
|
77
|
+
return resp.status, resp.reason or "", resp.getheaders(), elapsed
|
|
78
|
+
except (OSError, TimeoutError, ssl.SSLError, http.client.HTTPException, socket.gaierror) as e:
|
|
79
|
+
raise ConnectError(f"request to {url} failed: {e}") from e
|
|
80
|
+
finally:
|
|
81
|
+
conn.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def fetch_body(url: str, timeout: float = 15.0, insecure: bool = False, max_bytes: int = 10_000_000, max_redirects: int = 5) -> tuple[int, str]:
|
|
85
|
+
"""GET a URL and return ``(status, body_text)``, following redirects.
|
|
86
|
+
|
|
87
|
+
Used to retrieve documents (e.g. an OpenAPI spec). The body is size-capped so
|
|
88
|
+
a runaway endpoint can't exhaust memory. Raises :class:`ConnectError` on a
|
|
89
|
+
transport failure or when the body exceeds ``max_bytes``.
|
|
90
|
+
"""
|
|
91
|
+
current = url
|
|
92
|
+
seen: set[str] = set()
|
|
93
|
+
for _ in range(max_redirects + 1):
|
|
94
|
+
parts = urlsplit(current)
|
|
95
|
+
if parts.scheme not in ("http", "https"):
|
|
96
|
+
raise ConnectError(f"unsupported URL scheme: {parts.scheme!r} (expected http or https)")
|
|
97
|
+
if not parts.hostname:
|
|
98
|
+
raise ConnectError(f"no host in URL: {current!r}")
|
|
99
|
+
if current in seen:
|
|
100
|
+
raise ConnectError(f"redirect loop while fetching {url}")
|
|
101
|
+
seen.add(current)
|
|
102
|
+
|
|
103
|
+
is_https = parts.scheme == "https"
|
|
104
|
+
port = parts.port or (443 if is_https else 80)
|
|
105
|
+
path = parts.path or "/"
|
|
106
|
+
if parts.query:
|
|
107
|
+
path = f"{path}?{parts.query}"
|
|
108
|
+
|
|
109
|
+
if is_https:
|
|
110
|
+
ctx = ssl.create_default_context()
|
|
111
|
+
if insecure:
|
|
112
|
+
ctx.check_hostname = False
|
|
113
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
114
|
+
conn: http.client.HTTPConnection = http.client.HTTPSConnection(parts.hostname, port, timeout=timeout, context=ctx)
|
|
115
|
+
else:
|
|
116
|
+
conn = http.client.HTTPConnection(parts.hostname, port, timeout=timeout)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
conn.request("GET", path, headers={"User-Agent": _USER_AGENT, "Accept": "application/json, application/yaml;q=0.9, */*;q=0.8"})
|
|
120
|
+
resp = conn.getresponse()
|
|
121
|
+
if 300 <= resp.status < 400:
|
|
122
|
+
location = resp.getheader("location")
|
|
123
|
+
resp.read()
|
|
124
|
+
if location:
|
|
125
|
+
current = urljoin(current, location)
|
|
126
|
+
continue
|
|
127
|
+
raw = resp.read(max_bytes + 1)
|
|
128
|
+
if len(raw) > max_bytes:
|
|
129
|
+
raise ConnectError(f"response from {current} exceeds {max_bytes} bytes")
|
|
130
|
+
charset = "utf-8"
|
|
131
|
+
ctype = resp.getheader("content-type") or ""
|
|
132
|
+
if "charset=" in ctype:
|
|
133
|
+
charset = ctype.split("charset=", 1)[1].split(";")[0].strip() or "utf-8"
|
|
134
|
+
return resp.status, raw.decode(charset, errors="replace")
|
|
135
|
+
except (OSError, TimeoutError, ssl.SSLError, http.client.HTTPException, socket.gaierror) as e:
|
|
136
|
+
raise ConnectError(f"request to {current} failed: {e}") from e
|
|
137
|
+
finally:
|
|
138
|
+
conn.close()
|
|
139
|
+
|
|
140
|
+
raise ConnectError(f"too many redirects while fetching {url}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _header(headers: list[tuple[str, str]], name: str) -> str | None:
|
|
144
|
+
target = name.lower()
|
|
145
|
+
for key, value in headers:
|
|
146
|
+
if key.lower() == target:
|
|
147
|
+
return value
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def probe(url: str, timeout: float = 10.0, method: str = "GET", insecure: bool = False) -> dict:
|
|
152
|
+
"""Single-request probe: status, timing, ``server`` / ``location`` headers."""
|
|
153
|
+
status, reason, headers, elapsed = _request(url, timeout, method, insecure)
|
|
154
|
+
return {
|
|
155
|
+
"url": url,
|
|
156
|
+
"status": status,
|
|
157
|
+
"status_text": reason,
|
|
158
|
+
"time_ms": elapsed,
|
|
159
|
+
"server": _header(headers, "server"),
|
|
160
|
+
"location": _header(headers, "location"),
|
|
161
|
+
"class": classify(status),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def fetch_headers(url: str, timeout: float = 10.0, method: str = "HEAD", insecure: bool = False) -> dict:
|
|
166
|
+
"""Fetch response headers and report on the security headers (present/missing)."""
|
|
167
|
+
status, reason, headers, elapsed = _request(url, timeout, method, insecure)
|
|
168
|
+
header_map = {key.lower(): value for key, value in headers}
|
|
169
|
+
security: dict[str, str | None] = {}
|
|
170
|
+
missing: list[str] = []
|
|
171
|
+
for raw, friendly in SECURITY_HEADERS.items():
|
|
172
|
+
value = header_map.get(raw)
|
|
173
|
+
security[friendly] = value
|
|
174
|
+
if value is None:
|
|
175
|
+
missing.append(raw)
|
|
176
|
+
return {
|
|
177
|
+
"url": url,
|
|
178
|
+
"status": status,
|
|
179
|
+
"status_text": reason,
|
|
180
|
+
"time_ms": elapsed,
|
|
181
|
+
"class": classify(status),
|
|
182
|
+
"headers": dict(header_map),
|
|
183
|
+
"security": security,
|
|
184
|
+
"missing_security_headers": missing,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def trace_redirects(url: str, timeout: float = 10.0, max_hops: int = 10, insecure: bool = False) -> dict:
|
|
189
|
+
"""Walk the redirect chain, recording each hop until a non-3xx or ``max_hops``."""
|
|
190
|
+
hops: list[dict] = []
|
|
191
|
+
seen: set[str] = set()
|
|
192
|
+
current = url
|
|
193
|
+
final_status = 0
|
|
194
|
+
for _ in range(max_hops + 1):
|
|
195
|
+
if current in seen:
|
|
196
|
+
hops.append({"url": current, "status": None, "status_text": "redirect loop", "location": None, "time_ms": 0.0})
|
|
197
|
+
break
|
|
198
|
+
seen.add(current)
|
|
199
|
+
status, reason, headers, elapsed = _request(current, timeout, "GET", insecure)
|
|
200
|
+
location = _header(headers, "location")
|
|
201
|
+
hops.append(
|
|
202
|
+
{
|
|
203
|
+
"url": current,
|
|
204
|
+
"status": status,
|
|
205
|
+
"status_text": reason,
|
|
206
|
+
"location": location,
|
|
207
|
+
"time_ms": elapsed,
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
final_status = status
|
|
211
|
+
if not (300 <= status < 400) or not location:
|
|
212
|
+
break
|
|
213
|
+
current = urljoin(current, location)
|
|
214
|
+
else:
|
|
215
|
+
# Loop exhausted without a terminal response.
|
|
216
|
+
hops[-1]["status_text"] = (hops[-1]["status_text"] or "") + " (max hops reached)"
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"url": url,
|
|
220
|
+
"hops": hops,
|
|
221
|
+
"final_url": hops[-1]["url"] if hops else url,
|
|
222
|
+
"final_status": final_status,
|
|
223
|
+
"redirect_count": max(len(hops) - 1, 0),
|
|
224
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""OpenAPI / Swagger spec reading — condense an API's current shape to bounded JSON.
|
|
2
|
+
|
|
3
|
+
The point: let an agent (or operator) see the *live* shape of an internal API —
|
|
4
|
+
its operations, parameters, and request/response schemas — so it can write or
|
|
5
|
+
update the runspec runnables that call it, and re-check when the API drifts. The
|
|
6
|
+
output is small and bounded (a map, not the raw spec), so it stays cheap to feed
|
|
7
|
+
to a model.
|
|
8
|
+
|
|
9
|
+
JSON specs (FastAPI ``/openapi.json``, springdoc ``/v3/api-docs``,
|
|
10
|
+
``swagger.json``) parse with the stdlib. YAML specs need the optional ``pyyaml``
|
|
11
|
+
extra. Both OpenAPI 3.x and Swagger 2.0 are understood. The fetch/read is the
|
|
12
|
+
only I/O; summarisation is pure.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from runspec_webops_core.errors import WebopsCoreError
|
|
23
|
+
from runspec_webops_core.http import fetch_body
|
|
24
|
+
|
|
25
|
+
# HTTP methods an OpenAPI path item may carry as operations.
|
|
26
|
+
_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
|
27
|
+
|
|
28
|
+
# Defensive caps so the summary stays bounded regardless of spec size.
|
|
29
|
+
MAX_OPERATIONS = 300
|
|
30
|
+
_MAX_PROPERTIES = 60
|
|
31
|
+
_MAX_ENUM = 20
|
|
32
|
+
_MAX_SCHEMA_DEPTH = 3
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SpecError(WebopsCoreError):
|
|
36
|
+
"""The spec could not be fetched, read, or parsed."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── loading ───────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_document(source: str, timeout: float = 15.0, insecure: bool = False) -> dict:
|
|
43
|
+
"""Fetch (URL) or read (file path) a spec and parse it into a dict."""
|
|
44
|
+
if source.startswith(("http://", "https://")):
|
|
45
|
+
status, body = fetch_body(source, timeout=timeout, insecure=insecure)
|
|
46
|
+
if status >= 400:
|
|
47
|
+
raise SpecError(f"fetching {source} returned HTTP {status}")
|
|
48
|
+
else:
|
|
49
|
+
path = Path(source)
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
raise SpecError(f"no such spec file: {source}")
|
|
52
|
+
body = path.read_text(encoding="utf-8", errors="replace")
|
|
53
|
+
return parse_document(body)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_document(text: str) -> dict:
|
|
57
|
+
"""Parse spec text as JSON, falling back to YAML when ``pyyaml`` is present."""
|
|
58
|
+
text = text.strip()
|
|
59
|
+
if not text:
|
|
60
|
+
raise SpecError("spec document is empty")
|
|
61
|
+
try:
|
|
62
|
+
data: Any = json.loads(text)
|
|
63
|
+
except json.JSONDecodeError:
|
|
64
|
+
data = _parse_yaml(text)
|
|
65
|
+
if not isinstance(data, dict):
|
|
66
|
+
raise SpecError("spec did not parse to an object")
|
|
67
|
+
if "openapi" not in data and "swagger" not in data:
|
|
68
|
+
raise SpecError("document is not an OpenAPI/Swagger spec (no 'openapi' or 'swagger' key)")
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_yaml(text: str) -> Any:
|
|
73
|
+
try:
|
|
74
|
+
import yaml # type: ignore[import-untyped] # noqa: PLC0415 — optional dependency, only needed for YAML specs
|
|
75
|
+
except ModuleNotFoundError as e:
|
|
76
|
+
raise SpecError(
|
|
77
|
+
"spec is not JSON and PyYAML is not installed — install the 'yaml' extra (pip install runspec-webops-core[yaml]) or point at the JSON spec endpoint (e.g. /openapi.json or /v3/api-docs)"
|
|
78
|
+
) from e
|
|
79
|
+
try:
|
|
80
|
+
return yaml.safe_load(text)
|
|
81
|
+
except yaml.YAMLError as e:
|
|
82
|
+
raise SpecError(f"could not parse spec as JSON or YAML: {e}") from e
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── $ref resolution ───────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resolve_ref(doc: dict, ref: str) -> Any:
|
|
89
|
+
"""Resolve a local JSON-pointer ``$ref`` (``#/...``); returns None if missing."""
|
|
90
|
+
if not ref.startswith("#/"):
|
|
91
|
+
return None
|
|
92
|
+
node: Any = doc
|
|
93
|
+
for part in ref[2:].split("/"):
|
|
94
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
95
|
+
if isinstance(node, dict) and part in node:
|
|
96
|
+
node = node[part]
|
|
97
|
+
else:
|
|
98
|
+
return None
|
|
99
|
+
return node
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _deref(doc: dict, schema: Any, depth: int = 0) -> Any:
|
|
103
|
+
"""Follow a single ``$ref`` (bounded), returning the referenced node."""
|
|
104
|
+
if not isinstance(schema, dict) or "$ref" not in schema:
|
|
105
|
+
return schema
|
|
106
|
+
if depth > _MAX_SCHEMA_DEPTH + 2:
|
|
107
|
+
return schema
|
|
108
|
+
target = _resolve_ref(doc, schema["$ref"])
|
|
109
|
+
if target is None:
|
|
110
|
+
return schema
|
|
111
|
+
return _deref(doc, target, depth + 1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── schema summarising ────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _ref_name(ref: str) -> str:
|
|
118
|
+
return ref.rsplit("/", 1)[-1]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def short_type(doc: dict, schema: Any, depth: int = 0) -> str:
|
|
122
|
+
"""A one-token type label for a schema (``string``, ``array<Order>``, ``User``)."""
|
|
123
|
+
if isinstance(schema, dict) and "$ref" in schema:
|
|
124
|
+
return _ref_name(schema["$ref"])
|
|
125
|
+
schema = _deref(doc, schema, depth)
|
|
126
|
+
if not isinstance(schema, dict):
|
|
127
|
+
return "any"
|
|
128
|
+
kind = schema.get("type")
|
|
129
|
+
if kind == "array":
|
|
130
|
+
items = schema.get("items", {})
|
|
131
|
+
if isinstance(items, dict) and "$ref" in items:
|
|
132
|
+
return f"array<{_ref_name(items['$ref'])}>"
|
|
133
|
+
return f"array<{short_type(doc, items, depth + 1)}>"
|
|
134
|
+
fmt = schema.get("format")
|
|
135
|
+
if isinstance(kind, str):
|
|
136
|
+
return f"{kind}({fmt})" if fmt else kind
|
|
137
|
+
if "properties" in schema:
|
|
138
|
+
return "object"
|
|
139
|
+
return "any"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def summarize_schema(doc: dict, schema: Any, depth: int = 0) -> dict:
|
|
143
|
+
"""A bounded structural summary of a schema (type, properties, required, …)."""
|
|
144
|
+
if isinstance(schema, dict) and "$ref" in schema and depth >= _MAX_SCHEMA_DEPTH:
|
|
145
|
+
return {"$ref": _ref_name(schema["$ref"])}
|
|
146
|
+
resolved = _deref(doc, schema, depth)
|
|
147
|
+
if not isinstance(resolved, dict):
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
out: dict = {}
|
|
151
|
+
if isinstance(resolved.get("type"), str):
|
|
152
|
+
out["type"] = resolved["type"]
|
|
153
|
+
if "format" in resolved:
|
|
154
|
+
out["format"] = resolved["format"]
|
|
155
|
+
if "enum" in resolved and isinstance(resolved["enum"], list):
|
|
156
|
+
out["enum"] = resolved["enum"][:_MAX_ENUM]
|
|
157
|
+
if resolved.get("type") == "array" and "items" in resolved and depth < _MAX_SCHEMA_DEPTH:
|
|
158
|
+
out["items"] = summarize_schema(doc, resolved["items"], depth + 1)
|
|
159
|
+
|
|
160
|
+
props = resolved.get("properties")
|
|
161
|
+
if isinstance(props, dict):
|
|
162
|
+
if depth < _MAX_SCHEMA_DEPTH:
|
|
163
|
+
out["properties"] = {name: short_type(doc, sub, depth + 1) for name, sub in list(props.items())[:_MAX_PROPERTIES]}
|
|
164
|
+
if len(props) > _MAX_PROPERTIES:
|
|
165
|
+
out["properties_truncated"] = True
|
|
166
|
+
out.setdefault("type", "object")
|
|
167
|
+
if isinstance(resolved.get("required"), list):
|
|
168
|
+
out["required"] = resolved["required"]
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── operations ────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _iter_operations(doc: dict) -> Iterator[tuple[str, str, dict, list]]:
|
|
176
|
+
"""Yield ``(path, METHOD, operation, shared_parameters)`` for every operation."""
|
|
177
|
+
paths = doc.get("paths")
|
|
178
|
+
if not isinstance(paths, dict):
|
|
179
|
+
return
|
|
180
|
+
for path, item in paths.items():
|
|
181
|
+
if not isinstance(item, dict):
|
|
182
|
+
continue
|
|
183
|
+
shared = item.get("parameters", []) if isinstance(item.get("parameters"), list) else []
|
|
184
|
+
for method in _METHODS:
|
|
185
|
+
op = item.get(method)
|
|
186
|
+
if isinstance(op, dict):
|
|
187
|
+
yield path, method.upper(), op, shared
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _servers(doc: dict) -> list[str]:
|
|
191
|
+
if isinstance(doc.get("servers"), list):
|
|
192
|
+
return [s["url"] for s in doc["servers"] if isinstance(s, dict) and isinstance(s.get("url"), str)]
|
|
193
|
+
# Swagger 2.0: host + basePath + schemes.
|
|
194
|
+
host = doc.get("host")
|
|
195
|
+
if not host:
|
|
196
|
+
return []
|
|
197
|
+
base = doc.get("basePath", "") or ""
|
|
198
|
+
raw_schemes = doc.get("schemes")
|
|
199
|
+
schemes = raw_schemes if isinstance(raw_schemes, list) else ["https"]
|
|
200
|
+
return [f"{scheme}://{host}{base}" for scheme in schemes]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def summarize_spec(doc: dict, *, tag: str | None = None, path_filter: str | None = None, max_operations: int = MAX_OPERATIONS) -> dict:
|
|
204
|
+
"""Overview: API info, servers, and a bounded list of operations."""
|
|
205
|
+
info = doc.get("info", {}) if isinstance(doc.get("info"), dict) else {}
|
|
206
|
+
operations: list[dict] = []
|
|
207
|
+
total = 0
|
|
208
|
+
for path, method, op, _shared in _iter_operations(doc):
|
|
209
|
+
if tag is not None and tag not in (op.get("tags") or []):
|
|
210
|
+
continue
|
|
211
|
+
if path_filter is not None and path_filter not in path:
|
|
212
|
+
continue
|
|
213
|
+
total += 1
|
|
214
|
+
if len(operations) < max_operations:
|
|
215
|
+
summary = op.get("summary") or (op.get("description") or "")[:120] or None
|
|
216
|
+
operations.append(
|
|
217
|
+
{
|
|
218
|
+
"method": method,
|
|
219
|
+
"path": path,
|
|
220
|
+
"operation_id": op.get("operationId"),
|
|
221
|
+
"summary": summary,
|
|
222
|
+
"tags": op.get("tags") or [],
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
return {
|
|
226
|
+
"title": info.get("title"),
|
|
227
|
+
"version": info.get("version"),
|
|
228
|
+
"spec_version": doc.get("openapi") or doc.get("swagger"),
|
|
229
|
+
"servers": _servers(doc),
|
|
230
|
+
"operation_count": total,
|
|
231
|
+
"returned": len(operations),
|
|
232
|
+
"truncated": total > len(operations),
|
|
233
|
+
"operations": operations,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _find_operation(doc: dict, operation_id: str | None, path: str | None, method: str | None) -> tuple[str, str, dict, list] | None:
|
|
238
|
+
want_method = method.upper() if method else None
|
|
239
|
+
for p, m, op, shared in _iter_operations(doc):
|
|
240
|
+
if operation_id is not None and op.get("operationId") == operation_id:
|
|
241
|
+
return p, m, op, shared
|
|
242
|
+
if path is not None and p == path and (want_method is None or m == want_method):
|
|
243
|
+
return p, m, op, shared
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _parameter(doc: dict, raw: Any) -> dict | None:
|
|
248
|
+
raw = _deref(doc, raw)
|
|
249
|
+
if not isinstance(raw, dict):
|
|
250
|
+
return None
|
|
251
|
+
# OpenAPI 3 nests the type under `schema`; Swagger 2 puts `type` on the param.
|
|
252
|
+
schema = raw.get("schema")
|
|
253
|
+
if schema is None and "type" in raw:
|
|
254
|
+
schema = {"type": raw["type"], **({"format": raw["format"]} if "format" in raw else {})}
|
|
255
|
+
return {
|
|
256
|
+
"name": raw.get("name"),
|
|
257
|
+
"in": raw.get("in"),
|
|
258
|
+
"required": bool(raw.get("required")),
|
|
259
|
+
"description": raw.get("description"),
|
|
260
|
+
"type": short_type(doc, schema or {}),
|
|
261
|
+
"schema": summarize_schema(doc, schema) if schema else {},
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _request_body(doc: dict, op: dict) -> dict | None:
|
|
266
|
+
rb = op.get("requestBody")
|
|
267
|
+
if not isinstance(rb, dict):
|
|
268
|
+
return None
|
|
269
|
+
rb = _deref(doc, rb)
|
|
270
|
+
content = rb.get("content") if isinstance(rb.get("content"), dict) else {}
|
|
271
|
+
out: dict = {"required": bool(rb.get("required")), "content_types": list(content.keys()), "schema": None}
|
|
272
|
+
for ctype, media in content.items():
|
|
273
|
+
if isinstance(media, dict) and "schema" in media:
|
|
274
|
+
out["selected_content_type"] = ctype
|
|
275
|
+
out["schema"] = summarize_schema(doc, media["schema"])
|
|
276
|
+
break
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _responses(doc: dict, op: dict) -> list[dict]:
|
|
281
|
+
out: list[dict] = []
|
|
282
|
+
raw_responses = op.get("responses")
|
|
283
|
+
responses = raw_responses if isinstance(raw_responses, dict) else {}
|
|
284
|
+
for code, resp in responses.items():
|
|
285
|
+
resp = _deref(doc, resp) if isinstance(resp, dict) else {}
|
|
286
|
+
schema = None
|
|
287
|
+
content = resp.get("content")
|
|
288
|
+
if isinstance(content, dict):
|
|
289
|
+
for media in content.values():
|
|
290
|
+
if isinstance(media, dict) and "schema" in media:
|
|
291
|
+
schema = summarize_schema(doc, media["schema"])
|
|
292
|
+
break
|
|
293
|
+
elif "schema" in resp: # Swagger 2.0
|
|
294
|
+
schema = summarize_schema(doc, resp["schema"])
|
|
295
|
+
out.append({"status": str(code), "description": resp.get("description"), "schema": schema})
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def describe_operation(doc: dict, *, operation_id: str | None = None, path: str | None = None, method: str | None = None) -> dict:
|
|
300
|
+
"""Drill into one operation: parameters, request body, and responses (schemas resolved)."""
|
|
301
|
+
match = _find_operation(doc, operation_id, path, method)
|
|
302
|
+
if match is None:
|
|
303
|
+
target = operation_id or (f"{(method or '').upper()} {path}".strip() if path else None) or "(none given)"
|
|
304
|
+
raise SpecError(f"operation not found: {target}")
|
|
305
|
+
p, m, op, shared = match
|
|
306
|
+
parameters = [param for raw in [*shared, *(op.get("parameters") or [])] if (param := _parameter(doc, raw)) is not None]
|
|
307
|
+
return {
|
|
308
|
+
"method": m,
|
|
309
|
+
"path": p,
|
|
310
|
+
"operation_id": op.get("operationId"),
|
|
311
|
+
"summary": op.get("summary"),
|
|
312
|
+
"description": op.get("description"),
|
|
313
|
+
"tags": op.get("tags") or [],
|
|
314
|
+
"parameters": parameters,
|
|
315
|
+
"request_body": _request_body(doc, op),
|
|
316
|
+
"responses": _responses(doc, op),
|
|
317
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""TLS certificate + connection inspection — stdlib ``ssl`` / ``socket`` only.
|
|
2
|
+
|
|
3
|
+
The inspection connection deliberately disables verification (``CERT_NONE``): a
|
|
4
|
+
cert *checker* must still complete the handshake against an expired, self-signed
|
|
5
|
+
or otherwise untrusted endpoint in order to report on it. The trust result is
|
|
6
|
+
captured separately by a second, *verifying* connection and surfaced via
|
|
7
|
+
``authorized`` / ``authorization_error`` rather than raising.
|
|
8
|
+
|
|
9
|
+
The network I/O is confined to :func:`connect_peer`; the reporting functions
|
|
10
|
+
below are pure so they can be unit-tested without a socket.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import socket
|
|
17
|
+
import ssl
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from runspec_webops_core.errors import ConnectError
|
|
24
|
+
|
|
25
|
+
# Certificate freshness buckets.
|
|
26
|
+
OK = "ok"
|
|
27
|
+
EXPIRING = "expiring"
|
|
28
|
+
EXPIRED = "expired"
|
|
29
|
+
|
|
30
|
+
# ── data model ────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CertInfo:
|
|
35
|
+
"""One certificate's parsed fields plus its SHA-256 fingerprint."""
|
|
36
|
+
|
|
37
|
+
subject: str
|
|
38
|
+
issuer: str
|
|
39
|
+
valid_from: str
|
|
40
|
+
valid_to: str
|
|
41
|
+
alt_names: list[str] = field(default_factory=list)
|
|
42
|
+
serial_number: str = ""
|
|
43
|
+
fingerprint_sha256: str = ""
|
|
44
|
+
self_signed: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class TlsPeer:
|
|
49
|
+
"""The result of a single TLS inspection connection."""
|
|
50
|
+
|
|
51
|
+
chain: list[CertInfo]
|
|
52
|
+
protocol: str | None
|
|
53
|
+
cipher_name: str | None
|
|
54
|
+
cipher_version: str | None
|
|
55
|
+
authorized: bool
|
|
56
|
+
authorization_error: str | None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def leaf(self) -> CertInfo | None:
|
|
60
|
+
return self.chain[0] if self.chain else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── pure reporting ────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def days_until(not_after: str, now: float | None = None) -> int:
|
|
67
|
+
"""Whole days until an X.509 ``notAfter`` string (``'Jun 4 23:59:59 2026 GMT'``).
|
|
68
|
+
|
|
69
|
+
Negative when already expired. Raises ``ValueError`` when unparseable.
|
|
70
|
+
"""
|
|
71
|
+
expiry = ssl.cert_time_to_seconds(not_after)
|
|
72
|
+
if now is None:
|
|
73
|
+
now = datetime.now(timezone.utc).timestamp()
|
|
74
|
+
return int((expiry - now) // 86_400)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cert_status(days_remaining: int, warn_days: int) -> str:
|
|
78
|
+
"""Bucket a certificate by days-to-expiry. Pure."""
|
|
79
|
+
if days_remaining < 0:
|
|
80
|
+
return EXPIRED
|
|
81
|
+
if days_remaining <= warn_days:
|
|
82
|
+
return EXPIRING
|
|
83
|
+
return OK
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _rdn_get(name: tuple, key: str) -> str:
|
|
87
|
+
"""Pull a value out of an ``ssl`` RDN structure (tuple of relative-DN sets)."""
|
|
88
|
+
for rdn in name or ():
|
|
89
|
+
for pair in rdn:
|
|
90
|
+
if len(pair) == 2 and pair[0] == key:
|
|
91
|
+
return str(pair[1])
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _common_name(name: tuple) -> str:
|
|
96
|
+
return _rdn_get(name, "commonName")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _issuer_label(name: tuple) -> str:
|
|
100
|
+
return _rdn_get(name, "commonName") or _rdn_get(name, "organizationName")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _alt_names(san: tuple | None) -> list[str]:
|
|
104
|
+
"""Flatten an ``ssl`` ``subjectAltName`` tuple into a list of DNS names."""
|
|
105
|
+
return [str(value) for (kind, value) in (san or ()) if kind == "DNS"]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cert_info_from_ssl(info: dict, der: bytes) -> CertInfo:
|
|
109
|
+
"""Build a :class:`CertInfo` from an ``ssl`` cert dict plus the DER bytes.
|
|
110
|
+
|
|
111
|
+
``info`` is the shape returned by ``getpeercert()`` / ``Certificate.get_info()``.
|
|
112
|
+
``self_signed`` is decided structurally (subject == issuer) rather than by a
|
|
113
|
+
trust check, so it holds for an inspection-only connection.
|
|
114
|
+
"""
|
|
115
|
+
subject = _common_name(info.get("subject", ()))
|
|
116
|
+
issuer = _issuer_label(info.get("issuer", ()))
|
|
117
|
+
return CertInfo(
|
|
118
|
+
subject=subject,
|
|
119
|
+
issuer=issuer,
|
|
120
|
+
valid_from=str(info.get("notBefore", "")),
|
|
121
|
+
valid_to=str(info.get("notAfter", "")),
|
|
122
|
+
alt_names=_alt_names(info.get("subjectAltName")),
|
|
123
|
+
serial_number=str(info.get("serialNumber", "")),
|
|
124
|
+
fingerprint_sha256=_fingerprint(der),
|
|
125
|
+
self_signed=info.get("subject", ()) == info.get("issuer", ()),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _fingerprint(der: bytes) -> str:
|
|
130
|
+
"""Colon-separated uppercase SHA-256 of a DER certificate (OpenSSL style)."""
|
|
131
|
+
hexed = hashlib.sha256(der).hexdigest().upper()
|
|
132
|
+
return ":".join(hexed[i : i + 2] for i in range(0, len(hexed), 2))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def leaf_report(peer: TlsPeer, warn_days: int, now: float | None = None) -> dict:
|
|
136
|
+
"""The cert-check view: leaf cert fields + freshness status + trust result."""
|
|
137
|
+
leaf = peer.leaf
|
|
138
|
+
if leaf is None:
|
|
139
|
+
raise ConnectError("no peer certificate was presented")
|
|
140
|
+
try:
|
|
141
|
+
days = days_until(leaf.valid_to, now)
|
|
142
|
+
status = cert_status(days, warn_days)
|
|
143
|
+
except ValueError:
|
|
144
|
+
days, status = -1, EXPIRED
|
|
145
|
+
return {
|
|
146
|
+
"subject": leaf.subject,
|
|
147
|
+
"issuer": leaf.issuer,
|
|
148
|
+
"valid_from": leaf.valid_from,
|
|
149
|
+
"valid_to": leaf.valid_to,
|
|
150
|
+
"days_remaining": days,
|
|
151
|
+
"status": status,
|
|
152
|
+
"alt_names": leaf.alt_names,
|
|
153
|
+
"serial_number": leaf.serial_number,
|
|
154
|
+
"fingerprint_sha256": leaf.fingerprint_sha256,
|
|
155
|
+
"authorized": peer.authorized,
|
|
156
|
+
"authorization_error": peer.authorization_error,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def chain_report(peer: TlsPeer, now: float | None = None) -> list[dict]:
|
|
161
|
+
"""The cert-chain view: one entry per cert, leaf → … → root."""
|
|
162
|
+
out: list[dict] = []
|
|
163
|
+
for link in peer.chain:
|
|
164
|
+
try:
|
|
165
|
+
days = days_until(link.valid_to, now)
|
|
166
|
+
except ValueError:
|
|
167
|
+
days = -1
|
|
168
|
+
out.append(
|
|
169
|
+
{
|
|
170
|
+
"subject": link.subject,
|
|
171
|
+
"issuer": link.issuer,
|
|
172
|
+
"valid_to": link.valid_to,
|
|
173
|
+
"days_remaining": days,
|
|
174
|
+
"self_signed": link.self_signed,
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── network I/O ───────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _peer_chain(ssock: ssl.SSLSocket) -> list[CertInfo]:
|
|
184
|
+
"""Return the server-presented certificate chain (leaf first).
|
|
185
|
+
|
|
186
|
+
Prefers the public ``SSLSocket.get_unverified_chain()`` (Python 3.13+),
|
|
187
|
+
falls back to the underlying ``_sslobj`` method (3.10–3.12), and finally to
|
|
188
|
+
the leaf-only certificate via ``getpeercert`` when neither is available.
|
|
189
|
+
"""
|
|
190
|
+
getter = getattr(ssock, "get_unverified_chain", None)
|
|
191
|
+
if getter is None:
|
|
192
|
+
sslobj = getattr(ssock, "_sslobj", None)
|
|
193
|
+
getter = getattr(sslobj, "get_unverified_chain", None) if sslobj is not None else None
|
|
194
|
+
|
|
195
|
+
if getter is not None:
|
|
196
|
+
try:
|
|
197
|
+
raw = getter()
|
|
198
|
+
except Exception:
|
|
199
|
+
raw = None
|
|
200
|
+
if raw:
|
|
201
|
+
out: list[CertInfo] = []
|
|
202
|
+
for cert in raw:
|
|
203
|
+
der = ssl.PEM_cert_to_DER_cert(cert.public_bytes())
|
|
204
|
+
out.append(cert_info_from_ssl(cert.get_info(), der))
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
# Fallback: only the leaf is recoverable on this interpreter.
|
|
208
|
+
leaf_der = ssock.getpeercert(binary_form=True)
|
|
209
|
+
if not leaf_der:
|
|
210
|
+
return []
|
|
211
|
+
return [cert_info_from_ssl(_decode_der(leaf_der), leaf_der)]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _decode_der(der: bytes) -> dict:
|
|
215
|
+
"""Parse a DER certificate into the ``getpeercert`` dict via the stdlib decoder."""
|
|
216
|
+
pem = ssl.DER_cert_to_PEM_cert(der)
|
|
217
|
+
with tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) as fh:
|
|
218
|
+
fh.write(pem)
|
|
219
|
+
path = fh.name
|
|
220
|
+
try:
|
|
221
|
+
import _ssl # noqa: PLC0415 — stdlib helper, imported lazily for the fallback path
|
|
222
|
+
|
|
223
|
+
return dict(_ssl._test_decode_cert(path)) # type: ignore[attr-defined]
|
|
224
|
+
finally:
|
|
225
|
+
Path(path).unlink(missing_ok=True)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _verify_trust(host: str, port: int, servername: str, timeout: float) -> tuple[bool, str | None]:
|
|
229
|
+
"""Second connection with full verification — captures the trust verdict."""
|
|
230
|
+
ctx = ssl.create_default_context()
|
|
231
|
+
try:
|
|
232
|
+
with socket.create_connection((host, port), timeout=timeout) as sock, ctx.wrap_socket(sock, server_hostname=servername):
|
|
233
|
+
return True, None
|
|
234
|
+
except ssl.SSLCertVerificationError as e:
|
|
235
|
+
return False, getattr(e, "verify_message", None) or str(e)
|
|
236
|
+
except ssl.SSLError as e:
|
|
237
|
+
return False, str(e)
|
|
238
|
+
except (OSError, TimeoutError) as e:
|
|
239
|
+
# The unverified connection already succeeded, so a failure here is the
|
|
240
|
+
# trust handshake itself (e.g. a TLS alert), not unreachability.
|
|
241
|
+
return False, str(e)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def connect_peer(host: str, port: int = 443, timeout: float = 10.0, servername: str | None = None) -> TlsPeer:
|
|
245
|
+
"""Open a TLS connection and capture the cert chain, protocol, cipher, trust.
|
|
246
|
+
|
|
247
|
+
Raises :class:`ConnectError` only when the endpoint is unreachable — an
|
|
248
|
+
untrusted / expired certificate is reported, not raised.
|
|
249
|
+
"""
|
|
250
|
+
sni = servername or host
|
|
251
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
252
|
+
ctx.check_hostname = False
|
|
253
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
254
|
+
try:
|
|
255
|
+
with socket.create_connection((host, port), timeout=timeout) as sock, ctx.wrap_socket(sock, server_hostname=sni) as ssock:
|
|
256
|
+
protocol = ssock.version()
|
|
257
|
+
cipher = ssock.cipher()
|
|
258
|
+
chain = _peer_chain(ssock)
|
|
259
|
+
except (OSError, TimeoutError, ssl.SSLError) as e:
|
|
260
|
+
raise ConnectError(f"connection to {host}:{port} failed: {e}") from e
|
|
261
|
+
|
|
262
|
+
authorized, auth_error = _verify_trust(host, port, sni, timeout)
|
|
263
|
+
return TlsPeer(
|
|
264
|
+
chain=chain,
|
|
265
|
+
protocol=protocol,
|
|
266
|
+
cipher_name=cipher[0] if cipher else None,
|
|
267
|
+
cipher_version=cipher[1] if cipher else None,
|
|
268
|
+
authorized=authorized,
|
|
269
|
+
authorization_error=auth_error,
|
|
270
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runspec-webops-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python HTTPS/TLS certificate + web-endpoint helpers — the importable core behind the runspec web runnables (no runspec dependency, no runnables)
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Provides-Extra: dev
|
|
7
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
8
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
9
|
+
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
11
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
12
|
+
Provides-Extra: yaml
|
|
13
|
+
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
runspec_webops_core/__init__.py,sha256=0Ps89jzfI58c4GCIkIvJIlhH7HsU9YaC6w4C0KODWqs,1739
|
|
2
|
+
runspec_webops_core/dns.py,sha256=eogx6Wej8zMnc4bMaIDUD27i8BVpcXLR6xnWIo7Urv0,2011
|
|
3
|
+
runspec_webops_core/errors.py,sha256=Ihv-Z8JIMHVJ06r8BSBoY3d5Q8MPAqpJimALlgT4FFI,550
|
|
4
|
+
runspec_webops_core/http.py,sha256=lVUYP8MMPZlGE88UEj3fqg9tykcXHLwxJu930M_T9lA,8687
|
|
5
|
+
runspec_webops_core/openapi.py,sha256=fegjPJRdV0C0D9nZrWY5nbF9Vs71Beb2ZaXaZZxGpBY,13087
|
|
6
|
+
runspec_webops_core/tls.py,sha256=7GEmEPv_Zj2sr64Fq1fQ1zYN2C9OowkksvE0jx3dkAc,9884
|
|
7
|
+
runspec_webops_core-0.1.0.dist-info/METADATA,sha256=E5ee_6uY8qvpX9VxcRd8_bPMLFgeA5JEp7_0NTECNb4,535
|
|
8
|
+
runspec_webops_core-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
runspec_webops_core-0.1.0.dist-info/RECORD,,
|