finishing up some of the mount parsing. it now successfully builds a dictionary map at least. docs updated and print formatter done.

This commit is contained in:
brent s 2020-03-31 16:16:56 -04:00
parent 58accf8c7e
commit 236af1ea37
Signed by: bts
GPG Key ID: 8C004C2F93481F6B
5 changed files with 189 additions and 61 deletions

View File

@ -58,6 +58,7 @@ One of either:footnote:optelem[]
one of either: one of either:
.... `auth` (see <<Auth>> section below), or .... `auth` (see <<Auth>> section below), or
.... `authGpg`, an <<Auth>> config snippet encrypted with GPG. See the section on <<GPG-Encrypted Elements>>. .... `authGpg`, an <<Auth>> config snippet encrypted with GPG. See the section on <<GPG-Encrypted Elements>>.
... An optional `mounts` container.footnote:optelem[] See the section on <<Mounts>>.


Let's look at an example configuration. Let's look at an example configuration.


@ -78,6 +79,11 @@ Let's look at an example configuration.
<auth> <auth>
<token/> <token/>
</auth> </auth>
<mounts>
<mount type="kv1">secret_legacy</mount>
<mount type="kv2">secret</mount>
<mount type="cubbyhole">cubbyhole</mount>
</mounts>


</vaultpass> </vaultpass>
---- ----
@ -85,7 +91,7 @@ Let's look at an example configuration.
In the above, we can see that it would use the vault server at `http://localhost:8200/` using whatever token is either In the above, we can see that it would use the vault server at `http://localhost:8200/` using whatever token is either
in the **`VAULT_TOKEN`** environment variable or, if empty, the `~/.vault-token` file. Because an unseal shard was in the **`VAULT_TOKEN`** environment variable or, if empty, the `~/.vault-token` file. Because an unseal shard was
provided, it will be able to attempt to automatically unseal the Vault (assuming its shard will complete the threshold provided, it will be able to attempt to automatically unseal the Vault (assuming its shard will complete the threshold
needed). needed). Because we specify mounts, we do not need permissions in Vault to list `/sys/mounts`.


=== Auth === Auth
Vault itself supports a https://www.vaultproject.io/docs/auth/[large number of authentication methods^]. However, in Vault itself supports a https://www.vaultproject.io/docs/auth/[large number of authentication methods^]. However, in
@ -221,6 +227,25 @@ If not specified, the default is `userpass`.
<!-- SNIP --> <!-- SNIP -->
---- ----


=== Mounts
VaultPass has the ability to automatically detect (some) mounts and their paths.

So why, then, should you specify them in the configuration file? Simple: because you might not have permission to list
them! Even if you can see the mounts in the web UI that you have permission to, that **doesn't guarantee** that they're
accessible/viewable https://www.vaultproject.io/api-docs/[via the API^] (which is how VaultPass, and even the upstream
Vault binary client, operates). So by specifying them in the configuration file, you're able to "bootstrap" the process.

The optional `mounts` footnote:optelem[] container contains one or more `mount` child elements, with the name of the
mountpoint as the content.

Each `mount` element has one optional attribute, `type` footnote:optelem[], which can be one of:

* https://www.vaultproject.io/docs/secrets/cubbyhole/[`cubbyhole`^]
* https://www.vaultproject.io/docs/secrets/kv/kv-v1/[`kv1`^]
* https://www.vaultproject.io/docs/secrets/kv/kv-v2/[`kv2`^] _(this is the default if not specified)_

https://www.vaultproject.io/docs/secrets/[More mount types^] may be added upon popular demand and technical feasability.

=== GPG-Encrypted Elements === GPG-Encrypted Elements
Understandably, in order to have a persistent configuration, that means storing on disk. That also means that they need Understandably, in order to have a persistent configuration, that means storing on disk. That also means that they need
to be able to be accessed with no or minimal user interruption. Pass used GPG natively, so it didn't have an issue with to be able to be accessed with no or minimal user interruption. Pass used GPG natively, so it didn't have an issue with
@ -257,7 +282,7 @@ Let's look at an example of GPG-encrypted elements.


==== GPG-Encrypted Elements Example ==== GPG-Encrypted Elements Example


.`~/.config/vaultpass.xml` snippet: .`~/.config/vaultpass.xml`:
[source,xml] [source,xml]
---- ----
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>

View File

@ -14,8 +14,8 @@
be able to iterate through available mounts if not. --> be able to iterate through available mounts if not. -->
<!-- Default type if not specified is kv2. --> <!-- Default type if not specified is kv2. -->
<mounts> <mounts>
<mount type="kv">secrets_legacy</mount> <mount type="kv1">secret_legacy</mount>
<mount type="kv2">secrets</mount> <mount type="kv2">secret</mount>
<mount type="cubbyhole">cubbyhole</mount> <mount type="cubbyhole">cubbyhole</mount>
</mounts> </mounts>
</vaultpass> </vaultpass>

View File

@ -41,7 +41,7 @@ class PassMan(object):
return(None) return(None)


def _getMount(self): def _getMount(self):
mounts_xml = self.xml.find('.//mounts') mounts_xml = self.cfg.xml.find('.//mounts')
self.mount = mounts.MountHandler(self.client, mounts_xml = mounts_xml) self.mount = mounts.MountHandler(self.client, mounts_xml = mounts_xml)
return(None) return(None)



View File

@ -2,7 +2,7 @@ import logging
import re import re
import warnings import warnings
## ##
import dpath # https://pypi.org/project/dpath/ import dpath.util # https://pypi.org/project/dpath/
import hvac.exceptions import hvac.exceptions




@ -23,10 +23,11 @@ class CubbyHandler(object):
resp = self.client._adapter.list(url = uri) resp = self.client._adapter.list(url = uri)
return(resp.json()) return(resp.json())


def read_secret(self, *args, **kwargs): def read_secret(self, path, mount_point = 'cubbyhole'):
# https://github.com/hashicorp/vault/issues/8644 path = path.lstrip('/')
_logger.warning('Cannot get path info from a cubbyhole') uri = '/v1/{0}/{1}'.format(mount_point, path)
return({'data': {}}) resp = self.client._adapter.get(url = uri)
return(resp.json())




class MountHandler(object): class MountHandler(object):
@ -40,6 +41,105 @@ class MountHandler(object):
self.paths = {} self.paths = {}
self.getSysMounts() self.getSysMounts()


def getMountType(self, mount):
if not self.mounts:
self.getSysMounts()
mtype = self.mounts.get(mount)
if not mtype:
_logger.error('Mount not found in defined mounts')
_logger.debug('The mount {0} was not found in the defined mounts.'.format(mount))
raise ValueError('Mount not found in defined mounts')
return(mtype)

def getSecret(self, path, mount, version = None):
pass

def getSecretNames(self, path, mount, version = None):
reader = None
mtype = self.getMountType(mount)
secrets_list = []
keypath = ['data']
args = {'path': path,
'mount_point': mount}
if mtype == 'cubbyhole':
reader = self.cubbyhandler.read_secret
elif mtype == 'kv1':
reader = self.client.secrets.kv.v1.read_secret
elif mtype == 'kv2':
if not any(((version is None), isinstance(version, int))):
_logger.error('version parameter must be an integer or None')
_logger.debug('The version parameter ({0}) must be an integer or None'.format(version))
raise ValueError('version parameter must be an integer or None')
reader = self.client.secrets.kv.v2.read_secret_version
args['version'] = version
keypath = ['data', 'data']
data = reader(**args)
try:
# secrets_list = list(data.get('data', {}).keys())
secrets_list = list(dpath.util.get(data, keypath, {}).keys())
except (KeyError, TypeError):
secrets_list = []
return(secrets_list)

def getSecretsTree(self, path = '/', mounts = None, version = None):
if not mounts:
mounts = self.mounts
if isinstance(mounts, dict):
mounts = list(mounts.keys())
if not isinstance(mounts, list):
mounts = [mounts]
for mount in mounts:
mtype = self.getMountType(mount)
handler = None
args = {'path': path,
'mount_point': mount}
relpath = path.replace('//', '/').lstrip('/')
fullpath = '/'.join((mount, relpath)).replace('//', '/').lstrip('/')
if mtype == 'cubbyhole':
handler = self.cubbyhandler
elif mtype == 'kv1':
handler = self.client.secrets.kv.v1
elif mtype == 'kv2':
if not any(((version is None), isinstance(version, int))):
_logger.error('version parameter must be an integer or None')
_logger.debug('The version parameter ({0}) must be an integer or None'.format(version))
raise ValueError('version parameter must be an integer or None')
handler = self.client.secrets.kv.v2
if mount not in self.paths.keys():
self.paths[mount] = {}
try:
paths = handler.list_secrets(**args)
except hvac.exceptions.InvalidPath:
# It's a secret name.
_logger.debug('Path {0} on mount {1} is a secret, not a subdir.'.format(path, mount))
dpath.util.new(self.paths, fullpath, self.getSecretNames(path, mount, version = version))
continue
# if 'data' not in paths.keys() or 'keys' not in paths['data'].keys():
try:
paths_list = paths['data']['keys']
except (KeyError, TypeError):
_logger.warning('Mount has no secrets/subdirs')
_logger.debug('The mount {0} has no secrets or subdirectories'.format(mount))
warnings.warn('Mount has no secrets/subdirs')
continue
for p in paths_list:
p_relpath = '/'.join((relpath, p)).replace('//', '/').lstrip('/')
p_fullpath = '/'.join((fullpath, p)).replace('//', '/').lstrip('/')
_logger.debug(('Recursing getSecretsTree. '
'path={0} '
'fullpath={1} '
'relpath={2} '
'p={3} '
'p_relpath={4} '
'p_fullpath={5}').format(path,
fullpath,
relpath,
p,
p_relpath,
p_fullpath))
self.getSecretsTree(path = p_relpath, mounts = mount)
return(None)

def getSysMounts(self): def getSysMounts(self):
try: try:
for mount, mount_info in self.client.sys.list_mounted_secrets_engines()['data'].items(): for mount, mount_info in self.client.sys.list_mounted_secrets_engines()['data'].items():
@ -62,7 +162,7 @@ class MountHandler(object):
_logger.debug('Added mountpoint {0} to mounts list with type {1}'.format(mount, mtype)) _logger.debug('Added mountpoint {0} to mounts list with type {1}'.format(mount, mtype))
except hvac.exceptions.Forbidden: except hvac.exceptions.Forbidden:
_logger.warning('Client does not have permission to read /sys/mounts.') _logger.warning('Client does not have permission to read /sys/mounts.')
# TODO: should I blindly merge in instead or no? # TODO: should I blindly merge in instead?
if self.xml: if self.xml:
for mount in self.xml.findall('.//mount'): for mount in self.xml.findall('.//mount'):
mname = mount.text mname = mount.text
@ -72,56 +172,47 @@ class MountHandler(object):
_logger.debug('Added mountpoint {0} to mounts list with type {1}'.format(mount, mtype)) _logger.debug('Added mountpoint {0} to mounts list with type {1}'.format(mount, mtype))
return(None) return(None)


def getSecrets(self, path = '/', mounts = None): def printer(self, output = None, indent = 4):
if not mounts: def treePrint(obj, s = 'Password Store\n', level = 0):
mounts = self.mounts prefix = '├──'
if isinstance(mounts, dict): leading_prefix = '│'
mounts = list(mounts.keys()) last_prefix = '└──'
if not isinstance(mounts, list): pass
mounts = [mounts] return(s)
for mount in mounts: if output:
mtype = self.mounts.get(mount) output = output.lower()
if not mtype: if output and output not in ('pretty', 'yaml', 'json'):
_logger.error('Mount not found in defined mounts') # if output and output not in ('pretty', 'yaml', 'json', 'tree'):
_logger.debug('The mount {0} was not found in the defined mounts.'.format(mount)) _logger.error('Invalid output format')
raise ValueError('Mount not found in defined mounts') _logger.debug('The output parameter ("{0}") must be one of: pretty, yaml, json, or None'.format(output))
handler = None # _logger.debug(('The output parameter ("{0}") must be one of: '
if mtype == 'cubbyhole': # 'pretty, yaml, json, tree, or None').format(output))
handler = self.cubbyhandler raise ValueError('Invalid output format')
elif mtype == 'kv': if output in ('pretty', 'yaml', 'json'):
handler = self.client.secrets.kv.v1 if not any(((indent is None), isinstance(indent, int))):
elif mtype == 'kv2': _logger.error('indent parameter must be an integer or None')
handler = self.client.secrets.kv.v2 _logger.debug('The indent parameter ({0}) must be an integer or None'.format(indent))
if mount not in self.paths.keys(): raise ValueError('indent parameter must be an integer or None')
self.paths[mount] = {} if not self.paths:
try: self.getSecretsTree()
paths = handler.list_secrets(path = path, mount_point = mount) if output == 'json':
except hvac.exceptions.InvalidPath: import json
_logger.error('Path does not exist') return(json.dumps(self.paths, indent = indent))
_logger.debug('Path {0} on mount {1} does not exist.'.format(path, mount)) elif output == 'yaml':
continue import yaml # https://pypi.org/project/PyYAML/
if 'data' not in paths.keys() or 'keys' not in paths['data'].keys(): # import pyaml # https://pypi.python.org/pypi/pyaml
_logger.warning('Mount has no secrets/subdirs') return(yaml.dump(self.paths, indent = indent))
_logger.debug('The mount {0} has no secrets or subdirectories'.format(mount)) elif output == 'pretty':
warnings.warn('Mount has no secrets/subdirs') import pprint
for p2 in paths['data']['keys']: if indent is None:
is_dir = False indent = 1
fullpath = '/'.join((path, p2)).replace('//', '/') return(pprint.pformat(self.paths, indent = indent))
if p2.endswith('/'): # elif output == 'tree':
r = _mount_re.search(fullpath) # # UNIX tree command output.
fullpath = r.group('mount') # # has prefixes like ├──, │   ├──, └──, etc.
is_dir = True # import tree
self.paths[mount][fullpath] = None elif not output:
self.getSecrets(path = p2, mounts = mount) return(str(self.paths))
sep_p2 = [i for i in fullpath.split('/') if i.strip() != '']
if is_dir:
pass
# print(mount, sep_p2)


def print(self):
import pprint
pprint.pprint(self.paths)
return(None) return(None)


def search(self): def search(self):

12
vaultpass/tree.py Normal file
View File

@ -0,0 +1,12 @@
# Thanks, dude: https://stackoverflow.com/a/49912639/733214
# TODO?


class Tree(object):
prefix_middle = '├──'
prefix_last = '└──'
spacer_middle = (' ' * 4)
spacer_last = ('│' + (' ' * 3))
parent_fmt = '\033[01;34m{0}/\033[00m'
depth = 0