sqlinsight-pro/tests/SQLInsight.IntegrationTests/PrivilegeTests.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

197 lines
10 KiB
C#

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;
/// <summary>The privilege audit against real SQL Server, as the least-privilege agent login.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class PrivilegeTests(SqlServerFixture sql)
{
/// <summary>A resolver that answers like Active Directory would, for Windows groups the test cannot create.</summary>
private sealed class FakeDirectory(Dictionary<string, GroupExpansion> groups) : IGroupMemberResolver
{
public Task<GroupExpansion> 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<PrivilegeSnapshot>(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<PrivilegeSnapshot>(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<PrivilegeSnapshot>(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<PrivilegeSnapshot>(id, "privileges"))!);
var results = new RuleEngine([new NewPrivilegeRule()]).Evaluate(context);
await repository.SaveRuleResultsAsync(id, results, now);
await using var factory = new WebApplicationFactory<Program>().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}];");
}
}
}