mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -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.
152 lines
5.5 KiB
Go
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))
|
|
}
|
|
}
|