Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Enpass - a password manager for secrets #1189

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/kamal/cli/secrets.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class Kamal::Cli::Secrets < Kamal::Cli::Base
desc "fetch [SECRETS...]", "Fetch secrets from a vault"
option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use"
option :account, type: :string, required: true, desc: "The account identifier or username"
option :account, type: :string, required: false, desc: "The account identifier or username"
option :from, type: :string, required: false, desc: "A vault or folder to fetch the secrets from"
option :inline, type: :boolean, required: false, hidden: true
def fetch(*secrets)
Expand Down
68 changes: 68 additions & 0 deletions lib/kamal/secrets/adapters/enpass.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
##
# 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
end
end.to_a
end

def parse_result_and_take_secrets(unparsed_result, secrets)
result = JSON.parse(unparsed_result)

result.reduce({}) do |secrets_with_passwords, item|
title = item["title"]
label = item["label"]
password = item["password"]

if title && password.present?
key = [ title, label ].compact.reject(&:empty?).join("/")

if secrets.include?(title) || secrets.include?(key)
raise RuntimeError, "#{key} is present more than once" if secrets_with_passwords[key]
secrets_with_passwords[key] = password
end
end

secrets_with_passwords
end
end
end
81 changes: 81 additions & 0 deletions test/secrets/enpass_adapter_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
require "test_helper"

class EnpassAdapterTest < SecretAdapterTestCase
test "fetch without CLI installed" do
stub_ticks_with("enpass-cli version 2> /dev/null", succeed: false)

error = assert_raises RuntimeError do
JSON.parse(shellunescape(run_command("fetch", "mynote")))
end

assert_equal "Enpass CLI is not installed", error.message
end

test "fetch one item" do
stub_ticks_with("enpass-cli version 2> /dev/null")

stub_ticks
.with("enpass-cli -json -vault vault-path show FooBar")
.returns(<<~JSON)
[{"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"}]
JSON

json = JSON.parse(shellunescape(run_command("fetch", "FooBar/SECRET_1")))

expected_json = { "FooBar/SECRET_1" => "my-password-1" }

assert_equal expected_json, json
end

test "fetch multiple items" do
stub_ticks_with("enpass-cli version 2> /dev/null")

stub_ticks
.with("enpass-cli -json -vault vault-path show FooBar")
.returns(<<~JSON)
[
{"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_2","login":"","password":"my-password-2","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_3","login":"","password":"my-password-1","title":"Hello","type":"password"}
]
JSON

json = JSON.parse(shellunescape(run_command("fetch", "FooBar/SECRET_1", "FooBar/SECRET_2")))

expected_json = { "FooBar/SECRET_1" => "my-password-1", "FooBar/SECRET_2" => "my-password-2" }

assert_equal expected_json, json
end

test "fetch all with from" do
stub_ticks_with("enpass-cli version 2> /dev/null")

stub_ticks
.with("enpass-cli -json -vault vault-path show FooBar")
.returns(<<~JSON)
[
{"category":"computer","label":"SECRET_1","login":"","password":"my-password-1","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_2","login":"","password":"my-password-2","title":"FooBar","type":"password"},
{"category":"computer","label":"SECRET_3","login":"","password":"my-password-1","title":"Hello","type":"password"},
{"category":"computer","label":"","login":"","password":"my-password-3","title":"FooBar","type":"password"}
]
JSON

json = JSON.parse(shellunescape(run_command("fetch", "FooBar")))

expected_json = { "FooBar/SECRET_1" => "my-password-1", "FooBar/SECRET_2" => "my-password-2", "FooBar" => "my-password-3" }

assert_equal expected_json, json
end

private
def run_command(*command)
stdouted do
Kamal::Cli::Secrets.start \
[ *command,
"-c", "test/fixtures/deploy_with_accessories.yml",
"--adapter", "enpass",
"--from", "vault-path" ]
end
end
end