Review 1.5: API v1 redacts the agent's account, logins, user names and URL queries; history, event log metadata, paging

- 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) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 15:52:45 +02:00
parent 1c9d24a6a2
commit 20829890e9
5 changed files with 667 additions and 175 deletions

View file

@ -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.");
}
/// <summary>Logins and people's names, "[udeladt]" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.</summary>
internal sealed class Sensitive(bool include)
{
public static Sensitive For(IOptions<WebOptions> options) => new(options.Value.ApiIncludesSensitiveEvidence);
public bool Include => include;
public string? Value(string? value) => include || value is null ? value : ApiV1Endpoints.Redacted;
/// <summary>An error text from SQL Server or the agent, with the login in it (Login failed for user '…') left out.</summary>
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
/// <summary>A request to the agent, the same in every endpoint (also the Windows server tests and check runs).</summary>
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),
};
/// <summary>The agent's result; a connection test's login and error text are redacted like the rest.</summary>
/// <summary>
/// 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.
/// </summary>
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<ConnectionTestResult>(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<ConnectionTestResult>(result) is { } test
? AgentRequestJson.Serialize(test with { Login = s.Value(test.Login), Error = s.Text(test.Error) })
: result,
ServerRequestKind.ServerTest => AgentRequestJson.Deserialize<WindowsServerTestResult>(result) is { } server
? AgentRequestJson.Serialize(s.ServerTest(server))
: null,
ServerRequestKind.SyntheticCheckRun => Deserialize<SyntheticCheckResult>(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;
}
}
/// <summary>As <see cref="AgentRequestJson.Deserialize{T}"/>, also for a type with required members the JSON lacks.</summary>
private static T? Deserialize<T>(string json)
where T : class
{
try
{
return JsonSerializer.Deserialize<T>(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,

View file

@ -0,0 +1,103 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using SQLInsight.Core.Servers;
using SQLInsight.Core.SyntheticChecks;
namespace SQLInsight.Web.Api;
/// <summary>
/// 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.
/// </summary>
internal sealed partial class Sensitive(bool include)
{
public static Sensitive For(IOptions<WebOptions> options) => new(options.Value.ApiIncludesSensitiveEvidence);
public bool Include => include;
public string? Value(string? value) => include || value is null ? value : ApiV1Endpoints.Redacted;
/// <summary>A value that is only redacted when <paramref name="sensitive"/> (evidence and details marked so).</summary>
public string? Value(string? value, bool sensitive) => sensitive ? Value(value) : value;
/// <summary>An error text from SQL Server or the agent, with the login in it (Login failed for user '…') left out.</summary>
public string? Text(string? value) => include || value is null ? value : LoginInText().Replace(value, m => m.Groups[1].Value + "'" + ApiV1Endpoints.Redacted + "'");
/// <summary>
/// A text about a Windows server (the agent's errors, area details, rule reasons): as <see cref="Text"/>, and the
/// agent's account in "Agentens konto (FIRMA\gmsa$)" and, when known, wherever <paramref name="account"/> occurs.
/// </summary>
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;
}
/// <summary>An HTTP address without its query (it may carry a token or a key): https://host/path?[udeladt].</summary>
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);
}
/// <summary>What a check points at, as <see cref="SyntheticCheckSettings.Target"/>, with an HTTP address's query left out.</summary>
public string Target(string kind, SyntheticCheckSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
return kind == SyntheticCheckKind.Http ? Url(settings.Url) ?? string.Empty : settings.Target(kind);
}
/// <summary>A Windows server test as the agent stored it, with the agent's account and its traces in the texts left out.</summary>
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(),
};
}
/// <summary>A check run as the agent stored it: a failure's message and details marked sensitive left out, as in …/results.</summary>
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();
/// <summary>"Agentens konto (FIRMA\gmsa$)", as SQLInsight.Servers.ServerErrors and the connection test write it: the part in brackets.</summary>
[GeneratedRegex(@"(?<=\bagentens konto \()[^)\r\n]+(?=\))", RegexOptions.IgnoreCase)]
private static partial Regex AccountInText();
}

View file

@ -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);
/// <summary>
/// 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.
/// </summary>
public static class ServerApiEndpoints
{
/// <summary>The most findings one page of /api/v1/server-findings returns.</summary>
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<WebOptions> 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<WebOptions> 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<WebOptions> 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<Severity, int>? 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<WebOptions> options) => new
private static object Json(WindowsServerRow w, Sensitive s, WindowsServerOverview? overview, IReadOnlyDictionary<Severity, int>? 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<WebOptions> options) => new
/// <summary>A check's settings, with the SQL login and an HTTP address's query redacted.</summary>
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<Severity, int>? 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<IResult> ListServersAsync(InsightRepository repository, CancellationToken ct) =>
Results.Ok((await repository.GetWindowsServersAsync(ct).ConfigureAwait(false)).Select(Json));
private static async Task<(IReadOnlyDictionary<int, WindowsServerOverview> Overviews, IReadOnlyDictionary<(string Scope, int ScopeId), IReadOnlyDictionary<Severity, int>> 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<IResult> 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<object> 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<IResult> ListServersAsync(InsightRepository repository, IOptions<WebOptions> 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<IResult> GetServerAsync(int id, InsightRepository repository, IOptions<WebOptions> 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<IResult> AddServerAsync(WindowsServerBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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<IResult> PatchServerAsync(int id, WindowsServerPatch body, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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<IResult> RemoveServerAsync(int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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<IResult> LatestTestAsync(int id, InsightRepository repository, CancellationToken ct)
private static async Task<IResult> LatestTestAsync(int id, InsightRepository repository, IOptions<WebOptions> 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<WindowsServerTestResult>(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<WindowsServerTestResult>(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<IResult> GrantScriptAsync(int id, InsightRepository repository, CancellationToken ct)
private static async Task<IResult> GrantScriptAsync(int id, InsightRepository repository, IOptions<WebOptions> 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<ServerEventSnapshot>(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<IResult> TasksAsync(int id, InsightRepository repository, CancellationToken ct)
@ -411,7 +517,7 @@ public static class ServerApiEndpoints
});
}
private static async Task<IResult> RuleStatesAsync(string scope, int id, InsightRepository repository, CancellationToken ct)
private static async Task<IResult> RuleStatesAsync(string scope, int id, InsightRepository repository, IOptions<WebOptions> 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<IResult> FindingsAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? status = null, string? scope = null,
int? id = null)
private static async Task<IResult> FindingsAsync(HttpContext http, InsightRepository repository, IOptions<WebOptions> 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<IResult> FindingAsync(long id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct) =>
await repository.GetServerFindingAsync(id, ct).ConfigureAwait(false) is { } finding ? Results.Ok(Json(finding, options)) : Results.NotFound();
private static async Task<IResult> FindingAsync(long id, InsightRepository repository, IOptions<WebOptions> 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<IResult> ListChecksAsync(InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct) =>
Results.Ok((await repository.GetSyntheticChecksAsync(ct).ConfigureAwait(false)).Select(c => Json(c, options)));
private static async Task<object> 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<IResult> ListChecksAsync(InsightRepository repository, IOptions<WebOptions> 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<IResult> GetCheckAsync(int id, InsightRepository repository, IOptions<WebOptions> 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<IResult> AddCheckAsync(SyntheticCheckBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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<IResult> UpdateCheckAsync(int id, SyntheticCheckBody body, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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<IResult> PatchCheckAsync(int id, SyntheticCheckPatch body, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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<IResult> RemoveCheckAsync(int id, HttpContext http, InsightRepository repository, IOptions<WebOptions> 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<IResult> ResultsAsync(int id, InsightRepository repository, IOptions<WebOptions> 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,

View file

@ -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);
/// <summary>Service names as the form's list gives them: trimmed, without empty ones and duplicates (any case).</summary>
public static IReadOnlyList<string>? CleanList(IReadOnlyList<string>? 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);
}
/// <summary>Asks the agent to test a server (a test already waiting is reused).</summary>
public static async Task<Outcome> RequestServerTestAsync(InsightRepository repository, int id, string? by, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(repository);
@ -196,12 +208,16 @@ public static class ServerSetup
return new Outcome(id);
}
/// <summary>Asks the agent to run a check now (a run already waiting is reused). A paused check is not run: <see cref="Outcome.Conflict"/>.</summary>
public static async Task<Outcome> 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 ?? "–",
};
/// <summary>"hvert minut", "hvert 5. minut" or, when not whole minutes (set through the API), "hvert 90. sekund".</summary>
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";
/// <summary>The comma-separated status codes from a form field.</summary>

View file

@ -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()
{