Files
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

404 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package notify
import (
"errors"
"strings"
"testing"
)
func TestMaskedValueHidesBodyButKeepsTailHint(t *testing.T) {
const secret = "https://oapi.dingtalk.com/robot/send?access_token=abcdef123456"
got := MaskedValue(secret)
if strings.Contains(got, "abcdef123456") {
t.Fatalf("掩码值泄露了完整凭据: %q", got)
}
if strings.Contains(got, "oapi.dingtalk.com") {
t.Fatalf("掩码值不应暴露地址主体: %q", got)
}
// 末 6 位要保留,用户才能认出是哪个机器人。
if !strings.HasSuffix(got, "123456") {
t.Fatalf("应保留末 6 位作为辨识提示: %q", got)
}
if !IsMasked(got) {
t.Fatalf("掩码值必须能被 IsMasked 识别: %q", got)
}
}
func TestMaskedValueShortSecretGivesNoHint(t *testing.T) {
// 短凭据如果也暴露末 6 位,等于把整个凭据暴露出去。
for _, s := range []string{"abc", "abcdef", ""} {
got := MaskedValue(s)
if got != MaskedPrefix {
t.Fatalf("长度 %d 的凭据不应给出尾部提示,得到 %q", len(s), got)
}
if s != "" && strings.Contains(got, s) {
t.Fatalf("掩码值包含了原值: %q", got)
}
}
}
func TestMaskConfigMasksOnlySecrets(t *testing.T) {
cfg := map[string]any{
"webhook": "https://example.com/hook?token=SECRETVALUE",
"secret": "SECtest123456",
"port": float64(587),
"host": "smtp.example.com",
}
masked := MaskConfig(KindDingTalk, cfg)
for _, k := range []string{"webhook", "secret"} {
s, _ := masked[k].(string)
if !IsMasked(s) {
t.Errorf("%s 应被掩码,得到 %q", k, s)
}
}
// 非凭据字段必须原样保留,否则 UI 无法展示。
if masked["port"] != float64(587) {
t.Errorf("非凭据字段 port 不应改动: %v", masked["port"])
}
}
func TestMaskConfigUnknownKindReturnsEmpty(t *testing.T) {
// 渠道类型无法识别时,宁可让 UI 显示空配置,也不要把可能含凭据的原始内容吐回去。
got := MaskConfig("nope", map[string]any{"webhook": "https://x/y?token=LEAK"})
if len(got) != 0 {
t.Fatalf("未知渠道类型应返回空配置,得到 %v", got)
}
}
func TestMaskConfigDoesNotMutateInput(t *testing.T) {
// 掩码是展示层行为,不能反过来把库里的真值改掉。
cfg := map[string]any{"webhook": "https://example.com/hook", "secret": "SECtest123456"}
_ = MaskConfig(KindDingTalk, cfg)
if IsMasked(cfg["secret"].(string)) {
t.Fatal("MaskConfig 修改了入参,会导致真实凭据被掩码值覆盖")
}
}
func TestMergeConfigKeepsStoredOnMaskedIncoming(t *testing.T) {
stored := map[string]any{"webhook": "https://real/hook", "secret": "REALSECRET", "method": "POST"}
// 用户只改了 method,浏览器提交的是掩码值+新 method。
incoming := map[string]any{
"webhook": MaskedValue("https://real/hook"),
"secret": MaskedValue("REALSECRET"),
"method": "PUT",
}
got := MergeConfig(stored, incoming)
if got["webhook"] != "https://real/hook" || got["secret"] != "REALSECRET" {
t.Fatalf("掩码字段应保留库中原值,得到 %v", got)
}
if got["method"] != "PUT" {
t.Fatalf("被修改的字段应生效,得到 %v", got["method"])
}
}
func TestMergeConfigEmptyStringClears(t *testing.T) {
stored := map[string]any{"webhook": "https://real/hook", "secret": "REALSECRET"}
got := MergeConfig(stored, map[string]any{"secret": ""})
if _, ok := got["secret"]; ok {
t.Fatalf("空串应清空该字段,得到 %v", got)
}
// 没提到的字段保留(局部更新语义)。
if got["webhook"] != "https://real/hook" {
t.Fatalf("未提及的字段应保留,得到 %v", got)
}
}
func TestMergeConfigKeepsUnmentionedStoredKeys(t *testing.T) {
stored := map[string]any{"host": "smtp.example.com", "port": float64(587), "password": "pw"}
got := MergeConfig(stored, map[string]any{"port": float64(465)})
if got["host"] != "smtp.example.com" || got["password"] != "pw" {
t.Fatalf("未提及的字段应保留,得到 %v", got)
}
if got["port"] != float64(465) {
t.Fatalf("已提及的字段应更新,得到 %v", got["port"])
}
}
// TestPrepareConfigUpdateBlocksDestinationSwap 是本包最重要的一条安全不变量:
// **改目标地址不能把旧凭据带过去**。
//
// 这些用例用的正是攻击形状的输入(只改地址、对凭据避而不谈),
// 而不是「防御逻辑的正确输入」——只测后者的话,防御没生效也照样全绿。
func TestPrepareConfigUpdateBlocksDestinationSwap(t *testing.T) {
cases := []struct {
name string
kind string
stored map[string]any
incoming map[string]any
// wantMissing 是预期被点名的凭据键。
wantMissing string
}{
{
name: "通用 Webhook 改地址想沿用 Authorization 头",
kind: KindWebhook,
stored: map[string]any{
"url": "https://legit.example.com/hook",
"headers": map[string]any{"Authorization": "Bearer REAL-TOKEN"},
},
incoming: map[string]any{"url": "https://attacker.tld/c"},
wantMissing: "headers",
},
{
name: "Telegram 改 base_url 想把 Bot Token 发到自己的端点",
kind: KindTelegram,
stored: map[string]any{"bot_token": "123456:REAL", "chat_id": "1", "base_url": "https://api.telegram.org"},
incoming: map[string]any{"base_url": "https://attacker.tld"},
wantMissing: "bot_token",
},
{
name: "邮件改 SMTP 主机想交出密码",
kind: KindEmail,
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "password": "REALPW", "from": "a@b.c", "to": []any{"d@e.f"}},
incoming: map[string]any{"host": "smtp.attacker.tld"},
wantMissing: "password",
},
{
name: "邮件关掉 TLS 也必须重新表态密码",
kind: KindEmail,
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "tls": false, "password": "REALPW", "from": "a@b.c", "to": []any{"d@e.f"}},
incoming: map[string]any{"tls": true},
wantMissing: "password",
},
{
// 掩码值 = 「沿用旧凭据」,在地址变更的语境下同样必须拒绝。
name: "回传掩码凭据 + 新地址",
kind: KindTelegram,
stored: map[string]any{"bot_token": "123456:REAL", "chat_id": "1", "base_url": "https://api.telegram.org"},
incoming: map[string]any{"base_url": "https://attacker.tld", "bot_token": MaskedValue("123456:REAL")},
wantMissing: "bot_token",
},
{
name: "钉钉改 Webhook 想沿用加签密钥",
kind: KindDingTalk,
stored: map[string]any{"webhook": "https://oapi.dingtalk.com/robot/send?access_token=OLD", "secret": "REALSEC"},
incoming: map[string]any{"webhook": "https://attacker.tld/hook"},
wantMissing: "secret",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
merged, err := PrepareConfigUpdate(tc.kind, tc.stored, tc.incoming)
if err == nil {
t.Fatalf("改地址却未重新表态凭据,应当被拒绝;得到配置 %v", merged)
}
var target *ErrDestinationChangedWithoutCredentials
if !errors.As(err, &target) {
t.Fatalf("应返回专门的错误类型以便接口给出可操作提示,得到 %T: %v", err, err)
}
found := false
for _, m := range target.Missing {
if m == tc.wantMissing {
found = true
}
}
if !found {
t.Fatalf("应点名缺失的凭据键 %q,得到 %v", tc.wantMissing, target.Missing)
}
// 错误信息要能指导操作者怎么修。
if !strings.Contains(err.Error(), tc.wantMissing) {
t.Errorf("错误信息应提到 %q: %v", tc.wantMissing, err)
}
})
}
}
// TestPrepareConfigUpdateAllowsLegitimateEdits 反向用例:正常的编辑不能被误拦,
// 否则这个防护会因为「太烦」而被绕过或删掉。
func TestPrepareConfigUpdateAllowsLegitimateEdits(t *testing.T) {
cases := []struct {
name string
kind string
stored map[string]any
incoming map[string]any
}{
{
name: "只改名字(配置原样回传)",
kind: KindWebhook,
stored: map[string]any{"url": "https://legit.example.com/hook", "headers": map[string]any{"Authorization": "Bearer REAL"}},
incoming: map[string]any{"url": MaskedValue("https://legit.example.com/hook")},
},
{
name: "只改请求方法,地址与凭据都不动",
kind: KindWebhook,
stored: map[string]any{"url": "https://legit.example.com/hook", "method": "POST"},
incoming: map[string]any{"method": "PUT"},
},
{
name: "换地址并**同时**给新凭据",
kind: KindWebhook,
stored: map[string]any{"url": "https://old.example.com/hook", "headers": map[string]any{"Authorization": "Bearer OLD"}},
incoming: map[string]any{"url": "https://new.example.com/hook", "headers": map[string]any{"Authorization": "Bearer NEW"}},
},
{
name: "换地址并显式声明不再需要凭据",
kind: KindWebhook,
stored: map[string]any{"url": "https://old.example.com/hook", "headers": map[string]any{"Authorization": "Bearer OLD"}},
incoming: map[string]any{"url": "https://new.example.com/hook", "headers": ""},
},
{
name: "Telegram 改 chat_id(不是目的地)",
kind: KindTelegram,
stored: map[string]any{"bot_token": "t", "chat_id": "1", "base_url": "https://api.telegram.org"},
incoming: map[string]any{"chat_id": "-100200"},
},
{
name: "邮件改收件人(不是目的地)",
kind: KindEmail,
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "password": "PW", "from": "a@b.c", "to": []any{"x@y.z"}},
incoming: map[string]any{"to": []any{"new@y.z"}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
merged, err := PrepareConfigUpdate(tc.kind, tc.stored, tc.incoming)
if err != nil {
t.Fatalf("合法编辑被误拦: %v", err)
}
if merged == nil {
t.Fatal("应返回合并结果")
}
})
}
}
// TestPrepareConfigUpdatePortTypeTolerance 覆盖一个容易误判的细节:
// 前端提交的端口是 JSON number(float64),库里读回来也是 float64,
// 但两个值的类型可能不同(如 int vs float64)。用 == 比较会把「没改」判成「改了」,
// 从而对只改了名字的用户弹出「请重新填写密码」——假警报会让人不再信任这个防护。
func TestPrepareConfigUpdatePortTypeTolerance(t *testing.T) {
stored := map[string]any{"host": "smtp.corp.com", "port": float64(587), "password": "PW"}
// 同一个端口,以 int 形式提交。
if _, err := PrepareConfigUpdate(KindEmail, stored, map[string]any{"port": 587}); err != nil {
t.Fatalf("端口值相同(仅类型不同)不应被判为地址变更: %v", err)
}
// 真的换了端口则必须拦。
if _, err := PrepareConfigUpdate(KindEmail, stored, map[string]any{"port": 25}); err == nil {
t.Fatal("端口变更应被拦下")
}
}
// TestPrepareConfigUpdateSurvivesRepeatedSaveWithBlankDestination 覆盖「可留空的
// 目的地字段」这条路径:Telegram 的 base_url 留空表示用官方 API 地址。
//
// 曾经这里会让渠道从第二次保存起永久保存失败:
//
// 新建时库里存下 base_url:""(创建路径直接存前端提交的 config,不走 MergeConfig)
// → 第一次保存,MergeConfig 把空串当显式清空、delete 掉该键
// → 第二次保存,incoming 仍是 ""、而 stored 里已经没这个键,被判成「地址变了」
// → bot_token 是掩码回显值 → 400「目标地址已变更,请同时重新填写凭据字段」
//
// 用户什么都没改,却从此再也存不上,除非重新粘贴一遍 Bot Token。
func TestPrepareConfigUpdateSurvivesRepeatedSaveWithBlankDestination(t *testing.T) {
stored := map[string]any{"bot_token": "123:ABC", "chat_id": "-100", "base_url": ""}
// 前端 buildConfig() 对该渠道的每个字段定义都提交一个值:凭据回填掩码,
// 空文本框提交空串。这里完整复现它的输出,而不是只提交「改动的键」。
submit := func() map[string]any {
return map[string]any{
"bot_token": MaskedValue("123:ABC"),
"chat_id": "-100",
"base_url": "",
}
}
// 第一次保存:只改了渠道名字,config 原样回传。
merged, err := PrepareConfigUpdate(KindTelegram, stored, submit())
if err != nil {
t.Fatalf("第一次保存被误拦: %v", err)
}
if _, ok := merged["base_url"]; ok {
t.Fatal("前提已变:空串应被 MergeConfig 删除——本用例要覆盖的正是「键消失之后」那一步")
}
// 第二次保存:提交内容与上次完全一致,用户什么都没改。
merged2, err := PrepareConfigUpdate(KindTelegram, merged, submit())
if err != nil {
t.Fatalf("第二次保存被误拦(用户什么都没改): %v", err)
}
// 第三次,确认不是「只错一次」而是稳定可保存。
if _, err := PrepareConfigUpdate(KindTelegram, merged2, submit()); err != nil {
t.Fatalf("第三次保存被误拦: %v", err)
}
// 凭据必须一路保留下来,没有被空串逻辑连带清掉。
if got := merged2["bot_token"]; got != "123:ABC" {
t.Fatalf("Bot Token 应沿用原值,得到 %v", got)
}
}
// TestPrepareConfigUpdateStillGuardsBlankDestinationChanges 是上一条用例的配对
// 断言:把空串与「键不存在」视为等价,**不能**连带放过真正的地址变更。
// 这两个方向都是真实的凭据外发路径——Telegram 的 Bot Token 走在 URL 路径里,
// 换了 base_url 就等于把 Token 送给新地址。
func TestPrepareConfigUpdateStillGuardsBlankDestinationChanges(t *testing.T) {
// 方向一:从「空」(官方地址)换到自建地址。
official := map[string]any{"bot_token": "123:ABC", "chat_id": "-100"}
if _, err := PrepareConfigUpdate(KindTelegram, official, map[string]any{
"bot_token": MaskedValue("123:ABC"),
"base_url": "https://tg-proxy.attacker.tld",
}); err == nil {
t.Fatal("从官方地址换到自建地址必须要求重新填写 Token")
}
// 方向二:把自建地址清空(= 换回官方 API)同样是地址变更。
proxied := map[string]any{"bot_token": "123:ABC", "base_url": "https://proxy.internal/bot"}
if _, err := PrepareConfigUpdate(KindTelegram, proxied, map[string]any{
"bot_token": MaskedValue("123:ABC"),
"base_url": "",
}); err == nil {
t.Fatal("清空自建地址(换回官方 API)同样是地址变更,必须要求重新填写 Token")
}
}
func TestDestinationKeysDeclaredForEveryKind(t *testing.T) {
// 与 SecretKeys 同理:渠道若忘记声明目的地键,PrepareConfigUpdate 就保护不到它。
for kind, ch := range registry {
if len(ch.DestinationKeys()) == 0 {
t.Errorf("渠道 %s 未声明目的地键,改地址带出凭据的防护对它无效", kind)
}
if len(ch.SecretKeys()) == 0 {
t.Errorf("渠道 %s 未声明凭据键", kind)
}
}
}
func TestSecretKeysDeclaredForEveryKind(t *testing.T) {
// 编译器已经强制每个渠道实现 SecretKeys,这里再确认一遍「没有渠道在掩码上
// 交白卷」——返回空切片的渠道意味着它的凭据会明文回显到浏览器。
expect := map[string]bool{
KindDingTalk: true, KindFeishu: true, KindWeCom: true,
KindWebhook: true, KindTelegram: true, KindEmail: true,
}
for kind, ch := range registry {
if !expect[kind] {
t.Errorf("渠道 %s 未在测试中登记掩码预期", kind)
continue
}
if len(ch.SecretKeys()) == 0 {
t.Errorf("渠道 %s 未声明任何凭据字段,其配置会明文回显", kind)
}
}
}
// TestPrepareConfigUpdateRejectsMaskedInContainer 覆盖审计指出的一处口子:
// 把掩码哨兵塞进**非字符串**结构(如 webhook.headers 是个对象)时,
// MergeConfig 只认「字符串且带前缀」为掩码,于是字面量 "__masked__" 会被当成
// 真实头值存进库——后续鉴权静默失效,且没有任何报错。
func TestPrepareConfigUpdateRejectsMaskedInContainer(t *testing.T) {
stored := map[string]any{
"url": "https://legit.example.com/hook",
"headers": map[string]any{"Authorization": "Bearer REAL"},
}
// 对象内部夹带掩码哨兵。
incoming := map[string]any{
"headers": map[string]any{"Authorization": MaskedPrefix},
}
if _, err := PrepareConfigUpdate(KindWebhook, stored, incoming); err == nil {
t.Fatal("结构体内部夹带掩码哨兵应被拒绝(否则会把字面量存进库)")
}
// 整体提交对象(真实新值)照常接受。
ok := map[string]any{"headers": map[string]any{"Authorization": "Bearer NEW"}}
if _, err := PrepareConfigUpdate(KindWebhook, stored, ok); err != nil {
t.Fatalf("正常提交新请求头不应被拦: %v", err)
}
}