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/assets/css/components/risk-level-indicator.scss b/rocky/assets/css/components/risk-level-indicator.scss index 58d13b9cb80..a5e4fb20c5b 100644 --- a/rocky/assets/css/components/risk-level-indicator.scss +++ b/rocky/assets/css/components/risk-level-indicator.scss @@ -14,7 +14,7 @@ --risk-level-low: var(--colors-green-150); --risk-level-informational: var(--colors-blue-150); --risk-level-pending: var(--colors-grey-400); - --risk-level-unkown: var(--colors-grey-400); + --risk-level-unknown: var(--colors-grey-400); display: flex; align-items: center; @@ -57,5 +57,5 @@ } .unknown::before { - background-color: var(--risk-level-unkown); + background-color: var(--risk-level-unknown); } 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..46bdd78d328 --- /dev/null +++ b/rocky/crisis_room/management/commands/dashboards.py @@ -0,0 +1,87 @@ +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, octopoes_client: OctopoesAPIConnector | None = None +) -> bool: + valid_time = datetime.now(timezone.utc) + created = False + 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(octopoes_client, valid_time, organization, recipe_default) + dashboard_data.recipe = recipe.recipe_id + 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( + octopoes_client: OctopoesAPIConnector | None, + valid_time: datetime, + organization: Organization, + recipe_default: dict[str, Any], +) -> ReportRecipe: + report_recipe = ReportRecipe(recipe_id=uuid4(), **recipe_default) + + if octopoes_client is None: + 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 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="report", + organisation=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..808bd7b143a --- /dev/null +++ b/rocky/crisis_room/management/commands/recipe_seeder.json @@ -0,0 +1,31 @@ +{ + "report_name_format": "Crisis Room Aggregate Report", + "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" + } + }, + "report_type": "aggregate-organisation-report", + "asset_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..1a269e5f2b7 --- /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.PositiveSmallIntegerField( + 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" %}

+

{% translate "Crisis Room overview for all organizations" %}

+
+ {% include "crisis_room_findings.html" %} + +
+
+{% endblock content %} diff --git a/rocky/crisis_room/templates/crisis_room_dashboards.html b/rocky/crisis_room/templates/crisis_room_dashboards.html new file mode 100644 index 00000000000..a6569bda896 --- /dev/null +++ b/rocky/crisis_room/templates/crisis_room_dashboards.html @@ -0,0 +1,46 @@ +{% extends "layouts/base.html" %} + +{% load i18n %} +{% load static %} + +{% block content %} + {% include "header.html" %} + +
+
+
+

{% translate "Dashboards" %}

+

+ {% blocktranslate %} + On this page you can see an overview of the dashboards for all organizations. + **More context can be written here** + {% endblocktranslate %} +

+
+ {% for organization, organization_dashboards in organizations_dashboards.items %} +
+
+

{{ 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" %} + +
+
+ {% endif %} + {% endfor %} + {% endfor %} + {% else %} +

{% translate "There are no dashboards to display." %}

+ {% endif %} +
+
+ {% endfor %} +
+
+{% endblock content %} diff --git a/rocky/crisis_room/templates/crisis_room_findings.html b/rocky/crisis_room/templates/crisis_room_findings.html new file mode 100644 index 00000000000..f795015fecb --- /dev/null +++ b/rocky/crisis_room/templates/crisis_room_findings.html @@ -0,0 +1,55 @@ +{% load i18n %} + +{% if organizations_dashboards %} + {% if organizations_findings_summary and organizations_findings_summary.total_finding_types != 0 %} +
+
+
+

{% translate "Findings overview" %}

+

+ {% blocktranslate %} + This overview shows the total number of findings per + severity that have been identified for all organizations. + This data is based on the latest Crisis Room Findings Report + of each organization. + {% endblocktranslate %} +

+
+ {% include "partials/report_severity_totals_table.html" with data=organizations_findings_summary %} + +
+
+
+
+
+

{% translate "Findings per organization" %}

+

+ {% blocktranslate %} + This table shows the findings that have been identiefied for each organization, + sorted by the finding types and grouped by organizations. + This data is based on the latest Crisis Room Findings Report + of each organization. + {% endblocktranslate %} +

+
+ {% include "findings_report/report.html" with is_dashboard_findings="yes" %} + +
+
+ {% else %} +

{% translate "Findings overview" %}

+

+ {% 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 %} + +
+ +
diff --git a/rocky/crisis_room/urls.py b/rocky/crisis_room/urls.py index 3fad14c20ac..46eeeda4f63 100644 --- a/rocky/crisis_room/urls.py +++ b/rocky/crisis_room/urls.py @@ -2,4 +2,5 @@ from . import views -urlpatterns = [path("", views.CrisisRoomView.as_view(), name="crisis_room")] +# Crisis room overview urls +urlpatterns = [path("", views.CrisisRoom.as_view(), name="crisis_room")] diff --git a/rocky/crisis_room/views.py b/rocky/crisis_room/views.py index 4a2f8163882..954b24a5ac7 100644 --- a/rocky/crisis_room/views.py +++ b/rocky/crisis_room/views.py @@ -1,98 +1,151 @@ -from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any +from uuid import UUID import structlog -from account.models import KATUser from django.conf import settings -from django.contrib import messages -from django.urls.base import reverse -from django.utils.translation import gettext_lazy as _ +from django.http.request import HttpRequest +from django.urls import reverse from django.views.generic import TemplateView -from tools.forms.base import ObservedAtForm -from tools.models import Organization -from tools.view_helpers import BreadcrumbsMixin +from httpx import HTTPStatusError +from pydantic import TypeAdapter +from reports.report_types.findings_report.report import SEVERITY_OPTIONS +from tools.models import Organization, OrganizationMember -from octopoes.connector import ConnectorException +from crisis_room.management.commands.dashboards import FINDINGS_DASHBOARD_NAME +from crisis_room.models import DashboardData from octopoes.connector.octopoes import OctopoesAPIConnector -from octopoes.models.ooi.findings import RiskLevelSeverity -from rocky.views.mixins import ConnectorFormMixin, ObservedAtMixin +from octopoes.models.exception import ObjectNotFoundException +from octopoes.models.ooi.reports import HydratedReport +from rocky.bytes_client import BytesClient, get_bytes_client logger = structlog.get_logger(__name__) -# dataclass to store finding type counts -@dataclass -class OrganizationFindingCountPerSeverity: - name: str - code: str - finding_count_per_severity: dict[str, int] - - @property - def total(self) -> int: - return sum(self.finding_count_per_severity.values()) - - @property - def total_critical(self) -> int: - try: - return self.finding_count_per_severity[RiskLevelSeverity.CRITICAL.value] - except KeyError: - return 0 - - -class CrisisRoomView(BreadcrumbsMixin, ConnectorFormMixin, ObservedAtMixin, TemplateView): - template_name = "crisis_room/crisis_room.html" - connector_form_class = ObservedAtForm - breadcrumbs = [{"url": "", "text": "Crisis Room"}] +class DashboardService: + observed_at = datetime.now(timezone.utc) # we can later set any observed_at + + @staticmethod + def get_organizations_findings(report_data: dict[str, Any]) -> dict[str, Any]: + findings = {} + highest_risk_level = "" + if "findings" in report_data and report_data["findings"] and report_data["findings"]["finding_types"]: + finding_types = report_data["findings"]["finding_types"] + highest_risk_level = finding_types[0]["finding_type"]["risk_severity"] + critical_high_finding_types = list( + filter( + lambda finding_type: finding_type["finding_type"]["risk_severity"] == "critical" + or finding_type["finding_type"]["risk_severity"] == "high", + finding_types, + ) + ) + report_data["findings"]["finding_types"] = critical_high_finding_types[:25] - def sort_by_total( - self, finding_counts: list[OrganizationFindingCountPerSeverity] - ) -> list[OrganizationFindingCountPerSeverity]: - is_desc = self.request.GET.get("sort_total_by", "desc") != "asc" - return sorted(finding_counts, key=lambda x: x.total, reverse=is_desc) + findings = report_data | {"highest_risk_level": highest_risk_level} + return findings - def sort_by_severity( - self, finding_counts: list[OrganizationFindingCountPerSeverity] - ) -> list[OrganizationFindingCountPerSeverity]: - is_desc = self.request.GET.get("sort_critical_by", "desc") != "asc" - return sorted(finding_counts, key=lambda x: x.total_critical, reverse=is_desc) + @staticmethod + def get_octopoes_client(organization_code: str) -> OctopoesAPIConnector: + return OctopoesAPIConnector( + settings.OCTOPOES_API, organization_code, timeout=settings.ROCKY_OUTGOING_REQUEST_TIMEOUT + ) - def get_finding_type_severity_count(self, organization: Organization) -> dict[str, int]: + @staticmethod + def get_reports( + observed_at: datetime, octopoes_api_connector: OctopoesAPIConnector, recipe_id: str + ) -> list[HydratedReport]: try: - api_connector = OctopoesAPIConnector( - settings.OCTOPOES_API, organization.code, timeout=settings.ROCKY_OUTGOING_REQUEST_TIMEOUT - ) - return api_connector.count_findings_by_severity(valid_time=self.observed_at) - except ConnectorException: - messages.add_message( - self.request, - messages.ERROR, - _("Failed to get list of findings for organization {}, check server logs for more details.").format( - organization.code - ), - ) - logger.exception("Failed to get list of findings for organization %s", organization.code) + return octopoes_api_connector.list_reports(valid_time=observed_at, recipe_id=UUID(recipe_id)).items + except (HTTPStatusError, ObjectNotFoundException): + return [] + + @staticmethod + def get_report_bytes_data(bytes_client: BytesClient, data_raw_id: str): + bytes_client.login() + return TypeAdapter(Any, config={"arbitrary_types_allowed": True}).validate_json( + bytes_client.get_raw(raw_id=data_raw_id) + ) + + def collect_findings_dashboard( + self, organizations: list[Organization] + ) -> dict[Organization, dict[DashboardData, dict[str, Any]]]: + findings_dashboard = {} + + dashboards_data = DashboardData.objects.filter( + dashboard__name=FINDINGS_DASHBOARD_NAME, dashboard__organization__in=organizations, findings_dashboard=True + ) + + for data in dashboards_data: + organization = data.dashboard.organization + octopoes_client = self.get_octopoes_client(organization.code) + bytes_client = get_bytes_client(organization.code) + recipe_id = data.recipe + + # get reports with recipe id + # TODO: change this method to get_report, since there's only one report that belongs to a recipe_id + reports = self.get_reports(self.observed_at, octopoes_client, recipe_id) + + if reports: + report = reports[0] + report_data_from_bytes = self.get_report_bytes_data(bytes_client, report.data_raw_id) + report_data = self.get_organizations_findings(report_data_from_bytes) + + if report_data: + findings_dashboard[organization] = {data: {"report": report, "report_data": report_data}} + + return findings_dashboard + + @staticmethod + def get_organizations_findings_summary( + organizations_findings: dict[Organization, dict[DashboardData, dict[str, Any]]], + ) -> dict[str, Any]: + summary: dict[str, Any] = { + "total_by_severity_per_finding_type": {severity: 0 for severity in SEVERITY_OPTIONS}, + "total_by_severity": {severity: 0 for severity in SEVERITY_OPTIONS}, + "total_finding_types": 0, + "total_occurrences": 0, + } + + summary_added = False + + for organization, organizations_data in organizations_findings.items(): + for data in organizations_data.values(): + if "findings" in data["report_data"] and "summary" in data["report_data"]["findings"]: + for summary_item, data in data["report_data"]["findings"]["summary"].items(): + if isinstance(data, dict): + for severity, total in data.items(): + summary[summary_item][severity] += total + summary_added = True + else: + summary[summary_item] += data + summary_added = True + + if not summary_added: return {} - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) + return summary - user: KATUser = self.request.user - # query each organization's finding type count - org_finding_counts_per_severity = [ - OrganizationFindingCountPerSeverity( - name=org.name, code=org.code, finding_count_per_severity=self.get_finding_type_severity_count(org) - ) - for org in user.organizations - ] +class CrisisRoom(TemplateView): + template_name = "crisis_room.html" - context["breadcrumb_list"] = [{"url": reverse("crisis_room"), "text": "CRISIS ROOM"}] + def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: + super().setup(request, *args, **kwargs) - context["organizations"] = user.organizations + dashboard_service = DashboardService() + organizations = self.get_user_organizations() - context["org_finding_counts_per_severity"] = self.sort_by_total(org_finding_counts_per_severity) - context["org_finding_counts_per_severity_critical"] = self.sort_by_severity(org_finding_counts_per_severity) + self.organizations_findings = dashboard_service.collect_findings_dashboard(organizations) + self.organizations_findings_summary = dashboard_service.get_organizations_findings_summary( + self.organizations_findings + ) - context["observed_at_form"] = self.get_connector_form() - context["observed_at"] = self.observed_at.date() + def get_user_organizations(self) -> list[Organization]: + return [member.organization for member in OrganizationMember.objects.filter(user=self.request.user)] + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["breadcrumbs"] = [{"url": reverse("crisis_room"), "text": "Crisis "}] + context["organizations_dashboards"] = self.organizations_findings + context["organizations_findings_summary"] = self.organizations_findings_summary return context diff --git a/rocky/reports/report_types/aggregate_organisation_report/report.html b/rocky/reports/report_types/aggregate_organisation_report/report.html index e943ac966ef..0f63f0138ed 100644 --- a/rocky/reports/report_types/aggregate_organisation_report/report.html +++ b/rocky/reports/report_types/aggregate_organisation_report/report.html @@ -103,6 +103,21 @@

{% translate "Vulnerabilities" %}

{% endif %} + {% if data.findings %} +
+
+

{% translate "Findings" %}

+

+ {% 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 %} + +
+
+ {% endif %}
{% include "aggregate_organisation_report/appendix.html" %} diff --git a/rocky/reports/report_types/aggregate_organisation_report/report.py b/rocky/reports/report_types/aggregate_organisation_report/report.py index 8c337716e83..485bebc167a 100644 --- a/rocky/reports/report_types/aggregate_organisation_report/report.py +++ b/rocky/reports/report_types/aggregate_organisation_report/report.py @@ -6,6 +6,7 @@ from octopoes.models.ooi.config import Config from reports.report_types.definitions import AggregateReport +from reports.report_types.findings_report.report import SEVERITY_OPTIONS, FindingsReport from reports.report_types.ipv6_report.report import IPv6Report from reports.report_types.mail_report.report import MailReport from reports.report_types.name_server_report.report import NameServerSystemReport @@ -35,6 +36,7 @@ class AggregateOrganisationReport(AggregateReport): WebSystemReport, NameServerSystemReport, SafeConnectionsReport, + FindingsReport, ], } template_path = "aggregate_organisation_report/report.html" @@ -47,6 +49,7 @@ def post_process_data( open_ports = {} ipv6 = {} vulnerabilities = {} + findings: dict[str, Any] = {} total_criticals = 0 total_systems = 0 unique_ips = set() @@ -62,7 +65,7 @@ def post_process_data( # report_id => input_ooi => {data} # to # input_ooi => report_id => {data} - data: dict[str, dict[str, Any]] = {} + data: dict[str, Any] = {} for report_id, report_datas in report_data.items(): for input_ooi, item in report_datas.items(): @@ -126,6 +129,32 @@ def post_process_data( if report_id == SafeConnectionsReport.id: safe_connections_ips.update({ip: value for ip, value in report_specific_data["sc_ips"].items()}) + if report_id == FindingsReport.id: + if not findings: + findings["finding_types"] = {} + findings["summary"] = { + "total_by_severity": {severity: 0 for severity in SEVERITY_OPTIONS}, + "total_by_severity_per_finding_type": {severity: 0 for severity in SEVERITY_OPTIONS}, + "total_finding_types": 0, + "total_occurrences": 0, + } + + for data in report_specific_data["finding_types"]: + finding_type = data["finding_type"] + finding_type_id = finding_type.id + occurrences = data["occurrences"] + severity = finding_type.risk_severity.value + + if finding_type_id not in findings["finding_types"]: + findings["finding_types"][finding_type_id] = { + "finding_type": finding_type, + "occurrences": occurrences, + } + findings["summary"]["total_by_severity_per_finding_type"][severity] += 1 + findings["summary"]["total_finding_types"] += 1 + else: + findings["finding_types"][finding_type_id]["occurrences"].extend(occurrences) + mail_report_data = self.collect_system_specific_data(data, services, SystemType.MAIL, MailReport.id) web_report_data = self.collect_system_specific_data(data, services, SystemType.WEB, WebSystemReport.id) dns_report_data = self.collect_system_specific_data(data, services, SystemType.DNS, NameServerSystemReport.id) @@ -138,7 +167,7 @@ def post_process_data( basic_security: dict[str, Any] = {"rpki": {}, "system_specific": {}, "safe_connections": {}} # Safe connections - for ip, findings in safe_connections_ips.items(): + for ip, findings_types in safe_connections_ips.items(): ip_services = systems["services"][str(ip)]["services"] for service in ip_services: @@ -152,12 +181,12 @@ def post_process_data( if ip in basic_security["safe_connections"][service]["sc_ips"]: continue # We already processed data from this ip for this service - basic_security["safe_connections"][service]["sc_ips"][ip.tokenized.address] = findings + basic_security["safe_connections"][service]["sc_ips"][ip.tokenized.address] = findings_types basic_security["safe_connections"][service]["number_of_ips"] += 1 - basic_security["safe_connections"][service]["number_of_available"] += 1 if not findings else 0 + basic_security["safe_connections"][service]["number_of_available"] += 1 if not findings_types else 0 # Collect recommendations from findings - recommendations.extend({finding_type.recommendation for finding_type in findings}) + recommendations.extend({finding_type.recommendation for finding_type in findings_types}) # RPKI for ip, compliance in rpki_ips.items(): @@ -196,6 +225,29 @@ def post_process_data( report for ip in dns_report_data for report in dns_report_data[ip] ] + # Findings + if "finding_types" in findings: + for finding_type in findings["finding_types"].values(): + # Remove duplicate occurrences + severity = finding_type["finding_type"].risk_severity.value + unique_occurrences = [] + seen_keys = set() + + for occurrence in finding_type["occurrences"]: + occurrence_ooi = occurrence["finding"].ooi + + if occurrence_ooi not in seen_keys: + seen_keys.add(occurrence_ooi) + unique_occurrences.append(occurrence) + findings["summary"]["total_by_severity"][severity] += 1 + + finding_type["occurrences"] = unique_occurrences + findings["summary"]["total_occurrences"] += len(unique_occurrences) + + findings["finding_types"] = sorted( + findings["finding_types"].values(), key=lambda x: x["finding_type"].risk_score or 0, reverse=True + ) + # Summary basic_security["summary"] = {} @@ -415,6 +467,7 @@ def is_mail_compliant(result): "open_ports": open_ports, "ipv6": ipv6, "vulnerabilities": vulnerabilities, + "findings": findings, "basic_security": basic_security, "summary": summary, "total_findings": len(all_findings), diff --git a/rocky/reports/report_types/findings_report/report.html b/rocky/reports/report_types/findings_report/report.html index 6eecba51e50..d0382318be1 100644 --- a/rocky/reports/report_types/findings_report/report.html +++ b/rocky/reports/report_types/findings_report/report.html @@ -1,85 +1,137 @@ {% load i18n %} {% load ooi_extra %} -{% if data.finding_types %} - {% if show_introduction %} -

- {% 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 %} -
- {% include "partials/report_severity_totals_table.html" with data=data.summary %} +{% if show_introduction %} +

+ {% 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 %} +
+ + + + + + + + + + + + + + {% for organization, organization_dashboard in organizations_dashboards.items %} + {% for report in organization_dashboard.values %} + {% with findings=report.report_data.findings %} + {% if findings %} + + + + + + + + + + +
{% translate "Findings per organization overview" %}
{% 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 %} + + +
+
+
{% translate "Findings overview" %}
+

+ {% translate "This overview shows the total number of findings per severity that have been identified for this organization." %} +

+
+ {% include "partials/report_severity_totals_table.html" with data=findings.summary %} + +
+
+ {% 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 %} +

+
+ {% include "partials/report_findings_table.html" with finding_types=findings.finding_types %} -

{% translate "Findings" %}

-
- - - - - - - - - - - - {% for info in data.finding_types %} - - - - - - - - - + + + + {% endif %} + {% endwith %} {% endfor %} - -
{% translate "Other findings found" %}
{% translate "Finding" %}{% translate "Risk level" %}{% translate "Occurrences" %}{% translate "Details" %}
{{ info.finding_type.id }} - {{ info.finding_type.risk_severity|capfirst }} - {{ info.occurrences|length }} - -
-

{% 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 "Occurrences" %}

- -
-
+ {% endfor %} +
+
+{% elif is_dashboard %} +
+
{% translate "Findings overview" %}
+ {% include "partials/report_severity_totals_table.html" with show_introduction="yes" data=data.findings.summary %} + +
{% translate "Findings" %}
+ {% include "partials/report_findings_table.html" with show_introduction="yes" finding_types=data.findings.finding_types %} +
{% else %} -

{% translate "No findings have been identified yet." %}

+
+

{% translate "Findings overview" %}

+ {% include "partials/report_severity_totals_table.html" with show_introduction="yes" data=data.summary %} + +

{% translate "Findings" %}

+ {% include "partials/report_findings_table.html" with show_introduction="yes" finding_types=data.finding_types %} + +
{% endif %} diff --git a/rocky/reports/report_types/multi_organization_report/report.html b/rocky/reports/report_types/multi_organization_report/report.html index d05cb418c40..1bf483f5395 100644 --- a/rocky/reports/report_types/multi_organization_report/report.html +++ b/rocky/reports/report_types/multi_organization_report/report.html @@ -27,6 +27,20 @@
{% include "multi_organization_report/vulnerabilities.html" %} + + {% if data.findings %} +
+
+

{% translate "Findings" %}

+ {% if not data.findings %} +

{% 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 %} +
+
+ {% endif %} {% include "multi_organization_report/appendix.html" %}
diff --git a/rocky/reports/report_types/multi_organization_report/report.py b/rocky/reports/report_types/multi_organization_report/report.py index 1fbc91a7c4b..0121c6edd6c 100644 --- a/rocky/reports/report_types/multi_organization_report/report.py +++ b/rocky/reports/report_types/multi_organization_report/report.py @@ -7,6 +7,7 @@ from octopoes.models import Reference from octopoes.models.ooi.reports import ReportData from reports.report_types.definitions import MultiReport, ReportPlugins +from reports.report_types.findings_report.report import SEVERITY_OPTIONS class OpenPortsDict(TypedDict): @@ -51,6 +52,7 @@ def post_process_data(self, data: dict[str, Any]) -> dict[str, Any]: system_specific: dict[str, SystemSpecificDict] = {} rpki_summary = {} ipv6 = {} + findings: dict[str, Any] = {} recommendation_counts = {} organization_metrics: dict[str, Any] = {} @@ -169,6 +171,32 @@ def post_process_data(self, data: dict[str, Any]) -> dict[str, Any]: recommendation_counts[recommendation] += 1 + # Findings + if not findings: + findings["finding_types"] = {} + findings["summary"] = { + "total_by_severity": {severity: 0 for severity in SEVERITY_OPTIONS}, + "total_by_severity_per_finding_type": {severity: 0 for severity in SEVERITY_OPTIONS}, + "total_finding_types": 0, + "total_occurrences": 0, + } + + for finding_type_with_occurrences in aggregate_data["findings"]["finding_types"]: + finding_type = finding_type_with_occurrences["finding_type"] + finding_type_id = finding_type["id"] + occurrences = finding_type_with_occurrences["occurrences"] + severity = finding_type["risk_severity"] + + if finding_type_id not in findings["finding_types"]: + findings["finding_types"][finding_type_id] = { + "finding_type": finding_type, + "occurrences": occurrences, + } + findings["summary"]["total_by_severity_per_finding_type"][severity] += 1 + findings["summary"]["total_finding_types"] += 1 + else: + findings["finding_types"][finding_type_id]["occurrences"].extend(occurrences) + # Get metrics per organization for best and worst security score ## Safe Connections is_check_compliant = ( @@ -222,6 +250,27 @@ def post_process_data(self, data: dict[str, Any]) -> dict[str, Any]: sorted(system_vulnerabilities.items(), key=lambda x: x[1]["cvss"] or 0, reverse=True) ) + # Remove duplicate occurrences + for finding_type in findings["finding_types"].values(): + severity = finding_type["finding_type"]["risk_severity"] + unique_occurrences = [] + seen_keys = set() + + for occurrence in finding_type["occurrences"]: + occurrence_ooi = occurrence["finding"]["ooi"] + + if occurrence_ooi not in seen_keys: + seen_keys.add(occurrence_ooi) + unique_occurrences.append(occurrence) + findings["summary"]["total_by_severity"][severity] += 1 + + finding_type["occurrences"] = unique_occurrences + findings["summary"]["total_occurrences"] += len(unique_occurrences) + + findings["finding_types"] = sorted( + findings["finding_types"].values(), key=lambda x: x["finding_type"]["risk_score"] or 0, reverse=True + ) + return { "multi_data": data, "organizations": [value["organization_code"] for key, value in data.items()], @@ -252,6 +301,7 @@ def post_process_data(self, data: dict[str, Any]) -> dict[str, Any]: "best_scoring": best_score, "worst_scoring": worst_score, "ipv6": ipv6, + "findings": findings, } diff --git a/rocky/reports/templates/partials/report_findings_table.html b/rocky/reports/templates/partials/report_findings_table.html new file mode 100644 index 00000000000..c1ec5cff4b3 --- /dev/null +++ b/rocky/reports/templates/partials/report_findings_table.html @@ -0,0 +1,102 @@ +{% load i18n %} +{% load ooi_extra %} + +{% if show_introduction %} +

+ {% 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 %} +
+ + + + + + + + + {% if is_dashboard_findings %} + + {% else %} + + {% endif %} + + + + {% for info in finding_types %} + + + + + + {% if is_dashboard_findings %} + + {% else %} + + {% endif %} + + {% if not is_dashboard_findings %} + + + + {% endif %} + {% endfor %} + +
{% translate "Other findings found" %}
{% translate "Risk level" %}{% translate "Finding types" %}{% translate "Occurrences" %}{% translate "First known occurrence" %}{% translate "Open in report" %}{% translate "Details" %}
+ {{ info.finding_type.risk_severity|capfirst }} + {{ info.finding_type.id }}{{ info.occurrences|length }}{{ info.occurrences|get_first_seen }} + + + +
+

{% 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" %}

+ + + + + + + + + + {% for occurrence in info.occurrences %} + + + + + {% endfor %} + +
{% translate "Findings overview" %}
{% translate "Finding" %}{% translate "First known occurrence" %}
+ {{ occurrence.finding.ooi|human_readable }} + {{ occurrence.first_seen|get_datetime }}
+
+
+{% elif is_dashboard_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 "Findings overview" %}

-
- {% include "partials/report_severity_totals_table.html" %} - -
-
-
diff --git a/rocky/reports/templates/partials/report_severity_totals_table.html b/rocky/reports/templates/partials/report_severity_totals_table.html index 0074622bfc1..9669305f5de 100644 --- a/rocky/reports/templates/partials/report_severity_totals_table.html +++ b/rocky/reports/templates/partials/report_severity_totals_table.html @@ -1,63 +1,80 @@ {% load i18n %} -
-
-

{% translate "Findings overview" %}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% translate "Total per severity overview" %}
{% 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 }}
-
-
-
+{% if show_introduction %} +

+ {% blocktranslate %} + This overview shows the total number of findings per + severity that have been identified for this organization. + {% endblocktranslate %} +

+{% endif %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% translate "Total per severity overview" %}
{% 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 }}
+ Pending + {{ data.total_by_severity_per_finding_type.pending }}{{ data.total_by_severity.pending }}
+ Unknown + {{ data.total_by_severity_per_finding_type.unknown }}{{ data.total_by_severity.unknown }}
Total{{ data.total_finding_types }}{{ data.total_occurrences }}
+
diff --git a/rocky/reports/templates/partials/report_sidemenu.html b/rocky/reports/templates/partials/report_sidemenu.html index 7e617dfce29..070c9be3818 100644 --- a/rocky/reports/templates/partials/report_sidemenu.html +++ b/rocky/reports/templates/partials/report_sidemenu.html @@ -49,6 +49,21 @@

{% translate "Table of contents" %}

{% endif %} + {% if data.findings.summary %} +
  • + {% translate "Findings" %} +
      +
    1. + {% translate "Findings overview" %} +
    2. + {% if data.findings.finding_types %} +
    3. + {% translate "Findings" %} +
    4. + {% endif %} +
    +
  • + {% endif %}
  • {% translate "Vulnerabilities" %} {% if data.vulnerabilities %} diff --git a/rocky/reports/views/report_overview.py b/rocky/reports/views/report_overview.py index 302af4697f9..1caca037222 100644 --- a/rocky/reports/views/report_overview.py +++ b/rocky/reports/views/report_overview.py @@ -83,7 +83,7 @@ def get_queryset(self) -> list[dict[str, Any]]: "recipe": report_recipe, "cron": schedule["schedule"], "deadline_at": datetime.fromisoformat(schedule_datetime) if schedule_datetime else "asap", - "reports": reports, + "reports": reports[:5], "total_oois": len( {asset_report.input_ooi for report in reports for asset_report in report.input_oois} ), diff --git a/rocky/rocky/locale/django.pot b/rocky/rocky/locale/django.pot index fa127eb135e..79d056c9b3d 100644 --- a/rocky/rocky/locale/django.pot +++ b/rocky/rocky/locale/django.pot @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-13 15:25+0000\n" +"POT-Creation-Date: 2025-02-14 16:47+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -353,7 +353,7 @@ msgid "Member type" msgstr "" #: account/templates/account_detail.html -#: rocky/templates/crisis_room/crisis_room_findings_block.html +#: reports/report_types/findings_report/report.html #: rocky/templates/tasks/normalizers.html msgid "Organization" msgstr "" @@ -595,10 +595,135 @@ msgstr "" msgid "Cancel" msgstr "" -#: crisis_room/views.py +#: crisis_room/models.py msgid "" -"Failed to get list of findings for organization {}, check server logs for " -"more details." +"Where on the dashboard do you want to show the data? Position 1 is the most " +"top level and the max position is 16." +msgstr "" + +#: crisis_room/models.py +msgid "Will be displayed on the general crisis room, for all organizations." +msgstr "" + +#: crisis_room/models.py +msgid "Will be displayed on a single organization dashboard" +msgstr "" + +#: crisis_room/models.py +msgid "Will be displayed on the findings dashboard for all organizations" +msgstr "" + +#: crisis_room/templates/crisis_room.html rocky/templates/403.html +#: rocky/templates/404.html +msgid "Crisis Room" +msgstr "" + +#: crisis_room/templates/crisis_room.html +msgid "Crisis Room overview for all organizations" +msgstr "" + +#: crisis_room/templates/crisis_room_dashboards.html +msgid "Dashboards" +msgstr "" + +#: crisis_room/templates/crisis_room_dashboards.html +msgid "" +"\n" +" On this page you can see an overview of the " +"dashboards for all organizations.\n" +" **More context can be written here**\n" +" " +msgstr "" + +#: crisis_room/templates/crisis_room_dashboards.html +msgid "dashboards" +msgstr "" + +#: crisis_room/templates/crisis_room_dashboards.html +msgid "There are no dashboards to display." +msgstr "" + +#: crisis_room/templates/crisis_room_findings.html +#: reports/report_types/findings_report/report.html +#: reports/templates/partials/report_findings_table.html +#: reports/templates/partials/report_sidemenu.html +msgid "Findings overview" +msgstr "" + +#: crisis_room/templates/crisis_room_findings.html +msgid "" +"\n" +" This overview shows the total number of findings " +"per\n" +" severity that have been identified for all " +"organizations.\n" +" This data is based on the latest Crisis Room " +"Findings Report\n" +" of each organization.\n" +" " +msgstr "" + +#: crisis_room/templates/crisis_room_findings.html +msgid "Findings per organization" +msgstr "" + +#: crisis_room/templates/crisis_room_findings.html +msgid "" +"\n" +" This table shows the findings that have been " +"identiefied for each organization,\n" +" sorted by the finding types and grouped by " +"organizations.\n" +" This data is based on the latest Crisis Room " +"Findings Report\n" +" of each organization.\n" +" " +msgstr "" + +#: crisis_room/templates/crisis_room_findings.html +msgid "" +"\n" +" No findings have been identified yet. As soon as they have " +"been\n" +" identified, they will be shown on this page.\n" +" " +msgstr "" + +#: crisis_room/templates/crisis_room_findings.html +msgid "" +"\n" +" There are no organizations yet. After creating an organization,\n" +" the identified findings with severity 'critical' and 'high' will " +"be shown here.\n" +" " +msgstr "" + +#: crisis_room/templates/crisis_room_header.html +msgid "Crisis Room Navigation" +msgstr "" + +#: crisis_room/templates/crisis_room_header.html +#: reports/report_types/aggregate_organisation_report/report.html +#: reports/report_types/aggregate_organisation_report/system_specific.html +#: reports/report_types/findings_report/report.html +#: reports/report_types/mail_report/report.html +#: reports/report_types/multi_organization_report/report.html +#: reports/report_types/name_server_report/report.html +#: reports/report_types/tls_report/report.html +#: reports/report_types/vulnerability_report/report.html +#: reports/report_types/web_system_report/report.html +#: reports/templates/partials/report_findings_table.html +#: reports/templates/partials/report_sidemenu.html +#: rocky/templates/dashboard_client.html rocky/templates/dashboard_redteam.html +#: rocky/templates/header.html +#: rocky/templates/oois/ooi_detail_findings_list.html +#: rocky/templates/oois/ooi_detail_findings_overview.html +#: rocky/templates/oois/ooi_page_tabs.html +#: rocky/templates/partials/ooi_report_findings_block.html +#: rocky/templates/partials/ooi_report_findings_block_table.html +#: rocky/views/finding_list.py rocky/views/finding_type_add.py +#: rocky/views/ooi_view.py +msgid "Findings" msgstr "" #: katalogus/client.py @@ -921,8 +1046,8 @@ msgstr "" #: katalogus/templates/normalizer_detail.html #: reports/report_types/aggregate_organisation_report/appendix.html #: reports/report_types/dns_report/report.html -#: reports/report_types/findings_report/report.html #: reports/report_types/vulnerability_report/report.html +#: reports/templates/partials/report_findings_table.html #: reports/templates/summary/report_asset_overview.html tools/forms/boefje.py #: tools/forms/finding_type.py rocky/templates/oois/ooi_detail.html #: rocky/templates/oois/ooi_detail_findings_list.html rocky/templates/scan.html @@ -1255,9 +1380,9 @@ msgstr "" #: reports/report_types/dns_report/report.html #: reports/report_types/findings_report/report.html #: reports/report_types/vulnerability_report/report.html +#: reports/templates/partials/report_findings_table.html #: reports/templates/report_overview/scheduled_reports_table.html #: reports/templates/summary/selected_plugins.html -#: rocky/templates/crisis_room/crisis_room_findings_block.html #: rocky/templates/findings/finding_list.html #: rocky/templates/organizations/organization_crisis_room.html #: rocky/templates/tasks/boefjes.html rocky/templates/tasks/normalizers.html @@ -1270,9 +1395,9 @@ msgstr "" #: reports/report_types/dns_report/report.html #: reports/report_types/findings_report/report.html #: reports/report_types/vulnerability_report/report.html +#: reports/templates/partials/report_findings_table.html #: reports/templates/report_overview/scheduled_reports_table.html #: reports/templates/summary/selected_plugins.html -#: rocky/templates/crisis_room/crisis_room_findings_block.html #: rocky/templates/findings/finding_list.html #: rocky/templates/organizations/organization_crisis_room.html #: rocky/templates/tasks/boefjes.html rocky/templates/tasks/normalizers.html @@ -2785,6 +2910,15 @@ msgstr "" msgid "No CVEs have been found." msgstr "" +#: reports/report_types/aggregate_organisation_report/report.html +msgid "" +"\n" +" This chapter contains information about the " +"findings that have been identified\n" +" for this organization.\n" +" " +msgstr "" + #: reports/report_types/aggregate_organisation_report/report.py msgid "Aggregate Organisation Report" msgstr "" @@ -2874,27 +3008,6 @@ msgstr "" msgid "Host:" msgstr "" -#: reports/report_types/aggregate_organisation_report/system_specific.html -#: reports/report_types/findings_report/report.html -#: reports/report_types/mail_report/report.html -#: reports/report_types/name_server_report/report.html -#: reports/report_types/tls_report/report.html -#: reports/report_types/vulnerability_report/report.html -#: reports/report_types/web_system_report/report.html -#: reports/templates/partials/report_severity_totals_table.html -#: rocky/templates/crisis_room/crisis_room_findings_block.html -#: rocky/templates/dashboard_client.html rocky/templates/dashboard_redteam.html -#: rocky/templates/header.html -#: rocky/templates/oois/ooi_detail_findings_list.html -#: rocky/templates/oois/ooi_detail_findings_overview.html -#: rocky/templates/oois/ooi_page_tabs.html -#: rocky/templates/partials/ooi_report_findings_block.html -#: rocky/templates/partials/ooi_report_findings_block_table.html -#: rocky/views/finding_list.py rocky/views/finding_type_add.py -#: rocky/views/ooi_view.py -msgid "Findings" -msgstr "" - #: reports/report_types/aggregate_organisation_report/system_specific.html #: reports/report_types/mail_report/report.html #: reports/report_types/name_server_report/report.html @@ -2905,13 +3018,13 @@ msgid "Compliance issue" msgstr "" #: reports/report_types/aggregate_organisation_report/system_specific.html -#: reports/report_types/findings_report/report.html #: reports/report_types/mail_report/report.html #: reports/report_types/name_server_report/report.html #: reports/report_types/rpki_report/report.html #: reports/report_types/safe_connections_report/report.html #: reports/report_types/vulnerability_report/report.html #: reports/report_types/web_system_report/report.html +#: reports/templates/partials/report_findings_table.html #: reports/templates/partials/report_severity_totals_table.html #: rocky/templates/partials/ooi_report_findings_block_table.html #: rocky/templates/partials/ooi_report_findings_block_table_expanded_row.html @@ -3047,7 +3160,7 @@ msgid "No" msgstr "" #: reports/report_types/dns_report/report.html -#: reports/report_types/findings_report/report.html +#: reports/templates/partials/report_findings_table.html msgid "Other findings found" msgstr "" @@ -3061,7 +3174,7 @@ msgid "Severity" msgstr "" #: reports/report_types/dns_report/report.html -#: reports/report_types/findings_report/report.html +#: reports/templates/partials/report_findings_table.html #: rocky/templates/findings/finding_add.html #: rocky/templates/findings/finding_list.html #: rocky/templates/organizations/organization_crisis_room.html @@ -3072,9 +3185,9 @@ msgstr "" #: reports/report_types/dns_report/report.html #: reports/report_types/findings_report/report.html #: reports/report_types/vulnerability_report/report.html +#: reports/templates/partials/report_findings_table.html #: reports/templates/report_overview/scheduled_reports_table.html #: reports/templates/summary/selected_plugins.html -#: rocky/templates/crisis_room/crisis_room_findings_block.html #: rocky/templates/findings/finding_list.html #: rocky/templates/organizations/organization_crisis_room.html #: rocky/templates/partials/ooi_report_findings_block_table.html @@ -3091,6 +3204,7 @@ msgstr "" #: reports/report_types/dns_report/report.html #: reports/report_types/findings_report/report.html #: reports/report_types/vulnerability_report/report.html +#: reports/templates/partials/report_findings_table.html #: reports/templates/partials/report_severity_totals_table.html #: rocky/templates/oois/ooi_detail_findings_overview.html #: rocky/templates/partials/ooi_report_findings_block_table.html @@ -3106,38 +3220,58 @@ msgstr "" #: reports/report_types/findings_report/report.html msgid "" -"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." +"The Findings Report contains information about the findings that have been " +"identified for the selected asset and organization." msgstr "" #: reports/report_types/findings_report/report.html -#: reports/report_types/vulnerability_report/report.py -#: rocky/templates/oois/ooi_detail_origins_inference.html -#: rocky/templates/oois/ooi_detail_origins_observations.html -#: rocky/templates/partials/ooi_report_findings_block_table_expanded_row.html -msgid "Source" +msgid "Findings per organization overview" msgstr "" #: reports/report_types/findings_report/report.html -msgid "Impact" +#: reports/templates/partials/report_findings_table.html +#: reports/templates/partials/report_severity_totals_table.html +#: tools/forms/finding_type.py +msgid "Finding types" msgstr "" #: reports/report_types/findings_report/report.html -#: reports/report_types/multi_organization_report/recommendations.html -msgid "Recommendation" +msgid "Highest risk level" msgstr "" #: reports/report_types/findings_report/report.html -#: reports/report_types/vulnerability_report/report.py -msgid "First seen" +msgid "Critical finding types" msgstr "" #: reports/report_types/findings_report/report.html -#: rocky/templates/organizations/organization_crisis_room.html -msgid "No findings have been identified yet." +msgid "" +"This overview shows the total number of findings per severity that have been " +"identified for this organization." +msgstr "" + +#: reports/report_types/findings_report/report.html +msgid "Critical and high findings" +msgstr "" + +#: reports/report_types/findings_report/report.html +msgid "" +"\n" +" This table shows the top 25 " +"critical and high findings that have\n" +" been identified for this " +"organization, grouped by finding types.\n" +" A table with all the " +"identified findings can be found in the Findings Report.\n" +" " +msgstr "" + +#: reports/report_types/findings_report/report.html +msgid "View findings report" +msgstr "" + +#: reports/report_types/findings_report/report.html +#: reports/templates/report_overview/scheduled_reports_table.html +msgid "Edit report recipe" msgstr "" #: reports/report_types/findings_report/report.py @@ -3364,11 +3498,21 @@ msgstr "" msgid "Overview of recommendations" msgstr "" +#: reports/report_types/multi_organization_report/recommendations.html +#: reports/templates/partials/report_findings_table.html +msgid "Recommendation" +msgstr "" + #: reports/report_types/multi_organization_report/recommendations.html #: rocky/templates/partials/ooi_report_findings_block_table_expanded_row.html msgid "Occurrence" msgstr "" +#: reports/report_types/multi_organization_report/report.html +#: rocky/templates/organizations/organization_crisis_room.html +msgid "No findings have been identified yet." +msgstr "" + #: reports/report_types/multi_organization_report/report.py msgid "Multi Organization Report" msgstr "" @@ -3655,6 +3799,18 @@ msgstr "" msgid "Vulnerabilities found are grouped for each system." msgstr "" +#: reports/report_types/vulnerability_report/report.py +#: reports/templates/partials/report_findings_table.html +#: rocky/templates/oois/ooi_detail_origins_inference.html +#: rocky/templates/oois/ooi_detail_origins_observations.html +#: rocky/templates/partials/ooi_report_findings_block_table_expanded_row.html +msgid "Source" +msgstr "" + +#: reports/report_types/vulnerability_report/report.py +msgid "First seen" +msgstr "" + #: reports/report_types/vulnerability_report/report.py msgid "Last seen" msgstr "" @@ -3805,6 +3961,43 @@ msgstr "" msgid "Ready" msgstr "" +#: reports/templates/partials/report_findings_table.html +msgid "" +"\n" +" This table provides an overview of the identified findings on " +"the scanned\n" +" systems. For each finding type it shows the risk level, the " +"number of occurrences\n" +" and the first known occurrence of the finding. The risk level " +"may be different for your specific environment.\n" +" The details can be seen when expanding a row. A description, the " +"source, impact and recommendation of the finding\n" +" can be found here. It also shows in which findings the finding " +"type occurred.\n" +" " +msgstr "" + +#: reports/templates/partials/report_findings_table.html +msgid "First known occurrence" +msgstr "" + +#: reports/templates/partials/report_findings_table.html +msgid "Open in report" +msgstr "" + +#: reports/templates/partials/report_findings_table.html +msgid "Impact" +msgstr "" + +#: reports/templates/partials/report_findings_table.html +msgid "" +"No critical and high findings have been identified for this organization." +msgstr "" + +#: reports/templates/partials/report_findings_table.html +msgid "No findings have been identified for this organization." +msgstr "" + #: reports/templates/partials/report_header.html msgid "" "All selected report types for the selected objects are displayed one below " @@ -4062,9 +4255,12 @@ msgstr "" msgid "Enable plugins and continue" msgstr "" -#: reports/templates/partials/report_severity_totals.html #: reports/templates/partials/report_severity_totals_table.html -msgid "Findings overview" +msgid "" +"\n" +" This overview shows the total number of findings per\n" +" severity that have been identified for this organization.\n" +" " msgstr "" #: reports/templates/partials/report_severity_totals_table.html @@ -4345,10 +4541,6 @@ msgstr "" msgid "objects" msgstr "" -#: reports/templates/report_overview/scheduled_reports_table.html -msgid "Edit report recipe" -msgstr "" - #: reports/templates/report_overview/scheduled_reports_table.html msgid "Enable schedule" msgstr "" @@ -4703,10 +4895,6 @@ msgstr "" msgid "Click to select one of the available options" msgstr "" -#: tools/forms/finding_type.py -msgid "Finding types" -msgstr "" - #: tools/forms/finding_type.py #: rocky/templates/partials/finding_occurrence_definition_list.html msgid "Proof" @@ -5235,11 +5423,6 @@ msgstr "" msgid "You may want to go back to the" msgstr "" -#: rocky/templates/403.html rocky/templates/404.html -#: rocky/templates/crisis_room/crisis_room.html -msgid "Crisis Room" -msgstr "" - #: rocky/templates/404.html msgid "Error code 404: Page not found" msgstr "" @@ -5374,41 +5557,6 @@ msgstr "" msgid "Popup closing…" msgstr "" -#: rocky/templates/crisis_room/crisis_room.html -msgid "" -"An overview of all (critical) findings OpenKAT found. Check the detail " -"section for additional severity information." -msgstr "" - -#: rocky/templates/crisis_room/crisis_room_findings_block.html -msgid "Total findings" -msgstr "" - -#: rocky/templates/crisis_room/crisis_room_findings_block.html -msgid "Total Findings" -msgstr "" - -#: rocky/templates/crisis_room/crisis_room_findings_block.html -msgid " Finding Details" -msgstr "" - -#: rocky/templates/crisis_room/crisis_room_findings_block.html -#: rocky/templates/organizations/organization_list.html -msgid "There were no organizations found for your user account" -msgstr "" - -#: rocky/templates/crisis_room/crisis_room_findings_block.html -msgid "Top critical organizations" -msgstr "" - -#: rocky/templates/crisis_room/crisis_room_findings_block.html -msgid "Critical findings" -msgstr "" - -#: rocky/templates/crisis_room/crisis_room_findings_block.html -msgid "Critical Findings" -msgstr "" - #: rocky/templates/dashboard_client.html rocky/templates/dashboard_redteam.html #: rocky/templates/header.html msgid "Close menu" @@ -6153,6 +6301,10 @@ msgstr "" msgid "Tags" msgstr "" +#: rocky/templates/organizations/organization_list.html +msgid "There were no organizations found for your user account" +msgstr "" + #: rocky/templates/organizations/organization_list.html msgid "Actions to perform for all of your organizations." msgstr "" diff --git a/rocky/rocky/scheduler.py b/rocky/rocky/scheduler.py index 3353551d2ea..c599984dfb5 100644 --- a/rocky/rocky/scheduler.py +++ b/rocky/rocky/scheduler.py @@ -171,6 +171,17 @@ class ScheduleResponse(BaseModel): modified_at: datetime.datetime +class SchedulerResponse(BaseModel): + id: str + enabled: bool + priority_queue: dict[str, Any] + last_activity: str | None + + +class SchedulerNoResponse(BaseModel): + detail: str + + class Queue(BaseModel): id: str size: int @@ -315,8 +326,12 @@ def post_schedule(self, schedule: ScheduleRequest) -> ScheduleResponse: logger.info("Schedule created", event_code=800081, schedule=schedule) return ScheduleResponse.model_validate_json(res.content) - except (ValidationError, HTTPStatusError, ConnectError): + except ValidationError: raise SchedulerValidationError(extra_message="Report schedule failed: ") + except HTTPStatusError: + raise SchedulerHTTPError() + except ConnectError: + raise SchedulerConnectError() def delete_schedule(self, schedule_id: str) -> None: try: diff --git a/rocky/rocky/signals.py b/rocky/rocky/signals.py index 249b21cc252..5449cea1cb4 100644 --- a/rocky/rocky/signals.py +++ b/rocky/rocky/signals.py @@ -1,5 +1,6 @@ import datetime +from crisis_room.management.commands.dashboards import get_or_create_default_dashboard from django.conf import settings from django.contrib.admin.models import LogEntry from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed @@ -115,6 +116,7 @@ def organization_pre_save(sender, instance, *args, **kwargs): @receiver(post_save, sender=Organization) def organization_post_save(sender, instance, *args, **kwargs): octopoes_client = _get_healthy_octopoes(instance.code) + get_or_create_default_dashboard(instance, octopoes_client) try: valid_time = datetime.datetime.now(datetime.timezone.utc) diff --git a/rocky/rocky/templates/crisis_room/crisis_room.html b/rocky/rocky/templates/crisis_room/crisis_room.html deleted file mode 100644 index f08a8efcccf..00000000000 --- a/rocky/rocky/templates/crisis_room/crisis_room.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "layouts/base.html" %} - -{% load i18n %} -{% load static %} - -{% block content %} - {% include "header.html" %} - -
    -
    -
    -

    {% translate "Crisis Room" %} @ {{ observed_at|date:'M d, Y' }}

    -

    - {% translate "An overview of all (critical) findings OpenKAT found. Check the detail section for additional severity information." %} -

    -
    -
    -
    -
    - {% include "partials/elements/ooi_report_settings.html" %} - -
    -
    - {% include "crisis_room/crisis_room_findings_block.html" %} - -
    -{% endblock content %} diff --git a/rocky/rocky/templates/crisis_room/crisis_room_findings_block.html b/rocky/rocky/templates/crisis_room/crisis_room_findings_block.html deleted file mode 100644 index 2beecb9e82c..00000000000 --- a/rocky/rocky/templates/crisis_room/crisis_room_findings_block.html +++ /dev/null @@ -1,134 +0,0 @@ -{% load i18n %} - -
    -
    -
    -

    {% translate "Total findings" %}

    - {% if organizations %} -
    - - - - - - - - - - - {% for org_finding_count in org_finding_counts_per_severity %} - - - - - - - - - {% endfor %} - -
    {% translate "Findings" %}
    {% translate "Organization" %}{% translate "Total Findings" %}{% translate "Details" %}
    - {{ org_finding_count.name }} - {{ org_finding_count.total }} - -
    -

    {{ org_finding_count.name }} {% translate " Finding Details" %}

    -
    - {% for severity, count in org_finding_count.finding_count_per_severity.items %} -
    -
    - {% if count != 0 %} - {{ severity|title }} - {% else %} - {{ severity|title }} - {% endif %} -
    -
    - {% if count != 0 %} - {{ count }} - {% else %} - {{ count }} - {% endif %} -
    -
    - {% endfor %} -
    -
    -
    - {% else %} - {% translate "There were no organizations found for your user account" %}. - {% endif %} -
    -
    - {% if perms.tools.view_organization %} -

    {% translate "Top critical organizations" %}

    - {% else %} -

    {% translate "Critical findings" %}

    - {% endif %} -
    - {% if organizations %} - - - - - - - - - - - {% for org_finding_count in org_finding_counts_per_severity_critical %} - - - - - - - - - {% endfor %} - -
    {% translate "Critical findings" %}
    {% translate "Organization" %}{% translate "Critical Findings" %}{% translate "Details" %}
    - {{ org_finding_count.name }} - {{ org_finding_count.total_critical }} - -
    -

    {{ org_finding_count.name }} {% translate " Finding Details" %}

    -
    - {% for severity, count in org_finding_count.finding_count_per_severity.items %} -
    -
    - {% if count != 0 %} - {{ severity|title }} - {% else %} - {{ severity|title }} - {% endif %} -
    -
    - {% if count != 0 %} - {{ count }} - {% else %} - {{ count }} - {% endif %} -
    -
    - {% endfor %} -
    -
    - {% else %} - {% translate "There were no organizations found for your user account" %}. - {% endif %} -
    -
    -
    -
    diff --git a/rocky/rocky/templates/organizations/organization_crisis_room.html b/rocky/rocky/templates/organizations/organization_crisis_room.html index bdf47c846c5..59a4515712a 100644 --- a/rocky/rocky/templates/organizations/organization_crisis_room.html +++ b/rocky/rocky/templates/organizations/organization_crisis_room.html @@ -9,6 +9,7 @@
    + {{ member }}

    {% translate "Crisis room" %} {{ organization.name }} @ {{ observed_at|date:'M d, Y' }}

    {% if not indemnification_present %}

    {% 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 }}

    {% translate "Top 10 most severe Findings" %}

    {% if object_list %} {% translate "Object list" as filter_title %} diff --git a/rocky/tests/conftest.py b/rocky/tests/conftest.py index c57e59007a6..12f7da15e9b 100644 --- a/rocky/tests/conftest.py +++ b/rocky/tests/conftest.py @@ -12,6 +12,7 @@ import pytest import structlog +from crisis_room.models import Dashboard, DashboardData from django.conf import settings from django.contrib.auth.models import Group, Permission from django.contrib.messages.middleware import MessageMiddleware @@ -21,6 +22,7 @@ from django_otp.middleware import OTPMiddleware from httpx import Response from katalogus.client import Boefje, parse_plugin +from reports.report_types.findings_report.report import FindingsReport from tools.enums import SCAN_LEVEL from tools.models import GROUP_ADMIN, GROUP_CLIENT, GROUP_REDTEAM, Indemnification, Organization, OrganizationMember @@ -94,7 +96,10 @@ def create_user(django_user_model, email, password, name, device_name, superuser def create_organization(name, organization_code): katalogus_client = "katalogus.client.KATalogusClient" octopoes_node = "rocky.signals.OctopoesAPIConnector" - with patch(katalogus_client), patch(octopoes_node): + scheduler_client = "crisis_room.management.commands.dashboards.scheduler_client" + bytes_client = "crisis_room.management.commands.dashboards.get_bytes_client" + + with patch(katalogus_client), patch(octopoes_node), patch(scheduler_client), patch(bytes_client): return Organization.objects.create(name=name, code=organization_code) @@ -1872,3 +1877,305 @@ def reports_task_list(): ), ], ) + + +@pytest.fixture +def dashboard_data(client_member, client_member_b): + # make sure that no dashboards exist to test this particular set + Dashboard.objects.all().delete() + DashboardData.objects.all().delete() + + recipe_id_a = "7ebcdb32-e7f2-4c2d-840a-d7b8e6b37616" + recipe_id_b = "c41bbf9a-7102-4b6b-b256-b3036e106316" + + dashboard_a = Dashboard.objects.create( + name="Crisis Room Findings Dashboard", organization=client_member.organization + ) + dashboard_data_a = DashboardData.objects.create(dashboard=dashboard_a, recipe=recipe_id_a, findings_dashboard=True) + + dashboard_b = Dashboard.objects.create( + name="Crisis Room Findings Dashboard", organization=client_member_b.organization + ) + dashboard_data_b = DashboardData.objects.create(dashboard=dashboard_b, recipe=recipe_id_b, findings_dashboard=True) + + return [dashboard_data_a, dashboard_data_b] + + +@pytest.fixture +def findings_reports(client_member, client_member_b): + bytes_raw_id_a = "62258c3d-89b2-4fde-a2e0-d78715a174e6" + bytes_raw_id_b = "1b887350-0afb-4786-b587-4323cd8e4180" + + recipe_id_a = "7ebcdb32-e7f2-4c2d-840a-d7b8e6b37616" + recipe_id_b = "c41bbf9a-7102-4b6b-b256-b3036e106316" + + asset_report_a = create_asset_report( + name="Findings Report for mispo.es", + report_type=FindingsReport.id, + template=FindingsReport.template_path, + uuid_iterator=iter(["a5ccf97b-d4e9-442d-85bf-84e739b63da9s"]), + ) + + asset_report_b = create_asset_report( + name="Findings Report for mispo.es", + report_type=FindingsReport.id, + template=FindingsReport.template_path, + uuid_iterator=iter(["a5ccf97b-d4e9-442d-85bf-84e739b63da9l"]), + ) + + report_a = HydratedReport( + object_type="HydratedReport", + scan_profile=None, + user_id=None, + primary_key="Report|9a0fd1f4-ba2b-4800-ade8-7f17f099e179", + name="Crisis Room Aggregate Report", + report_type="aggregate-organisation-report", + template="aggregate_organisation_report/report.html", + date_generated=datetime(2024, 12, 23, 12, 0, 32, 730678), + reference_date=datetime(2024, 12, 23, 12, 0, 32, 730678), + input_oois=[asset_report_a], + report_id=UUID("9a0fd1f4-ba2b-4800-ade8-7f17f099e179"), + organization_code=client_member.organization.code, + organization_name=client_member.organization.name, + organization_tags=[], + data_raw_id=bytes_raw_id_a, + observed_at=datetime(2024, 12, 23, 12, 0, 32, 53194), + parent_report=None, + report_recipe=Reference(recipe_id_a), + has_parent=False, + ) + + report_b = HydratedReport( + object_type="HydratedReport", + scan_profile=None, + user_id=None, + primary_key="Report|2b871ed0-44e5-4375-85af-4a1cf44145f7", + name="Crisis Room Aggregate Report", + report_type="aggregate-organisation-report", + template="aggregate_organisation_report/report.html", + date_generated=datetime(2024, 12, 23, 11, 0, 32, 447950), + reference_date=datetime(2024, 12, 23, 11, 0, 32, 447950), + input_oois=[asset_report_b], + report_id=UUID("2b871ed0-44e5-4375-85af-4a1cf44145f7"), + organization_code=client_member_b.organization.code, + organization_name=client_member_b.organization.name, + organization_tags=[], + data_raw_id=bytes_raw_id_b, + observed_at=datetime(2024, 12, 23, 11, 0, 31, 602127), + parent_report=None, + report_recipe=Reference(recipe_id_b), + has_parent=False, + ) + + reports = [report_a, report_b] + + return Paginated(count=len(reports), items=reports) + + +@pytest.fixture +def findings_report_bytes_data(): + report_data_a = { + "systems": {"services": {}}, + "services": {}, + "recommendations": [], + "recommendation_counts": {}, + "open_ports": {}, + "ipv6": {}, + "vulnerabilities": {}, + "findings": { + "finding_types": [], + "summary": { + "total_by_severity_per_finding_type": { + "critical": 0, + "high": 0, + "medium": 3, + "low": 1, + "recommendation": 0, + "pending": 0, + "unknown": 0, + }, + "total_by_severity": { + "critical": 0, + "high": 0, + "medium": 4, + "low": 3, + "recommendation": 0, + "pending": 0, + "unknown": 0, + }, + "total_finding_types": 4, + "total_occurrences": 7, + }, + }, + "basic_security": { + "rpki": {}, + "system_specific": {"Mail": [], "Web": [], "DNS": []}, + "safe_connections": {}, + "summary": {}, + }, + "summary": {"critical_vulnerabilities": 0, "ips_scanned": 0, "hostnames_scanned": 0, "terms_in_report": ""}, + "total_findings": 0, + "total_systems": 0, + "total_hostnames": 0, + "total_systems_basic_security": 0, + "health": [ + {"service": "rocky", "healthy": True, "version": "0.0.1.dev1", "additional": None, "results": []}, + {"service": "octopoes", "healthy": True, "version": "0.0.1.dev1", "additional": None, "results": []}, + { + "service": "xtdb", + "healthy": True, + "version": "1.24.4", + "additional": { + "version": "1.24.4", + "revision": "b46e92df67699cb25f3b21a61742c79da564b3b0", + "indexVersion": 22, + "consumerState": None, + "kvStore": "xtdb.rocksdb.RocksKv", + "estimateNumKeys": 56338, + "size": 93781419, + }, + "results": [], + }, + { + "service": "katalogus", + "healthy": True, + "version": "0.0.1-development", + "additional": None, + "results": [], + }, + {"service": "scheduler", "healthy": True, "version": "0.0.1.dev1", "additional": None, "results": []}, + {"service": "bytes", "healthy": True, "version": "0.0.1.dev1", "additional": None, "results": []}, + {"service": "keiko", "healthy": True, "version": "0.0.1.dev1", "additional": None, "results": []}, + ], + "config_oois": [], + "input_data": { + "input_oois": ["Hostname|internet|mispo.es"], + "report_types": ["systems-report", "findings-report"], + "plugins": { + "required": [ + "nmap", + "webpage-analysis", + "ssl-certificates", + "nmap-udp", + "ssl-version", + "testssl-sh-ciphers", + "dns-records", + ], + "optional": ["leakix", "snyk", "service_banner", "shodan"], + }, + }, + } + + report_data_b = { + "systems": {"services": {}}, + "services": {}, + "recommendations": [], + "recommendation_counts": {}, + "open_ports": {}, + "ipv6": {}, + "vulnerabilities": {}, + "findings": { + "finding_types": [], + "summary": { + "total_by_severity_per_finding_type": { + "critical": 1, + "high": 2, + "medium": 4, + "low": 2, + "recommendation": 1, + "pending": 1, + "unknown": 1, + }, + "total_by_severity": { + "critical": 3, + "high": 3, + "medium": 5, + "low": 3, + "recommendation": 1, + "pending": 1, + "unknown": 1, + }, + "total_finding_types": 12, + "total_occurrences": 17, + }, + }, + "basic_security": { + "rpki": {}, + "system_specific": {"Mail": [], "Web": [], "DNS": []}, + "safe_connections": {}, + "summary": {}, + }, + "summary": {"critical_vulnerabilities": 0, "ips_scanned": 0, "hostnames_scanned": 0, "terms_in_report": ""}, + "total_findings": 0, + "total_systems": 0, + "total_hostnames": 0, + "total_systems_basic_security": 0, + "health": [ + {"service": "rocky", "healthy": True, "version": "0.0.1.dev1", "additional": None, "results": []}, + {"service": "octopoes", "healthy": True, "version": "0.0.1.dev1", "additional": None, "results": []}, + { + "service": "xtdb", + "healthy": True, + "version": "1.24.4", + "additional": { + "version": "1.24.4", + "revision": "b46e92df67699cb25f3b21a61742c79da564b3b0", + "indexVersion": 22, + "consumerState": None, + "kvStore": "xtdb.rocksdb.RocksKv", + "estimateNumKeys": 54693, + "size": 91850532, + }, + "results": [], + }, + { + "service": "katalogus", + "healthy": True, + "version": "0.0.1-development", + "additional": None, + "results": [], + }, + {"service": "scheduler", "healthy": True, "version": "0.0.1.dev1", "additional": None, "results": []}, + {"service": "bytes", "healthy": True, "version": "0.0.1.dev1", "additional": None, "results": []}, + {"service": "keiko", "healthy": True, "version": "0.0.1.dev1", "additional": None, "results": []}, + ], + "config_oois": [], + "input_data": { + "input_oois": ["Hostname|internet|mispo.es"], + "report_types": ["systems-report", "findings-report"], + "plugins": { + "required": [ + "nmap", + "webpage-analysis", + "ssl-certificates", + "nmap-udp", + "ssl-version", + "testssl-sh-ciphers", + "dns-records", + ], + "optional": ["leakix", "snyk", "service_banner", "shodan"], + }, + }, + } + return [report_data_a, report_data_b] + + +@pytest.fixture +def findings_dashboard_mock_data(dashboard_data, findings_reports, findings_report_bytes_data): + dashboard_data_a = dashboard_data[0] + dashboard_data_b = dashboard_data[1] + + report_a = findings_reports.items[0] + report_b = findings_reports.items[1] + + report_data_a = findings_report_bytes_data[0] + report_data_b = findings_report_bytes_data[1] + + return { + dashboard_data_a.dashboard.organization: { + dashboard_data_a: {"report": report_a, "report_data": report_data_a, "highest_risk_level": "medium"} + }, + dashboard_data_b.dashboard.organization: { + dashboard_data_b: {"report": report_b, "report_data": report_data_b, "highest_risk_level": "medium"} + }, + } diff --git a/rocky/tests/integration/conftest.py b/rocky/tests/integration/conftest.py index de2383630dc..9cca6f4f21c 100644 --- a/rocky/tests/integration/conftest.py +++ b/rocky/tests/integration/conftest.py @@ -34,7 +34,11 @@ def katalogus_mock(mocker): @pytest.fixture -def integration_organization(katalogus_mock, request) -> Organization: +def integration_organization(katalogus_mock, mocker, request) -> Organization: + mocker.patch("rocky.signals.OctopoesAPIConnector") + mocker.patch("crisis_room.management.commands.dashboards.scheduler_client") + mocker.patch("crisis_room.management.commands.dashboards.get_bytes_client") + test_node = f"test-{request.node.originalname}" return Organization.objects.create(name="Test", code=test_node) diff --git a/rocky/tests/onboarding/test_onboarding_create_organization.py b/rocky/tests/onboarding/test_onboarding_create_organization.py index 66d435a80b1..825a779c333 100644 --- a/rocky/tests/onboarding/test_onboarding_create_organization.py +++ b/rocky/tests/onboarding/test_onboarding_create_organization.py @@ -18,8 +18,12 @@ def test_onboarding_create_organization(rf, superuser_member, mock_models_katalo def test_onboarding_create_organization_already_exist_katalogus( - rf, superuser, mock_katalogus_client, mock_models_octopoes + rf, superuser, mock_katalogus_client, mock_models_octopoes, mocker ): + mocker.patch("katalogus.client.KATalogusClient") + mocker.patch("rocky.signals.OctopoesAPIConnector") + mocker.patch("crisis_room.management.commands.dashboards.scheduler_client") + mocker.patch("crisis_room.management.commands.dashboards.get_bytes_client") request = setup_request( rf.post("step_organization_setup", {"name": "Test Organization", "code": "test"}), superuser ) diff --git a/rocky/tests/test_admin.py b/rocky/tests/test_admin.py index b4cd0071bfd..e8cd1e0fd21 100644 --- a/rocky/tests/test_admin.py +++ b/rocky/tests/test_admin.py @@ -23,6 +23,14 @@ def setUp(self): octopoes_patcher.start() self.addCleanup(octopoes_patcher.stop) + scheduler_patcher = patch("crisis_room.management.commands.dashboards.scheduler_client") + scheduler_patcher.start() + self.addCleanup(scheduler_patcher.stop) + + bytes_patcher = patch("rocky.bytes_client.BytesClient") + bytes_patcher.start() + self.addCleanup(bytes_patcher.stop) + class AuthTokenAdminTestCase(ModelAdminTestCase): model = AuthToken diff --git a/rocky/tests/test_api_organization.py b/rocky/tests/test_api_organization.py index 61d22222d76..4993aa2b0e5 100644 --- a/rocky/tests/test_api_organization.py +++ b/rocky/tests/test_api_organization.py @@ -46,15 +46,27 @@ def express_organization(organization: Organization) -> dict[str, Any]: class TestOrganizationViewSet(ViewSetTest): @pytest.fixture def organizations(self): - with patch("katalogus.client.KATalogusClient"), patch("rocky.signals.OctopoesAPIConnector"): - return [ - Organization.objects.create(name="Test Organization 1", code="test1", tags=["tag1", "tag2"]), - Organization.objects.create(name="Test Organization 2", code="test2"), - ] + created_organizations = [] + organizations = [ + {"name": "Test Organization 1", "code": "test1", "tags": ["tag1", "tag2"]}, + {"name": "Test Organization 2", "code": "test2"}, + ] + + for org in organizations: + with ( + patch("katalogus.client.KATalogusClient"), + patch("rocky.signals.OctopoesAPIConnector"), + patch("crisis_room.management.commands.dashboards.scheduler_client"), + patch("crisis_room.management.commands.dashboards.get_bytes_client"), + ): + created_organizations.append(Organization.objects.create(**org)) + + return created_organizations organization = lambda_fixture(lambda organizations: organizations[0]) list_url = lambda_fixture(lambda: url_for("organization-list")) + detail_url = lambda_fixture(lambda organization: url_for("organization-detail", organization.pk)) client = lambda_fixture("drf_admin_client") @@ -79,6 +91,18 @@ class TestCreate(UsesPostMethod, UsesListEndpoint, Returns201): def mock_katalogus(self, mocker): mocker.patch("katalogus.client.KATalogusClient") + @pytest.fixture(autouse=True) + def mock_octopoes(self, mocker): + mocker.patch("rocky.signals.OctopoesAPIConnector") + + @pytest.fixture(autouse=True) + def mock_scheduler(self, mocker): + mocker.patch("crisis_room.management.commands.dashboards.scheduler_client") + + @pytest.fixture(autouse=True) + def mock_bytes(self, mocker): + mocker.patch("crisis_room.management.commands.dashboards.get_bytes_client") + def test_it_creates_new_organization(self, initial_ids, json): expected = initial_ids | {json["id"]} actual = set(Organization.objects.values_list("id", flat=True)) diff --git a/rocky/tests/test_crisis_room.py b/rocky/tests/test_crisis_room.py deleted file mode 100644 index 94e9b8f577a..00000000000 --- a/rocky/tests/test_crisis_room.py +++ /dev/null @@ -1,69 +0,0 @@ -from crisis_room.views import CrisisRoomView, OrganizationFindingCountPerSeverity -from django.urls import resolve, reverse -from pytest_django.asserts import assertContains - -from octopoes.connector import ConnectorException -from tests.conftest import setup_request - - -def test_crisis_room(rf, client_member, mock_crisis_room_octopoes): - request = setup_request(rf.get("crisis_room"), client_member.user) - request.resolver_match = resolve(reverse("crisis_room")) - - mock_crisis_room_octopoes().count_findings_by_severity.return_value = {"medium": 1, "critical": 0} - - response = CrisisRoomView.as_view()(request) - - assert response.status_code == 200 - - assertContains(response, '1', html=True) - assertContains(response, "
    0
    ", html=True) - - assert mock_crisis_room_octopoes().count_findings_by_severity.call_count == 1 - - -def test_crisis_room_observed_at(rf, client_member, mock_crisis_room_octopoes): - request = setup_request(rf.get("crisis_room", {"observed_at": "2021-01-01"}), client_member.user) - request.resolver_match = resolve(reverse("crisis_room")) - response = CrisisRoomView.as_view()(request) - assert response.status_code == 200 - assertContains(response, "Jan 01, 2021") # Next to title crisis room - assertContains(response, "2021-01-01") # Date Widget - - -def test_crisis_room_observed_at_bad_format(rf, client_member, mock_crisis_room_octopoes): - request = setup_request(rf.get("crisis_room", {"observed_at": "2021-bad-format"}), client_member.user) - request.resolver_match = resolve(reverse("crisis_room")) - response = CrisisRoomView.as_view()(request) - assert response.status_code == 200 - assertContains(response, "Can not parse date, falling back to show current date.") - assertContains(response, "Enter a valid date.") - - -def test_org_finding_count_total(): - assert OrganizationFindingCountPerSeverity("dev", "_dev", {"medium": 1, "low": 2}).total == 3 - - -def test_crisis_room_error(rf, client_user_two_organizations, mock_crisis_room_octopoes): - request = setup_request(rf.get("crisis_room"), client_user_two_organizations) - request.resolver_match = resolve(reverse("crisis_room")) - - mock_crisis_room_octopoes().count_findings_by_severity.side_effect = [ - {"medium": 1, "critical": 0}, - ConnectorException("error"), - ] - - response = CrisisRoomView.as_view()(request) - - assert response.status_code == 200 - - assertContains(response, '1', html=True) - assertContains(response, "
    0
    ", html=True) - - messages = list(request._messages) - assert ( - messages[0].message - == "Failed to get list of findings for organization org_b, check server logs for more details." - ) - - assert mock_crisis_room_octopoes().count_findings_by_severity.call_count == 2 diff --git a/rocky/tests/test_dashboard.py b/rocky/tests/test_dashboard.py new file mode 100644 index 00000000000..564e7d26607 --- /dev/null +++ b/rocky/tests/test_dashboard.py @@ -0,0 +1,142 @@ +import json + +from crisis_room.views import CrisisRoom, DashboardService +from pytest_django.asserts import assertContains + +from tests.conftest import setup_request + + +def test_crisis_room_findings_dashboard(rf, mocker, client_member, findings_dashboard_mock_data): + """Test if the view is visible and if data is shown in the tables.""" + dashboard_service = mocker.patch("crisis_room.views.DashboardService")() + dashboard_service.collect_findings_dashboard.return_value = findings_dashboard_mock_data + summary = dashboard_service.get_organizations_findings_summary.side_effect = ( + DashboardService().get_organizations_findings_summary + ) + summary(findings_dashboard_mock_data) + + request = setup_request(rf.get("crisis_room"), client_member.user) + response = CrisisRoom.as_view()(request) + + assert response.status_code == 200 + # View should show the 'Findings overview' for all organizations + assertContains(response, "

    Findings overview

    ", html=True) + assertContains(response, 'Total per severity overview', html=True) + assertContains( + response, + 'Critical13', + html=True, + ) + assertContains(response, 'Total1624', html=True) + + # View should also show the 'Findings for all orgniazations' table for all organizations + assertContains(response, "

    Findings per organization

    ", html=True) + assertContains(response, 'Findings per organization overview', html=True) + + assertContains(response, 'Test Organization', html=True) + assertContains(response, "
    Findings overview
    ", html=True) + assertContains(response, 'Total47', html=True) + + assertContains(response, 'OrganizationB', html=True) + assertContains(response, 'Total1217', html=True) + assertContains( + response, "

    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, findings_dashboard_mock_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().list_reports.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.items[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/tests/test_organization.py b/rocky/tests/test_organization.py index 45b23676994..3c0519c2019 100644 --- a/rocky/tests/test_organization.py +++ b/rocky/tests/test_organization.py @@ -21,7 +21,12 @@ @pytest.fixture def bulk_organizations(active_member, blocked_member): - with patch("katalogus.client.KATalogusClient"), patch("rocky.signals.OctopoesAPIConnector"): + katalogus_client = "katalogus.client.KATalogusClient" + octopoes_node = "rocky.signals.OctopoesAPIConnector" + scheduler_client = "crisis_room.management.commands.dashboards.scheduler_client" + bytes_client = "crisis_room.management.commands.dashboards.get_bytes_client" + + with patch(katalogus_client), patch(octopoes_node), patch(scheduler_client), patch(bytes_client): organizations = [] for i in range(1, AMOUNT_OF_TEST_ORGANIZATIONS): org = Organization.objects.create(name=f"Test Organization {i}", code=f"test{i}", tags=f"test-tag{i}") @@ -69,6 +74,11 @@ def test_add_organization_page(rf, superuser_member): def test_add_organization_submit_success(rf, superuser_member, mocker, mock_models_octopoes, log_output): mocker.patch("katalogus.client.KATalogusClient") + mocker.patch("rocky.signals.OctopoesAPIConnector") + mocker.patch("crisis_room.management.commands.dashboards.scheduler_client") + mocker.patch("crisis_room.management.commands.dashboards.get_bytes_client") + mocker.patch("rocky.bytes_client.get_bytes_client") + request = setup_request(rf.post("organization_add", {"name": "neworg", "code": "norg"}), superuser_member.user) response = OrganizationAddView.as_view()(request, organization_code=superuser_member.organization.code) assert response.status_code == 302 @@ -76,14 +86,68 @@ def test_add_organization_submit_success(rf, superuser_member, mocker, mock_mode messages = list(request._messages) assert "Organization added successfully" in messages[0].message - organization_created_log = log_output.entries[-3] - organization_member_created_log = log_output.entries[-2] - assert organization_created_log["event"] == "%s %s created" - assert organization_created_log["object"] == "neworg" - assert organization_created_log["object_type"] == "Organization" - assert organization_member_created_log["event"] == "%s %s created" - assert organization_member_created_log["object"] == "superuser@openkat.nl" - assert organization_member_created_log["object_type"] == "OrganizationMember" + logs = log_output.entries + + group_client_log, group_redteam_log, group_admin_log = logs[0], logs[1], logs[2] + superuser_log_created, superuser_log_updated = logs[3], logs[4] + static_device_log, static_token_log = logs[5], logs[6] + superuser_organization_log = logs[7] + + dashboard_log, dashboard_log_data_created, dashboard_log_data_updated = logs[8], logs[9], logs[10] + + superuser_indemnification = logs[11] + superuser_organization_member = logs[12] + + this_organization = logs[13] + this_organization_dashboard = logs[14] + this_organization_dashboard_data_created = logs[15] + this_organization_dashboard_data_updated = logs[16] + + this_organization_organization_member_created = logs[17] + this_organization_organization_member_updated = logs[18] + + # groups are created + assert group_client_log["event"] == "%s %s created" + assert group_redteam_log["event"] == "%s %s created" + assert group_admin_log["event"] == "%s %s created" + + # superuser created and updated + assert superuser_log_created["event"] == "%s %s created" + assert superuser_log_updated["event"] == "%s %s updated" + + # 2AF created + assert static_device_log["event"] == "%s %s created" + assert static_token_log["event"] == "%s %s created" + + # superuser org created + assert superuser_organization_log["event"] == "%s %s created" + + # dashboard and dashboard data created and updated + assert dashboard_log["event"] == "%s %s created" + assert dashboard_log_data_created["event"] == "%s %s created" + assert dashboard_log_data_updated["event"] == "%s %s updated" + + # create indemnification for superuser + assert superuser_indemnification["event"] == "%s %s created" + + # create superuser member + assert superuser_organization_member["event"] == "%s %s created" + assert superuser_organization_member["object"] == "superuser@openkat.nl" + assert superuser_organization_member["object_type"] == "OrganizationMember" + + # Organization created for this test + assert this_organization["event"] == "%s %s created" + assert this_organization["object"] == "neworg" + assert this_organization["object_type"] == "Organization" + + # dashboard and dashboard data created and updated for this test (when org is created) + assert this_organization_dashboard["event"] == "%s %s created" + assert this_organization_dashboard_data_created["event"] == "%s %s created" + assert this_organization_dashboard_data_updated["event"] == "%s %s updated" + + # member created and updated for this org + assert this_organization_organization_member_created["event"] == "%s %s created" + assert this_organization_organization_member_updated["event"] == "%s %s updated" def test_add_organization_submit_katalogus_down(rf, superuser_member, mocker): @@ -323,6 +387,10 @@ def test_organization_code_validator_from_view(rf, superuser_member, mocker, moc @pytest.mark.django_db def test_organization_code_validator_from_model(mocker, mock_models_octopoes): mocker.patch("katalogus.client.KATalogusClient") + mocker.patch("rocky.signals.OctopoesAPIConnector") + mocker.patch("crisis_room.management.commands.dashboards.scheduler_client") + mocker.patch("crisis_room.management.commands.dashboards.get_bytes_client") + with pytest.raises(ValidationError): Organization.objects.create(name="Test", code=DENY_ORGANIZATION_CODES[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 3dc087e8aa9..62342bfac65 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 @@ -103,5 +104,15 @@ def ooi_type(reference_string: str) -> str: @register.filter +def get_datetime(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) + + def get_user_full_name(ooi: OOI) -> str: return KATUser.objects.get(id=ooi.user_id).get_full_name()