json-logify 0.1.2__tar.gz → 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.
Files changed (22) hide show
  1. {json_logify-0.1.2 → json_logify-0.1.3}/PKG-INFO +1 -1
  2. {json_logify-0.1.2 → json_logify-0.1.3}/json_logify.egg-info/PKG-INFO +1 -1
  3. {json_logify-0.1.2 → json_logify-0.1.3}/json_logify.egg-info/SOURCES.txt +3 -1
  4. {json_logify-0.1.2 → json_logify-0.1.3}/logify/core.py +132 -63
  5. {json_logify-0.1.2 → json_logify-0.1.3}/logify/django.py +103 -162
  6. {json_logify-0.1.2 → json_logify-0.1.3}/pyproject.toml +1 -1
  7. json_logify-0.1.3/tests/test_config_logic.py +54 -0
  8. {json_logify-0.1.2 → json_logify-0.1.3}/tests/test_django.py +57 -0
  9. json_logify-0.1.3/tests/test_security_masking.py +86 -0
  10. {json_logify-0.1.2 → json_logify-0.1.3}/LICENSE +0 -0
  11. {json_logify-0.1.2 → json_logify-0.1.3}/README.md +0 -0
  12. {json_logify-0.1.2 → json_logify-0.1.3}/json_logify.egg-info/dependency_links.txt +0 -0
  13. {json_logify-0.1.2 → json_logify-0.1.3}/json_logify.egg-info/requires.txt +0 -0
  14. {json_logify-0.1.2 → json_logify-0.1.3}/json_logify.egg-info/top_level.txt +0 -0
  15. {json_logify-0.1.2 → json_logify-0.1.3}/logify/__init__.py +0 -0
  16. {json_logify-0.1.2 → json_logify-0.1.3}/logify/fastapi.py +0 -0
  17. {json_logify-0.1.2 → json_logify-0.1.3}/logify/flask.py +0 -0
  18. {json_logify-0.1.2 → json_logify-0.1.3}/logify/version.py +0 -0
  19. {json_logify-0.1.2 → json_logify-0.1.3}/setup.cfg +0 -0
  20. {json_logify-0.1.2 → json_logify-0.1.3}/tests/test_core.py +0 -0
  21. {json_logify-0.1.2 → json_logify-0.1.3}/tests/test_fastapi.py +0 -0
  22. {json_logify-0.1.2 → json_logify-0.1.3}/tests/test_flask.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json-logify
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Universal structured logging with exact JSON schema for Python frameworks
5
5
  Author-email: Bakdoolot Kulbarakov <kulbarakovbh@gmail.com>
6
6
  Maintainer-email: Bakdoolot Kulbarakov <kulbarakovbh@gmail.com>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: json-logify
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Universal structured logging with exact JSON schema for Python frameworks
5
5
  Author-email: Bakdoolot Kulbarakov <kulbarakovbh@gmail.com>
6
6
  Maintainer-email: Bakdoolot Kulbarakov <kulbarakovbh@gmail.com>
@@ -12,7 +12,9 @@ logify/django.py
12
12
  logify/fastapi.py
13
13
  logify/flask.py
14
14
  logify/version.py
15
+ tests/test_config_logic.py
15
16
  tests/test_core.py
16
17
  tests/test_django.py
17
18
  tests/test_fastapi.py
18
- tests/test_flask.py
19
+ tests/test_flask.py
20
+ tests/test_security_masking.py
@@ -15,6 +15,62 @@ import structlog
15
15
  # Context variables for request tracking
16
16
  _request_context: ContextVar[Dict[str, Any]] = ContextVar("request_context", default={})
17
17
 
18
+ # Internal configuration storage
19
+ _config = {
20
+ "MAX_STRING_LENGTH": 100,
21
+ "SENSITIVE_FIELDS": {
22
+ "password",
23
+ "passwd",
24
+ "pass",
25
+ "pwd",
26
+ "secret",
27
+ "token",
28
+ "key",
29
+ "api_key",
30
+ "access_token",
31
+ "refresh_token",
32
+ "auth_token",
33
+ "session_key",
34
+ "private_key",
35
+ "credit_card",
36
+ "card_number",
37
+ "cvv",
38
+ "ssn",
39
+ "social_security_number",
40
+ },
41
+ }
42
+
43
+
44
+ def configure_core(sensitive_fields=None, replace_sensitive_defaults=False, max_string_length=None):
45
+ """
46
+ Configure core logging settings.
47
+
48
+ Args:
49
+ sensitive_fields: List or set of field names to mask
50
+ replace_sensitive_defaults: If True, replace default sensitive fields with provided ones.
51
+ If False, merge provided fields with defaults.
52
+ max_string_length: Maximum length for string truncation
53
+ """
54
+ if sensitive_fields is not None:
55
+ new_fields = set()
56
+ if isinstance(sensitive_fields, (list, tuple)):
57
+ new_fields = set(field.lower() for field in sensitive_fields)
58
+ elif isinstance(sensitive_fields, set):
59
+ new_fields = set(field.lower() for field in sensitive_fields)
60
+
61
+ if replace_sensitive_defaults:
62
+ _config["SENSITIVE_FIELDS"] = new_fields
63
+ else:
64
+ _config["SENSITIVE_FIELDS"].update(new_fields)
65
+
66
+ if max_string_length is not None:
67
+ _config["MAX_STRING_LENGTH"] = max_string_length
68
+
69
+
70
+ def get_config_value(key: str, default=None):
71
+ """Get a value from core configuration."""
72
+ return _config.get(key, default)
73
+
18
74
 
19
75
  def orjson_serializer(_, __, event_dict):
20
76
  """Serialize log entries using orjson for performance."""
@@ -23,15 +79,7 @@ def orjson_serializer(_, __, event_dict):
23
79
 
24
80
  def truncate_long_strings(_, __, event_dict):
25
81
  """Truncate long strings in log entries based on max_string_length setting."""
26
- # Get max length from global settings only
27
- max_length = 100
28
- try:
29
- from .django import _get_setting
30
-
31
- max_length = _get_setting("LOGIFY_MAX_STRING_LENGTH", 100)
32
- except ImportError:
33
- # Use default if django module not available
34
- pass
82
+ max_length = _config["MAX_STRING_LENGTH"]
35
83
 
36
84
  # Truncate long strings - only if max_length is positive
37
85
  if max_length > 0:
@@ -44,14 +92,7 @@ def truncate_long_strings(_, __, event_dict):
44
92
 
45
93
  def clean_non_serializable_objects(_, __, event_dict):
46
94
  """Clean non-serializable objects from log entries."""
47
- # Get settings for string length limits
48
- max_length = 100
49
- try:
50
- from .django import _get_setting
51
-
52
- max_length = _get_setting("LOGIFY_MAX_STRING_LENGTH", 100)
53
- except ImportError:
54
- pass
95
+ max_length = _config["MAX_STRING_LENGTH"]
55
96
 
56
97
  cleaned = {}
57
98
  for key, value in event_dict.items():
@@ -75,63 +116,78 @@ def clean_non_serializable_objects(_, __, event_dict):
75
116
 
76
117
  def mask_sensitive_fields(_, __, event_dict):
77
118
  """Mask sensitive fields in log entries recursively."""
78
- # Default sensitive fields
79
- default_sensitive = [
80
- "password",
81
- "passwd",
82
- "pass",
83
- "pwd",
84
- "secret",
85
- "token",
86
- "key",
87
- "api_key",
88
- "access_token",
89
- "refresh_token",
90
- "auth_token",
91
- "session_key",
92
- "private_key",
93
- "credit_card",
94
- "card_number",
95
- "cvv",
96
- "ssn",
97
- "social_security_number",
98
- ]
99
-
100
- # Get sensitive fields from global settings only
101
- sensitive_fields = default_sensitive
102
- try:
103
- from .django import _get_setting
119
+ sensitive_fields = _config["SENSITIVE_FIELDS"]
120
+
121
+ def _mask_value_if_sensitive(key, value):
122
+ """Check if key is sensitive and return masked value if so."""
123
+ key_lower = key.lower()
124
+ if any(s in key_lower for s in sensitive_fields):
125
+ if value and str(value).strip():
126
+ return "***"
127
+ return value
128
+
129
+ def _scrub_string(text):
130
+ """Scrub sensitive data from strings (e.g. url encoded bodies, query strings)."""
131
+ if not text:
132
+ return text
133
+
134
+ # Handle URLs with query parameters
135
+ if "?" in text:
136
+ base, query = text.split("?", 1)
137
+ # Recursively scrub the query part
138
+ scrubbed_query = _scrub_string(query)
139
+ return f"{base}?{scrubbed_query}"
140
+
141
+ # pattern matching for key=value pairs where key is sensitive
142
+ # We do a simple approach: split by & and =
143
+ if "=" in text:
144
+ # Check if this looks like a query string or form data
145
+ # This is a heuristic
146
+ parts = text.split("&")
147
+ new_parts = []
148
+ modified = False
149
+
150
+ for part in parts:
151
+ if "=" in part:
152
+ k, v = part.split("=", 1)
153
+ # Check if key is sensitive
154
+ k_lower = k.lower()
155
+ if any(s in k_lower for s in sensitive_fields):
156
+ new_parts.append(f"{k}=***")
157
+ modified = True
158
+ else:
159
+ new_parts.append(part)
160
+ else:
161
+ new_parts.append(part)
104
162
 
105
- sensitive_fields = _get_setting("LOGIFY_SENSITIVE_FIELDS", default_sensitive)
106
- except ImportError:
107
- # Use defaults if django module not available
108
- pass
163
+ if modified:
164
+ return "&".join(new_parts)
109
165
 
110
- # Convert to set of lowercase strings
111
- if isinstance(sensitive_fields, (list, tuple)):
112
- sensitive_fields = set(field.lower() for field in sensitive_fields)
113
- elif isinstance(sensitive_fields, set):
114
- sensitive_fields = set(field.lower() for field in sensitive_fields)
115
- else:
116
- sensitive_fields = set(field.lower() for field in default_sensitive)
166
+ return text
117
167
 
118
168
  def _mask_recursive(obj):
119
169
  """Recursively mask sensitive fields in dicts and lists."""
120
170
  if isinstance(obj, dict):
121
171
  masked = {}
122
172
  for k, v in obj.items():
123
- key_lower = k.lower()
124
- # Check if key contains any sensitive substring
125
- if any(s in key_lower for s in sensitive_fields):
126
- if v and str(v).strip():
127
- masked[k] = "***"
128
- else:
129
- masked[k] = v
130
- else:
173
+ # First check if the key itself is sensitive
174
+ v_masked = _mask_value_if_sensitive(k, v)
175
+ if v_masked == "***":
176
+ masked[k] = "***"
177
+ continue
178
+
179
+ # If not masked by key, recurse or string scrub
180
+ if isinstance(v, (dict, list)):
131
181
  masked[k] = _mask_recursive(v)
182
+ elif isinstance(v, str):
183
+ masked[k] = _scrub_string(v)
184
+ else:
185
+ masked[k] = v
132
186
  return masked
133
187
  elif isinstance(obj, list):
134
188
  return [_mask_recursive(item) for item in obj]
189
+ elif isinstance(obj, str):
190
+ return _scrub_string(obj)
135
191
  return obj
136
192
 
137
193
  # Apply masking to the entire event_dict (including top-level keys)
@@ -259,10 +315,23 @@ def get_logger(name: str = "json-logify"):
259
315
  return bound_logger
260
316
 
261
317
 
262
- def configure_logging(service_name: str = "app", level: str = "INFO"):
318
+ def configure_logging(
319
+ service_name: str = "app",
320
+ level: str = "INFO",
321
+ sensitive_fields=None,
322
+ replace_sensitive_defaults=False,
323
+ max_string_length=None,
324
+ ):
263
325
  """Configure logging for the application."""
264
326
  import logging
265
327
 
328
+ # Configure core settings
329
+ configure_core(
330
+ sensitive_fields=sensitive_fields,
331
+ replace_sensitive_defaults=replace_sensitive_defaults,
332
+ max_string_length=max_string_length,
333
+ )
334
+
266
335
  # Set logging level on stdlib logger
267
336
  logging.basicConfig(level=getattr(logging, level.upper(), logging.INFO))
268
337
 
@@ -6,21 +6,43 @@ import logging
6
6
 
7
7
  import structlog
8
8
 
9
- from .core import clear_request_context, configure_logging, generate_request_id, set_request_context
10
-
11
- # Global settings storage for when Django settings are not available
12
- _global_settings = {}
13
-
14
-
15
- def _set_global_setting(key: str, value):
16
- """Set a global setting value."""
17
- _global_settings[key] = value
18
-
19
-
20
- def _get_setting(key: str, default=None):
21
- """Get setting value from global settings only."""
22
- # Return value from global settings (set by get_logging_config parameters)
23
- return _global_settings.get(key, default)
9
+ from .core import clear_request_context, configure_core
10
+ from .core import configure_logging as configure_core_logging
11
+ from .core import generate_request_id, get_config_value, info, set_request_context
12
+
13
+ # Django-specific settings storage
14
+ _django_settings = {
15
+ "SERVICE_NAME": "django",
16
+ "IGNORE_PATHS": ["/health/", "/healthz/", "/api/schema/", "/static/", "/favicon.ico", "/robots.txt"],
17
+ "EXCLUDED_FIELDS": {
18
+ "name",
19
+ "msg",
20
+ "args",
21
+ "levelname",
22
+ "levelno",
23
+ "pathname",
24
+ "filename",
25
+ "module",
26
+ "lineno",
27
+ "funcName",
28
+ "created",
29
+ "msecs",
30
+ "relativeCreated",
31
+ "thread",
32
+ "threadName",
33
+ "processName",
34
+ "process",
35
+ "message",
36
+ "exc_info",
37
+ "exc_text",
38
+ "stack_info",
39
+ "getMessage",
40
+ "request",
41
+ "response",
42
+ "server_time",
43
+ "status_code",
44
+ },
45
+ }
24
46
 
25
47
 
26
48
  def get_logging_config(
@@ -29,6 +51,7 @@ def get_logging_config(
29
51
  json_logs: bool = True,
30
52
  excluded_fields: list = None,
31
53
  sensitive_fields: list = None,
54
+ replace_sensitive_defaults: bool = False,
32
55
  max_string_length: int = None,
33
56
  ignore_paths: list = None,
34
57
  ):
@@ -41,6 +64,7 @@ def get_logging_config(
41
64
  json_logs: Whether to enable JSON logging
42
65
  excluded_fields: List of fields to exclude from logs
43
66
  sensitive_fields: List of fields to mask with *** (also used for sensitive headers)
67
+ replace_sensitive_defaults: If True, replace default sensitive fields. If False, merge.
44
68
  max_string_length: Maximum length for string truncation
45
69
  ignore_paths: List of URL paths to ignore from logging
46
70
 
@@ -51,23 +75,31 @@ def get_logging_config(
51
75
  level="INFO",
52
76
  excluded_fields=["custom_field"],
53
77
  sensitive_fields=["password", "secret", "authorization"],
78
+ replace_sensitive_defaults=False, # Optional, defaults to False (merge)
54
79
  max_string_length=200,
55
80
  ignore_paths=["/health/", "/static/"]
56
81
  )
57
82
  """
83
+ # Configure core settings (this sets global state in core module)
84
+ configure_core(
85
+ sensitive_fields=sensitive_fields,
86
+ replace_sensitive_defaults=replace_sensitive_defaults,
87
+ max_string_length=max_string_length,
88
+ )
89
+
58
90
  if json_logs:
59
- configure_logging(service_name=service_name, level=level)
91
+ # This sets up the structlog pipeline
92
+ configure_core_logging(service_name=service_name, level=level)
93
+
94
+ # Store Django-specific settings
95
+ _django_settings["SERVICE_NAME"] = service_name
60
96
 
61
- # Store settings globally for the processors to use
62
- _set_global_setting("SERVICE_NAME", service_name)
63
97
  if excluded_fields is not None:
64
- _set_global_setting("LOGIFY_EXCLUDED_FIELDS", excluded_fields)
65
- if sensitive_fields is not None:
66
- _set_global_setting("LOGIFY_SENSITIVE_FIELDS", sensitive_fields)
67
- if max_string_length is not None:
68
- _set_global_setting("LOGIFY_MAX_STRING_LENGTH", max_string_length)
98
+ default_excluded = _django_settings["EXCLUDED_FIELDS"]
99
+ _django_settings["EXCLUDED_FIELDS"] = set(default_excluded) | set(excluded_fields)
100
+
69
101
  if ignore_paths is not None:
70
- _set_global_setting("LOGIFY_IGNORE_PATHS", ignore_paths)
102
+ _django_settings["IGNORE_PATHS"] = ignore_paths
71
103
 
72
104
  return {
73
105
  "version": 1,
@@ -116,15 +148,12 @@ class LogifyMiddleware:
116
148
  if self._should_ignore_path(request.path):
117
149
  return self.get_response(request)
118
150
 
119
- # Import here to avoid circular imports
120
- from .core import info
121
-
122
151
  # Generate request ID and set context
123
152
  request_id = generate_request_id()
124
153
  request.logify_request_id = request_id
125
154
 
126
- # Get service name from global settings or use default
127
- service_name = _get_setting("SERVICE_NAME", "django-app")
155
+ # Get service name
156
+ service_name = _django_settings.get("SERVICE_NAME", "django-app")
128
157
 
129
158
  # Get user info
130
159
  user_info = self._get_user_info(request)
@@ -132,7 +161,7 @@ class LogifyMiddleware:
132
161
  # Get and scrub headers
133
162
  scrubbed_headers = self._scrub_headers(dict(request.headers))
134
163
 
135
- # Get request body (no scrubbing - core processors will handle)
164
+ # Get request body (no scrubbing needed here - core processors will handle masking)
136
165
  request_body = self._get_request_body(request)
137
166
 
138
167
  # Log request start with all context information ONCE
@@ -154,7 +183,7 @@ class LogifyMiddleware:
154
183
  try:
155
184
  response = self.get_response(request)
156
185
 
157
- # Get response body with content type filtering (no scrubbing)
186
+ # Get response body with content type filtering
158
187
  response_body = self._get_response_body(response)
159
188
 
160
189
  # Log request completion with response info
@@ -173,13 +202,7 @@ class LogifyMiddleware:
173
202
 
174
203
  def _should_ignore_path(self, path):
175
204
  """Check if path should be ignored from logging."""
176
- # Get ignore paths from global settings
177
- default_ignore_paths = ["/health/", "/healthz/", "/api/schema/", "/static/", "/favicon.ico", "/robots.txt"]
178
-
179
- try:
180
- ignore_paths = _get_setting("LOGIFY_IGNORE_PATHS", default_ignore_paths)
181
- except Exception:
182
- ignore_paths = default_ignore_paths
205
+ ignore_paths = _django_settings.get("IGNORE_PATHS", [])
183
206
 
184
207
  if isinstance(ignore_paths, (list, tuple)):
185
208
  for ignore_path in ignore_paths:
@@ -195,37 +218,13 @@ class LogifyMiddleware:
195
218
 
196
219
  def _scrub_headers(self, headers):
197
220
  """Mask sensitive headers using sensitive_fields settings."""
198
- # Get sensitive fields which will be used for headers too
199
- default_sensitive = [
200
- "password",
201
- "passwd",
202
- "pass",
203
- "pwd",
204
- "secret",
205
- "token",
206
- "key",
207
- "api_key",
208
- "access_token",
209
- "refresh_token",
210
- "auth_token",
211
- "session_key",
212
- "private_key",
213
- "authorization",
214
- "cookie",
215
- "x-api-key",
216
- "x-auth-token",
217
- "x-csrf-token",
218
- ]
219
-
220
- try:
221
- sensitive_fields = _get_setting("LOGIFY_SENSITIVE_FIELDS", default_sensitive)
222
- except Exception:
223
- sensitive_fields = default_sensitive
221
+ # Get sensitive fields from core config
222
+ sensitive_fields = get_config_value("SENSITIVE_FIELDS")
224
223
 
225
- if isinstance(sensitive_fields, (list, tuple)):
226
- sensitive_fields = set(field.lower() for field in sensitive_fields)
227
- else:
228
- sensitive_fields = set(field.lower() for field in default_sensitive)
224
+ # We need to manually scrub here because headers are a dict we are constructing to pass to log
225
+ # And we want to label filtered headers specifically as [FILTERED] sometimes, or just use core logic.
226
+ # But core logic turns things into "***".
227
+ # Let's align with core logic.
229
228
 
230
229
  scrubbed = {}
231
230
  for key, value in headers.items():
@@ -238,7 +237,7 @@ class LogifyMiddleware:
238
237
  return scrubbed
239
238
 
240
239
  def _get_request_body(self, request):
241
- """Get request body with size limits (no scrubbing - core processors will handle)."""
240
+ """Get request body with size limits."""
242
241
  if not request.body:
243
242
  return None
244
243
 
@@ -249,27 +248,32 @@ class LogifyMiddleware:
249
248
  if request.content_type and "json" in request.content_type:
250
249
  import json
251
250
 
252
- request_body = json.loads(body_bytes.decode("utf-8"))
253
- return request_body
254
- else:
255
- # For non-JSON data, try to parse as form data
256
- if request.method in ["POST", "PUT", "PATCH"]:
257
- try:
258
- # Try form data first
259
- form_data = dict(request.POST)
260
- if form_data:
261
- return form_data
262
- except Exception:
263
- pass
264
-
265
- # Fall back to raw string (truncated)
266
- return body_bytes.decode("utf-8", errors="replace")[:1000]
251
+ try:
252
+ request_body = json.loads(body_bytes.decode("utf-8"))
253
+ return request_body
254
+ except Exception:
255
+ # Fallback if JSON parse fails
256
+ pass
257
+
258
+ # For non-JSON data, try to parse as form data
259
+ if request.method in ["POST", "PUT", "PATCH"]:
260
+ try:
261
+ # Try form data first
262
+ form_data = dict(request.POST)
263
+ if form_data:
264
+ return form_data
265
+ except Exception:
266
+ pass
267
+
268
+ # Fall back to raw string (truncated)
269
+ # The CORE processor will mask sensitive data in this string!
270
+ return body_bytes.decode("utf-8", errors="replace")[:1000]
267
271
 
268
272
  except Exception:
269
273
  return "<non-readable body>"
270
274
 
271
275
  def _get_response_body(self, response):
272
- """Get response body with content type filtering (no scrubbing - core processors will handle)."""
276
+ """Get response body with content type filtering."""
273
277
  if not hasattr(response, "content") or not response.content:
274
278
  return None
275
279
 
@@ -298,11 +302,14 @@ class LogifyMiddleware:
298
302
  if "json" in content_type:
299
303
  import json
300
304
 
301
- response_body = json.loads(body_bytes.decode("utf-8"))
302
- return response_body
303
- else:
304
- # For non-JSON responses, return truncated text
305
- return body_bytes.decode("utf-8", errors="replace")[:1000]
305
+ try:
306
+ response_body = json.loads(body_bytes.decode("utf-8"))
307
+ return response_body
308
+ except Exception:
309
+ pass
310
+
311
+ # For non-JSON responses, return truncated text
312
+ return body_bytes.decode("utf-8", errors="replace")[:1000]
306
313
 
307
314
  except Exception:
308
315
  return "<non-readable body>"
@@ -314,7 +321,7 @@ def setup_django_logging(service_name: str = "django"):
314
321
  Call this in your Django settings or apps.py
315
322
  """
316
323
  # Configure json-logify structlog
317
- configure_logging(service_name=service_name)
324
+ configure_core_logging(service_name=service_name)
318
325
 
319
326
  # Configure structlog to intercept standard logging
320
327
  structlog.configure(
@@ -349,85 +356,19 @@ class StructlogHandler(logging.Handler):
349
356
  structlog_method = getattr(self.structlog_logger, level_name, self.structlog_logger.info)
350
357
 
351
358
  # Get excluded fields from settings
352
- default_excluded = [
353
- "name",
354
- "msg",
355
- "args",
356
- "levelname",
357
- "levelno",
358
- "pathname",
359
- "filename",
360
- "module",
361
- "lineno",
362
- "funcName",
363
- "created",
364
- "msecs",
365
- "relativeCreated",
366
- "thread",
367
- "threadName",
368
- "processName",
369
- "process",
370
- "message",
371
- "exc_info",
372
- "exc_text",
373
- "stack_info",
374
- "getMessage",
375
- # Add Django-specific problematic fields
376
- "request",
377
- "response",
378
- "server_time",
379
- "status_code",
380
- ]
381
-
382
- excluded_fields = _get_setting("LOGIFY_EXCLUDED_FIELDS", default_excluded)
383
- if isinstance(excluded_fields, (list, tuple)):
384
- excluded_fields = set(excluded_fields)
385
- elif not isinstance(excluded_fields, set):
386
- excluded_fields = set(default_excluded)
387
-
388
- # Get sensitive fields from settings
389
- default_sensitive = [
390
- "password",
391
- "passwd",
392
- "pass",
393
- "pwd",
394
- "secret",
395
- "token",
396
- "key",
397
- "api_key",
398
- "access_token",
399
- "refresh_token",
400
- "auth_token",
401
- "session_key",
402
- "private_key",
403
- "credit_card",
404
- "card_number",
405
- "cvv",
406
- "ssn",
407
- "social_security_number",
408
- ]
409
- sensitive_fields = _get_setting("LOGIFY_SENSITIVE_FIELDS", default_sensitive)
410
- if isinstance(sensitive_fields, (list, tuple)):
411
- sensitive_fields = set(field.lower() for field in sensitive_fields)
412
- elif not isinstance(sensitive_fields, set):
413
- sensitive_fields = set(field.lower() for field in default_sensitive)
359
+ excluded_fields = _django_settings.get("EXCLUDED_FIELDS", set())
414
360
 
415
- # Extract extra fields, excluding specified fields and masking sensitive ones
361
+ # Extract extra fields, excluding specified fields
362
+ # Note: We rely on core processors to mask sensitive data!
416
363
  extra = {}
417
364
  for key, value in record.__dict__.items():
418
365
  if key not in excluded_fields:
419
366
  # Skip complex objects that can't be JSON serialized
367
+ # Core has a cleaner which handles this too, but we can filtering here for safety
420
368
  if hasattr(value, "__dict__") and not isinstance(value, (str, int, float, bool, list, dict)):
421
369
  continue
422
370
 
423
- # Mask sensitive fields
424
- if key.lower() in sensitive_fields:
425
- if value and str(value).strip():
426
- extra[key] = "***"
427
- else:
428
- extra[key] = value
429
- else:
430
- extra[key] = value
371
+ extra[key] = value
431
372
 
432
373
  # Log with structlog
433
374
  structlog_method(
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "json-logify"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Universal structured logging with exact JSON schema for Python frameworks"
9
9
  readme = "README.md"
10
10
  license = {file = "LICENSE"}
@@ -0,0 +1,54 @@
1
+ """Tests for configuration logic."""
2
+
3
+ from logify.core import configure_core, get_config_value
4
+
5
+
6
+ class TestConfigurationLogic:
7
+ """Test configuration logic including merge and replace strategies."""
8
+
9
+ def setup_method(self):
10
+ """Reset configuration before each test."""
11
+ # Reset to known state.
12
+ # Note: We can't easily "reset" global state perfectly without private access,
13
+ # but we can set it to a known state using replace_sensitive_defaults=True.
14
+ configure_core(sensitive_fields=["password", "token"], replace_sensitive_defaults=True, max_string_length=100)
15
+
16
+ def test_default_merge_behavior(self):
17
+ """Test that sensitive fields are merged by default."""
18
+ # Setup initial state
19
+ configure_core(sensitive_fields=["initial"], replace_sensitive_defaults=True)
20
+ assert "initial" in get_config_value("SENSITIVE_FIELDS")
21
+
22
+ # Test merge (default)
23
+ configure_core(sensitive_fields=["new_field"])
24
+
25
+ fields = get_config_value("SENSITIVE_FIELDS")
26
+ assert "initial" in fields
27
+ assert "new_field" in fields
28
+ assert len(fields) >= 2
29
+
30
+ def test_explicit_replace_behavior(self):
31
+ """Test that sensitive fields are replaced when requested."""
32
+ # Setup initial state
33
+ configure_core(sensitive_fields=["initial"], replace_sensitive_defaults=True)
34
+
35
+ # Test replace
36
+ configure_core(sensitive_fields=["replaced"], replace_sensitive_defaults=True)
37
+
38
+ fields = get_config_value("SENSITIVE_FIELDS")
39
+ assert "initial" not in fields
40
+ assert "replaced" in fields
41
+ assert len(fields) == 1
42
+
43
+ def test_max_string_length_update(self):
44
+ """Test max string length update."""
45
+ configure_core(max_string_length=500)
46
+ assert get_config_value("MAX_STRING_LENGTH") == 500
47
+
48
+ configure_core(max_string_length=10)
49
+ assert get_config_value("MAX_STRING_LENGTH") == 10
50
+
51
+ def test_clearing_fields_with_replace(self):
52
+ """Test clearing all sensitive fields."""
53
+ configure_core(sensitive_fields=[], replace_sensitive_defaults=True)
54
+ assert len(get_config_value("SENSITIVE_FIELDS")) == 0
@@ -208,6 +208,63 @@ class TestLogifyMiddleware:
208
208
  response = middleware(request)
209
209
  assert response.status_code == 200
210
210
 
211
+ @patch("sys.stdout", new_callable=StringIO)
212
+ def test_sensitive_body_masking_integration(self, mock_stdout):
213
+ """
214
+ REGRESSION TEST: Verify that raw request bodies with sensitive data are masked.
215
+ This ensures the vulnerability (leaking passwords in raw bodies) is fixed.
216
+ """
217
+ from logify.core import configure_logging
218
+
219
+ # Reset defaults explicitly to avoid pollution from other tests
220
+ configure_logging(
221
+ "test-django", "INFO", sensitive_fields=["password", "token", "secret"], replace_sensitive_defaults=True
222
+ )
223
+
224
+ def mock_get_response(request):
225
+ return MockDjangoResponse()
226
+
227
+ middleware = LogifyMiddleware(mock_get_response)
228
+
229
+ # Create a request with a raw body that looks like form data but isn't parsed as such
230
+ # (e.g. invalid content type or just raw bytes read)
231
+ sensitive_content = "username=admin&password=supersecretpassword&token=12345"
232
+
233
+ request = MockDjangoRequest(method="POST", path="/login")
234
+ request.body = sensitive_content.encode("utf-8")
235
+ request.content_type = "application/x-www-form-urlencoded" # Logic tries to parse this
236
+
237
+ # We need to simulate the case where parse fails OR just standard body logging
238
+ # The middleware `_get_request_body` tries to parse form data if it can.
239
+ # If we want to test the FALLBACK (raw string scrubbing), we should ensure form parsing fails
240
+ # OR we just rely on the fact that `request.POST` might be empty in this mock if we don't set it.
241
+ # MockDjangoRequest sets self.POST = {}, so parsing returns empty, logic falls back to raw body?
242
+ # Let's check `django.py`:
243
+ # if method in POST/PUT...: try: form_data = dict(request.POST); if form_data: return form_data
244
+ # If request.POST is empty, it falls back to: body_bytes.decode()[:1000]
245
+ # Perfect.
246
+
247
+ middleware(request)
248
+
249
+ output = mock_stdout.getvalue().strip()
250
+
251
+ # Find the log entry with request_body
252
+ lines = output.splitlines()
253
+ log_entry = None
254
+ for line in lines:
255
+ if "request_body" in line:
256
+ log_entry = json.loads(line)
257
+ break
258
+
259
+ assert log_entry is not None, "Did not find log with request_body"
260
+
261
+ body_log = log_entry["payload"].get("request_body")
262
+ assert isinstance(body_log, str), "Body should be logged as string when parsing fails"
263
+
264
+ # THE CORE ASSERTION:
265
+ assert "password=***" in body_log, "Password should be masked in raw body log"
266
+ assert "supersecretpassword" not in body_log, "vulnerability: Plaintext password leaked!"
267
+
211
268
 
212
269
  class TestDjangoIntegration:
213
270
  """Test Django integration scenarios."""
@@ -0,0 +1,86 @@
1
+ """Tests for security masking logic."""
2
+
3
+
4
+ from logify.core import configure_core, mask_sensitive_fields
5
+
6
+
7
+ class TestSecurityMasking:
8
+ """Test security masking including raw string scrubbing."""
9
+
10
+ def setup_method(self):
11
+ """Set up sensitive fields."""
12
+ configure_core(sensitive_fields=["password", "token", "secret", "key"], replace_sensitive_defaults=True)
13
+
14
+ def test_recursive_dict_masking(self):
15
+ """Test standard recursive dictionary masking."""
16
+ event = {
17
+ "user": "baha",
18
+ "password": "supersecret",
19
+ "nested": {"token": "12345", "safe": "data"},
20
+ "list": [{"key": "private_key", "value": "check"}],
21
+ }
22
+
23
+ masked = mask_sensitive_fields(None, None, event)
24
+
25
+ assert masked["password"] == "***"
26
+ assert masked["nested"]["token"] == "***"
27
+ assert masked["nested"]["safe"] == "data"
28
+ assert masked["list"][0]["key"] == "***"
29
+
30
+ def test_raw_string_scrubbing_basic(self):
31
+ """Test scrubbing of raw strings with key=value patterns."""
32
+ event = {"body": "username=admin&password=secret123&other=value"}
33
+
34
+ masked = mask_sensitive_fields(None, None, event)
35
+
36
+ assert "password=***" in masked["body"]
37
+ assert "secret123" not in masked["body"]
38
+ assert "username=admin" in masked["body"]
39
+
40
+ def test_raw_string_scrubbing_multiple(self):
41
+ """Test scrubbing multiple sensitive fields in one string."""
42
+ event = {"data": "api_key=12345&secret=ABCDE&public=yes"}
43
+ # Note: api_key is not in our setup list above, let's add it to be safe or rely on substring match
44
+ # Wait, 'key' is in the list. 'api_key' contains 'key'.
45
+
46
+ masked = mask_sensitive_fields(None, None, event)
47
+
48
+ assert "api_key=***" in masked["data"]
49
+ assert "secret=***" in masked["data"]
50
+ assert "12345" not in masked["data"]
51
+ assert "ABCDE" not in masked["data"]
52
+
53
+ def test_raw_string_edge_cases(self):
54
+ """Test edge cases for string scrubbing."""
55
+ # Empty value
56
+ event = {"empty": "password="}
57
+ masked = mask_sensitive_fields(None, None, event)
58
+ # Should simple remain password= or password=***?
59
+ # Logic says: parts.split("=", 1).
60
+ # We assume value is empty string.
61
+ # Logic: if any(s in k_lower): new_parts.append(f"{k}=***")
62
+ # So even empty password gets masked to ***. This is acceptable/safer.
63
+ assert masked["empty"] == "password=***"
64
+
65
+ # No sensitive data
66
+ event = {"safe": "user=admin&role=editor"}
67
+ masked = mask_sensitive_fields(None, None, event)
68
+ assert masked["safe"] == "user=admin&role=editor"
69
+
70
+ # Malformed but suspicious
71
+ event = {"weird": "password=secret=token"}
72
+ # split by &, then each by =.
73
+ # Here only one part? "password=secret=token"
74
+ # k="password", v="secret=token".
75
+ # Should become "password=***".
76
+ masked = mask_sensitive_fields(None, None, event)
77
+ assert masked["weird"] == "password=***"
78
+
79
+ def test_scrubbing_inside_lists(self):
80
+ """Test scrubbing strings inside lists."""
81
+ event = {"history": ["path=/login?password=secret", "normal_string"]}
82
+
83
+ masked = mask_sensitive_fields(None, None, event)
84
+
85
+ assert "password=***" in masked["history"][0]
86
+ assert "secret" not in masked["history"][0]
File without changes
File without changes
File without changes
File without changes