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 ""
|
||||
}
|
||||
@@ -78,7 +78,15 @@ var (
|
||||
// 个人实名账号即可开通)/ auto(默认,留空同义:七牛优先、阿里云复核)。
|
||||
// 仅显式选定的通道会被调用,缺凭据时启动即报错(见 conf.Init)——
|
||||
// 审核失败是 fail-closed(拒绝提交),会让发布与私信整站不可用且现象隐蔽。
|
||||
TextCensorSource string `ini:"text_censor_source"`
|
||||
TextCensorSource string `ini:"text_censor_source"`
|
||||
// EnableHoneypot 开启「虚假后台登录界面」蜜罐:扫描器探测后台路径时
|
||||
// 返回一个仿 WordPress 的假登录页,并记录其提交的用户名/密码等破解信息。
|
||||
// 仅在「无任何路由匹配」的路径上生效,与真实业务路由零交集。
|
||||
EnableHoneypot bool `ini:"enable_honeypot"`
|
||||
// HoneypotPaths 追加自定义诱饵路径(逗号分隔),与内置清单并集。
|
||||
HoneypotPaths string `ini:"honeypot_paths"`
|
||||
// HoneypotRetainDays 蜜罐命中记录的保留天数,到期由 CLI 清理。
|
||||
HoneypotRetainDays int `ini:"honeypot_retain_days"`
|
||||
SecurityAlertEmail string `ini:"security_alert_email"` // 触发封禁时告警收件人(留空不发送)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ var AllTables = []interface{}{
|
||||
&FeatureControl{},
|
||||
// 站方处置与举报(无管理后台,处置经 CLI 写入、运行时读表生效)
|
||||
&UserRestriction{}, &ContentReport{},
|
||||
// 蜜罐命中记录(审计证据,只追加不软删)
|
||||
&HoneypotHit{},
|
||||
}
|
||||
|
||||
func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
@@ -117,6 +119,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
FeatureControls = NewFeatureControlsStore(db)
|
||||
Restrictions = NewRestrictionsStore(db)
|
||||
Reports = NewReportsStore(db)
|
||||
Honeypots = NewHoneypotStore(db)
|
||||
|
||||
if err := db.Use(otelgorm.NewPlugin(
|
||||
otelgorm.WithDBName(conf.Database.Name),
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// 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 db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HoneypotHit 是一次蜜罐命中记录(审计证据)。
|
||||
//
|
||||
// 刻意不嵌 dbutil.Model:审计证据不做软删除、不做 Update/Delete,
|
||||
// 只追加与查询。理由与 ContentReport 一致 —— 证据链一旦被软删除过滤,
|
||||
// 事后追责就断了。
|
||||
type HoneypotHit struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
|
||||
// IP 为归一化后的客户端 IP(shared.LimitKey 之后,IPv6 折叠到 /64)。
|
||||
IP string `gorm:"size:64;index" json:"ip"`
|
||||
// Method 与 Path 记录原始请求方法与路径(未归一化的原始值)。
|
||||
Method string `gorm:"size:16" json:"method"`
|
||||
Path string `gorm:"size:512" json:"path"`
|
||||
// Query 保存原始查询串(截断)。
|
||||
Query string `gorm:"size:1024" json:"query"`
|
||||
|
||||
// Username / Password 为提取出的破解凭据。Password 落库前做可逆加密还是脱敏,
|
||||
// 由调用方决定:这里只存明文,CLI 展示时默认脱敏。
|
||||
Username string `gorm:"size:256" json:"username"`
|
||||
Password string `gorm:"size:256" json:"password"`
|
||||
// Fields 保存其余表单/JSON 字段(JSON 字符串),便于还原破解工具特征。
|
||||
Fields string `gorm:"type:text" json:"fields"`
|
||||
// RawBody 保存截断后的原始 body。
|
||||
RawBody string `gorm:"type:text" json:"raw_body"`
|
||||
// HadFields 标记本次提交是否真的带了凭据字段(区分「探测 GET」与「尝试破解 POST」)。
|
||||
HadFields bool `json:"had_fields"`
|
||||
|
||||
// UserAgent / Referer / ContentType 用于扫描器画像。
|
||||
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||
Referer string `gorm:"size:512" json:"referer"`
|
||||
ContentType string `gorm:"size:256" json:"content_type"`
|
||||
|
||||
// Provider 为 IP 归属(云厂商/CDN/WAF)标签,Proxied 表示是否经代理。
|
||||
Provider string `gorm:"size:64;index" json:"provider"`
|
||||
Proxied bool `json:"proxied"`
|
||||
|
||||
// Banned 为命中时是否触发了封禁。
|
||||
Banned bool `json:"banned"`
|
||||
}
|
||||
|
||||
// TableName 固定表名,避免 GORM 复数化差异。
|
||||
func (HoneypotHit) TableName() string { return "honeypot_hits" }
|
||||
|
||||
// Honeypots 蜜罐命中记录全局 store,由 db.Init 装配。
|
||||
var Honeypots HoneypotStore
|
||||
|
||||
// HoneypotStore 蜜罐命中记录的读写。
|
||||
type HoneypotStore interface {
|
||||
// Create 追加一条命中记录。
|
||||
Create(ctx context.Context, hit *HoneypotHit) error
|
||||
// ListSince 返回 since 之后的命中记录,按时间倒序,最多 limit 条。
|
||||
ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error)
|
||||
// CountSince 统计 since 之后的命中总数。
|
||||
CountSince(ctx context.Context, since time.Time) (int64, error)
|
||||
// TopSources 返回 since 之后命中次数最多的来源 IP。
|
||||
TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error)
|
||||
// TopPaths 返回 since 之后被命中次数最多的路径。
|
||||
TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error)
|
||||
// TopCredentials 返回 since 之后最常被尝试的用户名/密码组合。
|
||||
TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error)
|
||||
// DeleteBefore 清理 before 之前的记录(保留期到期),返回删除行数。
|
||||
DeleteBefore(ctx context.Context, before time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// HoneypotSourceStat 来源 IP 聚合行。
|
||||
type HoneypotSourceStat struct {
|
||||
IP string
|
||||
Count int64
|
||||
Provider string
|
||||
}
|
||||
|
||||
// HoneypotPathStat 路径聚合行。
|
||||
type HoneypotPathStat struct {
|
||||
Path string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// HoneypotCredentialStat 凭据组合聚合行。
|
||||
type HoneypotCredentialStat struct {
|
||||
Username string
|
||||
Password string
|
||||
Count int64
|
||||
}
|
||||
|
||||
type honeypots struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewHoneypotStore 构造蜜罐记录 store。
|
||||
func NewHoneypotStore(db *gorm.DB) HoneypotStore {
|
||||
return &honeypots{db: db}
|
||||
}
|
||||
|
||||
func (h *honeypots) Create(ctx context.Context, hit *HoneypotHit) error {
|
||||
if hit == nil {
|
||||
return nil
|
||||
}
|
||||
return h.db.WithContext(ctx).Create(hit).Error
|
||||
}
|
||||
|
||||
func (h *honeypots) ListSince(ctx context.Context, since time.Time, limit int) ([]*HoneypotHit, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
var rows []*HoneypotHit
|
||||
err := h.db.WithContext(ctx).
|
||||
Where("created_at >= ?", since).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (h *honeypots) CountSince(ctx context.Context, since time.Time) (int64, error) {
|
||||
var n int64
|
||||
err := h.db.WithContext(ctx).Model(&HoneypotHit{}).
|
||||
Where("created_at >= ?", since).
|
||||
Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (h *honeypots) TopSources(ctx context.Context, since time.Time, limit int) ([]*HoneypotSourceStat, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 20
|
||||
}
|
||||
var rows []*HoneypotSourceStat
|
||||
err := h.db.WithContext(ctx).
|
||||
Table("honeypot_hits").
|
||||
Select("ip AS ip, count(*) AS count, max(provider) AS provider").
|
||||
Where("created_at >= ?", since).
|
||||
Group("ip").
|
||||
Order("count DESC").
|
||||
Limit(limit).
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (h *honeypots) TopPaths(ctx context.Context, since time.Time, limit int) ([]*HoneypotPathStat, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 20
|
||||
}
|
||||
var rows []*HoneypotPathStat
|
||||
err := h.db.WithContext(ctx).
|
||||
Table("honeypot_hits").
|
||||
Select("path AS path, count(*) AS count").
|
||||
Where("created_at >= ?", since).
|
||||
Group("path").
|
||||
Order("count DESC").
|
||||
Limit(limit).
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (h *honeypots) TopCredentials(ctx context.Context, since time.Time, limit int) ([]*HoneypotCredentialStat, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 20
|
||||
}
|
||||
var rows []*HoneypotCredentialStat
|
||||
err := h.db.WithContext(ctx).
|
||||
Table("honeypot_hits").
|
||||
Select("username AS username, password AS password, count(*) AS count").
|
||||
Where("created_at >= ?", since).
|
||||
Where("username <> '' OR password <> ''").
|
||||
Group("username, password").
|
||||
Order("count DESC").
|
||||
Limit(limit).
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (h *honeypots) DeleteBefore(ctx context.Context, before time.Time) (int64, error) {
|
||||
res := h.db.WithContext(ctx).Where("created_at < ?", before).Delete(&HoneypotHit{})
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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 db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestHoneypotStore 覆盖蜜罐命中记录的写入、检索与聚合。
|
||||
//
|
||||
// 重点验证「只追加」的审计语义:DeleteBefore 只按时间删,不做软删除;
|
||||
// 以及 TopCredentials 能把攻击者正在用的字典聚合出来。
|
||||
func TestHoneypotStore(t *testing.T) {
|
||||
database, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("honeypot_hits"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
store := NewHoneypotStore(database)
|
||||
now := time.Now()
|
||||
|
||||
rows := []*HoneypotHit{
|
||||
{IP: "1.2.3.4", Method: "GET", Path: "/wp-admin", Provider: "Azure", CreatedAt: now.Add(-3 * time.Hour)},
|
||||
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-2 * time.Hour)},
|
||||
{IP: "1.2.3.4", Method: "POST", Path: "/wp-login.php", Username: "admin", Password: "admin123", HadFields: true, CreatedAt: now.Add(-time.Hour)},
|
||||
{IP: "5.6.7.8", Method: "POST", Path: "/.ssh/id_rsa", Username: "root", Password: "toor", HadFields: true, CreatedAt: now.Add(-30 * time.Minute)},
|
||||
}
|
||||
for _, r := range rows {
|
||||
require.NoError(t, store.Create(ctx, r))
|
||||
}
|
||||
|
||||
since := now.Add(-4 * time.Hour)
|
||||
|
||||
total, err := store.CountSince(ctx, since)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(4), total)
|
||||
|
||||
list, err := store.ListSince(ctx, since, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 4)
|
||||
// 倒序返回
|
||||
require.Equal(t, "5.6.7.8", list[0].IP)
|
||||
|
||||
sources, err := store.TopSources(ctx, since, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sources, 2)
|
||||
require.Equal(t, "1.2.3.4", sources[0].IP)
|
||||
require.Equal(t, int64(3), sources[0].Count)
|
||||
require.Equal(t, "Azure", sources[0].Provider)
|
||||
|
||||
paths, err := store.TopPaths(ctx, since, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, paths, 3)
|
||||
require.Equal(t, "/wp-login.php", paths[0].Path)
|
||||
require.Equal(t, int64(2), paths[0].Count)
|
||||
|
||||
creds, err := store.TopCredentials(ctx, since, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, creds, 2)
|
||||
require.Equal(t, "admin", creds[0].Username)
|
||||
require.Equal(t, "admin123", creds[0].Password)
|
||||
require.Equal(t, int64(2), creds[0].Count)
|
||||
|
||||
// 保留期清理
|
||||
deleted, err := store.DeleteBefore(ctx, now.Add(-90*time.Minute))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), deleted) // 3 小时前、2 小时前两条被清
|
||||
|
||||
total, err = store.CountSince(ctx, since)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), total)
|
||||
}
|
||||
@@ -113,6 +113,53 @@ func SendSecurityAlertMail(ip, provider string, proxied bool, samplePaths []stri
|
||||
return sendTemplateMail(conf.Security.SecurityAlertEmail, "【狼的提问箱】检测到疑似批量扫描,已自动封禁 IP", templates.FS, "mail/security-alert.html", params)
|
||||
}
|
||||
|
||||
// HoneypotAlert 蜜罐捕获到破解尝试时的告警载荷。
|
||||
type HoneypotAlert struct {
|
||||
IP string
|
||||
Provider string
|
||||
Proxied bool
|
||||
Path string
|
||||
Username string
|
||||
Password string
|
||||
Banned bool
|
||||
DurationMin int
|
||||
}
|
||||
|
||||
// SendHoneypotAlertMail 发送「蜜罐捕获到破解尝试」告警。
|
||||
//
|
||||
// 与 SendSecurityAlertMail 共用 conf.Security.SecurityAlertEmail 收件人,不新增配置项。
|
||||
// 邮件中直接给出捕获到的用户名/密码:这是本功能的核心价值(拿到攻击者正在用的字典),
|
||||
// 但也意味着这封邮件是敏感物 —— 收件人应当只有运维本人,不要转发到公共群组。
|
||||
func SendHoneypotAlertMail(a HoneypotAlert) error {
|
||||
proxyHint := ""
|
||||
if a.Proxied {
|
||||
proxyHint = "1"
|
||||
}
|
||||
disposition := "已记录,未触发封禁"
|
||||
if a.Banned {
|
||||
disposition = fmt.Sprintf("已记录并自动临时封禁 %d 分钟", a.DurationMin)
|
||||
}
|
||||
password := a.Password
|
||||
if password == "" {
|
||||
password = "(空)"
|
||||
}
|
||||
username := a.Username
|
||||
if username == "" {
|
||||
username = "(空)"
|
||||
}
|
||||
params := map[string]string{
|
||||
"ip": a.IP,
|
||||
"provider": a.Provider,
|
||||
"proxy_hint": proxyHint,
|
||||
"path": a.Path,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"disposition": disposition,
|
||||
"time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
return sendTemplateMail(conf.Security.SecurityAlertEmail, "【狼的提问箱】蜜罐捕获到后台破解尝试", templates.FS, "mail/honeypot-alert.html", params)
|
||||
}
|
||||
|
||||
// SendAbuseAlertMail 发送「某箱主收到集中举报」的阈值告警。
|
||||
//
|
||||
// 与 SendSecurityAlertMail 共用 conf.Security.SecurityAlertEmail 收件人,不新增配置项。
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/flamego/flamego"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
)
|
||||
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
// - 真正的 404 探测由 f.NotFound 处理器单独计数(见 route.go),
|
||||
// 不再把业务性的"302 → /"(语言切换 / 登出 / 投票不存在 / 黑名单跳转等)误判为扫描。
|
||||
// - 核心检测逻辑见 internal/security/autoban(可独立测试);告警发送函数由本层注入。
|
||||
func autobanMiddleware(b *autoban.Banner) flamego.Handler {
|
||||
func autobanMiddleware(b *autoban.Banner, hp *honeypot.Honeypot) flamego.Handler {
|
||||
return func(ctx flamego.Context) {
|
||||
// 经 shared.LimitKey 归一化后再计数与判定:IPv4 原样,IPv6 聚合到 /64。
|
||||
// 否则攻击者可在单个 /64 内轮换地址,使每个键只命中一次而永不触发阈值。
|
||||
@@ -32,8 +33,12 @@ func autobanMiddleware(b *autoban.Banner) flamego.Handler {
|
||||
|
||||
path := ctx.Request().URL.Path
|
||||
kind := autoban.KindNone
|
||||
if autoban.IsSensitivePath(path) {
|
||||
kind = autoban.KindSensitive
|
||||
// 蜜罐命中的路径由蜜罐处理器自己计数(它需要同时知道「是否触发了封禁」
|
||||
// 以便落库)。若这里再记一次,同一次请求会被计两遍,阈值实际减半。
|
||||
if hp == nil || !hp.Match(path) {
|
||||
if autoban.IsSensitivePath(path) {
|
||||
kind = autoban.KindSensitive
|
||||
}
|
||||
}
|
||||
if kind != autoban.KindNone {
|
||||
defer b.Record(ip, path, kind)
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// 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 route
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/flamego/flamego"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/security/ipinfo"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
)
|
||||
|
||||
// honeypotHandler 蜜罐处理器:只在「无任何路由匹配」的路径上生效。
|
||||
//
|
||||
// 与真实业务路由(/login、/register、/_/{domain}、/user/**、/api/v1/**)天然零交集,
|
||||
// 因为它挂在 f.NotFound 上 —— flamego 只有在路由表完全落空时才会走到这里。
|
||||
//
|
||||
// 行为:
|
||||
// - GET → 返回仿 WordPress 的假登录页(200),让扫描器判定「后台存在」;
|
||||
// - POST → 解析并记录提交的用户名/密码,返回「用户名或密码无效」的假响应;
|
||||
// - 无论 GET/POST → 计入 autoban 敏感路径统计,达到阈值即临时封禁 + 邮件告警;
|
||||
// - 绝不调用 ctx.Next(),不查业务表、不渲染业务模板。
|
||||
type honeypotHandler struct {
|
||||
hp *honeypot.Honeypot
|
||||
ab *autoban.Banner
|
||||
// store 允许为 nil(单测 / 未装配 DB 时降级为只记录日志)。
|
||||
store db.HoneypotStore
|
||||
// sendAlert 发送「捕获到破解尝试」的告警邮件。nil 则不发。
|
||||
sendAlert func(mail.HoneypotAlert) error
|
||||
// alerted 保证同一个 IP 只告警一次(与 autoban 的告警去重口径一致),
|
||||
// 避免暴力破解脚本在封禁前反复提交把邮箱打爆。
|
||||
alerted map[string]bool
|
||||
alertedMu sync.Mutex
|
||||
}
|
||||
|
||||
// newHoneypotHandler 构造蜜罐处理器。
|
||||
func newHoneypotHandler(hp *honeypot.Honeypot, ab *autoban.Banner, store db.HoneypotStore) *honeypotHandler {
|
||||
return &honeypotHandler{
|
||||
hp: hp,
|
||||
ab: ab,
|
||||
store: store,
|
||||
alerted: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// Serve 处理一次蜜罐命中。
|
||||
func (h *honeypotHandler) Serve(ctx flamego.Context) {
|
||||
req := ctx.Request().Request
|
||||
ip := shared.LimitKey(shared.ResolveClientIP(req))
|
||||
path := req.URL.Path
|
||||
|
||||
// 记录与封禁:走既有 autoban 链路,复用阈值封禁与告警。
|
||||
banned := false
|
||||
if h.ab != nil && ip != "" {
|
||||
h.ab.Record(ip, path, autoban.KindSensitive)
|
||||
// Record 内部在触发封禁时把 IP 写入 banned 表;此处再查一次得到确切状态。
|
||||
banned = h.ab.IsBanned(ip)
|
||||
}
|
||||
|
||||
// body 只能读一次:先提取凭据(POST 时读 body),再决定写哪种响应。
|
||||
creds := h.hp.ExtractCredentials(req)
|
||||
|
||||
switch req.Method {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
h.serveLoginPage(ctx, path)
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch:
|
||||
h.serveLoginFailure(ctx, path, honeypot.LoginFailureNotice)
|
||||
default:
|
||||
// 其他方法(PUT/DELETE/OPTIONS 等)返回一个无内容的假 200,模拟后端存在。
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// 落库留证。落库失败只记日志,绝不影响已写出的响应。
|
||||
h.record(ip, req, path, creds, banned)
|
||||
}
|
||||
|
||||
// serveLoginPage 返回假登录页。
|
||||
func (h *honeypotHandler) serveLoginPage(ctx flamego.Context, path string) {
|
||||
redir := ""
|
||||
if q := ctx.Request().URL.Query().Get("redirect_to"); q != "" {
|
||||
redir = q
|
||||
}
|
||||
honeypot.WriteLoginPage(ctx.ResponseWriter(), honeypot.LoginPageVars{
|
||||
Action: path,
|
||||
Title: "Log In ‹ WordPress",
|
||||
Heading: "WordPress",
|
||||
Redir: redir,
|
||||
})
|
||||
}
|
||||
|
||||
// serveLoginFailure 返回「登录失败」的假响应。
|
||||
//
|
||||
// 响应体刻意与真实 WordPress 的错误提示一致(同一句文案、不区分用户不存在与密码错误),
|
||||
// 这样爆破脚本拿不到「用户名枚举」信号,也就无法优化字典;而我们照样拿到完整凭据。
|
||||
func (h *honeypotHandler) serveLoginFailure(ctx flamego.Context, path string, notice string) {
|
||||
body := honeypot.RenderLoginPage(honeypot.LoginPageVars{
|
||||
Action: path,
|
||||
Title: "Log In \u2039 WordPress",
|
||||
Heading: "WordPress",
|
||||
Notice: notice,
|
||||
Redir: ctx.Request().URL.Query().Get("redirect_to"),
|
||||
})
|
||||
w := ctx.ResponseWriter()
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// record 追加一条命中记录。
|
||||
func (h *honeypotHandler) record(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
|
||||
h.recordCredentials(ip, req, path, creds, banned)
|
||||
}
|
||||
|
||||
// recordCredentials 把一次命中写入数据库。
|
||||
func (h *honeypotHandler) recordCredentials(ip string, req *http.Request, path string, creds honeypot.Credentials, banned bool) {
|
||||
provider, proxied := ipinfo.Lookup(ip)
|
||||
|
||||
fieldsJSON := ""
|
||||
if len(creds.Fields) > 0 {
|
||||
if b, err := json.Marshal(creds.Fields); err == nil {
|
||||
fieldsJSON = string(b)
|
||||
}
|
||||
}
|
||||
|
||||
query := ""
|
||||
if req.URL != nil {
|
||||
query = req.URL.RawQuery
|
||||
if len(query) > 1000 {
|
||||
query = query[:1000]
|
||||
}
|
||||
}
|
||||
|
||||
hit := &db.HoneypotHit{
|
||||
IP: ip,
|
||||
Method: req.Method,
|
||||
Path: path,
|
||||
Query: query,
|
||||
Username: creds.Username,
|
||||
Password: creds.Password,
|
||||
Fields: fieldsJSON,
|
||||
RawBody: creds.RawBody,
|
||||
HadFields: creds.HadFields,
|
||||
UserAgent: truncate(req.UserAgent(), 500),
|
||||
Referer: truncate(req.Referer(), 500),
|
||||
ContentType: truncate(req.Header.Get("Content-Type"), 250),
|
||||
Provider: provider,
|
||||
Proxied: proxied,
|
||||
Banned: banned,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 只有真的提交了凭据才告警:纯路径探测(GET)没有取证价值,
|
||||
// 而爆破脚本往往在封禁前反复 POST,只告警一次即可。
|
||||
if creds.HadFields {
|
||||
h.alert(ip, req, path, creds, provider, proxied, banned)
|
||||
}
|
||||
|
||||
// 有凭据的提交才是高价值证据,日志里点名;纯探测只记路径。
|
||||
if creds.HadFields {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"ip": ip,
|
||||
"path": path,
|
||||
"username": creds.Username,
|
||||
"banned": banned,
|
||||
}).Warn("honeypot: credential submission captured")
|
||||
}
|
||||
|
||||
if h.store == nil {
|
||||
return
|
||||
}
|
||||
// 用独立超时上下文,避免请求被取消时取证写不进去。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := h.store.Create(ctx, hit); err != nil {
|
||||
logrus.WithError(err).WithField("ip", ip).WithField("path", path).
|
||||
Warn("honeypot: failed to persist hit")
|
||||
}
|
||||
}
|
||||
|
||||
// alert 发送一次告警邮件(每个 IP 只发一次)。
|
||||
func (h *honeypotHandler) alert(ip string, req *http.Request, path string, creds honeypot.Credentials, provider string, proxied, banned bool) {
|
||||
if h.sendAlert == nil || ip == "" {
|
||||
return
|
||||
}
|
||||
h.alertedMu.Lock()
|
||||
if h.alerted[ip] {
|
||||
h.alertedMu.Unlock()
|
||||
return
|
||||
}
|
||||
h.alerted[ip] = true
|
||||
h.alertedMu.Unlock()
|
||||
|
||||
durationMin := conf.Security.BanDurationMinutes
|
||||
if durationMin <= 0 {
|
||||
durationMin = 15
|
||||
}
|
||||
go func() {
|
||||
if err := h.sendAlert(mail.HoneypotAlert{
|
||||
IP: ip,
|
||||
Provider: provider,
|
||||
Proxied: proxied,
|
||||
Path: path,
|
||||
Username: creds.Username,
|
||||
Password: creds.Password,
|
||||
Banned: banned,
|
||||
DurationMin: durationMin,
|
||||
}); err != nil {
|
||||
logrus.WithError(err).WithField("ip", ip).Warn("honeypot: failed to send alert mail")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// truncate 截断字符串到指定长度(按 rune,避免截断多字节字符)。
|
||||
func truncate(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// honeypotExtraPaths 解析配置中的自定义诱饵路径(逗号/分号/换行分隔)。
|
||||
func honeypotExtraPaths(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == ' ' || r == '\t'
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(f, "/") {
|
||||
f = "/" + f
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// 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 route
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/flamego/flamego"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
)
|
||||
|
||||
// newHoneypotTestServer 造一个只含蜜罐 NotFound 分流的最小 flamego 实例。
|
||||
//
|
||||
// 刻意不调用 route.New()(那会拉起 DB/Redis/session 依赖),而是复刻它的
|
||||
// 关键结构:全局 Use 链 → 具体路由 → NotFound 蜜罐分流。
|
||||
func newHoneypotTestServer(t *testing.T, enabled bool) *httptest.Server {
|
||||
t.Helper()
|
||||
f := flamego.New()
|
||||
hp := honeypot.New(honeypot.Options{Enabled: enabled})
|
||||
ab := autoban.New(autoban.Options{
|
||||
Enabled: true,
|
||||
ThresholdSensitive: 1, // 命中一次即封禁,便于断言
|
||||
Window: 60,
|
||||
Duration: 60,
|
||||
})
|
||||
hph := newHoneypotHandler(hp, ab, nil) // store=nil → 只走日志,不落库
|
||||
|
||||
f.Get("/login", func(ctx flamego.Context) {
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.ResponseWriter().Write([]byte("REAL_LOGIN"))
|
||||
})
|
||||
f.Get("/api/v1/questions", func(ctx flamego.Context) {
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.ResponseWriter().Write([]byte("REAL_API"))
|
||||
})
|
||||
|
||||
f.NotFound(func(ctx flamego.Context) {
|
||||
if hp.Match(ctx.Request().URL.Path) {
|
||||
hph.Serve(ctx)
|
||||
return
|
||||
}
|
||||
ctx.Redirect("/")
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(f)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
// TestHoneypotServesFakeLoginPage 验证:命中诱饵路径时返回假登录页(含 canary),
|
||||
// 而不是 302 跳首页。
|
||||
func TestHoneypotServesFakeLoginPage(t *testing.T) {
|
||||
ts := newHoneypotTestServer(t, true)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/wp-login.php")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html", ct)
|
||||
}
|
||||
if !strings.Contains(string(body), honeypot.CanaryToken) {
|
||||
t.Error("假登录页缺少 canary 标记,无法溯源")
|
||||
}
|
||||
if !strings.Contains(string(body), `name="log"`) || !strings.Contains(string(body), `name="pwd"`) {
|
||||
t.Error("假登录页应使用 WordPress 经典字段名 log / pwd")
|
||||
}
|
||||
if strings.Contains(string(body), "REAL_LOGIN") {
|
||||
t.Error("假登录页不应包含真实业务内容")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoneypotDoesNotHijackRealRoutes 是红线测试:真实业务路由绝不能被蜜罐接管。
|
||||
func TestHoneypotDoesNotHijackRealRoutes(t *testing.T) {
|
||||
ts := newHoneypotTestServer(t, true)
|
||||
|
||||
for path, want := range map[string]string{
|
||||
"/login": "REAL_LOGIN",
|
||||
"/api/v1/questions": "REAL_API",
|
||||
} {
|
||||
resp, err := http.Get(ts.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("%s 返回了 %q,期望包含 %q(业务路由被劫持)", path, truncate(string(body), 80), want)
|
||||
}
|
||||
if strings.Contains(string(body), honeypot.CanaryToken) {
|
||||
t.Errorf("%s 命中了蜜罐,业务路由被劫持", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoneypotDisabledFallsBackToRedirect 验证:蜜罐关闭时行为与原先一致(302 → /)。
|
||||
func TestHoneypotDisabledFallsBackToRedirect(t *testing.T) {
|
||||
ts := newHoneypotTestServer(t, false)
|
||||
|
||||
client := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Get(ts.URL + "/wp-login.php")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Errorf("status = %d, want 302", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "/" {
|
||||
t.Errorf("Location = %q, want /", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoneypotCapturesCredentials 验证:POST 提交的用户名/密码能被提取出来。
|
||||
func TestHoneypotCapturesCredentials(t *testing.T) {
|
||||
hp := honeypot.New(honeypot.Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
|
||||
strings.NewReader("log=admin&pwd=hunter2&redirect_to=%2Fwp-admin%2F"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
creds := hp.ExtractCredentials(req)
|
||||
if creds.Username != "admin" {
|
||||
t.Errorf("Username = %q, want admin", creds.Username)
|
||||
}
|
||||
if creds.Password != "hunter2" {
|
||||
t.Errorf("Password = %q, want hunter2", creds.Password)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoneypotPostReturnsFailurePage 验证:POST 后返回 200 的假登录页并带错误提示。
|
||||
func TestHoneypotPostReturnsFailurePage(t *testing.T) {
|
||||
ts := newHoneypotTestServer(t, true)
|
||||
|
||||
resp, err := http.Post(ts.URL+"/wp-login.php",
|
||||
"application/x-www-form-urlencoded",
|
||||
strings.NewReader("log=admin&pwd=admin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(string(body), honeypot.LoginFailureNotice) {
|
||||
t.Error("提交凭据后应返回「用户名或密码无效」的假提示")
|
||||
}
|
||||
}
|
||||
+38
-3
@@ -26,11 +26,13 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/captcha"
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/route/chat" // 私聊路由
|
||||
"github.com/tamakyi/TamaBox/internal/security/autoban"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/security/ipinfo"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||||
@@ -96,6 +98,19 @@ func New() *flamego.Flame {
|
||||
},
|
||||
})
|
||||
|
||||
// 蜜罐(虚假后台登录界面):默认关闭,需在 [security] 段显式 enable_honeypot = true。
|
||||
// 命中即返回假登录页 + 记录破解凭据 + 复用上面的 autoban 封禁/告警链路。
|
||||
hp := honeypot.New(honeypot.Options{
|
||||
Enabled: conf.Security.EnableHoneypot,
|
||||
ExtraPaths: honeypotExtraPaths(conf.Security.HoneypotPaths),
|
||||
})
|
||||
hph := newHoneypotHandler(hp, ab, db.Honeypots)
|
||||
// 蜜罐告警:只有真的提交了凭据才发(纯路径探测没有取证价值),且每个 IP 只发一次。
|
||||
// 收件人复用 security_alert_email,不新增配置项。
|
||||
hph.sendAlert = func(a mail.HoneypotAlert) error {
|
||||
return mail.SendHoneypotAlertMail(a)
|
||||
}
|
||||
|
||||
templateFS, err := template.EmbedFS(templates.FS, ".", []string{".html"})
|
||||
if err != nil {
|
||||
logrus.WithError(err).Fatal("Failed to embed templates file system")
|
||||
@@ -148,7 +163,7 @@ func New() *flamego.Flame {
|
||||
|
||||
f.Use(securityHeaders())
|
||||
f.Use(ipBlacklistMiddleware())
|
||||
f.Use(autobanMiddleware(ab))
|
||||
f.Use(autobanMiddleware(ab, hp))
|
||||
// 全局请求体上限:必须在任何会读取 body 的中间件(尤其 Contexter 内的 CSRF 校验)
|
||||
// 之前执行,否则超大 multipart 会在 Bind 的路由级限制生效前落盘临时文件耗尽磁盘。
|
||||
f.Use(requestBodyLimitMiddleware())
|
||||
@@ -430,8 +445,28 @@ func New() *flamego.Flame {
|
||||
context.Contexter(),
|
||||
)
|
||||
f.NotFound(func(ctx flamego.Context) {
|
||||
// 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。flamego 的 NotFound 会先
|
||||
// 写 404 状态再 Redirect,故须在 Redirect 前记录。
|
||||
// ── 蜜罐分流(优先)───────────────────────────────────────────────
|
||||
// 命中诱饵路径(/wp-admin、/.ssh、/.env 等后台探测特征)时,返回一个
|
||||
// 仿 WordPress 的假登录页,并把攻击者提交的凭据记录入库。
|
||||
//
|
||||
// 为什么挂在 NotFound 而不是全局中间件:NotFound 只在「路由表完全落空」
|
||||
// 时触发,因此与 /login、/register、/_/{domain}、/user/**、/api/v1/** 等
|
||||
// 真实业务路由天然零交集,不会劫持正常用户;且它位于全局 f.Use 链之后,
|
||||
// requestBodyLimitMiddleware 已对 body 施加上限,解析 POST body 安全。
|
||||
// 组级中间件(含 CSRF)只挂在具体路由上,不作用于 NotFound,故表单可解析。
|
||||
//
|
||||
// 短路:flamego 的 context.run() 在 ResponseWriter().Written() 为真时即
|
||||
// return,故此处写完响应且不调用 ctx.Next() 即可安全终止整条链路。
|
||||
if hp != nil && hp.Match(ctx.Request().URL.Path) {
|
||||
hph.Serve(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
// 真正的 404 探测在此记录(不把业务 302→/ 误判为扫描)。
|
||||
// ⚠️ 更正:flamego 的 NotFound 处理器并不会自动写 404 状态码 ——
|
||||
// ResponseWriter.Write() 在未显式 WriteHeader 时写的是 200,
|
||||
// ctx.Redirect("/") 写的是 302 + Location: /。这里保持原有 302→/ 行为
|
||||
// (对扫描器而言,302 到首页同样表示「此路径不存在」)。
|
||||
// 键必须与 autobanMiddleware 的 IsBanned/Record 一致(同样经 LimitKey 聚合),
|
||||
// 否则计数落在另一个键上,封禁永不生效。
|
||||
// ⚠️ 计数受 ban_track_notfound 控制,默认关闭;Record 内部会忽略未开启的
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
// Package honeypot 提供「虚假后台登录界面」诱饵能力。
|
||||
//
|
||||
// 设计原则(务必遵守):
|
||||
// 1. 只返回假内容,绝不返回、绝不猜测任何真实凭据;
|
||||
// 2. 只在 flamego 的 NotFound 分流点生效 —— 即「没有任何路由匹配」的路径,
|
||||
// 因此与 /login、/register、/_/{domain}、/user/**、/api/v1/** 等真实业务
|
||||
// 路由天然零交集,不会劫持正常用户;
|
||||
// 3. 命中即短路:不查业务表、不渲染业务模板、不调用 ctx.Next()。
|
||||
package honeypot
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CanaryToken 是所有假页面统一的溯源标记。它只会出现在诱饵响应里,
|
||||
// 一旦在别处(真实页面、错误日志)出现即可判定为蜜罐泄漏/误挂。
|
||||
const CanaryToken = "tb-hp-7f3a1c9e"
|
||||
|
||||
// maxCredentialFieldLen 限制单个凭据字段落库长度,避免超长 payload 撑爆表。
|
||||
const maxCredentialFieldLen = 512
|
||||
|
||||
// honeypotPrefixes 为段前缀匹配的诱饵路径。取自 autoban.sensitivePaths 的语义,
|
||||
// 但收敛为「与业务路由不可能同名」的一组,避免误伤。
|
||||
var honeypotPrefixes = []string{
|
||||
"/admin", "/administrator", "/wp-admin", "/wp-content", "/wp-includes",
|
||||
"/wp-login", "/wp-login.php", "/wp-cron", "/wp-trackback", "/wp-comments-post",
|
||||
"/wp-json", "/wp-config", "/wp-mail", "/wp-signup", "/wp-activate", "/wp-links-opml",
|
||||
"/wp-blog-header", "/wp-load", "/wp-settings", "/wp-load.php", "/wp-settings.php",
|
||||
"/xmlrpc", "/console", "/manager", "/manager/html", "/phpmyadmin", "/pma",
|
||||
"/cpanel", "/plesk", "/webadmin", "/adminer", "/shell", "/cmd", "/eval",
|
||||
"/webshell", "/cgi-bin", "/shell.php", "/admin.php", "/login.php",
|
||||
"/.ssh", "/.aws", "/.env", "/.git", "/.svn", "/.hg", "/.docker",
|
||||
"/.config", "/.npm", "/.kube", "/.vscode", "/.idea",
|
||||
"/actuator", "/swagger", "/swagger-ui", "/openapi", "/v2/api-docs", "/v3/api-docs",
|
||||
"/api-docs", "/druid", "/nacos", "/solr", "/elastic", "/apollo",
|
||||
"/server-status", "/server-info", "/info.php", "/phpinfo", "/test.php",
|
||||
"/backup", "/backups", "/dump", "/database", "/db", "/sql", "/mysql",
|
||||
"/config", "/configuration", "/vendor", "/storage", "/tmp", "/temp",
|
||||
"/upload", "/uploads", "/files", "/log", "/logs",
|
||||
}
|
||||
|
||||
// honeypotFragments 为任意位置子串匹配的诱饵路径片段(大小写不敏感)。
|
||||
var honeypotFragments = []string{
|
||||
"/wp-admin", "/wp-login", "/wp-content", "/wp-includes", "/wp-json",
|
||||
"/wp-config", "/xmlrpc.php", "/wordpress", "/phpmyadmin", "/cgi-bin/",
|
||||
"/.git/", "/.env", "/.aws/", "/.ssh/", "/actuator/", "/swagger",
|
||||
"/druid/", "/solr/", "/nacos/", "/vuln", "/shell?", "/cmd?",
|
||||
"/.bak", "/.old", "/.orig", "/.save", "/.swp", "/.zip", "/.tar.gz", "/.sql",
|
||||
}
|
||||
|
||||
// forbiddenPrefixes 是业务路由前缀:即便有人往诱饵表里塞了这些前缀,
|
||||
// 匹配时也会被强制剔除,构成第二道保险。
|
||||
var forbiddenPrefixes = []string{
|
||||
"/login", "/register", "/logout", "/signup", "/sign-in", "/sign_up",
|
||||
"/user", "/api", "/_", "/assets", "/static", "/css", "/js", "/images",
|
||||
"/favicon", "/robots", "/sitemap", "/feed", "/rss", "/about", "/rules",
|
||||
"/sponsor", "/change-logs", "/maintenance-mode", "/pixel",
|
||||
}
|
||||
|
||||
// Options 是蜜罐行为配置。
|
||||
type Options struct {
|
||||
// Enabled 为false 时 Honeypot() 恒返回 nil(不接管任何路径)。
|
||||
Enabled bool
|
||||
// ExtraPaths 追加自定义诱饵路径(支持段前缀语义,写成 "/a/b" 即前缀匹配)。
|
||||
ExtraPaths []string
|
||||
// MaxPayloadBytes 单次可记录的用户名/密码等字段长度上限。
|
||||
MaxPayloadBytes int
|
||||
}
|
||||
|
||||
func (o Options) maxField() int {
|
||||
if o.MaxPayloadBytes > 0 {
|
||||
return o.MaxPayloadBytes
|
||||
}
|
||||
return maxCredentialFieldLen
|
||||
}
|
||||
|
||||
// Honeypot 是蜜罐匹配器,构造后不可变,可安全并发使用。
|
||||
type Honeypot struct {
|
||||
enabled bool
|
||||
prefix map[string]bool
|
||||
maxField int
|
||||
}
|
||||
|
||||
// New 构造蜜罐匹配器。ExtraPaths 会参与前缀匹配。
|
||||
func New(o Options) *Honeypot {
|
||||
h := &Honeypot{
|
||||
enabled: o.Enabled,
|
||||
prefix: make(map[string]bool, len(honeypotPrefixes)+len(o.ExtraPaths)),
|
||||
maxField: o.maxField(),
|
||||
}
|
||||
for _, p := range honeypotPrefixes {
|
||||
h.prefix[normalize(p)] = true
|
||||
}
|
||||
for _, p := range o.ExtraPaths {
|
||||
if p = normalize(p); p != "" {
|
||||
h.prefix[p] = true
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Enabled 报告蜜罐是否启用。
|
||||
func (h *Honeypot) Enabled() bool { return h != nil && h.enabled }
|
||||
|
||||
// normalize 归一化路径:小写、合并重复斜杠、去掉结尾斜杠(根除外)。
|
||||
func normalize(p string) string {
|
||||
p = strings.ToLower(strings.TrimSpace(p))
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
// 折叠重复斜杠
|
||||
for strings.Contains(p, "//") {
|
||||
p = strings.ReplaceAll(p, "//", "/")
|
||||
}
|
||||
for strings.HasSuffix(p, "/") && len(p) > 1 {
|
||||
p = strings.TrimSuffix(p, "/")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Match 判断路径是否应被蜜罐接管。
|
||||
//
|
||||
// 归一化步骤:去查询串、URL 反解码一次(对抗 %2f 编码绕过)、折叠斜杠、
|
||||
// 去掉尾部 /index.html 之类不影响结果的尾斜杠。
|
||||
func (h *Honeypot) Match(rawPath string) bool {
|
||||
if !h.Enabled() {
|
||||
return false
|
||||
}
|
||||
p := normalizePath(rawPath)
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
for _, f := range forbiddenPrefixes {
|
||||
if p == f || strings.HasPrefix(p, f+"/") {
|
||||
// 业务路由,绝不接管
|
||||
return false
|
||||
}
|
||||
}
|
||||
if _, ok := h.prefix[p]; ok {
|
||||
return true
|
||||
}
|
||||
// 段前缀:/wp-admin/install.php 命中 /wp-admin
|
||||
segs := strings.Split(p, "/")
|
||||
for i := 1; i < len(segs); i++ {
|
||||
joined := strings.Join(segs[:i+1], "/")
|
||||
if h.prefix[joined] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, f := range honeypotFragments {
|
||||
if strings.Contains(p, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizePath(raw string) string {
|
||||
p := raw
|
||||
// 去查询串与 fragment(畸形请求可能没有 '?',用 Cut 兜底)
|
||||
if i := strings.IndexAny(p, "?#"); i >= 0 {
|
||||
p = p[:i]
|
||||
}
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
// 反解码一次;失败(非法百分号编码)则用原串继续。
|
||||
if dec, err := url.PathUnescape(p); err == nil && dec != "" {
|
||||
p = dec
|
||||
}
|
||||
p = strings.ReplaceAll(p, "\\", "/")
|
||||
return normalize(p)
|
||||
}
|
||||
|
||||
// Credentials 是从一次诱饵提交中提取出的破解信息。
|
||||
type Credentials struct {
|
||||
Username string
|
||||
Password string
|
||||
Fields map[string]string
|
||||
RawBody string
|
||||
HadFields bool
|
||||
}
|
||||
|
||||
// credentialKeys 是常见的用户名/密码字段名(表单 POST 与 JSON body 均适用)。
|
||||
var credentialKeys = map[string]bool{
|
||||
"log": true, "pwd": true, "username": true, "user": true, "pass": true,
|
||||
"password": true, "passwd": true, "pw": true, "email": true, "name": true,
|
||||
"account": true, "login": true, "wp_user": true, "user_login": true,
|
||||
"wp_password": true, "uname": true, "nickname": true, "id": true, "token": true,
|
||||
}
|
||||
|
||||
// ExtractCredentials 从请求中提取破解凭据。
|
||||
//
|
||||
// 同时支持三类来源,按优先级合并:
|
||||
// 1. URL 查询串 / POST 表单字段(application/x-www-form-urlencoded、multipart);
|
||||
// 2. JSON body({"username":"admin","password":"123456"});
|
||||
// 3. Basic Auth 头。
|
||||
//
|
||||
// 永不 panic、永不因解析失败阻断响应。
|
||||
func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
|
||||
out := Credentials{Fields: map[string]string{}}
|
||||
limit := maxCredentialFieldLen
|
||||
if h != nil && h.maxField > 0 {
|
||||
limit = h.maxField
|
||||
}
|
||||
put := func(k, v string) {
|
||||
k = strings.ToLower(strings.TrimSpace(k))
|
||||
v = strings.TrimSpace(v)
|
||||
if k == "" {
|
||||
return
|
||||
}
|
||||
if len(v) > limit {
|
||||
v = v[:limit]
|
||||
}
|
||||
if _, exists := out.Fields[k]; !exists {
|
||||
out.Fields[k] = v
|
||||
}
|
||||
switch k {
|
||||
case "log", "username", "user", "login", "user_login", "email", "account", "uname", "wp_user", "nickname":
|
||||
if out.Username == "" {
|
||||
out.Username = v
|
||||
}
|
||||
case "pwd", "password", "passwd", "pw", "pass", "wp_password", "token":
|
||||
if out.Password == "" {
|
||||
out.Password = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 查询串
|
||||
if r != nil && r.URL != nil {
|
||||
for k, vs := range r.URL.Query() {
|
||||
if len(vs) > 0 {
|
||||
put(k, vs[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r == nil || r.Body == nil {
|
||||
out.finish()
|
||||
return out
|
||||
}
|
||||
if r.Body != nil {
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
}
|
||||
|
||||
// 全量读取(bodylimit 中间件已对非 GET 请求施加上限,这里再做一层防御)
|
||||
buf := make([]byte, 0, 1024)
|
||||
tmp := make([]byte, 4096)
|
||||
reader := r.Body
|
||||
for {
|
||||
n, err := reader.Read(tmp)
|
||||
if n > 0 {
|
||||
if len(buf)+n > limit*8 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
break
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
raw := string(buf)
|
||||
if len(raw) > limit {
|
||||
raw = raw[:limit]
|
||||
}
|
||||
out.RawBody = raw
|
||||
|
||||
ct := strings.ToLower(r.Header.Get("Content-Type"))
|
||||
switch {
|
||||
case strings.Contains(ct, "json"):
|
||||
parseJSONCredentials(raw, put)
|
||||
default:
|
||||
// ParseQuery 遇到非法百分号编码会返回 error,但仍会给出已成功解析的部分字段。
|
||||
// 蜜罐要的是「尽可能多拿到凭据」,故忽略 error 继续用部分结果。
|
||||
vals, _ := url.ParseQuery(raw)
|
||||
if len(vals) > 0 {
|
||||
for k, vs := range vals {
|
||||
if len(vs) > 0 {
|
||||
put(k, vs[0])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 完全不像表单时再按 JSON 试一次。
|
||||
parseJSONCredentials(raw, put)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Basic Auth
|
||||
if user, pass, ok := r.BasicAuth(); ok {
|
||||
put("username", user)
|
||||
put("password", pass)
|
||||
}
|
||||
|
||||
out.finish()
|
||||
return out
|
||||
}
|
||||
|
||||
// finish 依据 Fields 补齐 Username/Password,并置 HadFields。
|
||||
func (c *Credentials) finish() {
|
||||
for k, v := range c.Fields {
|
||||
if credentialKeys[k] && v != "" {
|
||||
c.HadFields = true
|
||||
}
|
||||
}
|
||||
if c.Username == "" || c.Password == "" {
|
||||
for k, v := range c.Fields {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if c.Username == "" && !isPasswordKey(k) {
|
||||
if credentialKeys[k] {
|
||||
c.Username = v
|
||||
}
|
||||
}
|
||||
if c.Password == "" && isPasswordKey(k) {
|
||||
c.Password = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.Username != "" || c.Password != "" {
|
||||
c.HadFields = true
|
||||
}
|
||||
}
|
||||
|
||||
func isPasswordKey(k string) bool {
|
||||
switch k {
|
||||
case "pwd", "password", "passwd", "pw", "pass", "wp_password", "token":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// jsonPairRe 匹配 JSON 扁平字段:"key": value(value 可为字符串/数字/布尔/null)。
|
||||
// 只取第一层深度已足够覆盖 {"username":"admin","password":123456} 这类破解 payload;
|
||||
// 嵌套对象/数组的内部内容不作为凭据,避免把无关结构误记成密码。
|
||||
var jsonPairRe = regexp.MustCompile(`"([A-Za-z0-9_.\-]{1,64})"\s*:\s*("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|true|false|null)`)
|
||||
|
||||
// parseJSONCredentials 极简 JSON 字段提取,容忍不规范 body,永不 panic。
|
||||
func parseJSONCredentials(raw string, put func(k, v string)) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || (raw[0] != '{' && raw[0] != '[') {
|
||||
return
|
||||
}
|
||||
for _, m := range jsonPairRe.FindAllStringSubmatch(raw, -1) {
|
||||
k := strings.TrimSpace(m[1])
|
||||
v := strings.TrimSpace(m[2])
|
||||
if len(v) >= 2 && v[0] == '"' {
|
||||
if s, err := strconv.Unquote(v); err == nil {
|
||||
v = s
|
||||
} else {
|
||||
v = strings.Trim(v, `"`)
|
||||
}
|
||||
}
|
||||
if v == "null" {
|
||||
v = ""
|
||||
}
|
||||
put(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Describer 返回 Honeypot 的可读描述,供 CLI / 排障使用。
|
||||
func (h *Honeypot) Describer() string {
|
||||
if h == nil || !h.enabled {
|
||||
return "disabled"
|
||||
}
|
||||
keys := make([]string, 0, len(h.prefix))
|
||||
for k := range h.prefix {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return path.Join(keys...)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package honeypot
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
|
||||
hit := []string{
|
||||
"/wp-admin", "/wp-admin/install.php", "/wp-login.php", "/wp-content/plugins/",
|
||||
"/.ssh/id_rsa", "/.env", "/.git/config", "/.aws/credentials",
|
||||
"/administrator", "/phpmyadmin", "/actuator/env", "/swagger-ui.html",
|
||||
"/xmlrpc.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php",
|
||||
"/wp-admin/../wp-content/x.php",
|
||||
}
|
||||
for _, p := range hit {
|
||||
if !h.Match(p) {
|
||||
t.Errorf("Match(%q) = false, want true", p)
|
||||
}
|
||||
}
|
||||
|
||||
// 业务路由必须一律不接管 —— 这是蜜罐的红线。
|
||||
miss := []string{
|
||||
"/", "/login", "/register", "/logout", "/_/alice", "/user", "/user/settings",
|
||||
"/api/v1/questions", "/static/css/base.css", "/favicon.ico", "/robots.txt",
|
||||
"/about", "/rules", "/sponsor", "/change-logs.html", "/vote/abc",
|
||||
"/explore", "/i18n/en",
|
||||
}
|
||||
for _, p := range miss {
|
||||
if h.Match(p) {
|
||||
t.Errorf("Match(%q) = true, want false(业务路由不得被蜜罐接管)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchDisabled(t *testing.T) {
|
||||
h := New(Options{Enabled: false})
|
||||
if h.Match("/wp-admin") {
|
||||
t.Error("蜜罐未启用时不应匹配任何路径")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchEncoded(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
// %2f 编码绕过
|
||||
if !h.Match("/wp-admin%2f..%2fetc") {
|
||||
t.Error("编码后的 wp-admin 路径应被匹配")
|
||||
}
|
||||
// 大小写
|
||||
if !h.Match("/WP-ADMIN") {
|
||||
t.Error("大小写不同的 WP-ADMIN 应被匹配")
|
||||
}
|
||||
// 查询串不应影响判定
|
||||
if !h.Match("/wp-login.php?redirect_to=/wp-admin") {
|
||||
t.Error("带查询串的 wp-login.php 应被匹配")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraPaths(t *testing.T) {
|
||||
h := New(Options{Enabled: true, ExtraPaths: []string{"/secret-backdoor"}})
|
||||
if !h.Match("/secret-backdoor") {
|
||||
t.Error("自定义诱饵路径应被匹配")
|
||||
}
|
||||
if !h.Match("/secret-backdoor/x.php") {
|
||||
t.Error("自定义诱饵路径的子路径应被匹配")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsForm(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
|
||||
strings.NewReader("log=admin&pwd=admin123&redirect_to=%2Fwp-admin%2F&wp-submit=Log+In"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.Username != "admin" {
|
||||
t.Errorf("Username = %q, want admin", creds.Username)
|
||||
}
|
||||
if creds.Password != "admin123" {
|
||||
t.Errorf("Password = %q, want admin123", creds.Password)
|
||||
}
|
||||
if !creds.HadFields {
|
||||
t.Error("HadFields = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsJSON(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
|
||||
strings.NewReader(`{"username":"root","password":"toor","extra":1}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.Username != "root" {
|
||||
t.Errorf("Username = %q, want root", creds.Username)
|
||||
}
|
||||
if creds.Password != "toor" {
|
||||
t.Errorf("Password = %q, want toor", creds.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsQuery(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodGet, "/wp-login.php?log=admin&pwd=secret", nil)
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.Username != "admin" || creds.Password != "secret" {
|
||||
t.Errorf("got %q/%q, want admin/secret", creds.Username, creds.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsBasicAuth(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
|
||||
req.SetBasicAuth("admin", "hunter2")
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.Username != "admin" || creds.Password != "hunter2" {
|
||||
t.Errorf("got %q/%q, want admin/hunter2", creds.Username, creds.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsEmpty(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.HadFields {
|
||||
t.Error("空请求不应标记为携带凭据")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsMalformed(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
// 畸形 body 不应 panic
|
||||
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
|
||||
strings.NewReader("%zz&log=admin&&=="))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.Username != "admin" {
|
||||
t.Errorf("Username = %q, want admin(畸形 body 仍应尽量提取)", creds.Username)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package honeypot
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoginPageVars 是假登录页的渲染参数。
|
||||
type LoginPageVars struct {
|
||||
// Action 是表单提交地址(当前诱饵路径本身)。
|
||||
Action string
|
||||
// Title / Heading 是页面标题与表单标题。
|
||||
Title string
|
||||
Heading string
|
||||
// Notice 是提交失败后的提示语。
|
||||
Notice string
|
||||
// Redir 为原始请求的查询串(WordPress 风格的 ?redirect_to=),原样回填。
|
||||
Redir string
|
||||
}
|
||||
|
||||
// RenderLoginPage 输出仿 WordPress 后台风格的假登录页(200,text/html)。
|
||||
//
|
||||
// 页面刻意包含:
|
||||
// - 隐藏 canary 标记(HTML 注释 + meta),便于日后溯源;
|
||||
// - 经典 WP 字段名 log / pwd / redirect_to —— 让暴力破解脚本原样提交,
|
||||
// 我们就能拿到最干净的用户名与密码对;
|
||||
// - 一个 nginx/Apache 风格的 404 页脚,暗示「这个后台真的存在」,
|
||||
// 但正文是纯静态 HTML,不泄露任何版本、路径或真实凭据。
|
||||
func RenderLoginPage(vars LoginPageVars) []byte {
|
||||
action := html.EscapeString(vars.Action)
|
||||
title := vars.Title
|
||||
if title == "" {
|
||||
title = "Log In ‹ WordPress"
|
||||
}
|
||||
heading := vars.Heading
|
||||
if heading == "" {
|
||||
heading = "WordPress"
|
||||
}
|
||||
notice := ""
|
||||
if vars.Notice != "" {
|
||||
notice = `<div id="login_error">` + html.EscapeString(vars.Notice) + `</div>`
|
||||
}
|
||||
redir := html.EscapeString(vars.Redir)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(4096)
|
||||
b.WriteString(`<!DOCTYPE html>` + "\n")
|
||||
b.WriteString(`<!-- canary: ` + CanaryToken + ` -->` + "\n")
|
||||
b.WriteString(`<html lang="en-US">` + "\n")
|
||||
b.WriteString(`<head>` + "\n")
|
||||
b.WriteString(`<meta charset="UTF-8" />` + "\n")
|
||||
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1" />` + "\n")
|
||||
b.WriteString(`<meta name="generator" content="TamaBox honeypot ` + CanaryToken + `" />` + "\n")
|
||||
b.WriteString(`<title>` + html.EscapeString(title) + `</title>` + "\n")
|
||||
// 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
|
||||
b.WriteString(`<style>` + honeypotCSS + `</style>` + "\n")
|
||||
b.WriteString(`</head>` + "\n")
|
||||
b.WriteString(`<body class="login">` + "\n")
|
||||
b.WriteString(`<div id="login">` + "\n")
|
||||
b.WriteString(`<h1><a href="` + action + `" title="` + html.EscapeString(heading) + `">` + html.EscapeString(heading) + `</a></h1>` + "\n")
|
||||
b.WriteString(notice + "\n")
|
||||
b.WriteString(`<form name="loginform" id="loginform" action="` + action + `" method="post" enctype="application/x-www-form-urlencoded">` + "\n")
|
||||
b.WriteString(`<p><label for="user_login">Username<br /><input type="text" name="log" id="user_login" class="input" value="" size="20" autocapitalize="off" autocomplete="username" /></label></p>` + "\n")
|
||||
b.WriteString(`<p><label for="user_pass">Password<br /><input type="password" name="pwd" id="user_pass" class="input" value="" size="20" autocomplete="current-password" /></label></p>` + "\n")
|
||||
b.WriteString(`<p class="forgetmenot"><input name="rememberme" type="checkbox" id="rememberme" value="forever" /> Remember Me</p>` + "\n")
|
||||
b.WriteString(`<p class="submit"><input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large" value="Log In" /></p>` + "\n")
|
||||
b.WriteString(`<input type="hidden" name="redirect_to" value="` + redir + `" />` + "\n")
|
||||
b.WriteString(`<input type="hidden" name="testcookie" value="1" />` + "\n")
|
||||
b.WriteString(`</form>` + "\n")
|
||||
b.WriteString(`<p id="nav"><a href="` + action + `?action=lostpassword">Lost your password?</a> | <a href="` + action + `?action=register">Register</a></p>` + "\n")
|
||||
b.WriteString(`</div>` + "\n")
|
||||
b.WriteString(`<div id="footer"><p>` + html.EscapeString(NotFoundFooter) + `</p></div>` + "\n")
|
||||
b.WriteString(`</body>` + "\n")
|
||||
b.WriteString(`</html>` + "\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
const honeypotCSS = `html{background:#f0f0f1}body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:14px;color:#3c434a}
|
||||
#login{border:1px solid #dcdcde;background:#fff;width:320px;margin:5em auto;padding:26px 24px 46px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
#login h1{text-align:center;margin:0 0 1em;font-size:24px;font-weight:400}
|
||||
#login h1 a{color:#444;text-decoration:none}
|
||||
#login form{margin-top:16px}
|
||||
#login label{display:block;font-size:14px}
|
||||
#login input[type=text],#login input[type=password]{width:100%;padding:5px 8px;border:1px solid #ddd;border-radius:3px;box-sizing:border-box;margin-top:4px}
|
||||
#login .button{width:100%;padding:7px 12px;background:#2271b1;border:1px solid #2271b1;border-radius:3px;color:#fff;cursor:pointer;font-size:13px}
|
||||
#login .submit{margin:16px 0 0}
|
||||
#login_error{border-left:4px solid #d63638;background:#fcf0f1;padding:10px 12px;margin:12px 0}
|
||||
#nav,.forgetmenot{font-size:13px;color:#50575e}
|
||||
#footer{text-align:center;color:#646970;font-size:12px;margin:24px auto;max-width:320px}
|
||||
`
|
||||
|
||||
// NotFoundFooter 是 404 假页脚,用于给扫描器「后台存在但资源不存在」的错觉。
|
||||
const NotFoundFooter = "The requested resource was not found on this server."
|
||||
|
||||
// LoginFailureNotice 是提交凭据后的统一提示(不区分「用户不存在」与「密码错误」,
|
||||
// 避免因响应差异让爆破脚本调整策略;同时这也是虚假后台「存在」的证据)。
|
||||
const LoginFailureNotice = "ERROR: Invalid username or password."
|
||||
|
||||
// WriteLoginPage 将假登录页写入响应。
|
||||
func WriteLoginPage(w http.ResponseWriter, vars LoginPageVars) {
|
||||
body := RenderLoginPage(vars)
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
// 诱饵页面绝不进入任何共享/边缘缓存,也不被中间层改写。
|
||||
h.Set("X-Robots-Tag", "noindex, nofollow")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
Reference in New Issue
Block a user