runspec-webops-core 0.2.0__tar.gz → 0.3.1__tar.gz
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-0.2.0 → runspec_webops_core-0.3.1}/.gitignore +3 -0
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/CHANGELOG.md +23 -0
- runspec_webops_core-0.3.1/PKG-INFO +56 -0
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/README.md +1 -1
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/pyproject.toml +2 -1
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/__init__.py +2 -0
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/http.py +18 -24
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/openapi.py +5 -3
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/tls.py +29 -6
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/test_http.py +6 -0
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/test_tls.py +31 -1
- runspec_webops_core-0.2.0/PKG-INFO +0 -13
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/dns.py +0 -0
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/errors.py +0 -0
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/proxy.py +0 -0
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/__init__.py +0 -0
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/test_dns.py +0 -0
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/test_openapi.py +0 -0
- {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/test_proxy.py +0 -0
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# runspec-webops-core Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.1] — 2026-11-05
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- The PyPI page shows the README (`pyproject.toml` didn't declare it).
|
|
8
|
+
|
|
9
|
+
## [0.3.0] — 2026-09-01
|
|
10
|
+
|
|
11
|
+
### Added — custom CA bundle for TLS verification
|
|
12
|
+
|
|
13
|
+
- Every network helper gained a `ca_bundle` parameter (a PEM path): `probe`,
|
|
14
|
+
`fetch_headers`, `trace_redirects`, `fetch_body`, `load_document`, and
|
|
15
|
+
`connect_peer`. It verifies TLS against a **private/internal CA or a
|
|
16
|
+
TLS-intercepting proxy** — the common companion to proxy support, since a
|
|
17
|
+
corporate proxy that inspects TLS presents its own CA. `insecure` still turns
|
|
18
|
+
verification off entirely and overrides `ca_bundle`.
|
|
19
|
+
- New `client_ssl_context(insecure=False, ca_bundle=None)` centralises client
|
|
20
|
+
SSL-context construction (used by the HTTP and TLS-trust paths); a bad bundle
|
|
21
|
+
path raises a clear `ConnectError`. Exported from the package.
|
|
22
|
+
- For `connect_peer`, `ca_bundle` affects only the trust verdict — the
|
|
23
|
+
inspection connection stays unverified, so the chain is still reported for an
|
|
24
|
+
untrusted endpoint.
|
|
25
|
+
|
|
3
26
|
## [0.2.0] — 2026-08-26
|
|
4
27
|
|
|
5
28
|
### Added — proxy discovery + CONNECT tunnelling
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: runspec-webops-core
|
|
3
|
+
Version: 0.3.1
|
|
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==0.15.20; extra == 'dev'
|
|
11
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
12
|
+
Provides-Extra: yaml
|
|
13
|
+
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# runspec-webops-core
|
|
17
|
+
|
|
18
|
+
Pure-Python HTTPS/TLS certificate + web-endpoint helpers — the importable logic
|
|
19
|
+
core behind the cross-platform web runnables in
|
|
20
|
+
[`runspec-linux`](https://pypi.org/project/runspec-linux/) and [`runspec-windows`](https://pypi.org/project/runspec-windows/).
|
|
21
|
+
|
|
22
|
+
Stdlib only (`ssl` / `socket` / `http.client`) — **no `runspec` dependency, no
|
|
23
|
+
`runspec.toml`, and no entry points**, so installing it exposes the helper
|
|
24
|
+
functions for import without surfacing any runnables (it is invisible to
|
|
25
|
+
`runspec local` / `runspec serve` discovery).
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from runspec_webops_core import connect_peer, leaf_report, probe, resolve
|
|
29
|
+
|
|
30
|
+
peer = connect_peer("example.com", 443, 10)
|
|
31
|
+
print(leaf_report(peer, warn_days=30)) # days-to-expiry, issuer, SANs, trust
|
|
32
|
+
print(probe("https://example.com", 10)) # status, timing, redirect target
|
|
33
|
+
print(resolve("example.com", want_reverse=True))
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Function | What it does |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `connect_peer` / `leaf_report` / `chain_report` | TLS certificate inspection — expiry, issuer, SANs, fingerprint, full chain, trust result |
|
|
39
|
+
| `probe` / `classify` | Single HTTP(S) request — status, response time, redirect target |
|
|
40
|
+
| `fetch_headers` | Response headers + a security-header report (HSTS, CSP, …) |
|
|
41
|
+
| `trace_redirects` | Walk the redirect chain, hop by hop, with loop detection |
|
|
42
|
+
| `resolve` | Forward (A/AAAA) and reverse (PTR) DNS lookups |
|
|
43
|
+
| `load_document` / `summarize_spec` / `describe_operation` | Read an OpenAPI/Swagger spec (URL or file) and condense its shape — operations, params, request/response schemas — to bounded JSON |
|
|
44
|
+
|
|
45
|
+
OpenAPI/Swagger specs in JSON parse with the stdlib; YAML needs the optional
|
|
46
|
+
`yaml` extra (`pip install runspec-webops-core[yaml]`).
|
|
47
|
+
|
|
48
|
+
Each function returns plain data and *raises* on failure (`ConnectError`,
|
|
49
|
+
`ResolveError` — both subclasses of `WebopsCoreError`).
|
|
50
|
+
|
|
51
|
+
## Corporate wrapping
|
|
52
|
+
|
|
53
|
+
Depend on `runspec-webops-core` alone, import the helpers, and ship your own
|
|
54
|
+
private package with its own `runspec.toml` that bakes in corporate defaults
|
|
55
|
+
(proxies, allowed hosts, redaction) as plain params — only your wrapped
|
|
56
|
+
runnables surface in the venv, the generic published code stays clean.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Pure-Python HTTPS/TLS certificate + web-endpoint helpers — the importable logic
|
|
4
4
|
core behind the cross-platform web runnables in
|
|
5
|
-
[`runspec-linux`](
|
|
5
|
+
[`runspec-linux`](https://pypi.org/project/runspec-linux/) and [`runspec-windows`](https://pypi.org/project/runspec-windows/).
|
|
6
6
|
|
|
7
7
|
Stdlib only (`ssl` / `socket` / `http.client`) — **no `runspec` dependency, no
|
|
8
8
|
`runspec.toml`, and no entry points**, so installing it exposes the helper
|
|
@@ -4,9 +4,10 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runspec-webops-core"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
requires-python = ">=3.10"
|
|
9
9
|
description = "Pure-Python HTTPS/TLS certificate + web-endpoint helpers — the importable core behind the runspec web runnables (no runspec dependency, no runnables)"
|
|
10
|
+
readme = "README.md"
|
|
10
11
|
dependencies = []
|
|
11
12
|
|
|
12
13
|
[project.optional-dependencies]
|
|
@@ -33,6 +33,7 @@ from runspec_webops_core.tls import (
|
|
|
33
33
|
TlsPeer,
|
|
34
34
|
cert_status,
|
|
35
35
|
chain_report,
|
|
36
|
+
client_ssl_context,
|
|
36
37
|
connect_peer,
|
|
37
38
|
days_until,
|
|
38
39
|
leaf_report,
|
|
@@ -52,6 +53,7 @@ __all__ = [
|
|
|
52
53
|
"chain_report",
|
|
53
54
|
"days_until",
|
|
54
55
|
"cert_status",
|
|
56
|
+
"client_ssl_context",
|
|
55
57
|
# http
|
|
56
58
|
"probe",
|
|
57
59
|
"classify",
|
|
@@ -16,6 +16,7 @@ from urllib.parse import urljoin, urlsplit
|
|
|
16
16
|
|
|
17
17
|
from runspec_webops_core.errors import ConnectError
|
|
18
18
|
from runspec_webops_core.proxy import http_connection, resolve_proxy
|
|
19
|
+
from runspec_webops_core.tls import client_ssl_context
|
|
19
20
|
|
|
20
21
|
_USER_AGENT = "runspec-webops"
|
|
21
22
|
|
|
@@ -43,13 +44,14 @@ def classify(status: int) -> str:
|
|
|
43
44
|
return "unknown"
|
|
44
45
|
|
|
45
46
|
|
|
46
|
-
def _request(url: str, timeout: float, method: str, insecure: bool, proxy: str | None = None) -> tuple[int, str, list[tuple[str, str]], float]:
|
|
47
|
+
def _request(url: str, timeout: float, method: str, insecure: bool, proxy: str | None = None, ca_bundle: str | None = None) -> tuple[int, str, list[tuple[str, str]], float]:
|
|
47
48
|
"""Issue one request and return ``(status, reason, headers, elapsed_ms)``.
|
|
48
49
|
|
|
49
50
|
``proxy`` overrides proxy resolution for this request (``"direct"`` forces no
|
|
50
51
|
proxy); when ``None`` the proxy is auto-discovered from the environment and,
|
|
51
|
-
on Windows, the system configuration.
|
|
52
|
-
|
|
52
|
+
on Windows, the system configuration. ``ca_bundle`` is a PEM path of extra CA
|
|
53
|
+
certs to verify against (private CA / TLS-intercepting proxy); ``insecure``
|
|
54
|
+
overrides it. Raises :class:`ConnectError` on any transport-level failure.
|
|
53
55
|
"""
|
|
54
56
|
parts = urlsplit(url)
|
|
55
57
|
if parts.scheme not in ("http", "https"):
|
|
@@ -63,12 +65,7 @@ def _request(url: str, timeout: float, method: str, insecure: bool, proxy: str |
|
|
|
63
65
|
if parts.query:
|
|
64
66
|
path = f"{path}?{parts.query}"
|
|
65
67
|
|
|
66
|
-
ctx = None
|
|
67
|
-
if is_https:
|
|
68
|
-
ctx = ssl.create_default_context()
|
|
69
|
-
if insecure:
|
|
70
|
-
ctx.check_hostname = False
|
|
71
|
-
ctx.verify_mode = ssl.CERT_NONE
|
|
68
|
+
ctx = client_ssl_context(insecure, ca_bundle) if is_https else None
|
|
72
69
|
|
|
73
70
|
resolved = resolve_proxy(url, proxy)
|
|
74
71
|
conn, prefix, extra_headers = http_connection(parts.scheme, parts.hostname, port, timeout, ctx, resolved)
|
|
@@ -87,14 +84,16 @@ def _request(url: str, timeout: float, method: str, insecure: bool, proxy: str |
|
|
|
87
84
|
conn.close()
|
|
88
85
|
|
|
89
86
|
|
|
90
|
-
def fetch_body(url: str, timeout: float = 15.0, insecure: bool = False, max_bytes: int = 10_000_000, max_redirects: int = 5, proxy: str | None = None) -> tuple[int, str]:
|
|
87
|
+
def fetch_body(url: str, timeout: float = 15.0, insecure: bool = False, max_bytes: int = 10_000_000, max_redirects: int = 5, proxy: str | None = None, ca_bundle: str | None = None) -> tuple[int, str]:
|
|
91
88
|
"""GET a URL and return ``(status, body_text)``, following redirects.
|
|
92
89
|
|
|
93
90
|
Used to retrieve documents (e.g. an OpenAPI spec). The body is size-capped so
|
|
94
91
|
a runaway endpoint can't exhaust memory. ``proxy`` overrides proxy resolution
|
|
95
92
|
(``"direct"`` forces none; ``None`` auto-discovers) and is re-evaluated per
|
|
96
|
-
redirect hop, so a cross-host bypass is honoured.
|
|
97
|
-
|
|
93
|
+
redirect hop, so a cross-host bypass is honoured. ``ca_bundle`` is a PEM path
|
|
94
|
+
of extra CA certs to verify against (private CA / TLS proxy); ``insecure``
|
|
95
|
+
overrides it. Raises :class:`ConnectError` on a transport failure or when the
|
|
96
|
+
body exceeds ``max_bytes``.
|
|
98
97
|
"""
|
|
99
98
|
current = url
|
|
100
99
|
seen: set[str] = set()
|
|
@@ -114,12 +113,7 @@ def fetch_body(url: str, timeout: float = 15.0, insecure: bool = False, max_byte
|
|
|
114
113
|
if parts.query:
|
|
115
114
|
path = f"{path}?{parts.query}"
|
|
116
115
|
|
|
117
|
-
ctx = None
|
|
118
|
-
if is_https:
|
|
119
|
-
ctx = ssl.create_default_context()
|
|
120
|
-
if insecure:
|
|
121
|
-
ctx.check_hostname = False
|
|
122
|
-
ctx.verify_mode = ssl.CERT_NONE
|
|
116
|
+
ctx = client_ssl_context(insecure, ca_bundle) if is_https else None
|
|
123
117
|
|
|
124
118
|
resolved = resolve_proxy(current, proxy)
|
|
125
119
|
conn, prefix, extra_headers = http_connection(parts.scheme, parts.hostname, port, timeout, ctx, resolved)
|
|
@@ -157,9 +151,9 @@ def _header(headers: list[tuple[str, str]], name: str) -> str | None:
|
|
|
157
151
|
return None
|
|
158
152
|
|
|
159
153
|
|
|
160
|
-
def probe(url: str, timeout: float = 10.0, method: str = "GET", insecure: bool = False, proxy: str | None = None) -> dict:
|
|
154
|
+
def probe(url: str, timeout: float = 10.0, method: str = "GET", insecure: bool = False, proxy: str | None = None, ca_bundle: str | None = None) -> dict:
|
|
161
155
|
"""Single-request probe: status, timing, ``server`` / ``location`` headers."""
|
|
162
|
-
status, reason, headers, elapsed = _request(url, timeout, method, insecure, proxy)
|
|
156
|
+
status, reason, headers, elapsed = _request(url, timeout, method, insecure, proxy, ca_bundle)
|
|
163
157
|
return {
|
|
164
158
|
"url": url,
|
|
165
159
|
"status": status,
|
|
@@ -171,9 +165,9 @@ def probe(url: str, timeout: float = 10.0, method: str = "GET", insecure: bool =
|
|
|
171
165
|
}
|
|
172
166
|
|
|
173
167
|
|
|
174
|
-
def fetch_headers(url: str, timeout: float = 10.0, method: str = "HEAD", insecure: bool = False, proxy: str | None = None) -> dict:
|
|
168
|
+
def fetch_headers(url: str, timeout: float = 10.0, method: str = "HEAD", insecure: bool = False, proxy: str | None = None, ca_bundle: str | None = None) -> dict:
|
|
175
169
|
"""Fetch response headers and report on the security headers (present/missing)."""
|
|
176
|
-
status, reason, headers, elapsed = _request(url, timeout, method, insecure, proxy)
|
|
170
|
+
status, reason, headers, elapsed = _request(url, timeout, method, insecure, proxy, ca_bundle)
|
|
177
171
|
header_map = {key.lower(): value for key, value in headers}
|
|
178
172
|
security: dict[str, str | None] = {}
|
|
179
173
|
missing: list[str] = []
|
|
@@ -194,7 +188,7 @@ def fetch_headers(url: str, timeout: float = 10.0, method: str = "HEAD", insecur
|
|
|
194
188
|
}
|
|
195
189
|
|
|
196
190
|
|
|
197
|
-
def trace_redirects(url: str, timeout: float = 10.0, max_hops: int = 10, insecure: bool = False, proxy: str | None = None) -> dict:
|
|
191
|
+
def trace_redirects(url: str, timeout: float = 10.0, max_hops: int = 10, insecure: bool = False, proxy: str | None = None, ca_bundle: str | None = None) -> dict:
|
|
198
192
|
"""Walk the redirect chain, recording each hop until a non-3xx or ``max_hops``."""
|
|
199
193
|
hops: list[dict] = []
|
|
200
194
|
seen: set[str] = set()
|
|
@@ -205,7 +199,7 @@ def trace_redirects(url: str, timeout: float = 10.0, max_hops: int = 10, insecur
|
|
|
205
199
|
hops.append({"url": current, "status": None, "status_text": "redirect loop", "location": None, "time_ms": 0.0})
|
|
206
200
|
break
|
|
207
201
|
seen.add(current)
|
|
208
|
-
status, reason, headers, elapsed = _request(current, timeout, "GET", insecure, proxy)
|
|
202
|
+
status, reason, headers, elapsed = _request(current, timeout, "GET", insecure, proxy, ca_bundle)
|
|
209
203
|
location = _header(headers, "location")
|
|
210
204
|
hops.append(
|
|
211
205
|
{
|
|
@@ -39,14 +39,16 @@ class SpecError(WebopsCoreError):
|
|
|
39
39
|
# ── loading ───────────────────────────────────────────────────────────────────
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
def load_document(source: str, timeout: float = 15.0, insecure: bool = False, proxy: str | None = None) -> dict:
|
|
42
|
+
def load_document(source: str, timeout: float = 15.0, insecure: bool = False, proxy: str | None = None, ca_bundle: str | None = None) -> dict:
|
|
43
43
|
"""Fetch (URL) or read (file path) a spec and parse it into a dict.
|
|
44
44
|
|
|
45
45
|
``proxy`` is forwarded to the fetch for URL sources (``"direct"`` forces no
|
|
46
|
-
proxy; ``None`` auto-discovers);
|
|
46
|
+
proxy; ``None`` auto-discovers); ``ca_bundle`` (a PEM path) is forwarded too
|
|
47
|
+
for verifying against a private CA / TLS proxy. Both are ignored for local
|
|
48
|
+
file paths.
|
|
47
49
|
"""
|
|
48
50
|
if source.startswith(("http://", "https://")):
|
|
49
|
-
status, body = fetch_body(source, timeout=timeout, insecure=insecure, proxy=proxy)
|
|
51
|
+
status, body = fetch_body(source, timeout=timeout, insecure=insecure, proxy=proxy, ca_bundle=ca_bundle)
|
|
50
52
|
if status >= 400:
|
|
51
53
|
raise SpecError(f"fetching {source} returned HTTP {status}")
|
|
52
54
|
else:
|
|
@@ -180,6 +180,24 @@ def chain_report(peer: TlsPeer, now: float | None = None) -> list[dict]:
|
|
|
180
180
|
# ── network I/O ───────────────────────────────────────────────────────────────
|
|
181
181
|
|
|
182
182
|
|
|
183
|
+
def client_ssl_context(insecure: bool = False, ca_bundle: str | None = None) -> ssl.SSLContext:
|
|
184
|
+
"""Build a verifying client SSL context, optionally trusting a custom CA bundle.
|
|
185
|
+
|
|
186
|
+
``ca_bundle`` is a path to a PEM file of extra CA certificate(s) to verify
|
|
187
|
+
against — for a private/internal CA or a TLS-intercepting proxy. ``insecure``
|
|
188
|
+
turns verification off entirely (``CERT_NONE``), which overrides ``ca_bundle``.
|
|
189
|
+
Raises :class:`ConnectError` when the bundle path can't be loaded.
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
ctx = ssl.create_default_context(cafile=ca_bundle) if ca_bundle else ssl.create_default_context()
|
|
193
|
+
except OSError as e:
|
|
194
|
+
raise ConnectError(f"could not load CA bundle {ca_bundle!r}: {e}") from e
|
|
195
|
+
if insecure:
|
|
196
|
+
ctx.check_hostname = False
|
|
197
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
198
|
+
return ctx
|
|
199
|
+
|
|
200
|
+
|
|
183
201
|
def _peer_chain(ssock: ssl.SSLSocket) -> list[CertInfo]:
|
|
184
202
|
"""Return the server-presented certificate chain (leaf first).
|
|
185
203
|
|
|
@@ -225,9 +243,12 @@ def _decode_der(der: bytes) -> dict:
|
|
|
225
243
|
Path(path).unlink(missing_ok=True)
|
|
226
244
|
|
|
227
245
|
|
|
228
|
-
def _verify_trust(host: str, port: int, servername: str, timeout: float, proxy: str | None = None) -> tuple[bool, str | None]:
|
|
229
|
-
"""Second connection with full verification — captures the trust verdict.
|
|
230
|
-
|
|
246
|
+
def _verify_trust(host: str, port: int, servername: str, timeout: float, proxy: str | None = None, ca_bundle: str | None = None) -> tuple[bool, str | None]:
|
|
247
|
+
"""Second connection with full verification — captures the trust verdict.
|
|
248
|
+
|
|
249
|
+
``ca_bundle`` adds a custom CA to the trust store (private CA / TLS proxy).
|
|
250
|
+
"""
|
|
251
|
+
ctx = client_ssl_context(ca_bundle=ca_bundle)
|
|
231
252
|
try:
|
|
232
253
|
with open_socket(host, port, timeout, proxy) as sock, ctx.wrap_socket(sock, server_hostname=servername):
|
|
233
254
|
return True, None
|
|
@@ -241,13 +262,15 @@ def _verify_trust(host: str, port: int, servername: str, timeout: float, proxy:
|
|
|
241
262
|
return False, str(e)
|
|
242
263
|
|
|
243
264
|
|
|
244
|
-
def connect_peer(host: str, port: int = 443, timeout: float = 10.0, servername: str | None = None, proxy: str | None = None) -> TlsPeer:
|
|
265
|
+
def connect_peer(host: str, port: int = 443, timeout: float = 10.0, servername: str | None = None, proxy: str | None = None, ca_bundle: str | None = None) -> TlsPeer:
|
|
245
266
|
"""Open a TLS connection and capture the cert chain, protocol, cipher, trust.
|
|
246
267
|
|
|
247
268
|
``proxy`` overrides proxy resolution (``"direct"`` forces none; ``None``
|
|
248
269
|
auto-discovers from the environment and, on Windows, the system config). When
|
|
249
270
|
a proxy applies the connection is CONNECT-tunnelled, so the certificate
|
|
250
|
-
inspected is the real host's, not the proxy's.
|
|
271
|
+
inspected is the real host's, not the proxy's. ``ca_bundle`` is a PEM path
|
|
272
|
+
used only for the trust verdict — the inspection connection is unverified
|
|
273
|
+
regardless, so the chain is still reported for an untrusted endpoint.
|
|
251
274
|
|
|
252
275
|
Raises :class:`ConnectError` only when the endpoint is unreachable — an
|
|
253
276
|
untrusted / expired certificate is reported, not raised.
|
|
@@ -265,7 +288,7 @@ def connect_peer(host: str, port: int = 443, timeout: float = 10.0, servername:
|
|
|
265
288
|
except (OSError, TimeoutError, ssl.SSLError) as e:
|
|
266
289
|
raise ConnectError(f"connection to {host}:{port} failed: {e}") from e
|
|
267
290
|
|
|
268
|
-
authorized, auth_error = _verify_trust(host, port, sni, timeout, resolved)
|
|
291
|
+
authorized, auth_error = _verify_trust(host, port, sni, timeout, resolved, ca_bundle)
|
|
269
292
|
return TlsPeer(
|
|
270
293
|
chain=chain,
|
|
271
294
|
protocol=protocol,
|
|
@@ -78,6 +78,12 @@ def test_probe_bad_scheme_raises() -> None:
|
|
|
78
78
|
probe("ftp://example.com", timeout=2)
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
def test_probe_bad_ca_bundle_raises() -> None:
|
|
82
|
+
# A missing CA bundle fails at context build, before any network I/O.
|
|
83
|
+
with pytest.raises(ConnectError, match="CA bundle"):
|
|
84
|
+
probe("https://example.com", timeout=2, ca_bundle="/no/such/ca.pem")
|
|
85
|
+
|
|
86
|
+
|
|
81
87
|
def test_fetch_headers_flags_missing_security(server: str) -> None:
|
|
82
88
|
res = fetch_headers(f"{server}/ok", timeout=5)
|
|
83
89
|
assert res["security"]["strict_transport_security"] == "max-age=63072000"
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"""Pure TLS reporting logic — no socket required."""
|
|
2
2
|
|
|
3
|
+
import ssl
|
|
3
4
|
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
4
6
|
|
|
5
7
|
import pytest
|
|
6
8
|
|
|
7
|
-
from runspec_webops_core import cert_status, chain_report, days_until, leaf_report
|
|
9
|
+
from runspec_webops_core import cert_status, chain_report, client_ssl_context, days_until, leaf_report
|
|
8
10
|
from runspec_webops_core.errors import ConnectError
|
|
9
11
|
from runspec_webops_core.tls import (
|
|
10
12
|
EXPIRED,
|
|
@@ -48,6 +50,34 @@ def test_issuer_label_prefers_cn_then_org() -> None:
|
|
|
48
50
|
assert _issuer_label(((("organizationName", "Acme"),),)) == "Acme"
|
|
49
51
|
|
|
50
52
|
|
|
53
|
+
def test_client_ssl_context_default_verifies() -> None:
|
|
54
|
+
ctx = client_ssl_context()
|
|
55
|
+
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
|
56
|
+
assert ctx.check_hostname is True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_client_ssl_context_insecure_disables_verification() -> None:
|
|
60
|
+
ctx = client_ssl_context(insecure=True)
|
|
61
|
+
assert ctx.verify_mode == ssl.CERT_NONE
|
|
62
|
+
assert ctx.check_hostname is False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_client_ssl_context_loads_ca_bundle(tmp_path) -> None:
|
|
66
|
+
# A valid PEM (a copy of the system default CAs) loads without error.
|
|
67
|
+
system_cafile = ssl.get_default_verify_paths().cafile
|
|
68
|
+
if not system_cafile or not Path(system_cafile).is_file():
|
|
69
|
+
pytest.skip("no system CA bundle to copy")
|
|
70
|
+
bundle = tmp_path / "ca.pem"
|
|
71
|
+
bundle.write_text(Path(system_cafile).read_text())
|
|
72
|
+
ctx = client_ssl_context(ca_bundle=str(bundle))
|
|
73
|
+
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_client_ssl_context_bad_ca_bundle_raises() -> None:
|
|
77
|
+
with pytest.raises(ConnectError, match="CA bundle"):
|
|
78
|
+
client_ssl_context(ca_bundle="/no/such/ca-bundle.pem")
|
|
79
|
+
|
|
80
|
+
|
|
51
81
|
def _ssl_info(cn: str, issuer_cn: str, not_after: str) -> dict:
|
|
52
82
|
return {
|
|
53
83
|
"subject": ((("commonName", cn),),),
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: runspec-webops-core
|
|
3
|
-
Version: 0.2.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==0.15.20; extra == 'dev'
|
|
11
|
-
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
12
|
-
Provides-Extra: yaml
|
|
13
|
-
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|