{% translate "Crisis Room" %}
+{% translate "Crisis Room overview for all organizations" %}
+diff --git a/docs/source/manual/crisis-room.rst b/docs/source/manual/crisis-room.rst
new file mode 100644
index 00000000000..80be35906ea
--- /dev/null
+++ b/docs/source/manual/crisis-room.rst
@@ -0,0 +1,90 @@
+===========
+Crisis Room
+===========
+
+In OpenKAT we differentiate two Crisis Rooms:
+
+- **Single Organization Crisis Room:** a Crisis Room for each organization separately
+- **General Crisis Room:** one general Crisis Room with for all organizations
+
+
+Single Organization Crisis Room
+===============================
+
+This page shows a Crisis Room for each organization separately.
+Currently, this Crisis Room shows the top 10 most severe Findings.
+In the future it will serve as a dashboard which can be customized by the user.
+
+
+General Crisis Room
+===================
+
+This page shows the Crisis Room for all organizations.
+Currently, this Crisis Room only shows the Findings, but in the future it will also show dashboards,
+which can be customized by the user.
+
+Findings
+--------
+This section shows all the findings that have been identified for all organizations.
+These findings are shown in a table, grouped by organization and finding types.
+
+Every organization has one default report recipe. This recipe is used to create an Aggregate Findings Report.
+The output of this report, for each organization, is shown in this section.
+
+The default settings for this report recipe are:
+
+- report_name_format = ``Crisis Room Aggregate Report``
+- ooi_types = ``["IPAddressV6", "Hostname", "IPAddressV4", "URL"]``
+- scan_level = ``[1, 2, 3, 4]``
+- scan_type = ``["declared"]``
+- report_types = ``["systems-report", "findings-report"]``
+- cron_expression = ``0 * * * *`` (every hour)
+
+It is possible to update the report recipe*. To do this:
+
+- Go to "Reports"- Click on the tab "Scheduled"
+- Look for the "Criris Room Aggregate Report"
+- Open the row
+- Click on "Edit report recipe"
+
+*\*Note: if you want to update the report recipe, you have to do this for every organization.*
+
+Create a Findings Dashboard for Your Organization
+=================================================
+
+OpenKAT automates the process of creating findings dashboards for your organization.
+
+Steps to Create a Findings Dashboard:
+--------------------------------------
+
+1. **Install OpenKAT or Add a New Organization:**
+ Ensure that you have OpenKAT installed or a new organization has been added to your setup.
+
+2. **Navigate to Your OpenKAT Installation Directory:**
+ Open a terminal and change to the OpenKAT installation folder:
+
+ .. code-block:: bash
+
+ cd nl-kat-coordination
+
+3. **Go to the 'rocky' Folder:**
+ Within the OpenKAT directory, enter the ``rocky`` folder:
+
+ .. code-block:: bash
+
+ cd rocky
+
+4. **Run the Dashboard Creation Command:**
+ Execute the following command to create the findings dashboard:
+
+ .. code-block:: bash
+
+ make dashboards
+
+What Happens After Running the Command:
+---------------------------------------
+
+- The system will automatically search for all installed organizations.
+- A **recipe** for the findings dashboard will be generated.
+- A **scheduled task** will be created to generate findings reports every hour.
+- Findings will be **added to the organization’s crisis room** for easy access and monitoring.
diff --git a/docs/source/manual/index.rst b/docs/source/manual/index.rst
index 8cd5034b8f9..daecb553580 100644
--- a/docs/source/manual/index.rst
+++ b/docs/source/manual/index.rst
@@ -8,5 +8,6 @@ An overview of all KAT functionality, from a user perspective.
:caption: Contents
user-manual
+ crisis-room
reports
normalizers
diff --git a/rocky/Makefile b/rocky/Makefile
index bce14cf9615..95b241d18af 100644
--- a/rocky/Makefile
+++ b/rocky/Makefile
@@ -14,6 +14,9 @@ build-rocky:
# Set DATABASE_MIGRATION=false to prevent entrypoint from running migration
docker compose run --rm -e DATABASE_MIGRATION=false rocky make build-rocky-native
+dashboards:
+ docker compose run --rm rocky python3 manage.py dashboards
+
build-rocky-native:
while ! nc -vz $$ROCKY_DB_HOST $$ROCKY_DB_PORT; do sleep 0.1; done
python3 manage.py migrate
diff --git a/rocky/crisis_room/management/commands/__init__.py b/rocky/crisis_room/management/commands/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/rocky/crisis_room/management/commands/dashboards.py b/rocky/crisis_room/management/commands/dashboards.py
new file mode 100644
index 00000000000..76b121d4452
--- /dev/null
+++ b/rocky/crisis_room/management/commands/dashboards.py
@@ -0,0 +1,86 @@
+import json
+import logging
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+from crisis_room.models import Dashboard, DashboardData
+from django.conf import settings
+from django.core.management import BaseCommand
+from tools.models import Organization
+from tools.ooi_helpers import create_ooi
+
+from octopoes.connector.octopoes import OctopoesAPIConnector
+from octopoes.models.ooi.reports import ReportRecipe
+from rocky.bytes_client import get_bytes_client
+from rocky.scheduler import ReportTask, ScheduleRequest, scheduler_client
+
+FINDINGS_DASHBOARD_NAME = "Crisis Room Findings Dashboard"
+
+
+def get_or_create_default_dashboard(organization: Organization) -> bool:
+ valid_time = datetime.now(timezone.utc)
+ is_scheduler_ready_for_schedule = is_scheduler_enabled(organization)
+ created = False
+
+ if is_scheduler_ready_for_schedule:
+ path = Path(__file__).parent / "recipe_seeder.json"
+ with path.open("r") as recipe_seeder:
+ recipe_default = json.load(recipe_seeder)
+
+ dashboard, _ = Dashboard.objects.get_or_create(name=FINDINGS_DASHBOARD_NAME, organization=organization)
+
+ dashboard_data, created = DashboardData.objects.get_or_create(dashboard=dashboard)
+ if created:
+ recipe = create_organization_recipe(valid_time, organization, recipe_default)
+ dashboard_data.recipe = recipe.primary_key
+ schedule_request = create_schedule_request(valid_time, organization, recipe)
+ scheduler_client(organization.code).post_schedule(schedule=schedule_request)
+
+ dashboard_data.findings_dashboard = True
+ dashboard_data.save()
+ return created
+
+
+def create_organization_recipe(
+ valid_time: datetime, organization: Organization, recipe_default: dict[str, Any]
+) -> ReportRecipe:
+ report_recipe = ReportRecipe(recipe_id=uuid4(), **recipe_default)
+
+ octopoes_client = OctopoesAPIConnector(
+ settings.OCTOPOES_API, organization.code, timeout=settings.ROCKY_OUTGOING_REQUEST_TIMEOUT
+ )
+ bytes_client = get_bytes_client(organization.code)
+
+ create_ooi(api_connector=octopoes_client, bytes_client=bytes_client, ooi=report_recipe, observed_at=valid_time)
+ return report_recipe
+
+
+def is_scheduler_enabled(organization: Organization) -> bool:
+ scheduler_id = f"report-{organization.code}"
+ return scheduler_client(organization.code).is_scheduler_ready(scheduler_id)
+
+
+def create_schedule_request(
+ start_datetime: datetime, organization: Organization, report_recipe: ReportRecipe
+) -> ScheduleRequest:
+ report_task = ReportTask(
+ organisation_id=organization.code, report_recipe_id=str(report_recipe.recipe_id)
+ ).model_dump()
+
+ return ScheduleRequest(
+ scheduler_id=f"report-{organization.code}",
+ data=report_task,
+ schedule=report_recipe.cron_expression,
+ deadline_at=start_datetime.isoformat(),
+ )
+
+
+class Command(BaseCommand):
+ def handle(self, *args, **options):
+ organizations = Organization.objects.all()
+ for organization in organizations:
+ created = get_or_create_default_dashboard(organization)
+ if created:
+ logging.info("Dashboard created for organization %s", organization.name)
diff --git a/rocky/crisis_room/management/commands/recipe_seeder.json b/rocky/crisis_room/management/commands/recipe_seeder.json
new file mode 100644
index 00000000000..fa51d182142
--- /dev/null
+++ b/rocky/crisis_room/management/commands/recipe_seeder.json
@@ -0,0 +1,32 @@
+{
+ "report_name_format": "Crisis Room Aggregate Report",
+ "subreport_name_format": "Findings Report for ${ooi}",
+ "input_recipe": {
+ "query": {
+ "ooi_types": [
+ "IPAddressV6",
+ "Hostname",
+ "IPAddressV4",
+ "URL"
+ ],
+ "scan_level": [
+ 1,
+ 2,
+ 3,
+ 4
+ ],
+ "scan_type": [
+ "declared"
+ ],
+ "search_string": "",
+ "order_by": "object_type",
+ "asc_desc": "desc"
+ }
+ },
+ "parent_report_type": "aggregate-organisation-report",
+ "report_types": [
+ "systems-report",
+ "findings-report"
+ ],
+ "cron_expression": "0 * * * *"
+}
diff --git a/rocky/crisis_room/migrations/0001_initial.py b/rocky/crisis_room/migrations/0001_initial.py
new file mode 100644
index 00000000000..42cdb7d70a1
--- /dev/null
+++ b/rocky/crisis_room/migrations/0001_initial.py
@@ -0,0 +1,71 @@
+# Generated by Django 5.0.9 on 2024-12-18 11:29
+
+import django.core.validators
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [("tools", "0044_alter_organization_options")]
+
+ operations = [
+ migrations.CreateModel(
+ name="Dashboard",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("name", models.CharField(max_length=126)),
+ (
+ "organization",
+ models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="tools.organization"),
+ ),
+ ],
+ options={"unique_together": {("name", "organization")}},
+ ),
+ migrations.CreateModel(
+ name="DashboardData",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("recipe", models.CharField(max_length=126)),
+ ("template", models.CharField(blank=True, default="findings_report/report.html", max_length=126)),
+ (
+ "position",
+ models.IntegerField(
+ blank=True,
+ default=1,
+ help_text="Where on the dashboard do you want to show the data? Position 1 is the most top level and the max position is 16.",
+ validators=[
+ django.core.validators.MinValueValidator(1),
+ django.core.validators.MaxValueValidator(16),
+ ],
+ ),
+ ),
+ (
+ "display_in_crisis_room",
+ models.BooleanField(
+ default=False, help_text="Will be displayed on the general crisis room, for all organizations."
+ ),
+ ),
+ (
+ "display_in_dashboard",
+ models.BooleanField(
+ default=False, help_text="Will be displayed on a single organization dashboard"
+ ),
+ ),
+ (
+ "findings_dashboard",
+ models.BooleanField(
+ default=False, help_text="Will be displayed on the findings dashboard for all organizations"
+ ),
+ ),
+ (
+ "dashboard",
+ models.ForeignKey(
+ null=True, on_delete=django.db.models.deletion.SET_NULL, to="crisis_room.dashboard"
+ ),
+ ),
+ ],
+ options={"unique_together": {("dashboard", "findings_dashboard"), ("dashboard", "position")}},
+ ),
+ ]
diff --git a/rocky/crisis_room/models.py b/rocky/crisis_room/models.py
new file mode 100644
index 00000000000..494590280af
--- /dev/null
+++ b/rocky/crisis_room/models.py
@@ -0,0 +1,49 @@
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from tools.models import Organization
+
+
+class Dashboard(models.Model):
+ name = models.CharField(blank=False, max_length=126)
+ organization = models.ForeignKey(Organization, on_delete=models.SET_NULL, null=True)
+
+ class Meta:
+ unique_together = ["name", "organization"]
+
+ def __str__(self) -> str:
+ if self.name:
+ return f"{self.name} for organization {self.organization}"
+ return super().__str__()
+
+
+class DashboardData(models.Model):
+ dashboard = models.ForeignKey(Dashboard, on_delete=models.SET_NULL, null=True)
+ recipe = models.CharField(blank=False, max_length=126)
+ template = models.CharField(blank=True, max_length=126, default="findings_report/report.html")
+ position = models.IntegerField(
+ blank=True,
+ default=1,
+ validators=[MinValueValidator(1), MaxValueValidator(16)],
+ help_text=_(
+ "Where on the dashboard do you want to show the data? "
+ "Position 1 is the most top level and the max position is 16."
+ ),
+ )
+ display_in_crisis_room = models.BooleanField(
+ default=False, help_text=_("Will be displayed on the general crisis room, for all organizations.")
+ )
+ display_in_dashboard = models.BooleanField(
+ default=False, help_text=_("Will be displayed on a single organization dashboard")
+ )
+ findings_dashboard = models.BooleanField(
+ default=False, help_text=_("Will be displayed on the findings dashboard for all organizations")
+ )
+
+ class Meta:
+ unique_together = [["dashboard", "position"], ["dashboard", "findings_dashboard"]]
+
+ def __str__(self) -> str:
+ if self.dashboard:
+ return str(self.dashboard)
+ return super().__str__()
diff --git a/rocky/crisis_room/templates/crisis_room.html b/rocky/crisis_room/templates/crisis_room.html
new file mode 100644
index 00000000000..c3efc21f121
--- /dev/null
+++ b/rocky/crisis_room/templates/crisis_room.html
@@ -0,0 +1,19 @@
+{% extends "layouts/base.html" %}
+
+{% load i18n %}
+{% load static %}
+
+{% block content %}
+ {% include "header.html" %}
+
+ {% translate "Crisis Room overview for all organizations" %}
+ {% blocktranslate %}
+ On this page you can see an overview of the dashboards for all organizations.
+ **More context can be written here**
+ {% endblocktranslate %}
+ {% translate "There are no dashboards to display." %}
+ {% blocktranslate %}
+ This overview shows the total number of findings per
+ severity that have been identified for all organizations.
+ {% endblocktranslate %}
+
+ {% blocktranslate %}
+ This table shows the findings that have been identiefied for each organization,
+ sorted by the finding types and grouped by organizations.
+ {% endblocktranslate %}
+ {% translate "Crisis Room" %}
+ {% translate "Dashboards" %}
+ {{ organization.name }} {% translate "dashboards" %}
+ {% if organization_dashboards %}
+ {% for dashboards in organization_dashboards %}
+ {% for dashboard_data, report in dashboards.items %}
+ {% if report %}
+ {{ dashboard_data }}
+ {% include dashboard_data.template with data=report.1 is_dashboard="yes" %}
+
+ {% translate "Findings overview" %}
+ {% translate "Findings per organization" %}
+
+ {% blocktranslate %} + No findings have been identified yet. As soon as they have been + identified, they will be shown on this page. + {% endblocktranslate %} +
+ {% endif %} +{% else %} ++ {% blocktranslate %} + There are no organizations yet. After creating an organization, + the identified findings with severity 'critical' and 'high' will be shown here. + {% endblocktranslate %} +
+{% endif %} diff --git a/rocky/crisis_room/templates/crisis_room_header.html b/rocky/crisis_room/templates/crisis_room_header.html new file mode 100644 index 00000000000..07b20b58295 --- /dev/null +++ b/rocky/crisis_room/templates/crisis_room_header.html @@ -0,0 +1,11 @@ +{% load i18n %} + ++ {% blocktranslate %} + This chapter contains information about the findings that have been identified + for this organization. + {% endblocktranslate%} +
+ {% include "findings_report/report.html" with data=data.findings %} + +- {% blocktranslate trimmed %} - The Findings Report provides an overview of the identified findings on the scanned - systems. For each finding it shows the risk level and the number of occurrences of - the finding. Under the 'Details' section a description, impact, recommendation and - location of the finding can be found. The risk level may be different for your - specific environment. - {% endblocktranslate %} -
- {% endif %} -+ {% blocktranslate trimmed %} + The Findings Report contains information about the findings that have been identified + for the selected asset and organization. + {% endblocktranslate %} +
+{% endif %} +{% if is_dashboard_findings %} +{% translate "Organization" %} | +{% translate "Finding types" %} | +{% translate "Occurrences" %} | +{% translate "Highest risk level" %} | +{% translate "Critical finding types" %} | +{% translate "Details" %} | +|||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
+ {{ organization.name }} + | ++ {% if findings.summary.total_finding_types %} + {{ findings.summary.total_finding_types }} + {% else %} + - + {% endif %} + | ++ {% if findings.summary.total_occurrences %} + {{ findings.summary.total_occurrences }} + {% else %} + - + {% endif %} + | ++ {% if report.report_data.highest_risk_level %} + {{ report.report_data.highest_risk_level|capfirst }} + {% else %} + - + {% endif %} + | ++ {% if findings.summary.total_by_severity_per_finding_type %} + {{ findings.summary.total_by_severity_per_finding_type.critical }} + {% else %} + - + {% endif %} + | ++ + | +|||||||||||||
+
+
+ {% include "partials/report_severity_totals_table.html" with data=findings.summary %}
+
+ {% translate "Findings overview" %}++ {% translate "This overview shows the total number of findings per severity that have been identified for this organization." %} + +
+
+ {% include "partials/report_findings_table.html" with finding_types=findings.finding_types %}
- + {% with total=findings.summary.total_by_severity_per_finding_type %} + {% translate "Critical and high findings" %} ({{ findings.finding_types|length }}/{{ total.critical|add:total.high }}) + {% endwith %} +++ {% blocktranslate %} + This table shows the top 25 critical and high findings that have + been identified for this organization, grouped by finding types. + A table with all the identified findings can be found in the Findings Report. + {% endblocktranslate %} + +{% translate "Findings" %}-
-
+ {% endfor %}
+
|
{% translate "No findings have been identified yet." %}
+{% translate "No findings have been identified yet." %}
+ {% else %} + {% include "findings_report/report.html" with data=data.findings show_introduction="yes" is_multi_report="yes" %} + + {% endif %} ++ {% blocktranslate %} + This table provides an overview of the identified findings on the scanned + systems. For each finding type it shows the risk level, the number of occurrences + and the first known occurrence of the finding. The risk level may be different for your specific environment. + The details can be seen when expanding a row. A description, the source, impact and recommendation of the finding + can be found here. It also shows in which findings the finding type occurred. + {% endblocktranslate %} +
+{% endif %} +{% if finding_types %} +{% translate "Risk level" %} | +{% translate "Finding types" %} | +{% translate "Occurrences" %} | +{% translate "First known occurrence" %} | + {% if is_dashboard_findings %} +{% translate "Open in report" %} | + {% else %} +{% translate "Details" %} | + {% endif %} +|||
---|---|---|---|---|---|---|---|---|
+ {{ info.finding_type.risk_severity|capfirst }} + | +{{ info.finding_type.id }} | +{{ info.occurrences|length }} | +{{ info.occurrences|get_first_seen }} | + {% if is_dashboard_findings %} ++ + | + {% else %} ++ + | + {% endif %} +|||
+ {% translate "Description" %}+{{ info.finding_type.description }} +{% translate "Source" %}+ {% if info.finding_type.source %} + {{ info.finding_type.source }} + {% else %} +{{ info.finding_type.source }} + {% endif %} +{% translate "Impact" %}+{{ info.finding_type.impact }} +{% translate "Recommendation" %}+{{ info.finding_type.recommendation }} +{% translate "Findings" %}+
|
+
{% translate "No critical and high findings have been identified for this organization." %}
+{% else %} +{% translate "No findings have been identified for this organization." %}
+{% endif %} diff --git a/rocky/reports/templates/partials/report_severity_totals.html b/rocky/reports/templates/partials/report_severity_totals.html deleted file mode 100644 index b3663db51e6..00000000000 --- a/rocky/reports/templates/partials/report_severity_totals.html +++ /dev/null @@ -1,11 +0,0 @@ -{% load i18n %} - -{% translate "Risk level" %} | -{% translate "Findings" %} | -{% translate "Occurrences" %} | -
---|---|---|
- Critical - | -{{ data.total_by_severity_per_finding_type.critical }} | -{{ data.total_by_severity.critical }} | -
- High - | -{{ data.total_by_severity_per_finding_type.high }} | -{{ data.total_by_severity.high }} | -
- Medium - | -{{ data.total_by_severity_per_finding_type.medium }} | -{{ data.total_by_severity.medium }} | -
- Low - | -{{ data.total_by_severity_per_finding_type.low }} | -{{ data.total_by_severity.low }} | -
- Recommendation - | -{{ data.total_by_severity_per_finding_type.recommendation }} | -{{ data.total_by_severity.recommendation }} | -
Total | -{{ data.total_finding_types }} | -{{ data.total_occurrences }} | -
+ {% blocktranslate %} + This overview shows the total number of findings per + severity that have been identified for this organization. + {% endblocktranslate %} +
+{% endif %} +{% translate "Risk level" %} | +{% translate "Finding types" %} | +{% translate "Occurrences" %} | +
---|---|---|
+ Critical + | +{{ data.total_by_severity_per_finding_type.critical }} | +{{ data.total_by_severity.critical }} | +
+ High + | +{{ data.total_by_severity_per_finding_type.high }} | +{{ data.total_by_severity.high }} | +
+ Medium + | +{{ data.total_by_severity_per_finding_type.medium }} | +{{ data.total_by_severity.medium }} | +
+ Low + | +{{ data.total_by_severity_per_finding_type.low }} | +{{ data.total_by_severity.low }} | +
+ Recommendation + | +{{ data.total_by_severity_per_finding_type.recommendation }} | +{{ data.total_by_severity.recommendation }} | +
Total | +{{ data.total_finding_types }} | +{{ data.total_occurrences }} | +
{% translate "Organization" %} | -{% translate "Total Findings" %} | -{% translate "Details" %} | -
---|---|---|
- {{ org_finding_count.name }} - | -{{ org_finding_count.total }} | -- - | -
- {{ org_finding_count.name }} {% translate " Finding Details" %} -
-
- {% endfor %}
- |
-
{% translate "Organization" %} | -{% translate "Critical Findings" %} | -{% translate "Details" %} | -
---|---|---|
- {{ org_finding_count.name }} - | -{{ org_finding_count.total_critical }} | -- - | -
- {{ org_finding_count.name }} {% translate " Finding Details" %} -
-
- {% endfor %}
- |
-
{% translate "Crisis room" %} {{ organization.name }} @ {{ observed_at|date:
{% translate "An overview of the top 10 most severe findings OpenKAT found. Check the detail section for additional severity information." %}
+ {{ recipe_form_fields }}No critical and high findings have been identified for this organization.
", html=True + ) + + +def test_get_organizations_findings_summary(findings_dashboard_mock_data): + """Test if summary has counted the results of both reports correctly.""" + dashboard_service = DashboardService() + summary_results = dashboard_service.get_organizations_findings_summary(findings_dashboard_mock_data) + + assert summary_results["total_by_severity_per_finding_type"] == { + "critical": 1, + "high": 2, + "medium": 7, + "low": 3, + "recommendation": 1, + "pending": 1, + "unknown": 1, + } + assert summary_results["total_by_severity"] == { + "critical": 3, + "high": 3, + "medium": 9, + "low": 6, + "recommendation": 1, + "pending": 1, + "unknown": 1, + } + assert summary_results["total_finding_types"] == 16 + assert summary_results["total_occurrences"] == 24 + + +def test_get_organizations_findings_summary_no_input(): + """Test if summary returns an empty dict if there is not input.""" + dashboard_service = DashboardService() + summary_results = dashboard_service.get_organizations_findings_summary({}) + + assert summary_results == {} + + +def test_get_organizations_findings(findings_report_bytes_data): + """Test if the highest risk level is collected, only critical and high finding types are returned.""" + dashboard_service = DashboardService() + report_data = findings_report_bytes_data[0] + report_data["findings"]["finding_types"] = [ + {"finding_type": {"risk_severity": "critical"}, "occurrences": {}}, + {"finding_type": {"risk_severity": "high"}, "occurrences": {}}, + {"finding_type": {"risk_severity": "low"}, "occurrences": {}}, + ] + findings = dashboard_service.get_organizations_findings(report_data) + + assert len(findings["findings"]["finding_types"]) == 2 + assert findings["highest_risk_level"] == "critical" + assert findings["findings"]["finding_types"][0]["finding_type"]["risk_severity"] == "critical" + assert findings["findings"]["finding_types"][1]["finding_type"]["risk_severity"] == "high" + + +def test_get_organizations_findings_no_finding_types(findings_report_bytes_data): + """ + When there are no finding types, the result should contain the report data and + highest_risk_level should be an empty string. + """ + dashboard_service = DashboardService() + report_data = findings_report_bytes_data[0] + findings = dashboard_service.get_organizations_findings(report_data) + + assert findings == report_data | {"highest_risk_level": ""} + + +def test_get_organizations_findings_no_input(): + """When there is no input, the result should only contain an empty highest_risk_level""" + dashboard_service = DashboardService() + findings = dashboard_service.get_organizations_findings({}) + + assert findings == {"highest_risk_level": ""} + + +def test_collect_findings_dashboard(mocker, dashboard_data, findings_reports, findings_report_bytes_data): + """ + Test if the right dashboard is filtered and if the method returns the right dict format. + Only the most recent report should be visible in the dict. + """ + + octopoes_client = mocker.patch("crisis_room.views.OctopoesAPIConnector") + octopoes_client().query.return_value = findings_reports + + bytes_client = mocker.patch("crisis_room.views.get_bytes_client") + bytes_raw_data = [json.dumps(data).encode("utf-8") for data in findings_report_bytes_data] + bytes_client().get_raw.return_value = bytes_raw_data[0] + + organizations = [data.dashboard.organization for data in dashboard_data] + + dashboard_service = DashboardService() + findings_dashboard = dashboard_service.collect_findings_dashboard(organizations) + + assert findings_dashboard[organizations[0]][dashboard_data[0]]["report"] == findings_reports[0] + assert findings_dashboard[organizations[0]][dashboard_data[0]][ + "report_data" + ] == dashboard_service.get_organizations_findings(findings_report_bytes_data[0]) diff --git a/rocky/tools/admin.py b/rocky/tools/admin.py index 112d3201826..988c131fced 100644 --- a/rocky/tools/admin.py +++ b/rocky/tools/admin.py @@ -2,6 +2,7 @@ from json import JSONDecodeError import tagulous.admin +from crisis_room.models import Dashboard, DashboardData from django.contrib import admin, messages from django.db.models import JSONField from django.forms import widgets @@ -86,4 +87,14 @@ class OrganizationTagAdmin(admin.ModelAdmin): pass +@admin.register(DashboardData) +class DahboardDataAdmin(admin.ModelAdmin): + pass + + +@admin.register(Dashboard) +class DahboardAdmin(admin.ModelAdmin): + pass + + tagulous.admin.register(Organization, OrganizationAdmin) diff --git a/rocky/tools/templatetags/ooi_extra.py b/rocky/tools/templatetags/ooi_extra.py index 7a6724540dd..26c2744266f 100644 --- a/rocky/tools/templatetags/ooi_extra.py +++ b/rocky/tools/templatetags/ooi_extra.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from typing import Any from urllib import parse @@ -99,3 +100,14 @@ def clearance_level(ooi: OOI) -> ScanLevel: @register.filter def ooi_type(reference_string: str) -> str: return Reference.from_str(reference_string).class_ + + +@register.filter +def get_date(date_str: str) -> datetime: + return datetime.fromisoformat(date_str) + + +@register.filter +def get_first_seen(occurrences: dict) -> datetime: + first_seen_list = [datetime.fromisoformat(occurrence["first_seen"]) for occurrence in occurrences] + return min(first_seen_list)