Instance configuration core: database selection, sealed credentials, agent request contracts

DatabaseSelection decides which databases the database-specific checks run in
(system databases always), and which may send Dynamics AX findings to the hub
(default none). CredentialEnvelope seals a SQL login password with the agent's
RSA public key (RSA-OAEP-SHA256 + AES-256-GCM), bound to server and login, so only
the agent can open it. Shared contracts for connection tests and discovery, server
address validation, the service account login rule, and the least-privilege GRANT
script filled in for one login.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 11:33:43 +02:00
parent 2284e7f082
commit 8776faefb3
7 changed files with 695 additions and 0 deletions

View file

@ -0,0 +1,150 @@
using System.Text.Json;
namespace SQLInsight.Core.Configuration;
/// <summary>
/// What SQLInsight Web asks the agent to do (insight.AgentRequests). Connection tests and discovery run in the agent,
/// as the agent's identity, because that is what connects to the instances in production; the web UI only queues the
/// request and shows the result.
/// </summary>
public static class AgentRequestKind
{
/// <summary>Connect to a configured instance and check the agent's rights there.</summary>
public const string ConnectionTest = "ConnectionTest";
/// <summary>List the databases on a configured instance (state, size, recovery model, Dynamics AX …).</summary>
public const string DatabaseDiscovery = "DatabaseDiscovery";
/// <summary>Find SQL Server instances on a computer (SQL Server Browser, and the registry on the agent's own computer).</summary>
public const string InstanceDiscovery = "InstanceDiscovery";
public static readonly IReadOnlyList<string> All = [ConnectionTest, DatabaseDiscovery, InstanceDiscovery];
}
public static class AgentRequestStatus
{
public const string Pending = "Pending";
public const string Running = "Running";
public const string Done = "Done";
public const string Failed = "Failed";
}
/// <summary>JSON of request parameters and results, shared by the agent (writes) and the web UI and API (reads).</summary>
public static class AgentRequestJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, Options);
public static T? Deserialize<T>(string? json)
where T : class
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JsonSerializer.Deserialize<T>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
/// <summary>Parameters of an <see cref="AgentRequestKind.InstanceDiscovery"/>.</summary>
/// <param name="Host">The computer to ask; empty for the agent's own computer.</param>
public sealed record InstanceDiscoveryParameters(string? Host);
/// <summary>One right the agent needs (or may have) on an instance, and whether it has it.</summary>
/// <param name="Name">The permission as written in GRANT, e.g. VIEW SERVER STATE, or SELECT ON msdb.dbo.backupset.</param>
/// <param name="Required">False for optional rights (ALTER TRACE): missing them limits a feature, not the monitoring.</param>
/// <param name="Purpose">What SQLInsight uses it for (Danish).</param>
public sealed record PermissionStatus(string Name, bool Granted, bool Required, string Purpose);
/// <summary>The outcome of a connection test, as the agent saw it.</summary>
public sealed record ConnectionTestResult
{
public bool Connected { get; init; }
public string? Error { get; init; }
/// <summary>What to do about <see cref="Error"/> (Danish).</summary>
public string? Hint { get; init; }
public string? Provider { get; init; }
public bool UsedLegacyFallback { get; init; }
public string? ProductVersion { get; init; }
public string? Release { get; init; }
public string? Edition { get; init; }
public string? ServerName { get; init; }
/// <summary>The login the agent arrived as (SUSER_SNAME()): the one the GRANT script is for.</summary>
public string? Login { get; init; }
/// <summary>The agent is sysadmin on the instance. It works, but it is more than it needs.</summary>
public bool IsSysadmin { get; init; }
/// <summary>False on SQL Server 2000, which cannot report effective permissions (no HAS_PERMS_BY_NAME).</summary>
public bool PermissionsChecked { get; init; }
public IReadOnlyList<PermissionStatus> Permissions { get; init; } = [];
/// <summary>Notes about the result, e.g. why permissions were not checked (Danish).</summary>
public IReadOnlyList<string> Notes { get; init; } = [];
public IEnumerable<PermissionStatus> MissingRequired => Permissions.Where(p => p.Required && !p.Granted);
}
/// <summary>A database as the agent found it on an instance.</summary>
public sealed record DiscoveredDatabase
{
public required string Name { get; init; }
public int DatabaseId { get; init; }
public string State { get; init; } = "ONLINE";
public long? SizeMb { get; init; }
public string? RecoveryModel { get; init; }
public int? CompatibilityLevel { get; init; }
public bool IsReadOnly { get; init; }
public bool IsSystem { get; init; }
/// <summary>A database snapshot (not monitored on its own).</summary>
public bool IsSnapshot { get; init; }
/// <summary>The agent can enter the database (HAS_DBACCESS); without it, database-specific checks cannot run there.</summary>
public bool HasAccess { get; init; }
/// <summary>The Dynamics AX system tables (SQLDICTIONARY, DATAAREA, SYSCONFIG) are there.</summary>
public bool IsDynamicsAx { get; init; }
/// <summary>"3.0" (32-bit RECID) or "4.0+" (64-bit RECID), when <see cref="IsDynamicsAx"/>.</summary>
public string? DynamicsAxVersion { get; init; }
}
/// <summary>Summary of a database discovery; the databases themselves are in insight.ConfiguredDatabases.</summary>
public sealed record DatabaseDiscoveryResult
{
public int Databases { get; init; }
public IReadOnlyList<string> New { get; init; } = [];
public IReadOnlyList<string> Gone { get; init; } = [];
/// <summary>New databases were monitored at once (the instance monitors new databases automatically).</summary>
public bool NewMonitored { get; init; }
public string? Error { get; init; }
public string? Hint { get; init; }
}
/// <summary>A SQL Server instance found on a computer.</summary>
/// <param name="Server">What to type as the server: HOST or HOST\INSTANCE.</param>
/// <param name="Source">Where it was found (Danish): "SQL Server Browser" or "registreringsdatabasen".</param>
public sealed record DiscoveredInstance(string Server, string InstanceName, string? Version, int? TcpPort, bool IsClustered, string Source);
public sealed record InstanceDiscoveryResult
{
public string Host { get; init; } = string.Empty;
public IReadOnlyList<DiscoveredInstance> Instances { get; init; } = [];
public IReadOnlyList<string> Notes { get; init; } = [];
}

View file

@ -0,0 +1,160 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace SQLInsight.Core.Configuration;
/// <summary>
/// A SQL login password at rest in the repository, readable only by the agent.
/// <para>
/// The agent has an RSA key pair. The private key never leaves the agent's computer (protected with DPAPI
/// LocalMachine on Windows); the public key is published in the repository (insight.AgentKeys). SQLInsight Web seals a
/// password with the public key and can never open it again, so neither the web UI, the API nor anyone who can read the
/// repository can get it back. Each password gets its own AES-256-GCM key, wrapped with RSA-OAEP-SHA256.
/// </para>
/// <para>
/// The envelope is bound to the server and login it was entered for (<see cref="Context"/>): moving the ciphertext to
/// another row, or pointing the row at another server, makes it unreadable, so the agent never sends a password to a
/// server it was not entered for.
/// </para>
/// Format: version (1) · key id length (1) · key id (UTF-8) · wrapped key length (2, big-endian) · wrapped key · nonce (12) · tag (16) · ciphertext.
/// </summary>
public static class CredentialEnvelope
{
public const byte FormatVersion = 1;
private const int NonceSize = 12;
private const int TagSize = 16;
private const int KeySize = 32;
/// <summary>What a password is bound to: the server (with port) and the login it was entered for.</summary>
public static string Context(string server, string? userName) =>
"sqlinsight-credential-v1\n" + (server ?? string.Empty).Trim().ToUpperInvariant() + "\n" + (userName ?? string.Empty).Trim().ToUpperInvariant();
/// <summary>Seals <paramref name="secret"/> for the agent whose public key (SubjectPublicKeyInfo, DER) is given.</summary>
public static byte[] Seal(string secret, string keyId, byte[] publicKey, string context)
{
ArgumentNullException.ThrowIfNull(secret);
ArgumentException.ThrowIfNullOrEmpty(keyId);
ArgumentNullException.ThrowIfNull(publicKey);
ArgumentNullException.ThrowIfNull(context);
var keyIdBytes = Encoding.UTF8.GetBytes(keyId);
if (keyIdBytes.Length > byte.MaxValue)
{
throw new ArgumentException("Nøgle-id'et er for langt.", nameof(keyId));
}
using var rsa = RSA.Create();
rsa.ImportSubjectPublicKeyInfo(publicKey, out _);
var key = RandomNumberGenerator.GetBytes(KeySize);
try
{
var wrapped = rsa.Encrypt(key, RSAEncryptionPadding.OaepSHA256);
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var plaintext = Encoding.UTF8.GetBytes(secret);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[TagSize];
using (var aes = new AesGcm(key, TagSize))
{
aes.Encrypt(nonce, plaintext, ciphertext, tag, AssociatedData(keyId, context));
}
CryptographicOperations.ZeroMemory(plaintext);
var envelope = new byte[1 + 1 + keyIdBytes.Length + 2 + wrapped.Length + NonceSize + TagSize + ciphertext.Length];
var at = 0;
envelope[at++] = FormatVersion;
envelope[at++] = (byte)keyIdBytes.Length;
keyIdBytes.CopyTo(envelope, at);
at += keyIdBytes.Length;
BinaryPrimitives.WriteUInt16BigEndian(envelope.AsSpan(at), (ushort)wrapped.Length);
at += 2;
wrapped.CopyTo(envelope, at);
at += wrapped.Length;
nonce.CopyTo(envelope, at);
at += NonceSize;
tag.CopyTo(envelope, at);
at += TagSize;
ciphertext.CopyTo(envelope, at);
return envelope;
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
/// <summary>The id of the agent key an envelope was sealed for, or null if it is not an envelope.</summary>
public static string? KeyIdOf(byte[]? envelope)
{
if (envelope is null || envelope.Length < 2 || envelope[0] != FormatVersion || envelope.Length < 2 + envelope[1])
{
return null;
}
return Encoding.UTF8.GetString(envelope, 2, envelope[1]);
}
/// <summary>Opens an envelope with the agent's private key. Throws <see cref="CredentialException"/> when it cannot.</summary>
public static string Open(byte[] envelope, string keyId, RSA privateKey, string context)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(privateKey);
ArgumentNullException.ThrowIfNull(context);
var sealedFor = KeyIdOf(envelope) ?? throw new CredentialException("Adgangskoden er ikke gemt i et format, agenten kender.");
if (!string.Equals(sealedFor, keyId, StringComparison.Ordinal))
{
throw new CredentialException("Adgangskoden er krypteret til en anden agent-nøgle (fx før agenten blev geninstalleret). Indtast den igen.");
}
try
{
var at = 2 + envelope[1];
var wrappedLength = BinaryPrimitives.ReadUInt16BigEndian(envelope.AsSpan(at));
at += 2;
var wrapped = envelope.AsSpan(at, wrappedLength).ToArray();
at += wrappedLength;
var nonce = envelope.AsSpan(at, NonceSize);
at += NonceSize;
var tag = envelope.AsSpan(at, TagSize);
at += TagSize;
var ciphertext = envelope.AsSpan(at);
var key = privateKey.Decrypt(wrapped, RSAEncryptionPadding.OaepSHA256);
try
{
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key, TagSize);
aes.Decrypt(nonce, ciphertext, tag, plaintext, AssociatedData(keyId, context));
var secret = Encoding.UTF8.GetString(plaintext);
CryptographicOperations.ZeroMemory(plaintext);
return secret;
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
{
throw new CredentialException("Adgangskoden kan ikke dekrypteres. Er server eller login ændret, uden at adgangskoden blev indtastet igen? Indtast den igen.", ex);
}
}
private static byte[] AssociatedData(string keyId, string context) => Encoding.UTF8.GetBytes(keyId + "\n" + context);
}
/// <summary>A stored password the agent cannot use. The message is Danish and shown to the user.</summary>
public sealed class CredentialException : Exception
{
public CredentialException()
{
}
public CredentialException(string message)
: base(message)
{
}
public CredentialException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,82 @@
namespace SQLInsight.Core.Configuration;
/// <summary>
/// Which databases on an instance are monitored, as chosen in SQLInsight Web (Opsætning → Overvågede servere). The
/// system databases (master, model, msdb, tempdb) are always monitored: backups of master and msdb and the state of
/// tempdb concern the whole instance. A database nobody has decided on yet (new since the last discovery) follows
/// <see cref="MonitorNew"/>.
/// </summary>
public sealed class DatabaseSelection
{
private static readonly HashSet<string> SystemDatabases = new(["master", "model", "msdb", "tempdb"], StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _monitored;
private readonly HashSet<string> _notMonitored;
private readonly HashSet<string> _dynamicsAxToHub;
/// <summary>Every database, and no Dynamics AX findings to the hub: what 1.2 and earlier did.</summary>
public static readonly DatabaseSelection All = new(true, [], [], []);
public DatabaseSelection(bool monitorNew, IEnumerable<string> monitored, IEnumerable<string> notMonitored, IEnumerable<string> dynamicsAxToHub)
{
ArgumentNullException.ThrowIfNull(monitored);
ArgumentNullException.ThrowIfNull(notMonitored);
ArgumentNullException.ThrowIfNull(dynamicsAxToHub);
MonitorNew = monitorNew;
_monitored = new HashSet<string>(monitored, StringComparer.OrdinalIgnoreCase);
_notMonitored = new HashSet<string>(notMonitored, StringComparer.OrdinalIgnoreCase);
_notMonitored.ExceptWith(_monitored);
_dynamicsAxToHub = new HashSet<string>(dynamicsAxToHub, StringComparer.OrdinalIgnoreCase);
}
/// <summary>Databases not yet decided on (new on the instance) are monitored.</summary>
public bool MonitorNew { get; }
/// <summary>Nothing is left out: every database is monitored.</summary>
public bool IsEverything => MonitorNew && _notMonitored.Count == 0;
public static bool IsSystemDatabase(string database) => SystemDatabases.Contains(database);
/// <summary>Whether the database-specific checks run in <paramref name="database"/>.</summary>
public bool Includes(string database)
{
ArgumentNullException.ThrowIfNull(database);
if (IsSystemDatabase(database) || _monitored.Contains(database))
{
return true;
}
return !_notMonitored.Contains(database) && MonitorNew;
}
/// <summary>
/// Whether findings about Dynamics AX in <paramref name="database"/> may be sent to SQLInsight Hub. Off unless the
/// customer turned it on for that database.
/// </summary>
public bool SendsDynamicsAxToHub(string? database) => database is not null && _dynamicsAxToHub.Contains(database) && Includes(database);
/// <summary>At least one database sends Dynamics AX findings to the hub.</summary>
public bool AnyDynamicsAxToHub => _dynamicsAxToHub.Any(Includes);
/// <summary>Keeps the databases in <paramref name="names"/> that are monitored.</summary>
public List<string> Filter(IEnumerable<string> names) => names.Where(Includes).ToList();
}
/// <summary>
/// Dynamics AX findings and SQLInsight Hub: they stay in the customer's network unless sending is turned on for the
/// database (default off).
/// </summary>
public static class DynamicsAxFindings
{
public const string RulePrefix = "COM-AX-";
private const string DatabaseSubject = "database: ";
public static bool IsDynamicsAxRule(string ruleId) => ruleId?.StartsWith(RulePrefix, StringComparison.OrdinalIgnoreCase) == true;
/// <summary>The database a finding is about, from its subject (<c>database: AX30</c>), or null.</summary>
public static string? DatabaseOf(string? subject) =>
subject is not null && subject.StartsWith(DatabaseSubject, StringComparison.OrdinalIgnoreCase) && subject.Length > DatabaseSubject.Length
? subject[DatabaseSubject.Length..]
: null;
}

View file

@ -0,0 +1,49 @@
using System.Text;
using System.Text.RegularExpressions;
namespace SQLInsight.Core.Configuration;
/// <summary>
/// db/permissions/grant-least-privilege.sql, ready to run for one login: what SQLInsight Web shows for a newly added
/// server. SQLInsight never runs it on the customer's server itself; a sysadmin runs it.
/// </summary>
public static partial class GrantScript
{
/// <summary>The script with <paramref name="login"/> filled in. For a Windows login it starts by creating the login if it is missing.</summary>
public static string For(string login, bool windowsLogin)
{
ArgumentException.ThrowIfNullOrWhiteSpace(login);
var literal = "N'" + login.Trim().Replace("'", "''", StringComparison.Ordinal) + "'";
var script = LoginPlaceholder().Replace(Template(), _ => "SET @LoginName = " + literal + ";", 1);
var header = new StringBuilder();
header.Append("/* Kør som sysadmin på instansen. Giver SQLInsight-agentens login ").Append(InComment(login.Trim())).Append(" de rettigheder, den skal bruge. */\r\n");
if (windowsLogin)
{
header.Append("IF SUSER_ID(").Append(literal).Append(") IS NULL\r\n CREATE LOGIN ")
.Append(Quote(login.Trim())).Append(" FROM WINDOWS;\r\nGO\r\n");
}
else
{
header.Append("/* SQL-login: opret det først, hvis det mangler, fx\r\n CREATE LOGIN ").Append(InComment(Quote(login.Trim())))
.Append(" WITH PASSWORD = N'…', CHECK_POLICY = ON; */\r\n");
}
return header.Append(script).ToString();
}
/// <summary>The script as shipped, with the placeholder login.</summary>
public static string Template()
{
using var stream = typeof(GrantScript).Assembly.GetManifestResourceStream("SQLInsight.Core.grant-least-privilege.sql")
?? throw new InvalidOperationException("grant-least-privilege.sql mangler i programmet.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static string InComment(string text) => text.Replace("*/", "* /", StringComparison.Ordinal).Replace("/*", "/ *", StringComparison.Ordinal);
private static string Quote(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
[GeneratedRegex(@"SET @LoginName = N'[^']*';[^\r\n]*")]
private static partial Regex LoginPlaceholder();
}

View file

@ -0,0 +1,86 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace SQLInsight.Core.Configuration;
/// <summary>How an instance entered in SQLInsight Web is addressed: <c>server</c>, <c>server\instance</c>, optionally a port.</summary>
public static partial class ServerAddress
{
public const int MaxServerLength = 256;
public const int MaxNameLength = 128;
/// <summary>The data source to connect to: <c>server\instance</c>, or <c>server,port</c> when a port is given (it then wins over the instance name).</summary>
public static string DataSource(string server, int? port)
{
ArgumentNullException.ThrowIfNull(server);
return port is { } p ? server.Trim() + "," + p.ToString(CultureInfo.InvariantCulture) : server.Trim();
}
/// <summary>Why <paramref name="server"/> and <paramref name="port"/> cannot be used (Danish), or null when they can.</summary>
public static string? Validate(string? server, int? port)
{
if (string.IsNullOrWhiteSpace(server))
{
return "Angiv serveren, fx sql01 eller sql01\\PROD.";
}
var trimmed = server.Trim();
if (trimmed.Length > MaxServerLength || !ServerPattern().IsMatch(trimmed))
{
return "Servernavnet må kun indeholde bogstaver, tal, punktum, bindestreg, understregning og én omvendt skråstreg før instansnavnet (fx sql01.firma.local\\PROD).";
}
if (port is not null and (< 1 or > 65535))
{
return "Porten skal være mellem 1 og 65535.";
}
return null;
}
/// <summary>Why <paramref name="name"/> cannot be the display name (Danish), or null.</summary>
public static string? ValidateName(string? name) =>
string.IsNullOrWhiteSpace(name) ? "Angiv et navn." :
name.Trim().Length > MaxNameLength ? $"Navnet må højst være {MaxNameLength} tegn." :
name.Any(char.IsControl) ? "Navnet må ikke indeholde kontroltegn." :
null;
[GeneratedRegex(@"^(tcp:)?[A-Za-z0-9._\-]+(\\[A-Za-z0-9_$#\-]+)?$")]
private static partial Regex ServerPattern();
}
/// <summary>
/// The Windows login a service account has on a SQL Server. Virtual and built-in machine accounts (NT SERVICE\…,
/// LocalSystem, NETWORK SERVICE) only exist locally; on another server they arrive as the computer account
/// DOMAIN\COMPUTER$. Domain accounts and gMSAs are the same everywhere.
/// </summary>
public static partial class ServiceAccounts
{
public static string LoginFor(string account, string sqlServer, string machineName, string domain)
{
ArgumentException.ThrowIfNullOrWhiteSpace(account);
var normalized = account.Trim();
if (normalized.Equals("LocalSystem", StringComparison.OrdinalIgnoreCase))
{
normalized = @"NT AUTHORITY\SYSTEM";
}
var machineIdentity = normalized.StartsWith(@"NT SERVICE\", StringComparison.OrdinalIgnoreCase)
|| normalized.StartsWith(@"NT AUTHORITY\", StringComparison.OrdinalIgnoreCase);
return machineIdentity && !IsLocal(sqlServer, machineName) ? $@"{domain}\{machineName}$" : normalized;
}
/// <summary>Whether a SQL Server data source (server, server\instance, tcp:server,port …) is this computer.</summary>
public static bool IsLocal(string sqlServer, string machineName)
{
ArgumentNullException.ThrowIfNull(sqlServer);
var host = ProtocolPrefix().Replace(sqlServer.Trim(), string.Empty).Split('\\', ',')[0].Trim();
return host is "" or "." or "(local)" or "(localdb)" or "127.0.0.1" or "::1"
|| host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| host.Equals(machineName, StringComparison.OrdinalIgnoreCase)
|| host.StartsWith(machineName + ".", StringComparison.OrdinalIgnoreCase);
}
[GeneratedRegex(@"^(tcp|np|lpc|admin):", RegexOptions.IgnoreCase)]
private static partial Regex ProtocolPrefix();
}

View file

@ -2,4 +2,8 @@
<PropertyGroup> <PropertyGroup>
<RootNamespace>SQLInsight.Core</RootNamespace> <RootNamespace>SQLInsight.Core</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<!-- Shown in SQLInsight Web for a newly added server (Configuration/GrantScript.cs). -->
<EmbeddedResource Include="..\..\db\permissions\grant-least-privilege.sql" LogicalName="SQLInsight.Core.grant-least-privilege.sql" />
</ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,164 @@
using System.Security.Cryptography;
using SQLInsight.Core.Configuration;
namespace SQLInsight.Core.Tests;
public sealed class DatabaseSelectionTests
{
[Fact]
public void All_monitors_every_database_and_sends_no_dynamics_ax_findings()
{
Assert.True(DatabaseSelection.All.IsEverything);
Assert.True(DatabaseSelection.All.Includes("Sales"));
Assert.False(DatabaseSelection.All.SendsDynamicsAxToHub("AX30"));
Assert.False(DatabaseSelection.All.AnyDynamicsAxToHub);
}
[Fact]
public void Ticked_databases_are_monitored_unticked_are_not_and_new_ones_follow_the_instance_setting()
{
var selection = new DatabaseSelection(monitorNew: false, ["AX30"], ["Archive"], []);
Assert.True(selection.Includes("ax30"));
Assert.False(selection.Includes("Archive"));
Assert.False(selection.Includes("NewDb"));
Assert.True(new DatabaseSelection(monitorNew: true, ["AX30"], ["Archive"], []).Includes("NewDb"));
Assert.Equal(["AX30"], selection.Filter(["AX30", "Archive", "NewDb"]));
}
[Theory]
[InlineData("master")]
[InlineData("MSDB")]
[InlineData("model")]
[InlineData("tempdb")]
public void System_databases_are_always_monitored(string database)
{
Assert.True(new DatabaseSelection(false, [], [database], []).Includes(database));
}
[Fact]
public void Dynamics_ax_findings_go_to_the_hub_only_for_opted_in_monitored_databases()
{
var selection = new DatabaseSelection(false, ["AX30", "AX40"], ["AXOLD"], ["AX30", "AXOLD"]);
Assert.True(selection.SendsDynamicsAxToHub("AX30"));
Assert.False(selection.SendsDynamicsAxToHub("AX40"));
Assert.False(selection.SendsDynamicsAxToHub("AXOLD")); // opted in, but not monitored
Assert.False(selection.SendsDynamicsAxToHub(null));
Assert.True(selection.AnyDynamicsAxToHub);
}
[Theory]
[InlineData("COM-AX-001", true)]
[InlineData("com-ax-012", true)]
[InlineData("COM-FT-001", false)]
[InlineData("SQLI-DB-001", false)]
public void Dynamics_ax_rules_are_recognised_by_their_id(string ruleId, bool expected) =>
Assert.Equal(expected, DynamicsAxFindings.IsDynamicsAxRule(ruleId));
[Theory]
[InlineData("database: AX30", "AX30")]
[InlineData("database: ", null)]
[InlineData("ag-database: g/r/AX30", null)]
[InlineData(null, null)]
public void Database_is_read_from_the_subject(string? subject, string? expected) =>
Assert.Equal(expected, DynamicsAxFindings.DatabaseOf(subject));
}
public sealed class CredentialEnvelopeTests
{
private static (RSA Key, byte[] PublicKey) NewKey()
{
var rsa = RSA.Create(3072);
return (rsa, rsa.ExportSubjectPublicKeyInfo());
}
[Fact]
public void Sealed_password_opens_only_with_the_agent_key_and_the_same_server_and_login()
{
var (key, publicKey) = NewKey();
using var _ = key;
var context = CredentialEnvelope.Context("sql01\\PROD,1433", "sqlinsight");
var envelope = CredentialEnvelope.Seal("Hemmelig-1!", "agent-1", publicKey, context);
Assert.Equal("agent-1", CredentialEnvelope.KeyIdOf(envelope));
Assert.DoesNotContain("Hemmelig", System.Text.Encoding.UTF8.GetString(envelope), StringComparison.Ordinal);
Assert.Equal("Hemmelig-1!", CredentialEnvelope.Open(envelope, "agent-1", key, CredentialEnvelope.Context("SQL01\\prod,1433", "SQLINSIGHT")));
Assert.Throws<CredentialException>(() => CredentialEnvelope.Open(envelope, "agent-1", key, CredentialEnvelope.Context("evil01", "sqlinsight")));
Assert.Throws<CredentialException>(() => CredentialEnvelope.Open(envelope, "agent-1", key, CredentialEnvelope.Context("sql01\\PROD,1433", "sa")));
Assert.Throws<CredentialException>(() => CredentialEnvelope.Open(envelope, "agent-2", key, context));
}
[Fact]
public void Another_agent_key_or_a_tampered_envelope_cannot_be_opened()
{
var (key, publicKey) = NewKey();
var (other, _) = NewKey();
using var a = key;
using var b = other;
var context = CredentialEnvelope.Context("sql01", "u");
var envelope = CredentialEnvelope.Seal("pw", "k", publicKey, context);
Assert.Throws<CredentialException>(() => CredentialEnvelope.Open(envelope, "k", other, context));
envelope[^1] ^= 0xFF;
Assert.Throws<CredentialException>(() => CredentialEnvelope.Open(envelope, "k", key, context));
Assert.Null(CredentialEnvelope.KeyIdOf([9, 9, 9]));
}
}
public sealed class ServerAddressTests
{
[Theory]
[InlineData("sql01", null, "sql01")]
[InlineData(" sql01\\PROD ", null, "sql01\\PROD")]
[InlineData("sql01.firma.local\\PROD", 1450, "sql01.firma.local\\PROD,1450")]
public void Data_source_adds_the_port(string server, int? port, string expected) =>
Assert.Equal(expected, ServerAddress.DataSource(server, port));
[Theory]
[InlineData("sql01", null, true)]
[InlineData("sql01\\PROD", 1433, true)]
[InlineData("tcp:sql01.firma.local", null, true)]
[InlineData("", null, false)]
[InlineData("sql01;Integrated Security=false", null, false)]
[InlineData("sql01,1433", null, false)]
[InlineData("sql01\\a\\b", null, false)]
[InlineData("sql01", 0, false)]
[InlineData("sql01", 70000, false)]
public void Server_and_port_are_validated(string server, int? port, bool valid) =>
Assert.Equal(valid, ServerAddress.Validate(server, port) is null);
[Theory]
[InlineData(@"NT SERVICE\SQLInsight Agent", "sql01", "AGENT01", "FIRMA", @"FIRMA\AGENT01$")]
[InlineData(@"NT SERVICE\SQLInsight Agent", "agent01\\PROD", "AGENT01", "FIRMA", @"NT SERVICE\SQLInsight Agent")]
[InlineData(@"FIRMA\svc-sqlinsight$", "sql01", "AGENT01", "FIRMA", @"FIRMA\svc-sqlinsight$")]
[InlineData("LocalSystem", "sql01", "AGENT01", "FIRMA", @"FIRMA\AGENT01$")]
public void Service_account_login_on_the_server(string account, string server, string machine, string domain, string expected) =>
Assert.Equal(expected, ServiceAccounts.LoginFor(account, server, machine, domain));
}
public sealed class GrantScriptTests
{
[Fact]
public void Script_is_filled_in_for_the_login_and_creates_a_missing_windows_login()
{
var script = GrantScript.For(@"FIRMA\AGENT01$", windowsLogin: true);
Assert.Contains(@"SET @LoginName = N'FIRMA\AGENT01$';", script, StringComparison.Ordinal);
Assert.DoesNotContain("SET @LoginName = N'DOMÆNE", script, StringComparison.Ordinal);
Assert.Contains(@"CREATE LOGIN [FIRMA\AGENT01$] FROM WINDOWS;", script, StringComparison.Ordinal);
Assert.Contains("VIEW SERVER STATE", script, StringComparison.Ordinal);
Assert.DoesNotContain("PXI_BATCHMONITORTABLE", script, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Quotes_in_a_login_are_escaped()
{
var script = GrantScript.For("o'brien]", windowsLogin: false);
Assert.Contains("SET @LoginName = N'o''brien]';", script, StringComparison.Ordinal);
Assert.Contains("CREATE LOGIN [o'brien]]]", script, StringComparison.Ordinal);
Assert.DoesNotContain("IF SUSER_ID(", script, StringComparison.Ordinal);
}
}