This is an automated email from the ASF dual-hosted git repository.
FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
The following commit(s) were added to refs/heads/master by this push:
new 3da8dc4 fix: redact sensitive logs and errors (#127)
3da8dc4 is described below
commit 3da8dc49009d2364f56d9135fd67fc661204e854
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 00:13:05 2026 +0800
fix: redact sensitive logs and errors (#127)
---
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]