Collects members of the fixed server roles and CONTROL SERVER every 15
minutes with login type, disabled state and SQL login password policy.
Windows groups are unfolded recursively through Active Directory on a
Windows agent (cached, 20 s timeout, failures shown with the reason).
Changes since the previous collection are logged, and who/when is read
from the default trace (event 108) when the agent has the optional
ALTER TRACE; authors that only reach the buffered trace later are filled
in at the next collection. The first collection is the baseline.
New rules SQLI-SEC-001 (new privilege, High for sysadmin/securityadmin/
CONTROL SERVER) and SQLI-SEC-002 (more sysadmins than the limit, default
3). Names are sensitive evidence and never in titles or subjects.
Web: /instanser/{navn}/rettigheder and /rettigheder with change log by
period, alert banner on the instance page and overview, and an audit
PDF (/rapporter/rettigheder.pdf) linked from Rapporter.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
125 lines
6.4 KiB
C#
125 lines
6.4 KiB
C#
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);
|
|
}
|
|
}
|