From 677823b9f7171994b83914aeee08ba60de8f6ad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Mon, 20 Oct 2025 11:00:35 +0100 Subject: [PATCH 01/39] django unfold --- pyproject.toml | 2 +- surface/core_utils/admin.py | 36 +- surface/core_utils/admin_filters.py | 297 ++++++++----- surface/core_utils/decorators.py | 44 +- surface/dns_ips/admin.py | 412 +++++++++--------- surface/inventory/admin.py | 11 +- surface/requirements.txt | 28 +- surface/sca/admin.py | 14 +- surface/scanners/admin.py | 323 +++++++------- surface/surface/settings.py | 59 ++- surface/surface/urls.py | 1 - surface/surfapp/admin.py | 9 +- .../admin/apitokens/mytoken/change_list.html | 31 ++ surface/surfapp/templates/admin/base.html | 62 +++ .../surfapp/templates/admin/change_list.html | 138 ++++++ .../dkron/job/change_list_object_tools.html | 41 +- surface/surfapp/templates/admin/filter.html | 88 ++++ surface/surfapp/templates/admin/index.html | 19 - .../change_form_object_tools.html | 8 +- .../notification/preview_mail.html | 93 ++-- .../notification/preview_slack.html | 115 +++-- .../templates/includes/navigation.html | 4 + .../surfapp/templates/includes/sidenav.html | 10 + .../unfold/helpers/account_links.html | 35 ++ .../templates/unfold/helpers/app_list.html | 111 +++++ .../unfold/helpers/fieldset_row_field.html | 41 ++ .../templates/unfold/helpers/header.html | 28 ++ .../templates/unfold/helpers/welcomemsg.html | 39 ++ surface/surfapp/templatetags/__init__.py | 0 .../templatetags/surface_templatetags.py | 91 ++++ surface/vulns/admin.py | 33 +- 31 files changed, 1552 insertions(+), 671 deletions(-) create mode 100644 surface/surfapp/templates/admin/apitokens/mytoken/change_list.html create mode 100644 surface/surfapp/templates/admin/base.html create mode 100644 surface/surfapp/templates/admin/change_list.html create mode 100644 surface/surfapp/templates/admin/filter.html delete mode 100644 surface/surfapp/templates/admin/index.html create mode 100644 surface/surfapp/templates/includes/sidenav.html create mode 100644 surface/surfapp/templates/unfold/helpers/account_links.html create mode 100644 surface/surfapp/templates/unfold/helpers/app_list.html create mode 100644 surface/surfapp/templates/unfold/helpers/fieldset_row_field.html create mode 100644 surface/surfapp/templates/unfold/helpers/header.html create mode 100644 surface/surfapp/templates/unfold/helpers/welcomemsg.html create mode 100644 surface/surfapp/templatetags/__init__.py create mode 100644 surface/surfapp/templatetags/surface_templatetags.py diff --git a/pyproject.toml b/pyproject.toml index 8d6f161a..ec579e2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,4 +38,4 @@ select = [ src = ['surface', 'e2e'] [tool.ruff.isort] -known-first-party = ["theme", "dkron", "django_restful_admin", "slackbot", "dbcleanup", "olympus", "notifications", "ppbenviron", "logbasecommand", "impersonate", "apitokens", "sbomrepo"] +known-first-party = ["dkron", "django_restful_admin", "slackbot", "dbcleanup", "olympus", "notifications", "ppbenviron", "logbasecommand", "impersonate", "apitokens", "sbomrepo"] diff --git a/surface/core_utils/admin.py b/surface/core_utils/admin.py index 43a5674d..e207613a 100644 --- a/surface/core_utils/admin.py +++ b/surface/core_utils/admin.py @@ -1,9 +1,43 @@ -from django_restful_admin import site as rest from django.apps import apps +from django.db import models +from jsoneditor.forms import JSONEditor +from unfold.admin import ModelAdmin +from django_restful_admin import site as rest # Register all models for REST API except the registered ones for model in apps.get_models(): if model in rest._registry: continue rest.register(model) + + +class DefaultModelAdmin(ModelAdmin): + list_filter_submit = True + list_filter_sheet = False + list_fullwidth = True + add_fieldsets = () + formfield_overrides = { + models.JSONField: {"widget": JSONEditor(attrs={"style": "background-color: white !important;"})} + } + + def get_list_display(self, request): + """ + make sure model primary key is always present as first column for standard UX + """ + default_list_display = list(super(DefaultModelAdmin, self).get_list_display(request)) + + pk = self.model._meta.pk.name + if pk in default_list_display: + default_list_display.remove(pk) + default_list_display.insert(0, self.model._meta.pk.name) + + return default_list_display + + def get_list_display_links(self, request, list_display): + default_list_display_links = super(DefaultModelAdmin, self).get_list_display_links(request, list_display) + + if not default_list_display_links: + default_list_display_links = ("pk",) + + return default_list_display_links diff --git a/surface/core_utils/admin_filters.py b/surface/core_utils/admin_filters.py index 9070c133..f9552c51 100644 --- a/surface/core_utils/admin_filters.py +++ b/surface/core_utils/admin_filters.py @@ -1,126 +1,28 @@ import datetime -import operator +from collections import OrderedDict +from typing import Any from urllib.parse import urlencode from django import forms from django.contrib import admin -from django.contrib.admin.filters import RelatedFieldListFilter from django.contrib.admin.options import ModelAdmin +from django.contrib.admin.views.main import ChangeList from django.contrib.admin.widgets import AdminDateWidget -from django.core.exceptions import ImproperlyConfigured -from django.core.handlers.wsgi import WSGIRequest -from django.db.models import Model +from django.core.validators import EMPTY_VALUES +from django.db.models import Model, QuerySet from django.db.models.base import Model -from django.db.models.fields import BLANK_CHOICE_DASH, Field -from django.db.models.fields.related import RelatedField +from django.db.models.fields import Field +from django.forms import ValidationError +from django.http import HttpRequest from django.shortcuts import redirect -from django.utils import timezone - -try: - from theme.filters import DateRangeFilter as OriginalDateRangeFilter - - class CalendarFilter(OriginalDateRangeFilter): - def __init__(self, field, request, params, model, model_admin, field_path): - self.lookup_kwarg_within = f"{field_path}__within" - super().__init__(field, request, params, model, model_admin, field_path) - - def _get_form_fields(self): - return { - self.lookup_kwarg_within: forms.DateField( - label="", - widget=AdminDateWidget(attrs={"placeholder": self.field_path.replace("_", " ").title()}), - localize=True, - required=False, - ) - } - - def _get_expected_fields(self): - return [self.lookup_kwarg_within] - - def _make_query_filter(self, request, validated_data): - query_params = {} - date_value = validated_data.get(self.lookup_kwarg_within, None) - - if date_value: - date_gte = timezone.make_aware( - datetime.datetime.combine(date_value, datetime.time.min), self.get_timezone(request) - ) - query_params[f"{self.field_path}__gte"] = date_gte - query_params[f"{self.field_path}__lt"] = date_gte + datetime.timedelta(days=1) - - return query_params - - def get_timezone(self, request): - return timezone.get_default_timezone() - -except ImportError: - pass - - -class SelectRelatedFilter(RelatedFieldListFilter): - def __init__( - self, - field: Field, - request: WSGIRequest, - params: dict[str, str], - model: type[Model], - model_admin: ModelAdmin, - field_path: str, - ) -> None: - # validate select_related is defined now for early errors - if not hasattr(model_admin, "list_filter_select_related"): - raise ImproperlyConfigured( - "The list filter '%s' requires '%s' to define 'list_filter_select_related'." - % ( - self.__class__.__name__, - model_admin.__class__.__name__, - ) - ) - if field.name not in model_admin.list_filter_select_related: - raise ImproperlyConfigured( - "The list filter '%s' '%s.list_filter_select_related' is not set for field '%s'." - % ( - self.__class__.__name__, - model_admin.__class__.__name__, - field.name, - ) - ) - super().__init__(field, request, params, model, model_admin, field_path) - - def _custom_field_get_choices( - self, field, include_blank=True, blank_choice=BLANK_CHOICE_DASH, limit_choices_to=None, ordering=() - ): - """ - copy from django.db.models.fields.related.RelatedField.get_choices - only change was returning "qs" at the end instead of the stringified options - (and replacing "self" per "field" obviously) - """ - if field.choices is not None: - choices = list(field.choices) - if include_blank: - blank_defined = any(choice in ("", None) for choice, _ in field.flatchoices) - if not blank_defined: - choices = blank_choice + choices - return choices - rel_model = field.remote_field.model - limit_choices_to = limit_choices_to or field.get_limit_choices_to() - qs = rel_model._default_manager.complex_filter(limit_choices_to) - if ordering: - qs = qs.order_by(*ordering) - return qs - - def field_choices( - self, field: RelatedField, request: WSGIRequest, model_admin: ModelAdmin - ) -> list[tuple[str, str]]: - # re-implement Field get_choices as it doesn't return the queryset itself, but the strings already built... - ordering = self.field_admin_ordering(field, request, model_admin) - choice_func = operator.attrgetter( - field.remote_field.get_related_field().attname if hasattr(field.remote_field, "get_related_field") else "pk" - ) - qs = self._custom_field_get_choices(field, include_blank=False, ordering=ordering).select_related( - *model_admin.list_filter_select_related[field.name] - ) - return [(choice_func(x), str(x)) for x in qs] +from rangefilter.filter import DateRangeFilter as OriginalDateRangeFilter +from unfold.admin import ModelAdmin +from unfold.contrib.filters.admin import DropdownFilter as UnfoldDropdownFilter +from unfold.contrib.filters.admin.dropdown_filters import RelatedDropdownFilter +from unfold.contrib.filters.admin.mixins import AutocompleteMixin +from unfold.contrib.filters.forms import AutocompleteDropdownForm, DropdownForm +from unfold.utils import parse_date_str +from unfold.widgets import INPUT_CLASSES class DefaultFilterMixin: @@ -155,3 +57,170 @@ def __new__(cls, *args, **kwargs): return instance return Wrapper + + +class DateForm(forms.Form): + class Media: + js = [ + "admin/js/calendar.js", + "unfold/filters/js/DateTimeShortcuts.js", + ] + + def __init__(self, name: str, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.name = name + # Ensure the filter value is always a string, not a list + key = f"{name}__within" + if hasattr(self, "data") and self.data.get(key): + val = self.data.get(key) + if isinstance(val, list): + # Take the first value if it's a list + data = self.data.copy() + data[key] = val[0] if val else "" + self.data = data + # Normalize '0' to empty string for the filter value + if hasattr(self, "data") and self.data.get(key) == "0": + data = self.data.copy() + data[key] = "" + self.data = data + self.fields[key] = forms.DateField( + label="", + required=False, + widget=forms.DateInput( + attrs={ + "placeholder": "Select date", + "class": f"vCustomDateField {' '.join(INPUT_CLASSES)}", + } + ), + ) + + def clean(self): + cleaned_data = super().clean() + # Ensure '0' is treated as empty + key = f"{self.name}__within" + if cleaned_data.get(key) == "0": + cleaned_data[key] = None + return cleaned_data + + +class CalendarFilter(admin.FieldListFilter): + form_class = DateForm + request = None + parameter_name = None + template = "unfold/filters/filters_date_range.html" + + def __init__( + self, + field: Field, + request: HttpRequest, + params: dict[str, str], + model: type[Model], + model_admin: ModelAdmin, + field_path: str, + ) -> None: + super().__init__(field, request, params, model, model_admin, field_path) + self.request = request + if self.parameter_name is None: + self.parameter_name = self.field_path + + if self.parameter_name + "__within" in params: + value = params.pop(self.field_path + "__within") + value = value[0] if isinstance(value, list) else value + + if value not in EMPTY_VALUES: + self.used_parameters[self.field_path + "__within"] = value + + def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet: + filters = {} + + value_within = self.used_parameters.get(self.parameter_name + "__within") + if value_within not in EMPTY_VALUES: + filters.update({"last_seen__lt": parse_date_str(value_within) + datetime.timedelta(days=1)}) + filters.update({"first_seen__gte": parse_date_str(value_within)}) + + try: + return queryset.filter(**filters) + except (ValueError, ValidationError): + return None + + def expected_parameters(self) -> list[str]: + return [ + f"{self.parameter_name}__within", + ] + + def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]: + parameter_name = self.parameter_name or "" + value = self.used_parameters.get(f"{parameter_name}__within", None) + # Normalize value to a string if it's a list + if isinstance(value, list): + value = value[0] if value else None + # If value is "0", use current date in YYYY-MM-DD format + if value == "0": + value = datetime.date.today().strftime("%Y-%m-%d") + return ( + { + "request": self.request, + "parameter_name": parameter_name, + "form": self.form_class( + name=str(parameter_name), + data={ + f"{parameter_name}__within": value, + }, + ), + }, + ) + + +class customAutocompleteDropdownForm(AutocompleteDropdownForm): + class Media: + js = () + css = {} + + +class AutocompleteSelectFilter(AutocompleteMixin, RelatedDropdownFilter): + form_class = customAutocompleteDropdownForm + + +class RelatedFieldAjaxListFilter(AutocompleteSelectFilter): + pass + + +class CustomDropdownForm(DropdownForm): + class Media: + js = () + css = {} + + +class DropdownFilter(UnfoldDropdownFilter): + form_class = CustomDropdownForm + + +class DateRangeFilter(OriginalDateRangeFilter): + def get_template(self): + return "rangefilter/date_filter.html" + + def _get_form_fields(self): + # this is here, because in parent DateRangeFilter AdminDateWidget + # could be imported from django-suit + return OrderedDict( + ( + ( + self.lookup_kwarg_gte, + forms.DateField( + label="", + widget=AdminDateWidget(attrs={"placeholder": "From date"}), + localize=True, + required=False, + ), + ), + ( + self.lookup_kwarg_lte, + forms.DateField( + label="", + widget=AdminDateWidget(attrs={"placeholder": "To date"}), + localize=True, + required=False, + ), + ), + ) + ) diff --git a/surface/core_utils/decorators.py b/surface/core_utils/decorators.py index d8519d32..9f8e1ecb 100644 --- a/surface/core_utils/decorators.py +++ b/surface/core_utils/decorators.py @@ -45,7 +45,7 @@ def mark_safe_display(attr, column_name=None): """ if column_name is None: # like django.db.models.Field does - column_name = attr.replace('_', ' ') + column_name = attr.replace("_", " ") def _get_attr(obj): r = getattr(obj, attr) @@ -63,7 +63,7 @@ def linebreaks_display(attr, column_name=None): """ if column_name is None: # like django.db.models.Field does - column_name = attr.replace('_', ' ') + column_name = attr.replace("_", " ") def _get_attr(obj): r = getattr(obj, attr) @@ -82,8 +82,8 @@ def admin_change_url(obj, relative=True): # TODO replace with django contrib admin admin_urls template tag... does the same... app_label = obj._meta.app_label model_name = obj._meta.model_name - return ('' if relative else settings.BASE_HOSTNAME) + reverse( - f'admin:{app_label}_{model_name}_change', args=(obj.pk,) + return ("" if relative else settings.BASE_HOSTNAME) + reverse( + f"admin:{app_label}_{model_name}_change", args=(obj.pk,) ) @@ -99,11 +99,11 @@ def admin_link_helper(attr, column_name=None, description=None, empty_descriptio """ if column_name is None: # like django.db.models.Field does - column_name = attr.replace('_', ' ') + column_name = attr.replace("_", " ") def _get_attr(obj): related_obj = getattr(obj, attr) - related_obj_id = getattr(obj, f'{attr}_id') + related_obj_id = getattr(obj, f"{attr}_id") if related_obj is None or not related_obj_id: return empty_description return admin_change_link(related_obj, description or str(related_obj)) @@ -135,7 +135,7 @@ def credit_card_link(self, credit_card): def wrap(func): def field_func(self, obj): related_obj = getattr(obj, attr) - related_obj_id = getattr(obj, f'{attr}_id') + related_obj_id = getattr(obj, f"{attr}_id") if related_obj is None or not related_obj_id: return empty_description return admin_change_link(related_obj, func(self, related_obj)) @@ -153,7 +153,7 @@ def admin_changelist_url(model, relative=True): # TODO replace with django contrib admin admin_urls template tag... does the same... app_label = model._meta.app_label model_name = model._meta.model_name - return ('' if relative else settings.BASE_HOSTNAME) + reverse(f'admin:{app_label}_{model_name}_changelist') + return ("" if relative else settings.BASE_HOSTNAME) + reverse(f"admin:{app_label}_{model_name}_changelist") def admin_changelist_link(attr, short_description, empty_description="-", query_string=None): @@ -185,7 +185,7 @@ def field_func(self, obj): return empty_description url = admin_changelist_url(related_obj.model) if query_string: - url += '?' + query_string(obj) + url += "?" + query_string(obj) return format_html('{}', url, func(self, related_obj)) field_func.short_description = short_description @@ -211,7 +211,7 @@ def api_view(request): def wrap(func): def wrapped_func(request, *args, **kwargs): - inp_token = request.GET.get('token') or request.POST.get('token') + inp_token = request.GET.get("token") or request.POST.get("token") if inp_token != _token: return HttpResponseForbidden() return func(request, *args, **kwargs) @@ -225,7 +225,7 @@ def wrapped_func(request, *args, **kwargs): return wrap -def confirm_action(template=None, title='Are you sure?', short_description=None): +def confirm_action(template=None, title="Are you sure?", short_description=None): """ Decorator used for ModelAdmin actions that require a confirmation page. template: @@ -259,26 +259,26 @@ def _confirm_first(model_admin, request, queryset): # The user has already confirmed the action. # Just Do It - if request.POST.get('post'): + if request.POST.get("post"): return func(model_admin, request, queryset) short_description_in_use = ( short_description - or getattr(func.__wrapper, 'short_description', func.__name__.replace('_', ' ')).lower() + or getattr(func.__wrapper, "short_description", func.__name__.replace("_", " ")).lower() ) opts = model_admin.model._meta app_label = opts.app_label context = { **model_admin.admin_site.each_context(request), - 'title': title, - 'objects_name': str(model_ngettext(queryset)), - 'queryset': queryset, - 'opts': opts, - 'action': request.POST.get('action'), - 'action_short_description': short_description_in_use, - 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME, - 'media': model_admin.media, + "title": title, + "objects_name": str(model_ngettext(queryset)), + "queryset": queryset, + "opts": opts, + "action": request.POST.get("action"), + "action_short_description": short_description_in_use, + "action_checkbox_name": admin.helpers.ACTION_CHECKBOX_NAME, + "media": model_admin.media, } request.current_app = model_admin.admin_site.name @@ -314,5 +314,5 @@ def _get_attr(obj): return getattr(related_obj, attr_field) _get_attr.short_description = description # TODO get attr_field verbose_name somehow? - _get_attr.admin_order_field = f'{attr}__{attr_field}' + _get_attr.admin_order_field = f"{attr}__{attr_field}" return _get_attr diff --git a/surface/dns_ips/admin.py b/surface/dns_ips/admin.py index 59ab82d6..be7eedec 100644 --- a/surface/dns_ips/admin.py +++ b/surface/dns_ips/admin.py @@ -6,58 +6,58 @@ from django.shortcuts import render from django.utils.html import format_html_join from import_export import resources -from import_export.admin import ExportActionMixin, ImportMixin +from import_export.admin import ExportMixin, ImportMixin -from core_utils.admin_filters import DefaultFilterMixin +from core_utils.admin import DefaultModelAdmin +from core_utils.admin_filters import DefaultFilterMixin, RelatedFieldAjaxListFilter from core_utils.decorators import admin_link_helper, relatedobj_field -from theme.filters import RelatedFieldAjaxListFilter from . import models @admin.register(models.Source) -class SourceAdmin(DefaultFilterMixin, admin.ModelAdmin): - list_display = ('name', 'active', 'last_sync', 'notes') - list_display_links = ('name',) - search_fields = ('name', 'active', 'last_sync', 'notes') - list_filter = ('active', 'last_sync') +class SourceAdmin(DefaultFilterMixin, DefaultModelAdmin): + list_display = ("name", "active", "last_sync", "notes") + list_display_links = ("name",) + search_fields = ("name", "active", "last_sync", "notes") + list_filter = ("active", "last_sync") def get_default_filters(self, request): - return {'active__exact': 1} + return {"active__exact": 1} @admin.register(models.Organisation) -class OrganisationAdmin(DefaultFilterMixin, admin.ModelAdmin): - list_display = [field.name for field in models.Organisation._meta.fields if field.name not in ('id', 'source_key')] - list_display_links = ('name',) - search_fields = ('name',) - list_filter = ('active', 'owned_by_us', 'whitelisted_to_be_scanned') +class OrganisationAdmin(DefaultFilterMixin, DefaultModelAdmin): + list_display = [field.name for field in models.Organisation._meta.fields if field.name not in ("id", "source_key")] + list_display_links = ("name",) + search_fields = ("name",) + list_filter = ("active", "owned_by_us", "whitelisted_to_be_scanned") def get_default_filters(self, request): - return {'active__exact': 1} + return {"active__exact": 1} @admin.register(models.Tag) -class TagAdmin(admin.ModelAdmin): - list_display = ('name', 'notes') - list_display_links = ('name',) - search_fields = ('name', 'notes') +class TagAdmin(DefaultModelAdmin): + list_display = ("name", "notes") + list_display_links = ("name",) + search_fields = ("name", "notes") @admin.register(models.IPRange) -class IPRangeAdmin(DefaultFilterMixin, admin.ModelAdmin): +class IPRangeAdmin(DefaultFilterMixin, DefaultModelAdmin): list_display = [ - field.name for field in models.IPRange._meta.fields if field.name not in ('id', 'range_min', 'range_max') + field.name for field in models.IPRange._meta.fields if field.name not in ("id", "range_min", "range_max") ] - list_display_links = ('range',) - search_fields = ('range', 'zone', 'datacenter', 'description', 'notes') - list_filter = ('source', 'active', 'zone', 'datacenter', 'tags') - exclude = ('range_min', 'range_max') + list_display_links = ("range",) + search_fields = ("range", "zone", "datacenter", "description", "notes") + list_filter = ("source", "active", "zone", "datacenter", "tags") + exclude = ("range_min", "range_max") def get_search_results(self, request, queryset, search_term): q, d = super().get_search_results(request, queryset, search_term) search_term = search_term.strip() - if search_term.count('.') == 3: + if search_term.count(".") == 3: try: val = netaddr.IPAddress(search_term) q |= queryset.filter(range_min__lte=val, range_max__gte=val) @@ -71,217 +71,217 @@ def get_queryset(self, request): return my_model def get_default_filters(self, request): - return {'active__exact': 1} + return {"active__exact": 1} @admin.register(models.IPAddress) -class IPAddress(DefaultFilterMixin, admin.ModelAdmin): +class IPAddress(DefaultFilterMixin, DefaultModelAdmin): list_display = ( - 'source', - 'active', - 'last_seen', - 'name', - 'organisation', - 'organisation_ip_owner', - 'get_tags', - 'notes', + "source", + "active", + "last_seen", + "name", + "organisation", + "organisation_ip_owner", + "get_tags", + "notes", ) - list_display_links = ('name',) - search_fields = ('=name',) - list_filter = ('source', 'active', 'tags') - list_select_related = ('source', 'organisation', 'organisation_ip_owner') + list_display_links = ("name",) + search_fields = ("=name",) + list_filter = ("source", "active", "tags") + list_select_related = ("source", "organisation", "organisation_ip_owner") slack_display_name = True def get_queryset(self, request): - return super().get_queryset(request).prefetch_related('tags') + return super().get_queryset(request).prefetch_related("tags") def get_tags(self, obj): - return format_html_join('', '{}', ((x.name,) for x in obj.tags.all())) + return format_html_join("", '{}', ((x.name,) for x in obj.tags.all())) - get_tags.short_description = 'Tags' + get_tags.short_description = "Tags" def get_default_filters(self, request): - return {'active__exact': 1} + return {"active__exact": 1} @admin.register(models.DNSDomain) -class DNSDomain(DefaultFilterMixin, admin.ModelAdmin): +class DNSDomain(DefaultFilterMixin, DefaultModelAdmin): list_display = ( - 'source', - 'active', - 'last_seen', - 'name', - 'register_nameservers', - 'registration_date', - 'expire_date', - 'raw_whois', - 'register_registrant_name', - 'register_registrant_organisation', - 'register_registrant_email', + "source", + "active", + "last_seen", + "name", + "register_nameservers", + "registration_date", + "expire_date", + "raw_whois", + "register_registrant_name", + "register_registrant_organisation", + "register_registrant_email", ) - list_display_links = ('name',) + list_display_links = ("name",) search_fields = ( - 'name', - 'notes', - 'register_nameservers', - 'registration_date', - 'expire_date', - 'raw_whois', - 'register_management_status', - 'register_dns_managed', - 'register_registrant_name', - 'register_registrant_organisation', - 'register_registrant_address', - 'register_registrant_postcode', - 'register_registrant_city', - 'register_registrant_state', - 'register_registrant_country', - 'register_registrant_phone', - 'register_registrant_fax', - 'register_registrant_email', - 'register_admin_name', - 'register_admin_organisation', - 'register_admin_address', - 'register_admin_postcode', - 'register_admin_city', - 'register_admin_state', - 'register_admin_country', - 'register_admin_phone', - 'register_admin_fax', - 'register_admin_email', - 'register_technical_name', - 'register_technical_organisation', - 'register_technical_address', - 'register_technical_postcode', - 'register_technical_city', - 'register_technical_state', - 'register_technical_country', - 'register_technical_phone', - 'register_technical_fax', - 'register_technical_email', - 'register_account_name', - 'register_email', - 'register_registrar', - 'register_website', + "name", + "notes", + "register_nameservers", + "registration_date", + "expire_date", + "raw_whois", + "register_management_status", + "register_dns_managed", + "register_registrant_name", + "register_registrant_organisation", + "register_registrant_address", + "register_registrant_postcode", + "register_registrant_city", + "register_registrant_state", + "register_registrant_country", + "register_registrant_phone", + "register_registrant_fax", + "register_registrant_email", + "register_admin_name", + "register_admin_organisation", + "register_admin_address", + "register_admin_postcode", + "register_admin_city", + "register_admin_state", + "register_admin_country", + "register_admin_phone", + "register_admin_fax", + "register_admin_email", + "register_technical_name", + "register_technical_organisation", + "register_technical_address", + "register_technical_postcode", + "register_technical_city", + "register_technical_state", + "register_technical_country", + "register_technical_phone", + "register_technical_fax", + "register_technical_email", + "register_account_name", + "register_email", + "register_registrar", + "register_website", ) list_filter = ( - 'source', - 'active', - 'registration_date', - 'expire_date', - 'register_registrant_name', - 'register_registrant_organisation', - 'register_registrant_email', - 'register_management_status', - 'register_dns_managed', + "source", + "active", + "registration_date", + "expire_date", + "register_registrant_name", + "register_registrant_organisation", + "register_registrant_email", + "register_management_status", + "register_dns_managed", ) readonly_fields = ( - 'registration_date', - 'expire_date', - 'raw_whois', - 'register_management_status', - 'register_dns_managed', - 'register_registrant_name', - 'register_registrant_organisation', - 'register_registrant_address', - 'register_registrant_postcode', - 'register_registrant_city', - 'register_registrant_state', - 'register_registrant_country', - 'register_registrant_phone', - 'register_registrant_fax', - 'register_registrant_email', - 'register_admin_name', - 'register_admin_organisation', - 'register_admin_address', - 'register_admin_postcode', - 'register_admin_city', - 'register_admin_state', - 'register_admin_country', - 'register_admin_phone', - 'register_admin_fax', - 'register_admin_email', - 'register_technical_name', - 'register_technical_organisation', - 'register_technical_address', - 'register_technical_postcode', - 'register_technical_city', - 'register_technical_state', - 'register_technical_country', - 'register_technical_phone', - 'register_technical_fax', - 'register_technical_email', - 'register_account_name', - 'register_email', - 'register_registrar', - 'register_website', + "registration_date", + "expire_date", + "raw_whois", + "register_management_status", + "register_dns_managed", + "register_registrant_name", + "register_registrant_organisation", + "register_registrant_address", + "register_registrant_postcode", + "register_registrant_city", + "register_registrant_state", + "register_registrant_country", + "register_registrant_phone", + "register_registrant_fax", + "register_registrant_email", + "register_admin_name", + "register_admin_organisation", + "register_admin_address", + "register_admin_postcode", + "register_admin_city", + "register_admin_state", + "register_admin_country", + "register_admin_phone", + "register_admin_fax", + "register_admin_email", + "register_technical_name", + "register_technical_organisation", + "register_technical_address", + "register_technical_postcode", + "register_technical_city", + "register_technical_state", + "register_technical_country", + "register_technical_phone", + "register_technical_fax", + "register_technical_email", + "register_account_name", + "register_email", + "register_registrar", + "register_website", ) def get_default_filters(self, request): - return {'active__exact': 1} + return {"active__exact": 1} class DNSRecordResource(resources.ModelResource): class Meta: skip_unchanged = True model = models.DNSRecord - import_id_fields = ('source', 'name') - fields = ('source', 'name') + import_id_fields = ("source", "name") + fields = ("source", "name") @admin.register(models.DNSRecord) -class DNSRecordAdmin(DefaultFilterMixin, ImportMixin, ExportActionMixin, admin.ModelAdmin): +class DNSRecordAdmin(DefaultFilterMixin, DefaultModelAdmin): resource_class = DNSRecordResource list_display = ( - 'id', - 'name', - 'source', - 'active', - 'last_seen', - 'tla', - 'domain', - 'get_tags', + "id", + "name", + "source", + "active", + "last_seen", + "tla", + "domain", + "get_tags", ) - search_fields = ('name', 'domain__name', 'notes') + search_fields = ("name", "domain__name", "notes") list_filter = ( - ('domain', RelatedFieldAjaxListFilter), - ('tla__managed_by', RelatedFieldAjaxListFilter), - ('tla__owned_by', RelatedFieldAjaxListFilter), - ('tla__director_direct', RelatedFieldAjaxListFilter), - ('tla__director', RelatedFieldAjaxListFilter), - ('tla', RelatedFieldAjaxListFilter), - 'source', - 'active', - 'tags', + ("domain", RelatedFieldAjaxListFilter), + ("tla__managed_by", RelatedFieldAjaxListFilter), + ("tla__owned_by", RelatedFieldAjaxListFilter), + ("tla__director_direct", RelatedFieldAjaxListFilter), + ("tla__director", RelatedFieldAjaxListFilter), + ("tla", RelatedFieldAjaxListFilter), + "source", + "active", + "tags", ) - list_select_related = ('source', 'domain', 'tla') + list_select_related = ("source", "domain", "tla") slack_display_name = True - actions = ['update_tag_on_selected'] # override our default + actions = ["update_tag_on_selected"] # override our default def get_queryset(self, request: HttpRequest) -> QuerySet: - return super().get_queryset(request).prefetch_related('tags') + return super().get_queryset(request).prefetch_related("tags") def get_tags(self, obj): - return format_html_join('', '{}', ((x.name,) for x in obj.tags.all())) + return format_html_join("", '{}', ((x.name,) for x in obj.tags.all())) - get_tags.short_description = 'Tags' + get_tags.short_description = "Tags" def get_default_filters(self, request): - return {'active__exact': 1} + return {"active__exact": 1} def update_tag_on_selected(self, request, queryset): - if 'apply' in request.POST: - tag = models.Tag.objects.get(pk=request.POST.get('tag', None)) - action = request.POST.get('update', None) + if "apply" in request.POST: + tag = models.Tag.objects.get(pk=request.POST.get("tag", None)) + action = request.POST.get("update", None) - if action == 'add': + if action == "add": for i in queryset: i.tags.add(tag) - elif action == 'remove': + elif action == "remove": for i in queryset: i.tags.remove(tag) else: @@ -291,7 +291,7 @@ def update_tag_on_selected(self, request, queryset): self.message_user(request, f"Updated tags for {queryset.count()} selected items") return HttpResponseRedirect(request.get_full_path()) - return render(request, 'tag_intermediate.html', context={'items': queryset, 'tags': models.Tag.objects.all()}) + return render(request, "tag_intermediate.html", context={"items": queryset, "tags": models.Tag.objects.all()}) update_tag_on_selected.short_description = "Update Tags on selected items" @@ -300,45 +300,45 @@ class DNSRecordValueResource(resources.ModelResource): class Meta: skip_unchanged = True model = models.DNSRecordValue - import_id_fields = ('source', 'name', 'rtype', 'value') - fields = ('source', 'name', 'ttl', 'rtype', 'value') + import_id_fields = ("source", "name", "rtype", "value") + fields = ("source", "name", "ttl", "rtype", "value") @admin.register(models.DNSRecordValue) -class DNSRecordValueAdmin(DefaultFilterMixin, ImportMixin, ExportActionMixin, admin.ModelAdmin): +class DNSRecordValueAdmin(DefaultFilterMixin, ImportMixin, ExportMixin, DefaultModelAdmin): resource_class = DNSRecordValueResource list_display = ( - 'id', - admin_link_helper('record'), - relatedobj_field('record', 'source', description='Source'), - 'active', - 'last_seen', - relatedobj_field('record', 'tla', description='TLA'), - 'ttl', - 'rtype', - 'value', - 'get_ips', + "id", + admin_link_helper("record"), + relatedobj_field("record", "source", description="Source"), + "active", + "last_seen", + relatedobj_field("record", "tla", description="TLA"), + "ttl", + "rtype", + "value", + "get_ips", ) - list_display_links = ('id',) - search_fields = ('record__name', 'record__domain__name', 'rtype', 'value', 'ips__name') + list_display_links = ("id",) + search_fields = ("record__name", "record__domain__name", "rtype", "value", "ips__name") list_filter = ( - ('record', RelatedFieldAjaxListFilter), - 'record__source', - 'active', - 'rtype', + ("record", RelatedFieldAjaxListFilter), + "record__source", + "active", + "rtype", ) - list_select_related = ('record', 'record__tla', 'record__source', 'record__domain') - readonly_fields = ('record', 'ips') + list_select_related = ("record", "record__tla", "record__source", "record__domain") + readonly_fields = ("record", "ips") slack_display_name = True def get_queryset(self, request: HttpRequest) -> QuerySet: - return super().get_queryset(request).prefetch_related('ips') + return super().get_queryset(request).prefetch_related("ips") def get_ips(self, obj): - return ', '.join([p.name for p in obj.ips.all()]) + return ", ".join([p.name for p in obj.ips.all()]) - get_ips.short_description = 'IP List' + get_ips.short_description = "IP List" def get_default_filters(self, request): - return {'active__exact': 1} + return {"active__exact": 1} diff --git a/surface/inventory/admin.py b/surface/inventory/admin.py index a472d5ef..d8dadb3f 100644 --- a/surface/inventory/admin.py +++ b/surface/inventory/admin.py @@ -3,13 +3,14 @@ from django.template.defaultfilters import truncatechars from django.utils.html import format_html -from theme.filters import RelatedFieldAjaxListFilter +from core_utils.admin import DefaultModelAdmin +from core_utils.admin_filters import RelatedFieldAjaxListFilter from . import models @register(models.Application) -class ApplicationAdmin(admin.ModelAdmin): +class ApplicationAdmin(DefaultModelAdmin): list_display = [ "tla", "managed_by", @@ -38,7 +39,7 @@ class ApplicationAdmin(admin.ModelAdmin): @admin.register(models.GitSource) -class GitSourceAdmin(admin.ModelAdmin): +class GitSourceAdmin(DefaultModelAdmin): filter_vertical = ("apps",) list_display = ( "id", @@ -64,9 +65,7 @@ def get_apps(self, obj): @admin.display(description="Repo") def get_link(self, obj): if obj.repo_url: - return format_html( - '{url}', url=obj.repo_url - ) # nosec - intencional use in order to create admin links + return format_html('{url}', url=obj.repo_url) # nosec - intencional use in order to create admin links return "" def get_queryset(self, request): diff --git a/surface/requirements.txt b/surface/requirements.txt index 23bfbecf..0dd95411 100644 --- a/surface/requirements.txt +++ b/surface/requirements.txt @@ -1,5 +1,5 @@ # Core Libraries -Django==3.2.25 +Django==5.2.4 django-admin-rangefilter==0.11.0 django-after-response==0.2.2 django-object-actions==4.2.0 @@ -7,24 +7,24 @@ djangorestframework==3.14.0 django-restful-admin==1.1.3 djangorestframework-queryfields==1.0.0 django-filter==2.4.0 -django-import-export==2.5.0 +django-import-export==4.3.9 django-nested-admin==4.0.2 django-daterangefilter==1.0.0 -django-jsoneditor==0.1.6 +django-jsoneditor==0.2.4 netaddr==0.8.0 +django-unfold==0.68.0 # our own -django-surface-theme==0.0.11 -django-dbcleanup==0.1.4 -django-logbasecommand==0.0.2 -django-notification-sender[slack]==0.0.6 -django-dkron==1.1.1 -django-slack-processor==0.0.5 -django-olympus==0.0.5 -django-environ-ppb[vault]==1.0.1 -django-impersonator==0.0.2 -django-apitokens==0.0.2 -django-sbomrepo==0.0.9 +django-dbcleanup==0.1.5 +django-logbasecommand==0.0.5 +django-notification-sender==0.1.0 +django-dkron==1.2.2 +django-slack-processor==0.1.9 +django-olympus==0.0.6 +django-environ==0.10.0 +django-impersonator==0.0.3 +django-apitokens==0.0.3 +django-sbomrepo==0.0.10 mysqlclient==2.2.4 tqdm==4.65.0 # for core_utils that is not really a app/package ..? diff --git a/surface/sca/admin.py b/surface/sca/admin.py index 4e9c9d0e..824baf8d 100644 --- a/surface/sca/admin.py +++ b/surface/sca/admin.py @@ -14,13 +14,13 @@ from django_object_actions import DjangoObjectActions from jsoneditor.forms import JSONEditor -from core_utils.admin_filters import DefaultFilterMixin +from core_utils.admin import DefaultModelAdmin +from core_utils.admin_filters import DefaultFilterMixin, RelatedFieldAjaxListFilter from core_utils.utils import admin_reverse from dkron.utils import run_async from inventory.models import GitSource from sca import models from sca.utils import only_highest_version_dependencies -from theme.filters import RelatedFieldAjaxListFilter logger = logging.getLogger(__name__) @@ -63,7 +63,7 @@ class SupportFilter(EndOfLifeDependencyBoolFilter): @admin.register(models.EndOfLifeDependency) -class EndOfLifeDependencyAdmin(admin.ModelAdmin, DefaultFilterMixin, EndOfLifeDependencyBoolFilter): +class EndOfLifeDependencyAdmin(DefaultModelAdmin, DefaultFilterMixin, EndOfLifeDependencyBoolFilter): list_display = [ "product", "cycle", @@ -100,7 +100,7 @@ def __init__(self, *args, **kwargs): @admin.register(models.SCADependency) -class SCADependencyAdmin(admin.ModelAdmin, DefaultFilterMixin): +class SCADependencyAdmin(DefaultModelAdmin, DefaultFilterMixin): form = SCADependencyForm list_display = [ "purl", @@ -230,7 +230,7 @@ def filter_vulnerable(self, queryset, name, value): @admin.register(models.SCAProject) -class SCAProjectAdmin(admin.ModelAdmin): +class SCAProjectAdmin(DefaultModelAdmin): list_display = ["purl", "get_vulns", "get_git_source", "name", "last_scan", "created_at"] list_filter = ["name", "git_source", "git_source__apps__tla"] search_fields = ["name", "purl", "depends_on__name", "depends_on__purl", "git_source__repo_url"] @@ -390,7 +390,7 @@ def has_add_permission(self, request: HttpRequest) -> bool: @admin.register(models.SCAFinding) -class SCAFindingAdmin(DjangoObjectActions, admin.ModelAdmin): +class SCAFindingAdmin(DjangoObjectActions, DefaultModelAdmin): list_display = [ "vuln_id", "truncated_aliases", @@ -471,7 +471,7 @@ def has_change_permission(self, request, obj=None): @admin.register(models.SuppressedSCAFinding) -class SuppressedSCAFindingAdmin(admin.ModelAdmin): +class SuppressedSCAFindingAdmin(DefaultModelAdmin): list_display = [ "vuln_id", "get_dependency", diff --git a/surface/scanners/admin.py b/surface/scanners/admin.py index a39a7c9f..fade7ba7 100644 --- a/surface/scanners/admin.py +++ b/surface/scanners/admin.py @@ -2,167 +2,168 @@ from datetime import datetime from django import forms -from django.contrib.admin.templatetags.admin_urls import admin_urlname -from django.db.models import Q from django.contrib import admin, messages from django.contrib.admin import SimpleListFilter -from django.shortcuts import render +from django.contrib.admin.templatetags.admin_urls import admin_urlname +from django.contrib.admin.utils import unquote +from django.core.exceptions import PermissionDenied +from django.db.models import Q from django.http.response import JsonResponse, StreamingHttpResponse +from django.shortcuts import render from django.template.response import TemplateResponse +from django.urls import reverse from django.utils.html import format_html, format_html_join -from django.contrib.admin.utils import unquote -from django.core.exceptions import PermissionDenied from django.utils.text import capfirst -from django.urls import reverse +from core_utils.admin import DefaultModelAdmin +from core_utils.admin_filters import DefaultFilterMixin from dkron.utils import run_async from scanners import models, utils -from core_utils.admin_filters import DefaultFilterMixin class FinalHTTPFilter(SimpleListFilter): - title = 'Final HTTP' - parameter_name = 'final_http' + title = "Final HTTP" + parameter_name = "final_http" def lookups(self, request, model_admin): - return [('Yes', 'Yes'), ('No', 'No')] + return [("Yes", "Yes"), ("No", "No")] def queryset(self, request, queryset): - if self.value() == 'Yes': - return queryset.filter(final_url__startswith='http:') - elif self.value() == 'No': - return queryset.exclude(final_url__startswith='http:') + if self.value() == "Yes": + return queryset.filter(final_url__startswith="http:") + elif self.value() == "No": + return queryset.exclude(final_url__startswith="http:") return queryset class NoLBie1ie2Filter(SimpleListFilter): - title = 'No LB or IE1/IE2' - parameter_name = 'no_lb_ie1ie2' + title = "No LB or IE1/IE2" + parameter_name = "no_lb_ie1ie2" def lookups(self, request, model_admin): - return [('Yes', 'Yes'), ('No', 'No')] + return [("Yes", "Yes"), ("No", "No")] def queryset(self, request, queryset): - if self.value() == 'Yes': + if self.value() == "Yes": return queryset.filter(record__isnull=False).exclude( - Q(record__name__startswith='ie1-') - | Q(record__name__startswith='ie2-') - | Q(record__name__icontains='.lb.') - | Q(record__name__icontains='.ie1.') - | Q(record__name__icontains='.ie2.') + Q(record__name__startswith="ie1-") + | Q(record__name__startswith="ie2-") + | Q(record__name__icontains=".lb.") + | Q(record__name__icontains=".ie1.") + | Q(record__name__icontains=".ie2.") ) return queryset class TypeRecordFilter(SimpleListFilter): - title = 'Type Record' - parameter_name = 'type_record' + title = "Type Record" + parameter_name = "type_record" def lookups(self, request, model_admin): - return [('DNS Record', 'DNS Record'), ('IP', 'IP')] + return [("DNS Record", "DNS Record"), ("IP", "IP")] def queryset(self, request, queryset): - if self.value() == 'DNS Record': + if self.value() == "DNS Record": return queryset.exclude(record__isnull=True) - elif self.value() == 'IP': + elif self.value() == "IP": return queryset.exclude(ip__isnull=True) return queryset class ExitCodeFilter(SimpleListFilter): - title = 'Success' - parameter_name = 'success_exit' + title = "Success" + parameter_name = "success_exit" def lookups(self, request, model_admin): - return [('yes', 'Yes'), ('no', 'No')] + return [("yes", "Yes"), ("no", "No")] def queryset(self, request, queryset): val = self.value() - if val == 'yes': + if val == "yes": return queryset.filter(exit_code=0) - elif val == 'no': + elif val == "no": return queryset.filter(exit_code__isnull=False).exclude(exit_code=0) else: return queryset @admin.register(models.Rootbox) -class RootboxAdmin(DefaultFilterMixin, admin.ModelAdmin): - list_display = ('active', 'name', 'ip', 'ssh_user', 'ssh_port', 'location', 'notes') - list_display_links = ('name',) - search_fields = ('name', 'ip', 'ssh_user', 'ssh_port', 'location', 'notes') - list_filter = ('active', 'location') - actions = ['check_scanners'] +class RootboxAdmin(DefaultFilterMixin, DefaultModelAdmin): + list_display = ("active", "name", "ip", "ssh_user", "ssh_port", "location", "notes") + list_display_links = ("name",) + search_fields = ("name", "ip", "ssh_user", "ssh_port", "location", "notes") + list_filter = ("active", "location") + actions = ["check_scanners"] def check_scanners(self, request, queryset): objs = [o.name for o in queryset] opts = self.opts context = { **self.admin_site.each_context(request), - 'module_name': str(opts.verbose_name_plural), - 'preserved_filters': self.get_preserved_filters(request), - 'title': 'Running scanners', + "module_name": str(opts.verbose_name_plural), + "preserved_filters": self.get_preserved_filters(request), + "title": "Running scanners", # hack alert: not an object, but works for change_form... - 'original': 'Running scanners', - 'opts': opts, - 'has_view_permission': self.has_view_permission(request), - 'output': utils.check_scanners(objs), + "original": "Running scanners", + "opts": opts, + "has_view_permission": self.has_view_permission(request), + "output": utils.check_scanners(objs), } - return render(request, 'admin/scanners/check_scanners.html', context=context) + return render(request, "admin/scanners/check_scanners.html", context=context) - check_scanners.short_description = 'Check running scanners' - check_scanners.allowed_permissions = ('check_scanners',) + check_scanners.short_description = "Check running scanners" + check_scanners.allowed_permissions = ("check_scanners",) def has_check_scanners_permission(self, request, obj=None): - return request.user.has_perm('scanners.check_scanners') + return request.user.has_perm("scanners.check_scanners") def get_default_filters(self, request): - return {'active__exact': 1} + return {"active__exact": 1} @admin.register(models.ScannerImage) -class ScannerImageAdmin(admin.ModelAdmin): - list_display = ('name', 'description', 'vault_secrets') - list_display_links = ('name',) - search_fields = ('name', 'description') +class ScannerImageAdmin(DefaultModelAdmin): + list_display = ("name", "description", "vault_secrets") + list_display_links = ("name",) + search_fields = ("name", "description") class ScannerAdminForm(forms.ModelForm): def clean_environment_vars(self): - data = self.cleaned_data['environment_vars'] + data = self.cleaned_data["environment_vars"] if data: try: json.loads(data) except ValueError as e: - raise forms.ValidationError(f'An error occurred while parsing the environment variables. {str(e)}') + raise forms.ValidationError(f"An error occurred while parsing the environment variables. {str(e)}") return data @admin.register(models.Scanner) -class ScannerAdmin(admin.ModelAdmin): +class ScannerAdmin(DefaultModelAdmin): list_display = ( - 'id', - 'scanner_name', - 'image', - 'extra_args', - 'input', - 'parser', - 'continous_running', - 'logs_link', - 'notes', + "id", + "scanner_name", + "image", + "extra_args", + "input", + "parser", + "continous_running", + "logs_link", + "notes", ) - list_display_links = ('id',) - search_fields = ('image__name', 'scanner_name', 'notes') - list_filter = ('image__name', 'rootbox', 'continous_running', 'input', 'parser') - actions = ['run_scanner'] + list_display_links = ("id",) + search_fields = ("image__name", "scanner_name", "notes") + list_filter = ("image__name", "rootbox", "continous_running", "input", "parser") + actions = ["run_scanner"] form = ScannerAdminForm def run_scanner(self, request, queryset): for obj in queryset: - x = run_async('run_scanner', obj.scanner_name) + x = run_async("run_scanner", obj.scanner_name) if x is None: self.message_user( - request, format_html('Scanner {} launching', obj.scanner_name), level=messages.SUCCESS + request, format_html("Scanner {} launching", obj.scanner_name), level=messages.SUCCESS ) else: self.message_user( @@ -175,37 +176,37 @@ def run_scanner(self, request, queryset): level=messages.SUCCESS, ) - run_scanner.short_description = 'Run this scanner...' + run_scanner.short_description = "Run this scanner..." def logs_link(self, obj): return format_html( '', - reverse('admin:scanners_scanlog_changelist'), + reverse("admin:scanners_scanlog_changelist"), obj.pk, ) - logs_link.short_description = 'Logs' + logs_link.short_description = "Logs" @admin.register(models.ScanLog) -class ScanLogAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'scanner', 'rootbox', 'first_seen', 'get_runtime', 'get_state', 'view_logs') - list_display_links = ('id',) - search_fields = ('name', 'scanner__scanner_name') - list_filter = ('scanner', 'rootbox', 'state', ExitCodeFilter) +class ScanLogAdmin(DefaultModelAdmin): + list_display = ("id", "name", "scanner", "rootbox", "first_seen", "get_runtime", "get_state", "view_logs") + list_display_links = ("id",) + search_fields = ("name", "scanner__scanner_name") + list_filter = ("scanner", "rootbox", "state", ExitCodeFilter) def get_runtime(self, obj): return obj.last_seen - obj.first_seen - get_runtime.short_description = 'Runtime' + get_runtime.short_description = "Runtime" def view_logs(self, obj): return format_html( '', - reverse('admin:scanners_scanlog_output', args=(obj.pk,), current_app=self.admin_site.name), + reverse("admin:scanners_scanlog_output", args=(obj.pk,), current_app=self.admin_site.name), ) - view_logs.short_description = 'Output' + view_logs.short_description = "Output" def get_state(self, obj): state = obj.get_state_display() @@ -220,8 +221,8 @@ def get_state(self, obj): obj.exit_code, ) - get_state.short_description = 'Status' - get_state.admin_order_field = 'state' + get_state.short_description = "Status" + get_state.admin_order_field = "state" def get_urls(self): from django.urls import path @@ -231,17 +232,17 @@ def get_urls(self): urls.insert( 0, path( - '/output/', self.admin_site.admin_view(self.output_view), name='scanners_scanlog_output' + "/output/", self.admin_site.admin_view(self.output_view), name="scanners_scanlog_output" ), ) return urls def _output_view_json(self, request, obj): - qs = obj.output_lines.order_by('timestamp') + qs = obj.output_lines.order_by("timestamp") cursor = None - if request.GET.get('cursor'): + if request.GET.get("cursor"): try: - cursor = datetime.fromisoformat(request.GET.get('cursor')) + cursor = datetime.fromisoformat(request.GET.get("cursor")) qs = qs.filter(timestamp__gt=cursor) except (ValueError, TypeError): pass @@ -254,9 +255,9 @@ def _output_view_json(self, request, obj): cursor = last_time return JsonResponse( { - 'lines': lines, - 'cursor': cursor.isoformat() if cursor is not None else None, - 'state': obj.get_state_display(), + "lines": lines, + "cursor": cursor.isoformat() if cursor is not None else None, + "state": obj.get_state_display(), } ) @@ -270,19 +271,19 @@ def output_view(self, request, object_id, extra_context=None): # check that user has permissions on ScanOutput model as well if not self.has_view_permission(request) or not ( - request.user.has_perm('scanners.view_scanoutput') or request.user.has_perm('scanners.change_scanoutput') + request.user.has_perm("scanners.view_scanoutput") or request.user.has_perm("scanners.change_scanoutput") ): raise PermissionDenied - if request.GET.get('mode') == 'json': + if request.GET.get("mode") == "json": return self._output_view_json(request, obj) - output_list = obj.output_lines.order_by('-timestamp') - raw_mode = request.GET.get('mode') == 'raw' + output_list = obj.output_lines.order_by("-timestamp") + raw_mode = request.GET.get("mode") == "raw" if raw_mode: # return full log in raw plain/text return StreamingHttpResponse( - streaming_content=(f'[{x.timestamp}] {x.line}\n' for x in output_list), content_type='text/plain' + streaming_content=(f"[{x.timestamp}] {x.line}\n" for x in output_list), content_type="text/plain" ) # otherwise show only last 100 lines output_list = output_list[:100] @@ -293,15 +294,15 @@ def output_view(self, request, object_id, extra_context=None): opts = model._meta context = { **self.admin_site.each_context(request), - 'title': 'View output: %s' % obj, - 'module_name': str(capfirst(opts.verbose_name_plural)), - 'object_id': object_id, - 'original': obj, - 'opts': opts, - 'output_list': output_list, - 'preserved_filters': self.get_preserved_filters(request), - 'has_view_permission': True, - 'full_mode': len(output_list) < 100, + "title": "View output: %s" % obj, + "module_name": str(capfirst(opts.verbose_name_plural)), + "object_id": object_id, + "original": obj, + "opts": opts, + "output_list": output_list, + "preserved_filters": self.get_preserved_filters(request), + "has_view_permission": True, + "full_mode": len(output_list) < 100, **(extra_context or {}), } request.current_app = self.admin_site.name @@ -316,99 +317,99 @@ def has_change_permission(self, request, obj=None): @admin.register(models.LiveHost) -class LiveHostAdmin(admin.ModelAdmin): +class LiveHostAdmin(DefaultModelAdmin): list_display = [ - 'id', - 'get_host', - 'port', - 'status_code', - 'final_url', - 'third_party', - 'last_seen', - 'get_technologies', + "id", + "get_host", + "port", + "status_code", + "final_url", + "third_party", + "last_seen", + "get_technologies", ] - list_display_links = ('id',) + list_display_links = ("id",) list_filter = ( - 'last_seen', - 'port', - 'status_code', - 'third_party', - 'host_content_type', - 'technologies', + "last_seen", + "port", + "status_code", + "third_party", + "host_content_type", + "technologies", FinalHTTPFilter, NoLBie1ie2Filter, ) search_fields = [ - 'host__any__name', - 'final_url', - 'redirects', - 'headers', - 'cookies', - 'technologies__name', - 'notes', + "host__any__name", + "final_url", + "redirects", + "headers", + "cookies", + "technologies__name", + "notes", ] - readonly_fields = [field.name for field in models.LiveHost._meta.fields if field.name not in ('notes',)] + [ - 'get_technologies' + readonly_fields = [field.name for field in models.LiveHost._meta.fields if field.name not in ("notes",)] + [ + "get_technologies" ] - exclude = ('technologies',) - list_select_related = ('host_content_type',) + exclude = ("technologies",) + list_select_related = ("host_content_type",) def get_queryset(self, request): - return super().get_queryset(request).prefetch_related('host', 'technologies') + return super().get_queryset(request).prefetch_related("host", "technologies") def get_host(self, obj): meta = obj.host_content_type.model_class()._meta return format_html( '{} ({})', obj.host, - reverse(admin_urlname(meta, 'change'), args=(obj.host_object_id,)), + reverse(admin_urlname(meta, "change"), args=(obj.host_object_id,)), meta.verbose_name, ) - get_host.short_description = 'Host' - get_host.admin_order_field = 'content_type' + get_host.short_description = "Host" + get_host.admin_order_field = "content_type" def get_technologies(self, obj): - return format_html_join('', '{}', ((x.name,) for x in obj.technologies.all())) + return format_html_join("", '{}', ((x.name,) for x in obj.technologies.all())) - get_technologies.short_description = 'Technologies' + get_technologies.short_description = "Technologies" def has_add_permission(self, request): return False @admin.register(models.RawResult) -class RawResultAdmin(DefaultFilterMixin, admin.ModelAdmin): - list_display = ('file_name', 'scanner', 'rootbox', 'last_seen', 'notes') - list_display_links = ('file_name',) - search_fields = ('raw_results', 'file_name') - list_filter = ('rootbox', 'scanner') - readonly_fields = [field.name for field in models.RawResult._meta.fields if field.name not in ('id')] +class RawResultAdmin(DefaultFilterMixin, DefaultModelAdmin): + list_display = ("file_name", "scanner", "rootbox", "last_seen", "notes") + list_display_links = ("file_name",) + search_fields = ("raw_results", "file_name") + list_filter = ("rootbox", "scanner") + readonly_fields = [field.name for field in models.RawResult._meta.fields if field.name not in ("id")] def get_default_filters(self, request): - return {'active__exact': 1} + return {"active__exact": 1} @admin.register(models.TechUsedResult) -class TechUsedResultAdmin(DefaultFilterMixin, admin.ModelAdmin): +class TechUsedResultAdmin(DefaultFilterMixin, DefaultModelAdmin): list_display = [ field.name for field in models.TechUsedResult._meta.fields - if field.name not in ('id', 'scanner', 'rootbox', 'notes') + if field.name not in ("id", "scanner", "rootbox", "notes") ] - list_display_links = ('first_seen',) + list_display_links = ("first_seen",) list_filter = ( - 'active', - 'rootbox', - 'scanner', - 'first_seen', - 'last_seen', - 'category', - 'application', - 'version', - 'confidence', + "active", + "rootbox", + "scanner", + "first_seen", + "last_seen", + "category", + "application", + "version", + "confidence", ) - readonly_fields = [field.name for field in models.TechUsedResult._meta.fields if field.name not in ('notes',)] + readonly_fields = [field.name for field in models.TechUsedResult._meta.fields if field.name not in ("notes",)] def has_add_permission(self, request): return False @@ -417,4 +418,4 @@ def has_delete_permission(self, request, obj=None): return False def get_default_filters(self, request): - return {'active__exact': 1} + return {"active__exact": 1} diff --git a/surface/surface/settings.py b/surface/surface/settings.py index 367e7f5e..bdd1e1b5 100644 --- a/surface/surface/settings.py +++ b/surface/surface/settings.py @@ -23,7 +23,12 @@ # Application definition INSTALLED_APPS = [ - "theme", + "unfold", + "unfold.contrib.filters", + "unfold.contrib.forms", + "unfold.contrib.inlines", + "unfold.contrib.import_export", + "unfold.contrib.simple_history", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -64,8 +69,6 @@ TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - # surfapp templates required here as well to override `theme` ones, - # (as theme needs to come first in INSTALLED_APPS) "DIRS": [BASE_DIR / "surfapp" / "templates"], "APP_DIRS": True, "OPTIONS": { @@ -250,3 +253,53 @@ TITLE = "Surface" VERSION = "dev" + + +###################################################################### +# Unfold +###################################################################### +LOGO = "data:image/png;base64, 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" + +UNFOLD = { + "SITE_TITLE": "Surface Security", + "SITE_HEADER": "Surface", + "SITE_SUBHEADER": "Security Intelligence Automation Platform", + "ENVIRONMENT": [ENV_VAR("SURF_ENVIRONMENT", default="dev"), "primary"], + "SITE_ICON": LOGO, + "SITE_FAVICONS": [ + { + "rel": "icon", + "href": LOGO, + }, + ], + "SHOW_HISTORY": True, + "COLORS": { + "base": { + "50": "249, 250, 251", + "100": "243, 244, 246", + "200": "229, 231, 235", + "300": "209, 213, 219", + "400": "156, 163, 175", + "500": "107, 114, 128", + "600": "75, 85, 99", + "700": "55, 65, 81", + "800": "31, 41, 55", + "900": "17, 24, 39", + "950": "3, 7, 18", + }, + "primary": { + "50": "239, 246, 255", + "100": "219, 234, 254", + "200": "191, 219, 254", + "300": "147, 197, 253", + "400": "96, 165, 250", + "500": "59, 130, 246", + "600": "37, 99, 235", + "700": "29, 78, 216", + "800": "30, 64, 175", + "900": "30, 58, 138", + "950": "23, 37, 84", + }, + }, + # "SIDEBAR": SIDEBAR, +} diff --git a/surface/surface/urls.py b/surface/surface/urls.py index 77e2ad70..6880ce9e 100644 --- a/surface/surface/urls.py +++ b/surface/surface/urls.py @@ -18,7 +18,6 @@ from django.urls import include, path urlpatterns = [ - path("", include(("theme.urls", "theme"), namespace="surface_theme")), path("dkron/", include("dkron.urls")), path("sbomrepo/", include("sbomrepo.urls")), path("", admin.site.urls), diff --git a/surface/surfapp/admin.py b/surface/surfapp/admin.py index 04cb6ae3..0acaae51 100644 --- a/surface/surfapp/admin.py +++ b/surface/surfapp/admin.py @@ -1,9 +1,10 @@ from django.contrib.admin import site from django.contrib.auth import admin + from impersonate.admin import impersonate_action -admin.UserAdmin.actions.append(impersonate_action) +admin.UserAdmin.actions = list(admin.UserAdmin.actions) + [impersonate_action] -site.site_title = 'Surface' -site.site_url = 'https://github.com/surface-security/surface' -site.index_title = 'Home' +site.site_title = "Surface" +site.site_url = "https://github.com/surface-security/surface" +site.index_title = "Home" diff --git a/surface/surfapp/templates/admin/apitokens/mytoken/change_list.html b/surface/surfapp/templates/admin/apitokens/mytoken/change_list.html new file mode 100644 index 00000000..9139ec01 --- /dev/null +++ b/surface/surfapp/templates/admin/apitokens/mytoken/change_list.html @@ -0,0 +1,31 @@ +{% extends "admin/change_list.html" %} +{% load unfold %} + +{% block messages %} +
+
+ {% if messages %} +
    + {% for message in messages %} +
  • + {% if message.tags == "token info" %} +
    + {{ message }} + Token generated - save it before it is gone! +
    + {% elif message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} + {% include "unfold/helpers/messages/success.html" with message=message %} + {% elif message.level == DEFAULT_MESSAGE_LEVELS.WARNING %} + {% include "unfold/helpers/messages/warning.html" with message=message %} + {% elif message.level == DEFAULT_MESSAGE_LEVELS.ERROR %} + {% include "unfold/helpers/messages/error.html" with message=message %} + {% else %} + {% include "unfold/helpers/messages/debug.html" with message=message %} + {% endif %} +
  • + {% endfor %} +
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/surface/surfapp/templates/admin/base.html b/surface/surfapp/templates/admin/base.html new file mode 100644 index 00000000..e98d87dd --- /dev/null +++ b/surface/surfapp/templates/admin/base.html @@ -0,0 +1,62 @@ +{% extends 'unfold/layouts/skeleton.html' %} + +{% load admin_list i18n unfold %} + +{% block base %} +
+ {% if not is_popup and is_nav_sidebar_enabled %} + {% block nav-sidebar %} + {% include "admin/nav_sidebar.html" %} + {% endblock %} + {% endif %} + +
+ {% include "unfold/helpers/header.html" %} + + {% if not is_popup %} + {% spaceless %} + {% block breadcrumbs %} +
+
+
    + {% url 'admin:index' as link %} + {% trans 'Home' as name %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=name %} + {% block custom_breadcrumbs %}{% endblock %} +
+
+
+ {% endblock %} + {% endspaceless %} + {% endif %} + + {% block messages %} +
+
+ {% include "unfold/helpers/messages.html" %} +
+
+ {% endblock messages %} + +
+
+ {% if cl %} + {% tab_list "changelist" cl.opts %} + {% elif opts %} + {% tab_list "changeform" opts %} + {% endif %} + + {% block content %} + {% block object-tools %}{% endblock %} + + {{ content }} + {% endblock %} + + {% block sidebar %}{% endblock %} +
+
+ + {% block footer %}{% endblock %} +
+
+{% endblock %} \ No newline at end of file diff --git a/surface/surfapp/templates/admin/change_list.html b/surface/surfapp/templates/admin/change_list.html new file mode 100644 index 00000000..e3f292fe --- /dev/null +++ b/surface/surfapp/templates/admin/change_list.html @@ -0,0 +1,138 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_list unfold_list %} + +{% block extrastyle %} + {{ block.super }} + + + + + + + + + + {{ media.css }} + + {% if not actions_on_top and not actions_on_bottom %} + + {% endif %} +{% endblock %} + +{% block extrahead %} + {{ block.super }} + {{ media.js }} +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %} + +{% if not is_popup %} + {% block breadcrumbs %} +
+
+
    + {% url 'admin:index' as link %} + {% trans 'Home' as name %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=name %} + + {% url 'admin:app_list' app_label=cl.opts.app_label as link %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=cl.opts.app_config.verbose_name %} + + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=cl.opts.verbose_name_plural|capfirst %} +
+
+
+ {% endblock %} +{% endif %} + +{% block coltype %}{% endblock %} + +{% block nav-global-side %} + {% block object-tools %} +
+ {% block object-tools-items %} + {% change_list_object_tools %} + {% endblock %} +
+ {% endblock %} +{% endblock %} + +{% block content %} +
+ {% include "unfold/helpers/popup_header.html" %} + + {% if cl.formset and cl.formset.errors %} + {% include "unfold/helpers/messages/errornote.html" with errors=cl.formset.errors %} + + {{ cl.formset.non_form_errors }} + {% endif %} + +
+
+
+ {% block date_hierarchy %} + {% if cl.date_hierarchy %} + {% date_hierarchy cl %} + {% endif %} + {% endblock %} + + {% if cl.model_admin.list_before_template and not is_popup %} + {% include cl.model_admin.list_before_template %} + {% endif %} + + {% spaceless %} +
+ {% block search %} + {% search_form cl %} + {% endblock %} + + {% block filters %} + {% if cl.has_filters %} + + {% trans "Filters" %} + + filter_list + + {% endif %} + {% endblock %} +
+ {% endspaceless %} + +
+ {% csrf_token %} + + {% if cl.formset %} + {{ cl.formset.management_form }} + {% endif %} + + {% block result_list %} + {% include "unfold/helpers/change_list_actions.html" %} + + {% unfold_result_list cl %} + {% endblock %} +
+ + {% if cl.model_admin.list_after_template and not is_popup %} + {% include cl.model_admin.list_after_template %} + {% endif %} +
+ + {% if cl.has_filters %} + {% include "unfold/helpers/change_list_filter.html" %} + {% endif %} +
+
+
+{% endblock %} + +{% block footer %} + {% block pagination %} + {% include "unfold/helpers/pagination.html" %} + {% endblock %} +{% endblock %} diff --git a/surface/surfapp/templates/admin/dkron/job/change_list_object_tools.html b/surface/surfapp/templates/admin/dkron/job/change_list_object_tools.html index 2a702d6b..966fdf63 100644 --- a/surface/surfapp/templates/admin/dkron/job/change_list_object_tools.html +++ b/surface/surfapp/templates/admin/dkron/job/change_list_object_tools.html @@ -1,23 +1,22 @@ -{% extends "admin/change_list_object_tools.html" %} -{% load i18n admin_urls dkron %} +{% load unfold dkron admin_urls i18n %} {% block object-tools-items %} -{% if has_dashboard_permission %} -
  • - - - - {% url cl.opts|admin_urlname:'resync' as resync_url %} - - - -
  • -{% endif %} -{{ block.super }} -{% endblock %} +
    + {% if has_add_permission %} + {% include "unfold/helpers/add_link.html" %} + {% endif %} + {% if has_dashboard_permission %} + {% dkron_path as dashboard_url %} + {% url cl.opts|admin_urlname:'resync' as resync_url %} + {% add_preserved_filters resync_url is_popup to_field as resync_href %} + {% component "unfold/components/flex.html" with class="flex-col gap-4 lg:flex-row" %} + {% component "unfold/components/button.html" with href=dashboard_url %} + Dashboard + {% endcomponent %} + {% component "unfold/components/button.html" with href=resync_href variant="default"%} + Resync jobs + {% endcomponent %} + {% endcomponent %} + {% endif %} +
    +{% endblock %} \ No newline at end of file diff --git a/surface/surfapp/templates/admin/filter.html b/surface/surfapp/templates/admin/filter.html new file mode 100644 index 00000000..8afbc4b4 --- /dev/null +++ b/surface/surfapp/templates/admin/filter.html @@ -0,0 +1,88 @@ +{% load i18n unfold surface_templatetags %} + +
    +
    + + + {% if spec|class_name == "BooleanFieldListFilter" or spec.horizontal %} + {% for choice in choices %} + {% if choice.selected and spec.lookup_val.0 %} + + {% endif %} + {% endfor %} + + {% elif spec|class_name == "DateFieldListFilter" %} + {% for choice in choices %} + {% if choice.selected and spec.lookup_val.0 %} + + {% endif %} + {% endfor %} + + {% else %} +
    + +
    + {% endif %} +
    +
    + + + diff --git a/surface/surfapp/templates/admin/index.html b/surface/surfapp/templates/admin/index.html deleted file mode 100644 index 4eb9ab08..00000000 --- a/surface/surfapp/templates/admin/index.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "admin/index.html" %} - -{% block index-top-panel %} -
    -
    -
    -
    -

    {{ site_title|default:_('Django site admin') }}

    -
    Security Intelligence Automation Platform
    -
    - -
    -
    -
    -{% endblock %} diff --git a/surface/surfapp/templates/admin/notifications/notification/change_form_object_tools.html b/surface/surfapp/templates/admin/notifications/notification/change_form_object_tools.html index fb945539..82574e35 100644 --- a/surface/surfapp/templates/admin/notifications/notification/change_form_object_tools.html +++ b/surface/surfapp/templates/admin/notifications/notification/change_form_object_tools.html @@ -1,10 +1,14 @@ {% extends "admin/change_form_object_tools.html" %} -{% load i18n admin_urls %} +{% load i18n admin_urls unfold%} {% block object-tools-items %}
  • {% url opts|admin_urlname:'preview' original.pk|admin_urlquote as preview_url %} - {% translate "Preview" %} +
    + {% component "unfold/components/button.html" with type="submit" class="mr-2" %} + {% translate "Preview" %} + {% endcomponent %} +
  • {# this is the only reason for overriding this template -> blank item for now to separate - FIXME: fix CSS in surface-theme to handle multiple buttons correctly #}
  • diff --git a/surface/surfapp/templates/admin/notifications/notification/preview_mail.html b/surface/surfapp/templates/admin/notifications/notification/preview_mail.html index 79fbbd8f..805631a8 100644 --- a/surface/surfapp/templates/admin/notifications/notification/preview_mail.html +++ b/surface/surfapp/templates/admin/notifications/notification/preview_mail.html @@ -1,40 +1,71 @@ -{% extends "admin/base.html" %} -{% load i18n admin_urls static admin_list %} +{% extends "admin/base_site.html" %} -{% block title %}{{ title }}{% endblock %} +{% load i18n admin_urls static admin_modify unfold %} -{% block content %} +{% block extrahead %}{{ block.super }} + + {{ media }} +{% endblock %} -
    - -
    -
    -
    -
    -
    -

    {{ title }}

    -
    -
    +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-form{% endblock %} + +{% if not is_popup %} + {% block breadcrumbs %} +
    +
    +
      + {% url 'admin:index' as link %} + {% trans 'Home' as name %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=name %} + + {% url 'admin:app_list' app_label=opts.app_label as link %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=opts.app_config.verbose_name %} + + {% if has_view_permission %} + {% url opts|admin_urlname:'changelist' as link %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=opts.verbose_name_plural|capfirst %} + {% else %} + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=opts.verbose_name_plural|capfirst %} + {% endif %} + + {% if add %} + {% blocktranslate trimmed with name=opts.verbose_name asvar breadcrumb_name %} + Add {{ name }} + {% endblocktranslate %} + + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=breadcrumb_name %} + {% else %} + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=original|truncatewords:'18' %} + {% endif %} +
    + {% endblock %} +{% endif %} + + +{% block nav-global-side %} + {% if has_add_permission %} + {% include "unfold/helpers/add_link.html" %} + {% endif %} +{% endblock %} +{% block content %} + +
    +
    +

    {{ title }}

    -
    - {% if html_message %} - - {% else %} -
    {{ original.message }}
    - {% endif %} -
    + {% if html_message %} +
    + +
    + {% else %} +
    {{ original.message }}
    + {% endif %}
    {% endblock %} - -{% block javascripts %} - {{ block.super }} - -{% endblock javascripts %} diff --git a/surface/surfapp/templates/admin/notifications/notification/preview_slack.html b/surface/surfapp/templates/admin/notifications/notification/preview_slack.html index 2a881ecf..8fd91b37 100644 --- a/surface/surfapp/templates/admin/notifications/notification/preview_slack.html +++ b/surface/surfapp/templates/admin/notifications/notification/preview_slack.html @@ -1,50 +1,81 @@ -{% extends "admin/base.html" %} -{% load i18n admin_urls static admin_list %} +{% extends "admin/base_site.html" %} -{% block title %}{{ title }}{% endblock %} +{% load i18n admin_urls static admin_modify unfold %} -{% block content %} +{% block extrahead %}{{ block.super }} + + {{ media }} +{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-form{% endblock %} + +{% if not is_popup %} + {% block breadcrumbs %} +
    +
    +
      + {% url 'admin:index' as link %} + {% trans 'Home' as name %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=name %} + + {% url 'admin:app_list' app_label=opts.app_label as link %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=opts.app_config.verbose_name %} + + {% if has_view_permission %} + {% url opts|admin_urlname:'changelist' as link %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=opts.verbose_name_plural|capfirst %} + {% else %} + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=opts.verbose_name_plural|capfirst %} + {% endif %} + + {% if add %} + {% blocktranslate trimmed with name=opts.verbose_name asvar breadcrumb_name %} + Add {{ name }} + {% endblocktranslate %} -
      - -
      -
      -
      -
      -
      -

      {{ title }}

      -
      -
      - -
      -
      - - markdown is readable but you can always render this message in - - block-kit-builder - - -
      -
       
      -
      {{ message }}
      - {% for attachment in attachments %} -
       
      -
      - - markdown is readable but you can always render this attachment in - - block-kit-builder - - -
      -
      {{ attachment.0 }}
      - {% endfor %} -
      + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=breadcrumb_name %} + {% else %} + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=original|truncatewords:'18' %} + {% endif %} +
    -
    + {% endblock %} +{% endif %} + + +{% block nav-global-side %} + {% if has_add_permission %} + {% include "unfold/helpers/add_link.html" %} + {% endif %} +{% endblock %} +{% block content %} + +
    +
    +

    {{ title }}

    +
    +
    +
    + + markdown is readable but you can always render this message in + + block-kit-builder + + +
    +
    {{ message }}
    + {% for attachment in attachments %} +
    + + markdown is readable but you can always render this attachment in + + block-kit-builder + + +
    +
    {{ attachment.0 }}
    + {% endfor %}
    {% endblock %} diff --git a/surface/surfapp/templates/includes/navigation.html b/surface/surfapp/templates/includes/navigation.html index e1170d0d..c00b868b 100644 --- a/surface/surfapp/templates/includes/navigation.html +++ b/surface/surfapp/templates/includes/navigation.html @@ -1,5 +1,9 @@ {% extends "includes/navigation.html" %} +{% block top_bar_authenticated %} +{{ block.super }} +{% endblock %} + {% block menu_options %} Profile diff --git a/surface/surfapp/templates/includes/sidenav.html b/surface/surfapp/templates/includes/sidenav.html new file mode 100644 index 00000000..3e892149 --- /dev/null +++ b/surface/surfapp/templates/includes/sidenav.html @@ -0,0 +1,10 @@ +{% extends "includes/sidenav.html" %} + +{% block side_menu_authenticated_extra %} + +{% endblock %} diff --git a/surface/surfapp/templates/unfold/helpers/account_links.html b/surface/surfapp/templates/unfold/helpers/account_links.html new file mode 100644 index 00000000..59bfe1f8 --- /dev/null +++ b/surface/surfapp/templates/unfold/helpers/account_links.html @@ -0,0 +1,35 @@ +{% load i18n %} + +
    + + + person + + + + +
    \ No newline at end of file diff --git a/surface/surfapp/templates/unfold/helpers/app_list.html b/surface/surfapp/templates/unfold/helpers/app_list.html new file mode 100644 index 00000000..1b869390 --- /dev/null +++ b/surface/surfapp/templates/unfold/helpers/app_list.html @@ -0,0 +1,111 @@ +{% load i18n unfold %} + +{% if sidebar_navigation %} + + + {% if sidebar_show_all_applications and app_list|length > 0 %} +
    + + + apps + + + + {% trans "All applications" %} + + + +
    +
    + +
    + {% for app in app_list %} +
    +

    + {{ app.name }} +

    + + +
    + {% endfor %} +
    +
    +
    + {% endif %} +{% else %} +

    + {% trans "You don’t have permission to view or edit anything." as error_message %} + {% include "unfold/helpers/messages/error.html" with error=error_message %} +

    +{% endif %} \ No newline at end of file diff --git a/surface/surfapp/templates/unfold/helpers/fieldset_row_field.html b/surface/surfapp/templates/unfold/helpers/fieldset_row_field.html new file mode 100644 index 00000000..86470651 --- /dev/null +++ b/surface/surfapp/templates/unfold/helpers/fieldset_row_field.html @@ -0,0 +1,41 @@ +{% load surface_templatetags %} +
    + {{ field.label_tag }} +
    + +
    + {% if field.is_readonly %} + {% if value %} +
    + {{ value }} +
    + {% elif field.contents %} + {% with content=original|get_field_content:field %} + {% if content.type == 'json' %} + {{ content.field }} + {% else %} +
    + {{ field.contents }} +
    + {% endif %} + {% endwith %} + {% else %} +
    + - +
    + {% endif %} + + {% else %} + {{ field.field }} + {% endif %} + + {% if field.field.help_text and not field.is_checkbox %} + {% include "unfold/helpers/help_text.html" with help_text=field.field.help_text %} + {% endif %} + + {% if field.errors %} +
    + {{ field.errors }} +
    + {% endif %} +
    diff --git a/surface/surfapp/templates/unfold/helpers/header.html b/surface/surfapp/templates/unfold/helpers/header.html new file mode 100644 index 00000000..b61097d7 --- /dev/null +++ b/surface/surfapp/templates/unfold/helpers/header.html @@ -0,0 +1,28 @@ +{% load unfold %} +{% if not is_popup %} + {% block header %} +
    +
    +
    +
    +
    + {% block usertools %} + {% if has_permission %} + {% block welcome-msg %} + {% include 'unfold/helpers/welcomemsg.html' %} + {% endblock %} + {% component "unfold/components/navigation.html" with items=navigation %}{% endcomponent %} + {% endif %} + {% endblock %} +
    +
    + {% block userlinks %} + {% include 'unfold/helpers/userlinks.html' %} + {% endblock %} +
    +
    +
    +
    +
    + {% endblock %} +{% endif %} \ No newline at end of file diff --git a/surface/surfapp/templates/unfold/helpers/welcomemsg.html b/surface/surfapp/templates/unfold/helpers/welcomemsg.html new file mode 100644 index 00000000..e864514d --- /dev/null +++ b/surface/surfapp/templates/unfold/helpers/welcomemsg.html @@ -0,0 +1,39 @@ +{% load unfold i18n %} + +
    + {% if is_nav_sidebar_enabled %} + + + dock_to_right + + + + dock_to_right + + + {% endif %} + + + + {% include "unfold/helpers/header_back_button.html" %} + +

    + {% if content_title %} + + {{ pretitle }} + + {{ content_title }} + + + {% if cl and cl.full_result_count != cl.result_count and cl.paginator|class_name != "InfinitePaginator" %} + + {% blocktranslate count counter=cl.result_count %}{{ counter }} result{% plural %}{{ counter }} results{% endblocktranslate %} ({% if cl.show_full_result_count %}{% blocktranslate with full_result_count=cl.full_result_count %}{{ full_result_count }} total{% endblocktranslate %}{% else %}{% translate "Show all" %}{% endif %}) + + {% endif %} + + {% if content_subtitle %} + {{ content_subtitle }} + {% endif %} + {% endif %} +

    +
    \ No newline at end of file diff --git a/surface/surfapp/templatetags/__init__.py b/surface/surfapp/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/surface/surfapp/templatetags/surface_templatetags.py b/surface/surfapp/templatetags/surface_templatetags.py new file mode 100644 index 00000000..15900347 --- /dev/null +++ b/surface/surfapp/templatetags/surface_templatetags.py @@ -0,0 +1,91 @@ +from datetime import timedelta +from urllib.parse import parse_qs + +from django import template +from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import models +from django.urls import reverse +from django.utils import timezone +from jsoneditor.forms import JSONEditor + +register = template.Library() + + +@register.simple_tag +def surface_stats(period): + today = timezone.now() + + if period == "last_24_hours": + yesterday = today - timedelta(days=1) + return get_user_model().objects.filter(last_login__gte=yesterday).count() + elif period == "last_2_weeks": + two_weeks_ago = today - timedelta(days=14) + return get_user_model().objects.filter(last_login__gte=two_weeks_ago).count() + elif period == "total": + return get_user_model().objects.exclude(last_login=None).count() + return 0 + + +@register.simple_tag(takes_context=True) +def surface_get_links(context): + return getattr(settings, "SURFACE_LINKS_ITEMS", None) + + +@register.filter +def get_query_param(query_string, lookup_kwarg): + """ + Extracts the value of param_name from a query string. + Usage: {{ query_string|get_query_param:param_name }} + """ + if not query_string: + return "" + # Remove leading '?' if present + if query_string.startswith("?"): + query_string = query_string[1:] + params = parse_qs(query_string) + value = params.get(lookup_kwarg) + if value: + return value[0] + return "" + + +@register.filter +def get_setting_value(setting_name): + """ + Returns the value of a setting by its name. + Usage: {{ 'MY_SETTING'|get_setting_value }} + """ + return getattr(settings, setting_name, None) + + +# Helpful with templates to see what's in an object +@register.filter +def get_field_content(obj, field): + try: + field_name = field.field["name"] + obj_field = obj._meta.get_field(field_name) + + if obj_field.__class__ is models.JSONField: + return { + "type": "json", + "field": JSONEditor(attrs={"style": "background-color: white !important;"}).render( + field_name, getattr(obj, field_name) or [], attrs={"id": f"id_{field_name}"} + ), + } + + elif isinstance(obj_field, models.ManyToManyField): + objects = getattr(obj, field_name) + + urls = [] + for obj in objects.all(): + url = reverse(f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change", args=[obj.pk]) + urls.append({"url": url, "obj": obj}) + + return {"type": "urls", "field": urls} + + else: + return {"type": "field", "field": field} + + except Exception: + return {"type": "field", "field": field} diff --git a/surface/vulns/admin.py b/surface/vulns/admin.py index b9ecb0c3..b7387ffe 100644 --- a/surface/vulns/admin.py +++ b/surface/vulns/admin.py @@ -2,31 +2,32 @@ from django.urls import reverse from django.utils.html import format_html +from core_utils.admin import DefaultModelAdmin from core_utils.decorators import admin_link from vulns import models @admin.register(models.Finding) -class FindingAdmin(admin.ModelAdmin): - search_fields = ['id', 'title', 'summary'] +class FindingAdmin(DefaultModelAdmin): + search_fields = ["id", "title", "summary"] list_display = [ - 'id', - 'title', - 'severity', - 'summary', - 'state', - 'get_tla_link', - 'get_content_source', + "id", + "title", + "severity", + "summary", + "state", + "get_tla_link", + "get_content_source", ] - list_select_related = ['content_source'] + list_select_related = ["content_source"] list_filter = [ "severity", "state", ] - @admin_link('application', 'Application') + @admin_link("application", "Application") def get_tla_link(self, app): return str(app) @@ -34,12 +35,12 @@ 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}', + 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' + get_content_source.short_description = "Content Source" + get_content_source.admin_order_field = "content_source" def has_delete_permission(self, request, obj=None): # check children Admin model for ALLOW_DELETE! @@ -49,4 +50,4 @@ def has_delete_permission(self, request, obj=None): ak = self.admin_site._registry.get(k) if ak is None or not ak.ALLOW_DELETE: return False - return admin.ModelAdmin.has_delete_permission(self, request, obj) + return DefaultModelAdmin.has_delete_permission(self, request, obj) From a32ba5d2cdc466ceae038d4742fb58aa8ae34ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Mon, 20 Oct 2025 16:02:49 +0100 Subject: [PATCH 02/39] Django unfold changes --- surface/surface/settings.py | 13 ++- surface/surfapp/admin.py | 171 +++++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 6 deletions(-) diff --git a/surface/surface/settings.py b/surface/surface/settings.py index bdd1e1b5..43969a32 100644 --- a/surface/surface/settings.py +++ b/surface/surface/settings.py @@ -12,6 +12,8 @@ from pathlib import Path +import yaml + import ppbenviron # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -20,6 +22,8 @@ ENV_VAR = ppbenviron.CustomEnv() ENV_VAR.read_env(BASE_DIR / "local.env") +from surface.sidebar import SIDEBAR + # Application definition INSTALLED_APPS = [ @@ -35,8 +39,8 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.db.migrations", "impersonate", - "surfapp", "dkron", "notifications", "slackbot", @@ -51,6 +55,7 @@ "sca", "sbomrepo", "jsoneditor", + "surfapp", ] MIDDLEWARE = [ @@ -197,8 +202,8 @@ SURFACE_GITLAB_TOKEN = ENV_VAR("SURF_GITLAB_TOKEN", default=None) -SURFACE_LINKS_ITEMS = None -SURFACE_MENU_ITEMS = None +with open(BASE_DIR / "surface" / "links.yml") as f: + SURFACE_LINKS_ITEMS = yaml.safe_load(f) LOGBASECOMMAND_PREFIX = "surface.command" @@ -301,5 +306,5 @@ "950": "23, 37, 84", }, }, - # "SIDEBAR": SIDEBAR, + "SIDEBAR": SIDEBAR, } diff --git a/surface/surfapp/admin.py b/surface/surfapp/admin.py index 0acaae51..4b430f8a 100644 --- a/surface/surfapp/admin.py +++ b/surface/surfapp/admin.py @@ -1,10 +1,177 @@ +from django.contrib import admin from django.contrib.admin import site -from django.contrib.auth import admin +from django.contrib.auth import admin as AuthAdmin +from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.models import Group, User +from django.db.migrations.recorder import MigrationRecorder +from django.utils.html import format_html +from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm +from apitokens.admin import MyTokenAdmin, TokenAdmin +from apitokens.models import MyToken, Token +from core_utils.admin import DefaultModelAdmin +from core_utils.admin_filters import DropdownFilter +from dbcleanup import utils as dbcleanup_utils +from dbcleanup.admin import TableAdmin +from dbcleanup.models import Table +from dkron import utils +from dkron.admin import JobAdmin +from dkron.models import Job from impersonate.admin import impersonate_action +from notifications.admin import EventAdmin, SubscriptionAdmin +from notifications.models import Event, Subscription -admin.UserAdmin.actions = list(admin.UserAdmin.actions) + [impersonate_action] +AuthAdmin.UserAdmin.actions = AuthAdmin.UserAdmin.actions + (impersonate_action,) site.site_title = "Surface" site.site_url = "https://github.com/surface-security/surface" site.index_title = "Home" + + +# User Admin Patch +admin.site.unregister(User) + + +@admin.register(User) +class UserAdmin(BaseUserAdmin, DefaultModelAdmin): + # Forms loaded from `unfold.forms` + form = UserChangeForm + add_form = UserCreationForm + change_password_form = AdminPasswordChangeForm + + +# Group Admin Patch +admin.site.unregister(Group) + + +@admin.register(Group) +class GroupAdmin(BaseGroupAdmin, DefaultModelAdmin): + pass + + +@admin.register(MigrationRecorder.Migration) # noqa: F405 +class MigrationAdmin(DefaultModelAdmin): # noqa: F405 + list_display = ("app", "name", "applied") + search_fields = ("app", "name") + list_filter = ("app",) + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +# User Admin Patch +admin.site.unregister(User) + + +@admin.register(User) +class UserAdmin(BaseUserAdmin, DefaultModelAdmin): + # Forms loaded from `unfold.forms` + form = UserChangeForm + add_form = UserCreationForm + change_password_form = AdminPasswordChangeForm + + +admin.site.unregister(Group) + + +@admin.register(Group) +class GroupAdmin(BaseGroupAdmin, DefaultModelAdmin): + pass + + +# Notifications Admin patch +admin.site.unregister(Event) + + +@admin.register(Event) +class EventAdminAdmin(EventAdmin, DefaultModelAdmin): + pass + + +admin.site.unregister(Subscription) + + +@admin.register(Subscription) +class SubscriptionAdmin(SubscriptionAdmin, DefaultModelAdmin): + pass + + +# Token Admin Patch +admin.site.unregister(Token) + + +@admin.register(Token) +class TokenAdmin(TokenAdmin, DefaultModelAdmin): + pass + + +admin.site.unregister(MyToken) + + +@admin.register(MyToken) +class MyTokenAdmin(MyTokenAdmin, DefaultModelAdmin): + pass + + +# DKron Job Admin Patch +admin.site.unregister(Job) + + +@admin.register(Job) +class JobAdmin(JobAdmin, DefaultModelAdmin): + @admin.display(description="DKRON Link") + def get_dkron_link(self, obj): + return format_html( + '' + 'link' + "", + utils.job_executions(obj.name), + ) + + +# Administration dbcleanup TableAdmin and Filters Patch +admin.site.unregister(Table) + + +class TableAppFilter(DropdownFilter): + title = "App" + parameter_name = "app_label" + + def lookups(self, request, model_admin): + unique_labels = {x._meta.app_label for x in dbcleanup_utils.model_tables().values()} + return [(x, x) for x in unique_labels] + + def queryset(self, request, queryset): + val = self.value() + if val is None: + return None + app_tables = [k for k, v in dbcleanup_utils.model_tables().items() if v._meta.app_label == val] + return queryset.filter(name__in=app_tables) + + +class TableModelFilter(DropdownFilter): + title = "Model" + parameter_name = "label" + + def lookups(self, request, model_admin): + unique_labels = {x._meta.label for x in dbcleanup_utils.model_tables().values()} + return [(x, x) for x in unique_labels] + + def queryset(self, request, queryset): + val = self.value() + if val is None: + return None + app_tables = [k for k, v in dbcleanup_utils.model_tables().items() if v._meta.label == val] + return queryset.filter(name__in=app_tables) + + +@admin.register(Table) +class TableAdmin(TableAdmin, DefaultModelAdmin): + list_filter = ( + TableAppFilter, + TableModelFilter, + ) From 4f7ed96b50560eeff2aae85ce82b514fd163ff90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Mon, 20 Oct 2025 16:03:30 +0100 Subject: [PATCH 03/39] django unfold index and sidebar --- surface/surface/links.yml | 8 + surface/surface/sidebar.py | 94 ++++++++ surface/surfapp/templates/admin/index.html | 226 ++++++++++++++++++ .../unfold/helpers/surface_stats.html | 24 ++ 4 files changed, 352 insertions(+) create mode 100644 surface/surface/links.yml create mode 100644 surface/surface/sidebar.py create mode 100644 surface/surfapp/templates/admin/index.html create mode 100644 surface/surfapp/templates/unfold/helpers/surface_stats.html diff --git a/surface/surface/links.yml b/surface/surface/links.yml new file mode 100644 index 00000000..dcf76a1c --- /dev/null +++ b/surface/surface/links.yml @@ -0,0 +1,8 @@ +--- +- label: Surface Security + items: + - name: Surface + url: https://github.com/surface-security/surface + - name: Surface Security Org + url: https://github.com/surface-security/ + \ No newline at end of file diff --git a/surface/surface/sidebar.py b/surface/surface/sidebar.py new file mode 100644 index 00000000..a7061fc5 --- /dev/null +++ b/surface/surface/sidebar.py @@ -0,0 +1,94 @@ +from django.urls import reverse_lazy + + +def check_permission(permission): + """ + Callback function to check if the user has a specific permission. + This is used in the sidebar menu to conditionally display items based on permissions. + """ + + def checker(request): + if user := getattr(request, "user", None): + return user.has_perm(permission) + return False + + return checker + + +SIDEBAR = { + "show_search": True, + "show_all_applications": True, + "navigation": [ + { + "title": "Navigation", + "items": [ + { + "title": "Dashboard", + "icon": "dashboard", + "link": reverse_lazy("admin:index"), + }, + ], + }, + { + "title": "Administration", + "icon": "admin_panel_settings", + "collapsible": True, + "items": [ + { + "title": "Notifications Logs", + "icon": "notifications", + "link": reverse_lazy("admin:notifications_notification_changelist"), + "permission": check_permission("notifications.view_notification"), + }, + { + "title": "Notifications Events", + "icon": "event", + "link": reverse_lazy("admin:notifications_event_changelist"), + "permission": check_permission("notifications.view_event"), + }, + { + "title": "Notifications Subscriptions", + "icon": "subscriptions", + "link": reverse_lazy("admin:notifications_subscription_changelist"), + "permission": check_permission("notifications.view_subscription"), + }, + { + "title": "Database Logs (Migrations)", + "icon": "storage", + "link": reverse_lazy("admin:migrations_migration_changelist"), + "permission": check_permission("admin.view_logentry"), + }, + { + "title": "Database Info (Size)", + "icon": "table_chart", + "link": "/dbcleanup/table/", + "permission": check_permission("auth.view_user"), + }, + { + "title": "Cron Jobs (dkron)", + "icon": "schedule", + "link": reverse_lazy("admin:dkron_job_changelist"), + "permission": check_permission("dkron.view_job"), + }, + { + "title": "REST API Tokens", + "icon": "vpn_key", + "link": reverse_lazy("admin:apitokens_token_changelist"), + "permission": check_permission("apitokens.view_token"), + }, + { + "title": "Users permission (Surface)", + "icon": "person", + "link": reverse_lazy("admin:auth_user_changelist"), + "permission": check_permission("auth.view_user"), + }, + { + "title": "Groups permission (Surface)", + "icon": "group", + "link": reverse_lazy("admin:auth_group_changelist"), + "permission": check_permission("auth.view_group"), + }, + ], + }, + ], +} diff --git a/surface/surfapp/templates/admin/index.html b/surface/surfapp/templates/admin/index.html new file mode 100644 index 00000000..817e8d55 --- /dev/null +++ b/surface/surfapp/templates/admin/index.html @@ -0,0 +1,226 @@ +{% extends 'admin/base.html' %} + +{% load i18n unfold surface_templatetags log %} + +{% block breadcrumbs %}{% endblock %} + +{% block title %} + {% trans 'Dashboard' %} | {{ site_title|default:_('Django site admin') }} +{% endblock %} + +{% block extrahead %} + {{ block.super }} + + {% if plausible_domain %} + + {% endif %} +{% endblock %} + +{% block branding %} + {% include "unfold/helpers/site_branding.html" %} +{% endblock %} +{% block content %} + {% include "unfold/helpers/messages.html" %} + {% component "unfold/components/container.html" with class="h-full min-h-screen overflow-y-auto" %} +
    +
    +
    +
    + {% component "unfold/components/navigation.html" with items=navigation %}{% endcomponent %} + {% component "unfold/components/navigation.html" with class="sm:ml-auto" items=filters %}{% endcomponent %} + +
    + {% component "unfold/components/navigation.html" with items=navigation %}{% endcomponent %} + {% component "unfold/components/navigation.html" with class="sm:ml-auto" items=filters %}{% endcomponent %} +
    +
    +
    + {% include "unfold/helpers/surface_stats.html" %} +
    +
    + +
    + {% component "unfold/components/card.html" with title="List of Modules" class="w-full" %} + {% for app in app_list %} + {% if app.models %} +
    +
    +
    + + {{ app.name }} + +
    {{ app.models|length }} models
    +
    +
    +
    + {% for model in app.models %} + {{ model.name }} + {% endfor %} +
    +
    + {% if not forloop.last %} +
    + {% endif %} + {% endif %} + {% endfor %} + {% endcomponent %} +
    + +
    + {% component "unfold/components/card.html" with title="Useful Links" class="w-full" %} + {% surface_get_links as surface_get_links_list %} + {% if surface_get_links_list %} +
      + {% for link in surface_get_links_list %} +
    • +
      {{ link.label }}
      + +
    • + {% endfor %} +
    + {% else %} +
    {% trans "No useful links available." %}
    + {% endif %} + {% endcomponent %} +
    + +
    + {% component "unfold/components/card.html" with title=_('Recent Actions') class="w-full" %} + {% get_admin_log 10 as admin_log_user for_user user %} +
    +
    {% trans "Your Activity" %}
    + {% if admin_log_user %} +
      + {% for entry in admin_log_user %} +
    1. +
      + {% if entry.is_addition %} + + add + + {% elif entry.is_change %} + + edit + + {% elif entry.is_deletion %} + + delete + + {% else %} + + + {% endif %} +
      +
      +
      + + + calendar_clock + + {{ entry.action_time|date:'M d, H:i' }} + + + + person + + {{ entry.user.get_username }} + +
      +
      + {% if entry.is_deletion or not entry.get_admin_url %} + {{ entry.content_type }} | {{ entry.object_id }} + {% else %} + + {{ entry.content_type }} | {{ entry.object_id }} + + {% endif %} +
      +
      +
    2. + {% endfor %} +
    + {% else %} +
    {% trans "No recent actions available for you." %}
    + {% endif %} +
    + {% if request.user.is_superuser %} + {% get_admin_log 50 as admin_log_all %} +
    +
    {% trans "Everyone's Activity" %}
    + {% if admin_log_all %} +
      + {% for entry in admin_log_all %} +
    1. +
      + {% if entry.is_addition %} + + add + + {% elif entry.is_change %} + + edit + + {% elif entry.is_deletion %} + + delete + + {% else %} + + + {% endif %} +
      +
      +
      + + + calendar_clock + + {{ entry.action_time|date:'M d, H:i' }} + + + + person + + {{ entry.user.get_username }} + +
      +
      + {% if entry.is_deletion or not entry.get_admin_url %} + {{ entry.content_type }} | {{ entry.object_id }} + {% else %} + + {{ entry.content_type }} | {{ entry.object_id }} + + {% endif %} +
      +
      +
    2. + {% endfor %} +
    + {% else %} +
    {% trans "No recent actions available for everyone." %}
    + {% endif %} +
    + {% endif %} + {% endcomponent %} +
    +
    + {% endcomponent %} +{% endblock %} +{% block footer %} +
    + + +{% endblock %} \ No newline at end of file diff --git a/surface/surfapp/templates/unfold/helpers/surface_stats.html b/surface/surfapp/templates/unfold/helpers/surface_stats.html new file mode 100644 index 00000000..f79c27fb --- /dev/null +++ b/surface/surfapp/templates/unfold/helpers/surface_stats.html @@ -0,0 +1,24 @@ +{% load i18n unfold surface_templatetags log %} +{% component "unfold/components/container.html" %} +
    + {% component "unfold/components/card.html" with class="p-4 text-center flex-[2]" %} + groups +
    Last 24 Hours
    +
    {% surface_stats 'last_24_hours' %}
    + {% endcomponent %} + {% component "unfold/components/card.html" with class="p-4 text-center flex-1" %} + groups +
    Last 2 Weeks
    +
    {% surface_stats 'last_2_weeks' %}
    + {% endcomponent %} + {% component "unfold/components/card.html" with class="p-4 text-center flex-1" %} + groups +
    Total
    +
    {% surface_stats 'total' %}
    + {% endcomponent %} + {% component "unfold/components/card.html" with class="p-4 text-center flex-1" %} +
    Code Contributors
    +
    +30
    + {% endcomponent %} +
    +{% endcomponent %} From f82cff3abdc409aea61b70f0e5c51cbe3e2c0a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Mon, 20 Oct 2025 16:16:51 +0100 Subject: [PATCH 04/39] SCA - Django Unfold and Improvements --- surface/sca/admin.py | 277 ++++++++++++---- surface/sca/templates/views/dependencies.html | 180 +++++++---- surface/sca/templates/views/layout.html | 297 +++++++++++------- .../sca/templates/views/vulnerabilities.html | 122 +++---- 4 files changed, 578 insertions(+), 298 deletions(-) diff --git a/surface/sca/admin.py b/surface/sca/admin.py index 824baf8d..b6384ca1 100644 --- a/surface/sca/admin.py +++ b/surface/sca/admin.py @@ -1,31 +1,33 @@ import logging from datetime import datetime -from typing import Any +from typing import Any, Optional import django_filters from django import forms from django.contrib import admin, messages from django.db.models import Count, Q from django.db.models.query import QuerySet +from django.forms.models import model_to_dict from django.http import HttpRequest, HttpResponseRedirect from django.shortcuts import redirect from django.template.defaultfilters import truncatechars +from django.urls import reverse from django.utils.html import format_html +from django.utils.safestring import mark_safe from django_object_actions import DjangoObjectActions from jsoneditor.forms import JSONEditor from core_utils.admin import DefaultModelAdmin -from core_utils.admin_filters import DefaultFilterMixin, RelatedFieldAjaxListFilter +from core_utils.admin_filters import DefaultFilterMixin, DropdownFilter, RelatedFieldAjaxListFilter from core_utils.utils import admin_reverse from dkron.utils import run_async -from inventory.models import GitSource from sca import models from sca.utils import only_highest_version_dependencies logger = logging.getLogger(__name__) -class EndOfLifeDependencyBoolFilter(admin.SimpleListFilter): +class EndOfLifeDependencyBoolFilter(DropdownFilter): title = "EoL" parameter_name = "eol_filter" field = "eol" @@ -86,8 +88,12 @@ class Meta: fields = ("dependency_tree", "parent_tree") # Custom field - dependency_tree = forms.JSONField(widget=JSONEditor, label="Dependency Tree") - parent_tree = forms.JSONField(widget=JSONEditor, label="Parent Tree") + dependency_tree = forms.JSONField( + widget=JSONEditor(attrs={"style": "background-color: white !important;"}), label="Dependency Tree" + ) + parent_tree = forms.JSONField( + widget=JSONEditor(attrs={"style": "background-color: white !important;"}), label="Parent Tree" + ) def __init__(self, *args, **kwargs): instance = kwargs.get("instance", None) @@ -100,7 +106,7 @@ def __init__(self, *args, **kwargs): @admin.register(models.SCADependency) -class SCADependencyAdmin(DefaultModelAdmin, DefaultFilterMixin): +class SCADependencyAdmin(DefaultFilterMixin, DefaultModelAdmin): form = SCADependencyForm list_display = [ "purl", @@ -115,6 +121,7 @@ class SCADependencyAdmin(DefaultModelAdmin, DefaultFilterMixin): "name", "git_source__repo_url", "git_source__apps__tla", + "git_source__excluded", "is_public", "is_project", ] @@ -133,7 +140,7 @@ def get_queryset(self, request): def get_git_source(self, obj): if obj.git_source: return format_html( - f'{obj.git_source.repo_url}' + f'{obj.git_source.repo_url}' ) @admin.display(description="Depends On") @@ -150,29 +157,35 @@ def get_dependencies(self, obj): ( "General Info", { + "classes": ["tab"], "fields": ( "purl", "name", "version", "dependency_type", "git_source", - ) + ), }, ), ( "Dependency Tree", { + "classes": ["tab"], "fields": ("dependency_tree",), }, ), ( "Parent Tree", { + "classes": ["tab"], "fields": ("parent_tree",), }, ), ) + def get_default_filters(self, request): + return {"git_source__excluded__exact": 0} + class SCAFindingFilter(django_filters.FilterSet): dependency__purl = django_filters.CharFilter(lookup_expr="icontains", label="Dependency") @@ -230,9 +243,15 @@ def filter_vulnerable(self, queryset, name, value): @admin.register(models.SCAProject) -class SCAProjectAdmin(DefaultModelAdmin): - list_display = ["purl", "get_vulns", "get_git_source", "name", "last_scan", "created_at"] - list_filter = ["name", "git_source", "git_source__apps__tla"] +class SCAProjectAdmin(DefaultFilterMixin, DefaultModelAdmin): + list_display = ["purl", "get_vulns", "get_git_source", "get_sbom_link", "name", "last_scan", "created_at"] + list_filter = [ + "name", + "git_source", + ("git_source__apps", RelatedFieldAjaxListFilter), + "git_source__excluded", + BlockedBoolFilter, + ] search_fields = ["name", "purl", "depends_on__name", "depends_on__purl", "git_source__repo_url"] def change_view(self, request, object_id, form_url="", extra_context=None): @@ -260,45 +279,85 @@ def change_view(self, request, object_id, form_url="", extra_context=None): } vulnerabilities = self.get_vulnerabilities(obj) + # set fixed_in as True by default if not passed in the request + if "fixed_in" not in request.GET: + request.GET = request.GET.copy() + request.GET["fixed_in"] = "true" extra_context["vulns_filter"] = SCAFindingFilter(request.GET, queryset=vulnerabilities) if request.method == "POST": + git_source = obj.git_source if hasattr(obj, "git_source") else None if request.POST.get("action") == "run_renovate_dependencies": vulnerabilities = self.get_vulnerabilities(obj) dependencies = [vuln.dependency.purl for vuln in vulnerabilities] - git_source = obj.git_source if hasattr(obj, "git_source") else [] self.renovate(request, git_source, dependencies) return HttpResponseRedirect(request.get_full_path()) elif request.POST.get("action") == "run_renovate_dependencies_no_deps": - git_source = obj.git_source if hasattr(obj, "git_source") else [] self.renovate(request, git_source) return HttpResponseRedirect(request.get_full_path()) elif request.POST.get("action") == "run_renovate_dependency": dependency_id = request.POST.get("dependency_id") dependency = models.SCADependency.objects.get(pk=dependency_id).purl - git_source = obj.git_source if hasattr(obj, "git_source") else [] self.renovate(request, git_source, dependency) return HttpResponseRedirect(request.get_full_path()) + elif request.POST.get("jira_project"): + jira_project_value = request.POST.get("jira_project", "") + obj.jira_project = jira_project_value.upper() + obj.save() + messages.success(request, "Jira Support Team updated Successfully.") + elif request.POST.get("renovate_periodicity"): + obj.renovate_periodicity = int( + request.POST.get("renovate_periodicity", models.SCAProject.RenovatePeriodicity.NEVER) + ) + obj.save() + try: + if create_renovate_job(obj): + messages.success(request, f"Renovate job created for {obj.git_source.repo_url}") + else: + messages.error(request, f"Failed to create Renovate job for {obj.git_source.repo_url}") + except Exception as e: + messages.error(request, f"Failed to create Renovate job for {obj.git_source.repo_url}: {e}") + logger.exception("Failed to create Renovate job for %s", obj.git_source.repo_url) + + return HttpResponseRedirect(request.get_full_path()) else: + # Get only the highest version dependencies as those should be the ones actually installed + project_deps = only_highest_version_dependencies(obj.dependencies) + dependencies = ( - models.SCADependency.objects.filter(purl__in=obj.dependencies) + models.SCADependency.objects.filter(purl__in=project_deps) .prefetch_related("depends_on", "git_source__apps") .exclude(purl=obj.purl) ) - extra_context["deps_filter"] = SCADependencyFilter(request.GET, queryset=dependencies) + filtered_dependencies = [] + for dep in SCADependencyFilter(request.GET, queryset=dependencies).qs: + dep_dict = model_to_dict(dep) + dep_dict["created_at"] = dep.created_at + dep_dict["vulns_counters"] = dep.get_vulns_counter(obj) + filtered_dependencies.append(dep_dict) + + extra_context["deps_filter"] = SCADependencyFilter(request.GET, queryset=dependencies).form + extra_context["dependencies"] = filtered_dependencies return super().change_view(request, object_id, form_url, extra_context=extra_context) def get_vulnerabilities(self, obj): """Retrieve and filter vulnerabilities.""" - vulnerabilities = models.SCAFinding.objects.filter( - dependency__purl__in=obj.dependencies, - state__in=(models.SCAFinding.State.NEW, models.SCAFinding.State.OPEN), - ).prefetch_related("dependency") + suppressed_findings = models.SuppressedSCAFinding.objects.filter( + Q(sca_project=obj) | Q(sca_project__isnull=True) + ).values_list("vuln_id", flat=True) + vulnerabilities = ( + models.SCAFinding.objects.filter( + dependency__purl__in=obj.dependencies, + state__in=(models.SCAFinding.State.NEW, models.SCAFinding.State.OPEN), + ) + .prefetch_related("dependency") + .exclude(vuln_id__in=suppressed_findings) + ) highest_version_vulns_purls = only_highest_version_dependencies( vulnerabilities.values_list("dependency__purl", flat=True).distinct() @@ -306,7 +365,7 @@ def get_vulnerabilities(self, obj): return vulnerabilities.filter(dependency__purl__in=highest_version_vulns_purls) - def renovate(self, request, git_source: GitSource, dependencies=None): + def renovate(self, request, git_source: Optional[GitSource], dependencies=None): """Helper method to process sources for renovation.""" if not git_source: messages.error(request, "No sources found for the project.") @@ -329,7 +388,15 @@ def renovate(self, request, git_source: GitSource, dependencies=None): def get_git_source(self, obj): if obj.git_source: return format_html( - f'{obj.git_source.repo_url}' + f'{obj.git_source.repo_url}' + ) + + @admin.display(description="SBOM") + def get_sbom_link(self, obj): + if obj.sbom_uuid: + return format_html( + 'Download sbom json', + reverse("sca:download_sbom_as_json", args=[obj.sbom_uuid, obj.name]), ) @admin.display(description="Vulnerabilities") @@ -368,19 +435,30 @@ def get_vulns(self, obj): severity=vuln["severity"], color=vuln["color"], criticality=criticality if criticality != "eol" else None, - finding_type=( - models.SCAFinding.FindingType.VULN if criticality != "eol" else models.SCAFinding.FindingType.EOL - ), + finding_type=models.SCAFinding.FindingType.VULN + if criticality != "eol" + else models.SCAFinding.FindingType.EOL, ) for criticality, vuln in severity_mapping.items() ] - return format_html(" ".join(formatted_items)) + return format_html( + '
    {}
    ', + mark_safe("".join(formatted_items)), + ) def get_queryset(self, request): if request.resolver_match.view_name == "admin:sca_scaproject_change": return super().get_queryset(request).prefetch_related("depends_on") - return super().get_queryset(request).filter(is_project=True).prefetch_related("depends_on") + return ( + super() + .get_queryset(request) + .filter(is_project=True) + .prefetch_related("depends_on", "git_source", "git_source__apps") + ) + + def get_default_filters(self, request): + return {"git_source__excluded__exact": 0} def has_delete_permission(self, request, obj=None): return False @@ -388,6 +466,11 @@ def has_delete_permission(self, request, obj=None): def has_add_permission(self, request: HttpRequest) -> bool: return False + def lookup_allowed(self, lookup, value): + if lookup == "git_source__apps__tla": # not covered by list_filter + return True + return super().lookup_allowed(lookup, value) + @admin.register(models.SCAFinding) class SCAFindingAdmin(DjangoObjectActions, DefaultModelAdmin): @@ -413,6 +496,7 @@ class SCAFindingAdmin(DjangoObjectActions, DefaultModelAdmin): "published", "ecosystem", "finding_type", + HighEPSSFilter, ] search_fields = ["vuln_id", "ecosystem", "title", "summary", "aliases"] list_select_related = ["dependency"] @@ -458,6 +542,7 @@ def get_cvss_score(self, obj): obj.cvss_vector.split(":")[1].split("/")[0], obj.cvss_score, ) + return "N/A" def has_add_permission(self, request): @@ -475,6 +560,7 @@ class SuppressedSCAFindingAdmin(DefaultModelAdmin): list_display = [ "vuln_id", "get_dependency", + "get_sca_project", "suppress_reason", "created_by", "updated_by", @@ -484,10 +570,12 @@ class SuppressedSCAFindingAdmin(DefaultModelAdmin): list_filter = [ "vuln_id", ("dependency", RelatedFieldAjaxListFilter), + ("sca_project", RelatedFieldAjaxListFilter), ] search_fields = [ "vuln_id", "dependency__purl", + "sca_project", ] list_select_related = ["dependency", "created_by", "updated_by"] readonly_fields = ["created_by", "updated_by"] @@ -500,6 +588,21 @@ def get_dependency(self, obj): obj.dependency.purl, ) + @admin.display(description="SCA Project") + def get_sca_project(self, obj): + if obj.sca_project: + return format_html( + '{}', + admin_reverse( + obj.sca_project, + "changelist", + relative=True, + query_kwargs={"name": obj.sca_project.name}, + ), + obj.sca_project.name, + ) + return None + def get_changeform_initial_data(self, request): initial = super().get_changeform_initial_data(request) @@ -521,45 +624,99 @@ def get_changeform_initial_data(self, request): return initial + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + if obj is None: # Only for new objects + dependency_id = request.GET.get("dependency_id") + project_id = request.GET.get("project_id") + if dependency_id and project_id: + form.base_fields["vuln_id"].initial = request.GET.get("vuln_id") + form.base_fields["vuln_id"].disabled = True + try: + dependency_instance = models.SCADependency.objects.get(pk=int(dependency_id)) + project_instance = models.SCAProject.objects.get(pk=int(project_id)) + form.base_fields["dependency"].queryset = models.SCADependency.objects.filter( + pk=dependency_instance.pk + ) + form.base_fields["sca_project"].queryset = models.SCAProject.objects.filter(pk=project_instance.pk) + form.base_fields["dependency"].initial = dependency_instance + form.base_fields["dependency"].disabled = True + form.base_fields["dependency"].widget.can_add_related = False + form.base_fields["dependency"].widget.can_change_related = False + form.base_fields["dependency"].widget.can_delete_related = False + form.base_fields["sca_project"].initial = project_instance + form.base_fields["sca_project"].disabled = True + form.base_fields["sca_project"].widget.can_add_related = False + form.base_fields["sca_project"].widget.can_change_related = False + form.base_fields["sca_project"].widget.can_delete_related = False + except (models.SCADependency.DoesNotExist, models.SCAProject.DoesNotExist): + pass # Handle the case when the Dependency does not exist + return form + def save_model(self, request, obj, form, change): obj.updated_by = request.user if not obj.created_by: obj.created_by = request.user - # Close SCA Finding - closed_sca_findings = models.SCAFinding.objects.filter( - dependency=obj.dependency, - vuln_id=obj.vuln_id, - state__in=(models.SCAFinding.State.NEW, models.SCAFinding.State.OPEN), - ).update(state=models.SCAFinding.State.CLOSED) - - for project in obj.dependency.projects: - project.update_vulnerability_counters() - - messages.success( - request, - format_html( - "{} Findings Suppressed!", - closed_sca_findings, - ), - ) + if not obj.sca_project: + closed_sca_findings = models.SCAFinding.objects.filter( + dependency=obj.dependency, + vuln_id=obj.vuln_id, + state__in=(models.SCAFinding.State.NEW, models.SCAFinding.State.OPEN), + ).update(state=models.SCAFinding.State.CLOSED) + if closed_sca_findings: + messages.success( + request, + format_html( + "{} Findings Closed!", + closed_sca_findings, + ), + ) + else: + messages.success( + request, + format_html( + "{} Suppressed!", + obj.vuln_id, + ), + ) + return super().save_model(request, obj, form, change) def delete_queryset(self, request, queryset: QuerySet[Any]) -> None: - reopened_sca_findings = [] + reopened_sca_findings = 0 for obj in queryset: - reopened_sca_findings = models.SCAFinding.objects.filter( - dependency=obj.dependency, vuln_id=obj.vuln_id, state=models.SCAFinding.State.CLOSED - ).update(state=models.SCAFinding.State.OPEN) - - for project in obj.dependency.projects: - project.update_vulnerability_counters() - - messages.success( - request, - format_html( - "{} Findings Re-Opened!", - reopened_sca_findings, - ), - ) + if not obj.sca_project: + reopened_sca_findings = models.SCAFinding.objects.filter( + dependency=obj.dependency, vuln_id=obj.vuln_id, state=models.SCAFinding.State.CLOSED + ).update(state=models.SCAFinding.State.OPEN) + + if reopened_sca_findings: + messages.success( + request, + format_html( + "{} Findings Re-Opened!", + reopened_sca_findings, + ), + ) return super().delete_queryset(request, queryset) + + +@admin.register(models.SCAFindingCounter) +class SCAFindingCounterAdmin(DefaultModelAdmin): + list_display = ["get_purl", "critical", "high", "medium", "low", "eol", "last_sync"] + list_filter = ["dependency__purl"] + list_select_related = ["dependency"] + + @admin.display(description="Dependency") + def get_purl(self, obj): + return obj.dependency.purl + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/surface/sca/templates/views/dependencies.html b/surface/sca/templates/views/dependencies.html index 1363b300..f84c0a95 100644 --- a/surface/sca/templates/views/dependencies.html +++ b/surface/sca/templates/views/dependencies.html @@ -1,65 +1,109 @@ {% extends 'views/layout.html' %} {% load sca_templatetags %} -{% block sca_content %} - - -
    -
    -
    -
    - {% for field in deps_filter.form %} - {% if field.name != "show_vulnerable" %} -
    - {{ field.label_tag }} - {% if field.field.widget.input_type == 'select' %} - - {% else %} - {{ field }} - {% endif %} -
    - {% endif %} - {% endfor %} -
    - - +{% block content %} + +
    + +
    +
    + +
    + {% for field in deps_filter %} + {% if field.name != "show_vulnerable" %} +
    + {{ field.label_tag }} + {% if field.field.widget.input_type == 'select' %} + + {% else %} + {{ field }} + {% endif %} +
    + {% endif %} + {% endfor %} +
    + + +
    +
    -
    -
    -
    - -
    - - -
    +
    +
    +
    + +

    +
    + + +
    +
    +
    +
    +
    +
    + {% csrf_token %} +
    + +
    + + +
    +
    +
    +
    +
    +
    + {% csrf_token %} +
    + +
    + + +
    +
    +
    -
    - - - {% if current_object.is_project %} -
    - {% csrf_token %} - -
    - -
    +
    - {% endif %} - - + {% if current_object.is_project %} + + {% csrf_token %} + +
    + +
    + + {% endif %} + +
    + + @@ -73,21 +117,21 @@ - {% for dep in deps_filter.qs %} + {% for dep in dependencies %} @@ -106,12 +150,12 @@ + N/A {% endif %} @@ -138,4 +182,4 @@ window.location.search = searchParams.toString(); }); -{% endblock sca_content %} +{% endblock content %} diff --git a/surface/sca/templates/views/layout.html b/surface/sca/templates/views/layout.html index e7ca1614..dc95b002 100644 --- a/surface/sca/templates/views/layout.html +++ b/surface/sca/templates/views/layout.html @@ -1,126 +1,191 @@ -{% extends 'admin/base.html' %} -{% block title %}{{ title }}{% endblock %} -{% block extrahead %} - {{ block.super }} +{% extends "admin/base_site.html" %} + +{% load i18n admin_urls static admin_modify unfold %} + +{% block extrahead %}{{ block.super }} + + {{ media }} - - - - - - - - - - - - - - - - - - + + {% endblock %} -{% block content %} -
    -
    -
    - -
    -
    -
    -
    - {% block sca_content %} - {% endblock sca_content %} -
    -
    -
    -
    + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-form{% endblock %} + +{% if not is_popup %} + {% block breadcrumbs %} +
    +
    +
      + {% url 'admin:index' as link %} + {% trans 'Home' as name %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=name %} + + {% url 'admin:app_list' app_label=opts.app_label as link %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=opts.app_config.verbose_name %} + + {% if has_view_permission %} + {% url opts|admin_urlname:'changelist' as link %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=opts.verbose_name_plural|capfirst %} + {% else %} + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=opts.verbose_name_plural|capfirst %} + {% endif %} + + {% if add %} + {% blocktranslate trimmed with name=opts.verbose_name asvar breadcrumb_name %} + Add {{ name }} + {% endblocktranslate %} + + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=breadcrumb_name %} + {% else %} + {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=original|truncatewords:'18' %} + {% endif %} +
    -
    -{% endblock content %} + {% endblock %} +{% endif %} + + + +{% block nav-global-side %} + {% if has_add_permission %} + {% include "unfold/helpers/add_link.html" %} + {% endif %} +{% endblock %} + +{% block content %} +{% endblock %} + +{% block footer %} + {% block submit_buttons_bottom %}{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/surface/sca/templates/views/vulnerabilities.html b/surface/sca/templates/views/vulnerabilities.html index ef01d7d1..76d74d10 100644 --- a/surface/sca/templates/views/vulnerabilities.html +++ b/surface/sca/templates/views/vulnerabilities.html @@ -1,50 +1,60 @@ {% extends 'views/layout.html' %} {% load sca_templatetags %} -{% block sca_content %} - - -
    -
    - {% for field in vulns_filter.form %} -
    - {{ field.label_tag }} - {% if field.field.widget.input_type == 'select' %} - - {% else %} - {{ field }} - {% endif %} -
    - {% endfor %} -
    - - -
    +{% block content %} +
    + - - - {% if current_object.is_project and vulns_filter.qs %} -
    - {% csrf_token %} - -
    - + +
    + {% for field in vulns_filter.form %} +
    + {{ field.label_tag }} + {% if field.field.widget.input_type == 'select' %} + + {% else %} + {{ field }} + {% endif %} +
    + {% endfor %} +
    + + +
    + - {% endif %} - -
    Dependency
    - {{ dep }} - {% for k, v in dep.vulns.items %} + {{ dep.purl }} + {% for k, v in dep.vulns_counters.items %} {% if k %} {{ v.count }} + href="/sca/scaproject/{{ dep.id }}/change/?view=vulnerabilities&severity={{ k }}&finding_type=0" + title="{{ v.count }} {{ k|criticality_to_str }}" + class="ui {{ k|severity_to_color }} circular label">{{ v.count }} {% else %} {{ v.eol }} + href="/sca/scaproject/{{ dep.id }}/change/?view=vulnerabilities&finding_type=1" + title="{{ v.eol }} End of Life" + class="ui black circular label">{{ v.eol }} {% endif %} {% endfor %} {{ dep.version }} {% if dep.depends_on %} - {% for d in dep.depends_on.all %} - {{ d }} + {% for d in dep.depends_on %} + {{ d }}
    {% endfor %} {% else %} -
    N/A {{ dep.last_scan }}
    + {% if current_object.is_project and vulns_filter.qs %} + + {% csrf_token %} + +
    + +
    + + {% endif %} + +
    + @@ -56,7 +66,9 @@ - + {% if current_object.is_project %} + + {% endif %} @@ -73,7 +85,7 @@ {% if vuln.cvss_vector %} {% else %} @@ -82,16 +94,18 @@ - + {% if current_object.is_project %} + + {% endif %} From 3f6d6722e02959eeed1676100c4f32b9d1c4294a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 11:19:36 +0100 Subject: [PATCH 12/39] removes comment --- surface/surface/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surface/surface/settings.py b/surface/surface/settings.py index 3ea73cd5..07444851 100644 --- a/surface/surface/settings.py +++ b/surface/surface/settings.py @@ -61,7 +61,7 @@ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", - # "django.middleware.csrf.CsrfViewMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", From 115f03e289e2c703f67b2e13d984b4ab251a5a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 11:30:18 +0100 Subject: [PATCH 13/39] adds sidebar items --- surface/surface/sidebar.py | 124 +++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/surface/surface/sidebar.py b/surface/surface/sidebar.py index a7061fc5..4129bbe9 100644 --- a/surface/surface/sidebar.py +++ b/surface/surface/sidebar.py @@ -90,5 +90,129 @@ def checker(request): }, ], }, + { + "title": "CMDB", + "icon": "map", + "collapsible": True, + "items": [ + { + "title": "Applications (TLAs) (All)", + "icon": "apps", + "link": reverse_lazy("admin:inventory_application_changelist"), + "permission": check_permission("inventory.view_application"), + }, + { + "title": "Git (Repos) Sources", + "icon": "code", + "link": reverse_lazy("admin:inventory_gitsource_changelist"), + "permission": check_permission("inventory.view_gitsource"), + }, + ], + }, + { + "title": "DNS & IPs", + "icon": "dns", + "collapsible": True, + "items": [ + { + "title": "Sources", + "icon": "source", + "link": reverse_lazy("admin:dns_ips_source_changelist"), + "permission": check_permission("dns_ips.view_source"), + }, + { + "title": "Tags", + "icon": "label", + "link": reverse_lazy("admin:dns_ips_tag_changelist"), + "permission": check_permission("dns_ips.view_tag"), + }, + { + "title": "IP Addresses", + "icon": "pin_drop", + "link": reverse_lazy("admin:dns_ips_ipaddress_changelist"), + "permission": check_permission("dns_ips.view_ipaddress"), + }, + { + "title": "IP Ranges", + "icon": "swap_horiz", + "link": reverse_lazy("admin:dns_ips_iprange_changelist"), + "permission": check_permission("dns_ips.view_iprange"), + }, + { + "title": "DNS Domains", + "icon": "domain", + "link": reverse_lazy("admin:dns_ips_dnsdomain_changelist"), + "permission": check_permission("dns_ips.view_dnsdomain"), + }, + { + "title": "DNS Records", + "icon": "dns", + "link": reverse_lazy("admin:dns_ips_dnsrecord_changelist"), + "permission": check_permission("dns_ips.view_dnsrecord"), + }, + { + "title": "DNS Record Values", + "icon": "fact_check", + "link": reverse_lazy("admin:dns_ips_dnsrecordvalue_changelist"), + "permission": check_permission("dns_ips.view_dnsrecordvalue"), + }, + ], + }, + { + "title": "Security Testing & VM", + "icon": "search", + "collapsible": True, + "items": [ + { + "title": "Findings (All)", + "icon": "find_in_page", + "link": reverse_lazy("admin:vulns_finding_changelist"), + "permission": check_permission("vulns.view_finding"), + }, + ], + }, + { + "title": "Rootboxes & Scanners", + "icon": "host", + "collapsible": True, + "items": [ + { + "title": "Scanners Logs", + "icon": "list_alt", + "link": reverse_lazy("admin:scanners_scanlog_changelist"), + "permission": check_permission("scanners.view_scanlog"), + }, + { + "title": "Rootboxes", + "icon": "dns", + "link": reverse_lazy("admin:scanners_rootbox_changelist"), + "permission": check_permission("scanners.view_rootbox"), + }, + { + "title": "Scanners", + "icon": "search", + "link": reverse_lazy("admin:scanners_scanner_changelist"), + "permission": check_permission("scanners.view_scanner"), + }, + { + "title": "Scanners Images", + "icon": "image", + "link": reverse_lazy("admin:scanners_scannerimage_changelist"), + "permission": check_permission("scanners.view_scannerimage"), + }, + { + "title": "Live Webservers", + "icon": "public", + "link": reverse_lazy("admin:scanners_livehost_changelist"), + "permission": check_permission("scanners.view_livehost"), + }, + { + "title": "Raw Results", + "icon": "description", + "link": reverse_lazy("admin:scanners_rawresult_changelist"), + "permission": check_permission("scanners.view_rawresult"), + }, + ], + }, ], } From 05bb31163050bf98bf4874a49d35b0d1cfe1a260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 12:13:19 +0100 Subject: [PATCH 14/39] python 3.11 --- .github/workflows/integration.yml | 2 +- .github/workflows/run_tests.yml | 6 +++--- dev/Dockerfile | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 6d6980d6..cfced070 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.11' - name: Set-up environment run: pip install -r surface/requirements_test.txt diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 9657675f..c8755357 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -17,10 +17,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.9 + - name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: 3.11 - name: Install black run: pip install black==22.8.0 @@ -56,7 +56,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.9, 3.11] + python-version: [3.11] database: - db: mysql url: mysql://root:root@127.0.0.1:8877/surface diff --git a/dev/Dockerfile b/dev/Dockerfile index 2f63cd36..9a207da6 100644 --- a/dev/Dockerfile +++ b/dev/Dockerfile @@ -7,7 +7,7 @@ RUN --mount=type=bind,target=/tmpapp \ python /run.py /tmpapp/surface/requirements_prod.txt \ /tmpapp/surface/requirements_psql.txt > /requirements_full.txt -FROM python:3.9-slim-buster as builder +FROM python:3.11-slim-buster as builder RUN apt-get update \ && apt-get install --no-install-recommends -y \ @@ -27,7 +27,7 @@ RUN pip wheel -w /wheels -r requirements_full.txt ############################################################################ -FROM python:3.9-slim-buster as main +FROM python:3.11-slim-buster as main RUN apt-get update \ && apt-get install --no-install-recommends -y \ From 02371d9e004e65c030b335499f3c43acff5dd0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 12:19:09 +0100 Subject: [PATCH 15/39] 3.11-slim --- dev/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/Dockerfile b/dev/Dockerfile index 9a207da6..1bde3eea 100644 --- a/dev/Dockerfile +++ b/dev/Dockerfile @@ -7,7 +7,7 @@ RUN --mount=type=bind,target=/tmpapp \ python /run.py /tmpapp/surface/requirements_prod.txt \ /tmpapp/surface/requirements_psql.txt > /requirements_full.txt -FROM python:3.11-slim-buster as builder +FROM python:3.11-slim as builder RUN apt-get update \ && apt-get install --no-install-recommends -y \ @@ -27,7 +27,7 @@ RUN pip wheel -w /wheels -r requirements_full.txt ############################################################################ -FROM python:3.11-slim-buster as main +FROM python:3.11-slim as main RUN apt-get update \ && apt-get install --no-install-recommends -y \ From 01142d4639bda12e6ab11889ef256541fd25f49a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 12:55:44 +0100 Subject: [PATCH 16/39] 3.11-slim-buster --- dev/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/Dockerfile b/dev/Dockerfile index 1bde3eea..9a207da6 100644 --- a/dev/Dockerfile +++ b/dev/Dockerfile @@ -7,7 +7,7 @@ RUN --mount=type=bind,target=/tmpapp \ python /run.py /tmpapp/surface/requirements_prod.txt \ /tmpapp/surface/requirements_psql.txt > /requirements_full.txt -FROM python:3.11-slim as builder +FROM python:3.11-slim-buster as builder RUN apt-get update \ && apt-get install --no-install-recommends -y \ @@ -27,7 +27,7 @@ RUN pip wheel -w /wheels -r requirements_full.txt ############################################################################ -FROM python:3.11-slim as main +FROM python:3.11-slim-buster as main RUN apt-get update \ && apt-get install --no-install-recommends -y \ From 3e7406036789bfbfbfdd82e357c26f566ac485d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 13:10:00 +0100 Subject: [PATCH 17/39] 3.11-slim-trixie --- dev/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/Dockerfile b/dev/Dockerfile index 9a207da6..a53bb607 100644 --- a/dev/Dockerfile +++ b/dev/Dockerfile @@ -7,7 +7,7 @@ RUN --mount=type=bind,target=/tmpapp \ python /run.py /tmpapp/surface/requirements_prod.txt \ /tmpapp/surface/requirements_psql.txt > /requirements_full.txt -FROM python:3.11-slim-buster as builder +FROM python:3.11-slim-trixie as builder RUN apt-get update \ && apt-get install --no-install-recommends -y \ @@ -27,7 +27,7 @@ RUN pip wheel -w /wheels -r requirements_full.txt ############################################################################ -FROM python:3.11-slim-buster as main +FROM python:3.11-slim-trixie as main RUN apt-get update \ && apt-get install --no-install-recommends -y \ From 3dbd277d0373e223a8b18311cac6891937726440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 13:59:44 +0100 Subject: [PATCH 18/39] fixes dockerfile --- dev/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/Dockerfile b/dev/Dockerfile index a53bb607..6037b8ff 100644 --- a/dev/Dockerfile +++ b/dev/Dockerfile @@ -11,7 +11,7 @@ FROM python:3.11-slim-trixie as builder RUN apt-get update \ && apt-get install --no-install-recommends -y \ - libmariadbclient-dev \ + libmariadb-dev \ libpq-dev \ build-essential \ libldap2-dev \ From 90c92e8fc8cad3f98bd25b5f447da57074e413e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 14:35:39 +0100 Subject: [PATCH 19/39] fixes tests --- surface/requirements.txt | 2 ++ surface/sca/models.py | 17 ++++------------- surface/sca/tests/test_admin.py | 10 +++++----- surface/sca/tests/test_resync_sbom_repo.py | 10 +++++----- 4 files changed, 16 insertions(+), 23 deletions(-) diff --git a/surface/requirements.txt b/surface/requirements.txt index 6d885cfe..b941f515 100644 --- a/surface/requirements.txt +++ b/surface/requirements.txt @@ -12,6 +12,7 @@ django-jsoneditor==0.2.4 netaddr==0.8.0 django-unfold==0.63.0 django-filter==25.1 +PyYAML==6.0.2 # our own django-dbcleanup==0.1.5 @@ -27,6 +28,7 @@ django-sbomrepo==0.0.10 mysqlclient==2.2.4 tqdm==4.65.0 # for core_utils that is not really a app/package ..? +django-database-locks==0.5 django-bulk-update-or-create==0.3.0 # for faster batch operations with update_or_create django-dynamicsettings==0.0.3 diff --git a/surface/sca/models.py b/surface/sca/models.py index fd973c5a..bc24d8ab 100644 --- a/surface/sca/models.py +++ b/surface/sca/models.py @@ -144,17 +144,6 @@ def get_vulns_counter(self, project): ).exclude(vuln_id__in=suppressed_findings) return {item["severity"]: {"count": item["count"], "eol": item["eol"]} for item in findings} - @property - def vulns(self): - findings = ( - SCAFinding.objects.filter(dependency=self) - .select_related("dependency") - .values("severity", "finding_type") - .annotate(count=Count("severity"), eol=Count(Case(When(finding_type=1, then=1)))) - .order_by("-severity") - ) - return {item["severity"]: {"count": item["count"], "eol": item["eol"]} for item in findings} - @staticmethod @lru_cache_time(3600) def get_dependencies(root_dependency: "SCADependency") -> list: @@ -168,22 +157,24 @@ def get_dependencies(root_dependency: "SCADependency") -> list: current_dep = stack.pop() # Add the current dependency's purl to the set of dependencies - dependencies.add(current_dep.purl) + dependencies.add(str(current_dep.purl)) # Iterate over the dependencies of the current dependency for dep in current_dep.depends_on.all(): - if dep.purl not in dependencies: + if str(dep.purl) not in dependencies: stack.append(dep) return list(dependencies) def update_vulnerability_counters(self) -> "SCAFindingCounter": + project_suppressed_findings = SuppressedSCAFinding.objects.filter(sca_project=self) severity_counters = ( SCAFinding.objects.filter( (Q(fixed_in__gt="") | Q(finding_type=SCAFinding.FindingType.EOL)), dependency__purl__in=self.dependencies, state__in=(SCAFinding.State.NEW, SCAFinding.State.OPEN), ) + .exclude(vuln_id__in=project_suppressed_findings.values_list("vuln_id", flat=True)) .prefetch_related("dependency") .values("severity") .annotate( diff --git a/surface/sca/tests/test_admin.py b/surface/sca/tests/test_admin.py index d6312143..23da5492 100644 --- a/surface/sca/tests/test_admin.py +++ b/surface/sca/tests/test_admin.py @@ -1,4 +1,4 @@ -from datetime import datetime +import datetime from unittest import mock import responses @@ -18,7 +18,7 @@ class Test(TestCase): @responses.activate - @mock.patch("django.utils.timezone.now", return_value=datetime(2023, 9, 7, tzinfo=timezone.utc)) + @mock.patch("django.utils.timezone.now", return_value=datetime.datetime(2023, 9, 7, tzinfo=datetime.timezone.utc)) def setUp(self, now) -> None: self.user = get_user_model().objects.create_user("tester", "tester@ppb.it", "tester") self.site = AdminSite() @@ -32,7 +32,7 @@ def setUp(self, now) -> None: responses.add( responses.GET, - f"{settings.SCA_SBOM_REPO_URL}/all?since={datetime.strftime(timezone.now() - timezone.timedelta(hours=1), '%Y-%m-%dT%H:%M:%S.%f')}", + f"{settings.SCA_SBOM_REPO_URL}/all?since={datetime.datetime.strftime(timezone.now() - timezone.timedelta(hours=1), '%Y-%m-%dT%H:%M:%S.%f')}", status=200, content_type="application/json", json=["urn:uuid:46d764e2-aae1-4f82-b9f1-c616308e921d"], @@ -65,10 +65,10 @@ def test_admin_changelist(self): assert SCADependency.objects.filter(is_project=True).count() == 1 # Assert specific HTML content - content = r.content.decode() + content = " ".join(r.content.decode().split()) assert "pkg:github.com/test/repo@master" in content assert "https://github.com/test/repo" in content - assert "Vulnerabilities" in content + assert "Vulnerabilities" in content assert "1 Project (SCA)" in content # Assert Vulnerabilities Counters diff --git a/surface/sca/tests/test_resync_sbom_repo.py b/surface/sca/tests/test_resync_sbom_repo.py index 849227cd..1046bc2b 100644 --- a/surface/sca/tests/test_resync_sbom_repo.py +++ b/surface/sca/tests/test_resync_sbom_repo.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +import datetime from unittest import mock import responses @@ -17,7 +17,7 @@ class Test(TestCase): @responses.activate - @mock.patch("django.utils.timezone.now", return_value=datetime(2023, 9, 7, tzinfo=timezone.utc)) + @mock.patch("django.utils.timezone.now", return_value=datetime.datetime(2023, 9, 7, tzinfo=datetime.timezone.utc)) def test_resync_sbom_repo(self, now): responses.add( responses.GET, @@ -29,7 +29,7 @@ def test_resync_sbom_repo(self, now): responses.add( responses.GET, - f"{settings.SCA_SBOM_REPO_URL}/all?since={datetime.strftime(timezone.now() - timezone.timedelta(hours=1), '%Y-%m-%dT%H:%M:%S.%f')}", + f"{settings.SCA_SBOM_REPO_URL}/all?since={datetime.datetime.strftime(timezone.now() - timezone.timedelta(hours=1), '%Y-%m-%dT%H:%M:%S.%f')}", status=200, content_type="application/json", json=["urn:uuid:46d764e2-aae1-4f82-b9f1-c616308e921d"], @@ -93,14 +93,14 @@ def test_resync_sbom_repo(self, now): assert counter.critical == 0 def test_handle_eol_dependency(self): - eol_date = timezone.now().date() - timedelta(days=1) + eol_date = timezone.now().date() - datetime.timedelta(days=1) purl_str = "pkg:pypi/django@3.2.0" purl = PackageURL.from_string(purl_str) eol_dependency = EndOfLifeDependency.objects.create( product="django", cycle="3.2.0", - release_date=timezone.now().date() - timedelta(days=10), + release_date=timezone.now().date() - datetime.timedelta(days=10), eol=eol_date, latest_version="3.2.8", ) From a73c6bd81919ee9ddf7eb0eda83d86909f84b4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 14:40:59 +0100 Subject: [PATCH 20/39] reformat with black --- surface/inventory/admin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/surface/inventory/admin.py b/surface/inventory/admin.py index d8dadb3f..ca22e2b2 100644 --- a/surface/inventory/admin.py +++ b/surface/inventory/admin.py @@ -65,7 +65,9 @@ def get_apps(self, obj): @admin.display(description="Repo") def get_link(self, obj): if obj.repo_url: - return format_html('{url}', url=obj.repo_url) # nosec - intencional use in order to create admin links + return format_html( + '{url}', url=obj.repo_url + ) # nosec - intencional use in order to create admin links return "" def get_queryset(self, request): From 584d8ce3fcc56970d3027ebf62820aee633900eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 14:45:25 +0100 Subject: [PATCH 21/39] adds hvac --- surface/scanners/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/surface/scanners/requirements.txt b/surface/scanners/requirements.txt index 37409755..ab6d1bf3 100644 --- a/surface/scanners/requirements.txt +++ b/surface/scanners/requirements.txt @@ -2,3 +2,4 @@ cffi==1.15.1 packaging==23.1 pyOpenSSL==23.2.0 docker[tls]==6.1.3 +hvac==2.3.0 From ade88bc37d08c620e1e46b916ea7ad04c8816d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 15:01:46 +0100 Subject: [PATCH 22/39] fixes test --- e2e/test_dkron.py | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/e2e/test_dkron.py b/e2e/test_dkron.py index 3000f857..2a1430a7 100644 --- a/e2e/test_dkron.py +++ b/e2e/test_dkron.py @@ -13,17 +13,11 @@ @pytest.mark.webtest def test_dkron_admin_views(prep_dkron, test_cfg, live_server): test_cfg.driver.get(f"{live_server.url}/login/") - assert "Login" in test_cfg.driver.title - - test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys( - test_cfg.username - ) - test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys( - f"{test_cfg.password}" - ) - test_cfg.driver.find_element( - by=By.XPATH, value='//button[text()="Submit"]' - ).send_keys(Keys.ENTER) + assert "Log in" in test_cfg.driver.title + + test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username) + test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}") + test_cfg.driver.find_element(by=By.XPATH, value='//button[text()="Submit"]').send_keys(Keys.ENTER) assert "Home | Surface" == test_cfg.driver.title @@ -31,15 +25,9 @@ def test_dkron_admin_views(prep_dkron, test_cfg, live_server): assert "Add job | Surface" == test_cfg.driver.title test_cfg.driver.find_element(by=By.ID, value="id_name").send_keys(TEST_IO["name"]) - test_cfg.driver.find_element(by=By.ID, value="id_schedule").send_keys( - TEST_IO["schedule"] - ) - test_cfg.driver.find_element(by=By.ID, value="id_command").send_keys( - TEST_IO["command"] - ) - test_cfg.driver.find_element(by=By.ID, value="id_description").send_keys( - TEST_IO["description"] - ) + test_cfg.driver.find_element(by=By.ID, value="id_schedule").send_keys(TEST_IO["schedule"]) + test_cfg.driver.find_element(by=By.ID, value="id_command").send_keys(TEST_IO["command"]) + test_cfg.driver.find_element(by=By.ID, value="id_description").send_keys(TEST_IO["description"]) test_cfg.driver.find_element(by=By.ID, value="id_use_shell").click() test_cfg.driver.find_element(by=By.ID, value="id_notify_on_error").click() @@ -49,4 +37,4 @@ def test_dkron_admin_views(prep_dkron, test_cfg, live_server): assert "Select job to change | Surface" == test_cfg.driver.title for k, v in TEST_IO.items(): - assert v == test_cfg.driver.find_element(by=By.CLASS_NAME, value=f"field-{k}").text \ No newline at end of file + assert v == test_cfg.driver.find_element(by=By.CLASS_NAME, value=f"field-{k}").text From 769d12252404b56b36090d9ee10352a111674694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 15:09:20 +0100 Subject: [PATCH 23/39] fixes test --- e2e/test_login.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/e2e/test_login.py b/e2e/test_login.py index 4fbe181e..148d7b2d 100644 --- a/e2e/test_login.py +++ b/e2e/test_login.py @@ -6,16 +6,10 @@ @pytest.mark.webtest def test_login(live_server, test_cfg): test_cfg.driver.get(f"{live_server.url}/login/") - assert "Login" in test_cfg.driver.title + assert "Log in" in test_cfg.driver.title - test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys( - test_cfg.username - ) - test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys( - f"{test_cfg.password}" - ) - test_cfg.driver.find_element( - by=By.XPATH, value='//button[text()="Submit"]' - ).send_keys(Keys.ENTER) + test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username) + test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}") + test_cfg.driver.find_element(by=By.XPATH, value='//button[text()="Submit"]').send_keys(Keys.ENTER) assert "Home | Surface" == test_cfg.driver.title From cd1e5878a143ae2a616f516fd31cefc9d28089af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 15:39:23 +0100 Subject: [PATCH 24/39] Log In button --- e2e/test_login.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/test_login.py b/e2e/test_login.py index 148d7b2d..1be73cc4 100644 --- a/e2e/test_login.py +++ b/e2e/test_login.py @@ -10,6 +10,6 @@ def test_login(live_server, test_cfg): test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username) test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}") - test_cfg.driver.find_element(by=By.XPATH, value='//button[text()="Submit"]').send_keys(Keys.ENTER) + test_cfg.driver.find_element(by=By.XPATH, value='//button[text()="Log in"]').send_keys(Keys.ENTER) assert "Home | Surface" == test_cfg.driver.title From 84fe29a5ae2c731764369c907e1feee42c82fe35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 16:14:19 +0100 Subject: [PATCH 25/39] fixes login test --- e2e/test_login.py | 5 ++--- surface/surface/settings.py | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/test_login.py b/e2e/test_login.py index 1be73cc4..81e2ac7c 100644 --- a/e2e/test_login.py +++ b/e2e/test_login.py @@ -1,6 +1,5 @@ import pytest from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys @pytest.mark.webtest @@ -10,6 +9,6 @@ def test_login(live_server, test_cfg): test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username) test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}") - test_cfg.driver.find_element(by=By.XPATH, value='//button[text()="Log in"]').send_keys(Keys.ENTER) + test_cfg.driver.find_element(by=By.XPATH, value='//button[contains(., "Log in")]').click() - assert "Home | Surface" == test_cfg.driver.title + assert "Dashboard | Surface Security" == test_cfg.driver.title diff --git a/surface/surface/settings.py b/surface/surface/settings.py index 07444851..1c63108a 100644 --- a/surface/surface/settings.py +++ b/surface/surface/settings.py @@ -258,6 +258,7 @@ TITLE = "Surface" VERSION = "dev" +LOGIN_REDIRECT_URL = "/" ###################################################################### # Unfold From 534893de0e3f98e8d6330feb7a27aba678a522c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 16:31:31 +0100 Subject: [PATCH 26/39] dkron --- e2e/test_dkron.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/test_dkron.py b/e2e/test_dkron.py index 2a1430a7..c16bdd87 100644 --- a/e2e/test_dkron.py +++ b/e2e/test_dkron.py @@ -17,7 +17,7 @@ def test_dkron_admin_views(prep_dkron, test_cfg, live_server): test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username) test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}") - test_cfg.driver.find_element(by=By.XPATH, value='//button[text()="Submit"]').send_keys(Keys.ENTER) + test_cfg.driver.find_element(by=By.XPATH, value='//button[contains(., "Log in")]').click() assert "Home | Surface" == test_cfg.driver.title From e4eb25452ab41b4e51588431767fa96ebeb0cdf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 16:36:27 +0100 Subject: [PATCH 27/39] dkron test fix --- e2e/test_dkron.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/test_dkron.py b/e2e/test_dkron.py index c16bdd87..5caa2eec 100644 --- a/e2e/test_dkron.py +++ b/e2e/test_dkron.py @@ -19,10 +19,10 @@ def test_dkron_admin_views(prep_dkron, test_cfg, live_server): test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}") test_cfg.driver.find_element(by=By.XPATH, value='//button[contains(., "Log in")]').click() - assert "Home | Surface" == test_cfg.driver.title + assert "Dashboard | Surface Security" == test_cfg.driver.title test_cfg.driver.get(f"{live_server.url}/dkron/job/add/") - assert "Add job | Surface" == test_cfg.driver.title + assert "Add job | Surface Security" == test_cfg.driver.title test_cfg.driver.find_element(by=By.ID, value="id_name").send_keys(TEST_IO["name"]) test_cfg.driver.find_element(by=By.ID, value="id_schedule").send_keys(TEST_IO["schedule"]) @@ -34,7 +34,7 @@ def test_dkron_admin_views(prep_dkron, test_cfg, live_server): test_cfg.driver.find_element(by=By.NAME, value="_save").send_keys(Keys.ENTER) test_cfg.driver.get(f"{live_server.url}/dkron/job") - assert "Select job to change | Surface" == test_cfg.driver.title + assert "Select job to change | Surface Security" == test_cfg.driver.title for k, v in TEST_IO.items(): assert v == test_cfg.driver.find_element(by=By.CLASS_NAME, value=f"field-{k}").text From c1ed5cc94e7455c3daf366f9dd94455788f16b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 21 Oct 2025 18:16:03 +0100 Subject: [PATCH 28/39] use filters --- surface/core_utils/admin_filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surface/core_utils/admin_filters.py b/surface/core_utils/admin_filters.py index e0f7a250..397f1c82 100644 --- a/surface/core_utils/admin_filters.py +++ b/surface/core_utils/admin_filters.py @@ -14,7 +14,7 @@ from django.forms import ValidationError from django.http import HttpRequest from django.shortcuts import redirect -from rangefilter.filter import DateRangeFilter as OriginalDateRangeFilter +from rangefilter.filters import DateRangeFilter as OriginalDateRangeFilter from unfold.admin import ModelAdmin from unfold.contrib.filters.admin import DropdownFilter as UnfoldDropdownFilter from unfold.contrib.filters.admin.dropdown_filters import RelatedDropdownFilter From d33de8979a2d9135eafd8761477b5dc673e40c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Mon, 27 Oct 2025 17:23:46 +0000 Subject: [PATCH 29/39] fix: adds missing installed app, adds sca to sidebar and removes add permission on ScaDependency --- surface/sca/admin.py | 3 +++ surface/scanners/admin.py | 10 +++++----- surface/surface/settings.py | 1 + surface/surface/sidebar.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/surface/sca/admin.py b/surface/sca/admin.py index 5a57d532..dfefb411 100644 --- a/surface/sca/admin.py +++ b/surface/sca/admin.py @@ -133,6 +133,9 @@ class SCADependencyAdmin(ReverseReadonlyMixin, DefaultModelAdmin): def get_queryset(self, request): return super().get_queryset(request).prefetch_related("depends_on", "git_source__apps") + + def has_add_permission(self, request: HttpRequest) -> bool: + return False @admin.display(description="Repository") def get_git_source(self, obj): diff --git a/surface/scanners/admin.py b/surface/scanners/admin.py index fade7ba7..a8f589f5 100644 --- a/surface/scanners/admin.py +++ b/surface/scanners/admin.py @@ -3,7 +3,6 @@ from django import forms from django.contrib import admin, messages -from django.contrib.admin import SimpleListFilter from django.contrib.admin.templatetags.admin_urls import admin_urlname from django.contrib.admin.utils import unquote from django.core.exceptions import PermissionDenied @@ -14,6 +13,7 @@ from django.urls import reverse from django.utils.html import format_html, format_html_join from django.utils.text import capfirst +from core_utils.admin_filters import DropdownFilter from core_utils.admin import DefaultModelAdmin from core_utils.admin_filters import DefaultFilterMixin @@ -21,7 +21,7 @@ from scanners import models, utils -class FinalHTTPFilter(SimpleListFilter): +class FinalHTTPFilter(DropdownFilter): title = "Final HTTP" parameter_name = "final_http" @@ -36,7 +36,7 @@ def queryset(self, request, queryset): return queryset -class NoLBie1ie2Filter(SimpleListFilter): +class NoLBie1ie2Filter(DropdownFilter): title = "No LB or IE1/IE2" parameter_name = "no_lb_ie1ie2" @@ -55,7 +55,7 @@ def queryset(self, request, queryset): return queryset -class TypeRecordFilter(SimpleListFilter): +class TypeRecordFilter(DropdownFilter): title = "Type Record" parameter_name = "type_record" @@ -70,7 +70,7 @@ def queryset(self, request, queryset): return queryset -class ExitCodeFilter(SimpleListFilter): +class ExitCodeFilter(DropdownFilter): title = "Success" parameter_name = "success_exit" diff --git a/surface/surface/settings.py b/surface/surface/settings.py index 1c63108a..c0e9b772 100644 --- a/surface/surface/settings.py +++ b/surface/surface/settings.py @@ -40,6 +40,7 @@ "django.contrib.staticfiles", "django.db.migrations", "impersonate", + "import_export", "dkron", "notifications", "slackbot", diff --git a/surface/surface/sidebar.py b/surface/surface/sidebar.py index 4129bbe9..dca1947f 100644 --- a/surface/surface/sidebar.py +++ b/surface/surface/sidebar.py @@ -171,6 +171,37 @@ def checker(request): }, ], }, + { + "title": "Secure SDLC / AppSec", + "icon": "security", + "collapsible": True, + "items": [ + { + "title": "SCA - Dependencies", + "icon": "extension", + "link": reverse_lazy("admin:sca_scadependency_changelist"), + "permission": check_permission("sca.view_scadependency"), + }, + { + "title": "SCA - Projects", + "icon": "workspaces", + "link": reverse_lazy("admin:sca_scaproject_changelist"), + "permission": check_permission("sca.view_scaproject"), + }, + { + "title": "SCA - Dependencies (EoL)", + "icon": "event_busy", + "link": reverse_lazy("admin:sca_endoflifedependency_changelist"), + "permission": check_permission("sca.view_endoflifedependency"), + }, + { + "title": "SCA - Findings (Suppressed)", + "icon": "block", + "link": reverse_lazy("admin:sca_suppressedscafinding_changelist"), + "permission": check_permission("sca.view_suppressedscafinding"), + }, + ], + }, { "title": "Rootboxes & Scanners", "icon": "host", From 01540452954dfb4d5a175f363a48f9614be068b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Mon, 27 Oct 2025 17:29:40 +0000 Subject: [PATCH 30/39] black --- surface/sca/admin.py | 8 ++++---- surface/surface/settings.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/surface/sca/admin.py b/surface/sca/admin.py index dfefb411..8b824654 100644 --- a/surface/sca/admin.py +++ b/surface/sca/admin.py @@ -133,7 +133,7 @@ class SCADependencyAdmin(ReverseReadonlyMixin, DefaultModelAdmin): def get_queryset(self, request): return super().get_queryset(request).prefetch_related("depends_on", "git_source__apps") - + def has_add_permission(self, request: HttpRequest) -> bool: return False @@ -412,9 +412,9 @@ def get_vulns(self, obj): severity=vuln["severity"], color=vuln["color"], criticality=criticality if criticality != "eol" else None, - finding_type=models.SCAFinding.FindingType.VULN - if criticality != "eol" - else models.SCAFinding.FindingType.EOL, + finding_type=( + models.SCAFinding.FindingType.VULN if criticality != "eol" else models.SCAFinding.FindingType.EOL + ), ) for criticality, vuln in severity_mapping.items() ] diff --git a/surface/surface/settings.py b/surface/surface/settings.py index c0e9b772..4797b525 100644 --- a/surface/surface/settings.py +++ b/surface/surface/settings.py @@ -40,7 +40,7 @@ "django.contrib.staticfiles", "django.db.migrations", "impersonate", - "import_export", + "import_export", "dkron", "notifications", "slackbot", From 9da933cc05a46ff5eb5c9e59cf23c23759f3d714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Mon, 27 Oct 2025 18:01:16 +0000 Subject: [PATCH 31/39] form submit --- e2e/test_dkron.py | 3 ++- e2e/test_login.py | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/test_dkron.py b/e2e/test_dkron.py index 5caa2eec..3163b34c 100644 --- a/e2e/test_dkron.py +++ b/e2e/test_dkron.py @@ -1,3 +1,4 @@ +from e2e.conftest import test_cfg import pytest from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys @@ -17,7 +18,7 @@ def test_dkron_admin_views(prep_dkron, test_cfg, live_server): test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username) test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}") - test_cfg.driver.find_element(by=By.XPATH, value='//button[contains(., "Log in")]').click() + test_cfg.driver.find_element(by=By.ID, value="login-form").submit() assert "Dashboard | Surface Security" == test_cfg.driver.title diff --git a/e2e/test_login.py b/e2e/test_login.py index 81e2ac7c..0074e209 100644 --- a/e2e/test_login.py +++ b/e2e/test_login.py @@ -8,7 +8,6 @@ def test_login(live_server, test_cfg): assert "Log in" in test_cfg.driver.title test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username) - test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}") - test_cfg.driver.find_element(by=By.XPATH, value='//button[contains(., "Log in")]').click() - + test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(test_cfg.password) + test_cfg.driver.find_element(by=By.ID, value="login-form").submit() assert "Dashboard | Surface Security" == test_cfg.driver.title From be06d881073d8fd602ee1c97759dcc436ce707cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Mon, 27 Oct 2025 18:07:55 +0000 Subject: [PATCH 32/39] rm import --- e2e/test_dkron.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/test_dkron.py b/e2e/test_dkron.py index 3163b34c..abbff5f2 100644 --- a/e2e/test_dkron.py +++ b/e2e/test_dkron.py @@ -1,4 +1,3 @@ -from e2e.conftest import test_cfg import pytest from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys @@ -18,7 +17,8 @@ def test_dkron_admin_views(prep_dkron, test_cfg, live_server): test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username) test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}") - test_cfg.driver.find_element(by=By.ID, value="login-form").submit() + test_cfg.driver.find_element(By.TAG_NAME, "form").submit() + assert "Dashboard | Surface Security" == test_cfg.driver.title From 2ec0d248a359b56693fe0941933f8891498acae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Mon, 27 Oct 2025 18:15:12 +0000 Subject: [PATCH 33/39] indent --- e2e/test_dkron.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/test_dkron.py b/e2e/test_dkron.py index abbff5f2..53bd50cd 100644 --- a/e2e/test_dkron.py +++ b/e2e/test_dkron.py @@ -17,8 +17,7 @@ def test_dkron_admin_views(prep_dkron, test_cfg, live_server): test_cfg.driver.find_element(by=By.ID, value="id_username").send_keys(test_cfg.username) test_cfg.driver.find_element(by=By.ID, value="id_password").send_keys(f"{test_cfg.password}") - test_cfg.driver.find_element(By.TAG_NAME, "form").submit() - + test_cfg.driver.find_element(by=By.ID, value="login-form").submit() assert "Dashboard | Surface Security" == test_cfg.driver.title From fb46f81ffafe11421a592961376856a34fdc547b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 28 Oct 2025 14:43:10 +0000 Subject: [PATCH 34/39] ui color changes --- surface/requirements.txt | 2 +- surface/sca/templates/views/dependencies.html | 4 +- surface/sca/templates/views/layout.html | 4 -- .../sca/templates/views/vulnerabilities.html | 5 +- surface/surface/settings.py | 54 +++++++++++-------- .../surfapp/templates/admin/change_list.html | 9 ++-- surface/surfapp/templates/admin/index.html | 19 +++---- .../templates/unfold/helpers/app_list.html | 4 +- .../unfold/helpers/surface_stats.html | 1 + 9 files changed, 51 insertions(+), 51 deletions(-) diff --git a/surface/requirements.txt b/surface/requirements.txt index b941f515..674b2094 100644 --- a/surface/requirements.txt +++ b/surface/requirements.txt @@ -10,7 +10,7 @@ django-nested-admin==4.1.1 django-daterangefilter==1.0.0 django-jsoneditor==0.2.4 netaddr==0.8.0 -django-unfold==0.63.0 +django-unfold==0.69.0 django-filter==25.1 PyYAML==6.0.2 diff --git a/surface/sca/templates/views/dependencies.html b/surface/sca/templates/views/dependencies.html index a21e3749..1fb34b75 100644 --- a/surface/sca/templates/views/dependencies.html +++ b/surface/sca/templates/views/dependencies.html @@ -90,7 +90,7 @@ {% for dep in dependencies %} {% else %} @@ -94,7 +95,7 @@
    diff --git a/surface/surface/settings.py b/surface/surface/settings.py index 4797b525..68631fce 100644 --- a/surface/surface/settings.py +++ b/surface/surface/settings.py @@ -279,33 +279,41 @@ }, ], "SHOW_HISTORY": True, + "SIDEBAR": SIDEBAR, "COLORS": { "base": { - "50": "249, 250, 251", - "100": "243, 244, 246", - "200": "229, 231, 235", - "300": "209, 213, 219", - "400": "156, 163, 175", - "500": "107, 114, 128", - "600": "75, 85, 99", - "700": "55, 65, 81", - "800": "31, 41, 55", - "900": "17, 24, 39", - "950": "3, 7, 18", + "50": "oklch(98.5% .002 247.839)", + "100": "oklch(96.7% .003 264.542)", + "200": "oklch(92.8% .006 264.531)", + "300": "oklch(87.2% .01 258.338)", + "400": "oklch(70.7% .022 261.325)", + "500": "oklch(55.1% .027 264.364)", + "600": "oklch(44.6% .03 256.802)", + "700": "oklch(37.3% .034 259.733)", + "800": "oklch(27.8% .033 256.848)", + "900": "oklch(21% .034 264.665)", + "950": "oklch(13% .028 261.692)", }, "primary": { - "50": "239, 246, 255", - "100": "219, 234, 254", - "200": "191, 219, 254", - "300": "147, 197, 253", - "400": "96, 165, 250", - "500": "59, 130, 246", - "600": "37, 99, 235", - "700": "29, 78, 216", - "800": "30, 64, 175", - "900": "30, 58, 138", - "950": "23, 37, 84", + "50": "oklch(97.7% .013 236.62)", + "100": "oklch(95.1% .026 236.824)", + "200": "oklch(90.1% .058 230.902)", + "300": "oklch(82.8% .111 230.318)", + "400": "oklch(74.6% .16 232.661)", + "500": "oklch(68.5% .169 237.323)", + "600": "oklch(58.8% .158 241.966)", + "700": "oklch(50% .134 242.749)", + "800": "oklch(44.3% .11 240.79)", + "900": "oklch(39.1% .09 240.876)", + "950": "oklch(29.3% .066 243.157)", + }, + "font": { + "subtle-light": "var(--color-base-500)", # text-base-500 + "subtle-dark": "var(--color-base-400)", # text-base-400 + "default-light": "var(--color-base-600)", # text-base-600 + "default-dark": "var(--color-base-300)", # text-base-300 + "important-light": "var(--color-base-900)", # text-base-900 + "important-dark": "var(--color-primary-500)", # text-base-100 }, }, - "SIDEBAR": SIDEBAR, } diff --git a/surface/surfapp/templates/admin/change_list.html b/surface/surfapp/templates/admin/change_list.html index 2bea927c..0c8d274a 100644 --- a/surface/surfapp/templates/admin/change_list.html +++ b/surface/surfapp/templates/admin/change_list.html @@ -11,11 +11,7 @@ - + {{ media.css }} {% if not actions_on_top and not actions_on_bottom %} @@ -44,7 +40,8 @@ {% url 'admin:app_list' app_label=cl.opts.app_label as link %} {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=cl.opts.app_config.verbose_name %} - {% include 'unfold/helpers/breadcrumb_item.html' with link='' name=cl.opts.verbose_name_plural|capfirst %} + {% url opts|admin_urlname:'changelist' as link %} + {% include 'unfold/helpers/breadcrumb_item.html' with link=link name=cl.opts.verbose_name_plural|capfirst no_separator=True %}
    diff --git a/surface/surfapp/templates/admin/index.html b/surface/surfapp/templates/admin/index.html index 2cf38d35..a0d065d0 100644 --- a/surface/surfapp/templates/admin/index.html +++ b/surface/surfapp/templates/admin/index.html @@ -11,9 +11,6 @@ {% block extrahead %} {{ block.super }} - {% if plausible_domain %} - - {% endif %} {% endblock %} {% block branding %} @@ -35,10 +32,10 @@
    - + {{ app.name }} -
    {{ app.models|length }} models
    +
    {{ app.models|length }} models
    @@ -48,7 +45,7 @@
    {% if not forloop.last %} -
    +
    {% endif %} {% endif %} {% endfor %} @@ -66,7 +63,7 @@
      {% for item in link.items %}
    • - + {{ item.name }}
    • @@ -127,7 +124,7 @@ {% if entry.is_deletion or not entry.get_admin_url %} {{ entry.content_type }} | {{ entry.object_id }} {% else %} - + {{ entry.content_type }} | {{ entry.object_id }} {% endif %} @@ -185,7 +182,7 @@ {% if entry.is_deletion or not entry.get_admin_url %} {{ entry.content_type }} | {{ entry.object_id }} {% else %} - + {{ entry.content_type }} | {{ entry.object_id }} {% endif %} @@ -207,9 +204,9 @@ {% block footer %}
      {% endblock %} \ No newline at end of file diff --git a/surface/surfapp/templates/unfold/helpers/app_list.html b/surface/surfapp/templates/unfold/helpers/app_list.html index 1b869390..c432a841 100644 --- a/surface/surfapp/templates/unfold/helpers/app_list.html +++ b/surface/surfapp/templates/unfold/helpers/app_list.html @@ -11,7 +11,7 @@
      {% endif %} {% if group.title %} -

      {% for app in app_list %}
      -

      +

      {{ app.name }}

      diff --git a/surface/surfapp/templates/unfold/helpers/surface_stats.html b/surface/surfapp/templates/unfold/helpers/surface_stats.html index f79c27fb..e7c4cd4a 100644 --- a/surface/surfapp/templates/unfold/helpers/surface_stats.html +++ b/surface/surfapp/templates/unfold/helpers/surface_stats.html @@ -17,6 +17,7 @@
      {% surface_stats 'total' %}
      {% endcomponent %} {% component "unfold/components/card.html" with class="p-4 text-center flex-1" %} + commit
      Code Contributors
      +30
      {% endcomponent %} From e864a87870e531b405eea90c8b12670f6dd05630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Tue, 28 Oct 2025 14:55:26 +0000 Subject: [PATCH 35/39] adds missing files --- surface/sca/urls.py | 9 +++++ surface/sca/views.py | 38 +++++++++++++++++++ .../unfold/helpers/breadcrumb_item.html | 11 ++++++ 3 files changed, 58 insertions(+) create mode 100644 surface/sca/urls.py create mode 100644 surface/sca/views.py create mode 100644 surface/surfapp/templates/unfold/helpers/breadcrumb_item.html diff --git a/surface/sca/urls.py b/surface/sca/urls.py new file mode 100644 index 00000000..f7aeaa19 --- /dev/null +++ b/surface/sca/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from sca.views import download_sbom_as_json + +app_name = "sca" + +urlpatterns = [ + path("download-sbom-json///", download_sbom_as_json, name="download_sbom_as_json"), +] diff --git a/surface/sca/views.py b/surface/sca/views.py new file mode 100644 index 00000000..00c01bee --- /dev/null +++ b/surface/sca/views.py @@ -0,0 +1,38 @@ +import logging +import re + +import requests +from django.conf import settings +from django.http HttpResponse, JsonResponse +from django.views.decorators.http import require_GET + + +logger = logging.getLogger(__name__) + + +@require_GET +def download_sbom_as_json(request, uuid: str, proj_name: str): + if not re.match(r"^[a-zA-Z0-9\-:]+$", uuid): + return HttpResponse("Invalid UUID format", status=400) + + if not re.match(r"^[a-zA-Z0-9\-:_]+$", proj_name): + return HttpResponse("Invalid project name format", status=400) + + sbom_url = f"{settings.SCA_SBOM_REPO_URL}/v1/sbom/{uuid}" + try: + response = requests.get(sbom_url) + response.raise_for_status() + + page_data = { + "url": sbom_url, + "status_code": response.status_code, + "project_name": proj_name, + "content": response.json(), + } + + response = JsonResponse(page_data, json_dumps_params={"indent": 2}) + response["Content-Disposition"] = f"attachment; filename=sbom-{proj_name}.json" + return response + + except requests.RequestException as e: + return HttpResponse(f"Error fetching page: {str(e)}", status=500) diff --git a/surface/surfapp/templates/unfold/helpers/breadcrumb_item.html b/surface/surfapp/templates/unfold/helpers/breadcrumb_item.html new file mode 100644 index 00000000..a681ce41 --- /dev/null +++ b/surface/surfapp/templates/unfold/helpers/breadcrumb_item.html @@ -0,0 +1,11 @@ + +
    • + {% if link %} + {{ name }} + {% if not no_separator %} + / + {% endif %} + {% else %} + {{ name }} + {% endif %} +
    • \ No newline at end of file From 3f30729b2f8ba81452cae0110321fa193c065bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Wed, 29 Oct 2025 17:35:36 +0000 Subject: [PATCH 36/39] adds result-list-wrapper --- surface/surfapp/templates/admin/change_list.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surface/surfapp/templates/admin/change_list.html b/surface/surfapp/templates/admin/change_list.html index 0c8d274a..aa3d1c2a 100644 --- a/surface/surfapp/templates/admin/change_list.html +++ b/surface/surfapp/templates/admin/change_list.html @@ -71,7 +71,7 @@ {% endif %}
      -
      +
      {% block date_hierarchy %} {% if cl.date_hierarchy %} From 9c4aff80c957dbc4b5474f898f718bb29799a4cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Thu, 30 Oct 2025 14:28:23 +0000 Subject: [PATCH 37/39] fixes import --- surface/sca/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surface/sca/views.py b/surface/sca/views.py index 00c01bee..2c029983 100644 --- a/surface/sca/views.py +++ b/surface/sca/views.py @@ -3,7 +3,7 @@ import requests from django.conf import settings -from django.http HttpResponse, JsonResponse +from django.http import HttpResponse, JsonResponse from django.views.decorators.http import require_GET From 0dcd9554511e75c2b008d14c5fc7842f722fd51e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Thu, 30 Oct 2025 14:44:46 +0000 Subject: [PATCH 38/39] download sbom --- surface/sca/admin.py | 4 +-- .../management/commands/resync_sbom_repo.py | 32 ++++++++++--------- surface/sca/urls.py | 2 -- surface/surface/settings.py | 2 +- surface/surface/urls.py | 1 + 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/surface/sca/admin.py b/surface/sca/admin.py index 8b824654..3cf580e4 100644 --- a/surface/sca/admin.py +++ b/surface/sca/admin.py @@ -141,7 +141,7 @@ def has_add_permission(self, request: HttpRequest) -> bool: def get_git_source(self, obj): if obj.git_source: return format_html( - f'{obj.git_source.repo_url}' + f'{obj.git_source.repo_url}' ) @admin.display(description="Depends On") @@ -365,7 +365,7 @@ def renovate(self, request, git_source: Optional[GitSource], dependencies=None): def get_git_source(self, obj): if obj.git_source: return format_html( - f'{obj.git_source.repo_url}' + f'{obj.git_source.repo_url}' ) @admin.display(description="SBOM") diff --git a/surface/sca/management/commands/resync_sbom_repo.py b/surface/sca/management/commands/resync_sbom_repo.py index 8a61d9ad..5cc3ac99 100644 --- a/surface/sca/management/commands/resync_sbom_repo.py +++ b/surface/sca/management/commands/resync_sbom_repo.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Optional +from typing import Any import requests import semver @@ -31,16 +31,16 @@ def add_arguments(self, parser: CommandParser) -> None: def get_sboms(self, since: datetime) -> list[str]: since_str = datetime.strftime(since, "%Y-%m-%dT%H:%M:%S.%f") - res = requests.get(f"{settings.SCA_SBOM_REPO_URL}/all", params={"since": since_str}) + res = requests.get(f"{settings.SCA_SBOM_REPO_URL}/v1/sbom/all", params={"since": since_str}) res.raise_for_status() return res.json() def get_sbom_details(self, serial_number: str) -> dict[str, Any]: - res = requests.get(f"{settings.SCA_SBOM_REPO_URL}/{serial_number}", params={"vuln_data": True}) + res = requests.get(f"{settings.SCA_SBOM_REPO_URL}/v1/sbom/{serial_number}", params={"vuln_data": True}) res.raise_for_status() return res.json() - def create_dependency(self, purl: str, scan_date: str) -> tuple[Optional[PackageURL], Optional[SCADependency]]: + def create_dependency(self, purl: str, scan_date: str) -> tuple[PackageURL | None, SCADependency | None]: if not purl: return None, None try: @@ -86,11 +86,11 @@ def handle_eol(self, purl: PackageURL, dependency: SCADependency): "finding_type": SCAFinding.FindingType.EOL, "published": eol.eol, "ecosystem": purl.type, - "state": ( - SCAFinding.State.NEW - if not suppressed_findings.filter(vuln_id=eol.pk) - else SCAFinding.State.CLOSED - ), + "state": SCAFinding.State.CLOSED + if suppressed_findings.filter( + vuln_id=eol.pk, sca_project__isnull=True + ) # Closed if global suppression + else SCAFinding.State.NEW, "last_seen_date": self.sync_time, }, ) @@ -123,17 +123,18 @@ def handle_vuln(self, vuln: dict[str, Any], pkg_obj: SCADependency): dependency=pkg_obj, vuln_id=vuln["id"], defaults={ + "application": None, # a finding / dependency can have multiple Applications, we link to Applications through # the dependency tree instead "published": vuln["published"], "cvss_vector": cvss3[0] if cvss3 else "", "finding_type": SCAFinding.FindingType.VULN, "title": vuln.get("summary", "").capitalize(), "summary": vuln["details"], - "state": ( - SCAFinding.State.NEW - if not suppressed_findings.filter(vuln_id=vuln["id"]) - else SCAFinding.State.CLOSED - ), + "state": SCAFinding.State.CLOSED + if suppressed_findings.filter( + vuln_id=vuln["id"], sca_project__isnull=True + ) # Closed if global suppression + else SCAFinding.State.NEW, "aliases": ", ".join(vuln.get("aliases", [])), "fixed_in": ", ".join(fixed_in) if fixed_in else "", "last_seen_date": self.sync_time, @@ -153,7 +154,7 @@ def handle_sbom(self, sbom: str) -> bool: secondary_dependencies = set() if branch != main_branch: - self.log_warning(f'{sbom} skiped for repo: {sbom_data["sbomrepo"]["metadata"]["repo"]}, branch: {branch}') + self.log_warning(f"{sbom} skiped for repo: {sbom_data['sbomrepo']['metadata']['repo']}, branch: {branch}") self.exited_earlier_not_master += 1 return False @@ -191,6 +192,7 @@ def handle_sbom(self, sbom: str) -> bool: if purl.type in settings.SCA_SOURCE_PURL_TYPES and f"{purl.namespace}/{purl.name}" in repo: dep_object.git_source = git_source dep_object.is_project = True + dep_object.sbom_uuid = sbom dep_object.save() project = dep_object diff --git a/surface/sca/urls.py b/surface/sca/urls.py index f7aeaa19..6269583c 100644 --- a/surface/sca/urls.py +++ b/surface/sca/urls.py @@ -2,8 +2,6 @@ from sca.views import download_sbom_as_json -app_name = "sca" - urlpatterns = [ path("download-sbom-json///", download_sbom_as_json, name="download_sbom_as_json"), ] diff --git a/surface/surface/settings.py b/surface/surface/settings.py index 68631fce..070761dc 100644 --- a/surface/surface/settings.py +++ b/surface/surface/settings.py @@ -193,7 +193,7 @@ DATABASE_LOCKS_STATUS_FILE = None DATABASE_LOCKS_ENABLED = False -SCA_SBOM_REPO_URL = ENV_VAR("SURF_SCA_SBOM_REPO_URL", default="http://localhost:8000/sbomrepo/v1/sbom") +SCA_SBOM_REPO_URL = ENV_VAR("SURF_SCA_SBOM_REPO_URL", default="http://localhost:8000/sbomrepo") SCA_SOURCE_PURL_TYPES = ["github.com"] SCA_INTERNAL_RENOVATE = ENV_VAR("SURF_SCA_INTERNAL_RENOVATE", default=None) SCA_INTERNAL_GITLAB_API = ENV_VAR("SURF_SCA_INTERNAL_GITLAB_API", default=None) diff --git a/surface/surface/urls.py b/surface/surface/urls.py index 6880ce9e..fd9e2e33 100644 --- a/surface/surface/urls.py +++ b/surface/surface/urls.py @@ -20,5 +20,6 @@ urlpatterns = [ path("dkron/", include("dkron.urls")), path("sbomrepo/", include("sbomrepo.urls")), + path("sca/", include(("sca.urls", "sca"), namespace="sca")), path("", admin.site.urls), ] From 56276b05cd86dff8073de47fc44e7ce42fed8044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Pinto?= Date: Thu, 30 Oct 2025 15:03:16 +0000 Subject: [PATCH 39/39] fixes tests --- surface/sca/tests/test_admin.py | 4 ++-- surface/sca/tests/test_resync_sbom_repo.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/surface/sca/tests/test_admin.py b/surface/sca/tests/test_admin.py index 23da5492..6869216c 100644 --- a/surface/sca/tests/test_admin.py +++ b/surface/sca/tests/test_admin.py @@ -24,7 +24,7 @@ def setUp(self, now) -> None: self.site = AdminSite() responses.add( responses.GET, - f"{settings.SCA_SBOM_REPO_URL}/urn:uuid:46d764e2-aae1-4f82-b9f1-c616308e921d?vuln_data=True", + f"{settings.SCA_SBOM_REPO_URL}/v1/sbom/urn:uuid:46d764e2-aae1-4f82-b9f1-c616308e921d?vuln_data=True", status=200, content_type="application/json", json=data.sbom_data, @@ -32,7 +32,7 @@ def setUp(self, now) -> None: responses.add( responses.GET, - f"{settings.SCA_SBOM_REPO_URL}/all?since={datetime.datetime.strftime(timezone.now() - timezone.timedelta(hours=1), '%Y-%m-%dT%H:%M:%S.%f')}", + f"{settings.SCA_SBOM_REPO_URL}/v1/sbom/all?since={datetime.datetime.strftime(timezone.now() - timezone.timedelta(hours=1), '%Y-%m-%dT%H:%M:%S.%f')}", status=200, content_type="application/json", json=["urn:uuid:46d764e2-aae1-4f82-b9f1-c616308e921d"], diff --git a/surface/sca/tests/test_resync_sbom_repo.py b/surface/sca/tests/test_resync_sbom_repo.py index 1046bc2b..dfb9be0c 100644 --- a/surface/sca/tests/test_resync_sbom_repo.py +++ b/surface/sca/tests/test_resync_sbom_repo.py @@ -21,7 +21,7 @@ class Test(TestCase): def test_resync_sbom_repo(self, now): responses.add( responses.GET, - f"{settings.SCA_SBOM_REPO_URL}/urn:uuid:46d764e2-aae1-4f82-b9f1-c616308e921d?vuln_data=True", + f"{settings.SCA_SBOM_REPO_URL}/v1/sbom/urn:uuid:46d764e2-aae1-4f82-b9f1-c616308e921d?vuln_data=True", status=200, content_type="application/json", json=data.sbom_data, @@ -29,7 +29,7 @@ def test_resync_sbom_repo(self, now): responses.add( responses.GET, - f"{settings.SCA_SBOM_REPO_URL}/all?since={datetime.datetime.strftime(timezone.now() - timezone.timedelta(hours=1), '%Y-%m-%dT%H:%M:%S.%f')}", + f"{settings.SCA_SBOM_REPO_URL}/v1/sbom/all?since={datetime.datetime.strftime(timezone.now() - timezone.timedelta(hours=1), '%Y-%m-%dT%H:%M:%S.%f')}", status=200, content_type="application/json", json=["urn:uuid:46d764e2-aae1-4f82-b9f1-c616308e921d"],

    Vulnerability IDFixed In Type DependencyActionsActions
    {{ vuln.cvss_score }} + href="https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator?vector={{ vuln.cvss_vector|cvss_vector }}&version={{ vuln.cvss_vector|cvss_version }}">{{ vuln.cvss_score }} {{ cvss_score }}{{ vuln.fixed_in|default_if_none:"" }} {{ vuln.get_finding_type_display }} {{ vuln.dependency }} -
    - - - -
    -
    +
    + + + +
    +
    - {{ dep.purl }} + {{ dep.purl }} {% for k, v in dep.vulns_counters.items %} {% if k %} {% if dep.depends_on %} {% for d in dep.depends_on %} - {{ d }} + {{ d }}
    {% endfor %} {% else %} diff --git a/surface/sca/templates/views/layout.html b/surface/sca/templates/views/layout.html index 627604bf..8a96943d 100644 --- a/surface/sca/templates/views/layout.html +++ b/surface/sca/templates/views/layout.html @@ -136,10 +136,6 @@ .ui.dropdown > .text { color: var(--unfold-foreground, inherit) !important; } - - td a:not(.ui.label):not(.ui.label *) { - color: #3366CC !important; - } {% endblock %} diff --git a/surface/sca/templates/views/vulnerabilities.html b/surface/sca/templates/views/vulnerabilities.html index cc1a3719..ddb7240b 100644 --- a/surface/sca/templates/views/vulnerabilities.html +++ b/surface/sca/templates/views/vulnerabilities.html @@ -70,7 +70,7 @@ {% for vuln in vulns_filter.qs %}
    - {{ vuln }} + {{ vuln }} {{ vuln.title }} @@ -80,6 +80,7 @@ {% if vuln.cvss_vector %} {{ vuln.cvss_score }}