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>
This commit is contained in:
parent
ab8cfcad97
commit
4ba7e5195e
39 changed files with 1913 additions and 6 deletions
|
|
@ -22,6 +22,7 @@
|
|||
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
|
||||
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.12" />
|
||||
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.12" />
|
||||
<PackageVersion Include="System.DirectoryServices.AccountManagement" Version="10.0.12" />
|
||||
<PackageVersion Include="PDFsharp-MigraDoc" Version="6.2.4" />
|
||||
<PackageVersion Include="Markdig" Version="1.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA.
|
||||
|
||||
> **Status:** Fase 1–4 er implementeret: agent, 62 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
> **Status:** Fase 1–4 er implementeret: agent, 64 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -205,6 +205,7 @@ Hvert **finding** indeholder:
|
|||
### Fase 4: Videre
|
||||
- [x] Anbefalinger på tværs af kunder: Hubben ser, hvilke fund der forsvinder efter en opdatering, og agenterne viser "hos 4 kunder forsvandt dette efter CU21" på fundet. Deles kun samlet og anonymt fra mindst 3 kunder. Se [docs/hub.md](docs/hub.md#erfaring-på-tværs-af-kunder)
|
||||
- [x] Rapporter (PDF) til ledelse og compliance: nøgletal, sammenfatning i almindeligt sprog, kontroller, instanser og alvorlige fund. Kan hentes i webbrugerfladen og sendes månedligt på mail. Se [docs/rapporter.md](docs/rapporter.md)
|
||||
- [x] Rettighedsrevision: sysadmin og øvrige serverroller med Windows-grupper foldet ud via AD, ændringer med hvem/hvornår fra default trace, fund ved nye medlemmer og revisionsrapport (PDF). Se [docs/rettigheder.md](docs/rettigheder.md)
|
||||
- [x] Mulighed for, at community selv bidrager med regler: regler som SQL i JSON (community via pull request og signeret regelpakke, lokale regler hos kunden), med validering og `rules test` mod en instans. Se [docs/regler.md](docs/regler.md)
|
||||
- [x] API til integration med eksisterende monitorering: JSON-API og Prometheus-endpoint med API-tokens, Grafana-dashboard, og Windows' hændelseslog med faste event-id'er til SCOM. Se [docs/integration.md](docs/integration.md)
|
||||
- [x] Community-websted (download af signerede udgivelser, dokumentation, SQL Server-versioner, regler, support) og Discourse-forum på dansk med kategorier, løste svar og opsætning som kode. Se [docs/community.md](docs/community.md)
|
||||
|
|
|
|||
|
|
@ -105,4 +105,14 @@ SET @Sql = N'USE msdb;
|
|||
GRANT SELECT ON dbo.syscategories TO ' + @Quoted + N';';
|
||||
EXEC (@Sql);
|
||||
|
||||
/*
|
||||
Valgfrit: hvem og hvornår ved ændringer af serverroller.
|
||||
Rettighedsrevisionen (sysadmin m.m.) virker uden. Med ALTER TRACE kan agenten desuden læse default trace og vise,
|
||||
HVEM der gav en ny rettighed og HVORNÅR. ALTER TRACE giver også adgang til at oprette traces, så det er et bevidst
|
||||
valg; fjern kommentaren for at give den:
|
||||
|
||||
SET @Sql = N'GRANT ALTER TRACE TO ' + @Quoted + N';';
|
||||
EXEC (@Sql);
|
||||
*/
|
||||
|
||||
PRINT N'Rettigheder givet til ' + @LoginName + N'.';
|
||||
|
|
|
|||
|
|
@ -98,3 +98,5 @@ Deling er slået fra som standard og er ikke en betingelse for Professional elle
|
|||
## Kundens egen installation
|
||||
|
||||
Agent og webbrugerflade gemmer data i kundens eget repository hos kunden. Her er kunden selv ansvarlig. Repositoriet indeholder de samme typer data som ovenfor, inklusive følsomme målinger (forespørgselstekst, logins, deadlock-grafer), fordi de er nødvendige for at løse problemerne lokalt. Til [analyse af tidsrum](analyse.md) gemmes desuden de tungeste forespørgsler med tekst og forbrug hvert 5. minut i `RawDays` (standard 14 dage); den tekst bruges kun lokalt og indgår aldrig i det, der sendes til SQLInsight Hub. Adgang til webbrugerfladen styres med Windows-login, og databasebrugeren har kun læserettigheder.
|
||||
|
||||
Til [rettighedsrevisionen](rettigheder.md) gemmes navne på personer og konti med forhøjede serverrettigheder (også personer i Windows-grupper, slået op i kundens Active Directory af agenten), hvem der har fået eller mistet dem, og hvem der foretog ændringen. Det er personoplysninger. De bliver i kundens repository: ændringsloggen slettes efter `ResolvedFindingDays` (standard 365 dage), og i fund er navnene følsomme målinger, som sendes til SQLInsight Hub og integrations-API'et som `[udeladt]`. Fundets titel og emne indeholder ingen navne. Revisionsrapporten (PDF) dannes kun lokalt.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,17 @@ Navnet på rapporten sættes i webbens `appsettings.json`:
|
|||
"SQLInsight": { "Web": { "Organization": "Nordisk Logistik A/S", "TimeZone": "Europe/Copenhagen" } }
|
||||
```
|
||||
|
||||
## Rettighedsrevision
|
||||
|
||||
En særskilt PDF til revisionen om forhøjede serverrettigheder, for én instans (knappen under **Rettigheder** på instansens side) eller alle (under **Rapporter**), for en valgt periode. Den indeholder:
|
||||
|
||||
- de nuværende rettigheder (faste serverroller og CONTROL SERVER) med Windows-grupper foldet ud til personer, og antallet af personer med sysadmin mod grænsen;
|
||||
- ændringer i perioden med hvem og hvornår (fra default trace);
|
||||
- sa-kontoens status, SQL-logins uden adgangskodepolitik og default trace;
|
||||
- en redegørelse for datakilde og metode.
|
||||
|
||||
Adressen er `/rapporter/rettigheder.pdf?dage=90` (alle) eller `…&instans=<navn>`. Rapporten indeholder navne: den dannes i webbrugerfladen for den, der er logget ind, og sendes aldrig til SQLInsight Hub. Se [rettigheder.md](rettigheder.md).
|
||||
|
||||
## Månedlig rapport på mail
|
||||
|
||||
Agenten kan sende sidste måneds rapport automatisk. Den bruger SMTP-indstillingerne fra notifikationerne (`SQLInsight:Notifications:Email`: Host, Port, Security, From og eventuelt login). E-mail-notifikationerne behøver ikke være slået til.
|
||||
|
|
|
|||
64
docs/rettigheder.md
Normal file
64
docs/rettigheder.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Rettigheder og rettighedsrevision
|
||||
|
||||
SQLInsight Pro holder øje med, hvem der har **sysadmin** og de øvrige forhøjede serverrettigheder, og hvem der får eller mister dem. Det er typisk det første, en revisor spørger til.
|
||||
|
||||
## Hvad måles
|
||||
|
||||
Hvert 15. minut læser agenten:
|
||||
|
||||
- medlemmer af de faste serverroller `sysadmin`, `securityadmin`, `serveradmin`, `setupadmin`, `processadmin`, `diskadmin`, `dbcreator` og `bulkadmin`;
|
||||
- logins med `CONTROL SERVER`;
|
||||
- for hvert login: type (SQL-login, Windows-login, Windows-gruppe …) og om det er deaktiveret;
|
||||
- for SQL-logins: om de følger Windows' adgangskodepolitik (`CHECK_POLICY`) og udløb (`CHECK_EXPIRATION`), og om `sa` er aktiv;
|
||||
- om SQL Servers **default trace** er slået til.
|
||||
|
||||
Det kræver kun den rettighed, agenten i forvejen har (`VIEW ANY DEFINITION`). Det virker fra SQL Server 2005.
|
||||
|
||||
### Windows-grupper foldes ud
|
||||
|
||||
Har en Windows-gruppe (fx `KUNDE\SQL-Admins`) sysadmin, er det personerne i gruppen, der har den. Agenten spørger derfor Active Directory om gruppens medlemmer, også medlemmer af grupper i gruppen. Svaret gemmes en time (fejl i 10 minutter), og et opslag afbrydes efter 20 sekunder, så en langsom domænecontroller ikke forsinker overvågningen.
|
||||
|
||||
Det kræver, at agenten kører på **Windows i domænet**. Standardkontoen (`NT SERVICE\SQLInsight Agent`) taler med AD som maskinens computerkonto, som normalt må læse gruppemedlemskaber. Kan en gruppe ikke foldes ud, står der **"kunne ikke foldes ud"** og årsagen ved gruppen (fx en lokal gruppe på SQL-serveren, en gruppe i et domæne uden tillid, eller timeout). Gruppens medlemmer tælles så ikke med, og antallet vises som "3+" i oversigten.
|
||||
|
||||
Slå udfoldningen fra med:
|
||||
|
||||
```json
|
||||
"SQLInsight": { "Privileges": { "ExpandWindowsGroups": false } }
|
||||
```
|
||||
|
||||
En agent på Linux folder ikke grupper ud.
|
||||
|
||||
## Ændringer, og hvem der lavede dem
|
||||
|
||||
Hver måling sammenlignes med den forrige. **Den første måling efter installation er udgangspunktet**: eksisterende medlemmer meldes ikke som nye.
|
||||
|
||||
- Direkte medlemmer (logins) sammenlignes på navn og rettighed.
|
||||
- Personer i Windows-grupper sammenlignes kun, når gruppen kunne foldes ud begge gange. En gruppe, AD ikke svarede for, ligner altså ikke, at alle har forladt den.
|
||||
|
||||
**Hvem og hvornår** læses fra default trace (hændelse 108, "Audit Add Login to Server Role"). Det kræver, at agenten har rettigheden `ALTER TRACE`, som er et tilvalg i `db/permissions/grant-least-privilege.sql` (se kommentaren i scriptet). Uden den findes ændringen stadig, men "Ændret af" står som "ukendt" med årsagen. Bemærk:
|
||||
|
||||
- SQL Server skriver default trace i buffere; en ændring lige før en måling får derfor ofte sin forfatter først ved næste måling, og ændringsloggen opdateres så.
|
||||
- Default trace gemmer kun de seneste hændelser (5 filer à 20 MB). På en travl server kan hændelsen være væk, hvis agenten har været stoppet længe.
|
||||
- Ændringer i AD-gruppers medlemmer står ikke i SQL Servers trace. De vises med "Active Directory" som kilde; hvem der ændrede gruppen, skal findes i domænecontrollerens sikkerhedslog.
|
||||
- `CONTROL SERVER` (GRANT) har ikke hændelse 108 og vises uden forfatter.
|
||||
|
||||
## Fund
|
||||
|
||||
| Regel | Alvor | Hvornår |
|
||||
|---|---|---|
|
||||
| SQLI-SEC-001 Ny forhøjet rettighed | **Høj** for sysadmin, securityadmin og CONTROL SERVER; Middel for de øvrige roller | En konto eller person har fået rettigheden. Fundet er åbent i 7 dage og vises øverst på instanssiden og overblikket og sendes som notifikation. |
|
||||
| SQLI-SEC-002 For mange med sysadmin | Middel | Flere personer og konti med sysadmin end grænsen (standard 3). Tjenestekonti (`NT SERVICE\…`, `NT AUTHORITY\…`, `##…##`, konti der ender på `$`) og deaktiverede logins tælles ikke. |
|
||||
|
||||
Grænsen sættes i agentens konfiguration:
|
||||
|
||||
```json
|
||||
"SQLInsight": { "Privileges": { "MaxSysadminAccounts": 3 } }
|
||||
```
|
||||
|
||||
**Navne er følsomme.** Hvem der fik rettigheden, hvilken gruppe og hvem der ændrede den, er følsomme målinger: de vises lokalt, men sendes som `[udeladt]` til SQLInsight Hub og i integrations-API'et (medmindre kunden selv slår det til). Fundets titel og emne indeholder aldrig navne.
|
||||
|
||||
## I webbrugerfladen
|
||||
|
||||
- **Instans → Rettigheder** (`/instanser/{navn}/rettigheder`): personer med sysadmin efter udfoldning, alle forhøjede rettigheder med gruppernes medlemmer, sa, SQL-logins uden adgangskodepolitik, default trace og ændringsloggen for 7, 30, 90 dage eller 1 år.
|
||||
- **Rettigheder** i menuen (`/rettigheder`): alle instanser i én tabel og en samlet ændringslog.
|
||||
- **Revisionsrapport (PDF)**: se [rapporter.md](rapporter.md).
|
||||
|
|
@ -20,6 +20,8 @@ public sealed class AgentOptions
|
|||
|
||||
public RetentionOptions Retention { get; set; } = new();
|
||||
|
||||
public PrivilegeOptions Privileges { get; set; } = new();
|
||||
|
||||
public IList<MonitoredInstance> Instances { get; } = [];
|
||||
}
|
||||
|
||||
|
|
@ -62,3 +64,13 @@ public sealed class MonitoredInstance
|
|||
TenantId = TenantId,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The privilege audit (sysadmin and the other elevated server rights).</summary>
|
||||
public sealed class PrivilegeOptions
|
||||
{
|
||||
/// <summary>More people and accounts with sysadmin than this is a finding (SQLI-SEC-002).</summary>
|
||||
public int MaxSysadminAccounts { get; set; } = 3;
|
||||
|
||||
/// <summary>Unfold Windows groups with server rights to the people in them, by asking Active Directory (Windows only).</summary>
|
||||
public bool ExpandWindowsGroups { get; set; } = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,11 @@ public sealed partial class InstanceMonitor(
|
|||
return await repository.GetSnapshotAsync<Core.Data.InstanceConfigurationSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (collector is Collectors.Instance.PrivilegeCollector)
|
||||
{
|
||||
return await repository.GetSnapshotAsync<Core.Data.PrivilegeSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (collector is not Collectors.Concurrency.DeadlockCollector)
|
||||
{
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ public sealed partial class MonitoringService(
|
|||
NotificationDispatcher notifications,
|
||||
IOptions<NotificationOptions> notificationOptions,
|
||||
Hub.HubOutbox hubOutbox,
|
||||
Core.CustomRules.CustomRuleCatalog customRules) : BackgroundService
|
||||
Core.CustomRules.CustomRuleCatalog customRules,
|
||||
Core.Data.IGroupMemberResolver groupResolver) : BackgroundService
|
||||
{
|
||||
private readonly ILogger _logger = loggerFactory.CreateLogger<MonitoringService>();
|
||||
|
||||
|
|
@ -31,7 +32,7 @@ public sealed partial class MonitoringService(
|
|||
|
||||
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
|
||||
var monitors = settings.Instances
|
||||
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules), intervals, connectionFactory, ruleEngine, repository,
|
||||
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules, groupResolver, settings.Privileges.MaxSysadminAccounts), intervals, connectionFactory, ruleEngine, repository,
|
||||
loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox))
|
||||
.ToList();
|
||||
if (monitors.Count == 0)
|
||||
|
|
|
|||
|
|
@ -96,6 +96,18 @@ builder.Services.AddHttpClient("updates", c => c.Timeout = TimeSpan.FromMinutes(
|
|||
builder.Services.AddHostedService<UpdateService>();
|
||||
builder.Services.Configure<SQLInsight.Agent.Reports.ReportOptions>(builder.Configuration.GetSection(SQLInsight.Agent.Reports.ReportOptions.SectionName));
|
||||
builder.Services.AddHostedService<SQLInsight.Agent.Reports.MonthlyReportService>();
|
||||
builder.Services.AddSingleton<SQLInsight.Core.Data.IGroupMemberResolver>(sp =>
|
||||
{
|
||||
if (!sp.GetRequiredService<IOptions<AgentOptions>>().Value.Privileges.ExpandWindowsGroups)
|
||||
{
|
||||
return new SQLInsight.Core.Data.NoGroupResolver("Udfoldning af Windows-grupper er slået fra (SQLInsight:Privileges:ExpandWindowsGroups).");
|
||||
}
|
||||
|
||||
return OperatingSystem.IsWindows()
|
||||
? new SQLInsight.Agent.Security.ActiveDirectoryGroupResolver(
|
||||
sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<ILogger<SQLInsight.Agent.Security.ActiveDirectoryGroupResolver>>())
|
||||
: new SQLInsight.Core.Data.NoGroupResolver("Agenten kører ikke på Windows og kan derfor ikke spørge Active Directory.");
|
||||
});
|
||||
builder.Services.AddHostedService<MonitoringService>();
|
||||
|
||||
var host = builder.Build();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Ini" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
|
||||
<PackageReference Include="System.DirectoryServices.AccountManagement" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- setup-repository gives the agent these rights on the monitored instances (Setup/RepositorySetup.cs). -->
|
||||
|
|
|
|||
114
src/SQLInsight.Agent/Security/ActiveDirectoryGroupResolver.cs
Normal file
114
src/SQLInsight.Agent/Security/ActiveDirectoryGroupResolver.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -8,12 +8,15 @@ namespace SQLInsight.Collectors;
|
|||
public static class BuiltInCollectors
|
||||
{
|
||||
/// <param name="custom">Community and local rules whose queries are run by the custom-rules collector.</param>
|
||||
public static IReadOnlyList<ICollector> Create(Core.CustomRules.CustomRuleCatalog? custom = null) =>
|
||||
/// <param name="groups">Unfolds Windows groups with server rights to the people in them (Active Directory).</param>
|
||||
/// <param name="maxSysadminAccounts">More people and accounts with sysadmin than this is a finding.</param>
|
||||
public static IReadOnlyList<ICollector> Create(Core.CustomRules.CustomRuleCatalog? custom = null, Core.Data.IGroupMemberResolver? groups = null, int maxSysadminAccounts = 3) =>
|
||||
[
|
||||
new ServerCollector(),
|
||||
new ConfigurationCollector(),
|
||||
new TraceFlagCollector(),
|
||||
new InstanceConfigurationCollector(),
|
||||
new PrivilegeCollector(groups, maxSysadminAccounts),
|
||||
new DatabaseCollector(),
|
||||
new SuspectPageCollector(),
|
||||
new PlanCacheCollector(),
|
||||
|
|
|
|||
154
src/SQLInsight.Collectors/Instance/PrivilegeCollector.cs
Normal file
154
src/SQLInsight.Collectors/Instance/PrivilegeCollector.cs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using SQLInsight.Core.Data;
|
||||
using SQLInsight.Core.Instances;
|
||||
|
||||
namespace SQLInsight.Collectors.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Members of the fixed server roles and holders of CONTROL SERVER, with Windows groups unfolded through
|
||||
/// <see cref="IGroupMemberResolver"/>, SQL logins' password policy, and the changes since the previous collection.
|
||||
/// Needs only VIEW ANY DEFINITION. Who made a change and when comes from the default trace, which needs ALTER TRACE;
|
||||
/// without it the changes are still found, just without that.
|
||||
/// </summary>
|
||||
public sealed class PrivilegeCollector(IGroupMemberResolver? groups = null, int maxSysadminAccounts = 3) : Collector<PrivilegeSnapshot>
|
||||
{
|
||||
internal const string MembersSql = """
|
||||
SELECT p.name, p.type, r.name, p.is_disabled
|
||||
FROM sys.server_role_members m
|
||||
JOIN sys.server_principals r ON r.principal_id = m.role_principal_id
|
||||
JOIN sys.server_principals p ON p.principal_id = m.member_principal_id
|
||||
WHERE r.name IN (N'sysadmin', N'securityadmin', N'serveradmin', N'setupadmin', N'processadmin', N'diskadmin', N'dbcreator', N'bulkadmin')
|
||||
UNION ALL
|
||||
SELECT p.name, p.type, N'CONTROL SERVER', p.is_disabled
|
||||
FROM sys.server_permissions sp
|
||||
JOIN sys.server_principals p ON p.principal_id = sp.grantee_principal_id
|
||||
WHERE sp.permission_name = N'CONTROL SERVER' AND sp.state IN ('G', 'W')
|
||||
""";
|
||||
|
||||
internal const string SqlLoginsSql = """
|
||||
SELECT name, is_disabled, is_policy_checked, is_expiration_checked, CASE WHEN principal_id = 1 THEN 1 ELSE 0 END
|
||||
FROM sys.sql_logins WHERE name NOT LIKE N'##%'
|
||||
""";
|
||||
|
||||
internal const string DefaultTraceEnabledSql = """
|
||||
SELECT CAST(value_in_use AS int) FROM sys.configurations WHERE name = N'default trace enabled'
|
||||
""";
|
||||
|
||||
// sys.traces and fn_trace_gettable need ALTER TRACE.
|
||||
internal const string DefaultTracePathSql = """
|
||||
SELECT path, DATEDIFF(minute, GETUTCDATE(), GETDATE()) FROM sys.traces WHERE is_default = 1
|
||||
""";
|
||||
|
||||
private readonly IGroupMemberResolver _groups = groups ?? new NoGroupResolver();
|
||||
|
||||
public override string Name => "privileges";
|
||||
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
|
||||
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||
|
||||
protected override async Task<PrivilegeSnapshot> CollectAsync(CollectorContext context, PrivilegeSnapshot? previous)
|
||||
{
|
||||
var ct = context.CancellationToken;
|
||||
var connection = context.Connection;
|
||||
var rows = await Db.QueryAsync(connection, MembersSql, r => (Name: r.String(0), Type: r.String(1), Privilege: r.String(2), Disabled: r.Bool(3)), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var principals = new List<PrivilegedPrincipal>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var type = TypeName(row.Type);
|
||||
var principal = new PrivilegedPrincipal(row.Name, type, row.Privilege, row.Disabled);
|
||||
if (type == "Windows-gruppe")
|
||||
{
|
||||
var expansion = await _groups.ExpandAsync(row.Name, ct).ConfigureAwait(false);
|
||||
principal = expansion.Error is null
|
||||
? principal with { Members = expansion.Members }
|
||||
: principal with { ExpansionError = expansion.Error };
|
||||
}
|
||||
|
||||
principals.Add(principal);
|
||||
}
|
||||
|
||||
var logins = await Db.QueryAsync(connection, SqlLoginsSql, r => new SqlLoginInfo(r.String(0), r.Bool(1), r.Bool(2), r.Bool(3), r.Bool(4)), ct)
|
||||
.ConfigureAwait(false);
|
||||
var traceEnabled = (await Db.QueryAsync(connection, DefaultTraceEnabledSql, r => r.Int(0), ct).ConfigureAwait(false)).FirstOrDefault() == 1;
|
||||
|
||||
var snapshot = new PrivilegeSnapshot
|
||||
{
|
||||
CapturedAt = context.Now,
|
||||
Principals = principals,
|
||||
SqlLogins = logins,
|
||||
DefaultTraceEnabled = traceEnabled,
|
||||
MaxSysadminAccounts = maxSysadminAccounts,
|
||||
};
|
||||
|
||||
if (previous is null)
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
var changes = PrivilegeAnalysis.Diff(previous, snapshot, context.Now);
|
||||
var recent = previous.RecentChanges.Where(c => context.Now - c.DetectedAt < PrivilegeAnalysis.RecentWindow).ToList();
|
||||
|
||||
// The default trace is written in buffers: a change made just before the previous collection may only be in it now.
|
||||
var unknown = recent.Where(PrivilegeAnalysis.CanFindWho).ToList();
|
||||
var traceStatus = previous.TraceStatus;
|
||||
if (changes.Count > 0 || unknown.Count > 0)
|
||||
{
|
||||
var since = unknown.Select(c => c.DetectedAt).Append(previous.CapturedAt).Min().AddHours(-1);
|
||||
(var events, traceStatus) = await ReadTraceAsync(connection, traceEnabled, since, ct).ConfigureAwait(false);
|
||||
changes = PrivilegeAnalysis.Enrich(changes, events);
|
||||
recent = [.. PrivilegeAnalysis.Enrich(recent, events)];
|
||||
}
|
||||
|
||||
return snapshot with { Changes = changes, RecentChanges = [.. recent, .. changes], TraceStatus = traceStatus };
|
||||
}
|
||||
|
||||
/// <summary>Role membership events from the default trace since <paramref name="since"/>, or why they cannot be read.</summary>
|
||||
private static async Task<(IReadOnlyList<TraceSecurityEvent> Events, string? Status)> ReadTraceAsync(
|
||||
DbConnection connection, bool enabled, DateTimeOffset since, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!enabled)
|
||||
{
|
||||
return ([], "Default trace er slået fra, så det kan ikke ses, hvem der ændrede rettighederne.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var trace = (await Db.QueryAsync(connection, DefaultTracePathSql, r => (Path: r.NullableString(0), Offset: r.Int(1)), cancellationToken)
|
||||
.ConfigureAwait(false)).FirstOrDefault();
|
||||
if (trace.Path is null)
|
||||
{
|
||||
return ([], "Default trace kører ikke.");
|
||||
}
|
||||
|
||||
var offset = TimeSpan.FromMinutes(trace.Offset);
|
||||
var sinceLocal = (since.UtcDateTime + offset).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
|
||||
var sql = $"""
|
||||
SELECT StartTime, EventClass, EventSubClass, LoginName, TargetLoginName, RoleName
|
||||
FROM sys.fn_trace_gettable({Db.Literal(PrivilegeAnalysis.TraceFileBase(trace.Path))}, DEFAULT)
|
||||
WHERE EventClass IN (104, 105, 108) AND StartTime >= '{sinceLocal}'
|
||||
""";
|
||||
var events = await Db.QueryAsync(connection, sql, r => new TraceSecurityEvent(
|
||||
new DateTimeOffset(DateTime.SpecifyKind(r.DateTime(0) - offset, DateTimeKind.Utc)),
|
||||
r.Int(1), r.NullableInt(2) ?? 0, r.NullableString(3), r.NullableString(4), r.NullableString(5)), cancellationToken).ConfigureAwait(false);
|
||||
return (events, null);
|
||||
}
|
||||
catch (DbException ex)
|
||||
{
|
||||
// Typically: no ALTER TRACE (grant-least-privilege.sql leaves it out unless chosen).
|
||||
return ([], $"Default trace kunne ikke læses ({ex.Message.Split('\n')[0].Trim()}). Giv agenten ALTER TRACE for at se, hvem der ændrede rettighederne (se grant-least-privilege.sql).");
|
||||
}
|
||||
}
|
||||
|
||||
private static string TypeName(string type) => type.Trim() switch
|
||||
{
|
||||
"S" => "SQL-login",
|
||||
"U" => "Windows-login",
|
||||
"G" => "Windows-gruppe",
|
||||
"R" => "Serverrolle",
|
||||
"C" => "Certifikat",
|
||||
"K" => "Nøgle",
|
||||
_ => "Andet",
|
||||
};
|
||||
}
|
||||
234
src/SQLInsight.Core/Data/Privileges.cs
Normal file
234
src/SQLInsight.Core/Data/Privileges.cs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SQLInsight.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Who has elevated rights on the instance: members of the fixed server roles and holders of CONTROL SERVER, with
|
||||
/// Windows groups unfolded to the people in them (from Active Directory, by the agent), SQL logins' password policy,
|
||||
/// and what changed since the previous collection. Names are customer data: they stay in the customer's repository and
|
||||
/// are sensitive evidence in findings.
|
||||
/// </summary>
|
||||
public sealed record PrivilegeSnapshot
|
||||
{
|
||||
public required DateTimeOffset CapturedAt { get; init; }
|
||||
public required IReadOnlyList<PrivilegedPrincipal> Principals { get; init; }
|
||||
|
||||
/// <summary>All SQL logins (not only the privileged ones), for password policy and the sa account.</summary>
|
||||
public IReadOnlyList<SqlLoginInfo> SqlLogins { get; init; } = [];
|
||||
|
||||
/// <summary>Whether the default trace is on (sp_configure 'default trace enabled'); it records who changed roles.</summary>
|
||||
public bool? DefaultTraceEnabled { get; init; }
|
||||
|
||||
/// <summary>Why "who and when" could not be read from the default trace; null when it could (or was not needed).</summary>
|
||||
public string? TraceStatus { get; init; }
|
||||
|
||||
/// <summary>More people and accounts with sysadmin than this is a finding (SQLInsight:Privileges:MaxSysadminAccounts).</summary>
|
||||
public int MaxSysadminAccounts { get; init; } = 3;
|
||||
|
||||
/// <summary>Changes since the previous collection (empty on the first: that one is the starting point).</summary>
|
||||
public IReadOnlyList<PrivilegeChange> Changes { get; init; } = [];
|
||||
|
||||
/// <summary>Changes detected within <see cref="PrivilegeAnalysis.RecentWindow"/>: they keep their findings open.</summary>
|
||||
public IReadOnlyList<PrivilegeChange> RecentChanges { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <param name="Type">SQL-login, Windows-login, Windows-gruppe, Serverrolle, Certifikat or Nøgle.</param>
|
||||
/// <param name="Privilege">The fixed server role (sysadmin …) or CONTROL SERVER.</param>
|
||||
/// <param name="Members">The people in a Windows group (recursively); null when not a group or not unfolded.</param>
|
||||
/// <param name="ExpansionError">Why a Windows group could not be unfolded.</param>
|
||||
public sealed record PrivilegedPrincipal(string Name, string Type, string Privilege, bool IsDisabled,
|
||||
IReadOnlyList<GroupMember>? Members = null, string? ExpansionError = null);
|
||||
|
||||
/// <param name="Account">DOMAIN\user.</param>
|
||||
public sealed record GroupMember(string Account, string? DisplayName, bool Enabled);
|
||||
|
||||
public sealed record SqlLoginInfo(string Name, bool IsDisabled, bool PolicyChecked, bool ExpirationChecked, bool IsSa);
|
||||
|
||||
/// <param name="Kind">Tilføjet or Fjernet.</param>
|
||||
/// <param name="ViaGroup">The Windows group a person got (or lost) the right through; null for a direct member.</param>
|
||||
/// <param name="ChangedBy">Who made the change, from the default trace; null when unknown.</param>
|
||||
/// <param name="ChangedAt">When the change was made, from the default trace (UTC); null when unknown.</param>
|
||||
public sealed record PrivilegeChange(DateTimeOffset DetectedAt, string Kind, string Privilege, string Principal, string PrincipalType,
|
||||
string? ViaGroup = null, string? ChangedBy = null, DateTimeOffset? ChangedAt = null);
|
||||
|
||||
/// <summary>An account that effectively holds a privilege, directly or through a Windows group.</summary>
|
||||
/// <param name="Source">"direkte" or the Windows group's name.</param>
|
||||
public sealed record PrivilegedAccount(string Account, string? DisplayName, string Type, string Source);
|
||||
|
||||
/// <summary>An audit event from the default trace (role membership, logins, grants).</summary>
|
||||
/// <param name="StartTime">UTC.</param>
|
||||
public sealed record TraceSecurityEvent(DateTimeOffset StartTime, int EventClass, int EventSubClass, string? LoginName, string? TargetLoginName, string? RoleName);
|
||||
|
||||
/// <summary>Unfolds a Windows group to the people in it. The agent's implementation asks Active Directory.</summary>
|
||||
public interface IGroupMemberResolver
|
||||
{
|
||||
Task<GroupExpansion> ExpandAsync(string group, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <param name="Error">Why the group could not be unfolded; then <paramref name="Members"/> is empty.</param>
|
||||
public sealed record GroupExpansion(IReadOnlyList<GroupMember> Members, string? Error = null)
|
||||
{
|
||||
public static GroupExpansion Failed(string error) => new([], error);
|
||||
}
|
||||
|
||||
/// <summary>For agents that cannot ask Active Directory (Linux, or turned off).</summary>
|
||||
public sealed class NoGroupResolver(string reason = "Windows-grupper foldes kun ud af en Windows-agent i domænet.") : IGroupMemberResolver
|
||||
{
|
||||
public Task<GroupExpansion> ExpandAsync(string group, CancellationToken cancellationToken) => Task.FromResult(GroupExpansion.Failed(reason));
|
||||
}
|
||||
|
||||
public static class PrivilegeAnalysis
|
||||
{
|
||||
public const string Sysadmin = "sysadmin";
|
||||
public const string ControlServer = "CONTROL SERVER";
|
||||
public const string Added = "Tilføjet";
|
||||
public const string Removed = "Fjernet";
|
||||
public const string Direct = "direkte";
|
||||
|
||||
/// <summary>The fixed server roles watched, and CONTROL SERVER.</summary>
|
||||
public static readonly IReadOnlyList<string> Privileges =
|
||||
[Sysadmin, "securityadmin", "serveradmin", "setupadmin", "processadmin", "diskadmin", "dbcreator", "bulkadmin", ControlServer];
|
||||
|
||||
/// <summary>How long a new privilege keeps its finding open.</summary>
|
||||
public static readonly TimeSpan RecentWindow = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>Rights that amount to (or can grant) full control of the instance.</summary>
|
||||
public static bool IsHighRisk(string privilege) =>
|
||||
privilege is Sysadmin or "securityadmin" or ControlServer;
|
||||
|
||||
/// <summary>
|
||||
/// Service and machine identities, internal certificate logins: they hold rights but are not people
|
||||
/// (NT SERVICE\…, NT AUTHORITY\…, ##MS_…##, gMSA and computer accounts ending in $).
|
||||
/// </summary>
|
||||
public static bool IsServiceIdentity(string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
return name.StartsWith(@"NT SERVICE\", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.StartsWith(@"NT AUTHORITY\", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.StartsWith("##", StringComparison.Ordinal)
|
||||
|| name.EndsWith('$');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The enabled accounts that hold a privilege: SQL and Windows logins directly, and the enabled people in Windows
|
||||
/// groups that could be unfolded. Service identities are left out: they are not people.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PrivilegedAccount> Accounts(PrivilegeSnapshot snapshot, string privilege)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
var accounts = new List<PrivilegedAccount>();
|
||||
foreach (var p in snapshot.Principals.Where(p => p.Privilege == privilege && !p.IsDisabled))
|
||||
{
|
||||
if (p.Type == "Windows-gruppe")
|
||||
{
|
||||
accounts.AddRange((p.Members ?? []).Where(m => m.Enabled && !IsServiceIdentity(m.Account))
|
||||
.Select(m => new PrivilegedAccount(m.Account, m.DisplayName, "Windows-bruger", p.Name)));
|
||||
}
|
||||
else if (p.Type is "SQL-login" or "Windows-login" && !IsServiceIdentity(p.Name))
|
||||
{
|
||||
accounts.Add(new PrivilegedAccount(p.Name, null, p.Type, Direct));
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
.GroupBy(a => a.Account, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.OrderBy(a => a.Source == Direct ? 0 : 1).First())
|
||||
.OrderBy(a => a.Account, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Enabled Windows groups with the privilege that could not be unfolded: their members are unknown.</summary>
|
||||
public static IReadOnlyList<PrivilegedPrincipal> UnfoldedGroupsMissing(PrivilegeSnapshot snapshot, string privilege)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
return snapshot.Principals.Where(p => p.Privilege == privilege && !p.IsDisabled && p.Type == "Windows-gruppe" && p.Members is null).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Who got or lost a privilege: direct members by principal, and people in Windows groups by account (only for groups
|
||||
/// unfolded both times, so a group Active Directory could not answer for does not look like everyone left).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PrivilegeChange> Diff(PrivilegeSnapshot previous, PrivilegeSnapshot current, DateTimeOffset at)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(previous);
|
||||
ArgumentNullException.ThrowIfNull(current);
|
||||
var changes = new List<PrivilegeChange>();
|
||||
var before = previous.Principals.GroupBy(Key, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
var after = current.Principals.GroupBy(Key, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
changes.AddRange(after.Where(a => !before.ContainsKey(a.Key)).Select(a => new PrivilegeChange(at, Added, a.Value.Privilege, a.Value.Name, a.Value.Type)));
|
||||
changes.AddRange(before.Where(b => !after.ContainsKey(b.Key)).Select(b => new PrivilegeChange(at, Removed, b.Value.Privilege, b.Value.Name, b.Value.Type)));
|
||||
|
||||
foreach (var (key, group) in after.Where(a => a.Value.Members is not null))
|
||||
{
|
||||
if (!before.TryGetValue(key, out var was) || was.Members is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var old = was.Members.Where(m => m.Enabled).Select(m => m.Account).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var now = group.Members!.Where(m => m.Enabled).Select(m => m.Account).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
changes.AddRange(now.Except(old, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(m => new PrivilegeChange(at, Added, group.Privilege, m, "Windows-bruger", group.Name)));
|
||||
changes.AddRange(old.Except(now, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(m => new PrivilegeChange(at, Removed, group.Privilege, m, "Windows-bruger", group.Name)));
|
||||
}
|
||||
|
||||
return changes;
|
||||
|
||||
static string Key(PrivilegedPrincipal p) => p.Privilege + "\u001f" + p.Name;
|
||||
}
|
||||
|
||||
/// <summary>A direct server role change whose author is not known yet: the default trace may tell.</summary>
|
||||
public static bool CanFindWho(PrivilegeChange change)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
return change.ChangedBy is null && change.ViaGroup is null && change.Privilege != ControlServer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Who changed a server role membership and when, from the default trace: the latest matching
|
||||
/// "Audit Add Login to Server Role" event (108; subclass 1 add, 2 drop) before the change was detected.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PrivilegeChange> Enrich(IReadOnlyList<PrivilegeChange> changes, IReadOnlyList<TraceSecurityEvent> events)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(changes);
|
||||
ArgumentNullException.ThrowIfNull(events);
|
||||
return changes.Select(c =>
|
||||
{
|
||||
if (!CanFindWho(c))
|
||||
{
|
||||
return c;
|
||||
}
|
||||
|
||||
var subclass = c.Kind == Added ? 1 : 2;
|
||||
var match = events
|
||||
.Where(e => e.EventClass == AddLoginToServerRole && e.EventSubClass == subclass
|
||||
&& string.Equals(e.RoleName, c.Privilege, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(e.TargetLoginName, c.Principal, StringComparison.OrdinalIgnoreCase)
|
||||
&& e.StartTime <= c.DetectedAt.AddMinutes(1))
|
||||
.MaxBy(e => e.StartTime);
|
||||
return match is null ? c : c with { ChangedBy = match.LoginName, ChangedAt = match.StartTime };
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Default trace event "Audit Add Login to Server Role Event".</summary>
|
||||
public const int AddLoginToServerRole = 108;
|
||||
|
||||
/// <summary>A stable, name-free key for a finding's subject (names must not reach the hub).</summary>
|
||||
public static string Fingerprint(string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToUpperInvariant()));
|
||||
return Convert.ToHexString(hash, 0, 6).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Default trace files: the folder of the current file plus log.trc, so its rollover files are read too.</summary>
|
||||
public static string TraceFileBase(string currentPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(currentPath);
|
||||
var separator = currentPath.LastIndexOfAny(['\\', '/']);
|
||||
return separator < 0 ? "log.trc" : currentPath[..(separator + 1)] + "log.trc";
|
||||
}
|
||||
}
|
||||
274
src/SQLInsight.Reports/PrivilegeAudit.cs
Normal file
274
src/SQLInsight.Reports/PrivilegeAudit.cs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
using System.Globalization;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using MigraDoc.DocumentObjectModel.Tables;
|
||||
using MigraDoc.Rendering;
|
||||
using SQLInsight.Core.Data;
|
||||
using SQLInsight.Repository;
|
||||
|
||||
namespace SQLInsight.Reports;
|
||||
|
||||
/// <summary>One instance in the privilege audit: the latest collection and the changes in the period.</summary>
|
||||
public sealed record PrivilegeAuditInstance(InstanceOverview Instance, PrivilegeSnapshot? Snapshot, IReadOnlyList<StoredPrivilegeChange> Changes)
|
||||
{
|
||||
public IReadOnlyList<PrivilegedAccount> Sysadmins => Snapshot is null ? [] : PrivilegeAnalysis.Accounts(Snapshot, PrivilegeAnalysis.Sysadmin);
|
||||
|
||||
public SqlLoginInfo? Sa => Snapshot?.SqlLogins.FirstOrDefault(l => l.IsSa);
|
||||
|
||||
/// <summary>Enabled SQL logins that do not follow Windows' password policy (sa included).</summary>
|
||||
public IReadOnlyList<SqlLoginInfo> LoginsWithoutPolicy =>
|
||||
Snapshot?.SqlLogins.Where(l => !l.IsDisabled && !l.PolicyChecked && !PrivilegeAnalysis.IsServiceIdentity(l.Name)).ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The privilege audit for the auditors: who has elevated server rights now (Windows groups unfolded), what changed in
|
||||
/// the period and by whom, sa, SQL logins without password policy and the default trace. Contains names (customer
|
||||
/// data): produced locally in the web UI and never sent anywhere.
|
||||
/// </summary>
|
||||
public sealed record PrivilegeAudit(DateTimeOffset From, DateTimeOffset To, IReadOnlyList<PrivilegeAuditInstance> Instances)
|
||||
{
|
||||
public static async Task<PrivilegeAudit> LoadAsync(InsightRepository repository, InstanceOverview? instance, DateTimeOffset from, DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(repository);
|
||||
var instances = instance is null ? await repository.GetInstancesAsync(cancellationToken).ConfigureAwait(false) : [instance];
|
||||
var changes = await repository.GetPrivilegeChangesAsync(instance?.InstanceId, from, to, 5000, cancellationToken).ConfigureAwait(false);
|
||||
var result = new List<PrivilegeAuditInstance>();
|
||||
foreach (var i in instances.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var snapshot = await repository.GetSnapshotAsync<PrivilegeSnapshot>(i.InstanceId, "privileges", cancellationToken).ConfigureAwait(false);
|
||||
result.Add(new PrivilegeAuditInstance(i, snapshot, changes.Where(c => c.InstanceId == i.InstanceId).ToList()));
|
||||
}
|
||||
|
||||
return new PrivilegeAudit(from, to, result);
|
||||
}
|
||||
|
||||
/// <summary>The audit's statement of where the data comes from; shared with the web page.</summary>
|
||||
public const string MethodText =
|
||||
"Rettighederne læses af SQLInsight Agent hvert 15. minut fra SQL Servers egne kataloger: medlemmer af de faste serverroller "
|
||||
+ "(sys.server_role_members) og CONTROL SERVER (sys.server_permissions), loginenes type og status (sys.server_principals) og "
|
||||
+ "SQL-loginenes adgangskodepolitik (sys.sql_logins). Windows-grupper foldes rekursivt ud til personerne i dem ved at spørge Active "
|
||||
+ "Directory fra agentens maskine; kan en gruppe ikke foldes ud, står årsagen ved gruppen, og dens medlemmer tælles ikke. "
|
||||
+ "Ændringer findes ved at sammenligne hver måling med den forrige; den første måling efter installation er udgangspunktet. "
|
||||
+ "Hvem der foretog en ændring af en serverrolle, og hvornår, læses fra SQL Servers default trace (hændelse 108), når agenten har "
|
||||
+ "rettigheden ALTER TRACE; default trace gemmer kun de seneste hændelser (typisk dage), så et hul i agentens drift kan betyde, at "
|
||||
+ "hvem/hvornår er ukendt. Ændringer i Windows-gruppers medlemmer registreres, når de opdages, uden hvem i Active Directory der foretog dem. "
|
||||
+ "Alle navne bliver i kundens egen SQLInsight-database og sendes ikke til SQLInsight Hub.";
|
||||
|
||||
private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK");
|
||||
private static readonly Color Text = new(0x10, 0x18, 0x28);
|
||||
private static readonly Color Muted = new(0x66, 0x70, 0x85);
|
||||
private static readonly Color Border = new(0xE4, 0xE7, 0xEC);
|
||||
private static readonly Color Surface = new(0xF5, 0xF7, 0xFA);
|
||||
private static readonly Color Brand = new(0x0E, 0x74, 0x90);
|
||||
private static readonly Color Bad = new(0xD9, 0x2D, 0x20);
|
||||
|
||||
public byte[] Render(string organization, TimeZoneInfo zone)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
InterFontResolver.Install();
|
||||
|
||||
var document = new Document();
|
||||
document.Info.Title = $"Rettighedsrevision {Time(From, zone)} – {Time(To, zone)}";
|
||||
document.Info.Author = "SQLInsight Pro";
|
||||
Styles(document);
|
||||
|
||||
var section = document.AddSection();
|
||||
section.PageSetup = document.DefaultPageSetup.Clone();
|
||||
section.PageSetup.PageFormat = PageFormat.A4;
|
||||
section.PageSetup.TopMargin = Unit.FromCentimeter(2);
|
||||
section.PageSetup.BottomMargin = Unit.FromCentimeter(2);
|
||||
section.PageSetup.LeftMargin = Unit.FromCentimeter(1.8);
|
||||
section.PageSetup.RightMargin = Unit.FromCentimeter(1.8);
|
||||
|
||||
section.Headers.Primary.AddParagraph($"SQLInsight Pro · {organization} · Fortroligt: indeholder navne").Style = "Muted";
|
||||
var footer = section.Footers.Primary.AddParagraph("Rettighedsrevision Side ");
|
||||
footer.Style = "Muted";
|
||||
footer.AddPageField();
|
||||
footer.AddText(" af ");
|
||||
footer.AddNumPagesField();
|
||||
|
||||
var title = section.AddParagraph("Rettighedsrevision");
|
||||
title.Format.Font.Size = 20;
|
||||
title.Format.Font.Bold = true;
|
||||
section.AddParagraph($"{organization} · {Time(From, zone)} – {Time(To, zone)} · {Count(Instances.Count, "instans", "instanser")}").Format.Font.Color = Brand;
|
||||
|
||||
if (Instances.Count == 0)
|
||||
{
|
||||
section.AddParagraph("Ingen instanser overvåges.");
|
||||
}
|
||||
|
||||
foreach (var entry in Instances)
|
||||
{
|
||||
Instance(section, entry, zone);
|
||||
}
|
||||
|
||||
section.AddParagraph("Datakilde og metode", StyleNames.Heading1);
|
||||
section.AddParagraph(MethodText);
|
||||
section.AddParagraph($"Udskrevet {Time(DateTimeOffset.UtcNow, zone)} ({zone.DisplayName}).", "Muted");
|
||||
|
||||
var renderer = new PdfDocumentRenderer { Document = document };
|
||||
renderer.RenderDocument();
|
||||
using var output = new MemoryStream();
|
||||
renderer.PdfDocument.Save(output, false);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static void Instance(Section section, PrivilegeAuditInstance entry, TimeZoneInfo zone)
|
||||
{
|
||||
section.AddParagraph(entry.Instance.Name, StyleNames.Heading1);
|
||||
var snapshot = entry.Snapshot;
|
||||
if (snapshot is null)
|
||||
{
|
||||
section.AddParagraph("Rettighederne er endnu ikke målt på denne instans (måles hvert 15. minut fra version 1.0.5).", "Muted");
|
||||
return;
|
||||
}
|
||||
|
||||
section.AddParagraph($"Målt {Time(snapshot.CapturedAt, zone)}.", "Muted");
|
||||
|
||||
var sysadmins = entry.Sysadmins;
|
||||
var summary = section.AddParagraph($"Personer og konti med sysadmin: {sysadmins.Count} (grænse {snapshot.MaxSysadminAccounts}).");
|
||||
if (sysadmins.Count > snapshot.MaxSysadminAccounts)
|
||||
{
|
||||
summary.Format.Font.Color = Bad;
|
||||
}
|
||||
|
||||
section.AddParagraph("Nuværende rettigheder", "Heading2");
|
||||
var table = Table(section, [3.2, 5.6, 3, 5.6]);
|
||||
Header(table, "Rettighed", "Login eller gruppe", "Type", "Personer (efter udfoldning)");
|
||||
foreach (var p in snapshot.Principals.OrderBy(p => PrivilegeAnalysis.Privileges.ToList().IndexOf(p.Privilege)).ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Row(table, PrivilegeAnalysis.IsHighRisk(p.Privilege) && !p.IsDisabled, p.Privilege, p.Name + (p.IsDisabled ? " (deaktiveret)" : ""), p.Type, Members(p));
|
||||
}
|
||||
|
||||
section.AddParagraph("Ændringer i perioden", "Heading2");
|
||||
if (entry.Changes.Count == 0)
|
||||
{
|
||||
section.AddParagraph("Ingen ændringer registreret i perioden.", "Muted");
|
||||
}
|
||||
else
|
||||
{
|
||||
var changes = Table(section, [2.8, 1.7, 2.6, 4.4, 3.2, 2.7]);
|
||||
Header(changes, "Opdaget", "Ændring", "Rettighed", "Hvem", "Ændret af", "Ændret");
|
||||
foreach (var c in entry.Changes.OrderBy(c => c.DetectedAt))
|
||||
{
|
||||
Row(changes, c.Kind == PrivilegeAnalysis.Added && PrivilegeAnalysis.IsHighRisk(c.Privilege),
|
||||
Time(new DateTimeOffset(c.DetectedAt, TimeSpan.Zero), zone), c.Kind, c.Privilege,
|
||||
c.Principal + (c.ViaGroup is null ? "" : $" (via {c.ViaGroup})"),
|
||||
c.ChangedBy ?? (c.ViaGroup is null ? "ukendt" : "Active Directory"),
|
||||
c.ChangedAt is { } at ? Time(new DateTimeOffset(at, TimeSpan.Zero), zone) : "–");
|
||||
}
|
||||
}
|
||||
|
||||
section.AddParagraph("Kontroller", "Heading2");
|
||||
var controls = Table(section, [5.5, 11.9]);
|
||||
Row(controls, entry.Sa is { IsDisabled: false }, "sa-kontoen", SaText(entry.Sa));
|
||||
var withoutPolicy = entry.LoginsWithoutPolicy;
|
||||
Row(controls, withoutPolicy.Count > 0, "SQL-logins uden adgangskodepolitik",
|
||||
withoutPolicy.Count == 0 ? "Ingen" : string.Join(", ", withoutPolicy.Select(l => l.Name)));
|
||||
var withoutExpiration = snapshot.SqlLogins.Count(l => !l.IsDisabled && l.PolicyChecked && !l.ExpirationChecked);
|
||||
Row(controls, false, "SQL-logins med politik men uden udløb", withoutExpiration.ToString(Danish));
|
||||
Row(controls, snapshot.DefaultTraceEnabled == false, "Default trace", TraceText(snapshot));
|
||||
var unknown = snapshot.Principals.Where(p => p.Type == "Windows-gruppe" && p.Members is null && !p.IsDisabled).ToList();
|
||||
if (unknown.Count > 0)
|
||||
{
|
||||
Row(controls, true, "Grupper der ikke kunne foldes ud", string.Join("; ", unknown.Select(g => $"{g.Name}: {g.ExpansionError}")));
|
||||
}
|
||||
}
|
||||
|
||||
public static string SaText(SqlLoginInfo? sa) => sa switch
|
||||
{
|
||||
null => "Findes ikke som SQL-login (kun Windows-godkendelse, eller omdøbt og ikke genkendt)",
|
||||
{ IsDisabled: true } => "Deaktiveret",
|
||||
{ PolicyChecked: false } => "Aktiv, uden adgangskodepolitik",
|
||||
_ => "Aktiv, med adgangskodepolitik",
|
||||
};
|
||||
|
||||
public static string TraceText(PrivilegeSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
var state = snapshot.DefaultTraceEnabled switch
|
||||
{
|
||||
true => "Slået til",
|
||||
false => "Slået fra: hvem der ændrer serverroller, kan ikke ses",
|
||||
null => "Ukendt",
|
||||
};
|
||||
return snapshot.TraceStatus is { } why ? $"{state}. {why}" : state;
|
||||
}
|
||||
|
||||
private static string Members(PrivilegedPrincipal p) => p switch
|
||||
{
|
||||
{ Type: not "Windows-gruppe" } => "–",
|
||||
{ Members: null } => $"kunne ikke foldes ud: {p.ExpansionError}",
|
||||
{ Members.Count: 0 } => "ingen medlemmer",
|
||||
_ => string.Join(", ", p.Members.Select(m => m.Account + (m.Enabled ? "" : " (deaktiveret)"))),
|
||||
};
|
||||
|
||||
private static string Count(int n, string one, string many) => $"{n.ToString(Danish)} {(n == 1 ? one : many)}";
|
||||
|
||||
private static string Time(DateTimeOffset utc, TimeZoneInfo zone) => TimeZoneInfo.ConvertTime(utc, zone).ToString("dd-MM-yyyy HH:mm", Danish);
|
||||
|
||||
private static void Styles(Document document)
|
||||
{
|
||||
var normal = document.Styles[StyleNames.Normal]!;
|
||||
normal.Font.Name = InterFontResolver.Family;
|
||||
normal.Font.Size = 9;
|
||||
normal.Font.Color = Text;
|
||||
normal.ParagraphFormat.SpaceAfter = Unit.FromPoint(3);
|
||||
|
||||
var heading = document.Styles[StyleNames.Heading1]!;
|
||||
heading.Font.Size = 13;
|
||||
heading.Font.Bold = true;
|
||||
heading.ParagraphFormat.SpaceBefore = Unit.FromPoint(14);
|
||||
heading.ParagraphFormat.SpaceAfter = Unit.FromPoint(5);
|
||||
heading.ParagraphFormat.KeepWithNext = true;
|
||||
|
||||
var sub = document.Styles[StyleNames.Heading2]!;
|
||||
sub.Font.Size = 10.5;
|
||||
sub.Font.Bold = true;
|
||||
sub.ParagraphFormat.SpaceBefore = Unit.FromPoint(8);
|
||||
sub.ParagraphFormat.SpaceAfter = Unit.FromPoint(3);
|
||||
sub.ParagraphFormat.KeepWithNext = true;
|
||||
|
||||
var muted = document.Styles.AddStyle("Muted", StyleNames.Normal);
|
||||
muted.Font.Color = Muted;
|
||||
muted.Font.Size = 8;
|
||||
}
|
||||
|
||||
private static Table Table(Section section, double[] widthsCm)
|
||||
{
|
||||
var table = section.AddTable();
|
||||
table.Borders.Bottom = new Border { Width = 0.5, Color = Border };
|
||||
foreach (var width in widthsCm)
|
||||
{
|
||||
table.AddColumn(Unit.FromCentimeter(width));
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static void Header(Table table, params string[] titles)
|
||||
{
|
||||
var row = table.AddRow();
|
||||
row.HeadingFormat = true;
|
||||
row.Shading.Color = Surface;
|
||||
row.Format.Font.Bold = true;
|
||||
for (var i = 0; i < titles.Length; i++)
|
||||
{
|
||||
row.Cells[i].AddParagraph(titles[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Row(Table table, bool bad, params string[] values)
|
||||
{
|
||||
var row = table.AddRow();
|
||||
row.Borders.Bottom = new Border { Width = 0.25, Color = Border };
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
{
|
||||
var paragraph = row.Cells[i].AddParagraph(values[i]);
|
||||
if (bad)
|
||||
{
|
||||
paragraph.Format.Font.Color = Bad;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@ public sealed partial class InsightRepository
|
|||
await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @cutoff", events).ConfigureAwait(false);
|
||||
await DeleteInBatchesAsync(connection, "insight.Findings", "ResolvedAt < @cutoff", resolved).ConfigureAwait(false);
|
||||
await DeleteInBatchesAsync(connection, "insight.ConfigurationChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false);
|
||||
await DeleteInBatchesAsync(connection, "insight.PrivilegeChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false);
|
||||
await DeleteInBatchesAsync(connection, "insight.OutboundSubmissions", "COALESCE(SentAt, GaveUpAt) < @cutoff", utc.AddDays(-7)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
|
|||
96
src/SQLInsight.Repository/InsightRepository.Privileges.cs
Normal file
96
src/SQLInsight.Repository/InsightRepository.Privileges.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SQLInsight.Core.Data;
|
||||
|
||||
namespace SQLInsight.Repository;
|
||||
|
||||
public sealed record StoredPrivilegeChange(long ChangeId, int InstanceId, string InstanceName, DateTime DetectedAt, string Kind, string Privilege,
|
||||
string Principal, string PrincipalType, string? ViaGroup, string? ChangedBy, DateTime? ChangedAt);
|
||||
|
||||
public sealed partial class InsightRepository
|
||||
{
|
||||
/// <summary>Privilege changes, newest first: for one instance (or all), detected in [from, to].</summary>
|
||||
public async Task<IReadOnlyList<StoredPrivilegeChange>> GetPrivilegeChangesAsync(int? instanceId, DateTimeOffset from, DateTimeOffset to, int top = 1000, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
var rows = await connection.QueryAsync<StoredPrivilegeChange>("""
|
||||
SELECT TOP (@top) c.ChangeId, c.InstanceId, i.Name AS InstanceName, c.DetectedAt, c.Kind, c.Privilege, c.Principal, c.PrincipalType,
|
||||
c.ViaGroup, c.ChangedBy, c.ChangedAt
|
||||
FROM insight.PrivilegeChanges c JOIN insight.Instances i ON i.InstanceId = c.InstanceId
|
||||
WHERE (@instanceId IS NULL OR c.InstanceId = @instanceId) AND c.DetectedAt >= @from AND c.DetectedAt <= @to
|
||||
ORDER BY c.DetectedAt DESC, c.ChangeId DESC;
|
||||
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
private static async Task SavePrivilegeChangesAsync(SqlConnection connection, int instanceId, IReadOnlyList<PrivilegeChange> changes)
|
||||
{
|
||||
if (changes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync("""
|
||||
INSERT insight.PrivilegeChanges (InstanceId, DetectedAt, Kind, Privilege, Principal, PrincipalType, ViaGroup, ChangedBy, ChangedAt)
|
||||
SELECT @instanceId, DetectedAt, Kind, Privilege, Principal, PrincipalType, ViaGroup, ChangedBy, ChangedAt
|
||||
FROM OPENJSON(@json) WITH (DetectedAt datetime2(0), Kind nvarchar(20), Privilege nvarchar(40), Principal nvarchar(260),
|
||||
PrincipalType nvarchar(40), ViaGroup nvarchar(260), ChangedBy nvarchar(260), ChangedAt datetime2(0));
|
||||
""", new
|
||||
{
|
||||
instanceId,
|
||||
json = JsonSerializer.Serialize(changes.Select(c => new
|
||||
{
|
||||
DetectedAt = Iso(c.DetectedAt),
|
||||
c.Kind,
|
||||
c.Privilege,
|
||||
Principal = Cut(c.Principal),
|
||||
c.PrincipalType,
|
||||
ViaGroup = c.ViaGroup is null ? null : Cut(c.ViaGroup),
|
||||
ChangedBy = c.ChangedBy is null ? null : Cut(c.ChangedBy),
|
||||
ChangedAt = c.ChangedAt is { } at ? Iso(at) : null,
|
||||
}), Json),
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
static string Iso(DateTimeOffset value) => Utc(value).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
|
||||
static string Cut(string value) => value.Length <= 260 ? value : value[..260];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Who and when, found after the change was stored (the default trace is written in buffers, so a change made just
|
||||
/// before a collection may only be in it at the next): filled in where it was unknown.
|
||||
/// </summary>
|
||||
private static async Task SaveLatePrivilegeAuthorsAsync(SqlConnection connection, int instanceId, IReadOnlyList<PrivilegeChange> recent)
|
||||
{
|
||||
var known = recent.Where(c => c.ChangedBy is not null && c.ViaGroup is null).ToList();
|
||||
if (known.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync("""
|
||||
UPDATE c SET ChangedBy = j.ChangedBy, ChangedAt = j.ChangedAt
|
||||
FROM insight.PrivilegeChanges c
|
||||
JOIN OPENJSON(@json) WITH (DetectedAt datetime2(0), Kind nvarchar(20), Privilege nvarchar(40), Principal nvarchar(260),
|
||||
ChangedBy nvarchar(260), ChangedAt datetime2(0)) j
|
||||
ON c.DetectedAt = j.DetectedAt AND c.Kind = j.Kind AND c.Privilege = j.Privilege AND c.Principal = j.Principal
|
||||
WHERE c.InstanceId = @instanceId AND c.ChangedBy IS NULL AND c.ViaGroup IS NULL;
|
||||
""", new
|
||||
{
|
||||
instanceId,
|
||||
json = JsonSerializer.Serialize(known.Select(c => new
|
||||
{
|
||||
DetectedAt = Iso(c.DetectedAt),
|
||||
c.Kind,
|
||||
c.Privilege,
|
||||
Principal = Cut(c.Principal),
|
||||
ChangedBy = Cut(c.ChangedBy!),
|
||||
ChangedAt = c.ChangedAt is { } at ? Iso(at) : null,
|
||||
}), Json),
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
static string Iso(DateTimeOffset value) => Utc(value).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
|
||||
static string Cut(string value) => value.Length <= 260 ? value : value[..260];
|
||||
}
|
||||
}
|
||||
|
|
@ -256,6 +256,11 @@ public sealed partial class InsightRepository(string connectionString)
|
|||
await SaveConfigurationChangesAsync(connection, instanceId, configuration.Changes).ConfigureAwait(false);
|
||||
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
|
||||
return;
|
||||
case PrivilegeSnapshot privileges:
|
||||
await SavePrivilegeChangesAsync(connection, instanceId, privileges.Changes).ConfigureAwait(false);
|
||||
await SaveLatePrivilegeAuthorsAsync(connection, instanceId, privileges.RecentChanges).ConfigureAwait(false);
|
||||
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
|
||||
return;
|
||||
default:
|
||||
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
/* Privilege audit: who got or lost a fixed server role or CONTROL SERVER, directly or through a Windows group, when it
|
||||
was detected, and (from the default trace, when readable) who did it and when. Names are customer data: they stay
|
||||
in this repository and are never sent to SQLInsight Hub. Kept as long as resolved findings. */
|
||||
CREATE TABLE insight.PrivilegeChanges
|
||||
(
|
||||
ChangeId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_PrivilegeChanges PRIMARY KEY,
|
||||
InstanceId int NOT NULL CONSTRAINT FK_PrivilegeChanges_Instances REFERENCES insight.Instances (InstanceId),
|
||||
DetectedAt datetime2(0) NOT NULL,
|
||||
Kind nvarchar(20) NOT NULL,
|
||||
Privilege nvarchar(40) NOT NULL,
|
||||
Principal nvarchar(260) NOT NULL,
|
||||
PrincipalType nvarchar(40) NOT NULL,
|
||||
ViaGroup nvarchar(260) NULL,
|
||||
ChangedBy nvarchar(260) NULL,
|
||||
ChangedAt datetime2(0) NULL
|
||||
);
|
||||
CREATE INDEX IX_PrivilegeChanges_Instance ON insight.PrivilegeChanges (InstanceId, DetectedAt);
|
||||
|
|
@ -6,6 +6,7 @@ using SQLInsight.Rules.Engine;
|
|||
using SQLInsight.Rules.Indexes;
|
||||
using SQLInsight.Rules.Lifecycle;
|
||||
using SQLInsight.Rules.Performance;
|
||||
using SQLInsight.Rules.Security;
|
||||
|
||||
namespace SQLInsight.Rules;
|
||||
|
||||
|
|
@ -31,6 +32,8 @@ public static class BuiltInRules
|
|||
new PendingConfigurationRule(),
|
||||
new DeadlockTraceFlagRule(),
|
||||
new ConfigurationChangedRule(),
|
||||
new NewPrivilegeRule(),
|
||||
new TooManySysadminsRule(),
|
||||
|
||||
// Databases
|
||||
new DatabaseStateRule(),
|
||||
|
|
|
|||
109
src/SQLInsight.Rules/Security/PrivilegeRules.cs
Normal file
109
src/SQLInsight.Rules/Security/PrivilegeRules.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System.Globalization;
|
||||
using SQLInsight.Core.Data;
|
||||
using SQLInsight.Core.Findings;
|
||||
using SQLInsight.Core.Instances;
|
||||
using SQLInsight.Rules.Engine;
|
||||
|
||||
namespace SQLInsight.Rules.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Someone got sysadmin (or another elevated server right): directly or because they were added to a Windows group that
|
||||
/// has it. High for rights that give full control, kept open for a week so it is seen. Names are sensitive evidence;
|
||||
/// title and subject carry no names, so nothing identifying reaches SQLInsight Hub.
|
||||
/// </summary>
|
||||
public sealed class NewPrivilegeRule : Rule
|
||||
{
|
||||
public override string Id => "SQLI-SEC-001";
|
||||
public override string Title => "Ny forhøjet rettighed på serveren";
|
||||
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||
|
||||
public override RuleEvaluation Evaluate(RuleContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
var snapshot = context.Data.Get<PrivilegeSnapshot>();
|
||||
return Result(snapshot.RecentChanges
|
||||
.Where(c => c.Kind == PrivilegeAnalysis.Added)
|
||||
.Select(c =>
|
||||
{
|
||||
var highRisk = PrivilegeAnalysis.IsHighRisk(c.Privilege);
|
||||
var how = c.ViaGroup is null ? c.PrincipalType : "Windows-bruger via gruppe";
|
||||
var evidence = new List<Evidence>
|
||||
{
|
||||
new("Rettighed", c.Privilege),
|
||||
new("Hvem fik den", c.Principal, Sensitive: true),
|
||||
new("Type", how),
|
||||
};
|
||||
if (c.ViaGroup is not null)
|
||||
{
|
||||
evidence.Add(new("Via Windows-gruppe", c.ViaGroup, Sensitive: true));
|
||||
}
|
||||
|
||||
evidence.Add(new("Ændret af", c.ChangedBy ?? (c.ViaGroup is not null ? "ændret i Active Directory" : "ukendt"), Sensitive: c.ChangedBy is not null));
|
||||
evidence.Add(new("Ændret", c.ChangedAt is { } at ? Utc(at) : "ukendt"));
|
||||
evidence.Add(new("Opdaget", Utc(c.DetectedAt)));
|
||||
if (c.ChangedBy is null && c.ViaGroup is null && snapshot.TraceStatus is { } why)
|
||||
{
|
||||
evidence.Add(new("Hvorfor ukendt", why));
|
||||
}
|
||||
|
||||
return CreateFinding(
|
||||
highRisk ? Severity.High : Severity.Medium,
|
||||
$"Ny {c.Privilege}-rettighed ({how})",
|
||||
highRisk
|
||||
? $"En konto har fået {c.Privilege}. Det giver fuld kontrol over SQL Server: alle data i alle databaser, alle indstillinger og "
|
||||
+ "muligheden for at give andre rettigheder. Nye medlemmer af denne gruppe skal kunne forklares, fx over for revisionen."
|
||||
: $"En konto har fået {c.Privilege}, en forhøjet serverrettighed. Den giver ikke fuld kontrol, men mere end almindelige brugere bør have.",
|
||||
"Bekræft, at ændringen var godkendt, og hvem der står bag (se målingerne). Var den ikke det, så fjern rettigheden med det samme "
|
||||
+ $"(ALTER SERVER ROLE [{c.Privilege}] DROP MEMBER …, eller fjern brugeren fra Windows-gruppen), og find ud af, hvordan den blev givet. "
|
||||
+ "Hele historikken står under Rettigheder på instansens side og i revisionsrapporten.",
|
||||
evidence,
|
||||
subject: $"privilege: {c.Privilege}/{PrivilegeAnalysis.Fingerprint(c.Principal + "|" + c.ViaGroup)}/{c.DetectedAt.UtcDateTime:yyyyMMddHHmm}");
|
||||
}));
|
||||
}
|
||||
|
||||
private static string Utc(DateTimeOffset value) => value.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// More people and accounts with sysadmin than the customer's own limit (SQLInsight:Privileges:MaxSysadminAccounts,
|
||||
/// default 3). Windows groups count as the people in them.
|
||||
/// </summary>
|
||||
public sealed class TooManySysadminsRule : Rule
|
||||
{
|
||||
public override string Id => "SQLI-SEC-002";
|
||||
public override string Title => "For mange personer og konti med sysadmin";
|
||||
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
|
||||
|
||||
public override RuleEvaluation Evaluate(RuleContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
var snapshot = context.Data.Get<PrivilegeSnapshot>();
|
||||
var accounts = PrivilegeAnalysis.Accounts(snapshot, PrivilegeAnalysis.Sysadmin);
|
||||
var unknownGroups = PrivilegeAnalysis.UnfoldedGroupsMissing(snapshot, PrivilegeAnalysis.Sysadmin);
|
||||
if (accounts.Count <= snapshot.MaxSysadminAccounts)
|
||||
{
|
||||
return RuleEvaluation.Passed();
|
||||
}
|
||||
|
||||
var evidence = new List<Evidence>
|
||||
{
|
||||
new("Antal", accounts.Count.ToString(CultureInfo.InvariantCulture)),
|
||||
new("Grænse", snapshot.MaxSysadminAccounts.ToString(CultureInfo.InvariantCulture)),
|
||||
new("Personer og konti", string.Join(", ", accounts.Select(a => a.Source == PrivilegeAnalysis.Direct ? a.Account : $"{a.Account} (via {a.Source})")), Sensitive: true),
|
||||
};
|
||||
if (unknownGroups.Count > 0)
|
||||
{
|
||||
evidence.Add(new("Grupper der ikke kunne foldes ud", string.Join(", ", unknownGroups.Select(g => g.Name)), Sensitive: true));
|
||||
}
|
||||
|
||||
return RuleEvaluation.Found(CreateFinding(
|
||||
Severity.Medium,
|
||||
$"{accounts.Count} personer og konti har sysadmin (grænse {snapshot.MaxSysadminAccounts})",
|
||||
"Alle med sysadmin kan læse og ændre alle data og alle indstillinger, og deres fejl eller kompromitterede konti rammer hele instansen. "
|
||||
+ "Jo flere der har den, jo sværere er det at styre, og revisionen vil typisk spørge til det. Windows-grupper tæller med de personer, de indeholder.",
|
||||
"Gennemgå listen (under Rettigheder på instansens side), og fjern sysadmin fra dem, der ikke har brug for den. Giv i stedet de "
|
||||
+ "rettigheder, opgaven kræver (fx db_owner på én database eller VIEW SERVER STATE). Grænsen sættes med SQLInsight:Privileges:MaxSysadminAccounts.",
|
||||
evidence,
|
||||
subject: "privilege: sysadmin count"));
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
<span class="nav-label">Overvågning</span>
|
||||
<NavLink href="/" Match="NavLinkMatch.All"><Icon Name="servers" /> Instanser</NavLink>
|
||||
<NavLink href="/fund"><Icon Name="list" /> Alle fund</NavLink>
|
||||
<NavLink href="/rettigheder"><Icon Name="users" /> Rettigheder</NavLink>
|
||||
<NavLink href="/rapporter"><Icon Name="book" /> Rapporter</NavLink>
|
||||
<NavLink href="/system"><Icon Name="gauge" /> System</NavLink>
|
||||
<NavLink href="/support"><Icon Name="support" /> Support</NavLink>
|
||||
|
|
|
|||
69
src/SQLInsight.Web/Components/Pages/AllPrivileges.razor
Normal file
69
src/SQLInsight.Web/Components/Pages/AllPrivileges.razor
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
@page "/rettigheder"
|
||||
@inject InsightRepository Repository
|
||||
|
||||
<PageTitle>Rettigheder · SQLInsight Pro</PageTitle>
|
||||
<h1>Rettigheder</h1>
|
||||
<p class="lead">Sysadmin og de øvrige forhøjede serverrettigheder på alle instanser, og hvem der har fået eller mistet dem.</p>
|
||||
<p class="report-links">
|
||||
<a class="button" href="/rapporter/rettigheder.pdf?dage=@Dage">Revisionsrapport for alle instanser (PDF)</a>
|
||||
</p>
|
||||
|
||||
@if (_audit is not null)
|
||||
{
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Instanser</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>Instans</th><th class="num">Sysadmin</th><th class="num">Grænse</th><th>sa</th><th class="num">SQL-logins uden politik</th><th>Default trace</th><th class="num">Ændringer</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var e in _audit.Instances)
|
||||
{
|
||||
<tr>
|
||||
<td><a href="/instanser/@Uri.EscapeDataString(e.Instance.Name)/rettigheder"><strong>@e.Instance.Name</strong></a></td>
|
||||
@if (e.Snapshot is null)
|
||||
{
|
||||
<td colspan="5" class="muted">endnu ikke målt</td>
|
||||
}
|
||||
else
|
||||
{
|
||||
<td class="num @(e.Sysadmins.Count > e.Snapshot.MaxSysadminAccounts ? "bad" : "")">@e.Sysadmins.Count@(PrivilegeAnalysis.UnfoldedGroupsMissing(e.Snapshot, PrivilegeAnalysis.Sysadmin).Count > 0 ? "+" : "")</td>
|
||||
<td class="num">@e.Snapshot.MaxSysadminAccounts</td>
|
||||
<td class="@(e.Sa is { IsDisabled: false } ? "bad" : "")">@SaShort(e.Sa)</td>
|
||||
<td class="num @(e.LoginsWithoutPolicy.Count > 0 ? "bad" : "")">@e.LoginsWithoutPolicy.Count</td>
|
||||
<td class="@(e.Snapshot.DefaultTraceEnabled == false ? "bad" : "")">@(e.Snapshot.DefaultTraceEnabled switch { true => "til", false => "fra", null => "ukendt" })</td>
|
||||
}
|
||||
<td class="num">@e.Changes.Count</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">"+" efter antallet: en Windows-gruppe med sysadmin kunne ikke foldes ud, så der kan være flere.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Ændringslog</h2><span class="muted">seneste @Dage dage</span></div>
|
||||
<PeriodLinks Days="Dage!.Value" />
|
||||
<PrivilegeChanges Changes="_changes" ShowInstance="true" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery] public int? Dage { get; set; }
|
||||
|
||||
private PrivilegeAudit? _audit;
|
||||
private IReadOnlyList<StoredPrivilegeChange> _changes = [];
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
Dage = Math.Clamp(Dage ?? 90, 1, 366);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
_audit = await PrivilegeAudit.LoadAsync(Repository, null, now.AddDays(-Dage.Value), now);
|
||||
_changes = _audit.Instances.SelectMany(i => i.Changes).OrderByDescending(c => c.DetectedAt).ToList();
|
||||
}
|
||||
|
||||
private static string SaShort(SqlLoginInfo? sa) => sa switch
|
||||
{
|
||||
null => "findes ikke",
|
||||
{ IsDisabled: true } => "deaktiveret",
|
||||
_ => "aktiv",
|
||||
};
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@
|
|||
}
|
||||
else
|
||||
{
|
||||
<PrivilegeAlert Findings="_active" ShowInstance="true" />
|
||||
|
||||
<div class="kpis">
|
||||
<div class="kpi"><span class="kpi-icon"><Icon Name="servers" /></span><div><strong>@_instances.Count</strong><span>instanser</span></div></div>
|
||||
<div class="kpi sev-critical"><span class="kpi-icon"><Icon Name="alert" /></span><div><strong>@_instances.Sum(i => i.Critical)</strong><span>kritiske fund</span></div></div>
|
||||
|
|
@ -67,11 +69,13 @@ else
|
|||
|
||||
@code {
|
||||
private IReadOnlyList<InstanceOverview> _instances = [];
|
||||
private IReadOnlyList<StoredFinding> _active = [];
|
||||
private IReadOnlyList<StoredFinding> _urgent = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_instances = await Repository.GetInstancesAsync();
|
||||
_urgent = (await Repository.GetActiveFindingsAsync()).Where(f => f.Severity >= Severity.High).ToList();
|
||||
_active = await Repository.GetActiveFindingsAsync();
|
||||
_urgent = _active.Where(f => f.Severity >= Severity.High).ToList();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,11 @@
|
|||
</p>
|
||||
<p class="report-links">
|
||||
<a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/konfiguration">Konfiguration</a>
|
||||
<a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/rettigheder">Rettigheder</a>
|
||||
</p>
|
||||
|
||||
<PrivilegeAlert Findings="_findings" />
|
||||
|
||||
@if (_instance.IsFailing)
|
||||
{
|
||||
<div class="alert">
|
||||
|
|
|
|||
58
src/SQLInsight.Web/Components/Pages/Privileges.razor
Normal file
58
src/SQLInsight.Web/Components/Pages/Privileges.razor
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
@page "/instanser/{Name}/rettigheder"
|
||||
@inject InsightRepository Repository
|
||||
|
||||
@if (_instance is null)
|
||||
{
|
||||
<PageTitle>Ukendt instans · SQLInsight Pro</PageTitle>
|
||||
<h1>Ukendt instans</h1>
|
||||
<p>Der findes ingen instans med navnet <strong>@Name</strong>.</p>
|
||||
return;
|
||||
}
|
||||
|
||||
<PageTitle>Rettigheder · @_instance.Name · SQLInsight Pro</PageTitle>
|
||||
<p class="crumbs"><a href="/">Instanser</a> / <a href="/instanser/@Uri.EscapeDataString(_instance.Name)">@_instance.Name</a> / Rettigheder</p>
|
||||
<h1>Rettigheder</h1>
|
||||
<p class="lead">Hvem har sysadmin og de øvrige forhøjede serverrettigheder, med Windows-grupper foldet ud til personer, og hvem der har fået eller mistet dem. Opdateres hvert 15. minut.</p>
|
||||
<p class="report-links">
|
||||
<a class="button" href="/rapporter/rettigheder.pdf?dage=@Dage&instans=@Uri.EscapeDataString(_instance.Name)">Revisionsrapport (PDF)</a>
|
||||
<a href="/rettigheder">Alle instanser</a>
|
||||
</p>
|
||||
|
||||
@if (_audit is not null)
|
||||
{
|
||||
<PrivilegeDetails Entry="_audit.Instances[0]" />
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Ændringslog</h2><span class="muted">seneste @Dage dage</span></div>
|
||||
<PeriodLinks Days="Dage!.Value" />
|
||||
<PrivilegeChanges Changes="_audit.Instances[0].Changes" />
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="card">
|
||||
<details>
|
||||
<summary>Datakilde og metode</summary>
|
||||
<p>@PrivilegeAudit.MethodText</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Name { get; set; } = string.Empty;
|
||||
[SupplyParameterFromQuery] public int? Dage { get; set; }
|
||||
|
||||
private InstanceOverview? _instance;
|
||||
private PrivilegeAudit? _audit;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
Dage = Math.Clamp(Dage ?? 90, 1, 366);
|
||||
_instance = await Repository.GetInstanceAsync(Name);
|
||||
if (_instance is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
_audit = await PrivilegeAudit.LoadAsync(Repository, _instance, now.AddDays(-Dage.Value), now);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,3 +16,14 @@
|
|||
</p>
|
||||
<p class="muted">Rapporten kan også sendes automatisk hver måned. Se <code>SQLInsight:Reports</code> i agentens konfiguration.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Rettighedsrevision</h2>
|
||||
<p>Hvem har sysadmin og de øvrige forhøjede serverrettigheder nu (Windows-grupper foldet ud til personer), hvem der har fået eller mistet dem i perioden og af hvem, sa-kontoen, SQL-logins uden adgangskodepolitik og default trace. Indeholder navne: rapporten dannes her og sendes ikke nogen steder hen.</p>
|
||||
<p class="report-links">
|
||||
<a class="button" href="/rapporter/rettigheder.pdf?dage=30">Seneste 30 dage</a>
|
||||
<a class="button" href="/rapporter/rettigheder.pdf?dage=90">Seneste kvartal</a>
|
||||
<a class="button" href="/rapporter/rettigheder.pdf?dage=365">Seneste år</a>
|
||||
</p>
|
||||
<p class="muted">For én instans: brug knappen på instansens side under Rettigheder. Se også <a href="/rettigheder">Rettigheder for alle instanser</a>.</p>
|
||||
</section>
|
||||
|
|
|
|||
10
src/SQLInsight.Web/Components/Shared/PeriodLinks.razor
Normal file
10
src/SQLInsight.Web/Components/Shared/PeriodLinks.razor
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<p class="report-links">
|
||||
@foreach (var d in new[] { 7, 30, 90, 365 })
|
||||
{
|
||||
<a href="?dage=@d" class="@(d == Days ? "active" : "")">@(d == 365 ? "1 år" : $"{d} dage")</a>
|
||||
}
|
||||
</p>
|
||||
|
||||
@code {
|
||||
[Parameter] public int Days { get; set; }
|
||||
}
|
||||
33
src/SQLInsight.Web/Components/Shared/PrivilegeAlert.razor
Normal file
33
src/SQLInsight.Web/Components/Shared/PrivilegeAlert.razor
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
@inject Display Display
|
||||
|
||||
@* New sysadmin and other elevated rights, shown above everything else. Names are in the finding's evidence, not here. *@
|
||||
@if (_alerts.Count > 0)
|
||||
{
|
||||
<div class="alert">
|
||||
<strong>Nye forhøjede rettigheder</strong>
|
||||
<ul>
|
||||
@foreach (var f in _alerts)
|
||||
{
|
||||
<li>
|
||||
<a href="/fund/@f.FindingId">@f.Title</a>
|
||||
@if (ShowInstance)
|
||||
{
|
||||
<span> på <a href="/instanser/@Uri.EscapeDataString(f.InstanceName)/rettigheder">@f.InstanceName</a></span>
|
||||
}
|
||||
<span class="muted"> · opdaget @Display.Time(f.FirstSeenAt)</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
Bekræft, at ændringerne var godkendt. Se hvem og hvornår under fundet eller på siden Rettigheder.
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public IReadOnlyList<StoredFinding> Findings { get; set; } = [];
|
||||
[Parameter] public bool ShowInstance { get; set; }
|
||||
|
||||
private IReadOnlyList<StoredFinding> _alerts = [];
|
||||
|
||||
protected override void OnParametersSet() =>
|
||||
_alerts = Findings.Where(f => f.RuleId == "SQLI-SEC-001").OrderByDescending(f => f.Severity).ThenByDescending(f => f.FirstSeenAt).ToList();
|
||||
}
|
||||
31
src/SQLInsight.Web/Components/Shared/PrivilegeChanges.razor
Normal file
31
src/SQLInsight.Web/Components/Shared/PrivilegeChanges.razor
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
@inject Display Display
|
||||
|
||||
@if (Changes.Count == 0)
|
||||
{
|
||||
<p class="muted">Ingen ændringer registreret i perioden. Den første måling efter installation er udgangspunktet; ændringer derefter vises her.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table>
|
||||
<thead><tr><th>Opdaget</th>@if (ShowInstance) { <th>Instans</th> }<th>Ændring</th><th>Rettighed</th><th>Hvem</th><th>Ændret af</th><th>Ændret</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in Changes)
|
||||
{
|
||||
<tr class="@(c.Kind == PrivilegeAnalysis.Added && PrivilegeAnalysis.IsHighRisk(c.Privilege) ? "bad" : "")">
|
||||
<td class="nowrap">@Display.Time(c.DetectedAt)</td>
|
||||
@if (ShowInstance) { <td><a href="/instanser/@Uri.EscapeDataString(c.InstanceName)/rettigheder">@c.InstanceName</a></td> }
|
||||
<td>@c.Kind</td>
|
||||
<td>@c.Privilege</td>
|
||||
<td class="mono">@c.Principal@(c.ViaGroup is null ? "" : $" (via {c.ViaGroup})")</td>
|
||||
<td class="mono">@(c.ChangedBy ?? (c.ViaGroup is null ? "ukendt" : "Active Directory"))</td>
|
||||
<td class="nowrap">@(c.ChangedAt is { } at ? Display.Time(at) : "–")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public IReadOnlyList<StoredPrivilegeChange> Changes { get; set; } = [];
|
||||
[Parameter] public bool ShowInstance { get; set; }
|
||||
}
|
||||
95
src/SQLInsight.Web/Components/Shared/PrivilegeDetails.razor
Normal file
95
src/SQLInsight.Web/Components/Shared/PrivilegeDetails.razor
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
@inject Display Display
|
||||
|
||||
@if (Entry.Snapshot is not { } snapshot)
|
||||
{
|
||||
<p class="muted">Rettighederne er endnu ikke målt. De læses første gang inden for 15 minutter efter opgradering til version 1.0.5.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
var sysadmins = Entry.Sysadmins;
|
||||
<div class="grid2">
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Sysadmin</h2><span class="muted">målt @Display.Ago(snapshot.CapturedAt.UtcDateTime)</span></div>
|
||||
<p class="@(sysadmins.Count > snapshot.MaxSysadminAccounts ? "bad" : "")"><strong>@sysadmins.Count</strong> personer og konti har sysadmin (grænse @snapshot.MaxSysadminAccounts).</p>
|
||||
@if (sysadmins.Count > 0)
|
||||
{
|
||||
<table>
|
||||
<thead><tr><th>Konto</th><th>Navn</th><th>Type</th><th>Kilde</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in sysadmins)
|
||||
{
|
||||
<tr><td class="mono">@a.Account</td><td>@(a.DisplayName ?? "–")</td><td>@a.Type</td><td>@(a.Source == PrivilegeAnalysis.Direct ? "direkte" : $"via {a.Source}")</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
@foreach (var g in PrivilegeAnalysis.UnfoldedGroupsMissing(snapshot, PrivilegeAnalysis.Sysadmin))
|
||||
{
|
||||
<p class="muted">Gruppen <span class="mono">@g.Name</span> kunne ikke foldes ud: @g.ExpansionError Dens medlemmer er ikke talt med.</p>
|
||||
}
|
||||
</section>
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Kontroller</h2></div>
|
||||
<table class="kv">
|
||||
<tbody>
|
||||
<tr><th>sa-kontoen</th><td class="@(Entry.Sa is { IsDisabled: false } ? "bad" : "")">@PrivilegeAudit.SaText(Entry.Sa)</td></tr>
|
||||
<tr><th>SQL-logins uden adgangskodepolitik</th><td class="@(Entry.LoginsWithoutPolicy.Count > 0 ? "bad" : "")">@(Entry.LoginsWithoutPolicy.Count == 0 ? "Ingen" : string.Join(", ", Entry.LoginsWithoutPolicy.Select(l => l.Name)))</td></tr>
|
||||
<tr><th>SQL-logins med politik men uden udløb</th><td>@snapshot.SqlLogins.Count(l => !l.IsDisabled && l.PolicyChecked && !l.ExpirationChecked)</td></tr>
|
||||
<tr><th>Default trace</th><td class="@(snapshot.DefaultTraceEnabled == false ? "bad" : "")">@PrivilegeAudit.TraceText(snapshot)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><h2>Forhøjede serverrettigheder</h2><span class="muted">faste serverroller og CONTROL SERVER</span></div>
|
||||
@if (snapshot.Principals.Count == 0)
|
||||
{
|
||||
<p class="muted">Ingen.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table>
|
||||
<thead><tr><th>Rettighed</th><th>Login eller gruppe</th><th>Type</th><th>Status</th><th>Personer i gruppen</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var p in snapshot.Principals.OrderBy(p => Order(p.Privilege)).ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
<tr>
|
||||
<td>@p.Privilege</td>
|
||||
<td class="mono">@p.Name</td>
|
||||
<td>@p.Type</td>
|
||||
<td>@(p.IsDisabled ? "deaktiveret" : "aktiv")</td>
|
||||
<td>
|
||||
@if (p.Type != "Windows-gruppe")
|
||||
{
|
||||
<span class="muted">–</span>
|
||||
}
|
||||
else if (p.Members is null)
|
||||
{
|
||||
<span class="bad">kunne ikke foldes ud: @p.ExpansionError</span>
|
||||
}
|
||||
else if (p.Members.Count == 0)
|
||||
{
|
||||
<span class="muted">ingen medlemmer</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var m in p.Members)
|
||||
{
|
||||
<div class="mono">@m.Account@(m.DisplayName is { } n ? $" ({n})" : "")@(m.Enabled ? "" : " – deaktiveret")</div>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public PrivilegeAuditInstance Entry { get; set; } = default!;
|
||||
|
||||
private static int Order(string privilege) => PrivilegeAnalysis.Privileges.ToList().IndexOf(privilege);
|
||||
}
|
||||
|
|
@ -10,3 +10,4 @@
|
|||
@using SQLInsight.Web.Components
|
||||
@using SQLInsight.Web.Components.Layout
|
||||
@using SQLInsight.Web.Components.Shared
|
||||
@using SQLInsight.Reports
|
||||
|
|
|
|||
|
|
@ -113,6 +113,23 @@ app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository r
|
|||
return Results.File(SQLInsight.Reports.ManagementReport.Render(data), "application/pdf", $"driftsrapport-{now:yyyy-MM-dd}.pdf");
|
||||
});
|
||||
|
||||
// Privilege audit as PDF, for one instance or all. Contains names: produced here, for the person who is logged in.
|
||||
app.MapGet("/rapporter/rettigheder.pdf", async (int? dage, string? instans, InsightRepository repository, Display display,
|
||||
Microsoft.Extensions.Options.IOptions<WebOptions> options, CancellationToken ct) =>
|
||||
{
|
||||
InstanceOverview? instance = null;
|
||||
if (instans is { Length: > 0 } && (instance = await repository.GetInstanceAsync(instans, ct)) is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var days = Math.Clamp(dage ?? 90, 1, 366);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var audit = await SQLInsight.Reports.PrivilegeAudit.LoadAsync(repository, instance, now.AddDays(-days), now, ct);
|
||||
var scope = instance is null ? "alle" : string.Concat(instance.Name.Select(c => char.IsLetterOrDigit(c) || c == '-' ? c : '_'));
|
||||
return Results.File(audit.Render(options.Value.Organization, display.Zone), "application/pdf", $"rettigheder-{scope}-{now:yyyy-MM-dd}.pdf");
|
||||
});
|
||||
|
||||
// Time window analysis as PDF. Contains statement texts: produced here, for the person who is logged in.
|
||||
app.MapGet("/analyse.pdf", async (string? instans, string? fra, string? til, string? sortering, InsightRepository repository, Display display,
|
||||
Microsoft.Extensions.Options.IOptions<WebOptions> options, CancellationToken ct) =>
|
||||
|
|
|
|||
29
tests/SQLInsight.Agent.Tests/GroupResolverTests.cs
Normal file
29
tests/SQLInsight.Agent.Tests/GroupResolverTests.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SQLInsight.Agent.Security;
|
||||
|
||||
namespace SQLInsight.Agent.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Active Directory resolver's answers that need no domain. Unfolding real groups needs a domain controller and is
|
||||
/// not tested here.
|
||||
/// </summary>
|
||||
public class GroupResolverTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(@"BUILTIN\Administrators", "lokal gruppe")]
|
||||
[InlineData("SQL-Admins", "intet domæne")]
|
||||
public async Task Groups_that_cannot_be_looked_up_give_the_reason(string group, string reason)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resolver = new ActiveDirectoryGroupResolver(TimeProvider.System, NullLogger<ActiveDirectoryGroupResolver>.Instance);
|
||||
|
||||
var expansion = await resolver.ExpandAsync(group, CancellationToken.None);
|
||||
|
||||
Assert.Empty(expansion.Members);
|
||||
Assert.Contains(reason, expansion.Error, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
125
tests/SQLInsight.Core.Tests/PrivilegeAnalysisTests.cs
Normal file
125
tests/SQLInsight.Core.Tests/PrivilegeAnalysisTests.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
using SQLInsight.Core.Data;
|
||||
|
||||
namespace SQLInsight.Core.Tests;
|
||||
|
||||
public class PrivilegeAnalysisTests
|
||||
{
|
||||
private static readonly DateTimeOffset Then = new(2026, 9, 29, 10, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateTimeOffset Now = Then.AddMinutes(15);
|
||||
|
||||
private static PrivilegeSnapshot Snapshot(params PrivilegedPrincipal[] principals) => new() { CapturedAt = Now, Principals = principals };
|
||||
|
||||
private static PrivilegedPrincipal Group(string name, params string[] members) =>
|
||||
new(name, "Windows-gruppe", "sysadmin", false, members.Select(m => new GroupMember(m, null, true)).ToList());
|
||||
|
||||
[Fact]
|
||||
public void New_and_removed_direct_members_are_found_by_privilege_and_name()
|
||||
{
|
||||
var before = Snapshot(new("sa", "SQL-login", "sysadmin", false), new("KUNDE\\anne", "Windows-login", "dbcreator", false));
|
||||
var after = Snapshot(new("sa", "SQL-login", "sysadmin", false), new("KUNDE\\anne", "Windows-login", "sysadmin", false));
|
||||
|
||||
var changes = PrivilegeAnalysis.Diff(before, after, Now);
|
||||
|
||||
Assert.Equal(2, changes.Count);
|
||||
Assert.Contains(changes, c => c is { Kind: PrivilegeAnalysis.Added, Privilege: "sysadmin", Principal: "KUNDE\\anne", ViaGroup: null });
|
||||
Assert.Contains(changes, c => c is { Kind: PrivilegeAnalysis.Removed, Privilege: "dbcreator", Principal: "KUNDE\\anne" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void People_added_to_an_unfolded_group_are_new_members_via_the_group()
|
||||
{
|
||||
var before = Snapshot(Group("KUNDE\\SQL-Admins", "KUNDE\\anne"));
|
||||
var after = Snapshot(Group("KUNDE\\SQL-Admins", "KUNDE\\anne", "KUNDE\\bo"));
|
||||
|
||||
var change = Assert.Single(PrivilegeAnalysis.Diff(before, after, Now));
|
||||
|
||||
Assert.Equal((PrivilegeAnalysis.Added, "KUNDE\\bo", "KUNDE\\SQL-Admins", "Windows-bruger"), (change.Kind, change.Principal, change.ViaGroup, change.PrincipalType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_group_that_could_not_be_unfolded_does_not_look_like_everyone_left()
|
||||
{
|
||||
var before = Snapshot(Group("KUNDE\\SQL-Admins", "KUNDE\\anne", "KUNDE\\bo"));
|
||||
var after = Snapshot(new PrivilegedPrincipal("KUNDE\\SQL-Admins", "Windows-gruppe", "sysadmin", false, ExpansionError: "Active Directory svarede ikke"));
|
||||
|
||||
Assert.Empty(PrivilegeAnalysis.Diff(before, after, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Who_and_when_comes_from_the_matching_trace_event()
|
||||
{
|
||||
var changes = new[]
|
||||
{
|
||||
new PrivilegeChange(Now, PrivilegeAnalysis.Added, "sysadmin", "ny_admin", "SQL-login"),
|
||||
new PrivilegeChange(Now, PrivilegeAnalysis.Removed, "dbcreator", "gammel", "SQL-login"),
|
||||
new PrivilegeChange(Now, PrivilegeAnalysis.Added, "sysadmin", "KUNDE\\bo", "Windows-bruger", "KUNDE\\SQL-Admins"),
|
||||
};
|
||||
var events = new[]
|
||||
{
|
||||
new TraceSecurityEvent(Then.AddMinutes(3), 108, 1, "KUNDE\\it-chef", "ny_admin", "sysadmin"),
|
||||
new TraceSecurityEvent(Then.AddMinutes(5), 108, 1, "KUNDE\\vikar", "ny_admin", "sysadmin"), // the latest wins
|
||||
new TraceSecurityEvent(Then.AddMinutes(6), 108, 1, "KUNDE\\vikar", "ny_admin", "serveradmin"), // other role
|
||||
new TraceSecurityEvent(Now.AddMinutes(5), 108, 1, "KUNDE\\senere", "ny_admin", "sysadmin"), // after detection
|
||||
new TraceSecurityEvent(Then.AddMinutes(7), 108, 2, "KUNDE\\it-chef", "gammel", "dbcreator"),
|
||||
};
|
||||
|
||||
var enriched = PrivilegeAnalysis.Enrich(changes, events);
|
||||
|
||||
Assert.Equal(("KUNDE\\vikar", Then.AddMinutes(5)), (enriched[0].ChangedBy, enriched[0].ChangedAt));
|
||||
Assert.Equal("KUNDE\\it-chef", enriched[1].ChangedBy);
|
||||
Assert.Null(enriched[2].ChangedBy); // a change in Active Directory is not in SQL Server's trace
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accounts_count_people_in_groups_once_and_leave_out_services_and_disabled()
|
||||
{
|
||||
var snapshot = Snapshot(
|
||||
new("sa", "SQL-login", "sysadmin", true),
|
||||
new("KUNDE\\anne", "Windows-login", "sysadmin", false),
|
||||
new("NT SERVICE\\MSSQLSERVER", "Windows-login", "sysadmin", false),
|
||||
new("KUNDE\\SQL-Admins", "Windows-gruppe", "sysadmin", false,
|
||||
[new("KUNDE\\anne", "Anne", true), new("KUNDE\\bo", "Bo", true), new("KUNDE\\fratraadt", null, false), new("KUNDE\\SRV01$", null, true)]),
|
||||
new("KUNDE\\DBA", "Windows-gruppe", "sysadmin", false, ExpansionError: "timeout"),
|
||||
new("KUNDE\\cecilie", "Windows-login", "dbcreator", false));
|
||||
|
||||
var accounts = PrivilegeAnalysis.Accounts(snapshot, PrivilegeAnalysis.Sysadmin);
|
||||
|
||||
Assert.Equal(["KUNDE\\anne", "KUNDE\\bo"], accounts.Select(a => a.Account));
|
||||
Assert.Equal(PrivilegeAnalysis.Direct, accounts[0].Source);
|
||||
Assert.Equal("KUNDE\\SQL-Admins", accounts[1].Source);
|
||||
Assert.Equal("KUNDE\\DBA", Assert.Single(PrivilegeAnalysis.UnfoldedGroupsMissing(snapshot, PrivilegeAnalysis.Sysadmin)).Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"NT SERVICE\SQLSERVERAGENT", true)]
|
||||
[InlineData(@"NT AUTHORITY\SYSTEM", true)]
|
||||
[InlineData("##MS_PolicyTsqlExecutionLogin##", true)]
|
||||
[InlineData(@"KUNDE\gmsa-sql$", true)]
|
||||
[InlineData(@"KUNDE\anne", false)]
|
||||
[InlineData("sa", false)]
|
||||
public void Service_identities_are_not_people(string name, bool expected) => Assert.Equal(expected, PrivilegeAnalysis.IsServiceIdentity(name));
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"C:\Program Files\Microsoft SQL Server\MSSQL16.MSSQLSERVER\MSSQL\Log\log_123.trc", @"C:\Program Files\Microsoft SQL Server\MSSQL16.MSSQLSERVER\MSSQL\Log\log.trc")]
|
||||
[InlineData("/var/opt/mssql/log/log_7.trc", "/var/opt/mssql/log/log.trc")]
|
||||
public void Trace_files_are_read_from_the_first_rollover_file(string current, string expected) =>
|
||||
Assert.Equal(expected, PrivilegeAnalysis.TraceFileBase(current));
|
||||
|
||||
[Fact]
|
||||
public void Fingerprints_are_stable_short_and_free_of_names()
|
||||
{
|
||||
var a = PrivilegeAnalysis.Fingerprint("KUNDE\\anne|");
|
||||
Assert.Equal(12, a.Length);
|
||||
Assert.Equal(a, PrivilegeAnalysis.Fingerprint("kunde\\ANNE|"));
|
||||
Assert.DoesNotContain("anne", a, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_active_directory_groups_fail_with_the_reason()
|
||||
{
|
||||
var expansion = await new NoGroupResolver("Agenten kører ikke på Windows").ExpandAsync("KUNDE\\SQL-Admins", CancellationToken.None);
|
||||
|
||||
Assert.Empty(expansion.Members);
|
||||
Assert.Equal("Agenten kører ikke på Windows", expansion.Error);
|
||||
}
|
||||
}
|
||||
197
tests/SQLInsight.IntegrationTests/PrivilegeTests.cs
Normal file
197
tests/SQLInsight.IntegrationTests/PrivilegeTests.cs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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}];");
|
||||
}
|
||||
}
|
||||
}
|
||||
93
tests/SQLInsight.Rules.Tests/PrivilegeRuleTests.cs
Normal file
93
tests/SQLInsight.Rules.Tests/PrivilegeRuleTests.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,6 @@ public class RuleCatalogTests
|
|||
[Fact]
|
||||
public void Rule_count()
|
||||
{
|
||||
Assert.Equal(62, BuiltInRules.Create().Count);
|
||||
Assert.Equal(64, BuiltInRules.Create().Count);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue