From 77d70d4f86177617f686cd9a56b70ce6c6f3b6a8 Mon Sep 17 00:00:00 2001 From: gyanranjanpanda Date: Mon, 17 Aug 2026 14:04:16 +0530 Subject: [PATCH 1/2] fix(security): redact credentials in JSON and form bodies under --verbose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regex introduced in 0f8b000 only matched name=value (URL form encoding). The two dump sites that carry secrets both produce JSON bodies, so the pattern never fired. Additionally, the OAuth2 body field names (clientSecret, password, refreshToken) were absent from the alternation entirely. Replace the single catch-all regex with a structured approach: - Parse JSON bodies with encoding/json and mask sensitive keys by name. - Parse form bodies with url.ParseQuery and mask by key name. - Fall back to text-pattern matching for chunked or unparseable bodies. - Match key names after normalising snake_case / camelCase / kebab-case so every spelling variant of the same credential is covered. - Widen the header pattern to cover Proxy-Authorization, X-Auth-Token, Cookie and Set-Cookie in addition to Authorization. Sensitive key set: accessToken, refreshToken, idToken, token, clientSecret, password, secret, code, codeVerifier, authorization, apiKey. Non-sensitive metadata (token_type, expires_in, serviceId, …) is preserved so --verbose output remains useful for debugging. Fixes: #503 (follow-up to 0f8b000, closes #449) Signed-off-by: gyanranjanpanda --- pkg/config/config.go | 193 ++++++++++++++++++++++++++-- pkg/config/config_test.go | 258 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 444 insertions(+), 7 deletions(-) create mode 100644 pkg/config/config_test.go diff --git a/pkg/config/config.go b/pkg/config/config.go index 2c473f8b..7d99e07a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -18,13 +18,16 @@ package config import ( "crypto/tls" "crypto/x509" + "encoding/json" "fmt" "net/http" "net/http/httputil" + "net/url" "os" "path/filepath" "regexp" "strings" + "unicode" ) var ( @@ -38,13 +41,60 @@ var ( ConfigPath = filepath.Join(os.Getenv("HOME"), ".microcks-cli", "config.yaml") ) +const redactedValue = "[REDACTED]" + +// sensitiveHeaderPattern matches headers whose value is a credential. The auth +// scheme is kept so dumps stay useful for debugging. var sensitiveHeaderPattern = regexp.MustCompile( - `(?im)^(Authorization:\s*)(Bearer\s+)?(.+)$`, + `(?im)^((?:Authorization|Proxy-Authorization|X-Auth-Token|Cookie|Set-Cookie):[ \t]*)(Bearer[ \t]+|Basic[ \t]+|Digest[ \t]+)?[^\r\n]*`, ) -var sensitiveParamPattern = regexp.MustCompile( - `(?i)(access_token|refresh_token|id_token|code)=([^&\s]+)`, + +// contentTypePattern extracts the media type from a dumped header block. +var contentTypePattern = regexp.MustCompile(`(?im)^Content-Type:[ \t]*([^\r\n]+)`) + +// sensitiveTextPattern matches "key=value", `"key":"value"` and quoted variants +// in bodies that cannot be parsed structurally (chunked framing, truncated or +// unknown encodings). Key membership is checked in the replacement callback. +var sensitiveTextPattern = regexp.MustCompile( + `(["']?)([A-Za-z0-9_-]+)(["']?[ \t]*[:=][ \t]*)(["']?)([^"'&,}\r\n\s]+)(["']?)`, ) +// sensitiveValueKeys holds the normalized parameter and field names whose +// values must never reach verbose output, whatever encoding carries them. +// Matching is on the name, not on the delimiter, so form bodies +// (access_token=...) and JSON bodies ("accessToken": "...") are covered alike. +var sensitiveValueKeys = map[string]struct{}{ + "accesstoken": {}, + "refreshtoken": {}, + "idtoken": {}, + "token": {}, + "clientsecret": {}, + "password": {}, + "secret": {}, + "code": {}, + "codeverifier": {}, + "authorization": {}, + "apikey": {}, +} + +// normalizeKey folds a name so snake_case, camelCase, kebab-case and +// capitalized spellings of the same field compare equal. +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{} @@ -103,9 +153,138 @@ func DumpResponseIfRequired(name string, resp *http.Response, body bool) { } } -// redactSensitiveContent masks OAuth tokens and credentials in HTTP dump output. +// redactSensitiveContent masks OAuth tokens and credentials in HTTP dump +// output. Headers and body are redacted separately: the body is parsed +// according to its Content-Type so that credentials are matched by field name +// rather than by wire delimiter. func redactSensitiveContent(dump string) string { - redacted := sensitiveHeaderPattern.ReplaceAllString(dump, "${1}[REDACTED]") - redacted = sensitiveParamPattern.ReplaceAllString(redacted, "${1}=[REDACTED]") - return redacted + head, sep, body := splitHTTPMessage(dump) + head = sensitiveHeaderPattern.ReplaceAllString(head, "${1}${2}"+redactedValue) + if sep == "" { + return head + } + return head + sep + redactBody(contentTypeOf(head), body) +} + +// splitHTTPMessage divides a dumped HTTP message into its header block, the +// blank-line separator and its body. sep is empty when there is no body. +func splitHTTPMessage(dump string) (head, sep, body string) { + // "\r\n\r\n" is checked first: it contains no "\n\n", so a CRLF message can + // never be split on the LF-only boundary by mistake. + 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 "" +} + +// redactBody masks credential-bearing fields in a dumped body, falling back to +// text matching whenever the body cannot be parsed as its declared type. +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) +} + +// redactJSONBody rewrites a JSON body with sensitive members masked. It reports +// false when the body is not a single well-formed JSON value, so the caller can +// fall back rather than emit a truncated re-encoding. +func redactJSONBody(body string) (string, bool) { + trimmed := strings.TrimSpace(body) + if trimmed == "" { + return body, true + } + + decoder := json.NewDecoder(strings.NewReader(trimmed)) + // Preserve the original number formatting instead of round-tripping + // every number through float64. + decoder.UseNumber() + + var value interface{} + if err := decoder.Decode(&value); err != nil { + return "", false + } + // Trailing content means this was not a bare JSON body (chunked transfer + // framing, for instance); re-encoding would silently drop it. + if decoder.More() { + return "", false + } + + 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 +} + +// redactFormBody rewrites a form-urlencoded body with sensitive parameters +// masked. It reports false when the body cannot be parsed as a query string. +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 + } + } + // Preserve whatever trailing newlines the dump carried. + return values.Encode() + body[len(trimmed):], true +} + +// redactText masks sensitive key/value pairs in a body of unknown or +// unparseable encoding. +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 new file mode 100644 index 00000000..8d16c1b7 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,258 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package config + +import ( + "bytes" + "io" + "net/http" + "os" + "strings" + "testing" +) + +// secretMarkers are values that must never survive redaction, whatever +// encoding carries them. +var secretMarkers = []string{ + "eyJLEAKEDACCESS", + "eyJLEAKEDREFRESH", + "eyJLEAKEDID", + "LEAKEDCLIENTSECRET", + "LEAKEDPASSWORD", + "LEAKEDAUTHCODE", +} + +func assertRedacted(t *testing.T, got string) { + t.Helper() + for _, marker := range secretMarkers { + if strings.Contains(got, marker) { + t.Errorf("credential %q leaked into output:\n%s", marker, got) + } + } +} + +func TestRedactSensitiveContent(t *testing.T) { + tests := []struct { + name string + dump string + mustNotHave []string + mustHave []string + }{ + { + // Regression: the Keycloak token endpoint answers in JSON, so the + // form-encoded "access_token=..." shape never appears on the wire. + 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"}, + }, + { + // Regression: oAuth2Context carries a client secret and an end-user + // password in the body of POST /api/tests. + 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", "cli", "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: "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"}, + }, + { + // Chunked framing defeats structural parsing; the text fallback + // must still catch the credential. + 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: "authorization header keeps its scheme", + dump: "GET /api/tests/1 HTTP/1.1\r\n" + + "Authorization: Bearer eyJLEAKEDACCESS\r\n" + + "Accept: application/json\r\n" + + "\r\n", + mustHave: []string{"Bearer [REDACTED]", "Accept: application/json"}, + mustNotHave: []string{"Bearer eyJ"}, + }, + { + name: "basic auth header", + dump: "POST /token HTTP/1.1\r\n" + + "Authorization: Basic dXNlcjpMRUFLRURQQVNTV09SRA==\r\n" + + "\r\n", + mustHave: []string{"Basic [REDACTED]"}, + mustNotHave: []string{"dXNlcjpMRUFLRURQQVNTV09SRA=="}, + }, + { + // Nothing sensitive: the dump must survive untouched so --verbose + // stays useful. + 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) + assertRedacted(t, got) + for _, want := range tt.mustHave { + if !strings.Contains(got, want) { + t.Errorf("expected %q to survive redaction, got:\n%s", want, got) + } + } + for _, unwanted := range tt.mustNotHave { + if strings.Contains(got, unwanted) { + t.Errorf("did not expect %q in output, got:\n%s", unwanted, got) + } + } + }) + } +} + +func TestRedactSensitiveContentPreservesCRLF(t *testing.T) { + dump := "GET / HTTP/1.1\r\nAuthorization: Bearer eyJLEAKEDACCESS\r\nAccept: */*\r\n\r\n" + got := redactSensitiveContent(dump) + if !strings.Contains(got, "[REDACTED]\r\nAccept:") { + t.Errorf("CRLF line ending was not preserved around the redacted header:\n%q", got) + } +} + +func TestNormalizeKeyMatchesSpellingVariants(t *testing.T) { + sensitive := []string{"access_token", "accessToken", "Access-Token", "ACCESS_TOKEN", "clientSecret", "client_secret"} + for _, key := range sensitive { + if !isSensitiveKey(key) { + t.Errorf("expected %q to be treated as sensitive", key) + } + } + // token_type is metadata, not a credential, and must stay readable. + for _, key := range []string{"token_type", "tokenType", "serviceId", "expires_in"} { + if isSensitiveKey(key) { + t.Errorf("did not expect %q to be treated as sensitive", key) + } + } +} + +func captureStdout(t *testing.T, f func()) string { + t.Helper() + original := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = original }() + + f() + w.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("failed to read captured output: %v", err) + } + return buf.String() +} + +// End-to-end over the dump helpers, on the exact call shapes used by +// test/import/importURL under --verbose. +func TestDumpHelpersRedactCredentials(t *testing.T) { + previous := Verbose + Verbose = true + defer func() { Verbose = previous }() + + t.Run("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)), + } + assertRedacted(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)) + if err != nil { + t.Fatalf("failed to build request: %v", err) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Authorization", "Bearer eyJLEAKEDACCESS") + + assertRedacted(t, captureStdout(t, func() { + DumpRequestIfRequired("Microcks for creating test", req, true) + })) + }) +} + +// The response body must remain readable by the caller after being dumped. +func TestDumpResponseLeavesBodyReadable(t *testing.T) { + previous := Verbose + Verbose = true + defer func() { Verbose = previous }() + + 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) + if err != nil { + t.Fatalf("failed to read body after dump: %v", err) + } + if string(got) != body { + t.Errorf("body altered by dump: got %q, want %q", got, body) + } +} From d0549dadb686ecce9f8b386cd2faf63a492bd3e6 Mon Sep 17 00:00:00 2001 From: gyanranjanpanda Date: Tue, 18 Aug 2026 00:55:27 +0530 Subject: [PATCH 2/2] fix(security): add authtoken redaction and clean up comment verbosity - Add authtoken to sensitiveValueKeys to cover authToken and auth-token in JSON and form bodies. - Add one-line note regarding json.Marshal re-encoding. - Reduce comment noise across redaction logic and tests. - Add test coverage for authToken and auth-token redaction. Signed-off-by: gyanranjanpanda --- pkg/config/config.go | 45 ++++----------------------------------- pkg/config/config_test.go | 37 ++++++++++++++++---------------- 2 files changed, 22 insertions(+), 60 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index b7238853..0b2559c8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -40,34 +40,23 @@ var ( const redactedValue = "[REDACTED]" -// sensitiveHeaderPattern matches headers whose whole value is a credential. var sensitiveHeaderPattern = regexp.MustCompile( `(?im)^((?:Authorization|Proxy-Authorization|X-Auth-Token|Cookie|Set-Cookie):[ \t]*)[^\r\n]*`, ) -// contentTypePattern extracts the media type from a dumped header block. var contentTypePattern = regexp.MustCompile(`(?im)^Content-Type:[ \t]*([^\r\n]+)`) -// sensitiveTextPattern matches "key=value", `"key":"value"` and quoted variants. -// It covers query strings in request lines and redirect headers, plus bodies -// that cannot be parsed structurally (chunked framing, unknown encodings). Key -// membership is checked in the replacement callback. -// -// '?' is excluded from the value class so that a URL such as -// "http://host?access_token=x" does not let the leading "http://host" match -// swallow the query string before its parameters are examined. +// 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]+)(["']?)`, ) -// sensitiveValueKeys holds the normalized parameter and field names whose -// values must never reach verbose output, whatever encoding carries them. -// Matching is on the name, not on the delimiter, so form bodies -// (access_token=...) and JSON bodies ("accessToken": "...") are covered alike. var sensitiveValueKeys = map[string]struct{}{ "accesstoken": {}, "refreshtoken": {}, "idtoken": {}, + "authtoken": {}, "token": {}, "clientsecret": {}, "password": {}, @@ -78,8 +67,6 @@ var sensitiveValueKeys = map[string]struct{}{ "apikey": {}, } -// normalizeKey folds a name so snake_case, camelCase, kebab-case and -// capitalized spellings of the same field compare equal. func normalizeKey(key string) string { var b strings.Builder for _, r := range key { @@ -154,17 +141,10 @@ func DumpResponseIfRequired(name string, resp *http.Response, body bool) { } } -// redactSensitiveContent masks OAuth tokens and credentials in HTTP dump -// output. Headers and body are redacted separately: the body is parsed -// according to its Content-Type so that credentials are matched by field name -// rather than by wire delimiter. func redactSensitiveContent(dump string) string { head, sep, body := splitHTTPMessage(dump) contentType := contentTypeOf(head) - // Credential-bearing headers are masked whole. The rest of the head still - // needs scanning: the request line and redirect targets carry OAuth - // parameters in their query string. head = sensitiveHeaderPattern.ReplaceAllString(head, "${1}"+redactedValue) head = redactText(head) @@ -174,11 +154,7 @@ func redactSensitiveContent(dump string) string { return head + sep + redactBody(contentType, body) } -// splitHTTPMessage divides a dumped HTTP message into its header block, the -// blank-line separator and its body. sep is empty when there is no body. func splitHTTPMessage(dump string) (head, sep, body string) { - // "\r\n\r\n" is checked first: it contains no "\n\n", so a CRLF message can - // never be split on the LF-only boundary by mistake. for _, candidate := range []string{"\r\n\r\n", "\n\n"} { if before, after, found := strings.Cut(dump, candidate); found { return before, candidate, after @@ -194,8 +170,6 @@ func contentTypeOf(head string) string { return "" } -// redactBody masks credential-bearing fields in a dumped body, falling back to -// text matching whenever the body cannot be parsed as its declared type. func redactBody(contentType, body string) string { switch { case strings.Contains(contentType, "json"): @@ -210,9 +184,6 @@ func redactBody(contentType, body string) string { return redactText(body) } -// redactJSONBody rewrites a JSON body with sensitive members masked. It reports -// false when the body is not a single well-formed JSON value, so the caller can -// fall back rather than emit a truncated re-encoding. func redactJSONBody(body string) (string, bool) { trimmed := strings.TrimSpace(body) if trimmed == "" { @@ -220,20 +191,17 @@ func redactJSONBody(body string) (string, bool) { } decoder := json.NewDecoder(strings.NewReader(trimmed)) - // Preserve the original number formatting instead of round-tripping - // every number through float64. decoder.UseNumber() var value interface{} if err := decoder.Decode(&value); err != nil { return "", false } - // Trailing content means this was not a bare JSON body (chunked transfer - // framing, for instance); re-encoding would silently drop it. 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 @@ -261,8 +229,6 @@ func redactJSONValue(value interface{}) interface{} { return value } -// redactFormBody rewrites a form-urlencoded body with sensitive parameters -// masked. It reports false when the body cannot be parsed as a query string. func redactFormBody(body string) (string, bool) { trimmed := strings.TrimRight(body, "\r\n") if trimmed == "" { @@ -281,12 +247,9 @@ func redactFormBody(body string) (string, bool) { vals[i] = redactedValue } } - // Preserve whatever trailing newlines the dump carried. return values.Encode() + body[len(trimmed):], true } -// redactText masks sensitive key/value pairs in a body of unknown or -// unparseable encoding. func redactText(body string) string { return sensitiveTextPattern.ReplaceAllStringFunc(body, func(match string) string { groups := sensitiveTextPattern.FindStringSubmatch(match) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 03f70a83..c7d74839 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -457,12 +457,11 @@ func TestWatchConfig(t *testing.T) { } } -// secretMarkers are values that must never survive redaction, whatever -// encoding carries them. var secretMarkers = []string{ "eyJLEAKEDACCESS", "eyJLEAKEDREFRESH", "eyJLEAKEDID", + "eyJPROBELEAK", "LEAKEDCLIENTSECRET", "LEAKEDPASSWORD", "LEAKEDAUTHCODE", @@ -475,9 +474,6 @@ func assertNoCredentialLeaked(t testing.TB, got string) { } } -// TestRedactSensitiveContentInBodies covers the encodings that carry -// credentials in request and response bodies. Matching is by field name, so -// neither JSON nor form spelling can slip past. func TestRedactSensitiveContentInBodies(t *testing.T) { tests := []struct { name string @@ -486,8 +482,6 @@ func TestRedactSensitiveContentInBodies(t *testing.T) { mustNotHave []string }{ { - // Regression: the Keycloak token endpoint answers in JSON, so the - // form-encoded "access_token=..." shape never appears on the wire. name: "json token response", dump: "HTTP/1.1 200 OK\r\n" + "Content-Type: application/json\r\n" + @@ -496,8 +490,14 @@ func TestRedactSensitiveContentInBodies(t *testing.T) { mustHave: []string{"token_type", "Bearer", "expires_in", "300"}, }, { - // Regression: oAuth2Context carries a client secret and an end-user - // password in the body of POST /api/tests. + 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" + @@ -514,6 +514,14 @@ func TestRedactSensitiveContentInBodies(t *testing.T) { "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" + @@ -523,8 +531,6 @@ func TestRedactSensitiveContentInBodies(t *testing.T) { mustHave: []string{"sessions", "bob"}, }, { - // Chunked framing defeats structural parsing; the text fallback - // must still catch the credential. name: "chunked json falls back to text redaction", dump: "HTTP/1.1 200 OK\r\n" + "Content-Type: application/json\r\n" + @@ -533,7 +539,6 @@ func TestRedactSensitiveContentInBodies(t *testing.T) { "3a\r\n" + `{"access_token":"eyJLEAKEDACCESS"}` + "\r\n0\r\n\r\n", }, { - // The authorization code travels in the request line, not the body. name: "oauth code in request line", dump: "GET /auth/callback?state=abc&code=LEAKEDAUTHCODE HTTP/1.1\r\n" + "Host: localhost:58085\r\n" + @@ -550,8 +555,6 @@ func TestRedactSensitiveContentInBodies(t *testing.T) { mustHave: []string{"Authorization: [REDACTED]"}, }, { - // Nothing sensitive: the dump must survive untouched so --verbose - // stays useful. name: "non sensitive body is preserved", dump: "HTTP/1.1 200 OK\r\n" + "Content-Type: application/json\r\n" + @@ -590,19 +593,16 @@ func TestRedactSensitiveContentPreservesCRLF(t *testing.T) { } func TestIsSensitiveKeyMatchesSpellingVariants(t *testing.T) { - sensitive := []string{"access_token", "accessToken", "Access-Token", "ACCESS_TOKEN", "clientSecret", "client_secret"} + 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) } - // Metadata, not credentials: these must stay readable. 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) } } -// TestDumpHelpersRedactCredentials exercises the dump helpers on the exact call -// shapes test/import/importURL use under --verbose. func TestDumpHelpersRedactCredentials(t *testing.T) { oldVerbose := Verbose Verbose = true @@ -635,7 +635,6 @@ func TestDumpHelpersRedactCredentials(t *testing.T) { }) } -// The response body must remain readable by the caller after being dumped. func TestDumpResponseLeavesBodyReadable(t *testing.T) { oldVerbose := Verbose Verbose = true