diff --git a/src/OpenTelemetry.Instrumentation.EntityFrameworkCore/CHANGELOG.md b/src/OpenTelemetry.Instrumentation.EntityFrameworkCore/CHANGELOG.md index 860a20d347..87720ff8c3 100644 --- a/src/OpenTelemetry.Instrumentation.EntityFrameworkCore/CHANGELOG.md +++ b/src/OpenTelemetry.Instrumentation.EntityFrameworkCore/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +* Fixed query sanitization so that backslash-escaped quotes (`'a\'b'`) in + MySQL/MariaDB string literals and PostgreSQL dollar-quoted strings + (`$$...$$`) are correctly redacted. + ([#4986](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/4986)) + +* Fixed query sanitization so that MySQL/MariaDB double-quoted string + literals (`"..."`, valid when `ANSI_QUOTES` is disabled) are correctly + redacted. + ([#4986](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/4986)) + ## 1.18.0-beta.1 Released 2026-Aug-21 diff --git a/src/OpenTelemetry.Instrumentation.EntityFrameworkCore/Implementation/EntityFrameworkDiagnosticListener.cs b/src/OpenTelemetry.Instrumentation.EntityFrameworkCore/Implementation/EntityFrameworkDiagnosticListener.cs index d4c5ece156..962c2c2ea1 100644 --- a/src/OpenTelemetry.Instrumentation.EntityFrameworkCore/Implementation/EntityFrameworkDiagnosticListener.cs +++ b/src/OpenTelemetry.Instrumentation.EntityFrameworkCore/Implementation/EntityFrameworkDiagnosticListener.cs @@ -190,12 +190,18 @@ public override void OnEventWritten(string name, object? payload) // able to sanitize arbitrary commands for other query dialects. var sanitizeQuery = IsSqlLikeProvider(providerName); + // MySQL/MariaDB treat a backslash as a string-literal escape + // character by default, which the sanitizer must honor to avoid + // leaking literal content into the query text. + var useBackslashEscapes = IsBackslashEscapeProvider(providerName); + DatabaseSemanticConventionHelper.ApplyConventionsForQueryText( activity, commandText, this.options.EmitOldAttributes, this.options.EmitNewAttributes, - sanitizeQuery); + sanitizeQuery, + useBackslashEscapes); break; case CommandType.TableDirect: @@ -371,6 +377,21 @@ DbSystemNames.Sqlite or }; } + internal static bool IsBackslashEscapeProvider(string? providerOrCommandName) + { + // MySQL and MariaDB (which use the MySQL providers) treat a backslash as a + // string-literal escape character unless the NO_BACKSLASH_ESCAPES SQL mode + // is enabled. The other supported engines follow the SQL standard where + // only a doubled quote ('') escapes a quote. + // + // This assumes the default (NO_BACKSLASH_ESCAPES disabled) behaviour: there is + // no way to detect the session SQL mode from the provider/command name alone, + // so if an application has enabled NO_BACKSLASH_ESCAPES the sanitizer will still + // treat '\' as an escape character for that connection. + (_, var dbSystemName) = GetDbSystemNames(providerOrCommandName); + return dbSystemName == DbSystemNames.Mysql; + } + private void AddTag(Activity activity, (string Old, string New) attributes, string? value) => this.AddTag(activity, attributes, (value, value)); diff --git a/src/Shared/DatabaseSemanticConventionHelper.cs b/src/Shared/DatabaseSemanticConventionHelper.cs index 6c40334b9b..1c07747914 100644 --- a/src/Shared/DatabaseSemanticConventionHelper.cs +++ b/src/Shared/DatabaseSemanticConventionHelper.cs @@ -69,14 +69,15 @@ public static void ApplyConventionsForQueryText( string? commandText, bool emitOldAttributes, bool emitNewAttributes, - bool sanitizeQuery = true) + bool sanitizeQuery = true, + bool useBackslashEscapes = false) { var queryText = commandText ?? string.Empty; var querySummary = string.Empty; if (sanitizeQuery) { - var sqlStatementInfo = SqlProcessor.GetSanitizedSql(commandText); + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(commandText, useBackslashEscapes); queryText = sqlStatementInfo.SanitizedSql; querySummary = sqlStatementInfo.DbQuerySummary; diff --git a/src/Shared/SqlProcessor.cs b/src/Shared/SqlProcessor.cs index 7711132bd7..bb3beddf03 100644 --- a/src/Shared/SqlProcessor.cs +++ b/src/Shared/SqlProcessor.cs @@ -22,6 +22,9 @@ internal static class SqlProcessor private const char DashChar = '-'; private const char ForwardSlashChar = '/'; private const char SingleQuoteChar = '\''; + private const char DoubleQuoteChar = '"'; + private const char BackslashChar = '\\'; + private const char DollarChar = '$'; private const char AsteriskChar = '*'; private const char UnderscoreChar = '_'; private const char DotChar = '.'; @@ -31,6 +34,7 @@ internal static class SqlProcessor private const char UnicodePrefixChar = 'N'; private static readonly ConcurrentDictionary Cache = new(); + private static readonly ConcurrentDictionary BackslashEscapeCache = new(); private static readonly char[] WhitespaceChars = [SpaceChar, TabChar, CarriageReturnChar, NewLineChar]; #if !NET @@ -93,6 +97,7 @@ internal static class SqlProcessor // We only increment on successful TryAdd. This may result in a slightly oversized cache // under high concurrency but this is acceptable for this scenario. private static int approxCacheCount; + private static int approxBackslashEscapeCacheCount; private enum SqlKeyword { @@ -143,35 +148,53 @@ private enum SqlKeyword View, } - public static SqlStatementInfo GetSanitizedSql(string? sql) + /// + /// Sanitizes a SQL statement by replacing its literal values with placeholders and computes the + /// corresponding db.query.summary. Results are cached per statement and dialect. + /// + /// The SQL statement to sanitize. + /// + /// if the source database is MySQL or MariaDB with their default SQL modes + /// (NO_BACKSLASH_ESCAPES and ANSI_QUOTES disabled), in which case a backslash is + /// treated as a string-literal escape character and a double-quoted ("...") value is treated + /// as a string literal rather than a quoted identifier; otherwise . + /// + /// The sanitized SQL and query summary. + public static SqlStatementInfo GetSanitizedSql(string? sql, bool useBackslashEscapes = false) => + sql == null + ? default + : useBackslashEscapes + ? GetSanitizedSql(sql, BackslashEscapeCache, ref approxBackslashEscapeCacheCount, useBackslashEscapes: true) + : GetSanitizedSql(sql, Cache, ref approxCacheCount, useBackslashEscapes: false); + + private static SqlStatementInfo GetSanitizedSql( + string sql, + ConcurrentDictionary cache, + ref int approxCount, + bool useBackslashEscapes) { - if (sql == null) - { - return default; - } - - if (Cache.TryGetValue(sql, out var sqlStatementInfo)) + if (cache.TryGetValue(sql, out var sqlStatementInfo)) { return sqlStatementInfo; } - sqlStatementInfo = SanitizeSql(sql); + sqlStatementInfo = SanitizeSql(sql, useBackslashEscapes); // Fast-path capacity check using our own approximate count to avoid ConcurrentDictionary.Count cost. - if (Volatile.Read(ref approxCacheCount) >= CacheCapacity) + if (Volatile.Read(ref approxCount) >= CacheCapacity) { return sqlStatementInfo; } // Attempt to add when under capacity. Increment our count only on successful add. - if (Cache.TryAdd(sql, sqlStatementInfo)) + if (cache.TryAdd(sql, sqlStatementInfo)) { - Interlocked.Increment(ref approxCacheCount); + Interlocked.Increment(ref approxCount); return sqlStatementInfo; } // If another thread added meanwhile, return the cached value if available. - return Cache.TryGetValue(sql, out var existing) ? existing : sqlStatementInfo; + return cache.TryGetValue(sql, out var existing) ? existing : sqlStatementInfo; } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -239,7 +262,7 @@ private static bool HasTerminatingEscapedIdentifier(ReadOnlySpan sql, int return false; } - private static SqlStatementInfo SanitizeSql(string sql) + private static SqlStatementInfo SanitizeSql(string sql, bool useBackslashEscapes) { var sqlSpan = sql.AsSpan(); @@ -252,6 +275,7 @@ private static SqlStatementInfo SanitizeSql(string sql) var buffer = rentedBuffer.AsSpan(); ParseState state = default; + state.UseBackslashEscapes = useBackslashEscapes; // Precompute the summary buffer slice once and carry it via state to avoid repeated Span.Slice calls. state.SummaryBuffer = buffer.Slice(rentedBuffer.Length / 2); @@ -264,6 +288,7 @@ private static SqlStatementInfo SanitizeSql(string sql) } if (SanitizeStringLiteral(sqlSpan, buffer, ref state) || + SanitizeDollarQuotedLiteral(sqlSpan, buffer, ref state) || SanitizeHexLiteral(sqlSpan, buffer, ref state) || SanitizeNumericLiteral(sqlSpan, buffer, ref state)) { @@ -747,50 +772,141 @@ private static bool SanitizeStringLiteral(ReadOnlySpan sql, Span buf var currentChar = sql[state.ParsePosition]; if (currentChar == SingleQuoteChar) { - if (TrySanitizeLiteralsForInClause(sql, buffer, ref state, state.ParsePosition)) + return TrySanitizeLiteralsForInClause(sql, buffer, ref state, state.ParsePosition) || + SanitizeQuotedLiteral(sql, buffer, ref state, SingleQuoteChar, allowUnicodePrefix: true); + } + + // MySQL/MariaDB (the same dialects for which useBackslashEscapes is set) also treat a + // double-quoted value as a string literal unless the ANSI_QUOTES sql_mode is enabled. + // The other dialects supported here use '"' exclusively to delimit a quoted identifier + // (e.g. "columnName"), so this must remain gated on the dialect flag: otherwise a quoted + // identifier in those dialects would be misidentified as a literal and redacted, and + // conversely a MySQL/MariaDB double-quoted literal would leak into the sanitized SQL + // verbatim (it is never recognized as an identifier or literal by any other check here). + return currentChar == DoubleQuoteChar && + state.UseBackslashEscapes && + SanitizeQuotedLiteral(sql, buffer, ref state, DoubleQuoteChar, allowUnicodePrefix: false); + } + + private static bool SanitizeQuotedLiteral(ReadOnlySpan sql, Span buffer, ref ParseState state, char delimiter, bool allowUnicodePrefix) + { + // Is the string literal of the form `N'foo'` (i.e. a Unicode literal)? + // If so, we want to skip the Unicode prefix when sanitizing. + var isUnicode = allowUnicodePrefix && state.ParsePosition >= 1 && sql[state.ParsePosition - 1] is UnicodePrefixChar; + + var literalStart = state.ParsePosition; + var searchPos = state.ParsePosition + 1; + while (searchPos < sql.Length) + { + var quoteIndex = sql.Slice(searchPos).IndexOf(delimiter); + if (quoteIndex < 0) { - return true; + break; } - // Is the string literal of the form `N'foo'` (i.e. a Unicode literal)? - // If so, we want to skip the Unicode prefix when sanitizing. - var isUnicode = state.ParsePosition >= 1 && sql[state.ParsePosition - 1] is UnicodePrefixChar; + searchPos += quoteIndex; + + // Skip a backslash-escaped delimiter (\' or \"). MySQL/MariaDB (with the default + // NO_BACKSLASH_ESCAPES disabled) treat a backslash as a string escape character in + // both single- and double-quoted literals, so a delimiter preceded by an odd number + // of backslashes does not terminate the literal. Without this a value such as + // 'a\'secret' would be incorrectly parsed and the trailing "secret" copied into the + // sanitized SQL verbatim. This is gated on the dialect because '\' is not an escape + // in the other engines, where treating it as one would instead cause a doubled-quote + // -escaped literal to be incorrectly parsed. + if (state.UseBackslashEscapes && IsBackslashEscaped(sql, searchPos, literalStart)) + { + searchPos += 1; + continue; + } - var searchPos = state.ParsePosition + 1; - while (searchPos < sql.Length) + if (searchPos + 1 < sql.Length && sql[searchPos + 1] == delimiter) { - var quoteIndex = sql.Slice(searchPos).IndexOf(SingleQuoteChar); - if (quoteIndex < 0) - { - break; - } + // Skip escaped delimiter ('' or "") + searchPos += 2; + continue; + } - searchPos += quoteIndex; - if (searchPos + 1 < sql.Length && sql[searchPos + 1] == SingleQuoteChar) - { - // Skip escaped quote ('') - searchPos += 2; - continue; - } + // Found terminating delimiter + if (isUnicode) + { + // Skip the Unicode prefix by overwriting the previous position instead + state.SanitizedPosition--; + } - // Found terminating quote - if (isUnicode) - { - // Skip the Unicode prefix by overwriting the previous position instead - state.SanitizedPosition--; - } + state.ParsePosition = searchPos + 1; + buffer[state.SanitizedPosition++] = SanitizationPlaceholder; + return true; + } - state.ParsePosition = searchPos + 1; - buffer[state.SanitizedPosition++] = SanitizationPlaceholder; - return true; + state.ParsePosition = sql.Length; + buffer[state.SanitizedPosition++] = SanitizationPlaceholder; + return true; + } + + private static bool IsBackslashEscaped(ReadOnlySpan sql, int quoteIndex, int literalStart) + { + var backslashes = 0; + for (var i = quoteIndex - 1; i > literalStart && sql[i] == BackslashChar; i--) + { + backslashes++; + } + + return (backslashes & 1) == 1; + } + + private static bool SanitizeDollarQuotedLiteral(ReadOnlySpan sql, Span buffer, ref ParseState state) + { + // PostgreSQL dollar-quoted string: $tag$...$tag$ (the tag is optional, so $$...$$ is valid). + // The body between the delimiters is a literal with no escaping, so it must be redacted. + // This syntax is unambiguous across the SQL dialects handled here, so it is safe to apply. + var start = state.ParsePosition; + if (sql[start] != DollarChar) + { + return false; + } + + // Parse the opening delimiter: a dollar sign, an optional tag, then a closing dollar sign. + var tagEnd = start + 1; + if (tagEnd < sql.Length && sql[tagEnd] != DollarChar) + { + if (!IsDollarQuoteTagStartChar(sql[tagEnd])) + { + return false; } + tagEnd++; + while (tagEnd < sql.Length && IsDollarQuoteTagChar(sql[tagEnd])) + { + tagEnd++; + } + } + + if (tagEnd >= sql.Length || sql[tagEnd] != DollarChar) + { + return false; + } + + var delimiter = sql.Slice(start, tagEnd - start + 1); + var bodyStart = tagEnd + 1; + + var closeOffset = sql.Slice(bodyStart).IndexOf(delimiter); + if (closeOffset < 0) + { state.ParsePosition = sql.Length; buffer[state.SanitizedPosition++] = SanitizationPlaceholder; return true; } - return false; + state.ParsePosition = bodyStart + closeOffset + delimiter.Length; + buffer[state.SanitizedPosition++] = SanitizationPlaceholder; + return true; + + static bool IsDollarQuoteTagStartChar(char c) + => char.IsAsciiLetter(c) || c == UnderscoreChar; + + static bool IsDollarQuoteTagChar(char c) + => char.IsAsciiLetterOrDigit(c) || c == UnderscoreChar; } private static bool SanitizeHexLiteral(ReadOnlySpan sql, Span buffer, ref ParseState state) @@ -922,7 +1038,7 @@ private static bool TrySanitizeLiteralsForInClause(ReadOnlySpan sql, Span< // "IN ('a)b', 'secret')"), which would leave the parser positioned in the middle of // that literal. Every subsequent quote would then be mismatched and the remaining // values would be copied into the sanitized SQL verbatim instead of being replaced. - if (TryFindEndOfInClause(sql, parsePosition, out var closeParenIndex)) + if (TryFindEndOfInClause(sql, parsePosition, state.UseBackslashEscapes, out var closeParenIndex)) { state.ParsePosition = closeParenIndex; buffer[state.SanitizedPosition++] = SanitizationPlaceholder; @@ -943,7 +1059,7 @@ private static bool TrySanitizeLiteralsForInClause(ReadOnlySpan sql, Span< /// if the clause is not terminated, in which case the caller /// falls back to sanitizing each value individually. /// - private static bool TryFindEndOfInClause(ReadOnlySpan sql, int start, out int closeParenIndex) + private static bool TryFindEndOfInClause(ReadOnlySpan sql, int start, bool useBackslashEscapes, out int closeParenIndex) { var length = sql.Length; var i = start; @@ -970,7 +1086,7 @@ private static bool TryFindEndOfInClause(ReadOnlySpan sql, int start, out return true; case SingleQuoteChar: - i = SkipStringLiteral(sql, i); + i = SkipStringLiteral(sql, i, useBackslashEscapes); break; case DashChar: @@ -993,7 +1109,7 @@ private static bool TryFindEndOfInClause(ReadOnlySpan sql, int start, out // Returns the index after the closing quote, or the end of the input if the // literal is not terminated. - static int SkipStringLiteral(ReadOnlySpan sql, int quotePosition) + static int SkipStringLiteral(ReadOnlySpan sql, int quotePosition, bool useBackslashEscapes) { var length = sql.Length; var i = quotePosition + 1; @@ -1008,6 +1124,14 @@ static int SkipStringLiteral(ReadOnlySpan sql, int quotePosition) i += quoteIndex; + // A backslash-escaped quote (\') does not terminate the literal in dialects that use + // backslash escapes. See the note in SanitizeStringLiteral. + if (useBackslashEscapes && IsBackslashEscaped(sql, i, quotePosition)) + { + i += 1; + continue; + } + // A doubled quote ('') is an escaped quote within the literal. if (i + 1 < length && sql[i + 1] == SingleQuoteChar) { @@ -1100,6 +1224,12 @@ private ref struct ParseState public bool SanitizeNextNonKeywordToken; // 1 byte + /// + /// Whether the source dialect treats a backslash as a string-literal escape character + /// (MySQL/MariaDB). Controls whether \' is recognized as an escaped quote. + /// + public bool UseBackslashEscapes; // 1 byte + /// /// Used to track if we are in an escaped identifier (e.g., "[table]"). /// diff --git a/test/OpenTelemetry.Contrib.Shared.Tests/SqlProcessorTests.cs b/test/OpenTelemetry.Contrib.Shared.Tests/SqlProcessorTests.cs index 962ac962be..ec52a9f011 100644 --- a/test/OpenTelemetry.Contrib.Shared.Tests/SqlProcessorTests.cs +++ b/test/OpenTelemetry.Contrib.Shared.Tests/SqlProcessorTests.cs @@ -108,6 +108,170 @@ public void GetSanitizedSql_UnterminatedInClauseLiteralContainingCloseParen_Sani Assert.Equal("SELECT * FROM Users WHERE Name IN (?, ?", sqlStatementInfo.SanitizedSql); } + [Fact] + public void GetSanitizedSql_BackslashEscapedQuoteWithBackslashDialect_SanitizesLiteral() + { + var sql = "SELECT * FROM Users WHERE Password = 'a\\'secret-name'"; + + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql, useBackslashEscapes: true); + + this.output.WriteLine($"Sanitized: {sqlStatementInfo.SanitizedSql}"); + + Assert.DoesNotContain("secret-name", sqlStatementInfo.SanitizedSql); + Assert.Equal("SELECT * FROM Users WHERE Password = ?", sqlStatementInfo.SanitizedSql); + } + + [Fact] + public void GetSanitizedSql_BackslashEscapedQuoteInInClauseWithBackslashDialect_SanitizesLiterals() + { + var sql = "SELECT * FROM Users WHERE Name IN ('a\\'secret-name', 'b')"; + + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql, useBackslashEscapes: true); + + this.output.WriteLine($"Sanitized: {sqlStatementInfo.SanitizedSql}"); + + Assert.DoesNotContain("secret-name", sqlStatementInfo.SanitizedSql); + Assert.Equal("SELECT * FROM Users WHERE Name IN (?)", sqlStatementInfo.SanitizedSql); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetSanitizedSql_DoubledQuoteEscape_SanitizesLiteralInEitherDialect(bool useBackslashEscapes) + { + var sql = "SELECT * FROM Users WHERE Password = 'a''secret-name'"; + + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql, useBackslashEscapes); + + Assert.DoesNotContain("secret-name", sqlStatementInfo.SanitizedSql); + Assert.Equal("SELECT * FROM Users WHERE Password = ?", sqlStatementInfo.SanitizedSql); + } + + [Fact] + public void GetSanitizedSql_BackslashBeforeDoubledQuoteWithoutBackslashDialect_DoesNotLeak() + { + var sql = "SELECT * FROM Users WHERE Password = 'a\\''secret-name'"; + + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql, useBackslashEscapes: false); + + this.output.WriteLine($"Sanitized: {sqlStatementInfo.SanitizedSql}"); + + Assert.DoesNotContain("secret-name", sqlStatementInfo.SanitizedSql); + Assert.Equal("SELECT * FROM Users WHERE Password = ?", sqlStatementInfo.SanitizedSql); + } + + [Theory] + [InlineData("SELECT * FROM t WHERE c = $$secret-name$$")] + [InlineData("SELECT * FROM t WHERE c = $tag$se'cret-'name$tag$")] // Body may contain quotes/dollars. + public void GetSanitizedSql_DollarQuotedString_SanitizesLiteral(string sql) + { + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql); + + this.output.WriteLine($"Sanitized: {sqlStatementInfo.SanitizedSql}"); + + Assert.DoesNotContain("secret", sqlStatementInfo.SanitizedSql); + Assert.Equal("SELECT * FROM t WHERE c = ?", sqlStatementInfo.SanitizedSql); + } + + [Theory] + [InlineData("SELECT $IDENTITY FROM t", "SELECT $IDENTITY FROM t")] // SQL Server pseudo-column. + [InlineData("SELECT a WHERE b = $1", "SELECT a WHERE b = $?")] // PostgreSQL positional parameter. + public void GetSanitizedSql_LoneDollarSign_IsNotTreatedAsDollarQuote(string sql, string expected) + { + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql); + + this.output.WriteLine($"Sanitized: {sqlStatementInfo.SanitizedSql}"); + + Assert.Equal(expected, sqlStatementInfo.SanitizedSql); + } + + [Fact] + public void GetSanitizedSql_DollarQuoteTagStartingWithDigit_IsNotTreatedAsDollarQuote() + { + var sql = "SELECT a WHERE b = $1$not-a-secret$1$"; + + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql); + + this.output.WriteLine($"Sanitized: {sqlStatementInfo.SanitizedSql}"); + + Assert.Contains("not-a-secret", sqlStatementInfo.SanitizedSql); + } + + [Fact] + public void GetSanitizedSql_UnterminatedDollarQuotedString_SanitizesLiteral() + { + var sql = "SELECT * FROM t WHERE c = $$secret-name"; + + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql); + + this.output.WriteLine($"Sanitized: {sqlStatementInfo.SanitizedSql}"); + + Assert.DoesNotContain("secret-name", sqlStatementInfo.SanitizedSql); + Assert.Equal("SELECT * FROM t WHERE c = ?", sqlStatementInfo.SanitizedSql); + } + + [Fact] + public void GetSanitizedSql_DoubleQuotedString_IsPreservedAsIdentifier() + { + var sql = "SELECT * FROM t WHERE c = \"identifier_or_mysql_string\""; + + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql); + + Assert.Contains("identifier_or_mysql_string", sqlStatementInfo.SanitizedSql); + } + + [Fact] + public void GetSanitizedSql_DoubleQuotedStringWithBackslashDialect_SanitizesLiteral() + { + var sql = "SELECT * FROM Users WHERE Name = \"secret-value\" AND Id = 1"; + + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql, useBackslashEscapes: true); + + this.output.WriteLine($"Sanitized: {sqlStatementInfo.SanitizedSql}"); + + Assert.DoesNotContain("secret-value", sqlStatementInfo.SanitizedSql); + Assert.Equal("SELECT * FROM Users WHERE Name = ? AND Id = ?", sqlStatementInfo.SanitizedSql); + } + + [Fact] + public void GetSanitizedSql_DoubledDoubleQuoteEscapeWithBackslashDialect_SanitizesLiteral() + { + var sql = "SELECT * FROM Users WHERE Name = \"a\"\"secret-value\""; + + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql, useBackslashEscapes: true); + + this.output.WriteLine($"Sanitized: {sqlStatementInfo.SanitizedSql}"); + + Assert.DoesNotContain("secret-value", sqlStatementInfo.SanitizedSql); + Assert.Equal("SELECT * FROM Users WHERE Name = ?", sqlStatementInfo.SanitizedSql); + } + + [Fact] + public void GetSanitizedSql_BackslashEscapedDoubleQuoteWithBackslashDialect_SanitizesLiteral() + { + var sql = "SELECT * FROM Users WHERE Name = \"a\\\"secret-value\""; + + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql, useBackslashEscapes: true); + + this.output.WriteLine($"Sanitized: {sqlStatementInfo.SanitizedSql}"); + + Assert.DoesNotContain("secret-value", sqlStatementInfo.SanitizedSql); + Assert.Equal("SELECT * FROM Users WHERE Name = ?", sqlStatementInfo.SanitizedSql); + } + + [Fact] + public void GetSanitizedSql_UnterminatedDoubleQuotedStringWithBackslashDialect_SanitizesLiteral() + { + var sql = "SELECT * FROM Users WHERE Name = \"secret-value"; + + var sqlStatementInfo = SqlProcessor.GetSanitizedSql(sql, useBackslashEscapes: true); + + this.output.WriteLine($"Sanitized: {sqlStatementInfo.SanitizedSql}"); + + Assert.DoesNotContain("secret-value", sqlStatementInfo.SanitizedSql); + Assert.Equal("SELECT * FROM Users WHERE Name = ?", sqlStatementInfo.SanitizedSql); + } + [Fact] public void GetSanitizedSql_UnterminatedInClauseStringLiteral_SanitizesLiteral() { diff --git a/test/OpenTelemetry.Instrumentation.EntityFrameworkCore.Tests/EntityFrameworkDiagnosticListenerTests.cs b/test/OpenTelemetry.Instrumentation.EntityFrameworkCore.Tests/EntityFrameworkDiagnosticListenerTests.cs index bcadec75cd..5fcbf24833 100644 --- a/test/OpenTelemetry.Instrumentation.EntityFrameworkCore.Tests/EntityFrameworkDiagnosticListenerTests.cs +++ b/test/OpenTelemetry.Instrumentation.EntityFrameworkCore.Tests/EntityFrameworkDiagnosticListenerTests.cs @@ -233,6 +233,35 @@ public static TheoryData IsSqlLikeProviderTestCases() return testCases; } + public static TheoryData IsBackslashEscapeProviderTestCases() + { + var values = DbSystemTestCases().ToDictionary((k) => (string)k[0], (v) => false); + + string[] backslashEscapeProviders = + [ + "Devart.Data.MySql.Entity.EFCore", + "Devart.Data.MySql.MySqlCommand", + "MySql.Data.EntityFrameworkCore", + "MySql.Data.MySqlClient.MySqlCommand", + "MySql.EntityFrameworkCore", + "Pomelo.EntityFrameworkCore.MySql", + ]; + + foreach (var name in backslashEscapeProviders) + { + values[name] = true; + } + + var testCases = new TheoryData(); + + foreach ((var name, var expected) in values) + { + testCases.Add(name, expected); + } + + return testCases; + } + [Theory] [MemberData(nameof(DbSystemTestCases))] public void ShouldReturnCorrectAttributeValuesProviderOrCommandName(string name, string expectedDbSystem, string expectedDbSystemName) @@ -252,6 +281,15 @@ public void ShouldReturnCorrectValueForSqlLikeProviderOrCommandName(string name, Assert.Equal(expected, actual); } + [Theory] + [MemberData(nameof(IsBackslashEscapeProviderTestCases))] + public void ShouldReturnCorrectValueForBackslashEscapeProviderOrCommandName(string name, bool expected) + { + var actual = EntityFrameworkDiagnosticListener.IsBackslashEscapeProvider(name); + + Assert.Equal(expected, actual); + } + [Fact] public void EntityFrameworkContextEventsInstrumentedTest() {