diff --git a/custom/iam/policies/dao.go b/custom/iam/policies/dao.go index b4326fae..6adcaafa 100644 --- a/custom/iam/policies/dao.go +++ b/custom/iam/policies/dao.go @@ -92,8 +92,6 @@ func (d *PolicyDAO) Get(ctx context.Context, id string) (dao.Resource, error) { res.PolicyDocumentStatus = enrichment.Fetched } else if err != nil { res.PolicyDocumentStatus = enrichment.FailureStatus(err) - } else { - res.PolicyDocumentStatus = enrichment.Fetched } } diff --git a/custom/s3/buckets/render.go b/custom/s3/buckets/render.go index c9ef81a4..452b5422 100644 --- a/custom/s3/buckets/render.go +++ b/custom/s3/buckets/render.go @@ -120,7 +120,7 @@ func (r *BucketRenderer) RenderDetail(resource dao.Resource) string { // Encryption d.Section("Server-Side Encryption") - if b.EncryptionStatus == enrichment.AccessDenied || b.EncryptionStatus == enrichment.FetchFailed || b.EncryptionStatus == enrichment.Unknown { + if enrichment.IsFailure(b.EncryptionStatus) { d.Field("Status", enrichment.Display(b.EncryptionStatus)) } else if b.EncryptionEnabled { d.Field("Status", "Enabled") @@ -137,7 +137,7 @@ func (r *BucketRenderer) RenderDetail(resource dao.Resource) string { // Public Access Block d.Section("Block Public Access") - if b.PublicAccessBlockStatus == enrichment.AccessDenied || b.PublicAccessBlockStatus == enrichment.FetchFailed || b.PublicAccessBlockStatus == enrichment.Unknown { + if enrichment.IsFailure(b.PublicAccessBlockStatus) { d.Field("Status", enrichment.Display(b.PublicAccessBlockStatus)) } else if b.PublicAccessBlock != nil { pab := b.PublicAccessBlock diff --git a/internal/ai/session.go b/internal/ai/session.go index 538779fb..1dff32b6 100644 --- a/internal/ai/session.go +++ b/internal/ai/session.go @@ -90,6 +90,14 @@ func (m *SessionManager) sessionsDir() (string, error) { return filepath.Join(dir, sessionDir), nil } +func (m *SessionManager) sessionPath(id string) (string, error) { + dir, err := m.sessionsDir() + if err != nil { + return "", err + } + return filepath.Join(dir, id+".json"), nil +} + func (m *SessionManager) currentPath() (string, error) { dir, err := config.ConfigDir() if err != nil { @@ -143,12 +151,11 @@ func (m *SessionManager) CurrentSession() (*Session, error) { } func (m *SessionManager) LoadSession(id string) (*Session, error) { - dir, err := m.sessionsDir() + path, err := m.sessionPath(id) if err != nil { return nil, err } - path := filepath.Join(dir, id+".json") data, err := os.ReadFile(path) if err != nil { return nil, err @@ -262,7 +269,10 @@ func (m *SessionManager) saveSession(session *Session) error { return err } - path := filepath.Join(dir, session.ID+".json") + path, err := m.sessionPath(session.ID) + if err != nil { + return err + } data, err := json.MarshalIndent(session, "", " ") if err != nil { return err diff --git a/internal/ai/session_test.go b/internal/ai/session_test.go index adcd03fd..11bd5e76 100644 --- a/internal/ai/session_test.go +++ b/internal/ai/session_test.go @@ -191,13 +191,15 @@ func TestSessionPersistenceDoesNotContainRedactedSecrets(t *testing.T) { t.Fatalf("failed to add message: %v", err) } - sessionFile := filepath.Join(tmpDir, ".config", "claws", "chat", "sessions", session.ID+".json") + sessionFile, err := sm.sessionPath(session.ID) + if err != nil { + t.Fatalf("failed to get session path: %v", err) + } data, err := os.ReadFile(sessionFile) if err != nil { t.Fatalf("failed to read session file: %v", err) } - if strings.Contains(string(data), "persist-me-not") || strings.Contains(string(data), "TOKEN") || - strings.Contains(string(data), "persist-output-secret") || strings.Contains(string(data), "DB_PASSWORD") { + if strings.Contains(string(data), "persist-me-not") || strings.Contains(string(data), "persist-output-secret") || strings.Contains(string(data), "DB_PASSWORD") { t.Fatalf("session file contains secret data: %s", string(data)) } if !strings.Contains(string(data), "[REDACTED]") { diff --git a/internal/ai/tools.go b/internal/ai/tools.go index a3c9bd9c..a6f971ae 100644 --- a/internal/ai/tools.go +++ b/internal/ai/tools.go @@ -770,6 +770,13 @@ func formatResourceDetail(r dao.Resource) string { } func redactSensitiveRaw(raw any) any { + switch value := raw.(type) { + case map[string]any, []any: + return redactSensitiveValue(value) + } + + // Some resources may expose typed SDK structs or maps with typed values. + // Normalize those through JSON before redacting so traversal sees map[string]any. data, err := json.Marshal(raw) if err != nil { return raw @@ -800,6 +807,12 @@ func redactSensitiveValue(v any) any { redacted[key] = redactSensitiveValue(nested) } return redacted + case []map[string]any: + redacted := make([]any, len(value)) + for i, nested := range value { + redacted[i] = redactSensitiveValue(nested) + } + return redacted case []any: redacted := make([]any, len(value)) for i, nested := range value { @@ -860,10 +873,8 @@ var exactSensitiveRawKeys = map[string]bool{ "clientsecret": true, "credential": true, "credentials": true, - "environment": true, "environmentvariables": true, "privatekey": true, - "variables": true, "secret": true, "secrets": true, "secretstring": true, @@ -881,7 +892,6 @@ var sensitiveRawKeySegments = map[string]bool{ "authorization": true, "credential": true, "credentials": true, - "environment": true, "password": true, "private": true, "secret": true, @@ -891,24 +901,36 @@ var sensitiveRawKeySegments = map[string]bool{ func rawKeySegments(key string) []string { var segments []string var current []rune - var previous rune - for _, r := range key { + runes := []rune(key) + for i, r := range runes { if r == '_' || r == '-' || r == ' ' || r == '.' || r == '/' { segments = appendNormalizedSegment(segments, current) current = nil - previous = 0 continue } - if len(current) > 0 && unicode.IsUpper(r) && (unicode.IsLower(previous) || unicode.IsDigit(previous)) { + if shouldSplitRawKeySegment(runes, i, current) { segments = appendNormalizedSegment(segments, current) current = nil } current = append(current, unicode.ToLower(r)) - previous = r } return appendNormalizedSegment(segments, current) } +func shouldSplitRawKeySegment(runes []rune, index int, current []rune) bool { + if len(current) == 0 || index == 0 || !unicode.IsUpper(runes[index]) { + return false + } + previous := runes[index-1] + if unicode.IsLower(previous) || unicode.IsDigit(previous) { + return true + } + if !unicode.IsUpper(previous) || index+1 >= len(runes) { + return false + } + return unicode.IsLower(runes[index+1]) +} + func appendNormalizedSegment(segments []string, segment []rune) []string { if len(segment) == 0 { return segments diff --git a/internal/ai/tools_test.go b/internal/ai/tools_test.go index 2778bdd5..6dde6703 100644 --- a/internal/ai/tools_test.go +++ b/internal/ai/tools_test.go @@ -330,9 +330,12 @@ func TestFormatResourceDetailRedactsSensitiveRawData(t *testing.T) { result := formatResourceDetail(resource) - if strings.Contains(result, "super-secret-value") || strings.Contains(result, "API_KEY") { + if strings.Contains(result, "super-secret-value") { t.Fatalf("expected sensitive environment values to be redacted, got %q", result) } + if !strings.Contains(result, "API_KEY") { + t.Fatalf("expected sensitive key name to be preserved for context, got %q", result) + } if !strings.Contains(result, "[REDACTED]") { t.Fatalf("expected redaction marker, got %q", result) } @@ -379,19 +382,68 @@ func TestFormatResourceDetailRedactsSensitiveLabelValueRecords(t *testing.T) { } func TestIsSensitiveRawKeyAvoidsSubstringFalsePositives(t *testing.T) { - for _, key := range []string{"tokenization", "tokencount", "accessTokens_issued", "secretsmanager_arn", "credentialsexpiry"} { + for _, key := range []string{"environment", "variables", "tokenization", "tokencount", "accessTokens_issued", "secretsmanager_arn", "credentialsexpiry"} { if isSensitiveRawKey(key) { t.Fatalf("isSensitiveRawKey(%q) = true, want false", key) } } - for _, key := range []string{"DB_PASSWORD", "ApiToken", "clientSecret", "SecretAccessKey", "EnvironmentVariables"} { + for _, key := range []string{"DB_PASSWORD", "DBPassword", "DBMasterPassword", "MasterUserPassword", "ApiToken", "clientSecret", "SecretAccessKey", "EnvironmentVariables"} { if !isSensitiveRawKey(key) { t.Fatalf("isSensitiveRawKey(%q) = false, want true", key) } } } +func TestFormatResourceDetailKeepsNonSensitiveEnvironmentFields(t *testing.T) { + resource := &mockResource{ + id: "instance-1", + name: "my-instance", + raw: map[string]any{ + "environment": "production", + "variables": "some-list", + "DBPassword": "secret-db-password", + }, + } + + result := formatResourceDetail(resource) + + if !strings.Contains(result, "production") || !strings.Contains(result, "some-list") { + t.Fatalf("expected non-sensitive environment fields to remain, got %q", result) + } + if strings.Contains(result, "secret-db-password") { + t.Fatalf("expected DBPassword value to be redacted, got %q", result) + } + if !strings.Contains(result, "DBPassword") { + t.Fatalf("expected sensitive key name to be preserved, got %q", result) + } +} + +func TestFormatResourceDetailPreservesMultipleSensitiveKeyNames(t *testing.T) { + resource := &mockResource{ + id: "resource-1", + name: "resource", + raw: map[string]any{ + "DBPassword": "db-secret", + "ApiToken": "api-secret", + "clientSecret": "client-secret", + }, + } + + result := formatResourceDetail(resource) + + for _, key := range []string{"DBPassword", "ApiToken", "clientSecret"} { + if !strings.Contains(result, key) { + t.Fatalf("expected sensitive key %q to be preserved, got %q", key, result) + } + } + for _, secret := range []string{"db-secret", "api-secret", "client-secret"} { + if strings.Contains(result, secret) { + t.Fatalf("expected sensitive value %q to be redacted, got %q", secret, result) + } + } +} + type mockResource struct { id string name string diff --git a/internal/view/resource_browser_fetch.go b/internal/view/resource_browser_fetch.go index ef1442c0..a6ac697d 100644 --- a/internal/view/resource_browser_fetch.go +++ b/internal/view/resource_browser_fetch.go @@ -131,10 +131,8 @@ func (r *ResourceBrowser) fetchMultiProfileResources(profiles []config.ProfileSe for _, sel := range profiles { for _, region := range regions { key := profileRegionKey{Profile: sel.ID(), Region: region} - if existingTokens != nil { - if _, ok := existingTokens[key]; !ok { - continue - } + if existingTokens != nil && !hasProfileRegionToken(existingTokens, key) { + continue } keys = append(keys, key) } @@ -178,6 +176,11 @@ func (r *ResourceBrowser) fetchMultiProfileResources(profiles []config.ProfileSe return fetchParallel(r.ctx, keys, fetch, formatError) } +func hasProfileRegionToken(tokens map[profileRegionKey]string, key profileRegionKey) bool { + _, ok := tokens[key] + return ok +} + func (r *ResourceBrowser) fetchMultiRegionResources(regions []string, existingTokens map[string]string) parallelFetchResult[string] { fetch := func(ctx context.Context, region string) ([]dao.Resource, string, error) { regionCtx := aws.WithRegionOverride(ctx, region)