diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 49e3486fe2..45d2707a6e 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -2004,8 +2004,20 @@ class Meta: exclude = ( "tid", "updated", + "async_updating" ) + def validate(self, data): + async_updating = getattr(self.instance, 'async_updating', None) + if async_updating: + new_sla_config = data.get('sla_configuration', None) + old_sla_config = getattr(self.instance, 'sla_configuration', None) + if new_sla_config and old_sla_config and new_sla_config != old_sla_config: + raise serializers.ValidationError( + 'Finding SLA expiration dates are currently being recalculated. The SLA configuration for this product cannot be changed until the calculation is complete.' + ) + return data + def get_findings_count(self, obj) -> int: return obj.findings_count @@ -3031,7 +3043,21 @@ class Meta: class SLAConfigurationSerializer(serializers.ModelSerializer): class Meta: model = SLA_Configuration - fields = "__all__" + exclude = ( + "async_updating", + ) + + def validate(self, data): + async_updating = getattr(self.instance, 'async_updating', None) + if async_updating: + for field in ['critical', 'high', 'medium', 'low']: + old_days = getattr(self.instance, field, None) + new_days = data.get(field, None) + if old_days and new_days and (old_days != new_days): + raise serializers.ValidationError( + 'Finding SLA expiration dates are currently being calculated. The SLA days for this SLA configuration cannot be changed until the calculation is complete.' + ) + return data class UserProfileSerializer(serializers.Serializer): diff --git a/dojo/apps.py b/dojo/apps.py index 30a1711b19..6c84a420de 100644 --- a/dojo/apps.py +++ b/dojo/apps.py @@ -74,6 +74,7 @@ def ready(self): import dojo.announcement.signals # noqa import dojo.product.signals # noqa import dojo.test.signals # noqa + import dojo.sla_config.helpers # noqa def get_model_fields_with_extra(model, extra_fields=()): diff --git a/dojo/db_migrations/0200_finding_sla_expiration_date_product_async_updating_and_more.py b/dojo/db_migrations/0200_finding_sla_expiration_date_product_async_updating_and_more.py new file mode 100644 index 0000000000..20ef3e4f68 --- /dev/null +++ b/dojo/db_migrations/0200_finding_sla_expiration_date_product_async_updating_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.1.13 on 2024-01-17 03:07 + +from django.db import migrations, models +import logging + +logger = logging.getLogger(__name__) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0199_whitesource_to_mend'), + ] + + operations = [ + migrations.AddField( + model_name='finding', + name='sla_expiration_date', + field=models.DateField(blank=True, help_text="(readonly)The date SLA expires for this finding. Empty by default, causing a fallback to 'date'.", null=True, verbose_name='SLA Expiration Date'), + ), + migrations.AddField( + model_name='product', + name='async_updating', + field=models.BooleanField(default=False, help_text='Findings under this Product or SLA configuration are asynchronously being updated'), + ), + migrations.AddField( + model_name='sla_configuration', + name='async_updating', + field=models.BooleanField(default=False, help_text='Findings under this SLA configuration are asynchronously being updated'), + ), + ] diff --git a/dojo/filters.py b/dojo/filters.py index 20db8bddcf..51279d76a9 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -147,13 +147,13 @@ class FindingSLAFilter(ChoiceFilter): def any(self, qs, name): return qs - def satisfies_sla(self, qs, name): + def sla_satisfied(self, qs, name): for finding in qs: if finding.violates_sla: qs = qs.exclude(id=finding.id) return qs - def violates_sla(self, qs, name): + def sla_violated(self, qs, name): for finding in qs: if not finding.violates_sla: qs = qs.exclude(id=finding.id) @@ -161,8 +161,8 @@ def violates_sla(self, qs, name): options = { None: (_('Any'), any), - 0: (_('False'), satisfies_sla), - 1: (_('True'), violates_sla), + 0: (_('False'), sla_satisfied), + 1: (_('True'), sla_violated), } def __init__(self, *args, **kwargs): @@ -182,13 +182,13 @@ class ProductSLAFilter(ChoiceFilter): def any(self, qs, name): return qs - def satisfies_sla(self, qs, name): + def sla_satisifed(self, qs, name): for product in qs: if product.violates_sla: qs = qs.exclude(id=product.id) return qs - def violates_sla(self, qs, name): + def sla_violated(self, qs, name): for product in qs: if not product.violates_sla: qs = qs.exclude(id=product.id) @@ -196,8 +196,8 @@ def violates_sla(self, qs, name): options = { None: (_('Any'), any), - 0: (_('False'), satisfies_sla), - 1: (_('True'), violates_sla), + 0: (_('False'), sla_satisifed), + 1: (_('True'), sla_violated), } def __init__(self, *args, **kwargs): @@ -1465,9 +1465,8 @@ class Meta: 'endpoints', 'references', 'thread_id', 'notes', 'scanner_confidence', 'numerical_severity', 'line', 'duplicate_finding', - 'hash_code', - 'reviewers', - 'created', 'files', 'sla_start_date', 'cvssv3', + 'hash_code', 'reviewers', 'created', 'files', + 'sla_start_date', 'sla_expiration_date', 'cvssv3', 'severity_justification', 'steps_to_reproduce'] def __init__(self, *args, **kwargs): diff --git a/dojo/forms.py b/dojo/forms.py index b544c09d05..558c09ae69 100755 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -263,6 +263,12 @@ def __init__(self, *args, **kwargs): super(ProductForm, self).__init__(*args, **kwargs) self.fields['prod_type'].queryset = get_authorized_product_types(Permissions.Product_Type_Add_Product) + # if this product has findings being asynchronously updated, disable the sla config field + if self.instance.async_updating: + self.fields['sla_configuration'].disabled = True + self.fields['sla_configuration'].widget.attrs['message'] = 'Finding SLA expiration dates are currently being recalculated. ' + \ + 'This field cannot be changed until the calculation is complete.' + class Meta: model = Product fields = ['name', 'description', 'tags', 'product_manager', 'technical_contact', 'team_manager', 'prod_type', 'sla_configuration', 'regulations', @@ -1073,7 +1079,7 @@ class AdHocFindingForm(forms.ModelForm): # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit field_order = ('title', 'date', 'cwe', 'vulnerability_ids', 'severity', 'cvssv3', 'description', 'mitigation', 'impact', 'request', 'response', 'steps_to_reproduce', 'severity_justification', 'endpoints', 'endpoints_to_add', 'references', 'active', 'verified', 'false_p', 'duplicate', 'out_of_scope', - 'risk_accepted', 'under_defect_review', 'sla_start_date') + 'risk_accepted', 'under_defect_review', 'sla_start_date', 'sla_expiration_date') def __init__(self, *args, **kwargs): req_resp = kwargs.pop('req_resp') @@ -1113,7 +1119,8 @@ def clean(self): class Meta: model = Finding exclude = ('reporter', 'url', 'numerical_severity', 'under_review', 'reviewers', 'cve', 'inherited_tags', - 'review_requested_by', 'is_mitigated', 'jira_creation', 'jira_change', 'endpoint_status', 'sla_start_date') + 'review_requested_by', 'is_mitigated', 'jira_creation', 'jira_change', 'endpoints', 'sla_start_date', + 'sla_expiration_date') class PromoteFindingForm(forms.ModelForm): @@ -1139,9 +1146,9 @@ class PromoteFindingForm(forms.ModelForm): references = forms.CharField(widget=forms.Textarea, required=False) # the onyl reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ('title', 'group', 'date', 'sla_start_date', 'cwe', 'vulnerability_ids', 'severity', 'cvssv3', 'cvssv3_score', 'description', 'mitigation', 'impact', - 'request', 'response', 'steps_to_reproduce', 'severity_justification', 'endpoints', 'endpoints_to_add', 'references', - 'active', 'mitigated', 'mitigated_by', 'verified', 'false_p', 'duplicate', + field_order = ('title', 'group', 'date', 'sla_start_date', 'sla_expiration_date', 'cwe', 'vulnerability_ids', 'severity', 'cvssv3', + 'cvssv3_score', 'description', 'mitigation', 'impact', 'request', 'response', 'steps_to_reproduce', 'severity_justification', + 'endpoints', 'endpoints_to_add', 'references', 'active', 'mitigated', 'mitigated_by', 'verified', 'false_p', 'duplicate', 'out_of_scope', 'risk_accept', 'under_defect_review') def __init__(self, *args, **kwargs): @@ -1211,9 +1218,9 @@ class FindingForm(forms.ModelForm): 'invalid_choice': EFFORT_FOR_FIXING_INVALID_CHOICE}) # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ('title', 'group', 'date', 'sla_start_date', 'cwe', 'vulnerability_ids', 'severity', 'cvssv3', 'cvssv3_score', 'description', 'mitigation', 'impact', - 'request', 'response', 'steps_to_reproduce', 'severity_justification', 'endpoints', 'endpoints_to_add', 'references', - 'active', 'mitigated', 'mitigated_by', 'verified', 'false_p', 'duplicate', + field_order = ('title', 'group', 'date', 'sla_start_date', 'sla_expiration_date', 'cwe', 'vulnerability_ids', 'severity', 'cvssv3', + 'cvssv3_score', 'description', 'mitigation', 'impact', 'request', 'response', 'steps_to_reproduce', 'severity_justification', + 'endpoints', 'endpoints_to_add', 'references', 'active', 'mitigated', 'mitigated_by', 'verified', 'false_p', 'duplicate', 'out_of_scope', 'risk_accept', 'under_defect_review') def __init__(self, *args, **kwargs): @@ -1251,6 +1258,7 @@ def __init__(self, *args, **kwargs): self.fields['duplicate'].help_text = "You can mark findings as duplicate only from the view finding page." self.fields['sla_start_date'].disabled = True + self.fields['sla_expiration_date'].disabled = True if self.can_edit_mitigated_data: if hasattr(self, 'instance'): @@ -2436,6 +2444,22 @@ def clean(self): class SLAConfigForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super(SLAConfigForm, self).__init__(*args, **kwargs) + + # if this sla config has findings being asynchronously updated, disable the days by severity fields + if self.instance.async_updating: + msg = 'Finding SLA expiration dates are currently being recalculated. ' + \ + 'This field cannot be changed until the calculation is complete.' + self.fields['critical'].disabled = True + self.fields['critical'].widget.attrs['message'] = msg + self.fields['high'].disabled = True + self.fields['high'].widget.attrs['message'] = msg + self.fields['medium'].disabled = True + self.fields['medium'].widget.attrs['message'] = msg + self.fields['low'].disabled = True + self.fields['low'].widget.attrs['message'] = msg + class Meta: model = SLA_Configuration fields = ['name', 'description', 'critical', 'high', 'medium', 'low'] diff --git a/dojo/models.py b/dojo/models.py index 64fc813716..7bda3997c0 100755 --- a/dojo/models.py +++ b/dojo/models.py @@ -31,6 +31,7 @@ from django import forms from django.utils.translation import gettext as _ from dateutil.relativedelta import relativedelta +from datetime import datetime from tagulous.models import TagField from tagulous.models.managers import FakeTagRelatedManager import tagulous.admin @@ -856,9 +857,7 @@ class Meta: class SLA_Configuration(models.Model): name = models.CharField(max_length=128, unique=True, blank=False, verbose_name=_('Custom SLA Name'), - help_text=_('A unique name for the set of SLAs.') - ) - + help_text=_('A unique name for the set of SLAs.')) description = models.CharField(max_length=512, null=True, blank=True) critical = models.IntegerField(default=7, verbose_name=_('Critical Finding SLA Days'), help_text=_('number of days to remediate a critical finding.')) @@ -868,15 +867,56 @@ class SLA_Configuration(models.Model): help_text=_('number of days to remediate a medium finding.')) low = models.IntegerField(default=120, verbose_name=_('Low Finding SLA Days'), help_text=_('number of days to remediate a low finding.')) + async_updating = models.BooleanField(default=False, + help_text=_('Findings under this SLA configuration are asynchronously being updated')) def clean(self): - sla_days = [self.critical, self.high, self.medium, self.low] for sla_day in sla_days: if sla_day < 1: raise ValidationError('SLA Days must be at least 1') + def save(self, *args, **kwargs): + # get the initial sla config before saving (if this is an existing sla config) + initial_sla_config = None + if self.pk is not None: + initial_sla_config = SLA_Configuration.objects.get(pk=self.pk) + # if initial config exists and async finding update is already running, revert sla config before saving + if initial_sla_config and self.async_updating: + self.critical = initial_sla_config.critical + self.high = initial_sla_config.high + self.medium = initial_sla_config.medium + self.low = initial_sla_config.low + + super(SLA_Configuration, self).save(*args, **kwargs) + + # if the initial sla config exists and async finding update is not running + if initial_sla_config is not None and not self.async_updating: + # check which sla days fields changed based on severity + severities = [] + if initial_sla_config.critical != self.critical: + severities.append('Critical') + if initial_sla_config.high != self.high: + severities.append('High') + if initial_sla_config.medium != self.medium: + severities.append('Medium') + if initial_sla_config.low != self.low: + severities.append('Low') + # if severities have changed, update finding sla expiration dates with those severities + if len(severities): + # set the async updating flag to true for this sla config + self.async_updating = True + super(SLA_Configuration, self).save(*args, **kwargs) + # set the async updating flag to true for all products using this sla config + products = Product.objects.filter(sla_configuration=self) + for product in products: + product.async_updating = True + super(Product, product).save() + # launch the async task to update all finding sla expiration dates + from dojo.sla_config.helpers import update_sla_expiration_dates_sla_config_async + update_sla_expiration_dates_sla_config_async(self, tuple(severities), products) + def __str__(self): return self.name @@ -998,6 +1038,37 @@ class Product(models.Model): blank=False, verbose_name=_("Disable SLA breach notifications"), help_text=_("Disable SLA breach notifications if configured in the global settings")) + async_updating = models.BooleanField(default=False, + help_text=_('Findings under this Product or SLA configuration are asynchronously being updated')) + + def save(self, *args, **kwargs): + # get the product's sla config before saving (if this is an existing product) + initial_sla_config = None + if self.pk is not None: + initial_sla_config = getattr(Product.objects.get(pk=self.pk), 'sla_configuration', None) + # if initial sla config exists and async finding update is already running, revert sla config before saving + if initial_sla_config and self.async_updating: + self.sla_configuration = initial_sla_config + + super(Product, self).save(*args, **kwargs) + + # if the initial sla config exists and async finding update is not running + if initial_sla_config is not None and not self.async_updating: + # get the new sla config from the saved product + new_sla_config = getattr(self, 'sla_configuration', None) + # if the sla config has changed, update finding sla expiration dates within this product + if new_sla_config and (initial_sla_config != new_sla_config): + # set the async updating flag to true for this product + self.async_updating = True + super(Product, self).save(*args, **kwargs) + # set the async updating flag to true for the sla config assigned to this product + sla_config = getattr(self, 'sla_configuration', None) + if sla_config: + sla_config.async_updating = True + super(SLA_Configuration, sla_config).save() + # launch the async task to update all finding sla expiration dates + from dojo.product.helpers import update_sla_expiration_dates_product_async + update_sla_expiration_dates_product_async(self, sla_config) def __str__(self): return self.name @@ -1123,8 +1194,7 @@ def get_absolute_url(self): @property def violates_sla(self): - findings = Finding.objects.filter(test__engagement__product=self, - active=True) + findings = Finding.objects.filter(test__engagement__product=self, active=True) for f in findings: if f.violates_sla: return True @@ -2110,20 +2180,22 @@ def __str__(self): class Finding(models.Model): - title = models.CharField(max_length=511, verbose_name=_('Title'), help_text=_("A short description of the flaw.")) date = models.DateField(default=get_current_date, verbose_name=_('Date'), help_text=_("The date the flaw was discovered.")) - sla_start_date = models.DateField( blank=True, null=True, verbose_name=_('SLA Start Date'), help_text=_("(readonly)The date used as start date for SLA calculation. Set by expiring risk acceptances. Empty by default, causing a fallback to 'date'.")) - + sla_expiration_date = models.DateField( + blank=True, + null=True, + verbose_name=_('SLA Expiration Date'), + help_text=_("(readonly)The date SLA expires for this finding. Empty by default, causing a fallback to 'date'.")) cwe = models.IntegerField(default=0, null=True, blank=True, verbose_name=_("CWE"), help_text=_("The CWE number associated with this flaw.")) @@ -2750,19 +2822,28 @@ def status(self): return ", ".join([str(s) for s in status]) def _age(self, start_date): + from dateutil.parser import parse + if start_date and isinstance(start_date, str): + start_date = parse(start_date).date() + from dojo.utils import get_work_days if settings.SLA_BUSINESS_DAYS: if self.mitigated: - days = get_work_days(self.date, self.mitigated.date()) + mitigated_date = self.mitigated + if isinstance(mitigated_date, datetime): + mitigated_date = self.mitigated.date() + days = get_work_days(self.date, mitigated_date) else: days = get_work_days(self.date, get_current_date()) else: - from datetime import datetime if isinstance(start_date, datetime): start_date = start_date.date() if self.mitigated: - diff = self.mitigated.date() - start_date + mitigated_date = self.mitigated + if isinstance(mitigated_date, datetime): + mitigated_date = self.mitigated.date() + diff = mitigated_date - start_date else: diff = get_current_date() - start_date days = diff.days @@ -2772,9 +2853,9 @@ def _age(self, start_date): def age(self): return self._age(self.date) - def get_sla_periods(self): - sla_configuration = SLA_Configuration.objects.filter(id=self.test.engagement.product.sla_configuration_id).first() - return sla_configuration + @property + def sla_age(self): + return self._age(self.get_sla_start_date()) def get_sla_start_date(self): if self.sla_start_date: @@ -2782,16 +2863,34 @@ def get_sla_start_date(self): else: return self.date - @property - def sla_age(self): - return self._age(self.get_sla_start_date()) + def get_sla_period(self): + sla_configuration = SLA_Configuration.objects.filter(id=self.test.engagement.product.sla_configuration_id).first() + return getattr(sla_configuration, self.severity.lower(), None) + + def set_sla_expiration_date(self): + system_settings = System_Settings.objects.get() + if not system_settings.enable_finding_sla: + return None + + days_remaining = None + sla_period = self.get_sla_period() + if sla_period: + days_remaining = sla_period - self.sla_age + + if days_remaining: + if self.mitigated: + mitigated_date = self.mitigated + if isinstance(mitigated_date, datetime): + mitigated_date = self.mitigated.date() + self.sla_expiration_date = mitigated_date + relativedelta(days=days_remaining) + else: + self.sla_expiration_date = get_current_date() + relativedelta(days=days_remaining) def sla_days_remaining(self): sla_calculation = None - sla_periods = self.get_sla_periods() - sla_age = getattr(sla_periods, self.severity.lower(), None) - if sla_age: - sla_calculation = sla_age - self.sla_age + sla_period = self.get_sla_period() + if sla_period: + sla_calculation = sla_period - self.sla_age return sla_calculation def sla_deadline(self): @@ -2923,6 +3022,9 @@ def save(self, dedupe_option=True, rules_option=True, product_grading_option=Tru elif (self.file_path is not None): self.static_finding = True + # update the SLA expiration date last, after all other finding fields have been updated + self.set_sla_expiration_date() + logger.debug("Saving finding of id " + str(self.id) + " dedupe_option:" + str(dedupe_option) + " (self.pk is %s)", "None" if self.pk is None else "not None") super(Finding, self).save(*args, **kwargs) diff --git a/dojo/product/helpers.py b/dojo/product/helpers.py index c2d3f634ae..74530744cd 100644 --- a/dojo/product/helpers.py +++ b/dojo/product/helpers.py @@ -1,11 +1,31 @@ import contextlib -from celery.utils.log import get_task_logger +import logging from dojo.celery import app -from dojo.models import Product, Engagement, Test, Finding, Endpoint +from dojo.models import SLA_Configuration, Product, Engagement, Test, Finding, Endpoint from dojo.decorators import dojo_async_task -logger = get_task_logger(__name__) +logger = logging.getLogger(__name__) + + +@dojo_async_task +@app.task +def update_sla_expiration_dates_product_async(product, sla_config, *args, **kwargs): + update_sla_expiration_dates_product_sync(product, sla_config) + + +def update_sla_expiration_dates_product_sync(product, sla_config): + logger.info(f"Updating finding SLA expiration dates within product {product}") + # update each finding that is within the SLA configuration that was saved + for f in Finding.objects.filter(test__engagement__product=product): + f.save() + # reset the async updating flag to false for the sla config assigned to this product + if sla_config: + sla_config.async_updating = False + super(SLA_Configuration, sla_config).save() + # set the async updating flag to false for the sla config assigned to this product + product.async_updating = False + super(Product, product).save() @dojo_async_task diff --git a/dojo/product/views.py b/dojo/product/views.py index be1b9afe0c..c2dc16098c 100755 --- a/dojo/product/views.py +++ b/dojo/product/views.py @@ -873,10 +873,15 @@ def edit_product(request, pid): form = ProductForm(request.POST, instance=product) jira_project = jira_helper.get_jira_project(product) if form.is_valid(): + initial_sla_config = Product.objects.get(pk=form.instance.id).sla_configuration form.save() + msg = 'Product updated successfully.' + # check if the SLA config was changed, append additional context to message + if initial_sla_config != form.instance.sla_configuration: + msg += ' All SLA expiration dates for findings within this product will be recalculated asynchronously for the newly assigned SLA configuration.' messages.add_message(request, messages.SUCCESS, - _('Product updated successfully.'), + _(msg), extra_tags='alert-success') success, jform = jira_helper.process_jira_project_form(request, instance=jira_project, product=product) diff --git a/dojo/sla_config/helpers.py b/dojo/sla_config/helpers.py new file mode 100644 index 0000000000..e9665adce4 --- /dev/null +++ b/dojo/sla_config/helpers.py @@ -0,0 +1,26 @@ +import logging +from dojo.models import SLA_Configuration, Product, Finding +from dojo.celery import app +from dojo.decorators import dojo_async_task + +logger = logging.getLogger(__name__) + + +@dojo_async_task +@app.task +def update_sla_expiration_dates_sla_config_async(sla_config, severities, products, *args, **kwargs): + update_sla_expiration_dates_sla_config_sync(sla_config, severities, products) + + +def update_sla_expiration_dates_sla_config_sync(sla_config, severities, products): + logger.info(f"Updating finding SLA expiration dates within the {sla_config} SLA configuration") + # update each finding that is within the SLA configuration that was saved + for f in Finding.objects.filter(test__engagement__product__sla_configuration_id=sla_config.id, severity__in=severities): + f.save() + # reset the async updating flag to false for all products using this sla config + for product in products: + product.async_updating = False + super(Product, product).save() + # reset the async updating flag to false for this sla config + sla_config.async_updating = False + super(SLA_Configuration, sla_config).save() diff --git a/dojo/sla_config/views.py b/dojo/sla_config/views.py index f247cd7725..e85b06ea8f 100644 --- a/dojo/sla_config/views.py +++ b/dojo/sla_config/views.py @@ -8,7 +8,7 @@ from dojo.authorization.authorization import user_has_configuration_permission_or_403 from dojo.authorization.authorization_decorators import user_is_configuration_authorized from dojo.forms import SLAConfigForm -from dojo.models import SLA_Configuration, System_Settings +from dojo.models import SLA_Configuration, System_Settings, Product from dojo.utils import add_breadcrumb logger = logging.getLogger(__name__) @@ -41,13 +41,20 @@ def edit_sla_config(request, slaid): if request.method == 'POST' and request.POST.get('delete'): if sla_config.id != 1: - user_has_configuration_permission_or_403( - request.user, 'dojo.delete_sla_configuration') - sla_config.delete() - messages.add_message(request, - messages.SUCCESS, - 'SLA Configuration Deleted.', - extra_tags='alert-success') + if Product.objects.filter(sla_configuration=sla_config).count(): + msg = f"The \"{sla_config}\" SLA configuration could not be deleted, as it is currently in use by one or more products." + messages.add_message(request, + messages.ERROR, + msg, + extra_tags='alert-warning') + else: + user_has_configuration_permission_or_403( + request.user, 'dojo.delete_sla_configuration') + sla_config.delete() + messages.add_message(request, + messages.SUCCESS, + 'SLA Configuration Deleted.', + extra_tags='alert-success') return HttpResponseRedirect(reverse('sla_config', )) else: messages.add_message(request, @@ -59,12 +66,12 @@ def edit_sla_config(request, slaid): elif request.method == 'POST': form = SLAConfigForm(request.POST, instance=sla_config) if form.is_valid(): - form.save() + form.save(commit=True) messages.add_message(request, messages.SUCCESS, - 'SLA configuration successfully updated.', + 'SLA configuration successfully updated. All SLA expiration dates for findings within this SLA configuration will be recalculated asynchronously.', extra_tags='alert-success') - form.save(commit=True) + return HttpResponseRedirect(reverse('sla_config', )) else: form = SLAConfigForm(instance=sla_config) diff --git a/dojo/templates/dojo/form_fields.html b/dojo/templates/dojo/form_fields.html index fe2b949162..98706ee46d 100644 --- a/dojo/templates/dojo/form_fields.html +++ b/dojo/templates/dojo/form_fields.html @@ -73,8 +73,7 @@ {% endif %}
{{ field.field.widget.attrs.message }}
{% for error in field.errors %} {{ error }} {% endfor %} diff --git a/dojo/templatetags/display_tags.py b/dojo/templatetags/display_tags.py index d0251eaad4..0095428194 100644 --- a/dojo/templatetags/display_tags.py +++ b/dojo/templatetags/display_tags.py @@ -255,7 +255,7 @@ def finding_sla(finding): title = "" severity = finding.severity find_sla = finding.sla_days_remaining() - sla_age = getattr(finding.get_sla_periods(), severity.lower(), None) + sla_age = finding.get_sla_period() if finding.mitigated: status = "blue" status_text = 'Remediated within SLA for ' + severity.lower() + ' findings (' + str(sla_age) + ' days since ' + finding.get_sla_start_date().strftime("%b %d, %Y") + ')' diff --git a/dojo/utils.py b/dojo/utils.py index eac65b08a4..135d341e54 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -1547,7 +1547,7 @@ def calculate_grade(product, *args, **kwargs): grade_product = "grade_product(%s, %s, %s, %s)" % ( critical, high, medium, low) product.prod_numeric_grade = aeval(grade_product) - product.save() + super(Product, product).save() def get_celery_worker_status(): diff --git a/unittests/dojo_test_case.py b/unittests/dojo_test_case.py index c5165febba..e6f0b19fce 100644 --- a/unittests/dojo_test_case.py +++ b/unittests/dojo_test_case.py @@ -18,7 +18,7 @@ from dojo.models import (SEVERITIES, DojoMeta, Endpoint, Endpoint_Status, Engagement, Finding, JIRA_Issue, JIRA_Project, Notes, Product, Product_Type, System_Settings, Test, - Test_Type, User) + SLA_Configuration, Test_Type, User) logger = logging.getLogger(__name__) @@ -53,6 +53,11 @@ def create_product_type(self, name, *args, description='dummy description', **kw product_type.save() return product_type + def create_sla_configuration(self, name, *args, description='dummy description', critical=7, high=30, medium=60, low=120, **kwargs): + sla_configuration = SLA_Configuration(name=name, description=description, critical=critical, high=high, medium=medium, low=low) + sla_configuration.save() + return sla_configuration + def create_product(self, name, *args, description='dummy description', prod_type=None, tags=[], **kwargs): if not prod_type: prod_type = Product_Type.objects.first() diff --git a/unittests/test_finding_model.py b/unittests/test_finding_model.py index ca7494142e..e6053dcd91 100644 --- a/unittests/test_finding_model.py +++ b/unittests/test_finding_model.py @@ -1,5 +1,7 @@ from .dojo_test_case import DojoTestCase -from dojo.models import Finding, Test, Engagement, DojoMeta +from dojo.models import User, Finding, Test, Engagement, DojoMeta +from datetime import datetime, timedelta +from crum import impersonate class TestFindingModel(DojoTestCase): @@ -262,3 +264,147 @@ def test_get_references_with_links_markdown(self): finding = Finding() finding.references = 'URL: [https://www.example.com](https://www.example.com)' self.assertEqual('URL: [https://www.example.com](https://www.example.com)', finding.get_references_with_links()) + + +class TestFindingSLAExpiration(DojoTestCase): + fixtures = ['dojo_testdata.json'] + + def run(self, result=None): + testuser = User.objects.get(username='admin') + testuser.usercontactinfo.block_execution = True + testuser.save() + + # unit tests are running without any user, which will result in actions like dedupe happening in the celery process + # this doesn't work in unittests as unittests are using an in memory sqlite database and celery can't see the data + # so we're running the test under the admin user context and set block_execution to True + with impersonate(testuser): + super().run(result) + + def test_sla_expiration_date(self): + """ + tests if the SLA expiration date and SLA days remaining are calculated correctly + after a finding's severity is updated + """ + user, _ = User.objects.get_or_create(username='admin') + product_type = self.create_product_type('test_product_type') + sla_config = self.create_sla_configuration(name='test_sla_config') + product = self.create_product(name='test_product', prod_type=product_type) + product.sla_configuration = sla_config + product.save() + engagement = self.create_engagement('test_eng', product) + test = self.create_test(engagement=engagement, scan_type='ZAP Scan', title='test_test') + finding = Finding.objects.create( + test=test, + reporter=user, + title='test_finding', + severity='Critical', + date=datetime.now().date()) + finding.set_sla_expiration_date() + + expected_sla_days = getattr(product.sla_configuration, finding.severity.lower(), None) + self.assertEqual(finding.sla_expiration_date, datetime.now().date() + timedelta(days=expected_sla_days)) + self.assertEqual(finding.sla_days_remaining(), expected_sla_days) + + def test_sla_expiration_date_after_finding_severity_updated(self): + """ + tests if the SLA expiration date and SLA days remaining are calculated correctly + after a finding's severity is updated + """ + user, _ = User.objects.get_or_create(username='admin') + product_type = self.create_product_type('test_product_type') + sla_config = self.create_sla_configuration(name='test_sla_config') + product = self.create_product(name='test_product', prod_type=product_type) + product.sla_configuration = sla_config + product.save() + engagement = self.create_engagement('test_eng', product) + test = self.create_test(engagement=engagement, scan_type='ZAP Scan', title='test_test') + finding = Finding.objects.create( + test=test, + reporter=user, + title='test_finding', + severity='Critical', + date=datetime.now().date()) + finding.set_sla_expiration_date() + + expected_sla_days = getattr(product.sla_configuration, finding.severity.lower(), None) + self.assertEqual(finding.sla_expiration_date, datetime.now().date() + timedelta(days=expected_sla_days)) + self.assertEqual(finding.sla_days_remaining(), expected_sla_days) + + finding.severity = 'Medium' + finding.set_sla_expiration_date() + + expected_sla_days = getattr(product.sla_configuration, finding.severity.lower(), None) + self.assertEqual(finding.sla_expiration_date, datetime.now().date() + timedelta(days=expected_sla_days)) + self.assertEqual(finding.sla_days_remaining(), expected_sla_days) + + def test_sla_expiration_date_after_product_updated(self): + """ + tests if the SLA expiration date and SLA days remaining are calculated correctly + after a product changed from one SLA configuration to another + """ + user, _ = User.objects.get_or_create(username='admin') + product_type = self.create_product_type('test_product_type') + sla_config_1 = self.create_sla_configuration(name='test_sla_config_1') + sla_config_2 = self.create_sla_configuration( + name='test_sla_config_2', + critical=1, + high=2, + medium=3, + low=4) + product = self.create_product(name='test_product', prod_type=product_type) + product.sla_configuration = sla_config_1 + product.save() + engagement = self.create_engagement('test_eng', product) + test = self.create_test(engagement=engagement, scan_type='ZAP Scan', title='test_test') + finding = Finding.objects.create( + test=test, + reporter=user, + title='test_finding', + severity='Critical', + date=datetime.now().date()) + + expected_sla_days = getattr(product.sla_configuration, finding.severity.lower(), None) + self.assertEqual(finding.sla_expiration_date, datetime.now().date() + timedelta(days=expected_sla_days)) + self.assertEqual(finding.sla_days_remaining(), expected_sla_days) + + product.sla_configuration = sla_config_2 + product.save() + + finding.set_sla_expiration_date() + + expected_sla_days = getattr(product.sla_configuration, finding.severity.lower(), None) + self.assertEqual(finding.sla_expiration_date, datetime.now().date() + timedelta(days=expected_sla_days)) + self.assertEqual(finding.sla_days_remaining(), expected_sla_days) + + def test_sla_expiration_date_after_sla_configuration_updated(self): + """ + tests if the SLA expiration date and SLA days remaining are calculated correctly + after the SLA configuration on a product was updated to a different number of SLA days + """ + user, _ = User.objects.get_or_create(username='admin') + product_type = self.create_product_type('test_product_type') + sla_config = self.create_sla_configuration(name='test_sla_config') + product = self.create_product(name='test_product', prod_type=product_type) + product.sla_configuration = sla_config + product.save() + engagement = self.create_engagement('test_eng', product) + test = self.create_test(engagement=engagement, scan_type='ZAP Scan', title='test_test') + finding = Finding.objects.create( + test=test, + reporter=user, + title='test_finding', + severity='Critical', + date=datetime.now().date()) + + expected_sla_days = getattr(product.sla_configuration, finding.severity.lower(), None) + self.assertEqual(finding.sla_expiration_date, datetime.now().date() + timedelta(days=expected_sla_days)) + self.assertEqual(finding.sla_days_remaining(), expected_sla_days) + + sla_config.critical = 10 + sla_config.save() + + finding.set_sla_expiration_date() + + expected_sla_days = getattr(product.sla_configuration, finding.severity.lower(), None) + self.assertEqual(finding.sla_expiration_date, datetime.now().date() + timedelta(days=expected_sla_days)) + self.assertEqual(finding.sla_days_remaining(), expected_sla_days) diff --git a/unittests/tools/test_veracode_parser.py b/unittests/tools/test_veracode_parser.py index 878629e6b7..55799e9cf8 100644 --- a/unittests/tools/test_veracode_parser.py +++ b/unittests/tools/test_veracode_parser.py @@ -156,8 +156,6 @@ def parse_file_with_mitigated_finding(self): self.assertEqual(datetime.datetime(2020, 6, 1, 10, 2, 1), finding.mitigated) self.assertEqual("app-1234_issue-1", finding.unique_id_from_tool) self.assertEqual(0, finding.sla_age) - self.assertEqual(90, finding.sla_days_remaining()) - self.assertEqual((datetime.datetime(2020, 6, 1, 10, 2, 1) + datetime.timedelta(days=90)).date(), finding.sla_deadline()) @override_settings(USE_FIRST_SEEN=True) def test_parse_file_with_mitigated_fixed_finding_first_seen(self):