On SQL Server 2005 twenty rules stood as "not measurable" (Query Store, system_health, memory columns …) between the real gaps, and counted in the overview's "regler ikke målbare". They are not gaps: the version simply lacks what they look at. - New RuleOutcome.NotApplicable (last in the enum: outcomes are stored as numbers, so existing rows keep their meaning). - A rule or collector missing only capabilities inherent to the instance (version-gated, plan cache memory columns, or Azure-managed) is not applicable, with the version each needs: "Ikke relevant for SQL Server 2005: kræver QueryStore (fra SQL Server 2016)". Missing settings or rights (HADR not enabled …), collector failures and permission errors stay "not measurable". CollectedData.MarkNotApplicable carries this from collectors to the rules that use their data; community rules below their minimumVersion are not applicable too. - Counts are unchanged in shape: the overview, KPI, JSON API and Prometheus count outcomes 2 and 3 only. The instance page lists the rest in a collapsed "Ikke relevant for SQL Server 2005 (n)" (details/summary, no JS) after the real gaps. Report controls whose core rule does not apply count neither as met nor as not measured. Tests: engine (version vs. setting vs. collected data), inherent capabilities, the existing version tests now expect not applicable, report control status, and the web page and overview count. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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_applicable()
|
|
{
|
|
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.NotApplicable, 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;
|
|
}
|
|
}
|