Skip to content

Commit f6a6e88

Browse files
authored
feat: add env command for shell-consumable vault export (#158)
* feat: add env command for shell-consumable vault export Outputs vault field values as shell-safe KEY='value' lines, designed for eval $(enpass-cli env ...) workflows. Supports -field flag to select a specific field label (default: password) and -json for programmatic consumption. Claude-Session: https://claude.ai/code/session_01Lr7gquKQVPezfc8NvN39q8 * fix: harden env command input validation and field resolution Validate varName as a POSIX shell identifier to prevent injection via eval. Clear the default cardType filter in the -field path so non-password field labels (username, Access Key, etc.) are actually reachable. Output text pairs immediately instead of accumulating. Claude-Session: https://claude.ai/code/session_01Lr7gquKQVPezfc8NvN39q8
1 parent 36b13ef commit f6a6e88

1 file changed

Lines changed: 127 additions & 1 deletion

File tree

cmd/enpasscli/main.go

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const (
3737
cmdTrash = "trash"
3838
cmdRestore = "restore"
3939
cmdDelete = "delete"
40+
cmdEnv = "env"
4041

4142
// defaults
4243
defaultLogLevel = logrus.InfoLevel
@@ -51,7 +52,7 @@ var (
5152
commands = map[string]struct{}{
5253
cmdVersion: {}, cmdHelp: {}, cmdDryRun: {}, cmdList: {},
5354
cmdShow: {}, cmdCopy: {}, cmdPass: {}, cmdUi: {},
54-
cmdCreate: {}, cmdEdit: {}, cmdTrash: {}, cmdRestore: {}, cmdDelete: {},
55+
cmdCreate: {}, cmdEdit: {}, cmdTrash: {}, cmdRestore: {}, cmdDelete: {}, cmdEnv: {},
5556
}
5657
)
5758

@@ -72,6 +73,7 @@ type Args struct {
7273
detailed *bool
7374
and *bool
7475
clipboardPrimary *bool
76+
field *string
7577
// write command flags
7678
title *string
7779
login *string
@@ -95,6 +97,7 @@ func (args *Args) parse() {
9597
args.trashed = flag.Bool("trashed", false, "Show trashed items in the 'list' and 'show' command.")
9698
args.detailed = flag.Bool("detailed", false, "Show every field of each entry in 'list' and 'show'. Without this flag, only the original summary fields (title, login, category, label, type) are displayed.")
9799
args.clipboardPrimary = flag.Bool("clipboardPrimary", false, "Use primary X selection instead of clipboard for the 'copy' command.")
100+
args.field = flag.String("field", "", "Field label to extract (default: password). Used with 'env' command.")
98101
// write command flags
99102
args.title = flag.String("title", "", "Entry title (for create/edit).")
100103
args.login = flag.String("login", "", "Username or email (for create/edit).")
@@ -131,6 +134,7 @@ func printHelp() {
131134
fmt.Println(" show [filter] Show entries (with passwords; computes RFC 6238 TOTP code)")
132135
fmt.Println(" copy <filter> Copy password to clipboard")
133136
fmt.Println(" pass <filter> Print password to stdout")
137+
fmt.Println(" env VARNAME=filter Output entry field as KEY=VALUE for shell eval")
134138
fmt.Println(" ui Interactive terminal UI")
135139
fmt.Println(" create Create a new entry")
136140
fmt.Println(" edit <filter> Edit an existing entry")
@@ -146,6 +150,11 @@ func printHelp() {
146150
fmt.Println("are treated as sensitive: their secret is hidden in list, and show prints")
147151
fmt.Println("the current RFC 6238 code alongside the secret.")
148152
fmt.Println()
153+
fmt.Println("The env command outputs vault values as shell-safe KEY='value' lines.")
154+
fmt.Println("Use -field to select a specific field label (default: password).")
155+
fmt.Println(" eval $(enpass-cli -vault /path env MY_SECRET=\"entry title\")")
156+
fmt.Println(" eval $(enpass-cli -vault /path env -field \"Access Key\" AWS_KEY=\"AWS\")")
157+
fmt.Println()
149158
fmt.Println("Flags:")
150159
flag.Usage()
151160
}
@@ -482,6 +491,121 @@ func entryPassword(logger *logrus.Logger, vault *enpass.Vault, args *Args) {
482491
}
483492
}
484493

494+
func envEntries(logger *logrus.Logger, vault *enpass.Vault, args *Args) {
495+
if len(args.filters) == 0 {
496+
logger.Fatal("env command requires at least one VARNAME=filter argument")
497+
}
498+
499+
var jsonResult map[string]string
500+
if *args.jsonOutput {
501+
jsonResult = make(map[string]string, len(args.filters))
502+
}
503+
504+
for _, arg := range args.filters {
505+
eqIdx := strings.Index(arg, "=")
506+
if eqIdx < 1 {
507+
logger.Fatalf("invalid argument %q: expected VARNAME=filter", arg)
508+
}
509+
510+
varName := arg[:eqIdx]
511+
filter := arg[eqIdx+1:]
512+
513+
for _, r := range varName {
514+
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') {
515+
logger.Fatalf("invalid variable name %q: must contain only letters, digits, and underscores", varName)
516+
}
517+
}
518+
if varName[0] >= '0' && varName[0] <= '9' {
519+
logger.Fatalf("invalid variable name %q: must not start with a digit", varName)
520+
}
521+
522+
var value string
523+
524+
if *args.field == "" {
525+
card, err := vault.GetEntry(*args.cardType, []string{filter}, true)
526+
if err != nil {
527+
logger.WithError(err).Fatalf("could not retrieve entry for %s", varName)
528+
}
529+
530+
decrypted, err := card.Decrypt()
531+
if err != nil {
532+
logger.WithError(err).Fatalf("could not decrypt entry for %s", varName)
533+
}
534+
535+
value = decrypted
536+
} else {
537+
typeFilter := *args.cardType
538+
if typeFilter == "password" {
539+
typeFilter = ""
540+
}
541+
542+
cards, err := vault.GetAllFields(typeFilter, []string{filter})
543+
if err != nil {
544+
logger.WithError(err).Fatalf("could not retrieve fields for %s", varName)
545+
}
546+
547+
// Group by entry UUID and enforce uniqueness.
548+
entries := make(map[string][]enpass.Card)
549+
var order []string
550+
for _, c := range cards {
551+
if c.IsDeleted() || c.IsTrashed() {
552+
continue
553+
}
554+
if _, seen := entries[c.UUID]; !seen {
555+
order = append(order, c.UUID)
556+
}
557+
entries[c.UUID] = append(entries[c.UUID], c)
558+
}
559+
560+
if len(entries) == 0 {
561+
logger.Fatalf("no entry found matching filter for %s", varName)
562+
}
563+
if len(entries) > 1 {
564+
logger.Fatalf("multiple entries match filter for %s, refine your filter", varName)
565+
}
566+
567+
// Find the field matching -field label.
568+
fields := entries[order[0]]
569+
var match *enpass.Card
570+
for i, c := range fields {
571+
if strings.EqualFold(c.Label, *args.field) {
572+
match = &fields[i]
573+
break
574+
}
575+
}
576+
577+
if match == nil {
578+
logger.Fatalf("no field %q found in entry for %s", *args.field, varName)
579+
}
580+
581+
decrypted, err := match.Decrypt()
582+
if err != nil {
583+
logger.WithError(err).Fatalf("could not decrypt field %q for %s", *args.field, varName)
584+
}
585+
586+
value = decrypted
587+
}
588+
589+
if jsonResult != nil {
590+
jsonResult[varName] = value
591+
} else {
592+
fmt.Printf("%s='%s'\n", varName, shellQuote(value))
593+
}
594+
}
595+
596+
if jsonResult != nil {
597+
jsonData, err := json.Marshal(jsonResult)
598+
if err != nil {
599+
logger.WithError(err).Fatal("could not marshal JSON output")
600+
}
601+
fmt.Println(string(jsonData))
602+
}
603+
}
604+
605+
func shellQuote(s string) string {
606+
return strings.ReplaceAll(s, "'", "'\\''")
607+
}
608+
485609
func ui(logger *logrus.Logger, vault *enpass.Vault, args *Args) {
486610
cards, err := vault.GetEntries(*args.cardType, args.filters)
487611
if err != nil {
@@ -893,6 +1017,8 @@ func main() {
8931017
trashEntry(logger, vault, args)
8941018
case cmdRestore:
8951019
restoreEntry(logger, vault, args)
1020+
case cmdEnv:
1021+
envEntries(logger, vault, args)
8961022
case cmdDelete:
8971023
deleteEntry(logger, vault, args)
8981024
default:

0 commit comments

Comments
 (0)