Files
tamabox/internal/security/autoban/autoban.go
T
tamakyi 036156b6fb 蜜罐告警: 修复去重口径与空收件人短路
审阅蜜罐邮件提醒功能发现的问题,三项一并修:

1. autoban.alerted 从 map[string]bool(永不过期)改为带时间戳的
   map[string]time.Time,冷却期 24h 与蜜罐侧对齐。cleanup 里清理过期项,
   长期盯防同一来源不再漏掉后续攻击活动(此前一生只报一次)。
2. 蜜罐告警在 security_alert_email 为空时短路:route.go 注入 sendAlert
   前判空,收件人缺失置 nil,避免每次凭据提交白做 SMTP 拨号 + 刷 Warn。
3. 强化 security_alert_email 的配置警示(蜜罐邮件含明文密码,
   必须是个人邮箱、禁群组/分发列表)。
2026-10-30 17:17:25 +08:00

460 lines
15 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
// alertCooldown 同一来源的告警冷却时间:冷却期内重复命中不再发第二封。
// 与蜜罐侧(route.honeypotHandler 的 alertDedupTTL = 24h)口径一致——
// 早先是 map[string]bool「一生只发一次」,长期盯防同一来源会漏掉
// 后续攻击活动(换路径再来、隔几个月再来都收不到提醒)。
alertCooldown = 24 * time.Hour
)
// 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 —— 往假后台里填账号密码是证据最强的行为,一次就足够;
// 纯路径探测不要走这个通道(否则对方 GET 一次就被封,走不到提交凭据那步)。
// 留空或 <= 0 时兜底为 1。
HoneypotThreshold int
// HoneypotDuration 蜜罐独立封禁时长,<= 0 时复用 Duration。
HoneypotDuration time.Duration
// OnBan 在「新建一条封禁」时回调,供上层打日志 / 埋点。
//
// 存在的意义:封禁表是纯内存的,从外部完全看不见 —— 线上出现过
// 「邮件说封了、实际没挡住」,而当时没有任何日志能说明封的是哪个键、
// 到什么时候解除,排查只能靠猜。有了回调,每一次建封都会留下痕迹。
//
// ⚠️ 只在真正新建封禁时触发:已在封禁期内再次命中不会重复回调
// (与告警去重口径一致)。回调在独立 goroutine 中执行,不要假设顺序,
// 也不要在回调里调用本 Banner 的方法。
OnBan func(key string, duration time.Duration, reason string)
}
// 封禁原因(传给 Options.OnBan)。
const (
// ReasonProbe 常规通道:敏感路径 / 404 探测累计到阈值。
ReasonProbe = "probe"
// ReasonHoneypot 蜜罐通道:往假登录页提交了凭据。
ReasonHoneypot = "honeypot"
)
// 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]time.Time
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
onBan func(key string, duration time.Duration, reason string)
}
// 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]time.Time),
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,
onBan: opts.OnBan,
}
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)
b.notifyBan(ip, b.honeypotDuration, ReasonHoneypot)
// 告警(每 IP 冷却期内仅一次)
if b.alertEmail != "" && b.sendAlert != nil && !b.alertedRecently(ip, now) {
b.alerted[ip] = now
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
b.notifyBan(ip, b.duration, ReasonProbe)
// 告警(每 IP 冷却期内仅一次)
if b.alertEmail != "" && b.sendAlert != nil && !b.alertedRecently(ip, now) {
b.alerted[ip] = now
sample := append([]string{}, st.paths...)
dur := int(b.duration.Minutes())
go func() {
_ = b.sendAlert(ip, sample, dur)
}()
}
}
// alertedRecently 报告该 IP 是否在告警冷却期内(曾发过告警且未过期)。
//
// 调用点都在持锁区间内。冷却期用 alertCooldown(24h),与蜜罐侧口径一致:
// 告警去重是「压制同一来源的重复邮件」,不是「一生只报一次」——长期攻击
// 换路径再来、隔几天再来,都应当能再次提醒。
func (b *Banner) alertedRecently(ip string, now time.Time) bool {
if until, ok := b.alerted[ip]; ok && until.Add(alertCooldown).After(now) {
return true
}
return false
}
// notifyBan 触发 OnBan 回调。
//
// 调用点都在持锁区间内,因此这里只做「取函数 + 起 goroutine」:
// 回调本身可能在写日志、发邮件,放到锁外执行才不会拖慢被判定的请求。
func (b *Banner) notifyBan(key string, duration time.Duration, reason string) {
fn := b.onBan
if fn == nil {
return
}
go fn(key, duration, reason)
}
// 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)
}
}
for ip, at := range b.alerted {
if !at.Add(alertCooldown).After(now) {
delete(b.alerted, ip)
}
}
}