Repository: mrsked/mrsk
File: lib/kamal/secrets/adapters/enpass.rb (Ruby)

Code before:
require "open3"

##
# Enpass is different from most password managers, in a way that it's offline. A path to a vault is treated as account.
#
# Pass it like so: `kamal secrets fetch --adapter enpass --account /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary --from MY_PROD_SERVER`
class Kamal::Secrets::Adapters::Enpass < Kamal::Secrets::Adapters::Base
  private
    def login(account)
      # There is no concept of session in enpass-cli
      true
    end

    def fetch_secrets(secrets, account:, session:)
      secrets_titles = fetch_secret_titles(secrets)

      # Enpass outputs result as stderr, I did not find a way to stub backticks and output to stderr. Open3 did the job.
      _stdout, stderr, status = Open3.capture3("enpass-cli -vault #{account.shellescape} show #{secrets_titles.map(&:shellescape).join(" ")}")
      raise RuntimeError, "Could not read #{secrets} from Enpass" unless status.success?

      parse_result_and_take_secrets(stderr, secrets)
    end

    def check_dependencies!
      raise RuntimeError, "Enpass CLI is not installed" unless cli_installed?
    end

    def cli_installed?
      `enpass-cli version 2> /dev/null`
      $?.success?
    end

    def fetch_secret_titles(secrets)
      secrets.reduce(Set.new) do |acc, secret|
        # Sometimes secrets contain a '/', sometimes not
        key, separator, value = secret.rpartition("/")
        if key.empty?
          acc << value
        else
          acc << key
        end
      end.to_a
    end

Review comment (security):
There no account here, so we probably:

1. Avoid passing an account at all
2. Use the from field to specify the vault

The base adapter just appends the from field and secrets together and passes them into `fetch_secrets`. 

That's not going to work here since the separator it uses is a `/`, so I think it should be ok to replace the `fetch` method for this adapter and have it pass the from field separately. 

We can refactor that to be neater later once all the other new adapter PRs have landed to avoid messing with them.

Code after:
##
# Enpass is different from most password managers, in a way that it's offline and doesn't need an account.
#
# Usage
#
# Fetch all password from FooBar item
# `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar`
#
# Fetch only DB_PASSWORD from FooBar item
# `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar/DB_PASSWORD`
class Kamal::Secrets::Adapters::Enpass < Kamal::Secrets::Adapters::Base
  def fetch(secrets, account: nil, from:)
    check_dependencies!
    fetch_secrets(secrets, from)
  end

  private
    def fetch_secrets(secrets, vault)
      secrets_titles = fetch_secret_titles(secrets)

      result = `enpass-cli -json -vault #{vault.shellescape} show #{secrets_titles.map(&:shellescape).join(" ")}`.strip

      parse_result_and_take_secrets(result, secrets)
    end

    def check_dependencies!
      raise RuntimeError, "Enpass CLI is not installed" unless cli_installed?
    end

    def cli_installed?
      `enpass-cli version 2> /dev/null`
      $?.success?
    end

    def fetch_secret_titles(secrets)
      secrets.reduce(Set.new) do |secret_titles, secret|
        # Sometimes secrets contain a '/', when the intent is to fetch a single password for an item. Example: FooBar/DB_PASSWORD
        # Another case is, when the intent is to fetch all passwords for an item. Example: FooBar (and FooBar may have multiple different passwords)
        key, separator, value = secret.rpartition("/")
        if key.empty?
          secret_titles << value
        else
          secret_titles << key