miguan v1

This commit is contained in:
tamakyi
2026-10-21 12:09:28 +08:00
parent 2d4609dc0e
commit 79051acae8
28 changed files with 2128 additions and 182 deletions
+383
View File
@@ -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...)
}