Fase 4: community and local rules written as SQL
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
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
This commit is contained in:
parent
d238580614
commit
7f640e74e5
20 changed files with 1189 additions and 16 deletions
|
|
@ -38,6 +38,10 @@ Warnings behandles som fejl, og .NET-analyzers kører på niveauet `latest-recom
|
||||||
4. Skriv unit tests for alle udfald: fundet, ikke fundet, grænseværdier og versioner, hvor reglen ikke kan måles.
|
4. Skriv unit tests for alle udfald: fundet, ikke fundet, grænseværdier og versioner, hvor reglen ikke kan måles.
|
||||||
5. Registrér reglen i `BuiltInRules`.
|
5. Registrér reglen i `BuiltInRules`.
|
||||||
|
|
||||||
|
### Uden C#: community-regler
|
||||||
|
|
||||||
|
Mange regler kan skrives som en SQL-forespørgsel i en JSON-fil under `rules/community/`. De kræver ingen C# og kan afprøves direkte mod en instans med `SQLInsight.Agent rules test`. Se [docs/regler.md](docs/regler.md).
|
||||||
|
|
||||||
## Queries mod overvågede servere
|
## Queries mod overvågede servere
|
||||||
|
|
||||||
- De skal være **read-only** og kunne køre med rettighederne fra `db/permissions/grant-least-privilege.sql`. Kræver de mere, skal scriptet og `LeastPrivilegeTests` opdateres i samme ændring.
|
- De skal være **read-only** og kunne køre med rettighederne fra `db/permissions/grant-least-privilege.sql`. Kræver de mere, skal scriptet og `LeastPrivilegeTests` opdateres i samme ændring.
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ Hvert **finding** indeholder:
|
||||||
### Fase 4: Videre
|
### Fase 4: Videre
|
||||||
- [ ] Anbefalinger på tværs af kunder: "denne CU har løst problemet hos andre"
|
- [ ] Anbefalinger på tværs af kunder: "denne CU har løst problemet hos andre"
|
||||||
- [x] Rapporter (PDF) til ledelse og compliance: nøgletal, sammenfatning i almindeligt sprog, kontroller, instanser og alvorlige fund. Kan hentes i webbrugerfladen og sendes månedligt på mail. Se [docs/rapporter.md](docs/rapporter.md)
|
- [x] Rapporter (PDF) til ledelse og compliance: nøgletal, sammenfatning i almindeligt sprog, kontroller, instanser og alvorlige fund. Kan hentes i webbrugerfladen og sendes månedligt på mail. Se [docs/rapporter.md](docs/rapporter.md)
|
||||||
- [ ] Mulighed for, at community selv bidrager med regler
|
- [x] Mulighed for, at community selv bidrager med regler: regler som SQL i JSON (community via pull request og signeret regelpakke, lokale regler hos kunden), med validering og `rules test` mod en instans. Se [docs/regler.md](docs/regler.md)
|
||||||
- [x] API til integration med eksisterende monitorering: JSON-API og Prometheus-endpoint med API-tokens, Grafana-dashboard, og Windows' hændelseslog med faste event-id'er til SCOM. Se [docs/integration.md](docs/integration.md)
|
- [x] API til integration med eksisterende monitorering: JSON-API og Prometheus-endpoint med API-tokens, Grafana-dashboard, og Windows' hændelseslog med faste event-id'er til SCOM. Se [docs/integration.md](docs/integration.md)
|
||||||
|
|
||||||
## Understøttede versioner
|
## Understøttede versioner
|
||||||
|
|
|
||||||
76
docs/regler.md
Normal file
76
docs/regler.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Egne regler og community-regler
|
||||||
|
|
||||||
|
De indbyggede regler er skrevet i C#. Derudover kan regler skrives som **data**: en skrivebeskyttet SQL-forespørgsel, hvor hver række, den returnerer, bliver til et fund. Det kræver ingen programmering og ingen ny version af SQLInsight Pro.
|
||||||
|
|
||||||
|
| | Community-regler | Lokale regler |
|
||||||
|
|---|---|---|
|
||||||
|
| Id | `COM-OMRÅDE-123` | `LOCAL-OMRÅDE-123` |
|
||||||
|
| Hvor | `rules/community/` i repositoriet | `rules`-mappen i agentens datamappe (`%ProgramData%\SQLInsight\rules` eller `/var/lib/sqlinsight/rules`) |
|
||||||
|
| Hvem | Alle, via pull request. Reviewes før de kommer med | Kunden selv, fx interne standarder |
|
||||||
|
| Levering | Følger med hver version og med den signerede regelpakke | Læses af agenten hvert 5. minut |
|
||||||
|
|
||||||
|
Fundene vises, sendes (notifikationer, Hub, API) og indgår i rapporter som alle andre fund. Forklaringen afsluttes med "(Community-regel af …)" eller "(Lokal regel.)", så det er tydeligt, hvor reglen kommer fra.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
Én regel eller en liste af regler pr. `.json`-fil. Kommentarer (`//`) og afsluttende kommaer er tilladt.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "LOCAL-SEC-001",
|
||||||
|
"title": "Login'et sa er aktivt",
|
||||||
|
"severity": "Medium",
|
||||||
|
"scope": "Server",
|
||||||
|
"minimumVersion": "10.0",
|
||||||
|
"query": "SELECT name AS [login] FROM sys.sql_logins WHERE principal_id = 1 AND is_disabled = 0",
|
||||||
|
"subject": "login: {login}",
|
||||||
|
"findingTitle": "Det indbyggede login '{login}' er aktivt",
|
||||||
|
"explanation": "sa er det login, angribere prøver først …",
|
||||||
|
"recommendation": "Deaktivér sa: ALTER LOGIN [sa] DISABLE …",
|
||||||
|
"evidence": [ "login" ],
|
||||||
|
"sensitiveColumns": [ "login" ],
|
||||||
|
"references": [ { "title": "Choose an authentication mode", "url": "https://learn.microsoft.com/…" } ],
|
||||||
|
"author": "Dit navn"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Felt | |
|
||||||
|
|---|---|
|
||||||
|
| `id` | `COM-` eller `LOCAL-`, et område på 2–10 store bogstaver og tre cifre |
|
||||||
|
| `title` | Reglens navn, som det vises i reglistingen |
|
||||||
|
| `severity` | `Info`, `Low`, `Medium`, `High` eller `Critical` |
|
||||||
|
| `scope` | `Server` (køres én gang i master) eller `Database` (køres i hver online brugerdatabase; kolonnen `database` tilføjes automatisk) |
|
||||||
|
| `minimumVersion` | Ældste SQL Server-version, forespørgslen virker på: `9.0` = 2005 (standard), `10.0` = 2008, `11.0` = 2012, `13.0` = 2016, `14.0` = 2017, `15.0` = 2019, `16.0` = 2022. På ældre versioner er reglen "ikke målbar" |
|
||||||
|
| `query` | Én `SELECT` (eller `WITH … SELECT`). Hver række er et fund. Højst 100 rækker pr. regel |
|
||||||
|
| `subject` | Hvad fundet handler om. Bruges til at genkende det samme fund fra gang til gang. Standard: første kolonnes værdi |
|
||||||
|
| `findingTitle`, `explanation`, `recommendation` | Teksterne. `{kolonne}` erstattes med rækkens værdi |
|
||||||
|
| `evidence` | Kolonner, der vises som målinger. Standard: alle |
|
||||||
|
| `sensitiveColumns` | Kolonner med kundedata (logins, værtsnavne, forespørgselstekst). De sendes ikke til Hub, medmindre kunden har slået det til, og vises som `[udeladt]` i API'et |
|
||||||
|
| `references`, `author` | Valgfri |
|
||||||
|
|
||||||
|
## Sikkerhed
|
||||||
|
|
||||||
|
Forespørgslen skal være én `SELECT` og må ikke indeholde fx `INSERT`, `UPDATE`, `DELETE`, `EXEC`, `INTO`, `DROP`, `ALTER`, `DBCC`, `OPENROWSET`, `WAITFOR` eller `sp_`/`xp_`-procedurer. Det er et ekstra værn. Det egentlige værn er, at agentens login kun har læserettigheder (`db/permissions/grant-least-privilege.sql`). Mangler login'et en rettighed, som reglen kræver, vises reglen som "ikke målbar" med SQL Servers fejlbesked. En forespørgsel med fejl vises som "fejl" og påvirker ikke de andre regler.
|
||||||
|
|
||||||
|
Hold forespørgslerne lette: de køres hvert 15. minut på hver instans (og i hver database for `Database`-regler) med en timeout på 30 sekunder.
|
||||||
|
|
||||||
|
## Afprøv en regel
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Kontrollér format og forespørgsel, uden at forbinde
|
||||||
|
& "C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe" rules validate C:\regler\sa.json
|
||||||
|
|
||||||
|
# Kør den mod en instans fra appsettings.json, med agentens rettigheder, og se fundene
|
||||||
|
& "C:\Program Files\SQLInsight\Agent\SQLInsight.Agent.exe" rules test C:\regler\sa.json --instance prod-sql01
|
||||||
|
```
|
||||||
|
|
||||||
|
Når reglen virker, kopieres filen til `rules`-mappen i agentens datamappe. Den tages i brug inden for 5 minutter. En regel slås fra med `SQLInsight:CustomRules:Disabled` (liste af id'er). Community-reglerne kan slås helt fra med `SQLInsight:CustomRules:Community: false`.
|
||||||
|
|
||||||
|
## Bidrag med en community-regel
|
||||||
|
|
||||||
|
1. Læg reglen i en fil under `rules/community/` (en fil pr. område, fx `security.json`). Id'et er `COM-OMRÅDE-nnn`; tag næste ledige nummer.
|
||||||
|
2. Afprøv den med `rules test` mod de versioner, `minimumVersion` lover. Nævn i pull requesten, hvilke versioner du har testet på.
|
||||||
|
3. Skriv forklaringen til en driftsansvarlig, der ikke er DBA: hvorfor er det et problem, hvad sker der, hvis man lader være, og hvad gør man konkret.
|
||||||
|
4. Opret en pull request på [git.seosoft.dk/seo/sqlinsight-pro](https://git.seosoft.dk/seo/sqlinsight-pro). Testen `Community_rules_in_the_repository_are_valid_and_ship_with_the_build` fanger format- og sikkerhedsfejl.
|
||||||
|
|
||||||
|
Efter review kommer reglen med i næste version og i næste regelpakke, så eksisterende installationer får den uden opdatering af programmet.
|
||||||
19
rules/community/integrity.json
Normal file
19
rules/community/integrity.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
// Example of a database-scoped rule: the query runs in every online user database, and the column
|
||||||
|
// "database" is added to each row automatically.
|
||||||
|
"id": "COM-DB-001",
|
||||||
|
"title": "Fremmednøgler, som SQL Server ikke stoler på",
|
||||||
|
"severity": "Low",
|
||||||
|
"scope": "Database",
|
||||||
|
"query": "SELECT OBJECT_SCHEMA_NAME(parent_object_id) + '.' + OBJECT_NAME(parent_object_id) AS [table], name AS [constraint] FROM sys.foreign_keys WHERE is_not_trusted = 1 AND is_disabled = 0",
|
||||||
|
"subject": "constraint: {database}/{constraint}",
|
||||||
|
"findingTitle": "{database}: fremmednøglen {constraint} på {table} er ikke betroet",
|
||||||
|
"explanation": "Nøglen er blevet slået til igen uden WITH CHECK, fx efter en dataindlæsning. Den håndhæves for nye rækker, men SQL Server kan ikke stole på eksisterende data, så optimizeren udnytter den ikke til at fjerne joins, og der kan ligge ugyldige data.",
|
||||||
|
"recommendation": "Kontrollér og betro nøglen: ALTER TABLE {table} WITH CHECK CHECK CONSTRAINT [{constraint}]; Fejler det, findes der rækker, som bryder nøglen, og de skal rettes først. Kør det uden for spidsbelastning på store tabeller.",
|
||||||
|
"references": [
|
||||||
|
{ "title": "ALTER TABLE ... WITH CHECK", "url": "https://learn.microsoft.com/sql/t-sql/statements/alter-table-transact-sql" }
|
||||||
|
],
|
||||||
|
"author": "SQLInsight Pro"
|
||||||
|
}
|
||||||
|
]
|
||||||
40
rules/community/security.json
Normal file
40
rules/community/security.json
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
// Contributed as an example of a server-scoped community rule.
|
||||||
|
"id": "COM-SEC-001",
|
||||||
|
"title": "Login'et sa er aktivt",
|
||||||
|
"severity": "Medium",
|
||||||
|
"query": "SELECT name AS [login], CASE WHEN is_policy_checked = 1 THEN 'ja' ELSE 'nej' END AS [password_policy] FROM sys.sql_logins WHERE principal_id = 1 AND is_disabled = 0",
|
||||||
|
"subject": "login: sa",
|
||||||
|
"findingTitle": "Det indbyggede login '{login}' er aktivt",
|
||||||
|
"explanation": "sa er det login, angribere prøver først, fordi navnet er kendt, og det har fulde rettigheder. Password-politik håndhæves: {password_policy}.",
|
||||||
|
"recommendation": "Deaktivér sa (ALTER LOGIN [sa] DISABLE) og brug navngivne logins, helst Windows-logins. Er sa i brug af en applikation, så giv applikationen sit eget login med de rettigheder, den behøver.",
|
||||||
|
"references": [
|
||||||
|
{ "title": "Choose an authentication mode (sa)", "url": "https://learn.microsoft.com/sql/relational-databases/security/choose-an-authentication-mode" }
|
||||||
|
],
|
||||||
|
"author": "SQLInsight Pro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "COM-SEC-002",
|
||||||
|
"minimumVersion": "10.0",
|
||||||
|
"title": "Database ejes af et login, der ikke findes",
|
||||||
|
"severity": "Low",
|
||||||
|
"query": "SELECT d.name AS [database], CONVERT(varchar(100), d.owner_sid, 1) AS [owner_sid] FROM sys.databases d LEFT JOIN sys.server_principals p ON p.sid = d.owner_sid WHERE p.sid IS NULL AND d.database_id > 4",
|
||||||
|
"subject": "database: {database}",
|
||||||
|
"findingTitle": "Databasen {database} ejes af et login, der ikke længere findes",
|
||||||
|
"explanation": "Ejeren er typisk en medarbejder, som er stoppet, eller en database, der er gendannet fra en anden server. Det giver fejl i fx EXECUTE AS OWNER, cross-database ownership chaining og nogle jobs.",
|
||||||
|
"recommendation": "Sæt en ejer, der findes, fx et dedikeret, deaktiveret login: ALTER AUTHORIZATION ON DATABASE::[{database}] TO [sa-ejer]; Undgå at bruge personlige logins som ejer.",
|
||||||
|
"author": "SQLInsight Pro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "COM-CFG-001",
|
||||||
|
"title": "Default trace er slået fra",
|
||||||
|
"severity": "Low",
|
||||||
|
"query": "SELECT CAST(value_in_use AS int) AS [value_in_use] FROM sys.configurations WHERE name = 'default trace enabled' AND value_in_use = 0",
|
||||||
|
"subject": "configuration: default trace enabled",
|
||||||
|
"findingTitle": "Default trace er slået fra",
|
||||||
|
"explanation": "Default trace er en let sporing, der registrerer bl.a. autogrowth, ændringer af objekter og logins. Uden den er det svært at efterforske, hvad der skete, efter en hændelse.",
|
||||||
|
"recommendation": "Slå den til igen: EXEC sp_configure 'default trace enabled', 1; RECONFIGURE; Den koster næsten intet.",
|
||||||
|
"author": "SQLInsight Pro"
|
||||||
|
}
|
||||||
|
]
|
||||||
91
src/SQLInsight.Agent/CustomRules/CustomRuleService.cs
Normal file
91
src/SQLInsight.Agent/CustomRules/CustomRuleService.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SQLInsight.Agent.Updates;
|
||||||
|
using SQLInsight.Core.CustomRules;
|
||||||
|
using SQLInsight.Rules.Lifecycle;
|
||||||
|
|
||||||
|
namespace SQLInsight.Agent.CustomRules;
|
||||||
|
|
||||||
|
public sealed class CustomRuleOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "SQLInsight:CustomRules";
|
||||||
|
|
||||||
|
/// <summary>Folder with local rules (*.json). Default: the "rules" folder in the agent's data folder.</summary>
|
||||||
|
public string? Directory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Use the community rules that come with the rule pack.</summary>
|
||||||
|
public bool Community { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Community or local rule ids that should not run.</summary>
|
||||||
|
public IList<string> Disabled { get; } = [];
|
||||||
|
|
||||||
|
public string DirectoryFor(UpdateOptions updates) => Directory ?? Path.Combine(updates.Directories.Root, "rules");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps the custom rule catalog current: local rule files are re-read every few minutes, and community rules follow
|
||||||
|
/// the rule pack. Invalid rules are logged and left out; they never stop the others.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class CustomRuleService(
|
||||||
|
IOptions<CustomRuleOptions> options,
|
||||||
|
IOptions<UpdateOptions> updates,
|
||||||
|
RuleKnowledge knowledge,
|
||||||
|
CustomRuleCatalog catalog,
|
||||||
|
TimeProvider time,
|
||||||
|
ILogger<CustomRuleService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan ReloadInterval = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
private string _signature = string.Empty;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(ReloadInterval, time);
|
||||||
|
do
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
LogReloadFailed(logger, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the rules again; returns the errors found (also logged when they change).</summary>
|
||||||
|
public IReadOnlyList<string> Reload()
|
||||||
|
{
|
||||||
|
var settings = options.Value;
|
||||||
|
var local = CustomRuleLoader.LoadDirectory(settings.DirectoryFor(updates.Value));
|
||||||
|
var community = settings.Community ? knowledge.Current.CommunityRules : [];
|
||||||
|
var combined = CustomRuleLoader.Combine(community.Concat(local.Rules), local.Errors);
|
||||||
|
var rules = combined.Rules.Where(r => !settings.Disabled.Contains(r.Id, StringComparer.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
|
var signature = JsonSerializer.Serialize(new { rules, combined.Errors }, CustomRuleDefinition.Json);
|
||||||
|
if (signature != _signature)
|
||||||
|
{
|
||||||
|
_signature = signature;
|
||||||
|
catalog.Replace(rules);
|
||||||
|
var fromCommunity = rules.Count(r => r.Origin == "community");
|
||||||
|
LogLoaded(logger, fromCommunity, rules.Count - fromCommunity);
|
||||||
|
foreach (var error in combined.Errors)
|
||||||
|
{
|
||||||
|
LogInvalid(logger, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined.Errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Information, Message = "Brugerdefinerede regler: {Community} community-regler og {Local} lokale regler.")]
|
||||||
|
private static partial void LogLoaded(ILogger logger, int community, int local);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Regel bruges ikke: {Error}")]
|
||||||
|
private static partial void LogInvalid(ILogger logger, string error);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Warning, Message = "Lokale regler kunne ikke læses: {Message}")]
|
||||||
|
private static partial void LogReloadFailed(ILogger logger, string message);
|
||||||
|
}
|
||||||
119
src/SQLInsight.Agent/CustomRules/RuleCommands.cs
Normal file
119
src/SQLInsight.Agent/CustomRules/RuleCommands.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
using SQLInsight.Collectors;
|
||||||
|
using SQLInsight.Collectors.Custom;
|
||||||
|
using SQLInsight.Connectivity;
|
||||||
|
using SQLInsight.Core.Collection;
|
||||||
|
using SQLInsight.Core.CustomRules;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Rules.Custom;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.Agent.CustomRules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For rule authors:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>rules validate <fil|mappe></c>: check rule files without connecting anywhere.</item>
|
||||||
|
/// <item><c>rules test <fil> --instance <navn></c>: run the rules against a configured instance and print the findings.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static class RuleCommands
|
||||||
|
{
|
||||||
|
public static bool Handles(string[] args) => args.FirstOrDefault() == "rules";
|
||||||
|
|
||||||
|
public static async Task<int> RunAsync(string[] args, TextWriter output)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(args);
|
||||||
|
ArgumentNullException.ThrowIfNull(output);
|
||||||
|
var path = args.ElementAtOrDefault(2);
|
||||||
|
if (path is null || args[1] is not ("validate" or "test"))
|
||||||
|
{
|
||||||
|
await output.WriteLineAsync("""
|
||||||
|
SQLInsight.Agent rules validate <fil|mappe> Kontrollér regelfiler
|
||||||
|
SQLInsight.Agent rules test <fil> --instance <navn> Kør reglerne mod en instans og vis fundene
|
||||||
|
""").ConfigureAwait(false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loaded = Load(path);
|
||||||
|
foreach (var error in loaded.Errors)
|
||||||
|
{
|
||||||
|
await output.WriteLineAsync("FEJL " + error).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rule in loaded.Rules)
|
||||||
|
{
|
||||||
|
await output.WriteLineAsync($"OK {rule.Id}: {rule.Title} ({rule.Scope}, fra SQL Server {rule.MinimumVersion})").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args[1] == "validate" || loaded.Rules.Count == 0)
|
||||||
|
{
|
||||||
|
return loaded.Errors.Count == 0 && loaded.Rules.Count > 0 ? 0 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = Option(args, "--instance");
|
||||||
|
var settings = LoadAgentOptions();
|
||||||
|
var instance = settings.Instances.FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (instance is null)
|
||||||
|
{
|
||||||
|
await output.WriteLineAsync($"Instansen '{name}' findes ikke i appsettings.json (SQLInsight:Instances). Kendte: {string.Join(", ", settings.Instances.Select(i => i.Name))}").ConfigureAwait(false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await TestAsync(loaded.Rules, instance, new SqlConnectionFactory(), output, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<int> TestAsync(IReadOnlyList<CustomRuleDefinition> rules, MonitoredInstance instance, ISqlConnectionFactory connections, TextWriter output, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(instance);
|
||||||
|
ArgumentNullException.ThrowIfNull(connections);
|
||||||
|
ArgumentNullException.ThrowIfNull(output);
|
||||||
|
var catalog = new CustomRuleCatalog();
|
||||||
|
catalog.Replace(rules);
|
||||||
|
await using var opened = await connections.OpenAsync(instance.ToTarget(), cancellationToken).ConfigureAwait(false);
|
||||||
|
var info = await InstanceProbe.DetectAsync(opened.Connection, cancellationToken).ConfigureAwait(false);
|
||||||
|
var capabilities = InstanceCapabilities.From(info);
|
||||||
|
ICollector collector = new CustomQueryCollector(catalog);
|
||||||
|
var data = new CollectedData();
|
||||||
|
data.Set(collector.DataType, await collector.CollectAsync(new CollectorContext(opened.Connection, info, capabilities, DateTimeOffset.UtcNow, null, cancellationToken)).ConfigureAwait(false));
|
||||||
|
|
||||||
|
await output.WriteLineAsync($"\n{instance.Name}: {info.Release.DisplayName()} ({info.Version})\n").ConfigureAwait(false);
|
||||||
|
var failed = false;
|
||||||
|
foreach (var result in new RuleEngine([], catalog).Evaluate(new RuleContext(info, capabilities, DateTimeOffset.UtcNow, data)))
|
||||||
|
{
|
||||||
|
await output.WriteLineAsync($"{result.RuleId}: {result.Outcome}{(result.Reason is { } reason ? " – " + reason : "")}").ConfigureAwait(false);
|
||||||
|
failed |= result.Outcome == RuleOutcome.Error;
|
||||||
|
foreach (var finding in result.Findings)
|
||||||
|
{
|
||||||
|
await output.WriteLineAsync($" [{finding.Severity}] {finding.Title} ({finding.Subject})").ConfigureAwait(false);
|
||||||
|
foreach (var evidence in finding.Evidence)
|
||||||
|
{
|
||||||
|
await output.WriteLineAsync($" {evidence.Name}: {evidence.Value}{(evidence.Sensitive ? " (følsom)" : "")}").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return failed ? 2 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CustomRuleLoadResult Load(string path) => Directory.Exists(path)
|
||||||
|
? CustomRuleLoader.LoadDirectory(path, origin: null)
|
||||||
|
: CustomRuleLoader.Parse(File.ReadAllText(path), Path.GetFileName(path), origin: null);
|
||||||
|
|
||||||
|
private static AgentOptions LoadAgentOptions()
|
||||||
|
{
|
||||||
|
var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production";
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(AppContext.BaseDirectory)
|
||||||
|
.AddJsonFile("appsettings.json", optional: true)
|
||||||
|
.AddJsonFile($"appsettings.{environment}.json", optional: true)
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
return configuration.GetSection(AgentOptions.SectionName).Get<AgentOptions>() ?? new AgentOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Option(string[] args, string name)
|
||||||
|
{
|
||||||
|
var i = Array.IndexOf(args, name);
|
||||||
|
return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,8 @@ public sealed partial class MonitoringService(
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
NotificationDispatcher notifications,
|
NotificationDispatcher notifications,
|
||||||
IOptions<NotificationOptions> notificationOptions,
|
IOptions<NotificationOptions> notificationOptions,
|
||||||
Hub.HubOutbox hubOutbox) : BackgroundService
|
Hub.HubOutbox hubOutbox,
|
||||||
|
Core.CustomRules.CustomRuleCatalog customRules) : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = loggerFactory.CreateLogger<MonitoringService>();
|
private readonly ILogger _logger = loggerFactory.CreateLogger<MonitoringService>();
|
||||||
|
|
||||||
|
|
@ -30,7 +31,7 @@ public sealed partial class MonitoringService(
|
||||||
|
|
||||||
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
|
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
|
||||||
var monitors = settings.Instances
|
var monitors = settings.Instances
|
||||||
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(), intervals, connectionFactory, ruleEngine, repository,
|
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules), intervals, connectionFactory, ruleEngine, repository,
|
||||||
loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox))
|
loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox))
|
||||||
.ToList();
|
.ToList();
|
||||||
if (monitors.Count == 0)
|
if (monitors.Count == 0)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ using SQLInsight.Repository;
|
||||||
using SQLInsight.Rules;
|
using SQLInsight.Rules;
|
||||||
using SQLInsight.Rules.Engine;
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
if (SQLInsight.Agent.CustomRules.RuleCommands.Handles(args))
|
||||||
|
{
|
||||||
|
return await SQLInsight.Agent.CustomRules.RuleCommands.RunAsync(args, Console.Out);
|
||||||
|
}
|
||||||
|
|
||||||
if (UpdateCommands.Handles(args))
|
if (UpdateCommands.Handles(args))
|
||||||
{
|
{
|
||||||
return await UpdateCommands.RunAsync(args);
|
return await UpdateCommands.RunAsync(args);
|
||||||
|
|
@ -24,7 +29,13 @@ builder.Services.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
|
||||||
builder.Services.Configure<UpdateOptions>(builder.Configuration.GetSection(UpdateOptions.SectionName));
|
builder.Services.Configure<UpdateOptions>(builder.Configuration.GetSection(UpdateOptions.SectionName));
|
||||||
builder.Services.AddSingleton(sp => UpdateService.LoadKnowledge(
|
builder.Services.AddSingleton(sp => UpdateService.LoadKnowledge(
|
||||||
sp.GetRequiredService<IOptions<UpdateOptions>>().Value, sp.GetRequiredService<ILoggerFactory>().CreateLogger<UpdateService>()));
|
sp.GetRequiredService<IOptions<UpdateOptions>>().Value, sp.GetRequiredService<ILoggerFactory>().CreateLogger<UpdateService>()));
|
||||||
builder.Services.AddSingleton(sp => new RuleEngine(BuiltInRules.Create(sp.GetRequiredService<SQLInsight.Rules.Lifecycle.RuleKnowledge>())));
|
builder.Services.Configure<SQLInsight.Agent.CustomRules.CustomRuleOptions>(builder.Configuration.GetSection(SQLInsight.Agent.CustomRules.CustomRuleOptions.SectionName));
|
||||||
|
builder.Services.AddSingleton(new SQLInsight.Core.CustomRules.CustomRuleCatalog());
|
||||||
|
builder.Services.AddSingleton<SQLInsight.Agent.CustomRules.CustomRuleService>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<SQLInsight.Agent.CustomRules.CustomRuleService>());
|
||||||
|
builder.Services.AddSingleton(sp => new RuleEngine(
|
||||||
|
BuiltInRules.Create(sp.GetRequiredService<SQLInsight.Rules.Lifecycle.RuleKnowledge>()),
|
||||||
|
sp.GetRequiredService<SQLInsight.Core.CustomRules.CustomRuleCatalog>()));
|
||||||
builder.Services.AddSingleton(sp =>
|
builder.Services.AddSingleton(sp =>
|
||||||
{
|
{
|
||||||
var connectionString = sp.GetRequiredService<IOptions<AgentOptions>>().Value.Repository.ConnectionString;
|
var connectionString = sp.GetRequiredService<IOptions<AgentOptions>>().Value.Repository.ConnectionString;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ namespace SQLInsight.Collectors;
|
||||||
|
|
||||||
public static class BuiltInCollectors
|
public static class BuiltInCollectors
|
||||||
{
|
{
|
||||||
public static IReadOnlyList<ICollector> Create() =>
|
/// <param name="custom">Community and local rules whose queries are run by the custom-rules collector.</param>
|
||||||
|
public static IReadOnlyList<ICollector> Create(Core.CustomRules.CustomRuleCatalog? custom = null) =>
|
||||||
[
|
[
|
||||||
new ServerCollector(),
|
new ServerCollector(),
|
||||||
new ConfigurationCollector(),
|
new ConfigurationCollector(),
|
||||||
|
|
@ -26,5 +27,6 @@ public static class BuiltInCollectors
|
||||||
new IndexCollector(),
|
new IndexCollector(),
|
||||||
new AvailabilityGroupCollector(),
|
new AvailabilityGroupCollector(),
|
||||||
new AgentJobCollector(),
|
new AgentJobCollector(),
|
||||||
|
.. custom is null ? Array.Empty<ICollector>() : [new Custom.CustomQueryCollector(custom)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
163
src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs
Normal file
163
src/SQLInsight.Collectors/Custom/CustomQueryCollector.cs
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
using System.Data.Common;
|
||||||
|
using System.Globalization;
|
||||||
|
using SQLInsight.Core.CustomRules;
|
||||||
|
|
||||||
|
namespace SQLInsight.Collectors.Custom;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the queries of the custom rules (community and local). Each rule gets its own result or error, so one broken
|
||||||
|
/// or unpermitted query only affects its own rule.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CustomQueryCollector(CustomRuleCatalog catalog) : Collector<CustomQueryResults>
|
||||||
|
{
|
||||||
|
/// <summary>SQL Server error numbers that mean the login lacks a permission or access to a database.</summary>
|
||||||
|
private static readonly HashSet<int> PermissionErrors = [229, 230, 262, 297, 300, 916, 4060, 15247, 15151];
|
||||||
|
|
||||||
|
public override string Name => "custom-rules";
|
||||||
|
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
protected override async Task<CustomQueryResults> CollectAsync(CollectorContext context, CustomQueryResults? previous)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
var rules = catalog.Rules.Where(r => context.Instance.Version >= r.MinimumSqlVersion).ToList();
|
||||||
|
var results = new Dictionary<string, CustomQueryResult>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (rules.Count == 0)
|
||||||
|
{
|
||||||
|
return new CustomQueryResults(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
var original = (await Db.QueryAsync(context.Connection, "SELECT DB_NAME()", r => r.String(0), context.CancellationToken).ConfigureAwait(false))[0];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<string>? databases = null;
|
||||||
|
foreach (var rule in rules)
|
||||||
|
{
|
||||||
|
if (rule.Scope == CustomRuleScope.Server)
|
||||||
|
{
|
||||||
|
results[rule.Id] = await RunAsync(context, rule, "master", addDatabase: false).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
databases ??= await Db.QueryAsync(context.Connection,
|
||||||
|
"SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 AND source_database_id IS NULL AND HAS_DBACCESS(name) = 1 ORDER BY name",
|
||||||
|
r => r.String(0), context.CancellationToken).ConfigureAwait(false);
|
||||||
|
results[rule.Id] = await RunPerDatabaseAsync(context, rule, databases).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await ExecuteAsync(context.Connection, "USE " + Db.Identifier(original), context.CancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CustomQueryResults(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<CustomQueryResult> RunPerDatabaseAsync(CollectorContext context, CustomRuleDefinition rule, List<string> databases)
|
||||||
|
{
|
||||||
|
var rows = new List<IReadOnlyDictionary<string, string?>>();
|
||||||
|
var errors = new List<string>();
|
||||||
|
var denied = 0;
|
||||||
|
var truncated = false;
|
||||||
|
foreach (var database in databases)
|
||||||
|
{
|
||||||
|
var result = await RunAsync(context, rule, database, addDatabase: true).ConfigureAwait(false);
|
||||||
|
if (result.Error is { } error)
|
||||||
|
{
|
||||||
|
errors.Add($"{database}: {error}");
|
||||||
|
denied += result.PermissionDenied ? 1 : 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
truncated |= result.Truncated;
|
||||||
|
rows.AddRange(result.Rows.Take(CustomRuleDefinition.MaxRowsPerRule - rows.Count));
|
||||||
|
if (rows.Count >= CustomRuleDefinition.MaxRowsPerRule)
|
||||||
|
{
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Databases it could not read are only a problem when it could read none of them.
|
||||||
|
return errors.Count > 0 && errors.Count == databases.Count
|
||||||
|
? new CustomQueryResult([], string.Join("; ", errors.Take(3)), denied == errors.Count)
|
||||||
|
: new CustomQueryResult(rows, null, false, truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<CustomQueryResult> RunAsync(CollectorContext context, CustomRuleDefinition rule, string database, bool addDatabase)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var command = context.Connection.CreateCommand();
|
||||||
|
command.CommandText = "USE " + Db.Identifier(database) + ";\n" + rule.Query;
|
||||||
|
command.CommandTimeout = Db.CommandTimeoutSeconds;
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(context.CancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = new List<IReadOnlyDictionary<string, string?>>();
|
||||||
|
var truncated = false;
|
||||||
|
while (await reader.ReadAsync(context.CancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (rows.Count == CustomRuleDefinition.MaxRowsPerRule)
|
||||||
|
{
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (addDatabase)
|
||||||
|
{
|
||||||
|
row["database"] = database;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < reader.FieldCount; i++)
|
||||||
|
{
|
||||||
|
var name = reader.GetName(i) is { Length: > 0 } n ? n : $"column{i + 1}";
|
||||||
|
row[name] = reader.IsDBNull(i) ? null : Text(reader.GetValue(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CustomQueryResult(rows, null, false, truncated);
|
||||||
|
}
|
||||||
|
catch (DbException ex)
|
||||||
|
{
|
||||||
|
return new CustomQueryResult([], ex.Message, IsPermissionError(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Text(object value) => value switch
|
||||||
|
{
|
||||||
|
byte[] bytes => "0x" + Convert.ToHexString(bytes),
|
||||||
|
DateTime time => time.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
|
bool flag => flag ? "1" : "0",
|
||||||
|
_ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>SqlClient exposes the error number as Number; ODBC has it per error as NativeError.</summary>
|
||||||
|
private static bool IsPermissionError(DbException ex)
|
||||||
|
{
|
||||||
|
if (ex.GetType().GetProperty("Number")?.GetValue(ex) is int number)
|
||||||
|
{
|
||||||
|
return PermissionErrors.Contains(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ex.GetType().GetProperty("Errors")?.GetValue(ex) is System.Collections.IEnumerable errors)
|
||||||
|
{
|
||||||
|
foreach (var error in errors)
|
||||||
|
{
|
||||||
|
if (error.GetType().GetProperty("NativeError")?.GetValue(error) is int native && PermissionErrors.Contains(native))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ExecuteAsync(DbConnection connection, string sql, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = sql;
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
248
src/SQLInsight.Core/CustomRules/CustomRuleDefinition.cs
Normal file
248
src/SQLInsight.Core/CustomRules/CustomRuleDefinition.cs
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
|
namespace SQLInsight.Core.CustomRules;
|
||||||
|
|
||||||
|
public enum CustomRuleScope
|
||||||
|
{
|
||||||
|
/// <summary>The query runs once, in master.</summary>
|
||||||
|
Server,
|
||||||
|
|
||||||
|
/// <summary>The query runs in every online user database; the column "database" is added to each row.</summary>
|
||||||
|
Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CustomRuleReference(string Title, Uri Url);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A rule written as data: a read-only query where every returned row is a finding. Written by the community (shipped
|
||||||
|
/// in the signed rule pack after review) or locally by the customer. Texts can use {column} placeholders.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial record CustomRuleDefinition
|
||||||
|
{
|
||||||
|
public const int MaxRowsPerRule = 100;
|
||||||
|
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Title { get; init; }
|
||||||
|
public required Severity Severity { get; init; }
|
||||||
|
public CustomRuleScope Scope { get; init; } = CustomRuleScope.Server;
|
||||||
|
public required string Query { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Oldest SQL Server version the query works on, e.g. "11.0" for 2012. Default 9.0 (2005: the sys views).</summary>
|
||||||
|
public string MinimumVersion { get; init; } = "9.0";
|
||||||
|
|
||||||
|
/// <summary>What the finding is about, e.g. "database: {database}". Default: the first column's value.</summary>
|
||||||
|
public string? Subject { get; init; }
|
||||||
|
|
||||||
|
public required string FindingTitle { get; init; }
|
||||||
|
public required string Explanation { get; init; }
|
||||||
|
public required string Recommendation { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Columns shown as evidence. Default: all.</summary>
|
||||||
|
public IReadOnlyList<string>? Evidence { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Columns that can hold customer data (logins, query text, host names): marked sensitive, not sent to the hub by default.</summary>
|
||||||
|
public IReadOnlyList<string> SensitiveColumns { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<CustomRuleReference> References { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Who wrote the rule (name or Forgejo user). Shown in the rule list.</summary>
|
||||||
|
public string? Author { get; init; }
|
||||||
|
|
||||||
|
/// <summary>"community" for rules from the signed rule pack, "lokal" for the customer's own. Set when loaded.</summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public string Origin { get; init; } = "lokal";
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public SqlServerVersion MinimumSqlVersion => SqlServerVersion.Parse(MinimumVersion.Count(c => c == '.') >= 2 ? MinimumVersion : MinimumVersion + ".0.0");
|
||||||
|
|
||||||
|
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Problems that stop the rule from being loaded. Empty when it is valid.</summary>
|
||||||
|
public IReadOnlyList<string> Validate()
|
||||||
|
{
|
||||||
|
var errors = new List<string>();
|
||||||
|
if (!IdPattern().IsMatch(Id ?? string.Empty))
|
||||||
|
{
|
||||||
|
errors.Add($"Id '{Id}' skal have formen COM-OMRÅDE-123 (community) eller LOCAL-OMRÅDE-123 (lokal), fx LOCAL-SEC-001.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (name, value) in new[] { ("title", Title), ("query", Query), ("findingTitle", FindingTitle), ("explanation", Explanation), ("recommendation", Recommendation) })
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
errors.Add($"'{name}' mangler.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = MinimumSqlVersion;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
errors.Add($"minimumVersion '{MinimumVersion}' er ikke en version som 11.0.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Query))
|
||||||
|
{
|
||||||
|
errors.AddRange(QueryProblems(Query));
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The query must be a single read-only SELECT. This is a second line of defence: the agent's login has no write
|
||||||
|
/// permissions to begin with (grant-least-privilege.sql).
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<string> QueryProblems(string query)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
|
var code = StringsAndComments().Replace(query, " ");
|
||||||
|
if (!StartsWithSelect().IsMatch(code))
|
||||||
|
{
|
||||||
|
yield return "Forespørgslen skal starte med SELECT eller WITH.";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match forbidden in ForbiddenKeywords().Matches(code))
|
||||||
|
{
|
||||||
|
yield return $"Forespørgslen må ikke indeholde {forbidden.Value.ToUpperInvariant()}: regler må kun læse.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.TrimEnd().TrimEnd(';').Contains(';', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
yield return "Forespørgslen må kun være én sætning (intet semikolon undervejs).";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces {column} with the row's value; unknown placeholders are left as they are.</summary>
|
||||||
|
public static string Fill(string template, IReadOnlyDictionary<string, string?> row)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(template);
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
return Placeholder().Replace(template, m => row.TryGetValue(m.Groups[1].Value, out var value) ? value ?? "(null)" : m.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(COM|LOCAL)-[A-Z]{2,10}-[0-9]{3}$")]
|
||||||
|
private static partial Regex IdPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"'(?:[^']|'')*'|\[[^\]]*\]|""[^""]*""|--[^\n]*|/\*.*?\*/", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex StringsAndComments();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^\s*(SELECT|WITH)\b", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex StartsWithSelect();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|GRANT|REVOKE|DENY|SHUTDOWN|KILL|BACKUP|RESTORE|DBCC|RECONFIGURE|OPENROWSET|OPENDATASOURCE|OPENQUERY|INTO|WAITFOR|USE|BULK|SETUSER)\b|\b(sp|xp)_\w+", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex ForbiddenKeywords();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{([A-Za-z0-9_ ]+)\}")]
|
||||||
|
private static partial Regex Placeholder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The custom rules in use: local files plus community rules from the rule pack. Swapped as a whole.</summary>
|
||||||
|
public sealed class CustomRuleCatalog
|
||||||
|
{
|
||||||
|
private volatile IReadOnlyList<CustomRuleDefinition> _rules = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<CustomRuleDefinition> Rules => _rules;
|
||||||
|
|
||||||
|
public void Replace(IReadOnlyList<CustomRuleDefinition> rules) => _rules = rules ?? throw new ArgumentNullException(nameof(rules));
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CustomRuleLoadResult(IReadOnlyList<CustomRuleDefinition> Rules, IReadOnlyList<string> Errors);
|
||||||
|
|
||||||
|
public static class CustomRuleLoader
|
||||||
|
{
|
||||||
|
/// <summary>Reads one rule or an array of rules. Invalid rules are left out with a reason.</summary>
|
||||||
|
/// <param name="origin">"community" or "lokal", enforced against the id prefix; null to take it from the prefix (for authors' tools).</param>
|
||||||
|
public static CustomRuleLoadResult Parse(string json, string source, string? origin)
|
||||||
|
{
|
||||||
|
var errors = new List<string>();
|
||||||
|
List<CustomRuleDefinition> parsed;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
|
||||||
|
parsed = document.RootElement.ValueKind == JsonValueKind.Array
|
||||||
|
? document.RootElement.Deserialize<List<CustomRuleDefinition>>(CustomRuleDefinition.Json) ?? []
|
||||||
|
: [document.RootElement.Deserialize<CustomRuleDefinition>(CustomRuleDefinition.Json)!];
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
return new CustomRuleLoadResult([], [$"{source}: ugyldig JSON: {ex.Message}"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules = new List<CustomRuleDefinition>();
|
||||||
|
foreach (var rule in parsed)
|
||||||
|
{
|
||||||
|
var problems = rule.Validate();
|
||||||
|
if (problems.Count > 0)
|
||||||
|
{
|
||||||
|
errors.AddRange(problems.Select(p => $"{source} ({rule.Id}): {p}"));
|
||||||
|
}
|
||||||
|
else if (origin is not null && (origin == "community") != rule.Id.StartsWith("COM-", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
errors.Add($"{source} ({rule.Id}): community-regler har id'er, der starter med COM-, lokale med LOCAL-.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rules.Add(rule with { Origin = origin ?? (rule.Id.StartsWith("COM-", StringComparison.Ordinal) ? "community" : "lokal") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CustomRuleLoadResult(rules, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>All *.json files in a folder (not sub folders). A missing folder means no rules.</summary>
|
||||||
|
public static CustomRuleLoadResult LoadDirectory(string directory, string? origin = "lokal")
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
return new CustomRuleLoadResult([], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules = new List<CustomRuleDefinition>();
|
||||||
|
var errors = new List<string>();
|
||||||
|
foreach (var file in Directory.EnumerateFiles(directory, "*.json").Order(StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var result = Parse(File.ReadAllText(file), Path.GetFileName(file), origin);
|
||||||
|
rules.AddRange(result.Rules);
|
||||||
|
errors.AddRange(result.Errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Combine(rules, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops duplicate ids (first one wins) with an error.</summary>
|
||||||
|
public static CustomRuleLoadResult Combine(IEnumerable<CustomRuleDefinition> rules, IEnumerable<string> errors)
|
||||||
|
{
|
||||||
|
var unique = new List<CustomRuleDefinition>();
|
||||||
|
var all = errors.ToList();
|
||||||
|
foreach (var group in rules.GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
unique.Add(group.First());
|
||||||
|
if (group.Count() > 1)
|
||||||
|
{
|
||||||
|
all.Add($"{group.Key} er defineret {group.Count()} gange; kun den første bruges.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CustomRuleLoadResult(unique, all);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the custom queries returned on one instance, per rule id.</summary>
|
||||||
|
public sealed record CustomQueryResults(IReadOnlyDictionary<string, CustomQueryResult> Results);
|
||||||
|
|
||||||
|
/// <param name="Rows">Column name → value (as text) per row, when the query ran.</param>
|
||||||
|
/// <param name="Error">Why it did not run.</param>
|
||||||
|
/// <param name="PermissionDenied">The error was a missing permission (the rule is then not measurable, not failing).</param>
|
||||||
|
public sealed record CustomQueryResult(IReadOnlyList<IReadOnlyDictionary<string, string?>> Rows, string? Error = null, bool PermissionDenied = false, bool Truncated = false);
|
||||||
66
src/SQLInsight.Rules/Custom/DeclarativeRule.cs
Normal file
66
src/SQLInsight.Rules/Custom/DeclarativeRule.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
using SQLInsight.Core.Collection;
|
||||||
|
using SQLInsight.Core.CustomRules;
|
||||||
|
using SQLInsight.Core.Findings;
|
||||||
|
using SQLInsight.Core.Instances;
|
||||||
|
using SQLInsight.Rules.Engine;
|
||||||
|
|
||||||
|
namespace SQLInsight.Rules.Custom;
|
||||||
|
|
||||||
|
/// <summary>A rule defined as data (<see cref="CustomRuleDefinition"/>): every row its query returns is a finding.</summary>
|
||||||
|
public sealed class DeclarativeRule(CustomRuleDefinition definition) : IRule
|
||||||
|
{
|
||||||
|
public CustomRuleDefinition Definition { get; } = definition ?? throw new ArgumentNullException(nameof(definition));
|
||||||
|
|
||||||
|
public string Id => Definition.Id;
|
||||||
|
public string Title => Definition.Title;
|
||||||
|
public IReadOnlyCollection<Capability> RequiredCapabilities => [];
|
||||||
|
|
||||||
|
public RuleEvaluation Evaluate(RuleContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
if (context.Instance.Version < Definition.MinimumSqlVersion)
|
||||||
|
{
|
||||||
|
return RuleEvaluation.NotMeasurable($"Reglen kræver SQL Server {Definition.MinimumVersion} eller nyere.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = context.Data.Get<CustomQueryResults>();
|
||||||
|
if (!results.Results.TryGetValue(Definition.Id, out var result))
|
||||||
|
{
|
||||||
|
throw new DataUnavailableException("Reglens forespørgsel er ikke kørt endnu.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Error is { } error)
|
||||||
|
{
|
||||||
|
if (result.PermissionDenied)
|
||||||
|
{
|
||||||
|
return RuleEvaluation.NotMeasurable("Agentens login mangler rettigheder til reglens forespørgsel: " + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Reglens forespørgsel fejlede: " + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Rows.Count == 0)
|
||||||
|
{
|
||||||
|
return RuleEvaluation.Passed();
|
||||||
|
}
|
||||||
|
|
||||||
|
var references = Definition.References.Select(r => new Reference(r.Title, r.Url)).ToArray();
|
||||||
|
var findings = result.Rows.Select(row => new Finding
|
||||||
|
{
|
||||||
|
RuleId = Definition.Id,
|
||||||
|
Severity = Definition.Severity,
|
||||||
|
Title = CustomRuleDefinition.Fill(Definition.FindingTitle, row),
|
||||||
|
Subject = Definition.Subject is { } subject ? CustomRuleDefinition.Fill(subject, row) : row.Values.FirstOrDefault(),
|
||||||
|
Explanation = CustomRuleDefinition.Fill(Definition.Explanation, row)
|
||||||
|
+ (Definition.Origin == "community" ? " (Community-regel" + (Definition.Author is { } a ? $" af {a}" : "") + ".)" : " (Lokal regel.)"),
|
||||||
|
Recommendation = CustomRuleDefinition.Fill(Definition.Recommendation, row),
|
||||||
|
Evidence = row
|
||||||
|
.Where(c => Definition.Evidence is null || Definition.Evidence.Contains(c.Key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
.Select(c => new Evidence(c.Key, c.Value ?? "(null)", Definition.SensitiveColumns.Contains(c.Key, StringComparer.OrdinalIgnoreCase)))
|
||||||
|
.Concat(result.Truncated ? [new Evidence("Bemærk", $"Kun de første {CustomRuleDefinition.MaxRowsPerRule} rækker er medtaget.")] : [])
|
||||||
|
.ToList(),
|
||||||
|
References = references,
|
||||||
|
}).ToArray();
|
||||||
|
return RuleEvaluation.Found(findings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using SQLInsight.Core.Collection;
|
using SQLInsight.Core.Collection;
|
||||||
|
using SQLInsight.Core.CustomRules;
|
||||||
using SQLInsight.Core.Instances;
|
using SQLInsight.Core.Instances;
|
||||||
|
|
||||||
namespace SQLInsight.Rules.Engine;
|
namespace SQLInsight.Rules.Engine;
|
||||||
|
|
@ -10,6 +11,15 @@ namespace SQLInsight.Rules.Engine;
|
||||||
public sealed class RuleEngine
|
public sealed class RuleEngine
|
||||||
{
|
{
|
||||||
private readonly IReadOnlyList<IRule> _rules;
|
private readonly IReadOnlyList<IRule> _rules;
|
||||||
|
private readonly CustomRuleCatalog? _custom;
|
||||||
|
private volatile CustomCache _customRules = new([], []);
|
||||||
|
|
||||||
|
/// <param name="custom">Community and local rules; read on every evaluation, so changes apply without a restart.</param>
|
||||||
|
public RuleEngine(IEnumerable<IRule> rules, CustomRuleCatalog? custom = null)
|
||||||
|
: this(rules)
|
||||||
|
{
|
||||||
|
_custom = custom;
|
||||||
|
}
|
||||||
|
|
||||||
public RuleEngine(IEnumerable<IRule> rules)
|
public RuleEngine(IEnumerable<IRule> rules)
|
||||||
{
|
{
|
||||||
|
|
@ -26,7 +36,30 @@ public sealed class RuleEngine
|
||||||
public IReadOnlyList<RuleResult> Evaluate(RuleContext context)
|
public IReadOnlyList<RuleResult> Evaluate(RuleContext context)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
return _rules.Select(rule => Evaluate(rule, context)).ToList();
|
return Rules.Select(rule => Evaluate(rule, context)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The built-in rules followed by the custom ones; a custom rule never replaces a built-in one.</summary>
|
||||||
|
public IReadOnlyList<IRule> Rules
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_custom is null)
|
||||||
|
{
|
||||||
|
return _rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = _custom.Rules;
|
||||||
|
var cached = _customRules;
|
||||||
|
if (!ReferenceEquals(cached.Source, current))
|
||||||
|
{
|
||||||
|
var builtIn = _rules.Select(r => r.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
cached = new CustomCache(current, current.Where(d => !builtIn.Contains(d.Id)).Select(d => (IRule)new Custom.DeclarativeRule(d)).ToList());
|
||||||
|
_customRules = cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. _rules, .. cached.Rules];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RuleResult Evaluate(IRule rule, RuleContext context)
|
private static RuleResult Evaluate(IRule rule, RuleContext context)
|
||||||
|
|
@ -81,4 +114,6 @@ public sealed class RuleEngine
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record CustomCache(IReadOnlyList<CustomRuleDefinition> Source, IReadOnlyList<IRule> Rules);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@ public sealed class RulePack
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
|
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
|
||||||
|
|
||||||
public RulePack(Version version, string source, BuildCatalog builds, SupportLifecycleCatalog lifecycle)
|
public RulePack(Version version, string source, BuildCatalog builds, SupportLifecycleCatalog lifecycle, IReadOnlyList<Core.CustomRules.CustomRuleDefinition>? communityRules = null)
|
||||||
{
|
{
|
||||||
|
CommunityRules = communityRules ?? [];
|
||||||
Version = version ?? throw new ArgumentNullException(nameof(version));
|
Version = version ?? throw new ArgumentNullException(nameof(version));
|
||||||
Source = source ?? throw new ArgumentNullException(nameof(source));
|
Source = source ?? throw new ArgumentNullException(nameof(source));
|
||||||
Builds = builds ?? throw new ArgumentNullException(nameof(builds));
|
Builds = builds ?? throw new ArgumentNullException(nameof(builds));
|
||||||
|
|
@ -30,11 +31,33 @@ public sealed class RulePack
|
||||||
|
|
||||||
public SupportLifecycleCatalog Lifecycle { get; }
|
public SupportLifecycleCatalog Lifecycle { get; }
|
||||||
|
|
||||||
|
/// <summary>Rules contributed by the community and reviewed before release (see docs/regler.md).</summary>
|
||||||
|
public IReadOnlyList<Core.CustomRules.CustomRuleDefinition> CommunityRules { get; }
|
||||||
|
|
||||||
/// <summary>The pack compiled into this build. Its version is the build catalog date, e.g. 2026.9.29.</summary>
|
/// <summary>The pack compiled into this build. Its version is the build catalog date, e.g. 2026.9.29.</summary>
|
||||||
public static RulePack Embedded()
|
public static RulePack Embedded()
|
||||||
{
|
{
|
||||||
var builds = BuildCatalog.LoadEmbedded();
|
var builds = BuildCatalog.LoadEmbedded();
|
||||||
return new RulePack(VersionOf(builds.AsOf), "indbygget", builds, SupportLifecycleCatalog.LoadEmbedded());
|
return new RulePack(VersionOf(builds.AsOf), "indbygget", builds, SupportLifecycleCatalog.LoadEmbedded(), EmbeddedCommunityRules());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Core.CustomRules.CustomRuleDefinition> EmbeddedCommunityRules()
|
||||||
|
{
|
||||||
|
var assembly = typeof(RulePack).Assembly;
|
||||||
|
var rules = new List<Core.CustomRules.CustomRuleDefinition>();
|
||||||
|
foreach (var name in assembly.GetManifestResourceNames().Where(n => n.StartsWith("SQLInsight.Rules.community.", StringComparison.Ordinal)).Order(StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(assembly.GetManifestResourceStream(name)!);
|
||||||
|
var loaded = Core.CustomRules.CustomRuleLoader.Parse(reader.ReadToEnd(), name, "community");
|
||||||
|
if (loaded.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Indbyggede community-regler er ugyldige: " + string.Join(" ", loaded.Errors));
|
||||||
|
}
|
||||||
|
|
||||||
|
rules.AddRange(loaded.Rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Version VersionOf(DateOnly date) => new(date.Year, date.Month, date.Day);
|
public static Version VersionOf(DateOnly date) => new(date.Year, date.Month, date.Day);
|
||||||
|
|
@ -64,7 +87,19 @@ public sealed class RulePack
|
||||||
throw new InvalidDataException("Regelpakken mangler buildCatalog eller supportLifecycle.");
|
throw new InvalidDataException("Regelpakken mangler buildCatalog eller supportLifecycle.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RulePack(version, source, BuildCatalog.FromJson(builds), SupportLifecycleCatalog.FromJson(lifecycle));
|
IReadOnlyList<Core.CustomRules.CustomRuleDefinition> community = [];
|
||||||
|
if (root.TryGetProperty("communityRules", out var rules))
|
||||||
|
{
|
||||||
|
var loaded = Core.CustomRules.CustomRuleLoader.Parse(rules.GetRawText(), "regelpakken", "community");
|
||||||
|
if (loaded.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("Regelpakkens community-regler er ugyldige: " + string.Join(" ", loaded.Errors));
|
||||||
|
}
|
||||||
|
|
||||||
|
community = loaded.Rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RulePack(version, source, BuildCatalog.FromJson(builds), SupportLifecycleCatalog.FromJson(lifecycle), community);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
|
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
|
||||||
{
|
{
|
||||||
|
|
@ -73,11 +108,13 @@ public sealed class RulePack
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Builds a rule pack file from the two catalog files (vendor tool).</summary>
|
/// <summary>Builds a rule pack file from the two catalog files (vendor tool).</summary>
|
||||||
public static byte[] Create(Version version, ReadOnlySpan<byte> buildCatalogJson, ReadOnlySpan<byte> supportLifecycleJson)
|
/// <param name="communityRulesJson">A JSON array of community rules, or empty for none.</param>
|
||||||
|
public static byte[] Create(Version version, ReadOnlySpan<byte> buildCatalogJson, ReadOnlySpan<byte> supportLifecycleJson, ReadOnlySpan<byte> communityRulesJson = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(version);
|
ArgumentNullException.ThrowIfNull(version);
|
||||||
using var builds = JsonDocument.Parse(buildCatalogJson.ToArray());
|
using var builds = JsonDocument.Parse(buildCatalogJson.ToArray());
|
||||||
using var lifecycle = JsonDocument.Parse(supportLifecycleJson.ToArray());
|
using var lifecycle = JsonDocument.Parse(supportLifecycleJson.ToArray());
|
||||||
|
using var community = JsonDocument.Parse(communityRulesJson.IsEmpty ? "[]"u8.ToArray() : communityRulesJson.ToArray(), new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
|
||||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(new
|
var bytes = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
{
|
{
|
||||||
formatVersion = FormatVersion,
|
formatVersion = FormatVersion,
|
||||||
|
|
@ -85,6 +122,7 @@ public sealed class RulePack
|
||||||
createdAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
createdAt = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture),
|
||||||
buildCatalog = builds.RootElement,
|
buildCatalog = builds.RootElement,
|
||||||
supportLifecycle = lifecycle.RootElement,
|
supportLifecycle = lifecycle.RootElement,
|
||||||
|
communityRules = community.RootElement,
|
||||||
}, Indented);
|
}, Indented);
|
||||||
|
|
||||||
// Refuse to publish a pack this version could not load.
|
// Refuse to publish a pack this version could not load.
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="Lifecycle\support-lifecycle.json" LogicalName="SQLInsight.Rules.support-lifecycle.json" />
|
<EmbeddedResource Include="Lifecycle\support-lifecycle.json" LogicalName="SQLInsight.Rules.support-lifecycle.json" />
|
||||||
<EmbeddedResource Include="Lifecycle\build-catalog.json" LogicalName="SQLInsight.Rules.build-catalog.json" />
|
<EmbeddedResource Include="Lifecycle\build-catalog.json" LogicalName="SQLInsight.Rules.build-catalog.json" />
|
||||||
|
<!-- Community rules reviewed in this repository ship with every build, and newer ones with rule packs. -->
|
||||||
|
<EmbeddedResource Include="..\..\rules\community\*.json" LogicalName="SQLInsight.Rules.community.%(Filename)%(Extension)" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,25 @@ public class AgentPipelineTests(SqlServerFixture sql) : IAsyncLifetime
|
||||||
Provider = ConnectionProvider.SqlClient,
|
Provider = ConnectionProvider.SqlClient,
|
||||||
TrustServerCertificate = true,
|
TrustServerCertificate = true,
|
||||||
},
|
},
|
||||||
BuiltInCollectors.Create(),
|
BuiltInCollectors.Create(Community),
|
||||||
new Dictionary<string, TimeSpan>(),
|
new Dictionary<string, TimeSpan>(),
|
||||||
new SqlConnectionFactory(),
|
new SqlConnectionFactory(),
|
||||||
new RuleEngine(BuiltInRules.Create()),
|
new RuleEngine(BuiltInRules.Create(), Community),
|
||||||
_repository,
|
_repository,
|
||||||
NullLogger.Instance);
|
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]
|
[Fact]
|
||||||
public async Task Two_cycles_store_findings_rule_states_and_time_series()
|
public async Task Two_cycles_store_findings_rule_states_and_time_series()
|
||||||
{
|
{
|
||||||
|
|
@ -61,7 +73,7 @@ public class AgentPipelineTests(SqlServerFixture sql) : IAsyncLifetime
|
||||||
Assert.NotNull(first);
|
Assert.NotNull(first);
|
||||||
Assert.Null(nothingDue);
|
Assert.Null(nothingDue);
|
||||||
Assert.NotNull(second);
|
Assert.NotNull(second);
|
||||||
Assert.Equal(BuiltInRules.Create().Count, second.Count);
|
Assert.Equal(RuleCount, second.Count);
|
||||||
Assert.DoesNotContain(second, r => r.Outcome == RuleOutcome.Error);
|
Assert.DoesNotContain(second, r => r.Outcome == RuleOutcome.Error);
|
||||||
|
|
||||||
var instance = await _repository.GetInstanceAsync("container");
|
var instance = await _repository.GetInstanceAsync("container");
|
||||||
|
|
@ -74,7 +86,8 @@ public class AgentPipelineTests(SqlServerFixture sql) : IAsyncLifetime
|
||||||
Assert.Contains(findings, f => f.RuleId == "SQLI-DB-007" && f.Subject == "database: master");
|
Assert.Contains(findings, f => f.RuleId == "SQLI-DB-007" && f.Subject == "database: master");
|
||||||
|
|
||||||
var states = await _repository.GetRuleStatesAsync(instance.InstanceId);
|
var states = await _repository.GetRuleStatesAsync(instance.InstanceId);
|
||||||
Assert.Equal(BuiltInRules.Create().Count, states.Count);
|
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.
|
// 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.NotEqual(RuleOutcome.NotMeasurable, states.Single(s => s.RuleId == "SQLI-WAIT-003").Outcome);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
using SQLInsight.Agent;
|
||||||
|
using SQLInsight.Agent.CustomRules;
|
||||||
|
using SQLInsight.Connectivity;
|
||||||
|
using SQLInsight.Core.CustomRules;
|
||||||
|
using SQLInsight.Rules.Lifecycle;
|
||||||
|
|
||||||
|
namespace SQLInsight.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>Community and local rules run with the agent's least-privilege login against a real SQL Server.</summary>
|
||||||
|
[Collection(SqlServerTestGroup.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public class CustomRuleIntegrationTests(SqlServerFixture sql) : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly string _database = "SQLInsightFk_" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
|
||||||
|
public Task InitializeAsync() => sql.ExecuteAsSaAsync($"""
|
||||||
|
CREATE DATABASE [{_database}];
|
||||||
|
EXEC('USE [{_database}];
|
||||||
|
CREATE TABLE dbo.Kunder (Id int PRIMARY KEY);
|
||||||
|
CREATE TABLE dbo.Ordrer (Id int PRIMARY KEY, KundeId int CONSTRAINT FK_Ordrer_Kunder REFERENCES dbo.Kunder (Id));
|
||||||
|
ALTER TABLE dbo.Ordrer NOCHECK CONSTRAINT FK_Ordrer_Kunder;
|
||||||
|
ALTER TABLE dbo.Ordrer CHECK CONSTRAINT FK_Ordrer_Kunder;');
|
||||||
|
""");
|
||||||
|
|
||||||
|
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Community_and_local_rules_run_with_least_privilege()
|
||||||
|
{
|
||||||
|
var local = CustomRuleLoader.Parse("""
|
||||||
|
[
|
||||||
|
{ "id": "LOCAL-SEC-001", "title": "Databasemail", "severity": "Low", "query": "SELECT name FROM msdb.dbo.sysmail_account",
|
||||||
|
"findingTitle": "{name}", "explanation": "x", "recommendation": "y" },
|
||||||
|
{ "id": "LOCAL-SEC-002", "title": "Stavefejl", "severity": "Low", "query": "SELECT nosuchcolumn FROM sys.databases",
|
||||||
|
"findingTitle": "x", "explanation": "x", "recommendation": "y" }
|
||||||
|
]
|
||||||
|
""", "local.json", "lokal");
|
||||||
|
Assert.Empty(local.Errors);
|
||||||
|
var rules = RulePack.Embedded().CommunityRules.Concat(local.Rules).ToList();
|
||||||
|
var instance = new MonitoredInstance
|
||||||
|
{
|
||||||
|
Name = "itest", Server = sql.Server, Authentication = AuthenticationMode.SqlLogin, UserName = SqlServerFixture.AgentLogin,
|
||||||
|
Password = SqlServerFixture.AgentPassword, Provider = ConnectionProvider.SqlClient, TrustServerCertificate = true,
|
||||||
|
};
|
||||||
|
using var output = new StringWriter();
|
||||||
|
|
||||||
|
var exit = await RuleCommands.TestAsync(rules, instance, new SqlConnectionFactory(), output, CancellationToken.None);
|
||||||
|
|
||||||
|
var text = output.ToString();
|
||||||
|
Assert.Contains("COM-SEC-001: Findings", text, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Det indbyggede login 'sa' er aktivt", text, StringComparison.Ordinal);
|
||||||
|
Assert.Contains($"{_database}: fremmednøglen FK_Ordrer_Kunder på dbo.Ordrer er ikke betroet", text, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("COM-CFG-001: Passed", text, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("LOCAL-SEC-001: NotMeasurable – Agentens login mangler rettigheder", text, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("LOCAL-SEC-002: Error", text, StringComparison.Ordinal);
|
||||||
|
Assert.Equal(2, exit);
|
||||||
|
}
|
||||||
|
}
|
||||||
177
tests/SQLInsight.Rules.Tests/CustomRuleTests.cs
Normal file
177
tests/SQLInsight.Rules.Tests/CustomRuleTests.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,7 @@ int Usage()
|
||||||
--expires <yyyy-mm-dd> [--instances <antal>] Udsted licens
|
--expires <yyyy-mm-dd> [--instances <antal>] Udsted licens
|
||||||
verify --public <base64> --kid <id> <licens> Verificér licens
|
verify --public <base64> --kid <id> <licens> Verificér licens
|
||||||
rulepack --version <yyyy.m.d[.n]> --out <pack.json>
|
rulepack --version <yyyy.m.d[.n]> --out <pack.json>
|
||||||
[--catalog build-catalog.json] [--lifecycle support-lifecycle.json]
|
[--catalog build-catalog.json] [--lifecycle support-lifecycle.json] [--community rules/community]
|
||||||
Byg regelpakke af katalogfilerne
|
Byg regelpakke af katalogfilerne
|
||||||
publish --key <privat.pem> --kid <id> --feed <mappe> --channel <stable|preview> [--days 90]
|
publish --key <privat.pem> --kid <id> --feed <mappe> --channel <stable|preview> [--days 90]
|
||||||
[--rulepack <pack.json>]
|
[--rulepack <pack.json>]
|
||||||
|
|
@ -82,7 +82,17 @@ int BuildRulePack()
|
||||||
var root = FindRepositoryRoot();
|
var root = FindRepositoryRoot();
|
||||||
var catalog = Option("--catalog") ?? Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "build-catalog.json");
|
var catalog = Option("--catalog") ?? Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "build-catalog.json");
|
||||||
var lifecycle = Option("--lifecycle") ?? Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "support-lifecycle.json");
|
var lifecycle = Option("--lifecycle") ?? Path.Combine(root, "src", "SQLInsight.Rules", "Lifecycle", "support-lifecycle.json");
|
||||||
var pack = RulePack.Create(Version.Parse(Required("--version")), File.ReadAllBytes(catalog), File.ReadAllBytes(lifecycle));
|
var communityFolder = Option("--community") ?? Path.Combine(root, "rules", "community");
|
||||||
|
var community = SQLInsight.Core.CustomRules.CustomRuleLoader.LoadDirectory(communityFolder, "community");
|
||||||
|
if (community.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
community.Errors.ToList().ForEach(e => Console.Error.WriteLine(e));
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
var communityJson = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(community.Rules, SQLInsight.Core.CustomRules.CustomRuleDefinition.Json);
|
||||||
|
var pack = RulePack.Create(Version.Parse(Required("--version")), File.ReadAllBytes(catalog), File.ReadAllBytes(lifecycle), communityJson);
|
||||||
|
Console.WriteLine($"{community.Rules.Count} community-regler fra {communityFolder}");
|
||||||
File.WriteAllBytes(Required("--out"), pack);
|
File.WriteAllBytes(Required("--out"), pack);
|
||||||
Console.WriteLine($"Regelpakke {Required("--version")} skrevet til {Required("--out")}");
|
Console.WriteLine($"Regelpakke {Required("--version")} skrevet til {Required("--out")}");
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue