Skip to content

Commit 825e832

Browse files
authored
feature: add Finding to Surface (#110)
* 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. * fixup: add missing migration
1 parent f455a07 commit 825e832

File tree

3 files changed

+162
-0
lines changed

3 files changed

+162
-0
lines changed

surface/inventory/admin.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from django.contrib import admin
22
from django.contrib.admin.decorators import register
3+
from django.urls import reverse
4+
from django.utils.html import format_html
5+
36
from . import models
47

58

@@ -8,3 +11,29 @@ class ApplicationAdmin(admin.ModelAdmin):
811
"""
912
empty for now
1013
"""
14+
15+
16+
@admin.register(models.Finding)
17+
class FindingAdmin(admin.ModelAdmin):
18+
list_select_related = ['content_source']
19+
20+
def get_list_display(self, request):
21+
l = super().get_list_display(request).copy()
22+
l.insert(1, 'get_content_source')
23+
return l
24+
25+
def get_readonly_fields(self, request, obj=None):
26+
l = super().get_readonly_fields(request, obj=obj).copy()
27+
l.insert(1, 'get_content_source')
28+
return l
29+
30+
def get_content_source(self, obj):
31+
meta = obj.content_source.model_class()._meta
32+
return format_html(
33+
'<a href="{}">{}</a>',
34+
reverse(f'admin:{meta.app_label}_{meta.model_name}_change', args=(obj.pk,)),
35+
f'{meta.app_label}: {meta.verbose_name}',
36+
)
37+
38+
get_content_source.short_description = 'Content Source'
39+
get_content_source.admin_order_field = 'content_source'
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Generated by Django 3.2.18 on 2023-04-10 21:38
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('contenttypes', '0002_remove_content_type_name'),
11+
('inventory', '0001_initial_20211102'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='Finding',
17+
fields=[
18+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('title', models.TextField(blank=True)),
20+
('summary', models.TextField(blank=True, null=True)),
21+
(
22+
'severity',
23+
models.IntegerField(
24+
blank=True,
25+
choices=[(1, 'Informative'), (2, 'Low'), (3, 'Medium'), (4, 'High'), (5, 'Critical')],
26+
db_index=True,
27+
null=True,
28+
),
29+
),
30+
(
31+
'state',
32+
models.IntegerField(
33+
choices=[(1, 'New'), (2, 'Open'), (3, 'Closed'), (4, 'Resolved')], db_index=True, default=1
34+
),
35+
),
36+
('first_seen', models.DateTimeField(auto_now_add=True)),
37+
('last_seen_date', models.DateTimeField(blank=True, null=True)),
38+
(
39+
'application',
40+
models.ForeignKey(
41+
blank=True,
42+
null=True,
43+
on_delete=django.db.models.deletion.SET_NULL,
44+
to='inventory.application',
45+
verbose_name='Application',
46+
),
47+
),
48+
(
49+
'content_source',
50+
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'),
51+
),
52+
(
53+
'related_to',
54+
models.ManyToManyField(
55+
blank=True,
56+
help_text='Other findings related to this one',
57+
related_name='_inventory_finding_related_to_+',
58+
to='inventory.Finding',
59+
),
60+
),
61+
],
62+
),
63+
]

surface/inventory/models.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.db import models
2+
from django.contrib.contenttypes import models as ct_models
23

34

45
class Person(models.Model):
@@ -12,3 +13,72 @@ class Application(models.Model):
1213
director = models.ForeignKey('Person', blank=True, null=True, on_delete=models.SET_NULL, related_name='+')
1314
director_direct = models.ForeignKey('Person', blank=True, null=True, on_delete=models.SET_NULL, related_name='+')
1415
dev_lead = models.ForeignKey('Person', blank=True, null=True, on_delete=models.SET_NULL, related_name='+')
16+
17+
18+
class FindingInheritanceQS(models.QuerySet):
19+
def get_children(self) -> list:
20+
return [
21+
getattr(m, m.content_source.model)
22+
for m in self.prefetch_related("content_source__model__finding_ptr").select_related('content_source')
23+
]
24+
25+
26+
class Finding(models.Model):
27+
class Severity(models.IntegerChoices):
28+
INFORMATIVE = 1
29+
LOW = 2
30+
MEDIUM = 3
31+
HIGH = 4
32+
CRITICAL = 5
33+
34+
class State(models.IntegerChoices):
35+
"""
36+
States represent a point in the workflow.
37+
States are not Status.
38+
Do not add a state if the transitions for that state are the same as an existing one.
39+
"""
40+
41+
# to be reviewed by Security Testing: NEW -> OPEN/CLOSED
42+
NEW = 1
43+
# viewed by the teams, included in score: OPEN -> CLOSED
44+
OPEN = 2
45+
# no score, nothing to do. Final state.
46+
CLOSED = 3
47+
# resolved/mitigated, can be re-open: RESOLVED -> NEW/OPEN
48+
RESOLVED = 4
49+
50+
content_source = models.ForeignKey(ct_models.ContentType, on_delete=models.CASCADE)
51+
52+
title = models.TextField(blank=True)
53+
summary = models.TextField(null=True, blank=True)
54+
severity = models.IntegerField(null=True, blank=True, choices=Severity.choices, db_index=True)
55+
state = models.IntegerField(choices=State.choices, default=State.NEW, db_index=True)
56+
57+
first_seen = models.DateTimeField(auto_now_add=True)
58+
last_seen_date = models.DateTimeField(blank=True, null=True)
59+
60+
application = models.ForeignKey(
61+
'inventory.Application', blank=True, null=True, on_delete=models.SET_NULL, verbose_name="Application"
62+
)
63+
64+
related_to = models.ManyToManyField('self', blank=True, help_text='Other findings related to this one')
65+
66+
objects = FindingInheritanceQS.as_manager()
67+
68+
def __init__(self, *args, **kwargs):
69+
if 'content_source' not in kwargs:
70+
kwargs['content_source'] = self.content_type()
71+
super().__init__(*args, **kwargs)
72+
73+
@classmethod
74+
def content_type(cls):
75+
return ct_models.ContentType.objects.get_for_model(cls)
76+
77+
@property
78+
def cached_content_source(self):
79+
if self.content_source_id is not None and not Finding.content_source.is_cached(self):
80+
self.content_source = ct_models.ContentType.objects.get_for_id(self.content_source_id)
81+
return self.content_source
82+
83+
def __str__(self):
84+
return f'{self.pk} [{self.cached_content_source.app_label}] - {self.title}'

0 commit comments

Comments
 (0)