package honeypot import ( "net/http" "net/http/httptest" "strings" "testing" "unicode/utf8" ) func TestMatch(t *testing.T) { h := New(Options{Enabled: true}) hit := []string{ "/wp-admin", "/wp-admin/install.php", "/wp-login.php", "/wp-content/plugins/", "/.ssh/id_rsa", "/.env", "/.git/config", "/.aws/credentials", "/administrator", "/phpmyadmin", "/actuator/env", "/swagger-ui.html", "/xmlrpc.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php", "/wp-admin/../wp-content/x.php", // 后缀匹配:备份/配置/密钥泄露扫描 "/backup.zip", "/www.zip", "/site.tar.gz", "/db.sql", "/dump.sql", "/x.bak", "/config.php.bak", "/.env.bak", "/id_rsa.pem", "/private.key", "/server.jks", "/data.ovpn", } for _, p := range hit { if !h.Match(p) { t.Errorf("Match(%q) = false, want true", p) } } // 业务路由必须一律不接管 —— 这是蜜罐的红线。 miss := []string{ "/", "/login", "/register", "/logout", "/_/alice", "/user", "/user/settings", "/api/v1/questions", "/static/css/base.css", "/favicon.ico", "/robots.txt", "/about", "/rules", "/sponsor", "/change-logs.html", "/vote/abc", "/explore", "/i18n/en", // 业务 URL 空间里不存在这些后缀 "/_/admin.ini", "/api/v1/questions.json", "/user/settings.yaml", } for _, p := range miss { if h.Match(p) { t.Errorf("Match(%q) = true, want false(业务路由不得被蜜罐接管)", p) } } } func TestMatchDisabled(t *testing.T) { h := New(Options{Enabled: false}) if h.Match("/wp-admin") { t.Error("蜜罐未启用时不应匹配任何路径") } } func TestMatchEncoded(t *testing.T) { h := New(Options{Enabled: true}) // %2f 编码绕过 if !h.Match("/wp-admin%2f..%2fetc") { t.Error("编码后的 wp-admin 路径应被匹配") } // 大小写 if !h.Match("/WP-ADMIN") { t.Error("大小写不同的 WP-ADMIN 应被匹配") } // 查询串不应影响判定 if !h.Match("/wp-login.php?redirect_to=/wp-admin") { t.Error("带查询串的 wp-login.php 应被匹配") } } func TestExtraPaths(t *testing.T) { h := New(Options{Enabled: true, ExtraPaths: []string{"/secret-backdoor"}}) if !h.Match("/secret-backdoor") { t.Error("自定义诱饵路径应被匹配") } if !h.Match("/secret-backdoor/x.php") { t.Error("自定义诱饵路径的子路径应被匹配") } } func TestExtractCredentialsForm(t *testing.T) { h := New(Options{Enabled: true}) req := httptest.NewRequest(http.MethodPost, "/wp-login.php", strings.NewReader("log=admin&pwd=admin123&redirect_to=%2Fwp-admin%2F&wp-submit=Log+In")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") creds := h.ExtractCredentials(req) if creds.Username != "admin" { t.Errorf("Username = %q, want admin", creds.Username) } if creds.Password != "admin123" { t.Errorf("Password = %q, want admin123", creds.Password) } if !creds.HadFields { t.Error("HadFields = false, want true") } } func TestExtractCredentialsJSON(t *testing.T) { h := New(Options{Enabled: true}) req := httptest.NewRequest(http.MethodPost, "/wp-login.php", strings.NewReader(`{"username":"root","password":"toor","extra":1}`)) req.Header.Set("Content-Type", "application/json") creds := h.ExtractCredentials(req) if creds.Username != "root" { t.Errorf("Username = %q, want root", creds.Username) } if creds.Password != "toor" { t.Errorf("Password = %q, want toor", creds.Password) } } func TestExtractCredentialsQuery(t *testing.T) { h := New(Options{Enabled: true}) req := httptest.NewRequest(http.MethodGet, "/wp-login.php?log=admin&pwd=secret", nil) creds := h.ExtractCredentials(req) if creds.Username != "admin" || creds.Password != "secret" { t.Errorf("got %q/%q, want admin/secret", creds.Username, creds.Password) } } func TestExtractCredentialsBasicAuth(t *testing.T) { h := New(Options{Enabled: true}) req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil) req.SetBasicAuth("admin", "hunter2") creds := h.ExtractCredentials(req) if creds.Username != "admin" || creds.Password != "hunter2" { t.Errorf("got %q/%q, want admin/hunter2", creds.Username, creds.Password) } } func TestExtractCredentialsEmpty(t *testing.T) { h := New(Options{Enabled: true}) req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil) creds := h.ExtractCredentials(req) if creds.HadFields { t.Error("空请求不应标记为携带凭据") } } func TestExtractCredentialsMalformed(t *testing.T) { h := New(Options{Enabled: true}) // 畸形 body 不应 panic req := httptest.NewRequest(http.MethodPost, "/wp-login.php", strings.NewReader("%zz&log=admin&&==")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") creds := h.ExtractCredentials(req) if creds.Username != "admin" { t.Errorf("Username = %q, want admin(畸形 body 仍应尽量提取)", creds.Username) } } // TestClipRunesKeepsValidUTF8 守住「按字节截断」的回归。 // // 凭据是直接落库的,非法 UTF-8 会让 PostgreSQL 以 // `invalid byte sequence for encoding "UTF8"` 拒绝整条插入 —— 取证记录静默丢失。 // 攻击者只要发一个超长中文密码就能稳定触发,所以这是可达路径。 func TestClipRunesKeepsValidUTF8(t *testing.T) { s := strings.Repeat("密码", 400) // 2400 字节 / 800 字符 got := clipRunes(s, 512) if !utf8.ValidString(got) { t.Fatal("截断结果不是合法 UTF-8(说明退回了字节截断)") } if n := len([]rune(got)); n != 512 { t.Errorf("截断后字符数 = %d, want 512", n) } // 确认这个用例真的能区分两种实现:字节截断在 512 处必然切在字符中间。 if utf8.ValidString(s[:512]) { t.Fatal("测试素材本身能被字节安全截断,用例失去区分力") } } // TestExtractCredentialsIgnoresCommonJSONFields 守住误报回归。 // // "id" / "name" 是任意 JSON 里最常见的字段。把它们当凭据会让 // 「扫描器 POST 了一个无关探测」被记成破解提交,并触发假的告警邮件。 func TestExtractCredentialsIgnoresCommonJSONFields(t *testing.T) { h := New(Options{Enabled: true}) req := httptest.NewRequest(http.MethodPost, "/api/v1/thing", strings.NewReader(`{"id":1,"name":"probe","status":"ok"}`)) req.Header.Set("Content-Type", "application/json") creds := h.ExtractCredentials(req) if creds.HadFields { t.Errorf("普通 JSON 探测不应被判定为凭据提交(Username=%q Password=%q)", creds.Username, creds.Password) } } // TestSplitPaths 覆盖自定义诱饵路径的解析与归一化。 func TestSplitPaths(t *testing.T) { raw := " /admin, foo;bar\n/baz " got := SplitPaths(raw) want := []string{"/admin", "/foo", "/bar", "/baz"} if len(got) != len(want) { t.Fatalf("SplitPaths() = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Errorf("[%d] = %q, want %q", i, got[i], want[i]) } } // 未写前导斜杠的项也应在 New 里被归一化,否则永远匹配不上。 h := New(Options{Enabled: true, ExtraPaths: SplitPaths("foo")}) if !h.Match("/foo/bar") { t.Error("自定义路径 foo 归一化后应能匹配 /foo/bar") } // 根路径会让所有 404 都变成假登录页,必须拒绝。 if got := SplitPaths("/"); len(got) != 0 { t.Errorf(`SplitPaths("/") = %v, want 空(根路径必须拒绝)`, got) } }