diff --git a/plugins/detectors/templatedweakcredentials/credentialstore.go b/plugins/detectors/templatedweakcredentials/credentialstore.go new file mode 100644 index 0000000..9a7820d --- /dev/null +++ b/plugins/detectors/templatedweakcredentials/credentialstore.go @@ -0,0 +1,256 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templatedweakcredentials + +import ( + "bufio" + "context" + "os" + "slices" + "sync" + + "github.com/google/goonami-scanner/core/config" + "github.com/google/goonami-scanner/core/log" +) + +// CredentialStore is a store of credentials (username associated with a list of passwords). +// Note that it keeps track of the order in which username are added. +type CredentialStore struct { + mu sync.RWMutex + usernames []string + credentials map[string][]string + loadOnce sync.Once + loadErr error +} + +// NewCredentialStore creates a new CredentialStore. +func NewCredentialStore() *CredentialStore { + return &CredentialStore{ + mu: sync.RWMutex{}, + usernames: []string{}, + credentials: make(map[string][]string), + } +} + +// Copy returns a deep copy of the credential store. +func (c *CredentialStore) Copy() *CredentialStore { + c.mu.RLock() + defer c.mu.RUnlock() + + cp := NewCredentialStore() + cp.usernames = make([]string, len(c.usernames)) + copy(cp.usernames, c.usernames) + + for user, passes := range c.credentials { + cp.credentials[user] = make([]string, len(passes)) + copy(cp.credentials[user], passes) + } + + return cp +} + +// AddMultiplePasswordsForUser adds multiple passwords for a given username. +func (c *CredentialStore) AddMultiplePasswordsForUser(username string, passwords []string) { + for _, password := range passwords { + c.AddPasswordForUser(username, password) + } +} + +// AddPasswordForUser adds a password for a given username. +func (c *CredentialStore) AddPasswordForUser(username string, password string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if _, ok := c.credentials[username]; ok { + knownPasses := c.credentials[username] + if slices.Contains(knownPasses, password) { + return nil + } + } + + c.credentials[username] = append(c.credentials[username], password) + return nil +} + +// AddPassword adds a password for all users in the store. +func (c *CredentialStore) AddPassword(password string) error { + for username := range c.credentials { + if err := c.AddPasswordForUser(username, password); err != nil { + return err + } + } + + return nil +} + +// AddUser adds a user to the store. +func (c *CredentialStore) AddUser(username string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if _, ok := c.credentials[username]; ok { + return nil + } + + c.usernames = append(c.usernames, username) + c.credentials[username] = []string{} + return nil +} + +// PrependUser adds a user at the beginning of the store's usernames list. If the user already exists, it is moved to the front. +func (c *CredentialStore) PrependUser(username string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if _, ok := c.credentials[username]; ok { + idx := slices.Index(c.usernames, username) + if idx != -1 { + c.usernames = slices.Delete(c.usernames, idx, idx+1) + } + c.usernames = append([]string{username}, c.usernames...) + return nil + } + + c.usernames = append([]string{username}, c.usernames...) + c.credentials[username] = []string{} + return nil +} + +// PrependPasswordForUser adds a password at the beginning of the password list for a given username. If the password already exists, it is moved to the front. +func (c *CredentialStore) PrependPasswordForUser(username string, password string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if _, ok := c.credentials[username]; ok { + knownPasses := c.credentials[username] + if idx := slices.Index(knownPasses, password); idx != -1 { + c.credentials[username] = slices.Delete(knownPasses, idx, idx+1) + } + } + + c.credentials[username] = append([]string{password}, c.credentials[username]...) + return nil +} + +// UsersFromFile reads a list of users from a file and adds them to the store. Each user should be +// on a separate line. +func (c *CredentialStore) UsersFromFile(path string) error { + users, err := fromFile(path) + if err != nil { + return err + } + + for _, user := range users { + if err := c.AddUser(user); err != nil { + return err + } + } + + return nil +} + +// PasswordsFromFile reads a list of passwords from a file and adds them to the store. Each password +// should be on a separate line. Each password is added to every user in the store. +func (c *CredentialStore) PasswordsFromFile(path string) error { + passwords, err := fromFile(path) + if err != nil { + return err + } + + for _, pass := range passwords { + if err := c.AddPassword(pass); err != nil { + return err + } + } + + return nil +} + +// Count the number of credentials in the store. +func (c *CredentialStore) Count() int { + c.mu.RLock() + defer c.mu.RUnlock() + + var count int + + for _, passwords := range c.credentials { + count += len(passwords) + } + + return count +} + +// Usernames returns a list of all usernames in the store. +func (c *CredentialStore) Usernames() []string { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.usernames +} + +// Passwords returns a list of all passwords for a given username. +func (c *CredentialStore) Passwords(username string) []string { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.credentials[username] +} + +func fromFile(path string) ([]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var results []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + results = append(results, line) + } + + return results, nil +} + +// LoadFromConfigOnce loads usernames and passwords from config files (only once). +func (c *CredentialStore) LoadFromConfigOnce(ctx context.Context, cfg *config.Config) error { + c.loadOnce.Do(func() { + c.loadErr = c.fromConfig(ctx, cfg) + }) + return c.loadErr +} + +func (c *CredentialStore) fromConfig(ctx context.Context, cfg *config.Config) error { + if cfg.PluginsConfig().GetTemplatedweakcredentials().HasUsernameFile() { + userFile := cfg.PluginsConfig().GetTemplatedweakcredentials().GetUsernameFile() + log.DebugContextf(ctx, log.DebugLevelSession, "loading all usernames from %q", userFile) + if err := c.UsersFromFile(userFile); err != nil { + return err + } + } + + if cfg.PluginsConfig().GetTemplatedweakcredentials().HasPasswordsFile() { + passFile := cfg.PluginsConfig().GetTemplatedweakcredentials().GetPasswordsFile() + log.DebugContextf(ctx, log.DebugLevelSession, "loading passwords from %q", passFile) + if err := c.PasswordsFromFile(passFile); err != nil { + return err + } + } + + return nil +} diff --git a/plugins/detectors/templatedweakcredentials/credentialstore_test.go b/plugins/detectors/templatedweakcredentials/credentialstore_test.go new file mode 100644 index 0000000..03a23b1 --- /dev/null +++ b/plugins/detectors/templatedweakcredentials/credentialstore_test.go @@ -0,0 +1,455 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package templatedweakcredentials + +import ( + "slices" + "testing" +) + +func TestCredentialStore_NewCredentialStore(t *testing.T) { + store := NewCredentialStore() + if store == nil { + t.Fatal("NewCredentialStore() returned nil") + } + if store.Count() != 0 { + t.Errorf("expected count 0, got %d", store.Count()) + } + if len(store.Usernames()) != 0 { + t.Errorf("expected 0 users, got %d", len(store.Usernames())) + } +} + +func TestCredentialStore_Copy(t *testing.T) { + store := NewCredentialStore() + store.AddUser("user1") + store.AddPasswordForUser("user1", "pass1") + store.AddUser("user2") + store.AddPasswordForUser("user2", "pass2") + + cp := store.Copy() + + if len(cp.Usernames()) != 2 { + t.Errorf("expected 2 users, got %d", len(cp.Usernames())) + } + if cp.Count() != 2 { + t.Errorf("expected 2 passwords, got %d", cp.Count()) + } + + // modify copy + cp.AddUser("user3") + cp.AddPasswordForUser("user1", "pass3") + + if len(store.Usernames()) != 2 { + t.Errorf("expected original users unchanged, got %d", len(store.Usernames())) + } + if len(store.Passwords("user1")) != 1 { + t.Errorf("expected original passwords unchanged, got %d", len(store.Passwords("user1"))) + } +} + +func TestCredentialStore_AddUser(t *testing.T) { + tests := []struct { + name string + initialUsers []string + userToAdd string + wantUsers int + wantErr bool + }{ + { + name: "when_adding_new_user_adds_user", + userToAdd: "user1", + wantUsers: 1, + wantErr: false, + }, + { + name: "when_adding_existing_user_does_not_error", + initialUsers: []string{"user1"}, + userToAdd: "user1", + wantUsers: 1, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewCredentialStore() + for _, u := range tt.initialUsers { + store.AddUser(u) + } + err := store.AddUser(tt.userToAdd) + if (err != nil) != tt.wantErr { + t.Fatalf("AddUser() error = %v, wantErr %v", err, tt.wantErr) + } + if len(store.Usernames()) != tt.wantUsers { + t.Errorf("expected %d user, got %d", tt.wantUsers, len(store.Usernames())) + } + if !tt.wantErr && !slices.Contains(store.Usernames(), tt.userToAdd) { + t.Errorf("expected %s in credentials", tt.userToAdd) + } + }) + } +} + +func TestCredentialStore_PrependUser(t *testing.T) { + tests := []struct { + name string + initialUsers []string + userToAdd string + wantUsers []string + wantErr bool + }{ + { + name: "when_prepending_new_user_adds_to_front", + initialUsers: []string{"user1", "user2"}, + userToAdd: "user3", + wantUsers: []string{"user3", "user1", "user2"}, + wantErr: false, + }, + { + name: "when_prepending_existing_user_moves_to_front", + initialUsers: []string{"user1", "user2", "user3"}, + userToAdd: "user2", + wantUsers: []string{"user2", "user1", "user3"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewCredentialStore() + for _, u := range tt.initialUsers { + store.AddUser(u) + } + err := store.PrependUser(tt.userToAdd) + if (err != nil) != tt.wantErr { + t.Fatalf("PrependUser() error = %v, wantErr %v", err, tt.wantErr) + } + users := store.Usernames() + if len(users) != len(tt.wantUsers) { + t.Errorf("expected %d user, got %d", len(tt.wantUsers), len(users)) + } + for i, u := range users { + if u != tt.wantUsers[i] { + t.Errorf("expected users[%d] to be %q, got %q", i, tt.wantUsers[i], u) + } + } + }) + } +} + +func TestCredentialStore_AddPasswordForUser(t *testing.T) { + tests := []struct { + name string + initialPasswords map[string][]string + userToAdd string + passToAdd string + wantPasswords int + wantErr bool + }{ + { + name: "when_adding_password_for_new_user_adds_password", + userToAdd: "user1", + passToAdd: "pass1", + wantPasswords: 1, + wantErr: false, + }, + { + name: "when_adding_duplicate_password_for_user_does_not_add_again", + initialPasswords: map[string][]string{"user1": {"pass1"}}, + userToAdd: "user1", + passToAdd: "pass1", + wantPasswords: 1, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewCredentialStore() + for u, passes := range tt.initialPasswords { + for _, p := range passes { + store.AddPasswordForUser(u, p) + } + } + err := store.AddPasswordForUser(tt.userToAdd, tt.passToAdd) + if (err != nil) != tt.wantErr { + t.Fatalf("AddPasswordForUser() error = %v, wantErr %v", err, tt.wantErr) + } + creds := store.Passwords(tt.userToAdd) + if len(creds) != tt.wantPasswords { + t.Errorf("expected %s to have %d passwords, got %v", tt.userToAdd, tt.wantPasswords, creds) + } + if !tt.wantErr && !slices.Contains(creds, tt.passToAdd) { + t.Errorf("expected %s to have %s", tt.userToAdd, tt.passToAdd) + } + }) + } +} + +func TestCredentialStore_PrependPasswordForUser(t *testing.T) { + tests := []struct { + name string + initialPasswords map[string][]string + userToAdd string + passToAdd string + wantPasswords []string + wantErr bool + }{ + { + name: "when_prepending_new_password_adds_to_front", + initialPasswords: map[string][]string{"user1": {"pass1", "pass2"}}, + userToAdd: "user1", + passToAdd: "pass3", + wantPasswords: []string{"pass3", "pass1", "pass2"}, + wantErr: false, + }, + { + name: "when_prepending_existing_password_moves_to_front", + initialPasswords: map[string][]string{"user1": {"pass1", "pass2", "pass3"}}, + userToAdd: "user1", + passToAdd: "pass2", + wantPasswords: []string{"pass2", "pass1", "pass3"}, + wantErr: false, + }, + { + name: "when_prepending_password_for_new_user_adds_user_and_password", + initialPasswords: map[string][]string{}, + userToAdd: "user1", + passToAdd: "pass1", + wantPasswords: []string{"pass1"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewCredentialStore() + for u, passes := range tt.initialPasswords { + for _, p := range passes { + store.AddPasswordForUser(u, p) + } + } + err := store.PrependPasswordForUser(tt.userToAdd, tt.passToAdd) + if (err != nil) != tt.wantErr { + t.Fatalf("PrependPasswordForUser() error = %v, wantErr %v", err, tt.wantErr) + } + creds := store.Passwords(tt.userToAdd) + if len(creds) != len(tt.wantPasswords) { + t.Errorf("expected %s to have %d passwords, got %v", tt.userToAdd, len(tt.wantPasswords), creds) + } + for i, p := range creds { + if p != tt.wantPasswords[i] { + t.Errorf("expected passwords[%d] to be %q, got %q", i, tt.wantPasswords[i], p) + } + } + }) + } +} + +func TestCredentialStore_AddMultiplePasswordsForUser(t *testing.T) { + tests := []struct { + name string + user string + passwords []string + wantPasswords []string + }{ + { + name: "when_adding_multiple_passwords_adds_all", + user: "user1", + passwords: []string{"pass1", "pass2"}, + wantPasswords: []string{"pass1", "pass2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewCredentialStore() + store.AddMultiplePasswordsForUser(tt.user, tt.passwords) + + creds := store.Passwords(tt.user) + if len(creds) != len(tt.wantPasswords) { + t.Errorf("expected %d passwords, got %d", len(tt.wantPasswords), len(creds)) + } + for _, p := range tt.wantPasswords { + if !slices.Contains(creds, p) { + t.Errorf("missing password, expected: %s, got: %v", p, creds) + } + } + }) + } +} + +func TestCredentialStore_AddPassword(t *testing.T) { + tests := []struct { + name string + initialUsers []string + passToAdd string + wantErr bool + }{ + { + name: "when_adding_password_adds_to_all_users", + initialUsers: []string{"user1", "user2"}, + passToAdd: "pass1", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewCredentialStore() + for _, u := range tt.initialUsers { + store.AddUser(u) + } + + err := store.AddPassword(tt.passToAdd) + if (err != nil) != tt.wantErr { + t.Fatalf("AddPassword() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + for _, u := range tt.initialUsers { + if !slices.Contains(store.Passwords(u), tt.passToAdd) { + t.Errorf("expected %s to have %s", u, tt.passToAdd) + } + } + } + }) + } +} + +func TestCredentialStore_Count(t *testing.T) { + tests := []struct { + name string + initialPasswords map[string][]string + wantCount int + }{ + { + name: "when_calculating_count_returns_total_passwords", + initialPasswords: map[string][]string{ + "user1": {"pass1", "pass2"}, + "user2": {"pass3"}, + }, + wantCount: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewCredentialStore() + for u, passes := range tt.initialPasswords { + for _, p := range passes { + store.AddPasswordForUser(u, p) + } + } + + if store.Count() != tt.wantCount { + t.Errorf("expected count %d, got %d", tt.wantCount, store.Count()) + } + }) + } +} + +func TestCredentialStore_UsersFromFile(t *testing.T) { + tests := []struct { + name string + path string + wantUsers []string + wantErr bool + }{ + { + name: "when_reading_valid_users_file_adds_users", + path: "testdata/users.txt", + wantUsers: []string{"testuser1", "testuser2"}, + wantErr: false, + }, + { + name: "when_reading_invalid_file_returns_error", + path: "testdata/nonexistent.txt", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewCredentialStore() + + err := store.UsersFromFile(tt.path) + if (err != nil) != tt.wantErr { + t.Fatalf("UsersFromFile() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + usernames := store.Usernames() + if len(usernames) != len(tt.wantUsers) { + t.Errorf("expected %d users, got %d", len(tt.wantUsers), len(usernames)) + } + for _, u := range tt.wantUsers { + if !slices.Contains(usernames, u) { + t.Errorf("expected %s in credentials", u) + } + } + } + }) + } +} + +func TestCredentialStore_PasswordsFromFile(t *testing.T) { + tests := []struct { + name string + initialUsers []string + path string + wantPasswords []string + wantErr bool + }{ + { + name: "when_reading_valid_passwords_file_adds_passwords_to_all_users", + initialUsers: []string{"user1"}, + path: "testdata/passwords.txt", + wantPasswords: []string{"testpassword1", "testpassword2"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := NewCredentialStore() + for _, u := range tt.initialUsers { + store.AddUser(u) + } + + err := store.PasswordsFromFile(tt.path) + if (err != nil) != tt.wantErr { + t.Fatalf("PasswordsFromFile() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + for _, u := range tt.initialUsers { + creds := store.Passwords(u) + if len(creds) != len(tt.wantPasswords) { + t.Errorf("expected %d passwords, got %d", len(tt.wantPasswords), len(creds)) + } + for _, p := range tt.wantPasswords { + if !slices.Contains(creds, p) { + t.Errorf("expected %s", p) + } + } + } + } + }) + } +}