# 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 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)