From 18ee8945d243236e1356f9b50396fb7730d8b54c Mon Sep 17 00:00:00 2001 From: Gustavo Silva Date: Mon, 10 Apr 2023 20:08:36 +0100 Subject: [PATCH 1/2] feature: add Finding to Surface `Finding` is a key model in Surface to allow others to bootstrap their own Vulnerability Management program. With this simple implementation, users can create classes in other apps that inherit this one, centralizing vulnerabilities and risks from all other apps of their own implementation. Examples to be posted in the Wiki. --- surface/inventory/admin.py | 29 +++++++++++++++ surface/inventory/models.py | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) 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/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}' From d468ea4797f6e536d9303fceaefcc5b53c59200d Mon Sep 17 00:00:00 2001 From: Gustavo Silva Date: Mon, 10 Apr 2023 22:45:16 +0100 Subject: [PATCH 2/2] fixup: add missing migration --- surface/inventory/migrations/0002_finding.py | 63 ++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 surface/inventory/migrations/0002_finding.py 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', + ), + ), + ], + ), + ]