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:
parent
1c9d24a6a2
commit
20829890e9
5 changed files with 667 additions and 175 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SQLInsight.Core.Configuration;
|
using SQLInsight.Core.Configuration;
|
||||||
|
using SQLInsight.Core.Servers;
|
||||||
|
using SQLInsight.Core.SyntheticChecks;
|
||||||
using SQLInsight.Repository;
|
using SQLInsight.Repository;
|
||||||
|
|
||||||
namespace SQLInsight.Web.Api;
|
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")
|
.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);
|
.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)
|
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",
|
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).");
|
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),
|
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,
|
Id = r.RequestId,
|
||||||
r.Kind,
|
r.Kind,
|
||||||
InstanceId = r.ConfiguredInstanceId,
|
InstanceId = r.ConfiguredInstanceId,
|
||||||
|
ServerId = r.Kind == ServerRequestKind.ServerTest ? r.TargetId : null,
|
||||||
|
CheckId = r.Kind == ServerRequestKind.SyntheticCheckRun ? r.TargetId : null,
|
||||||
r.Status,
|
r.Status,
|
||||||
RequestedBy = s.Value(r.RequestedBy),
|
RequestedBy = s.Value(r.RequestedBy),
|
||||||
r.RequestedAt,
|
r.RequestedAt,
|
||||||
r.StartedAt,
|
r.StartedAt,
|
||||||
r.CompletedAt,
|
r.CompletedAt,
|
||||||
Error = s.Text(r.Error),
|
Error = s.ServerText(r.Error),
|
||||||
Result = ResultJson(r, s),
|
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)
|
private static JsonElement? ResultJson(AgentRequestRow r, Sensitive s)
|
||||||
{
|
{
|
||||||
if (r.Result is not { Length: > 0 } result)
|
if (r.Result is not { Length: > 0 } result)
|
||||||
|
|
@ -174,13 +168,50 @@ public static partial class ConfigurationApiEndpoints
|
||||||
return null;
|
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);
|
try
|
||||||
return document.RootElement.Clone();
|
{
|
||||||
|
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,
|
private static InstanceInput Input(MonitoredInstanceBody body) => new(body.Name, body.Server, body.Port, body.Authentication, body.UserName, body.Password,
|
||||||
|
|
|
||||||
103
src/SQLInsight.Web/Api/Sensitive.cs
Normal file
103
src/SQLInsight.Web/Api/Sensitive.cs
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using System.Text.Json;
|
using System.Globalization;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SQLInsight.Core.Configuration;
|
using SQLInsight.Core.Configuration;
|
||||||
using SQLInsight.Core.Findings;
|
using SQLInsight.Core.Findings;
|
||||||
|
|
@ -29,158 +29,238 @@ public sealed record SyntheticCheckPatch(bool? Paused);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Windows servers and synthetic checks through the API: everything the pages Windows-servere and Syntetiske tjek
|
/// 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
|
/// show and do. Reading needs the scope read; changing needs config:write for tokens, or for Windows users membership of
|
||||||
/// Windows users. Tests and check runs are queued for the agent (202 with a request id for /api/v1/agent-requests/{id}).
|
/// SQLInsight:Web:AdminGroups (the web server's local Administrators while that is empty). Tests and check runs are
|
||||||
/// Passwords are write-only. Event messages, failure messages and evidence marked sensitive are "[udeladt]" unless
|
/// queued for the agent (202 with a request id for /api/v1/agent-requests/{id}); one already waiting is reused.
|
||||||
/// SQLInsight:Web:ApiIncludesSensitiveEvidence is on.
|
/// 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>
|
/// </summary>
|
||||||
public static class ServerApiEndpoints
|
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)
|
public static void Map(IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(app);
|
ArgumentNullException.ThrowIfNull(app);
|
||||||
var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
|
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")
|
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);
|
.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");
|
api.MapGet("/windows-servers/{id:int}", GetServerAsync).WithSummary("Én Windows-server / One Windows server").WithDescription(overview + redacted);
|
||||||
api.MapPatch("/windows-servers/{id:int}", PatchServerAsync)
|
api.MapPatch("/windows-servers/{id:int}", PatchServerAsync)
|
||||||
.WithSummary("Pause, rolle, overvågede og ignorerede tjenester / Pause, role, watched and ignored services").WithDescription(write.Trim());
|
.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")
|
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")
|
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);
|
.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");
|
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)
|
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}/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}/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}/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))
|
api.MapGet("/windows-servers/{id:int}/rule-states", (int id, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct) =>
|
||||||
.WithSummary("Reglernes udfald på serveren / Rule outcomes on the server");
|
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)
|
api.MapGet("/server-findings", FindingsAsync)
|
||||||
.WithSummary("Fund på Windows-servere og syntetiske tjek / Findings on Windows servers and synthetic checks")
|
.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).");
|
.WithDescription("status=active|resolved|all (default active), scope=server|check, id (with scope), limit (1–500, default 100), continuation. "
|
||||||
api.MapGet("/server-findings/{id:long}", FindingAsync).WithSummary("Ét fund på en server eller et tjek / One server or check finding");
|
+ "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")
|
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);
|
.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")
|
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.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")
|
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);
|
.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");
|
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))
|
.WithDescription("A failed run's message and details marked sensitive are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.");
|
||||||
.WithSummary("Reglernes udfald på tjekket / Rule outcomes on the check");
|
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",
|
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 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) =>
|
private static object Counts(IReadOnlyDictionary<Severity, int>? counts) => new
|
||||||
sensitive && !options.Value.ApiIncludesSensitiveEvidence && value is not null ? ApiV1Endpoints.Redacted : value;
|
|
||||||
|
|
||||||
private static object Json(WindowsServerRow s) => new
|
|
||||||
{
|
{
|
||||||
Id = s.ServerId,
|
Critical = counts?.GetValueOrDefault(Severity.Critical) ?? 0,
|
||||||
s.Name,
|
High = counts?.GetValueOrDefault(Severity.High) ?? 0,
|
||||||
s.Host,
|
Medium = counts?.GetValueOrDefault(Severity.Medium) ?? 0,
|
||||||
s.Role,
|
Low = counts?.GetValueOrDefault(Severity.Low) ?? 0,
|
||||||
Source = s.IsSqlHost ? "sqlHost" : "web",
|
Info = counts?.GetValueOrDefault(Severity.Info) ?? 0,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
Id = w.ServerId,
|
||||||
c.Name,
|
w.Name,
|
||||||
c.Kind,
|
w.Host,
|
||||||
c.ServerId,
|
w.Role,
|
||||||
c.ServerName,
|
Source = w.IsSqlHost ? "sqlHost" : "web",
|
||||||
Target = c.Settings.Target(c.Kind),
|
w.Paused,
|
||||||
c.IntervalSeconds,
|
WatchedServices = w.Watched,
|
||||||
c.TimeoutSeconds,
|
IgnoredServices = w.Ignored,
|
||||||
c.Paused,
|
w.Reachable,
|
||||||
Settings = c.Settings,
|
w.LastContactAt,
|
||||||
HasPassword = c.HasSecret,
|
w.LastErrorAt,
|
||||||
c.SecretError,
|
LastError = s.ServerText(w.LastError),
|
||||||
c.LastRunAt,
|
w.Protocol,
|
||||||
c.LastSuccess,
|
w.OperatingSystem,
|
||||||
c.LastDurationMs,
|
w.OsVersion,
|
||||||
LastMessage = c.LastSuccess == false ? Redact(c.LastMessage, options) : c.LastMessage,
|
w.LastBootUpTime,
|
||||||
c.ConsecutiveFailures,
|
w.TotalMemoryMb,
|
||||||
c.CreatedAt,
|
w.LogicalProcessors,
|
||||||
c.CreatedBy,
|
Areas = w.Areas.Select(a => new { a.Area, a.Ok, Detail = s.ServerText(a.Detail), a.Right }),
|
||||||
c.UpdatedAt,
|
Latest = overview?.Latest is { } p ? new { p.SampledAt, p.CpuPercent, p.AvailableMb, p.AvailablePct } : null,
|
||||||
c.UpdatedBy,
|
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,
|
Id = f.FindingId,
|
||||||
Scope = f.Scope == FindingScope.Server ? "server" : "check",
|
Scope = f.Scope == FindingScope.Server ? "server" : "check",
|
||||||
ScopeId = f.ScopeId,
|
ScopeId = f.ScopeId,
|
||||||
Name = f.ScopeName,
|
Name = f.ScopeName,
|
||||||
|
f.ScopePaused,
|
||||||
|
f.ScopeRemoved,
|
||||||
|
Status = f.ResolvedAt is null ? "active" : "resolved",
|
||||||
f.RuleId,
|
f.RuleId,
|
||||||
f.Subject,
|
f.Subject,
|
||||||
Severity = f.Severity.ToString(),
|
Severity = f.Severity.ToString(),
|
||||||
f.Title,
|
Title = s.ServerText(f.Title),
|
||||||
f.Explanation,
|
Explanation = s.ServerText(f.Explanation),
|
||||||
f.Recommendation,
|
Recommendation = s.ServerText(f.Recommendation),
|
||||||
Evidence = f.Evidence.Select(e => new { e.Name, Value = Redact(e.Value, options, e.Sensitive) }),
|
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 }),
|
References = f.References.Select(r => new { r.Title, r.Url }),
|
||||||
f.FirstSeenAt,
|
f.FirstSeenAt,
|
||||||
f.LastSeenAt,
|
f.LastSeenAt,
|
||||||
f.ResolvedAt,
|
f.ResolvedAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static object Json(AgentRequestRow r) => new
|
private static IResult Accepted(AgentRequestRow request, Sensitive s) =>
|
||||||
{
|
Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", ConfigurationApiEndpoints.Json(request, s));
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Windows servers --------------------------------------------------------------------------------------
|
// ---- Windows servers --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
private static async Task<IResult> ListServersAsync(InsightRepository repository, CancellationToken ct) =>
|
private static async Task<(IReadOnlyDictionary<int, WindowsServerOverview> Overviews, IReadOnlyDictionary<(string Scope, int ScopeId), IReadOnlyDictionary<Severity, int>> Counts)>
|
||||||
Results.Ok((await repository.GetWindowsServersAsync(ct).ConfigureAwait(false)).Select(Json));
|
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) =>
|
private static async Task<object> ServerJsonAsync(WindowsServerRow row, InsightRepository repository, Sensitive s, CancellationToken ct)
|
||||||
await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row)) : Results.NotFound();
|
{
|
||||||
|
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)
|
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))!;
|
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)
|
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);
|
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)
|
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();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!;
|
return Accepted((await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!, Sensitive.For(options));
|
||||||
return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
if (await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is null)
|
||||||
{
|
{
|
||||||
|
|
@ -274,11 +354,16 @@ public static class ServerApiEndpoints
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = AgentRequestJson.Deserialize<WindowsServerTestResult>(test.Result);
|
var s = Sensitive.For(options);
|
||||||
return Results.Ok(new { Request = Json(test), MissingAreas = result?.Missing.Select(a => new { a.Area, a.Right, a.Detail }).ToList() ?? [] });
|
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)
|
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);
|
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 });
|
return Results.Ok(new { Account = account, Script = script, Note = note });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -368,20 +465,29 @@ public static class ServerApiEndpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
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);
|
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,
|
CapturedAt = snapshot?.CapturedAt,
|
||||||
Log = e.LogName,
|
snapshot?.Method,
|
||||||
e.Source,
|
snapshot?.Ignored,
|
||||||
e.EventId,
|
snapshot?.Coverage,
|
||||||
e.Level,
|
Events = events.Select(e => new
|
||||||
e.Category,
|
{
|
||||||
e.Count,
|
e.CollectedAt,
|
||||||
e.FirstAt,
|
Log = e.LogName,
|
||||||
e.LastAt,
|
e.Source,
|
||||||
Sample = Redact(e.Sample, options),
|
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)
|
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
|
var exists = scope == FindingScope.Server
|
||||||
? await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is not null
|
? await repository.GetWindowsServerAsync(id, ct).ConfigureAwait(false) is not null
|
||||||
|
|
@ -421,18 +527,20 @@ public static class ServerApiEndpoints
|
||||||
return Results.NotFound();
|
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,
|
r.RuleId,
|
||||||
s.RuleTitle,
|
r.RuleTitle,
|
||||||
Outcome = char.ToLowerInvariant(s.Outcome.ToString()[0]) + s.Outcome.ToString()[1..],
|
Outcome = char.ToLowerInvariant(r.Outcome.ToString()[0]) + r.Outcome.ToString()[1..],
|
||||||
s.Reason,
|
// A reason may name the agent's account ("Agentens konto (…) mangler …"): redacted like accounts elsewhere.
|
||||||
s.EvaluatedAt,
|
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,
|
private static async Task<IResult> FindingsAsync(HttpContext http, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, string? status = null,
|
||||||
int? id = null)
|
string? scope = null, int? id = null, int? limit = null, string? continuation = null)
|
||||||
{
|
{
|
||||||
var findingStatus = status?.ToLowerInvariant() switch
|
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).");
|
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);
|
var offset = 0;
|
||||||
return Results.Ok(findings.Select(f => Json(f, options)));
|
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) =>
|
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();
|
{
|
||||||
|
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 -------------------------------------------------------------------------------------
|
// ---- Synthetic checks -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
private static CheckInput Input(SyntheticCheckBody body) =>
|
private static CheckInput Input(SyntheticCheckBody body) =>
|
||||||
new(body.Name, body.Kind, body.ServerId, body.Settings ?? new SyntheticCheckSettings(), body.Password, body.IntervalSeconds, body.TimeoutSeconds);
|
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) =>
|
private static async Task<object> CheckJsonAsync(SyntheticCheckRow row, InsightRepository repository, Sensitive s, CancellationToken ct) =>
|
||||||
Results.Ok((await repository.GetSyntheticChecksAsync(ct).ConfigureAwait(false)).Select(c => Json(c, options)));
|
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) =>
|
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)
|
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))!;
|
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,
|
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 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,
|
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);
|
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)
|
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();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!;
|
if (outcome.Conflict)
|
||||||
return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request));
|
{
|
||||||
|
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)
|
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();
|
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);
|
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
|
return Results.Ok(results.Select(r => new
|
||||||
{
|
{
|
||||||
r.RunAt,
|
r.RunAt,
|
||||||
r.Success,
|
r.Success,
|
||||||
r.DurationMs,
|
r.DurationMs,
|
||||||
Message = r.Success ? r.Message : Redact(r.Message, options),
|
Message = r.Success ? r.Message : s.Value(r.Message),
|
||||||
Details = r.Details.Select(d => new { d.Name, Value = Redact(d.Value, options, d.Sensitive) }),
|
Details = r.Details.Select(d => new { d.Name, Value = s.Value(d.Value, d.Sensitive), d.Sensitive }),
|
||||||
r.CertificateExpiresAt,
|
r.CertificateExpiresAt,
|
||||||
r.CertificateSubject,
|
r.CertificateSubject,
|
||||||
r.ServiceState,
|
r.ServiceState,
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,19 @@ public static class ServerSetup
|
||||||
public static string FindingPage(long id) => ServersPage + "/fund/" + id.ToString(CultureInfo.InvariantCulture);
|
public static string FindingPage(long id) => ServersPage + "/fund/" + id.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
public const string NotAdminText =
|
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);
|
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)
|
private static string? Validate(ServerInput input, bool creating)
|
||||||
{
|
{
|
||||||
if (creating && ServerHost.Validate(input.Host) is { } host)
|
if (creating && ServerHost.Validate(input.Host) is { } host)
|
||||||
|
|
@ -61,6 +70,7 @@ public static class ServerSetup
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(repository);
|
ArgumentNullException.ThrowIfNull(repository);
|
||||||
ArgumentNullException.ThrowIfNull(input);
|
ArgumentNullException.ThrowIfNull(input);
|
||||||
|
input = Clean(input);
|
||||||
var name = string.IsNullOrWhiteSpace(input.Name) ? (input.Host is null ? null : ServerHost.ShortName(input.Host)) : input.Name.Trim();
|
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)
|
if ((Validate(input, creating: true) ?? ServerAddress.ValidateName(name)) is { } invalid)
|
||||||
{
|
{
|
||||||
|
|
@ -87,6 +97,7 @@ public static class ServerSetup
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(repository);
|
ArgumentNullException.ThrowIfNull(repository);
|
||||||
ArgumentNullException.ThrowIfNull(input);
|
ArgumentNullException.ThrowIfNull(input);
|
||||||
|
input = Clean(input);
|
||||||
var row = await repository.GetWindowsServerAsync(id, cancellationToken).ConfigureAwait(false);
|
var row = await repository.GetWindowsServerAsync(id, cancellationToken).ConfigureAwait(false);
|
||||||
if (row is null)
|
if (row is null)
|
||||||
{
|
{
|
||||||
|
|
@ -103,6 +114,7 @@ public static class ServerSetup
|
||||||
return new Outcome(id);
|
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)
|
public static async Task<Outcome> RequestServerTestAsync(InsightRepository repository, int id, string? by, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(repository);
|
ArgumentNullException.ThrowIfNull(repository);
|
||||||
|
|
@ -196,12 +208,16 @@ public static class ServerSetup
|
||||||
return new Outcome(id);
|
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)
|
public static async Task<Outcome> RequestCheckRunAsync(InsightRepository repository, int id, string? by, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(repository);
|
ArgumentNullException.ThrowIfNull(repository);
|
||||||
return await repository.GetSyntheticCheckAsync(id, cancellationToken).ConfigureAwait(false) is null
|
return await repository.GetSyntheticCheckAsync(id, cancellationToken).ConfigureAwait(false) switch
|
||||||
? new Outcome(NotFound: true)
|
{
|
||||||
: new Outcome(id, await repository.AddServerRequestAsync(ServerRequestKind.SyntheticCheckRun, id, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false));
|
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,
|
private static async Task<(SyntheticCheckInput? Check, string? Error)> PrepareAsync(InsightRepository repository, CheckInput input, SyntheticCheckRow? existing,
|
||||||
|
|
@ -306,6 +322,14 @@ public static class ServerSetup
|
||||||
_ => mode ?? "–",
|
_ => 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";
|
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>
|
/// <summary>The comma-separated status codes from a form field.</summary>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using SQLInsight.Core.Configuration;
|
||||||
using SQLInsight.Core.Findings;
|
using SQLInsight.Core.Findings;
|
||||||
using SQLInsight.Core.Servers;
|
using SQLInsight.Core.Servers;
|
||||||
using SQLInsight.Core.SyntheticChecks;
|
using SQLInsight.Core.SyntheticChecks;
|
||||||
|
|
@ -124,7 +125,12 @@ public sealed class ServerApiTests(SqlServerFixture sql) : IAsyncLifetime
|
||||||
], now);
|
], now);
|
||||||
|
|
||||||
var list = await JsonAsync(await read.GetAsync("/api/v1/windows-servers"));
|
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());
|
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"));
|
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());
|
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"));
|
var tasks = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/tasks"));
|
||||||
Assert.True(tasks.GetProperty("tasks")[0].GetProperty("failed").GetBoolean());
|
Assert.True(tasks.GetProperty("tasks")[0].GetProperty("failed").GetBoolean());
|
||||||
var events = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/events"));
|
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 findings = await JsonAsync(await read.GetAsync($"/api/v1/server-findings?scope=server&id={id}"));
|
||||||
var finding = Assert.Single(findings.EnumerateArray());
|
var finding = Assert.Single(findings.EnumerateArray());
|
||||||
Assert.DoesNotContain("hemmeligt", finding.ToString(), StringComparison.Ordinal);
|
Assert.DoesNotContain("hemmeligt", finding.ToString(), StringComparison.Ordinal);
|
||||||
|
Assert.True(finding.GetProperty("evidence")[1].GetProperty("sensitive").GetBoolean());
|
||||||
var findingId = finding.GetProperty("id").GetInt64();
|
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("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);
|
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"));
|
var states = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/rule-states"));
|
||||||
Assert.Equal("findings", Assert.Single(states.EnumerateArray()).GetProperty("outcome").GetString());
|
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"));
|
var grant = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/grant-script"));
|
||||||
Assert.Equal(@"FIRMA\gmsa-sqlinsight$", grant.GetProperty("account").GetString());
|
Assert.Equal(ApiV1Endpoints.Redacted, grant.GetProperty("account").GetString());
|
||||||
Assert.Contains(@"$Account = 'FIRMA\gmsa-sqlinsight$'", grant.GetProperty("script").GetString(), StringComparison.Ordinal);
|
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);
|
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) })));
|
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" },
|
name = "SQL01 login", settings = new { server = "sql02", authentication = "SqlLogin", userName = "axapp" },
|
||||||
}))).StatusCode);
|
}))).StatusCode);
|
||||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PutAsync($"/api/v1/synthetic-checks/{id}", Body(new { name = "SQL01 login", kind = "Tcp" }))).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.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.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.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");
|
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);
|
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]
|
[Fact]
|
||||||
public async Task Openapi_describes_the_server_and_check_endpoints()
|
public async Task Openapi_describes_the_server_and_check_endpoints()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue