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:
+144
-23
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
)
|
||||
|
||||
// Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。
|
||||
@@ -53,7 +54,7 @@ var Honeypot = &cli.Command{
|
||||
},
|
||||
{
|
||||
Name: "prune",
|
||||
Usage: "Delete honeypot records older than the retention period",
|
||||
Usage: "Delete honeypot records (DB metadata + credential files) older than the retention period",
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{Name: "days", Value: 0, Usage: "Days to keep (0 = use honeypot_retain_days from config)"},
|
||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||
@@ -65,6 +66,19 @@ var Honeypot = &cli.Command{
|
||||
Usage: "Report honeypot table row count and disk usage",
|
||||
Action: runHoneypotSize,
|
||||
},
|
||||
{
|
||||
Name: "paths",
|
||||
Usage: "List active decoy paths (built-in + honeypot_paths)",
|
||||
Action: runHoneypotPaths,
|
||||
},
|
||||
{
|
||||
Name: "purge-creds",
|
||||
Usage: "Drop legacy credential columns (username/password/fields/raw_body) from honeypot_hits — irreversible",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||
},
|
||||
Action: runHoneypotPurgeCreds,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -96,28 +110,40 @@ func parseSince(s string) (time.Duration, error) {
|
||||
return time.Duration(n) * mult, nil
|
||||
}
|
||||
|
||||
// credStoreFromConfig 用已加载的配置打开凭据取证目录。
|
||||
//
|
||||
// 凭据(用户名/密码)只在文件里,不查数据库 —— 所以任何需要展示凭据的
|
||||
// 命令都走这里,而不是 db.HoneypotStore。
|
||||
func credStoreFromConfig() (honeypot.CredStore, error) {
|
||||
return honeypot.NewFileCredStore(conf.Security.HoneypotCredDir)
|
||||
}
|
||||
|
||||
func runHoneypotHits(ctx *cli.Context) error {
|
||||
dur, err := parseSince(ctx.String("since"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
// 凭据只在取证文件里,本命令不再连数据库 —— DB 挂了也能查。
|
||||
// (元数据在库里不含凭据内容,见 db.HoneypotHit.HadFields 的注释。)
|
||||
if err := conf.Init(); err != nil {
|
||||
return errors.Wrap(err, "load configuration")
|
||||
}
|
||||
store, err := credStoreFromConfig()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "open honeypot credential store")
|
||||
}
|
||||
store := db.NewHoneypotStore(database)
|
||||
since := time.Now().Add(-dur)
|
||||
filterIP := strings.TrimSpace(ctx.String("ip"))
|
||||
showPwd := ctx.Bool("show-password")
|
||||
|
||||
// 按 IP 过滤在 SQL 层做:取回最近 N 条再在内存里筛,会导致「指定 IP 的
|
||||
// 历史命中基本查不出来」(它的记录被其他 IP 的更近记录挤出了窗口)。
|
||||
rows, err := store.ListSinceByIP(context.Background(), since, filterIP, ctx.Int("limit"))
|
||||
// IP 过滤在读取阶段做(列表本来就是流式读,先取最近 N 条再筛会让
|
||||
// 指定 IP 的历史记录被其他 IP 挤出去)。
|
||||
rows, err := store.ListSince(since, filterIP, ctx.Int("limit"))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query honeypot hits")
|
||||
return errors.Wrap(err, "read honeypot credentials")
|
||||
}
|
||||
|
||||
fmt.Printf("蜜罐命中记录(近 %s,共 %d 条)\n", dur, len(rows))
|
||||
fmt.Printf("蜜罐凭据记录(近 %s,共 %d 条,来源 %s)\n", dur, len(rows), store.Dir())
|
||||
fmt.Println(strings.Repeat("-", 72))
|
||||
if len(rows) == 0 {
|
||||
fmt.Println(" 无")
|
||||
@@ -134,11 +160,8 @@ func runHoneypotHits(ctx *cli.Context) error {
|
||||
if user == "" {
|
||||
user = "(空)"
|
||||
}
|
||||
fmt.Printf("%s %-8s %-6s %s\n", r.CreatedAt.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48))
|
||||
fmt.Printf("%s %-8s %-6s %s\n", r.Time.Format("2006-01-02 15:04:05"), r.IP, r.Method, truncateForCLI(r.Path, 48))
|
||||
fmt.Printf(" 用户名:%s 密码:%s\n", truncateForCLI(user, 64), truncateForCLI(pwd, 64))
|
||||
if r.Provider != "" {
|
||||
fmt.Printf(" 归属:%s%s\n", r.Provider, proxiedSuffix(r.Proxied))
|
||||
}
|
||||
if r.UserAgent != "" {
|
||||
fmt.Printf(" UA:%s\n", truncateForCLI(r.UserAgent, 96))
|
||||
}
|
||||
@@ -178,7 +201,11 @@ func runHoneypotStats(ctx *cli.Context) error {
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query top paths")
|
||||
}
|
||||
creds, err := store.TopCredentials(cctx, since, ctx.Int("limit"))
|
||||
credStore, err := credStoreFromConfig()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "open honeypot credential store")
|
||||
}
|
||||
creds, err := credStore.TopCredentials(since, ctx.Int("limit"))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query top credentials")
|
||||
}
|
||||
@@ -204,7 +231,7 @@ func runHoneypotStats(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("最常被尝试的凭据组合:")
|
||||
fmt.Println("最常被尝试的凭据组合(来自取证文件,非数据库):")
|
||||
if len(creds) == 0 {
|
||||
fmt.Println(" 无")
|
||||
}
|
||||
@@ -264,6 +291,16 @@ func runHoneypotPrune(ctx *cli.Context) error {
|
||||
fmt.Printf("提示:空间回收失败(%v),数据本身已正确删除;PostgreSQL 下可手动执行 VACUUM honeypot_hits。\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 凭据文件同样会过期,一并清理:明文账号密码不该比元数据留得更久。
|
||||
// 这也让「prune」在运维心里只有一个语义 —— 按保留期清干净,不用记两套。
|
||||
if cs, err := credStoreFromConfig(); err != nil {
|
||||
fmt.Printf("提示:无法打开凭据目录,凭据文件未清理(%v)。\n", err)
|
||||
} else if m, err := cs.PurgeBefore(before); err != nil {
|
||||
fmt.Printf("提示:凭据文件清理失败(%v)。\n", err)
|
||||
} else if m > 0 {
|
||||
fmt.Printf("已删除 %d 个过期凭据文件(%s)。\n", m, cs.Dir())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -314,6 +351,97 @@ func runHoneypotSize(ctx *cli.Context) error {
|
||||
} else {
|
||||
fmt.Printf("保留期:未设置 —— 记录只增不减,需要手动执行 TamaBox honeypot prune\n")
|
||||
}
|
||||
|
||||
// 凭据与元数据是两套存储,体积必须分开看:
|
||||
// 数据库里只有元数据,明文账号密码在文件里。
|
||||
fmt.Println()
|
||||
if cs, err := credStoreFromConfig(); err == nil {
|
||||
if n, size, err := cs.Size(); err == nil {
|
||||
fmt.Printf("凭据取证文件:%d 个,%s\n", n, humanBytes(size))
|
||||
fmt.Printf(" 目录:%s\n", cs.Dir())
|
||||
fmt.Println(" 说明:用户名/密码只存在这里,不入库;按天一个文件,随保留期一并清理。")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHoneypotPaths 列出当前生效的诱饵路径。
|
||||
//
|
||||
// 存在的意义:honeypot_paths 是自由文本配置,写错了(拼错、写成正则、漏斜杠)
|
||||
// 从任何输出里都看不出来 —— 这个命令让「我配的到底生效没」变成一条可查的事实。
|
||||
func runHoneypotPaths(ctx *cli.Context) error {
|
||||
// 本命令不查库(DB 挂掉时也能查配置),因此不能走 openDatabase 那条
|
||||
// 顺带 conf.Init 的路径 —— 必须显式加载,否则读到的全是零值,
|
||||
// 会显示「蜜罐关闭」并把 honeypot_paths 吞掉。
|
||||
if err := conf.Init(); err != nil {
|
||||
return errors.Wrap(err, "load configuration")
|
||||
}
|
||||
|
||||
extra := honeypot.SplitPaths(conf.Security.HoneypotPaths)
|
||||
builtin := honeypot.New(honeypot.Options{Enabled: true}).Prefixes()
|
||||
active := honeypot.New(honeypot.Options{Enabled: true, ExtraPaths: extra}).Prefixes()
|
||||
|
||||
fmt.Printf("蜜罐开关:%s\n", onOff(conf.Security.EnableHoneypot))
|
||||
if !conf.Security.EnableHoneypot {
|
||||
fmt.Println("提示:enable_honeypot = false,以下路径当前不会接管任何请求。")
|
||||
}
|
||||
fmt.Println(strings.Repeat("-", 72))
|
||||
fmt.Printf("生效诱饵路径:%d 条(内置 %d + 自定义 %d,重名已合并)\n",
|
||||
len(active), len(builtin), len(extra))
|
||||
for _, p := range active {
|
||||
fmt.Println(" " + p)
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println("自定义路径(honeypot_paths):")
|
||||
for _, p := range extra {
|
||||
fmt.Println(" " + p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runHoneypotPurgeCreds 删除旧版本留在 honeypot_hits 上的明文凭据列。
|
||||
//
|
||||
// 为什么需要:结构体早已去掉这些字段,但 GORM 的 AutoMigrate **不删既有列**,
|
||||
// 老库升级上来后历史明文仍在,还会跟着进备份与主从复制。
|
||||
// DROP COLUMN 不可逆,所以做成显式命令 + 二次确认,不放启动流程里自动执行
|
||||
// (启动时只会 WARN 提示)。
|
||||
func runHoneypotPurgeCreds(ctx *cli.Context) error {
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store := db.NewHoneypotStore(database)
|
||||
|
||||
cols, err := store.LegacyCredentialColumns(context.Background())
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "check legacy credential columns")
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
fmt.Println("honeypot_hits 上没有遗留的凭据列,无需清理。")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("honeypot_hits 上仍存在以下旧版明文凭据列(整列删除,不可逆):")
|
||||
for _, c := range cols {
|
||||
fmt.Println(" " + c)
|
||||
}
|
||||
fmt.Println("注意:本命令只处理数据库;磁盘上的历史备份需要你自行处理。")
|
||||
if !ctx.Bool("yes") {
|
||||
fmt.Print("确认继续?输入 yes 继续:")
|
||||
var answer string
|
||||
if _, err := fmt.Scanln(&answer); err != nil || strings.TrimSpace(answer) != "yes" {
|
||||
fmt.Println("已取消。")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
dropped, err := store.PurgeLegacyCredentialColumns(context.Background())
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "凭据列删除中途失败,已删除 %v", dropped)
|
||||
}
|
||||
fmt.Printf("已删除 %d 列:%s\n", len(dropped), strings.Join(dropped, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -367,10 +495,3 @@ func maskSecret(s string) string {
|
||||
return string(r[0]) + strings.Repeat("*", 6) + string(r[len(r)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func proxiedSuffix(proxied bool) string {
|
||||
if proxied {
|
||||
return "(CDN/代理出口)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
+12
-1
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/route"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
"github.com/tamakyi/TamaBox/internal/tracing"
|
||||
)
|
||||
@@ -75,9 +76,19 @@ func runWeb(ctx *cli.Context) error {
|
||||
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
|
||||
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
|
||||
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
|
||||
//
|
||||
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
|
||||
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
|
||||
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
|
||||
stopRetention := make(chan struct{})
|
||||
if conf.Security.HoneypotAutoClean {
|
||||
db.NewHoneypotRetention(db.Honeypots, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
|
||||
var credPurge db.CredPurgeable
|
||||
if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil {
|
||||
logrus.WithError(err).Warn("蜜罐凭据目录不可用,凭据文件将不会被自动清理")
|
||||
} else {
|
||||
credPurge = cs
|
||||
}
|
||||
db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
|
||||
defer close(stopRetention)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user