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
7 changes: 4 additions & 3 deletions cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")))
Expand Down Expand Up @@ -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
}

Expand Down
6 changes: 4 additions & 2 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import (
"os"
"path/filepath"
strings "strings"

"github.com/microcks/microcks-cli/pkg/utils"
)

var (
Expand Down Expand Up @@ -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)))
}
}

Expand All @@ -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("")
}
Expand Down
214 changes: 214 additions & 0 deletions pkg/utils/sanitize.go
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
}

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
}
117 changes: 117 additions & 0 deletions pkg/utils/sanitize_test.go
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")
}
}

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