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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Salah Al Saleh
2026-09-04 11:25:07 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent f4f02e0ee1
commit ff9a7f87b8
3 changed files with 170 additions and 9 deletions
+18 -5
View File
@@ -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<mask>[^:\"\/]+){1}(:)(?P<mask>[^@\"\/]+){1}(?P<host>@[^:\/\s\"]+){1}(?P<port>:[\d]+)?`,
scan: `https?|ftp`,
regex: `(?i)([a-z][a-z\d+.\-]*)(:\/\/)(?P<mask>[^:\"\/]+){1}(:)(?P<mask>[^@\"\/]+){1}(?P<host>@[^:\/\s\"]+){1}(?P<port>:[\d]+)?`,
scan: `:\/\/[^:\"\/]+:[^@\"\/]+@`,
},
name: "Redact connection strings with username and password",
},
// user:password@tcp(host:3309)/db-name
{
regex: LineRedactor{
regex: `\b(?P<mask>[^:\"\/]*){1}(:)(?P<mask>[^:\"\/]*){1}(@tcp\()(?P<mask>[^:\"\/]*){1}(?P<port>:[\d]*)?(\)\/)(?P<mask>[\w\d\S-_]+){1}\b`,
scan: `@tcp`,
regex: `\b(?P<mask>[^:\"\/]*){1}(:)(?P<mask>[^:\"\/]*){1}(@(?:tcp[46]?|unix)\()(?P<mask>[^:\"\/]*){1}(?P<port>:[\d]*)?(\)\/)(?P<mask>[\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<mask>[^:\"\/\s]*){1}(:)(?P<mask>[^@\"\s]*){1}(@(?:tcp[46]?|unix)\()`,
scan: `@(?:tcp|unix)`,
},
name: "Redact database connection strings that contain username and password",
},
+149 -1
View File
@@ -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
+3 -3
View File
@@ -131,7 +131,7 @@ func TestNewSingleLineRedactor(t *testing.T) {
},
{
name: "Redact connection strings with username and password",
re: `(?i)(https?|ftp)(:\/\/)(?P<mask>[^:\"\/]+){1}(:)(?P<mask>[^@\"\/]+){1}(?P<host>@[^:\/\s\"]+){1}(?P<port>:[\d]+)?`,
re: `(?i)([a-z][a-z\d+.\-]*)(:\/\/)(?P<mask>[^:\"\/]+){1}(:)(?P<mask>[^@\"\/]+){1}(?P<host>@[^:\/\s\"]+){1}(?P<port>:[\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<mask>[^:\"\/]+){1}(:)(?P<mask>[^@\"\/]+){1}(?P<host>@[^:\/\s\"]+){1}(?P<port>:[\d]+)?`,
scan: `https?|ftp`,
re: `(?i)([a-z][a-z\d+.\-]*)(:\/\/)(?P<mask>[^:\"\/]+){1}(:)(?P<mask>[^@\"\/]+){1}(?P<host>@[^:\/\s\"]+){1}(?P<port>:[\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{