mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
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
351 lines
11 KiB
Go
351 lines
11 KiB
Go
package notify
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// This file adds the protocol-level tests for the email channel. Before it,
|
|
// email.Send had zero coverage — no test case ever ran the whole SMTP path,
|
|
// even though it is the channel with the largest protocol surface and the most
|
|
// room for error (handshake, auth, envelope, and DATA each fail differently).
|
|
//
|
|
// It is driven by a minimal hand-rolled SMTP server rather than mocking
|
|
// net/smtp: almost all of the email channel's risk lives in "talking to a real
|
|
// SMTP server", and mocking that step away is equivalent to not testing it.
|
|
|
|
// fakeSMTP is a just-good-enough SMTP server: it completes
|
|
// greet/EHLO/AUTH/MAIL/RCPT/DATA/QUIT, and returns the reply codes each test
|
|
// case asks for at specific stages.
|
|
type fakeSMTP struct {
|
|
ln net.Listener
|
|
|
|
// rcptReply is the reply to RCPT TO; default 250.
|
|
rcptReply string
|
|
// mailReply is the reply to MAIL FROM; default 250.
|
|
mailReply string
|
|
// advertiseAuth declares AUTH PLAIN support in EHLO when true.
|
|
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("not a TCP listen address")
|
|
}
|
|
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"):
|
|
// Do not advertise STARTTLS: force the code down the plaintext branch
|
|
// (the test targets envelope logic, not TLS).
|
|
w("250-fake.local")
|
|
if f.advertiseAuth {
|
|
w("250-AUTH PLAIN")
|
|
}
|
|
w("250 8BITMIME")
|
|
case strings.HasPrefix(line, "AUTH"):
|
|
// Simplified: PLAIN's initial response may span multiple lines; just
|
|
// accept it.
|
|
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("delivery failed: %v", err)
|
|
}
|
|
// The envelope stages must all happen: sender, both recipients, 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 session missing %q, actual commands: %v", want, f.commands)
|
|
}
|
|
}
|
|
// The body is base64 HTML and must carry the real finding content (still
|
|
// recognizable after encoding).
|
|
body := f.body()
|
|
if body == "" {
|
|
t.Fatal("no body received in the DATA phase")
|
|
}
|
|
if !strings.Contains(body, "Content-Type: text/html") {
|
|
t.Errorf("missing Content-Type header:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "base64") {
|
|
t.Errorf("body not base64-encoded (long HTML lines would break SMTP's 1000-byte line limit):\n%s", body)
|
|
}
|
|
// All recipients must appear in the To header.
|
|
if !strings.Contains(body, "a@example.com, b@example.com") {
|
|
t.Errorf("To header does not include all recipients:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestEmailSendWithoutAuth(t *testing.T) {
|
|
// AUTH must not be sent when no account is configured — some relays reject
|
|
// because of it.
|
|
f := newFakeSMTP(t)
|
|
cfg := emailCfg(t, f, nil)
|
|
if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
|
|
t.Fatalf("delivery failed: %v", err)
|
|
}
|
|
if f.sawCommand("AUTH") {
|
|
t.Errorf("AUTH sent despite no account configured: %v", f.commands)
|
|
}
|
|
}
|
|
|
|
// TestEmailSendClassifiesSMTPReplies directly verifies this audit fix:
|
|
// 5xx is a permanent failure, 4xx (greylisting) is retryable.
|
|
func TestEmailSendClassifiesSMTPReplies(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
rcptReply string
|
|
mailReply string
|
|
permanent bool
|
|
}{
|
|
{"recipient permanently rejected with 550", "550 5.1.1 User unknown", "250 OK", true},
|
|
{"recipient hits 450 greylisting", "450 4.7.1 Greylisting in action", "250 OK", false},
|
|
{"recipient hits 452 mailbox full", "452 4.2.2 Mailbox full", "250 OK", false},
|
|
{"sender permanently rejected with 553", "250 OK", "553 5.1.3 Bad address", true},
|
|
{"sender hits 451 temporary error", "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("should error")
|
|
}
|
|
if got := IsPermanent(err); got != tc.permanent {
|
|
t.Fatalf("wrong permanent classification: want %v got %v (%v)", tc.permanent, got, err)
|
|
}
|
|
// The server's original reply must be preserved, otherwise the user
|
|
// cannot tell whether to contact the server admin or fix the address.
|
|
if !strings.Contains(err.Error(), strings.Fields(tc.rcptReply)[0]) && !strings.Contains(err.Error(), strings.Fields(tc.mailReply)[0]) {
|
|
t.Errorf("the error should keep the server's reply code: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmailSendRefusesPlaintextCredentials(t *testing.T) {
|
|
// net/smtp's PlainAuth refuses to send credentials over an unencrypted
|
|
// connection (unless the target is localhost). That is **correct** security
|
|
// behavior and must not be bypassed; but the error must guide the user to a
|
|
// fix. A non-localhost hostname triggers it here.
|
|
f := newFakeSMTP(t)
|
|
f.advertiseAuth = true
|
|
_, port := f.hostPort(t)
|
|
cfg := map[string]any{
|
|
"host": "smtp.example.com", // non-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("local DNS resolved to a local server, skipping (does not affect other cases)")
|
|
}
|
|
// Either failing to connect or being refused to send credentials passes this
|
|
// assertion; the key is the password must **never** be sent silently.
|
|
if !IsPermanent(err) && !strings.Contains(err.Error(), "connect") {
|
|
t.Logf("error: %v (failing to connect under non-localhost is expected)", err)
|
|
}
|
|
}
|
|
|
|
func TestEmailValidateReportsMissingFields(t *testing.T) {
|
|
// The email channel has the most config fields; missing any one only
|
|
// surfaces at delivery time. Here we confirm validation catches each one
|
|
// early. The assertion checks that "the error message mentions what is
|
|
// missing".
|
|
cases := []struct {
|
|
name string
|
|
cfg map[string]any
|
|
}{
|
|
{"missing host", map[string]any{"port": float64(25), "from": "a@b.c", "to": []any{"d@e.f"}}},
|
|
{"missing port", map[string]any{"host": "smtp.example.com"}},
|
|
{"port out of range", map[string]any{"host": "h", "port": float64(70000), "from": "a@b.c", "to": []any{"d@e.f"}}},
|
|
{"missing from", map[string]any{"host": "h", "port": float64(25), "to": []any{"d@e.f"}}},
|
|
{"missing 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("should fail validation: %v", tc.cfg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmailConfigTolerance covers config-reading tolerance: numbers in JSONB
|
|
// are float64, but a user may enter the port as a string in the UI; arrays may
|
|
// also be a single string.
|
|
func TestEmailConfigTolerance(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"host": "smtp.example.com",
|
|
"port": "587", // port as a string
|
|
"from": "a@b.c",
|
|
"to": "d@e.f", // single string instead of an array
|
|
"tls": "true", // boolean as a string
|
|
}
|
|
if err := (emailChannel{}).Validate(cfg); err != nil {
|
|
t.Fatalf("should tolerate numbers in string form: %v", err)
|
|
}
|
|
if got := cfgInt(cfg, "port"); got != 587 {
|
|
t.Errorf("cfgInt did not parse the string port, got %d", got)
|
|
}
|
|
if !cfgBool(cfg, "tls") {
|
|
t.Error("cfgBool did not parse the string \"true\"")
|
|
}
|
|
if to := cfgStrings(cfg, "to"); len(to) != 1 || to[0] != "d@e.f" {
|
|
t.Errorf("cfgStrings did not accept a single string, got %v", to)
|
|
}
|
|
}
|
|
|
|
// TestFilterValidateRejectsTypo directly verifies the audit fix:
|
|
// a misspelled threshold must be rejected at write time, otherwise the filter
|
|
// silently fails open and pushes everything.
|
|
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("valid threshold %q rejected: %v", s, err)
|
|
}
|
|
}
|
|
// These are typos that really happen — all must be rejected.
|
|
for _, s := range []string{"hgih", "HIGH", "severe", "high ", "crit"} {
|
|
err := (Filter{MinSeverity: s}).Validate()
|
|
if err == nil {
|
|
t.Errorf("invalid threshold %q should be rejected (otherwise the filter silently fails open and pushes everything)", s)
|
|
continue
|
|
}
|
|
// The error message must guide the user to a fix.
|
|
if !strings.Contains(err.Error(), "low") || !strings.Contains(err.Error(), "critical") {
|
|
t.Errorf("the error message should list the valid values, got %q", err.Error())
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFilterValidateIsWriteTimeOnly locks in the "strict on write, lenient on
|
|
// read" split: a bad value already in the DB must not make the channel
|
|
// unreadable (that would suddenly stop pushes for all historical channels).
|
|
func TestFilterValidateIsWriteTimeOnly(t *testing.T) {
|
|
raw := []byte(`{"min_severity":"hgih"}`)
|
|
f := ParseFilter(raw) // does not error
|
|
if f.MinSeverity != "hgih" {
|
|
t.Fatalf("the read path should keep it verbatim, got %q", f.MinSeverity)
|
|
}
|
|
// And the channel can still judge events (no panic, no blocking).
|
|
_ = Match(f, Snapshot{Kind: EventFindingCreated, Severity: "critical"})
|
|
}
|