diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0ad87be..2edb9e5f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - HackTricks integration to link findings to interesing wiki resources (https://github.com/pablosnt/rekono/issues/271) - Creation of reports in JSON, XML and PDF formats (https://github.com/pablosnt/rekono/issues/273) - Customization of HTTP headers sent by tools (https://github.com/pablosnt/rekono/issues/297) +- Multi Factor Authentication (MFA) (https://github.com/pablosnt/rekono/issues/303) +- Customization of alerts to be notified when a finding under certain circumstances has been found (https://github.com/pablosnt/rekono/issues/301) +- CVE Crowd integration to identify trending CVEs on social networks (https://github.com/pablosnt/rekono/issues/301) ### Security diff --git a/config.yaml b/config.yaml index fe50c323b..f25740524 100644 --- a/config.yaml +++ b/config.yaml @@ -28,3 +28,6 @@ tools: directory: /opt/spring4shell-scan reports: pdf-template: null +monitor: + cvecrowd: + hour: 0 diff --git a/src/backend/alerts/__init__.py b/src/backend/alerts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/alerts/admin.py b/src/backend/alerts/admin.py new file mode 100644 index 000000000..6bdf35592 --- /dev/null +++ b/src/backend/alerts/admin.py @@ -0,0 +1,6 @@ +from alerts.models import Alert +from django.contrib import admin + +# Register your models here. + +admin.site.register(Alert) diff --git a/src/backend/alerts/apps.py b/src/backend/alerts/apps.py new file mode 100644 index 000000000..558ab48cc --- /dev/null +++ b/src/backend/alerts/apps.py @@ -0,0 +1,16 @@ +from pathlib import Path +from typing import Any, List + +from django.apps import AppConfig +from framework.apps import BaseApp + + +class AlertsConfig(BaseApp, AppConfig): + name = "alerts" + fixtures_path = Path(__file__).resolve().parent / "fixtures" + skip_if_model_exists = True + + def _get_models(self) -> List[Any]: + from alerts.models import MonitorSettings + + return [MonitorSettings] diff --git a/src/backend/alerts/enums.py b/src/backend/alerts/enums.py new file mode 100644 index 000000000..446779de1 --- /dev/null +++ b/src/backend/alerts/enums.py @@ -0,0 +1,18 @@ +from django.db.models import TextChoices + + +class AlertItem(TextChoices): + OSINT = "OSINT" + HOST = "Host" + OPEN_PORT = "Open Port" + SERVICE = "Service" + TECHNOLOGY = "Technology" + CREDENTIAL = "Credential" + VULNERABILITY = "Vulnerability" + CVE = "CVE" + + +class AlertMode(TextChoices): + NEW = "New" + FILTER = "Filter" + MONITOR = "Monitor" diff --git a/src/backend/alerts/filters.py b/src/backend/alerts/filters.py new file mode 100644 index 000000000..bfeb9bd98 --- /dev/null +++ b/src/backend/alerts/filters.py @@ -0,0 +1,16 @@ +from alerts.models import Alert +from django_filters.rest_framework import FilterSet + + +class AlertFilter(FilterSet): + class Meta: + model = Alert + fields = { + "project": ["exact"], + "item": ["exact"], + "mode": ["exact"], + "value": ["exact", "icontains"], + "enabled": ["exact"], + "owner": ["exact"], + "suscribers": ["exact"], + } diff --git a/src/backend/alerts/fixtures/1_default.json b/src/backend/alerts/fixtures/1_default.json new file mode 100644 index 000000000..8d7d9980f --- /dev/null +++ b/src/backend/alerts/fixtures/1_default.json @@ -0,0 +1,11 @@ +[ + { + "model": "alerts.monitorsettings", + "pk": 1, + "fields": { + "rq_job_id": null, + "last_monitor": null, + "hour_span": 24 + } + } +] \ No newline at end of file diff --git a/src/backend/alerts/management/__init__.py b/src/backend/alerts/management/__init__.py new file mode 100644 index 000000000..2226fb739 --- /dev/null +++ b/src/backend/alerts/management/__init__.py @@ -0,0 +1 @@ +"""Management commands.""" diff --git a/src/backend/alerts/management/commands/__init__.py b/src/backend/alerts/management/commands/__init__.py new file mode 100644 index 000000000..2226fb739 --- /dev/null +++ b/src/backend/alerts/management/commands/__init__.py @@ -0,0 +1 @@ +"""Management commands.""" diff --git a/src/backend/alerts/management/commands/monitor.py b/src/backend/alerts/management/commands/monitor.py new file mode 100644 index 000000000..36b8d035b --- /dev/null +++ b/src/backend/alerts/management/commands/monitor.py @@ -0,0 +1,11 @@ +from typing import Any + +from alerts.queues import MonitorQueue +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Trigger monitor system" + + def handle(self, *args: Any, **options: Any) -> None: + MonitorQueue().enqueue() diff --git a/src/backend/alerts/models.py b/src/backend/alerts/models.py new file mode 100644 index 000000000..16023cadb --- /dev/null +++ b/src/backend/alerts/models.py @@ -0,0 +1,141 @@ +from alerts.enums import AlertItem, AlertMode +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from executions.models import Execution +from findings.enums import PortStatus, TriageStatus +from findings.models import ( + OSINT, + Credential, + Finding, + Host, + Port, + Technology, + Vulnerability, +) +from framework.models import BaseModel +from projects.models import Project +from rekono.settings import AUTH_USER_MODEL +from security.validators.input_validator import Regex, Validator + +# Create your models here. + + +class Alert(BaseModel): + project = models.ForeignKey( + Project, related_name="alerts", on_delete=models.CASCADE + ) + item = models.TextField(max_length=15, choices=AlertItem.choices) + mode = models.TextField( + max_length=7, choices=AlertMode.choices, default=AlertMode.NEW + ) + value = models.TextField( + max_length=100, + validators=[Validator(Regex.NAME.value, code="filter_value")], + blank=True, + null=True, + ) + enabled = models.BooleanField(default=True) + suscribe_all_members = models.BooleanField(default=False) + owner = models.ForeignKey( + AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True + ) + suscribers = models.ManyToManyField( + AUTH_USER_MODEL, related_name="alerts", blank=True + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["project", "item", "mode", "value"], + name="unique_alerts_1", + condition=models.Q(value__isnull=False), + ), + models.UniqueConstraint( + fields=["project", "item", "mode"], + name="unique_alerts_2", + condition=models.Q(value__isnull=True), + ), + ] + + mapping = { + AlertItem.OSINT.value: {"model": OSINT, AlertMode.NEW.value: True}, + AlertItem.HOST.value: { + "model": Host, + AlertMode.NEW.value: True, + AlertMode.FILTER.value: "address", + }, + AlertItem.OPEN_PORT.value: { + "model": Port, + "filter": lambda f: f.status == PortStatus.OPEN, + AlertMode.NEW.value: True, + }, + AlertItem.SERVICE.value: { + "model": Port, + "filter": lambda f: f.service is not None, + AlertMode.NEW.value: True, + AlertMode.FILTER.value: "service", + }, + AlertItem.TECHNOLOGY.value: { + "model": Technology, + AlertMode.NEW.value: True, + AlertMode.FILTER.value: "name", + }, + AlertItem.CREDENTIAL.value: {"model": Credential, AlertMode.NEW.value: True}, + AlertItem.VULNERABILITY.value: { + "model": Vulnerability, + AlertMode.NEW.value: True, + }, + AlertItem.CVE.value: { + "model": Vulnerability, + "filter": lambda f: f.cve is not None, + AlertMode.NEW.value: True, + AlertMode.FILTER.value: "cve", + AlertMode.MONITOR.value: "trending", + }, + } + + def __str__(self) -> str: + values = [self.project.__str__(), self.mode, self.item] + if self.value: + values.append(self.value) + return " - ".join(values) + + @classmethod + def get_project_field(cls) -> str: + return "project" + + def must_be_triggered(self, execution: Execution, finding: Finding) -> bool: + data = self.mapping[self.item] + if ( + not isinstance(finding, data["model"]) + or finding.is_fixed + or ( + hasattr(finding, "triage_status") + and finding.triage_status == TriageStatus.FALSE_POSITIVE + ) + or not data.get(self.mode) + or (data.get("filter") and not data["filter"](finding)) + ): + return False + if self.mode == AlertMode.NEW.value: + return not finding.executions.exclude(id=execution.id).exists() + elif self.mode == AlertMode.FILTER.value: + return ( + getattr(finding, data.get(AlertMode.FILTER.value, "").lower()) + == self.value.lower() + ) + else: + return ( + getattr(finding, data.get(AlertMode.MONITOR.value, "").lower()) is True + ) + + +class MonitorSettings(BaseModel): + rq_job_id = models.TextField(max_length=50, blank=True, null=True) + last_monitor = models.DateTimeField(blank=True, null=True) + hour_span = models.IntegerField( + default=7, validators=[MinValueValidator(24), MaxValueValidator(168)] + ) + + def __str__(self) -> str: + return f"Last monitor was at {self.last_monitor}. Next one in {self.hour_span} hours" diff --git a/src/backend/alerts/queues.py b/src/backend/alerts/queues.py new file mode 100644 index 000000000..872658748 --- /dev/null +++ b/src/backend/alerts/queues.py @@ -0,0 +1,49 @@ +import logging +from datetime import timedelta +from typing import Any + +from alerts.models import MonitorSettings +from django.utils import timezone +from django_rq import job +from framework.queues import BaseQueue +from platforms.cvecrowd.integrations import CVECrowd +from rq.job import Job + +logger = logging.getLogger() + + +class MonitorQueue(BaseQueue): + name = "monitor" + + def enqueue(self, **kwargs: Any) -> Job: + settings = MonitorSettings.objects.first() + job = self._get_queue().enqueue( + self.consume, on_success=self._scheduled_callback + ) + settings.rq_job_id = job.id + settings.save(update_fields=["rq_job_id"]) + return job + + @staticmethod + @job("monitor") + def consume() -> None: + logger.info("[Monitor] Monitor job has started") + settings = MonitorSettings.objects.first() + settings.last_monitor = timezone.now() + settings.save(update_fields=["last_monitor"]) + for platform in [CVECrowd()]: + platform.monitor() + + @staticmethod + def _scheduled_callback( + job: Any, connection: Any, *args: Any, **kwargs: Any + ) -> None: + settings = MonitorSettings.objects.first() + instance = MonitorQueue() + job = instance._get_queue().enqueue_at( + settings.last_monitor + timedelta(hours=settings.hour_span), + instance.consume, + on_success=instance._scheduled_callback, + ) + settings.rq_job_id = job.id + settings.save(update_fields=["rq_job_id"]) diff --git a/src/backend/alerts/serializers.py b/src/backend/alerts/serializers.py new file mode 100644 index 000000000..d926d118b --- /dev/null +++ b/src/backend/alerts/serializers.py @@ -0,0 +1,86 @@ +from typing import Any, Dict + +from alerts.enums import AlertMode +from alerts.models import Alert, MonitorSettings +from django.core.exceptions import ValidationError +from django.db import transaction +from rest_framework.serializers import ModelSerializer, SerializerMethodField +from users.serializers import SimpleUserSerializer + + +class AlertSerializer(ModelSerializer): + suscribed = SerializerMethodField(read_only=True) + owner = SimpleUserSerializer(many=False, read_only=True) + + class Meta: + model = Alert + fields = ( + "id", + "project", + "item", + "mode", + "value", + "enabled", + "owner", + "suscribed", + "suscribers", + "suscribe_all_members", + ) + read_only_fields = ("id", "suscribed", "enabled", "owner", "suscribers") + extra_kwargs = {"suscribe_all_members": {"write_only": True}} + + def get_suscribed(self, instance: Any) -> bool: + return instance.suscribers.filter( + pk=self.context.get("request").user.id + ).exists() + + def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: + attrs = super().validate(attrs) + if attrs.get("mode") == AlertMode.FILTER and not attrs.get("value"): + raise ValidationError( + "Value is required when the alert mode is 'filter'", code="value" + ) + attrs["enabled"] = True + return attrs + + @transaction.atomic() + def create(self, validated_data: Dict[str, Any]) -> Alert: + alert = super().create(validated_data) + if alert.suscribe_all_members: + alert.suscribers.set(alert.project.members.all()) + else: + alert.suscribers.add(alert.owner) + return alert + + +class EditAlertSerializer(AlertSerializer): + class Meta: + model = Alert + fields = ( + "id", + "project", + "item", + "mode", + "value", + "enabled", + "owner", + "suscribed", + "suscribers", + ) + read_only_fields = ( + "id", + "project", + "item", + "mode", + "enabled", + "owner", + "suscribed", + "suscribers", + ) + + +class MonitorSettingsSerializer(ModelSerializer): + class Meta: + model = MonitorSettings + fields = ("id", "last_monitor", "hour_span") + read_only_fields = ("id", "last_monitor") diff --git a/src/backend/alerts/urls.py b/src/backend/alerts/urls.py new file mode 100644 index 000000000..301d2efd6 --- /dev/null +++ b/src/backend/alerts/urls.py @@ -0,0 +1,8 @@ +from alerts.views import AlertViewSet, MonitorSettingsViewSet +from rest_framework.routers import SimpleRouter + +router = SimpleRouter() +router.register("alerts", AlertViewSet) +router.register("monitor", MonitorSettingsViewSet) + +urlpatterns = router.urls diff --git a/src/backend/alerts/views.py b/src/backend/alerts/views.py new file mode 100644 index 000000000..9b7101908 --- /dev/null +++ b/src/backend/alerts/views.py @@ -0,0 +1,119 @@ +from alerts.filters import AlertFilter +from alerts.models import Alert, MonitorSettings +from alerts.serializers import ( + AlertSerializer, + EditAlertSerializer, + MonitorSettingsSerializer, +) +from django.db.models import QuerySet +from drf_spectacular.utils import extend_schema +from framework.views import BaseViewSet +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import Serializer +from security.authorization.permissions import ( + OwnerPermission, + ProjectMemberPermission, + RekonoModelPermission, +) + +# Create your views here. + + +class AlertViewSet(BaseViewSet): + queryset = Alert.objects.all() + serializer_class = AlertSerializer + filterset_class = AlertFilter + permission_classes = [ + IsAuthenticated, + RekonoModelPermission, + ProjectMemberPermission, + OwnerPermission, + ] + search_fields = ["value"] + ordering_fields = ["id", "item", "mode"] + http_method_names = ["get", "post", "put", "delete"] + + def get_serializer_class(self) -> Serializer: + return ( + EditAlertSerializer + if self.request.method == "PUT" + else super().get_serializer_class() + ) + + def get_queryset(self) -> QuerySet: + queryset = super().get_queryset() + return ( + queryset.filter(enabled=True).all() + if self.request.method == "PUT" + else queryset + ) + + @extend_schema(request=None, responses={204: None}) + @action( + detail=True, + methods=["POST", "DELETE"], + permission_classes=[ + IsAuthenticated, + RekonoModelPermission, + ProjectMemberPermission, + ], + ) + def suscription(self, request: Request, pk: str) -> Response: + alert = self.get_object() + for method, expected_exists, error, operation in [ + ( + "POST", + False, + "You are already suscribed to this alert", + alert.suscribers.add, + ), + ( + "DELETE", + True, + "You are already not suscribed to this alert", + alert.suscribers.remove, + ), + ]: + if request.method == method: + if ( + alert.suscribers.filter(id=request.user.id).exists() + is not expected_exists + ): + return Response( + {"suscribe": error}, status=status.HTTP_400_BAD_REQUEST + ) + operation(request.user) + break + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema(request=None, responses={200: AlertSerializer}) + @action(detail=True, methods=["POST", "DELETE"]) + def enable(self, request: Request, pk: str) -> Response: + alert = self.get_object() + for method, new_value, operation in [ + ("POST", True, "enabled"), + ("DELETE", False, "disabled"), + ]: + if request.method == method: + if alert.enabled == new_value: + return Response( + {"enable": f"This alert is already {operation}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + alert.enabled = new_value + alert.save(update_fields=["enabled"]) + return Response( + AlertSerializer(alert, context={"request": request}).data, + status=status.HTTP_200_OK, + ) + + +class MonitorSettingsViewSet(BaseViewSet): + queryset = MonitorSettings.objects.all() + serializer_class = MonitorSettingsSerializer + permission_classes = [IsAuthenticated, RekonoModelPermission] + http_method_names = ["get", "put"] diff --git a/src/backend/findings/filters.py b/src/backend/findings/filters.py index 9e92a3434..5828906a1 100644 --- a/src/backend/findings/filters.py +++ b/src/backend/findings/filters.py @@ -113,6 +113,7 @@ class Meta: "cve": ["exact", "contains"], "cwe": ["exact", "contains"], "osvdb": ["exact", "contains"], + "trending": ["exact"], } diff --git a/src/backend/findings/framework/views.py b/src/backend/findings/framework/views.py index 2f0414fc2..772c04e5b 100644 --- a/src/backend/findings/framework/views.py +++ b/src/backend/findings/framework/views.py @@ -31,7 +31,7 @@ def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: return self._method_not_allowed("DELETE") # pragma: no cover @extend_schema(request=None, responses={204: None}) - @action(detail=True, methods=["POST", "DELETE"], url_path="fix", url_name="fix") + @action(detail=True, methods=["POST", "DELETE"]) def fix(self, request: Request, pk: str) -> Response: finding = self.get_object() bad_request = None diff --git a/src/backend/findings/models.py b/src/backend/findings/models.py index ee0814450..8a3c40896 100644 --- a/src/backend/findings/models.py +++ b/src/backend/findings/models.py @@ -362,6 +362,7 @@ class Vulnerability(TriageFinding): cwe = models.TextField(max_length=20, blank=True, null=True) osvdb = models.TextField(max_length=20, blank=True, null=True) reference = models.TextField(max_length=250, blank=True, null=True) + trending = models.BooleanField(default=False) unique_fields = ["technology", "port", "name", "cve"] filters = [ diff --git a/src/backend/findings/queues.py b/src/backend/findings/queues.py index b5b5eeb5f..cc78e1bba 100644 --- a/src/backend/findings/queues.py +++ b/src/backend/findings/queues.py @@ -15,10 +15,11 @@ Vulnerability, ) from framework.queues import BaseQueue +from platforms.cvecrowd.integrations import CVECrowd from platforms.defect_dojo.integrations import DefectDojo from platforms.hacktricks import HackTricks from platforms.mail.notifications import SMTP -from platforms.nvd_nist import NvdNist +from platforms.nvdnist import NvdNist from platforms.telegram_app.notifications.notifications import Telegram from rq.job import Job from settings.models import Settings @@ -39,14 +40,29 @@ def enqueue(self, execution: Execution, findings: List[Finding]) -> Job: @staticmethod @job("findings") def consume(execution: Execution, findings: List[Finding]) -> None: - if findings: - for platform in [NvdNist, HackTricks, DefectDojo, SMTP, Telegram]: - platform().process_findings(execution, findings) settings = Settings.objects.first() - if settings.auto_fix_findings: + if findings: + notifications = [SMTP(), Telegram()] + for platform in [ + NvdNist(), + HackTricks(), + CVECrowd(), + DefectDojo(), + ] + notifications: + platform.process_findings(execution, findings) for finding in findings: - if finding.is_fixed: + if settings.auto_fix_findings and finding.is_fixed: finding.__class__.objects.remove_fix(finding) + for alert in ( + execution.task.target.project.alerts.filter(enabled=True) + .sort("-item") + .all() + ): + if alert.must_be_triggered(execution, finding): + for platform in notifications: + platform.process_alert(alert, finding) + break + if settings.auto_fix_findings: for finding_type in [ OSINT, Host, diff --git a/src/backend/findings/serializers.py b/src/backend/findings/serializers.py index af71edacf..f4224e7ba 100644 --- a/src/backend/findings/serializers.py +++ b/src/backend/findings/serializers.py @@ -116,6 +116,7 @@ class Meta: "cve", "cwe", "reference", + "trending", "exploit", ) read_only_fields = TriageFindingSerializer.Meta.read_only_fields + ( @@ -127,6 +128,7 @@ class Meta: "cve", "cwe", "reference", + "trending", "exploit", ) diff --git a/src/backend/findings/views.py b/src/backend/findings/views.py index 9809a2de6..00c47afd9 100644 --- a/src/backend/findings/views.py +++ b/src/backend/findings/views.py @@ -48,7 +48,7 @@ class OSINTViewSet(TriageFindingViewSet): ordering_fields = ["id", "data", "data_type", "source"] @extend_schema(request=None, responses={201: TargetSerializer}) - @action(detail=True, methods=["POST"], url_path="target", url_name="target") + @action(detail=True, methods=["POST"]) def target(self, request: Request, pk: str) -> Response: """Target creation from OSINT data. diff --git a/src/backend/framework/fields.py b/src/backend/framework/fields.py index 7248fbd9a..f802d0ce1 100644 --- a/src/backend/framework/fields.py +++ b/src/backend/framework/fields.py @@ -1,6 +1,6 @@ from typing import Any, Callable, Optional -from django.forms import ValidationError +from django.core.exceptions import ValidationError from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework.serializers import Field diff --git a/src/backend/framework/platforms.py b/src/backend/framework/platforms.py index 18cf155b5..a92569b43 100644 --- a/src/backend/framework/platforms.py +++ b/src/backend/framework/platforms.py @@ -3,6 +3,7 @@ from urllib.parse import urlparse import requests +from alerts.models import Alert from executions.models import Execution from findings.framework.models import Finding from integrations.models import Integration @@ -16,10 +17,6 @@ class BasePlatform: def is_available(self) -> bool: return True - def process_findings(self, execution: Execution, findings: List[Finding]) -> None: - if not self.is_available(): - return - class BaseIntegration(BasePlatform): url = "" @@ -54,10 +51,13 @@ def _request( def is_enabled(self) -> bool: return self.integration.enabled if self.integration else False + def _process_findings(self, execution: Execution, findings: List[Finding]) -> None: + pass + def process_findings(self, execution: Execution, findings: List[Finding]) -> None: if not self.is_enabled(): return - super().process_findings(execution, findings) + self._process_findings(execution, findings) class BaseNotification(BasePlatform): @@ -96,12 +96,26 @@ def _get_users_to_notify_execution(self, execution: Execution) -> List[Any]: ) return list(users) + def _get_users_to_notify_alert(self, alert: Alert) -> List[Any]: + return alert.suscribers.filter(**{self.enable_field: True}).all() + def _notify_execution( self, users: List[Any], execution: Execution, findings: List[Finding] ) -> None: pass + def _notify_alert(self, users: List[Any], alert: Alert, finding: Finding) -> None: + pass + def process_findings(self, execution: Execution, findings: List[Finding]) -> None: - super().process_findings(execution, findings) + if not self.is_available(): + return users = self._get_users_to_notify_execution(execution) self._notify_execution(users, execution, findings) + + def process_alert(self, alert: Alert, finding: Finding) -> None: + if not self.is_available(): + return + self._notify_alert( + alert.suscribers.filter(**{self.enable_field: True}).all(), alert, finding + ) diff --git a/src/backend/framework/views.py b/src/backend/framework/views.py index 9d837711e..cf1869494 100644 --- a/src/backend/framework/views.py +++ b/src/backend/framework/views.py @@ -101,8 +101,6 @@ def get_queryset(self) -> QuerySet: @action( detail=True, methods=["POST", "DELETE"], - url_path="like", - url_name="like", permission_classes=[IsAuthenticated, IsAuditor], ) def like(self, request: Request, pk: str) -> Response: diff --git a/src/backend/input_types/enums.py b/src/backend/input_types/enums.py index a608d5f23..c7c44199f 100644 --- a/src/backend/input_types/enums.py +++ b/src/backend/input_types/enums.py @@ -12,3 +12,4 @@ class InputTypeName(TextChoices): EXPLOIT = "Exploit" WORDLIST = "Wordlist" AUTHENTICATION = "Authentication" + HTTP_HEADER = "Http Header" diff --git a/src/backend/integrations/fixtures/1_integrations.json b/src/backend/integrations/fixtures/1_integrations.json index 93fad76f3..32e1d31c6 100644 --- a/src/backend/integrations/fixtures/1_integrations.json +++ b/src/backend/integrations/fixtures/1_integrations.json @@ -31,5 +31,16 @@ "enabled": true, "reference": "https://book.hacktricks.xyz/" } + }, + { + "model": "integrations.integration", + "pk": 4, + "fields": { + "key": "cvecrowd", + "name": "CVE Crowd", + "description": "Platform that identifies the most trending CVEs at the moment based on their impact on social networks, and whose API will be used by Rekono to check if the CVEs detected on the targets are trending or not", + "enabled": true, + "reference": "https://cvecrowd.com/" + } } ] \ No newline at end of file diff --git a/src/backend/notes/views.py b/src/backend/notes/views.py index fac130654..8ed244bae 100644 --- a/src/backend/notes/views.py +++ b/src/backend/notes/views.py @@ -70,8 +70,16 @@ def get_queryset(self) -> QuerySet: ) @extend_schema(request=None, responses={201: NoteSerializer}) - @action(detail=True, methods=["POST"], url_path="fork", url_name="fork") - def target(self, request: Request, pk: str) -> Response: + @action( + detail=True, + methods=["POST"], + permission_classes=[ + IsAuthenticated, + RekonoModelPermission, + ProjectMemberPermission, + ], + ) + def fork(self, request: Request, pk: str) -> Response: note = self.get_object() fork = Note.objects.create( project=note.project, diff --git a/src/backend/platforms/cvecrowd/__init__.py b/src/backend/platforms/cvecrowd/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/platforms/cvecrowd/admin.py b/src/backend/platforms/cvecrowd/admin.py new file mode 100644 index 000000000..d690a382b --- /dev/null +++ b/src/backend/platforms/cvecrowd/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from platforms.cvecrowd.models import CVECrowdSettings + +# Register your models here. + +admin.site.register(CVECrowdSettings) diff --git a/src/backend/platforms/cvecrowd/apps.py b/src/backend/platforms/cvecrowd/apps.py new file mode 100644 index 000000000..1cf552206 --- /dev/null +++ b/src/backend/platforms/cvecrowd/apps.py @@ -0,0 +1,16 @@ +from pathlib import Path +from typing import Any, List + +from django.apps import AppConfig +from framework.apps import BaseApp + + +class CvecrowdConfig(BaseApp, AppConfig): + name = "platforms.cvecrowd" + fixtures_path = Path(__file__).resolve().parent / "fixtures" + skip_if_model_exists = True + + def _get_models(self) -> List[Any]: + from platforms.cvecrowd.models import CVECrowdSettings + + return [CVECrowdSettings] diff --git a/src/backend/platforms/cvecrowd/fixtures/1_default.json b/src/backend/platforms/cvecrowd/fixtures/1_default.json new file mode 100644 index 000000000..a3659289a --- /dev/null +++ b/src/backend/platforms/cvecrowd/fixtures/1_default.json @@ -0,0 +1,11 @@ +[ + { + "model": "cvecrowd.cvecrowdsettings", + "pk": 1, + "fields": { + "_api_token": null, + "trending_span_days": 7, + "execute_per_execution": true + } + } +] \ No newline at end of file diff --git a/src/backend/platforms/cvecrowd/integrations.py b/src/backend/platforms/cvecrowd/integrations.py new file mode 100644 index 000000000..a16e1e8e5 --- /dev/null +++ b/src/backend/platforms/cvecrowd/integrations.py @@ -0,0 +1,99 @@ +import logging +from typing import List + +from alerts.enums import AlertItem, AlertMode +from alerts.models import Alert +from executions.models import Execution +from findings.enums import TriageStatus +from findings.framework.models import Finding +from findings.models import Vulnerability +from framework.platforms import BaseIntegration +from platforms.cvecrowd.models import CVECrowdSettings +from platforms.mail.notifications import SMTP +from platforms.telegram_app.notifications.notifications import Telegram + +logger = logging.getLogger() + + +class CVECrowd(BaseIntegration): + def __init__(self) -> None: + self.settings = CVECrowdSettings.objects.first() + self.url = "https://api.cvecrowd.com/api/v1/cves" + self.trending_cves: List[str] = [] + super().__init__() + + def is_available(self) -> bool: + if self.settings.secret: + self._get_trending_cves() + return len(self.trending_cves) > 0 + return False + + def _get_trending_cves(self) -> None: + if ( + self.integration.enabled + and self.settings.secret + and len(self.trending_cves) == 0 + ): + try: + self.trending_cves = self._request( + self.url, + headers={"Authorization": f"Bearer {self.settings.secret}"}, + params={"days": self.settings.trending_span_days}, + ) + except Exception: # nosec + pass + + def _process_findings(self, execution: Execution, findings: List[Finding]) -> None: + if not self.settings.execute_per_execution: + return + self._get_trending_cves() + if not self.trending_cves: + return + for finding in findings: + if ( + isinstance(finding, Vulnerability) + and finding.cve is not None + and finding.cve in self.trending_cves + ): + finding.trending = True + finding.save(update_fields=["trending"]) + + def monitor(self) -> None: + self._get_trending_cves() + if not self.trending_cves: + logger.warn("[CVE Crowd] No trending CVEs found") + return + already_trending_queryset = Vulnerability.objects.filter(trending=True).all() + already_trending_cves = list( + already_trending_queryset.values_list("cve", flat=True) + ) + already_trending_queryset.exclude(cve__in=self.trending_cves).update( + trending=False + ) + Vulnerability.objects.filter(trending=False, cve__in=self.trending_cves).update( + trending=True + ) + notified_vulnerabilities: List[int] = [] + for alert in Alert.objects.filter( + item=AlertItem.CVE, mode=AlertMode.MONITOR, enabled=True + ).all(): + vulnerabilities = ( + Vulnerability.objects.filter( + executions__task__target__project=alert.project, + cve__isnull=False, + is_fixed=False, + trending=True, + ) + .exclude(triage_status=TriageStatus.FALSE_POSITIVE) + .exclude(cve__in=already_trending_cves) + .exclude(id__in=notified_vulnerabilities) + .all() + ) + logger.info( + f"[CVE Crowd] New {vulnerabilities.count()} trending vulnerabilities found in project {alert.project.id}" + ) + for vulnerability in vulnerabilities: + if alert.must_be_triggered(None, vulnerability): + notified_vulnerabilities.append(vulnerability.id) + for platform in [SMTP, Telegram]: + platform().process_alert(alert, vulnerability) diff --git a/src/backend/platforms/cvecrowd/models.py b/src/backend/platforms/cvecrowd/models.py new file mode 100644 index 000000000..88bd44bbc --- /dev/null +++ b/src/backend/platforms/cvecrowd/models.py @@ -0,0 +1,25 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from framework.models import BaseEncrypted +from security.validators.input_validator import Regex, Validator + +# Create your models here. + + +class CVECrowdSettings(BaseEncrypted): + _api_token = models.TextField( + max_length=50, + validators=[Validator(Regex.SECRET.value, code="api_token")], + null=True, + blank=True, + db_column="api_token", + ) + trending_span_days = models.IntegerField( + default=7, validators=[MinValueValidator(1), MaxValueValidator(7)] + ) + execute_per_execution = models.BooleanField(default=True) + + _encrypted_field = "_api_token" + + def __str__(self) -> str: + return "CVE Crowd" diff --git a/src/backend/platforms/cvecrowd/serializers.py b/src/backend/platforms/cvecrowd/serializers.py new file mode 100644 index 000000000..cd081a759 --- /dev/null +++ b/src/backend/platforms/cvecrowd/serializers.py @@ -0,0 +1,28 @@ +from framework.fields import ProtectedSecretField +from platforms.cvecrowd.integrations import CVECrowd +from platforms.cvecrowd.models import CVECrowdSettings +from rest_framework.serializers import ModelSerializer, SerializerMethodField +from security.validators.input_validator import Regex, Validator + + +class CVECrowdSettingsSerializer(ModelSerializer): + api_token = ProtectedSecretField( + validators=[Validator(Regex.SECRET.value, code="api_token")], + required=False, + allow_null=True, + source="secret", + ) + is_available = SerializerMethodField(read_only=True) + + class Meta: + model = CVECrowdSettings + fields = ( + "id", + "trending_span_days", + "execute_per_execution", + "api_token", + "is_available", + ) + + def get_is_available(self, instance: CVECrowdSettings) -> bool: + return CVECrowd().is_available() diff --git a/src/backend/platforms/cvecrowd/urls.py b/src/backend/platforms/cvecrowd/urls.py new file mode 100644 index 000000000..affa8e267 --- /dev/null +++ b/src/backend/platforms/cvecrowd/urls.py @@ -0,0 +1,9 @@ +from platforms.cvecrowd.views import CVECrowdSettingsViewSet +from rest_framework.routers import SimpleRouter + +# Register your views here. + +router = SimpleRouter() +router.register("cvecrowd", CVECrowdSettingsViewSet) + +urlpatterns = router.urls diff --git a/src/backend/platforms/cvecrowd/views.py b/src/backend/platforms/cvecrowd/views.py new file mode 100644 index 000000000..95e5a607e --- /dev/null +++ b/src/backend/platforms/cvecrowd/views.py @@ -0,0 +1,12 @@ +from framework.views import BaseViewSet +from platforms.cvecrowd.models import CVECrowdSettings +from platforms.cvecrowd.serializers import CVECrowdSettingsSerializer +from rest_framework.permissions import IsAuthenticated +from security.authorization.permissions import RekonoModelPermission + + +class CVECrowdSettingsViewSet(BaseViewSet): + queryset = CVECrowdSettings.objects.all() + serializer_class = CVECrowdSettingsSerializer + permission_classes = [IsAuthenticated, RekonoModelPermission] + http_method_names = ["get", "put"] diff --git a/src/backend/platforms/defect_dojo/integrations.py b/src/backend/platforms/defect_dojo/integrations.py index 3204b8c5a..2e40a20d3 100644 --- a/src/backend/platforms/defect_dojo/integrations.py +++ b/src/backend/platforms/defect_dojo/integrations.py @@ -4,8 +4,6 @@ import requests from django.utils import timezone -from requests.exceptions import HTTPError - from executions.models import Execution from findings.enums import PathType, Severity from findings.framework.models import Finding @@ -16,6 +14,7 @@ DefectDojoSync, DefectDojoTargetSync, ) +from requests.exceptions import HTTPError from targets.models import Target @@ -174,8 +173,7 @@ def _import_scan( files={"file": report}, ) - def process_findings(self, execution: Execution, findings: List[Finding]) -> None: - super().process_findings(execution, findings) + def _process_findings(self, execution: Execution, findings: List[Finding]) -> None: target_sync = DefectDojoTargetSync.objects.filter(target=execution.task.target) if target_sync.exists(): sync = target_sync.first() diff --git a/src/backend/platforms/defect_dojo/serializers.py b/src/backend/platforms/defect_dojo/serializers.py index 23b55e807..2d2a0517e 100644 --- a/src/backend/platforms/defect_dojo/serializers.py +++ b/src/backend/platforms/defect_dojo/serializers.py @@ -1,17 +1,8 @@ from typing import Any, Dict, cast +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator -from django.forms import ValidationError from django.shortcuts import get_object_or_404 -from rest_framework.serializers import ( - CharField, - IntegerField, - ModelSerializer, - PrimaryKeyRelatedField, - Serializer, - SerializerMethodField, -) - from framework.fields import ProtectedSecretField from platforms.defect_dojo.integrations import DefectDojo from platforms.defect_dojo.models import ( @@ -20,6 +11,14 @@ DefectDojoTargetSync, ) from projects.models import Project +from rest_framework.serializers import ( + CharField, + IntegerField, + ModelSerializer, + PrimaryKeyRelatedField, + Serializer, + SerializerMethodField, +) from security.validators.input_validator import Regex, Validator diff --git a/src/backend/platforms/hacktricks.py b/src/backend/platforms/hacktricks.py index 686788b96..408da3565 100644 --- a/src/backend/platforms/hacktricks.py +++ b/src/backend/platforms/hacktricks.py @@ -1,7 +1,6 @@ from typing import List, Optional import defusedxml.ElementTree as parser - from executions.models import Execution from findings.enums import HostOS from findings.framework.models import Finding @@ -95,10 +94,9 @@ def _get_mapped_value_for_service(self, service: str) -> Optional[str]: for mapped_value, services in self.services_mapping.items(): if service in services: return mapped_value - return None # TOTEST + return None - def process_findings(self, execution: Execution, findings: List[Finding]) -> None: - super().process_findings(execution, findings) + def _process_findings(self, execution: Execution, findings: List[Finding]) -> None: for finding in findings: hacktricks_link = None if isinstance(finding, Host) and finding.os_type in self.host_type_mapping: @@ -108,7 +106,7 @@ def process_findings(self, execution: Execution, findings: List[Finding]) -> Non mapped_value = self._get_mapped_value_for_service(service_comparator) if self.url in (mapped_value or ""): hacktricks_link = mapped_value - elif mapped_value: # TOTEST + elif mapped_value: service_comparator = mapped_value if not hacktricks_link: for link in self.all_links: diff --git a/src/backend/platforms/mail/notifications.py b/src/backend/platforms/mail/notifications.py index 095a9cff7..f791195b3 100644 --- a/src/backend/platforms/mail/notifications.py +++ b/src/backend/platforms/mail/notifications.py @@ -4,6 +4,8 @@ from typing import Any, Dict, List import certifi +from alerts.enums import AlertMode +from alerts.models import Alert from django.core.mail import EmailMultiAlternatives from django.core.mail.backends.smtp import EmailBackend from django.template.loader import get_template @@ -95,7 +97,7 @@ def _notify_execution( if findings.__class__.__name__.lower() not in findings_by_class: findings_by_class[findings.__class__.__name__.lower()] = [] findings_by_class[findings.__class__.__name__.lower()].append(finding) - self._notify_if_available( + self._notify( users, f"[Rekono] {execution.configuration.tool.name} execution completed", "execution_notification.html", @@ -106,6 +108,20 @@ def _notify_execution( background=False, ) + def _notify_alert(self, users: List[Any], alert: Alert, finding: Finding) -> None: + subjects = { + AlertMode.NEW: f"New {finding.__class__.__name__.lower()} detected", + AlertMode.FILTER.value: f"New {finding.__class__.__name__.lower()} matches alert criteria", + AlertMode.MONITOR.value: "New trending CVE", + } + self._notify( + users, + f"[Rekono] {subjects[alert.mode]}", + "alert_notification.html", + {"alert": alert, "finding": finding}, + background=False, + ) + def invite_user(self, user: Any, otp: str) -> None: self._notify_if_available( [user], diff --git a/src/backend/platforms/mail/templates/alert_notification.html b/src/backend/platforms/mail/templates/alert_notification.html new file mode 100644 index 000000000..7ac6c9e05 --- /dev/null +++ b/src/backend/platforms/mail/templates/alert_notification.html @@ -0,0 +1,196 @@ + + + + + + + Rekono + + +
+
+ Rekono +
+
+

{{ finding.__class__.__name__ }}

+
+
+
+ {% if finding.__class__.__name__ == "OSINT" %} +
+
+

Data

+

{{ finding.data }}

+
+
+

Data Type

+

{{ finding.data_type }}

+
+
+

Source

+

{{ finding.source }}

+
+
+ {% endif %} + + {% if finding.__class__.__name__ == "Host" %} +
+
+

Address

+

{{ finding.address }}

+
+
+

OS

+

{{ finding.os }}

+
+
+

OS Type

+

{{ finding.os_type }}

+
+
+ {% endif %} + + {% if finding.__class__.__name__ == "Port" %} +
+
+

Host

+ {% if finding.host %} +

{{ finding.host.address }}

+ {% endif %} +
+
+

Port

+

{{ finding.port }}

+
+
+

Status

+

{{ finding.status }}

+
+
+

Protocol

+

{{ finding.protocol }}

+
+
+

Service

+

{{ finding.service }}

+
+
+ {% endif %} + + {% if finding.__class__.__name__ == "Technology" %} +
+
+

Host

+ {% if finding.port and finding.port.host %} +

{{ finding.port.host.address }}

+ {% endif %} +
+
+

Port

+ {% if finding.port %} +

{{ finding.port.port }}

+ {% endif %} +
+
+

Technology

+

{{ finding.name }}

+
+
+

Technology

+

{{ finding.version }}

+
+
+

Reference

+ {% if finding.reference %} + + Link + {% endif %} +
+
+ {% endif %} + + {% if finding.__class__.__name__ == "Credential" %} +
+
+

Technology

+ {% if finding.technology %} +

{{ finding.technology.name }}

+ {% endif %} +
+
+

Email

+

{{ finding.email }}

+
+
+

Username

+

{{ finding.username }}

+
+
+

Secret

+

{{ finding.secret }}

+
+
+

Context

+

{{ finding.context }}

+
+
+ {% endif %} + + {% if finding.__class__.__name__ == "Vulnerability" %} +
+
+

Host

+ {% if finding.port and finding.port.host %} +

{{ finding.port.host.address }}

+ {% endif %} +
+
+

Port

+ {% if finding.port %} +

{{ finding.port.port }}

+ {% endif %} +
+
+

Technology

+ {% if finding.technology %} +

{{ finding.technology.name }}

+ {% endif %} +
+
+

Name

+

{{ finding.name }}

+
+
+

Description

+

{{ finding.description }}

+
+
+

Severity

+

{{ finding.severity }}

+
+
+

CVE

+

{{ finding.cve }}

+
+
+

CWE

+

{{ finding.cwe }}

+
+
+

Reference

+ {% if finding.reference %} + + Link + {% endif %} +
+
+ {% endif %} +
+
+
+ + Review all details +
+
+ + \ No newline at end of file diff --git a/src/backend/platforms/nvd_nist.py b/src/backend/platforms/nvdnist.py similarity index 96% rename from src/backend/platforms/nvd_nist.py rename to src/backend/platforms/nvdnist.py index 747dfaef7..5fed6b748 100644 --- a/src/backend/platforms/nvd_nist.py +++ b/src/backend/platforms/nvdnist.py @@ -20,8 +20,7 @@ def __init__(self) -> None: Severity.INFO: (0, 2), } - def process_findings(self, execution: Execution, findings: List[Finding]) -> None: - super().process_findings(execution, findings) + def _process_findings(self, execution: Execution, findings: List[Finding]) -> None: for finding in findings: if isinstance(finding, Vulnerability) and finding.cve: try: diff --git a/src/backend/platforms/telegram_app/notifications/notifications.py b/src/backend/platforms/telegram_app/notifications/notifications.py index 148b6e1a1..8d41f7395 100644 --- a/src/backend/platforms/telegram_app/notifications/notifications.py +++ b/src/backend/platforms/telegram_app/notifications/notifications.py @@ -1,11 +1,17 @@ from typing import Any, Dict, List +from alerts.models import Alert from django.forms.models import model_to_dict from executions.models import Execution from findings.framework.models import Finding from framework.platforms import BaseNotification from platforms.telegram_app.framework import BaseTelegram -from platforms.telegram_app.notifications.templates import EXECUTION, FINDINGS, HEADER +from platforms.telegram_app.notifications.templates import ( + ALERTS, + EXECUTION, + FINDINGS, + HEADER, +) from rekono.settings import CONFIG from users.models import User @@ -61,7 +67,28 @@ def _notify_execution( ] ), ) - self._notify_if_available(users, message) + self._notify(users, message) + + def _notify_alert(self, users: List[User], alert: Alert, finding: Finding) -> None: + self._notify( + users, + HEADER.format( + icon=FINDINGS[finding.__class__].get("icon", ""), + title=ALERTS.get(alert.mode, "").format( + finding=finding.__class__.__name__.lower() + ), + details=FINDINGS[finding.__class__] + .get("template", "") + .format( + **{ + k: self._escape( + str(v) if not isinstance(v, Finding) else v.__str__() + ) + for k, v in model_to_dict(finding).items() + } + ), + ), + ) def welcome_message(self, user: User) -> None: self._notify_if_available( diff --git a/src/backend/platforms/telegram_app/notifications/templates.py b/src/backend/platforms/telegram_app/notifications/templates.py index fdf5e72b7..6fbc906cb 100644 --- a/src/backend/platforms/telegram_app/notifications/templates.py +++ b/src/backend/platforms/telegram_app/notifications/templates.py @@ -1,3 +1,4 @@ +from alerts.enums import AlertMode from findings.models import ( OSINT, Credential, @@ -55,7 +56,7 @@ _Port_ *{port}* _Status_ {status} _Protocol_ {protocol} -_Service_ {service} +_Service_ *{service}* """, }, Path: { @@ -107,3 +108,9 @@ """, }, } + +ALERTS = { + AlertMode.NEW.value: "[ALERT] New {finding} detected", + AlertMode.FILTER.value: "[ALERT] New {finding} matches the criteria", + AlertMode.MONITOR.value: "[ALERT] New trending CVE 🔥", +} diff --git a/src/backend/projects/serializers.py b/src/backend/projects/serializers.py index 735538e5b..0f45996f4 100644 --- a/src/backend/projects/serializers.py +++ b/src/backend/projects/serializers.py @@ -1,6 +1,8 @@ import logging from typing import Any, Dict +from alerts.enums import AlertItem, AlertMode +from alerts.models import Alert from django.db import transaction from django.shortcuts import get_object_or_404 from framework.fields import TagField @@ -61,6 +63,14 @@ def create(self, validated_data: Dict[str, Any]) -> Project: project = super().create(validated_data) # Create project # Add project owner also in member list project.members.add(validated_data.get("owner")) + alert = Alert.objects.create( + project=project, + item=AlertItem.CVE, + mode=AlertMode.MONITOR, + enabled=True, + owner=validated_data.get("owner"), + ) + alert.suscribers.add(validated_data.get("owner")) return project @@ -79,7 +89,10 @@ def update(self, instance: Project, validated_data: Dict[str, Any]) -> Project: Returns: Project: Updated instance """ - instance.members.add( - get_object_or_404(User, pk=validated_data.get("user"), is_active=True) - ) + user = get_object_or_404(User, pk=validated_data.get("user"), is_active=True) + instance.members.add(user) + for alert in Alert.objects.filter( + project=instance, suscribe_all_members=True, enabled=True + ).all(): + alert.suscribers.add(user) return instance diff --git a/src/backend/projects/views.py b/src/backend/projects/views.py index 8f27028cc..4be834bb8 100644 --- a/src/backend/projects/views.py +++ b/src/backend/projects/views.py @@ -1,15 +1,14 @@ from drf_spectacular.utils import extend_schema +from framework.views import BaseViewSet +from projects.filters import ProjectFilter +from projects.models import Project +from projects.serializers import ProjectMemberSerializer, ProjectSerializer from rest_framework import status from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response - -from framework.views import BaseViewSet -from projects.filters import ProjectFilter -from projects.models import Project -from projects.serializers import ProjectMemberSerializer, ProjectSerializer from security.authorization.permissions import ( ProjectMemberPermission, RekonoModelPermission, @@ -33,8 +32,8 @@ class ProjectViewSet(BaseViewSet): ordering_fields = ["id", "name"] @extend_schema(request=ProjectMemberSerializer, responses={201: None}) - @action(detail=True, methods=["POST"], url_path="members", url_name="members") - def add_member(self, request: Request, pk: str) -> Response: + @action(detail=True, methods=["POST"]) + def members(self, request: Request, pk: str) -> Response: """Add user to the project members. Args: @@ -72,6 +71,8 @@ def remove_member(self, request: Request, member_id: str, pk: str) -> Response: if int(member_id) != project.owner.id: # Member found and it isn't the project owner project.members.remove(member) # Remove project member + for alert in project.alerts.filter(suscribers=member).all(): + alert.suscribers.remove(member) return Response(status=status.HTTP_204_NO_CONTENT) return Response( {"user": ["The project owner can't be removed"]}, diff --git a/src/backend/rekono/settings.py b/src/backend/rekono/settings.py index e68859c99..04a02f873 100644 --- a/src/backend/rekono/settings.py +++ b/src/backend/rekono/settings.py @@ -51,6 +51,7 @@ "rest_framework", "rest_framework_simplejwt.token_blacklist", "taggit", + "alerts", "api_tokens", "authentications", "executions", @@ -59,6 +60,7 @@ "input_types", "integrations", "notes", + "platforms.cvecrowd", "platforms.defect_dojo", "platforms.mail", "platforms.telegram_app", @@ -159,7 +161,7 @@ "ISSUER": "Rekono", } -LOGGING = { +LOGGING: Dict[str, Any] = { "version": 1, # Disable default Django logging system to avoid noise "disable_existing_loggers": False, @@ -307,6 +309,7 @@ "tasks": default_rq_queue, "executions": default_rq_queue, "findings": default_rq_queue, + "monitor": default_rq_queue, } RQ_QUEUES["executions"]["DEFAULT_TIMEOUT"] = 28800 # 8 hours diff --git a/src/backend/rekono/urls.py b/src/backend/rekono/urls.py index ec379188c..bd8bd6ac6 100644 --- a/src/backend/rekono/urls.py +++ b/src/backend/rekono/urls.py @@ -27,6 +27,7 @@ urlpatterns = [ path("admin/", admin.site.urls), + path("api/", include("alerts.urls")), path("api/", include("api_tokens.urls")), path("api/", include("authentications.urls")), path("api/", include("executions.urls")), @@ -35,6 +36,7 @@ path("api/", include("integrations.urls")), path("api/", include("notes.urls")), path("api/", include("parameters.urls")), + path("api/", include("platforms.cvecrowd.urls")), path("api/", include("platforms.defect_dojo.urls")), path("api/", include("platforms.mail.urls")), path("api/", include("platforms.telegram_app.urls")), diff --git a/src/backend/rekono/views.py b/src/backend/rekono/views.py index 0dc25a7f0..f2ae09888 100644 --- a/src/backend/rekono/views.py +++ b/src/backend/rekono/views.py @@ -28,7 +28,7 @@ class RQStatsView(APIView): name="RQStats", fields={ "executions": inline_serializer( - name="ExecutionStats", + name="ExecutionsStats", fields={k: serializers.IntegerField() for k in exposed_fields}, ), "findings": inline_serializer( diff --git a/src/backend/reporting/views.py b/src/backend/reporting/views.py index d1d4ec9ab..fa0bb40eb 100644 --- a/src/backend/reporting/views.py +++ b/src/backend/reporting/views.py @@ -143,7 +143,7 @@ def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: 404: None, }, ) - @action(detail=True, methods=["GET"], url_path="download", url_name="download") + @action(detail=True, methods=["GET"]) def download(self, request: Request, pk: str) -> FileResponse: report = self.get_object() if report.status != ReportStatus.READY: diff --git a/src/backend/security/authorization/permissions.py b/src/backend/security/authorization/permissions.py index 8751f5a0a..5b84a5343 100644 --- a/src/backend/security/authorization/permissions.py +++ b/src/backend/security/authorization/permissions.py @@ -1,5 +1,6 @@ -from typing import Any +from typing import Any, Dict +from alerts.models import Alert from notes.models import Note from platforms.telegram_app.models import TelegramChat from processes.models import Process, Step @@ -90,6 +91,24 @@ def has_object_permission(self, request: Request, view: View, obj: Any) -> bool: class OwnerPermission(BasePermission): """Check if current user can access an object based on HTTP method and creator user.""" + # By default: instance returns the same object, allow_admin is True and owner_field is owner + mapping: Dict[Any, Dict[str, Any]] = { + Wordlist: {}, + Process: {}, + Step: { + "instance": lambda o: o.process, + }, + Note: { + "allow_admin": False, + }, + Alert: {}, + TelegramChat: { + "owner_field": "user", + "allow_admin": False, + }, + Report: {"owner_field": "user"}, + } + def _has_object_permission( self, request: Request, @@ -103,11 +122,6 @@ def _has_object_permission( return ( not instance or request.method == "GET" - or ( - request.method == "POST" - and "/fork/" in request.path - and isinstance(instance, Note) - ) or ( hasattr(instance, owner_field) and getattr(instance, owner_field) == request.user @@ -139,15 +153,10 @@ def has_object_permission(self, request: Request, view: View, obj: Any) -> bool: Returns: bool: Indicate if user is authorized to make this request or not """ - instance = None - owner_field = "" - allow_admin = not isinstance(obj, Note) and not isinstance(obj, TelegramChat) - if obj.__class__ in [Wordlist, Process, Step, Note]: - instance = obj.process if obj.__class__ == Step else obj - owner_field = "owner" - elif obj.__class__ in [TelegramChat, Report]: - instance = obj - owner_field = "user" return self._has_object_permission( - request, view, instance, owner_field, allow_admin + request, + view, + self.mapping[obj.__class__].get("instance", lambda o: o)(obj), + self.mapping[obj.__class__].get("owner_field", "owner"), + self.mapping[obj.__class__].get("allow_admin", True), ) diff --git a/src/backend/security/authorization/roles.py b/src/backend/security/authorization/roles.py index bc8c14a2c..24abd0b21 100644 --- a/src/backend/security/authorization/roles.py +++ b/src/backend/security/authorization/roles.py @@ -244,4 +244,22 @@ class Role(models.TextChoices): "change": [Role.ADMIN, Role.AUDITOR], "delete": [Role.ADMIN, Role.AUDITOR], }, + "alert": { + "view": [Role.ADMIN, Role.AUDITOR, Role.READER], + "add": [Role.ADMIN, Role.AUDITOR, Role.READER], + "change": [Role.ADMIN, Role.AUDITOR, Role.READER], + "delete": [Role.ADMIN, Role.AUDITOR, Role.READER], + }, + "cvecrowdsettings": { + "view": [Role.ADMIN], + "add": [], + "change": [Role.ADMIN], + "delete": [], + }, + "monitorsettings": { + "view": [Role.ADMIN], + "add": [], + "change": [Role.ADMIN], + "delete": [], + }, } diff --git a/src/backend/security/validators/input_validator.py b/src/backend/security/validators/input_validator.py index 738a60efd..091c07d50 100644 --- a/src/backend/security/validators/input_validator.py +++ b/src/backend/security/validators/input_validator.py @@ -4,8 +4,8 @@ from re import RegexFlag from typing import Any +from django.core.exceptions import ValidationError from django.core.validators import RegexValidator -from django.forms import ValidationError from django.utils import timezone logger = logging.getLogger() diff --git a/src/backend/security/validators/target_validator.py b/src/backend/security/validators/target_validator.py index fafe51e2b..e052fca09 100644 --- a/src/backend/security/validators/target_validator.py +++ b/src/backend/security/validators/target_validator.py @@ -3,9 +3,8 @@ from re import RegexFlag from typing import Any +from django.core.exceptions import ValidationError from django.core.validators import RegexValidator -from django.forms import ValidationError - from target_blacklist.models import TargetBlacklist diff --git a/src/backend/tasks/views.py b/src/backend/tasks/views.py index 455da25a9..edf15bc31 100644 --- a/src/backend/tasks/views.py +++ b/src/backend/tasks/views.py @@ -100,8 +100,8 @@ def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: ) @extend_schema(request=None, responses={200: TaskSerializer}) - @action(detail=True, methods=["POST"], url_path="repeat", url_name="repeat") - def repeat_task(self, request: Request, pk: str) -> Response: + @action(detail=True, methods=["POST"]) + def repeat(self, request: Request, pk: str) -> Response: """Repeat task execution. Args: diff --git a/src/backend/tests/platforms/test_cvecrowd.py b/src/backend/tests/platforms/test_cvecrowd.py new file mode 100644 index 000000000..d65918d86 --- /dev/null +++ b/src/backend/tests/platforms/test_cvecrowd.py @@ -0,0 +1,148 @@ +from typing import Any, List +from unittest import mock + +from alerts.enums import AlertItem, AlertMode +from alerts.models import Alert +from findings.enums import Severity +from findings.models import Vulnerability +from platforms.cvecrowd.integrations import CVECrowd +from platforms.cvecrowd.models import CVECrowdSettings +from tests.cases import ApiTestCase +from tests.framework import ApiTest, RekonoTest + + +def success(*args: Any, **kwargs: Any) -> List[str]: + return ["CVE-2020-1111", "CVE-2021-1112", "CVE-2022-1113"] + + +def not_found(*args: Any, **kwargs: Any) -> List[str]: + return [] + + +class CVECrowdTest(RekonoTest): + def setUp(self) -> None: + super().setUp() + self._setup_tasks_and_executions() + self.not_trending = Vulnerability.objects.create( + name="not trending", + description="not trending", + cve="CVE-2023-9999", + severity=Severity.LOW, + ) + self.trending = Vulnerability.objects.create( + name="trending", + description="trending", + cve="CVE-2022-1113", + severity=Severity.HIGH, + ) + self.not_trending.executions.add(self.execution3) + self.trending.executions.add(self.execution3) + self.settings = CVECrowdSettings.objects.first() + self.settings.secret = "fake-token" + self.settings.save(update_fields=["_api_token"]) + Alert.objects.create( + project=self.execution3.task.target.project, + item=AlertItem.CVE, + mode=AlertMode.MONITOR, + enabled=True, + ) + self.cvecrowd = CVECrowd() + + def _verify_success(self) -> None: + self.assertTrue(Vulnerability.objects.get(pk=self.trending.id).trending) + self.assertFalse(Vulnerability.objects.get(pk=self.not_trending.id).trending) + + def _verify_error(self) -> None: + self.assertFalse(Vulnerability.objects.get(pk=self.trending.id).trending) + self.assertFalse(Vulnerability.objects.get(pk=self.not_trending.id).trending) + + @mock.patch("platforms.cvecrowd.integrations.CVECrowd._request", success) + def test_process_findings(self) -> None: + self.cvecrowd.process_findings( + self.execution3, [self.trending, self.not_trending] + ) + self._verify_success() + + @mock.patch("platforms.cvecrowd.integrations.CVECrowd._request", not_found) + def test_process_findings_not_found(self) -> None: + self.cvecrowd.process_findings( + self.execution3, [self.trending, self.not_trending] + ) + self._verify_error() + + @mock.patch("platforms.cvecrowd.integrations.CVECrowd._request", success) + def test_process_findings_not_enabled(self) -> None: + self.settings.execute_per_execution = False + self.settings.save(update_fields=["execute_per_execution"]) + self.cvecrowd = CVECrowd() + self.cvecrowd.process_findings( + self.execution3, [self.trending, self.not_trending] + ) + self._verify_error() + + @mock.patch("platforms.cvecrowd.integrations.CVECrowd._request", success) + def test_monitor(self) -> None: + self.cvecrowd.monitor() + self._verify_success() + + @mock.patch("platforms.cvecrowd.integrations.CVECrowd._request", not_found) + def test_monitor_not_found(self) -> None: + self.cvecrowd.monitor() + self._verify_error() + + +new_settings = { + "api_token": "cve-crowd-token", + "trending_span_days": 3, + "execute_per_execution": False, +} +invalid_settings = {**new_settings, "trending_span_days": 10} + + +class CVECrowdSettingsTest(ApiTest): + endpoint = "/api/cvecrowd/1/" + expected_str = "CVE Crowd" + cases = [ + ApiTestCase(["auditor1", "auditor2", "reader1", "reader2"], "get", 403), + ApiTestCase( + ["admin1", "admin2"], + "get", + 200, + expected={ + "id": 1, + "api_token": None, + "trending_span_days": 7, + "execute_per_execution": True, + }, + ), + ApiTestCase( + ["auditor1", "auditor2", "reader1", "reader2"], "put", 403, new_settings + ), + ApiTestCase(["admin1", "admin2"], "put", 400, invalid_settings), + ApiTestCase( + ["admin1", "admin2"], + "put", + 200, + new_settings, + expected={ + "id": 1, + **new_settings, + "api_token": "*" * len(str(new_settings.get("api_token", ""))), + "is_available": False, + }, + ), + ApiTestCase( + ["admin1", "admin2"], + "get", + 200, + expected={ + "id": 1, + **new_settings, + "api_token": "*" * len(str(new_settings.get("api_token", ""))), + "is_available": False, + }, + ), + ] + + def _get_object(self) -> Any: + return CVECrowdSettings.objects.first() diff --git a/src/backend/tests/platforms/test_nvd_nist.py b/src/backend/tests/platforms/test_nvdnist.py similarity index 82% rename from src/backend/tests/platforms/test_nvd_nist.py rename to src/backend/tests/platforms/test_nvdnist.py index 366978a53..6ae221ab3 100644 --- a/src/backend/tests/platforms/test_nvd_nist.py +++ b/src/backend/tests/platforms/test_nvdnist.py @@ -3,7 +3,7 @@ from findings.enums import Severity from findings.models import Vulnerability -from platforms.nvd_nist import NvdNist +from platforms.nvdnist import NvdNist from tests.framework import RekonoTest success = { @@ -57,7 +57,7 @@ def setUp(self) -> None: name="test", description="test", cve="CVE-2023-1111", severity=Severity.LOW ) self.vulnerability.executions.add(self.execution3) - self.nvd_nist = NvdNist() + self.nvdnist = NvdNist() def _test( self, @@ -66,25 +66,25 @@ def _test( cwe: Optional[str] = "CWE-200", description: str = "description", ) -> None: - self.nvd_nist.process_findings(self.execution3, [self.vulnerability]) + self.nvdnist.process_findings(self.execution3, [self.vulnerability]) self.assertEqual(reference, self.vulnerability.reference) self.assertEqual(cwe, self.vulnerability.cwe) self.assertEqual(description, self.vulnerability.description) self.assertEqual(severity, self.vulnerability.severity) - @mock.patch("platforms.nvd_nist.NvdNist._request", success_cvss_3) + @mock.patch("platforms.nvdnist.NvdNist._request", success_cvss_3) def test_integration_cvss_3(self) -> None: self._test( Severity.CRITICAL, - self.nvd_nist.reference.format(cve=self.vulnerability.cve), + self.nvdnist.reference.format(cve=self.vulnerability.cve), ) - @mock.patch("platforms.nvd_nist.NvdNist._request", success_cvss_2) + @mock.patch("platforms.nvdnist.NvdNist._request", success_cvss_2) def test_integration_cvss_2(self) -> None: self._test( - Severity.HIGH, self.nvd_nist.reference.format(cve=self.vulnerability.cve) + Severity.HIGH, self.nvdnist.reference.format(cve=self.vulnerability.cve) ) - @mock.patch("platforms.nvd_nist.NvdNist._request", not_found) + @mock.patch("platforms.nvdnist.NvdNist._request", not_found) def test_integration_not_found(self) -> None: self._test(Severity.LOW, None, None, "test") diff --git a/src/backend/tests/test_alerts.py b/src/backend/tests/test_alerts.py new file mode 100644 index 000000000..bd47f93e1 --- /dev/null +++ b/src/backend/tests/test_alerts.py @@ -0,0 +1,403 @@ +from typing import Any + +from alerts.enums import AlertItem, AlertMode +from alerts.models import Alert, MonitorSettings +from tests.cases import ApiTestCase +from tests.framework import ApiTest + +new_alert = { + "project": 1, + "item": AlertItem.HOST.value, + "mode": AlertMode.NEW.value, + "suscribe_all_members": True, +} +filter_alert = { + "project": 1, + "item": AlertItem.SERVICE.value, + "mode": AlertMode.FILTER.value, + "value": "ssh", + "suscribe_all_members": False, +} +invalid_filter_alert = {**filter_alert, "value": None} +monitor_alert = { + "project": 1, + "item": AlertItem.CVE.value, + "mode": AlertMode.MONITOR.value, + "suscribe_all_members": False, +} + + +class AlertTest(ApiTest): + endpoint = "/api/alerts/" + expected_str = "test - Filter - CVE - CVE-2020-1111" + cases = [ + ApiTestCase( + ["admin1", "admin2", "auditor1", "auditor2", "reader1", "reader2"], + "get", + 200, + expected=[], + ), + ApiTestCase(["admin2", "auditor2", "reader2"], "post", 403, new_alert), + ApiTestCase( + ["admin1"], + "post", + 201, + new_alert, + { + "id": 1, + **new_alert, + "suscribe_all_members": None, + "suscribed": True, + "enabled": True, + "owner": {"id": 1, "username": "admin1"}, + }, + ), + ApiTestCase( + ["admin1", "auditor1", "reader1"], + "get", + 200, + expected=[ + { + "id": 1, + **new_alert, + "suscribe_all_members": None, + "suscribed": True, + "enabled": True, + "owner": {"id": 1, "username": "admin1"}, + } + ], + ), + ApiTestCase(["admin2", "auditor2", "reader2"], "get", 200, expected=[]), + ApiTestCase( + ["auditor1", "reader1"], "post", 403, endpoint="{endpoint}1/enable/" + ), + ApiTestCase(["admin1"], "post", 400, endpoint="{endpoint}1/enable/"), + ApiTestCase( + ["admin1"], + "delete", + 200, + expected={ + "id": 1, + **new_alert, + "suscribe_all_members": None, + "suscribed": True, + "enabled": False, + "owner": {"id": 1, "username": "admin1"}, + }, + endpoint="{endpoint}1/enable/", + ), + ApiTestCase(["admin1"], "delete", 400, endpoint="{endpoint}1/enable/"), + ApiTestCase( + ["admin1"], + "post", + 200, + expected={ + "id": 1, + **new_alert, + "suscribe_all_members": None, + "suscribed": True, + "enabled": True, + "owner": {"id": 1, "username": "admin1"}, + }, + endpoint="{endpoint}1/enable/", + ), + ApiTestCase( + ["admin1", "auditor1", "reader1"], + "get", + 200, + expected=[ + { + "id": 1, + **new_alert, + "suscribe_all_members": None, + "suscribed": True, + "enabled": True, + "owner": {"id": 1, "username": "admin1"}, + } + ], + ), + ApiTestCase(["auditor1", "reader1"], "delete", 403, endpoint="{endpoint}1/"), + ApiTestCase(["admin1"], "delete", 204, endpoint="{endpoint}1/"), + ApiTestCase( + ["admin1", "auditor1", "reader1"], "post", 400, invalid_filter_alert + ), + ApiTestCase( + ["auditor1"], + "post", + 201, + filter_alert, + { + "id": 2, + **filter_alert, + "suscribe_all_members": None, + "suscribed": True, + "enabled": True, + "owner": {"id": 3, "username": "auditor1"}, + }, + ), + ApiTestCase( + ["admin1", "reader1"], + "get", + 200, + expected=[ + { + "id": 2, + **filter_alert, + "suscribe_all_members": None, + "suscribed": False, + "enabled": True, + "owner": {"id": 3, "username": "auditor1"}, + }, + ], + ), + ApiTestCase( + ["auditor1"], + "get", + 200, + expected=[ + { + "id": 2, + **filter_alert, + "suscribe_all_members": None, + "suscribed": True, + "enabled": True, + "owner": {"id": 3, "username": "auditor1"}, + }, + ], + ), + ApiTestCase( + ["admin1", "reader1"], "post", 204, endpoint="{endpoint}2/suscription/" + ), + ApiTestCase( + ["admin1", "reader1"], "post", 400, endpoint="{endpoint}2/suscription/" + ), + ApiTestCase( + ["admin1", "auditor1", "reader1"], + "get", + 200, + expected=[ + { + "id": 2, + **filter_alert, + "suscribe_all_members": None, + "suscribed": True, + "enabled": True, + "owner": {"id": 3, "username": "auditor1"}, + }, + ], + ), + ApiTestCase( + ["admin1", "auditor1", "reader1"], + "delete", + 204, + endpoint="{endpoint}2/suscription/", + ), + ApiTestCase( + ["admin1", "auditor1", "reader1"], + "delete", + 400, + endpoint="{endpoint}2/suscription/", + ), + ApiTestCase( + ["admin1", "auditor1", "reader1"], + "get", + 200, + expected=[ + { + "id": 2, + **filter_alert, + "suscribe_all_members": None, + "suscribed": False, + "enabled": True, + "owner": {"id": 3, "username": "auditor1"}, + }, + ], + ), + ApiTestCase( + ["reader1"], "put", 403, {"value": "http"}, endpoint="{endpoint}2/" + ), + ApiTestCase( + ["auditor1"], + "put", + 200, + {"value": "http"}, + { + "id": 2, + **filter_alert, + "suscribe_all_members": None, + "value": "http", + "suscribed": False, + "enabled": True, + "owner": {"id": 3, "username": "auditor1"}, + }, + "{endpoint}2/", + ), + ApiTestCase( + ["admin1"], + "put", + 200, + {"value": "https"}, + { + "id": 2, + **filter_alert, + "suscribe_all_members": None, + "value": "https", + "suscribed": False, + "enabled": True, + "owner": {"id": 3, "username": "auditor1"}, + }, + "{endpoint}2/", + ), + ApiTestCase(["reader1"], "delete", 403, endpoint="{endpoint}2/"), + ApiTestCase(["auditor1"], "delete", 204, endpoint="{endpoint}2/"), + ApiTestCase( + ["reader1"], + "post", + 201, + monitor_alert, + { + "id": 3, + **monitor_alert, + "suscribe_all_members": None, + "suscribed": True, + "enabled": True, + "owner": {"id": 5, "username": "reader1"}, + }, + ), + ApiTestCase( + ["admin1", "auditor1"], + "get", + 200, + expected=[ + { + "id": 3, + **monitor_alert, + "suscribe_all_members": None, + "suscribed": False, + "enabled": True, + "owner": {"id": 5, "username": "reader1"}, + }, + ], + ), + ApiTestCase( + ["reader1"], + "get", + 200, + expected=[ + { + "id": 3, + **monitor_alert, + "suscribe_all_members": None, + "suscribed": True, + "enabled": True, + "owner": {"id": 5, "username": "reader1"}, + }, + ], + ), + ApiTestCase(["auditor1"], "delete", 403, endpoint="{endpoint}3/"), + ApiTestCase(["admin1"], "delete", 204, endpoint="{endpoint}3/"), + ] + + def setUp(self) -> None: + super().setUp() + self._setup_tasks_and_executions() + self._setup_findings(self.execution3) + + def _get_object(self) -> Any: + return Alert.objects.create( + project=self.project, + mode=AlertMode.FILTER, + item=AlertItem.CVE, + value="CVE-2020-1111", + ) + + def test_must_be_triggered(self) -> None: + for alert, finding, expected in [ + ( + Alert.objects.create( + project=self.execution3.task.target.project, + mode=AlertMode.MONITOR, + item=AlertItem.CVE, + ), + self.vulnerability, + False, + ), + ( + Alert.objects.create( + project=self.execution3.task.target.project, + mode=AlertMode.NEW, + item=AlertItem.HOST, + ), + self.host, + True, + ), + ( + Alert.objects.create( + project=self.execution3.task.target.project, + mode=AlertMode.NEW, + item=AlertItem.OPEN_PORT, + ), + self.host, + False, + ), + ( + Alert.objects.create( + project=self.execution3.task.target.project, + mode=AlertMode.FILTER, + item=AlertItem.SERVICE, + value="ssh", + ), + self.port, + False, + ), + ( + Alert.objects.create( + project=self.execution3.task.target.project, + mode=AlertMode.FILTER, + item=AlertItem.SERVICE, + value="http", + ), + self.port, + True, + ), + ]: + self.assertEqual( + expected, alert.must_be_triggered(self.execution3, finding) + ) + + +new_monitor = {"hour_span": 48} +invalid_monitor_1 = {"hour_span": 169} +invalid_monitor_2 = {"hour_span": 23} + + +class MonitorSettingsTest(ApiTest): + endpoint = "/api/monitor/1/" + expected_str = "Last monitor was at None. Next one in 24 hours" + cases = [ + ApiTestCase(["auditor1", "auditor2", "reader1", "reader2"], "get", 403), + ApiTestCase( + ["admin1", "admin2"], + "get", + 200, + expected={"id": 1, "last_monitor": None, "hour_span": 24}, + ), + ApiTestCase(["admin1", "admin2"], "put", 400, invalid_monitor_1), + ApiTestCase(["admin1", "admin2"], "put", 400, invalid_monitor_2), + ApiTestCase( + ["admin1", "admin2"], + "put", + 200, + new_monitor, + expected={"id": 1, "last_monitor": None, **new_monitor}, + ), + ApiTestCase( + ["admin1", "admin2"], + "get", + 200, + expected={"id": 1, "last_monitor": None, **new_monitor}, + ), + ] + + def _get_object(self) -> Any: + return MonitorSettings.objects.first() diff --git a/src/backend/tests/test_integrations.py b/src/backend/tests/test_integrations.py index af9bcfce4..2553a4858 100644 --- a/src/backend/tests/test_integrations.py +++ b/src/backend/tests/test_integrations.py @@ -14,6 +14,7 @@ class IntegrationTest(ApiTest): "get", 200, expected=[ + {"id": 4, "enabled": True}, {"id": 3, "enabled": True}, {"id": 2, "enabled": True}, {"id": 1, "enabled": True}, @@ -38,6 +39,7 @@ class IntegrationTest(ApiTest): "get", 200, expected=[ + {"id": 4, "enabled": True}, {"id": 3, "enabled": True}, {"id": 2, "enabled": True}, {"id": 1, "enabled": False}, diff --git a/src/backend/users/views.py b/src/backend/users/views.py index f633cf31b..03d8c3f00 100644 --- a/src/backend/users/views.py +++ b/src/backend/users/views.py @@ -129,7 +129,7 @@ def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: return Response(status=status.HTTP_204_NO_CONTENT) @extend_schema(request=None, responses={200: UserSerializer}) - @action(detail=True, methods=["POST"], url_path="enable") + @action(detail=True, methods=["POST"]) def enable(self, request: Request, pk: str) -> Response: """Enable disabled user.