feat(modules): add tcp module executor and recon modules (#308)

* 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.
This commit is contained in:
Tigah
2026-07-22 12:49:22 -07:00
committed by GitHub
parent e8eee5c530
commit bf8c6a8643
17 changed files with 1200 additions and 29 deletions
-7
View File
@@ -543,10 +543,3 @@ func truncateEvidence(s string) string {
func ExecuteDNSModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) {
return nil, fmt.Errorf("dns module %q: %w", def.ID, ErrUnsupportedModuleType)
}
// ExecuteTCPModule runs a TCP-based module (not yet implemented).
// returns ErrUnsupportedModuleType so the caller logs a clear failure rather
// than reporting an empty (but successful-looking) result.
func ExecuteTCPModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) {
return nil, fmt.Errorf("tcp module %q: %w", def.ID, ErrUnsupportedModuleType)
}
+12 -15
View File
@@ -277,17 +277,6 @@ func TestExecuteDNSModuleUnsupported(t *testing.T) {
}
}
func TestExecuteTCPModuleUnsupported(t *testing.T) {
def := &YAMLModule{ID: "tcp-mod", Type: TypeTCP, TCP: &TCPConfig{Port: 22}}
result, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{})
if result != nil {
t.Errorf("result = %v, want nil for unsupported type", result)
}
if !errors.Is(err, ErrUnsupportedModuleType) {
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
}
}
// TestWrapperExecuteRoutesByType confirms the Module wrapper dispatches each
// type to the right executor and propagates the unsupported-type sentinel.
func TestWrapperExecuteRoutesByType(t *testing.T) {
@@ -299,11 +288,19 @@ func TestWrapperExecuteRoutesByType(t *testing.T) {
}
})
t.Run("tcp routes to unsupported", func(t *testing.T) {
def := &YAMLModule{ID: "t", Type: TypeTCP, TCP: &TCPConfig{}}
t.Run("tcp routes to executor", func(t *testing.T) {
withFakeTCP(t, "SSH-2.0-OpenSSH_9.6")
def := &YAMLModule{ID: "t", Type: TypeTCP, TCP: &TCPConfig{
Port: 22,
Matchers: []Matcher{{Type: "word", Words: []string{"SSH-2.0"}}},
}}
w := newYAMLModuleWrapper(def, "t.yaml")
if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) {
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
res, err := w.Execute(context.Background(), "host", Options{})
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(res.Findings) != 1 {
t.Fatalf("findings = %d, want 1", len(res.Findings))
}
})
@@ -130,3 +130,22 @@ func TestExecuteHTTPModuleMatchersConditionOr(t *testing.T) {
t.Fatalf("and: got %d findings, want 0 (status:500 fails)", len(res.Findings))
}
}
func TestExecuteTCPModuleMatchersConditionOr(t *testing.T) {
withFakeTCP(t, "+PONG\r\n")
def := tcpDef(&TCPConfig{
Port: 6379,
Matchers: []Matcher{tcpWord("absent-token"), tcpWord("+PONG")},
MatchersCondition: "or",
})
res, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{})
if err != nil {
t.Fatalf("or: %v", err)
}
// or: the second matcher hits, so the finding fires even though the first
// missed; default and would suppress it.
if len(res.Findings) != 1 {
t.Fatalf("or: got %d findings, want 1", len(res.Findings))
}
}
+307
View File
@@ -0,0 +1,307 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import (
"context"
"fmt"
"net"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
// defaultTCPTimeout bounds the dial and the banner read when the caller passes
// no timeout, so a silent service cannot block the scan forever.
const defaultTCPTimeout = 10 * time.Second
// tcpReadLimit caps how many bytes a banner read keeps, guarding against a
// chatty or hostile service exhausting memory.
const tcpReadLimit = 64 * 1024
// newTCPConn dials the address over TCP. It is a package var so tests can supply
// a fake connection (e.g. a net.Pipe end) without touching the network.
var newTCPConn = func(ctx context.Context, addr string, timeout time.Duration) (net.Conn, error) {
d := net.Dialer{Timeout: timeout}
return d.DialContext(ctx, "tcp", addr)
}
// validateTCP rejects, at load time, a tcp config the executor cannot run: a
// port outside 1-65535, an unknown matchers-condition, or a matcher type other
// than word, regex, or size (status and favicon are http only).
func validateTCP(cfg *TCPConfig) error {
if cfg.Port < 1 || cfg.Port > 65535 {
return fmt.Errorf("tcp port %d out of range (use 1-65535)", cfg.Port)
}
if err := validateMatchersCondition(cfg.MatchersCondition); err != nil {
return err
}
for i := range cfg.Matchers {
switch cfg.Matchers[i].Type {
case "word", "regex", "size":
default:
return fmt.Errorf("tcp matcher type %q is not supported (use word, regex, or size)", cfg.Matchers[i].Type)
}
}
return nil
}
// ExecuteTCPModule connects to the target host on the configured port, sends the
// optional data probe, then applies the module's matchers and extractors to the
// bytes the service returns.
func ExecuteTCPModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
if def.TCP == nil {
return nil, fmt.Errorf("no TCP configuration")
}
cfg := def.TCP
result := &Result{
ModuleID: def.ID,
Target: target,
Findings: make([]Finding, 0),
}
addr, err := tcpAddress(target, cfg.Port)
if err != nil {
return nil, err
}
timeout := opts.Timeout
if timeout <= 0 {
timeout = defaultTCPTimeout
}
conn, err := newTCPConn(ctx, addr, timeout)
if err != nil {
return nil, fmt.Errorf("tcp dial %q: %w", addr, err)
}
defer func() { _ = conn.Close() }()
// retryabledns-style context handling: TCP I/O does not take a context, so
// trip the deadline when the caller cancels to unblock a pending read/write.
stop := make(chan struct{})
defer close(stop)
go func() {
select {
case <-ctx.Done():
_ = conn.SetDeadline(time.Now())
case <-stop:
}
}()
if cfg.Data != "" {
payload := decodeTCPData(cfg.Data)
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
if _, err := conn.Write([]byte(payload)); err != nil {
return nil, fmt.Errorf("tcp write %q: %w", addr, err)
}
}
data := readTCP(conn, timeout)
if err := ctx.Err(); err != nil {
return result, err
}
if !checkTCPMatchers(cfg.Matchers, cfg.MatchersCondition, data) {
return result, nil
}
result.Findings = append(result.Findings, Finding{
Severity: def.Info.Severity,
Evidence: truncateEvidence(data),
Extracted: runTCPExtractors(cfg.Extractors, data),
})
return result, nil
}
// readTCP reads from the connection until tcpReadLimit bytes accumulate, the
// timeout elapses, or the service closes, and returns what arrived. The byte
// cap bounds memory to roughly the limit plus one buffer. A timeout or EOF ends
// the read normally: a silent or half-open service yields the bytes seen so far
// rather than an error, leaving the verdict to the matchers.
func readTCP(conn net.Conn, timeout time.Duration) string {
_ = conn.SetReadDeadline(time.Now().Add(timeout))
var out []byte
buf := make([]byte, 4096)
for len(out) < tcpReadLimit {
n, err := conn.Read(buf)
if n > 0 {
out = append(out, buf[:n]...)
}
if err != nil {
break
}
}
return string(out)
}
// decodeTCPData interprets C-style escape sequences in a tcp data payload so a
// module can put control bytes on the wire regardless of how the yaml scalar is
// quoted. A double-quoted yaml string already turns \r\n into real bytes before
// sif sees it, leaving no backslash for this to act on; a single-quoted or plain
// scalar keeps the backslashes, and this decode gives both forms the same bytes.
// Recognized escapes are \\ \a \b \f \n \r \t \v and \xHH; an unrecognized escape
// is kept verbatim (the backslash plus its character) so nothing is silently lost.
func decodeTCPData(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] != '\\' || i+1 >= len(s) {
b.WriteByte(s[i])
continue
}
i++
switch s[i] {
case '\\':
b.WriteByte('\\')
case 'a':
b.WriteByte('\a')
case 'b':
b.WriteByte('\b')
case 'f':
b.WriteByte('\f')
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
case 'v':
b.WriteByte('\v')
case 'x':
if i+2 < len(s) {
if v, err := strconv.ParseUint(s[i+1:i+3], 16, 8); err == nil {
b.WriteByte(byte(v))
i += 2
continue
}
}
// malformed \xHH: keep it literal rather than drop bytes.
b.WriteByte('\\')
b.WriteByte('x')
default:
// unknown escape: preserve both bytes so data is never lost.
b.WriteByte('\\')
b.WriteByte(s[i])
}
}
return b.String()
}
// checkTCPMatchers evaluates all matchers against the response, combining them
// with AND (default) or OR per the matchers-condition.
func checkTCPMatchers(matchers []Matcher, condition string, data string) bool {
if len(matchers) == 0 {
return false
}
or := strings.EqualFold(condition, "or")
for i := range matchers {
matched := checkTCPMatcher(&matchers[i], data)
if matchers[i].Negative {
matched = !matched
}
if or && matched {
return true
}
if !or && !matched {
return false
}
}
// and: all matched; or: none matched.
return !or
}
// checkTCPMatcher evaluates a single matcher against the response bytes. TCP
// exposes one response stream, so there is no part selection; status and favicon
// are http only and validateTCP rejects them at load.
func checkTCPMatcher(m *Matcher, data string) bool {
switch m.Type {
case "word":
return checkWords(data, m.Words, m.Condition)
case "regex":
return checkRegex(data, m.Regex, m.Condition)
case "size":
for _, n := range m.Size {
if len(data) == n {
return true
}
}
return false
default:
return false
}
}
// runTCPExtractors pulls regex captures from the response bytes. A banner is raw
// text, so regex is the available extractor; other types are skipped.
func runTCPExtractors(extractors []Extractor, data string) map[string]string {
if len(extractors) == 0 {
return nil
}
result := make(map[string]string)
for _, e := range extractors {
if e.Type != "regex" {
continue
}
for _, pattern := range e.Regex {
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
matches := re.FindStringSubmatch(data)
if len(matches) > e.Group {
result[e.Name] = matches[e.Group]
break
}
}
}
return result
}
// tcpAddress reduces target to its hostname and joins it with the configured
// port. Any scheme, port, path, or userinfo on the target is stripped: the
// module's port selects the service, not the target string.
func tcpAddress(target string, port int) (string, error) {
host := tcpHost(target)
if host == "" {
return "", fmt.Errorf("tcp target %q has no host", target)
}
return net.JoinHostPort(host, strconv.Itoa(port)), nil
}
// tcpHost extracts the hostname from a target that may be a bare host, host:port,
// or a full URL.
func tcpHost(target string) string {
target = strings.TrimSpace(target)
if target == "" {
return target
}
// url.Parse only populates Host when a scheme is present; add one for a bare
// host or host:port so the same parse handles every form.
parse := target
if !strings.Contains(parse, "://") {
parse = "//" + parse
}
if u, err := url.Parse(parse); err == nil && u.Hostname() != "" {
return u.Hostname()
}
return target
}
+531
View File
@@ -0,0 +1,531 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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")
}
}
+10 -4
View File
@@ -77,10 +77,11 @@ type DNSConfig struct {
// TCPConfig defines TCP module settings
type TCPConfig struct {
Port int `yaml:"port"`
Data string `yaml:"data,omitempty"`
Matchers []Matcher `yaml:"matchers"`
Extractors []Extractor `yaml:"extractors,omitempty"`
Port int `yaml:"port"`
Data string `yaml:"data,omitempty"`
Matchers []Matcher `yaml:"matchers"`
MatchersCondition string `yaml:"matchers-condition,omitempty"` // and (default), or
Extractors []Extractor `yaml:"extractors,omitempty"`
}
// ParseYAMLModule parses a YAML file into a module definition
@@ -111,6 +112,11 @@ func ParseYAMLModule(path string) (*YAMLModule, error) {
return nil, fmt.Errorf("module %q: %w", ym.ID, err)
}
}
if ym.TCP != nil {
if err := validateTCP(ym.TCP); err != nil {
return nil, fmt.Errorf("module %q: %w", ym.ID, err)
}
}
var matchers []Matcher
switch {
case ym.HTTP != nil: