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
177 lines
7.5 KiB
C#
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;
|
|
}
|
|
}
|