diff --git a/src/SQLInsight.Core/Configuration/AgentRequests.cs b/src/SQLInsight.Core/Configuration/AgentRequests.cs new file mode 100644 index 0000000..3e4c5fe --- /dev/null +++ b/src/SQLInsight.Core/Configuration/AgentRequests.cs @@ -0,0 +1,150 @@ +using System.Text.Json; + +namespace SQLInsight.Core.Configuration; + +/// +/// 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. +/// +public static class AgentRequestKind +{ + /// Connect to a configured instance and check the agent's rights there. + public const string ConnectionTest = "ConnectionTest"; + + /// List the databases on a configured instance (state, size, recovery model, Dynamics AX …). + public const string DatabaseDiscovery = "DatabaseDiscovery"; + + /// Find SQL Server instances on a computer (SQL Server Browser, and the registry on the agent's own computer). + public const string InstanceDiscovery = "InstanceDiscovery"; + + public static readonly IReadOnlyList 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"; +} + +/// JSON of request parameters and results, shared by the agent (writes) and the web UI and API (reads). +public static class AgentRequestJson +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + public static string Serialize(T value) => JsonSerializer.Serialize(value, Options); + + public static T? Deserialize(string? json) + where T : class + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json, Options); + } + catch (JsonException) + { + return null; + } + } +} + +/// Parameters of an . +/// The computer to ask; empty for the agent's own computer. +public sealed record InstanceDiscoveryParameters(string? Host); + +/// One right the agent needs (or may have) on an instance, and whether it has it. +/// The permission as written in GRANT, e.g. VIEW SERVER STATE, or SELECT ON msdb.dbo.backupset. +/// False for optional rights (ALTER TRACE): missing them limits a feature, not the monitoring. +/// What SQLInsight uses it for (Danish). +public sealed record PermissionStatus(string Name, bool Granted, bool Required, string Purpose); + +/// The outcome of a connection test, as the agent saw it. +public sealed record ConnectionTestResult +{ + public bool Connected { get; init; } + public string? Error { get; init; } + + /// What to do about (Danish). + 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; } + + /// The login the agent arrived as (SUSER_SNAME()): the one the GRANT script is for. + public string? Login { get; init; } + + /// The agent is sysadmin on the instance. It works, but it is more than it needs. + public bool IsSysadmin { get; init; } + + /// False on SQL Server 2000, which cannot report effective permissions (no HAS_PERMS_BY_NAME). + public bool PermissionsChecked { get; init; } + + public IReadOnlyList Permissions { get; init; } = []; + + /// Notes about the result, e.g. why permissions were not checked (Danish). + public IReadOnlyList Notes { get; init; } = []; + + public IEnumerable MissingRequired => Permissions.Where(p => p.Required && !p.Granted); +} + +/// A database as the agent found it on an instance. +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; } + + /// A database snapshot (not monitored on its own). + public bool IsSnapshot { get; init; } + + /// The agent can enter the database (HAS_DBACCESS); without it, database-specific checks cannot run there. + public bool HasAccess { get; init; } + + /// The Dynamics AX system tables (SQLDICTIONARY, DATAAREA, SYSCONFIG) are there. + public bool IsDynamicsAx { get; init; } + + /// "3.0" (32-bit RECID) or "4.0+" (64-bit RECID), when . + public string? DynamicsAxVersion { get; init; } +} + +/// Summary of a database discovery; the databases themselves are in insight.ConfiguredDatabases. +public sealed record DatabaseDiscoveryResult +{ + public int Databases { get; init; } + public IReadOnlyList New { get; init; } = []; + public IReadOnlyList Gone { get; init; } = []; + + /// New databases were monitored at once (the instance monitors new databases automatically). + public bool NewMonitored { get; init; } + + public string? Error { get; init; } + public string? Hint { get; init; } +} + +/// A SQL Server instance found on a computer. +/// What to type as the server: HOST or HOST\INSTANCE. +/// Where it was found (Danish): "SQL Server Browser" or "registreringsdatabasen". +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 Instances { get; init; } = []; + public IReadOnlyList Notes { get; init; } = []; +} diff --git a/src/SQLInsight.Core/Configuration/CredentialEnvelope.cs b/src/SQLInsight.Core/Configuration/CredentialEnvelope.cs new file mode 100644 index 0000000..2a037e7 --- /dev/null +++ b/src/SQLInsight.Core/Configuration/CredentialEnvelope.cs @@ -0,0 +1,160 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace SQLInsight.Core.Configuration; + +/// +/// A SQL login password at rest in the repository, readable only by the agent. +/// +/// 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. +/// +/// +/// The envelope is bound to the server and login it was entered for (): 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. +/// +/// Format: version (1) · key id length (1) · key id (UTF-8) · wrapped key length (2, big-endian) · wrapped key · nonce (12) · tag (16) · ciphertext. +/// +public static class CredentialEnvelope +{ + public const byte FormatVersion = 1; + private const int NonceSize = 12; + private const int TagSize = 16; + private const int KeySize = 32; + + /// What a password is bound to: the server (with port) and the login it was entered for. + public static string Context(string server, string? userName) => + "sqlinsight-credential-v1\n" + (server ?? string.Empty).Trim().ToUpperInvariant() + "\n" + (userName ?? string.Empty).Trim().ToUpperInvariant(); + + /// Seals for the agent whose public key (SubjectPublicKeyInfo, DER) is given. + 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); + } + } + + /// The id of the agent key an envelope was sealed for, or null if it is not an envelope. + 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]); + } + + /// Opens an envelope with the agent's private key. Throws when it cannot. + 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); +} + +/// A stored password the agent cannot use. The message is Danish and shown to the user. +public sealed class CredentialException : Exception +{ + public CredentialException() + { + } + + public CredentialException(string message) + : base(message) + { + } + + public CredentialException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/SQLInsight.Core/Configuration/DatabaseSelection.cs b/src/SQLInsight.Core/Configuration/DatabaseSelection.cs new file mode 100644 index 0000000..2d4d157 --- /dev/null +++ b/src/SQLInsight.Core/Configuration/DatabaseSelection.cs @@ -0,0 +1,82 @@ +namespace SQLInsight.Core.Configuration; + +/// +/// 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 +/// . +/// +public sealed class DatabaseSelection +{ + private static readonly HashSet SystemDatabases = new(["master", "model", "msdb", "tempdb"], StringComparer.OrdinalIgnoreCase); + + private readonly HashSet _monitored; + private readonly HashSet _notMonitored; + private readonly HashSet _dynamicsAxToHub; + + /// Every database, and no Dynamics AX findings to the hub: what 1.2 and earlier did. + public static readonly DatabaseSelection All = new(true, [], [], []); + + public DatabaseSelection(bool monitorNew, IEnumerable monitored, IEnumerable notMonitored, IEnumerable dynamicsAxToHub) + { + ArgumentNullException.ThrowIfNull(monitored); + ArgumentNullException.ThrowIfNull(notMonitored); + ArgumentNullException.ThrowIfNull(dynamicsAxToHub); + MonitorNew = monitorNew; + _monitored = new HashSet(monitored, StringComparer.OrdinalIgnoreCase); + _notMonitored = new HashSet(notMonitored, StringComparer.OrdinalIgnoreCase); + _notMonitored.ExceptWith(_monitored); + _dynamicsAxToHub = new HashSet(dynamicsAxToHub, StringComparer.OrdinalIgnoreCase); + } + + /// Databases not yet decided on (new on the instance) are monitored. + public bool MonitorNew { get; } + + /// Nothing is left out: every database is monitored. + public bool IsEverything => MonitorNew && _notMonitored.Count == 0; + + public static bool IsSystemDatabase(string database) => SystemDatabases.Contains(database); + + /// Whether the database-specific checks run in . + public bool Includes(string database) + { + ArgumentNullException.ThrowIfNull(database); + if (IsSystemDatabase(database) || _monitored.Contains(database)) + { + return true; + } + + return !_notMonitored.Contains(database) && MonitorNew; + } + + /// + /// Whether findings about Dynamics AX in may be sent to SQLInsight Hub. Off unless the + /// customer turned it on for that database. + /// + public bool SendsDynamicsAxToHub(string? database) => database is not null && _dynamicsAxToHub.Contains(database) && Includes(database); + + /// At least one database sends Dynamics AX findings to the hub. + public bool AnyDynamicsAxToHub => _dynamicsAxToHub.Any(Includes); + + /// Keeps the databases in that are monitored. + public List Filter(IEnumerable names) => names.Where(Includes).ToList(); +} + +/// +/// Dynamics AX findings and SQLInsight Hub: they stay in the customer's network unless sending is turned on for the +/// database (default off). +/// +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; + + /// The database a finding is about, from its subject (database: AX30), or null. + public static string? DatabaseOf(string? subject) => + subject is not null && subject.StartsWith(DatabaseSubject, StringComparison.OrdinalIgnoreCase) && subject.Length > DatabaseSubject.Length + ? subject[DatabaseSubject.Length..] + : null; +} diff --git a/src/SQLInsight.Core/Configuration/GrantScript.cs b/src/SQLInsight.Core/Configuration/GrantScript.cs new file mode 100644 index 0000000..57eb0a6 --- /dev/null +++ b/src/SQLInsight.Core/Configuration/GrantScript.cs @@ -0,0 +1,49 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace SQLInsight.Core.Configuration; + +/// +/// 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. +/// +public static partial class GrantScript +{ + /// The script with filled in. For a Windows login it starts by creating the login if it is missing. + 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(); + } + + /// The script as shipped, with the placeholder login. + 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(); +} diff --git a/src/SQLInsight.Core/Configuration/ServerAddress.cs b/src/SQLInsight.Core/Configuration/ServerAddress.cs new file mode 100644 index 0000000..7ac9c35 --- /dev/null +++ b/src/SQLInsight.Core/Configuration/ServerAddress.cs @@ -0,0 +1,86 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace SQLInsight.Core.Configuration; + +/// How an instance entered in SQLInsight Web is addressed: server, server\instance, optionally a port. +public static partial class ServerAddress +{ + public const int MaxServerLength = 256; + public const int MaxNameLength = 128; + + /// The data source to connect to: server\instance, or server,port when a port is given (it then wins over the instance name). + public static string DataSource(string server, int? port) + { + ArgumentNullException.ThrowIfNull(server); + return port is { } p ? server.Trim() + "," + p.ToString(CultureInfo.InvariantCulture) : server.Trim(); + } + + /// Why and cannot be used (Danish), or null when they can. + 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; + } + + /// Why cannot be the display name (Danish), or null. + 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(); +} + +/// +/// 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. +/// +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; + } + + /// Whether a SQL Server data source (server, server\instance, tcp:server,port …) is this computer. + 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(); +} diff --git a/src/SQLInsight.Core/SQLInsight.Core.csproj b/src/SQLInsight.Core/SQLInsight.Core.csproj index b1a5cac..d26d127 100644 --- a/src/SQLInsight.Core/SQLInsight.Core.csproj +++ b/src/SQLInsight.Core/SQLInsight.Core.csproj @@ -2,4 +2,8 @@ SQLInsight.Core + + + + diff --git a/tests/SQLInsight.Core.Tests/InstanceConfigurationTests.cs b/tests/SQLInsight.Core.Tests/InstanceConfigurationTests.cs new file mode 100644 index 0000000..80d67ea --- /dev/null +++ b/tests/SQLInsight.Core.Tests/InstanceConfigurationTests.cs @@ -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(() => CredentialEnvelope.Open(envelope, "agent-1", key, CredentialEnvelope.Context("evil01", "sqlinsight"))); + Assert.Throws(() => CredentialEnvelope.Open(envelope, "agent-1", key, CredentialEnvelope.Context("sql01\\PROD,1433", "sa"))); + Assert.Throws(() => 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(() => CredentialEnvelope.Open(envelope, "k", other, context)); + envelope[^1] ^= 0xFF; + Assert.Throws(() => 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); + } +}