Logging SSL certificate data to Azure Log Analytics

There's no built-in way to log SSL data with Azure Application Insights. While there's a way to get notified if the certificate on your Azure Website is expired, there's no general way to log this information on solutions outside of Azure. APIs for SSL analytics exist and that data could be sent in with the "Azure Monitor HTTP Data Collector API". From the Microsoft documentation:

You can use the HTTP Data Collector API to send log data to a Log Analytics workspace in Azure Monitor from any client that can call a REST API.

SSL Labs delivers an API for SSL analytics! SSL Labs API also requires a good way to handle network resilience. From the SSL Labs API documentation:

We may limit your usage of the API, by enforcing a limit on concurrent assessments, and the overall number of assessments performed in a time period. If that happens, we will respond with 429 (Too Many Requests) to API calls that wish to initiate new assessments. If you receive a 429 response, reduce the number of concurrent assessments and check that you're not submitting new assessments at a rate higher than allowed. If the server is overloaded (a condition that is not a result of the client's behaviour), the 529 status code will be used instead. This is not a situation we wish to be in. If you encounter it, take a break and come back later.

There are some pitfalls here. The API will return a 429 if you exceed your given rate limit. It's also recommended that you wait 15 to 30 minutes if SSL Labs API returns a 529 status code. We're going to need a way to handle these scenarios.

A good way to achieve good network resilience with .NET Core is to use a package called Polly. From the Polly documentation:

Polly is a .NET resilience and transient-fault-handling library that allows developers to express policies such as Retry, Circuit Breaker, Timeout, Bulkhead Isolation, and Fallback in a fluent and thread-safe manner.

I want to run this program as a scheduled job running every day in Azure Functions. Using Polly allows us to write clean HTTP client code without worrying too much about the handling of different statuses.

public class SslLabsAnalyzerClient
{
    private readonly HttpClient _client;

    public SslLabsAnalyzerClient(HttpClient client)
    {
        _client = client;
    }

    public async Task<SslLabsResult> AnalyzeAsync(string host)
    {
        var query = new Dictionary<string, string>
        {
            ["host"] = host,
            ["all"] = "done",
            ["fromCache"] = "on"
        };
        
        var response = await _client.GetAsync(QueryHelpers.AddQueryString("/api/v3/analyze", query));
        response.EnsureSuccessStatusCode();
        return await JsonSerializer.DeserializeAsync<SslLabsResult>(await response.Content.ReadAsStreamAsync());
    }
}

Very little code here! Picking out the important bits for our use from the SSL Labs API:

public class SslLabsResult {
    [JsonPropertyName("host")]
    public string Host { get; set; } 

    [JsonPropertyName("port")]
    public int Port { get; set; } 

    [JsonPropertyName("protocol")]
    public string Protocol { get; set; } 

    [JsonPropertyName("isPublic")]
    public bool IsPublic { get; set; } 

    [JsonPropertyName("status")]
    [JsonConverter(typeof(JsonStringEnumConverter))]
    public Status Status { get; set; }

    [JsonPropertyName("startTime")]
    public long StartTime { get; set; } 

    [JsonPropertyName("testTime")]
    public long TestTime { get; set; } 

    [JsonPropertyName("engineVersion")]
    public string EngineVersion { get; set; } 

    [JsonPropertyName("criteriaVersion")]
    public string CriteriaVersion { get; set; } 

    [JsonPropertyName("endpoints")]
    public List<Endpoint> Endpoints { get; set; }
    
    [JsonPropertyName("certs")]
    public List<Certificate> Certs { get; set; }
}

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Status
{
    [EnumMember(Value = "DNS")]
    Dns,
    [EnumMember(Value = "ERROR")]
    Error,
    [EnumMember(Value = "IN_PROGRESS")]
    InProgress,
    [EnumMember(Value = "READY")]
    Ready
}

public class Endpoint {
    [JsonPropertyName("grade")]
    public string Grade { get; set; }
}

public class Certificate
{
    [JsonPropertyName("notBefore")]
    public long IssuedOn { get; set; }
    
    [JsonPropertyName("notAfter")]
    public long ExpiresOn { get; set; }
}

Polly allows us to write the rules according to the SSL Labs rate limitations specified. We can also write custom rules based on the response.

  • Timeout set to 1 hour. These are long-polling jobs.
  • If the status is TooManyRequests, I want to wait for 1 minute and try again.
  • If the status is 529, I want to wait for 15 minutes and try again.
  • If the status is OK and if the analyzer is still in progress, I want to wait for 2 minutes and try again.

The policies do all the heavy lifting. Here's our startup code for our Azure Function.

[assembly: FunctionsStartup(typeof(Startup))]

namespace SslCollector.Functions
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            builder.Services.AddHttpClient<AzureMonitorClient>();
            builder.Services
                .AddHttpClient<SslLabsAnalyzerClient>(client =>
                {
                    client.BaseAddress = new Uri("https://api.ssllabs.com");
                    client.Timeout = TimeSpan.FromHours(1);
                })
                .AddPolicyHandler(Policy<HttpResponseMessage>
                    .HandleResult(msg =>
                    {
                        var result = JsonSerializer.Deserialize<SslLabsResult>(
                            msg.Content.ReadAsStringAsync()
                                    .GetAwaiter()
                                    .GetResult());
                        return result.Status == Status.InProgress;
                    })
                    .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromMinutes(2)))
                .AddPolicyHandler(Policy<HttpResponseMessage>
                    .HandleResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests)
                    .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromMinutes(1)))
                .AddPolicyHandler(Policy<HttpResponseMessage>
                    .HandleResult(msg => (int)msg.StatusCode == 529)
                    .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromMinutes(15)));
            builder.Services.AddLogging();
        }
    }
}

Now we have all our data from SSL Labs. In my solution, I want to send the certificate letter grade and expiration date. I want my event in Azure Log Analytics to look like this:

public class AzureMonitorSslEvent
{
    public string Host { get; set; }
    public DateTime IssuedOn { get; set; }
    public DateTime ExpiresOn { get; set; }
    public int DaysToExpiry { get; set; }
    public bool IsExpired { get; set; }
    public string Grade { get; set; } 
}

To send the results to Azure Log Analytics, we can follow the Azure guide for this. For this to work, you'll need the customer id and a shared key. I got all this information with ease using the az cli for monitor, az monitor log-analytics workspace. I'll put the code for our sender class here.

public class AzureMonitorClient
{
    private readonly HttpClient _client;
    
    public AzureMonitorClient(HttpClient client)
    {
        _client = client;
    }

    public async Task<bool> SendEventAsync(IEnumerable<AzureMonitorSslEvent> payload)
    {
        const string customerId = "******";
        const string sharedKey = "******";
        
        var json = JsonSerializer.Serialize(payload);
        var date = DateTime.UtcNow.ToString("r");
        var jsonBytes = Encoding.UTF8.GetBytes(json);
        var stringToHash = $"POST\n{jsonBytes.Length}\napplication/json\nx-ms-date:{date}\n/api/logs";
        var hashedString = BuildSignature(stringToHash, sharedKey);
        var signature = "SharedKey " + customerId + ":" + hashedString;
        
        _client.DefaultRequestHeaders.Add("Accept", "application/json");
        _client.DefaultRequestHeaders.Add("Log-Type", "SslEvent");
        _client.DefaultRequestHeaders.Add("Authorization", signature);
        _client.DefaultRequestHeaders.Add("x-ms-date", date);
        
        var httpContent = new StringContent(json, Encoding.UTF8);
        httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
        
        var response = await _client.PostAsync(
            new Uri($"https://{customerId}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01"),
            httpContent);

        return response.IsSuccessStatusCode;
    }

    private static string BuildSignature(string message, string secret)
    {
        var encoding = new ASCIIEncoding();
        var keyByte = Convert.FromBase64String(secret);
        var messageBytes = encoding.GetBytes(message);
        using var hmacsha256 = new HMACSHA256(keyByte);
        var hash = hmacsha256.ComputeHash(messageBytes);
        return Convert.ToBase64String(hash);
    }
}

Sewing it all together in our timer method.

public class SendSslInformation
{
    private readonly AzureMonitorClient _azureMonitorClient;
    private readonly SslLabsAnalyzerClient _sslLabsAnalyzerClient;
    private readonly ILogger<SendSslInformation> _logger;

    public SendSslInformation(
        AzureMonitorClient azureMonitorClient,
        SslLabsAnalyzerClient sslLabsAnalyzerClient,
        ILogger<SendSslInformation> logger)
    {
        _azureMonitorClient = azureMonitorClient;
        _sslLabsAnalyzerClient = sslLabsAnalyzerClient;
        _logger = logger;
    }

    [FunctionName("SendSslInformation")]
    public async Task RunAsync([TimerTrigger("0 0 9 * * *")] TimerInfo myTimer, ILogger log)
    {
        var customers = new[]
        {
            "www.novacare.no",
            "blog.novacare.no",
            "another.domain.com"
        };

        var results = new List<SslLabsResult>();
        foreach (var host in customers)
        {
            try
            {
                results.Add(await _sslLabsAnalyzerClient.AnalyzeAsync(host));
            }
            catch (Exception e)
            {
                _logger.LogError(e,$"\"{host}\" failed.");
            }
        }
        
        var sendList = new List<AzureMonitorSslEvent>();
        foreach (var sslLabsResult in results)
        {
            try {
                var cert = sslLabsResult.Certs.FirstOrDefault();
                var endpoint = sslLabsResult.Endpoints.FirstOrDefault();
                if (cert == null || endpoint == null) continue;
                var expiresOn = new DateTime(DateTimeOffset.FromUnixTimeMilliseconds(cert.ExpiresOn).Ticks);
                sendList.Add(new AzureMonitorSslEvent
                {
                    IsExpired = DateTime.UtcNow > expiresOn,
                    DaysToExpiry = (expiresOn - DateTime.UtcNow).Days,
                    ExpiresOn = expiresOn,
                    IssuedOn = new DateTime(DateTimeOffset.FromUnixTimeMilliseconds(cert.IssuedOn).Ticks),
                    Grade = endpoint.Grade,
                    Host = sslLabsResult.Host
                });
            } catch (Exception){
                _logger.LogError($"\"{sslLabsResult?.Host}\" failed. All properties: {JsonSerializer.Serialize(sslLabsResult)}");
            }
            
        }

        await _azureMonitorClient.SendEventAsync(sendList);
    }
}

After a successful run, we can search for our events in our Log Analytics Workspace. We can now do whatever we want with this data. For example, creating alerts or show the events in dashboards.

Screen-Capture_select-area_202101201335095