fix(security): Resolve SonarQube security hotspots
SonarQube Analysis / sonarqube (pull_request) Successful in 3m9s

Introduce a default regex match timeout across the library to prevent potential ReDoS attacks (SonarQube rule S6444).
Implement `[OnDeserialized]` methods to re-establish object invariants and validate state after deserialization, addressing SonarQube rule S5766.
This commit is contained in:
Thom Lamb
2026-05-20 17:19:17 -05:00
parent df1805a402
commit e3153e58c4
26 changed files with 268 additions and 58 deletions
@@ -48,6 +48,18 @@ public abstract class SqlBreakdownBase : ISqlBreakdown
/// </summary>
public bool IsUsingFinishClause => FinishClauses.Count > 0;
/// <summary>
/// Re-establishes the invariants normally guaranteed by the constructors after the object
/// is reconstructed by deserialization. Deserialization bypasses constructors, so the
/// collection state must be re-validated to avoid a partially-initialized object
/// (SonarQube rule S5766).
/// </summary>
protected void RevalidateBreakdownState()
{
SetupClauses ??= new List<string>();
FinishClauses ??= new ArrayList();
}
/// <summary>
/// Gets the SQL breakdown as a string. Must be implemented by derived classes.
/// </summary>
@@ -1,3 +1,4 @@
using System.Runtime.Serialization;
using System.Text;
using Strata.SqlTools.SqlBreakdown.Interfaces;
using Strata.SqlTools.SqlBreakdown.Interfaces.QueryEngine;
@@ -35,6 +36,20 @@ public class SqlBreakdownCollection : ICollection<ISqlBreakdown>
_breakdowns = new List<ISqlBreakdown>(breakdowns ?? Enumerable.Empty<ISqlBreakdown>());
}
/// <summary>
/// Validates that the backing list survived deserialization, since deserialization bypasses
/// the constructors that normally initialize it (SonarQube rule S5766).
/// </summary>
/// <param name="context">The streaming context for the deserialization operation.</param>
[OnDeserialized]
private void OnDeserialized(StreamingContext context)
{
if (_breakdowns is null)
{
throw new SerializationException("Deserialized SqlBreakdownCollection is missing its backing list.");
}
}
/// <summary>
/// Gets the collection of SQL breakdowns.
/// </summary>
@@ -1,3 +1,4 @@
using System.Runtime.Serialization;
using System.Text;
using Strata.SqlTools.SqlBreakdown.Interfaces.Core;
@@ -58,6 +59,21 @@ public class SqlFilter : ISqlAppendable
}
}
/// <summary>
/// Validates that the expression and parameter state survived deserialization, since
/// deserialization bypasses the constructors that normally initialize them and enforce the
/// even parameter-name/value pairing (SonarQube rule S5766).
/// </summary>
/// <param name="context">The streaming context for the deserialization operation.</param>
[OnDeserialized]
private void OnDeserialized(StreamingContext context)
{
if (_sqlExpression is null || _parameterValues is null)
{
throw new SerializationException("Deserialized SqlFilter is missing its expression or parameter state.");
}
}
/// <summary>
/// Gets or sets the SQL expression.
/// </summary>
@@ -1,5 +1,6 @@
using System.Text;
using System.Text.RegularExpressions;
using Strata.SqlTools.SqlBreakdown.Utilities;
namespace Strata.SqlTools.SqlBreakdown.Extensions;
@@ -10,7 +11,7 @@ public class StringBuilderEx
{
private static readonly Regex AppendFormatExRegex = new Regex(
@"\{(?<Index>.*?)(?<Comment>!.*?)?\}",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
RegexOptions.Compiled | RegexOptions.IgnoreCase, RegexDefaults.MatchTimeout);
private readonly StringBuilder _innerStringBuilder;
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using Strata.SqlTools.SqlBreakdown.Utilities;
namespace Strata.SqlTools.SqlBreakdown.Extensions;
@@ -119,7 +120,7 @@ public static class StringExtensions
public static bool IsGUID(this string aString)
{
const string pattern = "^[0-9a-zA-Z]{8}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{12}$";
var match = Regex.Match(aString, pattern);
var match = Regex.Match(aString, pattern, RegexOptions.None, RegexDefaults.MatchTimeout);
return match.Success;
}
@@ -226,7 +226,7 @@ public static class GuidUtils
private static readonly Regex FindFirstGuidRegex = new Regex(
"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexDefaults.MatchTimeout);
#endregion
@@ -292,7 +292,7 @@ public static class GuidUtils
/// <returns>A list of GUID strings found in the input.</returns>
public static List<string> GetGuids(string value)
{
MatchCollection matches = Regex.Matches(value, GUID_STRING);
MatchCollection matches = Regex.Matches(value, GUID_STRING, RegexOptions.None, RegexDefaults.MatchTimeout);
return matches.Cast<Match>().Select(x => x.Value).ToList();
}
@@ -0,0 +1,14 @@
namespace Strata.SqlTools.SqlBreakdown.Utilities;
/// <summary>
/// Shared defaults for <see cref="System.Text.RegularExpressions.Regex"/> usage across the SQL tools.
/// </summary>
public static class RegexDefaults
{
/// <summary>
/// Default match timeout applied to regular expressions to guard against catastrophic
/// backtracking / ReDoS denial-of-service attacks (SonarQube rule S6444). Regular expressions
/// in this library process arbitrary SQL text, so every pattern is given a bounded execution time.
/// </summary>
public static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(1);
}
@@ -115,7 +115,7 @@ public static partial class SqlUtils
public static string GetSqlFriendlyName(string str, string replacement = "")
{
// Invalid characters --> . , ; ' ` : / \ * | ? " & % $ ! + = ( ) [ ] { } - ~ ^
return new Regex(@"[\.,;'`:/\\*\|?""&%\$!\+=\(\)\[\]{}\-~\ \^]").Replace(str, replacement);
return new Regex(@"[\.,;'`:/\\*\|?""&%\$!\+=\(\)\[\]{}\-~\ \^]", RegexOptions.None, RegexDefaults.MatchTimeout).Replace(str, replacement);
}
#endregion
@@ -382,37 +382,37 @@ public static partial class SqlUtils
{
string msg = ex.Message;
Match match = Regex.Match(msg, @".*?Violation of UNIQUE KEY constraint (.*?) Cannot insert duplicate key in object (.*?)");
Match match = Regex.Match(msg, @".*?Violation of UNIQUE KEY constraint (.*?) Cannot insert duplicate key in object (.*?)", RegexOptions.None, RegexDefaults.MatchTimeout);
if (match.Success)
{
return WellKnownSqlError.UniqueKeyViolation;
}
match = Regex.Match(msg, @".*Cannot insert duplicate key row in object.*", RegexOptions.IgnoreCase);
match = Regex.Match(msg, @".*Cannot insert duplicate key row in object.*", RegexOptions.IgnoreCase, RegexDefaults.MatchTimeout);
if (match.Success)
{
return WellKnownSqlError.UniqueKeyViolation;
}
match = Regex.Match(msg, @".*Update or insert of view or function (.*?) failed because it contains a derived or constant field.*");
match = Regex.Match(msg, @".*Update or insert of view or function (.*?) failed because it contains a derived or constant field.*", RegexOptions.None, RegexDefaults.MatchTimeout);
if (match.Success)
{
return WellKnownSqlError.UpdateViewMultipleTables;
}
match = Regex.Match(msg, @".*There is already an object named (.*?) in the database.*");
match = Regex.Match(msg, @".*There is already an object named (.*?) in the database.*", RegexOptions.None, RegexDefaults.MatchTimeout);
if (match.Success)
{
return WellKnownSqlError.ObjectAlreadyExists;
}
match = Regex.Match(msg, @".*Cannot truncate table (.*?) because it is being referenced by a FOREIGN KEY constraint.*");
match = Regex.Match(msg, @".*Cannot truncate table (.*?) because it is being referenced by a FOREIGN KEY constraint.*", RegexOptions.None, RegexDefaults.MatchTimeout);
if (match.Success)
{
return WellKnownSqlError.TruncateTableForeignKeyReferenceError;
}
match = Regex.Match(msg, @".*Could not truncate object (.*?) because it is not a table.*");
match = Regex.Match(msg, @".*Could not truncate object (.*?) because it is not a table.*", RegexOptions.None, RegexDefaults.MatchTimeout);
if (match.Success)
{
return WellKnownSqlError.TruncateTableNotATableError;