diff --git a/cmd/api/main.go b/cmd/api/main.go index f0d01a3..94d6623 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -126,6 +126,7 @@ func main() { api.Put("/secret/:name", h.UpdateSecret) api.Delete("/secret/:name", h.DeleteSecret) api.Get("/folders", h.ListFolders) + api.Get("/search", h.SearchSecrets) api.Post("/refresh", h.RefreshCache) // Graceful shutdown. diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index fd8de72..0c6b97b 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -271,6 +271,27 @@ func (h *Handler) RefreshCache(c *fiber.Ctx) error { }) } +// SearchSecrets handles GET /search?q=. +// Returns every vault item matching the query across ALL fields (name, username, +// uri, notes, password, custom fields). Values are masked in the listing. +// Requires the q parameter; empty query returns an empty list. +func (h *Handler) SearchSecrets(c *fiber.Ctx) error { + q := strings.TrimSpace(c.Query("q")) + if q == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "query parameter 'q' is required", + }) + } + + results := h.vaultClient.SearchSecrets(q) + + return c.JSON(fiber.Map{ + "query": q, + "count": len(results), + "results": results, + }) +} + // CreateSecret handles POST /secret. Body (JSON): // // { diff --git a/internal/vaultwarden/client.go b/internal/vaultwarden/client.go index ee1e865..e922099 100644 --- a/internal/vaultwarden/client.go +++ b/internal/vaultwarden/client.go @@ -173,6 +173,74 @@ func (c *Client) getSecretItem(name string, filter SecretFilter) (DecryptedItem, return DecryptedItem{}, fmt.Errorf("secret not found") } +// SearchSecrets returns every decrypted item whose name, username, uri, notes, +// password, or any custom field contains the query string (case-insensitive). +// It is a substring search across ALL fields — unlike GetSecret which is limited +// to the item name. Values are masked so the list can be returned safely. +func (c *Client) SearchSecrets(query string) []map[string]interface{} { + q := strings.ToLower(strings.TrimSpace(query)) + if q == "" { + return []map[string]interface{}{} + } + + c.mu.RLock() + defer c.mu.RUnlock() + + results := make([]map[string]interface{}, 0) + for _, item := range c.items { + if matchesAllFields(item, q) { + m := item.ToMap() + // Mask sensitive values in the listing. + if v, ok := m["password"].(string); ok && v != "" { + m["password"] = maskSecret(v) + } + if v, ok := m["value"].(string); ok && v != "" { + m["value"] = maskSecret(v) + } + if v, ok := m["username"].(string); ok && v != "" { + m["username"] = maskSecret(v) + } + results = append(results, m) + } + } + return results +} + +// matchesAllFields checks the query against every decryptable field of an item. +func matchesAllFields(item DecryptedItem, q string) bool { + if strings.Contains(strings.ToLower(item.Name), q) { + return true + } + if strings.Contains(strings.ToLower(item.Username), q) { + return true + } + if strings.Contains(strings.ToLower(item.URI), q) { + return true + } + if strings.Contains(strings.ToLower(item.Notes), q) { + return true + } + if strings.Contains(strings.ToLower(item.Password), q) { + return true + } + for k, v := range item.Fields { + if strings.Contains(strings.ToLower(k), q) || + strings.Contains(strings.ToLower(v), q) { + return true + } + } + return false +} + +// maskSecret reveals only the first 2 and last 2 characters of a value. +func maskSecret(v string) string { + r := []rune(v) + if len(r) <= 4 { + return "***" + } + return string(r[:2]) + "***" + string(r[len(r)-2:]) +} + // ToMap renders the decrypted item as a flat map for JSON output. // The "value" key holds the same primary value extractSecret() would return, // while the other keys expose every decrypted field.