dploydb 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.
- dploydb/__init__.py +1 -0
- dploydb/__main__.py +6 -0
- dploydb/backup.py +455 -0
- dploydb/candidate.py +868 -0
- dploydb/cli.py +1111 -0
- dploydb/config.py +784 -0
- dploydb/cutover.py +326 -0
- dploydb/deploy.py +1163 -0
- dploydb/deployment_dependencies.py +366 -0
- dploydb/deployment_evidence.py +136 -0
- dploydb/diagnostics.py +1020 -0
- dploydb/errors.py +140 -0
- dploydb/health.py +625 -0
- dploydb/locking.py +609 -0
- dploydb/manual_restore.py +825 -0
- dploydb/migration.py +575 -0
- dploydb/models.py +927 -0
- dploydb/recovery.py +1210 -0
- dploydb/redaction.py +229 -0
- dploydb/releases.py +611 -0
- dploydb/restore.py +461 -0
- dploydb/retention.py +165 -0
- dploydb/runners/__init__.py +33 -0
- dploydb/runners/base.py +389 -0
- dploydb/runners/docker_compose.py +590 -0
- dploydb/runners/docker_compose_production.py +966 -0
- dploydb/sqlite_checks.py +136 -0
- dploydb/state.py +604 -0
- dploydb/storage/__init__.py +13 -0
- dploydb/storage/base.py +52 -0
- dploydb/storage/local.py +301 -0
- dploydb/storage/s3.py +737 -0
- dploydb/subprocesses.py +641 -0
- dploydb/traffic.py +165 -0
- dploydb-0.1.0.dist-info/METADATA +583 -0
- dploydb-0.1.0.dist-info/RECORD +40 -0
- dploydb-0.1.0.dist-info/WHEEL +4 -0
- dploydb-0.1.0.dist-info/entry_points.txt +2 -0
- dploydb-0.1.0.dist-info/licenses/LICENSE +201 -0
- dploydb-0.1.0.dist-info/licenses/NOTICE +4 -0
dploydb/redaction.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""In-memory secret registration and output redaction.
|
|
2
|
+
|
|
3
|
+
The registry is deliberately an output-boundary object: callers register resolved
|
|
4
|
+
secret values, then redact data before it is displayed or handed to persistence.
|
|
5
|
+
It has no serialization API and its representation never includes secret values.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from typing import NoReturn
|
|
13
|
+
|
|
14
|
+
REDACTION_MARKER = "[REDACTED]"
|
|
15
|
+
|
|
16
|
+
type JsonScalar = None | bool | int | float | str
|
|
17
|
+
type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue]
|
|
18
|
+
|
|
19
|
+
_SENSITIVE_KEY_SOURCE = r"""
|
|
20
|
+
(?:[a-z0-9]+[-_.])*
|
|
21
|
+
(?:
|
|
22
|
+
password
|
|
23
|
+
| passwd
|
|
24
|
+
| pwd
|
|
25
|
+
| secret
|
|
26
|
+
| token
|
|
27
|
+
| credentials?
|
|
28
|
+
| authorization
|
|
29
|
+
| proxy[-_]?authorization
|
|
30
|
+
| api[-_]?key
|
|
31
|
+
| access[-_]?key(?:[-_]?id)?
|
|
32
|
+
| secret[-_]?key
|
|
33
|
+
| client[-_]?secret
|
|
34
|
+
| private[-_]?key
|
|
35
|
+
| aws[-_]?access[-_]?key[-_]?id
|
|
36
|
+
| signature
|
|
37
|
+
| sig
|
|
38
|
+
| cookies?
|
|
39
|
+
| set[-_]?cookie
|
|
40
|
+
)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_QUOTED_VALUE_SOURCE = r"""
|
|
44
|
+
(?:
|
|
45
|
+
\\[^\r\n]
|
|
46
|
+
| (?!(?P=value_quote)) [^\\\r\n]
|
|
47
|
+
)*
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
_SENSITIVE_KEY = re.compile(rf"^(?:{_SENSITIVE_KEY_SOURCE})$", re.IGNORECASE | re.VERBOSE)
|
|
51
|
+
|
|
52
|
+
_AUTHORIZATION = re.compile(
|
|
53
|
+
r"""
|
|
54
|
+
(?P<prefix>
|
|
55
|
+
(?<![\w.-])
|
|
56
|
+
(?:authorization|proxy[-_]?authorization)
|
|
57
|
+
\s*[:=]\s*
|
|
58
|
+
)
|
|
59
|
+
(?P<value>
|
|
60
|
+
(?:(?:bearer|basic)\s+)?[^\s,;&]+
|
|
61
|
+
)
|
|
62
|
+
""",
|
|
63
|
+
re.IGNORECASE | re.VERBOSE,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_QUOTED_KEY_VALUE = re.compile(
|
|
67
|
+
rf"""
|
|
68
|
+
(?P<prefix>
|
|
69
|
+
(?<![\w.-])
|
|
70
|
+
(?P<key_quote>["']?)
|
|
71
|
+
{_SENSITIVE_KEY_SOURCE}
|
|
72
|
+
(?P=key_quote)
|
|
73
|
+
\s*[:=]\s*
|
|
74
|
+
)
|
|
75
|
+
(?P<value_quote>["'])
|
|
76
|
+
(?P<value>{_QUOTED_VALUE_SOURCE})
|
|
77
|
+
(?P=value_quote)
|
|
78
|
+
""",
|
|
79
|
+
re.IGNORECASE | re.VERBOSE,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
_UNQUOTED_KEY_VALUE = re.compile(
|
|
83
|
+
rf"""
|
|
84
|
+
(?P<prefix>
|
|
85
|
+
(?<![\w.-])
|
|
86
|
+
(?P<key_quote>["']?)
|
|
87
|
+
{_SENSITIVE_KEY_SOURCE}
|
|
88
|
+
(?P=key_quote)
|
|
89
|
+
\s*[:=]\s*
|
|
90
|
+
)
|
|
91
|
+
(?P<value>\[REDACTED\]|[^\s,;&}}\]]+)
|
|
92
|
+
""",
|
|
93
|
+
re.IGNORECASE | re.VERBOSE,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
_QUOTED_COMMAND_OPTION = re.compile(
|
|
97
|
+
rf"""
|
|
98
|
+
(?P<prefix>
|
|
99
|
+
(?<![\w-])
|
|
100
|
+
--{_SENSITIVE_KEY_SOURCE}
|
|
101
|
+
(?:=|\s+)
|
|
102
|
+
)
|
|
103
|
+
(?P<value_quote>["'])
|
|
104
|
+
(?P<value>{_QUOTED_VALUE_SOURCE})
|
|
105
|
+
(?P=value_quote)
|
|
106
|
+
""",
|
|
107
|
+
re.IGNORECASE | re.VERBOSE,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
_UNQUOTED_COMMAND_OPTION = re.compile(
|
|
111
|
+
rf"""
|
|
112
|
+
(?P<prefix>
|
|
113
|
+
(?<![\w-])
|
|
114
|
+
--{_SENSITIVE_KEY_SOURCE}
|
|
115
|
+
(?:=|\s+)
|
|
116
|
+
)
|
|
117
|
+
(?P<value>[^\s,;&]+)
|
|
118
|
+
""",
|
|
119
|
+
re.IGNORECASE | re.VERBOSE,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
_URL_USERINFO = re.compile(
|
|
123
|
+
r"(?P<prefix>\b[a-z][a-z0-9+.-]*://)(?P<userinfo>[^/@\s]+)@",
|
|
124
|
+
re.IGNORECASE,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_sensitive_key(key: str) -> bool:
|
|
129
|
+
"""Return whether a mapping key conventionally contains a secret value."""
|
|
130
|
+
candidate = key.strip().strip("\"'").removeprefix("--")
|
|
131
|
+
return _SENSITIVE_KEY.fullmatch(candidate) is not None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class SecretRegistry:
|
|
135
|
+
"""Hold resolved secrets in memory and redact output-bound values."""
|
|
136
|
+
|
|
137
|
+
__slots__ = ("_exact_pattern", "_secrets")
|
|
138
|
+
|
|
139
|
+
def __init__(self) -> None:
|
|
140
|
+
self._secrets: set[str] = set()
|
|
141
|
+
self._exact_pattern: re.Pattern[str] | None = None
|
|
142
|
+
|
|
143
|
+
def __repr__(self) -> str:
|
|
144
|
+
return f"SecretRegistry(secret_count={len(self._secrets)})"
|
|
145
|
+
|
|
146
|
+
def __reduce__(self) -> NoReturn:
|
|
147
|
+
raise TypeError("SecretRegistry cannot be serialized")
|
|
148
|
+
|
|
149
|
+
def register(self, value: str | None) -> None:
|
|
150
|
+
"""Register one resolved secret without persisting or exposing it."""
|
|
151
|
+
if value is None or value == "":
|
|
152
|
+
return
|
|
153
|
+
if not isinstance(value, str):
|
|
154
|
+
raise TypeError("secret values must be strings")
|
|
155
|
+
if value not in self._secrets:
|
|
156
|
+
self._secrets.add(value)
|
|
157
|
+
self._exact_pattern = None
|
|
158
|
+
|
|
159
|
+
def register_many(self, values: Iterable[str | None]) -> None:
|
|
160
|
+
"""Register multiple resolved secrets."""
|
|
161
|
+
for value in values:
|
|
162
|
+
self.register(value)
|
|
163
|
+
|
|
164
|
+
def redact_text(self, text: str) -> str:
|
|
165
|
+
"""Redact registered values and recognizable credential forms in text."""
|
|
166
|
+
if not isinstance(text, str):
|
|
167
|
+
raise TypeError("text to redact must be a string")
|
|
168
|
+
|
|
169
|
+
redacted = self._redact_exact_values(text)
|
|
170
|
+
redacted = _URL_USERINFO.sub(
|
|
171
|
+
lambda match: f"{match.group('prefix')}{REDACTION_MARKER}@", redacted
|
|
172
|
+
)
|
|
173
|
+
redacted = _AUTHORIZATION.sub(
|
|
174
|
+
lambda match: f"{match.group('prefix')}{REDACTION_MARKER}", redacted
|
|
175
|
+
)
|
|
176
|
+
redacted = _QUOTED_COMMAND_OPTION.sub(self._replace_quoted_value, redacted)
|
|
177
|
+
redacted = _UNQUOTED_COMMAND_OPTION.sub(self._replace_unquoted_value, redacted)
|
|
178
|
+
redacted = _QUOTED_KEY_VALUE.sub(self._replace_quoted_value, redacted)
|
|
179
|
+
return _UNQUOTED_KEY_VALUE.sub(self._replace_unquoted_value, redacted)
|
|
180
|
+
|
|
181
|
+
def redact(self, value: JsonValue) -> JsonValue:
|
|
182
|
+
"""Recursively redact a JSON-compatible value without mutating the input."""
|
|
183
|
+
if isinstance(value, str):
|
|
184
|
+
return self.redact_text(value)
|
|
185
|
+
if value is None or isinstance(value, bool | int | float):
|
|
186
|
+
return value
|
|
187
|
+
if isinstance(value, list):
|
|
188
|
+
return [self.redact(item) for item in value]
|
|
189
|
+
if isinstance(value, dict):
|
|
190
|
+
redacted: dict[str, JsonValue] = {}
|
|
191
|
+
for key, item in value.items():
|
|
192
|
+
redacted_key = self.redact_text(key)
|
|
193
|
+
if redacted_key in redacted:
|
|
194
|
+
suffix = 2
|
|
195
|
+
while f"{redacted_key}_{suffix}" in redacted:
|
|
196
|
+
suffix += 1
|
|
197
|
+
redacted_key = f"{redacted_key}_{suffix}"
|
|
198
|
+
redacted[redacted_key] = (
|
|
199
|
+
REDACTION_MARKER if is_sensitive_key(key) else self.redact(item)
|
|
200
|
+
)
|
|
201
|
+
return redacted
|
|
202
|
+
raise TypeError(f"value is not JSON-compatible: {type(value).__name__}")
|
|
203
|
+
|
|
204
|
+
def _redact_exact_values(self, text: str) -> str:
|
|
205
|
+
pattern = self._exact_value_pattern()
|
|
206
|
+
if pattern is None:
|
|
207
|
+
return text
|
|
208
|
+
return pattern.sub(REDACTION_MARKER, text)
|
|
209
|
+
|
|
210
|
+
def _exact_value_pattern(self) -> re.Pattern[str] | None:
|
|
211
|
+
if not self._secrets:
|
|
212
|
+
return None
|
|
213
|
+
if self._exact_pattern is None:
|
|
214
|
+
exact_values = sorted(
|
|
215
|
+
{*self._secrets, REDACTION_MARKER},
|
|
216
|
+
key=lambda value: (len(value), value == REDACTION_MARKER),
|
|
217
|
+
reverse=True,
|
|
218
|
+
)
|
|
219
|
+
self._exact_pattern = re.compile("|".join(re.escape(value) for value in exact_values))
|
|
220
|
+
return self._exact_pattern
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _replace_quoted_value(match: re.Match[str]) -> str:
|
|
224
|
+
quote = match.group("value_quote")
|
|
225
|
+
return f"{match.group('prefix')}{quote}{REDACTION_MARKER}{quote}"
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _replace_unquoted_value(match: re.Match[str]) -> str:
|
|
229
|
+
return f"{match.group('prefix')}{REDACTION_MARKER}"
|