From c02d812b6c7d483834d5e263d31d4a24a0076447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 18 Feb 2026 07:00:13 +0100 Subject: [PATCH] fix(slog): masking of grouped attributes (#11606) # Which Problems Are Solved Group attributes weren't masked if their corresponding key was configured. For example setting `data` as a masked key entry in the runtime config would still print unmasked event data. This was because ReplaceAttr does not receive group attributes, only the flattened attributes with group information. # How the Problems Are Solved Check the current groups stack in the replacer. When a configured key is found in the group, mask the current attribute. This means that the attribute structure is preserved and all sub-keys of a masked group are still printed. # Additional Changes - Use a binary search for key matching. As we are now comparing slice against slice, a binary search should make the process a little faster if many keys are configured. # Additional Context - Follow-up for PR #11435 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: muhlemmer <5411563+muhlemmer@users.noreply.github.com> --- backend/v3/instrumentation/slog.go | 17 +++++++++++-- backend/v3/instrumentation/slog_test.go | 34 ++++++++++++++++++++++--- cmd/defaults.yaml | 6 +++-- cmd/mirror/defaults.yaml | 6 +++-- cmd/setup/steps.yaml | 6 +++-- 5 files changed, 58 insertions(+), 11 deletions(-) diff --git a/backend/v3/instrumentation/slog.go b/backend/v3/instrumentation/slog.go index ec6bc1d3e..c4847908b 100644 --- a/backend/v3/instrumentation/slog.go +++ b/backend/v3/instrumentation/slog.go @@ -82,9 +82,22 @@ type MaskConfig struct { Value string } +// replacer returns a function that replaces the value of any attribute whose key matches +// one of the configured keys with the configured value. +// If a group key matches, all attribute values in that group are replaced. +// Attribute structure is preserved, e.g. "password" is replaced with "masked" but still appears as "password" in the logs. func (c MaskConfig) replacer() replacer { - return func(_ []string, a slog.Attr) slog.Attr { - if slices.Contains(c.Keys, a.Key) { + keys := slices.Clone(c.Keys) + slices.Sort(keys) + return func(groups []string, a slog.Attr) slog.Attr { + // mask all entries in a matching group, e.g. "data.*" + for _, g := range groups { + if _, found := slices.BinarySearch(keys, g); found { + a.Value = slog.StringValue(c.Value) + return a + } + } + if _, found := slices.BinarySearch(keys, a.Key); found { a.Value = slog.StringValue(c.Value) } return a diff --git a/backend/v3/instrumentation/slog_test.go b/backend/v3/instrumentation/slog_test.go index 6f993afe0..077fa8279 100644 --- a/backend/v3/instrumentation/slog_test.go +++ b/backend/v3/instrumentation/slog_test.go @@ -72,7 +72,7 @@ func TestLogConfig_replacer(t *testing.T) { name: "masking configured key", c: LogConfig{ Mask: MaskConfig{ - Keys: []string{"sensitive"}, + Keys: []string{"sensitive", "foo", "bar"}, Value: "masked", }, }, @@ -85,7 +85,7 @@ func TestLogConfig_replacer(t *testing.T) { name: "masking configured key in any group", c: LogConfig{ Mask: MaskConfig{ - Keys: []string{"sensitive"}, + Keys: []string{"sensitive", "foo", "bar"}, Value: "masked", }, }, @@ -95,11 +95,39 @@ func TestLogConfig_replacer(t *testing.T) { }, want: slog.String("sensitive", "masked"), }, + { + name: "masking configured group", + c: LogConfig{ + Mask: MaskConfig{ + Keys: []string{"sensitive", "foo", "bar"}, + Value: "masked", + }, + }, + args: args{ + groups: []string{"sensitive"}, + a: slog.String("unmatched", "value"), + }, + want: slog.String("unmatched", "masked"), + }, + { + name: "masking configured sub-group", + c: LogConfig{ + Mask: MaskConfig{ + Keys: []string{"sensitive", "foo", "bar"}, + Value: "masked", + }, + }, + args: args{ + groups: []string{"a", "sensitive", "b"}, + a: slog.String("unmatched", "value"), + }, + want: slog.String("unmatched", "masked"), + }, { name: "not masking unmatched key", c: LogConfig{ Mask: MaskConfig{ - Keys: []string{"sensitive"}, + Keys: []string{"sensitive", "foo", "bar"}, Value: "masked", }, }, diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 579e09e47..babfda3c3 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -59,8 +59,10 @@ Instrumentation: # Keys are unqualified attribute names and apply to all attributes with the specified name, # regardless of their position in the attribute hierarchy. # Eg. "some_key" matches "some_key" and "parent.some_key" etc. - # If the matched log attribute is a nested object or array, - # the entire structure is replaced with the specified Value. + # When the key matches a group of attributes (e.g. slog.Group), all attribute values in the + # group are masked and the structure of the group is preserved. + # When the key matches a non-group attribute whose value is a nested object/array + # (e.g. added via slog.Any), the entire value is replaced with Value and its structure is not preserved. Keys: # ZITADEL_INSTRUMENTATION_LOG_MASK_KEYS (comma separated list) # - "first_name" # - "last_name" diff --git a/cmd/mirror/defaults.yaml b/cmd/mirror/defaults.yaml index d7b7ea71e..9cad15dd0 100644 --- a/cmd/mirror/defaults.yaml +++ b/cmd/mirror/defaults.yaml @@ -59,8 +59,10 @@ Instrumentation: # Keys are unqualified attribute names and apply to all attributes with the specified name, # regardless of their position in the attribute hierarchy. # Eg. "some_key" matches "some_key" and "parent.some_key" etc. - # If the matched log attribute is a nested object or array, - # the entire structure is replaced with the specified Value. + # When the key matches a group of attributes (e.g. slog.Group), all attribute values in the + # group are masked and the structure of the group is preserved. + # When the key matches a non-group attribute whose value is a nested object/array + # (e.g. added via slog.Any), the entire value is replaced with Value and its structure is not preserved. Keys: # ZITADEL_INSTRUMENTATION_LOG_MASK_KEYS (comma separated list) # - "first_name" # - "last_name" diff --git a/cmd/setup/steps.yaml b/cmd/setup/steps.yaml index aad283d6c..fc1ee5bb6 100644 --- a/cmd/setup/steps.yaml +++ b/cmd/setup/steps.yaml @@ -59,8 +59,10 @@ Instrumentation: # Keys are unqualified attribute names and apply to all attributes with the specified name, # regardless of their position in the attribute hierarchy. # Eg. "some_key" matches "some_key" and "parent.some_key" etc. - # If the matched log attribute is a nested object or array, - # the entire structure is replaced with the specified Value. + # When the key matches a group of attributes (e.g. slog.Group), all attribute values in the + # group are masked and the structure of the group is preserved. + # When the key matches a non-group attribute whose value is a nested object/array + # (e.g. added via slog.Any), the entire value is replaced with Value and its structure is not preserved. Keys: # ZITADEL_INSTRUMENTATION_LOG_MASK_KEYS (comma separated list) # - "first_name" # - "last_name"