diff --git a/framework/python/src/api/api.py b/framework/python/src/api/api.py
index 6a66613b2..fbca9a0d1 100644
--- a/framework/python/src/api/api.py
+++ b/framework/python/src/api/api.py
@@ -563,7 +563,7 @@ async def get_results(self, request: Request, response: Response, device_name,
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 453e09beb..69e77e6d2 100644
--- a/framework/python/src/common/risk_profile.py
+++ b/framework/python/src/common/risk_profile.py
@@ -14,13 +14,29 @@
"""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
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"""
@@ -36,6 +52,8 @@ def __init__(self, profile_json=None, profile_format=None):
self.questions = profile_json['questions']
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)
@@ -49,6 +67,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)
@@ -157,13 +176,14 @@ def update_risk(self, profile_format):
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:
@@ -271,3 +291,344 @@ def to_json(self, pretty=False):
}
indent = 2 if pretty else None
return json.dumps(json_dict, indent=indent)
+
+ def to_html(self, device):
+
+ self._device = device
+
+ 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'''
+
+ '''
+ 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 '''
+
+
'''
+
+ 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;
+ align-items: stretch;
+ overflow: hidden;
+ }
+
+ .risk-question-no {
+ padding: 15px 20px;
+ width: 10px;
+ display: inline-block;
+ vertical-align: top;
+ position: relative;
+ }
+
+ .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;
+ height: 100%;
+ }
+
+ ul {
+ margin-top: 0;
+ }
+ '''
+
+ 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..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(
@@ -286,9 +290,14 @@ def zip_results(self,
src_path,
"profile.json"))
- # 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.pdf"), "wb") as f:
+ f.write(profile.to_pdf(device).getvalue())
+
+ 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..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==60.2
+weasyprint==61.2
# Requirements for the API
fastapi==0.109.1
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"
},
{