Files
tamabox/internal/security/honeypot/family_test.go
T
tamakyi 811578dcf1 feat(honeypot): 按扫描目标伪装成 9 类后台,静态资源不落库
原来所有诱饵路径都回同一个 WordPress 登录页。爆破工具的标准流程是
「先按路径判断这是什么后台,再拿它认识的字段名填表」——在 /phpmyadmin/index.php
上回 WP 表单,等于告诉对方「这里没有 PMA」,对方直接换目标,既拿不到凭据
也没有取证价值。

新增 family.go(皮肤表)+ asset.go(静态资源占位),page.go 改为按家族渲染:

  wordpress   /wp-login.php /wp-admin /wp-json    log/pwd、wordpress_test_cookie
  phpmyadmin  /phpmyadmin /pma /adminer.php       pma_username/pma_password、hidden token
  tomcat      /manager/html /host-manager         401 + WWW-Authenticate: Basic(无表单)
  jenkins     /jenkins /jnlpJars                  j_username/j_password、X-Jenkins
  grafana     /grafana                            user/password、深色主题
  gitlab      /users/sign_in                      user[login]/user[password]
  luci        /cgi-bin/luci /luci                 luci_username/luci_password
  joomla      /administrator                      username/passwd、hidden option=com_login
  generic     /admin /console /zabbix /jira…      username/password

Tomcat 刻意不给 HTML 表单:真实 Manager 未认证就是 401 + Basic 弹框,给表单
反而更假;Authorization: Basic 本就在 ExtractCredentials 覆盖范围内,捕获能力不减。

凭据字段:
- credentialKeys 扩 pma_* / j_* / luci_* / user[login] / user[password];
- token 只用于判 HadFields,**移出 isPasswordKey** —— PMA/GitLab 的登录表单带
  hidden CSRF token,而 Fields 是 map、遍历无序,原来的实现会让 token 随机
  顶掉真密码,记录下来的「密码」时对时错。

资源占用(这是本次主要约束):
- 渲染一页 3.7µs / 2 次分配 / 5.2KB(改前 12µs / 22 次 / 19.5KB):
  不用 html/template(每次 ParseFS 是毫秒级 IO+编译),改单次 strings.Builder
  并避免 "a"+b+"c" 反复分配,Replacer 提到包级;
- 静态资源请求 55ns / 0 分配:CSS/JS 回常量片段、图片回真 1×1 PNG,
  **不解析 IP、不判封禁、不写库**——真实后台页一次加载 5–10 个资源请求,
  否则等于把页面浏览放大成十几条 INSERT。插件目录下的 .php 回 404(webshell 探测)。

honeypot paths 增加家族汇总,便于确认实际生效的分布。
新增 dev/preview-honeypot:把 9 个家族渲染到 outputs/honeypot-preview/index.html
再配 headless 截图核对外观——本轮两个「源码看不出、截图一眼假」的问题
(Tomcat 忘配 css 导致按钮白字无底色、WP 登录页底部挂着 404 文案)都是它发现的。

测试:TestFamilyOf / TestEveryFamilyRendersItsOwnFields / TestTomcatUses401Basic /
TestAssetFor / TestRenderIsCheap(4KB 上限)/ BenchmarkRenderFamily /
BenchmarkAssetFor,route 层 TestHoneypotServesFamilySpecificPage /
TestHoneypotCapturesFamilyFields / TestHoneypotAssetRequestsAreNotRecorded。
变异验证:资源短路失效 → 3 个资源全变 text/html 且落库;FamilyOf 恒返回
Generic → 页面缺特征字段。两项均按预期变红。
2026-10-23 12:38:35 +08:00

182 lines
6.8 KiB
Go

package honeypot
import (
"bytes"
"net/http/httptest"
"strings"
"testing"
)
func TestFamilyOf(t *testing.T) {
cases := []struct {
path string
want Family
}{
{"/wp-login.php", FamilyWordPress},
{"/wp-admin/setup-config.php", FamilyWordPress},
{"/wp-json/wp/v2/users", FamilyWordPress},
{"/phpmyadmin/index.php", FamilyPhpMyAdmin},
{"/pma", FamilyPhpMyAdmin},
{"/adminer.php", FamilyPhpMyAdmin},
{"/manager/html", FamilyTomcat},
{"/host-manager/html", FamilyTomcat},
{"/jenkins/login", FamilyJenkins},
{"/grafana/login", FamilyGrafana},
{"/users/sign_in", FamilyGitLab},
{"/cgi-bin/luci", FamilyLuCI},
{"/administrator/index.php", FamilyJoomla},
{"/admin", FamilyGeneric},
{"/console/login", FamilyGeneric},
{"/zabbix/index.php", FamilyGeneric},
}
for _, c := range cases {
if got := FamilyOf(c.path); got != c.want {
t.Errorf("FamilyOf(%q) = %v, want %v", c.path, got, c.want)
}
}
}
// TestFamilyOfDoesNotClaimBusinessRoutes 保证分类不会把真实业务路径认成某个后台。
// (是否接管由 Match + forbiddenPrefixes 决定,这里是第二道防线:即便将来
// 诱饵表被改宽,也不该让 /user 被归进 GitLab 家族。)
func TestFamilyOfDoesNotClaimBusinessRoutes(t *testing.T) {
for _, p := range []string{
"/", "/login", "/register", "/user", "/api/v1/questions",
"/explore", "/_/tamakyi", "/vote/abc", "/static/css/base.css",
} {
if fam := FamilyOf(p); fam != FamilyGeneric {
t.Errorf("FamilyOf(%q) = %v, want FamilyGeneric(业务路径不该被归类)", p, fam)
}
}
}
// TestEveryFamilyRendersOwnSkin 逐个家族渲染,断言页面上出现该家族特有的字段名。
//
// 为什么盯字段名:爆破脚本靠字段名决定要不要提交。所有家族都输出 log/pwd,
// 等于对 phpMyAdmin 的爆破工具说「这里不是 PMA」,对方会直接放弃。
func TestEveryFamilyRendersOwnSkin(t *testing.T) {
uniqueField := map[Family]string{
FamilyWordPress: `name="log"`,
FamilyPhpMyAdmin: `name="pma_username"`,
FamilyJenkins: `name="j_username"`,
FamilyGrafana: `name="user"`,
FamilyGitLab: `name="user[login]"`,
FamilyLuCI: `name="luci_username"`,
FamilyJoomla: `name="username"`,
FamilyGeneric: `name="csrf_token"`,
}
for _, fam := range Families() {
body := string(RenderFamily(fam, LoginPageVars{Action: "/probe"}))
if !strings.Contains(body, CanaryToken) {
t.Errorf("%v: 页面缺少 canary", fam)
}
if !strings.Contains(body, `action="/probe"`) {
t.Errorf("%v: 表单 action 未回填请求路径", fam)
}
if want, ok := uniqueField[fam]; ok && !strings.Contains(body, want) {
t.Errorf("%v: 页面缺少特征字段 %s", fam, want)
}
// 每个家族都必须有密码框,否则爆破脚本不会提交。
if !strings.Contains(body, `type="password"`) && fam != FamilyTomcat {
t.Errorf("%v: 页面没有密码输入框", fam)
}
// generator 必须伪装成真实产品,绝不能出现蜜罐标记(扫描器必查项)。
if strings.Contains(body, "TamaBox") || strings.Contains(body, "honeypot") {
t.Errorf("%v: 页面自曝了蜜罐身份", fam)
}
}
}
// TestTomcatFamilyUsesBasicAuth 验证 Tomcat 家族走的是 401 + WWW-Authenticate。
//
// 真实 Tomcat Manager 未认证时就是 401 Basic,给一个 HTML 表单反而不像;
// 而且 Basic 头里的凭据 ExtractCredentials 能直接解析,不损失捕获能力。
func TestTomcatFamilyUsesBasicAuth(t *testing.T) {
rec := httptest.NewRecorder()
WriteFamilyPage(rec, FamilyTomcat, LoginPageVars{Action: "/manager/html"})
if rec.Code != 401 {
t.Errorf("status = %d, want 401", rec.Code)
}
if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Tomcat") {
t.Errorf("WWW-Authenticate = %q, want 含 Tomcat realm", got)
}
}
// TestFamilyHeadersLookReal 检查各家族的指纹响应头。
// 这些头是扫描器的第一判断依据,比页面内容更早被读到。
func TestFamilyHeadersLookReal(t *testing.T) {
cases := []struct {
fam Family
header string
wantSub string
}{
{FamilyWordPress, "X-Powered-By", "PHP"},
{FamilyPhpMyAdmin, "X-Powered-By", "PHP"},
{FamilyJenkins, "X-Jenkins", "2.401"},
{FamilyGrafana, "X-Grafana-Org-Id", "1"},
}
for _, c := range cases {
rec := httptest.NewRecorder()
WriteFamilyPage(rec, c.fam, LoginPageVars{Action: "/x"})
if got := rec.Header().Get(c.header); !strings.Contains(got, c.wantSub) {
t.Errorf("%v: %s = %q, want 含 %q", c.fam, c.header, got, c.wantSub)
}
}
// WordPress 真实登录页会下发 test cookie,爆破工具常拿它判断会话是否有效。
rec := httptest.NewRecorder()
WriteFamilyPage(rec, FamilyWordPress, LoginPageVars{Action: "/wp-login.php"})
if !strings.Contains(strings.Join(rec.Header().Values("Set-Cookie"), ";"), "wordpress_test_cookie") {
t.Error("WordPress 家族缺少 wordpress_test_cookie")
}
}
func TestAssetFor(t *testing.T) {
if body, ctype, status, ok := AssetFor("/wp-admin/css/login.min.css"); !ok || status != 200 ||
!strings.Contains(ctype, "text/css") || len(body) == 0 {
t.Errorf("css 资源未命中:ok=%v status=%d ctype=%q", ok, status, ctype)
}
if _, ctype, _, ok := AssetFor("/wp-includes/js/jquery/jquery.min.js"); !ok || !strings.Contains(ctype, "javascript") {
t.Errorf("js 资源 ctype = %q", ctype)
}
if body, _, _, ok := AssetFor("/wp-admin/images/logo.png"); !ok || !bytes.HasPrefix(body, []byte{0x89, 'P', 'N', 'G'}) {
t.Error("png 资源内容不是合法 PNG")
}
// 插件目录下的 php 是 webshell 探测,回 404 比回登录页更真实
if _, _, status, ok := AssetFor("/wp-content/plugins/x.php"); !ok || status != 404 {
t.Errorf("未知后缀 status = %d, want 404", status)
}
// 不在资源前缀内的一律交回登录页逻辑
for _, p := range []string{"/admin", "/wp-login.php", "/static/css/base.css", "/assets/app.js"} {
if _, _, _, ok := AssetFor(p); ok {
t.Errorf("%q 不该被当成页面资源", p)
}
}
}
// TestRenderIsCheap 粗略守住「单页体积」,防止有人往 skin 里塞大段样式/脚本。
func TestRenderIsCheap(t *testing.T) {
for _, fam := range Families() {
if n := len(RenderFamily(fam, LoginPageVars{Action: "/x"})); n > 4096 {
t.Errorf("%v: 页面体积 %d 字节,超过 4KB 预算", fam, n)
}
}
}
// BenchmarkRenderFamily 量化「渲染一个伪装页」的成本。
// 蜜罐会被扫描器高频命中,这里刻意守住:单页只做一次 Builder 拼接。
func BenchmarkRenderFamily(b *testing.B) {
vars := LoginPageVars{Action: "/wp-login.php", Redir: "/wp-admin/"}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = RenderFamily(FamilyWordPress, vars)
}
}
// BenchmarkAssetFor 资源请求必须零分配:一次页面加载会打 5–10 个资源请求。
func BenchmarkAssetFor(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _, _, _ = AssetFor("/wp-admin/css/login.min.css")
}
}