diff --git a/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/HttpLoggingServiceExtensions.cs b/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/HttpLoggingServiceExtensions.cs index bfa5f65e800..bca1a793fde 100644 --- a/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/HttpLoggingServiceExtensions.cs +++ b/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/HttpLoggingServiceExtensions.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpLogging; using Microsoft.AspNetCore.Telemetry.Http.Logging; using Microsoft.AspNetCore.Telemetry.Internal; using Microsoft.Extensions.Configuration; @@ -22,6 +23,17 @@ namespace Microsoft.AspNetCore.Telemetry; /// public static class HttpLoggingServiceExtensions { + public static IServiceCollection AddHttpLoggingRedaction(IServiceCollection services, Action configureRedaction) + { + _ = Throw.IfNull(services); + _ = Throw.IfNull(configureRedaction); + + _ = services.Configure(configureRedaction) + .AddSingleton(); + + return services; + } + /// /// Adds components for incoming HTTP requests logging into . /// diff --git a/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/Internal/HeaderReader.cs b/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/Internal/HeaderReader.cs index f6c0fca7568..df46b845a31 100644 --- a/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/Internal/HeaderReader.cs +++ b/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/Internal/HeaderReader.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpLogging; using Microsoft.Extensions.Compliance.Classification; using Microsoft.Extensions.Compliance.Redaction; @@ -46,4 +47,22 @@ public void Read(IHeaderDictionary headers, List> l } } } + + public void Read(IHeaderDictionary headers, HttpLoggingContext logContext) + { + if (headers.Count == 0) + { + return; + } + + foreach (var header in _headers) + { + if (headers.TryGetValue(header.Key, out var headerValue)) + { + var provider = _redactorProvider.GetRedactor(header.Value); + var redacted = provider.Redact(headerValue.ToString()); + logContext.Add(header.Key, redacted); + } + } + } } diff --git a/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/Internal/HttpRedactionHandler.cs b/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/Internal/HttpRedactionHandler.cs new file mode 100644 index 00000000000..76447eeb782 --- /dev/null +++ b/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/Internal/HttpRedactionHandler.cs @@ -0,0 +1,203 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpLogging; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Telemetry.Internal; +using Microsoft.Extensions.Compliance.Classification; +using Microsoft.Extensions.Compliance.Redaction; +using Microsoft.Extensions.Http.Telemetry; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Telemetry.Internal; +using Microsoft.Extensions.Telemetry.Logging; + +namespace Microsoft.AspNetCore.Telemetry.Http.Logging; + +internal sealed class HttpRedactionHandler : IHttpLoggingInterceptor +{ + // These three fields are "internal" solely for testing purposes: + internal TimeProvider TimeProvider = TimeProvider.System; + + private readonly IncomingPathLoggingMode _requestPathLogMode; + private readonly HttpRouteParameterRedactionMode _parameterRedactionMode; + private readonly ILogger _logger; + private readonly IHttpRouteParser _httpRouteParser; + private readonly IHttpRouteFormatter _httpRouteFormatter; + private readonly IIncomingHttpRouteUtility _httpRouteUtility; + private readonly HeaderReader _requestHeadersReader; + private readonly HeaderReader _responseHeadersReader; + private readonly string[] _excludePathStartsWith; + private readonly IHttpLogEnricher[] _enrichers; + private readonly FrozenDictionary _parametersToRedactMap; + + public HttpRedactionHandler( + IOptions options, + ILogger logger, + IEnumerable httpLogEnrichers, + IHttpRouteParser httpRouteParser, + IHttpRouteFormatter httpRouteFormatter, + IRedactorProvider redactorProvider, + IIncomingHttpRouteUtility httpRouteUtility) + { + var optionsValue = options.Value; + _logger = logger; + _httpRouteParser = httpRouteParser; + _httpRouteFormatter = httpRouteFormatter; + _httpRouteUtility = httpRouteUtility; + + _parametersToRedactMap = optionsValue.RouteParameterDataClasses.ToFrozenDictionary(StringComparer.Ordinal, optimizeForReading: true); + + _requestPathLogMode = EnsureRequestPathLoggingModeIsValid(optionsValue.RequestPathLoggingMode); + _parameterRedactionMode = optionsValue.RequestPathParameterRedactionMode; + + _requestHeadersReader = new(optionsValue.RequestHeadersDataClasses, redactorProvider); + _responseHeadersReader = new(optionsValue.ResponseHeadersDataClasses, redactorProvider); + + _excludePathStartsWith = optionsValue.ExcludePathStartsWith.ToArray(); + + _enrichers = httpLogEnrichers.ToArray(); + } + + public void OnRequest(HttpLoggingContext logContext) + { + var context = logContext.HttpContext; + var request = context.Request; + if (ShouldExcludePath(context.Request.Path)) + { + logContext.LoggingFields = HttpLoggingFields.None; + } + + // Don't enrich if we're not going to log any part of the request + if ((HttpLoggingFields.Request & logContext.LoggingFields) == HttpLoggingFields.None) + { + return; + } + + // TODO: Should we put a state filed on logContext? + context.Items["RequestStartTimestamp"] = TimeProvider.GetTimestamp(); + + if (logContext.LoggingFields.HasFlag(HttpLoggingFields.RequestPath)) + { + string path = TelemetryConstants.Unknown; + + if (_parameterRedactionMode != HttpRouteParameterRedactionMode.None) + { + var endpoint = context.GetEndpoint() as RouteEndpoint; + + if (endpoint?.RoutePattern.RawText != null) + { + var httpRoute = endpoint.RoutePattern.RawText; + var paramsToRedact = _httpRouteUtility.GetSensitiveParameters(httpRoute, request, _parametersToRedactMap); + + var routeSegments = _httpRouteParser.ParseRoute(httpRoute); + + if (_requestPathLogMode == IncomingPathLoggingMode.Formatted) + { + path = _httpRouteFormatter.Format(in routeSegments, request.Path, _parameterRedactionMode, paramsToRedact); + } + else + { + // Case when logging mode is IncomingPathLoggingMode.Structured + path = httpRoute; + var routeParams = ArrayPool.Shared.Rent(routeSegments.ParameterCount); + + // Setting this value right away to be able to return it back to pool in a callee's "finally" block: + if (_httpRouteParser.TryExtractParameters(request.Path, in routeSegments, _parameterRedactionMode, paramsToRedact, ref routeParams)) + { + foreach (var param in routeParams) + { + logContext.Add(param.Name, param.Value); + } + } + } + } + } + else if (request.Path.HasValue) + { + path = request.Path.Value!; + } + + logContext.Add("path", path); + + // We've handled the path, turn off the default logging + logContext.LoggingFields &= ~HttpLoggingFields.RequestPath; + } + + if (logContext.LoggingFields.HasFlag(HttpLoggingFields.RequestHeaders)) + { + // TODO: HttpLoggingOptions.Request/ResponseHeaders are ignored which could be confusing. + // Do we try to reconcile that with LoggingRedactionOptions.RequestHeadersDataClasses? + _requestHeadersReader.Read(context.Request.Headers, logContext); + + // We've handled the request headers, turn off the default logging + logContext.LoggingFields &= ~HttpLoggingFields.RequestHeaders; + } + } + + public void OnResponse(HttpLoggingContext logContext) + { + // Don't enrich if we're not going to log any part of the response + if ((HttpLoggingFields.Response & logContext.LoggingFields) == HttpLoggingFields.None) + { + return; + } + + var context = logContext.HttpContext; + + if (logContext.LoggingFields.HasFlag(HttpLoggingFields.ResponseHeaders)) + { + _responseHeadersReader.Read(context.Response.Headers, logContext); + + // We've handled the response headers, turn off the default logging + logContext.LoggingFields &= ~HttpLoggingFields.ResponseHeaders; + } + + if (_enrichers.Length == 0) + { + var enrichmentBag = LogMethodHelper.GetHelper(); + foreach (var enricher in _enrichers) + { + enricher.Enrich(enrichmentBag, context.Request, context.Response); + } + + foreach (var (key, value) in enrichmentBag) + { + logContext.Add(key, value); + } + } + + // Catching duration at the end: + var startTime = (long)context.Items["RequestStartTimestamp"]!; + var duration = (long)TimeProvider.GetElapsedTime(startTime, TimeProvider.GetTimestamp()).TotalMilliseconds; + logContext.Add("duration", duration); + + // TODO: What about the exception case? + } + + private static IncomingPathLoggingMode EnsureRequestPathLoggingModeIsValid(IncomingPathLoggingMode mode) + => mode switch + { + IncomingPathLoggingMode.Structured or IncomingPathLoggingMode.Formatted => mode, + _ => throw new InvalidOperationException($"Unsupported value '{mode}' for enum type '{nameof(IncomingPathLoggingMode)}'"), + }; + + private bool ShouldExcludePath(string path) + { + foreach (var excludedPath in _excludePathStartsWith) + { + if (path.StartsWith(excludedPath, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/LoggingRedactionOptions.cs b/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/LoggingRedactionOptions.cs new file mode 100644 index 00000000000..762baa76b5f --- /dev/null +++ b/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Logging/LoggingRedactionOptions.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Compliance.Classification; +using Microsoft.Extensions.Http.Telemetry; + +namespace Microsoft.AspNetCore.Telemetry; + +/// +/// Top-level model for redacting incoming HTTP requests and their corresponding responses. +/// +public class LoggingRedactionOptions +{ + private const IncomingPathLoggingMode DefaultRequestPathLoggingMode = IncomingPathLoggingMode.Formatted; + private const HttpRouteParameterRedactionMode DefaultPathParameterRedactionMode = HttpRouteParameterRedactionMode.Strict; + + /// + /// Gets or sets a strategy how request path should be logged. + /// + /// + /// Make sure you add redactors to ensure that sensitive information doesn't find its way into your log records. + /// Default set to . + /// This option only applies when the + /// option is not set to . + /// + public IncomingPathLoggingMode RequestPathLoggingMode { get; set; } = DefaultRequestPathLoggingMode; + + /// + /// Gets or sets a value indicating how request path parameter should be redacted. + /// + /// + /// Default set to . + /// + [Experimental] + public HttpRouteParameterRedactionMode RequestPathParameterRedactionMode { get; set; } = DefaultPathParameterRedactionMode; + + /// + /// Gets or sets a map between HTTP path parameters and their data classification. + /// + /// + /// Default set to an empty dictionary. + /// If a parameter within a controller's action is not annotated with a data classification attribute and + /// it's not found in this map, it will be redacted as if it was . + /// If you don't want a parameter to be redacted, mark it as . + /// + [Required] + [SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Options pattern.")] + public IDictionary RouteParameterDataClasses { get; set; } = new Dictionary(); + + /// + /// Gets or sets a map between request headers to be logged and their data classification. + /// + /// + /// Default set to an empty dictionary. + /// That means that no request header will be logged by default. + /// + [Required] + [SuppressMessage("Usage", "CA2227:Collection properties should be read only", + Justification = "Options pattern.")] + public IDictionary RequestHeadersDataClasses { get; set; } = new Dictionary(); + + /// + /// Gets or sets a map between response headers to be logged and their data classification. + /// + /// + /// Default set to an empty dictionary. + /// That means that no response header will be logged by default. + /// + [Required] + [SuppressMessage("Usage", "CA2227:Collection properties should be read only", + Justification = "Options pattern.")] + public IDictionary ResponseHeadersDataClasses { get; set; } = new Dictionary(); + + /// + /// Gets or sets the set of HTTP paths that should be excluded from logging. + /// + /// + /// Any path added to the set will not be logged. + /// Paths are case insensitive. + /// Default set to an empty . + /// + /// + /// A typical set of HTTP paths would be: + /// + /// ExcludePathStartsWith = new HashSet<string> + /// { + /// "/probe/live", + /// "/probe/ready" + /// }; + /// + /// + [Experimental] + [Required] + [SuppressMessage("Usage", "CA2227:Collection properties should be read only", + Justification = "Options pattern.")] + public ISet ExcludePathStartsWith { get; set; } = new HashSet(); +} diff --git a/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Microsoft.AspNetCore.Telemetry.Middleware.csproj b/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Microsoft.AspNetCore.Telemetry.Middleware.csproj index 42486a8523a..d3d639bfc79 100644 --- a/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Microsoft.AspNetCore.Telemetry.Middleware.csproj +++ b/src/Libraries/Microsoft.AspNetCore.Telemetry.Middleware/Microsoft.AspNetCore.Telemetry.Middleware.csproj @@ -7,7 +7,7 @@ - $(NetCoreTargetFrameworks) + net8.0 true true true diff --git a/test/Libraries/Microsoft.AspNetCore.Telemetry.Middleware.Tests/Microsoft.AspNetCore.Telemetry.Middleware.Tests.csproj b/test/Libraries/Microsoft.AspNetCore.Telemetry.Middleware.Tests/Microsoft.AspNetCore.Telemetry.Middleware.Tests.csproj index 4996f2f5183..a3cd5056393 100644 --- a/test/Libraries/Microsoft.AspNetCore.Telemetry.Middleware.Tests/Microsoft.AspNetCore.Telemetry.Middleware.Tests.csproj +++ b/test/Libraries/Microsoft.AspNetCore.Telemetry.Middleware.Tests/Microsoft.AspNetCore.Telemetry.Middleware.Tests.csproj @@ -5,7 +5,7 @@ - $(NetCoreTargetFrameworks) + net8.0 true