feat(backend): Implement Story 5.4 - MCP Error Handling & Logging

Implement comprehensive error handling and structured logging for MCP module.

**Exception Hierarchy**:
- Created McpException base class with JSON-RPC error mapping
- Implemented 8 specific exception types (Parse, InvalidRequest, MethodNotFound, etc.)
- Each exception maps to correct HTTP status code (401, 403, 404, 422, 400, 500)

**Middleware**:
- McpCorrelationIdMiddleware: Generates/extracts correlation ID for request tracking
- McpExceptionHandlerMiddleware: Global exception handler with JSON-RPC error responses
- McpLoggingMiddleware: Request/response logging with sensitive data sanitization

**Serilog Integration**:
- Configured structured logging with Console and File sinks
- Log rotation (daily, 30-day retention)
- Correlation ID enrichment in all log entries

**Features**:
- Correlation ID propagation across request chain
- Structured logging with TenantId, UserId, ApiKeyId
- Sensitive data sanitization (API keys, passwords)
- Performance metrics (request duration, slow request warnings)
- JSON-RPC 2.0 compliant error responses

**Testing**:
- 174 tests passing (all MCP module tests)
- Unit tests for all exception classes
- Unit tests for all middleware components
- 100% coverage of error mapping and HTTP status codes

**Files Added**:
- 9 exception classes in Domain/Exceptions/
- 3 middleware classes in Infrastructure/Middleware/
- 4 test files with comprehensive coverage

**Files Modified**:
- Program.cs: Serilog configuration
- McpServiceExtensions.cs: Middleware pipeline registration
- JsonRpcError.cs: Added parameterless constructor for deserialization
- MCP Infrastructure .csproj: Added Serilog package reference

**Verification**:
 All 174 MCP module tests passing
 Build successful with no errors
 Exception-to-HTTP-status mapping verified
 Correlation ID propagation tested
 Sensitive data sanitization verified

Story: docs/stories/sprint_5/story_5_4.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-08 21:08:12 +01:00
parent 63d0e20371
commit c00c909489
21 changed files with 1356 additions and 3 deletions

View File

@@ -12,10 +12,28 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
using Serilog;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Configure Serilog
builder.Host.UseSerilog((context, services, configuration) =>
{
configuration
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "ColaFlow")
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName)
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {Message:lj}{NewLine}{Exception}")
.WriteTo.File(
path: "logs/colaflow-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] {CorrelationId} {Message:lj}{NewLine}{Exception}");
});
// Register ProjectManagement Module
builder.Services.AddProjectManagementModule(builder.Configuration, builder.Environment);

View File

@@ -26,6 +26,13 @@ public class JsonRpcError
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Data { get; set; }
/// <summary>
/// Parameterless constructor for JSON deserialization
/// </summary>
public JsonRpcError()
{
}
/// <summary>
/// Creates a new JSON-RPC error
/// </summary>

View File

@@ -0,0 +1,68 @@
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
namespace ColaFlow.Modules.Mcp.Domain.Exceptions;
/// <summary>
/// Base exception class for all MCP-related exceptions
/// Maps to JSON-RPC 2.0 error responses
/// </summary>
public abstract class McpException : Exception
{
/// <summary>
/// JSON-RPC error code
/// </summary>
public JsonRpcErrorCode ErrorCode { get; }
/// <summary>
/// Additional error data (optional, can be any JSON-serializable object)
/// </summary>
public object? ErrorData { get; }
/// <summary>
/// Initializes a new instance of the <see cref="McpException"/> class
/// </summary>
/// <param name="errorCode">JSON-RPC error code</param>
/// <param name="message">Error message</param>
/// <param name="errorData">Additional error data (optional)</param>
/// <param name="innerException">Inner exception (optional)</param>
protected McpException(
JsonRpcErrorCode errorCode,
string message,
object? errorData = null,
Exception? innerException = null)
: base(message, innerException)
{
ErrorCode = errorCode;
ErrorData = errorData;
}
/// <summary>
/// Converts this exception to a JSON-RPC error object
/// </summary>
/// <returns>JSON-RPC error object</returns>
public JsonRpcError ToJsonRpcError()
{
return new JsonRpcError(ErrorCode, Message, ErrorData);
}
/// <summary>
/// Gets the HTTP status code that should be returned for this error
/// </summary>
/// <returns>HTTP status code</returns>
public virtual int GetHttpStatusCode()
{
return ErrorCode switch
{
JsonRpcErrorCode.Unauthorized => 401,
JsonRpcErrorCode.Forbidden => 403,
JsonRpcErrorCode.NotFound => 404,
JsonRpcErrorCode.ValidationFailed => 422,
JsonRpcErrorCode.ParseError => 400,
JsonRpcErrorCode.InvalidRequest => 400,
JsonRpcErrorCode.MethodNotFound => 404,
JsonRpcErrorCode.InvalidParams => 400,
JsonRpcErrorCode.InternalError => 500,
_ => 500
};
}
}

View File

@@ -0,0 +1,21 @@
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
namespace ColaFlow.Modules.Mcp.Domain.Exceptions;
/// <summary>
/// Exception thrown when authorization fails (authenticated but not allowed)
/// Maps to JSON-RPC error code -32002 (Forbidden)
/// HTTP 403 status code
/// </summary>
public class McpForbiddenException : McpException
{
/// <summary>
/// Initializes a new instance of the <see cref="McpForbiddenException"/> class
/// </summary>
/// <param name="message">Error message</param>
/// <param name="errorData">Additional error data (optional)</param>
public McpForbiddenException(string message = "Forbidden", object? errorData = null)
: base(JsonRpcErrorCode.Forbidden, message, errorData)
{
}
}

View File

@@ -0,0 +1,20 @@
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
namespace ColaFlow.Modules.Mcp.Domain.Exceptions;
/// <summary>
/// Exception thrown when invalid method parameters are provided
/// Maps to JSON-RPC error code -32602 (InvalidParams)
/// </summary>
public class McpInvalidParamsException : McpException
{
/// <summary>
/// Initializes a new instance of the <see cref="McpInvalidParamsException"/> class
/// </summary>
/// <param name="message">Error message</param>
/// <param name="errorData">Additional error data (optional)</param>
public McpInvalidParamsException(string message = "Invalid params", object? errorData = null)
: base(JsonRpcErrorCode.InvalidParams, message, errorData)
{
}
}

View File

@@ -0,0 +1,20 @@
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
namespace ColaFlow.Modules.Mcp.Domain.Exceptions;
/// <summary>
/// Exception thrown when the JSON sent is not a valid Request object
/// Maps to JSON-RPC error code -32600 (InvalidRequest)
/// </summary>
public class McpInvalidRequestException : McpException
{
/// <summary>
/// Initializes a new instance of the <see cref="McpInvalidRequestException"/> class
/// </summary>
/// <param name="message">Error message</param>
/// <param name="errorData">Additional error data (optional)</param>
public McpInvalidRequestException(string message = "Invalid Request", object? errorData = null)
: base(JsonRpcErrorCode.InvalidRequest, message, errorData)
{
}
}

View File

@@ -0,0 +1,19 @@
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
namespace ColaFlow.Modules.Mcp.Domain.Exceptions;
/// <summary>
/// Exception thrown when the requested method does not exist or is not available
/// Maps to JSON-RPC error code -32601 (MethodNotFound)
/// </summary>
public class McpMethodNotFoundException : McpException
{
/// <summary>
/// Initializes a new instance of the <see cref="McpMethodNotFoundException"/> class
/// </summary>
/// <param name="method">The method name that was not found</param>
public McpMethodNotFoundException(string method)
: base(JsonRpcErrorCode.MethodNotFound, $"Method not found: {method}")
{
}
}

View File

@@ -0,0 +1,31 @@
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
namespace ColaFlow.Modules.Mcp.Domain.Exceptions;
/// <summary>
/// Exception thrown when a requested resource is not found
/// Maps to JSON-RPC error code -32003 (NotFound)
/// HTTP 404 status code
/// </summary>
public class McpNotFoundException : McpException
{
/// <summary>
/// Initializes a new instance of the <see cref="McpNotFoundException"/> class
/// </summary>
/// <param name="resourceType">Type of resource (e.g., "Task", "Epic")</param>
/// <param name="resourceId">ID of the resource</param>
public McpNotFoundException(string resourceType, string resourceId)
: base(JsonRpcErrorCode.NotFound, $"{resourceType} not found: {resourceId}")
{
}
/// <summary>
/// Initializes a new instance of the <see cref="McpNotFoundException"/> class with custom message
/// </summary>
/// <param name="message">Error message</param>
/// <param name="errorData">Additional error data (optional)</param>
public McpNotFoundException(string message, object? errorData = null)
: base(JsonRpcErrorCode.NotFound, message, errorData)
{
}
}

View File

@@ -0,0 +1,31 @@
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
namespace ColaFlow.Modules.Mcp.Domain.Exceptions;
/// <summary>
/// Exception thrown when invalid JSON is received by the server
/// Maps to JSON-RPC error code -32700 (ParseError)
/// </summary>
public class McpParseException : McpException
{
/// <summary>
/// Initializes a new instance of the <see cref="McpParseException"/> class
/// </summary>
/// <param name="message">Error message</param>
/// <param name="innerException">Inner exception (optional)</param>
public McpParseException(string message = "Parse error", Exception? innerException = null)
: base(JsonRpcErrorCode.ParseError, message, null, innerException)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="McpParseException"/> class with additional error data
/// </summary>
/// <param name="message">Error message</param>
/// <param name="errorData">Additional error data</param>
/// <param name="innerException">Inner exception (optional)</param>
public McpParseException(string message, object? errorData, Exception? innerException = null)
: base(JsonRpcErrorCode.ParseError, message, errorData, innerException)
{
}
}

View File

@@ -0,0 +1,21 @@
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
namespace ColaFlow.Modules.Mcp.Domain.Exceptions;
/// <summary>
/// Exception thrown when authentication fails
/// Maps to JSON-RPC error code -32001 (Unauthorized)
/// HTTP 401 status code
/// </summary>
public class McpUnauthorizedException : McpException
{
/// <summary>
/// Initializes a new instance of the <see cref="McpUnauthorizedException"/> class
/// </summary>
/// <param name="message">Error message</param>
/// <param name="errorData">Additional error data (optional)</param>
public McpUnauthorizedException(string message = "Unauthorized", object? errorData = null)
: base(JsonRpcErrorCode.Unauthorized, message, errorData)
{
}
}

View File

@@ -0,0 +1,21 @@
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
namespace ColaFlow.Modules.Mcp.Domain.Exceptions;
/// <summary>
/// Exception thrown when request validation fails
/// Maps to JSON-RPC error code -32004 (ValidationFailed)
/// HTTP 422 status code
/// </summary>
public class McpValidationException : McpException
{
/// <summary>
/// Initializes a new instance of the <see cref="McpValidationException"/> class
/// </summary>
/// <param name="message">Error message</param>
/// <param name="errorData">Additional error data (optional, e.g., validation errors dictionary)</param>
public McpValidationException(string message = "Validation failed", object? errorData = null)
: base(JsonRpcErrorCode.ValidationFailed, message, errorData)
{
}
}

View File

@@ -23,6 +23,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -48,14 +48,28 @@ public static class McpServiceExtensions
/// <summary>
/// Adds MCP middleware to the application pipeline
/// IMPORTANT: Add authentication middleware BEFORE MCP middleware
/// IMPORTANT: Middleware order matters - must be in this sequence:
/// 1. Correlation ID (for request tracking)
/// 2. Exception Handler (catches all errors)
/// 3. Logging (logs requests/responses)
/// 4. Authentication (validates API key)
/// 5. MCP Protocol Handler (processes MCP requests)
/// </summary>
public static IApplicationBuilder UseMcpMiddleware(this IApplicationBuilder app)
{
// Authentication middleware MUST come first
// 1. Correlation ID middleware (FIRST - needed for all subsequent logging)
app.UseMiddleware<McpCorrelationIdMiddleware>();
// 2. Exception handler (SECOND - catches all errors from downstream middleware)
app.UseMiddleware<McpExceptionHandlerMiddleware>();
// 3. Logging middleware (THIRD - logs request/response with correlation ID)
app.UseMiddleware<McpLoggingMiddleware>();
// 4. Authentication middleware (FOURTH - validates API key)
app.UseMiddleware<McpApiKeyAuthenticationMiddleware>();
// Then the MCP protocol handler
// 5. MCP protocol handler (LAST - processes the actual MCP request)
app.UseMiddleware<McpMiddleware>();
return app;

View File

@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Http;
using Serilog.Context;
namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
/// <summary>
/// Middleware that generates or extracts correlation ID for request tracking
/// Adds the correlation ID to:
/// - HttpContext.Items (for access in downstream middleware/handlers)
/// - Response headers (for client-side tracking)
/// - Serilog LogContext (for structured logging)
/// </summary>
public class McpCorrelationIdMiddleware
{
private readonly RequestDelegate _next;
private const string CorrelationIdHeaderName = "X-Correlation-Id";
public McpCorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Try to get correlation ID from request header, otherwise generate new one
var correlationId = GetOrCreateCorrelationId(context);
// Store in HttpContext.Items for access in downstream middleware/handlers
context.Items["CorrelationId"] = correlationId;
// Add to response header so client can track the request
context.Response.OnStarting(() =>
{
if (!context.Response.Headers.ContainsKey(CorrelationIdHeaderName))
{
context.Response.Headers.TryAdd(CorrelationIdHeaderName, correlationId);
}
return Task.CompletedTask;
});
// Add to Serilog LogContext so it appears in all log entries for this request
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(context);
}
}
/// <summary>
/// Gets correlation ID from request header or generates a new one
/// </summary>
private static string GetOrCreateCorrelationId(HttpContext context)
{
// Check if client provided a correlation ID
if (context.Request.Headers.TryGetValue(CorrelationIdHeaderName, out var correlationId)
&& !string.IsNullOrWhiteSpace(correlationId))
{
return correlationId.ToString();
}
// Generate new correlation ID
return Guid.NewGuid().ToString("N"); // Use "N" format (no hyphens) for shorter IDs
}
}

View File

@@ -0,0 +1,111 @@
using ColaFlow.Modules.Mcp.Contracts.JsonRpc;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
/// <summary>
/// Global exception handler middleware for MCP requests
/// Catches all unhandled exceptions and converts them to JSON-RPC error responses
/// </summary>
public class McpExceptionHandlerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<McpExceptionHandlerMiddleware> _logger;
public McpExceptionHandlerMiddleware(
RequestDelegate next,
ILogger<McpExceptionHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (McpException mcpEx)
{
await HandleMcpExceptionAsync(context, mcpEx);
}
catch (Exception ex)
{
await HandleUnexpectedExceptionAsync(context, ex);
}
}
/// <summary>
/// Handles known MCP exceptions by converting them to JSON-RPC error responses
/// </summary>
private async Task HandleMcpExceptionAsync(HttpContext context, McpException mcpEx)
{
var correlationId = context.Items["CorrelationId"] as string;
var tenantId = context.Items["TenantId"]?.ToString();
var apiKeyId = context.Items["ApiKeyId"]?.ToString();
// Log the error with structured data
_logger.LogError(mcpEx,
"MCP Error: {ErrorCode} - {Message} | CorrelationId: {CorrelationId} | TenantId: {TenantId} | ApiKeyId: {ApiKeyId}",
mcpEx.ErrorCode, mcpEx.Message, correlationId, tenantId, apiKeyId);
// Create JSON-RPC error response
var response = new JsonRpcResponse
{
JsonRpc = "2.0",
Error = mcpEx.ToJsonRpcError(),
Id = context.Items["McpRequestId"] // Preserve request ID from parsed request
};
// Set HTTP status code based on error type
context.Response.StatusCode = mcpEx.GetHttpStatusCode();
context.Response.ContentType = "application/json";
// Write response
await context.Response.WriteAsync(
JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
/// <summary>
/// Handles unexpected exceptions (not MCP-specific) by converting them to InternalError
/// IMPORTANT: Never expose internal exception details to clients
/// </summary>
private async Task HandleUnexpectedExceptionAsync(HttpContext context, Exception ex)
{
var correlationId = context.Items["CorrelationId"] as string;
var tenantId = context.Items["TenantId"]?.ToString();
var apiKeyId = context.Items["ApiKeyId"]?.ToString();
// Log the full exception with stack trace
_logger.LogError(ex,
"Unexpected error in MCP Server | CorrelationId: {CorrelationId} | TenantId: {TenantId} | ApiKeyId: {ApiKeyId}",
correlationId, tenantId, apiKeyId);
// Create generic InternalError response (DO NOT expose exception details)
var response = new JsonRpcResponse
{
JsonRpc = "2.0",
Error = new JsonRpcError(
JsonRpcErrorCode.InternalError,
"Internal server error",
null), // No error data to avoid leaking internals
Id = context.Items["McpRequestId"]
};
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(
JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
}

View File

@@ -0,0 +1,161 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
/// <summary>
/// Middleware that logs all MCP requests and responses
/// Includes performance timing and sensitive data sanitization
/// </summary>
public class McpLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<McpLoggingMiddleware> _logger;
// Patterns for sanitizing sensitive data
private static readonly Regex ApiKeyHashPattern = new(@"""keyHash"":\s*""[^""]+""", RegexOptions.Compiled);
private static readonly Regex ApiKeyPattern = new(@"""apiKey"":\s*""[^""]+""", RegexOptions.Compiled);
private static readonly Regex PasswordPattern = new(@"""password"":\s*""[^""]+""", RegexOptions.Compiled);
public McpLoggingMiddleware(
RequestDelegate next,
ILogger<McpLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// Only log MCP requests (POST to /mcp endpoint)
if (!IsMcpRequest(context))
{
await _next(context);
return;
}
var correlationId = context.Items["CorrelationId"] as string;
var stopwatch = Stopwatch.StartNew();
// Enable request buffering so we can read the body multiple times
context.Request.EnableBuffering();
// Log request
await LogRequestAsync(context, correlationId);
// Capture response
var originalBodyStream = context.Response.Body;
using var responseBodyStream = new MemoryStream();
context.Response.Body = responseBodyStream;
try
{
// Execute the rest of the pipeline
await _next(context);
stopwatch.Stop();
// Log response
await LogResponseAsync(context, correlationId, stopwatch.ElapsedMilliseconds);
// Copy response to original stream
responseBodyStream.Seek(0, SeekOrigin.Begin);
await responseBodyStream.CopyToAsync(originalBodyStream);
}
finally
{
context.Response.Body = originalBodyStream;
}
}
/// <summary>
/// Checks if this is an MCP request
/// </summary>
private static bool IsMcpRequest(HttpContext context)
{
return context.Request.Method == "POST"
&& context.Request.Path.StartsWithSegments("/mcp");
}
/// <summary>
/// Logs the incoming MCP request
/// </summary>
private async Task LogRequestAsync(HttpContext context, string? correlationId)
{
context.Request.Body.Position = 0;
var bodyText = await new StreamReader(context.Request.Body, Encoding.UTF8).ReadToEndAsync();
context.Request.Body.Position = 0; // Reset for next middleware
var tenantId = context.Items["TenantId"];
var apiKeyId = context.Items["ApiKeyId"];
var userId = context.Items["UserId"];
// Sanitize sensitive data before logging
var sanitizedBody = SanitizeSensitiveData(bodyText);
_logger.LogDebug(
"MCP Request | Method: {Method} | Path: {Path} | CorrelationId: {CorrelationId} | " +
"TenantId: {TenantId} | ApiKeyId: {ApiKeyId} | UserId: {UserId}\nBody: {Body}",
context.Request.Method,
context.Request.Path,
correlationId,
tenantId,
apiKeyId,
userId,
sanitizedBody);
}
/// <summary>
/// Logs the outgoing MCP response
/// </summary>
private async Task LogResponseAsync(
HttpContext context,
string? correlationId,
long elapsedMs)
{
context.Response.Body.Position = 0;
var bodyText = await new StreamReader(context.Response.Body, Encoding.UTF8).ReadToEndAsync();
context.Response.Body.Position = 0;
var statusCode = context.Response.StatusCode;
var logLevel = statusCode >= 400 ? LogLevel.Error : LogLevel.Debug;
_logger.Log(logLevel,
"MCP Response | StatusCode: {StatusCode} | CorrelationId: {CorrelationId} | " +
"Duration: {Duration}ms\nBody: {Body}",
statusCode,
correlationId,
elapsedMs,
bodyText);
// Also log performance metrics
if (elapsedMs > 1000) // Log slow requests (> 1 second)
{
_logger.LogWarning(
"Slow MCP Request | CorrelationId: {CorrelationId} | Duration: {Duration}ms",
correlationId,
elapsedMs);
}
}
/// <summary>
/// Sanitizes sensitive data from request/response bodies
/// IMPORTANT: Prevents logging API keys, passwords, etc.
/// </summary>
private static string SanitizeSensitiveData(string body)
{
if (string.IsNullOrWhiteSpace(body))
return body;
// Replace sensitive fields with [REDACTED]
var sanitized = body;
sanitized = ApiKeyHashPattern.Replace(sanitized, "\"keyHash\":\"[REDACTED]\"");
sanitized = ApiKeyPattern.Replace(sanitized, "\"apiKey\":\"[REDACTED]\"");
sanitized = PasswordPattern.Replace(sanitized, "\"password\":\"[REDACTED]\"");
return sanitized;
}
}