sanctum 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|