Package: release.debian.org Severity: normal Tags: trixie X-Debbugs-Cc: [email protected] Control: affects -1 + src:ironic-python-agent User: [email protected] Usertags: pu
Hi, I've uploaded ironic-python-agent/10.2.0-3+deb13u1 to address the below CVEs: [ Reason ] CVE-2026-66138 / OSSA-2026-027: Command execution via unsanitized config CVE-2026-54422 / OSSA-2026-028: Credential extraction from Ironic Python Agent via malicious container CVE-2026-43003: Command injection via chroot execution of tenant-controlled binaries. [ Impact ] The above CVEs. (What is the impact for the user if the update isn't approved?) [ Tests ] The upstream patches include new tests, all of them ran at build time, as always in OpenStack. [ Risks ] This agent runs on nodes when they boot up in a live system, so the risks for our users is minimal. Worst case, we break enroling new nodes, and we address this by a later upload, though running nodes aren't affected. [ Checklist ] [x] *all* changes are documented in the d/changelog [x] I reviewed all changes and I approve them [x] attach debdiff against the package in (old)stable [x] the issue is verified as fixed in unstable [ Changes ] All the 3 CVE patches, plus another patch removing some broken tests. Please approve the upload of ironic-python-agent/10.2.0-3+deb13u1. Cheers, Thomas Goirand (zigo)
diff -Nru ironic-python-agent-10.2.0/debian/changelog ironic-python-agent-10.2.0/debian/changelog --- ironic-python-agent-10.2.0/debian/changelog 2025-04-01 15:48:41.000000000 +0200 +++ ironic-python-agent-10.2.0/debian/changelog 2026-11-09 17:49:45.000000000 +0100 @@ -1,3 +1,16 @@ +ironic-python-agent (10.2.0-3+deb13u1) trixie; urgency=high + + * CVE-2026-43003: Command injection via chroot execution of tenant-controlled + binaries. Applied upstream patch: "Add a flag to disable installing + bootloaders" (Closes: #1135646). + * CVE-2026-66138 / OSSA-2026-027: command execution via unsanitized config. + Applied upstream patch: "fix NTP command handling" (Closes: #1142857). + * CVE-2026-54422 / OSSA-2026-028: credential extraction from Ironic Python + Agent via malicious container. Applied upstream patch: "Do not expose + registry pull secret to bootc container" (Closes: #1142943). + + -- Thomas Goirand <[email protected]> Mon, 09 Nov 2026 17:49:45 +0100 + ironic-python-agent (10.2.0-3) unstable; urgency=medium * Blacklist TestStandbyExtension.test_execute_bootc_install that is failing diff -Nru ironic-python-agent-10.2.0/debian/patches/CVE-2026-43003_Add_a_flag_to_disable_installing_bootloaders.patch ironic-python-agent-10.2.0/debian/patches/CVE-2026-43003_Add_a_flag_to_disable_installing_bootloaders.patch --- ironic-python-agent-10.2.0/debian/patches/CVE-2026-43003_Add_a_flag_to_disable_installing_bootloaders.patch 1970-01-01 01:00:00.000000000 +0100 +++ ironic-python-agent-10.2.0/debian/patches/CVE-2026-43003_Add_a_flag_to_disable_installing_bootloaders.patch 2026-11-09 17:49:45.000000000 +0100 @@ -0,0 +1,155 @@ +Author: Clif Houck <[email protected]> +Date: Wed, 15 Apr 2026 11:54:05 -0500 +Description: Add a flag to disable installing bootloaders + Disables bootloader installations (calls to grub-install) + for security reasons as part of the mitigation for CVE-2026-43003. + . + Back-port only change: Default enable_bios_bootloader_install to true in + order to not break functionality for older versions. This mirrors the + change on ironic's side. +Depends-On: https://review.opendev.org/c/openstack/ironic/+/990724 +Bug: https://launchpad.net/bugs/2148310 +Change-Id: I10c88426d5838820ecf6853dca5b3878dc29bdf4 +Signed-off-by: Clif Houck <[email protected]> +Signed-off-by: Jay Faulkner <[email protected]> +Origin: upstream, https://review.opendev.org/c/openstack/ironic-python-agent/+/993024 +Last-Update: 2026-06-18 + +Index: ironic-python-agent/ironic_python_agent/agent.py +=================================================================== +--- ironic-python-agent.orig/ironic_python_agent/agent.py ++++ ironic-python-agent/ironic_python_agent/agent.py +@@ -455,6 +455,9 @@ class IronicPythonAgent(base.ExecuteComm + + # Update config with values from Ironic + config = content.get('config', {}) ++ if config.get('enable_bios_bootloader_install'): ++ cfg.CONF.set_override('enable_bios_bootloader_install', ++ config['enable_bios_bootloader_install']) + if config.get('metrics'): + for opt, val in config.items(): + setattr(cfg.CONF.metrics, opt, val) +Index: ironic-python-agent/ironic_python_agent/config.py +=================================================================== +--- ironic-python-agent.orig/ironic_python_agent/config.py ++++ ironic-python-agent/ironic_python_agent/config.py +@@ -393,6 +393,13 @@ cli_opts = [ + help='This disables bootc deployment methods in the ramdisk ' + 'because the bootc command inside of the ramdisk ' + 'comes from the supplied image to be deployed.'), ++ cfg.BoolOpt('enable_bios_bootloader_install', ++ default=True, ++ help='Enables support for partition images which require a ' ++ 'legacy bootloader -- and a call to ``grub-install``. ' ++ 'Generally, this should remain disabled for maximum ' ++ 'security, however, this option allows it to be ' ++ 're-enabled for compatibility.'), + ] + + disk_utils_opts = [ +Index: ironic-python-agent/ironic_python_agent/extensions/image.py +=================================================================== +--- ironic-python-agent.orig/ironic_python_agent/extensions/image.py ++++ ironic-python-agent/ironic_python_agent/extensions/image.py +@@ -715,15 +715,24 @@ class ImageExtension(base.BaseAgentExten + ' Assuming a whole disk image') + return + +- # In case we can't use efibootmgr for uefi we will continue using grub2 +- LOG.debug('Using grub2-install to set up boot files') +- try: +- _install_grub2(device, +- root_uuid=root_uuid, +- efi_system_part_uuid=efi_system_part_uuid, +- prep_boot_part_uuid=prep_boot_part_uuid, +- target_boot_mode=target_boot_mode) +- except Exception as e: +- LOG.error('Error setting up bootloader. Error %s', e) ++ if CONF.enable_bios_bootloader_install: ++ # In case we can't use efibootmgr for uefi we will continue ++ # using grub2 ++ LOG.debug('Using grub2-install to set up boot files') ++ try: ++ _install_grub2(device, ++ root_uuid=root_uuid, ++ efi_system_part_uuid=efi_system_part_uuid, ++ prep_boot_part_uuid=prep_boot_part_uuid, ++ target_boot_mode=target_boot_mode) ++ except Exception as e: ++ LOG.error('Error setting up bootloader. Error %s', e) ++ if not ignore_failure: ++ raise ++ else: ++ msg = ("Install of legacy BIOS bootloaders disabled by " ++ "CONF.enable_bios_bootloader_install as part of " ++ "CVE-2026-43003 mitigation.") ++ LOG.error(msg) + if not ignore_failure: +- raise ++ raise errors.InvalidImage(details=msg) +Index: ironic-python-agent/ironic_python_agent/tests/unit/extensions/test_image.py +=================================================================== +--- ironic-python-agent.orig/ironic_python_agent/tests/unit/extensions/test_image.py ++++ ironic-python-agent/ironic_python_agent/tests/unit/extensions/test_image.py +@@ -52,6 +52,7 @@ class TestImageExtension(base.IronicAgen + self.fake_efi_system_part_uuid = '45AB-2312' + self.fake_prep_boot_part_uuid = '76937797-3253-8843-999999999999' + self.fake_dir = '/tmp/fake-dir' ++ self.config(enable_bios_bootloader_install=True) + + @mock.patch.object(image, '_install_grub2', autospec=True) + def test__install_bootloader_bios(self, mock_grub2, +@@ -68,8 +69,39 @@ class TestImageExtension(base.IronicAgen + self.fake_dev, root_uuid=self.fake_root_uuid, + efi_system_part_uuid=None, prep_boot_part_uuid=None, + target_boot_mode='bios' ++ + ) + ++ @mock.patch.object(image, '_install_grub2', autospec=True) ++ def test__install_bootloader_bios_disabled(self, mock_grub2, ++ mock_execute, mock_dispatch): ++ self.config(enable_bios_bootloader_install=False) ++ mock_dispatch.side_effect = [ ++ self.fake_dev, hardware.BootInfo(current_boot_mode='bios') ++ ] ++ self.agent_extension.install_bootloader( ++ root_uuid=self.fake_root_uuid).join() ++ mock_dispatch.assert_any_call('get_os_install_device') ++ mock_dispatch.assert_any_call('get_boot_info') ++ self.assertEqual(2, mock_dispatch.call_count) ++ mock_grub2.assert_not_called() ++ ++ @mock.patch.object(image, '_install_grub2', autospec=True) ++ def test__install_bootloader_bios_disabled_dont_ignore_failures( ++ self, mock_grub2, mock_execute, mock_dispatch): ++ self.config(enable_bios_bootloader_install=False) ++ self.config(ignore_bootloader_failure=False) ++ mock_dispatch.side_effect = [ ++ self.fake_dev, hardware.BootInfo(current_boot_mode='bios') ++ ] ++ result = self.agent_extension.install_bootloader( ++ root_uuid=self.fake_root_uuid).join() ++ mock_dispatch.assert_any_call('get_os_install_device') ++ mock_dispatch.assert_any_call('get_boot_info') ++ self.assertEqual(2, mock_dispatch.call_count) ++ self.assertIsNotNone(result.command_error) ++ mock_grub2.assert_not_called() ++ + @mock.patch.object(efi_utils, 'manage_uefi', autospec=True) + @mock.patch.object(image, '_install_grub2', autospec=True) + def test__install_bootloader_uefi(self, mock_grub2, mock_uefi, +Index: ironic-python-agent/releasenotes/notes/disable-installing-bootloaders-by-default-1029ed4faaf049aa.yaml +=================================================================== +--- /dev/null ++++ ironic-python-agent/releasenotes/notes/disable-installing-bootloaders-by-default-1029ed4faaf049aa.yaml +@@ -0,0 +1,9 @@ ++--- ++security: ++ - | ++ Disable installation of bootloaders (via grub-install) by default in order ++ to improve security posture by adding a new configuration option ++ `enable_bios_bootloader_install` which defaults to `False`. Operators ++ who still need this functionality can re-enable installation of ++ bootloaders by setting `enable_bios_bootloader_install` to `True`. ++ Addresses CVE-2026-43003. diff -Nru ironic-python-agent-10.2.0/debian/patches/CVE-2026-54422-OSSA-2026-028_Do_not_expose_registry_pull_secret_to_bootc_container.patch ironic-python-agent-10.2.0/debian/patches/CVE-2026-54422-OSSA-2026-028_Do_not_expose_registry_pull_secret_to_bootc_container.patch --- ironic-python-agent-10.2.0/debian/patches/CVE-2026-54422-OSSA-2026-028_Do_not_expose_registry_pull_secret_to_bootc_container.patch 1970-01-01 01:00:00.000000000 +0100 +++ ironic-python-agent-10.2.0/debian/patches/CVE-2026-54422-OSSA-2026-028_Do_not_expose_registry_pull_secret_to_bootc_container.patch 2026-11-09 17:49:45.000000000 +0100 @@ -0,0 +1,614 @@ +Author: Steve Baker <[email protected]> +Date: Thu, 11 Jun 2026 22:39:18 +0000 +Description: CVE-2026-54422-OSSA-2026-028: Do not expose registry pull secret to bootc container + During bootc deployment, the registry pull secret (authfile) was + written to a well-known path on the host and passed to + 'podman run --privileged --pid=host --authfile=...'. Because the + container runs with host PID namespace visibility, tenant-controlled + code in the bootc image could read the credential via + /proc/1/root/<authfile-path>. + . + When the pull secret originates from an operator/admin source + (driver_info.image_pull_secret, [deploy] image_server_user/password, + or [oci] authentication_config) rather than the tenant's own + instance_info.image_pull_secret, this constitutes a cross-boundary + credential disclosure: a project-scoped node owner member recovers + the operator's registry credential, which may be shared across + nodes, projects, or the entire conductor. + . + Fix by splitting the authenticated image pull from the container + execution: + . + 1. Write the auth file to a random temporary path (not the + well-known ~/.config/containers/auth.json). + 2. Run 'podman pull --authfile=<tmpfile>' to fetch and cache the + image in the local store. + 3. Delete the auth file in a finally block, guaranteeing cleanup + even on pull failure. + 4. Run 'podman run' without --authfile. The image is already + cached locally and --skip-fetch-check is already in use, so + no registry credential is needed at runtime. + ยท + By the time tenant container code executes, the credential file + no longer exists on the host filesystem and cannot be read via + /proc/1/root. + . + In regards to merge conflicts, there was a conflict on this + cherry-pick to tests/unit/extensions/test_standby.py where + git attempted to pull in a neighboring test for selinux support + which didn't exist yet in this backport branch. +Bug: https://launchpad.net/bugs/2155826 +Bug-Debian: https://bugs.debian.org/1142943 +Assisted-By: Opencode - Claude Opus 4.6 +Signed-off-by: Steve Baker <[email protected]> +Signed-off-by: Julia Kreger <[email protected]> +Change-Id: I595ba9bdc305ea928ac2f6e26ff24338dbd03450 +Origin: upstream, https://review.opendev.org/c/openstack/ironic-python-agent/+/998485 +Last-Update: 2026-07-28 + +Index: ironic-python-agent/ironic_python_agent/extensions/standby.py +=================================================================== +--- ironic-python-agent.orig/ironic_python_agent/extensions/standby.py ++++ ironic-python-agent/ironic_python_agent/extensions/standby.py +@@ -1227,8 +1227,24 @@ class StandbyExtension(base.BaseAgentExt + # This works because the path is maintained. + container_url = url.netloc + url.path + ++ # Always pull the image in a separate step so that ++ # ``podman run`` uses the locally cached image and does ++ # not need an ``--authfile`` flag. When a pull secret is ++ # present the auth file is written to a temporary path, ++ # passed to ``podman pull``, and then deleted before any ++ # tenant-controlled container code can run. This prevents ++ # the tenant bootc image from reading the credential via ++ # /proc/1/root/<path> when running with --pid=host. ++ # This fixes the security issue tracked by: ++ # https://bugs.launchpad.net/ironic/+bug/2155826 + if pull_secret: +- self._write_container_auth(pull_secret, url.netloc) ++ auth_path = self._write_container_auth(pull_secret, url.netloc) ++ try: ++ self._pull_container_image(container_url, auth_path) ++ finally: ++ self._cleanup_container_auth(auth_path) ++ else: ++ self._pull_container_image(container_url) + + # Get the disk size, and convert it to megabtyes. + disk_size = disk_utils.get_dev_byte_size(device) // 1024 // 1024 +@@ -1264,20 +1280,19 @@ class StandbyExtension(base.BaseAgentExt + # but that is not the best idea. + # --disable-selinux is alternatively needed if selinux is *not* + # enabled on the host. ++ # ++ # NOTE: The image has already been pulled above (with auth if ++ # needed), so we intentionally do NOT pass --authfile here. ++ # This prevents tenant-controlled code in the bootc image ++ # from accessing registry credentials via /proc/1/root. + command = [ + 'podman', + '--log-level=debug', + 'run', '--rm', '--privileged', '--pid=host', ++ '--pull=never', + '-v', '/var/lib/containers:/var/lib/containers', + '-v', '/dev:/dev', +- # By default, podman's retry starts at 3s and extends +- # expentionally, which can lead to podman appearing +- # to hang when downloading. This pins it so it just +- # retires in relatively short order. +- '--retry-delay=5s', + ] +- if pull_secret: +- command.append('--authfile=/root/.config/containers/auth.json') + if authorized_keys: + # NOTE(TheJulia): Bandit flags on this, but we need a folder which + # should exist in the container *and* locally to the ramdisk. +@@ -1337,6 +1352,58 @@ class StandbyExtension(base.BaseAgentExt + with open(file_path, 'w') as file: + file.write(file_content) + ++ def _pull_container_image(self, container_url, auth_path=None): ++ """Pre-pull a container image into the local podman store. ++ ++ Pulls the image so that a subsequent ``podman run`` can use ++ the locally cached image without needing an ``--authfile`` ++ flag, keeping registry credentials out of the tenant-visible ++ host filesystem during container execution. ++ ++ :param container_url: The container image URL to pull. ++ :param auth_path: Optional path to an authentication JSON ++ file. When provided, ``--authfile`` is passed to podman. ++ :raises: ImageDownloadError if the pull fails. ++ """ ++ pull_command = [ ++ 'podman', 'pull', ++ ] ++ if auth_path: ++ pull_command.append('--authfile=' + auth_path) ++ pull_command.extend([ ++ '--retry-delay=5s', ++ container_url, ++ ]) ++ try: ++ utils.execute(*pull_command, use_standard_locale=True) ++ except processutils.ProcessExecutionError as e: ++ LOG.debug('Failed to pull container image %s: %s', ++ container_url, e) ++ raise errors.ImageDownloadError( ++ container_url, ++ "Failed to pull container image: %s" % e) ++ ++ def _cleanup_container_auth(self, auth_path): ++ """Remove the container registry authentication file. ++ ++ Ensures that the pull secret is not present on the host ++ filesystem when tenant-controlled container code runs. ++ ++ :param auth_path: Path to the authentication JSON file to remove. ++ """ ++ try: ++ os.remove(auth_path) ++ LOG.debug('Removed container auth file %s', auth_path) ++ except OSError as e: ++ LOG.warning('Failed to remove container auth file %s: %s', ++ auth_path, e) ++ # Also try to remove the parent directory if it is empty, ++ # to avoid leaving a trace of the credential path. ++ try: ++ os.rmdir(os.path.dirname(auth_path)) ++ except OSError: ++ pass ++ + def _write_container_auth(self, pull_secret, netloc): + """Write authentication configuration for container registry auth. + +@@ -1344,6 +1411,7 @@ class StandbyExtension(base.BaseAgentExt + interacting with a remote container registry. + :param netloc: The FQDN, or network location portion of the URL + used to access the container registry. ++ :returns: The path to the written authentication file. + """ + # extract secret + decoded_pull_secret = base64.standard_b64decode( +@@ -1355,19 +1423,20 @@ class StandbyExtension(base.BaseAgentExt + auth_dict = { + "auths": {netloc: {"auth": decoded_pull_secret}}} + +- # Make the folders to $HOME/.config/containers/auth.json +- # which would normally be generated by podman login, but +- # we don't need to actually do that as we have a secret. +- # Default to root, as we don't launch IPA with a HOME +- # folder in most cases. +- home = '/root' +- folder = os.path.join(home, '.config/containers') +- os.makedirs(folder, mode=0o700, exist_ok=True) +- auth_path = os.path.join(folder, 'auth.json') +- +- # Save the pull secret +- with open(auth_path, 'w') as file: +- json.dump(auth_dict, file) ++ # Write auth to a temporary file rather than a well-known ++ # path such as $HOME/.config/containers/auth.json. Using a ++ # temporary path provides defense-in-depth: even if cleanup ++ # fails, the credential is not at a predictable location. ++ fd, auth_path = tempfile.mkstemp(suffix='.json', ++ prefix='ipa-container-auth-') ++ try: ++ with os.fdopen(fd, 'w') as file: ++ json.dump(auth_dict, file) ++ except Exception: ++ os.close(fd) ++ raise ++ ++ return auth_path + + def _write_authorized_keys(self, authorized_keys): + """Write a temporary authorized keys file for bootc use.""" +Index: ironic-python-agent/ironic_python_agent/tests/unit/extensions/test_standby.py +=================================================================== +--- ironic-python-agent.orig/ironic_python_agent/tests/unit/extensions/test_standby.py ++++ ironic-python-agent/ironic_python_agent/tests/unit/extensions/test_standby.py +@@ -1834,6 +1834,12 @@ class TestStandbyExtension(base.IronicAg + '_write_authorized_keys', + autospec=True) + @mock.patch.object(standby.StandbyExtension, ++ '_cleanup_container_auth', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, ++ '_pull_container_image', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, + '_write_container_auth', + autospec=True) + @mock.patch.object(standby.StandbyExtension, +@@ -1843,6 +1849,8 @@ class TestStandbyExtension(base.IronicAg + self, + no_pivot_mock, + write_container_auth_mock, ++ pull_container_image_mock, ++ cleanup_container_auth_mock, + write_authorized_keys_mock, + get_size_mock, + execute_mock): +@@ -1851,22 +1859,26 @@ class TestStandbyExtension(base.IronicAg + (('Enforcing\n'), ()), + ((), ())]) + write_authorized_keys_mock.return_value = '/tmp/fake/file' ++ write_container_auth_mock.return_value = '/tmp/ipa-container-auth.json' + self.agent_extension._download_container_and_bootc_install( + 'oci://foo/container', '/dev/fake', 'secret', False, 'keys!') + no_pivot_mock.assert_called_once() + write_container_auth_mock.assert_called_once_with(mock.ANY, + 'secret', + 'foo') ++ pull_container_image_mock.assert_called_once_with( ++ mock.ANY, 'foo/container', '/tmp/ipa-container-auth.json') ++ cleanup_container_auth_mock.assert_called_once_with( ++ mock.ANY, '/tmp/ipa-container-auth.json') + get_size_mock.assert_called_once_with('/dev/fake') + execute_mock.assert_has_calls([ + mock.call('getenforce', use_standard_locale=True), + mock.call( + 'podman', '--log-level=debug', 'run', '--rm', + '--privileged', +- '--pid=host', ++ '--pid=host', '--pull=never', + '-v', '/var/lib/containers:/var/lib/containers', +- '-v', '/dev:/dev', '--retry-delay=5s', +- '--authfile=/root/.config/containers/auth.json', ++ '-v', '/dev:/dev', + '-v', '/tmp:/tmp', '--security-opt', + 'label=type:unconfined_t', 'foo/container', + 'bootc', 'install', 'to-disk', '--wipe', +@@ -1882,6 +1894,12 @@ class TestStandbyExtension(base.IronicAg + '_write_authorized_keys', + autospec=True) + @mock.patch.object(standby.StandbyExtension, ++ '_cleanup_container_auth', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, ++ '_pull_container_image', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, + '_write_container_auth', + autospec=True) + @mock.patch.object(standby.StandbyExtension, +@@ -1891,6 +1909,8 @@ class TestStandbyExtension(base.IronicAg + self, + no_pivot_mock, + write_container_auth_mock, ++ pull_container_image_mock, ++ cleanup_container_auth_mock, + write_authorized_keys_mock, + get_size_mock, + execute_mock): +@@ -1899,22 +1919,26 @@ class TestStandbyExtension(base.IronicAg + (('Enforcing\n'), ()), + ((), ())]) + write_authorized_keys_mock.return_value = '/tmp/fake/file' ++ write_container_auth_mock.return_value = '/tmp/ipa-container-auth.json' + self.agent_extension._download_container_and_bootc_install( + 'oci://foo/container', '/dev/fake', 'secret', True, 'keys!') + no_pivot_mock.assert_called_once() + write_container_auth_mock.assert_called_once_with(mock.ANY, + 'secret', + 'foo') ++ pull_container_image_mock.assert_called_once_with( ++ mock.ANY, 'foo/container', '/tmp/ipa-container-auth.json') ++ cleanup_container_auth_mock.assert_called_once_with( ++ mock.ANY, '/tmp/ipa-container-auth.json') + get_size_mock.assert_called_once_with('/dev/fake') + execute_mock.assert_has_calls([ + mock.call('getenforce', use_standard_locale=True), + mock.call( + 'podman', '--log-level=debug', 'run', '--rm', + '--privileged', +- '--pid=host', ++ '--pid=host', '--pull=never', + '-v', '/var/lib/containers:/var/lib/containers', +- '-v', '/dev:/dev', '--retry-delay=5s', +- '--authfile=/root/.config/containers/auth.json', ++ '-v', '/dev:/dev', + '-v', '/tmp:/tmp', '--security-opt', + 'label=type:unconfined_t', 'foo/container', + 'bootc', 'install', 'to-disk', '--wipe', +@@ -1931,6 +1955,12 @@ class TestStandbyExtension(base.IronicAg + '_write_authorized_keys', + autospec=True) + @mock.patch.object(standby.StandbyExtension, ++ '_cleanup_container_auth', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, ++ '_pull_container_image', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, + '_write_container_auth', + autospec=True) + @mock.patch.object(standby.StandbyExtension, +@@ -1940,6 +1970,8 @@ class TestStandbyExtension(base.IronicAg + self, + no_pivot_mock, + write_container_auth_mock, ++ pull_container_image_mock, ++ cleanup_container_auth_mock, + write_authorized_keys_mock, + get_size_mock, + execute_mock): +@@ -1954,15 +1986,18 @@ class TestStandbyExtension(base.IronicAg + + no_pivot_mock.assert_called_once() + write_container_auth_mock.assert_not_called() ++ pull_container_image_mock.assert_called_once_with( ++ mock.ANY, 'foo/container') ++ cleanup_container_auth_mock.assert_not_called() + get_size_mock.assert_called_once_with('/dev/fake') + execute_mock.assert_has_calls([ + mock.call('getenforce', use_standard_locale=True), + mock.call( + 'podman', '--log-level=debug', 'run', '--rm', + '--privileged', +- '--pid=host', ++ '--pid=host', '--pull=never', + '-v', '/var/lib/containers:/var/lib/containers', +- '-v', '/dev:/dev', '--retry-delay=5s', ++ '-v', '/dev:/dev', + 'foo/container', + 'bootc', 'install', 'to-disk', '--wipe', + '--skip-fetch-check', '--root-size=13537M', +@@ -1977,6 +2012,12 @@ class TestStandbyExtension(base.IronicAg + '_write_authorized_keys', + autospec=True) + @mock.patch.object(standby.StandbyExtension, ++ '_cleanup_container_auth', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, ++ '_pull_container_image', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, + '_write_container_auth', + autospec=True) + @mock.patch.object(standby.StandbyExtension, +@@ -1986,6 +2027,8 @@ class TestStandbyExtension(base.IronicAg + self, + no_pivot_mock, + write_container_auth_mock, ++ pull_container_image_mock, ++ cleanup_container_auth_mock, + write_authorized_keys_mock, + get_size_mock, + execute_mock): +@@ -2004,15 +2047,18 @@ class TestStandbyExtension(base.IronicAg + + no_pivot_mock.assert_called_once() + write_container_auth_mock.assert_not_called() ++ pull_container_image_mock.assert_called_once_with( ++ mock.ANY, 'foo/container') ++ cleanup_container_auth_mock.assert_not_called() + get_size_mock.assert_called_once_with('/dev/fake') + execute_mock.assert_has_calls([ + mock.call('getenforce', use_standard_locale=True), + mock.call( + 'podman', '--log-level=debug', 'run', '--rm', + '--privileged', +- '--pid=host', ++ '--pid=host', '--pull=never', + '-v', '/var/lib/containers:/var/lib/containers', +- '-v', '/dev:/dev', '--retry-delay=5s', ++ '-v', '/dev:/dev', + 'foo/container', + 'bootc', 'install', 'to-disk', '--wipe', + '--skip-fetch-check', '--root-size=13537M', +@@ -2027,6 +2073,12 @@ class TestStandbyExtension(base.IronicAg + '_write_authorized_keys', + autospec=True) + @mock.patch.object(standby.StandbyExtension, ++ '_cleanup_container_auth', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, ++ '_pull_container_image', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, + '_write_container_auth', + autospec=True) + @mock.patch.object(standby.StandbyExtension, +@@ -2036,6 +2088,8 @@ class TestStandbyExtension(base.IronicAg + self, + no_pivot_mock, + write_container_auth_mock, ++ pull_container_image_mock, ++ cleanup_container_auth_mock, + write_authorized_keys_mock, + get_size_mock, + execute_mock): +@@ -2052,15 +2106,18 @@ class TestStandbyExtension(base.IronicAg + 'oci://foo/container', '/dev/fake', None, False, None) + no_pivot_mock.assert_called_once() + write_container_auth_mock.assert_not_called() ++ pull_container_image_mock.assert_called_once_with( ++ mock.ANY, 'foo/container') ++ cleanup_container_auth_mock.assert_not_called() + get_size_mock.assert_called_once_with('/dev/fake') + execute_mock.assert_has_calls([ + mock.call('getenforce', use_standard_locale=True), + mock.call( + 'podman', '--log-level=debug', 'run', '--rm', + '--privileged', +- '--pid=host', ++ '--pid=host', '--pull=never', + '-v', '/var/lib/containers:/var/lib/containers', +- '-v', '/dev:/dev', '--retry-delay=5s', ++ '-v', '/dev:/dev', + 'foo/container', + 'bootc', 'install', 'to-disk', '--wipe', + '--skip-fetch-check', '--root-size=13537M', +@@ -2081,21 +2138,24 @@ class TestStandbyExtension(base.IronicAg + mock_write.assert_called_once_with( + '[engine]\nno_pivot_root = true\n') + +- @mock.patch.object(os, 'makedirs', autospec=True) +- @mock.patch('builtins.open', new_callable=mock.mock_open()) +- def test__write_container_auth(self, mock_open, mkdir_mock): +- self.agent_extension._write_container_auth( ++ @mock.patch.object(tempfile, 'mkstemp', autospec=True) ++ @mock.patch.object(os, 'fdopen', autospec=True) ++ def test__write_container_auth(self, mock_fdopen, mock_mkstemp): ++ mock_mkstemp.return_value = (42, '/tmp/ipa-container-auth-abc.json') ++ mock_file = mock.MagicMock() ++ mock_fdopen.return_value.__enter__ = mock.Mock( ++ return_value=mock_file) ++ mock_fdopen.return_value.__exit__ = mock.Mock(return_value=False) ++ result = self.agent_extension._write_container_auth( + b'c2VjcmV0', 'foo.tld') +- mkdir_mock.assert_called_once_with( +- '/root/.config/containers', +- mode=0o700, +- exist_ok=True) +- mock_open.assert_called_once_with( +- '/root/.config/containers/auth.json', 'w') +- mock_write = mock_open.return_value.__enter__.return_value.write ++ mock_mkstemp.assert_called_once_with(suffix='.json', ++ prefix='ipa-container-auth-') ++ mock_fdopen.assert_called_once_with(42, 'w') ++ self.assertEqual('/tmp/ipa-container-auth-abc.json', result) + # NOTE(TheJulia): This is a side effect of using json.dump to make + # the actual write call, and python internally does buffered io which + # should concatenate the writes together appropriately as needed. ++ mock_write = mock_file.write + mock_write.assert_has_calls([ + mock.call('{'), + mock.call('"auths"'), +@@ -2112,6 +2172,110 @@ class TestStandbyExtension(base.IronicAg + mock.call('}') + ]) + ++ @mock.patch('ironic_python_agent.utils.execute', autospec=True) ++ def test__pull_container_image_with_auth(self, execute_mock): ++ execute_mock.return_value = ((), ()) ++ self.agent_extension._pull_container_image( ++ 'foo/container', '/tmp/auth.json') ++ execute_mock.assert_called_once_with( ++ 'podman', 'pull', ++ '--authfile=/tmp/auth.json', ++ '--retry-delay=5s', ++ 'foo/container', ++ use_standard_locale=True) ++ ++ @mock.patch('ironic_python_agent.utils.execute', autospec=True) ++ def test__pull_container_image_no_auth(self, execute_mock): ++ execute_mock.return_value = ((), ()) ++ self.agent_extension._pull_container_image('foo/container') ++ execute_mock.assert_called_once_with( ++ 'podman', 'pull', ++ '--retry-delay=5s', ++ 'foo/container', ++ use_standard_locale=True) ++ ++ @mock.patch('ironic_python_agent.utils.execute', autospec=True) ++ def test__pull_container_image_error(self, execute_mock): ++ execute_mock.side_effect = processutils.ProcessExecutionError() ++ self.assertRaisesRegex( ++ errors.ImageDownloadError, ++ 'Failed to pull container image', ++ self.agent_extension._pull_container_image, ++ 'foo/container', '/tmp/auth.json') ++ ++ @mock.patch.object(os, 'rmdir', autospec=True) ++ @mock.patch.object(os, 'remove', autospec=True) ++ def test__cleanup_container_auth(self, mock_remove, mock_rmdir): ++ self.agent_extension._cleanup_container_auth( ++ '/tmp/ipa-container-auth-abc.json') ++ mock_remove.assert_called_once_with( ++ '/tmp/ipa-container-auth-abc.json') ++ mock_rmdir.assert_called_once_with('/tmp') ++ ++ @mock.patch.object(os, 'rmdir', autospec=True) ++ @mock.patch.object(os, 'remove', autospec=True) ++ def test__cleanup_container_auth_file_missing(self, mock_remove, ++ mock_rmdir): ++ mock_remove.side_effect = OSError('No such file') ++ # Should not raise even if the file is already gone. ++ self.agent_extension._cleanup_container_auth( ++ '/tmp/ipa-container-auth-abc.json') ++ mock_remove.assert_called_once_with( ++ '/tmp/ipa-container-auth-abc.json') ++ ++ @mock.patch('ironic_python_agent.utils.execute', autospec=True) ++ @mock.patch.object(disk_utils, 'get_dev_byte_size', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, ++ '_write_authorized_keys', ++ autospec=True) ++ @mock.patch.object(standby.StandbyExtension, ++ '_write_no_pivot_root', ++ autospec=True) ++ def test__download_container_and_bootc_install_pull_fail_cleans_auth( ++ self, ++ no_pivot_mock, ++ write_authorized_keys_mock, ++ get_size_mock, ++ execute_mock): ++ """Verify auth file is cleaned up even when pull fails.""" ++ get_size_mock.return_value = 2000000000 ++ # The pull is done via _pull_container_image which calls ++ # utils.execute internally; here we test the real ++ # _write_container_auth / _pull / _cleanup path by only ++ # mocking utils.execute and the file-system bits. ++ with mock.patch.object( ++ standby.StandbyExtension, ++ '_write_container_auth', ++ autospec=True, ++ return_value='/tmp/ipa-container-auth-xyz.json') \ ++ as write_auth_mock, \ ++ mock.patch.object( ++ standby.StandbyExtension, ++ '_pull_container_image', ++ autospec=True, ++ side_effect=errors.ImageDownloadError( ++ 'foo/container', ++ 'pull failed')) \ ++ as pull_mock, \ ++ mock.patch.object( ++ standby.StandbyExtension, ++ '_cleanup_container_auth', ++ autospec=True) \ ++ as cleanup_mock: ++ self.assertRaises( ++ errors.ImageDownloadError, ++ self.agent_extension._download_container_and_bootc_install, ++ 'oci://foo/container', '/dev/fake', 'secret', False, None) ++ write_auth_mock.assert_called_once_with( ++ mock.ANY, 'secret', 'foo') ++ pull_mock.assert_called_once_with( ++ mock.ANY, 'foo/container', ++ '/tmp/ipa-container-auth-xyz.json') ++ # Cleanup must be called even though pull raised. ++ cleanup_mock.assert_called_once_with( ++ mock.ANY, '/tmp/ipa-container-auth-xyz.json') ++ + @mock.patch.object(os, 'close', autospec=True) + @mock.patch.object(os, 'write', autospec=True) + @mock.patch.object(tempfile, 'mkstemp', autospec=True) +Index: ironic-python-agent/releasenotes/notes/bootc-auth-rm-c981559f79ba6315.yaml +=================================================================== +--- /dev/null ++++ ironic-python-agent/releasenotes/notes/bootc-auth-rm-c981559f79ba6315.yaml +@@ -0,0 +1,18 @@ ++--- ++security: ++ - | ++ During bootc deployment, the container registry pull secret was written to ++ a well-known path on the host and passed to ``podman run`` via ++ ``--authfile``. Because the container runs with ``--privileged --pid=host``, ++ tenant-controlled code in the bootc image could read the credential through ++ ``/proc/1/root/<authfile-path>``. When the pull secret originates from an ++ operator or admin source (``driver_info.image_pull_secret``, ++ ``[deploy] image_server_user/password``, or ++ ``[oci] authentication_config``) rather than the tenant's own ++ ``instance_info.image_pull_secret``, this constitutes a cross-boundary ++ credential disclosure to tenant-controlled bootc image code. ++ The image pull is now performed in a separate ``podman pull`` step and the ++ auth file is deleted before the tenant container executes. The subsequent ++ ``podman run`` uses ``--pull=never`` so that no registry credentials are ++ required or present at container runtime. ++ Addresses CVE-2026-54422. +\ No newline at end of file diff -Nru ironic-python-agent-10.2.0/debian/patches/CVE-2026-66138_OSSA-2026-027_fix_NTP_command_handling.patch ironic-python-agent-10.2.0/debian/patches/CVE-2026-66138_OSSA-2026-027_fix_NTP_command_handling.patch --- ironic-python-agent-10.2.0/debian/patches/CVE-2026-66138_OSSA-2026-027_fix_NTP_command_handling.patch 1970-01-01 01:00:00.000000000 +0100 +++ ironic-python-agent-10.2.0/debian/patches/CVE-2026-66138_OSSA-2026-027_fix_NTP_command_handling.patch 2026-11-09 17:49:45.000000000 +0100 @@ -0,0 +1,248 @@ +Author: Julia Kreger <[email protected]> +Date: Tue, 14 Jul 2026 13:13:49 -0700 +Description: security: fix NTP command handling + Adds basic validation of the NTP server parameter to harden the + usage of the NTP server time setting *AND* also uses shlex.quote + to and removes shell=true from the execution invocation to prevent + any possibliity of injecting a malicious command to be executed. +Bug: https://launchpad.net/bugs/2160050 +Bug-Debian: https://bugs.debian.org/1142857 +Assisted-By: Claude Opus 4.6 +Change-Id: I69e85a1c5040066674d90a6e12acc30cec96ba76 +Signed-off-by: Julia Kreger <[email protected]> +Origin: upstream, https://review.opendev.org/c/openstack/ironic-python-agent/+/998490 + +diff --git a/ironic_python_agent/tests/unit/extensions/test_standby.py b/ironic_python_agent/tests/unit/extensions/test_standby.py +index c7a6784..31eacb1 100644 +--- a/ironic_python_agent/tests/unit/extensions/test_standby.py ++++ b/ironic_python_agent/tests/unit/extensions/test_standby.py +@@ -1651,8 +1651,8 @@ + self.agent_extension._sync_clock() + + calls = [mock.call('chronyc', 'shutdown', check_exit_code=[0, 1]), +- mock.call("chronyd -q 'server 192.168.1.1 iburst'", +- shell=True), ++ mock.call('chronyd', '-q', ++ 'server 192.168.1.1 iburst'), + mock.call('hwclock', '-v', '--systohc')] + execute_mock.assert_has_calls(calls) + +diff --git a/ironic_python_agent/tests/unit/test_utils.py b/ironic_python_agent/tests/unit/test_utils.py +index 18a5aa4..fce1f9d 100644 +--- a/ironic_python_agent/tests/unit/test_utils.py ++++ b/ironic_python_agent/tests/unit/test_utils.py +@@ -904,7 +904,7 @@ + utils.sync_clock() + mock_execute.assert_has_calls([ + mock.call('chronyc', 'shutdown', check_exit_code=[0, 1]), +- mock.call("chronyd -q 'server 192.168.1.1 iburst'", shell=True), ++ mock.call('chronyd', '-q', 'server 192.168.1.1 iburst'), + ]) + + @mock.patch.object(utils, 'determine_time_method', autospec=True) +@@ -935,6 +935,126 @@ + utils.sync_clock() + self.assertEqual(0, mock_execute.call_count) + ++ def test_sync_clock_invalid_ntp_server_shell_escape( ++ self, mock_execute): ++ self.config(ntp_server="'; rm -rf /; echo '") ++ self.assertRaisesRegex( ++ errors.CommandExecutionError, ++ 'Invalid NTP server address', ++ utils.sync_clock) ++ mock_execute.assert_not_called() ++ ++ def test_sync_clock_invalid_ntp_server_command_sub( ++ self, mock_execute): ++ self.config(ntp_server='$(reboot)') ++ self.assertRaisesRegex( ++ errors.CommandExecutionError, ++ 'Invalid NTP server address', ++ utils.sync_clock) ++ mock_execute.assert_not_called() ++ ++ def test_sync_clock_invalid_ntp_server_backtick( ++ self, mock_execute): ++ self.config(ntp_server='`reboot`') ++ self.assertRaisesRegex( ++ errors.CommandExecutionError, ++ 'Invalid NTP server address', ++ utils.sync_clock) ++ mock_execute.assert_not_called() ++ ++ def test_sync_clock_invalid_ntp_server_pipe( ++ self, mock_execute): ++ self.config(ntp_server='foo | bar') ++ self.assertRaisesRegex( ++ errors.CommandExecutionError, ++ 'Invalid NTP server address', ++ utils.sync_clock) ++ mock_execute.assert_not_called() ++ ++ def test_sync_clock_invalid_ntp_server_chain( ++ self, mock_execute): ++ self.config(ntp_server='foo && bar') ++ self.assertRaisesRegex( ++ errors.CommandExecutionError, ++ 'Invalid NTP server address', ++ utils.sync_clock) ++ mock_execute.assert_not_called() ++ ++ def test_sync_clock_invalid_ntp_server_space( ++ self, mock_execute): ++ self.config(ntp_server='foo bar') ++ self.assertRaisesRegex( ++ errors.CommandExecutionError, ++ 'Invalid NTP server address', ++ utils.sync_clock) ++ mock_execute.assert_not_called() ++ ++ @mock.patch.object(utils, 'determine_time_method', autospec=True) ++ def test_sync_clock_valid_ipv6(self, mock_time_method, ++ mock_execute): ++ self.config(ntp_server='2001:db8::1') ++ mock_time_method.return_value = 'ntpdate' ++ utils.sync_clock() ++ mock_execute.assert_has_calls( ++ [mock.call('ntpdate', '2001:db8::1')]) ++ ++ @mock.patch.object(utils, 'determine_time_method', autospec=True) ++ def test_sync_clock_valid_hostname(self, mock_time_method, ++ mock_execute): ++ self.config(ntp_server='ntp.example.com') ++ mock_time_method.return_value = 'ntpdate' ++ utils.sync_clock() ++ mock_execute.assert_has_calls( ++ [mock.call('ntpdate', 'ntp.example.com')]) ++ ++ @mock.patch.object(utils, 'determine_time_method', autospec=True) ++ def test_sync_clock_valid_hostname_with_hyphens( ++ self, mock_time_method, mock_execute): ++ self.config(ntp_server='my-ntp_server.example.com') ++ mock_time_method.return_value = 'ntpdate' ++ utils.sync_clock() ++ mock_execute.assert_has_calls( ++ [mock.call('ntpdate', ++ 'my-ntp_server.example.com')]) ++ ++ def test_validate_ntp_server_valid_ipv4(self, mock_execute): ++ utils._validate_ntp_server('192.168.1.1') ++ ++ def test_validate_ntp_server_valid_ipv6(self, mock_execute): ++ utils._validate_ntp_server('2001:db8::1') ++ ++ def test_validate_ntp_server_valid_hostname(self, ++ mock_execute): ++ utils._validate_ntp_server('ntp.example.com') ++ ++ def test_validate_ntp_server_rejects_semicolon( ++ self, mock_execute): ++ self.assertRaises( ++ errors.CommandExecutionError, ++ utils._validate_ntp_server, ++ "'; rm -rf /; echo '") ++ ++ def test_validate_ntp_server_rejects_dollar(self, ++ mock_execute): ++ self.assertRaises( ++ errors.CommandExecutionError, ++ utils._validate_ntp_server, ++ '$(reboot)') ++ ++ def test_validate_ntp_server_rejects_backtick( ++ self, mock_execute): ++ self.assertRaises( ++ errors.CommandExecutionError, ++ utils._validate_ntp_server, ++ '`reboot`') ++ ++ def test_validate_ntp_server_rejects_space(self, ++ mock_execute): ++ self.assertRaises( ++ errors.CommandExecutionError, ++ utils._validate_ntp_server, ++ 'foo bar') ++ + + @mock.patch.object(utils, '_unmount_any_config_drives', autospec=True) + @mock.patch.object(utils, '_booted_from_vmedia', autospec=True) +diff --git a/ironic_python_agent/utils.py b/ironic_python_agent/utils.py +index ee955c5..210d62e 100644 +--- a/ironic_python_agent/utils.py ++++ b/ironic_python_agent/utils.py +@@ -954,6 +954,7 @@ + + + _LARGE_KEYS = frozenset(['configdrive', 'system_logs']) ++_VALID_NTP_SERVER_RE = re.compile(r'^[a-zA-Z0-9.:_-]+$') + + + def remove_large_keys(var): +@@ -988,6 +989,27 @@ + return None + + ++def _validate_ntp_server(ntp_server): ++ """Validate an NTP server address for safety. ++ ++ :param ntp_server: The NTP server address string. ++ :raises: CommandExecutionError if the value is not valid. ++ """ ++ try: ++ ipaddress.ip_address(ntp_server) ++ return ++ except ValueError: ++ pass ++ ++ if (ntp_server ++ and len(ntp_server) <= 253 ++ and _VALID_NTP_SERVER_RE.match(ntp_server)): ++ return ++ ++ raise errors.CommandExecutionError( ++ 'Invalid NTP server address: %s' % ntp_server) ++ ++ + def sync_clock(ignore_errors=False): + """Syncs the software clock of the system. + +@@ -1011,6 +1033,8 @@ + if not CONF.ntp_server: + return + ++ _validate_ntp_server(CONF.ntp_server) ++ + method = determine_time_method() + + if method == 'ntpdate': +@@ -1028,8 +1052,9 @@ + # stop chronyd, ignore if it ran before or not + execute('chronyc', 'shutdown', check_exit_code=[0, 1]) + # force a time sync now +- query = "server " + CONF.ntp_server + " iburst" +- execute("chronyd -q \'%s\'" % query, shell=True) ++ query = ("server %s iburst" ++ % shlex.quote(CONF.ntp_server)) ++ execute('chronyd', '-q', query) + LOG.debug('Set software clock using chrony') + except (processutils.ProcessExecutionError, + errors.CommandExecutionError) as e: +diff --git a/releasenotes/notes/fix-ntp-server-shell-injection-5ccf2972e0ea632e.yaml b/releasenotes/notes/fix-ntp-server-shell-injection-5ccf2972e0ea632e.yaml +new file mode 100644 +index 0000000..68361f5 +--- /dev/null ++++ b/releasenotes/notes/fix-ntp-server-shell-injection-5ccf2972e0ea632e.yaml +@@ -0,0 +1,12 @@ ++--- ++security: ++ - | ++ Fixes a shell command injection vulnerability in the NTP clock ++ synchronization when using chrony. The ``ntp_server`` configuration ++ value was interpolated into a shell command string, allowing ++ a crafted value to execute arbitrary system commands. The chrony ++ execution path no longer uses a shell, and the ``ntp_server`` ++ value is now validated and sanitized before use. ++ See `bug 2160050 ++ <https://bugs.launchpad.net/ironic-python-agent/+bug/2160050>`_ ++ for details. diff -Nru ironic-python-agent-10.2.0/debian/patches/remove-broken-test.patch ironic-python-agent-10.2.0/debian/patches/remove-broken-test.patch --- ironic-python-agent-10.2.0/debian/patches/remove-broken-test.patch 2025-04-01 15:48:41.000000000 +0200 +++ ironic-python-agent-10.2.0/debian/patches/remove-broken-test.patch 2026-11-09 17:49:45.000000000 +0100 @@ -7,7 +7,7 @@ =================================================================== --- ironic-python-agent.orig/ironic_python_agent/tests/unit/test_hardware.py +++ ironic-python-agent/ironic_python_agent/tests/unit/test_hardware.py -@@ -4719,92 +4719,6 @@ +@@ -4720,92 +4720,6 @@ class TestGenericHardwareManager(base.Ir self.hardware.create_configuration, self.node, []) @@ -100,7 +100,7 @@ @mock.patch.object(raid_utils, 'get_volume_name_of_raid_device', autospec=True) @mock.patch.object(hardware, 'list_all_block_devices', autospec=True) -@@ -4854,105 +4768,6 @@ +@@ -4855,105 +4769,6 @@ class TestGenericHardwareManager(base.Ir mocked_execute.assert_called_once_with( 'mdadm', '--examine', '/dev/md0', use_standard_locale=True) diff -Nru ironic-python-agent-10.2.0/debian/patches/series ironic-python-agent-10.2.0/debian/patches/series --- ironic-python-agent-10.2.0/debian/patches/series 2025-04-01 15:48:41.000000000 +0200 +++ ironic-python-agent-10.2.0/debian/patches/series 2026-11-09 17:49:45.000000000 +0100 @@ -1,2 +1,5 @@ remove-broken-test.patch fix-syntax-warning.patch +CVE-2026-43003_Add_a_flag_to_disable_installing_bootloaders.patch +CVE-2026-66138_OSSA-2026-027_fix_NTP_command_handling.patch +CVE-2026-54422-OSSA-2026-028_Do_not_expose_registry_pull_secret_to_bootc_container.patch

