Source code for flowstrider.converters.threats_to_file_converter

# SPDX-FileCopyrightText: 2025 German Aerospace Center (DLR)
#
# SPDX-License-Identifier: BSD-3-Clause

import os
import pathlib
import sys
import typing
from datetime import date
from io import StringIO

from fpdf import FPDF, XPos, YPos, svg

from flowstrider import __version__, settings
from flowstrider.converters import threats_formatter
from flowstrider.models import dataflowdiagram, threat_management
from flowstrider.rules.collections import all_collections

# Constants:
FONT_SIZE_HEADER1 = 20
FONT_SIZE_HEADER2 = 16
FONT_SIZE_HEADER3 = 14
FONT_SIZE_NORMAL = 12
FONT_SIZE_SMALL = 9
# Default A4 (210 * 297) measurements
WIDTH = 210
HEIGHT = 297
IMAGE_BORDER = 12
IMAGE_Y_START = 70
TABLE_LEFT_WIDTH = 40
LINE_HEIGHT = 8


[docs] class PDF(FPDF): page: int
[docs] def footer(self): self.set_y(-15) self.set_font("Helvetica", "", FONT_SIZE_NORMAL) if self.page_no() > 1: self.cell(0, 10, str(self.page_no()), align="C")
[docs] def get_string_line_count(self, input: str, width: int) -> int: """ Returns number of lines a given string would need to be displayed in a column of given width """ line_count = 1 words = input.split(" ") current_line = words[0] i = 1 while i <= len(words): if self.get_string_width(current_line) > width: line_count += 1 if len(current_line.split(" ")) == 1: j = 0 for j in range(len(current_line)): if self.get_string_width(current_line[:j]) > width: break current_line = current_line[j - 1 :] else: current_line = words[i - 1] else: if i < len(words): current_line += " " + words[i] i += 1 return line_count
[docs] def add_row( self, left_value: str, right_value: str, left_width: int = TABLE_LEFT_WIDTH, ): """ Method to add two cells next to each other with content dependent sizing """ # Get needed y-space for left column to insert new page preventatively # ...if necessary because it's being cut off otherwise lines = self.get_string_line_count(left_value, TABLE_LEFT_WIDTH) if self.get_y() + (lines * LINE_HEIGHT) > HEIGHT - 20: self.add_page() x = self.get_x() y = self.get_y() p: int = self.page self.multi_cell(left_width, LINE_HEIGHT, left_value) y_left_new = self.get_y() page_left_new = self.page self.page = p self.set_xy(x + left_width, y) self.multi_cell(0, LINE_HEIGHT, right_value) # Return to position if y_left_new > self.get_y() and page_left_new >= self.page: self.set_xy(x, y_left_new) else: self.set_xy(x, self.get_y())
[docs] def add_table_h_line(self): """ Adds a horizontal line to the pdf for use in tables (needed because the cell borders work differently in fpdf2 than in fpdf, they are repeated on a new page) """ x = self.get_x() y = self.get_y() p = self.page self.cell(0, 1, "", "T") self.page = p self.set_xy(x, y)
[docs] def create_threats_pdf( dfd: dataflowdiagram.DataflowDiagram, threat_management_database: threat_management.ThreatManagementDatabase, threats_info_holder: threats_formatter.ThreatsInfoContainer, dfd_path: pathlib.Path, management_path: typing.Union[pathlib.Path, None], output_path: pathlib.Path, quiet: bool, ): """Generates a pdf report with all generated threats for a dfd and the graphviz visualisation if apllicable Args: dfd: the dataflowdiagram for which the threats were generated threats_management_database: management data for the threats threats_info_holder: strings to print and formatted strings output_path: path to save the pdf to """ # Define output language _ = settings.lang_out.gettext # Creating the PDF pdf = PDF() pdf.alias_nb_pages() pdf.set_margins(20, 20, 20) pdf.add_page() # First page w header and title pdf.set_font("Helvetica", "B", FONT_SIZE_HEADER1) pdf.ln(10) pdf.cell( 0, 5, _("Automated Threat Modeling Results"), 0, align="C", new_x=XPos.LMARGIN, new_y=YPos.NEXT, ) pdf.ln(10) pdf.set_font("", "", FONT_SIZE_HEADER3) pdf.cell( 0, 5, _("Project: {name}").format(name=dfd.id), 0, align="C", new_x=XPos.LMARGIN, new_y=YPos.NEXT, ) pdf.ln(2) pdf.cell( 0, 5, _("Generated on: {today}").format(today=date.today().strftime("%Y-%m-%d")), 0, align="C", new_x=XPos.LMARGIN, new_y=YPos.NEXT, ) # FPDF2 doesn't recognize the metadata information in the svgs, warnings are being # ...filtered here. (warnings.filterwarnings() didn't work with FPDF2) error_stream = StringIO() temp_stderr = sys.stderr sys.stderr = error_stream # Add dfd image pdf.start_section(_("DFD Graph"), level=0) visualization_file = "output/visualization/visualization.svg" if os.path.exists(visualization_file): visualization_svg = svg.SVGObject.from_file(visualization_file) visualization_width = visualization_svg.width visualization_height = visualization_svg.height visualization_ratio = visualization_width / visualization_height area_width = WIDTH - (2 * IMAGE_BORDER) area_height = HEIGHT - IMAGE_Y_START - IMAGE_BORDER area_ratio = area_width / area_height # Fit to either height or width of image area depending on ratio of png if visualization_ratio > area_ratio: # Fit to width of image area image_height = area_width / visualization_ratio center_position = IMAGE_Y_START + (area_height / 2) - (image_height / 2) pdf.image(visualization_file, IMAGE_BORDER, center_position, area_width, 0) else: # Fit to height of image area image_width = area_height * visualization_ratio center_position = (WIDTH / 2) - (image_width / 2) pdf.image( visualization_file, center_position, IMAGE_Y_START, 0, area_height ) sys.stderr = temp_stderr # Write if FPDF2 threw warnings errors = error_stream.getvalue() error_stream.close() if errors: for error in errors.split("\n"): if not ( "Ignoring unsupported SVG tag: <title>" in error or ("Ignoring unsupported SVG tag: <a>") in error ): print(error) # Add threats starting on second page pdf.add_page() pdf.start_section(_("Elicited Threats"), level=0) if "no_threats" in threats_info_holder.info_strings: pdf.set_font("", "B", FONT_SIZE_HEADER2) pdf.write(5, threats_info_holder.info_strings["no_threats"] + "\n") else: pdf.set_font("", "BU", FONT_SIZE_HEADER2) pdf.write(5, _("The following threats were elicited:") + "\n") # Number of threats: pdf.ln(10) pdf.set_font("", "B", FONT_SIZE_NORMAL) pdf.write( 5, threats_info_holder.info_strings["threat_numbers"] + "\n", ) pdf.set_font("", "", FONT_SIZE_NORMAL) # Filters: pdf.ln(5) if len(threats_info_holder.info_strings["filters"]) > 0: pdf.write(5, threats_info_holder.info_strings["filters"] + "\n") # Number of displayed threats: pdf.ln(5) if len(threats_info_holder.info_strings["threat_numbers_after_filters"]) > 0: pdf.write( 5, threats_info_holder.info_strings["threat_numbers_after_filters"] + "\n", ) pdf.ln(5) # Used rule collections: pdf.write(5, threats_info_holder.info_strings["collections_header"] + "\n") i = 0 while "collection" + str(i) in threats_info_holder.info_strings: pdf.write(5, threats_info_holder.info_strings["collection" + str(i)] + "\n") i += 1 pdf.ln(10) sources_occurences: typing.Dict[str, int] = ( threats_info_holder.sources_occurences.copy() ) # Iterate over all threat groups source_index = 0 for group_index, (ignore, group) in enumerate( threats_info_holder.threat_groups.items(), 1 ): if not quiet and group_index > 1: pdf.add_page() # Print the group name (group.name == "" only occurs if there is only one group # ...meaning no grouping was applied and groups are ignored for output) if group.name != "": group_display_name = _("Group") + f" {str(group_index)} - {group.name}" pdf.start_section(group_display_name, level=1) pdf.set_font("", "BU", FONT_SIZE_HEADER2) pdf.add_row( "", group_display_name + ":", 0, ) pdf.set_font("", "", FONT_SIZE_NORMAL) # Print each individual combination of threat source and severity within the # ...current group for index, ((source, severity), threats_) in enumerate( group.threats_by_source_and_severity.items() ): source_index += 1 pdf.set_font("", "B", FONT_SIZE_HEADER3) if sources_occurences[source] > 0: source_occurence = sources_occurences[source] sources_occurences[source] += 1 pdf.add_row( "", _("#{number} Threat source: {src}").format( number=source_index, src=source ) + " (" + str(source_occurence) + ")", 0, ) else: pdf.add_row( "", _("#{number} Threat source: {src}").format( number=source_index, src=source ), 0, ) pdf.set_font("", "", FONT_SIZE_NORMAL) # Print which rule set the rule came from if multiple rule sets are active # ...("collection1" would be the second collection in the info strings if it # ...exists meaning there are at least two) if not quiet and "collection1" in threats_info_holder.info_strings: pdf.ln(-1) pdf.set_font("", "I", FONT_SIZE_SMALL) pdf.add_row( "", "(" + threats_[0].rule_set_name + " " + _("rule") + ")", 0 ) pdf.set_font("", "", FONT_SIZE_NORMAL) pdf.ln(1) elif not quiet: pdf.ln(3) pdf.add_table_h_line() pdf.add_row(_("Description:"), threats_[0].short_description) pdf.add_table_h_line() pdf.set_font("", "B", FONT_SIZE_NORMAL) pdf.add_row(_("Severity:"), str(round(severity, 2))) pdf.set_font("", "", FONT_SIZE_NORMAL) if not quiet: pdf.add_table_h_line() pdf.add_row(_("Long Description:"), threats_[0].long_description) for mitigation_option in threats_[0].mitigation_options: # Add top border only for the first mitigation option if mitigation_option is threats_[0].mitigation_options[0]: pdf.add_table_h_line() pdf.add_row(_("Mitigation Option:"), mitigation_option) if threats_[0].requirement != "": pdf.add_table_h_line() pdf.add_row(_("Requirement:"), threats_[0].requirement) # Print each location where the threat occurs for j in range(len(threats_)): if j == 0: pdf.add_table_h_line() pdf.add_row( _("Locations:") + " (" + str(len(threats_)) + ")", threats_[j].location_str(dfd) + ":", ) else: pdf.add_row("", threats_[j].location_str(dfd) + ":") if threats_[j].req_status != "": pdf.set_font("", "B", FONT_SIZE_NORMAL) pdf.add_row( "", threats_[j].req_status, left_width=TABLE_LEFT_WIDTH + 5 ) pdf.set_font("", "", FONT_SIZE_NORMAL) if not quiet: threat_management_item = threat_management_database.get( threats_[j], dfd ) pdf.add_row( "", _("Management State:") + " {content}".format( content=threat_management_item.management_state ), left_width=TABLE_LEFT_WIDTH, ) if len(threat_management_item.explanation) > 0: pdf.add_row( "", _("Management Explanation:") + " " + threat_management_item.explanation, left_width=TABLE_LEFT_WIDTH, ) if not quiet and j != len(threats_) - 1: pdf.add_row("", "", left_width=TABLE_LEFT_WIDTH) pdf.add_table_h_line() pdf.ln(19) # Add references for used rule sets: if not quiet and len(threats_info_holder.sources_occurences) > 0: pdf.add_page() pdf.start_section(_("References"), level=0) pdf.set_font("", "BU", FONT_SIZE_HEADER2) pdf.write(5, _("References:")) pdf.ln(5) pdf.set_font("", "", FONT_SIZE_NORMAL) for collection in all_collections: if collection.tag in dfd.tags: pdf.ln(5) pdf.write(5, collection.name + " " + _("rule collection") + ":\n") for ref in collection.references: pdf.write(5, ref + "\n", ref) # Add FlowStrider version: pdf.ln(20) pdf.set_font("", "I", FONT_SIZE_NORMAL) temp_str = _( "This report for the project '{name}' was generated on {today} based on " + "the data-flow diagram located at '{path}'" ).format( name=dfd.id, today=date.today().strftime("%Y-%m-%d"), path=os.path.abspath(dfd_path), ) if management_path is not None: temp_str += _( " and the management information file located at '{path}'" ).format(path=os.path.abspath(management_path)) temp_str += ".\n" + _("Generated by FlowStrider " + "version {vers}.").format( vers=__version__ ) pdf.write(5, temp_str) if not os.path.exists("output"): os.mkdir("output") pdf.output(output_path)