diff --git a/SOURCES/ansible-core-2.14.18-CVE-2026-11332.patch b/SOURCES/ansible-core-2.14.18-CVE-2026-11332.patch new file mode 100644 index 0000000..f540e46 --- /dev/null +++ b/SOURCES/ansible-core-2.14.18-CVE-2026-11332.patch @@ -0,0 +1,96 @@ +From b4ddf456ecbe8256881585e4e668f27d634532fd Mon Sep 17 00:00:00 2001 +From: Sloane Hertel <19572925+s-hertel@users.noreply.github.com> +Date: Fri, 5 Jun 2026 18:38:56 -0400 +Subject: [PATCH] Fix CVE-2026-11332 - prevent role requirements from + configuring git (#87070) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +* Pass malformed role requirements as positional arguments to prevent arbitrary git configuration + +* Add test coverage, checking for specific errors and that git clone is always followed by -- + +Co-authored-by: 🇺🇦 Sviatoslav Sydorenko (Святослав Сидоренко) +--- + lib/ansible/utils/galaxy.py | 2 +- + .../tasks/git-config-injection.yml | 47 +++++++++++++++++++ + .../ansible-galaxy-role/tasks/main.yml | 1 + + 3 files changed, 49 insertions(+), 1 deletion(-) + create mode 100644 test/integration/targets/ansible-galaxy-role/tasks/git-config-injection.yml + +diff --git a/lib/ansible/utils/galaxy.py b/lib/ansible/utils/galaxy.py +index bbb26fb111..b463ab0f08 100644 +--- a/lib/ansible/utils/galaxy.py ++++ b/lib/ansible/utils/galaxy.py +@@ -74,7 +74,7 @@ def scm_archive_resource(src, scm='git', name=None, version='HEAD', keep_scm_met + elif scm == 'hg': + clone_cmd.append('--insecure') + +- clone_cmd.extend([src, name]) ++ clone_cmd.extend(['--', src, name]) + + run_scm_cmd(clone_cmd, tempdir) + +diff --git a/test/integration/targets/ansible-galaxy-role/tasks/git-config-injection.yml b/test/integration/targets/ansible-galaxy-role/tasks/git-config-injection.yml +new file mode 100644 +index 0000000000..32ed15c3e6 +--- /dev/null ++++ b/test/integration/targets/ansible-galaxy-role/tasks/git-config-injection.yml +@@ -0,0 +1,47 @@ ++- vars: ++ invalid_git_opts: '-ccore.sshCommand=sh -c "id > {{ remote_tmp_dir }}/role_exe"' ++ # use SSH protocol to test core.sshCommand is not configured ++ dummy_repo: git@github.com:ansible/nosuchrepo.git ++ block: ++ - name: Ensure git is installed ++ package: ++ name: git ++ when: ansible_distribution not in ["MacOSX", "Alpine"] ++ register: git_install ++ ++ - name: Create invalid requirements file ++ copy: ++ dest: "{{ remote_tmp_dir }}/invalid-requirements.yml" ++ content: | ++ - src: {{ invalid_git_opts }} ++ scm: git ++ name: {{ dummy_repo }} ++ - src: {{ dummy_repo }} ++ scm: git ++ name: {{ invalid_git_opts }} ++ ++ - name: Attempt to install invalid role requirements ++ command: ansible-galaxy install -r {{ remote_tmp_dir }}/invalid-requirements.yml --ignore-errors ++ register: result ++ ++ - name: Validate git core.sshCommand did not run ++ stat: ++ path: "{{ remote_tmp_dir }}/role_exe" ++ failed_when: _task.result.stat.exists ++ ++ - name: Verify the invalid field is treated as a single positional argument (repo or dest) ++ assert: ++ that: ++ - result.stderr is search(error1) ++ - result.stderr is search(error2) ++ - (result.stderr | regex_findall("git clone") | length) == (result.stderr | regex_findall("git clone --") | length) == 2 ++ vars: ++ error1: "repository '{{ invalid_git_opts }}' does not exist" ++ error2: "Cloning into '{{ invalid_git_opts }}'" ++ ++ always: ++ - name: Uninstall git if it was installed ++ package: ++ name: git ++ state: absent ++ when: git_install is changed | default(false) +diff --git a/test/integration/targets/ansible-galaxy-role/tasks/main.yml b/test/integration/targets/ansible-galaxy-role/tasks/main.yml +index 5f88a55765..f41273b488 100644 +--- a/test/integration/targets/ansible-galaxy-role/tasks/main.yml ++++ b/test/integration/targets/ansible-galaxy-role/tasks/main.yml +@@ -70,3 +70,4 @@ + + - import_tasks: dir-traversal.yml + - import_tasks: valid-role-symlinks.yml ++- import_tasks: git-config-injection.yml diff --git a/SOURCES/telemetry.patch b/SOURCES/telemetry.patch new file mode 100644 index 0000000..2a8a6fa --- /dev/null +++ b/SOURCES/telemetry.patch @@ -0,0 +1,13 @@ +diff --git a/lib/ansible/plugins/loader.py b/lib/ansible/plugins/loader.py +index 2e8a77e398..fcd941e14e 100644 +--- a/lib/ansible/plugins/loader.py ++++ b/lib/ansible/plugins/loader.py +@@ -1483,7 +1483,7 @@ cache_loader = PluginLoader( + callback_loader = PluginLoader( + 'CallbackModule', + 'ansible.plugins.callback', +- C.DEFAULT_CALLBACK_PLUGIN_PATH, ++ C.DEFAULT_CALLBACK_PLUGIN_PATH + ['/usr/share/ansible/telemetry'], + 'callback_plugins', + ) + diff --git a/SOURCES/telemetry.py b/SOURCES/telemetry.py new file mode 100755 index 0000000..8412b05 --- /dev/null +++ b/SOURCES/telemetry.py @@ -0,0 +1,268 @@ +#!/usr/bin/python3 +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = ''' + name: telemetry + short_description: Track various telemetry related data and store in journald + description: + - Track various telemetry related data and store in journald + type: aggregate + options: + bypass_insights_check: + name: Bypass Insights Check + description: Bypass check to see if insights-client is active + default: false + type: bool + env: + - name: TELEMETRY_BYPASS_INSIGHTS_CHECK + notes: + - The file can also be executed directly from the CLI to retrieve + the stored telemetry data from journald +''' + +import contextlib +import ctypes +import datetime +import functools +import importlib.util +import json +import os +import pathlib +import subprocess +import sys +import uuid +from collections import defaultdict + +if __name__ == '__main__': + # When run as a script via insights-client, these are not needed + CallbackBase = object + Display = None +else: + from ansible.executor.task_result import TaskResult + from ansible.module_utils.common.yaml import yaml_load + from ansible.plugins.callback import CallbackBase + from ansible.plugins.loader import connection_loader + from ansible.release import __version__ as ansible_version + from ansible.utils.display import Display + +journal_sendv = None +try: + from systemd import journal + journal_sendv = journal.sendv +except ImportError: + # When invoked using the non-system python + # the systemd python bindings are not available + with contextlib.suppress(AttributeError, OSError): + _libsysetmd = ctypes.CDLL('libsystemd.so.0') + sd_journal_send = _libsysetmd.sd_journal_send + + def journal_sendv(*args): + b_args = [ctypes.c_char_p(a.encode()) for a in args] + [None] + sd_journal_send(*b_args) + +if sys.version_info >= (3, 10): + from importlib.resources import files +else: + # importlib.resources below Python 3.10 doesn't support + # custom loaders, this is not a full featured replacement + # but suffices for the needs of this plugin + def files(name): + spec = importlib.util.find_spec(name) + if spec is None: + raise ImportError(name) + origin = pathlib.Path(spec.origin) + if origin.name == '__synthetic__': + origin = origin.parent + return origin + +if Display: + display = Display() + +_CORE_SYN_COLLECTIONS = frozenset(( + 'ansible.builtin', + 'ansible.legacy', +)) + +_ANSIBLE_TELEMETRY_MESSAGE_ID = uuid.UUID('73df9e03d8c6473eb811065c4ebc370a') + + +@functools.lru_cache(maxsize=None) +def _get_connection_fqcn(task, host): + connection = host.vars.get('ansible_connection') + if not connection: + connection = task.connection + is_connection_fqcn = '.' in connection + if is_connection_fqcn: + return connection + + return connection_loader.get_with_context( + connection, + class_only=True, + collection_list=task.collections, + ).plugin_load_context.resolved_fqcn + + +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'aggregate' + CALLBACK_NAME = 'telemetry' + + def __init__(self, *args, **kwargs): + self.wants_implicit_tasks = False + self.disabled = False + + self._telemetry = { + 'collections': defaultdict( + lambda: { + 'resources': defaultdict( + lambda: defaultdict(lambda: { + 'count': 0, + }) + ), + 'version': '*', + }, + ), + } + self._hosts = set() + self._conn_cache = {} + + def _is_insights_client_active(self): + if self.get_option('bypass_insights_check'): + return True + + if not os.path.exists('/etc/insights-client/.registered'): + return False + + with contextlib.suppress(subprocess.CalledProcessError): + subprocess.run( + ['systemctl', 'is-active', '--quiet', 'insights-client.timer'], + capture_output=True, + check=True, + ) + return True + + return False + + def _set_disabled(self): + if not self._is_insights_client_active(): + return True + + if journal_sendv is None: + return True + + return False + + def set_options(self, task_keys=None, var_options=None, direct=None): + super().set_options(task_keys=task_keys, var_options=var_options, direct=direct) + self.disabled = self._set_disabled() + + def _handle_result(self, *args, **kwargs): + if not args: + return + + result = args[0] + if not isinstance(result, TaskResult): + return + + host = result._host + task = result._task + role = task._role + + self._hosts.add(host.get_name()) + + action = task.resolved_action + action_collection = None + if action and '.' in action: + action_collection = '.'.join(action.split('.', 2)[:2]) + + role_collection = None + role_name = None if not role else role.get_name() + if role and '.' in role_name: + role_collection = '.'.join(role_name.split('.', 2)[:2]) + + connection_collection = None + connection = _get_connection_fqcn(task, host) + if '.' in connection: + connection_collection = '.'.join(connection.split('.')[:2]) + + collections = self._telemetry['collections'] + for resource, collection, name in (('action', action_collection, action), + ('role', role_collection, role_name), + ('connection', connection_collection, connection)): + if not collection: + continue + + collections[collection]['resources'][resource][name]['count'] += 1 + + # v2_on_file_diff = _handle_result + v2_runner_item_on_failed = _handle_result + v2_runner_item_on_ok = _handle_result + v2_runner_item_on_skipped = _handle_result + v2_runner_on_async_failed = _handle_result + v2_runner_on_async_ok = _handle_result + v2_runner_on_async_poll = _handle_result + v2_runner_on_failed = _handle_result + v2_runner_on_ok = _handle_result + v2_runner_on_skipped = _handle_result + v2_runner_on_unreachable = _handle_result + v2_runner_retry = _handle_result + + def v2_playbook_on_stats(self, stats): + self._telemetry['ansible_core'] = { + 'version': ansible_version, + } + self._telemetry['hosts'] = { + 'count': len(self._hosts), + } + + for collection in self._telemetry['collections']: + if collection in _CORE_SYN_COLLECTIONS: + continue + cpath = files('ansible_collections.%s' % collection) + for candidate in ('METADATA.json', 'galaxy.yml'): + mdfile = cpath.joinpath(candidate) + if mdfile.exists(): + self._telemetry['collections'][collection]['version'] = yaml_load( + mdfile.read_text() + ).get('version', '*') + break + + with contextlib.suppress(Exception): + self._write_journal() + + def _write_journal(self): + journal_sendv( + 'MESSAGE=%s' % json.dumps(self._telemetry, separators=(',', ':')), + 'MESSAGE_ID=%s' % _ANSIBLE_TELEMETRY_MESSAGE_ID.hex, + 'ANSIBLE_TELEMETRY=core', + 'ANSIBLE_TELEMETRY_ID=%s' % uuid.uuid4().hex, + ) + + +@contextlib.contextmanager +def _get_journal_reader(seek=None): + with journal.Reader() as j: + if seek: + j.seek_realtime(seek) + j.add_match('MESSAGE_ID=%s' % _ANSIBLE_TELEMETRY_MESSAGE_ID.hex) + j.add_match('ANSIBLE_TELEMETRY=core') + yield j + + +def main(): + lastupload_file = pathlib.Path('/etc/insights-client/.lastupload') + lastupload = None + with contextlib.suppress(ValueError, OSError): + lastupload = datetime.datetime.strptime( + lastupload_file.read_text(), + '%Y-%m-%dT%H:%M:%S.%f' + ) + + with _get_journal_reader(lastupload) as j: + for entry in j: + print(entry['MESSAGE']) + + +if __name__ == '__main__': + main() diff --git a/SPECS/ansible-core.spec b/SPECS/ansible-core.spec index 4563afc..ce677d5 100644 --- a/SPECS/ansible-core.spec +++ b/SPECS/ansible-core.spec @@ -29,7 +29,7 @@ Name: ansible-core Summary: SSH-based configuration management, deployment, and task execution system Epoch: 1 Version: 2.14.18 -Release: 1%{?dist} +Release: 3%{?dist}.1 Group: Development/Libraries License: GPLv3+ @@ -43,6 +43,14 @@ Source4: https://files.pythonhosted.org/packages/source/M/MarkupSafe/MarkupSafe- Patch0: remove-bundled-deps-from-requirements.patch +# https://github.com/ansible/ansible/commit/edee59aa15abcc74d920bb3e9c3835ab8db05a2f +Patch1: ansible-core-2.14.18-CVE-2026-11332.patch + +%if 0%{!?centos:1} && 0%{?rhel} +Source99: telemetry.py +Patch99: telemetry.patch +%endif + URL: http://ansible.com # We obsolete old ansible, and any version of ansible-base. @@ -104,10 +112,16 @@ developed for ansible. %prep %setup -q -b1 -b3 -b4 -n ansible_core-%{version} %patch0 -p1 +%patch1 -p1 # Fix all Python shebangs recursively in ansible-test %{py3_shebang_fix} test/lib/ansible_test +%if 0%{!?centos:1} && 0%{?rhel} +%patch99 -p1 +%{py3_shebang_fix} %{SOURCE99} +%endif + %build %{py3_build} @@ -159,6 +173,12 @@ mkdir -p %{buildroot}%{_sysconfdir}/ansible/roles/ cp ../ansible-documentation-%{doc_version}/examples/hosts %{buildroot}%{_sysconfdir}/ansible/ cp ../ansible-documentation-%{doc_version}/examples/ansible.cfg %{buildroot}%{_sysconfdir}/ansible/ +%if 0%{!?centos:1} && 0%{?rhel} +mkdir -p %{buildroot}%{_datadir}/ansible/telemetry +cp %{SOURCE99} %{buildroot}%{_datadir}/ansible/telemetry/ +%py_byte_compile %{__python3} %{buildroot}%{_datadir}/ansible/telemetry/telemetry.py +%endif + mkdir -p %{buildroot}/%{_mandir}/man1/ mkdir -p docs/man/man1 @@ -191,6 +211,16 @@ strip --strip-unneeded %{vendor_path}/markupsafe/_speedups%{python3_ext_suffix} %changelog +* Sat Jul 11 2026 RHEL Packaging Agent - 1:2.14.18-3.1 +- Fix CVE-2026-11332 (prevent arbitrary git configuration via role + requirements) (RHEL-194134) + +* Tue Feb 10 2026 Dimitri Savineau - 1:2.14.18-3 +- Fix selinux AVC denial when telemetry is enabled (RHEL-148293) + +* Thu Oct 30 2025 Dimitri Savineau - 1:2.14.18-2 +- Add telemetry for RHEL (RHEL-123003) + * Fri Jan 03 2025 Dimitri Savineau - 1:2.14.18-1 - ansible-core 2.14.18 release (RHEL-69086) - Fix license file path