diff --git a/backend/core/admin.py b/backend/core/admin.py index 8623efb..2a366f5 100644 --- a/backend/core/admin.py +++ b/backend/core/admin.py @@ -36,7 +36,7 @@ class RiskAdmin(admin.ModelAdmin): list_display = ['risk_id','risk_name','category'] class ControlAdmin(admin.ModelAdmin): - list_display = ('id', 'name') + list_display = ('id','safeguard_id','name', 'description') class DocumentRiskControlAdmin(admin.ModelAdmin): list_display = ('document', 'risk', 'control', 'weight','likelihood') diff --git a/backend/core/management/commands/import_controls.py b/backend/core/management/commands/import_controls.py index e201705..2c51526 100644 --- a/backend/core/management/commands/import_controls.py +++ b/backend/core/management/commands/import_controls.py @@ -15,10 +15,13 @@ class Command(BaseCommand): reader = csv.DictReader(csv_file) for row in reader: - safeguard = row["CIS v8.1 Safeguards (Sub-Controls)"].strip() - + safeguard_id = row["Safeguard ID"].strip() + safeguard = row["Name"].strip() + description = row["Description"].strip() Control.objects.update_or_create( - name=safeguard, + name=safeguard, + safeguard_id = safeguard_id, + description=description, defaults={"name": safeguard}, ) diff --git a/backend/core/migrations/0015_control_description_control_safeguard_id.py b/backend/core/migrations/0015_control_description_control_safeguard_id.py new file mode 100644 index 0000000..8659390 --- /dev/null +++ b/backend/core/migrations/0015_control_description_control_safeguard_id.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.3 on 2025-06-13 14:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_alter_organization_customer_base'), + ] + + operations = [ + migrations.AddField( + model_name='control', + name='description', + field=models.TextField(default=' ', help_text='Description of the control'), + ), + migrations.AddField( + model_name='control', + name='safeguard_id', + field=models.CharField(help_text="Unique identifier for the safeguard (e.g. '1.1', '4.6')", max_length=10, null=True, unique=True), + ), + ] diff --git a/backend/core/models.py b/backend/core/models.py index 1d7d566..75cc0a4 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -157,8 +157,9 @@ class Risk(models.Model): class Control(models.Model): id = models.AutoField(primary_key=True) + safeguard_id = models.CharField(max_length=10, unique=True, null=True, help_text="Unique identifier for the safeguard (e.g. '1.1', '4.6')") name = models.CharField(max_length=255) - + description = models.TextField(default=" ", help_text="Description of the control") def __str__(self): return f"{self.id} ({self.name})" diff --git a/backend/core/processors.py b/backend/core/processors.py index 74c6309..6a5ecef 100644 --- a/backend/core/processors.py +++ b/backend/core/processors.py @@ -18,6 +18,50 @@ def render_universal_segment(segment, context_data): rendered = [] context = Context(context_data) + if segment_type == 'organization': + rendered.append( + f'
' + f'' + f'

Cyber Risk Assessment Report

' + f'

Comprehensive Evaluation and Strategic Recommendations for Enhanced Cybersecurity Posture

' + f'
' + f'

Prepared for

') + for item in content: + name = Template(item.get('name', '')).render(context) + date = Template(item.get('date', '')).render(context) + if name: + rendered.append(f'

{name}

') + rendered.append(f'

Prepared by

' + f'

Risklet

' + f'
') + if date: + rendered.append(f'

{date}

') + rendered.append(f'
') + return '\n'.join(rendered) + + elif segment_type == 'disclaimer': + rendered.append( + f'
' + f'' + ) + for item in content: + subtitle = Template(item.get('subtitle', '')).render(context) + description = Template(item.get('description', '')).render(context) + if subtitle: + rendered.append(f'

{subtitle}

') + if description: + processed_desc = [] + for line in description.split('\n'): + line = line.strip() + if line: + processed_desc.append(f'

{line}

') + rendered.append('\n'.join(processed_desc)) + rendered.append(f'
') + return '\n'.join(rendered) + + else: + rendered.append(f'
') + for item in content: if not isinstance(item, dict): continue @@ -27,10 +71,10 @@ def render_universal_segment(segment, context_data): description = Template(item.get('description', '')).render(context) if title: - rendered.append(f'

{title}

') + rendered.append(f'

{title}

') if subtitle: - rendered.append(f'

{subtitle}

') + rendered.append(f'

{subtitle}

') if description: processed_desc = [] @@ -47,7 +91,7 @@ def render_universal_segment(segment, context_data): processed_desc.append('') in_list = False if line: - processed_desc.append(f'

{line}

') + processed_desc.append(f'

{line}

') if in_list: processed_desc.append('') rendered.append('\n'.join(processed_desc)) @@ -68,20 +112,53 @@ def render_universal_segment(segment, context_data): table_html.append('') rendered.append('\n'.join(table_html)) + if 'html' in item: + html_template = Template(item['html']) + rendered_html = html_template.render(context) + rendered.append(rendered_html) + if 'image' in item: image_url = Template(item['image']).render(context) rendered.append(f'{title}') + + if 'warning' in item: + warning_text = Template(item['warning']).render(context) + rendered.append(f'

{warning_text}

') + + if 'note' in item: + note_text = Template(item['note']).render(context) + rendered.append(f'

{note_text}

') if 'html' in segment: html_template = Template(segment['html']) rendered_html = html_template.render(context) rendered.append(rendered_html) + rendered.append('
') return '\n'.join(rendered) def render_template(template_segments, context_data): final_output = [] + container_segments = [] + disclaimer_segment = None + for segment in template_segments: - segment_html = render_universal_segment(segment, context_data) - final_output.append(f'
{segment_html}
') + segment_type = segment.get('segment_type', 'unknown') + if segment_type == 'organization': + final_output.append(render_universal_segment(segment, context_data)) + elif segment_type == 'disclaimer': + disclaimer_segment = segment + else: + container_segments.append(segment) + + if container_segments: + container_html = ['
'] + for segment in container_segments: + container_html.append(render_universal_segment(segment, context_data)) + container_html.append('
') + final_output.append('\n'.join(container_html)) + + if disclaimer_segment: + final_output.append(render_universal_segment(disclaimer_segment, context_data)) + return '\n'.join(final_output) \ No newline at end of file diff --git a/backend/core/static/css/document.css b/backend/core/static/css/document.css new file mode 100644 index 0000000..ba9a29f --- /dev/null +++ b/backend/core/static/css/document.css @@ -0,0 +1,420 @@ + body { + font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; /* Modern sans-serif font stack */ + line-height: 1.6; /* Improved line spacing */ + margin: 0; + padding: 0; + background-color: #f4f4f4; /* Light background for screen */ + color: #333; /* Dark gray for body text */ + font-size: 16px; /* Base font size */ + font-weight: 400; /* Normal font weight for body */ + } + .container { + max-width: 1000px; + margin: 20px auto; + background-color: #fff; + padding: 30px; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); + border-radius: 8px; + } + h1, h2, h3, h4, h5, h6 { + color: #212529; /* Darker gray for all headings */ + font-weight: 700; /* Bold headings */ + } + + /* --- Front Page Styles --- */ + .front-page { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100vh; /* Full viewport height */ + text-align: center; + background-color: #1a1a2e; /* Dark background from logo */ + color: #fff; /* White text on dark background */ + padding: 20px; + box-sizing: border-box; + } + .front-page .logo { + max-width: 250px; /* Slightly larger logo for front page */ + margin-bottom: 40px; + } + .front-page h1 { + color: #fff; /* White title on dark background */ + font-size: 3.5em; /* Larger title */ + margin: 0 0 15px 0; + text-transform: uppercase; /* Formal look */ + } + .front-page p { + font-size: 1.3em; /* Larger text */ + margin: 8px 0; + color: #ccc; /* Lighter text for details */ + } + .front-page .prepared-by { + margin-top: 30px; + font-size: 1.1em; + } + .front-page strong { + color: #fff; /* Ensure bold text on front page is white */ + } + + + /* --- Section Styles --- */ + .section { + margin-bottom: 40px; /* More white space */ + padding-bottom: 30px; /* More white space */ + border-bottom: 1px solid #eee; + } + .section:last-child { + border-bottom: none; + } + .section h2 { + font-size: 2em; /* Larger section titles */ + border-bottom: 3px solid #4a90e2; /* Thicker blue underline */ + padding-bottom: 15px; + margin-bottom: 25px; + text-transform: uppercase; /* Formal look */ + color: #212529; /* Darker gray for section titles */ + } + .section h3 { + font-size: 1.6em; /* Larger subheadings */ + margin-top: 30px; /* More space above subheadings */ + margin-bottom: 15px; + color: #212529; /* Darker gray for subheadings */ + } + .section p { + margin-bottom: 18px; /* More space between paragraphs */ + } + .section ul, .section ol { + margin-bottom: 18px; + padding-left: 25px; /* More padding */ + } + .section li { + margin-bottom: 10px; /* More space between list items */ + } + + /* --- Table Styles --- */ + table { + width: 100%; + border-collapse: collapse; + margin-bottom: 25px; /* More space below tables */ + box-shadow: 0 2px 8px rgba(0,0,0,0.08); /* More prominent shadow */ + font-size: 0.9em; /* Slightly smaller font in tables */ + } + th, td { + padding: 10px 12px; /* Adjusted padding */ + text-align: left; + border-bottom: 1px solid #ddd; + } + th { + background-color: #4a90e2; /* Primary blue from logo */ + color: #fff; /* White text on blue header */ + font-weight: bold; + text-transform: uppercase; /* Formal header text */ + } + tbody tr:nth-child(even) { + background-color: #f9f9f9; /* Slight stripe for readability */ + } + td { + word-break: break-word; /* Allow long words to break */ + overflow-wrap: break-word; /* Standard way to break words */ + } + + /* Adjust specific column widths if necessary to prevent wrapping */ + .top-risks-table td:nth-child(2) { width: 20%; } /* Risk Name */ + .top-risks-table td:nth-child(6) { width: 35%; } /* Description */ + .residual-risks-table td:nth-child(2) { width: 20%; } /* Risk Name */ + .safeguard-summary-table td:nth-child(2) { width: 20%; } /* Control Title */ + .safeguard-summary-table td:nth-child(3) { width: 30%; } /* Safeguard ID */ + .safeguard-summary-table td:nth-child(4) { width: 30%; } /* Safeguard Description */ + + + /* --- Risk Matrix Styles (Table) --- */ + .risk-matrix table { + width: 90%; /* Wider table */ + margin: 30px auto; /* More space around matrix */ + text-align: center; + table-layout: fixed; /* Fixed layout for uniform cells */ + } + .risk-matrix th, .risk-matrix td { + padding: 15px 5px; /* Adjust padding for square-like cells */ + border: 1px solid #ccc; + width: calc(90% / 6); /* Attempt to make cells roughly square based on width */ + height: 60px; /* Fixed height for square appearance */ + box-sizing: border-box; /* Include padding and border in element's total width and height */ + vertical-align: middle; /* Vertically center content */ + font-size: 0.9em; + text-align: center; /* Center numbers in matrix */ + } + .risk-matrix th { + background-color: #eee; + color: #333; + text-transform: none; + font-size: 1em; + height: 40px; /* Smaller height for header cells */ + } + .risk-matrix td { + font-weight: bold; + color: #333; /* Default color, overridden by background classes */ + } + .risk-matrix .bg-critical { background-color: #e74c3c; color: white; } /* Red */ + .risk-matrix .bg-high { background-color: #f39c12; color: white; } /* Orange */ + .risk-matrix .bg-medium { background-color: #f1c40f; } /* Yellow */ + .risk-matrix .bg-low { background-color: #2ecc71; color: white; } /* Green */ + .risk-matrix .bg-very-low { background-color: #1abc9c; color: white; } /* Teal */ + + + /* --- Risk Matrix Chart Styles --- */ + .risk-chart-container { + width: 90%; + margin: 40px auto; + position: relative; /* For absolute positioning of risks */ + aspect-ratio: 1 / 1; /* Make the container square */ + background: linear-gradient(to top right, #1abc9c, #f1c40f, #f39c12, #e74c3c); /* Gradient background */ + border: 1px solid #ccc; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + } + + .risk-chart-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); /* 5 columns for Likelihood */ + grid-template-rows: repeat(5, 1fr); /* 5 rows for Impact */ + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 1; /* Below the risk markers */ + } + + .risk-chart-grid > div { + border: 1px solid rgba(255, 255, 255, 0.2); /* Subtle grid lines */ + box-sizing: border-box; + } + + .risk-chart-axis-label { + position: absolute; + font-weight: bold; + color: #333; /* Dark text for labels */ + font-size: 1.1em; + z-index: 2; + } + + .risk-chart-axis-label.likelihood { + bottom: -30px; + left: 50%; + transform: translateX(-50%); + } + + .risk-chart-axis-label.impact { + top: 50%; + left: -40px; + transform: translateY(-50%) rotate(-90deg); + white-space: nowrap; + } + + .risk-chart-level-label { + position: absolute; + font-size: 0.9em; + color: #555; + z-index: 2; + } + .risk-chart-level-label.likelihood { + bottom: -15px; + transform: translateX(-50%); + } + .risk-chart-level-label.impact { + left: -25px; + transform: translateY(-50%); + } + + + .risk-marker { + position: absolute; + background-color: #4a90e2; /* Blue marker */ + color: white; + border-radius: 50%; /* Circular marker */ + display: flex; + justify-content: center; + align-items: center; + font-size: 0.8em; + font-weight: bold; + z-index: 3; /* Above grid and labels */ + transform: translate(-5% -5%); /* Center the marker on the exact point */ + box-shadow: 0 1px 4px rgba(0,0,0,0.2); + } + /* Marker Sizes */ + .marker-size-1 { width: 25px; height: 25px; } + .marker-size-3 { width: 35px; height: 35px; font-size: 0.9em; } + .marker-size-6 { width: 45px; height: 45px; font-size: 1em; } + + + /* --- Disclaimer Styles --- */ + .disclaimer-page { + margin-top: 50px; /* More space above disclaimer */ + padding: 40px; /* More padding */ + background-color: #f9f9f9; + border: 1px solid #eee; + border-radius: 8px; + font-size: 0.95em; /* Slightly larger font */ + color: #555; + text-align: center; /* Center disclaimer content */ + } + .disclaimer-page .logo { + max-width: 180px; /* Slightly smaller logo for disclaimer */ + margin-bottom: 30px; + } + .disclaimer-page h3 { + color: #212529; /* Darker gray for disclaimer heading */ + margin-top: 0; + margin-bottom: 20px; + border-bottom: 1px solid #ddd; + padding-bottom: 15px; + font-size: 1.5em; + } + + /* --- Footer Styles --- */ + .footer { + text-align: center; + margin-top: 50px; /* More space above footer */ + padding-top: 25px; + border-top: 1px solid #eee; + font-size: 0.9em; + color: #777; + } + + /* --- Print Styles --- */ + @media print { + body { + background-color: #fff; /* White background for printing */ + color: #000; /* Black text for printing */ + -webkit-print-color-adjust: exact; /* Ensure colors are printed */ + print-color-adjust: exact; + font-size: 10pt; /* Standard print font size */ + } + .container { + box-shadow: none; /* Remove shadow in print */ + margin: 0; + padding: 0 15mm; /* Add metric padding for print margins */ + max-width: 100%; + } + .front-page { + height: auto; /* Auto height for print */ + min-height: 95vh; /* Ensure it takes at least one page */ + page-break-after: always; /* Start main content on a new page */ + background-color: #1a1a2e !important; /* Keep dark background for print */ + color: #fff !important; + padding: 50mm 15mm; /* Adjust padding for print */ + } + .front-page .logo { + max-width: 180px; /* Adjust logo size for print front page */ + } + .front-page h1, .front-page p, .front-page strong { + color: #fff !important; /* Ensure white text prints white */ + } + .section { + border-bottom: 1px solid #eee; + padding-bottom: 15px; + margin-bottom: 20px; + page-break-inside: avoid; /* Avoid breaking sections across pages if possible */ + } + .section h2 { + border-bottom-color: #4a90e2 !important; /* Ensure blue underline prints */ + color: #212529 !important; /* Ensure darker gray prints */ + } + h3 { + color: #212529 !important; /* Ensure darker gray prints */ + } + table { + box-shadow: none; /* Remove table shadow in print */ + page-break-inside: avoid; /* Avoid breaking tables */ + font-size: 0.85em; /* Slightly smaller font for print tables */ + } + th { + background-color: #4a90e2 !important; /* Ensure blue header prints */ + color: #fff !important; + } + tbody tr:nth-child(even) { + background-color: #f9f9f9 !important; /* Ensure stripe prints */ + } + .risk-matrix table { + width: 100%; /* Use full width for print */ + } + .risk-matrix th, .risk-matrix td { + height: 40px; /* Smaller height for print matrix cells */ + padding: 8px 3px; /* Adjust padding */ + } + .risk-matrix td { + background-color: inherit !important; /* Reset background for matrix cells in print */ + color: inherit !important; + } + /* Ensure matrix colors print */ + .risk-matrix .bg-critical { background-color: #e74c3c !important; color: white !important; } + .risk-matrix .bg-high { background-color: #f39c12 !important; color: white !important; } + .risk-matrix .bg-medium { background-color: #f1c40f !important; color: #000 !important; } + .risk-matrix .bg-low { background-color: #2ecc71 !important; color: white !important; } + .risk-matrix .bg-very-low { background-color: #1abc9c !important; color: white !important; } + + .risk-chart-container { + background: none !important; /* Remove gradient background for print */ + border: 1px solid #ccc; /* Keep border */ + box-shadow: none; + aspect-ratio: auto; /* Auto aspect ratio for print */ + height: 300px; /* Fixed height for print chart */ + } + .risk-chart-grid > div { + border: 1px solid #ccc !important; /* Solid grid lines for print */ + } + .risk-marker { + background-color: #4a90e2 !important; /* Ensure marker color prints */ + color: white !important; + box-shadow: none; + } + .risk-chart-axis-label, .risk-chart-level-label { + color: #000 !important; /* Ensure labels print black */ + } + + + .disclaimer-page { + page-break-before: always; /* Start disclaimer on a new page */ + margin-top: 0; + padding: 30mm 15mm; /* Adjust padding */ + border: none; /* Remove border in print */ + background-color: #fff; /* White background for print */ + text-align: center; + } + .disclaimer-page h3 { + color: #212529 !important; /* Ensure darker gray prints */ + } + .footer { + display: none; /* Hide footer in print, or style for page numbers */ + } + table { + page-break-inside: avoid !important; + break-inside: avoid !important; + } + .risk-matrix table { + page-break-inside: avoid !important; + break-inside: avoid !important; + width: 100% !important; /* Full width for print tables */ + justify-self: center; /* Center tables in print */ + } + tr, td, th { + page-break-inside: avoid !important; + break-inside: avoid !important; + } + .residual-table { + table-layout: fixed; + font-size: 8pt; + width: 100%; + } + + .residual-table th, + .residual-table td { + padding: 4px; + word-break: none; + overflow-wrap: break-word; + } + + /* Optional: Add page numbers - requires more complex CSS/JS */ + /* @bottom-right { content: counter(page) " of " counter(pages); } */ + } \ No newline at end of file diff --git a/backend/core/tables.py b/backend/core/tables.py index ad97d47..e60727a 100644 --- a/backend/core/tables.py +++ b/backend/core/tables.py @@ -3,14 +3,6 @@ from backend.core.utils import calculate_aggregate_likelihood, calculate_aggrega def risk_matrix_table(): - likelihood_labels = [ - "Almost Certain (90-100%) (5)", - "Probable (51-89%) (4)", - "Possible (25-50%) (3)", - "Unlikely (11-24%) (2)", - "Rare (0-10%) (1)" - ] - impact_labels = [ "Insignificant (1)", "Significant (2)", @@ -18,39 +10,28 @@ def risk_matrix_table(): "Material (4)", "Major (5)" ] + header = ["Likelihood ↓ / Impact →"] + impact_labels - color_mapping = { - "Very Low": "lightgreen", - "Low": "green", - "Medium": "yellow", - "High": "orange", - "Critical": "red" - } + matrix = [ + ["Almost Certain (5)", + (5, "bg-medium"), (10, "bg-high"), (15, "bg-critical"), (20, "bg-critical"), (25, "bg-critical") + ], + ["Likely (4)", + (4, "bg-low"), (8, "bg-medium"), (12, "bg-high"), (16, "bg-high"), (20, "bg-critical") + ], + ["Probable (3)", + (3, "bg-low"), (6, "bg-low"), (9, "bg-medium"), (12, "bg-high"), (15, "bg-high") + ], + ["Unlikely (2)", + (2, "bg-very-low"), (4, "bg-low"), (6, "bg-medium"), (8, "bg-medium"), (10, "bg-medium") + ], + ["Rare (1)", + (1, "bg-very-low"), (2, "bg-very-low"), (3, "bg-low"), (4, "bg-low"), (5, "bg-medium") + ], + ] - def get_label(score): - if score <= 2: - return "Very Low" - elif score <= 4: - return "Low" - elif score <= 10: - return "Medium" - elif score <= 16: - return "High" - else: - return "Critical" - - table_matrix_risk = [["Likelihood ↓ / Impact →"] + impact_labels] - - for likelihood in range(5, 0, -1): - row = [likelihood_labels[5 - likelihood]] - for impact in range(1, 6): - score = likelihood * impact - label = get_label(score) - color_class = color_mapping[label] - row.append((score, label, color_class)) - table_matrix_risk.append(row) - - return table_matrix_risk + table = [header] + matrix + return table def get_risk_table(document): risks = ( @@ -98,3 +79,34 @@ def get_risk_table(document): risks_with_controls.sort(key=lambda x: x['risk_score'], reverse=True) return risks_with_controls + +def get_safeguard_summary_table(risks_with_controls): + from collections import Counter + from backend.core.models import Control + + safeguard_counter = Counter() + safeguard_names = {} + + for risk in risks_with_controls: + for control in risk.get('controls', []): + control_id = control.get('control') + control_name = control.get('control__name') + if control_id: + safeguard_counter[control_id] += 1 + safeguard_names[control_id] = control_name + + summary = [] + controls = Control.objects.filter(id__in=safeguard_counter.keys()) + controls_map = {c.id: c for c in controls} + + for control_id, count in safeguard_counter.items(): + control = controls_map.get(control_id) + summary.append({ + 'id': control_id, + 'safeguard_id': control.safeguard_id if control else '', + 'name': safeguard_names.get(control_id, ''), + 'description': control.description if control else '', + 'count': count, + }) + summary.sort(key=lambda x: x['count'], reverse=True) + return summary \ No newline at end of file diff --git a/backend/core/templates/document.html b/backend/core/templates/document.html index 3b4496f..ff6edd8 100644 --- a/backend/core/templates/document.html +++ b/backend/core/templates/document.html @@ -3,157 +3,14 @@ - Document PDF - + + Cyber Risk Assessment Report - {{document.organization.name}} + -
{% if error %}

{{ error }}

{% endif %} - -
- {{ rendered_html|safe }} -
-
+ {{ rendered_html|safe }} diff --git a/backend/core/tests/test_processors.py b/backend/core/tests/test_processors.py index 62ccfd0..854a721 100644 --- a/backend/core/tests/test_processors.py +++ b/backend/core/tests/test_processors.py @@ -32,14 +32,14 @@ class TestProcessors(unittest.TestCase): def test_render_universal_segment(self): segment = self.template_segments[0] result = render_universal_segment(segment, self.context_data) - self.assertIn("

", result) + self.assertIn("

", result) self.assertIn("