diff --git a/CHANGELOG.md b/CHANGELOG.md index 383bd03b4de..54692dc6527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,21 @@ Ansible Changes By Release ========================== + + +## 2.4.7 "Dancing Days" - 2018-07-05 + +### Bugfixes + +* The fix for CVE-2018-10875 (https://access.redhat.com/security/cve/cve-2018-10875) + prints out a warning message about skipping a config file from a world + writable current working directory. However, if the user explicitly + specifies that the config file should be used via the ANSIBLE_CONFIG + environment variable then Ansible would honor that but still print out the + warning message. This has been fixed so that Ansible honors the user's + explicit wishes and does not print a warning message in that circumstance. + + ## 2.4.6 "Dancing Days" - 2018-07-05 diff --git a/docs/templates/config.rst.j2 b/docs/templates/config.rst.j2 index f40143690f1..51c3d89b28e 100644 --- a/docs/templates/config.rst.j2 +++ b/docs/templates/config.rst.j2 @@ -6,6 +6,72 @@ {{name}} {{ '=' * name_len }} +Ansible supports a few ways of providing configuration variables, mainly through environment variables, command line switches and an ini file named ``ansible.cfg``. + +Starting at Ansible 2.4 the ``ansible-config`` utility allows users to see all the configuration settings available, their defaults, how to set them and +where their current value comes from. See :doc:ansible-config for more information. + +.. _ansible_configuration_settings_locations: + +The configuration file +====================== + +Changes can be made and used in a configuration file which will be searched for in the following order: + + * ``ANSIBLE_CONFIG`` (environment variable if set) + * ``ansible.cfg`` (in the current directory) + * ``~/.ansible.cfg`` (in the home directory) + * ``/etc/ansible/ansible.cfg`` + +Ansible will process the above list and use the first file found, all others are ignored. + +.. note:: + + The configuration file is one variant of an INI format. + Both the hash sign (``#``) and semicolon (``;``) are allowed as + comment markers when the comment starts the line. + However, if the comment is inline with regular values, + only the semicolon is allowed to introduce the comment. + For instance:: + + # some basic default values... + inventory = /etc/ansible/hosts ; This points to the file that lists your hosts + + +.. _cfg_in_world_writable_dir: + +Avoiding security risks with ``ansible.cfg`` in the current directory +--------------------------------------------------------------------- + + +If Ansible were to load :file:ansible.cfg from a world-writable current working +directory, it would create a serious security risk. Another user could place +their own config file there, designed to make Ansible run malicious code both +locally and remotely, possibly with elevated privileges. For this reason, +Ansible will not automatically load a config file from the current working +directory if the directory is world-writable. + +If you depend on using Ansible with a config file in the current working +directory, the best way to avoid this problem is to restrict access to your +Ansible directories to particular user(s) and/or group(s). If your Ansible +directories live on a filesystem which has to emulate Unix permissions, like +Vagrant or Windows Subsystem for Linux (WSL), you may, at first, not know how +you can fix this as ``chmod``, ``chown``, and ``chgrp`` might not work there. +In most of those cases, the correct fix is to modify the mount options of the +filesystem so the files and directories are readable and writable by the users +and groups running Ansible but closed to others. For more details on the +correct settings, see: + +* for Vagrant, Jeremy Kendall's `blog post `_ covers synced folder permissions. +* for WSL, the `WSL docs `_ + and this `Microsoft blog post `_ cover mount options. + +If you absolutely depend on having the config live in a world-writable current +working directory, you can explicitly specify the config file via the +:envvar:`ANSIBLE_CONFIG` environment variable. Please take +appropriate steps to mitigate the security concerns above before doing so. + + Common Options ============== diff --git a/lib/ansible/config/manager.py b/lib/ansible/config/manager.py index 03ccc0149d9..d5a00400579 100644 --- a/lib/ansible/config/manager.py +++ b/lib/ansible/config/manager.py @@ -6,6 +6,7 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type import os +import os.path import sys import stat import tempfile @@ -139,31 +140,59 @@ def find_ini_config_file(warnings=None): ''' Load INI Config File order(first found is used): ENV, CWD, HOME, /etc/ansible ''' # FIXME: eventually deprecate ini configs - path0 = os.getenv("ANSIBLE_CONFIG", None) - if path0 is not None: - path0 = unfrackpath(path0, follow=False) - if os.path.isdir(path0): - path0 += "/ansible.cfg" + if warnings is None: + # Note: In this case, warnings does nothing + warnings = set() + + # A value that can never be a valid path so that we can tell if ANSIBLE_CONFIG was set later + # We can't use None because we could set path to None. + SENTINEL = object + + potential_paths = [] + + # Environment setting + path_from_env = os.getenv("ANSIBLE_CONFIG", SENTINEL) + if path_from_env is not SENTINEL: + path_from_env = unfrackpath(path_from_env, follow=False) + if os.path.isdir(path_from_env): + path_from_env = os.path.join(path_from_env, "ansible.cfg") + potential_paths.append(path_from_env) + + # Current working directory + warn_cmd_public = False try: - path1 = os.getcwd() - perms1 = os.stat(path1) - if perms1.st_mode & stat.S_IWOTH: - if warnings is not None: - warnings.add("Ansible is in a world writable directory (%s), ignoring it as an ansible.cfg source." % to_text(path1)) - path1 = None + cwd = os.getcwd() + perms = os.stat(cwd) + if perms.st_mode & stat.S_IWOTH: + warn_cmd_public = True else: - path1 += "/ansible.cfg" + potential_paths.append(os.path.join(cwd, "ansible.cfg")) except OSError: - path1 = None - path2 = unfrackpath("~/.ansible.cfg", follow=False) - path3 = "/etc/ansible/ansible.cfg" + # If we can't access cwd, we'll simply skip it as a possible config source + pass + + # Per user location + potential_paths.append(unfrackpath("~/.ansible.cfg", follow=False)) - for path in [path0, path1, path2, path3]: - if path is not None and os.path.exists(path): + # System location + potential_paths.append("/etc/ansible/ansible.cfg") + + for path in potential_paths: + if os.path.exists(path): break else: path = None + # Emit a warning if all the following are true: + # * We did not use a config from ANSIBLE_CONFIG + # * There's an ansible.cfg in the current working directory that we skipped + if path_from_env != path and warn_cmd_public: + warnings.add(u"Ansible is being run in a world writable directory (%s)," + u" ignoring it as an ansible.cfg source." + u" For more information see" + u" https://docs.ansible.com/ansible/devel/reference_appendices/config.html#cfg-in-world-writable-dir" + % to_text(cwd)) + return path diff --git a/test/units/config/manager/__init__.py b/test/units/config/manager/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/units/config/manager/test_find_ini_config_file.py b/test/units/config/manager/test_find_ini_config_file.py new file mode 100644 index 00000000000..f8f3d72c239 --- /dev/null +++ b/test/units/config/manager/test_find_ini_config_file.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2017, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# Make coding more python3-ish +from __future__ import (absolute_import, division) +__metaclass__ = type + +import os +import os.path +import stat + +import pytest + +from ansible.config.manager import find_ini_config_file + + +real_exists = os.path.exists +real_isdir = os.path.isdir + +working_dir = os.path.dirname(__file__) +cfg_in_cwd = os.path.join(working_dir, 'ansible.cfg') + +cfg_dir = os.path.join(working_dir, 'data') +cfg_file = os.path.join(cfg_dir, 'ansible.cfg') +alt_cfg_file = os.path.join(cfg_dir, 'test.cfg') +cfg_in_homedir = os.path.expanduser('~/.ansible.cfg') + + +@pytest.fixture +def setup_env(request): + cur_config = os.environ.get('ANSIBLE_CONFIG', None) + cfg_path = request.param[0] + + if cfg_path is None and cur_config: + del os.environ['ANSIBLE_CONFIG'] + else: + os.environ['ANSIBLE_CONFIG'] = request.param[0] + + yield + + if cur_config is None and cfg_path: + del os.environ['ANSIBLE_CONFIG'] + else: + os.environ['ANSIBLE_CONFIG'] = cur_config + + +@pytest.fixture +def setup_existing_files(request, monkeypatch): + def _os_path_exists(path): + if path in (request.param[0]): + return True + else: + return False + + # Enable user and system dirs so that we know cwd takes precedence + monkeypatch.setattr("os.path.exists", _os_path_exists) + monkeypatch.setattr("os.getcwd", lambda: os.path.dirname(cfg_dir)) + monkeypatch.setattr("os.path.isdir", lambda path: True if path == cfg_dir else real_isdir(path)) + + +class TestFindIniFile: + # This tells us to run twice, once with a file specified and once with a directory + @pytest.mark.parametrize('setup_env, expected', (([alt_cfg_file], alt_cfg_file), ([cfg_dir], cfg_file)), indirect=['setup_env']) + # This just passes the list of files that exist to the fixture + @pytest.mark.parametrize('setup_existing_files', + [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, alt_cfg_file, cfg_file)]], + indirect=['setup_existing_files']) + def test_env_has_cfg_file(self, setup_env, setup_existing_files, expected): + """ANSIBLE_CONFIG is specified, use it""" + warnings = set() + assert find_ini_config_file(warnings) == expected + assert warnings == set() + + @pytest.mark.parametrize('setup_env', ([alt_cfg_file], [cfg_dir]), indirect=['setup_env']) + @pytest.mark.parametrize('setup_existing_files', + [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd)]], + indirect=['setup_existing_files']) + def test_env_has_no_cfg_file(self, setup_env, setup_existing_files): + """ANSIBLE_CONFIG is specified but the file does not exist""" + + warnings = set() + # since the cfg file specified by ANSIBLE_CONFIG doesn't exist, the one at cwd that does + # exist should be returned + assert find_ini_config_file(warnings) == cfg_in_cwd + assert warnings == set() + + # ANSIBLE_CONFIG not specified + @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) + # All config files are present + @pytest.mark.parametrize('setup_existing_files', + [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]], + indirect=['setup_existing_files']) + def test_ini_in_cwd(self, setup_env, setup_existing_files): + """ANSIBLE_CONFIG not specified. Use the cwd cfg""" + warnings = set() + assert find_ini_config_file(warnings) == cfg_in_cwd + assert warnings == set() + + # ANSIBLE_CONFIG not specified + @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) + # No config in cwd + @pytest.mark.parametrize('setup_existing_files', + [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_file, alt_cfg_file)]], + indirect=['setup_existing_files']) + def test_ini_in_homedir(self, setup_env, setup_existing_files): + """First config found is in the homedir""" + warnings = set() + assert find_ini_config_file(warnings) == cfg_in_homedir + assert warnings == set() + + # ANSIBLE_CONFIG not specified + @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) + # No config in cwd + @pytest.mark.parametrize('setup_existing_files', [[('/etc/ansible/ansible.cfg', cfg_file, alt_cfg_file)]], indirect=['setup_existing_files']) + def test_ini_in_systemdir(self, setup_env, setup_existing_files): + """First config found is the system config""" + warnings = set() + assert find_ini_config_file(warnings) == '/etc/ansible/ansible.cfg' + assert warnings == set() + + # ANSIBLE_CONFIG not specified + @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) + # No config in cwd + @pytest.mark.parametrize('setup_existing_files', + [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_file, alt_cfg_file)]], + indirect=['setup_existing_files']) + def test_cwd_does_not_exist(self, setup_env, setup_existing_files, monkeypatch): + """Smoketest current working directory doesn't exist""" + def _os_stat(path): + raise OSError('%s does not exist' % path) + monkeypatch.setattr('os.stat', _os_stat) + + warnings = set() + assert find_ini_config_file(warnings) == cfg_in_homedir + assert warnings == set() + + @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) + # No config in cwd + @pytest.mark.parametrize('setup_existing_files', [[list()]], indirect=['setup_existing_files']) + def test_no_config(self, setup_env, setup_existing_files): + """No config present, no config found""" + warnings = set() + assert find_ini_config_file(warnings) is None + assert warnings == set() + + # ANSIBLE_CONFIG not specified + @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) + # All config files are present + @pytest.mark.parametrize('setup_existing_files', + [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]], + indirect=['setup_existing_files']) + def test_cwd_warning_on_writable(self, setup_env, setup_existing_files, monkeypatch): + """If the cwd is writable, warn and skip it """ + real_stat = os.stat + + def _os_stat(path): + if path == working_dir: + from posix import stat_result + stat_info = list(real_stat(path)) + stat_info[stat.ST_MODE] |= stat.S_IWOTH + return stat_result(stat_info) + else: + return real_stat(path) + + monkeypatch.setattr('os.stat', _os_stat) + + warnings = set() + assert find_ini_config_file(warnings) == cfg_in_homedir + assert len(warnings) == 1 + warning = warnings.pop() + assert u'Ansible is being run in a world writable directory' in warning + assert u'ignoring it as an ansible.cfg source' in warning + + # ANSIBLE_CONFIG is sepcified + @pytest.mark.parametrize('setup_env, expected', (([alt_cfg_file], alt_cfg_file), ([cfg_in_cwd], cfg_in_cwd)), indirect=['setup_env']) + # All config files are present + @pytest.mark.parametrize('setup_existing_files', + [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]], + indirect=['setup_existing_files']) + def test_no_warning_on_writable_if_env_used(self, setup_env, setup_existing_files, monkeypatch, expected): + """If the cwd is writable but ANSIBLE_CONFIG was used, no warning should be issued""" + real_stat = os.stat + + def _os_stat(path): + if path == working_dir: + from posix import stat_result + stat_info = list(real_stat(path)) + stat_info[stat.ST_MODE] |= stat.S_IWOTH + return stat_result(stat_info) + else: + return real_stat(path) + + monkeypatch.setattr('os.stat', _os_stat) + + warnings = set() + assert find_ini_config_file(warnings) == expected + assert warnings == set() + + # ANSIBLE_CONFIG not specified + @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) + # All config files are present + @pytest.mark.parametrize('setup_existing_files', + [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]], + indirect=['setup_existing_files']) + def test_cwd_warning_on_writable_no_warning_set(self, setup_env, setup_existing_files, monkeypatch): + """Smoketest that the function succeeds even though no warning set was passed in""" + real_stat = os.stat + + def _os_stat(path): + if path == working_dir: + from posix import stat_result + stat_info = list(real_stat(path)) + stat_info[stat.ST_MODE] |= stat.S_IWOTH + return stat_result(stat_info) + else: + return real_stat(path) + + monkeypatch.setattr('os.stat', _os_stat) + + assert find_ini_config_file() == cfg_in_homedir diff --git a/test/units/config/test_manager.py b/test/units/config/test_manager.py new file mode 100644 index 00000000000..e69de29bb2d