From 20829890e90f2902517560d9862d5bcfaea136ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Fri, 2 Oct 2026 15:52:45 +0200 Subject: [PATCH] Review 1.5: API v1 redacts the agent's account, logins, user names and URL queries; history, event log metadata, paging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Sensitive helper (Value/Text) is shared (Api/Sensitive.cs) and gains ServerText ("Agentens konto (…)" and the test's account), Url (query left out), ServerTest and CheckResult. - Windows servers and checks: CreatedBy/UpdatedBy, RequestedBy, LastError, area details, rule-state reasons, the check's SQL login and an HTTP address's query (settings.url and target) are "[udeladt]" unless ApiIncludesSensitiveEvidence is on. The grant script endpoint then returns the shipped template, Account "[udeladt]" and a note without the service account. - GET /agent-requests/{id} rewrites ServerTest results (account and texts) and SyntheticCheckRun results (failure message, sensitive details) like ResultJson does for connection tests, and names serverId/checkId. - Tests and runs reuse a request already waiting (said in the descriptions); running a paused check is 409. - GET /server-findings: limit (max 500) and continuation with X-Continuation, evidence's sensitive flag, status, scopePaused/scopeRemoved; GET /server-findings/{id} has history. GET /windows-servers/{id}/events returns { capturedAt, method, ignored, coverage, events }. The server list/one server has latest, worstDisk, soonestFull and findings per severity; checks have findings per severity. - Admin texts as main (AdminGroups, else the local Administrators); service lists trimmed and deduplicated. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Api/ConfigurationApiEndpoints.cs | 81 +++- src/SQLInsight.Web/Api/Sensitive.cs | 103 ++++ src/SQLInsight.Web/Api/ServerApiEndpoints.cs | 459 ++++++++++++------ src/SQLInsight.Web/ServerSetup.cs | 32 +- .../ServerApiTests.cs | 167 ++++++- 5 files changed, 667 insertions(+), 175 deletions(-) create mode 100644 src/SQLInsight.Web/Api/Sensitive.cs diff --git a/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs b/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs index 0d1fea4..0ed1d05 100644 --- a/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs +++ b/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs @@ -1,7 +1,8 @@ using System.Text.Json; -using System.Text.RegularExpressions; using Microsoft.Extensions.Options; using SQLInsight.Core.Configuration; +using SQLInsight.Core.Servers; +using SQLInsight.Core.SyntheticChecks; using SQLInsight.Repository; namespace SQLInsight.Web.Api; @@ -82,25 +83,11 @@ public static partial class ConfigurationApiEndpoints .WithSummary("Find SQL Server-instanser på en computer / Find SQL Server instances on a computer") .WithDescription("202 Accepted with requestId. The agent asks SQL Server Browser (UDP 1434) and, on its own computer, the registry." + write); api.MapGet("/agent-requests/{id:long}", RequestAsync) - .WithSummary("Status og resultat af en anmodning til agenten / Status and result of an agent request").WithDescription(redacted.Trim()); + .WithSummary("Status og resultat af en anmodning til agenten / Status and result of an agent request") + .WithDescription("Kinds: ConnectionTest, DatabaseDiscovery, InstanceDiscovery (instanceId), ServerTest (serverId), SyntheticCheckRun (checkId). " + + "Logins, accounts, user names, a failed check run's message and details marked sensitive are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on."); } - /// Logins and people's names, "[udeladt]" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on. - internal sealed class Sensitive(bool include) - { - public static Sensitive For(IOptions options) => new(options.Value.ApiIncludesSensitiveEvidence); - - public bool Include => include; - - public string? Value(string? value) => include || value is null ? value : ApiV1Endpoints.Redacted; - - /// An error text from SQL Server or the agent, with the login in it (Login failed for user '…') left out. - public string? Text(string? value) => include || value is null ? value : LoginInText().Replace(value, m => m.Groups[1].Value + "'" + ApiV1Endpoints.Redacted + "'"); - } - - [GeneratedRegex(@"(\b(?:user|login|bruger|login'et)\s+)'[^'\r\n]*'", RegexOptions.IgnoreCase)] - private static partial Regex LoginInText(); - private static IResult Forbidden() => Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Ikke tilladt", detail: "Ændringer kræver scope \"config:write\" (API-token) eller medlemskab af en gruppe i SQLInsight:Web:AdminGroups (er den tom: den lokale gruppe Administratorer på webserveren)."); @@ -152,21 +139,28 @@ public static partial class ConfigurationApiEndpoints SelectionChangedBy = s.Value(d.SelectionChangedBy), }; - private static object Json(AgentRequestRow r, Sensitive s) => new + /// A request to the agent, the same in every endpoint (also the Windows server tests and check runs). + internal static object Json(AgentRequestRow r, Sensitive s) => new { Id = r.RequestId, r.Kind, InstanceId = r.ConfiguredInstanceId, + ServerId = r.Kind == ServerRequestKind.ServerTest ? r.TargetId : null, + CheckId = r.Kind == ServerRequestKind.SyntheticCheckRun ? r.TargetId : null, r.Status, RequestedBy = s.Value(r.RequestedBy), r.RequestedAt, r.StartedAt, r.CompletedAt, - Error = s.Text(r.Error), + Error = s.ServerText(r.Error), Result = ResultJson(r, s), }; - /// The agent's result; a connection test's login and error text are redacted like the rest. + /// + /// The agent's result, redacted like the rest: a connection test's login and error text, a Windows server test's + /// account and the account in its texts, a check run's failure message and the details marked sensitive. A result + /// that cannot be read is left out rather than returned unredacted. + /// private static JsonElement? ResultJson(AgentRequestRow r, Sensitive s) { if (r.Result is not { Length: > 0 } result) @@ -174,13 +168,50 @@ public static partial class ConfigurationApiEndpoints return null; } - if (!s.Include && r.Kind == AgentRequestKind.ConnectionTest && AgentRequestJson.Deserialize(result) is { } test) + if (!s.Include) { - result = AgentRequestJson.Serialize(test with { Login = s.Value(test.Login), Error = s.Text(test.Error) }); + string? redacted = r.Kind switch + { + AgentRequestKind.ConnectionTest => AgentRequestJson.Deserialize(result) is { } test + ? AgentRequestJson.Serialize(test with { Login = s.Value(test.Login), Error = s.Text(test.Error) }) + : result, + ServerRequestKind.ServerTest => AgentRequestJson.Deserialize(result) is { } server + ? AgentRequestJson.Serialize(s.ServerTest(server)) + : null, + ServerRequestKind.SyntheticCheckRun => Deserialize(result) is { } run ? AgentRequestJson.Serialize(s.CheckResult(run)) : null, + _ => result, + }; + if (redacted is null) + { + return null; + } + + result = redacted; } - using var document = JsonDocument.Parse(result); - return document.RootElement.Clone(); + try + { + using var document = JsonDocument.Parse(result); + return document.RootElement.Clone(); + } + catch (JsonException) + { + return null; + } + } + + /// As , also for a type with required members the JSON lacks. + private static T? Deserialize(string json) + where T : class + { + try + { + return JsonSerializer.Deserialize(json, AgentRequestJson.Options); + } + catch (Exception ex) when (ex is JsonException or NotSupportedException) + { + return null; + } } private static InstanceInput Input(MonitoredInstanceBody body) => new(body.Name, body.Server, body.Port, body.Authentication, body.UserName, body.Password, diff --git a/src/SQLInsight.Web/Api/Sensitive.cs b/src/SQLInsight.Web/Api/Sensitive.cs new file mode 100644 index 0000000..b9aca07 --- /dev/null +++ b/src/SQLInsight.Web/Api/Sensitive.cs @@ -0,0 +1,103 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; +using SQLInsight.Core.Servers; +using SQLInsight.Core.SyntheticChecks; + +namespace SQLInsight.Web.Api; + +/// +/// Logins, accounts and people's names in API v1: customer data, returned as "[udeladt]" unless +/// SQLInsight:Web:ApiIncludesSensitiveEvidence is on, as evidence marked sensitive is. +/// +internal sealed partial class Sensitive(bool include) +{ + public static Sensitive For(IOptions options) => new(options.Value.ApiIncludesSensitiveEvidence); + + public bool Include => include; + + public string? Value(string? value) => include || value is null ? value : ApiV1Endpoints.Redacted; + + /// A value that is only redacted when (evidence and details marked so). + public string? Value(string? value, bool sensitive) => sensitive ? Value(value) : value; + + /// An error text from SQL Server or the agent, with the login in it (Login failed for user '…') left out. + public string? Text(string? value) => include || value is null ? value : LoginInText().Replace(value, m => m.Groups[1].Value + "'" + ApiV1Endpoints.Redacted + "'"); + + /// + /// A text about a Windows server (the agent's errors, area details, rule reasons): as , and the + /// agent's account in "Agentens konto (FIRMA\gmsa$)" and, when known, wherever occurs. + /// + public string? ServerText(string? value, string? account = null) + { + if (include || value is null) + { + return value; + } + + var text = AccountInText().Replace(Text(value)!, ApiV1Endpoints.Redacted); + return account is { Length: > 2 } ? text.Replace(account, ApiV1Endpoints.Redacted, StringComparison.OrdinalIgnoreCase) : text; + } + + /// An HTTP address without its query (it may carry a token or a key): https://host/path?[udeladt]. + public string? Url(Uri? url) + { + if (url is null) + { + return null; + } + + if (include || !url.IsAbsoluteUri || (url.Query.Length == 0 && url.Fragment.Length == 0)) + { + return url.ToString(); + } + + return url.GetLeftPart(UriPartial.Path) + (url.Query.Length > 0 ? "?" + ApiV1Endpoints.Redacted : string.Empty); + } + + /// What a check points at, as , with an HTTP address's query left out. + public string Target(string kind, SyntheticCheckSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + return kind == SyntheticCheckKind.Http ? Url(settings.Url) ?? string.Empty : settings.Target(kind); + } + + /// A Windows server test as the agent stored it, with the agent's account and its traces in the texts left out. + public WindowsServerTestResult ServerTest(WindowsServerTestResult test) + { + ArgumentNullException.ThrowIfNull(test); + if (include) + { + return test; + } + + var account = test.Account; + return test with + { + Account = Value(account), + Error = ServerText(test.Error, account), + Hint = ServerText(test.Hint, account), + Notes = test.Notes.Select(n => ServerText(n, account)!).ToList(), + Areas = test.Areas.Select(a => a with { Detail = ServerText(a.Detail, account) }).ToList(), + }; + } + + /// A check run as the agent stored it: a failure's message and details marked sensitive left out, as in …/results. + public SyntheticCheckResult CheckResult(SyntheticCheckResult result) + { + ArgumentNullException.ThrowIfNull(result); + return include + ? result + : result with + { + Message = result.Success ? result.Message : ApiV1Endpoints.Redacted, + Details = result.Details.Select(d => d.Sensitive ? d with { Value = ApiV1Endpoints.Redacted } : d).ToList(), + }; + } + + [GeneratedRegex(@"(\b(?:user|login|bruger|login'et)\s+)'[^'\r\n]*'", RegexOptions.IgnoreCase)] + private static partial Regex LoginInText(); + + /// "Agentens konto (FIRMA\gmsa$)", as SQLInsight.Servers.ServerErrors and the connection test write it: the part in brackets. + [GeneratedRegex(@"(?<=\bagentens konto \()[^)\r\n]+(?=\))", RegexOptions.IgnoreCase)] + private static partial Regex AccountInText(); +} diff --git a/src/SQLInsight.Web/Api/ServerApiEndpoints.cs b/src/SQLInsight.Web/Api/ServerApiEndpoints.cs index 699166e..4329f0f 100644 --- a/src/SQLInsight.Web/Api/ServerApiEndpoints.cs +++ b/src/SQLInsight.Web/Api/ServerApiEndpoints.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Globalization; using Microsoft.Extensions.Options; using SQLInsight.Core.Configuration; using SQLInsight.Core.Findings; @@ -29,158 +29,238 @@ public sealed record SyntheticCheckPatch(bool? Paused); /// /// Windows servers and synthetic checks through the API: everything the pages Windows-servere and Syntetiske tjek -/// show and do. Reading needs the scope read; changing needs config:write for tokens, or SQLInsight:Web:AdminGroups for -/// Windows users. Tests and check runs are queued for the agent (202 with a request id for /api/v1/agent-requests/{id}). -/// Passwords are write-only. Event messages, failure messages and evidence marked sensitive are "[udeladt]" unless -/// SQLInsight:Web:ApiIncludesSensitiveEvidence is on. +/// show and do. Reading needs the scope read; changing needs config:write for tokens, or for Windows users membership of +/// SQLInsight:Web:AdminGroups (the web server's local Administrators while that is empty). Tests and check runs are +/// queued for the agent (202 with a request id for /api/v1/agent-requests/{id}); one already waiting is reused. +/// Passwords are write-only. The agent's account, logins, the names of the people who made changes, the query of an +/// HTTP address, event messages, failure messages and evidence marked sensitive are "[udeladt]" unless +/// SQLInsight:Web:ApiIncludesSensitiveEvidence is on, as in the rest of API v1. /// public static class ServerApiEndpoints { + /// The most findings one page of /api/v1/server-findings returns. + public const int MaxFindingPage = 500; + public static void Map(IEndpointRouteBuilder app) { ArgumentNullException.ThrowIfNull(app); var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead(); - const string write = " Needs scope config:write for tokens, or SQLInsight:Web:AdminGroups for Windows users."; + const string write = " Needs scope config:write for tokens, or for Windows users SQLInsight:Web:AdminGroups (empty: the web server's local Administrators)."; + const string redacted = " The agent's account, logins and user names are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on."; + const string overview = "With the latest CPU and memory (last 10 minutes), the fullest disk, the disk that will be full first and the active findings per severity " + + "(none for a paused server), as on the page Windows-servere."; - api.MapGet("/windows-servers", ListServersAsync).WithSummary("Windows-servere med status / Windows servers"); + api.MapGet("/windows-servers", ListServersAsync).WithSummary("Windows-servere med status / Windows servers").WithDescription(overview + redacted); api.MapPost("/windows-servers", AddServerAsync).WithSummary("Tilføj en Windows-server / Add a Windows server") - .WithDescription("201 Created. The agent tests the connection and rights at once (serverTestRequestId)." + write); - api.MapGet("/windows-servers/{id:int}", GetServerAsync).WithSummary("Én Windows-server / One Windows server"); + .WithDescription("201 Created. The agent tests the connection and rights at once (serverTestRequestId). Service lists are trimmed and duplicates removed." + write); + api.MapGet("/windows-servers/{id:int}", GetServerAsync).WithSummary("Én Windows-server / One Windows server").WithDescription(overview + redacted); api.MapPatch("/windows-servers/{id:int}", PatchServerAsync) .WithSummary("Pause, rolle, overvågede og ignorerede tjenester / Pause, role, watched and ignored services").WithDescription(write.Trim()); api.MapDelete("/windows-servers/{id:int}", RemoveServerAsync).WithSummary("Stop overvågningen af en server / Remove a Windows server") - .WithDescription("204. Collected data is kept. 409 for a SQL Server host (it follows its instances; pause it instead)." + write); + .WithDescription("204. Collected data is kept. The server's synthetic checks are kept without the server (and its service). " + + "409 for a SQL Server host (it follows its instances; pause it instead)." + write); api.MapPost("/windows-servers/{id:int}/test", TestServerAsync).WithSummary("Test forbindelse og rettigheder fra agenten / Test the connection and rights from the agent") - .WithDescription("202 Accepted with requestId." + write); - api.MapGet("/windows-servers/{id:int}/test", LatestTestAsync).WithSummary("Seneste test og manglende rettigheder / Latest test and missing rights"); + .WithDescription("202 Accepted with requestId. A test already waiting for the agent is returned instead of a new one." + write); + api.MapGet("/windows-servers/{id:int}/test", LatestTestAsync).WithSummary("Seneste test og manglende rettigheder / Latest test and missing rights") + .WithDescription(redacted.Trim()); api.MapGet("/windows-servers/{id:int}/grant-script", GrantScriptAsync) - .WithSummary("PowerShell-script med least-privilege-rettigheder til agentens konto / Least-privilege grant script for the agent's account"); + .WithSummary("PowerShell-script med least-privilege-rettigheder til agentens konto / Least-privilege grant script for the agent's account") + .WithDescription("With the agent's account filled in only when SQLInsight:Web:ApiIncludesSensitiveEvidence is on; otherwise the shipped script with its placeholder " + + "(give the account with -Account). Run it with -WhatIf first."); api.MapGet("/windows-servers/{id:int}/performance", PerformanceAsync).WithSummary("CPU og hukommelse pr. minut (hours, standard 24) / CPU and memory per minute"); - api.MapGet("/windows-servers/{id:int}/disks", DisksAsync).WithSummary("Diske med ledig plads og prognose / Disks with free space and forecast"); + api.MapGet("/windows-servers/{id:int}/disks", DisksAsync).WithSummary("Diske med ledig plads og prognose / Disks with free space and forecast") + .WithDescription("forecast.historyDays: how many days of SQLInsight's own samples the forecast is computed from."); api.MapGet("/windows-servers/{id:int}/services", ServicesAsync).WithSummary("Tjenester / Services"); - api.MapGet("/windows-servers/{id:int}/events", EventsAsync).WithSummary("Grupperede fejl fra hændelsesloggen (days, standard 1) / Grouped event log errors"); + api.MapGet("/windows-servers/{id:int}/events", EventsAsync).WithSummary("Grupperede fejl fra hændelsesloggen (days, standard 1) / Grouped event log errors") + .WithDescription("{ capturedAt, method, ignored, coverage, events }: how the latest collection read the logs, how many events it left out as known noise, " + + "why less may be seen, and the groups last seen in the period (days 1–90). Samples are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on."); api.MapGet("/windows-servers/{id:int}/tasks", TasksAsync).WithSummary("Overvågede planlagte opgaver / Watched scheduled tasks"); - api.MapGet("/windows-servers/{id:int}/rule-states", (int id, InsightRepository repository, CancellationToken ct) => RuleStatesAsync(FindingScope.Server, id, repository, ct)) - .WithSummary("Reglernes udfald på serveren / Rule outcomes on the server"); + api.MapGet("/windows-servers/{id:int}/rule-states", (int id, InsightRepository repository, IOptions options, CancellationToken ct) => + RuleStatesAsync(FindingScope.Server, id, repository, options, ct)) + .WithSummary("Reglernes udfald på serveren / Rule outcomes on the server").WithDescription(redacted.Trim()); api.MapGet("/server-findings", FindingsAsync) .WithSummary("Fund på Windows-servere og syntetiske tjek / Findings on Windows servers and synthetic checks") - .WithDescription("status=active|resolved|all (default active), scope=server|check, id (with scope)."); - api.MapGet("/server-findings/{id:long}", FindingAsync).WithSummary("Ét fund på en server eller et tjek / One server or check finding"); + .WithDescription("status=active|resolved|all (default active), scope=server|check, id (with scope), limit (1–500, default 100), continuation. " + + "Active leaves out removed servers and checks, and paused ones unless scope and id name one (as on the pages). " + + "Returns an array. More pages: the response header X-Continuation holds the value for ?continuation=."); + api.MapGet("/server-findings/{id:long}", FindingAsync).WithSummary("Ét fund på en server eller et tjek, med historik / One server or check finding with history") + .WithDescription("history: the earlier occurrences of the same finding (same server or check, rule and subject), newest first."); - api.MapGet("/synthetic-checks", ListChecksAsync).WithSummary("Syntetiske tjek med seneste resultat / Synthetic checks"); + api.MapGet("/synthetic-checks", ListChecksAsync).WithSummary("Syntetiske tjek med seneste resultat / Synthetic checks") + .WithDescription("With the active findings per severity (none for a paused check). The query of an HTTP address, SQL logins and user names are \"[udeladt]\" " + + "unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on."); api.MapPost("/synthetic-checks", AddCheckAsync).WithSummary("Tilføj et syntetisk tjek / Add a synthetic check") .WithDescription("201 Created; the agent runs it at once (runRequestId). Kinds: Tcp, Http, Sql, Dns, AxLogon." + write); - api.MapGet("/synthetic-checks/{id:int}", GetCheckAsync).WithSummary("Ét syntetisk tjek / One synthetic check"); + api.MapGet("/synthetic-checks/{id:int}", GetCheckAsync).WithSummary("Ét syntetisk tjek / One synthetic check") + .WithDescription("The query of an HTTP address, SQL logins and user names are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on."); api.MapPut("/synthetic-checks/{id:int}", UpdateCheckAsync).WithSummary("Ret et syntetisk tjek / Change a synthetic check") - .WithDescription("The kind cannot change. A SQL login password must be given again when server, port or login change." + write); + .WithDescription("The kind cannot change. A SQL login password must be given again when server, port or login change. " + + "Give the full address and login again: the redacted values from GET are not real ones." + write); api.MapPatch("/synthetic-checks/{id:int}", PatchCheckAsync).WithSummary("Sæt på pause eller genoptag / Pause or resume").WithDescription(write.Trim()); - api.MapDelete("/synthetic-checks/{id:int}", RemoveCheckAsync).WithSummary("Fjern et syntetisk tjek / Remove a synthetic check").WithDescription("204." + write); + api.MapDelete("/synthetic-checks/{id:int}", RemoveCheckAsync).WithSummary("Fjern et syntetisk tjek / Remove a synthetic check") + .WithDescription("204. The sealed password is deleted at once; results and findings follow retention." + write); api.MapPost("/synthetic-checks/{id:int}/run", RunCheckAsync).WithSummary("Kør tjekket nu fra agenten / Run the check now from the agent") - .WithDescription("202 Accepted with requestId." + write); - api.MapGet("/synthetic-checks/{id:int}/results", ResultsAsync).WithSummary("Seneste kørsler (hours, standard 24) / Latest runs"); - api.MapGet("/synthetic-checks/{id:int}/rule-states", (int id, InsightRepository repository, CancellationToken ct) => RuleStatesAsync(FindingScope.Check, id, repository, ct)) - .WithSummary("Reglernes udfald på tjekket / Rule outcomes on the check"); + .WithDescription("202 Accepted with requestId. A run already waiting for the agent is returned instead of a new one. 409 for a paused check." + write); + api.MapGet("/synthetic-checks/{id:int}/results", ResultsAsync).WithSummary("Seneste kørsler (hours, standard 24) / Latest runs") + .WithDescription("A failed run's message and details marked sensitive are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on."); + api.MapGet("/synthetic-checks/{id:int}/rule-states", (int id, InsightRepository repository, IOptions options, CancellationToken ct) => + RuleStatesAsync(FindingScope.Check, id, repository, options, ct)) + .WithSummary("Reglernes udfald på tjekket / Rule outcomes on the check").WithDescription(redacted.Trim()); } private static IResult Forbidden() => Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Ikke tilladt", - detail: "Ændringer kræver scope \"config:write\" (API-token) eller medlemskab af en gruppe i SQLInsight:Web:AdminGroups."); + detail: "Ændringer kræver scope \"config:write\" (API-token) eller medlemskab af en gruppe i SQLInsight:Web:AdminGroups (er den tom: den lokale gruppe Administratorer på webserveren)."); private static IResult Invalid(string detail) => Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig", detail: detail); - private static string? Redact(string? value, IOptions options, bool sensitive = true) => - sensitive && !options.Value.ApiIncludesSensitiveEvidence && value is not null ? ApiV1Endpoints.Redacted : value; - - private static object Json(WindowsServerRow s) => new + private static object Counts(IReadOnlyDictionary? counts) => new { - Id = s.ServerId, - s.Name, - s.Host, - s.Role, - Source = s.IsSqlHost ? "sqlHost" : "web", - s.Paused, - WatchedServices = s.Watched, - IgnoredServices = s.Ignored, - s.Reachable, - s.LastContactAt, - s.LastErrorAt, - s.LastError, - s.Protocol, - s.OperatingSystem, - s.OsVersion, - s.LastBootUpTime, - s.TotalMemoryMb, - s.LogicalProcessors, - Areas = s.Areas.Select(a => new { a.Area, a.Ok, a.Detail, a.Right }), - s.CreatedAt, - s.CreatedBy, - s.UpdatedAt, - s.UpdatedBy, + Critical = counts?.GetValueOrDefault(Severity.Critical) ?? 0, + High = counts?.GetValueOrDefault(Severity.High) ?? 0, + Medium = counts?.GetValueOrDefault(Severity.Medium) ?? 0, + Low = counts?.GetValueOrDefault(Severity.Low) ?? 0, + Info = counts?.GetValueOrDefault(Severity.Info) ?? 0, }; - private static object Json(SyntheticCheckRow c, IOptions options) => new + private static object Json(WindowsServerRow w, Sensitive s, WindowsServerOverview? overview, IReadOnlyDictionary? counts) => new { - Id = c.CheckId, - c.Name, - c.Kind, - c.ServerId, - c.ServerName, - Target = c.Settings.Target(c.Kind), - c.IntervalSeconds, - c.TimeoutSeconds, - c.Paused, - Settings = c.Settings, - HasPassword = c.HasSecret, - c.SecretError, - c.LastRunAt, - c.LastSuccess, - c.LastDurationMs, - LastMessage = c.LastSuccess == false ? Redact(c.LastMessage, options) : c.LastMessage, - c.ConsecutiveFailures, - c.CreatedAt, - c.CreatedBy, - c.UpdatedAt, - c.UpdatedBy, + Id = w.ServerId, + w.Name, + w.Host, + w.Role, + Source = w.IsSqlHost ? "sqlHost" : "web", + w.Paused, + WatchedServices = w.Watched, + IgnoredServices = w.Ignored, + w.Reachable, + w.LastContactAt, + w.LastErrorAt, + LastError = s.ServerText(w.LastError), + w.Protocol, + w.OperatingSystem, + w.OsVersion, + w.LastBootUpTime, + w.TotalMemoryMb, + w.LogicalProcessors, + Areas = w.Areas.Select(a => new { a.Area, a.Ok, Detail = s.ServerText(a.Detail), a.Right }), + Latest = overview?.Latest is { } p ? new { p.SampledAt, p.CpuPercent, p.AvailableMb, p.AvailablePct } : null, + WorstDisk = overview?.WorstDisk is { } d ? new { d.Volume, d.Label, d.SizeGb, d.FreeGb, d.FreePercent } : null, + SoonestFull = overview is { SoonestFull: { } f, Disks: { } disks } + ? new { f.Volume, f.DaysUntilFull, FullAt = f.FullAt(disks.CapturedAt), Text = f.Describe() } + : null, + Findings = Counts(counts), + w.CreatedAt, + CreatedBy = s.Value(w.CreatedBy), + w.UpdatedAt, + UpdatedBy = s.Value(w.UpdatedBy), }; - private static object Json(StoredServerFinding f, IOptions options) => new + /// A check's settings, with the SQL login and an HTTP address's query redacted. + private static object Json(SyntheticCheckSettings c, Sensitive s) => new + { + c.Host, + c.Port, + Url = s.Url(c.Url), + c.ExpectedStatus, + c.ExpectedText, + c.UseWindowsAuthentication, + c.AllowInvalidCertificate, + c.Server, + c.Database, + c.Authentication, + UserName = s.Value(c.UserName), + c.Encryption, + c.TrustServerCertificate, + c.Query, + c.ExpectedAddresses, + c.Company, + c.ObjectServer, + c.Configuration, + c.BusinessConnectorLogon, + c.ServiceName, + c.WarnMilliseconds, + }; + + private static object Json(SyntheticCheckRow c, Sensitive s, IReadOnlyDictionary? counts) + { + var settings = c.Settings; + return new + { + Id = c.CheckId, + c.Name, + c.Kind, + c.ServerId, + c.ServerName, + Target = s.Target(c.Kind, settings), + c.IntervalSeconds, + c.TimeoutSeconds, + c.Paused, + Settings = Json(settings, s), + HasPassword = c.HasSecret, + SecretError = s.ServerText(c.SecretError), + c.LastRunAt, + c.LastSuccess, + c.LastDurationMs, + LastMessage = c.LastSuccess == false ? s.Value(c.LastMessage) : c.LastMessage, + c.ConsecutiveFailures, + Findings = Counts(counts), + c.CreatedAt, + CreatedBy = s.Value(c.CreatedBy), + c.UpdatedAt, + UpdatedBy = s.Value(c.UpdatedBy), + }; + } + + private static object Json(StoredServerFinding f, Sensitive s) => new { Id = f.FindingId, Scope = f.Scope == FindingScope.Server ? "server" : "check", ScopeId = f.ScopeId, Name = f.ScopeName, + f.ScopePaused, + f.ScopeRemoved, + Status = f.ResolvedAt is null ? "active" : "resolved", f.RuleId, f.Subject, Severity = f.Severity.ToString(), - f.Title, - f.Explanation, - f.Recommendation, - Evidence = f.Evidence.Select(e => new { e.Name, Value = Redact(e.Value, options, e.Sensitive) }), + Title = s.ServerText(f.Title), + Explanation = s.ServerText(f.Explanation), + Recommendation = s.ServerText(f.Recommendation), + Evidence = f.Evidence.Select(e => new { e.Name, Value = s.Value(e.Value, e.Sensitive), e.Sensitive }), References = f.References.Select(r => new { r.Title, r.Url }), f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt, }; - private static object Json(AgentRequestRow r) => new - { - Id = r.RequestId, - r.Kind, - r.Status, - r.RequestedBy, - r.RequestedAt, - r.StartedAt, - r.CompletedAt, - r.Error, - Result = r.Result is { Length: > 0 } result ? JsonDocument.Parse(result).RootElement.Clone() : (JsonElement?)null, - }; + private static IResult Accepted(AgentRequestRow request, Sensitive s) => + Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", ConfigurationApiEndpoints.Json(request, s)); // ---- Windows servers -------------------------------------------------------------------------------------- - private static async Task ListServersAsync(InsightRepository repository, CancellationToken ct) => - Results.Ok((await repository.GetWindowsServersAsync(ct).ConfigureAwait(false)).Select(Json)); + private static async Task<(IReadOnlyDictionary Overviews, IReadOnlyDictionary<(string Scope, int ScopeId), IReadOnlyDictionary> Counts)> + OverviewAsync(InsightRepository repository, CancellationToken ct) => + (await repository.GetWindowsServerOverviewsAsync(DateTimeOffset.UtcNow.AddMinutes(-10), ct).ConfigureAwait(false), + await repository.GetServerFindingCountsAsync(ct).ConfigureAwait(false)); - private static async Task GetServerAsync(int id, InsightRepository repository, CancellationToken ct) => - await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row)) : Results.NotFound(); + private static async Task ServerJsonAsync(WindowsServerRow row, InsightRepository repository, Sensitive s, CancellationToken ct) + { + var (overviews, counts) = await OverviewAsync(repository, ct).ConfigureAwait(false); + return Json(row, s, overviews.GetValueOrDefault(row.ServerId), counts.GetValueOrDefault((FindingScope.Server, row.ServerId))); + } + + private static async Task ListServersAsync(InsightRepository repository, IOptions options, CancellationToken ct) + { + var s = Sensitive.For(options); + var servers = await repository.GetWindowsServersAsync(ct).ConfigureAwait(false); + var (overviews, counts) = await OverviewAsync(repository, ct).ConfigureAwait(false); + return Results.Ok(servers.Select(w => Json(w, s, overviews.GetValueOrDefault(w.ServerId), counts.GetValueOrDefault((FindingScope.Server, w.ServerId))))); + } + + private static async Task GetServerAsync(int id, InsightRepository repository, IOptions options, CancellationToken ct) => + await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is { } row + ? Results.Ok(await ServerJsonAsync(row, repository, Sensitive.For(options), ct).ConfigureAwait(false)) + : Results.NotFound(); private static async Task AddServerAsync(WindowsServerBody body, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) { @@ -197,7 +277,8 @@ public static class ServerApiEndpoints } var row = (await repository.GetWindowsServerAsync(outcome.Id!.Value, ct).ConfigureAwait(false))!; - return Results.Created($"/api/v1/windows-servers/{row.ServerId}", new { Server = Json(row), ServerTestRequestId = outcome.RequestId }); + return Results.Created($"/api/v1/windows-servers/{row.ServerId}", + new { Server = await ServerJsonAsync(row, repository, Sensitive.For(options), ct).ConfigureAwait(false), ServerTestRequestId = outcome.RequestId }); } private static async Task PatchServerAsync(int id, WindowsServerPatch body, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) @@ -225,7 +306,7 @@ public static class ServerApiEndpoints await repository.SetWindowsServerPausedAsync(id, paused, by, DateTimeOffset.UtcNow, ct).ConfigureAwait(false); } - return Results.Ok(Json((await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false))!)); + return Results.Ok(await ServerJsonAsync((await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false))!, repository, Sensitive.For(options), ct).ConfigureAwait(false)); } private static async Task RemoveServerAsync(int id, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) @@ -257,11 +338,10 @@ public static class ServerApiEndpoints return Results.NotFound(); } - var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!; - return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request)); + return Accepted((await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!, Sensitive.For(options)); } - private static async Task LatestTestAsync(int id, InsightRepository repository, CancellationToken ct) + private static async Task LatestTestAsync(int id, InsightRepository repository, IOptions options, CancellationToken ct) { if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null) { @@ -274,11 +354,16 @@ public static class ServerApiEndpoints return Results.NotFound(); } - var result = AgentRequestJson.Deserialize(test.Result); - return Results.Ok(new { Request = Json(test), MissingAreas = result?.Missing.Select(a => new { a.Area, a.Right, a.Detail }).ToList() ?? [] }); + var s = Sensitive.For(options); + var result = AgentRequestJson.Deserialize(test.Result) is { } stored ? s.ServerTest(stored) : null; + return Results.Ok(new + { + Request = ConfigurationApiEndpoints.Json(test, s), + MissingAreas = result?.Missing.Select(a => new { a.Area, a.Right, a.Detail }).ToList() ?? [], + }); } - private static async Task GrantScriptAsync(int id, InsightRepository repository, CancellationToken ct) + private static async Task GrantScriptAsync(int id, InsightRepository repository, IOptions options, CancellationToken ct) { if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is not { } row) { @@ -286,6 +371,18 @@ public static class ServerApiEndpoints } var (account, script, note) = await ServerSetup.GrantScriptAsync(repository, row, ct).ConfigureAwait(false); + if (!Sensitive.For(options).Include && account is not null) + { + // The script and the note name the agent's account; without sensitive data it is the shipped script with its placeholder. + return Results.Ok(new + { + Account = ApiV1Endpoints.Redacted, + Script = ServerGrantScript.Template(), + Note = "Agentens konto er udeladt (SQLInsight:Web:ApiIncludesSensitiveEvidence er slået fra). Angiv den med -Account, " + + "eller hent det udfyldte script på serverens side under Windows-servere.", + }); + } + return Results.Ok(new { Account = account, Script = script, Note = note }); } @@ -368,20 +465,29 @@ public static class ServerApiEndpoints } var now = DateTimeOffset.UtcNow; + var s = Sensitive.For(options); + var snapshot = await repository.GetServerSnapshotAsync(id, ServerArea.Events, ct).ConfigureAwait(false); var events = await repository.GetServerEventsAsync(id, now.AddDays(-Math.Clamp(days ?? 1, 1, 90)), now, cancellationToken: ct).ConfigureAwait(false); - return Results.Ok(events.Select(e => new + return Results.Ok(new { - e.CollectedAt, - Log = e.LogName, - e.Source, - e.EventId, - e.Level, - e.Category, - e.Count, - e.FirstAt, - e.LastAt, - Sample = Redact(e.Sample, options), - })); + CapturedAt = snapshot?.CapturedAt, + snapshot?.Method, + snapshot?.Ignored, + snapshot?.Coverage, + Events = events.Select(e => new + { + e.CollectedAt, + Log = e.LogName, + e.Source, + e.EventId, + e.Level, + e.Category, + e.Count, + e.FirstAt, + e.LastAt, + Sample = s.Value(e.Sample), + }), + }); } private static async Task TasksAsync(int id, InsightRepository repository, CancellationToken ct) @@ -411,7 +517,7 @@ public static class ServerApiEndpoints }); } - private static async Task RuleStatesAsync(string scope, int id, InsightRepository repository, CancellationToken ct) + private static async Task RuleStatesAsync(string scope, int id, InsightRepository repository, IOptions options, CancellationToken ct) { var exists = scope == FindingScope.Server ? await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is not null @@ -421,18 +527,20 @@ public static class ServerApiEndpoints return Results.NotFound(); } - return Results.Ok((await repository.GetServerRuleStatesAsync(scope, id, ct).ConfigureAwait(false)).Select(s => new + var s = Sensitive.For(options); + return Results.Ok((await repository.GetServerRuleStatesAsync(scope, id, ct).ConfigureAwait(false)).Select(r => new { - s.RuleId, - s.RuleTitle, - Outcome = char.ToLowerInvariant(s.Outcome.ToString()[0]) + s.Outcome.ToString()[1..], - s.Reason, - s.EvaluatedAt, + r.RuleId, + r.RuleTitle, + Outcome = char.ToLowerInvariant(r.Outcome.ToString()[0]) + r.Outcome.ToString()[1..], + // A reason may name the agent's account ("Agentens konto (…) mangler …"): redacted like accounts elsewhere. + Reason = s.ServerText(r.Reason), + r.EvaluatedAt, })); } - private static async Task FindingsAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? status = null, string? scope = null, - int? id = null) + private static async Task FindingsAsync(HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct, string? status = null, + string? scope = null, int? id = null, int? limit = null, string? continuation = null) { var findingStatus = status?.ToLowerInvariant() switch { @@ -453,23 +561,84 @@ public static class ServerApiEndpoints return Results.BadRequest("status skal være active, resolved eller all; scope skal være server eller check (og angives sammen med id)."); } - var findings = await repository.GetServerFindingsAsync(findingScope, id, findingStatus.Value, cancellationToken: ct).ConfigureAwait(false); - return Results.Ok(findings.Select(f => Json(f, options))); + var offset = 0; + if (continuation is { Length: > 0 } && !int.TryParse(continuation, NumberStyles.None, CultureInfo.InvariantCulture, out offset)) + { + return Results.BadRequest("Ugyldig continuation."); + } + + var size = Math.Clamp(limit ?? 100, 1, MaxFindingPage); + var findings = await repository.GetServerFindingsAsync(findingScope, id, findingStatus.Value, size + 1, offset, ct).ConfigureAwait(false); + if (findings.Count > size) + { + http.Response.Headers["X-Continuation"] = ((long)offset + size).ToString(CultureInfo.InvariantCulture); + } + + var s = Sensitive.For(options); + return Results.Ok(findings.Take(size).Select(f => Json(f, s))); } - private static async Task FindingAsync(long id, InsightRepository repository, IOptions options, CancellationToken ct) => - await repository.GetServerFindingAsync(id, ct).ConfigureAwait(false) is { } finding ? Results.Ok(Json(finding, options)) : Results.NotFound(); + private static async Task FindingAsync(long id, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (await repository.GetServerFindingAsync(id, ct).ConfigureAwait(false) is not { } f) + { + return Results.NotFound(); + } + + var s = Sensitive.For(options); + var history = await repository.GetServerFindingHistoryAsync(f, ct).ConfigureAwait(false); + return Results.Ok(new + { + Id = f.FindingId, + Scope = f.Scope == FindingScope.Server ? "server" : "check", + ScopeId = f.ScopeId, + Name = f.ScopeName, + f.ScopePaused, + f.ScopeRemoved, + Status = f.ResolvedAt is null ? "active" : "resolved", + f.RuleId, + f.Subject, + Severity = f.Severity.ToString(), + Title = s.ServerText(f.Title), + Explanation = s.ServerText(f.Explanation), + Recommendation = s.ServerText(f.Recommendation), + Evidence = f.Evidence.Select(e => new { e.Name, Value = s.Value(e.Value, e.Sensitive), e.Sensitive }), + References = f.References.Select(r => new { r.Title, r.Url }), + f.FirstSeenAt, + f.LastSeenAt, + f.ResolvedAt, + History = history.Select(h => new + { + Id = h.FindingId, + Severity = h.Severity.ToString(), + Title = s.ServerText(h.Title), + h.FirstSeenAt, + h.LastSeenAt, + h.ResolvedAt, + }), + }); + } // ---- Synthetic checks ------------------------------------------------------------------------------------- private static CheckInput Input(SyntheticCheckBody body) => new(body.Name, body.Kind, body.ServerId, body.Settings ?? new SyntheticCheckSettings(), body.Password, body.IntervalSeconds, body.TimeoutSeconds); - private static async Task ListChecksAsync(InsightRepository repository, IOptions options, CancellationToken ct) => - Results.Ok((await repository.GetSyntheticChecksAsync(ct).ConfigureAwait(false)).Select(c => Json(c, options))); + private static async Task CheckJsonAsync(SyntheticCheckRow row, InsightRepository repository, Sensitive s, CancellationToken ct) => + Json(row, s, (await repository.GetServerFindingCountsAsync(ct).ConfigureAwait(false)).GetValueOrDefault((FindingScope.Check, row.CheckId))); + + private static async Task ListChecksAsync(InsightRepository repository, IOptions options, CancellationToken ct) + { + var s = Sensitive.For(options); + var checks = await repository.GetSyntheticChecksAsync(ct).ConfigureAwait(false); + var counts = await repository.GetServerFindingCountsAsync(ct).ConfigureAwait(false); + return Results.Ok(checks.Select(c => Json(c, s, counts.GetValueOrDefault((FindingScope.Check, c.CheckId))))); + } private static async Task GetCheckAsync(int id, InsightRepository repository, IOptions options, CancellationToken ct) => - await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row, options)) : Results.NotFound(); + await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false) is { } row + ? Results.Ok(await CheckJsonAsync(row, repository, Sensitive.For(options), ct).ConfigureAwait(false)) + : Results.NotFound(); private static async Task AddCheckAsync(SyntheticCheckBody body, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) { @@ -485,7 +654,8 @@ public static class ServerApiEndpoints } var row = (await repository.GetSyntheticCheckAsync(outcome.Id!.Value, ct).ConfigureAwait(false))!; - return Results.Created($"/api/v1/synthetic-checks/{row.CheckId}", new { Check = Json(row, options), RunRequestId = outcome.RequestId }); + return Results.Created($"/api/v1/synthetic-checks/{row.CheckId}", + new { Check = await CheckJsonAsync(row, repository, Sensitive.For(options), ct).ConfigureAwait(false), RunRequestId = outcome.RequestId }); } private static async Task UpdateCheckAsync(int id, SyntheticCheckBody body, HttpContext http, InsightRepository repository, IOptions options, @@ -502,7 +672,9 @@ public static class ServerApiEndpoints return Results.NotFound(); } - return outcome.Error is { } error ? Invalid(error) : Results.Ok(Json((await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false))!, options)); + return outcome.Error is { } error + ? Invalid(error) + : Results.Ok(await CheckJsonAsync((await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false))!, repository, Sensitive.For(options), ct).ConfigureAwait(false)); } private static async Task PatchCheckAsync(int id, SyntheticCheckPatch body, HttpContext http, InsightRepository repository, IOptions options, @@ -523,7 +695,7 @@ public static class ServerApiEndpoints await repository.SetSyntheticCheckPausedAsync(id, paused, http.User.Identity?.Name, DateTimeOffset.UtcNow, ct).ConfigureAwait(false); } - return Results.Ok(Json((await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false))!, options)); + return Results.Ok(await CheckJsonAsync((await repository.GetSyntheticCheckAsync(id, ct).ConfigureAwait(false))!, repository, Sensitive.For(options), ct).ConfigureAwait(false)); } private static async Task RemoveCheckAsync(int id, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) @@ -549,8 +721,12 @@ public static class ServerApiEndpoints return Results.NotFound(); } - var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!; - return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request)); + if (outcome.Conflict) + { + return Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "På pause", detail: outcome.Error); + } + + return Accepted((await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!, Sensitive.For(options)); } private static async Task ResultsAsync(int id, InsightRepository repository, IOptions options, CancellationToken ct, int? hours = null) @@ -560,14 +736,15 @@ public static class ServerApiEndpoints return Results.NotFound(); } + var s = Sensitive.For(options); var results = await repository.GetSyntheticCheckResultsAsync(id, 5000, DateTimeOffset.UtcNow.AddHours(-Math.Clamp(hours ?? 24, 1, 24 * 31)), ct).ConfigureAwait(false); return Results.Ok(results.Select(r => new { r.RunAt, r.Success, r.DurationMs, - Message = r.Success ? r.Message : Redact(r.Message, options), - Details = r.Details.Select(d => new { d.Name, Value = Redact(d.Value, options, d.Sensitive) }), + Message = r.Success ? r.Message : s.Value(r.Message), + Details = r.Details.Select(d => new { d.Name, Value = s.Value(d.Value, d.Sensitive), d.Sensitive }), r.CertificateExpiresAt, r.CertificateSubject, r.ServiceState, diff --git a/src/SQLInsight.Web/ServerSetup.cs b/src/SQLInsight.Web/ServerSetup.cs index afa9d93..2e1242a 100644 --- a/src/SQLInsight.Web/ServerSetup.cs +++ b/src/SQLInsight.Web/ServerSetup.cs @@ -37,10 +37,19 @@ public static class ServerSetup public static string FindingPage(long id) => ServersPage + "/fund/" + id.ToString(CultureInfo.InvariantCulture); public const string NotAdminText = - "Kun administratorer kan ændre servere og syntetiske tjek. Bed en administrator om at tilføje jeres gruppe under SQLInsight:Web:AdminGroups."; + "Kun administratorer kan ændre servere og syntetiske tjek (SQLInsight:Web:AdminGroups, eller den lokale gruppe Administratorer på webserveren, når den er tom). Bed en administrator om at tilføje jeres gruppe."; + + public const string PausedCheckText = "Tjekket er sat på pause og køres ikke. Genoptag det først."; public sealed record Outcome(int? Id = null, long? RequestId = null, string? Error = null, bool NotFound = false, bool Conflict = false); + /// Service names as the form's list gives them: trimmed, without empty ones and duplicates (any case). + public static IReadOnlyList? CleanList(IReadOnlyList? names) => + names?.Select(n => n?.Trim() ?? string.Empty).Where(n => n.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + private static ServerInput Clean(ServerInput input) => + input with { WatchedServices = CleanList(input.WatchedServices), IgnoredServices = CleanList(input.IgnoredServices) }; + private static string? Validate(ServerInput input, bool creating) { if (creating && ServerHost.Validate(input.Host) is { } host) @@ -61,6 +70,7 @@ public static class ServerSetup { ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(input); + input = Clean(input); var name = string.IsNullOrWhiteSpace(input.Name) ? (input.Host is null ? null : ServerHost.ShortName(input.Host)) : input.Name.Trim(); if ((Validate(input, creating: true) ?? ServerAddress.ValidateName(name)) is { } invalid) { @@ -87,6 +97,7 @@ public static class ServerSetup { ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(input); + input = Clean(input); var row = await repository.GetWindowsServerAsync(id, cancellationToken).ConfigureAwait(false); if (row is null) { @@ -103,6 +114,7 @@ public static class ServerSetup return new Outcome(id); } + /// Asks the agent to test a server (a test already waiting is reused). public static async Task RequestServerTestAsync(InsightRepository repository, int id, string? by, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(repository); @@ -196,12 +208,16 @@ public static class ServerSetup return new Outcome(id); } + /// Asks the agent to run a check now (a run already waiting is reused). A paused check is not run: . public static async Task RequestCheckRunAsync(InsightRepository repository, int id, string? by, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(repository); - return await repository.GetSyntheticCheckAsync(id, cancellationToken).ConfigureAwait(false) is null - ? new Outcome(NotFound: true) - : new Outcome(id, await repository.AddServerRequestAsync(ServerRequestKind.SyntheticCheckRun, id, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false)); + return await repository.GetSyntheticCheckAsync(id, cancellationToken).ConfigureAwait(false) switch + { + null => new Outcome(NotFound: true), + { Paused: true } => new Outcome(id, Error: PausedCheckText, Conflict: true), + _ => new Outcome(id, await repository.AddServerRequestAsync(ServerRequestKind.SyntheticCheckRun, id, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false)), + }; } private static async Task<(SyntheticCheckInput? Check, string? Error)> PrepareAsync(InsightRepository repository, CheckInput input, SyntheticCheckRow? existing, @@ -306,6 +322,14 @@ public static class ServerSetup _ => mode ?? "–", }; + /// "hvert minut", "hvert 5. minut" or, when not whole minutes (set through the API), "hvert 90. sekund". + public static string IntervalText(int seconds) => seconds switch + { + 60 => "hvert minut", + _ when seconds % 60 == 0 => string.Create(CultureInfo.InvariantCulture, $"hvert {seconds / 60}. minut"), + _ => string.Create(CultureInfo.InvariantCulture, $"hvert {seconds}. sekund"), + }; + public static string Size(double gigabytes) => gigabytes >= 1024 ? Display.Number(gigabytes / 1024, 1) + " TB" : Display.Number(gigabytes, gigabytes < 10 ? 1 : 0) + " GB"; /// The comma-separated status codes from a form field. diff --git a/tests/SQLInsight.IntegrationTests/ServerApiTests.cs b/tests/SQLInsight.IntegrationTests/ServerApiTests.cs index b1e67db..ac3ac07 100644 --- a/tests/SQLInsight.IntegrationTests/ServerApiTests.cs +++ b/tests/SQLInsight.IntegrationTests/ServerApiTests.cs @@ -5,6 +5,7 @@ using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using SQLInsight.Core.Configuration; using SQLInsight.Core.Findings; using SQLInsight.Core.Servers; using SQLInsight.Core.SyntheticChecks; @@ -124,7 +125,12 @@ public sealed class ServerApiTests(SqlServerFixture sql) : IAsyncLifetime ], now); var list = await JsonAsync(await read.GetAsync("/api/v1/windows-servers")); - Assert.Equal("aos01.firma.local", Assert.Single(list.EnumerateArray()).GetProperty("host").GetString()); + var listed = Assert.Single(list.EnumerateArray()); + Assert.Equal("aos01.firma.local", listed.GetProperty("host").GetString()); + Assert.Equal(42, listed.GetProperty("latest").GetProperty("cpuPercent").GetDouble(), 3); + Assert.Equal(@"D:\", listed.GetProperty("worstDisk").GetProperty("volume").GetString()); + Assert.Equal("fuld om ca. 12 dage", listed.GetProperty("soonestFull").GetProperty("text").GetString()); + Assert.Equal(1, listed.GetProperty("findings").GetProperty("high").GetInt32()); Assert.Equal(1, (await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/performance"))).GetArrayLength()); var disks = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/disks")); Assert.Equal("fuld om ca. 12 dage", disks.GetProperty("disks")[0].GetProperty("forecast").GetProperty("text").GetString()); @@ -132,19 +138,23 @@ public sealed class ServerApiTests(SqlServerFixture sql) : IAsyncLifetime var tasks = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/tasks")); Assert.True(tasks.GetProperty("tasks")[0].GetProperty("failed").GetBoolean()); var events = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/events")); - Assert.Equal(ApiV1Endpoints.Redacted, events[0].GetProperty("sample").GetString()); + Assert.Equal(ApiV1Endpoints.Redacted, events.GetProperty("events")[0].GetProperty("sample").GetString()); var findings = await JsonAsync(await read.GetAsync($"/api/v1/server-findings?scope=server&id={id}")); var finding = Assert.Single(findings.EnumerateArray()); Assert.DoesNotContain("hemmeligt", finding.ToString(), StringComparison.Ordinal); + Assert.True(finding.GetProperty("evidence")[1].GetProperty("sensitive").GetBoolean()); var findingId = finding.GetProperty("id").GetInt64(); Assert.Equal("SQLI-WIN-009", (await JsonAsync(await read.GetAsync($"/api/v1/server-findings/{findingId}"))).GetProperty("ruleId").GetString()); Assert.Equal(HttpStatusCode.BadRequest, (await read.GetAsync("/api/v1/server-findings?id=1")).StatusCode); var states = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/rule-states")); Assert.Equal("findings", Assert.Single(states.EnumerateArray()).GetProperty("outcome").GetString()); + // The agent's account is customer data: without sensitive data the shipped script with its placeholder. var grant = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/grant-script")); - Assert.Equal(@"FIRMA\gmsa-sqlinsight$", grant.GetProperty("account").GetString()); - Assert.Contains(@"$Account = 'FIRMA\gmsa-sqlinsight$'", grant.GetProperty("script").GetString(), StringComparison.Ordinal); + Assert.Equal(ApiV1Endpoints.Redacted, grant.GetProperty("account").GetString()); + Assert.Equal(ServerGrantScript.Template(), grant.GetProperty("script").GetString()); + Assert.DoesNotContain("gmsa-sqlinsight", grant.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("AGENT01", grant.ToString(), StringComparison.Ordinal); Assert.Equal(HttpStatusCode.Forbidden, (await read.PatchAsync($"/api/v1/windows-servers/{id}", Body(new { paused = true }))).StatusCode); var patched = await JsonAsync(await write.PatchAsync($"/api/v1/windows-servers/{id}", Body(new { paused = true, ignoredServices = Enumerable.Repeat("MyBatch", 1) }))); @@ -208,8 +218,11 @@ public sealed class ServerApiTests(SqlServerFixture sql) : IAsyncLifetime name = "SQL01 login", settings = new { server = "sql02", authentication = "SqlLogin", userName = "axapp" }, }))).StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PutAsync($"/api/v1/synthetic-checks/{id}", Body(new { name = "SQL01 login", kind = "Tcp" }))).StatusCode); - Assert.True((await JsonAsync(await write.PatchAsync($"/api/v1/synthetic-checks/{id}", Body(new { paused = true })))).GetProperty("paused").GetBoolean()); Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync($"/api/v1/synthetic-checks/{id}/run", Body(new { }))).StatusCode); + Assert.True((await JsonAsync(await write.PatchAsync($"/api/v1/synthetic-checks/{id}", Body(new { paused = true })))).GetProperty("paused").GetBoolean()); + var paused = await write.PostAsync($"/api/v1/synthetic-checks/{id}/run", Body(new { })); + Assert.Equal(HttpStatusCode.Conflict, paused.StatusCode); + Assert.Contains("pause", (await JsonAsync(paused)).GetProperty("detail").GetString(), StringComparison.Ordinal); Assert.Equal(HttpStatusCode.OK, (await read.GetAsync($"/api/v1/synthetic-checks/{id}/rule-states")).StatusCode); Assert.Single((await JsonAsync(await read.GetAsync("/api/v1/synthetic-checks"))).EnumerateArray()); Assert.Contains((await JsonAsync(await read.GetAsync("/api/v1/rules"))).EnumerateArray(), r => r.GetProperty("id").GetString() == "SQLI-SYN-001"); @@ -217,6 +230,150 @@ public sealed class ServerApiTests(SqlServerFixture sql) : IAsyncLifetime Assert.Equal(HttpStatusCode.NoContent, (await write.DeleteAsync($"/api/v1/synthetic-checks/{id}")).StatusCode); } + [Fact] + public async Task Repeated_tests_and_runs_reuse_the_request_waiting_for_the_agent() + { + var write = Client(WriteToken); + var server = (await JsonAsync(await write.PostAsync("/api/v1/windows-servers", Body(new { host = "fil01.firma.local", role = "File" })))).GetProperty("server"); + var serverId = server.GetProperty("id").GetInt32(); + var created = await JsonAsync(await write.PostAsync("/api/v1/synthetic-checks", Body(new { name = "FIL01 SMB", kind = "Tcp", settings = new { host = "fil01", port = 445 } }))); + var checkId = created.GetProperty("check").GetProperty("id").GetInt32(); + var firstRun = created.GetProperty("runRequestId").GetInt64(); + + var test1 = await JsonAsync(await write.PostAsync($"/api/v1/windows-servers/{serverId}/test", Body(new { }))); + var test2 = await JsonAsync(await write.PostAsync($"/api/v1/windows-servers/{serverId}/test", Body(new { }))); + var run1 = await JsonAsync(await write.PostAsync($"/api/v1/synthetic-checks/{checkId}/run", Body(new { }))); + var run2 = await JsonAsync(await write.PostAsync($"/api/v1/synthetic-checks/{checkId}/run", Body(new { }))); + + Assert.Equal(test1.GetProperty("id").GetInt64(), test2.GetProperty("id").GetInt64()); + Assert.Equal(serverId, test1.GetProperty("serverId").GetInt32()); + Assert.Equal(firstRun, run1.GetProperty("id").GetInt64()); + Assert.Equal(firstRun, run2.GetProperty("id").GetInt64()); + Assert.Equal(checkId, run2.GetProperty("checkId").GetInt32()); + + // Taken by the agent: the next click queues a new one. + while (await _repository.ClaimAgentRequestAsync(DateTimeOffset.UtcNow) is { } claimed) + { + await _repository.CompleteAgentRequestAsync(claimed.RequestId, true, null, null, DateTimeOffset.UtcNow); + } + + Assert.NotEqual(firstRun, (await JsonAsync(await write.PostAsync($"/api/v1/synthetic-checks/{checkId}/run", Body(new { })))).GetProperty("id").GetInt64()); + } + + [Fact] + public async Task Accounts_logins_user_names_and_url_queries_are_left_out_of_the_api() + { + var read = Client(ReadToken); + var now = DateTimeOffset.UtcNow; + const string account = @"FIRMA\gmsa-sqlinsight$"; + var id = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), @"FIRMA\anna", now); + var denied = $"Adgang nægtet til hændelseslog på aos01 over DCOM: Agentens konto ({account}) mangler Event Log Readers."; + await _repository.RecordServerContactAsync(id, new ServerOsInfo { Caption = "Windows Server 2019" }, "DCOM", + [new ServerAreaStatus(ServerArea.Events, false, denied, ServerArea.Right(ServerArea.Events))], now.AddMinutes(-1)); + await _repository.RecordServerErrorAsync(id, $"Adgang nægtet til serveren på aos01 over WS-Man: Agentens konto ({account}) mangler Remote Enable.", now); + await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, id, + [new RuleResult { RuleId = "SQLI-WIN-009", RuleTitle = "Hændelseslog", Outcome = RuleOutcome.NotMeasurable, Reason = denied }], now); + var test = await _repository.AddServerRequestAsync(ServerRequestKind.ServerTest, id, @"FIRMA\anna", now); + await _repository.ClaimAgentRequestAsync(now); + await _repository.CompleteAgentRequestAsync(test, true, AgentRequestJson.Serialize(new WindowsServerTestResult + { + Reachable = true, Protocol = "DCOM", Account = account, Areas = [new ServerAreaStatus(ServerArea.Events, false, denied, ServerArea.Right(ServerArea.Events))], + Notes = [$"WS-Man blev afvist for {account}; DCOM bruges."], + }), null, now); + + var check = await _repository.AddSyntheticCheckAsync(new SyntheticCheckInput("EP", SyntheticCheckKind.Http, null, + new SyntheticCheckSettings { Url = new Uri("https://ep.firma.local/sites/ax?token=hemmelig-noegle&x=1") }, null, null, 300, 15), @"FIRMA\bo", now); + var sql = await _repository.AddSyntheticCheckAsync(new SyntheticCheckInput("SQL", SyntheticCheckKind.Sql, null, + new SyntheticCheckSettings { Server = "sql01", Authentication = "SqlLogin", UserName = "axapp" }, [1, 2, 3], "agent-api", 300, 15), @"FIRMA\bo", now); + var run = await _repository.AddServerRequestAsync(ServerRequestKind.SyntheticCheckRun, sql, @"FIRMA\bo", now); + await _repository.ClaimAgentRequestAsync(now); + await _repository.CompleteAgentRequestAsync(run, true, AgentRequestJson.Serialize(new SyntheticCheckResult + { + At = now, Success = false, Message = "Login failed for user 'axapp'.", Details = [new SyntheticCheckDetail("Server", "sql01"), new SyntheticCheckDetail("Login", "axapp", Sensitive: true)], + }), null, now); + + var serverJson = (await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}"))).ToString(); + var testJson = (await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/test"))).ToString(); + var requestJson = (await JsonAsync(await read.GetAsync($"/api/v1/agent-requests/{test}"))).ToString(); + var statesJson = (await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/rule-states"))).ToString(); + var httpJson = await JsonAsync(await read.GetAsync($"/api/v1/synthetic-checks/{check}")); + var sqlJson = await JsonAsync(await read.GetAsync($"/api/v1/synthetic-checks/{sql}")); + var runJson = await JsonAsync(await read.GetAsync($"/api/v1/agent-requests/{run}")); + var listJson = (await JsonAsync(await read.GetAsync("/api/v1/synthetic-checks"))).ToString(); + + foreach (var json in new[] { serverJson, testJson, requestJson, statesJson, httpJson.ToString(), sqlJson.ToString(), runJson.ToString(), listJson }) + { + Assert.DoesNotContain("gmsa", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("anna", json, StringComparison.Ordinal); + Assert.DoesNotContain(@"FIRMA\\bo", json, StringComparison.Ordinal); + Assert.DoesNotContain("axapp", json, StringComparison.Ordinal); + Assert.DoesNotContain("hemmelig-noegle", json, StringComparison.Ordinal); + } + + Assert.Contains("Event Log Readers", serverJson, StringComparison.Ordinal); + Assert.Contains("Agentens konto ([udeladt])", serverJson, StringComparison.Ordinal); + Assert.Contains("Agentens konto ([udeladt])", statesJson, StringComparison.Ordinal); + Assert.Contains("\"account\":\"[udeladt]\"", requestJson, StringComparison.Ordinal); + Assert.Contains("\"serverId\":" + id.ToString(System.Globalization.CultureInfo.InvariantCulture), requestJson, StringComparison.Ordinal); + Assert.Equal("https://ep.firma.local/sites/ax?[udeladt]", httpJson.GetProperty("target").GetString()); + Assert.Equal("https://ep.firma.local/sites/ax?[udeladt]", httpJson.GetProperty("settings").GetProperty("url").GetString()); + Assert.Equal(ApiV1Endpoints.Redacted, httpJson.GetProperty("createdBy").GetString()); + Assert.Equal(ApiV1Endpoints.Redacted, sqlJson.GetProperty("settings").GetProperty("userName").GetString()); + var result = runJson.GetProperty("result"); + Assert.Equal(ApiV1Endpoints.Redacted, result.GetProperty("message").GetString()); + Assert.Equal("sql01", result.GetProperty("details")[0].GetProperty("value").GetString()); + Assert.Equal(ApiV1Endpoints.Redacted, result.GetProperty("details")[1].GetProperty("value").GetString()); + Assert.Equal(ApiV1Endpoints.Redacted, runJson.GetProperty("requestedBy").GetString()); + } + + [Fact] + public async Task Server_findings_come_in_pages_with_history_and_the_event_log_with_how_it_was_read() + { + var read = Client(ReadToken); + var now = DateTimeOffset.UtcNow; + var id = await _repository.AddWindowsServerAsync(new NewWindowsServer("FIL01", "fil01", ServerRoles.File, [], []), null, now); + var paused = await _repository.AddWindowsServerAsync(new NewWindowsServer("FIL02", "fil02", ServerRoles.File, [], []), null, now); + static RuleResult Disks(params string[] volumes) => new() + { + RuleId = "SQLI-WIN-005", RuleTitle = "Disk", Outcome = volumes.Length == 0 ? RuleOutcome.Passed : RuleOutcome.Findings, + Findings = volumes.Select(v => new Finding { RuleId = "SQLI-WIN-005", Severity = Severity.High, Title = "Disk " + v, Explanation = "e", Recommendation = "r", Subject = "disk: " + v }) + .ToList(), + }; + + await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, id, [Disks("C:", "D:", "E:")], now.AddMinutes(-3)); + await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, id, [Disks("D:", "E:")], now.AddMinutes(-2)); + await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, id, [Disks("C:", "D:", "E:")], now.AddMinutes(-1)); + await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, paused, [Disks("C:")], now); + await _repository.SetWindowsServerPausedAsync(paused, true, null, now); + + var page1 = await read.GetAsync("/api/v1/server-findings?limit=2"); + var continuation = Assert.Single(page1.Headers.GetValues("X-Continuation")); + var page2 = await read.GetAsync("/api/v1/server-findings?limit=2&continuation=" + continuation); + Assert.Equal(2, (await JsonAsync(page1)).GetArrayLength()); + var last = Assert.Single((await JsonAsync(page2)).EnumerateArray()); + Assert.False(page2.Headers.Contains("X-Continuation")); + Assert.Equal(id, last.GetProperty("scopeId").GetInt32()); + Assert.Equal(HttpStatusCode.BadRequest, (await read.GetAsync("/api/v1/server-findings?continuation=x")).StatusCode); + var pausedOwn = Assert.Single((await JsonAsync(await read.GetAsync($"/api/v1/server-findings?scope=server&id={paused}"))).EnumerateArray()); + Assert.True(pausedOwn.GetProperty("scopePaused").GetBoolean()); + + var reopened = (await _repository.GetServerFindingsAsync(FindingScope.Server, id)).Single(f => f.Subject == "disk: C:"); + var detail = await JsonAsync(await read.GetAsync($"/api/v1/server-findings/{reopened.FindingId}")); + var earlier = Assert.Single(detail.GetProperty("history").EnumerateArray()); + Assert.NotEqual(JsonValueKind.Null, earlier.GetProperty("resolvedAt").ValueKind); + Assert.Equal("active", detail.GetProperty("status").GetString()); + + await _repository.SaveServerSnapshotAsync(id, ServerArea.Events, new ServerEventSnapshot + { + CapturedAt = now, ReadUntil = now, Method = "Event Log (RPC)", Ignored = 7, Coverage = "Kun de nyeste 500 hændelser er læst.", + }, now); + var events = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/events")); + Assert.Equal("Event Log (RPC)", events.GetProperty("method").GetString()); + Assert.Equal(7, events.GetProperty("ignored").GetInt32()); + Assert.Equal("Kun de nyeste 500 hændelser er læst.", events.GetProperty("coverage").GetString()); + Assert.Equal(0, events.GetProperty("events").GetArrayLength()); + } + [Fact] public async Task Openapi_describes_the_server_and_check_endpoints() {