From 09b213dd606ea8d8ec5e7f032dbf3fcc84f35c77 Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Fri, 28 Aug 2026 20:14:09 +0200 Subject: [PATCH 1/3] fix(redaction): split camelCase keys in normalizeKey A lower-to-upper boundary now inserts an underscore, so accessToken and refreshToken match the registry instead of leaking into logs. Token-count fields such as promptTokens stay unredacted. Fixes #922 --- internal/redaction/audit_fixes_test.go | 86 ++++++++++++++++++++++++++ internal/redaction/redaction.go | 8 +++ 2 files changed, 94 insertions(+) diff --git a/internal/redaction/audit_fixes_test.go b/internal/redaction/audit_fixes_test.go index 28f9fc0f9..47ef8dfdb 100644 --- a/internal/redaction/audit_fixes_test.go +++ b/internal/redaction/audit_fixes_test.go @@ -281,3 +281,89 @@ func TestRedactValue_CompoundKeys(t *testing.T) { t.Errorf("nested session_secret not redacted: %v", inner["session_secret"]) } } + +func TestNormalizeKey_CamelCaseBoundaries(t *testing.T) { + tests := []struct{ in, want string }{ + {"accessToken", "access_token"}, + {"refreshToken", "refresh_token"}, + {"apiKey", "api_key"}, + {"access_token", "access_token"}, + {"APIKey", "apikey"}, + {"promptTokens", "prompt_tokens"}, + {"maxTokens", "max_tokens"}, + {"Authorization", "authorization"}, + {"x-api-key", "x_api_key"}, + } + for _, tc := range tests { + if got := normalizeKey(tc.in); got != tc.want { + t.Errorf("normalizeKey(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestIsSensitiveKey_CamelCaseCredentials(t *testing.T) { + o := Options{} + sensitive := []string{ + "accessToken", "refreshToken", "apiKey", + "clientSecret", "idToken", "sessionToken", + "AccessToken", "refresh_token", "access_token", + } + for _, k := range sensitive { + if !IsSensitiveKey(k, o) { + t.Errorf("expected %q to be sensitive", k) + } + } + notSensitive := []string{ + "promptTokens", "maxTokens", "completionTokens", + "tokenCount", "prompt_tokens", "max_tokens", + } + for _, k := range notSensitive { + if IsSensitiveKey(k, o) { + t.Errorf("expected %q to NOT be sensitive (false positive)", k) + } + } +} + +func TestRedactValue_CamelCaseTokenLeak(t *testing.T) { + const access = "leak-access-token-value" + const refresh = "leak-refresh-token-value" + const api = "leak-api-key-value" + const snake = "leak-snake-access-token" + in := map[string]any{ + "accessToken": access, + "refreshToken": refresh, + "apiKey": api, + "access_token": snake, + "promptTokens": 12, + } + out, ok := RedactValue(in, Options{}).(map[string]any) + if !ok { + t.Fatalf("expected map result, got %T", RedactValue(in, Options{})) + } + for _, key := range []string{"accessToken", "refreshToken", "apiKey", "access_token"} { + if out[key] != RedactedSecret { + t.Errorf("%s = %#v, want %s", key, out[key], RedactedSecret) + } + } + if out["promptTokens"] != int64(12) { + t.Errorf("promptTokens should stay numeric, got %#v", out["promptTokens"]) + } +} + +func TestRedactString_CamelCaseJSONKeys(t *testing.T) { + o := Options{} + cases := []struct{ in, secret string }{ + {`{"accessToken":"leak-access-token-value"}`, "leak-access-token-value"}, + {`{"refreshToken":"leak-refresh-token-value"}`, "leak-refresh-token-value"}, + {`{"apiKey":"leak-api-key-value"}`, "leak-api-key-value"}, + } + for _, c := range cases { + out := RedactString(c.in, o) + if strings.Contains(out, c.secret) { + t.Errorf("RedactString(%q) leaked %q: got %q", c.in, c.secret, out) + } + } + if out := RedactString(`{"promptTokens":12}`, o); out != `{"promptTokens":12}` { + t.Errorf("promptTokens JSON should be unchanged, got %q", out) + } +} diff --git a/internal/redaction/redaction.go b/internal/redaction/redaction.go index 24685eca9..e65312821 100644 --- a/internal/redaction/redaction.go +++ b/internal/redaction/redaction.go @@ -457,16 +457,24 @@ func normalizeKey(key string) string { key = strings.TrimSpace(key) var builder strings.Builder var lastUnderscore bool + var prev rune + var hasPrev bool for _, r := range key { if unicode.IsLetter(r) || unicode.IsDigit(r) { + if hasPrev && !lastUnderscore && unicode.IsLower(prev) && unicode.IsUpper(r) { + builder.WriteByte('_') + } builder.WriteRune(unicode.ToLower(r)) lastUnderscore = false + prev = r + hasPrev = true continue } if !lastUnderscore { builder.WriteByte('_') lastUnderscore = true } + hasPrev = false } return strings.Trim(builder.String(), "_") } From 78a74bc8737b17ac558e00701c47d9216d29a38b Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Fri, 28 Aug 2026 20:19:03 +0200 Subject: [PATCH 2/3] fix(config): replace deprecated reflect.Ptr with reflect.Pointer --- internal/config/unknownfields.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/unknownfields.go b/internal/config/unknownfields.go index e5c36901d..7341a098a 100644 --- a/internal/config/unknownfields.go +++ b/internal/config/unknownfields.go @@ -131,7 +131,7 @@ type knownField struct { } func derefType(t reflect.Type) reflect.Type { - for t != nil && t.Kind() == reflect.Ptr { + for t != nil && t.Kind() == reflect.Pointer { t = t.Elem() } return t From 5be6eae00d0134534ca056635a1eb949a3103b90 Mon Sep 17 00:00:00 2001 From: hazyhaar Date: Fri, 28 Aug 2026 20:23:46 +0200 Subject: [PATCH 3/3] fix(doctor): report auth presence under a non-sensitive key camelCase normalization treats credentialConfigured as a credential segment, so check() redacted the set/not-set indicator. authConfigured does not match the registry. --- internal/doctor/doctor.go | 17 +++++++++-------- internal/doctor/doctor_test.go | 4 ++-- internal/redaction/audit_fixes_test.go | 1 + 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index b6f222573..7ac2d9a56 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -142,9 +142,10 @@ func providerConfigCheck(profile config.ProviderProfile) Check { return check("provider.config", "Provider config", StatusFail, "No LLM provider is configured.", map[string]any{"help": "Set a provider in config or environment."}) } // Report credential PRESENCE, never the value. Reported under a non-sensitive - // key ("credentialConfigured"): the prior "apiKey" key was itself sensitive, so - // check()'s redaction scrubbed the indicator to [REDACTED] — making "set"/"not - // set" invisible. HasConfiguredCredential is the shared definition of + // key ("authConfigured"): "apiKey" and "credentialConfigured" are themselves + // sensitive after camelCase normalization, so check()'s redaction would scrub + // the indicator to [REDACTED] — making "set"/"not set" invisible. + // HasConfiguredCredential is the shared definition of // "key-authed" (inline key, raw auth header, or a key in the encrypted // credential store), matching ProviderSnapshot.APIKeySet — checking only the // inline fields made doctor and `zero providers list` disagree about the @@ -159,11 +160,11 @@ func providerConfigCheck(profile config.ProviderProfile) Check { credential = "oauth login" } details := map[string]any{ - "name": profile.Name, - "provider": profile.ProviderKind, - "baseURL": profile.BaseURL, - "model": profile.Model, - "credentialConfigured": credential, + "name": profile.Name, + "provider": profile.ProviderKind, + "baseURL": profile.BaseURL, + "model": profile.Model, + "authConfigured": credential, } // A remote provider with no credential cannot make a request, so doctor must NOT // report it as healthy — otherwise "Overall: pass" gives a false all-clear for the diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go index 4fecf761b..904ac2e5a 100644 --- a/internal/doctor/doctor_test.go +++ b/internal/doctor/doctor_test.go @@ -388,9 +388,9 @@ func TestProviderConfigCheckCredentialPresence(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := providerConfigCheck(tc.profile).Details["credentialConfigured"] + got := providerConfigCheck(tc.profile).Details["authConfigured"] if got != tc.want { - t.Fatalf("credentialConfigured = %v, want %q (matches ProviderSnapshot.APIKeySet trimming)", got, tc.want) + t.Fatalf("authConfigured = %v, want %q (matches ProviderSnapshot.APIKeySet trimming)", got, tc.want) } }) } diff --git a/internal/redaction/audit_fixes_test.go b/internal/redaction/audit_fixes_test.go index 47ef8dfdb..dc4e3a5f1 100644 --- a/internal/redaction/audit_fixes_test.go +++ b/internal/redaction/audit_fixes_test.go @@ -316,6 +316,7 @@ func TestIsSensitiveKey_CamelCaseCredentials(t *testing.T) { notSensitive := []string{ "promptTokens", "maxTokens", "completionTokens", "tokenCount", "prompt_tokens", "max_tokens", + "authConfigured", } for _, k := range notSensitive { if IsSensitiveKey(k, o) {