# SPDX-FileCopyrightText: 2023 3mdeb # # SPDX-License-Identifier: MIT import re import os import subprocess from pathlib import Path from typing import List import matplotlib.pyplot as plt """This module is responsible for parsing coreboot images""" class DasharoCorebootImage: """DasharoCorebootImage class The main class representing a coreboot-based firmware image """ debug = False region_patterns = [ r"'(?P\w+?)' ", r"\((?P(read-only, |preserve, |CBFS, ){0,1}?)", r"size (?P\d+?), offset (?P\d+?)\)" ] """Set of regular expressions used to extract the flashmap regions""" region_regexp = re.compile(''.join(region_patterns), re.MULTILINE) """Regular expression variable used to extract the flashmap regions""" ifdtool_pattern = r'^FLREG(?P\d+):\s+(?P0x[0-9a-fA-F]+)\s*?\n\s+Flash Region \d+ \((?P.+?)\): (?P[0-9a-fA-F]+) - (?P[0-9a-fA-F]+)(?: \((?Punused)\))?' ifdtool_regexp = re.compile(ifdtool_pattern, re.MULTILINE) # Regions to consider as data, they should not contain any code ever. # Some of the regions are used only by certain platforms and may not be met # on Dasharo builds. DATA_REGIONS = ['SI_DESC', 'RECOVERY_MRC_CACHE', 'RW_MRC_CACHE', 'RW_VPD', 'SMMSTORE', 'SHARED_DATA', 'VBLOCK_DEV', 'RW_NVRAM', 'CONSOLE', 'RW_FWID_A', 'RW_FWID_B', 'VBLOCK_A', 'RO_VPD', 'VBLOCK_B', 'HSPHY_FW', 'RW_ELOG', 'FMAP', 'RO_FRID', 'RO_FRID_PAD', 'SPD_CACHE', 'FPF_STATUS', 'RO_LIMITS_CFG', 'RW_DDR_TRAINING', 'GBB', 'BOOTORDER', 'RESERVED', 'BPA', 'ROMHOLE', 'SI_GBE', 'RO_GSCVD', 'RW_VAR_MRC_CACHE' ] """A list of region names known to contain data""" IFD_DATA_REGIONS = ['Flash Descriptor', 'Platform Data', 'GbE'] """A list of IFD regions known to contain data""" # Regions that are not CBFSes and may contain open-source code # Their whole size is counted as code. CODE_REGIONS = ['BOOTBLOCK'] """A list of region names known to contain open-source code""" # Regions that may contain code but in closed-source binary form # HSPHY_FW does not belong here, because it is part of ME which counts # as closed-source binary blob as a whole. BLOB_REGIONS = ['RW_VBIOS_CACHE', 'ME_RW_A', 'ME_RW_B', 'IFWI', 'SI_ME', 'SIGN_CSE'] """A list of region names known to contain closed-source code""" IFD_BLOB_REGIONS = ['Intel ME', 'IE', 'PTT', '10GbE_0', '10GbE_1', 'EC'] """A list of closed-source code IFD regions""" # Regions to not account for in calculations. # These are containers aggregating smaller regions. SKIP_REGIONS = ['RW_MISC', 'UNIFIED_MRC_CACHE', 'RW_SHARED', 'SI_ALL', 'RW_SECTION_A', 'RW_SECTION_B', 'WP_RO', 'RO_SECTION', 'SI_BIOS'] """A list of region names known to be containers or aliases of other regions. These regions are skipped from classification.""" # Regions to not account for in calculations when ifdtool is used. # These regions will be classified based on their presence in IFD. IFD_SKIP_REGIONS = ['SI_DESC', 'SI_ME', 'SI_GBE', 'SI_PDR', 'SI_EC', 'SI_DEVICEEXT', 'SI_BIOS2', 'SI_DEVICEEXT2', 'SI_IE', 'SI_10GBE0', 'SI_10GBE1', 'SI_PTT'] """A list of region names to be skipped when ifdtool is used. These regions willbe classified by IFD region purpose.""" # Regions to count as empty/unused EMPTY_REGIONS = ['UNUSED', 'RW_UNUSED', 'SI_DEVICEEXT2', 'UNUSED_HOLE', 'BIOS_UNUSABLE'] """A list of region names known to be empty spaces, e.g. between IFD regions.""" def __init__(self, image_path, verbose=False, microarch=""): """DasharoCorebootImage class init method Initializes the class fields for storing the firmware image components classified to specific groups. Also calls :meth:`~coreboot.DasharoCorebootImage._parse_cb_fmap_layout` and :meth:`~coreboot.DasharoCorebootImage._calculate_metrics` methods to parse the image and calculate the metrics. :param image_path: Path the the firmware image file being parsed. :type image_path: str :param verbose: Optional parameter to turn on debug information during the image parsing, defaults to False :type verbose: bool, optional """ self.image_path = image_path """Path to the image represented by DasharoCorebootImage class""" self.microarch = microarch """CPU michroarchitecture supported by the firmware binary to be passed to ifdtool. For a complete list of supported microarchitectures, use 'ifdtool -h'. """ self.image_size = os.path.getsize(image_path) """Image size in bytes""" self.fmap_regions = {} """A dictionary holding the coreboot image flashmap regions""" self.ifdtool_regions = {} """A dictionary holding regions found by ifdtool""" self.cbfs_images = [] """A list holding the regions with CBFS""" self.num_regions = 0 """Total number of flashmap regions""" self.num_ifdtool_regions = 0 """Total number of regions found by ifdtool""" self.num_cbfses = 0 """Total number of flashmap regions containing CBFSes""" self.open_code_size = 0 """Total number of bytes classified as open-source code""" self.closed_code_size = 0 """Total number of bytes classified as closed-source code""" self.data_size = 0 """Total number of bytes classified as data""" self.empty_size = 0 """Total number of bytes classified as empty""" self.open_code_regions = [] """A list holding flashmap regions filled with open-source code""" self.closed_code_regions = [] """A list holding flashmap regions filled with closed-source code""" self.data_regions = [] """A list holding flashmap regions filled with data""" self.empty_regions = [] """A list holding empty flashmap regions""" self.closed_code_regions_ifdtool = [] """A list holding ifdtool regions filled with closed-source code""" self.data_regions_ifdtool = [] """A list holding ifdtool regions filled with data""" self.empty_regions_ifdtool = [] """A list holding empty ifdtool regions""" # This type of regions will be counted as closed-source at the end of # metrics calculation. Keep them in separate array to export them into # CSV later for review. self.uncategorized_regions = [] """A list holding flashmap regions that could not be classified. Counted as closed-source code at the end of calculation process. """ self.uncategorized_regions_ifdtool = [] """A list holding ifdtool regions that could not be classified. Counted as closed-source code at the end of calculation process. """ self.debug = verbose """Used to enable verbose debug output from the parsing process""" self.ifd_found = False """Boolean vlaue if Intel Flash Descriptor has been detected in the image by ifdtool""" self._parse_cb_fmap_layout() if bool(microarch): self._parse_ifdtool_regions(microarch) self._calculate_metrics() def __len__(self): """Returns the length of the coreboot firmware image :return: Length of the firmware binary file :rtype: int """ return self.image_size def __repr__(self): """DasharoCorebootImage class representation :return: class representation :rtype: str """ return 'DasharoCorebootImage()' def __str__(self): """Returns string representation of the firmware image Prints the firmware image statistics. :return: DasharoCorebootImage string representation :rtype: str """ return 'Dasharo image %s:\n' \ '\tImage size: %d\n' \ '\tNumber of regions: %d\n' \ '\tNumber of CBFSes: %d\n' \ '\tTotal open-source code size: %d\n' \ '\tTotal closed-source code size: %d\n' \ '\tTotal data size: %d\n' \ '\tTotal empty size: %d' % ( self.image_path, self.image_size, self.num_regions, self.num_cbfses, self.open_code_size, self.closed_code_size, self.data_size, self.empty_size) def _region_is_cbfs(self, region): """Checks if given region has a CBFS attribute :param region: Flashmap region entry from dictionary :type region: dict :return: True if regions contains CBFS attribute, false otherwise. :rtype: bool """ if region['attributes'] == 'CBFS': return True else: return False def _parse_cb_fmap_layout(self): """Parses the cbfstool flashmap layout output Parses the output of 'cbfstool self.image_path layout -w' and extract the flashmap regions to a self.fmap_regions dictionary using the :const:`coreboot.DasharoCorebootImage.region_regexp` regular expression. If a flashmap region has a CBFS attribute, the self.cbfs_images list is appended with a new instance of :class:`coreboot.CBFSImage`. If :attr:`coreboot.DasharoCorebootImage.debug` is True, all flashmap regions with their attributes are printed on the console at the end. """ cmd = ['cbfstool', self.image_path, 'layout', '-w'] layout = subprocess.run(cmd, text=True, capture_output=True) for match in re.finditer(self.region_regexp, layout.stdout): self.fmap_regions[self.num_regions] = { 'name': match.group('region'), 'offset': int(match.group('offset')), 'size': int(match.group('size')), 'attributes': match.group('attribute').strip(', '), } if self._region_is_cbfs(self.fmap_regions[self.num_regions]): cbfs = CBFSImage(self.image_path, self.fmap_regions[self.num_regions], self.debug) self.cbfs_images.append(cbfs) self.num_cbfses += 1 print(cbfs) self.num_regions += 1 if self.debug: print('Dasharo image regions:') [print(self.fmap_regions[i]) for i in range(self.num_regions)] def _validate_fmap_layout(self): offset = 0 hole_size = 0 for i in range(self.num_regions - 1): # If the first region does not start at address zero and we use # ifdtool it is likely that FMAP starts with BIOS region and # ifdtool will handle it. However, if there is no descriptor # found, this space will be added as closed-source later. if i == 0 and self.fmap_regions[i]['offset'] != 0: offset = self.fmap_regions[i]['offset'] # Skip containers as they may have bigger size than offset of the # next region. Exception: FMAP is always read-only but is not a # container. if self.fmap_regions[i]['attributes'] == 'read-only': if self.fmap_regions[i]['name'] != 'FMAP': continue offset += self.fmap_regions[i]['size'] if offset != self.fmap_regions[i + 1]['offset']: if offset > self.fmap_regions[i + 1]['offset']: print('ERROR: Broken FMAP layout!\n' 'End of %s region in the middle of %s region\n' % (self.fmap_regions[i]['name'], self.fmap_regions[i + 1]['name']) ) return -1 else: print('WARNING: FMAP layout is not contiguous.\n' 'The space between region %s and %s is not described' ' in the FMAP layout and will be classified as ' 'closed-source.\n' % (self.fmap_regions[i]['name'], self.fmap_regions[i + 1]['name'])) hole_size += (self.fmap_regions[i + 1]['offset'] - offset) # Reset the offset to detect more non-contiguous regions offset = self.fmap_regions[i + 1]['offset'] offset += self.fmap_regions[self.num_regions - 1]['size'] if offset != self.image_size: print('WARNING: The last region (%s) offset + size is not equal ' 'the image size and will be classified as closed-source.\n' % self.fmap_regions[self.num_regions - 1]['name']) return hole_size def _parse_ifdtool_regions(self, microarch): """Parses `ifdtool --dump` output Extracts IFD regions to the `self.ifdtool_regions` dictionary using the `coreboot.DasharoCorebootImage.ifdtool_regexp` regular expression. If `coreboot.DasharoCorebootImage.debug` is True, all IFD regions with their attributes are printed on the console at the end. """ if self.debug: print('Using ifdtool to detect Intel flash regions') cmd = ['ifdtool', '-p', microarch, '-d', self.image_path] output = subprocess.run(cmd, text=True, capture_output=True) if output.returncode != 0: if self.debug: print ('ERROR: ifdtool returned an error, assuming no flash descriptor in the image') self.ifd_found = False return elif 'No Flash Descriptor found in this image' in output.stdout: if self.debug: print ('No Flash Descriptor found in this image or ifdtool') self.ifd_found = False return else: self.ifd_found = True for match in re.finditer(self.ifdtool_regexp, output.stdout): # Do not add regions marked as unused or if region value is invalid (0xffffffff) if not bool(match.group('status')) and int(match.group('reg_val'), 16) != 0xffffffff: self.ifdtool_regions[self.num_ifdtool_regions] = { 'id': int(match.group('id')), 'reg_val': int(match.group('reg_val'), 16), 'name': match.group('name'), 'start': f"0x{match.group('start')}", 'end': f"0x{match.group('end')}", } start_int = int(self.ifdtool_regions[self.num_ifdtool_regions]['start'], 16) end_int = int(self.ifdtool_regions[self.num_ifdtool_regions]['end'], 16) self.ifdtool_regions[self.num_ifdtool_regions]['size'] = end_int - start_int + 1 self.num_ifdtool_regions += 1 if self.debug: print('IFD regions:') [print(self.ifdtool_regions[i]) for i in range(self.num_ifdtool_regions)] def _classify_ifdtool_region(self, region): """Classifies the IFD regions into basic categories Each region is being classified into 3 basic categories and appended to respective lists. `coreboot.DasharoCorebootImage.closed_code_regions_ifdtool` are appended with regions found in `coreboot.DasharoCorebootImage.IFD_BLOB_REGIONS` `coreboot.DasharoCorebootImage.data_regions_ifdtool` are appended with regions found in `coreboot.DasharoCorebootImage.IFD_DATA_REGIONS` `coreboot.DasharoCorebootImage.empty_regions_ifdtool` are appended with regions that are detected to be empty using `coreboot.DasharoCorebootImage._is_empty` Any other unrecognized region falls into `coreboot.DasharoCorebootImage.uncategorized_regions_ifdtool` list which will be counted as closed-source code region because we were unable to identify what can be inside. :param region: IFD region entry from dictionary :type region: dict """ if self._is_empty(int(region["start"], 16), int(region["end"],16)): self.empty_regions_ifdtool.append(region) return if region["name"] in self.IFD_BLOB_REGIONS: self.closed_code_regions_ifdtool.append(region) elif region["name"] in self.IFD_DATA_REGIONS: self.data_regions_ifdtool.append(region) elif region["name"] == "BIOS": return else: self.uncategorized_regions_ifdtool.append(region) def _is_empty(self, start, end): """Checks if a flash region is empty, where empty is defined as filled with 0x00 or 0xFF bytes. :param: start: Start address of the region :type start: int :param end: End address of the region :type end: int :rtype: bool """ with open(self.image_path, 'rb') as f: f.seek(start) region_data = f.read(end - start + 1) return all(b in (0x00, 0xFF) for b in region_data) def _classify_region(self, region): """Classifies the flashmap regions into basic categories Each detected flashmap region is being classified into 4 basic categories and appended to respective lists. CBFS regions are processed separately and not included here. :attr:`coreboot.DasharoCorebootImage.open_code_regions` are appended with flashmap regions which name is found in :const:`coreboot.DasharoCorebootImage.CODE_REGIONS` :attr:`coreboot.DasharoCorebootImage.closed_code_regions` are appended with flashmap regions which name is found in :const:`coreboot.DasharoCorebootImage.BLOB_REGIONS` :attr:`coreboot.DasharoCorebootImage.empty_regions` are appended with flashmap regions which name is found in :const:`coreboot.DasharoCorebootImage.EMPTY_REGIONS` :attr:`coreboot.DasharoCorebootImage.data_regions` are appended with flashmap regions which name is found in :const:`coreboot.DasharoCorebootImage.DATA_REGIONS` Flashmap regions which names is found in :const:`coreboot.DasharoCorebootImage.SKIP_REGIONS` are not classified due to being cotnainers or aliases to other regions. Counting them would result in duplication of the sizes when calculating metrics. Any other unrecognized flashmap region falls into :attr:`coreboot.DasharoCorebootImage.data_regions` list which will be counted as closed-source code region because we were unable to identify what can be inside. :param region: Flashmap region entry from dictionary :type region: dict """ if self._region_is_cbfs(region): # Skip CBFSes because they have separate class and methods to # calculate metrics return elif self.ifd_found and region['name'] in self.IFD_SKIP_REGIONS: return elif region['name'] in self.SKIP_REGIONS: return elif region['name'] in self.CODE_REGIONS: self.open_code_regions.append(region) elif region['name'] in self.BLOB_REGIONS: self.closed_code_regions.append(region) elif region['name'] in self.EMPTY_REGIONS: self.empty_regions.append(region) elif region['name'] in self.DATA_REGIONS: self.data_regions.append(region) elif region['attributes'] == 'read-only': # Regions with read-only attribute are containers. Skip them. The # FMAP region is an exception and there may be more, so keep this # IF branch at the very end. print('WARNING: Skipped %s region, suspected to be a container' % region['name']) return else: self.uncategorized_regions.append(region) def _calculate_metrics(self): """Calculates the sizes of the four basic firmware components categories Calls :meth:`~coreboot.DasharoCorebootImage._classify_region` for each detected region. The sums the regions sizes from all 5 lists :attr:`coreboot.DasharoCorebootImage.open_code_regions` sizes sum is added to :attr:`coreboot.DasharoCorebootImage.open_code_size` :attr:`coreboot.DasharoCorebootImage.closed_code_regions` sizes sum is added to :attr:`coreboot.DasharoCorebootImage.closed_code_size` :attr:`coreboot.DasharoCorebootImage.data_regions` sizes sum is added to :attr:`coreboot.DasharoCorebootImage.data_size` :attr:`coreboot.DasharoCorebootImage.empty_regions` sizes sum is added to :attr:`coreboot.DasharoCorebootImage.empty_size` :attr:`coreboot.DasharoCorebootImage.uncategorized_regions` sizes sum is added to :attr:`coreboot.DasharoCorebootImage.closed_code_size` Additionally for each detected CBFS region their four basic component's categories are also added to the total metrics. :attr:`coreboot.CBFSImage.open_code_size` is added to :attr:`coreboot.DasharoCorebootImage.open_code_size` :attr:`coreboot.CBFSImage.closed_code_size` is added to :attr:`coreboot.DasharoCorebootImage.closed_code_size` :attr:`coreboot.CBFSImage.data_size` is added to :attr:`coreboot.DasharoCorebootImage.data_size` :attr:`coreboot.CBFSImage.empty_size` is added to :attr:`coreboot.DasharoCorebootImage.empty_size` At the end the method calls :meth:`coreboot.DasharoCorebootImage._normalize_sizes` """ for i in range(self.num_regions): self._classify_region(self.fmap_regions[i]) fmap_hole = self._validate_fmap_layout() if fmap_hole > 0: self.closed_code_size += fmap_hole if self.ifd_found: for i in range(self.num_ifdtool_regions): self._classify_ifdtool_region(self.ifdtool_regions[i]) self.open_code_size += self._sum_sizes(self.open_code_regions) self.closed_code_size += self._sum_sizes(self.closed_code_regions) + self._sum_sizes(self.closed_code_regions_ifdtool) self.data_size += self._sum_sizes(self.data_regions) + self._sum_sizes(self.data_regions_ifdtool) self.empty_size += self._sum_sizes(self.empty_regions) + self._sum_sizes(self.empty_regions_ifdtool) self.closed_code_size += self._sum_sizes(self.uncategorized_regions) + self._sum_sizes(self.uncategorized_regions_ifdtool) if len(self.uncategorized_regions) != 0: print('INFO: Found %d uncategorized regions of total size %d bytes' % (len(self.uncategorized_regions), self._sum_sizes(self.uncategorized_regions))) print(self.uncategorized_regions) for i in range(self.num_cbfses): self.open_code_size += self.cbfs_images[i].open_code_size self.closed_code_size += self.cbfs_images[i].closed_code_size self.data_size += self.cbfs_images[i].data_size self.empty_size += self.cbfs_images[i].empty_size self._normalize_sizes() def _sum_sizes(self, regions): """Sums the size of the regions :param regions: Dictionary of regions to sum :type regions: dict :return: Sum of the region sizes :rtype: int """ return sum(list(r['size'] for r in regions)) def _normalize_sizes(self): """Checks if all firmware image components sizes sum up to whole image size This method acts as a safety check if there was no error during parsing and classification. Additionally it verifies whether the flashmap starts right at offset zero. It may happen that the flashmap does not start at offset zero, which is possible for Intel board coreboot images without IFD and ME regions specified. In such case the missing regions are counted as closed-source and added to :attr:`coreboot.DasharoCorebootImage.closed_code_size` """ # It may happen that the FMAP does not cover whole flash size and the # first region will start with non-zero offset. Check if first region # offset is zero, if not count all bytes from the start of flash to the # start of first region as closed source. This is only done if ifdtool # is not used or IFD was not found, because ifdtool will always parse # those regions correctly. if self.fmap_regions[0]['offset'] != 0 and not self.ifd_found: self.closed_code_size += self.fmap_regions[0]['offset'] # Final check if all sizes are summing up to whole image size full_size = sum([self.open_code_size, self.empty_size, self.closed_code_size, self.data_size]) if full_size != self.image_size: print('WARNING: Something went wrong.\n' 'The component sizes do not sum up to the image size. ' '%d != %d' % (full_size, self.image_size)) def _get_percentage(self, metric): """Helper function to generate code share percentage :param metric: The size of open-source or closed-source code :type metric: int :return: Percentage share of given metric compared to the sum of open-source and closed-source code size. :rtype: int """ return metric * 100 / (self.open_code_size + self.closed_code_size) def _export_regions_md(self, file, regions, category): """Write flashmap regions for given category to the markdown file :param file: Markdown file handle to write the regions's info to :type file: file :param regions: Dictionary containing regions to be written to the markdown file. :type regions: dict :param category: Category of the regions to be written to the markdown file. Should be one of: open-source, closed-source, data, empty. :type category: str """ for region in regions: file.write('| {} | {} | {} | {} |\n'.format( region['name'], hex(region['offset']), hex(region['size']), category)) def _export_ifdtool_regions_md(self, file, regions, category): """Write IFD regions for given category to the markdown file :param file: Markdown file handle to write the regions's info to :type file: file :param regions: Dictionary containing regions to be written to the markdown file. :type regions: dict :param category: Category of the regions to be written to the markdown file. Should be one of: open-source, closed-source, data, empty. :type category: str """ for region in regions: file.write('| {} | {} | {} | {} | {} |\n'.format( region['name'], region['start'], region['end'], hex(region['size']), category)) def export_markdown(self, file, mkdocs, version): """Opens a file and saves the openness report in markdown format Saves the parsed information and classified image components into a markdown file. Also for each CBFS in :attr:`coreboot.DasharoCorebootImage.cbfs_images` it calls :meth:`coreboot.CBFSImage.export_markdown` to save the CBFS region statistics. :param file: Path to markdown file :type file: str :param mkdocs: Switch to export the report for mkdocs :type mkdocs: bool """ with open(file, 'w') as md: if not mkdocs: md.write('# Dasharo Openness Score\n\n') md.write('Report has been generated with Openness Score utility version %s\n\n' % version) md.write('Openness Score for %s\n\n' % Path(self.image_path).name) md.write('Open-source code percentage: **%1.1f%%**\n' % self._get_percentage(self.open_code_size)) md.write('Closed-source code percentage: **%1.1f%%**\n\n' % self._get_percentage(self.closed_code_size)) md.write('* Image size: %d (%s)\n' '* Number of regions: %d\n' '* Number of CBFSes: %d\n' '* Total open-source code size: %d (%s)\n' '* Total closed-source code size: %d (%s)\n' '* Total data size: %d (%s)\n' '* Total empty size: %d (%s)\n\n' % ( self.image_size, hex(self.image_size), self.num_regions, self.num_cbfses, self.open_code_size, hex(self.open_code_size), self.closed_code_size, hex(self.closed_code_size), self.data_size, hex(self.data_size), self.empty_size, hex(self.empty_size))) md.write('![](%s_openness_chart.png)\n\n' % Path(self.image_path).name) md.write('![](%s_openness_chart_full_image.png)\n\n' % Path(self.image_path).name) md.write('> Numbers given above already include the calculations') md.write(' from CBFS regions\n> presented below\n\n') # Regions first if not mkdocs: md.write('## FMAP regions\n\n') else: md.write('### FMAP regions\n\n') md.write('| FMAP region | Offset | Size | Category |\n') md.write('| ----------- | ------ | ---- | -------- |\n') self._export_regions_md(md, self.open_code_regions, 'open-source') self._export_regions_md(md, self.closed_code_regions, 'closed-source') self._export_regions_md(md, self.data_regions, 'data') self._export_regions_md(md, self.empty_regions, 'empty') if self.ifd_found: if not mkdocs: md.write('\n## IFD regions\n\n') else: md.write('\n### IFD regions\n\n') md.write('| IFD region | Start | End | Size | Category |\n') md.write('| -------------- | ----- | --- | ---- | -------- |\n') self._export_ifdtool_regions_md(md, self.closed_code_regions_ifdtool, 'closed-source') self._export_ifdtool_regions_md(md, self.data_regions_ifdtool, 'data') self._export_ifdtool_regions_md(md, self.empty_regions_ifdtool, 'empty') for cbfs in self.cbfs_images: md.write('\n') cbfs.export_markdown(md, mkdocs) def export_charts(self, dir): """Plots the pie charts with firmware image statistics Method plots two pie charts. One containing only the closed-source to open-source code ratio. Second the share percentage of all four image components categories: closed-source, open-source, data and empty space. :param dir: Path to the directory where the charts will be saved. :type dir: str """ labels = 'closed-source', 'open-source' sizes = [self.closed_code_size, self.open_code_size] explode = (0, 0.1) fig, ax = plt.subplots() ax.pie(sizes, explode=explode, labels=labels, autopct='%1.1f%%') fig.suptitle('Dasharo coreboot image code openness\n%s' % Path(self.image_path).name) plt.savefig('%s_openness_chart.png' % dir.joinpath(Path(self.image_path).name)) labels = 'closed-source', 'open-source', 'data', 'empty' sizes = [self.closed_code_size, self.open_code_size, self.data_size, self.empty_size] explode = (0, 0.1, 0, 0) fig, ax = plt.subplots() ax.pie(sizes, explode=explode, labels=labels, autopct='%1.1f%%') fig.suptitle('Dasharo coreboot full image component share\n%s' % Path(self.image_path).name) plt.savefig('%s_openness_chart_full_image.png' % dir.joinpath(Path(self.image_path).name)) class CBFSImage: """ CBFSImage class The main class representing a coreboot's CBFS """ debug = False CBFS_FILETYPES = [ 'bootblock', 'cbfs header', 'stage', 'simple elf', 'fit_payload', 'optionrom', 'bootsplash', 'raw', 'vsa', 'mbi', 'microcode', 'intel_fit', 'fsp', 'mrc', 'cmos_default', 'cmos_layout', 'spd', 'mrc_cache', 'mma', 'efi', 'struct', 'deleted', 'null', 'amdfw' ] """A list of all known CBFS filetypes for regexp matching""" OPEN_SOURCE_FILETYPES = [ 'bootblock', 'stage', 'simple elf', 'fit_payload', ] """A list of CBFS filetypes known to be open-source code""" CLOSED_SOURCE_FILETYPES = [ 'optionrom', 'vsa', 'mbi', 'microcode', 'fsp', 'mrc', 'mma', 'efi', 'amdfw' ] """A list of CBFS filetypes known to be closed-source code""" DATA_FILETYPES = [ 'cbfs header', 'bootsplash', 'intel_fit', 'cmos_default', 'cmos_layout', 'spd', 'mrc_cache', 'struct', ] """A list of CBFS filetypes known to be data""" # Some binary blobs containing code are not added as raw files or as fsp, # etc, for example refcode blob is a stage type. We keep them here to # account for such exceptions. Some non-x86 files are also here for the # future. The list may not be exhaustive. Search for 'cbfs-files' pattern # in coreobot Makefiles. CLOSED_SOURCE_EXCEPTIONS = [ 'fallback/refcode', 'fallback/secure_os', 'fallback/dram', 'fallback/qcsdi', 'fallback/qclib', 'fallback/pmiccfg', 'fallback/dcb', 'fallback/dcb_longsys1p8', 'fallback/aop', 'fallback/uart_fw', 'fallback/spi_fw', 'fallback/i2c_fw', 'fallback/cpucp', 'fallback/shrm', 'fallback/gsi_fw', ] """A list of CBFS filenames exceptions known to be closed-source code""" # Filetype raw can be anything and can also be named arbitrarily. We trust # that Dasharo binary is unmodified and standard names used by coreboot # have not been misused to hide blobs. These names are below for data and # code respecitvely. We also assume VBT to be data, becasue Intel publishes # VBT BSF/JSON files with the meaning of each byte in it. The lists may not # be exhaustive. Search for 'cbfs-files' pattern in coreobot Makefiles. RAW_DATA_FILES = [ 'config', 'revision', 'build_info', 'vbt.bin', 'payload_config', 'payload_revision', 'etc/grub.cfg', 'logo.bmp', 'rt8168-macaddress', 'atl1e-macaddress', 'wifi_sar_defaults.hex', 'ecrw.hash', 'pdrw.hash', 'oem.bin', 'sbom', 'boot_policy_manifest.bin', 'key_manifest.bin', 'txt_bios_policy.bin', 'apu/amdfw_a', 'apu/amdfw_b', 'me_rw.hash', 'me_rw.version', 'vboot_public_key.bin', # SeaBIOS runtime config below https://www.seabios.org/Runtime_config 'links', 'bootorder', 'etc/show-boot-menu', 'etc/boot-menu-message', 'etc/boot-menu-key', 'etc/boot-menu-wait', 'etc/boot-fail-wait', 'etc/extra-pci-roots', 'etc/ps2-keyboard-spinup', 'etc/threads', 'etc/optionroms-checksum', 'etc/pci-optionrom-exec', 'etc/s3-resume-vga-init', 'etc/screen-and-debug', 'etc/sercon-port', 'etc/advertise-serial-debug-port', 'etc/floppy0', 'etc/floppy1', 'etc/usb-time-sigatt', 'etc/sdcard0', 'etc/sdcard1', 'etc/sdcard2', 'etc/sdcard3', # PC Engines apu specific 'bootorder_def', 'bootorder_map' ] """A list of CBFS filenames known to be data""" # Everything derived from open-source code which is an executable code or # was created from open-source code in a reproducible way RAW_OPEN_SOURCE_FILES = [ 'fallback/dsdt.aml', 'vgaroms/seavgabios.bin', 'pagetables', 'pdpt', 'pt', 'ecrw', 'pdrw', 'sff8104-linux.dtb', 'stm.bin', 'fallback/DTB', 'oemmanifest.bin', 'smcbiosinfo.bin', 'genroms/pxe.rom', 'ec.rom' ] """A list of CBFS filenames known to be created from open-source code""" # PSE binary is treated as closed source as there is no guarantee of open # code availability for given build. RAW_CLOSED_SOURCE_FILES = [ 'doom.wad', 'ecfw1.bin', 'ecfw2.bin', 'apu/amdfw', 'ec/ecfw', 'sch5545_ecfw.bin', 'txt_bios_acm.bin', 'txt_sinit_acm.bin', 'apu/amdfw_a_body', 'apu/amdfw_b_body', 'smu_fw', 'smu_fw2', 'dmic-1ch-48khz-16b.bin', 'dmic-2ch-48khz-16b.bin', 'me_rw', 'dmic-4ch-48khz-16b.bin', 'max98357-render-2ch-48khz-24b.bin', 'nau88l25-2ch-48khz-24b.bin', 'max98927-render-2ch-48khz-24b.bin', 'max98927-render-2ch-48khz-16b.bin', 'dmic-2ch-48khz-32b.bin', 'rt5514-capture-4ch-48khz-16b.bin', 'dmic-4ch-48khz-32b.bin', 'max98373-render-2ch-48khz-24b.bin', 'dialog-2ch-48khz-24b.bin', 'max98373-render-2ch-48khz-16b.bin', 'rt5682-2ch-48khz-24b.bin', 'rt5663-2ch-48khz-24b.bin', 'ssm4567-render-2ch-48khz-24b.bin', 'ssm4567-capture-4ch-48khz-32b.bin', 'pcm_allinone_lp4_3200.bin', 'pcm_allinone_lp4_3733.bin', 'sspm.bin', 'spm_firmware.bin', 'AGESA', 'cse_iom', 'cse_nphy', 'pse.bin', 'rmu.bin', 'tegra_mtc.bin', 'tz.mbn', 'cdt.mbn', 'ddr.mbn', 'rpm.mbn' ] """A list of CBFS filenames known to be closed-source""" # A list of regions that are supposed to have a config file. We use this # list to skip a warning and reduce confusion when a config file is not # found in a region that is not listed here. REGIONS_WITH_CONFIG = [ 'COREBOOT', 'FW_MAIN_A', 'FW_MAIN_B' ] """A list of CBFS regions that should contain a config file""" DASHARO_LAN_ROM_GUID = 'DEB917C0-C56A-4860-A05B-BF2F22EBB717' """GUID of the Dasharo UEFI Paylaod file that contains closed-source EFI driver for LAN NIC""" file_patterns = [ r"(?P[a-zA-Z0-9\(\)\/\.\,\_\-]*?)\s+", r"(?P0x[0-9a-f]+?)\s+", r"(?P(" + "|".join(CBFS_FILETYPES) + r"){1}?)\s+", r"(?P\d+?)\s+(?P\w+?)(\s+\(\d+ \w+\))?$" ] """Set of regular expressions used to parse the cbfstool output""" file_regexp = re.compile(''.join(file_patterns), re.MULTILINE) """Regular expression variable used to parse the cbfstool output""" def __init__(self, image_path, region, verbose=False): """CBFSImage class init method Initializes the class fields for storing the CBFS region components classified to specific groups. Also calls :meth:`~coreboot.DasharoCorebootImage._parse_cbfs_files`, :meth:`~coreboot.DasharoCorebootImage._parse_cb_config` and :meth:`~coreboot.DasharoCorebootImage._calculate_metrics` methods to parse the CBFS and calculate the metrics. :param region: Path the the firmware image file being parsed. :type image_path: str :param region: The flashmap region where the CBFS resides. :type image_path: dict :param verbose: Optional parameter to turn on debug information during the image parsing, defaults to False :type verbose: bool, optional """ self.image_path = image_path """Path to the image represented by DasharoCorebootImage class""" self.region_name = region['name'] """The region name where the CBFS is located""" self.cbfs_size = region['size'] """The region size where the CBFS is located""" self.cbfs_files = {} """A dictionary holding the CBFS files and their attributes""" self.kconfig_opts = {} """A dictionary holding the coreboot config used to produce the CBFS """ self.num_files = 0 """Number of files in the CBFS""" self.num_opts = 0 """Number of options coreboot config file found in CBFS""" self.open_code_size = 0 """Total number of bytes classified as open-source code""" self.closed_code_size = 0 """Total number of bytes classified as closed-source code""" self.data_size = 0 """Total number of bytes classified as data""" self.empty_size = 0 """Total number of bytes classified as empty""" self.open_code_files = [] """A list holding CBFS files classified as open-source code""" self.closed_code_files = [] """A list holding CBFS files classified as closed-source code""" self.data_files = [] """A list holding CBFS files classified as data""" self.empty_files = [] """A list holding CBFS empty spaces""" # This type of files will be counted as closed-source at the end of # metrics calculation. Keep them in separate array to export them into # CSV later for review. self.uncategorized_files = [] """A list holding CBFS files that could not be classified. Counted as closed-source code at the end of calculation process. """ self.edk2_ipxe = False """Variable to hold the status whether iPXE was built for EDK2""" self.ipxe_present = False """Variable to hold the status of iPXE presence in the CBFS""" self.ipxe_rom_id = None """Variable to hold the PCI ID used for iPXE build""" self.lan_rom_size = 0 """Variable to hold the size of optional LAN EFI driver used in Dasharo builds. If such driver is detected based on coreboot config, the driver's size is subtracted from open-source code and added to closed-source code. """ self.debug = verbose """Used to enable verbose debug output from the parsing process""" self._parse_cbfs_files() self._parse_cb_config() self._calculate_metrics() def __len__(self): """Returns the length of the CBFS region :return: Length of the CBFS :rtype: int """ return self.cbfs_size def __repr__(self): """CBFSImage class representation :return: class representation :rtype: str """ return 'CBFSImage()' def __str__(self): """Returns string representation of the CBFS Prints the firmware image statistics. :return: CBFSImage string representation :rtype: str """ return 'CBFS region %s:\n' \ '\tCBFS size: %d\n' \ '\tNumber of files: %d\n' \ '\tOpen-source files size: %d\n' \ '\tClosed-source files size: %d\n' \ '\tData size: %d\n' \ '\tEmpty size: %d' % ( self.region_name, self.cbfs_size, self.num_files, self.open_code_size, self.closed_code_size, self.data_size, self.empty_size) def _parse_cbfs_files(self): """Parses the CBFS contents from cbfstool output Parses the output of 'cbfstool :attr:`coreboot.CBFSImage.image_path` print -r :attr:`coreboot.CBFSImage.region_name`' and extracts the CBFS files information to the :attr:`coreboot.CBFSImage.cbfs_files` dictionary using the :const:`coreboot.CBFSImage.file_regexp` regular expression. If :attr:`coreboot.CBFSImage.debug` is True, all CBFS contents with their attributes are printed on the console at the end. """ cmd = ['cbfstool', self.image_path, 'print', '-r', self.region_name] cbfs_content = subprocess.run(cmd, text=True, capture_output=True) for match in re.finditer(self.file_regexp, cbfs_content.stdout): self.cbfs_files[self.num_files] = { 'filename': match.group('filename'), 'offset': int(match.group('offset'), 16), 'filetype': match.group('filetype'), 'size': int(match.group('size')), 'compression': match.group('compression'), } self.num_files = self.num_files + 1 if self.debug: print('Region %s CBFS contents:' % self.region_name) [print(self.cbfs_files[i]) for i in range(self.num_files)] def _calculate_metrics(self): """Calculates the sizes of the four basic firmware components categories Calls :meth:`~coreboot.CBFSImage._classify_file` for each detected CBFS file. Then sums the files' sizes from all 5 lists: :attr:`coreboot.CBFSImage.open_code_files` sizes sum is added to :attr:`coreboot.CBFSImage.open_code_size` :attr:`coreboot.CBFSImage.closed_code_files` sizes sum is added to :attr:`coreboot.CBFSImage.closed_code_size` :attr:`coreboot.CBFSImage.data_files` sizes sum is added to :attr:`coreboot.CBFSImage.data_size` :attr:`coreboot.CBFSImage.empty_files` sizes sum is added to :attr:`coreboot.CBFSImage.empty_size` :attr:`coreboot.CBFSImage.uncategorized_files` sizes sum is added to :attr:`coreboot.CBFSImage.closed_code_size` Additionally if a LAN EFI driver has been detected, it is subtracted from open-source code size (normally the driver is part ofthe payload considered to be open-source) and added to the closed-source size. At the end the method calls :meth:`coreboot.CBFSImage._normalize_sizes` """ for i in range(self.num_files): self._classify_file(self.cbfs_files[i]) self.open_code_size = self._sum_sizes(self.open_code_files) self.closed_code_size = self._sum_sizes(self.closed_code_files) self.data_size = self._sum_sizes(self.data_files) self.empty_size = self._sum_sizes(self.empty_files) self.closed_code_size += self._sum_sizes(self.uncategorized_files) if len(self.uncategorized_files) != 0: print('INFO: Found %d uncategorized files of total size %d bytes' % (len(self.uncategorized_files), self._sum_sizes(self.uncategorized_files))) print(self.uncategorized_files) # Account for an externally added LAN driver to the EDK2 payload. We # subtract the compressed size of the driver from the compressed size # of the paylaod counted as open-source and add the value to # closed-source. if self.lan_rom_size != 0: print('INFO: Found external LAN driver blob of size %d bytes' % self.lan_rom_size) self.open_code_size -= self.lan_rom_size self.closed_code_size += self.lan_rom_size self._normalize_sizes() def _classify_file(self, file): """Classifies the CBFS file into basic categories. Each detected CBFS file is being classified into 4 basic categories and appended to respective lists. :attr:`coreboot.CBFSImage.open_code_files` are appended with CBFS files which type is found in :const:`coreboot.CBFSImage.OPEN_SOURCE_FILETYPES` and names are not found in :const:`coreboot.CBFSImage.CLOSED_SOURCE_EXCEPTIONS`. CBFS files of type 'raw' are also classified as open-source code if its name is found in :const:`coreboot.CBFSImage.RAW_OPEN_SOURCE_FILES` or if it is an iPXE legacy ROM (based on the PCI ID detected from coreboot's config). :attr:`coreboot.CBFSImage.closed_code_files` are appended with CBFS files which name is found in :const:`coreboot.CBFSImage.CLOSED_SOURCE_FILETYPES` or with CBFS file's type found in :const:`coreboot.CBFSImage.OPEN_SOURCE_FILETYPES` and name found in :const:`coreboot.CBFSImage.CLOSED_SOURCE_EXCEPTIONS` or with CBFS files of type 'raw' which names are found in :const:`coreboot.CBFSImage.RAW_CLOSED_SOURCE_FILES`. :attr:`coreboot.CBFSImage.empty_files` are appended with CBFS files with type 'null'. :attr:`coreboot.CBFSImage.data_files` are appended with CBFS files which type is found in :const:`coreboot.CBFSImage.DATA_FILETYPES` or with CBFS file of type 'raw' and names found in :const:`coreboot.CBFSImage.RAW_DATA_FILES`. Any other unrecognized CBFS files fall into :attr:`coreboot.CBFSImage.uncategorized_files` list which will be counted as closed-source code because we were unable to identify what can be inside. :param file: CBFS file entry from dictionary :type region: dict """ if file['filetype'] in self.OPEN_SOURCE_FILETYPES: if file['filename'] not in self.CLOSED_SOURCE_EXCEPTIONS: self.open_code_files.append(file) else: self.closed_code_files.append(file) elif file['filetype'] in self.CLOSED_SOURCE_FILETYPES: self.closed_code_files.append(file) elif file['filetype'] in self.DATA_FILETYPES: self.data_files.append(file) elif file['filetype'] == 'null': self.empty_files.append(file) elif file['filetype'] == 'raw': if file['filename'] in self.RAW_DATA_FILES: self.data_files.append(file) elif file['filename'] in self.RAW_OPEN_SOURCE_FILES: self.open_code_files.append(file) elif file['filename'] in self.RAW_CLOSED_SOURCE_FILES: self.closed_code_files.append(file) # iPXE is added as a raw file elif self.ipxe_present and not self.edk2_ipxe: if file['filename'] == 'pci' + self.ipxe_rom_id + '.rom' or \ file['filename'] == 'pci' + self.ipxe_rom_id + '.rom.lzma': self.open_code_files.append(file) else: self.uncategorized_files.append(file) else: self.uncategorized_files.append(file) else: self.uncategorized_files.append(file) def _normalize_sizes(self): """Ensures that all CBFS components sizes sum up to whole image size This function takes into account a situation when the CBFS is truncated (e.g. vboot RW CBFS regions). In such case we calculate the byte offset of the end of last file in CBFS and calculate the truncated size by subtracting the offset from the CBFS region size. The truncated size is then added to the :attr:`coreboot.CBFSImage.empty_size`. cbfstool prints only the sizes of files and does not account for the metadata surrounding the file. It is necessary to calculate the metadata size by subtarcting all file's sizes from the whole CBFS region size. The metadata size is then added to the :attr:`coreboot.CBFSImage.data_size`. """ # We have to take into account truncated CBFSes like FW_MAIN_A or # FW_MAIN_B, where the space after the last file is empty but not # listed as such. last_file_end = (self.cbfs_files[self.num_files-1]['size'] + self.cbfs_files[self.num_files-1]['offset']) truncated_size = self.cbfs_size - last_file_end # COREBOOT region will always have the bootblock at its end, so the # truncated_size will be always equal to 64 (size of metadata at the # beginning of the file). If the gap is bigger than 64 bytes, then it # means we have truncated CBFS and have to add the truncated_size to # the sum of empty files. if truncated_size > 64: self.empty_size += truncated_size # We have to normalize the total size of files in each group to the # total region size, because the cbfstool does not report the size of # the file metadata, so the sum of all file sizes would not match the # CBFS region size. This metadata will be counted as data bytes. metadata_size = self.cbfs_size - sum([self.open_code_size, self.empty_size, self.closed_code_size, self.data_size]) self.data_size += metadata_size if self.debug: print('Size of metadata in %s CBFS: %d bytes' % (self.region_name, metadata_size)) def _sum_sizes(self, files): """Sums the size of the CBFS files :param files: Dictionary of files to sum :type files: dict :return: Sum of the files' sizes :rtype: int """ return sum(list(f['size'] for f in files)) def _get_kconfig_value(self, option): """Returns a value of given coreboot's Kconfig option :param option: Name of the Kconfig option without 'CONFIG_' prefix. :type option: str :return: The value of Kconfig option :rtype: str """ for i in range(len(self.kconfig_opts)): if self.kconfig_opts[i]['option'] == option: return self.kconfig_opts[i]['value'] return None def _parse_cb_config(self): """Extracts and parses the CBFS config file The function uses the cbfstool to extract the coreboot's config and a regexp to extract the Kconfig names and values to :attr:`coreboot.CBFSImage.kconfig_opts`. Additionally the function calls :meth:`coreboot.CBFSImage._check_for_ipxe` and :meth:`coreboot.CBFSImage._check_for_lanrom`. """ kconfig_pattern = r'^CONFIG_(?P