Skip to content

Commit 2ee0783

Browse files
committed
feat: add host, db and username to ExtraData for database detectors
Populate ExtraData with parsed fields for all database connection string detectors (MongoDB, PostgreSQL, Redis, JDBC). This surfaces useful metadata about detected credentials. The parsing logic already existed in each detector — this change exposes the extracted values in the result's ExtraData map alongside any pre-existing fields (rotation_guide, sslmode, etc.).
1 parent 6171fa9 commit 2ee0783

File tree

8 files changed

+354
-25
lines changed

8 files changed

+354
-25
lines changed

pkg/detectors/jdbc/jdbc.go

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -85,27 +85,42 @@ matchLoop:
8585
Redacted: tryRedactAnonymousJDBC(jdbcConn),
8686
}
8787

88-
if verify {
89-
j, err := NewJDBC(logCtx, jdbcConn)
90-
if err != nil {
91-
continue
88+
// Try to parse connection info for ExtraData regardless of verification.
89+
if j, parseErr := NewJDBC(logCtx, jdbcConn); parseErr == nil {
90+
if info := j.GetConnectionInfo(); info != nil {
91+
extraData := make(map[string]string)
92+
if info.Host != "" {
93+
extraData["host"] = info.Host
94+
}
95+
if info.User != "" {
96+
extraData["username"] = info.User
97+
}
98+
if info.Database != "" {
99+
extraData["database"] = info.Database
100+
}
101+
if len(extraData) > 0 {
102+
result.ExtraData = extraData
103+
}
92104
}
93105

94-
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
95-
defer cancel()
96-
pingRes := j.ping(ctx)
97-
result.Verified = pingRes.err == nil
98-
// If there's a ping error that is marked as "determinate" we throw it away. We do this because this was the
99-
// behavior before tri-state verification was introduced and preserving it allows us to gradually migrate
100-
// detectors to use tri-state verification.
101-
if pingRes.err != nil && !pingRes.determinate {
102-
err = pingRes.err
103-
result.SetVerificationError(err, jdbcConn)
104-
}
105-
result.AnalysisInfo = map[string]string{
106-
"connection_string": jdbcConn,
106+
if verify {
107+
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
108+
defer cancel()
109+
pingRes := j.ping(ctx)
110+
result.Verified = pingRes.err == nil
111+
// If there's a ping error that is marked as "determinate" we throw it away. We do this because this was the
112+
// behavior before tri-state verification was introduced and preserving it allows us to gradually migrate
113+
// detectors to use tri-state verification.
114+
if pingRes.err != nil && !pingRes.determinate {
115+
result.SetVerificationError(pingRes.err, jdbcConn)
116+
}
117+
result.AnalysisInfo = map[string]string{
118+
"connection_string": jdbcConn,
119+
}
120+
// TODO: specialized redaction
107121
}
108-
// TODO: specialized redaction
122+
} else if verify {
123+
continue
109124
}
110125

111126
results = append(results, result)

pkg/detectors/jdbc/jdbc_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,92 @@ func TestJdbc_Pattern(t *testing.T) {
151151
}
152152
}
153153

154+
func TestJdbc_ExtraData(t *testing.T) {
155+
tests := []struct {
156+
name string
157+
data string
158+
wantHost string
159+
wantUsername string
160+
wantDatabase string
161+
}{
162+
{
163+
name: "mysql with basic auth",
164+
data: `jdbc:mysql://root:password@localhost:3306/testdb`,
165+
wantHost: "tcp(localhost:3306)",
166+
wantUsername: "root",
167+
wantDatabase: "testdb",
168+
},
169+
{
170+
name: "postgresql with basic auth",
171+
data: `jdbc:postgresql://postgres:secret@dbhost:5432/mydb`,
172+
wantHost: "dbhost:5432",
173+
wantUsername: "postgres",
174+
wantDatabase: "mydb",
175+
},
176+
{
177+
name: "sqlserver with semicolon params",
178+
data: `jdbc:sqlserver://server.example.com:1433;database=testdb;user=sa;password=Pass123`,
179+
wantHost: "server.example.com:1433",
180+
wantUsername: "sa",
181+
wantDatabase: "testdb",
182+
},
183+
{
184+
name: "mysql with query params for credentials",
185+
data: `jdbc:mysql://dbhost:3307/testdb?user=admin&password=secret`,
186+
wantHost: "tcp(dbhost:3307)",
187+
wantUsername: "admin",
188+
wantDatabase: "testdb",
189+
},
190+
{
191+
name: "postgresql with query params for credentials",
192+
data: `jdbc:postgresql://localhost:1521/testdb?sslmode=disable&password=testpassword&user=testuser`,
193+
wantHost: "localhost:1521",
194+
wantUsername: "testuser",
195+
wantDatabase: "testdb",
196+
},
197+
}
198+
199+
for _, tt := range tests {
200+
t.Run(tt.name, func(t *testing.T) {
201+
s := Scanner{}
202+
results, err := s.FromData(context.Background(), false, []byte(tt.data))
203+
if err != nil {
204+
t.Fatalf("FromData() error = %v", err)
205+
}
206+
if len(results) == 0 {
207+
t.Fatal("expected at least one result")
208+
}
209+
r := results[0]
210+
if got := r.ExtraData["host"]; got != tt.wantHost {
211+
t.Errorf("ExtraData[host] = %q, want %q", got, tt.wantHost)
212+
}
213+
if got := r.ExtraData["username"]; got != tt.wantUsername {
214+
t.Errorf("ExtraData[username] = %q, want %q", got, tt.wantUsername)
215+
}
216+
if got := r.ExtraData["database"]; got != tt.wantDatabase {
217+
t.Errorf("ExtraData[database] = %q, want %q", got, tt.wantDatabase)
218+
}
219+
})
220+
}
221+
}
222+
223+
func TestJdbc_ExtraData_UnsupportedSubprotocol(t *testing.T) {
224+
// For unsupported subprotocols (e.g., sqlite), ExtraData should be nil
225+
// because we can't parse connection info, but the result should still be returned.
226+
s := Scanner{}
227+
results, err := s.FromData(context.Background(), false, []byte(`jdbc:sqlite:/data/test.db`))
228+
if err != nil {
229+
t.Fatalf("FromData() error = %v", err)
230+
}
231+
if len(results) == 0 {
232+
t.Fatal("expected at least one result")
233+
}
234+
r := results[0]
235+
if r.ExtraData != nil {
236+
t.Errorf("expected nil ExtraData for unsupported subprotocol, got %v", r.ExtraData)
237+
}
238+
}
239+
154240
func TestJdbc_FromDataWithIgnorePattern(t *testing.T) {
155241
type args struct {
156242
ctx context.Context

pkg/detectors/mongodb/mongodb.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
4545
logger := logContext.AddLogger(ctx).Logger().WithName("mongodb")
4646
dataStr := string(data)
4747

48-
uniqueMatches := make(map[string]string)
48+
type mongoMatch struct {
49+
password string
50+
parsedURL *url.URL
51+
}
52+
uniqueMatches := make(map[string]mongoMatch)
4953
for _, match := range connStrPat.FindAllStringSubmatch(dataStr, -1) {
5054
// Filter out common placeholder passwords.
5155
password := match[3]
@@ -78,16 +82,27 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
7882
connUrl.RawQuery = params.Encode()
7983
connStr = connUrl.String()
8084

81-
uniqueMatches[connStr] = password
85+
uniqueMatches[connStr] = mongoMatch{password: password, parsedURL: connUrl}
8286
}
8387

84-
for connStr, password := range uniqueMatches {
88+
for connStr, m := range uniqueMatches {
89+
extraData := map[string]string{
90+
"rotation_guide": "https://howtorotate.com/docs/tutorials/mongo/",
91+
}
92+
if m.parsedURL.Host != "" {
93+
extraData["host"] = m.parsedURL.Host
94+
}
95+
if m.parsedURL.User != nil && m.parsedURL.User.Username() != "" {
96+
extraData["username"] = m.parsedURL.User.Username()
97+
}
98+
if db := strings.TrimPrefix(m.parsedURL.Path, "/"); db != "" {
99+
extraData["database"] = db
100+
}
101+
85102
r := detectors.Result{
86103
DetectorType: detectorspb.DetectorType_MongoDB,
87104
Raw: []byte(connStr),
88-
ExtraData: map[string]string{
89-
"rotation_guide": "https://howtorotate.com/docs/tutorials/mongo/",
90-
},
105+
ExtraData: extraData,
91106
}
92107

93108
if verify {
@@ -101,7 +116,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
101116
if isErrDeterminate(vErr) {
102117
continue
103118
}
104-
r.SetVerificationError(vErr, password)
119+
r.SetVerificationError(vErr, m.password)
105120

106121
if isVerified {
107122
r.AnalysisInfo = map[string]string{

pkg/detectors/mongodb/mongodb_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,87 @@ import (
55
"testing"
66
)
77

8+
func TestMongoDB_ExtraData(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
data string
12+
wantHost string
13+
wantUsername string
14+
wantDatabase string
15+
}{
16+
{
17+
name: "single host with port",
18+
data: `mongodb://myDBReader:D1fficultP%40ssw0rd@mongodb0.example.com:27017`,
19+
wantHost: "mongodb0.example.com:27017",
20+
wantUsername: "myDBReader",
21+
},
22+
{
23+
name: "single host without port",
24+
data: `mongodb://myDBReader:D1fficultP%40ssw0rd@mongodb0.example.com`,
25+
wantHost: "mongodb0.example.com",
26+
wantUsername: "myDBReader",
27+
},
28+
{
29+
name: "with options and no database",
30+
data: `mongodb://username:password@host.docker.internal:27018/?authMechanism=PLAIN&tls=true`,
31+
wantHost: "host.docker.internal:27018",
32+
wantUsername: "username",
33+
},
34+
{
35+
name: "cosmos db style with database",
36+
data: `mongodb://agenda-live:m21w7PFfRXQwfHZU1Fgx0rTX29ZBQaWMODLeAjsmyslVcMmcmy6CnLyu3byVDtdLYcCokze8lIE4KyAgSCGZxQ==@agenda-live.mongo.cosmos.azure.com:10255/csb-db?retryWrites=false&ssl=true&replicaSet=globaldb&maxIdleTimeMS=120000&appName=@agenda-live@`,
37+
wantHost: "agenda-live.mongo.cosmos.azure.com:10255",
38+
wantUsername: "agenda-live",
39+
wantDatabase: "csb-db",
40+
},
41+
{
42+
name: "with database in path",
43+
data: `mongodb://db-user:db-password@mongodb-instance:27017/db-name`,
44+
wantHost: "mongodb-instance:27017",
45+
wantUsername: "db-user",
46+
wantDatabase: "db-name",
47+
},
48+
}
49+
50+
for _, tt := range tests {
51+
t.Run(tt.name, func(t *testing.T) {
52+
s := Scanner{}
53+
results, err := s.FromData(context.Background(), false, []byte(tt.data))
54+
if err != nil {
55+
t.Fatalf("FromData() error = %v", err)
56+
}
57+
if len(results) == 0 {
58+
t.Fatal("expected at least one result")
59+
}
60+
r := results[0]
61+
if got := r.ExtraData["host"]; got != tt.wantHost {
62+
t.Errorf("ExtraData[host] = %q, want %q", got, tt.wantHost)
63+
}
64+
if tt.wantUsername != "" {
65+
if got := r.ExtraData["username"]; got != tt.wantUsername {
66+
t.Errorf("ExtraData[username] = %q, want %q", got, tt.wantUsername)
67+
}
68+
} else {
69+
if got, ok := r.ExtraData["username"]; ok {
70+
t.Errorf("ExtraData[username] should be absent, got %q", got)
71+
}
72+
}
73+
if tt.wantDatabase != "" {
74+
if got := r.ExtraData["database"]; got != tt.wantDatabase {
75+
t.Errorf("ExtraData[database] = %q, want %q", got, tt.wantDatabase)
76+
}
77+
} else {
78+
if got, ok := r.ExtraData["database"]; ok {
79+
t.Errorf("ExtraData[database] should be absent, got %q", got)
80+
}
81+
}
82+
if got := r.ExtraData["rotation_guide"]; got == "" {
83+
t.Error("ExtraData[rotation_guide] should still be present")
84+
}
85+
})
86+
}
87+
}
88+
889
func TestMongoDB_Pattern(t *testing.T) {
990
tests := []struct {
1091
name string

pkg/detectors/postgres/postgres.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,15 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) ([]dete
177177
result.ExtraData = map[string]string{
178178
pgSslmode: sslmode,
179179
}
180+
if host != "" {
181+
result.ExtraData["host"] = host
182+
}
183+
if user != "" {
184+
result.ExtraData["username"] = user
185+
}
186+
if dbname := params[pgDbname]; dbname != "" {
187+
result.ExtraData["database"] = dbname
188+
}
180189

181190
results = append(results, result)
182191
}

pkg/detectors/postgres/postgres_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,63 @@ func TestPostgres_Pattern(t *testing.T) {
8282
}
8383
}
8484

85+
func TestPostgres_ExtraData(t *testing.T) {
86+
tests := []struct {
87+
name string
88+
data string
89+
wantHost string
90+
wantUsername string
91+
wantDatabase string
92+
}{
93+
{
94+
name: "standard URI with database",
95+
data: "postgres://myuser:mypass@dbhost.example.com:5432/mydb",
96+
wantHost: "dbhost.example.com",
97+
wantUsername: "myuser",
98+
wantDatabase: "mydb",
99+
},
100+
{
101+
name: "postgresql scheme",
102+
data: "postgresql://admin:secret@10.0.0.1:5433/production",
103+
wantHost: "10.0.0.1",
104+
wantUsername: "admin",
105+
wantDatabase: "production",
106+
},
107+
{
108+
name: "without database",
109+
data: "postgres://sN19x:d7N8bs@1.2.3.4:5432?sslmode=require",
110+
wantHost: "1.2.3.4",
111+
wantUsername: "sN19x",
112+
},
113+
}
114+
115+
for _, tt := range tests {
116+
t.Run(tt.name, func(t *testing.T) {
117+
s := Scanner{detectLoopback: true}
118+
results, err := s.FromData(context.Background(), false, []byte(tt.data))
119+
if err != nil {
120+
t.Fatalf("FromData() error = %v", err)
121+
}
122+
if len(results) == 0 {
123+
t.Fatal("expected at least one result")
124+
}
125+
r := results[0]
126+
if got := r.ExtraData["host"]; got != tt.wantHost {
127+
t.Errorf("ExtraData[host] = %q, want %q", got, tt.wantHost)
128+
}
129+
if got := r.ExtraData["username"]; got != tt.wantUsername {
130+
t.Errorf("ExtraData[username] = %q, want %q", got, tt.wantUsername)
131+
}
132+
if got := r.ExtraData["database"]; got != tt.wantDatabase {
133+
t.Errorf("ExtraData[database] = %q, want %q", got, tt.wantDatabase)
134+
}
135+
if _, ok := r.ExtraData["sslmode"]; !ok {
136+
t.Error("ExtraData[sslmode] should still be present")
137+
}
138+
})
139+
}
140+
}
141+
85142
func TestPostgres_FromDataWithIgnorePattern(t *testing.T) {
86143
s := New(
87144
WithIgnorePattern([]string{

0 commit comments

Comments
 (0)