mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user