From 625b843a9415e0823a051ccad9375e54d41efd89 Mon Sep 17 00:00:00 2001 From: Augustin-FL Date: Mon, 11 Nov 2024 15:23:29 +0100 Subject: [PATCH 1/6] Implement dynamic settings for labels move severity to labels, with 'color' as dynamic setting --- fir_api/filters.py | 5 +- fir_api/permissions.py | 10 +- fir_api/serializers.py | 14 +- fir_api/urls.py | 2 +- fir_api/views.py | 37 ++- incidents/admin.py | 8 +- incidents/fixtures/01_seed_data.json | 149 +++++---- ...config_alter_incident_severity_and_more.py | 38 +++ incidents/models.py | 289 +++++++++++++----- incidents/static/custom_js/admin_labels.js | 109 +++++++ incidents/views.py | 6 +- 11 files changed, 513 insertions(+), 154 deletions(-) create mode 100644 incidents/migrations/0013_label_dynamic_config_alter_incident_severity_and_more.py create mode 100644 incidents/static/custom_js/admin_labels.js diff --git a/fir_api/filters.py b/fir_api/filters.py index 5953b87a..bfb89a33 100644 --- a/fir_api/filters.py +++ b/fir_api/filters.py @@ -125,10 +125,13 @@ class LabelFilter(FilterSet): id = NumberFilter(field_name="id") name = CharFilter(field_name="name") + color = CharFilter( + field_name="dynamic_config__color", lookup_expr="icontains", label="color" + ) class Meta: model = Label - fields = ["id", "name", "group"] + fields = ["id", "name", "group", "color"] class ValidAttributeFilter(FilterSet): diff --git a/fir_api/permissions.py b/fir_api/permissions.py index f60c6874..9a029805 100644 --- a/fir_api/permissions.py +++ b/fir_api/permissions.py @@ -1,4 +1,4 @@ -from rest_framework.permissions import BasePermission +from rest_framework.permissions import BasePermission, IsAdminUser, SAFE_METHODS from incidents.models import Incident, AccessControlEntry @@ -33,3 +33,11 @@ def has_object_permission(self, request, view, obj): return True except: return False + + +class IsAdminUserOrReadOnly(IsAdminUser): + def has_permission(self, request, view): + is_admin = super().has_permission(request, view) + is_read = bool(request.method in SAFE_METHODS) + + return request.user.is_authenticated and is_read or is_admin diff --git a/fir_api/serializers.py b/fir_api/serializers.py index 5523e24f..9a2d06b9 100644 --- a/fir_api/serializers.py +++ b/fir_api/serializers.py @@ -8,15 +8,16 @@ Incident, Artifact, Label, + LabelGroup, File, IncidentCategory, BusinessLine, Comments, Attribute, ValidAttribute, - SeverityChoice, STATUS_CHOICES, CONFIDENTIALITY_LEVEL, + PrettyJSONEncoder, ) if apps.is_installed("fir_todos"): @@ -113,11 +114,16 @@ class Meta: class LabelSerializer(serializers.ModelSerializer): - group = serializers.SlugRelatedField(many=False, read_only=True, slug_field="name") + group = serializers.SlugRelatedField( + many=False, + queryset=LabelGroup.objects.all(), + slug_field="name", + ) + dynamic_config = serializers.JSONField(encoder=PrettyJSONEncoder, initial={}) class Meta: model = Label - fields = ("id", "name", "group") + fields = ("id", "name", "group", "dynamic_config") read_only_fields = ("id",) @@ -216,7 +222,7 @@ class IncidentSerializer(serializers.ModelSerializer): ) severity = serializers.SlugRelatedField( slug_field="name", - queryset=SeverityChoice.objects.all(), + queryset=Label.objects.filter(group__name="severity"), required=True, ) category = serializers.SlugRelatedField( diff --git a/fir_api/urls.py b/fir_api/urls.py index 3ae47d97..19854892 100644 --- a/fir_api/urls.py +++ b/fir_api/urls.py @@ -17,7 +17,7 @@ router.register(r"artifacts", views.ArtifactViewSet) router.register(r"files", views.FileViewSet) router.register(r"comments", views.CommentViewSet) -router.register(r"labels", views.LabelViewSet) +router.register(r"labels", views.LabelViewSet, basename="labels") router.register(r"attributes", views.AttributeViewSet) router.register(r"validattributes", views.ValidAttributeViewSet) router.register(r"businesslines", views.BusinessLinesViewSet) diff --git a/fir_api/views.py b/fir_api/views.py index 63a16539..a678ad1c 100644 --- a/fir_api/views.py +++ b/fir_api/views.py @@ -24,6 +24,7 @@ from rest_framework import renderers from rest_framework.response import Response from rest_framework.filters import OrderingFilter +from rest_framework.serializers import ValidationError from django_filters.rest_framework import ( DjangoFilterBackend, FilterSet, @@ -58,7 +59,7 @@ ValidAttributeFilter, FileFilter, ) -from fir_api.permissions import IsIncidentHandler +from fir_api.permissions import IsIncidentHandler, IsAdminUserOrReadOnly from fir_artifacts.files import handle_uploaded_file, do_download from fir_artifacts.models import Artifact, File from incidents.models import ( @@ -132,7 +133,7 @@ def get_businesslines(self, businesslines): def perform_create(self, serializer): opened_by = self.request.user serializer.is_valid(raise_exception=True) - if type(self.request.data).__name__ == 'dict': + if type(self.request.data).__name__ == "dict": bls = self.request.data.get("concerned_business_lines", []) else: bls = self.request.data.getlist("concerned_business_lines", []) @@ -153,7 +154,7 @@ def perform_update(self, serializer): Comments.create_diff_comment( self.get_object(), serializer.validated_data, self.request.user ) - if type(self.request.data).__name__ == 'dict': + if type(self.request.data).__name__ == "dict": bls = self.request.data.get("concerned_business_lines", []) else: bls = self.request.data.getlist("concerned_business_lines", []) @@ -237,19 +238,31 @@ def perform_destroy(self, instance): return super().perform_destroy(instance) -class LabelViewSet(ListModelMixin, viewsets.GenericViewSet): +class LabelViewSet(viewsets.ModelViewSet): """ API endpoint for viewing labels """ - queryset = Label.objects.all() + queryset = Label.objects.all().order_by('id') serializer_class = LabelSerializer - permission_classes = (IsAuthenticated,) + permission_classes = (IsAdminUserOrReadOnly,) filter_backends = [DjangoFilterBackend] filterset_class = LabelFilter - def get_queryset(self): - return super().get_queryset() + def perform_update(self, serializer): + self.perform_create(serializer) + + def perform_create(self, serializer): + if serializer.is_valid(): + try: + serializer.Meta.model.validate_dynamic_config( + serializer.validated_data["name"], + serializer.validated_data["group"], + serializer.validated_data["dynamic_config"], + ) + except Exception as e: + raise ValidationError(e.message) + serializer.save() class FileViewSet(ListModelMixin, RetrieveModelMixin, viewsets.GenericViewSet): @@ -287,12 +300,12 @@ def upload(self, request, pk): pk=pk, ) files_added = [] - if type(self.request.data).__name__ == 'dict': + if type(self.request.data).__name__ == "dict": uploaded_files = request.FILES.get("file", []) else: uploaded_files = request.FILES.getlist("file", []) - if type(self.request.data).__name__ == 'dict': + if type(self.request.data).__name__ == "dict": descriptions = request.data.get("description", []) else: descriptions = request.data.getlist("description", []) @@ -303,9 +316,7 @@ def upload(self, request, pk): status=status.HTTP_400_BAD_REQUEST, ) - for uploaded_file, description in zip( - uploaded_files, descriptions - ): + for uploaded_file, description in zip(uploaded_files, descriptions): file_wrapper = FileWrapper(uploaded_file.file) file_wrapper.name = uploaded_file.name file = handle_uploaded_file(file_wrapper, description, incident) diff --git a/incidents/admin.py b/incidents/admin.py index ebd1eb1b..6ac7e8ab 100755 --- a/incidents/admin.py +++ b/incidents/admin.py @@ -24,18 +24,22 @@ class BusinessLineAdmin(TreeAdmin): class IncidentAdmin(admin.ModelAdmin): exclude = ("artifacts", ) +class LabelAdmin(admin.ModelAdmin): + class Media: + js = ("colorfield/jscolor/jscolor.js", "custom_js/admin_labels.js", ) + + admin.site.register(Incident, IncidentAdmin) admin.site.register(BusinessLine, BusinessLineAdmin) admin.site.register(BaleCategory) admin.site.register(Comments) admin.site.register(LabelGroup) -admin.site.register(Label) +admin.site.register(Label, LabelAdmin) admin.site.register(IncidentCategory) admin.site.register(Log) admin.site.register(Profile) admin.site.register(IncidentTemplate) admin.site.register(Attribute) admin.site.register(ValidAttribute) -admin.site.register(SeverityChoice) admin.site.unregister(User) admin.site.register(User, UserAdmin) diff --git a/incidents/fixtures/01_seed_data.json b/incidents/fixtures/01_seed_data.json index 8370f25e..8c71f081 100755 --- a/incidents/fixtures/01_seed_data.json +++ b/incidents/fixtures/01_seed_data.json @@ -27,12 +27,20 @@ "name": "detection" } }, +{ + "pk": 5, + "model": "incidents.labelgroup", + "fields": { + "name": "severity" + } +}, { "pk": 1, "model": "incidents.label", "fields": { "group": 4, - "name": "CERT" + "name": "CERT", + "dynamic_config": {} } }, { @@ -40,7 +48,8 @@ "model": "incidents.label", "fields": { "group": 4, - "name": "External" + "name": "External", + "dynamic_config": {} } }, { @@ -48,7 +57,8 @@ "model": "incidents.label", "fields": { "group": 2, - "name": "CERT" + "name": "CERT", + "dynamic_config": {} } }, { @@ -56,7 +66,8 @@ "model": "incidents.label", "fields": { "group": 2, - "name": "Entity" + "name": "Entity", + "dynamic_config": {} } }, { @@ -64,7 +75,8 @@ "model": "incidents.label", "fields": { "group": 1, - "name": "A" + "name": "A", + "dynamic_config": {} } }, { @@ -72,7 +84,8 @@ "model": "incidents.label", "fields": { "group": 1, - "name": "B" + "name": "B", + "dynamic_config": {} } }, { @@ -80,7 +93,8 @@ "model": "incidents.label", "fields": { "group": 1, - "name": "C" + "name": "C", + "dynamic_config": {} } }, { @@ -88,7 +102,8 @@ "model": "incidents.label", "fields": { "group": 1, - "name": "1" + "name": "1", + "dynamic_config": {} } }, { @@ -96,7 +111,8 @@ "model": "incidents.label", "fields": { "group": 1, - "name": "2" + "name": "2", + "dynamic_config": {} } }, { @@ -104,7 +120,8 @@ "model": "incidents.label", "fields": { "group": 1, - "name": "5" + "name": "5", + "dynamic_config": {} } }, { @@ -112,7 +129,8 @@ "model": "incidents.label", "fields": { "group": 1, - "name": "6" + "name": "6", + "dynamic_config": {} } }, { @@ -120,7 +138,8 @@ "model": "incidents.label", "fields": { "group": 3, - "name": "Opened" + "name": "Opened", + "dynamic_config": {} } }, { @@ -128,7 +147,8 @@ "model": "incidents.label", "fields": { "group": 3, - "name": "Closed" + "name": "Closed", + "dynamic_config": {} } }, { @@ -136,7 +156,8 @@ "model": "incidents.label", "fields": { "group": 3, - "name": "Monitor" + "name": "Monitor", + "dynamic_config": {} } }, { @@ -144,7 +165,8 @@ "model": "incidents.label", "fields": { "group": 3, - "name": "Takedown" + "name": "Takedown", + "dynamic_config": {} } }, { @@ -152,7 +174,8 @@ "model": "incidents.label", "fields": { "group": 3, - "name": "Info" + "name": "Info", + "dynamic_config": {} } }, { @@ -160,7 +183,8 @@ "model": "incidents.label", "fields": { "group": 3, - "name": "Alerting" + "name": "Alerting", + "dynamic_config": {} } }, { @@ -168,7 +192,8 @@ "model": "incidents.label", "fields": { "group": 3, - "name": "Investigation" + "name": "Investigation", + "dynamic_config": {} } }, { @@ -176,7 +201,8 @@ "model": "incidents.label", "fields": { "group": 4, - "name": "BL" + "name": "BL", + "dynamic_config": {} } }, { @@ -184,7 +210,8 @@ "model": "incidents.label", "fields": { "group": 4, - "name": "SOC" + "name": "SOC", + "dynamic_config": {} } }, { @@ -192,7 +219,8 @@ "model": "incidents.label", "fields": { "group": 3, - "name": "Abuse" + "name": "Abuse", + "dynamic_config": {} } }, { @@ -200,7 +228,52 @@ "model": "incidents.label", "fields": { "group": 3, - "name": "Blocked" + "name": "Blocked", + "dynamic_config": {} + } +}, +{ + "model": "incidents.label", + "pk": 23, + "fields": { + "name": "1", + "group": 5, + "dynamic_config": { + "color": "#468847" + } + } +}, +{ + "model": "incidents.label", + "pk": 24, + "fields": { + "name": "2", + "group": 5, + "dynamic_config": { + "color": "#fefe00" + } + } +}, +{ + "model": "incidents.label", + "pk": 25, + "fields": { + "name": "3", + "group": 5, + "dynamic_config": { + "color": "#f89406" + } + } +}, +{ + "model": "incidents.label", + "pk": 26, + "fields": { + "name": "4", + "group": 5, + "dynamic_config": { + "color": "#f81920" + } } }, { @@ -679,37 +752,5 @@ ] ] } -}, -{ - "model": "incidents.severitychoice", - "pk": 1, - "fields": { - "name": "1", - "color": "#468847" - } -}, -{ - "model": "incidents.severitychoice", - "pk": 2, - "fields": { - "name": "2", - "color": "#c8c800" - } -}, -{ - "model": "incidents.severitychoice", - "pk": 3, - "fields": { - "name": "3", - "color": "#f89406" - } -}, -{ - "model": "incidents.severitychoice", - "pk": 4, - "fields": { - "name": "4", - "color": "#f81920" - } } ] diff --git a/incidents/migrations/0013_label_dynamic_config_alter_incident_severity_and_more.py b/incidents/migrations/0013_label_dynamic_config_alter_incident_severity_and_more.py new file mode 100644 index 00000000..b3a91e7e --- /dev/null +++ b/incidents/migrations/0013_label_dynamic_config_alter_incident_severity_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.16 on 2024-11-11 16:04 + +from django.db import migrations, models +import django.db.models.deletion +import incidents.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('incidents', '0012_severitychoice_alter_incident_severity_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='label', + name='dynamic_config', + field=models.JSONField(blank=True, default=dict, encoder=incidents.models.PrettyJSONEncoder), + ), + migrations.AlterField( + model_name='incident', + name='severity', + field=models.ForeignKey(blank=True, limit_choices_to={'group__name': 'severity'}, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='severity', to='incidents.label'), + ), + migrations.AlterField( + model_name='incidenttemplate', + name='detection', + field=models.ForeignKey(blank=True, limit_choices_to={'group__name': 'detection'}, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='incidents.label'), + ), + migrations.AlterField( + model_name='incidenttemplate', + name='severity', + field=models.ForeignKey(blank=True, limit_choices_to={'group__name': 'severity'}, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='incidents.label'), + ), + migrations.DeleteModel( + name='SeverityChoice', + ), + ] diff --git a/incidents/models.py b/incidents/models.py index ff076742..c24e2392 100755 --- a/incidents/models.py +++ b/incidents/models.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import datetime +import json from colorfield.fields import ColorField from django.db.models.signals import post_save @@ -8,6 +9,7 @@ from django.contrib.auth.models import User from django.utils.translation import gettext_lazy as _ from django.conf import settings +from django.core.exceptions import ValidationError from treebeard.mp_tree import MP_Node @@ -62,7 +64,7 @@ class Profile(models.Model): hide_closed = models.BooleanField(default=False) def __str__(self): - return u"Profile for user '{}'".format(self.user) + return "Profile for user '{}'".format(self.user) # Audit trail ================================================================ @@ -72,16 +74,30 @@ class Log(models.Model): who = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) what = models.CharField(max_length=100, choices=STATUS_CHOICES) when = models.DateTimeField(auto_now_add=True) - incident = models.ForeignKey('Incident', on_delete=models.CASCADE, null=True, blank=True) - comment = models.ForeignKey('Comments', on_delete=models.CASCADE, null=True, blank=True) + incident = models.ForeignKey( + "Incident", on_delete=models.CASCADE, null=True, blank=True + ) + comment = models.ForeignKey( + "Comments", on_delete=models.CASCADE, null=True, blank=True + ) def __str__(self): if self.incident: - return u"[%s] %s %s (%s)" % (self.when, self.what, self.incident, self.who) + return "[%s] %s %s (%s)" % (self.when, self.what, self.incident, self.who) elif self.comment: - return u"[%s] %s comment on %s (%s)" % (self.when, self.what, self.comment.incident, self.who) + return "[%s] %s comment on %s (%s)" % ( + self.when, + self.what, + self.comment.incident, + self.who, + ) else: - return u"[%s] %s (%s)" % (self.when, self.what, self.who) + return "[%s] %s (%s)" % (self.when, self.what, self.who) + + +class PrettyJSONEncoder(json.JSONEncoder): + def __init__(self, *args, indent, sort_keys, **kwargs): + super().__init__(*args, indent=2, sort_keys=True, **kwargs) class LabelGroup(models.Model): @@ -94,10 +110,49 @@ def __str__(self): class Label(models.Model): name = models.CharField(max_length=50) group = models.ForeignKey(LabelGroup, on_delete=models.CASCADE) + dynamic_config = models.JSONField( + default=dict, blank=True, encoder=PrettyJSONEncoder + ) def __str__(self): return "%s" % (self.name) + def __getattr__(self, name): + try: + return self.dynamic_config[name] + except KeyError: + try: + return super().__getattr__(name) + except AttributeError: + raise AttributeError( + "object %s has no attribute '%s'" % (type(self).__name__, name) + ) + + @staticmethod + def validate_dynamic_config(name, group, dynamic_config): + if not isinstance(dynamic_config, dict): + raise ValidationError(_("Field 'dynamic_config' must be a dict")) + + if str(group) == _("severity"): + if not "color" in dynamic_config: + raise ValidationError( + _("Field 'color' is required for 'severity' labels") + ) + if ( + not isinstance(dynamic_config["color"], str) + or not dynamic_config["color"].startswith("#") + or not len(dynamic_config["color"]) == 7 + ): + raise ValidationError(_("Field 'color' does not contains valid data")) + if not str(name).isdigit(): + raise ValidationError( + _("Field 'name' must be a number for 'severity' labels") + ) + + def clean(self): + validate_dynamic_config(self.name, self.group, self.dynamic_config) + return super().clean() + class BusinessLine(MP_Node, AuthorizationModelMixin): name = models.CharField(max_length=100) @@ -105,43 +160,62 @@ class BusinessLine(MP_Node, AuthorizationModelMixin): def __str__(self): parents = list(self.get_ancestors()) parents.append(self) - return u" > ".join([bl.name for bl in parents]) + return " > ".join([bl.name for bl in parents]) class Meta: - verbose_name = _('business line') + verbose_name = _("business line") def get_incident_count(self, query): incident_count = self.incident_set.filter(query).distinct().count() - incident_count += Incident.objects.filter(query).filter( - concerned_business_lines__in=self.get_descendants()).distinct().count() + incident_count += ( + Incident.objects.filter(query) + .filter(concerned_business_lines__in=self.get_descendants()) + .distinct() + .count() + ) return incident_count class AccessControlEntry(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_('user')) - business_line = models.ForeignKey(BusinessLine, on_delete=models.CASCADE, verbose_name=_('business line'), related_name='acl') - role = models.ForeignKey('auth.Group', on_delete=models.CASCADE, verbose_name=_('role')) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name=_("user") + ) + business_line = models.ForeignKey( + BusinessLine, + on_delete=models.CASCADE, + verbose_name=_("business line"), + related_name="acl", + ) + role = models.ForeignKey( + "auth.Group", on_delete=models.CASCADE, verbose_name=_("role") + ) def __str__(self): return _("{} is {} on {}").format(self.user, self.role, self.business_line) class Meta: - verbose_name = _('access control entry') - verbose_name_plural = _('access control entries') + verbose_name = _("access control entry") + verbose_name_plural = _("access control entries") class BaleCategory(models.Model): name = models.CharField(max_length=200) description = models.TextField(blank=True, null=True) category_number = models.IntegerField() - parent_category = models.ForeignKey('BaleCategory', on_delete=models.CASCADE, null=True, blank=True) + parent_category = models.ForeignKey( + "BaleCategory", on_delete=models.CASCADE, null=True, blank=True + ) class Meta: verbose_name_plural = "Bale categories" def __str__(self): if self.parent_category: - return "(%s > %s) %s" % (self.parent_category.category_number, self.category_number, self.name) + return "(%s > %s) %s" % ( + self.parent_category.category_number, + self.category_number, + self.name, + ) else: return "(%s) %s" % (self.category_number, self.name) @@ -160,8 +234,15 @@ def __str__(self): # Core models ================================================================ -@tree_authorization(fields=['concerned_business_lines', ], tree_model='incidents.BusinessLine', - owner_field='opened_by', owner_permission=settings.INCIDENT_CREATOR_PERMISSION) + +@tree_authorization( + fields=[ + "concerned_business_lines", + ], + tree_model="incidents.BusinessLine", + owner_field="opened_by", + owner_permission=settings.INCIDENT_CREATOR_PERMISSION, +) @link_to(File) @link_to(Artifact) class Incident(FIRModel, models.Model): @@ -171,19 +252,44 @@ class Incident(FIRModel, models.Model): description = models.TextField() category = models.ForeignKey(IncidentCategory, on_delete=models.CASCADE) concerned_business_lines = models.ManyToManyField(BusinessLine, blank=True) - main_business_lines = models.ManyToManyField(BusinessLine, related_name='incidents_affecting_main', blank=True) - detection = models.ForeignKey(Label, on_delete=models.CASCADE, limit_choices_to={'group__name': 'detection'}, related_name='detection_label') + main_business_lines = models.ManyToManyField( + BusinessLine, related_name="incidents_affecting_main", blank=True + ) + detection = models.ForeignKey( + Label, + on_delete=models.CASCADE, + limit_choices_to={"group__name": "detection"}, + related_name="detection_label", + ) severity = models.ForeignKey( - 'SeverityChoice', null=True, blank=True, on_delete=models.SET_NULL) + Label, + on_delete=models.CASCADE, + limit_choices_to={"group__name": "severity"}, + related_name="severity", + blank=True, + null=True, + ) is_incident = models.BooleanField(default=False) is_major = models.BooleanField(default=False) - actor = models.ForeignKey(Label, on_delete=models.CASCADE, limit_choices_to={'group__name': 'actor'}, related_name='actor_label', blank=True, - null=True) - plan = models.ForeignKey(Label, on_delete=models.CASCADE, limit_choices_to={'group__name': 'plan'}, related_name='plan_label', blank=True, - null=True) + actor = models.ForeignKey( + Label, + on_delete=models.CASCADE, + limit_choices_to={"group__name": "actor"}, + related_name="actor_label", + blank=True, + null=True, + ) + plan = models.ForeignKey( + Label, + on_delete=models.CASCADE, + limit_choices_to={"group__name": "plan"}, + related_name="plan_label", + blank=True, + null=True, + ) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=_("Open")) opened_by = models.ForeignKey(User, on_delete=models.CASCADE) - confidentiality = models.IntegerField(choices=CONFIDENTIALITY_LEVEL, default='1') + confidentiality = models.IntegerField(choices=CONFIDENTIALITY_LEVEL, default="1") def __str__(self): return self.subject @@ -191,25 +297,27 @@ def __str__(self): def is_open(self): return self.get_last_action != "Closed" - def close_timeout(self, username='cert'): + def close_timeout(self, username="cert"): previous_status = self.status - self.status = 'C' + self.status = "C" self.save() - model_status_changed.send(sender=Incident, instance=self, previous_status=previous_status) + model_status_changed.send( + sender=Incident, instance=self, previous_status=previous_status + ) c = Comments() c.comment = "Incident closed (timeout)" c.date = datetime.datetime.now() - c.action = Label.objects.get(name='Closed', group__name='action') + c.action = Label.objects.get(name="Closed", group__name="action") c.incident = self - c.opened_by = User.objects.get(username= username) + c.opened_by = User.objects.get(username=username) c.save() def get_last_comment(self): - return self.comments_set.order_by('-date')[0] + return self.comments_set.order_by("-date")[0] def get_last_action(self): - c = self.comments_set.order_by('-date')[0] + c = self.comments_set.order_by("-date")[0] action = "%s (%s)" % (c.action, c.date.strftime("%Y %d %b %H:%M:%S")) @@ -269,32 +377,41 @@ def refresh_artifacts(self, data=""): class Meta: permissions = ( - ('handle_incidents', 'Can handle incidents'), - ('report_events', 'Can report events'), - ('view_incidents', 'Can view incidents'), - ('view_statistics', 'Can view statistics'), + ("handle_incidents", "Can handle incidents"), + ("report_events", "Can report events"), + ("view_incidents", "Can view incidents"), + ("view_statistics", "Can view statistics"), ) class Comments(models.Model): date = models.DateTimeField(default=datetime.datetime.now, blank=True) comment = models.TextField() - action = models.ForeignKey(Label, on_delete=models.CASCADE, limit_choices_to={'group__name': 'action'}, related_name='action_label') + action = models.ForeignKey( + Label, + on_delete=models.CASCADE, + limit_choices_to={"group__name": "action"}, + related_name="action_label", + ) incident = models.ForeignKey(Incident, on_delete=models.CASCADE) opened_by = models.ForeignKey(User, on_delete=models.CASCADE) class Meta: - verbose_name_plural = 'comments' + verbose_name_plural = "comments" def __str__(self): - return u"Comment for incident %s" % self.incident.id + return "Comment for incident %s" % self.incident.id @classmethod def create_diff_comment(cls, incident, data, user): - comments = '' + comments = "" for key in data: # skip the following fields from diff - if key in ['description', 'concerned_business_lines', 'main_business_lines']: + if key in [ + "description", + "concerned_business_lines", + "main_business_lines", + ]: continue new = data[key] @@ -303,36 +420,36 @@ def create_diff_comment(cls, incident, data, user): if new != old: label = key - if key == 'is_major': - label = 'major' - if key == 'concerned_business_lines': + if key == "is_major": + label = "major" + if key == "concerned_business_lines": label = "business lines" - if key == 'main_business_line': + if key == "main_business_line": label = "main business line" - if key == 'is_incident': - label = 'incident' + if key == "is_incident": + label = "incident" if old == "O": - old = 'Open' + old = "Open" if old == "C": - old = 'Closed' + old = "Closed" if old == "B": - old = 'Blocked' + old = "Blocked" if new == "O": - new = 'Open' + new = "Open" if new == "C": - new = 'Closed' + new = "Closed" if new == "B": - new = 'Blocked' + new = "Blocked" - comments += u'Changed "%s" from "%s" to "%s"; ' % (label, old, new) + comments += 'Changed "%s" from "%s" to "%s"; ' % (label, old, new) if comments: Comments.objects.create( comment=comments, - action=Label.objects.get(name='Info'), + action=Label.objects.get(name="Info"), incident=incident, - opened_by=user + opened_by=user, ) @@ -355,28 +472,50 @@ def __str__(self): return self.name -class SeverityChoice(models.Model): - name = models.CharField(max_length=50) - color = ColorField(default='#777') - - def __str__(self): - return self.name - - # Templating ================================================================= + class IncidentTemplate(models.Model): name = models.CharField(max_length=100) subject = models.CharField(max_length=256, null=True, blank=True) description = models.TextField(null=True, blank=True) - category = models.ForeignKey(IncidentCategory, on_delete=models.CASCADE, null=True, blank=True) + category = models.ForeignKey( + IncidentCategory, on_delete=models.CASCADE, null=True, blank=True + ) concerned_business_lines = models.ManyToManyField(BusinessLine, blank=True) - detection = models.ForeignKey(Label, on_delete=models.CASCADE, limit_choices_to={'group__name': 'detection'}, null=True, blank=True) + detection = models.ForeignKey( + Label, + on_delete=models.CASCADE, + limit_choices_to={"group__name": "detection"}, + null=True, + blank=True, + related_name="+", + ) severity = models.ForeignKey( - 'SeverityChoice', null=True, blank=True, on_delete=models.SET_NULL) + Label, + on_delete=models.CASCADE, + limit_choices_to={"group__name": "severity"}, + null=True, + blank=True, + related_name="+", + ) is_incident = models.BooleanField(default=False) - actor = models.ForeignKey(Label, on_delete=models.CASCADE, limit_choices_to={'group__name': 'actor'}, related_name='+', blank=True, null=True) - plan = models.ForeignKey(Label, on_delete=models.CASCADE, limit_choices_to={'group__name': 'plan'}, related_name='+', blank=True, null=True) + actor = models.ForeignKey( + Label, + on_delete=models.CASCADE, + limit_choices_to={"group__name": "actor"}, + related_name="+", + blank=True, + null=True, + ) + plan = models.ForeignKey( + Label, + on_delete=models.CASCADE, + limit_choices_to={"group__name": "plan"}, + related_name="+", + blank=True, + null=True, + ) def __str__(self): return self.name @@ -400,8 +539,8 @@ def refresh_incident(sender, instance, **kwargs): def comment_new_incident(sender, instance, created, **kwargs): if created: Comments.objects.create( - comment='Incident opened', - action=Label.objects.get(name='Opened'), + comment="Incident opened", + action=Label.objects.get(name="Opened"), incident=instance, opened_by=instance.opened_by, date=instance.date, @@ -414,8 +553,8 @@ def comment_new_incident(sender, instance, created, **kwargs): @receiver(post_save, sender=Incident) def log_new_incident(sender, instance, created, **kwargs): if created: - what = 'Created incident' + what = "Created incident" else: - what = 'Edit incident' + what = "Edit incident" Log.objects.create(who=instance.opened_by, what=what, incident=instance) diff --git a/incidents/static/custom_js/admin_labels.js b/incidents/static/custom_js/admin_labels.js new file mode 100644 index 00000000..c35605f6 --- /dev/null +++ b/incidents/static/custom_js/admin_labels.js @@ -0,0 +1,109 @@ +function removeDOM(className) { + domelem = document.getElementsByClassName(className)[0]; + if (domelem != undefined) { + domelem.remove(); + } +} + +function configDisplay(show) { + document.getElementsByClassName( + "form-row field-dynamic_config", + )[0].style.display = show; +} + +function allowOnlyNumbers(e) { + if (!/[0-9]/i.test(e.key)) { + e.preventDefault(); + } +} + +function changeNameType(type) { + if (type == "number") { + document.getElementById("id_name").value = document + .getElementById("id_name") + .value.replace(/[^0-9]/gi, ""); + document + .getElementById("id_name") + .addEventListener("keypress", allowOnlyNumbers); + } else { + document + .getElementById("id_name") + .removeEventListener("keypress", allowOnlyNumbers); + } +} + +function addColorField(color) { + div = document.createElement("div"); + div.classList.add("form-row", "field-color"); + div.innerHTML = + '
'; + document.getElementsByClassName("module aligned")[0].appendChild(div); + jscolor.install(); +} + +function getColor(getFrom, default_color) { + color = undefined; + if (getFrom == "config") { + color = JSON.parse( + document.getElementById("id_dynamic_config").value, + ).color; + } else if (getFrom == "input") { + elem = document.getElementById("id_color"); + if (elem != undefined) { + color = elem.value; + } + } + if (color == undefined) { + return default_color; + } + return color; +} + +document.addEventListener("DOMContentLoaded", function (event) { + function toggle_labelgroup() { + e = document.getElementById("id_group"); + if (e == undefined) { + return; + } + value = e.options[e.selectedIndex].text; + configDisplay("none"); + + if (value == "severity") { + existing_color = getColor("config", "#000000"); + changeNameType("number"); + addColorField(existing_color); + document // event listner has been removed when editing HTML + .getElementById("id_group") + .addEventListener("change", toggle_labelgroup); + } else { + changeNameType("text"); + removeDOM("field-color"); + } + return true; + } + + function save_json() { + json = JSON.parse(document.getElementById("id_dynamic_config").value); + color = getColor("input"); + delete json.color; + if (color != undefined) { + json.color = color; + } + document.getElementById("id_dynamic_config").value = JSON.stringify(json); + } + + id_group = document.getElementById("id_group"); + if (id_group != undefined) { + id_group.addEventListener("change", toggle_labelgroup); + } + toggle_labelgroup(); + + label_form = document.getElementById("label_form"); + if (label_form != undefined) { + label_form.addEventListener("submit", save_json); + } +}); diff --git a/incidents/views.py b/incidents/views.py index cc0c07bb..dc6b6274 100755 --- a/incidents/views.py +++ b/incidents/views.py @@ -9,7 +9,7 @@ from django.views.decorators.http import require_POST from incidents.models import IncidentCategory, Incident, Comments, BusinessLine, model_status_changed -from incidents.models import Label, Log, BaleCategory, SeverityChoice +from incidents.models import Label, Log, BaleCategory from incidents.models import Attribute, ValidAttribute, IncidentTemplate, Profile from incidents.forms import IncidentForm, CommentForm @@ -1758,7 +1758,7 @@ def data_yearly_bl_severity(request): q = q & Q(confidentiality__lte=2) chart_data = [] - severity_choices = SeverityChoice.objects.all() + severity_choices = Label.objects.filter(group__name='severity') for bl in bls: d = {} @@ -1934,7 +1934,7 @@ def data_quarterly_bl(request, business_line, divisor, num_months=3, is_incident chart_data.append(d) elif divisor == 'severity': - severity_choices = SeverityChoice.objects.all() + severity_choices = Label.objects.filter(group__name='severity') for i in range(num_months): d = {} From 9af607158c539156c552e30d2a7a1c18843d84b6 Mon Sep 17 00:00:00 2001 From: Augustin-FL Date: Mon, 11 Nov 2024 16:34:29 +0100 Subject: [PATCH 2/6] define DEFAULT_AUTO_FIELD to suppress warnings --- fir/config/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fir/config/base.py b/fir/config/base.py index 7bb8c7f5..451a2981 100755 --- a/fir/config/base.py +++ b/fir/config/base.py @@ -213,3 +213,5 @@ # HTTP_X_API == "X-Api" in HTTP headers. 'TOKEN_AUTHENTICATION_META': 'HTTP_X_API', } + +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' From eac1044601868a00a23ab285f0667d0c885b66d5 Mon Sep 17 00:00:00 2001 From: Augustin-FL Date: Mon, 11 Nov 2024 16:36:37 +0100 Subject: [PATCH 3/6] Increase wait-for timeouts --- docker/docker-compose.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4375d219..c7b77124 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,7 +12,7 @@ services: context: ../ dockerfile: docker/Dockerfile entrypoint: /bin/sh - command: -c "wait-for -t 10 fir_db:3306 && python manage.py makemigrations && python manage.py migrate && python manage.py loaddata incidents/fixtures/*.json && python manage.py collectstatic --no-input && python manage.py runserver 0.0.0.0:8000" + command: -c "wait-for -t 20 fir_db:3306 && python manage.py makemigrations && python manage.py migrate && python manage.py loaddata incidents/fixtures/*.json && python manage.py collectstatic --no-input && python manage.py runserver 0.0.0.0:8000" container_name: fir hostname: fir depends_on: @@ -58,7 +58,7 @@ services: fir_celery_worker: image: fir:latest entrypoint: /bin/sh - command: -c "wait-for -t 10 fir_redis:6379 && wait-for -t 30 fir:8000 -- celery -A fir_celery.celeryconf.celery_app worker -l debug" + command: -c "wait-for -t 20 fir_redis:6379 && wait-for -t 40 fir:8000 -- celery -A fir_celery.celeryconf.celery_app worker -l debug" container_name: fir_celery_worker hostname: fir_celery_worker depends_on: @@ -73,7 +73,7 @@ services: fir_celery_beat: image: fir:latest entrypoint: /bin/sh - command: -c "wait-for -t 10 fir_redis:6379 && wait-for -t 30 fir:8000 -- celery -A fir_celery.celeryconf.celery_app beat -l debug" + command: -c "wait-for -t 20 fir_redis:6379 && wait-for -t 40 fir:8000 -- celery -A fir_celery.celeryconf.celery_app beat -l debug" container_name: fir_celery_beat hostname: fir_celery_beat depends_on: From 4bd815140a79651e533af6856f21866a70476187 Mon Sep 17 00:00:00 2001 From: Augustin-FL Date: Mon, 11 Nov 2024 20:31:33 +0100 Subject: [PATCH 4/6] Fix typo --- incidents/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/incidents/models.py b/incidents/models.py index c24e2392..7259297b 100755 --- a/incidents/models.py +++ b/incidents/models.py @@ -150,7 +150,7 @@ def validate_dynamic_config(name, group, dynamic_config): ) def clean(self): - validate_dynamic_config(self.name, self.group, self.dynamic_config) + self.validate_dynamic_config(self.name, self.group, self.dynamic_config) return super().clean() From ed7a84ed869421e7be3f4b36cdb3e88f4451e31d Mon Sep 17 00:00:00 2001 From: Augustin-FL Date: Tue, 12 Nov 2024 14:23:15 +0100 Subject: [PATCH 5/6] Filter by name instead of IDs --- fir_api/filters.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/fir_api/filters.py b/fir_api/filters.py index bfb89a33..3a3fbd83 100644 --- a/fir_api/filters.py +++ b/fir_api/filters.py @@ -13,6 +13,7 @@ from incidents.models import ( Incident, Label, + LabelGroup, IncidentCategory, Comments, File, @@ -125,6 +126,11 @@ class LabelFilter(FilterSet): id = NumberFilter(field_name="id") name = CharFilter(field_name="name") + group = ModelChoiceFilter( + to_field_name="name", + field_name="group__name", + queryset=LabelGroup.objects.all(), + ) color = CharFilter( field_name="dynamic_config__color", lookup_expr="icontains", label="color" ) From c313ca3b646d1e6220e41dfa6698a3f4386e849b Mon Sep 17 00:00:00 2001 From: Augustin-FL Date: Wed, 13 Nov 2024 23:47:09 +0100 Subject: [PATCH 6/6] Keep the same color for severity 2/4 --- incidents/fixtures/01_seed_data.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/incidents/fixtures/01_seed_data.json b/incidents/fixtures/01_seed_data.json index 8c71f081..07c0798f 100755 --- a/incidents/fixtures/01_seed_data.json +++ b/incidents/fixtures/01_seed_data.json @@ -250,7 +250,7 @@ "name": "2", "group": 5, "dynamic_config": { - "color": "#fefe00" + "color": "#c8c800" } } },