From 4e02afb0159d0de9be6ceaa2064177f1b16f5033 Mon Sep 17 00:00:00 2001 From: Mustansir Date: Fri, 9 Jan 2026 12:40:34 +0500 Subject: [PATCH] [INS-170] Unify JDBC URL Parsing Across Detector and Analyzer (Continued) (#4606) * changes to unify with analyzer --- pkg/detectors/jdbc/jdbc.go | 34 +--- pkg/detectors/jdbc/jdbc_test.go | 46 ++++++ pkg/detectors/jdbc/models.go | 56 +++++++ pkg/detectors/jdbc/mysql.go | 22 ++- pkg/detectors/jdbc/mysql_integration_test.go | 2 +- pkg/detectors/jdbc/mysql_test.go | 147 +++++++++++++++++- pkg/detectors/jdbc/postgres.go | 22 ++- .../jdbc/postgres_integration_test.go | 2 +- pkg/detectors/jdbc/postgres_test.go | 144 ++++++++++++++++- pkg/detectors/jdbc/sqlserver.go | 20 ++- pkg/detectors/jdbc/sqlserver_test.go | 109 ++++++++++++- 11 files changed, 559 insertions(+), 45 deletions(-) create mode 100644 pkg/detectors/jdbc/models.go diff --git a/pkg/detectors/jdbc/jdbc.go b/pkg/detectors/jdbc/jdbc.go index 173665faf..a424d7072 100644 --- a/pkg/detectors/jdbc/jdbc.go +++ b/pkg/detectors/jdbc/jdbc.go @@ -83,7 +83,7 @@ matchLoop: } if verify { - j, err := newJDBC(logCtx, jdbcConn) + j, err := NewJDBC(logCtx, jdbcConn) if err != nil { continue } @@ -206,31 +206,13 @@ func tryRedactRegex(conn string) (string, bool) { return newConn, true } -var supportedSubprotocols = map[string]func(logContext.Context, string) (jdbc, error){ - "mysql": ParseMySQL, - "postgresql": ParsePostgres, - "sqlserver": ParseSqlServer, +var supportedSubprotocols = map[string]func(logContext.Context, string) (JDBC, error){ + "mysql": parseMySQL, + "postgresql": parsePostgres, + "sqlserver": parseSqlServer, } -type pingResult struct { - err error - determinate bool -} - -// ConnectionInfo holds parsed connection information -type ConnectionInfo struct { - Host string // includes port if specified, e.g., "host:port" - Database string - User string - Password string - Params map[string]string -} - -type jdbc interface { - ping(context.Context) pingResult -} - -func newJDBC(ctx logContext.Context, conn string) (jdbc, error) { +func NewJDBC(ctx logContext.Context, conn string) (JDBC, error) { // expected format: "jdbc:{subprotocol}:{subname}" if !strings.HasPrefix(strings.ToLower(conn), "jdbc:") { return nil, errors.New("expected jdbc prefix") @@ -242,11 +224,11 @@ func newJDBC(ctx logContext.Context, conn string) (jdbc, error) { return nil, errors.New("expected a colon separated subprotocol and subname") } - // get the subprotocol parser parser, ok := supportedSubprotocols[strings.ToLower(subprotocol)] if !ok { - return nil, errors.New("unsupported subprotocol") + return nil, fmt.Errorf("unsupported subprotocol: %s", subprotocol) } + return parser(ctx, subname) } diff --git a/pkg/detectors/jdbc/jdbc_test.go b/pkg/detectors/jdbc/jdbc_test.go index f05ccb64c..14649c7ff 100644 --- a/pkg/detectors/jdbc/jdbc_test.go +++ b/pkg/detectors/jdbc/jdbc_test.go @@ -7,6 +7,9 @@ import ( "github.com/google/go-cmp/cmp" "github.com/kylelemons/godebug/pretty" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" "github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick" ) @@ -183,3 +186,46 @@ func TestJdbc_FromDataWithIgnorePattern(t *testing.T) { }) } } + +func TestParseJDBCURL_EdgeCases(t *testing.T) { + t.Run("MySQL with special characters in password", func(t *testing.T) { + // Special chars: @ # $ % ^ & * ( ) + jdbcURL := "jdbc:mysql://user:p@ss%23word@localhost:3306/testdb" + jdbc, err := NewJDBC(logContext.Background(), jdbcURL) + require.NoError(t, err) + + info := jdbc.GetConnectionInfo() + assert.NoError(t, err) + assert.NotNil(t, info) + assert.Equal(t, "user", info.User) + // URL encoding should be handled by url.Parse + }) + + t.Run("PostgreSQL with empty database", func(t *testing.T) { + jdbcURL := "jdbc:postgresql://user:pass@localhost:5432" + jdbc, err := NewJDBC(logContext.Background(), jdbcURL) + require.NoError(t, err) + + info := jdbc.GetConnectionInfo() + assert.Equal(t, "postgres", info.Database) // default + }) + + t.Run("SQL Server with multiple semicolon params", func(t *testing.T) { + jdbcURL := "jdbc:sqlserver://localhost:1433;database=testdb;user=sa;password=Pass123;encrypt=true;trustServerCertificate=false" + jdbc, err := NewJDBC(logContext.Background(), jdbcURL) + require.NoError(t, err) + + info := jdbc.GetConnectionInfo() + assert.Equal(t, "testdb", info.Database) + assert.Equal(t, "sa", info.User) + assert.Equal(t, "Pass123", info.Password) + }) + + t.Run("MySQL missing host", func(t *testing.T) { + // Missing // after prefix - will trigger error + jdbcURL := "jdbc:mysql:/testdb" + _, err := NewJDBC(logContext.Background(), jdbcURL) + assert.Error(t, err) + assert.Contains(t, err.Error(), "expected host to start with //") + }) +} diff --git a/pkg/detectors/jdbc/models.go b/pkg/detectors/jdbc/models.go new file mode 100644 index 000000000..90e1b8928 --- /dev/null +++ b/pkg/detectors/jdbc/models.go @@ -0,0 +1,56 @@ +package jdbc + +import ( + "context" +) + +type DatabaseType int + +const ( + Unknown DatabaseType = iota + MySQL + PostgreSQL + SQLServer +) + +func (dt DatabaseType) String() string { + switch dt { + case MySQL: + return "mysql" + case PostgreSQL: + return "postgresql" + case SQLServer: + return "sqlserver" + default: + return "unknown" + } +} + +type pingResult struct { + err error + determinate bool +} + +// ConnectionInfo holds parsed connection information +type ConnectionInfo struct { + Host string // includes port if specified, e.g., "host:port" + Database string + User string + Password string + Params map[string]string +} + +type jdbcPinger interface { + ping(context.Context) pingResult +} + +// public interfaces for analyzer +type JDBCParser interface { + GetConnectionInfo() *ConnectionInfo + GetDBType() DatabaseType + BuildConnectionString() string +} +type JDBC interface { + jdbcPinger + JDBCParser +} diff --git a/pkg/detectors/jdbc/mysql.go b/pkg/detectors/jdbc/mysql.go index 0a7148b11..de9441f1e 100644 --- a/pkg/detectors/jdbc/mysql.go +++ b/pkg/detectors/jdbc/mysql.go @@ -16,12 +16,26 @@ type MysqlJDBC struct { ConnectionInfo } +var _ JDBC = (*MysqlJDBC)(nil) + func (s *MysqlJDBC) ping(ctx context.Context) pingResult { return ping(ctx, "mysql", isMySQLErrorDeterminate, - BuildMySQLConnectionString(s.Host, "", s.User, s.Password, s.Params)) + buildMySQLConnectionString(s.Host, "", s.User, s.Password, s.Params)) } -func BuildMySQLConnectionString(host, database, user, password string, params map[string]string) string { +func (s *MysqlJDBC) GetDBType() DatabaseType { + return MySQL +} + +func (s *MysqlJDBC) GetConnectionInfo() *ConnectionInfo { + return &s.ConnectionInfo +} + +func (s *MysqlJDBC) BuildConnectionString() string { + return buildMySQLConnectionString(s.Host, s.Database, s.User, s.Password, s.Params) +} + +func buildMySQLConnectionString(host, database, user, password string, params map[string]string) string { conn := host + "/" + database userPass := user if password != "" { @@ -56,7 +70,7 @@ func isMySQLErrorDeterminate(err error) bool { return false } -func ParseMySQL(ctx logContext.Context, subname string) (jdbc, error) { +func parseMySQL(ctx logContext.Context, subname string) (JDBC, error) { // expected form: [subprotocol:]//[user:password@]HOST[/DB][?key=val[&key=val]] if !strings.HasPrefix(subname, "//") { return nil, errors.New("expected host to start with //") @@ -86,7 +100,7 @@ func ParseMySQL(ctx logContext.Context, subname string) (jdbc, error) { }, nil } -func parseMySQLURI(ctx logContext.Context, subname string) (jdbc, error) { +func parseMySQLURI(ctx logContext.Context, subname string) (JDBC, error) { // for standard URI format, which is all i've seen for JDBC u, err := url.Parse(subname) diff --git a/pkg/detectors/jdbc/mysql_integration_test.go b/pkg/detectors/jdbc/mysql_integration_test.go index a0b3f9709..602065c4c 100644 --- a/pkg/detectors/jdbc/mysql_integration_test.go +++ b/pkg/detectors/jdbc/mysql_integration_test.go @@ -91,7 +91,7 @@ func TestMySQL(t *testing.T) { } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - j, err := ParseMySQL(logContext.Background(), tt.input) + j, err := parseMySQL(logContext.Background(), tt.input) if err != nil { got := result{ParseErr: true} diff --git a/pkg/detectors/jdbc/mysql_test.go b/pkg/detectors/jdbc/mysql_test.go index 5af03812b..ec178dbf4 100644 --- a/pkg/detectors/jdbc/mysql_test.go +++ b/pkg/detectors/jdbc/mysql_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context" ) @@ -58,7 +60,7 @@ func TestParseMySQLMissingCredentials(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := ParseMySQL(ctx, tt.subname) + j, err := parseMySQL(ctx, tt.subname) if tt.shouldBeNil { if j != nil { @@ -102,7 +104,7 @@ func TestParseMySQLUsernameRecognition(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := ParseMySQL(ctx, tt.subname) + j, err := parseMySQL(ctx, tt.subname) if err != nil { t.Fatalf("parseMySQL() error = %v", err) } @@ -115,3 +117,144 @@ func TestParseMySQLUsernameRecognition(t *testing.T) { }) } } + +func TestMySQL_ParseJDBCURL(t *testing.T) { + tests := []struct { + name string + jdbcURL string + wantHost string + wantDB string + wantUser string + wantPass string + wantErr bool + }{ + { + name: "basic URL with all parts", + jdbcURL: "jdbc:mysql://root:password@localhost:3306/testdb", + wantHost: "tcp(localhost:3306)", + wantDB: "testdb", + wantUser: "root", + wantPass: "password", + }, + { + name: "URL with default port", + jdbcURL: "jdbc:mysql://user:pass@dbhost/mydb", + wantHost: "tcp(dbhost)", + wantDB: "mydb", + wantUser: "user", + wantPass: "pass", + }, + { + name: "URL with query params for credentials", + jdbcURL: "jdbc:mysql://dbhost:3307/testdb?user=admin&password=secret", + wantHost: "tcp(dbhost:3307)", + wantDB: "testdb", + wantUser: "admin", + wantPass: "secret", + }, + { + name: "invalid URL - missing jdbc:mysql prefix", + jdbcURL: "postgresql://user:pass@localhost/db", + wantErr: true, + }, + { + name: "invalid URL - missing //", + jdbcURL: "jdbc:mysql:user:pass@localhost/db", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jdbc, err := NewJDBC(logContext.Background(), tt.jdbcURL) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + info := jdbc.GetConnectionInfo() + assert.Equal(t, tt.wantHost, info.Host) + assert.Equal(t, tt.wantDB, info.Database) + assert.Equal(t, tt.wantUser, info.User) + assert.Equal(t, tt.wantPass, info.Password) + }) + } +} + +func TestMySQL_ParseJDBCURL_DSNAddressParsing(t *testing.T) { + tests := []struct { + name string + jdbcURL string + wantHost string + }{ + { + name: "DSN format with explicit port", + jdbcURL: "jdbc:mysql://myuser:mypass@tcp(localhost:3307)/mydb", + wantHost: "tcp(localhost:3307)", + }, + { + name: "DSN format with default port", + jdbcURL: "jdbc:mysql://myuser:mypass@tcp(db.example.com:3306)/testdb", + wantHost: "tcp(db.example.com:3306)", + }, + { + name: "DSN format without port", + jdbcURL: "jdbc:mysql://myuser:mypass@tcp(myhost)/mydb", + wantHost: "tcp(myhost:3306)", + }, + { + name: "Simple host:port format", + jdbcURL: "jdbc:mysql://root:password@mysql.server.com:3308/database", + wantHost: "tcp(mysql.server.com:3308)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jdbc, err := NewJDBC(logContext.Background(), tt.jdbcURL) + require.NoError(t, err) + info := jdbc.GetConnectionInfo() + assert.Equal(t, tt.wantHost, info.Host) + }) + } +} + +func TestMySQL_BuildNativeConnectionString(t *testing.T) { + tests := []struct { + name string + info *ConnectionInfo + wantUser string + wantPass string + wantHost string + wantDB string + }{ + { + name: "basic connection", + info: &ConnectionInfo{ + Host: "localhost", + Database: "testdb", + User: "root", + Password: "secret", + }, + wantUser: "root", + wantPass: "secret", + wantHost: "localhost", + wantDB: "testdb", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mysqlJDBC := &MysqlJDBC{ + ConnectionInfo: *tt.info, + } + connStr := mysqlJDBC.BuildConnectionString() + + // MySQL format: [user[:password]@]tcp(host:port)/database?timeout=10s + assert.Contains(t, connStr, tt.wantUser) + assert.Contains(t, connStr, tt.wantPass) + assert.Contains(t, connStr, tt.wantHost) + assert.Contains(t, connStr, "/"+tt.wantDB) + }) + } +} diff --git a/pkg/detectors/jdbc/postgres.go b/pkg/detectors/jdbc/postgres.go index ca5a057a9..152a60ad7 100644 --- a/pkg/detectors/jdbc/postgres.go +++ b/pkg/detectors/jdbc/postgres.go @@ -16,6 +16,8 @@ type PostgresJDBC struct { ConnectionInfo } +var _ JDBC = (*PostgresJDBC)(nil) + func (s *PostgresJDBC) ping(ctx context.Context) pingResult { // It is crucial that we try to build a connection string ourselves before using the one we found. This is because // if the found connection string doesn't include a username, the driver will attempt to connect using the current @@ -23,11 +25,23 @@ func (s *PostgresJDBC) ping(ctx context.Context) pingResult { // contrast, when we build a connection string ourselves, if there's no username, we try 'postgres' instead, which // actually has a chance of working. return ping(ctx, "postgres", isPostgresErrorDeterminate, - BuildPostgresConnectionString(s.Host, s.User, s.Password, "postgres", s.Params, true), - BuildPostgresConnectionString(s.Host, s.User, s.Password, "postgres", s.Params, false), + buildPostgresConnectionString(s.Host, s.User, s.Password, "postgres", s.Params, true), + buildPostgresConnectionString(s.Host, s.User, s.Password, "postgres", s.Params, false), ) } +func (s *PostgresJDBC) GetDBType() DatabaseType { + return PostgreSQL +} + +func (s *PostgresJDBC) GetConnectionInfo() *ConnectionInfo { + return &s.ConnectionInfo +} + +func (s *PostgresJDBC) BuildConnectionString() string { + return buildPostgresConnectionString(s.Host, s.User, s.Password, s.Database, s.Params, true) +} + func isPostgresErrorDeterminate(err error) bool { // Postgres codes from https://www.postgresql.org/docs/current/errcodes-appendix.html if pqErr, isPostgresError := err.(*pq.Error); isPostgresError { @@ -58,7 +72,7 @@ func joinKeyValues(m map[string]string, sep string) string { return strings.Join(data, sep) } -func ParsePostgres(ctx logContext.Context, subname string) (jdbc, error) { +func parsePostgres(ctx logContext.Context, subname string) (JDBC, error) { // expected form: [subprotocol:]//[user:password@]HOST[/DB][?key=val[&key=val]] if !strings.HasPrefix(subname, "//") { @@ -122,7 +136,7 @@ func ParsePostgres(ctx logContext.Context, subname string) (jdbc, error) { return postgresJDBC, nil } -func BuildPostgresConnectionString(host string, user string, password string, dbName string, params map[string]string, includeDbName bool) string { +func buildPostgresConnectionString(host string, user string, password string, dbName string, params map[string]string, includeDbName bool) string { data := map[string]string{ // default user "user": "postgres", diff --git a/pkg/detectors/jdbc/postgres_integration_test.go b/pkg/detectors/jdbc/postgres_integration_test.go index 4fc8d4408..0aeb20889 100644 --- a/pkg/detectors/jdbc/postgres_integration_test.go +++ b/pkg/detectors/jdbc/postgres_integration_test.go @@ -121,7 +121,7 @@ func TestPostgres(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - j, err := ParsePostgres(logContext.Background(), tt.input) + j, err := parsePostgres(logContext.Background(), tt.input) if err != nil { got := result{ParseErr: true} diff --git a/pkg/detectors/jdbc/postgres_test.go b/pkg/detectors/jdbc/postgres_test.go index bf85853b4..db2e8ec98 100644 --- a/pkg/detectors/jdbc/postgres_test.go +++ b/pkg/detectors/jdbc/postgres_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context" ) @@ -47,7 +49,7 @@ func TestParsePostgresMissingCredentials(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := ParsePostgres(ctx, tt.subname) + j, err := parsePostgres(ctx, tt.subname) if tt.shouldBeNil { if j != nil { @@ -86,7 +88,7 @@ func TestParsePostgresUsernameRecognition(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := ParsePostgres(ctx, tt.subname) + j, err := parsePostgres(ctx, tt.subname) if err != nil { t.Fatalf("ParsePostgres() error = %v", err) } @@ -98,3 +100,141 @@ func TestParsePostgresUsernameRecognition(t *testing.T) { }) } } + +func TestPostgreSQLHandler_ParseJDBCURL(t *testing.T) { + tests := []struct { + name string + jdbcURL string + wantHost string + wantDB string + wantUser string + wantPass string + wantSSLMode string + wantErr bool + }{ + { + name: "basic URL with all parts", + jdbcURL: "jdbc:postgresql://postgres:secret@localhost:5432/mydb", + wantHost: "localhost:5432", + wantDB: "mydb", + wantUser: "postgres", + wantPass: "secret", + }, + { + name: "URL with default port", + jdbcURL: "jdbc:postgresql://user:pass@dbhost/testdb", + wantHost: "dbhost", + wantDB: "testdb", + wantUser: "user", + wantPass: "pass", + }, + { + name: "URL with default database", + jdbcURL: "jdbc:postgresql://user:pass@dbhost:5433", + wantHost: "dbhost:5433", + wantDB: "postgres", + wantUser: "user", + wantPass: "pass", + }, + { + name: "URL with SSL mode", + jdbcURL: "jdbc:postgresql://user:pass@dbhost:5432/mydb?sslmode=require", + wantHost: "dbhost:5432", + wantDB: "mydb", + wantUser: "user", + wantPass: "pass", + wantSSLMode: "require", + }, + { + name: "invalid URL - missing jdbc:postgresql prefix", + jdbcURL: "mysql://user:pass@localhost/db", + wantErr: true, + }, + { + name: "invalid URL - missing //", + jdbcURL: "jdbc:postgresql:user:pass@localhost/db", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jdbc, err := NewJDBC(logContext.Background(), tt.jdbcURL) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + info := jdbc.GetConnectionInfo() + assert.Equal(t, tt.wantHost, info.Host) + assert.Equal(t, tt.wantDB, info.Database) + assert.Equal(t, tt.wantUser, info.User) + assert.Equal(t, tt.wantPass, info.Password) + + if tt.wantSSLMode != "" { + assert.Equal(t, tt.wantSSLMode, info.Params["sslmode"]) + } + }) + } +} + +func TestPostgreSQLHandler_BuildNativeConnectionString(t *testing.T) { + tests := []struct { + name string + info *ConnectionInfo + want map[string]string // key-value pairs that should be in the connection string + }{ + { + name: "basic connection", + info: &ConnectionInfo{ + Host: "localhost", + Database: "testdb", + User: "postgres", + Password: "secret", + Params: map[string]string{ + "connect_timeout": "10", + }, + }, + want: map[string]string{ + "host": "localhost", + "dbname": "testdb", + "user": "postgres", + "password": "secret", + "connect_timeout": "10", + }, + }, + { + name: "with SSL mode", + info: &ConnectionInfo{ + Host: "dbhost:5433", + Database: "mydb", + User: "user", + Password: "pass", + Params: map[string]string{"sslmode": "require"}, + }, + want: map[string]string{ + "host": "dbhost", + "port": "5433", + "dbname": "mydb", + "sslmode": "require", + "user": "user", + "password": "pass", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jdbc := &PostgresJDBC{ + ConnectionInfo: *tt.info, + } + + connStr := jdbc.BuildConnectionString() + // Verify all expected key-value pairs are in the connection string + for key, expectedValue := range tt.want { + expectedPair := key + "=" + expectedValue + assert.Contains(t, connStr, expectedPair) + } + }) + } +} diff --git a/pkg/detectors/jdbc/sqlserver.go b/pkg/detectors/jdbc/sqlserver.go index dc6746f20..4633e92a0 100644 --- a/pkg/detectors/jdbc/sqlserver.go +++ b/pkg/detectors/jdbc/sqlserver.go @@ -15,9 +15,23 @@ type SqlServerJDBC struct { ConnectionInfo } +var _ JDBC = (*SqlServerJDBC)(nil) + func (s *SqlServerJDBC) ping(ctx context.Context) pingResult { return ping(ctx, "mssql", isSqlServerErrorDeterminate, - BuildSQLServerConnectionString(s.Host, s.User, s.Password, "master", map[string]string{"connection+timeout": "5"})) + buildSQLServerConnectionString(s.Host, s.User, s.Password, "master", map[string]string{"connection+timeout": "5"})) +} + +func (s *SqlServerJDBC) GetDBType() DatabaseType { + return SQLServer +} + +func (s *SqlServerJDBC) GetConnectionInfo() *ConnectionInfo { + return &s.ConnectionInfo +} + +func (s *SqlServerJDBC) BuildConnectionString() string { + return buildSQLServerConnectionString(s.Host, s.User, s.Password, s.Database, s.Params) } func isSqlServerErrorDeterminate(err error) bool { @@ -34,7 +48,7 @@ func isSqlServerErrorDeterminate(err error) bool { return false } -func ParseSqlServer(ctx logContext.Context, subname string) (jdbc, error) { +func parseSqlServer(ctx logContext.Context, subname string) (JDBC, error) { if !strings.HasPrefix(subname, "//") { return nil, errors.New("expected connection to start with //") } @@ -93,7 +107,7 @@ func ParseSqlServer(ctx logContext.Context, subname string) (jdbc, error) { }, nil } -func BuildSQLServerConnectionString(host, user, password, database string, params map[string]string) string { +func buildSQLServerConnectionString(host, user, password, database string, params map[string]string) string { conn := fmt.Sprintf("sqlserver://%s:%s@%s?database=%s", user, password, host, database) if len(params) > 0 { for k, v := range params { diff --git a/pkg/detectors/jdbc/sqlserver_test.go b/pkg/detectors/jdbc/sqlserver_test.go index f86b80d10..32bfbae23 100644 --- a/pkg/detectors/jdbc/sqlserver_test.go +++ b/pkg/detectors/jdbc/sqlserver_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context" ) @@ -54,7 +56,7 @@ func TestParseSqlServerMissingCredentials(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := ParseSqlServer(ctx, tt.subname) + j, err := parseSqlServer(ctx, tt.subname) if tt.shouldBeNil { if j != nil { @@ -101,7 +103,7 @@ func TestParseSqlServerUserIgnoredBug2(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := logContext.AddLogger(context.Background()) - j, err := ParseSqlServer(ctx, tt.subname) + j, err := parseSqlServer(ctx, tt.subname) if err != nil { t.Fatalf("parseSqlServer() error = %v", err) } @@ -115,3 +117,106 @@ func TestParseSqlServerUserIgnoredBug2(t *testing.T) { }) } } + +func TestSQLServerHandler_ParseJDBCURL(t *testing.T) { + tests := []struct { + name string + jdbcURL string + wantHost string + wantDB string + wantUser string + wantPass string + wantErr bool + }{ + { + name: "basic URL with semicolon params", + jdbcURL: "jdbc:sqlserver://localhost:1433;database=testdb;user=sa;password=Pass123", + wantHost: "localhost:1433", + wantDB: "testdb", + wantUser: "sa", + wantPass: "Pass123", + }, + { + name: "URL with default port and database", + jdbcURL: "jdbc:sqlserver://dbhost;user=testuser;password=secret", + wantHost: "dbhost:1433", + wantDB: "master", + wantUser: "testuser", + wantPass: "secret", + }, + { + name: "URL with port in host", + jdbcURL: "jdbc:sqlserver://server.example.com:1434;databaseName=mydb;userId=admin;pwd=admin123", + wantHost: "server.example.com:1434", + wantDB: "mydb", + wantUser: "admin", + wantPass: "admin123", + }, + { + name: "invalid URL - missing jdbc:sqlserver prefix", + jdbcURL: "jdbc:mysql://localhost/db", + wantErr: true, + }, + { + name: "invalid URL - missing //", + jdbcURL: "jdbc:sqlserver:localhost;database=test", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jdbc, err := NewJDBC(logContext.Background(), tt.jdbcURL) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + info := jdbc.GetConnectionInfo() + assert.Equal(t, tt.wantHost, info.Host) + assert.Equal(t, tt.wantDB, info.Database) + assert.Equal(t, tt.wantUser, info.User) + assert.Equal(t, tt.wantPass, info.Password) + }) + } +} + +func TestSQLServerHandler_BuildNativeConnectionString(t *testing.T) { + tests := []struct { + name string + info *ConnectionInfo + wantUser string + wantPass string + wantHost string + wantDB string + }{ + { + name: "basic connection", + info: &ConnectionInfo{ + Host: "localhost", + Database: "testdb", + User: "sa", + Password: "Pass123", + }, + wantUser: "sa", + wantPass: "Pass123", + wantHost: "localhost", + wantDB: "testdb", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jdbc := &SqlServerJDBC{ + ConnectionInfo: *tt.info, + } + connStr := jdbc.BuildConnectionString() + + // SQL Server format: sqlserver://user:password@host:port?database=db&connection+timeout=10 + assert.Contains(t, connStr, tt.wantUser) + assert.Contains(t, connStr, tt.wantPass) + assert.Contains(t, connStr, tt.wantHost) + assert.Contains(t, connStr, "database="+tt.wantDB) + }) + } +}