Skip to content
68 changes: 67 additions & 1 deletion docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ info:

### type (required)

module type. currently only `http` is supported.
module type. `http` and `tcp` are supported.

```yaml
type: http
Expand Down Expand Up @@ -168,6 +168,58 @@ http:
threads: 5
```

### tcp

raw tcp configuration. connects to a port, optionally sends a payload, and runs
matchers and extractors against the response banner.

#### port

the tcp port to connect to (required, 1-65535). the port selects the service, so
the target's own scheme and port are ignored.

```yaml
tcp:
port: 6379
```

#### data

an optional payload sent after connecting. sif decodes C-style escapes in the
value, so `\r`, `\n`, `\t`, `\\` and `\xHH` reach the wire as the raw bytes no
matter how the yaml scalar is quoted; an unrecognized escape is left verbatim. a
server that only banners (ssh, smtp) needs no data at all.

```yaml
tcp:
port: 6379
data: "INFO\r\n"
```

#### matchers and extractors

tcp runs `word`, `regex` and `size` matchers (no `status`/`favicon`, those are
http only) against the banner string, and `regex` extractors pull values out of
it. there is no `part` selector: the banner is the only stream.

```yaml
tcp:
port: 6379
data: "INFO\r\n"
matchers:
- type: word
words:
- "redis_version:"
extractors:
- type: regex
name: redis_version
regex:
- "redis_version:([0-9.]+)"
group: 1
```

see `modules/recon/redis-unauth-exposure.yaml` for the full module.

## matchers

matchers determine if a response indicates a finding.
Expand Down Expand Up @@ -277,6 +329,20 @@ matchers:

this matches responses with status 200 AND containing "ref: refs/".

set `matchers-condition: or` to fire when any matcher hits instead of all. it
applies to `http` and `tcp` modules alike.

```yaml
matchers-condition: or
matchers:
- type: word
words:
- "redis_version:"
- type: word
words:
- "+PONG"
```

to require any matcher instead of all, set `matchers-condition: or` on the http
block; the module then reports a finding when any one matcher matches.

Expand Down
7 changes: 0 additions & 7 deletions internal/modules/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,10 +531,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)
}
27 changes: 12 additions & 15 deletions internal/modules/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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))
}
})

Expand Down
19 changes: 19 additions & 0 deletions internal/modules/matchers_condition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Loading
Loading