diff --git a/docs/source/manual/crisis-room.rst b/docs/source/manual/crisis-room.rst new file mode 100644 index 00000000000..80be35906ea --- /dev/null +++ b/docs/source/manual/crisis-room.rst @@ -0,0 +1,90 @@ +=========== +Crisis Room +=========== + +In OpenKAT we differentiate two Crisis Rooms: + +- **Single Organization Crisis Room:** a Crisis Room for each organization separately +- **General Crisis Room:** one general Crisis Room with for all organizations + + +Single Organization Crisis Room +=============================== + +This page shows a Crisis Room for each organization separately. +Currently, this Crisis Room shows the top 10 most severe Findings. +In the future it will serve as a dashboard which can be customized by the user. + + +General Crisis Room +=================== + +This page shows the Crisis Room for all organizations. +Currently, this Crisis Room only shows the Findings, but in the future it will also show dashboards, +which can be customized by the user. + +Findings +-------- +This section shows all the findings that have been identified for all organizations. +These findings are shown in a table, grouped by organization and finding types. + +Every organization has one default report recipe. This recipe is used to create an Aggregate Findings Report. +The output of this report, for each organization, is shown in this section. + +The default settings for this report recipe are: + +- report_name_format = ``Crisis Room Aggregate Report`` +- ooi_types = ``["IPAddressV6", "Hostname", "IPAddressV4", "URL"]`` +- scan_level = ``[1, 2, 3, 4]`` +- scan_type = ``["declared"]`` +- report_types = ``["systems-report", "findings-report"]`` +- cron_expression = ``0 * * * *`` (every hour) + +It is possible to update the report recipe*. To do this: + +- Go to "Reports"- Click on the tab "Scheduled" +- Look for the "Criris Room Aggregate Report" +- Open the row +- Click on "Edit report recipe" + +*\*Note: if you want to update the report recipe, you have to do this for every organization.* + +Create a Findings Dashboard for Your Organization +================================================= + +OpenKAT automates the process of creating findings dashboards for your organization. + +Steps to Create a Findings Dashboard: +-------------------------------------- + +1. **Install OpenKAT or Add a New Organization:** + Ensure that you have OpenKAT installed or a new organization has been added to your setup. + +2. **Navigate to Your OpenKAT Installation Directory:** + Open a terminal and change to the OpenKAT installation folder: + + .. code-block:: bash + + cd nl-kat-coordination + +3. **Go to the 'rocky' Folder:** + Within the OpenKAT directory, enter the ``rocky`` folder: + + .. code-block:: bash + + cd rocky + +4. **Run the Dashboard Creation Command:** + Execute the following command to create the findings dashboard: + + .. code-block:: bash + + make dashboards + +What Happens After Running the Command: +--------------------------------------- + +- The system will automatically search for all installed organizations. +- A **recipe** for the findings dashboard will be generated. +- A **scheduled task** will be created to generate findings reports every hour. +- Findings will be **added to the organization’s crisis room** for easy access and monitoring. diff --git a/docs/source/manual/index.rst b/docs/source/manual/index.rst index 8cd5034b8f9..daecb553580 100644 --- a/docs/source/manual/index.rst +++ b/docs/source/manual/index.rst @@ -8,5 +8,6 @@ An overview of all KAT functionality, from a user perspective. :caption: Contents user-manual + crisis-room reports normalizers diff --git a/rocky/Makefile b/rocky/Makefile index bce14cf9615..95b241d18af 100644 --- a/rocky/Makefile +++ b/rocky/Makefile @@ -14,6 +14,9 @@ build-rocky: # Set DATABASE_MIGRATION=false to prevent entrypoint from running migration docker compose run --rm -e DATABASE_MIGRATION=false rocky make build-rocky-native +dashboards: + docker compose run --rm rocky python3 manage.py dashboards + build-rocky-native: while ! nc -vz $$ROCKY_DB_HOST $$ROCKY_DB_PORT; do sleep 0.1; done python3 manage.py migrate diff --git a/rocky/crisis_room/management/commands/__init__.py b/rocky/crisis_room/management/commands/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/rocky/crisis_room/management/commands/dashboards.py b/rocky/crisis_room/management/commands/dashboards.py new file mode 100644 index 00000000000..76b121d4452 --- /dev/null +++ b/rocky/crisis_room/management/commands/dashboards.py @@ -0,0 +1,86 @@ +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from crisis_room.models import Dashboard, DashboardData +from django.conf import settings +from django.core.management import BaseCommand +from tools.models import Organization +from tools.ooi_helpers import create_ooi + +from octopoes.connector.octopoes import OctopoesAPIConnector +from octopoes.models.ooi.reports import ReportRecipe +from rocky.bytes_client import get_bytes_client +from rocky.scheduler import ReportTask, ScheduleRequest, scheduler_client + +FINDINGS_DASHBOARD_NAME = "Crisis Room Findings Dashboard" + + +def get_or_create_default_dashboard(organization: Organization) -> bool: + valid_time = datetime.now(timezone.utc) + is_scheduler_ready_for_schedule = is_scheduler_enabled(organization) + created = False + + if is_scheduler_ready_for_schedule: + path = Path(__file__).parent / "recipe_seeder.json" + with path.open("r") as recipe_seeder: + recipe_default = json.load(recipe_seeder) + + dashboard, _ = Dashboard.objects.get_or_create(name=FINDINGS_DASHBOARD_NAME, organization=organization) + + dashboard_data, created = DashboardData.objects.get_or_create(dashboard=dashboard) + if created: + recipe = create_organization_recipe(valid_time, organization, recipe_default) + dashboard_data.recipe = recipe.primary_key + schedule_request = create_schedule_request(valid_time, organization, recipe) + scheduler_client(organization.code).post_schedule(schedule=schedule_request) + + dashboard_data.findings_dashboard = True + dashboard_data.save() + return created + + +def create_organization_recipe( + valid_time: datetime, organization: Organization, recipe_default: dict[str, Any] +) -> ReportRecipe: + report_recipe = ReportRecipe(recipe_id=uuid4(), **recipe_default) + + octopoes_client = OctopoesAPIConnector( + settings.OCTOPOES_API, organization.code, timeout=settings.ROCKY_OUTGOING_REQUEST_TIMEOUT + ) + bytes_client = get_bytes_client(organization.code) + + create_ooi(api_connector=octopoes_client, bytes_client=bytes_client, ooi=report_recipe, observed_at=valid_time) + return report_recipe + + +def is_scheduler_enabled(organization: Organization) -> bool: + scheduler_id = f"report-{organization.code}" + return scheduler_client(organization.code).is_scheduler_ready(scheduler_id) + + +def create_schedule_request( + start_datetime: datetime, organization: Organization, report_recipe: ReportRecipe +) -> ScheduleRequest: + report_task = ReportTask( + organisation_id=organization.code, report_recipe_id=str(report_recipe.recipe_id) + ).model_dump() + + return ScheduleRequest( + scheduler_id=f"report-{organization.code}", + data=report_task, + schedule=report_recipe.cron_expression, + deadline_at=start_datetime.isoformat(), + ) + + +class Command(BaseCommand): + def handle(self, *args, **options): + organizations = Organization.objects.all() + for organization in organizations: + created = get_or_create_default_dashboard(organization) + if created: + logging.info("Dashboard created for organization %s", organization.name) diff --git a/rocky/crisis_room/management/commands/recipe_seeder.json b/rocky/crisis_room/management/commands/recipe_seeder.json new file mode 100644 index 00000000000..fa51d182142 --- /dev/null +++ b/rocky/crisis_room/management/commands/recipe_seeder.json @@ -0,0 +1,32 @@ +{ + "report_name_format": "Crisis Room Aggregate Report", + "subreport_name_format": "Findings Report for ${ooi}", + "input_recipe": { + "query": { + "ooi_types": [ + "IPAddressV6", + "Hostname", + "IPAddressV4", + "URL" + ], + "scan_level": [ + 1, + 2, + 3, + 4 + ], + "scan_type": [ + "declared" + ], + "search_string": "", + "order_by": "object_type", + "asc_desc": "desc" + } + }, + "parent_report_type": "aggregate-organisation-report", + "report_types": [ + "systems-report", + "findings-report" + ], + "cron_expression": "0 * * * *" +} diff --git a/rocky/crisis_room/migrations/0001_initial.py b/rocky/crisis_room/migrations/0001_initial.py new file mode 100644 index 00000000000..42cdb7d70a1 --- /dev/null +++ b/rocky/crisis_room/migrations/0001_initial.py @@ -0,0 +1,71 @@ +# Generated by Django 5.0.9 on 2024-12-18 11:29 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [("tools", "0044_alter_organization_options")] + + operations = [ + migrations.CreateModel( + name="Dashboard", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=126)), + ( + "organization", + models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="tools.organization"), + ), + ], + options={"unique_together": {("name", "organization")}}, + ), + migrations.CreateModel( + name="DashboardData", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("recipe", models.CharField(max_length=126)), + ("template", models.CharField(blank=True, default="findings_report/report.html", max_length=126)), + ( + "position", + models.IntegerField( + blank=True, + default=1, + help_text="Where on the dashboard do you want to show the data? Position 1 is the most top level and the max position is 16.", + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(16), + ], + ), + ), + ( + "display_in_crisis_room", + models.BooleanField( + default=False, help_text="Will be displayed on the general crisis room, for all organizations." + ), + ), + ( + "display_in_dashboard", + models.BooleanField( + default=False, help_text="Will be displayed on a single organization dashboard" + ), + ), + ( + "findings_dashboard", + models.BooleanField( + default=False, help_text="Will be displayed on the findings dashboard for all organizations" + ), + ), + ( + "dashboard", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to="crisis_room.dashboard" + ), + ), + ], + options={"unique_together": {("dashboard", "findings_dashboard"), ("dashboard", "position")}}, + ), + ] diff --git a/rocky/crisis_room/models.py b/rocky/crisis_room/models.py new file mode 100644 index 00000000000..494590280af --- /dev/null +++ b/rocky/crisis_room/models.py @@ -0,0 +1,49 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ +from tools.models import Organization + + +class Dashboard(models.Model): + name = models.CharField(blank=False, max_length=126) + organization = models.ForeignKey(Organization, on_delete=models.SET_NULL, null=True) + + class Meta: + unique_together = ["name", "organization"] + + def __str__(self) -> str: + if self.name: + return f"{self.name} for organization {self.organization}" + return super().__str__() + + +class DashboardData(models.Model): + dashboard = models.ForeignKey(Dashboard, on_delete=models.SET_NULL, null=True) + recipe = models.CharField(blank=False, max_length=126) + template = models.CharField(blank=True, max_length=126, default="findings_report/report.html") + position = models.IntegerField( + blank=True, + default=1, + validators=[MinValueValidator(1), MaxValueValidator(16)], + help_text=_( + "Where on the dashboard do you want to show the data? " + "Position 1 is the most top level and the max position is 16." + ), + ) + display_in_crisis_room = models.BooleanField( + default=False, help_text=_("Will be displayed on the general crisis room, for all organizations.") + ) + display_in_dashboard = models.BooleanField( + default=False, help_text=_("Will be displayed on a single organization dashboard") + ) + findings_dashboard = models.BooleanField( + default=False, help_text=_("Will be displayed on the findings dashboard for all organizations") + ) + + class Meta: + unique_together = [["dashboard", "position"], ["dashboard", "findings_dashboard"]] + + def __str__(self) -> str: + if self.dashboard: + return str(self.dashboard) + return super().__str__() diff --git a/rocky/crisis_room/templates/crisis_room.html b/rocky/crisis_room/templates/crisis_room.html new file mode 100644 index 00000000000..c3efc21f121 --- /dev/null +++ b/rocky/crisis_room/templates/crisis_room.html @@ -0,0 +1,19 @@ +{% extends "layouts/base.html" %} + +{% load i18n %} +{% load static %} + +{% block content %} + {% include "header.html" %} + +
+
+
+

{% translate "Crisis Room" %}

+

{% 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..f990e1146ae --- /dev/null +++ b/rocky/crisis_room/templates/crisis_room_findings.html @@ -0,0 +1,51 @@ +{% load i18n %} + +{% if organizations_dashboards %} + {% if 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. + {% 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. + {% 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..ce1b375e600 100644 --- a/rocky/crisis_room/views.py +++ b/rocky/crisis_room/views.py @@ -1,98 +1,145 @@ -from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any 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 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 import Reference +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"}] - - 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) - - 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) - - def get_finding_type_severity_count(self, organization: Organization) -> dict[str, int]: - try: - api_connector = OctopoesAPIConnector( - settings.OCTOPOES_API, organization.code, timeout=settings.ROCKY_OUTGOING_REQUEST_TIMEOUT +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, + ) ) - 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) + report_data["findings"]["finding_types"] = critical_high_finding_types[:25] + + findings = report_data | {"highest_risk_level": highest_risk_level} + return findings + + @staticmethod + def get_octopoes_client(organization_code: str) -> OctopoesAPIConnector: + return OctopoesAPIConnector( + settings.OCTOPOES_API, organization_code, timeout=settings.ROCKY_OUTGOING_REQUEST_TIMEOUT + ) + + @staticmethod + def get_reports(valid_time: datetime, octopoes_client: OctopoesAPIConnector, recipe_id: str): + return octopoes_client.query( + "ReportRecipe. 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 + reports = self.get_reports(self.observed_at, octopoes_client, recipe_id) + + if reports: + reports.sort(key=lambda ooi: ooi.date_generated, reverse=True) + 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 de7f1fbc61f..bb4c9d7be90 100644 --- a/rocky/reports/report_types/aggregate_organisation_report/report.py +++ b/rocky/reports/report_types/aggregate_organisation_report/report.py @@ -9,6 +9,7 @@ from octopoes.models.exception import ObjectNotFoundException 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 @@ -38,6 +39,7 @@ class AggregateOrganisationReport(AggregateReport): WebSystemReport, NameServerSystemReport, SafeConnectionsReport, + FindingsReport, ], } template_path = "aggregate_organisation_report/report.html" @@ -48,6 +50,7 @@ def post_process_data(self, data: dict[str, Any], valid_time: datetime, organiza open_ports = {} ipv6 = {} vulnerabilities = {} + findings: dict[str, Any] = {} total_criticals = 0 total_systems = 0 unique_ips = set() @@ -113,6 +116,32 @@ def post_process_data(self, data: dict[str, Any], valid_time: datetime, organiza 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) @@ -125,7 +154,7 @@ def post_process_data(self, data: dict[str, Any], valid_time: datetime, organiza 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: @@ -139,12 +168,12 @@ def post_process_data(self, data: dict[str, Any], valid_time: datetime, organiza 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(): @@ -183,6 +212,29 @@ def post_process_data(self, data: dict[str, Any], valid_time: datetime, organiza 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"] = {} @@ -402,6 +454,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/runner/worker.py b/rocky/reports/runner/worker.py index 3a8181979b4..ecdff5d3261 100644 --- a/rocky/reports/runner/worker.py +++ b/rocky/reports/runner/worker.py @@ -243,7 +243,7 @@ def _start_working( finally: try: # The docker runner could have handled this already - if scheduler.get_task_details(p_item.id).status == TaskStatus.RUNNING: + if scheduler.get_task_details(str(p_item.id)).status == TaskStatus.RUNNING: scheduler.patch_task(p_item.id, status) # Note that implicitly, we have p_item.id == task_id logger.info("Set status to %s in the scheduler for task[id=%s]", status, p_item.id) except HTTPError: 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..f8dcd6f79bb --- /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_date }}
+
+
+{% 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..ee282a9e43a 100644 --- a/rocky/reports/templates/partials/report_severity_totals_table.html +++ b/rocky/reports/templates/partials/report_severity_totals_table.html @@ -1,63 +1,66 @@ {% 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 }}
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/templates/report_overview/scheduled_reports_table.html b/rocky/reports/templates/report_overview/scheduled_reports_table.html index 8a241a53064..54bab558fed 100644 --- a/rocky/reports/templates/report_overview/scheduled_reports_table.html +++ b/rocky/reports/templates/report_overview/scheduled_reports_table.html @@ -71,6 +71,7 @@ +
    {% translate "Most recent reports" %}
    {% if schedule.reports %} diff --git a/rocky/reports/views/base.py b/rocky/reports/views/base.py index 3eb27848bf7..bf35a17a2a9 100644 --- a/rocky/reports/views/base.py +++ b/rocky/reports/views/base.py @@ -550,6 +550,21 @@ def get_context_data(self, **kwargs): class SaveReportView(BaseReportView, SchedulerView): task_type = "report" + def get_query(self): + object_selection = self.request.POST.get("object_selection", "") + query = {} + + if object_selection == "query": + query = { + "ooi_types": [t.__name__ for t in self.get_ooi_types()], + "scan_level": self.get_ooi_scan_levels(), + "scan_type": self.get_ooi_profile_types(), + "search_string": self.search_string, + "order_by": self.order_by, + "asc_desc": self.sorting_order, + } + return query + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: old_report_names = request.POST.getlist("old_report_name") report_names = request.POST.getlist("report_name", []) @@ -567,24 +582,12 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: elif self.is_scheduled_report(): report_name_format = request.POST.get("parent_report_name", "") subreport_name_format = request.POST.get("child_report_name", "") - object_selection = request.POST.get("object_selection", "") form = ReportScheduleStartDateForm(request.POST) if form.is_valid(): start_datetime = form.cleaned_data["start_datetime"] recurrence = form.cleaned_data["recurrence"] - query = {} - if object_selection == "query": - query = { - "ooi_types": [t.__name__ for t in self.get_ooi_types()], - "scan_level": self.get_ooi_scan_levels(), - "scan_type": self.get_ooi_profile_types(), - "search_string": self.search_string, - "order_by": self.order_by, - "asc_desc": self.sorting_order, - } - parent_report_type = None if self.report_type is not None: parent_report_type = self.report_type.id @@ -594,7 +597,7 @@ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: schedule = self.convert_recurrence_to_cron_expressions(recurrence, start_datetime) report_recipe = self.create_report_recipe( - report_name_format, subreport_name_format, parent_report_type, schedule, query + report_name_format, subreport_name_format, parent_report_type, schedule, self.get_query() ) self.create_report_schedule(report_recipe, start_datetime) diff --git a/rocky/reports/views/report_overview.py b/rocky/reports/views/report_overview.py index 341a1127d41..9b5a0c7cf53 100644 --- a/rocky/reports/views/report_overview.py +++ b/rocky/reports/views/report_overview.py @@ -80,7 +80,7 @@ def get_queryset(self) -> list[dict[str, Any]]: "recipe": recipe_ooi, "cron": schedule["schedule"], "deadline_at": datetime.fromisoformat(schedule["deadline_at"]), - "reports": report_oois, + "reports": report_oois[:5], } ) diff --git a/rocky/rocky/locale/django.pot b/rocky/rocky/locale/django.pot index 01d345e0abc..32a97766577 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-01-03 20:02+0000\n" +"POT-Creation-Date: 2025-01-08 15:41+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,129 @@ msgstr "" msgid "Cancel" msgstr "" -#: crisis_room/views.py +#: crisis_room/models.py +msgid "" +"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" +" " +msgstr "" + +#: crisis_room/templates/crisis_room_findings.html +msgid "Findings per organization" +msgstr "" + +#: crisis_room/templates/crisis_room_findings.html msgid "" -"Failed to get list of findings for organization {}, check server logs for " -"more details." +"\n" +" This table shows the findings that have been " +"identiefied for each organization,\n" +" sorted by the finding types and grouped by " +"organizations.\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 +1040,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 @@ -1250,9 +1369,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 @@ -1265,9 +1384,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 @@ -2772,6 +2891,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 "" @@ -2857,27 +2985,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 @@ -2888,13 +2995,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 @@ -3034,7 +3141,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 "" @@ -3048,7 +3155,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 @@ -3059,8 +3166,8 @@ 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/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 @@ -3077,6 +3184,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 @@ -3092,38 +3200,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 @@ -3350,11 +3478,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 "" @@ -3641,6 +3779,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 "" @@ -3785,6 +3935,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 " @@ -4072,9 +4259,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 @@ -4327,6 +4517,10 @@ msgstr "" msgid "Schedule status" msgstr "" +#: reports/templates/report_overview/scheduled_reports_table.html +msgid "Most recent reports" +msgstr "" + #: reports/templates/report_overview/scheduled_reports_table.html msgid "Scheduled Reports:" msgstr "" @@ -4343,10 +4537,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 "" @@ -4705,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" @@ -5232,11 +5418,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 "" @@ -5371,41 +5552,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" @@ -6144,6 +6290,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 447b67d03b0..c0526231fec 100644 --- a/rocky/rocky/scheduler.py +++ b/rocky/rocky/scheduler.py @@ -3,6 +3,7 @@ import collections import datetime import logging +import time import uuid from enum import Enum from functools import cached_property @@ -168,6 +169,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 @@ -287,6 +299,23 @@ def get_schedule_details(self, schedule_id: str) -> ScheduleResponse: except ConnectError: raise SchedulerConnectError() + def is_scheduler_ready(self, scheduler_id: str) -> bool: + """Max trails is 1 minute, 10 x 10 seconds""" + trials = 0 + interval = 10 # in seconds + while trials < 10: + try: + res = self._client.get(f"/schedulers/{scheduler_id}") + res.raise_for_status() + break + except HTTPStatusError as http_error: + if http_error.response.status_code == codes.NOT_FOUND: + trials += 1 + time.sleep(interval) + continue + raise SchedulerHTTPError() + return SchedulerResponse.model_validate_json(res.content).enabled + def post_schedule_search(self, filters: dict[str, list[dict[str, str]]]) -> PaginatedSchedulesResponse: try: res = self._client.post("/schedules/search", json=filters) 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 %} -
    -
    {% translate "Scheduled Reports:" %}
    - - - - - - - - - - {% 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 c11aea4b237..113b3ac1867 100644 --- a/rocky/tests/conftest.py +++ b/rocky/tests/conftest.py @@ -10,6 +10,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 @@ -2411,3 +2412,283 @@ def aggregate_report_with_sub_reports(): ], ) return aggregate_report + + +@pytest.fixture +def dashboard_data(client_member, client_member_b): + recipe_id_a = "ReportRecipe|7ebcdb32-e7f2-4c2d-840a-d7b8e6b37616" + recipe_id_b = "ReportRecipe|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 = "ReportRecipe|7ebcdb32-e7f2-4c2d-840a-d7b8e6b37616" + recipe_id_b = "ReportRecipe|c41bbf9a-7102-4b6b-b256-b3036e106316" + + report_a = Report( + object_type="Report", + 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), + input_oois=["Hostname|internet|mispo.es"], + 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 = Report( + object_type="Report", + 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), + input_oois=["Hostname|internet|mispo.es"], + 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, + ) + + return [report_a, report_b] + + +@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[0] + report_b = findings_reports[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/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..bdbc71314ef --- /dev/null +++ b/rocky/tests/test_dashboard.py @@ -0,0 +1,140 @@ +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): + """ + Test if the right dashboard is filtered and if the method returns the right dict format. + Only the most recent report should be visible in the dict. + """ + + octopoes_client = mocker.patch("crisis_room.views.OctopoesAPIConnector") + octopoes_client().query.return_value = findings_reports + + bytes_client = mocker.patch("crisis_room.views.get_bytes_client") + bytes_raw_data = [json.dumps(data).encode("utf-8") for data in findings_report_bytes_data] + bytes_client().get_raw.return_value = bytes_raw_data[0] + + organizations = [data.dashboard.organization for data in dashboard_data] + + dashboard_service = DashboardService() + findings_dashboard = dashboard_service.collect_findings_dashboard(organizations) + + assert findings_dashboard[organizations[0]][dashboard_data[0]]["report"] == findings_reports[0] + assert findings_dashboard[organizations[0]][dashboard_data[0]][ + "report_data" + ] == dashboard_service.get_organizations_findings(findings_report_bytes_data[0]) diff --git a/rocky/tools/admin.py b/rocky/tools/admin.py index 112d3201826..988c131fced 100644 --- a/rocky/tools/admin.py +++ b/rocky/tools/admin.py @@ -2,6 +2,7 @@ from json import JSONDecodeError import tagulous.admin +from crisis_room.models import Dashboard, DashboardData from django.contrib import admin, messages from django.db.models import JSONField from django.forms import widgets @@ -86,4 +87,14 @@ class OrganizationTagAdmin(admin.ModelAdmin): pass +@admin.register(DashboardData) +class DahboardDataAdmin(admin.ModelAdmin): + pass + + +@admin.register(Dashboard) +class DahboardAdmin(admin.ModelAdmin): + pass + + tagulous.admin.register(Organization, OrganizationAdmin) diff --git a/rocky/tools/templatetags/ooi_extra.py b/rocky/tools/templatetags/ooi_extra.py index 7a6724540dd..26c2744266f 100644 --- a/rocky/tools/templatetags/ooi_extra.py +++ b/rocky/tools/templatetags/ooi_extra.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from typing import Any from urllib import parse @@ -99,3 +100,14 @@ def clearance_level(ooi: OOI) -> ScanLevel: @register.filter def ooi_type(reference_string: str) -> str: return Reference.from_str(reference_string).class_ + + +@register.filter +def get_date(date_str: str) -> datetime: + return datetime.fromisoformat(date_str) + + +@register.filter +def get_first_seen(occurrences: dict) -> datetime: + first_seen_list = [datetime.fromisoformat(occurrence["first_seen"]) for occurrence in occurrences] + return min(first_seen_list)