# SPDX-FileCopyrightText: 2025 German Aerospace Center (DLR)
#
# SPDX-License-Identifier: BSD-3-Clause
import os
import pathlib
from datetime import date
from typing import List
from fpdf import FPDF
from PIL import Image
from flowstrider import settings
from flowstrider.models import dataflowdiagram, threat, 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
# 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):
[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
# Helper method to add two cells next to each other with content dependent sizing
[docs]
def add_row(
self,
left_value: str,
right_value: str,
left_width: int = TABLE_LEFT_WIDTH,
border: str = "",
):
# 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 = self.page
self.multi_cell(left_width, LINE_HEIGHT, left_value, border=border)
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, border=border)
if y_left_new > self.get_y() and page_left_new >= self.page:
self.set_xy(self.get_x(), y_left_new)
[docs]
def create_threats_pdf(
threats: List[threat.Threat],
dfd: dataflowdiagram.DataflowDiagram,
threat_management_database: threat_management.ThreatManagementDatabase,
output_path: pathlib.Path,
):
"""Generates a pdf report with all generated threats for a dfd
and the graphviz visualisation if apllicable
Args:
threats: a list of threats generated by the elicit command
dfd: the dataflowdiagram for which the threats were generated
"""
# 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("Arial", "B", FONT_SIZE_HEADER1)
pdf.ln(10)
pdf.cell(0, 5, _("Automated Threat Modeling Results"), 0, 1, "C")
pdf.ln(10)
pdf.set_font("", "", FONT_SIZE_HEADER3)
pdf.cell(0, 5, _("Project: {name}").format(name=dfd.id), 0, 1, "C")
pdf.ln(2)
pdf.cell(
0,
5,
_("Generated on: {today}").format(today=date.today().strftime("%d.%m.%Y")),
0,
1,
"C",
)
# Add dfd image
visualization_file = "output/visualization/visualization.png"
if os.path.exists(visualization_file):
visualization_png = Image.open(visualization_file)
visualization_width, visualization_height = visualization_png.size
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
)
# Count and divide threats by source and severity for better overview
threats_by_source = {}
threats_by_source_and_severity = {}
threat_sources_occurences = {} # How often each single source occurs
threats_involved_locations = []
for threat_ in threats:
source = threat_.source
severity = threat_.severity
location = threat.location_str(threat_.location, dfd)
if source not in threats_by_source:
threats_by_source[source] = []
threat_sources_occurences[source] = 0
threats_by_source[source].append(threat_)
if (source, severity) not in threats_by_source_and_severity:
threats_by_source_and_severity[(source, severity)] = []
threat_sources_occurences[source] += 1
threats_by_source_and_severity[(source, severity)].append(threat_)
if location not in threats_involved_locations:
threats_involved_locations.append(location)
# Disregard the counter for sources who appear only once
for source, occurences in threat_sources_occurences.items():
if occurences == 1:
threat_sources_occurences[source] = 0
else:
threat_sources_occurences[source] = 1
# Add threats starting on second page
pdf.add_page()
if len(threats) == 0:
pdf.set_font("", "B", FONT_SIZE_HEADER2)
pdf.cell(0, 5, _("There were no threats found."), 0, 1, "")
else:
pdf.set_font("", "BU", FONT_SIZE_HEADER2)
pdf.cell(0, 5, _("The following threats were elicited:"), 0, 1, "")
pdf.ln(10)
# Used rule collections:
pdf.set_font("", "I", FONT_SIZE_NORMAL)
pdf.cell(0, 5, _("Used rule collections:"), 0, 1, "")
collection_names_list = []
for collection in all_collections:
for tag in collection.tags:
if tag in dfd.tags:
collection_names_list.append(collection.name)
break
collection_names = ", ".join(collection_names_list)
pdf.cell(0, 5, collection_names, 0, 1, "")
# Number of threats:
pdf.ln(10)
pdf.write(
5,
settings.lang_out.ngettext(
"One threat has been found.",
"{count} threats have been found.",
len(threats),
).format(count=len(threats))
+ " ("
+ settings.lang_out.ngettext(
"One different threat",
"{count} different threats",
len(threats_by_source),
).format(count=len(threats_by_source))
+ " "
+ settings.lang_out.ngettext(
"and a total of one involved location",
"and a total of {count} involved locations",
len(threats_involved_locations),
).format(count=len(threats_involved_locations))
+ ".)\n",
)
pdf.set_font("", "", FONT_SIZE_NORMAL)
pdf.ln(10)
# Print each individual combination of threat source and severity so results are
# ...primarily sorted by severity and by source secondarily
for i, ((source, severity), threats_) in enumerate(
threats_by_source_and_severity.items()
):
pdf.set_font("", "B", FONT_SIZE_HEADER3)
if threat_sources_occurences[source] > 0:
source_occurence = threat_sources_occurences[source]
threat_sources_occurences[source] += 1
pdf.add_row(
"",
_("#{number} Threat source: {src}").format(number=i + 1, src=source)
+ " ("
+ str(source_occurence)
+ ")",
0,
)
else:
pdf.add_row(
"",
_("#{number} Threat source: {src}").format(number=i + 1, src=source),
0,
)
pdf.set_font("", "", FONT_SIZE_NORMAL)
pdf.ln(3)
pdf.add_row(_("Description:"), threats_[0].short_description, border="T")
pdf.set_font("", "B", FONT_SIZE_NORMAL)
pdf.add_row(_("Severity:"), str(round(severity, 2)), border="T")
pdf.set_font("", "", FONT_SIZE_NORMAL)
pdf.add_row(_("Long Description:"), threats_[0].long_description, border="T")
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_row(_("Mitigation Option:"), mitigation_option, border="T")
else:
pdf.add_row(_("Mitigation Option:"), mitigation_option, border="")
if threats_[0].requirement != "":
pdf.add_row(_("Requirement:"), threats_[0].requirement, border="T")
# Print each location where the threat occurs
for j in range(len(threats_)):
if j == 0:
pdf.add_row(
_("Locations:"), threats_[j].location_str(dfd) + ":", border="T"
)
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)
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 j != len(threats_) - 1:
pdf.add_row("", "\n", left_width=TABLE_LEFT_WIDTH)
pdf.cell(TABLE_LEFT_WIDTH, 0, "", border="T")
pdf.multi_cell(0, 0, "", border="T")
pdf.ln(20)
# Add references for used rule sets:
if len(threats) > 0:
pdf.set_font("", "BU", FONT_SIZE_HEADER2)
pdf.write(5, _("References:"))
pdf.set_font("", "", FONT_SIZE_NORMAL)
for collection in all_collections:
for tag in collection.tags:
if tag in dfd.tags:
pdf.ln(10)
pdf.write(5, collection.name + ":\n")
for ref in collection.references:
pdf.write(5, ref + "\n", ref)
if not os.path.exists("output"):
os.mkdir("output")
pdf.output(output_path)