蜜罐告警: 修复去重口径与空收件人短路

审阅蜜罐邮件提醒功能发现的问题,三项一并修:

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 的配置警示(蜜罐邮件含明文密码,
   必须是个人邮箱、禁群组/分发列表)。
This commit is contained in:
tamakyi
2026-10-30 17:17:25 +08:00
parent 48eea98a27
commit 036156b6fb
5 changed files with 91 additions and 11 deletions
+6
View File
@@ -140,6 +140,12 @@ var (
BanImagePath string `ini:"ban_image_path"`
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
// ⚠️ security_alert_email 也是蜜罐告警邮件的收件人,而蜜罐邮件正文
// **含明文账号密码**(这是功能核心价值:运维立刻拿到攻击者正在用的字典)。
// 因此这里必须是**个人邮箱**,禁止填群组邮箱 / 分发列表 / 团队共用邮箱——
// 否则等于把攻击者的破解字典广播给无关人员,且经 SMTP 中转后明文密码
// 会留在第三方邮件系统的日志/归档里,超出本地 0600 取证文件的控制范围。
// CSP 内容安全策略的生效模式:enforce / report / off,缺省 enforce。
//
// enforce 下发 Content-Security-Policy(真正拦截);report 只下发 Report-Only
+6 -2
View File
@@ -227,8 +227,12 @@ func New() *flamego.Flame {
hph := newHoneypotHandler(hp, ab, db.Honeypots, credStore)
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
// 收件人复用 security_alert_email,不新增配置项。
hph.sendAlert = func(a mail.HoneypotAlert) error {
return mail.SendHoneypotAlertMail(a)
// 收件人为空时把 sendAlert 置 nil —— alert() 里 `h.sendAlert == nil` 会直接短路,
// 避免「蜜罐开着但没配收件人」时每次凭据提交都白做一次 SMTP 拨号并留一堆 Warn 日志。
if strings.TrimSpace(conf.Security.SecurityAlertEmail) != "" {
hph.sendAlert = func(a mail.HoneypotAlert) error {
return mail.SendHoneypotAlertMail(a)
}
}
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
+30 -8
View File
@@ -22,6 +22,11 @@ const (
DefaultThresholdSensitive = 10
DefaultThreshold404 = 20
pathSampleMax = 6
// alertCooldown 同一来源的告警冷却时间:冷却期内重复命中不再发第二封。
// 与蜜罐侧(route.honeypotHandler 的 alertDedupTTL = 24h)口径一致——
// 早先是 map[string]bool「一生只发一次」,长期盯防同一来源会漏掉
// 后续攻击活动(换路径再来、隔几个月再来都收不到提醒)。
alertCooldown = 24 * time.Hour
)
// Options 自动封禁配置。
@@ -118,7 +123,7 @@ type Banner struct {
probes map[string]*probeStat
banned map[string]time.Time
alerted map[string]bool
alerted map[string]time.Time
thresholdSensitive int
threshold404 int
@@ -166,7 +171,7 @@ func New(opts Options) *Banner {
b := &Banner{
probes: make(map[string]*probeStat),
banned: make(map[string]time.Time),
alerted: make(map[string]bool),
alerted: make(map[string]time.Time),
thresholdSensitive: sensitive,
threshold404: notFound,
trackNotFound: opts.TrackNotFound,
@@ -238,9 +243,9 @@ func (b *Banner) RecordHoneypot(ip, path string) bool {
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
// 告警(每 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() {
@@ -310,9 +315,9 @@ func (b *Banner) Record(ip, path string, kind int) {
b.banned[ip] = until
b.notifyBan(ip, b.duration, ReasonProbe)
// 告警(每 IP 仅一次)
if b.alertEmail != "" && b.sendAlert != nil && !b.alerted[ip] {
b.alerted[ip] = true
// 告警(每 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() {
@@ -321,6 +326,18 @@ func (b *Banner) Record(ip, path string, kind int) {
}
}
// 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」:
@@ -434,4 +451,9 @@ func (b *Banner) cleanup() {
delete(b.banned, ip)
}
}
for ip, at := range b.alerted {
if !at.Add(alertCooldown).After(now) {
delete(b.alerted, ip)
}
}
}
+46
View File
@@ -101,6 +101,52 @@ func TestAlertOncePerIP(t *testing.T) {
}
}
// TestAlertCooldownExpires 回归「告警冷却期」而非「一生只发一次」:
// 冷却期(alertCooldown = 24h)过后,同一来源再次触发封禁应能再告警。
//
// 早先是 map[string]bool 永久去重,长期盯防同一来源会漏掉后续攻击活动
// (换路径再来、隔几天再来都收不到提醒)。这里直接回拨 alerted 的时间戳,
// 模拟「冷却期已过」,断言能再次发出告警。
func TestAlertCooldownExpires(t *testing.T) {
var calls int64
b := newTestBanner(1, 5, time.Minute, time.Minute, "[email protected]", &calls)
// 第一次封禁 + 告警
b.Record("8.8.8.8", "/admin", KindSensitive)
waitFor(&calls, 1, t)
// 冷却期内再次命中:不应重复告警(此时已在封禁中,先手动解除封禁以走到告警判定)
b.mu.Lock()
delete(b.banned, "8.8.8.8")
b.mu.Unlock()
b.Record("8.8.8.8", "/config", KindSensitive)
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt64(&calls); got != 1 {
t.Fatalf("冷却期内不应重复告警,got %d", got)
}
// 回拨 alerted 时间戳到冷却期之前,模拟「24 小时后攻击者再来」
b.mu.Lock()
b.alerted["8.8.8.8"] = time.Now().Add(-alertCooldown - time.Minute)
delete(b.banned, "8.8.8.8")
b.mu.Unlock()
b.Record("8.8.8.8", "/.env", KindSensitive)
waitFor(&calls, 2, t)
if got := atomic.LoadInt64(&calls); got != 2 {
t.Fatalf("冷却期过后应再次告警,got %d", got)
}
}
// waitFor 轮询等待 atomic 计数器达到 want(sendAlert 是异步 goroutine)。
func waitFor(c *int64, want int64, t *testing.T) {
t.Helper()
deadline := time.Now().Add(500 * time.Millisecond)
for atomic.LoadInt64(c) < want && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
}
func TestDisabled(t *testing.T) {
b := New(Options{Enabled: false})
b.Record("3.3.3.3", "/admin", KindSensitive)