miguan v1
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
// 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user