feat: make sure creds are always masked (#24241)

## Summary  
Adds a `sanitizeCredentialHint` safety check in the db-to-SDK conversion
layer to ensure credential hints are always masked before being exposed
in the API. Also adds `credential_kind` and `credential_hint` assertions
to the session threads API test.
This commit is contained in:
Yevhenii Shcherbina
2026-04-13 10:14:38 -04:00
committed by GitHub
parent 4854f33678
commit b78eba9f9d
5 changed files with 57 additions and 8 deletions
+20 -1
View File
@@ -1241,7 +1241,7 @@ func buildAIBridgeThread(
thread.Model = rootIntc.Model
thread.Provider = rootIntc.Provider
thread.CredentialKind = string(rootIntc.CredentialKind)
thread.CredentialHint = rootIntc.CredentialHint
thread.CredentialHint = sanitizeCredentialHint(rootIntc.CredentialHint)
// Get first user prompt from root interception.
// A thread can only have one prompt, by definition, since we currently
// only store the last prompt observed in an interception.
@@ -1407,6 +1407,25 @@ func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidat
return presets
}
// sanitizeCredentialHint ensures the hint looks masked before exposing
// it in the API. The aibridge library uses "..." as the masking
// delimiter (e.g. "sk-a...efgh"), so we check for its presence. If
// the hint doesn't contain "..." or exceeds the max length, it's
// replaced with "..." to prevent leaking raw secrets.
func sanitizeCredentialHint(hint string) string {
// Matches the VARCHAR(15) DB constraint.
const maxCredentialHintLength = 15
if hint == "" {
return ""
}
if len(hint) > maxCredentialHintLength || !strings.Contains(hint, "...") {
return "..."
}
return hint
}
func jsonOrEmptyMap(rawMessage pqtype.NullRawMessage) map[string]any {
var m map[string]any
if !rawMessage.Valid {
@@ -306,3 +306,29 @@ func TestAggregateTokenUsage(t *testing.T) {
require.Empty(t, result.Metadata)
})
}
func TestSanitizeCredentialHint(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expected string
}{
{"valid_short", "s...t", "s...t"},
{"valid_long", "sk-a...efgh", "sk-a...efgh"},
{"valid_only_dots", "...", "..."},
{"empty", "", ""},
{"short_unmasked_secret", "abc12", "..."},
{"missing_dots", "sk-abcdefgh", "..."},
{"too_long", "sk-a...efghijklmn", "..."},
{"raw_secret", "sk-proj-abc123xyz789", "..."},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.expected, sanitizeCredentialHint(tc.input))
})
}
}
+8 -4
View File
@@ -1836,10 +1836,12 @@ func TestAIBridgeGetSessionThreads(t *testing.T) {
now := dbtime.Now()
endedAt := now.Add(time.Minute)
i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
Provider: "openai",
Model: "gpt-4",
StartedAt: now,
InitiatorID: firstUser.UserID,
Provider: "openai",
Model: "gpt-4",
StartedAt: now,
CredentialKind: database.CredentialKindByok,
CredentialHint: "sk-a...efgh",
}, &endedAt)
// When no client session ID is set, the interception ID becomes the session identifier.
@@ -1847,6 +1849,8 @@ func TestAIBridgeGetSessionThreads(t *testing.T) {
require.NoError(t, err)
require.Equal(t, i1.ID.String(), res.ID)
require.Len(t, res.Threads, 1)
require.Equal(t, "byok", res.Threads[0].CredentialKind)
require.Equal(t, "sk-a...efgh", res.Threads[0].CredentialHint)
})
t.Run("ThreadsWithAgenticActions", func(t *testing.T) {
+1 -1
View File
@@ -496,7 +496,7 @@ require (
github.com/anthropics/anthropic-sdk-go v1.19.0
github.com/brianvoe/gofakeit/v7 v7.14.0
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
github.com/coder/aibridge v1.1.1-0.20260408143328-f72a795f1e77
github.com/coder/aibridge v1.1.1-0.20260410135158-9ac7525ed947
github.com/coder/aisdk-go v0.0.9
github.com/coder/boundary v0.8.4-0.20260304164748-566aeea939ab
github.com/coder/preview v1.0.8
+2 -2
View File
@@ -312,8 +312,8 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
github.com/coder/aibridge v1.1.1-0.20260408143328-f72a795f1e77 h1:VNN+JBL4/IiC/sb9eTQmAkRv4ALwSnmMvW9iI2o+G6o=
github.com/coder/aibridge v1.1.1-0.20260408143328-f72a795f1e77/go.mod h1:u6WvGLMQQbk3ByeOw+LBdVgDNc/v/ujAtUc6MfvzQb4=
github.com/coder/aibridge v1.1.1-0.20260410135158-9ac7525ed947 h1:5asZktST8evXjs9vaS9ujSew/LNyp/lBLfJk8Rd8+pk=
github.com/coder/aibridge v1.1.1-0.20260410135158-9ac7525ed947/go.mod h1:u6WvGLMQQbk3ByeOw+LBdVgDNc/v/ujAtUc6MfvzQb4=
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
github.com/coder/anthropic-sdk-go v0.0.0-20260409105508-5711db120546 h1:dYAA1uo93n9r/B4Gvx76pjaeS0kBTX1lO0WX0p0uqo8=