diff --git a/src/Agent.Sdk/Util/SslUtil.cs b/src/Agent.Sdk/Util/SslUtil.cs index 5d6f2b1011..a74bdb8332 100644 --- a/src/Agent.Sdk/Util/SslUtil.cs +++ b/src/Agent.Sdk/Util/SslUtil.cs @@ -1,22 +1,24 @@ -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using System.Net.Http; using Agent.Sdk; +using System; using System.Collections.Generic; using System.Linq; -using System; +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; namespace Microsoft.VisualStudio.Services.Agent.Util { public sealed class SslUtil { - private ITraceWriter _trace { get; set; } - private bool _IgnoreCertificateErrors { get; set; } + private readonly ITraceWriter _trace; - public SslUtil(ITraceWriter trace, bool IgnoreCertificateErrors = false) + private readonly bool _ignoreCertificateErrors; + + public SslUtil(ITraceWriter trace, bool ignoreCertificateErrors = false) { this._trace = trace; - this._IgnoreCertificateErrors = IgnoreCertificateErrors; + this._ignoreCertificateErrors = ignoreCertificateErrors; } /// Implementation of custom callback function that logs SSL-related data from the web request to the agent's logs. @@ -24,38 +26,42 @@ public SslUtil(ITraceWriter trace, bool IgnoreCertificateErrors = false) public bool RequestStatusCustomValidation(HttpRequestMessage requestMessage, X509Certificate2 certificate, X509Chain chain, SslPolicyErrors sslErrors) { bool isRequestSuccessful = (sslErrors == SslPolicyErrors.None); - + if (!isRequestSuccessful) { LoggingRequestDiagnosticData(requestMessage, certificate, chain, sslErrors); } - if (this._IgnoreCertificateErrors) { - this._trace?.Info("Ignoring certificate errors."); + if (this._ignoreCertificateErrors) + { + this._trace?.Info("Ignoring certificate errors."); } - return this._IgnoreCertificateErrors || isRequestSuccessful; + return this._ignoreCertificateErrors || isRequestSuccessful; } /// Logs SSL related data to agent's logs private void LoggingRequestDiagnosticData(HttpRequestMessage requestMessage, X509Certificate2 certificate, X509Chain chain, SslPolicyErrors sslErrors) { - string diagInfo = "Diagnostic data for request:\n"; - if (this._trace != null) { - diagInfo += SslDiagnosticDataProvider.ResolveSslPolicyErrorsMessage(sslErrors); - diagInfo += SslDiagnosticDataProvider.GetRequestMessageData(requestMessage); - diagInfo += SslDiagnosticDataProvider.GetCertificateData(certificate); - diagInfo += SslDiagnosticDataProvider.GetChainData(chain); + var logBuilder = new SslDiagnosticsLogBuilder(); + logBuilder.AddSslPolicyErrorsMessages(sslErrors); + logBuilder.AddRequestMessageLog(requestMessage); + logBuilder.AddCertificateLog(certificate); + logBuilder.AddChainLog(chain); + + var formattedLog = logBuilder.BuildLog(); - this._trace?.Info(diagInfo); + this._trace?.Info($"Diagnostic data for request:{Environment.NewLine}{formattedLog}"); } } } - public static class SslDiagnosticDataProvider + internal sealed class SslDiagnosticsLogBuilder { + private readonly StringBuilder _logBuilder = new StringBuilder(); + /// A predefined list of headers to get from the request private static readonly string[] _requiredRequestHeaders = new[] { @@ -74,122 +80,99 @@ public static class SslDiagnosticDataProvider }; /// - /// Get diagnostic data about the HTTP request. + /// Add diagnostics data about the HTTP request. /// This method extracts common information about the request itself and the request's headers. /// To expand list of headers please update . - /// Diagnostic data as a formatted string - public static string GetRequestMessageData(HttpRequestMessage requestMessage) + public void AddRequestMessageLog(HttpRequestMessage requestMessage) { // Getting general information about request - string requestDiagInfoHeader = "HttpRequest"; - string diagInfo = string.Empty; - if (requestMessage is null) { - return $"{requestDiagInfoHeader} data is empty"; + _logBuilder.AppendLine($"HttpRequest data is empty"); + return; } - var requestDiagInfo = new List>(); var requestedUri = requestMessage?.RequestUri.ToString(); var methodType = requestMessage?.Method.ToString(); - requestDiagInfo.Add(new KeyValuePair("Requested URI", requestedUri)); - requestDiagInfo.Add(new KeyValuePair("Request method", methodType)); - - diagInfo = GetFormattedData(requestDiagInfoHeader, requestDiagInfo); + _logBuilder.AppendLine($"[HttpRequest]"); + _logBuilder.AppendLine($"Requested URI: {requestedUri}"); + _logBuilder.AppendLine($"Requested method: {methodType}"); // Getting informantion from headers var requestHeaders = requestMessage?.Headers; - if (requestHeaders is null) + if (requestHeaders is null || !requestHeaders.Any()) { - return diagInfo; + return; } - string headersDiagInfoHeader = "HttpRequestHeaders"; - - var headersDiagInfo = new List>(); + _logBuilder.AppendLine($"[HttpRequestHeaders]"); foreach (var headerKey in _requiredRequestHeaders) { IEnumerable headerValues; if (requestHeaders.TryGetValues(headerKey, out headerValues)) { - var headerValue = string.Join(", ", headerValues.ToArray()); - if (headerValue != null) - { - headersDiagInfo.Add(new KeyValuePair(headerKey, headerValue.ToString())); - } + _logBuilder.AppendLine($"{headerKey}: {string.Join(", ", headerValues.ToArray())}"); } } - - diagInfo += GetFormattedData(headersDiagInfoHeader, headersDiagInfo); - - return diagInfo; } /// - /// Get diagnostic data about the certificate. + /// Add diagnostics data about the certificate. /// This method extracts common information about the certificate. /// - /// Diagnostic data as a formatted string - public static string GetCertificateData(X509Certificate2 certificate) + public void AddCertificateLog(X509Certificate2 certificate) { - string diagInfoHeader = "Certificate"; var diagInfo = new List>(); if (certificate is null) { - return $"{diagInfoHeader} data is empty"; + _logBuilder.AppendLine($"Certificate data is empty"); + return; } - diagInfo.Add(new KeyValuePair("Effective date", certificate?.GetEffectiveDateString())); - diagInfo.Add(new KeyValuePair("Expiration date", certificate?.GetExpirationDateString())); - diagInfo.Add(new KeyValuePair("Issuer", certificate?.Issuer)); - diagInfo.Add(new KeyValuePair("Subject", certificate?.Subject)); - - return GetFormattedData(diagInfoHeader, diagInfo); + _logBuilder.AppendLine($"[Certificate]"); + AddCertificateData(certificate); } /// - /// Get diagnostic data about the chain. + /// Add diagnostics data about the chain. /// This method extracts common information about the chain. /// - /// Diagnostic data as a formatted string - public static string GetChainData(X509Chain chain) + public void AddChainLog(X509Chain chain) { - string diagInfoHeader = "ChainStatus"; - var diagInfo = new List>(); - - if (chain is null) + if (chain is null || chain.ChainElements is null) { - return $"{diagInfoHeader} data is empty"; + _logBuilder.AppendLine($"ChainElements data is empty"); + return; } - foreach (var status in chain.ChainStatus) + _logBuilder.AppendLine("[ChainElements]"); + foreach (var chainElement in chain.ChainElements) { - diagInfo.Add(new KeyValuePair("Status", status.Status.ToString())); - diagInfo.Add(new KeyValuePair("Status Information", status.StatusInformation)); + AddCertificateData(chainElement.Certificate); + foreach (var status in chainElement.ChainElementStatus) + { + _logBuilder.AppendLine($"Status: {status.Status}"); + _logBuilder.AppendLine($"Status Information: {status.StatusInformation}"); + } } - - return GetFormattedData(diagInfoHeader, diagInfo); } /// - /// Get list of SSL policy errors with descriptions. + /// Add list of SSL policy errors with descriptions. /// This method checks SSL policy errors and mapping them to user-friendly descriptions. /// To update SSL policy errors description please update . /// - /// Diagnostic data as a formatted string - public static string ResolveSslPolicyErrorsMessage(SslPolicyErrors sslErrors) + public void AddSslPolicyErrorsMessages(SslPolicyErrors sslErrors) { - string diagInfoHeader = $"SSL Policy Errors"; - var diagInfo = new List>(); + _logBuilder.AppendLine($"[SSL Policy Errors]"); if (sslErrors == SslPolicyErrors.None) { - diagInfo.Add(new KeyValuePair(sslErrors.ToString(), _sslPolicyErrorsMapping[sslErrors])); - return GetFormattedData(diagInfoHeader, diagInfo); + _logBuilder.AppendLine($"No SSL policy errors"); } // Since we can get several SSL policy errors we should check all of them @@ -197,33 +180,31 @@ public static string ResolveSslPolicyErrorsMessage(SslPolicyErrors sslErrors) { if ((sslErrors & errorCode) != 0) { - string errorValue = errorCode.ToString(); - string errorMessage = string.Empty; - - if (!_sslPolicyErrorsMapping.TryGetValue(errorCode, out errorMessage)) + if (!_sslPolicyErrorsMapping.ContainsKey(errorCode)) { - errorMessage = "Could not resolve related error message"; + _logBuilder.AppendLine($"{errorCode.ToString()}: Could not resolve related error message"); + } + else + { + _logBuilder.AppendLine($"{errorCode.ToString()}: {_sslPolicyErrorsMapping[errorCode]}"); } - - diagInfo.Add(new KeyValuePair(errorValue, errorMessage)); } } - - return GetFormattedData(diagInfoHeader, diagInfo); } - /// Get diagnostic data as formatted text - /// Formatted string - private static string GetFormattedData(string diagInfoHeader, List> diagInfo) + public string BuildLog() { - string formattedData = $"[{diagInfoHeader}]\n"; + return _logBuilder.ToString(); + } - foreach (var record in diagInfo) - { - formattedData += $"{record.Key}: {record.Value}\n"; - } - return formattedData; + private void AddCertificateData(X509Certificate2 certificate) + { + _logBuilder.AppendLine($"Effective date: {certificate?.GetEffectiveDateString()}"); + _logBuilder.AppendLine($"Expiration date: {certificate?.GetExpirationDateString()}"); + _logBuilder.AppendLine($"Issuer: {certificate?.Issuer}"); + _logBuilder.AppendLine($"Subject: {certificate?.Subject}"); + _logBuilder.AppendLine($"Thumbprint: {certificate?.Thumbprint}"); } } } diff --git a/src/Test/L0/Util/SslUtilL0.cs b/src/Test/L0/Util/SslUtilL0.cs new file mode 100644 index 0000000000..2f57ea7e7c --- /dev/null +++ b/src/Test/L0/Util/SslUtilL0.cs @@ -0,0 +1,193 @@ +using Microsoft.VisualStudio.Services.Agent.Util; +using System; +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Xunit; + +namespace Microsoft.VisualStudio.Services.Agent.Tests.Util +{ + public sealed class SslUtilL0 + { + [Fact] + public void AddSslPolicyErrorsMesssages_NoErrors_ShouldReturnCorrectLog() + { + // Arrange + var logBuilder = new SslDiagnosticsLogBuilder(); + + // Act + logBuilder.AddSslPolicyErrorsMessages(SslPolicyErrors.None); + var log = logBuilder.BuildLog(); + + // Assert + Assert.Contains("No SSL policy errors", log); + } + + [Fact] + public void AddSslPolicyErrorsMesssages_HasErrors_ShouldReturnCorrectLog() + { + // Arrange + var logBuilder = new SslDiagnosticsLogBuilder(); + + // Act + logBuilder.AddSslPolicyErrorsMessages(SslPolicyErrors.RemoteCertificateNameMismatch); + logBuilder.AddSslPolicyErrorsMessages(SslPolicyErrors.RemoteCertificateChainErrors); + var log = logBuilder.BuildLog(); + + // Assert + Assert.Contains("ChainStatus has returned a non empty array", log); + Assert.Contains("Certificate name mismatch", log); + } + + [Fact] + public void AddRequestMessageLog_RequestMessageIsNull_ShouldReturnCorrectLog() + { + // Arrange + HttpRequestMessage requestMessage = null; + var logBuilder = new SslDiagnosticsLogBuilder(); + + // Act + logBuilder.AddRequestMessageLog(requestMessage); + var log = logBuilder.BuildLog(); + + // Assert + Assert.Equal($"HttpRequest data is empty{Environment.NewLine}", log); + } + + [Fact] + public void AddRequestMessageLog_RequestMessageIsNotNull_ShouldReturnCorrectLog() + { + // Arrange + var requestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost"); + var logBuilder = new SslDiagnosticsLogBuilder(); + var log = string.Empty; + + // Act + using (requestMessage) + { + logBuilder.AddRequestMessageLog(requestMessage); + log = logBuilder.BuildLog(); + } + + // Assert + Assert.Contains("Requested URI: http://localhost/", log); + Assert.Contains("Requested method: GET", log); + } + + [Fact] + public void AddRequestMessageLog_RequestMessageHasHeaders_ShouldReturnCorrectLog() + { + // Arrange + var requestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost"); + requestMessage.Headers.Add("X-TFS-Session", "value1"); + requestMessage.Headers.Add("X-VSS-E2EID", "value2_1"); + requestMessage.Headers.Add("X-VSS-E2EID", "value2_2"); + requestMessage.Headers.Add("User-Agent", "value3"); + requestMessage.Headers.Add("CustomHeader", "CustomValue"); + var logBuilder = new SslDiagnosticsLogBuilder(); + var log = string.Empty; + + // Act + using (requestMessage) + { + logBuilder.AddRequestMessageLog(requestMessage); + log = logBuilder.BuildLog(); + } + + // Assert + Assert.Contains("Requested URI: http://localhost/", log); + Assert.Contains("Requested method: GET", log); + Assert.Contains("X-TFS-Session: value1", log); + Assert.Contains("X-VSS-E2EID: value2_1, value2_2", log); + Assert.Contains("User-Agent: value3", log); + Assert.DoesNotContain("CustomHeader", log); + } + + [Fact] + public void AddCertificateLog_CertificateIsNull_ShouldReturnCorrectLog() + { + // Arrange + var logBuilder = new SslDiagnosticsLogBuilder(); + + // Act + logBuilder.AddCertificateLog(null); + var log = logBuilder.BuildLog(); + + // Assert + Assert.Equal($"Certificate data is empty{Environment.NewLine}", log); + } + + [Fact] + public void AddCertificateLog_CertificateIsNotNull_ShouldReturnCorrectLog() + { + // Arrange + var certificate = GenerateTestCertificate(); + var logBuilder = new SslDiagnosticsLogBuilder(); + var log = string.Empty; + + // Act + using (certificate) + { + logBuilder.AddCertificateLog(certificate); + log = logBuilder.BuildLog(); + } + + // Assert + Assert.Contains("Effective date: ", log); + Assert.Contains("Expiration date: ", log); + Assert.Contains("Subject: ", log); + Assert.Contains("Issuer: ", log); + Assert.Contains("Thumbprint: ", log); + } + + [Fact] + public void AddChainLog_ChainIsNull_ShouldReturnCorrectLog() + { + // Arrange + var logBuilder = new SslDiagnosticsLogBuilder(); + + // Act + logBuilder.AddChainLog(null); + var log = logBuilder.BuildLog(); + + // Assert + Assert.Equal($"ChainElements data is empty{Environment.NewLine}", log); + } + + [Fact] + public void AddChainLog_ChainIsNotNull_ShouldReturnCorrectLog() + { + // Arrange + var certificate = GenerateTestCertificate(); + using var chain = new X509Chain(); + chain.Build(certificate); + var logBuilder = new SslDiagnosticsLogBuilder(); + var log = string.Empty; + + // Act + using (certificate) + { + logBuilder.AddChainLog(chain); + log = logBuilder.BuildLog(); + } + + // Assert + Assert.Contains("[ChainElements]", log); + Assert.Contains("Effective date: ", log); + Assert.Contains("Expiration date: ", log); + Assert.Contains("Subject: ", log); + Assert.Contains("Issuer: ", log); + Assert.Contains("Thumbprint: ", log); + } + + private X509Certificate2 GenerateTestCertificate() + { + using var ecdsa = ECDsa.Create(); // generate asymmetric key pair + var req = new CertificateRequest("CN=TestSubject", ecdsa, HashAlgorithmName.SHA256); + var cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); + + return cert; + } + } +}