From ff9a7f87b8355b8d07c167af5105ca73b8cef543 Mon Sep 17 00:00:00 2001 From: Salah Al Saleh Date: Fri, 4 Sep 2026 11:25:07 -0700 Subject: [PATCH] fix(redactors): mask connection strings without a trailing database name (#2125) * fix(redactors): mask connection strings without a trailing database name The built-in redactors that mask credentials in a database URI only match when a trailing /db-name is present, so `postgres://user:pass@host:5432` and `user:pass@tcp(host:3306)` were written to bundles unredacted. Generalize the URI credential redactor from http/https/ftp to any scheme, since it does not require a path, and add a companion redactor for the MySQL DSN form. The existing redactors that also mask the host and database name are unchanged, so URIs with a database name redact exactly as before. Co-Authored-By: Claude Opus 5 (1M context) * fix(redactors): mask slashes and colons in mysql DSN passwords The password in the no-database-name DSN redactor stopped at the first slash or colon, so `user:p/ass@tcp(host:3306)` was left unredacted. Run the password to the @ instead. Co-Authored-By: Claude Opus 5 (1M context) * fix(redactors): mask mysql DSNs over tcp4, tcp6 and unix sockets Both DSN redactors only matched the tcp network type, so credentials in `user:pass@tcp6([::1]:3306)` and `user:pass@unix(/path/mysqld.sock)/db` were left unredacted. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- pkg/redact/redact.go | 23 +++-- pkg/redact/redact_test.go | 150 ++++++++++++++++++++++++++++++++- pkg/redact/single_line_test.go | 6 +- 3 files changed, 170 insertions(+), 9 deletions(-) diff --git a/pkg/redact/redact.go b/pkg/redact/redact.go index b2fa92ab..72fa4e62 100644 --- a/pkg/redact/redact.go +++ b/pkg/redact/redact.go @@ -312,20 +312,33 @@ func getRedactors(path string) ([]Redactor, error) { }, name: "Redact values for environment variables with names beginning with 'user'", }, - // connection strings with username and password + // credentials in a URI of any scheme, with or without a trailing path. + // the fuller redactors below mask the host and database name as well, + // but only match when a database name is present. // http://user:password@host:8888 + // postgres://user:password@host:5432 { regex: LineRedactor{ - regex: `(?i)(https?|ftp)(:\/\/)(?P[^:\"\/]+){1}(:)(?P[^@\"\/]+){1}(?P@[^:\/\s\"]+){1}(?P:[\d]+)?`, - scan: `https?|ftp`, + regex: `(?i)([a-z][a-z\d+.\-]*)(:\/\/)(?P[^:\"\/]+){1}(:)(?P[^@\"\/]+){1}(?P@[^:\/\s\"]+){1}(?P:[\d]+)?`, + scan: `:\/\/[^:\"\/]+:[^@\"\/]+@`, }, name: "Redact connection strings with username and password", }, // user:password@tcp(host:3309)/db-name { regex: LineRedactor{ - regex: `\b(?P[^:\"\/]*){1}(:)(?P[^:\"\/]*){1}(@tcp\()(?P[^:\"\/]*){1}(?P:[\d]*)?(\)\/)(?P[\w\d\S-_]+){1}\b`, - scan: `@tcp`, + regex: `\b(?P[^:\"\/]*){1}(:)(?P[^:\"\/]*){1}(@(?:tcp[46]?|unix)\()(?P[^:\"\/]*){1}(?P:[\d]*)?(\)\/)(?P[\w\d\S-_]+){1}\b`, + scan: `@(?:tcp|unix)`, + }, + name: "Redact database connection strings that contain username and password", + }, + // user:password@tcp(host:3309), with no trailing /db-name. tcp4, tcp6 and + // unix sockets are the other network types the mysql driver accepts. + // the password runs to the @, so that slashes and colons in it are masked too. + { + regex: LineRedactor{ + regex: `(?P[^:\"\/\s]*){1}(:)(?P[^@\"\s]*){1}(@(?:tcp[46]?|unix)\()`, + scan: `@(?:tcp|unix)`, }, name: "Redact database connection strings that contain username and password", }, diff --git a/pkg/redact/redact_test.go b/pkg/redact/redact_test.go index 2a3bb720..9cc7a37b 100644 --- a/pkg/redact/redact_test.go +++ b/pkg/redact/redact_test.go @@ -1720,7 +1720,7 @@ func Test_Redactors(t *testing.T) { } ]` - wantRedactionsLen := 47 + wantRedactionsLen := 48 wantRedactionsCount := 29 t.Run("test default redactors", func(t *testing.T) { @@ -1747,6 +1747,154 @@ func Test_Redactors(t *testing.T) { }) } +// Connection strings are commonly written without a trailing database name, e.g. +// "postgres://user:password@host:5432". The default redactors that mask the host and +// database name only match when a database name is present, so credentials in these +// URIs are masked by a separate redactor. +func Test_DefaultRedactors_ConnectionStrings(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "postgres uri with database name", + input: "postgres://pg_user:pg_password@pg_host:5432/pg_database", + want: "postgres://***HIDDEN***:***HIDDEN***@***HIDDEN***:5432/***HIDDEN***", + }, + { + name: "postgres uri without database name", + input: "postgres://pg_user:pg_password@pg_host:5432", + want: "postgres://***HIDDEN***:***HIDDEN***@pg_host:5432", + }, + { + name: "postgres uri without port or database name", + input: "postgres://pg_user:pg_password@pg_host", + want: "postgres://***HIDDEN***:***HIDDEN***@pg_host", + }, + { + name: "postgres uri with query parameters instead of a database name", + input: "postgres://pg_user:pg_password@pg_host:5432?sslmode=require", + want: "postgres://***HIDDEN***:***HIDDEN***@pg_host:5432?sslmode=require", + }, + { + name: "mysql uri without database name", + input: "mysql://my_user:my_password@my_host:3306", + want: "mysql://***HIDDEN***:***HIDDEN***@my_host:3306", + }, + { + name: "redis uri without database name", + input: "redis://redis_user:redis_password@redis_host:6379", + want: "redis://***HIDDEN***:***HIDDEN***@redis_host:6379", + }, + { + name: "amqp uri with a trailing slash and no database name", + input: "amqp://rabbit_user:rabbit_password@rabbit_host:5672/", + want: "amqp://***HIDDEN***:***HIDDEN***@rabbit_host:5672/", + }, + { + name: "uri without database name in json", + input: `{"name":"DB_URI","value":"mongodb://mongo_user:mongo_password@mongo_host:27017"}`, + want: `{"name":"DB_URI","value":"mongodb://***HIDDEN***:***HIDDEN***@mongo_host:27017"}`, + }, + { + name: "http url with credentials", + input: "http://user:password@host:8888", + want: "http://***HIDDEN***:***HIDDEN***@host:8888", + }, + { + name: "mysql dsn with database name", + input: "dbuser:thisisasecret@tcp(dbserver.org:3309)/blog_production", + want: "***HIDDEN***:***HIDDEN***@tcp(***HIDDEN***:3309)/***HIDDEN***", + }, + { + name: "mysql dsn without database name", + input: "dbuser:thisisasecret@tcp(dbserver.org:3309)", + want: "***HIDDEN***:***HIDDEN***@tcp(dbserver.org:3309)", + }, + { + name: "mysql dsn with a trailing slash and no database name", + input: "dbuser:thisisasecret@tcp(dbserver.org:3309)/", + want: "***HIDDEN***:***HIDDEN***@tcp(dbserver.org:3309)/", + }, + { + name: "mysql dsn over tcp6 with database name", + input: "dbuser:thisisasecret@tcp6(dbserver.org:3306)/blog_production", + want: "***HIDDEN***:***HIDDEN***@tcp6(***HIDDEN***:3306)/***HIDDEN***", + }, + { + name: "mysql dsn over tcp6 with an ipv6 address and no database name", + input: "dbuser:thisisasecret@tcp6([::1]:3306)", + want: "***HIDDEN***:***HIDDEN***@tcp6([::1]:3306)", + }, + { + name: "mysql dsn over tcp4 without database name", + input: "dbuser:thisisasecret@tcp4(dbserver.org:3306)", + want: "***HIDDEN***:***HIDDEN***@tcp4(dbserver.org:3306)", + }, + { + name: "mysql dsn over a unix socket", + input: "dbuser:thisisasecret@unix(/var/run/mysqld/mysqld.sock)/blog_production", + want: "***HIDDEN***:***HIDDEN***@unix(/var/run/mysqld/mysqld.sock)/blog_production", + }, + { + name: "mysql dsn with a slash in the password", + input: "dbuser:this/is/a/secret@tcp(dbserver.org:3309)", + want: "***HIDDEN***:***HIDDEN***@tcp(dbserver.org:3309)", + }, + { + name: "mysql dsn with a slash in the password and a database name", + input: "dbuser:this/is/a/secret@tcp(dbserver.org:3309)/blog_production", + want: "***HIDDEN***:***HIDDEN***@tcp(dbserver.org:3309)/blog_production", + }, + { + name: "mysql dsn in a log line", + input: "INFO connecting to dbuser:thisisasecret@tcp(dbserver.org:3309)", + want: "INFO connecting to ***HIDDEN***:***HIDDEN***@tcp(dbserver.org:3309)", + }, + { + name: "url without credentials is not redacted", + input: "http://awesome-api:8013/graphql", + want: "http://awesome-api:8013/graphql", + }, + { + name: "url without credentials or path is not redacted", + input: "https://registry:10443", + want: "https://registry:10443", + }, + { + name: "image reference is not redacted", + input: "image: localhost:32000/awesome-api:e9a281f7@sha256:6e988461ffce2bac3561234f736b9a504bfda1911fa6432b90e6bbb16f67f925", + want: "image: localhost:32000/awesome-api:e9a281f7@sha256:6e988461ffce2bac3561234f736b9a504bfda1911fa6432b90e6bbb16f67f925", + }, + { + name: "ssh remote is not redacted", + input: "git@github.com:replicatedhq/troubleshoot.git", + want: "git@github.com:replicatedhq/troubleshoot.git", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := require.New(t) + ResetRedactionList() + defer ResetRedactionList() + + redactors, err := getRedactors("testpath") + req.NoError(err) + + nextReader := io.Reader(strings.NewReader(tt.input)) + for _, r := range redactors { + nextReader = r.Redact(nextReader, "testpath") + } + + redacted, err := io.ReadAll(nextReader) + req.NoError(err) + req.Equal(tt.want, string(redacted)) + }) + } +} + func Test_redactMatchesPath(t *testing.T) { type args struct { path string diff --git a/pkg/redact/single_line_test.go b/pkg/redact/single_line_test.go index 29765271..c7867456 100644 --- a/pkg/redact/single_line_test.go +++ b/pkg/redact/single_line_test.go @@ -131,7 +131,7 @@ func TestNewSingleLineRedactor(t *testing.T) { }, { name: "Redact connection strings with username and password", - re: `(?i)(https?|ftp)(:\/\/)(?P[^:\"\/]+){1}(:)(?P[^@\"\/]+){1}(?P@[^:\/\s\"]+){1}(?P:[\d]+)?`, + re: `(?i)([a-z][a-z\d+.\-]*)(:\/\/)(?P[^:\"\/]+){1}(:)(?P[^@\"\/]+){1}(?P@[^:\/\s\"]+){1}(?P:[\d]+)?`, inputString: `http://user:password@host:8888`, wantString: "http://***HIDDEN***:***HIDDEN***@host:8888", // No trailing newline in input, so none in output wantRedactions: RedactionList{ @@ -275,8 +275,8 @@ func TestNewSingleLineRedactor(t *testing.T) { }, { name: "Redact connection strings With Scan", - re: `(?i)(https?|ftp)(:\/\/)(?P[^:\"\/]+){1}(:)(?P[^@\"\/]+){1}(?P@[^:\/\s\"]+){1}(?P:[\d]+)?`, - scan: `https?|ftp`, + re: `(?i)([a-z][a-z\d+.\-]*)(:\/\/)(?P[^:\"\/]+){1}(:)(?P[^@\"\/]+){1}(?P@[^:\/\s\"]+){1}(?P:[\d]+)?`, + scan: `:\/\/[^:\"\/]+:[^@\"\/]+@`, inputString: `http://user:password@host:8888;`, wantString: `http://***HIDDEN***:***HIDDEN***@host:8888;`, // No trailing newline in input, so none in output wantRedactions: RedactionList{