Review 1.5: server and check rules never pass unmeasured, cap and stabilize findings, keep sensitive text out of titles

- SQLI-WIN-006 is "ikke målbar" (with the forecaster's note) until a volume has enough
  history; titles "Drev X er fuldt (om ca. N dage | inden for et døgn)", as SQLI-WIN-005.
- SQLI-WIN-007/008: the startup grace period is "ikke målbar" instead of passed, so
  open findings are not resolved and reopened at every restart. One matcher for both
  rules (name or display name, prefix*). SQLI-WIN-007 is capped by
  Services:MaxFindings (default 15, 1-100) with one summary finding for the rest.
- SQLI-WIN-009: stable order (category, oldest first, key) and one summary finding
  over MaxFindings (now bounded 1-100).
- SQLI-WIN-010 only says "uden tegn på at det skete uventet" when the event log was
  read past the boot; before that "ikke målbar", and Low "årsagen kan ikke ses" when
  the log cannot be read.
- SQLI-WIN-012/013: Tasks:IgnoreUserAndUpdaterTasks (default true) leaves out per-user
  and updater tasks; TaskThresholds.Watches is the one filter for the agent and the
  rules. A SID is never in a title or subject, only in sensitive evidence.
- SQLI-SYN-001: neutral title "{navn} fejler ({n} gange i træk)", the failure message
  only in sensitive evidence; "mindst N" and "... eller tidligere" when every result
  given failed; "findes ikke" for a missing service; evidence "Tjenestens tilstand".
- SQLI-SYN-003: "udløber inden for et døgn" / "om 1 dag".
- Agent (minimal): the task filter is TaskThresholds.Watches, and performance samples
  get the configured interval.

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:44:16 +02:00
parent 2345b199fa
commit 7e66dc94c4
5 changed files with 394 additions and 61 deletions

View file

@ -214,7 +214,7 @@ public sealed partial class ServerMonitor(
}
_lastBoot = os.LastBootUpTime ?? _lastBoot;
var sample = ServerPerformanceMath.Sample(_previousRaw, raw, os);
var sample = ServerPerformanceMath.Sample(_previousRaw, raw, os, options.IntervalOf(ServerArea.Performance));
_previousRaw = raw;
_samples = ServerPerformanceMath.Append(_samples, sample);
var snapshot = new ServerPerformanceSnapshot
@ -295,7 +295,7 @@ public sealed partial class ServerMonitor(
ArgumentNullException.ThrowIfNull(session);
var tasks = options.Tasks;
var read = await session.GetTasksAsync(
name => (tasks.IncludeMicrosoftTasks || !TaskResults.IsMicrosoftTask(name)) && !WindowsServerRulesAccess.Matches(name, tasks.Ignore),
tasks.Watches,
Math.Max(1, tasks.MaxWatched), cancellationToken).ConfigureAwait(false);
var snapshot = new ServerTaskSnapshot { CapturedAt = now, Tasks = read.Watched, Visible = read.Visible, Coverage = read.Note };
_data.Set(snapshot);
@ -350,10 +350,3 @@ public sealed partial class ServerMonitor(
[LoggerMessage(Level = LogLevel.Debug, Message = "Windows server {Server}: {Findings} findings, {NotMeasurable} rules not measurable")]
private static partial void LogCycle(ILogger logger, string server, int findings, int notMeasurable);
}
/// <summary>The rules' name matching (names, or prefixes ending in *), for the agent's task filter.</summary>
internal static class WindowsServerRulesAccess
{
public static bool Matches(string name, IEnumerable<string> patterns) => patterns.Any(p =>
p.EndsWith('*') ? name.StartsWith(p[..^1], StringComparison.OrdinalIgnoreCase) : name.Equals(p, StringComparison.OrdinalIgnoreCase));
}

View file

@ -72,6 +72,12 @@ public sealed class ServiceThresholds
/// <summary>Right after a restart services are still starting: no finding for this many minutes.</summary>
public int StartupGraceMinutes { get; set; } = 15;
/// <summary>
/// At most this many findings for stopped automatic services per server (SQLI-WIN-007), by name; the rest are named in
/// one summary finding. Between 1 and 100.
/// </summary>
public int MaxFindings { get; set; } = 15;
}
public sealed class EventLogThresholds
@ -82,7 +88,7 @@ public sealed class EventLogThresholds
/// <summary>Events left out as noise, in addition to the built-in list: "Kilde:id", "Kilde" or "Kilde*".</summary>
public IList<string> Ignore { get; } = [];
/// <summary>At most this many event findings per server, most important first.</summary>
/// <summary>At most this many event findings per server, most important first; the rest are named in one summary finding. Between 1 and 100.</summary>
public int MaxFindings { get; set; } = 15;
/// <summary>At most this many events per log are read in one collection; the rest is read next time.</summary>
@ -100,8 +106,23 @@ public sealed class TaskThresholds
/// <summary>At most this many tasks per server are watched (their run information is read one at a time).</summary>
public int MaxWatched { get; set; } = 50;
/// <summary>
/// Leave out the tasks programs create per user (a user's SID in the name) or to update themselves (OneDrive, Edge,
/// Google, User_Feed_Synchronization; <see cref="TaskResults.DefaultIgnoredNames"/>). On by default.
/// </summary>
public bool IgnoreUserAndUpdaterTasks { get; set; } = true;
/// <summary>Tasks never watched (path\name, or a prefix ending in *).</summary>
public IList<string> Ignore { get; } = [];
/// <summary>Whether the task (its full path) is watched: the agent's filter when it reads tasks, and the rules' too.</summary>
public bool Watches(string fullName)
{
ArgumentNullException.ThrowIfNull(fullName);
return (IncludeMicrosoftTasks || !TaskResults.IsMicrosoftTask(fullName))
&& !(IgnoreUserAndUpdaterTasks && TaskResults.IsUserOrUpdaterTask(fullName))
&& !WindowsServerRules.Matches(fullName, Ignore);
}
}
/// <summary>What the Windows server rules see: the server, the latest data per area, and the thresholds.</summary>

View file

@ -62,29 +62,43 @@ public sealed class SyntheticCheckFailingRule : ScopedRule<SyntheticCheckRuleCon
var check = context.Check;
var latest = failures[0];
// Only the results the agent passed are known: when every one of them failed, the run of failures may be longer.
var all = failures.Count == context.Recent.Count;
var times = (all ? "mindst " : string.Empty) + failures.Count.ToString(CultureInfo.InvariantCulture);
var serviceRunning = string.Equals(latest.ServiceState, "Running", StringComparison.OrdinalIgnoreCase);
var serviceMissing = string.Equals(latest.ServiceState, NotFound, StringComparison.OrdinalIgnoreCase);
var serviceKnown = check.ServiceName is not null && latest.ServiceState is not null;
var title = serviceKnown && serviceRunning
? $"{check.Name} svarer ikke, selv om tjenesten {check.ServiceName} kører"
: serviceKnown
? $"{check.Name} svarer ikke: tjenesten {check.ServiceName} er {StateText(latest.ServiceState!)}"
: $"{check.Name} fejler: {latest.Message}";
// The title goes to the API and to notifications (Teams, webhooks, e-mail) as it is: never the failure message,
// which can name hosts, logins and paths. That is in the evidence, marked sensitive.
var title = !serviceKnown ? $"{check.Name} fejler ({times} gange i træk)"
: serviceRunning ? $"{check.Name} svarer ikke, selv om tjenesten {check.ServiceName} kører"
: serviceMissing ? $"{check.Name} svarer ikke: tjenesten {check.ServiceName} findes ikke"
: $"{check.Name} svarer ikke: tjenesten {check.ServiceName} er {StateText(latest.ServiceState!)}";
var explanation = What(check.Kind)
+ (serviceKnown && serviceRunning
+ (!serviceKnown ? string.Empty
: serviceRunning
? $" Tjenesten {check.ServiceName} på {check.ServerName} kører, så Windows ser alt som i orden, men den svarer ikke: den kan hænge, være overbelastet, "
+ "vente på databasen eller være blokeret af en firewall. Det er netop den slags fejl, en tjenesteovervågning alene ikke ser."
: serviceKnown ? $" Tjenesten {check.ServiceName} på {check.ServerName} kører ikke." : string.Empty);
: serviceMissing
? $" Tjenesten {check.ServiceName} findes ikke på {check.ServerName} (eller agenten må ikke se den): den kan være afinstalleret eller omdøbt, "
+ "eller tjekket er knyttet til et forkert tjenestenavn."
: $" Tjenesten {check.ServiceName} på {check.ServerName} kører ikke.");
var evidence = new List<Evidence>
{
new("Fejl", latest.Message, Sensitive: true),
new("Mål", check.Target),
new("Fejlet i træk", failures.Count.ToString(CultureInfo.InvariantCulture)),
new("Fejler siden", SyntheticCheckRules.Time(failures[^1].At)),
new("Fejlet i træk", times),
new("Fejler siden", all
? $"{SyntheticCheckRules.Time(failures[^1].At)} eller tidligere (ældre resultater er ikke med i vurderingen)"
: SyntheticCheckRules.Time(failures[^1].At)),
new("Seneste forsøg", SyntheticCheckRules.Time(latest.At)),
};
if (check.ServiceName is not null)
{
evidence.Add(new Evidence("Tjeneste", $"{check.ServiceName}: {(latest.ServiceState is { } state ? StateText(state) : "kunne ikke aflæses")}"));
// Not "Tjeneste": the agent adds its own detail by that name.
evidence.Add(new Evidence("Tjenestens tilstand", $"{check.ServiceName}: {(latest.ServiceState is { } state ? StateText(state) : "kunne ikke aflæses")}"));
}
evidence.AddRange(latest.Details.Select(d => new Evidence(d.Name, d.Value, d.Sensitive)));
@ -97,14 +111,20 @@ public sealed class SyntheticCheckFailingRule : ScopedRule<SyntheticCheckRuleCon
subject: "check"));
}
/// <summary>The service state the agent records when the check's service does not exist on the server.</summary>
private const string NotFound = "NotFound";
private static string StateText(string state) => state switch
{
"Running" => "kører",
"Stopped" => "stoppet",
"Start Pending" => "ved at starte",
"Stop Pending" => "ved at stoppe",
"Continue Pending" => "ved at fortsætte",
"Pause Pending" => "ved at gå på pause",
"Paused" => "sat på pause",
_ => state,
NotFound => "findes ikke",
_ => $"i tilstanden \"{state}\"",
};
private static string What(string kind) => kind switch
@ -194,6 +214,8 @@ public sealed class CertificateExpiryRule : ScopedRule<SyntheticCheckRuleContext
severity,
days <= 0
? $"Certifikatet for {context.Check.Name} er udløbet"
: days < 1 ? $"Certifikatet for {context.Check.Name} udløber inden for et døgn"
: days < 2 ? $"Certifikatet for {context.Check.Name} udløber om 1 dag"
: $"Certifikatet for {context.Check.Name} udløber om {Math.Floor(days).ToString(CultureInfo.InvariantCulture)} dage",
"Når certifikatet udløber, afviser browsere og integrationer forbindelsen (eller viser en sikkerhedsadvarsel), og siden er i praksis nede.",
"Forny certifikatet og bind det nye i webserveren (IIS: Bindinger på webstedet). Tjek også, om andre servere bruger samme certifikat.",

View file

@ -32,8 +32,26 @@ public static class WindowsServerRules
internal static Reference Learn(string title, string path) => new(title, new Uri("https://learn.microsoft.com/" + path));
/// <summary>A name in a list where entries may end in * (prefix).</summary>
internal static bool Matches(string name, IEnumerable<string> patterns) => patterns.Any(p =>
p.EndsWith('*') ? name.StartsWith(p[..^1], StringComparison.OrdinalIgnoreCase) : name.Equals(p, StringComparison.OrdinalIgnoreCase));
internal static bool Matches(string name, IEnumerable<string> patterns) => patterns.Any(p => Matches(name, p));
private static bool Matches(string name, string pattern) =>
pattern.EndsWith('*') ? name.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase) : name.Equals(pattern, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Whether a service is meant by a name in a service list (watched, ignored): its name or its display name, either
/// one exactly or by a prefix ending in *. SQLI-WIN-007 and SQLI-WIN-008 match the same way, so a service is never
/// reported by both or "not found" by one and found by the other.
/// </summary>
internal static bool IsService(ServerService service, string pattern) => Matches(service.Name, pattern) || Matches(service.DisplayName, pattern);
internal static bool IsService(ServerService service, IEnumerable<string> patterns) => patterns.Any(p => IsService(service, p));
/// <summary>A "max findings" setting, kept between 1 and 100.</summary>
internal static int Bounded(int maxFindings) => Math.Clamp(maxFindings, 1, 100);
/// <summary>Names for the summary of the findings over the cap: at most 50, then "og N flere".</summary>
internal static string List(IReadOnlyCollection<string> names) =>
string.Join(", ", names.Take(50)) + (names.Count > 50 ? $" og {names.Count - 50} flere" : string.Empty);
}
public sealed class ServerCpuRule : ScopedRule<ServerRuleContext>
@ -239,9 +257,11 @@ public sealed class DiskForecastRule : ScopedRule<ServerRuleContext>
ArgumentNullException.ThrowIfNull(context);
var disks = context.Data.Get<ServerDiskSnapshot>();
var settings = context.Settings.Disks;
if (disks.Disks.Count > 0 && disks.Forecasts.Count == 0)
if (disks.Disks.Count > 0 && !disks.Forecasts.Any(f => f.EnoughHistory))
{
return RuleEvaluation.NotMeasurable("Prognosen beregnes ud fra SQLInsights egne målinger af ledig plads; der er endnu ingen historik.");
var note = disks.Forecasts.Select(f => f.Note).FirstOrDefault(n => n is not null);
return RuleEvaluation.NotMeasurable("Prognosen beregnes ud fra SQLInsights egne målinger af ledig plads over mindst et par dage; "
+ (note is null ? "der er endnu ingen historik." : $"der er ikke nok endnu ({note})."));
}
return Result(disks.Forecasts
@ -252,7 +272,9 @@ public sealed class DiskForecastRule : ScopedRule<ServerRuleContext>
var disk = disks.Disks.FirstOrDefault(d => string.Equals(d.Volume, f.Volume, StringComparison.OrdinalIgnoreCase));
return CreateFinding(
days <= 2 ? Severity.Critical : days <= 7 ? Severity.High : Severity.Medium,
$"Disken {f.Volume} er {f.Describe()}",
days <= 0 ? $"Drev {f.Volume} er fuldt"
: days < 1 ? $"Drev {f.Volume} er fuldt inden for et døgn"
: $"Drev {f.Volume} er fuldt om ca. {WindowsServerRules.Number(Math.Round(days))} dage",
$"Pladsen på drevet er de seneste {WindowsServerRules.Number(f.HistoryDays)} dage brugt med ca. {WindowsServerRules.Number(f.GrowthGbPerDay, 1)} GB "
+ "om dagen. Fortsætter det, er drevet fuldt, og så stopper alt, der skriver til det (databaser, backup, logfiler). Prognosen er en ret linje "
+ "gennem SQLInsights egne målinger og tager ikke højde for oprydning, der ikke er sket endnu.",
@ -260,7 +282,7 @@ public sealed class DiskForecastRule : ScopedRule<ServerRuleContext>
+ "backup-rotation faktisk kører.",
[
new Evidence("Fuldt om ca.", WindowsServerRules.Number(days, days < 10 ? 1 : 0) + " dage"),
new Evidence("Forventet fuld", f.FullAt(context.Now) is { } at ? at.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) : "–"),
new Evidence("Forventet fuld", f.FullAt(disks.CapturedAt) is { } at ? at.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) : "–"),
new Evidence("Vækst pr. dag", WindowsServerRules.Number(f.GrowthGbPerDay, 2) + " GB"),
new Evidence("Ledig nu", disk is null ? "–" : WindowsServerRules.Number(disk.FreeGb, 1) + " GB af " + WindowsServerRules.Number(disk.SizeGb, 1) + " GB"),
new Evidence("Historik", $"{WindowsServerRules.Number(f.HistoryDays, 1)} dage, {f.Samples} målinger, R² {WindowsServerRules.Number(f.Fit, 2)}"),
@ -288,15 +310,39 @@ public sealed class StoppedAutomaticServiceRule : ScopedRule<ServerRuleContext>
{
ArgumentNullException.ThrowIfNull(context);
var services = context.Data.Get<ServerServiceSnapshot>();
if (InGracePeriod(context))
if (GraceReason(context) is { } grace)
{
return RuleEvaluation.Passed();
return RuleEvaluation.NotMeasurable(grace);
}
var ignore = StopByDesign.Concat(context.Settings.Services.Ignore).Concat(context.Server.IgnoredServices).ToList();
return Result(services.Services
.Where(s => s.IsAutomatic && !s.IsRunning && !WindowsServerRules.Matches(s.Name, ignore) && !WindowsServerRules.Matches(s.Name, context.Server.WatchedServices))
.Select(s => CreateFinding(
var stopped = services.Services
.Where(s => s.IsAutomatic && !s.IsRunning && !WindowsServerRules.IsService(s, ignore) && !WindowsServerRules.IsService(s, context.Server.WatchedServices))
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var max = WindowsServerRules.Bounded(context.Settings.Services.MaxFindings);
var findings = stopped.Take(max).Select(One).ToList();
if (stopped.Count > max)
{
var rest = stopped.Skip(max).Select(s => s.Name).ToList();
findings.Add(CreateFinding(
Severity.Medium,
$"{rest.Count} automatiske tjenester mere kører ikke",
$"Ud over de {max} tjenester med egne fund står yderligere {rest.Count} tjenester, der er sat til at starte automatisk, som ikke kørende. "
+ "Så mange stoppede tjenester på én gang skyldes ofte en fælles årsag: en tjeneste, de afhænger af, en konto med udløbet adgangskode, "
+ "eller en genstart, der ikke blev fuldført.",
"Se Windows' hændelseslog (System, Service Control Manager) og listen her. Tjenester, der skal være stoppet, sættes til Manuel eller "
+ "Deaktiveret eller tilføjes til listen over tjenester, der ignoreres for serveren. SQLInsight starter aldrig selv tjenester.",
[
new Evidence("Tjenester", WindowsServerRules.List(rest)),
new Evidence("Fund pr. tjeneste", $"højst {max} (SQLInsight:Servers:Services:MaxFindings)"),
],
subject: "service: (flere)"));
}
return Result(findings);
Finding One(ServerService s) => CreateFinding(
Severity.Medium,
$"Tjenesten {s.DisplayName} ({s.Name}) kører ikke",
$"Tjenesten er sat til at starte automatisk, men står som \"{s.State}\"" + (s.ExitCode is { } code and not 0 and not 1077 ? $" (afslutningskode {code})" : string.Empty)
@ -310,12 +356,25 @@ public sealed class StoppedAutomaticServiceRule : ScopedRule<ServerRuleContext>
new Evidence("Start", s.StartMode + (s.DelayedAutoStart ? " (forsinket)" : string.Empty)),
new Evidence("Afslutningskode", s.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "–"),
],
subject: "service: " + s.Name)));
subject: "service: " + s.Name);
}
internal static bool InGracePeriod(ServerRuleContext context) =>
context.Data.TryGet<ServerPerformanceSnapshot>(out var performance) && performance.Os.LastBootUpTime is { } boot
&& context.Now - boot < TimeSpan.FromMinutes(context.Settings.Services.StartupGraceMinutes);
/// <summary>
/// Right after a restart services are still starting. Not "passed": that would resolve open findings at every restart
/// and open them again a quarter of an hour later. Not measurable keeps them as they are.
/// </summary>
internal static string? GraceReason(ServerRuleContext context)
{
if (!context.Data.TryGet<ServerPerformanceSnapshot>(out var performance) || performance.Os.LastBootUpTime is not { } boot)
{
return null;
}
var left = TimeSpan.FromMinutes(context.Settings.Services.StartupGraceMinutes) - (context.Now - boot);
return left > TimeSpan.Zero
? $"Serveren er lige startet; tjenester vurderes igen efter {Math.Max(1, (int)Math.Ceiling(left.TotalMinutes)).ToString(CultureInfo.InvariantCulture)} minutter."
: null;
}
}
/// <summary>Services chosen for the server (the AOS, IIS, SQL Server …) that must run, whatever their start mode.</summary>
@ -333,29 +392,30 @@ public sealed class WatchedServiceRule : ScopedRule<ServerRuleContext>
}
var services = context.Data.Get<ServerServiceSnapshot>();
if (StoppedAutomaticServiceRule.InGracePeriod(context))
if (StoppedAutomaticServiceRule.GraceReason(context) is { } grace)
{
return RuleEvaluation.Passed();
return RuleEvaluation.NotMeasurable(grace);
}
var findings = new List<Finding>();
var reported = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in context.Server.WatchedServices)
{
var service = services.Services.FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase) || s.DisplayName.Equals(name, StringComparison.OrdinalIgnoreCase));
if (service is null)
var matching = services.Services.Where(s => WindowsServerRules.IsService(s, name)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList();
if (matching.Count == 0)
{
findings.Add(CreateFinding(
Severity.Medium,
$"Den overvågede tjeneste {name} findes ikke eller kan ikke ses",
"Tjenesten står på listen over tjenester, der skal køre på serveren, men Windows viser den ikke. Den kan være afinstalleret eller omdøbt, "
+ "eller agenten må ikke se den (tjenestens egne sikkerhedsindstillinger).",
"Tjek tjenestens navn (ikke visningsnavnet) i services.msc, og ret listen. Kør tildelingsscriptet igen, hvis agenten mangler rettigheder.",
"Tjek tjenestens navn eller visningsnavn i services.msc (en * til sidst matcher alle, der begynder sådan), og ret listen. Kør tildelingsscriptet igen, hvis agenten mangler rettigheder.",
[new Evidence("Tjeneste", name), new Evidence("Tjenester agenten kan se", services.Services.Count.ToString(CultureInfo.InvariantCulture))],
subject: "watched: " + name));
continue;
}
if (!service.IsRunning)
foreach (var service in matching.Where(s => !s.IsRunning && reported.Add(s.Name)))
{
findings.Add(CreateFinding(
Severity.High,
@ -387,12 +447,34 @@ public sealed class WindowsEventLogRule : ScopedRule<ServerRuleContext>
ArgumentNullException.ThrowIfNull(context);
var events = context.Data.Get<ServerEventSnapshot>();
var settings = context.Settings.EventLog;
return Result(events.Recent
// A stable order (kind, then the oldest first, then the key), so the same groups keep their findings from one
// evaluation to the next instead of trading places as counts change.
var relevant = events.Recent
.Where(g => g.Category != ServerEventCategory.UnexpectedShutdown)
.Where(g => g.Category != ServerEventCategory.Error || g.Count >= Math.Max(1, settings.RepeatThreshold))
.OrderByDescending(g => g.Category).ThenByDescending(g => g.Count)
.Take(Math.Max(1, settings.MaxFindings))
.Select(g => CreateFinding(
.OrderByDescending(g => g.Category).ThenBy(g => g.FirstAt).ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.ToList();
var max = WindowsServerRules.Bounded(settings.MaxFindings);
var findings = relevant.Take(max).Select(One).ToList();
if (relevant.Count > max)
{
var rest = relevant.Skip(max).ToList();
findings.Add(CreateFinding(
rest.Max(SeverityOf),
$"{rest.Count} hændelser mere i Windows' hændelseslog",
$"Ud over de {max} hændelser med egne fund er der yderligere {rest.Count} slags fejl i hændelsesloggen det seneste døgn. "
+ "Så mange forskellige fejl på én gang har ofte en fælles årsag (en disk, netværket, en tjeneste mange andre afhænger af).",
"Se hændelserne i Logbog (Event Viewer) på serveren, de vigtigste først. Harmløse kilder kan ignoreres under SQLInsight:Servers:EventLog:Ignore.",
[
new Evidence("Hændelser", WindowsServerRules.List(rest.Select(g => $"{g.Source} {g.EventId} ({g.Count})").ToList())),
new Evidence("Fund pr. hændelse", $"højst {max} (SQLInsight:Servers:EventLog:MaxFindings)"),
],
subject: "event: (flere)"));
}
return Result(findings);
Finding One(ServerEventGroup g) => CreateFinding(
SeverityOf(g),
$"{ServerEventAnalysis.Display(g.Category)}: {g.Source} {g.EventId} ({g.Count} {(g.Count == 1 ? "gang" : "gange")} i {g.Log}-loggen)",
Explanation(g.Category),
@ -407,7 +489,7 @@ public sealed class WindowsEventLogRule : ScopedRule<ServerRuleContext>
new Evidence("Seneste", WindowsServerRules.Time(g.LastAt)),
new Evidence("Eksempel", g.Sample ?? "(ingen tekst)", Sensitive: true),
],
subject: "event: " + g.Key)));
subject: "event: " + g.Key);
}
private static Severity SeverityOf(ServerEventGroup group) => group.Category switch
@ -490,6 +572,27 @@ public sealed class ServerRestartRule : ScopedRule<ServerRuleContext>
subject: "restart"));
}
if (events is null)
{
// The event log cannot be read: say that the cause is unknown rather than call it planned.
return RuleEvaluation.Found(CreateFinding(
Severity.Low,
$"Serveren er genstartet {WindowsServerRules.Time(boot)} (årsagen kan ikke ses)",
"Serveren er genstartet det seneste døgn. Agenten kan ikke læse Windows' hændelseslog på serveren, så det vides ikke, om genstarten var "
+ "planlagt (fx Windows-opdateringer) eller uventet (strøm, blå skærm, hård nulstilling). Fundet forsvinder af sig selv efter et døgn.",
"Se Logbog (System) omkring tidspunktet: User32 1074 er en planlagt genstart; Kernel-Power 41, EventLog 6008 og BugCheck 1001 en uventet. "
+ "Giv agenten læseret til hændelsesloggen (Event Log Readers, se tildelingsscriptet), så SQLInsight selv kan skelne.",
evidence,
subject: "restart"));
}
if (events.ReadUntil < boot || events.CapturedAt <= boot)
{
// Kernel-Power 41 and EventLog 6008 are written while Windows starts; until the log is read past the start they cannot have been seen.
return RuleEvaluation.NotMeasurable("Hændelsesloggen er ikke læst efter genstarten endnu, så det vides ikke, om den var planlagt eller uventet. "
+ "Det vurderes, når hændelsesloggen er indsamlet igen.");
}
return RuleEvaluation.Found(CreateFinding(
Severity.Info,
$"Serveren er genstartet {WindowsServerRules.Time(boot)}",
@ -544,21 +647,21 @@ public sealed class FailedScheduledTaskRule : ScopedRule<ServerRuleContext>
ArgumentNullException.ThrowIfNull(context);
var tasks = context.Data.Get<ServerTaskSnapshot>();
return Result(tasks.Tasks
.Where(t => t.Enabled && TaskResults.IsFailure(t.LastTaskResult) && !WindowsServerRules.Matches(t.FullName, context.Settings.Tasks.Ignore))
.Where(t => t.Enabled && TaskResults.IsFailure(t.LastTaskResult) && context.Settings.Tasks.Watches(t.FullName))
.Select(t => CreateFinding(
Severity.Medium,
$"Opgaven {t.FullName} fejlede: {TaskResults.Describe(t.LastTaskResult!.Value)}",
$"Opgaven {TaskResults.Display(t.FullName)} fejlede: {TaskResults.Describe(t.LastTaskResult!.Value)}",
"Opgaven i Opgavestyring (Task Scheduler) sluttede med en fejlkode sidste gang, den kørte. Planlagte opgaver er ofte oprydning, eksport, "
+ "integrationer eller backup af filer: når de fejler, opdager ingen det, før noget mangler.",
"Åbn opgaven i Opgavestyring, se fanen Historik og opgavens egen log. Tjek kontoen, opgaven kører som (adgangskode, rettigheder), og at "
+ "programmet eller scriptet findes.",
[
new Evidence("Opgave", t.FullName),
new Evidence("Opgave", t.FullName, Sensitive: TaskResults.HasUserSid(t.FullName)),
new Evidence("Resultat", TaskResults.Describe(t.LastTaskResult.Value)),
new Evidence("Seneste kørsel", WindowsServerRules.Time(t.LastRunTime)),
new Evidence("Næste kørsel", WindowsServerRules.Time(t.NextRunTime)),
],
subject: "task: " + t.FullName)));
subject: "task: " + TaskResults.Display(t.FullName))));
}
}
@ -574,21 +677,21 @@ public sealed class MissedScheduledTaskRule : ScopedRule<ServerRuleContext>
var late = TimeSpan.FromMinutes(Math.Max(1, context.Settings.Tasks.LateMinutes));
return Result(tasks.Tasks
.Where(t => t.Enabled && !string.Equals(t.State, "Running", StringComparison.OrdinalIgnoreCase)
&& !WindowsServerRules.Matches(t.FullName, context.Settings.Tasks.Ignore)
&& context.Settings.Tasks.Watches(t.FullName)
&& (t.MissedRuns > 0 || (t.NextRunTime is { } next && next < context.Now - late && (t.LastRunTime is null || t.LastRunTime < next))))
.Select(t => CreateFinding(
Severity.Medium,
t.MissedRuns > 0 ? $"Opgaven {t.FullName} har sprunget {t.MissedRuns} kørsler over" : $"Opgaven {t.FullName} kørte ikke {WindowsServerRules.Time(t.NextRunTime)}",
t.MissedRuns > 0 ? $"Opgaven {TaskResults.Display(t.FullName)} har sprunget {t.MissedRuns} kørsler over" : $"Opgaven {TaskResults.Display(t.FullName)} kørte ikke {WindowsServerRules.Time(t.NextRunTime)}",
"Opgaven skulle have kørt, men gjorde det ikke. Det sker, når Opgavestyring ikke kører, når opgavens betingelser ikke er opfyldt (fx \"kun når "
+ "brugeren er logget på\"), eller når serveren var slukket. Det, opgaven gør, er ikke sket.",
"Se opgavens Historik i Opgavestyring og dens betingelser og indstillinger (\"Kør opgaven hurtigst muligt efter en mistet start\").",
[
new Evidence("Opgave", t.FullName),
new Evidence("Opgave", t.FullName, Sensitive: TaskResults.HasUserSid(t.FullName)),
new Evidence("Planlagt", WindowsServerRules.Time(t.NextRunTime)),
new Evidence("Seneste kørsel", WindowsServerRules.Time(t.LastRunTime)),
new Evidence("Mistede kørsler", t.MissedRuns.ToString(CultureInfo.InvariantCulture)),
new Evidence("Status", t.State),
],
subject: "task-missed: " + t.FullName)));
subject: "task-missed: " + TaskResults.Display(t.FullName))));
}
}

View file

@ -130,9 +130,37 @@ public class WindowsServerRuleTests
var findings = Run(new DiskForecastRule(), Context(With(disks))).Findings;
Assert.Equal(2, findings.Count);
Assert.Contains(findings, f => f.Title == @"Disken D:\ er fuld om ca. 12 dage" && f.Severity == Severity.Medium);
Assert.Contains(findings, f => f.Title == @"Drev D:\ er fuldt om ca. 12 dage" && f.Severity == Severity.Medium);
Assert.Contains(findings, f => f.Subject == @"forecast: E:\" && f.Severity == Severity.Critical);
Assert.Equal(RuleOutcome.NotMeasurable, Run(new DiskForecastRule(), Context(With(disks with { Forecasts = [] }))).Outcome);
var full = Run(new DiskForecastRule(), Context(With(disks with { Forecasts = [new DiskForecast(@"D:\", 0, 8, 300, 14, 0.95)] })));
Assert.Equal(@"Drev D:\ er fuldt", Assert.Single(full.Findings).Title);
}
[Fact]
public void A_new_server_has_no_forecast_yet_and_says_so()
{
// What the agent builds for a server it started watching yesterday: one forecast per volume, none with enough history.
var history = Enumerable.Range(0, 24).Select(h => new DiskHistoryPoint(Now.AddHours(-h), 500, 100 + h)).Reverse().ToList();
var disks = new ServerDiskSnapshot
{
CapturedAt = Now,
Disks = [new ServerDisk(@"C:\", null, "NTFS", 500, 100), new ServerDisk(@"D:\", null, "NTFS", 500, 100)],
Forecasts = [DiskForecaster.Forecast(@"C:\", history, Now), DiskForecaster.Forecast(@"D:\", [], Now)],
};
var result = Run(new DiskForecastRule(), Context(With(disks)));
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
Assert.Contains("for lidt historik", result.Reason, StringComparison.Ordinal);
// Once one volume has enough history, the rule judges it (and passes, as it is not filling up).
var enough = Enumerable.Range(0, 24 * 5).Select(h => new DiskHistoryPoint(Now.AddHours(-h), 500, 100)).Reverse().ToList();
Assert.Equal(RuleOutcome.Passed, Run(new DiskForecastRule(), Context(With(disks with
{
Forecasts = [DiskForecaster.Forecast(@"C:\", enough, Now), DiskForecaster.Forecast(@"D:\", [], Now)],
}))).Outcome);
}
private static ServerServiceSnapshot Services(params ServerService[] services) => new() { CapturedAt = Now, Services = services };
@ -162,7 +190,52 @@ public class WindowsServerRuleTests
var data = With(Services(new ServerService("AOS60$01", "AOS", "Stopped", "Auto")));
data.Set(Performance(_ => Sample(), boot: Now.AddMinutes(-5)));
Assert.Equal(RuleOutcome.Passed, Run(new StoppedAutomaticServiceRule(), Context(data)).Outcome);
// Not "passed": that would resolve the open findings at every restart and open them again later.
var automatic = Run(new StoppedAutomaticServiceRule(), Context(data));
var watched = Run(new WatchedServiceRule(), Context(data, Server(watched: ["AOS60$01"])));
Assert.Equal(RuleOutcome.NotMeasurable, automatic.Outcome);
Assert.Equal("Serveren er lige startet; tjenester vurderes igen efter 10 minutter.", automatic.Reason);
Assert.Equal(RuleOutcome.NotMeasurable, watched.Outcome);
}
[Fact]
public void Watched_and_automatic_services_are_matched_the_same_way()
{
var data = With(Services(
new ServerService("AOS60$01", "Microsoft Dynamics AX Object Server 6.0$01", "Stopped", "Auto"),
new ServerService("AOS60$02", "Microsoft Dynamics AX Object Server 6.0$02", "Stopped", "Auto"),
new ServerService("ReportServer", "SQL Server Reporting Services", "Stopped", "Auto")));
data.Set(Performance(_ => Sample()));
var server = Server(watched: ["AOS60$*", "SQL Server Reporting Services"]);
var watched = Run(new WatchedServiceRule(), Context(data, server));
var automatic = Run(new StoppedAutomaticServiceRule(), Context(data, server));
// A prefix finds both AOS instances and a display name finds Reporting Services: none is "not found", and none is reported twice.
Assert.Equal(["watched: AOS60$01", "watched: AOS60$02", "watched: ReportServer"], watched.Findings.Select(f => f.Subject!).Order(StringComparer.Ordinal).ToList());
Assert.All(watched.Findings, f => Assert.Equal(Severity.High, f.Severity));
Assert.Equal(RuleOutcome.Passed, automatic.Outcome);
}
[Fact]
public void Stopped_automatic_services_are_capped_with_one_summary()
{
var data = With(Services(Enumerable.Range(1, 25).Select(i => new ServerService($"Svc{i:00}", $"Service {i}", "Stopped", "Auto")).Reverse().ToArray()));
data.Set(Performance(_ => Sample()));
var findings = Run(new StoppedAutomaticServiceRule(), Context(data)).Findings;
Assert.Equal(16, findings.Count);
Assert.Equal(Enumerable.Range(1, 15).Select(i => $"service: Svc{i:00}"), findings.Take(15).Select(f => f.Subject));
var summary = findings[^1];
Assert.Equal("service: (flere)", summary.Subject);
Assert.Equal("10 automatiske tjenester mere kører ikke", summary.Title);
Assert.Contains("Svc16", summary.Evidence.Single(e => e.Name == "Tjenester").Value, StringComparison.Ordinal);
var settings = new ServerRuleSettings();
settings.Services.MaxFindings = 0; // bounded: at least one
Assert.Equal(2, Run(new StoppedAutomaticServiceRule(), Context(data, settings: settings)).Findings.Count);
}
[Fact]
@ -218,13 +291,36 @@ public class WindowsServerRuleTests
Recent = Enumerable.Range(1, 40).Select(i => Group("App" + i, i, ServerEventCategory.ApplicationCrash, 1)).ToList(),
};
Assert.Equal(15, Run(new WindowsEventLogRule(), Context(With(events))).Findings.Count);
var findings = Run(new WindowsEventLogRule(), Context(With(events))).Findings;
Assert.Equal(16, findings.Count);
Assert.Equal("event: (flere)", findings[^1].Subject);
Assert.Equal("25 hændelser mere i Windows' hændelseslog", findings[^1].Title);
Assert.Equal(Severity.Medium, findings[^1].Severity);
}
[Fact]
public void Event_findings_over_the_cap_do_not_trade_places_as_counts_change()
{
ServerEventSnapshot Snapshot(Func<int, int> count) => new()
{
CapturedAt = Now,
ReadUntil = Now,
Recent = Enumerable.Range(1, 20).Select(i => Group("App" + i, i, ServerEventCategory.ApplicationCrash, count(i)) with { FirstAt = Now.AddHours(-20).AddMinutes(i) }).ToList(),
};
var before = Run(new WindowsEventLogRule(), Context(With(Snapshot(_ => 1)))).Findings.Select(f => f.Subject).ToList();
var after = Run(new WindowsEventLogRule(), Context(With(Snapshot(i => i)))).Findings.Select(f => f.Subject).ToList();
Assert.Equal(before, after);
Assert.Equal("event: System/App1/1", before[0]);
}
[Fact]
public void Restart_unexpected_with_a_crash_event_otherwise_info()
{
var data = With(Performance(_ => Sample(), boot: Now.AddHours(-3)));
data.Set(new ServerEventSnapshot { CapturedAt = Now, ReadUntil = Now.AddMinutes(-10) });
var planned = Run(new ServerRestartRule(), Context(data));
data.Set(new ServerEventSnapshot
{
@ -240,6 +336,24 @@ public class WindowsServerRuleTests
Assert.Equal(RuleOutcome.Passed, old.Outcome);
}
[Fact]
public void A_restart_is_not_called_planned_before_the_event_log_is_read_past_it()
{
var data = With(Performance(_ => Sample(), boot: Now.AddMinutes(-4)));
// The event log cannot be read at all: the cause is unknown, and the finding says so.
var unreadable = Run(new ServerRestartRule(), Context(data));
Assert.Equal(Severity.Low, Assert.Single(unreadable.Findings).Severity);
Assert.Contains("årsagen kan ikke ses", unreadable.Findings[0].Title, StringComparison.Ordinal);
Assert.DoesNotContain("uden tegn", unreadable.Findings[0].Explanation, StringComparison.Ordinal);
// Read last before the restart: not measurable until it is read again.
data.Set(new ServerEventSnapshot { CapturedAt = Now.AddMinutes(-6), ReadUntil = Now.AddMinutes(-16) });
var notYet = Run(new ServerRestartRule(), Context(data));
Assert.Equal(RuleOutcome.NotMeasurable, notYet.Outcome);
Assert.Contains("ikke læst efter genstarten", notYet.Reason, StringComparison.Ordinal);
}
[Fact]
public void Uptime_rule_is_off_by_default()
{
@ -274,6 +388,49 @@ public class WindowsServerRuleTests
Assert.Equal(@"task: \AX export", Assert.Single(failed.Findings).Subject);
Assert.Equal([@"task-missed: \Firma\Late", @"task-missed: \Missed"], missed.Findings.Select(f => f.Subject).ToList());
}
[Theory]
[InlineData(@"\OneDrive Standalone Update Task-S-1-5-21-1004336348-1177238915-682003330-1001", true)]
[InlineData(@"\MicrosoftEdgeUpdateTaskMachineUA", true)]
[InlineData(@"\GoogleUpdateTaskMachineCore{6B8F5D2E-1D0B-4C1B-9E1A-2F8E7A0C1D11}", true)]
[InlineData(@"\User_Feed_Synchronization-{9A1B7C3D-0000-4E5F-8A9B-123456789ABC}", true)]
[InlineData(@"\Firma\Sync for S-1-5-21-1-2-3-500", true)]
[InlineData(@"\AX export", false)]
[InlineData(@"\Firma\OneDrive-kopi af rapporter", false)]
public void Per_user_and_updater_tasks_are_not_watched_by_default(string path, bool left)
{
Assert.Equal(left, !new TaskThresholds().Watches(path));
Assert.True(new TaskThresholds { IgnoreUserAndUpdaterTasks = false }.Watches(path));
}
[Fact]
public void A_users_sid_is_never_in_a_task_findings_title_or_subject()
{
const string sid = "S-1-5-21-1004336348-1177238915-682003330-1001";
var tasks = new ServerTaskSnapshot
{
CapturedAt = Now,
Tasks = [new ServerTask { Path = @"\", Name = "Rapport-" + sid, State = "Ready", LastTaskResult = 1, LastRunTime = Now.AddDays(-2), NextRunTime = Now.AddHours(-5) }],
};
var settings = new ServerRuleSettings();
// By default the task is left out …
Assert.Equal(RuleOutcome.Passed, Run(new FailedScheduledTaskRule(), Context(With(tasks), settings: settings)).Outcome);
// … and when it is watched anyway, the SID is only in sensitive evidence.
settings.Tasks.IgnoreUserAndUpdaterTasks = false;
var findings = Run(new FailedScheduledTaskRule(), Context(With(tasks), settings: settings)).Findings
.Concat(Run(new MissedScheduledTaskRule(), Context(With(tasks), settings: settings)).Findings).ToList();
Assert.Equal(2, findings.Count);
Assert.All(findings, f =>
{
Assert.DoesNotContain(sid, f.Title, StringComparison.Ordinal);
Assert.DoesNotContain(sid, f.Subject, StringComparison.Ordinal);
Assert.Contains(@"\Rapport-bruger-", f.Title, StringComparison.Ordinal);
Assert.True(f.Evidence.Single(e => e.Name == "Opgave").Sensitive);
});
}
}
public class SyntheticCheckRuleTests
@ -295,7 +452,7 @@ public class SyntheticCheckRuleTests
Assert.Equal(RuleOutcome.Passed, Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(true, 5)).Outcome);
var failing = Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(false, 5), Result(true, 10));
Assert.Equal(Severity.High, Assert.Single(failing.Findings).Severity);
Assert.Equal("AOS01 port fejler: Forbindelsen blev afvist.", failing.Findings[0].Title);
Assert.Equal("AOS01 port fejler (2 gange i træk)", failing.Findings[0].Title);
Assert.Equal(RuleOutcome.NotMeasurable, Run(new SyntheticCheckFailingRule(), Check()).Outcome);
}
@ -308,6 +465,41 @@ public class SyntheticCheckRuleTests
Assert.Equal("AOS01 port svarer ikke, selv om tjenesten AOS60$01 kører", running.Findings[0].Title);
Assert.Contains("en tjenesteovervågning alene ikke ser", running.Findings[0].Explanation, StringComparison.Ordinal);
Assert.Equal("AOS01 port svarer ikke: tjenesten AOS60$01 er stoppet", stopped.Findings[0].Title);
Assert.Equal("AOS60$01: stoppet", stopped.Findings[0].Evidence.Single(e => e.Name == "Tjenestens tilstand").Value);
}
[Fact]
public void A_service_that_does_not_exist_is_said_in_danish()
{
var missing = Run(new SyntheticCheckFailingRule(), Check(service: "AOS60$01"), Result(false, 0, service: "NotFound"), Result(false, 5, service: "NotFound"));
var finding = Assert.Single(missing.Findings);
Assert.Equal("AOS01 port svarer ikke: tjenesten AOS60$01 findes ikke", finding.Title);
Assert.Contains("findes ikke på AOS01", finding.Explanation, StringComparison.Ordinal);
Assert.DoesNotContain("NotFound", finding.Title + finding.Explanation, StringComparison.Ordinal);
}
[Fact]
public void The_failure_message_is_never_in_the_title()
{
var failing = Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(false, 5), Result(false, 10), Result(true, 15));
var finding = Assert.Single(failing.Findings);
Assert.DoesNotContain("afvist", finding.Title, StringComparison.Ordinal);
Assert.True(finding.Evidence.Single(e => e.Name == "Fejl").Sensitive);
Assert.Equal("3", finding.Evidence.Single(e => e.Name == "Fejlet i træk").Value);
Assert.Equal("2026-10-02 11:50 UTC", finding.Evidence.Single(e => e.Name == "Fejler siden").Value);
}
[Fact]
public void When_every_result_failed_the_run_may_be_longer()
{
var failing = Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(false, 5), Result(false, 10));
var finding = Assert.Single(failing.Findings);
Assert.Equal("AOS01 port fejler (mindst 3 gange i træk)", finding.Title);
Assert.Equal("mindst 3", finding.Evidence.Single(e => e.Name == "Fejlet i træk").Value);
Assert.StartsWith("2026-10-02 11:50 UTC eller tidligere", finding.Evidence.Single(e => e.Name == "Fejler siden").Value, StringComparison.Ordinal);
}
[Fact]
@ -330,5 +522,7 @@ public class SyntheticCheckRuleTests
Assert.Equal(Severity.Critical, Run(new CertificateExpiryRule(), check, Result(false, 0, certificate: Now.AddDays(-1))).Findings[0].Severity);
Assert.Equal(RuleOutcome.NotApplicable, Run(new CertificateExpiryRule(), Check(), Result(true, 0)).Outcome);
Assert.Equal(RuleOutcome.NotMeasurable, Run(new CertificateExpiryRule(), check, Result(false, 0)).Outcome);
Assert.Equal("Certifikatet for AOS01 port udløber inden for et døgn", Run(new CertificateExpiryRule(), check, Result(true, 0, certificate: Now.AddHours(5))).Findings[0].Title);
Assert.Equal("Certifikatet for AOS01 port udløber om 1 dag", Run(new CertificateExpiryRule(), check, Result(true, 0, certificate: Now.AddHours(30))).Findings[0].Title);
}
}