From d6c4411e97a1fedce07f2fc1698a953c0f1fa56d Mon Sep 17 00:00:00 2001 From: Aleksandr Lossenko Date: Sun, 3 Nov 2024 15:27:54 +0100 Subject: [PATCH] add support to enpass --- lib/kamal/secrets/adapters/enpass.rb | 59 +++++++++++++++++ test/secrets/enpass_adapter_test.rb | 99 ++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 lib/kamal/secrets/adapters/enpass.rb create mode 100644 test/secrets/enpass_adapter_test.rb diff --git a/lib/kamal/secrets/adapters/enpass.rb b/lib/kamal/secrets/adapters/enpass.rb new file mode 100644 index 00000000..b698d8c6 --- /dev/null +++ b/lib/kamal/secrets/adapters/enpass.rb @@ -0,0 +1,59 @@ +require "open3" + +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| + # Use rpartition to split the string at the last '/' + key, separator, value = secret.rpartition("/") + if key.empty? + acc << value + else + acc << key + end + end.to_a + end + + def parse_result_and_take_secrets(unparsed_result, secrets) + unparsed_result.split("\n").reduce({}) do |acc, line| + title = line[/title:\s*(\w+)/, 1] + label = line[/label:\s*(.*?)\s{2}/, 1] + password = line[/password:\s*([^"]+)/, 1] + + # If title and label are not empty and password is defined, add to the hash + if title && !password.to_s.empty? + key = label.nil? || label.empty? ? title : "#{title}/#{label}" + if secrets.include?(title) || secrets.include?(key) + raise RuntimeError, "#{key} is present more than once" if acc[key] + acc[key] = password + end + end + + acc + end + end +end diff --git a/test/secrets/enpass_adapter_test.rb b/test/secrets/enpass_adapter_test.rb new file mode 100644 index 00000000..d64bb6f6 --- /dev/null +++ b/test/secrets/enpass_adapter_test.rb @@ -0,0 +1,99 @@ +require "test_helper" + +class EnpassAdapterTest < SecretAdapterTestCase + setup do + `true` # Ensure $? is 0 + end + + 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") + + stderr_response = <<~RESULT + time="2024-11-03T13:34:39+01:00" level=info msg="> title: FooBar login: cat.: computer label: SECRET_1 password: my-password-1 + RESULT + + Open3.stubs(:capture3).returns([ "", stderr_response, OpenStruct.new(success?: true) ]) + + 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") + + stderr_response = <<~RESULT + time="2024-11-03T13:34:39+01:00" level=info msg="> title: FooBar login: cat.: computer label: SECRET_1 password: my-password-1 + time="2024-11-03T13:34:39+01:00" level=info msg="> title: FooBar login: cat.: computer label: SECRET_2 password: my-password-2 + time="2024-11-03T13:34:39+01:00" level=info msg="> title: Hello login: cat.: computer label: SECRET_3 password: my-password-3 + RESULT + + Open3.stubs(:capture3).returns([ "", stderr_response, OpenStruct.new(success?: true) ]) + + 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 multiple items with from" do + stub_ticks_with("enpass-cli version 2> /dev/null") + + stderr_response = <<~RESULT + time="2024-11-03T13:34:39+01:00" level=info msg="> title: FooBar login: cat.: computer label: SECRET_1 password: my-password-1 + time="2024-11-03T13:34:39+01:00" level=info msg="> title: FooBar login: cat.: computer label: SECRET_2 password: my-password-2 + time="2024-11-03T13:34:39+01:00" level=info msg="> title: Hello login: cat.: computer label: SECRET_3 password: my-password-3 + RESULT + + Open3.stubs(:capture3).returns([ "", stderr_response, OpenStruct.new(success?: true) ]) + + json = JSON.parse(shellunescape(run_command("fetch", "--from", "FooBar", "SECRET_1", "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") + + stderr_response = <<~RESULT + time="2024-11-03T13:34:39+01:00" level=info msg="> title: FooBar login: cat.: computer label: SECRET_1 password: my-password-1 + time="2024-11-03T13:34:39+01:00" level=info msg="> title: FooBar login: cat.: computer label: SECRET_2 password: my-password-2 + time="2024-11-03T13:34:39+01:00" level=info msg="> title: Hello login: cat.: computer label: SECRET_3 password: my-password-3 + time="2024-11-03T13:34:39+01:00" level=info msg="> title: FooBar login: cat.: computer label: password: my-password-3 + RESULT + + Open3.stubs(:capture3).returns([ "", stderr_response, OpenStruct.new(success?: true) ]) + + 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", + "--account", "vault-path" ] + end + end +end