From f11b78ced9a8997057ac2e8195f8bde73f35c545 Mon Sep 17 00:00:00 2001 From: Kevin Wan Date: Fri, 18 Jul 2025 19:51:22 +0800 Subject: [PATCH] feat: support masking sensitive data in logx (#5003) Signed-off-by: kevin --- core/logx/sensitive.go | 21 ++++++++++++++++ core/logx/sensitive_test.go | 50 +++++++++++++++++++++++++++++++++++++ core/logx/writer.go | 9 ++++--- core/logx/writer_test.go | 42 +++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 core/logx/sensitive.go create mode 100644 core/logx/sensitive_test.go diff --git a/core/logx/sensitive.go b/core/logx/sensitive.go new file mode 100644 index 000000000..b0b2dac0c --- /dev/null +++ b/core/logx/sensitive.go @@ -0,0 +1,21 @@ +package logx + +// Sensitive is an interface that defines a method for masking sensitive information in logs. +// It is typically implemented by types that contain sensitive data, +// such as passwords or personal information. +// Infov, Errorv, Debugv, and Slowv methods will call this method to mask sensitive data. +// The values in LogField will also be masked if they implement the Sensitive interface. +type Sensitive interface { + // MaskSensitive masks sensitive information in the log. + MaskSensitive() any +} + +// maskSensitive returns the value returned by MaskSensitive method, +// if the value implements Sensitive interface. +func maskSensitive(v any) any { + if s, ok := v.(Sensitive); ok { + return s.MaskSensitive() + } + + return v +} diff --git a/core/logx/sensitive_test.go b/core/logx/sensitive_test.go new file mode 100644 index 000000000..f2c07841e --- /dev/null +++ b/core/logx/sensitive_test.go @@ -0,0 +1,50 @@ +package logx + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const maskedContent = "******" + +type User struct { + Name string + Pass string +} + +func (u User) MaskSensitive() any { + return User{ + Name: u.Name, + Pass: maskedContent, + } +} + +type NonSensitiveUser struct { + Name string + Pass string +} + +func TestMaskSensitive(t *testing.T) { + t.Run("sensitive", func(t *testing.T) { + user := User{ + Name: "kevin", + Pass: "123", + } + + mu := maskSensitive(user) + assert.Equal(t, user.Name, mu.(User).Name) + assert.Equal(t, maskedContent, mu.(User).Pass) + }) + + t.Run("non-sensitive", func(t *testing.T) { + user := NonSensitiveUser{ + Name: "kevin", + Pass: "123", + } + + mu := maskSensitive(user) + assert.Equal(t, user.Name, mu.(NonSensitiveUser).Name) + assert.Equal(t, user.Pass, mu.(NonSensitiveUser).Pass) + }) +} diff --git a/core/logx/writer.go b/core/logx/writer.go index ce57c811b..0e2ca9e8a 100644 --- a/core/logx/writer.go +++ b/core/logx/writer.go @@ -365,19 +365,22 @@ func mergeGlobalFields(fields []LogField) []LogField { } func output(writer io.Writer, level string, val any, fields ...LogField) { - // only truncate string content, don't know how to truncate the values of other types. - if v, ok := val.(string); ok { + switch v := val.(type) { + case string: + // only truncate string content, don't know how to truncate the values of other types. maxLen := atomic.LoadUint32(&maxContentLength) if maxLen > 0 && len(v) > int(maxLen) { val = v[:maxLen] fields = append(fields, truncatedField) } + case Sensitive: + val = v.MaskSensitive() } // +3 for timestamp, level and content entry := make(logEntry, len(fields)+3) for _, field := range fields { - entry[field.Key] = field.Value + entry[field.Key] = maskSensitive(field.Value) } switch atomic.LoadUint32(&encoding) { diff --git a/core/logx/writer_test.go b/core/logx/writer_test.go index 7d223dd40..65ca7998d 100644 --- a/core/logx/writer_test.go +++ b/core/logx/writer_test.go @@ -225,6 +225,48 @@ func TestWritePlainDuplicate(t *testing.T) { assert.Contains(t, buf.String(), "second=c") } +func TestLogWithSensitive(t *testing.T) { + old := atomic.SwapUint32(&encoding, plainEncodingType) + t.Cleanup(func() { + atomic.StoreUint32(&encoding, old) + }) + + t.Run("sensitive", func(t *testing.T) { + var buf bytes.Buffer + output(&buf, levelInfo, User{ + Name: "kevin", + Pass: "123", + }, LogField{ + Key: "first", + Value: "a", + }, LogField{ + Key: "first", + Value: "b", + }) + assert.Contains(t, buf.String(), maskedContent) + assert.NotContains(t, buf.String(), "first=a") + assert.Contains(t, buf.String(), "first=b") + }) + + t.Run("sensitive fields", func(t *testing.T) { + var buf bytes.Buffer + output(&buf, levelInfo, "foo", LogField{ + Key: "first", + Value: User{ + Name: "kevin", + Pass: "123", + }, + }, LogField{ + Key: "second", + Value: "b", + }) + assert.Contains(t, buf.String(), "foo") + assert.Contains(t, buf.String(), "first") + assert.Contains(t, buf.String(), maskedContent) + assert.Contains(t, buf.String(), "second=b") + }) +} + func TestLogWithLimitContentLength(t *testing.T) { maxLen := atomic.LoadUint32(&maxContentLength) atomic.StoreUint32(&maxContentLength, 10)