diff --git a/pkg/config/config.go b/pkg/config/config.go index dc165a5..0b2559c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -18,12 +18,15 @@ package config import ( "crypto/tls" "crypto/x509" + "encoding/json" "fmt" "net/http" "net/http/httputil" + "net/url" "os" "regexp" "strings" + "unicode" ) var ( @@ -35,13 +38,51 @@ var ( Verbose bool = false ) +const redactedValue = "[REDACTED]" + var sensitiveHeaderPattern = regexp.MustCompile( - `(?im)^(Authorization:\s*)(Bearer\s+)?(.+)$`, + `(?im)^((?:Authorization|Proxy-Authorization|X-Auth-Token|Cookie|Set-Cookie):[ \t]*)[^\r\n]*`, ) -var sensitiveParamPattern = regexp.MustCompile( - `(?i)(access_token|refresh_token|id_token|code)=([^&\s]+)`, + +var contentTypePattern = regexp.MustCompile(`(?im)^Content-Type:[ \t]*([^\r\n]+)`) + +// sensitiveTextPattern matches key-value pairs in text and query strings. +// '?' is excluded to avoid swallowing query strings in URLs. +var sensitiveTextPattern = regexp.MustCompile( + `(["']?)([A-Za-z0-9_-]+)(["']?[ \t]*[:=][ \t]*)(["']?)([^"'&,}?\r\n\s]+)(["']?)`, ) +var sensitiveValueKeys = map[string]struct{}{ + "accesstoken": {}, + "refreshtoken": {}, + "idtoken": {}, + "authtoken": {}, + "token": {}, + "clientsecret": {}, + "password": {}, + "secret": {}, + "code": {}, + "codeverifier": {}, + "authorization": {}, + "apikey": {}, +} + +func normalizeKey(key string) string { + var b strings.Builder + for _, r := range key { + if r == '_' || r == '-' || r == ' ' { + continue + } + b.WriteRune(unicode.ToLower(r)) + } + return b.String() +} + +func isSensitiveKey(key string) bool { + _, ok := sensitiveValueKeys[normalizeKey(key)] + return ok +} + // CreateTLSConfig wraps the creation of tls.Config object for use with HTTP Client for example. func CreateTLSConfig() *tls.Config { tlsConfig := &tls.Config{} @@ -100,9 +141,121 @@ func DumpResponseIfRequired(name string, resp *http.Response, body bool) { } } -// redactSensitiveContent masks OAuth tokens and credentials in HTTP dump output. func redactSensitiveContent(dump string) string { - redacted := sensitiveHeaderPattern.ReplaceAllString(dump, "${1}[REDACTED]") - redacted = sensitiveParamPattern.ReplaceAllString(redacted, "${1}=[REDACTED]") - return redacted + head, sep, body := splitHTTPMessage(dump) + contentType := contentTypeOf(head) + + head = sensitiveHeaderPattern.ReplaceAllString(head, "${1}"+redactedValue) + head = redactText(head) + + if sep == "" { + return head + } + return head + sep + redactBody(contentType, body) +} + +func splitHTTPMessage(dump string) (head, sep, body string) { + for _, candidate := range []string{"\r\n\r\n", "\n\n"} { + if before, after, found := strings.Cut(dump, candidate); found { + return before, candidate, after + } + } + return dump, "", "" +} + +func contentTypeOf(head string) string { + if m := contentTypePattern.FindStringSubmatch(head); m != nil { + return strings.ToLower(m[1]) + } + return "" +} + +func redactBody(contentType, body string) string { + switch { + case strings.Contains(contentType, "json"): + if out, ok := redactJSONBody(body); ok { + return out + } + case strings.Contains(contentType, "x-www-form-urlencoded"): + if out, ok := redactFormBody(body); ok { + return out + } + } + return redactText(body) +} + +func redactJSONBody(body string) (string, bool) { + trimmed := strings.TrimSpace(body) + if trimmed == "" { + return body, true + } + + decoder := json.NewDecoder(strings.NewReader(trimmed)) + decoder.UseNumber() + + var value interface{} + if err := decoder.Decode(&value); err != nil { + return "", false + } + if decoder.More() { + return "", false + } + + // json.Marshal reorders keys and HTML-escapes, so dumped bodies are not byte-faithful. + out, err := json.Marshal(redactJSONValue(value)) + if err != nil { + return "", false + } + return string(out), true +} + +func redactJSONValue(value interface{}) interface{} { + switch typed := value.(type) { + case map[string]interface{}: + for k, v := range typed { + if isSensitiveKey(k) { + typed[k] = redactedValue + continue + } + typed[k] = redactJSONValue(v) + } + return typed + case []interface{}: + for i, v := range typed { + typed[i] = redactJSONValue(v) + } + return typed + } + return value +} + +func redactFormBody(body string) (string, bool) { + trimmed := strings.TrimRight(body, "\r\n") + if trimmed == "" { + return body, true + } + + values, err := url.ParseQuery(trimmed) + if err != nil { + return "", false + } + for key, vals := range values { + if !isSensitiveKey(key) { + continue + } + for i := range vals { + vals[i] = redactedValue + } + } + return values.Encode() + body[len(trimmed):], true +} + +func redactText(body string) string { + return sensitiveTextPattern.ReplaceAllStringFunc(body, func(match string) string { + groups := sensitiveTextPattern.FindStringSubmatch(match) + if !isSensitiveKey(groups[2]) { + return match + } + return groups[1] + groups[2] + groups[3] + groups[4] + redactedValue + groups[6] + }) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d15a272..c7d7483 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -456,3 +456,201 @@ func TestWatchConfig(t *testing.T) { } } } + +var secretMarkers = []string{ + "eyJLEAKEDACCESS", + "eyJLEAKEDREFRESH", + "eyJLEAKEDID", + "eyJPROBELEAK", + "LEAKEDCLIENTSECRET", + "LEAKEDPASSWORD", + "LEAKEDAUTHCODE", +} + +func assertNoCredentialLeaked(t testing.TB, got string) { + t.Helper() + for _, marker := range secretMarkers { + assert.NotContains(t, got, marker, "credential leaked into output") + } +} + +func TestRedactSensitiveContentInBodies(t *testing.T) { + tests := []struct { + name string + dump string + mustHave []string + mustNotHave []string + }{ + { + name: "json token response", + dump: "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "\r\n" + + `{"access_token":"eyJLEAKEDACCESS","refresh_token":"eyJLEAKEDREFRESH","token_type":"Bearer","expires_in":300}`, + mustHave: []string{"token_type", "Bearer", "expires_in", "300"}, + }, + { + name: "json authtoken in body", + dump: "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "\r\n" + + `{"authToken":"eyJPROBELEAK"}`, + mustHave: []string{`{"authToken":"[REDACTED]"}`}, + }, + { + name: "json test request with oauth2 context", + dump: "POST /api/tests HTTP/1.1\r\n" + + "Content-Type: application/json; charset=utf-8\r\n" + + "Authorization: Bearer eyJLEAKEDACCESS\r\n" + + "\r\n" + + `{"serviceId":"Beer Catalog:0.9","oAuth2Context":{"clientId":"cli","clientSecret":"LEAKEDCLIENTSECRET","username":"bob","password":"LEAKEDPASSWORD","grantType":"PASSWORD"}}`, + mustHave: []string{"Beer Catalog:0.9", "clientId", "bob", "PASSWORD"}, + }, + { + name: "form encoded token exchange", + dump: "POST /token HTTP/1.1\r\n" + + "Content-Type: application/x-www-form-urlencoded\r\n" + + "\r\n" + + "grant_type=authorization_code&code=LEAKEDAUTHCODE&client_secret=LEAKEDCLIENTSECRET", + mustHave: []string{"grant_type", "authorization_code"}, + }, + { + name: "form encoded auth-token", + dump: "POST /api/login HTTP/1.1\r\n" + + "Content-Type: application/x-www-form-urlencoded\r\n" + + "\r\n" + + "auth-token=eyJPROBELEAK", + mustHave: []string{"auth-token=%5BREDACTED%5D"}, + }, + { + name: "nested and array json", + dump: "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "\r\n" + + `{"sessions":[{"user":"bob","credentials":{"idToken":"eyJLEAKEDID"}}]}`, + mustHave: []string{"sessions", "bob"}, + }, + { + name: "chunked json falls back to text redaction", + dump: "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "3a\r\n" + `{"access_token":"eyJLEAKEDACCESS"}` + "\r\n0\r\n\r\n", + }, + { + name: "oauth code in request line", + dump: "GET /auth/callback?state=abc&code=LEAKEDAUTHCODE HTTP/1.1\r\n" + + "Host: localhost:58085\r\n" + + "\r\n", + mustHave: []string{"state=abc", "Host: localhost:58085"}, + }, + { + name: "credentials in both header and body", + dump: "POST /api/tests HTTP/1.1\r\n" + + "Authorization: Bearer eyJLEAKEDACCESS\r\n" + + "Content-Type: application/json\r\n" + + "\r\n" + + `{"password":"LEAKEDPASSWORD"}`, + mustHave: []string{"Authorization: [REDACTED]"}, + }, + { + name: "non sensitive body is preserved", + dump: "HTTP/1.1 200 OK\r\n" + + "Content-Type: application/json\r\n" + + "\r\n" + + `{"id":"abc123","success":true,"elapsedTime":42}`, + mustHave: []string{"abc123", "true", "42"}, + mustNotHave: []string{"[REDACTED]"}, + }, + { + name: "header only dump without body", + dump: "GET /api/keycloak/config HTTP/1.1\r\n" + + "Accept: application/json", + mustHave: []string{"Accept: application/json"}, + mustNotHave: []string{"[REDACTED]"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := redactSensitiveContent(tt.dump) + assertNoCredentialLeaked(t, got) + for _, want := range tt.mustHave { + assert.Contains(t, got, want, "expected value to survive redaction") + } + for _, unwanted := range tt.mustNotHave { + assert.NotContains(t, got, unwanted) + } + }) + } +} + +func TestRedactSensitiveContentPreservesCRLF(t *testing.T) { + dump := "GET / HTTP/1.1\r\nAuthorization: Bearer eyJLEAKEDACCESS\r\nAccept: */*\r\n\r\n" + got := redactSensitiveContent(dump) + assert.Contains(t, got, "[REDACTED]\r\nAccept:", "CRLF line ending was not preserved around the redacted header") +} + +func TestIsSensitiveKeyMatchesSpellingVariants(t *testing.T) { + sensitive := []string{"access_token", "accessToken", "Access-Token", "ACCESS_TOKEN", "clientSecret", "client_secret", "authToken", "auth-token", "AUTH_TOKEN"} + for _, key := range sensitive { + assert.True(t, isSensitiveKey(key), "expected %q to be treated as sensitive", key) + } + + for _, key := range []string{"token_type", "tokenType", "serviceId", "expires_in"} { + assert.False(t, isSensitiveKey(key), "did not expect %q to be treated as sensitive", key) + } +} + +func TestDumpHelpersRedactCredentials(t *testing.T) { + oldVerbose := Verbose + Verbose = true + defer func() { Verbose = oldVerbose }() + + t.Run("keycloak token response", func(t *testing.T) { + body := `{"access_token":"eyJLEAKEDACCESS","refresh_token":"eyJLEAKEDREFRESH"}` + resp := &http.Response{ + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: int64(len(body)), + } + assertNoCredentialLeaked(t, captureStdout(t, func() { + DumpResponseIfRequired("Keycloak for getting token", resp, true) + })) + }) + + t.Run("test creation request", func(t *testing.T) { + payload := `{"serviceId":"x","oAuth2Context":{"clientSecret":"LEAKEDCLIENTSECRET","password":"LEAKEDPASSWORD"}}` + req, err := http.NewRequest("POST", "https://microcks.example.com/api/tests", strings.NewReader(payload)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Authorization", "Bearer eyJLEAKEDACCESS") + + assertNoCredentialLeaked(t, captureStdout(t, func() { + DumpRequestIfRequired("Microcks for creating test", req, true) + })) + }) +} + +func TestDumpResponseLeavesBodyReadable(t *testing.T) { + oldVerbose := Verbose + Verbose = true + defer func() { Verbose = oldVerbose }() + + body := `{"access_token":"eyJLEAKEDACCESS"}` + resp := &http.Response{ + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: int64(len(body)), + } + captureStdout(t, func() { DumpResponseIfRequired("token", resp, true) }) + + got, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, body, string(got), "body was altered by dump") +}