Files
tamabox/internal/cmd/honeypot.go
T
2026-10-21 12:09:28 +08:00

278 lines
7.7 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"
)
// 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 ""
}