iflow-mcp_enuno-unifi-mcp-server 0.2.1__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.
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
- src/__init__.py +3 -0
- src/__main__.py +6 -0
- src/api/__init__.py +5 -0
- src/api/client.py +727 -0
- src/api/site_manager_client.py +176 -0
- src/cache.py +483 -0
- src/config/__init__.py +5 -0
- src/config/config.py +321 -0
- src/main.py +2234 -0
- src/models/__init__.py +126 -0
- src/models/acl.py +41 -0
- src/models/backup.py +272 -0
- src/models/client.py +74 -0
- src/models/device.py +53 -0
- src/models/dpi.py +50 -0
- src/models/firewall_policy.py +123 -0
- src/models/firewall_zone.py +28 -0
- src/models/network.py +62 -0
- src/models/qos_profile.py +458 -0
- src/models/radius.py +141 -0
- src/models/reference_data.py +34 -0
- src/models/site.py +59 -0
- src/models/site_manager.py +120 -0
- src/models/topology.py +138 -0
- src/models/traffic_flow.py +137 -0
- src/models/traffic_matching_list.py +56 -0
- src/models/voucher.py +42 -0
- src/models/vpn.py +73 -0
- src/models/wan.py +48 -0
- src/models/zbf_matrix.py +49 -0
- src/resources/__init__.py +8 -0
- src/resources/clients.py +111 -0
- src/resources/devices.py +102 -0
- src/resources/networks.py +93 -0
- src/resources/site_manager.py +64 -0
- src/resources/sites.py +86 -0
- src/tools/__init__.py +25 -0
- src/tools/acls.py +328 -0
- src/tools/application.py +42 -0
- src/tools/backups.py +1173 -0
- src/tools/client_management.py +505 -0
- src/tools/clients.py +203 -0
- src/tools/device_control.py +325 -0
- src/tools/devices.py +354 -0
- src/tools/dpi.py +241 -0
- src/tools/dpi_tools.py +89 -0
- src/tools/firewall.py +417 -0
- src/tools/firewall_policies.py +430 -0
- src/tools/firewall_zones.py +515 -0
- src/tools/network_config.py +388 -0
- src/tools/networks.py +190 -0
- src/tools/port_forwarding.py +263 -0
- src/tools/qos.py +1070 -0
- src/tools/radius.py +763 -0
- src/tools/reference_data.py +107 -0
- src/tools/site_manager.py +466 -0
- src/tools/site_vpn.py +95 -0
- src/tools/sites.py +187 -0
- src/tools/topology.py +406 -0
- src/tools/traffic_flows.py +1062 -0
- src/tools/traffic_matching_lists.py +371 -0
- src/tools/vouchers.py +249 -0
- src/tools/vpn.py +76 -0
- src/tools/wans.py +30 -0
- src/tools/wifi.py +498 -0
- src/tools/zbf_matrix.py +326 -0
- src/utils/__init__.py +88 -0
- src/utils/audit.py +213 -0
- src/utils/exceptions.py +114 -0
- src/utils/helpers.py +159 -0
- src/utils/logger.py +105 -0
- src/utils/sanitize.py +244 -0
- src/utils/validators.py +160 -0
- src/webhooks/__init__.py +6 -0
- src/webhooks/handlers.py +196 -0
- src/webhooks/receiver.py +290 -0
src/utils/exceptions.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Custom exception classes for UniFi MCP Server."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class UniFiMCPException(Exception):
|
|
7
|
+
"""Base exception for UniFi MCP Server."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
10
|
+
"""Initialize exception with message and optional details.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
message: Human-readable error message
|
|
14
|
+
details: Additional context about the error
|
|
15
|
+
"""
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.message = message
|
|
18
|
+
self.details = details or {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigurationError(UniFiMCPException):
|
|
22
|
+
"""Raised when configuration is invalid or missing."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthenticationError(UniFiMCPException):
|
|
28
|
+
"""Raised when authentication fails."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class APIError(UniFiMCPException):
|
|
34
|
+
"""Raised when UniFi API returns an error."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str,
|
|
39
|
+
status_code: int | None = None,
|
|
40
|
+
response_data: dict[str, Any] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Initialize API error.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
message: Human-readable error message
|
|
46
|
+
status_code: HTTP status code
|
|
47
|
+
response_data: Raw API response data
|
|
48
|
+
"""
|
|
49
|
+
super().__init__(message, {"status_code": status_code, "response": response_data})
|
|
50
|
+
self.status_code = status_code
|
|
51
|
+
self.response_data = response_data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RateLimitError(APIError):
|
|
55
|
+
"""Raised when API rate limit is exceeded."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
message: str = "Rate limit exceeded",
|
|
60
|
+
retry_after: int | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Initialize rate limit error.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
message: Human-readable error message
|
|
66
|
+
retry_after: Seconds until rate limit resets
|
|
67
|
+
"""
|
|
68
|
+
super().__init__(message, status_code=429, response_data={"retry_after": retry_after})
|
|
69
|
+
self.retry_after = retry_after
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ResourceNotFoundError(APIError):
|
|
73
|
+
"""Raised when requested resource is not found."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, resource_type: str, resource_id: str) -> None:
|
|
76
|
+
"""Initialize resource not found error.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
resource_type: Type of resource (site, device, client, etc.)
|
|
80
|
+
resource_id: ID of the resource
|
|
81
|
+
"""
|
|
82
|
+
message = f"{resource_type} '{resource_id}' not found"
|
|
83
|
+
super().__init__(message, status_code=404)
|
|
84
|
+
self.resource_type = resource_type
|
|
85
|
+
self.resource_id = resource_id
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ValidationError(UniFiMCPException):
|
|
89
|
+
"""Raised when input validation fails."""
|
|
90
|
+
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NetworkError(UniFiMCPException):
|
|
95
|
+
"""Raised when network communication fails."""
|
|
96
|
+
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ConfirmationRequiredError(UniFiMCPException):
|
|
101
|
+
"""Raised when mutating operation requires confirmation."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, operation: str) -> None:
|
|
104
|
+
"""Initialize confirmation required error.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
operation: Name of the operation requiring confirmation
|
|
108
|
+
"""
|
|
109
|
+
message = (
|
|
110
|
+
f"Operation '{operation}' requires confirmation. "
|
|
111
|
+
"Set confirm=true to proceed with this mutating operation."
|
|
112
|
+
)
|
|
113
|
+
super().__init__(message, {"operation": operation})
|
|
114
|
+
self.operation = operation
|
src/utils/helpers.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Helper utility functions for UniFi MCP Server."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_timestamp() -> int:
|
|
9
|
+
"""Get current Unix timestamp in seconds.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
Current timestamp
|
|
13
|
+
"""
|
|
14
|
+
return int(time.time())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_iso_timestamp() -> str:
|
|
18
|
+
"""Get current ISO 8601 formatted timestamp.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
ISO formatted timestamp string
|
|
22
|
+
"""
|
|
23
|
+
return datetime.now(timezone.utc).isoformat()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def format_uptime(uptime_seconds: int) -> str:
|
|
27
|
+
"""Format uptime seconds into human-readable string.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
uptime_seconds: Uptime in seconds
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Formatted uptime string (e.g., "2d 4h 30m")
|
|
34
|
+
"""
|
|
35
|
+
days = uptime_seconds // 86400
|
|
36
|
+
hours = (uptime_seconds % 86400) // 3600
|
|
37
|
+
minutes = (uptime_seconds % 3600) // 60
|
|
38
|
+
|
|
39
|
+
parts = []
|
|
40
|
+
if days > 0:
|
|
41
|
+
parts.append(f"{days}d")
|
|
42
|
+
parts.append(f"{hours}h")
|
|
43
|
+
parts.append(f"{minutes}m")
|
|
44
|
+
elif hours > 0:
|
|
45
|
+
parts.append(f"{hours}h")
|
|
46
|
+
parts.append(f"{minutes}m")
|
|
47
|
+
else:
|
|
48
|
+
parts.append(f"{minutes}m")
|
|
49
|
+
|
|
50
|
+
return " ".join(parts)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def format_bytes(bytes_value: int, precision: int = 2) -> str:
|
|
54
|
+
"""Format bytes into human-readable string.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
bytes_value: Number of bytes
|
|
58
|
+
precision: Decimal precision
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Formatted bytes string (e.g., "1.23 GB")
|
|
62
|
+
"""
|
|
63
|
+
bytes_float = float(bytes_value)
|
|
64
|
+
for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
|
|
65
|
+
if bytes_float < 1024.0:
|
|
66
|
+
return f"{bytes_float:.{precision}f} {unit}"
|
|
67
|
+
bytes_float /= 1024.0
|
|
68
|
+
return f"{bytes_float:.{precision}f} PB"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def format_percentage(value: float, precision: int = 1) -> str:
|
|
72
|
+
"""Format value as percentage string.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
value: Decimal value (0.0 to 1.0 or 0 to 100)
|
|
76
|
+
precision: Decimal precision
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Formatted percentage string (e.g., "45.3%")
|
|
80
|
+
"""
|
|
81
|
+
# Handle both 0-1 and 0-100 ranges
|
|
82
|
+
pct = value if value > 1 else value * 100
|
|
83
|
+
return f"{pct:.{precision}f}%"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def sanitize_dict(data: dict[str, Any], exclude_keys: list[str] | None = None) -> dict[str, Any]:
|
|
87
|
+
"""Remove sensitive keys from dictionary.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
data: Dictionary to sanitize
|
|
91
|
+
exclude_keys: List of keys to remove (default: common sensitive keys)
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Sanitized dictionary copy
|
|
95
|
+
"""
|
|
96
|
+
if exclude_keys is None:
|
|
97
|
+
exclude_keys = ["password", "api_key", "secret", "token", "x_api_key", "x-api-key"]
|
|
98
|
+
|
|
99
|
+
return {k: v for k, v in data.items() if k.lower() not in [e.lower() for e in exclude_keys]}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
103
|
+
"""Merge two dictionaries, with override taking precedence.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
base: Base dictionary
|
|
107
|
+
override: Override dictionary
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Merged dictionary
|
|
111
|
+
"""
|
|
112
|
+
result = base.copy()
|
|
113
|
+
result.update(override)
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_device_type(model: str) -> str:
|
|
118
|
+
"""Parse device type from model string.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
model: Device model string
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Device type (ap, switch, gateway, etc.)
|
|
125
|
+
"""
|
|
126
|
+
model_lower = model.lower()
|
|
127
|
+
|
|
128
|
+
if "uap" in model_lower or "u6" in model_lower or "u7" in model_lower:
|
|
129
|
+
return "ap"
|
|
130
|
+
elif "usw" in model_lower or "switch" in model_lower:
|
|
131
|
+
return "switch"
|
|
132
|
+
elif "usg" in model_lower or "udm" in model_lower or "uxg" in model_lower:
|
|
133
|
+
return "gateway"
|
|
134
|
+
elif "unvr" in model_lower or "nvr" in model_lower:
|
|
135
|
+
return "nvr"
|
|
136
|
+
else:
|
|
137
|
+
return "unknown"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_uri(scheme: str, *parts: str, query: dict[str, Any] | None = None) -> str:
|
|
141
|
+
"""Build a URI with optional query parameters.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
scheme: URI scheme (e.g., "sites")
|
|
145
|
+
*parts: URI path parts
|
|
146
|
+
query: Optional query parameters
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Complete URI string
|
|
150
|
+
"""
|
|
151
|
+
path = "/".join(str(p) for p in parts if p)
|
|
152
|
+
uri = f"{scheme}://{path}" if path else f"{scheme}://"
|
|
153
|
+
|
|
154
|
+
if query:
|
|
155
|
+
query_str = "&".join(f"{k}={v}" for k, v in query.items() if v is not None)
|
|
156
|
+
if query_str:
|
|
157
|
+
uri += f"?{query_str}"
|
|
158
|
+
|
|
159
|
+
return uri
|
src/utils/logger.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Structured logging configuration for UniFi MCP Server."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
# Configure logging format
|
|
8
|
+
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
9
|
+
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_logger(name: str, level: str | None = None) -> logging.Logger:
|
|
13
|
+
"""Get a configured logger instance.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
name: Logger name (typically __name__ of the module)
|
|
17
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Configured logger instance
|
|
21
|
+
"""
|
|
22
|
+
logger = logging.getLogger(name)
|
|
23
|
+
|
|
24
|
+
# Set log level from parameter or environment variable
|
|
25
|
+
log_level = level or "INFO"
|
|
26
|
+
logger.setLevel(getattr(logging, log_level.upper()))
|
|
27
|
+
|
|
28
|
+
# Avoid duplicate handlers
|
|
29
|
+
if not logger.handlers:
|
|
30
|
+
# Console handler
|
|
31
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
32
|
+
handler.setLevel(logging.DEBUG)
|
|
33
|
+
|
|
34
|
+
# Formatter
|
|
35
|
+
formatter = logging.Formatter(LOG_FORMAT, DATE_FORMAT)
|
|
36
|
+
handler.setFormatter(formatter)
|
|
37
|
+
|
|
38
|
+
logger.addHandler(handler)
|
|
39
|
+
|
|
40
|
+
return logger
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def log_api_request(
|
|
44
|
+
logger: logging.Logger,
|
|
45
|
+
method: str,
|
|
46
|
+
url: str,
|
|
47
|
+
status_code: int | None = None,
|
|
48
|
+
duration_ms: float | None = None,
|
|
49
|
+
**kwargs: Any,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Log API request details in structured format.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
logger: Logger instance
|
|
55
|
+
method: HTTP method
|
|
56
|
+
url: Request URL
|
|
57
|
+
status_code: Response status code
|
|
58
|
+
duration_ms: Request duration in milliseconds
|
|
59
|
+
**kwargs: Additional context to log
|
|
60
|
+
"""
|
|
61
|
+
context = {
|
|
62
|
+
"method": method,
|
|
63
|
+
"url": url,
|
|
64
|
+
"status_code": status_code,
|
|
65
|
+
"duration_ms": duration_ms,
|
|
66
|
+
**kwargs,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Remove None values
|
|
70
|
+
context = {k: v for k, v in context.items() if v is not None}
|
|
71
|
+
|
|
72
|
+
if status_code and status_code >= 400:
|
|
73
|
+
logger.error(f"API request failed: {context}")
|
|
74
|
+
else:
|
|
75
|
+
logger.info(f"API request: {context}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def log_audit_event(
|
|
79
|
+
logger: logging.Logger,
|
|
80
|
+
operation: str,
|
|
81
|
+
resource_type: str,
|
|
82
|
+
resource_id: str,
|
|
83
|
+
success: bool,
|
|
84
|
+
**kwargs: Any,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Log audit event for mutating operations.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
logger: Logger instance
|
|
90
|
+
operation: Operation performed (create, update, delete, etc.)
|
|
91
|
+
resource_type: Type of resource affected
|
|
92
|
+
resource_id: ID of the resource
|
|
93
|
+
success: Whether operation succeeded
|
|
94
|
+
**kwargs: Additional context to log
|
|
95
|
+
"""
|
|
96
|
+
context = {
|
|
97
|
+
"operation": operation,
|
|
98
|
+
"resource_type": resource_type,
|
|
99
|
+
"resource_id": resource_id,
|
|
100
|
+
"success": success,
|
|
101
|
+
**kwargs,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
level = logging.INFO if success else logging.ERROR
|
|
105
|
+
logger.log(level, f"Audit event: {context}")
|
src/utils/sanitize.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Data sanitization utilities for logging and audit trails.
|
|
2
|
+
|
|
3
|
+
Provides functions to sanitize sensitive data before logging to prevent
|
|
4
|
+
exposure of private information such as MAC addresses, IP addresses,
|
|
5
|
+
client identifiers, and network configuration details.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
# Sensitive field patterns
|
|
12
|
+
SENSITIVE_FIELDS = {
|
|
13
|
+
# Network identifiers
|
|
14
|
+
"mac",
|
|
15
|
+
"mac_address",
|
|
16
|
+
"client_mac",
|
|
17
|
+
"device_mac",
|
|
18
|
+
"bssid",
|
|
19
|
+
"oui",
|
|
20
|
+
# IP and network info
|
|
21
|
+
"ip",
|
|
22
|
+
"ip_address",
|
|
23
|
+
"fixed_ip",
|
|
24
|
+
"network_id",
|
|
25
|
+
"subnet",
|
|
26
|
+
"gateway",
|
|
27
|
+
"dns",
|
|
28
|
+
"dhcp_start",
|
|
29
|
+
"dhcp_stop",
|
|
30
|
+
# Device identifiers
|
|
31
|
+
"device_id",
|
|
32
|
+
"client_id",
|
|
33
|
+
"user_id",
|
|
34
|
+
"site_id",
|
|
35
|
+
"serial",
|
|
36
|
+
"serial_number",
|
|
37
|
+
# Authentication
|
|
38
|
+
"password",
|
|
39
|
+
"passphrase",
|
|
40
|
+
"psk",
|
|
41
|
+
"key",
|
|
42
|
+
"secret",
|
|
43
|
+
"token",
|
|
44
|
+
"api_key",
|
|
45
|
+
# Personal information
|
|
46
|
+
"email",
|
|
47
|
+
"hostname",
|
|
48
|
+
"name",
|
|
49
|
+
"username",
|
|
50
|
+
"user",
|
|
51
|
+
# Location
|
|
52
|
+
"latitude",
|
|
53
|
+
"longitude",
|
|
54
|
+
"location",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Partial redaction patterns (show last N characters)
|
|
58
|
+
PARTIAL_REDACT_FIELDS = {
|
|
59
|
+
"mac",
|
|
60
|
+
"mac_address",
|
|
61
|
+
"client_mac",
|
|
62
|
+
"device_mac",
|
|
63
|
+
"ip",
|
|
64
|
+
"ip_address",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _redact_value(key: str, value: Any, partial: bool = True) -> str:
|
|
69
|
+
"""Redact a sensitive value.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
key: Field name
|
|
73
|
+
value: Value to redact
|
|
74
|
+
partial: If True, show last few characters for debugging
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Redacted string representation
|
|
78
|
+
"""
|
|
79
|
+
if value is None:
|
|
80
|
+
return "None"
|
|
81
|
+
|
|
82
|
+
str_value = str(value)
|
|
83
|
+
|
|
84
|
+
# For MAC addresses and IPs, show last segment for debugging
|
|
85
|
+
if partial and key.lower() in PARTIAL_REDACT_FIELDS:
|
|
86
|
+
# MAC address: show last octet (XX:XX:XX:XX:XX:AB)
|
|
87
|
+
if ":" in str_value and len(str_value) == 17:
|
|
88
|
+
return f"**:**:**:**:**:{str_value[-2:]}"
|
|
89
|
+
# IP address: show last octet (XXX.XXX.XXX.123)
|
|
90
|
+
if "." in str_value and re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", str_value):
|
|
91
|
+
return f"***.***.***.{str_value.split('.')[-1]}"
|
|
92
|
+
|
|
93
|
+
# Full redaction
|
|
94
|
+
if len(str_value) <= 4:
|
|
95
|
+
return "***"
|
|
96
|
+
return f"***{str_value[-2:]}" if partial else "***"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sanitize_dict(data: dict[str, Any], partial: bool = True) -> dict[str, Any]:
|
|
100
|
+
"""Sanitize sensitive fields in a dictionary.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
data: Dictionary potentially containing sensitive data
|
|
104
|
+
partial: If True, partially redact some fields for debugging
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Dictionary with sensitive fields redacted
|
|
108
|
+
"""
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
sanitized = {}
|
|
113
|
+
for key, value in data.items():
|
|
114
|
+
key_lower = key.lower()
|
|
115
|
+
|
|
116
|
+
# Check if this key is sensitive
|
|
117
|
+
is_sensitive = any(pattern in key_lower for pattern in SENSITIVE_FIELDS)
|
|
118
|
+
|
|
119
|
+
if is_sensitive:
|
|
120
|
+
# Redact the value
|
|
121
|
+
sanitized[key] = _redact_value(key_lower, value, partial)
|
|
122
|
+
elif isinstance(value, dict):
|
|
123
|
+
# Recursively sanitize nested dictionaries
|
|
124
|
+
sanitized[key] = sanitize_dict(value, partial)
|
|
125
|
+
elif isinstance(value, list):
|
|
126
|
+
# Sanitize lists
|
|
127
|
+
sanitized[key] = [
|
|
128
|
+
sanitize_dict(item, partial) if isinstance(item, dict) else item for item in value
|
|
129
|
+
]
|
|
130
|
+
else:
|
|
131
|
+
# Keep non-sensitive values as-is
|
|
132
|
+
sanitized[key] = value
|
|
133
|
+
|
|
134
|
+
return sanitized
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def sanitize_list(data: list[Any], partial: bool = True) -> list[Any]:
|
|
138
|
+
"""Sanitize sensitive fields in a list of dictionaries.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
data: List of dictionaries potentially containing sensitive data
|
|
142
|
+
partial: If True, partially redact some fields for debugging
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
List with sensitive fields redacted
|
|
146
|
+
"""
|
|
147
|
+
if not isinstance(data, list):
|
|
148
|
+
return data
|
|
149
|
+
|
|
150
|
+
return [sanitize_dict(item, partial) if isinstance(item, dict) else item for item in data]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def sanitize_log_message(message: str, context: dict[str, Any] | None = None) -> str:
|
|
154
|
+
"""Sanitize a log message and optional context.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
message: Log message to sanitize
|
|
158
|
+
context: Optional context dictionary to include
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Sanitized log message with context
|
|
162
|
+
"""
|
|
163
|
+
# Sanitize common patterns in the message itself
|
|
164
|
+
sanitized_msg = message
|
|
165
|
+
|
|
166
|
+
# Redact MAC addresses (XX:XX:XX:XX:XX:XX)
|
|
167
|
+
sanitized_msg = re.sub(
|
|
168
|
+
r"\b([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})\b",
|
|
169
|
+
lambda m: f"**:**:**:**:**:{m.group(2)}",
|
|
170
|
+
sanitized_msg,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Redact IP addresses (XXX.XXX.XXX.XXX)
|
|
174
|
+
sanitized_msg = re.sub(
|
|
175
|
+
r"\b(\d{1,3}\.){3}(\d{1,3})\b",
|
|
176
|
+
lambda m: f"***.***.***.{m.group(2)}" if m.group(0) != "0.0.0.0" else m.group(0),
|
|
177
|
+
sanitized_msg,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Add sanitized context if provided
|
|
181
|
+
if context:
|
|
182
|
+
sanitized_context = sanitize_dict(context)
|
|
183
|
+
sanitized_msg = f"{sanitized_msg} | Context: {sanitized_context}"
|
|
184
|
+
|
|
185
|
+
return sanitized_msg
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def is_production() -> bool:
|
|
189
|
+
"""Check if running in production environment.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
True if production, False otherwise
|
|
193
|
+
"""
|
|
194
|
+
import os
|
|
195
|
+
|
|
196
|
+
return os.getenv("ENVIRONMENT", "development").lower() in ("production", "prod")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def sanitize_for_logging(
|
|
200
|
+
data: dict[str, Any] | list[Any] | str,
|
|
201
|
+
force_sanitize: bool = False,
|
|
202
|
+
) -> dict[str, Any] | list[Any] | str:
|
|
203
|
+
"""Sanitize data for logging based on environment.
|
|
204
|
+
|
|
205
|
+
In production, always sanitizes. In development, only sanitizes if forced.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
data: Data to potentially sanitize
|
|
209
|
+
force_sanitize: Force sanitization even in development
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Sanitized or original data based on environment
|
|
213
|
+
"""
|
|
214
|
+
# Always sanitize in production or when forced
|
|
215
|
+
if is_production() or force_sanitize:
|
|
216
|
+
if isinstance(data, dict):
|
|
217
|
+
return sanitize_dict(data)
|
|
218
|
+
elif isinstance(data, list):
|
|
219
|
+
return sanitize_list(data)
|
|
220
|
+
elif isinstance(data, str):
|
|
221
|
+
return sanitize_log_message(data)
|
|
222
|
+
|
|
223
|
+
# In development, return original data for debugging
|
|
224
|
+
return data
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# Convenience function for backward compatibility
|
|
228
|
+
def sanitize_sensitive_data(
|
|
229
|
+
data: dict[str, Any] | list[Any], partial: bool = True
|
|
230
|
+
) -> dict[str, Any] | list[Any]:
|
|
231
|
+
"""Sanitize sensitive data (legacy function name).
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
data: Data to sanitize
|
|
235
|
+
partial: If True, partially redact some fields for debugging
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Sanitized data
|
|
239
|
+
"""
|
|
240
|
+
if isinstance(data, dict):
|
|
241
|
+
return sanitize_dict(data, partial)
|
|
242
|
+
elif isinstance(data, list):
|
|
243
|
+
return sanitize_list(data, partial)
|
|
244
|
+
return data
|