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
5 changes: 4 additions & 1 deletion 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,6 +294,8 @@ func oauth2login(
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
log.Printf("Token: %s\n", utils.MaskSecret(tokenString))
log.Printf("Refresh Token: %s\n", utils.MaskSecret(refreshToken))
Comment on lines +297 to +298

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These two log.Printf lines are added unconditionally (outside any --verbose guard). The original code didn't log tokens at all after the server shutdown. Now every SSO login prints [REDACTED] tokens to the terminal. Not a security leak since you're masking them, but it's adding new output that wasn't there before. Is that intentional? Might confuse users or break scripts that parse stdout.

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 @@ -24,6 +24,8 @@ import (
"os"
"path/filepath"
strings "strings"

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

var (
Expand Down Expand Up @@ -76,7 +78,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 @@ -88,7 +90,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
}

Comment on lines +99 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SanitizeJSON uses json.Marshal which produces compact JSON. If the original dump was indented/prettified for debugging, the sanitized output comes back minified. Could be disorienting when comparing before/after. Consider using json.MarshalIndent to preserve formatting.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No tests for empty/nil inputs in SanitizeString or SanitizeHeaders. SanitizeString("") should return "" but it's not verified.

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")
}
}
Comment on lines +89 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

containsLine uses plain strings.Contains, not an actual line-boundary check. The name is misleading, a match inside a longer string would pass. Fine for these tests but worth renaming to containsSubstr or similar for clarity.


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