sqlinsight-pro/tests/SQLInsight.Rules.Tests/PrivilegeRuleTests.cs
Søren Eggert Lundsteen Olsen 4ba7e5195e Privilege audit: sysadmin and server roles with AD unfolding
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>
2026-10-02 08:39:56 +02:00

93 lines
4.2 KiB
C#

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<PrivilegeChange>? 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);
}
}
}