messagefoundry 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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
r"""SOAP transport: a destination that POSTs a SOAP envelope to a web service.
|
|
4
|
+
|
|
5
|
+
The **destination** is a thin SOAP layer over the same stdlib HTTP client the REST connector uses
|
|
6
|
+
(ADR 0003) — it reuses REST's no-redirect, http(s)-only opener (so a 3xx can't divert a PHI-bearing
|
|
7
|
+
request; ASVS 15.3.2) and the fail-closed ``[egress].allowed_http`` host gate.
|
|
8
|
+
|
|
9
|
+
**Two modes (ADR 0015).**
|
|
10
|
+
- *Plain* (the default): the Handler produces the **full SOAP envelope** (XML); this adds the SOAP
|
|
11
|
+
``Content-Type`` + action and POSTs it — byte-identical to the original connector.
|
|
12
|
+
- *WS-\** (opt-in, `ws_addressing`/`ws_security`): the Handler produces only the operation **`<Body>`
|
|
13
|
+
fragment**; the **transport** wraps it in the envelope and **stamps the non-deterministic
|
|
14
|
+
WS-Addressing / WS-Security headers** (``<wsa:MessageID>``, ``<wsu:Timestamp>``, optional
|
|
15
|
+
``<wsse:UsernameToken>`` Nonce/Created) **in `send()`** — after the queue boundary, so a pure
|
|
16
|
+
transform never produces a per-call nonce/timestamp and re-run purity holds (ADR 0015 §1). Envelope
|
|
17
|
+
assembly is **stdlib string templating** (no XML parser); the attacker-influenceable ``<Body>``
|
|
18
|
+
fragment is checked once with a hardened, non-resolving, no-DTD well-formedness gate (XXE-negative).
|
|
19
|
+
WS-\* requires SOAP 1.2.
|
|
20
|
+
|
|
21
|
+
**Mutual TLS (ADR 0015).** ``client_cert_file``/``client_key_file`` present a client certificate via a
|
|
22
|
+
per-connection opener (server verification stays on); incompatible with ``verify_tls=False``.
|
|
23
|
+
|
|
24
|
+
**SOAP version.** ``1.1`` → ``Content-Type: text/xml; charset=utf-8`` + a ``SOAPAction`` header;
|
|
25
|
+
``1.2`` → ``Content-Type: application/soap+xml; charset=utf-8; action="…"`` (no ``SOAPAction`` header).
|
|
26
|
+
|
|
27
|
+
**Fault mapping.** A response is inspected for a SOAP ``Fault`` (faults can arrive as HTTP 500 *or* an
|
|
28
|
+
HTTP 200 body). A WS-Security fault (FailedAuthentication / InvalidSecurityToken / MessageExpired) →
|
|
29
|
+
:class:`NegativeAckError` (``permanent=True``) → dead-letter (a credential/expiry reject won't fix on a
|
|
30
|
+
retry). A **Sender/Client** fault → permanent dead-letter; a **Receiver/Server** fault →
|
|
31
|
+
:class:`DeliveryError` (retry). An unrecognized fault is treated as permanent. With no fault, the HTTP
|
|
32
|
+
status decides: 2xx delivered, 5xx retry, other 4xx / refused 3xx dead-letter; a connection/timeout
|
|
33
|
+
error retries. Fault bodies are **not** echoed into errors/logs (they may carry PHI) — only the SOAP
|
|
34
|
+
fault role + HTTP status are.
|
|
35
|
+
|
|
36
|
+
**Idempotency.** Delivery is at-least-once, so a retry **re-sends** (minting a fresh ``<wsa:MessageID>``
|
|
37
|
+
in WS-\* mode — correct WS-\* retry semantics). The service operation **must be idempotent** and its
|
|
38
|
+
dedup must treat a re-send as a retry, not a duplicate. See docs/CONNECTIONS.md.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import asyncio
|
|
44
|
+
import base64
|
|
45
|
+
import hashlib
|
|
46
|
+
import io
|
|
47
|
+
import logging
|
|
48
|
+
import os
|
|
49
|
+
import re
|
|
50
|
+
import ssl
|
|
51
|
+
import time
|
|
52
|
+
import urllib.error
|
|
53
|
+
import urllib.parse
|
|
54
|
+
import urllib.request
|
|
55
|
+
import uuid
|
|
56
|
+
import xml.sax # nosec B406 — hardened, non-resolving, no-DTD well-formedness gate only (ADR 0015 §2a)
|
|
57
|
+
from collections.abc import Callable
|
|
58
|
+
from typing import Any
|
|
59
|
+
from xml.sax.handler import ( # nosec B406 — see _assert_well_formed_fragment (external entities OFF)
|
|
60
|
+
ContentHandler,
|
|
61
|
+
feature_external_ges,
|
|
62
|
+
feature_external_pes,
|
|
63
|
+
)
|
|
64
|
+
from xml.sax.saxutils import escape as _xml_escape # nosec B406 — pure string escaper, not a parser
|
|
65
|
+
from xml.sax.xmlreader import InputSource # nosec B406 — fed only the hardened, no-DTD parser
|
|
66
|
+
|
|
67
|
+
from messagefoundry.config.models import ConnectorType, Destination
|
|
68
|
+
from messagefoundry.config.settings import INSECURE_TLS_ESCAPE_ENV, insecure_tls_allowed
|
|
69
|
+
from messagefoundry.transports.base import (
|
|
70
|
+
DeliveryError,
|
|
71
|
+
DeliveryResponse,
|
|
72
|
+
DestinationConnector,
|
|
73
|
+
NegativeAckError,
|
|
74
|
+
register_destination,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Reuse REST's hardened HTTP plumbing — same transports/ package, same no-redirect + TLS posture.
|
|
78
|
+
from messagefoundry.transports.rest import (
|
|
79
|
+
_NO_REDIRECT_OPENER,
|
|
80
|
+
_NoRedirectHandler,
|
|
81
|
+
_insecure_opener,
|
|
82
|
+
_redact_url,
|
|
83
|
+
enforce_outbound_length_limits,
|
|
84
|
+
refuse_cleartext_credentials,
|
|
85
|
+
)
|
|
86
|
+
from messagefoundry.transports.signing import MessageSigner, signer_from_destination
|
|
87
|
+
|
|
88
|
+
__all__ = ["SoapDestination"]
|
|
89
|
+
|
|
90
|
+
logger = logging.getLogger(__name__)
|
|
91
|
+
|
|
92
|
+
_FAULT_RE = re.compile(r"<(?:\w+:)?Fault[\s>]", re.IGNORECASE)
|
|
93
|
+
_SENDER_RE = re.compile(r"client|sender", re.IGNORECASE)
|
|
94
|
+
_RECEIVER_RE = re.compile(r"server|receiver", re.IGNORECASE)
|
|
95
|
+
# WS-Security fault codes that are PERMANENT (a retry won't fix a rejected/expired credential).
|
|
96
|
+
_WSSE_FAULT_RE = re.compile(
|
|
97
|
+
r"FailedAuthentication|InvalidSecurityToken|MessageExpired", re.IGNORECASE
|
|
98
|
+
)
|
|
99
|
+
# SOAP 1.1 <faultcode>…</faultcode>; SOAP 1.2 <Code><Value>…</Value></Code>.
|
|
100
|
+
_FAULTCODE_RE = re.compile(r"<(?:\w+:)?faultcode\b[^>]*>(.*?)</", re.IGNORECASE | re.DOTALL)
|
|
101
|
+
_CODEVALUE_RE = re.compile(r"<(?:\w+:)?Value\b[^>]*>(.*?)</", re.IGNORECASE | re.DOTALL)
|
|
102
|
+
# A <…:Header> element (any/no namespace prefix) smuggled into a <Body> fragment (purity-leak lint).
|
|
103
|
+
_HEADER_EL_RE = re.compile(r"<\s*(?:[\w.\-]+:)?Header[\s/>]", re.IGNORECASE)
|
|
104
|
+
|
|
105
|
+
# Namespace URIs (fixed; the transport's header is namespace-controlled, ADR 0015 §2a).
|
|
106
|
+
_NS_SOAP12 = "http://www.w3.org/2003/05/soap-envelope"
|
|
107
|
+
_NS_WSA = "http://www.w3.org/2005/08/addressing"
|
|
108
|
+
_NS_WSSE = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
|
|
109
|
+
_NS_WSU = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"
|
|
110
|
+
_PW_TEXT = (
|
|
111
|
+
"http://docs.oasis-open.org/wss/2004/01/"
|
|
112
|
+
"oasis-200401-wss-username-token-profile-1.0#PasswordText"
|
|
113
|
+
)
|
|
114
|
+
_PW_DIGEST = (
|
|
115
|
+
"http://docs.oasis-open.org/wss/2004/01/"
|
|
116
|
+
"oasis-200401-wss-username-token-profile-1.0#PasswordDigest"
|
|
117
|
+
)
|
|
118
|
+
_NONCE_ENC = (
|
|
119
|
+
"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# The envelope skeleton (SOAP 1.2 only; WS-* requires 1.2). Built by string concatenation, NOT
|
|
123
|
+
# str.format — an HL7-derived <Body> fragment may contain literal { } that would break .format.
|
|
124
|
+
_ENV_OPEN = (
|
|
125
|
+
'<?xml version="1.0" encoding="utf-8"?>'
|
|
126
|
+
f'<soap:Envelope xmlns:soap="{_NS_SOAP12}" xmlns:wsa="{_NS_WSA}" '
|
|
127
|
+
f'xmlns:wsse="{_NS_WSSE}" xmlns:wsu="{_NS_WSU}">'
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _fault_code(body: str) -> str:
|
|
132
|
+
"""The fault code text (e.g. ``soap:Client`` / ``soap:Receiver``), SOAP 1.1 or 1.2, or ``""``."""
|
|
133
|
+
m = _FAULTCODE_RE.search(body) or _CODEVALUE_RE.search(body)
|
|
134
|
+
return m.group(1) if m else ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _classify_soap(status: int, body: str) -> DeliveryError | None:
|
|
138
|
+
"""``None`` if delivered, else the classified failure. A WS-Security fault (auth/expiry) →
|
|
139
|
+
permanent; a SOAP fault is classified by its code (Sender/Client → permanent; Receiver/Server →
|
|
140
|
+
transient; unrecognized → permanent so a rejected request can't loop the lane); a fault-less
|
|
141
|
+
response falls back to the HTTP status."""
|
|
142
|
+
if _FAULT_RE.search(body):
|
|
143
|
+
code = _fault_code(body)
|
|
144
|
+
if _WSSE_FAULT_RE.search(code) or _WSSE_FAULT_RE.search(body):
|
|
145
|
+
return NegativeAckError(
|
|
146
|
+
f"WS-Security fault (HTTP {status})", code="wssecurity", permanent=True
|
|
147
|
+
)
|
|
148
|
+
if _SENDER_RE.search(code):
|
|
149
|
+
return NegativeAckError(
|
|
150
|
+
f"SOAP Sender fault (HTTP {status})", code="soap-sender", permanent=True
|
|
151
|
+
)
|
|
152
|
+
if _RECEIVER_RE.search(code):
|
|
153
|
+
return DeliveryError(f"SOAP Receiver fault (HTTP {status})")
|
|
154
|
+
return NegativeAckError(f"SOAP fault (HTTP {status})", code="soap-fault", permanent=True)
|
|
155
|
+
if 200 <= status < 300:
|
|
156
|
+
return None
|
|
157
|
+
if 500 <= status < 600:
|
|
158
|
+
return DeliveryError(f"SOAP endpoint returned HTTP {status}")
|
|
159
|
+
return NegativeAckError(
|
|
160
|
+
f"SOAP endpoint rejected with HTTP {status}", code=str(status), permanent=True
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _client_cert_opener(
|
|
165
|
+
certfile: str, keyfile: str, password: str | None
|
|
166
|
+
) -> urllib.request.OpenerDirector:
|
|
167
|
+
"""A no-redirect opener that presents a **client certificate** for mutual TLS (ADR 0015 §3).
|
|
168
|
+
|
|
169
|
+
Server verification stays on (``create_default_context`` verifies the peer + hostname); a client
|
|
170
|
+
cert against an unverified peer is incoherent and rejected at construction, so this never combines
|
|
171
|
+
with ``verify_tls=False``. TLS 1.2+ floor (ADR 0002), as in ``mllp.py``/``api/tls.py``. Per
|
|
172
|
+
connection — REST's shared module-level openers are left untouched."""
|
|
173
|
+
ctx = ssl.create_default_context()
|
|
174
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
175
|
+
ctx.load_cert_chain(certfile, keyfile, password)
|
|
176
|
+
return urllib.request.build_opener(_NoRedirectHandler, urllib.request.HTTPSHandler(context=ctx))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _assert_well_formed_fragment(fragment: str) -> None:
|
|
180
|
+
"""Reject a ``<Body>`` fragment that is not well-formed XML, **before** it reaches the wire
|
|
181
|
+
(ADR 0015 §2a). Uses a single hardened, **non-resolving, no-DTD** ``xml.sax`` parse of the fragment
|
|
182
|
+
*in isolation* (wrapped in a throwaway shell we discard) — external entity resolution is OFF, so it
|
|
183
|
+
is **not** an XXE vector; a DOCTYPE is rejected outright (no internal entity expansion). This is a
|
|
184
|
+
balanced-tags / no-smuggled-close gate on attacker-influenceable HL7-derived content, not a schema
|
|
185
|
+
check; no parsed tree is kept or trusted."""
|
|
186
|
+
if "<!doctype" in fragment.lower():
|
|
187
|
+
raise ValueError("SOAP <Body> fragment must not contain a DOCTYPE (ADR 0015)")
|
|
188
|
+
parser = xml.sax.make_parser() # noqa: S317 # nosec B317 — hardened below: external entities OFF
|
|
189
|
+
parser.setFeature(feature_external_ges, False)
|
|
190
|
+
parser.setFeature(feature_external_pes, False)
|
|
191
|
+
parser.setContentHandler(ContentHandler())
|
|
192
|
+
source = InputSource()
|
|
193
|
+
source.setByteStream(io.BytesIO(f"<_mf_frag>{fragment}</_mf_frag>".encode()))
|
|
194
|
+
try:
|
|
195
|
+
parser.parse(source)
|
|
196
|
+
except xml.sax.SAXException as exc:
|
|
197
|
+
raise ValueError(f"SOAP <Body> fragment is not well-formed XML: {exc}") from exc
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _reject_ws_leak(fragment: str) -> None:
|
|
201
|
+
"""Best-effort lint (ADR 0015 §2b, NOT a structural guarantee): reject a ``<Body>`` fragment that
|
|
202
|
+
already carries a ``<Header>`` or a WS-\\* element **by namespace URI** — i.e. an author trying to
|
|
203
|
+
hand-build the non-deterministic header inside the (pure) transform. The real purity guarantee is
|
|
204
|
+
§1's value-placement; this only catches an accidental mistake early. Matches on URI (prefixes are
|
|
205
|
+
author-chosen) so it can be evaded (a URI in a comment/CDATA) and is intentionally conservative."""
|
|
206
|
+
if any(ns in fragment for ns in (_NS_WSA, _NS_WSSE, _NS_WSU)):
|
|
207
|
+
raise ValueError(
|
|
208
|
+
"SOAP <Body> fragment declares a WS-* namespace (wsa/wsse/wsu); the transport stamps those "
|
|
209
|
+
"headers in send() to preserve re-run purity — the Handler must return only the <Body> "
|
|
210
|
+
"operation fragment (ADR 0015)"
|
|
211
|
+
)
|
|
212
|
+
if _HEADER_EL_RE.search(fragment):
|
|
213
|
+
raise ValueError(
|
|
214
|
+
"SOAP <Body> fragment contains a <Header> element; in WS-* mode the Handler returns only "
|
|
215
|
+
"the operation <Body> fragment and the transport builds the <Header> (ADR 0015)"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _iso(t: float) -> str:
|
|
220
|
+
"""An XSD ``dateTime`` in UTC, e.g. ``2026-06-15T05:30:00Z`` (WS-Security Created/Expires)."""
|
|
221
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _default_message_id() -> str:
|
|
225
|
+
"""A fresh WS-Addressing MessageID (a per-call URN); overridable for deterministic tests."""
|
|
226
|
+
return f"urn:uuid:{uuid.uuid4()}"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _default_nonce() -> bytes:
|
|
230
|
+
"""A fresh WS-Security nonce; overridable for deterministic tests."""
|
|
231
|
+
return os.urandom(16)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class SoapDestination(DestinationConnector):
|
|
235
|
+
"""POST each SOAP envelope to a web-service endpoint (plain or WS-* mode; ADR 0003 + 0015)."""
|
|
236
|
+
|
|
237
|
+
def __init__(self, config: Destination) -> None:
|
|
238
|
+
s = config.settings
|
|
239
|
+
url = s.get("url")
|
|
240
|
+
if not isinstance(url, str) or not url:
|
|
241
|
+
raise ValueError("SOAP destination requires a 'url' setting")
|
|
242
|
+
scheme = urllib.parse.urlsplit(url).scheme.lower()
|
|
243
|
+
if scheme not in ("http", "https"):
|
|
244
|
+
raise ValueError(f"SOAP destination 'url' must be http or https, got scheme {scheme!r}")
|
|
245
|
+
self.url = url
|
|
246
|
+
self.timeout: float = float(s.get("timeout_seconds", 30.0))
|
|
247
|
+
self.encoding: str = s.get("encoding", "utf-8")
|
|
248
|
+
self.version: str = str(s.get("soap_version", "1.1"))
|
|
249
|
+
if self.version not in ("1.1", "1.2"):
|
|
250
|
+
raise ValueError(
|
|
251
|
+
f"SOAP destination soap_version must be 1.1 or 1.2, got {self.version!r}"
|
|
252
|
+
)
|
|
253
|
+
# The action is the single source of truth: the 1.2 Content-Type action param AND wsa:Action.
|
|
254
|
+
self.soap_action: str = str(s.get("soap_action") or "")
|
|
255
|
+
# ADR 0013: capture the SOAP response envelope. Default False → returns None, byte-identical.
|
|
256
|
+
self.capture_response: bool = bool(s.get("capture_response", False))
|
|
257
|
+
|
|
258
|
+
# --- ADR 0015: WS-* + mutual-TLS settings -----------------------------------------------
|
|
259
|
+
self.client_cert_file: str | None = s.get("client_cert_file") or None
|
|
260
|
+
self.client_key_file: str | None = s.get("client_key_file") or None
|
|
261
|
+
self.client_key_password: str | None = s.get("client_key_password") or None
|
|
262
|
+
self.ws_addressing: bool = bool(s.get("ws_addressing", False))
|
|
263
|
+
self.ws_security: bool = bool(s.get("ws_security", False))
|
|
264
|
+
self.ws_username: str | None = s.get("ws_username") or s.get("basic_user") or None
|
|
265
|
+
self.ws_password: str | None = s.get("ws_password") or s.get("basic_password") or None
|
|
266
|
+
self.ws_password_type: str = str(s.get("ws_password_type", "text"))
|
|
267
|
+
self.ws_timestamp_ttl_seconds: int = int(s.get("ws_timestamp_ttl_seconds", 300))
|
|
268
|
+
self._ws_mode: bool = self.ws_addressing or self.ws_security
|
|
269
|
+
|
|
270
|
+
# Non-deterministic generators — instance attributes so tests can inject a fixed clock / UUID /
|
|
271
|
+
# nonce and assert the values are minted in send() (ADR 0015 testing strategy).
|
|
272
|
+
self._now_fn: Callable[[], float] = time.time
|
|
273
|
+
self._uuid_fn: Callable[[], str] = _default_message_id
|
|
274
|
+
self._nonce_fn: Callable[[], bytes] = _default_nonce
|
|
275
|
+
|
|
276
|
+
self._validate_ws(scheme, s)
|
|
277
|
+
|
|
278
|
+
self._headers = self._build_headers(s)
|
|
279
|
+
enforce_outbound_length_limits(self.url, self._headers)
|
|
280
|
+
refuse_cleartext_credentials(scheme, self._headers, self.url)
|
|
281
|
+
# ASVS 4.1.5 (ADR 0018): opt-in detached-JWS signing of the outbound envelope. None = off
|
|
282
|
+
# (byte-identical). Built here so a bad key/algorithm fails loud at connector construction; the
|
|
283
|
+
# signature is minted in _post over the FINAL wire bytes (the WS-* wrapped envelope, ADR 0015).
|
|
284
|
+
self._signer: MessageSigner | None = signer_from_destination(config)
|
|
285
|
+
|
|
286
|
+
if self.client_cert_file and self.client_key_file: # NEW — mutual TLS, takes precedence
|
|
287
|
+
self._opener: urllib.request.OpenerDirector = _client_cert_opener(
|
|
288
|
+
self.client_cert_file, self.client_key_file, self.client_key_password
|
|
289
|
+
)
|
|
290
|
+
elif bool(s.get("verify_tls", True)):
|
|
291
|
+
self._opener = _NO_REDIRECT_OPENER
|
|
292
|
+
else:
|
|
293
|
+
if scheme == "https" and not insecure_tls_allowed():
|
|
294
|
+
raise ValueError(
|
|
295
|
+
"SOAP destination verify_tls=false disables TLS certificate verification; "
|
|
296
|
+
f"refused unless {INSECURE_TLS_ESCAPE_ENV} is set (dev/trusted-network only)"
|
|
297
|
+
)
|
|
298
|
+
logger.warning(
|
|
299
|
+
"SOAP destination %s has TLS verification DISABLED (verify_tls=false)",
|
|
300
|
+
_redact_url(self.url),
|
|
301
|
+
)
|
|
302
|
+
self._opener = _insecure_opener()
|
|
303
|
+
|
|
304
|
+
def _validate_ws(self, scheme: str, s: dict[str, Any]) -> None:
|
|
305
|
+
"""Runtime validation of the WS-* / mTLS settings (also enforced at wiring time by
|
|
306
|
+
``build_outbound_connection`` so ``check``/dry-run catches it without a store; the url-scheme
|
|
307
|
+
checks need the resolved url and live here). ADR 0015 §6."""
|
|
308
|
+
if self.client_cert_file and not self.client_key_file:
|
|
309
|
+
raise ValueError("SOAP client_cert_file requires client_key_file (ADR 0015)")
|
|
310
|
+
if self.client_key_file and not self.client_cert_file:
|
|
311
|
+
raise ValueError("SOAP client_key_file requires client_cert_file (ADR 0015)")
|
|
312
|
+
if self.client_cert_file:
|
|
313
|
+
if scheme != "https":
|
|
314
|
+
raise ValueError("SOAP client certificate requires an https url (ADR 0015)")
|
|
315
|
+
if not bool(s.get("verify_tls", True)):
|
|
316
|
+
raise ValueError(
|
|
317
|
+
"SOAP client cert is incompatible with verify_tls=false — the peer must be "
|
|
318
|
+
"verified (ADR 0015)"
|
|
319
|
+
)
|
|
320
|
+
if self.ws_password_type not in ("text", "digest"):
|
|
321
|
+
raise ValueError("SOAP ws_password_type must be 'text' or 'digest' (ADR 0015)")
|
|
322
|
+
if self._ws_mode and self.version != "1.2":
|
|
323
|
+
raise ValueError("SOAP ws_addressing/ws_security require soap_version='1.2' (ADR 0015)")
|
|
324
|
+
# A UsernameToken password over cleartext http is a credential on the wire — refuse like the
|
|
325
|
+
# Authorization-header path (ADR 0015 §6), unless the dev escape is set.
|
|
326
|
+
if self.ws_username and scheme == "http" and not insecure_tls_allowed():
|
|
327
|
+
raise ValueError(
|
|
328
|
+
"SOAP ws_username sends a UsernameToken credential over cleartext http; refused "
|
|
329
|
+
f"unless {INSECURE_TLS_ESCAPE_ENV} is set — use https (ADR 0015)"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def _build_headers(self, s: dict[str, Any]) -> dict[str, str]:
|
|
333
|
+
"""SOAP Content-Type (+ SOAPAction for 1.1) + static ``headers`` + optional bearer/basic auth."""
|
|
334
|
+
action = str(s.get("soap_action") or "")
|
|
335
|
+
headers: dict[str, str] = {}
|
|
336
|
+
if self.version == "1.2":
|
|
337
|
+
ctype = "application/soap+xml; charset=utf-8"
|
|
338
|
+
if action:
|
|
339
|
+
ctype += f'; action="{action}"'
|
|
340
|
+
headers["Content-Type"] = ctype
|
|
341
|
+
else:
|
|
342
|
+
headers["Content-Type"] = "text/xml; charset=utf-8"
|
|
343
|
+
headers["SOAPAction"] = f'"{action}"' # quoted; an empty "" is the no-action convention
|
|
344
|
+
extra = s.get("headers") or {}
|
|
345
|
+
if isinstance(extra, dict):
|
|
346
|
+
headers.update({str(k): str(v) for k, v in extra.items()})
|
|
347
|
+
token = s.get("bearer_token")
|
|
348
|
+
if token:
|
|
349
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
350
|
+
user, password = s.get("basic_user"), s.get("basic_password")
|
|
351
|
+
if user and password:
|
|
352
|
+
raw = f"{user}:{password}".encode()
|
|
353
|
+
headers["Authorization"] = "Basic " + base64.b64encode(raw).decode("ascii")
|
|
354
|
+
return headers
|
|
355
|
+
|
|
356
|
+
# --- WS-* envelope assembly (all non-deterministic values minted here, in send()) -------------
|
|
357
|
+
|
|
358
|
+
def _build_wsa_header(self) -> str:
|
|
359
|
+
# wsa:Action is sourced from soap_action — the single source of truth, never a divergent second
|
|
360
|
+
# value (ADR 0015 §6). wsa:To is the static endpoint url; wsa:MessageID is per-call.
|
|
361
|
+
action = _xml_escape(self.soap_action)
|
|
362
|
+
to = _xml_escape(self.url)
|
|
363
|
+
message_id = _xml_escape(self._uuid_fn())
|
|
364
|
+
return (
|
|
365
|
+
f"<wsa:Action>{action}</wsa:Action>"
|
|
366
|
+
f"<wsa:To>{to}</wsa:To>"
|
|
367
|
+
f"<wsa:MessageID>{message_id}</wsa:MessageID>"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def _build_wsse_header(self, now: float) -> str:
|
|
371
|
+
created = _iso(now)
|
|
372
|
+
expires = _iso(now + self.ws_timestamp_ttl_seconds)
|
|
373
|
+
timestamp = (
|
|
374
|
+
'<wsu:Timestamp wsu:Id="TS-1">'
|
|
375
|
+
f"<wsu:Created>{created}</wsu:Created><wsu:Expires>{expires}</wsu:Expires>"
|
|
376
|
+
"</wsu:Timestamp>"
|
|
377
|
+
)
|
|
378
|
+
token = self._build_username_token(created) if self.ws_username else ""
|
|
379
|
+
return f'<wsse:Security soap:mustUnderstand="true">{timestamp}{token}</wsse:Security>'
|
|
380
|
+
|
|
381
|
+
def _build_username_token(self, created: str) -> str:
|
|
382
|
+
username = _xml_escape(self.ws_username or "")
|
|
383
|
+
if self.ws_password_type == "digest": # nosec B105 — a WS-Security password *type*, not a secret
|
|
384
|
+
nonce = self._nonce_fn()
|
|
385
|
+
# Legacy WS-Security UsernameToken digest = Base64(SHA1(Nonce + Created + Password)). This
|
|
386
|
+
# is the spec's token construction, NOT a message-integrity signature (SHA1 here is the
|
|
387
|
+
# profile's defined hash; XML-DSig is deferred — ADR 0015 §4a).
|
|
388
|
+
digest = base64.b64encode(
|
|
389
|
+
hashlib.sha1( # noqa: S324 # nosec B324 — WS-Security UsernameToken profile, not integrity
|
|
390
|
+
nonce + created.encode() + (self.ws_password or "").encode()
|
|
391
|
+
).digest()
|
|
392
|
+
).decode("ascii")
|
|
393
|
+
nonce_b64 = base64.b64encode(nonce).decode("ascii")
|
|
394
|
+
return (
|
|
395
|
+
f"<wsse:UsernameToken><wsse:Username>{username}</wsse:Username>"
|
|
396
|
+
f'<wsse:Password Type="{_PW_DIGEST}">{_xml_escape(digest)}</wsse:Password>'
|
|
397
|
+
f'<wsse:Nonce EncodingType="{_NONCE_ENC}">{nonce_b64}</wsse:Nonce>'
|
|
398
|
+
f"<wsu:Created>{created}</wsu:Created></wsse:UsernameToken>"
|
|
399
|
+
)
|
|
400
|
+
password = _xml_escape(self.ws_password or "")
|
|
401
|
+
return (
|
|
402
|
+
f"<wsse:UsernameToken><wsse:Username>{username}</wsse:Username>"
|
|
403
|
+
f'<wsse:Password Type="{_PW_TEXT}">{password}</wsse:Password></wsse:UsernameToken>'
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def _wrap_envelope(self, body_fragment: str) -> str:
|
|
407
|
+
"""Wrap the Handler's operation ``<Body>`` fragment in a transport-built envelope + stamped
|
|
408
|
+
``<Header>`` (ADR 0015 §2). The fragment is well-formedness-checked and lint-screened first."""
|
|
409
|
+
_assert_well_formed_fragment(body_fragment)
|
|
410
|
+
_reject_ws_leak(body_fragment)
|
|
411
|
+
now = self._now_fn()
|
|
412
|
+
header = ""
|
|
413
|
+
if self.ws_addressing:
|
|
414
|
+
header += self._build_wsa_header()
|
|
415
|
+
if self.ws_security:
|
|
416
|
+
header += self._build_wsse_header(now)
|
|
417
|
+
return (
|
|
418
|
+
_ENV_OPEN
|
|
419
|
+
+ f"<soap:Header>{header}</soap:Header>"
|
|
420
|
+
+ f"<soap:Body>{body_fragment}</soap:Body>"
|
|
421
|
+
+ "</soap:Envelope>"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
async def send(self, payload: str) -> DeliveryResponse | None:
|
|
425
|
+
# WS-* mode: the Handler returned only the <Body> fragment; wrap + stamp here (post-queue
|
|
426
|
+
# boundary), so the per-call MessageID/Timestamp/Nonce never live in a pure transform.
|
|
427
|
+
if self._ws_mode:
|
|
428
|
+
payload = self._wrap_envelope(payload)
|
|
429
|
+
body, status = await asyncio.to_thread(self._post, payload)
|
|
430
|
+
# A fault can arrive inside a 2xx body, so classify it. Transport-level faults (non-2xx,
|
|
431
|
+
# URL/timeout) already raised inside _post, for both modes.
|
|
432
|
+
failure = _classify_soap(status, body)
|
|
433
|
+
if not self.capture_response:
|
|
434
|
+
if failure is not None:
|
|
435
|
+
raise failure # byte-identical: a 2xx <Fault> still dead-letters/retries
|
|
436
|
+
return None
|
|
437
|
+
if failure is not None:
|
|
438
|
+
# Capturing: record the application <Fault> as a rejected reply rather than raising, so the
|
|
439
|
+
# row is delivered-with-a-rejection (operators reconcile from the captured response).
|
|
440
|
+
return DeliveryResponse(
|
|
441
|
+
body=body, outcome="rejected", detail=f"SOAP fault (HTTP {status})"
|
|
442
|
+
)
|
|
443
|
+
if not body:
|
|
444
|
+
return DeliveryResponse(body="", outcome="no_reply", detail=f"HTTP {status}")
|
|
445
|
+
return DeliveryResponse(body=body, outcome="accepted", detail=f"HTTP {status}")
|
|
446
|
+
|
|
447
|
+
async def test_connection(self) -> None:
|
|
448
|
+
await asyncio.to_thread(self._probe)
|
|
449
|
+
|
|
450
|
+
def _probe(self) -> None:
|
|
451
|
+
# Reachability only: a HEAD reaches the endpoint without POSTing an envelope. An HTTP response
|
|
452
|
+
# means the host answered (a 405 is still a pass), but a 401/403 means the configured
|
|
453
|
+
# credentials would be rejected — surface that as a failure. Connection/DNS/TLS/timeout fails.
|
|
454
|
+
req = urllib.request.Request( # noqa: S310 # nosec B310 — scheme constrained to http(s) in __init__
|
|
455
|
+
self.url, headers=self._headers, method="HEAD"
|
|
456
|
+
)
|
|
457
|
+
try:
|
|
458
|
+
with self._opener.open(req, timeout=self.timeout) as resp:
|
|
459
|
+
resp.read()
|
|
460
|
+
except urllib.error.HTTPError as exc:
|
|
461
|
+
if exc.code in (401, 403):
|
|
462
|
+
raise DeliveryError(
|
|
463
|
+
f"SOAP {_redact_url(self.url)} returned HTTP {exc.code} (check credentials)"
|
|
464
|
+
) from exc
|
|
465
|
+
return # any other status (the host answered) → reachable
|
|
466
|
+
except urllib.error.URLError as exc:
|
|
467
|
+
raise DeliveryError(f"SOAP {_redact_url(self.url)} unreachable: {exc.reason}") from exc
|
|
468
|
+
except (TimeoutError, OSError) as exc:
|
|
469
|
+
raise DeliveryError(f"SOAP {_redact_url(self.url)} failed: {exc}") from exc
|
|
470
|
+
|
|
471
|
+
def _post(self, payload: str) -> tuple[str, int]:
|
|
472
|
+
# payload is the FINAL wire body (in WS-* mode send() already wrapped + stamped the envelope),
|
|
473
|
+
# so signing over these bytes covers exactly what the partner receives.
|
|
474
|
+
data = payload.encode(self.encoding)
|
|
475
|
+
headers = self._headers
|
|
476
|
+
if self._signer is not None:
|
|
477
|
+
# ASVS 4.1.5 (ADR 0018): detached JWS over the envelope, minted off-loop past the queue
|
|
478
|
+
# boundary so a retry re-mints it (re-run purity holds, like the WS-Security nonce).
|
|
479
|
+
headers = {**self._headers, **self._signer.signature_headers(data)}
|
|
480
|
+
req = urllib.request.Request( # noqa: S310 # nosec B310 — scheme constrained to http(s) in __init__
|
|
481
|
+
self.url,
|
|
482
|
+
data=data,
|
|
483
|
+
headers=headers,
|
|
484
|
+
method="POST",
|
|
485
|
+
)
|
|
486
|
+
try:
|
|
487
|
+
with self._opener.open(req, timeout=self.timeout) as resp:
|
|
488
|
+
body = resp.read().decode(self.encoding, errors="replace")
|
|
489
|
+
status = int(getattr(resp, "status", 200))
|
|
490
|
+
return body, status
|
|
491
|
+
except urllib.error.HTTPError as exc:
|
|
492
|
+
try:
|
|
493
|
+
body = exc.read().decode(self.encoding, errors="replace")
|
|
494
|
+
except Exception: # noqa: BLE001 - a body we can't read just becomes status-only
|
|
495
|
+
body = ""
|
|
496
|
+
# A non-2xx status: _classify_soap always returns a failure here (it returns None only on
|
|
497
|
+
# 2xx). This is a transport-level fault — raised for BOTH capturing and non-capturing.
|
|
498
|
+
raise (
|
|
499
|
+
_classify_soap(exc.code, body) or DeliveryError(f"SOAP HTTP {exc.code}")
|
|
500
|
+
) from exc
|
|
501
|
+
except urllib.error.URLError as exc:
|
|
502
|
+
raise DeliveryError(f"SOAP {_redact_url(self.url)} unreachable: {exc.reason}") from exc
|
|
503
|
+
except (TimeoutError, OSError) as exc:
|
|
504
|
+
raise DeliveryError(f"SOAP {_redact_url(self.url)} failed: {exc}") from exc
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
register_destination(ConnectorType.SOAP, SoapDestination)
|