diff --git a/surface/sca/tests/test_admin.py b/surface/sca/tests/test_admin.py
index d6312143..6869216c 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,13 +18,13 @@
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()
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.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"],
@@ -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..dfb9be0c 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,11 +17,11 @@
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,
- 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.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"],
@@ -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",
)
diff --git a/surface/sca/urls.py b/surface/sca/urls.py
new file mode 100644
index 00000000..6269583c
--- /dev/null
+++ b/surface/sca/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from sca.views import download_sbom_as_json
+
+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..2c029983
--- /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 import 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/scanners/admin.py b/surface/scanners/admin.py
index a39a7c9f..a8f589f5 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 import admin, messages
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.contrib import admin, messages
-from django.contrib.admin import SimpleListFilter
-from django.shortcuts import render
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_filters import DropdownFilter
+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'
+class FinalHTTPFilter(DropdownFilter):
+ 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'
+class NoLBie1ie2Filter(DropdownFilter):
+ 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'
+class TypeRecordFilter(DropdownFilter):
+ 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'
+class ExitCodeFilter(DropdownFilter):
+ 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/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
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/settings.py b/surface/surface/settings.py
index 367e7f5e..070761dc 100644
--- a/surface/surface/settings.py
+++ b/surface/surface/settings.py
@@ -12,26 +12,35 @@
from pathlib import Path
-import ppbenviron
+import environ
+import yaml
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
-ENV_VAR = ppbenviron.CustomEnv()
+ENV_VAR = environ.Env()
ENV_VAR.read_env(BASE_DIR / "local.env")
+from surface.sidebar import SIDEBAR
+
# 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",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
+ "django.db.migrations",
"impersonate",
- "surfapp",
+ "import_export",
"dkron",
"notifications",
"slackbot",
@@ -46,6 +55,7 @@
"sca",
"sbomrepo",
"jsoneditor",
+ "surfapp",
]
MIDDLEWARE = [
@@ -64,8 +74,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": {
@@ -185,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)
@@ -194,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"
@@ -250,3 +258,62 @@
TITLE = "Surface"
VERSION = "dev"
+
+LOGIN_REDIRECT_URL = "/"
+
+######################################################################
+# 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,
+ "SIDEBAR": SIDEBAR,
+ "COLORS": {
+ "base": {
+ "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": "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
+ },
+ },
+}
diff --git a/surface/surface/sidebar.py b/surface/surface/sidebar.py
new file mode 100644
index 00000000..dca1947f
--- /dev/null
+++ b/surface/surface/sidebar.py
@@ -0,0 +1,249 @@
+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"),
+ },
+ ],
+ },
+ {
+ "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": "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",
+ "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"),
+ },
+ ],
+ },
+ ],
+}
diff --git a/surface/surface/urls.py b/surface/surface/urls.py
index 77e2ad70..fd9e2e33 100644
--- a/surface/surface/urls.py
+++ b/surface/surface/urls.py
@@ -18,8 +18,8 @@
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("sca/", include(("sca.urls", "sca"), namespace="sca")),
path("", admin.site.urls),
]
diff --git a/surface/surfapp/admin.py b/surface/surfapp/admin.py
index 04cb6ae3..4b430f8a 100644
--- a/surface/surfapp/admin.py
+++ b/surface/surfapp/admin.py
@@ -1,9 +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
+
+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.UserAdmin.actions.append(impersonate_action)
-site.site_title = 'Surface'
-site.site_url = 'https://github.com/surface-security/surface'
-site.index_title = 'Home'
+@admin.register(Table)
+class TableAdmin(TableAdmin, DefaultModelAdmin):
+ list_filter = (
+ TableAppFilter,
+ TableModelFilter,
+ )
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 %}
+
+ {% 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..aa3d1c2a
--- /dev/null
+++ b/surface/surfapp/templates/admin/change_list.html
@@ -0,0 +1,135 @@
+{% 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 %}
+
+ {% 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 %}
+
+
+
+ {% 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 %}
+
+
+
+{% 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 %}
+
+ {% for choice in choices %}
+ -
+
+ {{ choice.display }}
+
+
+ {% 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
index 4eb9ab08..a0d065d0 100644
--- a/surface/surfapp/templates/admin/index.html
+++ b/surface/surfapp/templates/admin/index.html
@@ -1,19 +1,212 @@
-{% extends "admin/index.html" %}
+{% extends 'admin/base.html' %}
-{% block index-top-panel %}
-
+{% load i18n unfold surface_templatetags log %}
+
+{% block breadcrumbs %}{% endblock %}
+
+{% block title %}
+ {% trans 'Dashboard' %} | {{ site_title|default:_('Django site admin') }}
+{% endblock %}
+
+{% block extrahead %}
+ {{ block.super }}
+
+{% 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" %}
+
+
+ {% 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 %}
+
+ {% 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 %}
+ -
+
+ {% 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 %}
+
+
+
+ {% 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 %}
+ -
+
+ {% 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 %}
+
+
+
+ {% 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/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" %}
+
{# 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 %}
-
-
-
-
-
-
+{% 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 %}
+ |