diff --git a/examples/ansible.cfg b/examples/ansible.cfg index 10983b4dc7..fde7257163 100644 --- a/examples/ansible.cfg +++ b/examples/ansible.cfg @@ -16,6 +16,7 @@ #module_utils = /usr/share/my_module_utils/ #remote_tmp = ~/.ansible/tmp #local_tmp = ~/.ansible/tmp +#plugin_filters_cfg = /etc/ansible/plugin_filters.yml #forks = 5 #poll_interval = 15 #sudo_user = root diff --git a/examples/plugin_filters.yml b/examples/plugin_filters.yml new file mode 100644 index 0000000000..b089a4d1f8 --- /dev/null +++ b/examples/plugin_filters.yml @@ -0,0 +1,6 @@ +--- +filter_version: '1.0' +module_blacklist: + # List the modules to blacklist here + #- easy_install + #- s3 diff --git a/lib/ansible/config/base.yml b/lib/ansible/config/base.yml index 7f61046a96..8f791eeac2 100644 --- a/lib/ansible/config/base.yml +++ b/lib/ansible/config/base.yml @@ -1387,6 +1387,16 @@ PLAYBOOK_VARS_ROOT: ini: - {key: playbook_vars_root, section: defaults} choices: [ top, bottom, all ] +PLUGIN_FILTERS_CFG: + name: Config file for limiting valid plugins + default: null + version_added: "2.5.0" + description: + - "A path to configuration for filtering which plugins installed on the system are allowed to be used" + - " The default is /etc/ansible/plugin_filters.yml" + ini: + - key: plugin_filters_cfg + section: default RETRY_FILES_ENABLED: name: Retry files default: True diff --git a/lib/ansible/parsing/utils/yaml.py b/lib/ansible/parsing/utils/yaml.py index de36928c02..82a3d9d6bc 100644 --- a/lib/ansible/parsing/utils/yaml.py +++ b/lib/ansible/parsing/utils/yaml.py @@ -54,7 +54,7 @@ def _safe_load(stream, file_name=None, vault_secrets=None): pass # older versions of yaml don't have dispose function, ignore -def from_yaml(data, file_name='', show_content=True): +def from_yaml(data, file_name='', show_content=True, vault_secrets=None): ''' Creates a python datastructure from the given data, which can be either a JSON or YAML string. @@ -80,7 +80,7 @@ def from_yaml(data, file_name='', show_content=True): except Exception: # must not be JSON, let the rest try try: - new_data = _safe_load(in_data, file_name=file_name) + new_data = _safe_load(in_data, file_name=file_name, vault_secrets=vault_secrets) except YAMLError as yaml_exc: _handle_error(yaml_exc, file_name, show_content) diff --git a/lib/ansible/plugins/loader.py b/lib/ansible/plugins/loader.py index d3602ebdfb..43a6564562 100644 --- a/lib/ansible/plugins/loader.py +++ b/lib/ansible/plugins/loader.py @@ -17,8 +17,10 @@ import warnings from collections import defaultdict from ansible import constants as C -from ansible.plugins import get_plugin_class, MODULE_CACHE, PATH_CACHE, PLUGIN_PATH_CACHE +from ansible.errors import AnsibleError from ansible.module_utils._text import to_text +from ansible.parsing.utils.yaml import from_yaml +from ansible.plugins import get_plugin_class, MODULE_CACHE, PATH_CACHE, PLUGIN_PATH_CACHE from ansible.utils.plugin_docs import get_docstring try: @@ -235,6 +237,10 @@ class PluginLoader: def find_plugin(self, name, mod_type='', ignore_deprecated=False, check_aliases=False): ''' Find a plugin named name ''' + global _PLUGIN_FILTERS + if name in _PLUGIN_FILTERS[self.package]: + return None + if mod_type: suffix = mod_type elif self.class_name: @@ -405,6 +411,8 @@ class PluginLoader: def all(self, *args, **kwargs): ''' instantiates all plugins with the same arguments ''' + global _PLUGIN_FILTERS + path_only = kwargs.pop('path_only', False) class_only = kwargs.pop('class_only', False) all_matches = [] @@ -416,7 +424,7 @@ class PluginLoader: for path in sorted(all_matches, key=os.path.basename): name = os.path.basename(os.path.splitext(path)[0]) - if '__init__' in name: + if '__init__' in name or name in _PLUGIN_FILTERS[self.package]: continue if path_only: @@ -462,6 +470,63 @@ class PluginLoader: self._update_object(obj, name, path) yield obj + +def _load_plugin_filter(): + filters = defaultdict(frozenset) + + if C.PLUGIN_FILTERS_CFG is None: + filter_cfg = '/etc/ansible/plugin_filters.yml' + user_set = False + else: + filter_cfg = C.PLUGIN_FILTERS_CFG + user_set = True + + if os.path.exists(filter_cfg): + with open(filter_cfg, 'rb') as f: + try: + filter_data = from_yaml(f.read()) + except Exception as e: + display.warning(u'The plugin filter file, {0} was not parsable.' + u' Skipping: {1}'.format(filter_cfg, to_text(e))) + return filters + + try: + version = filter_data['filter_version'] + except KeyError: + display.warning(u'The plugin filter file, {0} was invalid.' + u' Skipping.'.format(filter_cfg)) + return filters + + # Try to convert for people specifying version as a float instead of string + version = to_text(version) + version = version.strip() + + if version == u'1.0': + # Modules and action plugins share the same blacklist since the difference between the + # two isn't visible to the users + filters['ansible.modules'] = frozenset(filter_data['module_blacklist']) + filters['ansible.plugins.action'] = filters['ansible.modules'] + else: + display.warning(u'The plugin filter file, {0} was a version not recognized by this' + u' version of Ansible. Skipping.') + else: + if user_set: + display.warning(u'The plugin filter file, {0} does not exist.' + u' Skipping.'.format(filter_cfg)) + + # Specialcase the stat module as Ansible can run very few things if stat is blacklisted. + if 'stat' in filters['ansible.modules']: + raise AnsibleError('The stat module was specified in the module blacklist file, {0}, but' + ' Ansible will not function without the stat module. Please remove stat' + ' from the blacklist.'.format(filter_cfg)) + return filters + + +# TODO: All of the following is initialization code It should be moved inside of an initialization +# function which is called at some point early in the ansible and ansible-playbook CLI startup. + +_PLUGIN_FILTERS = _load_plugin_filter() + # doc fragments first fragment_loader = PluginLoader( 'ModuleDocFragment', @@ -470,6 +535,7 @@ fragment_loader = PluginLoader( '', ) + action_loader = PluginLoader( 'ActionModule', 'ansible.plugins.action', diff --git a/test/integration/targets/plugin_filtering/aliases b/test/integration/targets/plugin_filtering/aliases new file mode 100644 index 0000000000..79d8b9285e --- /dev/null +++ b/test/integration/targets/plugin_filtering/aliases @@ -0,0 +1 @@ +posix/ci/group3 diff --git a/test/integration/targets/plugin_filtering/copy.yml b/test/integration/targets/plugin_filtering/copy.yml new file mode 100644 index 0000000000..083386a1de --- /dev/null +++ b/test/integration/targets/plugin_filtering/copy.yml @@ -0,0 +1,10 @@ +--- +- hosts: testhost + gather_facts: False + tasks: + - copy: + content: 'Testing 1... 2... 3...' + dest: ./testing.txt + - file: + state: absent + path: ./testing.txt diff --git a/test/integration/targets/plugin_filtering/filter_lookup.ini b/test/integration/targets/plugin_filtering/filter_lookup.ini new file mode 100644 index 0000000000..17e58e334d --- /dev/null +++ b/test/integration/targets/plugin_filtering/filter_lookup.ini @@ -0,0 +1,4 @@ +[default] +retry_files_enabled = False +plugin_filters_cfg = ./filter_lookup.yml + diff --git a/test/integration/targets/plugin_filtering/filter_lookup.yml b/test/integration/targets/plugin_filtering/filter_lookup.yml new file mode 100644 index 0000000000..694ebfcb72 --- /dev/null +++ b/test/integration/targets/plugin_filtering/filter_lookup.yml @@ -0,0 +1,6 @@ +--- +filter_version: 1.0 +module_blacklist: + # Specify the name of a lookup plugin here. This should have no effect as + # this is only for filtering modules + - list diff --git a/test/integration/targets/plugin_filtering/filter_modules.ini b/test/integration/targets/plugin_filtering/filter_modules.ini new file mode 100644 index 0000000000..ab39beddbc --- /dev/null +++ b/test/integration/targets/plugin_filtering/filter_modules.ini @@ -0,0 +1,4 @@ +[default] +retry_files_enabled = False +plugin_filters_cfg = ./filter_modules.yml + diff --git a/test/integration/targets/plugin_filtering/filter_modules.yml b/test/integration/targets/plugin_filtering/filter_modules.yml new file mode 100644 index 0000000000..6cffa67614 --- /dev/null +++ b/test/integration/targets/plugin_filtering/filter_modules.yml @@ -0,0 +1,9 @@ +--- +filter_version: 1.0 +module_blacklist: + # A pure action plugin + - pause + # A hybrid action plugin with module + - copy + # A pure module + - tempfile diff --git a/test/integration/targets/plugin_filtering/filter_ping.ini b/test/integration/targets/plugin_filtering/filter_ping.ini new file mode 100644 index 0000000000..aabbde4566 --- /dev/null +++ b/test/integration/targets/plugin_filtering/filter_ping.ini @@ -0,0 +1,4 @@ +[default] +retry_files_enabled = False +plugin_filters_cfg = ./filter_ping.yml + diff --git a/test/integration/targets/plugin_filtering/filter_ping.yml b/test/integration/targets/plugin_filtering/filter_ping.yml new file mode 100644 index 0000000000..08e56f2439 --- /dev/null +++ b/test/integration/targets/plugin_filtering/filter_ping.yml @@ -0,0 +1,5 @@ +--- +filter_version: 1.0 +module_blacklist: + # Ping is special + - ping diff --git a/test/integration/targets/plugin_filtering/filter_stat.ini b/test/integration/targets/plugin_filtering/filter_stat.ini new file mode 100644 index 0000000000..13a103ddb3 --- /dev/null +++ b/test/integration/targets/plugin_filtering/filter_stat.ini @@ -0,0 +1,4 @@ +[default] +retry_files_enabled = False +plugin_filters_cfg = ./filter_stat.yml + diff --git a/test/integration/targets/plugin_filtering/filter_stat.yml b/test/integration/targets/plugin_filtering/filter_stat.yml new file mode 100644 index 0000000000..c1ce42efd7 --- /dev/null +++ b/test/integration/targets/plugin_filtering/filter_stat.yml @@ -0,0 +1,5 @@ +--- +filter_version: 1.0 +module_blacklist: + # Stat is special + - stat diff --git a/test/integration/targets/plugin_filtering/lookup.yml b/test/integration/targets/plugin_filtering/lookup.yml new file mode 100644 index 0000000000..de6d1b485b --- /dev/null +++ b/test/integration/targets/plugin_filtering/lookup.yml @@ -0,0 +1,14 @@ +--- +- hosts: testhost + gather_facts: False + vars: + data: + - one + - two + tasks: + - debug: + msg: '{{ lookup("list", data) }}' + + - debug: + msg: '{{ item }}' + with_list: '{{ data }}' diff --git a/test/integration/targets/plugin_filtering/no_filters.ini b/test/integration/targets/plugin_filtering/no_filters.ini new file mode 100644 index 0000000000..4b42c8c465 --- /dev/null +++ b/test/integration/targets/plugin_filtering/no_filters.ini @@ -0,0 +1,4 @@ +[default] +retry_files_enabled = False +plugin_filters_cfg = ./empty.yml + diff --git a/test/integration/targets/plugin_filtering/pause.yml b/test/integration/targets/plugin_filtering/pause.yml new file mode 100644 index 0000000000..e2c1ef9c0f --- /dev/null +++ b/test/integration/targets/plugin_filtering/pause.yml @@ -0,0 +1,6 @@ +--- +- hosts: testhost + gather_facts: False + tasks: + - pause: + seconds: 1 diff --git a/test/integration/targets/plugin_filtering/ping.yml b/test/integration/targets/plugin_filtering/ping.yml new file mode 100644 index 0000000000..9e2214b039 --- /dev/null +++ b/test/integration/targets/plugin_filtering/ping.yml @@ -0,0 +1,6 @@ +--- +- hosts: testhost + gather_facts: False + tasks: + - ping: + data: 'Testing 1... 2... 3...' diff --git a/test/integration/targets/plugin_filtering/runme.sh b/test/integration/targets/plugin_filtering/runme.sh new file mode 100755 index 0000000000..d1ef407879 --- /dev/null +++ b/test/integration/targets/plugin_filtering/runme.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +set -ux + +# +# Check that with no filters set, all of these modules run as expected +# +ANSIBLE_CONFIG=no_filters.ini ansible-playbook copy.yml -i ../../inventory -vvv "$@" +if test $? != 0 ; then + echo "### Failed to run copy with no filters applied" + exit 1 +fi +ANSIBLE_CONFIG=no_filters.ini ansible-playbook pause.yml -i ../../inventory -vvv "$@" +if test $? != 0 ; then + echo "### Failed to run pause with no filters applied" + exit 1 +fi +ANSIBLE_CONFIG=no_filters.ini ansible-playbook tempfile.yml -i ../../inventory -vvv "$@" +if test $? != 0 ; then + echo "### Failed to run tempfile with no filters applied" + exit 1 +fi + +# +# Check that with these modules filtered out, all of these modules fail to be found +# +ANSIBLE_CONFIG=filter_modules.ini ansible-playbook copy.yml -i ../../inventory -v "$@" +if test $? = 0 ; then + echo "### Failed to prevent copy from running" + exit 1 +else + echo "### Copy was prevented from running as expected" +fi +ANSIBLE_CONFIG=filter_modules.ini ansible-playbook pause.yml -i ../../inventory -v "$@" +if test $? = 0 ; then + echo "### Failed to prevent pause from running" + exit 1 +else + echo "### pause was prevented from running as expected" +fi +ANSIBLE_CONFIG=filter_modules.ini ansible-playbook tempfile.yml -i ../../inventory -v "$@" +if test $? = 0 ; then + echo "### Failed to prevent tempfile from running" + exit 1 +else + echo "### tempfile was prevented from running as expected" +fi + +# +# ping is a special module as we test for its existence. Check it specially +# + +# Check that ping runs with no filter +ANSIBLE_CONFIG=no_filters.ini ansible-playbook ping.yml -i ../../inventory -vvv "$@" +if test $? != 0 ; then + echo "### Failed to run ping with no filters applied" + exit 1 +fi + +# Check that other modules run with ping filtered +ANSIBLE_CONFIG=filter_ping.ini ansible-playbook copy.yml -i ../../inventory -vvv "$@" +if test $? != 0 ; then + echo "### Failed to run copy when a filter was applied to ping" + exit 1 +fi +# Check that ping fails to run when it is filtered +ANSIBLE_CONFIG=filter_ping.ini ansible-playbook ping.yml -i ../../inventory -v "$@" +if test $? = 0 ; then + echo "### Failed to prevent ping from running" + exit 1 +else + echo "### Ping was prevented from running as expected" +fi + +# +# Check that specifying a lookup plugin in the filter has no effect +# + +ANSIBLE_CONFIG=filter_lookup.ini ansible-playbook lookup.yml -i ../../inventory -vvv "$@" +if test $? != 0 ; then + echo "### Failed to use a lookup plugin when it is incorrectly specified in the *module* blacklist" + exit 1 +fi + +# +# stat is a special module as we use it to run nearly every other module. Check it specially +# + +# Check that stat runs with no filter +ANSIBLE_CONFIG=no_filters.ini ansible-playbook stat.yml -i ../../inventory -vvv "$@" +if test $? != 0 ; then + echo "### Failed to run stat with no filters applied" + exit 1 +fi + +# Check that running another module when stat is filtered gives us our custom error message +ANSIBLE_CONFIG=filter_stat.ini +export ANSIBLE_CONFIG +CAPTURE=$(ansible-playbook copy.yml -i ../../inventory -vvv "$@" 2>&1) +if test $? = 0 ; then + echo "### Copy ran even though stat is in the module blacklist" + exit 1 +else + echo "$CAPTURE" | grep 'The stat module was specified in the module blacklist file,.*, but Ansible will not function without the stat module. Please remove stat from the blacklist.' + if test $? != 0 ; then + echo "### Stat did not give us our custom error message" + exit 1 + fi + echo "### Filtering stat failed with our custom error message as expected" +fi +unset ANSIBLE_CONFIG + +# Check that running stat when stat is filtered gives our custom error message +ANSIBLE_CONFIG=filter_stat.ini +export ANSIBLE_CONFIG +CAPTURE=$(ansible-playbook stat.yml -i ../../inventory -vvv "$@" 2>&1) +if test $? = 0 ; then + echo "### Stat ran even though it is in the module blacklist" + exit 1 +else + echo "$CAPTURE" | grep 'The stat module was specified in the module blacklist file,.*, but Ansible will not function without the stat module. Please remove stat from the blacklist.' + if test $? != 0 ; then + echo "### Stat did not give us our custom error message" + exit 1 + fi + echo "### Filtering stat failed with our custom error message as expected" +fi +unset ANSIBLE_CONFIG diff --git a/test/integration/targets/plugin_filtering/stat.yml b/test/integration/targets/plugin_filtering/stat.yml new file mode 100644 index 0000000000..4f24baaea2 --- /dev/null +++ b/test/integration/targets/plugin_filtering/stat.yml @@ -0,0 +1,6 @@ +--- +- hosts: testhost + gather_facts: False + tasks: + - stat: + path: '/' diff --git a/test/integration/targets/plugin_filtering/tempfile.yml b/test/integration/targets/plugin_filtering/tempfile.yml new file mode 100644 index 0000000000..06463547de --- /dev/null +++ b/test/integration/targets/plugin_filtering/tempfile.yml @@ -0,0 +1,9 @@ +--- +- hosts: testhost + gather_facts: False + tasks: + - tempfile: + register: temp_result + - file: + state: absent + path: '{{ temp_result["path"] }}'