From f0786e3516666b1feabd94ac0a70f1142421a10e Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Thu, 27 Jun 2024 19:08:39 +0100 Subject: [PATCH 1/7] Work on pdf --- framework/python/src/common/risk_profile.py | 28 +++++++++++++++++++ .../python/src/test_orc/test_orchestrator.py | 3 ++ 2 files changed, 31 insertions(+) diff --git a/framework/python/src/common/risk_profile.py b/framework/python/src/common/risk_profile.py index 4eca18b88..6d9952df7 100644 --- a/framework/python/src/common/risk_profile.py +++ b/framework/python/src/common/risk_profile.py @@ -14,6 +14,9 @@ """Stores additional information about a device's risk""" from datetime import datetime from dateutil.relativedelta import relativedelta +from weasyprint import HTML +from io import BytesIO +import base64 from common import logger import json @@ -244,3 +247,28 @@ def to_json(self, pretty=False): } indent = 2 if pretty else None return json.dumps(json_dict, indent=indent) + + def to_html(self, device): + + print(device.firmware) + + json_data = self.to_json() + return f''' + + + + + + + + ''' + + def to_pdf(self, device): + + # Resolve the data as html first + html = self.to_html(device) + + # Convert HTML to PDF in memory using weasyprint + pdf_bytes = BytesIO() + HTML(string=html).write_pdf(pdf_bytes) + return pdf_bytes diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index d3c12d905..1b732d5ea 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -286,6 +286,9 @@ def zip_results(self, src_path, "profile.json")) + with open(os.path.join(src_path, "profile.pdf"), "wb") as f: + f.write(profile.to_pdf(device).getvalue()) + # Create ZIP file if not os.path.exists(zip_location + ".zip"): shutil.make_archive(zip_location, "zip", src_path) From 0effa16441d09ede5ac2653c40a2485d3c71bb20 Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Fri, 28 Jun 2024 16:17:49 +0100 Subject: [PATCH 2/7] Work on profile PDF --- framework/python/src/api/api.py | 2 +- framework/python/src/common/risk_profile.py | 346 +++++++++++++++++- .../python/src/test_orc/test_orchestrator.py | 12 +- framework/requirements.txt | 2 +- 4 files changed, 350 insertions(+), 12 deletions(-) diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py index 62a4f78ad..044d3fb07 100644 --- a/framework/python/src/api/api.py +++ b/framework/python/src/api/api.py @@ -598,7 +598,7 @@ async def get_results(self, request: Request, req_json = json.loads(req_raw) # Check if profile has been specified - if "profile" in req_json: + if "profile" in req_json and len(req_json.get("profile")) > 0: profile_name = req_json.get("profile") profile = self.get_session().get_profile(profile_name) diff --git a/framework/python/src/common/risk_profile.py b/framework/python/src/common/risk_profile.py index d80f804be..3fd153638 100644 --- a/framework/python/src/common/risk_profile.py +++ b/framework/python/src/common/risk_profile.py @@ -22,8 +22,21 @@ import os PROFILES_PATH = 'local/risk_profiles' - LOGGER = logger.get_logger('risk_profile') +RESOURCES_DIR = 'resources/report' + +# Locate parent directory +current_dir = os.path.dirname(os.path.realpath(__file__)) + +# Locate the test-run root directory, 4 levels, src->python->framework->test-run +root_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(current_dir)))) + +# Obtain the report resources directory +report_resource_dir = os.path.join(root_dir, RESOURCES_DIR) + +test_run_img_file = os.path.join(report_resource_dir, 'testrun.png') + class RiskProfile(): """Python representation of a risk profile""" @@ -40,6 +53,9 @@ def __init__(self, profile_json=None, profile_format=None): self.status = None self.risk = None + self._profile_format = profile_format + self._device = None + self._validate(profile_json, profile_format) self._update_risk(profile_format) @@ -52,6 +68,7 @@ def load(self, profile_json, profile_format): self.version = profile_json['version'] self.questions = profile_json['questions'] self.status = None + self._profile_format = profile_format self._validate(profile_json, profile_format) self._update_risk(profile_format) @@ -153,13 +170,14 @@ def _update_risk(self, profile_format): risk = None self.risk = risk - def _get_format_question(self, question, profile_format): + def _get_format_question(self, question: str, profile_format: dict): + for q in profile_format: if q['question'] == question: return q return None - def _get_option_from_index(self, options, index): + def _get_option_from_index(self, options: list, index: int): i = 0 for option in options: if i == index: @@ -257,19 +275,333 @@ def to_json(self, pretty=False): def to_html(self, device): - print(device.firmware) + self._device = device - json_data = self.to_json() return f''' - + {self._generate_head()} - +
+ {self._generate_header()} + {self._generate_risk_banner()} + {self._generate_risk_questions()} + {self._generate_footer()} +
''' + def _generate_head(self): + + return f''' + + + + Risk Assessment + + + ''' + + def _generate_header(self): + with open(test_run_img_file, 'rb') as f: + tr_img_b64 = base64.b64encode(f.read()).decode('utf-8') + header = f''' +
+

Risk assessment

+

+ {self._device.manufacturer} + {self._device.model} +

''' + header += f'''Testrun +
+ ''' + return header + + def _generate_risk_banner(self): + return f''' +
+
+

{'high' if self.risk == 'High' else 'limited'} Risk

+
+
+ The device assessed with a high security risk profile due to its dedicated functionality, lack of sensitive data storage, and closed network operation. +
+
+ ''' + + def _generate_risk_questions(self): + + max_page_height = 350 + content = '' + + content += self._generate_table_head() + + index = 1 + height = 0 + + for question in self.questions: + + if height > max_page_height: + content += self._generate_new_page() + height = 0 + + content += f''' +
+
{index}.
+
{question['question']}
+
''' + + # String answers (one line) + if isinstance(question['answer'], str): + content += question['answer'] + height += 53 + + # Select multiple answers + elif isinstance(question['answer'], list): + content += '
    ' + + options = self._get_format_question( + question=question['question'], + profile_format=self._profile_format)['options'] + + for answer_index in question['answer']: + height += 40 + content += f''' +
  • + {self._get_option_from_index(options, answer_index)['text']}
  • ''' + + content += '
' + + content += '''
''' + + index += 1 + + return content + + def _generate_table_head(self): + return ''' +
+
+
Question
+
Answer
+
''' + + def _generate_new_page(self): + + # End the current table + content = ''' +
''' + + # End the page + content += self._generate_footer() + content += '' + + # Start a new page + content += ''' +
+ ''' + + content += self._generate_header() + + content += self._generate_table_head() + + return content + + def _generate_footer(self): + footer = f''' + + ''' + return footer + + def _generate_css(self): + return ''' + /* Set some global variables */ + :root { + --header-height: .75in; + --header-width: 8.5in; + --header-pos-x: 0in; + --header-pos-y: 0in; + --page-width: 8.5in; + } + + @font-face { + font-family: 'Google Sans'; + font-style: normal; + src: url(https://fonts.gstatic.com/s/googlesans/v58/4Ua_rENHsxJlGDuGo1OIlJfC6l_24rlCK1Yo_Iqcsih3SAyH6cAwhX9RFD48TE63OOYKtrwEIJllpyk.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + @font-face { + font-family: 'Roboto Mono'; + font-style: normal; + src: url(https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + + /* Define some common body formatting*/ + body { + font-family: 'Google Sans', sans-serif; + margin: 0px; + padding: 0px; + } + + /* Sets proper page size during print to pdf for weasyprint */ + @page { + size: Letter; + width: 8.5in; + height: 11in; + } + + .page { + position: relative; + margin: 0 20px; + width: 8.5in; + height: 11in; + } + + /* Define the header related css elements*/ + .header { + position: relative; + } + + h1 { + margin: 0 0 8px 0; + font-size: 20px; + font-weight: 400; + } + + h2 { + margin: 0px; + font-size: 48px; + font-weight: 700; + } + + h3 { + font-size: 24px; + margin-bottom: 10px; + margin-top: 15px; + } + + h4 { + font-size: 12px; + font-weight: 500; + color: #5F6368; + margin-bottom: 0; + margin-top: 0; + } + + /* CSS for the footer */ + .footer { + position: absolute; + height: 30px; + width: 8.5in; + bottom: 0in; + border-top: 1px solid #D3D3D3; + } + + .footer-label { + color: #3C4043; + position: absolute; + top: 5px; + font-size: 12px; + } + + @media print { + @page { + size: Letter; + width: 8.5in; + height: 11in; + } + } + + .risk-banner { + min-height: 120px; + padding: 5px 40px 0 40px; + margin-top: 30px; + } + + .risk-banner-limited { + background-color: #E4F7FB; + color: #007B83; + } + + .risk-banner-high { + background-color: #FCE8E6; + color: #C5221F; + } + + .risk-banner-title { + text-transform: uppercase; + font-weight: bold; + } + + .risk-table { + width: 100%; + margin-top: 40px; + text-align: left; + color: #3C4043; + font-size: 14px; + } + + .risk-table-head { + margin-bottom: 15px; + } + + .risk-table-head-question { + display: inline-block; + margin-left: 70px; + font-weight: bold; + } + + .risk-table-head-answer { + display: inline-block; + margin-left: 325px; + font-weight: bold; + } + + .risk-table-row { + margin-bottom: 8px; + background-color: #F8F9FA; + display: flex; + } + + .risk-question-no { + padding: 15px 20px; + width: 10px; + display: inline-block; + vertical-align: top; + position: relative; + height: 100%; + } + + .risk-question { + padding: 15px 20px; + display: inline-block; + width: 350px; + vertical-align: top; + position: relative; + height: 100%; + } + + .risk-answer { + background-color: #E8F0FE; + padding: 15px 20px; + display: inline-block; + width: 340px; + position: relative; + } + + ul { + margin-top: 0; + } + ''' + def to_pdf(self, device): # Resolve the data as html first diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index 1b732d5ea..8f35b3457 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -277,6 +277,10 @@ def zip_results(self, "{device_folder}", device.device_folder), timestamp ) + # Delete ZIP if it already exists + if os.path.exists(zip_location + ".zip"): + os.remove(zip_location + ".zip") + # Include profile if specified if profile is not None: LOGGER.debug( @@ -289,9 +293,11 @@ def zip_results(self, with open(os.path.join(src_path, "profile.pdf"), "wb") as f: f.write(profile.to_pdf(device).getvalue()) - # Create ZIP file - if not os.path.exists(zip_location + ".zip"): - shutil.make_archive(zip_location, "zip", src_path) + with open(os.path.join(src_path, "profile.html"), "w") as fp: + fp.write(profile.to_html(device)) + + # Create ZIP archive + shutil.make_archive(zip_location, "zip", src_path) # Check that the ZIP was successfully created zip_file = zip_location + ".zip" diff --git a/framework/requirements.txt b/framework/requirements.txt index ee96c7e61..109131656 100644 --- a/framework/requirements.txt +++ b/framework/requirements.txt @@ -8,7 +8,7 @@ netifaces==0.11.0 scapy==2.5.0 # Requirments for the test_orc module -weasyprint==60.2 +weasyprint==62.3 # Requirements for the API fastapi==0.109.1 From e11e42a6cc55b855c7d49ced2b51faa778887597 Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Mon, 1 Jul 2024 11:41:57 +0100 Subject: [PATCH 3/7] Fix risk answer formatting --- framework/python/src/common/risk_profile.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/framework/python/src/common/risk_profile.py b/framework/python/src/common/risk_profile.py index 3fd153638..d74e2f4c4 100644 --- a/framework/python/src/common/risk_profile.py +++ b/framework/python/src/common/risk_profile.py @@ -569,6 +569,8 @@ def _generate_css(self): margin-bottom: 8px; background-color: #F8F9FA; display: flex; + align-items: stretch; + overflow: hidden; } .risk-question-no { @@ -577,7 +579,6 @@ def _generate_css(self): display: inline-block; vertical-align: top; position: relative; - height: 100%; } .risk-question { @@ -595,6 +596,7 @@ def _generate_css(self): display: inline-block; width: 340px; position: relative; + height: 100%; } ul { From bd8f702d11184e68d3f436da3b359d7cd238b612 Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Mon, 1 Jul 2024 11:43:04 +0100 Subject: [PATCH 4/7] Downgrade weasyprint --- framework/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/requirements.txt b/framework/requirements.txt index 109131656..cd82b918a 100644 --- a/framework/requirements.txt +++ b/framework/requirements.txt @@ -8,7 +8,7 @@ netifaces==0.11.0 scapy==2.5.0 # Requirments for the test_orc module -weasyprint==62.3 +weasyprint==61.2 # Requirements for the API fastapi==0.109.1 From 385d85a323d34c7e279e8580a2180a0fb6a982e6 Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Mon, 1 Jul 2024 22:35:14 +0100 Subject: [PATCH 5/7] Remove duplicate line --- framework/python/src/common/risk_profile.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/framework/python/src/common/risk_profile.py b/framework/python/src/common/risk_profile.py index d74e2f4c4..c52d9b1f3 100644 --- a/framework/python/src/common/risk_profile.py +++ b/framework/python/src/common/risk_profile.py @@ -52,8 +52,6 @@ def __init__(self, profile_json=None, profile_format=None): self.questions = profile_json['questions'] self.status = None self.risk = None - - self._profile_format = profile_format self._device = None self._validate(profile_json, profile_format) From e32d547bd33aca9dc0ca103a9514a17a702d8a18 Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Mon, 1 Jul 2024 22:37:21 +0100 Subject: [PATCH 6/7] Update risk assessment after review --- resources/risk_assessment.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/resources/risk_assessment.json b/resources/risk_assessment.json index 784322421..b94c5b7b6 100644 --- a/resources/risk_assessment.json +++ b/resources/risk_assessment.json @@ -13,7 +13,7 @@ }, { "text": "Controller - AHU", - "risk": "Limited" + "risk": "High" }, { "text": "Controller - Boiler", @@ -33,7 +33,7 @@ }, { "text": "Controller - CRAC", - "risk": "Limited" + "risk": "High" }, { "text": "Controller - VAV", @@ -57,7 +57,7 @@ }, { "text": "Controller - Blinds/Facades", - "risk": "Limited" + "risk": "High" }, { "text": "Controller - Lifts/Elevators", @@ -65,7 +65,7 @@ }, { "text": "Controller - UPS", - "risk": "Limited" + "risk": "High" }, { "text": "Sensor - Air Quality", @@ -165,7 +165,7 @@ }, { "text": "Tablet", - "risk": "Limited" + "risk": "High" } ], "validation": { @@ -252,7 +252,7 @@ "type": "select-multiple", "options": [ { - "text": "PII/PHI, confidential business data, or crown jewel data is transmitted to a destination outside Alphabet's ownership", + "text": "PII/PHI, confidential/sensitive business data, Intellectual Property and Trade Secrets, Critical Infrastructure and Identity Assets to a domain outside Alphabet's ownership", "risk": "High" }, { From 7f6cbd0b65a3668ddfa7e4a10354e6a538cffced Mon Sep 17 00:00:00 2001 From: Jacob Boddey Date: Thu, 4 Jul 2024 12:05:51 +0100 Subject: [PATCH 7/7] Fix profile format undefined --- framework/python/src/common/risk_profile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/framework/python/src/common/risk_profile.py b/framework/python/src/common/risk_profile.py index 017251c29..69e77e6d2 100644 --- a/framework/python/src/common/risk_profile.py +++ b/framework/python/src/common/risk_profile.py @@ -53,6 +53,7 @@ def __init__(self, profile_json=None, profile_format=None): self.status = None self.risk = None self._device = None + self._profile_format = profile_format self._validate(profile_json, profile_format) self.update_risk(profile_format)