diff --git a/docs/cmds/values.md b/docs/cmds/values.md new file mode 100644 index 0000000..ab0cb51 --- /dev/null +++ b/docs/cmds/values.md @@ -0,0 +1,344 @@ +# safe values + +The `values` command searches through your Vault secrets to find all secrets that contain specific values. This is particularly useful when you need to audit where certain values (like passwords, API keys, or tokens) are being used across your infrastructure. + +## Usage + +``` +safe values [--keys] VALUE [VALUE ...] [PATH ...] +``` + +### Arguments + +- `VALUE` - One or more exact values to search for (required) +- `PATH` - One or more paths to search under (optional, defaults to "secret") + +### Options + +- `--keys` - Show the specific keys containing the values (format: `:`) + +### Output Format + +**Without --keys (default):** +``` + +``` +Shows only the paths of secrets containing any of the specified values. + +**With --keys:** +``` +: +``` +Shows the specific location of each key containing a matching value. + +Results are sorted alphabetically for consistent output. + +## Examples + +### Basic Search (Paths Only) + +Search for a value across all secrets under the default path: + +```bash +$ safe values "us-east-1" +secret/app/backend +secret/app/frontend +``` + +### Show Specific Keys + +Use `--keys` to see which keys contain the values: + +```bash +$ safe values --keys "prod-api-key-123" +secret/app/backend:region +secret/app/frontend:region +``` + +### Search Multiple Values + +Search for any of several values at once: + +```bash +$ safe values "postres" "admin" "root" +secret/app/db +secret/backup/db/username +secret/users/admin +secret/root/username +``` + +With keys: +```bash +$ safe values --keys "password123" "admin123" +secret/app/db:password +secret/backup/postgresql:admin_pass +secret/users/admin:password +secret/users/admin:old_password +``` + +### Search Specific Paths + +Search only within specific paths: + +```bash +$ safe values "admin" secret/users secret/admins +secret/admins/root +secret/users/alice +secret/users/bob +``` + +With keys: +```bash +$ safe values --keys "admin" secret/users secret/admins +secret/admins/root:username +secret/users/alice:role +secret/users/bob:permission +``` + +### Search with Special Characters + +Values containing special characters work as expected: + +```bash +$ safe values "user@example.com" +secret/config/smtp +secret/users/profile + +$ safe values --keys "pass:word:123" +secret/test/creds:complex_password +``` + +### Empty Results + +When no matches are found, the command returns empty output: + +```bash +$ safe values "nonexistent-value" +``` + +## Common Use Cases + +### 1. Password Audit + +Find all locations where specific passwords are used: + +```bash +$ safe values "MyOldPassword123" "MyOtherOldPass" +secret/app/db +secret/backup/postgresql +secret/legacy/system +``` + +Get specific details with --keys: +```bash +$ safe values --keys "MyOldPassword123" +secret/app/db:password +secret/backup/postgresql:admin_pass +secret/legacy/system:pwd +``` + +This helps identify secrets that need to be rotated. + +### 2. API Key Management + +Track where API keys are configured: + +```bash +$ safe values "sk_live_4242424242424242" "pk_live_5353535353535353" +secret/production/stripe +secret/billing/processor +``` + +With details: +```bash +$ safe values --keys "sk_live_4242424242424242" +secret/production/stripe:secret_key +secret/billing/processor:api_key +``` + +### 3. Configuration Values + +Find all services using specific configurations: + +```bash +$ safe values "https://api.internal.company.com" "https://legacy-api.company.com" +secret/app/config +secret/services/auth +secret/monitoring/config +``` + +### 4. Compliance Checks + +Verify that sensitive values aren't stored in unexpected locations: + +```bash +$ safe values --keys "SSN-123-45-6789" +secret/test/data:sample_ssn # This shouldn't be in test! +``` + +### 5. Bulk Search + +Search for multiple related values at once: + +```bash +$ safe values "prod-db-pass" "staging-db-pass" "dev-db-pass" secret/databases/ +secret/databases/production +secret/databases/staging +secret/databases/development +``` + +## Behavior Notes + +### Version Handling + +- Only searches the latest version of each secret +- Ignores deleted or destroyed secret versions +- For versioned backends (KV v2), only the current version is checked + +### Performance + +- The command walks the entire tree under the specified paths +- For large Vaults, consider searching specific paths rather than the entire tree +- Results are collected in memory and sorted before output + +### Permissions + +- Requires read access to the paths being searched +- Secrets you don't have permission to read will be silently skipped +- No error is shown for inaccessible paths during the search + +### Special Characters + +- The search values must match exactly (case-sensitive) +- Special characters in paths and keys are properly escaped in output +- Use quotes around values containing spaces or shell metacharacters +- Multiple values are searched with OR logic (finds secrets containing ANY of the values) + +### Argument Parsing + +- Values are specified first, followed by optional paths +- Paths are identified by containing "/" or being common mount points (secret, cubbyhole, sys) +- The command works backwards through arguments to identify paths +- Everything before the paths is treated as values to search for + +## Examples with Setup + +Here's a complete example showing setup and search: + +```bash +# Create some test data +$ safe set secret/app/prod database_url="postgres://prod.db" api_key="abc123" +$ safe set secret/app/staging database_url="postgres://staging.db" api_key="abc123" +$ safe set secret/app/dev database_url="postgres://dev.db" api_key="xyz789" + +# Search for the shared API key (paths only) +$ safe values "abc123" +secret/app/prod +secret/app/staging + +# Search with key details +$ safe values --keys "abc123" +secret/app/prod:api_key +secret/app/staging:api_key + +# Search for multiple values +$ safe values "abc123" "xyz789" +secret/app/dev +secret/app/prod +secret/app/staging + +# Search for database URLs in production +$ safe values --keys "postgres://prod.db" secret/app/prod +secret/app/prod:database_url + +# Clean up +$ safe delete secret/app/prod secret/app/staging secret/app/dev +``` + +## Comparison with Other Commands + +- Use `safe find` or `safe search` (if available) to search by key names +- Use `safe tree --keys` to see all keys and their values in a tree format +- Use `safe paths` to list all secret paths without values +- Use `safe get` to retrieve specific secrets once found + +## Error Handling + +The command will return an error if: +- No value arguments are provided +- A specified path doesn't exist +- You don't have permission to access a specified path + +Errors are reported to stderr, while results go to stdout, allowing for proper scripting: + +```bash +# Redirect errors while capturing results +results=$(safe values "api-key" 2>/dev/null) +``` + +## Security Considerations + +- The search values are provided on the command line and may be visible in process listings +- Consider using this command from scripts rather than interactively for sensitive values +- Results show paths (and optionally keys) but not the full secret values +- Command history may contain the searched values + +## Integration Examples + +### Bash Script for Rotation + +```bash +#!/bin/bash +# Find all secrets using old API keys + +OLD_KEYS=("sk_old_123" "sk_old_456" "sk_old_789") +echo "Secrets using old API keys:" +safe values "${OLD_KEYS[@]}" | while read -r path; do + echo " - $path" +done +``` + +With specific key details: +```bash +#!/bin/bash +# Find and list all keys using old API keys + +OLD_KEY="sk_old_1234567890" +echo "Keys using old API key:" +safe values --keys "$OLD_KEY" | while read -r location; do + echo " - $location" +done +``` + +### Migration Helper + +```bash +#!/bin/bash +# Find all database URLs pointing to old servers + +OLD_DBS=("postgres://old1.company.com" "postgres://old2.company.com") +NEW_DB="postgres://new-server.company.com" + +echo "Secrets to migrate:" +safe values "${OLD_DBS[@]}" | while read -r path; do + echo "Processing: $path" + # Would need to get, modify, and set the secret +done +``` + +### Audit Script + +```bash +#!/bin/bash +# Audit for common weak passwords + +WEAK_PASSWORDS=("password" "123456" "admin" "default" "changeme") +echo "Checking for weak passwords..." +results=$(safe values --keys "${WEAK_PASSWORDS[@]}" 2>/dev/null) +if [ -n "$results" ]; then + echo "WARNING: Weak passwords found:" + echo "$results" +else + echo "No weak passwords found." +fi +``` diff --git a/main.go b/main.go index b20eb2f..24c9eea 100644 --- a/main.go +++ b/main.go @@ -194,6 +194,10 @@ type Options struct { Quick bool `cli:"-q, --quick"` } `cli:"tree"` + Values struct { + ShowKeys bool `cli:"--keys"` + } `cli:"values"` + Target struct { JSON bool `cli:"--json"` Interactive bool `cli:"-i, --interactive"` @@ -2191,6 +2195,77 @@ vaults. This flag does nothing for kv v1 mounts. return nil }) + r.Dispatch("values", &Help{ + Summary: "Find secrets containing specified values", + Usage: "safe values [--keys] VALUE [VALUE ...] [PATH ...]", + Type: NonDestructiveCommand, + Description: ` +Searches the hierarchy of secrets for any that contain the specified value(s). +By default, outputs just the paths of secrets containing any of the values. +With --keys, outputs the location in the format : for each match. + +Multiple values can be specified and the command will find secrets containing +any of the given values. Paths at the end of the argument list are treated as +search locations. If no paths are specified, defaults to searching under 'secret'. + +Examples: + safe values password123 # Find secrets containing this value + safe values --keys password123 secret/prod # Show which keys have this value + safe values val1 val2 val3 secret/ # Find any of these values +`, + }, func(command string, args ...string) error { + rc.Apply(opt.UseTarget) + + if len(args) < 1 { + r.ExitWithUsage("values") + } + + // Separate values from paths + // Simple heuristic: paths contain "/" or are common mount points + var values []string + var paths []string + + // Find where paths start (working backwards) + pathStart := len(args) + for i := len(args) - 1; i >= 0; i-- { + arg := args[i] + // Check if this looks like a path + if strings.Contains(arg, "/") || arg == "secret" || arg == "cubbyhole" || arg == "sys" { + pathStart = i + } else { + // Not a path pattern, stop checking + break + } + } + + values = args[:pathStart] + if pathStart < len(args) { + paths = args[pathStart:] + } + + // Need at least one value + if len(values) == 0 { + return fmt.Errorf("no values specified to search for") + } + + // Default to secret if no paths specified + if len(paths) == 0 { + paths = append(paths, "secret") + } + + v := connect(true) + results, err := v.FindValueMatches(paths, values, opt.Values.ShowKeys) + if err != nil { + return err + } + + for _, result := range results { + fmt.Printf("%s\n", result) + } + + return nil + }) + r.Dispatch("delete", &Help{ Summary: "Remove one or more path from the Vault", Usage: "safe delete [-rfDa] PATH [PATH ...]", diff --git a/rc/config.go b/rc/config.go index 6c7371c..8db6ee1 100644 --- a/rc/config.go +++ b/rc/config.go @@ -161,7 +161,7 @@ func (c *Config) Write() error { return ioutil.WriteFile(svtoken(), b, 0600) } -//Returns the path of the file that the certificates were written into +// Returns the path of the file that the certificates were written into func writeTempCACerts(certs []string) (string, error) { cleanupLock.Lock() defer cleanupLock.Unlock() @@ -348,7 +348,7 @@ func (c *Config) Vault(which string) (*Vault, error) { return v, nil } -//Cleanup will clean up any temporary files that the rc package may have made. +// Cleanup will clean up any temporary files that the rc package may have made. // Cleanup is thread-safe and can be called multiple times. func Cleanup() { cleanupLock.Lock() diff --git a/util.go b/util.go index 920c04d..805246b 100644 --- a/util.go +++ b/util.go @@ -30,7 +30,7 @@ func duration(s string) (time.Duration, error) { } func uniq(l []string) []string { - seen := make(map[string] bool) + seen := make(map[string]bool) u := make([]string, 0) for _, s := range l { diff --git a/vault/errors.go b/vault/errors.go index b1f5975..db3fb52 100644 --- a/vault/errors.go +++ b/vault/errors.go @@ -19,26 +19,27 @@ func (e keyNotFound) Error() string { return fmt.Sprintf("no key `%s` exists in secret `%s`", e.key, e.secret) } -//IsNotFound returns true if the given error is a SecretNotFound error -// or a KeyNotFound error. Returns false otherwise. +// IsNotFound returns true if the given error is a SecretNotFound error +// +// or a KeyNotFound error. Returns false otherwise. func IsNotFound(err error) bool { return IsSecretNotFound(err) || IsKeyNotFound(err) } -//NewSecretNotFoundError returns an error with a message descibing the path +// NewSecretNotFoundError returns an error with a message descibing the path // which could not be found in the secret backend. func NewSecretNotFoundError(path string) error { return secretNotFound{message: fmt.Sprintf("no secret exists at path `%s`", path)} } -//IsSecretNotFound returns true if the given error was created with +// IsSecretNotFound returns true if the given error was created with // NewSecretNotFoundError(). False otherwise. func IsSecretNotFound(err error) bool { _, is := err.(secretNotFound) return is } -//NewKeyNotFoundError returns an error object describing the key that could not +// NewKeyNotFoundError returns an error object describing the key that could not // be located within the secret it was searched for in. Returning a KeyNotFound // error should semantically mean that the secret it would've been contained in // was located in the vault. @@ -46,7 +47,7 @@ func NewKeyNotFoundError(path, key string) error { return keyNotFound{secret: path, key: key} } -//IsKeyNotFound returns true if the given error was created with +// IsKeyNotFound returns true if the given error was created with // NewKeyNotFoundError(). False otherwise. func IsKeyNotFound(err error) bool { _, is := err.(keyNotFound) diff --git a/vault/seal.go b/vault/seal.go index 458d3c8..5046c72 100644 --- a/vault/seal.go +++ b/vault/seal.go @@ -4,7 +4,7 @@ import ( "github.com/cloudfoundry-community/vaultkv" ) -//SealKeys returns the threshold for unsealing the vault +// SealKeys returns the threshold for unsealing the vault func (v *Vault) SealKeys() (int, error) { state, err := v.client.Client.SealStatus() if err != nil { diff --git a/vault/tree.go b/vault/tree.go index 305a60a..7289d08 100644 --- a/vault/tree.go +++ b/vault/tree.go @@ -637,7 +637,7 @@ func (s Secrets) printableTree(color, secrets bool, index int) *tree.Node { if len(s[0].Versions) > 0 && len(s[0].Versions[len(s[0].Versions)-1].Data.Keys()) > 0 { keys := s[0].Versions[len(s[0].Versions)-1].Data.Keys() sort.Strings(keys) // Sort keys for consistent output - + // Create child nodes for each key instead of appending to the name for _, key := range keys { keyName := fmt.Sprintf(":%s", key) @@ -934,3 +934,68 @@ func (w *treeWorker) workVersions(t secretTree) ([]secretTree, error) { return ret, nil } + +func (v *Vault) FindValueMatches(paths []string, targetValues []string, showKeys bool) ([]string, error) { + // Use a map to track unique results + resultMap := make(map[string]bool) + + // Create a map for faster value lookups + valueMap := make(map[string]bool) + for _, val := range targetValues { + valueMap[val] = true + } + + for _, path := range paths { + secrets, err := v.ConstructSecrets(path, TreeOpts{ + FetchKeys: true, + AllowDeletedSecrets: false, + SkipVersionInfo: false, + }) + if err != nil { + return nil, err + } + + for _, secret := range secrets { + if len(secret.Versions) == 0 { + continue + } + + // Check the latest version + latestVersion := secret.Versions[len(secret.Versions)-1] + if latestVersion.State != SecretStateAlive { + continue + } + + foundInSecret := false + for _, key := range latestVersion.Data.Keys() { + value := latestVersion.Data.Get(key) + if valueMap[value] { + if showKeys { + // Add path:key format + result := fmt.Sprintf("%s:%s", + EscapePathSegment(secret.Path), + EscapePathSegment(key)) + resultMap[result] = true + } else { + // Just mark that this secret contains a matching value + foundInSecret = true + } + } + } + + // If not showing keys and we found a match, add just the path + if !showKeys && foundInSecret { + resultMap[EscapePathSegment(secret.Path)] = true + } + } + } + + // Convert map to sorted slice + results := make([]string, 0, len(resultMap)) + for result := range resultMap { + results = append(results, result) + } + sort.Strings(results) + + return results, nil +} diff --git a/vault/tree_test.go b/vault/tree_test.go new file mode 100644 index 0000000..eb58a41 --- /dev/null +++ b/vault/tree_test.go @@ -0,0 +1,153 @@ +package vault_test + +import ( + "github.com/cloudfoundry-community/safe/vault" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Tree", func() { + Describe("FindValueMatches", func() { + var v *vault.Vault + var testSecrets map[string]map[string]string + + BeforeEach(func() { + // This is a mock setup - in real tests, we'd need a proper Vault instance + // For now, we're documenting the test structure + testSecrets = map[string]map[string]string{ + "secret/test1": { + "username": "admin", + "password": "secret123", + "api_key": "abc123", + }, + "secret/test2": { + "token": "secret123", + "username": "user", + "code": "xyz789", + }, + "secret/nested/path": { + "password": "secret123", + "apikey": "different", + }, + } + }) + + Context("when searching for values with showKeys=true", func() { + It("should find all keys with matching values", func() { + // This would need a real Vault instance to test + // Expected behavior: + // results, err := v.FindValueMatches([]string{"secret"}, []string{"secret123"}, true) + // Expect(err).NotTo(HaveOccurred()) + // Expect(results).To(ConsistOf( + // "secret/test1:password", + // "secret/test2:token", + // "secret/nested/path:password", + // )) + }) + + It("should return results sorted alphabetically", func() { + // This would verify the sorting behavior + // results, err := v.FindValueMatches([]string{"secret"}, []string{"secret123"}, true) + // Expect(err).NotTo(HaveOccurred()) + // Expect(results).To(Equal([]string{ + // "secret/nested/path:password", + // "secret/test1:password", + // "secret/test2:token", + // })) + }) + + It("should find matches for multiple values", func() { + // results, err := v.FindValueMatches([]string{"secret"}, []string{"secret123", "abc123"}, true) + // Expect(err).NotTo(HaveOccurred()) + // Expect(results).To(ConsistOf( + // "secret/test1:password", + // "secret/test1:api_key", + // "secret/test2:token", + // "secret/nested/path:password", + // )) + }) + }) + + Context("when searching for values with showKeys=false", func() { + It("should return only paths containing matching values", func() { + // results, err := v.FindValueMatches([]string{"secret"}, []string{"secret123"}, false) + // Expect(err).NotTo(HaveOccurred()) + // Expect(results).To(ConsistOf( + // "secret/test1", + // "secret/test2", + // "secret/nested/path", + // )) + }) + + It("should not duplicate paths when multiple keys match", func() { + // If a secret has multiple keys with matching values, path should appear only once + // results, err := v.FindValueMatches([]string{"secret"}, []string{"admin", "secret123"}, false) + // Expect(err).NotTo(HaveOccurred()) + // Expect(results).To(ConsistOf( + // "secret/test1", // has both username=admin and password=secret123 + // "secret/test2", + // "secret/nested/path", + // )) + }) + }) + + Context("when searching for a value that doesn't exist", func() { + It("should return an empty result", func() { + // results, err := v.FindValueMatches([]string{"secret"}, []string{"nonexistent"}, true) + // Expect(err).NotTo(HaveOccurred()) + // Expect(results).To(BeEmpty()) + }) + }) + + Context("when searching multiple paths", func() { + It("should search all provided paths", func() { + // results, err := v.FindValueMatches([]string{"secret/test1", "secret/test2"}, []string{"admin"}, true) + // Expect(err).NotTo(HaveOccurred()) + // Expect(results).To(Equal([]string{"secret/test1:username"})) + }) + }) + + Context("when a path doesn't exist", func() { + It("should return an error", func() { + // _, err := v.FindValueMatches([]string{"nonexistent/path"}, []string{"value"}, true) + // Expect(err).To(HaveOccurred()) + }) + }) + + Context("with special characters in values", func() { + It("should handle values with colons", func() { + // Test with value containing ":" + }) + + It("should handle values with special characters", func() { + // Test with values containing various special chars + }) + }) + + Context("with deleted or destroyed secrets", func() { + It("should skip deleted secrets", func() { + // Verify that deleted secrets are not included in results + }) + + It("should skip destroyed secrets", func() { + // Verify that destroyed secrets are not included in results + }) + }) + }) + + Describe("PathLessThan", func() { + Context("when comparing paths", func() { + It("should sort paths correctly", func() { + Expect(vault.PathLessThan("secret/a", "secret/b")).To(BeTrue()) + Expect(vault.PathLessThan("secret/b", "secret/a")).To(BeFalse()) + Expect(vault.PathLessThan("secret/a", "secret/a/b")).To(BeTrue()) + Expect(vault.PathLessThan("secret/a/b", "secret/a")).To(BeFalse()) + }) + + It("should handle paths with trailing slashes", func() { + Expect(vault.PathLessThan("secret/a/", "secret/a")).To(BeFalse()) + Expect(vault.PathLessThan("secret/a", "secret/a/")).To(BeTrue()) + }) + }) + }) +}) \ No newline at end of file