KpiSqlSecurityValidator.cs 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. using System.Text;
  2. using System.Text.RegularExpressions;
  3. namespace Admin.NET.Plugin.AiDOP.SmartOps;
  4. /// <summary>
  5. /// KPI 配置 SQL 安全校验(多层、非仅正则)。真正的写保护由只读事务在 DB 层强制(KpiSqlReadOnlyExecutor);
  6. /// 本校验器是意图闸门 + 纵深防御:单语句、起始 token 白名单、token 级 denylist、FROM 表白名单。
  7. /// 顺序:去注释 → 单语句门 → 起始 token 白名单(SELECT/WITH) → denylist(token 边界) → FROM 表白名单。
  8. /// </summary>
  9. public static class KpiSqlSecurityValidator
  10. {
  11. /// <summary>允许查询的表前缀(中台内部表;租户谓词软限降级——限制 FROM 目标而非静态分析谓词)。</summary>
  12. public static readonly string[] AllowedTablePrefixes =
  13. {
  14. "mdp_std_", "mdp_stg_", "dwd_", "ado_s9_kpi_value_", "ado_smart_ops_kpi_atomic"
  15. };
  16. // token 边界匹配的危险关键字(去注释后,按单词边界;避免子串误伤列名如 create_time)。
  17. private static readonly string[] DenyKeywords =
  18. {
  19. "INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE", "DROP", "ALTER", "CREATE", "TRUNCATE",
  20. "GRANT", "REVOKE", "RENAME", "LOAD", "CALL", "EXEC", "EXECUTE", "SET", "LOCK", "UNLOCK",
  21. "HANDLER", "PREPARE", "DEALLOCATE", "INTO", "OUTFILE", "DUMPFILE", "USE", "SHOW", "DESCRIBE",
  22. "ATTACH", "PRAGMA", "COPY"
  23. };
  24. private static readonly HashSet<string> StartTokenWhitelist = new(StringComparer.OrdinalIgnoreCase) { "SELECT", "WITH" };
  25. public sealed class Result
  26. {
  27. public bool Ok { get; set; }
  28. public string? ErrorCode { get; set; }
  29. public string? ErrorMessage { get; set; }
  30. public List<string> ReferencedTables { get; } = new();
  31. public static Result Fail(string code, string msg) => new() { Ok = false, ErrorCode = code, ErrorMessage = msg };
  32. }
  33. public static Result Validate(string? rawSql)
  34. {
  35. if (string.IsNullOrWhiteSpace(rawSql))
  36. return Result.Fail("EMPTY_SQL", "SQL 不能为空");
  37. // 1) 去注释:-- 行注释、# 行注释、/* */ 块注释(防止注释绕过关键字/多语句检测)
  38. var sql = StripComments(rawSql).Trim();
  39. if (sql.Length == 0)
  40. return Result.Fail("EMPTY_SQL", "SQL 去注释后为空");
  41. // 2) 单语句门:去尾部分号后,若仍含分号 → 多语句,拒
  42. var trimmed = sql.TrimEnd().TrimEnd(';').TrimEnd();
  43. if (trimmed.Contains(';'))
  44. return Result.Fail("MULTI_STATEMENT", "只允许单条语句,禁止分号多语句");
  45. if (trimmed.Length == 0)
  46. return Result.Fail("EMPTY_SQL", "SQL 为空语句");
  47. // token 化(按非标识符字符切分,保留 . 以便识别 schema.table)
  48. var tokens = Tokenize(trimmed);
  49. if (tokens.Count == 0)
  50. return Result.Fail("EMPTY_SQL", "SQL 无有效 token");
  51. // 3) 起始 token 白名单:必须以 SELECT 或 WITH 开头
  52. if (!StartTokenWhitelist.Contains(tokens[0]))
  53. return Result.Fail("NOT_SELECT", "只允许 SELECT 或 WITH ... SELECT 查询");
  54. // WITH 需其后出现 SELECT 主语句
  55. if (string.Equals(tokens[0], "WITH", StringComparison.OrdinalIgnoreCase)
  56. && !tokens.Any(t => string.Equals(t, "SELECT", StringComparison.OrdinalIgnoreCase)))
  57. return Result.Fail("NOT_SELECT", "WITH 之后必须为 SELECT 查询");
  58. // 4) token 级 denylist(单词边界匹配,非裸子串)
  59. var upperTokens = new HashSet<string>(tokens.Select(t => t.ToUpperInvariant()));
  60. foreach (var kw in DenyKeywords)
  61. {
  62. if (upperTokens.Contains(kw))
  63. return Result.Fail("FORBIDDEN_KEYWORD", $"禁止的关键字/操作:{kw}");
  64. }
  65. // 5) FROM 表白名单:提取 FROM/JOIN 后的表名,须命中允许前缀
  66. var result = new Result { Ok = true };
  67. var tables = ExtractTables(tokens);
  68. result.ReferencedTables.AddRange(tables);
  69. foreach (var tbl in tables)
  70. {
  71. var bare = tbl.Contains('.') ? tbl.Substring(tbl.LastIndexOf('.') + 1) : tbl;
  72. bare = bare.Trim('`', '"', '[', ']');
  73. if (!AllowedTablePrefixes.Any(p => bare.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
  74. return Result.Fail("TABLE_NOT_ALLOWED",
  75. $"表 {tbl} 不在允许的中台数据集范围(仅 {string.Join("/", AllowedTablePrefixes)})");
  76. }
  77. return result;
  78. }
  79. private static string StripComments(string sql)
  80. {
  81. // 块注释 /* ... */(含跨行)
  82. sql = Regex.Replace(sql, @"/\*.*?\*/", " ", RegexOptions.Singleline);
  83. var sb = new StringBuilder();
  84. foreach (var line in sql.Split('\n'))
  85. {
  86. var l = line;
  87. var dash = l.IndexOf("--", StringComparison.Ordinal);
  88. var hash = l.IndexOf('#');
  89. var cut = -1;
  90. if (dash >= 0) cut = dash;
  91. if (hash >= 0 && (cut < 0 || hash < cut)) cut = hash;
  92. if (cut >= 0) l = l.Substring(0, cut);
  93. sb.Append(l).Append('\n');
  94. }
  95. return sb.ToString();
  96. }
  97. private static List<string> Tokenize(string sql)
  98. {
  99. // 保留标识符字符 + . + @(参数);其余作分隔。字符串字面量整体折叠为占位,避免其中关键字误判。
  100. var noStrings = Regex.Replace(sql, @"'([^'\\]|\\.)*'", " '' ");
  101. noStrings = Regex.Replace(noStrings, "\"([^\"\\\\]|\\\\.)*\"", " \"\" ");
  102. var matches = Regex.Matches(noStrings, @"[A-Za-z0-9_@\.\$]+");
  103. return matches.Select(m => m.Value).Where(v => v.Length > 0).ToList();
  104. }
  105. private static List<string> ExtractTables(List<string> tokens)
  106. {
  107. var tables = new List<string>();
  108. for (var i = 0; i < tokens.Count - 1; i++)
  109. {
  110. var t = tokens[i].ToUpperInvariant();
  111. if (t == "FROM" || t == "JOIN")
  112. {
  113. var next = tokens[i + 1];
  114. // 跳过子查询开括号场景:Tokenize 已去括号,子查询里 FROM 会各自被扫描到;
  115. // 若 next 是保留字(SELECT 等)说明是子查询,跳过表名收集。
  116. if (StartTokenWhitelist.Contains(next) || string.Equals(next, "SELECT", StringComparison.OrdinalIgnoreCase))
  117. continue;
  118. tables.Add(next);
  119. }
  120. }
  121. return tables;
  122. }
  123. }