diff --git a/cmd/login.go b/cmd/login.go index 2b624f7..4632b66 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -20,6 +20,7 @@ import ( "github.com/microcks/microcks-cli/pkg/connectors" "github.com/microcks/microcks-cli/pkg/errors" "github.com/microcks/microcks-cli/pkg/util/rand" + "github.com/microcks/microcks-cli/pkg/utils" "github.com/skratchdot/open-golang/open" "github.com/spf13/cobra" "golang.org/x/oauth2" @@ -219,7 +220,7 @@ func oauth2login( // Authorization redirect callback from OAuth2 auth flow. // Handles both implicit and authorization code flow callbackHandler := func(w http.ResponseWriter, r *http.Request) { - log.Printf("Callback: %s\n", r.URL) + log.Printf("Callback: %s\n", utils.SanitizeString(r.URL.String())) if formErr := r.FormValue("error"); formErr != "" { handleErr(w, fmt.Sprintf("%s: %s", formErr, r.FormValue("error_description"))) @@ -293,8 +294,8 @@ func oauth2login( ctx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() _ = srv.Shutdown(ctx) - log.Printf("Token: %s\n", tokenString) - log.Printf("Refresh Token: %s\n", refreshToken) + log.Printf("Token: %s\n", utils.MaskSecret(tokenString)) + log.Printf("Refresh Token: %s\n", utils.MaskSecret(refreshToken)) return tokenString, refreshToken } diff --git a/pkg/config/config.go b/pkg/config/config.go index a1bd8d5..b703c04 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -25,6 +25,8 @@ import ( "os" "path/filepath" strings "strings" + + "github.com/microcks/microcks-cli/pkg/utils" ) var ( @@ -77,7 +79,7 @@ func DumpRequestIfRequired(name string, req *http.Request, body bool) { if err != nil { fmt.Println("Got error while dumping request out") } - fmt.Printf("%s", dump) + fmt.Printf("%s", utils.SanitizeString(string(dump))) } } @@ -89,7 +91,7 @@ func DumpResponseIfRequired(name string, resp *http.Response, body bool) { if err != nil { fmt.Println("Got error while dumping response") } - fmt.Printf("%s", dump) + fmt.Printf("%s", utils.SanitizeString(string(dump))) if body { fmt.Println("") } diff --git a/pkg/utils/sanitize.go b/pkg/utils/sanitize.go new file mode 100644 index 0000000..d5a4f9e --- /dev/null +++ b/pkg/utils/sanitize.go @@ -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 + } + + 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 +} diff --git a/pkg/utils/sanitize_test.go b/pkg/utils/sanitize_test.go new file mode 100644 index 0000000..8c3902e --- /dev/null +++ b/pkg/utils/sanitize_test.go @@ -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") + } +} + +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) +}