一、封禁链路整条没接上(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。
166 lines
5.8 KiB
Go
166 lines
5.8 KiB
Go
// 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("蜜罐记录空间回收失败(不影响数据正确性)")
|
||
}
|
||
}
|