Skip to content
Merged
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: 0 additions & 2 deletions custom/iam/policies/dao.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
4 changes: 2 additions & 2 deletions custom/s3/buckets/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down
16 changes: 13 additions & 3 deletions internal/ai/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions internal/ai/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]") {
Expand Down
38 changes: 30 additions & 8 deletions internal/ai/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -881,7 +892,6 @@ var sensitiveRawKeySegments = map[string]bool{
"authorization": true,
"credential": true,
"credentials": true,
"environment": true,
"password": true,
"private": true,
"secret": true,
Expand All @@ -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
Expand Down
58 changes: 55 additions & 3 deletions internal/ai/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions internal/view/resource_browser_fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
Loading