Files
tamabox/internal/conf/honeypot_defaults.go
T
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

113 lines
5.3 KiB
Go
Raw 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 conf
import (
"strings"
"github.com/sirupsen/logrus"
)
// 蜜罐相关配置的默认值。
//
// 为什么要显式兜底:ini 的 MapTo 在「键缺失」或「值非法」时**不覆盖**目标字段,
// 所以结构体的零值就是「未配置」的表现。而零值对这几个开关恰好是最危险的那个:
//
// - HoneypotAutoClean 零值 false → 未配置的站点自动清理是关的,
// honeypot_hits 只增不减;
// - HoneypotRetainDays 零值 0 → 保留期 0 天 = 永不清理;
// - HoneypotMaxRows 零值 0 → 无行数上限。
//
// 现场就踩过这个坑:配置写成了 honeypot_retention_days(多一个 t),
// 键名不匹配被 ini 静默忽略,保留期成了 0,自动清理形同关闭。
const (
// DefaultHoneypotRetainDays 蜜罐记录默认保留天数。
DefaultHoneypotRetainDays = 30
// DefaultHoneypotMaxRows 蜜罐记录默认行数上限。
// 实测单行约 281 字节(含索引,常规 UA),20 万行约 53 MB。
DefaultHoneypotMaxRows = 200000
// DefaultHoneypotThreshold 「提交凭据」多少次即封禁。默认 1:
// 往假后台里填账号密码是证据最强的行为,抓到一次就足够。
// 纯路径探测不走这个阈值(见 route.honeypotHandler.Serve)。
DefaultHoneypotThreshold = 1
// DefaultHoneypotBanMinutes 蜜罐封禁的默认时长(分钟)。
//
// 刻意远长于常规 ban_duration_minutes(15 分钟):蜜罐命中的不是「访问了一个
// 可疑路径」,而是「往假后台里填了账号密码」—— 对方已经付出破解成本、
// 且拿到了一次响应(知道「后台存在」)。15 分钟不足以让对方放弃,
// 24 小时能覆盖一轮扫描活动周期。
DefaultHoneypotBanMinutes = 1440
// DefaultHoneypotCredDir 凭据取证文件的默认目录(相对进程工作目录)。
//
// 不放在数据库所在目录:这两者的权限、备份策略、留存周期都应该分开管。
DefaultHoneypotCredDir = "data/honeypot-creds"
)
// sanitizeHoneypot 归一化蜜罐相关配置,修正「未配置」与「显式关闭」的歧义。
//
// 语义约定(两个开关各管一件事,不再互相牵连):
//
// honeypot_auto_clean 是否启用后台自动清理。缺省 true(开)。
// honeypot_retain_days 保留多少天。缺省 30 天。
//
// 也就是说:**要关闭自动清理请写 honeypot_auto_clean = false**,
// 而不要靠把 retain_days 写成 0 —— 保留 0 天意味着「记录一进来就删」,
// 既不是任何人的真实意图,也会让「自动清理到底开没开」变得无法判断。
//
// 显式写 honeypot_max_rows = 0 仍表示「不设行数上限」(这是明确的逃生口),
// 但会告警提醒:没有时间闸也没有数量闸的表只增不减。
func sanitizeHoneypot() {
sec := File.Section("security")
hasRetain := sec.HasKey("honeypot_retain_days")
hasMaxRows := sec.HasKey("honeypot_max_rows")
// 保留期:未配置或显式 <= 0 → 回退默认。
// 显式 0 的语义是「立刻删除」,与「不自动清理」混淆,判为误配。
if !hasRetain {
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
} else if Security.HoneypotRetainDays <= 0 {
logrus.WithField("default", DefaultHoneypotRetainDays).
Warn("honeypot_retain_days <= 0 已被忽略,改用默认值(如需关闭自动清理请设 honeypot_auto_clean = false)")
Security.HoneypotRetainDays = DefaultHoneypotRetainDays
}
// 行数上限:未配置 → 默认;显式 0 → 不设上限(逃生口,但告警)。
if !hasMaxRows {
Security.HoneypotMaxRows = DefaultHoneypotMaxRows
} else if Security.HoneypotMaxRows <= 0 {
logrus.Warn("honeypot_max_rows = 0 表示不设行数上限:蜜罐表将只增不减,请确认这是有意为之")
}
// 自动清理:缺省开启。
// 判据用 HasKey 而不是值 —— 因为 ini 对 bool 的解析是「有键才赋值」,
// 缺键时零值 false 会让「未配置」与「显式关闭」无法区分。
// 显式 false 是合法的关闭意图,予以尊重(与 retain_days 的处理不同)。
if !sec.HasKey("honeypot_auto_clean") {
Security.HoneypotAutoClean = true
}
// 封禁阈值:缺省 1。
if !sec.HasKey("honeypot_threshold") {
Security.HoneypotThreshold = DefaultHoneypotThreshold
}
// 封禁时长:缺省 24 小时(见 DefaultHoneypotBanMinutes 的理由)。
// 显式写 <= 0 是逃生口:交给 autoban 回退到 ban_duration_minutes,
// 但那样「蜜罐按常规时长封」的意图不明显,故明确告警。
if !sec.HasKey("honeypot_ban_minutes") {
Security.HoneypotBanMinutes = DefaultHoneypotBanMinutes
} else if Security.HoneypotBanMinutes <= 0 {
logrus.WithField("fallback_key", "ban_duration_minutes").
Warn("honeypot_ban_minutes <= 0 已忽略,蜜罐封禁将回退为 ban_duration_minutes")
}
// 凭据文件目录:未配置或留空都用默认值。
// 这里不回退「不保存」——用户显式开启蜜罐就是想要取证,
// 目录写错会在 route 层触发「不可用 → 不保存凭据」的告警,而不是静默丢弃。
if strings.TrimSpace(Security.HoneypotCredDir) == "" {
Security.HoneypotCredDir = DefaultHoneypotCredDir
}
}