sanctum 0.8.0

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