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/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}"