Files
tamabox/internal/security/autoban/autoban.go
T
tamakyi 818fa74160 fix(honeypot): 取证记精确地址、封禁用 /64 键,并补建封与拦截日志
线上实测:IPv6 访问蜜罐后收到「你的 IP 已被封禁」,邮件里却是
`2408:…::/64` —— 既不像自己的地址,也说不清封了多大范围;同时用户自己
仍能正常访问,看日志也无从判断到底有没有封。

根因是「取证地址」与「封禁键」混用同一个变量(都取 LimitKey 的折叠值):
- 取证丢掉精确 IPv6 后 64 位,事后无法定位到具体主机、没法和其他日志对账;
- 邮件只给聚合键,运维第一反应是「不是我」,排查方向直接被带偏。

改动:
- Serve 拆 clientIP(ResolveClientIP,精确)与 banKey(LimitKey,/64):
  DB / 凭据文件 / 告警邮件用前者,封禁 / 探测采样 / 告警去重用后者;
- autoban.Options 新增 OnBan 回调(保持纯标准库、不引入 logrus),
  route.newBanner 注入后每次建封打 WARN:ban_key / reason / duration / expires_at;
  中间件 403 分支加 Debug 日志(被封者会持续请求,Info 会被刷屏);
- 告警邮件新增「封禁范围」行,仅 ip != ban_key 时渲染(IPv4 不重复显示),
  顺带把过时的「已入库」改为「写入取证文件」——凭据早就外置了;
- honeypot stats 的来源排行改按 LimitKey 合并,否则 IPv6 轮换地址会把
  排行碎成几十条各 1 次的记录;合并口径与封禁完全一致。

测试(每项都做了变异验证:改坏→确认变红→恢复):
- TestHoneypotRecordsExactIPv6ButBansByPrefix:精确地址进 DB 与凭据文件、
  /64 才是封禁键、同 /64 换地址仍 403;
- TestOnBanCallback:建封回调带键/时长/原因,封禁期内重复命中不重复回调;
- TestHoneypotAlertTemplateRenders:IPv6 渲染封禁范围、IPv4 不重复;
- TestMergeSourcesByKey:同 /64 合并、IPv4 原样、limit 与空输入。
2026-10-23 10:53:03 +08:00

438 lines
14 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 —— 往假后台里填账号密码是证据最强的行为,一次就足够;
// 纯路径探测不要走这个通道(否则对方 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]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
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]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,
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.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
b.notifyBan(ip, b.duration, ReasonProbe)
// 告警(每 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)
}()
}
}
// 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)
}
}
}