Localize user-facing UI, agent output, and backend messages to Simplified Chinese
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
docs / links (push) Waiting to run
web / web (push) Waiting to run

This commit is contained in:
dela
2026-10-09 14:08:31 +08:00
parent 8af63417c8
commit 97668dab7e
148 changed files with 1671 additions and 1672 deletions
+4 -4
View File
@@ -88,7 +88,7 @@ func TestDingTalkFallsBackToMarkdownForBatch(t *testing.T) {
t.Fatalf("汇总消息应发 markdown,得到 %v", body["msgtype"])
}
md, _ := body["markdown"].(map[string]any)
if !strings.Contains(md["text"].(string), "최근 30분간") {
if !strings.Contains(md["text"].(string), "最近 30 分钟") {
t.Errorf("汇总正文缺少时间窗: %v", md["text"])
}
})
@@ -251,7 +251,7 @@ 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注入" 风险` {
if body["title"] != `[🟠 高危] 登录处 "SQL注入" 风险` {
t.Errorf("标题未正确还原: %v", body["title"])
}
items, _ := body["items"].([]any)
@@ -414,8 +414,8 @@ func TestChannelValidateReportsMissingFields(t *testing.T) {
{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"}, "수신자"},
{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)
+10 -10
View File
@@ -38,10 +38,10 @@ 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 주소가 없습니다")
return errors.New("缺少 Webhook 地址")
}
if err := validateHTTPURL(hook); err != nil {
return fmt.Errorf("Webhook 주소가 올바르지 않습니다: %w", err)
return fmt.Errorf("Webhook 地址无效:%w", err)
}
return nil
}
@@ -69,7 +69,7 @@ func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message
"title": title,
"text": text,
"btnOrientation": "0",
"singleTitle": "상세 보기",
"singleTitle": "查看详情",
"singleURL": m.Items[0].DetailURL,
},
}
@@ -90,12 +90,12 @@ func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return 0, fmt.Errorf("DingTalk 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
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 0, Permanent(fmt.Errorf("DingTalk 发生错误 (%d):%s", res.ErrCode, res.ErrMsg))
}
return kept, nil
}
@@ -117,7 +117,7 @@ func dingTalkSignedURL(hook, secret string, now time.Time) (string, error) {
u, err := url.Parse(hook)
if err != nil {
// 不透传 err:url.Parse 的错误文本里带完整地址(含 access_token)。
return "", fmt.Errorf("Webhook 주소를 해석하지 못했습니다: %s", redactRequestTarget(hook))
return "", fmt.Errorf("无法解析 Webhook 地址:%s", redactRequestTarget(hook))
}
q := u.Query()
q.Set("timestamp", ts)
@@ -145,16 +145,16 @@ func dingTalkSignedURL(hook, secret string, now time.Time) (string, error) {
func validateHTTPURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("주소를 해석할 수 없습니다 (%s)", redactRequestTarget(raw))
return fmt.Errorf("无法解析地址 (%s)", redactRequestTarget(raw))
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("http 또는 https 만 지원합니다. 받은 값: %q", u.Scheme)
return fmt.Errorf("仅支持 http 或 https。收到的值:%q", u.Scheme)
}
if u.Host == "" {
return errors.New("호스트 이름이 없습니다")
return errors.New("缺少主机名")
}
if ip := net.ParseIP(u.Hostname()); ip != nil && isBlockedDialIP(ip) && !allowLocalTargets() {
return fmt.Errorf("로컬 또는 링크 로컬 주소 %s 로는 전송하지 않습니다 (로컬 서비스로 보내야 하면 %s=1 로 설정하세요)", ip, AllowLocalTargetsEnv)
return fmt.Errorf("不会向本地或链路本地地址 %s 发送 (如需发送到本地服务,请设置 %s=1)", ip, AllowLocalTargetsEnv)
}
return nil
}
+30 -30
View File
@@ -40,12 +40,12 @@ func mustJSON(t *testing.T, v any) string {
// 별개로 이 핀이 있어야 飞书 본문만 중국어로 되돌아가는 회귀를 잡는다.
func TestFeishuItemLinesLocalized(t *testing.T) {
got := feishuItemLines(asciiStatusChangeItem())
assertKorean(t, "feishuItemLines", got)
assertChinese(t, "feishuItemLines", got)
for _, want := range []string{
"**상태 변경**: 처리 대기 → 수정됨",
"**유형**: SQLi",
"**자산**: a.example.com",
"**개요**: SQL injection via q param",
"**状态变更**:待处理 → 已修复",
"**类型**:SQLi",
"**资产**:a.example.com",
"**概要**:SQL injection via q param",
} {
if !strings.Contains(got, want) {
t.Errorf("飞书 단건 본문에 %q 가 있어야 합니다:\n%s", want, got)
@@ -59,21 +59,21 @@ 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 hasHangul(singleJSON) {
t.Errorf("단건 飞书 카드에 한글이 남았습니다:\n%s", singleJSON)
}
if !strings.Contains(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 hasHangul(batchJSON) {
t.Errorf("다건 飞书 카드에 한글이 남았습니다:\n%s", batchJSON)
}
if !strings.Contains(batchJSON, "플랫폼에서 전체 보기") {
t.Errorf("다건 飞书 카드에 '플랫폼에서 전체 보기' 버튼이 있어야 합니다:\n%s", batchJSON)
if !strings.Contains(batchJSON, "在平台查看全部") {
t.Errorf("다건 飞书 카드에 '在平台查看全部' 버튼이 있어야 합니다:\n%s", batchJSON)
}
}
@@ -89,8 +89,8 @@ func TestDingTalkActionCardButtonLocalized(t *testing.T) {
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)
if singleTitle != "查看详情" {
t.Errorf("钉钉 ActionCard singleTitle 은 '查看详情' 여야 합니다, 받은 값 %q", singleTitle)
}
}
@@ -108,21 +108,21 @@ func TestChinaPlatformValidateLocalized(t *testing.T) {
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)
assertChinese(t, kind+" missing", missing.Error())
if !strings.Contains(missing.Error(), "Webhook") || !strings.Contains(missing.Error(), "缺少") {
t.Errorf("%s: 누락 오류는 '缺少 Webhook 地址' 여야 합니다, 받은 값 %q", kind, missing)
}
// 잘못된 주소(ftp): "Webhook 주소가 올바르지 않습니다: …"
// 잘못된 주소(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 hasHangul(bad.Error()) {
t.Errorf("%s: 잘못된 주소 오류에 한글이 남았습니다: %q", kind, bad)
}
if !strings.Contains(bad.Error(), "Webhook 주소가 올바르지 않습니다") {
t.Errorf("%s: 잘못된 주소 오류는 'Webhook 주소가 올바르지 않습니다' 로 시작해야 합니다, 받은 값 %q", kind, bad)
if !strings.Contains(bad.Error(), "Webhook 地址无效") {
t.Errorf("%s: 잘못된 주소 오류는 'Webhook 地址无效' 로 시작해야 합니다, 받은 값 %q", kind, bad)
}
}
}
@@ -138,10 +138,10 @@ func TestChinaPlatformSendErrorsLocalized(t *testing.T) {
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"}},
{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)
@@ -153,8 +153,8 @@ func TestChinaPlatformSendErrorsLocalized(t *testing.T) {
if err == nil {
t.Fatalf("%s: %s 응답은 오류여야 합니다", tc.kind, tc.resp)
}
if hasHan(err.Error()) {
t.Errorf("%s: 발송 오류에 중국어 한자가 남았습니다: %q", tc.kind, err)
if hasHangul(err.Error()) {
t.Errorf("%s: 발송 오류에 한글이 남았습니다: %q", tc.kind, err)
}
for _, sub := range tc.wantSubs {
if !strings.Contains(err.Error(), sub) {
@@ -182,7 +182,7 @@ func TestChinaPlatformSendErrorsLocalized(t *testing.T) {
if err == nil {
t.Fatalf("%s: JSON 이 아닌 응답은 오류여야 합니다", pc.kind)
}
want := pc.platform + " 응답을 해석하지 못했습니다"
want := "无法解析 " + pc.platform + " 响应"
if !strings.Contains(err.Error(), want) {
t.Errorf("%s: 파싱 실패 오류에 %q 가 있어야 합니다, 받은 값 %q", pc.kind, want, err)
}
+14 -14
View File
@@ -40,17 +40,17 @@ func (emailChannel) DestinationKeys() []string { return []string{"host", "port",
func (emailChannel) Validate(cfg map[string]any) error {
if cfgString(cfg, "host") == "" {
return errors.New("SMTP 서버 주소가 없습니다")
return errors.New("缺少 SMTP 服务器地址")
}
port := cfgInt(cfg, "port")
if port <= 0 || port > 65535 {
return errors.New("SMTP 포트가 올바르지 않습니다 (1-65535 범위여야 합니다)")
return errors.New("SMTP 端口无效(必须在 1-65535 范围内)")
}
if cfgString(cfg, "from") == "" {
return errors.New("발신자 주소가 없습니다")
return errors.New("缺少发件人地址")
}
if len(cfgStrings(cfg, "to")) == 0 {
return errors.New("수신자 주소가 최소 하나 필요합니다")
return errors.New("至少需要一个收件人地址")
}
return nil
}
@@ -83,7 +83,7 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (
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)
return 0, fmt.Errorf("STARTTLS 失败:%w", err)
}
}
}
@@ -93,28 +93,28 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (
// 这是**正确**的安全行为,不能绕过,但需要把原因翻译清楚——
// 否则使用者只会看到「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("拒绝发送凭据:连接未加密。请开启 TLS、使用 465 端口(隐式 TLS),或勾选「使用 TLS」(%w)", err))
}
return 0, Permanent(fmt.Errorf("SMTP 인증 실패: %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)
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)
return 0, smtpStageError(fmt.Sprintf("收件人地址 %s 被拒绝", rcpt), err)
}
}
w, err := client.Data()
if err != nil {
return 0, fmt.Errorf("SMTP DATA 실패: %w", err)
return 0, fmt.Errorf("SMTP DATA 失败:%w", err)
}
if _, err := w.Write([]byte(msg)); err != nil {
return 0, fmt.Errorf("메일 본문 쓰기 실패: %w", err)
return 0, fmt.Errorf("写入邮件正文失败:%w", err)
}
if err := w.Close(); err != nil {
return 0, fmt.Errorf("메일 제출 실패: %w", err)
return 0, fmt.Errorf("提交邮件失败:%w", err)
}
// Quit 失败不影响「邮件已被服务器接收」这个事实,因此忽略其错误。
_ = client.Quit()
@@ -145,13 +145,13 @@ func emailDial(ctx context.Context, addr, host string, implicitTLS bool) (*smtp.
conn, err = d.DialContext(ctx, "tcp", addr)
}
if err != nil {
return nil, fmt.Errorf("SMTP 서버 연결 실패: %w", err)
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 nil, fmt.Errorf("SMTP 握手失败:%w", err)
}
return client, nil
}
+1 -1
View File
@@ -249,7 +249,7 @@ func TestEmailSendRefusesPlaintextCredentials(t *testing.T) {
t.Skip("本机 DNS 解析到了本地服务器,跳过(不影响其它用例)")
}
// 连不上 或 被拒发凭据都算通过这条断言;关键是**不能**静默把密码发出去。
if !IsPermanent(err) && !strings.Contains(err.Error(), "연결") {
if !IsPermanent(err) && !strings.Contains(err.Error(), "连接") {
t.Logf("错误:%v(非 localhost 下未能连上属预期)", err)
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ func (i Item) Title() string {
if i.VulnClass != "" {
return i.VulnClass
}
return "(이름 없는 취약점)"
return "(未命名漏洞)"
}
// Message 是一次渠道发送的完整内容。
+11 -11
View File
@@ -34,10 +34,10 @@ 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 주소가 없습니다")
return errors.New("缺少 Webhook 地址")
}
if err := validateHTTPURL(hook); err != nil {
return fmt.Errorf("Webhook 주소가 올바르지 않습니다: %w", err)
return fmt.Errorf("Webhook 地址无效:%w", err)
}
return nil
}
@@ -69,13 +69,13 @@ func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message)
StatusMessage string `json:"StatusMessage"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return 0, fmt.Errorf("Feishu 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
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))
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 0, Permanent(fmt.Errorf("Feishu 发生错误 (%d):%s", res.StatusCode, res.StatusMessage))
}
return kept, nil
}
@@ -133,14 +133,14 @@ func feishuCard(m Message) (map[string]any, int) {
elements = append(elements, feishuMarkdownDiv(feishuBatchLine(it, i+1)))
}
if m.HomeURL != "" {
elements = append(elements, feishuButton("플랫폼에서 전체 보기", 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))
elements = append(elements, feishuButton("查看详情", it.DetailURL))
}
}
@@ -179,18 +179,18 @@ func feishuButton(label, url string) map[string]any {
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",
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))
out += fmt.Sprintf("\n**类型**:%s", markdownText(it.VulnClass, 0))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
out += fmt.Sprintf("\n**자산**: %s", markdownText(a, 0))
out += fmt.Sprintf("\n**资产**:%s", markdownText(a, 0))
}
if it.Summary != "" {
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
out += fmt.Sprintf("\n**개요**: %s", s)
out += fmt.Sprintf("\n**概要**:%s", s)
}
}
return out
+1 -1
View File
@@ -61,7 +61,7 @@ func ValidMinSeverity(s string) bool {
// 这样历史数据里已经存在的坏值不会让渠道整个读不出来。
func (f Filter) Validate() error {
if !ValidMinSeverity(f.MinSeverity) {
return fmt.Errorf("올바르지 않은 최소 심각도입니다: %q. low / medium / high / critical 중에서 선택하거나, 비워 두면 제한하지 않습니다", f.MinSeverity)
return fmt.Errorf("最低严重级别无效:%q。请从 low / medium / high / critical 中选择,留空表示不限制", f.MinSeverity)
}
return nil
}
+8 -8
View File
@@ -43,7 +43,7 @@ func htmlBody(m Message, maxRunes int) string {
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))
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)
@@ -53,9 +53,9 @@ func htmlBody(m Message, maxRunes int) string {
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))
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))
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 {
@@ -103,20 +103,20 @@ func htmlItem(it Item, full bool) string {
}
if it.IsStatusChange() {
fmt.Fprintf(&b, `<div><b>상태 변경</b>: %s → %s</div>`,
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))
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))
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))
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))
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()
+15 -15
View File
@@ -7,7 +7,7 @@ import (
// 이 파일은 F4 ③(이메일 본문·HTML 템플릿) 한국어화를 회귀로부터 지킨다.
// html.go 의 라벨과 email.go 의 검증 오류가 중국어로 되돌아가면 잡아낸다.
// 헬퍼 hasHan·hasHangul·assertKorean 은 notify_localized_test.go 에 있다(같은 패키지).
// 헬퍼 hasHan·hasHangul·assertChinese 은 notify_localized_test.go 에 있다(같은 패키지).
//
// 데이터(제목·유형·자산·개요)는 전부 ASCII 로 둔다. 그래야 "출력 전체에 한자 0"
// 이라는 단언이 콘텐츠가 아니라 골격 라벨의 회귀만 정확히 포착한다. 심각도·상태
@@ -30,11 +30,11 @@ func TestHTMLItemLabelsLocalized(t *testing.T) {
}},
}
out := htmlBody(m, 0)
if hasHan(out) {
t.Errorf("이메일 본문에 중국어 한자가 남아 있습니다:\n%s", out)
if hasHangul(out) {
t.Errorf("이메일 본문에 한글이 남아 있습니다:\n%s", out)
}
for _, want := range []string{
"상태 변경", "유형", "자산", "개요", "상세 보기", "플랫폼에서 전체 보기",
"状态变更", "类型", "资产", "概要", "查看详情", "在平台查看全部",
} {
if !strings.Contains(out, want) {
t.Errorf("이메일 본문에 %q 라벨이 없습니다:\n%s", want, out)
@@ -48,20 +48,20 @@ 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건"} {
assertChinese(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)
assertChinese(t, "htmlBatchIntro(시간창 없음)", noWindow)
if !strings.Contains(noWindow, "新增漏洞 2 条") {
t.Errorf("시간창 없는 머리말이 %q 를 포함해야 합니다: %q", "新增漏洞 2 条", noWindow)
}
if strings.Contains(noWindow, "분간") {
t.Errorf("시간창이 없는데 '분간' 이 들어갔습니다: %q", noWindow)
if strings.Contains(noWindow, "分钟") {
t.Errorf("시간창이 없는데 '分钟' 이 들어갔습니다: %q", noWindow)
}
}
@@ -75,9 +75,9 @@ func TestEmailValidateLocalized(t *testing.T) {
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"}, "수신자"},
{"포트 범위 벗어남", 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) {
@@ -85,7 +85,7 @@ func TestEmailValidateLocalized(t *testing.T) {
if err == nil {
t.Fatalf("검증이 실패해야 합니다: %v", tc.cfg)
}
assertKorean(t, "Validate("+tc.name+")", err.Error())
assertChinese(t, "Validate("+tc.name+")", err.Error())
if !strings.Contains(err.Error(), tc.substr) {
t.Errorf("오류 메시지에 %q 가 있어야 합니다, 받은 값 %q", tc.substr, err.Error())
}
+13 -13
View File
@@ -73,10 +73,10 @@ func blockInternalDial(_, address string, _ syscall.RawConn) error {
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("대상 주소를 해석할 수 없습니다: %q", host)
return fmt.Errorf("无法解析目标地址:%q", host)
}
if isBlockedDialIP(ip) {
return fmt.Errorf("로컬 또는 링크 로컬 주소 %s 로는 전송하지 않습니다 (로컬 서비스로 보내야 하면 %s=1 로 설정하세요)", ip, AllowLocalTargetsEnv)
return fmt.Errorf("不会向本地或链路本地地址 %s 发送 (如需发送到本地服务,请设置 %s=1)", ip, AllowLocalTargetsEnv)
}
return nil
}
@@ -109,10 +109,10 @@ var httpClient = &http.Client{
Transport: notifyTransport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("리다이렉트가 너무 많습니다")
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 fmt.Errorf("不允许重定向到其他主机 (%s → %s)", via[0].URL.Host, req.URL.Host)
}
return nil
},
@@ -135,7 +135,7 @@ func doJSON(ctx context.Context, method, url string, headers map[string]string,
raw, err := json.Marshal(payload)
if err != nil {
// 序列化失败是本地 bug(配置字段类型不对),重试也不会变好。
return nil, Permanent(fmt.Errorf("요청 본문을 만들지 못했습니다: %w", err))
return nil, Permanent(fmt.Errorf("无法构造请求体:%w", err))
}
body = bytes.NewReader(raw)
}
@@ -143,7 +143,7 @@ func doJSON(ctx context.Context, method, url string, headers map[string]string,
if err != nil {
// URL 非法——多半是用户把地址填错了,属于永久失败。
// 这里同样不能透传 err:url.Parse 的错误文本里含完整地址。
return nil, Permanent(fmt.Errorf("요청 주소가 올바르지 않습니다: %s", redactRequestTarget(url)))
return nil, Permanent(fmt.Errorf("请求地址无效:%s", redactRequestTarget(url)))
}
if payload != nil {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
@@ -161,24 +161,24 @@ func doJSON(ctx context.Context, method, url string, headers map[string]string,
// 不脱敏的话,凭据会顺着这条错误串流到四个地方:notification_deliveries
// 的 last_error(明文落库)、投递历史接口的响应(**绕过渠道配置的掩码**)、
// 服务端日志、以及测试发送接口回给前端的 502 文本。
return nil, fmt.Errorf("요청에 실패했습니다: %s", redactTransportError(err))
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)
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))
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, fmt.Errorf("对端服务器发生错误 (HTTP %d):%s", resp.StatusCode, snippet(raw))
}
return nil, Permanent(fmt.Errorf("상대 서버가 요청을 거부했습니다 (HTTP %d): %s", resp.StatusCode, snippet(raw)))
return nil, Permanent(fmt.Errorf("对端服务器拒绝了请求 (HTTP %d):%s", resp.StatusCode, snippet(raw)))
}
// snippet 把响应体压成一行短文本,用于错误信息。响应里可能带换行与大量空白,
@@ -205,7 +205,7 @@ func snippet(raw []byte) string {
func redactRequestTarget(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return "(주소를 해석할 수 없음)"
return "(无法解析地址)"
}
return u.Scheme + "://" + u.Host + "/…"
}
@@ -225,7 +225,7 @@ func redactTransportError(err error) string {
if uerr.Err != nil {
return fmt.Sprintf("%s %s: %s", uerr.Op, host, uerr.Err)
}
return fmt.Sprintf("%s %s: 알 수 없는 오류", uerr.Op, host)
return fmt.Sprintf("%s %s: 未知错误", uerr.Op, host)
}
// 非 *url.Error(如重定向策略返回的错误)也可能带地址,统一走脱敏。
return redactURLsInText(err.Error())
+11 -11
View File
@@ -72,10 +72,10 @@ func markdownText(s string, maxRunes int) string {
// 出现了 `\(1\)` 这种可见的反斜杠。
func markdownTitle(m Message) string {
if m.Batch {
return fmt.Sprintf("취약점 요약 · 총 %d건", len(m.Items))
return fmt.Sprintf("漏洞汇总 · 共 %d 条", len(m.Items))
}
if len(m.Items) == 0 {
return "취약점 알림"
return "漏洞通知"
}
it := m.Items[0]
return fmt.Sprintf("[%s] %s", SeverityLabel(it.Severity), OneLine(it.Title(), 0))
@@ -103,7 +103,7 @@ func markdownBody(m Message, maxBytes int) (string, int) {
footer := ""
if m.HomeURL != "" {
footer = fmt.Sprintf("\n[플랫폼에서 전체 보기](%s)\n", 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
@@ -130,12 +130,12 @@ func markdownBody(m Message, maxBytes int) (string, int) {
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)
fmt.Fprintf(&b, "**最近 %d 分钟新增漏洞 %d 条**", m.WindowMinutes, total)
} else {
fmt.Fprintf(&b, "**신규 취약점 %d건**", total)
fmt.Fprintf(&b, "**新增漏洞 %d 条**", total)
}
if extra := total - len(items); extra > 0 {
fmt.Fprintf(&b, "(이 메시지에는 앞 %d건만 표시하며, 나머지 %d건은 다음 메시지에서 이어서 보냅니다)", len(items), extra)
fmt.Fprintf(&b, "(本条仅显示前 %d 条,其余 %d 条将在下一条消息中继续发送)", len(items), extra)
}
// 按级别给出分布,让读者一眼看到有没有严重项。只统计**本条实际包含**的
// 条目,保证「严重 3」和下面能数出来的条目一致。
@@ -183,21 +183,21 @@ func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
}
b.WriteString(line + "\n")
if it.IsStatusChange() {
fmt.Fprintf(b, "**상태 변경**: %s → %s\n",
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))
fmt.Fprintf(b, "**类型**:%s\n", markdownText(it.VulnClass, 0))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
fmt.Fprintf(b, "**자산**: %s\n", markdownText(a, 0))
fmt.Fprintf(b, "**资产**:%s\n", markdownText(a, 0))
}
if it.Summary != "" {
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
fmt.Fprintf(b, "**개요**: %s\n", s)
fmt.Fprintf(b, "**概要**:%s\n", s)
}
}
if it.DetailURL != "" {
fmt.Fprintf(b, "[상세 보기](%s)\n", it.DetailURL)
fmt.Fprintf(b, "[查看详情](%s)\n", it.DetailURL)
}
}
+29 -29
View File
@@ -27,17 +27,17 @@ func hanFreeItems(n int) []Item {
// 공유하는 feishu·html·telegram·webhook 이 함께 쓰는 markdownTitle 이 한국어인지
// 검사한다. 하나라도 중국어로 되돌아가면 이 다섯 채널의 제목이 전부 혼재된다.
func TestMarkdownTitleLocalized(t *testing.T) {
// 다건(汇总): "취약점 요약 · 총 N건"
// 다건(汇总): "漏洞汇总 · 共 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)
assertChinese(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)
assertChinese(t, "markdownTitle(empty)", empty)
if empty != "漏洞通知" {
t.Errorf("빈 메시지 제목은 '漏洞通知' 이어야 합니다, 받은 값 %q", empty)
}
}
@@ -49,28 +49,28 @@ func TestMarkdownBatchIntroLocalized(t *testing.T) {
// 시간창 있음: "최근 N분간 신규 취약점 N건"
win := markdownBatchIntro(Message{Batch: true, WindowMinutes: 30}, items, 3)
if hasHan(win) {
t.Errorf("시간창 머리말에 중국어 한자가 남았습니다: %q", win)
if hasHangul(win) {
t.Errorf("시간창 머리말에 한글이 남았습니다: %q", win)
}
if !strings.Contains(win, "최근 30분간") || !strings.Contains(win, "신규 취약점 3건") {
t.Errorf("시간창 머리말이 '최근 30분간 신규 취약점 3건' 형태여야 합니다, 받은 값 %q", win)
if !strings.Contains(win, "最近 30 分钟") || !strings.Contains(win, "新增漏洞 3 条") {
t.Errorf("시간창 머리말이 '最近 30 分钟新增漏洞 3 条' 형태여야 합니다, 받은 값 %q", win)
}
// 시간창 없음: "신규 취약점 N건"(분간 표기 없음)
// 시간창 없음: "新增漏洞 N条"(분간 표기 없음)
noWin := markdownBatchIntro(Message{Batch: true}, items, 3)
if hasHan(noWin) {
t.Errorf("머리말에 중국어 한자가 남았습니다: %q", noWin)
if hasHangul(noWin) {
t.Errorf("머리말에 한글이 남았습니다: %q", noWin)
}
if !strings.Contains(noWin, "신규 취약점 3건") || strings.Contains(noWin, "분간") {
t.Errorf("시간창 없는 머리말은 '신규 취약점 3건'(분간 표기 없음)이어야 합니다, 받은 값 %q", noWin)
if !strings.Contains(noWin, "新增漏洞 3 条") || strings.Contains(noWin, "分钟") {
t.Errorf("시간창 없는 머리말은 '新增漏洞 3 条'(分钟 표기 없음)이어야 합니다, 받은 값 %q", noWin)
}
// 일부만 담겼을 때: "(이 메시지에는 앞 N건만 … 나머지 N건은 다음 메시지에서 …)"
// 일부만 담겼을 때: "(本条仅显示前 N 条, 其余 N 条将在下一条消息中继续发送)"
trunc := markdownBatchIntro(Message{Batch: true, WindowMinutes: 30}, items, 5)
if hasHan(trunc) {
t.Errorf("초과 안내에 중국어 한자가 남았습니다: %q", trunc)
if hasHangul(trunc) {
t.Errorf("초과 안내에 한글이 남았습니다: %q", trunc)
}
for _, want := range []string{"앞 3건만", "나머지 2건", "다음 메시지에서"} {
for _, want := range []string{"前 3 条", "其余 2 条", "下一条消息"} {
if !strings.Contains(trunc, want) {
t.Errorf("초과 안내에 %q 가 있어야 합니다, 받은 값 %q", want, trunc)
}
@@ -94,13 +94,13 @@ func TestWriteItemLabelsLocalized(t *testing.T) {
writeItem(&b, it, "", true)
got := b.String()
assertKorean(t, "writeItem(single)", got)
assertChinese(t, "writeItem(single)", got)
for _, want := range []string{
"**상태 변경**: 처리 대기 → 수정됨",
"**유형**: SQLi",
"**자산**: a.example.com",
"**개요**: SQL injection via q param",
"[상세 보기](https://platform.example/finding/1)",
"**状态变更**:待处理 → 已修复",
"**类型**: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)
@@ -116,7 +116,7 @@ func TestMarkdownBodyFooterLocalized(t *testing.T) {
if kept != 2 {
t.Fatalf("한도 없음(0)이면 2건 모두 담겨야 합니다, 받은 값 %d", kept)
}
if !strings.Contains(body, "[플랫폼에서 전체 보기](https://platform.example)") {
t.Errorf("본문 끝에 '플랫폼에서 전체 보기' 링크가 있어야 합니다:\n%s", body)
if !strings.Contains(body, "[在平台查看全部](https://platform.example)") {
t.Errorf("본문 끝에 '在平台查看全部' 링크가 있어야 합니다:\n%s", body)
}
}
+5 -5
View File
@@ -70,9 +70,9 @@ type ErrDestinationChangedWithoutCredentials struct {
}
func (e *ErrDestinationChangedWithoutCredentials) Error() string {
return "대상 주소(" + strings.Join(e.Changed, ", ") + ")가 변경되었습니다. 자격 증명 필드(" +
strings.Join(e.Missing, ", ") + ")를 함께 다시 입력하세요: 새 값을 넣거나, 비워 두어 더는 자격 증명이 필요 없음을 표시하세요. " +
"기존 자격 증명은 이전 주소에만 유효하므로, 그대로 사용하면 새 주소에 넘겨주는 셈입니다."
return "目标地址(" + strings.Join(e.Changed, ", ") + ")已更改。请同时重新填写凭据字段(" +
strings.Join(e.Missing, ", ") + "):填入新值,或留空以表示不再需要凭据。" +
"现有凭据仅对旧地址有效,继续沿用等于将其交给新地址。"
}
// PrepareConfigUpdate 合并渠道配置,并处理「目标地址变更」这一安全敏感情况。
@@ -101,7 +101,7 @@ func (e *ErrDestinationChangedWithoutCredentials) Error() string {
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)
return nil, fmt.Errorf("未注册的渠道类型:%q", kind)
}
secrets := channel.SecretKeys()
destinations := channel.DestinationKeys()
@@ -174,7 +174,7 @@ func rejectMaskedInContainers(incoming map[string]any, secretKeys []string) erro
continue
}
if strings.Contains(string(encoded), MaskedPrefix) {
return fmt.Errorf("%s 필드의 내용에 마스킹 표시(%q)가 들어 있습니다. 이 필드는 전체를 비워 두어 기존 값을 유지하거나 전체를 새 값으로 제출해야 하며, 구조체 내부에 마스킹 표시를 끼워 넣을 수 없습니다",
return fmt.Errorf("%s 字段的内容中带有掩码标记(%q)。该字段必须整体留空以保留原值,或整体提交新值,不能在结构体内部插入掩码标记",
key, MaskedPrefix)
}
}
+5 -5
View File
@@ -9,7 +9,7 @@ import (
// F4 ⑥: notify/mask.go·filter.go 의 사용자 노출 문구(알림 채널 설정을 저장·갱신할 때
// server/notify_api.go 가 writeErr(400) 로 그대로 돌려주는 검증 오류)가 한국어이고
// 한자가 없음을 핀 고정한다. channel.go 는 사용자 노출 문구가 없어(전부 주석) 대상이
// 아니다. hasHan / hasHangul / assertKorean 은 notify_localized_test.go 에 정의돼 있다.
// 아니다. hasHan / hasHangul / assertChinese 은 notify_localized_test.go 에 정의돼 있다.
// TestFilterValidateLocalized 는 min_severity 를 잘못 넣었을 때의 검증 오류를 검사한다.
// 이 오류는 저장·갱신 경로(server/notify_api.go:264·369)에서 400 으로 노출된다.
@@ -18,7 +18,7 @@ func TestFilterValidateLocalized(t *testing.T) {
if err == nil {
t.Fatal("잘못된 최소 심각도인데 오류가 없습니다")
}
assertKorean(t, "Filter.Validate", err.Error())
assertChinese(t, "Filter.Validate", err.Error())
// low/medium/high/critical 는 설정 enum 이라 원문 그대로 남아야 한다.
for _, tok := range []string{"low", "medium", "high", "critical"} {
if !strings.Contains(err.Error(), tok) {
@@ -41,7 +41,7 @@ func TestPrepareConfigUpdateUnknownKindLocalized(t *testing.T) {
if err == nil {
t.Fatal("미등록 채널 유형인데 오류가 없습니다")
}
assertKorean(t, "PrepareConfigUpdate unknown kind", err.Error())
assertChinese(t, "PrepareConfigUpdate unknown kind", err.Error())
}
// TestDestinationChangedErrorLocalized 는 실제 경로로 ErrDestinationChangedWithoutCredentials
@@ -62,7 +62,7 @@ func TestDestinationChangedErrorLocalized(t *testing.T) {
if !errors.As(err, &de) {
t.Fatalf("예상한 오류 유형이 아닙니다: %T", err)
}
assertKorean(t, "ErrDestinationChangedWithoutCredentials", err.Error())
assertChinese(t, "ErrDestinationChangedWithoutCredentials", err.Error())
// 바뀐 대상 키·누락된 자격 증명 키 이름은 설정 필드명이라 메시지에 그대로 들어가야 한다.
if !strings.Contains(err.Error(), "url") || !strings.Contains(err.Error(), "headers") {
t.Errorf("필드 키 이름이 누락됐습니다: %q", err.Error())
@@ -79,7 +79,7 @@ func TestRejectMaskedInContainersLocalized(t *testing.T) {
if err == nil {
t.Fatal("구조체 내부에 마스킹 센티넬을 끼워 넣었는데 오류가 없습니다")
}
assertKorean(t, "rejectMaskedInContainers", err.Error())
assertChinese(t, "rejectMaskedInContainers", err.Error())
// 센티넬 리터럴(__masked__)은 운영자가 어느 값이 문제인지 알 수 있게 메시지에 표시된다.
if !strings.Contains(err.Error(), MaskedPrefix) {
t.Errorf("마스킹 센티넬이 메시지에 없습니다: %q", err.Error())
+13 -13
View File
@@ -47,13 +47,13 @@ func SeverityRank(severity string) int { return severityRank[severity] }
func SeverityLabel(severity string) string {
switch severity {
case "critical":
return "🔴 심각"
return "🔴 严重"
case "high":
return "🟠 높음"
return "🟠 高危"
case "medium":
return "🟡 중간"
return "🟡 中危"
case "low":
return "🔵 낮음"
return "🔵 低危"
default:
return severity
}
@@ -63,23 +63,23 @@ func SeverityLabel(severity string) string {
func StatusLabel(status string) string {
switch status {
case "pending":
return "처리 대기"
return "待处理"
case "in_progress":
return "처리 중"
return "处理中"
case "confirmed":
return "확인됨"
return "已确认"
case "resolved":
return "처리됨"
return "已处置"
case "fixed":
return "수정됨"
return "已修复"
case "false_positive":
return "오탐"
return "误报"
case "ignored":
return "무시"
return "已忽略"
case "duplicate":
return "중복"
return "重复"
case "risk_accepted":
return "위험 수용"
return "风险接受"
default:
return status
}
+26 -26
View File
@@ -26,14 +26,14 @@ func hasHangul(s string) bool {
return false
}
// assertKorean 은 라벨이 한글을 포함하고 중국어 한자는 없음을 단언한다.
func assertKorean(t *testing.T, where, got string) {
// assertChinese 은 라벨이 한자를 포함하고 한글은 없음을 단언한다.
func assertChinese(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)
}
if !hasHangul(got) {
t.Errorf("%s: 한글이 없습니다: %q", where, got)
if !hasHan(got) {
t.Errorf("%s: 한자가 없습니다: %q", where, got)
}
}
@@ -43,14 +43,14 @@ func assertKorean(t *testing.T, where, got string) {
func TestSeverityLabelLocalized(t *testing.T) {
// 중국어 라벨(严重/高危/中危/低危) → 한국어(심각/높음/중간/낮음), UI status.severity 정합.
want := map[string]string{
"critical": "심각",
"high": "높음",
"medium": "중간",
"low": "낮음",
"critical": "严重",
"high": "高危",
"medium": "中危",
"low": "低危",
}
for sev, label := range want {
got := SeverityLabel(sev)
assertKorean(t, "SeverityLabel("+sev+")", got)
assertChinese(t, "SeverityLabel("+sev+")", got)
if !strings.Contains(got, label) {
t.Errorf("SeverityLabel(%q)=%q, %q 를 포함해야 합니다", sev, got, label)
}
@@ -65,19 +65,19 @@ func TestSeverityLabelLocalized(t *testing.T) {
// 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": "위험 수용",
"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)
assertChinese(t, "StatusLabel("+status+")", got)
if got != label {
t.Errorf("StatusLabel(%q)=%q, %q 를 기대했습니다", status, got, label)
}
@@ -92,7 +92,7 @@ func TestStatusLabelLocalized(t *testing.T) {
// 한국어 자리표시자(이름 없는 취약점)인지 검사한다. 절대 빈 제목을 내보내지 않는다.
func TestItemTitlePlaceholderLocalized(t *testing.T) {
got := Item{}.Title()
assertKorean(t, "Item{}.Title()", got)
assertChinese(t, "Item{}.Title()", got)
// 이름이 있으면 그 이름을, 유형만 있으면 유형을 우선한다(대체 로직 미회귀 확인).
if n := (Item{Name: "로그인 SQLi"}).Title(); n != "로그인 SQLi" {
t.Errorf("이름 우선 로직이 깨졌습니다, 받은 값 %q", n)
@@ -111,10 +111,10 @@ func TestAssetLineLocalized(t *testing.T) {
}
// 상한 초과: 앞 limit 개 + 한국어 "등 N개"(N 은 전체 개수).
got := assetLine([]string{"a", "b", "c", "d", "e"}, 2)
if hasHan(got) {
t.Errorf("자산 나열에 중국어 한자가 남았습니다: %q", got)
if hasHangul(got) {
t.Errorf("자산 나열에 한글이 남았습니다: %q", got)
}
if !strings.Contains(got, "등 5개") {
t.Errorf("전체 개수 5 를 '등 5개'로 표기해야 합니다, 받은 값 %q", got)
if !strings.Contains(got, "等 5 个") {
t.Errorf("전체 개수 5 를 '等 5 个'로 표기해야 합니다, 받은 값 %q", got)
}
}
+2 -2
View File
@@ -28,7 +28,7 @@ func TestMarkdownBodyPacksWholeItemsWithinByteLimit(t *testing.T) {
}
// 头部必须如实说明本条只包含多少条、其余有多少条——否则读者会把头部
// 那个数字当成全部。
if !strings.Contains(body, "나머지") || !strings.Contains(body, "다음 메시지에서") {
if !strings.Contains(body, "其余") || !strings.Contains(body, "下一条消息") {
t.Fatalf("头部应说明还有多少条未包含在本条里:\n%s", body[:minInt(400, len(body))])
}
// 只应包含前 kept 条。
@@ -84,7 +84,7 @@ func TestTelegramPackingUsesRuneBudget(t *testing.T) {
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("应只装下一部分,得到 %d", kept)
}
if !strings.Contains(text, "다음 메시지") {
if !strings.Contains(text, "下一条消息") {
t.Fatalf("应说明还有余量未包含:\n%.300s", text)
}
}
+1 -1
View File
@@ -133,7 +133,7 @@ func assetLine(assets []string, limit int) string {
if limit <= 0 || len(assets) <= limit {
return strings.Join(assets, ", ")
}
return strings.Join(assets[:limit], ", ") + " 등 " + itoa(len(assets)) + "개"
return strings.Join(assets[:limit], ", ") + " 等 " + itoa(len(assets)) + " 个"
}
// itoa 是 strconv.Itoa 的短别名,仅用于拼接展示文本,避免到处 import strconv。
+2 -2
View File
@@ -109,7 +109,7 @@ func TestAssetLineOmitsExcess(t *testing.T) {
}
// 超出上限时必须标注总数,否则读者不知道还有多少资产没列出来。
got := assetLine([]string{"a", "b", "c", "d", "e"}, 2)
if !strings.Contains(got, "등 5개") {
if !strings.Contains(got, "等 5 个") {
t.Fatalf("应标注总数 5,得到 %q", got)
}
}
@@ -121,7 +121,7 @@ func TestSeverityAndStatusLabels(t *testing.T) {
if !AtLeast("critical", "") {
t.Fatal("空门槛应放行")
}
if got := StatusLabel("fixed"); got != "수정됨" {
if got := StatusLabel("fixed"); got != "已修复" {
t.Fatalf("未知状态映射,得到 %q", got)
}
// 未知状态原样回显,不臆造标签。
+16 -16
View File
@@ -34,14 +34,14 @@ 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이 없습니다")
return errors.New("缺少 Bot Token")
}
if cfgString(cfg, "chat_id") == "" {
return errors.New("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 fmt.Errorf("API 地址无效:%w", err)
}
}
return nil
@@ -72,7 +72,7 @@ func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message
Description string `json:"description"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return 0, fmt.Errorf("Telegram 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
return 0, fmt.Errorf("无法解析 Telegram 响应:%w (%s)", err, snippet(raw))
}
if res.OK {
return kept, nil
@@ -80,9 +80,9 @@ func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message
// 429 是限流,退避后重试有效;其余(400 参数错、401 token 错、403 被拉黑、
// 404 chat 不存在)都是配置问题,重试不会自愈。
if res.ErrorCode == 429 {
return 0, fmt.Errorf("Telegram 요청 제한에 걸렸습니다: %s", res.Description)
return 0, fmt.Errorf("Telegram 请求受限:%s", res.Description)
}
return 0, Permanent(fmt.Errorf("Telegram 응답 오류 %d: %s", res.ErrorCode, res.Description))
return 0, Permanent(fmt.Errorf("Telegram 响应错误 %d:%s", res.ErrorCode, res.Description))
}
// telegramEndpoint 拼出 sendMessage 地址。base_url 留空时用官方 API,
@@ -98,7 +98,7 @@ func telegramEndpoint(cfg map[string]any) (string, error) {
u, err := url.Parse(raw)
if err != nil {
// 不透传 err:地址里含 Bot Token,且此时连 addr 都不该回显。
return "", fmt.Errorf("API 주소를 조합하지 못했습니다 (API 주소: %s)", redactRequestTarget(base))
return "", fmt.Errorf("无法拼接 API 地址 (API 地址: %s)", redactRequestTarget(base))
}
return u.String(), nil
}
@@ -111,7 +111,7 @@ func telegramHTML(m Message) (string, int) {
// Telegram 的上限是**字符数**,所以打包也按字符计量(runeSize)。
footer := ""
if m.HomeURL != "" {
footer = fmt.Sprintf("\n\n<a href=\"%s\">플랫폼에서 전체 보기</a>", telegramEscapeAttr(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)
@@ -130,20 +130,20 @@ func telegramHTML(m Message) (string, int) {
}
it := m.Items[0]
if it.IsStatusChange() {
b.WriteString(fmt.Sprintf("\n<b>상태 변경</b>: %s → %s",
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))
b.WriteString("\n<b>类型</b>:" + telegramEscape(it.VulnClass))
}
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
b.WriteString("\n<b>자산</b>: " + telegramEscape(a))
b.WriteString("\n<b>资产</b>:" + telegramEscape(a))
}
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
b.WriteString("\n<b>개요</b>: " + telegramEscape(s))
b.WriteString("\n<b>概要</b>:" + telegramEscape(s))
}
if it.DetailURL != "" {
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">상세 보기</a>", telegramEscapeAttr(it.DetailURL)))
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">查看详情</a>", telegramEscapeAttr(it.DetailURL)))
}
return TruncateHTML(b.String(), telegramTextLimit), 1
}
@@ -162,12 +162,12 @@ func telegramBatchLine(it Item, idx int) string {
// telegramBatchTitle 渲染汇总消息的标题行。条数用的是**本条实际包含**的条数,
// 而不是本批总数——否则读者会以为消息头写的数字就是全部。
func telegramBatchTitle(m Message, items []Item, total int) string {
title := fmt.Sprintf("취약점 요약 · 총 %d건", total)
title := fmt.Sprintf("漏洞汇总 · 共 %d 条", total)
if extra := total - len(items); extra > 0 {
title += fmt.Sprintf(" (앞 %d건만 표시, 나머지 %d건은 다음 메시지에서 이어집니다)", len(items), extra)
title += fmt.Sprintf(" (仅显示前 %d 条,其余 %d 条将在下一条消息中继续)", len(items), extra)
}
if m.WindowMinutes > 0 {
title = fmt.Sprintf("최근 %d분간 · %s", m.WindowMinutes, title)
title = fmt.Sprintf("最近 %d 分钟 · %s", m.WindowMinutes, title)
}
return title
}
+7 -7
View File
@@ -83,17 +83,17 @@ type webhookItem struct {
func (webhookChannel) Validate(cfg map[string]any) error {
raw := cfgString(cfg, "url")
if raw == "" {
return errors.New("대상 URL이 없습니다")
return errors.New("缺少目标 URL")
}
if err := validateHTTPURL(raw); err != nil {
return fmt.Errorf("대상 URL이 올바르지 않습니다: %w", err)
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)
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 fmt.Errorf("请求体模板语法无效:%w", err)
}
}
return nil
@@ -119,7 +119,7 @@ func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message)
// 模板渲染出的是字符串形式的 JSON,这里转成 json.RawMessage 原样发出,
// 避免二次转义把用户精心构造的结构套进一个 JSON 字符串里。
if !json.Valid([]byte(body)) {
return 0, Permanent(errors.New("요청 본문 템플릿의 렌더링 결과가 올바른 JSON이 아닙니다"))
return 0, Permanent(errors.New("请求体模板的渲染结果不是合法的 JSON"))
}
payload = json.RawMessage(body)
}
@@ -147,11 +147,11 @@ func renderWebhookBody(tpl string, m Message) (string, error) {
}
t, err := parseWebhookTemplate(tpl)
if err != nil {
return "", fmt.Errorf("요청 본문 템플릿 문법이 올바르지 않습니다: %w", err)
return "", fmt.Errorf("请求体模板语法无效:%w", err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, newWebhookTemplateData(m)); err != nil {
return "", fmt.Errorf("요청 본문 템플릿을 렌더링하지 못했습니다: %w", err)
return "", fmt.Errorf("无法渲染请求体模板:%w", err)
}
return buf.String(), nil
}
+32 -32
View File
@@ -12,7 +12,7 @@ import (
// 이 파일은 F4 ④⑤(telegram·webhook·공용 HTTP 전송 계층) 한국어화를 회귀로부터
// 지킨다. telegram.go 의 메시지 라벨·배치 제목, webhook.go 의 설정 검증 오류,
// http.go 의 전송 오류, 그리고 다섯 채널이 공유하는 validateHTTPURL 이 중국어로
// 되돌아가면 잡아낸다. 헬퍼 hasHan·hasHangul·assertKorean 은
// 되돌아가면 잡아낸다. 헬퍼 hasHan·hasHangul·assertChinese 은
// notify_localized_test.go 에 있다(같은 패키지).
//
// 데이터(제목·유형·자산·개요)는 전부 ASCII 로 둔다. 그래야 "출력 전체에 한자 0"
@@ -39,10 +39,10 @@ func TestTelegramItemLabelsLocalized(t *testing.T) {
if kept != 1 {
t.Fatalf("단건 메시지는 1 을 보고해야 합니다, 받은 값 %d", kept)
}
if hasHan(out) {
t.Errorf("Telegram 본문에 중국어 한자가 남아 있습니다:\n%s", out)
if hasHangul(out) {
t.Errorf("Telegram 본문에 한글이 남아 있습니다:\n%s", out)
}
for _, want := range []string{"상태 변경", "유형", "자산", "개요", "상세 보기"} {
for _, want := range []string{"状态变更", "类型", "资产", "概要", "查看详情"} {
if !strings.Contains(out, want) {
t.Errorf("Telegram 본문에 %q 라벨이 없습니다:\n%s", want, out)
}
@@ -57,8 +57,8 @@ func TestTelegramBatchTitleLocalized(t *testing.T) {
// 넘침 있음 + 시간창: 요약·총건수·앞 N건·나머지·다음 메시지·최근 N분간.
over := telegramBatchTitle(Message{WindowMinutes: 30}, items, 5)
assertKorean(t, "telegramBatchTitle(넘침)", over)
for _, want := range []string{"취약점 요약 · 총 5건", "앞 2건만 표시", "나머지 3건", "다음 메시지", "최근 30분간"} {
assertChinese(t, "telegramBatchTitle(넘침)", over)
for _, want := range []string{"漏洞汇总 · 共 5 条", "前 2 条", "其余 3 条", "下一条消息", "最近 30 分钟"} {
if !strings.Contains(over, want) {
t.Errorf("넘침 제목에 %q 가 없습니다: %q", want, over)
}
@@ -66,12 +66,12 @@ func TestTelegramBatchTitleLocalized(t *testing.T) {
// 넘침 없음 + 시간창 없음: 다음 메시지 안내가 없어야 한다.
full := telegramBatchTitle(Message{}, items, 2)
assertKorean(t, "telegramBatchTitle(전량)", full)
if !strings.Contains(full, "취약점 요약 · 총 2건") {
t.Errorf("전량 제목에 '취약점 요약 · 총 2건' 이 있어야 합니다: %q", full)
assertChinese(t, "telegramBatchTitle(전량)", full)
if !strings.Contains(full, "漏洞汇总 · 共 2 条") {
t.Errorf("전량 제목에 '漏洞汇总 · 共 2 条' 이 있어야 합니다: %q", full)
}
if strings.Contains(full, "다음 메시지") {
t.Errorf("넘치지 않았는데 '다음 메시지' 안내가 들어갔습니다: %q", full)
if strings.Contains(full, "下一条消息") {
t.Errorf("넘치지 않았는데 '下一条消息' 안내가 들어갔습니다: %q", full)
}
}
@@ -81,7 +81,7 @@ 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())
assertChinese(t, "telegram Validate(bot_token)", err.Error())
if !strings.Contains(err.Error(), "Bot Token") {
t.Errorf("Bot Token 누락을 알려줘야 합니다: %q", err.Error())
}
@@ -89,7 +89,7 @@ func TestTelegramValidateLocalized(t *testing.T) {
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())
assertChinese(t, "telegram Validate(chat_id)", err.Error())
if !strings.Contains(err.Error(), "Chat ID") {
t.Errorf("Chat ID 누락을 알려줘야 합니다: %q", err.Error())
}
@@ -102,16 +102,16 @@ 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())
assertChinese(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())
assertChinese(t, "webhook Validate(method)", err.Error())
if !strings.Contains(err.Error(), "GET/POST/PUT/PATCH") {
t.Errorf("허용 메서드 목록을 알려줘야 합니다: %q", err.Error())
}
@@ -120,8 +120,8 @@ func TestWebhookValidateLocalized(t *testing.T) {
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(), "템플릿") {
assertChinese(t, "webhook Validate(template)", err.Error())
if !strings.Contains(err.Error(), "模板") {
t.Errorf("템플릿 오류임을 알려줘야 합니다: %q", err.Error())
}
}
@@ -135,7 +135,7 @@ func TestValidateHTTPURLLocalized(t *testing.T) {
if err := validateHTTPURL("ftp://example.com/x"); err == nil {
t.Fatal("ftp 스킴은 거부되어야 합니다")
} else {
assertKorean(t, "validateHTTPURL(scheme)", err.Error())
assertChinese(t, "validateHTTPURL(scheme)", err.Error())
if !strings.Contains(err.Error(), "http") {
t.Errorf("지원 스킴(http/https)을 알려줘야 합니다: %q", err.Error())
}
@@ -144,7 +144,7 @@ func TestValidateHTTPURLLocalized(t *testing.T) {
if err := validateHTTPURL("http://"); err == nil {
t.Fatal("호스트 없는 주소는 거부되어야 합니다")
} else {
assertKorean(t, "validateHTTPURL(host)", err.Error())
assertChinese(t, "validateHTTPURL(host)", err.Error())
}
}
@@ -156,7 +156,7 @@ func TestHTTPLocalTargetBlockedLocalized(t *testing.T) {
if err == nil {
t.Fatal("로컬 주소는 기본적으로 차단되어야 합니다")
}
assertKorean(t, "blockInternalDial", err.Error())
assertChinese(t, "blockInternalDial", err.Error())
if !strings.Contains(err.Error(), AllowLocalTargetsEnv) {
t.Errorf("차단 오류는 우회 방법(%s)을 알려줘야 합니다: %q", AllowLocalTargetsEnv, err.Error())
}
@@ -165,16 +165,16 @@ func TestHTTPLocalTargetBlockedLocalized(t *testing.T) {
// TestHTTPRedactHelpersLocalized 는 주소 탈감(redact) 자리표시자와 전송 오류의
// "알 수 없는 오류" 분기가 한국어인지 확인한다.
func TestHTTPRedactHelpersLocalized(t *testing.T) {
if got := redactRequestTarget(""); !strings.Contains(got, "해석할 수 없") {
t.Errorf("해석 불가 주소는 한국어 자리표시자여야 합니다, 받은 값 %q", got)
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 hasHangul(got) {
t.Errorf("전송 오류 탈감 결과에 한글이 남았습니다: %q", got)
}
if !strings.Contains(got, "알 수 없는 오류") {
t.Errorf("Err 가 nil 이면 '알 수 없는 오류' 여야 합니다, 받은 값 %q", got)
if !strings.Contains(got, "未知错误") {
t.Errorf("Err 가 nil 이면 '未知错误' 여야 합니다, 받은 값 %q", got)
}
}
@@ -187,9 +187,9 @@ func TestHTTPStatusErrorsLocalized(t *testing.T) {
code int
want string
}{
{http.StatusForbidden, "요청을 거부했습니다"},
{http.StatusInternalServerError, "오류가 발생했습니다"},
{http.StatusTooManyRequests, "요청을 제한하거나"},
{http.StatusForbidden, "拒绝"},
{http.StatusInternalServerError, "错误"},
{http.StatusTooManyRequests, "限制"},
}
for _, tc := range cases {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -201,7 +201,7 @@ func TestHTTPStatusErrorsLocalized(t *testing.T) {
if err == nil {
t.Fatalf("HTTP %d 는 오류여야 합니다", tc.code)
}
assertKorean(t, "doJSON(HTTP status)", err.Error())
assertChinese(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())
}
+1 -1
View File
@@ -100,7 +100,7 @@ func TestTemplateCanOnlyProduceJSON(t *testing.T) {
if err == nil || !IsPermanent(err) {
t.Fatalf("渲染出非 JSON 应判永久失败,得到 %v", err)
}
if !strings.Contains(err.Error(), "올바른 JSON") {
if !strings.Contains(err.Error(), "合法的 JSON") {
t.Errorf("错误信息应说明是 JSON 问题,得到 %v", err)
}
}
+5 -5
View File
@@ -34,10 +34,10 @@ 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 주소가 없습니다")
return errors.New("缺少 Webhook 地址")
}
if err := validateHTTPURL(hook); err != nil {
return fmt.Errorf("Webhook 주소가 올바르지 않습니다: %w", err)
return fmt.Errorf("Webhook 地址无效:%w", err)
}
return nil
}
@@ -62,17 +62,17 @@ func (c weComChannel) Send(ctx context.Context, cfg map[string]any, m Message) (
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return 0, fmt.Errorf("WeCom 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
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)
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 0, Permanent(fmt.Errorf("WeCom 发生错误 (%d):%s", res.ErrCode, res.ErrMsg))
}
return kept, nil
}