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:
parent
2284e7f082
commit
8776faefb3
7 changed files with 695 additions and 0 deletions
150
src/SQLInsight.Core/Configuration/AgentRequests.cs
Normal file
150
src/SQLInsight.Core/Configuration/AgentRequests.cs
Normal 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; } = [];
|
||||
}
|
||||
160
src/SQLInsight.Core/Configuration/CredentialEnvelope.cs
Normal file
160
src/SQLInsight.Core/Configuration/CredentialEnvelope.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
82
src/SQLInsight.Core/Configuration/DatabaseSelection.cs
Normal file
82
src/SQLInsight.Core/Configuration/DatabaseSelection.cs
Normal 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;
|
||||
}
|
||||
49
src/SQLInsight.Core/Configuration/GrantScript.cs
Normal file
49
src/SQLInsight.Core/Configuration/GrantScript.cs
Normal 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();
|
||||
}
|
||||
86
src/SQLInsight.Core/Configuration/ServerAddress.cs
Normal file
86
src/SQLInsight.Core/Configuration/ServerAddress.cs
Normal 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();
|
||||
}
|
||||
|
|
@ -2,4 +2,8 @@
|
|||
<PropertyGroup>
|
||||
<RootNamespace>SQLInsight.Core</RootNamespace>
|
||||
</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>
|
||||
|
|
|
|||
164
tests/SQLInsight.Core.Tests/InstanceConfigurationTests.cs
Normal file
164
tests/SQLInsight.Core.Tests/InstanceConfigurationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue