线上验收发现两个「能被指纹识别」的破绽,都不影响原有功能但削弱伪装: 一、/xmlrpc.php 回的是 HTML 登录页(P1) 我们自己发的响应头里就有 X-Pingback: /xmlrpc.php —— 扫描器顺着跟过来, 一看是 HTML 就知道整站是假的(真实 WordPress 在这里回 text/xml)。 更可惜的是 system.multicall 这条通道被浪费:一次请求能带上千组用户名/密码、 且绕开登录页限速插件,是 WP 爆破里产出最高的入口。 新增 xmlrpc.go: - 非 POST → 405 + fault「XML-RPC services are disabled on this site.」(与真实 WP 一致) - POST methodCall → 200 + fault「Incorrect username or password.」(不区分用户是否存在) - POST system.listMethods → 200 + 方法列表(枚举类工具靠它确认对面是真 WP) - 凭据提取刻意不上 encoding/xml(蜜罐被高频命中):抓所有 <string>、剔除 methodName 与站点 URL 后两两配对。wp.getUsersBlogs 的 blog_id 若是短数字会让 配对错位一位,故「奇数长度 + 首项短数字」时丢首项;另外原始串全量写进 fields(xmlrpc.0/1/…),配对失败也不丢原文。 multicall 的第一组进标准字段,其余进 cred2/cred3… —— 一个用户名配一长串 密码是常见字典形态,只留第一组等于丢掉整份字典。 - 封禁与表单提交同规则:带凭据走 RecordHoneypot(立即封),否则走常规阈值。 二、进入页面的隐藏值每次请求都一样(P2) 两次请求拿到同一个 pma token / csrf_token / X-Jenkins-Session,任何脚本都能 判定对面是伪造的。改为按请求生成 32 位 hex(math/rand/v2 —— 这些是假 token, 不需要不可预测,只需要每次不同;crypto/rand 在 Windows 要走系统调用,贵一个量级), 页头、hidden 与 Set-Cookie 共用同一套。 顺带: - X-Pingback 改绝对地址($base/xmlrpc.php)。真实 WP 给绝对 URL,相对路径本身 就是破绽;没配 external_url 时干脆不发这个头。 - Cache-Control 从 no-store 改 no-cache, must-revalidate, max-age=0 (no-store 更像安全设备/代理加的,不像登录页)。 - Grafana / GitLab 补上真实产品会种的会话 cookie(grafana_sess、_gitlab_session)。 - Match 增加 .php 变体兜底:清单里是 /phpinfo,扫描器打 /phpinfo.php, 原来漏掉 → 302 回首页,等于明确告诉对方「这个文件不存在」。 测试:xmlrpc_test(IsXMLRPCPath / getUsersBlogs / multicall / 数字 blog_id / 非 XML-RPC body / fault 与 listMethods 输出)、family_test(动态 token 两次不同且 是合法 hex、X-Jenkins-Session 与 correlation_id 动态、Pingback 绝对地址与缺省不发、 所有家族渲染后不残留 $ 占位符)、route 层(POST multicall 捕获凭据 + 立即封 + 回 text/xml;GET 回 405 fault 而非登录页)、honeypot(.php 变体命中且不牵连业务路径)。 四处变异验证均按预期变红:固定 token、xmlrpc 分支失效、Pingback 退化成相对路径、 .php 兜底失效。 性能:RenderFamily 3.7µs→4.18µs、2→3 allocs(多的一次是 hex 编码), AssetFor 仍 0 分配。
501 lines
17 KiB
Go
501 lines
17 KiB
Go
// Package honeypot 提供「虚假后台登录界面」诱饵能力。
|
||
//
|
||
// 设计原则(务必遵守):
|
||
// 1. 只返回假内容,绝不返回、绝不猜测任何真实凭据;
|
||
// 2. 只在 flamego 的 NotFound 分流点生效 —— 即「没有任何路由匹配」的路径,
|
||
// 因此与 /login、/register、/_/{domain}、/user/**、/api/v1/** 等真实业务
|
||
// 路由天然零交集,不会劫持正常用户;
|
||
// 3. 命中即短路:不查业务表、不渲染业务模板、不调用 ctx.Next()。
|
||
package honeypot
|
||
|
||
import (
|
||
"net/http"
|
||
"net/url"
|
||
"path"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// CanaryToken 是所有假页面统一的溯源标记。它只会出现在诱饵响应里,
|
||
// 一旦在别处(真实页面、错误日志)出现即可判定为蜜罐泄漏/误挂。
|
||
const CanaryToken = "tb-hp-7f3a1c9e"
|
||
|
||
// maxCredentialFieldLen 限制单个凭据字段落库长度,避免超长 payload 撑爆表。
|
||
const maxCredentialFieldLen = 512
|
||
|
||
// clipRunes 按字符(rune)截断字符串。
|
||
//
|
||
// 为什么不能用 s[:n]:按字节切会把多字节 UTF-8 字符劈成半个序列,得到非法
|
||
// UTF-8。凭据是直接落库的,PostgreSQL 会以 `invalid byte sequence for encoding
|
||
// "UTF8"` 拒绝整条插入 —— 取证记录就这么丢了,而且只在日志里留一行 warn。
|
||
//
|
||
// 攻击者可以随便发一个 600 字节的中文密码来稳定触发这个问题,所以这里是
|
||
// 可达路径,不是理论风险。
|
||
func clipRunes(s string, n int) string {
|
||
// 字节数已不超过 n,字符数必然也不超过,无需转换。
|
||
if n <= 0 || len(s) <= n {
|
||
return s
|
||
}
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n])
|
||
}
|
||
|
||
// 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",
|
||
// 其它常见后台/运维面板:每条都对应 family.go 里的一个伪装家族,
|
||
// 这些前缀同样与业务路由零交集(业务只有 /user、/login、/_/ 等)。
|
||
"/jenkins", "/jnlpJars", "/grafana", "/users/sign_in", "/users/password",
|
||
"/users/sign_up", "/luci", "/monitoring", "/zabbix", "/nagios",
|
||
"/jira", "/confluence", "/gitlab", "/nextcloud", "/owncloud",
|
||
"/webmail", "/roundcube", "/whm", "/host-manager", "/jmx-console",
|
||
"/web-console", "/invoker",
|
||
"/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",
|
||
}
|
||
|
||
// SplitPaths 解析配置中的自定义诱饵路径(逗号 / 分号 / 空白 / 换行分隔)。
|
||
//
|
||
// 放在本包而不是 route:CLI 的 `honeypot paths` 也要用同一份解析规则,
|
||
// 两边各写一遍迟早漂移(一处补了分号、另一处没补),而配置解析不一致
|
||
// 会让「我配了但没生效」这类问题更难查。
|
||
func SplitPaths(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
|
||
}
|
||
// 在这里就补前导斜杠:CLI 会把这个结果直接打给运维看,
|
||
// 显示 "foo" 而实际生效 "/foo" 会让人以为是两个不同的东西
|
||
// (New 里的 normalize 是幂等的,重复补不会出问题)。
|
||
if !strings.HasPrefix(f, "/") {
|
||
f = "/" + f
|
||
}
|
||
// 拒绝根路径:"/" 会让**所有** 404 都变成假登录页 —— 正常访客输错
|
||
// 网址也看到 WordPress 后台,是灾难级的配置失误,几乎不可能是本意。
|
||
if f == "/" {
|
||
continue
|
||
}
|
||
out = append(out, f)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// .php 变体:清单里写的是 /phpinfo,而扫描器打的是 /phpinfo.php。
|
||
// 段前缀匹配按 '/' 与 '.' 做边界,但 /phpinfo.php 的 '.' 后面直接是 "php",
|
||
// 段前缀拼出来的 /phpinfo.php 本身不在集合里,于是会漏掉 ——
|
||
// 漏掉的表现是 302 回首页,等于明确告诉对方「这个文件不存在」,
|
||
// 而真跑着 PHP 的站点会回 200 或 403。
|
||
if strings.HasSuffix(p, ".php") {
|
||
if _, ok := h.prefix[strings.TrimSuffix(p, ".php")]; 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 均适用)。
|
||
//
|
||
// ⚠️ 刻意不收 "id" / "name":这两个是任意 JSON 里最常见的字段,
|
||
// 收进来会让「扫描器 POST 了一个带 {"id":1} 的无关探测」被判定为凭据提交,
|
||
// 既污染取证数据,也会触发一封假的「捕获到破解尝试」告警邮件。
|
||
var credentialKeys = map[string]bool{
|
||
"log": true, "pwd": true, "username": true, "user": true, "pass": true,
|
||
"password": true, "passwd": true, "pw": true, "email": true,
|
||
"account": true, "login": true, "wp_user": true, "user_login": true,
|
||
"wp_password": true, "uname": true, "nickname": true, "token": true,
|
||
// 其它家族的表单字段名(见 family.go):
|
||
"pma_username": true, "pma_password": true, // phpMyAdmin
|
||
"j_username": true, "j_password": true, // Jenkins / Tomcat
|
||
"luci_username": true, "luci_password": true, // OpenWrt LuCI
|
||
"user[login]": true, "user[password]": true, // GitLab
|
||
}
|
||
|
||
// 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 = clipRunes(v, limit)
|
||
}
|
||
if _, exists := out.Fields[k]; !exists {
|
||
out.Fields[k] = v
|
||
}
|
||
// 注意:token 只算「凭据字段」(用于判定 HadFields),不算密码。
|
||
// phpMyAdmin / GitLab 的登录表单会带一个 hidden CSRF token,
|
||
// 把它当密码会让真正的 pma_password 被随机覆盖
|
||
// (map 遍历无固定顺序,"密码"会时对时错)。
|
||
switch k {
|
||
case "log", "username", "user", "login", "user_login", "email", "account", "uname", "wp_user", "nickname",
|
||
"pma_username", "j_username", "luci_username", "user[login]":
|
||
if out.Username == "" {
|
||
out.Username = v
|
||
}
|
||
case "pwd", "password", "passwd", "pw", "pass", "wp_password",
|
||
"pma_password", "j_password", "luci_password", "user[password]":
|
||
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 = clipRunes(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",
|
||
"pma_password", "j_password", "luci_password", "user[password]":
|
||
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)
|
||
}
|
||
}
|
||
|
||
// Prefixes 返回当前生效的诱饵路径清单(已归一化、已排序),供 CLI / 排障使用。
|
||
//
|
||
// 之前的 Describer 用 path.Join(keys...) 把全部前缀拼成一条路径(形如
|
||
// "/admin/wp-admin/..."),语义是错的;而且全代码库零调用。改为返回切片,
|
||
// 调用方自己决定怎么展示。
|
||
func (h *Honeypot) Prefixes() []string {
|
||
if h == nil || !h.enabled {
|
||
return nil
|
||
}
|
||
out := make([]string, 0, len(h.prefix))
|
||
for k := range h.prefix {
|
||
out = append(out, k)
|
||
}
|
||
// 用简单插入排序替代 sort 包:前缀数量在几十个量级,且这样不必让
|
||
// 排序依赖渗透进整个包的 import 列表。
|
||
for i := 1; i < len(out); i++ {
|
||
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||
out[j], out[j-1] = out[j-1], out[j]
|
||
}
|
||
}
|
||
return out
|
||
}
|