diff --git a/compose/cli/command.py b/compose/cli/command.py index e829b25b2d8..085cd49ba60 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -5,7 +5,6 @@ import os import re import six - from .. import config from ..project import Project from ..service import ConfigError @@ -41,7 +40,7 @@ def dispatch(self, *args, **kwargs): raise errors.ConnectionErrorGeneric(self.get_client().base_url) def perform_command(self, options, handler, command_options): - if options['COMMAND'] == 'help': + if options['COMMAND'] == 'help' or options['COMMAND'] == 'encrypt': # Skip looking up the compose file. handler(None, command_options) return diff --git a/compose/cli/main.py b/compose/cli/main.py index 85e6755687b..7a7b6cd2bc1 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -2,12 +2,15 @@ from __future__ import unicode_literals from inspect import getdoc from operator import attrgetter +from simplecrypt import encrypt as enc +from operator import attrgetter +from docker.errors import APIError import logging import re import signal +import os +import base64 import sys - -from docker.errors import APIError import dockerpty from .. import __version__ @@ -82,6 +85,7 @@ class TopLevelCommand(Command): Commands: build Build or rebuild services + encrypt Helper to encrypt environmental variables help Get help on a command kill Kill containers logs View output from containers @@ -118,6 +122,18 @@ def build(self, project, options): no_cache = bool(options.get('--no-cache', False)) project.build(service_names=options['SERVICE'], no_cache=no_cache) + def encrypt(self, project, options): + """ + Helper to encrypt a string with the current FIG_CRYPT_KEY environment variable. + + Usage: encrypt STRING + """ + string = options['STRING'] + if os.environ.get('FIG_CRYPT_KEY') is None: + raise SystemExit("You must set 'FIG_CRYPT_KEY in your environment") + print("Use the following as your key's value in your yml configuration(this may take a moment):") + print("encrypted:%s" % base64.urlsafe_b64encode(enc(os.environ.get('FIG_CRYPT_KEY'), string))) + def help(self, project, options): """ Get help on a command. diff --git a/compose/config.py b/compose/config.py index f87da1d8c15..3e00e380a50 100644 --- a/compose/config.py +++ b/compose/config.py @@ -1,3 +1,7 @@ +from simplecrypt import decrypt +from simplecrypt import DecryptionException +import logging +import base64 import os import yaml import six @@ -50,10 +54,28 @@ 'workdir': 'working_dir', } +log = logging.getLogger(__name__) + + +def decrypt_config(config): + for project in config: + if 'environment' in config[project] and hasattr(config[project]['environment'], 'items'): + for key, var in config[project]['environment'].items(): + if str(var).startswith('encrypted:'): + secret = os.environ.get('FIG_CRYPT_KEY') + if secret is None: + raise SystemExit("Your yml configuration has encrypted environmental variables but you haven't set 'FIG_CRYPT_KEY in your environment. Please set it and try again.") + try: + config[project]['environment'][key] = decrypt(secret, base64.urlsafe_b64decode(var.replace('encrypted:', '').encode('utf8'))) + except DecryptionException: + log.fatal("Decryption Error: We couldn't decrypt the environmental variable %s in your yml config with the given FIG_CRYPT_KEY. The value has been set to BAD_DECRYPT." % key) + config[project]['environment'][key] = "BAD_DECRYPT" + return config + def load(filename): working_dir = os.path.dirname(filename) - return from_dictionary(load_yaml(filename), working_dir=working_dir, filename=filename) + return from_dictionary(decrypt_config(load_yaml(filename)), working_dir=working_dir, filename=filename) def from_dictionary(dictionary, working_dir=None, filename=None): diff --git a/requirements.txt b/requirements.txt index 4c4113ab9f2..09e9c0e0e37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,5 @@ requests==2.2.1 six==1.7.3 texttable==0.8.2 websocket-client==0.11.0 +simple-crypt==4.0.0 +pycrypto==2.6.1 diff --git a/setup.py b/setup.py index 39ac0f6f50b..73bf6668485 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ def find_version(*file_paths): 'docker-py >= 1.0.0, < 1.2', 'dockerpty >= 0.3.2, < 0.4', 'six >= 1.3.0, < 2', + 'simple-crypt == 4.0.0' ] diff --git a/tests/fixtures/encrypted-environment-composefile/docker-compose.yml b/tests/fixtures/encrypted-environment-composefile/docker-compose.yml new file mode 100644 index 00000000000..cd4493483ec --- /dev/null +++ b/tests/fixtures/encrypted-environment-composefile/docker-compose.yml @@ -0,0 +1,6 @@ +service: + image: busybox:latest + command: sleep 5 + + environment: + encrypted_foo: encrypted:c2MAAqVVdUbUUFfIpxi60K5CscqSH_2x0g4NqNpvIE9vwf8NmAaThh55ZFzd1F8TQbe5BFKSow7-l0EasOLPHt9FhtbJGmVJHOfYO1JnjGqYeld40Fqv9Y_ZhLG8gEMGCb9EBt2uhBQYP_vXQ782ZJ-iLnIEaRgCcnTjKJwq4ZN_NaGMqP3K1yY= diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index c7e2ea3438d..bb635486d99 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -49,6 +49,18 @@ def test_ps(self, mock_stdout): self.command.dispatch(['ps'], None) self.assertIn('simplecomposefile_simple_1', mock_stdout.getvalue()) + @patch('sys.stdout', new_callable=StringIO) + def test_encrypt(self, mock_stdout): + self.project.get_service('simple').create_container() + secret = 'this is only a test' + with self.assertRaises(SystemExit) as exc_context: + self.command.dispatch(['encrypt', secret], None) + self.assertIn('You must set', str(exc_context.exception)) + testphrase = 'Any sufficiently advanced technology is indistinguishable from magic.' + os.environ['FIG_CRYPT_KEY'] = secret + self.command.dispatch(['encrypt', testphrase], None) + self.assertIn('encrypted:', mock_stdout.getvalue()) + @patch('sys.stdout', new_callable=StringIO) def test_ps_default_composefile(self, mock_stdout): self.command.base_dir = 'tests/fixtures/multiple-composefiles' @@ -318,6 +330,28 @@ def test_run_service_with_map_ports(self, __): self.assertIn("0.0.0.0", port_random) self.assertEqual(port_assigned, "0.0.0.0:49152") + @patch('dockerpty.start') + def test_run_with_encrypted_var(self, _): + self.command.base_dir = 'tests/fixtures/encrypted-environment-composefile' + secret = 'this is only a test' + os.environ['FIG_CRYPT_KEY'] = secret + name = 'service' + self.command.dispatch(['run', name, 'env'], None) + service = self.project.get_service(name) + container = service.containers(stopped=True, one_off=True)[0] + self.assertEqual('Any sufficiently advanced technology is indistinguishable from magic.', container.environment['encrypted_foo']) + + @patch('dockerpty.start') + def test_run_bad_decrypt(self, _): + self.command.base_dir = 'tests/fixtures/encrypted-environment-composefile' + secret = 'this is a bad key' + os.environ['FIG_CRYPT_KEY'] = secret + name = 'service' + self.command.dispatch(['run', name, 'env'], None) + service = self.project.get_service(name) + container = service.containers(stopped=True, one_off=True)[0] + self.assertEqual('BAD_DECRYPT', container.environment['encrypted_foo']) + def test_rm(self): service = self.project.get_service('simple') service.create_container()