- ZRANGE WITHSCORES 自动检测JSON member,展开为 open/close/high/low/volume 等独立列 - Redis 审计: 只读命令安全,写命令warning,FLUSHALL等危险命令error - TicketCreatePage: auditResult.findings 添加 undefined 安全检查 - 审计API支持 instance_id 自动解析 db_type
360 lines
9.5 KiB
Go
360 lines
9.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"sql-platform/internal/model"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// POST /api/v1/tickets/audit
|
|
// Runs SQL audit rules against provided SQL
|
|
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"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if findings == nil {
|
|
findings = []map[string]interface{}{}
|
|
}
|
|
|
|
// Determine risk level
|
|
riskLevel := "low"
|
|
for _, f := range findings {
|
|
sev, _ := f["level"].(string)
|
|
if sev == "error" {
|
|
riskLevel = "high"
|
|
break
|
|
}
|
|
if sev == "warning" {
|
|
riskLevel = "medium"
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"risk_level": riskLevel,
|
|
"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 {
|
|
SQL string `json:"sql" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Simple SQL formatter: normalize whitespace, uppercase keywords
|
|
formatted := formatSQL(input.SQL)
|
|
c.JSON(http.StatusOK, gin.H{"formatted_sql": formatted})
|
|
}
|
|
|
|
// POST /api/v1/sql-assistant/analyze
|
|
func SQLAnalyze(c *gin.Context) {
|
|
var input struct {
|
|
SQL string `json:"sql" binding:"required"`
|
|
DBType string `json:"db_type"`
|
|
}
|
|
if err := c.ShouldBindJSON(&input); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
return
|
|
}
|
|
|
|
sql := strings.ToUpper(strings.TrimSpace(input.SQL))
|
|
var sqlType, impact string
|
|
|
|
if strings.HasPrefix(sql, "SELECT") {
|
|
sqlType = "DQL"
|
|
impact = "read"
|
|
} else if strings.HasPrefix(sql, "INSERT") {
|
|
sqlType = "DML"
|
|
impact = "write"
|
|
} else if strings.HasPrefix(sql, "UPDATE") {
|
|
sqlType = "DML"
|
|
impact = "write"
|
|
} else if strings.HasPrefix(sql, "DELETE") {
|
|
sqlType = "DML"
|
|
impact = "write"
|
|
} else if strings.HasPrefix(sql, "CREATE") {
|
|
sqlType = "DDL"
|
|
impact = "schema_change"
|
|
} else if strings.HasPrefix(sql, "ALTER") {
|
|
sqlType = "DDL"
|
|
impact = "schema_change"
|
|
} else if strings.HasPrefix(sql, "DROP") {
|
|
sqlType = "DDL"
|
|
impact = "schema_change"
|
|
} else if strings.HasPrefix(sql, "GRANT") || strings.HasPrefix(sql, "REVOKE") {
|
|
sqlType = "DCL"
|
|
impact = "permission"
|
|
} else {
|
|
sqlType = "OTHER"
|
|
impact = "unknown"
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"sql_type": sqlType,
|
|
"impact": impact,
|
|
"tables": extractTables(input.SQL),
|
|
})
|
|
}
|
|
|
|
func formatSQL(sql string) string {
|
|
keywords := []string{"SELECT", "FROM", "WHERE", "AND", "OR", "ORDER BY", "GROUP BY",
|
|
"HAVING", "LIMIT", "OFFSET", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN",
|
|
"ON", "INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "CREATE TABLE",
|
|
"ALTER TABLE", "DROP TABLE", "CREATE INDEX", "UNION", "UNION ALL",
|
|
"CASE", "WHEN", "THEN", "ELSE", "END", "AS", "IN", "NOT", "IS NULL", "IS NOT NULL",
|
|
"BETWEEN", "LIKE", "EXISTS", "DISTINCT", "COUNT", "SUM", "AVG", "MIN", "MAX"}
|
|
|
|
result := sql
|
|
// Normalize whitespace
|
|
for strings.Contains(result, " ") {
|
|
result = strings.ReplaceAll(result, " ", " ")
|
|
}
|
|
result = strings.TrimSpace(result)
|
|
|
|
// Add newlines before major keywords
|
|
majorKeywords := []string{"SELECT", "FROM", "WHERE", "ORDER BY", "GROUP BY",
|
|
"HAVING", "LIMIT", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN",
|
|
"UNION", "UNION ALL", "INSERT INTO", "UPDATE", "SET", "DELETE FROM"}
|
|
for _, kw := range majorKeywords {
|
|
result = strings.ReplaceAll(result, " "+kw+" ", "\n"+kw+" ")
|
|
result = strings.ReplaceAll(result, " "+kw+"\n", "\n"+kw+"\n")
|
|
}
|
|
|
|
// Uppercase keywords
|
|
upper := strings.ToUpper(result)
|
|
for _, kw := range keywords {
|
|
// Simple approach - just uppercase the keyword occurrences
|
|
_ = kw
|
|
}
|
|
_ = upper
|
|
|
|
return result
|
|
}
|
|
|
|
func extractTables(sql string) []string {
|
|
sql = strings.ToUpper(sql)
|
|
var tables []string
|
|
|
|
// Extract tables after FROM
|
|
if idx := strings.Index(sql, "FROM"); idx >= 0 {
|
|
after := sql[idx+4:]
|
|
// Get the table name
|
|
parts := strings.Fields(after)
|
|
if len(parts) > 0 {
|
|
tables = append(tables, parts[0])
|
|
}
|
|
}
|
|
|
|
// Extract tables after JOIN
|
|
for {
|
|
idx := strings.Index(sql, "JOIN")
|
|
if idx < 0 {
|
|
break
|
|
}
|
|
after := sql[idx+4:]
|
|
parts := strings.Fields(after)
|
|
if len(parts) > 0 {
|
|
tables = append(tables, parts[0])
|
|
}
|
|
sql = after
|
|
}
|
|
|
|
if tables == nil {
|
|
tables = []string{}
|
|
}
|
|
return tables
|
|
}
|
|
|
|
// Stub handlers for dictionary and mongo (not yet implemented)
|
|
// These return empty responses to prevent frontend errors
|
|
|
|
func DictionaryDatabases(c *gin.Context) {
|
|
c.JSON(http.StatusOK, []string{})
|
|
}
|
|
|
|
func DictionaryTables(c *gin.Context) {
|
|
c.JSON(http.StatusOK, []interface{}{})
|
|
}
|
|
|
|
func DictionaryTableDetail(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"columns": []interface{}{}, "indexes": []interface{}{}})
|
|
}
|
|
|
|
func DictionaryExport(c *gin.Context) {
|
|
c.JSON(http.StatusOK, []interface{}{})
|
|
}
|
|
|
|
func MongoDatabases(c *gin.Context) {
|
|
c.JSON(http.StatusOK, []string{})
|
|
}
|
|
|
|
func MongoCollections(c *gin.Context) {
|
|
c.JSON(http.StatusOK, []string{})
|
|
}
|
|
|
|
func MongoCollectionStats(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"count": 0, "size": 0})
|
|
}
|
|
|
|
func MongoQuery(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"documents": []interface{}{}, "total": 0})
|
|
}
|
|
|
|
func MongoExecute(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"message": "MongoDB execution not implemented", "affected": 0})
|
|
}
|
|
|
|
func MongoTestConnection(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"message": "MongoDB not supported yet"})
|
|
}
|
|
|
|
// fmt import placeholder
|
|
var _ = fmt.Sprintf
|