This is an automated email from the ASF dual-hosted git repository. FreeOnePlus pushed a commit to branch agent/sec-016-redact-logs-errors in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
commit ac52c52f052cd5f78619af15f03b83abb8976c0b Author: FreeOnePlus <[email protected]> AuthorDate: Thu Jul 30 00:10:09 2026 +0800 fix: redact sensitive logs and errors --- doris_mcp_server/auth/auth_middleware.py | 7 +- doris_mcp_server/auth/doris_oauth_handlers.py | 2 +- doris_mcp_server/auth/jwt_manager.py | 4 +- doris_mcp_server/auth/mcp_auth_middleware.py | 23 ++- doris_mcp_server/auth/oauth_client.py | 2 +- doris_mcp_server/auth/oauth_handlers.py | 37 +++- doris_mcp_server/auth/token_handlers.py | 53 +++-- doris_mcp_server/auth/token_manager.py | 7 +- doris_mcp_server/auth/token_validators.py | 8 +- doris_mcp_server/protocol.py | 60 +++++- doris_mcp_server/tools/resources_manager.py | 18 +- doris_mcp_server/tools/tools_manager.py | 10 +- doris_mcp_server/utils/analysis_tools.py | 32 ++- doris_mcp_server/utils/logger.py | 14 ++ doris_mcp_server/utils/query_executor.py | 10 +- doris_mcp_server/utils/redaction.py | 273 +++++++++++++++++++++++++ doris_mcp_server/utils/schema_extractor.py | 23 ++- doris_mcp_server/utils/security.py | 6 +- test/integration/test_real_doris_transports.py | 17 ++ test/protocol/stdio_capability_server.py | 11 + test/protocol/test_mcp_v2_protocol.py | 44 +++- test/security/test_mcp_auth_middleware.py | 65 ++++++ test/security/test_sensitive_data_redaction.py | 193 +++++++++++++++++ test/tools/test_resources_manager_cache.py | 9 +- test/tools/test_tools_manager.py | 3 +- 25 files changed, 852 insertions(+), 79 deletions(-) diff --git a/doris_mcp_server/auth/auth_middleware.py b/doris_mcp_server/auth/auth_middleware.py index e358063..4e7137e 100644 --- a/doris_mcp_server/auth/auth_middleware.py +++ b/doris_mcp_server/auth/auth_middleware.py @@ -193,9 +193,12 @@ class AuthMiddleware: return await self.app(scope, receive, send_wrapper) - except Exception as e: + except Exception: # Authentication failed, return 401 error - response_body = f'{{"error": "Authentication failed", "message": "{str(e)}"}}' + response_body = ( + '{"error": "Authentication failed", ' + '"message": "Authentication failed"}' + ) await send({ 'type': 'http.response.start', diff --git a/doris_mcp_server/auth/doris_oauth_handlers.py b/doris_mcp_server/auth/doris_oauth_handlers.py index d847152..b6f8bd0 100644 --- a/doris_mcp_server/auth/doris_oauth_handlers.py +++ b/doris_mcp_server/auth/doris_oauth_handlers.py @@ -60,7 +60,7 @@ def protected_resource_error_response(error: Exception, base_url: str) -> JSONRe scope = "tool:list" body = { "error": "authentication_required", - "error_description": str(error) or "Authentication required", + "error_description": "Authentication required", } status_code = 401 return JSONResponse( diff --git a/doris_mcp_server/auth/jwt_manager.py b/doris_mcp_server/auth/jwt_manager.py index fa79d1a..76a080e 100644 --- a/doris_mcp_server/auth/jwt_manager.py +++ b/doris_mcp_server/auth/jwt_manager.py @@ -360,7 +360,7 @@ class JWTManager: # Add to blacklist await self.validator.revoke_token(jti, exp) - logger.info(f"Token {jti} revoked successfully") + logger.info("Token revoked successfully") return True except Exception as e: @@ -468,4 +468,4 @@ class JWTManager: }, 'key_manager': key_info, 'validator': validation_stats - } \ No newline at end of file + } diff --git a/doris_mcp_server/auth/mcp_auth_middleware.py b/doris_mcp_server/auth/mcp_auth_middleware.py index 631480a..67097d0 100644 --- a/doris_mcp_server/auth/mcp_auth_middleware.py +++ b/doris_mcp_server/auth/mcp_auth_middleware.py @@ -25,6 +25,7 @@ from starlette.responses import JSONResponse from ..utils.auth_credentials import BearerCredentials from ..utils.config import EffectiveAuthConfig from ..utils.logger import get_logger +from ..utils.redaction import redact_error_payload from ..utils.security import ( clear_current_auth_context, get_current_auth_context, @@ -90,7 +91,10 @@ class MCPAuthASGIMiddleware: ) else: response = JSONResponse( - {"error": "Authentication required", "message": str(exc)}, + { + "error": "Authentication required", + "message": "Authentication failed", + }, status_code=401, ) await response(scope, receive, send) @@ -109,10 +113,16 @@ class MCPAuthASGIMiddleware: try: reset_auth_context(context_token) except Exception as reset_exc: - logger.error(f"Failed to reset auth context after verification failure: {reset_exc}") + logger.error( + "Failed to reset auth context after verification failure (%s)", + type(reset_exc).__name__, + ) clear_current_auth_context() response = JSONResponse( - {"error": "auth_context_unavailable", "message": str(exc)}, + { + "error": "auth_context_unavailable", + "message": "Authentication context unavailable", + }, status_code=500, ) await response(scope, receive, send) @@ -121,7 +131,7 @@ class MCPAuthASGIMiddleware: try: await self.downstream(scoped_request, receive, send) except OperationAuthorizationError as exc: - body = exc.to_dict() + body = redact_error_payload(exc.to_dict()) if ( self.effective_auth.oauth_discovery_mode == "doris_oauth" and exc.required_scope @@ -149,5 +159,8 @@ class MCPAuthASGIMiddleware: try: reset_auth_context(context_token) except Exception as exc: - logger.error(f"Failed to reset auth context after request: {exc}") + logger.error( + "Failed to reset auth context after request (%s)", + type(exc).__name__, + ) clear_current_auth_context() diff --git a/doris_mcp_server/auth/oauth_client.py b/doris_mcp_server/auth/oauth_client.py index 3e60fc5..a172e76 100644 --- a/doris_mcp_server/auth/oauth_client.py +++ b/doris_mcp_server/auth/oauth_client.py @@ -423,7 +423,7 @@ class OAuthClient: # Build URL authorization_url = f"{self.provider_config.authorization_endpoint}?{urlencode(params)}" - logger.info(f"Built OAuth authorization URL for state: {oauth_state.state}") + logger.info("Built OAuth authorization URL") return authorization_url, oauth_state async def exchange_code_for_tokens(self, code: str, state: str) -> Tuple[OAuthTokens, OAuthState]: diff --git a/doris_mcp_server/auth/oauth_handlers.py b/doris_mcp_server/auth/oauth_handlers.py index 57ce38c..135f6cf 100644 --- a/doris_mcp_server/auth/oauth_handlers.py +++ b/doris_mcp_server/auth/oauth_handlers.py @@ -29,6 +29,7 @@ from starlette.requests import Request from ..utils.config import get_effective_auth_config from ..utils.logger import get_logger +from ..utils.redaction import redact_sensitive_text from .oauth_resource import external_oauth_protected_resource_metadata logger = get_logger(__name__) @@ -71,9 +72,12 @@ class OAuthHandlers: }) except Exception as e: - logger.error(f"OAuth login initiation failed: {e}") + logger.error( + "OAuth login initiation failed (%s)", + type(e).__name__, + ) return JSONResponse( - {"error": f"OAuth login failed: {str(e)}"}, + {"error": "OAuth login failed"}, status_code=500 ) @@ -89,12 +93,19 @@ class OAuthHandlers: # Check for error in callback if "error" in query_params: error_description = query_params.get("error_description", "Unknown error") - logger.warning(f"OAuth callback error: {query_params['error']} - {error_description}") + error_uri = query_params.get("error_uri") + logger.warning("OAuth callback returned an authorization error") return JSONResponse( { - "error": query_params["error"], - "error_description": error_description, - "error_uri": query_params.get("error_uri") + "error": redact_sensitive_text(query_params["error"]), + "error_description": redact_sensitive_text( + error_description + ), + "error_uri": ( + redact_sensitive_text(error_uri) + if error_uri is not None + else None + ) }, status_code=400 ) @@ -124,9 +135,12 @@ class OAuthHandlers: }) except Exception as e: - logger.error(f"OAuth callback handling failed: {e}") + logger.error( + "OAuth callback handling failed (%s)", + type(e).__name__, + ) return JSONResponse( - {"error": f"OAuth callback failed: {str(e)}"}, + {"error": "OAuth callback failed"}, status_code=500 ) @@ -140,9 +154,12 @@ class OAuthHandlers: return JSONResponse(provider_info) except Exception as e: - logger.error(f"Failed to get OAuth provider info: {e}") + logger.error( + "Failed to get OAuth provider info (%s)", + type(e).__name__, + ) return JSONResponse( - {"error": f"Failed to get provider info: {str(e)}"}, + {"error": "Failed to get provider info"}, status_code=500 ) diff --git a/doris_mcp_server/auth/token_handlers.py b/doris_mcp_server/auth/token_handlers.py index 06a4743..45a1407 100644 --- a/doris_mcp_server/auth/token_handlers.py +++ b/doris_mcp_server/auth/token_handlers.py @@ -108,9 +108,9 @@ class TokenHandlers: database=db_data.get("database", "information_schema"), fe_http_port=int(db_data.get("fe_http_port", 8030)) ) - except (ValueError, TypeError) as e: + except (ValueError, TypeError): return JSONResponse({ - "error": f"Invalid database configuration: {str(e)}" + "error": "Invalid database configuration" }, status_code=400) # Validate required fields @@ -149,15 +149,21 @@ class TokenHandlers: }) except Exception as e: - self.logger.error(f"Token creation failed: {e}") + self.logger.error( + "Token creation failed (%s)", + type(e).__name__, + ) return JSONResponse({ - "error": f"Token creation failed: {str(e)}" + "error": "Token creation failed" }, status_code=400) except Exception as e: - self.logger.error(f"Error in handle_create_token: {e}") + self.logger.error( + "Error in handle_create_token (%s)", + type(e).__name__, + ) return JSONResponse({ - "error": f"Internal server error: {str(e)}" + "error": "Internal server error" }, status_code=500) async def handle_revoke_token(self, request: Request) -> JSONResponse: @@ -205,9 +211,12 @@ class TokenHandlers: }, status_code=404) except Exception as e: - self.logger.error(f"Error in handle_revoke_token: {e}") + self.logger.error( + "Error in handle_revoke_token (%s)", + type(e).__name__, + ) return JSONResponse({ - "error": f"Internal server error: {str(e)}" + "error": "Internal server error" }, status_code=500) async def handle_list_tokens(self, request: Request) -> JSONResponse: @@ -235,9 +244,12 @@ class TokenHandlers: }) except Exception as e: - self.logger.error(f"Error in handle_list_tokens: {e}") + self.logger.error( + "Error in handle_list_tokens (%s)", + type(e).__name__, + ) return JSONResponse({ - "error": f"Internal server error: {str(e)}" + "error": "Internal server error" }, status_code=500) async def handle_token_stats(self, request: Request) -> JSONResponse: @@ -264,9 +276,12 @@ class TokenHandlers: }) except Exception as e: - self.logger.error(f"Error in handle_token_stats: {e}") + self.logger.error( + "Error in handle_token_stats (%s)", + type(e).__name__, + ) return JSONResponse({ - "error": f"Internal server error: {str(e)}" + "error": "Internal server error" }, status_code=500) async def handle_cleanup_tokens(self, request: Request) -> JSONResponse: @@ -294,9 +309,12 @@ class TokenHandlers: }) except Exception as e: - self.logger.error(f"Error in handle_cleanup_tokens: {e}") + self.logger.error( + "Error in handle_cleanup_tokens (%s)", + type(e).__name__, + ) return JSONResponse({ - "error": f"Internal server error: {str(e)}" + "error": "Internal server error" }, status_code=500) async def handle_management_page(self, request: Request) -> HTMLResponse: @@ -655,7 +673,10 @@ class TokenHandlers: return HTMLResponse(html_content) except Exception as e: - self.logger.error(f"Error in handle_demo_page: {e}") + self.logger.error( + "Error in handle_demo_page (%s)", + type(e).__name__, + ) error_html = f""" <!DOCTYPE html> <html> @@ -665,7 +686,7 @@ class TokenHandlers: </head> <body> <h1>Token Management Error</h1> - <p>Error loading token management page: {str(e)}</p> + <p>Error loading token management page.</p> </body> </html> """ diff --git a/doris_mcp_server/auth/token_manager.py b/doris_mcp_server/auth/token_manager.py index 2416377..75ff481 100644 --- a/doris_mcp_server/auth/token_manager.py +++ b/doris_mcp_server/auth/token_manager.py @@ -556,10 +556,13 @@ class TokenManager: ) except Exception as e: - self.logger.error(f"Token validation error: {e}") + self.logger.error( + "Token validation error (%s)", + type(e).__name__, + ) return TokenValidationResult( is_valid=False, - error_message=f"Token validation failed: {str(e)}" + error_message="Token validation failed" ) def generate_token(self, length: int = 32) -> str: diff --git a/doris_mcp_server/auth/token_validators.py b/doris_mcp_server/auth/token_validators.py index 43731af..bc680ac 100644 --- a/doris_mcp_server/auth/token_validators.py +++ b/doris_mcp_server/auth/token_validators.py @@ -74,7 +74,7 @@ class TokenBlacklist: exp: Token expiration timestamp """ self._blacklisted_tokens[jti] = exp - logger.info(f"Token {jti} added to blacklist") + logger.info("Token added to blacklist") async def is_blacklisted(self, jti: str) -> bool: """Check if token is blacklisted @@ -98,7 +98,7 @@ class TokenBlacklist: """ if jti in self._blacklisted_tokens: del self._blacklisted_tokens[jti] - logger.info(f"Token {jti} removed from blacklist") + logger.info("Token removed from blacklist") return True return False @@ -341,7 +341,7 @@ class TokenValidator: exp: Token expiration time """ await self.blacklist.add_token(jti, exp) - logger.info(f"Token {jti} has been revoked") + logger.info("Token has been revoked") async def get_validation_stats(self) -> Dict[str, Any]: """Get validation statistics""" @@ -362,4 +362,4 @@ class TokenValidator: async def get_user_rate_limit_info(self, user_id: str) -> Dict[str, Any]: """Get user rate limit information""" - return await self.rate_limiter.get_usage(user_id) \ No newline at end of file + return await self.rate_limiter.get_usage(user_id) diff --git a/doris_mcp_server/protocol.py b/doris_mcp_server/protocol.py index de4cd63..0790d1d 100644 --- a/doris_mcp_server/protocol.py +++ b/doris_mcp_server/protocol.py @@ -52,7 +52,12 @@ from mcp.types import ( Tool, ) -from .auth.operation_policy import authorize_operation +from .auth.operation_policy import OperationAuthorizationError, authorize_operation +from .utils.redaction import ( + redact_error_payload, + redact_sensitive_text, + redact_uri, +) from .utils.security import get_current_auth_context @@ -109,6 +114,20 @@ def _decode_structured_tool_result(payload: str) -> tuple[Any | None, bool]: return decoded, "error" in decoded +def _sanitize_manager_error_payload( + payload: str, +) -> tuple[str, Any | None, bool]: + decoded, is_error = _decode_structured_tool_result(payload) + if not is_error: + return payload, decoded, False + decoded = redact_error_payload(decoded) + return ( + json.dumps(decoded, ensure_ascii=False, indent=2), + decoded, + True, + ) + + _RESOURCE_INVALID_PARAMS_MESSAGES = { "INVALID_RESOURCE_URI": "Invalid resource URI", "RESOURCE_NOT_FOUND": "Resource not found", @@ -168,6 +187,7 @@ def create_doris_mcp_server( ) -> ReadResourceResult: authorize_operation(get_current_auth_context(), "read_resource") content = await resources_manager.read_resource(params.uri) + content, _, _ = _sanitize_manager_error_payload(content) if ctx.protocol_version == LATEST_PROTOCOL_VERSION: request_error = _decode_resource_request_error(content) if request_error is not None: @@ -176,7 +196,7 @@ def create_doris_mcp_server( code=INVALID_PARAMS, message=message, data={ - "uri": str(params.uri), + "uri": redact_uri(str(params.uri)), "resourceErrorCode": error_code, }, ) @@ -206,8 +226,21 @@ def create_doris_mcp_server( ) -> CallToolResult: del ctx arguments = params.arguments or {} - payload = await tools_manager.call_tool(params.name, arguments) - structured_content, is_error = _decode_structured_tool_result(payload) + try: + payload = await tools_manager.call_tool(params.name, arguments) + except OperationAuthorizationError: + raise + except Exception: + logger.exception("Tool execution failed") + payload = json.dumps( + { + "error": "Tool execution failed", + "error_code": "TOOL_EXECUTION_FAILED", + } + ) + payload, structured_content, is_error = _sanitize_manager_error_payload( + payload + ) return CallToolResult( content=[TextContent(type="text", text=payload)], structured_content=structured_content, @@ -240,7 +273,7 @@ def create_doris_mcp_server( message = _PROMPT_INVALID_PARAMS_MESSAGES.get(prompt_error_code) if message is not None: data = { - "name": params.name, + "name": redact_sensitive_text(params.name), "promptErrorCode": prompt_error_code, } argument = getattr(exc, "argument", None) @@ -286,6 +319,23 @@ def create_doris_mcp_server( on_get_prompt=get_prompt, ) + async def hide_unhandled_errors( + ctx: ServerRequestContext, + call_next: CallNext, + ) -> Any: + try: + return await call_next(ctx) + except (MCPError, OperationAuthorizationError): + raise + except Exception as exc: + logger.exception("Unhandled MCP request failure for %s", ctx.method) + raise MCPError( + code=INTERNAL_ERROR, + message="Internal server error", + ) from exc + + server.middleware.append(hide_unhandled_errors) + if required_client_capabilities or required_tool_capabilities: requirements = dict(required_client_capabilities or {}) tool_requirements = dict(required_tool_capabilities or {}) diff --git a/doris_mcp_server/tools/resources_manager.py b/doris_mcp_server/tools/resources_manager.py index 1f615bb..b481c36 100644 --- a/doris_mcp_server/tools/resources_manager.py +++ b/doris_mcp_server/tools/resources_manager.py @@ -28,9 +28,14 @@ from urllib.parse import quote, unquote from mcp.types import Resource from ..utils.db import DorisConnectionManager +from ..utils.logger import get_logger +from ..utils.redaction import redact_uri from ..utils.sql_security_utils import get_auth_context +logger = get_logger(__name__) + + class TableMetadata: """Data table metadata""" @@ -268,7 +273,7 @@ class DorisResourcesManager: except Exception as e: self._reraise_if_doris_oauth_resource_error(e) - print(f"Failed to get resource list: {e}") + logger.exception("Failed to get resource list") return resources @@ -334,9 +339,16 @@ class DorisResourcesManager: except Exception as e: self._reraise_if_doris_oauth_resource_error(e) + if isinstance(e, InvalidResourceURIError): + message = "Invalid resource URI" + elif isinstance(e, ResourceNotFoundError): + message = "Resource not found" + else: + logger.exception("Failed to read resource") + message = "Resource read failed" payload = { - "error": f"Failed to read resource: {str(e)}", - "uri": uri, + "error": message, + "uri": redact_uri(uri), } if isinstance(e, InvalidResourceURIError | ResourceNotFoundError): payload["error_code"] = e.error_code diff --git a/doris_mcp_server/tools/tools_manager.py b/doris_mcp_server/tools/tools_manager.py index 89e5e30..c352797 100644 --- a/doris_mcp_server/tools/tools_manager.py +++ b/doris_mcp_server/tools/tools_manager.py @@ -1459,11 +1459,13 @@ No parameters required. Returns connection status, configuration, and diagnostic except OperationAuthorizationError: raise except Exception as e: - logger.error(f"Tool call failed {name}: {str(e)}") + logger.error( + "Tool call failed (%s)", + type(e).__name__, + ) error_result = { - "error": str(e), - "tool_name": name, - "arguments": arguments, + "error": "Tool execution failed", + "error_code": "TOOL_EXECUTION_FAILED", "timestamp": datetime.now().isoformat(), } return json.dumps(error_result, ensure_ascii=False, indent=2) diff --git a/doris_mcp_server/utils/analysis_tools.py b/doris_mcp_server/utils/analysis_tools.py index d50f3d3..ecf33a7 100644 --- a/doris_mcp_server/utils/analysis_tools.py +++ b/doris_mcp_server/utils/analysis_tools.py @@ -428,7 +428,7 @@ class SQLAnalyzer: explain_type = "EXPLAIN VERBOSE" if verbose else "EXPLAIN" explain_sql = f"{explain_type} {sql.strip().rstrip(';')}" - logger.info(f"Executing explain query: {explain_sql}") + logger.info("Executing explain query") # Execute context switching and explain query on one routed connection # whenever a database/catalog context is requested. @@ -618,7 +618,7 @@ class SQLAnalyzer: logger.info(f"Enabled profile") # Execute the SQL statement - logger.info(f"Executing SQL with trace ID: {sql}") + logger.info("Executing SQL with trace ID %s", trace_id) start_time = time.time() sql_result = await connection.execute(sql, auth_context=auth_context) execution_time = time.time() - start_time @@ -871,14 +871,17 @@ class SQLAnalyzer: content_type = response.headers.get('content-type', '') response_text = await response.text() logger.info(f"Response content type: {content_type}") - logger.info(f"Response body: {response_text}") + logger.info( + "Query ID response body length: %s", + len(response_text), + ) # Parse JSON response (regardless of content-type) if response_text.strip(): try: import json result = json.loads(response_text) - logger.info(f"Query ID API response: {result}") + logger.info("Query ID API returned JSON") # Parse response according to Doris API format if result.get("code") == 0 and result.get("data"): @@ -911,7 +914,10 @@ class SQLAnalyzer: else: logger.error(f"HTTP request failed with status {response.status}") response_text = await response.text() - logger.error(f"Response body: {response_text}") + logger.error( + "Query ID response body omitted (length=%s)", + len(response_text), + ) return None @@ -957,7 +963,7 @@ class SQLAnalyzer: if 'application/json' in content_type: try: result = await response.json() - logger.info(f"Profile JSON response: {result}") + logger.info("Profile API returned JSON") if result.get("code") == 0 and result.get("data"): profile_text = result["data"].get("profile", "") @@ -987,7 +993,9 @@ class SQLAnalyzer: "api_endpoint": url } else: - logger.warning(f"Profile not found or empty: {response_text}") + logger.warning( + "Profile not found or empty" + ) continue # Try next URL elif response.status == 404: @@ -996,7 +1004,10 @@ class SQLAnalyzer: else: logger.error(f"Profile HTTP request failed with status {response.status} at {url}") response_text = await response.text() - logger.error(f"Response body: {response_text}") + logger.error( + "Profile response body omitted (length=%s)", + len(response_text), + ) continue # Try next URL return None @@ -1093,7 +1104,10 @@ class SQLAnalyzer: else: logger.error(f"HTTP request failed with status {response.status}") response_text = await response.text() - logger.error(f"Response body: {response_text}") + logger.error( + "Table size response body omitted (length=%s)", + len(response_text), + ) return { "success": False, "error": f"HTTP request failed with status {response.status}", diff --git a/doris_mcp_server/utils/logger.py b/doris_mcp_server/utils/logger.py index 9dc612d..eb3c68e 100644 --- a/doris_mcp_server/utils/logger.py +++ b/doris_mcp_server/utils/logger.py @@ -35,6 +35,11 @@ from typing import Any, Optional from datetime import datetime, timedelta import threading +from .redaction import SensitiveDataFilter + + +_sensitive_data_filter = SensitiveDataFilter() + class TimestampedFormatter(logging.Formatter): """Custom formatter with enhanced timestamp and structured format""" @@ -348,6 +353,7 @@ class DorisLoggerManager: if enable_console: console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(getattr(logging, level.upper())) + console_handler.addFilter(_sensitive_data_filter) console_formatter = TimestampedFormatter( fmt="%(asctime)s.%(msecs)03d %(level_aligned)s %(name)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" @@ -364,6 +370,7 @@ class DorisLoggerManager: backup_count=backup_count ) level_handler.setLevel(logging.DEBUG) # Accept all levels + level_handler.addFilter(_sensitive_data_filter) handlers.append(level_handler) # Combined application log (all levels in one file) @@ -376,6 +383,7 @@ class DorisLoggerManager: encoding='utf-8' ) app_handler.setLevel(getattr(logging, level.upper())) + app_handler.addFilter(_sensitive_data_filter) app_formatter = TimestampedFormatter() app_handler.setFormatter(app_formatter) handlers.append(app_handler) @@ -396,12 +404,14 @@ class DorisLoggerManager: backupCount=backup_count, encoding='utf-8' ) + audit_handler.addFilter(_sensitive_data_filter) audit_formatter = TimestampedFormatter( fmt="%(asctime)s.%(msecs)03d [AUDIT] %(name)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) audit_handler.setFormatter(audit_formatter) audit_logger.addHandler(audit_handler) + audit_logger.addFilter(_sensitive_data_filter) audit_logger.propagate = False # Don't propagate to root logger # Add all handlers to root logger @@ -455,6 +465,8 @@ class DorisLoggerManager: for logger_name in package_loggers: logger = logging.getLogger(logger_name) logger.setLevel(getattr(logging, level.upper())) + if _sensitive_data_filter not in logger.filters: + logger.addFilter(_sensitive_data_filter) # Don't add handlers here - they inherit from root logger def get_logger(self, name: str) -> logging.Logger: @@ -469,6 +481,8 @@ class DorisLoggerManager: """ if name not in self.loggers: logger = logging.getLogger(name) + if _sensitive_data_filter not in logger.filters: + logger.addFilter(_sensitive_data_filter) self.loggers[name] = logger return self.loggers[name] diff --git a/doris_mcp_server/utils/query_executor.py b/doris_mcp_server/utils/query_executor.py index 269cc9d..744e387 100644 --- a/doris_mcp_server/utils/query_executor.py +++ b/doris_mcp_server/utils/query_executor.py @@ -417,7 +417,7 @@ class DorisQueryExecutor: ) if cached_result: self.metrics.cache_hits += 1 - self.logger.debug(f"Cache hit for query: {query_request.sql[:50]}...") + self.logger.debug("Query cache hit") return cached_result.result self.metrics.cache_misses += 1 @@ -802,7 +802,9 @@ class DorisQueryExecutor: validation_result = await security_manager.validate_sql_security(sql, auth_context) if not validation_result.is_valid: - self.logger.warning(f"SQL security validation failed for query: {sql[:100]}...") + self.logger.warning( + "SQL security validation rejected a query" + ) return { "success": False, "error": f"SQL security validation failed: {validation_result.error_message}", @@ -819,7 +821,9 @@ class DorisQueryExecutor: } } else: - self.logger.debug(f"SQL security validation passed for query: {sql[:100]}...") + self.logger.debug( + "SQL security validation passed" + ) except Exception as security_error: self.logger.error(f"Security validation error: {str(security_error)}") # In case of security validation error, fail safe diff --git a/doris_mcp_server/utils/redaction.py b/doris_mcp_server/utils/redaction.py new file mode 100644 index 0000000..86e2b19 --- /dev/null +++ b/doris_mcp_server/utils/redaction.py @@ -0,0 +1,273 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Secret redaction helpers for logs and public error payloads.""" + +from __future__ import annotations + +import logging +import re +import traceback +from collections.abc import Mapping +from types import TracebackType +from typing import Any + +REDACTED = "[REDACTED]" + +_SENSITIVE_KEYS = frozenset( + { + "authorization", + "proxyauthorization", + "password", + "passwd", + "pwd", + "dbpassword", + "admintoken", + "customtoken", + "token", + "authtoken", + "sessiontoken", + "accesstoken", + "refreshtoken", + "idtoken", + "bearertoken", + "secret", + "secretkey", + "clientsecret", + "tokensecret", + "apikey", + "privatekey", + "credential", + "credentials", + "cookie", + "setcookie", + } +) +_SQL_KEYS = frozenset({"sql", "query", "statement", "sqltext", "querytext"}) +_ERROR_DROP_KEYS = frozenset( + { + "arguments", + "headers", + "payload", + "rawrequest", + "request", + "requestbody", + } +) + +_TEXT_SECRET_KEY = ( + r"(?:authorization|proxy[-_ ]authorization|password|passwd|pwd|" + r"db[-_ ]password|admin[-_ ]token|custom[-_ ]token|auth[-_ ]token|" + r"session[-_ ]token|token|secret|secret[-_ ]key|" + r"access[-_ ]token|refresh[-_ ]token|id[-_ ]token|bearer[-_ ]token|" + r"client[-_ ]secret|token[-_ ]secret|api[-_ ]key|private[-_ ]key|" + r"cookie|set[-_ ]cookie)" +) +_QUOTED_KEY_VALUE_RE = re.compile( + rf"(?i)(?P<prefix>[\"']?{_TEXT_SECRET_KEY}[\"']?\s*[:=]\s*)" + r"(?P<quote>[\"'])(?P<value>.*?)(?P=quote)" +) +_AUTHORIZATION_VALUE_RE = re.compile( + r"(?i)(?P<prefix>\b(?:authorization|proxy[-_ ]authorization)" + r"\s*[:=]\s*)(?:(?:bearer|basic|token)\s+)?" + r"(?P<value>[^\s,;\"'}]+)" +) +_UNQUOTED_KEY_VALUE_RE = re.compile( + rf"(?i)(?P<prefix>\b{_TEXT_SECRET_KEY}\b\s*[:=]\s*)" + r"(?P<value>(?![\"'\[])[^\s,;&}\]]+)" +) +_AUTH_SCHEME_RE = re.compile( + r"(?i)\b(?P<scheme>bearer|basic|token)\s+" + r"(?P<value>[A-Za-z0-9._~+/=-]+)" +) +_QUERY_SECRET_RE = re.compile( + rf"(?i)(?P<prefix>[?&]{_TEXT_SECRET_KEY}=)(?P<value>[^&#\s]*)" +) +_DSN_PASSWORD_RE = re.compile( + r"(?i)(?P<prefix>[a-z][a-z0-9+.-]*://[^:/@\s]+:)" + r"(?P<password>[^@\s/]+)(?P<suffix>@)" +) + +_SQL_KEYWORD_RE = re.compile( + r"(?i)\b(?:select|insert|update|delete|replace|merge|create|alter|drop|" + r"truncate|explain|with)\b" +) +_SQL_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) +_SQL_LINE_COMMENT_RE = re.compile(r"--[^\r\n]*") +_SQL_SINGLE_QUOTED_RE = re.compile(r"'(?:''|\\.|[^'])*'") +_SQL_DOUBLE_QUOTED_RE = re.compile(r'"(?:""|\\.|[^"])*"') +_SQL_HEX_RE = re.compile(r"(?i)\b(?:0x[0-9a-f]+|x'[0-9a-f]+')\b") +_SQL_NUMBER_RE = re.compile(r"(?<![\w.])[-+]?\d+(?:\.\d+)?(?![\w.])") + +_ERROR_DETAIL_RE = re.compile( + r"(?is)^(?P<prefix>.*?\b(?:failed|failure|error|exception)\b" + r"[^:\n]{0,160}:)\s*.+$" +) + + +def _normalized_key(key: object) -> str: + return re.sub(r"[^a-z0-9]", "", str(key).casefold()) + + +def redact_sql_literals(value: str) -> str: + """Remove values and comments from SQL while retaining diagnostic shape.""" + if not _SQL_KEYWORD_RE.search(value): + return value + value = _SQL_BLOCK_COMMENT_RE.sub("/* [REDACTED] */", value) + value = _SQL_LINE_COMMENT_RE.sub("-- [REDACTED]", value) + value = _SQL_HEX_RE.sub(REDACTED, value) + value = _SQL_SINGLE_QUOTED_RE.sub(f"'{REDACTED}'", value) + value = _SQL_DOUBLE_QUOTED_RE.sub(f'"{REDACTED}"', value) + return _SQL_NUMBER_RE.sub("?", value) + + +def redact_sensitive_text(value: str) -> str: + """Redact recognized credentials, secret fields, URIs, and SQL literals.""" + + def quoted_replacement(match: re.Match[str]) -> str: + return f"{match.group('prefix')}{match.group('quote')}{REDACTED}{match.group('quote')}" + + value = _AUTHORIZATION_VALUE_RE.sub( + lambda match: f"{match.group('prefix')}{REDACTED}", + value, + ) + value = _QUOTED_KEY_VALUE_RE.sub(quoted_replacement, value) + value = _UNQUOTED_KEY_VALUE_RE.sub( + lambda match: f"{match.group('prefix')}{REDACTED}", + value, + ) + value = _AUTH_SCHEME_RE.sub( + lambda match: f"{match.group('scheme')} {REDACTED}", + value, + ) + value = _QUERY_SECRET_RE.sub( + lambda match: f"{match.group('prefix')}{REDACTED}", + value, + ) + value = _DSN_PASSWORD_RE.sub( + lambda match: (f"{match.group('prefix')}{REDACTED}{match.group('suffix')}"), + value, + ) + return redact_sql_literals(value) + + +def redact_log_message(value: str) -> str: + """Redact a log message and suppress untrusted exception detail suffixes.""" + redacted = redact_sensitive_text(value) + match = _ERROR_DETAIL_RE.match(redacted) + if match is None: + return redacted + return f"{match.group('prefix')} {REDACTED}" + + +def redact_sensitive_data(value: Any, *, key: object | None = None) -> Any: + """Recursively redact data by semantic key and recognizable string format.""" + normalized_key = _normalized_key(key) if key is not None else "" + if normalized_key in _SENSITIVE_KEYS or normalized_key in _SQL_KEYS: + return REDACTED + if isinstance(value, Mapping): + return { + item_key: redact_sensitive_data(item_value, key=item_key) + for item_key, item_value in value.items() + } + if isinstance(value, list): + return [redact_sensitive_data(item) for item in value] + if isinstance(value, tuple): + return tuple(redact_sensitive_data(item) for item in value) + if isinstance(value, set): + return {redact_sensitive_data(item) for item in value} + if isinstance(value, BaseException): + return value.__class__.__name__ + if isinstance(value, str): + return redact_sensitive_text(value) + if isinstance(value, bytes): + return redact_sensitive_text(value.decode("utf-8", errors="replace")).encode() + return value + + +def redact_error_payload(value: Any) -> Any: + """Sanitize an error-only response without touching successful results.""" + if isinstance(value, Mapping): + sanitized = {} + for key, item_value in value.items(): + normalized_key = _normalized_key(key) + if normalized_key in _ERROR_DROP_KEYS: + continue + sanitized[key] = redact_error_payload( + REDACTED + if normalized_key in _SENSITIVE_KEYS or normalized_key in _SQL_KEYS + else item_value + ) + return sanitized + if isinstance(value, list): + return [redact_error_payload(item) for item in value] + if isinstance(value, tuple): + return tuple(redact_error_payload(item) for item in value) + if isinstance(value, str): + return redact_sensitive_text(value) + return value + + +def redact_uri(value: str) -> str: + """Redact credential-bearing URI components without changing safe URIs.""" + return redact_sensitive_text(value) + + +def _safe_exception_text( + exc_info: tuple[ + type[BaseException], + BaseException, + TracebackType | None, + ], +) -> str: + exception_type, _exception, traceback_value = exc_info + frames = traceback.format_list(traceback.extract_tb(traceback_value)) + frames.append(f"{exception_type.__name__}: {REDACTED}\n") + return "".join(frames) + + +class SensitiveDataFilter(logging.Filter): + """Apply defense-in-depth redaction before any configured log handler.""" + + def filter(self, record: logging.LogRecord) -> bool: + dropped_error_details = False + if isinstance(record.msg, str): + sanitized_message = redact_sensitive_text(record.msg) + dropped_error_details = ( + _ERROR_DETAIL_RE.match(sanitized_message) is not None + ) + record.msg = redact_log_message(record.msg) + else: + record.msg = redact_sensitive_data(record.msg) + + if dropped_error_details: + record.args = () + elif isinstance(record.args, Mapping): + record.args = redact_sensitive_data(record.args) + elif isinstance(record.args, tuple): + record.args = tuple( + redact_sensitive_data(argument) for argument in record.args + ) + + if ( + record.exc_info + and record.exc_info[0] is not None + and record.exc_info[1] is not None + ): + record.exc_text = _safe_exception_text(record.exc_info) + if record.stack_info: + record.stack_info = redact_sensitive_text(record.stack_info) + return True diff --git a/doris_mcp_server/utils/schema_extractor.py b/doris_mcp_server/utils/schema_extractor.py index 22f57a3..e6da0bb 100644 --- a/doris_mcp_server/utils/schema_extractor.py +++ b/doris_mcp_server/utils/schema_extractor.py @@ -386,7 +386,11 @@ class MetadataExtractor: """ result = self._execute_query_with_catalog(query, db_name, effective_catalog) - logger.info(f"{effective_catalog or 'default'}.{db_name}.information_schema.tables query result: {result}") + logger.info( + "%s.%s.information_schema.tables query completed", + effective_catalog or "default", + db_name, + ) if not result: tables = [] @@ -815,7 +819,7 @@ class MetadataExtractor: if effective_catalog: safe_catalog = quote_identifier(effective_catalog, "catalog name") query = f"SHOW INDEX FROM {safe_catalog}.{safe_db}.{safe_table}" - logger.info(f"Using three-part naming for index query: {query}") + logger.info("Using three-part naming for index query") else: query = f"SHOW INDEX FROM {safe_db}.{safe_table}" @@ -1288,7 +1292,10 @@ class MetadataExtractor: try: if catalog_name and 'information_schema' in query.lower(): modified_query = query.replace('information_schema', f'{catalog_name}.information_schema') - logger.info(f"Modified query for catalog {catalog_name}: {modified_query}") + logger.info( + "Prepared catalog-qualified query for %s", + catalog_name, + ) return await self._execute_query_async(modified_query, db_name) else: return await self._execute_query_async(query, db_name) @@ -1633,7 +1640,7 @@ class MetadataExtractor: if effective_catalog: safe_catalog = quote_identifier(effective_catalog, "catalog name") query = f"SHOW INDEX FROM {safe_catalog}.{safe_db}.{safe_table}" - logger.info(f"Using three-part naming for async index query: {query}") + logger.info("Using three-part naming for async index query") else: query = f"SHOW INDEX FROM {safe_db}.{safe_table}" @@ -1753,7 +1760,13 @@ class MetadataExtractor: FIX for Issue #62 Bug 1: Now retrieves auth_context from context variable to support token-bound database configuration FIX for Issue #62 Bug 3: Now uses db_name and catalog_name parameters to switch database context """ - logger.info(f"Executing SQL query: {sql}, DB: {db_name}, Catalog: {catalog_name}, MaxRows: {max_rows}, Timeout: {timeout}") + logger.info( + "Executing SQL query for DB=%s, catalog=%s, max_rows=%s, timeout=%s", + db_name, + catalog_name, + max_rows, + timeout, + ) try: if not sql: diff --git a/doris_mcp_server/utils/security.py b/doris_mcp_server/utils/security.py index d6acb27..e1e6915 100644 --- a/doris_mcp_server/utils/security.py +++ b/doris_mcp_server/utils/security.py @@ -1087,7 +1087,11 @@ class SQLSecurityValidator: if not parsed.tokens or str(parsed).strip() == '': continue - self.logger.debug(f"Validating SQL statement {idx + 1}/{len(all_statements)}: {str(parsed)[:100]}...") + self.logger.debug( + "Validating SQL statement %s/%s", + idx + 1, + len(all_statements), + ) # Check blocked operations first (more specific) keyword_result = await self._check_blocked_keywords(parsed) diff --git a/test/integration/test_real_doris_transports.py b/test/integration/test_real_doris_transports.py index 28afb28..df9099a 100644 --- a/test/integration/test_real_doris_transports.py +++ b/test/integration/test_real_doris_transports.py @@ -25,6 +25,7 @@ to 9030 and ``DORIS_REAL_PASSWORD`` may be empty. from __future__ import annotations import asyncio +import json import os import re import secrets @@ -369,6 +370,22 @@ async def test_real_doris_read_write_permission_timeout_and_recovery( assert verify_result.is_error is False assert verify_payload["data"] == [{"marker": doris_sandbox.marker}] + response_secret = f"sec016-{secrets.token_hex(12)}" + sensitive_error_result, sensitive_error_payload = await _exec_query( + client, + ( + f"SELECT '{response_secret}' AS marker " + f"FROM `{doris_sandbox.table}_missing`" + ), + ) + assert sensitive_error_result.is_error is True + assert sensitive_error_payload["success"] is False + serialized_error = json.dumps( + sensitive_error_result.model_dump(by_alias=True, mode="json"), + ensure_ascii=False, + ) + assert response_secret not in serialized_error + timeout_result, timeout_payload = await _exec_query( client, "SELECT SLEEP(2) AS slept", diff --git a/test/protocol/stdio_capability_server.py b/test/protocol/stdio_capability_server.py index d0d222e..ad588fc 100644 --- a/test/protocol/stdio_capability_server.py +++ b/test/protocol/stdio_capability_server.py @@ -231,6 +231,17 @@ class OneToolManager: ] async def call_tool(self, name: str, arguments: dict) -> str: + if name == "echo" and arguments.get("fail"): + return json.dumps( + { + "error": ( + f"query failed: password={arguments['password']}; " + f"token={arguments['token']}; {arguments['sql']}" + ), + "arguments": arguments, + "token": arguments["token"], + } + ) if name == "get_sql_profile": return json.dumps( await self.profile_analyzer.get_sql_profile( diff --git a/test/protocol/test_mcp_v2_protocol.py b/test/protocol/test_mcp_v2_protocol.py index a050239..aa816e6 100644 --- a/test/protocol/test_mcp_v2_protocol.py +++ b/test/protocol/test_mcp_v2_protocol.py @@ -94,6 +94,17 @@ class StubToolsManager: async def call_tool(self, name: str, arguments: dict) -> str: if name == "fail": + if arguments: + return json.dumps( + { + "error": ( + f"query failed: password={arguments['password']}; " + f"token={arguments['token']}; {arguments['sql']}" + ), + "arguments": arguments, + "token": arguments["token"], + } + ) return json.dumps({"error": "expected failure"}) return json.dumps({"name": name, "arguments": arguments}) @@ -574,13 +585,26 @@ async def test_http_enforces_tool_specific_client_capabilities_and_recovers(): "arguments": {"value": "capable"}, } + secret = "http-secret-sec-016" recovered = await client.post( "/mcp", - json=modern_tool_request(3, "fail"), + json=modern_tool_request( + 3, + "fail", + { + "password": secret, + "token": secret, + "sql": f"SELECT '{secret}'", + }, + ), headers=modern_tool_headers("fail"), ) assert recovered.status_code == 200 assert recovered.json()["result"]["isError"] is True + assert secret not in recovered.text + structured = recovered.json()["result"]["structuredContent"] + assert "arguments" not in structured + assert structured["token"] == "[REDACTED]" @pytest.mark.asyncio @@ -863,6 +887,24 @@ async def test_stdio_validates_capabilities_versions_and_process_survival(): "monitor_data_freshness", "analyze_data_access_patterns", ] + secret = "stdio-secret-sec-016" + error_result = await capable.call_tool( + "echo", + { + "fail": True, + "password": secret, + "token": secret, + "sql": f"SELECT '{secret}'", + }, + ) + assert error_result.is_error is True + serialized = json.dumps( + error_result.model_dump(by_alias=True, mode="json"), + ensure_ascii=False, + ) + assert secret not in serialized + assert "arguments" not in error_result.structured_content + assert error_result.structured_content["token"] == "[REDACTED]" async with Client(stdio_client(server_params), mode="legacy") as legacy: assert [tool.name for tool in (await legacy.list_tools()).tools] == [ diff --git a/test/security/test_mcp_auth_middleware.py b/test/security/test_mcp_auth_middleware.py index 7aec4b5..ad9b9c4 100644 --- a/test/security/test_mcp_auth_middleware.py +++ b/test/security/test_mcp_auth_middleware.py @@ -118,6 +118,71 @@ async def test_mcp_auth_middleware_rejects_query_string_token(): assert get_current_auth_context() is None [email protected] [email protected]( + ("auth_methods", "discovery_mode", "base_url"), + [ + (("token",), "none", ""), + ( + ("doris_oauth",), + "doris_oauth", + "https://mcp.example.test", + ), + ], +) +async def test_mcp_auth_middleware_does_not_reflect_unexpected_auth_details( + auth_methods, + discovery_mode, + base_url, +): + secret = "auth-provider-secret-sec-016" + + class SecurityManager: + async def authenticate_request(self, credentials): + del credentials + raise RuntimeError( + f"Authorization: Bearer {secret}; password={secret}" + ) + + async def downstream(scope, receive, send): + raise AssertionError("downstream must not be called") + + messages = [] + middleware = MCPAuthASGIMiddleware( + SecurityManager(), + downstream, + _effective( + auth_methods=auth_methods, + discovery_mode=discovery_mode, + base_url=base_url, + ), + ) + await middleware( + { + "type": "http", + "path": "/mcp", + "headers": [(b"authorization", f"Bearer {secret}".encode())], + "client": ("127.0.0.1", 1), + }, + _receive, + _send_collector(messages), + ) + + assert messages[0]["status"] == 401 + assert secret.encode() not in messages[1]["body"] + body = json.loads(messages[1]["body"]) + if discovery_mode == "doris_oauth": + assert body == { + "error": "authentication_required", + "error_description": "Authentication required", + } + else: + assert body == { + "error": "Authentication required", + "message": "Authentication failed", + } + + @pytest.mark.asyncio async def test_mcp_auth_middleware_returns_doris_oauth_challenge_on_401(): class SecurityManager: diff --git a/test/security/test_sensitive_data_redaction.py b/test/security/test_sensitive_data_redaction.py new file mode 100644 index 0000000..289b987 --- /dev/null +++ b/test/security/test_sensitive_data_redaction.py @@ -0,0 +1,193 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import io +import json +import logging +from unittest.mock import Mock + +import pytest + +from doris_mcp_server.tools.resources_manager import DorisResourcesManager +from doris_mcp_server.tools.tools_manager import DorisToolsManager +from doris_mcp_server.utils.redaction import ( + REDACTED, + SensitiveDataFilter, + redact_error_payload, + redact_sensitive_data, + redact_sensitive_text, +) + +AUTH_SECRET = "auth-secret-sec-016" +PASSWORD_SECRET = "password-secret-sec-016" +TOKEN_SECRET = "token-secret-sec-016" +URI_SECRET = "uri-secret-sec-016" +SQL_SECRET = "customer-secret-sec-016" + + +def assert_secrets_absent(value) -> None: + serialized = ( + value + if isinstance(value, str) + else json.dumps(value, ensure_ascii=False, default=str) + ) + for secret in ( + AUTH_SECRET, + PASSWORD_SECRET, + TOKEN_SECRET, + URI_SECRET, + SQL_SECRET, + ): + assert secret not in serialized + + +def test_recursive_redaction_uses_keys_without_hiding_safe_token_ids(): + payload = { + "Authorization": f"Bearer {AUTH_SECRET}", + "database": {"user": "alice", "password": PASSWORD_SECRET}, + "access_token": TOKEN_SECRET, + "secret_key": TOKEN_SECRET, + "token_id": "public-token-id", + "sql": f"SELECT * FROM customer WHERE email = '{SQL_SECRET}'", + } + + redacted = redact_sensitive_data(payload) + + assert redacted["Authorization"] == REDACTED + assert redacted["database"]["password"] == REDACTED + assert redacted["access_token"] == REDACTED + assert redacted["secret_key"] == REDACTED + assert redacted["token_id"] == "public-token-id" + assert redacted["sql"] == REDACTED + assert_secrets_absent(redacted) + + +def test_text_redaction_covers_headers_dsn_query_parameters_and_sql_literals(): + message = ( + f"Authorization: Bearer {AUTH_SECRET}; " + f"password='{PASSWORD_SECRET}'; " + f"mysql://alice:{URI_SECRET}@db.example.test/analytics" + f"?access_token={TOKEN_SECRET}; " + f"SELECT * FROM customer WHERE email = '{SQL_SECRET}' AND id = 42" + ) + + redacted = redact_sensitive_text(message) + + assert redacted.count(REDACTED) >= 5 + assert "id = ?" in redacted + assert_secrets_absent(redacted) + + +def test_error_payload_drops_request_material_and_preserves_safe_diagnostics(): + payload = { + "error": ( + f"Access denied password={PASSWORD_SECRET}; " + f"SELECT * FROM customer WHERE email='{SQL_SECRET}'" + ), + "error_code": "QUERY_FAILED", + "arguments": { + "Authorization": f"Bearer {AUTH_SECRET}", + "token": TOKEN_SECRET, + }, + "details": {"access_token": TOKEN_SECRET}, + } + + redacted = redact_error_payload(payload) + + assert redacted["error_code"] == "QUERY_FAILED" + assert "arguments" not in redacted + assert redacted["details"]["access_token"] == REDACTED + assert_secrets_absent(redacted) + + +def test_logging_filter_redacts_arguments_and_exception_tracebacks(): + output = io.StringIO() + handler = logging.StreamHandler(output) + handler.addFilter(SensitiveDataFilter()) + handler.setFormatter(logging.Formatter("%(levelname)s %(message)s")) + logger = logging.Logger("sec-016-redaction-test") + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + + logger.info( + "request headers=%s payload=%s", + {"Authorization": f"Bearer {AUTH_SECRET}"}, + { + "password": PASSWORD_SECRET, + "token": TOKEN_SECRET, + "sql": f"SELECT '{SQL_SECRET}'", + }, + ) + try: + raise RuntimeError( + f"backend echoed password={PASSWORD_SECRET} and token={TOKEN_SECRET}" + ) + except RuntimeError: + logger.exception("Backend operation failed") + + rendered = output.getvalue() + assert REDACTED in rendered + assert "RuntimeError" in rendered + assert_secrets_absent(rendered) + + [email protected] +async def test_tool_manager_never_returns_exception_or_arguments(): + manager = object.__new__(DorisToolsManager) + + async def fail_with_secrets(arguments): + del arguments + raise RuntimeError( + f"password={PASSWORD_SECRET}; token={TOKEN_SECRET}; SELECT '{SQL_SECRET}'" + ) + + manager._exec_query_tool = fail_with_secrets + result = await manager.call_tool( + "exec_query", + { + "sql": f"SELECT '{SQL_SECRET}'", + "password": PASSWORD_SECRET, + "token": TOKEN_SECRET, + }, + ) + payload = json.loads(result) + + assert payload["error"] == "Tool execution failed" + assert payload["error_code"] == "TOOL_EXECUTION_FAILED" + assert "arguments" not in payload + assert_secrets_absent(payload) + + [email protected] +async def test_resource_error_redacts_uri_and_backend_exception(): + manager = DorisResourcesManager(Mock()) + + def fail_to_parse(uri): + del uri + raise RuntimeError( + f"password={PASSWORD_SECRET}; token={TOKEN_SECRET}; SELECT '{SQL_SECRET}'" + ) + + manager._parse_resource_uri = fail_to_parse + result = await manager.read_resource( + f"doris://table/orders?access_token={URI_SECRET}" + ) + payload = json.loads(result) + + assert payload["error"] == "Resource read failed" + assert payload["uri"].endswith(f"access_token={REDACTED}") + assert_secrets_absent(payload) diff --git a/test/tools/test_resources_manager_cache.py b/test/tools/test_resources_manager_cache.py index 77596cd..4dcff8c 100644 --- a/test/tools/test_resources_manager_cache.py +++ b/test/tools/test_resources_manager_cache.py @@ -320,14 +320,15 @@ def test_parse_stats_resource_distinguishes_legacy_current_database_from_literal @pytest.mark.asyncio -async def test_legacy_read_resource_keeps_json_error_body_compatibility(): +async def test_legacy_read_resource_hides_backend_error_details(): manager = DorisResourcesManager(RaisingConnectionManager()) result = await manager.read_resource("doris://table/orders") payload = json.loads(result) assert payload["uri"] == "doris://table/orders" - assert "metadata backend failed" in payload["error"] + assert payload["error"] == "Resource read failed" + assert "metadata backend failed" not in payload["error"] assert "error_code" not in payload @@ -339,7 +340,7 @@ async def test_read_resource_marks_invalid_uri_for_protocol_boundary(): payload = json.loads(result) assert payload == { - "error": "Failed to read resource: Invalid resource URI format", + "error": "Invalid resource URI", "error_code": "INVALID_RESOURCE_URI", "uri": "https://example.com/orders", } @@ -362,7 +363,7 @@ async def test_read_resource_marks_missing_table_for_protocol_boundary(): payload = json.loads(result) assert payload == { - "error": "Failed to read resource: Table missing does not exist", + "error": "Resource not found", "error_code": "RESOURCE_NOT_FOUND", "uri": "doris://table/missing", } diff --git a/test/tools/test_tools_manager.py b/test/tools/test_tools_manager.py index 83b29ac..9c94ab6 100644 --- a/test/tools/test_tools_manager.py +++ b/test/tools/test_tools_manager.py @@ -240,7 +240,8 @@ class TestDorisToolsManager: assert "error" in result_data or "success" in result_data if "error" in result_data: - assert "Unknown tool" in result_data["error"] + assert result_data["error"] == "Tool execution failed" + assert result_data["error_code"] == "TOOL_EXECUTION_FAILED" @pytest.mark.asyncio async def test_missing_required_arguments(self, tools_manager): --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
