This commit is contained in:
MengMengCode
2026-06-17 20:41:11 +08:00
parent 92e846eecc
commit cd258fd6ac
3 changed files with 342 additions and 67 deletions
+127 -48
View File
@@ -49,15 +49,19 @@ type connEntry struct {
}
type trafficStats struct {
total int
totalSynSent int
destCounts map[string]int
destPorts map[string]map[int]int
portDestCounts map[int]map[string]int
portTotalCounts map[int]int
udpDestCounts map[int]map[string]int
udpTotalCounts map[int]int
synSentByDst map[string]int
total int
totalSynSent int
destCounts map[string]int
destPorts map[string]map[int]int
portDestCounts map[int]map[string]int
portTotalCounts map[int]int
udpDestCounts map[int]map[string]int
udpTotalCounts map[int]int
udpDestTotalCounts map[string]int
synSentByDst map[string]int
tcpSynDestPorts map[string]map[int]int
tcpSynPortDestCounts map[int]map[string]int
tcpSynPortTotalCounts map[int]int
}
var scanner *SecurityScanner
@@ -232,13 +236,17 @@ func (ss *SecurityScanner) checkContainer(name, ip string) {
func newTrafficStats() *trafficStats {
return &trafficStats{
destCounts: make(map[string]int),
destPorts: make(map[string]map[int]int),
portDestCounts: make(map[int]map[string]int),
portTotalCounts: make(map[int]int),
udpDestCounts: make(map[int]map[string]int),
udpTotalCounts: make(map[int]int),
synSentByDst: make(map[string]int),
destCounts: make(map[string]int),
destPorts: make(map[string]map[int]int),
portDestCounts: make(map[int]map[string]int),
portTotalCounts: make(map[int]int),
udpDestCounts: make(map[int]map[string]int),
udpTotalCounts: make(map[int]int),
synSentByDst: make(map[string]int),
udpDestTotalCounts: make(map[string]int),
tcpSynDestPorts: make(map[string]map[int]int),
tcpSynPortDestCounts: make(map[int]map[string]int),
tcpSynPortTotalCounts: make(map[int]int),
}
}
@@ -264,52 +272,64 @@ func (ts *trafficStats) add(conn connEntry) {
}
ts.udpDestCounts[conn.dstPort][conn.dstIP]++
ts.udpTotalCounts[conn.dstPort]++
ts.udpDestTotalCounts[conn.dstIP]++
}
}
if conn.state == "SYN_SENT" {
if conn.proto == "tcp" && conn.state == "SYN_SENT" {
ts.totalSynSent++
ts.synSentByDst[conn.dstIP]++
if conn.dstPort > 0 {
if ts.tcpSynDestPorts[conn.dstIP] == nil {
ts.tcpSynDestPorts[conn.dstIP] = make(map[int]int)
}
ts.tcpSynDestPorts[conn.dstIP][conn.dstPort]++
if ts.tcpSynPortDestCounts[conn.dstPort] == nil {
ts.tcpSynPortDestCounts[conn.dstPort] = make(map[string]int)
}
ts.tcpSynPortDestCounts[conn.dstPort][conn.dstIP]++
ts.tcpSynPortTotalCounts[conn.dstPort]++
}
}
}
func (ss *SecurityScanner) detectPortScans(name, ip string, stats *trafficStats) {
for dstIP, portCounts := range stats.destPorts {
for dstIP, portCounts := range stats.tcpSynDestPorts {
uniquePorts := len(portCounts)
switch {
case uniquePorts >= 20:
case uniquePorts >= 25:
ss.addAlert(name, "port_scan", "high", ip, dstIP, 0,
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts),
"")
case uniquePorts >= 8:
case uniquePorts >= 12:
ss.addAlert(name, "port_scan", "medium", ip, dstIP, 0,
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同 TCP 半开目标端口", dstIP, uniquePorts),
"")
}
}
for port, targets := range stats.portDestCounts {
for port, targets := range stats.tcpSynPortDestCounts {
uniqueTargets := len(targets)
if service, ok := bruteForcePorts[port]; ok {
if uniqueTargets >= 30 {
ss.addAlert(name, "brute_force", "critical", ip, "*", port,
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets),
"")
} else if uniqueTargets >= 10 {
} else if uniqueTargets >= 12 {
ss.addAlert(name, "brute_force", "high", ip, "*", port,
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 出现 TCP 半开连接并覆盖 %d 个不同 IP", service, port, uniqueTargets),
"")
}
continue
}
if uniqueTargets >= 40 {
if uniqueTargets >= 50 {
ss.addAlert(name, "horizontal_scan", "high", ip, "*", port,
fmt.Sprintf("横向扫描: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
fmt.Sprintf("横向扫描: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets),
"")
} else if uniqueTargets >= 15 {
} else if uniqueTargets >= 20 {
ss.addAlert(name, "horizontal_scan", "medium", ip, "*", port,
fmt.Sprintf("可疑横向探测: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
fmt.Sprintf("可疑横向探测: 同一 TCP 端口 %d 出现半开连接并覆盖 %d 个不同目标", port, uniqueTargets),
"")
}
}
@@ -323,13 +343,25 @@ func (ss *SecurityScanner) detectBruteForce(name, ip string, stats *trafficStats
continue
}
if count >= 20 {
synCount := 0
if ports := stats.tcpSynDestPorts[dstIP]; ports != nil {
synCount = ports[port]
}
if synCount >= 25 {
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
fmt.Sprintf("暴力破解: %s(%d) 当前连接 %d", service, port, count),
fmt.Sprintf("暴力破解: %s(%d) 当前 TCP 半开连接 %d", service, port, synCount),
"")
} else if count >= 10 {
} else if synCount >= 12 {
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接 %d", service, port, count),
fmt.Sprintf("疑似暴力破解: %s(%d) 当前 TCP 半开连接 %d", service, port, synCount),
"")
} else if count >= 60 {
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d 条", service, port, count),
"")
} else if count >= 30 {
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d 条", service, port, count),
"")
}
}
@@ -356,30 +388,41 @@ func (ss *SecurityScanner) detectSpam(name, ip string, stats *trafficStats) {
func (ss *SecurityScanner) detectMassAbuse(name, ip string, stats *trafficStats) {
targets := len(stats.destCounts)
switch {
case targets >= 100:
case targets >= 120 && stats.total >= 600:
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("大规模对外连接: 当前覆盖 %d 个不同目标", targets),
fmt.Sprintf("大规模对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets),
"")
case targets >= 35:
case targets >= 60 && stats.total >= 300:
ss.addAlert(name, "ddos", "high", ip, "*", 0,
fmt.Sprintf("大量对外连接: 当前覆盖 %d 个不同目标", targets),
fmt.Sprintf("大量对外连接: 当前 conntrack 出站记录 %d 条,覆盖 %d 个不同目标", stats.total, targets),
"")
}
synTargets := len(stats.synSentByDst)
switch {
case stats.total >= 500:
case stats.totalSynSent >= 250 || (synTargets >= 80 && stats.totalSynSent >= 160):
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("异常大量连接: 当前 conntrack 出站记录 %d 条", stats.total),
fmt.Sprintf("大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets),
"")
case stats.total >= 200:
case stats.totalSynSent >= 100 || (synTargets >= 35 && stats.totalSynSent >= 70):
ss.addAlert(name, "ddos", "high", ip, "*", 0,
fmt.Sprintf("连接: 当前 conntrack 出站记录 %d 条", stats.total),
fmt.Sprintf("可疑大量半开连接: 当前 TCP SYN_SENT %d 条,覆盖 %d 个不同目标", stats.totalSynSent, synTargets),
"")
}
if stats.totalSynSent >= 100 {
udpTargets := len(stats.udpDestTotalCounts)
udpTotal := 0
for _, count := range stats.udpTotalCounts {
udpTotal += count
}
switch {
case udpTargets >= 120 && udpTotal >= 300:
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("大量半开连接: 当前 SYN_SENT %d 条", stats.totalSynSent),
fmt.Sprintf("UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets),
"")
case udpTargets >= 50 && udpTotal >= 120:
ss.addAlert(name, "ddos", "high", ip, "*", 0,
fmt.Sprintf("可疑 UDP 大规模外发: 当前 UDP 连接 %d 条,覆盖 %d 个不同目标", udpTotal, udpTargets),
"")
}
@@ -404,11 +447,18 @@ func (ss *SecurityScanner) detectReflectionAbuse(name, ip string, stats *traffic
continue
}
if targets >= 30 || total >= 100 {
criticalTargets, criticalTotal := 40, 120
highTargets, highTotal := 15, 45
if port == 53 {
criticalTargets, criticalTotal = 75, 300
highTargets, highTotal = 25, 100
}
if targets >= criticalTargets && total >= criticalTotal {
ss.addAlert(name, "reflection", "critical", ip, "*", port,
fmt.Sprintf("UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
"")
} else if targets >= 10 || total >= 30 {
} else if targets >= highTargets && total >= highTotal {
ss.addAlert(name, "reflection", "high", ip, "*", port,
fmt.Sprintf("疑似 UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
"")
@@ -645,6 +695,9 @@ func severityRank(severity string) int {
}
func autoShutdownAlertContainer(containerName, alertType, severity string) {
if !config.AppConfig.SecurityAutoShutdown {
return
}
c := config.FindContainerByName(containerName)
if c == nil || c.Status != "running" {
return
@@ -660,6 +713,24 @@ func autoShutdownAlertContainer(containerName, alertType, severity string) {
}
}
func clearSecurityPolicyBlocks() int {
cleared := 0
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if !c.PolicyBlocked || !isSecurityPolicyBlockReason(c.PolicyBlockedReason) {
continue
}
config.SetContainerPolicyBlock(c.ID, false, "")
config.AddAuditLog("security_policy_unblock", c.Name, "关闭安全告警自动关机后解除策略临时封禁", "system")
cleared++
}
return cleared
}
func isSecurityPolicyBlockReason(reason string) bool {
return strings.Contains(reason, "告警触发策略临时封禁")
}
// HandleSecurityAlerts returns all security alerts.
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@@ -699,9 +770,17 @@ func HandleSecuritySettings(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
cancelledTasks := 0
clearedBlocks := 0
if !req.AutoShutdown {
cancelledTasks = globalQueue.CancelPendingSecurityStops()
clearedBlocks = clearSecurityPolicyBlocks()
}
auditRequest(r, "security.settings", "auto_shutdown", fmt.Sprintf("auto_shutdown=%v", req.AutoShutdown), true, "")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]bool{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]interface{}{
"auto_shutdown": config.AppConfig.SecurityAutoShutdown,
"cancelled_tasks": cancelledTasks,
"cleared_blocks": clearedBlocks,
}})
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
+148
View File
@@ -0,0 +1,148 @@
package api
import (
"fmt"
"testing"
"clicd/internal/config"
)
func TestDetectReflectionAbuseIgnoresSingleDNSResolver(t *testing.T) {
resetSecurityTestConfig()
stats := newTrafficStats()
for i := 0; i < 180; i++ {
stats.add(connEntry{
dstIP: "1.1.1.1",
dstPort: 53,
proto: "udp",
state: "UNREPLIED",
})
}
ss := newSecurityScanner()
ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats)
if len(ss.alerts) != 0 {
t.Fatalf("normal DNS queries to one resolver should not trigger reflection alert: %+v", ss.alerts)
}
}
func TestDetectReflectionAbuseFlagsWideDNSFanout(t *testing.T) {
resetSecurityTestConfig()
stats := newTrafficStats()
for i := 0; i < 120; i++ {
stats.add(connEntry{
dstIP: fmt.Sprintf("203.0.113.%d", i),
dstPort: 53,
proto: "udp",
state: "UNREPLIED",
})
}
ss := newSecurityScanner()
ss.detectReflectionAbuse("ct-dns", "10.0.0.2", stats)
if len(ss.alerts) != 1 {
t.Fatalf("expected one reflection alert, got %+v", ss.alerts)
}
if got := ss.alerts[0].Type; got != "reflection" {
t.Fatalf("expected reflection alert, got %q", got)
}
}
func TestDetectPortScansUsesHalfOpenConnections(t *testing.T) {
resetSecurityTestConfig()
established := newTrafficStats()
for port := 8000; port < 8020; port++ {
established.add(connEntry{
dstIP: "198.51.100.10",
dstPort: port,
proto: "tcp",
state: "ESTABLISHED",
})
}
ss := newSecurityScanner()
ss.detectPortScans("ct-web", "10.0.0.3", established)
if len(ss.alerts) != 0 {
t.Fatalf("established multi-port connections should not trigger port scan alert: %+v", ss.alerts)
}
halfOpen := newTrafficStats()
for port := 8000; port < 8012; port++ {
halfOpen.add(connEntry{
dstIP: "198.51.100.10",
dstPort: port,
proto: "tcp",
state: "SYN_SENT",
})
}
ss.detectPortScans("ct-web", "10.0.0.3", halfOpen)
if len(ss.alerts) != 1 {
t.Fatalf("expected one port scan alert, got %+v", ss.alerts)
}
if got := ss.alerts[0].Type; got != "port_scan" {
t.Fatalf("expected port_scan alert, got %q", got)
}
}
func TestCancelPendingSecurityStops(t *testing.T) {
resetSecurityTestConfig()
q := &TaskQueue{
tasks: map[string]*Task{},
}
securityTask := &Task{
ID: "task-1",
Type: TaskStop,
ContainerID: 1,
Status: "pending",
User: "system:security",
}
userTask := &Task{
ID: "task-2",
Type: TaskStop,
ContainerID: 2,
Status: "pending",
User: "admin",
}
runningSecurityTask := &Task{
ID: "task-3",
Type: TaskStop,
ContainerID: 3,
Status: "running",
User: "system:security",
}
q.tasks[securityTask.ID] = securityTask
q.tasks[userTask.ID] = userTask
q.tasks[runningSecurityTask.ID] = runningSecurityTask
q.opQueue = []*Task{securityTask, userTask, runningSecurityTask}
if got := q.CancelPendingSecurityStops(); got != 1 {
t.Fatalf("expected one pending security stop to be cancelled, got %d", got)
}
if _, ok := q.tasks[securityTask.ID]; ok {
t.Fatal("pending security stop task was not removed")
}
if _, ok := q.tasks[userTask.ID]; !ok {
t.Fatal("user stop task should not be removed")
}
if _, ok := q.tasks[runningSecurityTask.ID]; !ok {
t.Fatal("running security stop task should be left for worker-side skip")
}
if len(q.opQueue) != 2 {
t.Fatalf("expected op queue to keep two tasks, got %d", len(q.opQueue))
}
}
func resetSecurityTestConfig() {
config.AppConfig = &config.ClicdConfig{
Containers: []config.Container{},
AuditLogs: []config.AuditLog{},
Tasks: []config.SavedTask{},
}
}
+67 -19
View File
@@ -213,6 +213,10 @@ func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string
}
func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (string, bool) {
if !config.AppConfig.SecurityAutoShutdown {
return "", false
}
q.mu.Lock()
defer q.mu.Unlock()
@@ -230,6 +234,34 @@ func (q *TaskQueue) EnqueueSecurityStop(containerID int, containerName string) (
return taskID, true
}
func (q *TaskQueue) CancelPendingSecurityStops() int {
q.mu.Lock()
defer q.mu.Unlock()
cancelled := 0
newOpQueue := make([]*Task, 0, len(q.opQueue))
for _, task := range q.opQueue {
if isSecurityStopTask(task) && task.Status == "pending" {
delete(q.tasks, task.ID)
cancelled++
continue
}
newOpQueue = append(newOpQueue, task)
}
q.opQueue = newOpQueue
for id, task := range q.tasks {
if isSecurityStopTask(task) && task.Status == "pending" {
delete(q.tasks, id)
cancelled++
}
}
if cancelled > 0 {
q.persistTasks()
}
return cancelled
}
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
// If a restored task already has a same-name container in config, it resumes
// initialization instead of creating another ct-{id}.
@@ -324,6 +356,7 @@ func (q *TaskQueue) opWorker() {
q.mu.Unlock()
var err error
skipped := false
err = resolveTaskContainer(task)
// Block operations on expired or traffic-exceeded containers (except stop/delete)
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
@@ -336,27 +369,32 @@ func (q *TaskQueue) opWorker() {
}
}
}
if err == nil && isSecurityStopTask(task) && !config.AppConfig.SecurityAutoShutdown {
skipped = true
}
if err == nil {
switch task.Type {
case TaskStart:
err = startByRuntime(task.ContainerID)
case TaskStop:
err = stopByRuntime(task.ContainerID)
case TaskRestart:
err = restartByRuntime(task.ContainerID)
case TaskDelete:
err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil {
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
if !skipped {
switch task.Type {
case TaskStart:
err = startByRuntime(task.ContainerID)
case TaskStop:
err = stopByRuntime(task.ContainerID)
case TaskRestart:
err = restartByRuntime(task.ContainerID)
case TaskDelete:
err = destroyByRuntime(task.ContainerID)
if err == nil {
time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil {
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
}
}
case TaskReinstall:
if lxc.HasSSHAuthOptions(task.Config) {
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
} else {
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
case TaskReinstall:
if lxc.HasSSHAuthOptions(task.Config) {
err = reinstallByRuntime(task.ContainerID, task.TemplateID, task.Config)
} else {
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
}
}
}
@@ -370,6 +408,9 @@ func (q *TaskQueue) opWorker() {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
} else if skipped {
task.Status = "done"
config.AddAuditLogFull(string(task.Type), task.ContainerName, "跳过: 安全告警自动关机已关闭", auditUser, task.IP, task.UserAgent, true, "")
} else {
task.Status = "done"
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
@@ -391,6 +432,10 @@ func (q *TaskQueue) opWorker() {
}
}
func isSecurityStopTask(task *Task) bool {
return task != nil && task.Type == TaskStop && task.User == "system:security"
}
func clearPolicyBlockAfterAdminRecovery(task *Task) {
if task == nil || strings.HasPrefix(task.User, "user:") || task.User == "system:security" {
return
@@ -817,6 +862,9 @@ func HandleTasks(w http.ResponseWriter, r *http.Request) {
// RestoreTasks restores task queue from config
func RestoreTasks() {
for _, st := range config.AppConfig.Tasks {
if st.Type == string(TaskStop) && st.User == "system:security" && !config.AppConfig.SecurityAutoShutdown {
continue
}
var cfg lxc.ContainerConfig
if st.Config != "" {
json.Unmarshal([]byte(st.Config), &cfg)