diff --git a/.gitignore b/.gitignore index 894a44c..5a08c18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,78 @@ +# security +secring.* + +# Emacs +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile + +# directory configuration +.dir-locals.el + +# network security +/network-security.data + + +# Vim files +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + + + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -102,3 +177,67 @@ venv.bak/ # mypy .mypy_cache/ + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser diff --git a/Dockerfile b/Dockerfile index 7e9e01a..22eb01a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,10 +4,17 @@ WORKDIR /code COPY requirements.txt . RUN mkdir secrets \ - && apk add --no-cache gnupg \ - && pip3 install -r requirements.txt + && apk add --no-cache \ + gnupg\ + openssl\ + && pip3 install -r requirements.txt\ + && adduser -D vaultify\ + && chown vaultify . COPY ./vaultify vaultify COPY ./entry.py entry.py -ENTRYPOINT ["python3", "/code/entry.py"] +USER vaultify + +ENTRYPOINT ["python3"] +CMD ["/code/entry.py"] diff --git a/Makefile b/Makefile index 0740bb5..da02612 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,20 @@ +run/test: + rm -rf tests/new* assets/* + gpg \ + --symmetric\ + --batch\ + --passphrase=abc\ + -o assets/test.gpg\ + tests/secrets.env + openssl enc \ + -k abc \ + -aes-256-cbc \ + -salt \ + -a \ + -in tests/secrets.env \ + -out assets/test.enc + VAULTIFY_LOG_LEVEL=WARNING python3 runtests.py + manual: @groff -man -Tascii man/vaultify.1 diff --git a/requirements.txt b/requirements.txt index 181e197..97b99f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ hvac>=0.6.4 -pyyaml PyYAML==3.12 diff --git a/runtests.py b/runtests.py new file mode 100644 index 0000000..63d4a35 --- /dev/null +++ b/runtests.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +import unittest +import doctest +import os + +files = [] +root_dir = 'vaultify/' + +for root, _, filenames in os.walk(root_dir): + for filename in filenames: + if (filename == '__init__.py' + or filename[-3:] != '.py' + or filename.startswith('.#') + ): + continue + f = os.path.join(root, filename) + f = f.replace('/', '.') + f = f[:-3] + files.append(f) + +suite = unittest.TestSuite() +for module in files: + suite.addTest(doctest.DocTestSuite(module)) +unittest.TextTestRunner(verbosity=1).run(suite) diff --git a/tests/echo-vars.sh b/tests/echo-vars.sh new file mode 100755 index 0000000..e8d1d53 --- /dev/null +++ b/tests/echo-vars.sh @@ -0,0 +1,4 @@ +#!/bin/sh +printf "K1=%s\n" "$K1" +printf "K2=%s\n" "$K2" +printf "K3=%s" "$K3" diff --git a/vaultify/consumers.py b/vaultify/consumers.py index 15c3dc5..a76896e 100644 --- a/vaultify/consumers.py +++ b/vaultify/consumers.py @@ -26,18 +26,40 @@ class DotEnvWriter(Consumer): """ This Consumer will write secrets as a set of sourceable `export KEY=value` lines + + We should fail for existing destination files, when trying to consume: + >>> DotEnvWriter('/etc/passwd').consume_secrets({"":""}) + Traceback (most recent call last): + ... + RuntimeError: /etc/passwd already exists + + We want the dictionary outputted in the right format: + >>> DotEnvWriter('tests/new.env').consume_secrets({"K1":"V1","K2":"V2"}) + >>> open('tests/new.env').read() + "export K1='V1'\\nexport K2='V2'\\n" + + We may specify to overwrite the destination file + >>> DotEnvWriter( + ... 'tests/new.env', + ... overwrite=True + ... ).consume_secrets({"K1":"V1","K2":"V2"}) + >>> open('tests/new.env').read() + "export K1='V1'\\nexport K2='V2'\\n" + + """ - def __init__(self, path: str = './secrets.env'): - self.path = os.environ.get("VAULTIFY_DESTFILE", path) + def __init__(self, path: str, overwrite: bool = False): + self.path = path + self.overwrite = overwrite - def consume_secrets(self, data: dict): + def consume_secrets(self, data: dict) -> bool: """ Write data as `export K=V` pairs into self.path, but die if self.path already exists. That file can be sourced or evaluated with the unix shell """ - if os.path.exists(self.path): + if os.path.exists(self.path) and not self.overwrite: raise RuntimeError(f'{self.path} already exists') with open(self.path, 'w') as secrets_file: @@ -51,14 +73,35 @@ def consume_secrets(self, data: dict): ) ) secrets_file.write('\n') + class JsonWriter(Consumer): """ This Consumer will write secrets as a JSON dictionary + + >>> JsonWriter('/etc/passwd').consume_secrets({"K":"V"}) + Traceback (most recent call last): + ... + RuntimeError: /etc/passwd already exists + + We want the dictionary outputted in the right format: + >>> JsonWriter('tests/new.json').consume_secrets({"K1":"V1","K2":"V2"}) + >>> open('tests/new.json').read() + '{\\n "K1": "V1",\\n "K2": "V2"\\n}\\n' + + We may specify to overwrite the destination file + >>> JsonWriter( + ... 'tests/new.json', + ... overwrite=True + ... ).consume_secrets({"K1":"V1","K2":"V2"}) + >>> open('tests/new.json').read() + '{\\n "K1": "V1",\\n "K2": "V2"\\n}\\n' + """ - def __init__(self, path='./secrets.json'): - self.path = os.environ.get("VAULTIFY_DESTFILE", path) + def __init__(self, path: str, overwrite: bool = False): + self.path = path + self.overwrite = overwrite def __str__(self): return f'{self.__class__}->{self.path}' @@ -68,7 +111,7 @@ def consume_secrets(self, data: dict): Write data as json into fname That file can be evaluated by any json-aware application. """ - if os.path.exists(self.path): + if os.path.exists(self.path) and not self.overwrite: raise RuntimeError(f'{self.path} already exists') with open(self.path, 'w') as json_file: @@ -85,8 +128,21 @@ class EnvRunner(Consumer): """ This Consumer will update the environment and then run a subprocess in that altered environment. + + We carry our local environment over into the spawned process: + >>> os.environ.update({"K1": "V1"}) + >>> EnvRunner('./tests/echo-vars.sh').consume_secrets({"K2":"V2","K3":"V3"}) + K1=V1 + K2=V2 + K3=V3 + + We fail when the command can not be found: + >>> EnvRunner('nowhere.sh').consume_secrets({"K1":"V1"}) + Traceback (most recent call last): + ... + FileNotFoundError: [Errno 2] No such file or directory: 'nowhere.sh': 'nowhere.sh' """ - def __init__(self, path: str = 'env'): + def __init__(self, path: str): self.path = os.environ.get( "VAULTIFY_TARGET", path ).split() @@ -106,6 +162,7 @@ def consume_secrets(self, data: dict): f'{self} enriched the environment') try: + # TODO Overhaul this proc = run( self.path, stdout=PIPE, @@ -115,9 +172,9 @@ def consume_secrets(self, data: dict): logger.info( f'running the process "{self.path}"') - except Exception as error: + except FileNotFoundError as error: logger.critical( - f'encountered error in {self} executing "{self.path}"') + f'error in {self} executing "{self.path}"') raise error print( diff --git a/vaultify/providers.py b/vaultify/providers.py index e680478..7e31025 100644 --- a/vaultify/providers.py +++ b/vaultify/providers.py @@ -60,8 +60,9 @@ class OpenSSLProvider(Provider): """ Decrypt and provide secrets from a static file encrypted symmetrically with OpenSSL. + """ - def __init__(self, secret: str): # nosec + def __init__(self, secret: str): self.secret = os.environ.get('VAULTIFY_SECRET', secret) self.popen_kwargs = dict( bufsize=-1, @@ -75,6 +76,7 @@ def __init__(self, secret: str): # nosec def get_secrets(self): """ + This implementation uses a preexisting openssl from the host system to run a command equivalent to: