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:
1 parent
8fbcb8647d
commit
3a6dd99633
3 files changed
+563
-55
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user