sanctum 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +49 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/Rakefile +6 -0
- data/bin/console +10 -0
- data/bin/sanctum +3 -0
- data/bin/setup +6 -0
- data/lib/sanctum.rb +1 -0
- data/lib/sanctum/cli.rb +109 -0
- data/lib/sanctum/colorize_string.rb +44 -0
- data/lib/sanctum/command.rb +16 -0
- data/lib/sanctum/command/base.rb +30 -0
- data/lib/sanctum/command/check.rb +29 -0
- data/lib/sanctum/command/config.rb +34 -0
- data/lib/sanctum/command/create.rb +62 -0
- data/lib/sanctum/command/diff_helper.rb +70 -0
- data/lib/sanctum/command/edit.rb +63 -0
- data/lib/sanctum/command/editor_helper.rb +58 -0
- data/lib/sanctum/command/paths_helper.rb +64 -0
- data/lib/sanctum/command/pull.rb +81 -0
- data/lib/sanctum/command/push.rb +98 -0
- data/lib/sanctum/command/sanctum.example.yaml +33 -0
- data/lib/sanctum/command/view.rb +21 -0
- data/lib/sanctum/get_config.rb +1 -0
- data/lib/sanctum/get_config/config_file.rb +46 -0
- data/lib/sanctum/get_config/config_merge.rb +60 -0
- data/lib/sanctum/get_config/env.rb +18 -0
- data/lib/sanctum/get_config/hash_merger.rb +12 -0
- data/lib/sanctum/get_config/options.rb +41 -0
- data/lib/sanctum/vault_client.rb +31 -0
- data/lib/sanctum/vault_secrets.rb +46 -0
- data/lib/sanctum/vault_transit.rb +60 -0
- data/lib/sanctum/version.rb +3 -0
- data/sanctum.gemspec +32 -0
- metadata +180 -0
@@ -0,0 +1,29 @@
|
|
1
|
+
module Sanctum
|
2
|
+
module Command
|
3
|
+
class Check < Base
|
4
|
+
|
5
|
+
def run
|
6
|
+
targets.each do |target|
|
7
|
+
# Recursively get local files for each prefix specified in sanctum.yaml
|
8
|
+
local_paths = get_local_paths(File.join(File.dirname(config_file), target[:path]))
|
9
|
+
# Read each file
|
10
|
+
local_secrets = read_local_files(local_paths)
|
11
|
+
# Decrypt each secret
|
12
|
+
local_secrets = VaultTransit.decrypt(vault_client, local_secrets, transit_key)
|
13
|
+
|
14
|
+
# Recursively get vault secrets for each prefix specified in sanctum.yaml
|
15
|
+
secrets_list = VaultSecrets.new(vault_client, target[:prefix]).get
|
16
|
+
|
17
|
+
# Only one entry in this hash (which will be the target).
|
18
|
+
tree = secrets_list.values.first
|
19
|
+
# Build local paths based on prefix and paths specified in sanctum.yaml
|
20
|
+
vault_secrets = build_path(tree, [target[:path]])
|
21
|
+
# Join the path array to create a path
|
22
|
+
vault_secrets = join_path(vault_secrets, config_file)
|
23
|
+
compare_secrets(vault_secrets, local_secrets, target[:name])
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
|
3
|
+
module Sanctum
|
4
|
+
module Command
|
5
|
+
# Intentionally not extending Base
|
6
|
+
# This command creates an example config
|
7
|
+
class Config
|
8
|
+
include Colorizer
|
9
|
+
attr_reader :config_path, :example_file
|
10
|
+
|
11
|
+
def initialize(options={}, _args=[])
|
12
|
+
options = {working_dir: Dir.pwd}.merge(options)
|
13
|
+
|
14
|
+
relative_path = File.expand_path File.dirname(__FILE__)
|
15
|
+
@config_path = "#{options[:working_dir]}/sanctum.yaml"
|
16
|
+
@example_file = "#{relative_path}/sanctum.example.yaml"
|
17
|
+
end
|
18
|
+
|
19
|
+
def run
|
20
|
+
raise yellow("config file already exists") if config_exist?
|
21
|
+
create_config_file
|
22
|
+
end
|
23
|
+
|
24
|
+
def config_exist?
|
25
|
+
File.file?(config_path)
|
26
|
+
end
|
27
|
+
|
28
|
+
def create_config_file
|
29
|
+
FileUtils.cp(example_file, config_path)
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'json'
|
3
|
+
require 'pathname'
|
4
|
+
require 'tempfile'
|
5
|
+
require 'yaml'
|
6
|
+
|
7
|
+
module Sanctum
|
8
|
+
module Command
|
9
|
+
class Create < Base
|
10
|
+
|
11
|
+
def run(&block)
|
12
|
+
if args.one?
|
13
|
+
path = args[0]
|
14
|
+
validate_path(path)
|
15
|
+
create_file(path, &block)
|
16
|
+
else
|
17
|
+
raise ArgumentError, red('Please pass only one path argument')
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
def create_file(path)
|
23
|
+
# Calling vault_client will help prevent a race condition where the token is expired
|
24
|
+
# and contents fail to encrypt
|
25
|
+
vault_client
|
26
|
+
tmp_file = Tempfile.new(File.basename(path))
|
27
|
+
|
28
|
+
begin
|
29
|
+
if block_given?
|
30
|
+
yield tmp_file
|
31
|
+
else
|
32
|
+
editor = ENV.fetch('EDITOR', 'vi')
|
33
|
+
raise red("Error with editor") unless system(editor, tmp_file.path)
|
34
|
+
end
|
35
|
+
|
36
|
+
contents = File.read(tmp_file.path)
|
37
|
+
data_hash = {"#{tmp_file.path}" => validate(contents)}
|
38
|
+
write_encrypted_data(vault_client, data_hash, transit_key)
|
39
|
+
tmp_file.close
|
40
|
+
|
41
|
+
FileUtils.cp(tmp_file.path, path)
|
42
|
+
rescue Exception => e
|
43
|
+
# If write_encrypted_data failed, data would fail to write to disk
|
44
|
+
# It would be sad to lose that data, at least this would print the contents to the console.
|
45
|
+
puts red("Contents may have failed to write\nError: #{e}")
|
46
|
+
puts yellow("Contents: \n#{contents}")
|
47
|
+
ensure
|
48
|
+
tmp_file.close
|
49
|
+
secure_erase(tmp_file.path, tmp_file.length)
|
50
|
+
tmp_file.unlink
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def validate_path(path)
|
55
|
+
path = Pathname.new(path)
|
56
|
+
raise yellow("File exists, use edit command") if path.exist?
|
57
|
+
path.dirname.mkpath unless path.dirname.exist?
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'hashdiff'
|
2
|
+
|
3
|
+
module Sanctum
|
4
|
+
module Command
|
5
|
+
module DiffHelper
|
6
|
+
|
7
|
+
def hash_diff(first_hash, second_hash)
|
8
|
+
differences = HashDiff.best_diff(first_hash, second_hash, delimiter: " => ", array_path: true)
|
9
|
+
|
10
|
+
differences.each do |diff|
|
11
|
+
if diff[0] == "+"
|
12
|
+
puts green("#{diff[0].to_s + diff[1].join(" => ").to_s} => #{diff[2]}")
|
13
|
+
else
|
14
|
+
puts red("#{diff[0].to_s + diff[1].join(" => ").to_s} => #{diff[2]}")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
differences
|
18
|
+
end
|
19
|
+
|
20
|
+
def compare_secrets(vault_secrets, local_secrets, name, direction="both")
|
21
|
+
if vault_secrets == local_secrets
|
22
|
+
warn yellow("Target #{name}: contains no differences")
|
23
|
+
else
|
24
|
+
case direction
|
25
|
+
when "pull"
|
26
|
+
puts yellow("Target #{name}: differences pulling from vault")
|
27
|
+
hash_diff(local_secrets, vault_secrets)
|
28
|
+
when "push"
|
29
|
+
puts yellow("Target #{name}: differences pushing to vault")
|
30
|
+
hash_diff(vault_secrets, local_secrets)
|
31
|
+
when "both"
|
32
|
+
puts yellow("Target #{name}: differences pulling from vault")
|
33
|
+
hash_diff(local_secrets, vault_secrets)
|
34
|
+
|
35
|
+
puts yellow("Target #{name}: differences pushing to vault")
|
36
|
+
hash_diff(vault_secrets, local_secrets)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def confirmed_with_user?
|
42
|
+
puts yellow("\nWould you like to continue?: ")
|
43
|
+
question = STDIN.gets.chomp.upcase
|
44
|
+
|
45
|
+
if ["Y", "YES"].include? question
|
46
|
+
puts yellow("Overwriting differences")
|
47
|
+
true
|
48
|
+
else
|
49
|
+
warn yellow("Skipping....\n")
|
50
|
+
false
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Array is a unique list of paths built from the differences of hash1 and hash2.
|
55
|
+
# See diff_paths variable in push or pull command
|
56
|
+
# Hash will be all local, or vault secrets.
|
57
|
+
# We then build a new hash that contains only the k,v needed to sync (to or from vault)
|
58
|
+
def only_changes(array, hash)
|
59
|
+
tmp_hash = Hash.new
|
60
|
+
array.each do |a|
|
61
|
+
hash.each do |k, v|
|
62
|
+
tmp_hash[k] = v if a == k
|
63
|
+
end
|
64
|
+
end
|
65
|
+
tmp_hash
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'tempfile'
|
3
|
+
require 'yaml'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module Sanctum
|
7
|
+
module Command
|
8
|
+
class Edit < Base
|
9
|
+
|
10
|
+
def run(&block)
|
11
|
+
if args.one?
|
12
|
+
path = args[0]
|
13
|
+
edit_file(path, &block)
|
14
|
+
else
|
15
|
+
raise ArgumentError, red('Please pass only one path argument')
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
def edit_file(path)
|
21
|
+
tmp_file = Tempfile.new(File.basename(path))
|
22
|
+
|
23
|
+
begin
|
24
|
+
encrypted_data_hash = {path => File.read(path)}
|
25
|
+
decrypted_data_hash = decrypt_data(vault_client, encrypted_data_hash, transit_key)
|
26
|
+
decrypted_data_hash.each_value do |v|
|
27
|
+
v = v.to_yaml
|
28
|
+
File.write(tmp_file.path, v)
|
29
|
+
end
|
30
|
+
|
31
|
+
if block_given?
|
32
|
+
yield tmp_file
|
33
|
+
else
|
34
|
+
previous_contents = File.read(tmp_file.path)
|
35
|
+
editor = ENV.fetch('EDITOR', 'vi')
|
36
|
+
raise red("Error with editor") unless system(editor, tmp_file.path )
|
37
|
+
end
|
38
|
+
contents = File.read(tmp_file.path)
|
39
|
+
|
40
|
+
# Only write contents if something changed
|
41
|
+
unless contents == previous_contents
|
42
|
+
data_hash = {"#{tmp_file.path}" => validate(contents)}
|
43
|
+
write_encrypted_data(vault_client, data_hash, transit_key)
|
44
|
+
tmp_file.close
|
45
|
+
|
46
|
+
FileUtils.cp(tmp_file.path, path)
|
47
|
+
end
|
48
|
+
|
49
|
+
rescue Exception => e
|
50
|
+
# If write_encrypted_data failed, data would fail to write to disk
|
51
|
+
# It would be sad to lose that data, at least this would print the contents to the console.
|
52
|
+
puts red("Contents may have failed to write\nError: #{e}")
|
53
|
+
puts yellow("Contents: \n#{contents}")
|
54
|
+
ensure
|
55
|
+
tmp_file.close
|
56
|
+
secure_erase(tmp_file.path, tmp_file.length)
|
57
|
+
tmp_file.unlink
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
require 'yaml'
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Sanctum
|
6
|
+
module Command
|
7
|
+
module EditorHelper
|
8
|
+
include Colorizer
|
9
|
+
|
10
|
+
def decrypt_data(vault_client, data, transit_key)
|
11
|
+
VaultTransit.decrypt(vault_client, data, transit_key)
|
12
|
+
end
|
13
|
+
|
14
|
+
def write_encrypted_data(vault_client, data, transit_key)
|
15
|
+
VaultTransit.write_to_file(vault_client, data, transit_key)
|
16
|
+
end
|
17
|
+
|
18
|
+
def validate(contents)
|
19
|
+
validate_json(contents) || validate_yaml(contents) || raise
|
20
|
+
rescue
|
21
|
+
fail red("Invalid Contents")
|
22
|
+
end
|
23
|
+
|
24
|
+
def validate_json(json)
|
25
|
+
JSON.parse(json)
|
26
|
+
rescue JSON::ParserError
|
27
|
+
nil
|
28
|
+
end
|
29
|
+
|
30
|
+
def validate_yaml(yaml)
|
31
|
+
YAML.load(yaml)
|
32
|
+
rescue YAML::SyntaxError
|
33
|
+
nil
|
34
|
+
end
|
35
|
+
|
36
|
+
def write_random_data(file, file_len)
|
37
|
+
max_chunk_len = [file_len, (1024 * 1024 * 2)].max
|
38
|
+
|
39
|
+
3.times do
|
40
|
+
random_data = SecureRandom.random_bytes(max_chunk_len)
|
41
|
+
File.write(file, random_data, 0, mode: 'wb')
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def secure_erase(file, file_len)
|
46
|
+
if file_len >= 1
|
47
|
+
begin
|
48
|
+
#Try to use shred if available on system
|
49
|
+
raise red("Failed system shred") unless system("shred", file)
|
50
|
+
rescue
|
51
|
+
write_random_data(file, file_len)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'find'
|
2
|
+
require 'pathname'
|
3
|
+
|
4
|
+
module Sanctum
|
5
|
+
module Command
|
6
|
+
module PathsHelper
|
7
|
+
|
8
|
+
#Helper methods for building, reading and joining paths
|
9
|
+
private
|
10
|
+
def build_path_helper(hash, path = [])
|
11
|
+
if hash.values.any? { |k| !k.is_a?(Hash) }
|
12
|
+
[path, hash]
|
13
|
+
else
|
14
|
+
hash.flat_map do |(key,value)|
|
15
|
+
build_path_helper(value, path+[key])
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
public
|
21
|
+
def build_path(hash, path = [])
|
22
|
+
build_path_helper(hash, path).each_slice(2).to_h
|
23
|
+
end
|
24
|
+
|
25
|
+
def join_path(hash, config_file)
|
26
|
+
config_file = Pathname.new(config_file)
|
27
|
+
tmp_hash = Hash.new
|
28
|
+
|
29
|
+
hash.each do |p, v|
|
30
|
+
p = config_file.dirname + Pathname.new(p.join("/"))
|
31
|
+
tmp_hash["#{p}"] = v
|
32
|
+
end
|
33
|
+
tmp_hash
|
34
|
+
end
|
35
|
+
|
36
|
+
def read_local_files(paths)
|
37
|
+
tmp_hash = Hash.new
|
38
|
+
paths.each do |k,v|
|
39
|
+
if File.file?(k)
|
40
|
+
v = File.read(k)
|
41
|
+
tmp_hash["#{k}"] = v
|
42
|
+
end
|
43
|
+
end
|
44
|
+
tmp_hash
|
45
|
+
end
|
46
|
+
|
47
|
+
def get_local_paths(paths)
|
48
|
+
tmp_array = Array.new
|
49
|
+
Find.find(paths) do |path|
|
50
|
+
if FileTest.file?(path)
|
51
|
+
tmp_array << path
|
52
|
+
if File.basename(path).start_with?(?.)
|
53
|
+
Find.prune
|
54
|
+
else
|
55
|
+
next
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
tmp_array
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
|
3
|
+
module Sanctum
|
4
|
+
module Command
|
5
|
+
class Pull < Base
|
6
|
+
|
7
|
+
def run
|
8
|
+
targets.each do |target|
|
9
|
+
# Use command line if force: true
|
10
|
+
if options[:cli][:force]
|
11
|
+
force = options[:cli][:force]
|
12
|
+
else
|
13
|
+
force = target.fetch(:force) {options[:sanctum][:force]}
|
14
|
+
end
|
15
|
+
|
16
|
+
# Recursively get vault secrets for each prefix specified in sanctum.yaml
|
17
|
+
secrets_list = VaultSecrets.new(vault_client, target[:prefix]).get
|
18
|
+
secrets_list.each do |k,v|
|
19
|
+
|
20
|
+
vault_secrets = build_vault_secrets(v, [target[:path]])
|
21
|
+
local_secrets = build_local_secrets(vault_secrets)
|
22
|
+
|
23
|
+
#Compare secrets, if there are no differences continue to next target
|
24
|
+
differences = compare_secrets(vault_secrets, local_secrets, target[:name], "pull")
|
25
|
+
next if differences.nil?
|
26
|
+
|
27
|
+
#Get uniq array of HashDiff returned paths
|
28
|
+
diff_paths = differences.map{|x| x[1][0]}.uniq
|
29
|
+
|
30
|
+
#Only sync the differences
|
31
|
+
vault_secrets = only_changes(diff_paths, vault_secrets)
|
32
|
+
|
33
|
+
if force
|
34
|
+
# Write files to disk and encrypt with transit
|
35
|
+
warn red("#{target[:name]}: Forcefully writing differences to disk(pull)")
|
36
|
+
VaultTransit.write_to_file(vault_client, vault_secrets, transit_key)
|
37
|
+
else
|
38
|
+
#Confirm with user, and write to local file if approved
|
39
|
+
next unless confirmed_with_user?
|
40
|
+
VaultTransit.write_to_file(vault_client, vault_secrets, transit_key)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
|
47
|
+
def create_paths(paths)
|
48
|
+
paths.each do |k,v|
|
49
|
+
k = Pathname.new(k)
|
50
|
+
unless k.dirname.exist?
|
51
|
+
k.dirname.mkpath
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def build_vault_secrets(tree, path)
|
57
|
+
# Build local paths based on vault_prefix(tree) and paths specified in sanctum.yaml
|
58
|
+
vault_secrets = build_path(tree, path)
|
59
|
+
|
60
|
+
# Join the path array to create a path
|
61
|
+
vault_secrets = join_path(vault_secrets, config_file)
|
62
|
+
|
63
|
+
# Ensure local paths exist, relative to sanctum.yaml if they don't create them
|
64
|
+
create_paths(vault_secrets)
|
65
|
+
vault_secrets
|
66
|
+
end
|
67
|
+
|
68
|
+
def build_local_secrets(vault_secrets)
|
69
|
+
|
70
|
+
# read_local_files uses vault_secrets paths to create a new hash with local paths and values.
|
71
|
+
# This means that we will only compare secrets/paths that exist in both vault and locally.
|
72
|
+
# We will not for example, see differences if a file exists locally but not in vault.
|
73
|
+
local_secrets = read_local_files(vault_secrets)
|
74
|
+
# Decrypt local_secrets
|
75
|
+
local_secrets = VaultTransit.decrypt(vault_client, local_secrets, transit_key)
|
76
|
+
local_secrets
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|