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,98 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Sanctum
|
5
|
+
module Command
|
6
|
+
class Push < Base
|
7
|
+
|
8
|
+
def run
|
9
|
+
targets.each do |target|
|
10
|
+
# Use command line if force: true
|
11
|
+
if options[:cli][:force]
|
12
|
+
force = options[:cli][:force]
|
13
|
+
else
|
14
|
+
force = target.fetch(:force) {options[:sanctum][:force]}
|
15
|
+
end
|
16
|
+
|
17
|
+
# Build array of local paths by recursively searching for local files for each prefix specified in sanctum.yaml
|
18
|
+
local_paths = get_local_paths(File.join(File.dirname(config_file), target[:path]))
|
19
|
+
|
20
|
+
local_secrets = build_local_secrets(local_paths)
|
21
|
+
vault_secrets = build_vault_secrets(local_paths, target[:prefix], target[:path])
|
22
|
+
|
23
|
+
# Compare secrets
|
24
|
+
# vault_secrets paths have been mapped to local_paths to make comparison easier
|
25
|
+
differences = compare_secrets(vault_secrets, local_secrets, target[:name], "push")
|
26
|
+
next if differences.nil?
|
27
|
+
|
28
|
+
# Get uniq array of HashDiff returned paths
|
29
|
+
diff_paths = differences.map{|x| x[1][0]}.uniq
|
30
|
+
|
31
|
+
# Only write changes
|
32
|
+
vault_secrets = only_changes(diff_paths, local_secrets)
|
33
|
+
|
34
|
+
#Convert paths back to vault prefix so we can sync
|
35
|
+
vault_secrets = vault_secrets.map {|k, v| [k.gsub(File.join(File.dirname(config_file), target[:path]), target[:prefix]), v] }.to_h
|
36
|
+
|
37
|
+
if force
|
38
|
+
warn red("#{target[:name]}: Forcefully writing differences to vault(push)")
|
39
|
+
VaultTransit.write_to_vault(vault_client, vault_secrets)
|
40
|
+
else
|
41
|
+
#Confirm with user, and write to local file if approved
|
42
|
+
next unless confirmed_with_user?
|
43
|
+
VaultTransit.write_to_vault(vault_client, vault_secrets)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def read_remote(paths, prefix)
|
49
|
+
tmp_hash = Hash.new
|
50
|
+
paths.each do |k,v|
|
51
|
+
p = File.join(prefix, k)
|
52
|
+
unless vault_client.logical.read(p).nil?
|
53
|
+
v = vault_client.logical.read(p).data
|
54
|
+
tmp_hash["#{k}"] = v
|
55
|
+
else
|
56
|
+
next
|
57
|
+
end
|
58
|
+
end
|
59
|
+
tmp_hash
|
60
|
+
end
|
61
|
+
|
62
|
+
def map_local_path(secrets_hash, local_path)
|
63
|
+
config_path = Pathname.new(config_file)
|
64
|
+
tmp_hash = Hash.new
|
65
|
+
|
66
|
+
secrets_hash.map do |p, v|
|
67
|
+
p = config_path.dirname + Pathname.new(File.join(local_path, p))
|
68
|
+
tmp_hash["#{p}"] = v
|
69
|
+
end
|
70
|
+
tmp_hash
|
71
|
+
end
|
72
|
+
|
73
|
+
def build_local_secrets(local_paths)
|
74
|
+
# Read each local file
|
75
|
+
local_secrets = read_local_files(local_paths)
|
76
|
+
# Decrypt local secrets
|
77
|
+
local_secrets = VaultTransit.decrypt(vault_client, local_secrets, transit_key)
|
78
|
+
end
|
79
|
+
|
80
|
+
def build_vault_secrets(local_paths, target_prefix, target_path)
|
81
|
+
# Map local_paths into vault_paths
|
82
|
+
vault_paths = local_paths.map{|x| x.gsub(File.join(File.dirname(config_file), target_path), "")}
|
83
|
+
|
84
|
+
# Get vault secrets (if they exist) for each vault prefix specified in sanctum.yaml that also maps to a local path
|
85
|
+
# This means that we will only compare secrets/paths that exist both locally and in vault.
|
86
|
+
# We will not for example, see differences if a secret exists in vault but not locally.
|
87
|
+
|
88
|
+
# Read secrets from vault
|
89
|
+
vault_secrets = read_remote(vault_paths, target_prefix)
|
90
|
+
|
91
|
+
# To make comparing a bit easier map vault_secrets paths back local_paths
|
92
|
+
# Convert to json, then read, to make keys strings vs symbols
|
93
|
+
vault_secrets = JSON(map_local_path(vault_secrets, target_path).to_json)
|
94
|
+
end
|
95
|
+
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# sanctum.yml
|
2
|
+
sanctum:
|
3
|
+
# force - defaults to false. Setting to true modifies behavior of push and pull commands.
|
4
|
+
# If true you will not be asked if you want to overwrite changes.
|
5
|
+
#force: false
|
6
|
+
# color - defaults to true. Setting to false will disable color to tty
|
7
|
+
#color: true
|
8
|
+
|
9
|
+
vault:
|
10
|
+
# url - will use `ENV["VAULT_ADDR"]` if available, otherwise defaults to http://localhost:8200
|
11
|
+
#url: http://localhost:8200
|
12
|
+
# token - (required) will use `ENV["VAULT_TOKEN"]` if available, otherwise tries to read from `ENV["HOME"]/.vault-token`
|
13
|
+
#token: aaabbbcc-ddee-ffgg-hhii-jjkkllmmnnoop
|
14
|
+
# transit_key - (required) will use `ENV["SANCTUM_TRANSIT_KEY"]` if available.
|
15
|
+
# Transit key ring used to encrypt/decrypt secrets for local storage.
|
16
|
+
# If you need to use multiple transit_keys you will need to create seperate config files
|
17
|
+
#transit_key: transit/keys/app-foo
|
18
|
+
|
19
|
+
sync:
|
20
|
+
# sync is an array of hashes of sync target configurations
|
21
|
+
# at least one app definition is REQUIRED Fields:
|
22
|
+
# name - (required) Friendly name of the sync target.
|
23
|
+
# prefix - (required) The vault prefix(secret mount) to synchronize to.
|
24
|
+
# path - (required) The relative filesystem path to the directory
|
25
|
+
# containing the files with content to synchronize to Vault.
|
26
|
+
# This path is calculated relative to the directory containing
|
27
|
+
# the configuration file.
|
28
|
+
# force - Whether or not to force push, pull actions (no user input)
|
29
|
+
# This inherits the setting from the `sanctum` section.
|
30
|
+
#- name: app-foo
|
31
|
+
#prefix: secrets/app-foo
|
32
|
+
#path: vault/app-foo
|
33
|
+
#force: false
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Sanctum
|
4
|
+
module Command
|
5
|
+
class View < Base
|
6
|
+
|
7
|
+
def run(command="less")
|
8
|
+
raise ArgumentError, red('Please provide at least one path') if args.empty?
|
9
|
+
|
10
|
+
local_secrets = read_local_files(args)
|
11
|
+
local_secrets = VaultTransit.decrypt(vault_client, local_secrets, transit_key)
|
12
|
+
begin
|
13
|
+
IO.popen(command, "w") { |f| f.puts "#{local_secrets.to_yaml}" }
|
14
|
+
rescue
|
15
|
+
puts light_blue(local_secrets.to_yaml)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
require 'sanctum/get_config/config_merge'
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Sanctum
|
4
|
+
module GetConfig
|
5
|
+
class ConfigFile
|
6
|
+
attr_reader :config_file
|
7
|
+
|
8
|
+
def initialize(config_file=nil)
|
9
|
+
raise "Please create or specify a config file. `sanctum config --help`" if config_file.nil?
|
10
|
+
raise "Config file not found" unless File.file?(config_file)
|
11
|
+
@config_file = config_file
|
12
|
+
end
|
13
|
+
|
14
|
+
def run
|
15
|
+
config_hash = load_config_file(config_file)
|
16
|
+
if config_hash.empty? || config_hash[:sync].nil?
|
17
|
+
raise "Please specify at least one sync target in your config file: #{config_file}"
|
18
|
+
else
|
19
|
+
config_hash
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def load_config_file(config_file)
|
24
|
+
config_hash = YAML.load_file(config_file)
|
25
|
+
config_hash.compact!
|
26
|
+
deep_symbolize(config_hash)
|
27
|
+
rescue
|
28
|
+
raise "Please ensure your config file is formatted correctly. `sanctum config --help`"
|
29
|
+
end
|
30
|
+
|
31
|
+
def deep_symbolize(obj)
|
32
|
+
case obj
|
33
|
+
when Hash
|
34
|
+
obj.each_with_object({}) do |(k, v), hash|
|
35
|
+
hash[k.to_sym] = deep_symbolize(v)
|
36
|
+
end
|
37
|
+
when Array
|
38
|
+
obj.map { |el| deep_symbolize(el) }
|
39
|
+
else
|
40
|
+
obj
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'sanctum/get_config/config_file'
|
2
|
+
require 'sanctum/get_config/env'
|
3
|
+
require 'sanctum/get_config/hash_merger'
|
4
|
+
require 'sanctum/get_config/options'
|
5
|
+
|
6
|
+
|
7
|
+
module Sanctum
|
8
|
+
module GetConfig
|
9
|
+
class ConfigMerge
|
10
|
+
|
11
|
+
using HashMerger
|
12
|
+
attr_reader :config_file, :targets, :force
|
13
|
+
|
14
|
+
def initialize(config_file: , targets: , force: )
|
15
|
+
@config_file = config_file
|
16
|
+
@targets = targets.split(/\,/).map(&:strip) unless targets.nil?
|
17
|
+
@force = force
|
18
|
+
end
|
19
|
+
|
20
|
+
def final_options
|
21
|
+
# default_options will search for config_file or take the path specified via cli
|
22
|
+
default_options = DefaultOptions.new(config_file).run
|
23
|
+
config_options = ConfigFile.new(default_options[:config_file]).run
|
24
|
+
env_options = Env.new.run
|
25
|
+
cli_options = {cli: {targets: targets, force: force }}
|
26
|
+
|
27
|
+
# Check that targets specified via commandline actually exist in config_file and update config_options[:sync] array
|
28
|
+
config_options = check_targets(targets, config_options) unless targets.nil?
|
29
|
+
|
30
|
+
merge_options(default_options, config_options, env_options, cli_options)
|
31
|
+
end
|
32
|
+
|
33
|
+
|
34
|
+
def merge_options(default_options, config_options, env_options, cli_options)
|
35
|
+
default_options.deep_merge(config_options).deep_merge(env_options).deep_merge(cli_options)
|
36
|
+
end
|
37
|
+
|
38
|
+
def check_targets(targets, config_options)
|
39
|
+
tmp_array = Array.new
|
40
|
+
sync = config_options[:sync]
|
41
|
+
|
42
|
+
targets.each do |t|
|
43
|
+
sync.each do |h|
|
44
|
+
tmp_array << h if h[:name] == t
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
if tmp_array.empty?
|
49
|
+
valid_targets = sync.map{|h| h[:name]}
|
50
|
+
raise "Please specify at least one valid target\n Valid targets are #{valid_targets}"
|
51
|
+
end
|
52
|
+
|
53
|
+
config_options[:sync] = tmp_array
|
54
|
+
config_options
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Sanctum
|
2
|
+
module GetConfig
|
3
|
+
class Env
|
4
|
+
attr_reader :env_options
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@env_options = {vault: {url: ENV["VAULT_ADDR"], token: ENV["VAULT_TOKEN"], transit_key: ENV["SANCTUM_TRANSIT_KEY"]}}
|
8
|
+
end
|
9
|
+
|
10
|
+
def run
|
11
|
+
env_options.each do |key, value|
|
12
|
+
value.compact!
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module Sanctum
|
2
|
+
module GetConfig
|
3
|
+
module HashMerger
|
4
|
+
refine ::Hash do
|
5
|
+
def deep_merge(second)
|
6
|
+
merger = proc { |key, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : Array === v1 && Array === v2 ? v1 | v2 : [:undefined, nil, :nil].include?(v2) ? v1 : v2 }
|
7
|
+
self.merge(second.to_h, &merger)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'etc'
|
2
|
+
require 'pathname'
|
3
|
+
|
4
|
+
module Sanctum
|
5
|
+
module GetConfig
|
6
|
+
class DefaultOptions
|
7
|
+
attr_reader :config_file
|
8
|
+
|
9
|
+
def initialize(config_file=nil)
|
10
|
+
@config_file = config_file
|
11
|
+
end
|
12
|
+
|
13
|
+
def run
|
14
|
+
{
|
15
|
+
config_file: config_file.nil? ? config_file_search : config_file,
|
16
|
+
sanctum: { force: false, color: true },
|
17
|
+
vault: { url: "https://127.0.0.1:8200", token: get_vault_token }
|
18
|
+
}
|
19
|
+
end
|
20
|
+
|
21
|
+
def config_file_search
|
22
|
+
path = Pathname.new(Dir.pwd)
|
23
|
+
path.ascend do |p|
|
24
|
+
if File.file?("#{p}/sanctum.yaml")
|
25
|
+
return "#{p}/sanctum.yaml"
|
26
|
+
else
|
27
|
+
next
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def get_vault_token
|
33
|
+
token_file = "#{Dir.home}/.vault-token"
|
34
|
+
if File.file?("#{token_file}") && File.readable?("#{token_file}")
|
35
|
+
File.read("#{token_file}")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'vault'
|
2
|
+
|
3
|
+
module Sanctum
|
4
|
+
class VaultClient
|
5
|
+
|
6
|
+
def self.build(vault_address, vault_token)
|
7
|
+
vault_client = Vault::Client.new(address: vault_address, token: vault_token)
|
8
|
+
check_token(vault_client)
|
9
|
+
vault_client
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.check_token(vault_client)
|
13
|
+
response = vault_client.request(:get, "v1/auth/token/lookup-self")
|
14
|
+
renewable = response[:data][:renewable]
|
15
|
+
|
16
|
+
if renewable
|
17
|
+
creation_ttl = response[:data][:creation_ttl]
|
18
|
+
remaining = response[:data][:ttl]
|
19
|
+
fifty_percent = (creation_ttl * 0.50).to_i
|
20
|
+
|
21
|
+
renew_token(vault_client, creation_ttl) if remaining < fifty_percent
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.renew_token(vault_client, increment)
|
26
|
+
payload = {"increment": increment}.to_json
|
27
|
+
vault_client.request(:post, "v1/auth/token/renew-self", payload)
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module Sanctum
|
2
|
+
class VaultSecrets
|
3
|
+
include Colorizer
|
4
|
+
attr_reader :vault_client, :prefix
|
5
|
+
|
6
|
+
def initialize(vault_client, prefix)
|
7
|
+
@vault_client = vault_client
|
8
|
+
@prefix = prefix
|
9
|
+
end
|
10
|
+
|
11
|
+
def get
|
12
|
+
if invalid_prefix?
|
13
|
+
raise yellow("Warning: Vault prefix: '#{prefix}' does not exist.. ")
|
14
|
+
end
|
15
|
+
|
16
|
+
secrets_from_vault = Hash.new
|
17
|
+
secrets_from_vault[prefix] = JSON(list_recursive(prefix).to_json)
|
18
|
+
secrets_from_vault
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
def list_recursive(prefix, parent = '')
|
23
|
+
me = File.join(parent, prefix)
|
24
|
+
result = vault_client.logical.list(me).inject({}) do |hash, item|
|
25
|
+
case item
|
26
|
+
when /.*\/$/
|
27
|
+
hash[item.gsub(/\/$/, '').to_sym] = list_recursive(item, me)
|
28
|
+
else
|
29
|
+
hash[item.to_sym] = read_data(item, me)
|
30
|
+
end
|
31
|
+
hash
|
32
|
+
end
|
33
|
+
result
|
34
|
+
end
|
35
|
+
|
36
|
+
def read_data(item, parent = '')
|
37
|
+
me = File.join(parent, item)
|
38
|
+
vault_client.logical.read(me).data
|
39
|
+
end
|
40
|
+
|
41
|
+
def invalid_prefix?
|
42
|
+
vault_client.logical.list(prefix).empty?
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'base64'
|
2
|
+
require 'pathname'
|
3
|
+
|
4
|
+
module Sanctum
|
5
|
+
class VaultTransit
|
6
|
+
include Colorizer
|
7
|
+
|
8
|
+
def self.encrypt(vault_client, secrets, transit_key)
|
9
|
+
transit_key = Pathname.new(transit_key)
|
10
|
+
|
11
|
+
unless transit_key_exist?(vault_client, transit_key)
|
12
|
+
raise red("#{transit_key} does not exist")
|
13
|
+
end
|
14
|
+
|
15
|
+
secrets.each do |k, v|
|
16
|
+
v = encode(v.to_json)
|
17
|
+
#TODO: Fix this....
|
18
|
+
v = vault_client.logical.write("#{transit_key.dirname.to_s.split("/")[0]}/encrypt/#{transit_key.basename}", plaintext: v)
|
19
|
+
secrets[k] = v
|
20
|
+
end
|
21
|
+
secrets
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.decrypt(vault_client, secrets, transit_key)
|
25
|
+
transit_key = Pathname.new(transit_key)
|
26
|
+
secrets.each do |k, v|
|
27
|
+
v = vault_client.logical.write("#{transit_key.dirname.to_s.split("/")[0]}/decrypt/#{transit_key.basename}", ciphertext: v)
|
28
|
+
v = JSON(decode(v.data[:plaintext]))
|
29
|
+
secrets[k] = v
|
30
|
+
end
|
31
|
+
secrets
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.write_to_file(vault_client, secrets, transit_key)
|
35
|
+
secrets = encrypt(vault_client, secrets, transit_key)
|
36
|
+
secrets.each do |k, v|
|
37
|
+
File.write(k, v.data[:ciphertext])
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.write_to_vault(vault_client, secrets)
|
42
|
+
secrets.each do |k, v|
|
43
|
+
vault_client.logical.write(k, v)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.encode(string)
|
48
|
+
Base64.encode64(string)
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.decode(string)
|
52
|
+
Base64.decode64(string)
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.transit_key_exist?(vault_client, transit_key)
|
56
|
+
!vault_client.logical.read(transit_key.to_path).nil?
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
end
|