sqlinsight-pro/tests/SQLInsight.Rules.Tests/CustomRuleTests.cs
Claude 7f640e74e5
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
Fase 4: community and local rules written as SQL
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
2026-09-29 10:23:01 +00:00

177 lines
7.5 KiB
C#

using SQLInsight.Core.CustomRules;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Custom;
using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Lifecycle;
namespace SQLInsight.Rules.Tests;
public class CustomRuleTests
{
private const string Valid = """
{
// A comment is allowed.
"id": "LOCAL-SEC-001",
"title": "Test",
"severity": "High",
"query": "SELECT name AS [login], 'x;y' AS [note] FROM sys.server_principals WHERE is_disabled = 0 -- DROP TABLE nope",
"subject": "login: {login}",
"findingTitle": "Login {login} er aktivt",
"explanation": "Fordi {login}.",
"recommendation": "Deaktivér {login}; {ukendt} bliver stående.",
"sensitiveColumns": [ "login" ],
}
""";
private static CustomRuleDefinition Rule(string json = Valid) => Assert.Single(CustomRuleLoader.Parse(json, "test.json", "lokal").Rules);
[Fact]
public void Valid_rule_loads_with_comments_trailing_commas_and_keywords_inside_strings_or_comments()
{
var rule = Rule();
Assert.Equal("LOCAL-SEC-001", rule.Id);
Assert.Equal(Severity.High, rule.Severity);
Assert.Equal("lokal", rule.Origin);
}
[Theory]
[InlineData("UPDATE dbo.t SET x = 1", "SELECT")]
[InlineData("SELECT 1; DROP TABLE dbo.t", "DROP")]
[InlineData("SELECT * INTO #t FROM sys.objects", "INTO")]
[InlineData("SELECT 1 EXEC('x')", "EXEC")]
[InlineData("WITH x AS (SELECT 1 AS a) SELECT * FROM x; SELECT 2", "én sætning")]
[InlineData("SELECT * FROM OPENROWSET('SQLNCLI', 'x', 'y')", "OPENROWSET")]
[InlineData("SELECT dbo.fn() FROM sys.objects WHERE xp_cmdshell = 1", "XP_CMDSHELL")]
public void Queries_that_could_change_anything_are_refused(string query, string expected)
{
var problems = CustomRuleDefinition.QueryProblems(query).ToList();
Assert.Contains(problems, p => p.Contains(expected, StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData("SELECT [update], [into] FROM dbo.audit")]
[InlineData("WITH d AS (SELECT name FROM sys.databases) SELECT * FROM d;")]
[InlineData("SELECT 'DELETE; EXEC' AS text /* INSERT */")]
public void Read_only_queries_are_accepted(string query) => Assert.Empty(CustomRuleDefinition.QueryProblems(query));
[Theory]
[InlineData("SQLI-SEC-001")]
[InlineData("COM-SEC-001")]
[InlineData("LOCAL-SEC-1")]
public void Ids_must_be_local_or_community_and_match_the_origin(string id)
{
var result = CustomRuleLoader.Parse(Valid.Replace("LOCAL-SEC-001", id, StringComparison.Ordinal), "test.json", "lokal");
Assert.Empty(result.Rules);
Assert.NotEmpty(result.Errors);
}
[Fact]
public void Broken_json_and_duplicates_are_reported_not_thrown()
{
Assert.Single(CustomRuleLoader.Parse("{ nope", "x.json", "lokal").Errors);
var combined = CustomRuleLoader.Combine([Rule(), Rule()], []);
Assert.Single(combined.Rules);
Assert.Single(combined.Errors);
}
[Fact]
public void Rows_become_findings_with_filled_texts_and_sensitive_evidence()
{
var context = TestInstances.Context("16.0.4175.1");
context.Data.Set(new CustomQueryResults(new Dictionary<string, CustomQueryResult>
{
["LOCAL-SEC-001"] = new([new Dictionary<string, string?> { ["login"] = "sa", ["note"] = null }]),
}));
var evaluation = new DeclarativeRule(Rule()).Evaluate(context);
var finding = Assert.Single(evaluation.Findings);
Assert.Equal("Login sa er aktivt", finding.Title);
Assert.Equal("login: sa", finding.Subject);
Assert.Equal("Deaktivér sa; {ukendt} bliver stående.", finding.Recommendation);
Assert.EndsWith("(Lokal regel.)", finding.Explanation, StringComparison.Ordinal);
Assert.True(finding.Evidence.Single(e => e.Name == "login").Sensitive);
Assert.Equal("(null)", finding.Evidence.Single(e => e.Name == "note").Value);
}
[Fact]
public void Permission_errors_are_not_measurable_and_other_errors_are_errors()
{
var engine = new RuleEngine([], Catalog(Rule()));
var context = TestInstances.Context("16.0.4175.1");
context.Data.Set(new CustomQueryResults(new Dictionary<string, CustomQueryResult> { ["LOCAL-SEC-001"] = new([], "denied", PermissionDenied: true) }));
Assert.Equal(RuleOutcome.NotMeasurable, Assert.Single(engine.Evaluate(context)).Outcome);
context.Data.Set(new CustomQueryResults(new Dictionary<string, CustomQueryResult> { ["LOCAL-SEC-001"] = new([], "Invalid column name 'x'.") }));
var result = Assert.Single(engine.Evaluate(context));
Assert.Equal(RuleOutcome.Error, result.Outcome);
Assert.Contains("Invalid column name", result.Reason, StringComparison.Ordinal);
context.Data.Set(new CustomQueryResults(new Dictionary<string, CustomQueryResult> { ["LOCAL-SEC-001"] = new([]) }));
Assert.Equal(RuleOutcome.Passed, Assert.Single(engine.Evaluate(context)).Outcome);
}
[Fact]
public void Rules_needing_a_newer_version_are_not_measurable()
{
var rule = Rule(Valid.Replace("\"severity\"", "\"minimumVersion\": \"13.0\", \"severity\"", StringComparison.Ordinal));
var result = Assert.Single(new RuleEngine([], Catalog(rule)).Evaluate(TestInstances.Context("11.0.7001.0")));
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
Assert.Contains("13.0", result.Reason, StringComparison.Ordinal);
}
[Fact]
public void Catalog_changes_apply_without_a_new_engine()
{
var catalog = new CustomRuleCatalog();
var engine = new RuleEngine(BuiltInRules.Create(), catalog);
var builtIn = engine.Rules.Count;
catalog.Replace([Rule()]);
Assert.Equal(builtIn + 1, engine.Rules.Count);
Assert.IsType<DeclarativeRule>(engine.Rules[^1]);
}
[Fact]
public void Community_rules_in_the_repository_are_valid_and_ship_with_the_build()
{
var pack = RulePack.Embedded();
Assert.True(pack.CommunityRules.Count >= 4);
Assert.All(pack.CommunityRules, r => Assert.Equal("community", r.Origin));
Assert.All(pack.CommunityRules, r => Assert.StartsWith("COM-", r.Id, StringComparison.Ordinal));
}
[Fact]
public void Rule_pack_carries_community_rules()
{
var root = AppContext.BaseDirectory;
while (!File.Exists(Path.Combine(root, "SQLInsight.slnx")))
{
root = Path.GetDirectoryName(root)!;
}
var lifecycle = Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle");
var bytes = RulePack.Create(new Version(2099, 1, 1), File.ReadAllBytes(Path.Combine(lifecycle, "build-catalog.json")), File.ReadAllBytes(Path.Combine(lifecycle, "support-lifecycle.json")),
System.Text.Encoding.UTF8.GetBytes("[" + Valid.Replace("LOCAL-", "COM-", StringComparison.Ordinal) + "]"));
var pack = RulePack.Parse(bytes, "test");
Assert.Equal("COM-SEC-001", Assert.Single(pack.CommunityRules).Id);
Assert.Throws<InvalidDataException>(() => RulePack.Create(new Version(2099, 1, 1), File.ReadAllBytes(Path.Combine(lifecycle, "build-catalog.json")),
File.ReadAllBytes(Path.Combine(lifecycle, "support-lifecycle.json")), System.Text.Encoding.UTF8.GetBytes("[" + Valid + "]")));
}
private static CustomRuleCatalog Catalog(params CustomRuleDefinition[] rules)
{
var catalog = new CustomRuleCatalog();
catalog.Replace(rules);
return catalog;
}
}