dotenvmodel 0.3.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.
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/workflows/ci.yml +1 -1
- dotenvmodel-0.3.1/.release-please-manifest.json +3 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/CHANGELOG.md +7 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/PKG-INFO +1 -1
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/__init__.py +1 -1
- dotenvmodel-0.3.1/dotenvmodel/_redaction.py +260 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/describe/formatters.py +26 -2
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/exceptions.py +13 -4
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/types.py +14 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/validation.py +28 -15
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/pyproject.toml +1 -1
- dotenvmodel-0.3.1/tests/test_secret_redaction.py +632 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/uv.lock +1 -1
- dotenvmodel-0.3.0/.release-please-manifest.json +0 -3
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/CODEOWNERS +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/dependabot.yml +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/pull_request_template.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/workflows/docs.yml +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/workflows/publish.yml +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/workflows/release-please.yml +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.gitignore +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.python-version +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/CODE_OF_CONDUCT.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/CONTRIBUTING.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/LICENSE +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/Makefile +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/README.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/SECURITY.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/coercion.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/config.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/constants.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/describe-formatters.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/describe-renderers.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/describe.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/dotenvmodel.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/exceptions.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/fields.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/loading.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/logging-config.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/metaclass.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/types.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/validation.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/changelog.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/examples/complete-app.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/getting-started/installation.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/getting-started/quick-start.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/configuration-docs.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/error-handling.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/fields.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/loading.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/logging.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/prefixes.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/types.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/validation.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/index.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/_constants.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/coercion.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/config.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/describe/__init__.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/describe/renderers.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/fields.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/loading.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/logging_config.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/metaclass.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/py.typed +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/examples/advanced_types.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/examples/basic_usage.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/examples/describe_documentation.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/examples/generate_env_example.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/examples/logging_example.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/mkdocs.yml +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/release-please-config.json +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/__init__.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/conftest.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_basic.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_coercion.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_collection_validators.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_describe.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_adoption.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_datetime.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_describe_output.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_enum.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_field_options.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_numbers.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_path_options.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_strings.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_empty_collection_items.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_empty_strings.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_enum.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_errors.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_field_constraints.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_future_annotations.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_inheritance.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_json.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_json_and_optional.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_loading.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_logging_config.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_prefix.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_reload.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_secretstr_security.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_types.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_union_types.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_url_dsn.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_url_password_decoding.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_url_unquote.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_urls.py +0 -0
|
@@ -133,7 +133,7 @@ jobs:
|
|
|
133
133
|
cd /tmp && $GITHUB_WORKSPACE/test-src-env/bin/python -c "import dotenvmodel; print(dotenvmodel.__version__)"
|
|
134
134
|
|
|
135
135
|
- name: Upload build artifacts
|
|
136
|
-
uses: actions/upload-artifact@
|
|
136
|
+
uses: actions/upload-artifact@v7
|
|
137
137
|
with:
|
|
138
138
|
name: dist
|
|
139
139
|
path: dist/
|
|
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.1](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.0...v0.3.1) (2026-07-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* redact credentials in DSN repr, errors, docs, and secret exception chains ([#29](https://github.com/AZX-PBC-OSS/dotenvmodel/issues/29)) ([cfdd218](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/cfdd218ff157d41f7af06c1beaf4c539080fa2dd))
|
|
14
|
+
|
|
8
15
|
## [0.3.0](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.2.0...v0.3.0) (2026-07-09)
|
|
9
16
|
|
|
10
17
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dotenvmodel
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Type-safe environment configuration with automatic .env file loading
|
|
5
5
|
Project-URL: Homepage, https://github.com/AZX-PBC-OSS/dotenvmodel
|
|
6
6
|
Project-URL: Repository, https://github.com/AZX-PBC-OSS/dotenvmodel
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Credential-redaction helpers shared across display paths.
|
|
2
|
+
|
|
3
|
+
Kept dependency-free (stdlib only) so both ``exceptions`` and ``types`` can
|
|
4
|
+
import it without creating an import cycle.
|
|
5
|
+
|
|
6
|
+
Masking philosophy — deliberately asymmetric. These helpers run only on
|
|
7
|
+
error/describe/``repr`` display paths, where over-masking a benign value
|
|
8
|
+
(``sort_key=name`` shown as ``sort_key=***``) costs a little debug context,
|
|
9
|
+
while under-masking leaks a credential. So the classifier keeps NO benign
|
|
10
|
+
carve-out lists: any key with a secret-word token is masked, and the word
|
|
11
|
+
list is the single place to maintain.
|
|
12
|
+
|
|
13
|
+
Known, documented non-goals (use ``SecretStr`` for these):
|
|
14
|
+
- secrets embedded in a URL *path* segment (Slack webhooks, ``/bot<token>/``);
|
|
15
|
+
- plain non-URL secret values and secrets inside containers/JSON bodies;
|
|
16
|
+
- ``user:pw@host`` shapes buried mid-string (only a leading one is masked).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from urllib.parse import unquote_plus, urlparse
|
|
21
|
+
|
|
22
|
+
__all__ = ["redact_url_password"]
|
|
23
|
+
|
|
24
|
+
_MASK = "***"
|
|
25
|
+
|
|
26
|
+
# A key is sensitive when ANY of its separator/camelCase tokens is one of
|
|
27
|
+
# these words, or the separator-stripped whole key matches (``session_id`` ->
|
|
28
|
+
# ``sessionid``, ``connection_string`` -> ``connectionstring``). Exact token
|
|
29
|
+
# equality keeps lookalikes benign: ``keyword``/``monkey``/``authors``/
|
|
30
|
+
# ``oauth``/``bypass``/``passwordless`` never match.
|
|
31
|
+
_SECRET_WORDS = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"password",
|
|
34
|
+
"passwd",
|
|
35
|
+
"pwd",
|
|
36
|
+
"pass",
|
|
37
|
+
"passphrase",
|
|
38
|
+
"passcode",
|
|
39
|
+
"secret",
|
|
40
|
+
"credential",
|
|
41
|
+
"credentials",
|
|
42
|
+
"apikey",
|
|
43
|
+
"key",
|
|
44
|
+
"token",
|
|
45
|
+
"auth",
|
|
46
|
+
"authorization",
|
|
47
|
+
"assertion",
|
|
48
|
+
"signature",
|
|
49
|
+
"sig",
|
|
50
|
+
"hmac",
|
|
51
|
+
"jwt",
|
|
52
|
+
"bearer",
|
|
53
|
+
"sessionid",
|
|
54
|
+
"jsessionid",
|
|
55
|
+
"phpsessid",
|
|
56
|
+
"sid",
|
|
57
|
+
"session",
|
|
58
|
+
"otp",
|
|
59
|
+
"totp",
|
|
60
|
+
"sas",
|
|
61
|
+
"saml",
|
|
62
|
+
"verifier",
|
|
63
|
+
"challenge",
|
|
64
|
+
"pin",
|
|
65
|
+
"cvv",
|
|
66
|
+
"cvc",
|
|
67
|
+
"pat",
|
|
68
|
+
"license",
|
|
69
|
+
"dsn",
|
|
70
|
+
"connectionstring",
|
|
71
|
+
"connstr",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Credential only as the ENTIRE key: OAuth's bare ``?code=`` masks, while
|
|
76
|
+
# ``status_code``/``error_code`` (debug-critical) stay visible.
|
|
77
|
+
_SECRET_WHOLE_ONLY = frozenset({"code"})
|
|
78
|
+
|
|
79
|
+
# Glued (separator-less) suffixes, e.g. ``sslpassword`` / ``xapikey``.
|
|
80
|
+
_SECRET_SUFFIXES = (
|
|
81
|
+
"password",
|
|
82
|
+
"passphrase",
|
|
83
|
+
"passcode",
|
|
84
|
+
"signature",
|
|
85
|
+
"apikey",
|
|
86
|
+
"connectionstring",
|
|
87
|
+
"connstr",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Prefixes that make a glued (separator-less) ``*key``/``*token``/``*secret``
|
|
91
|
+
# compound a credential: ``secretkey``, ``accesstoken``, ``clientsecret``.
|
|
92
|
+
_CREDENTIAL_PREFIXES = frozenset(
|
|
93
|
+
{
|
|
94
|
+
"secret",
|
|
95
|
+
"access",
|
|
96
|
+
"private",
|
|
97
|
+
"api",
|
|
98
|
+
"client",
|
|
99
|
+
"signing",
|
|
100
|
+
"encryption",
|
|
101
|
+
"refresh",
|
|
102
|
+
"session",
|
|
103
|
+
"shared",
|
|
104
|
+
"master",
|
|
105
|
+
"auth",
|
|
106
|
+
"bearer",
|
|
107
|
+
"consumer",
|
|
108
|
+
"sas",
|
|
109
|
+
"ssl",
|
|
110
|
+
"account",
|
|
111
|
+
"storage",
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Split camelCase and ACRONYMBoundaries (HMACKey -> HMAC, Key).
|
|
116
|
+
_CAMEL = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
117
|
+
_SEPARATORS = re.compile(r"[-_.]+")
|
|
118
|
+
# Fallback for unparseable URLs: mask the userinfo password (scheme://user:PW@).
|
|
119
|
+
# The password class allows '@'/':' and backtracks to the last '@' before the
|
|
120
|
+
# path, so an unescaped '@' in the password is still fully masked.
|
|
121
|
+
_USERINFO_PW = re.compile(r"(://[^:/?#@\s]*:)[^/?#\s]+(@)")
|
|
122
|
+
# Scheme-less userinfo (the forgot-the-scheme typo: ``dbuser:pw@host/db``).
|
|
123
|
+
# urlparse reads ``dbuser`` as the scheme there, so the netloc path never
|
|
124
|
+
# fires; anchored at the start so prose with colons is left alone.
|
|
125
|
+
_SCHEMELESS_PW = re.compile(r"^([^:/?#@\s]+:)[^/?#\s]+(@)(?=[^/?#\s@]*[/:])")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_sensitive_key(key: str) -> bool:
|
|
129
|
+
"""True if a query/fragment/params key names a credential.
|
|
130
|
+
|
|
131
|
+
Any secret-word token masks (``access_token``, ``db_pass``,
|
|
132
|
+
``api_key_v2beta`` — the qualifier tokens don't hide the match), plus a
|
|
133
|
+
stripped-whole-key check for separator-spanning words and a suffix/prefix
|
|
134
|
+
check for glued forms like ``sslpassword``/``secretkey``. No benign
|
|
135
|
+
carve-outs: ``sort_key``/``page_token`` mask too, by design (see module
|
|
136
|
+
docstring).
|
|
137
|
+
"""
|
|
138
|
+
raw = unquote_plus(key).strip()
|
|
139
|
+
if not raw:
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
k = _CAMEL.sub("_", raw).lower()
|
|
143
|
+
tokens = [t for t in _SEPARATORS.split(k) if t]
|
|
144
|
+
if not tokens:
|
|
145
|
+
return False
|
|
146
|
+
stripped = "".join(tokens)
|
|
147
|
+
|
|
148
|
+
if stripped in _SECRET_WHOLE_ONLY:
|
|
149
|
+
return True
|
|
150
|
+
# A secret-word token (or its plural: passwords/tokens/keys).
|
|
151
|
+
if any(t in _SECRET_WORDS or (t.endswith("s") and t[:-1] in _SECRET_WORDS) for t in tokens):
|
|
152
|
+
return True
|
|
153
|
+
# A secret word spanning separators (session_id -> sessionid).
|
|
154
|
+
if stripped in _SECRET_WORDS:
|
|
155
|
+
return True
|
|
156
|
+
if _is_glued_credential(stripped):
|
|
157
|
+
return True
|
|
158
|
+
return any(stripped.endswith(sfx) for sfx in _SECRET_SUFFIXES)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _is_glued_credential(stripped: str) -> bool:
|
|
162
|
+
"""True for separator-less compounds like ``secretkey`` / ``accesstoken``."""
|
|
163
|
+
for suffix in ("key", "token", "secret", "password"):
|
|
164
|
+
if stripped.endswith(suffix) and len(stripped) > len(suffix):
|
|
165
|
+
prefix = stripped[: -len(suffix)]
|
|
166
|
+
# ``secretaccesskey`` -> prefix ``secretaccess`` starts with ``secret``.
|
|
167
|
+
if any(prefix.startswith(p) for p in _CREDENTIAL_PREFIXES):
|
|
168
|
+
return True
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _redact_pairs(component: str) -> tuple[str, bool]:
|
|
173
|
+
"""Mask sensitive values in an ``&``-separated key=value string.
|
|
174
|
+
|
|
175
|
+
Per the WHATWG URL standard, ``&`` is the only separator; a ``;`` is data
|
|
176
|
+
and stays inside its value. A sensitive key masks its whole value
|
|
177
|
+
(including any ``;`` tail). Defensively, a legacy ``;``-glued sub-pair
|
|
178
|
+
inside a *benign* key's value (``db=0;password=x``) is also masked, pair
|
|
179
|
+
by pair. Non-sensitive pairs are preserved byte-for-byte. Returns the
|
|
180
|
+
(possibly rewritten) component and whether anything changed.
|
|
181
|
+
"""
|
|
182
|
+
changed = False
|
|
183
|
+
parts = component.split("&")
|
|
184
|
+
for i, part in enumerate(parts):
|
|
185
|
+
key, sep, value = part.partition("=")
|
|
186
|
+
if sep and value and _is_sensitive_key(key):
|
|
187
|
+
parts[i] = f"{key}={_MASK}"
|
|
188
|
+
changed = True
|
|
189
|
+
continue
|
|
190
|
+
if ";" in part:
|
|
191
|
+
subs = part.split(";")
|
|
192
|
+
sub_changed = False
|
|
193
|
+
for j, sub in enumerate(subs):
|
|
194
|
+
skey, ssep, svalue = sub.partition("=")
|
|
195
|
+
if ssep and svalue and _is_sensitive_key(skey):
|
|
196
|
+
subs[j] = f"{skey}={_MASK}"
|
|
197
|
+
sub_changed = True
|
|
198
|
+
if sub_changed:
|
|
199
|
+
parts[i] = ";".join(subs)
|
|
200
|
+
changed = True
|
|
201
|
+
return ("&".join(parts), changed) if changed else (component, False)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def redact_url_password(value: str) -> str:
|
|
205
|
+
"""Return ``value`` with any URL credential replaced by ``***``.
|
|
206
|
+
|
|
207
|
+
Masks the userinfo password (``user:pass@host`` -> ``user:***@host``,
|
|
208
|
+
including the scheme-less typo form ``user:pass@host`` with no ``//``)
|
|
209
|
+
and the value of any sensitive key in the query string, fragment, or
|
|
210
|
+
``;``-params component (e.g. ``?password=`` / ``#access_token=`` /
|
|
211
|
+
``/path;password=``). Host, port, and non-sensitive parameters are
|
|
212
|
+
preserved verbatim. A string without any credential-shaped part is
|
|
213
|
+
returned unchanged, so this is safe to call on arbitrary values.
|
|
214
|
+
"""
|
|
215
|
+
# Scheme-less userinfo: urlparse would read the username as the scheme, so
|
|
216
|
+
# mask a leading `user:pw@host/…` up front (anchored + a path/port lookahead
|
|
217
|
+
# so `https://…` and email-shaped `cc:john@example.com` are left alone). Done
|
|
218
|
+
# unconditionally so a later `://` (e.g. in a redirect param) can't skip it.
|
|
219
|
+
prefix_masked = _SCHEMELESS_PW.sub(rf"\1{_MASK}\2", value)
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
parsed = urlparse(prefix_masked)
|
|
223
|
+
except ValueError:
|
|
224
|
+
# Malformed URL (e.g. bad IPv6). urlparse can't help, but neither the
|
|
225
|
+
# userinfo password nor a query/fragment secret may leak, so mask the
|
|
226
|
+
# userinfo by regex and redact the (splittable) query and fragment.
|
|
227
|
+
masked = _USERINFO_PW.sub(rf"\1{_MASK}\2", prefix_masked)
|
|
228
|
+
head, hsep, frag = masked.partition("#")
|
|
229
|
+
head, qsep, query = head.partition("?")
|
|
230
|
+
head, psep, params = head.partition(";")
|
|
231
|
+
if psep:
|
|
232
|
+
params = _redact_pairs(params)[0]
|
|
233
|
+
if qsep:
|
|
234
|
+
query = _redact_pairs(query)[0]
|
|
235
|
+
if hsep:
|
|
236
|
+
frag = _redact_pairs(frag)[0]
|
|
237
|
+
return head + psep + params + qsep + query + hsep + frag
|
|
238
|
+
|
|
239
|
+
netloc = parsed.netloc
|
|
240
|
+
changed = prefix_masked != value
|
|
241
|
+
|
|
242
|
+
if parsed.password:
|
|
243
|
+
userinfo, _, hostpart = netloc.rpartition("@")
|
|
244
|
+
username = userinfo.partition(":")[0]
|
|
245
|
+
netloc = f"{username}:{_MASK}@{hostpart}"
|
|
246
|
+
changed = True
|
|
247
|
+
|
|
248
|
+
params, params_changed = (
|
|
249
|
+
_redact_pairs(parsed.params) if parsed.params else (parsed.params, False)
|
|
250
|
+
)
|
|
251
|
+
query, query_changed = _redact_pairs(parsed.query) if parsed.query else (parsed.query, False)
|
|
252
|
+
fragment, frag_changed = (
|
|
253
|
+
_redact_pairs(parsed.fragment) if parsed.fragment else (parsed.fragment, False)
|
|
254
|
+
)
|
|
255
|
+
changed = changed or params_changed or query_changed or frag_changed
|
|
256
|
+
|
|
257
|
+
if not changed:
|
|
258
|
+
return value
|
|
259
|
+
|
|
260
|
+
return parsed._replace(netloc=netloc, params=params, query=query, fragment=fragment).geturl()
|
|
@@ -13,8 +13,9 @@ from typing import Any, Union, get_args, get_origin
|
|
|
13
13
|
|
|
14
14
|
from typing_extensions import TypeForm
|
|
15
15
|
|
|
16
|
+
from dotenvmodel._redaction import redact_url_password
|
|
16
17
|
from dotenvmodel.fields import _MISSING, FieldInfo
|
|
17
|
-
from dotenvmodel.types import SecretStr
|
|
18
|
+
from dotenvmodel.types import BaseDsn, SecretStr
|
|
18
19
|
|
|
19
20
|
# Maximum column widths to prevent unbounded table growth
|
|
20
21
|
MAX_WIDTHS = {
|
|
@@ -317,6 +318,23 @@ def format_constraints(
|
|
|
317
318
|
return ", ".join(constraints) if constraints else "-"
|
|
318
319
|
|
|
319
320
|
|
|
321
|
+
def _union_members(field_type: TypeForm[Any]) -> list[Any]:
|
|
322
|
+
"""Return the non-None members of an Optional/Union, else ``[field_type]``.
|
|
323
|
+
|
|
324
|
+
Handles multi-member unions (``PostgresDsn | RedisDsn | None``) so a DSN or
|
|
325
|
+
SecretStr anywhere in the union is still recognised for redaction.
|
|
326
|
+
"""
|
|
327
|
+
origin = get_origin(field_type)
|
|
328
|
+
if origin is types.UnionType or origin is Union:
|
|
329
|
+
return [a for a in get_args(field_type) if a is not type(None)]
|
|
330
|
+
return [field_type]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _is_type_in_union(field_type: TypeForm[Any], target: type) -> bool:
|
|
334
|
+
"""True if any member of ``field_type`` is a subclass of ``target``."""
|
|
335
|
+
return any(isinstance(m, type) and issubclass(m, target) for m in _union_members(field_type))
|
|
336
|
+
|
|
337
|
+
|
|
320
338
|
def format_default(field_info: FieldInfo, field_type: TypeForm[Any], truncate: bool = True) -> str:
|
|
321
339
|
"""Format default value for display."""
|
|
322
340
|
if field_info.default is _MISSING and field_info.default_factory is None:
|
|
@@ -340,9 +358,15 @@ def format_default(field_info: FieldInfo, field_type: TypeForm[Any], truncate: b
|
|
|
340
358
|
if isinstance(default, Enum):
|
|
341
359
|
return str(default.value)
|
|
342
360
|
|
|
343
|
-
|
|
361
|
+
# Recognise DSN/SecretStr even inside an Optional/multi-member Union
|
|
362
|
+
# (e.g. `PostgresDsn | RedisDsn | None`) so nested defaults are redacted.
|
|
363
|
+
if _is_type_in_union(field_type, SecretStr):
|
|
344
364
|
return "<secret>"
|
|
345
365
|
|
|
366
|
+
# DSN defaults may embed credentials; redact the password before display.
|
|
367
|
+
if _is_type_in_union(field_type, BaseDsn) and isinstance(default, str):
|
|
368
|
+
return f'"{redact_url_password(str.__str__(default))}"'
|
|
369
|
+
|
|
346
370
|
if isinstance(default, str):
|
|
347
371
|
if truncate and len(default) > TRUNCATE_THRESHOLD_SHORT:
|
|
348
372
|
return f'"{default[: TRUNCATE_THRESHOLD_SHORT - 3]}..."'
|
|
@@ -4,6 +4,15 @@ from typing import Any
|
|
|
4
4
|
|
|
5
5
|
from typing_extensions import TypeForm
|
|
6
6
|
|
|
7
|
+
from dotenvmodel._redaction import redact_url_password
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _value_repr(value: Any) -> str:
|
|
11
|
+
"""Repr a field value for error messages, redacting URL passwords."""
|
|
12
|
+
if isinstance(value, str):
|
|
13
|
+
return repr(redact_url_password(str.__str__(value)))
|
|
14
|
+
return repr(value)
|
|
15
|
+
|
|
7
16
|
|
|
8
17
|
class DotEnvModelError(Exception):
|
|
9
18
|
"""Base exception for all dotenvmodel errors.
|
|
@@ -83,7 +92,7 @@ class ValidationError(DotEnvModelError):
|
|
|
83
92
|
def _format_message(self) -> str:
|
|
84
93
|
"""Format a detailed error message."""
|
|
85
94
|
msg = f"Field '{self.field_name}' validation failed:\n"
|
|
86
|
-
msg += f" Value: {self.value
|
|
95
|
+
msg += f" Value: {_value_repr(self.value)}\n"
|
|
87
96
|
if self.field_type:
|
|
88
97
|
type_name = getattr(self.field_type, "__name__", str(self.field_type))
|
|
89
98
|
msg += f" Expected type: {type_name}\n"
|
|
@@ -182,7 +191,7 @@ class TypeCoercionError(ValidationError):
|
|
|
182
191
|
if self.field_type:
|
|
183
192
|
type_name = getattr(self.field_type, "__name__", str(self.field_type))
|
|
184
193
|
msg = f"TypeCoercionError: Failed to coerce field '{self.field_name}' to type {type_name}.\n\n"
|
|
185
|
-
msg += f"Value: {self.value
|
|
194
|
+
msg += f"Value: {_value_repr(self.value)}\n"
|
|
186
195
|
msg += f"Environment variable: {self.env_var_name}\n"
|
|
187
196
|
msg += f"Error: {self.error_msg}\n"
|
|
188
197
|
msg += f"Hint: Ensure {self.env_var_name} contains a valid {type_name}"
|
|
@@ -239,7 +248,7 @@ class ConstraintViolationError(ValidationError):
|
|
|
239
248
|
def _format_message(self) -> str:
|
|
240
249
|
"""Format a detailed error message for constraint violations."""
|
|
241
250
|
msg = f"ConstraintViolationError: Field '{self.field_name}' violates constraint.\n\n"
|
|
242
|
-
msg += f"Value: {self.value
|
|
251
|
+
msg += f"Value: {_value_repr(self.value)}\n"
|
|
243
252
|
msg += f"Constraint: {self.constraint}\n"
|
|
244
253
|
msg += f"Error: {self.error_msg}\n"
|
|
245
254
|
msg += f"Hint: Set {self.env_var_name} to a value that satisfies the constraint"
|
|
@@ -284,7 +293,7 @@ class MultipleValidationErrors(DotEnvModelError):
|
|
|
284
293
|
msg += f"{i}. {error.__class__.__name__}: {error.error_msg}\n"
|
|
285
294
|
msg += f" Field: {error.field_name}\n"
|
|
286
295
|
if error.value is not None:
|
|
287
|
-
msg += f" Value: {error.value
|
|
296
|
+
msg += f" Value: {_value_repr(error.value)}\n"
|
|
288
297
|
msg += f" Environment variable: {error.env_var_name}\n"
|
|
289
298
|
if hasattr(error, "constraint") and error.constraint is not None: # type: ignore[attr-defined]
|
|
290
299
|
msg += f" Constraint: {error.constraint}\n" # type: ignore[attr-defined]
|
|
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, TypeVar
|
|
|
8
8
|
from urllib.parse import ParseResult, unquote, urlparse
|
|
9
9
|
from uuid import UUID
|
|
10
10
|
|
|
11
|
+
from dotenvmodel._redaction import redact_url_password
|
|
11
12
|
from dotenvmodel.exceptions import TypeCoercionError
|
|
12
13
|
|
|
13
14
|
_T = TypeVar("_T")
|
|
@@ -146,6 +147,19 @@ class BaseDsn(str):
|
|
|
146
147
|
|
|
147
148
|
return str.__new__(cls, value)
|
|
148
149
|
|
|
150
|
+
def __repr__(self) -> str:
|
|
151
|
+
"""Return a redacted ``repr`` with any password masked.
|
|
152
|
+
|
|
153
|
+
Only ``repr`` is overridden. ``str(dsn)`` and the raw buffer remain the
|
|
154
|
+
real connection string so the DSN stays usable with database drivers
|
|
155
|
+
(``create_engine(str(url))``, ``redis.from_url(str(url))``, etc.). This
|
|
156
|
+
masks the accidental-display path (``repr(config)``, debuggers, ``%r``
|
|
157
|
+
logging) without breaking functionality. Values that must never appear
|
|
158
|
+
in serialized output (``json.dumps``, ``.encode()``) should use
|
|
159
|
+
``SecretStr`` instead.
|
|
160
|
+
"""
|
|
161
|
+
return repr(redact_url_password(str.__str__(self)))
|
|
162
|
+
|
|
149
163
|
@property
|
|
150
164
|
def parsed(self) -> ParseResult:
|
|
151
165
|
"""Get the parsed URL components."""
|
|
@@ -49,16 +49,16 @@ def validate_field(field_name: str, value: Any, field_info: FieldInfo, env_var_n
|
|
|
49
49
|
if isinstance(value, str):
|
|
50
50
|
_validate_string(field_name, value, field_info, env_var_name)
|
|
51
51
|
elif isinstance(value, SecretStr):
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
52
|
+
# Check the plaintext for length/regex, but report the masked SecretStr.
|
|
53
|
+
# No plaintext-bearing exception is ever constructed, so nothing can
|
|
54
|
+
# leak via the exception chain (__cause__/__context__).
|
|
55
|
+
_validate_string(
|
|
56
|
+
field_name,
|
|
57
|
+
value.get_secret_value(),
|
|
58
|
+
field_info,
|
|
59
|
+
env_var_name,
|
|
60
|
+
report_value=value,
|
|
61
|
+
)
|
|
62
62
|
|
|
63
63
|
# Choice validation (works for any type)
|
|
64
64
|
if field_info.choices is not None:
|
|
@@ -114,12 +114,25 @@ def _validate_numeric(
|
|
|
114
114
|
)
|
|
115
115
|
|
|
116
116
|
|
|
117
|
-
def _validate_string(
|
|
118
|
-
|
|
117
|
+
def _validate_string(
|
|
118
|
+
field_name: str,
|
|
119
|
+
value: str,
|
|
120
|
+
field_info: FieldInfo,
|
|
121
|
+
env_var_name: str,
|
|
122
|
+
report_value: object | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Validate string constraints (min_length, max_length, regex).
|
|
125
|
+
|
|
126
|
+
``value`` is the plaintext used for the checks; ``report_value`` (when given,
|
|
127
|
+
e.g. the masking ``SecretStr`` wrapper) is what appears in any raised error,
|
|
128
|
+
so the plaintext never enters an exception.
|
|
129
|
+
"""
|
|
130
|
+
reported = value if report_value is None else report_value
|
|
131
|
+
|
|
119
132
|
if field_info.min_length is not None and len(value) < field_info.min_length:
|
|
120
133
|
raise ConstraintViolationError(
|
|
121
134
|
field_name=field_name,
|
|
122
|
-
value=
|
|
135
|
+
value=reported,
|
|
123
136
|
constraint=f"min_length={field_info.min_length}",
|
|
124
137
|
error_msg=f"String must be at least {field_info.min_length} characters long",
|
|
125
138
|
env_var_name=env_var_name,
|
|
@@ -128,7 +141,7 @@ def _validate_string(field_name: str, value: str, field_info: FieldInfo, env_var
|
|
|
128
141
|
if field_info.max_length is not None and len(value) > field_info.max_length:
|
|
129
142
|
raise ConstraintViolationError(
|
|
130
143
|
field_name=field_name,
|
|
131
|
-
value=
|
|
144
|
+
value=reported,
|
|
132
145
|
constraint=f"max_length={field_info.max_length}",
|
|
133
146
|
error_msg=f"String must be at most {field_info.max_length} characters long",
|
|
134
147
|
env_var_name=env_var_name,
|
|
@@ -141,7 +154,7 @@ def _validate_string(field_name: str, value: str, field_info: FieldInfo, env_var
|
|
|
141
154
|
):
|
|
142
155
|
raise ConstraintViolationError(
|
|
143
156
|
field_name=field_name,
|
|
144
|
-
value=
|
|
157
|
+
value=reported,
|
|
145
158
|
constraint=f"regex={field_info.regex!r}",
|
|
146
159
|
error_msg=f"String must match pattern: {field_info.regex}",
|
|
147
160
|
env_var_name=env_var_name,
|