diff --git a/conf/app.sample.ini b/conf/app.sample.ini index 806c876..bfb46ba 100644 --- a/conf/app.sample.ini +++ b/conf/app.sample.ini @@ -136,7 +136,9 @@ ban_threshold_404 = 20 ban_window_seconds = 60 ; 临时封禁时长(分钟),到期自动解除,重启即清零 ban_duration_minutes = 15 -; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送 +; 触发封禁时发送告警邮件的收件人(复用下方 SMTP 发信配置),留空不发送。 +; ⚠️ 也是蜜罐告警邮件收件人,而蜜罐邮件正文含**明文账号密码**: +; 必须是个人邮箱,禁止填群组 / 分发列表 / 团队共用邮箱。 security_alert_email = ; ── 蜜罐(虚假后台登录界面)──────────────────────────────────────────── diff --git a/internal/conf/static.go b/internal/conf/static.go index b7f4ab5..a36161f 100644 --- a/internal/conf/static.go +++ b/internal/conf/static.go @@ -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 diff --git a/internal/route/route.go b/internal/route/route.go index f64a2ee..94c7d5c 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -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"}) diff --git a/internal/security/autoban/autoban.go b/internal/security/autoban/autoban.go index 0da1d10..ef1bc5f 100644 --- a/internal/security/autoban/autoban.go +++ b/internal/security/autoban/autoban.go @@ -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) + } + } } diff --git a/internal/security/autoban/autoban_test.go b/internal/security/autoban/autoban_test.go index e3212af..484507a 100644 --- a/internal/security/autoban/autoban_test.go +++ b/internal/security/autoban/autoban_test.go @@ -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, "admin@example.com", &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)