feat: ZRANGE WITHSCORES JSON自动解析为结构化表格 + Redis审计规则 + 前端安全检查
- ZRANGE WITHSCORES 自动检测JSON member,展开为 open/close/high/low/volume 等独立列 - Redis 审计: 只读命令安全,写命令warning,FLUSHALL等危险命令error - TicketCreatePage: auditResult.findings 添加 undefined 安全检查 - 审计API支持 instance_id 自动解析 db_type
This commit is contained in:
1 parent
9c5cb04cfa
commit
24c633d9f0
3 files changed
+255
-54
No files matched your search
@@ -13,6 +13,7 @@ import (
|
|||||||
"sql-platform/internal/middleware"
|
"sql-platform/internal/middleware"
|
||||||
"strings"
|
"strings"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sort"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"bytes"
|
"bytes"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -869,7 +870,122 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
|
|||||||
|
|
||||||
switch v := result.(type) {
|
switch v := result.(type) {
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
// Array result (MGET, LRANGE, etc.)
|
// ZRANGE WITHSCORES: Do() returns [[member1, score1], [member2, score2], ...]
|
||||||
|
// Each element is a []interface{} pair
|
||||||
|
if (cmd == "ZRANGE" || cmd == "ZRANGEBYSCORE") && len(v) >= 1 {
|
||||||
|
withScores := false
|
||||||
|
if pair, ok := v[0].([]interface{}); ok && len(pair) == 2 {
|
||||||
|
// Check if second element is numeric (score)
|
||||||
|
switch pair[1].(type) {
|
||||||
|
case float64, int64, string:
|
||||||
|
withScores = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if withScores {
|
||||||
|
type jsonEntry struct {
|
||||||
|
Member string
|
||||||
|
Score float64
|
||||||
|
Fields map[string]interface{}
|
||||||
|
}
|
||||||
|
entries := make([]jsonEntry, 0, len(v))
|
||||||
|
allKeys := map[string]bool{}
|
||||||
|
|
||||||
|
for _, item := range v {
|
||||||
|
pair, ok := item.([]interface{})
|
||||||
|
if !ok || len(pair) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
member := fmt.Sprintf("%v", pair[0])
|
||||||
|
var score float64
|
||||||
|
switch s := pair[1].(type) {
|
||||||
|
case float64:
|
||||||
|
score = s
|
||||||
|
case int64:
|
||||||
|
score = float64(s)
|
||||||
|
case string:
|
||||||
|
score, _ = strconv.ParseFloat(s, 64)
|
||||||
|
}
|
||||||
|
entry := jsonEntry{Member: member, Score: score}
|
||||||
|
|
||||||
|
var parsed interface{}
|
||||||
|
if json.Unmarshal([]byte(member), &parsed) == nil {
|
||||||
|
if m, ok := parsed.(map[string]interface{}); ok {
|
||||||
|
entry.Fields = m
|
||||||
|
for k := range m {
|
||||||
|
allKeys[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allKeys) > 0 {
|
||||||
|
// JSON members → expand into table columns
|
||||||
|
sortedKeys := make([]string, 0, len(allKeys))
|
||||||
|
for k := range allKeys {
|
||||||
|
sortedKeys = append(sortedKeys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(sortedKeys)
|
||||||
|
|
||||||
|
cols := []string{"#", "timestamp"}
|
||||||
|
cols = append(cols, sortedKeys...)
|
||||||
|
|
||||||
|
for idx, entry := range entries {
|
||||||
|
row := map[string]interface{}{
|
||||||
|
"#": idx + 1,
|
||||||
|
"timestamp": int64(entry.Score),
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys {
|
||||||
|
if entry.Fields != nil {
|
||||||
|
if fv, ok := entry.Fields[k]; ok {
|
||||||
|
row[k] = fv
|
||||||
|
} else {
|
||||||
|
row[k] = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row[k] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
columns = cols
|
||||||
|
} else {
|
||||||
|
// Plain members with scores
|
||||||
|
for idx, item := range v {
|
||||||
|
pair, ok := item.([]interface{})
|
||||||
|
if !ok || len(pair) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
member := fmt.Sprintf("%v", pair[0])
|
||||||
|
var score float64
|
||||||
|
switch s := pair[1].(type) {
|
||||||
|
case float64:
|
||||||
|
score = s
|
||||||
|
case int64:
|
||||||
|
score = float64(s)
|
||||||
|
case string:
|
||||||
|
score, _ = strconv.ParseFloat(s, 64)
|
||||||
|
}
|
||||||
|
rows = append(rows, map[string]interface{}{
|
||||||
|
"#": idx + 1,
|
||||||
|
"member": member,
|
||||||
|
"timestamp": int64(score),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
columns = []string{"#", "member", "timestamp"}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(rows)
|
||||||
|
return gin.H{
|
||||||
|
"columns": columns,
|
||||||
|
"rows": rows,
|
||||||
|
"total": total,
|
||||||
|
"count": total,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array result (MGET, LRANGE, ZRANGE without WITHSCORES, etc.)
|
||||||
if cmd == "MGET" {
|
if cmd == "MGET" {
|
||||||
for i, val := range v {
|
for i, val := range v {
|
||||||
rows = append(rows, map[string]interface{}{
|
rows = append(rows, map[string]interface{}{
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"sql-platform/internal/model"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ import (
|
|||||||
func AuditTicketSQL(c *gin.Context) {
|
func AuditTicketSQL(c *gin.Context) {
|
||||||
var input struct {
|
var input struct {
|
||||||
SQLContent string `json:"sql_content" binding:"required"`
|
SQLContent string `json:"sql_content" binding:"required"`
|
||||||
|
InstanceID int `json:"instance_id"`
|
||||||
DBType string `json:"db_type"`
|
DBType string `json:"db_type"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
}
|
}
|
||||||
@@ -21,78 +24,160 @@ func AuditTicketSQL(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic SQL audit rules
|
// Resolve db_type from instance_id if not provided
|
||||||
var warnings []map[string]interface{}
|
dbType := input.DBType
|
||||||
sql := strings.ToUpper(input.SQLContent)
|
if dbType == "" && input.InstanceID > 0 {
|
||||||
|
var instance model.Instance
|
||||||
// Rule: No WHERE clause on UPDATE/DELETE
|
if err := model.DB.Select("db_type").First(&instance, input.InstanceID).Error; err == nil {
|
||||||
if (strings.HasPrefix(sql, "UPDATE") || strings.HasPrefix(sql, "DELETE")) && !strings.Contains(sql, "WHERE") {
|
dbType = instance.DBType
|
||||||
warnings = append(warnings, map[string]interface{}{
|
}
|
||||||
"rule": "no_where_clause",
|
|
||||||
"severity": "high",
|
|
||||||
"message": "UPDATE/DELETE without WHERE clause affects all rows",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule: SELECT *
|
var findings []map[string]interface{}
|
||||||
if strings.Contains(sql, "SELECT *") {
|
sql := strings.ToUpper(strings.TrimSpace(input.SQLContent))
|
||||||
warnings = append(warnings, map[string]interface{}{
|
|
||||||
"rule": "select_star",
|
if dbType == "redis" {
|
||||||
"severity": "low",
|
// Redis command audit
|
||||||
"message": "SELECT * may return unnecessary columns",
|
findings = auditRedisCommand(sql)
|
||||||
})
|
} else {
|
||||||
|
// SQL audit rules
|
||||||
|
findings = auditSQL(sql)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule: No LIMIT on SELECT (for large tables)
|
if findings == nil {
|
||||||
if strings.HasPrefix(sql, "SELECT") && !strings.Contains(sql, "LIMIT") {
|
findings = []map[string]interface{}{}
|
||||||
warnings = append(warnings, map[string]interface{}{
|
|
||||||
"rule": "no_limit",
|
|
||||||
"severity": "medium",
|
|
||||||
"message": "SELECT without LIMIT may return excessive rows",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule: DROP TABLE
|
|
||||||
if strings.Contains(sql, "DROP TABLE") {
|
|
||||||
warnings = append(warnings, map[string]interface{}{
|
|
||||||
"rule": "drop_table",
|
|
||||||
"severity": "critical",
|
|
||||||
"message": "DROP TABLE is a destructive operation",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule: TRUNCATE
|
|
||||||
if strings.Contains(sql, "TRUNCATE") {
|
|
||||||
warnings = append(warnings, map[string]interface{}{
|
|
||||||
"rule": "truncate",
|
|
||||||
"severity": "critical",
|
|
||||||
"message": "TRUNCATE is a destructive operation",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if warnings == nil {
|
|
||||||
warnings = []map[string]interface{}{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine risk level
|
// Determine risk level
|
||||||
riskLevel := "low"
|
riskLevel := "low"
|
||||||
for _, w := range warnings {
|
for _, f := range findings {
|
||||||
if w["severity"] == "critical" {
|
sev, _ := f["level"].(string)
|
||||||
|
if sev == "error" {
|
||||||
riskLevel = "high"
|
riskLevel = "high"
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if w["severity"] == "high" {
|
if sev == "warning" {
|
||||||
riskLevel = "medium"
|
riskLevel = "medium"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"risk_level": riskLevel,
|
"risk_level": riskLevel,
|
||||||
"warnings": warnings,
|
"findings": findings,
|
||||||
"passed": len(warnings) == 0,
|
"passed": len(findings) == 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func auditSQL(sql string) []map[string]interface{} {
|
||||||
|
var findings []map[string]interface{}
|
||||||
|
|
||||||
|
// Rule: No WHERE clause on UPDATE/DELETE
|
||||||
|
if (strings.HasPrefix(sql, "UPDATE") || strings.HasPrefix(sql, "DELETE")) && !strings.Contains(sql, "WHERE") {
|
||||||
|
findings = append(findings, map[string]interface{}{
|
||||||
|
"rule": "no_where_clause", "level": "error",
|
||||||
|
"message": "UPDATE/DELETE without WHERE clause affects all rows",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule: SELECT *
|
||||||
|
if strings.Contains(sql, "SELECT *") {
|
||||||
|
findings = append(findings, map[string]interface{}{
|
||||||
|
"rule": "select_star", "level": "info",
|
||||||
|
"message": "SELECT * may return unnecessary columns",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule: No LIMIT on SELECT (for large tables)
|
||||||
|
if strings.HasPrefix(sql, "SELECT") && !strings.Contains(sql, "LIMIT") {
|
||||||
|
findings = append(findings, map[string]interface{}{
|
||||||
|
"rule": "no_limit", "level": "warning",
|
||||||
|
"message": "SELECT without LIMIT may return excessive rows",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule: DROP TABLE
|
||||||
|
if strings.Contains(sql, "DROP TABLE") {
|
||||||
|
findings = append(findings, map[string]interface{}{
|
||||||
|
"rule": "drop_table", "level": "error",
|
||||||
|
"message": "DROP TABLE is a destructive operation",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule: TRUNCATE
|
||||||
|
if strings.Contains(sql, "TRUNCATE") {
|
||||||
|
findings = append(findings, map[string]interface{}{
|
||||||
|
"rule": "truncate", "level": "error",
|
||||||
|
"message": "TRUNCATE is a destructive operation",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditRedisCommand(sql string) []map[string]interface{} {
|
||||||
|
var findings []map[string]interface{}
|
||||||
|
|
||||||
|
// Extract command name
|
||||||
|
parts := strings.Fields(sql)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
cmd := parts[0]
|
||||||
|
|
||||||
|
// Read-only commands are safe
|
||||||
|
readOnly := map[string]bool{
|
||||||
|
"GET": true, "MGET": true, "HGET": true, "HGETALL": true,
|
||||||
|
"LRANGE": true, "SMEMBERS": true, "ZRANGE": true, "ZSCORE": true,
|
||||||
|
"TYPE": true, "TTL": true, "PTTL": true, "EXISTS": true, "KEYS": true,
|
||||||
|
"INFO": true, "DBSIZE": true, "SCAN": true, "STRLEN": true,
|
||||||
|
"LLEN": true, "SCARD": true, "ZCARD": true, "HLEN": true,
|
||||||
|
"HKEYS": true, "HVALS": true, "PING": true, "CONFIG": true,
|
||||||
|
"CLIENT": true, "SLOWLOG": true, "CLUSTER": true, "MEMORY": true,
|
||||||
|
"COMMAND": true, "SRANDMEMBER": true, "RANDOMKEY": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if readOnly[cmd] {
|
||||||
|
// Read-only: always safe
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write commands — always need approval
|
||||||
|
writeDangerous := map[string]bool{
|
||||||
|
"FLUSHALL": true, "FLUSHDB": true, "DEBUG": true,
|
||||||
|
}
|
||||||
|
if writeDangerous[cmd] {
|
||||||
|
findings = append(findings, map[string]interface{}{
|
||||||
|
"rule": "redis_dangerous_command", "level": "error",
|
||||||
|
"message": cmd + " is a dangerous operation",
|
||||||
|
})
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
|
// General write commands
|
||||||
|
writeCmds := map[string]bool{
|
||||||
|
"SET": true, "DEL": true, "HSET": true, "HDEL": true,
|
||||||
|
"LPUSH": true, "RPUSH": true, "LPOP": true, "RPOP": true,
|
||||||
|
"SADD": true, "SREM": true, "ZADD": true, "ZREM": true,
|
||||||
|
"EXPIRE": true, "TTL": true, "RENAME": true, "MOVE": true,
|
||||||
|
"MSET": true, "SETNX": true, "SETEX": true, "INCR": true,
|
||||||
|
"INCRBY": true, "DECR": true, "DECRBY": true, "APPEND": true,
|
||||||
|
"PERSIST": true, "PEXPIRE": true, "EXPIREAT": true,
|
||||||
|
}
|
||||||
|
if writeCmds[cmd] {
|
||||||
|
findings = append(findings, map[string]interface{}{
|
||||||
|
"rule": "redis_write_command", "level": "warning",
|
||||||
|
"message": cmd + " is a write operation, requires approval",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
findings = append(findings, map[string]interface{}{
|
||||||
|
"rule": "redis_unknown_command", "level": "info",
|
||||||
|
"message": cmd + " is not in known command list",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/v1/sql-assistant/format
|
// POST /api/v1/sql-assistant/format
|
||||||
func SQLFormat(c *gin.Context) {
|
func SQLFormat(c *gin.Context) {
|
||||||
var input struct {
|
var input struct {
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ export default function TicketCreatePage() {
|
|||||||
<span className="text-green-400 font-medium flex items-center gap-1"><Check className="w-3.5 h-3.5" /> 通过</span>
|
<span className="text-green-400 font-medium flex items-center gap-1"><Check className="w-3.5 h-3.5" /> 通过</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{auditResult.findings.length > 0 && (
|
{auditResult.findings && auditResult.findings.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{auditResult.findings.map((f, idx) => (
|
{auditResult.findings.map((f, idx) => (
|
||||||
<div key={idx} className={'flex items-center gap-2 px-3 py-2 rounded-lg text-xs ' + (LEVEL_COLOR[f.level] || '')}>
|
<div key={idx} className={'flex items-center gap-2 px-3 py-2 rounded-lg text-xs ' + (LEVEL_COLOR[f.level] || '')}>
|
||||||
|
|||||||
Reference in new issue
Block a user