2026-09-22 14:33:03 +02:00
|
|
|
package probe
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/tls"
|
|
|
|
|
"encoding/binary"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"math"
|
|
|
|
|
"net"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/model"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Deep, unauthenticated, read-only protocol probes. They determine whether a
|
|
|
|
|
// database endpoint actually answers a read without credentials. No password,
|
|
|
|
|
// token or credential of any kind is ever sent, and no state is mutated.
|
|
|
|
|
|
2026-09-22 14:47:49 +02:00
|
|
|
func (p *Prober) deepDB(ctx context.Context, c model.Candidate) (Result, bool) {
|
2026-09-22 14:33:03 +02:00
|
|
|
switch c.Service {
|
|
|
|
|
case model.ServiceRedis, model.ServiceElastiCache, model.ServiceMemoryDB:
|
|
|
|
|
return p.probeRedis(ctx, c.Hostname, dbPort(c.Service))
|
|
|
|
|
case model.ServiceRDS, model.ServicePostgres, model.ServiceRedshift:
|
|
|
|
|
return p.probePostgres(ctx, c.Hostname, 5432)
|
|
|
|
|
case model.ServiceDocumentDB, model.ServiceCosmosDB:
|
|
|
|
|
return p.probeMongo(ctx, c.Hostname, 27017)
|
|
|
|
|
case model.ServiceOpenSearch:
|
|
|
|
|
return p.probeHTTPReadable(ctx, "https://"+c.Hostname+"/_cat/indices?format=json&h=index")
|
|
|
|
|
case model.ServiceNeptune:
|
|
|
|
|
return p.probeHTTPReadable(ctx, "https://"+c.Hostname+"/status")
|
|
|
|
|
}
|
2026-09-22 14:47:49 +02:00
|
|
|
return Result{}, false
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func dbPort(s model.Service) int {
|
|
|
|
|
switch s {
|
|
|
|
|
case model.ServiceMySQL:
|
|
|
|
|
return 3306
|
|
|
|
|
case model.ServicePostgres, model.ServiceRDS:
|
|
|
|
|
return 5432
|
|
|
|
|
case model.ServiceRedis, model.ServiceElastiCache, model.ServiceMemoryDB:
|
|
|
|
|
return 6379
|
|
|
|
|
case model.ServiceDocumentDB, model.ServiceCosmosDB:
|
|
|
|
|
return 27017
|
|
|
|
|
case model.ServiceRedshift:
|
|
|
|
|
return 5439
|
|
|
|
|
case model.ServiceNeptune:
|
|
|
|
|
return 8182
|
|
|
|
|
case model.ServiceDatabase:
|
|
|
|
|
return 1433
|
|
|
|
|
default:
|
|
|
|
|
return 5432
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *Prober) dial(ctx context.Context, host string, port int) (net.Conn, error) {
|
|
|
|
|
d := net.Dialer{Timeout: p.cfg.Timeout}
|
|
|
|
|
return d.DialContext(ctx, "tcp", net.JoinHostPort(host, fmt.Sprint(port)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *Prober) dialTLS(ctx context.Context, host string, port int) (net.Conn, error) {
|
|
|
|
|
conn, err := p.dial(ctx, host, port)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
tconn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, ServerName: host}) // observation only
|
|
|
|
|
_ = tconn.SetDeadline(time.Now().Add(p.cfg.Timeout))
|
|
|
|
|
if err := tconn.HandshakeContext(ctx); err != nil {
|
|
|
|
|
conn.Close()
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return tconn, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// probeRedis sends PING. Redis answers +PONG when no authentication is required.
|
2026-09-22 14:47:49 +02:00
|
|
|
func (p *Prober) probeRedis(ctx context.Context, host string, port int) (Result, bool) {
|
2026-09-22 14:33:03 +02:00
|
|
|
var lastErr error
|
|
|
|
|
for _, tlsOn := range []bool{false, true} {
|
|
|
|
|
var (
|
|
|
|
|
conn net.Conn
|
|
|
|
|
err error
|
|
|
|
|
)
|
|
|
|
|
if tlsOn {
|
|
|
|
|
conn, err = p.dialTLS(ctx, host, port)
|
|
|
|
|
} else {
|
|
|
|
|
conn, err = p.dial(ctx, host, port)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
lastErr = err
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
_ = conn.SetDeadline(time.Now().Add(p.cfg.Timeout))
|
|
|
|
|
_, _ = conn.Write([]byte("PING\r\n"))
|
|
|
|
|
buf := make([]byte, 256)
|
|
|
|
|
n, _ := conn.Read(buf)
|
|
|
|
|
conn.Close()
|
|
|
|
|
resp := string(buf[:n])
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasPrefix(resp, "+PONG"):
|
2026-09-22 14:47:49 +02:00
|
|
|
return listRead("redis PING answered without auth"), true
|
2026-09-22 14:33:03 +02:00
|
|
|
case strings.Contains(resp, "NOAUTH"), strings.HasPrefix(resp, "-ERR"), strings.Contains(resp, "DENIED"), strings.Contains(resp, "protected mode"):
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(model.ExposureAuthRequired, "redis requires authentication"), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
// no/invalid response: try the other transport
|
|
|
|
|
}
|
|
|
|
|
if lastErr == nil {
|
|
|
|
|
lastErr = fmt.Errorf("redis: no response")
|
|
|
|
|
}
|
|
|
|
|
st, detail := classifyError(lastErr)
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(st, detail), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// probePostgres negotiates TLS if offered, then sends a StartupMessage with no
|
|
|
|
|
// password. The server's AuthenticationRequest tells us whether it trusts the
|
|
|
|
|
// connection (AuthenticationOk) or requires a password.
|
2026-09-22 14:47:49 +02:00
|
|
|
func (p *Prober) probePostgres(ctx context.Context, host string, port int) (Result, bool) {
|
2026-09-22 14:33:03 +02:00
|
|
|
conn, err := p.dial(ctx, host, port)
|
|
|
|
|
if err != nil {
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(classifyError(err)), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
defer conn.Close()
|
|
|
|
|
_ = conn.SetDeadline(time.Now().Add(p.cfg.Timeout))
|
|
|
|
|
|
|
|
|
|
// SSLRequest (length 8, code 80877103).
|
|
|
|
|
req := []byte{0, 0, 0, 8, 4, 210, 22, 47}
|
|
|
|
|
if _, err := conn.Write(req); err == nil {
|
|
|
|
|
one := make([]byte, 1)
|
|
|
|
|
if _, err := io.ReadFull(conn, one); err == nil {
|
|
|
|
|
switch one[0] {
|
|
|
|
|
case 'S': // proceed with TLS
|
|
|
|
|
tconn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, ServerName: host})
|
|
|
|
|
if err := tconn.HandshakeContext(ctx); err == nil {
|
|
|
|
|
conn = tconn
|
|
|
|
|
}
|
|
|
|
|
case 'N': // plaintext only
|
|
|
|
|
case 'E': // server refused; fall through and try startup anyway
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// StartupMessage: user=postgres database=postgres
|
|
|
|
|
payload := []byte{0, 3, 0, 0}
|
|
|
|
|
payload = append(payload, "user\x00postgres\x00database\x00postgres\x00\x00"...)
|
|
|
|
|
msg := make([]byte, 4+len(payload))
|
|
|
|
|
binary.BigEndian.PutUint32(msg, uint32(4+len(payload)))
|
|
|
|
|
copy(msg[4:], payload)
|
|
|
|
|
if _, err := conn.Write(msg); err != nil {
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(classifyError(err)), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
head := make([]byte, 5)
|
|
|
|
|
if _, err := io.ReadFull(conn, head); err != nil {
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(classifyError(err)), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
kind := head[0]
|
|
|
|
|
length := int(binary.BigEndian.Uint32(head[1:5]))
|
|
|
|
|
if length > 4 {
|
|
|
|
|
body := make([]byte, length-4)
|
|
|
|
|
_, _ = io.ReadFull(conn, body)
|
|
|
|
|
if kind == 'R' && len(body) >= 4 {
|
|
|
|
|
authType := binary.BigEndian.Uint32(body[:4])
|
|
|
|
|
if authType == 0 {
|
2026-09-22 14:47:49 +02:00
|
|
|
return listRead("postgres AuthenticationOk (trust, no password required)"), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(model.ExposureAuthRequired, fmt.Sprintf("postgres requires password (auth type %d)", authType)), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
if kind == 'E' {
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(model.ExposureAuthRequired, "postgres rejected startup (pg_hba/host not permitted)"), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(model.ExposureReachable, "postgres port open; auth state unclear"), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// probeMongo performs the OP_MSG handshake then a read-only listDatabases. A
|
|
|
|
|
// successful, non-empty reply means the deployment has no authentication.
|
2026-09-22 14:47:49 +02:00
|
|
|
func (p *Prober) probeMongo(ctx context.Context, host string, port int) (Result, bool) {
|
2026-09-22 14:33:03 +02:00
|
|
|
var (
|
|
|
|
|
conn net.Conn
|
|
|
|
|
err error
|
|
|
|
|
)
|
|
|
|
|
// Managed MongoDB (DocumentDB, Cosmos) requires TLS; try it, then plain.
|
|
|
|
|
conn, err = p.dialTLS(ctx, host, port)
|
|
|
|
|
if err != nil {
|
|
|
|
|
conn, err = p.dial(ctx, host, port)
|
|
|
|
|
if err != nil {
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(classifyError(err)), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
defer conn.Close()
|
|
|
|
|
_ = conn.SetDeadline(time.Now().Add(p.cfg.Timeout))
|
|
|
|
|
|
|
|
|
|
doc := bsonDoc{"listDatabases": int32(1), "$db": "admin"}
|
|
|
|
|
if err := writeOPMsg(conn, doc); err != nil {
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(classifyError(err)), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
raw, err := readOPMsg(conn)
|
|
|
|
|
if err != nil {
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(classifyError(err)), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
ok, errmsg := parseBSONFields(raw)
|
|
|
|
|
switch {
|
|
|
|
|
case ok == 1 && errmsg == "":
|
2026-09-22 14:47:49 +02:00
|
|
|
return listRead("mongodb listDatabases succeeded without auth"), true
|
2026-09-22 14:33:03 +02:00
|
|
|
case strings.Contains(strings.ToLower(errmsg), "auth") || strings.Contains(strings.ToLower(errmsg), "unauthorized"):
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(model.ExposureAuthRequired, "mongodb requires authentication"), true
|
2026-09-22 14:33:03 +02:00
|
|
|
default:
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(model.ExposureReachable, "mongodb replied but auth state unclear"), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 14:47:49 +02:00
|
|
|
func (p *Prober) probeHTTPReadable(ctx context.Context, endpoint string) (Result, bool) {
|
2026-09-22 14:33:03 +02:00
|
|
|
status, _, err := p.do(ctx, endpoint)
|
|
|
|
|
if err != nil {
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(classifyError(err)), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
2026-09-22 14:47:49 +02:00
|
|
|
return wrap(classifyStatus(status, "")), true
|
2026-09-22 14:33:03 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- minimal BSON/OP_MSG helpers (read-only) ---
|
|
|
|
|
|
|
|
|
|
type bsonDoc map[string]any
|
|
|
|
|
|
|
|
|
|
func appendCString(b []byte, s string) []byte {
|
|
|
|
|
b = append(b, s...)
|
|
|
|
|
return append(b, 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func encodeBSON(doc bsonDoc) []byte {
|
|
|
|
|
var body []byte
|
|
|
|
|
for k, v := range doc {
|
|
|
|
|
switch val := v.(type) {
|
|
|
|
|
case int32:
|
|
|
|
|
body = append(body, 0x10)
|
|
|
|
|
body = appendCString(body, k)
|
|
|
|
|
var tmp [4]byte
|
|
|
|
|
binary.LittleEndian.PutUint32(tmp[:], uint32(val))
|
|
|
|
|
body = append(body, tmp[:]...)
|
|
|
|
|
case string:
|
|
|
|
|
body = append(body, 0x02)
|
|
|
|
|
body = appendCString(body, k)
|
|
|
|
|
var tmp [4]byte
|
|
|
|
|
binary.LittleEndian.PutUint32(tmp[:], uint32(len(val)+1))
|
|
|
|
|
body = append(body, tmp[:]...)
|
|
|
|
|
body = appendCString(body, val)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out := make([]byte, 4, 4+len(body)+1)
|
|
|
|
|
binary.LittleEndian.PutUint32(out, uint32(4+len(body)+1))
|
|
|
|
|
out = append(out, body...)
|
|
|
|
|
out = append(out, 0)
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeOPMsg(w io.Writer, doc bsonDoc) error {
|
|
|
|
|
bson := encodeBSON(doc)
|
|
|
|
|
msg := make([]byte, 0, 16+4+1+len(bson))
|
|
|
|
|
var hdr [16]byte
|
|
|
|
|
binary.LittleEndian.PutUint32(hdr[0:], uint32(16+4+1+len(bson))) // messageLength
|
|
|
|
|
binary.LittleEndian.PutUint32(hdr[4:], 1) // requestID
|
|
|
|
|
binary.LittleEndian.PutUint32(hdr[8:], 0) // responseTo
|
|
|
|
|
binary.LittleEndian.PutUint32(hdr[12:], 2013) // opCode OP_MSG
|
|
|
|
|
msg = append(msg, hdr[:]...)
|
|
|
|
|
msg = append(msg, 0, 0, 0, 0) // flagBits
|
|
|
|
|
msg = append(msg, 0) // section kind 0
|
|
|
|
|
msg = append(msg, bson...)
|
|
|
|
|
_, err := w.Write(msg)
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func readOPMsg(r io.Reader) ([]byte, error) {
|
|
|
|
|
hdr := make([]byte, 16)
|
|
|
|
|
if _, err := io.ReadFull(r, hdr); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
length := int(binary.LittleEndian.Uint32(hdr[0:]))
|
|
|
|
|
if length < 21 || length > 8<<20 {
|
|
|
|
|
return nil, fmt.Errorf("mongo: bad message length %d", length)
|
|
|
|
|
}
|
|
|
|
|
body := make([]byte, length-16)
|
|
|
|
|
if _, err := io.ReadFull(r, body); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return body, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseBSONFields extracts the top-level "ok" (double/int) and "errmsg" fields.
|
|
|
|
|
func parseBSONFields(b []byte) (float64, string) {
|
|
|
|
|
if len(b) < 5 {
|
|
|
|
|
return 0, ""
|
|
|
|
|
}
|
|
|
|
|
// body: flagBits(4) sectionKind(1) [doc] or [kind 1 + size + id + doc]
|
|
|
|
|
p := 4
|
|
|
|
|
if p < len(b) && b[p] == 1 && p+5 <= len(b) {
|
|
|
|
|
p++ // section kind 1
|
|
|
|
|
sz := int(binary.LittleEndian.Uint32(b[p:]))
|
|
|
|
|
p += 4 + sz // skip identifier
|
|
|
|
|
} else if p < len(b) && b[p] == 0 {
|
|
|
|
|
p++
|
|
|
|
|
}
|
|
|
|
|
if p >= len(b) {
|
|
|
|
|
return 0, ""
|
|
|
|
|
}
|
|
|
|
|
docLen := int(int32(binary.LittleEndian.Uint32(b[p:])))
|
|
|
|
|
if p+docLen > len(b) {
|
|
|
|
|
docLen = len(b) - p
|
|
|
|
|
}
|
|
|
|
|
doc := b[p : p+docLen]
|
|
|
|
|
var ok float64
|
|
|
|
|
var errmsg string
|
|
|
|
|
i := 4
|
|
|
|
|
for i < len(doc)-1 {
|
|
|
|
|
typ := doc[i]
|
|
|
|
|
i++
|
|
|
|
|
start := i
|
|
|
|
|
for i < len(doc) && doc[i] != 0 {
|
|
|
|
|
i++
|
|
|
|
|
}
|
|
|
|
|
name := string(doc[start:i])
|
|
|
|
|
i++ // skip null
|
|
|
|
|
switch typ {
|
|
|
|
|
case 0x01: // double
|
|
|
|
|
if i+8 <= len(doc) {
|
|
|
|
|
bits := binary.LittleEndian.Uint64(doc[i:])
|
|
|
|
|
if name == "ok" {
|
|
|
|
|
ok = math.Float64frombits(bits)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
i += 8
|
|
|
|
|
case 0x10: // int32
|
|
|
|
|
if i+4 <= len(doc) {
|
|
|
|
|
v := int32(binary.LittleEndian.Uint32(doc[i:]))
|
|
|
|
|
if name == "ok" {
|
|
|
|
|
ok = float64(v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
i += 4
|
|
|
|
|
case 0x02: // string
|
|
|
|
|
if i+4 <= len(doc) {
|
|
|
|
|
n := int(int32(binary.LittleEndian.Uint32(doc[i:])))
|
|
|
|
|
i += 4
|
|
|
|
|
if n > 0 && i+n <= len(doc) {
|
|
|
|
|
if name == "errmsg" {
|
|
|
|
|
errmsg = string(doc[i : i+n-1])
|
|
|
|
|
}
|
|
|
|
|
i += n
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
case 0x07: // objectId
|
|
|
|
|
i += 12
|
|
|
|
|
case 0x08: // bool
|
|
|
|
|
i++
|
|
|
|
|
case 0x03, 0x04: // document/array
|
|
|
|
|
if i+4 <= len(doc) {
|
|
|
|
|
n := int(int32(binary.LittleEndian.Uint32(doc[i:])))
|
|
|
|
|
i += n
|
|
|
|
|
}
|
|
|
|
|
case 0x09: // datetime
|
|
|
|
|
i += 8
|
|
|
|
|
case 0x0A: // null
|
|
|
|
|
case 0x11: // timestamp
|
|
|
|
|
i += 8
|
|
|
|
|
default:
|
|
|
|
|
// Unknown type: abandon parsing to avoid mis-stepping.
|
|
|
|
|
return ok, errmsg
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ok, errmsg
|
|
|
|
|
}
|