diff --git a/.changes/next-release/bugfix-core-ki36vzwj.json b/.changes/next-release/bugfix-core-ki36vzwj.json new file mode 100644 index 0000000..608b1f3 --- /dev/null +++ b/.changes/next-release/bugfix-core-ki36vzwj.json @@ -0,0 +1,5 @@ +{ + "type": "bugfix", + "category": "core", + "description": "Redact credential fields (embedded kubeconfig, tokens, client certs/keys, secrets) in --debug request/response logging so 'update-kubeconfig --debug' and similar no longer print long-lived credentials to stderr (SEC-07)" +} diff --git a/go/internal/client/client.go b/go/internal/client/client.go index 6c209fb..939c1e8 100644 --- a/go/internal/client/client.go +++ b/go/internal/client/client.go @@ -209,7 +209,7 @@ func (c *GreenodeClient) requestRaw(method, path string, params map[string]strin if c.debug { fmt.Fprintf(os.Stderr, "[debug] %s %s\n", method, fullURL) if jsonBody != nil { - fmt.Fprintf(os.Stderr, "[debug] request body: %s\n", string(jsonBody)) + fmt.Fprintf(os.Stderr, "[debug] request body: %s\n", redactDebugBody(string(jsonBody))) } } @@ -228,7 +228,7 @@ func (c *GreenodeClient) requestRaw(method, path string, params map[string]strin resp.Body.Close() if c.debug { - fmt.Fprintf(os.Stderr, "[debug] response %d: %s\n", resp.StatusCode, string(respBody)) + fmt.Fprintf(os.Stderr, "[debug] response %d: %s\n", resp.StatusCode, redactDebugBody(string(respBody))) } // 401 — refresh token and retry once diff --git a/go/internal/client/redact.go b/go/internal/client/redact.go new file mode 100644 index 0000000..b121d3a --- /dev/null +++ b/go/internal/client/redact.go @@ -0,0 +1,60 @@ +package client + +import ( + "encoding/json" + "strings" +) + +// redactDebugBody returns a copy of a JSON request/response body safe to print +// in --debug output: values of credential-bearing fields (e.g. the embedded +// kubeconfig, tokens, client certs/keys, secrets) are replaced with +// "[REDACTED]". Only used for debug logging — the real body is never modified. +// Non-JSON bodies are returned unchanged (VKS/vServer bodies are JSON). +func redactDebugBody(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return raw + } + var v interface{} + if err := json.Unmarshal([]byte(trimmed), &v); err != nil { + return raw + } + redactValue(v) + out, err := json.Marshal(v) + if err != nil { + return raw + } + return string(out) +} + +func redactValue(v interface{}) { + switch t := v.(type) { + case map[string]interface{}: + for k, val := range t { + if isSensitiveKey(k) { + t[k] = "[REDACTED]" + continue + } + redactValue(val) + } + case []interface{}: + for _, item := range t { + redactValue(item) + } + } +} + +// isSensitiveKey reports whether a JSON key's value should be redacted in debug +// output. Matches known credential fields plus common secret-ish key patterns. +func isSensitiveKey(k string) bool { + lk := strings.ToLower(k) + switch lk { + case "kubeconfig", "token", "client-certificate-data", "client-key-data", + "client_secret", "clientsecret", "password": + return true + } + return strings.Contains(lk, "secret") || + strings.Contains(lk, "password") || + strings.HasSuffix(lk, "token") || + strings.HasSuffix(lk, "key-data") +} diff --git a/go/internal/client/redact_test.go b/go/internal/client/redact_test.go new file mode 100644 index 0000000..b0fdcf5 --- /dev/null +++ b/go/internal/client/redact_test.go @@ -0,0 +1,53 @@ +package client + +import ( + "strings" + "testing" +) + +func TestRedactDebugBodyMasksKubeconfig(t *testing.T) { + in := `{"status":"ACTIVE","kubeConfig":"apiVersion: v1\nclient-key-data: SUPERSECRETKEY","renewalWarning":false}` + out := redactDebugBody(in) + if strings.Contains(out, "SUPERSECRETKEY") { + t.Errorf("kubeConfig value not redacted: %s", out) + } + if !strings.Contains(out, "[REDACTED]") { + t.Errorf("expected [REDACTED] marker: %s", out) + } + if !strings.Contains(out, "ACTIVE") { + t.Errorf("non-sensitive field lost: %s", out) + } +} + +func TestRedactDebugBodyNestedAndTokens(t *testing.T) { + in := `{"data":{"token":"abc123","name":"ok"},"clientSecret":"shh","items":[{"bearerToken":"xyz"}]}` + out := redactDebugBody(in) + for _, leaked := range []string{"abc123", "shh", "xyz"} { + if strings.Contains(out, leaked) { + t.Errorf("secret %q leaked: %s", leaked, out) + } + } + if !strings.Contains(out, "ok") { + t.Errorf("non-sensitive value lost: %s", out) + } +} + +func TestRedactDebugBodyKeepsNonSensitive(t *testing.T) { + in := `{"id":"cls-1","numNodes":3,"status":"CREATING"}` + out := redactDebugBody(in) + for _, keep := range []string{"cls-1", "3", "CREATING"} { + if !strings.Contains(out, keep) { + t.Errorf("non-sensitive %q should be kept: %s", keep, out) + } + } + if strings.Contains(out, "[REDACTED]") { + t.Errorf("nothing should be redacted here: %s", out) + } +} + +func TestRedactDebugBodyNonJSONPassthrough(t *testing.T) { + in := "not json" + if got := redactDebugBody(in); got != in { + t.Errorf("non-JSON should pass through unchanged, got %q", got) + } +}