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}];"); } } }