Files
ARTEX/notify/pack_test.go
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

225 lines
7.6 KiB
Go

package notify
import (
"strings"
"testing"
"unicode/utf8"
)
// This file covers the "pack whole items" fix: when a digest message exceeds the
// channel's length limit, it must be truncated **item by item** and honestly
// report how many items did not fit, so the caller marks only the ones actually
// delivered.
//
// The previous approach rendered the whole thing, truncated it, and then marked
// the whole batch as delivered: the second half of the message vanished into
// thin air while the delivery history showed all succeeded — vulnerabilities
// were simply lost, and nowhere could you find out.
func TestMarkdownBodyPacksWholeItemsWithinByteLimit(t *testing.T) {
// 200 digest items are guaranteed to far exceed WeCom's 4096 bytes.
m := batchMsg(200)
body, kept := markdownBody(m, weComMarkdownLimit)
if len(body) > weComMarkdownLimit {
t.Fatalf("body of %d bytes exceeds the limit of %d", len(body), weComMarkdownLimit)
}
if !utf8.ValidString(body) {
t.Fatal("body is not valid UTF-8")
}
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("only a part should fit (0 < kept < %d), got %d", len(m.Items), kept)
}
// The header must honestly state how many items this message contains and how
// many remain — otherwise readers take the number in the header for the whole
// batch.
if !strings.Contains(body, "remaining") || !strings.Contains(body, "next message") {
t.Fatalf("the header should state how many items are not included in this message:\n%s", body[:minInt(400, len(body))])
}
// Only the first kept items should be included.
for i := 0; i < kept; i++ {
if !strings.Contains(body, "vuln"+itoa(i+1)) {
t.Fatalf("item %d should be in this message:\n%s", i+1, body)
}
}
if strings.Contains(body, "vuln"+itoa(kept+1)) {
t.Fatalf("item %d should not appear (it belongs to the next batch)", kept+1)
}
}
func TestMarkdownBodyKeepsEverythingWhenUnderLimit(t *testing.T) {
m := batchMsg(3)
body, kept := markdownBody(m, 0) // 0 = unlimited
if kept != len(m.Items) {
t.Fatalf("everything should be kept when length is unlimited, got kept=%d", kept)
}
if strings.Contains(body, "remaining") {
t.Fatalf("no truncation notice should appear when nothing was truncated:\n%s", body)
}
}
func TestMarkdownBodyAlwaysKeepsAtLeastOneItem(t *testing.T) {
// When the budget is too small to fit even one item, still send one (with
// final truncation as the backstop). Otherwise a single oversized
// vulnerability would permanently jam the whole batch in place: never fitting
// on every claim, never sent.
m := batchMsg(5)
_, kept := markdownBody(m, 50)
if kept != 1 {
t.Fatalf("at least 1 item should be kept, got %d", kept)
}
}
func TestMarkdownBodySingleReturnsOne(t *testing.T) {
_, kept := markdownBody(singleMsg(), 4096)
if kept != 1 {
t.Fatalf("a single message should report 1 delivered, got %d", kept)
}
// An empty message has no items to deliver.
if _, k := markdownBody(Message{}, 4096); k != 0 {
t.Fatalf("an empty message should report 0 items, got %d", k)
}
}
func TestTelegramPackingUsesRuneBudget(t *testing.T) {
m := batchMsg(200)
text, kept := telegramHTML(m)
// Telegram limits by **character count**; measuring in bytes would cram a
// CJK message down to a third.
if n := utf8.RuneCountInString(text); n > telegramTextLimit {
t.Fatalf("body of %d characters exceeds the limit of %d", n, telegramTextLimit)
}
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("only a part should fit, got %d", kept)
}
if !strings.Contains(text, "next message") {
t.Fatalf("should state that more items are not included:\n%.300s", text)
}
}
func TestFeishuPackingReportsKept(t *testing.T) {
m := batchMsg(2000)
_, kept := feishuCard(m)
if kept <= 0 || kept >= len(m.Items) {
t.Fatalf("the card should only fit a part, got %d", kept)
}
}
func TestWebhookAndEmailReportAllItems(t *testing.T) {
// These two channels do not truncate the body; the whole batch counts as
// delivered.
m := batchMsg(7)
if n := len(m.Items); n != 7 {
t.Fatal("precondition does not hold")
}
// Confirm indirectly via the renderer's return value: markdownBody(0) keeps
// everything when unlimited.
if _, k := markdownBody(m, 0); k != len(m.Items) {
t.Fatalf("everything should be used when length is unlimited, got %d", k)
}
}
// TestMarkdownEscapesUntrustedContent is the regression test for "untrusted
// content must not change message structure". Titles and summaries come from
// model output (the model reads the tested target's responses); asset names come
// from the tested target's URLs.
func TestMarkdownEscapesUntrustedContent(t *testing.T) {
cases := []struct {
name string
item Item
must []string // must appear in the result (escaped form)
wrong []string // must not appear in the result (unescaped form)
}{
{
name: "newline + external link in the title",
item: Item{
Severity: "high",
Name: "SQL injection on login page\n[URGENT: click to verify your account](http://attacker.tld)",
},
// The newline must be collapsed (otherwise it can forge a new list
// item / quote block); the square brackets and parentheses must be
// escaped (otherwise it is a clickable external link).
must: []string{`\[URGENT: click to verify your account\]`, `\(http://attacker.tld\)`},
wrong: []string{"\n[URGENT", "\n\n[URGENT"},
},
{
name: "image beacon in the title",
item: Item{
Severity: "high",
Name: "Vuln ![](http://attacker.tld/beacon)",
},
must: []string{`\!`, `\(http://attacker.tld/beacon\)`},
wrong: []string{"![]("},
},
{
name: "emphasis and quote in an asset name",
item: Item{
Severity: "high",
Name: "plain title",
Assets: []string{"a.com/*inject*>quote"},
},
must: []string{`\*inject\*`, `\>`},
wrong: []string{"*inject*"},
},
{
name: "backticks and pipe in the summary",
item: Item{
Severity: "high",
Name: "title",
Summary: "`code` | table",
},
must: []string{"\\`code\\`", `\|`},
wrong: []string{"`code`"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := Message{Items: []Item{tc.item}}
// writeItem in single mode is the rendering path shared by all three
// markdown channels.
var b strings.Builder
writeItem(&b, tc.item, "", true)
got := b.String()
for _, want := range tc.must {
if !strings.Contains(got, want) {
t.Errorf("missing escaped form %q:\n%s", want, got)
}
}
for _, bad := range tc.wrong {
if strings.Contains(got, bad) {
t.Errorf("unescaped form %q appeared (can be used to inject structure or external links):\n%s", bad, got)
}
}
_ = m
})
}
}
// TestMarkdownEscapeBackslashFirst locks the escape order: the backslash must be
// handled first, otherwise the backslashes added later get another layer and the
// output ends up with double backslashes.
func TestMarkdownEscapeBackslashFirst(t *testing.T) {
if got := markdownEscape(`a\b*c`); got != `a\\b\*c` {
t.Fatalf("wrong escape order, got %q", got)
}
}
// TestTelegramTitleHasNoMarkdownEscapes locks a specific regression: markdown
// escapes must not leak into Telegram's HTML output (an escape was once added to
// the shared title function, and Telegram messages ended up showing visible
// backslashes like `\(1\)`).
func TestTelegramTitleHasNoMarkdownEscapes(t *testing.T) {
m := Message{Items: []Item{{Severity: "high", Name: "alert(1) *important*"}}}
text, _ := telegramHTML(m)
if strings.Contains(text, `\(`) || strings.Contains(text, `\*`) {
t.Fatalf("markdown backslash escapes appeared in the Telegram body:\n%s", text)
}
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}