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:
@@ -13,7 +13,6 @@ import (
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -25,6 +24,26 @@ const CanaryToken = "tb-hp-7f3a1c9e"
|
||||
// maxCredentialFieldLen 限制单个凭据字段落库长度,避免超长 payload 撑爆表。
|
||||
const maxCredentialFieldLen = 512
|
||||
|
||||
// clipRunes 按字符(rune)截断字符串。
|
||||
//
|
||||
// 为什么不能用 s[:n]:按字节切会把多字节 UTF-8 字符劈成半个序列,得到非法
|
||||
// UTF-8。凭据是直接落库的,PostgreSQL 会以 `invalid byte sequence for encoding
|
||||
// "UTF8"` 拒绝整条插入 —— 取证记录就这么丢了,而且只在日志里留一行 warn。
|
||||
//
|
||||
// 攻击者可以随便发一个 600 字节的中文密码来稳定触发这个问题,所以这里是
|
||||
// 可达路径,不是理论风险。
|
||||
func clipRunes(s string, n int) string {
|
||||
// 字节数已不超过 n,字符数必然也不超过,无需转换。
|
||||
if n <= 0 || len(s) <= n {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// honeypotPrefixes 为段前缀匹配的诱饵路径。取自 autoban.sensitivePaths 的语义,
|
||||
// 但收敛为「与业务路由不可能同名」的一组,避免误伤。
|
||||
var honeypotPrefixes = []string{
|
||||
@@ -77,6 +96,40 @@ var forbiddenPrefixes = []string{
|
||||
"/sponsor", "/change-logs", "/maintenance-mode", "/pixel",
|
||||
}
|
||||
|
||||
// SplitPaths 解析配置中的自定义诱饵路径(逗号 / 分号 / 空白 / 换行分隔)。
|
||||
//
|
||||
// 放在本包而不是 route:CLI 的 `honeypot paths` 也要用同一份解析规则,
|
||||
// 两边各写一遍迟早漂移(一处补了分号、另一处没补),而配置解析不一致
|
||||
// 会让「我配了但没生效」这类问题更难查。
|
||||
func SplitPaths(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
// 在这里就补前导斜杠:CLI 会把这个结果直接打给运维看,
|
||||
// 显示 "foo" 而实际生效 "/foo" 会让人以为是两个不同的东西
|
||||
// (New 里的 normalize 是幂等的,重复补不会出问题)。
|
||||
if !strings.HasPrefix(f, "/") {
|
||||
f = "/" + f
|
||||
}
|
||||
// 拒绝根路径:"/" 会让**所有** 404 都变成假登录页 —— 正常访客输错
|
||||
// 网址也看到 WordPress 后台,是灾难级的配置失误,几乎不可能是本意。
|
||||
if f == "/" {
|
||||
continue
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Options 是蜜罐行为配置。
|
||||
type Options struct {
|
||||
// Enabled 为false 时 Honeypot() 恒返回 nil(不接管任何路径)。
|
||||
@@ -212,11 +265,15 @@ type Credentials struct {
|
||||
}
|
||||
|
||||
// credentialKeys 是常见的用户名/密码字段名(表单 POST 与 JSON body 均适用)。
|
||||
//
|
||||
// ⚠️ 刻意不收 "id" / "name":这两个是任意 JSON 里最常见的字段,
|
||||
// 收进来会让「扫描器 POST 了一个带 {"id":1} 的无关探测」被判定为凭据提交,
|
||||
// 既污染取证数据,也会触发一封假的「捕获到破解尝试」告警邮件。
|
||||
var credentialKeys = map[string]bool{
|
||||
"log": true, "pwd": true, "username": true, "user": true, "pass": true,
|
||||
"password": true, "passwd": true, "pw": true, "email": true, "name": true,
|
||||
"password": true, "passwd": true, "pw": true, "email": true,
|
||||
"account": true, "login": true, "wp_user": true, "user_login": true,
|
||||
"wp_password": true, "uname": true, "nickname": true, "id": true, "token": true,
|
||||
"wp_password": true, "uname": true, "nickname": true, "token": true,
|
||||
}
|
||||
|
||||
// ExtractCredentials 从请求中提取破解凭据。
|
||||
@@ -240,7 +297,7 @@ func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
|
||||
return
|
||||
}
|
||||
if len(v) > limit {
|
||||
v = v[:limit]
|
||||
v = clipRunes(v, limit)
|
||||
}
|
||||
if _, exists := out.Fields[k]; !exists {
|
||||
out.Fields[k] = v
|
||||
@@ -293,7 +350,7 @@ func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
|
||||
}
|
||||
raw := string(buf)
|
||||
if len(raw) > limit {
|
||||
raw = raw[:limit]
|
||||
raw = clipRunes(raw, limit)
|
||||
}
|
||||
out.RawBody = raw
|
||||
|
||||
@@ -390,15 +447,25 @@ func parseJSONCredentials(raw string, put func(k, v string)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Describer 返回 Honeypot 的可读描述,供 CLI / 排障使用。
|
||||
func (h *Honeypot) Describer() string {
|
||||
// Prefixes 返回当前生效的诱饵路径清单(已归一化、已排序),供 CLI / 排障使用。
|
||||
//
|
||||
// 之前的 Describer 用 path.Join(keys...) 把全部前缀拼成一条路径(形如
|
||||
// "/admin/wp-admin/..."),语义是错的;而且全代码库零调用。改为返回切片,
|
||||
// 调用方自己决定怎么展示。
|
||||
func (h *Honeypot) Prefixes() []string {
|
||||
if h == nil || !h.enabled {
|
||||
return "disabled"
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(h.prefix))
|
||||
out := make([]string, 0, len(h.prefix))
|
||||
for k := range h.prefix {
|
||||
keys = append(keys, k)
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return path.Join(keys...)
|
||||
// 用简单插入排序替代 sort 包:前缀数量在几十个量级,且这样不必让
|
||||
// 排序依赖渗透进整个包的 import 列表。
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user