using System.Net.Http.Headers; using System.Text.RegularExpressions; namespace ZymonicServices; public class HttpLoggingHandler : DelegatingHandler { IZymonicLogger _logger; private const string Redacted = "[REDACTED]"; private static readonly HashSet SensitiveParamNames = new(StringComparer.OrdinalIgnoreCase) { "password", "passwd", "passphrase", "secret", "id_token", "client_secret", "api_key", "apikey", "auth", "authorization" }; private static readonly HashSet SensitiveHeaderNames = new(StringComparer.OrdinalIgnoreCase) { "Authorization", "Proxy-Authorization", "X-API-Key", "X-Auth-Token" }; private static readonly Regex JsonSensitiveFieldRegex = new( "(\"(?password|passwd|passphrase|secret|id_token|client_secret|api_key|apikey|authorization)\"\\s*:\\s*\")(.*?)(\")", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex FormSensitiveFieldRegex = new( "((?:^|[?&;])(?password|passwd|passphrase|secret|token|id_token|client_secret|api_key|apikey|authorization)=)([^&;]*)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex BearerRegex = new( "(Bearer\\s+)[A-Za-z0-9\\-._~+/]+=*", RegexOptions.IgnoreCase | RegexOptions.Compiled); public HttpLoggingHandler(IZymonicLogger logger, HttpMessageHandler? innerHandler = null) : base(innerHandler ?? new HttpClientHandler() { UseCookies = false }) { _logger = logger; } async protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var req = request; var id = Guid.NewGuid().ToString(); _logger.LogDebug("[{MsgGuid}] {Method} {PathAndQuery} {Scheme}/{Version}", id, req.Method, SanitizePathAndQuery(req.RequestUri), req.RequestUri is not null ? req.RequestUri.Scheme : "", req.Version); _logger.LogDebug("[{MsgGUid}] Host: {Scheme}://{Host}", id, req.RequestUri is not null ? req.RequestUri.Scheme : "", req.RequestUri is not null ? req.RequestUri.Host : ""); foreach (var header in req.Headers) _logger.LogDebug("[{MsgGuid}] {Header}: {HeaderValue}", id, header.Key, SanitizeHeaderValue(header.Key, header.Value)); if (req.Content != null) { foreach (var header in req.Content.Headers) _logger.LogDebug("[{MsgGuid}] {Header}: {HeaderValue}", id, header.Key, SanitizeHeaderValue(header.Key, header.Value)); if (req.Content is StringContent || this.IsTextBasedContentType(req.Headers) || this.IsTextBasedContentType(req.Content.Headers)) { var result = await req.Content.ReadAsStringAsync(); _logger.LogDebug("[{MsgGuid}] {RequestContent}", id, SanitizeTextContent(string.Join("", result.Cast().Take(50000)))); } } var start = DateTime.Now; try { //using var listener = new TestEventListener((m) => { _logger.LogDebug($"[{MsgGuid}] - {m}"); }, TestEventListener.NetworkingEvents); var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); var end = DateTime.Now; _logger.LogDebug("[{MsgGuid}] Duration: {RequestDuration}", id, end - start); var resp = response; _logger.LogDebug("[{MsgGuid}] {Scheme}/{SchemeVersion} {StatusCode} {Reason}", id, req.RequestUri is not null ? req.RequestUri.Scheme.ToUpper() : "", resp.Version, resp.StatusCode, resp.ReasonPhrase is not null ? resp.ReasonPhrase : ""); foreach (var header in resp.Headers) _logger.LogDebug("[{MsgGuid}] {Header}: {HeaderValue}", id, header.Key, SanitizeHeaderValue(header.Key, header.Value)); if (resp.Content != null) { foreach (var header in resp.Content.Headers) _logger.LogDebug("[{MsgGuid}] {Header}: {HeaderValue}", id, header.Key, SanitizeHeaderValue(header.Key, header.Value)); if (resp.Content is StringContent || this.IsTextBasedContentType(resp.Headers) || this.IsTextBasedContentType(resp.Content.Headers)) { start = DateTime.Now; var result = await resp.Content.ReadAsStringAsync(); end = DateTime.Now; _logger.LogDebug("[{MsgGuid}] {ResponseContent}", id, SanitizeTextContent(string.Join("", result.Cast().Take(50000)))); _logger.LogDebug("[{MsgGuid}] Duration: {ReadDuration}", id, end - start); } } return response; } catch (Exception e) { var end = DateTime.Now; _logger.LogDebug("[{MsgGuid}] Duration: {Duration}", id, end - start); _logger.LogError("[{MsgGuid}] Exception occurred: {Exception}", id, e); throw; } } readonly string[] types = new[] { "html", "text", "xml", "json", "txt", "x-www-form-urlencoded" }; private static string SanitizePathAndQuery(Uri? uri) { if (uri is null) { return ""; } if (string.IsNullOrEmpty(uri.Query)) { return uri.AbsolutePath; } string queryWithoutQuestion = uri.Query.StartsWith("?") ? uri.Query[1..] : uri.Query; string sanitizedQuery = SanitizeDelimitedKeyValueText(queryWithoutQuestion); return $"{uri.AbsolutePath}?{sanitizedQuery}"; } private static string SanitizeHeaderValue(string headerName, IEnumerable values) { string joined = string.Join(", ", values); if (SensitiveHeaderNames.Contains(headerName)) { return Redacted; } return SanitizeTextContent(joined); } private static string SanitizeTextContent(string text) { if (string.IsNullOrEmpty(text)) { return text; } string redacted = JsonSensitiveFieldRegex.Replace(text, m => { return m.Groups[1].Value + Redacted + m.Groups[4].Value; }); redacted = FormSensitiveFieldRegex.Replace(redacted, m => { return m.Groups[1].Value + Redacted; }); redacted = BearerRegex.Replace(redacted, m => { return m.Groups[1].Value + Redacted; }); return redacted; } private static string SanitizeDelimitedKeyValueText(string query) { if (string.IsNullOrWhiteSpace(query)) { return query; } string[] parts = query.Split('&'); for (int i = 0; i < parts.Length; i++) { string part = parts[i]; if (string.IsNullOrEmpty(part)) { continue; } int equalsIndex = part.IndexOf('='); if (equalsIndex <= 0) { continue; } string key = part[..equalsIndex]; string decodedKey = Uri.UnescapeDataString(key); if (SensitiveParamNames.Contains(decodedKey)) { parts[i] = $"{key}={Redacted}"; } } return string.Join("&", parts); } bool IsTextBasedContentType(HttpHeaders headers) { IEnumerable? values; if (!headers.TryGetValues("Content-Type", out values)) return false; var header = string.Join(" ", values).ToLowerInvariant(); return types.Any(t => header.Contains(t)); } }