logmask 0.1.3__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.
logmask-0.1.3/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lido
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
logmask-0.1.3/PKG-INFO ADDED
@@ -0,0 +1,379 @@
1
+ Metadata-Version: 2.4
2
+ Name: logmask
3
+ Version: 0.1.3
4
+ Summary: JSON logging with masked secrets for Python services
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Author: Lido
8
+ Requires-Python: >=3.12,<4
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Programming Language :: Python :: 3.15
14
+ Project-URL: Repository, https://github.com/lidofinance/logmask
15
+ Description-Content-Type: text/markdown
16
+
17
+ # logmask
18
+
19
+ JSON logging for Python services, with secrets masked. Every log line is one JSON object. Every line goes through a masker that replaces every form of your secret values with `***`. This includes the messages of third-party libraries, warnings, and the traceback of an unhandled exception.
20
+
21
+ No runtime dependencies. Python 3.12 or newer.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ poetry add git+https://github.com/lidofinance/logmask.git@v0.1.3
27
+ ```
28
+
29
+ Pin a tag, or a commit with `rev`, and commit `poetry.lock`: it records the exact commit. Poetry clones the repository itself; a build stage that installs with `pip` needs `git`.
30
+
31
+ ## Quick start
32
+
33
+ ```py
34
+ import logging
35
+
36
+ from logmask import masked_config, setup_logging
37
+
38
+ PUBLIC = {'RPC_TIMEOUT': 30, 'DRY_RUN': True}
39
+ PRIVATE = {'RPC_URL': 'https://rpc.example.com/v3/a1b2c3d4e5f6', 'API_TOKEN': 'tok_9f8e7d6c5b4a'}
40
+
41
+ setup_logging(PRIVATE)
42
+
43
+ logger = logging.getLogger(__name__)
44
+ logger.info({'msg': 'Config', 'value': masked_config(PUBLIC, PRIVATE)})
45
+ logger.info('Connecting to %s', PRIVATE['RPC_URL'])
46
+ ```
47
+
48
+ Output (one line per record, stderr):
49
+
50
+ ```json
51
+ {"name": "app", "levelname": "INFO", "funcName": "main", "lineno": 12, "module": "main", "pathname": "/app/main.py", "timestamp": 1761600000, "msg": "Config", "value": {"RPC_TIMEOUT": 30, "DRY_RUN": true, "RPC_URL": "***", "API_TOKEN": "***"}}
52
+ {"name": "app", "levelname": "INFO", "funcName": "main", "lineno": 13, "module": "main", "pathname": "/app/main.py", "timestamp": 1761600000, "msg": "Connecting to ***"}
53
+ ```
54
+
55
+ `setup_logging(PRIVATE)` does four things:
56
+
57
+ - configures the root logger: JSON lines to stderr, level INFO, old handlers removed
58
+ - masks every line with every form of the values of `PRIVATE`
59
+ - routes `warnings.warn()` through the logger, so a warning text is masked too
60
+ - installs `sys.excepthook`, `threading.excepthook` and `sys.unraisablehook`, so an unhandled exception becomes one CRITICAL line with a masked traceback
61
+
62
+ `masked_config(PUBLIC, PRIVATE)` builds a dict for the startup log. Public values stay as they are. Private values become `***`. A private list keeps its length. An unset private value (`None`, `''` or `b''`) becomes `''`, so you can see which variables are set; any other value, `0` and `False` included, is set and shows `***`. A key present in both dicts raises `ValueError`, and a private value of a type the masker cannot search raises `TypeError` (see "What is masked").
63
+
64
+ ```py
65
+ masked_config({'PORT': 9000}, {'TOKEN': 'abc', 'RPC_URLS': ['a', 'b'], 'OPTIONAL_KEY': None})
66
+ # {'PORT': 9000, 'TOKEN': '***', 'RPC_URLS': ['***', '***'], 'OPTIONAL_KEY': ''}
67
+ ```
68
+
69
+ Every value of `PRIVATE` is treated as a secret, whatever it looks like (`None` and booleans are not values). Put a variable there only when its value must never appear in a log: a value such as `true` or `9000` in `PRIVATE` would be masked inside every line, field names included. A realistic set of secrets (about 20 variables, about 100 forms) costs about 50 microseconds per line; hundreds of secrets make every line noticeably slower.
70
+
71
+ ## How to
72
+
73
+ ### Log a message
74
+
75
+ A string message goes to the `msg` field. A dict message is merged into the line, so its keys become fields:
76
+
77
+ ```py
78
+ logger.info('Report sent for slot %d', 1234567)
79
+ # {..., "msg": "Report sent for slot 1234567"}
80
+
81
+ logger.info({'msg': 'Report sent', 'slot': 1234567, 'root': b'\x0a\x0b'})
82
+ # {..., "msg": "Report sent", "slot": 1234567, "root": "0x0a0b"}
83
+ ```
84
+
85
+ ### Log an exception
86
+
87
+ ```py
88
+ try:
89
+ client.get(url)
90
+ except ConnectionError:
91
+ logger.exception('Request failed')
92
+ # {..., "levelname": "ERROR", "msg": "Request failed", "exc_info": "Traceback (most recent call last):\n ...\nConnectionError: Max retries exceeded with url: ***"}
93
+ ```
94
+
95
+ ### Set the level and the stream
96
+
97
+ ```py
98
+ setup_logging(PRIVATE, level=os.getenv('LOG_LEVEL', 'INFO'), stream=sys.stdout)
99
+ ```
100
+
101
+ `level` takes a name in any case or an int. `stream` is stderr by default. Three outputs always go to stderr: the traceback of a KeyboardInterrupt, the report of a handler that cannot write, and the line written when the exception hook itself fails.
102
+
103
+ ### Add secrets later
104
+
105
+ `setup_logging` returns the masker. Pass `{}` and call `add()` when a secret becomes known after logging is configured, for example a token fetched from a vault or a config module that logs at import time:
106
+
107
+ ```py
108
+ masker = setup_logging({})
109
+
110
+ from app import config # logs at import time, before the config is known
111
+
112
+ masker.add(config.PRIVATE)
113
+ ```
114
+
115
+ Every line from that point on is masked with the new values too. The lines written before `add()` are not. A second `setup_logging` call in the same process adds its secrets to the same masker, so the secrets of the first call stay masked.
116
+
117
+ ### Mask text by hand
118
+
119
+ ```py
120
+ masker = setup_logging(PRIVATE)
121
+ masker.mask('connect https://rpc.example.com/v3/a1b2c3d4e5f6')
122
+ # 'connect ***'
123
+ ```
124
+
125
+ ### Use structlog
126
+
127
+ Call `setup_logging` as well: structlog does not route the standard-library loggers of `web3`, `urllib3` or `requests` anywhere, and without a configured root logger their warnings go to stderr unmasked.
128
+
129
+ ```py
130
+ import structlog
131
+
132
+ from logmask import mask_secrets_processor, setup_logging
133
+
134
+ masker = setup_logging(PRIVATE)
135
+ structlog.configure(
136
+ processors=[
137
+ structlog.processors.add_log_level,
138
+ structlog.processors.TimeStamper(fmt='iso'),
139
+ structlog.processors.JSONRenderer(),
140
+ mask_secrets_processor(masker), # last: masks the rendered line
141
+ ],
142
+ )
143
+ ```
144
+
145
+ The processor must be the last one, after the renderer: it masks the rendered line, keys and JSON-escaped forms included. Placed earlier it receives the event dict and raises `TypeError` at the first event that reaches the processors, which with a filtering logger is the first event at or above the configured level, not the first call. Do not render tracebacks with `structlog.processors.dict_tracebacks` in its default form: it prints the local variables of every frame cut to 80 characters, and a cut secret is a part that no form matches. Use `format_exc_info`, or `ExceptionRenderer(ExceptionDictTransformer(show_locals=False))`. The package does not depend on structlog: your service installs its own version.
146
+
147
+ ### Use the pieces one by one
148
+
149
+ `setup_logging` wires these classes; they can be used directly, and each of them requires the masker:
150
+
151
+ - `SecretsMasker(secrets)` - the masker; `mask(text)` and `add(secrets)`
152
+ - `JsonFormatter(masker)` - one JSON object per record, masked
153
+ - `SafeStreamHandler(stream, masker=masker)` - a `StreamHandler` that does not print the record when the write fails
154
+ - `install_excepthook(masker=masker)` - the three exception hooks; the masker covers the output that cannot go through the logger
155
+
156
+ ## What is masked
157
+
158
+ The examples use this masker:
159
+
160
+ ```py
161
+ masker = SecretsMasker(
162
+ {
163
+ 'RPC_URL': 'https://rpc.example.com/v3/a1b2c3d4e5f6/',
164
+ 'CL_URL': 'https://user:s3cretPassw0rd@beacon.example.com/',
165
+ 'API_TOKEN': 'tok_9f8e7d6c5b4a\n', # read from a file, with the trailing newline
166
+ 'RPC_LIST': ['https://one.example.com/key/abcdef123456', 'https://two.example.com/key/ghijkl789012'],
167
+ 'KEYS': {'nested-secret-key': 'nested-secret-value'},
168
+ 'PRIVATE_KEY': '0xABCDEF0123456789',
169
+ 'RAW_KEY': b'\xde\xad\xbe\xef\x01\x02\x03\x04',
170
+ }
171
+ )
172
+ ```
173
+
174
+ Each value, each item of a list, tuple, set or any other iterable, the keys and the values of a nested dict (only the top-level keys are the variable names, they stay), the fields of a dataclass, the value behind a `SecretStr`-like object (`get_secret_value()`), numbers and paths by their text:
175
+
176
+ ```py
177
+ masker.mask('connect https://rpc.example.com/v3/a1b2c3d4e5f6/')
178
+ # 'connect ***'
179
+ masker.mask('rpc https://two.example.com/key/ghijkl789012 failed')
180
+ # 'rpc *** failed'
181
+ masker.mask('KEYS nested-secret-key nested-secret-value')
182
+ # 'KEYS *** ***'
183
+ ```
184
+
185
+ A value of any other type (a plain object, a function, a module, a class) raises `TypeError` in `setup_logging`, `SecretsMasker`, `add()` and `masked_config`: the masker cannot promise to find every secret inside it, so it refuses the value instead of masking a part. Pass the data of such an object as a dict.
186
+
187
+ The values held by one string: a JSON document is searched as the container it encodes (a list of URLs, the JSON of a service account with its `private_key`), several URLs separated by commas, semicolons or white space are searched one by one, and every line of a multi-line value (a PEM key) is a secret on its own:
188
+
189
+ ```py
190
+ masker = SecretsMasker({'RPC_LIST': 'https://one.example.com/key/abcdef123456,https://two.example.com/key/ghijkl789012'})
191
+ masker.mask('rpc https://one.example.com/key/abcdef123456 failed, path /key/abcdef123456')
192
+ # 'rpc *** failed, path ***'
193
+ masker = SecretsMasker(
194
+ {'ACCOUNT': '{"type": "service_account", "private_key": "-----BEGIN KEY-----\\nMIIEvQIBADANBg\\n-----END KEY-----"}'}
195
+ )
196
+ masker.mask('key MIIEvQIBADANBg of type service_account')
197
+ # 'key *** of *** ***'
198
+ ```
199
+
200
+ The value without spaces and newlines around it, and without a trailing `/`:
201
+
202
+ ```py
203
+ masker.mask('token tok_9f8e7d6c5b4a expired')
204
+ # 'token *** expired'
205
+ masker.mask('connect https://rpc.example.com/v3/a1b2c3d4e5f6')
206
+ # 'connect ***'
207
+ ```
208
+
209
+ The parts of a URL on their own: the host with its credentials, the path, the path with the query, the query, each `name=value` pair, the user name, the password and `user:password`, percent-decoded as well, and the credentials as an HTTP Basic header. HTTP libraries often log only the path:
210
+
211
+ ```py
212
+ masker.mask("HTTPSConnectionPool(host='rpc.example.com', port=443): Max retries exceeded with url: /v3/a1b2c3d4e5f6")
213
+ # "HTTPSConnectionPool(host='rpc.example.com', port=443): Max retries exceeded with url: ***"
214
+ masker.mask('GET https://user:s3cretPassw0rd@beacon.example.com/eth/v1/beacon/genesis')
215
+ # 'GET ***eth/v1/beacon/genesis'
216
+ masker.mask('auth user:s3cretPassw0rd rejected, header Basic dXNlcjpzM2NyZXRQYXNzdzByZA==')
217
+ # 'auth *** rejected, header Basic ***'
218
+ ```
219
+
220
+ A single segment of the path, a single query value and the host without its credentials, when they can be a key: 16 characters or more, or 8 or more with a digit. A short word of a URL (`eth`, `v1`, a host like `el`) is masked only as part of the whole path or URL, otherwise it would vanish from every line:
221
+
222
+ ```py
223
+ masker.mask('project a1b2c3d4e5f6 rate limited, host beacon.example.com')
224
+ # 'project *** rate limited, host ***'
225
+ masker.mask('GET /eth/v1/beacon/genesis from rpc.example.com')
226
+ # 'GET /eth/v1/beacon/genesis from rpc.example.com'
227
+ ```
228
+
229
+ Hex values in any case, with and without `0x`, and as the bytes they encode. Bytes as text, as hex and as their `repr()`:
230
+
231
+ ```py
232
+ masker.mask('key 0xabcdef0123456789 or ABCDEF0123456789 or 0XABCDEF0123456789')
233
+ # 'key *** or *** or ***'
234
+ masker.mask("raw 0xdeadbeef01020304 DEADBEEF01020304 b'\\xde\\xad\\xbe\\xef\\x01\\x02\\x03\\x04'")
235
+ # "raw *** *** b'***'"
236
+ masker.mask(repr(bytes.fromhex('ABCDEF0123456789')))
237
+ # "b'***'"
238
+ ```
239
+
240
+ The value percent-encoded (with `quote` and with `quote_plus`), percent-decoded, base64-encoded, and as the hex or the `repr()` of its bytes, as an application writes it into a URL, a header or a bytes body:
241
+
242
+ ```py
243
+ SecretsMasker({'T': 'tok/en+value=1'}).mask('k=tok%2Fen%2Bvalue%3D1 auth dG9rL2VuK3ZhbHVlPTE= body 0x746f6b2f656e2b76616c75653d31')
244
+ # 'k=*** auth *** body 0x***'
245
+ ```
246
+
247
+ A value shown with `repr()`, inside `str(dict)`, in an exception text or inside a JSON document, up to two layers deep (for example the `repr()` of a JSON document). Quotes, backslashes and newlines in the secret are not a problem, and a secret made of JSON syntax (a quote, a brace, `true`) is masked inside the strings of the line, so the line stays valid JSON:
248
+
249
+ ```py
250
+ password = 'pa"ss\\word\n123'
251
+ masker.add({'PASSWORD': password})
252
+ logger.info('login %r failed with %s', password, json.dumps({'password': password}))
253
+ # {..., "msg": "login '***' failed with {\"password\": \"***\"}"}
254
+ ```
255
+
256
+ The case of the text does not matter:
257
+
258
+ ```py
259
+ masker.mask('TOK_9F8E7D6C5B4A')
260
+ # '***'
261
+ ```
262
+
263
+ Numbers and other non-string values, by their text:
264
+
265
+ ```py
266
+ SecretsMasker({'PIN': 12345678}).mask('pin 12345678')
267
+ # 'pin ***'
268
+ ```
269
+
270
+ Two secrets that overlap in the text are masked as one:
271
+
272
+ ```py
273
+ SecretsMasker({'A': 'abcdefgh', 'B': 'efghijklmnop'}).mask('abcdefghijklmnop')
274
+ # '***'
275
+ ```
276
+
277
+ ## What is not masked
278
+
279
+ A value that is not in your secrets. The masker replaces known values, it does not search for text that looks like a secret:
280
+
281
+ ```py
282
+ masker.mask('other secret zzz-not-configured')
283
+ # 'other secret zzz-not-configured'
284
+ ```
285
+
286
+ A form of the value the masker does not know: a part of the value, a hash of it, more than two layers of escaping:
287
+
288
+ ```py
289
+ masker.mask('prefix a1b2c3')
290
+ # 'prefix a1b2c3'
291
+ ```
292
+
293
+ A short word of a URL on its own (see above).
294
+
295
+ A password re-encoded by an HTTP library with its own set of safe characters. The masker knows the password raw, percent-decoded and in the three encodings of `urllib.parse` (`quote` with and without the default safe characters, `quote_plus`). yarl, used by aiohttp, keeps `+ ! & =` raw and encodes the rest, so `p%40ss+x!y` is a form the masker does not know, and only the user name and the host of such a URL are masked. A per-character pattern for the credentials (each character raw or percent-encoded) is planned; until then, prefer a password without characters that need encoding.
296
+
297
+ Text that does not pass through the handler installed by `setup_logging`:
298
+
299
+ - `print`, a library that writes to the stream directly, and the message of `sys.exit('...')`, which the interpreter prints without calling the exception hook
300
+ - a logger with its own handler that does not propagate to the root logger (a logger without any handler that does not propagate goes to `logging.lastResort`, which `setup_logging` replaces by a masked handler)
301
+ - a handler added to the root logger after `setup_logging` (a `FileHandler`, the handler of an error tracker), and a `logging.config.dictConfig` call after it: they format the record themselves
302
+ - a child process of `multiprocessing`: with the `fork` start method, an exception in the child is printed by `multiprocessing` itself, without the hooks; with `spawn`, the child starts without `setup_logging` unless its module calls it
303
+
304
+ ## When logging itself fails
305
+
306
+ A record that cannot be formatted (wrong `%` arguments, a value `json` cannot serialize, a dict message nested deeper than the recursion limit) is still written as one masked JSON line, with the problem in the `error` field. Every line the library writes on its own behalf, instead of the record, starts with `[logmask]`:
307
+
308
+ ```py
309
+ logger.info('rpc %d %s', 'https://rpc.example.com/v3/a1b2c3d4e5f6', 'x')
310
+ # {..., "msg": "rpc %d %s", "error": "[logmask] Log record could not be formatted: TypeError('%d format: a real number is required, not str')", "args": "('***', 'x')"}
311
+ ```
312
+
313
+ The standard library would print this instead, to stderr and without masking:
314
+
315
+ ```text
316
+ --- Logging error ---
317
+ Traceback (most recent call last):
318
+ ...
319
+ TypeError: %d format: a real number is required, not str
320
+ Message: 'rpc %d %s'
321
+ Arguments: ('https://rpc.example.com/v3/a1b2c3d4e5f6', 'x')
322
+ ```
323
+
324
+ A record that cannot be written (the stream is closed, the pipe is broken) is reported by `SafeStreamHandler` without the record, where the standard handler would print the same `Message:` and `Arguments:` lines as above:
325
+
326
+ ```text
327
+ --- [logmask] Logging error: BrokenPipeError(32, 'Broken pipe') (record from app, /app/main.py:31) ---
328
+ ```
329
+
330
+ An unhandled exception goes through the logger as a CRITICAL line:
331
+
332
+ ```py
333
+ raise ConnectionError(f'Max retries exceeded with url: {RPC_URL}')
334
+ # {"name": "unhandled", "levelname": "CRITICAL", ..., "msg": "Unhandled exception. Shut down.", "error": "Traceback (most recent call last):\n ...\nConnectionError: Max retries exceeded with url: ***\n"}
335
+ ```
336
+
337
+ An exception in another thread gives the same line with `"msg": "Unhandled exception in a thread."` and a `thread` field; the process goes on, as with the default hook. An exception in `__del__` or in a callback (`sys.unraisablehook`) gives `"msg": "Unraisable exception."`.
338
+
339
+ Three cases cannot go through the logger and are written to stderr masked: a `KeyboardInterrupt` (Ctrl-C is not an error, so there is no CRITICAL line, the traceback is printed as plain text), an exception when no handler is configured (one JSON line written by the hook itself), and a failure of the hook (one line with the exception name).
340
+
341
+ ## Log format
342
+
343
+ Every line has these fields, in this order:
344
+
345
+ ```json
346
+ {"name": "app", "levelname": "INFO", "funcName": "main", "lineno": 29, "module": "main", "pathname": "/app/main.py", "timestamp": 1761600000, "msg": "Config", "value": {"RPC_URL": "***"}}
347
+ ```
348
+
349
+ - `name`, `levelname`, `funcName`, `lineno`, `module`, `pathname` - from the log record
350
+ - `timestamp` - the record time in whole seconds
351
+ - `msg` - the string message; a dict message adds its keys after `timestamp` instead. A dict key with the name of a record field (for example `name`) is written as `msg_name`
352
+ - `exc_info` - the traceback, for `logger.exception()` and `exc_info=True`
353
+ - bytes are written as `0x`-hex, dataclasses as dicts, sets as lists, other unknown values as text
354
+
355
+ ## Development
356
+
357
+ ```bash
358
+ poetry install
359
+ poetry run ruff format --check . && poetry run ruff check . && poetry run pyright logmask tests
360
+ poetry run pytest
361
+ ```
362
+
363
+ Mutation tests check that the suite notices a weaker masker. They run in CI on every push and pull request; locally:
364
+
365
+ ```bash
366
+ poetry run pip install "mutmut==3.8.1"
367
+ poetry run mutmut run
368
+ poetry run mutmut results # the survivors
369
+ poetry run mutmut show <mutant> # the diff of one survivor
370
+ ```
371
+
372
+ About 50 mutants survive by design (the spelling of `utf-8`, the wording of messages, the references kept by the cycle guard); CI fails above that number.
373
+
374
+ ## Release
375
+
376
+ 1. Set the version in `pyproject.toml` and move the `Unreleased` section of `CHANGELOG.md` under the version and the date.
377
+ 2. Merge to `main` and create the tag `vX.Y.Z`.
378
+ 3. Consumers update the tag in their `pyproject.toml` and run `poetry lock`.
379
+