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
2 changes: 1 addition & 1 deletion internal/config/unknownfields.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 9 additions & 8 deletions internal/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/doctor/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Expand Down
87 changes: 87 additions & 0 deletions internal/redaction/audit_fixes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,90 @@ 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",
"authConfigured",
}
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)
}
}
8 changes: 8 additions & 0 deletions internal/redaction/redaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(), "_")
}
Expand Down
Loading