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 { ["LOCAL-SEC-001"] = new([new Dictionary { ["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 { ["LOCAL-SEC-001"] = new([], "denied", PermissionDenied: true) })); Assert.Equal(RuleOutcome.NotMeasurable, Assert.Single(engine.Evaluate(context)).Outcome); context.Data.Set(new CustomQueryResults(new Dictionary { ["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 { ["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(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(() => 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; } }