From eb4b4e5bbcb5d1276c5df859a8a93f6f6d9fd829 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Tue, 17 Sep 2024 20:23:02 +0200 Subject: [PATCH] Add support for checking minimal permissions for any API without APIC. Closes #861 --- dev-proxy-plugins/GraphUtils.cs | 182 ++-- .../MinimalPermissions/ApiOperation.cs | 11 + .../MinimalPermissions/ApiPermissionError.cs | 10 + .../MinimalPermissions/ApiPermissionsInfo.cs | 13 + ...issionError.cs => GraphPermissionError.cs} | 21 +- ...rmissionInfo.cs => GraphPermissionInfo.cs} | 25 +- .../GraphPermissionsType.cs | 10 + .../{RequestInfo.cs => GraphRequestInfo.cs} | 23 +- .../GraphResultsAndErrors.cs | 10 + .../MinimalPermissionsUtils.cs | 78 ++ .../MinimalPermissions/PermissionsType.cs | 7 - .../MinimalPermissions/ResultsAndErrors.cs | 7 - .../OpenApi/OpenApiDocumentExtensions.cs | 332 ++++-- .../Reporters/MarkdownReporter.cs | 998 +++++++++--------- .../Reporters/PlainTextReporter.cs | 890 ++++++++-------- .../ApiCenterMinimalPermissionsPlugin.cs | 638 +++++------ ... GraphMinimalPermissionsGuidancePlugin.cs} | 728 ++++++------- .../GraphMinimalPermissionsPlugin.cs | 203 ++++ .../RequestLogs/MinimalPermissionsPlugin.cs | 290 ++--- dev-proxy/presets/m365.json | 8 +- 20 files changed, 2397 insertions(+), 2087 deletions(-) create mode 100644 dev-proxy-plugins/MinimalPermissions/ApiOperation.cs create mode 100644 dev-proxy-plugins/MinimalPermissions/ApiPermissionError.cs create mode 100644 dev-proxy-plugins/MinimalPermissions/ApiPermissionsInfo.cs rename dev-proxy-plugins/MinimalPermissions/{PermissionError.cs => GraphPermissionError.cs} (66%) rename dev-proxy-plugins/MinimalPermissions/{PermissionInfo.cs => GraphPermissionInfo.cs} (77%) create mode 100644 dev-proxy-plugins/MinimalPermissions/GraphPermissionsType.cs rename dev-proxy-plugins/MinimalPermissions/{RequestInfo.cs => GraphRequestInfo.cs} (67%) create mode 100644 dev-proxy-plugins/MinimalPermissions/GraphResultsAndErrors.cs create mode 100644 dev-proxy-plugins/MinimalPermissions/MinimalPermissionsUtils.cs delete mode 100644 dev-proxy-plugins/MinimalPermissions/PermissionsType.cs delete mode 100644 dev-proxy-plugins/MinimalPermissions/ResultsAndErrors.cs rename dev-proxy-plugins/RequestLogs/{MinimalPermissionsGuidancePlugin.cs => GraphMinimalPermissionsGuidancePlugin.cs} (81%) create mode 100644 dev-proxy-plugins/RequestLogs/GraphMinimalPermissionsPlugin.cs diff --git a/dev-proxy-plugins/GraphUtils.cs b/dev-proxy-plugins/GraphUtils.cs index 87db7238..2da7c431 100644 --- a/dev-proxy-plugins/GraphUtils.cs +++ b/dev-proxy-plugins/GraphUtils.cs @@ -1,92 +1,92 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Net.Http.Json; -using Microsoft.DevProxy.Plugins.MinimalPermissions; -using Microsoft.Extensions.Logging; -using Titanium.Web.Proxy.Http; - -namespace Microsoft.DevProxy.Plugins; - -public class GraphUtils -{ - // throttle requests per workload - public static string BuildThrottleKey(Request r) => BuildThrottleKey(r.RequestUri); - - public static string BuildThrottleKey(Uri uri) - { - if (uri.Segments.Length < 3) - { - return uri.Host; - } - - // first segment is / - // second segment is Graph version (v1.0, beta) - // third segment is the workload (users, groups, etc.) - // segment can end with / if there are other segments following - var workload = uri.Segments[2].Trim('/'); - - // TODO: handle 'me' which is a proxy to other resources - - return workload; - } - - internal static string GetScopeTypeString(PermissionsType type) - { - return type switch - { - PermissionsType.Application => "Application", - PermissionsType.Delegated => "DelegatedWork", - _ => throw new InvalidOperationException($"Unknown scope type: {type}") - }; - } - - internal static async Task> UpdateUserScopesAsync(IEnumerable minimalScopes, IEnumerable<(string method, string url)> endpoints, PermissionsType permissionsType, ILogger logger) - { - var userEndpoints = endpoints.Where(e => e.url.Contains("/users/{", StringComparison.OrdinalIgnoreCase)); - if (!userEndpoints.Any()) - { - return minimalScopes; - } - - var newMinimalScopes = new HashSet(minimalScopes); - - var url = $"https://graphexplorerapi.azurewebsites.net/permissions?scopeType={GetScopeTypeString(permissionsType)}"; - using var httpClient = new HttpClient(); - var urls = userEndpoints.Select(e => { - logger.LogDebug("Getting permissions for {method} {url}", e.method, e.url); - return $"{url}&requesturl={e.url}&method={e.method}"; - }); - var tasks = urls.Select(u => { - logger.LogTrace("Calling {url}...", u); - return httpClient.GetFromJsonAsync(u); - }); - await Task.WhenAll(tasks); - - foreach (var task in tasks) - { - var response = await task; - if (response is null) - { - continue; - } - - // there's only one scope so it must be minimal already - if (response.Length < 2) - { - continue; - } - - if (newMinimalScopes.Contains(response[0].Value)) - { - logger.LogDebug("Replacing scope {old} with {new}", response[0].Value, response[1].Value); - newMinimalScopes.Remove(response[0].Value); - newMinimalScopes.Add(response[1].Value); - } - } - - logger.LogDebug("Updated minimal scopes. Original: {original}, New: {new}", string.Join(", ", minimalScopes), string.Join(", ", newMinimalScopes)); - - return newMinimalScopes; - } +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http.Json; +using Microsoft.DevProxy.Plugins.MinimalPermissions; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.Http; + +namespace Microsoft.DevProxy.Plugins; + +public class GraphUtils +{ + // throttle requests per workload + public static string BuildThrottleKey(Request r) => BuildThrottleKey(r.RequestUri); + + public static string BuildThrottleKey(Uri uri) + { + if (uri.Segments.Length < 3) + { + return uri.Host; + } + + // first segment is / + // second segment is Graph version (v1.0, beta) + // third segment is the workload (users, groups, etc.) + // segment can end with / if there are other segments following + var workload = uri.Segments[2].Trim('/'); + + // TODO: handle 'me' which is a proxy to other resources + + return workload; + } + + internal static string GetScopeTypeString(GraphPermissionsType type) + { + return type switch + { + GraphPermissionsType.Application => "Application", + GraphPermissionsType.Delegated => "DelegatedWork", + _ => throw new InvalidOperationException($"Unknown scope type: {type}") + }; + } + + internal static async Task> UpdateUserScopesAsync(IEnumerable minimalScopes, IEnumerable<(string method, string url)> endpoints, GraphPermissionsType permissionsType, ILogger logger) + { + var userEndpoints = endpoints.Where(e => e.url.Contains("/users/{", StringComparison.OrdinalIgnoreCase)); + if (!userEndpoints.Any()) + { + return minimalScopes; + } + + var newMinimalScopes = new HashSet(minimalScopes); + + var url = $"https://graphexplorerapi.azurewebsites.net/permissions?scopeType={GetScopeTypeString(permissionsType)}"; + using var httpClient = new HttpClient(); + var urls = userEndpoints.Select(e => { + logger.LogDebug("Getting permissions for {method} {url}", e.method, e.url); + return $"{url}&requesturl={e.url}&method={e.method}"; + }); + var tasks = urls.Select(u => { + logger.LogTrace("Calling {url}...", u); + return httpClient.GetFromJsonAsync(u); + }); + await Task.WhenAll(tasks); + + foreach (var task in tasks) + { + var response = await task; + if (response is null) + { + continue; + } + + // there's only one scope so it must be minimal already + if (response.Length < 2) + { + continue; + } + + if (newMinimalScopes.Contains(response[0].Value)) + { + logger.LogDebug("Replacing scope {old} with {new}", response[0].Value, response[1].Value); + newMinimalScopes.Remove(response[0].Value); + newMinimalScopes.Add(response[1].Value); + } + } + + logger.LogDebug("Updated minimal scopes. Original: {original}, New: {new}", string.Join(", ", minimalScopes), string.Join(", ", newMinimalScopes)); + + return newMinimalScopes; + } } \ No newline at end of file diff --git a/dev-proxy-plugins/MinimalPermissions/ApiOperation.cs b/dev-proxy-plugins/MinimalPermissions/ApiOperation.cs new file mode 100644 index 00000000..dfd56b6a --- /dev/null +++ b/dev-proxy-plugins/MinimalPermissions/ApiOperation.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DevProxy.Plugins.MinimalPermissions; + +public class ApiOperation +{ + public required string Method { get; init; } + public required string OriginalUrl { get; init; } + public required string TokenizedUrl { get; init; } +} \ No newline at end of file diff --git a/dev-proxy-plugins/MinimalPermissions/ApiPermissionError.cs b/dev-proxy-plugins/MinimalPermissions/ApiPermissionError.cs new file mode 100644 index 00000000..8a717771 --- /dev/null +++ b/dev-proxy-plugins/MinimalPermissions/ApiPermissionError.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DevProxy.Plugins.MinimalPermissions; + +public class ApiPermissionError +{ + public required string Request { get; init; } + public required string Error { get; init; } +} diff --git a/dev-proxy-plugins/MinimalPermissions/ApiPermissionsInfo.cs b/dev-proxy-plugins/MinimalPermissions/ApiPermissionsInfo.cs new file mode 100644 index 00000000..60893abd --- /dev/null +++ b/dev-proxy-plugins/MinimalPermissions/ApiPermissionsInfo.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DevProxy.Plugins.MinimalPermissions; + +public class ApiPermissionsInfo +{ + public required List TokenPermissions { get; init; } + public required List OperationsFromRequests { get; init; } + public required string[] MinimalScopes { get; init; } + public required string[] UnmatchedOperations { get; init; } + public required List Errors { get; init; } +} \ No newline at end of file diff --git a/dev-proxy-plugins/MinimalPermissions/PermissionError.cs b/dev-proxy-plugins/MinimalPermissions/GraphPermissionError.cs similarity index 66% rename from dev-proxy-plugins/MinimalPermissions/PermissionError.cs rename to dev-proxy-plugins/MinimalPermissions/GraphPermissionError.cs index 2f7fa408..595793ab 100644 --- a/dev-proxy-plugins/MinimalPermissions/PermissionError.cs +++ b/dev-proxy-plugins/MinimalPermissions/GraphPermissionError.cs @@ -1,10 +1,13 @@ -using System.Text.Json.Serialization; - -namespace Microsoft.DevProxy.Plugins.MinimalPermissions; - -internal class PermissionError -{ - [JsonPropertyName("requestUrl")] - public string Url { get; set; } = string.Empty; - public string Message { get; set; } = string.Empty; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.DevProxy.Plugins.MinimalPermissions; + +internal class GraphPermissionError +{ + [JsonPropertyName("requestUrl")] + public string Url { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; } \ No newline at end of file diff --git a/dev-proxy-plugins/MinimalPermissions/PermissionInfo.cs b/dev-proxy-plugins/MinimalPermissions/GraphPermissionInfo.cs similarity index 77% rename from dev-proxy-plugins/MinimalPermissions/PermissionInfo.cs rename to dev-proxy-plugins/MinimalPermissions/GraphPermissionInfo.cs index 37e28a38..2223853f 100644 --- a/dev-proxy-plugins/MinimalPermissions/PermissionInfo.cs +++ b/dev-proxy-plugins/MinimalPermissions/GraphPermissionInfo.cs @@ -1,12 +1,15 @@ -namespace Microsoft.DevProxy.Plugins.MinimalPermissions; - -internal class PermissionInfo -{ - public string Value { get; set; } = string.Empty; - public string ScopeType { get; set; } = string.Empty; - public string ConsentDisplayName { get; set; } = string.Empty; - public string ConsentDescription { get; set; } = string.Empty; - public bool IsAdmin { get; set; } - public bool IsLeastPrivilege { get; set; } - public bool IsHidden { get; set; } +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DevProxy.Plugins.MinimalPermissions; + +internal class GraphPermissionInfo +{ + public string Value { get; set; } = string.Empty; + public string ScopeType { get; set; } = string.Empty; + public string ConsentDisplayName { get; set; } = string.Empty; + public string ConsentDescription { get; set; } = string.Empty; + public bool IsAdmin { get; set; } + public bool IsLeastPrivilege { get; set; } + public bool IsHidden { get; set; } } \ No newline at end of file diff --git a/dev-proxy-plugins/MinimalPermissions/GraphPermissionsType.cs b/dev-proxy-plugins/MinimalPermissions/GraphPermissionsType.cs new file mode 100644 index 00000000..a8ad9772 --- /dev/null +++ b/dev-proxy-plugins/MinimalPermissions/GraphPermissionsType.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DevProxy.Plugins.MinimalPermissions; + +public enum GraphPermissionsType +{ + Application, + Delegated +} \ No newline at end of file diff --git a/dev-proxy-plugins/MinimalPermissions/RequestInfo.cs b/dev-proxy-plugins/MinimalPermissions/GraphRequestInfo.cs similarity index 67% rename from dev-proxy-plugins/MinimalPermissions/RequestInfo.cs rename to dev-proxy-plugins/MinimalPermissions/GraphRequestInfo.cs index 44cf9385..ee455e95 100644 --- a/dev-proxy-plugins/MinimalPermissions/RequestInfo.cs +++ b/dev-proxy-plugins/MinimalPermissions/GraphRequestInfo.cs @@ -1,11 +1,14 @@ - -using System.Text.Json.Serialization; - -namespace Microsoft.DevProxy.Plugins.MinimalPermissions; - -public class RequestInfo -{ - [JsonPropertyName("requestUrl")] - public string Url { get; set; } = string.Empty; - public string Method { get; set; } = string.Empty; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + + +using System.Text.Json.Serialization; + +namespace Microsoft.DevProxy.Plugins.MinimalPermissions; + +public class GraphRequestInfo +{ + [JsonPropertyName("requestUrl")] + public string Url { get; set; } = string.Empty; + public string Method { get; set; } = string.Empty; } \ No newline at end of file diff --git a/dev-proxy-plugins/MinimalPermissions/GraphResultsAndErrors.cs b/dev-proxy-plugins/MinimalPermissions/GraphResultsAndErrors.cs new file mode 100644 index 00000000..d43dac68 --- /dev/null +++ b/dev-proxy-plugins/MinimalPermissions/GraphResultsAndErrors.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DevProxy.Plugins.MinimalPermissions; + +internal class GraphResultsAndErrors +{ + public GraphPermissionInfo[]? Results { get; set; } + public GraphPermissionError[]? Errors { get; set; } +} \ No newline at end of file diff --git a/dev-proxy-plugins/MinimalPermissions/MinimalPermissionsUtils.cs b/dev-proxy-plugins/MinimalPermissions/MinimalPermissionsUtils.cs new file mode 100644 index 00000000..c76e5dc4 --- /dev/null +++ b/dev-proxy-plugins/MinimalPermissions/MinimalPermissionsUtils.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.IdentityModel.Tokens.Jwt; + +namespace Microsoft.DevProxy.Plugins.MinimalPermissions; + +public static class MinimalPermissionsUtils +{ + /// + /// Gets the scopes from the JWT token. + /// + /// The JWT token including the 'Bearer' prefix. + /// The scopes from the JWT token or empty array if no scopes found or error occurred. + public static string[] GetScopesFromToken(string? jwtToken, ILogger logger) + { + logger.LogDebug("Getting scopes from JWT token..."); + + if (string.IsNullOrEmpty(jwtToken)) + { + return []; + } + + try + { + var token = jwtToken.Split(' ')[1]; + var handler = new JwtSecurityTokenHandler(); + var jsonToken = handler.ReadToken(token) as JwtSecurityToken; + var scopes = jsonToken?.Claims + .Where(c => c.Type == "scp") + .Select(c => c.Value) + .ToArray() ?? []; + + logger.LogDebug("Scopes found in the token: {scopes}", string.Join(", ", scopes)); + return scopes; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to parse JWT token"); + return []; + } + } + + public static (string[] minimalScopes, string[] unmatchedOperations) GetMinimalScopes(string[] requests, Dictionary operationsAndScopes) + { + var unmatchedOperations = requests + .Where(o => !operationsAndScopes.Keys.Contains(o, StringComparer.OrdinalIgnoreCase)) + .ToArray(); + + var minimalScopesPerOperation = operationsAndScopes + .Where(o => requests.Contains(o.Key, StringComparer.OrdinalIgnoreCase)) + .Select(o => new KeyValuePair(o.Key, o.Value.First())) + .ToDictionary(); + + // for each minimal scope check if it overrules any other minimal scope + // (position > 0, because the minimal scope is always first). if it does, + // replace the minimal scope with the overruling scope + foreach (var scope in minimalScopesPerOperation.Values) + { + foreach (var minimalScope in minimalScopesPerOperation) + { + if (Array.IndexOf(operationsAndScopes[minimalScope.Key], scope) > 0) + { + minimalScopesPerOperation[minimalScope.Key] = scope; + } + } + } + + return ( + minimalScopesPerOperation + .Select(s => s.Value) + .Distinct() + .ToArray(), + unmatchedOperations + ); + } +} \ No newline at end of file diff --git a/dev-proxy-plugins/MinimalPermissions/PermissionsType.cs b/dev-proxy-plugins/MinimalPermissions/PermissionsType.cs deleted file mode 100644 index 9dad5e7f..00000000 --- a/dev-proxy-plugins/MinimalPermissions/PermissionsType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Microsoft.DevProxy.Plugins.MinimalPermissions; - -public enum PermissionsType -{ - Application, - Delegated -} \ No newline at end of file diff --git a/dev-proxy-plugins/MinimalPermissions/ResultsAndErrors.cs b/dev-proxy-plugins/MinimalPermissions/ResultsAndErrors.cs deleted file mode 100644 index dcdcb233..00000000 --- a/dev-proxy-plugins/MinimalPermissions/ResultsAndErrors.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Microsoft.DevProxy.Plugins.MinimalPermissions; - -internal class ResultsAndErrors -{ - public PermissionInfo[]? Results { get; set; } - public PermissionError[]? Errors { get; set; } -} \ No newline at end of file diff --git a/dev-proxy-plugins/OpenApi/OpenApiDocumentExtensions.cs b/dev-proxy-plugins/OpenApi/OpenApiDocumentExtensions.cs index 93d9a837..82d0a4de 100644 --- a/dev-proxy-plugins/OpenApi/OpenApiDocumentExtensions.cs +++ b/dev-proxy-plugins/OpenApi/OpenApiDocumentExtensions.cs @@ -1,117 +1,217 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.RegularExpressions; -using Microsoft.Extensions.Logging; - -#pragma warning disable IDE0130 -namespace Microsoft.OpenApi.Models; -#pragma warning restore IDE0130 - -public static class OpenApiDocumentExtensions -{ - public static KeyValuePair? FindMatchingPathItem(this OpenApiDocument openApiDocument, string requestUrl, ILogger logger) - { - foreach (var server in openApiDocument.Servers) - { - logger.LogDebug("Checking server URL {serverUrl}...", server.Url); - - if (!requestUrl.StartsWith(server.Url, StringComparison.OrdinalIgnoreCase)) - { - logger.LogDebug("Request URL {requestUrl} does not match server URL {serverUrl}", requestUrl, server.Url); - continue; - } - - var serverUrl = new Uri(server.Url); - var serverPath = serverUrl.AbsolutePath.TrimEnd('/'); - var requestUri = new Uri(requestUrl); - var urlPathFromRequest = requestUri.GetLeftPart(UriPartial.Path).Replace(server.Url.TrimEnd('/'), "", StringComparison.OrdinalIgnoreCase); - - foreach (var path in openApiDocument.Paths) - { - var urlPathFromSpec = path.Key; - logger.LogDebug("Checking path {urlPath}...", urlPathFromSpec); - - // check if path contains parameters. If it does, - // replace them with regex - if (urlPathFromSpec.Contains('{')) - { - logger.LogDebug("Path {urlPath} contains parameters and will be converted to Regex", urlPathFromSpec); - - // force replace all parameters with regex - // this is more robust than replacing parameters by name - // because it's possible to define parameters both on the path - // and operations and sometimes, parameters are defined only - // on the operation. This way, we cover all cases, and we don't - // care about the parameter anyway here - urlPathFromSpec = Regex.Replace(urlPathFromSpec, @"\{[^}]+\}", $"([^/]+)"); - - logger.LogDebug("Converted path to Regex: {urlPath}", urlPathFromSpec); - var regex = new Regex($"^{urlPathFromSpec}$"); - if (regex.IsMatch(urlPathFromRequest)) - { - logger.LogDebug("Regex matches {requestUrl}", urlPathFromRequest); - - return path; - } - - logger.LogDebug("Regex does not match {requestUrl}", urlPathFromRequest); - } - else - { - if (urlPathFromRequest.Equals(urlPathFromSpec, StringComparison.OrdinalIgnoreCase)) - { - logger.LogDebug("{requestUrl} matches {urlPath}", requestUrl, urlPathFromSpec); - return path; - } - - logger.LogDebug("{requestUrl} doesn't match {urlPath}", requestUrl, urlPathFromSpec); - } - } - } - - return null; - } - - public static string[] GetEffectiveScopes(this OpenApiOperation operation, OpenApiDocument openApiDocument, ILogger logger) - { - var oauth2Scheme = openApiDocument.GetOAuth2Schemes().FirstOrDefault(); - if (oauth2Scheme is null) - { - logger.LogDebug("No OAuth2 schemes found in OpenAPI document"); - return []; - } - - var globalScopes = Array.Empty(); - var globalOAuth2Requirement = openApiDocument.SecurityRequirements - .FirstOrDefault(req => req.ContainsKey(oauth2Scheme)); - if (globalOAuth2Requirement is not null) - { - globalScopes = [.. globalOAuth2Requirement[oauth2Scheme]]; - } - - if (operation.Security is null) - { - logger.LogDebug("No security requirements found in operation {operation}", operation.OperationId); - return globalScopes; - } - - var operationOAuth2Requirement = operation.Security - .Where(req => req.ContainsKey(oauth2Scheme)) - .SelectMany(req => req[oauth2Scheme]); - if (operationOAuth2Requirement is not null) - { - return operationOAuth2Requirement.ToArray(); - } - - return []; - } - - public static OpenApiSecurityScheme[] GetOAuth2Schemes(this OpenApiDocument openApiDocument) - { - return openApiDocument.Components.SecuritySchemes - .Where(s => s.Value.Type == SecuritySchemeType.OAuth2) - .Select(s => s.Value) - .ToArray(); - } +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.RegularExpressions; +using Microsoft.DevProxy.Abstractions; +using Microsoft.DevProxy.Plugins.MinimalPermissions; +using Microsoft.Extensions.Logging; + +#pragma warning disable IDE0130 +namespace Microsoft.OpenApi.Models; +#pragma warning restore IDE0130 + + +public static class OpenApiDocumentExtensions +{ + public static KeyValuePair? FindMatchingPathItem(this OpenApiDocument openApiDocument, string requestUrl, ILogger logger) + { + foreach (var server in openApiDocument.Servers) + { + logger.LogDebug("Checking server URL {serverUrl}...", server.Url); + + if (!requestUrl.StartsWith(server.Url, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("Request URL {requestUrl} does not match server URL {serverUrl}", requestUrl, server.Url); + continue; + } + + var serverUrl = new Uri(server.Url); + var serverPath = serverUrl.AbsolutePath.TrimEnd('/'); + var requestUri = new Uri(requestUrl); + var urlPathFromRequest = requestUri.GetLeftPart(UriPartial.Path).Replace(server.Url.TrimEnd('/'), "", StringComparison.OrdinalIgnoreCase); + + foreach (var path in openApiDocument.Paths) + { + var urlPathFromSpec = path.Key; + logger.LogDebug("Checking path {urlPath}...", urlPathFromSpec); + + // check if path contains parameters. If it does, + // replace them with regex + if (urlPathFromSpec.Contains('{')) + { + logger.LogDebug("Path {urlPath} contains parameters and will be converted to Regex", urlPathFromSpec); + + // force replace all parameters with regex + // this is more robust than replacing parameters by name + // because it's possible to define parameters both on the path + // and operations and sometimes, parameters are defined only + // on the operation. This way, we cover all cases, and we don't + // care about the parameter anyway here + urlPathFromSpec = Regex.Replace(urlPathFromSpec, @"\{[^}]+\}", $"([^/]+)"); + + logger.LogDebug("Converted path to Regex: {urlPath}", urlPathFromSpec); + var regex = new Regex($"^{urlPathFromSpec}$"); + if (regex.IsMatch(urlPathFromRequest)) + { + logger.LogDebug("Regex matches {requestUrl}", urlPathFromRequest); + + return path; + } + + logger.LogDebug("Regex does not match {requestUrl}", urlPathFromRequest); + } + else + { + if (urlPathFromRequest.Equals(urlPathFromSpec, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug("{requestUrl} matches {urlPath}", requestUrl, urlPathFromSpec); + return path; + } + + logger.LogDebug("{requestUrl} doesn't match {urlPath}", requestUrl, urlPathFromSpec); + } + } + } + + return null; + } + + public static string[] GetEffectiveScopes(this OpenApiOperation operation, OpenApiDocument openApiDocument, ILogger logger) + { + var oauth2Scheme = openApiDocument.GetOAuth2Schemes().FirstOrDefault(); + if (oauth2Scheme is null) + { + logger.LogDebug("No OAuth2 schemes found in OpenAPI document"); + return []; + } + + var globalScopes = Array.Empty(); + var globalOAuth2Requirement = openApiDocument.SecurityRequirements + .FirstOrDefault(req => req.ContainsKey(oauth2Scheme)); + if (globalOAuth2Requirement is not null) + { + globalScopes = [.. globalOAuth2Requirement[oauth2Scheme]]; + } + + if (operation.Security is null) + { + logger.LogDebug("No security requirements found in operation {operation}", operation.OperationId); + return globalScopes; + } + + var operationOAuth2Requirement = operation.Security + .Where(req => req.ContainsKey(oauth2Scheme)) + .SelectMany(req => req[oauth2Scheme]); + if (operationOAuth2Requirement is not null) + { + return operationOAuth2Requirement.ToArray(); + } + + return []; + } + + public static OpenApiSecurityScheme[] GetOAuth2Schemes(this OpenApiDocument openApiDocument) + { + return openApiDocument.Components.SecuritySchemes + .Where(s => s.Value.Type == SecuritySchemeType.OAuth2) + .Select(s => s.Value) + .ToArray(); + } + + public static ApiPermissionsInfo CheckMinimalPermissions(this OpenApiDocument openApiDocument, IEnumerable requests, ILogger logger) + { + logger.LogInformation("Checking minimal permissions for API {apiName}...", openApiDocument.Servers.First().Url); + + var tokenPermissions = new List(); + var operationsFromRequests = new List(); + var operationsAndScopes = new Dictionary(); + var errors = new List(); + + foreach (var request in requests) + { + // get scopes from the token + var methodAndUrl = request.MessageLines.First(); + var methodAndUrlChunks = methodAndUrl.Split(' '); + logger.LogDebug("Checking request {request}...", methodAndUrl); + var (method, url) = (methodAndUrlChunks[0].ToUpper(), methodAndUrlChunks[1]); + + var scopesFromTheToken = MinimalPermissionsUtils.GetScopesFromToken(request.Context?.Session.HttpClient.Request.Headers.First(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)).Value, logger); + if (scopesFromTheToken.Length != 0) + { + tokenPermissions.AddRange(scopesFromTheToken); + } + else + { + errors.Add(new() + { + Request = methodAndUrl, + Error = "No scopes found in the token" + }); + } + + // get allowed scopes for the operation + if (!Enum.TryParse(method, true, out var operationType)) + { + errors.Add(new() + { + Request = methodAndUrl, + Error = $"{method} is not a valid HTTP method" + }); + continue; + } + + var pathItem = openApiDocument.FindMatchingPathItem(url, logger); + if (pathItem is null) + { + errors.Add(new() + { + Request = methodAndUrl, + Error = "No matching path item found" + }); + continue; + } + + if (!pathItem.Value.Value.Operations.TryGetValue(operationType, out var operation)) + { + errors.Add(new() + { + Request = methodAndUrl, + Error = "No matching operation found" + }); + continue; + } + + var scopes = operation.GetEffectiveScopes(openApiDocument, logger); + if (scopes.Length != 0) + { + operationsAndScopes[$"{method} {pathItem.Value.Key}"] = scopes; + } + + operationsFromRequests.Add(new() + { + Method = operationType.ToString().ToUpper(), + OriginalUrl = url, + TokenizedUrl = pathItem.Value.Key + }); + } + + var (minimalScopes, unmatchedOperations) = MinimalPermissionsUtils.GetMinimalScopes( + operationsFromRequests + .Select(o => $"{o.Method} {o.TokenizedUrl}") + .Distinct() + .ToArray(), + operationsAndScopes + ); + + var permissionsInfo = new ApiPermissionsInfo + { + TokenPermissions = tokenPermissions, + OperationsFromRequests = operationsFromRequests, + MinimalScopes = minimalScopes, + UnmatchedOperations = unmatchedOperations, + Errors = errors + }; + + return permissionsInfo; + } } \ No newline at end of file diff --git a/dev-proxy-plugins/Reporters/MarkdownReporter.cs b/dev-proxy-plugins/Reporters/MarkdownReporter.cs index 41da9282..5dac6ecd 100644 --- a/dev-proxy-plugins/Reporters/MarkdownReporter.cs +++ b/dev-proxy-plugins/Reporters/MarkdownReporter.cs @@ -1,500 +1,500 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text; -using Microsoft.DevProxy.Abstractions; -using Microsoft.DevProxy.Plugins.RequestLogs; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace Microsoft.DevProxy.Plugins.Reporters; - -public class MarkdownReporter(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReporter(pluginEvents, context, logger, urlsToWatch, configSection) -{ - public override string Name => nameof(MarkdownReporter); - public override string FileExtension => ".md"; - - private readonly Dictionary> _transformers = new() - { - { typeof(ApiCenterMinimalPermissionsPluginReport), TransformApiCenterMinimalPermissionsReport }, - { typeof(ApiCenterOnboardingPluginReport), TransformApiCenterOnboardingReport }, - { typeof(ApiCenterProductionVersionPluginReport), TransformApiCenterProductionVersionReport }, - { typeof(ExecutionSummaryPluginReportByUrl), TransformExecutionSummaryByUrl }, - { typeof(ExecutionSummaryPluginReportByMessageType), TransformExecutionSummaryByMessageType }, - { typeof(HttpFileGeneratorPlugin), TransformHttpFileGeneratorReport }, - { typeof(MinimalPermissionsGuidancePluginReport), TransformMinimalPermissionsGuidanceReport }, - { typeof(MinimalPermissionsPluginReport), TransformMinimalPermissionsReport }, - { typeof(OpenApiSpecGeneratorPluginReport), TransformOpenApiSpecGeneratorReport } - }; - - private const string _requestsInterceptedMessage = "Requests intercepted"; - private const string _requestsPassedThroughMessage = "Requests passed through"; - - protected override string? GetReport(KeyValuePair report) - { - Logger.LogDebug("Transforming {report}...", report.Key); - - var reportType = report.Value.GetType(); - - if (_transformers.TryGetValue(reportType, out var transform)) - { - Logger.LogDebug("Transforming {reportType} using {transform}...", reportType.Name, transform.Method.Name); - - return transform(report.Value); - } - else - { - Logger.LogDebug("No transformer found for {reportType}", reportType.Name); - return null; - } - } - - private static string? TransformApiCenterOnboardingReport(object report) - { - var apiCenterOnboardingReport = (ApiCenterOnboardingPluginReport)report; - - if (apiCenterOnboardingReport.NewApis.Length == 0 && - apiCenterOnboardingReport.ExistingApis.Length == 0) - { - return null; - } - - var sb = new StringBuilder(); - - sb.AppendLine("# Azure API Center onboarding report"); - sb.AppendLine(); - - if (apiCenterOnboardingReport.NewApis.Length != 0) - { - var apisPerSchemeAndHost = apiCenterOnboardingReport.NewApis.GroupBy(x => - { - var u = new Uri(x.Url); - return u.GetLeftPart(UriPartial.Authority); - }); - - sb.AppendLine("## ⚠️ New APIs that aren't registered in Azure API Center"); - sb.AppendLine(); - - foreach (var apiPerHost in apisPerSchemeAndHost) - { - sb.AppendLine($"### {apiPerHost.Key}"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, apiPerHost.Select(a => $"- {a.Method} {a.Url}")); - sb.AppendLine(); - } - - sb.AppendLine(); - } - - if (apiCenterOnboardingReport.ExistingApis.Length != 0) - { - sb.AppendLine("## ✅ APIs that are already registered in Azure API Center"); - sb.AppendLine(); - sb.AppendLine("API|Definition ID|Operation ID"); - sb.AppendLine("---|------------|------------"); - sb.AppendJoin(Environment.NewLine, apiCenterOnboardingReport.ExistingApis.Select(a => $"{a.MethodAndUrl}|{a.ApiDefinitionId}|{a.OperationId}")); - sb.AppendLine(); - } - - sb.AppendLine(); - - return sb.ToString(); - } - - private static string? TransformApiCenterMinimalPermissionsReport(object report) - { - var apiCenterMinimalPermissionsReport = (ApiCenterMinimalPermissionsPluginReport)report; - - var sb = new StringBuilder(); - sb.AppendLine("# Azure API Center minimal permissions report") - .AppendLine(); - - sb.AppendLine("## ℹ️ Summary") - .AppendLine() - .AppendLine("") - .AppendFormat("{1}", apiCenterMinimalPermissionsReport.Results.Length, Environment.NewLine) - .AppendFormat("{1}", apiCenterMinimalPermissionsReport.Results.Sum(r => r.Requests.Length), Environment.NewLine) - .AppendFormat("{1}", apiCenterMinimalPermissionsReport.Results.Count(r => r.UsesMinimalPermissions), Environment.NewLine) - .AppendFormat("{1}", apiCenterMinimalPermissionsReport.Results.Count(r => !r.UsesMinimalPermissions), Environment.NewLine) - .AppendFormat("{1}", apiCenterMinimalPermissionsReport.UnmatchedRequests.Length, Environment.NewLine) - .AppendFormat("{1}", apiCenterMinimalPermissionsReport.Errors.Length, Environment.NewLine) - .AppendLine("
🔎 APIs inspected{0}
🔎 Requests inspected{0}
✅ APIs called using minimal permissions{0}
🛑 APIs called using excessive permissions{0}
⚠️ Unmatched requests{0}
🛑 Errors{0}
") - .AppendLine(); - - sb.AppendLine("## 🔌 APIs") - .AppendLine(); - - if (apiCenterMinimalPermissionsReport.Results.Length != 0) - { - foreach (var apiResult in apiCenterMinimalPermissionsReport.Results) - { - sb.AppendFormat("### {0}{1}", apiResult.ApiName, Environment.NewLine) - .AppendLine() - .AppendFormat(apiResult.UsesMinimalPermissions ? "✅ Called using minimal permissions{0}" : "🛑 Called using excessive permissions{0}", Environment.NewLine) - .AppendLine() - .AppendLine("#### Permissions") - .AppendLine() - .AppendFormat("- Minimal permissions: {0}{1}", string.Join(", ", apiResult.MinimalPermissions.Order().Select(p => $"`{p}`")), Environment.NewLine) - .AppendFormat("- Permissions on the token: {0}{1}", string.Join(", ", apiResult.TokenPermissions.Order().Select(p => $"`{p}`")), Environment.NewLine) - .AppendFormat("- Excessive permissions: {0}{1}", apiResult.ExcessivePermissions.Any() ? string.Join(", ", apiResult.ExcessivePermissions.Order().Select(p => $"`{p}`")) : "none", Environment.NewLine) - .AppendLine() - .AppendLine("#### Requests") - .AppendLine() - .AppendJoin(Environment.NewLine, apiResult.Requests.Select(r => $"- {r}")).AppendLine() - .AppendLine(); - } - } - else - { - sb.AppendLine("No APIs found.") - .AppendLine(); - } - - sb.AppendLine("## ⚠️ Unmatched requests") - .AppendLine(); - - if (apiCenterMinimalPermissionsReport.UnmatchedRequests.Length != 0) - { - sb.AppendLine("The following requests were not matched to any API in API Center:") - .AppendLine() - .AppendJoin(Environment.NewLine, apiCenterMinimalPermissionsReport.UnmatchedRequests - .Select(r => $"- {r}").Order()).AppendLine() - .AppendLine(); - } - else - { - sb.AppendLine("No unmatched requests found.") - .AppendLine(); - } - - sb.AppendLine("## 🛑 Errors") - .AppendLine(); - - if (apiCenterMinimalPermissionsReport.Errors.Length != 0) - { - sb.AppendLine("The following errors occurred while determining minimal permissions:") - .AppendLine() - .AppendJoin(Environment.NewLine, apiCenterMinimalPermissionsReport.Errors - .OrderBy(o => o.Request) - .Select(e => $"- `{e.Request}`: {e.Error}")).AppendLine() - .AppendLine(); - } - else - { - sb.AppendLine("No errors occurred."); - } - - return sb.ToString(); - } - - private static string? TransformApiCenterProductionVersionReport(object report) - { - static string getReadableApiStatus(ApiCenterProductionVersionPluginReportItemStatus status) => status switch - { - ApiCenterProductionVersionPluginReportItemStatus.NotRegistered => "🛑 Not registered", - ApiCenterProductionVersionPluginReportItemStatus.NonProduction => "⚠️ Non-production", - ApiCenterProductionVersionPluginReportItemStatus.Production => "✅ Production", - _ => "Unknown" - }; - - var apiCenterProductionVersionReport = (ApiCenterProductionVersionPluginReport)report; - - var groupedPerStatus = apiCenterProductionVersionReport - .GroupBy(a => a.Status) - .OrderBy(g => (int)g.Key); - - var sb = new StringBuilder(); - sb.AppendLine("# Azure API Center lifecycle report"); - sb.AppendLine(); - - foreach (var group in groupedPerStatus) - { - sb.AppendLine($"## {getReadableApiStatus(group.Key)} APIs"); - sb.AppendLine(); - - if (group.Key == ApiCenterProductionVersionPluginReportItemStatus.NonProduction) - { - sb.AppendLine("API|Recommendation"); - sb.AppendLine("---|------------"); - sb.AppendJoin(Environment.NewLine, group - .OrderBy(a => a.Url) - .Select(a => $"{a.Method} {a.Url}|{a.Recommendation ?? ""}")); - sb.AppendLine(); - } - else - { - sb.AppendJoin(Environment.NewLine, group - .OrderBy(a => a.Url) - .Select(a => $"- {a.Method} {a.Url}")); - sb.AppendLine(); - } - - sb.AppendLine(); - } - - return sb.ToString(); - } - - private static string TransformExecutionSummaryByMessageType(object report) - { - var executionSummaryReport = (ExecutionSummaryPluginReportByMessageType)report; - - var sb = new StringBuilder(); - - sb.AppendLine("# Dev Proxy execution summary"); - sb.AppendLine(); - sb.AppendLine($"Date: {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}"); - sb.AppendLine(); - - sb.AppendLine("## Message types"); - - var data = executionSummaryReport.Data; - var sortedMessageTypes = data.Keys.OrderBy(k => k); - foreach (var messageType in sortedMessageTypes) - { - sb.AppendLine(); - sb.AppendLine($"### {messageType}"); - - if (messageType == _requestsInterceptedMessage || - messageType == _requestsPassedThroughMessage) - { - sb.AppendLine(); - - var sortedMethodAndUrls = data[messageType][messageType].Keys.OrderBy(k => k); - foreach (var methodAndUrl in sortedMethodAndUrls) - { - sb.AppendLine($"- ({data[messageType][messageType][methodAndUrl]}) {methodAndUrl}"); - } - } - else - { - var sortedMessages = data[messageType].Keys.OrderBy(k => k); - foreach (var message in sortedMessages) - { - sb.AppendLine(); - sb.AppendLine($"#### {message}"); - sb.AppendLine(); - - var sortedMethodAndUrls = data[messageType][message].Keys.OrderBy(k => k); - foreach (var methodAndUrl in sortedMethodAndUrls) - { - sb.AppendLine($"- ({data[messageType][message][methodAndUrl]}) {methodAndUrl}"); - } - } - } - } - - AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); - sb.AppendLine(); - - return sb.ToString(); - } - - private static string TransformExecutionSummaryByUrl(object report) - { - var executionSummaryReport = (ExecutionSummaryPluginReportByUrl)report; - - var sb = new StringBuilder(); - - sb.AppendLine("# Dev Proxy execution summary"); - sb.AppendLine(); - sb.AppendLine($"Date: {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}"); - sb.AppendLine(); - - sb.AppendLine("## Requests"); - - var data = executionSummaryReport.Data; - var sortedMethodAndUrls = data.Keys.OrderBy(k => k); - foreach (var methodAndUrl in sortedMethodAndUrls) - { - sb.AppendLine(); - sb.AppendLine($"### {methodAndUrl}"); - - var sortedMessageTypes = data[methodAndUrl].Keys.OrderBy(k => k); - foreach (var messageType in sortedMessageTypes) - { - sb.AppendLine(); - sb.AppendLine($"#### {messageType}"); - sb.AppendLine(); - - var sortedMessages = data[methodAndUrl][messageType].Keys.OrderBy(k => k); - foreach (var message in sortedMessages) - { - sb.AppendLine($"- ({data[methodAndUrl][messageType][message]}) {message}"); - } - } - } - - AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); - sb.AppendLine(); - - return sb.ToString(); - } - - private static void AddExecutionSummaryReportSummary(IEnumerable requestLogs, StringBuilder sb) - { - static string getReadableMessageTypeForSummary(MessageType messageType) => messageType switch - { - MessageType.Chaos => "Requests with chaos", - MessageType.Failed => "Failures", - MessageType.InterceptedRequest => _requestsInterceptedMessage, - MessageType.Mocked => "Requests mocked", - MessageType.PassedThrough => _requestsPassedThroughMessage, - MessageType.Tip => "Tips", - MessageType.Warning => "Warnings", - _ => "Unknown" - }; - - var data = requestLogs - .Where(log => log.MessageType != MessageType.InterceptedResponse) - .Select(log => getReadableMessageTypeForSummary(log.MessageType)) - .OrderBy(log => log) - .GroupBy(log => log) - .ToDictionary(group => group.Key, group => group.Count()); - - sb.AppendLine(); - sb.AppendLine("## Summary"); - sb.AppendLine(); - sb.AppendLine("Category|Count"); - sb.AppendLine("--------|----:"); - - foreach (var messageType in data.Keys) - { - sb.AppendLine($"{messageType}|{data[messageType]}"); - } - } - - private static string? TransformMinimalPermissionsGuidanceReport(object report) - { - var minimalPermissionsGuidanceReport = (MinimalPermissionsGuidancePluginReport)report; - - var sb = new StringBuilder(); - sb.AppendLine("# Minimal permissions report"); - sb.AppendLine(); - - void transformPermissionsInfo(MinimalPermissionsInfo permissionsInfo, string type) - { - sb.AppendLine($"## Minimal {type} permissions"); - sb.AppendLine(); - sb.AppendLine("### Operations"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, permissionsInfo.Operations.Select(o => $"- {o.Method} {o.Endpoint}")); - sb.AppendLine(); - sb.AppendLine(); - sb.AppendLine("### Minimal permissions"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, permissionsInfo.MinimalPermissions.Select(p => $"- {p}")); - sb.AppendLine(); - sb.AppendLine(); - sb.AppendLine("### Permissions on the token"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, permissionsInfo.PermissionsFromTheToken.Select(p => $"- {p}")); - sb.AppendLine(); - sb.AppendLine(); - sb.AppendLine("### Excessive permissions"); - - if (permissionsInfo.ExcessPermissions.Any()) - { - sb.AppendLine(); - sb.AppendLine("The following permissions included in token are unnecessary:"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, permissionsInfo.ExcessPermissions.Select(p => $"- {p}")); - sb.AppendLine(); - } - else - { - sb.AppendLine(); - sb.AppendLine("The token has the minimal permissions required."); - } - - sb.AppendLine(); - } - - if (minimalPermissionsGuidanceReport.DelegatedPermissions is not null) - { - transformPermissionsInfo(minimalPermissionsGuidanceReport.DelegatedPermissions, "delegated"); - } - if (minimalPermissionsGuidanceReport.ApplicationPermissions is not null) - { - transformPermissionsInfo(minimalPermissionsGuidanceReport.ApplicationPermissions, "application"); - } - - if (minimalPermissionsGuidanceReport.ExcludedPermissions is not null && - minimalPermissionsGuidanceReport.ExcludedPermissions.Any()) - { - sb.AppendLine("## Excluded permissions"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, minimalPermissionsGuidanceReport.ExcludedPermissions.Select(p => $"- {p}")); - sb.AppendLine(); - } - - return sb.ToString(); - } - - private static string? TransformMinimalPermissionsReport(object report) - { - var minimalPermissionsReport = (MinimalPermissionsPluginReport)report; - - var sb = new StringBuilder(); - sb.AppendLine($"# Minimal {minimalPermissionsReport.PermissionsType.ToString().ToLower()} permissions report"); - sb.AppendLine(); - - sb.AppendLine("## Requests"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Requests.Select(r => $"- {r.Method} {r.Url}")); - sb.AppendLine(); - - sb.AppendLine(); - sb.AppendLine("## Minimal permissions"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.MinimalPermissions.Select(p => $"- {p}")); - sb.AppendLine(); - - if (minimalPermissionsReport.Errors.Any()) - { - sb.AppendLine(); - sb.AppendLine("## 🛑 Errors"); - sb.AppendLine(); - sb.AppendLine("Couldn't determine minimal permissions for the following URLs:"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Errors.Select(e => $"- {e}")); - sb.AppendLine(); - } - - sb.AppendLine(); - - return sb.ToString(); - } - - private static string? TransformOpenApiSpecGeneratorReport(object report) - { - var openApiSpecGeneratorReport = (OpenApiSpecGeneratorPluginReport)report; - - var sb = new StringBuilder(); - - sb.AppendLine("# Generated OpenAPI specs"); - sb.AppendLine(); - sb.AppendLine("Server URL|File name"); - sb.AppendLine("---|---------"); - sb.AppendJoin(Environment.NewLine, openApiSpecGeneratorReport.Select(r => $"{r.ServerUrl}|{r.FileName}")); - sb.AppendLine(); - sb.AppendLine(); - - return sb.ToString(); - } - - private static string? TransformHttpFileGeneratorReport(object report) - { - var httpFileGeneratorReport = (HttpFileGeneratorPluginReport)report; - - var sb = new StringBuilder(); - - sb.AppendLine("# Generated HTTP files"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, $"- {httpFileGeneratorReport}"); - sb.AppendLine(); - sb.AppendLine(); - - return sb.ToString(); - } +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.DevProxy.Abstractions; +using Microsoft.DevProxy.Plugins.RequestLogs; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DevProxy.Plugins.Reporters; + +public class MarkdownReporter(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReporter(pluginEvents, context, logger, urlsToWatch, configSection) +{ + public override string Name => nameof(MarkdownReporter); + public override string FileExtension => ".md"; + + private readonly Dictionary> _transformers = new() + { + { typeof(ApiCenterMinimalPermissionsPluginReport), TransformApiCenterMinimalPermissionsReport }, + { typeof(ApiCenterOnboardingPluginReport), TransformApiCenterOnboardingReport }, + { typeof(ApiCenterProductionVersionPluginReport), TransformApiCenterProductionVersionReport }, + { typeof(ExecutionSummaryPluginReportByUrl), TransformExecutionSummaryByUrl }, + { typeof(ExecutionSummaryPluginReportByMessageType), TransformExecutionSummaryByMessageType }, + { typeof(HttpFileGeneratorPlugin), TransformHttpFileGeneratorReport }, + { typeof(GraphMinimalPermissionsGuidancePluginReport), TransformMinimalPermissionsGuidanceReport }, + { typeof(GraphMinimalPermissionsPluginReport), TransformMinimalPermissionsReport }, + { typeof(OpenApiSpecGeneratorPluginReport), TransformOpenApiSpecGeneratorReport } + }; + + private const string _requestsInterceptedMessage = "Requests intercepted"; + private const string _requestsPassedThroughMessage = "Requests passed through"; + + protected override string? GetReport(KeyValuePair report) + { + Logger.LogDebug("Transforming {report}...", report.Key); + + var reportType = report.Value.GetType(); + + if (_transformers.TryGetValue(reportType, out var transform)) + { + Logger.LogDebug("Transforming {reportType} using {transform}...", reportType.Name, transform.Method.Name); + + return transform(report.Value); + } + else + { + Logger.LogDebug("No transformer found for {reportType}", reportType.Name); + return null; + } + } + + private static string? TransformApiCenterOnboardingReport(object report) + { + var apiCenterOnboardingReport = (ApiCenterOnboardingPluginReport)report; + + if (apiCenterOnboardingReport.NewApis.Length == 0 && + apiCenterOnboardingReport.ExistingApis.Length == 0) + { + return null; + } + + var sb = new StringBuilder(); + + sb.AppendLine("# Azure API Center onboarding report"); + sb.AppendLine(); + + if (apiCenterOnboardingReport.NewApis.Length != 0) + { + var apisPerSchemeAndHost = apiCenterOnboardingReport.NewApis.GroupBy(x => + { + var u = new Uri(x.Url); + return u.GetLeftPart(UriPartial.Authority); + }); + + sb.AppendLine("## ⚠️ New APIs that aren't registered in Azure API Center"); + sb.AppendLine(); + + foreach (var apiPerHost in apisPerSchemeAndHost) + { + sb.AppendLine($"### {apiPerHost.Key}"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, apiPerHost.Select(a => $"- {a.Method} {a.Url}")); + sb.AppendLine(); + } + + sb.AppendLine(); + } + + if (apiCenterOnboardingReport.ExistingApis.Length != 0) + { + sb.AppendLine("## ✅ APIs that are already registered in Azure API Center"); + sb.AppendLine(); + sb.AppendLine("API|Definition ID|Operation ID"); + sb.AppendLine("---|------------|------------"); + sb.AppendJoin(Environment.NewLine, apiCenterOnboardingReport.ExistingApis.Select(a => $"{a.MethodAndUrl}|{a.ApiDefinitionId}|{a.OperationId}")); + sb.AppendLine(); + } + + sb.AppendLine(); + + return sb.ToString(); + } + + private static string? TransformApiCenterMinimalPermissionsReport(object report) + { + var apiCenterMinimalPermissionsReport = (ApiCenterMinimalPermissionsPluginReport)report; + + var sb = new StringBuilder(); + sb.AppendLine("# Azure API Center minimal permissions report") + .AppendLine(); + + sb.AppendLine("## ℹ️ Summary") + .AppendLine() + .AppendLine("") + .AppendFormat("{1}", apiCenterMinimalPermissionsReport.Results.Length, Environment.NewLine) + .AppendFormat("{1}", apiCenterMinimalPermissionsReport.Results.Sum(r => r.Requests.Length), Environment.NewLine) + .AppendFormat("{1}", apiCenterMinimalPermissionsReport.Results.Count(r => r.UsesMinimalPermissions), Environment.NewLine) + .AppendFormat("{1}", apiCenterMinimalPermissionsReport.Results.Count(r => !r.UsesMinimalPermissions), Environment.NewLine) + .AppendFormat("{1}", apiCenterMinimalPermissionsReport.UnmatchedRequests.Length, Environment.NewLine) + .AppendFormat("{1}", apiCenterMinimalPermissionsReport.Errors.Length, Environment.NewLine) + .AppendLine("
🔎 APIs inspected{0}
🔎 Requests inspected{0}
✅ APIs called using minimal permissions{0}
🛑 APIs called using excessive permissions{0}
⚠️ Unmatched requests{0}
🛑 Errors{0}
") + .AppendLine(); + + sb.AppendLine("## 🔌 APIs") + .AppendLine(); + + if (apiCenterMinimalPermissionsReport.Results.Length != 0) + { + foreach (var apiResult in apiCenterMinimalPermissionsReport.Results) + { + sb.AppendFormat("### {0}{1}", apiResult.ApiName, Environment.NewLine) + .AppendLine() + .AppendFormat(apiResult.UsesMinimalPermissions ? "✅ Called using minimal permissions{0}" : "🛑 Called using excessive permissions{0}", Environment.NewLine) + .AppendLine() + .AppendLine("#### Permissions") + .AppendLine() + .AppendFormat("- Minimal permissions: {0}{1}", string.Join(", ", apiResult.MinimalPermissions.Order().Select(p => $"`{p}`")), Environment.NewLine) + .AppendFormat("- Permissions on the token: {0}{1}", string.Join(", ", apiResult.TokenPermissions.Order().Select(p => $"`{p}`")), Environment.NewLine) + .AppendFormat("- Excessive permissions: {0}{1}", apiResult.ExcessivePermissions.Any() ? string.Join(", ", apiResult.ExcessivePermissions.Order().Select(p => $"`{p}`")) : "none", Environment.NewLine) + .AppendLine() + .AppendLine("#### Requests") + .AppendLine() + .AppendJoin(Environment.NewLine, apiResult.Requests.Select(r => $"- {r}")).AppendLine() + .AppendLine(); + } + } + else + { + sb.AppendLine("No APIs found.") + .AppendLine(); + } + + sb.AppendLine("## ⚠️ Unmatched requests") + .AppendLine(); + + if (apiCenterMinimalPermissionsReport.UnmatchedRequests.Length != 0) + { + sb.AppendLine("The following requests were not matched to any API in API Center:") + .AppendLine() + .AppendJoin(Environment.NewLine, apiCenterMinimalPermissionsReport.UnmatchedRequests + .Select(r => $"- {r}").Order()).AppendLine() + .AppendLine(); + } + else + { + sb.AppendLine("No unmatched requests found.") + .AppendLine(); + } + + sb.AppendLine("## 🛑 Errors") + .AppendLine(); + + if (apiCenterMinimalPermissionsReport.Errors.Length != 0) + { + sb.AppendLine("The following errors occurred while determining minimal permissions:") + .AppendLine() + .AppendJoin(Environment.NewLine, apiCenterMinimalPermissionsReport.Errors + .OrderBy(o => o.Request) + .Select(e => $"- `{e.Request}`: {e.Error}")).AppendLine() + .AppendLine(); + } + else + { + sb.AppendLine("No errors occurred."); + } + + return sb.ToString(); + } + + private static string? TransformApiCenterProductionVersionReport(object report) + { + static string getReadableApiStatus(ApiCenterProductionVersionPluginReportItemStatus status) => status switch + { + ApiCenterProductionVersionPluginReportItemStatus.NotRegistered => "🛑 Not registered", + ApiCenterProductionVersionPluginReportItemStatus.NonProduction => "⚠️ Non-production", + ApiCenterProductionVersionPluginReportItemStatus.Production => "✅ Production", + _ => "Unknown" + }; + + var apiCenterProductionVersionReport = (ApiCenterProductionVersionPluginReport)report; + + var groupedPerStatus = apiCenterProductionVersionReport + .GroupBy(a => a.Status) + .OrderBy(g => (int)g.Key); + + var sb = new StringBuilder(); + sb.AppendLine("# Azure API Center lifecycle report"); + sb.AppendLine(); + + foreach (var group in groupedPerStatus) + { + sb.AppendLine($"## {getReadableApiStatus(group.Key)} APIs"); + sb.AppendLine(); + + if (group.Key == ApiCenterProductionVersionPluginReportItemStatus.NonProduction) + { + sb.AppendLine("API|Recommendation"); + sb.AppendLine("---|------------"); + sb.AppendJoin(Environment.NewLine, group + .OrderBy(a => a.Url) + .Select(a => $"{a.Method} {a.Url}|{a.Recommendation ?? ""}")); + sb.AppendLine(); + } + else + { + sb.AppendJoin(Environment.NewLine, group + .OrderBy(a => a.Url) + .Select(a => $"- {a.Method} {a.Url}")); + sb.AppendLine(); + } + + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static string TransformExecutionSummaryByMessageType(object report) + { + var executionSummaryReport = (ExecutionSummaryPluginReportByMessageType)report; + + var sb = new StringBuilder(); + + sb.AppendLine("# Dev Proxy execution summary"); + sb.AppendLine(); + sb.AppendLine($"Date: {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}"); + sb.AppendLine(); + + sb.AppendLine("## Message types"); + + var data = executionSummaryReport.Data; + var sortedMessageTypes = data.Keys.OrderBy(k => k); + foreach (var messageType in sortedMessageTypes) + { + sb.AppendLine(); + sb.AppendLine($"### {messageType}"); + + if (messageType == _requestsInterceptedMessage || + messageType == _requestsPassedThroughMessage) + { + sb.AppendLine(); + + var sortedMethodAndUrls = data[messageType][messageType].Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine($"- ({data[messageType][messageType][methodAndUrl]}) {methodAndUrl}"); + } + } + else + { + var sortedMessages = data[messageType].Keys.OrderBy(k => k); + foreach (var message in sortedMessages) + { + sb.AppendLine(); + sb.AppendLine($"#### {message}"); + sb.AppendLine(); + + var sortedMethodAndUrls = data[messageType][message].Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine($"- ({data[messageType][message][methodAndUrl]}) {methodAndUrl}"); + } + } + } + } + + AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); + sb.AppendLine(); + + return sb.ToString(); + } + + private static string TransformExecutionSummaryByUrl(object report) + { + var executionSummaryReport = (ExecutionSummaryPluginReportByUrl)report; + + var sb = new StringBuilder(); + + sb.AppendLine("# Dev Proxy execution summary"); + sb.AppendLine(); + sb.AppendLine($"Date: {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}"); + sb.AppendLine(); + + sb.AppendLine("## Requests"); + + var data = executionSummaryReport.Data; + var sortedMethodAndUrls = data.Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine(); + sb.AppendLine($"### {methodAndUrl}"); + + var sortedMessageTypes = data[methodAndUrl].Keys.OrderBy(k => k); + foreach (var messageType in sortedMessageTypes) + { + sb.AppendLine(); + sb.AppendLine($"#### {messageType}"); + sb.AppendLine(); + + var sortedMessages = data[methodAndUrl][messageType].Keys.OrderBy(k => k); + foreach (var message in sortedMessages) + { + sb.AppendLine($"- ({data[methodAndUrl][messageType][message]}) {message}"); + } + } + } + + AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); + sb.AppendLine(); + + return sb.ToString(); + } + + private static void AddExecutionSummaryReportSummary(IEnumerable requestLogs, StringBuilder sb) + { + static string getReadableMessageTypeForSummary(MessageType messageType) => messageType switch + { + MessageType.Chaos => "Requests with chaos", + MessageType.Failed => "Failures", + MessageType.InterceptedRequest => _requestsInterceptedMessage, + MessageType.Mocked => "Requests mocked", + MessageType.PassedThrough => _requestsPassedThroughMessage, + MessageType.Tip => "Tips", + MessageType.Warning => "Warnings", + _ => "Unknown" + }; + + var data = requestLogs + .Where(log => log.MessageType != MessageType.InterceptedResponse) + .Select(log => getReadableMessageTypeForSummary(log.MessageType)) + .OrderBy(log => log) + .GroupBy(log => log) + .ToDictionary(group => group.Key, group => group.Count()); + + sb.AppendLine(); + sb.AppendLine("## Summary"); + sb.AppendLine(); + sb.AppendLine("Category|Count"); + sb.AppendLine("--------|----:"); + + foreach (var messageType in data.Keys) + { + sb.AppendLine($"{messageType}|{data[messageType]}"); + } + } + + private static string? TransformMinimalPermissionsGuidanceReport(object report) + { + var minimalPermissionsGuidanceReport = (GraphMinimalPermissionsGuidancePluginReport)report; + + var sb = new StringBuilder(); + sb.AppendLine("# Minimal permissions report"); + sb.AppendLine(); + + void transformPermissionsInfo(GraphMinimalPermissionsInfo permissionsInfo, string type) + { + sb.AppendLine($"## Minimal {type} permissions"); + sb.AppendLine(); + sb.AppendLine("### Operations"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, permissionsInfo.Operations.Select(o => $"- {o.Method} {o.Endpoint}")); + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("### Minimal permissions"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, permissionsInfo.MinimalPermissions.Select(p => $"- {p}")); + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("### Permissions on the token"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, permissionsInfo.PermissionsFromTheToken.Select(p => $"- {p}")); + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("### Excessive permissions"); + + if (permissionsInfo.ExcessPermissions.Any()) + { + sb.AppendLine(); + sb.AppendLine("The following permissions included in token are unnecessary:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, permissionsInfo.ExcessPermissions.Select(p => $"- {p}")); + sb.AppendLine(); + } + else + { + sb.AppendLine(); + sb.AppendLine("The token has the minimal permissions required."); + } + + sb.AppendLine(); + } + + if (minimalPermissionsGuidanceReport.DelegatedPermissions is not null) + { + transformPermissionsInfo(minimalPermissionsGuidanceReport.DelegatedPermissions, "delegated"); + } + if (minimalPermissionsGuidanceReport.ApplicationPermissions is not null) + { + transformPermissionsInfo(minimalPermissionsGuidanceReport.ApplicationPermissions, "application"); + } + + if (minimalPermissionsGuidanceReport.ExcludedPermissions is not null && + minimalPermissionsGuidanceReport.ExcludedPermissions.Any()) + { + sb.AppendLine("## Excluded permissions"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsGuidanceReport.ExcludedPermissions.Select(p => $"- {p}")); + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static string? TransformMinimalPermissionsReport(object report) + { + var minimalPermissionsReport = (GraphMinimalPermissionsPluginReport)report; + + var sb = new StringBuilder(); + sb.AppendLine($"# Minimal {minimalPermissionsReport.PermissionsType.ToString().ToLower()} permissions report"); + sb.AppendLine(); + + sb.AppendLine("## Requests"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Requests.Select(r => $"- {r.Method} {r.Url}")); + sb.AppendLine(); + + sb.AppendLine(); + sb.AppendLine("## Minimal permissions"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.MinimalPermissions.Select(p => $"- {p}")); + sb.AppendLine(); + + if (minimalPermissionsReport.Errors.Any()) + { + sb.AppendLine(); + sb.AppendLine("## 🛑 Errors"); + sb.AppendLine(); + sb.AppendLine("Couldn't determine minimal permissions for the following URLs:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Errors.Select(e => $"- {e}")); + sb.AppendLine(); + } + + sb.AppendLine(); + + return sb.ToString(); + } + + private static string? TransformOpenApiSpecGeneratorReport(object report) + { + var openApiSpecGeneratorReport = (OpenApiSpecGeneratorPluginReport)report; + + var sb = new StringBuilder(); + + sb.AppendLine("# Generated OpenAPI specs"); + sb.AppendLine(); + sb.AppendLine("Server URL|File name"); + sb.AppendLine("---|---------"); + sb.AppendJoin(Environment.NewLine, openApiSpecGeneratorReport.Select(r => $"{r.ServerUrl}|{r.FileName}")); + sb.AppendLine(); + sb.AppendLine(); + + return sb.ToString(); + } + + private static string? TransformHttpFileGeneratorReport(object report) + { + var httpFileGeneratorReport = (HttpFileGeneratorPluginReport)report; + + var sb = new StringBuilder(); + + sb.AppendLine("# Generated HTTP files"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, $"- {httpFileGeneratorReport}"); + sb.AppendLine(); + sb.AppendLine(); + + return sb.ToString(); + } } \ No newline at end of file diff --git a/dev-proxy-plugins/Reporters/PlainTextReporter.cs b/dev-proxy-plugins/Reporters/PlainTextReporter.cs index ffbb4aae..352e3f0d 100644 --- a/dev-proxy-plugins/Reporters/PlainTextReporter.cs +++ b/dev-proxy-plugins/Reporters/PlainTextReporter.cs @@ -1,446 +1,446 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text; -using Microsoft.DevProxy.Abstractions; -using Microsoft.DevProxy.Plugins.RequestLogs; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace Microsoft.DevProxy.Plugins.Reporters; - -public class PlainTextReporter(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReporter(pluginEvents, context, logger, urlsToWatch, configSection) -{ - public override string Name => nameof(PlainTextReporter); - public override string FileExtension => ".txt"; - - private readonly Dictionary> _transformers = new() - { - { typeof(ApiCenterMinimalPermissionsPluginReport), TransformApiCenterMinimalPermissionsReport }, - { typeof(ApiCenterOnboardingPluginReport), TransformApiCenterOnboardingReport }, - { typeof(ApiCenterProductionVersionPluginReport), TransformApiCenterProductionVersionReport }, - { typeof(ExecutionSummaryPluginReportByUrl), TransformExecutionSummaryByUrl }, - { typeof(ExecutionSummaryPluginReportByMessageType), TransformExecutionSummaryByMessageType }, - { typeof(HttpFileGeneratorPluginReport), TransformHttpFileGeneratorReport }, - { typeof(MinimalPermissionsGuidancePluginReport), TransformMinimalPermissionsGuidanceReport }, - { typeof(MinimalPermissionsPluginReport), TransformMinimalPermissionsReport }, - { typeof(OpenApiSpecGeneratorPluginReport), TransformOpenApiSpecGeneratorReport } - }; - - private const string _requestsInterceptedMessage = "Requests intercepted"; - private const string _requestsPassedThroughMessage = "Requests passed through"; - - protected override string? GetReport(KeyValuePair report) - { - Logger.LogDebug("Transforming {report}...", report.Key); - - var reportType = report.Value.GetType(); - - if (_transformers.TryGetValue(reportType, out var transform)) - { - Logger.LogDebug("Transforming {reportType} using {transform}...", reportType.Name, transform.Method.Name); - - return transform(report.Value); - } - else - { - Logger.LogDebug("No transformer found for {reportType}", reportType.Name); - return null; - } - } - - private static string? TransformHttpFileGeneratorReport(object report) - { - var httpFileGeneratorReport = (HttpFileGeneratorPluginReport)report; - - var sb = new StringBuilder(); - - sb.AppendLine("Generated HTTP files:"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, httpFileGeneratorReport); - - return sb.ToString(); - } - - private static string? TransformOpenApiSpecGeneratorReport(object report) - { - var openApiSpecGeneratorReport = (OpenApiSpecGeneratorPluginReport)report; - - var sb = new StringBuilder(); - - sb.AppendLine("Generated OpenAPI specs:"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, openApiSpecGeneratorReport.Select(i => $"- {i.FileName} ({i.ServerUrl})")); - - return sb.ToString(); - } - - private static string? TransformExecutionSummaryByMessageType(object report) - { - var executionSummaryReport = (ExecutionSummaryPluginReportByMessageType)report; - - var sb = new StringBuilder(); - - sb.AppendLine("Dev Proxy execution summary"); - sb.AppendLine($"({DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")})"); - sb.AppendLine(); - - sb.AppendLine(":: Message types".ToUpper()); - - var data = executionSummaryReport.Data; - var sortedMessageTypes = data.Keys.OrderBy(k => k); - foreach (var messageType in sortedMessageTypes) - { - sb.AppendLine(); - sb.AppendLine(messageType.ToUpper()); - - if (messageType == _requestsInterceptedMessage || - messageType == _requestsPassedThroughMessage) - { - sb.AppendLine(); - - var sortedMethodAndUrls = data[messageType][messageType].Keys.OrderBy(k => k); - foreach (var methodAndUrl in sortedMethodAndUrls) - { - sb.AppendLine($"- ({data[messageType][messageType][methodAndUrl]}) {methodAndUrl}"); - } - } - else - { - var sortedMessages = data[messageType].Keys.OrderBy(k => k); - foreach (var message in sortedMessages) - { - sb.AppendLine(); - sb.AppendLine(message); - sb.AppendLine(); - - var sortedMethodAndUrls = data[messageType][message].Keys.OrderBy(k => k); - foreach (var methodAndUrl in sortedMethodAndUrls) - { - sb.AppendLine($"- ({data[messageType][message][methodAndUrl]}) {methodAndUrl}"); - } - } - } - } - - AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); - - return sb.ToString(); - } - - private static string? TransformExecutionSummaryByUrl(object report) - { - var executionSummaryReport = (ExecutionSummaryPluginReportByUrl)report; - - var sb = new StringBuilder(); - - sb.AppendLine("Dev Proxy execution summary"); - sb.AppendLine($"({DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")})"); - sb.AppendLine(); - - sb.AppendLine(":: Requests".ToUpper()); - - var data = executionSummaryReport.Data; - var sortedMethodAndUrls = data.Keys.OrderBy(k => k); - foreach (var methodAndUrl in sortedMethodAndUrls) - { - sb.AppendLine(); - sb.AppendLine(methodAndUrl); - - var sortedMessageTypes = data[methodAndUrl].Keys.OrderBy(k => k); - foreach (var messageType in sortedMessageTypes) - { - sb.AppendLine(); - sb.AppendLine(messageType.ToUpper()); - sb.AppendLine(); - - var sortedMessages = data[methodAndUrl][messageType].Keys.OrderBy(k => k); - foreach (var message in sortedMessages) - { - sb.AppendLine($"- ({data[methodAndUrl][messageType][message]}) {message}"); - } - } - } - - AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); - - return sb.ToString(); - } - - private static void AddExecutionSummaryReportSummary(IEnumerable requestLogs, StringBuilder sb) - { - static string getReadableMessageTypeForSummary(MessageType messageType) => messageType switch - { - MessageType.Chaos => "Requests with chaos", - MessageType.Failed => "Failures", - MessageType.InterceptedRequest => _requestsInterceptedMessage, - MessageType.Mocked => "Requests mocked", - MessageType.PassedThrough => _requestsPassedThroughMessage, - MessageType.Tip => "Tips", - MessageType.Warning => "Warnings", - _ => "Unknown" - }; - - var data = requestLogs - .Where(log => log.MessageType != MessageType.InterceptedResponse) - .Select(log => getReadableMessageTypeForSummary(log.MessageType)) - .OrderBy(log => log) - .GroupBy(log => log) - .ToDictionary(group => group.Key, group => group.Count()); - - sb.AppendLine(); - sb.AppendLine(":: Summary".ToUpper()); - sb.AppendLine(); - - foreach (var messageType in data.Keys) - { - sb.AppendLine($"{messageType} ({data[messageType]})"); - } - } - - private static string? TransformApiCenterProductionVersionReport(object report) - { - static string getReadableApiStatus(ApiCenterProductionVersionPluginReportItemStatus status) => status switch - { - ApiCenterProductionVersionPluginReportItemStatus.NotRegistered => "Not registered", - ApiCenterProductionVersionPluginReportItemStatus.NonProduction => "Non-production", - ApiCenterProductionVersionPluginReportItemStatus.Production => "Production", - _ => "Unknown" - }; - - var apiCenterProductionVersionReport = (ApiCenterProductionVersionPluginReport)report; - - var groupedPerStatus = apiCenterProductionVersionReport - .GroupBy(a => a.Status) - .OrderBy(g => (int)g.Key); - - var sb = new StringBuilder(); - - foreach (var group in groupedPerStatus) - { - sb.AppendLine($"{getReadableApiStatus(group.Key)} APIs:"); - sb.AppendLine(); - - sb.AppendJoin(Environment.NewLine, group.Select(a => $" {a.Method} {a.Url}")); - sb.AppendLine(); - sb.AppendLine(); - } - - return sb.ToString(); - } - - private static string? TransformApiCenterMinimalPermissionsReport(object report) - { - var apiCenterMinimalPermissionsReport = (ApiCenterMinimalPermissionsPluginReport)report; - - var sb = new StringBuilder(); - - sb.AppendLine("Azure API Center minimal permissions report") - .AppendLine(); - - sb.AppendLine("APIS") - .AppendLine(); - - if (apiCenterMinimalPermissionsReport.Results.Length != 0) - { - foreach (var apiResult in apiCenterMinimalPermissionsReport.Results) - { - sb.AppendFormat("{0}{1}", apiResult.ApiName, Environment.NewLine) - .AppendLine() - .AppendLine(apiResult.UsesMinimalPermissions ? "v Called using minimal permissions" : "x Called using excessive permissions") - .AppendLine() - .AppendLine("Permissions") - .AppendLine() - .AppendFormat("- Minimal permissions: {0}{1}", string.Join(", ", apiResult.MinimalPermissions.Order()), Environment.NewLine) - .AppendFormat("- Permissions on the token: {0}{1}", string.Join(", ", apiResult.TokenPermissions.Order()), Environment.NewLine) - .AppendFormat("- Excessive permissions: {0}{1}", apiResult.ExcessivePermissions.Any() ? string.Join(", ", apiResult.ExcessivePermissions.Order()) : "none", Environment.NewLine) - .AppendLine() - .AppendLine("Requests") - .AppendLine() - .AppendJoin(Environment.NewLine, apiResult.Requests.Select(r => $"- {r}")).AppendLine() - .AppendLine(); - } - } - else - { - sb.AppendLine("No APIs found.") - .AppendLine(); - } - - sb.AppendLine("UNMATCHED REQUESTS") - .AppendLine(); - - if (apiCenterMinimalPermissionsReport.UnmatchedRequests.Length != 0) - { - sb.AppendLine("The following requests were not matched to any API in API Center:") - .AppendLine() - .AppendJoin(Environment.NewLine, apiCenterMinimalPermissionsReport.UnmatchedRequests - .Select(r => $"- {r}").Order()).AppendLine() - .AppendLine(); - } - else - { - sb.AppendLine("No unmatched requests found.") - .AppendLine(); - } - - sb.AppendLine("ERRORS") - .AppendLine(); - - if (apiCenterMinimalPermissionsReport.Errors.Length != 0) - { - sb.AppendLine("The following errors occurred while determining minimal permissions:") - .AppendLine() - .AppendJoin(Environment.NewLine, apiCenterMinimalPermissionsReport.Errors - .OrderBy(o => o.Request) - .Select(e => $"- `{e.Request}`: {e.Error}")).AppendLine() - .AppendLine(); - } - else - { - sb.AppendLine("No errors occurred."); - } - - return sb.ToString(); - } - - private static string? TransformApiCenterOnboardingReport(object report) - { - var apiCenterOnboardingReport = (ApiCenterOnboardingPluginReport)report; - - if (apiCenterOnboardingReport.NewApis.Length == 0 && - apiCenterOnboardingReport.ExistingApis.Length == 0) - { - return null; - } - - var sb = new StringBuilder(); - - if (apiCenterOnboardingReport.NewApis.Length != 0) - { - var apisPerAuthority = apiCenterOnboardingReport.NewApis.GroupBy(x => - { - var u = new Uri(x.Url); - return u.GetLeftPart(UriPartial.Authority); - }); - - sb.AppendLine("New APIs that aren't registered in Azure API Center:"); - sb.AppendLine(); - - foreach (var apiPerAuthority in apisPerAuthority) - { - sb.AppendLine($"{apiPerAuthority.Key}:"); - sb.AppendJoin(Environment.NewLine, apiPerAuthority.Select(a => $" {a.Method} {a.Url}")); - sb.AppendLine(); - } - - sb.AppendLine(); - } - - if (apiCenterOnboardingReport.ExistingApis.Length != 0) - { - var apisPerAuthority = apiCenterOnboardingReport.ExistingApis.GroupBy(x => - { - var methodAndUrl = x.MethodAndUrl.Split(' '); - var u = new Uri(methodAndUrl[1]); - return u.GetLeftPart(UriPartial.Authority); - }); - - sb.AppendLine("APIs that are already registered in Azure API Center:"); - sb.AppendLine(); - - foreach (var apiPerAuthority in apisPerAuthority) - { - sb.AppendLine($"{apiPerAuthority.Key}:"); - sb.AppendJoin(Environment.NewLine, apiPerAuthority.Select(a => $" {a.MethodAndUrl}")); - sb.AppendLine(); - } - } - - return sb.ToString(); - } - - private static string? TransformMinimalPermissionsReport(object report) - { - var minimalPermissionsReport = (MinimalPermissionsPluginReport)report; - - var sb = new StringBuilder(); - - sb.AppendLine($"Minimal {minimalPermissionsReport.PermissionsType.ToString().ToLower()} permissions report"); - sb.AppendLine(); - sb.AppendLine("Requests:"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Requests.Select(r => $"- {r.Method} {r.Url}")); - sb.AppendLine(); - sb.AppendLine(); - sb.AppendLine("Minimal permissions:"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.MinimalPermissions.Select(p => $"- {p}")); - - if (minimalPermissionsReport.Errors.Any()) - { - sb.AppendLine(); - sb.AppendLine("Couldn't determine minimal permissions for the following URLs:"); - sb.AppendLine(); - sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Errors.Select(e => $"- {e}")); - } - - return sb.ToString(); - } - - private static string? TransformMinimalPermissionsGuidanceReport(object report) - { - var minimalPermissionsGuidanceReport = (MinimalPermissionsGuidancePluginReport)report; - - var sb = new StringBuilder(); - - void transformPermissionsInfo(MinimalPermissionsInfo permissionsInfo, string type) - { - sb.AppendLine($"{type} permissions for:"); - sb.AppendLine(); - sb.AppendLine(string.Join(Environment.NewLine, permissionsInfo.Operations.Select(o => $"- {o.Method} {o.Endpoint}"))); - sb.AppendLine(); - sb.AppendLine("Minimal permissions:"); - sb.AppendLine(); - sb.AppendLine(string.Join(", ", permissionsInfo.MinimalPermissions)); - sb.AppendLine(); - sb.AppendLine("Permissions on the token:"); - sb.AppendLine(); - sb.AppendLine(string.Join(", ", permissionsInfo.PermissionsFromTheToken)); - - if (permissionsInfo.ExcessPermissions.Any()) - { - sb.AppendLine(); - sb.AppendLine("The following permissions are unnecessary:"); - sb.AppendLine(); - sb.AppendLine(string.Join(", ", permissionsInfo.ExcessPermissions)); - } - else - { - sb.AppendLine(); - sb.AppendLine("The token has the minimal permissions required."); - } - - sb.AppendLine(); - } - - if (minimalPermissionsGuidanceReport.DelegatedPermissions is not null) - { - transformPermissionsInfo(minimalPermissionsGuidanceReport.DelegatedPermissions, "Delegated"); - } - if (minimalPermissionsGuidanceReport.ApplicationPermissions is not null) - { - transformPermissionsInfo(minimalPermissionsGuidanceReport.ApplicationPermissions, "Application"); - } - - if (minimalPermissionsGuidanceReport.ExcludedPermissions is not null && - minimalPermissionsGuidanceReport.ExcludedPermissions.Any()) - { - sb.AppendLine("Excluded: permissions:"); - sb.AppendLine(); - sb.AppendLine(string.Join(", ", minimalPermissionsGuidanceReport.ExcludedPermissions)); - } - - return sb.ToString(); - } +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.DevProxy.Abstractions; +using Microsoft.DevProxy.Plugins.RequestLogs; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DevProxy.Plugins.Reporters; + +public class PlainTextReporter(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReporter(pluginEvents, context, logger, urlsToWatch, configSection) +{ + public override string Name => nameof(PlainTextReporter); + public override string FileExtension => ".txt"; + + private readonly Dictionary> _transformers = new() + { + { typeof(ApiCenterMinimalPermissionsPluginReport), TransformApiCenterMinimalPermissionsReport }, + { typeof(ApiCenterOnboardingPluginReport), TransformApiCenterOnboardingReport }, + { typeof(ApiCenterProductionVersionPluginReport), TransformApiCenterProductionVersionReport }, + { typeof(ExecutionSummaryPluginReportByUrl), TransformExecutionSummaryByUrl }, + { typeof(ExecutionSummaryPluginReportByMessageType), TransformExecutionSummaryByMessageType }, + { typeof(HttpFileGeneratorPluginReport), TransformHttpFileGeneratorReport }, + { typeof(GraphMinimalPermissionsGuidancePluginReport), TransformMinimalPermissionsGuidanceReport }, + { typeof(GraphMinimalPermissionsPluginReport), TransformMinimalPermissionsReport }, + { typeof(OpenApiSpecGeneratorPluginReport), TransformOpenApiSpecGeneratorReport } + }; + + private const string _requestsInterceptedMessage = "Requests intercepted"; + private const string _requestsPassedThroughMessage = "Requests passed through"; + + protected override string? GetReport(KeyValuePair report) + { + Logger.LogDebug("Transforming {report}...", report.Key); + + var reportType = report.Value.GetType(); + + if (_transformers.TryGetValue(reportType, out var transform)) + { + Logger.LogDebug("Transforming {reportType} using {transform}...", reportType.Name, transform.Method.Name); + + return transform(report.Value); + } + else + { + Logger.LogDebug("No transformer found for {reportType}", reportType.Name); + return null; + } + } + + private static string? TransformHttpFileGeneratorReport(object report) + { + var httpFileGeneratorReport = (HttpFileGeneratorPluginReport)report; + + var sb = new StringBuilder(); + + sb.AppendLine("Generated HTTP files:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, httpFileGeneratorReport); + + return sb.ToString(); + } + + private static string? TransformOpenApiSpecGeneratorReport(object report) + { + var openApiSpecGeneratorReport = (OpenApiSpecGeneratorPluginReport)report; + + var sb = new StringBuilder(); + + sb.AppendLine("Generated OpenAPI specs:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, openApiSpecGeneratorReport.Select(i => $"- {i.FileName} ({i.ServerUrl})")); + + return sb.ToString(); + } + + private static string? TransformExecutionSummaryByMessageType(object report) + { + var executionSummaryReport = (ExecutionSummaryPluginReportByMessageType)report; + + var sb = new StringBuilder(); + + sb.AppendLine("Dev Proxy execution summary"); + sb.AppendLine($"({DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")})"); + sb.AppendLine(); + + sb.AppendLine(":: Message types".ToUpper()); + + var data = executionSummaryReport.Data; + var sortedMessageTypes = data.Keys.OrderBy(k => k); + foreach (var messageType in sortedMessageTypes) + { + sb.AppendLine(); + sb.AppendLine(messageType.ToUpper()); + + if (messageType == _requestsInterceptedMessage || + messageType == _requestsPassedThroughMessage) + { + sb.AppendLine(); + + var sortedMethodAndUrls = data[messageType][messageType].Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine($"- ({data[messageType][messageType][methodAndUrl]}) {methodAndUrl}"); + } + } + else + { + var sortedMessages = data[messageType].Keys.OrderBy(k => k); + foreach (var message in sortedMessages) + { + sb.AppendLine(); + sb.AppendLine(message); + sb.AppendLine(); + + var sortedMethodAndUrls = data[messageType][message].Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine($"- ({data[messageType][message][methodAndUrl]}) {methodAndUrl}"); + } + } + } + } + + AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); + + return sb.ToString(); + } + + private static string? TransformExecutionSummaryByUrl(object report) + { + var executionSummaryReport = (ExecutionSummaryPluginReportByUrl)report; + + var sb = new StringBuilder(); + + sb.AppendLine("Dev Proxy execution summary"); + sb.AppendLine($"({DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")})"); + sb.AppendLine(); + + sb.AppendLine(":: Requests".ToUpper()); + + var data = executionSummaryReport.Data; + var sortedMethodAndUrls = data.Keys.OrderBy(k => k); + foreach (var methodAndUrl in sortedMethodAndUrls) + { + sb.AppendLine(); + sb.AppendLine(methodAndUrl); + + var sortedMessageTypes = data[methodAndUrl].Keys.OrderBy(k => k); + foreach (var messageType in sortedMessageTypes) + { + sb.AppendLine(); + sb.AppendLine(messageType.ToUpper()); + sb.AppendLine(); + + var sortedMessages = data[methodAndUrl][messageType].Keys.OrderBy(k => k); + foreach (var message in sortedMessages) + { + sb.AppendLine($"- ({data[methodAndUrl][messageType][message]}) {message}"); + } + } + } + + AddExecutionSummaryReportSummary(executionSummaryReport.Logs, sb); + + return sb.ToString(); + } + + private static void AddExecutionSummaryReportSummary(IEnumerable requestLogs, StringBuilder sb) + { + static string getReadableMessageTypeForSummary(MessageType messageType) => messageType switch + { + MessageType.Chaos => "Requests with chaos", + MessageType.Failed => "Failures", + MessageType.InterceptedRequest => _requestsInterceptedMessage, + MessageType.Mocked => "Requests mocked", + MessageType.PassedThrough => _requestsPassedThroughMessage, + MessageType.Tip => "Tips", + MessageType.Warning => "Warnings", + _ => "Unknown" + }; + + var data = requestLogs + .Where(log => log.MessageType != MessageType.InterceptedResponse) + .Select(log => getReadableMessageTypeForSummary(log.MessageType)) + .OrderBy(log => log) + .GroupBy(log => log) + .ToDictionary(group => group.Key, group => group.Count()); + + sb.AppendLine(); + sb.AppendLine(":: Summary".ToUpper()); + sb.AppendLine(); + + foreach (var messageType in data.Keys) + { + sb.AppendLine($"{messageType} ({data[messageType]})"); + } + } + + private static string? TransformApiCenterProductionVersionReport(object report) + { + static string getReadableApiStatus(ApiCenterProductionVersionPluginReportItemStatus status) => status switch + { + ApiCenterProductionVersionPluginReportItemStatus.NotRegistered => "Not registered", + ApiCenterProductionVersionPluginReportItemStatus.NonProduction => "Non-production", + ApiCenterProductionVersionPluginReportItemStatus.Production => "Production", + _ => "Unknown" + }; + + var apiCenterProductionVersionReport = (ApiCenterProductionVersionPluginReport)report; + + var groupedPerStatus = apiCenterProductionVersionReport + .GroupBy(a => a.Status) + .OrderBy(g => (int)g.Key); + + var sb = new StringBuilder(); + + foreach (var group in groupedPerStatus) + { + sb.AppendLine($"{getReadableApiStatus(group.Key)} APIs:"); + sb.AppendLine(); + + sb.AppendJoin(Environment.NewLine, group.Select(a => $" {a.Method} {a.Url}")); + sb.AppendLine(); + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static string? TransformApiCenterMinimalPermissionsReport(object report) + { + var apiCenterMinimalPermissionsReport = (ApiCenterMinimalPermissionsPluginReport)report; + + var sb = new StringBuilder(); + + sb.AppendLine("Azure API Center minimal permissions report") + .AppendLine(); + + sb.AppendLine("APIS") + .AppendLine(); + + if (apiCenterMinimalPermissionsReport.Results.Length != 0) + { + foreach (var apiResult in apiCenterMinimalPermissionsReport.Results) + { + sb.AppendFormat("{0}{1}", apiResult.ApiName, Environment.NewLine) + .AppendLine() + .AppendLine(apiResult.UsesMinimalPermissions ? "v Called using minimal permissions" : "x Called using excessive permissions") + .AppendLine() + .AppendLine("Permissions") + .AppendLine() + .AppendFormat("- Minimal permissions: {0}{1}", string.Join(", ", apiResult.MinimalPermissions.Order()), Environment.NewLine) + .AppendFormat("- Permissions on the token: {0}{1}", string.Join(", ", apiResult.TokenPermissions.Order()), Environment.NewLine) + .AppendFormat("- Excessive permissions: {0}{1}", apiResult.ExcessivePermissions.Any() ? string.Join(", ", apiResult.ExcessivePermissions.Order()) : "none", Environment.NewLine) + .AppendLine() + .AppendLine("Requests") + .AppendLine() + .AppendJoin(Environment.NewLine, apiResult.Requests.Select(r => $"- {r}")).AppendLine() + .AppendLine(); + } + } + else + { + sb.AppendLine("No APIs found.") + .AppendLine(); + } + + sb.AppendLine("UNMATCHED REQUESTS") + .AppendLine(); + + if (apiCenterMinimalPermissionsReport.UnmatchedRequests.Length != 0) + { + sb.AppendLine("The following requests were not matched to any API in API Center:") + .AppendLine() + .AppendJoin(Environment.NewLine, apiCenterMinimalPermissionsReport.UnmatchedRequests + .Select(r => $"- {r}").Order()).AppendLine() + .AppendLine(); + } + else + { + sb.AppendLine("No unmatched requests found.") + .AppendLine(); + } + + sb.AppendLine("ERRORS") + .AppendLine(); + + if (apiCenterMinimalPermissionsReport.Errors.Length != 0) + { + sb.AppendLine("The following errors occurred while determining minimal permissions:") + .AppendLine() + .AppendJoin(Environment.NewLine, apiCenterMinimalPermissionsReport.Errors + .OrderBy(o => o.Request) + .Select(e => $"- `{e.Request}`: {e.Error}")).AppendLine() + .AppendLine(); + } + else + { + sb.AppendLine("No errors occurred."); + } + + return sb.ToString(); + } + + private static string? TransformApiCenterOnboardingReport(object report) + { + var apiCenterOnboardingReport = (ApiCenterOnboardingPluginReport)report; + + if (apiCenterOnboardingReport.NewApis.Length == 0 && + apiCenterOnboardingReport.ExistingApis.Length == 0) + { + return null; + } + + var sb = new StringBuilder(); + + if (apiCenterOnboardingReport.NewApis.Length != 0) + { + var apisPerAuthority = apiCenterOnboardingReport.NewApis.GroupBy(x => + { + var u = new Uri(x.Url); + return u.GetLeftPart(UriPartial.Authority); + }); + + sb.AppendLine("New APIs that aren't registered in Azure API Center:"); + sb.AppendLine(); + + foreach (var apiPerAuthority in apisPerAuthority) + { + sb.AppendLine($"{apiPerAuthority.Key}:"); + sb.AppendJoin(Environment.NewLine, apiPerAuthority.Select(a => $" {a.Method} {a.Url}")); + sb.AppendLine(); + } + + sb.AppendLine(); + } + + if (apiCenterOnboardingReport.ExistingApis.Length != 0) + { + var apisPerAuthority = apiCenterOnboardingReport.ExistingApis.GroupBy(x => + { + var methodAndUrl = x.MethodAndUrl.Split(' '); + var u = new Uri(methodAndUrl[1]); + return u.GetLeftPart(UriPartial.Authority); + }); + + sb.AppendLine("APIs that are already registered in Azure API Center:"); + sb.AppendLine(); + + foreach (var apiPerAuthority in apisPerAuthority) + { + sb.AppendLine($"{apiPerAuthority.Key}:"); + sb.AppendJoin(Environment.NewLine, apiPerAuthority.Select(a => $" {a.MethodAndUrl}")); + sb.AppendLine(); + } + } + + return sb.ToString(); + } + + private static string? TransformMinimalPermissionsReport(object report) + { + var minimalPermissionsReport = (GraphMinimalPermissionsPluginReport)report; + + var sb = new StringBuilder(); + + sb.AppendLine($"Minimal {minimalPermissionsReport.PermissionsType.ToString().ToLower()} permissions report"); + sb.AppendLine(); + sb.AppendLine("Requests:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Requests.Select(r => $"- {r.Method} {r.Url}")); + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("Minimal permissions:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.MinimalPermissions.Select(p => $"- {p}")); + + if (minimalPermissionsReport.Errors.Any()) + { + sb.AppendLine(); + sb.AppendLine("Couldn't determine minimal permissions for the following URLs:"); + sb.AppendLine(); + sb.AppendJoin(Environment.NewLine, minimalPermissionsReport.Errors.Select(e => $"- {e}")); + } + + return sb.ToString(); + } + + private static string? TransformMinimalPermissionsGuidanceReport(object report) + { + var minimalPermissionsGuidanceReport = (GraphMinimalPermissionsGuidancePluginReport)report; + + var sb = new StringBuilder(); + + void transformPermissionsInfo(GraphMinimalPermissionsInfo permissionsInfo, string type) + { + sb.AppendLine($"{type} permissions for:"); + sb.AppendLine(); + sb.AppendLine(string.Join(Environment.NewLine, permissionsInfo.Operations.Select(o => $"- {o.Method} {o.Endpoint}"))); + sb.AppendLine(); + sb.AppendLine("Minimal permissions:"); + sb.AppendLine(); + sb.AppendLine(string.Join(", ", permissionsInfo.MinimalPermissions)); + sb.AppendLine(); + sb.AppendLine("Permissions on the token:"); + sb.AppendLine(); + sb.AppendLine(string.Join(", ", permissionsInfo.PermissionsFromTheToken)); + + if (permissionsInfo.ExcessPermissions.Any()) + { + sb.AppendLine(); + sb.AppendLine("The following permissions are unnecessary:"); + sb.AppendLine(); + sb.AppendLine(string.Join(", ", permissionsInfo.ExcessPermissions)); + } + else + { + sb.AppendLine(); + sb.AppendLine("The token has the minimal permissions required."); + } + + sb.AppendLine(); + } + + if (minimalPermissionsGuidanceReport.DelegatedPermissions is not null) + { + transformPermissionsInfo(minimalPermissionsGuidanceReport.DelegatedPermissions, "Delegated"); + } + if (minimalPermissionsGuidanceReport.ApplicationPermissions is not null) + { + transformPermissionsInfo(minimalPermissionsGuidanceReport.ApplicationPermissions, "Application"); + } + + if (minimalPermissionsGuidanceReport.ExcludedPermissions is not null && + minimalPermissionsGuidanceReport.ExcludedPermissions.Any()) + { + sb.AppendLine("Excluded: permissions:"); + sb.AppendLine(); + sb.AppendLine(string.Join(", ", minimalPermissionsGuidanceReport.ExcludedPermissions)); + } + + return sb.ToString(); + } } \ No newline at end of file diff --git a/dev-proxy-plugins/RequestLogs/ApiCenterMinimalPermissionsPlugin.cs b/dev-proxy-plugins/RequestLogs/ApiCenterMinimalPermissionsPlugin.cs index 21c4fff9..b2628f91 100644 --- a/dev-proxy-plugins/RequestLogs/ApiCenterMinimalPermissionsPlugin.cs +++ b/dev-proxy-plugins/RequestLogs/ApiCenterMinimalPermissionsPlugin.cs @@ -1,401 +1,239 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Diagnostics; -using System.IdentityModel.Tokens.Jwt; -using Microsoft.DevProxy.Abstractions; -using Microsoft.DevProxy.Plugins.ApiCenter; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.OpenApi.Models; - -namespace Microsoft.DevProxy.Plugins.RequestLogs; - -public class ApiCenterMinimalPermissionsPluginReportApiResult -{ - public required string ApiId { get; init; } - public required string ApiName { get; init; } - public required string ApiDefinitionId { get; init; } - public required string[] Requests { get; init; } - public required string[] TokenPermissions { get; init; } - public required string[] MinimalPermissions { get; init; } - public required string[] ExcessivePermissions { get; init; } - public required bool UsesMinimalPermissions { get; init; } -} - -public class ApiCenterMinimalPermissionsPluginReportError -{ - public required string Request { get; init; } - public required string Error { get; init; } -} - -public class ApiCenterMinimalPermissionsPluginReport -{ - public required ApiCenterMinimalPermissionsPluginReportApiResult[] Results { get; init; } - public required string[] UnmatchedRequests { get; init; } - public required ApiCenterMinimalPermissionsPluginReportError[] Errors { get; init; } -} - -internal class ApiCenterMinimalPermissionsPluginConfiguration -{ - public string SubscriptionId { get; set; } = ""; - public string ResourceGroupName { get; set; } = ""; - public string ServiceName { get; set; } = ""; - public string WorkspaceName { get; set; } = "default"; -} - -public class ApiCenterMinimalPermissionsPlugin(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReportingPlugin(pluginEvents, context, logger, urlsToWatch, configSection) -{ - private readonly ApiCenterProductionVersionPluginConfiguration _configuration = new(); - private ApiCenterClient? _apiCenterClient; - private Api[]? _apis; - private Dictionary? _apiDefinitionsByUrl; - - public override string Name => nameof(ApiCenterMinimalPermissionsPlugin); - - public override async Task RegisterAsync() - { - await base.RegisterAsync(); - - ConfigSection?.Bind(_configuration); - - try - { - _apiCenterClient = new( - new() - { - SubscriptionId = _configuration.SubscriptionId, - ResourceGroupName = _configuration.ResourceGroupName, - ServiceName = _configuration.ServiceName, - WorkspaceName = _configuration.WorkspaceName - }, - Logger - ); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to create API Center client. The {plugin} will not be used.", Name); - return; - } - - Logger.LogInformation("Plugin {plugin} connecting to Azure...", Name); - try - { - _ = await _apiCenterClient.GetAccessTokenAsync(CancellationToken.None); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to authenticate with Azure. The {plugin} will not be used.", Name); - return; - } - Logger.LogDebug("Plugin {plugin} auth confirmed...", Name); - - PluginEvents.AfterRecordingStop += AfterRecordingStopAsync; - } - - private async Task AfterRecordingStopAsync(object sender, RecordingArgs e) - { - var interceptedRequests = e.RequestLogs - .Where(l => - l.MessageType == MessageType.InterceptedRequest && - !l.MessageLines.First().StartsWith("OPTIONS") && - l.Context?.Session is not null && - l.Context.Session.HttpClient.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)) - ); - if (!interceptedRequests.Any()) - { - Logger.LogDebug("No requests to process"); - return; - } - - Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API Center..."); - - Debug.Assert(_apiCenterClient is not null); - - _apis ??= await _apiCenterClient.GetApisAsync(); - if (_apis is null || _apis.Length == 0) - { - Logger.LogInformation("No APIs found in API Center"); - return; - } - - // get all API definitions by URL so that we can easily match - // API requests to API definitions, for permissions lookup - _apiDefinitionsByUrl ??= await _apis.GetApiDefinitionsByUrlAsync(_apiCenterClient, Logger); - - var (requestsByApiDefinition, unmatchedApicRequests) = GetRequestsByApiDefinition(interceptedRequests, _apiDefinitionsByUrl); - - var errors = new List(); - var results = new List(); - var unmatchedRequests = new List( - unmatchedApicRequests.Select(r => r.MessageLines.First()) - ); - - foreach (var (apiDefinition, requests) in requestsByApiDefinition) - { - var ( - tokenPermissions, - operationsFromRequests, - minimalScopes, - unmatchedOperations, - errorsForApi - ) = CheckMinimalPermissions(requests, apiDefinition); - - var api = _apis.FindApiByDefinition(apiDefinition, Logger); - var result = new ApiCenterMinimalPermissionsPluginReportApiResult - { - ApiId = api?.Id ?? "unknown", - ApiName = api?.Properties?.Title ?? "unknown", - ApiDefinitionId = apiDefinition.Id!, - Requests = operationsFromRequests - .Select(o => $"{o.method} {o.originalUrl}") - .Distinct() - .ToArray(), - TokenPermissions = tokenPermissions.Distinct().ToArray(), - MinimalPermissions = minimalScopes, - ExcessivePermissions = tokenPermissions.Except(minimalScopes).ToArray(), - UsesMinimalPermissions = !tokenPermissions.Except(minimalScopes).Any() - }; - results.Add(result); - - var unmatchedApiRequests = operationsFromRequests - .Where(o => unmatchedOperations.Contains($"{o.method} {o.tokenizedUrl}")) - .Select(o => $"{o.method} {o.originalUrl}"); - unmatchedRequests.AddRange(unmatchedApiRequests); - errors.AddRange(errorsForApi); - - if (result.UsesMinimalPermissions) - { - Logger.LogInformation( - "API {apiName} is called with minimal permissions: {minimalPermissions}", - result.ApiName, - string.Join(", ", result.MinimalPermissions) - ); - } - else - { - Logger.LogWarning( - "Calling API {apiName} with excessive permissions: {excessivePermissions}. Minimal permissions are: {minimalPermissions}", - result.ApiName, - string.Join(", ", result.ExcessivePermissions), - string.Join(", ", result.MinimalPermissions) - ); - } - - if (unmatchedApiRequests.Any()) - { - Logger.LogWarning( - "Unmatched requests for API {apiName}:{newLine}- {unmatchedRequests}", - result.ApiName, - Environment.NewLine, - string.Join($"{Environment.NewLine}- ", unmatchedApiRequests) - ); - } - - if (errorsForApi.Count != 0) - { - Logger.LogWarning( - "Errors for API {apiName}:{newLine}- {errors}", - result.ApiName, - Environment.NewLine, - string.Join($"{Environment.NewLine}- ", errorsForApi.Select(e => $"{e.Request}: {e.Error}")) - ); - } - } - - var report = new ApiCenterMinimalPermissionsPluginReport() - { - Results = [.. results], - UnmatchedRequests = [.. unmatchedRequests], - Errors = [.. errors] - }; - - StoreReport(report, e); - } - - private ( - List tokenPermissions, - List<(string method, string originalUrl, string tokenizedUrl)> operationsFromRequests, - string[] minimalScopes, - string[] unmatchedOperations, - List errors - ) CheckMinimalPermissions(IEnumerable requests, ApiDefinition apiDefinition) - { - Logger.LogInformation("Checking minimal permissions for API {apiName}...", apiDefinition.Definition!.Servers.First().Url); - - var tokenPermissions = new List(); - var operationsFromRequests = new List<(string method, string originalUrl, string tokenizedUrl)>(); - var operationsAndScopes = new Dictionary(); - var errors = new List(); - - foreach (var request in requests) - { - // get scopes from the token - var methodAndUrl = request.MessageLines.First(); - var methodAndUrlChunks = methodAndUrl.Split(' '); - Logger.LogDebug("Checking request {request}...", methodAndUrl); - var (method, url) = (methodAndUrlChunks[0].ToUpper(), methodAndUrlChunks[1]); - - var scopesFromTheToken = GetScopesFromToken(request.Context?.Session.HttpClient.Request.Headers.First(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)).Value); - if (scopesFromTheToken.Length != 0) - { - tokenPermissions.AddRange(scopesFromTheToken); - } - else - { - errors.Add(new() - { - Request = methodAndUrl, - Error = "No scopes found in the token" - }); - } - - // get allowed scopes for the operation - if (!Enum.TryParse(method, true, out var operationType)) - { - errors.Add(new() - { - Request = methodAndUrl, - Error = $"{method} is not a valid HTTP method" - }); - continue; - } - - var pathItem = apiDefinition.Definition!.FindMatchingPathItem(url, Logger); - if (pathItem is null) - { - errors.Add(new() - { - Request = methodAndUrl, - Error = "No matching path item found" - }); - continue; - } - - if (!pathItem.Value.Value.Operations.TryGetValue(operationType, out var operation)) - { - errors.Add(new() - { - Request = methodAndUrl, - Error = "No matching operation found" - }); - continue; - } - - var scopes = operation.GetEffectiveScopes(apiDefinition.Definition!, Logger); - if (scopes.Length != 0) - { - operationsAndScopes[$"{method} {pathItem.Value.Key}"] = scopes; - } - - operationsFromRequests.Add((operationType.ToString().ToUpper(), url, pathItem.Value.Key)); - } - - var (minimalScopes, unmatchedOperations) = GetMinimalScopes( - operationsFromRequests - .Select(o => $"{o.method} {o.tokenizedUrl}") - .Distinct() - .ToArray(), - operationsAndScopes - ); - - return (tokenPermissions, operationsFromRequests, minimalScopes, unmatchedOperations, errors); - } - - /// - /// Gets the scopes from the JWT token. - /// - /// The JWT token including the 'Bearer' prefix. - /// The scopes from the JWT token or empty array if no scopes found or error occurred. - private string[] GetScopesFromToken(string? jwtToken) - { - Logger.LogDebug("Getting scopes from JWT token..."); - - if (string.IsNullOrEmpty(jwtToken)) - { - return []; - } - - try - { - var token = jwtToken.Split(' ')[1]; - var handler = new JwtSecurityTokenHandler(); - var jsonToken = handler.ReadToken(token) as JwtSecurityToken; - var scopes = jsonToken?.Claims - .Where(c => c.Type == "scp") - .Select(c => c.Value) - .ToArray() ?? []; - - Logger.LogDebug("Scopes found in the token: {scopes}", string.Join(", ", scopes)); - return scopes; - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to parse JWT token"); - return []; - } - } - - private (Dictionary> RequestsByApiDefinition, IEnumerable UnmatchedRequests) GetRequestsByApiDefinition(IEnumerable interceptedRequests, Dictionary apiDefinitionsByUrl) - { - var unmatchedRequests = new List(); - var requestsByApiDefinition = new Dictionary>(); - foreach (var request in interceptedRequests) - { - var url = request.MessageLines.First().Split(' ')[1]; - Logger.LogDebug("Matching request {requestUrl} to API definitions...", url); - - var matchingKey = apiDefinitionsByUrl.Keys.FirstOrDefault(url.StartsWith); - if (matchingKey is null) - { - Logger.LogDebug("No matching API definition found for {requestUrl}", url); - unmatchedRequests.Add(request); - continue; - } - - if (!requestsByApiDefinition.TryGetValue(apiDefinitionsByUrl[matchingKey], out List? value)) - { - value = []; - requestsByApiDefinition[apiDefinitionsByUrl[matchingKey]] = value; - } - - value.Add(request); - } - - return (requestsByApiDefinition, unmatchedRequests); - } - - static (string[] minimalScopes, string[] unmatchedOperations) GetMinimalScopes(string[] requests, Dictionary operationsAndScopes) - { - var unmatchedOperations = requests - .Where(o => !operationsAndScopes.Keys.Contains(o, StringComparer.OrdinalIgnoreCase)) - .ToArray(); - - var minimalScopesPerOperation = operationsAndScopes - .Where(o => requests.Contains(o.Key, StringComparer.OrdinalIgnoreCase)) - .Select(o => new KeyValuePair(o.Key, o.Value.First())) - .ToDictionary(); - - // for each minimal scope check if it overrules any other minimal scope - // (position > 0, because the minimal scope is always first). if it does, - // replace the minimal scope with the overruling scope - foreach (var scope in minimalScopesPerOperation.Values) - { - foreach (var minimalScope in minimalScopesPerOperation) - { - if (Array.IndexOf(operationsAndScopes[minimalScope.Key], scope) > 0) - { - minimalScopesPerOperation[minimalScope.Key] = scope; - } - } - } - - return ( - minimalScopesPerOperation - .Select(s => s.Value) - .Distinct() - .ToArray(), - unmatchedOperations - ); - } +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using Microsoft.DevProxy.Abstractions; +using Microsoft.DevProxy.Plugins.ApiCenter; +using Microsoft.DevProxy.Plugins.MinimalPermissions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi.Models; + +namespace Microsoft.DevProxy.Plugins.RequestLogs; + +public class ApiCenterMinimalPermissionsPluginReportApiResult +{ + public required string ApiId { get; init; } + public required string ApiName { get; init; } + public required string ApiDefinitionId { get; init; } + public required string[] Requests { get; init; } + public required string[] TokenPermissions { get; init; } + public required string[] MinimalPermissions { get; init; } + public required string[] ExcessivePermissions { get; init; } + public required bool UsesMinimalPermissions { get; init; } +} + +public class ApiCenterMinimalPermissionsPluginReport +{ + public required ApiCenterMinimalPermissionsPluginReportApiResult[] Results { get; init; } + public required string[] UnmatchedRequests { get; init; } + public required ApiPermissionError[] Errors { get; init; } +} + +internal class ApiCenterMinimalPermissionsPluginConfiguration +{ + public string SubscriptionId { get; set; } = ""; + public string ResourceGroupName { get; set; } = ""; + public string ServiceName { get; set; } = ""; + public string WorkspaceName { get; set; } = "default"; +} + +public class ApiCenterMinimalPermissionsPlugin(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReportingPlugin(pluginEvents, context, logger, urlsToWatch, configSection) +{ + private readonly ApiCenterProductionVersionPluginConfiguration _configuration = new(); + private ApiCenterClient? _apiCenterClient; + private Api[]? _apis; + private Dictionary? _apiDefinitionsByUrl; + + public override string Name => nameof(ApiCenterMinimalPermissionsPlugin); + + public override async Task RegisterAsync() + { + await base.RegisterAsync(); + + ConfigSection?.Bind(_configuration); + + try + { + _apiCenterClient = new( + new() + { + SubscriptionId = _configuration.SubscriptionId, + ResourceGroupName = _configuration.ResourceGroupName, + ServiceName = _configuration.ServiceName, + WorkspaceName = _configuration.WorkspaceName + }, + Logger + ); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to create API Center client. The {plugin} will not be used.", Name); + return; + } + + Logger.LogInformation("Plugin {plugin} connecting to Azure...", Name); + try + { + _ = await _apiCenterClient.GetAccessTokenAsync(CancellationToken.None); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to authenticate with Azure. The {plugin} will not be used.", Name); + return; + } + Logger.LogDebug("Plugin {plugin} auth confirmed...", Name); + + PluginEvents.AfterRecordingStop += AfterRecordingStopAsync; + } + + private async Task AfterRecordingStopAsync(object sender, RecordingArgs e) + { + var interceptedRequests = e.RequestLogs + .Where(l => + l.MessageType == MessageType.InterceptedRequest && + !l.MessageLines.First().StartsWith("OPTIONS") && + l.Context?.Session is not null && + l.Context.Session.HttpClient.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)) + ); + if (!interceptedRequests.Any()) + { + Logger.LogDebug("No requests to process"); + return; + } + + Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API Center..."); + + Debug.Assert(_apiCenterClient is not null); + + _apis ??= await _apiCenterClient.GetApisAsync(); + if (_apis is null || _apis.Length == 0) + { + Logger.LogInformation("No APIs found in API Center"); + return; + } + + // get all API definitions by URL so that we can easily match + // API requests to API definitions, for permissions lookup + _apiDefinitionsByUrl ??= await _apis.GetApiDefinitionsByUrlAsync(_apiCenterClient, Logger); + + var (requestsByApiDefinition, unmatchedApicRequests) = GetRequestsByApiDefinition(interceptedRequests, _apiDefinitionsByUrl); + + var errors = new List(); + var results = new List(); + var unmatchedRequests = new List( + unmatchedApicRequests.Select(r => r.MessageLines.First()) + ); + + foreach (var (apiDefinition, requests) in requestsByApiDefinition) + { + var minimalPermissions = CheckMinimalPermissions(requests, apiDefinition); + + var api = _apis.FindApiByDefinition(apiDefinition, Logger); + var result = new ApiCenterMinimalPermissionsPluginReportApiResult + { + ApiId = api?.Id ?? "unknown", + ApiName = api?.Properties?.Title ?? "unknown", + ApiDefinitionId = apiDefinition.Id!, + Requests = minimalPermissions.OperationsFromRequests + .Select(o => $"{o.Method} {o.OriginalUrl}") + .Distinct() + .ToArray(), + TokenPermissions = minimalPermissions.TokenPermissions.Distinct().ToArray(), + MinimalPermissions = minimalPermissions.MinimalScopes, + ExcessivePermissions = minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes).ToArray(), + UsesMinimalPermissions = !minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes).Any() + }; + results.Add(result); + + var unmatchedApiRequests = minimalPermissions.OperationsFromRequests + .Where(o => minimalPermissions.UnmatchedOperations.Contains($"{o.Method} {o.TokenizedUrl}")) + .Select(o => $"{o.Method} {o.OriginalUrl}"); + unmatchedRequests.AddRange(unmatchedApiRequests); + errors.AddRange(minimalPermissions.Errors); + + if (result.UsesMinimalPermissions) + { + Logger.LogInformation( + "API {apiName} is called with minimal permissions: {minimalPermissions}", + result.ApiName, + string.Join(", ", result.MinimalPermissions) + ); + } + else + { + Logger.LogWarning( + "Calling API {apiName} with excessive permissions: {excessivePermissions}. Minimal permissions are: {minimalPermissions}", + result.ApiName, + string.Join(", ", result.ExcessivePermissions), + string.Join(", ", result.MinimalPermissions) + ); + } + + if (unmatchedApiRequests.Any()) + { + Logger.LogWarning( + "Unmatched requests for API {apiName}:{newLine}- {unmatchedRequests}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", unmatchedApiRequests) + ); + } + + if (minimalPermissions.Errors.Count != 0) + { + Logger.LogWarning( + "Errors for API {apiName}:{newLine}- {errors}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", minimalPermissions.Errors.Select(e => $"{e.Request}: {e.Error}")) + ); + } + } + + var report = new ApiCenterMinimalPermissionsPluginReport() + { + Results = [.. results], + UnmatchedRequests = [.. unmatchedRequests], + Errors = [.. errors] + }; + + StoreReport(report, e); + } + + private ApiPermissionsInfo CheckMinimalPermissions(IEnumerable requests, ApiDefinition apiDefinition) + { + Logger.LogInformation("Checking minimal permissions for API {apiName}...", apiDefinition.Definition!.Servers.First().Url); + + return apiDefinition.Definition.CheckMinimalPermissions(requests, Logger); + } + + private (Dictionary> RequestsByApiDefinition, IEnumerable UnmatchedRequests) GetRequestsByApiDefinition(IEnumerable interceptedRequests, Dictionary apiDefinitionsByUrl) + { + var unmatchedRequests = new List(); + var requestsByApiDefinition = new Dictionary>(); + foreach (var request in interceptedRequests) + { + var url = request.MessageLines.First().Split(' ')[1]; + Logger.LogDebug("Matching request {requestUrl} to API definitions...", url); + + var matchingKey = apiDefinitionsByUrl.Keys.FirstOrDefault(url.StartsWith); + if (matchingKey is null) + { + Logger.LogDebug("No matching API definition found for {requestUrl}", url); + unmatchedRequests.Add(request); + continue; + } + + if (!requestsByApiDefinition.TryGetValue(apiDefinitionsByUrl[matchingKey], out List? value)) + { + value = []; + requestsByApiDefinition[apiDefinitionsByUrl[matchingKey]] = value; + } + + value.Add(request); + } + + return (requestsByApiDefinition, unmatchedRequests); + } } \ No newline at end of file diff --git a/dev-proxy-plugins/RequestLogs/MinimalPermissionsGuidancePlugin.cs b/dev-proxy-plugins/RequestLogs/GraphMinimalPermissionsGuidancePlugin.cs similarity index 81% rename from dev-proxy-plugins/RequestLogs/MinimalPermissionsGuidancePlugin.cs rename to dev-proxy-plugins/RequestLogs/GraphMinimalPermissionsGuidancePlugin.cs index c8be5a0b..8dd00a78 100644 --- a/dev-proxy-plugins/RequestLogs/MinimalPermissionsGuidancePlugin.cs +++ b/dev-proxy-plugins/RequestLogs/GraphMinimalPermissionsGuidancePlugin.cs @@ -1,364 +1,364 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.DevProxy.Abstractions; -using System.IdentityModel.Tokens.Jwt; -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.DevProxy.Plugins.MinimalPermissions; - -namespace Microsoft.DevProxy.Plugins.RequestLogs; - -public class MinimalPermissionsGuidancePluginReport -{ - public MinimalPermissionsInfo? DelegatedPermissions { get; set; } - public MinimalPermissionsInfo? ApplicationPermissions { get; set; } - public IEnumerable? ExcludedPermissions { get; set; } -} - -public class OperationInfo -{ - public string Method { get; set; } = string.Empty; - public string Endpoint { get; set; } = string.Empty; -} - -public class MinimalPermissionsInfo -{ - public IEnumerable MinimalPermissions { get; set; } = []; - public IEnumerable PermissionsFromTheToken { get; set; } = []; - public IEnumerable ExcessPermissions { get; set; } = []; - public OperationInfo[] Operations { get; set; } = []; -} - -internal class MinimalPermissionsGuidancePluginConfiguration -{ - public IEnumerable? PermissionsToExclude { get; set; } -} - -public class MinimalPermissionsGuidancePlugin(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReportingPlugin(pluginEvents, context, logger, urlsToWatch, configSection) -{ - public override string Name => nameof(MinimalPermissionsGuidancePlugin); - private readonly MinimalPermissionsGuidancePluginConfiguration _configuration = new(); - - public override async Task RegisterAsync() - { - await base.RegisterAsync(); - - ConfigSection?.Bind(_configuration); - // we need to do it this way because .NET doesn't distinguish between - // an empty array and a null value and we want to be able to tell - // if the user hasn't specified a value and we should use the default - // set or if they have specified an empty array and we shouldn't exclude - // any permissions - if (_configuration.PermissionsToExclude is null) - { - _configuration.PermissionsToExclude = ["profile", "openid", "offline_access", "email"]; - } - else { - // remove empty strings - _configuration.PermissionsToExclude = _configuration.PermissionsToExclude.Where(p => !string.IsNullOrEmpty(p)); - } - - PluginEvents.AfterRecordingStop += AfterRecordingStopAsync; - } - - private async Task AfterRecordingStopAsync(object? sender, RecordingArgs e) - { - if (!e.RequestLogs.Any()) - { - return; - } - - var methodAndUrlComparer = new MethodAndUrlComparer(); - var delegatedEndpoints = new List<(string method, string url)>(); - var applicationEndpoints = new List<(string method, string url)>(); - - // scope for delegated permissions - IEnumerable scopesToEvaluate = []; - // roles for application permissions - IEnumerable rolesToEvaluate = []; - - foreach (var request in e.RequestLogs) - { - if (request.MessageType != MessageType.InterceptedRequest) - { - continue; - } - - var methodAndUrlString = request.MessageLines.First(); - var methodAndUrl = GetMethodAndUrl(methodAndUrlString); - if (methodAndUrl.method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var requestsFromBatch = Array.Empty<(string method, string url)>(); - - var uri = new Uri(methodAndUrl.url); - if (!ProxyUtils.IsGraphUrl(uri)) - { - continue; - } - - if (ProxyUtils.IsGraphBatchUrl(uri)) - { - var graphVersion = ProxyUtils.IsGraphBetaUrl(uri) ? "beta" : "v1.0"; - requestsFromBatch = GetRequestsFromBatch(request.Context?.Session.HttpClient.Request.BodyString!, graphVersion, uri.Host); - } - else - { - methodAndUrl = (methodAndUrl.method, GetTokenizedUrl(methodAndUrl.url)); - } - - var scopesAndType = GetPermissionsAndType(request); - if (scopesAndType.type == PermissionsType.Delegated) - { - // use the scopes from the last request in case the app is using incremental consent - scopesToEvaluate = scopesAndType.permissions; - - if (ProxyUtils.IsGraphBatchUrl(uri)) - { - delegatedEndpoints.AddRange(requestsFromBatch); - } - else - { - delegatedEndpoints.Add(methodAndUrl); - } - } - else - { - // skip empty roles which are returned in case we couldn't get permissions information - // - // application permissions are always the same because they come from app reg - // so we can just use the first request that has them - if (scopesAndType.permissions.Any() && !rolesToEvaluate.Any()) - { - rolesToEvaluate = scopesAndType.permissions; - - if (ProxyUtils.IsGraphBatchUrl(uri)) - { - applicationEndpoints.AddRange(requestsFromBatch); - } - else - { - applicationEndpoints.Add(methodAndUrl); - } - } - } - } - - // Remove duplicates - delegatedEndpoints = delegatedEndpoints.Distinct(methodAndUrlComparer).ToList(); - applicationEndpoints = applicationEndpoints.Distinct(methodAndUrlComparer).ToList(); - - if (delegatedEndpoints.Count == 0 && applicationEndpoints.Count == 0) - { - return; - } - - var report = new MinimalPermissionsGuidancePluginReport - { - ExcludedPermissions = _configuration.PermissionsToExclude - }; - - Logger.LogWarning("This plugin is in preview and may not return the correct results.\r\nPlease review the permissions and test your app before using them in production.\r\nIf you have any feedback, please open an issue at https://aka.ms/devproxy/issue.\r\n"); - - if (_configuration.PermissionsToExclude is not null && - _configuration.PermissionsToExclude.Any()) - { - Logger.LogInformation("Excluding the following permissions: {permissions}", string.Join(", ", _configuration.PermissionsToExclude)); - } - - if (delegatedEndpoints.Count > 0) - { - var delegatedPermissionsInfo = new MinimalPermissionsInfo(); - report.DelegatedPermissions = delegatedPermissionsInfo; - - Logger.LogInformation("Evaluating delegated permissions for: {endpoints}", string.Join(", ", delegatedEndpoints.Select(e => $"{e.method} {e.url}"))); - - await EvaluateMinimalScopesAsync(delegatedEndpoints, scopesToEvaluate, PermissionsType.Delegated, delegatedPermissionsInfo); - } - - if (applicationEndpoints.Count > 0) - { - var applicationPermissionsInfo = new MinimalPermissionsInfo(); - report.ApplicationPermissions = applicationPermissionsInfo; - - Logger.LogInformation("Evaluating application permissions for: {endpoints}", string.Join(", ", applicationEndpoints.Select(e => $"{e.method} {e.url}"))); - - await EvaluateMinimalScopesAsync(applicationEndpoints, rolesToEvaluate, PermissionsType.Application, applicationPermissionsInfo); - } - - StoreReport(report, e); - } - - private static (string method, string url)[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) - { - var requests = new List<(string method, string url)>(); - - if (string.IsNullOrEmpty(batchBody)) - { - return [.. requests]; - } - - try - { - var batch = JsonSerializer.Deserialize(batchBody, ProxyUtils.JsonSerializerOptions); - if (batch == null) - { - return [.. requests]; - } - - foreach (var request in batch.Requests) - { - try - { - var method = request.Method; - var url = request.Url; - var absoluteUrl = $"https://{graphHostName}/{graphVersion}{url}"; - requests.Add((method, GetTokenizedUrl(absoluteUrl))); - } - catch { } - } - } - catch { } - - return [.. requests]; - } - - /// - /// Returns permissions and type (delegated or application) from the access token - /// used on the request. - /// If it can't get the permissions, returns PermissionType.Application - /// and an empty array - /// - private static (PermissionsType type, IEnumerable permissions) GetPermissionsAndType(RequestLog request) - { - var authHeader = request.Context?.Session.HttpClient.Request.Headers.GetFirstHeader("Authorization"); - if (authHeader == null) - { - return (PermissionsType.Application, []); - } - - var token = authHeader.Value.Replace("Bearer ", string.Empty); - var tokenChunks = token.Split('.'); - if (tokenChunks.Length != 3) - { - return (PermissionsType.Application, []); - } - - try - { - var handler = new JwtSecurityTokenHandler(); - var jwtSecurityToken = handler.ReadJwtToken(token); - - var scopeClaim = jwtSecurityToken.Claims.FirstOrDefault(c => c.Type == "scp"); - if (scopeClaim == null) - { - // possibly an application token - // roles is an array so we need to handle it differently - var roles = jwtSecurityToken.Claims - .Where(c => c.Type == "roles") - .Select(c => c.Value); - if (!roles.Any()) - { - return (PermissionsType.Application, []); - } - else - { - return (PermissionsType.Application, roles); - } - } - else - { - return (PermissionsType.Delegated, scopeClaim.Value.Split(' ')); - } - } - catch - { - return (PermissionsType.Application, []); - } - } - - private async Task EvaluateMinimalScopesAsync(IEnumerable<(string method, string url)> endpoints, IEnumerable permissionsFromAccessToken, PermissionsType scopeType, MinimalPermissionsInfo permissionsInfo) - { - var payload = endpoints.Select(e => new RequestInfo { Method = e.method, Url = e.url }); - - permissionsInfo.Operations = endpoints.Select(e => new OperationInfo - { - Method = e.method, - Endpoint = e.url - }).ToArray(); - permissionsInfo.PermissionsFromTheToken = permissionsFromAccessToken; - - try - { - var url = $"https://graphexplorerapi.azurewebsites.net/permissions?scopeType={GraphUtils.GetScopeTypeString(scopeType)}"; - using var client = new HttpClient(); - var stringPayload = JsonSerializer.Serialize(payload, ProxyUtils.JsonSerializerOptions); - Logger.LogDebug(string.Format("Calling {0} with payload{1}{2}", url, Environment.NewLine, stringPayload)); - - var response = await client.PostAsJsonAsync(url, payload); - var content = await response.Content.ReadAsStringAsync(); - - Logger.LogDebug(string.Format("Response:{0}{1}", Environment.NewLine, content)); - - var resultsAndErrors = JsonSerializer.Deserialize(content, ProxyUtils.JsonSerializerOptions); - var minimalPermissions = resultsAndErrors?.Results?.Select(p => p.Value) ?? []; - var errors = resultsAndErrors?.Errors?.Select(e => $"- {e.Url} ({e.Message})") ?? []; - - if (scopeType == PermissionsType.Delegated) - { - minimalPermissions = await GraphUtils.UpdateUserScopesAsync(minimalPermissions, endpoints, scopeType, Logger); - } - - if (minimalPermissions.Any()) - { - var excessPermissions = permissionsFromAccessToken - .Except(_configuration.PermissionsToExclude ?? []) - .Where(p => !minimalPermissions.Contains(p)); - - permissionsInfo.MinimalPermissions = minimalPermissions; - permissionsInfo.ExcessPermissions = excessPermissions; - - Logger.LogInformation("Minimal permissions: {minimalPermissions}", string.Join(", ", minimalPermissions)); - Logger.LogInformation("Permissions on the token: {tokenPermissions}", string.Join(", ", permissionsFromAccessToken)); - - if (excessPermissions.Any()) - { - Logger.LogWarning("The following permissions are unnecessary: {permissions}", string.Join(", ", excessPermissions)); - } - else - { - Logger.LogInformation("The token has the minimal permissions required."); - } - } - if (errors.Any()) - { - Logger.LogError("Couldn't determine minimal permissions for the following URLs: {errors}", string.Join(", ", errors)); - } - } - catch (Exception ex) - { - Logger.LogError(ex, "An error has occurred while retrieving minimal permissions: {message}", ex.Message); - } - } - - private static (string method, string url) GetMethodAndUrl(string message) - { - var info = message.Split(" "); - if (info.Length > 2) - { - info = [info[0], string.Join(" ", info.Skip(1))]; - } - return (method: info[0], url: info[1]); - } - - private static string GetTokenizedUrl(string absoluteUrl) - { - var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); - return "/" + string.Join("", new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString)); - } -} +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.DevProxy.Abstractions; +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.DevProxy.Plugins.MinimalPermissions; + +namespace Microsoft.DevProxy.Plugins.RequestLogs; + +public class GraphMinimalPermissionsGuidancePluginReport +{ + public GraphMinimalPermissionsInfo? DelegatedPermissions { get; set; } + public GraphMinimalPermissionsInfo? ApplicationPermissions { get; set; } + public IEnumerable? ExcludedPermissions { get; set; } +} + +public class GraphMinimalPermissionsOperationInfo +{ + public string Method { get; set; } = string.Empty; + public string Endpoint { get; set; } = string.Empty; +} + +public class GraphMinimalPermissionsInfo +{ + public IEnumerable MinimalPermissions { get; set; } = []; + public IEnumerable PermissionsFromTheToken { get; set; } = []; + public IEnumerable ExcessPermissions { get; set; } = []; + public GraphMinimalPermissionsOperationInfo[] Operations { get; set; } = []; +} + +internal class GraphMinimalPermissionsGuidancePluginConfiguration +{ + public IEnumerable? PermissionsToExclude { get; set; } +} + +public class GraphMinimalPermissionsGuidancePlugin(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReportingPlugin(pluginEvents, context, logger, urlsToWatch, configSection) +{ + public override string Name => nameof(GraphMinimalPermissionsGuidancePlugin); + private readonly GraphMinimalPermissionsGuidancePluginConfiguration _configuration = new(); + + public override async Task RegisterAsync() + { + await base.RegisterAsync(); + + ConfigSection?.Bind(_configuration); + // we need to do it this way because .NET doesn't distinguish between + // an empty array and a null value and we want to be able to tell + // if the user hasn't specified a value and we should use the default + // set or if they have specified an empty array and we shouldn't exclude + // any permissions + if (_configuration.PermissionsToExclude is null) + { + _configuration.PermissionsToExclude = ["profile", "openid", "offline_access", "email"]; + } + else { + // remove empty strings + _configuration.PermissionsToExclude = _configuration.PermissionsToExclude.Where(p => !string.IsNullOrEmpty(p)); + } + + PluginEvents.AfterRecordingStop += AfterRecordingStopAsync; + } + + private async Task AfterRecordingStopAsync(object? sender, RecordingArgs e) + { + if (!e.RequestLogs.Any()) + { + return; + } + + var methodAndUrlComparer = new MethodAndUrlComparer(); + var delegatedEndpoints = new List<(string method, string url)>(); + var applicationEndpoints = new List<(string method, string url)>(); + + // scope for delegated permissions + IEnumerable scopesToEvaluate = []; + // roles for application permissions + IEnumerable rolesToEvaluate = []; + + foreach (var request in e.RequestLogs) + { + if (request.MessageType != MessageType.InterceptedRequest) + { + continue; + } + + var methodAndUrlString = request.MessageLines.First(); + var methodAndUrl = GetMethodAndUrl(methodAndUrlString); + if (methodAndUrl.method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var requestsFromBatch = Array.Empty<(string method, string url)>(); + + var uri = new Uri(methodAndUrl.url); + if (!ProxyUtils.IsGraphUrl(uri)) + { + continue; + } + + if (ProxyUtils.IsGraphBatchUrl(uri)) + { + var graphVersion = ProxyUtils.IsGraphBetaUrl(uri) ? "beta" : "v1.0"; + requestsFromBatch = GetRequestsFromBatch(request.Context?.Session.HttpClient.Request.BodyString!, graphVersion, uri.Host); + } + else + { + methodAndUrl = (methodAndUrl.method, GetTokenizedUrl(methodAndUrl.url)); + } + + var scopesAndType = GetPermissionsAndType(request); + if (scopesAndType.type == GraphPermissionsType.Delegated) + { + // use the scopes from the last request in case the app is using incremental consent + scopesToEvaluate = scopesAndType.permissions; + + if (ProxyUtils.IsGraphBatchUrl(uri)) + { + delegatedEndpoints.AddRange(requestsFromBatch); + } + else + { + delegatedEndpoints.Add(methodAndUrl); + } + } + else + { + // skip empty roles which are returned in case we couldn't get permissions information + // + // application permissions are always the same because they come from app reg + // so we can just use the first request that has them + if (scopesAndType.permissions.Any() && !rolesToEvaluate.Any()) + { + rolesToEvaluate = scopesAndType.permissions; + + if (ProxyUtils.IsGraphBatchUrl(uri)) + { + applicationEndpoints.AddRange(requestsFromBatch); + } + else + { + applicationEndpoints.Add(methodAndUrl); + } + } + } + } + + // Remove duplicates + delegatedEndpoints = delegatedEndpoints.Distinct(methodAndUrlComparer).ToList(); + applicationEndpoints = applicationEndpoints.Distinct(methodAndUrlComparer).ToList(); + + if (delegatedEndpoints.Count == 0 && applicationEndpoints.Count == 0) + { + return; + } + + var report = new GraphMinimalPermissionsGuidancePluginReport + { + ExcludedPermissions = _configuration.PermissionsToExclude + }; + + Logger.LogWarning("This plugin is in preview and may not return the correct results.\r\nPlease review the permissions and test your app before using them in production.\r\nIf you have any feedback, please open an issue at https://aka.ms/devproxy/issue.\r\n"); + + if (_configuration.PermissionsToExclude is not null && + _configuration.PermissionsToExclude.Any()) + { + Logger.LogInformation("Excluding the following permissions: {permissions}", string.Join(", ", _configuration.PermissionsToExclude)); + } + + if (delegatedEndpoints.Count > 0) + { + var delegatedPermissionsInfo = new GraphMinimalPermissionsInfo(); + report.DelegatedPermissions = delegatedPermissionsInfo; + + Logger.LogInformation("Evaluating delegated permissions for: {endpoints}", string.Join(", ", delegatedEndpoints.Select(e => $"{e.method} {e.url}"))); + + await EvaluateMinimalScopesAsync(delegatedEndpoints, scopesToEvaluate, GraphPermissionsType.Delegated, delegatedPermissionsInfo); + } + + if (applicationEndpoints.Count > 0) + { + var applicationPermissionsInfo = new GraphMinimalPermissionsInfo(); + report.ApplicationPermissions = applicationPermissionsInfo; + + Logger.LogInformation("Evaluating application permissions for: {endpoints}", string.Join(", ", applicationEndpoints.Select(e => $"{e.method} {e.url}"))); + + await EvaluateMinimalScopesAsync(applicationEndpoints, rolesToEvaluate, GraphPermissionsType.Application, applicationPermissionsInfo); + } + + StoreReport(report, e); + } + + private static (string method, string url)[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) + { + var requests = new List<(string method, string url)>(); + + if (string.IsNullOrEmpty(batchBody)) + { + return [.. requests]; + } + + try + { + var batch = JsonSerializer.Deserialize(batchBody, ProxyUtils.JsonSerializerOptions); + if (batch == null) + { + return [.. requests]; + } + + foreach (var request in batch.Requests) + { + try + { + var method = request.Method; + var url = request.Url; + var absoluteUrl = $"https://{graphHostName}/{graphVersion}{url}"; + requests.Add((method, GetTokenizedUrl(absoluteUrl))); + } + catch { } + } + } + catch { } + + return [.. requests]; + } + + /// + /// Returns permissions and type (delegated or application) from the access token + /// used on the request. + /// If it can't get the permissions, returns PermissionType.Application + /// and an empty array + /// + private static (GraphPermissionsType type, IEnumerable permissions) GetPermissionsAndType(RequestLog request) + { + var authHeader = request.Context?.Session.HttpClient.Request.Headers.GetFirstHeader("Authorization"); + if (authHeader == null) + { + return (GraphPermissionsType.Application, []); + } + + var token = authHeader.Value.Replace("Bearer ", string.Empty); + var tokenChunks = token.Split('.'); + if (tokenChunks.Length != 3) + { + return (GraphPermissionsType.Application, []); + } + + try + { + var handler = new JwtSecurityTokenHandler(); + var jwtSecurityToken = handler.ReadJwtToken(token); + + var scopeClaim = jwtSecurityToken.Claims.FirstOrDefault(c => c.Type == "scp"); + if (scopeClaim == null) + { + // possibly an application token + // roles is an array so we need to handle it differently + var roles = jwtSecurityToken.Claims + .Where(c => c.Type == "roles") + .Select(c => c.Value); + if (!roles.Any()) + { + return (GraphPermissionsType.Application, []); + } + else + { + return (GraphPermissionsType.Application, roles); + } + } + else + { + return (GraphPermissionsType.Delegated, scopeClaim.Value.Split(' ')); + } + } + catch + { + return (GraphPermissionsType.Application, []); + } + } + + private async Task EvaluateMinimalScopesAsync(IEnumerable<(string method, string url)> endpoints, IEnumerable permissionsFromAccessToken, GraphPermissionsType scopeType, GraphMinimalPermissionsInfo permissionsInfo) + { + var payload = endpoints.Select(e => new GraphRequestInfo { Method = e.method, Url = e.url }); + + permissionsInfo.Operations = endpoints.Select(e => new GraphMinimalPermissionsOperationInfo + { + Method = e.method, + Endpoint = e.url + }).ToArray(); + permissionsInfo.PermissionsFromTheToken = permissionsFromAccessToken; + + try + { + var url = $"https://graphexplorerapi.azurewebsites.net/permissions?scopeType={GraphUtils.GetScopeTypeString(scopeType)}"; + using var client = new HttpClient(); + var stringPayload = JsonSerializer.Serialize(payload, ProxyUtils.JsonSerializerOptions); + Logger.LogDebug(string.Format("Calling {0} with payload{1}{2}", url, Environment.NewLine, stringPayload)); + + var response = await client.PostAsJsonAsync(url, payload); + var content = await response.Content.ReadAsStringAsync(); + + Logger.LogDebug(string.Format("Response:{0}{1}", Environment.NewLine, content)); + + var resultsAndErrors = JsonSerializer.Deserialize(content, ProxyUtils.JsonSerializerOptions); + var minimalPermissions = resultsAndErrors?.Results?.Select(p => p.Value) ?? []; + var errors = resultsAndErrors?.Errors?.Select(e => $"- {e.Url} ({e.Message})") ?? []; + + if (scopeType == GraphPermissionsType.Delegated) + { + minimalPermissions = await GraphUtils.UpdateUserScopesAsync(minimalPermissions, endpoints, scopeType, Logger); + } + + if (minimalPermissions.Any()) + { + var excessPermissions = permissionsFromAccessToken + .Except(_configuration.PermissionsToExclude ?? []) + .Where(p => !minimalPermissions.Contains(p)); + + permissionsInfo.MinimalPermissions = minimalPermissions; + permissionsInfo.ExcessPermissions = excessPermissions; + + Logger.LogInformation("Minimal permissions: {minimalPermissions}", string.Join(", ", minimalPermissions)); + Logger.LogInformation("Permissions on the token: {tokenPermissions}", string.Join(", ", permissionsFromAccessToken)); + + if (excessPermissions.Any()) + { + Logger.LogWarning("The following permissions are unnecessary: {permissions}", string.Join(", ", excessPermissions)); + } + else + { + Logger.LogInformation("The token has the minimal permissions required."); + } + } + if (errors.Any()) + { + Logger.LogError("Couldn't determine minimal permissions for the following URLs: {errors}", string.Join(", ", errors)); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "An error has occurred while retrieving minimal permissions: {message}", ex.Message); + } + } + + private static (string method, string url) GetMethodAndUrl(string message) + { + var info = message.Split(" "); + if (info.Length > 2) + { + info = [info[0], string.Join(" ", info.Skip(1))]; + } + return (method: info[0], url: info[1]); + } + + private static string GetTokenizedUrl(string absoluteUrl) + { + var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); + return "/" + string.Join("", new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString)); + } +} diff --git a/dev-proxy-plugins/RequestLogs/GraphMinimalPermissionsPlugin.cs b/dev-proxy-plugins/RequestLogs/GraphMinimalPermissionsPlugin.cs new file mode 100644 index 00000000..2ffa8386 --- /dev/null +++ b/dev-proxy-plugins/RequestLogs/GraphMinimalPermissionsPlugin.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; +using Microsoft.DevProxy.Abstractions; +using Microsoft.Extensions.Logging; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.DevProxy.Plugins.MinimalPermissions; + +namespace Microsoft.DevProxy.Plugins.RequestLogs; + +public class GraphMinimalPermissionsPluginReport +{ + public required IEnumerable Requests { get; init; } + [JsonConverter(typeof(JsonStringEnumConverter))] + public required GraphPermissionsType PermissionsType { get; init; } + public required IEnumerable MinimalPermissions { get; init; } + public required IEnumerable Errors { get; init; } +} + +internal class GraphMinimalPermissionsPluginConfiguration +{ + public GraphPermissionsType Type { get; set; } = GraphPermissionsType.Delegated; +} + +public class GraphMinimalPermissionsPlugin(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReportingPlugin(pluginEvents, context, logger, urlsToWatch, configSection) +{ + public override string Name => nameof(GraphMinimalPermissionsPlugin); + private readonly GraphMinimalPermissionsPluginConfiguration _configuration = new(); + + public override async Task RegisterAsync() + { + await base.RegisterAsync(); + + ConfigSection?.Bind(_configuration); + + PluginEvents.AfterRecordingStop += AfterRecordingStopAsync; + } + + private async Task AfterRecordingStopAsync(object? sender, RecordingArgs e) + { + if (!e.RequestLogs.Any()) + { + return; + } + + var methodAndUrlComparer = new MethodAndUrlComparer(); + var endpoints = new List<(string method, string url)>(); + + foreach (var request in e.RequestLogs) + { + if (request.MessageType != MessageType.InterceptedRequest) + { + continue; + } + + var methodAndUrlString = request.MessageLines.First(); + var methodAndUrl = GetMethodAndUrl(methodAndUrlString); + if (methodAndUrl.method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var uri = new Uri(methodAndUrl.url); + if (!ProxyUtils.IsGraphUrl(uri)) + { + continue; + } + + if (ProxyUtils.IsGraphBatchUrl(uri)) + { + var graphVersion = ProxyUtils.IsGraphBetaUrl(uri) ? "beta" : "v1.0"; + var requestsFromBatch = GetRequestsFromBatch(request.Context?.Session.HttpClient.Request.BodyString!, graphVersion, uri.Host); + endpoints.AddRange(requestsFromBatch); + } + else + { + methodAndUrl = (methodAndUrl.method, GetTokenizedUrl(methodAndUrl.url)); + endpoints.Add(methodAndUrl); + } + } + + // Remove duplicates + endpoints = endpoints.Distinct(methodAndUrlComparer).ToList(); + + if (endpoints.Count == 0) + { + Logger.LogInformation("No requests to Microsoft Graph endpoints recorded. Will not retrieve minimal permissions."); + return; + } + + Logger.LogInformation("Retrieving minimal permissions for:\r\n{endpoints}\r\n", string.Join(Environment.NewLine, endpoints.Select(e => $"- {e.method} {e.url}"))); + + Logger.LogWarning("This plugin is in preview and may not return the correct results.\r\nPlease review the permissions and test your app before using them in production.\r\nIf you have any feedback, please open an issue at https://aka.ms/devproxy/issue.\r\n"); + + var report = await DetermineMinimalScopesAsync(endpoints); + if (report is not null) + { + StoreReport(report, e); + } + } + + private static (string method, string url)[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) + { + var requests = new List<(string, string)>(); + + if (string.IsNullOrEmpty(batchBody)) + { + return [.. requests]; + } + + try + { + var batch = JsonSerializer.Deserialize(batchBody, ProxyUtils.JsonSerializerOptions); + if (batch == null) + { + return [.. requests]; + } + + foreach (var request in batch.Requests) + { + try + { + var method = request.Method; + var url = request.Url; + var absoluteUrl = $"https://{graphHostName}/{graphVersion}{url}"; + requests.Add((method, GetTokenizedUrl(absoluteUrl))); + } + catch { } + } + } + catch { } + + return [.. requests]; + } + + private async Task DetermineMinimalScopesAsync(IEnumerable<(string method, string url)> endpoints) + { + var payload = endpoints.Select(e => new GraphRequestInfo { Method = e.method, Url = e.url }); + + try + { + var url = $"https://graphexplorerapi.azurewebsites.net/permissions?scopeType={GraphUtils.GetScopeTypeString(_configuration.Type)}"; + using var client = new HttpClient(); + var stringPayload = JsonSerializer.Serialize(payload, ProxyUtils.JsonSerializerOptions); + Logger.LogDebug("Calling {url} with payload\r\n{stringPayload}", url, stringPayload); + + var response = await client.PostAsJsonAsync(url, payload); + var content = await response.Content.ReadAsStringAsync(); + + Logger.LogDebug("Response:\r\n{content}", content); + + var resultsAndErrors = JsonSerializer.Deserialize(content, ProxyUtils.JsonSerializerOptions); + var minimalScopes = resultsAndErrors?.Results?.Select(p => p.Value) ?? []; + var errors = resultsAndErrors?.Errors?.Select(e => $"- {e.Url} ({e.Message})") ?? []; + + if (_configuration.Type == GraphPermissionsType.Delegated) + { + minimalScopes = await GraphUtils.UpdateUserScopesAsync(minimalScopes, endpoints, _configuration.Type, Logger); + } + + if (minimalScopes.Any()) + { + Logger.LogInformation("Minimal permissions:\r\n{permissions}", string.Join(", ", minimalScopes)); + } + if (errors.Any()) + { + Logger.LogError("Couldn't determine minimal permissions for the following URLs:\r\n{errors}", string.Join(Environment.NewLine, errors)); + } + + return new GraphMinimalPermissionsPluginReport + { + Requests = payload.ToArray(), + PermissionsType = _configuration.Type, + MinimalPermissions = minimalScopes, + Errors = errors.ToArray() + }; + } + catch (Exception ex) + { + Logger.LogError(ex, "An error has occurred while retrieving minimal permissions:"); + return null; + } + } + + private static (string method, string url) GetMethodAndUrl(string message) + { + var info = message.Split(" "); + if (info.Length > 2) + { + info = [info[0], string.Join(" ", info.Skip(1))]; + } + return (info[0], info[1]); + } + + private static string GetTokenizedUrl(string absoluteUrl) + { + var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); + return "/" + string.Join("", new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString)); + } +} diff --git a/dev-proxy-plugins/RequestLogs/MinimalPermissionsPlugin.cs b/dev-proxy-plugins/RequestLogs/MinimalPermissionsPlugin.cs index 7556e372..51f073fe 100644 --- a/dev-proxy-plugins/RequestLogs/MinimalPermissionsPlugin.cs +++ b/dev-proxy-plugins/RequestLogs/MinimalPermissionsPlugin.cs @@ -1,34 +1,42 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.Extensions.Configuration; using Microsoft.DevProxy.Abstractions; -using Microsoft.Extensions.Logging; -using System.Net.Http.Json; -using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.DevProxy.Plugins.MinimalPermissions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Readers; namespace Microsoft.DevProxy.Plugins.RequestLogs; +public class MinimalPermissionsPluginReportApiResult +{ + public required string ApiName { get; init; } + public required string[] Requests { get; init; } + public required string[] TokenPermissions { get; init; } + public required string[] MinimalPermissions { get; init; } + public required string[] ExcessivePermissions { get; init; } + public required bool UsesMinimalPermissions { get; init; } +} + public class MinimalPermissionsPluginReport { - public required IEnumerable Requests { get; init; } - [JsonConverter(typeof(JsonStringEnumConverter))] - public required PermissionsType PermissionsType { get; init; } - public required IEnumerable MinimalPermissions { get; init; } - public required IEnumerable Errors { get; init; } + public required MinimalPermissionsPluginReportApiResult[] Results { get; init; } + public required string[] UnmatchedRequests { get; init; } + public required ApiPermissionError[] Errors { get; init; } } -internal class MinimalPermissionsPluginConfiguration +public class MinimalPermissionsPluginConfiguration { - public PermissionsType Type { get; set; } = PermissionsType.Delegated; + public string? ApiSpecsFolderPath { get; set; } } public class MinimalPermissionsPlugin(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet urlsToWatch, IConfigurationSection? configSection = null) : BaseReportingPlugin(pluginEvents, context, logger, urlsToWatch, configSection) { - public override string Name => nameof(MinimalPermissionsPlugin); private readonly MinimalPermissionsPluginConfiguration _configuration = new(); + private Dictionary? _apiSpecsByUrl; + public override string Name => nameof(MinimalPermissionsPlugin); public override async Task RegisterAsync() { @@ -36,168 +44,202 @@ public override async Task RegisterAsync() ConfigSection?.Bind(_configuration); + if (string.IsNullOrWhiteSpace(_configuration.ApiSpecsFolderPath)) + { + throw new InvalidOperationException("ApiSpecsFolderPath is required."); + } + if (!Path.Exists(_configuration.ApiSpecsFolderPath)) + { + throw new InvalidOperationException($"ApiSpecsFolderPath '{_configuration.ApiSpecsFolderPath}' does not exist."); + } + PluginEvents.AfterRecordingStop += AfterRecordingStopAsync; } - private async Task AfterRecordingStopAsync(object? sender, RecordingArgs e) +#pragma warning disable CS1998 + private async Task AfterRecordingStopAsync(object sender, RecordingArgs e) +#pragma warning restore CS1998 { - if (!e.RequestLogs.Any()) + var interceptedRequests = e.RequestLogs + .Where(l => + l.MessageType == MessageType.InterceptedRequest && + !l.MessageLines.First().StartsWith("OPTIONS") && + l.Context?.Session is not null && + l.Context.Session.HttpClient.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase)) + ); + if (!interceptedRequests.Any()) { + Logger.LogDebug("No requests to process"); return; } - var methodAndUrlComparer = new MethodAndUrlComparer(); - var endpoints = new List<(string method, string url)>(); + Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API specs..."); - foreach (var request in e.RequestLogs) + _apiSpecsByUrl ??= LoadApiSpecs(_configuration.ApiSpecsFolderPath!); + if (_apiSpecsByUrl is null || _apiSpecsByUrl.Count == 0) { - if (request.MessageType != MessageType.InterceptedRequest) + Logger.LogWarning("No API definitions found in the specified folder."); + return; + } + + var (requestsByApiSpec, unmatchedApiSpecRequests) = GetRequestsByApiSpec(interceptedRequests, _apiSpecsByUrl); + + var errors = new List(); + var results = new List(); + var unmatchedRequests = new List( + unmatchedApiSpecRequests.Select(r => r.MessageLines.First()) + ); + + foreach (var (apiSpec, requests) in requestsByApiSpec) + { + var minimalPermissions = apiSpec.CheckMinimalPermissions(requests, Logger); + + var result = new MinimalPermissionsPluginReportApiResult { - continue; - } + ApiName = GetApiName(minimalPermissions.OperationsFromRequests.First().OriginalUrl), + Requests = minimalPermissions.OperationsFromRequests + .Select(o => $"{o.Method} {o.OriginalUrl}") + .Distinct() + .ToArray(), + TokenPermissions = minimalPermissions.TokenPermissions.Distinct().ToArray(), + MinimalPermissions = minimalPermissions.MinimalScopes, + ExcessivePermissions = minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes).ToArray(), + UsesMinimalPermissions = !minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes).Any() + }; + results.Add(result); + + var unmatchedApiRequests = minimalPermissions.OperationsFromRequests + .Where(o => minimalPermissions.UnmatchedOperations.Contains($"{o.Method} {o.TokenizedUrl}")) + .Select(o => $"{o.Method} {o.OriginalUrl}"); + unmatchedRequests.AddRange(unmatchedApiRequests); + errors.AddRange(minimalPermissions.Errors); - var methodAndUrlString = request.MessageLines.First(); - var methodAndUrl = GetMethodAndUrl(methodAndUrlString); - if (methodAndUrl.method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) + if (result.UsesMinimalPermissions) { - continue; + Logger.LogInformation( + "API {apiName} is called with minimal permissions: {minimalPermissions}", + result.ApiName, + string.Join(", ", result.MinimalPermissions) + ); } - - var uri = new Uri(methodAndUrl.url); - if (!ProxyUtils.IsGraphUrl(uri)) + else { - continue; + Logger.LogWarning( + "Calling API {apiName} with excessive permissions: {excessivePermissions}. Minimal permissions are: {minimalPermissions}", + result.ApiName, + string.Join(", ", result.ExcessivePermissions), + string.Join(", ", result.MinimalPermissions) + ); } - if (ProxyUtils.IsGraphBatchUrl(uri)) + if (unmatchedApiRequests.Any()) { - var graphVersion = ProxyUtils.IsGraphBetaUrl(uri) ? "beta" : "v1.0"; - var requestsFromBatch = GetRequestsFromBatch(request.Context?.Session.HttpClient.Request.BodyString!, graphVersion, uri.Host); - endpoints.AddRange(requestsFromBatch); + Logger.LogWarning( + "Unmatched requests for API {apiName}:{newLine}- {unmatchedRequests}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", unmatchedApiRequests) + ); } - else + + if (minimalPermissions.Errors.Count != 0) { - methodAndUrl = (methodAndUrl.method, GetTokenizedUrl(methodAndUrl.url)); - endpoints.Add(methodAndUrl); + Logger.LogWarning( + "Errors for API {apiName}:{newLine}- {errors}", + result.ApiName, + Environment.NewLine, + string.Join($"{Environment.NewLine}- ", minimalPermissions.Errors.Select(e => $"{e.Request}: {e.Error}")) + ); } } - // Remove duplicates - endpoints = endpoints.Distinct(methodAndUrlComparer).ToList(); - - if (endpoints.Count == 0) + var report = new MinimalPermissionsPluginReport() { - Logger.LogInformation("No requests to Microsoft Graph endpoints recorded. Will not retrieve minimal permissions."); - return; - } - - Logger.LogInformation("Retrieving minimal permissions for:\r\n{endpoints}\r\n", string.Join(Environment.NewLine, endpoints.Select(e => $"- {e.method} {e.url}"))); + Results = [.. results], + UnmatchedRequests = [.. unmatchedRequests], + Errors = [.. errors] + }; - Logger.LogWarning("This plugin is in preview and may not return the correct results.\r\nPlease review the permissions and test your app before using them in production.\r\nIf you have any feedback, please open an issue at https://aka.ms/devproxy/issue.\r\n"); - - var report = await DetermineMinimalScopesAsync(endpoints); - if (report is not null) - { - StoreReport(report, e); - } + StoreReport(report, e); } - private static (string method, string url)[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) + private Dictionary LoadApiSpecs(string apiSpecsFolderPath) { - var requests = new List<(string, string)>(); - - if (string.IsNullOrEmpty(batchBody)) - { - return [.. requests]; - } - - try + var apiDefinitions = new Dictionary(); + foreach (var file in Directory.EnumerateFiles(apiSpecsFolderPath, "*.*", SearchOption.AllDirectories)) { - var batch = JsonSerializer.Deserialize(batchBody, ProxyUtils.JsonSerializerOptions); - if (batch == null) + var extension = Path.GetExtension(file); + if (!extension.Equals(".json", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) && + !extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)) { - return [.. requests]; + Logger.LogDebug("Skipping file '{file}' because it is not a JSON or YAML file", file); + continue; } - foreach (var request in batch.Requests) + Logger.LogDebug("Processing file '{file}'...", file); + try { - try + var apiDefinition = new OpenApiStringReader().Read(File.ReadAllText(file), out _); + if (apiDefinition is null) + { + continue; + } + if (apiDefinition.Servers is null || apiDefinition.Servers.Count == 0) { - var method = request.Method; - var url = request.Url; - var absoluteUrl = $"https://{graphHostName}/{graphVersion}{url}"; - requests.Add((method, GetTokenizedUrl(absoluteUrl))); + Logger.LogDebug("No servers found in API definition file '{file}'", file); + continue; } - catch { } + foreach (var server in apiDefinition.Servers) + { + if (server.Url is null) + { + Logger.LogDebug("No URL found for server '{server}'", server.Description ?? "unnamed"); + continue; + } + apiDefinitions[server.Url] = apiDefinition; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load API definition from file '{file}'", file); } } - catch { } - - return [.. requests]; + return apiDefinitions; } - private async Task DetermineMinimalScopesAsync(IEnumerable<(string method, string url)> endpoints) + private (Dictionary> RequestsByApiSpec, IEnumerable UnmatchedRequests) GetRequestsByApiSpec(IEnumerable interceptedRequests, Dictionary apiSpecsByUrl) { - var payload = endpoints.Select(e => new RequestInfo { Method = e.method, Url = e.url }); - - try + var unmatchedRequests = new List(); + var requestsByApiSpec = new Dictionary>(); + foreach (var request in interceptedRequests) { - var url = $"https://graphexplorerapi.azurewebsites.net/permissions?scopeType={GraphUtils.GetScopeTypeString(_configuration.Type)}"; - using var client = new HttpClient(); - var stringPayload = JsonSerializer.Serialize(payload, ProxyUtils.JsonSerializerOptions); - Logger.LogDebug("Calling {url} with payload\r\n{stringPayload}", url, stringPayload); - - var response = await client.PostAsJsonAsync(url, payload); - var content = await response.Content.ReadAsStringAsync(); - - Logger.LogDebug("Response:\r\n{content}", content); + var url = request.MessageLines.First().Split(' ')[1]; + Logger.LogDebug("Matching request {requestUrl} to API specs...", url); - var resultsAndErrors = JsonSerializer.Deserialize(content, ProxyUtils.JsonSerializerOptions); - var minimalScopes = resultsAndErrors?.Results?.Select(p => p.Value) ?? []; - var errors = resultsAndErrors?.Errors?.Select(e => $"- {e.Url} ({e.Message})") ?? []; - - if (_configuration.Type == PermissionsType.Delegated) + var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(url.StartsWith); + if (matchingKey is null) { - minimalScopes = await GraphUtils.UpdateUserScopesAsync(minimalScopes, endpoints, _configuration.Type, Logger); + Logger.LogDebug("No matching API spec found for {requestUrl}", url); + unmatchedRequests.Add(request); + continue; } - if (minimalScopes.Any()) - { - Logger.LogInformation("Minimal permissions:\r\n{permissions}", string.Join(", ", minimalScopes)); - } - if (errors.Any()) + if (!requestsByApiSpec.TryGetValue(apiSpecsByUrl[matchingKey], out List? value)) { - Logger.LogError("Couldn't determine minimal permissions for the following URLs:\r\n{errors}", string.Join(Environment.NewLine, errors)); + value = []; + requestsByApiSpec[apiSpecsByUrl[matchingKey]] = value; } - return new MinimalPermissionsPluginReport - { - Requests = payload.ToArray(), - PermissionsType = _configuration.Type, - MinimalPermissions = minimalScopes, - Errors = errors.ToArray() - }; - } - catch (Exception ex) - { - Logger.LogError(ex, "An error has occurred while retrieving minimal permissions:"); - return null; + value.Add(request); } - } - private static (string method, string url) GetMethodAndUrl(string message) - { - var info = message.Split(" "); - if (info.Length > 2) - { - info = [info[0], string.Join(" ", info.Skip(1))]; - } - return (info[0], info[1]); + return (requestsByApiSpec, unmatchedRequests); } - private static string GetTokenizedUrl(string absoluteUrl) + private static string GetApiName(string url) { - var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); - return "/" + string.Join("", new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString)); + var uri = new Uri(url); + return uri.Authority; } } diff --git a/dev-proxy/presets/m365.json b/dev-proxy/presets/m365.json index 117126e1..57eb9cad 100644 --- a/dev-proxy/presets/m365.json +++ b/dev-proxy/presets/m365.json @@ -142,13 +142,13 @@ "configSection": "executionSummaryPlugin" }, { - "name": "MinimalPermissionsPlugin", + "name": "GraphMinimalPermissionsPlugin", "enabled": true, "pluginPath": "~appFolder/plugins/dev-proxy-plugins.dll", - "configSection": "minimalPermissionsPlugin" + "configSection": "graphMinimalPermissionsPlugin" }, { - "name": "MinimalPermissionsGuidancePlugin", + "name": "GraphMinimalPermissionsGuidancePlugin", "enabled": false, "pluginPath": "~appFolder/plugins/dev-proxy-plugins.dll" }, @@ -181,7 +181,7 @@ "executionSummaryPlugin": { "groupBy": "url" }, - "minimalPermissionsPlugin": { + "graphMinimalPermissionsPlugin": { "type": "delegated" }, "cachingGuidance": {