Compare commits

...

1 Commits

Author SHA1 Message Date
Donal McBreen
ff24fd9874 Escape secrets in inline command substitution
Kamal "inlines" calls to `kamal secrets` in the dotenv file, but the
results of the calls were not being escaped properly. To "fix" this
`kamal secrets fetch` escaped the JSON string before returning it.

The two errors cancelled out, but it meant that the commands didn't
work from a shell.

To fix, we'll escape the inline command results and remove the escaping
from `kamal secrets fetch`.
2024-09-30 10:45:11 +01:00
8 changed files with 22 additions and 20 deletions

View File

@@ -7,7 +7,7 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base
def fetch(*secrets) def fetch(*secrets)
results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :from).symbolize_keys) results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :from).symbolize_keys)
return_or_puts JSON.dump(results).shellescape, inline: options[:inline] return_or_puts JSON.dump(results), inline: options[:inline]
end end
desc "extract", "Extract a single secret from the results of a fetch call" desc "extract", "Extract a single secret from the results of a fetch call"

View File

@@ -16,7 +16,7 @@ class Kamal::Secrets::Dotenv::InlineCommandSubstitution
else else
if command =~ /\A\s*kamal\s*secrets\s+/ if command =~ /\A\s*kamal\s*secrets\s+/
# Inline the command # Inline the command
inline_secrets_command(command) inline_secrets_command(command).shellescape
else else
# Execute the command and return the value # Execute the command and return the value
`#{command}`.chomp `#{command}`.chomp

View File

@@ -3,7 +3,7 @@ require_relative "cli_test_case"
class CliSecretsTest < CliTestCase class CliSecretsTest < CliTestCase
test "fetch" do test "fetch" do
assert_equal \ assert_equal \
"\\{\\\"foo\\\":\\\"oof\\\",\\\"bar\\\":\\\"rab\\\",\\\"baz\\\":\\\"zab\\\"\\}", "{\"foo\":\"oof\",\"bar\":\"rab\",\"baz\":\"zab\"}",
run_command("fetch", "foo", "bar", "baz", "--account", "myaccount", "--adapter", "test") run_command("fetch", "foo", "bar", "baz", "--account", "myaccount", "--adapter", "test")
end end

View File

@@ -6,7 +6,7 @@ class BitwardenAdapterTest < SecretAdapterTestCase
stub_ticks.with("bw sync").returns("") stub_ticks.with("bw sync").returns("")
stub_mypassword stub_mypassword
json = JSON.parse(shellunescape(run_command("fetch", "mypassword"))) json = JSON.parse(run_command("fetch", "mypassword"))
expected_json = { "mypassword"=>"secret123" } expected_json = { "mypassword"=>"secret123" }
@@ -18,7 +18,7 @@ class BitwardenAdapterTest < SecretAdapterTestCase
stub_ticks.with("bw sync").returns("") stub_ticks.with("bw sync").returns("")
stub_myitem stub_myitem
json = JSON.parse(shellunescape(run_command("fetch", "--from", "myitem", "field1", "field2", "field3"))) json = JSON.parse(run_command("fetch", "--from", "myitem", "field1", "field2", "field3"))
expected_json = { expected_json = {
"myitem/field1"=>"secret1", "myitem/field2"=>"blam", "myitem/field3"=>"fewgrwjgk" "myitem/field1"=>"secret1", "myitem/field2"=>"blam", "myitem/field3"=>"fewgrwjgk"
@@ -59,7 +59,7 @@ class BitwardenAdapterTest < SecretAdapterTestCase
JSON JSON
json = JSON.parse(shellunescape(run_command("fetch", "mypassword", "myitem/field1", "myitem/field2", "myitem2/field3"))) json = JSON.parse(run_command("fetch", "mypassword", "myitem/field1", "myitem/field2", "myitem2/field3"))
expected_json = { expected_json = {
"mypassword"=>"secret123", "myitem/field1"=>"secret1", "myitem/field2"=>"blam", "myitem2/field3"=>"fewgrwjgk" "mypassword"=>"secret123", "myitem/field1"=>"secret1", "myitem/field2"=>"blam", "myitem2/field3"=>"fewgrwjgk"
@@ -82,7 +82,7 @@ class BitwardenAdapterTest < SecretAdapterTestCase
stub_ticks.with("bw sync").returns("") stub_ticks.with("bw sync").returns("")
stub_mypassword stub_mypassword
json = JSON.parse(shellunescape(run_command("fetch", "mypassword"))) json = JSON.parse(run_command("fetch", "mypassword"))
expected_json = { "mypassword"=>"secret123" } expected_json = { "mypassword"=>"secret123" }
@@ -107,7 +107,7 @@ class BitwardenAdapterTest < SecretAdapterTestCase
stub_ticks.with("bw sync").returns("") stub_ticks.with("bw sync").returns("")
stub_mypassword stub_mypassword
json = JSON.parse(shellunescape(run_command("fetch", "mypassword"))) json = JSON.parse(run_command("fetch", "mypassword"))
expected_json = { "mypassword"=>"secret123" } expected_json = { "mypassword"=>"secret123" }
@@ -132,7 +132,7 @@ class BitwardenAdapterTest < SecretAdapterTestCase
stub_ticks.with("BW_SESSION=0987654321 bw sync").returns("") stub_ticks.with("BW_SESSION=0987654321 bw sync").returns("")
stub_mypassword(session: "0987654321") stub_mypassword(session: "0987654321")
json = JSON.parse(shellunescape(run_command("fetch", "mypassword"))) json = JSON.parse(run_command("fetch", "mypassword"))
expected_json = { "mypassword"=>"secret123" } expected_json = { "mypassword"=>"secret123" }

View File

@@ -12,4 +12,10 @@ class SecretsInlineCommandSubstitution < SecretAdapterTestCase
substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call("FOO=$(blah)", nil, overwrite: false) substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call("FOO=$(blah)", nil, overwrite: false)
assert_equal "FOO=results", substituted assert_equal "FOO=results", substituted
end end
test "escapes correctly" do
Kamal::Cli::Main.expects(:start).with { |command| command == [ "secrets", "fetch", "...", "--inline" ] }.returns("{ \"foo\" : \"bar\" }")
substituted = Kamal::Secrets::Dotenv::InlineCommandSubstitution.call("SECRETS=$(kamal secrets fetch ...)", nil, overwrite: false)
assert_equal "SECRETS=\\{\\ \\\"foo\\\"\\ :\\ \\\"bar\\\"\\ \\}", substituted
end
end end

View File

@@ -51,7 +51,7 @@ class LastPassAdapterTest < SecretAdapterTestCase
] ]
JSON JSON
json = JSON.parse(shellunescape(run_command("fetch", "SECRET1", "FOLDER1/FSECRET1", "FOLDER1/FSECRET2"))) json = JSON.parse(run_command("fetch", "SECRET1", "FOLDER1/FSECRET1", "FOLDER1/FSECRET2"))
expected_json = { expected_json = {
"SECRET1"=>"secret1", "SECRET1"=>"secret1",
@@ -96,7 +96,7 @@ class LastPassAdapterTest < SecretAdapterTestCase
] ]
JSON JSON
json = JSON.parse(shellunescape(run_command("fetch", "--from", "FOLDER1", "FSECRET1", "FSECRET2"))) json = JSON.parse(run_command("fetch", "--from", "FOLDER1", "FSECRET1", "FSECRET2"))
expected_json = { expected_json = {
"FOLDER1/FSECRET1"=>"fsecret1", "FOLDER1/FSECRET1"=>"fsecret1",
@@ -111,7 +111,7 @@ class LastPassAdapterTest < SecretAdapterTestCase
stub_ticks_with("lpass login email@example.com", succeed: true).returns("") stub_ticks_with("lpass login email@example.com", succeed: true).returns("")
stub_ticks.with("lpass show SECRET1 --json").returns(single_item_json) stub_ticks.with("lpass show SECRET1 --json").returns(single_item_json)
json = JSON.parse(shellunescape(run_command("fetch", "SECRET1"))) json = JSON.parse(run_command("fetch", "SECRET1"))
expected_json = { expected_json = {
"SECRET1"=>"secret1" "SECRET1"=>"secret1"

View File

@@ -44,7 +44,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
] ]
JSON JSON
json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1", "section/SECRET2", "section2/SECRET3"))) json = JSON.parse(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1", "section/SECRET2", "section2/SECRET3"))
expected_json = { expected_json = {
"myvault/myitem/section/SECRET1"=>"VALUE1", "myvault/myitem/section/SECRET1"=>"VALUE1",
@@ -103,7 +103,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
} }
JSON JSON
json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault", "myitem/section/SECRET1", "myitem/section/SECRET2", "myitem2/section2/SECRET3"))) json = JSON.parse(run_command("fetch", "--from", "op://myvault", "myitem/section/SECRET1", "myitem/section/SECRET2", "myitem2/section2/SECRET3"))
expected_json = { expected_json = {
"myvault/myitem/section/SECRET1"=>"VALUE1", "myvault/myitem/section/SECRET1"=>"VALUE1",
@@ -122,7 +122,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
.with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1\" --format \"json\" --account \"myaccount\"") .with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1\" --format \"json\" --account \"myaccount\"")
.returns(single_item_json) .returns(single_item_json)
json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1"))) json = JSON.parse(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1"))
expected_json = { expected_json = {
"myvault/myitem/section/SECRET1"=>"VALUE1" "myvault/myitem/section/SECRET1"=>"VALUE1"
@@ -139,7 +139,7 @@ class SecretsOnePasswordAdapterTest < SecretAdapterTestCase
.with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1\" --format \"json\" --account \"myaccount\" --session \"1234567890\"") .with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1\" --format \"json\" --account \"myaccount\" --session \"1234567890\"")
.returns(single_item_json) .returns(single_item_json)
json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1"))) json = JSON.parse(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1"))
expected_json = { expected_json = {
"myvault/myitem/section/SECRET1"=>"VALUE1" "myvault/myitem/section/SECRET1"=>"VALUE1"

View File

@@ -86,8 +86,4 @@ class SecretAdapterTestCase < ActiveSupport::TestCase
stub_ticks.with { |c| c == command && (succeed ? `true` : `false`) } stub_ticks.with { |c| c == command && (succeed ? `true` : `false`) }
Kamal::Secrets::Adapters::Base.any_instance.stubs(:`) Kamal::Secrets::Adapters::Base.any_instance.stubs(:`)
end end
def shellunescape(string)
"\"#{string}\"".undump.gsub(/\\([{}])/, "\\1")
end
end end