Repository: oliver006/redis_exporter

Issue:
About password encryption
**Describe the problem**
For now, the redis password and basic auth secret is not encrypted. Is there any plan to provide further password encryption?

[reference (node_exporter basic auth encryption)](https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md#about-bcrypt)

**What version of redis_exporter are you running?**
v1.67.0


Discussion:
@oliver006: > Is there any plan to provide further password encryption?

Not as of right now. @oliver006: This could be a good feature to implement for someone that's interested in contributing.

Solution patch:
diff --git a/README.md b/README.md
index 0902ed7..2992e4d 100644
--- a/README.md
+++ b/README.md
@@ -213,7 +213,8 @@ scrape_configs:
 | max-distinct-key-groups             | REDIS_EXPORTER_MAX_DISTINCT_KEY_GROUPS           | Maximum number of distinct key groups that can be tracked independently *per Redis database*. If exceeded, only key groups with the highest memory consumption within the limit will be tracked separately, all remaining key groups will be tracked under a single `overflow` key group.                                                                                                                                                                                                                                                                                                                                                       |
 | config-command                      | REDIS_EXPORTER_CONFIG_COMMAND                    | What to use for the CONFIG command, defaults to `CONFIG`, , set to "-" to skip config metrics extraction.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
 | basic-auth-username                 | REDIS_EXPORTER_BASIC_AUTH_USERNAME               | Username for Basic Authentication with the redis exporter needs to be set together with basic-auth-password to be effective
-| basic-auth-password                 | REDIS_EXPORTER_BASIC_AUTH_PASSWORD               | Password for Basic Authentication with the redis exporter needs to be set together with basic-auth-username to be effective
+| basic-auth-password                 | REDIS_EXPORTER_BASIC_AUTH_PASSWORD               | Password for Basic Authentication with the redis exporter needs to be set together with basic-auth-username to be effective, conflicts with `basic-auth-hash-password`.
+| basic-auth-hash-password            | REDIS_EXPORTER_BASIC_AUTH_HASH_PASSWORD          | Bcrypt-hashed password for Basic Authentication with the redis exporter needs to be set together with basic-auth-username to be effective,  conflicts with `basic-auth-password`. 
 | include-metrics-for-empty-databases | REDIS_EXPORTER_INCL_METRICS_FOR_EMPTY_DATABASES  | Whether to emit db metrics (like db_keys) for empty databases
 
 Redis instance addresses can be tcp addresses: `redis://localhost:6379`, `redis.example.com:6379` or e.g. unix sockets: `unix:///tmp/redis.sock`.\
diff --git a/exporter/exporter.go b/exporter/exporter.go
index a74365d..844e562 100644
--- a/exporter/exporter.go
+++ b/exporter/exporter.go
@@ -89,6 +89,7 @@ type Options struct {
 	BuildInfo                      BuildInfo
 	BasicAuthUsername              string
 	BasicAuthPassword              string
+	BasicAuthHashPassword          string
 	SkipCheckKeysForRoleMaster     bool
 	InclMetricsForEmptyDatabases   bool
 }
diff --git a/exporter/http.go b/exporter/http.go
index bad9d81..281270f 100644
--- a/exporter/http.go
+++ b/exporter/http.go
@@ -12,6 +12,7 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 	log "github.com/sirupsen/logrus"
+	"golang.org/x/crypto/bcrypt"
 )
 
 func (e *Exporter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -170,11 +171,10 @@ func (e *Exporter) reloadPwdFile(w http.ResponseWriter, r *http.Request) {
 }
 
 func (e *Exporter) isBasicAuthConfigured() bool {
-	return e.options.BasicAuthUsername != "" && e.options.BasicAuthPassword != ""
+	return e.options.BasicAuthUsername != "" && (e.options.BasicAuthPassword != "" || e.options.BasicAuthHashPassword != "")
 }
 
 func (e *Exporter) verifyBasicAuth(user, password string, authHeaderSet bool) error {
-
 	if !e.isBasicAuthConfigured() {
 		return nil
 	}
@@ -183,12 +183,20 @@ func (e *Exporter) verifyBasicAuth(user, password string, authHeaderSet bool) er
 		return errors.New("Unauthorized")
 	}
 
-	userCorrect := subtle.ConstantTimeCompare([]byte(user), []byte(e.options.BasicAuthUsername))
-	passCorrect := subtle.ConstantTimeCompare([]byte(password), []byte(e.options.BasicAuthPassword))
+	userCorrect := subtle.ConstantTimeCompare([]byte(user), []byte(e.options.BasicAuthUsername)) == 1
+	passCorrect := e.validateBasicAuthPassword(password)
 
-	if userCorrect == 0 || passCorrect == 0 {
+	if !userCorrect || !passCorrect {
 		return errors.New("Unauthorized")
 	}
 
 	return nil
 }
+
+func (e *Exporter) validateBasicAuthPassword(password string) bool {
+	if e.options.BasicAuthHashPassword != "" {
+		return bcrypt.CompareHashAndPassword([]byte(e.options.BasicAuthHashPassword), []byte(password)) == nil
+	} else {
+		return subtle.ConstantTimeCompare([]byte(password), []byte(e.options.BasicAuthPassword)) == 1
+	}
+}
diff --git a/go.mod b/go.mod
index 2058224..2a6dc41 100644
--- a/go.mod
+++ b/go.mod
@@ -19,6 +19,7 @@ require (
 	github.com/prometheus/common v0.66.1 // indirect
 	github.com/prometheus/procfs v0.16.1 // indirect
 	go.yaml.in/yaml/v2 v2.4.2 // indirect
-	golang.org/x/sys v0.35.0 // indirect
+	golang.org/x/crypto v0.45.0
+	golang.org/x/sys v0.38.0 // indirect
 	google.golang.org/protobuf v1.36.8 // indirect
 )
diff --git a/go.sum b/go.sum
index f6bd33e..cd4436a 100644
--- a/go.sum
+++ b/go.sum
@@ -46,9 +46,11 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
 go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
 go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
-golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
 google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/main.go b/main.go
index a2818eb..30b285e 100644
--- a/main.go
+++ b/main.go
@@ -16,6 +16,7 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/collectors"
 	log "github.com/sirupsen/logrus"
+	"golang.org/x/crypto/bcrypt"
 
 	"github.com/oliver006/redis_exporter/exporter"
 )
@@ -80,6 +81,17 @@ func validateTLSClientConfig(certFile, keyFile string) error {
 	return nil
 }
 
+func validateAuthParams(basicAuthPassword, basicAuthHashPassword string) error {
+	if basicAuthPassword != "" && basicAuthHashPassword != "" {
+		return errors.New("cannot set both basic auth password and basic auth hash password")
+	}
+	if basicAuthHashPassword != "" {
+		_, err := bcrypt.Cost([]byte(basicAuthHashPassword))
+		return err
+	}
+	return nil
+}
+
 // loadScripts loads Lua scripts from the provided script paths
 func loadScripts(scriptPath string) (map[string][]byte, error) {
 	if scriptPath == "" {
@@ -192,8 +204,10 @@ func main() {
 		skipTLSVerification            = flag.Bool("skip-tls-verification", getEnvBool("REDIS_EXPORTER_SKIP_TLS_VERIFICATION", false), "Whether to to skip TLS verification")
 		skipCheckKeysForRoleMaster     = flag.Bool("skip-checkkeys-for-role-master", getEnvBool("REDIS_EXPORTER_SKIP_CHECKKEYS_FOR_ROLE_MASTER", false), "Whether to skip gathering the check-keys metrics (size, val) when the instance is of type master (reduce load on master nodes)")
 		basicAuthUsername              = flag.String("basic-auth-username", getEnv("REDIS_EXPORTER_BASIC_AUTH_USERNAME", ""), "Username for basic authentication")
-		basicAuthPassword              = flag.String("basic-auth-password", getEnv("REDIS_EXPORTER_BASIC_AUTH_PASSWORD", ""), "Password for basic authentication")
-		inclMetricsForEmptyDatabases   = flag.Bool("include-metrics-for-empty-databases", getEnvBool("REDIS_EXPORTER_INCL_METRICS_FOR_EMPTY_DATABASES", true), "Whether to emit db metrics (like db_keys) for empty databases")
+		basicAuthPassword              = flag.String("basic-auth-password", getEnv("REDIS_EXPORTER_BASIC_AUTH_PASSWORD", ""), "Password for basic authentication, conflicts with --basic-auth-hash-password")
+		basicAuthHashPassword          = flag.String("basic-auth-hash-password", getEnv("REDIS_EXPORTER_BASIC_AUTH_HASH_PASSWORD", ""), "Hashed password for basic authentication, bcrypt format, conflicts with --basic-auth-password")
+
+		inclMetricsForEmptyDatabases = flag.Bool("include-metrics-for-empty-databases", getEnvBool("REDIS_EXPORTER_INCL_METRICS_FOR_EMPTY_DATABASES", true), "Whether to emit db metrics (like db_keys) for empty databases")
 	)
 	flag.Parse()
 
@@ -287,12 +301,17 @@ func main() {
 			},
 			BasicAuthUsername:            *basicAuthUsername,
 			BasicAuthPassword:            *basicAuthPassword,
+			BasicAuthHashPassword:        *basicAuthHashPassword,
 			InclMetricsForEmptyDatabases: *inclMetricsForEmptyDatabases,
 		},
 	)
 	if err != nil {
 		log.Fatal(err)
 	}
+	// Validate auth parameters
+	if err := validateAuthParams(*basicAuthPassword, *basicAuthHashPassword); err != nil {
+		log.Fatal(err)
+	}
 
 	// Verify that initial client keypair and CA are accepted
 	if err := validateTLSClientConfig(*tlsClientCertFile, *tlsClientKeyFile); err != nil {



Test patch:
diff --git a/exporter/http_test.go b/exporter/http_test.go
index eec0bac..c72281b 100644
--- a/exporter/http_test.go
+++ b/exporter/http_test.go
@@ -511,14 +511,15 @@ func TestIsBasicAuthConfigured(t *testing.T) {
 
 func TestVerifyBasicAuth(t *testing.T) {
 	tests := []struct {
-		name          string
-		configUser    string
-		configPass    string
-		providedUser  string
-		providedPass  string
-		authHeaderSet bool
-		wantErr       bool
-		wantErrString string
+		name           string
+		configUser     string
+		configPass     string
+		configHashPass string
+		providedUser   string
+		providedPass   string
+		authHeaderSet  bool
+		wantErr        bool
+		wantErrString  string
 	}{
 		{
 			name:          "no auth configured - no credentials provided",
@@ -568,13 +569,43 @@ func TestVerifyBasicAuth(t *testing.T) {
 			wantErr:       true,
 			wantErrString: "Unauthorized",
 		},
+		{
+			name:           "auth configured with hash password - correct credentials",
+			configUser:     "user",
+			configHashPass: "$2b$12$slCbgjdTTCEZKRvp7fEd3exTXLqvq43kr3bZ6cGUfVLGJTC18SNJO",
+			providedUser:   "user",
+			providedPass:   "pass",
+			authHeaderSet:  true,
+			wantErr:        false,
+		},
+		{
+			name:           "auth configured with hash password - wrong username",
+			configUser:     "user",
+			configHashPass: "$2b$12$slCbgjdTTCEZKRvp7fEd3exTXLqvq43kr3bZ6cGUfVLGJTC18SNJO",
+			providedUser:   "wronguser",
+			providedPass:   "pass",
+			authHeaderSet:  true,
+			wantErr:        true,
+			wantErrString:  "Unauthorized",
+		},
+		{
+			name:           "auth configured with hash password - wrong password",
+			configUser:     "user",
+			configHashPass: "$2b$12$slCbgjdTTCEZKRvp7fEd3exTXLqvq43kr3bZ6cGUfVLGJTC18SNJO",
+			providedUser:   "user",
+			providedPass:   "wrongpass",
+			authHeaderSet:  true,
+			wantErr:        true,
+			wantErrString:  "Unauthorized",
+		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			e, _ := NewRedisExporter("", Options{
-				BasicAuthUsername: tt.configUser,
-				BasicAuthPassword: tt.configPass,
+				BasicAuthUsername:     tt.configUser,
+				BasicAuthPassword:     tt.configPass,
+				BasicAuthHashPassword: tt.configHashPass,
 			})
 
 			err := e.verifyBasicAuth(tt.providedUser, tt.providedPass, tt.authHeaderSet)
@@ -597,12 +628,13 @@ func TestBasicAuth(t *testing.T) {
 	}
 
 	tests := []struct {
-		name           string
-		username       string
-		password       string
-		configUsername string
-		configPassword string
-		wantStatusCode int
+		name               string
+		username           string
+		password           string
+		configUsername     string
+		configPassword     string
+		configHashPassword string
+		wantStatusCode     int
 	}{
 		{
 			name:           "No auth configured - no credentials provided",
@@ -644,14 +676,47 @@ func TestBasicAuth(t *testing.T) {
 			configPassword: "testpass",
 			wantStatusCode: http.StatusUnauthorized,
 		},
+		{
+			name:               "Auth configured with hash password - correct credentials",
+			username:           "testuser",
+			password:           "testpass",
+			configUsername:     "testuser",
+			configHashPassword: "$2b$12$6LXQAFSyKb4lP67Zrk7rtOTyXhpomZZAQbRmvj90mCJ0Lgs3jTmhi",
+			wantStatusCode:     http.StatusOK,
+		},
+		{
+			name:               "Auth configured with hash password - wrong password",
+			username:           "testuser",
+			password:           "wrongpass",
+			configUsername:     "testuser",
+			configHashPassword: "$2b$12$6LXQAFSyKb4lP67Zrk7rtOTyXhpomZZAQbRmvj90mCJ0Lgs3jTmhi",
+			wantStatusCode:     http.StatusUnauthorized,
+		},
+		{
+			name:               "Auth configured with hash password - wrong username",
+			username:           "wronguser",
+			password:           "testpass",
+			configUsername:     "testuser",
+			configHashPassword: "$2b$12$6LXQAFSyKb4lP67Zrk7rtOTyXhpomZZAQbRmvj90mCJ0Lgs3jTmhi",
+			wantStatusCode:     http.StatusUnauthorized,
+		},
+		{
+			name:               "Auth configured with hash password - no credentials provided",
+			username:           "",
+			password:           "",
+			configUsername:     "testuser",
+			configHashPassword: "$2b$12$6LXQAFSyKb4lP67Zrk7rtOTyXhpomZZAQbRmvj90mCJ0Lgs3jTmhi",
+			wantStatusCode:     http.StatusUnauthorized,
+		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			e, _ := NewRedisExporter(os.Getenv("TEST_REDIS_URI"), Options{
-				Namespace:         "test",
-				BasicAuthUsername: tt.configUsername,
-				BasicAuthPassword: tt.configPassword,
+				Namespace:             "test",
+				BasicAuthUsername:     tt.configUsername,
+				BasicAuthPassword:     tt.configPassword,
+				BasicAuthHashPassword: tt.configHashPassword,
 			})
 			ts := httptest.NewServer(e)
 			defer ts.Close()
@@ -714,3 +779,52 @@ func downloadURLWithStatusCode(t *testing.T, u string) (int, string) {
 
 	return resp.StatusCode, string(body)
 }
+
+func TestValidateBasicAuthPassword(t *testing.T) {
+	tests := []struct {
+		name               string
+		configHashPassword string
+		configPassword     string
+		inputPassword      string
+		expectStatus       bool
+	}{
+		{
+			name:           "Valid password",
+			configPassword: "password",
+			inputPassword:  "password",
+			expectStatus:   true,
+		}, {
+			name:           "Invalid password",
+			configPassword: "password",
+			inputPassword:  "wrongpassword",
+			expectStatus:   false,
+		},
+		{
+			name:               "Valid  hash password",
+			configHashPassword: "$2b$12$ODSJd0tmxY7H/adgD7R5SO43d8nmhUsa8OM6Weo7VFs3MbrsEY7tu",
+			inputPassword:      "password",
+			expectStatus:       true,
+		},
+		{
+			name:               "Invalid bcrypt hash",
+			configHashPassword: "$2b$12$ODSJd0tmxY7H/adgD7R5SO43d8nmhUsa8OM6Weo7VFs3MbrsEY7tu",
+			inputPassword:      "wrongpassword",
+			expectStatus:       false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			e := &Exporter{
+				options: Options{
+					BasicAuthHashPassword: tt.configHashPassword,
+					BasicAuthPassword:     tt.configPassword,
+				},
+			}
+			st := e.validateBasicAuthPassword(tt.inputPassword)
+			if st != tt.expectStatus {
+				t.Errorf("Expected error: %v, got: %v", tt.expectStatus, st)
+			}
+		})
+	}
+}
diff --git a/main_test.go b/main_test.go
index c2d014d..ed54fc7 100644
--- a/main_test.go
+++ b/main_test.go
@@ -497,3 +497,52 @@ func TestMainFunctionsIntegration(t *testing.T) {
 		t.Error("Registry creation failed")
 	}
 }
+
+func TestValidateAuthParams(t *testing.T) {
+	tests := []struct {
+		name                  string
+		basicAuthPassword     string
+		basicAuthHashPassword string
+		expectError           bool
+	}{
+		{
+			name:                  "Both passwords set",
+			basicAuthPassword:     "password",
+			basicAuthHashPassword: "$2b$12$ODSJd0tmxY7H/adgD7R5SO43d8nmhUsa8OM6Weo7VFs3MbrsEY7tu",
+			expectError:           true,
+		},
+		{
+			name:                  "Only basicAuthPassword set",
+			basicAuthPassword:     "password",
+			basicAuthHashPassword: "",
+			expectError:           false,
+		},
+		{
+			name:                  "Only basicAuthHashPassword set",
+			basicAuthPassword:     "",
+			basicAuthHashPassword: "$2b$12$ODSJd0tmxY7H/adgD7R5SO43d8nmhUsa8OM6Weo7VFs3MbrsEY7tu",
+			expectError:           false,
+		},
+		{
+			name:                  "Invalid basicAuthHashPassword",
+			basicAuthPassword:     "",
+			basicAuthHashPassword: "invalid_hash",
+			expectError:           true,
+		},
+		{
+			name:                  "Neither password set",
+			basicAuthPassword:     "",
+			basicAuthHashPassword: "",
+			expectError:           false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateAuthParams(tt.basicAuthPassword, tt.basicAuthHashPassword)
+			if (err != nil) != tt.expectError {
+				t.Errorf("Expected error: %v, got: %v", tt.expectError, err)
+			}
+		})
+	}
+}