一、封禁链路整条没接上(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。
113 lines
5.3 KiB
Go
113 lines
5.3 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 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
|
||
}
|
||
}
|