Skip to content

Commit 06fe5ff

Browse files
jayofdoomdtantsur
andcommitted
Inspect non-raw images for safety
This is a backport of two changes merged together to facilitate backporting: The first is a refactor of disk utilities: Import disk_{utils,partitioner} from ironic-lib With the iscsi deploy long gone, these modules are only used in IPA and in fact represent a large part of its critical logic. Having them separately sometimes makes fixing issues tricky if an interface of a function needs changing. This change imports the code mostly as it is, just removing run_as_root and a deprecated function, as well as moving configuration options to config.py. Also migrates one relevant function from ironic_lib.utils. The second is the fix for the security issue: Inspect non-raw images for safety When IPA gets a non-raw image, it performs an on-the-fly conversion using qemu-img convert, as well as running qemu-img frequently to get basic information about the image before validating it. Now, we ensure that before any qemu-img calls are made, that we have inspected the image for safety and pass through the detected format. If given a disk_format=raw image and image streaming is enabled (default), we retain the existing behavior of not inspecting it in any way and streaming it bit-perfect to the device. In this case, we never use qemu-based tools on the image at all. If given a disk_format=raw image and image streaming is disabled, this change fixes a bug where the image may have been converted if it was not actually raw in the first place. We now stream these bit-perfect to the device. Adds two config options: - [DEFAULT]/disable_deep_image_inspection, which can be set to "True" in order to disable all security features. Do not do this. - [DEFAULT]/permitted_image_formats, default raw,qcow2, for image types IPA should accept. Both of these configuration options are wired up to be set by the lookup data returned by Ironic at lookup time. This uses a image format inspection module imported from Nova; this inspector will eventually live in oslo.utils, at which point we'll migrate our usage of the inspector to it. Closes-Bug: #2071740 Co-Authored-By: Dmitry Tantsur <[email protected]> Change-Id: I5254b80717cb5a7f9084e3eff32a00b968f987b7
1 parent 46744b1 commit 06fe5ff

16 files changed

+4038
-116
lines changed

ironic_python_agent/agent.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,12 @@ def process_lookup_data(self, content):
467467
if config.get('metrics_statsd'):
468468
for opt, val in config.items():
469469
setattr(cfg.CONF.metrics_statsd, opt, val)
470+
if config.get('disable_deep_image_inspection') is not None:
471+
cfg.CONF.set_override('disable_deep_image_inspection',
472+
config['disable_deep_image_inspection'])
473+
if config.get('permitted_image_formats') is not None:
474+
cfg.CONF.set_override('permitted_image_formats',
475+
config['permitted_image_formats'])
470476
md5_allowed = config.get('agent_md5_checksum_enable')
471477
if md5_allowed is not None:
472478
cfg.CONF.set_override('md5_enabled', md5_allowed)

ironic_python_agent/config.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,13 +370,82 @@
370370
help='If the agent should rebuild the configuration drive '
371371
'using a local filesystem, instead of letting Ironic '
372372
'determine if this action is necessary.'),
373+
cfg.BoolOpt('disable_deep_image_inspection',
374+
default=False,
375+
help='This disables the additional deep image inspection '
376+
'the agent does before converting and writing an image. '
377+
'Generally, this should remain enabled for maximum '
378+
'security, but this option allows disabling it if there '
379+
'is a compatability concern.'),
380+
cfg.ListOpt('permitted_image_formats',
381+
default='raw,qcow2',
382+
help='The supported list of image formats which are '
383+
'permitted for deployment with Ironic Python Agent. If '
384+
'an image format outside of this list is detected, the '
385+
'image validation logic will fail the deployment '
386+
'process. This check is skipped if deep image '
387+
'inspection is disabled.'),
373388
]
374389

375-
CONF.register_cli_opts(cli_opts)
390+
disk_utils_opts = [
391+
cfg.IntOpt('efi_system_partition_size',
392+
default=550,
393+
help='Size of EFI system partition in MiB when configuring '
394+
'UEFI systems for local boot. A common minimum is ~200 '
395+
'megabytes, however OS driven firmware updates and '
396+
'unikernel usage generally requires more space on the '
397+
'efi partition.'),
398+
cfg.IntOpt('bios_boot_partition_size',
399+
default=1,
400+
help='Size of BIOS Boot partition in MiB when configuring '
401+
'GPT partitioned systems for local boot in BIOS.'),
402+
cfg.StrOpt('dd_block_size',
403+
default='1M',
404+
help='Block size to use when writing to the nodes disk.'),
405+
cfg.IntOpt('partition_detection_attempts',
406+
default=3,
407+
min=1,
408+
help='Maximum attempts to detect a newly created partition.'),
409+
cfg.IntOpt('partprobe_attempts',
410+
default=10,
411+
help='Maximum number of attempts to try to read the '
412+
'partition.'),
413+
cfg.IntOpt('image_convert_memory_limit',
414+
default=2048,
415+
help='Memory limit for "qemu-img convert" in MiB. Implemented '
416+
'via the address space resource limit.'),
417+
cfg.IntOpt('image_convert_attempts',
418+
default=3,
419+
help='Number of attempts to convert an image.'),
420+
]
421+
422+
disk_part_opts = [
423+
cfg.IntOpt('check_device_interval',
424+
default=1,
425+
help='After Ironic has completed creating the partition table, '
426+
'it continues to check for activity on the attached iSCSI '
427+
'device status at this interval prior to copying the image'
428+
' to the node, in seconds'),
429+
cfg.IntOpt('check_device_max_retries',
430+
default=20,
431+
help='The maximum number of times to check that the device is '
432+
'not accessed by another process. If the device is still '
433+
'busy after that, the disk partitioning will be treated as'
434+
' having failed.')
435+
]
376436

377437

378438
def list_opts():
379-
return [('DEFAULT', cli_opts)]
439+
return [('DEFAULT', cli_opts),
440+
('disk_utils', disk_utils_opts),
441+
('disk_partitioner', disk_part_opts)]
442+
443+
444+
def populate_config():
445+
"""Populate configuration. In a method so tests can easily utilize it."""
446+
CONF.register_cli_opts(cli_opts)
447+
CONF.register_opts(disk_utils_opts, group='disk_utils')
448+
CONF.register_opts(disk_part_opts, group='disk_partitioner')
380449

381450

382451
def override(params):
@@ -403,3 +472,6 @@ def override(params):
403472
LOG.warning('Unable to override configuration option %(key)s '
404473
'with %(value)r: %(exc)s',
405474
{'key': key, 'value': value, 'exc': exc})
475+
476+
477+
populate_config()
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Copyright 2014 Red Hat, Inc.
2+
# All Rights Reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
5+
# not use this file except in compliance with the License. You may obtain
6+
# a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
# License for the specific language governing permissions and limitations
14+
# under the License.
15+
16+
"""
17+
Code for creating partitions on a disk.
18+
19+
Imported from ironic-lib's disk_utils as of the following commit:
20+
https://opendev.org/openstack/ironic-lib/commit/42fa5d63861ba0f04b9a4f67212173d7013a1332
21+
"""
22+
23+
import logging
24+
25+
from ironic_lib.common.i18n import _
26+
from ironic_lib import exception
27+
from ironic_lib import utils
28+
from oslo_config import cfg
29+
30+
CONF = cfg.CONF
31+
32+
LOG = logging.getLogger(__name__)
33+
34+
35+
class DiskPartitioner(object):
36+
37+
def __init__(self, device, disk_label='msdos', alignment='optimal'):
38+
"""A convenient wrapper around the parted tool.
39+
40+
:param device: The device path.
41+
:param disk_label: The type of the partition table. Valid types are:
42+
"bsd", "dvh", "gpt", "loop", "mac", "msdos",
43+
"pc98", or "sun".
44+
:param alignment: Set alignment for newly created partitions.
45+
Valid types are: none, cylinder, minimal and
46+
optimal.
47+
48+
"""
49+
self._device = device
50+
self._disk_label = disk_label
51+
self._alignment = alignment
52+
self._partitions = []
53+
54+
def _exec(self, *args):
55+
# NOTE(lucasagomes): utils.execute() is already a wrapper on top
56+
# of processutils.execute() which raises specific
57+
# exceptions. It also logs any failure so we don't
58+
# need to log it again here.
59+
utils.execute('parted', '-a', self._alignment, '-s', self._device,
60+
'--', 'unit', 'MiB', *args, use_standard_locale=True)
61+
62+
def add_partition(self, size, part_type='primary', fs_type='',
63+
boot_flag=None, extra_flags=None):
64+
"""Add a partition.
65+
66+
:param size: The size of the partition in MiB.
67+
:param part_type: The type of the partition. Valid values are:
68+
primary, logical, or extended.
69+
:param fs_type: The filesystem type. Valid types are: ext2, fat32,
70+
fat16, HFS, linux-swap, NTFS, reiserfs, ufs.
71+
If blank (''), it will create a Linux native
72+
partition (83).
73+
:param boot_flag: Boot flag that needs to be configured on the
74+
partition. Ignored if None. It can take values
75+
'bios_grub', 'boot'.
76+
:param extra_flags: List of flags to set on the partition. Ignored
77+
if None.
78+
:returns: The partition number.
79+
80+
"""
81+
self._partitions.append({'size': size,
82+
'type': part_type,
83+
'fs_type': fs_type,
84+
'boot_flag': boot_flag,
85+
'extra_flags': extra_flags})
86+
return len(self._partitions)
87+
88+
def get_partitions(self):
89+
"""Get the partitioning layout.
90+
91+
:returns: An iterator with the partition number and the
92+
partition layout.
93+
94+
"""
95+
return enumerate(self._partitions, 1)
96+
97+
def commit(self):
98+
"""Write to the disk."""
99+
LOG.debug("Committing partitions to disk.")
100+
cmd_args = ['mklabel', self._disk_label]
101+
# NOTE(lucasagomes): Lead in with 1MiB to allow room for the
102+
# partition table itself.
103+
start = 1
104+
for num, part in self.get_partitions():
105+
end = start + part['size']
106+
cmd_args.extend(['mkpart', part['type'], part['fs_type'],
107+
str(start), str(end)])
108+
if part['boot_flag']:
109+
cmd_args.extend(['set', str(num), part['boot_flag'], 'on'])
110+
if part['extra_flags']:
111+
for flag in part['extra_flags']:
112+
cmd_args.extend(['set', str(num), flag, 'on'])
113+
start = end
114+
115+
self._exec(*cmd_args)
116+
117+
try:
118+
from ironic_python_agent import disk_utils # circular dependency
119+
disk_utils.wait_for_disk_to_become_available(self._device)
120+
except exception.IronicException as e:
121+
raise exception.InstanceDeployFailure(
122+
_('Disk partitioning failed on device %(device)s. '
123+
'Error: %(error)s')
124+
% {'device': self._device, 'error': e})

0 commit comments

Comments
 (0)