Fase 0: solution structure, capability model, rule engine, least-privilege script
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

- .NET 10 solution with Core, Connectivity, Rules, Contracts, Agent, Web and Hub
- Capability model gating collectors and rules by SQL Server version (2000 and later);
  rules that cannot run are reported as not measurable, never as passed
- Connectivity via Microsoft.Data.SqlClient with ODBC fallback for legacy instances,
  hardened monitoring session settings valid back to SQL Server 2000
- Support lifecycle rule (SQLI-VER-001) with lifecycle data shipped as a rule pack
- Version-aware least-privilege permissions script, verified by integration tests
  against SQL Server in Docker (reads monitoring sources, cannot read customer data)
- Forgejo Actions CI, contributing guide, code of conduct, issue templates,
  legacy version notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 05:40:49 +00:00
parent 63b54a8a68
commit f8aa0c307b
No known key found for this signature in database
75 changed files with 3466 additions and 9 deletions

389
.editorconfig Normal file
View file

@ -0,0 +1,389 @@
root = true
# All files
[*]
indent_style = space
# Xml files
[*.xml]
indent_size = 2
# Xml project files
[*.{csproj,fsproj,vbproj,proj,slnx}]
indent_size = 2
# Xml config files
[*.{props,targets,config,nuspec}]
indent_size = 2
[*.json]
indent_size = 2
# C# files
[*.cs]
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
tab_width = 4
# New line preferences
insert_final_newline = false
#### .NET Coding Conventions ####
[*.{cs,vb}]
# Organize usings
dotnet_separate_import_directive_groups = true
dotnet_sort_system_directives_first = true
file_header_template = unset
# this. and Me. preferences
dotnet_style_qualification_for_event = false:silent
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_method = false:silent
dotnet_style_qualification_for_property = false:silent
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
dotnet_style_predefined_type_for_member_access = true:silent
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
# Expression-level preferences
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_namespace_match_folder = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
# Field preferences
dotnet_style_readonly_field = true:warning
# Parameter preferences
dotnet_code_quality_unused_parameters = all:suggestion
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
#### C# Coding Conventions ####
[*.cs]
# var preferences
csharp_style_var_elsewhere = false:silent
csharp_style_var_for_built_in_types = false:silent
csharp_style_var_when_type_is_apparent = false:silent
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:suggestion
csharp_style_expression_bodied_local_functions = false:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_extended_property_pattern = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_prefer_pattern_matching = true:silent
csharp_style_prefer_switch_expression = true:suggestion
# Null-checking preferences
csharp_style_conditional_delegate_call = true:suggestion
# Modifier preferences
csharp_prefer_static_anonymous_function = true:suggestion
csharp_prefer_static_local_function = true:warning
csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion
csharp_style_prefer_readonly_struct = true:suggestion
csharp_style_prefer_readonly_struct_member = true:suggestion
# Code-block preferences
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = file_scoped:suggestion
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_prefer_top_level_statements = true:silent
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:silent
#### C# Formatting Rules ####
# New line preferences
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### Naming styles ####
[*.{cs,vb}]
# Naming rules
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.events_should_be_pascalcase.symbols = events
dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
# Symbol specifications
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interfaces.required_modifiers =
dotnet_naming_symbols.enums.applicable_kinds = enum
dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.enums.required_modifiers =
dotnet_naming_symbols.events.applicable_kinds = event
dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.events.required_modifiers =
dotnet_naming_symbols.methods.applicable_kinds = method
dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.methods.required_modifiers =
dotnet_naming_symbols.properties.applicable_kinds = property
dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.properties.required_modifiers =
dotnet_naming_symbols.public_fields.applicable_kinds = field
dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_fields.required_modifiers =
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_fields.required_modifiers = static
dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types_and_namespaces.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
dotnet_naming_symbols.type_parameters.applicable_kinds = namespace
dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
dotnet_naming_symbols.type_parameters.required_modifiers =
dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_constant_fields.required_modifiers = const
dotnet_naming_symbols.local_variables.applicable_kinds = local
dotnet_naming_symbols.local_variables.applicable_accessibilities = local
dotnet_naming_symbols.local_variables.required_modifiers =
dotnet_naming_symbols.local_constants.applicable_kinds = local
dotnet_naming_symbols.local_constants.applicable_accessibilities = local
dotnet_naming_symbols.local_constants.required_modifiers = const
dotnet_naming_symbols.parameters.applicable_kinds = parameter
dotnet_naming_symbols.parameters.applicable_accessibilities = *
dotnet_naming_symbols.parameters.required_modifiers =
dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_constant_fields.required_modifiers = const
dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
dotnet_naming_symbols.local_functions.applicable_accessibilities = *
dotnet_naming_symbols.local_functions.required_modifiers =
# Naming styles
dotnet_naming_style.pascalcase.required_prefix =
dotnet_naming_style.pascalcase.required_suffix =
dotnet_naming_style.pascalcase.word_separator =
dotnet_naming_style.pascalcase.capitalization = pascal_case
dotnet_naming_style.ipascalcase.required_prefix = I
dotnet_naming_style.ipascalcase.required_suffix =
dotnet_naming_style.ipascalcase.word_separator =
dotnet_naming_style.ipascalcase.capitalization = pascal_case
dotnet_naming_style.tpascalcase.required_prefix = T
dotnet_naming_style.tpascalcase.required_suffix =
dotnet_naming_style.tpascalcase.word_separator =
dotnet_naming_style.tpascalcase.capitalization = pascal_case
dotnet_naming_style._camelcase.required_prefix = _
dotnet_naming_style._camelcase.required_suffix =
dotnet_naming_style._camelcase.word_separator =
dotnet_naming_style._camelcase.capitalization = camel_case
dotnet_naming_style.camelcase.required_prefix =
dotnet_naming_style.camelcase.required_suffix =
dotnet_naming_style.camelcase.word_separator =
dotnet_naming_style.camelcase.capitalization = camel_case
dotnet_naming_style.s_camelcase.required_prefix = s_
dotnet_naming_style.s_camelcase.required_suffix =
dotnet_naming_style.s_camelcase.word_separator =
dotnet_naming_style.s_camelcase.capitalization = camel_case

View file

@ -0,0 +1,20 @@
---
name: Fejlrapport
about: Noget i SQLInsight Pro virker ikke som forventet
labels: ["fejl"]
---
## Hvad skete der
## Hvad forventede du
## Miljø
- SQLInsight Pro-version:
- SQL Server-version (`SELECT @@VERSION`):
- Agent kører på (Windows/Linux, version):
- Forbindelse (SqlClient / ODBC-fallback):
## Log
<!-- Relevante linjer fra agentens log. Fjern servernavne og andet følsomt. -->

View file

@ -0,0 +1,21 @@
---
name: Forslag til regel
about: Et SQL Server-problem, som SQLInsight Pro burde finde
labels: ["regel"]
---
## Problemet
<!-- Hvad går galt på instansen? Hvordan opdagede du det? -->
## Hvordan man måler det
<!-- DMV'er, Query Store, XE-events, error log … Gerne med en query. -->
## Hvilke versioner
<!-- Fra hvilken SQL Server-version kan det måles? Er der forskelle mellem versioner? -->
## Anbefalet løsning
<!-- Hvad bør brugeren gøre? Links til Microsoft Learn eller anden dokumentation. -->

58
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,58 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
jobs:
build:
name: Build og unit tests
# "docker" er standard-label for en Forgejo runner. Ret til jeres runners label, hvis det er et andet.
runs-on: docker
container:
image: mcr.microsoft.com/dotnet/sdk:10.0
steps:
- name: Checkout
run: |
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraHeader="Authorization: token ${{ secrets.GITHUB_TOKEN }}" fetch -q --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Unit tests
run: dotnet test --no-build --configuration Release --filter "Category!=Integration"
integration:
name: Integration (${{ matrix.image }})
needs: build
# Kræver en runner, der har adgang til Docker (Testcontainers starter SQL Server-containere).
# Slå til ved at sætte repo-variablen RUN_INTEGRATION_TESTS=true og give runneren labelen "docker-host".
if: ${{ vars.RUN_INTEGRATION_TESTS == 'true' }}
runs-on: docker-host
strategy:
fail-fast: false
matrix:
image:
- mcr.microsoft.com/mssql/server:2017-latest
- mcr.microsoft.com/mssql/server:2019-latest
- mcr.microsoft.com/mssql/server:2022-latest
- mcr.microsoft.com/mssql/server:2025-latest
env:
SQLINSIGHT_MSSQL_IMAGE: ${{ matrix.image }}
steps:
- name: Checkout
run: |
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraHeader="Authorization: token ${{ secrets.GITHUB_TOKEN }}" fetch -q --depth 1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Integration tests
run: dotnet test tests/SQLInsight.IntegrationTests --configuration Release

482
.gitignore vendored Normal file
View file

@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

7
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,7 @@
# Code of Conduct
Projektet følger [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
Kort fortalt: Vær venlig og konstruktiv, også når du er uenig. Chikane og personangreb accepteres ikke.
Brud på reglerne kan rapporteres til seo@seosoft.dk. Alle henvendelser behandles fortroligt.

49
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,49 @@
# Bidrag til SQLInsight Pro
Tak fordi du vil hjælpe. De mest værdifulde bidrag er **SQL Server-ekspertise**: problemer du har set i produktion, hvordan de måles, og hvordan de løses. Opret dem som et issue med skabelonen *Forslag til regel*.
## Byg og test
Du skal bruge [.NET 10 SDK](https://dotnet.microsoft.com/download) og, til integrationstests, Docker.
```bash
dotnet build
dotnet test --filter "Category!=Integration" # unit tests, ingen afhængigheder
dotnet test tests/SQLInsight.IntegrationTests # starter SQL Server 2022 i Docker
SQLINSIGHT_MSSQL_IMAGE=mcr.microsoft.com/mssql/server:2019-latest \
dotnet test tests/SQLInsight.IntegrationTests # anden version
```
Warnings behandles som fejl, og .NET-analyzers kører på niveauet `latest-recommended`.
## Struktur
| Mappe | Indhold |
|---|---|
| `src/SQLInsight.Core` | Domænemodel: versioner, capabilities, findings |
| `src/SQLInsight.Connectivity` | Forbindelser (SqlClient + ODBC-fallback) og registrering af instansen |
| `src/SQLInsight.Rules` | Regelmotor og regler |
| `src/SQLInsight.Agent` | Windows Service / systemd-agent |
| `src/SQLInsight.Web` | On-prem web-UI (Fase 1) |
| `src/SQLInsight.Contracts` | Wire-kontrakt mellem agent og Hub |
| `src/SQLInsight.Hub` | Centralt API til findings, PostgreSQL (Fase 3) |
| `db/permissions` | Least-privilege-script til agentens login |
| `tests/` | Unit og integrationstests |
## Sådan skriver du en regel
1. Implementér `IRule` i `src/SQLInsight.Rules`. Giv den et nyt, fast id (`SQLI-<OMRÅDE>-<NR>`). Et id genbruges aldrig.
2. Angiv i `RequiredCapabilities` alt, hvad reglen kræver. Motoren rapporterer den som *ikke målbar* på versioner, der mangler noget. Reglen må aldrig selv returnere `Passed`, når den ikke har kunnet måle.
3. Et finding skal have **evidens** (de konkrete tal), **forklaring** (hvorfor det er et problem) og **anbefaling** (hvad man gør). Fix-scripts vises for brugeren og køres aldrig automatisk.
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`.
## 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 billige: ingen `DETAILED`-scans, grænser på antal rækker og intet, der kan tage låse på brugerdata.
- Tjek, hvilke versioner DMV'en og kolonnerne findes i, og angiv det som capabilities.
## Commits
Små, fokuserede commits med en besked, der forklarer *hvorfor*.

24
Directory.Build.props Normal file
View file

@ -0,0 +1,24 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- CS1591: missing XML comments. Public API docs are wanted, but not enforced on every member. -->
<NoWarn>$(NoWarn);CS1591</NoWarn>
<Company>SEOSoft</Company>
<Product>SQLInsight Pro</Product>
<Copyright>Copyright (c) SEOSoft</Copyright>
</PropertyGroup>
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- CA1707: underscores in test method names are the convention here. -->
<NoWarn>$(NoWarn);CA1707</NoWarn>
</PropertyGroup>
</Project>

19
Directory.Packages.props Normal file
View file

@ -0,0 +1,19 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Runtime -->
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.1.0" />
<PackageVersion Include="System.Data.Odbc" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" />
<!-- Test -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Testcontainers.MsSql" Version="4.15.0" />
</ItemGroup>
</Project>

View file

@ -4,7 +4,7 @@
SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA. SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA.
> **Status:** Projektet er i idéfasen. Der er endnu ingen kode. Se [Roadmap](#roadmap). > **Status:** Fase 0 (fundament) er i gang. Se [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md) for at bygge og teste.
--- ---
@ -161,15 +161,15 @@ Hvert **finding** indeholder:
## Roadmap ## Roadmap
### Fase 0: Fundament ### Fase 0: Fundament
- [ ] Solution-struktur (.NET 10 LTS): Agent, Rules, Web, Hub, fælles domænemodel - [x] Solution-struktur (.NET 10 LTS): Agent, Rules, Web, Hub, fælles domænemodel
- [ ] CI-pipeline (build, test, statisk analyse) - [x] CI-pipeline til Forgejo Actions (build, test, statisk analyse). Endnu ikke kørt på en runner
- [ ] Integrationstests mod rigtige SQL Server-containere (2017, 2019, 2022 og 2025) med Testcontainers. Versionerne 2000–2016 findes ikke som containere og testes på VM'er - [x] Integrationstests mod rigtige SQL Server-containere (2017, 2019, 2022 og 2025) med Testcontainers. Indtil videre verificeret på 2022. Versionerne 2000–2016 findes ikke som containere og testes på VM'er
- [ ] Least-privilege-script til opsætning af rettigheder og en test, der verificerer det - [x] Least-privilege-script til opsætning af rettigheder og en test, der verificerer det
- [ ] Capability-model: collectors og regler angiver, hvilken minimumsversion, edition og funktion de kræver - [x] Capability-model: collectors og regler angiver, hvilken minimumsversion, edition og funktion de kræver
- [ ] Spike: forbindelse til SQL Server 2000, 2005 og 2008 (driver, TLS 1.0 og ODBC-fallback). Resultatet afgør, om legacy-laget er et separat modul - [ ] Spike: forbindelse til SQL Server 2000, 2005 og 2008 (driver, TLS 1.0 og ODBC-fallback). Resultatet afgør, om legacy-laget er et separat modul. ODBC-fallback er bygget, men ikke verificeret. Se [docs/legacy-versions.md](docs/legacy-versions.md)
- [ ] Testmiljø med VM'er til de legacy-versioner, der ikke findes som containere (2000–2016) - [ ] Testmiljø med VM'er til de legacy-versioner, der ikke findes som containere (2000–2016)
- [ ] Datamodel for findings (fælles kontrakt mellem Agent og Hub) - [x] Datamodel for findings (fælles kontrakt mellem Agent og Hub)
- [ ] Bidragsguide, code of conduct og issue templates - [x] Bidragsguide, code of conduct og issue templates
### Fase 1: Community MVP ### Fase 1: Community MVP
- [ ] Agent som Windows Service med forbindelse til en eller flere SQL Server-instanser - [ ] Agent som Windows Service med forbindelse til en eller flere SQL Server-instanser

17
SQLInsight.slnx Normal file
View file

@ -0,0 +1,17 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/SQLInsight.Agent/SQLInsight.Agent.csproj" />
<Project Path="src/SQLInsight.Connectivity/SQLInsight.Connectivity.csproj" />
<Project Path="src/SQLInsight.Contracts/SQLInsight.Contracts.csproj" />
<Project Path="src/SQLInsight.Core/SQLInsight.Core.csproj" />
<Project Path="src/SQLInsight.Hub/SQLInsight.Hub.csproj" />
<Project Path="src/SQLInsight.Rules/SQLInsight.Rules.csproj" />
<Project Path="src/SQLInsight.Web/SQLInsight.Web.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SQLInsight.Connectivity.Tests/SQLInsight.Connectivity.Tests.csproj" />
<Project Path="tests/SQLInsight.Core.Tests/SQLInsight.Core.Tests.csproj" />
<Project Path="tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj" />
<Project Path="tests/SQLInsight.Rules.Tests/SQLInsight.Rules.Tests.csproj" />
</Folder>
</Solution>

View file

@ -0,0 +1,105 @@
/*
SQLInsight Pro: least-privilege rettigheder til agentens login
================================================================
Giver agenten præcis de rettigheder, den skal bruge for at overvåge instansen. Aldrig sysadmin.
Scriptet kan køres igen uden skade, fx efter en opgradering eller når der er oprettet nye databaser.
Understøtter SQL Server 2005 og nyere. SQL Server 2000 har ikke VIEW SERVER STATE: se docs/legacy-versions.md.
Forudsætning: login'et findes allerede, fx
CREATE LOGIN [DOMÆNE\svc-sqlinsight$] FROM WINDOWS; -- gMSA (anbefalet)
Hvad der gives, og hvorfor:
Server
2005-2019: VIEW SERVER STATE DMV'er: waits, sessions, plan cache, I/O
2022+: VIEW SERVER PERFORMANCE STATE samme, mere granulært
VIEW SERVER SECURITY STATE sikkerheds-DMV'er (fx audit, certifikater)
Alle: VIEW ANY DEFINITION metadata (indexes, statistikker). Giver IKKE adgang til data.
2014+: CONNECT ANY DATABASE kan åbne alle databaser (nuværende og fremtidige). Giver IKKE adgang til data.
Databaser (kun før 2014): en bruger uden rettigheder i hver online database, så DMV'er og Query Store kan læses.
msdb: SELECT på backup-, restore-, job- og suspect_pages-tabeller. Intet andet.
Hvad der IKKE gives: adgang til tabeldata, skriverettigheder, xp_cmdshell eller sysadmin.
*/
SET NOCOUNT ON;
DECLARE @LoginName sysname;
SET @LoginName = N'DOMÆNE\svc-sqlinsight$'; -- <== ret til agentens login
DECLARE @Major int;
DECLARE @Sql nvarchar(max);
DECLARE @Quoted nvarchar(300);
SET @Major = CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)), 4) AS int);
SET @Quoted = QUOTENAME(@LoginName);
IF @Major < 9
BEGIN
RAISERROR(N'SQL Server 2000 understøttes ikke af dette script. Se docs/legacy-versions.md.', 16, 1);
RETURN;
END;
IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = @LoginName)
BEGIN
RAISERROR(N'Login %s findes ikke. Opret det først.', 16, 1, @LoginName);
RETURN;
END;
IF IS_SRVROLEMEMBER(N'sysadmin', @LoginName) = 1
PRINT N'ADVARSEL: ' + @LoginName + N' er sysadmin. Agenten har ikke brug for det; fjern rollen.';
/* Server-rettigheder */
IF @Major >= 16
SET @Sql = N'GRANT VIEW SERVER PERFORMANCE STATE TO ' + @Quoted + N'; GRANT VIEW SERVER SECURITY STATE TO ' + @Quoted + N';';
ELSE
SET @Sql = N'GRANT VIEW SERVER STATE TO ' + @Quoted + N';';
EXEC (@Sql);
SET @Sql = N'GRANT VIEW ANY DEFINITION TO ' + @Quoted + N';';
EXEC (@Sql);
IF @Major >= 12
BEGIN
SET @Sql = N'GRANT CONNECT ANY DATABASE TO ' + @Quoted + N';';
EXEC (@Sql);
END
ELSE
BEGIN
/* Før 2014: en bruger uden rettigheder i hver online database. Kør scriptet igen, når der oprettes nye. */
DECLARE @Db sysname;
DECLARE db_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT name FROM sys.databases
WHERE state = 0 AND name NOT IN (N'master', N'tempdb', N'model', N'msdb') AND is_read_only = 0;
OPEN db_cursor;
FETCH NEXT FROM db_cursor INTO @Db;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @Sql = N'USE ' + QUOTENAME(@Db) + N';
IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE sid = SUSER_SID(' + QUOTENAME(@LoginName, '''') + N'))
CREATE USER ' + @Quoted + N' FOR LOGIN ' + @Quoted + N';';
EXEC (@Sql);
FETCH NEXT FROM db_cursor INTO @Db;
END;
CLOSE db_cursor;
DEALLOCATE db_cursor;
END;
/* msdb: backup-historik, jobs og suspect pages */
SET @Sql = N'USE msdb;
IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE sid = SUSER_SID(' + QUOTENAME(@LoginName, '''') + N'))
CREATE USER ' + @Quoted + N' FOR LOGIN ' + @Quoted + N';
GRANT SELECT ON dbo.backupset TO ' + @Quoted + N';
GRANT SELECT ON dbo.backupmediafamily TO ' + @Quoted + N';
GRANT SELECT ON dbo.backupmediaset TO ' + @Quoted + N';
GRANT SELECT ON dbo.restorehistory TO ' + @Quoted + N';
GRANT SELECT ON dbo.suspect_pages TO ' + @Quoted + N';
GRANT SELECT ON dbo.sysjobs TO ' + @Quoted + N';
GRANT SELECT ON dbo.sysjobsteps TO ' + @Quoted + N';
GRANT SELECT ON dbo.sysjobhistory TO ' + @Quoted + N';
GRANT SELECT ON dbo.sysjobactivity TO ' + @Quoted + N';
GRANT SELECT ON dbo.sysjobschedules TO ' + @Quoted + N';
GRANT SELECT ON dbo.sysschedules TO ' + @Quoted + N';
GRANT SELECT ON dbo.syscategories TO ' + @Quoted + N';';
EXEC (@Sql);
PRINT N'Rettigheder givet til ' + @LoginName + N'.';

45
docs/legacy-versions.md Normal file
View file

@ -0,0 +1,45 @@
# Legacy-versioner: SQL Server 2000–2016
SQLInsight Pro overvåger alle versioner fra SQL Server 2000. Denne side beskriver de særlige forhold for de ældste versioner, og hvad der skal verificeres i Fase 0-spiken.
## Forbindelse
| Version | Problem | Løsning |
|---|---|---|
| 2000 | Microsoft.Data.SqlClient understøtter ikke TDS 7.0/7.1 | ODBC (`System.Data.Odbc`) med en driver, der understøtter 2000 |
| 2005, 2008, 2008 R2 uden TLS 1.2-patch | Kun TLS 1.0/1.1; moderne Windows og .NET afviser handshaken | Installér TLS 1.2-opdateringen på serveren (anbefalet), ellers ODBC med `Encryption=Optional` |
| 2012, 2014 uden TLS 1.2-patch | Som ovenfor | Som ovenfor |
Agenten bruger som standard `Provider=Auto`: den prøver SqlClient først og falder kun tilbage til ODBC, når fejlen ligner en versions- eller TLS-inkompatibilitet (`LegacyFailureClassifier`). Login- og netværksfejl giver ikke fallback, for så ville den rigtige fejl blive skjult. Når fallback bruges, logges en advarsel.
## Spike: det skal verificeres på rigtige VM'er
Versionerne 2000–2016 findes ikke som containere, så de kan ikke testes i CI med Testcontainers.
- [ ] Hvilken ODBC-driver virker mod SQL Server 2000 fra agentens OS? Kandidater: den indbyggede Windows-driver `SQL Server` (sqlsrv32) og `ODBC Driver 17 for SQL Server`. ODBC Driver 18 understøtter formentlig ikke 2000.
- [ ] Kan agenten på Linux nå SQL 2000 overhovedet? Hvis ikke, kræver legacy-overvågning en Windows-agent.
- [ ] Rammer `LegacyFailureClassifier` de fejlbeskeder, SqlClient faktisk giver mod 2000, 2005 og 2008 uden TLS 1.2?
- [ ] Returnerer `InstanceProbe` korrekte værdier på 2000 (`SERVERPROPERTY` for ukendte properties skal give NULL)?
- [ ] Bliver sessionsindstillingerne (`LOCK_TIMEOUT`, `DEADLOCK_PRIORITY LOW`, `READ UNCOMMITTED`) accepteret via ODBC på alle versioner?
Resultatet afgør, om legacy-laget forbliver en del af `SQLInsight.Connectivity` eller bliver et separat modul eller en separat agent.
**Testmiljø:** Én VM pr. version (2000 SP4, 2005 SP4, 2008 R2 SP3, 2012 SP4, 2014 SP3, 2016 SP3), gerne som snapshots, så de kan nulstilles. Der skal bruges licenser og installationsmedier.
## Rettigheder på SQL Server 2000
`VIEW SERVER STATE` findes ikke i SQL Server 2000. Det betyder:
- `sysprocesses`, `sysdatabases`, `sysfiles`, `sp_configure` og `msdb`-tabellerne kan læses af et almindeligt login med en bruger i `msdb`.
- `DBCC SQLPERF(WAITSTATS)` og læsning af error log kræver forhøjede rettigheder (i praksis sysadmin).
Kunden vælger derfor pr. SQL 2000-instans mellem:
1. **Begrænset overvågning** uden forhøjede rettigheder. Waits og error log vises som *ikke målbar*.
2. **Fuld overvågning** med sysadmin. Det vises tydeligt i UI'et som en risiko.
`db/permissions/grant-least-privilege.sql` afviser at køre på 2000 og henviser hertil. Et separat script til 2000 kommer med spiken.
## Åbne punkter
- Læsning af error log (`xp_readerrorlog`) kræver rettigheder, der varierer mellem versionerne. De skal kortlægges, før error log-collectoren bygges (Fase 1).

6
global.json Normal file
View file

@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}

View file

@ -0,0 +1,37 @@
using SQLInsight.Connectivity;
namespace SQLInsight.Agent;
public sealed class AgentOptions
{
public const string SectionName = "SQLInsight";
public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(15);
public IList<MonitoredInstance> Instances { get; } = [];
}
public sealed class MonitoredInstance
{
/// <summary>Stable name shown in the UI and used as the key in SQLInsight Hub.</summary>
public string Name { get; set; } = string.Empty;
public string Server { get; set; } = string.Empty;
public AuthenticationMode Authentication { get; set; } = AuthenticationMode.Windows;
public string? UserName { get; set; }
public string? Password { get; set; }
public ConnectionProvider Provider { get; set; } = ConnectionProvider.Auto;
public EncryptionMode Encryption { get; set; } = EncryptionMode.Mandatory;
public bool TrustServerCertificate { get; set; }
public ConnectionTarget ToTarget() => new()
{
Server = Server,
Authentication = Authentication,
UserName = UserName,
Password = Password,
Provider = Provider,
Encryption = Encryption,
TrustServerCertificate = TrustServerCertificate,
};
}

View file

@ -0,0 +1,18 @@
using SQLInsight.Agent;
using SQLInsight.Connectivity;
using SQLInsight.Rules;
using SQLInsight.Rules.Engine;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Agent");
builder.Services.AddSystemd();
builder.Services.Configure<AgentOptions>(builder.Configuration.GetSection(AgentOptions.SectionName));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
builder.Services.AddSingleton(new RuleEngine(BuiltInRules.Create()));
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();

View file

@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"SQLInsight.Agent": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<RootNamespace>SQLInsight.Agent</RootNamespace>
<UserSecretsId>sqlinsight-agent</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
<ProjectReference Include="..\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
<ProjectReference Include="..\SQLInsight.Rules\SQLInsight.Rules.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,91 @@
using Microsoft.Extensions.Options;
using SQLInsight.Connectivity;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Agent;
public sealed partial class Worker(
ISqlConnectionFactory connectionFactory,
RuleEngine ruleEngine,
IOptions<AgentOptions> options,
TimeProvider timeProvider,
ILogger<Worker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var settings = options.Value;
if (settings.Instances.Count == 0)
{
LogNoInstances(logger);
}
using var timer = new PeriodicTimer(settings.Interval, timeProvider);
do
{
foreach (var instance in settings.Instances)
{
await CheckInstanceAsync(instance, stoppingToken).ConfigureAwait(false);
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
private async Task CheckInstanceAsync(MonitoredInstance instance, CancellationToken cancellationToken)
{
try
{
await using var opened = await connectionFactory.OpenAsync(instance.ToTarget(), cancellationToken).ConfigureAwait(false);
if (opened.UsedLegacyFallback)
{
LogLegacyFallback(logger, instance.Name);
}
var info = await InstanceProbe.DetectAsync(opened.Connection, cancellationToken).ConfigureAwait(false);
var context = new RuleContext(info, InstanceCapabilities.From(info), timeProvider.GetUtcNow());
LogDetected(logger, instance.Name, info.Release, info.Version, info.Edition);
foreach (var result in ruleEngine.Evaluate(context))
{
switch (result.Outcome)
{
case RuleOutcome.Findings:
foreach (var finding in result.Findings)
{
LogFinding(logger, instance.Name, finding.Severity, result.RuleId, finding.Title);
}
break;
case RuleOutcome.NotMeasurable:
case RuleOutcome.Error:
LogNotEvaluated(logger, instance.Name, result.RuleId, result.Outcome, result.Reason);
break;
case RuleOutcome.Passed:
break;
}
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogInstanceFailed(logger, ex, instance.Name);
}
}
[LoggerMessage(Level = LogLevel.Warning, Message = "No instances configured under 'SQLInsight:Instances'.")]
private static partial void LogNoInstances(ILogger logger);
[LoggerMessage(Level = LogLevel.Warning, Message = "{Instance}: SqlClient could not connect, using the legacy ODBC connection. Encryption may be weaker.")]
private static partial void LogLegacyFallback(ILogger logger, string instance);
[LoggerMessage(Level = LogLevel.Information, Message = "{Instance}: {Release} ({Version}, {Edition})")]
private static partial void LogDetected(ILogger logger, string instance, SqlServerRelease release, SqlServerVersion version, string edition);
[LoggerMessage(Level = LogLevel.Warning, Message = "{Instance}: [{Severity}] {RuleId} {Title}")]
private static partial void LogFinding(ILogger logger, string instance, Severity severity, string ruleId, string title);
[LoggerMessage(Level = LogLevel.Information, Message = "{Instance}: {RuleId} {Outcome}: {Reason}")]
private static partial void LogNotEvaluated(ILogger logger, string instance, string ruleId, RuleOutcome outcome, string? reason);
[LoggerMessage(Level = LogLevel.Error, Message = "{Instance}: check failed")]
private static partial void LogInstanceFailed(ILogger logger, Exception exception, string instance);
}

View file

@ -0,0 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug"
}
}
}

View file

@ -0,0 +1,21 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"SQLInsight": {
"Interval": "00:15:00",
"Instances": [
{
"Name": "eksempel-sql01",
"Server": "sql01.eksempel.local",
"Authentication": "Windows",
"Provider": "Auto",
"Encryption": "Mandatory",
"TrustServerCertificate": false
}
]
}
}

View file

@ -0,0 +1,66 @@
using System.Data.Odbc;
using Microsoft.Data.SqlClient;
namespace SQLInsight.Connectivity;
internal static class ConnectionStrings
{
public const string ApplicationName = "SQLInsight Pro";
public static string ForSqlClient(ConnectionTarget target)
{
var builder = new SqlConnectionStringBuilder
{
DataSource = target.Server,
InitialCatalog = "master",
ApplicationName = ApplicationName,
ConnectTimeout = (int)target.ConnectTimeout.TotalSeconds,
Encrypt = target.Encryption == EncryptionMode.Mandatory ? SqlConnectionEncryptOption.Mandatory : SqlConnectionEncryptOption.Optional,
TrustServerCertificate = target.TrustServerCertificate,
// Monitoring must never hold a pooled session with leftover state on a busy server.
Pooling = false,
};
if (target.Authentication == AuthenticationMode.Windows)
{
builder.IntegratedSecurity = true;
}
else
{
builder.UserID = RequireUserName(target);
builder.Password = target.Password ?? string.Empty;
}
return builder.ConnectionString;
}
public static string ForOdbc(ConnectionTarget target)
{
var builder = new OdbcConnectionStringBuilder
{
Driver = target.OdbcDriver,
};
builder["Server"] = target.Server;
builder["Database"] = "master";
builder["APP"] = ApplicationName;
builder["Encrypt"] = target.Encryption == EncryptionMode.Mandatory ? "yes" : "no";
builder["TrustServerCertificate"] = target.TrustServerCertificate ? "yes" : "no";
if (target.Authentication == AuthenticationMode.Windows)
{
builder["Trusted_Connection"] = "yes";
}
else
{
builder["UID"] = RequireUserName(target);
builder["PWD"] = target.Password ?? string.Empty;
}
return builder.ConnectionString;
}
private static string RequireUserName(ConnectionTarget target) =>
string.IsNullOrWhiteSpace(target.UserName)
? throw new ArgumentException($"A user name is required for SQL login authentication to '{target.Server}'.", nameof(target))
: target.UserName;
}

View file

@ -0,0 +1,55 @@
namespace SQLInsight.Connectivity;
/// <summary>
/// How to reach one monitored instance.
/// </summary>
public sealed record ConnectionTarget
{
/// <summary>Server name, <c>host\instance</c> or <c>host,port</c>.</summary>
public required string Server { get; init; }
public AuthenticationMode Authentication { get; init; } = AuthenticationMode.Windows;
/// <summary>SQL login name. Only used with <see cref="AuthenticationMode.SqlLogin"/>.</summary>
public string? UserName { get; init; }
/// <summary>SQL login password. Only used with <see cref="AuthenticationMode.SqlLogin"/>.</summary>
public string? Password { get; init; }
public ConnectionProvider Provider { get; init; } = ConnectionProvider.Auto;
/// <summary>
/// Whether the connection must be encrypted. Mandatory is the default; old unpatched instances that only speak
/// TLS 1.0 need <see cref="EncryptionMode.Optional"/>, which the agent reports as a warning.
/// </summary>
public EncryptionMode Encryption { get; init; } = EncryptionMode.Mandatory;
/// <summary>Skip certificate validation. Off by default; must be a deliberate choice.</summary>
public bool TrustServerCertificate { get; init; }
/// <summary>ODBC driver used for legacy instances.</summary>
public string OdbcDriver { get; init; } = "ODBC Driver 18 for SQL Server";
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15);
}
public enum AuthenticationMode
{
/// <summary>Integrated security. Preferred; run the agent as a gMSA.</summary>
Windows,
SqlLogin,
}
public enum ConnectionProvider
{
/// <summary>Try Microsoft.Data.SqlClient, fall back to ODBC when the instance is too old for it.</summary>
Auto,
SqlClient,
Odbc,
}
public enum EncryptionMode
{
Mandatory,
Optional,
}

View file

@ -0,0 +1,9 @@
namespace SQLInsight.Connectivity;
public interface ISqlConnectionFactory
{
/// <summary>
/// Opens a connection to <paramref name="target"/> with monitoring session settings applied.
/// </summary>
Task<OpenedConnection> OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,62 @@
using System.Data.Common;
using SQLInsight.Core.Instances;
namespace SQLInsight.Connectivity;
/// <summary>
/// Reads version, edition and key settings from an instance. Works on every version from SQL Server 2000:
/// only <c>SERVERPROPERTY</c> is used, and properties an older version does not know return NULL.
/// </summary>
public static class InstanceProbe
{
internal const string Query = """
SELECT
CAST(SERVERPROPERTY('ServerName') AS nvarchar(256)),
CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(128)),
CAST(SERVERPROPERTY('ProductLevel') AS nvarchar(128)),
CAST(SERVERPROPERTY('ProductUpdateLevel') AS nvarchar(128)),
CAST(SERVERPROPERTY('Edition') AS nvarchar(128)),
CAST(SERVERPROPERTY('EngineEdition') AS int),
CAST(SERVERPROPERTY('IsClustered') AS int),
CAST(SERVERPROPERTY('IsHadrEnabled') AS int)
""";
public static async Task<InstanceInfo> DetectAsync(DbConnection connection, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(connection);
await using var command = connection.CreateCommand();
command.CommandText = Query;
command.CommandTimeout = 30;
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
throw new InvalidOperationException("SERVERPROPERTY returned no rows.");
}
var productVersion = GetString(reader, 1);
if (!SqlServerVersion.TryParse(productVersion, out var version))
{
throw new InvalidOperationException($"Could not parse ProductVersion '{productVersion}'.");
}
return new InstanceInfo
{
ServerName = GetString(reader, 0) ?? string.Empty,
Version = version,
ProductLevel = GetString(reader, 2),
ProductUpdateLevel = GetString(reader, 3),
Edition = GetString(reader, 4) ?? string.Empty,
EngineEdition = reader.IsDBNull(5) ? EngineEdition.Unknown : (EngineEdition)reader.GetInt32(5),
IsClustered = GetBool(reader, 6),
IsHadrEnabled = GetBool(reader, 7),
};
}
private static string? GetString(DbDataReader reader, int ordinal) =>
reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
private static bool? GetBool(DbDataReader reader, int ordinal) =>
reader.IsDBNull(ordinal) ? null : reader.GetInt32(ordinal) == 1;
}

View file

@ -0,0 +1,42 @@
using System.Security.Authentication;
namespace SQLInsight.Connectivity;
/// <summary>
/// Decides whether a SqlClient connection failure looks like "this instance is too old for SqlClient"
/// (TLS 1.0-only, unsupported TDS version) rather than a network, login or permission problem.
/// Only the former should trigger the ODBC fallback; the others would fail on ODBC too and hide the real cause.
/// </summary>
internal static class LegacyFailureClassifier
{
private static readonly string[] LegacyMessageFragments =
[
"pre-login handshake",
"SSL Provider",
"TLS",
"protocol version",
"server version is not supported",
"does not support the requested protocol",
];
public static bool IsLegacyIncompatibility(Exception exception)
{
for (var current = exception; current is not null; current = current.InnerException)
{
if (current is AuthenticationException)
{
return true;
}
foreach (var fragment in LegacyMessageFragments)
{
if (current.Message.Contains(fragment, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
}

View file

@ -0,0 +1,22 @@
using System.Data.Common;
namespace SQLInsight.Connectivity;
/// <summary>
/// An open, monitoring-hardened connection and how it was established.
/// </summary>
public sealed class OpenedConnection(DbConnection connection, ConnectionProvider provider, bool usedLegacyFallback)
: IAsyncDisposable, IDisposable
{
public DbConnection Connection { get; } = connection;
/// <summary>The provider actually used: <see cref="ConnectionProvider.SqlClient"/> or <see cref="ConnectionProvider.Odbc"/>.</summary>
public ConnectionProvider Provider { get; } = provider;
/// <summary>True when SqlClient failed and the ODBC fallback was used.</summary>
public bool UsedLegacyFallback { get; } = usedLegacyFallback;
public ValueTask DisposeAsync() => Connection.DisposeAsync();
public void Dispose() => Connection.Dispose();
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SQLInsight.Connectivity</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="System.Data.Odbc" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SQLInsight.Connectivity.Tests" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,75 @@
using System.Data.Common;
using System.Data.Odbc;
using Microsoft.Data.SqlClient;
namespace SQLInsight.Connectivity;
/// <summary>
/// Opens monitoring connections with Microsoft.Data.SqlClient, falling back to ODBC for instances too old for it.
/// </summary>
public sealed class SqlConnectionFactory : ISqlConnectionFactory
{
// Keeps the monitoring session from ever being the one that blocks or wins a deadlock on the monitored server.
// Every statement here is valid on SQL Server 2000.
internal const string SessionSettings =
"SET LOCK_TIMEOUT 5000; SET DEADLOCK_PRIORITY LOW; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;";
private readonly Func<ConnectionTarget, DbConnection> _createSqlClient;
private readonly Func<ConnectionTarget, DbConnection> _createOdbc;
public SqlConnectionFactory()
: this(
t => new SqlConnection(ConnectionStrings.ForSqlClient(t)),
t => new OdbcConnection(ConnectionStrings.ForOdbc(t)))
{
}
internal SqlConnectionFactory(
Func<ConnectionTarget, DbConnection> createSqlClient,
Func<ConnectionTarget, DbConnection> createOdbc)
{
_createSqlClient = createSqlClient;
_createOdbc = createOdbc;
}
public async Task<OpenedConnection> OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(target);
switch (target.Provider)
{
case ConnectionProvider.SqlClient:
return new OpenedConnection(await OpenAsync(_createSqlClient(target), cancellationToken).ConfigureAwait(false), ConnectionProvider.SqlClient, false);
case ConnectionProvider.Odbc:
return new OpenedConnection(await OpenAsync(_createOdbc(target), cancellationToken).ConfigureAwait(false), ConnectionProvider.Odbc, false);
}
try
{
return new OpenedConnection(await OpenAsync(_createSqlClient(target), cancellationToken).ConfigureAwait(false), ConnectionProvider.SqlClient, false);
}
catch (Exception ex) when (ex is not OperationCanceledException && LegacyFailureClassifier.IsLegacyIncompatibility(ex))
{
return new OpenedConnection(await OpenAsync(_createOdbc(target), cancellationToken).ConfigureAwait(false), ConnectionProvider.Odbc, true);
}
}
private static async Task<DbConnection> OpenAsync(DbConnection connection, CancellationToken cancellationToken)
{
try
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var command = connection.CreateCommand();
command.CommandText = SessionSettings;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return connection;
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
}
}

View file

@ -0,0 +1,57 @@
namespace SQLInsight.Contracts;
/// <summary>
/// What an agent sends to SQLInsight Hub when findings sync is enabled. This is a wire contract between
/// independently released agents and the hub: add optional members only, and bump
/// <see cref="CurrentSchemaVersion"/> for anything else.
/// </summary>
/// <remarks>
/// Only findings and instance metadata are sent. Table data is never sent, and query text only when the customer
/// has allowed it.
/// </remarks>
public sealed record FindingsSubmission
{
public const int CurrentSchemaVersion = 1;
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
public required string AgentVersion { get; init; }
public required string RulePackVersion { get; init; }
public required DateTimeOffset CollectedAt { get; init; }
public required InstanceSnapshot Instance { get; init; }
public required IReadOnlyList<RuleResultDto> Results { get; init; }
}
public sealed record InstanceSnapshot
{
/// <summary>A stable, customer-chosen name for the instance. The hub keys history on it.</summary>
public required string InstanceKey { get; init; }
public required string ProductVersion { get; init; }
public string? ProductLevel { get; init; }
public string? ProductUpdateLevel { get; init; }
public required string Edition { get; init; }
}
public sealed record RuleResultDto
{
public required string RuleId { get; init; }
/// <summary>Passed, Findings, NotMeasurable or Error.</summary>
public required string Outcome { get; init; }
public string? Reason { get; init; }
public IReadOnlyList<FindingDto> Findings { get; init; } = [];
}
public sealed record FindingDto
{
/// <summary>Info, Low, Medium, High or Critical.</summary>
public required string Severity { get; init; }
public required string Title { get; init; }
public required string Explanation { get; init; }
public required string Recommendation { get; init; }
public IReadOnlyList<EvidenceDto> Evidence { get; init; } = [];
}
public sealed record EvidenceDto(string Name, string Value);

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SQLInsight.Contracts</RootNamespace>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,29 @@
namespace SQLInsight.Core.Findings;
/// <summary>
/// One problem found on an instance, with everything needed to understand and fix it.
/// </summary>
public sealed record Finding
{
public required string RuleId { get; init; }
public required Severity Severity { get; init; }
public required string Title { get; init; }
/// <summary>Why this is a problem.</summary>
public required string Explanation { get; init; }
/// <summary>What to do about it.</summary>
public required string Recommendation { get; init; }
/// <summary>The measurements that triggered the finding.</summary>
public IReadOnlyList<Evidence> Evidence { get; init; } = [];
/// <summary>A suggested fix script. Shown to the user, never executed automatically.</summary>
public string? FixScript { get; init; }
public IReadOnlyList<Reference> References { get; init; } = [];
}
public sealed record Evidence(string Name, string Value);
public sealed record Reference(string Title, Uri Url);

View file

@ -0,0 +1,10 @@
namespace SQLInsight.Core.Findings;
public enum Severity
{
Info = 0,
Low = 1,
Medium = 2,
High = 3,
Critical = 4,
}

View file

@ -0,0 +1,49 @@
namespace SQLInsight.Core.Instances;
/// <summary>
/// Something a collector or rule depends on that not every SQL Server version or configuration offers.
/// </summary>
public enum Capability
{
/// <summary>Dynamic management views (<c>sys.dm_*</c>). SQL Server 2005+.</summary>
DynamicManagementViews,
/// <summary>The always-on default trace. SQL Server 2005+.</summary>
DefaultTrace,
/// <summary>The <c>VIEW SERVER STATE</c> permission, so monitoring does not need sysadmin. SQL Server 2005+.</summary>
ViewServerStatePermission,
/// <summary>Extended Events. SQL Server 2008+.</summary>
ExtendedEvents,
/// <summary>The built-in <c>system_health</c> Extended Events session (deadlock graphs). SQL Server 2008+.</summary>
SystemHealthSession,
/// <summary><c>system_health</c> writes to a file target, so history survives the ring buffer. SQL Server 2012+.</summary>
SystemHealthFileTarget,
/// <summary>Always On availability group DMVs exist. SQL Server 2012+.</summary>
AvailabilityGroupDmvs,
/// <summary>Always On is enabled on this instance (runtime setting, not just the version).</summary>
HadrEnabled,
/// <summary>The <c>CONNECT ANY DATABASE</c> permission. SQL Server 2014+.</summary>
ConnectAnyDatabasePermission,
/// <summary>Query Store. SQL Server 2016+.</summary>
QueryStore,
/// <summary><c>sys.dm_exec_session_wait_stats</c>. SQL Server 2016+.</summary>
SessionWaitStats,
/// <summary><c>DATABASEPROPERTYEX(..., 'LastGoodCheckDbTime')</c>, so CHECKDB status does not need sysadmin. SQL Server 2016 SP2+.</summary>
LastGoodCheckDbTime,
/// <summary><c>sys.dm_exec_query_plan_stats</c> (last actual execution plan). SQL Server 2019+.</summary>
LastActualPlan,
/// <summary>Granular server permissions such as <c>VIEW SERVER PERFORMANCE STATE</c>. SQL Server 2022+.</summary>
GranularServerPermissions,
}

View file

@ -0,0 +1,19 @@
namespace SQLInsight.Core.Instances;
/// <summary>
/// Values of <c>SERVERPROPERTY('EngineEdition')</c>.
/// </summary>
public enum EngineEdition
{
Unknown = 0,
PersonalOrDesktop = 1,
Standard = 2,
Enterprise = 3,
Express = 4,
AzureSqlDatabase = 5,
AzureSynapse = 6,
AzureSqlManagedInstance = 8,
AzureSqlEdge = 9,
AzureSynapseServerless = 11,
FabricSqlDatabase = 12,
}

View file

@ -0,0 +1,67 @@
namespace SQLInsight.Core.Instances;
/// <summary>
/// The capabilities an instance actually has, derived from its version and runtime settings.
/// </summary>
public sealed class InstanceCapabilities
{
// Minimum build for each version-gated capability.
private static readonly Dictionary<Capability, SqlServerVersion> MinimumVersion =
new Dictionary<Capability, SqlServerVersion>
{
[Capability.DynamicManagementViews] = new(9, 0, 0, 0),
[Capability.DefaultTrace] = new(9, 0, 0, 0),
[Capability.ViewServerStatePermission] = new(9, 0, 0, 0),
[Capability.ExtendedEvents] = new(10, 0, 0, 0),
[Capability.SystemHealthSession] = new(10, 0, 0, 0),
[Capability.SystemHealthFileTarget] = new(11, 0, 0, 0),
[Capability.AvailabilityGroupDmvs] = new(11, 0, 0, 0),
[Capability.ConnectAnyDatabasePermission] = new(12, 0, 0, 0),
[Capability.QueryStore] = new(13, 0, 0, 0),
[Capability.SessionWaitStats] = new(13, 0, 0, 0),
[Capability.LastGoodCheckDbTime] = new(13, 0, 5026, 0), // 2016 SP2
[Capability.LastActualPlan] = new(15, 0, 0, 0),
[Capability.GranularServerPermissions] = new(16, 0, 0, 0),
};
private readonly HashSet<Capability> _capabilities;
private InstanceCapabilities(HashSet<Capability> capabilities) => _capabilities = capabilities;
public IReadOnlySet<Capability> All => _capabilities;
public static InstanceCapabilities From(InstanceInfo instance)
{
ArgumentNullException.ThrowIfNull(instance);
var set = new HashSet<Capability>();
foreach (var (capability, minimum) in MinimumVersion)
{
if (instance.Version >= minimum)
{
set.Add(capability);
}
}
if (instance.IsHadrEnabled == true && set.Contains(Capability.AvailabilityGroupDmvs))
{
set.Add(Capability.HadrEnabled);
}
return new InstanceCapabilities(set);
}
public bool Has(Capability capability) => _capabilities.Contains(capability);
public IReadOnlyList<Capability> Missing(IEnumerable<Capability> required)
{
ArgumentNullException.ThrowIfNull(required);
return required.Where(c => !_capabilities.Contains(c)).Distinct().ToList();
}
/// <summary>
/// The minimum version for a version-gated capability, or <see langword="null"/> for runtime-only capabilities.
/// </summary>
public static SqlServerVersion? MinimumVersionFor(Capability capability) =>
MinimumVersion.TryGetValue(capability, out var version) ? version : null;
}

View file

@ -0,0 +1,26 @@
namespace SQLInsight.Core.Instances;
/// <summary>
/// What an instance reports about itself. Collected with a query that works on every version from SQL Server 2000.
/// Properties that older versions do not expose are <see langword="null"/>.
/// </summary>
public sealed record InstanceInfo
{
public required string ServerName { get; init; }
public required SqlServerVersion Version { get; init; }
public required string Edition { get; init; }
public required EngineEdition EngineEdition { get; init; }
/// <summary>RTM, SP1, SP2 … (<c>ProductLevel</c>).</summary>
public string? ProductLevel { get; init; }
/// <summary>CU level such as <c>CU14</c> (<c>ProductUpdateLevel</c>, only on patched 2012 and later).</summary>
public string? ProductUpdateLevel { get; init; }
public bool? IsClustered { get; init; }
/// <summary>Whether Always On availability groups are enabled (2012 and later).</summary>
public bool? IsHadrEnabled { get; init; }
public SqlServerRelease Release => Version.Release;
}

View file

@ -0,0 +1,39 @@
namespace SQLInsight.Core.Instances;
/// <summary>
/// A SQL Server release line. Values are ordered chronologically so releases can be compared.
/// </summary>
public enum SqlServerRelease
{
Unknown = 0,
SqlServer2000 = 2000,
SqlServer2005 = 2005,
SqlServer2008 = 2008,
SqlServer2008R2 = 2009,
SqlServer2012 = 2012,
SqlServer2014 = 2014,
SqlServer2016 = 2016,
SqlServer2017 = 2017,
SqlServer2019 = 2019,
SqlServer2022 = 2022,
SqlServer2025 = 2025,
}
public static class SqlServerReleaseExtensions
{
public static string DisplayName(this SqlServerRelease release) => release switch
{
SqlServerRelease.SqlServer2000 => "SQL Server 2000",
SqlServerRelease.SqlServer2005 => "SQL Server 2005",
SqlServerRelease.SqlServer2008 => "SQL Server 2008",
SqlServerRelease.SqlServer2008R2 => "SQL Server 2008 R2",
SqlServerRelease.SqlServer2012 => "SQL Server 2012",
SqlServerRelease.SqlServer2014 => "SQL Server 2014",
SqlServerRelease.SqlServer2016 => "SQL Server 2016",
SqlServerRelease.SqlServer2017 => "SQL Server 2017",
SqlServerRelease.SqlServer2019 => "SQL Server 2019",
SqlServerRelease.SqlServer2022 => "SQL Server 2022",
SqlServerRelease.SqlServer2025 => "SQL Server 2025",
_ => "Ukendt SQL Server-version",
};
}

View file

@ -0,0 +1,77 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace SQLInsight.Core.Instances;
/// <summary>
/// A SQL Server build number as returned by <c>SERVERPROPERTY('ProductVersion')</c>, e.g. <c>16.0.4135.4</c>.
/// </summary>
public readonly record struct SqlServerVersion(int Major, int Minor, int Build, int Revision)
: IComparable<SqlServerVersion>
{
public SqlServerRelease Release => (Major, Minor) switch
{
(8, _) => SqlServerRelease.SqlServer2000,
(9, _) => SqlServerRelease.SqlServer2005,
(10, >= 50) => SqlServerRelease.SqlServer2008R2,
(10, _) => SqlServerRelease.SqlServer2008,
(11, _) => SqlServerRelease.SqlServer2012,
(12, _) => SqlServerRelease.SqlServer2014,
(13, _) => SqlServerRelease.SqlServer2016,
(14, _) => SqlServerRelease.SqlServer2017,
(15, _) => SqlServerRelease.SqlServer2019,
(16, _) => SqlServerRelease.SqlServer2022,
(17, _) => SqlServerRelease.SqlServer2025,
_ => SqlServerRelease.Unknown,
};
public static SqlServerVersion Parse(string value) =>
TryParse(value, out var version)
? version
: throw new FormatException($"'{value}' is not a valid SQL Server version number.");
public static bool TryParse([NotNullWhen(true)] string? value, out SqlServerVersion version)
{
version = default;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var parts = value.Trim().Split('.');
if (parts.Length is < 2 or > 4)
{
return false;
}
var numbers = new int[4];
for (var i = 0; i < parts.Length; i++)
{
if (!int.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out numbers[i]))
{
return false;
}
}
version = new SqlServerVersion(numbers[0], numbers[1], numbers[2], numbers[3]);
return true;
}
public int CompareTo(SqlServerVersion other)
{
var result = Major.CompareTo(other.Major);
if (result != 0) return result;
result = Minor.CompareTo(other.Minor);
if (result != 0) return result;
result = Build.CompareTo(other.Build);
return result != 0 ? result : Revision.CompareTo(other.Revision);
}
public static bool operator <(SqlServerVersion left, SqlServerVersion right) => left.CompareTo(right) < 0;
public static bool operator >(SqlServerVersion left, SqlServerVersion right) => left.CompareTo(right) > 0;
public static bool operator <=(SqlServerVersion left, SqlServerVersion right) => left.CompareTo(right) <= 0;
public static bool operator >=(SqlServerVersion left, SqlServerVersion right) => left.CompareTo(right) >= 0;
public override string ToString() =>
string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Build}.{Revision}");
}

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SQLInsight.Core</RootNamespace>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,7 @@
// Central ingestion API for findings from paying customers, backed by PostgreSQL. Placeholder until Fase 3.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.Run();

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5296",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7203;http://localhost:5296",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>SQLInsight.Hub</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Contracts\SQLInsight.Contracts.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,12 @@
using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Lifecycle;
namespace SQLInsight.Rules;
public static class BuiltInRules
{
public static IReadOnlyList<IRule> Create() =>
[
new SupportLifecycleRule(SupportLifecycleCatalog.LoadEmbedded()),
];
}

View file

@ -0,0 +1,22 @@
using SQLInsight.Core.Instances;
namespace SQLInsight.Rules.Engine;
/// <summary>
/// One piece of SQL Server expertise, expressed as a testable check.
/// </summary>
public interface IRule
{
/// <summary>Stable identifier, e.g. <c>SQLI-VER-001</c>. Never reused for a different check.</summary>
string Id { get; }
string Title { get; }
/// <summary>
/// What the instance must support for this rule to be evaluated. If any are missing, the rule is reported as
/// not measurable instead of being run.
/// </summary>
IReadOnlyCollection<Capability> RequiredCapabilities { get; }
RuleEvaluation Evaluate(RuleContext context);
}

View file

@ -0,0 +1,5 @@
using SQLInsight.Core.Instances;
namespace SQLInsight.Rules.Engine;
public sealed record RuleContext(InstanceInfo Instance, InstanceCapabilities Capabilities, DateTimeOffset Now);

View file

@ -0,0 +1,71 @@
using SQLInsight.Core.Instances;
namespace SQLInsight.Rules.Engine;
/// <summary>
/// Runs rules against an instance. A rule that cannot run is reported as not measurable or as an error,
/// never silently skipped, so an old instance never looks healthier than a new one.
/// </summary>
public sealed class RuleEngine
{
private readonly IReadOnlyList<IRule> _rules;
public RuleEngine(IEnumerable<IRule> rules)
{
ArgumentNullException.ThrowIfNull(rules);
_rules = rules.ToList();
var duplicate = _rules.GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
if (duplicate is not null)
{
throw new ArgumentException($"Rule id '{duplicate.Key}' is registered more than once.", nameof(rules));
}
}
public IReadOnlyList<RuleResult> Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
return _rules.Select(rule => Evaluate(rule, context)).ToList();
}
private static RuleResult Evaluate(IRule rule, RuleContext context)
{
var missing = context.Capabilities.Missing(rule.RequiredCapabilities);
if (missing.Count > 0)
{
return new RuleResult
{
RuleId = rule.Id,
RuleTitle = rule.Title,
Outcome = RuleOutcome.NotMeasurable,
MissingCapabilities = missing,
Reason = $"Ikke målbar på {context.Instance.Release.DisplayName()}: kræver {string.Join(", ", missing)}.",
};
}
try
{
var evaluation = rule.Evaluate(context);
return new RuleResult
{
RuleId = rule.Id,
RuleTitle = rule.Title,
Outcome = evaluation.Outcome,
Findings = evaluation.Findings,
Reason = evaluation.Reason,
};
}
#pragma warning disable CA1031 // One faulty rule must not stop the others; the failure is reported, not hidden.
catch (Exception ex)
#pragma warning restore CA1031
{
return new RuleResult
{
RuleId = rule.Id,
RuleTitle = rule.Title,
Outcome = RuleOutcome.Error,
Reason = $"{ex.GetType().Name}: {ex.Message}",
};
}
}
}

View file

@ -0,0 +1,52 @@
using SQLInsight.Core.Findings;
namespace SQLInsight.Rules.Engine;
/// <summary>
/// What a rule concluded. Created through the static factory methods.
/// </summary>
public sealed record RuleEvaluation
{
private RuleEvaluation(RuleOutcome outcome, IReadOnlyList<Finding> findings, string? reason)
{
Outcome = outcome;
Findings = findings;
Reason = reason;
}
public RuleOutcome Outcome { get; }
public IReadOnlyList<Finding> Findings { get; }
/// <summary>Why the rule could not be measured, if it could not.</summary>
public string? Reason { get; }
public static RuleEvaluation Passed() => new(RuleOutcome.Passed, [], null);
public static RuleEvaluation Found(params Finding[] findings)
{
ArgumentNullException.ThrowIfNull(findings);
if (findings.Length == 0)
{
throw new ArgumentException("At least one finding is required. Use Passed() when there is none.", nameof(findings));
}
return new(RuleOutcome.Findings, findings, null);
}
public static RuleEvaluation NotMeasurable(string reason) => new(RuleOutcome.NotMeasurable, [], reason);
}
public enum RuleOutcome
{
/// <summary>The rule ran and found nothing wrong.</summary>
Passed,
/// <summary>The rule ran and found one or more problems.</summary>
Findings,
/// <summary>The rule could not be evaluated on this instance. This is never the same as <see cref="Passed"/>.</summary>
NotMeasurable,
/// <summary>The rule failed while running. This is never the same as <see cref="Passed"/>.</summary>
Error,
}

View file

@ -0,0 +1,21 @@
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
namespace SQLInsight.Rules.Engine;
/// <summary>
/// The result of running one rule against one instance.
/// </summary>
public sealed record RuleResult
{
public required string RuleId { get; init; }
public required string RuleTitle { get; init; }
public required RuleOutcome Outcome { get; init; }
public IReadOnlyList<Finding> Findings { get; init; } = [];
/// <summary>Capabilities the instance lacks, when the outcome is <see cref="RuleOutcome.NotMeasurable"/> for that reason.</summary>
public IReadOnlyList<Capability> MissingCapabilities { get; init; } = [];
/// <summary>Why the rule was not measurable or failed.</summary>
public string? Reason { get; init; }
}

View file

@ -0,0 +1,42 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using SQLInsight.Core.Instances;
namespace SQLInsight.Rules.Lifecycle;
public sealed record SupportLifecycle(SqlServerRelease Release, DateOnly? MainstreamEnd, DateOnly? ExtendedEnd);
/// <summary>
/// Microsoft's support dates per SQL Server release. Shipped as data so it can be updated with the rule pack.
/// </summary>
public sealed class SupportLifecycleCatalog
{
private const string ResourceName = "SQLInsight.Rules.support-lifecycle.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() },
};
private readonly Dictionary<SqlServerRelease, SupportLifecycle> _entries;
public SupportLifecycleCatalog(IEnumerable<SupportLifecycle> entries)
{
ArgumentNullException.ThrowIfNull(entries);
_entries = entries.ToDictionary(e => e.Release);
}
public static SupportLifecycleCatalog LoadEmbedded()
{
using var stream = typeof(SupportLifecycleCatalog).Assembly.GetManifestResourceStream(ResourceName)
?? throw new InvalidOperationException($"Embedded resource '{ResourceName}' is missing.");
var document = JsonSerializer.Deserialize<CatalogDocument>(stream, JsonOptions)
?? throw new InvalidOperationException("The support lifecycle catalog is empty.");
return new SupportLifecycleCatalog(document.Releases);
}
public SupportLifecycle? Find(SqlServerRelease release) => _entries.GetValueOrDefault(release);
private sealed record CatalogDocument(IReadOnlyList<SupportLifecycle> Releases);
}

View file

@ -0,0 +1,96 @@
using System.Globalization;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Lifecycle;
/// <summary>
/// Flags instances running a SQL Server release that is out of, or about to leave, Microsoft support.
/// Needs no capabilities, so it runs on every version from SQL Server 2000.
/// </summary>
public sealed class SupportLifecycleRule(SupportLifecycleCatalog catalog) : IRule
{
public const string RuleId = "SQLI-VER-001";
/// <summary>How long before end of extended support the finding escalates to High.</summary>
public static readonly TimeSpan ExtendedEndWarningWindow = TimeSpan.FromDays(365);
private static readonly Reference LifecycleReference =
new("Microsoft Lifecycle: SQL Server", new Uri("https://learn.microsoft.com/lifecycle/products/?terms=SQL%20Server"));
public string Id => RuleId;
public string Title => "SQL Server-versionens supportstatus";
public IReadOnlyCollection<Capability> RequiredCapabilities => [];
public RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var release = context.Instance.Release;
if (release == SqlServerRelease.Unknown)
{
return RuleEvaluation.NotMeasurable(
$"Versionen {context.Instance.Version} er ukendt for regelpakken. Opdater SQLInsight Pro.");
}
var lifecycle = catalog.Find(release);
if (lifecycle?.MainstreamEnd is not { } mainstreamEnd || lifecycle.ExtendedEnd is not { } extendedEnd)
{
return RuleEvaluation.NotMeasurable(
$"Supportdatoer for {release.DisplayName()} findes endnu ikke i regelpakken.");
}
var today = DateOnly.FromDateTime(context.Now.UtcDateTime);
var name = release.DisplayName();
var evidence = new List<Evidence>
{
new("Version", $"{name} ({context.Instance.Version}, {context.Instance.ProductLevel ?? "ukendt niveau"})"),
new("Mainstream support slutter", Format(mainstreamEnd)),
new("Extended support slutter", Format(extendedEnd)),
};
if (today > extendedEnd)
{
return RuleEvaluation.Found(new Finding
{
RuleId = Id,
Severity = Severity.Critical,
Title = $"{name} er uden support fra Microsoft",
Explanation =
$"Extended support for {name} sluttede {Format(extendedEnd)}. Instansen får ingen sikkerhedsopdateringer, " +
"og kendte sårbarheder bliver ikke lukket. Microsoft yder heller ikke support ved nedbrud.",
Recommendation =
"Planlæg en opgradering til en supporteret version. Kør Data Migration Assistant for at finde " +
"kompatibilitetsproblemer, og behold databasernes compatibility level ved første opgradering, " +
"så query-planerne ikke ændrer sig samtidig.",
Evidence = evidence,
References = [LifecycleReference],
});
}
if (today > mainstreamEnd)
{
var closeToEnd = extendedEnd.ToDateTime(TimeOnly.MinValue) - today.ToDateTime(TimeOnly.MinValue)
<= ExtendedEndWarningWindow;
return RuleEvaluation.Found(new Finding
{
RuleId = Id,
Severity = closeToEnd ? Severity.High : Severity.Medium,
Title = closeToEnd
? $"{name} mister al support {Format(extendedEnd)}"
: $"{name} er i extended support",
Explanation =
$"Mainstream support for {name} sluttede {Format(mainstreamEnd)}. Der kommer kun sikkerhedsopdateringer, " +
$"ingen fejlrettelser eller nye CU'er. Fra {Format(extendedEnd)} kommer der slet ingen opdateringer.",
Recommendation = "Planlæg opgradering til en version i mainstream support, før extended support udløber.",
Evidence = evidence,
References = [LifecycleReference],
});
}
return RuleEvaluation.Passed();
}
private static string Format(DateOnly date) => date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,17 @@
{
"source": "https://learn.microsoft.com/lifecycle/products/?terms=SQL%20Server",
"comment": "End of mainstream and extended support per release. null means not yet published in the catalog.",
"releases": [
{ "release": "SqlServer2000", "mainstreamEnd": "2008-04-08", "extendedEnd": "2013-04-09" },
{ "release": "SqlServer2005", "mainstreamEnd": "2011-04-12", "extendedEnd": "2016-04-12" },
{ "release": "SqlServer2008", "mainstreamEnd": "2014-07-08", "extendedEnd": "2019-07-09" },
{ "release": "SqlServer2008R2", "mainstreamEnd": "2014-07-08", "extendedEnd": "2019-07-09" },
{ "release": "SqlServer2012", "mainstreamEnd": "2017-07-11", "extendedEnd": "2022-07-12" },
{ "release": "SqlServer2014", "mainstreamEnd": "2019-07-09", "extendedEnd": "2024-07-09" },
{ "release": "SqlServer2016", "mainstreamEnd": "2021-07-13", "extendedEnd": "2026-07-14" },
{ "release": "SqlServer2017", "mainstreamEnd": "2022-10-11", "extendedEnd": "2027-10-12" },
{ "release": "SqlServer2019", "mainstreamEnd": "2025-02-28", "extendedEnd": "2030-01-08" },
{ "release": "SqlServer2022", "mainstreamEnd": "2028-01-11", "extendedEnd": "2033-01-11" },
{ "release": "SqlServer2025", "mainstreamEnd": null, "extendedEnd": null }
]
}

View file

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>SQLInsight.Rules</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Lifecycle\support-lifecycle.json" LogicalName="SQLInsight.Rules.support-lifecycle.json" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,7 @@
// On-prem web UI (dashboard, findings, history). Placeholder until Fase 1.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.Run();

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5225",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7224;http://localhost:5225",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>SQLInsight.Web</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SQLInsight.Core\SQLInsight.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,42 @@
namespace SQLInsight.Connectivity.Tests;
public class ConnectionStringsTests
{
[Fact]
public void SqlClient_defaults_to_mandatory_encryption_with_certificate_validation()
{
var cs = ConnectionStrings.ForSqlClient(new ConnectionTarget { Server = "sql01" });
Assert.Contains("Encrypt=True", cs, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Trust Server Certificate=True", cs, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Integrated Security=True", cs, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Application Name=\"SQLInsight Pro\"", cs, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Odbc_uses_configured_driver_and_sql_login()
{
var cs = ConnectionStrings.ForOdbc(new ConnectionTarget
{
Server = "legacy01",
Authentication = AuthenticationMode.SqlLogin,
UserName = "sqlinsight",
Password = "secret",
Encryption = EncryptionMode.Optional,
OdbcDriver = "SQL Server",
});
Assert.Contains("Driver={SQL Server}", cs, StringComparison.OrdinalIgnoreCase);
Assert.Contains("UID=sqlinsight", cs, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Encrypt=no", cs, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Trusted_Connection", cs, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Sql_login_without_user_name_is_rejected()
{
var target = new ConnectionTarget { Server = "sql01", Authentication = AuthenticationMode.SqlLogin };
Assert.Throws<ArgumentException>(() => ConnectionStrings.ForSqlClient(target));
}
}

View file

@ -0,0 +1,32 @@
using System.Security.Authentication;
namespace SQLInsight.Connectivity.Tests;
public class LegacyFailureClassifierTests
{
[Fact]
public void Tls_authentication_failure_is_legacy()
{
var ex = new InvalidOperationException("outer", new AuthenticationException("handshake failed"));
Assert.True(LegacyFailureClassifier.IsLegacyIncompatibility(ex));
}
[Fact]
public void Pre_login_handshake_message_is_legacy()
{
var ex = new InvalidOperationException(
"A connection was successfully established with the server, but then an error occurred during the pre-login handshake.");
Assert.True(LegacyFailureClassifier.IsLegacyIncompatibility(ex));
}
[Theory]
[InlineData("Login failed for user 'x'.")]
[InlineData("A network-related or instance-specific error occurred while establishing a connection to SQL Server.")]
[InlineData("Cannot open database \"master\" requested by the login.")]
public void Login_and_network_failures_are_not_legacy(string message)
{
Assert.False(LegacyFailureClassifier.IsLegacyIncompatibility(new InvalidOperationException(message)));
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,146 @@
using System.Data;
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
using System.Security.Authentication;
namespace SQLInsight.Connectivity.Tests;
public class SqlConnectionFactoryTests
{
private static readonly ConnectionTarget Target = new() { Server = "sql01" };
[Fact]
public async Task Auto_falls_back_to_odbc_on_legacy_failure()
{
var odbc = new FakeConnection();
var factory = new SqlConnectionFactory(
_ => new FakeConnection(new AuthenticationException("TLS 1.0 only")),
_ => odbc);
await using var opened = await factory.OpenAsync(Target);
Assert.Equal(ConnectionProvider.Odbc, opened.Provider);
Assert.True(opened.UsedLegacyFallback);
Assert.Equal(SqlConnectionFactory.SessionSettings, odbc.ExecutedCommands.Single());
}
[Fact]
public async Task Auto_does_not_fall_back_on_login_failure()
{
var odbcCreated = false;
var factory = new SqlConnectionFactory(
_ => new FakeConnection(new InvalidOperationException("Login failed for user 'x'.")),
_ =>
{
odbcCreated = true;
return new FakeConnection();
});
await Assert.ThrowsAsync<InvalidOperationException>(() => factory.OpenAsync(Target));
Assert.False(odbcCreated);
}
[Fact]
public async Task Explicit_sqlclient_never_falls_back()
{
var factory = new SqlConnectionFactory(
_ => new FakeConnection(new AuthenticationException("TLS 1.0 only")),
_ => new FakeConnection());
await Assert.ThrowsAsync<AuthenticationException>(
() => factory.OpenAsync(Target with { Provider = ConnectionProvider.SqlClient }));
}
[Fact]
public async Task Session_settings_are_applied_on_open()
{
var sqlClient = new FakeConnection();
var factory = new SqlConnectionFactory(_ => sqlClient, _ => new FakeConnection());
await using var opened = await factory.OpenAsync(Target);
Assert.False(opened.UsedLegacyFallback);
Assert.Equal(SqlConnectionFactory.SessionSettings, sqlClient.ExecutedCommands.Single());
}
[Fact]
public async Task Failed_connection_is_disposed()
{
var failing = new FakeConnection(new InvalidOperationException("Login failed"));
var factory = new SqlConnectionFactory(_ => failing, _ => new FakeConnection());
await Assert.ThrowsAsync<InvalidOperationException>(() => factory.OpenAsync(Target));
Assert.True(failing.IsDisposed);
}
[SuppressMessage("Design", "CA1065", Justification = "Test double; unused members throw.")]
private sealed class FakeConnection(Exception? openFailure = null) : DbConnection
{
private ConnectionState _state = ConnectionState.Closed;
public List<string> ExecutedCommands { get; } = [];
public bool IsDisposed { get; private set; }
[AllowNull]
public override string ConnectionString { get; set; } = string.Empty;
public override string Database => "master";
public override string DataSource => "fake";
public override string ServerVersion => "16.0.0.0";
public override ConnectionState State => _state;
public override void Open()
{
if (openFailure is not null) throw openFailure;
_state = ConnectionState.Open;
}
public override Task OpenAsync(CancellationToken cancellationToken)
{
Open();
return Task.CompletedTask;
}
public override void Close() => _state = ConnectionState.Closed;
public override void ChangeDatabase(string databaseName) => throw new NotSupportedException();
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => throw new NotSupportedException();
protected override DbCommand CreateDbCommand() => new FakeCommand(this);
protected override void Dispose(bool disposing)
{
IsDisposed = true;
base.Dispose(disposing);
}
public override ValueTask DisposeAsync()
{
IsDisposed = true;
return base.DisposeAsync();
}
private sealed class FakeCommand(FakeConnection owner) : DbCommand
{
[AllowNull]
public override string CommandText { get; set; } = string.Empty;
public override int CommandTimeout { get; set; }
public override CommandType CommandType { get; set; }
public override bool DesignTimeVisible { get; set; }
public override UpdateRowSource UpdatedRowSource { get; set; }
protected override DbConnection? DbConnection { get; set; }
protected override DbParameterCollection DbParameterCollection => throw new NotSupportedException();
protected override DbTransaction? DbTransaction { get; set; }
public override int ExecuteNonQuery()
{
owner.ExecutedCommands.Add(CommandText);
return 0;
}
public override Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken) => Task.FromResult(ExecuteNonQuery());
public override object? ExecuteScalar() => throw new NotSupportedException();
public override void Prepare() { }
public override void Cancel() { }
protected override DbParameter CreateDbParameter() => throw new NotSupportedException();
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => throw new NotSupportedException();
}
}
}

View file

@ -0,0 +1,85 @@
using SQLInsight.Core.Instances;
namespace SQLInsight.Core.Tests;
public class InstanceCapabilitiesTests
{
private static InstanceInfo Instance(string version, bool? hadr = null) => new()
{
ServerName = "test",
Version = SqlServerVersion.Parse(version),
Edition = "Enterprise Edition",
EngineEdition = EngineEdition.Enterprise,
IsHadrEnabled = hadr,
};
[Fact]
public void Sql2000_has_no_version_gated_capabilities()
{
var capabilities = InstanceCapabilities.From(Instance("8.00.2039"));
Assert.Empty(capabilities.All);
}
[Fact]
public void Sql2005_has_dmvs_but_no_extended_events()
{
var capabilities = InstanceCapabilities.From(Instance("9.00.5000.00"));
Assert.True(capabilities.Has(Capability.DynamicManagementViews));
Assert.True(capabilities.Has(Capability.ViewServerStatePermission));
Assert.False(capabilities.Has(Capability.ExtendedEvents));
}
[Fact]
public void Sql2008_has_system_health_without_file_target()
{
var capabilities = InstanceCapabilities.From(Instance("10.0.6000.29"));
Assert.True(capabilities.Has(Capability.SystemHealthSession));
Assert.False(capabilities.Has(Capability.SystemHealthFileTarget));
}
[Theory]
[InlineData("13.0.4001.0", false)] // 2016 SP1
[InlineData("13.0.5026.0", true)] // 2016 SP2
[InlineData("14.0.1000.169", true)]
public void LastGoodCheckDbTime_requires_2016_sp2(string version, bool expected)
{
Assert.Equal(expected, InstanceCapabilities.From(Instance(version)).Has(Capability.LastGoodCheckDbTime));
}
[Fact]
public void Query_store_starts_with_2016()
{
Assert.False(InstanceCapabilities.From(Instance("12.0.6024.0")).Has(Capability.QueryStore));
Assert.True(InstanceCapabilities.From(Instance("13.0.6300.2")).Has(Capability.QueryStore));
}
[Fact]
public void Granular_permissions_start_with_2022()
{
Assert.False(InstanceCapabilities.From(Instance("15.0.4355.3")).Has(Capability.GranularServerPermissions));
Assert.True(InstanceCapabilities.From(Instance("16.0.4135.4")).Has(Capability.GranularServerPermissions));
}
[Theory]
[InlineData("15.0.4355.3", true, true)]
[InlineData("15.0.4355.3", false, false)]
[InlineData("15.0.4355.3", null, false)]
[InlineData("10.50.6000.34", true, false)] // property cannot be true before 2012; ignore bad data
public void Hadr_requires_both_version_and_runtime_setting(string version, bool? hadr, bool expected)
{
Assert.Equal(expected, InstanceCapabilities.From(Instance(version, hadr)).Has(Capability.HadrEnabled));
}
[Fact]
public void Missing_lists_only_absent_capabilities()
{
var capabilities = InstanceCapabilities.From(Instance("11.0.7001.0"));
var missing = capabilities.Missing([Capability.ExtendedEvents, Capability.QueryStore, Capability.LastActualPlan]);
Assert.Equal([Capability.QueryStore, Capability.LastActualPlan], missing);
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Core\SQLInsight.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,50 @@
using SQLInsight.Core.Instances;
namespace SQLInsight.Core.Tests;
public class SqlServerVersionTests
{
[Theory]
[InlineData("8.00.2039", SqlServerRelease.SqlServer2000)]
[InlineData("9.00.5000.00", SqlServerRelease.SqlServer2005)]
[InlineData("10.0.6000.29", SqlServerRelease.SqlServer2008)]
[InlineData("10.50.6000.34", SqlServerRelease.SqlServer2008R2)]
[InlineData("11.0.7001.0", SqlServerRelease.SqlServer2012)]
[InlineData("12.0.6024.0", SqlServerRelease.SqlServer2014)]
[InlineData("13.0.6300.2", SqlServerRelease.SqlServer2016)]
[InlineData("14.0.3456.2", SqlServerRelease.SqlServer2017)]
[InlineData("15.0.4355.3", SqlServerRelease.SqlServer2019)]
[InlineData("16.0.4135.4", SqlServerRelease.SqlServer2022)]
[InlineData("17.0.1000.7", SqlServerRelease.SqlServer2025)]
[InlineData("18.0.100.1", SqlServerRelease.Unknown)]
public void Maps_product_version_to_release(string productVersion, SqlServerRelease expected)
{
Assert.Equal(expected, SqlServerVersion.Parse(productVersion).Release);
}
[Fact]
public void Parses_sql2000_style_version_with_three_parts()
{
Assert.Equal(new SqlServerVersion(8, 0, 2039, 0), SqlServerVersion.Parse("8.00.2039"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("sixteen")]
[InlineData("16")]
[InlineData("16.0.1.2.3")]
[InlineData("-16.0.1.2")]
public void Rejects_invalid_versions(string? value)
{
Assert.False(SqlServerVersion.TryParse(value, out _));
}
[Fact]
public void Compares_by_build_not_by_text()
{
Assert.True(SqlServerVersion.Parse("13.0.5026.0") > SqlServerVersion.Parse("13.0.4001.0"));
Assert.True(SqlServerVersion.Parse("10.50.1600.1") > SqlServerVersion.Parse("10.0.6000.29"));
Assert.True(SqlServerVersion.Parse("9.0.1399.6") < SqlServerVersion.Parse("10.0.1600.22"));
}
}

View file

@ -0,0 +1,48 @@
using SQLInsight.Connectivity;
using SQLInsight.Core.Instances;
using SQLInsight.Rules;
using SQLInsight.Rules.Engine;
namespace SQLInsight.IntegrationTests;
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class InstanceProbeTests(SqlServerFixture sql)
{
private ConnectionTarget SaTarget => new()
{
Server = sql.Server,
Authentication = AuthenticationMode.SqlLogin,
UserName = "sa",
Password = new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(sql.SaConnectionString).Password,
Provider = ConnectionProvider.SqlClient,
// Test containers use a self-signed certificate. A deliberate choice, as the README requires.
TrustServerCertificate = true,
};
[Fact]
public async Task Detects_release_matching_the_container_image()
{
await using var opened = await new SqlConnectionFactory().OpenAsync(SaTarget);
var info = await InstanceProbe.DetectAsync(opened.Connection);
var expectedYear = SqlServerFixture.Image.Split(':')[1][..4];
Assert.Equal(expectedYear, ((int)info.Release).ToString(System.Globalization.CultureInfo.InvariantCulture));
Assert.Equal(EngineEdition.Enterprise, info.EngineEdition); // Developer edition reports Enterprise.
Assert.False(string.IsNullOrEmpty(info.ServerName));
}
[Fact]
public async Task Full_pipeline_evaluates_every_built_in_rule()
{
await using var opened = await new SqlConnectionFactory().OpenAsync(SaTarget);
var info = await InstanceProbe.DetectAsync(opened.Connection);
var context = new RuleContext(info, InstanceCapabilities.From(info), DateTimeOffset.UtcNow);
var results = new RuleEngine(BuiltInRules.Create()).Evaluate(context);
Assert.NotEmpty(results);
Assert.DoesNotContain(results, r => r.Outcome == RuleOutcome.Error);
}
}

View file

@ -0,0 +1,107 @@
using Microsoft.Data.SqlClient;
namespace SQLInsight.IntegrationTests;
/// <summary>
/// Runs db/permissions/grant-least-privilege.sql and proves that the agent login can read everything it needs,
/// and nothing else.
/// </summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class LeastPrivilegeTests(SqlServerFixture sql) : IAsyncLifetime
{
private const string Login = "sqlinsight_agent";
private const string Password = "Agent-Pa55word!";
private const string Database = "SQLInsightQsTest";
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"""
IF SUSER_ID(N'{Login}') IS NULL CREATE LOGIN [{Login}] WITH PASSWORD = N'{Password}', CHECK_POLICY = OFF;
IF DB_ID(N'{Database}') IS NULL CREATE DATABASE [{Database}];
""");
await sql.ExecuteAsSaAsync($"""
USE [{Database}];
ALTER DATABASE [{Database}] SET QUERY_STORE = ON;
IF OBJECT_ID(N'dbo.CustomerData') IS NULL
BEGIN
CREATE TABLE dbo.CustomerData (Id int NOT NULL PRIMARY KEY, Secret nvarchar(50) NOT NULL);
INSERT dbo.CustomerData VALUES (1, N'fortroligt');
END;
""");
var script = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Scripts", "grant-least-privilege.sql"));
script = script.Replace(@"N'DOMÆNE\svc-sqlinsight$'", $"N'{Login}'", StringComparison.Ordinal);
await sql.ExecuteAsSaAsync(script);
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Agent_login_is_not_sysadmin()
{
Assert.Equal(0, await ScalarAsync<int>("SELECT IS_SRVROLEMEMBER('sysadmin')"));
}
[Theory]
[InlineData("SELECT COUNT(*) FROM sys.dm_os_wait_stats")]
[InlineData("SELECT COUNT(*) FROM sys.dm_exec_requests r CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t")]
[InlineData("SELECT COUNT(*) FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) p")]
[InlineData("SELECT COUNT(*) FROM sys.dm_io_virtual_file_stats(NULL, NULL)")]
[InlineData("SELECT COUNT(*) FROM sys.dm_os_ring_buffers")]
[InlineData("SELECT COUNT(*) FROM sys.dm_xe_sessions WHERE name = 'system_health'")]
[InlineData("SELECT COUNT(*) FROM sys.configurations")]
[InlineData("SELECT COUNT(*) FROM msdb.dbo.backupset")]
[InlineData("SELECT COUNT(*) FROM msdb.dbo.sysjobs")]
[InlineData("SELECT COUNT(*) FROM msdb.dbo.sysjobhistory")]
[InlineData("SELECT COUNT(*) FROM msdb.dbo.suspect_pages")]
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.database_query_store_options")]
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.query_store_runtime_stats")]
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.indexes WHERE object_id = OBJECT_ID(N'[{Database}].dbo.CustomerData')")]
[InlineData($"SELECT COUNT(*) FROM [{Database}].sys.dm_db_index_usage_stats")]
[InlineData($"SELECT COUNT(*) WHERE DATABASEPROPERTYEX(N'{Database}', 'LastGoodCheckDbTime') IS NOT NULL")]
public async Task Agent_can_read_monitoring_sources(string query)
{
Assert.True(await ScalarAsync<int>(query) >= 0);
}
[Fact]
public async Task Agent_cannot_read_customer_data()
{
var ex = await Assert.ThrowsAsync<SqlException>(
() => ScalarAsync<int>($"SELECT COUNT(*) FROM [{Database}].dbo.CustomerData"));
Assert.Equal(229, ex.Number); // The SELECT permission was denied.
}
[Fact]
public async Task Agent_cannot_change_anything()
{
var ex = await Assert.ThrowsAsync<SqlException>(
() => ScalarAsync<int>($"USE [{Database}]; CREATE TABLE dbo.ShouldNotExist (Id int); SELECT 1"));
Assert.Equal(262, ex.Number); // CREATE TABLE permission denied.
}
[Fact]
public async Task Script_is_idempotent()
{
var script = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Scripts", "grant-least-privilege.sql"));
script = script.Replace(@"N'DOMÆNE\svc-sqlinsight$'", $"N'{Login}'", StringComparison.Ordinal);
await sql.ExecuteAsSaAsync(script);
}
private async Task<T> ScalarAsync<T>(string query)
{
var builder = new SqlConnectionStringBuilder(sql.SaConnectionString)
{
UserID = Login,
Password = Password,
InitialCatalog = "master",
};
await using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = query;
return (T)Convert.ChangeType((await command.ExecuteScalarAsync())!, typeof(T), System.Globalization.CultureInfo.InvariantCulture);
}
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Testcontainers.MsSql" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Connectivity\SQLInsight.Connectivity.csproj" />
<ProjectReference Include="..\..\src\SQLInsight.Rules\SQLInsight.Rules.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,48 @@
using Microsoft.Data.SqlClient;
using Testcontainers.MsSql;
namespace SQLInsight.IntegrationTests;
/// <summary>
/// One SQL Server container for the test run. The image is chosen with <c>SQLINSIGHT_MSSQL_IMAGE</c> so CI can run
/// the same tests against 2017, 2019, 2022 and 2025.
/// </summary>
public sealed class SqlServerFixture : IAsyncLifetime
{
public const string DefaultImage = "mcr.microsoft.com/mssql/server:2022-latest";
public static string Image => Environment.GetEnvironmentVariable("SQLINSIGHT_MSSQL_IMAGE") ?? DefaultImage;
private readonly MsSqlContainer _container = new MsSqlBuilder(Image).Build();
public string SaConnectionString => _container.GetConnectionString();
public string Server
{
get
{
var builder = new SqlConnectionStringBuilder(SaConnectionString);
return builder.DataSource;
}
}
public Task InitializeAsync() => _container.StartAsync();
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
public async Task ExecuteAsSaAsync(string sql)
{
await using var connection = new SqlConnection(SaConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.CommandTimeout = 120;
await command.ExecuteNonQueryAsync();
}
}
[CollectionDefinition(Name)]
public sealed class SqlServerTestGroup : ICollectionFixture<SqlServerFixture>
{
public const string Name = "SQL Server";
}

View file

@ -0,0 +1,94 @@
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Tests;
public class RuleEngineTests
{
private sealed class StubRule(string id, IReadOnlyCollection<Capability> required, Func<RuleContext, RuleEvaluation> evaluate) : IRule
{
public int Calls { get; private set; }
public string Id => id;
public string Title => $"Stub {id}";
public IReadOnlyCollection<Capability> RequiredCapabilities => required;
public RuleEvaluation Evaluate(RuleContext context)
{
Calls++;
return evaluate(context);
}
}
[Fact]
public void Rule_missing_capabilities_is_not_measurable_and_never_runs()
{
var rule = new StubRule("QS", [Capability.QueryStore], _ => RuleEvaluation.Passed());
var engine = new RuleEngine([rule]);
var result = Assert.Single(engine.Evaluate(TestInstances.Context("11.0.7001.0")));
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
Assert.Equal([Capability.QueryStore], result.MissingCapabilities);
Assert.Contains("SQL Server 2012", result.Reason, StringComparison.Ordinal);
Assert.Equal(0, rule.Calls);
}
[Fact]
public void Rule_with_capabilities_runs()
{
var rule = new StubRule("QS", [Capability.QueryStore], _ => RuleEvaluation.Passed());
var engine = new RuleEngine([rule]);
var result = Assert.Single(engine.Evaluate(TestInstances.Context("16.0.4135.4")));
Assert.Equal(RuleOutcome.Passed, result.Outcome);
Assert.Equal(1, rule.Calls);
}
[Fact]
public void Throwing_rule_is_reported_as_error_and_does_not_stop_others()
{
var failing = new StubRule("A", [], _ => throw new InvalidOperationException("boom"));
var finding = new Finding
{
RuleId = "B",
Severity = Severity.Low,
Title = "t",
Explanation = "e",
Recommendation = "r",
};
var working = new StubRule("B", [], _ => RuleEvaluation.Found(finding));
var engine = new RuleEngine([failing, working]);
var results = engine.Evaluate(TestInstances.Context("16.0.4135.4"));
Assert.Equal(RuleOutcome.Error, results[0].Outcome);
Assert.Contains("boom", results[0].Reason, StringComparison.Ordinal);
Assert.Equal(RuleOutcome.Findings, results[1].Outcome);
Assert.Same(finding, Assert.Single(results[1].Findings));
}
[Fact]
public void Duplicate_rule_ids_are_rejected()
{
var a = new StubRule("SAME", [], _ => RuleEvaluation.Passed());
var b = new StubRule("same", [], _ => RuleEvaluation.Passed());
Assert.Throws<ArgumentException>(() => new RuleEngine([a, b]));
}
[Fact]
public void Found_requires_at_least_one_finding()
{
Assert.Throws<ArgumentException>(() => RuleEvaluation.Found());
}
[Fact]
public void Built_in_rule_ids_are_unique()
{
var engine = new RuleEngine(BuiltInRules.Create());
Assert.NotEmpty(engine.Evaluate(TestInstances.Context("16.0.4135.4")));
}
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SQLInsight.Rules\SQLInsight.Rules.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,93 @@
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Lifecycle;
namespace SQLInsight.Rules.Tests;
public class SupportLifecycleRuleTests
{
private static readonly SupportLifecycleRule Rule = new(SupportLifecycleCatalog.LoadEmbedded());
private static readonly DateTimeOffset Today = new(2026, 9, 29, 0, 0, 0, TimeSpan.Zero);
[Theory]
[InlineData("8.00.2039")]
[InlineData("9.00.5000.00")]
[InlineData("10.50.6000.34")]
[InlineData("12.0.6024.0")]
[InlineData("13.0.6300.2")]
public void Out_of_support_releases_are_critical(string version)
{
var evaluation = Rule.Evaluate(TestInstances.Context(version, Today));
Assert.Equal(RuleOutcome.Findings, evaluation.Outcome);
Assert.Equal(Severity.Critical, Assert.Single(evaluation.Findings).Severity);
}
[Theory]
[InlineData("2026-09-29", Severity.Medium)] // 378 days left
[InlineData("2026-10-12", Severity.High)] // exactly 365 days left
[InlineData("2027-10-12", Severity.High)] // last day of support
[InlineData("2027-10-13", Severity.Critical)]
public void Sql2017_escalates_as_extended_support_ends(string date, Severity expected)
{
// SQL Server 2017 extended support ends 2027-10-12.
var now = DateTimeOffset.Parse(date + "T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture);
var evaluation = Rule.Evaluate(TestInstances.Context("14.0.3456.2", now));
Assert.Equal(expected, Assert.Single(evaluation.Findings).Severity);
}
[Fact]
public void Extended_support_further_out_is_medium()
{
// SQL Server 2019 left mainstream support 2025-02-28; extended runs to 2030.
var evaluation = Rule.Evaluate(TestInstances.Context("15.0.4355.3", Today));
Assert.Equal(Severity.Medium, Assert.Single(evaluation.Findings).Severity);
}
[Fact]
public void Mainstream_support_passes()
{
var evaluation = Rule.Evaluate(TestInstances.Context("16.0.4135.4", Today));
Assert.Equal(RuleOutcome.Passed, evaluation.Outcome);
}
[Fact]
public void Release_without_published_dates_is_not_measurable()
{
var evaluation = Rule.Evaluate(TestInstances.Context("17.0.1000.7", Today));
Assert.Equal(RuleOutcome.NotMeasurable, evaluation.Outcome);
}
[Fact]
public void Unknown_future_release_is_not_measurable()
{
var evaluation = Rule.Evaluate(TestInstances.Context("18.0.100.1", Today));
Assert.Equal(RuleOutcome.NotMeasurable, evaluation.Outcome);
Assert.Contains("Opdater", evaluation.Reason, StringComparison.Ordinal);
}
[Fact]
public void Runs_on_sql2000_because_it_needs_no_capabilities()
{
Assert.Empty(Rule.RequiredCapabilities);
}
[Fact]
public void Catalog_covers_every_known_release()
{
var catalog = SupportLifecycleCatalog.LoadEmbedded();
foreach (var release in Enum.GetValues<SqlServerRelease>().Where(r => r != SqlServerRelease.Unknown))
{
Assert.NotNull(catalog.Find(release));
}
}
}

View file

@ -0,0 +1,20 @@
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Tests;
internal static class TestInstances
{
public static RuleContext Context(string version, DateTimeOffset? now = null)
{
var info = new InstanceInfo
{
ServerName = "test",
Version = SqlServerVersion.Parse(version),
ProductLevel = "RTM",
Edition = "Standard Edition",
EngineEdition = EngineEdition.Standard,
};
return new RuleContext(info, InstanceCapabilities.From(info), now ?? new DateTimeOffset(2026, 9, 29, 0, 0, 0, TimeSpan.Zero));
}
}