Browse code

[stable-2.5] Only print warning when ansible.cfg is actually skipped (#43583) (#43649)

Only print warning when ansible.cfg is actually skipped

* Also add unittests for the find_ini_config_file function
* Add documentation on world writable current working directory
config files can no longer be loaded from a world writable current
working directory but the end user is allowed to specify that
explicitly. Give appropriate warnings and information on how.

Fixes #42388
(cherry picked from commit 30662bedadda1cc00efb1946e8f75c5b9fb42d66)

Co-authored-by: Toshio Kuratomi <a.badger@gmail.com>

Toshio Kuratomi authored on 2018/08/14 10:16:24
Showing 6 changed files
1 1
new file mode 100644
... ...
@@ -0,0 +1,9 @@
0
+---
1
+bugfixes:
2
+- The fix for `CVE-2018-10875 <https://access.redhat.com/security/cve/cve-2018-10875>`_
3
+  prints out a warning message about skipping a config file from a world
4
+  writable current working directory.  However, if the user explicitly
5
+  specifies that the config file should be used via the ANSIBLE_CONFIG
6
+  environment variable then Ansible would honor that but still print out the
7
+  warning message.  This has been fixed so that Ansible honors the user's
8
+  explicit wishes and does not print a warning message in that circumstance.
... ...
@@ -39,6 +39,40 @@ Ansible will process the above list and use the first file found, all others are
39 39
         inventory = /etc/ansible/hosts  ; This points to the file that lists your hosts
40 40
 
41 41
 
42
+.. _cfg_in_world_writable_dir:
43
+
44
+Avoiding security risks with ``ansible.cfg`` in the current directory
45
+---------------------------------------------------------------------
46
+
47
+
48
+If Ansible were to load :file:ansible.cfg from a world-writable current working
49
+directory, it would create a serious security risk. Another user could place
50
+their own config file there, designed to make Ansible run malicious code both
51
+locally and remotely, possibly with elevated privileges. For this reason,
52
+Ansible will not automatically load a config file from the current working
53
+directory if the directory is world-writable.
54
+
55
+If you depend on using Ansible with a config file in the current working
56
+directory, the best way to avoid this problem is to restrict access to your
57
+Ansible directories to particular user(s) and/or group(s). If your Ansible
58
+directories live on a filesystem which has to emulate Unix permissions, like
59
+Vagrant or Windows Subsystem for Linux (WSL), you may, at first, not know how
60
+you can fix this as ``chmod``, ``chown``, and ``chgrp`` might not work there.
61
+In most of those cases, the correct fix is to modify the mount options of the
62
+filesystem so the files and directories are readable and writable by the users
63
+and groups running Ansible but closed to others.  For more details on the
64
+correct settings, see:
65
+
66
+* for Vagrant, Jeremy Kendall's `blog post <http://jeremykendall.net/2013/08/09/vagrant-synced-folders-permissions/>`_ covers synced folder permissions. 
67
+* for WSL, the `WSL docs <https://docs.microsoft.com/en-us/windows/wsl/wsl-config#set-wsl-launch-settings>`_
68
+  and this `Microsoft blog post <https://blogs.msdn.microsoft.com/commandline/2018/01/12/chmod-chown-wsl-improvements/>`_ cover mount options.
69
+
70
+If you absolutely depend on having the config live in a world-writable current
71
+working directory, you can explicitly specify the config file via the
72
+:envvar:`ANSIBLE_CONFIG` environment variable. Please take
73
+appropriate steps to mitigate the security concerns above before doing so.
74
+
75
+
42 76
 Common Options
43 77
 ==============
44 78
 
... ...
@@ -5,6 +5,7 @@ from __future__ import (absolute_import, division, print_function)
5 5
 __metaclass__ = type
6 6
 
7 7
 import os
8
+import os.path
8 9
 import sys
9 10
 import stat
10 11
 import tempfile
... ...
@@ -144,31 +145,59 @@ def find_ini_config_file(warnings=None):
144 144
     ''' Load INI Config File order(first found is used): ENV, CWD, HOME, /etc/ansible '''
145 145
     # FIXME: eventually deprecate ini configs
146 146
 
147
-    path0 = os.getenv("ANSIBLE_CONFIG", None)
148
-    if path0 is not None:
149
-        path0 = unfrackpath(path0, follow=False)
150
-        if os.path.isdir(path0):
151
-            path0 += "/ansible.cfg"
147
+    if warnings is None:
148
+        # Note: In this case, warnings does nothing
149
+        warnings = set()
150
+
151
+    # A value that can never be a valid path so that we can tell if ANSIBLE_CONFIG was set later
152
+    # We can't use None because we could set path to None.
153
+    SENTINEL = object
154
+
155
+    potential_paths = []
156
+
157
+    # Environment setting
158
+    path_from_env = os.getenv("ANSIBLE_CONFIG", SENTINEL)
159
+    if path_from_env is not SENTINEL:
160
+        path_from_env = unfrackpath(path_from_env, follow=False)
161
+        if os.path.isdir(path_from_env):
162
+            path_from_env = os.path.join(path_from_env, "ansible.cfg")
163
+        potential_paths.append(path_from_env)
164
+
165
+    # Current working directory
166
+    warn_cmd_public = False
152 167
     try:
153
-        path1 = os.getcwd()
154
-        perms1 = os.stat(path1)
155
-        if perms1.st_mode & stat.S_IWOTH:
156
-            if warnings is not None:
157
-                warnings.add("Ansible is in a world writable directory (%s), ignoring it as an ansible.cfg source." % to_text(path1))
158
-            path1 = None
168
+        cwd = os.getcwd()
169
+        perms = os.stat(cwd)
170
+        if perms.st_mode & stat.S_IWOTH:
171
+            warn_cmd_public = True
159 172
         else:
160
-            path1 += "/ansible.cfg"
173
+            potential_paths.append(os.path.join(cwd, "ansible.cfg"))
161 174
     except OSError:
162
-        path1 = None
163
-    path2 = unfrackpath("~/.ansible.cfg", follow=False)
164
-    path3 = "/etc/ansible/ansible.cfg"
175
+        # If we can't access cwd, we'll simply skip it as a possible config source
176
+        pass
177
+
178
+    # Per user location
179
+    potential_paths.append(unfrackpath("~/.ansible.cfg", follow=False))
165 180
 
166
-    for path in [path0, path1, path2, path3]:
167
-        if path is not None and os.path.exists(path):
181
+    # System location
182
+    potential_paths.append("/etc/ansible/ansible.cfg")
183
+
184
+    for path in potential_paths:
185
+        if os.path.exists(path):
168 186
             break
169 187
     else:
170 188
         path = None
171 189
 
190
+    # Emit a warning if all the following are true:
191
+    # * We did not use a config from ANSIBLE_CONFIG
192
+    # * There's an ansible.cfg in the current working directory that we skipped
193
+    if path_from_env != path and warn_cmd_public:
194
+        warnings.add(u"Ansible is being run in a world writable directory (%s),"
195
+                     u" ignoring it as an ansible.cfg source."
196
+                     u" For more information see"
197
+                     u" https://docs.ansible.com/ansible/devel/reference_appendices/config.html#cfg-in-world-writable-dir"
198
+                     % to_text(cwd))
199
+
172 200
     return path
173 201
 
174 202
 
175 203
new file mode 100644
176 204
new file mode 100644
... ...
@@ -0,0 +1,221 @@
0
+# -*- coding: utf-8 -*-
1
+# Copyright: (c) 2017, Ansible Project
2
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
3
+
4
+# Make coding more python3-ish
5
+from __future__ import (absolute_import, division)
6
+__metaclass__ = type
7
+
8
+import os
9
+import os.path
10
+import stat
11
+
12
+import pytest
13
+
14
+from ansible.config.manager import find_ini_config_file
15
+
16
+
17
+real_exists = os.path.exists
18
+real_isdir = os.path.isdir
19
+
20
+working_dir = os.path.dirname(__file__)
21
+cfg_in_cwd = os.path.join(working_dir, 'ansible.cfg')
22
+
23
+cfg_dir = os.path.join(working_dir, 'data')
24
+cfg_file = os.path.join(cfg_dir, 'ansible.cfg')
25
+alt_cfg_file = os.path.join(cfg_dir, 'test.cfg')
26
+cfg_in_homedir = os.path.expanduser('~/.ansible.cfg')
27
+
28
+
29
+@pytest.fixture
30
+def setup_env(request):
31
+    cur_config = os.environ.get('ANSIBLE_CONFIG', None)
32
+    cfg_path = request.param[0]
33
+
34
+    if cfg_path is None and cur_config:
35
+        del os.environ['ANSIBLE_CONFIG']
36
+    else:
37
+        os.environ['ANSIBLE_CONFIG'] = request.param[0]
38
+
39
+    yield
40
+
41
+    if cur_config is None and cfg_path:
42
+        del os.environ['ANSIBLE_CONFIG']
43
+    else:
44
+        os.environ['ANSIBLE_CONFIG'] = cur_config
45
+
46
+
47
+@pytest.fixture
48
+def setup_existing_files(request, monkeypatch):
49
+    def _os_path_exists(path):
50
+        if path in (request.param[0]):
51
+            return True
52
+        else:
53
+            return False
54
+
55
+    # Enable user and system dirs so that we know cwd takes precedence
56
+    monkeypatch.setattr("os.path.exists", _os_path_exists)
57
+    monkeypatch.setattr("os.getcwd", lambda: os.path.dirname(cfg_dir))
58
+    monkeypatch.setattr("os.path.isdir", lambda path: True if path == cfg_dir else real_isdir(path))
59
+
60
+
61
+class TestFindIniFile:
62
+    # This tells us to run twice, once with a file specified and once with a directory
63
+    @pytest.mark.parametrize('setup_env, expected', (([alt_cfg_file], alt_cfg_file), ([cfg_dir], cfg_file)), indirect=['setup_env'])
64
+    # This just passes the list of files that exist to the fixture
65
+    @pytest.mark.parametrize('setup_existing_files',
66
+                             [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, alt_cfg_file, cfg_file)]],
67
+                             indirect=['setup_existing_files'])
68
+    def test_env_has_cfg_file(self, setup_env, setup_existing_files, expected):
69
+        """ANSIBLE_CONFIG is specified, use it"""
70
+        warnings = set()
71
+        assert find_ini_config_file(warnings) == expected
72
+        assert warnings == set()
73
+
74
+    @pytest.mark.parametrize('setup_env', ([alt_cfg_file], [cfg_dir]), indirect=['setup_env'])
75
+    @pytest.mark.parametrize('setup_existing_files',
76
+                             [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd)]],
77
+                             indirect=['setup_existing_files'])
78
+    def test_env_has_no_cfg_file(self, setup_env, setup_existing_files):
79
+        """ANSIBLE_CONFIG is specified but the file does not exist"""
80
+
81
+        warnings = set()
82
+        # since the cfg file specified by ANSIBLE_CONFIG doesn't exist, the one at cwd that does
83
+        # exist should be returned
84
+        assert find_ini_config_file(warnings) == cfg_in_cwd
85
+        assert warnings == set()
86
+
87
+    # ANSIBLE_CONFIG not specified
88
+    @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env'])
89
+    # All config files are present
90
+    @pytest.mark.parametrize('setup_existing_files',
91
+                             [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]],
92
+                             indirect=['setup_existing_files'])
93
+    def test_ini_in_cwd(self, setup_env, setup_existing_files):
94
+        """ANSIBLE_CONFIG not specified.  Use the cwd cfg"""
95
+        warnings = set()
96
+        assert find_ini_config_file(warnings) == cfg_in_cwd
97
+        assert warnings == set()
98
+
99
+    # ANSIBLE_CONFIG not specified
100
+    @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env'])
101
+    # No config in cwd
102
+    @pytest.mark.parametrize('setup_existing_files',
103
+                             [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_file, alt_cfg_file)]],
104
+                             indirect=['setup_existing_files'])
105
+    def test_ini_in_homedir(self, setup_env, setup_existing_files):
106
+        """First config found is in the homedir"""
107
+        warnings = set()
108
+        assert find_ini_config_file(warnings) == cfg_in_homedir
109
+        assert warnings == set()
110
+
111
+    # ANSIBLE_CONFIG not specified
112
+    @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env'])
113
+    # No config in cwd
114
+    @pytest.mark.parametrize('setup_existing_files', [[('/etc/ansible/ansible.cfg', cfg_file, alt_cfg_file)]], indirect=['setup_existing_files'])
115
+    def test_ini_in_systemdir(self, setup_env, setup_existing_files):
116
+        """First config found is the system config"""
117
+        warnings = set()
118
+        assert find_ini_config_file(warnings) == '/etc/ansible/ansible.cfg'
119
+        assert warnings == set()
120
+
121
+    # ANSIBLE_CONFIG not specified
122
+    @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env'])
123
+    # No config in cwd
124
+    @pytest.mark.parametrize('setup_existing_files',
125
+                             [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_file, alt_cfg_file)]],
126
+                             indirect=['setup_existing_files'])
127
+    def test_cwd_does_not_exist(self, setup_env, setup_existing_files, monkeypatch):
128
+        """Smoketest current working directory doesn't exist"""
129
+        def _os_stat(path):
130
+            raise OSError('%s does not exist' % path)
131
+        monkeypatch.setattr('os.stat', _os_stat)
132
+
133
+        warnings = set()
134
+        assert find_ini_config_file(warnings) == cfg_in_homedir
135
+        assert warnings == set()
136
+
137
+    @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env'])
138
+    # No config in cwd
139
+    @pytest.mark.parametrize('setup_existing_files', [[list()]], indirect=['setup_existing_files'])
140
+    def test_no_config(self, setup_env, setup_existing_files):
141
+        """No config present, no config found"""
142
+        warnings = set()
143
+        assert find_ini_config_file(warnings) is None
144
+        assert warnings == set()
145
+
146
+    # ANSIBLE_CONFIG not specified
147
+    @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env'])
148
+    # All config files are present
149
+    @pytest.mark.parametrize('setup_existing_files',
150
+                             [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]],
151
+                             indirect=['setup_existing_files'])
152
+    def test_cwd_warning_on_writable(self, setup_env, setup_existing_files, monkeypatch):
153
+        """If the cwd is writable, warn and skip it """
154
+        real_stat = os.stat
155
+
156
+        def _os_stat(path):
157
+            if path == working_dir:
158
+                from posix import stat_result
159
+                stat_info = list(real_stat(path))
160
+                stat_info[stat.ST_MODE] |= stat.S_IWOTH
161
+                return stat_result(stat_info)
162
+            else:
163
+                return real_stat(path)
164
+
165
+        monkeypatch.setattr('os.stat', _os_stat)
166
+
167
+        warnings = set()
168
+        assert find_ini_config_file(warnings) == cfg_in_homedir
169
+        assert len(warnings) == 1
170
+        warning = warnings.pop()
171
+        assert u'Ansible is being run in a world writable directory' in warning
172
+        assert u'ignoring it as an ansible.cfg source' in warning
173
+
174
+    # ANSIBLE_CONFIG is sepcified
175
+    @pytest.mark.parametrize('setup_env, expected', (([alt_cfg_file], alt_cfg_file), ([cfg_in_cwd], cfg_in_cwd)), indirect=['setup_env'])
176
+    # All config files are present
177
+    @pytest.mark.parametrize('setup_existing_files',
178
+                             [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]],
179
+                             indirect=['setup_existing_files'])
180
+    def test_no_warning_on_writable_if_env_used(self, setup_env, setup_existing_files, monkeypatch, expected):
181
+        """If the cwd is writable but ANSIBLE_CONFIG was used, no warning should be issued"""
182
+        real_stat = os.stat
183
+
184
+        def _os_stat(path):
185
+            if path == working_dir:
186
+                from posix import stat_result
187
+                stat_info = list(real_stat(path))
188
+                stat_info[stat.ST_MODE] |= stat.S_IWOTH
189
+                return stat_result(stat_info)
190
+            else:
191
+                return real_stat(path)
192
+
193
+        monkeypatch.setattr('os.stat', _os_stat)
194
+
195
+        warnings = set()
196
+        assert find_ini_config_file(warnings) == expected
197
+        assert warnings == set()
198
+
199
+    # ANSIBLE_CONFIG not specified
200
+    @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env'])
201
+    # All config files are present
202
+    @pytest.mark.parametrize('setup_existing_files',
203
+                             [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]],
204
+                             indirect=['setup_existing_files'])
205
+    def test_cwd_warning_on_writable_no_warning_set(self, setup_env, setup_existing_files, monkeypatch):
206
+        """Smoketest that the function succeeds even though no warning set was passed in"""
207
+        real_stat = os.stat
208
+
209
+        def _os_stat(path):
210
+            if path == working_dir:
211
+                from posix import stat_result
212
+                stat_info = list(real_stat(path))
213
+                stat_info[stat.ST_MODE] |= stat.S_IWOTH
214
+                return stat_result(stat_info)
215
+            else:
216
+                return real_stat(path)
217
+
218
+        monkeypatch.setattr('os.stat', _os_stat)
219
+
220
+        assert find_ini_config_file() == cfg_in_homedir
... ...
@@ -3,6 +3,7 @@ from __future__ import (absolute_import, division, print_function)
3 3
 __metaclass__ = type
4 4
 
5 5
 import os
6
+import os.path
6 7
 
7 8
 from ansible.compat.tests import unittest
8 9
 
... ...
@@ -50,12 +51,6 @@ class TestConfigData(unittest.TestCase):
50 50
         self.assertIsInstance(ensure_type('0.10', 'float'), float)
51 51
         self.assertIsInstance(ensure_type(0.2, 'float'), float)
52 52
 
53
-    def test_find_ini_file(self):
54
-        cur_config = os.environ['ANSIBLE_CONFIG']
55
-        os.environ['ANSIBLE_CONFIG'] = cfg_file
56
-        self.assertEquals(cfg_file, find_ini_config_file())
57
-        os.environ['ANSIBLE_CONFIG'] = cur_config
58
-
59 53
     def test_resolve_path(self):
60 54
         self.assertEquals(os.path.join(curdir, 'test.yml'), resolve_path('./test.yml', cfg_file))
61 55