Skip to content

Commit 9e681dc

Browse files
committed
feat(MET-29): Add sensitive data filtering
- Implement SensitiveDataFilter class with 23+ default sensitive fields - Automatic filtering of passwords, tokens, API keys, credit cards, etc. - Case-insensitive matching with partial key matching support - Recursive filtering for nested dicts and lists - Configurable custom sensitive fields via DJANGO_SONAR settings - Apply filter to POST/GET data, headers, and session data - Refactor core module structure (parsers, collectors, filters) - Add comprehensive test coverage (13+ tests for filtering) - Update README with security documentation Resolves: MET-29
1 parent aaeda57 commit 9e681dc

20 files changed

+2091
-219
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,4 @@ cython_debug/
159159
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
160160
.idea/
161161
.vscode/
162-
.DS_Store
162+
.DS_Store

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ If you use this project, please consider giving it a ⭐.
3434
- Session vars
3535
- Headers
3636
- ...
37+
- 🔒 Automatic sensitive data filtering (passwords, tokens, API keys, etc.)
3738
- Historical data (clearable)
3839
- Simple and reactive UI
3940

@@ -83,6 +84,35 @@ DJANGO_SONAR = {
8384
In this example I'm excluding all the http requests to static files, uploads, the sonar dashboard itself, the django admin panels and the browser reload library.
8485
Update this setting accordingly, YMMW.
8586

87+
### 🔒 Sensitive Data Protection
88+
89+
DjangoSonar automatically filters sensitive data from request payloads, headers, and session data before storing them in the database. By default, it masks common sensitive fields like:
90+
- `password`, `passwd`, `pwd`, `pass`
91+
- `token`, `api_key`, `secret`, `authorization`
92+
- `credit_card`, `cvv`, `ssn`, `pin`
93+
- And more...
94+
95+
These fields are replaced with `***FILTERED***` in the stored data.
96+
97+
**Custom Sensitive Fields**: You can add your own sensitive field patterns by adding them to the configuration:
98+
99+
```python
100+
DJANGO_SONAR = {
101+
'excludes': [
102+
STATIC_URL,
103+
MEDIA_URL,
104+
'/sonar/',
105+
],
106+
'sensitive_fields': [
107+
'custom_secret',
108+
'internal_api_key',
109+
'private_data',
110+
],
111+
}
112+
```
113+
114+
The filtering is case-insensitive and works with partial matches (e.g., `user_password`, `my_api_key` will also be filtered).
115+
86116
5. Now you should be able to execute the migrations to create the two tables that DjangoSonar will use to collect the data.
87117

88118
```bash

django_sonar/core/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""
2+
Django Sonar Core Module
3+
4+
Core business logic for request processing, data collection, and filtering.
5+
"""
6+
7+
from .parsers import RequestParser
8+
from .collectors import DataCollector
9+
from .filters import PathFilter, SensitiveDataFilter
10+
11+
__all__ = [
12+
'RequestParser',
13+
'DataCollector',
14+
'PathFilter',
15+
'SensitiveDataFilter',
16+
]

django_sonar/core/collectors.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
Data collection and persistence utilities.
3+
4+
Handles saving request data to SonarData model with different categories:
5+
- details (user info, view function, memory usage)
6+
- payload (GET/POST data)
7+
- queries (database queries)
8+
- headers (request headers)
9+
- session (session data)
10+
- dumps (sonar() dumps)
11+
- exceptions (exception data)
12+
"""
13+
14+
from django_sonar.models import SonarData
15+
from django_sonar import utils
16+
17+
18+
class DataCollector:
19+
"""Handles collection and persistence of request data"""
20+
21+
def __init__(self, sonar_request_uuid):
22+
"""
23+
Initialize collector with request UUID.
24+
25+
:param sonar_request_uuid: UUID of the SonarRequest instance
26+
"""
27+
self.sonar_request_uuid = sonar_request_uuid
28+
29+
def save_details(self, user_info, view_func, middlewares_used, memory_diff):
30+
"""
31+
Save request details (user, view, memory, middlewares).
32+
33+
:param user_info: Dictionary with user information or None
34+
:param view_func: String identifying the view function
35+
:param middlewares_used: List/tuple of middleware names
36+
:param memory_diff: Memory usage in MB
37+
"""
38+
details = {
39+
'user_info': user_info,
40+
'view_func': view_func,
41+
'middlewares_used': middlewares_used,
42+
'memory_used': memory_diff
43+
}
44+
SonarData.objects.create(
45+
sonar_request_id=self.sonar_request_uuid,
46+
category='details',
47+
data=details
48+
)
49+
50+
def save_payload(self, get_payload, post_payload):
51+
"""
52+
Save request payload (GET and POST/body data).
53+
54+
:param get_payload: Dictionary with GET parameters
55+
:param post_payload: Dictionary with POST/body data
56+
"""
57+
payload = {
58+
'get_payload': get_payload,
59+
'post_payload': post_payload
60+
}
61+
SonarData.objects.create(
62+
sonar_request_id=self.sonar_request_uuid,
63+
category='payload',
64+
data=payload
65+
)
66+
67+
def save_queries(self, executed_queries):
68+
"""
69+
Save database queries executed during request.
70+
71+
:param executed_queries: List of query dictionaries from Django
72+
"""
73+
queries = {
74+
'executed_queries': executed_queries
75+
}
76+
SonarData.objects.create(
77+
sonar_request_id=self.sonar_request_uuid,
78+
category='queries',
79+
data=queries
80+
)
81+
82+
def save_headers(self, request_headers):
83+
"""
84+
Save request headers.
85+
86+
:param request_headers: Dictionary with request headers
87+
"""
88+
headers = {
89+
'request_headers': request_headers
90+
}
91+
SonarData.objects.create(
92+
sonar_request_id=self.sonar_request_uuid,
93+
category='headers',
94+
data=headers
95+
)
96+
97+
def save_session(self, session_data):
98+
"""
99+
Save session data.
100+
101+
:param session_data: Dictionary with session data
102+
"""
103+
session = {
104+
'session_data': session_data
105+
}
106+
SonarData.objects.create(
107+
sonar_request_id=self.sonar_request_uuid,
108+
category='session',
109+
data=session
110+
)
111+
112+
def save_dumps(self):
113+
"""
114+
Save sonar() dumps from thread local storage.
115+
116+
Retrieves dumps from utils.get_sonar_dump() and resets them.
117+
"""
118+
sonar_dumps = utils.get_sonar_dump()
119+
for dump in sonar_dumps:
120+
SonarData.objects.create(
121+
sonar_request_id=self.sonar_request_uuid,
122+
category='dumps',
123+
data=dump
124+
)
125+
utils.reset_sonar_dump()
126+
127+
def save_exceptions(self):
128+
"""
129+
Save exception data from thread local storage.
130+
131+
Retrieves exceptions from utils.get_sonar_exceptions() and resets them.
132+
"""
133+
sonar_exceptions = utils.get_sonar_exceptions()
134+
for ex in sonar_exceptions:
135+
SonarData.objects.create(
136+
sonar_request_id=self.sonar_request_uuid,
137+
category='exception',
138+
data=ex
139+
)
140+
utils.reset_sonar_exceptions()

django_sonar/core/filters.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""
2+
Path filtering utilities.
3+
4+
Handles exclusion of specific paths from monitoring based on:
5+
- Literal string matching (e.g., '/admin/')
6+
- Regex patterns (e.g., 'r^/api/v[0-9]+/')
7+
8+
Sensitive data filtering utilities.
9+
10+
Handles masking of sensitive data in payloads, headers, and session data.
11+
"""
12+
13+
import re
14+
from django.conf import settings
15+
16+
17+
class PathFilter:
18+
"""Handles path exclusion logic for the middleware"""
19+
20+
def __init__(self):
21+
"""Initialize filter by compiling exclusion patterns from settings"""
22+
self.compiled_excludes = self._compile_excludes()
23+
24+
def _compile_excludes(self):
25+
"""
26+
Compile the excludes list provided in the settings.
27+
28+
Patterns starting with 'r' are treated as regex patterns,
29+
all others are treated as literal strings for startswith matching.
30+
31+
:return: List of tuples (pattern_type, pattern) where pattern_type
32+
is 'regex' or 'literal'
33+
"""
34+
excluded_paths = settings.DJANGO_SONAR.get('excludes', [])
35+
compiled = []
36+
37+
for pattern in excluded_paths:
38+
if isinstance(pattern, str) and pattern.startswith('r'):
39+
try:
40+
regex_pattern = pattern[1:]
41+
compiled.append(('regex', re.compile(regex_pattern)))
42+
except re.error:
43+
# If regex compilation fails, treat as literal
44+
compiled.append(('literal', pattern))
45+
else:
46+
compiled.append(('literal', pattern))
47+
48+
return compiled
49+
50+
def should_exclude(self, path):
51+
"""
52+
Check if the path should be excluded based on configured patterns.
53+
54+
:param path: Request path to check
55+
:return: True if path should be excluded, False otherwise
56+
"""
57+
for pattern_type, pattern in self.compiled_excludes:
58+
if pattern_type == 'regex':
59+
if pattern.match(path):
60+
return True
61+
else: # literal
62+
if path.startswith(pattern):
63+
return True
64+
return False
65+
66+
67+
class SensitiveDataFilter:
68+
"""Handles sensitive data masking in request data"""
69+
70+
# Default sensitive field patterns (case-insensitive)
71+
DEFAULT_SENSITIVE_FIELDS = [
72+
'password',
73+
'passwd',
74+
'pwd',
75+
'pass',
76+
'secret',
77+
'api_key',
78+
'apikey',
79+
'api_secret',
80+
'token',
81+
'access_token',
82+
'refresh_token',
83+
'auth',
84+
'authorization',
85+
'credit_card',
86+
'card_number',
87+
'cvv',
88+
'cvc',
89+
'ssn',
90+
'pin',
91+
'session_id',
92+
'csrf',
93+
'private_key',
94+
]
95+
96+
MASK_VALUE = '***FILTERED***'
97+
98+
def __init__(self):
99+
"""Initialize filter with configured sensitive fields"""
100+
sonar_settings = getattr(settings, 'DJANGO_SONAR', {})
101+
custom_fields = sonar_settings.get('sensitive_fields', [])
102+
103+
# Combine default and custom fields, convert to lowercase for case-insensitive matching
104+
self.sensitive_fields = set(
105+
field.lower() for field in (self.DEFAULT_SENSITIVE_FIELDS + custom_fields)
106+
)
107+
108+
def _is_sensitive_key(self, key):
109+
"""
110+
Check if a key represents sensitive data.
111+
112+
:param key: Field name to check
113+
:return: True if key is sensitive, False otherwise
114+
"""
115+
if not isinstance(key, str):
116+
return False
117+
118+
key_lower = key.lower()
119+
120+
# Check for exact matches
121+
if key_lower in self.sensitive_fields:
122+
return True
123+
124+
# Check if any sensitive field is contained in the key
125+
for sensitive_field in self.sensitive_fields:
126+
if sensitive_field in key_lower:
127+
return True
128+
129+
return False
130+
131+
def filter_dict(self, data):
132+
"""
133+
Recursively filter sensitive data from a dictionary.
134+
135+
:param data: Dictionary to filter
136+
:return: Filtered dictionary with sensitive values masked
137+
"""
138+
if not isinstance(data, dict):
139+
return data
140+
141+
filtered = {}
142+
for key, value in data.items():
143+
if self._is_sensitive_key(key):
144+
filtered[key] = self.MASK_VALUE
145+
elif isinstance(value, dict):
146+
filtered[key] = self.filter_dict(value)
147+
elif isinstance(value, (list, tuple)):
148+
filtered[key] = self._filter_list(value)
149+
else:
150+
filtered[key] = value
151+
152+
return filtered
153+
154+
def _filter_list(self, data):
155+
"""
156+
Filter sensitive data from a list/tuple.
157+
158+
:param data: List or tuple to filter
159+
:return: Filtered list with sensitive values masked
160+
"""
161+
filtered = []
162+
for item in data:
163+
if isinstance(item, dict):
164+
filtered.append(self.filter_dict(item))
165+
elif isinstance(item, (list, tuple)):
166+
filtered.append(self._filter_list(item))
167+
else:
168+
filtered.append(item)
169+
170+
return filtered if isinstance(data, list) else tuple(filtered)

0 commit comments

Comments
 (0)