diff --git a/src/SQLInsight.Web/Components/Pages/Privileges.razor b/src/SQLInsight.Web/Components/Pages/Privileges.razor
new file mode 100644
index 0000000..1c46a69
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Pages/Privileges.razor
@@ -0,0 +1,58 @@
+@page "/instanser/{Name}/rettigheder"
+@inject InsightRepository Repository
+
+@if (_instance is null)
+{
+
Ukendt instans · SQLInsight Pro
+
Ukendt instans
+
Der findes ingen instans med navnet @Name.
+ return;
+}
+
+
Rettigheder · @_instance.Name · SQLInsight Pro
+
Instanser / @_instance.Name / Rettigheder
+
Rettigheder
+
Hvem har sysadmin og de øvrige forhøjede serverrettigheder, med Windows-grupper foldet ud til personer, og hvem der har fået eller mistet dem. Opdateres hvert 15. minut.
+
+ Revisionsrapport (PDF)
+ Alle instanser
+
+
+@if (_audit is not null)
+{
+
+
+
+ Ændringslog
seneste @Dage dage
+
+
+
+}
+
+
+
+ Datakilde og metode
+ @PrivilegeAudit.MethodText
+
+
+
+@code {
+ [Parameter] public string Name { get; set; } = string.Empty;
+ [SupplyParameterFromQuery] public int? Dage { get; set; }
+
+ private InstanceOverview? _instance;
+ private PrivilegeAudit? _audit;
+
+ protected override async Task OnParametersSetAsync()
+ {
+ Dage = Math.Clamp(Dage ?? 90, 1, 366);
+ _instance = await Repository.GetInstanceAsync(Name);
+ if (_instance is null)
+ {
+ return;
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ _audit = await PrivilegeAudit.LoadAsync(Repository, _instance, now.AddDays(-Dage.Value), now);
+ }
+}
diff --git a/src/SQLInsight.Web/Components/Pages/Reports.razor b/src/SQLInsight.Web/Components/Pages/Reports.razor
index 5d680f0..2e75c62 100644
--- a/src/SQLInsight.Web/Components/Pages/Reports.razor
+++ b/src/SQLInsight.Web/Components/Pages/Reports.razor
@@ -16,3 +16,14 @@
Rapporten kan også sendes automatisk hver måned. Se SQLInsight:Reports i agentens konfiguration.
+
+
+ Rettighedsrevision
+ Hvem har sysadmin og de øvrige forhøjede serverrettigheder nu (Windows-grupper foldet ud til personer), hvem der har fået eller mistet dem i perioden og af hvem, sa-kontoen, SQL-logins uden adgangskodepolitik og default trace. Indeholder navne: rapporten dannes her og sendes ikke nogen steder hen.
+
+ Seneste 30 dage
+ Seneste kvartal
+ Seneste år
+
+ For én instans: brug knappen på instansens side under Rettigheder. Se også Rettigheder for alle instanser.
+
diff --git a/src/SQLInsight.Web/Components/Shared/PeriodLinks.razor b/src/SQLInsight.Web/Components/Shared/PeriodLinks.razor
new file mode 100644
index 0000000..d581001
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Shared/PeriodLinks.razor
@@ -0,0 +1,10 @@
+
+ @foreach (var d in new[] { 7, 30, 90, 365 })
+ {
+ @(d == 365 ? "1 år" : $"{d} dage")
+ }
+
+
+@code {
+ [Parameter] public int Days { get; set; }
+}
diff --git a/src/SQLInsight.Web/Components/Shared/PrivilegeAlert.razor b/src/SQLInsight.Web/Components/Shared/PrivilegeAlert.razor
new file mode 100644
index 0000000..e463f02
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Shared/PrivilegeAlert.razor
@@ -0,0 +1,33 @@
+@inject Display Display
+
+@* New sysadmin and other elevated rights, shown above everything else. Names are in the finding's evidence, not here. *@
+@if (_alerts.Count > 0)
+{
+
+
Nye forhøjede rettigheder
+
+ @foreach (var f in _alerts)
+ {
+ -
+ @f.Title
+ @if (ShowInstance)
+ {
+ på @f.InstanceName
+ }
+ · opdaget @Display.Time(f.FirstSeenAt)
+
+ }
+
+ Bekræft, at ændringerne var godkendt. Se hvem og hvornår under fundet eller på siden Rettigheder.
+
+}
+
+@code {
+ [Parameter, EditorRequired] public IReadOnlyList
Findings { get; set; } = [];
+ [Parameter] public bool ShowInstance { get; set; }
+
+ private IReadOnlyList _alerts = [];
+
+ protected override void OnParametersSet() =>
+ _alerts = Findings.Where(f => f.RuleId == "SQLI-SEC-001").OrderByDescending(f => f.Severity).ThenByDescending(f => f.FirstSeenAt).ToList();
+}
diff --git a/src/SQLInsight.Web/Components/Shared/PrivilegeChanges.razor b/src/SQLInsight.Web/Components/Shared/PrivilegeChanges.razor
new file mode 100644
index 0000000..a5850eb
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Shared/PrivilegeChanges.razor
@@ -0,0 +1,31 @@
+@inject Display Display
+
+@if (Changes.Count == 0)
+{
+ Ingen ændringer registreret i perioden. Den første måling efter installation er udgangspunktet; ændringer derefter vises her.
+}
+else
+{
+
+ | Opdaget | @if (ShowInstance) { Instans | }Ændring | Rettighed | Hvem | Ændret af | Ændret |
+
+ @foreach (var c in Changes)
+ {
+
+ | @Display.Time(c.DetectedAt) |
+ @if (ShowInstance) { @c.InstanceName | }
+ @c.Kind |
+ @c.Privilege |
+ @c.Principal@(c.ViaGroup is null ? "" : $" (via {c.ViaGroup})") |
+ @(c.ChangedBy ?? (c.ViaGroup is null ? "ukendt" : "Active Directory")) |
+ @(c.ChangedAt is { } at ? Display.Time(at) : "–") |
+
+ }
+
+
+}
+
+@code {
+ [Parameter, EditorRequired] public IReadOnlyList Changes { get; set; } = [];
+ [Parameter] public bool ShowInstance { get; set; }
+}
diff --git a/src/SQLInsight.Web/Components/Shared/PrivilegeDetails.razor b/src/SQLInsight.Web/Components/Shared/PrivilegeDetails.razor
new file mode 100644
index 0000000..a6fb7cc
--- /dev/null
+++ b/src/SQLInsight.Web/Components/Shared/PrivilegeDetails.razor
@@ -0,0 +1,95 @@
+@inject Display Display
+
+@if (Entry.Snapshot is not { } snapshot)
+{
+ Rettighederne er endnu ikke målt. De læses første gang inden for 15 minutter efter opgradering til version 1.0.5.
+}
+else
+{
+ var sysadmins = Entry.Sysadmins;
+
+
+ Sysadmin
målt @Display.Ago(snapshot.CapturedAt.UtcDateTime)
+ @sysadmins.Count personer og konti har sysadmin (grænse @snapshot.MaxSysadminAccounts).
+ @if (sysadmins.Count > 0)
+ {
+
+ | Konto | Navn | Type | Kilde |
+
+ @foreach (var a in sysadmins)
+ {
+ | @a.Account | @(a.DisplayName ?? "–") | @a.Type | @(a.Source == PrivilegeAnalysis.Direct ? "direkte" : $"via {a.Source}") |
+ }
+
+
+ }
+ @foreach (var g in PrivilegeAnalysis.UnfoldedGroupsMissing(snapshot, PrivilegeAnalysis.Sysadmin))
+ {
+ Gruppen @g.Name kunne ikke foldes ud: @g.ExpansionError Dens medlemmer er ikke talt med.
+ }
+
+
+ Kontroller
+
+
+ | sa-kontoen | @PrivilegeAudit.SaText(Entry.Sa) |
+ | SQL-logins uden adgangskodepolitik | @(Entry.LoginsWithoutPolicy.Count == 0 ? "Ingen" : string.Join(", ", Entry.LoginsWithoutPolicy.Select(l => l.Name))) |
+ | SQL-logins med politik men uden udløb | @snapshot.SqlLogins.Count(l => !l.IsDisabled && l.PolicyChecked && !l.ExpirationChecked) |
+ | Default trace | @PrivilegeAudit.TraceText(snapshot) |
+
+
+
+
+
+
+ Forhøjede serverrettigheder
faste serverroller og CONTROL SERVER
+ @if (snapshot.Principals.Count == 0)
+ {
+ Ingen.
+ }
+ else
+ {
+
+ | Rettighed | Login eller gruppe | Type | Status | Personer i gruppen |
+
+ @foreach (var p in snapshot.Principals.OrderBy(p => Order(p.Privilege)).ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase))
+ {
+
+ | @p.Privilege |
+ @p.Name |
+ @p.Type |
+ @(p.IsDisabled ? "deaktiveret" : "aktiv") |
+
+ @if (p.Type != "Windows-gruppe")
+ {
+ –
+ }
+ else if (p.Members is null)
+ {
+ kunne ikke foldes ud: @p.ExpansionError
+ }
+ else if (p.Members.Count == 0)
+ {
+ ingen medlemmer
+ }
+ else
+ {
+ @foreach (var m in p.Members)
+ {
+ @m.Account@(m.DisplayName is { } n ? $" ({n})" : "")@(m.Enabled ? "" : " – deaktiveret")
+ }
+ }
+ |
+
+ }
+
+
+ }
+
+}
+
+@code {
+ [Parameter, EditorRequired] public PrivilegeAuditInstance Entry { get; set; } = default!;
+
+ private static int Order(string privilege) => PrivilegeAnalysis.Privileges.ToList().IndexOf(privilege);
+}
diff --git a/src/SQLInsight.Web/Components/_Imports.razor b/src/SQLInsight.Web/Components/_Imports.razor
index 8f223d9..055bd67 100644
--- a/src/SQLInsight.Web/Components/_Imports.razor
+++ b/src/SQLInsight.Web/Components/_Imports.razor
@@ -10,3 +10,4 @@
@using SQLInsight.Web.Components
@using SQLInsight.Web.Components.Layout
@using SQLInsight.Web.Components.Shared
+@using SQLInsight.Reports
diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs
index 9b4fa12..3097a8f 100644
--- a/src/SQLInsight.Web/Program.cs
+++ b/src/SQLInsight.Web/Program.cs
@@ -113,6 +113,23 @@ app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository r
return Results.File(SQLInsight.Reports.ManagementReport.Render(data), "application/pdf", $"driftsrapport-{now:yyyy-MM-dd}.pdf");
});
+// Privilege audit as PDF, for one instance or all. Contains names: produced here, for the person who is logged in.
+app.MapGet("/rapporter/rettigheder.pdf", async (int? dage, string? instans, InsightRepository repository, Display display,
+ Microsoft.Extensions.Options.IOptions options, CancellationToken ct) =>
+{
+ InstanceOverview? instance = null;
+ if (instans is { Length: > 0 } && (instance = await repository.GetInstanceAsync(instans, ct)) is null)
+ {
+ return Results.NotFound();
+ }
+
+ var days = Math.Clamp(dage ?? 90, 1, 366);
+ var now = DateTimeOffset.UtcNow;
+ var audit = await SQLInsight.Reports.PrivilegeAudit.LoadAsync(repository, instance, now.AddDays(-days), now, ct);
+ var scope = instance is null ? "alle" : string.Concat(instance.Name.Select(c => char.IsLetterOrDigit(c) || c == '-' ? c : '_'));
+ return Results.File(audit.Render(options.Value.Organization, display.Zone), "application/pdf", $"rettigheder-{scope}-{now:yyyy-MM-dd}.pdf");
+});
+
// Time window analysis as PDF. Contains statement texts: produced here, for the person who is logged in.
app.MapGet("/analyse.pdf", async (string? instans, string? fra, string? til, string? sortering, InsightRepository repository, Display display,
Microsoft.Extensions.Options.IOptions options, CancellationToken ct) =>
diff --git a/tests/SQLInsight.Agent.Tests/GroupResolverTests.cs b/tests/SQLInsight.Agent.Tests/GroupResolverTests.cs
new file mode 100644
index 0000000..e5db81e
--- /dev/null
+++ b/tests/SQLInsight.Agent.Tests/GroupResolverTests.cs
@@ -0,0 +1,29 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using SQLInsight.Agent.Security;
+
+namespace SQLInsight.Agent.Tests;
+
+///
+/// The Active Directory resolver's answers that need no domain. Unfolding real groups needs a domain controller and is
+/// not tested here.
+///
+public class GroupResolverTests
+{
+ [Theory]
+ [InlineData(@"BUILTIN\Administrators", "lokal gruppe")]
+ [InlineData("SQL-Admins", "intet domæne")]
+ public async Task Groups_that_cannot_be_looked_up_give_the_reason(string group, string reason)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var resolver = new ActiveDirectoryGroupResolver(TimeProvider.System, NullLogger.Instance);
+
+ var expansion = await resolver.ExpandAsync(group, CancellationToken.None);
+
+ Assert.Empty(expansion.Members);
+ Assert.Contains(reason, expansion.Error, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/SQLInsight.Core.Tests/PrivilegeAnalysisTests.cs b/tests/SQLInsight.Core.Tests/PrivilegeAnalysisTests.cs
new file mode 100644
index 0000000..5fb0f4d
--- /dev/null
+++ b/tests/SQLInsight.Core.Tests/PrivilegeAnalysisTests.cs
@@ -0,0 +1,125 @@
+using SQLInsight.Core.Data;
+
+namespace SQLInsight.Core.Tests;
+
+public class PrivilegeAnalysisTests
+{
+ private static readonly DateTimeOffset Then = new(2026, 9, 29, 10, 0, 0, TimeSpan.Zero);
+ private static readonly DateTimeOffset Now = Then.AddMinutes(15);
+
+ private static PrivilegeSnapshot Snapshot(params PrivilegedPrincipal[] principals) => new() { CapturedAt = Now, Principals = principals };
+
+ private static PrivilegedPrincipal Group(string name, params string[] members) =>
+ new(name, "Windows-gruppe", "sysadmin", false, members.Select(m => new GroupMember(m, null, true)).ToList());
+
+ [Fact]
+ public void New_and_removed_direct_members_are_found_by_privilege_and_name()
+ {
+ var before = Snapshot(new("sa", "SQL-login", "sysadmin", false), new("KUNDE\\anne", "Windows-login", "dbcreator", false));
+ var after = Snapshot(new("sa", "SQL-login", "sysadmin", false), new("KUNDE\\anne", "Windows-login", "sysadmin", false));
+
+ var changes = PrivilegeAnalysis.Diff(before, after, Now);
+
+ Assert.Equal(2, changes.Count);
+ Assert.Contains(changes, c => c is { Kind: PrivilegeAnalysis.Added, Privilege: "sysadmin", Principal: "KUNDE\\anne", ViaGroup: null });
+ Assert.Contains(changes, c => c is { Kind: PrivilegeAnalysis.Removed, Privilege: "dbcreator", Principal: "KUNDE\\anne" });
+ }
+
+ [Fact]
+ public void People_added_to_an_unfolded_group_are_new_members_via_the_group()
+ {
+ var before = Snapshot(Group("KUNDE\\SQL-Admins", "KUNDE\\anne"));
+ var after = Snapshot(Group("KUNDE\\SQL-Admins", "KUNDE\\anne", "KUNDE\\bo"));
+
+ var change = Assert.Single(PrivilegeAnalysis.Diff(before, after, Now));
+
+ Assert.Equal((PrivilegeAnalysis.Added, "KUNDE\\bo", "KUNDE\\SQL-Admins", "Windows-bruger"), (change.Kind, change.Principal, change.ViaGroup, change.PrincipalType));
+ }
+
+ [Fact]
+ public void A_group_that_could_not_be_unfolded_does_not_look_like_everyone_left()
+ {
+ var before = Snapshot(Group("KUNDE\\SQL-Admins", "KUNDE\\anne", "KUNDE\\bo"));
+ var after = Snapshot(new PrivilegedPrincipal("KUNDE\\SQL-Admins", "Windows-gruppe", "sysadmin", false, ExpansionError: "Active Directory svarede ikke"));
+
+ Assert.Empty(PrivilegeAnalysis.Diff(before, after, Now));
+ }
+
+ [Fact]
+ public void Who_and_when_comes_from_the_matching_trace_event()
+ {
+ var changes = new[]
+ {
+ new PrivilegeChange(Now, PrivilegeAnalysis.Added, "sysadmin", "ny_admin", "SQL-login"),
+ new PrivilegeChange(Now, PrivilegeAnalysis.Removed, "dbcreator", "gammel", "SQL-login"),
+ new PrivilegeChange(Now, PrivilegeAnalysis.Added, "sysadmin", "KUNDE\\bo", "Windows-bruger", "KUNDE\\SQL-Admins"),
+ };
+ var events = new[]
+ {
+ new TraceSecurityEvent(Then.AddMinutes(3), 108, 1, "KUNDE\\it-chef", "ny_admin", "sysadmin"),
+ new TraceSecurityEvent(Then.AddMinutes(5), 108, 1, "KUNDE\\vikar", "ny_admin", "sysadmin"), // the latest wins
+ new TraceSecurityEvent(Then.AddMinutes(6), 108, 1, "KUNDE\\vikar", "ny_admin", "serveradmin"), // other role
+ new TraceSecurityEvent(Now.AddMinutes(5), 108, 1, "KUNDE\\senere", "ny_admin", "sysadmin"), // after detection
+ new TraceSecurityEvent(Then.AddMinutes(7), 108, 2, "KUNDE\\it-chef", "gammel", "dbcreator"),
+ };
+
+ var enriched = PrivilegeAnalysis.Enrich(changes, events);
+
+ Assert.Equal(("KUNDE\\vikar", Then.AddMinutes(5)), (enriched[0].ChangedBy, enriched[0].ChangedAt));
+ Assert.Equal("KUNDE\\it-chef", enriched[1].ChangedBy);
+ Assert.Null(enriched[2].ChangedBy); // a change in Active Directory is not in SQL Server's trace
+ }
+
+ [Fact]
+ public void Accounts_count_people_in_groups_once_and_leave_out_services_and_disabled()
+ {
+ var snapshot = Snapshot(
+ new("sa", "SQL-login", "sysadmin", true),
+ new("KUNDE\\anne", "Windows-login", "sysadmin", false),
+ new("NT SERVICE\\MSSQLSERVER", "Windows-login", "sysadmin", false),
+ new("KUNDE\\SQL-Admins", "Windows-gruppe", "sysadmin", false,
+ [new("KUNDE\\anne", "Anne", true), new("KUNDE\\bo", "Bo", true), new("KUNDE\\fratraadt", null, false), new("KUNDE\\SRV01$", null, true)]),
+ new("KUNDE\\DBA", "Windows-gruppe", "sysadmin", false, ExpansionError: "timeout"),
+ new("KUNDE\\cecilie", "Windows-login", "dbcreator", false));
+
+ var accounts = PrivilegeAnalysis.Accounts(snapshot, PrivilegeAnalysis.Sysadmin);
+
+ Assert.Equal(["KUNDE\\anne", "KUNDE\\bo"], accounts.Select(a => a.Account));
+ Assert.Equal(PrivilegeAnalysis.Direct, accounts[0].Source);
+ Assert.Equal("KUNDE\\SQL-Admins", accounts[1].Source);
+ Assert.Equal("KUNDE\\DBA", Assert.Single(PrivilegeAnalysis.UnfoldedGroupsMissing(snapshot, PrivilegeAnalysis.Sysadmin)).Name);
+ }
+
+ [Theory]
+ [InlineData(@"NT SERVICE\SQLSERVERAGENT", true)]
+ [InlineData(@"NT AUTHORITY\SYSTEM", true)]
+ [InlineData("##MS_PolicyTsqlExecutionLogin##", true)]
+ [InlineData(@"KUNDE\gmsa-sql$", true)]
+ [InlineData(@"KUNDE\anne", false)]
+ [InlineData("sa", false)]
+ public void Service_identities_are_not_people(string name, bool expected) => Assert.Equal(expected, PrivilegeAnalysis.IsServiceIdentity(name));
+
+ [Theory]
+ [InlineData(@"C:\Program Files\Microsoft SQL Server\MSSQL16.MSSQLSERVER\MSSQL\Log\log_123.trc", @"C:\Program Files\Microsoft SQL Server\MSSQL16.MSSQLSERVER\MSSQL\Log\log.trc")]
+ [InlineData("/var/opt/mssql/log/log_7.trc", "/var/opt/mssql/log/log.trc")]
+ public void Trace_files_are_read_from_the_first_rollover_file(string current, string expected) =>
+ Assert.Equal(expected, PrivilegeAnalysis.TraceFileBase(current));
+
+ [Fact]
+ public void Fingerprints_are_stable_short_and_free_of_names()
+ {
+ var a = PrivilegeAnalysis.Fingerprint("KUNDE\\anne|");
+ Assert.Equal(12, a.Length);
+ Assert.Equal(a, PrivilegeAnalysis.Fingerprint("kunde\\ANNE|"));
+ Assert.DoesNotContain("anne", a, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Without_active_directory_groups_fail_with_the_reason()
+ {
+ var expansion = await new NoGroupResolver("Agenten kører ikke på Windows").ExpandAsync("KUNDE\\SQL-Admins", CancellationToken.None);
+
+ Assert.Empty(expansion.Members);
+ Assert.Equal("Agenten kører ikke på Windows", expansion.Error);
+ }
+}
diff --git a/tests/SQLInsight.IntegrationTests/PrivilegeTests.cs b/tests/SQLInsight.IntegrationTests/PrivilegeTests.cs
new file mode 100644
index 0000000..3a86a6a
--- /dev/null
+++ b/tests/SQLInsight.IntegrationTests/PrivilegeTests.cs
@@ -0,0 +1,197 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using SQLInsight.Collectors.Instance;
+using SQLInsight.Core.Data;
+using SQLInsight.Core.Findings;
+using SQLInsight.Core.Instances;
+using SQLInsight.Repository;
+using SQLInsight.Rules.Engine;
+using SQLInsight.Rules.Security;
+
+namespace SQLInsight.IntegrationTests;
+
+/// The privilege audit against real SQL Server, as the least-privilege agent login.
+[Collection(SqlServerTestGroup.Name)]
+[Trait("Category", "Integration")]
+public class PrivilegeTests(SqlServerFixture sql)
+{
+ /// A resolver that answers like Active Directory would, for Windows groups the test cannot create.
+ private sealed class FakeDirectory(Dictionary groups) : IGroupMemberResolver
+ {
+ public Task ExpandAsync(string group, CancellationToken cancellationToken) =>
+ Task.FromResult(groups.TryGetValue(group, out var e) ? e : GroupExpansion.Failed("ukendt gruppe"));
+ }
+
+ [Fact]
+ public async Task A_new_sysadmin_is_found_with_who_and_when_from_the_default_trace()
+ {
+ var login = "sqli_priv_" + Guid.NewGuid().ToString("N")[..6];
+ await sql.ExecuteAsSaAsync($"CREATE LOGIN [{login}] WITH PASSWORD = N'Pr1v-{Guid.NewGuid():N}', CHECK_POLICY = OFF;");
+ await sql.ExecuteAsSaAsync($"GRANT ALTER TRACE TO [{SqlServerFixture.AgentLogin}];");
+ try
+ {
+ await using var session = await AgentSession.OpenAsync(sql);
+ var collector = new PrivilegeCollector();
+ var first = await session.CollectAsync(collector);
+ await sql.ExecuteAsSaAsync($"ALTER SERVER ROLE [sysadmin] ADD MEMBER [{login}];");
+ var second = await session.CollectAsync(collector, first);
+
+ Assert.Empty(first.Changes);
+ Assert.Contains(first.Principals, p => p is { Name: "sa", Privilege: "sysadmin", Type: "SQL-login" });
+ Assert.Contains(first.SqlLogins, l => l.IsSa);
+ Assert.Contains(first.SqlLogins, l => l.Name == login && !l.PolicyChecked);
+ Assert.True(first.DefaultTraceEnabled);
+
+ var detected = Assert.Single(second.Changes);
+ Assert.Equal((PrivilegeAnalysis.Added, "sysadmin", login, "SQL-login"), (detected.Kind, detected.Privilege, detected.Principal, detected.PrincipalType));
+ Assert.Null(second.TraceStatus);
+
+ // SQL Server writes the default trace in buffers: who and when may only be readable at a later collection.
+ var latest = second;
+ for (var attempt = 0; attempt < 30 && latest.RecentChanges.Single().ChangedBy is null; attempt++)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(2));
+ await sql.ExecuteAsSaAsync("SELECT 1;");
+ latest = await session.CollectAsync(collector, latest);
+ }
+
+ var change = latest.RecentChanges.Single();
+ Assert.Equal("sa", change.ChangedBy);
+ Assert.NotNull(change.ChangedAt);
+ Assert.InRange(change.ChangedAt.Value, DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddMinutes(1));
+ Assert.Equal(detected.DetectedAt, change.DetectedAt);
+
+ var context = new RuleContext(session.Instance, session.Capabilities, DateTimeOffset.UtcNow);
+ context.Data.Set(latest);
+ var finding = Assert.Single(Assert.Single(new RuleEngine([new NewPrivilegeRule()]).Evaluate(context)).Findings);
+ Assert.Equal(Severity.High, finding.Severity);
+ Assert.Equal("sa", finding.Evidence.Single(e => e.Name == "Ændret af").Value);
+ Assert.DoesNotContain(login, finding.Title + finding.Subject, StringComparison.Ordinal);
+ }
+ finally
+ {
+ await sql.ExecuteAsSaAsync($"REVOKE ALTER TRACE FROM [{SqlServerFixture.AgentLogin}]; DROP LOGIN [{login}];");
+ }
+ }
+
+ [Fact]
+ public async Task Without_alter_trace_the_change_is_found_and_the_reason_given()
+ {
+ var login = "sqli_priv_" + Guid.NewGuid().ToString("N")[..6];
+ await sql.ExecuteAsSaAsync($"CREATE LOGIN [{login}] WITH PASSWORD = N'Pr1v-{Guid.NewGuid():N}', CHECK_POLICY = OFF;");
+ try
+ {
+ await using var session = await AgentSession.OpenAsync(sql);
+ var collector = new PrivilegeCollector();
+ var first = await session.CollectAsync(collector);
+ await sql.ExecuteAsSaAsync($"ALTER SERVER ROLE [dbcreator] ADD MEMBER [{login}];");
+ var second = await session.CollectAsync(collector, first);
+
+ var change = Assert.Single(second.Changes);
+ Assert.Equal("dbcreator", change.Privilege);
+ Assert.Null(change.ChangedBy);
+ Assert.Contains("ALTER TRACE", second.TraceStatus, StringComparison.Ordinal);
+ }
+ finally
+ {
+ await sql.ExecuteAsSaAsync($"DROP LOGIN [{login}];");
+ }
+ }
+
+ [Fact]
+ public async Task Windows_groups_are_unfolded_through_the_resolver_and_failures_kept()
+ {
+ // Windows logins cannot be created on SQL Server on Linux, so the group is checked with the resolver directly.
+ var directory = new FakeDirectory(new(StringComparer.OrdinalIgnoreCase)
+ {
+ [@"KUNDE\SQL-Admins"] = new([new(@"KUNDE\anne", "Anne", true)]),
+ });
+
+ var ok = await directory.ExpandAsync(@"KUNDE\SQL-Admins", CancellationToken.None);
+ var failed = await directory.ExpandAsync(@"KUNDE\Andet", CancellationToken.None);
+
+ Assert.Equal(@"KUNDE\anne", Assert.Single(ok.Members).Account);
+ Assert.Equal("ukendt gruppe", failed.Error);
+ await using var session = await AgentSession.OpenAsync(sql);
+ var snapshot = await session.CollectAsync(new PrivilegeCollector(directory, maxSysadminAccounts: 5));
+ Assert.Equal(5, snapshot.MaxSysadminAccounts);
+ }
+
+ [Fact]
+ public async Task Pages_and_audit_report_show_rights_changes_and_the_alert()
+ {
+ var database = "SQLInsightPriv_" + Guid.NewGuid().ToString("N")[..8];
+ await sql.ExecuteAsSaAsync($"CREATE DATABASE [{database}];");
+ try
+ {
+ var repository = new InsightRepository(sql.DatabaseConnectionString(database));
+ await repository.MigrateAsync();
+ var info = new InstanceInfo
+ {
+ ServerName = "AX01",
+ Version = SqlServerVersion.Parse("16.0.4135.4"),
+ ProductLevel = "RTM",
+ Edition = "Standard Edition",
+ EngineEdition = EngineEdition.Standard,
+ };
+ var now = DateTimeOffset.UtcNow;
+ var id = await repository.UpsertInstanceAsync(@"AX01\AX30", info, "SqlClient", false, now);
+ var change = new PrivilegeChange(now, PrivilegeAnalysis.Added, "sysadmin", "ny_admin", "SQL-login", ChangedBy: @"KUNDE\it-chef", ChangedAt: now.AddMinutes(-3));
+ await repository.SaveCollectedAsync(id, "privileges", new PrivilegeSnapshot
+ {
+ CapturedAt = now,
+ Principals =
+ [
+ new("sa", "SQL-login", "sysadmin", false),
+ new("ny_admin", "SQL-login", "sysadmin", false),
+ new(@"KUNDE\SQL-Admins", "Windows-gruppe", "sysadmin", false, [new(@"KUNDE\anne", "Anne Andersen", true)]),
+ new(@"KUNDE\DBA", "Windows-gruppe", "serveradmin", false, ExpansionError: "Active Directory svarede ikke inden for 20 sekunder."),
+ ],
+ SqlLogins = [new("sa", false, false, false, true), new("ny_admin", false, false, false, false)],
+ DefaultTraceEnabled = true,
+ Changes = [change],
+ RecentChanges = [change],
+ });
+ var context = new RuleContext(info, InstanceCapabilities.From(info), now);
+ context.Data.Set((await repository.GetSnapshotAsync(id, "privileges"))!);
+ var results = new RuleEngine([new NewPrivilegeRule()]).Evaluate(context);
+ await repository.SaveRuleResultsAsync(id, results, now);
+
+ await using var factory = new WebApplicationFactory().WithWebHostBuilder(b =>
+ {
+ b.UseEnvironment("Development");
+ b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(database));
+ b.UseSetting("SQLInsight:Web:Authentication", "None");
+ });
+ var client = factory.CreateClient();
+ var name = Uri.EscapeDataString(@"AX01\AX30");
+ var page = await client.GetStringAsync($"/instanser/{name}/rettigheder");
+ var all = await client.GetStringAsync("/rettigheder?dage=7");
+ var instancePage = await client.GetStringAsync($"/instanser/{name}");
+ var home = await client.GetStringAsync("/");
+ var reports = await client.GetStringAsync("/rapporter");
+ var pdf = await client.GetByteArrayAsync($"/rapporter/rettigheder.pdf?dage=30&instans={name}");
+ var pdfAll = await client.GetByteArrayAsync("/rapporter/rettigheder.pdf?dage=30");
+
+ Assert.Contains(@"KUNDE\anne", page, StringComparison.Ordinal); // unfolded
+ Assert.Contains("kunne ikke foldes ud", page, StringComparison.Ordinal);
+ Assert.Contains(@"KUNDE\it-chef", page, StringComparison.Ordinal); // who, in the change log
+ Assert.Contains("Default trace", page, StringComparison.Ordinal);
+ Assert.Contains("ny_admin", all, StringComparison.Ordinal);
+ Assert.Contains("Nye forhøjede rettigheder", instancePage, StringComparison.Ordinal);
+ Assert.Contains("Nye forhøjede rettigheder", home, StringComparison.Ordinal);
+ Assert.Contains("/rapporter/rettigheder.pdf", reports, StringComparison.Ordinal);
+ Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdf, 0, 4));
+ Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(pdfAll, 0, 4));
+ Assert.Single(await repository.GetPrivilegeChangesAsync(id, now.AddDays(-1), now.AddMinutes(1)));
+
+ // The integration API leaves the names out.
+ var api = await repository.GetActiveFindingsAsync(id);
+ Assert.Contains(api.Single().Evidence, e => e.Name == "Hvem fik den" && e.Sensitive);
+ }
+ finally
+ {
+ await sql.ExecuteAsSaAsync($"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}];");
+ }
+ }
+}
diff --git a/tests/SQLInsight.Rules.Tests/PrivilegeRuleTests.cs b/tests/SQLInsight.Rules.Tests/PrivilegeRuleTests.cs
new file mode 100644
index 0000000..59bf301
--- /dev/null
+++ b/tests/SQLInsight.Rules.Tests/PrivilegeRuleTests.cs
@@ -0,0 +1,93 @@
+using SQLInsight.Core.Data;
+using SQLInsight.Core.Findings;
+using SQLInsight.Rules.Engine;
+using SQLInsight.Rules.Security;
+using static SQLInsight.Rules.Tests.TestInstances;
+
+namespace SQLInsight.Rules.Tests;
+
+public class PrivilegeRuleTests
+{
+ private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
+
+ private static PrivilegeSnapshot Snapshot(IReadOnlyList? recent = null, int max = 3, params PrivilegedPrincipal[] principals) => new()
+ {
+ CapturedAt = Now,
+ Principals = principals,
+ RecentChanges = recent ?? [],
+ MaxSysadminAccounts = max,
+ };
+
+ [Fact]
+ public void New_sysadmin_is_high_with_who_and_when_as_sensitive_evidence()
+ {
+ var change = new PrivilegeChange(Now, PrivilegeAnalysis.Added, "sysadmin", "ny_admin", "SQL-login", ChangedBy: @"KUNDE\it-chef", ChangedAt: Now.AddMinutes(-4));
+
+ var finding = Assert.Single(Run(new NewPrivilegeRule(), With(Snapshot([change]))).Findings);
+
+ Assert.Equal(Severity.High, finding.Severity);
+ Assert.DoesNotContain("ny_admin", finding.Title, StringComparison.Ordinal);
+ Assert.DoesNotContain("ny_admin", finding.Subject, StringComparison.Ordinal);
+ Assert.True(Assert.Single(finding.Evidence, e => e.Name == "Hvem fik den").Sensitive);
+ var by = Assert.Single(finding.Evidence, e => e.Name == "Ændret af");
+ Assert.Equal((@"KUNDE\it-chef", true), (by.Value, by.Sensitive));
+ Assert.Contains(finding.Evidence, e => e.Name == "Ændret" && e.Value.StartsWith("2026-09-29 11:56", StringComparison.Ordinal));
+ }
+
+ [Theory]
+ [InlineData("securityadmin", Severity.High)]
+ [InlineData("CONTROL SERVER", Severity.High)]
+ [InlineData("dbcreator", Severity.Medium)]
+ [InlineData("bulkadmin", Severity.Medium)]
+ public void Severity_follows_the_privilege(string privilege, Severity expected)
+ {
+ var change = new PrivilegeChange(Now, PrivilegeAnalysis.Added, privilege, @"KUNDE\bo", "Windows-bruger", @"KUNDE\SQL-Admins");
+
+ var finding = Assert.Single(Run(new NewPrivilegeRule(), With(Snapshot([change]))).Findings);
+
+ Assert.Equal(expected, finding.Severity);
+ Assert.Contains(finding.Evidence, e => e.Name == "Via Windows-gruppe" && e.Sensitive);
+ }
+
+ [Fact]
+ public void Removed_rights_and_quiet_weeks_give_no_finding()
+ {
+ var removed = new PrivilegeChange(Now, PrivilegeAnalysis.Removed, "sysadmin", "gammel", "SQL-login");
+
+ Assert.Equal(RuleOutcome.Passed, Run(new NewPrivilegeRule(), With(Snapshot([removed]))).Outcome);
+ Assert.Equal(RuleOutcome.Passed, Run(new NewPrivilegeRule(), With(Snapshot())).Outcome);
+ }
+
+ [Fact]
+ public void Unknown_who_explains_why()
+ {
+ var change = new PrivilegeChange(Now, PrivilegeAnalysis.Added, "sysadmin", "ny_admin", "SQL-login");
+ var snapshot = Snapshot([change]) with { TraceStatus = "Giv agenten ALTER TRACE" };
+
+ var finding = Assert.Single(Run(new NewPrivilegeRule(), With(snapshot)).Findings);
+
+ Assert.Equal("ukendt", finding.Evidence.Single(e => e.Name == "Ændret af").Value);
+ Assert.Contains(finding.Evidence, e => e.Name == "Hvorfor ukendt" && e.Value.Contains("ALTER TRACE", StringComparison.Ordinal));
+ }
+
+ [Theory]
+ [InlineData(3, RuleOutcome.Passed)]
+ [InlineData(2, RuleOutcome.Findings)]
+ public void Too_many_sysadmins_counts_people_in_groups(int max, RuleOutcome expected)
+ {
+ var snapshot = Snapshot(null, max,
+ new("sa", "SQL-login", "sysadmin", false),
+ new(@"KUNDE\SQL-Admins", "Windows-gruppe", "sysadmin", false, [new(@"KUNDE\anne", "Anne", true), new(@"KUNDE\bo", "Bo", true)]),
+ new(@"NT SERVICE\MSSQLSERVER", "Windows-login", "sysadmin", false));
+
+ var result = Run(new TooManySysadminsRule(), With(snapshot));
+
+ Assert.Equal(expected, result.Outcome);
+ if (expected == RuleOutcome.Findings)
+ {
+ var finding = Assert.Single(result.Findings);
+ Assert.Equal("3", finding.Evidence.Single(e => e.Name == "Antal").Value);
+ Assert.True(finding.Evidence.Single(e => e.Name == "Personer og konti").Sensitive);
+ }
+ }
+}
diff --git a/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs b/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs
index ebb6883..9bea38e 100644
--- a/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs
+++ b/tests/SQLInsight.Rules.Tests/RuleCatalogTests.cs
@@ -18,6 +18,6 @@ public class RuleCatalogTests
[Fact]
public void Rule_count()
{
- Assert.Equal(62, BuiltInRules.Create().Count);
+ Assert.Equal(64, BuiltInRules.Create().Count);
}
}