Hide sensitive data in Azure Application Insights logs of our ASP.NET (Core) APIs

Azure Application Insights is a wonderful tool to log data and errors in your (not only) .NET applications.

But the Microsoft SDKs don't solve an issue I faced in one of the project I worked on: would it be possible to hide sensitive data from logs? Nobody should be able to read user sensitive data, especially from logs.

How we used to log APIs payloads

We have various log sources in our distributed system and one of them is the APIM (Azure Api Management): with a simple checkbox you can log APIs requests and responses bodies. But since it is a simple checkbox, you can't do anything more than that.
So, we moved our logging system out of it.
APIM log checkbox

Where we log APIs payloads now

The easiest way to do log in front of all the APIs in an ASP.NET Core project, is to add a FilterAttribute class to the ASP.NET action invocation pipeline:

// Startup.cs
protected override void ConfigureMvc(MvcOptions options)
{    
    options.Filters.Add<ApplicationInsightsActionFilterAttribute>();    
}

// ApplicationInsightsActionFilterAttribute.cs
public class ApplicationInsightsActionFilterAttribute : ActionFilterAttribute
{
    internal const string REQUEST_TELEMETRY_KEY = "Request-Body";
    internal const string RESPONSE_TELEMETRY_KEY = "Response-Body";

    private readonly ILogger<ApplicationInsightsActionFilterAttribute> _logger;

    public ApplicationInsightsActionFilterAttribute(
        ILogger<ApplicationInsightsActionFilterAttribute> logger)
    {
        _logger = logger;
    }

    public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        if (context.HttpContext is null)
        {
            await next();
            return;
        }

        try
        {
            string fromBodyParameter = context.ActionDescriptor.Parameters
                .Where(item => item.BindingInfo.BindingSource == BindingSource.Body)
                .FirstOrDefault()
                ?.Name.ToUpperInvariant();

            if (string.IsNullOrWhiteSpace(fromBodyParameter))
                return;

            KeyValuePair<string, object> arguments = context.ActionArguments
                .First(x => x.Key.ToUpperInvariant() == fromBodyParameter);

            string argument = SerializeDataUsingObfuscator(arguments.Value);

            RequestTelemetry request = context.HttpContext.Features.Get<RequestTelemetry>();
            request.Properties.Add(REQUEST_TELEMETRY_KEY, argument);
        }
        catch (Exception ex)
        {
            _logger.LogCritical(ex, "Error executing the response logger task");
        }
        finally
        {
            await next();
        }
    }

    public override async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
    {
        if (context?.HttpContext is null)
        {
            await next();
            return;
        }

        try
        {
            if (context.Result is ObjectResult result &&
                result.Value != null)
            {
                string serializedObj = SerializeDataUsingObfuscator(result.Value);
                RequestTelemetry request = context.HttpContext.Features.Get<RequestTelemetry>();
                request.Properties.Add(RESPONSE_TELEMETRY_KEY, serializedObj);
            };
        }
        catch (Exception ex)
        {
            _logger.LogCritical(ex, "Error executing the response logger task");
        }
        finally
        {
            await next();
        }
    }

    private static string SerializeDataUsingObfuscator(object value) =>
        JsonConvert.SerializeObject(value,
            new JsonSerializerSettings
            {
                ContractResolver = new ObfuscatorContractResolver()
            });
}

The code above is defining two methods: OnActionExecutionAsync and OnResultExecutionAsync. The first one is fired before the incoming request triggers the controller action; the latter is fired when the controller action returns. Let's focus on OnActionExecutionAsync for a moment: firstly it gets the controller action parameters that have a body, if any - you can change that code to get the query strings, paths, or whatever you'd like - and then calls the method SerializeDataUsingObfuscator passing the CLR object that represents the request body. To better explain this concept, consider the following API:

public async Task<ActionResult> MyWonderfulApi([FromBody] RequestDto dto)
{
    // code
    return Ok();
}

The object consumed by the SerializeDataUsingObfuscator method is of type RequestDto and it contains the incoming data.

As you can see, we are using a custom JSON.NET ContractResolver implementation, called ObfuscatorContractResolver. This class serializes the request body following our obfuscation rules - that we are going to define in a minute.

The Sensitive attribute

In order to define which properties must be obfuscated in ApplicationInsights logs, we defined a custom attribute named Sensitive. Moreover, this attribute allows some customization to obfuscate properties using different patterns - ie. truncate the string, show the first/last N chars, etc. Here's the definition and an usage example:

// WonderfulDto.cs
public class WonderfulDto
{
    [Sensitive(ObfuscationType.MaskChar)]
    [JsonProperty("pin")]
    public string Pin { get; set; }
}

// SensitiveAttribute.cs using the Factory pattern to separate responsibilities
[AttributeUsage(
    AttributeTargets.Property
    , AllowMultiple = false)]
public class SensitiveAttribute : Attribute
{
    private SensitiveBase Sensitive { get; }
    public int? TruncateAfter { get; }

    public SensitiveAttribute(
        ObfuscationType obfuscationType,
        int? truncateAfter)
    {
        TruncateAfter = truncateAfter;
        Sensitive = GetSensitive(obfuscationType);
    }

    public SensitiveAttribute(
        ObfuscationType obfuscationType)
    {
        TruncateAfter = null;
        Sensitive = GetSensitive(obfuscationType);
    }

    private SensitiveBase GetSensitive(ObfuscationType obfuscationType)
    {
        return obfuscationType switch
        {
            ObfuscationType.HeadVisible => new SensitiveHeadVisible(TruncateAfter),
            ObfuscationType.TailVisible => new SensitiveTailVisible(),
            ObfuscationType.MaskChar => new SensitiveMaskChar(TruncateAfter),
            _ => throw new NotImplementedException($"None type {nameof(obfuscationType)}: {obfuscationType}")
        };
    }

    internal string Obfuscate(string strValue)
    {
        return Sensitive.Obfuscate(strValue);
    }

    internal string Obfuscate(Guid guid) => Sensitive.Obfuscate(guid);

    internal string Obfuscate(DateTime dateTime) => Sensitive.Obfuscate(dateTime);

    internal string Obfuscate(Enum @enum) => Sensitive.Obfuscate(@enum);

    internal virtual string ObfuscateDefault(object value) => Sensitive.ObfuscateDefault(value);
}

Now that obfuscation rules are defined, I can show you how to use them in ObfuscatorContractResolver:

// ObfuscatorContractResolver.cs

public class ObfuscatorContractResolver : DefaultContractResolver
{
    protected override IList<JsonProperty> CreateProperties(Type type, MemberSerialization memberSerialization)
    {
        IList<JsonProperty> baseProperties = base.CreateProperties(type, memberSerialization);

        var sensitiveData = new Dictionary<string, SensitiveAttribute>();

        foreach (PropertyInfo p in type.GetProperties())
        {
            var customAttributes = p.GetCustomAttributes(false);

            var jsonPropertyAttribute = customAttributes
                .OfType<JsonPropertyAttribute>()
                .FirstOrDefault();

            if (jsonPropertyAttribute is null)
                continue;

            var sensitiveAttribute = customAttributes
                .OfType<SensitiveAttribute>()
                .FirstOrDefault();

            if (sensitiveAttribute is null)
                continue;

            var propertyName = jsonPropertyAttribute.PropertyName.ToUpperInvariant();

            sensitiveData.Add(propertyName, sensitiveAttribute);
        }

        if (!sensitiveData.Any())
            return baseProperties;

        var processedProperties = new List<JsonProperty>();

        foreach (JsonProperty baseProperty in baseProperties)
        {
            if (sensitiveData.TryGetValue(baseProperty.PropertyName.ToUpperInvariant(), out SensitiveAttribute sensitiveAttribute))
            {
                baseProperty.PropertyType = typeof(string);
                baseProperty.ValueProvider = new ObfuscatorValueProvider(baseProperty.ValueProvider, sensitiveAttribute);
            }

            processedProperties.Add(baseProperty);
        }

        return processedProperties;
    }
}

// ObfuscatorValueProvider.cs
    internal class ObfuscatorValueProvider : IValueProvider
{
    private readonly IValueProvider _valueProvider;
    private readonly SensitiveAttribute _sensitiveAttribute;

    public ObfuscatorValueProvider(
        IValueProvider valueProvider,
        SensitiveAttribute sensitiveAttribute)
    {
        _valueProvider = valueProvider;
        _sensitiveAttribute = sensitiveAttribute;
    }

    public object GetValue(object target)
    {
        var originalValue = _valueProvider.GetValue(target);

        var result = originalValue switch
        {
            null => null,
            string strValue => _sensitiveAttribute.Obfuscate(strValue),
            Guid guid => _sensitiveAttribute.Obfuscate(guid),
            Enum @enum => _sensitiveAttribute.Obfuscate(@enum),
            DateTime dateTime => _sensitiveAttribute.Obfuscate(dateTime),
            _ => _sensitiveAttribute.ObfuscateDefault(originalValue),
        };

        return result;
    }

    public void SetValue(object target, object value)
    {
        // we don't care
    }
}

And that's it: based on the Sensitive attribute definition over DTOs properties, JSON.NET understands if and how a given property should be serialized. The result will be sent to ApplicationInsights.

And what about my API response body?

The solution is pretty straightforward. The code above shows the OnResultExecutionAsync implementation: if code runs smooth and no exception is thrown, the response body serialized using the obfuscation rules is attached to the RequestTelemetry object.

Exception handling

If an exception occurs, you will realize that the OnResultExecutionAsync will not be fired. This happens because the flow runs out the normal ASP.NET action invocation pipeline.
But adding the exception handling middleware is enough to handle this:

public void Configure(IApplicationBuilder app)
{
    app.UseExceptionHandler(async (context) =>
    {
        RequestTelemetry request = context.Features.Get<RequestTelemetry>();

        string serializedObj = JsonConvert.SerializeObject(error,
            new JsonSerializerSettings
            {
                ContractResolver = new ObfuscatorContractResolver(),
            });

        request.Properties.Add(ApplicationInsightsActionFilterAttribute.RESPONSE_TELEMETRY_KEY, serializedObj);
    }));
}

Here you can see references to SensitiveHeadVisible, SensitiveMaskChar and SensitiveTailVisible classes that implement some code to apply different obfuscation rules - such as hiding the value with a specific char, showing the string tail, and so on. You are free to create your preferred ones.

Performance

After the implementation described in this post, we spent some time doing some benchmark to understand the performance impact over our APIs. So we searched the APIs with the biggest incoming and outgoing DTOs and then we stressed these APIs running 1000 requests each through Postman. We suddenly realized that we were going to lose 60-90ms for each request on each APIs! This performance drop was caused by the string serialization performed by JSON.NET, because the rest of the code needed a couple of milliseconds to run. Our code is fine then, how can we cut down the serialization time then?
After some research, we found a simple way to minimize the performance impact, keeping our APIs safe. Basically, we understood that we didn't need to wait that the serialization process to be finished before invoking the invocation pipeline!

Coming back at ApplicationInsightsActionFilterAttribute class, we did the following changes:

// OnActionExecutionAsync
var loggerTask = Task.Run(() =>
{
    string fromBodyParameter = context.ActionDescriptor.Parameters

    // same code as before
});

await next();

try
{
    if (loggerTask != null)
    {
        await loggerTask;
    }
}
catch (Exception ex)
{
    _logger.LogCritical(ex, "Error executing the response logger task");
}

// OnResultExecutionAsync
var loggerTask = Task.Run(() =>
{
    if (context.Result is ObjectResult result &&

    // same code as before
});

await next();

try
{
    if (loggerTask != null)
    {
        await loggerTask;
    }
}
catch (Exception ex)
{
    _logger.LogCritical(ex, "Error executing the response logger task");
}

This implementation doesn't wait for the serialization to be completed, but instead it runs a task that will be awaited AFTER the invocation pipeline is completed. Most of the times, the loggerTask is already completed when awaited, so the impact is almost nil.

Conclusion

We saw how to hide sensitive information from Application Insights logs, keeping our data (and users) safe.
Since all the logic is wrapped in a custom ContractResolver, you can obfuscate properties everywhere in your code! For example, we used the same approach for the log generated by our HttpClient instances.

I hope this tutorial was helpful! ๐ŸŽ‰

16