mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-29 06:50:13 -07:00
* feat(modules): tcp module executor dial+probe+banner with word/regex/size matchers, ctx-honest watcher, port required. * feat(modules): add redis unauthenticated access tcp module first shipped tcp module: sends INFO to redis (6379) and matches redis_version in the reply, which an auth-required server never returns (it answers -NOAUTH). extracts the leaked version. * docs(modules): document the tcp module type flip the http-only note and add a tcp config section (port, data, word/regex/size matchers and regex extractors against the banner), pointing at the redis demo module. * feat(modules): support matchers-condition in tcp modules tcp matchers were and-only; add the matchers-condition key (and default, or) mirroring the http executor's checkMatchers, validated at load via the shared validateMatchersCondition. documents the override in the combining matchers section. * feat(modules): decode escape sequences in tcp data payloads decode C-style escapes (\\ \a \b \f \n \r \t \v \xHH) in a tcp module's data value before it goes on the wire, so a single-quoted or plain yaml scalar reaches the service with the same control bytes a double-quoted one already would. an unrecognized escape is kept verbatim so no bytes are silently lost. * feat(modules): add tcp recon modules for common services add five built-in tcp modules alongside the redis one so the executor ships a real service set: memcached (unauth version probe), ssh and ftp (passive banner version disclosure), mysql/mariadb (handshake exposure, matched with matchers-condition: or across both auth plugin names), and vnc (rfb handshake exposure). each fingerprint tracks the documented on-wire protocol and pulls the version into an extractor. * feat(modules): add mail and rsync tcp recon modules add four more passive-banner tcp modules: smtp, pop3, and imap read the greeting each mail service sends on connect (the smtp matcher requires an esmtp/smtp marker so it does not fire on a bare 220 ftp greeting), and rsync detects an exposed daemon by its @RSYNCD handshake. each pulls the banner or protocol version into an extractor.
532 lines
18 KiB
Go
532 lines
18 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package modules
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeTCPServer answers from a fixture over an in-memory pipe and records the
|
|
// probe the executor wrote and the address it dialed.
|
|
type fakeTCPServer struct {
|
|
reply string
|
|
addr string
|
|
sent chan []byte
|
|
}
|
|
|
|
// serve drains any probe the client sends (so a synchronous pipe write does not
|
|
// deadlock) and replies with the fixture.
|
|
func (s *fakeTCPServer) serve(conn net.Conn) {
|
|
defer conn.Close()
|
|
go func() {
|
|
buf := make([]byte, 4096)
|
|
n, _ := conn.Read(buf)
|
|
s.sent <- append([]byte(nil), buf[:n]...)
|
|
}()
|
|
if s.reply != "" {
|
|
_, _ = conn.Write([]byte(s.reply))
|
|
}
|
|
}
|
|
|
|
// withFakeTCP installs a fake dialer that hands the executor one pipe end and
|
|
// streams the reply over the other, then returns the fake so a test can read
|
|
// back the dialed address and the probe bytes.
|
|
func withFakeTCP(t *testing.T, reply string) *fakeTCPServer {
|
|
t.Helper()
|
|
s := &fakeTCPServer{reply: reply, sent: make(chan []byte, 1)}
|
|
orig := newTCPConn
|
|
newTCPConn = func(_ context.Context, addr string, _ time.Duration) (net.Conn, error) {
|
|
s.addr = addr
|
|
client, server := net.Pipe()
|
|
go s.serve(server)
|
|
return client, nil
|
|
}
|
|
t.Cleanup(func() { newTCPConn = orig })
|
|
return s
|
|
}
|
|
|
|
func tcpWord(words ...string) Matcher {
|
|
return Matcher{Type: "word", Words: words}
|
|
}
|
|
|
|
func tcpDef(cfg *TCPConfig) *YAMLModule {
|
|
return &YAMLModule{ID: "tcp-test", Type: TypeTCP, Info: YAMLModuleInfo{Severity: "info"}, TCP: cfg}
|
|
}
|
|
|
|
func TestExecuteTCPModuleMatchAndExtract(t *testing.T) {
|
|
withFakeTCP(t, "+OK Redis 7.2.4 ready\r\n")
|
|
|
|
def := tcpDef(&TCPConfig{
|
|
Port: 6379,
|
|
Data: "PING\r\n",
|
|
Matchers: []Matcher{tcpWord("+OK", "Redis")},
|
|
Extractors: []Extractor{
|
|
{Type: "regex", Name: "version", Regex: []string{`Redis (\d+\.\d+\.\d+)`}, Group: 1},
|
|
},
|
|
})
|
|
|
|
res, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteTCPModule: %v", err)
|
|
}
|
|
if len(res.Findings) != 1 {
|
|
t.Fatalf("got %d findings, want 1", len(res.Findings))
|
|
}
|
|
if got := res.Findings[0].Extracted["version"]; got != "7.2.4" {
|
|
t.Errorf("extracted version = %q, want 7.2.4", got)
|
|
}
|
|
if res.Findings[0].Evidence == "" {
|
|
t.Error("evidence is empty")
|
|
}
|
|
}
|
|
|
|
func TestExecuteTCPModuleNoMatch(t *testing.T) {
|
|
withFakeTCP(t, "+OK ready\r\n")
|
|
def := tcpDef(&TCPConfig{Port: 6379, Matchers: []Matcher{tcpWord("absent-token")}})
|
|
|
|
res, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteTCPModule: %v", err)
|
|
}
|
|
if len(res.Findings) != 0 {
|
|
t.Fatalf("got %d findings, want 0", len(res.Findings))
|
|
}
|
|
}
|
|
|
|
func TestExecuteTCPModuleSendsProbe(t *testing.T) {
|
|
s := withFakeTCP(t, "PONG\r\n")
|
|
def := tcpDef(&TCPConfig{Port: 6379, Data: "PING\r\n", Matchers: []Matcher{tcpWord("PONG")}})
|
|
|
|
if _, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}); err != nil {
|
|
t.Fatalf("ExecuteTCPModule: %v", err)
|
|
}
|
|
if got := string(<-s.sent); got != "PING\r\n" {
|
|
t.Errorf("server received probe %q, want %q", got, "PING\r\n")
|
|
}
|
|
if s.addr != "example.com:6379" {
|
|
t.Errorf("dialed %q, want example.com:6379", s.addr)
|
|
}
|
|
}
|
|
|
|
func TestExecuteTCPModuleDecodesProbeEscapes(t *testing.T) {
|
|
// data written with literal backslashes (a single-quoted or plain yaml scalar)
|
|
// is decoded to real control bytes before it goes on the wire.
|
|
s := withFakeTCP(t, "PONG\r\n")
|
|
def := tcpDef(&TCPConfig{Port: 6379, Data: `PING\r\n`, Matchers: []Matcher{tcpWord("PONG")}})
|
|
|
|
if _, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}); err != nil {
|
|
t.Fatalf("ExecuteTCPModule: %v", err)
|
|
}
|
|
if got := string(<-s.sent); got != "PING\r\n" {
|
|
t.Errorf("server received probe %q, want PING followed by CRLF", got)
|
|
}
|
|
}
|
|
|
|
func TestDecodeTCPData(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"no backslash is unchanged", "PING", "PING"},
|
|
{"crlf", `PING\r\n`, "PING\r\n"},
|
|
{"tab then null via hex", `a\tb\x00`, "a\tb\x00"},
|
|
{"hex pair", `\x41\x42`, "AB"},
|
|
{"escaped backslash", `a\\b`, `a\b`},
|
|
{"unknown escape kept verbatim", `a\zb`, `a\zb`},
|
|
{"trailing backslash kept", `abc\`, `abc\`},
|
|
{"malformed hex kept", `\xZZ`, `\xZZ`},
|
|
{"short hex kept", `\x4`, `\x4`},
|
|
{"every simple escape", `\a\b\f\n\r\t\v`, "\a\b\f\n\r\t\v"},
|
|
}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := decodeTCPData(tt.in); got != tt.want {
|
|
t.Errorf("decodeTCPData(%q) = %q, want %q", tt.in, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecuteTCPModulePassiveBanner(t *testing.T) {
|
|
// no data probe: the service speaks first (ssh, ftp, smtp).
|
|
withFakeTCP(t, "SSH-2.0-OpenSSH_9.6\r\n")
|
|
def := tcpDef(&TCPConfig{Port: 22, Matchers: []Matcher{tcpWord("SSH-2.0")}})
|
|
|
|
res, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteTCPModule: %v", err)
|
|
}
|
|
if len(res.Findings) != 1 {
|
|
t.Fatalf("got %d findings, want 1", len(res.Findings))
|
|
}
|
|
}
|
|
|
|
func TestCheckTCPMatchers(t *testing.T) {
|
|
data := "SSH-2.0-OpenSSH_9.6 Ubuntu"
|
|
|
|
tests := []struct {
|
|
name string
|
|
matchers []Matcher
|
|
want bool
|
|
}{
|
|
{"no matchers is false", nil, false},
|
|
{"single word hit", []Matcher{tcpWord("OpenSSH")}, true},
|
|
{"single word miss", []Matcher{tcpWord("Dropbear")}, false},
|
|
{"and across matchers all hit", []Matcher{tcpWord("SSH-2.0"), tcpWord("Ubuntu")}, true},
|
|
{"and across matchers one miss", []Matcher{tcpWord("SSH-2.0"), tcpWord("Debian")}, false},
|
|
{"negative inverts a miss to a hit", []Matcher{{Type: "word", Words: []string{"Dropbear"}, Negative: true}}, true},
|
|
{"negative inverts a hit to a miss", []Matcher{{Type: "word", Words: []string{"OpenSSH"}, Negative: true}}, false},
|
|
{"regex hit", []Matcher{{Type: "regex", Regex: []string{`OpenSSH_\d+\.\d+`}}}, true},
|
|
{"size hit", []Matcher{{Type: "size", Size: []int{len(data)}}}, true},
|
|
{"size miss", []Matcher{{Type: "size", Size: []int{1}}}, false},
|
|
{"status type never matches in tcp", []Matcher{{Type: "status", Status: []int{0}}}, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := checkTCPMatchers(tt.matchers, "", data); got != tt.want {
|
|
t.Errorf("checkTCPMatchers = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckTCPMatchersOr(t *testing.T) {
|
|
data := "SSH-2.0-OpenSSH_9.6 Ubuntu"
|
|
|
|
tests := []struct {
|
|
name string
|
|
matchers []Matcher
|
|
want bool
|
|
}{
|
|
{"one of two hits", []Matcher{tcpWord("Dropbear"), tcpWord("OpenSSH")}, true},
|
|
{"none hit", []Matcher{tcpWord("Dropbear"), tcpWord("Debian")}, false},
|
|
{"first hit short-circuits", []Matcher{tcpWord("OpenSSH"), tcpWord("Dropbear")}, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := checkTCPMatchers(tt.matchers, "or", data); got != tt.want {
|
|
t.Errorf("checkTCPMatchers(or) = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunTCPExtractors(t *testing.T) {
|
|
data := "220 mail.example.com ESMTP Postfix 3.7.2"
|
|
|
|
t.Run("regex group 1", func(t *testing.T) {
|
|
ex := []Extractor{{Type: "regex", Name: "mta", Regex: []string{`ESMTP (\w+)`}, Group: 1}}
|
|
if got := runTCPExtractors(ex, data)["mta"]; got != "Postfix" {
|
|
t.Errorf("group 1 = %q, want Postfix", got)
|
|
}
|
|
})
|
|
t.Run("group 0 full match", func(t *testing.T) {
|
|
ex := []Extractor{{Type: "regex", Name: "ver", Regex: []string{`Postfix [\d.]+`}, Group: 0}}
|
|
if got := runTCPExtractors(ex, data)["ver"]; got != "Postfix 3.7.2" {
|
|
t.Errorf("group 0 = %q", got)
|
|
}
|
|
})
|
|
t.Run("miss sets nothing", func(t *testing.T) {
|
|
ex := []Extractor{{Type: "regex", Name: "x", Regex: []string{`nope(\d+)`}, Group: 1}}
|
|
if _, ok := runTCPExtractors(ex, data)["x"]; ok {
|
|
t.Error("a non-matching extractor set a value")
|
|
}
|
|
})
|
|
t.Run("non-regex type skipped", func(t *testing.T) {
|
|
ex := []Extractor{{Type: "kv", Name: "k"}}
|
|
if _, ok := runTCPExtractors(ex, data)["k"]; ok {
|
|
t.Error("a non-regex extractor produced a value")
|
|
}
|
|
})
|
|
t.Run("uncompilable pattern skipped", func(t *testing.T) {
|
|
// the bad pattern is skipped, the next one still matches.
|
|
ex := []Extractor{{Type: "regex", Name: "x", Regex: []string{"[", `(ESMTP)`}, Group: 1}}
|
|
if got := runTCPExtractors(ex, data)["x"]; got != "ESMTP" {
|
|
t.Errorf("after skipping an invalid regex, got %q, want ESMTP", got)
|
|
}
|
|
})
|
|
t.Run("no extractors is nil", func(t *testing.T) {
|
|
if runTCPExtractors(nil, data) != nil {
|
|
t.Error("want nil for no extractors")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestExecuteTCPModuleContextCancel(t *testing.T) {
|
|
// a server that never replies: the read blocks until the context cancels and
|
|
// the deadline-trip unblocks it.
|
|
orig := newTCPConn
|
|
newTCPConn = func(_ context.Context, _ string, _ time.Duration) (net.Conn, error) {
|
|
client, server := net.Pipe()
|
|
t.Cleanup(func() { server.Close() })
|
|
return client, nil
|
|
}
|
|
t.Cleanup(func() { newTCPConn = orig })
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
time.Sleep(20 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
def := tcpDef(&TCPConfig{Port: 22, Matchers: []Matcher{tcpWord("x")}})
|
|
start := time.Now()
|
|
res, err := ExecuteTCPModule(ctx, "example.com", def, Options{Timeout: 5 * time.Second})
|
|
// the cancel must trip the deadline and unblock the read promptly, well
|
|
// before the 5s read budget would have expired on its own.
|
|
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
|
t.Errorf("returned after %v, want a prompt cancel (the deadline trip did not fire)", elapsed)
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("err = %v, want context.Canceled", err)
|
|
}
|
|
if len(res.Findings) != 0 {
|
|
t.Errorf("got %d findings on cancel, want 0", len(res.Findings))
|
|
}
|
|
}
|
|
|
|
func TestExecuteTCPModuleDialError(t *testing.T) {
|
|
orig := newTCPConn
|
|
newTCPConn = func(context.Context, string, time.Duration) (net.Conn, error) {
|
|
return nil, fmt.Errorf("connection refused")
|
|
}
|
|
t.Cleanup(func() { newTCPConn = orig })
|
|
|
|
def := tcpDef(&TCPConfig{Port: 22, Matchers: []Matcher{tcpWord("x")}})
|
|
if _, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}); err == nil {
|
|
t.Fatal("expected error when the dial fails")
|
|
}
|
|
}
|
|
|
|
func TestExecuteTCPModuleWriteError(t *testing.T) {
|
|
// a pipe whose far end is already closed fails the probe write.
|
|
orig := newTCPConn
|
|
newTCPConn = func(context.Context, string, time.Duration) (net.Conn, error) {
|
|
client, server := net.Pipe()
|
|
server.Close()
|
|
return client, nil
|
|
}
|
|
t.Cleanup(func() { newTCPConn = orig })
|
|
|
|
def := tcpDef(&TCPConfig{Port: 6379, Data: "PING\r\n", Matchers: []Matcher{tcpWord("x")}})
|
|
if _, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{Timeout: time.Second}); err == nil {
|
|
t.Fatal("expected error when the probe write fails")
|
|
}
|
|
}
|
|
|
|
// TestExecuteTCPModuleRealListener drives the executor over the real net.Dialer
|
|
// against a live 127.0.0.1 listener (no newTCPConn stub), so the actual dial,
|
|
// probe write, and banner read path is exercised end to end. A matching banner
|
|
// yields a finding with the extracted version; a different banner yields none.
|
|
func TestExecuteTCPModuleRealListener(t *testing.T) {
|
|
serve := func(banner string) (port int, stop func()) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go func(c net.Conn) {
|
|
defer c.Close()
|
|
buf := make([]byte, 4096)
|
|
_ = c.SetReadDeadline(time.Now().Add(time.Second))
|
|
_, _ = c.Read(buf)
|
|
_, _ = c.Write([]byte(banner))
|
|
}(conn)
|
|
}
|
|
}()
|
|
return ln.Addr().(*net.TCPAddr).Port, func() { ln.Close(); <-done }
|
|
}
|
|
|
|
t.Run("positive", func(t *testing.T) {
|
|
port, stop := serve("+OK Redis 7.2.4 ready\r\n")
|
|
defer stop()
|
|
def := tcpDef(&TCPConfig{
|
|
Port: port,
|
|
Data: "INFO\r\n",
|
|
Matchers: []Matcher{tcpWord("+OK", "Redis")},
|
|
Extractors: []Extractor{
|
|
{Type: "regex", Name: "version", Regex: []string{`Redis (\d+\.\d+\.\d+)`}, Group: 1},
|
|
},
|
|
})
|
|
res, err := ExecuteTCPModule(context.Background(), "127.0.0.1", def, Options{Timeout: 2 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteTCPModule: %v", err)
|
|
}
|
|
if len(res.Findings) != 1 {
|
|
t.Fatalf("got %d findings, want 1", len(res.Findings))
|
|
}
|
|
if got := res.Findings[0].Extracted["version"]; got != "7.2.4" {
|
|
t.Errorf("extracted version = %q, want 7.2.4", got)
|
|
}
|
|
})
|
|
|
|
t.Run("negative", func(t *testing.T) {
|
|
port, stop := serve("-NOAUTH Authentication required\r\n")
|
|
defer stop()
|
|
def := tcpDef(&TCPConfig{
|
|
Port: port,
|
|
Data: "INFO\r\n",
|
|
Matchers: []Matcher{tcpWord("redis_version:")},
|
|
})
|
|
res, err := ExecuteTCPModule(context.Background(), "127.0.0.1", def, Options{Timeout: 2 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteTCPModule: %v", err)
|
|
}
|
|
if len(res.Findings) != 0 {
|
|
t.Fatalf("got %d findings, want 0", len(res.Findings))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestExecuteTCPModuleNoConfig(t *testing.T) {
|
|
def := &YAMLModule{ID: "x", Type: TypeTCP}
|
|
if _, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}); err == nil {
|
|
t.Fatal("expected error when TCP config is nil")
|
|
}
|
|
}
|
|
|
|
func TestExecuteTCPModuleNoHost(t *testing.T) {
|
|
def := tcpDef(&TCPConfig{Port: 22, Matchers: []Matcher{tcpWord("x")}})
|
|
if _, err := ExecuteTCPModule(context.Background(), "", def, Options{}); err == nil {
|
|
t.Fatal("expected error when the target has no host")
|
|
}
|
|
}
|
|
|
|
func TestReadTCPCapsAtLimit(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
go func() {
|
|
defer server.Close()
|
|
chunk := make([]byte, 8192)
|
|
for i := 0; i < 12; i++ { // 96 KiB offered, past the 64 KiB cap
|
|
if _, err := server.Write(chunk); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
defer client.Close()
|
|
|
|
got := readTCP(client, time.Second)
|
|
if len(got) < tcpReadLimit {
|
|
t.Fatalf("read %d bytes, want at least the %d cap", len(got), tcpReadLimit)
|
|
}
|
|
if len(got) > tcpReadLimit+4096 {
|
|
t.Fatalf("read %d bytes, want no more than the cap plus one buffer", len(got))
|
|
}
|
|
}
|
|
|
|
func TestTCPHost(t *testing.T) {
|
|
cases := map[string]string{
|
|
"example.com": "example.com",
|
|
"https://example.com:8443/path?q=1": "example.com",
|
|
"redis://user:pass@host.tld:6379": "host.tld",
|
|
"1.2.3.4:6379": "1.2.3.4",
|
|
"[2606:4700::1111]:6379": "2606:4700::1111",
|
|
"/justpath": "/justpath",
|
|
"": "",
|
|
}
|
|
for in, want := range cases {
|
|
if got := tcpHost(in); got != want {
|
|
t.Errorf("tcpHost(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTCPAddress(t *testing.T) {
|
|
t.Run("strips target port for the configured one", func(t *testing.T) {
|
|
got, err := tcpAddress("example.com:80", 6379)
|
|
if err != nil {
|
|
t.Fatalf("tcpAddress: %v", err)
|
|
}
|
|
if got != "example.com:6379" {
|
|
t.Errorf("address = %q, want example.com:6379", got)
|
|
}
|
|
})
|
|
t.Run("empty host errors", func(t *testing.T) {
|
|
if _, err := tcpAddress("", 6379); err == nil {
|
|
t.Fatal("expected error for an empty host")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestValidateTCP(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cfg *TCPConfig
|
|
ok bool
|
|
}{
|
|
{"valid port and matchers", &TCPConfig{Port: 6379, Matchers: []Matcher{{Type: "word"}, {Type: "regex"}, {Type: "size"}}}, true},
|
|
{"no matchers is allowed", &TCPConfig{Port: 22}, true},
|
|
{"port zero rejected", &TCPConfig{Port: 0}, false},
|
|
{"port too high rejected", &TCPConfig{Port: 70000}, false},
|
|
{"status matcher rejected", &TCPConfig{Port: 22, Matchers: []Matcher{{Type: "status"}}}, false},
|
|
{"favicon matcher rejected", &TCPConfig{Port: 22, Matchers: []Matcher{{Type: "favicon"}}}, false},
|
|
{"or condition allowed", &TCPConfig{Port: 6379, MatchersCondition: "or"}, true},
|
|
{"and condition allowed", &TCPConfig{Port: 6379, MatchersCondition: "and"}, true},
|
|
{"unknown condition rejected", &TCPConfig{Port: 6379, MatchersCondition: "xor"}, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateTCP(tt.cfg)
|
|
if (err == nil) != tt.ok {
|
|
t.Errorf("validateTCP = %v, want ok=%v", err, tt.ok)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseTCPValidation(t *testing.T) {
|
|
dir := t.TempDir()
|
|
write := func(name, body string) string {
|
|
p := filepath.Join(dir, name)
|
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
good := write("good.yaml", "id: ok\ntype: tcp\ntcp:\n port: 6379\n matchers:\n - type: word\n words: [PONG]\n")
|
|
if _, err := ParseYAMLModule(good); err != nil {
|
|
t.Fatalf("valid tcp module rejected: %v", err)
|
|
}
|
|
|
|
badPort := write("badport.yaml", "id: bp\ntype: tcp\ntcp:\n port: 0\n")
|
|
if _, err := ParseYAMLModule(badPort); err == nil {
|
|
t.Fatal("port zero accepted")
|
|
}
|
|
|
|
badMatcher := write("badmatcher.yaml", "id: bm\ntype: tcp\ntcp:\n port: 22\n matchers:\n - type: status\n status: [0]\n")
|
|
if _, err := ParseYAMLModule(badMatcher); err == nil {
|
|
t.Fatal("status matcher on tcp accepted")
|
|
}
|
|
|
|
badCond := write("badcond.yaml", "id: bc\ntype: tcp\ntcp:\n port: 6379\n matchers-condition: xor\n matchers:\n - type: word\n words: [PONG]\n")
|
|
if _, err := ParseYAMLModule(badCond); err == nil {
|
|
t.Fatal("invalid matchers-condition on tcp accepted")
|
|
}
|
|
}
|