Sign Up
Log In
Log In
or
Sign Up
Places
All Projects
Status Monitor
Collapse sidebar
SUSE:SLE-15-SP4:Update
salt.15448
fix-cve-2020-11651-and-fix-cve-2020-11652.patch
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
File fix-cve-2020-11651-and-fix-cve-2020-11652.patch of Package salt.15448
From d3b899aac9a0fc92f4598384e23f11eccf6a93c8 Mon Sep 17 00:00:00 2001 From: Jochen Breuer <jbreuer@suse.de> Date: Tue, 28 Apr 2020 11:40:53 +0200 Subject: [PATCH] Fix CVE-2020-11651 and Fix CVE-2020-11652 --- salt/master.py | 58 +++- salt/tokens/localfs.py | 3 + salt/utils/verify.py | 57 +++- salt/wheel/config.py | 8 +- salt/wheel/file_roots.py | 7 +- tests/integration/master/test_clear_funcs.py | 310 +++++++++++++++++++ tests/unit/test_master.py | 25 ++ tests/unit/test_module_names.py | 1 + tests/unit/utils/test_verify.py | 79 +++++ tests/whitelist.txt | 1 + 10 files changed, 533 insertions(+), 16 deletions(-) create mode 100644 tests/integration/master/test_clear_funcs.py diff --git a/salt/master.py b/salt/master.py index 3a9d12999d0cabe784f3069b7c803b5be1a4df19..e42b8385c3935726678d3b726f3b497f7dcef9ab 100644 --- a/salt/master.py +++ b/salt/master.py @@ -1089,12 +1089,13 @@ class MWorker(salt.utils.process.SignalHandlingProcess): ''' log.trace('Clear payload received with command %s', load['cmd']) cmd = load['cmd'] - if cmd.startswith('__'): - return False + method = self.clear_funcs.get_method(cmd) + if not method: + return {}, {'fun': 'send_clear'} if self.opts['master_stats']: start = time.time() self.stats[cmd]['runs'] += 1 - ret = getattr(self.clear_funcs, cmd)(load), {'fun': 'send_clear'} + ret = method(load), {'fun': 'send_clear'} if self.opts['master_stats']: self._post_stats(start, cmd) return ret @@ -1112,8 +1113,9 @@ class MWorker(salt.utils.process.SignalHandlingProcess): return {} cmd = data['cmd'] log.trace('AES payload received with command %s', data['cmd']) - if cmd.startswith('__'): - return False + method = self.aes_funcs.get_method(cmd) + if not method: + return {}, {'fun': 'send'} if self.opts['master_stats']: start = time.time() self.stats[cmd]['runs'] += 1 @@ -1144,13 +1146,44 @@ class MWorker(salt.utils.process.SignalHandlingProcess): self.__bind() +class TransportMethods(object): + ''' + Expose methods to the transport layer, methods with their names found in + the class attribute 'expose_methods' will be exposed to the transport layer + via 'get_method'. + ''' + + expose_methods = () + + def get_method(self, name): + ''' + Get a method which should be exposed to the transport layer + ''' + if name in self.expose_methods: + try: + return getattr(self, name) + except AttributeError: + log.error("Expose method not found: %s", name) + else: + log.error("Requested method not exposed: %s", name) + + # TODO: rename? No longer tied to "AES", just "encrypted" or "private" requests -class AESFuncs(object): +class AESFuncs(TransportMethods): ''' Set up functions that are available when the load is encrypted with AES ''' - # The AES Functions: - # + + expose_methods = ( + 'verify_minion', '_master_tops', '_ext_nodes', '_master_opts', + '_mine_get', '_mine', '_mine_delete', '_mine_flush', '_file_recv', + '_pillar', '_minion_event', '_handle_minion_event', '_return', + '_syndic_return', 'minion_runner', 'pub_ret', 'minion_pub', + 'minion_publish', 'revoke_auth', 'run_func', '_serve_file', + '_file_find', '_file_hash', '_file_find_and_stat', '_file_list', + '_file_list_emptydirs', '_dir_list', '_symlink_list', '_file_envs', + ) + def __init__(self, opts): ''' Create a new AESFuncs @@ -1864,11 +1897,18 @@ class AESFuncs(object): return ret, {'fun': 'send'} -class ClearFuncs(object): +class ClearFuncs(TransportMethods): ''' Set up functions that are safe to execute when commands sent to the master without encryption and authentication ''' + + # These methods will be exposed to the transport layer by + # MWorker._handle_clear + expose_methods = ( + 'ping', 'publish', 'get_token', 'mk_token', 'wheel', 'runner', + ) + # The ClearFuncs object encapsulates the functions that can be executed in # the clear: # publish (The publish from the LocalClient) diff --git a/salt/tokens/localfs.py b/salt/tokens/localfs.py index 3660ee31869239243ba3b1da0338cef6cb18917c..747f8eea1e4515370a1e35d3eb5428568225a683 100644 --- a/salt/tokens/localfs.py +++ b/salt/tokens/localfs.py @@ -12,6 +12,7 @@ import logging import salt.utils.files import salt.utils.path +import salt.utils.verify import salt.payload from salt.ext import six @@ -61,6 +62,8 @@ def get_token(opts, tok): :returns: Token data if successful. Empty dict if failed. ''' t_path = os.path.join(opts['token_dir'], tok) + if not salt.utils.verify.clean_path(opts['token_dir'], t_path): + return {} if not os.path.isfile(t_path): return {} serial = salt.payload.Serial(opts) diff --git a/salt/utils/verify.py b/salt/utils/verify.py index 57f6bb371fc909d8f706c3f7d708366dc1e3de50..e65d816538deb8e7cf5cb19bbd21880211c007d7 100644 --- a/salt/utils/verify.py +++ b/salt/utils/verify.py @@ -31,6 +31,7 @@ import salt.utils.files import salt.utils.path import salt.utils.platform import salt.utils.user +import salt.ext.six log = logging.getLogger(__name__) @@ -495,23 +496,69 @@ def check_max_open_files(opts): log.log(level=level, msg=msg) +def _realpath_darwin(path): + base = '' + for part in path.split(os.path.sep)[1:]: + if base != '': + if os.path.islink(os.path.sep.join([base, part])): + base = os.readlink(os.path.sep.join([base, part])) + else: + base = os.path.abspath(os.path.sep.join([base, part])) + else: + base = os.path.abspath(os.path.sep.join([base, part])) + return base + + +def _realpath_windows(path): + base = '' + for part in path.split(os.path.sep): + if base != '': + try: + part = os.readlink(os.path.sep.join([base, part])) + base = os.path.abspath(part) + except OSError: + base = os.path.abspath(os.path.sep.join([base, part])) + else: + base = part + return base + + +def _realpath(path): + ''' + Cross platform realpath method. On Windows when python 3, this method + uses the os.readlink method to resolve any filesystem links. On Windows + when python 2, this method is a no-op. All other platforms and version use + os.path.realpath + ''' + if salt.utils.platform.is_darwin(): + return _realpath_darwin(path) + elif salt.utils.platform.is_windows(): + if salt.ext.six.PY3: + return _realpath_windows(path) + else: + return path + return os.path.realpath(path) + + def clean_path(root, path, subdir=False): ''' Accepts the root the path needs to be under and verifies that the path is under said root. Pass in subdir=True if the path can result in a subdirectory of the root instead of having to reside directly in the root ''' - if not os.path.isabs(root): + real_root = _realpath(root) + if not os.path.isabs(real_root): return '' if not os.path.isabs(path): path = os.path.join(root, path) path = os.path.normpath(path) + real_path = _realpath(path) if subdir: - if path.startswith(root): - return path + if real_path.startswith(real_root): + return real_path else: - if os.path.dirname(path) == os.path.normpath(root): - return path + if os.path.dirname(real_path) == os.path.normpath(real_root): + return real_path return '' diff --git a/salt/wheel/config.py b/salt/wheel/config.py index a8a93c53e56dd18d384535d13dc97db4c7d23c50..3984444f8f1f966430e8f993f5a93514363868ba 100644 --- a/salt/wheel/config.py +++ b/salt/wheel/config.py @@ -75,13 +75,19 @@ def update_config(file_name, yaml_contents): dir_path = os.path.join(__opts__['config_dir'], os.path.dirname(__opts__['default_include'])) try: - yaml_out = salt.utils.yaml.safe_dump(yaml_contents, default_flow_style=False) + yaml_out = salt.utils.yaml.safe_dump( + yaml_contents, + default_flow_style=False, + ) if not os.path.exists(dir_path): log.debug('Creating directory %s', dir_path) os.makedirs(dir_path, 0o755) file_path = os.path.join(dir_path, file_name) + if not salt.utils.verify.clean_path(dir_path, file_path): + return 'Invalid path' + with salt.utils.files.fopen(file_path, 'w') as fp_: fp_.write(yaml_out) diff --git a/salt/wheel/file_roots.py b/salt/wheel/file_roots.py index 02cc8c5b32752bac3da40d069a7f0b7c7cdef568..ad42335734e0cb39cf5826cd74e567b85e1b0df0 100644 --- a/salt/wheel/file_roots.py +++ b/salt/wheel/file_roots.py @@ -25,6 +25,8 @@ def find(path, saltenv='base'): return ret for root in __opts__['file_roots'][saltenv]: full = os.path.join(root, path) + if not salt.utils.verify.clean_path(root, full): + continue if os.path.isfile(full): # Add it to the dict with salt.utils.files.fopen(full, 'rb') as fp_: @@ -107,7 +109,10 @@ def write(data, path, saltenv='base', index=0): if os.path.isabs(path): return ('The path passed in {0} is not relative to the environment ' '{1}').format(path, saltenv) - dest = os.path.join(__opts__['file_roots'][saltenv][index], path) + root = __opts__['file_roots'][saltenv][index] + dest = os.path.join(root, path) + if not salt.utils.verify.clean_path(root, dest, subdir=True): + return 'Invalid path: {}'.format(path) dest_dir = os.path.dirname(dest) if not os.path.isdir(dest_dir): os.makedirs(dest_dir) diff --git a/tests/integration/master/test_clear_funcs.py b/tests/integration/master/test_clear_funcs.py new file mode 100644 index 0000000000000000000000000000000000000000..4abb257dd96fe462b0de69605a4e0df24cee03a8 --- /dev/null +++ b/tests/integration/master/test_clear_funcs.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, print_function, unicode_literals +import getpass +import os +import tempfile +import time + +import salt.master +import salt.transport.client +import salt.utils.platform +import salt.utils.files +import salt.utils.user + +from tests.support.case import TestCase +from tests.support.mixins import AdaptedConfigurationTestCaseMixin +from tests.support.runtests import RUNTIME_VARS + + +def keyuser(): + user = salt.utils.user.get_specific_user() + if user.startswith('sudo_'): + user = user[5:].replace('\\', '_') + return user + + +class ClearFuncsAuthTestCase(TestCase): + + def test_auth_info_not_allowed(self): + assert hasattr(salt.master.ClearFuncs, '_prep_auth_info') + master = '127.0.0.1' + ret_port = 64506 + user = getpass.getuser() + keyfile = '.{}_key'.format(user) + + keypath = os.path.join(RUNTIME_VARS.TMP, 'rootdir', 'cache', keyfile) + + with salt.utils.files.fopen(keypath) as keyfd: + key = keyfd.read() + + minion_config = { + 'transport': 'zeromq', + 'pki_dir': '/tmp', + 'id': 'root', + 'master_ip': master, + 'master_port': ret_port, + 'auth_timeout': 5, + 'auth_tries': 1, + 'master_uri': 'tcp://{0}:{1}'.format(master, ret_port) + } + + clear_channel = salt.transport.client.ReqChannel.factory( + minion_config, crypt='clear') + + msg = {'cmd': '_prep_auth_info'} + rets = clear_channel.send(msg, timeout=15) + ret_key = None + for ret in rets: + try: + ret_key = ret[user] + break + except (TypeError, KeyError): + pass + assert ret_key != key, 'Able to retrieve user key' + + +class ClearFuncsPubTestCase(TestCase): + + def setUp(self): + self.master = '127.0.0.1' + self.ret_port = 64506 + self.tmpfile = os.path.join(tempfile.mkdtemp(), 'evil_file') + self.master_opts = AdaptedConfigurationTestCaseMixin.get_config('master') + + def tearDown(self): + try: + os.remove(self.tmpfile + 'x') + except OSError: + pass + delattr(self, 'master') + delattr(self, 'ret_port') + delattr(self, 'tmpfile') + + def test_pub_not_allowed(self): + assert hasattr(salt.master.ClearFuncs, '_send_pub') + assert not os.path.exists(self.tmpfile) + minion_config = { + 'transport': 'zeromq', + 'pki_dir': '/tmp', + 'id': 'root', + 'master_ip': self.master, + 'master_port': self.ret_port, + 'auth_timeout': 5, + 'auth_tries': 1, + 'master_uri': 'tcp://{0}:{1}'.format(self.master, self.ret_port), + } + clear_channel = salt.transport.client.ReqChannel.factory( + minion_config, crypt='clear') + jid = '202003100000000001' + msg = { + 'cmd': '_send_pub', + 'fun': 'file.write', + 'jid': jid, + 'arg': [self.tmpfile, 'evil contents'], + 'kwargs': {'show_jid': False, 'show_timeout': False}, + 'ret': '', + 'tgt': 'minion', + 'tgt_type': 'glob', + 'user': 'root' + } + eventbus = salt.utils.event.get_event( + 'master', + sock_dir=self.master_opts['sock_dir'], + transport=self.master_opts['transport'], + opts=self.master_opts) + ret = clear_channel.send(msg, timeout=15) + if salt.utils.platform.is_windows(): + time.sleep(30) + timeout = 30 + else: + timeout = 5 + ret_evt = None + start = time.time() + while time.time() - start <= timeout: + raw = eventbus.get_event(timeout, auto_reconnect=True) + if raw and 'jid' in raw and raw['jid'] == jid: + ret_evt = raw + break + assert not os.path.exists(self.tmpfile), 'Evil file created' + + +class ClearFuncsConfigTest(TestCase): + + def setUp(self): + master_opts = AdaptedConfigurationTestCaseMixin.get_config('master') + self.conf_dir = os.path.dirname(master_opts['conf_file']) + master = '127.0.0.1' + ret_port = 64506 + user = keyuser() + keyfile = '.{}_key'.format(user) + keypath = os.path.join(RUNTIME_VARS.TMP, 'rootdir', 'cache', keyfile) + + with salt.utils.files.fopen(keypath) as keyfd: + self.key = keyfd.read() + + self.minion_config = { + 'transport': 'zeromq', + 'pki_dir': '/tmp', + 'id': 'root', + 'master_ip': master, + 'master_port': ret_port, + 'auth_timeout': 5, + 'auth_tries': 1, + 'master_uri': 'tcp://{0}:{1}'.format(master, ret_port) + } + + def tearDown(self): + try: + os.remove(os.path.join(self.conf_dir, 'evil.conf')) + except OSError: + pass + delattr(self, 'conf_dir') + delattr(self, 'key') + delattr(self, 'minion_config') + + def test_clearfuncs_config(self): + clear_channel = salt.transport.client.ReqChannel.factory( + self.minion_config, crypt='clear') + + msg = { + 'key': self.key, + 'cmd': 'wheel', + 'fun': 'config.update_config', + 'file_name': '../evil', + 'yaml_contents': 'win', + } + ret = clear_channel.send(msg, timeout=5) + assert not os.path.exists(os.path.join(self.conf_dir, 'evil.conf')), \ + 'Wrote file via directory traversal' + + +class ClearFuncsFileRoots(TestCase): + + def setUp(self): + self.master_opts = AdaptedConfigurationTestCaseMixin.get_config('master') + self.target_dir = os.path.dirname( + self.master_opts['file_roots']['base'][0] + ) + master = '127.0.0.1' + ret_port = 64506 + user = keyuser() + self.keyfile = '.{}_key'.format(user) + keypath = os.path.join(RUNTIME_VARS.TMP, 'rootdir', 'cache', self.keyfile) + + with salt.utils.files.fopen(keypath) as keyfd: + self.key = keyfd.read() + + self.minion_config = { + 'transport': 'zeromq', + 'pki_dir': '/tmp', + 'id': 'root', + 'master_ip': master, + 'master_port': ret_port, + 'auth_timeout': 5, + 'auth_tries': 1, + 'master_uri': 'tcp://{0}:{1}'.format(master, ret_port) + } + + def tearDown(self): + try: + os.remove(os.path.join(self.target_dir, 'pwn.txt')) + except OSError: + pass + delattr(self, 'master_opts') + delattr(self, 'target_dir') + delattr(self, 'keyfile') + delattr(self, 'key') + delattr(self, 'minion_config') + + def test_fileroots_write(self): + clear_channel = salt.transport.client.ReqChannel.factory( + self.minion_config, crypt='clear') + + msg = { + 'key': self.key, + 'cmd': 'wheel', + 'fun': 'file_roots.write', + 'data': 'win', + 'path': os.path.join('..', 'pwn.txt'), + 'saltenv': 'base', + } + ret = clear_channel.send(msg, timeout=5) + assert not os.path.exists(os.path.join(self.target_dir, 'pwn.txt')), \ + 'Wrote file via directory traversal' + + def test_fileroots_read(self): + rootdir = self.master_opts['root_dir'] + fileroot = self.master_opts['file_roots']['base'][0] + # If this asserion fails the test may need to be re-written + assert os.path.dirname(os.path.dirname(rootdir)) == os.path.dirname(fileroot) + clear_channel = salt.transport.client.ReqChannel.factory( + self.minion_config, crypt='clear') + readpath = os.path.join( + '..', + 'salt-tests-tmpdir', + 'rootdir', + 'cache', + self.keyfile, + ) + msg = { + 'key': self.key, + 'cmd': 'wheel', + 'fun': 'file_roots.read', + 'path': os.path.join( + '..', + 'salt-tests-tmpdir', + 'rootdir', + 'cache', + self.keyfile, + ), + 'saltenv': 'base', + } + + ret = clear_channel.send(msg, timeout=5) + try: + # When vulnerable this assertion will fail. + assert list(ret['data']['return'][0].items())[0][1] != self.key, \ + 'Read file via directory traversal' + except IndexError: + pass + # If the vulnerability is fixed, no data will be returned. + assert ret['data']['return'] == [] + + +class ClearFuncsTokenTest(TestCase): + + def setUp(self): + self.master_opts = AdaptedConfigurationTestCaseMixin.get_config('master') + master = '127.0.0.1' + ret_port = 64506 + self.minion_config = { + 'transport': 'zeromq', + 'pki_dir': '/tmp', + 'id': 'root', + 'master_ip': master, + 'master_port': ret_port, + 'auth_timeout': 5, + 'auth_tries': 1, + 'master_uri': 'tcp://{0}:{1}'.format(master, ret_port) + } + + def tearDown(self): + delattr(self, 'master_opts') + delattr(self, 'minion_config') + + def test_token(self): + tokensdir = os.path.join( + self.master_opts['root_dir'], + self.master_opts['cachedir'], + 'tokens' + ) + assert os.path.exists(tokensdir), tokensdir + clear_channel = salt.transport.client.ReqChannel.factory( + self.minion_config, crypt='clear') + msg = { + 'arg': [], + 'cmd': 'get_token', + 'token': os.path.join('..', 'minions', 'minion', 'data.p'), + } + ret = clear_channel.send(msg, timeout=5) + assert 'pillar' not in ret, 'Read minion data via directory traversal' diff --git a/tests/unit/test_master.py b/tests/unit/test_master.py index b7394ffa01d76601d46f99894e8d33f0bb25d114..c730f61594dadcb5743cc8f228fe8c99153c24d7 100644 --- a/tests/unit/test_master.py +++ b/tests/unit/test_master.py @@ -15,6 +15,24 @@ from tests.support.mock import ( ) +class TransportMethodsTest(TestCase): + + def test_transport_methods(self): + + class Foo(salt.master.TransportMethods): + expose_methods = ['bar'] + + def bar(self): + pass + + def bang(self): + pass + + foo = Foo() + assert foo.get_method('bar') is not None + assert foo.get_method('bang') is None + + class ClearFuncsTestCase(TestCase): ''' TestCase for salt.master.ClearFuncs class @@ -24,6 +42,13 @@ class ClearFuncsTestCase(TestCase): opts = salt.config.master_config(None) self.clear_funcs = salt.master.ClearFuncs(opts, {}) + def tearDown(self): + del self.clear_funcs + + def test_get_method(self): + assert getattr(self.clear_funcs, '_send_pub', None) is not None + assert self.clear_funcs.get_method('_send_pub') is None + # runner tests def test_runner_token_not_authenticated(self): diff --git a/tests/unit/test_module_names.py b/tests/unit/test_module_names.py index 8ba2bd85df1bad17202487435e7dd01d00bfa377..7a873d4ec3df22609f47fea490e752c8e8c4b6b0 100644 --- a/tests/unit/test_module_names.py +++ b/tests/unit/test_module_names.py @@ -133,6 +133,7 @@ class BadTestModuleNamesTestCase(TestCase): 'integration.logging.test_jid_logging', 'integration.logging.handlers.test_logstash_mod', 'integration.master.test_event_return', + 'integration.master.test_clear_funcs', 'integration.minion.test_blackout', 'integration.minion.test_pillar', 'integration.minion.test_executor', diff --git a/tests/unit/utils/test_verify.py b/tests/unit/utils/test_verify.py index a90c4192b4bff9d8fc427cd8b3ea515f9b1925de..aa5cf4220dd008eecf36d3eec9304922bb553912 100644 --- a/tests/unit/utils/test_verify.py +++ b/tests/unit/utils/test_verify.py @@ -12,6 +12,7 @@ import stat import shutil import tempfile import socket +import ctypes # Import third party libs if sys.platform.startswith('win'): @@ -45,6 +46,7 @@ from salt.utils.verify import ( verify_log, verify_logs_filter, verify_log_files, + clean_path ) # Import 3rd-party libs @@ -320,3 +322,80 @@ class TestVerifyLog(TestCase): self.assertFalse(os.path.exists(path)) verify_log_files([path], getpass.getuser()) self.assertTrue(os.path.exists(path)) + + +class TestCleanPath(TestCase): + ''' + salt.utils.clean_path works as expected + ''' + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def test_clean_path_valid(self): + path_a = os.path.join(self.tmpdir, 'foo') + path_b = os.path.join(self.tmpdir, 'foo', 'bar') + assert clean_path(path_a, path_b) == path_b + + def test_clean_path_invalid(self): + path_a = os.path.join(self.tmpdir, 'foo') + path_b = os.path.join(self.tmpdir, 'baz', 'bar') + assert clean_path(path_a, path_b) == '' + + +__CSL = None + + +def symlink(source, link_name): + ''' + symlink(source, link_name) Creates a symbolic link pointing to source named + link_name + ''' + global __CSL + if __CSL is None: + csl = ctypes.windll.kernel32.CreateSymbolicLinkW + csl.argtypes = (ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_uint32) + csl.restype = ctypes.c_ubyte + __CSL = csl + flags = 0 + if source is not None and os.path.isdir(source): + flags = 1 + if __CSL(link_name, source, flags) == 0: + raise ctypes.WinError() + + +@skipIf(six.PY2 and salt.utils.platform.is_windows(), 'Skipped on windows py2') +class TestCleanPathLink(TestCase): + ''' + Ensure salt.utils.clean_path works with symlinked directories and files + ''' + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.to_path = os.path.join(self.tmpdir, 'linkto') + self.from_path = os.path.join(self.tmpdir, 'linkfrom') + if six.PY2 or salt.utils.platform.is_windows(): + kwargs = {} + else: + kwargs = {'target_is_directory': True} + if salt.utils.platform.is_windows(): + symlink(self.to_path, self.from_path, **kwargs) + else: + os.symlink(self.to_path, self.from_path, **kwargs) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def test_clean_path_symlinked_src(self): + test_path = os.path.join(self.from_path, 'test') + expect_path = os.path.join(self.to_path, 'test') + ret = clean_path(self.from_path, test_path) + assert ret == expect_path, "{} is not {}".format(ret, expect_path) + + def test_clean_path_symlinked_tgt(self): + test_path = os.path.join(self.to_path, 'test') + expect_path = os.path.join(self.to_path, 'test') + ret = clean_path(self.from_path, test_path) + assert ret == expect_path, "{} is not {}".format(ret, expect_path) diff --git a/tests/whitelist.txt b/tests/whitelist.txt index 9e63fa5e0aac49b7928cc119047e89914fca5f08..30fddb0e2446011eca987c84fc9c40ba3ca0912f 100644 --- a/tests/whitelist.txt +++ b/tests/whitelist.txt @@ -10,6 +10,7 @@ integration.grains.test_core integration.grains.test_custom integration.loader.test_ext_grains integration.loader.test_ext_modules +integration.master.test_clear_funcs integration.minion.test_blackout integration.minion.test_pillar integration.minion.test_timeout -- 2.23.0
Locations
Projects
Search
Status Monitor
Help
OpenBuildService.org
Documentation
API Documentation
Code of Conduct
Contact
Support
@OBShq
Terms
openSUSE Build Service is sponsored by
The Open Build Service is an
openSUSE project
.
Sign Up
Log In
Places
Places
All Projects
Status Monitor