Files
tamabox/internal/cmd/honeypot.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

498 lines
16 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 cmd
import (
"context"
"fmt"
"strings"
"time"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
)
// Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。
//
// TamaBox honeypot hits --since 24h 列出捕获到的破解凭据
// TamaBox honeypot stats --days 7 统计来源/路径/凭据排行
// TamaBox honeypot size 报告表行数与磁盘占用
// TamaBox honeypot prune --days 30 清理过期记录(保留期由配置决定)
//
// 密码默认脱敏:CLI 输出会被贴到工单、聊天窗口甚至截图里,
// 明文只在确有取证需要时用 --show-password 显式打开。
var Honeypot = &cli.Command{
Name: "honeypot",
Usage: "Inspect honeypot hits (fake admin login page credential capture)",
Subcommands: []*cli.Command{
{
Name: "hits",
Usage: "List captured credential submissions",
Flags: []cli.Flag{
&cli.StringFlag{Name: "since", Value: "24h", Usage: "Time window, e.g. 24h / 7d / 30m"},
&cli.IntFlag{Name: "limit", Value: 50, Usage: "Max rows to print"},
&cli.StringFlag{Name: "ip", Usage: "Only show hits from this source IP"},
&cli.BoolFlag{Name: "show-password", Usage: "Print passwords in plaintext (default: masked)"},
},
Action: runHoneypotHits,
},
{
Name: "stats",
Usage: "Show honeypot statistics (top sources / paths / credentials)",
Flags: []cli.Flag{
&cli.IntFlag{Name: "days", Value: 7, Usage: "Statistics window in days"},
&cli.IntFlag{Name: "limit", Value: 20, Usage: "Max rows per section"},
&cli.BoolFlag{Name: "show-password", Usage: "Print passwords in plaintext (default: masked)"},
},
Action: runHoneypotStats,
},
{
Name: "prune",
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"},
},
Action: runHoneypotPrune,
},
{
Name: "size",
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,
},
},
}
// parseSince 解析 --since 参数,支持 30m / 24h / 7d / 2w。
func parseSince(s string) (time.Duration, error) {
s = strings.TrimSpace(strings.ToLower(s))
if s == "" {
return 24 * time.Hour, nil
}
unit := s[len(s)-1]
numPart := s[:len(s)-1]
var mult time.Duration
switch unit {
case 'm':
mult = time.Minute
case 'h':
mult = time.Hour
case 'd':
mult = 24 * time.Hour
case 'w':
mult = 7 * 24 * time.Hour
default:
return 0, errors.Errorf("invalid --since value %q (expect e.g. 30m / 24h / 7d)", s)
}
var n int
if _, err := fmt.Sscanf(numPart, "%d", &n); err != nil || n <= 0 {
return 0, errors.Errorf("invalid --since value %q", s)
}
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
}
// 凭据只在取证文件里,本命令不再连数据库 —— 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")
}
since := time.Now().Add(-dur)
filterIP := strings.TrimSpace(ctx.String("ip"))
showPwd := ctx.Bool("show-password")
// IP 过滤在读取阶段做(列表本来就是流式读,先取最近 N 条再筛会让
// 指定 IP 的历史记录被其他 IP 挤出去)。
rows, err := store.ListSince(since, filterIP, ctx.Int("limit"))
if err != nil {
return errors.Wrap(err, "read honeypot credentials")
}
fmt.Printf("蜜罐凭据记录(近 %s,共 %d 条,来源 %s)\n", dur, len(rows), store.Dir())
fmt.Println(strings.Repeat("-", 72))
if len(rows) == 0 {
fmt.Println(" 无")
return nil
}
for _, r := range rows {
pwd := r.Password
if pwd == "" {
pwd = "(空)"
} else if !showPwd {
pwd = maskSecret(pwd)
}
user := r.Username
if user == "" {
user = "(空)"
}
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.UserAgent != "" {
fmt.Printf(" UA:%s\n", truncateForCLI(r.UserAgent, 96))
}
if r.Banned {
fmt.Printf(" 处置:已触发封禁\n")
}
fmt.Println()
}
if !showPwd {
fmt.Println("提示:密码默认脱敏。需要明文时加 --show-password。")
}
return nil
}
func runHoneypotStats(ctx *cli.Context) error {
days := ctx.Int("days")
if days <= 0 {
days = 7
}
database, err := openDatabase()
if err != nil {
return err
}
store := db.NewHoneypotStore(database)
cctx := context.Background()
since := time.Now().AddDate(0, 0, -days)
total, err := store.CountSince(cctx, since)
if err != nil {
return errors.Wrap(err, "count honeypot hits")
}
sources, err := store.TopSources(cctx, since, ctx.Int("limit"))
if err != nil {
return errors.Wrap(err, "query top sources")
}
paths, err := store.TopPaths(cctx, since, ctx.Int("limit"))
if err != nil {
return errors.Wrap(err, "query top paths")
}
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")
}
fmt.Printf("蜜罐统计:近 %d 天,共 %d 次命中\n", days, total)
fmt.Println(strings.Repeat("-", 72))
fmt.Println("来源 IP 排行:")
if len(sources) == 0 {
fmt.Println(" 无")
}
for i, s := range sources {
fmt.Printf(" %2d. %-40s %5d 次 %s\n", i+1, s.IP, s.Count, s.Provider)
}
fmt.Println()
fmt.Println("被探测路径排行:")
if len(paths) == 0 {
fmt.Println(" 无")
}
for i, p := range paths {
fmt.Printf(" %2d. %-50s %5d 次\n", i+1, truncateForCLI(p.Path, 50), p.Count)
}
fmt.Println()
fmt.Println("最常被尝试的凭据组合(来自取证文件,非数据库):")
if len(creds) == 0 {
fmt.Println(" 无")
}
showPwd := ctx.Bool("show-password")
for i, c := range creds {
pwd := c.Password
if pwd == "" {
pwd = "(空)"
} else if !showPwd {
pwd = maskSecret(pwd)
}
user := c.Username
if user == "" {
user = "(空)"
}
fmt.Printf(" %2d. %-30s %-30s %5d 次\n", i+1, truncateForCLI(user, 30), truncateForCLI(pwd, 30), c.Count)
}
if !showPwd {
fmt.Println()
fmt.Println("提示:密码默认脱敏。需要明文时加 --show-password。")
}
return nil
}
func runHoneypotPrune(ctx *cli.Context) error {
days := ctx.Int("days")
if days <= 0 {
days = conf.Security.HoneypotRetainDays
}
if days <= 0 {
days = 30
}
database, err := openDatabase()
if err != nil {
return err
}
before := time.Now().AddDate(0, 0, -days)
if !ctx.Bool("yes") {
fmt.Printf("将删除 %s 之前的蜜罐记录(共保留 %d 天)。\n", before.Format("2006-01-02 15:04:05"), days)
fmt.Print("确认继续?输入 yes 继续:")
var answer string
if _, err := fmt.Scanln(&answer); err != nil || strings.TrimSpace(answer) != "yes" {
fmt.Println("已取消。")
return nil
}
}
store := db.NewHoneypotStore(database)
n, err := store.DeleteBefore(context.Background(), before)
if err != nil {
return errors.Wrap(err, "prune honeypot records")
}
fmt.Printf("已删除 %d 条过期蜜罐记录。\n", n)
// 回收物理空间:PostgreSQL 的 DELETE 只标记死元组,表文件不会变小。
// 不回收的话「清理」只是逻辑变小,磁盘占用继续单调增长。
if n > 0 {
if err := store.Compact(context.Background()); err != nil {
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
}
// runHoneypotSize 报告蜜罐表的行数与磁盘占用。
//
// 这个命令存在的意义:把「会不会把数据库撑臃肿」变成运维随时能自己查的事,
// 而不是只能等开发一次性汇报。
func runHoneypotSize(ctx *cli.Context) error {
database, err := openDatabase()
if err != nil {
return err
}
store := db.NewHoneypotStore(database)
info, err := store.Size(context.Background())
if err != nil {
return errors.Wrap(err, "query honeypot table size")
}
fmt.Println("蜜罐表体积")
fmt.Println(strings.Repeat("-", 72))
fmt.Printf(" 表名:honeypot_hits\n")
fmt.Printf(" 存活记录:%d 条\n", info.Rows)
if info.Bytes > 0 {
fmt.Printf(" 磁盘占用:%s(含索引)\n", humanBytes(info.Bytes))
if info.Rows > 0 {
fmt.Printf(" 单条均摊:%d 字节\n", info.Bytes/info.Rows)
}
} else {
fmt.Printf(" 磁盘占用:(当前数据库不支持元数据查询)\n")
}
if info.Oldest != nil && info.Newest != nil {
fmt.Printf(" 时间跨度:%s → %s\n",
info.Oldest.Format("2006-01-02 15:04"),
info.Newest.Format("2006-01-02 15:04"))
}
fmt.Println()
// 与配置中的两道闸对照,让运维一眼看出「当前是否在界内」。
maxRows := conf.Security.HoneypotMaxRows
retainDays := conf.Security.HoneypotRetainDays
if maxRows > 0 {
fmt.Printf("行数上限:%d 条(当前 %d 条,%s)\n", maxRows, info.Rows, usageRatio(info.Rows, int64(maxRows)))
} else {
fmt.Printf("行数上限:未设置(当前 %d 条)\n", info.Rows)
}
if retainDays > 0 {
fmt.Printf("保留期:%d 天(自动清理 %s)\n", retainDays, onOff(conf.Security.HoneypotAutoClean))
} 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
}
// usageRatio 给出当前用量相对上限的百分比描述。
func usageRatio(cur int64, max int64) string {
if max <= 0 {
return ""
}
pct := float64(cur) / float64(max) * 100
switch {
case pct >= 100:
return "已达上限"
case pct >= 80:
return "接近上限"
default:
return fmt.Sprintf("%.1f%%", pct)
}
}
func onOff(b bool) string {
if b {
return "已开启"
}
return "已关闭"
}
// humanBytes 把字节数格式化成人类可读形式。
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %cB", float64(n)/float64(div), "KMGTPE"[exp])
}
// maskSecret 对密码做保长脱敏:保留首尾各 1 位,中间固定长度星号。
// 目的是让运维能分辨「是不是同一个密码」,但不把字典直接摊在终端上。
func maskSecret(s string) string {
r := []rune(s)
switch {
case len(r) <= 2:
return strings.Repeat("*", len(r))
case len(r) <= 6:
return string(r[0]) + strings.Repeat("*", len(r)-2) + string(r[len(r)-1])
default:
return string(r[0]) + strings.Repeat("*", 6) + string(r[len(r)-1])
}
}