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.
@@ -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