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") } }