-
-
Notifications
You must be signed in to change notification settings - Fork 67
Fix/redact sensitive auth logs #333
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
db4bb57
9360ded
453b88e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| package utils | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "encoding/json" | ||
| "net/http" | ||
| "regexp" | ||
| "strings" | ||
| ) | ||
|
|
||
| const redactedValue = "[REDACTED]" | ||
|
|
||
| var sensitiveKeys = map[string]struct{}{ | ||
| "authorization": {}, | ||
| "proxy-authorization": {}, | ||
| "access_token": {}, | ||
| "refresh_token": {}, | ||
| "id_token": {}, | ||
| "client_secret": {}, | ||
| "clientsecret": {}, | ||
| "password": {}, | ||
| "token": {}, | ||
| "api_key": {}, | ||
| "apikey": {}, | ||
| "secret": {}, | ||
| "cookie": {}, | ||
| "set-cookie": {}, | ||
| "x-api-key": {}, | ||
| } | ||
|
|
||
| var ( | ||
| urlEncodedSecretPattern = regexp.MustCompile(`(?i)(access_token|refresh_token|id_token|client_secret|clientsecret|password|token|api_key|apikey|secret)=([^&\s]+)`) | ||
| authSchemePattern = regexp.MustCompile(`(?i)\b(bearer|basic)\s+([A-Za-z0-9\-._~+/=]+)`) | ||
| ) | ||
|
|
||
| // MaskSecret replaces non-empty values with a redaction marker. | ||
| func MaskSecret(value string) string { | ||
| if value == "" { | ||
| return "" | ||
| } | ||
| return redactedValue | ||
| } | ||
|
|
||
| // SanitizeHeaders returns a sanitized copy of the headers. | ||
| func SanitizeHeaders(headers http.Header) http.Header { | ||
| if headers == nil { | ||
| return nil | ||
| } | ||
| sanitized := make(http.Header, len(headers)) | ||
| for key, values := range headers { | ||
| if isSensitiveKey(key) { | ||
| redactedValues := make([]string, len(values)) | ||
| for i := range redactedValues { | ||
| redactedValues[i] = redactedValue | ||
| } | ||
| sanitized[key] = redactedValues | ||
| continue | ||
| } | ||
| copied := make([]string, len(values)) | ||
| copy(copied, values) | ||
| sanitized[key] = copied | ||
| } | ||
| return sanitized | ||
| } | ||
|
|
||
| // SanitizeJSON sanitizes sensitive fields in JSON payloads. | ||
| func SanitizeJSON(data []byte) []byte { | ||
| if len(bytes.TrimSpace(data)) == 0 { | ||
| return data | ||
| } | ||
|
|
||
| var decoded interface{} | ||
| if err := json.Unmarshal(data, &decoded); err != nil { | ||
| return data | ||
| } | ||
|
|
||
| cleaned := sanitizeValue(decoded) | ||
| encoded, err := json.Marshal(cleaned) | ||
| if err != nil { | ||
| return data | ||
| } | ||
| return encoded | ||
| } | ||
|
|
||
| // SanitizeMap sanitizes sensitive fields in generic maps. | ||
| func SanitizeMap(data map[string]interface{}) map[string]interface{} { | ||
| if data == nil { | ||
| return nil | ||
| } | ||
| return sanitizeMap(data) | ||
| } | ||
|
|
||
| // SanitizeString attempts to redact secrets in string payloads. | ||
| func SanitizeString(input string) string { | ||
| if input == "" { | ||
| return input | ||
| } | ||
|
|
||
| if sanitized, ok := sanitizeHTTPDump(input); ok { | ||
| return sanitized | ||
| } | ||
|
|
||
|
Comment on lines
+99
to
+102
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| trimmed := strings.TrimSpace(input) | ||
| if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') { | ||
| if sanitized := SanitizeJSON([]byte(input)); sanitized != nil { | ||
| return string(sanitized) | ||
| } | ||
| } | ||
|
|
||
| sanitized := sanitizeHeaderLines(input) | ||
| sanitized = urlEncodedSecretPattern.ReplaceAllString(sanitized, "$1="+redactedValue) | ||
| sanitized = authSchemePattern.ReplaceAllString(sanitized, "$1 "+redactedValue) | ||
| return sanitized | ||
| } | ||
|
|
||
| func sanitizeHeaderLines(input string) string { | ||
| lines := strings.Split(input, "\n") | ||
| previousSensitive := false | ||
|
|
||
| for i, line := range lines { | ||
| trimmedLine := strings.TrimLeft(line, " \t") | ||
| leadingWhitespace := line[:len(line)-len(trimmedLine)] | ||
|
|
||
| if previousSensitive && trimmedLine != "" && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { | ||
| lines[i] = leadingWhitespace + redactedValue | ||
| continue | ||
| } | ||
|
|
||
| previousSensitive = false | ||
| separatorIndex := strings.Index(trimmedLine, ":") | ||
| if separatorIndex == -1 { | ||
| continue | ||
| } | ||
|
|
||
| keyPart := trimmedLine[:separatorIndex] | ||
| key := strings.TrimSpace(keyPart) | ||
| if !isSensitiveKey(key) { | ||
| continue | ||
| } | ||
|
|
||
| previousSensitive = true | ||
| lines[i] = leadingWhitespace + keyPart + ": " + redactedValue | ||
| } | ||
|
|
||
| return strings.Join(lines, "\n") | ||
| } | ||
|
|
||
| func sanitizeHTTPDump(input string) (string, bool) { | ||
| separator := "\r\n\r\n" | ||
| index := strings.Index(input, separator) | ||
| lineSep := "\r\n" | ||
| if index == -1 { | ||
| separator = "\n\n" | ||
| index = strings.Index(input, separator) | ||
| if index == -1 { | ||
| return "", false | ||
| } | ||
| lineSep = "\n" | ||
| } | ||
|
|
||
| headersPart := input[:index] | ||
| bodyPart := input[index+len(separator):] | ||
|
|
||
| headersNormalized := strings.ReplaceAll(headersPart, "\r\n", "\n") | ||
| headersSanitized := sanitizeHeaderLines(headersNormalized) | ||
| headersSanitized = strings.ReplaceAll(headersSanitized, "\n", lineSep) | ||
|
|
||
| bodySanitized := sanitizeBody(bodyPart) | ||
| combined := headersSanitized + separator + bodySanitized | ||
| combined = urlEncodedSecretPattern.ReplaceAllString(combined, "$1="+redactedValue) | ||
| combined = authSchemePattern.ReplaceAllString(combined, "$1 "+redactedValue) | ||
| return combined, true | ||
| } | ||
|
|
||
| func sanitizeBody(body string) string { | ||
| trimmed := strings.TrimSpace(body) | ||
| if len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') { | ||
| sanitized := SanitizeJSON([]byte(body)) | ||
| return string(sanitized) | ||
| } | ||
| return urlEncodedSecretPattern.ReplaceAllString(body, "$1="+redactedValue) | ||
| } | ||
|
|
||
| func sanitizeValue(value interface{}) interface{} { | ||
| switch typed := value.(type) { | ||
| case map[string]interface{}: | ||
| return sanitizeMap(typed) | ||
| case []interface{}: | ||
| cleaned := make([]interface{}, len(typed)) | ||
| for i, item := range typed { | ||
| cleaned[i] = sanitizeValue(item) | ||
| } | ||
| return cleaned | ||
| default: | ||
| return value | ||
| } | ||
| } | ||
|
|
||
| func sanitizeMap(data map[string]interface{}) map[string]interface{} { | ||
| cleaned := make(map[string]interface{}, len(data)) | ||
| for key, value := range data { | ||
| if isSensitiveKey(key) { | ||
| cleaned[key] = redactedValue | ||
| continue | ||
| } | ||
| cleaned[key] = sanitizeValue(value) | ||
| } | ||
| return cleaned | ||
| } | ||
|
|
||
| func isSensitiveKey(key string) bool { | ||
| _, ok := sensitiveKeys[strings.ToLower(strings.TrimSpace(key))] | ||
| return ok | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No tests for empty/nil inputs in |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| package utils | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "net/http" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestMaskSecret(t *testing.T) { | ||
| if MaskSecret("") != "" { | ||
| t.Fatalf("expected empty string to stay empty") | ||
| } | ||
| if MaskSecret("value") != redactedValue { | ||
| t.Fatalf("expected value to be redacted") | ||
| } | ||
| } | ||
|
|
||
| func TestSanitizeHeaders(t *testing.T) { | ||
| headers := http.Header{} | ||
| headers.Set("Authorization", "Bearer token") | ||
| headers.Set("X-Api-Key", "key") | ||
| headers.Set("Content-Type", "application/json") | ||
|
|
||
| sanitized := SanitizeHeaders(headers) | ||
| if sanitized.Get("Authorization") != redactedValue { | ||
| t.Fatalf("expected authorization to be redacted") | ||
| } | ||
| if sanitized.Get("X-Api-Key") != redactedValue { | ||
| t.Fatalf("expected api key to be redacted") | ||
| } | ||
| if sanitized.Get("Content-Type") != "application/json" { | ||
| t.Fatalf("expected content-type to be preserved") | ||
| } | ||
| if headers.Get("Authorization") == redactedValue { | ||
| t.Fatalf("expected original headers to remain unchanged") | ||
| } | ||
| } | ||
|
|
||
| func TestSanitizeJSONNested(t *testing.T) { | ||
| payload := []byte(`{"access_token":"abc","nested":{"refresh_token":"def","safe":"ok"},"list":[{"id_token":"ghi"},{"value":"ok"}]}`) | ||
| sanitized := SanitizeJSON(payload) | ||
|
|
||
| var decoded map[string]interface{} | ||
| if err := json.Unmarshal(sanitized, &decoded); err != nil { | ||
| t.Fatalf("failed to unmarshal sanitized json: %v", err) | ||
| } | ||
|
|
||
| if decoded["access_token"] != redactedValue { | ||
| t.Fatalf("expected access_token to be redacted") | ||
| } | ||
| if decoded["nested"].(map[string]interface{})["refresh_token"] != redactedValue { | ||
| t.Fatalf("expected refresh_token to be redacted") | ||
| } | ||
| if decoded["nested"].(map[string]interface{})["safe"] != "ok" { | ||
| t.Fatalf("expected safe value to be preserved") | ||
| } | ||
| list := decoded["list"].([]interface{}) | ||
| if list[0].(map[string]interface{})["id_token"] != redactedValue { | ||
| t.Fatalf("expected id_token to be redacted") | ||
| } | ||
| } | ||
|
|
||
| func TestSanitizeJSONCaseInsensitive(t *testing.T) { | ||
| payload := []byte(`{"Access_Token":"abc","Client_Secret":"def"}`) | ||
| sanitized := SanitizeJSON(payload) | ||
|
|
||
| var decoded map[string]interface{} | ||
| if err := json.Unmarshal(sanitized, &decoded); err != nil { | ||
| t.Fatalf("failed to unmarshal sanitized json: %v", err) | ||
| } | ||
|
|
||
| if decoded["Access_Token"] != redactedValue { | ||
| t.Fatalf("expected Access_Token to be redacted") | ||
| } | ||
| if decoded["Client_Secret"] != redactedValue { | ||
| t.Fatalf("expected Client_Secret to be redacted") | ||
| } | ||
| } | ||
|
|
||
| func TestSanitizeJSONMalformed(t *testing.T) { | ||
| payload := []byte(`{"access_token":`) // malformed | ||
| sanitized := SanitizeJSON(payload) | ||
| if string(sanitized) != string(payload) { | ||
| t.Fatalf("expected malformed json to remain unchanged") | ||
| } | ||
| } | ||
|
|
||
| func TestSanitizeStringHeadersAndForm(t *testing.T) { | ||
| input := "Authorization: Bearer abc\nX-Api-Key: key\nContent-Type: text/plain\n\nclient_secret=secret&grant_type=password" | ||
| sanitized := SanitizeString(input) | ||
|
|
||
| if !containsLine(sanitized, "Authorization: "+redactedValue) { | ||
| t.Fatalf("expected authorization header redacted") | ||
| } | ||
| if !containsLine(sanitized, "X-Api-Key: "+redactedValue) { | ||
| t.Fatalf("expected api key header redacted") | ||
| } | ||
| if !containsLine(sanitized, "Content-Type: text/plain") { | ||
| t.Fatalf("expected content-type preserved") | ||
| } | ||
| if !containsLine(sanitized, "client_secret="+redactedValue+"&grant_type=password") { | ||
| t.Fatalf("expected form secret redacted") | ||
| } | ||
| } | ||
|
Comment on lines
+89
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| func TestSanitizeStringBasicAuth(t *testing.T) { | ||
| input := "Authorization: Basic dGVzdDp0ZXN0" | ||
| sanitized := SanitizeString(input) | ||
| if sanitized != "Authorization: "+redactedValue { | ||
| t.Fatalf("expected basic auth to be redacted") | ||
| } | ||
| } | ||
|
|
||
| func containsLine(input, needle string) bool { | ||
| return len(input) > 0 && strings.Contains(input, needle) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These two
log.Printflines are added unconditionally (outside any--verboseguard). The original code didn't log tokens at all after the server shutdown. Now every SSO login prints[REDACTED]tokens to the terminal. Not a security leak since you're masking them, but it's adding new output that wasn't there before. Is that intentional? Might confuse users or break scripts that parse stdout.