// 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" ) // Honeypot 提供蜜罐取证命令(只读 + 一条显式清理)。 // // TamaBox honeypot hits --since 24h 列出捕获到的破解凭据 // TamaBox honeypot stats --days 7 统计来源/路径/凭据排行 // 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 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, }, }, } // 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 } 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 } store := db.NewHoneypotStore(database) since := time.Now().Add(-dur) rows, err := store.ListSince(context.Background(), since, ctx.Int("limit")) if err != nil { return errors.Wrap(err, "query honeypot hits") } filterIP := strings.TrimSpace(ctx.String("ip")) showPwd := ctx.Bool("show-password") fmt.Printf("蜜罐命中记录(近 %s,共 %d 条)\n", dur, len(rows)) fmt.Println(strings.Repeat("-", 72)) if len(rows) == 0 { fmt.Println(" 无") return nil } for _, r := range rows { if filterIP != "" && r.IP != filterIP { continue } 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.CreatedAt.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)) } 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") } creds, err := store.TopCredentials(cctx, 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) return nil } // 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]) } } func proxiedSuffix(proxied bool) string { if proxied { return "(CDN/代理出口)" } return "" }