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,153 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Password-strength and account-lockout policy (ASVS 6.2.x).
|
|
4
|
+
|
|
5
|
+
Modernized per ASVS 5.0 (WP-3): length-first (15+), **no mandatory character-class composition**
|
|
6
|
+
(the class rules are kept as *opt-in* knobs, default off), plus **offline breached/common-password
|
|
7
|
+
screening**, a small **context-word deny-list** (app/vendor/HL7 terms), and **username-in-password
|
|
8
|
+
rejection** (6.2.11). Defaults remain a direct improvement on Mirth, whose password requirements
|
|
9
|
+
default to zero. Operators tune these via the ``[auth]`` settings section.
|
|
10
|
+
|
|
11
|
+
The breach corpus is a bundled offline top-10k common-password list (see ``data/common_passwords.txt``
|
|
12
|
+
+ its ``.NOTICE``); the check is a case-insensitive set membership — no network/live-HIBP call.
|
|
13
|
+
Operators can widen it with an offline ``breach_corpus_file`` (6.2.12) — a plaintext list **or** an
|
|
14
|
+
HIBP-style SHA-1-hash export (``HASH[:count]`` lines, auto-detected), still fully offline. (True HIBP
|
|
15
|
+
k-anonymity needs a live range query, which this on-prem engine deliberately doesn't make.)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import functools
|
|
21
|
+
import hashlib
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from importlib.resources import files
|
|
25
|
+
|
|
26
|
+
#: Shortest username we'll substring-match inside a password — below this the false-positive risk on a
|
|
27
|
+
#: legitimate long passphrase outweighs the value (a 2-3 char username fragment is too common).
|
|
28
|
+
_MIN_USERNAME_MATCH = 4
|
|
29
|
+
|
|
30
|
+
#: A line in an HIBP-style SHA-1 export: 40 hex chars, optionally ``:<count>``. Used to auto-detect a
|
|
31
|
+
#: hashed corpus from its first entry.
|
|
32
|
+
_HASH_LINE = re.compile(r"[0-9A-Fa-f]{40}(:\d+)?")
|
|
33
|
+
|
|
34
|
+
#: App/vendor/protocol terms a local password must not *contain* (case-insensitive) — so an obvious
|
|
35
|
+
#: in-context credential like ``messagefoundry2026`` or ``Mefor-Admin!`` is rejected (ASVS 6.2.5).
|
|
36
|
+
#: Deliberately app-specific (not a generic dictionary) to keep false-positives rare; the broader
|
|
37
|
+
#: "common word" coverage comes from the breach corpus.
|
|
38
|
+
CONTEXT_WORDS: frozenset[str] = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"messagefoundry",
|
|
41
|
+
"mefor",
|
|
42
|
+
"mllp",
|
|
43
|
+
"hl7",
|
|
44
|
+
"corepoint",
|
|
45
|
+
"mirth",
|
|
46
|
+
"rhapsody",
|
|
47
|
+
"changeme",
|
|
48
|
+
"bootstrap",
|
|
49
|
+
"admin",
|
|
50
|
+
"administrator",
|
|
51
|
+
"password",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@functools.lru_cache(maxsize=1)
|
|
57
|
+
def _common_passwords() -> frozenset[str]:
|
|
58
|
+
"""The bundled offline common/breached-password set (lower-cased), loaded once and cached."""
|
|
59
|
+
data = (files("messagefoundry.auth") / "data" / "common_passwords.txt").read_bytes()
|
|
60
|
+
text = data.decode("utf-8", "ignore")
|
|
61
|
+
return frozenset(line.strip().lower() for line in text.splitlines() if line.strip())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@functools.lru_cache(maxsize=4)
|
|
65
|
+
def _operator_corpus(path: str) -> tuple[frozenset[str], bool]:
|
|
66
|
+
"""Load an operator-supplied offline breach corpus, returning ``(entries, hashed)``. Format is
|
|
67
|
+
auto-detected from the first non-empty line: an HIBP-style SHA-1 export (``HASH[:count]``) is
|
|
68
|
+
stored as upper-hex hashes (``hashed=True``); anything else is a plaintext list stored lower-cased.
|
|
69
|
+
Loaded once per path and cached. Raises ``OSError`` if the file can't be read (the caller degrades
|
|
70
|
+
gracefully; a configured-but-unreadable corpus is warned about at startup)."""
|
|
71
|
+
entries: set[str] = set()
|
|
72
|
+
hashed: bool | None = None
|
|
73
|
+
with open(path, encoding="utf-8", errors="ignore") as handle:
|
|
74
|
+
for raw in handle:
|
|
75
|
+
line = raw.strip()
|
|
76
|
+
if not line:
|
|
77
|
+
continue
|
|
78
|
+
if hashed is None: # detect format from the first real entry
|
|
79
|
+
hashed = _HASH_LINE.fullmatch(line) is not None
|
|
80
|
+
entries.add(line.split(":", 1)[0].upper() if hashed else line.lower())
|
|
81
|
+
return frozenset(entries), bool(hashed)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class PasswordPolicy:
|
|
86
|
+
"""Rules applied to *local* passwords (AD passwords are governed by the directory).
|
|
87
|
+
|
|
88
|
+
ASVS-aligned defaults: a 15-char minimum, **no** mandatory character classes (the ``require_*``
|
|
89
|
+
flags are opt-in, default off), and breach + context screening on. ``violations`` is the single
|
|
90
|
+
enforcement point — used on both create-user and change-password.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
min_length: int = 15
|
|
94
|
+
require_uppercase: bool = False
|
|
95
|
+
require_lowercase: bool = False
|
|
96
|
+
require_digit: bool = False
|
|
97
|
+
require_symbol: bool = False
|
|
98
|
+
check_breached: bool = True # reject known common/breached passwords (offline corpus)
|
|
99
|
+
check_context: bool = True # reject passwords containing app/vendor/HL7 terms
|
|
100
|
+
check_username: bool = True # reject passwords containing the user's own username (6.2.11)
|
|
101
|
+
breach_corpus_file: str | None = None # optional operator-supplied offline corpus (6.2.12)
|
|
102
|
+
lockout_threshold: int = 5 # consecutive failed logins before the account locks
|
|
103
|
+
lockout_minutes: int = 15 # how long a locked account stays locked
|
|
104
|
+
|
|
105
|
+
def violations(self, password: str, *, username: str | None = None) -> list[str]:
|
|
106
|
+
"""Return clauses completing *"password must …"*; an empty list means the password is
|
|
107
|
+
acceptable. Order: length → opt-in character classes → breach → username → context.
|
|
108
|
+
|
|
109
|
+
``username`` enables the 6.2.11 own-username check (omit it where there is no user context,
|
|
110
|
+
e.g. generating the bootstrap password)."""
|
|
111
|
+
problems: list[str] = []
|
|
112
|
+
if len(password) < self.min_length:
|
|
113
|
+
problems.append(f"be at least {self.min_length} characters")
|
|
114
|
+
if self.require_uppercase and not any(c.isupper() for c in password):
|
|
115
|
+
problems.append("contain an uppercase letter")
|
|
116
|
+
if self.require_lowercase and not any(c.islower() for c in password):
|
|
117
|
+
problems.append("contain a lowercase letter")
|
|
118
|
+
if self.require_digit and not any(c.isdigit() for c in password):
|
|
119
|
+
problems.append("contain a digit")
|
|
120
|
+
if self.require_symbol and all(c.isalnum() for c in password):
|
|
121
|
+
problems.append("contain a symbol")
|
|
122
|
+
lowered = password.lower()
|
|
123
|
+
if self.check_breached and (
|
|
124
|
+
lowered in _common_passwords() or self._in_operator_corpus(password)
|
|
125
|
+
):
|
|
126
|
+
problems.append("not be a common or breached password")
|
|
127
|
+
if (
|
|
128
|
+
self.check_username
|
|
129
|
+
and username
|
|
130
|
+
and len(username) >= _MIN_USERNAME_MATCH
|
|
131
|
+
and username.lower() in lowered
|
|
132
|
+
):
|
|
133
|
+
problems.append("not contain your username")
|
|
134
|
+
if self.check_context and any(word in lowered for word in CONTEXT_WORDS):
|
|
135
|
+
problems.append("not contain application or vendor terms")
|
|
136
|
+
return problems
|
|
137
|
+
|
|
138
|
+
def _in_operator_corpus(self, password: str) -> bool:
|
|
139
|
+
"""Whether ``password`` is in the operator-supplied corpus (if one is configured). Best-effort:
|
|
140
|
+
a missing/unreadable corpus file returns ``False`` rather than breaking a password change — the
|
|
141
|
+
misconfiguration is warned about once at startup (see ``AuthService``)."""
|
|
142
|
+
if not self.breach_corpus_file:
|
|
143
|
+
return False
|
|
144
|
+
try:
|
|
145
|
+
entries, hashed = _operator_corpus(self.breach_corpus_file)
|
|
146
|
+
except OSError:
|
|
147
|
+
return False
|
|
148
|
+
if hashed:
|
|
149
|
+
digest = (
|
|
150
|
+
hashlib.sha1(password.encode("utf-8"), usedforsecurity=False).hexdigest().upper()
|
|
151
|
+
)
|
|
152
|
+
return digest in entries
|
|
153
|
+
return password.lower() in entries
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""In-process sliding-window rate limiter for the unauthenticated auth surface (AUTH-RATE).
|
|
4
|
+
|
|
5
|
+
Bounds brute-force / password-spray and argon2 CPU-burn on ``/auth/login`` and friends *ahead*
|
|
6
|
+
of the per-account lockout (which a spray across many usernames never trips). It is in-process and
|
|
7
|
+
per-app, **not** distributed — an exposed or multi-host deployment must additionally front the API
|
|
8
|
+
with a proxy/WAF limiter. Decisions use ``time.monotonic()`` so a wall-clock step can't widen the
|
|
9
|
+
window. Calls are synchronous and complete without ``await``, so they're atomic on the event loop.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from collections import deque
|
|
16
|
+
|
|
17
|
+
__all__ = ["SlidingWindowRateLimiter"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SlidingWindowRateLimiter:
|
|
21
|
+
"""Allow up to ``per_key`` hits per key and ``glob`` hits overall within ``window_seconds``.
|
|
22
|
+
|
|
23
|
+
A falsy ``per_key``/``glob`` disables that dimension. Empty per-key buckets are dropped as they
|
|
24
|
+
age out, so memory is bounded by the number of *active* keys in the window.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, per_key: int, glob: int, window_seconds: float = 60.0) -> None:
|
|
28
|
+
self._per_key = per_key
|
|
29
|
+
self._global = glob
|
|
30
|
+
self._window = window_seconds
|
|
31
|
+
self._hits: dict[str, deque[float]] = {}
|
|
32
|
+
self._global_hits: deque[float] = deque()
|
|
33
|
+
|
|
34
|
+
def _prune(self, dq: deque[float], now: float) -> None:
|
|
35
|
+
cutoff = now - self._window
|
|
36
|
+
while dq and dq[0] <= cutoff:
|
|
37
|
+
dq.popleft()
|
|
38
|
+
|
|
39
|
+
def allow(self, key: str) -> bool:
|
|
40
|
+
"""Record and allow an attempt for ``key``, or return ``False`` if it would exceed a limit."""
|
|
41
|
+
now = time.monotonic()
|
|
42
|
+
self._prune(self._global_hits, now)
|
|
43
|
+
bucket = self._hits.get(key)
|
|
44
|
+
if bucket is not None:
|
|
45
|
+
self._prune(bucket, now)
|
|
46
|
+
if not bucket:
|
|
47
|
+
del self._hits[key]
|
|
48
|
+
bucket = None
|
|
49
|
+
global_full = bool(self._global) and len(self._global_hits) >= self._global
|
|
50
|
+
key_full = bucket is not None and bool(self._per_key) and len(bucket) >= self._per_key
|
|
51
|
+
if global_full or key_full:
|
|
52
|
+
return False # a rejected attempt does not count toward the window
|
|
53
|
+
self._global_hits.append(now)
|
|
54
|
+
self._hits.setdefault(key, deque()).append(now)
|
|
55
|
+
return True
|