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。
This commit is contained in:
+42
-12
@@ -73,16 +73,18 @@ func securityHeaders() flamego.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func New() *flamego.Flame {
|
||||
f := flamego.Classic()
|
||||
if conf.App.Production {
|
||||
flamego.SetEnv(flamego.EnvTypeProd)
|
||||
}
|
||||
|
||||
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
|
||||
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
|
||||
// 404 是否计入由 ban_track_notfound 控制(默认 false,仅敏感扫描路径计数)。
|
||||
ab := autoban.New(autoban.Options{
|
||||
// newBanner 按 conf.Security 装配自动封禁器。
|
||||
//
|
||||
// 抽成独立函数(而不是内联在 New 里)是有代价换来的:蜜罐的封禁通道参数
|
||||
// (HoneypotEnabled / HoneypotThreshold / HoneypotDuration)曾经在这里漏传,
|
||||
// 而 autoban.RecordHoneypot 第一行就是 `if !b.honeypotEnabled { return false }`
|
||||
// —— 结果是蜜罐命中永不封禁、落库的 banned 恒为 false、IsBanned 永不为真。
|
||||
// 更糟的是当时的测试都自建 Banner 并显式传了这些字段,所以全绿。
|
||||
//
|
||||
// 集中到一处后,banner_config_test.go 只调这个函数就能断言「配置真的传进去了」,
|
||||
// 下次再漏传会直接测试失败。
|
||||
func newBanner() *autoban.Banner {
|
||||
return autoban.New(autoban.Options{
|
||||
Enabled: conf.Security.EnableAutoBan,
|
||||
TrackNotFound: conf.Security.BanTrackNotFound,
|
||||
ThresholdSensitive: conf.Security.BanThresholdSensitive,
|
||||
@@ -96,15 +98,43 @@ func New() *flamego.Flame {
|
||||
provider, proxied := ipinfo.Lookup(ip)
|
||||
return mail.SendSecurityAlertMail(ip, provider, proxied, paths, durationMinutes)
|
||||
},
|
||||
|
||||
// 蜜罐独立通道:开关跟随 enable_honeypot,不受 enable_auto_ban 约束。
|
||||
// 蜜罐命中意味着对方已经在往假后台里提交账号密码,证据强度远高于
|
||||
// 「访问了一个可疑路径」,因此单独给阈值与时长,并默认封得比常规久。
|
||||
HoneypotEnabled: conf.Security.EnableHoneypot,
|
||||
HoneypotThreshold: conf.Security.HoneypotThreshold,
|
||||
HoneypotDuration: time.Duration(conf.Security.HoneypotBanMinutes) * time.Minute,
|
||||
})
|
||||
}
|
||||
|
||||
func New() *flamego.Flame {
|
||||
f := flamego.Classic()
|
||||
if conf.App.Production {
|
||||
flamego.SetEnv(flamego.EnvTypeProd)
|
||||
}
|
||||
|
||||
// 自动封禁实例:整个请求链路共享同一个,供中间件与 f.NotFound 复用,
|
||||
// 避免对业务性 302→/(语言切换、登出等)误判为路径扫描。
|
||||
// 装配细节见 newBanner(集中一处,便于回归测试断言配置真的传进去了)。
|
||||
ab := newBanner()
|
||||
|
||||
// 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。
|
||||
// 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。
|
||||
hp := honeypot.New(honeypot.Options{
|
||||
Enabled: conf.Security.EnableHoneypot,
|
||||
ExtraPaths: honeypotExtraPaths(conf.Security.HoneypotPaths),
|
||||
ExtraPaths: honeypot.SplitPaths(conf.Security.HoneypotPaths),
|
||||
})
|
||||
hph := newHoneypotHandler(hp, ab, db.Honeypots)
|
||||
// 凭据外置:账号密码绝不入库,落到 0600 权限的按天 JSONL 文件
|
||||
// (见 honeypot.CredRecord 的注释)。目录不可写时降级为「不保存凭据」,
|
||||
// 绝不让取证功能把站点拖挂。
|
||||
credStore, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir)
|
||||
if err != nil {
|
||||
logrus.WithError(err).WithField("dir", conf.Security.HoneypotCredDir).
|
||||
Warn("蜜罐凭据目录不可用,捕获到的凭据将不会被保存(其余功能不受影响)")
|
||||
credStore = nil
|
||||
}
|
||||
hph := newHoneypotHandler(hp, ab, db.Honeypots, credStore)
|
||||
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
|
||||
// 收件人复用 security_alert_email,不新增配置项。
|
||||
hph.sendAlert = func(a mail.HoneypotAlert) error {
|
||||
|
||||
Reference in New Issue
Block a user