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

Reply via email to