Skip to content

Commit 6b45f2c

Browse files
Lach-devkashifkhan0771coderabbitai[bot]
authored
feat: Added log redaction for sensitive information (#213)
* feat: Added log redaction for sensitive information * fix: birthday not yet reached this year test (#214) * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Kashif Khan <[email protected]> * fixed linter issue in logging.go Signed-off-by: Kashif Khan <[email protected]> --------- Signed-off-by: Kashif Khan <[email protected]> Co-authored-by: Kashif Khan <[email protected]> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent f86ffcd commit 6b45f2c

File tree

4 files changed

+266
-4
lines changed

4 files changed

+266
-4
lines changed

logging/EXAMPLES.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,35 @@ func main() {
153153
```
154154
[2025-01-09 12:34:56] [INFO] CustomPrefix: This message has a custom prefix.
155155
```
156+
157+
### Redact sensitive information in logs
158+
159+
```go
160+
package main
161+
162+
import (
163+
logging "github.com/kashifkhan0771/utils/logging"
164+
"os"
165+
)
166+
167+
func main() {
168+
// Create a logger
169+
logger := logging.NewLogger("MyApp", logging.DEBUG, os.Stdout)
170+
171+
// Set redaction rules for sensitive data
172+
logger.SetRedactionRules(map[string]string{
173+
"password:": "***REDACTED***",
174+
"credit_card=": "***REDACTED***",
175+
})
176+
177+
// Log messages with sensitive data
178+
logger.Info("User logged in with password:mysecretpass123")
179+
logger.Error("Payment failed for credit_card=1234-5678-9876-5432")
180+
}
181+
```
182+
#### Output:
183+
184+
```
185+
[2025-01-09 12:34:56] [INFO] MyApp: User logged in with password:***REDACTED***
186+
[2025-01-09 12:34:56] [ERROR] MyApp: Payment failed for credit_card=***REDACTED***
187+
```

logging/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ The `logging` package provides a simple, flexible, and color-coded logging syste
5353
- **Disable Colors**:
5454
The `disableColors` field in the `Logger` struct can be set to `true` to disable color codes (useful for testing or plain-text logs).
5555

56+
### Redaction
57+
58+
The `logging` package can redact sensitive values before writing logs. Two methods are provided:
59+
60+
- `SetRedactionRules(rules map[string]string)`
61+
- Treats each map key as a literal pattern prefix (e.g., `password:` or `api_key=`).
62+
- Matches the key plus the following non-space characters and replaces them with the key concatenated with the provided replacement.
63+
- Example: `{"password:": "***REDACTED***"}` turns `password:secret` into `password:***REDACTED***`.
64+
65+
- `SetRedactionRegex(patterns map[string]string) error`
66+
- Accepts full regular expressions as keys and replacement strings as values.
67+
- Compiles each regex and returns an error if any pattern is invalid.
68+
- The replacement string replaces the matched substring.
69+
5670
#### **Notes**
5771

5872
- If the `minLevel` is set to `DEBUG`, all log messages will be displayed.

logging/logging.go

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"os"
10+
"regexp"
1011
"time"
1112
)
1213

@@ -30,13 +31,20 @@ const (
3031
ERROR // ERROR is used for critical error messages.
3132
)
3233

34+
// RedactionRule defines how to redact sensitive information
35+
type RedactionRule struct {
36+
Pattern *regexp.Regexp // Regex pattern to match sensitive data
37+
Replacement string // Replacement text for matched patterns
38+
}
39+
3340
// Logger is a configurable logging instance that supports multiple log levels,
3441
// optional colored output, and custom prefixes for log messages.
3542
type Logger struct {
36-
minLevel LogLevel // Minimum log level for messages to be logged
37-
prefix string // Prefix to prepend to all log messages
38-
output io.Writer // Output destination for log messages (e.g., os.Stdout)
39-
disableColors bool // Flag to disable color codes (useful for testing or non-ANSI terminals)
43+
minLevel LogLevel // Minimum log level for messages to be logged
44+
prefix string // Prefix to prepend to all log messages
45+
output io.Writer // Output destination for log messages (e.g., os.Stdout)
46+
disableColors bool // Flag to disable color codes (useful for testing or non-ANSI terminals)
47+
redactionRules []RedactionRule // Rules for redacting sensitive information
4048
}
4149

4250
// NewLogger creates and returns a new Logger instance with the specified prefix,
@@ -63,6 +71,58 @@ func NewLogger(prefix string, minLevel LogLevel, output io.Writer) *Logger {
6371
}
6472
}
6573

74+
// SetRedactionRules configures the logger to redact sensitive information based on
75+
// the provided patterns. Each key represents a pattern to match, and the value is
76+
// the replacement text.
77+
//
78+
// Parameters:
79+
// - rules: A map where keys are patterns (e.g., "password=", "credit_card=") and
80+
// values are replacement strings (e.g., "***REDACTED***").
81+
func (l *Logger) SetRedactionRules(rules map[string]string) {
82+
l.redactionRules = make([]RedactionRule, 0, len(rules))
83+
for pattern, replacement := range rules {
84+
// Create a regex that matches the pattern followed by any non-space characters
85+
regex := regexp.MustCompile(regexp.QuoteMeta(pattern) + `[^\s]+`)
86+
l.redactionRules = append(l.redactionRules, RedactionRule{
87+
Pattern: regex,
88+
Replacement: pattern + replacement,
89+
})
90+
}
91+
}
92+
93+
// SetRedactionRegex allows users to define custom regex patterns for redaction.
94+
// This provides more flexibility than SetRedactionRules.
95+
//
96+
// Parameters:
97+
// - patterns: A map where keys are regex patterns and values are replacement strings.
98+
func (l *Logger) SetRedactionRegex(patterns map[string]string) error {
99+
rules := make([]RedactionRule, 0, len(patterns))
100+
for pattern, replacement := range patterns {
101+
regex, err := regexp.Compile(pattern)
102+
if err != nil {
103+
return fmt.Errorf("invalid regex pattern '%s': %w", pattern, err)
104+
}
105+
rules = append(rules, RedactionRule{
106+
Pattern: regex,
107+
Replacement: replacement,
108+
})
109+
}
110+
111+
l.redactionRules = rules
112+
113+
return nil
114+
}
115+
116+
// redact applies all configured redaction rules to the message
117+
func (l *Logger) redact(message string) string {
118+
redactedMessage := message
119+
for _, rule := range l.redactionRules {
120+
redactedMessage = rule.Pattern.ReplaceAllString(redactedMessage, rule.Replacement)
121+
}
122+
123+
return redactedMessage
124+
}
125+
66126
// log handles the core logic of logging messages. It applies the appropriate
67127
// color coding (if enabled), formats the log message with a timestamp and prefix,
68128
// and writes it to the configured output destination.
@@ -75,6 +135,11 @@ func (l *Logger) log(level LogLevel, message string) {
75135
return
76136
}
77137

138+
// Apply redaction rules
139+
if len(l.redactionRules) > 0 {
140+
message = l.redact(message)
141+
}
142+
78143
// Determine the color and level string for the log level
79144
color := ""
80145
levelStr := ""

logging/logging_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,145 @@ func TestLogger(t *testing.T) {
9797
}
9898
}
9999

100+
func TestLoggerRedaction(t *testing.T) {
101+
tests := []struct {
102+
name string
103+
rules map[string]string
104+
message string
105+
wantContains string
106+
wantNotContain string
107+
}{
108+
{
109+
name: "success - redact password",
110+
rules: map[string]string{
111+
"password:": "***REDACTED***",
112+
},
113+
message: "User logged in with password:mysecretpass123",
114+
wantContains: "password:***REDACTED***",
115+
wantNotContain: "mysecretpass123",
116+
},
117+
{
118+
name: "success - redact credit card",
119+
rules: map[string]string{
120+
"credit_card=": "***REDACTED***",
121+
},
122+
message: "Payment processed with credit_card=1234-5678-9876-5432",
123+
wantContains: "credit_card=***REDACTED***",
124+
wantNotContain: "1234-5678-9876-5432",
125+
},
126+
{
127+
name: "success - redact multiple fields",
128+
rules: map[string]string{
129+
"password:": "***REDACTED***",
130+
"email=": "***REDACTED***",
131+
"credit_card=": "***REDACTED***",
132+
},
133+
message: "User [email protected] logged in with password:secret123",
134+
wantContains: "email=***REDACTED***",
135+
wantNotContain: "[email protected]",
136+
},
137+
{
138+
name: "success - no redaction without rules",
139+
rules: map[string]string{},
140+
message: "User logged in with password:secret",
141+
wantContains: "password:secret",
142+
wantNotContain: "REDACTED",
143+
},
144+
}
145+
146+
for _, tt := range tests {
147+
t.Run(tt.name, func(t *testing.T) {
148+
buffer := &bytes.Buffer{}
149+
logger := logging.NewLogger("Test", logging.INFO, buffer)
150+
logger.SetRedactionRules(tt.rules)
151+
152+
logger.Info(tt.message)
153+
154+
output := buffer.String()
155+
if !strings.Contains(output, tt.wantContains) {
156+
t.Errorf("Expected output to contain '%v', got: %v", tt.wantContains, output)
157+
}
158+
if tt.wantNotContain != "" && strings.Contains(output, tt.wantNotContain) {
159+
t.Errorf("Expected output NOT to contain '%v', got: %v", tt.wantNotContain, output)
160+
}
161+
})
162+
}
163+
}
164+
165+
func TestLoggerRedactionRegex(t *testing.T) {
166+
tests := []struct {
167+
name string
168+
patterns map[string]string
169+
message string
170+
wantContains string
171+
wantNotContain string
172+
wantErr bool
173+
}{
174+
{
175+
name: "success - redact email with regex",
176+
patterns: map[string]string{
177+
`\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b`: "[EMAIL REDACTED]",
178+
},
179+
message: "Contact us at [email protected] for help",
180+
wantContains: "[EMAIL REDACTED]",
181+
wantNotContain: "[email protected]",
182+
},
183+
{
184+
name: "success - redact credit card numbers",
185+
patterns: map[string]string{
186+
`\b\d{4}-\d{4}-\d{4}-\d{4}\b`: "[CARD REDACTED]",
187+
},
188+
message: "Card number: 1234-5678-9012-3456",
189+
wantContains: "[CARD REDACTED]",
190+
wantNotContain: "1234-5678-9012-3456",
191+
},
192+
{
193+
name: "success - redact phone numbers",
194+
patterns: map[string]string{
195+
`\b\d{3}-\d{3}-\d{4}\b`: "[PHONE REDACTED]",
196+
},
197+
message: "Call me at 555-123-4567",
198+
wantContains: "[PHONE REDACTED]",
199+
wantNotContain: "555-123-4567",
200+
},
201+
{
202+
name: "error - invalid regex pattern",
203+
patterns: map[string]string{
204+
`[invalid(regex`: "REDACTED",
205+
},
206+
message: "test message",
207+
wantErr: true,
208+
},
209+
}
210+
211+
for _, tt := range tests {
212+
t.Run(tt.name, func(t *testing.T) {
213+
buffer := &bytes.Buffer{}
214+
logger := logging.NewLogger("Test", logging.INFO, buffer)
215+
216+
err := logger.SetRedactionRegex(tt.patterns)
217+
if (err != nil) != tt.wantErr {
218+
t.Errorf("SetRedactionRegex() error = %v, wantErr %v", err, tt.wantErr)
219+
return
220+
}
221+
222+
if tt.wantErr {
223+
return
224+
}
225+
226+
logger.Info(tt.message)
227+
228+
output := buffer.String()
229+
if !strings.Contains(output, tt.wantContains) {
230+
t.Errorf("Expected output to contain '%v', got: %v", tt.wantContains, output)
231+
}
232+
if tt.wantNotContain != "" && strings.Contains(output, tt.wantNotContain) {
233+
t.Errorf("Expected output NOT to contain '%v', got: %v", tt.wantNotContain, output)
234+
}
235+
})
236+
}
237+
}
238+
100239
// ================================================================================
101240
// ### BENCHMARKS
102241
// ================================================================================
@@ -108,3 +247,15 @@ func BenchmarkLogger(b *testing.B) {
108247
logger.Info("This is an info message")
109248
}
110249
}
250+
251+
func BenchmarkLoggerWithRedaction(b *testing.B) {
252+
logger := logging.NewLogger("Test", logging.INFO, io.Discard)
253+
logger.SetRedactionRules(map[string]string{
254+
"password:": "***REDACTED***",
255+
"email=": "***REDACTED***",
256+
})
257+
b.ReportAllocs()
258+
for b.Loop() {
259+
logger.Info("User [email protected] logged in with password:secret123")
260+
}
261+
}

0 commit comments

Comments
 (0)