Files
tamabox/internal/db/honeypot_retention.go
tamakyi c29e1d071f fix(honeypot): 补齐封禁装配与诱捕语义,凭据外置为文件不再入库
一、封禁链路整条没接上(P0,已实测复现)
route.New 的 autoban.New 漏传 HoneypotEnabled/Threshold/Duration,而
RecordHoneypot 首行就是 `if !b.honeypotEnabled { return false }`
→ 蜜罐命中从不封禁、落库 banned 恒 false、IsBanned 永不为真。
同一批改动又把中间件里蜜罐路径的常规计数也跳过了,净效果是蜜罐路径
从「10 次封禁」退化为零计数零封禁 —— 是 regression 不是修复。
处理:抽 newBanner() 作为唯一装配入口并补齐参数;新增 banner_config_test
四个用例(全部经由 newBanner,不再自建 Banner)。变异验证:分别把
HoneypotEnabled 置 false、HoneypotThreshold 置 0,对应用例按预期失败。

二、诱捕语义(审计时漏掉,比上面更影响功能价值)
原实现对**所有方法**计数且阈值 1 → 扫描器 GET 一次假登录页就吃 403,
永远走不到「POST 提交账号密码」那一步,蜜罐的核心价值(拿到字典)归零。
处理:提交凭据 → RecordHoneypot(不受 enable_auto_ban 约束,立即封);
纯探测 → 常规 Record(阈值 10)。新增 TestHoneypotProbeDoesNotBanImmediately,
变异验证:把 GET 也塞进 RecordHoneypot,测试按预期失败。

三、落库边界(实测取证,非推断)
- Path 原样落库而列宽 512 → PG `SQLSTATE 22001 值太长了(512)`,整条证据丢失;
  实测 600 字符失败、500 字符成功;
- 三处按字节截断 → `SQLSTATE 22021 无效的 "UTF8" 编码字节顺序`(中文密码/URL
  可稳定触发)。统一改 rune 截断并按列宽逐项对齐。

四、资源与噪音
- alerted 无界 map → 带 TTL 的 deduper(上限 2 万,过期清理 + 超限硬裁),
  同时承担告警 24h 冷却与纯探测落库采样(每 IP 每分钟一条)——
  GET 洪泛不再变成等量 INSERT;
- credentialKeys 去掉 id/name,普通 JSON 探测不再被误判为凭据提交并触发假告警;
- canary 从 meta generator 移到 HTML 注释,generator 伪装成 WordPress,
  不再同时向扫描器自曝「这是蜜罐」和「这站是 TamaBox」;
- serveLoginFailure 复用 WriteLoginPage,不再手抄响应头(已开始漂移);
- Describer 死代码 → Prefixes(),接到新命令 honeypot paths;
- honeypot_paths 解析挪到 honeypot.SplitPaths(CLI 与 route 共用),
  补前导斜杠、拒绝 "/"(会让所有 404 变假登录页)。

五、凭据外置:数据库不再存用户名/密码
明文账号密码一旦入库就会跟着进备份、主从复制与任何能连库的报表工具,
事后无法彻底清除。改为:
- 新增 honeypot.CredRecord/CredStore:按天文件 creds-YYYY-MM-DD.jsonl,
  目录 0700、文件 0600、坏行跳过、PurgeBefore 只删整天过期的文件;
- db.HoneypotHit 删掉 Username/Password/Fields/RawBody,只留 HadFields 元数据;
- HoneypotStore 删掉 TopCredentials/ListSinceByIP(CLI 改读文件后零调用);
- 新增 LegacyCredentialColumns/PurgeLegacyCredentialColumns:AutoMigrate 不删
  既有列,老库升级后历史明文仍在 → 启动 WARN + `honeypot purge-creds` 显式 DROP;
- 清理器 HoneypotRetention 增加 CredPurgeable 参数,与 DB 记录共用保留期;
  honeypot prune 也一并删过期凭据文件;
- CLI:hits 不再连库(DB 挂了也能查),stats 的凭据排行读文件;
- 配置 honeypot_cred_dir(缺省 data/honeypot-creds),.gitignore 加 /data/。

验证:go build ./... / go vet ./internal/... / go test ./...(含 PostgreSQL)全绿;
新增 credstore_test(追加/倒序/IP 过滤/坏行/Top/Purge/权限)、
TestHoneypotLegacyCredentialColumns(ADD COLUMN 模拟老库 → 检测 → DROP)、
TestHoneypotWritesCredentialsToFile;CLI 端到端手动验证 hits/--ip/--show-password。
2026-10-21 15:43:25 +08:00

166 lines
5.8 KiB
Go
Raw Permalink 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.
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package db
import (
"context"
"time"
"github.com/sirupsen/logrus"
)
// HoneypotRetention 蜜罐命中记录的保留期清理器。
//
// 为什么必须有它:蜜罐表是「只追加」的审计表,写入完全由外部扫描流量驱动
// (无法预期、无法节流、攻击者可以无限打)。若只靠人工跑 CLI prune,
// 表体积只增不减 —— 这是「数据库被扫描撑爆」的真实路径。
//
// 清理策略(每轮两道闸):
// 1. 时间闸:删除 created_at 早于保留期起点的记录;
// 2. 数量闸:若行数仍超过上限,删除最旧的记录直到回到上限内。
// 保留期是时间维度上界,但攻击者可以用海量源 IP 在保留期内把表撑爆;
// 数量闸提供第二重保险。
//
// 之后若确实删掉了行,再做一次空间回收(PostgreSQL 需要 VACUUM,
// 否则 DELETE 只标记死元组,表文件不会变小)。
//
// 设计取舍:
// - 频率固定为每小时一轮,不做成可配置:清理本身极轻(一次 DELETE + 一次
// VACUUM),而「忘记开」的后果是磁盘单调增长。可配置的频率只会让更多人
// 忘了配。
// - 保留期由配置给出(honeypot_retain_days),<= 0 视为「不自动清理」,
// 此时只记一条告警日志,不删任何数据。
// - 清理失败只记日志,绝不 panic、绝不阻断主流程:蜜罐是安全增强功能,
// 不能因为它把站点搞挂。
//
// CredPurgeable 是「凭据文件按月清理」的能力(由 honeypot.CredStore 实现)。
//
// 用本地小接口而不是直接 import honeypot 包:存储层不该反向依赖安全层,
// 也能让清理器在测试里用假实现。
type CredPurgeable interface {
// PurgeBefore 删除早于 before 的整天文件,返回删除的文件数。
PurgeBefore(before time.Time) (int, error)
}
type HoneypotRetention struct {
store HoneypotStore
creds CredPurgeable
retainDays int
maxRows int
interval time.Duration
}
// NewHoneypotRetention 构造保留期清理器。
//
// retainDays <= 0 表示不自动清理;maxRows <= 0 表示不设数量上限;
// interval <= 0 时使用默认 1 小时;creds 为 nil 表示不清理凭据文件。
func NewHoneypotRetention(store HoneypotStore, creds CredPurgeable, retainDays int, maxRows int, interval time.Duration) *HoneypotRetention {
if interval <= 0 {
interval = time.Hour
}
return &HoneypotRetention{
store: store,
creds: creds,
retainDays: retainDays,
maxRows: maxRows,
interval: interval,
}
}
// Start 启动后台清理循环,直到 stop 被关闭。
//
// 立即执行一轮(而不是等满一个周期后才开始),避免重启后旧数据一直滞留。
func (r *HoneypotRetention) Start(stop <-chan struct{}) {
if r == nil || r.store == nil {
return
}
if r.retainDays <= 0 {
// ⚠️ 键名是 honeypot_retain_days(没有 tion)。这里曾经写成
// honeypot_retention_days,把运维引到了错误的键名上 —— 现场确实踩了这个坑。
// 正常路径下 conf.sanitizeHoneypot 已保证保留期 > 0(缺省 30 天),
// 能走到这里说明配置被显式写成了 <= 0。
logrus.Warn("蜜罐自动清理未启用:honeypot_retain_days <= 0,honeypot_hits 表将只增不减(可手动执行 TamaBox honeypot prune)")
return
}
go func() {
r.runOnce()
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
r.runOnce()
}
}
}()
}
// RunOnceForTest 导出一轮清理,供端到端体积验证脚本调用。
// 仅测试/诊断用途,生产路径走 Start。
func (r *HoneypotRetention) RunOnceForTest() {
r.runOnce()
}
// runOnce 执行一轮清理。
func (r *HoneypotRetention) runOnce() {
// 保留期 <= 0 表示「不自动清理」。必须在这里再挡一次:
// retainDays=0 时 before = now,会把所有 created_at < now 的记录全删光 ——
// 等于「保留期 0 天」被误解释成「只留此刻」。
if r.retainDays <= 0 {
return
}
before := time.Now().AddDate(0, 0, -r.retainDays)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
total := int64(0)
// 闸一:时间。按保留期删掉过期记录。
n, err := r.store.DeleteBefore(ctx, before)
if err != nil {
logrus.WithError(err).Warn("蜜罐记录保留期清理失败")
return
}
total += n
// 闸二:数量。保留期内的行数仍超上限时,删掉最旧的。
if r.maxRows > 0 {
m, err := r.store.DeleteOldestBeyond(ctx, r.maxRows)
if err != nil {
logrus.WithError(err).Warn("蜜罐记录数量上限清理失败")
return
}
total += m
}
// 凭据文件按同一个保留期清理:明文账号密码不该比元数据活得更久。
// 独立于 DB 的删除量 —— 即使这批数据库记录本来就已达标,文件仍可能过期。
if r.creds != nil {
if n, err := r.creds.PurgeBefore(before); err != nil {
logrus.WithError(err).Warn("蜜罐凭据文件清理失败(不影响数据库记录)")
} else if n > 0 {
logrus.WithFields(logrus.Fields{"files": n, "days": r.retainDays}).
Info("已清理过期的蜜罐凭据文件")
}
}
if total == 0 {
return
}
logrus.WithFields(logrus.Fields{
"deleted": total,
"days": r.retainDays,
"maxRows": r.maxRows,
}).Info("蜜罐记录清理完成")
// 只有真删掉了行才回收空间:VACUUM 不是免费的,别为 0 行去扫全表。
if err := r.store.Compact(ctx); err != nil {
logrus.WithError(err).Warn("蜜罐记录空间回收失败(不影响数据正确性)")
}
}