Files
tamabox/internal/security/honeypot/honeypot.go
T
tamakyi 7d52155411 fix(honeypot): 蜜罐封禁与 enable_auto_ban 解耦 + 泄露扫描后缀匹配
- autoban: 新增 RecordHoneypot 独立通道(不受 enabled 约束)+ 独立阈值/时长;
  IsBanned 不再因 enable_auto_ban=false 而忽略已有封禁
- honeypot: 新增 honeypotSuffixes 后缀匹配,覆盖 /backup.zip、/db.sql、/id_rsa.pem
- 新增配置 honeypot_threshold / honeypot_ban_minutes
- 补测试:autoban/honeypot_test.go、route 侧禁用自动封禁仍封禁
2026-10-21 12:36:33 +08:00

405 lines
12 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.
// 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",
}
// honeypotSuffixes 是按「路径结尾」匹配的敏感文件后缀。
//
// 为什么单独列:备份/配置类泄露扫描(/backup.zip、/db.sql、/site.tar.gz、/x.bak)
// 的文件名主体是随机的,只有后缀稳定。上一版用子串 "/.zip" 匹配,
// 结果 /backup.zip 命中不了(前面是 "k" 不是 "/"),而 /www.zip、/1.sql 全部漏网。
// 结尾匹配既精确又不会误伤业务路由(业务 URL 空间里不存在这些后缀),
// 且 forbiddenPrefixes 仍会先剔除业务前缀。
var honeypotSuffixes = []string{
".zip", ".tar", ".tar.gz", ".tgz", ".gz", ".rar", ".7z", ".bz2",
".sql", ".sql.gz", ".dump", ".bak", ".old", ".orig", ".save",
".swp", ".env", ".env.bak", ".env.local",
".pem", ".key", ".p12", ".pfx", ".jks", ".kdbx", ".ovpn",
}
// 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
}
}
// 后缀匹配:备份/配置/密钥类泄露扫描(/backup.zip、/db.sql、/id_rsa.pem)。
base := path.Base(p)
for _, s := range honeypotSuffixes {
if strings.HasSuffix(base, s) {
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...)
}