diff --git a/backend/internal/handler/instance_detail.go b/backend/internal/handler/instance_detail.go index ac757c1..5d2020a 100644 --- a/backend/internal/handler/instance_detail.go +++ b/backend/internal/handler/instance_detail.go @@ -13,6 +13,7 @@ import ( "sql-platform/internal/middleware" "strings" "strconv" + "sort" "encoding/json" "bytes" "regexp" @@ -869,7 +870,122 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string switch v := result.(type) { 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" { for i, val := range v { rows = append(rows, map[string]interface{}{ diff --git a/backend/internal/handler/stub_handlers.go b/backend/internal/handler/stub_handlers.go index 853b5e2..946d75d 100644 --- a/backend/internal/handler/stub_handlers.go +++ b/backend/internal/handler/stub_handlers.go @@ -5,6 +5,8 @@ import ( "net/http" "strings" + "sql-platform/internal/model" + "github.com/gin-gonic/gin" ) @@ -13,6 +15,7 @@ import ( func AuditTicketSQL(c *gin.Context) { var input struct { SQLContent string `json:"sql_content" binding:"required"` + InstanceID int `json:"instance_id"` DBType string `json:"db_type"` Database string `json:"database"` } @@ -21,78 +24,160 @@ func AuditTicketSQL(c *gin.Context) { return } - // Basic SQL audit rules - var warnings []map[string]interface{} - sql := strings.ToUpper(input.SQLContent) - - // Rule: No WHERE clause on UPDATE/DELETE - if (strings.HasPrefix(sql, "UPDATE") || strings.HasPrefix(sql, "DELETE")) && !strings.Contains(sql, "WHERE") { - warnings = append(warnings, map[string]interface{}{ - "rule": "no_where_clause", - "severity": "high", - "message": "UPDATE/DELETE without WHERE clause affects all rows", - }) + // Resolve db_type from instance_id if not provided + dbType := input.DBType + if dbType == "" && input.InstanceID > 0 { + var instance model.Instance + if err := model.DB.Select("db_type").First(&instance, input.InstanceID).Error; err == nil { + dbType = instance.DBType + } } - // Rule: SELECT * - if strings.Contains(sql, "SELECT *") { - warnings = append(warnings, map[string]interface{}{ - "rule": "select_star", - "severity": "low", - "message": "SELECT * may return unnecessary columns", - }) + var findings []map[string]interface{} + sql := strings.ToUpper(strings.TrimSpace(input.SQLContent)) + + if dbType == "redis" { + // Redis command audit + findings = auditRedisCommand(sql) + } else { + // SQL audit rules + findings = auditSQL(sql) } - // Rule: No LIMIT on SELECT (for large tables) - if strings.HasPrefix(sql, "SELECT") && !strings.Contains(sql, "LIMIT") { - 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{}{} + if findings == nil { + findings = []map[string]interface{}{} } // Determine risk level riskLevel := "low" - for _, w := range warnings { - if w["severity"] == "critical" { + for _, f := range findings { + sev, _ := f["level"].(string) + if sev == "error" { riskLevel = "high" break } - if w["severity"] == "high" { + if sev == "warning" { riskLevel = "medium" } } c.JSON(http.StatusOK, gin.H{ "risk_level": riskLevel, - "warnings": warnings, - "passed": len(warnings) == 0, + "findings": findings, + "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 func SQLFormat(c *gin.Context) { var input struct { diff --git a/frontend/src/pages/TicketCreatePage.tsx b/frontend/src/pages/TicketCreatePage.tsx index 836de7f..9e8487a 100644 --- a/frontend/src/pages/TicketCreatePage.tsx +++ b/frontend/src/pages/TicketCreatePage.tsx @@ -399,7 +399,7 @@ export default function TicketCreatePage() { ι€šθΏ‡ )} - {auditResult.findings.length > 0 && ( + {auditResult.findings && auditResult.findings.length > 0 && (
{auditResult.findings.map((f, idx) => (