sqlinsight-pro/src/SQLInsight.Agent/Security/ActiveDirectoryGroupResolver.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

114 lines
5 KiB
C#

using System.Collections.Concurrent;
using System.DirectoryServices.AccountManagement;
using System.Runtime.Versioning;
using System.Security.Principal;
using SQLInsight.Core.Data;
namespace SQLInsight.Agent.Security;
/// <summary>
/// Unfolds a Windows group (DOMAIN\Group) to the people in it, recursively, by asking Active Directory as the agent's
/// account (the computer account for the default virtual account, which may read the directory in a domain).
/// Answers are cached for an hour (failures for ten minutes) and each lookup is cut off after 20 seconds, so a slow or
/// unreachable domain controller never holds up the monitoring.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed partial class ActiveDirectoryGroupResolver(TimeProvider time, ILogger<ActiveDirectoryGroupResolver> logger) : IGroupMemberResolver
{
public static readonly TimeSpan CacheFor = TimeSpan.FromHours(1);
public static readonly TimeSpan CacheFailuresFor = TimeSpan.FromMinutes(10);
public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(20);
private readonly ConcurrentDictionary<string, (DateTimeOffset At, GroupExpansion Result)> _cache = new(StringComparer.OrdinalIgnoreCase);
public async Task<GroupExpansion> ExpandAsync(string group, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(group);
var now = time.GetUtcNow();
if (_cache.TryGetValue(group, out var cached) && now - cached.At < (cached.Result.Error is null ? CacheFor : CacheFailuresFor))
{
return cached.Result;
}
GroupExpansion result;
try
{
result = await Task.Run(() => Expand(group), cancellationToken).WaitAsync(Timeout, cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
result = GroupExpansion.Failed($"Active Directory svarede ikke inden for {Timeout.TotalSeconds:0} sekunder.");
}
catch (Exception ex) when (ex is PrincipalException or System.DirectoryServices.DirectoryServicesCOMException or System.Runtime.InteropServices.COMException
or InvalidOperationException or UnauthorizedAccessException or ArgumentException)
{
result = GroupExpansion.Failed($"Active Directory kunne ikke spørges: {ex.Message}");
}
if (result.Error is not null)
{
LogExpansionFailed(logger, group, result.Error);
}
_cache[group] = (now, result);
return result;
}
private static GroupExpansion Expand(string group)
{
var separator = group.IndexOf('\\', StringComparison.Ordinal);
if (separator <= 0)
{
return GroupExpansion.Failed("Gruppen har intet domæne (forventet DOMÆNE\\gruppe).");
}
var domain = group[..separator];
var name = group[(separator + 1)..];
if (domain.Equals("BUILTIN", StringComparison.OrdinalIgnoreCase) || domain.Equals("NT AUTHORITY", StringComparison.OrdinalIgnoreCase))
{
return GroupExpansion.Failed("En lokal gruppe på SQL Server-maskinen; medlemmerne kan ikke læses fra Active Directory.");
}
var local = domain.Equals(Environment.MachineName, StringComparison.OrdinalIgnoreCase);
using var context = local ? new PrincipalContext(ContextType.Machine) : new PrincipalContext(ContextType.Domain, domain);
using var principal = GroupPrincipal.FindByIdentity(context, IdentityType.SamAccountName, name);
if (principal is null)
{
return GroupExpansion.Failed(local ? "Gruppen findes ikke på maskinen." : $"Gruppen findes ikke i domænet {domain}.");
}
var members = new List<GroupMember>();
using var found = principal.GetMembers(recursive: true);
foreach (var member in found)
{
using (member)
{
var account = Account(member, domain);
var enabled = member is AuthenticablePrincipal authenticable ? authenticable.Enabled ?? true : true;
members.Add(new GroupMember(account, member.DisplayName, enabled));
}
}
return new GroupExpansion(members.DistinctBy(m => m.Account, StringComparer.OrdinalIgnoreCase).OrderBy(m => m.Account, StringComparer.OrdinalIgnoreCase).ToList());
}
/// <summary>DOMAIN\sam, from the SID so members of other domains get their own domain.</summary>
private static string Account(Principal member, string fallbackDomain)
{
try
{
if (member.Sid?.Translate(typeof(NTAccount)) is NTAccount account)
{
return account.Value;
}
}
catch (IdentityNotMappedException)
{
}
return $@"{fallbackDomain}\{member.SamAccountName}";
}
[LoggerMessage(Level = LogLevel.Warning, Message = "Windows group {Group} could not be unfolded: {Error}")]
private static partial void LogExpansionFailed(ILogger logger, string group, string error);
}