Files
tamabox/internal/security/autoban/autoban.go
T
tamakyi 7d52155411 fix(honeypot): 蜜罐封禁与 enable_auto_ban 解耦 + 泄露扫描后缀匹配
- autoban: 新增 RecordHoneypot 独立通道(不受 enabled 约束)+ 独立阈值/时长;
  IsBanned 不再因 enable_auto_ban=false 而忽略已有封禁
- honeypot: 新增 honeypotSuffixes 后缀匹配,覆盖 /backup.zip、/db.sql、/id_rsa.pem
- 新增配置 honeypot_threshold / honeypot_ban_minutes
- 补测试:autoban/honeypot_test.go、route 侧禁用自动封禁仍封禁
2026-10-21 12:36:33 +08:00

402 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package autoban 提供 fail2ban 风格的自动临时封禁:检测批量扫描/探测特征后
// 临时封禁来源 IP,并可回调发送告警。纯标准库实现,便于独立测试与复用。
package autoban
import (
"strings"
"sync"
"time"
)
// 探测事件类型
const (
KindNone = 0 // 无探测特征
KindSensitive = 1 // 命中敏感路径
KindNotFound = 2 // 404 响应(未命中敏感路径)
)
// 默认阈值/窗口(Options 未配置或为 0 时兜底)。
const (
DefaultWindowSeconds = 60
DefaultDurationMinutes = 15
DefaultThresholdSensitive = 10
DefaultThreshold404 = 20
pathSampleMax = 6
)
// Options 自动封禁配置。
type Options struct {
Enabled bool
// TrackNotFound 是否把 KindNotFound(未注册路径的 404)计入封禁统计。
// 默认 false:正常业务路径一律放行,仅敏感扫描特征路径参与计数封禁,
// 避免共享出口下正常访问被 404 探测连坐误伤(详见 conf.Security.BanTrackNotFound)。
TrackNotFound bool
ThresholdSensitive int // 窗口内敏感路径命中次数
Threshold404 int // 窗口内 404 次数
Window time.Duration // 统计窗口
Duration time.Duration // 封禁时长
AlertEmail string // 告警收件人(留空不发送)
SendAlert func(ip string, paths []string, durationMinutes int) error // 告警发送函数(nil 则不发送)
// HoneypotEnabled 独立于 Enabled:蜜罐(假后台登录页 + 破解凭据取证)是显式
// 开启的功能,命中即应封禁,不应被 enable_auto_ban 连坐。
//
// 为什么必须独立:蜜罐命中的是「有人在真的往假登录表单里填账号密码」,
// 证据强度远高于「访问了一个可疑路径」。若两者共用同一个 enabled 开关,
// 运维关掉 enable_auto_ban(因为它对共享出口误伤多)就会连带让蜜罐变成
// 只记录不封禁的旁观者 —— 蜜罐的全部价值(威慑 + 阻断)就消失了。
//
// 开启后蜜罐命中仍走同一套计数/阈值/告警机制,只是绕开 enabled 早退。
HoneypotEnabled bool
// HoneypotThreshold 蜜罐独立阈值:窗口内蜜罐命中次数达到即封禁。
// 默认 1(一次即封)—— 蜜罐路径与业务路由零交集,正常访客不可能命中,
// 一次命中就足以判定为扫描器,无需等 autoban 的 10 次阈值。
// 留空或 <= 0 时兜底为 1。
HoneypotThreshold int
// HoneypotDuration 蜜罐独立封禁时长,<= 0 时复用 Duration。
HoneypotDuration time.Duration
}
// sensitivePaths 常见后台/配置/漏洞探测路径特征(忽略大小写,完整段前缀匹配)。
// 注意:前缀匹配抓不到 //xxx/wp-includes/wlwmanifest.xml 这类"目录枚举变体"(敏感段
// 出现在路径中部),需要与 sensitiveFragments(任意位置子串)配合使用。
var sensitivePaths = []string{
"/admin", "/wp-admin", "/wp-login.php", "/wp-content", "/wordpress",
"/administrator", "/manager", "/cpanel", "/plesk",
"/.env", "/.git", "/.aws", "/.ssh", "/.svn", "/.hg", "/.htaccess",
"/config", "/configuration", "/backup", "/database", "/db",
"/actuator", "/swagger", "/api-docs", "/openapi", "/v2/api-docs",
"/phpmyadmin", "/pma", "/mysql", "/server-status", "/server-info",
"/shell", "/webshell", "/cmd", "/eval", "/hack", "/shell.php",
"/login.php", "/admin.php", "/index.php", "/console", "/vendor",
}
// sensitiveFragments 任意位置命中即算敏感的"强指纹"(忽略大小写,子串匹配)。
// 与 sensitivePaths 互补:捕获 /<枚举前缀>/wp-includes/wlwmanifest.xml、//wp/xmlrpc.php
// 等前缀匹配失效的变体。仅收录业务 URL 空间(/、/_/、/user、/api/v1、/vote、/explore、
// /i18n、/static…)绝不可能出现的 WordPress 特征,确保不误伤正常路径。
var sensitiveFragments = []string{
"/wp-includes/", "/wp-json/", "/wp-cron.php", "/wp-trackback.php",
"/wp-admin", "/wp-content", "/wp-login.php", "/wlwmanifest.xml",
"/xmlrpc.php", "/wordpress",
}
type probeEvent struct {
at time.Time
kind int
}
type probeStat struct {
events []probeEvent
sensitive int
notFound int
paths []string // 命中路径样本(告警用)
}
// Banner 自动封禁器。方法并发安全。
type Banner struct {
mu sync.Mutex
probes map[string]*probeStat
banned map[string]time.Time
alerted map[string]bool
thresholdSensitive int
threshold404 int
trackNotFound bool
window time.Duration
duration time.Duration
alertEmail string
sendAlert func(ip string, paths []string, durationMinutes int) error
enabled bool
// 蜜罐独立通道(见 Options.HoneypotEnabled 的说明)。
honeypotEnabled bool
honeypotThreshold int
honeypotDuration time.Duration
}
// New 按配置创建封禁器并注册全局清理。
func New(opts Options) *Banner {
window := opts.Window
if window <= 0 {
window = DefaultWindowSeconds * time.Second
}
duration := opts.Duration
if duration <= 0 {
duration = DefaultDurationMinutes * time.Minute
}
sensitive := opts.ThresholdSensitive
if sensitive <= 0 {
sensitive = DefaultThresholdSensitive
}
notFound := opts.Threshold404
if notFound <= 0 {
notFound = DefaultThreshold404
}
honeypotThreshold := opts.HoneypotThreshold
if honeypotThreshold <= 0 {
honeypotThreshold = 1
}
honeypotDuration := opts.HoneypotDuration
if honeypotDuration <= 0 {
honeypotDuration = duration
}
b := &Banner{
probes: make(map[string]*probeStat),
banned: make(map[string]time.Time),
alerted: make(map[string]bool),
thresholdSensitive: sensitive,
threshold404: notFound,
trackNotFound: opts.TrackNotFound,
window: window,
duration: duration,
alertEmail: strings.TrimSpace(opts.AlertEmail),
sendAlert: opts.SendAlert,
enabled: opts.Enabled,
honeypotEnabled: opts.HoneypotEnabled,
honeypotThreshold: honeypotThreshold,
honeypotDuration: honeypotDuration,
}
register(b)
return b
}
// RecordHoneypot 记录一次蜜罐命中,命中即封禁。
//
// 与 Record 的区别:
// - 不受 enabled(enable_auto_ban)约束 —— 蜜罐是显式开启的功能,
// 开启即意味着要对命中者封禁(详见 Options.HoneypotEnabled);
// - 走独立的阈值(默认 1 次即封)与时长(默认复用常规时长);
// - 键与 Record 完全一致(同样经 shared.LimitKey 归一化),
// 否则 IsBanned 判不出来,封禁形同虚设。
//
// 告警仍走既有的 sendAlert 通道(每 IP 一次),与蜜罐自己的告警邮件互补:
// 前者说明「已封禁」,后者说明「捕获到了什么凭据」。
func (b *Banner) RecordHoneypot(ip, path string) bool {
if !b.honeypotEnabled || ip == "" {
return false
}
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
cutoff := now.Add(-b.window)
st := b.probes[ip]
if st == nil {
st = &probeStat{}
b.probes[ip] = st
}
// 滚动清理过期事件(只统计窗口内的蜜罐命中)
valid := st.events[:0]
hits := 0
for _, e := range st.events {
if e.at.After(cutoff) {
valid = append(valid, e)
hits++
}
}
valid = append(valid, probeEvent{at: now, kind: KindSensitive})
hits++
st.events = valid
st.sensitive = hits
if len(st.paths) < pathSampleMax && path != "" {
st.paths = append(st.paths, path)
}
if hits < b.honeypotThreshold {
return false
}
// 已在封禁中则跳过
if until, ok := b.banned[ip]; ok && until.After(now) {
return true
}
b.banned[ip] = now.Add(b.honeypotDuration)
// 告警(每 IP 仅一次)
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
b.alerted[ip] = true
sample := append([]string{}, st.paths...)
dur := int(b.honeypotDuration.Minutes())
go func() {
_ = b.sendAlert(ip, sample, dur)
}()
}
return true
}
// Record 记录一次探测事件,达到阈值时封禁 IP 并触发一次告警。
func (b *Banner) Record(ip, path string, kind int) {
if !b.enabled || ip == "" || kind == KindNone {
return
}
// 404 计数默认关闭(正常业务路径一律放行,仅敏感扫描特征参与封禁):
// 只有显式开启 TrackNotFound 时才把未注册路径的 404 计入统计。
if kind == KindNotFound && !b.trackNotFound {
return
}
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
cutoff := now.Add(-b.window)
st := b.probes[ip]
if st == nil {
st = &probeStat{}
b.probes[ip] = st
}
// 滚动清理过期事件并重算计数
valid := st.events[:0]
sensitive, notFound := 0, 0
for _, e := range st.events {
if e.at.After(cutoff) {
valid = append(valid, e)
if e.kind == KindSensitive {
sensitive++
} else if e.kind == KindNotFound {
notFound++
}
}
}
valid = append(valid, probeEvent{at: now, kind: kind})
if kind == KindSensitive {
sensitive++
} else {
notFound++
}
st.events = valid
st.sensitive = sensitive
st.notFound = notFound
if len(st.paths) < pathSampleMax && path != "" {
st.paths = append(st.paths, path)
}
if sensitive < b.thresholdSensitive && notFound < b.threshold404 {
return
}
// 已达阈值:封禁(已在封禁中则跳过)
if until, ok := b.banned[ip]; ok && until.After(now) {
return
}
until := now.Add(b.duration)
b.banned[ip] = until
// 告警(每 IP 仅一次)
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
b.alerted[ip] = true
sample := append([]string{}, st.paths...)
dur := int(b.duration.Minutes())
go func() {
_ = b.sendAlert(ip, sample, dur)
}()
}
}
// IsBanned 判断 IP 是否处于封禁期;到期自动解除。
//
// 注意:即使 enabled(enable_auto_ban)关闭,蜜罐通道写入的封禁依然生效 ——
// 否则「关了自动封禁就等于连蜜罐的封禁一起关了」,与 Options.HoneypotEnabled
// 的设计意图相悖。enabled 只控制「是否主动创建封禁」,不控制「是否尊重已有封禁」。
func (b *Banner) IsBanned(ip string) bool {
if ip == "" {
return false
}
b.mu.Lock()
defer b.mu.Unlock()
if until, ok := b.banned[ip]; ok {
if until.After(time.Now()) {
return true
}
delete(b.banned, ip)
}
return false
}
// IsSensitivePath 判断路径是否命中敏感探测特征(忽略大小写)。
// 命中条件:① 与 sensitivePaths 完整段前缀匹配(/admin、/.env 等);
// ② 包含 sensitiveFragments 强指纹子串(wp-includes/xmlrpc.php 等 WordPress 探测面,
//
// 覆盖 // 双斜杠与目录枚举变体)。
func IsSensitivePath(p string) bool {
lower := strings.ToLower(p)
for _, s := range sensitivePaths {
if lower == s || strings.HasPrefix(lower, s+"/") {
return true
}
}
for _, f := range sensitiveFragments {
if strings.Contains(lower, f) {
return true
}
}
return false
}
// ---------- 全局注册与清理 ----------
var (
bannersMu sync.Mutex
banners = make(map[*Banner]struct{})
bannersOnce sync.Once
)
func register(b *Banner) {
bannersMu.Lock()
banners[b] = struct{}{}
bannersMu.Unlock()
bannersOnce.Do(func() {
go func() {
for {
time.Sleep(2 * time.Minute)
bannersMu.Lock()
for b := range banners {
b.cleanup()
}
bannersMu.Unlock()
}
}()
})
}
// cleanup 定期清理过期探测记录与已到期封禁(防止内存膨胀)。
func (b *Banner) cleanup() {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
cutoff := now.Add(-b.window)
for ip, st := range b.probes {
if len(st.events) == 0 || st.events[len(st.events)-1].at.Before(cutoff) {
delete(b.probes, ip)
continue
}
valid := st.events[:0]
sensitive, notFound := 0, 0
for _, e := range st.events {
if e.at.After(cutoff) {
valid = append(valid, e)
if e.kind == KindSensitive {
sensitive++
} else if e.kind == KindNotFound {
notFound++
}
}
}
st.events = valid
st.sensitive = sensitive
st.notFound = notFound
}
for ip, until := range b.banned {
if !until.After(now) {
delete(b.banned, ip)
}
}
}