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") } } // hiddenValue 取某个 hidden input 的 value。 func hiddenValue(body, name string) string { marker := `name="` + name + `" value="` i := strings.Index(body, marker) if i < 0 { return "" } rest := body[i+len(marker):] if j := strings.IndexByte(rest, '"'); j >= 0 { return rest[:j] } return "" } // TestDynamicTokensDifferPerRequest 守住「每次请求的 token 必须不同」。 // // 恒定值是最容易被指纹识别的特征:连打两次拿到一模一样的 CSRF token, // 任何脚本都能判定对面是伪造的 —— 而这正是「伪装得像」的核心。 func TestDynamicTokensDifferPerRequest(t *testing.T) { cases := []struct { fam Family name string size int }{ {FamilyPhpMyAdmin, "token", 32}, {FamilyGeneric, "csrf_token", 16}, } for _, c := range cases { first := string(RenderFamily(c.fam, LoginPageVars{Action: "/x"})) second := string(RenderFamily(c.fam, LoginPageVars{Action: "/x"})) v1, v2 := hiddenValue(first, c.name), hiddenValue(second, c.name) if v1 == "" || v2 == "" { t.Fatalf("%s: 取不到 %s 的值", c.fam, c.name) } if v1 == v2 { t.Errorf("%s 的 %s 两次相同(%q)—— 恒定值可被指纹识别", c.fam, c.name, v1) } for _, v := range []string{v1, v2} { if len(v) != c.size { t.Errorf("%s 的 %s 长度 = %d, want %d(%q)", c.fam, c.name, len(v), c.size, v) } if strings.Trim(v, "0123456789abcdef") != "" { t.Errorf("%s 的 %s = %q, 含非 hex 字符(真实产品只给 hex)", c.fam, c.name, v) } } } } // TestHeadersDynamicValues 守住响应头里的动态值:Jenkins 会话号、GitLab 关联 id。 func TestHeadersDynamicValues(t *testing.T) { got := func(f Family, header string) string { rec := httptest.NewRecorder() WriteFamilyPage(rec, f, LoginPageVars{Action: "/x"}) return rec.Header().Get(header) } s1, s2 := got(FamilyJenkins, "X-Jenkins-Session"), got(FamilyJenkins, "X-Jenkins-Session") if len(s1) != 8 || s1 == s2 { t.Errorf("X-Jenkins-Session = %q / %q, want 两个不同的 8 位 hex", s1, s2) } g1, g2 := got(FamilyGitLab, "X-Gitlab-Meta"), got(FamilyGitLab, "X-Gitlab-Meta") if g1 == g2 || !strings.Contains(g1, `"correlation_id":"`) { t.Errorf("X-Gitlab-Meta = %q / %q, want 每次不同的 correlation_id", g1, g2) } // 会话 cookie 同样不能恒定。 if c1, c2 := got(FamilyGitLab, "Set-Cookie"), got(FamilyGitLab, "Set-Cookie"); c1 == c2 { t.Errorf("Set-Cookie = %q 两次相同", c1) } } // TestPingbackAbsoluteURL 守住 X-Pingback 必须是绝对地址: // 真实 WordPress 给的是 "https://host/xmlrpc.php",相对路径本身就是破绽。 // 没配置 external_url 时宁可不发这个头。 func TestPingbackAbsoluteURL(t *testing.T) { rec := httptest.NewRecorder() WriteFamilyPage(rec, FamilyWordPress, LoginPageVars{ Action: "/wp-login.php", BaseURL: "https://box.test/", }) if got := rec.Header().Get("X-Pingback"); got != "https://box.test/xmlrpc.php" { t.Errorf("X-Pingback = %q, want 绝对地址(尾部斜杠要折叠)", got) } rec = httptest.NewRecorder() WriteFamilyPage(rec, FamilyWordPress, LoginPageVars{Action: "/wp-login.php"}) if got := rec.Header().Get("X-Pingback"); got != "" { t.Errorf("X-Pingback = %q, want 不发(相对路径比不发更假)", got) } } // TestNoUnresolvedPlaceholders 守住渲染后不残留 $ 占位符 —— // 漏展开会在页面上留下 "$hex32" 这种一眼假的值。 func TestNoUnresolvedPlaceholders(t *testing.T) { for _, fam := range Families() { rec := httptest.NewRecorder() WriteFamilyPage(rec, fam, LoginPageVars{ Action: "/x", Redir: "/wp-admin/", BaseURL: "https://box.test", }) body := rec.Body.String() if strings.Contains(body, "$hex") || strings.Contains(body, "$base") { t.Errorf("%s 页面残留占位符:%s", fam, body) } for k, vs := range rec.Header() { for _, v := range vs { if strings.Contains(v, "$hex") || strings.Contains(v, "$base") { t.Errorf("%s 响应头 %s 残留占位符:%q", fam, k, v) } } } } }