feat: 审核规则驱动SQL检查 + db_type过滤 + Redis只读拦截 + 配置文档

1. 审核规则改为从audit_rules表动态匹配(去硬编码)
   - CreateTicket提交时查audit_rules WHERE db_type=? AND is_enabled=true
   - 正则匹配SQL,value=reject拦截,warn/review放行
   - UPDATE/DELETE无WHERE: Go RE2不支持(?!...)负向前瞻
     → 用value=where_check特殊标记+strings.Contains WHERE检测
   - 修复UPDATE/DELETE无WHERE正则(Go兼容)

2. ListAuditRules按db_type过滤
   - ghost.go: 添加c.Query('db_type') WHERE过滤
   - MySQL不显示KEYS规则,Redis不显示DROP规则

3. Redis只读命令拦截(工单提交)
   - 新增Redis只读命令白名单: GET/HGET/MGET/HGETALL/KEYS/SCAN等28个
   - 只读命令走工单→直接拦截,引导用户用查询功能

4. 补充audit_rules规则
   - Redis新增7条: DEBUG/SHUTDOWN/SCRIPT/SAVE/BGSAVE/BGREWRITEAOF/SLAVEOF/CLUSTER
   - MySQL新增1条: DROP SCHEMA
   - 总计34条规则(MySQL12/Redis11/PG7/MongoDB4)

5. docs/CONFIGURATION.md 配置系统文档
   - 三套配置体系: system_settings/audit_rules/ldap_configs
   - 每项配置: key/默认值/作用/代码引用位置
   - 未引用配置标注: executor_batch_size/executor_backup/query_max_rows_examined(待实现)
   - LDAP冗余配置: system_settings中7项ldap_*无代码引用(LDAP用独立表)

验证: 5/5审批流通过, 36/36安全测试通过, 27/27审核规则匹配正确
This commit is contained in:
logan committed 2026-06-03 03:17:22 +08:00
1 parent 8fbcb8647d
commit 3a6dd99633
3 files changed
+563 -55

No files matched your search

+73 -54
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
@@ -244,74 +245,92 @@ func CreateTicket(c *gin.Context) {
return
}
// Reject read-only SQL statements (SELECT/SHOW/DESC/EXPLAIN)
// Reject read-only SQL statements
upperSQL := strings.ToUpper(strings.TrimSpace(input.SQLContent))
readOnlyPrefixes := []string{"SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN"}
for _, prefix := range readOnlyPrefixes {
if strings.HasPrefix(upperSQL, prefix) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能"})
return
}
}
// ── Dangerous SQL blacklist ──
// MySQL: block DROP DATABASE / DROP SCHEMA
if input.DBType == "mysql" || input.DBType == "postgresql" || input.DBType == "postgres" || input.DBType == "pg" {
mysqlBlacklist := []struct {
prefix string
reason string
}{
{"DROP DATABASE ", "禁止执行 DROP DATABASE — 删库属于极端危险操作"},
{"DROP SCHEMA ", "禁止执行 DROP SCHEMA — 删库属于极端危险操作"},
}
statements := splitSQL(input.SQLContent)
for _, stmt := range statements {
upper := strings.ToUpper(strings.TrimSpace(stmt))
for _, bl := range mysqlBlacklist {
if strings.HasPrefix(upper, bl.prefix) {
c.JSON(http.StatusBadRequest, gin.H{"detail": bl.reason})
return
}
if input.DBType == "mysql" || input.DBType == "postgresql" {
readOnlyPrefixes := []string{"SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN"}
for _, prefix := range readOnlyPrefixes {
if strings.HasPrefix(upperSQL, prefix) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "数据变更工单不支持 SELECT/SHOW/DESC 等只读语句,请使用查询数据功能"})
return
}
// Warn: UPDATE/DELETE without WHERE
if (strings.HasPrefix(upper, "UPDATE ") || strings.HasPrefix(upper, "DELETE FROM ")) &&
!strings.Contains(upper, " WHERE ") {
c.JSON(http.StatusBadRequest, gin.H{"detail": "禁止无 WHERE 条件的 UPDATE/DELETE — 全表更新/删除属于高危操作,请添加 WHERE 子句限定范围"})
}
} else if input.DBType == "redis" {
// Redis: reject read-only commands (GET/HGET/KEYS/SCAN/TTL/TYPE/LLEN/LRANGE/INFO etc.)
redisReadOnlyCmds := []string{"GET", "HGET", "MGET", "HGETALL", "KEYS", "SCAN", "SSCAN", "HSCAN", "ZSCAN",
"TTL", "PTTL", "TYPE", "EXISTS", "LLEN", "LRANGE", "SRANDMEMBER", "SCARD", "SISMEMBER", "SMEMBERS",
"STRLEN", "INFO", "DBSIZE", "PING", "ECHO", "OBJECT", "BITCOUNT", "GETRANGE", "SUBSTR"}
cmdUpper := upperSQL
if idx := strings.IndexByte(cmdUpper, ' '); idx > 0 {
cmdUpper = cmdUpper[:idx]
}
for _, ro := range redisReadOnlyCmds {
if cmdUpper == ro {
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("数据变更工单不支持只读命令 %s,请使用查询功能", cmdUpper)})
return
}
}
}
// Redis: block extremely dangerous commands
// ── SQL审核规则检查 (from audit_rules table) ──
statements := splitSQL(input.SQLContent)
sqlToCheck := input.SQLContent
// For Redis, check as a single command
if input.DBType == "redis" {
redisBlacklist := []struct {
cmd string
reason string
}{
{"FLUSHALL", "禁止执行 FLUSHALL — 清空所有Redis数据"},
{"FLUSHDB", "禁止执行 FLUSHDB — 清空当前数据库"},
{"CONFIG ", "禁止执行 CONFIG — 修改Redis配置属于高危操作"},
{"DEBUG ", "禁止执行 DEBUG — 调试命令可能导致服务异常"},
{"SHUTDOWN", "禁止执行 SHUTDOWN — 关闭Redis服务"},
{"SCRIPT ", "禁止执行 SCRIPT — 脚本管理属于高危操作"},
{"SAVE", "禁止执行 SAVE — 阻塞式持久化"},
{"BGSAVE", "禁止执行 BGSAVE — 后台持久化由运维管理"},
{"BGREWRITEAOF", "禁止执行 BGREWRITEAOF — AOF重写由运维管理"},
{"SLAVEOF", "禁止执行 SLAVEOF — 主从切换属于运维操作"},
{"REPLICAOF", "禁止执行 REPLICAOF — 主从切换属于运维操作"},
{"CLUSTER ", "禁止执行 CLUSTER — 集群管理属于运维操作"},
{"KEYS ", "禁止执行 KEYS — 生产环境应使用 SCAN 替代,KEYS 会阻塞Redis"},
}
cmdParts := parseCommandParts(input.SQLContent)
if len(cmdParts) > 0 {
upperCmd := strings.ToUpper(cmdParts[0])
for _, bl := range redisBlacklist {
if upperCmd == strings.TrimSuffix(bl.cmd, " ") || strings.HasPrefix(upperCmd+" ", bl.cmd) {
c.JSON(http.StatusBadRequest, gin.H{"detail": bl.reason})
sqlToCheck = strings.ToUpper(cmdParts[0]) + " " + strings.Join(cmdParts[1:], " ")
}
}
var rules []model.AuditRule
model.DB.Where("db_type = ? AND is_enabled = ? AND pattern IS NOT NULL AND pattern != ''",
input.DBType, true).Find(&rules)
for _, stmt := range statements {
checkStmt := strings.TrimSpace(stmt)
if checkStmt == "" {
continue
}
// For Redis, the whole sql_content is one command
if input.DBType == "redis" {
checkStmt = sqlToCheck
}
for _, rule := range rules {
if rule.Pattern == nil || *rule.Pattern == "" {
continue
}
matched, _ := regexp.MatchString(*rule.Pattern, checkStmt)
if matched {
// Special: WHERE-check rules — reject only if no WHERE clause
if rule.Value != nil && *rule.Value == "where_check" {
if strings.Contains(strings.ToUpper(checkStmt), "WHERE") {
continue // has WHERE, safe
}
c.JSON(http.StatusBadRequest, gin.H{
"detail": fmt.Sprintf("审核规则拦截: %s — %s", rule.Name, rule.Message),
})
return
}
// value="reject" → block submission, "review"/"warn" → allow but record
action := "reject"
if rule.Value != nil {
action = *rule.Value
}
if action == "reject" {
c.JSON(http.StatusBadRequest, gin.H{
"detail": fmt.Sprintf("审核规则拦截: %s — %s", rule.Name, rule.Message),
})
return
}
// For warn/review rules, we could attach warnings later
// For now, reject-only rules block, others pass through
}
}
// Redis only has one command per ticket, break after first
if input.DBType == "redis" {
break
}
}
// Auto-fill workflow_id from instance if not provided