Files
sif/internal/modules/matchers_condition_test.go
T
TigahandGitHub bf8c6a8643 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.
2026-07-22 12:49:22 -07:00

152 lines
5.5 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/vmfunc/sif/internal/httpx"
)
func TestCheckMatchersCondition(t *testing.T) {
const body = "hello world"
resp := fakeResponse(t, 200, nil)
status200 := Matcher{Type: "status", Status: []int{200}}
status500 := Matcher{Type: "status", Status: []int{500}}
wordHit := Matcher{Type: "word", Part: "body", Words: []string{"hello"}}
wordMiss := Matcher{Type: "word", Part: "body", Words: []string{"absent"}}
tests := []struct {
name string
condition string
matchers []Matcher
expect bool
}{
{"and both match", "and", []Matcher{status200, wordHit}, true},
{"and one fails", "and", []Matcher{status200, wordMiss}, false},
{"empty defaults to and", "", []Matcher{status200, wordMiss}, false},
{"or one matches", "or", []Matcher{status500, wordHit}, true},
{"or none match", "or", []Matcher{status500, wordMiss}, false},
{"or all match", "or", []Matcher{status200, wordHit}, true},
{"or is case-insensitive", "OR", []Matcher{status500, wordHit}, true},
{"and is case-insensitive", "AND", []Matcher{status200, wordMiss}, false},
{"or with negative pass", "or", []Matcher{{Type: "word", Part: "body", Words: []string{"absent"}, Negative: true}}, true},
{"or all fail with negative", "or", []Matcher{{Type: "word", Part: "body", Words: []string{"hello"}, Negative: true}, wordMiss}, false},
{"empty matcher list", "or", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := checkMatchers(tt.matchers, tt.condition, resp, body); got != tt.expect {
t.Errorf("checkMatchers(%q) = %v, want %v", tt.condition, got, tt.expect)
}
})
}
}
func TestValidateMatchersCondition(t *testing.T) {
for _, ok := range []string{"", "and", "or", "AND", "Or"} {
if err := validateMatchersCondition(ok); err != nil {
t.Errorf("%q should be valid: %v", ok, err)
}
}
for _, bad := range []string{"xor", "nand", "any", "&&"} {
if err := validateMatchersCondition(bad); err == nil {
t.Errorf("%q should be rejected", bad)
}
}
}
func TestParseMatchersConditionValidation(t *testing.T) {
write := func(cond string) string {
p := filepath.Join(t.TempDir(), "m.yaml")
body := fmt.Sprintf("id: mc\ntype: http\nhttp:\n method: GET\n paths: [\"{{BaseURL}}\"]\n matchers-condition: %s\n matchers:\n - type: status\n status: [200]\n", cond)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}
if _, err := ParseYAMLModule(write("or")); err != nil {
t.Errorf("matchers-condition: or should parse: %v", err)
}
if _, err := ParseYAMLModule(write("xor")); err == nil {
t.Error("matchers-condition: xor should be rejected at load")
}
}
// or fires on the word match alone; and does not (status:500 fails).
func TestExecuteHTTPModuleMatchersConditionOr(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("hello"))
}))
defer srv.Close()
def := &YAMLModule{
ID: "mc",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "info"},
HTTP: &HTTPConfig{
Method: "GET",
Paths: []string{"{{BaseURL}}/"},
Matchers: []Matcher{
{Type: "status", Status: []int{500}},
{Type: "word", Part: "body", Words: []string{"hello"}},
},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
def.HTTP.MatchersCondition = "or"
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("or: %v", err)
}
if len(res.Findings) != 1 {
t.Fatalf("or: got %d findings, want 1", len(res.Findings))
}
def.HTTP.MatchersCondition = ""
res, err = ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("and: %v", err)
}
if len(res.Findings) != 0 {
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))
}
}