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 <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: muhlemmer <[email protected]>
This commit is contained in:
Tim Möhlmann
2026-02-18 06:00:13 +00:00
committed by GitHub
co-authored by Copilot Copilot muhlemmer
parent 93c6099482
commit c02d812b6c
5 changed files with 58 additions and 11 deletions
+15 -2
View File
@@ -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
+31 -3
View File
@@ -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",
},
},
+4 -2
View File
@@ -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"
+4 -2
View File
@@ -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"
+4 -2
View File
@@ -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"