diff --git a/surface/inventory/admin.py b/surface/inventory/admin.py index 810fe13b..f9c35f1d 100644 --- a/surface/inventory/admin.py +++ b/surface/inventory/admin.py @@ -1,5 +1,8 @@ from django.contrib import admin from django.contrib.admin.decorators import register +from django.urls import reverse +from django.utils.html import format_html + from . import models @@ -8,3 +11,29 @@ class ApplicationAdmin(admin.ModelAdmin): """ empty for now """ + + +@admin.register(models.Finding) +class FindingAdmin(admin.ModelAdmin): + list_select_related = ['content_source'] + + def get_list_display(self, request): + l = super().get_list_display(request).copy() + l.insert(1, 'get_content_source') + return l + + def get_readonly_fields(self, request, obj=None): + l = super().get_readonly_fields(request, obj=obj).copy() + l.insert(1, 'get_content_source') + return l + + def get_content_source(self, obj): + meta = obj.content_source.model_class()._meta + return format_html( + '{}', + reverse(f'admin:{meta.app_label}_{meta.model_name}_change', args=(obj.pk,)), + f'{meta.app_label}: {meta.verbose_name}', + ) + + get_content_source.short_description = 'Content Source' + get_content_source.admin_order_field = 'content_source' diff --git a/surface/inventory/migrations/0002_finding.py b/surface/inventory/migrations/0002_finding.py new file mode 100644 index 00000000..4a820e3b --- /dev/null +++ b/surface/inventory/migrations/0002_finding.py @@ -0,0 +1,63 @@ +# Generated by Django 3.2.18 on 2023-04-10 21:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('inventory', '0001_initial_20211102'), + ] + + operations = [ + migrations.CreateModel( + name='Finding', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.TextField(blank=True)), + ('summary', models.TextField(blank=True, null=True)), + ( + 'severity', + models.IntegerField( + blank=True, + choices=[(1, 'Informative'), (2, 'Low'), (3, 'Medium'), (4, 'High'), (5, 'Critical')], + db_index=True, + null=True, + ), + ), + ( + 'state', + models.IntegerField( + choices=[(1, 'New'), (2, 'Open'), (3, 'Closed'), (4, 'Resolved')], db_index=True, default=1 + ), + ), + ('first_seen', models.DateTimeField(auto_now_add=True)), + ('last_seen_date', models.DateTimeField(blank=True, null=True)), + ( + 'application', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to='inventory.application', + verbose_name='Application', + ), + ), + ( + 'content_source', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), + ), + ( + 'related_to', + models.ManyToManyField( + blank=True, + help_text='Other findings related to this one', + related_name='_inventory_finding_related_to_+', + to='inventory.Finding', + ), + ), + ], + ), + ] diff --git a/surface/inventory/models.py b/surface/inventory/models.py index f549f9ef..51f5dcc2 100644 --- a/surface/inventory/models.py +++ b/surface/inventory/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.contrib.contenttypes import models as ct_models class Person(models.Model): @@ -12,3 +13,72 @@ class Application(models.Model): director = models.ForeignKey('Person', blank=True, null=True, on_delete=models.SET_NULL, related_name='+') director_direct = models.ForeignKey('Person', blank=True, null=True, on_delete=models.SET_NULL, related_name='+') dev_lead = models.ForeignKey('Person', blank=True, null=True, on_delete=models.SET_NULL, related_name='+') + + +class FindingInheritanceQS(models.QuerySet): + def get_children(self) -> list: + return [ + getattr(m, m.content_source.model) + for m in self.prefetch_related("content_source__model__finding_ptr").select_related('content_source') + ] + + +class Finding(models.Model): + class Severity(models.IntegerChoices): + INFORMATIVE = 1 + LOW = 2 + MEDIUM = 3 + HIGH = 4 + CRITICAL = 5 + + class State(models.IntegerChoices): + """ + States represent a point in the workflow. + States are not Status. + Do not add a state if the transitions for that state are the same as an existing one. + """ + + # to be reviewed by Security Testing: NEW -> OPEN/CLOSED + NEW = 1 + # viewed by the teams, included in score: OPEN -> CLOSED + OPEN = 2 + # no score, nothing to do. Final state. + CLOSED = 3 + # resolved/mitigated, can be re-open: RESOLVED -> NEW/OPEN + RESOLVED = 4 + + content_source = models.ForeignKey(ct_models.ContentType, on_delete=models.CASCADE) + + title = models.TextField(blank=True) + summary = models.TextField(null=True, blank=True) + severity = models.IntegerField(null=True, blank=True, choices=Severity.choices, db_index=True) + state = models.IntegerField(choices=State.choices, default=State.NEW, db_index=True) + + first_seen = models.DateTimeField(auto_now_add=True) + last_seen_date = models.DateTimeField(blank=True, null=True) + + application = models.ForeignKey( + 'inventory.Application', blank=True, null=True, on_delete=models.SET_NULL, verbose_name="Application" + ) + + related_to = models.ManyToManyField('self', blank=True, help_text='Other findings related to this one') + + objects = FindingInheritanceQS.as_manager() + + def __init__(self, *args, **kwargs): + if 'content_source' not in kwargs: + kwargs['content_source'] = self.content_type() + super().__init__(*args, **kwargs) + + @classmethod + def content_type(cls): + return ct_models.ContentType.objects.get_for_model(cls) + + @property + def cached_content_source(self): + if self.content_source_id is not None and not Finding.content_source.is_cached(self): + self.content_source = ct_models.ContentType.objects.get_for_id(self.content_source_id) + return self.content_source + + def __str__(self): + return f'{self.pk} [{self.cached_content_source.app_label}] - {self.title}'