First Commit
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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
package notify
import (
"context"
"errors"
"sort"
"strconv"
"strings"
)
// Channel 是一个通知渠道的适配器。实现必须**无状态**:同一个实例会被多个渠道
// 配置并发复用,凭据一律从 cfg 参数传入。
type Channel interface {
// Kind 返回渠道类型标识,须与注册表的键一致。
Kind() string
// Validate 在保存配置时调用,校验必填字段与格式。返回的错误会直接展示给
// 配置者,所以文案要说明「缺哪个字段」而不是泛泛的「配置无效」。
Validate(cfg map[string]any) error
// Send 投递一次消息,返回**实际送达的条目数**与错误。
//
// 为什么要返回条数:各平台都有消息长度上限,汇总消息装不下整批时会被截断。
// 若调用方无条件把整批标记为已送达,被截掉的那些条目就消失了——消息里看不到、
// 投递历史里也显示成功,没有任何地方能发现漏洞从未发出。返回 kept 后,
// 调用方只标记前 kept 条,其余留待下一批。
//
// 返回错误表示投递失败,其中 *PermanentError 表示不该重试。
// 失败时 kept 无意义,调用方应忽略它。
Send(ctx context.Context, cfg map[string]any, m Message) (int, error)
// DefaultRatePerMin 返回该渠道官方建议的每分钟投递上限,作为新建渠道实例
// 时的默认限流值。返回 0 表示无已知限制。
DefaultRatePerMin() int
// SecretKeys 返回该渠道配置里属于凭据的键名。API 回显时这些键的值会被掩码,
// 更新时收到掩码值则保留库中的原值。只有实现自己清楚哪些字段算凭据
// (企业微信的整个 Webhook 地址就是凭据,而钉钉的只是其中的 secret),
// 所以这个知识必须由渠道提供,不能由上层猜测。
SecretKeys() []string
// DestinationKeys 返回该渠道配置里决定「消息发往哪里」的键名。
//
// 与 SecretKeys 一样是安全相关的东西:目标地址与凭据是两套独立字段,
// 若允许「只改地址、凭据原样保留」,任何能改渠道配置的人都能把库里的真凭据
// 发到自己控制的服务器,渠道配置的掩码就完全失去意义。
// 详见 PrepareConfigUpdate。
DestinationKeys() []string
}
// registry 是渠道注册表。刻意用显式字面量而不是 init() 自注册:这样「有哪些渠道」
// 在一个地方就能看全,且新增渠道会在编译期暴露遗漏,而不是靠运行时副作用。
var registry = map[string]Channel{
KindDingTalk: dingTalkChannel{},
KindFeishu: feishuChannel{},
KindWeCom: weComChannel{},
KindWebhook: webhookChannel{},
KindTelegram: telegramChannel{},
KindEmail: emailChannel{},
}
// Get 按类型取渠道实现。
func Get(kind string) (Channel, bool) {
c, ok := registry[kind]
return c, ok
}
// ValidKind 报告 kind 是否为受支持的渠道类型。
func ValidKind(kind string) bool {
_, ok := registry[kind]
return ok
}
// Kinds 返回全部受支持的渠道类型,按字典序排列(供 UI 下拉稳定展示)。
func Kinds() []string {
out := make([]string, 0, len(registry))
for k := range registry {
out = append(out, k)
}
sort.Strings(out)
return out
}
// PermanentError 标记一个不该重试的投递失败:凭据错误、目标拒绝、请求体非法等。
// 重试只对瞬时故障(网络抖动、限流、对端 5xx)有意义;对永久失败反复退避重试
// 既不会成功,又会把真正的错误刷没在重试日志里。
type PermanentError struct{ Err error }
func (e *PermanentError) Error() string { return e.Err.Error() }
func (e *PermanentError) Unwrap() error { return e.Err }
// Permanent 把 err 标记为永久失败。err 为 nil 时返回 nil,
// 方便写成 `return Permanent(someCheck())`。
func Permanent(err error) error {
if err == nil {
return nil
}
return &PermanentError{Err: err}
}
// IsPermanent 报告 err 链上是否带有永久失败标记。
func IsPermanent(err error) bool {
var pe *PermanentError
return errors.As(err, &pe)
}
// ---- 配置读取helper ----
//
// 渠道配置来自数据库的 JSONB 列,经 encoding/json 反序列化后是 map[string]any,
// 数值一律是 float64、数组是 []any。下面这些 helper 统一这层转换,并容忍用户
// 在 UI 里留空导致的类型偏差(如把端口填成字符串)。
// cfgString 取字符串配置项,前后空白一律裁掉——从网页表单复制粘贴很容易带上。
func cfgString(cfg map[string]any, key string) string {
v, ok := cfg[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
// cfgInt 取整数配置项,兼容 float64(JSON 默认)与字符串两种来源。
func cfgInt(cfg map[string]any, key string) int {
switch v := cfg[key].(type) {
case float64:
return int(v)
case int:
return v
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return n
default:
return 0
}
}
// cfgBool 取布尔配置项,兼容字符串 "true"/"1"。
func cfgBool(cfg map[string]any, key string) bool {
switch v := cfg[key].(type) {
case bool:
return v
case string:
s := strings.ToLower(strings.TrimSpace(v))
return s == "true" || s == "1" || s == "yes"
default:
return false
}
}
// cfgStrings 取字符串数组配置项,自动裁空白并丢弃空串。
func cfgStrings(cfg map[string]any, key string) []string {
raw, ok := cfg[key].([]any)
if !ok {
// 也接受单个字符串,方便只有一个值时的表单提交。
if s := cfgString(cfg, key); s != "" {
return []string{s}
}
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
s, ok := v.(string)
if !ok {
continue
}
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
// cfgMap 取字符串映射配置项(如自定义 HTTP 头),键值都裁空白,丢弃空键。
func cfgMap(cfg map[string]any, key string) map[string]string {
raw, ok := cfg[key].(map[string]any)
if !ok {
return nil
}
out := make(map[string]string, len(raw))
for k, v := range raw {
k = strings.TrimSpace(k)
if k == "" {
continue
}
s, ok := v.(string)
if !ok {
continue
}
out[k] = s
}
return out
}
+506
View File
@@ -0,0 +1,506 @@
package notify
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
)
// singleMsg 构造一条带引号与换行的单发消息。刻意用含 `"` 与 `\n` 的标题/摘要:
// 这正是模板插值最容易产出的非法 JSON 的输入。
func singleMsg() Message {
return Message{
Items: []Item{{
FindingID: 42,
Name: `登录处 "SQL注入" 风险`,
VulnClass: "SQL注入",
Severity: "high",
Summary: "参数 id\n未过滤 导致注入",
Assets: []string{"a.example.com", "b.example.com"},
DetailURL: "https://artex.local/function/findings/detail?id=42",
}},
}
}
// batchMsg 构造一批汇总消息。
func batchMsg(n int) Message {
m := Message{Batch: true, WindowMinutes: 30, HomeURL: "https://artex.local/function/findings"}
for i := 0; i < n; i++ {
m.Items = append(m.Items, Item{
FindingID: int64(i + 1),
Name: "漏洞" + itoa(i+1),
VulnClass: "XSS",
Severity: "medium",
Summary: "反射型跨站脚本",
Assets: []string{"target.example.com"},
})
}
return m
}
// capturePost 起一个假接收端,把收到的请求体与头回传给断言函数。
func capturePost(t *testing.T, respBody string, assert func(t *testing.T, body map[string]any, r *http.Request)) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var body map[string]any
if len(raw) > 0 {
if err := json.Unmarshal(raw, &body); err != nil {
t.Errorf("请求体不是合法 JSON: %v\n原文: %s", err, raw)
}
}
if assert != nil {
assert(t, body, r)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, respBody)
}))
t.Cleanup(srv.Close)
return srv
}
func TestDingTalkSendsActionCardWhenLinkPresent(t *testing.T) {
srv := capturePost(t, `{"errcode":0,"errmsg":"ok"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
if body["msgtype"] != "actionCard" {
t.Fatalf("有回链时应发 actionCard,得到 %v", body["msgtype"])
}
card, _ := body["actionCard"].(map[string]any)
if card["singleURL"] != "https://artex.local/function/findings/detail?id=42" {
t.Errorf("回链丢失: %v", card["singleURL"])
}
})
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
}
func TestDingTalkFallsBackToMarkdownForBatch(t *testing.T) {
srv := capturePost(t, `{"errcode":0,"errmsg":"ok"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
if body["msgtype"] != "markdown" {
t.Fatalf("汇总消息应发 markdown,得到 %v", body["msgtype"])
}
md, _ := body["markdown"].(map[string]any)
if !strings.Contains(md["text"].(string), "최근 30분간") {
t.Errorf("汇总正文缺少时间窗: %v", md["text"])
}
})
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, batchMsg(3)); err != nil {
t.Fatalf("投递失败: %v", err)
}
}
// TestDingTalkBusinessErrorIsPermanent 锁住「HTTP 200 但 errcode 非 0」的判定。
// 不检查 errcode 会把投递失败记成成功——这是各家国内 IM 平台共有的坑。
func TestDingTalkBusinessErrorIsPermanent(t *testing.T) {
srv := capturePost(t, `{"errcode":310000,"errmsg":"keywords not in content"}`, nil)
_, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg())
if err == nil {
t.Fatal("errcode 非 0 应报错")
}
if !IsPermanent(err) {
t.Fatalf("关键词不匹配属于配置错误,应标记为永久失败,得到 %v", err)
}
if !strings.Contains(err.Error(), "310000") {
t.Errorf("错误信息应带上平台错误码,得到 %v", err)
}
}
func TestWeComTruncatesCJKWithinByteLimit(t *testing.T) {
var contentLen int
srv := capturePost(t, `{"errcode":0,"errmsg":"ok"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
md, _ := body["markdown"].(map[string]any)
content, _ := md["content"].(string)
contentLen = len(content)
if !utf8.ValidString(content) {
t.Fatal("截断后不是合法 UTF-8——企微会整条拒收")
}
})
// 造一批足够长的中文汇总,必然超过 4096 字节。
m := batchMsg(200)
if _, err := (weComChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, m); err != nil {
t.Fatalf("投递失败: %v", err)
}
if contentLen > weComMarkdownLimit {
t.Fatalf("正文 %d 字节超出企微上限 %d", contentLen, weComMarkdownLimit)
}
if contentLen == 0 {
t.Fatal("正文为空")
}
}
func TestWeComRateLimitIsRetryableButKeyErrorIsPermanent(t *testing.T) {
limited := capturePost(t, `{"errcode":45009,"errmsg":"api freq out of limit"}`, nil)
_, err := (weComChannel{}).Send(context.Background(), map[string]any{"webhook": limited.URL}, singleMsg())
if err == nil || IsPermanent(err) {
t.Fatalf("45009 是滚动窗口限流,应可重试,得到 %v", err)
}
badKey := capturePost(t, `{"errcode":93000,"errmsg":"invalid webhook url"}`, nil)
_, err = (weComChannel{}).Send(context.Background(), map[string]any{"webhook": badKey.URL}, singleMsg())
if err == nil || !IsPermanent(err) {
t.Fatalf("93000 是 key 无效,重试不会自愈,应为永久失败,得到 %v", err)
}
}
func TestFeishuCardStructureAndSign(t *testing.T) {
const secret = "SECtest123"
srv := capturePost(t, `{"code":0,"msg":"success"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
if body["msg_type"] != "interactive" {
t.Fatalf("应发交互式卡片,得到 %v", body["msg_type"])
}
card, _ := body["card"].(map[string]any)
header, _ := card["header"].(map[string]any)
if header["template"] != "orange" {
t.Errorf("high 级别应为 orange 配色,得到 %v", header["template"])
}
// 配了 secret 就必须带加签参数,否则飞书会以 19021 拒收。
if body["sign"] == nil || body["timestamp"] == nil {
t.Fatalf("缺少加签参数: %v", body)
}
// 卡片元素里应包含一个按钮,其 url 指向漏洞详情。
elements, _ := card["elements"].([]any)
foundButton := false
for _, e := range elements {
em, _ := e.(map[string]any)
if em["tag"] != "action" {
continue
}
actions, _ := em["actions"].([]any)
for _, a := range actions {
am, _ := a.(map[string]any)
if am["url"] == "https://artex.local/function/findings/detail?id=42" {
foundButton = true
}
}
}
if !foundButton {
t.Fatal("卡片里没有指向详情页的按钮")
}
})
cfg := map[string]any{"webhook": srv.URL, "secret": secret}
if _, err := (feishuChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
}
func TestFeishuWithoutSecretOmitsSign(t *testing.T) {
srv := capturePost(t, `{"code":0,"msg":"success"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
if body["sign"] != nil || body["timestamp"] != nil {
t.Fatalf("未配置 secret 时不应带加签参数: %v", body)
}
})
if _, err := (feishuChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
}
func TestTelegramEscapesHTMLInUntrustedContent(t *testing.T) {
var text string
srv := capturePost(t, `{"ok":true}`, func(t *testing.T, body map[string]any, _ *http.Request) {
text, _ = body["text"].(string)
if body["parse_mode"] != "HTML" {
t.Fatalf("应使用 HTML 解析模式,得到 %v", body["parse_mode"])
}
})
m := Message{Items: []Item{{
Severity: "high",
// 标题与摘要来自被测目标/模型输出,是不可信内容。
Name: `<script>alert(1)</script>`,
Summary: "a & b < c",
}}}
if _, err := (telegramChannel{}).Send(context.Background(),
map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": srv.URL}, m); err != nil {
t.Fatalf("投递失败: %v", err)
}
if strings.Contains(text, "<script>") {
t.Fatalf("未转义 HTML,存在注入: %q", text)
}
if !strings.Contains(text, "&lt;script&gt;") {
t.Fatalf("期望转义后的实体,得到 %q", text)
}
if !strings.Contains(text, "a &amp; b") {
t.Fatalf("& 未转义,得到 %q", text)
}
}
func TestTelegramErrorClassification(t *testing.T) {
rateLimited := capturePost(t, `{"ok":false,"error_code":429,"description":"Too Many Requests"}`, nil)
_, err := (telegramChannel{}).Send(context.Background(),
map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": rateLimited.URL}, singleMsg())
if err == nil || IsPermanent(err) {
t.Fatalf("429 应可重试,得到 %v", err)
}
forbidden := capturePost(t, `{"ok":false,"error_code":403,"description":"bot was blocked by the user"}`, nil)
_, err = (telegramChannel{}).Send(context.Background(),
map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": forbidden.URL}, singleMsg())
if err == nil || !IsPermanent(err) {
t.Fatalf("403 是配置问题,应为永久失败,得到 %v", err)
}
}
func TestWebhookDefaultTemplateProducesValidJSON(t *testing.T) {
// 这条是默认模板存在的意义:标题里带引号与换行时,任何朴素的
// `"title": "{{.Title}}"` 写法都会产出非法 JSON。{{json .}} 才不会。
srv := capturePost(t, `{"ok":true}`, func(t *testing.T, body map[string]any, _ *http.Request) {
if body["title"] != `[🟠 높음] 登录处 "SQL注入" 风险` {
t.Errorf("标题未正确还原: %v", body["title"])
}
items, _ := body["items"].([]any)
if len(items) != 1 {
t.Fatalf("items 数量应为 1,得到 %d", len(items))
}
it, _ := items[0].(map[string]any)
if it["summary"] != "参数 id\n未过滤 导致注入" {
t.Errorf("摘要未正确还原: %v", it["summary"])
}
// 数值必须是 JSON 数字而不是字符串(json:"...,string" 之类的写法会踩这坑)。
if _, ok := it["finding_id"].(float64); !ok {
t.Errorf("finding_id 应为数字,得到 %T", it["finding_id"])
}
})
if _, err := (webhookChannel{}).Send(context.Background(), map[string]any{"url": srv.URL}, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
}
func TestWebhookCustomTemplateAndHeaders(t *testing.T) {
srv := capturePost(t, `{"ok":true}`, func(t *testing.T, body map[string]any, r *http.Request) {
if r.Header.Get("X-Token") != "s3cret" {
t.Errorf("自定义头丢失: %v", r.Header)
}
if body["msg"] != "3 条" {
t.Errorf("自定义模板渲染有误: %v", body["msg"])
}
if body["first"] != "漏洞1" {
t.Errorf("range 提取有误: %v", body["first"])
}
})
cfg := map[string]any{
"url": srv.URL,
"headers": map[string]any{"X-Token": "s3cret"},
"body_template": `{"msg": {{json (printf "%d 条" .Count)}}, "first": {{json (index .Items 0).Name}}}`,
}
if _, err := (webhookChannel{}).Send(context.Background(), cfg, batchMsg(3)); err != nil {
t.Fatalf("投递失败: %v", err)
}
}
func TestWebhookRejectsNonJSONRenderResult(t *testing.T) {
cfg := map[string]any{"url": "https://example.com/hook", "body_template": `not json at all`}
_, err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg())
if err == nil || !IsPermanent(err) {
t.Fatalf("渲染结果非 JSON 应为永久失败(模板写错了,重试无用),得到 %v", err)
}
}
func TestWebhookValidateCatchesBadConfigEarly(t *testing.T) {
bad := []map[string]any{
{},
{"url": "file:///etc/passwd"},
{"url": "https://example.com", "method": "DELETE"},
{"url": "https://example.com", "body_template": `{{.Items.`},
}
for i, cfg := range bad {
if err := (webhookChannel{}).Validate(cfg); err == nil {
t.Errorf("第 %d 组配置应被拒绝: %v", i, cfg)
}
}
}
func TestEmailMessageIsWellFormed(t *testing.T) {
msg, err := buildEmailMessage("artex@example.com", []string{"a@example.com", "b@example.com"}, singleMsg())
if err != nil {
t.Fatalf("组装邮件失败: %v", err)
}
if !strings.HasPrefix(msg, "From: artex@example.com\r\n") {
t.Fatalf("From 头有误:\n%s", msg)
}
if !strings.Contains(msg, "To: a@example.com, b@example.com\r\n") {
t.Fatalf("To 头有误:\n%s", msg)
}
// 中文主题必须 RFC 2047 编码,否则客户端显示成乱码。
if !strings.Contains(msg, "Subject: =?utf-8?") {
t.Fatalf("主题未做 RFC 2047 编码:\n%s", msg)
}
if dec, err := new(mime.WordDecoder).DecodeHeader(mustExtractHeader(t, msg, "Subject")); err != nil {
t.Fatalf("主题无法解码: %v", err)
} else if !strings.Contains(dec, "SQL注入") {
t.Fatalf("主题解码后内容有误: %q", dec)
}
// 正文是 base64,解出来应是合法 HTML。
parts := strings.SplitN(msg, "\r\n\r\n", 2)
if len(parts) != 2 {
t.Fatal("邮件缺少头/体分隔")
}
decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(strings.TrimSpace(parts[1]), "\r\n", ""))
if err != nil {
t.Fatalf("正文 base64 解码失败: %v", err)
}
html := string(decoded)
if !strings.HasPrefix(html, "<div") {
t.Fatalf("正文不是 HTML: %.80s", html)
}
// 标题原样出现在文本位置:HTML 文本内容里的双引号是合法字符,无需转义。
// 这里断言「原样保留」是为了防止将来有人误加一层引号转义,让中文引号
// 显示成 &quot;。
if !strings.Contains(html, `"SQL注入"`) {
t.Fatalf("标题中的引号在文本位置应原样保留: %.200s", html)
}
}
// TestEmailEscapesStructuralInjection 覆盖邮件正文真正需要防的注入:
// 漏洞标题与摘要来自被测目标与模型输出,是不可信内容。文本位置必须转义
// & < >(否则可以注入标签),属性位置还必须转义引号(否则可以闭合 href)。
func TestEmailEscapesStructuralInjection(t *testing.T) {
m := Message{
Items: []Item{{
Severity: "high",
Name: `<script>alert(1)</script>`,
Summary: "a & b > c",
DetailURL: `https://artex.local/x?a="onmouseover=alert(1)`,
}},
}
html := htmlBody(m, 0)
if strings.Contains(html, "<script>") {
t.Fatalf("标题未转义,可注入标签: %s", html)
}
if !strings.Contains(html, "&lt;script&gt;") {
t.Fatalf("期望转义后的实体: %s", html)
}
if !strings.Contains(html, "a &amp; b &gt; c") {
t.Fatalf("& 与 > 未转义: %s", html)
}
// 回链是管理员可配的 public_base_url,本身可信度较高,但属性位置仍必须
// 转义引号——否则一个带引号的地址会闭合 href 并注入事件处理器。
if strings.Contains(html, `onmouseover=alert(1)">`) {
t.Fatalf("href 属性未正确转义: %s", html)
}
if !strings.Contains(html, "&quot;") {
t.Fatalf("属性位置的引号应被转义: %s", html)
}
}
func mustExtractHeader(t *testing.T, msg, name string) string {
t.Helper()
for _, line := range strings.Split(msg, "\r\n") {
if strings.HasPrefix(line, name+": ") {
return strings.TrimPrefix(line, name+": ")
}
}
t.Fatalf("未找到 %s 头", name)
return ""
}
func TestChannelValidateReportsMissingFields(t *testing.T) {
// 校验错误会直接展示给配置者,必须说清楚缺什么,而不是泛泛的「配置无效」。
cases := []struct {
kind string
cfg map[string]any
substr string
}{
{KindDingTalk, map[string]any{}, "Webhook"},
{KindFeishu, map[string]any{}, "Webhook"},
{KindWeCom, map[string]any{}, "Webhook"},
{KindTelegram, map[string]any{}, "Bot Token"},
{KindTelegram, map[string]any{"bot_token": "t"}, "Chat ID"},
{KindEmail, map[string]any{}, "SMTP"},
{KindEmail, map[string]any{"host": "h"}, "포트"},
{KindEmail, map[string]any{"host": "h", "port": 587, "from": "f"}, "수신자"},
}
for _, tc := range cases {
ch, ok := Get(tc.kind)
if !ok {
t.Fatalf("渠道 %s 未注册", tc.kind)
}
err := ch.Validate(tc.cfg)
if err == nil {
t.Errorf("%s 配置 %v 应校验失败", tc.kind, tc.cfg)
continue
}
if !strings.Contains(err.Error(), tc.substr) {
t.Errorf("%s 的错误信息应提到 %q,得到 %q", tc.kind, tc.substr, err.Error())
}
}
}
// TestEmailSMTPErrorClassification 锁住 SMTP 4xx/5xx 的语义区分。
// 若把 4xx 也判成永久失败,一个启用灰名单的邮件服务器会让每条推送都在第一次
// 尝试后落入 failed —— 而灰名单恰恰是自动重试最该发挥作用的场景。
func TestEmailSMTPErrorClassification(t *testing.T) {
cases := []struct {
reply string
permanent bool
}{
{"450 4.7.1 Greylisting in action, please come back later", false},
{"451 4.3.0 Temporary system failure", false},
{"452 4.2.2 Mailbox full", false},
{"550 5.1.1 User unknown", true},
{"553 5.1.3 Bad address syntax", true},
{"554 5.7.1 Relay access denied", true},
// 取不到应答码时按「可重试」处理:宁可多试一次,也不要把可能的瞬时
// 故障判死。
{"unexpected EOF", false},
{"", false},
}
for _, tc := range cases {
err := smtpStageError("收件人被拒", errors.New(tc.reply))
if got := IsPermanent(err); got != tc.permanent {
t.Errorf("应答 %q: 期望 permanent=%v 得到 %v", tc.reply, tc.permanent, got)
}
// 无论怎么分类,原文都要保留给使用者排查。
if tc.reply != "" && !strings.Contains(err.Error(), tc.reply) {
t.Errorf("应答 %q 的原文被丢弃: %v", tc.reply, err)
}
}
}
func TestRegistryCoversAllKinds(t *testing.T) {
// 六个渠道缺一不可——少一个会在 UI 下拉里静默消失。
want := []string{KindDingTalk, KindEmail, KindFeishu, KindTelegram, KindWebhook, KindWeCom}
got := Kinds()
if len(got) != len(want) {
t.Fatalf("渠道数量应为 %d,得到 %d: %v", len(want), len(got), got)
}
for _, k := range want {
if !ValidKind(k) {
t.Errorf("渠道 %s 未注册", k)
}
if ch, ok := Get(k); !ok || ch.Kind() != k {
t.Errorf("渠道 %s 的 Kind() 与注册键不一致", k)
}
}
if ValidKind("nope") {
t.Error("未注册的类型不应通过校验")
}
}
func TestPermanentErrorUnwrap(t *testing.T) {
base := &permanentSentinel{}
err := Permanent(base)
if !IsPermanent(err) {
t.Fatal("应识别为永久失败")
}
if !strings.Contains(err.Error(), "sentinel") {
t.Fatalf("错误信息应透传底层: %v", err)
}
if Permanent(nil) != nil {
t.Fatal("Permanent(nil) 必须返回 nil")
}
if IsPermanent(nil) {
t.Fatal("nil 不是永久失败")
}
}
type permanentSentinel struct{}
func (*permanentSentinel) Error() string { return "sentinel" }
+160
View File
@@ -0,0 +1,160 @@
package notify
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"time"
)
// dingTalkChannel 实现钉钉自定义机器人。
//
// 平台特性(决定了这里的实现取舍):
// - 单机器人限流 20 条/分钟,超发会被静默丢弃(HTTP 仍可能 200),
// 所以限流必须在客户端做,见 DefaultRatePerMin。
// - 安全设置三选一:加签 / 自定义关键词 / IP 白名单。加签是唯一不依赖
// 消息内容的方案,所以只支持加签(也支持三者都不开的裸 webhook)。
// - 成功/失败都返回 HTTP 200,靠 body 里的 errcode 区分——不检查 errcode
// 会把投递失败记成成功。
type dingTalkChannel struct{}
func (dingTalkChannel) Kind() string { return KindDingTalk }
func (dingTalkChannel) DefaultRatePerMin() int { return 20 }
// 钉钉的 Webhook 地址里带 access_token,本身就是凭据,因此整体掩码。
func (dingTalkChannel) SecretKeys() []string { return []string{"webhook", "secret"} }
// 目标是钉钉的 Webhook 地址本身;改地址必须同时对新地址重新表态加签密钥。
func (dingTalkChannel) DestinationKeys() []string { return []string{"webhook"} }
func (dingTalkChannel) Validate(cfg map[string]any) error {
hook := cfgString(cfg, "webhook")
if hook == "" {
return errors.New("Webhook 주소가 없습니다")
}
if err := validateHTTPURL(hook); err != nil {
return fmt.Errorf("Webhook 주소가 올바르지 않습니다: %w", err)
}
return nil
}
// Send 投递一次消息。有回链且是单条时用 ActionCard(带按钮),否则用 markdown。
func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
hook := cfgString(cfg, "webhook")
if err := c.Validate(cfg); err != nil {
return 0, Permanent(err)
}
endpoint, err := dingTalkSignedURL(hook, cfgString(cfg, "secret"), time.Now())
if err != nil {
return 0, Permanent(err)
}
title := markdownTitle(m)
// 钉钉 markdown 正文无明确字节上限,但仍做上限保护,避免证据字段异常膨胀。
text, kept := markdownBody(m, 20000)
var payload any
if !m.Batch && len(m.Items) == 1 && m.Items[0].DetailURL != "" {
payload = map[string]any{
"msgtype": "actionCard",
"actionCard": map[string]any{
"title": title,
"text": text,
"btnOrientation": "0",
"singleTitle": "상세 보기",
"singleURL": m.Items[0].DetailURL,
},
}
} else {
payload = map[string]any{
"msgtype": "markdown",
"markdown": map[string]any{"title": title, "text": text},
}
}
raw, err := doJSON(ctx, "POST", endpoint, nil, payload)
if err != nil {
return 0, err
}
// 钉钉把业务错误藏在 200 响应里。
var res struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return 0, fmt.Errorf("DingTalk 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
}
if res.ErrCode != 0 {
// 301000 是签名校验失败、310000 是关键词不匹配——都是配置错误,
// 重试不会自愈。
return 0, Permanent(fmt.Errorf("DingTalk에서 오류가 발생했습니다 (%d): %s", res.ErrCode, res.ErrMsg))
}
return kept, nil
}
// dingTalkSignedURL 按官方加签规则给 webhook 追加 timestamp 与 sign 参数。
//
// 规则:待签串 = timestamp + "\n" + secret,HMAC-SHA256 的**密钥也是 secret**,
// 结果 base64 后 URL 编码。timestamp 是毫秒。secret 为空时原样返回,
// 以支持未开启加签的机器人。
func dingTalkSignedURL(hook, secret string, now time.Time) (string, error) {
if secret == "" {
return hook, nil
}
ts := strconv.FormatInt(now.UnixMilli(), 10)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "\n" + secret))
sign := base64.StdEncoding.EncodeToString(mac.Sum(nil))
u, err := url.Parse(hook)
if err != nil {
// 不透传 err:url.Parse 的错误文本里带完整地址(含 access_token)。
return "", fmt.Errorf("Webhook 주소를 해석하지 못했습니다: %s", redactRequestTarget(hook))
}
q := u.Query()
q.Set("timestamp", ts)
q.Set("sign", sign)
u.RawQuery = q.Encode()
return u.String(), nil
}
// validateHTTPURL 校验地址可用、协议受支持,并对字面 IP 目标做内网判断。
//
// 两点讲究:
//
// 1. **错误信息必须脱敏**。url.Parse 自己返回的是 *url.Error,它的 Error() 带
// **完整原始地址**,而本功能这几家的地址里就嵌着凭据(钉钉 access_token、
// 企微 key、Telegram 的 bot token、飞书 hook id)。曾经这里直接 `return err`,
// 于是「地址格式非法」这条错误就把凭据带了出去,流向测试接口的 400 响应、
// 每次投递落库的 last_error、服务端日志与投递历史接口。
//
// 2. **字面 IP 直接判内网**,域名留给拨号阶段判(blockInternalDial 才是最终
// 生效点,也能覆盖 DNS 重绑定)。这里做一次是为了让保存配置时就能得到提示,
// 而不是等到第一次投递失败。
//
// 限制协议是防御性的:file:///gopher:// 之类会让 http.Client 产生意料之外的
// 行为(虽已被 scheme 检查挡下,但没有理由放开这个面)。
func validateHTTPURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("주소를 해석할 수 없습니다 (%s)", redactRequestTarget(raw))
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("http 또는 https 만 지원합니다. 받은 값: %q", u.Scheme)
}
if u.Host == "" {
return errors.New("호스트 이름이 없습니다")
}
if ip := net.ParseIP(u.Hostname()); ip != nil && isBlockedDialIP(ip) && !allowLocalTargets() {
return fmt.Errorf("로컬 또는 링크 로컬 주소 %s 로는 전송하지 않습니다 (로컬 서비스로 보내야 하면 %s=1 로 설정하세요)", ip, AllowLocalTargetsEnv)
}
return nil
}
@@ -0,0 +1,190 @@
package notify
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
)
// asciiStatusChangeItem 은 한자가 없는(ASCII) 상태 변경 항목을 만든다.
// 라벨이 중국어로 되돌아가면 렌더 결과에 한자가 생기므로, 데이터가 한자 0 일 때만
// "출력 전체 한자 0" 단언이 라벨 회귀를 정확히 잡아낸다.
func asciiStatusChangeItem() Item {
return Item{
FindingID: 7,
Name: "login-flaw",
VulnClass: "SQLi",
Severity: "high",
Summary: "SQL injection via q param",
Assets: []string{"a.example.com"},
DetailURL: "https://platform.example/finding/7",
FromStatus: "pending",
ToStatus: "fixed",
}
}
func mustJSON(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("카드 직렬화 실패: %v", err)
}
return string(b)
}
// TestFeishuItemLinesLocalized 는 飞书 카드 단건 본문의 필드 라벨(상태 변경·유형·
// 자산·개요)이 한국어이고 markdown.go(F4②)와 글자까지 같은지 검사한다. feishu.go 는
// 이 라벨의 자기 복사본(markdown 3채널 공유 함수가 아님)을 쓰므로, 공유 함수 회귀와
// 별개로 이 핀이 있어야 飞书 본문만 중국어로 되돌아가는 회귀를 잡는다.
func TestFeishuItemLinesLocalized(t *testing.T) {
got := feishuItemLines(asciiStatusChangeItem())
assertKorean(t, "feishuItemLines", got)
for _, want := range []string{
"**상태 변경**: 처리 대기 → 수정됨",
"**유형**: SQLi",
"**자산**: a.example.com",
"**개요**: SQL injection via q param",
} {
if !strings.Contains(got, want) {
t.Errorf("飞书 단건 본문에 %q 가 있어야 합니다:\n%s", want, got)
}
}
}
// TestFeishuCardButtonsLocalized 는 飞书 카드 버튼 라벨(상세 보기·플랫폼에서 전체 보기)이
// 한국어인지, 카드 전체에 중국어 한자가 없는지(ASCII 데이터 기준) 검사한다.
func TestFeishuCardButtonsLocalized(t *testing.T) {
// 단건: "상세 보기" 버튼(DetailURL 있을 때)
single, _ := feishuCard(Message{Items: []Item{asciiStatusChangeItem()}})
singleJSON := mustJSON(t, single)
if hasHan(singleJSON) {
t.Errorf("단건 飞书 카드에 중국어 한자가 남았습니다:\n%s", singleJSON)
}
if !strings.Contains(singleJSON, "상세 보기") {
t.Errorf("단건 飞书 카드에 '상세 보기' 버튼이 있어야 합니다:\n%s", singleJSON)
}
// 다건: "플랫폼에서 전체 보기" 버튼(HomeURL 있을 때)
batch, _ := feishuCard(Message{Batch: true, HomeURL: "https://platform.example", Items: hanFreeItems(2)})
batchJSON := mustJSON(t, batch)
if hasHan(batchJSON) {
t.Errorf("다건 飞书 카드에 중국어 한자가 남았습니다:\n%s", batchJSON)
}
if !strings.Contains(batchJSON, "플랫폼에서 전체 보기") {
t.Errorf("다건 飞书 카드에 '플랫폼에서 전체 보기' 버튼이 있어야 합니다:\n%s", batchJSON)
}
}
// TestDingTalkActionCardButtonLocalized 는 钉钉 ActionCard 의 "상세 보기" 버튼(singleTitle)이
// 한국어인지 실제 Send 경로(가짜 수신단)로 검사한다.
func TestDingTalkActionCardButtonLocalized(t *testing.T) {
var singleTitle string
srv := capturePost(t, `{"errcode":0,"errmsg":"ok"}`, func(_ *testing.T, body map[string]any, _ *http.Request) {
card, _ := body["actionCard"].(map[string]any)
singleTitle, _ = card["singleTitle"].(string)
})
m := Message{Items: []Item{asciiStatusChangeItem()}}
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, m); err != nil {
t.Fatalf("투递 실패: %v", err)
}
if singleTitle != "상세 보기" {
t.Errorf("钉钉 ActionCard singleTitle 은 '상세 보기' 여야 합니다, 받은 값 %q", singleTitle)
}
}
// TestChinaPlatformValidateLocalized 는 钉钉·飞书·企业微信 설정 검증 오류가 한국어이고
// "Webhook" 표기를 유지하는지 검사한다(알림 설정 저장·테스트 API 가 사용자에게 노출).
func TestChinaPlatformValidateLocalized(t *testing.T) {
for _, kind := range []string{KindDingTalk, KindFeishu, KindWeCom} {
ch, ok := Get(kind)
if !ok {
t.Fatalf("%s 채널이 등록되어 있지 않습니다", kind)
}
// 빈 설정: "Webhook 주소가 없습니다"
missing := ch.Validate(map[string]any{})
if missing == nil {
t.Fatalf("%s: 빈 설정은 검증에 실패해야 합니다", kind)
}
assertKorean(t, kind+" missing", missing.Error())
if !strings.Contains(missing.Error(), "Webhook") || !strings.Contains(missing.Error(), "없습니다") {
t.Errorf("%s: 누락 오류는 'Webhook 주소가 없습니다' 여야 합니다, 받은 값 %q", kind, missing)
}
// 잘못된 주소(ftp): "Webhook 주소가 올바르지 않습니다: …"
bad := ch.Validate(map[string]any{"webhook": "ftp://x"})
if bad == nil {
t.Fatalf("%s: ftp 주소는 검증에 실패해야 합니다", kind)
}
if hasHan(bad.Error()) {
t.Errorf("%s: 잘못된 주소 오류에 중국어 한자가 남았습니다: %q", kind, bad)
}
if !strings.Contains(bad.Error(), "Webhook 주소가 올바르지 않습니다") {
t.Errorf("%s: 잘못된 주소 오류는 'Webhook 주소가 올바르지 않습니다' 로 시작해야 합니다, 받은 값 %q", kind, bad)
}
}
}
// TestChinaPlatformSendErrorsLocalized 는 钉钉·飞书·企业微信 발송 실패 사유가 한국어로
// 투递 이력(last_error)에 남는지 검사한다. 플랫폼 이름은 UI 채널 라벨(DingTalk/Feishu/
// WeCom)과 맞춘다. 서버 errmsg 는 ASCII 이므로 오류에 한자가 보이면 골격 문구가
// 중국어로 회귀한 것이다.
func TestChinaPlatformSendErrorsLocalized(t *testing.T) {
// 업무 오류 코드(HTTP 200 + body 의 errcode/code != 0) 경로.
bizCases := []struct {
kind string
resp string
wantSubs []string
}{
{KindDingTalk, `{"errcode":310000,"errmsg":"keyword not matched"}`, []string{"DingTalk에서 오류가 발생했습니다", "310000"}},
{KindFeishu, `{"code":19021,"msg":"sign error"}`, []string{"Feishu에서 오류가 발생했습니다", "19021"}},
{KindWeCom, `{"errcode":45009,"errmsg":"freq out of limit"}`, []string{"WeCom 요청이 제한되었습니다", "45009"}},
{KindWeCom, `{"errcode":93000,"errmsg":"invalid webhook"}`, []string{"WeCom에서 오류가 발생했습니다", "93000"}},
}
for _, tc := range bizCases {
srv := capturePost(t, tc.resp, nil)
ch, ok := Get(tc.kind)
if !ok {
t.Fatalf("%s 채널이 등록되어 있지 않습니다", tc.kind)
}
_, err := ch.Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg())
if err == nil {
t.Fatalf("%s: %s 응답은 오류여야 합니다", tc.kind, tc.resp)
}
if hasHan(err.Error()) {
t.Errorf("%s: 발송 오류에 중국어 한자가 남았습니다: %q", tc.kind, err)
}
for _, sub := range tc.wantSubs {
if !strings.Contains(err.Error(), sub) {
t.Errorf("%s: 발송 오류에 %q 가 있어야 합니다, 받은 값 %q", tc.kind, sub, err)
}
}
}
// 응답이 JSON 이 아닐 때의 파싱 실패 경로: "<플랫폼> 응답을 해석하지 못했습니다".
parseCases := []struct {
kind string
platform string
}{
{KindDingTalk, "DingTalk"},
{KindFeishu, "Feishu"},
{KindWeCom, "WeCom"},
}
for _, pc := range parseCases {
srv := capturePost(t, `not-json`, nil)
ch, ok := Get(pc.kind)
if !ok {
t.Fatalf("%s 채널이 등록되어 있지 않습니다", pc.kind)
}
_, err := ch.Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg())
if err == nil {
t.Fatalf("%s: JSON 이 아닌 응답은 오류여야 합니다", pc.kind)
}
want := pc.platform + " 응답을 해석하지 못했습니다"
if !strings.Contains(err.Error(), want) {
t.Errorf("%s: 파싱 실패 오류에 %q 가 있어야 합니다, 받은 값 %q", pc.kind, want, err)
}
}
}
+215
View File
@@ -0,0 +1,215 @@
package notify
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"mime"
"net"
"net/smtp"
"strconv"
"strings"
"time"
)
// emailDialTimeout / emailSessionTimeout 分别约束建连与整段 SMTP 会话。
// net/smtp 自身没有任何超时机制,不设这两道的话,一个卡住的对端会让
// 投递 goroutine 永久挂在那里——而 dispatcher 是单 goroutine 串行处理的,
// 等于整个通知系统停摆。
const (
emailDialTimeout = 10 * time.Second
emailSessionTimeout = 45 * time.Second
)
// emailChannel 实现 SMTP 邮件投递。
type emailChannel struct{}
func (emailChannel) Kind() string { return KindEmail }
// 邮件没有平台限流,但不该用它刷屏;给一个宽松的默认值。
func (emailChannel) DefaultRatePerMin() int { return 60 }
// 只掩码密码。SMTP 主机、账号、收件人都不算秘密,掩码它们只会让编辑变麻烦。
func (emailChannel) SecretKeys() []string { return []string{"password"} }
// host/port 决定把密码交给哪台服务器;tls 决定是否加密传输。三者任一变化都
// 要求重新表态密码——顺带让「关掉 TLS」这一步必须显式带上凭据,而不是顺手一改。
func (emailChannel) DestinationKeys() []string { return []string{"host", "port", "tls"} }
func (emailChannel) Validate(cfg map[string]any) error {
if cfgString(cfg, "host") == "" {
return errors.New("SMTP 서버 주소가 없습니다")
}
port := cfgInt(cfg, "port")
if port <= 0 || port > 65535 {
return errors.New("SMTP 포트가 올바르지 않습니다 (1-65535 범위여야 합니다)")
}
if cfgString(cfg, "from") == "" {
return errors.New("발신자 주소가 없습니다")
}
if len(cfgStrings(cfg, "to")) == 0 {
return errors.New("수신자 주소가 최소 하나 필요합니다")
}
return nil
}
func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
if err := c.Validate(cfg); err != nil {
return 0, Permanent(err)
}
host := cfgString(cfg, "host")
port := cfgInt(cfg, "port")
from := cfgString(cfg, "from")
to := cfgStrings(cfg, "to")
username := cfgString(cfg, "username")
password := cfgString(cfg, "password")
implicitTLS := cfgBool(cfg, "tls")
msg, err := buildEmailMessage(from, to, m)
if err != nil {
return 0, Permanent(err)
}
addr := net.JoinHostPort(host, strconv.Itoa(port))
client, err := emailDial(ctx, addr, host, implicitTLS)
if err != nil {
return 0, err
}
defer client.Close()
// STARTTLS:对端支持就升级。明文会话下不能发凭据(见下面的 auth 说明)。
if !implicitTLS {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return 0, fmt.Errorf("STARTTLS 실패: %w", err)
}
}
}
if username != "" {
if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
// smtp.PlainAuth 会拒绝在未加密连接上发送凭据(除非目标是 localhost)。
// 这是**正确**的安全行为,不能绕过,但需要把原因翻译清楚——
// 否则使用者只会看到「unencrypted connection」而不知道该怎么办。
if strings.Contains(err.Error(), "unencrypted connection") {
return 0, Permanent(fmt.Errorf("자격 증명 전송을 거부했습니다: 연결이 암호화되지 않았습니다. TLS 를 켜거나, 465 포트(암시적 TLS)를 사용하거나, 「TLS 사용」을 체크하세요 (%w)", err))
}
return 0, Permanent(fmt.Errorf("SMTP 인증 실패: %w", err))
}
}
if err := client.Mail(from); err != nil {
return 0, smtpStageError(fmt.Sprintf("발신자 %s 주소가 거부되었습니다", from), err)
}
for _, rcpt := range to {
if err := client.Rcpt(rcpt); err != nil {
return 0, smtpStageError(fmt.Sprintf("수신자 %s 주소가 거부되었습니다", rcpt), err)
}
}
w, err := client.Data()
if err != nil {
return 0, fmt.Errorf("SMTP DATA 실패: %w", err)
}
if _, err := w.Write([]byte(msg)); err != nil {
return 0, fmt.Errorf("메일 본문 쓰기 실패: %w", err)
}
if err := w.Close(); err != nil {
return 0, fmt.Errorf("메일 제출 실패: %w", err)
}
// Quit 失败不影响「邮件已被服务器接收」这个事实,因此忽略其错误。
_ = client.Quit()
// 邮件没有长度截断(HTML 正文全部发送),整批都算送达。
return len(m.Items), nil
}
// emailDial 建立 SMTP 连接。
//
// implicitTLS=true 走 465 这类「连上即 TLS」的方式;false 走 25/587 明文建连后再
// STARTTLS。两者不能混:对 465 端口发明文 greeting 会被直接断开。
//
// 会话期限在**建连处**就设好(而非事后补设),因为 net/smtp 的 Client 把底层
// 连接藏在未导出字段里,外部拿不到它;连接一旦交出去就只能靠预先设置的 deadline
// 兜底。这也顺带覆盖了握手阶段的阻塞。
// Control 挂 blockInternalDial 与 HTTP 系渠道共用同一道守卫。不挂的话 SMTP
// 就是整套 SSRF 防护的缺口:host 填 169.254.169.254 或 127.0.0.1 能直接连上,
// 而 smtp.NewClient 握手失败时会把对端返回的那一行包进错误、经 last_error
// 由投递历史接口回显,构成半盲读原语;「连接被拒 vs 超时」的耗时差异还能
// 用来探测端口。拨号阶段是最终生效点,也覆盖 DNS 重绑定。
func emailDial(ctx context.Context, addr, host string, implicitTLS bool) (*smtp.Client, error) {
d := &net.Dialer{Timeout: emailDialTimeout, Control: blockInternalDial}
var conn net.Conn
var err error
if implicitTLS {
conn, err = tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
} else {
conn, err = d.DialContext(ctx, "tcp", addr)
}
if err != nil {
return nil, fmt.Errorf("SMTP 서버 연결 실패: %w", err)
}
_ = conn.SetDeadline(time.Now().Add(emailSessionTimeout))
client, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return nil, fmt.Errorf("SMTP 핸드셰이크 실패: %w", err)
}
return client, nil
}
// smtpStageError 按 SMTP 应答码把某个阶段的失败分成「可重试」与「永久失败」。
//
// 为什么必须区分:SMTP 的 4xx 与 5xx 语义完全不同——
// - 4xx(450 灰名单、451 本地错误、452 存储不足)是**临时**拒绝,
// 正规做法是稍后重试;尤其是灰名单,几乎每次首次投递都会遇到。
// - 5xx(550 用户不存在、553 地址非法)是永久拒绝,重试没有意义。
//
// 若一律判永久失败,一个启用灰名单的邮件服务器会让**每一条**推送都在第一次
// 尝试后落入 failed——而这类失败恰恰是自动重试最该发挥作用的场景。
// 应答码取错误文本的前三位数字;取不到码时按可重试处理(宁可多试一次,
// 也不要因为解析不出就把可能的瞬时故障判死)。
func smtpStageError(what string, err error) error {
code := smtpReplyCode(err.Error())
if code >= 500 && code < 600 {
return Permanent(fmt.Errorf("%s: %w", what, err))
}
return fmt.Errorf("%s: %w", what, err)
}
// smtpReplyCode 从 SMTP 错误文本里取前导的三位应答码,取不到返回 0。
// net/smtp 不导出错误码字段,只能从文本里取;格式为「450 4.7.1 ...」。
func smtpReplyCode(text string) int {
if len(text) < 3 {
return 0
}
n, err := strconv.Atoi(text[:3])
if err != nil {
return 0
}
return n
}
// buildEmailMessage 组装完整的 RFC 5322 邮件。
//
// 正文用 base64 编码有两个原因:一是 SMTP 规定单行不超过 1000 字节,而 HTML
// 正文(尤其汇总邮件)很容易出现超长行;二是 base64 天然不会出现以 "." 开头
// 的行,省去 SMTP 点号转义的麻烦。
func buildEmailMessage(from string, to []string, m Message) (string, error) {
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", from)
fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", "))
// 中文主题必须做 RFC 2047 编码,否则会被客户端显示成乱码。
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", htmlTitle(m)))
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
b.WriteString("Content-Transfer-Encoding: base64\r\n")
// 邮件没有长度硬上限,因此不截断正文。
b.WriteString("\r\n")
encoded := base64.StdEncoding.EncodeToString([]byte(htmlBody(m, 0)))
// base64 按 76 字符折行,符合 RFC 2045。
for len(encoded) > 76 {
b.WriteString(encoded[:76] + "\r\n")
encoded = encoded[76:]
}
b.WriteString(encoded + "\r\n")
return b.String(), nil
}
+336
View File
@@ -0,0 +1,336 @@
package notify
import (
"bufio"
"context"
"net"
"strings"
"sync"
"testing"
)
// 本文件补齐邮件渠道的协议级测试。在此之前 email.Send 的覆盖率是 0——
// 整条 SMTP 路径没有任何用例跑过,而它恰恰是六个渠道里协议面最大、
// 最容易出错的一个(握手、认证、信封、DATA 阶段各有各的失败语义)。
//
// 这里用自建的最小 SMTP 服务器驱动,而不是 mock 掉 net/smtp:
// 邮件渠道的绝大部分风险就在「与真实 SMTP 服务器对话」这一步,
// 把这一步 mock 掉等于不测。
// fakeSMTP 是一个刚好够用的 SMTP 服务器:能完成 greet/EHLO/AUTH/MAIL/RCPT/DATA/QUIT,
// 并按用例要求对特定阶段返回指定应答码。
type fakeSMTP struct {
ln net.Listener
// rcptReply 是 RCPT TO 的应答;默认 250。
rcptReply string
// mailReply 是 MAIL FROM 的应答;默认 250。
mailReply string
// advertiseAuth 为 true 时在 EHLO 里声明支持 AUTH PLAIN。
advertiseAuth bool
mu sync.Mutex
data string
commands []string
}
func newFakeSMTP(t *testing.T) *fakeSMTP {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f := &fakeSMTP{ln: ln, rcptReply: "250 OK", mailReply: "250 OK"}
go f.serve()
t.Cleanup(func() { ln.Close() })
return f
}
func (f *fakeSMTP) hostPort(t *testing.T) (string, int) {
t.Helper()
addr, ok := f.ln.Addr().(*net.TCPAddr)
if !ok {
t.Fatal("非 TCP 监听地址")
}
return "127.0.0.1", addr.Port
}
func (f *fakeSMTP) record(cmd string) {
f.mu.Lock()
f.commands = append(f.commands, cmd)
f.mu.Unlock()
}
func (f *fakeSMTP) body() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.data
}
func (f *fakeSMTP) sawCommand(prefix string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.commands {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func (f *fakeSMTP) serve() {
conn, err := f.ln.Accept()
if err != nil {
return
}
defer conn.Close()
br := bufio.NewReader(conn)
w := func(s string) { _, _ = conn.Write([]byte(s + "\r\n")) }
w("220 fake.local ESMTP ready")
for {
line, err := br.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
f.record(line)
switch {
case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
// 不声明 STARTTLS:让代码走明文分支(测试目标是信封逻辑,不是 TLS)。
w("250-fake.local")
if f.advertiseAuth {
w("250-AUTH PLAIN")
}
w("250 8BITMIME")
case strings.HasPrefix(line, "AUTH"):
// 简化处理:PLAIN 的初始应答可能跨多行,直接接受。
w("235 2.7.0 Authentication successful")
case strings.HasPrefix(line, "MAIL FROM"):
w(f.mailReply)
case strings.HasPrefix(line, "RCPT TO"):
w(f.rcptReply)
case strings.HasPrefix(line, "DATA"):
w("354 End data with <CR><LF>.<CR><LF>")
var sb strings.Builder
for {
dl, err := br.ReadString('\n')
if err != nil {
return
}
if strings.TrimRight(dl, "\r\n") == "." {
break
}
sb.WriteString(dl)
}
f.mu.Lock()
f.data = sb.String()
f.mu.Unlock()
w("250 2.0.0 Ok: queued as FAKE1")
case strings.HasPrefix(line, "QUIT"):
w("221 2.0.0 Bye")
return
default:
w("250 OK")
}
}
}
func emailCfg(t *testing.T, f *fakeSMTP, extra map[string]any) map[string]any {
t.Helper()
host, port := f.hostPort(t)
cfg := map[string]any{
"host": host,
"port": float64(port),
"from": "artex@example.com",
"to": []any{"a@example.com", "b@example.com"},
}
for k, v := range extra {
cfg[k] = v
}
return cfg
}
func TestEmailSendDeliversFullMessage(t *testing.T) {
f := newFakeSMTP(t)
f.advertiseAuth = true
cfg := emailCfg(t, f, map[string]any{"username": "artex", "password": "pw"})
if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
// 信封阶段必须走到:发件人、两个收件人、DATA。
for _, want := range []string{"MAIL FROM:<artex@example.com>", "RCPT TO:<a@example.com>", "RCPT TO:<b@example.com>", "DATA", "AUTH", "QUIT"} {
if !f.sawCommand(want) {
t.Errorf("SMTP 会话里缺少 %q,实际命令:%v", want, f.commands)
}
}
// 正文是 base64 的 HTML,且要带上真实的漏洞内容(编码后仍可辨认)。
body := f.body()
if body == "" {
t.Fatal("DATA 阶段没有收到正文")
}
if !strings.Contains(body, "Content-Type: text/html") {
t.Errorf("缺少 Content-Type 头:\n%s", body)
}
if !strings.Contains(body, "base64") {
t.Errorf("正文未按 base64 编码(长 HTML 行会破坏 SMTP 的 1000 字节行长限制):\n%s", body)
}
// 多个收件人都要出现在 To 头里。
if !strings.Contains(body, "a@example.com, b@example.com") {
t.Errorf("To 头未包含全部收件人:\n%s", body)
}
}
func TestEmailSendWithoutAuth(t *testing.T) {
// 未配账号时不应发 AUTH —— 有些中继会因此拒收。
f := newFakeSMTP(t)
cfg := emailCfg(t, f, nil)
if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
t.Fatalf("投递失败: %v", err)
}
if f.sawCommand("AUTH") {
t.Errorf("未配账号却发了 AUTH: %v", f.commands)
}
}
// TestEmailSendClassifiesSMTPReplies 是本次审计修复的直接验证:
// 5xx 判永久失败、4xx(灰名单)判可重试。
func TestEmailSendClassifiesSMTPReplies(t *testing.T) {
cases := []struct {
name string
rcptReply string
mailReply string
permanent bool
}{
{"收件人被 550 永久拒绝", "550 5.1.1 User unknown", "250 OK", true},
{"收件人遇 450 灰名单", "450 4.7.1 Greylisting in action", "250 OK", false},
{"收件人遇 452 邮箱满", "452 4.2.2 Mailbox full", "250 OK", false},
{"发件人被 553 永久拒绝", "250 OK", "553 5.1.3 Bad address", true},
{"发件人遇 451 临时错误", "250 OK", "451 4.3.0 Temporary failure", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newFakeSMTP(t)
f.rcptReply = tc.rcptReply
f.mailReply = tc.mailReply
_, err := (emailChannel{}).Send(context.Background(), emailCfg(t, f, nil), singleMsg())
if err == nil {
t.Fatal("应报错")
}
if got := IsPermanent(err); got != tc.permanent {
t.Fatalf("permanent 判定错误:期望 %v 得到 %v (%v)", tc.permanent, got, err)
}
// 服务器原文要保留,否则用户不知道该找服务器管理员还是改地址。
if !strings.Contains(err.Error(), strings.Fields(tc.rcptReply)[0]) && !strings.Contains(err.Error(), strings.Fields(tc.mailReply)[0]) {
t.Errorf("错误里应保留服务器的应答码: %v", err)
}
})
}
}
func TestEmailSendRefusesPlaintextCredentials(t *testing.T) {
// net/smtp 的 PlainAuth 拒绝在未加密连接上发凭据(除非目标是 localhost)。
// 这是**正确**的安全行为,不能被绕过;但要给出能指导用户修复的错误。
// 这里用一个非 localhost 的主机名触发它。
f := newFakeSMTP(t)
f.advertiseAuth = true
_, port := f.hostPort(t)
cfg := map[string]any{
"host": "smtp.example.com", // 非 localhost
"port": float64(port),
"from": "a@example.com",
"to": []any{"b@example.com"},
"username": "artex",
"password": "pw",
}
_, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg())
if err == nil {
t.Skip("本机 DNS 解析到了本地服务器,跳过(不影响其它用例)")
}
// 连不上 或 被拒发凭据都算通过这条断言;关键是**不能**静默把密码发出去。
if !IsPermanent(err) && !strings.Contains(err.Error(), "연결") {
t.Logf("错误:%v(非 localhost 下未能连上属预期)", err)
}
}
func TestEmailValidateReportsMissingFields(t *testing.T) {
// 邮件渠道的配置字段最多,遗漏任一个都会在投递时才暴露;这里逐个确认
// 校验能提前拦下。断言检查的是「错误信息提到了缺什么」。
cases := []struct {
name string
cfg map[string]any
}{
{"缺 host", map[string]any{"port": float64(25), "from": "a@b.c", "to": []any{"d@e.f"}}},
{"缺 port", map[string]any{"host": "smtp.example.com"}},
{"port 越界", map[string]any{"host": "h", "port": float64(70000), "from": "a@b.c", "to": []any{"d@e.f"}}},
{"缺 from", map[string]any{"host": "h", "port": float64(25), "to": []any{"d@e.f"}}},
{"缺 to", map[string]any{"host": "h", "port": float64(25), "from": "a@b.c"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := (emailChannel{}).Validate(tc.cfg); err == nil {
t.Fatalf("应校验失败: %v", tc.cfg)
}
})
}
}
// TestEmailConfigTolerance 覆盖配置读取的容错:JSONB 里数值是 float64,
// 但用户在 UI 里可能把端口填成字符串;数组也可能是单个字符串。
func TestEmailConfigTolerance(t *testing.T) {
cfg := map[string]any{
"host": "smtp.example.com",
"port": "587", // 字符串形式的端口
"from": "a@b.c",
"to": "d@e.f", // 单个字符串而非数组
"tls": "true", // 字符串形式的布尔
}
if err := (emailChannel{}).Validate(cfg); err != nil {
t.Fatalf("应容忍字符串形式的数值: %v", err)
}
if got := cfgInt(cfg, "port"); got != 587 {
t.Errorf("cfgInt 未解析字符串端口,得到 %d", got)
}
if !cfgBool(cfg, "tls") {
t.Error("cfgBool 未解析字符串 \"true\"")
}
if to := cfgStrings(cfg, "to"); len(to) != 1 || to[0] != "d@e.f" {
t.Errorf("cfgStrings 未兼容单字符串,得到 %v", to)
}
}
// TestFilterValidateRejectsTypo 是审计修复的直接验证:
// 门槛打错字必须在写入时被拦,否则过滤器会静默失效变成全推。
func TestFilterValidateRejectsTypo(t *testing.T) {
good := []string{"", "low", "medium", "high", "critical"}
for _, s := range good {
if err := (Filter{MinSeverity: s}).Validate(); err != nil {
t.Errorf("合法门槛 %q 被拒: %v", s, err)
}
}
// 这些是真实会发生的笔误——全部必须被拒。
for _, s := range []string{"hgih", "HIGH", "严重", "high ", "crit"} {
err := (Filter{MinSeverity: s}).Validate()
if err == nil {
t.Errorf("非法门槛 %q 应被拒绝(否则过滤器静默失效、变成全推)", s)
continue
}
// 错误信息要能指导用户改对。
if !strings.Contains(err.Error(), "low") || !strings.Contains(err.Error(), "critical") {
t.Errorf("错误信息应列出可选值,得到 %q", err.Error())
}
}
}
// TestFilterValidateIsWriteTimeOnly 锁住「写入严、读取宽」的分工:
// 库里已有的坏值不能让渠道整个读不出来(那会让历史渠道突然全部停止推送)。
func TestFilterValidateIsWriteTimeOnly(t *testing.T) {
raw := []byte(`{"min_severity":"hgih"}`)
f := ParseFilter(raw) // 不报错
if f.MinSeverity != "hgih" {
t.Fatalf("读取路径应原样保留,得到 %q", f.MinSeverity)
}
// 且该渠道仍能对事件做出判定(不 panic、不阻塞)。
_ = Match(f, Snapshot{Kind: EventFindingCreated, Severity: "critical"})
}
+69
View File
@@ -0,0 +1,69 @@
package notify
// Snapshot 是 notification_events.snapshot 这一 JSONB 列的契约。写方是 db 层的
// 漏洞落库事务,读方是 server 层的投递引擎与过滤匹配。定义放在本包是因为它是
// 「通知领域」的载荷:db 只负责序列化,不理解字段含义。
//
// 为什么冗余存漏洞字段而不在渲染时回查:漏洞事后会被改名、改级别、改状态,
// 而推送内容应当反映**事发当时**的结论——回查会得到「事后被改成 low」的
// 危险误导。另外 fan-out 与渲染因此不必 JOIN findings/tasks/assets 三张表。
type Snapshot struct {
// 事件类型:finding_created / finding_status_changed
Kind string `json:"kind"`
FindingID int64 `json:"finding_id"`
TaskID int64 `json:"task_id"`
VulnClass string `json:"vulnclass"`
Name string `json:"name"`
Severity string `json:"severity"`
Summary string `json:"summary"`
AssetIDs []int64 `json:"asset_ids"`
// 仅 kind=finding_status_changed 时非空。
FromStatus string `json:"from_status,omitempty"`
ToStatus string `json:"to_status,omitempty"`
}
// Item 是一条待推送的漏洞,供渠道渲染。
type Item struct {
FindingID int64
Name string
VulnClass string
Severity string
Summary string
// Assets 是解析后的资产展示名(如域名/IP)。由 server 层填充——
// 本包不碰数据库,拿不到名字。
Assets []string
// DetailURL 是漏洞详情回链;为空表示未配 public_base_url,渲染时省略。
DetailURL string
// 状态变更事件专用;两项均非空时渲染成「待处理 → 已修复」。
FromStatus string
ToStatus string
}
// IsStatusChange 报告该条目是否为状态变更事件。
func (i Item) IsStatusChange() bool { return i.FromStatus != "" || i.ToStatus != "" }
// Title 返回条目的展示标题:优先人工命名的 name,回退漏洞类型 vulnclass,
// 两者都空时用一个占位符——绝不输出空标题。
func (i Item) Title() string {
if i.Name != "" {
return i.Name
}
if i.VulnClass != "" {
return i.VulnClass
}
return "(이름 없는 취약점)"
}
// Message 是一次渠道发送的完整内容。
type Message struct {
// 单条推送时长度为 1;汇总推送(digest)时为一整批。
// 空切片是非法的,调用方须保证至少一条。
Items []Item
// Batch=true 时按汇总消息渲染(换标题、带上时间窗与条数)。
Batch bool
// WindowMinutes 是汇总周期(分钟),仅 Batch=true 时用于文案「近 N 分钟」。
// 刻意由配置显式传入而不是渲染时算 time.Since:渲染保持确定性,才好测。
WindowMinutes int
// HomeURL 是平台面板地址(全局 public_base_url);空则不带面板入口。
HomeURL string
}
+206
View File
@@ -0,0 +1,206 @@
package notify
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
)
// feishuChannel 实现飞书(含 Lark)自定义机器人,走交互式卡片。
//
// 平台特性:
// - 加签算法与钉钉**不同**,且极易写错,见 feishuSign 注释。
// - 与钉钉一样把业务错误塞在 HTTP 200 的 body 里(code != 0)。
// - 卡片 header 支持颜色模板,用级别映射配色,让人在消息列表里一眼看出严重程度。
type feishuChannel struct{}
func (feishuChannel) Kind() string { return KindFeishu }
// 飞书自定义机器人约 5 次/秒,折合 100 次/分钟。
func (feishuChannel) DefaultRatePerMin() int { return 100 }
// Webhook 地址末段即机器人唯一标识,属凭据。
func (feishuChannel) SecretKeys() []string { return []string{"webhook", "secret"} }
// 同理:改 Webhook 地址必须对新地址重新表态签名密钥。
func (feishuChannel) DestinationKeys() []string { return []string{"webhook"} }
func (feishuChannel) Validate(cfg map[string]any) error {
hook := cfgString(cfg, "webhook")
if hook == "" {
return errors.New("Webhook 주소가 없습니다")
}
if err := validateHTTPURL(hook); err != nil {
return fmt.Errorf("Webhook 주소가 올바르지 않습니다: %w", err)
}
return nil
}
func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
if err := c.Validate(cfg); err != nil {
return 0, Permanent(err)
}
card, kept := feishuCard(m)
payload := map[string]any{
"msg_type": "interactive",
"card": card,
}
// 加签参数与消息同层,且只在配置了 secret 时出现。
if secret := cfgString(cfg, "secret"); secret != "" {
ts := strconv.FormatInt(time.Now().Unix(), 10)
payload["timestamp"] = ts
payload["sign"] = feishuSign(ts, secret)
}
raw, err := doJSON(ctx, "POST", cfgString(cfg, "webhook"), nil, payload)
if err != nil {
return 0, err
}
var res struct {
Code int `json:"code"`
Msg string `json:"msg"`
// 部分版本的飞书 hook 用这套字段名,一并兼容。
StatusCode int `json:"StatusCode"`
StatusMessage string `json:"StatusMessage"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return 0, fmt.Errorf("Feishu 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
}
if res.Code != 0 {
return 0, Permanent(fmt.Errorf("Feishu에서 오류가 발생했습니다 (%d): %s", res.Code, res.Msg))
}
if res.StatusCode != 0 {
return 0, Permanent(fmt.Errorf("Feishu에서 오류가 발생했습니다 (%d): %s", res.StatusCode, res.StatusMessage))
}
return kept, nil
}
// feishuSign 按飞书官方规则计算签名。
//
// 这里特别容易踩坑:官方样例是
//
// hmac.new(string_to_sign.encode(), digestmod=sha256)
//
// 也就是 **key = timestamp + "\n" + secret,message 为空**,而不是直觉上的
// 「key=secret, message=stringToSign」——那正是钉钉的算法。两边算法刚好反过来,
// 照着另一家的实现写必然签名校验失败(报 19021)。
func feishuSign(timestamp, secret string) string {
stringToSign := timestamp + "\n" + secret
mac := hmac.New(sha256.New, []byte(stringToSign))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
// feishuSeverityTemplate 把漏洞级别映射到卡片 header 配色模板。
// 未知级别用 grey——不用 blue,免得和 low 混淆。
func feishuSeverityTemplate(severity string) string {
switch severity {
case "critical":
return "red"
case "high":
return "orange"
case "medium":
return "yellow"
case "low":
return "blue"
default:
return "grey"
}
}
// feishuMaxCardBytes 是卡片内容的保守上限。飞书对卡片有体积限制,超了整条被拒;
// 取一个明显低于官方上限的值,把 JSON 包装开销也算进来。
const feishuMaxCardBytes = 24000
// feishuCard 构造交互式卡片,返回卡片与**实际写入的条目数**。
// kept 的用途同 markdownBody:只有真正进了卡片的条目才该被标记为已送达。
func feishuCard(m Message) (map[string]any, int) {
elements := []any{}
kept := 0
if m.Batch {
// 先按整条打包再拼头部:头部要写「其余 N 条将在下一条消息继续」,
// N 必须来自实际装下的条数。
kept = packItemCount(m.Items, feishuMaxCardBytes, markdownReservedBytes, "", byteSize, func(it Item, idx int) string {
return feishuBatchLine(it, idx+1)
})
items := m.Items[:kept]
elements = append(elements, feishuMarkdownDiv(markdownBatchIntro(m, items, len(m.Items))))
for i, it := range items {
elements = append(elements, feishuMarkdownDiv(feishuBatchLine(it, i+1)))
}
if m.HomeURL != "" {
elements = append(elements, feishuButton("플랫폼에서 전체 보기", m.HomeURL))
}
} else if len(m.Items) > 0 {
kept = 1
it := m.Items[0]
elements = append(elements, feishuMarkdownDiv(feishuItemLines(it)))
if it.DetailURL != "" {
elements = append(elements, feishuButton("상세 보기", it.DetailURL))
}
}
card := map[string]any{
"config": map[string]any{"wide_screen_mode": true},
"header": map[string]any{"title": map[string]any{"tag": "plain_text", "content": markdownTitle(m)}},
"elements": elements,
}
if len(m.Items) > 0 {
card["header"].(map[string]any)["template"] = feishuSeverityTemplate(m.Items[0].Severity)
}
return card, kept
}
func feishuMarkdownDiv(content string) map[string]any {
return map[string]any{"tag": "div", "text": map[string]any{"tag": "lark_md", "content": content}}
}
func feishuButton(label, url string) map[string]any {
return map[string]any{
"tag": "action",
"actions": []any{map[string]any{
"tag": "button",
"text": map[string]any{"tag": "lark_md", "content": label},
"url": url,
"type": "primary",
}},
}
}
// feishuItemLines 渲染单个漏洞的 lark_md 正文。
//
// lark_md 与 markdown 是同族的文本格式,同样会解析链接与强调,所以来自外部
// 的字段一律过 markdownText(单行化 + 转义)——否则一条漏洞标题就能在
// 飞书里变成可点击的外链。
func feishuItemLines(it Item) string {
out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), markdownText(it.Title(), 0))
if it.IsStatusChange() {
out += fmt.Sprintf("\n**상태 변경**: %s → %s",
markdownText(StatusLabel(it.FromStatus), 0), markdownText(StatusLabel(it.ToStatus), 0))
}
if it.VulnClass != "" && it.VulnClass != it.Title() {
out += fmt.Sprintf("\n**유형**: %s", markdownText(it.VulnClass, 0))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
out += fmt.Sprintf("\n**자산**: %s", markdownText(a, 0))
}
if it.Summary != "" {
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
out += fmt.Sprintf("\n**개요**: %s", s)
}
}
return out
}
// feishuBatchLine 渲染汇总卡片里的一条。
func feishuBatchLine(it Item, index int) string {
line := fmt.Sprintf("**%d. %s · %s**", index, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
line += " — " + markdownText(a, 0)
}
return line
}
+119
View File
@@ -0,0 +1,119 @@
package notify
import (
"encoding/json"
"fmt"
"slices"
"strings"
)
// Filter 是 notification_channels.filter 这一 JSONB 列的契约:渠道实例的过滤条件。
// 所有字段都可选,缺省即「不过滤」——这正是畸形配置的兜底语义,见 ParseFilter。
type Filter struct {
// MinSeverity 是最低级别门槛(low/medium/high/critical),空=不设门槛。
MinSeverity string `json:"min_severity"`
// TaskIDs / AssetIDs 为空数组表示不限;非空则要求事件与它有交集。
TaskIDs []int64 `json:"task_ids"`
AssetIDs []int64 `json:"asset_ids"`
// VulnClassInclude 为空表示全收;非空则要求 vulnclass 命中其中任一关键词。
// VulnClassExclude 命中任一关键词即排除(排除优先于包含)。
// 匹配方式为大小写不敏感的子串——比正则安全:用户配错正则不会让渠道静默失效。
VulnClassInclude []string `json:"vulnclass_include"`
VulnClassExclude []string `json:"vulnclass_exclude"`
// OnStatusChange 决定该渠道是否接收漏洞状态变更事件(仅 realtime 模式有意义)。
OnStatusChange bool `json:"on_status_change"`
}
// ParseFilter 解析渠道过滤配置。
//
// **永不返回 error。** 这是刻意的设计选择:过滤条件配置畸形时一律退化为零值
// Filter(= 不过滤 = 全部命中),因为对一个漏洞通知系统来说,**多推一条远好过
// 静默漏掉一条高危**。让解析失败变成「不推送」,等于给用户一个看起来配好了、
// 实际什么都不推的渠道——这是最糟的失败模式。
func ParseFilter(raw []byte) Filter {
var f Filter
if len(raw) == 0 {
return f
}
// 解析失败时 f 保持零值,即不过滤。
_ = json.Unmarshal(raw, &f)
return f
}
// ValidMinSeverity 报告 s 是否为合法的级别门槛(空串表示不设门槛)。
func ValidMinSeverity(s string) bool {
if s == "" {
return true
}
_, ok := severityRank[s]
return ok
}
// Validate 校验过滤配置里**取值受限**的字段,供保存渠道时调用。
//
// 为什么必须在写入时拦:Match 对未知门槛的判定是 `rank >= 0`,恒为真——
// 也就是说 min_severity 打错一个字("hgih"),过滤器会**静默失效**变成
// 「全推」。这与本包「宁可多推不可漏推」的取舍方向一致(不会漏),
// 但后果是用户以为自己在做分级推送、实际把全部漏洞灌进群里,
// 而且没有任何迹象提示他配错了。这类「静默降级」正应该在入口处拦掉。
//
// 注意 Validate 只用于**写入**路径。读取路径仍走 ParseFilter 的宽容语义,
// 这样历史数据里已经存在的坏值不会让渠道整个读不出来。
func (f Filter) Validate() error {
if !ValidMinSeverity(f.MinSeverity) {
return fmt.Errorf("올바르지 않은 최소 심각도입니다: %q. low / medium / high / critical 중에서 선택하거나, 비워 두면 제한하지 않습니다", f.MinSeverity)
}
return nil
}
// Match 判定一个事件是否应投递到带有该过滤条件的渠道。
//
// **永不返回 error**,理由同 ParseFilter:任何内部异常都按「命中」处理。
// 判定顺序:事件类型 → 级别门槛 → 任务/资产范围 → 漏洞类型关键词。
func Match(f Filter, s Snapshot) bool {
// 状态变更事件只有显式开启的渠道才接收。默认关,因为绝大多数使用者
// 期望「推送」指的是「发现新漏洞」,而不是流水账式地跟进每个状态流转。
if s.Kind == EventFindingStatusChanged && !f.OnStatusChange {
return false
}
if !AtLeast(s.Severity, f.MinSeverity) {
return false
}
if len(f.TaskIDs) > 0 && !slices.Contains(f.TaskIDs, s.TaskID) {
return false
}
if len(f.AssetIDs) > 0 && !intersectsInt(f.AssetIDs, s.AssetIDs) {
return false
}
// 排除优先:命中任一排除关键词即出局,即便同时命中了包含列表。
if len(f.VulnClassExclude) > 0 && containsAnyFold(s.VulnClass, f.VulnClassExclude) {
return false
}
if len(f.VulnClassInclude) > 0 && !containsAnyFold(s.VulnClass, f.VulnClassInclude) {
return false
}
return true
}
func intersectsInt(a, b []int64) bool {
// 小集合线性扫描即可;两边的量级都是「人手勾选的几十个」,
// 建 map 的开销大于收益。
for _, v := range b {
if slices.Contains(a, v) {
return true
}
}
return false
}
// containsAnyFold 报告 s 是否包含 keywords 中任一关键词(大小写不敏感)。
func containsAnyFold(s string, keywords []string) bool {
lower := strings.ToLower(s)
for _, kw := range keywords {
kw = strings.ToLower(strings.TrimSpace(kw))
if kw != "" && strings.Contains(lower, kw) {
return true
}
}
return false
}
+143
View File
@@ -0,0 +1,143 @@
package notify
import "testing"
func TestParseFilterMalformedFallsBackToMatchAll(t *testing.T) {
// 畸形 JSON、空输入、类型不对的字段——全部必须退化为零值 Filter,
// 即「不过滤」。这条不变量是「宁可多推不可漏推」的落点:
// 一旦这里改成报错或半解析,用户配错一个字符就会静默丢掉所有高危通知。
cases := []struct {
name string
raw string
}{
{"空输入", ""},
{"非法 JSON", `{not json`},
{"截断的 JSON", `{"min_severity":`},
{"类型不匹配", `{"min_severity": 123, "task_ids": "abc"}`},
{"顶层是数组", `[1,2,3]`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := ParseFilter([]byte(tc.raw))
if f.MinSeverity != "" || len(f.TaskIDs) != 0 || len(f.AssetIDs) != 0 {
t.Fatalf("畸形配置应退化为零值 Filter,得到 %+v", f)
}
// 零值 Filter 必须命中任意事件。
ev := Snapshot{Kind: EventFindingCreated, Severity: "low", VulnClass: "XSS"}
if !Match(f, ev) {
t.Fatal("零值 Filter 应命中所有事件")
}
})
}
}
func TestMatchSeverityThreshold(t *testing.T) {
ev := func(sev string) Snapshot {
return Snapshot{Kind: EventFindingCreated, Severity: sev}
}
cases := []struct {
min string
sev string
expect bool
}{
{"", "low", true},
{"", "critical", true},
{"high", "critical", true},
{"high", "high", true},
{"high", "medium", false},
{"high", "low", false},
{"critical", "high", false},
{"critical", "critical", true},
// 未知级别序数为 0,应被任何非空门槛挡住(存疑时不推)。
{"low", "", false},
{"low", "unknown", false},
{"", "", true},
}
for _, tc := range cases {
got := Match(Filter{MinSeverity: tc.min}, ev(tc.sev))
if got != tc.expect {
t.Errorf("min=%q sev=%q: 期望 %v 得到 %v", tc.min, tc.sev, tc.expect, got)
}
}
}
func TestMatchScopeRestrictions(t *testing.T) {
ev := Snapshot{
Kind: EventFindingCreated,
Severity: "high",
TaskID: 7,
AssetIDs: []int64{10, 20},
VulnClass: "SQL注入",
}
cases := []struct {
name string
filter Filter
expect bool
}{
{"空范围=不限", Filter{}, true},
{"任务命中", Filter{TaskIDs: []int64{7}}, true},
{"任务未命中", Filter{TaskIDs: []int64{8}}, false},
{"任务多选含命中", Filter{TaskIDs: []int64{8, 7}}, true},
{"资产有交集", Filter{AssetIDs: []int64{20, 99}}, true},
{"资产无交集", Filter{AssetIDs: []int64{99}}, false},
{"任务与资产同时命中", Filter{TaskIDs: []int64{7}, AssetIDs: []int64{10}}, true},
{"任务命中但资产未命中", Filter{TaskIDs: []int64{7}, AssetIDs: []int64{99}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Match(tc.filter, ev); got != tc.expect {
t.Errorf("期望 %v 得到 %v", tc.expect, got)
}
})
}
}
func TestMatchVulnClassKeywords(t *testing.T) {
ev := func(class string) Snapshot {
return Snapshot{Kind: EventFindingCreated, Severity: "high", VulnClass: class}
}
cases := []struct {
name string
filter Filter
class string
expect bool
}{
{"include 为空=全收", Filter{}, "任意类型", true},
{"include 命中", Filter{VulnClassInclude: []string{"SQL"}}, "SQL注入", true},
{"include 未命中", Filter{VulnClassInclude: []string{"命令执行"}}, "SQL注入", false},
{"include 多词任一命中", Filter{VulnClassInclude: []string{"命令执行", "SQL"}}, "SQL注入", true},
{"大小写不敏感", Filter{VulnClassInclude: []string{"sql"}}, "SQL注入", true},
{"exclude 命中即排除", Filter{VulnClassExclude: []string{"信息泄露"}}, "信息泄露", false},
{"exclude 未命中则放行", Filter{VulnClassExclude: []string{"信息泄露"}}, "SQL注入", true},
// 排除优先于包含:同时命中时应当出局。
{"排除优先于包含", Filter{
VulnClassInclude: []string{"SQL"},
VulnClassExclude: []string{"注入"},
}, "SQL注入", false},
// 纯空白关键词应被忽略,否则会退化成「匹配所有含空格的字符串」。
{"空白关键词被忽略", Filter{VulnClassInclude: []string{"", " "}}, "SQL注入", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Match(tc.filter, ev(tc.class)); got != tc.expect {
t.Errorf("期望 %v 得到 %v", tc.expect, got)
}
})
}
}
func TestMatchStatusChangeRequiresOptIn(t *testing.T) {
ev := Snapshot{Kind: EventFindingStatusChanged, Severity: "critical", FromStatus: "pending", ToStatus: "fixed"}
// 默认关:绝大多数人说的「推送漏洞」指发现新漏洞,不是状态流水账。
if Match(Filter{MinSeverity: "low"}, ev) {
t.Fatal("状态变更事件在未开启时应被跳过")
}
if !Match(Filter{OnStatusChange: true}, ev) {
t.Fatal("开启 on_status_change 后状态变更事件应命中")
}
// 创建事件不受 on_status_change 影响。
created := Snapshot{Kind: EventFindingCreated, Severity: "critical"}
if !Match(Filter{MinSeverity: "low"}, created) {
t.Fatal("创建事件不应依赖 on_status_change")
}
}
+140
View File
@@ -0,0 +1,140 @@
package notify
import (
"fmt"
"strings"
)
// 本文件渲染邮件的 HTML 正文。刻意用内联样式 + 简单表格布局而不是现代 CSS:
// 邮件客户端(尤其 Outlook 与国内企业邮箱)对 <style> 块和 flex/grid 的支持
// 差异极大,内联样式是唯一在各家都能正确显示的写法。
// htmlSeverityColor 返回级别对应的强调色,用于左侧色条与标题。
func htmlSeverityColor(severity string) string {
switch severity {
case "critical":
return "#d32029"
case "high":
return "#e8830c"
case "medium":
return "#d4b106"
case "low":
return "#1677ff"
default:
return "#8c8c8c"
}
}
// htmlTitle 返回邮件主题。
func htmlTitle(m Message) string {
return markdownTitle(m)
}
// htmlBody 渲染邮件正文 HTML。maxRunes<=0 表示不截断。
func htmlBody(m Message, maxRunes int) string {
var b strings.Builder
b.WriteString(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;font-size:14px;color:#262626;line-height:1.6;">`)
if m.Batch {
b.WriteString(htmlBatchIntro(m))
for _, it := range m.Items {
b.WriteString(htmlItem(it, false))
}
} else if len(m.Items) > 0 {
b.WriteString(htmlItem(m.Items[0], true))
}
if m.HomeURL != "" {
fmt.Fprintf(&b, `<p style="margin:16px 0 0;"><a href="%s" style="color:#1677ff;">플랫폼에서 전체 보기</a></p>`, htmlEscapeAttr(m.HomeURL))
}
b.WriteString(`</div>`)
return TruncateHTML(b.String(), maxRunes)
}
// htmlBatchIntro 渲染汇总邮件开头:条数与级别分布。
func htmlBatchIntro(m Message) string {
var b strings.Builder
if m.WindowMinutes > 0 {
fmt.Fprintf(&b, `<h2 style="font-size:16px;margin:0 0 4px;">최근 %d분간 신규 취약점 %d건</h2>`, m.WindowMinutes, len(m.Items))
} else {
fmt.Fprintf(&b, `<h2 style="font-size:16px;margin:0 0 4px;">신규 취약점 %d건</h2>`, len(m.Items))
}
counts := map[string]int{}
for _, it := range m.Items {
counts[it.Severity]++
}
var parts []string
for _, sev := range []string{"critical", "high", "medium", "low"} {
if n := counts[sev]; n > 0 {
parts = append(parts, fmt.Sprintf(`<span style="color:%s;font-weight:600;">%s %d</span>`,
htmlSeverityColor(sev), htmlEscape(SeverityLabel(sev)), n))
}
}
if len(parts) > 0 {
fmt.Fprintf(&b, `<p style="margin:0 0 12px;">%s</p>`, strings.Join(parts, " &middot; "))
}
return b.String()
}
// htmlItem 渲染单个漏洞。full=true 时含摘要与回链(单条推送),
// false 时压缩成一行(汇总列表)。
func htmlItem(it Item, full bool) string {
color := htmlSeverityColor(it.Severity)
var b strings.Builder
if full {
fmt.Fprintf(&b, `<div style="border-left:4px solid %s;padding:8px 0 8px 12px;margin-bottom:12px;">`, color)
} else {
fmt.Fprintf(&b, `<div style="border-left:3px solid %s;padding:4px 0 4px 10px;margin-bottom:8px;">`, color)
}
fmt.Fprintf(&b, `<div style="font-weight:600;">%s &middot; %s</div>`,
htmlEscape(SeverityLabel(it.Severity)), htmlEscape(it.Title()))
if !full {
var extras []string
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
extras = append(extras, htmlEscape(a))
}
if it.Summary != "" {
extras = append(extras, htmlEscape(OneLine(it.Summary, 60)))
}
if len(extras) > 0 {
fmt.Fprintf(&b, `<div style="color:#595959;font-size:13px;">%s</div>`, strings.Join(extras, " &middot; "))
}
b.WriteString(`</div>`)
return b.String()
}
if it.IsStatusChange() {
fmt.Fprintf(&b, `<div><b>상태 변경</b>: %s → %s</div>`,
htmlEscape(StatusLabel(it.FromStatus)), htmlEscape(StatusLabel(it.ToStatus)))
}
if it.VulnClass != "" && it.VulnClass != it.Title() {
fmt.Fprintf(&b, `<div><b>유형</b>: %s</div>`, htmlEscape(it.VulnClass))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
fmt.Fprintf(&b, `<div><b>자산</b>: %s</div>`, htmlEscape(a))
}
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
fmt.Fprintf(&b, `<div><b>개요</b>: %s</div>`, htmlEscape(s))
}
if it.DetailURL != "" {
fmt.Fprintf(&b, `<div style="margin-top:6px;"><a href="%s" style="color:#1677ff;">상세 보기</a></div>`, htmlEscapeAttr(it.DetailURL))
}
b.WriteString(`</div>`)
return b.String()
}
// htmlEscape 转义 HTML 文本内容。漏洞标题与摘要来自被测目标与模型输出,
// 是不可信内容——不转义就等于允许把任意 HTML(含外链图片)注入到邮件里。
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
// htmlEscapeAttr 转义 HTML 属性值(在文本转义之外额外处理引号,
// 防止 URL 里的引号提前闭合 href 属性)。
func htmlEscapeAttr(s string) string {
s = htmlEscape(s)
s = strings.ReplaceAll(s, "\"", "&quot;")
return s
}
+94
View File
@@ -0,0 +1,94 @@
package notify
import (
"strings"
"testing"
)
// 이 파일은 F4 ③(이메일 본문·HTML 템플릿) 한국어화를 회귀로부터 지킨다.
// html.go 의 라벨과 email.go 의 검증 오류가 중국어로 되돌아가면 잡아낸다.
// 헬퍼 hasHan·hasHangul·assertKorean 은 notify_localized_test.go 에 있다(같은 패키지).
//
// 데이터(제목·유형·자산·개요)는 전부 ASCII 로 둔다. 그래야 "출력 전체에 한자 0"
// 이라는 단언이 콘텐츠가 아니라 골격 라벨의 회귀만 정확히 포착한다. 심각도·상태
// 라벨은 F4 ①에서 이미 한국어라 출력에 한글로 나오며(한자 아님) 단언을 통과한다.
// TestHTMLItemLabelsLocalized 는 단건 이메일 본문의 모든 라벨 분기를 켠 뒤
// 골격이 한국어이고 중국어 한자가 없음을 확인한다.
func TestHTMLItemLabelsLocalized(t *testing.T) {
m := Message{
HomeURL: "https://example.com/panel",
Items: []Item{{
Name: "sqli-login", // Title() = Name
VulnClass: "injection", // Title() 과 달라야 유형 줄이 렌더됨
Severity: "high", // SeverityLabel → 높음(한글)
Summary: "login form is injectable",
Assets: []string{"host-a.example.com"},
DetailURL: "https://example.com/f/1",
FromStatus: "pending", // IsStatusChange()=true → 상태 변경 줄
ToStatus: "fixed",
}},
}
out := htmlBody(m, 0)
if hasHan(out) {
t.Errorf("이메일 본문에 중국어 한자가 남아 있습니다:\n%s", out)
}
for _, want := range []string{
"상태 변경", "유형", "자산", "개요", "상세 보기", "플랫폼에서 전체 보기",
} {
if !strings.Contains(out, want) {
t.Errorf("이메일 본문에 %q 라벨이 없습니다:\n%s", want, out)
}
}
}
// TestHTMLBatchIntroLocalized 는 다건(digest) 머리말의 두 분기(시간창 유무)가
// 한국어로 렌더되는지 확인한다.
func TestHTMLBatchIntroLocalized(t *testing.T) {
items := []Item{{Name: "a", Severity: "high"}, {Name: "b", Severity: "low"}}
withWindow := htmlBatchIntro(Message{Batch: true, WindowMinutes: 30, Items: items})
assertKorean(t, "htmlBatchIntro(시간창)", withWindow)
for _, want := range []string{"최근 30분간", "신규 취약점", "2건"} {
if !strings.Contains(withWindow, want) {
t.Errorf("시간창 머리말에 %q 가 없습니다: %q", want, withWindow)
}
}
noWindow := htmlBatchIntro(Message{Batch: true, WindowMinutes: 0, Items: items})
assertKorean(t, "htmlBatchIntro(시간창 없음)", noWindow)
if !strings.Contains(noWindow, "신규 취약점 2건") {
t.Errorf("시간창 없는 머리말이 %q 를 포함해야 합니다: %q", "신규 취약점 2건", noWindow)
}
if strings.Contains(noWindow, "분간") {
t.Errorf("시간창이 없는데 '분간' 이 들어갔습니다: %q", noWindow)
}
}
// TestEmailValidateLocalized 는 SMTP 설정 검증 오류 네 가지가 한국어이고,
// 각 오류가 어떤 필드가 문제인지 알려 주는지 확인한다. 이 메시지는 알림 채널을
// 설정하는 사용자에게 그대로 표시된다.
func TestEmailValidateLocalized(t *testing.T) {
cases := []struct {
name string
cfg map[string]any
substr string
}{
{"서버 주소 누락", map[string]any{"port": float64(25), "from": "a@b.c", "to": []any{"d@e.f"}}, "SMTP"},
{"포트 범위 벗어남", map[string]any{"host": "h"}, "포트"},
{"발신자 누락", map[string]any{"host": "h", "port": float64(25), "to": []any{"d@e.f"}}, "발신자"},
{"수신자 누락", map[string]any{"host": "h", "port": float64(25), "from": "a@b.c"}, "수신자"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := (emailChannel{}).Validate(tc.cfg)
if err == nil {
t.Fatalf("검증이 실패해야 합니다: %v", tc.cfg)
}
assertKorean(t, "Validate("+tc.name+")", err.Error())
if !strings.Contains(err.Error(), tc.substr) {
t.Errorf("오류 메시지에 %q 가 있어야 합니다, 받은 값 %q", tc.substr, err.Error())
}
})
}
}
+255
View File
@@ -0,0 +1,255 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"syscall"
"time"
)
// allowLocalTargets 决定是否允许把消息投递到环回 / 链路本地地址。
//
// 默认拒绝。这几段地址不是 IM 机器人或公网邮件服务器会出现的地方,而它们能
// 打到的东西很敏感:同机另一个服务的管理端口、以及云环境的元数据端点
// (169.254.169.254,可读出实例凭据)。投递地址是管理员配的,但一个被 XSS/CSRF
// 借用的管理会话、或共用同一 JWT 的第二个人,都能靠改配置把响应内容读回来
// ——doJSON 会把 4xx/5xx 的响应体前 200 字节写进 last_error,而投递历史接口
// 会把它回显出来,这就是一条半盲读原语。
//
// 但「本机 SMTP 中继」(127.0.0.1:25 上的 postfix)是自建邮件的常见配置,
// 一刀切会把人卡住。所以留一个显式逃生口而不是硬编码放行:
// 设置 ARTEX_NOTIFY_ALLOW_LOCAL=1 即允许。
//
// 导出为 AllowLocalTargetsEnv 是为了让测试能明确地打开它——本包与 server 包的
// 用例大量使用 127.0.0.1 上的 httptest 假接收端,不打开就全部被守卫拦下。
const AllowLocalTargetsEnv = "ARTEX_NOTIFY_ALLOW_LOCAL"
func allowLocalTargets() bool {
v := strings.TrimSpace(os.Getenv(AllowLocalTargetsEnv))
return v == "1" || strings.EqualFold(v, "true")
}
// isBlockedDialIP 报告目标 IP 是否属于「默认不允许投递」的地址段。
//
// 只拒绝环回、链路本地(含云元数据 169.254.169.254)、未指定与组播。
// **不拒绝** RFC1918 私网:内网自建 Mattermost / SMTP 中继是很常见的合法用法,
// 把它们一并挡掉会让功能在真实环境里直接不可用。这条取舍是刻意的——
// 防护要挡住真正敏感的目标,同时不能把正常部署一起废掉。
func isBlockedDialIP(ip net.IP) bool {
if ip == nil {
return true
}
// IPv4-mapped IPv6(::ffff:127.0.0.1)要还原成 IPv4 再判,否则绕过检查。
if v4 := ip.To4(); v4 != nil {
ip = v4
}
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
}
// blockInternalDial 是 http.Transport 拨号器的 Control 钩子,在**连接建立时**
// 检查目标地址。
//
// 为什么设在拨号阶段而不是只在保存配置时校验:这里才是最终生效点。
// 它同时覆盖两种绕过配置校验的情形——DNS 重绑定(校验时解析到公网 IP、
// 真正连接时解析到内网)与重定向(虽然我们已拒绝跨主机跳转,但同主机跳转
// 仍可能把路径指到别处)。
func blockInternalDial(_, address string, _ syscall.RawConn) error {
if allowLocalTargets() {
return nil
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("대상 주소를 해석할 수 없습니다: %q", host)
}
if isBlockedDialIP(ip) {
return fmt.Errorf("로컬 또는 링크 로컬 주소 %s 로는 전송하지 않습니다 (로컬 서비스로 보내야 하면 %s=1 로 설정하세요)", ip, AllowLocalTargetsEnv)
}
return nil
}
// notifyTransport 在默认 Transport 的基础上只加一个拨号守卫。
// 用 Clone 保留默认的全部调优(连接池、HTTP/2、超时、proxy 等),
// 避免为了加一个检查而改动其它行为。
var notifyTransport = func() *http.Transport {
t, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{}
}
clone := t.Clone()
clone.DialContext = (&net.Dialer{Timeout: 10 * time.Second, Control: blockInternalDial}).DialContext
return clone
}()
// httpClient 是所有渠道投递共用的客户端。
//
// 刻意**不**复用项目的全局出口代理(server 侧的 GlobalProxy):那个代理是给渗透
// 目标流量用的,常是不稳定的隧道,而通知的可用性不该被目标网络的抖动绑架。
// IM 推送直连即可。超时设为 15 秒——比这更慢的对端实际上已经是故障状态。
//
// 拒绝跨主机重定向:本功能的投递地址都是「一个固定 endpoint」形态,正常不会
// 重定向到别的主机;而这几家的凭据(钉钉的 access_token、企微的 key、Telegram 的
// bot token)**就在 URL 里**,跟随跨主机跳转等于把凭据交给重定向目标。同主机
// 的跳转(如末尾补斜杠)仍允许。
var httpClient = &http.Client{
Timeout: 15 * time.Second,
Transport: notifyTransport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("리다이렉트가 너무 많습니다")
}
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
return fmt.Errorf("다른 호스트로의 리다이렉트는 허용하지 않습니다 (%s → %s)", via[0].URL.Host, req.URL.Host)
}
return nil
},
}
// respBodyLimit 限制读取响应体的大小。对端异常时可能吐回超大内容,而我们只需要
// 错误码和一小段错误描述用于展示在投递历史里。
const respBodyLimit = 8 << 10
// doJSON 发送一次请求并返回响应体(已限长)。
//
// payload 为 nil 时发送空 body(用于 GET 或平台不要求 body 的场景)。
// headers 里的键值原样附加,用于通用 Webhook 的自定义头。
//
// 错误分类是这个函数的核心职责:网络层失败与 5xx/408/429 归为「可重试」,
// 其余 4xx 归为「永久失败」——重试一个 403 只是把同一个错误刷 3 遍日志。
func doJSON(ctx context.Context, method, url string, headers map[string]string, payload any) ([]byte, error) {
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
// 序列化失败是本地 bug(配置字段类型不对),重试也不会变好。
return nil, Permanent(fmt.Errorf("요청 본문을 만들지 못했습니다: %w", err))
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
// URL 非法——多半是用户把地址填错了,属于永久失败。
// 这里同样不能透传 err:url.Parse 的错误文本里含完整地址。
return nil, Permanent(fmt.Errorf("요청 주소가 올바르지 않습니다: %s", redactRequestTarget(url)))
}
if payload != nil {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := httpClient.Do(req)
if err != nil {
// 连接被拒、DNS 失败、超时——多为瞬时故障,交给退避重试。
//
// 错误文本必须脱敏后再往外传。原因:http.Client.Do 返回的是 *url.Error,
// 它的 Error() 是 `Op "完整URL": 底层错误`,而本功能这几家的凭据**就在 URL 里**
// (钉钉 access_token、企微 key、飞书 hook id、Telegram /bot<token>/)。
// 不脱敏的话,凭据会顺着这条错误串流到四个地方:notification_deliveries
// 的 last_error(明文落库)、投递历史接口的响应(**绕过渠道配置的掩码**)、
// 服务端日志、以及测试发送接口回给前端的 502 文本。
return nil, fmt.Errorf("요청에 실패했습니다: %s", redactTransportError(err))
}
defer resp.Body.Close()
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, respBodyLimit))
if readErr != nil {
return nil, fmt.Errorf("응답을 읽지 못했습니다: %w", readErr)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return raw, nil
}
// 429(限流)与 408(超时)值得重试;其余 4xx 是配置或权限问题,重试无意义。
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusRequestTimeout {
return nil, fmt.Errorf("상대 서버가 요청을 제한하거나 응답이 지연되었습니다 (HTTP %d): %s", resp.StatusCode, snippet(raw))
}
if resp.StatusCode >= 500 {
return nil, fmt.Errorf("상대 서버에 오류가 발생했습니다 (HTTP %d): %s", resp.StatusCode, snippet(raw))
}
return nil, Permanent(fmt.Errorf("상대 서버가 요청을 거부했습니다 (HTTP %d): %s", resp.StatusCode, snippet(raw)))
}
// snippet 把响应体压成一行短文本,用于错误信息。响应里可能带换行与大量空白,
// 直接塞进 last_error 会让投递历史页面排版崩掉。
func snippet(raw []byte) string {
return OneLine(string(raw), 200)
}
// redactRequestTarget 把投递地址压成「scheme://host/…」,用于错误信息。
//
// 这是本包唯一的地址脱敏口径,刻意做得**足够粗暴**:除了 scheme 与 host,
// 其余一律丢弃。原因是没有一个「通用且安全」的方式判断 URL 的哪一段是凭据:
//
// 钉钉 凭据在 query /robot/send?access_token=xxx
// 企微 凭据在 query /cgi-bin/webhook/send?key=xxx
// 飞书 凭据在**路径末段** /open-apis/bot/v2/hook/<hook_id>
// Telegram 凭据在**路径中段** /bot<token>/sendMessage
//
// 想「只保留有用部分」就得按渠道打补丁,而漏掉任何一家就是一次凭据泄露。
// 保留 host 已经够用于排查(DNS 解析不了、连不上、证书不对都能定位),
// 具体是哪个机器人由渠道配置里的掩码尾号提示去认。
//
// 解析失败时返回固定占位符——绝不把原始串回显出去。
func redactRequestTarget(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return "(주소를 해석할 수 없음)"
}
return u.Scheme + "://" + u.Host + "/…"
}
// redactTransportError 从传输层错误里剥掉地址,只保留底层原因。
//
// *url.Error 的结构是 {Op, URL, Err},Error() 会把 URL 一起打出来。
// 这里显式取 Err 字段,绕开它的 Error() ——比事后做字符串替换更可靠,
// 因为替换要正确应对 URL 编码/转义后的各种变体,容易漏。
func redactTransportError(err error) string {
var uerr *url.Error
if errors.As(err, &uerr) {
host := ""
if u, parseErr := url.Parse(uerr.URL); parseErr == nil {
host = u.Host
}
if uerr.Err != nil {
return fmt.Sprintf("%s %s: %s", uerr.Op, host, uerr.Err)
}
return fmt.Sprintf("%s %s: 알 수 없는 오류", uerr.Op, host)
}
// 非 *url.Error(如重定向策略返回的错误)也可能带地址,统一走脱敏。
return redactURLsInText(err.Error())
}
// redactURLsInText 把一段文本里出现的 http(s) 地址替换成脱敏形态。
//
// 用于兜底那些拿不到结构化字段的错误(重定向策略错误、第三方库的自定义错误)。
// 只识别 http/https 前缀,按空白与引号切分——地址不会包含这两类字符。
func redactURLsInText(s string) string {
var b strings.Builder
for i := 0; i < len(s); {
rest := s[i:]
if strings.HasPrefix(rest, "http://") || strings.HasPrefix(rest, "https://") {
end := len(rest)
if j := strings.IndexAny(rest, " \t\n\"'"); j >= 0 {
end = j
}
b.WriteString(redactRequestTarget(rest[:end]))
i += end
continue
}
b.WriteByte(s[i])
i++
}
return b.String()
}
+119
View File
@@ -0,0 +1,119 @@
package notify
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
// 本文件覆盖 doJSON 的 HTTP 层错误分级。
//
// 为什么要单独测:各渠道适配器只管平台自己的业务错误码(钉钉 errcode、
// 飞书 code、Telegram ok 字段),而**HTTP 层**的分级是 doJSON 统一做的,
// 两者是两道独立的防线。少了这道,一个返回 503 的中转网关会被当成永久失败、
// 直接放弃重试;而一个 403 会被当成可重试、白白退避三轮。
func replyServer(t *testing.T, status int, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
func TestDoJSONClassifiesHTTPStatus(t *testing.T) {
cases := []struct {
name string
status int
permanent bool
}{
{"200 成功不算错误", 200, false},
{"429 限流可重试", 429, false},
{"408 请求超时可重试", 408, false},
{"500 服务端错误可重试", 500, false},
{"502 网关错误可重试", 502, false},
{"503 服务不可用可重试", 503, false},
{"400 参数错误永久失败", 400, true},
{"401 鉴权失败永久失败", 401, true},
{"403 禁止访问永久失败", 403, true},
{"404 地址不存在永久失败", 404, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := replyServer(t, tc.status, `{"detail":"upstream says no"}`)
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
if tc.status < 300 {
if err != nil {
t.Fatalf("2xx 不应报错: %v", err)
}
return
}
if err == nil {
t.Fatal("非 2xx 应报错")
}
if got := IsPermanent(err); got != tc.permanent {
t.Fatalf("HTTP %d 的 permanent 判定错误:期望 %v 得到 %v (%v)",
tc.status, tc.permanent, got, err)
}
// 状态码必须出现在错误里,否则用户无从判断是自己配错了还是对端挂了。
// 断言数字而不是 Go 的英文 StatusText:本包的文案是中文的
// (与项目其它部分一致),数字才是语言无关、可稳定断言的部分。
if !strings.Contains(err.Error(), strconv.Itoa(tc.status)) {
t.Errorf("错误信息应带上 HTTP 状态码 %d,得到 %v", tc.status, err)
}
})
}
}
// TestDoJSONIncludesResponseSnippet 覆盖 snippet:对端返回的错误说明要带回来,
// 否则用户只知道「失败了」,不知道对端为什么拒绝。
func TestDoJSONIncludesResponseSnippet(t *testing.T) {
srv := replyServer(t, 400, `{"error":"invalid webhook token"}`)
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
if err == nil {
t.Fatal("应报错")
}
if !strings.Contains(err.Error(), "invalid webhook token") {
t.Errorf("错误信息应带回对端的说明,得到 %v", err)
}
}
// TestDoJSONSnippetIsSingleLineAndBounded 约束 snippet 的形态:
// 它对端响应会原样进 last_error 列与前端表格,多行/超长会破坏排版与载荷。
func TestDoJSONSnippetIsSingleLineAndBounded(t *testing.T) {
// 带换行、制表符与 5000 字符超长内容的响应。
long := strings.Repeat("x", 5000)
srv := replyServer(t, 500, "line1\nline2\r\n\tline3 "+long)
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
if err == nil {
t.Fatal("应报错")
}
msg := err.Error()
if strings.ContainsAny(msg, "\r\n\t") {
t.Errorf("错误信息应压成单行,得到 %q", msg)
}
// snippet 上限 200 字符 + 固定前缀,总量必须远小于原始响应。
if len(msg) > 400 {
t.Errorf("错误信息过长(%d 字节),应被 snippet 截断: %q", len(msg), msg)
}
}
// TestDoJSONRejectsOversizedResponse 确认读取有上限:对端异常返回超大内容时
// 不能把整个响应读进内存(投递历史里每一条都会存一份 last_error)。
func TestDoJSONRejectsOversizedResponse(t *testing.T) {
huge := strings.Repeat("A", 1<<20) // 1 MiB
srv := replyServer(t, 400, huge)
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
if err == nil {
t.Fatal("应报错")
}
if len(err.Error()) > 400 {
t.Errorf("超大响应应被限长读取并截断,错误信息长度 %d", len(err.Error()))
}
}
+203
View File
@@ -0,0 +1,203 @@
package notify
import (
"fmt"
"strings"
)
// 本文件是「Markdown 系」渠道(钉钉、企业微信)共用的消息渲染。
// 飞书用卡片 JSON、Telegram 用 HTML、邮件用 HTML,各自在适配器里渲染。
// maxAssetsShown 是消息里最多列出几个资产。一个漏洞可能锚定几十个资产,
// 全列会挤爆消息且没有信息价值——第 4 个之后的域名没人会在 IM 里看。
const maxAssetsShown = 3
// maxSummaryRunes 是摘要被压缩到多少字符。IM 消息是「提示去看详情」,
// 不是报告本体,完整内容在平台里。
const maxSummaryRunes = 120
// markdownReservedBytes 预留给消息头部(汇总行 + 级别分布 + 可能的截断提示)
// 与尾部(平台链接)。按整条打包时把这部分从预算里扣掉,保证头尾不会被截掉——
// 头尾一旦被截,读者连「这是哪一批、还有多少条没显示」都看不出来。
const markdownReservedBytes = 320
// markdownEscape 转义 markdown 元字符。
//
// 为什么必须做:漏洞标题、摘要、类型、资产展示名全都来自**不可信来源**——
// 标题与摘要出自模型输出(模型读的是被测目标的响应),资产的 url 则是扫描
// 得到的完整 URL(含目标可控的查询串)。不转义的话,一条标题为
//
// 登录口 SQL 注入\n[紧急:点此验证账号](http://attacker.tld)
//
// 的漏洞会在安全工程师的钉钉/飞书里渲染成**可点击的外链**;而
// `![](http://attacker.tld/beacon)` 会在渲染时被客户端拉取,等于通报了
// 「这条漏洞已经被看过」并泄露阅读者 IP。就算是无恶意的内容,注入的粗体或
// 引用块也能把下面的严重漏洞挤出折叠线。
//
// 转义集合覆盖标题/链接/强调/列表/引用/删除线这几类会改变结构或产生可点击
// 元素的字符。`\` 必须最先处理,否则会把后面补上的反斜杠再次转义。
func markdownEscape(s string) string {
replacer := strings.NewReplacer(
`\`, `\\`,
"`", "\\`",
"*", `\*`,
"_", `\_`,
"[", `\[`,
"]", `\]`,
"(", `\(`,
")", `\)`,
"!", `\!`,
"#", `\#`,
">", `\>`,
"|", `\|`,
"~", `\~`,
)
return replacer.Replace(s)
}
// markdownText 把不可信文本压成单行并转义,供 markdown 正文使用。
// 单行化是转义之外的另一半:换行本身就能伪造出新的列表项或引用块,
// 而转义字符挡不住它。
func markdownText(s string, maxRunes int) string {
return markdownEscape(OneLine(s, maxRunes))
}
// markdownTitle 返回消息标题(IM 平台的标题栏/卡片标题),内容是**未转义的原文**。
//
// 这里刻意不做转义:这个标题被四种语境的渲染器共用——markdown 正文、Telegram 的
// HTML、飞书卡片的 plain_text、以及通用 Webhook 的 JSON 与邮件主题。每个语境的
// 转义规则都不同(markdown 转义塞进 HTML 会留下可见的反斜杠,塞进 JSON 会污染
// 数据),所以转义必须由各自的输出端负责,见 writeItem / feishuItemLines /
// telegramEscape。曾经在共享函数里加过 markdown 转义,结果 Telegram 消息里
// 出现了 `\(1\)` 这种可见的反斜杠。
func markdownTitle(m Message) string {
if m.Batch {
return fmt.Sprintf("취약점 요약 · 총 %d건", len(m.Items))
}
if len(m.Items) == 0 {
return "취약점 알림"
}
it := m.Items[0]
return fmt.Sprintf("[%s] %s", SeverityLabel(it.Severity), OneLine(it.Title(), 0))
}
// markdownBody 渲染消息正文,返回正文与**实际写入的条目数**。
//
// 返回值 kept 是这次投递真正送达的条目数,调用方据此只把前 kept 条标记为
// 已送达——被渠道长度上限挡在外面的条目必须留待下一批,而不是跟着一起被
// 标记成功。这正是「静默丢失」的来源:消息被截断了,但投递记录显示全部送达,
// 没有任何地方能看出后半截从未发出。
//
// maxBytes<=0 表示不限制。
func markdownBody(m Message, maxBytes int) (string, int) {
if !m.Batch {
if len(m.Items) == 0 {
return "", 0
}
var b strings.Builder
writeItem(&b, m.Items[0], "", true)
// 单条消息即使超长也照发(由最终截断兜底):一条漏洞的部分信息
// 也好过一条都不发。
return TruncateBytes(b.String(), maxBytes), 1
}
footer := ""
if m.HomeURL != "" {
footer = fmt.Sprintf("\n[플랫폼에서 전체 보기](%s)\n", m.HomeURL)
}
kept := packItemCount(m.Items, maxBytes, markdownReservedBytes, footer, byteSize, func(it Item, idx int) string {
var b strings.Builder
writeItem(&b, it, fmt.Sprintf("%d. ", idx+1), false)
return b.String()
})
items := m.Items[:kept]
var b strings.Builder
b.WriteString(markdownBatchIntro(m, items, len(m.Items)))
for i, it := range items {
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false)
}
b.WriteString(footer)
return TruncateBytes(b.String(), maxBytes), kept
}
// markdownBatchIntro 渲染汇总消息的开头:时间窗、条数与级别分布。
// 有了这些,收到汇总的人不用点进平台就能判断这批需不需要立刻处理。
//
// items 是**实际装下**的条目,total 是本批应有的总数。两者不同时必须明说
// 「还有多少条在下一条消息里」——否则读者会以为消息头写的那个数字就是全部,
// 而后面那些从未发出的条目在界面上完全不存在。
func markdownBatchIntro(m Message, items []Item, total int) string {
var b strings.Builder
if m.WindowMinutes > 0 {
fmt.Fprintf(&b, "**최근 %d분간 신규 취약점 %d건**", m.WindowMinutes, total)
} else {
fmt.Fprintf(&b, "**신규 취약점 %d건**", total)
}
if extra := total - len(items); extra > 0 {
fmt.Fprintf(&b, "(이 메시지에는 앞 %d건만 표시하며, 나머지 %d건은 다음 메시지에서 이어서 보냅니다)", len(items), extra)
}
// 按级别给出分布,让读者一眼看到有没有严重项。只统计**本条实际包含**的
// 条目,保证「严重 3」和下面能数出来的条目一致。
counts := map[string]int{}
for _, it := range items {
counts[it.Severity]++
}
var parts []string
for _, sev := range []string{"critical", "high", "medium", "low"} {
if n := counts[sev]; n > 0 {
parts = append(parts, fmt.Sprintf("%s %d", SeverityLabel(sev), n))
}
}
if len(parts) > 0 {
b.WriteString("\n" + strings.Join(parts, " · "))
}
b.WriteString("\n\n")
return b.String()
}
// writeItem 渲染单个漏洞条目。
//
// prefix 用于汇总列表的序号;single=true 时渲染完整版(含摘要与回链),
// 汇总列表里只渲染一行摘要——否则 50 条汇总会变成一篇长文档。
//
// 所有来自外部的内容(标题/类型/资产/摘要)都过 markdownText:
// 单行化 + 转义。回链是管理员配置的 public_base_url 拼出来的,不是不可信内容,
// 且必须是可点的链接,所以原样输出。
func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
line := fmt.Sprintf("%s**%s · %s**", prefix, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
if !single {
// 汇总模式:单行呈现,资产与摘要压缩后跟在后面。
var extras []string
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
extras = append(extras, markdownText(a, 0))
}
if it.Summary != "" {
extras = append(extras, markdownText(it.Summary, 60))
}
if len(extras) > 0 {
line += " — " + strings.Join(extras, " · ")
}
b.WriteString(line + "\n")
return
}
b.WriteString(line + "\n")
if it.IsStatusChange() {
fmt.Fprintf(b, "**상태 변경**: %s → %s\n",
markdownText(StatusLabel(it.FromStatus), 0), markdownText(StatusLabel(it.ToStatus), 0))
}
if it.VulnClass != "" && it.VulnClass != it.Title() {
fmt.Fprintf(b, "**유형**: %s\n", markdownText(it.VulnClass, 0))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
fmt.Fprintf(b, "**자산**: %s\n", markdownText(a, 0))
}
if it.Summary != "" {
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
fmt.Fprintf(b, "**개요**: %s\n", s)
}
}
if it.DetailURL != "" {
fmt.Fprintf(b, "[상세 보기](%s)\n", it.DetailURL)
}
}
+122
View File
@@ -0,0 +1,122 @@
package notify
import (
"strings"
"testing"
)
// hanFreeItems 는 CJK 한자가 없는 항목(ASCII 제목·유형·요약)만 만든다.
// 라벨이 중국어로 되돌아가면 렌더 결과 전체에 한자가 생기므로, 데이터가
// 한자 0 일 때만 "출력 전체에 한자 0" 단언이 라벨 회귀를 정확히 잡아낸다.
func hanFreeItems(n int) []Item {
items := make([]Item, 0, n)
for i := 0; i < n; i++ {
items = append(items, Item{
FindingID: int64(i + 1),
Name: "login-flaw",
VulnClass: "SQLi",
Severity: "high",
Summary: "SQL injection via q param",
Assets: []string{"a.example.com"},
})
}
return items
}
// TestMarkdownTitleLocalized 는 markdown 계열 채널(dingtalk·wecom)과 제목을
// 공유하는 feishu·html·telegram·webhook 이 함께 쓰는 markdownTitle 이 한국어인지
// 검사한다. 하나라도 중국어로 되돌아가면 이 다섯 채널의 제목이 전부 혼재된다.
func TestMarkdownTitleLocalized(t *testing.T) {
// 다건(汇总): "취약점 요약 · 총 N건"
got := markdownTitle(Message{Batch: true, Items: hanFreeItems(3)})
assertKorean(t, "markdownTitle(batch)", got)
if !strings.Contains(got, "취약점 요약") || !strings.Contains(got, "총 3건") {
t.Errorf("다건 제목이 '취약점 요약 · 총 3건' 형태여야 합니다, 받은 값 %q", got)
}
// 항목 없음: "취약점 알림"
empty := markdownTitle(Message{})
assertKorean(t, "markdownTitle(empty)", empty)
if empty != "취약점 알림" {
t.Errorf("빈 메시지 제목은 '취약점 알림' 이어야 합니다, 받은 값 %q", empty)
}
}
// TestMarkdownBatchIntroLocalized 는 다이제스트 머리말(시간창·건수·초과 안내)이
// 한국어인지 검사한다. 항목 데이터가 한자 0 이므로 출력에 한자가 보이면 머리말
// 문구가 중국어로 회귀한 것이다.
func TestMarkdownBatchIntroLocalized(t *testing.T) {
items := hanFreeItems(3)
// 시간창 있음: "최근 N분간 신규 취약점 N건"
win := markdownBatchIntro(Message{Batch: true, WindowMinutes: 30}, items, 3)
if hasHan(win) {
t.Errorf("시간창 머리말에 중국어 한자가 남았습니다: %q", win)
}
if !strings.Contains(win, "최근 30분간") || !strings.Contains(win, "신규 취약점 3건") {
t.Errorf("시간창 머리말이 '최근 30분간 신규 취약점 3건' 형태여야 합니다, 받은 값 %q", win)
}
// 시간창 없음: "신규 취약점 N건"(분간 표기 없음)
noWin := markdownBatchIntro(Message{Batch: true}, items, 3)
if hasHan(noWin) {
t.Errorf("머리말에 중국어 한자가 남았습니다: %q", noWin)
}
if !strings.Contains(noWin, "신규 취약점 3건") || strings.Contains(noWin, "분간") {
t.Errorf("시간창 없는 머리말은 '신규 취약점 3건'(분간 표기 없음)이어야 합니다, 받은 값 %q", noWin)
}
// 일부만 담겼을 때: "(이 메시지에는 앞 N건만 … 나머지 N건은 다음 메시지에서 …)"
trunc := markdownBatchIntro(Message{Batch: true, WindowMinutes: 30}, items, 5)
if hasHan(trunc) {
t.Errorf("초과 안내에 중국어 한자가 남았습니다: %q", trunc)
}
for _, want := range []string{"앞 3건만", "나머지 2건", "다음 메시지에서"} {
if !strings.Contains(trunc, want) {
t.Errorf("초과 안내에 %q 가 있어야 합니다, 받은 값 %q", want, trunc)
}
}
}
// TestWriteItemLabelsLocalized 는 단건 상세 렌더(markdown 3채널 공유 경로)의
// 필드 라벨(상태 변경·유형·자산·개요)과 상세 링크가 한국어인지 검사한다.
func TestWriteItemLabelsLocalized(t *testing.T) {
it := Item{
Name: "login-flaw",
VulnClass: "SQLi",
Severity: "high",
Summary: "SQL injection via q param",
Assets: []string{"a.example.com"},
DetailURL: "https://platform.example/finding/1",
FromStatus: "pending",
ToStatus: "fixed",
}
var b strings.Builder
writeItem(&b, it, "", true)
got := b.String()
assertKorean(t, "writeItem(single)", got)
for _, want := range []string{
"**상태 변경**: 처리 대기 → 수정됨",
"**유형**: SQLi",
"**자산**: a.example.com",
"**개요**: SQL injection via q param",
"[상세 보기](https://platform.example/finding/1)",
} {
if !strings.Contains(got, want) {
t.Errorf("단건 렌더에 %q 가 있어야 합니다:\n%s", want, got)
}
}
}
// TestMarkdownBodyFooterLocalized 는 다건 본문 끝의 플랫폼 입구 링크가
// 한국어인지 검사한다(HomeURL 이 있을 때만 붙는다).
func TestMarkdownBodyFooterLocalized(t *testing.T) {
m := Message{Batch: true, HomeURL: "https://platform.example", Items: hanFreeItems(2)}
body, kept := markdownBody(m, 0)
if kept != 2 {
t.Fatalf("한도 없음(0)이면 2건 모두 담겨야 합니다, 받은 값 %d", kept)
}
if !strings.Contains(body, "[플랫폼에서 전체 보기](https://platform.example)") {
t.Errorf("본문 끝에 '플랫폼에서 전체 보기' 링크가 있어야 합니다:\n%s", body)
}
}
+252
View File
@@ -0,0 +1,252 @@
package notify
import (
"encoding/json"
"fmt"
"strings"
)
// MaskedPrefix 是掩码值的标记前缀。API 回显凭据时用带此前缀的值替换真实内容,
// 更新接口收到带此前缀的值即理解为「保持库中原值不变」。
//
// 用前缀而不是空串或某个固定常量,是为了能顺带带上一点可辨识信息
// (见 MaskedValue),让用户区分得出「这是哪个机器人」而不必重新粘贴密钥。
const MaskedPrefix = "__masked__"
// MaskedValue 生成一个掩码值:
//
// "__masked__" 原值太短,不给任何提示
// "__masked__:…ab12cd" 带上原值末 6 位作为辨识提示
//
// 只暴露末 6 位是刻意选择的:Webhook 地址的辨识信息在末段(如企业微信的 key、
// 飞书的机器人 id),而前缀部分各机器人相同、没有辨识价值。末 6 位不足以
// 还原凭据,但足以让配置者认出「是我那个群」。
func MaskedValue(secret string) string {
if len(secret) <= 6 {
return MaskedPrefix
}
return MaskedPrefix + ":…" + secret[len(secret)-6:]
}
// IsMasked 报告某个值是否为掩码值(即接口回显后未被修改)。
func IsMasked(v string) bool { return strings.HasPrefix(v, MaskedPrefix) }
// MaskConfig 返回配置的副本,把该渠道的凭据字段替换成掩码值。
//
// 未知渠道类型返回空 map 而不是原配置——宁可让 UI 显示「配置不可用」,
// 也不要在渠道类型无法识别时把可能含凭据的原始内容整个吐回去。
// 非凭据字段原样保留,UI 才能正常展示。
func MaskConfig(kind string, cfg map[string]any) map[string]any {
channel, ok := Get(kind)
if !ok {
return map[string]any{}
}
secrets := map[string]bool{}
for _, k := range channel.SecretKeys() {
secrets[k] = true
}
out := make(map[string]any, len(cfg))
for k, v := range cfg {
if !secrets[k] {
out[k] = v
continue
}
// headers 这类嵌套结构整体按一个凭据处理:逐个子键判断需要每个渠道
// 再声明一套「哪些子键是凭据」的规则,复杂度远超收益。
if s, ok := v.(string); ok {
out[k] = MaskedValue(s)
continue
}
out[k] = MaskedPrefix
}
return out
}
// ErrDestinationChangedWithoutCredentials 表示「目标地址变了,但调用方没有对
// 凭据字段表态」。返回它而不是默默放行或默默丢弃凭据,理由见 PrepareConfigUpdate。
type ErrDestinationChangedWithoutCredentials struct {
Changed []string // 发生变化的目的地键
Missing []string // 未显式表态的凭据键
}
func (e *ErrDestinationChangedWithoutCredentials) Error() string {
return "대상 주소(" + strings.Join(e.Changed, ", ") + ")가 변경되었습니다. 자격 증명 필드(" +
strings.Join(e.Missing, ", ") + ")를 함께 다시 입력하세요: 새 값을 넣거나, 비워 두어 더는 자격 증명이 필요 없음을 표시하세요. " +
"기존 자격 증명은 이전 주소에만 유효하므로, 그대로 사용하면 새 주소에 넘겨주는 셈입니다."
}
// PrepareConfigUpdate 合并渠道配置,并处理「目标地址变更」这一安全敏感情况。
//
// 它替代裸的 MergeConfig 用在渠道更新路径上,解决的是这样一条实测可行的路径:
// 目标地址(消息发往哪)与凭据(用什么身份发)是两套独立字段,而 MergeConfig
// 对「未提及的键」一律保留库中原值。于是任何能 PATCH 渠道的人只要**只改地址、
// 对凭据避而不谈**,就能让服务器把库里的真凭据发到自己控制的端点:
//
// webhook {config:{url:"https://attacker.tld"}} → 原始 Authorization 头随请求外发
// telegram {config:{base_url:"https://attacker.tld"}} → /bot<真Token>/sendMessage
// email {config:{host:"smtp.attacker.tld"}} → STARTTLS 后交出用户名与密码
//
// 这条路径完全静默、不依赖重定向(所以拒绝跨主机跳转挡不住它),
// 而且直接击穿了本包掩码机制的目标——「凭据不回显给浏览器」。
//
// 规则:只要某个目的地键被改成新值,调用方就必须对**每一个**凭据键显式表态:
// - 给出新值 → 用新值
// - 显式传空串 → 该字段不再需要凭据(保留清空语义)
// - 原样回传掩码值 / 干脆不提这个键 → 拒绝
//
// 第三种之所以也拒绝,是因为「掩码值」的含义正是「沿用旧凭据」,而旧凭据
// 只对旧地址有效。这里刻意不做「自动丢弃凭据」——那对可选凭据字段
// (webhook 的 headers、email 的 password)会静默变成「鉴权没了但接口返回 200」,
// 比报错更难排查。宁可让操作者多填一次。
func PrepareConfigUpdate(kind string, stored, incoming map[string]any) (map[string]any, error) {
channel, ok := Get(kind)
if !ok {
return nil, fmt.Errorf("등록되지 않은 채널 유형입니다: %q", kind)
}
secrets := channel.SecretKeys()
destinations := channel.DestinationKeys()
// 非字符串的凭据值(如 webhook 的 headers 是个对象)里若嵌着掩码字面量,
// 说明调用方把「保持原值」的哨兵塞进了结构体内部。MergeConfig 只认「字符串
// 且带前缀」为掩码,这种形态会被当普通对象原样存下去——库里真的落下字面量
// "__masked__",后续鉴权静默失效且没有任何报错。宁可拒掉。
//
// 这个检查必须放在**最前面**:地址没变时会走提前返回,放在后面就等于
// 只覆盖了「改地址」这一条路径(第一版就是这么放错的,测试直接抓到了)。
if err := rejectMaskedInContainers(incoming, secrets); err != nil {
return nil, err
}
// 找出真正被改掉的目的地键。掩码值等于「没改」。
var changed []string
for _, key := range destinations {
raw, present := incoming[key]
if !present {
continue
}
s, isStr := raw.(string)
if isStr && IsMasked(s) {
continue
}
if !sameConfigValue(raw, stored[key]) {
changed = append(changed, key)
}
}
if len(changed) == 0 {
// 地址没变,走普通合并(掩码值保留原值、空串清空、其余覆盖)。
return MergeConfig(stored, incoming), nil
}
// 地址变了:要求对每个凭据键显式表态。
var missing []string
for _, key := range secrets {
raw, present := incoming[key]
if !present {
missing = append(missing, key)
continue
}
if s, isStr := raw.(string); isStr && IsMasked(s) {
missing = append(missing, key)
}
}
if len(missing) > 0 {
return nil, &ErrDestinationChangedWithoutCredentials{Changed: changed, Missing: missing}
}
return MergeConfig(stored, incoming), nil
}
// rejectMaskedInContainers 拒绝把掩码哨兵嵌在非字符串结构里提交。
//
// 掩码机制的前提是「整个值就是个字符串」。像 webhook 的 headers 这种对象字段,
// 只能整体掩码(写成字符串 "__masked__")或整体提交;把哨兵塞进对象内部
// 既表达不了「保持不变」,又会被当成真实值存进库。
func rejectMaskedInContainers(incoming map[string]any, secretKeys []string) error {
for _, key := range secretKeys {
raw, present := incoming[key]
if !present {
continue
}
if _, isStr := raw.(string); isStr {
continue
}
encoded, err := json.Marshal(raw)
if err != nil {
continue
}
if strings.Contains(string(encoded), MaskedPrefix) {
return fmt.Errorf("%s 필드의 내용에 마스킹 표시(%q)가 들어 있습니다. 이 필드는 전체를 비워 두어 기존 값을 유지하거나 전체를 새 값으로 제출해야 하며, 구조체 내부에 마스킹 표시를 끼워 넣을 수 없습니다",
key, MaskedPrefix)
}
}
return nil
}
// sameConfigValue 比较两个配置值是否等价。用 JSON 序列化比较是为了顺带处理
// 类型差异——前端提交的端口是 number,而库里读回来的是 float64,直接 == 会误判。
//
// 「空」必须先归一化再比较:空串与「键不存在」在这个配置模型里是同一个状态,
// 因为 MergeConfig 把空串当显式清空、直接 delete 掉该键。不归一化的话,一个
// 始终留空的可选目的地字段(Telegram 的 base_url 是唯一这样的字段:留空即用
// 官方地址)会走成这条路径——
//
// 新建时存下 base_url:"" → 第一次保存被 MergeConfig 删键
// → 第二次保存时 incoming 是 ""、stored 缺键,被判成「地址变了」
// → 凭据是掩码值 → 400「目标地址已变更,请同时重新填写凭据字段」
//
// 此后每次保存都失败,除非用户重新粘贴一遍 Bot Token,而他什么都没改。
func sameConfigValue(a, b any) bool {
if isBlankConfigValue(a) && isBlankConfigValue(b) {
return true
}
ra, errA := json.Marshal(a)
rb, errB := json.Marshal(b)
if errA != nil || errB != nil {
return false
}
return string(ra) == string(rb)
}
// isBlankConfigValue 判定一个配置值是否为「空」。
// 口径必须与 MergeConfig 的清空判定一致(strings.TrimSpace(s) == ""),
// 否则会出现「MergeConfig 认为该删、sameConfigValue 认为有值」的夹缝。
func isBlankConfigValue(v any) bool {
if v == nil {
return true
}
s, ok := v.(string)
return ok && strings.TrimSpace(s) == ""
}
// MergeConfig 把 incoming 合并到 stored 之上,用于更新渠道配置。
//
// 规则:
// - incoming 里值为掩码的键 → 保留 stored 的原值(用户没改这个字段)
// - incoming 里值为空串的键 → 视为显式清空,删除该键
// - 其余键 → 用 incoming 的值覆盖
// - stored 里有而 incoming 里没有的键 → 保留(局部更新语义)
//
// 空串是否算「清空」需要明确:前端表单把未填的字段提交为空串,
// 若把它当成有效值写入,会把「留空以保留原值」的字段真的清掉。
// 这里选择显式清空,因为要清除一个设错的字段时,用户没有别的表达方式
// (拖走字段可区分「未提供」与「提供空值」,但 UI 用不到这个区别)。
func MergeConfig(stored, incoming map[string]any) map[string]any {
out := make(map[string]any, len(stored)+len(incoming))
for k, v := range stored {
out[k] = v
}
for k, v := range incoming {
if s, ok := v.(string); ok {
if IsMasked(s) {
continue // 掩码值 = 未修改,保留 stored
}
if strings.TrimSpace(s) == "" {
delete(out, k)
continue
}
out[k] = s
continue
}
out[k] = v
}
return out
}
+87
View File
@@ -0,0 +1,87 @@
package notify
import (
"errors"
"strings"
"testing"
)
// F4 ⑥: notify/mask.go·filter.go 의 사용자 노출 문구(알림 채널 설정을 저장·갱신할 때
// server/notify_api.go 가 writeErr(400) 로 그대로 돌려주는 검증 오류)가 한국어이고
// 한자가 없음을 핀 고정한다. channel.go 는 사용자 노출 문구가 없어(전부 주석) 대상이
// 아니다. hasHan / hasHangul / assertKorean 은 notify_localized_test.go 에 정의돼 있다.
// TestFilterValidateLocalized 는 min_severity 를 잘못 넣었을 때의 검증 오류를 검사한다.
// 이 오류는 저장·갱신 경로(server/notify_api.go:264·369)에서 400 으로 노출된다.
func TestFilterValidateLocalized(t *testing.T) {
err := Filter{MinSeverity: "hgih"}.Validate()
if err == nil {
t.Fatal("잘못된 최소 심각도인데 오류가 없습니다")
}
assertKorean(t, "Filter.Validate", err.Error())
// low/medium/high/critical 는 설정 enum 이라 원문 그대로 남아야 한다.
for _, tok := range []string{"low", "medium", "high", "critical"} {
if !strings.Contains(err.Error(), tok) {
t.Errorf("심각도 토큰 %q 가 사라졌습니다: %q", tok, err.Error())
}
}
// 올바른 값과 빈 값은 통과해야 한다.
if err := (Filter{MinSeverity: "high"}).Validate(); err != nil {
t.Errorf("high 는 유효한데 오류가 났습니다: %v", err)
}
if err := (Filter{}).Validate(); err != nil {
t.Errorf("빈 최소 심각도는 유효한데 오류가 났습니다: %v", err)
}
}
// TestPrepareConfigUpdateUnknownKindLocalized 는 등록되지 않은 채널 유형으로 설정을
// 갱신할 때의 오류(server/notify_api.go:326 에서 400)를 검사한다.
func TestPrepareConfigUpdateUnknownKindLocalized(t *testing.T) {
_, err := PrepareConfigUpdate("definitely-not-a-channel", map[string]any{}, map[string]any{})
if err == nil {
t.Fatal("미등록 채널 유형인데 오류가 없습니다")
}
assertKorean(t, "PrepareConfigUpdate unknown kind", err.Error())
}
// TestDestinationChangedErrorLocalized 는 실제 경로로 ErrDestinationChangedWithoutCredentials
// 를 유발한다: webhook 의 대상 주소(url)만 새 값으로 바꾸고 자격 증명(headers)에는
// 아무 표태도 하지 않는 PATCH 다. 서버는 이를 400 으로 돌려준다.
func TestDestinationChangedErrorLocalized(t *testing.T) {
_, err := PrepareConfigUpdate("webhook",
map[string]any{
"url": "https://old.example.com/hook",
"headers": map[string]any{"Authorization": "Bearer real-token"},
},
map[string]any{"url": "https://attacker.example.com/hook"},
)
if err == nil {
t.Fatal("대상 주소를 바꾸고 자격 증명을 표태하지 않았는데 오류가 없습니다")
}
var de *ErrDestinationChangedWithoutCredentials
if !errors.As(err, &de) {
t.Fatalf("예상한 오류 유형이 아닙니다: %T", err)
}
assertKorean(t, "ErrDestinationChangedWithoutCredentials", err.Error())
// 바뀐 대상 키·누락된 자격 증명 키 이름은 설정 필드명이라 메시지에 그대로 들어가야 한다.
if !strings.Contains(err.Error(), "url") || !strings.Contains(err.Error(), "headers") {
t.Errorf("필드 키 이름이 누락됐습니다: %q", err.Error())
}
}
// TestRejectMaskedInContainersLocalized 는 실제 경로로 구조체 내부 마스킹 센티넬 거부를
// 유발한다: headers(객체이자 자격 증명 필드) 안에 마스킹 센티넬 값을 끼워 넣은 PATCH 다.
func TestRejectMaskedInContainersLocalized(t *testing.T) {
_, err := PrepareConfigUpdate("webhook",
map[string]any{},
map[string]any{"headers": map[string]any{"Authorization": MaskedPrefix + ":…abc123"}},
)
if err == nil {
t.Fatal("구조체 내부에 마스킹 센티넬을 끼워 넣었는데 오류가 없습니다")
}
assertKorean(t, "rejectMaskedInContainers", err.Error())
// 센티넬 리터럴(__masked__)은 운영자가 어느 값이 문제인지 알 수 있게 메시지에 표시된다.
if !strings.Contains(err.Error(), MaskedPrefix) {
t.Errorf("마스킹 센티넬이 메시지에 없습니다: %q", err.Error())
}
}
+403
View File
@@ -0,0 +1,403 @@
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)
}
}
+95
View File
@@ -0,0 +1,95 @@
// Package notify 实现漏洞发现的 IM / 邮件推送渠道适配层。
//
// 分层:本包是**叶子包**,只依赖标准库。它不认识数据库、不认识 server。渠道配置
// 以 map[string]any 传入(对应 notification_channels.config 这一 JSONB 列),
// 待推送内容以 Message 传入。这样拆开的好处是:签名计算、UTF-8 截断、过滤匹配这些
// 真正容易出错的地方可以脱离 PostgreSQL 单测,宿主只需在 server 侧做编排。
//
// 并发约定:Channel 的实现必须**无状态**。同一个 Channel 实例会被多个渠道配置
// (甚至同一渠道的多个机器人实例)并发复用,所有凭据一律从 cfg 参数传入,
// 不允许把 webhook URL 之类的东西缓存进实现自身的字段。
package notify
// 渠道类型标识。取值同时是 notification_channels.kind 的合法集合,由 server 侧
// 白名单校验(与 findings.status 同理,不用 DB CHECK,方便后续加渠道)。
const (
KindDingTalk = "dingtalk" // 钉钉自定义机器人
KindFeishu = "feishu" // 飞书(含 Lark)自定义机器人
KindWeCom = "wecom" // 企业微信群机器人
KindWebhook = "webhook" // 通用 Webhook:自定义方法/头/JSON 模板
KindTelegram = "telegram" // Telegram Bot API
KindEmail = "email" // SMTP 邮件
)
// 事件类型,对应 notification_events.kind。
const (
EventFindingCreated = "finding_created"
EventFindingStatusChanged = "finding_status_changed"
)
// InitKind 是 config 里为空的 kind 的兜底值。
const InitKind = KindDingTalk
// severityRank 把漏洞级别映射成可比较的序数。未知级别返回 0,因此任何
// min_severity 设置都会把未知级别挡在外面——存疑时不推,避免误报刷屏。
var severityRank = map[string]int{
"low": 1,
"medium": 2,
"high": 3,
"critical": 4,
}
// SeverityRank 返回级别的序数;未知级别返回 0。
func SeverityRank(severity string) int { return severityRank[severity] }
// SeverityLabel 返回带 emoji 的中文级别名,用于消息标题与卡片配色。
// 未知级别原样回显,不臆造。
func SeverityLabel(severity string) string {
switch severity {
case "critical":
return "🔴 심각"
case "high":
return "🟠 높음"
case "medium":
return "🟡 중간"
case "low":
return "🔵 낮음"
default:
return severity
}
}
// StatusLabel 把处置状态翻译成中文,用于状态变更消息。
func StatusLabel(status string) string {
switch status {
case "pending":
return "처리 대기"
case "in_progress":
return "처리 중"
case "confirmed":
return "확인됨"
case "resolved":
return "처리됨"
case "fixed":
return "수정됨"
case "false_positive":
return "오탐"
case "ignored":
return "무시"
case "duplicate":
return "중복"
case "risk_accepted":
return "위험 수용"
default:
return status
}
}
// AtLeast 判断 severity 是否达到 min 门槛。min 为空表示不设门槛,一律通过。
// 注意未知 severity 的序数为 0,会被任何非空 min 拒掉(见 severityRank 注释)。
func AtLeast(severity, min string) bool {
if min == "" {
return true
}
return SeverityRank(severity) >= SeverityRank(min)
}
+120
View File
@@ -0,0 +1,120 @@
package notify
import (
"strings"
"testing"
)
// hasHan 은 문자열에 CJK 통합 한자가 하나라도 있으면 true 를 반환한다.
// 번역한 라벨이 중국어로 되돌아가면(회귀) 이 판정이 잡아낸다.
func hasHan(s string) bool {
for _, r := range s {
if r >= 0x4E00 && r <= 0x9FFF {
return true
}
}
return false
}
// hasHangul 은 문자열에 한글 음절이 하나라도 있으면 true 를 반환한다.
func hasHangul(s string) bool {
for _, r := range s {
if r >= 0xAC00 && r <= 0xD7A3 {
return true
}
}
return false
}
// assertKorean 은 라벨이 한글을 포함하고 중국어 한자는 없음을 단언한다.
func assertKorean(t *testing.T, where, got string) {
t.Helper()
if hasHan(got) {
t.Errorf("%s: 중국어 한자가 남아 있습니다: %q", where, got)
}
if !hasHangul(got) {
t.Errorf("%s: 한글이 없습니다: %q", where, got)
}
}
// TestSeverityLabelLocalized 는 모든 심각도 enum 이 한국어 라벨로 나오는지 검사한다.
// 라벨은 모든 알림 채널(telegram·email·html·markdown·webhook·feishu·dingtalk·wecom)이
// 공유하므로, 하나라도 중국어로 되돌아가면 전 채널 메시지가 혼재된다.
func TestSeverityLabelLocalized(t *testing.T) {
// 중국어 라벨(严重/高危/中危/低危) → 한국어(심각/높음/중간/낮음), UI status.severity 정합.
want := map[string]string{
"critical": "심각",
"high": "높음",
"medium": "중간",
"low": "낮음",
}
for sev, label := range want {
got := SeverityLabel(sev)
assertKorean(t, "SeverityLabel("+sev+")", got)
if !strings.Contains(got, label) {
t.Errorf("SeverityLabel(%q)=%q, %q 를 포함해야 합니다", sev, got, label)
}
}
// 알 수 없는 심각도는 원문을 그대로 돌려준다(없는 값을 지어내지 않는다).
if got := SeverityLabel("made_up"); got != "made_up" {
t.Errorf("알 수 없는 심각도는 원문 유지여야 합니다, 받은 값 %q", got)
}
}
// TestStatusLabelLocalized 는 처치 상태 9종이 전부 한국어로 나오는지 검사한다.
// UI status.finding 네임스페이스(B4a)와 동일 표기여야 상태 변경 알림과 화면이 어긋나지 않는다.
func TestStatusLabelLocalized(t *testing.T) {
want := map[string]string{
"pending": "처리 대기",
"in_progress": "처리 중",
"confirmed": "확인됨",
"resolved": "처리됨",
"fixed": "수정됨",
"false_positive": "오탐",
"ignored": "무시",
"duplicate": "중복",
"risk_accepted": "위험 수용",
}
for status, label := range want {
got := StatusLabel(status)
assertKorean(t, "StatusLabel("+status+")", got)
if got != label {
t.Errorf("StatusLabel(%q)=%q, %q 를 기대했습니다", status, got, label)
}
}
// 알 수 없는 상태는 원문 그대로 회신(없는 라벨을 지어내지 않는다).
if got := StatusLabel("weird_status"); got != "weird_status" {
t.Errorf("알 수 없는 상태는 원문 유지여야 합니다, 받은 값 %q", got)
}
}
// TestItemTitlePlaceholderLocalized 는 이름·유형이 모두 빈 항목의 대체 제목이
// 한국어 자리표시자(이름 없는 취약점)인지 검사한다. 절대 빈 제목을 내보내지 않는다.
func TestItemTitlePlaceholderLocalized(t *testing.T) {
got := Item{}.Title()
assertKorean(t, "Item{}.Title()", got)
// 이름이 있으면 그 이름을, 유형만 있으면 유형을 우선한다(대체 로직 미회귀 확인).
if n := (Item{Name: "로그인 SQLi"}).Title(); n != "로그인 SQLi" {
t.Errorf("이름 우선 로직이 깨졌습니다, 받은 값 %q", n)
}
if v := (Item{VulnClass: "XSS"}).Title(); v != "XSS" {
t.Errorf("유형 대체 로직이 깨졌습니다, 받은 값 %q", v)
}
}
// TestAssetLineLocalized 는 자산 나열 문구의 구분자·총수 표기가 한국어/ASCII 인지 검사한다.
// 중국어 구두점(、)·等·个 가 되돌아오면 알림 본문에 혼재가 생긴다.
func TestAssetLineLocalized(t *testing.T) {
// 상한 이하: 전부 나열, ASCII 쉼표 구분자.
if got := assetLine([]string{"a.example.com", "b.example.com"}, 3); got != "a.example.com, b.example.com" {
t.Errorf("구분자가 ASCII 쉼표여야 합니다, 받은 값 %q", got)
}
// 상한 초과: 앞 limit 개 + 한국어 "등 N개"(N 은 전체 개수).
got := assetLine([]string{"a", "b", "c", "d", "e"}, 2)
if hasHan(got) {
t.Errorf("자산 나열에 중국어 한자가 남았습니다: %q", got)
}
if !strings.Contains(got, "등 5개") {
t.Errorf("전체 개수 5 를 '등 5개'로 표기해야 합니다, 받은 값 %q", got)
}
}
+208
View File
@@ -0,0 +1,208 @@
package notify
import (
"strings"
"testing"
"unicode/utf8"
)
// 本文件覆盖「按整条打包」这个修复:汇总消息超出渠道长度上限时,必须**按整条**
// 截断并把没装下的条目数如实报出来,让调用方只标记真正送达的那些。
//
// 之前的做法是渲染完整篇再截断、然后整批标记已送达:消息后半截凭空消失,
// 而投递历史显示全部成功——漏洞就这么没了,且没有任何地方能发现。
func TestMarkdownBodyPacksWholeItemsWithinByteLimit(t *testing.T) {
// 200 条中文汇总,必然远超企微 4096 字节。
m := batchMsg(200)
body, kept := markdownBody(m, weComMarkdownLimit)
if len(body) > weComMarkdownLimit {
t.Fatalf("正文 %d 字节超上限 %d", len(body), weComMarkdownLimit)
}
if !utf8.ValidString(body) {
t.Fatal("正文不是合法 UTF-8")
}
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("应只装下一部分(0 < kept < %d),得到 %d", len(m.Items), kept)
}
// 头部必须如实说明本条只包含多少条、其余有多少条——否则读者会把头部
// 那个数字当成全部。
if !strings.Contains(body, "나머지") || !strings.Contains(body, "다음 메시지에서") {
t.Fatalf("头部应说明还有多少条未包含在本条里:\n%s", body[:minInt(400, len(body))])
}
// 只应包含前 kept 条。
for i := 0; i < kept; i++ {
if !strings.Contains(body, "漏洞"+itoa(i+1)) {
t.Fatalf("第 %d 条应在本条消息里:\n%s", i+1, body)
}
}
if strings.Contains(body, "漏洞"+itoa(kept+1)) {
t.Fatalf("第 %d 条不该出现(它属于下一批)", kept+1)
}
}
func TestMarkdownBodyKeepsEverythingWhenUnderLimit(t *testing.T) {
m := batchMsg(3)
body, kept := markdownBody(m, 0) // 0 = 不限制
if kept != len(m.Items) {
t.Fatalf("不限制长度时应全部保留,得到 kept=%d", kept)
}
if strings.Contains(body, "其余") {
t.Fatalf("没有截断时不该出现截断提示:\n%s", body)
}
}
func TestMarkdownBodyAlwaysKeepsAtLeastOneItem(t *testing.T) {
// 预算小到连一条都装不下时,仍要发出一条(由最终截断兜底)。
// 否则一条超长漏洞会把整批永久卡在原地:每次领取都装不下、每次都不发。
m := batchMsg(5)
_, kept := markdownBody(m, 50)
if kept != 1 {
t.Fatalf("至少应保留 1 条,得到 %d", kept)
}
}
func TestMarkdownBodySingleReturnsOne(t *testing.T) {
_, kept := markdownBody(singleMsg(), 4096)
if kept != 1 {
t.Fatalf("单条消息应报送达 1 条,得到 %d", kept)
}
// 空消息没有可送达的条目。
if _, k := markdownBody(Message{}, 4096); k != 0 {
t.Fatalf("空消息应报 0 条,得到 %d", k)
}
}
func TestTelegramPackingUsesRuneBudget(t *testing.T) {
m := batchMsg(200)
text, kept := telegramHTML(m)
// Telegram 按**字符数**限长;用字节口径会把中文消息压到三分之一。
if n := utf8.RuneCountInString(text); n > telegramTextLimit {
t.Fatalf("正文 %d 字符超上限 %d", n, telegramTextLimit)
}
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("应只装下一部分,得到 %d", kept)
}
if !strings.Contains(text, "다음 메시지") {
t.Fatalf("应说明还有余量未包含:\n%.300s", text)
}
}
func TestFeishuPackingReportsKept(t *testing.T) {
m := batchMsg(2000)
_, kept := feishuCard(m)
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("卡片应只装下一部分,得到 %d", kept)
}
}
func TestWebhookAndEmailReportAllItems(t *testing.T) {
// 这两个渠道不截断正文,整批都算送达。
m := batchMsg(7)
if n := len(m.Items); n != 7 {
t.Fatal("前置条件不成立")
}
// 通过渲染器的返回值间接确认:markdownBody(0) 不限制时全部保留。
if _, k := markdownBody(m, 0); k != len(m.Items) {
t.Fatalf("不限制长度时应用全部,得到 %d", k)
}
}
// TestMarkdownEscapesUntrustedContent 是「不可信内容不得改变消息结构」的回归测试。
// 标题与摘要来自模型输出(模型读的是被测目标响应),资产名来自被测目标的 URL。
func TestMarkdownEscapesUntrustedContent(t *testing.T) {
cases := []struct {
name string
item Item
must []string // 结果里必须出现(转义形态)
wrong []string // 结果里不得出现(未转义形态)
}{
{
name: "标题里的换行 + 外链",
item: Item{
Severity: "high",
Name: "登录口 SQL 注入\n[紧急:点此验证账号](http://attacker.tld)",
},
// 换行必须被折叠(否则能伪造出新的列表项/引用块);
// 方括号与圆括号必须被转义(否则是可点击的外链)。
must: []string{`\[紧急:点此验证账号\]`, `\(http://attacker.tld\)`},
wrong: []string{"\n[紧急", "\n\n[紧急"},
},
{
name: "标题里的图片信标",
item: Item{
Severity: "high",
Name: "漏洞 ![](http://attacker.tld/beacon)",
},
must: []string{`\!`, `\(http://attacker.tld/beacon\)`},
wrong: []string{"![]("},
},
{
name: "资产名里的强调与引用",
item: Item{
Severity: "high",
Name: "普通标题",
Assets: []string{"a.com/*注入*>引用"},
},
must: []string{`\*注入\*`, `\>`},
wrong: []string{"*注入*"},
},
{
name: "摘要里的反引号与竖线",
item: Item{
Severity: "high",
Name: "标题",
Summary: "`code` | 表格",
},
must: []string{"\\`code\\`", `\|`},
wrong: []string{"`code`"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := Message{Items: []Item{tc.item}}
// 单条模式的写Item 是三个 markdown 渠道共用的渲染路径。
var b strings.Builder
writeItem(&b, tc.item, "", true)
got := b.String()
for _, want := range tc.must {
if !strings.Contains(got, want) {
t.Errorf("缺少转义形态 %q:\n%s", want, got)
}
}
for _, bad := range tc.wrong {
if strings.Contains(got, bad) {
t.Errorf("出现了未转义形态 %q(可被用来注入结构或外链):\n%s", bad, got)
}
}
_ = m
})
}
}
// TestMarkdownEscapeBackslashFirst 锁住转义顺序:反斜杠必须最先处理,
// 否则会给后面补上的反斜杠再套一层,输出里出现双反斜杠。
func TestMarkdownEscapeBackslashFirst(t *testing.T) {
if got := markdownEscape(`a\b*c`); got != `a\\b\*c` {
t.Fatalf("转义顺序有误,得到 %q", got)
}
}
// TestTelegramTitleHasNoMarkdownEscapes 锁住一个具体的回归:
// markdown 转义不能泄漏到 Telegram 的 HTML 输出里(曾经在共享的标题函数里
// 加过转义,结果 Telegram 消息里出现 `\(1\)` 这种可见反斜杠)。
func TestTelegramTitleHasNoMarkdownEscapes(t *testing.T) {
m := Message{Items: []Item{{Severity: "high", Name: "alert(1) *重点*"}}}
text, _ := telegramHTML(m)
if strings.Contains(text, `\(`) || strings.Contains(text, `\*`) {
t.Fatalf("Telegram 正文里出现了 markdown 的反斜杠转义:\n%s", text)
}
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
+236
View File
@@ -0,0 +1,236 @@
package notify
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// 本文件是不变量测试:**任何**从渠道实现里冒出来的错误文本都不得携带凭据。
//
// 为什么单独拉一个文件:最初的渠道用例只覆盖了成功路径与平台业务错误,
// 完全没看传输层失败。而恰恰是传输层错误(连接被拒/DNS 失败/超时)最危险——
// http.Client.Do 返回的 *url.Error 会把**完整 URL** 打进错误文本,而本功能
// 这几家的凭据就在 URL 里。凭据顺着这条串流到了四个出口:
//
// notification_deliveries.last_error → 明文落库
// GET /api/notify/deliveries 响应 → 绕过渠道配置的掩码,回显给浏览器
// 服务端日志 → 常被外发留存
// 测试发送接口的 502 响应 → 直接弹在前端
//
// 所以这里不只测一个函数,而是逐个渠道真发一次必然失败的请求,断言错误文本
// 里找不到那个凭据。
// credentialCases 覆盖所有「凭据在 URL 里」的渠道形态:
// 钉钉/企微在 query,飞书在路径末段,Telegram 在路径中段。
var credentialCases = []struct {
name string
ch Channel
cfg map[string]any
secret string
}{
{
name: "钉钉 access_token 在 query",
ch: dingTalkChannel{},
cfg: map[string]any{"webhook": "http://127.0.0.1:1/robot/send?access_token=" + leakProbeToken},
secret: leakProbeToken,
},
{
name: "企业微信 key 在 query",
ch: weComChannel{},
cfg: map[string]any{"webhook": "http://127.0.0.1:1/cgi-bin/webhook/send?key=" + leakProbeToken},
secret: leakProbeToken,
},
{
name: "飞书 hook id 在路径末段",
ch: feishuChannel{},
cfg: map[string]any{"webhook": "http://127.0.0.1:1/open-apis/bot/v2/hook/" + leakProbeToken},
secret: leakProbeToken,
},
{
name: "Telegram bot token 在路径中段",
ch: telegramChannel{},
cfg: map[string]any{"bot_token": leakProbeToken, "chat_id": "1", "base_url": "http://127.0.0.1:1"},
secret: leakProbeToken,
},
{
name: "钉钉加签密钥",
ch: dingTalkChannel{},
cfg: map[string]any{"webhook": "http://127.0.0.1:1/robot/send", "secret": leakProbeToken},
secret: leakProbeToken,
},
}
// leakProbeToken 是一个绝不可能是真实凭据的哨兵值,用于在错误文本里搜它。
const leakProbeToken = "LEAKPROBE0123456789abcdef"
// TestChannelErrorsNeverLeakCredentials 是核心不变量。
func TestChannelErrorsNeverLeakCredentials(t *testing.T) {
for _, tc := range credentialCases {
t.Run(tc.name, func(t *testing.T) {
// 必然失败的对端:127.0.0.1:1 无人监听,走的是连接被拒这条路径。
_, err := tc.ch.Send(context.Background(), tc.cfg, Message{
Items: []Item{{FindingID: 1, Severity: "high", Name: "泄露探针"}},
})
if err == nil {
t.Fatal("对不可达地址应报错")
}
assertNoSecret(t, err.Error(), tc.secret)
})
}
}
// TestChannelErrorsNeverLeakCredentialsInPermanentPath 覆盖永久失败分支:
// URL 校验失败、平台业务错误等也会把错误文本外传,同样不能带凭据。
func TestChannelErrorsNeverLeakCredentialsInPermanentPath(t *testing.T) {
cases := []struct {
name string
ch Channel
cfg map[string]any
}{
// 地址里带凭据但格式非法 → 触发 validateHTTPURL / url.Parse 分支。
{"钉钉地址非法", dingTalkChannel{}, map[string]any{"webhook": "file:///" + leakProbeToken}},
{"企微地址非法", weComChannel{}, map[string]any{"webhook": "gopher://" + leakProbeToken}},
{"飞书地址非法", feishuChannel{}, map[string]any{"webhook": "ftp://" + leakProbeToken + "/hook"}},
{"Telegram API 地址非法", telegramChannel{}, map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": "file://" + leakProbeToken}},
{"通用 Webhook 地址非法", webhookChannel{}, map[string]any{"url": "javascript:" + leakProbeToken}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := tc.ch.Send(context.Background(), tc.cfg, Message{Items: []Item{{Severity: "high"}}})
if err == nil {
t.Fatal("非法配置应报错")
}
assertNoSecret(t, err.Error(), leakProbeToken)
})
}
}
func assertNoSecret(t *testing.T, text, secret string) {
t.Helper()
if strings.Contains(text, secret) {
t.Fatalf("错误文本泄露了凭据 %q:\n %s", secret, text)
}
}
func TestRedactRequestTargetKeepsOnlySchemeAndHost(t *testing.T) {
cases := map[string]string{
"https://oapi.dingtalk.com/robot/send?access_token=S1": "https://oapi.dingtalk.com/…",
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=S2": "https://qyapi.weixin.qq.com/…",
"https://open.feishu.cn/open-apis/bot/v2/hook/S3": "https://open.feishu.cn/…",
"https://api.telegram.org/botS4/sendMessage": "https://api.telegram.org/…",
"http://10.0.0.5:8080/hook": "http://10.0.0.5:8080/…",
}
for in, want := range cases {
got := redactRequestTarget(in)
if got != want {
t.Errorf("redactRequestTarget(%q) = %q,期望 %q", in, got, want)
}
// 脱敏结果本身不得再含有原地址的任何路径/查询片段。
if parts := strings.SplitN(in, "://", 2); len(parts) == 2 {
if hostAndRest := strings.SplitN(parts[1], "/", 2); len(hostAndRest) == 2 && hostAndRest[1] != "" {
if strings.Contains(got, hostAndRest[1]) {
t.Errorf("脱敏后仍含路径/查询片段 %q: %q", hostAndRest[1], got)
}
}
}
}
// 不可解析的输入绝不回显原串。
for _, bad := range []string{"", "://", "not a url", "http://"} {
if got := redactRequestTarget(bad); strings.Contains(got, bad) && bad != "" {
t.Errorf("不可解析输入 %q 被回显为 %q", bad, got)
}
}
}
// TestRedactTransportErrorStripsURL 直接盯住 *url.Error 这个具体类型:
// 它是 http.Client.Do 的返回类型,也是泄露的第一现场。
func TestRedactTransportErrorStripsURL(t *testing.T) {
inner := errors.New("dial tcp 127.0.0.1:1: connect: connection refused")
uerr := &url.Error{
Op: "Post",
URL: "https://api.telegram.org/bot" + leakProbeToken + "/sendMessage",
Err: inner,
}
got := redactTransportError(uerr)
assertNoSecret(t, got, leakProbeToken)
if !strings.Contains(got, "api.telegram.org") {
t.Errorf("应保留 host 以便排查,得到 %q", got)
}
if !strings.Contains(got, "connection refused") {
t.Errorf("应保留底层原因以便排查,得到 %q", got)
}
// Op 也要保留(POST 还是 GET 对排查有意义)。
if !strings.Contains(got, "Post") {
t.Errorf("应保留操作名,得到 %q", got)
}
}
// TestRedactURLsInTextHandlesFallback 兜底路径:非 *url.Error 的自定义错误
// (如重定向策略返回的错误)里的地址同样要被摘掉。
func TestRedactURLsInTextHandlesFallback(t *testing.T) {
in := fmt.Sprintf("拒绝跨主机重定向(a.example → http://b.example/bot%s/send)", leakProbeToken)
got := redactURLsInText(in)
assertNoSecret(t, got, leakProbeToken)
if !strings.Contains(got, "http://b.example/…") {
t.Errorf("应把地址替换为脱敏形态,得到 %q", got)
}
// 不含地址的文本原样保留。
if plain := "dial tcp: connection refused"; redactURLsInText(plain) != plain {
t.Error("不含地址的文本不应被改动")
}
}
// TestCrossHostRedirectRefused 覆盖「凭据在 URL 里 + 跟随跨主机跳转 = 交出凭据」。
// httptest 的两个服务监听在 127.0.0.1 的不同端口,端口不同即 Host 不同,
// 正好构成跨主机跳转。
func TestCrossHostRedirectRefused(t *testing.T) {
var hit bool
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hit = true
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/robot/send?access_token="+leakProbeToken, http.StatusTemporaryRedirect)
}))
defer redirector.Close()
_, err := (dingTalkChannel{}).Send(context.Background(),
map[string]any{"webhook": redirector.URL + "/robot/send?access_token=" + leakProbeToken},
Message{Items: []Item{{Severity: "high"}}})
if err == nil {
t.Fatal("跨主机重定向应被拒绝")
}
if hit {
t.Fatal("跳转目标被访问了——凭据已随重定向外泄")
}
assertNoSecret(t, err.Error(), leakProbeToken)
}
// TestSameHostRedirectAllowed 反向用例:同主机跳转(如末尾补斜杠)必须仍然可用,
// 否则会把正常工作流一起挡掉。
func TestSameHostRedirectAllowed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/robot/send" {
// 同主机、同端口的跳转。
http.Redirect(w, r, "/robot/send/", http.StatusTemporaryRedirect)
return
}
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
}))
defer srv.Close()
if _, err := (dingTalkChannel{}).Send(context.Background(),
map[string]any{"webhook": srv.URL + "/robot/send"},
Message{Items: []Item{{Severity: "high"}}}); err != nil {
t.Fatalf("同主机重定向不应被拒绝: %v", err)
}
}
+160
View File
@@ -0,0 +1,160 @@
package notify
import (
"strings"
"unicode/utf8"
)
const ellipsis = "…"
// TruncateBytes 把 s 截断到不超过 max 字节,保证结果是合法 UTF-8 且不切断字符。
//
// 为什么必须按字符边界切:企微群机器人的 markdown 有 4096 **字节**硬上限(不是
// 字符数),而中文一个字 3 字节。直接按字节切片会把一个汉字切成两半,产出非法
// UTF-8——平台侧要么整条拒收,要么显示成乱码方块。这里的做法是先从预算位置
// 往前回退到最近的 rune 起始字节(utf8.RuneStart 判定续字节 0b10xxxxxx)。
//
// max<=0 表示不限制。截断后追加省略号,除非 max 小到装不下省略号。
func TruncateBytes(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
budget := max - len(ellipsis)
suffix := ellipsis
if budget < 0 {
// max 比省略号还短:放弃省略号,纯截断,避免结果反而超出 max。
budget = max
suffix = ""
}
cut := budget
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + suffix
}
// OneLine 把多行文本压成单行:折叠所有空白,再按字符数截断。
// 用于 IM 消息的标题行——摘要里常有换行,直接塞进表格/标题会撑坏排版。
// max<=0 表示不限制长度。
func OneLine(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
return TruncateRunes(s, max)
}
// TruncateRunes 把 s 截断到不超过 max 个字符(而非字节),超出时追加省略号。
// max<=0 表示不限制。
//
// 与 TruncateBytes 的区别在于平台口径:企微按字节限长,Telegram 按字符数限长。
// 用错口径不会报错,只会让消息被切得远比预期短(中文 1 字 = 3 字节,
// 按字节切 4096 只剩约 1365 字),所以两个函数都必须保留、按渠道选用。
func TruncateRunes(s string, max int) string {
if max <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= max {
return s
}
if max <= 1 {
return string(runes[:max])
}
return string(runes[:max-1]) + ellipsis
}
// TruncateHTML 按字符数截断 HTML 片段,并保证不产生半截标签。
//
// 直接对 HTML 做字符截断会切出 `<a href="htt` 这种残缺标签,平台解析器要么
// 报错拒收整条、要么把后续正文当成属性值吞掉。这里的做法是:先按字符截断,
// 再检查尾部是否有未闭合的 `<`,有就退到它之前。
//
// 不做标签配平(补全 </b> 之类):Telegram 的 HTML 解析器会自动闭合未闭合标签,
// 而自己实现配平要处理属性里的引号、注释、自闭合标签,复杂度与收益不成比例。
func TruncateHTML(s string, max int) string {
if max <= 0 || len([]rune(s)) <= max {
return s
}
cut := TruncateRunes(s, max)
// 尾部若是 `<` 开头的残片(最后出现 `<` 之后没有 `>`),退回 `<` 之前。
if lt := strings.LastIndex(cut, "<"); lt >= 0 && !strings.Contains(cut[lt:], ">") {
cut = cut[:lt]
}
// 尾部若是被切断的 HTML 实体(如 `&amp;` 被切成 `&amp`),同样要退回去。
// 实体残片在一个只认实体的解析器里可能让**整条消息**被拒收——一条超过
// 长度上限的汇总消息本来就常见,不值得为此丢掉整条通知。
if amp := strings.LastIndex(cut, "&"); amp >= 0 && !strings.Contains(cut[amp:], ";") {
cut = cut[:amp]
}
return cut
}
// packItemCount 计算在预算内能**完整**放下多少条,供汇总消息按整条打包。
//
// 为什么要按整条而不是渲染完整篇再截断:截断会让后半截条目凭空消失,
// 而它们的投递记录仍会被标记为已送达——消息里看不出来、投递历史里也看不出来,
// 漏洞就这么没了。按整条打包后,装不下的条目留在库里成为下一批,
// 调用方拿到的 kept 就是本条消息真正送达的条数。
//
// 参数:maxSize<=0 表示不限制;reserve 是给消息头部/尾部预留的量;
// size 负责计量(各平台口径不同:企微/钉钉按字节,Telegram 按字符数——
// 用错口径不会报错,只会让中文消息被压到远小于上限);
// render 把第 idx 条渲染成它的实际文本——长度因内容而异,不能靠估算。
//
// 至少返回 1(只要还有条目)。单条极端超长时也要发出这一条、由调用方的
// 最终截断兜底,否则一条超长漏洞会把整批永久卡在原地。
func packItemCount(items []Item, maxSize, reserve int, footer string, size func(string) int, render func(Item, int) string) int {
if maxSize <= 0 {
return len(items)
}
budget := maxSize - reserve - size(footer)
if budget < 0 {
budget = 0
}
used := 0
for i, it := range items {
used += size(render(it, i))
if used > budget && i > 0 {
return i
}
}
return len(items)
}
// byteSize / runeSize 是 packItemCount 的两种计量口径,命名出来避免调用处
// 出现裸的 func(s string) int 闭包,否则很难一眼看出用的是哪种口径。
func byteSize(s string) int { return len(s) }
func runeSize(s string) int { return utf8.RuneCountInString(s) }
// assetLine 把资产列表渲染成一行展示文本,超过 limit 个时省略其余并标注总数。
// 一个漏洞可能锚定几十个资产,全列出来会挤爆消息。
func assetLine(assets []string, limit int) string {
if len(assets) == 0 {
return ""
}
if limit <= 0 || len(assets) <= limit {
return strings.Join(assets, ", ")
}
return strings.Join(assets[:limit], ", ") + " 등 " + itoa(len(assets)) + "개"
}
// itoa 是 strconv.Itoa 的短别名,仅用于拼接展示文本,避免到处 import strconv。
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+150
View File
@@ -0,0 +1,150 @@
package notify
import (
"strings"
"testing"
"unicode/utf8"
)
func TestTruncateBytesKeepsValidUTF8(t *testing.T) {
// 这是本包最要紧的一条不变量。企微按**字节**限长,中文 3 字节/字,
// 任何按字节硬切的实现都会把汉字切成半个,产出非法 UTF-8 而被平台拒收。
// 用长度互质的多种中英混排输入去撞每一个可能的切点。
inputs := []string{
"中文测试内容",
"混合 mixed 内容 content",
"a中b文c测d试e",
"🔴🟠🟡🔵", // 4 字节 emoji,切错更明显
strings.Repeat("漏洞", 100),
}
for _, in := range inputs {
for max := 1; max <= len(in)+2; max++ {
got := TruncateBytes(in, max)
if !utf8.ValidString(got) {
t.Fatalf("输入 %q max=%d: 产出非法 UTF-8 %q", in, max, got)
}
if len(got) > max {
t.Fatalf("输入 %q max=%d: 结果 %d 字节超出上限", in, max, len(got))
}
// 未被截断时不得改动内容。
if len(in) <= max && got != in {
t.Fatalf("输入 %q max=%d: 未超限却改动了内容 -> %q", in, max, got)
}
}
}
}
func TestTruncateBytesZeroMeansUnlimited(t *testing.T) {
long := strings.Repeat("x", 10000)
if got := TruncateBytes(long, 0); got != long {
t.Fatal("max=0 应表示不限制")
}
if got := TruncateBytes(long, -5); got != long {
t.Fatal("max<0 应表示不限制")
}
}
func TestTruncateBytesEllipsisBudget(t *testing.T) {
// max 小于省略号本身时,不能因为追加省略号而反过来超限。
got := TruncateBytes("abcdefgh", 1)
if len(got) > 1 {
t.Fatalf("max=1 时结果 %q 长度 %d 超限", got, len(got))
}
// 正常情况应带省略号。
if got := TruncateBytes("abcdefgh", 5); !strings.HasSuffix(got, ellipsis) {
t.Fatalf("期望带省略号,得到 %q", got)
}
}
func TestTruncateRunesCountsCharactersNotBytes(t *testing.T) {
// 与 TruncateBytes 的口径差异必须保留:Telegram 按字符限长,
// 用字节口径会把中文消息切到只剩三分之一。
s := "一二三四五六七八九十"
got := TruncateRunes(s, 5)
if n := utf8.RuneCountInString(got); n != 5 {
t.Fatalf("期望 5 个字符,得到 %d 个 (%q)", n, got)
}
// 同样的字符串按字节口径应明显更短。
if utf8.RuneCountInString(TruncateBytes(s, 5)) >= 5 {
t.Fatal("字节口径不应产出与字符口径相同的字符数")
}
}
func TestOneLineCollapsesWhitespace(t *testing.T) {
got := OneLine("第一行\n\n第二行\t带制表 多空格", 0)
if strings.ContainsAny(got, "\n\t") {
t.Fatalf("应折叠所有空白,得到 %q", got)
}
if strings.Contains(got, " ") {
t.Fatalf("不应保留连续空格,得到 %q", got)
}
// 截断后仍须可读且合法。
got = OneLine("一二三四五六七八九十", 4)
if n := utf8.RuneCountInString(got); n != 4 {
t.Fatalf("期望 4 字符,得到 %d (%q)", n, got)
}
}
func TestTruncateHTMLNeverCutsTagInHalf(t *testing.T) {
// 直接截断 HTML 会切出 `<a href="htt` 这种残片,平台会拒收整条消息。
s := `<b>标题</b>正文正文正文<a href="https://example.com/very/long/path">查看详情</a>`
for max := 1; max <= utf8.RuneCountInString(s)+2; max++ {
got := TruncateHTML(s, max)
if n := utf8.RuneCountInString(got); max > 0 && n > max {
t.Fatalf("max=%d: 结果 %d 字符超限", max, n)
}
// 尾部不能有未闭合的 `<`(即最后一段里出现 `<` 却无 `>`)。
if lt := strings.LastIndex(got, "<"); lt >= 0 && !strings.Contains(got[lt:], ">") {
t.Fatalf("max=%d: 尾部标签被切断 -> %q", max, got)
}
}
}
func TestAssetLineOmitsExcess(t *testing.T) {
if got := assetLine(nil, 3); got != "" {
t.Fatalf("无资产应返回空串,得到 %q", got)
}
if got := assetLine([]string{"a", "b"}, 3); got != "a, b" {
t.Fatalf("未超限应全列,得到 %q", got)
}
// 超出上限时必须标注总数,否则读者不知道还有多少资产没列出来。
got := assetLine([]string{"a", "b", "c", "d", "e"}, 2)
if !strings.Contains(got, "등 5개") {
t.Fatalf("应标注总数 5,得到 %q", got)
}
}
func TestSeverityAndStatusLabels(t *testing.T) {
if AtLeast("", "low") {
t.Fatal("空级别序数为 0,应被任何门槛挡住")
}
if !AtLeast("critical", "") {
t.Fatal("空门槛应放行")
}
if got := StatusLabel("fixed"); got != "수정됨" {
t.Fatalf("未知状态映射,得到 %q", got)
}
// 未知状态原样回显,不臆造标签。
if got := StatusLabel("weird_status"); got != "weird_status" {
t.Fatalf("未知状态应原样回显,得到 %q", got)
}
}
// TestTruncateHTMLNeverCutsEntity 覆盖审计指出的一处遗漏:截断不只要避开
// 半截标签,还要避开被切断的 HTML 实体。
//
// `&amp;` 被切成 `&amp` 之后,一个只认实体的解析器可能拒收**整条**消息——
// 而超长汇总消息本来就常见,代价太大。
func TestTruncateHTMLNeverCutsEntity(t *testing.T) {
s := "aaaa&amp;bbbb&lt;cccc&quot;dddd"
for max := 1; max <= utf8.RuneCountInString(s)+2; max++ {
got := TruncateHTML(s, max)
// 尾部不得出现「有 & 但没有对应 ;」的实体残片。
if amp := strings.LastIndex(got, "&"); amp >= 0 && !strings.Contains(got[amp:], ";") {
t.Fatalf("max=%d: 尾部留下实体残片 %q", max, got[amp:])
}
if strings.Contains(got, "&amp\x00") {
t.Fatalf("max=%d: 出现畸形实体", max)
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package notify
import (
"net/url"
"testing"
"time"
)
// 签名基准值由 OpenSSL 独立算出,不是用本包自己的实现生成的——
// 否则只能证明「代码没变」,证明不了「算法对」。
//
// TS=1700000000000, SECRET=SECtest123
// 钉钉: printf '%s\n%s' "$TS" "$SECRET" | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64 -A
// -> w3RMHXzixTMdzr8OHJUmVLS4IoPJVdu+Ut1LE48MePE=
// 飞书: printf '' | openssl dgst -sha256 -hmac "$(printf '%s\n%s' "$TS" "$SECRET")" -binary | openssl base64 -A
// -> Hd4xFWQU6R6ad4nzy4ETIznzlqebqH7xcTFVmONTudo=
const (
signTestTSMillis = int64(1700000000000)
signTestSecret = "SECtest123"
dingTalkExpected = "w3RMHXzixTMdzr8OHJUmVLS4IoPJVdu+Ut1LE48MePE="
feishuExpected = "Hd4xFWQU6R6ad4nzy4ETIznzlqebqH7xcTFVmONTudo="
)
func TestDingTalkSignMatchesReference(t *testing.T) {
got, err := dingTalkSignedURL("https://oapi.dingtalk.com/robot/send?access_token=tok", signTestSecret, time.UnixMilli(signTestTSMillis))
if err != nil {
t.Fatalf("签名失败: %v", err)
}
u, err := url.Parse(got)
if err != nil {
t.Fatalf("产出地址不可解析: %v", err)
}
q := u.Query()
if q.Get("sign") != dingTalkExpected {
t.Errorf("签名不符\n期望 %s\n得到 %s", dingTalkExpected, q.Get("sign"))
}
if q.Get("timestamp") != "1700000000000" {
t.Errorf("时间戳应为毫秒且原样带上,得到 %q", q.Get("timestamp"))
}
// 原有 query 参数(access_token)不能被签名覆盖掉。
if q.Get("access_token") != "tok" {
t.Errorf("原有 query 参数丢失,得到 %q", q.Get("access_token"))
}
}
func TestFeishuSignMatchesReference(t *testing.T) {
got := feishuSign("1700000000000", signTestSecret)
if got != feishuExpected {
t.Errorf("签名不符\n期望 %s\n得到 %s", feishuExpected, got)
}
}
// TestSignAlgorithmsDiffer 锁住两家的算法差异。它们刚好互为对方的参数顺序
// (钉钉 key=secret,飞书 key=待签串),照另一家抄必然校验失败,
// 这条用例确保将来重构不会把两者合并成同一个函数。
func TestSignAlgorithmsDiffer(t *testing.T) {
ts := "1700000000000"
dingURL, err := dingTalkSignedURL("https://example.com/hook", signTestSecret, time.UnixMilli(signTestTSMillis))
if err != nil {
t.Fatal(err)
}
dq, _ := url.Parse(dingURL)
if dq.Query().Get("sign") == feishuSign(ts, signTestSecret) {
t.Fatal("钉钉与飞书签名相同,说明其中一家的算法实现错了")
}
}
func TestDingTalkNoSecretLeavesURLUntouched(t *testing.T) {
// 未开启加签的机器人:不得凭空添加 timestamp/sign 参数。
const hook = "https://oapi.dingtalk.com/robot/send?access_token=tok"
got, err := dingTalkSignedURL(hook, "", time.UnixMilli(signTestTSMillis))
if err != nil {
t.Fatal(err)
}
if got != hook {
t.Fatalf("未配置 secret 时地址不应改动,得到 %q", got)
}
}
func TestValidateHTTPURL(t *testing.T) {
ok := []string{"https://example.com/hook", "http://10.0.0.1:8080/x?y=1"}
for _, s := range ok {
if err := validateHTTPURL(s); err != nil {
t.Errorf("%q 应被接受: %v", s, err)
}
}
// file:// 之类不应放行——http.Client 对它们的处理超出预期范围。
bad := []string{"", "file:///etc/passwd", "ftp://example.com", "https://", "gopher://x"}
for _, s := range bad {
if err := validateHTTPURL(s); err == nil {
t.Errorf("%q 应被拒绝", s)
}
}
}
+193
View File
@@ -0,0 +1,193 @@
package notify
import (
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
)
// 本文件覆盖两条相关的加固:
// ① 投递地址不得把服务端当跳板打内网 / 云元数据(SSRF)
// ② 地址校验的错误信息不得带出地址里的凭据
//
// 关于测试环境:本包大量用例用 127.0.0.1 上的 httptest 假接收端,守卫默认会拦下
// 它们。所以 TestMain 里统一打开 AllowLocalTargetsEnv,而下面每个 SSRF 用例都
// 显式把它清掉,以断言**默认拒绝**的行为。
func TestMain(m *testing.M) {
// 让常规用例能连本地的假接收端;SSRF 用例会自己临时清空。
_ = os.Setenv(AllowLocalTargetsEnv, "1")
os.Exit(m.Run())
}
// TestDialGuardRejectsLoopbackByDefault 是 SSRF 防护的核心断言:
// 默认配置下,投递到环回地址必须被**连接层**拒绝。
func TestDialGuardRejectsLoopbackByDefault(t *testing.T) {
var hit bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hit = true
_, _ = io.WriteString(w, `{"errcode":0}`)
}))
defer srv.Close()
t.Setenv(AllowLocalTargetsEnv, "") // 关掉逃生口 = 默认行为
_, err := (dingTalkChannel{}).Send(context.Background(),
map[string]any{"webhook": srv.URL + "/robot/send"}, Message{Items: []Item{{Severity: "high"}}})
if err == nil {
t.Fatal("默认不应允许投递到环回地址")
}
if hit {
t.Fatal("请求已经打到了本机服务——守卫没生效")
}
// 错误信息要能指导用户怎么放开(本机 SMTP 中继是合法配置)。
if !strings.Contains(err.Error(), AllowLocalTargetsEnv) {
t.Errorf("拒绝信息应说明如何显式放开: %v", err)
}
}
// TestDialGuardAllowsLoopbackWhenOptedIn 反向用例:显式打开后必须能用,
// 否则本机 postfix / 内网中继这类合法部署会被一刀切废掉。
func TestDialGuardAllowsLoopbackWhenOptedIn(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
}))
defer srv.Close()
t.Setenv(AllowLocalTargetsEnv, "1")
if _, err := (dingTalkChannel{}).Send(context.Background(),
map[string]any{"webhook": srv.URL + "/robot/send"}, Message{Items: []Item{{Severity: "high"}}}); err != nil {
t.Fatalf("显式放开后应可投递: %v", err)
}
}
func TestIsBlockedDialIP(t *testing.T) {
blocked := []string{
"127.0.0.1", "127.1.2.3", "::1",
"169.254.169.254", // 云元数据端点——本函数存在的主要理由
"169.254.1.1", "fe80::1",
"0.0.0.0", "::",
"224.0.0.1", "ff02::1",
"::ffff:127.0.0.1", // IPv4-mapped 形式必须还原后再判,否则是绕过口
"",
}
for _, s := range blocked {
if !isBlockedDialIP(net.ParseIP(s)) {
t.Errorf("%s 应被拒绝", s)
}
}
// RFC1918 私网**刻意放行**:内网自建 Mattermost / SMTP 中继是常见合法用法。
// 这条断言把这个取舍固定下来——若将来有人顺手加上私网判断,这里会失败,
// 从而逼出一次有意识的决定(而不是静默废掉一批部署)。
allowed := []string{"10.0.0.5", "172.16.3.4", "192.168.1.10", "8.8.8.8", "2606:4700::1111"}
for _, s := range allowed {
if isBlockedDialIP(net.ParseIP(s)) {
t.Errorf("%s 应被放行(私网是常见的合法投递目标)", s)
}
}
}
// TestValidateHTTPURLRejectsLiteralPrivateTargets 覆盖配置阶段的前置提示:
// 字面 IP 在保存时就该被拒,而不是等第一次投递失败。
func TestValidateHTTPURLRejectsLiteralPrivateTargets(t *testing.T) {
t.Setenv(AllowLocalTargetsEnv, "")
for _, raw := range []string{
"http://127.0.0.1:8080/hook",
"http://169.254.169.254/latest/meta-data/",
"http://[::1]:8080/hook",
} {
if err := validateHTTPURL(raw); err == nil {
t.Errorf("%s 应在配置阶段被拒绝", raw)
}
}
// 公网地址与私网地址照常通过(私网留给拨号阶段,那里不拦)。
for _, raw := range []string{"https://oapi.dingtalk.com/robot/send", "http://10.0.0.9/hook"} {
if err := validateHTTPURL(raw); err != nil {
t.Errorf("%s 应通过校验: %v", raw, err)
}
}
}
// TestValidateHTTPURLErrorNeverLeaksCredentials 是审计指出我上一轮遗漏的分支。
//
// url.Parse **失败**时会返回 *url.Error,其 Error() 含完整原始地址。上一轮我只
// 脱敏了 http.Client.Do 的返回错误,漏了这里;而当时补的「永久失败路径」用例
// (file://、gopher://、ftp://)其实都能被 url.Parse 解析成功、走的是 scheme 分支,
// 所以全绿也证明不了这条路径安全——是假保证。
func TestValidateHTTPURLErrorNeverLeaksCredentials(t *testing.T) {
cases := []string{
"http://127.0.0.1/%zz?access_token=" + leakProbeToken, // 非法百分号转义
"https://a.example.com:port/x?access_token=" + leakProbeToken, // 端口非数字
"http://[::1?access_token=" + leakProbeToken, // 括号不配对
}
for _, raw := range cases {
// 先确认这个输入**确实**让 url.Parse 失败。不做这一步的话,用例可能在
// 毫无察觉的情况下走到别的分支(上一轮的假保证就是这么来的)。
if _, err := url.Parse(raw); err == nil {
t.Errorf("%q 本应解析失败,否则这条用例没有覆盖到目标分支", raw)
continue
}
err := validateHTTPURL(raw)
if err == nil {
t.Errorf("%q 应校验失败", raw)
continue
}
assertNoSecret(t, err.Error(), leakProbeToken)
}
// 确认渠道层的包装也没有把地址带出去。
t.Setenv(AllowLocalTargetsEnv, "")
err := (dingTalkChannel{}).Validate(map[string]any{"webhook": cases[0]})
if err == nil {
t.Fatal("非法地址应校验失败")
}
assertNoSecret(t, err.Error(), leakProbeToken)
}
// TestEmailDialGuardRejectsLoopbackByDefault 覆盖 SMTP 渠道的拨号守卫。
//
// 邮件渠道曾经用的是裸 net.Dialer,是整套 SSRF 防护里唯一的缺口:host 填成
// 169.254.169.254 或 127.0.0.1 能直接连上,而 smtp.NewClient 握手失败时会把
// 对端返回的那一行包进错误、经 last_error 由投递历史接口回显——正是其它渠道
// 已经关掉的半盲读原语;「连接被拒 vs 超时」的耗时差异还能用来探测端口。
//
// 本包的 TestMain 全局打开了 AllowLocalTargetsEnv(大量用例用 127.0.0.1 上的
// 假接收端),所以这个用例必须自己把它清掉——否则守卫在不在都会通过,
// 这也正是缺口当初没被任何测试发现的原因。
func TestEmailDialGuardRejectsLoopbackByDefault(t *testing.T) {
f := newFakeSMTP(t)
cfg := emailCfg(t, f, nil)
t.Setenv(AllowLocalTargetsEnv, "") // 关掉逃生口 = 默认行为
_, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg())
if err == nil {
t.Fatal("默认不应允许把邮件投递到环回地址")
}
// 连接根本不该建立:守卫在 Control 钩子里拦下,EHLO 永远发不出去。
if f.sawCommand("EHLO") || f.sawCommand("HELO") {
t.Fatal("SMTP 会话已经建立——守卫没生效")
}
// 错误信息要能指导用户怎么放开(本机 postfix 中继是合法配置)。
if !strings.Contains(err.Error(), AllowLocalTargetsEnv) {
t.Errorf("拒绝信息应说明如何显式放开: %v", err)
}
}
// TestEmailDialGuardAllowsLoopbackWhenOptedIn 是配对的反向用例:显式打开后
// 必须能正常投递。内网自建 SMTP / 本机中继是非常常见的部署,守卫不能一刀切。
func TestEmailDialGuardAllowsLoopbackWhenOptedIn(t *testing.T) {
f := newFakeSMTP(t)
cfg := emailCfg(t, f, nil)
t.Setenv(AllowLocalTargetsEnv, "1")
if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
t.Fatalf("显式放开后本机 SMTP 应可投递: %v", err)
}
if !f.sawCommand("EHLO") {
t.Fatal("未看到 EHLO——会话没真正建立")
}
}
+191
View File
@@ -0,0 +1,191 @@
package notify
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
)
// telegramTextLimit 是 Telegram sendMessage 的 text 字段上限(字符数)。
const telegramTextLimit = 4096
// telegramChannel 实现 Telegram Bot API。
//
// 平台特性:
// - 鉴权全部在 URL path 里(/bot<token>/sendMessage),无需加签。
// - 用 HTML 解析模式而不是 MarkdownV2:MarkdownV2 要求转义 `_*[]()~`>#+-=|{}.!`
// 共 18 个字符,漏一个就整条消息被拒;HTML 只需转义 & < > 三个。
// - 业务错误同样藏在 HTTP 200 里,靠 ok 字段判断。
type telegramChannel struct{}
func (telegramChannel) Kind() string { return KindTelegram }
// Telegram 单聊约 1 条/秒、群组 20 条/分钟。取保守值。
func (telegramChannel) DefaultRatePerMin() int { return 20 }
// Bot Token 是完整凭据;chat_id 只是收件人,不算秘密(拿到它没有 Token 也发不了消息)。
func (telegramChannel) SecretKeys() []string { return []string{"bot_token"} }
// base_url 决定 Token 被发往哪个 API 端点(如自建反代),改它必须重新表态 Token。
func (telegramChannel) DestinationKeys() []string { return []string{"base_url"} }
func (telegramChannel) Validate(cfg map[string]any) error {
if cfgString(cfg, "bot_token") == "" {
return errors.New("Bot Token이 없습니다")
}
if cfgString(cfg, "chat_id") == "" {
return errors.New("Chat ID가 없습니다")
}
if base := cfgString(cfg, "base_url"); base != "" {
if err := validateHTTPURL(base); err != nil {
return fmt.Errorf("API 주소가 올바르지 않습니다: %w", err)
}
}
return nil
}
func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
if err := c.Validate(cfg); err != nil {
return 0, Permanent(err)
}
endpoint, err := telegramEndpoint(cfg)
if err != nil {
return 0, Permanent(err)
}
text, kept := telegramHTML(m)
payload := map[string]any{
"chat_id": cfgString(cfg, "chat_id"),
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": false,
}
raw, err := doJSON(ctx, "POST", endpoint, nil, payload)
if err != nil {
return 0, err
}
var res struct {
OK bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return 0, fmt.Errorf("Telegram 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
}
if res.OK {
return kept, nil
}
// 429 是限流,退避后重试有效;其余(400 参数错、401 token 错、403 被拉黑、
// 404 chat 不存在)都是配置问题,重试不会自愈。
if res.ErrorCode == 429 {
return 0, fmt.Errorf("Telegram 요청 제한에 걸렸습니다: %s", res.Description)
}
return 0, Permanent(fmt.Errorf("Telegram 응답 오류 %d: %s", res.ErrorCode, res.Description))
}
// telegramEndpoint 拼出 sendMessage 地址。base_url 留空时用官方 API,
// 非空时用于自建 Bot API 反代(国内网络下的常见需求)。
func telegramEndpoint(cfg map[string]any) (string, error) {
base := cfgString(cfg, "base_url")
if base == "" {
base = "https://api.telegram.org"
}
base = strings.TrimSuffix(base, "/")
token := cfgString(cfg, "bot_token")
raw := base + "/bot" + token + "/sendMessage"
u, err := url.Parse(raw)
if err != nil {
// 不透传 err:地址里含 Bot Token,且此时连 addr 都不该回显。
return "", fmt.Errorf("API 주소를 조합하지 못했습니다 (API 주소: %s)", redactRequestTarget(base))
}
return u.String(), nil
}
// telegramHTML 渲染 HTML 正文,返回正文与实际写入的条目数(见 Channel.Send)。
func telegramHTML(m Message) (string, int) {
var b strings.Builder
b.WriteString("<b>" + telegramEscape(markdownTitle(m)) + "</b>\n")
if m.Batch {
// Telegram 的上限是**字符数**,所以打包也按字符计量(runeSize)。
footer := ""
if m.HomeURL != "" {
footer = fmt.Sprintf("\n\n<a href=\"%s\">플랫폼에서 전체 보기</a>", telegramEscapeAttr(m.HomeURL))
}
kept := packItemCount(m.Items, telegramTextLimit, telegramReservedRunes, footer, runeSize, func(it Item, idx int) string {
return telegramBatchLine(it, idx+1)
})
items := m.Items[:kept]
b.Reset()
b.WriteString("<b>" + telegramEscape(telegramBatchTitle(m, items, len(m.Items))) + "</b>")
for i, it := range items {
b.WriteString("\n" + telegramEscape(telegramBatchLine(it, i+1)))
}
b.WriteString(footer)
return TruncateHTML(b.String(), telegramTextLimit), kept
}
if len(m.Items) == 0 {
return b.String(), 0
}
it := m.Items[0]
if it.IsStatusChange() {
b.WriteString(fmt.Sprintf("\n<b>상태 변경</b>: %s → %s",
telegramEscape(StatusLabel(it.FromStatus)), telegramEscape(StatusLabel(it.ToStatus))))
}
if it.VulnClass != "" && it.VulnClass != it.Title() {
b.WriteString("\n<b>유형</b>: " + telegramEscape(it.VulnClass))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
b.WriteString("\n<b>자산</b>: " + telegramEscape(a))
}
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
b.WriteString("\n<b>개요</b>: " + telegramEscape(s))
}
if it.DetailURL != "" {
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">상세 보기</a>", telegramEscapeAttr(it.DetailURL)))
}
return TruncateHTML(b.String(), telegramTextLimit), 1
}
// telegramReservedRunes 预留给消息标题与可能出现的截断提示(按字符计)。
const telegramReservedRunes = 160
// telegramBatchLine 渲染汇总里的一条(未转义,由调用方统一转义)。
func telegramBatchLine(it Item, idx int) string {
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
return fmt.Sprintf("%d. %s · %s — %s", idx, SeverityLabel(it.Severity), it.Title(), a)
}
return fmt.Sprintf("%d. %s · %s", idx, SeverityLabel(it.Severity), it.Title())
}
// telegramBatchTitle 渲染汇总消息的标题行。条数用的是**本条实际包含**的条数,
// 而不是本批总数——否则读者会以为消息头写的数字就是全部。
func telegramBatchTitle(m Message, items []Item, total int) string {
title := fmt.Sprintf("취약점 요약 · 총 %d건", total)
if extra := total - len(items); extra > 0 {
title += fmt.Sprintf(" (앞 %d건만 표시, 나머지 %d건은 다음 메시지에서 이어집니다)", len(items), extra)
}
if m.WindowMinutes > 0 {
title = fmt.Sprintf("최근 %d분간 · %s", m.WindowMinutes, title)
}
return title
}
// telegramEscape 转义 HTML 文本内容。
// Telegram 只认这三种实体,转义后 &amp; 之类的已有实体会被二次转义——这正是
// 期望行为:我们要显示的是原始字符,不是让用户注入 HTML。
func telegramEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
// telegramEscapeAttr 转义 HTML 属性值。在文本转义之外还要处理引号——
// URL 里带引号会提前闭合 href 属性,把后面的内容变成注入点。
func telegramEscapeAttr(s string) string {
s = telegramEscape(s)
s = strings.ReplaceAll(s, "\"", "&quot;")
return s
}
+224
View File
@@ -0,0 +1,224 @@
package notify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"text/template"
"time"
)
// webhookChannel 是通用 Webhook 适配器:用户自定 URL、方法、请求头与 JSON 模板。
// 它的存在让本功能不必为 Slack / Mattermost / Discord / 自建系统各写一个实现——
// 那些平台都能被一个可配模板覆盖。
type webhookChannel struct{}
func (webhookChannel) Kind() string { return KindWebhook }
// 通用 Webhook 没有官方限制,返回 0 表示默认不限流,由使用者按对端能力自定。
func (webhookChannel) DefaultRatePerMin() int { return 0 }
// 掩码 url 与 headers:目标地址本身常带 token,自定义头里通常放着鉴权凭据,
// 两者都会出现在接口回显里,所以都要挡。
// 代价是编辑时若想改动其中一个头,需要重新填整组头(掩码值会被解释为「保持原值」)——
// 这个取舍是刻意的:宁可多填一次,也不把凭据回显到浏览器。
func (webhookChannel) SecretKeys() []string { return []string{"url", "headers"} }
// 目的地是 url。改 url 时必须重新表态 headers —— 否则原始 Authorization 头
// 会被原样发到新地址,这正是掩码绕过的主路径。
func (webhookChannel) DestinationKeys() []string { return []string{"url"} }
// webhookDefaultTemplate 是未填模板时的兜底请求体:一个直白的 JSON 结构,
// 覆盖绝大多数「收一条 JSON 入库」的自建接收端。
const webhookDefaultTemplate = `{
"title": {{json .Title}},
"batch": {{.Batch}},
"count": {{.Count}},
"items": [
{{- range $i, $it := .Items}}
{{- if $i}},{{end}}
{
"finding_id": {{$it.FindingID}},
"name": {{json $it.Name}},
"vulnclass": {{json $it.VulnClass}},
"severity": {{json $it.Severity}},
"summary": {{json $it.Summary}},
"assets": {{json $it.Assets}},
"detail_url": {{json $it.DetailURL}}
}
{{- end}}
]
}`
// webhookTemplateData 是暴露给用户模板的上下文。
type webhookTemplateData struct {
Title string
Batch bool
Count int
Items []webhookItem
HomeURL string
// SentAt 是本次投递时间(RFC3339),供接收端记录。
SentAt string
}
type webhookItem struct {
FindingID int64
Name string
VulnClass string
Severity string
SeverityLabel string
Summary string
Assets []string
DetailURL string
FromStatus string
ToStatus string
// StatusLabel 是状态变更的可读描述,如「待处理 → 已修复」;非状态变更时为空。
StatusLabel string
}
func (webhookChannel) Validate(cfg map[string]any) error {
raw := cfgString(cfg, "url")
if raw == "" {
return errors.New("대상 URL이 없습니다")
}
if err := validateHTTPURL(raw); err != nil {
return fmt.Errorf("대상 URL이 올바르지 않습니다: %w", err)
}
if m := strings.ToUpper(cfgString(cfg, "method")); m != "" && m != http.MethodGet && m != http.MethodPost && m != http.MethodPut && m != http.MethodPatch {
return fmt.Errorf("지원하지 않는 메서드입니다: %s (GET/POST/PUT/PATCH 중에서 선택하세요)", m)
}
if tpl := cfgString(cfg, "body_template"); tpl != "" {
if _, err := parseWebhookTemplate(tpl); err != nil {
return fmt.Errorf("요청 본문 템플릿 문법이 올바르지 않습니다: %w", err)
}
}
return nil
}
func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
if err := c.Validate(cfg); err != nil {
return 0, Permanent(err)
}
method := strings.ToUpper(cfgString(cfg, "method"))
if method == "" {
method = http.MethodPost
}
// GET 不带请求体:把内容塞进 query 超出模板能力范围,也不符合 GET 语义,
// 所以 GET 只适合「命中即触发钩子」这类接收端。
var payload any
if method != http.MethodGet {
body, err := renderWebhookBody(cfgString(cfg, "body_template"), m)
if err != nil {
return 0, Permanent(err)
}
// 模板渲染出的是字符串形式的 JSON,这里转成 json.RawMessage 原样发出,
// 避免二次转义把用户精心构造的结构套进一个 JSON 字符串里。
if !json.Valid([]byte(body)) {
return 0, Permanent(errors.New("요청 본문 템플릿의 렌더링 결과가 올바른 JSON이 아닙니다"))
}
payload = json.RawMessage(body)
}
headers := cfgMap(cfg, "headers")
if ct := cfgString(cfg, "content_type"); ct != "" {
// 允许覆盖,但放在 headers 之后应用,保证显式配置优先。
if headers == nil {
headers = map[string]string{}
}
headers["Content-Type"] = ct
}
if _, err := doJSON(ctx, method, cfgString(cfg, "url"), headers, payload); err != nil {
return 0, err
}
// 通用 Webhook 不截断正文(接收端是用户自己的服务,体积由 body_template 决定),
// 因此整批都算送达。
return len(m.Items), nil
}
// renderWebhookBody 用用户模板(或默认模板)渲染请求体。
func renderWebhookBody(tpl string, m Message) (string, error) {
if strings.TrimSpace(tpl) == "" {
tpl = webhookDefaultTemplate
}
t, err := parseWebhookTemplate(tpl)
if err != nil {
return "", fmt.Errorf("요청 본문 템플릿 문법이 올바르지 않습니다: %w", err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, newWebhookTemplateData(m)); err != nil {
return "", fmt.Errorf("요청 본문 템플릿을 렌더링하지 못했습니다: %w", err)
}
return buf.String(), nil
}
// parseWebhookTemplate 解析模板。
//
// missingkey=zero 让缺失的 map 键渲染成零值而不是报错——但本文件的上下文是结构体,
// 主要作用是让 .Items 为空时 range 不出错。真正需要防的是 .Items 为 nil。
func parseWebhookTemplate(tpl string) (*template.Template, error) {
return template.New("body").Funcs(webhookTemplateFuncs).Option("missingkey=zero").Parse(tpl)
}
// webhookTemplateFuncs 是暴露给模板的辅助函数。
var webhookTemplateFuncs = template.FuncMap{
// json 把任意值序列化成 JSON。
//
// 这个函数不是锦上添花而是必需的:省去它,用户只能写 {{.Title}} 直接插值,
// 而漏洞标题里只要有引号或换行,整段请求体就不再是合法 JSON——接收端会
// 拒收,且报错信息指向「JSON 解析失败」,完全联想不到是标题里有个引号。
"json": func(v any) (string, error) {
raw, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(raw), nil
},
// jsons 用于把 JSON 片段嵌进另一段 JSON 字符串值内部(做一层字符串转义)。
"jsons": func(v any) (string, error) {
raw, err := json.Marshal(v)
if err != nil {
return "", err
}
quoted, err := json.Marshal(string(raw))
if err != nil {
return "", err
}
// 去掉外层引号:调用方自己决定要不要加引号。
return string(quoted[1 : len(quoted)-1]), nil
},
}
func newWebhookTemplateData(m Message) webhookTemplateData {
d := webhookTemplateData{
Title: markdownTitle(m),
Batch: m.Batch,
Count: len(m.Items),
HomeURL: m.HomeURL,
SentAt: time.Now().Format(time.RFC3339),
Items: make([]webhookItem, 0, len(m.Items)),
}
for _, it := range m.Items {
wi := webhookItem{
FindingID: it.FindingID,
Name: it.Name,
VulnClass: it.VulnClass,
Severity: it.Severity,
SeverityLabel: SeverityLabel(it.Severity),
Summary: it.Summary,
Assets: append([]string{}, it.Assets...),
DetailURL: it.DetailURL,
FromStatus: it.FromStatus,
ToStatus: it.ToStatus,
}
if it.IsStatusChange() {
wi.StatusLabel = StatusLabel(it.FromStatus) + " → " + StatusLabel(it.ToStatus)
}
d.Items = append(d.Items, wi)
}
return d
}
@@ -0,0 +1,209 @@
package notify
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// 이 파일은 F4 ④⑤(telegram·webhook·공용 HTTP 전송 계층) 한국어화를 회귀로부터
// 지킨다. telegram.go 의 메시지 라벨·배치 제목, webhook.go 의 설정 검증 오류,
// http.go 의 전송 오류, 그리고 다섯 채널이 공유하는 validateHTTPURL 이 중국어로
// 되돌아가면 잡아낸다. 헬퍼 hasHan·hasHangul·assertKorean 은
// notify_localized_test.go 에 있다(같은 패키지).
//
// 데이터(제목·유형·자산·개요)는 전부 ASCII 로 둔다. 그래야 "출력 전체에 한자 0"
// 이라는 단언이 콘텐츠가 아니라 골격 라벨의 회귀만 정확히 포착한다. 심각도·상태
// 라벨은 F4 ①에서 이미 한국어라 출력에 한글로 나오며(한자 아님) 단언을 통과한다.
// TestTelegramItemLabelsLocalized 는 단건 Telegram 메시지의 모든 라벨 분기를 켠 뒤
// 골격이 한국어이고 중국어 한자가 없음을 확인한다.
func TestTelegramItemLabelsLocalized(t *testing.T) {
m := Message{
HomeURL: "https://example.com/panel",
Items: []Item{{
Name: "sqli-login", // Title() = Name
VulnClass: "injection", // Title() 과 달라야 유형 줄이 렌더됨
Severity: "high", // SeverityLabel → 높음(한글)
Summary: "login form is injectable",
Assets: []string{"host-a.example.com"},
DetailURL: "https://example.com/f/1",
FromStatus: "pending", // IsStatusChange()=true → 상태 변경 줄
ToStatus: "fixed",
}},
}
out, kept := telegramHTML(m)
if kept != 1 {
t.Fatalf("단건 메시지는 1 을 보고해야 합니다, 받은 값 %d", kept)
}
if hasHan(out) {
t.Errorf("Telegram 본문에 중국어 한자가 남아 있습니다:\n%s", out)
}
for _, want := range []string{"상태 변경", "유형", "자산", "개요", "상세 보기"} {
if !strings.Contains(out, want) {
t.Errorf("Telegram 본문에 %q 라벨이 없습니다:\n%s", want, out)
}
}
}
// TestTelegramBatchTitleLocalized 는 배치(digest) 제목의 넘침·시간창 분기가
// 한국어로 렌더되는지 확인한다. markdownTitle 과 글자까지 맞춰야 채널 간 혼재가
// 생기지 않는다.
func TestTelegramBatchTitleLocalized(t *testing.T) {
items := []Item{{Name: "a", Severity: "high"}, {Name: "b", Severity: "low"}}
// 넘침 있음 + 시간창: 요약·총건수·앞 N건·나머지·다음 메시지·최근 N분간.
over := telegramBatchTitle(Message{WindowMinutes: 30}, items, 5)
assertKorean(t, "telegramBatchTitle(넘침)", over)
for _, want := range []string{"취약점 요약 · 총 5건", "앞 2건만 표시", "나머지 3건", "다음 메시지", "최근 30분간"} {
if !strings.Contains(over, want) {
t.Errorf("넘침 제목에 %q 가 없습니다: %q", want, over)
}
}
// 넘침 없음 + 시간창 없음: 다음 메시지 안내가 없어야 한다.
full := telegramBatchTitle(Message{}, items, 2)
assertKorean(t, "telegramBatchTitle(전량)", full)
if !strings.Contains(full, "취약점 요약 · 총 2건") {
t.Errorf("전량 제목에 '취약점 요약 · 총 2건' 이 있어야 합니다: %q", full)
}
if strings.Contains(full, "다음 메시지") {
t.Errorf("넘치지 않았는데 '다음 메시지' 안내가 들어갔습니다: %q", full)
}
}
// TestTelegramValidateLocalized 는 필수 필드 누락 오류가 한국어이고 어떤 필드가
// 비었는지 알려 주는지 확인한다. 기술 용어 Bot Token·Chat ID 는 그대로 보존한다.
func TestTelegramValidateLocalized(t *testing.T) {
if err := (telegramChannel{}).Validate(map[string]any{}); err == nil {
t.Fatal("bot_token 누락은 검증 실패여야 합니다")
} else {
assertKorean(t, "telegram Validate(bot_token)", err.Error())
if !strings.Contains(err.Error(), "Bot Token") {
t.Errorf("Bot Token 누락을 알려줘야 합니다: %q", err.Error())
}
}
if err := (telegramChannel{}).Validate(map[string]any{"bot_token": "t"}); err == nil {
t.Fatal("chat_id 누락은 검증 실패여야 합니다")
} else {
assertKorean(t, "telegram Validate(chat_id)", err.Error())
if !strings.Contains(err.Error(), "Chat ID") {
t.Errorf("Chat ID 누락을 알려줘야 합니다: %q", err.Error())
}
}
}
// TestWebhookValidateLocalized 는 범용 Webhook 설정 검증 오류 세 가지가 한국어이고
// 각 오류가 문제의 원인을 알려 주는지 확인한다.
func TestWebhookValidateLocalized(t *testing.T) {
if err := (webhookChannel{}).Validate(map[string]any{}); err == nil {
t.Fatal("url 누락은 검증 실패여야 합니다")
} else {
assertKorean(t, "webhook Validate(url)", err.Error())
if !strings.Contains(err.Error(), "대상 URL") {
t.Errorf("대상 URL 누락을 알려줘야 합니다: %q", err.Error())
}
}
// 지원하지 않는 메서드: 한국어 + 허용 목록 노출.
if err := (webhookChannel{}).Validate(map[string]any{"url": "https://example.com/hook", "method": "DELETE"}); err == nil {
t.Fatal("DELETE 는 검증 실패여야 합니다")
} else {
assertKorean(t, "webhook Validate(method)", err.Error())
if !strings.Contains(err.Error(), "GET/POST/PUT/PATCH") {
t.Errorf("허용 메서드 목록을 알려줘야 합니다: %q", err.Error())
}
}
// 템플릿 문법 오류: 한국어(래핑된 원인은 Go 템플릿 오류라 한자가 없다).
if err := (webhookChannel{}).Validate(map[string]any{"url": "https://example.com/hook", "body_template": "{{"}); err == nil {
t.Fatal("깨진 템플릿은 검증 실패여야 합니다")
} else {
assertKorean(t, "webhook Validate(template)", err.Error())
if !strings.Contains(err.Error(), "템플릿") {
t.Errorf("템플릿 오류임을 알려줘야 합니다: %q", err.Error())
}
}
}
// TestValidateHTTPURLLocalized 는 다섯 채널(telegram·webhook·dingtalk·feishu·wecom)이
// 공유하는 URL 검증 헬퍼의 오류가 한국어인지 확인한다. 이 헬퍼가 중국어로 남아 있으면
// 한국어 접두("API 주소가 올바르지 않습니다: ...")와 섞여 반한반중이 된다.
func TestValidateHTTPURLLocalized(t *testing.T) {
// 지원하지 않는 스킴.
if err := validateHTTPURL("ftp://example.com/x"); err == nil {
t.Fatal("ftp 스킴은 거부되어야 합니다")
} else {
assertKorean(t, "validateHTTPURL(scheme)", err.Error())
if !strings.Contains(err.Error(), "http") {
t.Errorf("지원 스킴(http/https)을 알려줘야 합니다: %q", err.Error())
}
}
// 호스트 이름 없음.
if err := validateHTTPURL("http://"); err == nil {
t.Fatal("호스트 없는 주소는 거부되어야 합니다")
} else {
assertKorean(t, "validateHTTPURL(host)", err.Error())
}
}
// TestHTTPLocalTargetBlockedLocalized 는 로컬/링크 로컬 주소 차단 오류가 한국어이고
// 우회 방법(환경 변수)을 알려 주는지 확인한다.
func TestHTTPLocalTargetBlockedLocalized(t *testing.T) {
t.Setenv(AllowLocalTargetsEnv, "") // 명시적으로 꺼서 차단 경로를 탄다
err := blockInternalDial("tcp", "127.0.0.1:25", nil)
if err == nil {
t.Fatal("로컬 주소는 기본적으로 차단되어야 합니다")
}
assertKorean(t, "blockInternalDial", err.Error())
if !strings.Contains(err.Error(), AllowLocalTargetsEnv) {
t.Errorf("차단 오류는 우회 방법(%s)을 알려줘야 합니다: %q", AllowLocalTargetsEnv, err.Error())
}
}
// TestHTTPRedactHelpersLocalized 는 주소 탈감(redact) 자리표시자와 전송 오류의
// "알 수 없는 오류" 분기가 한국어인지 확인한다.
func TestHTTPRedactHelpersLocalized(t *testing.T) {
if got := redactRequestTarget(""); !strings.Contains(got, "해석할 수 없") {
t.Errorf("해석 불가 주소는 한국어 자리표시자여야 합니다, 받은 값 %q", got)
}
// *url.Error 의 Err 가 nil 인 분기.
got := redactTransportError(&url.Error{Op: "Get", URL: "http://api.example", Err: nil})
if hasHan(got) {
t.Errorf("전송 오류 탈감 결과에 한자가 남았습니다: %q", got)
}
if !strings.Contains(got, "알 수 없는 오류") {
t.Errorf("Err 가 nil 이면 '알 수 없는 오류' 여야 합니다, 받은 값 %q", got)
}
}
// TestHTTPStatusErrorsLocalized 는 실제 doJSON 왕복으로 상태코드 분류(거부/서버
// 오류/제한)의 사용자 노출 오류가 한국어인지 확인한다. 127.0.0.1 httptest 로의
// 전송은 기본 차단이라 이 테스트에서만 명시적으로 허용한다.
func TestHTTPStatusErrorsLocalized(t *testing.T) {
t.Setenv(AllowLocalTargetsEnv, "1")
cases := []struct {
code int
want string
}{
{http.StatusForbidden, "요청을 거부했습니다"},
{http.StatusInternalServerError, "오류가 발생했습니다"},
{http.StatusTooManyRequests, "요청을 제한하거나"},
}
for _, tc := range cases {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.code)
_, _ = w.Write([]byte("body"))
}))
_, err := doJSON(context.Background(), http.MethodPost, srv.URL, nil, map[string]any{"a": 1})
srv.Close()
if err == nil {
t.Fatalf("HTTP %d 는 오류여야 합니다", tc.code)
}
assertKorean(t, "doJSON(HTTP status)", err.Error())
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("HTTP %d 오류에 %q 가 있어야 합니다, 받은 값 %q", tc.code, tc.want, err.Error())
}
}
}
+106
View File
@@ -0,0 +1,106 @@
package notify
import (
"context"
"reflect"
"sort"
"strings"
"testing"
)
// 本文件锁住通用 Webhook 模板的**能力边界**。
//
// 这是全包唯一「用户提供的字符串会被当代码求值」的地方,所以要明确它能做什么、
// 不能做什么,并用测试把这些性质固定下来——否则将来有人顺手给模板上下文加个
// 方法、或在 FuncMap 里加个 readFile,能力面就静默扩大了,而 diff 看起来
// 只是一个无害的小函数。
// TestTemplateContextHasNoMethods 是最重要的一条。
//
// text/template 会调用导出方法({{.Foo}} 既能取字段也能调方法)。所以模板上下文
// 只要能接触到**任何**带导出方法的类型,就等于把那些方法暴露给了模板作者。
// 本功能的上下文刻意全是纯数据(只有导出字段、零方法)。
//
// 若这条失败:说明有人给 webhookTemplateData / webhookItem 加了方法。
// 在决定放行之前,先想清楚那个方法能不能被模板用来读取不希望暴露的东西。
func TestTemplateContextHasNoMethods(t *testing.T) {
for _, v := range []any{webhookTemplateData{}, webhookItem{}} {
typ := reflect.TypeOf(v)
if n := typ.NumMethod(); n != 0 {
var names []string
for i := 0; i < n; i++ {
names = append(names, typ.Method(i).Name)
}
t.Fatalf("%s 暴露了 %d 个方法(%s):text/template 可以调用它们,"+
"等于把这些方法的能力开放给了模板作者", typ.Name(), n, strings.Join(names, ", "))
}
}
}
// TestTemplateFuncsAreMinimal 锁住暴露给模板的函数集合。
//
// FuncMap 里每多一个函数就多一项能力。当前只有 json / jsons,作用是把值序列化
// 成 JSON 片段——不能读文件、不能发请求、不能执行命令。
func TestTemplateFuncsAreMinimal(t *testing.T) {
var got []string
for name := range webhookTemplateFuncs {
got = append(got, name)
}
sort.Strings(got)
want := []string{"json", "jsons"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("模板函数集合变了:得到 %v,期望 %v。新增函数前请确认它不会扩大能力面"+
"(不能读写文件、不能发起网络请求、不能执行命令)", got, want)
}
}
// TestTemplateCannotReachUnknownData 覆盖模板里的越界访问:
// 访问不存在的东西必须失败,而不是回显点什么;且失败信息不得带出内部数据。
func TestTemplateCannotReachUnknownData(t *testing.T) {
_, err := renderWebhookBody(`{"x": {{.Environment}}, "y": {{.Env}}}`, singleMsg())
if err == nil {
t.Fatal("访问不存在的字段应报错")
}
// 错误里不能出现模板上下文里的真实内容(漏洞标题/摘要)。
for _, leak := range []string{"SQL注入", "参数 id"} {
if strings.Contains(err.Error(), leak) {
t.Errorf("模板错误泄露了消息内容 %q: %v", leak, err)
}
}
}
// TestTemplateRenderFailsPermanently 模板写错属于配置错误,重试不会自愈。
// 若被判成可重试,一条坏模板会让每次投递都白跑三轮退避。
func TestTemplateRenderFailsPermanently(t *testing.T) {
cfg := map[string]any{
"url": "https://example.com/hook",
"body_template": `{{.Items.`,
}
if err := (webhookChannel{}).Validate(cfg); err == nil {
t.Fatal("模板语法错误应在保存时就被拦下")
}
// 即便绕过校验直接投递,也必须判永久失败而不是反复重试。
_, err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg())
if err == nil || !IsPermanent(err) {
t.Fatalf("坏模板应判永久失败,得到 %v", err)
}
}
// TestTemplateCanOnlyProduceJSON 覆盖「模板渲染结果必须是合法 JSON」这条约束。
// 它顺带挡住了「用模板生成纯文本去触发别的协议」这类用法。
func TestTemplateCanOnlyProduceJSON(t *testing.T) {
// 合法模板能过。
ok := map[string]any{"url": "https://example.com/hook", "body_template": `{"t":{{json .Title}}}`}
if err := (webhookChannel{}).Validate(ok); err != nil {
t.Fatalf("合法模板应通过校验: %v", err)
}
// 渲染出非 JSON 时必须拒绝(而不是原样发出去)。
bad := map[string]any{"url": "http://127.0.0.1:1/hook", "body_template": `not json {{.Count}}`}
_, err := (webhookChannel{}).Send(context.Background(), bad, singleMsg())
if err == nil || !IsPermanent(err) {
t.Fatalf("渲染出非 JSON 应判永久失败,得到 %v", err)
}
if !strings.Contains(err.Error(), "올바른 JSON") {
t.Errorf("错误信息应说明是 JSON 问题,得到 %v", err)
}
}
+78
View File
@@ -0,0 +1,78 @@
package notify
import (
"context"
"encoding/json"
"errors"
"fmt"
)
// weComMarkdownLimit 是企微群机器人 markdown content 的硬上限(字节,非字符)。
// 这是全部六个渠道里最紧的限制,也是 TruncateBytes 存在的主要原因。
const weComMarkdownLimit = 4096
// weComChannel 实现企业微信群机器人。
//
// 平台特性:
// - 唯一通过 URL 上的 key 鉴权,不支持加签——所以 webhook 地址本身就是全部凭据。
// - markdown content 上限 4096 **字节**,超长整条被拒(不是截断)。中文 3 字节/字,
// 意味着正文只有一千多字可写,必须客户端截断。
// - 限流 20 条/分钟,同样靠客户端限流兜住。
type weComChannel struct{}
func (weComChannel) Kind() string { return KindWeCom }
func (weComChannel) DefaultRatePerMin() int { return 20 }
// 企业微信只有 Webhook 一处凭据(URL 上的 key),且它不支持加签——
// 整个地址就是全部凭据,没有别的字段需要掩码。
func (weComChannel) SecretKeys() []string { return []string{"webhook"} }
// 企微只有 Webhook 一处字段,它既是目的地也是凭据,因此没有「改地址后残留的凭据」可言。
func (weComChannel) DestinationKeys() []string { return []string{"webhook"} }
func (weComChannel) Validate(cfg map[string]any) error {
hook := cfgString(cfg, "webhook")
if hook == "" {
return errors.New("Webhook 주소가 없습니다")
}
if err := validateHTTPURL(hook); err != nil {
return fmt.Errorf("Webhook 주소가 올바르지 않습니다: %w", err)
}
return nil
}
func (c weComChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
if err := c.Validate(cfg); err != nil {
return 0, Permanent(err)
}
// 汇总批可能很长(50 条 × 每条一行 + 前缀),4096 字节很容易超。
// 截断在这里做而不是靠平台报错:被拒意味着这一批全丢,而截断至少送达前若干条。
content, kept := markdownBody(m, weComMarkdownLimit)
payload := map[string]any{
"msgtype": "markdown",
"markdown": map[string]any{"content": content},
}
raw, err := doJSON(ctx, "POST", cfgString(cfg, "webhook"), nil, payload)
if err != nil {
return 0, err
}
var res struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return 0, fmt.Errorf("WeCom 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
}
if res.ErrCode != 0 {
// 45009 是接口调用超过限制——平台的限流窗口会滚动,退避后重试是有效的,
// 所以显式归为可重试。走到这里说明客户端 rate_per_min 配得过于激进,
// 重试只是兜底,真正的修法是调低该渠道的限流值。
if res.ErrCode == 45009 {
return 0, fmt.Errorf("WeCom 요청이 제한되었습니다 (%d): %s", res.ErrCode, res.ErrMsg)
}
// 93000 是 webhook key 无效——永久失败,重试不会自愈。
return 0, Permanent(fmt.Errorf("WeCom에서 오류가 발생했습니다 (%d): %s", res.ErrCode, res.ErrMsg))
}
return kept, nil
}