Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 160 additions & 7 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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{}
Expand Down Expand Up @@ -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]
})
}
198 changes: 198 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One gap here: authToken / auth-token values leak through — authtoken isn't in sensitiveValueKeys, and auth-token is the exact key the CLI's own config uses:

dump := "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n" +
{"authToken":"eyJPROBELEAK"}
redactSensitiveContent(dump) // value survives
Adding "authtoken": {} to the set covers both spellings via the normalizer.

Minor, non-blocking: json.Marshal re-encoding reorders keys and HTML-escapes, so dumped bodies aren't byte-faithful (and won't match Content-Length) — worth a one-line code comment.

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")
}