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..6037b8ff 100644 --- a/dev/Dockerfile +++ b/dev/Dockerfile @@ -7,11 +7,11 @@ 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-trixie as builder RUN apt-get update \ && apt-get install --no-install-recommends -y \ - libmariadbclient-dev \ + libmariadb-dev \ libpq-dev \ build-essential \ libldap2-dev \ @@ -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-trixie as main RUN apt-get update \ && apt-get install --no-install-recommends -y \ diff --git a/e2e/test_dkron.py b/e2e/test_dkron.py index 3000f857..53bd50cd 100644 --- a/e2e/test_dkron.py +++ b/e2e/test_dkron.py @@ -13,40 +13,28 @@ @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 + 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.ID, value="login-form").submit() - 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"] - ) - 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() 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 \ No newline at end of file + assert v == test_cfg.driver.find_element(by=By.CLASS_NAME, value=f"field-{k}").text diff --git a/e2e/test_login.py b/e2e/test_login.py index 4fbe181e..0074e209 100644 --- a/e2e/test_login.py +++ b/e2e/test_login.py @@ -1,21 +1,13 @@ import pytest from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys @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) - - assert "Home | Surface" == 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(test_cfg.password) + test_cfg.driver.find_element(by=By.ID, value="login-form").submit() + assert "Dashboard | Surface Security" == test_cfg.driver.title 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..1afbfe34 100644 --- a/surface/core_utils/admin.py +++ b/surface/core_utils/admin.py @@ -1,9 +1,189 @@ -from django_restful_admin import site as rest +import logging +from urllib.parse import quote + from django.apps import apps +from django.db import models +from django.urls import reverse +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from jsoneditor.forms import JSONEditor +from unfold.admin import ModelAdmin + +logger = logging.getLogger(__name__) +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 + + +class ReverseReadonlyMixin: + """ + Mixin to add a readonly 'reverse' field showing related objects in Django admin. + Should be used with ModelAdmin or a compatible base class. + """ + + def get_readonly_fields(self, request, obj=None): + parent_method = getattr(super(), "get_readonly_fields", None) + fields = list(parent_method(request, obj)) if parent_method else [] + if obj and "reverse" not in fields: + fields.append("reverse") + return fields + + def get_fieldsets(self, request, obj=None): + parent_method = getattr(super(), "get_fieldsets", None) + fieldsets = list(parent_method(request, obj)) if parent_method else [] + if not fieldsets: + return fieldsets + label, opts = fieldsets[0] + opts = dict(opts) + opts.setdefault("classes", []).append("tab") + if "fields" in opts: + opts["fields"] = tuple(f for f in opts["fields"] if f != "reverse") + fieldsets[0] = ("General", opts) + if obj: + fieldsets.append(("Relationships", {"classes": ["tab"], "fields": ("reverse",)})) + return tuple(fieldsets) + + def reverse(self, obj): + """Render all relationships for the current object.""" + if not obj or not obj.pk: + return format_html("
No relationships available for new objects.
") + try: + html = self._render_relationships(obj) + return format_html("
{}
", html) + except Exception as e: + return format_html("
Error loading relationships: {}
", str(e)) + + def _render_relationships(self, obj): + html = [] + forward_html = self._get_forward_relationships(obj) + if forward_html: + html.append("Forward Relationships") + html.append(forward_html) + reverse_html = self._get_reverse_relationships(obj) + if reverse_html: + html.append("
Reverse Relationships") + html.append(reverse_html) + if not html: + html.append("
No relationships found.
") + return mark_safe("\n".join(html)) + + def _get_forward_relationships(self, obj): + html = [] + opts = obj._meta + for field in opts.get_fields(): + if field.is_relation: + try: + if not field.many_to_many and hasattr(field, "related_model"): + rel_obj = getattr(obj, field.name, None) + if rel_obj: + html.append( + self._format_relation(field, rel_obj, single_obj=True, relation_type="ForeignKey") + ) + elif field.many_to_many and not field.auto_created: + manager = getattr(obj, field.name) + rel_objs = manager.all()[:10] + total_count = manager.count() + if rel_objs: + html.append(self._format_relation(field, rel_objs, total_count, "ManyToMany")) + except AttributeError: + continue + return mark_safe("\n".join(html)) + + def _get_reverse_relationships(self, obj): + html = [] + opts = obj._meta + for field in opts.get_fields(): + if field.auto_created and field.is_relation: + try: + manager = getattr(obj, field.get_accessor_name()) + if field.one_to_many: + rel_objs = manager.all()[:10] + total_count = manager.count() + if rel_objs: + html.append(self._format_relation(field, rel_objs, total_count, "ForeignKey")) + elif field.one_to_one: + rel_obj = getattr(manager, getattr(field.related_model._meta, "model_name", ""), None) + if rel_obj: + html.append( + self._format_relation(field, rel_obj, single_obj=True, relation_type="OneToOne") + ) + elif field.many_to_many: + rel_objs = manager.all()[:10] + total_count = manager.count() + if rel_objs: + html.append(self._format_relation(field, rel_objs, total_count, "ManyToMany")) + except AttributeError: + continue + return mark_safe("\n".join(html)) + + def _format_relation(self, field, related_objs, total_count=None, relation_type=None, single_obj=False): + label = getattr(field, "verbose_name", None) + if not label and hasattr(field, "related_model") and field.related_model: + label = getattr(field.related_model._meta, "verbose_name_plural", str(field.related_model)) + label = label.title() if isinstance(label, str) else str(field) + rel_type = relation_type or "Relation" + header = f'
{label}: {rel_type}' + if total_count is not None: + header += f" ({total_count} total)" + header += "
" + + def obj_link(obj): + url = self._get_admin_url(obj) + return f'{obj}' if url else str(obj) + + if single_obj: + body = f'
• {obj_link(related_objs)}
' + else: + body = "\n".join( + f'
• {obj_link(obj)}
' for obj in related_objs + ) + if total_count and total_count > 10: + body += ( + f'\n
... and {total_count - 10} more
' + ) + return mark_safe(header + "\n" + body) + + def _get_admin_url(self, obj): + if not obj or not obj.pk: + return None + try: + opts = obj._meta + return reverse(f"admin:{opts.app_label}_{opts.model_name}_change", args=[quote(str(obj.pk))]) + except Exception: + return None diff --git a/surface/core_utils/admin_filters.py b/surface/core_utils/admin_filters.py index 9070c133..397f1c82 100644 --- a/surface/core_utils/admin_filters.py +++ b/surface/core_utils/admin_filters.py @@ -1,126 +1,27 @@ 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.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.core.validators import EMPTY_VALUES +from django.db.models import Model, QuerySet +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.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 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 +56,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/core_utils/fields.py b/surface/core_utils/fields.py index daa7b931..d31678a7 100644 --- a/surface/core_utils/fields.py +++ b/surface/core_utils/fields.py @@ -1,5 +1,4 @@ import netaddr - from django.conf import settings from django.core import exceptions, validators from django.db import models @@ -11,19 +10,19 @@ class UnsignedIntegerField(fields.IntegerField): def db_type(self, connection=None): # connection not used, types hardcoded - if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.mysql': + if settings.DATABASES["default"]["ENGINE"] == "django.db.backends.mysql": return "integer UNSIGNED" - if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.sqlite3': - return 'integer' - if settings.DATABASES['default']['ENGINE'] in ( - 'django.db.backends.postgresql_psycopg2', - 'django.db.backends.postgresql', + if settings.DATABASES["default"]["ENGINE"] == "django.db.backends.sqlite3": + return "integer" + if settings.DATABASES["default"]["ENGINE"] in ( + "django.db.backends.postgresql_psycopg2", + "django.db.backends.postgresql", ): - return 'bigint' - raise NotImplementedError(settings.DATABASES['default']['ENGINE']) + return "bigint" + raise NotImplementedError(settings.DATABASES["default"]["ENGINE"]) def get_internal_type(self): - return "UnsignedIntegerField" + return "PositiveIntegerField" def to_python(self, value): if value is None: @@ -41,8 +40,8 @@ class RangeModel(models.Model): def save(self, force_insert=False, force_update=False, using=None, update_fields=None): try: - if '-' in self.range: - first, second = self.range.split('-') + if "-" in self.range: + first, second = self.range.split("-") _x = netaddr.IPRange(first, second) else: _x = netaddr.IPNetwork(self.range) @@ -59,7 +58,7 @@ class Meta: class TruncatingCharField(models.CharField): def __init__(self, *args, **kwargs): - self._dotdotdot = kwargs.pop('dotdotdot', True) + self._dotdotdot = kwargs.pop("dotdotdot", True) super().__init__(*args, **kwargs) def get_prep_value(self, value): @@ -68,5 +67,5 @@ def get_prep_value(self, value): def trim_length(self, value): if value and len(value) > self.max_length: - return value[: self.max_length - 3] + '...' + return value[: self.max_length - 3] + "..." return value 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..ca22e2b2 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", diff --git a/surface/inventory/migrations/0004_alter_finding_related_to.py b/surface/inventory/migrations/0004_alter_finding_related_to.py new file mode 100644 index 00000000..2376b2ba --- /dev/null +++ b/surface/inventory/migrations/0004_alter_finding_related_to.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-10-20 21:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0003_gitsource'), + ] + + operations = [ + migrations.AlterField( + model_name='finding', + name='related_to', + field=models.ManyToManyField(blank=True, help_text='Other findings related to this one', to='inventory.finding'), + ), + ] diff --git a/surface/requirements.txt b/surface/requirements.txt index 23bfbecf..674b2094 100644 --- a/surface/requirements.txt +++ b/surface/requirements.txt @@ -1,34 +1,34 @@ # 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 -djangorestframework==3.14.0 +django-object-actions==3.0.2 +djangorestframework==3.16.0 django-restful-admin==1.1.3 djangorestframework-queryfields==1.0.0 -django-filter==2.4.0 -django-import-export==2.5.0 -django-nested-admin==4.0.2 +django-import-export==4.3.9 +django-nested-admin==4.1.1 django-daterangefilter==1.0.0 -django-jsoneditor==0.1.6 +django-jsoneditor==0.2.4 netaddr==0.8.0 +django-unfold==0.69.0 +django-filter==25.1 +PyYAML==6.0.2 # 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.12.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 ..? -django-database-locks==0.5 # distributed locks (on mysql) +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/admin.py b/surface/sca/admin.py index 4e9c9d0e..3cf580e4 100644 --- a/surface/sca/admin.py +++ b/surface/sca/admin.py @@ -1,31 +1,32 @@ 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_object_actions import DjangoObjectActions +from django.utils.safestring import mark_safe from jsoneditor.forms import JSONEditor -from core_utils.admin_filters import DefaultFilterMixin +from core_utils.admin import DefaultModelAdmin, ReverseReadonlyMixin +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 -from theme.filters import RelatedFieldAjaxListFilter logger = logging.getLogger(__name__) -class EndOfLifeDependencyBoolFilter(admin.SimpleListFilter): +class EndOfLifeDependencyBoolFilter(DropdownFilter): title = "EoL" parameter_name = "eol_filter" field = "eol" @@ -63,7 +64,7 @@ class SupportFilter(EndOfLifeDependencyBoolFilter): @admin.register(models.EndOfLifeDependency) -class EndOfLifeDependencyAdmin(admin.ModelAdmin, DefaultFilterMixin, EndOfLifeDependencyBoolFilter): +class EndOfLifeDependencyAdmin(DefaultModelAdmin, DefaultFilterMixin, EndOfLifeDependencyBoolFilter): list_display = [ "product", "cycle", @@ -86,8 +87,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 +105,7 @@ def __init__(self, *args, **kwargs): @admin.register(models.SCADependency) -class SCADependencyAdmin(admin.ModelAdmin, DefaultFilterMixin): +class SCADependencyAdmin(ReverseReadonlyMixin, DefaultModelAdmin): form = SCADependencyForm list_display = [ "purl", @@ -129,6 +134,9 @@ class SCADependencyAdmin(admin.ModelAdmin, DefaultFilterMixin): 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): if obj.git_source: @@ -150,24 +158,27 @@ 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",), }, ), @@ -230,9 +241,13 @@ def filter_vulnerable(self, queryset, name, value): @admin.register(models.SCAProject) -class SCAProjectAdmin(admin.ModelAdmin): - list_display = ["purl", "get_vulns", "get_git_source", "name", "last_scan", "created_at"] - list_filter = ["name", "git_source", "git_source__apps__tla"] +class SCAProjectAdmin(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), + ] 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 +275,66 @@ 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()) + 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 +342,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.") @@ -332,6 +368,14 @@ def get_git_source(self, obj): 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") def get_vulns(self, obj): vulns_counter = models.SCAFindingCounter.objects.filter(dependency=obj).first() @@ -375,12 +419,20 @@ def get_vulns(self, obj): 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 has_delete_permission(self, request, obj=None): return False @@ -388,32 +440,30 @@ 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, admin.ModelAdmin): +class SCAFindingAdmin(DefaultModelAdmin): + ALLOW_DELETE = True + list_display = [ "vuln_id", "truncated_aliases", - "get_dependency", "title", "severity", "state", "published", - "get_cvss_score", "truncated_fixed_in", "ecosystem", "finding_type", "first_seen", "last_seen_date", ] - list_filter = [ - ("dependency", RelatedFieldAjaxListFilter), - "severity", - "state", - "published", - "ecosystem", - "finding_type", - ] + search_fields = ["vuln_id", "ecosystem", "title", "summary", "aliases"] list_select_related = ["dependency"] @@ -425,53 +475,9 @@ def truncated_aliases(self, obj): def truncated_fixed_in(self, obj): return truncatechars(obj.fixed_in, 50) - def suppress_finding(self, request, obj): - return redirect( - admin_reverse( - models.SuppressedSCAFinding, - "add", - request=request, - query_kwargs={"dependency": obj.dependency_id, "vuln_id": obj.vuln_id}, - ) - ) - - suppress_finding.label = "Suppress Finding" - suppress_finding.attrs = {"class": "btn btn-round ml-auto btn-warning"} - suppress_finding.short_description = "This button will Suppress this finding" - - change_actions = ("suppress_finding",) - - @admin.display(description="Dependency") - def get_dependency(self, obj): - return format_html( - '{}', - admin_reverse(obj.dependency, "changelist", relative=True, query_kwargs={"purl": obj.dependency.purl}), - obj.dependency.purl, - ) - - @admin.display(description="CVSS", ordering="cvss_vector") - def get_cvss_score(self, obj): - if obj.cvss_vector: - return format_html( - '{}', - "/".join(obj.cvss_vector.split("/")[1:]), - obj.cvss_vector.split(":")[1].split("/")[0], - obj.cvss_score, - ) - return "N/A" - - 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 - @admin.register(models.SuppressedSCAFinding) -class SuppressedSCAFindingAdmin(admin.ModelAdmin): +class SuppressedSCAFindingAdmin(DefaultModelAdmin): list_display = [ "vuln_id", "get_dependency", @@ -484,10 +490,12 @@ class SuppressedSCAFindingAdmin(admin.ModelAdmin): 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 +508,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) @@ -513,7 +536,7 @@ def get_changeform_initial_data(self, request): # Assuming Dependency is the related model dependency_instance = models.SCADependency.objects.get(pk=int(dependency)) initial["dependency"] = dependency_instance - except models.Dependency.DoesNotExist: + except models.SCADependency.DoesNotExist: pass # Handle the case when the Dependency does not exist if vuln_id: @@ -521,45 +544,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/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/migrations/0003_scadependency_sbom_uuid_and_more.py b/surface/sca/migrations/0003_scadependency_sbom_uuid_and_more.py new file mode 100644 index 00000000..c233d5de --- /dev/null +++ b/surface/sca/migrations/0003_scadependency_sbom_uuid_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.4 on 2025-10-20 21:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sca', '0002_alter_scafinding_fixed_in'), + ] + + operations = [ + migrations.AddField( + model_name='scadependency', + name='sbom_uuid', + field=models.CharField(default=None, max_length=255, null=True), + ), + migrations.AddField( + model_name='suppressedscafinding', + name='sca_project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='suppressed_findings_project', to='sca.scaproject'), + ), + ] diff --git a/surface/sca/models.py b/surface/sca/models.py index 04c0957d..bc24d8ab 100644 --- a/surface/sca/models.py +++ b/surface/sca/models.py @@ -67,6 +67,7 @@ class SCADependency(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) last_scan = models.DateTimeField() + sbom_uuid = models.CharField(max_length=255, default=None, null=True) @staticmethod def get_dependencies_recursively( @@ -127,15 +128,20 @@ def projects(self): _, parent_projects = SCADependency.get_parents_recursively(self, parsed_parents) return parent_projects - @property - def vulns(self): + def get_vulns_counter(self, project): + suppressed_findings = ( + SuppressedSCAFinding.objects.filter(dependency=self) + .filter(Q(sca_project__isnull=True) | Q(sca_project=project)) + .values_list("vuln_id", flat=True) + ) + 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") - ) + ).exclude(vuln_id__in=suppressed_findings) return {item["severity"]: {"count": item["count"], "eol": item["eol"]} for item in findings} @staticmethod @@ -151,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( @@ -271,6 +279,9 @@ class SuppressedSCAFinding(models.Model): updated_at = models.DateTimeField(auto_now=True) created_by = models.ForeignKey("auth.User", null=True, on_delete=models.SET_NULL, related_name="+") updated_by = models.ForeignKey("auth.User", null=True, on_delete=models.SET_NULL, related_name="+") + sca_project = models.ForeignKey( + SCAProject, null=True, blank=True, on_delete=models.CASCADE, related_name="suppressed_findings_project" + ) class Meta: verbose_name = "Suppressed Dependency Finding (SCA)" diff --git a/surface/sca/templates/views/dependencies.html b/surface/sca/templates/views/dependencies.html index 1363b300..1fb34b75 100644 --- a/surface/sca/templates/views/dependencies.html +++ b/surface/sca/templates/views/dependencies.html @@ -1,65 +1,79 @@ {% 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 %} +
+ + +
+
-
-
-
- -
- - -
+
+
+
+ +
+ +
+
+
-
- - - {% if current_object.is_project %} -
- {% csrf_token %} - -
- -
+
- {% endif %} - - + {% if current_object.is_project %} + + {% csrf_token %} + +
+ +
+ + {% endif %} + +
+ + @@ -73,21 +87,21 @@ - {% for dep in deps_filter.qs %} + {% for dep in dependencies %} @@ -106,12 +120,12 @@ + N/A {% endif %} @@ -138,4 +152,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..8a96943d 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..ddb7240b 100644 --- a/surface/sca/templates/views/vulnerabilities.html +++ b/surface/sca/templates/views/vulnerabilities.html @@ -1,50 +1,55 @@ {% 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,14 +61,16 @@ - + {% if current_object.is_project %} + + {% endif %} {% for vuln in vulns_filter.qs %} {% else %} @@ -82,16 +90,18 @@ - + {% if current_object.is_project %} + + {% endif %}
Vulnerability IDFixed In Type DependencyActionsActions
- {{ vuln }} + {{ vuln }} {{ vuln.title }} @@ -73,7 +80,8 @@ {% if vuln.cvss_vector %} {{ vuln.cvss_score }} + class="text-font-important-light dark:text-font-important-dark" + 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 }} -
- - - -
-
+
+ + + +
+