diff --git a/src/SQLInsight.Core/Configuration/DatabaseSelection.cs b/src/SQLInsight.Core/Configuration/DatabaseSelection.cs index 0a05ed4..e0917a3 100644 --- a/src/SQLInsight.Core/Configuration/DatabaseSelection.cs +++ b/src/SQLInsight.Core/Configuration/DatabaseSelection.cs @@ -83,8 +83,16 @@ public static class DynamicsAxFindings ruleId?.StartsWith(RulePrefix, StringComparison.OrdinalIgnoreCase) == true || ruleId?.StartsWith(BuiltInRulePrefix, StringComparison.OrdinalIgnoreCase) == true; /// The subject of an AX finding about in . - public static string Subject(string database, string? detail = null) => - detail is null ? DatabaseSubject + database : DatabaseSubject + database + SubjectSeparator + detail; + /// + /// A middle dot in the database name is doubled, so the separator " · " (one dot) can only be the separator and a + /// database named e.g. "AX · test" can never be read as the database "AX" (whose hub opt-in would then apply). + /// + public static string Subject(string database, string? detail = null) + { + ArgumentNullException.ThrowIfNull(database); + var escaped = database.Replace("·", "··", StringComparison.Ordinal); + return detail is null ? DatabaseSubject + escaped : DatabaseSubject + escaped + SubjectSeparator + detail; + } /// The database a finding is about, from its subject (database: AX30 or database: AX30 · …), or null. public static string? DatabaseOf(string? subject) @@ -96,6 +104,6 @@ public static class DynamicsAxFindings var rest = subject[DatabaseSubject.Length..]; var separator = rest.IndexOf(SubjectSeparator, StringComparison.Ordinal); - return separator > 0 ? rest[..separator] : rest; + return (separator > 0 ? rest[..separator] : rest).Replace("··", "·", StringComparison.Ordinal); } } diff --git a/src/SQLInsight.Core/DynamicsAx/AxReadAccess.cs b/src/SQLInsight.Core/DynamicsAx/AxReadAccess.cs index 449b688..1ea9dbd 100644 --- a/src/SQLInsight.Core/DynamicsAx/AxReadAccess.cs +++ b/src/SQLInsight.Core/DynamicsAx/AxReadAccess.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.RegularExpressions; namespace SQLInsight.Core.DynamicsAx; @@ -74,6 +75,25 @@ public static class AxReadAccess StringComparison.Ordinal); } + /// + /// without the agent's login or database user: the GRANT texts in "Ikke målbar", notes and + /// detection problems name them (CREATE USER [x] FOR LOGIN [x], SUSER_SID(N'x'), TO [user]), and so may a SQL Server + /// error message (principal "x"). Logins are sensitive like everywhere else, so API v1 shows the placeholder instead + /// unless sensitive values are allowed. + /// + public static string? WithoutPrincipals(string? text) + { + if (string.IsNullOrEmpty(text)) + { + return text; + } + + var placeholder = AxSql.Identifier(LoginPlaceholder); + text = Regex.Replace(text, @"(CREATE USER |FOR LOGIN | TO )\[(?:[^\]]|\]\])*\]", m => m.Groups[1].Value + placeholder); + text = Regex.Replace(text, @"SUSER_SID\(N'(?:[^']|'')*'\)", "SUSER_SID(" + AxSql.Literal(LoginPlaceholder) + ")"); + return Regex.Replace(text, @"\b(principal|user|login)\s+""[^""]*""", "$1 \"[udeladt]\"", RegexOptions.IgnoreCase); + } + /// "Ikke målbar" for a check that needs , with the exact GRANT text. public static string NotMeasurable(AxDatabase database, IReadOnlyList tables, string? login = null) { diff --git a/src/SQLInsight.Core/DynamicsAx/AxSessionMapper.cs b/src/SQLInsight.Core/DynamicsAx/AxSessionMapper.cs index 3b16ff5..7262140 100644 --- a/src/SQLInsight.Core/DynamicsAx/AxSessionMapper.cs +++ b/src/SQLInsight.Core/DynamicsAx/AxSessionMapper.cs @@ -33,9 +33,11 @@ public static class AxSessionMapper if (version is AxVersion.Axapta30 or AxVersion.Ax40) { return new AxSessionIdentity(null, null, "ikke tilgængelig", - $"{AxVersions.Text(version)} sætter ikke CONTEXT_INFO, så AX-brugeren kan ikke ses fra SQL Server. " - + $"Vært: {session.HostName ?? "ukendt"} (AOS'en ved 3-lags-installation, brugerens egen pc ved 2-lags-klient). " - + $"Program: {session.ProgramName ?? "ukendt"}. Brugeren kan findes i AX under Online brugere ud fra tidspunktet.", text); + // No host or program name here: the explanation is not sensitive (it may reach SQLInsight Hub); they are + // in the finding's sensitive evidence "Vært / program". + $"{AxVersions.Text(version)} sætter ikke CONTEXT_INFO, så AX-brugeren kan ikke ses fra SQL Server. Værten under målingerne er " + + "AOS'en ved en 3-lags-installation og brugerens egen pc ved en 2-lags-klient. Brugeren kan findes i AX under Online brugere ud fra " + + "tidspunktet og værten.", text); } if (text is not null) @@ -47,7 +49,8 @@ public static class AxSessionMapper if (user is not null) { return new AxSessionIdentity(user, axSession, "context_info", - "Fra CONTEXT_INFO, som AOS'en sætter, når connectioncontext er slået til.", text); + "Fra CONTEXT_INFO, som AOS'en sætter, når connectioncontext er slået til (første ord med bogstaver er taget som AX-brugeren; " + + "tekstformatet er dokumenteret af Microsoft for AX 2012, men ikke verificeret mod kundedata – se den rå CONTEXT_INFO under målingerne).", text); } } diff --git a/src/SQLInsight.Rules/Custom/DeclarativeRule.cs b/src/SQLInsight.Rules/Custom/DeclarativeRule.cs index 78d9790..6fe7482 100644 --- a/src/SQLInsight.Rules/Custom/DeclarativeRule.cs +++ b/src/SQLInsight.Rules/Custom/DeclarativeRule.cs @@ -55,7 +55,7 @@ public sealed class DeclarativeRule(CustomRuleDefinition definition) : IRule RuleId = Definition.Id, Severity = Definition.Severity, Title = CustomRuleDefinition.Fill(Definition.FindingTitle, row), - Subject = Definition.Subject is { } subject ? CustomRuleDefinition.Fill(subject, row) : row.Values.FirstOrDefault(), + Subject = Subject(row), Explanation = CustomRuleDefinition.Fill(Definition.Explanation, row) + (Definition.Origin == "community" ? " (Community-regel" + (Definition.Author is { } a ? $" af {a}" : "") + ".)" : " (Lokal regel.)"), Recommendation = CustomRuleDefinition.Fill(Definition.Recommendation, row), @@ -68,4 +68,20 @@ public sealed class DeclarativeRule(CustomRuleDefinition definition) : IRule }).ToArray(); return RuleEvaluation.Found(findings); } + + /// + /// The finding's subject. A Dynamics AX rule's findings always start with the database they are about (built like the + /// built-in AX rules'), because the hub opt-in is per database: a subject that does not name it could never be sent, and + /// one that names it differently could be read as another database. + /// + private string? Subject(IReadOnlyDictionary row) + { + var subject = Definition.Subject is { } s ? CustomRuleDefinition.Fill(s, row) : row.Values.FirstOrDefault(); + if (!Definition.DynamicsAx || !row.TryGetValue("database", out var database) || database is null) + { + return subject; + } + + return Core.Configuration.DynamicsAxFindings.Subject(database, subject is null || subject == "database: " + database ? null : subject); + } } diff --git a/src/SQLInsight.Web/Api/ApiV1Endpoints.cs b/src/SQLInsight.Web/Api/ApiV1Endpoints.cs index 9b74f66..004123f 100644 --- a/src/SQLInsight.Web/Api/ApiV1Endpoints.cs +++ b/src/SQLInsight.Web/Api/ApiV1Endpoints.cs @@ -181,7 +181,7 @@ public static class ApiV1Endpoints .Select(r => new { r.Id, r.Title, Category = Category(r.Id), r.Source })); } - private static async Task RuleStatesAsync(string name, InsightRepository repository, CancellationToken ct) + private static async Task RuleStatesAsync(string name, InsightRepository repository, IOptions options, CancellationToken ct) { if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance) { @@ -201,7 +201,8 @@ public static class ApiV1Endpoints Rules.Engine.RuleOutcome.ManualCheck => "manualCheck", _ => "error", }, - s.Reason, + // A reason may name the agent's login (the GRANT text of the Dynamics AX rules, or a permission error): redacted like logins elsewhere. + Reason = RedactorFor(options).Include ? s.Reason : Core.DynamicsAx.AxReadAccess.WithoutPrincipals(s.Reason), s.EvaluatedAt, })); } diff --git a/src/SQLInsight.Web/Api/DynamicsAxApiEndpoints.cs b/src/SQLInsight.Web/Api/DynamicsAxApiEndpoints.cs index 3dd2b49..abc059a 100644 --- a/src/SQLInsight.Web/Api/DynamicsAxApiEndpoints.cs +++ b/src/SQLInsight.Web/Api/DynamicsAxApiEndpoints.cs @@ -22,12 +22,15 @@ public static class DynamicsAxApiEndpoints api.MapGet("/databases", DatabasesAsync) .WithSummary("AX-databaser: version, skema, firmaer og læseadgang / Recognised Dynamics AX databases") .WithDescription("Version (verified only for Axapta 3.0), schema owner (bmssa/dbo), model database, companies, which AX tables the agent may read, the missing tables and the GRANT that gives them. " + filter); + api.MapGet("/detection-problems", DetectionProblemsAsync) + .WithSummary("Databaser, AX-genkendelsen ikke kunne læse / Databases the AX detection could not read") + .WithDescription("Per monitored database: why its metadata could not be read (typically no database user before SQL Server 2014), as shown under Dynamics AX → AX-databaser. Logins are redacted unless sensitive values are allowed. " + filter); api.MapGet("/grant-script", GrantScriptAsync) .WithSummary("GRANT-sætninger til den valgfri AX-læseadgang / GRANT statements for the optional AX read access") .WithDescription("?instance= and ?database= are required. Returns the exact statements for the database (only SELECT on the version's AX system tables) and the full grant-dynamics-ax-read.sql with database and login filled in."); api.MapGet("/batch-jobs", BatchJobsAsync) .WithSummary("AX-batchjob lige nu med deres egen historik / Dynamics AX batch jobs now") - .WithDescription("Status, planned and actual start (UTC), how late or how long running, median and 90th percentile of the job's own runs, the raw STATUS counts. " + filter); + .WithDescription("Status, planned and actual start (UTC), how late or how long running, median and 90th percentile of the job's own runs, the raw STATUS counts and what each STATUS value means in this AX version. " + filter); api.MapGet("/batch-runs", BatchRunsAsync) .WithSummary("Afsluttede AX-batchkørsler / Completed Dynamics AX batch runs") .WithDescription("From SQLInsight's repository, newest first. ?instance=, ?database=, ?job= (job key) and ?days= (1–365, default 7) filter; at most 1000."); @@ -39,7 +42,7 @@ public static class DynamicsAxApiEndpoints .WithDescription("NEXTVAL against the limit (32-bit in Axapta 3.0), per day and days left. " + filter); api.MapGet("/sequence-samples", SamplesAsync) .WithSummary("Daglige målinger bag prognoserne / Daily samples behind the forecasts") - .WithDescription("?instance= is required; ?database=, ?kind= (N = number sequence, R = RecId), ?key= and ?days= (1–400, default 30) filter."); + .WithDescription("?instance= is required; ?database=, ?kind= (N = number sequence, R = RecId), ?key= and ?days= (1–400, default 30) filter. At most 10,000 rows; the header X-Truncated: true says there are more."); api.MapGet("/blocking", BlockingAsync) .WithSummary("AX-brugere bag blokering / Dynamics AX users behind blocking") .WithDescription("Blocking in AX databases with the AX user from CONTEXT_INFO (AX 2009/2012 with connectioncontext; never in Axapta 3.0/4.0, which the explanation says). ?days= (1–365, default 7). " + filter); @@ -48,18 +51,30 @@ public static class DynamicsAxApiEndpoints private sealed class Redactor(bool include) { public string? Value(string? value) => !include && value is not null ? ApiV1Endpoints.Redacted : value; + + /// A text that may name the agent's login or database user (GRANT texts, "Ikke målbar", detection problems). + public string? Text(string? text) => include ? text : AxReadAccess.WithoutPrincipals(text); + + public List Texts(IEnumerable texts) => texts.Select(t => Text(t) ?? string.Empty).ToList(); } private static Redactor RedactorFor(IOptions options) => new(options.Value.ApiIncludesSensitiveEvidence); - private static async Task<(IReadOnlyList Views, IResult? Error)> LoadAsync(InsightRepository repository, string? instance, CancellationToken ct) + /// At most this many batch runs per call. + private const int MaxRuns = 1000; + + /// At most this many daily samples per call (X-Truncated: true when there are more). + private const int MaxSamples = 10_000; + + private static async Task<(IReadOnlyList Views, IResult? Error)> LoadAsync(InsightRepository repository, string? instance, CancellationToken ct, + bool withProblems = false) { if (instance is { Length: > 0 } && await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is null) { return ([], Results.NotFound()); } - return (await DynamicsAxView.LoadAsync(repository, instance, ct).ConfigureAwait(false), null); + return (await DynamicsAxView.LoadAsync(repository, instance, withProblems, ct).ConfigureAwait(false), null); } private static async Task DatabasesAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? instance = null) @@ -85,23 +100,35 @@ public static class DynamicsAxApiEndpoints d.Schema, d.ModelDatabase, Companies = d.Companies?.Select(c => new { c.Id, c.Name, c.IsVirtual }), - d.CompaniesReason, + CompaniesReason = redact.Text(d.CompaniesReason), Isolation = d.Isolation.ToString(), - d.Notes, + Notes = redact.Texts(d.Notes), ReadAccess = AxReadAccess.Tables(d).Select(t => new { Table = d.Schema + "." + t, Readable = d.CanRead(t) }), MissingTables = missing, - Grant = missing.Count == 0 ? null : RedactLogin(AxReadAccess.GrantSql(d, d.AgentLogin ?? v.Databases.AgentLogin, missing), d.AgentLogin ?? v.Databases.AgentLogin, redact), + // The agent's login and database user are sensitive like every login: the placeholder unless sensitive values are allowed. + Grant = missing.Count == 0 ? null : redact.Text(AxReadAccess.GrantSql(d, d.AgentLogin ?? v.Databases.AgentLogin, missing)), DetectedAt = v.Databases.CapturedAt, }; }))); } - /// The agent's login is sensitive like every login: replaced by the placeholder unless sensitive values are allowed. - private static string RedactLogin(string sql, string? login, Redactor redact) => - login is { Length: > 0 } && redact.Value(login) != login - ? sql.Replace(AxSql.Identifier(login), AxSql.Identifier(AxReadAccess.LoginPlaceholder), StringComparison.Ordinal) - .Replace(AxSql.Literal(login), AxSql.Literal(AxReadAccess.LoginPlaceholder), StringComparison.Ordinal) - : sql; + private static async Task DetectionProblemsAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? instance = null) + { + var (views, error) = await LoadAsync(repository, instance, ct, withProblems: true).ConfigureAwait(false); + if (error is not null) + { + return error; + } + + var redact = RedactorFor(options); + return Results.Ok(views.SelectMany(v => v.Databases.Problems.Select(p => new + { + Instance = v.Instance.Name, + Database = p.Key, + Problem = redact.Text(p.Value), + DetectedAt = v.Databases.CapturedAt, + }))); + } private static async Task GrantScriptAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? instance = null, string? database = null) { @@ -124,6 +151,11 @@ public static class DynamicsAxApiEndpoints var redact = RedactorFor(options); var login = ax.AgentLogin ?? snapshot.AgentLogin; var shownLogin = redact.Value(login) == login ? login : AxReadAccess.LoginPlaceholder; + if (shownLogin != login && ax.DatabaseUser is not null) + { + // The database user is as sensitive as the login. + ax = ax with { DatabaseUser = AxReadAccess.LoginPlaceholder }; + } return Results.Ok(new { Instance = instance, @@ -138,7 +170,7 @@ public static class DynamicsAxApiEndpoints }); } - private static async Task BatchJobsAsync(InsightRepository repository, CancellationToken ct, string? instance = null) + private static async Task BatchJobsAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? instance = null) { var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); if (error is not null) @@ -146,6 +178,7 @@ public static class DynamicsAxApiEndpoints return error; } + var redact = RedactorFor(options); return Results.Ok(views.Where(v => v.Batch is not null).SelectMany(v => v.Batch!.Databases.Select(d => new { Instance = v.Instance.Name, @@ -153,9 +186,17 @@ public static class DynamicsAxApiEndpoints Version = d.Version.ToString(), v.Batch!.CapturedAt, v.Batch.Thresholds, - d.NotMeasurable, - d.Notes, + NotMeasurable = redact.Text(d.NotMeasurable), + Notes = redact.Texts(d.Notes), StatusCounts = d.StatusCounts.ToDictionary(s => s.Key.ToString(System.Globalization.CultureInfo.InvariantCulture), s => s.Value), + // What each raw STATUS means in this AX version, as the page shows it (Axapta 3.0/4.0's values are not verified). + Statuses = d.StatusCounts.OrderBy(s => s.Key).Select(s => new + { + Code = s.Key, + Status = AxSchemaAdapter.For(d.Version)?.BatchStatus(s.Key).ToString(), + Text = AxSchemaAdapter.For(d.Version) is { } adapter ? DynamicsAxView.StatusText(adapter.BatchStatus(s.Key)) : null, + Count = s.Value, + }), Jobs = d.Jobs.Select(j => new { j.Key, j.RecId, j.Caption, j.Company, j.Group, Status = j.Status.ToString(), j.StatusCode, j.PlannedStartUtc, j.StartedUtc, j.EndedUtc, @@ -168,22 +209,29 @@ public static class DynamicsAxApiEndpoints private static async Task BatchRunsAsync(InsightRepository repository, CancellationToken ct, string? instance = null, string? database = null, string? job = null, int? days = null) { - InstanceOverview? target = null; - if (instance is { Length: > 0 } && (target = await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false)) is null) + // The instances the AX pages show (not removed, with AX databases), each with its newest runs. + var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); + if (error is not null) { - return Results.NotFound(); + return error; } var now = DateTimeOffset.UtcNow; - var runs = await repository.GetAxBatchRunsAsync(target?.InstanceId, database, job, now.AddDays(-Math.Clamp(days ?? 7, 1, 365)), now, 1000, ct).ConfigureAwait(false); - return Results.Ok(runs.Select(r => new + var runs = new List(); + foreach (var view in views) + { + runs.AddRange(await repository.GetAxBatchRunsAsync(view.Instance.InstanceId, database, job, now.AddDays(-Math.Clamp(days ?? 7, 1, 365)), now, MaxRuns, ct) + .ConfigureAwait(false)); + } + + return Results.Ok(runs.OrderByDescending(r => r.EndedAt).Take(MaxRuns).Select(r => new { Instance = r.InstanceName, r.DatabaseName, r.JobKey, r.RunKey, r.Caption, r.Company, r.StartedAt, r.EndedAt, DurationSeconds = r.Duration.TotalSeconds, Status = r.Status.ToString(), })); } - private static async Task NumberSequencesAsync(InsightRepository repository, CancellationToken ct, string? instance = null) + private static async Task NumberSequencesAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? instance = null) { var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); if (error is not null) @@ -191,14 +239,16 @@ public static class DynamicsAxApiEndpoints return error; } + var redact = RedactorFor(options); return Results.Ok(views.Where(v => v.Sequences is not null).SelectMany(v => v.Sequences!.Databases.Select(d => new { Instance = v.Instance.Name, d.Database, Version = d.Version.ToString(), v.Sequences!.CapturedAt, - NotMeasurable = d.NumberSequencesNotMeasurable, - d.Notes, + v.Sequences.Thresholds, + NotMeasurable = redact.Text(d.NumberSequencesNotMeasurable), + Notes = redact.Texts(d.Notes), Sequences = d.NumberSequences.Select(s => new { s.Key, s.Company, s.Code, s.Text, s.Lowest, s.Highest, s.Next, s.Format, s.FormatDigits, s.FormatCapacity, s.FormatTooShort, s.Max, s.UsedPercent, @@ -208,7 +258,7 @@ public static class DynamicsAxApiEndpoints }))); } - private static async Task RecIdsAsync(InsightRepository repository, CancellationToken ct, string? instance = null) + private static async Task RecIdsAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? instance = null) { var (views, error) = await LoadAsync(repository, instance, ct).ConfigureAwait(false); if (error is not null) @@ -216,13 +266,15 @@ public static class DynamicsAxApiEndpoints return error; } + var redact = RedactorFor(options); return Results.Ok(views.Where(v => v.Sequences is not null).SelectMany(v => v.Sequences!.Databases.Select(d => new { Instance = v.Instance.Name, d.Database, Version = d.Version.ToString(), v.Sequences!.CapturedAt, - NotMeasurable = d.RecIdsNotMeasurable, + v.Sequences.Thresholds, + NotMeasurable = redact.Text(d.RecIdsNotMeasurable), RecIds = d.RecIds.Select(r => new { r.Key, r.TableId, r.TableName, r.Company, r.Next, r.Max, r.Bits, r.UsedPercent, r.Remaining, @@ -231,7 +283,8 @@ public static class DynamicsAxApiEndpoints }))); } - private static async Task SamplesAsync(InsightRepository repository, CancellationToken ct, string? instance = null, string? database = null, string? kind = null, + private static async Task SamplesAsync(HttpContext context, InsightRepository repository, CancellationToken ct, string? instance = null, string? database = null, + string? kind = null, string? key = null, int? days = null) { if (instance is not { Length: > 0 } || await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is not { } target) @@ -251,9 +304,15 @@ public static class DynamicsAxApiEndpoints return Results.BadRequest("kind skal være N (nummerserie) eller R (RecId)."); } - var samples = await repository.GetAxSequenceSamplesAsync(target.InstanceId, database, k, key, DateTimeOffset.UtcNow.AddDays(-Math.Clamp(days ?? 30, 1, 400)), ct) - .ConfigureAwait(false); - return Results.Ok(samples.Take(10_000).Select(s => new { s.DatabaseName, Kind = s.Kind.ToString(), s.SequenceKey, s.SampledAt, s.Value })); + // Bounded in SQL; one row more than the limit tells the caller that ?database=, ?kind= or ?key= is needed for the rest. + var samples = await repository.GetAxSequenceSamplesAsync(target.InstanceId, database, k, key, DateTimeOffset.UtcNow.AddDays(-Math.Clamp(days ?? 30, 1, 400)), + MaxSamples + 1, ct).ConfigureAwait(false); + if (samples.Count > MaxSamples) + { + context.Response.Headers["X-Truncated"] = "true"; + } + + return Results.Ok(samples.Take(MaxSamples).Select(s => new { s.DatabaseName, Kind = s.Kind.ToString(), s.SequenceKey, s.SampledAt, s.Value })); } private static async Task BlockingAsync(InsightRepository repository, IOptions options, CancellationToken ct, string? instance = null, int? days = null) @@ -269,7 +328,7 @@ public static class DynamicsAxApiEndpoints return Results.Ok(rows.Take(1000).Select(r => new { r.Instance, r.Database, Version = r.Version.ToString(), r.SampledAt, r.BlockedCount, r.MaxWaitMs, - AxUser = redact.Value(r.Identity.AxUser), r.Identity.AxSessionId, r.Identity.Source, Explanation = redact.Value(r.Identity.Explanation), + AxUser = redact.Value(r.Identity.AxUser), r.Identity.AxSessionId, r.Identity.Source, r.Identity.Explanation, Session = r.Head.SessionId, r.Head.Status, r.Head.OpenTransactionCount, HostName = redact.Value(r.Head.HostName), ProgramName = redact.Value(r.Head.ProgramName), LoginName = redact.Value(r.Head.LoginName), SqlText = redact.Value(r.Head.SqlText), ContextInfo = redact.Value(r.Identity.RawContext), diff --git a/tests/SQLInsight.Core.Tests/DynamicsAxTests.cs b/tests/SQLInsight.Core.Tests/DynamicsAxTests.cs index c784ac8..bce4836 100644 --- a/tests/SQLInsight.Core.Tests/DynamicsAxTests.cs +++ b/tests/SQLInsight.Core.Tests/DynamicsAxTests.cs @@ -337,7 +337,9 @@ public class AxSessionMapperTests Assert.False(identity.Known); Assert.Contains("sætter ikke CONTEXT_INFO", identity.Explanation, StringComparison.Ordinal); - Assert.Contains("AOS01", identity.Explanation, StringComparison.Ordinal); + // Host and program are sensitive: in the finding's evidence, not in the explanation (which may reach the hub). + Assert.DoesNotContain("AOS01", identity.Explanation, StringComparison.Ordinal); + Assert.Contains("Online brugere", identity.Explanation, StringComparison.Ordinal); } [Fact] @@ -475,3 +477,35 @@ public class AxLocalTimeTests Assert.Equal(Utc(2026, 6, 30, 22, 0), sequence.LastCleanUtc); } } + +public class AxRedactionTests +{ + [Fact] + public void A_database_name_with_the_separator_is_never_read_as_another_database() + { + var subject = DynamicsAxFindings.Subject("AX · test", "batch: 1"); + + Assert.Equal("AX · test", DynamicsAxFindings.DatabaseOf(subject)); + Assert.Equal("AX · test", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("AX · test"))); + Assert.Equal("a··b", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("a··b", "recid: 1/dat"))); + Assert.Equal("database: AX30 · batch: 1", DynamicsAxFindings.Subject("AX30", "batch: 1")); + } + + [Fact] + public void Grant_texts_and_error_messages_lose_the_login_and_the_database_user() + { + var database = AxFixtures.Recognise(AxFixtures.Ax30, readable: false, user: "svc") with { AgentLogin = @"KUNDE\svc-sqlinsight$" }; + var text = AxReadAccess.NotMeasurable(database, ["BATCH"]); + Assert.Contains("svc", text, StringComparison.Ordinal); + + var redacted = AxReadAccess.WithoutPrincipals(text)!; + Assert.DoesNotContain("KUNDE", redacted, StringComparison.Ordinal); + Assert.DoesNotContain("[svc]", redacted, StringComparison.Ordinal); + Assert.Contains("GRANT SELECT ON [bmssa].[BATCH] TO [" + AxReadAccess.LoginPlaceholder + "]", redacted, StringComparison.Ordinal); + + var noUser = AxReadAccess.WithoutPrincipals(AxReadAccess.GrantSql(database with { DatabaseUser = null }, @"KUNDE\svc-sqlinsight$"))!; + Assert.DoesNotContain("KUNDE", noUser, StringComparison.Ordinal); + Assert.Equal("The server principal \"[udeladt]\" is not able to access the database", + AxReadAccess.WithoutPrincipals("The server principal \"KUNDE\\svc\" is not able to access the database")); + } +}