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