Skip to content

Commit 39d31a7

Browse files
kamirclaude
andcommitted
feat: add connector config probe (SPEC-002) + SSRF protection + AI prompt improvements
Connector Probe: - Probe MongoDB, DB2, PostgreSQL targets via Kafka Connect REST API or local JSON config - MongoDB probe (mongo-driver), PostgreSQL probe (pgx), DB2 probe (custom DRDA wire protocol) - JDBC URL parsing, connector type detection, credential redaction - New CLI flags: --connect-url, --connector-name, --connector-config, --connect-basic-auth - Env var fallback: KSHARK_CONNECT_AUTH, KSHARK_CONNECT_TOKEN SSRF Protection: - Two-tier model: DENY (loopback, link-local, cloud metadata) + WARN (RFC1918 for PrivateLink) - Applied to Schema Registry, REST Proxy, and Connect API client - CheckRedirect handler blocks redirect-based SSRF bypass AI Analysis (SPEC-001): - Layered reasoning in system prompt (DNS/TCP/TLS/Kafka failure patterns) - Enhanced JSON schema: likelyCategory, confidence, severity, evidence - sasl.jaas.config now redacted (was leaking credentials) 64 tests across 3 packages, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2763881 commit 39d31a7

19 files changed

Lines changed: 3223 additions & 28 deletions

cmd/kshark/main.go

Lines changed: 392 additions & 22 deletions
Large diffs are not rendered by default.

cmd/kshark/ssrf_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package main
2+
3+
import (
4+
"net"
5+
"testing"
6+
)
7+
8+
func TestClassifyIP(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
ip string
12+
want ssrfVerdict
13+
}{
14+
// DENY (always blocked)
15+
{"loopback v4", "127.0.0.1", ssrfDeny},
16+
{"loopback v4 high", "127.255.255.254", ssrfDeny},
17+
{"loopback v6", "::1", ssrfDeny},
18+
{"link-local", "169.254.1.1", ssrfDeny},
19+
{"cloud metadata", "169.254.169.254", ssrfDeny},
20+
{"link-local v6", "fe80::1", ssrfDeny},
21+
{"CGN", "100.64.0.1", ssrfDeny},
22+
{"TEST-NET-1", "192.0.2.1", ssrfDeny},
23+
{"multicast", "224.0.0.1", ssrfDeny},
24+
{"reserved", "240.0.0.1", ssrfDeny},
25+
{"broadcast", "255.255.255.255", ssrfDeny},
26+
27+
// WARN (RFC1918 — allowed for PrivateLink)
28+
{"10/8", "10.0.0.1", ssrfWarn},
29+
{"172.16/12", "172.16.0.1", ssrfWarn},
30+
{"192.168/16", "192.168.1.1", ssrfWarn},
31+
{"ULA v6", "fc00::1", ssrfWarn},
32+
33+
// ALLOW (public)
34+
{"google dns", "8.8.8.8", ssrfAllow},
35+
{"cloudflare", "1.1.1.1", ssrfAllow},
36+
{"public v6", "2001:4860:4860::8888", ssrfAllow},
37+
{"172.32 outside /12", "172.32.0.1", ssrfAllow},
38+
}
39+
40+
for _, tt := range tests {
41+
t.Run(tt.name, func(t *testing.T) {
42+
ip := net.ParseIP(tt.ip)
43+
if ip == nil {
44+
t.Fatalf("bad test IP: %s", tt.ip)
45+
}
46+
got := classifyIP(ip)
47+
if got != tt.want {
48+
t.Errorf("classifyIP(%s) = %d, want %d", tt.ip, got, tt.want)
49+
}
50+
})
51+
}
52+
}
53+
54+
func TestIsAllowedURL_Blocked(t *testing.T) {
55+
blocked := []struct {
56+
name string
57+
url string
58+
}{
59+
{"loopback", "http://127.0.0.1:8081"},
60+
{"cloud metadata", "http://169.254.169.254/latest/meta-data/"},
61+
{"ftp scheme", "ftp://example.com/subjects"},
62+
{"embedded creds", "http://user:pass@registry.example.com:8081"},
63+
}
64+
65+
for _, tt := range blocked {
66+
t.Run(tt.name, func(t *testing.T) {
67+
_, err := isAllowedURL(tt.url)
68+
if err == nil {
69+
t.Errorf("isAllowedURL(%q) should have been blocked", tt.url)
70+
}
71+
})
72+
}
73+
}
74+
75+
func TestIsAllowedURL_RFC1918_Warns(t *testing.T) {
76+
warned := []string{
77+
"https://10.0.0.5:8081/subjects",
78+
"https://172.16.0.1:8081/subjects",
79+
"https://192.168.1.100:8081/subjects",
80+
}
81+
82+
for _, u := range warned {
83+
t.Run(u, func(t *testing.T) {
84+
warning, err := isAllowedURL(u)
85+
if err != nil {
86+
t.Errorf("isAllowedURL(%q) unexpected error: %v", u, err)
87+
}
88+
if warning == "" {
89+
t.Errorf("isAllowedURL(%q) should have returned a warning", u)
90+
}
91+
})
92+
}
93+
}

docs/RELEASE.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,21 @@
55
### Unreleased
66

77
#### Features
8+
- **Connector Config Probe (SPEC-002):** Probe MongoDB, DB2, and PostgreSQL targets by reading connector configs from Kafka Connect REST API or local JSON files. Layered diagnostics (DNS -> TCP -> TLS -> Application Protocol) with actionable hints.
9+
- New flags: `--connect-url`, `--connector-name`, `--connector-config`, `--connect-basic-auth`, `--connect-bearer-token`, `--connect-ca-cert`
10+
- Environment variable fallback: `KSHARK_CONNECT_AUTH`, `KSHARK_CONNECT_TOKEN`
11+
- MongoDB probe via official driver (SRV, SCRAM-SHA-256, TLS, ping, collection check)
12+
- PostgreSQL probe via pgx (wire protocol v3, all auth mechanisms)
13+
- DB2 probe via custom DRDA wire protocol (EXCSAT/ACCSEC/SECCHK/ACCRDB)
14+
- JDBC URL parsing for DB2 and PostgreSQL connector configs
15+
- Connector-only mode: run without `-props` when only probing connector targets
816
- **JAAS config credential extraction:** kshark now automatically extracts `username` and `password` from `sasl.jaas.config` when `sasl.username` / `sasl.password` are not explicitly set. This makes kshark compatible with standard Java Kafka client properties files that only use `sasl.jaas.config` for authentication (e.g., files generated by Confluent Cloud).
17+
- **Improved AI Analysis Prompt (SPEC-001):** Layered reasoning hints, enhanced JSON schema with `likelyCategory`, `confidence`, `severity`, and `evidence` fields. Defensive re-redaction before AI export.
18+
19+
#### Security
20+
- **SSRF Protection:** Two-tier SSRF validation for Schema Registry, REST Proxy, and Connect API. Blocks loopback/link-local/cloud metadata (DENY). Warns on RFC1918 private IPs (WARN) since PrivateLink targets are common. Redirect-based SSRF bypass prevention via `CheckRedirect` handler.
21+
- **Credential Redaction:** `sasl.jaas.config` now redacted in all output (was previously leaking JAAS credentials). Added `bearer`, `token`, `credential` keyword matching. Credential scrubbing in database probe error messages.
22+
- **Connect API Security:** URL scheme validation (http/https only), response body limit (1MB), link-local IP blocking, redirect protection.
923

1024
---
1125

go.mod

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@ module github.com/your-username/kshark
22

33
go 1.23.2
44

5-
require github.com/segmentio/kafka-go v0.4.49
5+
require (
6+
github.com/jackc/pgx/v5 v5.7.4
7+
github.com/segmentio/kafka-go v0.4.49
8+
go.mongodb.org/mongo-driver/v2 v2.1.0
9+
)
610

711
require (
8-
github.com/klauspost/compress v1.15.9 // indirect
12+
github.com/golang/snappy v0.0.4 // indirect
13+
github.com/jackc/pgpassfile v1.0.0 // indirect
14+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
15+
github.com/klauspost/compress v1.16.7 // indirect
916
github.com/pierrec/lz4/v4 v4.1.15 // indirect
1017
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
1118
github.com/xdg-go/scram v1.1.2 // indirect
1219
github.com/xdg-go/stringprep v1.0.4 // indirect
20+
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
21+
golang.org/x/crypto v0.33.0 // indirect
22+
golang.org/x/sync v0.12.0 // indirect
1323
golang.org/x/text v0.23.0 // indirect
1424
)

go.sum

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,46 @@
1+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
23
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3-
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
4-
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
4+
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
5+
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
6+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
7+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
8+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
9+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
10+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
11+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
12+
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
13+
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
14+
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
15+
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
16+
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
17+
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
518
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
619
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
720
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
821
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
922
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
1023
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
11-
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
12-
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
24+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
25+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
26+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
27+
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
28+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
1329
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
1430
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
1531
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
1632
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
1733
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
1834
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
35+
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
36+
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
1937
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
38+
go.mongodb.org/mongo-driver/v2 v2.1.0 h1:/ELnVNjmfUKDsoBisXxuJL0noR9CfeUIrP7Yt3R+egg=
39+
go.mongodb.org/mongo-driver/v2 v2.1.0/go.mod h1:AWiLRShSrk5RHQS3AEn3RL19rqOzVq49MCpWQ3x/huI=
2040
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
2141
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
42+
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
43+
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
2244
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
2345
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
2446
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -27,6 +49,8 @@ golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
2749
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
2850
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
2951
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
52+
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
53+
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
3054
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
3155
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
3256
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -44,5 +68,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
4468
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
4569
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
4670
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
71+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
72+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
4773
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
4874
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/connectapi/client.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package connectapi
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"crypto/x509"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net"
11+
"net/http"
12+
"net/url"
13+
"os"
14+
"strings"
15+
"time"
16+
)
17+
18+
// ConnectAuthOpts holds authentication options for the Kafka Connect REST API.
19+
type ConnectAuthOpts struct {
20+
BasicAuth string // "user:password" or empty
21+
BearerToken string // bearer token or empty
22+
CACertPath string // path to CA cert PEM file or empty
23+
}
24+
25+
// ConnectClient is an HTTP client for the Kafka Connect REST API.
26+
type ConnectClient struct {
27+
baseURL string
28+
httpClient *http.Client
29+
auth ConnectAuthOpts
30+
}
31+
32+
// NewConnectClient creates a new ConnectClient.
33+
func NewConnectClient(baseURL string, auth ConnectAuthOpts) (*ConnectClient, error) {
34+
// Validate URL scheme
35+
u, err := url.Parse(baseURL)
36+
if err != nil {
37+
return nil, fmt.Errorf("invalid Connect URL: %w", err)
38+
}
39+
if u.Scheme != "http" && u.Scheme != "https" {
40+
return nil, fmt.Errorf("Connect URL must use http or https scheme, got: %s", u.Scheme)
41+
}
42+
43+
transport := http.DefaultTransport.(*http.Transport).Clone()
44+
45+
if auth.CACertPath != "" {
46+
caPEM, err := os.ReadFile(auth.CACertPath)
47+
if err != nil {
48+
return nil, fmt.Errorf("failed to read Connect CA cert %s: %w", auth.CACertPath, err)
49+
}
50+
pool := x509.NewCertPool()
51+
if !pool.AppendCertsFromPEM(caPEM) {
52+
return nil, fmt.Errorf("failed to parse Connect CA cert from %s", auth.CACertPath)
53+
}
54+
transport.TLSClientConfig = &tls.Config{
55+
RootCAs: pool,
56+
}
57+
}
58+
59+
return &ConnectClient{
60+
baseURL: strings.TrimRight(baseURL, "/"),
61+
httpClient: &http.Client{
62+
Timeout: 15 * time.Second,
63+
Transport: transport,
64+
// Prevent redirect-based SSRF: do not follow redirects automatically.
65+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
66+
return fmt.Errorf("Connect API redirect blocked (to %s): redirects are not followed for security", req.URL.Host)
67+
},
68+
},
69+
auth: auth,
70+
}, nil
71+
}
72+
73+
// isLinkLocalIP checks if an IP is in the link-local range (169.254.0.0/16)
74+
// which includes the cloud metadata endpoint 169.254.169.254.
75+
func isLinkLocalIP(host string) bool {
76+
ips, err := net.LookupHost(host)
77+
if err != nil {
78+
return false
79+
}
80+
for _, ipStr := range ips {
81+
ip := net.ParseIP(ipStr)
82+
if ip == nil {
83+
continue
84+
}
85+
// Block link-local (169.254.0.0/16) — includes cloud metadata
86+
if ip4 := ip.To4(); ip4 != nil && ip4[0] == 169 && ip4[1] == 254 {
87+
return true
88+
}
89+
}
90+
return false
91+
}
92+
93+
// GetConnectorConfig fetches the configuration for a named connector.
94+
func (c *ConnectClient) GetConnectorConfig(ctx context.Context, name string) (map[string]string, error) {
95+
// SSRF protection: block link-local addresses (cloud metadata)
96+
u, _ := url.Parse(c.baseURL)
97+
if u != nil && isLinkLocalIP(u.Hostname()) {
98+
return nil, fmt.Errorf("Connect URL resolves to link-local address (blocked for security)")
99+
}
100+
101+
reqURL := fmt.Sprintf("%s/connectors/%s/config", c.baseURL, url.PathEscape(name))
102+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
103+
if err != nil {
104+
return nil, fmt.Errorf("failed to create request: %w", err)
105+
}
106+
107+
req.Header.Set("Accept", "application/json")
108+
c.applyAuth(req)
109+
110+
resp, err := c.httpClient.Do(req)
111+
if err != nil {
112+
return nil, fmt.Errorf("Connect API unreachable: %w", err)
113+
}
114+
defer resp.Body.Close()
115+
116+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // limit to 1MB
117+
118+
switch {
119+
case resp.StatusCode == http.StatusOK:
120+
var cfg map[string]string
121+
if err := json.Unmarshal(body, &cfg); err != nil {
122+
return nil, fmt.Errorf("failed to decode connector config: %w", err)
123+
}
124+
return cfg, nil
125+
126+
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
127+
return nil, fmt.Errorf("Connect API auth failed (HTTP %d): check --connect-basic-auth or --connect-bearer-token", resp.StatusCode)
128+
129+
case resp.StatusCode == http.StatusNotFound:
130+
return nil, fmt.Errorf("connector '%s' not found. Use GET /connectors to list available connectors", name)
131+
132+
default:
133+
return nil, fmt.Errorf("Connect API returned HTTP %d: %s", resp.StatusCode, string(body))
134+
}
135+
}
136+
137+
// ListConnectors returns the names of all connectors.
138+
func (c *ConnectClient) ListConnectors(ctx context.Context) ([]string, error) {
139+
reqURL := fmt.Sprintf("%s/connectors", c.baseURL)
140+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
141+
if err != nil {
142+
return nil, err
143+
}
144+
145+
req.Header.Set("Accept", "application/json")
146+
c.applyAuth(req)
147+
148+
resp, err := c.httpClient.Do(req)
149+
if err != nil {
150+
return nil, fmt.Errorf("Connect API unreachable: %w", err)
151+
}
152+
defer resp.Body.Close()
153+
154+
if resp.StatusCode != http.StatusOK {
155+
return nil, fmt.Errorf("Connect API returned HTTP %d", resp.StatusCode)
156+
}
157+
158+
var names []string
159+
if err := json.NewDecoder(resp.Body).Decode(&names); err != nil {
160+
return nil, fmt.Errorf("failed to decode connector list: %w", err)
161+
}
162+
return names, nil
163+
}
164+
165+
func (c *ConnectClient) applyAuth(req *http.Request) {
166+
if c.auth.BasicAuth != "" {
167+
parts := strings.SplitN(c.auth.BasicAuth, ":", 2)
168+
if len(parts) == 2 {
169+
req.SetBasicAuth(parts[0], parts[1])
170+
}
171+
}
172+
if c.auth.BearerToken != "" {
173+
req.Header.Set("Authorization", "Bearer "+c.auth.BearerToken)
174+
}
175+
}

0 commit comments

Comments
 (0)