Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Rules can now be data: a JSON file with one read-only SELECT where every
row is a finding, with {column} templates, server or per-database scope,
minimum version, sensitive columns and references. Community rules live
in rules/community (reviewed via pull request), ship with every build
and with the signed rule pack; local rules are read from the agent's
data folder every five minutes. Queries are checked for anything that
could write, run with the least-privilege login, and a missing
permission makes the rule not measurable rather than failing. The
rule engine picks up catalog changes without a restart. Authors get
'rules validate' and 'rules test --instance'. Four example community
rules (sa active, orphaned database owners, default trace off,
untrusted foreign keys).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
127 lines
5.5 KiB
C#
127 lines
5.5 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using SQLInsight.Agent;
|
|
using SQLInsight.Agent.Monitoring;
|
|
using SQLInsight.Collectors;
|
|
using SQLInsight.Connectivity;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Repository;
|
|
using SQLInsight.Rules;
|
|
using SQLInsight.Rules.Engine;
|
|
|
|
namespace SQLInsight.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// The agent's real pipeline end to end: least-privilege connection, all collectors, all rules, repository.
|
|
/// </summary>
|
|
[Collection(SqlServerTestGroup.Name)]
|
|
[Trait("Category", "Integration")]
|
|
public class AgentPipelineTests(SqlServerFixture sql) : IAsyncLifetime
|
|
{
|
|
private readonly string _database = "SQLInsightAgent_" + Guid.NewGuid().ToString("N")[..8];
|
|
private InsightRepository _repository = null!;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
|
|
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
|
|
await _repository.MigrateAsync();
|
|
}
|
|
|
|
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
|
|
|
|
private InstanceMonitor Monitor(string name, string? password = null) => new(
|
|
new MonitoredInstance
|
|
{
|
|
Name = name,
|
|
Server = sql.Server,
|
|
Authentication = AuthenticationMode.SqlLogin,
|
|
UserName = SqlServerFixture.AgentLogin,
|
|
Password = password ?? SqlServerFixture.AgentPassword,
|
|
Provider = ConnectionProvider.SqlClient,
|
|
TrustServerCertificate = true,
|
|
},
|
|
BuiltInCollectors.Create(Community),
|
|
new Dictionary<string, TimeSpan>(),
|
|
new SqlConnectionFactory(),
|
|
new RuleEngine(BuiltInRules.Create(), Community),
|
|
_repository,
|
|
NullLogger.Instance);
|
|
|
|
/// <summary>The community rules that ship with the build run in the same cycle as the built-in collectors.</summary>
|
|
private static readonly SQLInsight.Core.CustomRules.CustomRuleCatalog Community = CommunityCatalog();
|
|
|
|
private static SQLInsight.Core.CustomRules.CustomRuleCatalog CommunityCatalog()
|
|
{
|
|
var catalog = new SQLInsight.Core.CustomRules.CustomRuleCatalog();
|
|
catalog.Replace(SQLInsight.Rules.Lifecycle.RulePack.Embedded().CommunityRules);
|
|
return catalog;
|
|
}
|
|
|
|
private static int RuleCount => BuiltInRules.Create().Count + Community.Rules.Count;
|
|
|
|
[Fact]
|
|
public async Task Two_cycles_store_findings_rule_states_and_time_series()
|
|
{
|
|
var monitor = Monitor("container");
|
|
var start = DateTimeOffset.UtcNow;
|
|
|
|
var first = await monitor.RunCycleAsync(start, CancellationToken.None);
|
|
await sql.ExecuteAsSaAsync("SELECT COUNT(*) FROM sys.objects a CROSS JOIN sys.objects b;");
|
|
var nothingDue = await monitor.RunCycleAsync(start.AddSeconds(10), CancellationToken.None);
|
|
var second = await monitor.RunCycleAsync(start.AddMinutes(16), CancellationToken.None);
|
|
|
|
Assert.NotNull(first);
|
|
Assert.Null(nothingDue);
|
|
Assert.NotNull(second);
|
|
Assert.Equal(RuleCount, second.Count);
|
|
Assert.DoesNotContain(second, r => r.Outcome == RuleOutcome.Error);
|
|
|
|
var instance = await _repository.GetInstanceAsync("container");
|
|
Assert.NotNull(instance);
|
|
Assert.False(instance.IsFailing);
|
|
|
|
var findings = await _repository.GetActiveFindingsAsync(instance.InstanceId);
|
|
// A fresh container has well-known problems: default max server memory and master never backed up.
|
|
Assert.Contains(findings, f => f.RuleId == "SQLI-CFG-001" && f.Severity == Severity.High);
|
|
Assert.Contains(findings, f => f.RuleId == "SQLI-DB-007" && f.Subject == "database: master");
|
|
|
|
var states = await _repository.GetRuleStatesAsync(instance.InstanceId);
|
|
Assert.Equal(RuleCount, states.Count);
|
|
Assert.Contains(findings, f => f.RuleId == "COM-SEC-001");
|
|
|
|
// Wait stats need two samples; after the second cycle they are measurable.
|
|
Assert.NotEqual(RuleOutcome.NotMeasurable, states.Single(s => s.RuleId == "SQLI-WAIT-003").Outcome);
|
|
Assert.NotEmpty(await _repository.GetTopWaitsAsync(instance.InstanceId, start.AddHours(-1)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Restarted_agent_does_not_report_or_store_deadlocks_again()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
await Monitor("restart").RunCycleAsync(now, CancellationToken.None);
|
|
var instance = await _repository.GetInstanceAsync("restart");
|
|
var stored = (await _repository.GetDeadlocksAsync(instance!.InstanceId, now.AddDays(-2))).Count;
|
|
|
|
// A new monitor is what the agent has after a restart: no memory of where it stopped.
|
|
var results = await Monitor("restart").RunCycleAsync(now.AddMinutes(1), CancellationToken.None);
|
|
|
|
Assert.Equal(stored, (await _repository.GetDeadlocksAsync(instance.InstanceId, now.AddDays(-2))).Count);
|
|
if (stored > 0)
|
|
{
|
|
Assert.Equal(RuleOutcome.Passed, results!.Single(r => r.RuleId == "SQLI-DLK-001").Outcome);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unreachable_instance_is_recorded_as_failing()
|
|
{
|
|
var monitor = Monitor("wrong-password", password: "not-the-password");
|
|
|
|
var result = await monitor.RunCycleAsync(DateTimeOffset.UtcNow, CancellationToken.None);
|
|
|
|
Assert.Null(result);
|
|
var instance = await _repository.GetInstanceAsync("wrong-password");
|
|
Assert.True(instance!.IsFailing);
|
|
Assert.Contains("Login failed", instance.LastError, StringComparison.Ordinal);
|
|
}
|
|
}
|